Files
beads/cmd/bd/doctor/fix/database_integrity.go
Jordan Hubbard 1184bd1e59 doctor: add git hygiene checks and DB integrity auto-fix
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2025-12-25 21:35:44 -04:00

135 lines
3.6 KiB
Go

package fix
import (
"bufio"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/utils"
)
// DatabaseIntegrity attempts to recover from database corruption by:
// 1. Backing up the corrupt database (and WAL/SHM if present)
// 2. Re-initializing the database from the git-tracked JSONL export
//
// This is intentionally conservative: it will not delete JSONL, and it preserves the
// original DB as a backup for forensic recovery.
func DatabaseIntegrity(path string) error {
if err := validateBeadsWorkspace(path); err != nil {
return err
}
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to resolve path: %w", err)
}
beadsDir := filepath.Join(absPath, ".beads")
// Resolve database path (respects metadata.json database override).
var dbPath string
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
dbPath = cfg.DatabasePath(beadsDir)
} else {
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
}
// Find JSONL source of truth.
jsonlPath := ""
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil {
candidate := cfg.JSONLPath(beadsDir)
if _, err := os.Stat(candidate); err == nil {
jsonlPath = candidate
}
}
if jsonlPath == "" {
for _, name := range []string{"issues.jsonl", "beads.jsonl"} {
candidate := filepath.Join(beadsDir, name)
if _, err := os.Stat(candidate); err == nil {
jsonlPath = candidate
break
}
}
}
if jsonlPath == "" {
return fmt.Errorf("cannot auto-recover: no JSONL export found in %s", beadsDir)
}
// Back up corrupt DB and its sidecar files.
ts := time.Now().UTC().Format("20060102T150405Z")
backupDB := dbPath + "." + ts + ".corrupt.backup.db"
if err := os.Rename(dbPath, backupDB); err != nil {
return fmt.Errorf("failed to back up database: %w", err)
}
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
sidecar := dbPath + suffix
if _, err := os.Stat(sidecar); err == nil {
_ = os.Rename(sidecar, backupDB+suffix) // best effort
}
}
// Rebuild via bd init, pointing at the same db path.
bdBinary, err := getBdBinary()
if err != nil {
return err
}
args := []string{"--db", dbPath, "init", "--quiet", "--force", "--skip-hooks", "--skip-merge-driver"}
if prefix := detectPrefixFromJSONL(jsonlPath); prefix != "" {
args = append(args, "--prefix", prefix)
}
cmd := exec.Command(bdBinary, args...) // #nosec G204 -- bdBinary is a validated executable path
cmd.Dir = absPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if err := cmd.Run(); err != nil {
// Best-effort rollback: if init didn't recreate the db, restore the backup.
if _, statErr := os.Stat(dbPath); os.IsNotExist(statErr) {
_ = os.Rename(backupDB, dbPath)
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
_ = os.Rename(backupDB+suffix, dbPath+suffix)
}
}
return fmt.Errorf("failed to rebuild database from JSONL: %w (backup: %s)", err, backupDB)
}
return nil
}
func detectPrefixFromJSONL(jsonlPath string) string {
f, err := os.Open(jsonlPath) // #nosec G304 -- jsonlPath is within the workspace
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var issue struct {
ID string `json:"id"`
}
if err := json.Unmarshal(line, &issue); err != nil {
continue
}
if issue.ID == "" {
continue
}
return utils.ExtractIssuePrefix(issue.ID)
}
return ""
}