Files
beads/cmd/bd/repair.go
Bo a0dac11e42 refactor: reduce cyclomatic complexity in repair.go and config_values.go (#1214)
- repair.go: Extract validateRepairPaths(), findAllOrphans(), printOrphansText()
- config_values.go: Extract findConfigPath(), validateDurationConfig(), etc.
- Target: CC < 20 for each extracted function
2026-01-21 19:50:03 -08:00

652 lines
20 KiB
Go

package main
import (
"database/sql"
"encoding/json"
"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)
- Orphaned comments (issue_id not in issues)
- Orphaned events (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
bd repair --json # Output results as JSON`,
Run: runRepair,
}
var (
repairDryRun bool
repairPath string
repairJSON bool
)
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")
repairCmd.Flags().BoolVar(&repairJSON, "json", false, "Output results as JSON")
rootCmd.AddCommand(repairCmd)
}
// outputJSONAndExit outputs the repair result as JSON and exits
func outputJSONAndExit(result repairResult, exitCode int) {
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, `{"error": "failed to marshal JSON: %v"}`, err)
os.Exit(1)
}
fmt.Println(string(data))
os.Exit(exitCode)
}
// validateRepairPaths validates the beads directory and database exist.
func validateRepairPaths() (string, error) {
beadsDir := filepath.Join(repairPath, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return "", fmt.Errorf(".beads directory not found at %s", beadsDir)
}
dbPath := filepath.Join(beadsDir, "beads.db")
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return "", fmt.Errorf("database not found at %s", dbPath)
}
return dbPath, nil
}
// findAllOrphans finds all orphaned references in the database.
func findAllOrphans(db *sql.DB) (stats repairStats, orphans allOrphans, err error) {
orphans.depsIssueID, err = findOrphanedDepsIssueID(db)
if err != nil {
return stats, orphans, fmt.Errorf("checking orphaned deps (issue_id): %w", err)
}
stats.orphanedDepsIssueID = len(orphans.depsIssueID)
orphans.depsDependsOn, err = findOrphanedDepsDependsOn(db)
if err != nil {
return stats, orphans, fmt.Errorf("checking orphaned deps (depends_on_id): %w", err)
}
stats.orphanedDepsDependsOn = len(orphans.depsDependsOn)
orphans.labels, err = findOrphanedLabels(db)
if err != nil {
return stats, orphans, fmt.Errorf("checking orphaned labels: %w", err)
}
stats.orphanedLabels = len(orphans.labels)
orphans.comments, err = findOrphanedComments(db)
if err != nil {
return stats, orphans, fmt.Errorf("checking orphaned comments: %w", err)
}
stats.orphanedComments = len(orphans.comments)
orphans.events, err = findOrphanedEvents(db)
if err != nil {
return stats, orphans, fmt.Errorf("checking orphaned events: %w", err)
}
stats.orphanedEvents = len(orphans.events)
return stats, orphans, nil
}
// allOrphans holds all found orphaned references.
type allOrphans struct {
depsIssueID []orphanedDep
depsDependsOn []orphanedDep
labels []orphanedLabel
comments []orphanedComment
events []orphanedEvent
}
// printOrphansText prints orphan details in text mode.
func printOrphansText(stats repairStats, orphans allOrphans) {
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 orphans.depsIssueID {
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 orphans.depsDependsOn {
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 orphans.labels {
fmt.Printf(" - %s: %s\n", label.issueID, label.label)
}
}
if stats.orphanedComments > 0 {
fmt.Printf(" • %d comments with missing issue_id\n", stats.orphanedComments)
for _, comment := range orphans.comments {
fmt.Printf(" - %s (by %s)\n", comment.issueID, comment.author)
}
}
if stats.orphanedEvents > 0 {
fmt.Printf(" • %d events with missing issue_id\n", stats.orphanedEvents)
for _, event := range orphans.events {
fmt.Printf(" - %s: %s\n", event.issueID, event.eventType)
}
}
fmt.Println()
}
func runRepair(cmd *cobra.Command, args []string) {
dbPath, err := validateRepairPaths()
if err != nil {
if repairJSON {
outputJSONAndExit(repairResult{Status: "error", Error: err.Error()}, 1)
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if !repairJSON {
fmt.Printf("Repairing database: %s\n", dbPath)
if repairDryRun {
fmt.Println("[DRY-RUN] No changes will be made")
}
fmt.Println()
}
db, err := openRepairDB(dbPath)
if err != nil {
if repairJSON {
outputJSONAndExit(repairResult{DatabasePath: dbPath, Status: "error", Error: fmt.Sprintf("opening database: %v", err)}, 1)
}
fmt.Fprintf(os.Stderr, "Error opening database: %v\n", err)
os.Exit(1)
}
defer db.Close()
// Find all orphaned references
stats, orphans, err := findAllOrphans(db)
if err != nil {
if repairJSON {
outputJSONAndExit(repairResult{DatabasePath: dbPath, Status: "error", Error: err.Error()}, 1)
}
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Build JSON result structure (used for both JSON output and tracking)
jsonResult := repairResult{
DatabasePath: dbPath,
DryRun: repairDryRun,
OrphanCounts: repairOrphanCounts{
DependenciesIssueID: stats.orphanedDepsIssueID,
DependenciesDependsOn: stats.orphanedDepsDependsOn,
Labels: stats.orphanedLabels,
Comments: stats.orphanedComments,
Events: stats.orphanedEvents,
Total: stats.total(),
},
}
// Build orphan details
for _, dep := range orphans.depsIssueID {
jsonResult.OrphanDetails.DependenciesIssueID = append(jsonResult.OrphanDetails.DependenciesIssueID,
repairDepDetail{IssueID: dep.issueID, DependsOnID: dep.dependsOnID})
}
for _, dep := range orphans.depsDependsOn {
jsonResult.OrphanDetails.DependenciesDependsOn = append(jsonResult.OrphanDetails.DependenciesDependsOn,
repairDepDetail{IssueID: dep.issueID, DependsOnID: dep.dependsOnID})
}
for _, label := range orphans.labels {
jsonResult.OrphanDetails.Labels = append(jsonResult.OrphanDetails.Labels,
repairLabelDetail{IssueID: label.issueID, Label: label.label})
}
for _, comment := range orphans.comments {
jsonResult.OrphanDetails.Comments = append(jsonResult.OrphanDetails.Comments,
repairCommentDetail{ID: comment.id, IssueID: comment.issueID, Author: comment.author})
}
for _, event := range orphans.events {
jsonResult.OrphanDetails.Events = append(jsonResult.OrphanDetails.Events,
repairEventDetail{ID: event.id, IssueID: event.issueID, EventType: event.eventType})
}
// Handle no orphans case
if stats.total() == 0 {
if repairJSON {
jsonResult.Status = "no_orphans"
outputJSONAndExit(jsonResult, 0)
}
fmt.Printf("%s No orphaned references found - database is clean\n", ui.RenderPass("✓"))
return
}
// Print findings (text mode)
if !repairJSON {
printOrphansText(stats, orphans)
}
// Handle dry-run
if repairDryRun {
if repairJSON {
jsonResult.Status = "dry_run"
outputJSONAndExit(jsonResult, 0)
}
fmt.Printf("[DRY-RUN] Would delete %d orphaned reference(s)\n", stats.total())
return
}
// Create backup before destructive operations
backupPath := dbPath + ".pre-repair"
if !repairJSON {
fmt.Printf("Creating backup: %s\n", filepath.Base(backupPath))
}
if err := copyFile(dbPath, backupPath); err != nil {
if repairJSON {
jsonResult.Status = "error"
jsonResult.Error = fmt.Sprintf("creating backup: %v", err)
outputJSONAndExit(jsonResult, 1)
}
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)
}
jsonResult.BackupPath = backupPath
if !repairJSON {
fmt.Printf(" %s Backup created\n\n", ui.RenderPass("✓"))
}
// Apply repairs in a transaction
if !repairJSON {
fmt.Println("Cleaning orphaned references...")
}
tx, err := db.Begin()
if err != nil {
if repairJSON {
jsonResult.Status = "error"
jsonResult.Error = fmt.Sprintf("starting transaction: %v", err)
outputJSONAndExit(jsonResult, 1)
}
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(orphans.depsIssueID) > 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 if !repairJSON {
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(orphans.depsDependsOn) > 0 && repairErr == nil {
// Mark parent issues as dirty for export
for _, dep := range orphans.depsDependsOn {
_, _ = 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 if !repairJSON {
deleted, _ := result.RowsAffected()
fmt.Printf(" %s Deleted %d dependencies with missing depends_on_id\n", ui.RenderPass("✓"), deleted)
}
}
// Delete orphaned labels
if len(orphans.labels) > 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 if !repairJSON {
deleted, _ := result.RowsAffected()
fmt.Printf(" %s Deleted %d labels with missing issue_id\n", ui.RenderPass("✓"), deleted)
}
}
// Delete orphaned comments
if len(orphans.comments) > 0 && repairErr == nil {
// Comments reference non-existent issues, so no dirty marking needed
result, err := tx.Exec(`
DELETE FROM comments
WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = comments.issue_id)
`)
if err != nil {
repairErr = fmt.Errorf("deleting orphaned comments: %w", err)
} else if !repairJSON {
deleted, _ := result.RowsAffected()
fmt.Printf(" %s Deleted %d comments with missing issue_id\n", ui.RenderPass("✓"), deleted)
}
}
// Delete orphaned events
if len(orphans.events) > 0 && repairErr == nil {
// Events reference non-existent issues, so no dirty marking needed
result, err := tx.Exec(`
DELETE FROM events
WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = events.issue_id)
`)
if err != nil {
repairErr = fmt.Errorf("deleting orphaned events: %w", err)
} else if !repairJSON {
deleted, _ := result.RowsAffected()
fmt.Printf(" %s Deleted %d events with missing issue_id\n", ui.RenderPass("✓"), deleted)
}
}
// Commit or rollback
if repairErr != nil {
_ = tx.Rollback()
if repairJSON {
jsonResult.Status = "error"
jsonResult.Error = repairErr.Error()
outputJSONAndExit(jsonResult, 1)
}
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 {
if repairJSON {
jsonResult.Status = "error"
jsonResult.Error = fmt.Sprintf("committing transaction: %v", err)
outputJSONAndExit(jsonResult, 1)
}
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
if !repairJSON {
fmt.Print(" Running WAL checkpoint... ")
}
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
if !repairJSON {
fmt.Printf("%s %v\n", ui.RenderFail("✗"), err)
}
} else if !repairJSON {
fmt.Printf("%s\n", ui.RenderPass("✓"))
}
// Final output
if repairJSON {
jsonResult.Status = "success"
outputJSONAndExit(jsonResult, 0)
}
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
orphanedComments int
orphanedEvents int
}
func (s repairStats) total() int {
return s.orphanedDepsIssueID + s.orphanedDepsDependsOn + s.orphanedLabels + s.orphanedComments + s.orphanedEvents
}
// orphanedDep represents an orphaned dependency
type orphanedDep struct {
issueID string
dependsOnID string
}
// orphanedLabel represents an orphaned label
type orphanedLabel struct {
issueID string
label string
}
// orphanedComment represents an orphaned comment
type orphanedComment struct {
id int
issueID string
author string
}
// orphanedEvent represents an orphaned event
type orphanedEvent struct {
id int
issueID string
eventType string
}
// repairResult is the JSON output structure
type repairResult struct {
DatabasePath string `json:"database_path"`
DryRun bool `json:"dry_run"`
OrphanCounts repairOrphanCounts `json:"orphan_counts"`
OrphanDetails repairOrphanDetails `json:"orphan_details"`
Status string `json:"status"` // "success", "no_orphans", "dry_run", "error"
BackupPath string `json:"backup_path,omitempty"`
Error string `json:"error,omitempty"`
}
type repairOrphanCounts struct {
DependenciesIssueID int `json:"dependencies_issue_id"`
DependenciesDependsOn int `json:"dependencies_depends_on"`
Labels int `json:"labels"`
Comments int `json:"comments"`
Events int `json:"events"`
Total int `json:"total"`
}
type repairOrphanDetails struct {
DependenciesIssueID []repairDepDetail `json:"dependencies_issue_id,omitempty"`
DependenciesDependsOn []repairDepDetail `json:"dependencies_depends_on,omitempty"`
Labels []repairLabelDetail `json:"labels,omitempty"`
Comments []repairCommentDetail `json:"comments,omitempty"`
Events []repairEventDetail `json:"events,omitempty"`
}
type repairDepDetail struct {
IssueID string `json:"issue_id"`
DependsOnID string `json:"depends_on_id"`
}
type repairLabelDetail struct {
IssueID string `json:"issue_id"`
Label string `json:"label"`
}
type repairCommentDetail struct {
ID int `json:"id"`
IssueID string `json:"issue_id"`
Author string `json:"author"`
}
type repairEventDetail struct {
ID int `json:"id"`
IssueID string `json:"issue_id"`
EventType string `json:"event_type"`
}
// 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()
}
// findOrphanedComments finds comments where issue_id doesn't exist
func findOrphanedComments(db *sql.DB) ([]orphanedComment, error) {
rows, err := db.Query(`
SELECT c.id, c.issue_id, c.author
FROM comments c
WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = c.issue_id)
`)
if err != nil {
return nil, err
}
defer rows.Close()
var comments []orphanedComment
for rows.Next() {
var comment orphanedComment
if err := rows.Scan(&comment.id, &comment.issueID, &comment.author); err != nil {
return nil, err
}
comments = append(comments, comment)
}
return comments, rows.Err()
}
// findOrphanedEvents finds events where issue_id doesn't exist
func findOrphanedEvents(db *sql.DB) ([]orphanedEvent, error) {
rows, err := db.Query(`
SELECT e.id, e.issue_id, e.event_type
FROM events e
WHERE NOT EXISTS (SELECT 1 FROM issues WHERE id = e.issue_id)
`)
if err != nil {
return nil, err
}
defer rows.Close()
var events []orphanedEvent
for rows.Next() {
var event orphanedEvent
if err := rows.Scan(&event.id, &event.issueID, &event.eventType); err != nil {
return nil, err
}
events = append(events, event)
}
return events, rows.Err()
}