Files
beads/cmd/bd/repair.go
Steve Yegge 1facf7fb83 feat: Add bd repair command for orphaned foreign key refs (hq-2cchm)
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 cannot run.

The new bd repair command:
- Opens SQLite directly, bypassing invariant checks
- Deletes orphaned dependencies (issue_id or depends_on_id not in issues)
- Deletes orphaned labels (issue_id not in issues)
- Runs WAL checkpoint to persist changes
- Supports --dry-run to preview what would be cleaned

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 12:43:22 -08:00

308 lines
9.0 KiB
Go

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()
}