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>
202 lines
6.3 KiB
Go
202 lines
6.3 KiB
Go
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)
|
|
}
|
|
}
|