package main import ( "database/sql" "fmt" "os" "path/filepath" "strings" "time" _ "github.com/ncruces/go-sqlite3/driver" _ "github.com/ncruces/go-sqlite3/embed" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/ui" ) var repairCmd = &cobra.Command{ Use: "repair", GroupID: GroupMaintenance, Short: "Repair corrupted database by cleaning orphaned references", // PreRun disables PersistentPreRun for this command (we open DB directly, bypassing invariants) PreRun: func(cmd *cobra.Command, args []string) {}, Long: `Repair a database that won't open due to orphaned foreign key references. When the database has orphaned dependencies or labels, the migration invariant check fails and prevents the database from opening. This creates a chicken-and-egg problem where 'bd doctor --fix' can't run because it can't open the database. This command opens SQLite directly (bypassing invariant checks) and cleans: - Orphaned dependencies (issue_id not in issues) - Orphaned dependencies (depends_on_id not in issues, excluding external refs) - Orphaned labels (issue_id not in issues) After repair, normal bd commands should work again. Examples: bd repair # Repair database in current directory bd repair --dry-run # Show what would be cleaned without making changes bd repair --path /other/repo # Repair database in another location`, Run: runRepair, } var ( repairDryRun bool repairPath string ) func init() { repairCmd.Flags().BoolVar(&repairDryRun, "dry-run", false, "Show what would be cleaned without making changes") repairCmd.Flags().StringVar(&repairPath, "path", ".", "Path to repository with .beads directory") rootCmd.AddCommand(repairCmd) } func runRepair(cmd *cobra.Command, args []string) { // Find .beads directory beadsDir := filepath.Join(repairPath, ".beads") if _, err := os.Stat(beadsDir); os.IsNotExist(err) { fmt.Fprintf(os.Stderr, "Error: .beads directory not found at %s\n", beadsDir) os.Exit(1) } // Find database file dbPath := filepath.Join(beadsDir, "beads.db") if _, err := os.Stat(dbPath); os.IsNotExist(err) { fmt.Fprintf(os.Stderr, "Error: database not found at %s\n", dbPath) os.Exit(1) } fmt.Printf("Repairing database: %s\n", dbPath) if repairDryRun { fmt.Println("[DRY-RUN] No changes will be made") } fmt.Println() // Open database directly, bypassing beads storage layer db, err := openRepairDB(dbPath) if err != nil { fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err) os.Exit(1) } defer db.Close() // Collect repair statistics stats := repairStats{} // 1. Find and clean orphaned dependencies (issue_id not in issues) orphanedIssueID, err := findOrphanedDepsIssueID(db) if err != nil { fmt.Fprintf(os.Stderr, "Error checking orphaned deps (issue_id): %v\n", err) os.Exit(1) } stats.orphanedDepsIssueID = len(orphanedIssueID) // 2. Find and clean orphaned dependencies (depends_on_id not in issues) orphanedDependsOn, err := findOrphanedDepsDependsOn(db) if err != nil { fmt.Fprintf(os.Stderr, "Error checking orphaned deps (depends_on_id): %v\n", err) os.Exit(1) } stats.orphanedDepsDependsOn = len(orphanedDependsOn) // 3. Find and clean orphaned labels orphanedLabels, err := findOrphanedLabels(db) if err != nil { fmt.Fprintf(os.Stderr, "Error checking orphaned labels: %v\n", err) os.Exit(1) } stats.orphanedLabels = len(orphanedLabels) // Print findings if stats.total() == 0 { fmt.Printf("%s No orphaned references found - database is clean\n", ui.RenderPass("✓")) return } fmt.Printf("Found %d orphaned reference(s):\n", stats.total()) if stats.orphanedDepsIssueID > 0 { fmt.Printf(" • %d dependencies with missing issue_id\n", stats.orphanedDepsIssueID) for _, dep := range orphanedIssueID { fmt.Printf(" - %s → %s\n", dep.issueID, dep.dependsOnID) } } if stats.orphanedDepsDependsOn > 0 { fmt.Printf(" • %d dependencies with missing depends_on_id\n", stats.orphanedDepsDependsOn) for _, dep := range orphanedDependsOn { fmt.Printf(" - %s → %s\n", dep.issueID, dep.dependsOnID) } } if stats.orphanedLabels > 0 { fmt.Printf(" • %d labels with missing issue_id\n", stats.orphanedLabels) for _, label := range orphanedLabels { fmt.Printf(" - %s: %s\n", label.issueID, label.label) } } fmt.Println() if repairDryRun { fmt.Printf("[DRY-RUN] Would delete %d orphaned reference(s)\n", stats.total()) return } // Apply repairs fmt.Println("Cleaning orphaned references...") // Delete orphaned deps (issue_id) if len(orphanedIssueID) > 0 { result, err := db.Exec(` DELETE FROM dependencies WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = dependencies.issue_id) `) if err != nil { fmt.Fprintf(os.Stderr, "Error deleting orphaned deps (issue_id): %v\n", err) } else { deleted, _ := result.RowsAffected() fmt.Printf(" %s Deleted %d dependencies with missing issue_id\n", ui.RenderPass("✓"), deleted) } } // Delete orphaned deps (depends_on_id) if len(orphanedDependsOn) > 0 { result, err := db.Exec(` DELETE FROM dependencies WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = dependencies.depends_on_id) AND dependencies.depends_on_id NOT LIKE 'external:%' `) if err != nil { fmt.Fprintf(os.Stderr, "Error deleting orphaned deps (depends_on_id): %v\n", err) } else { deleted, _ := result.RowsAffected() fmt.Printf(" %s Deleted %d dependencies with missing depends_on_id\n", ui.RenderPass("✓"), deleted) } } // Delete orphaned labels if len(orphanedLabels) > 0 { result, err := db.Exec(` DELETE FROM labels WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = labels.issue_id) `) if err != nil { fmt.Fprintf(os.Stderr, "Error deleting orphaned labels: %v\n", err) } else { deleted, _ := result.RowsAffected() fmt.Printf(" %s Deleted %d labels with missing issue_id\n", ui.RenderPass("✓"), deleted) } } // Run WAL checkpoint to persist changes fmt.Print(" Running WAL checkpoint... ") if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { fmt.Printf("%s %v\n", ui.RenderFail("✗"), err) } else { fmt.Printf("%s\n", ui.RenderPass("✓")) } fmt.Println() fmt.Printf("%s Repair complete. Try running 'bd doctor' to verify.\n", ui.RenderPass("✓")) } // repairStats tracks what was found/cleaned type repairStats struct { orphanedDepsIssueID int orphanedDepsDependsOn int orphanedLabels int } func (s repairStats) total() int { return s.orphanedDepsIssueID + s.orphanedDepsDependsOn + s.orphanedLabels } // orphanedDep represents an orphaned dependency type orphanedDep struct { issueID string dependsOnID string } // orphanedLabel represents an orphaned label type orphanedLabel struct { issueID string label string } // openRepairDB opens SQLite directly for repair, bypassing all beads layer code func openRepairDB(dbPath string) (*sql.DB, error) { // Build connection string with pragmas busyMs := int64(30 * time.Second / time.Millisecond) if v := strings.TrimSpace(os.Getenv("BD_LOCK_TIMEOUT")); v != "" { if d, err := time.ParseDuration(v); err == nil { busyMs = int64(d / time.Millisecond) } } connStr := fmt.Sprintf("file:%s?_pragma=busy_timeout(%d)&_pragma=foreign_keys(OFF)&_time_format=sqlite", dbPath, busyMs) return sql.Open("sqlite3", connStr) } // findOrphanedDepsIssueID finds dependencies where issue_id doesn't exist func findOrphanedDepsIssueID(db *sql.DB) ([]orphanedDep, error) { rows, err := db.Query(` SELECT d.issue_id, d.depends_on_id FROM dependencies d WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = d.issue_id) `) if err != nil { return nil, err } defer rows.Close() var orphans []orphanedDep for rows.Next() { var dep orphanedDep if err := rows.Scan(&dep.issueID, &dep.dependsOnID); err != nil { return nil, err } orphans = append(orphans, dep) } return orphans, rows.Err() } // findOrphanedDepsDependsOn finds dependencies where depends_on_id doesn't exist func findOrphanedDepsDependsOn(db *sql.DB) ([]orphanedDep, error) { rows, err := db.Query(` SELECT d.issue_id, d.depends_on_id FROM dependencies d WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = d.depends_on_id) AND d.depends_on_id NOT LIKE 'external:%' `) if err != nil { return nil, err } defer rows.Close() var orphans []orphanedDep for rows.Next() { var dep orphanedDep if err := rows.Scan(&dep.issueID, &dep.dependsOnID); err != nil { return nil, err } orphans = append(orphans, dep) } return orphans, rows.Err() } // findOrphanedLabels finds labels where issue_id doesn't exist func findOrphanedLabels(db *sql.DB) ([]orphanedLabel, error) { rows, err := db.Query(` SELECT l.issue_id, l.label FROM labels l WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = l.issue_id) `) if err != nil { return nil, err } defer rows.Close() var labels []orphanedLabel for rows.Next() { var label orphanedLabel if err := rows.Scan(&label.issueID, &label.label); err != nil { return nil, err } labels = append(labels, label) } return labels, rows.Err() }