fix(sync): protect local issues from git-history-backfill during sync (#485)

Fix sync bug where newly created issues were incorrectly tombstoned during bd sync.

The root cause was git-history-backfill finding issues in local commits on the sync branch, then tombstoning them when they weren't in the merged JSONL. The fix protects issues from the left snapshot (local export) from git-history-backfill.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Rod Davenport
2025-12-12 16:28:48 -05:00
committed by GitHub
parent 8af08460a7
commit 6985ea94e5
5 changed files with 176 additions and 13 deletions

View File

@@ -44,6 +44,7 @@ type Options struct {
ClearDuplicateExternalRefs bool // Clear duplicate external_ref values instead of erroring
NoGitHistory bool // Skip git history backfill for deletions (prevents spurious deletion during JSONL migrations)
IgnoreDeletions bool // Import issues even if they're in the deletions manifest
ProtectLocalExportIDs map[string]bool // IDs from left snapshot to protect from git-history-backfill (bd-sync-deletion fix)
}
// Result contains statistics about the import operation
@@ -65,6 +66,8 @@ type Result struct {
SkippedDeletedIDs []string // IDs that were skipped due to deletions manifest
ConvertedToTombstone int // Legacy deletions.jsonl entries converted to tombstones (bd-wucl)
ConvertedTombstoneIDs []string // IDs that were converted to tombstones
PreservedLocalExport int // Issues preserved because they were in local export (bd-sync-deletion fix)
PreservedLocalIDs []string // IDs that were preserved from local export
}
// ImportIssues handles the core import logic used by both manual and auto-import.
@@ -894,6 +897,17 @@ func purgeDeletedIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage,
// This could be:
// 1. Local work (new issue not yet exported)
// 2. Deletion was pruned from manifest (check git history)
// 3. Issue was in local export but lost during pull/merge (bd-sync-deletion fix)
// Check if this issue was in our local export (left snapshot)
// If so, it's local work that got lost during merge - preserve it!
if opts.ProtectLocalExportIDs != nil && opts.ProtectLocalExportIDs[dbIssue.ID] {
fmt.Fprintf(os.Stderr, "Preserving %s (was in local export, lost during merge)\n", dbIssue.ID)
result.PreservedLocalExport++
result.PreservedLocalIDs = append(result.PreservedLocalIDs, dbIssue.ID)
continue
}
needGitCheck = append(needGitCheck, dbIssue.ID)
}
}

View File

@@ -188,6 +188,114 @@ func TestPurgeDeletedIssues_NoDeletionsManifest(t *testing.T) {
}
}
// TestPurgeDeletedIssues_ProtectLocalExportIDs tests that issues in ProtectLocalExportIDs
// are not tombstoned even if they're not in the JSONL (bd-sync-deletion fix)
func TestPurgeDeletedIssues_ProtectLocalExportIDs(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
// Create database
dbPath := filepath.Join(tmpDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer store.Close()
// Initialize prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("failed to set prefix: %v", err)
}
// Create issues in the database:
// - issue1: in JSONL (should survive)
// - issue2: NOT in JSONL, but in ProtectLocalExportIDs (should survive - this is the fix)
// - issue3: NOT in JSONL, NOT protected (would be checked by git-history, but we skip that)
issue1 := &types.Issue{
ID: "test-abc",
Title: "Issue 1 (in JSONL)",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue2 := &types.Issue{
ID: "test-def",
Title: "Issue 2 (protected local export)",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
issue3 := &types.Issue{
ID: "test-ghi",
Title: "Issue 3 (unprotected)",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
}
for _, iss := range []*types.Issue{issue1, issue2, issue3} {
if err := store.CreateIssue(ctx, iss, "test"); err != nil {
t.Fatalf("failed to create issue %s: %v", iss.ID, err)
}
}
// Simulate import where JSONL only has issue1 (issue2 was in our local export but lost during merge)
jsonlIssues := []*types.Issue{issue1}
result := &Result{
IDMapping: make(map[string]string),
MismatchPrefixes: make(map[string]int),
}
// Set ProtectLocalExportIDs to protect issue2 (simulates left snapshot protection)
opts := Options{
ProtectLocalExportIDs: map[string]bool{
"test-def": true, // Protect issue2
},
NoGitHistory: true, // Skip git history check for this test
}
// Call purgeDeletedIssues
if err := purgeDeletedIssues(ctx, store, dbPath, jsonlIssues, opts, result); err != nil {
t.Fatalf("purgeDeletedIssues failed: %v", err)
}
// Verify issue2 was preserved (the fix!)
if result.PreservedLocalExport != 1 {
t.Errorf("expected 1 preserved issue, got %d", result.PreservedLocalExport)
}
if len(result.PreservedLocalIDs) != 1 || result.PreservedLocalIDs[0] != "test-def" {
t.Errorf("expected PreservedLocalIDs to contain 'test-def', got %v", result.PreservedLocalIDs)
}
// Verify issue1 still exists (was in JSONL)
iss1, err := store.GetIssue(ctx, "test-abc")
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if iss1 == nil {
t.Errorf("expected issue1 to still exist")
}
// Verify issue2 still exists (was protected)
iss2, err := store.GetIssue(ctx, "test-def")
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if iss2 == nil {
t.Errorf("expected issue2 (protected local export) to still exist - THIS IS THE FIX")
}
// Verify issue3 still exists (not in deletions, git history check skipped)
iss3, err := store.GetIssue(ctx, "test-ghi")
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if iss3 == nil {
t.Errorf("expected issue3 to still exist (git history check skipped)")
}
}
// TestPurgeDeletedIssues_EmptyDeletionsManifest tests that import works with empty deletions manifest
func TestPurgeDeletedIssues_EmptyDeletionsManifest(t *testing.T) {
ctx := context.Background()