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>
This commit is contained in:
Steve Yegge
2025-12-29 12:43:05 -08:00
parent 93cc9df9ad
commit 1facf7fb83
3 changed files with 509 additions and 0 deletions

View File

@@ -281,6 +281,7 @@ var rootCmd = &cobra.Command{
"powershell",
"prime",
"quickstart",
"repair",
"setup",
"version",
"zsh",

307
cmd/bd/repair.go Normal file
View File

@@ -0,0 +1,307 @@
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()
}

View File

@@ -0,0 +1,201 @@
package main
import (
"database/sql"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
)
// runBDRepair runs the repair command with --path flag (bypasses normal db init)
func runBDRepair(t *testing.T, exe, path string, args ...string) (string, error) {
t.Helper()
fullArgs := []string{"repair", "--path", path}
fullArgs = append(fullArgs, args...)
cmd := exec.Command(exe, fullArgs...)
cmd.Dir = path
cmd.Env = append(os.Environ(), "BEADS_NO_DAEMON=1")
out, err := cmd.CombinedOutput()
return string(out), err
}
func TestRepairOrphans_DryRun(t *testing.T) {
requireTestGuardDisabled(t)
if testing.Short() {
t.Skip("skipping slow repair test in short mode")
}
bdExe := buildBDForTest(t)
ws := mkTmpDirInTmp(t, "bd-repair-orphans-*")
dbPath := filepath.Join(ws, ".beads", "beads.db")
// Initialize with some issues
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "test", "--quiet"); err != nil {
t.Fatalf("bd init failed: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Issue 1", "-p", "1"); err != nil {
t.Fatalf("bd create failed: %v", err)
}
// Directly insert orphaned data into the database
db, err := sql.Open("sqlite3", "file:"+dbPath+"?_pragma=foreign_keys(OFF)")
if err != nil {
t.Fatalf("open db: %v", err)
}
defer db.Close()
// Insert orphaned dependency (issue_id doesn't exist)
_, err = db.Exec("INSERT INTO dependencies (issue_id, depends_on_id, type, created_by) VALUES ('nonexistent-1', 'test-xyz', 'blocks', 'test')")
if err != nil {
t.Fatalf("insert orphan dep: %v", err)
}
// Insert orphaned dependency (depends_on_id doesn't exist)
_, err = db.Exec("INSERT INTO dependencies (issue_id, depends_on_id, type, created_by) VALUES ('test-xyz', 'deleted-issue', 'blocks', 'test')")
if err != nil {
t.Fatalf("insert orphan dep2: %v", err)
}
// Insert orphaned label
_, err = db.Exec("INSERT INTO labels (issue_id, label) VALUES ('gone-issue', 'bug')")
if err != nil {
t.Fatalf("insert orphan label: %v", err)
}
db.Close()
// Run repair with --dry-run (use --path, not --db, so repair bypasses normal init)
out, err := runBDRepair(t, bdExe, ws, "--dry-run")
if err != nil {
t.Fatalf("bd repair --dry-run failed: %v\n%s", err, out)
}
// Verify it found the orphans
if !strings.Contains(out, "dependencies with missing issue_id") {
t.Errorf("expected to find orphaned deps (issue_id), got: %s", out)
}
if !strings.Contains(out, "dependencies with missing depends_on_id") {
t.Errorf("expected to find orphaned deps (depends_on_id), got: %s", out)
}
if !strings.Contains(out, "labels with missing issue_id") {
t.Errorf("expected to find orphaned labels, got: %s", out)
}
if !strings.Contains(out, "[DRY-RUN]") {
t.Errorf("expected DRY-RUN message, got: %s", out)
}
// Verify data wasn't actually deleted
db2, err := sql.Open("sqlite3", "file:"+dbPath+"?_pragma=foreign_keys(OFF)")
if err != nil {
t.Fatalf("reopen db: %v", err)
}
defer db2.Close()
var count int
db2.QueryRow("SELECT COUNT(*) FROM dependencies WHERE issue_id = 'nonexistent-1'").Scan(&count)
if count != 1 {
t.Errorf("expected orphan dep to still exist after dry-run, got count=%d", count)
}
}
func TestRepairOrphans_Fix(t *testing.T) {
requireTestGuardDisabled(t)
if testing.Short() {
t.Skip("skipping slow repair test in short mode")
}
bdExe := buildBDForTest(t)
ws := mkTmpDirInTmp(t, "bd-repair-fix-*")
dbPath := filepath.Join(ws, ".beads", "beads.db")
// Initialize with some issues
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "test", "--quiet"); err != nil {
t.Fatalf("bd init failed: %v", err)
}
out, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Issue 1", "-p", "1", "--json")
if err != nil {
t.Fatalf("bd create failed: %v\n%s", err, out)
}
// Directly insert orphaned data
db, err := sql.Open("sqlite3", "file:"+dbPath+"?_pragma=foreign_keys(OFF)")
if err != nil {
t.Fatalf("open db: %v", err)
}
// Insert orphaned dependencies and labels
db.Exec("INSERT INTO dependencies (issue_id, depends_on_id, type, created_by) VALUES ('orphan-1', 'test-xyz', 'blocks', 'test')")
db.Exec("INSERT INTO dependencies (issue_id, depends_on_id, type, created_by) VALUES ('test-xyz', 'deleted-2', 'blocks', 'test')")
db.Exec("INSERT INTO labels (issue_id, label) VALUES ('orphan-3', 'wontfix')")
db.Close()
// Run repair (no --dry-run) - use --path to bypass normal db init
out, err = runBDRepair(t, bdExe, ws)
if err != nil {
t.Fatalf("bd repair failed: %v\n%s", err, out)
}
// Verify it cleaned up
if !strings.Contains(out, "Deleted") {
t.Errorf("expected deletion messages, got: %s", out)
}
if !strings.Contains(out, "Repair complete") {
t.Errorf("expected completion message, got: %s", out)
}
// Verify orphans are gone
db2, err := sql.Open("sqlite3", "file:"+dbPath+"?_pragma=foreign_keys(OFF)")
if err != nil {
t.Fatalf("reopen db: %v", err)
}
defer db2.Close()
var depCount, labelCount int
db2.QueryRow("SELECT COUNT(*) FROM dependencies WHERE issue_id = 'orphan-1' OR depends_on_id = 'deleted-2'").Scan(&depCount)
db2.QueryRow("SELECT COUNT(*) FROM labels WHERE issue_id = 'orphan-3'").Scan(&labelCount)
if depCount != 0 {
t.Errorf("expected orphan deps to be deleted, got count=%d", depCount)
}
if labelCount != 0 {
t.Errorf("expected orphan labels to be deleted, got count=%d", labelCount)
}
}
func TestRepairOrphans_CleanDatabase(t *testing.T) {
requireTestGuardDisabled(t)
if testing.Short() {
t.Skip("skipping slow repair test in short mode")
}
bdExe := buildBDForTest(t)
ws := mkTmpDirInTmp(t, "bd-repair-clean-*")
dbPath := filepath.Join(ws, ".beads", "beads.db")
// Initialize with a clean database
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "test", "--quiet"); err != nil {
t.Fatalf("bd init failed: %v", err)
}
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Issue 1", "-p", "1"); err != nil {
t.Fatalf("bd create failed: %v", err)
}
// Run repair on clean database - use --path to bypass normal db init
out, err := runBDRepair(t, bdExe, ws)
if err != nil {
t.Fatalf("bd repair failed: %v\n%s", err, out)
}
// Should report no orphans found
if !strings.Contains(out, "No orphaned references found") {
t.Errorf("expected clean database message, got: %s", out)
}
}