package main import ( "bufio" "database/sql" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "time" "github.com/fatih/color" _ "github.com/ncruces/go-sqlite3/driver" _ "github.com/ncruces/go-sqlite3/embed" "github.com/spf13/cobra" "github.com/steveyegge/beads/cmd/bd/doctor" "github.com/steveyegge/beads/cmd/bd/doctor/fix" "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/daemon" "github.com/steveyegge/beads/internal/git" "github.com/steveyegge/beads/internal/syncbranch" ) // Status constants for doctor checks const ( statusOK = "ok" statusWarning = "warning" statusError = "error" ) type doctorCheck struct { Name string `json:"name"` Status string `json:"status"` // statusOK, statusWarning, or statusError Message string `json:"message"` Detail string `json:"detail,omitempty"` // Additional detail like storage type Fix string `json:"fix,omitempty"` } type doctorResult struct { Path string `json:"path"` Checks []doctorCheck `json:"checks"` OverallOK bool `json:"overall_ok"` CLIVersion string `json:"cli_version"` Timestamp string `json:"timestamp,omitempty"` // bd-9cc: ISO8601 timestamp for historical tracking Platform map[string]string `json:"platform,omitempty"` // bd-9cc: platform info for debugging } var ( doctorFix bool doctorYes bool doctorInteractive bool // bd-3xl: per-fix confirmation mode doctorDryRun bool // bd-a5z: preview fixes without applying doctorOutput string // bd-9cc: export diagnostics to file perfMode bool checkHealthMode bool ) // ConfigKeyHintsDoctor is the config key for suppressing doctor hints const ConfigKeyHintsDoctor = "hints.doctor" // minSyncBranchHookVersion is the minimum hook version that supports sync-branch bypass (issue #532) const minSyncBranchHookVersion = "0.29.0" var doctorCmd = &cobra.Command{ Use: "doctor [path]", Short: "Check beads installation health", Long: `Sanity check the beads installation for the current directory or specified path. This command checks: - If .beads/ directory exists - Database version and migration status - Schema compatibility (all required tables and columns present) - Whether using hash-based vs sequential IDs - If CLI version is current (checks GitHub releases) - Multiple database files - Multiple JSONL files - Daemon health (version mismatches, stale processes) - Database-JSONL sync status - File permissions - Circular dependencies - Git hooks (pre-commit, post-merge, pre-push) - .beads/.gitignore up to date - Metadata.json version tracking (LastBdVersion field) Performance Mode (--perf): Run performance diagnostics on your database: - Times key operations (bd ready, bd list, bd show, etc.) - Collects system info (OS, arch, SQLite version, database stats) - Generates CPU profile for analysis - Outputs shareable report for bug reports Export Mode (--output): Save diagnostics to a JSON file for historical analysis and bug reporting. Includes timestamp and platform info for tracking intermittent issues. Examples: bd doctor # Check current directory bd doctor /path/to/repo # Check specific repository bd doctor --json # Machine-readable output bd doctor --fix # Automatically fix issues (with confirmation) bd doctor --fix --yes # Automatically fix issues (no confirmation) bd doctor --fix -i # Confirm each fix individually (bd-3xl) bd doctor --dry-run # Preview what --fix would do without making changes bd doctor --perf # Performance diagnostics bd doctor --output diagnostics.json # Export diagnostics to file`, Run: func(cmd *cobra.Command, args []string) { // Use global jsonOutput set by PersistentPreRun // Determine path to check checkPath := "." if len(args) > 0 { checkPath = args[0] } // Convert to absolute path absPath, err := filepath.Abs(checkPath) if err != nil { fmt.Fprintf(os.Stderr, "Error: failed to resolve path: %v\n", err) os.Exit(1) } // Run performance diagnostics if --perf flag is set if perfMode { doctor.RunPerformanceDiagnostics(absPath) return } // Run quick health check if --check-health flag is set if checkHealthMode { runCheckHealth(absPath) return } // Run diagnostics result := runDiagnostics(absPath) // bd-a5z: Preview fixes (dry-run) or apply fixes if requested if doctorDryRun { previewFixes(result) } else if doctorFix { applyFixes(result) // Re-run diagnostics to show results result = runDiagnostics(absPath) } // bd-9cc: Add timestamp and platform info for export if doctorOutput != "" || jsonOutput { result.Timestamp = time.Now().UTC().Format(time.RFC3339) result.Platform = doctor.CollectPlatformInfo(absPath) } // bd-9cc: Export to file if --output specified if doctorOutput != "" { if err := exportDiagnostics(result, doctorOutput); err != nil { fmt.Fprintf(os.Stderr, "Error: failed to export diagnostics: %v\n", err) os.Exit(1) } fmt.Printf("✓ Diagnostics exported to %s\n", doctorOutput) } // Output results if jsonOutput { outputJSON(result) } else if doctorOutput == "" { // Only print to console if not exporting (to avoid duplicate output) printDiagnostics(result) } // Exit with error if any checks failed if !result.OverallOK { os.Exit(1) } }, } func init() { doctorCmd.Flags().BoolVar(&doctorFix, "fix", false, "Automatically fix issues where possible") doctorCmd.Flags().BoolVarP(&doctorYes, "yes", "y", false, "Skip confirmation prompt (for non-interactive use)") doctorCmd.Flags().BoolVarP(&doctorInteractive, "interactive", "i", false, "Confirm each fix individually (bd-3xl)") doctorCmd.Flags().BoolVar(&doctorDryRun, "dry-run", false, "Preview fixes without making changes (bd-a5z)") } // previewFixes shows what would be fixed without applying changes (bd-a5z) func previewFixes(result doctorResult) { // Collect all fixable issues var fixableIssues []doctorCheck for _, check := range result.Checks { if (check.Status == statusWarning || check.Status == statusError) && check.Fix != "" { fixableIssues = append(fixableIssues, check) } } if len(fixableIssues) == 0 { fmt.Println("\n✓ No fixable issues found (dry-run)") return } fmt.Println("\n[DRY-RUN] The following issues would be fixed with --fix:") fmt.Println() for i, issue := range fixableIssues { // Show the issue details fmt.Printf(" %d. %s\n", i+1, issue.Name) if issue.Status == statusError { color.Red(" Status: ERROR\n") } else { color.Yellow(" Status: WARNING\n") } fmt.Printf(" Issue: %s\n", issue.Message) if issue.Detail != "" { fmt.Printf(" Detail: %s\n", issue.Detail) } fmt.Printf(" Fix: %s\n", issue.Fix) fmt.Println() } fmt.Printf("[DRY-RUN] Would attempt to fix %d issue(s)\n", len(fixableIssues)) fmt.Println("Run 'bd doctor --fix' to apply these fixes") } func applyFixes(result doctorResult) { // Collect all fixable issues var fixableIssues []doctorCheck for _, check := range result.Checks { if (check.Status == statusWarning || check.Status == statusError) && check.Fix != "" { fixableIssues = append(fixableIssues, check) } } if len(fixableIssues) == 0 { fmt.Println("\nNo fixable issues found.") return } // Show what will be fixed fmt.Println("\nFixable issues:") for i, issue := range fixableIssues { fmt.Printf(" %d. %s: %s\n", i+1, issue.Name, issue.Message) } // bd-3xl: Interactive mode - confirm each fix individually if doctorInteractive { applyFixesInteractive(result.Path, fixableIssues) return } // Ask for confirmation (skip if --yes flag is set) if !doctorYes { fmt.Printf("\nThis will attempt to fix %d issue(s). Continue? (Y/n): ", len(fixableIssues)) reader := bufio.NewReader(os.Stdin) response, err := reader.ReadString('\n') if err != nil { fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err) return } response = strings.TrimSpace(strings.ToLower(response)) if response != "" && response != "y" && response != "yes" { fmt.Println("Fix canceled.") return } } // Apply fixes fmt.Println("\nApplying fixes...") applyFixList(result.Path, fixableIssues) } // applyFixesInteractive prompts for each fix individually (bd-3xl) func applyFixesInteractive(path string, issues []doctorCheck) { reader := bufio.NewReader(os.Stdin) applyAll := false var approvedFixes []doctorCheck fmt.Println("\nReview each fix:") fmt.Println(" [y]es - apply this fix") fmt.Println(" [n]o - skip this fix") fmt.Println(" [a]ll - apply all remaining fixes") fmt.Println(" [q]uit - stop without applying more fixes") fmt.Println() for i, issue := range issues { // Show issue details fmt.Printf("(%d/%d) %s\n", i+1, len(issues), issue.Name) if issue.Status == statusError { color.Red(" Status: ERROR\n") } else { color.Yellow(" Status: WARNING\n") } fmt.Printf(" Issue: %s\n", issue.Message) if issue.Detail != "" { fmt.Printf(" Detail: %s\n", issue.Detail) } fmt.Printf(" Fix: %s\n", issue.Fix) // Check if we should apply all remaining if applyAll { fmt.Println(" → Auto-approved (apply all)") approvedFixes = append(approvedFixes, issue) continue } // Prompt for this fix fmt.Print("\n Apply this fix? [y/n/a/q]: ") response, err := reader.ReadString('\n') if err != nil { fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err) return } response = strings.TrimSpace(strings.ToLower(response)) switch response { case "y", "yes": approvedFixes = append(approvedFixes, issue) fmt.Println(" → Approved") case "n", "no", "": fmt.Println(" → Skipped") case "a", "all": applyAll = true approvedFixes = append(approvedFixes, issue) fmt.Println(" → Approved (applying all remaining)") case "q", "quit": fmt.Println(" → Quit") if len(approvedFixes) > 0 { fmt.Printf("\nApplying %d approved fix(es)...\n", len(approvedFixes)) applyFixList(path, approvedFixes) } else { fmt.Println("\nNo fixes applied.") } return default: // Treat unknown input as skip fmt.Println(" → Skipped (unrecognized input)") } fmt.Println() } // Apply all approved fixes if len(approvedFixes) > 0 { fmt.Printf("\nApplying %d approved fix(es)...\n", len(approvedFixes)) applyFixList(path, approvedFixes) } else { fmt.Println("\nNo fixes approved.") } } // applyFixList applies a list of fixes and reports results func applyFixList(path string, fixes []doctorCheck) { fixedCount := 0 errorCount := 0 for _, check := range fixes { fmt.Printf("\nFixing %s...\n", check.Name) var err error switch check.Name { case "Gitignore": err = doctor.FixGitignore() case "Git Hooks": err = fix.GitHooks(path) case "Daemon Health": err = fix.Daemon(path) case "DB-JSONL Sync": err = fix.DBJSONLSync(path) case "Permissions": err = fix.Permissions(path) case "Database": err = fix.DatabaseVersion(path) case "Schema Compatibility": err = fix.SchemaCompatibility(path) case "Git Merge Driver": err = fix.MergeDriver(path) case "Sync Branch Config": // No auto-fix: sync-branch should be added to config.yaml (version controlled) fmt.Printf(" ⚠ Add 'sync-branch: beads-sync' to .beads/config.yaml\n") continue case "Database Config": err = fix.DatabaseConfig(path) case "JSONL Config": err = fix.LegacyJSONLConfig(path) case "Untracked Files": err = fix.UntrackedJSONL(path) case "Sync Branch Health": // Get sync branch from config syncBranch := syncbranch.GetFromYAML() if syncBranch == "" { fmt.Printf(" ⚠ No sync branch configured in config.yaml\n") continue } err = fix.SyncBranchHealth(path, syncBranch) default: fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name) fmt.Printf(" Manual fix: %s\n", check.Fix) continue } if err != nil { errorCount++ color.Red(" ✗ Error: %v\n", err) // GH#403: Don't suggest "bd doctor --fix" when we're already running --fix manualFix := extractManualFix(check.Fix) if manualFix != "" { fmt.Printf(" Manual fix: %s\n", manualFix) } } else { fixedCount++ color.Green(" ✓ Fixed\n") } } // Summary fmt.Printf("\nFix summary: %d fixed, %d errors\n", fixedCount, errorCount) if errorCount > 0 { fmt.Println("\nSome fixes failed. Please review the errors above and apply manual fixes as needed.") } } // extractManualFix extracts the manual fix portion from a Fix message. // GH#403: When running "bd doctor --fix", suggesting "Run 'bd doctor --fix'" is circular. // This function extracts just the manual command when available. // // Examples: // - "Run 'bd doctor --fix' to ..., or manually: git config ..." -> "git config ..." // - "Run 'bd doctor --fix' or bd init" -> "bd init" // - "Run: bd init or bd doctor --fix" -> "bd init" // - "Run 'bd doctor --fix'" -> "" (no alternative) func extractManualFix(fix string) string { // Pattern 1: "..., or manually: " - extract everything after "manually:" if idx := strings.Index(strings.ToLower(fix), "manually:"); idx != -1 { manual := strings.TrimSpace(fix[idx+len("manually:"):]) return manual } // Pattern 2: "Run 'bd doctor --fix' or " - extract the alternative // Also handles "Run: bd init or bd doctor --fix" fixLower := strings.ToLower(fix) if strings.Contains(fixLower, "bd doctor --fix") { // Try to find " or " and extract the other option if idx := strings.Index(fixLower, " or "); idx != -1 { before := fix[:idx] after := fix[idx+4:] // len(" or ") = 4 // Check which side has "bd doctor --fix" if strings.Contains(strings.ToLower(before), "bd doctor --fix") { // "bd doctor --fix or " - return alternative return strings.TrimSpace(after) } // " or bd doctor --fix" - return alternative // Remove any "Run:" or "Run " prefix result := strings.TrimSpace(before) result = strings.TrimPrefix(result, "Run:") result = strings.TrimPrefix(result, "Run ") return strings.TrimSpace(result) } // No " or " found - the whole fix is just "bd doctor --fix" // Return empty string to indicate no manual alternative return "" } // No "bd doctor --fix" in the message - return as-is return fix } // runCheckHealth runs lightweight health checks for git hooks. // Silent on success, prints a hint if issues detected. // Respects hints.doctor config setting. func runCheckHealth(path string) { beadsDir := filepath.Join(path, ".beads") // Check if .beads/ exists if _, err := os.Stat(beadsDir); os.IsNotExist(err) { // No .beads directory - nothing to check return } // Get database path once (bd-b8h: centralized path resolution) dbPath := getCheckHealthDBPath(beadsDir) // Check if database exists if _, err := os.Stat(dbPath); os.IsNotExist(err) { // No database - only check hooks if issue := checkHooksQuick(); issue != "" { printCheckHealthHint([]string{issue}) } return } // Open database once for all checks (bd-xyc: single DB connection) db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro") if err != nil { // Can't open DB - only check hooks if issue := checkHooksQuick(); issue != "" { printCheckHealthHint([]string{issue}) } return } defer db.Close() // Check if hints.doctor is disabled in config if hintsDisabledDB(db) { return } // Run lightweight checks var issues []string // Check 1: Database version mismatch (CLI vs database bd_version) if issue := checkVersionMismatchDB(db); issue != "" { issues = append(issues, issue) } // Check 2: Sync branch not configured (now reads from config.yaml, not DB) if issue := checkSyncBranchQuick(); issue != "" { issues = append(issues, issue) } // Check 3: Outdated git hooks if issue := checkHooksQuick(); issue != "" { issues = append(issues, issue) } // Check 3: Sync-branch hook compatibility (issue #532) if issue := checkSyncBranchHookQuick(path); issue != "" { issues = append(issues, issue) } // If any issues found, print hint if len(issues) > 0 { printCheckHealthHint(issues) } // Silent exit on success } // printCheckHealthHint prints the health check hint and exits with error. func printCheckHealthHint(issues []string) { fmt.Fprintf(os.Stderr, "💡 bd doctor recommends a health check:\n") for _, issue := range issues { fmt.Fprintf(os.Stderr, " • %s\n", issue) } fmt.Fprintf(os.Stderr, " Run 'bd doctor' for details, or 'bd doctor --fix' to auto-repair\n") fmt.Fprintf(os.Stderr, " (Suppress with: bd config set %s false)\n", ConfigKeyHintsDoctor) os.Exit(1) } // getCheckHealthDBPath returns the database path for check-health operations. // This centralizes the path resolution logic (bd-b8h). func getCheckHealthDBPath(beadsDir string) string { if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { return cfg.DatabasePath(beadsDir) } return filepath.Join(beadsDir, beads.CanonicalDatabaseName) } // hintsDisabledDB checks if hints.doctor is set to "false" using an existing DB connection. // Used by runCheckHealth to avoid multiple DB opens (bd-xyc). func hintsDisabledDB(db *sql.DB) bool { var value string err := db.QueryRow("SELECT value FROM config WHERE key = ?", ConfigKeyHintsDoctor).Scan(&value) if err != nil { return false // Key not set, assume hints enabled } return strings.ToLower(value) == "false" } // checkVersionMismatchDB checks if CLI version differs from database bd_version. // Uses an existing DB connection (bd-xyc). func checkVersionMismatchDB(db *sql.DB) string { var dbVersion string err := db.QueryRow("SELECT value FROM metadata WHERE key = 'bd_version'").Scan(&dbVersion) if err != nil { return "" // Can't read version, skip } if dbVersion != "" && dbVersion != Version { return fmt.Sprintf("Version mismatch (CLI: %s, database: %s)", Version, dbVersion) } return "" } // checkSyncBranchQuick checks if sync-branch is configured in config.yaml. // Fast check that doesn't require database access. func checkSyncBranchQuick() string { if syncbranch.IsConfigured() { return "" } return "sync-branch not configured in config.yaml" } // checkHooksQuick does a fast check for outdated git hooks. // Checks all beads hooks: pre-commit, post-merge, pre-push, post-checkout (bd-2em). func checkHooksQuick() string { // Get actual git directory (handles worktrees where .git is a file) gitDir, err := git.GetGitDir() if err != nil { return "" // Not a git repo, skip } hooksDir := filepath.Join(gitDir, "hooks") // Check if hooks dir exists if _, err := os.Stat(hooksDir); os.IsNotExist(err) { return "" // No git hooks directory, skip } // Check all beads-managed hooks (bd-2em: expanded from just post-merge) hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"} var outdatedHooks []string var oldestVersion string for _, hookName := range hookNames { hookPath := filepath.Join(hooksDir, hookName) content, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled if err != nil { continue // Hook doesn't exist, skip (will be caught by full doctor) } // Look for version marker hookContent := string(content) if !strings.Contains(hookContent, "bd-hooks-version:") { continue // Not a bd hook or old format, skip } // Extract version for _, line := range strings.Split(hookContent, "\n") { if strings.Contains(line, "bd-hooks-version:") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { hookVersion := strings.TrimSpace(parts[1]) if hookVersion != Version { outdatedHooks = append(outdatedHooks, hookName) // Track the oldest version for display if oldestVersion == "" || compareVersions(hookVersion, oldestVersion) < 0 { oldestVersion = hookVersion } } } break } } } if len(outdatedHooks) == 0 { return "" } // Return summary of outdated hooks if len(outdatedHooks) == 1 { return fmt.Sprintf("Git hook %s outdated (%s → %s)", outdatedHooks[0], oldestVersion, Version) } return fmt.Sprintf("Git hooks outdated: %s (%s → %s)", strings.Join(outdatedHooks, ", "), oldestVersion, Version) } // getPrePushHookPath resolves the pre-push hook path for a git repository. // Handles both standard .git/hooks and shared hooks via core.hooksPath. // Returns (hookPath, error) where error is set if not a git repo. // (bd-e0o7: extracted common helper) func getPrePushHookPath(path string) (string, error) { // Get git directory (handles worktrees where .git is a file) cmd := exec.Command("git", "rev-parse", "--git-dir") cmd.Dir = path output, err := cmd.Output() if err != nil { return "", fmt.Errorf("not a git repository") } gitDir := strings.TrimSpace(string(output)) if !filepath.IsAbs(gitDir) { gitDir = filepath.Join(path, gitDir) } // Check for shared hooks via core.hooksPath first hooksPathCmd := exec.Command("git", "config", "--get", "core.hooksPath") hooksPathCmd.Dir = path if hooksPathOutput, err := hooksPathCmd.Output(); err == nil { sharedHooksDir := strings.TrimSpace(string(hooksPathOutput)) if !filepath.IsAbs(sharedHooksDir) { sharedHooksDir = filepath.Join(path, sharedHooksDir) } return filepath.Join(sharedHooksDir, "pre-push"), nil } // Use standard .git/hooks location return filepath.Join(gitDir, "hooks", "pre-push"), nil } // extractBdHookVersion extracts the version from a bd hook's content. // Returns empty string if not a bd hook or version cannot be determined. // (bd-e0o7: extracted common helper) func extractBdHookVersion(content string) string { if !strings.Contains(content, "bd-hooks-version:") { return "" // Not a bd hook } for _, line := range strings.Split(content, "\n") { if strings.Contains(line, "bd-hooks-version:") { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { return strings.TrimSpace(parts[1]) } break } } return "" } // checkSyncBranchHookQuick does a fast check for sync-branch hook compatibility (issue #532). // Returns empty string if OK, otherwise returns issue description. func checkSyncBranchHookQuick(path string) string { // Check if sync-branch is configured syncBranch := syncbranch.GetFromYAML() if syncBranch == "" { return "" // sync-branch not configured, nothing to check } hookPath, err := getPrePushHookPath(path) if err != nil { return "" // Not a git repo, skip } content, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled if err != nil { return "" // No pre-push hook, covered by other checks } hookVersion := extractBdHookVersion(string(content)) if hookVersion == "" { return "" // Not a bd hook or can't determine version } // Check if version < minSyncBranchHookVersion (when sync-branch bypass was added) if compareVersions(hookVersion, minSyncBranchHookVersion) < 0 { return fmt.Sprintf("Pre-push hook (%s) incompatible with sync-branch mode (requires %s+)", hookVersion, minSyncBranchHookVersion) } return "" } func runDiagnostics(path string) doctorResult { result := doctorResult{ Path: path, CLIVersion: Version, OverallOK: true, } // Check 1: Installation (.beads/ directory) installCheck := checkInstallation(path) result.Checks = append(result.Checks, installCheck) if installCheck.Status != statusOK { result.OverallOK = false } // Check Git Hooks early (even if .beads/ doesn't exist yet) hooksCheck := checkGitHooks() result.Checks = append(result.Checks, hooksCheck) // Don't fail overall check for missing hooks, just warn // Check sync-branch hook compatibility (issue #532) syncBranchHookCheck := checkSyncBranchHookCompatibility(path) result.Checks = append(result.Checks, syncBranchHookCheck) if syncBranchHookCheck.Status == statusError { result.OverallOK = false } // If no .beads/, skip remaining checks if installCheck.Status != statusOK { return result } // Check 1a: Fresh clone detection (bd-4ew) // Must come early - if this is a fresh clone, other checks may be misleading freshCloneCheck := convertDoctorCheck(doctor.CheckFreshClone(path)) result.Checks = append(result.Checks, freshCloneCheck) if freshCloneCheck.Status == statusWarning || freshCloneCheck.Status == statusError { result.OverallOK = false } // Check 2: Database version dbCheck := checkDatabaseVersion(path) result.Checks = append(result.Checks, dbCheck) if dbCheck.Status == statusError { result.OverallOK = false } // Check 2a: Schema compatibility (bd-ckvw) schemaCheck := checkSchemaCompatibility(path) result.Checks = append(result.Checks, schemaCheck) if schemaCheck.Status == statusError { result.OverallOK = false } // Check 2b: Database integrity (bd-2au) integrityCheck := checkDatabaseIntegrity(path) result.Checks = append(result.Checks, integrityCheck) if integrityCheck.Status == statusError { result.OverallOK = false } // Check 3: ID format (hash vs sequential) idCheck := checkIDFormat(path) result.Checks = append(result.Checks, idCheck) if idCheck.Status == statusWarning { result.OverallOK = false } // Check 4: CLI version (GitHub) versionCheck := checkCLIVersion() result.Checks = append(result.Checks, versionCheck) // Don't fail overall check for outdated CLI, just warn // Check 5: Multiple database files multiDBCheck := checkMultipleDatabases(path) result.Checks = append(result.Checks, multiDBCheck) if multiDBCheck.Status == statusWarning || multiDBCheck.Status == statusError { result.OverallOK = false } // Check 6: Multiple JSONL files (excluding merge artifacts) jsonlCheck := convertDoctorCheck(doctor.CheckLegacyJSONLFilename(path)) result.Checks = append(result.Checks, jsonlCheck) if jsonlCheck.Status == statusWarning || jsonlCheck.Status == statusError { result.OverallOK = false } // Check 6a: Legacy JSONL config (bd-6xd: migrate beads.jsonl to issues.jsonl) legacyConfigCheck := convertDoctorCheck(doctor.CheckLegacyJSONLConfig(path)) result.Checks = append(result.Checks, legacyConfigCheck) // Don't fail overall check for legacy config, just warn // Check 7: Database/JSONL configuration mismatch configCheck := convertDoctorCheck(doctor.CheckDatabaseConfig(path)) result.Checks = append(result.Checks, configCheck) if configCheck.Status == statusWarning || configCheck.Status == statusError { result.OverallOK = false } // Check 7a: Configuration value validation (bd-alz) configValuesCheck := convertDoctorCheck(doctor.CheckConfigValues(path)) result.Checks = append(result.Checks, configValuesCheck) // Don't fail overall check for config value warnings, just warn // Check 8: Daemon health daemonCheck := checkDaemonStatus(path) result.Checks = append(result.Checks, daemonCheck) if daemonCheck.Status == statusWarning || daemonCheck.Status == statusError { result.OverallOK = false } // Check 9: Database-JSONL sync syncCheck := checkDatabaseJSONLSync(path) result.Checks = append(result.Checks, syncCheck) if syncCheck.Status == statusWarning || syncCheck.Status == statusError { result.OverallOK = false } // Check 9: Permissions permCheck := checkPermissions(path) result.Checks = append(result.Checks, permCheck) if permCheck.Status == statusError { result.OverallOK = false } // Check 10: Dependency cycles cycleCheck := checkDependencyCycles(path) result.Checks = append(result.Checks, cycleCheck) if cycleCheck.Status == statusError || cycleCheck.Status == statusWarning { result.OverallOK = false } // Check 11: Claude integration claudeCheck := convertDoctorCheck(doctor.CheckClaude()) result.Checks = append(result.Checks, claudeCheck) // Don't fail overall check for missing Claude integration, just warn // Check 11a: bd in PATH (needed for Claude hooks to work) bdPathCheck := convertDoctorCheck(doctor.CheckBdInPath()) result.Checks = append(result.Checks, bdPathCheck) // Don't fail overall check for missing bd in PATH, just warn // Check 11b: Documentation bd prime references match installed version bdPrimeDocsCheck := convertDoctorCheck(doctor.CheckDocumentationBdPrimeReference(path)) result.Checks = append(result.Checks, bdPrimeDocsCheck) // Don't fail overall check for doc mismatch, just warn // Check 12: Agent documentation presence agentDocsCheck := convertDoctorCheck(doctor.CheckAgentDocumentation(path)) result.Checks = append(result.Checks, agentDocsCheck) // Don't fail overall check for missing docs, just warn // Check 13: Legacy beads slash commands in documentation legacyDocsCheck := convertDoctorCheck(doctor.CheckLegacyBeadsSlashCommands(path)) result.Checks = append(result.Checks, legacyDocsCheck) // Don't fail overall check for legacy docs, just warn // Check 14: Gitignore up to date gitignoreCheck := convertDoctorCheck(doctor.CheckGitignore()) result.Checks = append(result.Checks, gitignoreCheck) // Don't fail overall check for gitignore, just warn // Check 15: Git merge driver configuration mergeDriverCheck := checkMergeDriver(path) result.Checks = append(result.Checks, mergeDriverCheck) // Don't fail overall check for merge driver, just warn // Check 16: Metadata.json version tracking (bd-u4sb) metadataCheck := checkMetadataVersionTracking(path) result.Checks = append(result.Checks, metadataCheck) // Don't fail overall check for metadata, just warn // Check 17: Sync branch configuration (bd-rsua) syncBranchCheck := checkSyncBranchConfig(path) result.Checks = append(result.Checks, syncBranchCheck) // Don't fail overall check for missing sync.branch, just warn // Check 17a: Sync branch health (bd-6rf) syncBranchHealthCheck := checkSyncBranchHealth(path) result.Checks = append(result.Checks, syncBranchHealthCheck) // Don't fail overall check for sync branch health, just warn // Check 18: Tombstones health (bd-s3v) tombstonesCheck := checkTombstones(path) result.Checks = append(result.Checks, tombstonesCheck) // Don't fail overall check for tombstone issues, just warn // Check 20: Untracked .beads/*.jsonl files (bd-pbj) untrackedCheck := checkUntrackedBeadsFiles(path) result.Checks = append(result.Checks, untrackedCheck) // Don't fail overall check for untracked files, just warn return result } // convertDoctorCheck converts doctor package check to main package check func convertDoctorCheck(dc doctor.DoctorCheck) doctorCheck { return doctorCheck{ Name: dc.Name, Status: dc.Status, Message: dc.Message, Detail: dc.Detail, Fix: dc.Fix, } } func checkInstallation(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") if _, err := os.Stat(beadsDir); os.IsNotExist(err) { // Auto-detect prefix from directory name prefix := filepath.Base(path) prefix = strings.TrimRight(prefix, "-") return doctorCheck{ Name: "Installation", Status: statusError, Message: "No .beads/ directory found", Fix: fmt.Sprintf("Run 'bd init --prefix %s' to initialize beads", prefix), } } return doctorCheck{ Name: "Installation", Status: statusOK, Message: ".beads/ directory found", } } func checkDatabaseVersion(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") // Check metadata.json first for custom database name var dbPath string if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { dbPath = cfg.DatabasePath(beadsDir) } else { // Fall back to canonical database name dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) } // Check if database file exists if _, err := os.Stat(dbPath); os.IsNotExist(err) { // Check if JSONL exists // Check canonical (issues.jsonl) first, then legacy (beads.jsonl) issuesJSONL := filepath.Join(beadsDir, "issues.jsonl") beadsJSONL := filepath.Join(beadsDir, "beads.jsonl") var jsonlPath string if _, err := os.Stat(issuesJSONL); err == nil { jsonlPath = issuesJSONL } else if _, err := os.Stat(beadsJSONL); err == nil { jsonlPath = beadsJSONL } if jsonlPath != "" { // JSONL exists but no database - check if this is no-db mode or fresh clone // Use proper YAML parsing to detect no-db mode (bd-r6k2) if isNoDbModeConfigured(beadsDir) { return doctorCheck{ Name: "Database", Status: statusOK, Message: "JSONL-only mode", Detail: "Using issues.jsonl (no SQLite database)", } } // This is a fresh clone - JSONL exists but no database and not no-db mode // Count issues and detect prefix for helpful suggestion issueCount := countIssuesInJSONLFile(jsonlPath) prefix := detectPrefixFromJSONL(jsonlPath) message := "Fresh clone detected (no database)" detail := fmt.Sprintf("Found %d issue(s) in JSONL that need to be imported", issueCount) fix := "Run 'bd init' to hydrate the database from JSONL" if prefix != "" { fix = fmt.Sprintf("Run 'bd init' to hydrate the database (detected prefix: %s)", prefix) } return doctorCheck{ Name: "Database", Status: statusWarning, Message: message, Detail: detail, Fix: fix, } } return doctorCheck{ Name: "Database", Status: statusError, Message: "No beads.db found", Fix: "Run 'bd init' to create database", } } // Get database version dbVersion := getDatabaseVersionFromPath(dbPath) if dbVersion == "unknown" { return doctorCheck{ Name: "Database", Status: statusError, Message: "Unable to read database version", Detail: "Storage: SQLite", Fix: "Database may be corrupted. Try 'bd migrate'", } } if dbVersion == "pre-0.17.5" { return doctorCheck{ Name: "Database", Status: statusWarning, Message: fmt.Sprintf("version %s (very old)", dbVersion), Detail: "Storage: SQLite", Fix: "Run 'bd migrate' to upgrade database schema", } } if dbVersion != Version { return doctorCheck{ Name: "Database", Status: statusWarning, Message: fmt.Sprintf("version %s (CLI: %s)", dbVersion, Version), Detail: "Storage: SQLite", Fix: "Run 'bd migrate' to sync database with CLI version", } } return doctorCheck{ Name: "Database", Status: statusOK, Message: fmt.Sprintf("version %s", dbVersion), Detail: "Storage: SQLite", } } func checkIDFormat(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") // Check metadata.json first for custom database name var dbPath string if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { dbPath = cfg.DatabasePath(beadsDir) } else { // Fall back to canonical database name dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) } // Check if using JSONL-only mode if _, err := os.Stat(dbPath); os.IsNotExist(err) { // Check if JSONL exists (--no-db mode) jsonlPath := filepath.Join(beadsDir, "issues.jsonl") if _, err := os.Stat(jsonlPath); err == nil { return doctorCheck{ Name: "Issue IDs", Status: statusOK, Message: "N/A (JSONL-only mode)", } } // No database and no JSONL return doctorCheck{ Name: "Issue IDs", Status: statusOK, Message: "No issues yet (will use hash-based IDs)", } } // Open database db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro") if err != nil { return doctorCheck{ Name: "Issue IDs", Status: statusError, Message: "Unable to open database", } } defer func() { _ = db.Close() }() // Intentionally ignore close error // Get sample of issues to check ID format (up to 10 for pattern analysis) rows, err := db.Query("SELECT id FROM issues ORDER BY created_at LIMIT 10") if err != nil { return doctorCheck{ Name: "Issue IDs", Status: statusError, Message: "Unable to query issues", } } defer rows.Close() var issueIDs []string for rows.Next() { var id string if err := rows.Scan(&id); err == nil { issueIDs = append(issueIDs, id) } } if len(issueIDs) == 0 { return doctorCheck{ Name: "Issue IDs", Status: statusOK, Message: "No issues yet (will use hash-based IDs)", } } // Detect ID format using robust heuristic if detectHashBasedIDs(db, issueIDs) { return doctorCheck{ Name: "Issue IDs", Status: statusOK, Message: "hash-based ✓", } } // Sequential IDs - recommend migration return doctorCheck{ Name: "Issue IDs", Status: statusWarning, Message: "sequential (e.g., bd-1, bd-2, ...)", Fix: "Run 'bd migrate --to-hash-ids' to upgrade (prevents ID collisions in multi-worker scenarios)", } } func checkCLIVersion() doctorCheck { latestVersion, err := fetchLatestGitHubRelease() if err != nil { // Network error or API issue - don't fail, just warn return doctorCheck{ Name: "CLI Version", Status: statusOK, Message: fmt.Sprintf("%s (unable to check for updates)", Version), } } if latestVersion == "" || latestVersion == Version { return doctorCheck{ Name: "CLI Version", Status: statusOK, Message: fmt.Sprintf("%s (latest)", Version), } } // Compare versions using simple semver-aware comparison if compareVersions(latestVersion, Version) > 0 { upgradeCmds := ` • Homebrew: brew upgrade bd • Script: curl -fsSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash • Windows (PowerShell): irm https://raw.githubusercontent.com/steveyegge/beads/main/install.ps1 | iex` return doctorCheck{ Name: "CLI Version", Status: statusWarning, Message: fmt.Sprintf("%s (latest: %s)", Version, latestVersion), Fix: fmt.Sprintf("Upgrade to latest version:\n%s", upgradeCmds), } } return doctorCheck{ Name: "CLI Version", Status: statusOK, Message: fmt.Sprintf("%s (latest)", Version), } } func getDatabaseVersionFromPath(dbPath string) string { db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro") if err != nil { return "unknown" } defer db.Close() // Try to read version from metadata table var version string err = db.QueryRow("SELECT value FROM metadata WHERE key = 'bd_version'").Scan(&version) if err == nil { return version } // Check if metadata table exists var tableName string err = db.QueryRow(` SELECT name FROM sqlite_master WHERE type='table' AND name='metadata' `).Scan(&tableName) if err == sql.ErrNoRows { return "pre-0.17.5" } return "unknown" } // detectHashBasedIDs uses multiple heuristics to determine if the database uses hash-based IDs. // This is more robust than checking a single ID's format, since base36 hash IDs can be all-numeric. func detectHashBasedIDs(db *sql.DB, sampleIDs []string) bool { // Heuristic 1: Check for child_counters table (added for hash ID support) var tableName string err := db.QueryRow(` SELECT name FROM sqlite_master WHERE type='table' AND name='child_counters' `).Scan(&tableName) if err == nil { // child_counters table exists - this is a strong indicator of hash IDs return true } // Heuristic 2: Check if any sample ID clearly contains letters (a-z) // Hash IDs use base36 (0-9, a-z), sequential IDs are purely numeric for _, id := range sampleIDs { if isHashID(id) { return true } } // Heuristic 3: Look for patterns that indicate hash IDs if len(sampleIDs) >= 2 { // Extract suffixes (part after prefix-) for analysis var suffixes []string for _, id := range sampleIDs { parts := strings.SplitN(id, "-", 2) if len(parts) == 2 { // Strip hierarchical suffix like .1 or .1.2 baseSuffix := strings.Split(parts[1], ".")[0] suffixes = append(suffixes, baseSuffix) } } if len(suffixes) >= 2 { // Check for variable lengths (strong indicator of adaptive hash IDs) // BUT: sequential IDs can also have variable length (1, 10, 100) // So we need to check if the length variation is natural (1→2→3 digits) // or random (3→8→4 chars typical of adaptive hash IDs) lengths := make(map[int]int) // length -> count for _, s := range suffixes { lengths[len(s)]++ } // If we have 3+ different lengths, likely hash IDs (adaptive length) // Sequential IDs typically have 1-2 lengths (e.g., 1-9, 10-99, 100-999) if len(lengths) >= 3 { return true } // Check for leading zeros (rare in sequential IDs, common in hash IDs) // Sequential IDs: bd-1, bd-2, bd-10, bd-100 // Hash IDs: bd-0088, bd-02a4, bd-05a1 hasLeadingZero := false for _, s := range suffixes { if len(s) > 1 && s[0] == '0' { hasLeadingZero = true break } } if hasLeadingZero { return true } // Check for non-sequential ordering // Try to parse as integers - if they're not sequential, likely hash IDs allNumeric := true var nums []int for _, s := range suffixes { var num int if _, err := fmt.Sscanf(s, "%d", &num); err == nil { nums = append(nums, num) } else { allNumeric = false break } } if allNumeric && len(nums) >= 2 { // Check if they form a roughly sequential pattern (1,2,3 or 10,11,12) // Hash IDs would be more random (e.g., 88, 13452, 676) isSequentialPattern := true for i := 1; i < len(nums); i++ { diff := nums[i] - nums[i-1] // Allow for some gaps (deleted issues), but should be mostly sequential if diff < 0 || diff > 100 { isSequentialPattern = false break } } // If the numbers are NOT sequential, they're likely hash IDs if !isSequentialPattern { return true } } } } // If we can't determine for sure, default to assuming sequential IDs // This is conservative - better to recommend migration than miss sequential IDs return false } // Note: isHashID is defined in migrate_hash_ids.go to avoid duplication // compareVersions compares two semantic version strings. // Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 // Handles versions like "0.20.1", "1.2.3", etc. func compareVersions(v1, v2 string) int { // Split versions into parts parts1 := strings.Split(v1, ".") parts2 := strings.Split(v2, ".") // Compare each part maxLen := len(parts1) if len(parts2) > maxLen { maxLen = len(parts2) } for i := 0; i < maxLen; i++ { var p1, p2 int // Get part value or default to 0 if part doesn't exist if i < len(parts1) { _, _ = fmt.Sscanf(parts1[i], "%d", &p1) } if i < len(parts2) { _, _ = fmt.Sscanf(parts2[i], "%d", &p2) } if p1 < p2 { return -1 } if p1 > p2 { return 1 } } return 0 } func fetchLatestGitHubRelease() (string, error) { url := "https://api.github.com/repos/steveyegge/beads/releases/latest" client := &http.Client{ Timeout: 5 * time.Second, } req, err := http.NewRequest("GET", url, nil) if err != nil { return "", err } // Set User-Agent as required by GitHub API req.Header.Set("User-Agent", "beads-cli-doctor") resp, err := client.Do(req) if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("github api returned status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", err } var release struct { TagName string `json:"tag_name"` } if err := json.Unmarshal(body, &release); err != nil { return "", err } // Strip 'v' prefix if present version := strings.TrimPrefix(release.TagName, "v") return version, nil } // exportDiagnostics writes the doctor result to a JSON file (bd-9cc) func exportDiagnostics(result doctorResult, outputPath string) error { // #nosec G304 - outputPath is a user-provided flag value for file generation f, err := os.Create(outputPath) if err != nil { return fmt.Errorf("failed to create output file: %w", err) } defer f.Close() encoder := json.NewEncoder(f) encoder.SetIndent("", " ") if err := encoder.Encode(result); err != nil { return fmt.Errorf("failed to write JSON: %w", err) } return nil } func printDiagnostics(result doctorResult) { // Print header fmt.Println("\nDiagnostics") // Print each check with tree formatting for i, check := range result.Checks { // Determine prefix prefix := "├" if i == len(result.Checks)-1 { prefix = "└" } // Format status indicator var statusIcon string switch check.Status { case statusOK: statusIcon = "" case statusWarning: statusIcon = color.YellowString(" ⚠") case statusError: statusIcon = color.RedString(" ✗") } // Print main check line fmt.Printf(" %s %s: %s%s\n", prefix, check.Name, check.Message, statusIcon) // Print detail if present (indented under the check) if check.Detail != "" { detailPrefix := "│" if i == len(result.Checks)-1 { detailPrefix = " " } fmt.Printf(" %s %s\n", detailPrefix, color.New(color.Faint).Sprint(check.Detail)) } } fmt.Println() // Print warnings/errors with fixes hasIssues := false for _, check := range result.Checks { if check.Status != statusOK && check.Fix != "" { if !hasIssues { hasIssues = true } switch check.Status { case statusWarning: color.Yellow("⚠ Warning: %s\n", check.Message) case statusError: color.Red("✗ Error: %s\n", check.Message) } fmt.Printf(" Fix: %s\n\n", check.Fix) } } if !hasIssues { color.Green("✓ All checks passed\n") } } func checkMultipleDatabases(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") // Find all .db files (excluding backups and vc.db) files, err := filepath.Glob(filepath.Join(beadsDir, "*.db")) if err != nil { return doctorCheck{ Name: "Database Files", Status: statusError, Message: "Unable to check for multiple databases", } } // Filter out backups and vc.db var dbFiles []string for _, f := range files { base := filepath.Base(f) if !strings.HasSuffix(base, ".backup.db") && base != "vc.db" { dbFiles = append(dbFiles, base) } } if len(dbFiles) == 0 { return doctorCheck{ Name: "Database Files", Status: statusOK, Message: "No database files (JSONL-only mode)", } } if len(dbFiles) == 1 { return doctorCheck{ Name: "Database Files", Status: statusOK, Message: "Single database file", } } // Multiple databases found return doctorCheck{ Name: "Database Files", Status: statusWarning, Message: fmt.Sprintf("Multiple database files found: %s", strings.Join(dbFiles, ", ")), Fix: "Run 'bd migrate' to consolidate databases or manually remove old .db files", } } func checkDaemonStatus(path string) doctorCheck { // Normalize path for reliable comparison (handles symlinks) wsNorm, err := filepath.EvalSymlinks(path) if err != nil { // Fallback to absolute path if EvalSymlinks fails wsNorm, _ = filepath.Abs(path) } // Use global daemon discovery (registry-based) daemons, err := daemon.DiscoverDaemons(nil) if err != nil { return doctorCheck{ Name: "Daemon Health", Status: statusWarning, Message: "Unable to check daemon health", Detail: err.Error(), } } // Filter to this workspace using normalized paths var workspaceDaemons []daemon.DaemonInfo for _, d := range daemons { dPath, err := filepath.EvalSymlinks(d.WorkspacePath) if err != nil { dPath, _ = filepath.Abs(d.WorkspacePath) } if dPath == wsNorm { workspaceDaemons = append(workspaceDaemons, d) } } // Check for stale socket directly (catches cases where RPC failed so WorkspacePath is empty) beadsDir := filepath.Join(path, ".beads") socketPath := filepath.Join(beadsDir, "bd.sock") if _, err := os.Stat(socketPath); err == nil { // Socket exists - try to connect if len(workspaceDaemons) == 0 { // Socket exists but no daemon found in registry - likely stale return doctorCheck{ Name: "Daemon Health", Status: statusWarning, Message: "Stale daemon socket detected", Detail: fmt.Sprintf("Socket exists at %s but daemon is not responding", socketPath), Fix: "Run 'bd daemons killall' to clean up stale sockets", } } } if len(workspaceDaemons) == 0 { return doctorCheck{ Name: "Daemon Health", Status: statusOK, Message: "No daemon running (will auto-start on next command)", } } // Warn if multiple daemons for same workspace if len(workspaceDaemons) > 1 { return doctorCheck{ Name: "Daemon Health", Status: statusWarning, Message: fmt.Sprintf("Multiple daemons detected for this workspace (%d)", len(workspaceDaemons)), Fix: "Run 'bd daemons killall' to clean up duplicate daemons", } } // Check for stale or version mismatched daemons for _, d := range workspaceDaemons { if !d.Alive { return doctorCheck{ Name: "Daemon Health", Status: statusWarning, Message: "Stale daemon detected", Detail: fmt.Sprintf("PID %d is not alive", d.PID), Fix: "Run 'bd daemons killall' to clean up stale daemons", } } if d.Version != Version { return doctorCheck{ Name: "Daemon Health", Status: statusWarning, Message: fmt.Sprintf("Version mismatch (daemon: %s, CLI: %s)", d.Version, Version), Fix: "Run 'bd daemons killall' to restart daemons with current version", } } } return doctorCheck{ Name: "Daemon Health", Status: statusOK, Message: fmt.Sprintf("Daemon running (PID %d, version %s)", workspaceDaemons[0].PID, workspaceDaemons[0].Version), } } func checkDatabaseJSONLSync(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) // Find JSONL file var jsonlPath string for _, name := range []string{"issues.jsonl", "beads.jsonl"} { testPath := filepath.Join(beadsDir, name) if _, err := os.Stat(testPath); err == nil { jsonlPath = testPath break } } // If no database, skip this check if _, err := os.Stat(dbPath); os.IsNotExist(err) { return doctorCheck{ Name: "DB-JSONL Sync", Status: statusOK, Message: "N/A (no database)", } } // If no JSONL, skip this check if jsonlPath == "" { return doctorCheck{ Name: "DB-JSONL Sync", Status: statusOK, Message: "N/A (no JSONL file)", } } // Try to read JSONL first (doesn't depend on database) jsonlCount, jsonlPrefixes, jsonlErr := countJSONLIssues(jsonlPath) // Single database open for all queries (instead of 3 separate opens) db, err := sql.Open("sqlite3", dbPath) if err != nil { // Database can't be opened. If JSONL has issues, suggest recovery. if jsonlErr == nil && jsonlCount > 0 { return doctorCheck{ Name: "DB-JSONL Sync", Status: statusWarning, Message: fmt.Sprintf("Database cannot be opened but JSONL contains %d issues", jsonlCount), Detail: err.Error(), Fix: fmt.Sprintf("Run 'bd import -i %s --rename-on-import' to recover issues from JSONL", filepath.Base(jsonlPath)), } } return doctorCheck{ Name: "DB-JSONL Sync", Status: statusWarning, Message: "Unable to open database", Detail: err.Error(), } } defer db.Close() // Get database count var dbCount int err = db.QueryRow("SELECT COUNT(*) FROM issues").Scan(&dbCount) if err != nil { // Database opened but can't query. If JSONL has issues, suggest recovery. if jsonlErr == nil && jsonlCount > 0 { return doctorCheck{ Name: "DB-JSONL Sync", Status: statusWarning, Message: fmt.Sprintf("Database cannot be queried but JSONL contains %d issues", jsonlCount), Detail: err.Error(), Fix: fmt.Sprintf("Run 'bd import -i %s --rename-on-import' to recover issues from JSONL", filepath.Base(jsonlPath)), } } return doctorCheck{ Name: "DB-JSONL Sync", Status: statusWarning, Message: "Unable to query database", Detail: err.Error(), } } // Get database prefix var dbPrefix string err = db.QueryRow("SELECT value FROM config WHERE key = ?", "issue_prefix").Scan(&dbPrefix) if err != nil && err != sql.ErrNoRows { return doctorCheck{ Name: "DB-JSONL Sync", Status: statusWarning, Message: "Unable to read database prefix", Detail: err.Error(), } } // Use JSONL error if we got it earlier if jsonlErr != nil { return doctorCheck{ Name: "DB-JSONL Sync", Status: statusWarning, Message: "Unable to read JSONL file", Detail: jsonlErr.Error(), } } // Check for issues var issues []string // Count mismatch if dbCount != jsonlCount { issues = append(issues, fmt.Sprintf("Count mismatch: database has %d issues, JSONL has %d", dbCount, jsonlCount)) } // Prefix mismatch (only check most common prefix in JSONL) if dbPrefix != "" && len(jsonlPrefixes) > 0 { var mostCommonPrefix string maxCount := 0 for prefix, count := range jsonlPrefixes { if count > maxCount { maxCount = count mostCommonPrefix = prefix } } // Only warn if majority of issues have wrong prefix if mostCommonPrefix != dbPrefix && maxCount > jsonlCount/2 { issues = append(issues, fmt.Sprintf("Prefix mismatch: database uses %q but most JSONL issues use %q", dbPrefix, mostCommonPrefix)) } } // If we found issues, report them if len(issues) > 0 { return doctorCheck{ Name: "DB-JSONL Sync", Status: statusWarning, Message: strings.Join(issues, "; "), Fix: "Run 'bd sync --import-only' to import JSONL updates or 'bd import -i issues.jsonl --rename-on-import' to fix prefixes", } } // Check modification times (only if counts match) dbInfo, err := os.Stat(dbPath) if err != nil { return doctorCheck{ Name: "DB-JSONL Sync", Status: statusWarning, Message: "Unable to check database file", } } jsonlInfo, err := os.Stat(jsonlPath) if err != nil { return doctorCheck{ Name: "DB-JSONL Sync", Status: statusWarning, Message: "Unable to check JSONL file", } } if jsonlInfo.ModTime().After(dbInfo.ModTime()) { timeDiff := jsonlInfo.ModTime().Sub(dbInfo.ModTime()) if timeDiff > 30*time.Second { return doctorCheck{ Name: "DB-JSONL Sync", Status: statusWarning, Message: "JSONL is newer than database", Fix: "Run 'bd sync --import-only' to import JSONL updates", } } } return doctorCheck{ Name: "DB-JSONL Sync", Status: statusOK, Message: "Database and JSONL are in sync", } } // countJSONLIssues counts issues in the JSONL file and returns the count, prefixes, and any error. func countJSONLIssues(jsonlPath string) (int, map[string]int, error) { // jsonlPath is safe: constructed from filepath.Join(beadsDir, hardcoded name) file, err := os.Open(jsonlPath) //nolint:gosec if err != nil { return 0, nil, fmt.Errorf("failed to open JSONL file: %w", err) } defer file.Close() count := 0 prefixes := make(map[string]int) errorCount := 0 scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 { continue } // Parse JSON to get the ID var issue map[string]interface{} if err := json.Unmarshal(line, &issue); err != nil { errorCount++ continue } if id, ok := issue["id"].(string); ok { count++ // Extract prefix (everything before the last dash) lastDash := strings.LastIndex(id, "-") if lastDash != -1 { prefixes[id[:lastDash]]++ } else { prefixes[id]++ } } } if err := scanner.Err(); err != nil { return count, prefixes, fmt.Errorf("failed to read JSONL file: %w", err) } if errorCount > 0 { return count, prefixes, fmt.Errorf("skipped %d malformed lines in JSONL", errorCount) } return count, prefixes, nil } // countIssuesInJSONLFile counts the number of valid issues in a JSONL file. // This is a wrapper around countJSONLIssues that returns only the count. func countIssuesInJSONLFile(jsonlPath string) int { count, _, _ := countJSONLIssues(jsonlPath) return count } func checkPermissions(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") // Check if .beads/ is writable testFile := filepath.Join(beadsDir, ".doctor-test-write") if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil { return doctorCheck{ Name: "Permissions", Status: statusError, Message: ".beads/ directory is not writable", Fix: fmt.Sprintf("Fix permissions: chmod u+w %s", beadsDir), } } _ = os.Remove(testFile) // Clean up test file (intentionally ignore error) // Check database permissions dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) if _, err := os.Stat(dbPath); err == nil { // Try to open database db, err := sql.Open("sqlite3", dbPath) if err != nil { return doctorCheck{ Name: "Permissions", Status: statusError, Message: "Database file exists but cannot be opened", Fix: fmt.Sprintf("Check database permissions: %s", dbPath), } } _ = db.Close() // Intentionally ignore close error // Try a write test db, err = sql.Open("sqlite", dbPath) if err == nil { _, err = db.Exec("SELECT 1") _ = db.Close() // Intentionally ignore close error if err != nil { return doctorCheck{ Name: "Permissions", Status: statusError, Message: "Database file is not readable", Fix: fmt.Sprintf("Fix permissions: chmod u+rw %s", dbPath), } } } } return doctorCheck{ Name: "Permissions", Status: statusOK, Message: "All permissions OK", } } func checkDependencyCycles(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) // If no database, skip this check if _, err := os.Stat(dbPath); os.IsNotExist(err) { return doctorCheck{ Name: "Dependency Cycles", Status: statusOK, Message: "N/A (no database)", } } // Open database to check for cycles db, err := sql.Open("sqlite3", dbPath) if err != nil { return doctorCheck{ Name: "Dependency Cycles", Status: statusWarning, Message: "Unable to open database", Detail: err.Error(), } } defer db.Close() // Query for cycles using simplified SQL query := ` WITH RECURSIVE paths AS ( SELECT issue_id, depends_on_id, issue_id as start_id, issue_id || '→' || depends_on_id as path, 0 as depth FROM dependencies UNION ALL SELECT d.issue_id, d.depends_on_id, p.start_id, p.path || '→' || d.depends_on_id, p.depth + 1 FROM dependencies d JOIN paths p ON d.issue_id = p.depends_on_id WHERE p.depth < 100 AND p.path NOT LIKE '%' || d.depends_on_id || '→%' ) SELECT DISTINCT start_id FROM paths WHERE depends_on_id = start_id` rows, err := db.Query(query) if err != nil { return doctorCheck{ Name: "Dependency Cycles", Status: statusWarning, Message: "Unable to check for cycles", Detail: err.Error(), } } defer rows.Close() cycleCount := 0 var firstCycle string for rows.Next() { var startID string if err := rows.Scan(&startID); err != nil { continue } cycleCount++ if cycleCount == 1 { firstCycle = startID } } if cycleCount == 0 { return doctorCheck{ Name: "Dependency Cycles", Status: statusOK, Message: "No circular dependencies detected", } } return doctorCheck{ Name: "Dependency Cycles", Status: statusError, Message: fmt.Sprintf("Found %d circular dependency cycle(s)", cycleCount), Detail: fmt.Sprintf("First cycle involves: %s", firstCycle), Fix: "Run 'bd dep cycles' to see full cycle paths, then 'bd dep remove' to break cycles", } } func checkGitHooks() doctorCheck { // Check if we're in a git repository using worktree-aware detection gitDir, err := git.GetGitDir() if err != nil { return doctorCheck{ Name: "Git Hooks", Status: statusOK, Message: "N/A (not a git repository)", } } // Recommended hooks and their purposes recommendedHooks := map[string]string{ "pre-commit": "Flushes pending bd changes to JSONL before commit", "post-merge": "Imports updated JSONL after git pull/merge", "pre-push": "Exports database to JSONL before push", } hooksDir := filepath.Join(gitDir, "hooks") var missingHooks []string var installedHooks []string for hookName := range recommendedHooks { hookPath := filepath.Join(hooksDir, hookName) if _, err := os.Stat(hookPath); os.IsNotExist(err) { missingHooks = append(missingHooks, hookName) } else { installedHooks = append(installedHooks, hookName) } } if len(missingHooks) == 0 { return doctorCheck{ Name: "Git Hooks", Status: statusOK, Message: "All recommended hooks installed", Detail: fmt.Sprintf("Installed: %s", strings.Join(installedHooks, ", ")), } } hookInstallMsg := "Install hooks with 'bd hooks install'. See https://github.com/steveyegge/beads/tree/main/examples/git-hooks for installation instructions" if len(installedHooks) > 0 { return doctorCheck{ Name: "Git Hooks", Status: statusWarning, Message: fmt.Sprintf("Missing %d recommended hook(s)", len(missingHooks)), Detail: fmt.Sprintf("Missing: %s", strings.Join(missingHooks, ", ")), Fix: hookInstallMsg, } } return doctorCheck{ Name: "Git Hooks", Status: statusWarning, Message: "No recommended git hooks installed", Detail: fmt.Sprintf("Recommended: %s", strings.Join([]string{"pre-commit", "post-merge", "pre-push"}, ", ")), Fix: hookInstallMsg, } } // checkSyncBranchHookCompatibility checks if pre-push hook is compatible with sync-branch mode. // When sync-branch is configured, the pre-push hook must have the sync-branch bypass logic // (added in version 0.29.0). Without it, users experience circular "bd sync" failures (issue #532). // (bd-e0o7: refactored to use extracted helpers) func checkSyncBranchHookCompatibility(path string) doctorCheck { // Check if sync-branch is configured syncBranch := syncbranch.GetFromYAML() if syncBranch == "" { return doctorCheck{ Name: "Sync Branch Hook Compatibility", Status: statusOK, Message: "N/A (sync-branch not configured)", } } // Get pre-push hook path using common helper hookPath, err := getPrePushHookPath(path) if err != nil { return doctorCheck{ Name: "Sync Branch Hook Compatibility", Status: statusOK, Message: "N/A (not a git repository)", } } hookContent, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled if err != nil { // No pre-push hook installed - different issue, covered by checkGitHooks return doctorCheck{ Name: "Sync Branch Hook Compatibility", Status: statusOK, Message: "N/A (no pre-push hook installed)", } } // Extract version using common helper hookVersion := extractBdHookVersion(string(hookContent)) // Not a bd hook - this is intentionally a Warning (vs OK in quick check) // because the full doctor check wants to alert users to potential issues // with custom hooks that may not be sync-branch compatible if !strings.Contains(string(hookContent), "bd-hooks-version:") { return doctorCheck{ Name: "Sync Branch Hook Compatibility", Status: statusWarning, Message: "Pre-push hook is not a bd hook", Detail: "Cannot verify sync-branch compatibility with custom hooks", } } if hookVersion == "" { return doctorCheck{ Name: "Sync Branch Hook Compatibility", Status: statusWarning, Message: "Could not determine pre-push hook version", Detail: "Cannot verify sync-branch compatibility", Fix: "Run 'bd hooks install --force' to update hooks", } } // minSyncBranchHookVersion added sync-branch bypass logic // If hook version < minSyncBranchHookVersion, it will cause circular "bd sync" failures if compareVersions(hookVersion, minSyncBranchHookVersion) < 0 { return doctorCheck{ Name: "Sync Branch Hook Compatibility", Status: statusError, Message: fmt.Sprintf("Pre-push hook incompatible with sync-branch mode (version %s)", hookVersion), Detail: fmt.Sprintf("Hook version %s lacks sync-branch bypass (requires %s+). This causes circular 'bd sync' failures during push.", hookVersion, minSyncBranchHookVersion), Fix: "Run 'bd hooks install --force' to update hooks", } } return doctorCheck{ Name: "Sync Branch Hook Compatibility", Status: statusOK, Message: fmt.Sprintf("Pre-push hook compatible with sync-branch (version %s)", hookVersion), } } func checkSchemaCompatibility(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") // Check metadata.json first for custom database name var dbPath string if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { dbPath = cfg.DatabasePath(beadsDir) } else { // Fall back to canonical database name dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) } // If no database, skip this check if _, err := os.Stat(dbPath); os.IsNotExist(err) { return doctorCheck{ Name: "Schema Compatibility", Status: statusOK, Message: "N/A (no database)", } } // Open database (bd-ckvw: This will run migrations and schema probe) // Note: We can't use the global 'store' because doctor can check arbitrary paths db, err := sql.Open("sqlite3", "file:"+dbPath+"?_pragma=foreign_keys(ON)&_pragma=busy_timeout(30000)") if err != nil { return doctorCheck{ Name: "Schema Compatibility", Status: statusError, Message: "Failed to open database", Detail: err.Error(), Fix: "Database may be corrupted. Try 'bd migrate' or restore from backup", } } defer db.Close() // Run schema probe (defined in internal/storage/sqlite/schema_probe.go) // This is a simplified version since we can't import the internal package directly // Check all critical tables and columns criticalChecks := map[string][]string{ "issues": {"id", "title", "content_hash", "external_ref", "compacted_at", "close_reason"}, "dependencies": {"issue_id", "depends_on_id", "type"}, "child_counters": {"parent_id", "last_child"}, "export_hashes": {"issue_id", "content_hash"}, } var missingElements []string for table, columns := range criticalChecks { // Try to query all columns query := fmt.Sprintf( "SELECT %s FROM %s LIMIT 0", strings.Join(columns, ", "), table, ) // #nosec G201 -- table/column names sourced from hardcoded map _, err := db.Exec(query) if err != nil { errMsg := err.Error() if strings.Contains(errMsg, "no such table") { missingElements = append(missingElements, fmt.Sprintf("table:%s", table)) } else if strings.Contains(errMsg, "no such column") { // Find which columns are missing for _, col := range columns { colQuery := fmt.Sprintf("SELECT %s FROM %s LIMIT 0", col, table) // #nosec G201 -- names come from static schema definition if _, colErr := db.Exec(colQuery); colErr != nil && strings.Contains(colErr.Error(), "no such column") { missingElements = append(missingElements, fmt.Sprintf("%s.%s", table, col)) } } } } } if len(missingElements) > 0 { return doctorCheck{ Name: "Schema Compatibility", Status: statusError, Message: "Database schema is incomplete or incompatible", Detail: fmt.Sprintf("Missing: %s", strings.Join(missingElements, ", ")), Fix: "Run 'bd migrate' to upgrade schema, or if daemon is running an old version, run 'bd daemons killall' to restart", } } return doctorCheck{ Name: "Schema Compatibility", Status: statusOK, Message: "All required tables and columns present", } } // checkDatabaseIntegrity runs SQLite's PRAGMA integrity_check (bd-2au) func checkDatabaseIntegrity(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") // Get database path (same logic as checkSchemaCompatibility) var dbPath string if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" { dbPath = cfg.DatabasePath(beadsDir) } else { dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName) } // If no database, skip this check if _, err := os.Stat(dbPath); os.IsNotExist(err) { return doctorCheck{ Name: "Database Integrity", Status: statusOK, Message: "N/A (no database)", } } // Open database in read-only mode for integrity check db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro&_pragma=busy_timeout(30000)") if err != nil { return doctorCheck{ Name: "Database Integrity", Status: statusError, Message: "Failed to open database for integrity check", Detail: err.Error(), } } defer db.Close() // Run PRAGMA integrity_check // This checks the entire database for corruption rows, err := db.Query("PRAGMA integrity_check") if err != nil { return doctorCheck{ Name: "Database Integrity", Status: statusError, Message: "Failed to run integrity check", Detail: err.Error(), } } defer rows.Close() var results []string for rows.Next() { var result string if err := rows.Scan(&result); err != nil { continue } results = append(results, result) } // "ok" means no corruption detected if len(results) == 1 && results[0] == "ok" { return doctorCheck{ Name: "Database Integrity", Status: statusOK, Message: "No corruption detected", } } // Any other result indicates corruption return doctorCheck{ Name: "Database Integrity", Status: statusError, Message: "Database corruption detected", Detail: strings.Join(results, "; "), Fix: "Database may need recovery. Export with 'bd export' if possible, then restore from backup or reinitialize", } } func checkMergeDriver(path string) doctorCheck { // Check if we're in a git repository using worktree-aware detection _, err := git.GetGitDir() if err != nil { return doctorCheck{ Name: "Git Merge Driver", Status: statusOK, Message: "N/A (not a git repository)", } } // Get current merge driver configuration cmd := exec.Command("git", "config", "merge.beads.driver") cmd.Dir = path output, err := cmd.Output() if err != nil { // Merge driver not configured return doctorCheck{ Name: "Git Merge Driver", Status: statusWarning, Message: "Git merge driver not configured", Fix: "Run 'bd init' to configure the merge driver, or manually: git config merge.beads.driver \"bd merge %A %O %A %B\"", } } currentConfig := strings.TrimSpace(string(output)) correctConfig := "bd merge %A %O %A %B" // Check if using old incorrect placeholders if strings.Contains(currentConfig, "%L") || strings.Contains(currentConfig, "%R") { return doctorCheck{ Name: "Git Merge Driver", Status: statusError, Message: fmt.Sprintf("Incorrect merge driver config: %q (uses invalid %%L/%%R placeholders)", currentConfig), Detail: "Git only supports %O (base), %A (current), %B (other). Using %L/%R causes merge failures.", Fix: "Run 'bd doctor --fix' to update to correct config, or manually: git config merge.beads.driver \"bd merge %A %O %A %B\"", } } // Check if config is correct if currentConfig != correctConfig { return doctorCheck{ Name: "Git Merge Driver", Status: statusWarning, Message: fmt.Sprintf("Non-standard merge driver config: %q", currentConfig), Detail: fmt.Sprintf("Expected: %q", correctConfig), Fix: fmt.Sprintf("Run 'bd doctor --fix' to update config, or manually: git config merge.beads.driver \"%s\"", correctConfig), } } return doctorCheck{ Name: "Git Merge Driver", Status: statusOK, Message: "Correctly configured", Detail: currentConfig, } } func checkMetadataVersionTracking(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") // Load metadata.json cfg, err := configfile.Load(beadsDir) if err != nil { return doctorCheck{ Name: "Metadata Version Tracking", Status: statusError, Message: "Unable to read metadata.json", Detail: err.Error(), Fix: "Ensure metadata.json exists and is valid JSON. Run 'bd init' if needed.", } } // Check if metadata.json exists if cfg == nil { return doctorCheck{ Name: "Metadata Version Tracking", Status: statusWarning, Message: "metadata.json not found", Fix: "Run any bd command to create metadata.json with version tracking", } } // Check if LastBdVersion field is present if cfg.LastBdVersion == "" { return doctorCheck{ Name: "Metadata Version Tracking", Status: statusWarning, Message: "LastBdVersion field is empty (first run)", Detail: "Version tracking will be initialized on next command", Fix: "Run any bd command to initialize version tracking", } } // Validate that LastBdVersion is a valid semver-like string // Simple validation: should be X.Y.Z format where X, Y, Z are numbers if !isValidSemver(cfg.LastBdVersion) { return doctorCheck{ Name: "Metadata Version Tracking", Status: statusWarning, Message: fmt.Sprintf("LastBdVersion has invalid format: %q", cfg.LastBdVersion), Detail: "Expected semver format like '0.24.2'", Fix: "Run any bd command to reset version tracking to current version", } } // Check if LastBdVersion is very old (> 10 versions behind) // Calculate version distance versionDiff := compareVersions(Version, cfg.LastBdVersion) if versionDiff > 0 { // Current version is newer - check how far behind currentParts := parseVersionParts(Version) lastParts := parseVersionParts(cfg.LastBdVersion) // Simple heuristic: warn if minor version is 10+ behind or major version differs by 1+ majorDiff := currentParts[0] - lastParts[0] minorDiff := currentParts[1] - lastParts[1] if majorDiff >= 1 || (majorDiff == 0 && minorDiff >= 10) { return doctorCheck{ Name: "Metadata Version Tracking", Status: statusWarning, Message: fmt.Sprintf("LastBdVersion is very old: %s (current: %s)", cfg.LastBdVersion, Version), Detail: "You may have missed important upgrade notifications", Fix: "Run 'bd upgrade review' to see recent changes", } } // Version is behind but not too old return doctorCheck{ Name: "Metadata Version Tracking", Status: statusOK, Message: fmt.Sprintf("Version tracking active (last: %s, current: %s)", cfg.LastBdVersion, Version), } } // Version is current or ahead (shouldn't happen, but handle it) return doctorCheck{ Name: "Metadata Version Tracking", Status: statusOK, Message: fmt.Sprintf("Version tracking active (version: %s)", cfg.LastBdVersion), } } // isValidSemver checks if a version string is valid semver-like format (X.Y.Z) func isValidSemver(version string) bool { if version == "" { return false } // Split by dots and ensure all parts are numeric versionParts := strings.Split(version, ".") if len(versionParts) < 1 { return false } // Parse each part to ensure it's a valid number for _, part := range versionParts { if part == "" { return false } var num int if _, err := fmt.Sscanf(part, "%d", &num); err != nil { return false } if num < 0 { return false } } return true } // parseVersionParts parses version string into numeric parts // Returns [major, minor, patch, ...] or empty slice on error func parseVersionParts(version string) []int { parts := strings.Split(version, ".") result := make([]int, 0, len(parts)) for _, part := range parts { var num int if _, err := fmt.Sscanf(part, "%d", &num); err != nil { return result } result = append(result, num) } return result } func checkSyncBranchConfig(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") // Skip if .beads doesn't exist if _, err := os.Stat(beadsDir); os.IsNotExist(err) { return doctorCheck{ Name: "Sync Branch Config", Status: statusOK, Message: "N/A (no .beads directory)", } } // Check if we're in a git repository using worktree-aware detection _, err := git.GetGitDir() if err != nil { return doctorCheck{ Name: "Sync Branch Config", Status: statusOK, Message: "N/A (not a git repository)", } } // Check sync-branch from config.yaml or environment variable // This is the source of truth for multi-clone setups syncBranch := syncbranch.GetFromYAML() // Get current branch currentBranch := "" cmd := exec.Command("git", "symbolic-ref", "--short", "HEAD") cmd.Dir = path if output, err := cmd.Output(); err == nil { currentBranch = strings.TrimSpace(string(output)) } // GH#519: Check if we're on the sync branch - this is supported but worth noting // bd sync will commit directly instead of using worktree when on sync branch if syncBranch != "" && currentBranch == syncBranch { return doctorCheck{ Name: "Sync Branch Config", Status: statusOK, Message: fmt.Sprintf("On sync branch '%s' (direct mode)", syncBranch), Detail: fmt.Sprintf("Currently on sync branch '%s'. bd sync will commit directly instead of using worktree.", syncBranch), } } if syncBranch != "" { return doctorCheck{ Name: "Sync Branch Config", Status: statusOK, Message: fmt.Sprintf("Configured (%s)", syncBranch), Detail: fmt.Sprintf("Current branch: %s, sync branch: %s", currentBranch, syncBranch), } } // Not configured - this is optional but recommended for multi-clone setups // Check if this looks like a multi-clone setup (has remote) hasRemote := false cmd = exec.Command("git", "remote") cmd.Dir = path if output, err := cmd.Output(); err == nil && len(strings.TrimSpace(string(output))) > 0 { hasRemote = true } if hasRemote { return doctorCheck{ Name: "Sync Branch Config", Status: statusWarning, Message: "sync-branch not configured", Detail: "Multi-clone setups should configure sync-branch in config.yaml", Fix: "Add 'sync-branch: beads-sync' to .beads/config.yaml", } } // No remote - probably a local-only repo, sync-branch not needed return doctorCheck{ Name: "Sync Branch Config", Status: statusOK, Message: "N/A (no remote configured)", } } // checkSyncBranchHealth detects when the sync branch has diverged from main // or from the remote sync branch (after a force-push reset). // bd-6rf: Detect and fix stale beads-sync branch func checkSyncBranchHealth(path string) doctorCheck { // Skip if not in a git repo using worktree-aware detection _, err := git.GetGitDir() if err != nil { return doctorCheck{ Name: "Sync Branch Health", Status: statusOK, Message: "N/A (not a git repository)", } } // Get configured sync branch syncBranch := syncbranch.GetFromYAML() if syncBranch == "" { return doctorCheck{ Name: "Sync Branch Health", Status: statusOK, Message: "N/A (no sync branch configured)", } } // Check if local sync branch exists cmd := exec.Command("git", "rev-parse", "--verify", syncBranch) cmd.Dir = path if err := cmd.Run(); err != nil { // Local branch doesn't exist - that's fine, bd sync will create it return doctorCheck{ Name: "Sync Branch Health", Status: statusOK, Message: fmt.Sprintf("N/A (local %s branch not created yet)", syncBranch), } } // Check if remote sync branch exists remote := "origin" remoteBranch := fmt.Sprintf("%s/%s", remote, syncBranch) cmd = exec.Command("git", "rev-parse", "--verify", remoteBranch) cmd.Dir = path if err := cmd.Run(); err != nil { // Remote branch doesn't exist - that's fine return doctorCheck{ Name: "Sync Branch Health", Status: statusOK, Message: fmt.Sprintf("N/A (remote %s not found)", remoteBranch), } } // Check 1: Is local sync branch diverged from remote? (after force-push) // If they have no common ancestor in recent history, the remote was likely force-pushed cmd = exec.Command("git", "merge-base", syncBranch, remoteBranch) cmd.Dir = path mergeBaseOutput, err := cmd.Output() if err != nil { // No common ancestor - branches have completely diverged return doctorCheck{ Name: "Sync Branch Health", Status: statusWarning, Message: fmt.Sprintf("Local %s diverged from remote", syncBranch), Detail: "The remote sync branch was likely reset/force-pushed. Your local branch has orphaned history.", Fix: fmt.Sprintf("Reset local branch: git branch -D %s (it will be recreated on next bd sync)", syncBranch), } } // Check if local is behind remote (needs to fast-forward) mergeBase := strings.TrimSpace(string(mergeBaseOutput)) cmd = exec.Command("git", "rev-parse", syncBranch) cmd.Dir = path localHead, _ := cmd.Output() localHeadStr := strings.TrimSpace(string(localHead)) cmd = exec.Command("git", "rev-parse", remoteBranch) cmd.Dir = path remoteHead, _ := cmd.Output() remoteHeadStr := strings.TrimSpace(string(remoteHead)) // If merge base equals local but not remote, local is behind if mergeBase == localHeadStr && mergeBase != remoteHeadStr { // Count how far behind cmd = exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..%s", syncBranch, remoteBranch)) cmd.Dir = path countOutput, _ := cmd.Output() behindCount := strings.TrimSpace(string(countOutput)) return doctorCheck{ Name: "Sync Branch Health", Status: statusOK, Message: fmt.Sprintf("Local %s is %s commits behind remote (will sync)", syncBranch, behindCount), } } // Check 2: Is sync branch far behind main on source files? // Get the main branch name mainBranch := "main" cmd = exec.Command("git", "rev-parse", "--verify", "main") cmd.Dir = path if err := cmd.Run(); err != nil { // Try "master" as fallback cmd = exec.Command("git", "rev-parse", "--verify", "master") cmd.Dir = path if err := cmd.Run(); err != nil { // Can't determine main branch return doctorCheck{ Name: "Sync Branch Health", Status: statusOK, Message: "OK", } } mainBranch = "master" } // Count commits main is ahead of sync branch cmd = exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..%s", syncBranch, mainBranch)) cmd.Dir = path aheadOutput, err := cmd.Output() if err != nil { return doctorCheck{ Name: "Sync Branch Health", Status: statusOK, Message: "OK", } } aheadCount := strings.TrimSpace(string(aheadOutput)) // Check if there are non-.beads/ file differences (stale source code) cmd = exec.Command("git", "diff", "--name-only", fmt.Sprintf("%s..%s", syncBranch, mainBranch), "--", ":(exclude).beads/") cmd.Dir = path diffOutput, _ := cmd.Output() diffFiles := strings.TrimSpace(string(diffOutput)) if diffFiles != "" && aheadCount != "0" { // Count the number of different files fileCount := len(strings.Split(diffFiles, "\n")) // Parse ahead count as int for comparison aheadCountInt := 0 _, _ = fmt.Sscanf(aheadCount, "%d", &aheadCountInt) // Only warn if significantly behind (20+ commits AND 50+ source files) // Small drift is normal between bd sync operations if fileCount > 50 && aheadCountInt > 20 { return doctorCheck{ Name: "Sync Branch Health", Status: statusWarning, Message: fmt.Sprintf("Sync branch %s commits behind %s on source files", aheadCount, mainBranch), Detail: fmt.Sprintf("%d source files differ between %s and %s. The sync branch has stale code.", fileCount, syncBranch, mainBranch), Fix: fmt.Sprintf("Reset sync branch: git branch -f %s %s && git push --force-with-lease origin %s", syncBranch, mainBranch, syncBranch), } } } return doctorCheck{ Name: "Sync Branch Health", Status: statusOK, Message: "OK", } } // checkTombstones checks the health of tombstone records (bd-s3v) // Reports: total tombstones, expiring soon (within 7 days), already expired func checkTombstones(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) // Skip if database doesn't exist if _, err := os.Stat(dbPath); os.IsNotExist(err) { return doctorCheck{ Name: "Tombstones", Status: statusOK, Message: "N/A (no database)", } } db, err := sql.Open("sqlite3", dbPath) if err != nil { return doctorCheck{ Name: "Tombstones", Status: statusWarning, Message: "Unable to open database", Detail: err.Error(), } } defer db.Close() // Query tombstone statistics var totalTombstones int err = db.QueryRow("SELECT COUNT(*) FROM issues WHERE status = 'tombstone'").Scan(&totalTombstones) if err != nil { // Might be old schema without tombstone support return doctorCheck{ Name: "Tombstones", Status: statusOK, Message: "N/A (schema may not support tombstones)", } } if totalTombstones == 0 { return doctorCheck{ Name: "Tombstones", Status: statusOK, Message: "None (no deleted issues)", } } // Check for tombstones expiring within 7 days // Default TTL is 30 days, so expiring soon means deleted_at older than 23 days ago expiringThreshold := time.Now().Add(-23 * 24 * time.Hour).Format(time.RFC3339) expiredThreshold := time.Now().Add(-30 * 24 * time.Hour).Format(time.RFC3339) var expiringSoon, alreadyExpired int err = db.QueryRow(` SELECT COUNT(*) FROM issues WHERE status = 'tombstone' AND deleted_at IS NOT NULL AND deleted_at < ? AND deleted_at >= ? `, expiringThreshold, expiredThreshold).Scan(&expiringSoon) if err != nil { expiringSoon = 0 } err = db.QueryRow(` SELECT COUNT(*) FROM issues WHERE status = 'tombstone' AND deleted_at IS NOT NULL AND deleted_at < ? `, expiredThreshold).Scan(&alreadyExpired) if err != nil { alreadyExpired = 0 } // Build status message if alreadyExpired > 0 { return doctorCheck{ Name: "Tombstones", Status: statusWarning, Message: fmt.Sprintf("%d total, %d expired", totalTombstones, alreadyExpired), Detail: "Expired tombstones will be removed on next compact", Fix: "Run 'bd compact' to prune expired tombstones", } } if expiringSoon > 0 { return doctorCheck{ Name: "Tombstones", Status: statusOK, Message: fmt.Sprintf("%d total, %d expiring within 7 days", totalTombstones, expiringSoon), } } return doctorCheck{ Name: "Tombstones", Status: statusOK, Message: fmt.Sprintf("%d total", totalTombstones), } } // checkUntrackedBeadsFiles checks for untracked .beads/*.jsonl files that should be committed. // This catches deletions.jsonl created by bd cleanup -f that hasn't been committed yet. (bd-pbj) func checkUntrackedBeadsFiles(path string) doctorCheck { beadsDir := filepath.Join(path, ".beads") // Skip if .beads doesn't exist if _, err := os.Stat(beadsDir); os.IsNotExist(err) { return doctorCheck{ Name: "Untracked Files", Status: statusOK, Message: "N/A (no .beads directory)", } } // Check if we're in a git repository using worktree-aware detection _, err := git.GetGitDir() if err != nil { return doctorCheck{ Name: "Untracked Files", Status: statusOK, Message: "N/A (not a git repository)", } } // Run git status --porcelain to find untracked files in .beads/ cmd := exec.Command("git", "status", "--porcelain", ".beads/") cmd.Dir = path output, err := cmd.Output() if err != nil { return doctorCheck{ Name: "Untracked Files", Status: statusWarning, Message: "Unable to check git status", Detail: err.Error(), } } // Parse output for untracked JSONL files (lines starting with "??") var untrackedJSONL []string for _, line := range strings.Split(string(output), "\n") { line = strings.TrimSpace(line) if line == "" { continue } // Untracked files start with "?? " if strings.HasPrefix(line, "?? ") { file := strings.TrimPrefix(line, "?? ") // Only care about .jsonl files if strings.HasSuffix(file, ".jsonl") { untrackedJSONL = append(untrackedJSONL, filepath.Base(file)) } } } if len(untrackedJSONL) == 0 { return doctorCheck{ Name: "Untracked Files", Status: statusOK, Message: "All .beads/*.jsonl files are tracked", } } return doctorCheck{ Name: "Untracked Files", Status: statusWarning, Message: fmt.Sprintf("Untracked JSONL files: %s", strings.Join(untrackedJSONL, ", ")), Detail: "These files should be committed to propagate changes to other clones", Fix: "Run 'bd doctor --fix' to stage and commit untracked files, or manually: git add .beads/*.jsonl && git commit", } } // NOTE: countIssuesInJSONLFile is defined in init.go // detectPrefixFromJSONL detects the most common issue prefix from a JSONL file func detectPrefixFromJSONL(jsonlPath string) string { _, prefixes, _ := countJSONLIssues(jsonlPath) if len(prefixes) == 0 { return "" } // Find the most common prefix var mostCommonPrefix string maxCount := 0 for prefix, count := range prefixes { if count > maxCount { maxCount = count mostCommonPrefix = prefix } } return mostCommonPrefix } func init() { rootCmd.AddCommand(doctorCmd) doctorCmd.Flags().BoolVar(&perfMode, "perf", false, "Run performance diagnostics and generate CPU profile") doctorCmd.Flags().BoolVar(&checkHealthMode, "check-health", false, "Quick health check for git hooks (silent on success)") doctorCmd.Flags().StringVarP(&doctorOutput, "output", "o", "", "Export diagnostics to JSON file (bd-9cc)") }