doctor: add git hygiene checks and DB integrity auto-fix
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
@@ -373,6 +373,8 @@ func applyFixList(path string, fixes []doctorCheck) {
|
|||||||
err = fix.Permissions(path)
|
err = fix.Permissions(path)
|
||||||
case "Database":
|
case "Database":
|
||||||
err = fix.DatabaseVersion(path)
|
err = fix.DatabaseVersion(path)
|
||||||
|
case "Database Integrity":
|
||||||
|
err = fix.DatabaseIntegrity(path)
|
||||||
case "Schema Compatibility":
|
case "Schema Compatibility":
|
||||||
err = fix.SchemaCompatibility(path)
|
err = fix.SchemaCompatibility(path)
|
||||||
case "Repo Fingerprint":
|
case "Repo Fingerprint":
|
||||||
@@ -750,6 +752,16 @@ func runDiagnostics(path string) doctorResult {
|
|||||||
result.Checks = append(result.Checks, mergeDriverCheck)
|
result.Checks = append(result.Checks, mergeDriverCheck)
|
||||||
// Don't fail overall check for merge driver, just warn
|
// 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)
|
// Check 16: Metadata.json version tracking (bd-u4sb)
|
||||||
metadataCheck := convertWithCategory(doctor.CheckMetadataVersionTracking(path, Version), doctor.CategoryMetadata)
|
metadataCheck := convertWithCategory(doctor.CheckMetadataVersionTracking(path, Version), doctor.CategoryMetadata)
|
||||||
result.Checks = append(result.Checks, metadataCheck)
|
result.Checks = append(result.Checks, metadataCheck)
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ func CheckDatabaseIntegrity(path string) DoctorCheck {
|
|||||||
Status: StatusError,
|
Status: StatusError,
|
||||||
Message: "Failed to open database for integrity check",
|
Message: "Failed to open database for integrity check",
|
||||||
Detail: err.Error(),
|
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()
|
defer db.Close()
|
||||||
@@ -264,6 +265,7 @@ func CheckDatabaseIntegrity(path string) DoctorCheck {
|
|||||||
Status: StatusError,
|
Status: StatusError,
|
||||||
Message: "Failed to run integrity check",
|
Message: "Failed to run integrity check",
|
||||||
Detail: err.Error(),
|
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()
|
defer rows.Close()
|
||||||
@@ -292,7 +294,7 @@ func CheckDatabaseIntegrity(path string) DoctorCheck {
|
|||||||
Status: StatusError,
|
Status: StatusError,
|
||||||
Message: "Database corruption detected",
|
Message: "Database corruption detected",
|
||||||
Detail: strings.Join(results, "; "),
|
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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
134
cmd/bd/doctor/fix/database_integrity.go
Normal file
134
cmd/bd/doctor/fix/database_integrity.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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.
|
// 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
|
// 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).
|
// (added in version 0.29.0). Without it, users experience circular "bd sync" failures (issue #532).
|
||||||
|
|||||||
172
cmd/bd/doctor/git_hygiene_test.go
Normal file
172
cmd/bd/doctor/git_hygiene_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
147
cmd/bd/doctor_repair_test.go
Normal file
147
cmd/bd/doctor_repair_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -336,8 +336,8 @@ func TestRun_Async(t *testing.T) {
|
|||||||
outputFile := filepath.Join(tmpDir, "async_output.txt")
|
outputFile := filepath.Join(tmpDir, "async_output.txt")
|
||||||
|
|
||||||
// Create a hook that writes to a file
|
// Create a hook that writes to a file
|
||||||
hookScript := `#!/bin/sh
|
hookScript := "#!/bin/sh\n" +
|
||||||
echo "async" > ` + outputFile
|
"echo \"async\" > \"" + outputFile + "\"\n"
|
||||||
if err := os.WriteFile(hookPath, []byte(hookScript), 0755); err != nil {
|
if err := os.WriteFile(hookPath, []byte(hookScript), 0755); err != nil {
|
||||||
t.Fatalf("Failed to create hook file: %v", err)
|
t.Fatalf("Failed to create hook file: %v", err)
|
||||||
}
|
}
|
||||||
@@ -348,15 +348,17 @@ echo "async" > ` + outputFile
|
|||||||
// Run should return immediately
|
// Run should return immediately
|
||||||
runner.Run(EventClose, issue)
|
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 output []byte
|
||||||
var err error
|
var err error
|
||||||
for i := 0; i < 10; i++ {
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
time.Sleep(100 * time.Millisecond)
|
for time.Now().Before(deadline) {
|
||||||
output, err = os.ReadFile(outputFile)
|
output, err = os.ReadFile(outputFile)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ func setupTestRepoWithRemote(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize git repo
|
// 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.email", "test@test.com")
|
||||||
runGit(t, tmpDir, "config", "user.name", "Test User")
|
runGit(t, tmpDir, "config", "user.name", "Test User")
|
||||||
|
|
||||||
@@ -413,4 +413,3 @@ func setupTestRepoWithRemote(t *testing.T) string {
|
|||||||
|
|
||||||
return tmpDir
|
return tmpDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user