Files
beads/cmd/bd/repair.go
Steve Yegge a6bba83f09 fix: bd repair - add transaction, backup, dirty_issues marking
Code review fixes for critical issues:
- Wrap all DELETEs in a transaction with rollback on error
- Create .pre-repair backup before any destructive operations
- Mark parent issues as dirty when deleting orphaned depends_on refs
- Fix misleading PreRun comment

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

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

352 lines
11 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",
// Note: This command is in noDbCommands list (main.go) to skip normal db init.
// We open SQLite directly, bypassing migration invariant checks.
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
}
// Create backup before destructive operations
backupPath := dbPath + ".pre-repair"
fmt.Printf("Creating backup: %s\n", filepath.Base(backupPath))
if err := copyFile(dbPath, backupPath); err != nil {
fmt.Fprintf(os.Stderr, "Error creating backup: %v\n", err)
fmt.Fprintf(os.Stderr, "Aborting repair. Fix backup issue and retry.\n")
os.Exit(1)
}
fmt.Printf(" %s Backup created\n\n", ui.RenderPass("✓"))
// Apply repairs in a transaction
fmt.Println("Cleaning orphaned references...")
tx, err := db.Begin()
if err != nil {
fmt.Fprintf(os.Stderr, "Error starting transaction: %v\n", err)
os.Exit(1)
}
var repairErr error
// Delete orphaned deps (issue_id) and mark affected issues dirty
if len(orphanedIssueID) > 0 && repairErr == nil {
// Note: orphanedIssueID contains deps where issue_id doesn't exist,
// so we can't mark them dirty (the issue is gone). But for depends_on orphans,
// the issue_id still exists and should be marked dirty.
result, err := tx.Exec(`
DELETE FROM dependencies
WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = dependencies.issue_id)
`)
if err != nil {
repairErr = fmt.Errorf("deleting orphaned deps (issue_id): %w", 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) and mark parent issues dirty
if len(orphanedDependsOn) > 0 && repairErr == nil {
// Mark parent issues as dirty for export
for _, dep := range orphanedDependsOn {
_, _ = tx.Exec("INSERT OR IGNORE INTO dirty_issues (issue_id) VALUES (?)", dep.issueID)
}
result, err := tx.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 {
repairErr = fmt.Errorf("deleting orphaned deps (depends_on_id): %w", 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 && repairErr == nil {
// Labels reference non-existent issues, so no dirty marking needed
result, err := tx.Exec(`
DELETE FROM labels
WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = labels.issue_id)
`)
if err != nil {
repairErr = fmt.Errorf("deleting orphaned labels: %w", err)
} else {
deleted, _ := result.RowsAffected()
fmt.Printf(" %s Deleted %d labels with missing issue_id\n", ui.RenderPass("✓"), deleted)
}
}
// Commit or rollback
if repairErr != nil {
_ = tx.Rollback()
fmt.Fprintf(os.Stderr, "\n%s Error: %v\n", ui.RenderFail("✗"), repairErr)
fmt.Fprintf(os.Stderr, "Transaction rolled back. Database unchanged.\n")
fmt.Fprintf(os.Stderr, "Backup available at: %s\n", backupPath)
os.Exit(1)
}
if err := tx.Commit(); err != nil {
fmt.Fprintf(os.Stderr, "\n%s Error committing transaction: %v\n", ui.RenderFail("✗"), err)
fmt.Fprintf(os.Stderr, "Backup available at: %s\n", backupPath)
os.Exit(1)
}
// 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("✓"))
fmt.Printf("Backup preserved at: %s\n", filepath.Base(backupPath))
}
// 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()
}