diff --git a/cmd/bd/compact.go b/cmd/bd/compact.go index 214bdcbe..b95fb469 100644 --- a/cmd/bd/compact.go +++ b/cmd/bd/compact.go @@ -166,8 +166,7 @@ Examples: } else { sqliteStore, ok := store.(*sqlite.SQLiteStorage) if !ok { - fmt.Fprintf(os.Stderr, "Error: compact requires SQLite storage\n") - os.Exit(1) + FatalError("compact requires SQLite storage") } runCompactStats(ctx, sqliteStore) } @@ -188,26 +187,20 @@ Examples: // Check for exactly one mode if activeModes == 0 { - fmt.Fprintf(os.Stderr, "Error: must specify one mode: --analyze, --apply, or --auto\n") - os.Exit(1) + FatalError("must specify one mode: --analyze, --apply, or --auto") } if activeModes > 1 { - fmt.Fprintf(os.Stderr, "Error: cannot use multiple modes together (--analyze, --apply, --auto are mutually exclusive)\n") - os.Exit(1) + FatalError("cannot use multiple modes together (--analyze, --apply, --auto are mutually exclusive)") } // Handle analyze mode (requires direct database access) if compactAnalyze { if err := ensureDirectMode("compact --analyze requires direct database access"); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - fmt.Fprintf(os.Stderr, "Hint: Use --no-daemon flag to bypass daemon and access database directly\n") - os.Exit(1) + FatalErrorWithHint(fmt.Sprintf("%v", err), "Use --no-daemon flag to bypass daemon and access database directly") } sqliteStore, ok := store.(*sqlite.SQLiteStorage) if !ok { - fmt.Fprintf(os.Stderr, "Error: failed to open database in direct mode\n") - fmt.Fprintf(os.Stderr, "Hint: Ensure .beads/beads.db exists and is readable\n") - os.Exit(1) + FatalErrorWithHint("failed to open database in direct mode", "Ensure .beads/beads.db exists and is readable") } runCompactAnalyze(ctx, sqliteStore) return @@ -216,23 +209,17 @@ Examples: // Handle apply mode (requires direct database access) if compactApply { if err := ensureDirectMode("compact --apply requires direct database access"); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - fmt.Fprintf(os.Stderr, "Hint: Use --no-daemon flag to bypass daemon and access database directly\n") - os.Exit(1) + FatalErrorWithHint(fmt.Sprintf("%v", err), "Use --no-daemon flag to bypass daemon and access database directly") } if compactID == "" { - fmt.Fprintf(os.Stderr, "Error: --apply requires --id\n") - os.Exit(1) + FatalError("--apply requires --id") } if compactSummary == "" { - fmt.Fprintf(os.Stderr, "Error: --apply requires --summary\n") - os.Exit(1) + FatalError("--apply requires --summary") } sqliteStore, ok := store.(*sqlite.SQLiteStorage) if !ok { - fmt.Fprintf(os.Stderr, "Error: failed to open database in direct mode\n") - fmt.Fprintf(os.Stderr, "Hint: Ensure .beads/beads.db exists and is readable\n") - os.Exit(1) + FatalErrorWithHint("failed to open database in direct mode", "Ensure .beads/beads.db exists and is readable") } runCompactApply(ctx, sqliteStore) return @@ -248,16 +235,13 @@ Examples: // Validation checks if compactID != "" && compactAll { - fmt.Fprintf(os.Stderr, "Error: cannot use --id and --all together\n") - os.Exit(1) + FatalError("cannot use --id and --all together") } if compactForce && compactID == "" { - fmt.Fprintf(os.Stderr, "Error: --force requires --id\n") - os.Exit(1) + FatalError("--force requires --id") } if compactID == "" && !compactAll && !compactDryRun { - fmt.Fprintf(os.Stderr, "Error: must specify --all, --id, or --dry-run\n") - os.Exit(1) + FatalError("must specify --all, --id, or --dry-run") } // Use RPC if daemon available, otherwise direct mode @@ -269,14 +253,12 @@ Examples: // Fallback to direct mode apiKey := os.Getenv("ANTHROPIC_API_KEY") if apiKey == "" && !compactDryRun { - fmt.Fprintf(os.Stderr, "Error: --auto mode requires ANTHROPIC_API_KEY environment variable\n") - os.Exit(1) + FatalError("--auto mode requires ANTHROPIC_API_KEY environment variable") } sqliteStore, ok := store.(*sqlite.SQLiteStorage) if !ok { - fmt.Fprintf(os.Stderr, "Error: compact requires SQLite storage\n") - os.Exit(1) + FatalError("compact requires SQLite storage") } config := &compact.Config{ @@ -289,8 +271,7 @@ Examples: compactor, err := compact.New(sqliteStore, apiKey, config) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to create compactor: %v\n", err) - os.Exit(1) + FatalError("failed to create compactor: %v", err) } if compactID != "" { @@ -309,19 +290,16 @@ func runCompactSingle(ctx context.Context, compactor *compact.Compactor, store * if !compactForce { eligible, reason, err := store.CheckEligibility(ctx, issueID, compactTier) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to check eligibility: %v\n", err) - os.Exit(1) + FatalError("failed to check eligibility: %v", err) } if !eligible { - fmt.Fprintf(os.Stderr, "Error: %s is not eligible for Tier %d compaction: %s\n", issueID, compactTier, reason) - os.Exit(1) + FatalError("%s is not eligible for Tier %d compaction: %s", issueID, compactTier, reason) } } issue, err := store.GetIssue(ctx, issueID) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to get issue: %v\n", err) - os.Exit(1) + FatalError("failed to get issue: %v", err) } originalSize := len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria) @@ -349,19 +327,16 @@ func runCompactSingle(ctx context.Context, compactor *compact.Compactor, store * if compactTier == 1 { compactErr = compactor.CompactTier1(ctx, issueID) } else { - fmt.Fprintf(os.Stderr, "Error: Tier 2 compaction not yet implemented\n") - os.Exit(1) + FatalError("Tier 2 compaction not yet implemented") } if compactErr != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", compactErr) - os.Exit(1) + FatalError("%v", compactErr) } issue, err = store.GetIssue(ctx, issueID) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to get updated issue: %v\n", err) - os.Exit(1) + FatalError("failed to get updated issue: %v", err) } compactedSize := len(issue.Description) @@ -407,8 +382,7 @@ func runCompactAll(ctx context.Context, compactor *compact.Compactor, store *sql if compactTier == 1 { tier1, err := store.GetTier1Candidates(ctx) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to get candidates: %v\n", err) - os.Exit(1) + FatalError("failed to get candidates: %v", err) } for _, c := range tier1 { candidates = append(candidates, c.IssueID) @@ -416,8 +390,7 @@ func runCompactAll(ctx context.Context, compactor *compact.Compactor, store *sql } else { tier2, err := store.GetTier2Candidates(ctx) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to get candidates: %v\n", err) - os.Exit(1) + FatalError("failed to get candidates: %v", err) } for _, c := range tier2 { candidates = append(candidates, c.IssueID) @@ -471,8 +444,7 @@ func runCompactAll(ctx context.Context, compactor *compact.Compactor, store *sql results, err := compactor.CompactTier1Batch(ctx, candidates) if err != nil { - fmt.Fprintf(os.Stderr, "Error: batch compaction failed: %v\n", err) - os.Exit(1) + FatalError("batch compaction failed: %v", err) } successCount := 0 @@ -535,14 +507,12 @@ func runCompactAll(ctx context.Context, compactor *compact.Compactor, store *sql func runCompactStats(ctx context.Context, store *sqlite.SQLiteStorage) { tier1, err := store.GetTier1Candidates(ctx) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to get Tier 1 candidates: %v\n", err) - os.Exit(1) + FatalError("failed to get Tier 1 candidates: %v", err) } tier2, err := store.GetTier2Candidates(ctx) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to get Tier 2 candidates: %v\n", err) - os.Exit(1) + FatalError("failed to get Tier 2 candidates: %v", err) } tier1Size := 0 @@ -608,24 +578,20 @@ func progressBar(current, total int) string { //nolint:unparam // ctx may be used in future for cancellation func runCompactRPC(_ context.Context) { if compactID != "" && compactAll { - fmt.Fprintf(os.Stderr, "Error: cannot use --id and --all together\n") - os.Exit(1) + FatalError("cannot use --id and --all together") } if compactForce && compactID == "" { - fmt.Fprintf(os.Stderr, "Error: --force requires --id\n") - os.Exit(1) + FatalError("--force requires --id") } if compactID == "" && !compactAll && !compactDryRun { - fmt.Fprintf(os.Stderr, "Error: must specify --all, --id, or --dry-run\n") - os.Exit(1) + FatalError("must specify --all, --id, or --dry-run") } apiKey := os.Getenv("ANTHROPIC_API_KEY") if apiKey == "" && !compactDryRun { - fmt.Fprintf(os.Stderr, "Error: ANTHROPIC_API_KEY environment variable not set\n") - os.Exit(1) + FatalError("ANTHROPIC_API_KEY environment variable not set") } args := map[string]interface{}{ @@ -643,13 +609,11 @@ func runCompactRPC(_ context.Context) { resp, err := daemonClient.Execute("compact", args) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + FatalError("%v", err) } if !resp.Success { - fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) - os.Exit(1) + FatalError("%s", resp.Error) } if jsonOutput { @@ -676,8 +640,7 @@ func runCompactRPC(_ context.Context) { } if err := json.Unmarshal(resp.Data, &result); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) - os.Exit(1) + FatalError("parsing response: %v", err) } if compactID != "" { @@ -722,13 +685,11 @@ func runCompactStatsRPC() { resp, err := daemonClient.Execute("compact_stats", args) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + FatalError("%v", err) } if !resp.Success { - fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) - os.Exit(1) + FatalError("%s", resp.Error) } if jsonOutput { @@ -749,8 +710,7 @@ func runCompactStatsRPC() { } if err := json.Unmarshal(resp.Data, &result); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) - os.Exit(1) + FatalError("parsing response: %v", err) } fmt.Printf("\nCompaction Statistics\n") @@ -784,8 +744,7 @@ func runCompactAnalyze(ctx context.Context, store *sqlite.SQLiteStorage) { if compactID != "" { issue, err := store.GetIssue(ctx, compactID) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to get issue: %v\n", err) - os.Exit(1) + FatalError("failed to get issue: %v", err) } sizeBytes := len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria) @@ -816,8 +775,7 @@ func runCompactAnalyze(ctx context.Context, store *sqlite.SQLiteStorage) { tierCandidates, err = store.GetTier2Candidates(ctx) } if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to get candidates: %v\n", err) - os.Exit(1) + FatalError("failed to get candidates: %v", err) } // Apply limit if specified @@ -879,15 +837,13 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) { // Read from stdin summaryBytes, err = io.ReadAll(os.Stdin) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to read summary from stdin: %v\n", err) - os.Exit(1) + FatalError("failed to read summary from stdin: %v", err) } } else { // #nosec G304 -- summary file path provided explicitly by operator summaryBytes, err = os.ReadFile(compactSummary) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to read summary file: %v\n", err) - os.Exit(1) + FatalError("failed to read summary file: %v", err) } } summary := string(summaryBytes) @@ -895,8 +851,7 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) { // Get issue issue, err := store.GetIssue(ctx, compactID) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to get issue: %v\n", err) - os.Exit(1) + FatalError("failed to get issue: %v", err) } // Calculate sizes @@ -907,20 +862,15 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) { if !compactForce { eligible, reason, err := store.CheckEligibility(ctx, compactID, compactTier) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to check eligibility: %v\n", err) - os.Exit(1) + FatalError("failed to check eligibility: %v", err) } if !eligible { - fmt.Fprintf(os.Stderr, "Error: %s is not eligible for Tier %d compaction: %s\n", compactID, compactTier, reason) - fmt.Fprintf(os.Stderr, "Hint: use --force to bypass eligibility checks\n") - os.Exit(1) + FatalErrorWithHint(fmt.Sprintf("%s is not eligible for Tier %d compaction: %s", compactID, compactTier, reason), "use --force to bypass eligibility checks") } // Enforce size reduction unless --force if compactedSize >= originalSize { - fmt.Fprintf(os.Stderr, "Error: summary (%d bytes) is not shorter than original (%d bytes)\n", compactedSize, originalSize) - fmt.Fprintf(os.Stderr, "Hint: use --force to bypass size validation\n") - os.Exit(1) + FatalErrorWithHint(fmt.Sprintf("summary (%d bytes) is not shorter than original (%d bytes)", compactedSize, originalSize), "use --force to bypass size validation") } } @@ -938,27 +888,23 @@ func runCompactApply(ctx context.Context, store *sqlite.SQLiteStorage) { } if err := store.UpdateIssue(ctx, compactID, updates, actor); err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to update issue: %v\n", err) - os.Exit(1) + FatalError("failed to update issue: %v", err) } commitHash := compact.GetCurrentCommitHash() if err := store.ApplyCompaction(ctx, compactID, compactTier, originalSize, compactedSize, commitHash); err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to apply compaction: %v\n", err) - os.Exit(1) + FatalError("failed to apply compaction: %v", err) } savingBytes := originalSize - compactedSize reductionPct := float64(savingBytes) / float64(originalSize) * 100 eventData := fmt.Sprintf("Tier %d compaction: %d → %d bytes (saved %d, %.1f%%)", compactTier, originalSize, compactedSize, savingBytes, reductionPct) if err := store.AddComment(ctx, compactID, actor, eventData); err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to record event: %v\n", err) - os.Exit(1) + FatalError("failed to record event: %v", err) } if err := store.MarkIssueDirty(ctx, compactID); err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to mark dirty: %v\n", err) - os.Exit(1) + FatalError("failed to mark dirty: %v", err) } elapsed := time.Since(start) diff --git a/cmd/bd/migrate.go b/cmd/bd/migrate.go index e06470f2..24d30ad0 100644 --- a/cmd/bd/migrate.go +++ b/cmd/bd/migrate.go @@ -74,11 +74,10 @@ This command: "error": "no_beads_directory", "message": "No .beads directory found. Run 'bd init' first.", }) - } else { - fmt.Fprintf(os.Stderr, "Error: no .beads directory found\n") - fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to initialize bd\n") - } os.Exit(1) + } else { + FatalErrorWithHint("no .beads directory found", "run 'bd init' to initialize bd") + } } // Load config to get target database name (respects user's config.json) @@ -103,10 +102,10 @@ This command: "error": "detection_failed", "message": err.Error(), }) + os.Exit(1) } else { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + FatalError("%v", err) } - os.Exit(1) } if len(databases) == 0 { @@ -174,14 +173,15 @@ This command: "message": "Multiple old database files found", "databases": formatDBList(oldDBs), }) + os.Exit(1) } else { fmt.Fprintf(os.Stderr, "Error: multiple old database files found:\n") for _, db := range oldDBs { fmt.Fprintf(os.Stderr, " - %s (version: %s)\n", filepath.Base(db.path), db.version) } fmt.Fprintf(os.Stderr, "\nPlease manually rename the correct database to %s and remove others.\n", cfg.Database) + os.Exit(1) } - os.Exit(1) } else if currentDB != nil && currentDB.version != Version { // Update version metadata needsVersionUpdate = true diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index 23d9d8b1..7d75f25d 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -83,15 +83,13 @@ Use --merge to merge the sync branch back to main branch.`, // Find JSONL path jsonlPath := findJSONLPath() if jsonlPath == "" { - fmt.Fprintf(os.Stderr, "Error: not in a bd workspace (no .beads directory found)\n") - os.Exit(1) + FatalError("not in a bd workspace (no .beads directory found)") } // If status mode, show diff between sync branch and main if status { if err := showSyncStatus(ctx); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + FatalError("%v", err) } return } @@ -105,8 +103,7 @@ Use --merge to merge the sync branch back to main branch.`, // If merge mode, merge sync branch to main if merge { if err := mergeSyncBranch(ctx, dryRun); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + FatalError("%v", err) } return } @@ -114,8 +111,7 @@ Use --merge to merge the sync branch back to main branch.`, // If from-main mode, one-way sync from main branch (gt-ick9: ephemeral branch support) if fromMain { if err := doSyncFromMain(ctx, jsonlPath, renameOnImport, dryRun, noGitHistory); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + FatalError("%v", err) } return } @@ -127,8 +123,7 @@ Use --merge to merge the sync branch back to main branch.`, } else { fmt.Println("→ Importing from JSONL...") if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil { - fmt.Fprintf(os.Stderr, "Error importing: %v\n", err) - os.Exit(1) + FatalError("importing: %v", err) } fmt.Println("✓ Import complete") } @@ -141,8 +136,7 @@ Use --merge to merge the sync branch back to main branch.`, fmt.Println("→ [DRY RUN] Would export pending changes to JSONL") } else { if err := exportToJSONL(ctx, jsonlPath); err != nil { - fmt.Fprintf(os.Stderr, "Error exporting: %v\n", err) - os.Exit(1) + FatalError("exporting: %v", err) } } return @@ -156,8 +150,7 @@ Use --merge to merge the sync branch back to main branch.`, } else { fmt.Println("→ Exporting pending changes to JSONL (squash mode)...") if err := exportToJSONL(ctx, jsonlPath); err != nil { - fmt.Fprintf(os.Stderr, "Error exporting: %v\n", err) - os.Exit(1) + FatalError("exporting: %v", err) } fmt.Println("✓ Changes accumulated in JSONL") fmt.Println(" Run 'bd sync' (without --squash) to commit all accumulated changes") @@ -167,19 +160,14 @@ Use --merge to merge the sync branch back to main branch.`, // Check if we're in a git repository if !isGitRepo() { - fmt.Fprintf(os.Stderr, "Error: not in a git repository\n") - fmt.Fprintf(os.Stderr, "Hint: run 'git init' to initialize a repository\n") - os.Exit(1) + FatalErrorWithHint("not in a git repository", "run 'git init' to initialize a repository") } // Preflight: check for merge/rebase in progress if inMerge, err := gitHasUnmergedPaths(); err != nil { - fmt.Fprintf(os.Stderr, "Error checking git state: %v\n", err) - os.Exit(1) + FatalError("checking git state: %v", err) } else if inMerge { - fmt.Fprintf(os.Stderr, "Error: unmerged paths or merge in progress\n") - fmt.Fprintf(os.Stderr, "Hint: resolve conflicts, run 'bd import' if needed, then 'bd sync' again\n") - os.Exit(1) + FatalErrorWithHint("unmerged paths or merge in progress", "resolve conflicts, run 'bd import' if needed, then 'bd sync' again") } // GH#638: Check sync.branch BEFORE upstream check @@ -201,8 +189,7 @@ Use --merge to merge the sync branch back to main branch.`, fmt.Println("→ No upstream configured, using --from-main mode") // Force noGitHistory=true for auto-detected from-main mode (fixes #417) if err := doSyncFromMain(ctx, jsonlPath, renameOnImport, dryRun, true); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + FatalError("%v", err) } return } @@ -235,8 +222,7 @@ Use --merge to merge the sync branch back to main branch.`, fmt.Printf("→ DB has %d issues but JSONL has %d (stale JSONL detected)\n", dbCount, jsonlCount) fmt.Println("→ Importing JSONL first (ZFC)...") if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil { - fmt.Fprintf(os.Stderr, "Error importing (ZFC): %v\n", err) - os.Exit(1) + FatalError("importing (ZFC): %v", err) } // Skip export after ZFC import - JSONL is source of truth skipExport = true @@ -256,8 +242,7 @@ Use --merge to merge the sync branch back to main branch.`, fmt.Printf("→ JSONL has %d issues but DB has only %d (stale DB detected - bd-53c)\n", jsonlCount, dbCount) fmt.Println("→ Importing JSONL first to prevent data loss...") if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil { - fmt.Fprintf(os.Stderr, "Error importing (reverse ZFC): %v\n", err) - os.Exit(1) + FatalError("importing (reverse ZFC): %v", err) } // Skip export after import - JSONL is source of truth skipExport = true @@ -285,8 +270,7 @@ Use --merge to merge the sync branch back to main branch.`, fmt.Println("→ JSONL content differs from last sync (bd-f2f)") fmt.Println("→ Importing JSONL first to prevent stale DB from overwriting changes...") if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil { - fmt.Fprintf(os.Stderr, "Error importing (bd-f2f hash mismatch): %v\n", err) - os.Exit(1) + FatalError("importing (bd-f2f hash mismatch): %v", err) } // Don't skip export - we still want to export any remaining local dirty issues // The import updated DB with JSONL content, and export will write merged state @@ -299,12 +283,10 @@ Use --merge to merge the sync branch back to main branch.`, // Pre-export integrity checks if err := ensureStoreActive(); err == nil && store != nil { if err := validatePreExport(ctx, store, jsonlPath); err != nil { - fmt.Fprintf(os.Stderr, "Pre-export validation failed: %v\n", err) - os.Exit(1) + FatalError("pre-export validation failed: %v", err) } if err := checkDuplicateIDs(ctx, store); err != nil { - fmt.Fprintf(os.Stderr, "Database corruption detected: %v\n", err) - os.Exit(1) + FatalError("database corruption detected: %v", err) } if orphaned, err := checkOrphanedDeps(ctx, store); err != nil { fmt.Fprintf(os.Stderr, "Warning: orphaned dependency check failed: %v\n", err) @@ -315,16 +297,14 @@ Use --merge to merge the sync branch back to main branch.`, fmt.Println("→ Exporting pending changes to JSONL...") if err := exportToJSONL(ctx, jsonlPath); err != nil { - fmt.Fprintf(os.Stderr, "Error exporting: %v\n", err) - os.Exit(1) + FatalError("exporting: %v", err) } } // Capture left snapshot (pre-pull state) for 3-way merge // This is mandatory for deletion tracking integrity if err := captureLeftSnapshot(jsonlPath); err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to capture snapshot (required for deletion tracking): %v\n", err) - os.Exit(1) + FatalError("failed to capture snapshot (required for deletion tracking): %v", err) } } @@ -340,8 +320,7 @@ Use --merge to merge the sync branch back to main branch.`, // Check for changes in the external beads repo externalRepoRoot, err := getRepoRootFromPath(ctx, beadsDir) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + FatalError("%v", err) } // Check if there are changes to commit @@ -356,8 +335,7 @@ Use --merge to merge the sync branch back to main branch.`, } else { committed, err := commitToExternalBeadsRepo(ctx, beadsDir, message, !noPush) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + FatalError("%v", err) } if committed { if !noPush { @@ -377,16 +355,14 @@ Use --merge to merge the sync branch back to main branch.`, } else { fmt.Println("→ Pulling from external beads repo...") if err := pullFromExternalBeadsRepo(ctx, beadsDir); err != nil { - fmt.Fprintf(os.Stderr, "Error pulling: %v\n", err) - os.Exit(1) + FatalError("pulling: %v", err) } fmt.Println("✓ Pulled from external beads repo") // Re-import after pull to update local database fmt.Println("→ Importing JSONL...") if err := importFromJSONL(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil { - fmt.Fprintf(os.Stderr, "Error importing: %v\n", err) - os.Exit(1) + FatalError("importing: %v", err) } } } @@ -426,8 +402,7 @@ Use --merge to merge the sync branch back to main branch.`, // Step 2: Check if there are changes to commit (check entire .beads/ directory) hasChanges, err := gitHasBeadsChanges(ctx) if err != nil { - fmt.Fprintf(os.Stderr, "Error checking git status: %v\n", err) - os.Exit(1) + FatalError("checking git status: %v", err) } // Track if we already pushed via worktree (to skip Step 5) @@ -448,8 +423,7 @@ Use --merge to merge the sync branch back to main branch.`, fmt.Printf("→ Committing changes to sync branch '%s'...\n", syncBranchName) result, err := syncbranch.CommitToSyncBranch(ctx, repoRoot, syncBranchName, jsonlPath, !noPush) if err != nil { - fmt.Fprintf(os.Stderr, "Error committing to sync branch: %v\n", err) - os.Exit(1) + FatalError("committing to sync branch: %v", err) } if result.Committed { fmt.Printf("✓ Committed to %s\n", syncBranchName) @@ -467,8 +441,7 @@ Use --merge to merge the sync branch back to main branch.`, fmt.Println("→ Committing changes to git...") } if err := gitCommitBeadsDir(ctx, message); err != nil { - fmt.Fprintf(os.Stderr, "Error committing: %v\n", err) - os.Exit(1) + FatalError("committing: %v", err) } } } else { @@ -498,8 +471,7 @@ Use --merge to merge the sync branch back to main branch.`, pullResult, err := syncbranch.PullFromSyncBranch(ctx, repoRoot, syncBranchName, jsonlPath, !noPush, requireMassDeleteConfirmation) if err != nil { - fmt.Fprintf(os.Stderr, "Error pulling from sync branch: %v\n", err) - os.Exit(1) + FatalError("pulling from sync branch: %v", err) } if pullResult.Pulled { if pullResult.Merged { @@ -525,8 +497,7 @@ Use --merge to merge the sync branch back to main branch.`, if response == "y" || response == "yes" { fmt.Printf("→ Pushing to %s...\n", syncBranchName) if err := syncbranch.PushSyncBranch(ctx, repoRoot, syncBranchName); err != nil { - fmt.Fprintf(os.Stderr, "Error pushing to sync branch: %v\n", err) - os.Exit(1) + FatalError("pushing to sync branch: %v", err) } fmt.Printf("✓ Pushed merged changes to %s\n", syncBranchName) pushedViaSyncBranch = true @@ -564,31 +535,23 @@ Use --merge to merge the sync branch back to main branch.`, // Export clean JSONL from DB (database is source of truth) if exportErr := exportToJSONL(ctx, jsonlPath); exportErr != nil { - fmt.Fprintf(os.Stderr, "Error: failed to export for conflict resolution: %v\n", exportErr) - fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n") - os.Exit(1) + FatalErrorWithHint(fmt.Sprintf("failed to export for conflict resolution: %v", exportErr), "resolve conflicts manually and run 'bd import' then 'bd sync' again") } // Mark conflict as resolved addCmd := exec.CommandContext(ctx, "git", "add", jsonlPath) if addErr := addCmd.Run(); addErr != nil { - fmt.Fprintf(os.Stderr, "Error: failed to mark conflict resolved: %v\n", addErr) - fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n") - os.Exit(1) + FatalErrorWithHint(fmt.Sprintf("failed to mark conflict resolved: %v", addErr), "resolve conflicts manually and run 'bd import' then 'bd sync' again") } // Continue rebase if continueErr := runGitRebaseContinue(ctx); continueErr != nil { - fmt.Fprintf(os.Stderr, "Error: failed to continue rebase: %v\n", continueErr) - fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n") - os.Exit(1) + FatalErrorWithHint(fmt.Sprintf("failed to continue rebase: %v", continueErr), "resolve conflicts manually and run 'bd import' then 'bd sync' again") } fmt.Println("✓ Auto-resolved JSONL conflict") } else { // Not an auto-resolvable conflict, fail with original error - fmt.Fprintf(os.Stderr, "Error pulling: %v\n", err) - // Check if this looks like a merge driver failure errStr := err.Error() if strings.Contains(errStr, "merge driver") || @@ -598,8 +561,7 @@ Use --merge to merge the sync branch back to main branch.`, fmt.Fprintf(os.Stderr, "Fix: bd doctor --fix\n\n") } - fmt.Fprintf(os.Stderr, "Hint: resolve conflicts manually and run 'bd import' then 'bd sync' again\n") - os.Exit(1) + FatalErrorWithHint(fmt.Sprintf("pulling: %v", err), "resolve conflicts manually and run 'bd import' then 'bd sync' again") } } } @@ -617,8 +579,7 @@ Use --merge to merge the sync branch back to main branch.`, // Step 3.5: Perform 3-way merge and prune deletions if err := ensureStoreActive(); err == nil && store != nil { if err := applyDeletionsFromMerge(ctx, store, jsonlPath); err != nil { - fmt.Fprintf(os.Stderr, "Error during 3-way merge: %v\n", err) - os.Exit(1) + FatalError("during 3-way merge: %v", err) } } @@ -627,8 +588,7 @@ Use --merge to merge the sync branch back to main branch.`, // 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, true); err != nil { - fmt.Fprintf(os.Stderr, "Error importing: %v\n", err) - os.Exit(1) + FatalError("importing: %v", err) } // Validate import didn't cause data loss @@ -639,8 +599,7 @@ Use --merge to merge the sync branch back to main branch.`, fmt.Fprintf(os.Stderr, "Warning: failed to count issues after import: %v\n", err) } else { if err := validatePostImportWithExpectedDeletions(beforeCount, afterCount, 0, jsonlPath); err != nil { - fmt.Fprintf(os.Stderr, "Post-import validation failed: %v\n", err) - os.Exit(1) + FatalError("post-import validation failed: %v", err) } } } @@ -681,15 +640,13 @@ Use --merge to merge the sync branch back to main branch.`, if needsExport { fmt.Println("→ Re-exporting after import to sync DB changes...") if err := exportToJSONL(ctx, jsonlPath); err != nil { - fmt.Fprintf(os.Stderr, "Error re-exporting after import: %v\n", err) - os.Exit(1) + FatalError("re-exporting after import: %v", err) } // Step 4.6: Commit the re-export if it created changes hasPostImportChanges, err := gitHasBeadsChanges(ctx) if err != nil { - fmt.Fprintf(os.Stderr, "Error checking git status after re-export: %v\n", err) - os.Exit(1) + FatalError("checking git status after re-export: %v", err) } if hasPostImportChanges { fmt.Println("→ Committing DB changes from import...") @@ -697,16 +654,14 @@ Use --merge to merge the sync branch back to main branch.`, // Commit to sync branch via worktree (bd-e3w) result, err := syncbranch.CommitToSyncBranch(ctx, repoRoot, syncBranchName, jsonlPath, !noPush) if err != nil { - fmt.Fprintf(os.Stderr, "Error committing to sync branch: %v\n", err) - os.Exit(1) + FatalError("committing to sync branch: %v", err) } if result.Pushed { pushedViaSyncBranch = true } } else { if err := gitCommitBeadsDir(ctx, "bd sync: apply DB changes after import"); err != nil { - fmt.Fprintf(os.Stderr, "Error committing post-import changes: %v\n", err) - os.Exit(1) + FatalError("committing post-import changes: %v", err) } } hasChanges = true // Mark that we have changes to push @@ -733,9 +688,7 @@ Use --merge to merge the sync branch back to main branch.`, } else { fmt.Println("→ Pushing to remote...") if err := gitPush(ctx); err != nil { - fmt.Fprintf(os.Stderr, "Error pushing: %v\n", err) - fmt.Fprintf(os.Stderr, "Hint: pull may have brought new changes, run 'bd sync' again\n") - os.Exit(1) + FatalErrorWithHint(fmt.Sprintf("pushing: %v", err), "pull may have brought new changes, run 'bd sync' again") } } }