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:
Jordan Hubbard
2025-12-25 21:35:44 -04:00
parent c1fefc1bc4
commit 1184bd1e59
8 changed files with 645 additions and 10 deletions

View File

@@ -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).