Files
beads/cmd/bd/doctor/fix/recovery.go
Steve Yegge eefeb1a5bc refactor: dedupe error classification, fix --force+--source=db conflict
Post-merge cleanup of PR #805:

1. Extract duplicate error classification logic into classifyDatabaseError()
   helper function (was duplicated in two places in database.go)

2. Fix semantic conflict between --force and --source=db flags:
   - --force implies "database is broken, rebuild from JSONL"
   - --source=db implies "use database as source of truth"
   - These are contradictory; now errors with clear message
   - --force with --source=auto or --source=jsonl works as expected

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
2025-12-30 10:37:53 -08:00

238 lines
7.6 KiB
Go

package fix
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
// DatabaseCorruptionRecovery recovers a corrupted database from JSONL backup.
// It backs up the corrupted database, deletes it, and re-imports from JSONL.
func DatabaseCorruptionRecovery(path string) error {
// Validate workspace
if err := validateBeadsWorkspace(path); err != nil {
return err
}
beadsDir := filepath.Join(path, ".beads")
dbPath := filepath.Join(beadsDir, "beads.db")
// Check if database exists
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return fmt.Errorf("no database to recover")
}
// Find JSONL file
jsonlPath := findJSONLPath(beadsDir)
if jsonlPath == "" {
return fmt.Errorf("no JSONL backup found - cannot recover (try restoring from git history)")
}
// Count issues in JSONL
issueCount, err := countJSONLIssues(jsonlPath)
if err != nil {
return fmt.Errorf("failed to read JSONL: %w", err)
}
if issueCount == 0 {
return fmt.Errorf("JSONL is empty - cannot recover (try restoring from git history)")
}
// Backup corrupted database
backupPath := dbPath + ".corrupt"
fmt.Printf(" Backing up corrupted database to %s\n", filepath.Base(backupPath))
if err := os.Rename(dbPath, backupPath); err != nil {
return fmt.Errorf("failed to backup corrupted database: %w", err)
}
// Get bd binary path
bdBinary, err := getBdBinary()
if err != nil {
// Restore corrupted database on failure
_ = os.Rename(backupPath, dbPath)
return err
}
// Run bd import with --rename-on-import to handle prefix mismatches
fmt.Printf(" Recovering %d issues from %s\n", issueCount, filepath.Base(jsonlPath))
cmd := exec.Command(bdBinary, "import", "-i", jsonlPath, "--rename-on-import") // #nosec G204
cmd.Dir = path
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
// Keep backup on failure
fmt.Printf(" Warning: recovery failed, corrupted database preserved at %s\n", filepath.Base(backupPath))
return fmt.Errorf("failed to import from JSONL: %w", err)
}
// Run migrate to set version metadata
migrateCmd := exec.Command(bdBinary, "migrate") // #nosec G204
migrateCmd.Dir = path
migrateCmd.Stdout = os.Stdout
migrateCmd.Stderr = os.Stderr
if err := migrateCmd.Run(); err != nil {
// Non-fatal - import succeeded, version just won't be set
fmt.Printf(" Warning: migration failed (non-fatal): %v\n", err)
}
fmt.Printf(" Recovered %d issues from JSONL backup\n", issueCount)
return nil
}
// DatabaseCorruptionRecoveryWithOptions recovers a corrupted database with force and source selection support.
//
// Parameters:
// - path: workspace path
// - force: if true, bypasses validation and forces recovery even when database can't be opened
// - source: source of truth selection ("auto", "jsonl", "db")
//
// Force mode is useful when the database has validation errors that prevent normal opening.
// Source selection allows choosing between JSONL and database when both exist but diverge.
func DatabaseCorruptionRecoveryWithOptions(path string, force bool, source string) error {
// Validate workspace
if err := validateBeadsWorkspace(path); err != nil {
return err
}
beadsDir := filepath.Join(path, ".beads")
dbPath := filepath.Join(beadsDir, "beads.db")
// Check if database exists
dbExists := false
if _, err := os.Stat(dbPath); err == nil {
dbExists = true
}
// Find JSONL file
jsonlPath := findJSONLPath(beadsDir)
jsonlExists := jsonlPath != ""
// Check for contradictory flags early
if force && source == "db" {
return fmt.Errorf("--force and --source=db are contradictory: --force implies the database is broken and recovery should use JSONL. Use --source=jsonl or --source=auto with --force")
}
// Determine source of truth based on --source flag and availability
var useJSONL bool
switch source {
case "jsonl":
// Explicit JSONL preference
if !jsonlExists {
return fmt.Errorf("--source=jsonl specified but no JSONL file found")
}
useJSONL = true
if force {
fmt.Println(" Using JSONL as source of truth (--force --source=jsonl)")
} else {
fmt.Println(" Using JSONL as source of truth (--source=jsonl)")
}
case "db":
// Explicit database preference (already checked for force+db contradiction above)
if !dbExists {
return fmt.Errorf("--source=db specified but no database found")
}
useJSONL = false
fmt.Println(" Using database as source of truth (--source=db)")
case "auto":
// Auto-detect: prefer JSONL if database is corrupted or force is set
if force {
// Force mode implies database is broken - use JSONL
if !jsonlExists {
return fmt.Errorf("--force requires JSONL for recovery but no JSONL file found")
}
useJSONL = true
fmt.Println(" Using JSONL as source of truth (--force mode)")
} else if !dbExists && jsonlExists {
useJSONL = true
fmt.Println(" Using JSONL as source of truth (database missing)")
} else if dbExists && !jsonlExists {
useJSONL = false
fmt.Println(" Using database as source of truth (JSONL missing)")
} else if !dbExists && !jsonlExists {
return fmt.Errorf("neither database nor JSONL found - cannot recover")
} else {
// Both exist - prefer JSONL for recovery since we're in corruption recovery
useJSONL = true
fmt.Println(" Using JSONL as source of truth (auto-detected, database appears corrupted)")
}
default:
return fmt.Errorf("invalid --source value: %s (valid values: auto, jsonl, db)", source)
}
// If using database as source, just run migration (no recovery needed)
if !useJSONL {
fmt.Println(" Database is the source of truth - skipping recovery")
return nil
}
// JSONL recovery path
if jsonlPath == "" {
return fmt.Errorf("no JSONL backup found - cannot recover (try restoring from git history)")
}
// Count issues in JSONL
issueCount, err := countJSONLIssues(jsonlPath)
if err != nil {
return fmt.Errorf("failed to read JSONL: %w", err)
}
if issueCount == 0 {
return fmt.Errorf("JSONL is empty - cannot recover (try restoring from git history)")
}
// Backup existing database if it exists
if dbExists {
backupPath := dbPath + ".corrupt"
fmt.Printf(" Backing up database to %s\n", filepath.Base(backupPath))
if err := os.Rename(dbPath, backupPath); err != nil {
return fmt.Errorf("failed to backup database: %w", err)
}
}
// Get bd binary path
bdBinary, err := getBdBinary()
if err != nil {
// Restore database on failure if it existed
if dbExists {
backupPath := dbPath + ".corrupt"
_ = os.Rename(backupPath, dbPath)
}
return err
}
// Run bd import with --rename-on-import to handle prefix mismatches
fmt.Printf(" Recovering %d issues from %s\n", issueCount, filepath.Base(jsonlPath))
importArgs := []string{"import", "-i", jsonlPath, "--rename-on-import"}
if force {
// Force mode: skip git history checks, import from working tree
importArgs = append(importArgs, "--force", "--no-git-history")
}
cmd := exec.Command(bdBinary, importArgs...) // #nosec G204
cmd.Dir = path
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
// Keep backup on failure
if dbExists {
backupPath := dbPath + ".corrupt"
fmt.Printf(" Warning: recovery failed, database preserved at %s\n", filepath.Base(backupPath))
}
return fmt.Errorf("failed to import from JSONL: %w", err)
}
// Run migrate to set version metadata
migrateCmd := exec.Command(bdBinary, "migrate") // #nosec G204
migrateCmd.Dir = path
migrateCmd.Stdout = os.Stdout
migrateCmd.Stderr = os.Stderr
if err := migrateCmd.Run(); err != nil {
// Non-fatal - import succeeded, version just won't be set
fmt.Printf(" Warning: migration failed (non-fatal): %v\n", err)
}
fmt.Printf(" ✓ Recovered %d issues from JSONL backup\n", issueCount)
return nil
}