Files
beads/cmd/bd/doctor/git.go
gastown/crew/joe ca51980ffc fix(dolt): recognize shim hooks as Dolt-compatible
Shim hooks (bd-shim marker) delegate to 'bd hook' command which already
handles Dolt backend correctly. Update both doctor check and migration
warning to recognize shim hooks as OK.

Only inline hooks (older style with embedded logic) need the explicit
Dolt backend check in the shell script.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:04:06 -08:00

1081 lines
34 KiB
Go

package doctor
import (
"context"
"database/sql"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/steveyegge/beads/cmd/bd/doctor/fix"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/syncbranch"
"github.com/steveyegge/beads/internal/types"
)
const (
hooksExamplesURL = "https://github.com/steveyegge/beads/tree/main/examples/git-hooks"
hooksUpgradeURL = "https://github.com/steveyegge/beads/issues/615"
)
// bdShimMarker identifies bd shim hooks (GH#946)
const bdShimMarker = "# bd-shim"
// bdInlineHookMarker identifies inline hooks created by bd init (GH#1120)
// These hooks have the logic embedded directly rather than calling bd hooks run
const bdInlineHookMarker = "# bd (beads)"
// bdHooksRunPattern matches hooks that call bd hooks run
var bdHooksRunPattern = regexp.MustCompile(`\bbd\s+hooks\s+run\b`)
// CheckGitHooks verifies that recommended git hooks are installed.
func CheckGitHooks() DoctorCheck {
// Check if we're in a git repository using worktree-aware detection
hooksDir, err := git.GetGitHooksDir()
if err != nil {
return DoctorCheck{
Name: "Git Hooks",
Status: StatusOK,
Message: "N/A (not a git repository)",
}
}
// Recommended hooks and their purposes
recommendedHooks := map[string]string{
"pre-commit": "Flushes pending bd changes to JSONL before commit",
"post-merge": "Imports updated JSONL after git pull/merge",
"pre-push": "Exports database to JSONL before push",
}
var missingHooks []string
var installedHooks []string
for hookName := range recommendedHooks {
hookPath := filepath.Join(hooksDir, hookName)
if _, err := os.Stat(hookPath); os.IsNotExist(err) {
missingHooks = append(missingHooks, hookName)
} else {
installedHooks = append(installedHooks, hookName)
}
}
// Get repo root for external manager detection
repoRoot := git.GetRepoRoot()
// Check for external hook managers (lefthook, husky, etc.)
externalManagers := fix.DetectExternalHookManagers(repoRoot)
if len(externalManagers) > 0 {
// First, check if bd shims are installed (GH#946)
// If the actual hooks are bd shims, they're calling bd regardless of what
// the external manager config says (user may have leftover config files)
if hasBdShims, bdHooks := areBdShimsInstalled(hooksDir); hasBdShims {
return DoctorCheck{
Name: "Git Hooks",
Status: StatusOK,
Message: "bd shims installed (ignoring external manager config)",
Detail: fmt.Sprintf("bd hooks run: %s", strings.Join(bdHooks, ", ")),
}
}
// External manager detected - check if it's configured to call bd
integration := fix.CheckExternalHookManagerIntegration(repoRoot)
if integration != nil {
// Detection-only managers - we can't verify their config
if integration.DetectionOnly {
return DoctorCheck{
Name: "Git Hooks",
Status: StatusOK,
Message: fmt.Sprintf("%s detected (cannot verify bd integration)", integration.Manager),
Detail: "Ensure your hook config calls 'bd hooks run <hook>'",
}
}
if integration.Configured {
// Check if any hooks are missing bd integration
if len(integration.HooksWithoutBd) > 0 {
return DoctorCheck{
Name: "Git Hooks",
Status: StatusWarning,
Message: fmt.Sprintf("%s hooks not calling bd", integration.Manager),
Detail: fmt.Sprintf("Missing bd: %s", strings.Join(integration.HooksWithoutBd, ", ")),
Fix: "Add or upgrade to 'bd hooks run <hook>'. See " + hooksUpgradeURL,
}
}
// All hooks calling bd - success
return DoctorCheck{
Name: "Git Hooks",
Status: StatusOK,
Message: fmt.Sprintf("All hooks via %s", integration.Manager),
Detail: fmt.Sprintf("bd hooks run: %s", strings.Join(integration.HooksWithBd, ", ")),
}
}
// External manager exists but doesn't call bd at all
return DoctorCheck{
Name: "Git Hooks",
Status: StatusWarning,
Message: fmt.Sprintf("%s not calling bd", fix.ManagerNames(externalManagers)),
Detail: "Configure hooks to call bd commands",
Fix: "Add or upgrade to 'bd hooks run <hook>'. See " + hooksUpgradeURL,
}
}
}
if len(missingHooks) == 0 {
return DoctorCheck{
Name: "Git Hooks",
Status: StatusOK,
Message: "All recommended hooks installed",
Detail: fmt.Sprintf("Installed: %s", strings.Join(installedHooks, ", ")),
}
}
hookInstallMsg := "Install hooks with 'bd hooks install'. See " + hooksExamplesURL
if len(installedHooks) > 0 {
return DoctorCheck{
Name: "Git Hooks",
Status: StatusWarning,
Message: fmt.Sprintf("Missing %d recommended hook(s)", len(missingHooks)),
Detail: fmt.Sprintf("Missing: %s", strings.Join(missingHooks, ", ")),
Fix: hookInstallMsg,
}
}
return DoctorCheck{
Name: "Git Hooks",
Status: StatusWarning,
Message: "No recommended git hooks installed",
Detail: fmt.Sprintf("Recommended: %s", strings.Join([]string{"pre-commit", "post-merge", "pre-push"}, ", ")),
Fix: hookInstallMsg,
}
}
// areBdShimsInstalled checks if the installed hooks are bd shims, call bd hooks run,
// or are inline bd hooks created by bd init.
// This helps detect when bd hooks are installed directly but an external manager config exists.
// Returns (true, installedHooks) if bd hooks are detected, (false, nil) otherwise.
// (GH#946, GH#1120)
func areBdShimsInstalled(hooksDir string) (bool, []string) {
hooks := []string{"pre-commit", "post-merge", "pre-push"}
var bdHooks []string
for _, hookName := range hooks {
hookPath := filepath.Join(hooksDir, hookName)
content, err := os.ReadFile(hookPath)
if err != nil {
continue
}
contentStr := string(content)
// Check for bd-shim marker, bd hooks run call, or inline bd hook marker (from bd init)
if strings.Contains(contentStr, bdShimMarker) ||
strings.Contains(contentStr, bdInlineHookMarker) ||
bdHooksRunPattern.MatchString(contentStr) {
bdHooks = append(bdHooks, hookName)
}
}
return len(bdHooks) > 0, bdHooks
}
// 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).
func CheckSyncBranchHookCompatibility(path string) DoctorCheck {
// Check if sync-branch is configured
syncBranch := syncbranch.GetFromYAML()
if syncBranch == "" {
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusOK,
Message: "N/A (sync-branch not configured)",
}
}
// sync-branch is configured - check pre-push hook version
// Get common git directory for hooks (shared across worktrees)
cmd := exec.Command("git", "rev-parse", "--git-common-dir")
cmd.Dir = path
output, err := cmd.Output()
if err != nil {
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusOK,
Message: "N/A (not a git repository)",
}
}
gitCommonDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitCommonDir) {
gitCommonDir = filepath.Join(path, gitCommonDir)
}
// Hooks are shared across worktrees and live in the common git directory
hookPath := filepath.Join(gitCommonDir, "hooks", "pre-push")
hookContent, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled
if err != nil {
// No pre-push hook installed - different issue, covered by checkGitHooks
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusOK,
Message: "N/A (no pre-push hook installed)",
}
}
// Check if this is a bd hook and extract version
hookStr := string(hookContent)
if !strings.Contains(hookStr, "bd-hooks-version:") {
// Not a bd hook - check if it's an external hook manager
externalManagers := fix.DetectExternalHookManagers(path)
if len(externalManagers) > 0 {
names := fix.ManagerNames(externalManagers)
// Check if external manager has bd integration
integration := fix.CheckExternalHookManagerIntegration(path)
if integration != nil {
// Detection-only managers - we can't verify their config
if integration.DetectionOnly {
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusOK,
Message: fmt.Sprintf("Managed by %s (cannot verify bd integration)", names),
Detail: "Ensure pre-push hook calls 'bd hooks run pre-push' for sync-branch",
}
}
if integration.Configured {
// Has bd integration - check if pre-push is covered
hasPrepush := false
for _, h := range integration.HooksWithBd {
if h == "pre-push" {
hasPrepush = true
break
}
}
if hasPrepush {
var detail string
// Only report hooks that ARE in config but lack bd integration
if len(integration.HooksWithoutBd) > 0 {
detail = fmt.Sprintf("Hooks without bd: %s", strings.Join(integration.HooksWithoutBd, ", "))
}
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusOK,
Message: fmt.Sprintf("Managed by %s with bd integration", integration.Manager),
Detail: detail,
}
}
// Has bd integration but missing pre-push
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusWarning,
Message: fmt.Sprintf("Managed by %s (missing pre-push bd integration)", integration.Manager),
Detail: "pre-push hook needs 'bd hooks run pre-push' for sync-branch",
Fix: fmt.Sprintf("Add or upgrade to 'bd hooks run pre-push' in %s. See %s", integration.Manager, hooksExamplesURL),
}
}
}
// External manager detected but no bd integration found
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusWarning,
Message: fmt.Sprintf("Managed by %s (no bd integration detected)", names),
Detail: fmt.Sprintf("Pre-push hook managed by %s but no 'bd hooks run' found", names),
Fix: fmt.Sprintf("Add or upgrade to 'bd hooks run <hook>' in %s. See %s", names, hooksExamplesURL),
}
}
// No external manager - truly custom hook
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusWarning,
Message: "Pre-push hook is not a bd hook",
Detail: "Cannot verify sync-branch compatibility with custom hooks",
Fix: "Either run 'bd hooks install --force' to use bd hooks,\n" +
" or ensure your custom hook skips validation when pushing to sync-branch",
}
}
// Extract version from hook
var hookVersion string
for _, line := range strings.Split(hookStr, "\n") {
if strings.Contains(line, "bd-hooks-version:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
hookVersion = strings.TrimSpace(parts[1])
}
break
}
}
if hookVersion == "" {
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusWarning,
Message: "Could not determine pre-push hook version",
Detail: "Cannot verify sync-branch compatibility",
Fix: "Run 'bd hooks install --force' to update hooks",
}
}
// MinSyncBranchHookVersion added sync-branch bypass logic
// If hook version < MinSyncBranchHookVersion, it will cause circular "bd sync" failures
if CompareVersions(hookVersion, MinSyncBranchHookVersion) < 0 {
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusError,
Message: fmt.Sprintf("Pre-push hook incompatible with sync-branch mode (version %s)", hookVersion),
Detail: fmt.Sprintf("Hook version %s lacks sync-branch bypass (requires %s+). This causes circular 'bd sync' failures during push.", hookVersion, MinSyncBranchHookVersion),
Fix: "Run 'bd hooks install --force' to update hooks",
}
}
return DoctorCheck{
Name: "Sync Branch Hook Compatibility",
Status: StatusOK,
Message: fmt.Sprintf("Pre-push hook compatible with sync-branch (version %s)", hookVersion),
}
}
// CheckMergeDriver verifies that the git merge driver is correctly configured.
func CheckMergeDriver(path string) DoctorCheck {
// Check if we're in a git repository using worktree-aware detection
_, err := git.GetGitDir()
if err != nil {
return DoctorCheck{
Name: "Git Merge Driver",
Status: StatusOK,
Message: "N/A (not a git repository)",
}
}
// Get current merge driver configuration
cmd := exec.Command("git", "config", "merge.beads.driver")
cmd.Dir = path
output, err := cmd.Output()
if err != nil {
// Merge driver not configured
return DoctorCheck{
Name: "Git Merge Driver",
Status: StatusWarning,
Message: "Git merge driver not configured",
Fix: "Run 'bd init' to configure the merge driver, or manually: git config merge.beads.driver \"bd merge %A %O %A %B\"",
}
}
currentConfig := strings.TrimSpace(string(output))
correctConfig := "bd merge %A %O %A %B"
// Check if using old incorrect placeholders
if strings.Contains(currentConfig, "%L") || strings.Contains(currentConfig, "%R") {
return DoctorCheck{
Name: "Git Merge Driver",
Status: StatusError,
Message: fmt.Sprintf("Incorrect merge driver config: %q (uses invalid %%L/%%R placeholders)", currentConfig),
Detail: "Git only supports %O (base), %A (current), %B (other). Using %L/%R causes merge failures.",
Fix: "Run 'bd doctor --fix' to update to correct config, or manually: git config merge.beads.driver \"bd merge %A %O %A %B\"",
}
}
// Check if config is correct
if currentConfig != correctConfig {
return DoctorCheck{
Name: "Git Merge Driver",
Status: StatusWarning,
Message: fmt.Sprintf("Non-standard merge driver config: %q", currentConfig),
Detail: fmt.Sprintf("Expected: %q", correctConfig),
Fix: fmt.Sprintf("Run 'bd doctor --fix' to update config, or manually: git config merge.beads.driver \"%s\"", correctConfig),
}
}
return DoctorCheck{
Name: "Git Merge Driver",
Status: StatusOK,
Message: "Correctly configured",
Detail: currentConfig,
}
}
// CheckSyncBranchConfig checks if sync-branch is properly configured.
func CheckSyncBranchConfig(path string) DoctorCheck {
// Follow redirect to resolve actual beads directory (bd-tvus fix)
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
// Skip if .beads doesn't exist
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return DoctorCheck{
Name: "Sync Branch Config",
Status: StatusOK,
Message: "N/A (no .beads directory)",
}
}
// Check if we're in a git repository using worktree-aware detection
_, err := git.GetGitDir()
if err != nil {
return DoctorCheck{
Name: "Sync Branch Config",
Status: StatusOK,
Message: "N/A (not a git repository)",
}
}
// Check sync-branch from config.yaml or environment variable
// This is the source of truth for multi-clone setups
syncBranch := syncbranch.GetFromYAML()
// Get current branch
currentBranch := ""
cmd := exec.Command("git", "symbolic-ref", "--short", "HEAD")
cmd.Dir = path
if output, err := cmd.Output(); err == nil {
currentBranch = strings.TrimSpace(string(output))
}
// CRITICAL: Check if we're on the sync branch - this is a misconfiguration
// that will cause bd sync to fail trying to create a worktree for a branch
// that's already checked out
if syncBranch != "" && currentBranch == syncBranch {
return DoctorCheck{
Name: "Sync Branch Config",
Status: StatusError,
Message: fmt.Sprintf("On sync branch '%s'", syncBranch),
Detail: fmt.Sprintf("Currently on branch '%s' which is configured as the sync branch. bd sync cannot create a worktree for a branch that's already checked out.", syncBranch),
Fix: "Switch to your main working branch: git checkout main",
}
}
if syncBranch != "" {
return DoctorCheck{
Name: "Sync Branch Config",
Status: StatusOK,
Message: fmt.Sprintf("Configured (%s)", syncBranch),
Detail: fmt.Sprintf("Current branch: %s, sync branch: %s", currentBranch, syncBranch),
}
}
// Not configured - this is optional but recommended for multi-clone setups
// Check if this looks like a multi-clone setup (has remote)
hasRemote := false
cmd = exec.Command("git", "remote")
cmd.Dir = path
if output, err := cmd.Output(); err == nil && len(strings.TrimSpace(string(output))) > 0 {
hasRemote = true
}
if hasRemote {
return DoctorCheck{
Name: "Sync Branch Config",
Status: StatusWarning,
Message: "sync-branch not configured",
Detail: "Multi-clone setups should configure sync-branch for safe data synchronization",
Fix: "Run 'bd migrate sync beads-sync' to set up sync branch workflow",
}
}
// No remote - probably a local-only repo, sync-branch not needed
return DoctorCheck{
Name: "Sync Branch Config",
Status: StatusOK,
Message: "N/A (no remote configured)",
}
}
// CheckSyncBranchHealth detects when the sync branch has diverged from main
// or from the remote sync branch (after a force-push reset).
func CheckSyncBranchHealth(path string) DoctorCheck {
// Skip if not in a git repo using worktree-aware detection
_, err := git.GetGitDir()
if err != nil {
return DoctorCheck{
Name: "Sync Branch Health",
Status: StatusOK,
Message: "N/A (not a git repository)",
}
}
// Get configured sync branch
syncBranch := syncbranch.GetFromYAML()
if syncBranch == "" {
return DoctorCheck{
Name: "Sync Branch Health",
Status: StatusOK,
Message: "N/A (no sync branch configured)",
}
}
// Check if local sync branch exists
cmd := exec.Command("git", "rev-parse", "--verify", syncBranch) // #nosec G204 - syncBranch from config file
cmd.Dir = path
if err := cmd.Run(); err != nil {
// Local branch doesn't exist - that's fine, bd sync will create it
return DoctorCheck{
Name: "Sync Branch Health",
Status: StatusOK,
Message: fmt.Sprintf("N/A (local %s branch not created yet)", syncBranch),
}
}
// Check if remote sync branch exists
remote := "origin"
remoteBranch := fmt.Sprintf("%s/%s", remote, syncBranch)
cmd = exec.Command("git", "rev-parse", "--verify", remoteBranch) // #nosec G204 - remoteBranch from config
cmd.Dir = path
if err := cmd.Run(); err != nil {
// Remote branch doesn't exist - that's fine
return DoctorCheck{
Name: "Sync Branch Health",
Status: StatusOK,
Message: fmt.Sprintf("N/A (remote %s not found)", remoteBranch),
}
}
// Check 1: Is local sync branch diverged from remote? (after force-push)
// If they have no common ancestor in recent history, the remote was likely force-pushed
cmd = exec.Command("git", "merge-base", syncBranch, remoteBranch) // #nosec G204 - branches from config
cmd.Dir = path
mergeBaseOutput, err := cmd.Output()
if err != nil {
// No common ancestor - branches have completely diverged
return DoctorCheck{
Name: "Sync Branch Health",
Status: StatusWarning,
Message: fmt.Sprintf("Local %s diverged from remote", syncBranch),
Detail: "The remote sync branch was likely reset/force-pushed. Your local branch has orphaned history.",
Fix: "Run 'bd doctor --fix' to reset sync branch",
}
}
// Check if local is behind remote (needs to fast-forward)
mergeBase := strings.TrimSpace(string(mergeBaseOutput))
cmd = exec.Command("git", "rev-parse", syncBranch) // #nosec G204 - syncBranch from config
cmd.Dir = path
localHead, _ := cmd.Output()
localHeadStr := strings.TrimSpace(string(localHead))
cmd = exec.Command("git", "rev-parse", remoteBranch) // #nosec G204 - remoteBranch from config
cmd.Dir = path
remoteHead, _ := cmd.Output()
remoteHeadStr := strings.TrimSpace(string(remoteHead))
// If merge base equals local but not remote, local is behind
if mergeBase == localHeadStr && mergeBase != remoteHeadStr {
// Count how far behind
cmd = exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..%s", syncBranch, remoteBranch)) // #nosec G204 - branches from config
cmd.Dir = path
countOutput, _ := cmd.Output()
behindCount := strings.TrimSpace(string(countOutput))
return DoctorCheck{
Name: "Sync Branch Health",
Status: StatusOK,
Message: fmt.Sprintf("Local %s is %s commits behind remote (will sync)", syncBranch, behindCount),
}
}
// Note: We intentionally do NOT check if sync branch differs from main on source files.
// The sync branch only tracks .beads/ data - source file differences are expected behavior.
// See GH#1062 for why the previous check was removed (it caused destructive --fix behavior).
return DoctorCheck{
Name: "Sync Branch Health",
Status: StatusOK,
Message: "OK",
}
}
// CheckGitHooksDoltCompatibility checks if installed git hooks are compatible with Dolt backend.
// Hooks installed before Dolt support was added don't have the backend check and will
// fail with confusing errors on git pull/commit.
func CheckGitHooksDoltCompatibility(path string) DoctorCheck {
backend, beadsDir := getBackendAndBeadsDir(path)
// Only relevant for Dolt backend
if backend != configfile.BackendDolt {
return DoctorCheck{
Name: "Git Hooks Dolt Compatibility",
Status: StatusOK,
Message: "N/A (not using Dolt backend)",
}
}
// Check if we're in a git repository
hooksDir, err := git.GetGitHooksDir()
if err != nil {
return DoctorCheck{
Name: "Git Hooks Dolt Compatibility",
Status: StatusOK,
Message: "N/A (not a git repository)",
}
}
// Check post-merge hook (most likely to cause issues with Dolt)
postMergePath := filepath.Join(hooksDir, "post-merge")
content, err := os.ReadFile(postMergePath)
if err != nil {
// No hook installed - that's fine
return DoctorCheck{
Name: "Git Hooks Dolt Compatibility",
Status: StatusOK,
Message: "N/A (no post-merge hook installed)",
}
}
contentStr := string(content)
// Shim hooks (bd-shim) delegate to 'bd hook' which handles Dolt correctly
if strings.Contains(contentStr, bdShimMarker) {
return DoctorCheck{
Name: "Git Hooks Dolt Compatibility",
Status: StatusOK,
Message: "Shim hooks (Dolt handled by bd hook command)",
}
}
// Check if it's a bd inline hook
if !strings.Contains(contentStr, bdInlineHookMarker) && !strings.Contains(contentStr, "bd") {
return DoctorCheck{
Name: "Git Hooks Dolt Compatibility",
Status: StatusOK,
Message: "N/A (not a bd hook)",
}
}
// Check if inline hook has the Dolt backend skip logic
if strings.Contains(contentStr, `"backend"`) && strings.Contains(contentStr, `"dolt"`) {
return DoctorCheck{
Name: "Git Hooks Dolt Compatibility",
Status: StatusOK,
Message: "Inline hooks have Dolt backend check",
}
}
// Hook exists but lacks Dolt check - this will cause errors
_ = beadsDir // silence unused warning
return DoctorCheck{
Name: "Git Hooks Dolt Compatibility",
Status: StatusError,
Message: "Git hooks incompatible with Dolt backend",
Detail: "Installed hooks attempt JSONL sync which fails with Dolt. This causes errors on git pull/commit.",
Fix: "Run 'bd hooks install --force' to update hooks for Dolt compatibility",
}
}
// FixGitHooks fixes missing or broken git hooks by calling bd hooks install.
func FixGitHooks(path string) error {
return fix.GitHooks(path)
}
// FixMergeDriver fixes the git merge driver configuration to use correct placeholders.
func FixMergeDriver(path string) error {
return fix.MergeDriver(path)
}
// FixSyncBranchHealth fixes database-JSONL sync issues.
func FixSyncBranchHealth(path string) error {
return fix.DBJSONLSync(path)
}
// FindOrphanedIssues identifies issues referenced in git commits but still open in the database.
// This is the shared core logic used by both 'bd orphans' and 'bd doctor' commands.
// Returns empty slice if not a git repo, no issues from provider, or no orphans found (no error).
//
// Parameters:
// - gitPath: The directory to scan for git commits
// - provider: The issue provider to get open issues and prefix from
func FindOrphanedIssues(gitPath string, provider types.IssueProvider) ([]OrphanIssue, error) {
// Skip if not in a git repo
cmd := exec.Command("git", "rev-parse", "--git-dir")
cmd.Dir = gitPath
if err := cmd.Run(); err != nil {
return []OrphanIssue{}, nil // Not a git repo, return empty list
}
// Get issue prefix from provider
issuePrefix := provider.GetIssuePrefix()
// Get all open/in_progress issues from provider
ctx := context.Background()
issues, err := provider.GetOpenIssues(ctx)
if err != nil {
return []OrphanIssue{}, nil
}
openIssues := make(map[string]*OrphanIssue)
for _, issue := range issues {
openIssues[issue.ID] = &OrphanIssue{
IssueID: issue.ID,
Title: issue.Title,
Status: string(issue.Status),
}
}
if len(openIssues) == 0 {
return []OrphanIssue{}, nil
}
// Get git log
cmd = exec.Command("git", "log", "--oneline", "--all")
cmd.Dir = gitPath
output, err := cmd.Output()
if err != nil {
return []OrphanIssue{}, nil
}
// Parse commits for issue references
// Match pattern like (bd-xxx) or (bd-xxx.1) including hierarchical IDs
pattern := fmt.Sprintf(`\(%s-[a-z0-9.]+\)`, regexp.QuoteMeta(issuePrefix))
re := regexp.MustCompile(pattern)
var orphanedIssues []OrphanIssue
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if line == "" {
continue
}
// Extract commit hash and message
parts := strings.SplitN(line, " ", 2)
if len(parts) < 1 {
continue
}
commitHash := parts[0]
commitMsg := ""
if len(parts) > 1 {
commitMsg = parts[1]
}
// Find issue IDs in this commit
matches := re.FindAllString(line, -1)
for _, match := range matches {
issueID := strings.Trim(match, "()")
if orphan, exists := openIssues[issueID]; exists {
// Only record first (most recent) commit per issue
if orphan.LatestCommit == "" {
orphan.LatestCommit = commitHash
orphan.LatestCommitMessage = commitMsg
}
}
}
}
// Collect issues with commit references
for _, orphan := range openIssues {
if orphan.LatestCommit != "" {
orphanedIssues = append(orphanedIssues, *orphan)
}
}
return orphanedIssues, nil
}
// FindOrphanedIssuesFromPath is a convenience function for callers that don't have a provider.
// It creates a local provider from the given path's .beads/ directory.
// This preserves backward compatibility for CheckOrphanedIssues and similar callers.
func FindOrphanedIssuesFromPath(path string) ([]OrphanIssue, error) {
// Follow redirect to resolve actual beads directory (bd-tvus fix)
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
// Skip if no .beads directory
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return []OrphanIssue{}, nil
}
// Get database path
dbPath := filepath.Join(beadsDir, "beads.db")
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return []OrphanIssue{}, nil
}
// Create a local provider from the database
provider, err := storage.NewLocalProvider(dbPath)
if err != nil {
return []OrphanIssue{}, nil
}
defer func() { _ = provider.Close() }()
return FindOrphanedIssues(path, provider)
}
// CheckOrphanedIssues detects issues referenced in git commits but still open.
// This catches cases where someone implemented a fix with "(bd-xxx)" in the commit
// message but forgot to run "bd close".
func CheckOrphanedIssues(path string) DoctorCheck {
// Skip if not in a git repo (check from path directory)
cmd := exec.Command("git", "rev-parse", "--git-dir")
cmd.Dir = path
if err := cmd.Run(); err != nil {
return DoctorCheck{
Name: "Orphaned Issues",
Status: StatusOK,
Message: "N/A (not a git repository)",
Category: CategoryGit,
}
}
// Follow redirect to resolve actual beads directory (bd-tvus fix)
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
// Skip if no .beads directory
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return DoctorCheck{
Name: "Orphaned Issues",
Status: StatusOK,
Message: "N/A (no .beads directory)",
Category: CategoryGit,
}
}
// Get database path from config or use canonical name
dbPath := filepath.Join(beadsDir, "beads.db")
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return DoctorCheck{
Name: "Orphaned Issues",
Status: StatusOK,
Message: "N/A (no database)",
Category: CategoryGit,
}
}
// Use the shared FindOrphanedIssuesFromPath function (creates its own provider)
orphans, err := FindOrphanedIssuesFromPath(path)
if err != nil {
return DoctorCheck{
Name: "Orphaned Issues",
Status: StatusOK,
Message: "N/A (unable to check orphaned issues)",
Category: CategoryGit,
}
}
// Check for "no open issues" case - this requires checking the database
// since FindOrphanedIssues silently returns empty slice
db, err := openDBReadOnly(dbPath)
if err == nil {
defer db.Close()
rows, err := db.Query("SELECT COUNT(*) FROM issues WHERE status IN ('open', 'in_progress')")
if err == nil {
defer rows.Close()
if rows.Next() {
var count int
if err := rows.Scan(&count); err == nil && count == 0 {
return DoctorCheck{
Name: "Orphaned Issues",
Status: StatusOK,
Message: "No open issues to check",
Category: CategoryGit,
}
}
}
}
}
if len(orphans) == 0 {
return DoctorCheck{
Name: "Orphaned Issues",
Status: StatusOK,
Message: "No issues referenced in commits but still open",
Category: CategoryGit,
}
}
// Build detail message
var details []string
for _, orphan := range orphans {
details = append(details, fmt.Sprintf("%s (commit %s)", orphan.IssueID, orphan.LatestCommit))
}
return DoctorCheck{
Name: "Orphaned Issues",
Status: StatusWarning,
Message: fmt.Sprintf("%d issue(s) referenced in commits but still open", len(orphans)),
Detail: strings.Join(details, ", "),
Fix: "Run 'bd show <id>' to check if implemented, then 'bd close <id>' if done",
Category: CategoryGit,
}
}
// openDBReadOnly opens a SQLite database in read-only mode
func openDBReadOnly(dbPath string) (*sql.DB, error) {
return sql.Open("sqlite3", sqliteConnString(dbPath, true))
}