From 1184bd1e5983de974d88597cc89c8f1ff358bbf8 Mon Sep 17 00:00:00 2001 From: Jordan Hubbard Date: Thu, 25 Dec 2025 21:35:44 -0400 Subject: [PATCH] doctor: add git hygiene checks and DB integrity auto-fix Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/bd/doctor.go | 16 +- cmd/bd/doctor/database.go | 4 +- cmd/bd/doctor/fix/database_integrity.go | 134 +++++++++++++++++ cmd/bd/doctor/git.go | 167 +++++++++++++++++++++ cmd/bd/doctor/git_hygiene_test.go | 172 ++++++++++++++++++++++ cmd/bd/doctor_repair_test.go | 147 ++++++++++++++++++ internal/hooks/hooks_test.go | 12 +- internal/syncbranch/worktree_sync_test.go | 3 +- 8 files changed, 645 insertions(+), 10 deletions(-) create mode 100644 cmd/bd/doctor/fix/database_integrity.go create mode 100644 cmd/bd/doctor/git_hygiene_test.go create mode 100644 cmd/bd/doctor_repair_test.go diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index c2b617e1..ea03cc99 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -43,8 +43,8 @@ type doctorResult struct { Checks []doctorCheck `json:"checks"` OverallOK bool `json:"overall_ok"` CLIVersion string `json:"cli_version"` - Timestamp string `json:"timestamp,omitempty"` // bd-9cc: ISO8601 timestamp for historical tracking - Platform map[string]string `json:"platform,omitempty"` // bd-9cc: platform info for debugging + Timestamp string `json:"timestamp,omitempty"` // bd-9cc: ISO8601 timestamp for historical tracking + Platform map[string]string `json:"platform,omitempty"` // bd-9cc: platform info for debugging } var ( @@ -373,6 +373,8 @@ func applyFixList(path string, fixes []doctorCheck) { err = fix.Permissions(path) case "Database": err = fix.DatabaseVersion(path) + case "Database Integrity": + err = fix.DatabaseIntegrity(path) case "Schema Compatibility": err = fix.SchemaCompatibility(path) case "Repo Fingerprint": @@ -750,6 +752,16 @@ func runDiagnostics(path string) doctorResult { result.Checks = append(result.Checks, mergeDriverCheck) // Don't fail overall check for merge driver, just warn + // Check 15a: Git working tree cleanliness (AGENTS.md hygiene) + gitWorkingTreeCheck := convertWithCategory(doctor.CheckGitWorkingTree(path), doctor.CategoryGit) + result.Checks = append(result.Checks, gitWorkingTreeCheck) + // Don't fail overall check for dirty working tree, just warn + + // Check 15b: Git upstream sync (ahead/behind/diverged) + gitUpstreamCheck := convertWithCategory(doctor.CheckGitUpstream(path), doctor.CategoryGit) + result.Checks = append(result.Checks, gitUpstreamCheck) + // Don't fail overall check for upstream drift, just warn + // Check 16: Metadata.json version tracking (bd-u4sb) metadataCheck := convertWithCategory(doctor.CheckMetadataVersionTracking(path, Version), doctor.CategoryMetadata) result.Checks = append(result.Checks, metadataCheck) diff --git a/cmd/bd/doctor/database.go b/cmd/bd/doctor/database.go index 674a6c17..0ecf4492 100644 --- a/cmd/bd/doctor/database.go +++ b/cmd/bd/doctor/database.go @@ -251,6 +251,7 @@ func CheckDatabaseIntegrity(path string) DoctorCheck { Status: StatusError, Message: "Failed to open database for integrity check", Detail: err.Error(), + Fix: "Run 'bd doctor --fix' to back up the corrupt DB and rebuild from JSONL (if available), or restore from backup", } } defer db.Close() @@ -264,6 +265,7 @@ func CheckDatabaseIntegrity(path string) DoctorCheck { Status: StatusError, Message: "Failed to run integrity check", Detail: err.Error(), + Fix: "Run 'bd doctor --fix' to back up the corrupt DB and rebuild from JSONL (if available), or restore from backup", } } defer rows.Close() @@ -292,7 +294,7 @@ func CheckDatabaseIntegrity(path string) DoctorCheck { Status: StatusError, Message: "Database corruption detected", Detail: strings.Join(results, "; "), - Fix: "Database may need recovery. Export with 'bd export' if possible, then restore from backup or reinitialize", + Fix: "Run 'bd doctor --fix' to back up the corrupt DB and rebuild from JSONL (if available), or restore from backup", } } diff --git a/cmd/bd/doctor/fix/database_integrity.go b/cmd/bd/doctor/fix/database_integrity.go new file mode 100644 index 00000000..8c8e39c9 --- /dev/null +++ b/cmd/bd/doctor/fix/database_integrity.go @@ -0,0 +1,134 @@ +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 "" +} diff --git a/cmd/bd/doctor/git.go b/cmd/bd/doctor/git.go index 99687b7c..77c234a1 100644 --- a/cmd/bd/doctor/git.go +++ b/cmd/bd/doctor/git.go @@ -78,6 +78,173 @@ func CheckGitHooks() DoctorCheck { } } +// CheckGitWorkingTree checks if the git working tree is clean. +// This helps prevent leaving work stranded (AGENTS.md: keep git state clean). +func CheckGitWorkingTree(path string) DoctorCheck { + cmd := exec.Command("git", "rev-parse", "--git-dir") + cmd.Dir = path + if err := cmd.Run(); err != nil { + return DoctorCheck{ + Name: "Git Working Tree", + Status: StatusOK, + Message: "N/A (not a git repository)", + } + } + + cmd = exec.Command("git", "status", "--porcelain") + cmd.Dir = path + out, err := cmd.Output() + if err != nil { + return DoctorCheck{ + Name: "Git Working Tree", + Status: StatusWarning, + Message: "Unable to check git status", + Detail: err.Error(), + Fix: "Run 'git status' and commit/stash changes before syncing", + } + } + + status := strings.TrimSpace(string(out)) + if status == "" { + return DoctorCheck{ + Name: "Git Working Tree", + Status: StatusOK, + Message: "Clean", + } + } + + // Show a small sample of paths for quick debugging. + lines := strings.Split(status, "\n") + maxLines := 8 + if len(lines) > maxLines { + lines = append(lines[:maxLines], "…") + } + + return DoctorCheck{ + Name: "Git Working Tree", + Status: StatusWarning, + Message: "Uncommitted changes present", + Detail: strings.Join(lines, "\n"), + Fix: "Commit or stash changes, then follow AGENTS.md: git pull --rebase && git push", + } +} + +// CheckGitUpstream checks whether the current branch is up to date with its upstream. +// This catches common "forgot to pull/push" failure modes (AGENTS.md: pull --rebase, push). +func CheckGitUpstream(path string) DoctorCheck { + cmd := exec.Command("git", "rev-parse", "--git-dir") + cmd.Dir = path + if err := cmd.Run(); err != nil { + return DoctorCheck{ + Name: "Git Upstream", + Status: StatusOK, + Message: "N/A (not a git repository)", + } + } + + // Detect detached HEAD. + cmd = exec.Command("git", "symbolic-ref", "--short", "HEAD") + cmd.Dir = path + branchOut, err := cmd.Output() + if err != nil { + return DoctorCheck{ + Name: "Git Upstream", + Status: StatusWarning, + Message: "Detached HEAD (no branch)", + Fix: "Check out a branch before syncing", + } + } + branch := strings.TrimSpace(string(branchOut)) + + cmd = exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + cmd.Dir = path + upOut, err := cmd.Output() + if err != nil { + return DoctorCheck{ + Name: "Git Upstream", + Status: StatusWarning, + Message: fmt.Sprintf("No upstream configured for %s", branch), + Fix: fmt.Sprintf("Set upstream then push: git push -u origin %s", branch), + } + } + upstream := strings.TrimSpace(string(upOut)) + + ahead, aheadErr := gitRevListCount(path, "@{u}..HEAD") + behind, behindErr := gitRevListCount(path, "HEAD..@{u}") + if aheadErr != nil || behindErr != nil { + detailParts := []string{} + if aheadErr != nil { + detailParts = append(detailParts, "ahead: "+aheadErr.Error()) + } + if behindErr != nil { + detailParts = append(detailParts, "behind: "+behindErr.Error()) + } + return DoctorCheck{ + Name: "Git Upstream", + Status: StatusWarning, + Message: fmt.Sprintf("Unable to compare with upstream (%s)", upstream), + Detail: strings.Join(detailParts, "; "), + Fix: "Run 'git fetch' then check: git status -sb", + } + } + + if ahead == 0 && behind == 0 { + return DoctorCheck{ + Name: "Git Upstream", + Status: StatusOK, + Message: fmt.Sprintf("Up to date (%s)", upstream), + Detail: fmt.Sprintf("Branch: %s", branch), + } + } + + if ahead > 0 && behind == 0 { + return DoctorCheck{ + Name: "Git Upstream", + Status: StatusWarning, + Message: fmt.Sprintf("Ahead of upstream by %d commit(s)", ahead), + Detail: fmt.Sprintf("Branch: %s, upstream: %s", branch, upstream), + Fix: "Run 'git push' (AGENTS.md: git pull --rebase && git push)", + } + } + + if behind > 0 && ahead == 0 { + return DoctorCheck{ + Name: "Git Upstream", + Status: StatusWarning, + Message: fmt.Sprintf("Behind upstream by %d commit(s)", behind), + Detail: fmt.Sprintf("Branch: %s, upstream: %s", branch, upstream), + Fix: "Run 'git pull --rebase' (then re-run bd sync / bd doctor)", + } + } + + return DoctorCheck{ + Name: "Git Upstream", + Status: StatusWarning, + Message: fmt.Sprintf("Diverged from upstream (ahead %d, behind %d)", ahead, behind), + Detail: fmt.Sprintf("Branch: %s, upstream: %s", branch, upstream), + Fix: "Run 'git pull --rebase' then 'git push'", + } +} + +func gitRevListCount(path string, rangeExpr string) (int, error) { + cmd := exec.Command("git", "rev-list", "--count", rangeExpr) // #nosec G204 -- fixed args + cmd.Dir = path + out, err := cmd.Output() + if err != nil { + return 0, err + } + countStr := strings.TrimSpace(string(out)) + if countStr == "" { + return 0, nil + } + + var n int + if _, err := fmt.Sscanf(countStr, "%d", &n); err != nil { + return 0, err + } + return n, nil +} + // CheckSyncBranchHookCompatibility checks if pre-push hook is compatible with sync-branch mode. // When sync-branch is configured, the pre-push hook must have the sync-branch bypass logic // (added in version 0.29.0). Without it, users experience circular "bd sync" failures (issue #532). diff --git a/cmd/bd/doctor/git_hygiene_test.go b/cmd/bd/doctor/git_hygiene_test.go new file mode 100644 index 00000000..89c68aba --- /dev/null +++ b/cmd/bd/doctor/git_hygiene_test.go @@ -0,0 +1,172 @@ +package doctor + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func mkTmpDirInTmp(t *testing.T, prefix string) string { + t.Helper() + dir, err := os.MkdirTemp("/tmp", prefix) + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, string(out)) + } + return string(out) +} + +func initRepo(t *testing.T, dir string, branch string) { + t.Helper() + _ = os.MkdirAll(filepath.Join(dir, ".beads"), 0755) + runGit(t, dir, "init", "-b", branch) + runGit(t, dir, "config", "user.email", "test@test.com") + runGit(t, dir, "config", "user.name", "Test User") +} + +func commitFile(t *testing.T, dir, name, content, msg string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + runGit(t, dir, "add", name) + runGit(t, dir, "commit", "-m", msg) +} + +func TestCheckGitWorkingTree(t *testing.T) { + t.Run("not a git repo", func(t *testing.T) { + dir := mkTmpDirInTmp(t, "bd-git-nt-*") + check := CheckGitWorkingTree(dir) + if check.Status != StatusOK { + t.Fatalf("status=%q want %q", check.Status, StatusOK) + } + if !strings.Contains(check.Message, "N/A") { + t.Fatalf("message=%q want N/A", check.Message) + } + }) + + t.Run("clean", func(t *testing.T) { + dir := mkTmpDirInTmp(t, "bd-git-clean-*") + initRepo(t, dir, "main") + commitFile(t, dir, "README.md", "# test\n", "initial") + + check := CheckGitWorkingTree(dir) + if check.Status != StatusOK { + t.Fatalf("status=%q want %q (msg=%q)", check.Status, StatusOK, check.Message) + } + }) + + t.Run("dirty", func(t *testing.T) { + dir := mkTmpDirInTmp(t, "bd-git-dirty-*") + initRepo(t, dir, "main") + commitFile(t, dir, "README.md", "# test\n", "initial") + if err := os.WriteFile(filepath.Join(dir, "dirty.txt"), []byte("x"), 0644); err != nil { + t.Fatalf("write dirty file: %v", err) + } + + check := CheckGitWorkingTree(dir) + if check.Status != StatusWarning { + t.Fatalf("status=%q want %q (msg=%q)", check.Status, StatusWarning, check.Message) + } + }) +} + +func TestCheckGitUpstream(t *testing.T) { + t.Run("no upstream", func(t *testing.T) { + dir := mkTmpDirInTmp(t, "bd-git-up-*") + initRepo(t, dir, "main") + commitFile(t, dir, "README.md", "# test\n", "initial") + + check := CheckGitUpstream(dir) + if check.Status != StatusWarning { + t.Fatalf("status=%q want %q (msg=%q)", check.Status, StatusWarning, check.Message) + } + if !strings.Contains(check.Message, "No upstream") { + t.Fatalf("message=%q want to mention upstream", check.Message) + } + }) + + t.Run("up to date", func(t *testing.T) { + dir := mkTmpDirInTmp(t, "bd-git-up2-*") + remote := mkTmpDirInTmp(t, "bd-git-remote-*") + runGit(t, remote, "init", "--bare") + + initRepo(t, dir, "main") + commitFile(t, dir, "README.md", "# test\n", "initial") + runGit(t, dir, "remote", "add", "origin", remote) + runGit(t, dir, "push", "-u", "origin", "main") + + check := CheckGitUpstream(dir) + if check.Status != StatusOK { + t.Fatalf("status=%q want %q (msg=%q)", check.Status, StatusOK, check.Message) + } + }) + + t.Run("ahead of upstream", func(t *testing.T) { + dir := mkTmpDirInTmp(t, "bd-git-ahead-*") + remote := mkTmpDirInTmp(t, "bd-git-remote2-*") + runGit(t, remote, "init", "--bare") + + initRepo(t, dir, "main") + commitFile(t, dir, "README.md", "# test\n", "initial") + runGit(t, dir, "remote", "add", "origin", remote) + runGit(t, dir, "push", "-u", "origin", "main") + + commitFile(t, dir, "file2.txt", "x", "local commit") + + check := CheckGitUpstream(dir) + if check.Status != StatusWarning { + t.Fatalf("status=%q want %q (msg=%q)", check.Status, StatusWarning, check.Message) + } + if !strings.Contains(check.Message, "Ahead") { + t.Fatalf("message=%q want to mention ahead", check.Message) + } + }) + + t.Run("behind upstream", func(t *testing.T) { + dir := mkTmpDirInTmp(t, "bd-git-behind-*") + remote := mkTmpDirInTmp(t, "bd-git-remote3-*") + runGit(t, remote, "init", "--bare") + + initRepo(t, dir, "main") + commitFile(t, dir, "README.md", "# test\n", "initial") + runGit(t, dir, "remote", "add", "origin", remote) + runGit(t, dir, "push", "-u", "origin", "main") + + // Advance remote via another clone. + clone := mkTmpDirInTmp(t, "bd-git-clone-*") + runGit(t, clone, "clone", remote, ".") + runGit(t, clone, "config", "user.email", "test@test.com") + runGit(t, clone, "config", "user.name", "Test User") + commitFile(t, clone, "remote.txt", "y", "remote commit") + runGit(t, clone, "push", "origin", "main") + + // Update tracking refs. + runGit(t, dir, "fetch", "origin") + + check := CheckGitUpstream(dir) + if check.Status != StatusWarning { + t.Fatalf("status=%q want %q (msg=%q)", check.Status, StatusWarning, check.Message) + } + if !strings.Contains(check.Message, "Behind") { + t.Fatalf("message=%q want to mention behind", check.Message) + } + }) +} diff --git a/cmd/bd/doctor_repair_test.go b/cmd/bd/doctor_repair_test.go new file mode 100644 index 00000000..b7476bf5 --- /dev/null +++ b/cmd/bd/doctor_repair_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func buildBDForTest(t *testing.T) string { + t.Helper() + exeName := "bd" + if runtime.GOOS == "windows" { + exeName = "bd.exe" + } + + binDir := t.TempDir() + exe := filepath.Join(binDir, exeName) + cmd := exec.Command("go", "build", "-o", exe, ".") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("go build failed: %v\n%s", err, string(out)) + } + return exe +} + +func mkTmpDirInTmp(t *testing.T, prefix string) string { + t.Helper() + dir, err := os.MkdirTemp("/tmp", prefix) + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + +func runBDSideDB(t *testing.T, exe, dir, dbPath string, args ...string) (string, error) { + t.Helper() + fullArgs := []string{"--db", dbPath} + if len(args) > 0 && args[0] != "init" { + fullArgs = append(fullArgs, "--no-daemon") + } + fullArgs = append(fullArgs, args...) + + cmd := exec.Command(exe, fullArgs...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "BEADS_NO_DAEMON=1", + "BEADS_DIR="+filepath.Join(dir, ".beads"), + ) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func TestDoctorRepair_CorruptDatabase_RebuildFromJSONL(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow repair test in short mode") + } + + bdExe := buildBDForTest(t) + ws := mkTmpDirInTmp(t, "bd-doctor-repair-*") + dbPath := filepath.Join(ws, ".beads", "beads.db") + jsonlPath := filepath.Join(ws, ".beads", "issues.jsonl") + + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil { + t.Fatalf("bd init failed: %v", err) + } + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil { + t.Fatalf("bd create failed: %v", err) + } + if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil { + t.Fatalf("bd export failed: %v", err) + } + + // Corrupt the SQLite file (truncate) and verify doctor reports an integrity error. + if err := os.Truncate(dbPath, 128); err != nil { + t.Fatalf("truncate db: %v", err) + } + + out, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--json") + if err == nil { + t.Fatalf("expected bd doctor to fail on corrupt db") + } + jsonStart := strings.Index(out, "{") + if jsonStart < 0 { + t.Fatalf("doctor output missing JSON: %s", out) + } + var before doctorResult + if err := json.Unmarshal([]byte(out[jsonStart:]), &before); err != nil { + t.Fatalf("unmarshal doctor json: %v\n%s", err, out) + } + var foundIntegrity bool + for _, c := range before.Checks { + if c.Name == "Database Integrity" { + foundIntegrity = true + if c.Status != statusError { + t.Fatalf("Database Integrity status=%q want %q", c.Status, statusError) + } + } + } + if !foundIntegrity { + t.Fatalf("Database Integrity check not found") + } + + // Attempt auto-repair. + out, err = runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes") + if err != nil { + t.Fatalf("bd doctor --fix failed: %v\n%s", err, out) + } + + // Doctor should now pass. + out, err = runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--json") + if err != nil { + t.Fatalf("bd doctor after fix failed: %v\n%s", err, out) + } + jsonStart = strings.Index(out, "{") + if jsonStart < 0 { + t.Fatalf("doctor output missing JSON: %s", out) + } + var after doctorResult + if err := json.Unmarshal([]byte(out[jsonStart:]), &after); err != nil { + t.Fatalf("unmarshal doctor json: %v\n%s", err, out) + } + if !after.OverallOK { + t.Fatalf("expected overall_ok=true after repair") + } + + // Data should still be present. + out, err = runBDSideDB(t, bdExe, ws, dbPath, "list", "--json") + if err != nil { + t.Fatalf("bd list failed after repair: %v\n%s", err, out) + } + jsonStart = strings.Index(out, "[") + if jsonStart < 0 { + t.Fatalf("list output missing JSON array: %s", out) + } + var issues []map[string]any + if err := json.Unmarshal([]byte(out[jsonStart:]), &issues); err != nil { + t.Fatalf("unmarshal list json: %v\n%s", err, out) + } + if len(issues) != 1 { + t.Fatalf("expected 1 issue after repair, got %d", len(issues)) + } +} diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go index db4204e5..4e73ab3f 100644 --- a/internal/hooks/hooks_test.go +++ b/internal/hooks/hooks_test.go @@ -336,8 +336,8 @@ func TestRun_Async(t *testing.T) { outputFile := filepath.Join(tmpDir, "async_output.txt") // Create a hook that writes to a file - hookScript := `#!/bin/sh -echo "async" > ` + outputFile + hookScript := "#!/bin/sh\n" + + "echo \"async\" > \"" + outputFile + "\"\n" if err := os.WriteFile(hookPath, []byte(hookScript), 0755); err != nil { t.Fatalf("Failed to create hook file: %v", err) } @@ -348,15 +348,17 @@ echo "async" > ` + outputFile // Run should return immediately runner.Run(EventClose, issue) - // Wait for the async hook to complete with retries + // Wait for the async hook to complete with retries. + // Under high test load the goroutine scheduling + exec can be delayed. var output []byte var err error - for i := 0; i < 10; i++ { - time.Sleep(100 * time.Millisecond) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { output, err = os.ReadFile(outputFile) if err == nil { break } + time.Sleep(50 * time.Millisecond) } if err != nil { diff --git a/internal/syncbranch/worktree_sync_test.go b/internal/syncbranch/worktree_sync_test.go index 038738b9..e161b3b8 100644 --- a/internal/syncbranch/worktree_sync_test.go +++ b/internal/syncbranch/worktree_sync_test.go @@ -392,7 +392,7 @@ func setupTestRepoWithRemote(t *testing.T) string { } // Initialize git repo - runGit(t, tmpDir, "init") + runGit(t, tmpDir, "init", "-b", "master") runGit(t, tmpDir, "config", "user.email", "test@test.com") runGit(t, tmpDir, "config", "user.name", "Test User") @@ -413,4 +413,3 @@ func setupTestRepoWithRemote(t *testing.T) string { return tmpDir } -