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

@@ -98,6 +98,7 @@ NOTE: Import requires direct database access and does not work with daemon mode.
force, _ := cmd.Flags().GetBool("force")
noGitHistory, _ := cmd.Flags().GetBool("no-git-history")
ignoreDeletions, _ := cmd.Flags().GetBool("ignore-deletions")
protectLeftSnapshot, _ := cmd.Flags().GetBool("protect-left-snapshot")
// Check if stdin is being used interactively (not piped)
if input == "" && term.IsTerminal(int(os.Stdin.Fd())) {
@@ -260,6 +261,23 @@ NOTE: Import requires direct database access and does not work with daemon mode.
IgnoreDeletions: ignoreDeletions,
}
// If --protect-left-snapshot is set, read the left snapshot and build ID set
// This protects locally exported issues from git-history-backfill (bd-sync-deletion fix)
if protectLeftSnapshot && input != "" {
beadsDir := filepath.Dir(input)
leftSnapshotPath := filepath.Join(beadsDir, "beads.left.jsonl")
if _, err := os.Stat(leftSnapshotPath); err == nil {
sm := NewSnapshotManager(input)
leftIDs, err := sm.BuildIDSet(leftSnapshotPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to read left snapshot: %v\n", err)
} else if len(leftIDs) > 0 {
opts.ProtectLocalExportIDs = leftIDs
fmt.Fprintf(os.Stderr, "Protecting %d issue(s) from left snapshot\n", len(leftIDs))
}
}
}
result, err := importIssuesCore(ctx, dbPath, store, allIssues, opts)
// Check for uncommitted changes in JSONL after import
@@ -774,6 +792,7 @@ func init() {
importCmd.Flags().Bool("force", false, "Force metadata update even when database is already in sync with JSONL")
importCmd.Flags().Bool("no-git-history", false, "Skip git history backfill for deletions (use during JSONL filename migrations)")
importCmd.Flags().Bool("ignore-deletions", false, "Import issues even if they're in the deletions manifest")
importCmd.Flags().Bool("protect-left-snapshot", false, "Protect issues in left snapshot from git-history-backfill (bd-sync-deletion fix)")
importCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output import statistics in JSON format")
rootCmd.AddCommand(importCmd)
}

View File

@@ -158,15 +158,16 @@ func issueDataChanged(existing *types.Issue, updates map[string]interface{}) boo
// ImportOptions configures how the import behaves
type ImportOptions struct {
DryRun bool // Preview changes without applying them
SkipUpdate bool // Skip updating existing issues (create-only mode)
Strict bool // Fail on any error (dependencies, labels, etc.)
RenameOnImport bool // Rename imported issues to match database prefix
SkipPrefixValidation bool // Skip prefix validation (for auto-import)
ClearDuplicateExternalRefs bool // Clear duplicate external_ref values instead of erroring
OrphanHandling string // Orphan handling mode: strict/resurrect/skip/allow (empty = use config)
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
DryRun bool // Preview changes without applying them
SkipUpdate bool // Skip updating existing issues (create-only mode)
Strict bool // Fail on any error (dependencies, labels, etc.)
RenameOnImport bool // Rename imported issues to match database prefix
SkipPrefixValidation bool // Skip prefix validation (for auto-import)
ClearDuplicateExternalRefs bool // Clear duplicate external_ref values instead of erroring
OrphanHandling string // Orphan handling mode: strict/resurrect/skip/allow (empty = use config)
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)
}
// ImportResult contains statistics about the import operation
@@ -186,6 +187,8 @@ type ImportResult struct {
PurgedIDs []string // IDs that were purged
SkippedDeleted int // Issues skipped because they're in deletions manifest
SkippedDeletedIDs []string // IDs that were skipped due to deletions manifest
PreservedLocalExport int // Issues preserved because they were in local export (bd-sync-deletion fix)
PreservedLocalIDs []string // IDs that were preserved from local export
}
// importIssuesCore handles the core import logic used by both manual and auto-import.
@@ -227,6 +230,7 @@ func importIssuesCore(ctx context.Context, dbPath string, store storage.Storage,
OrphanHandling: importer.OrphanHandling(orphanHandling),
NoGitHistory: opts.NoGitHistory,
IgnoreDeletions: opts.IgnoreDeletions,
ProtectLocalExportIDs: opts.ProtectLocalExportIDs,
}
// Delegate to the importer package
@@ -252,6 +256,8 @@ func importIssuesCore(ctx context.Context, dbPath string, store storage.Storage,
PurgedIDs: result.PurgedIDs,
SkippedDeleted: result.SkippedDeleted,
SkippedDeletedIDs: result.SkippedDeletedIDs,
PreservedLocalExport: result.PreservedLocalExport,
PreservedLocalIDs: result.PreservedLocalIDs,
}, nil
}

View File

@@ -520,8 +520,10 @@ Use --merge to merge the sync branch back to main branch.`,
}
// Step 4: Import updated JSONL after pull
// Enable --protect-left-snapshot to prevent git-history-backfill from
// tombstoning issues that were in our local export but got lost during merge (bd-sync-deletion fix)
fmt.Println("→ Importing updated JSONL...")
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil {
if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory, true); err != nil {
fmt.Fprintf(os.Stderr, "Error importing: %v\n", err)
os.Exit(1)
}
@@ -1455,23 +1457,37 @@ func mergeSyncBranch(ctx context.Context, dryRun bool) error {
}
// importFromJSONL imports the JSONL file by running the import command
func importFromJSONL(ctx context.Context, jsonlPath string, renameOnImport bool, noGitHistory ...bool) error {
// Optional parameters: noGitHistory, protectLeftSnapshot (bd-sync-deletion fix)
func importFromJSONL(ctx context.Context, jsonlPath string, renameOnImport bool, opts ...bool) error {
// Get current executable path to avoid "./bd" path issues
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("cannot resolve current executable: %w", err)
}
// Parse optional parameters
noGitHistory := false
protectLeftSnapshot := false
if len(opts) > 0 {
noGitHistory = opts[0]
}
if len(opts) > 1 {
protectLeftSnapshot = opts[1]
}
// Build args for import command
// Use --no-daemon to ensure subprocess uses direct mode, avoiding daemon connection issues
args := []string{"--no-daemon", "import", "-i", jsonlPath}
if renameOnImport {
args = append(args, "--rename-on-import")
}
// Handle optional noGitHistory parameter
if len(noGitHistory) > 0 && noGitHistory[0] {
if noGitHistory {
args = append(args, "--no-git-history")
}
// Add --protect-left-snapshot flag for post-pull imports (bd-sync-deletion fix)
if protectLeftSnapshot {
args = append(args, "--protect-left-snapshot")
}
// Run import command
cmd := exec.CommandContext(ctx, exe, args...) // #nosec G204 - bd import command from trusted binary