Files
beads/cmd/bd/doctor/quick.go
Zain Rizvi e723417168 Fix git hooks not working in worktrees (#1126)
Git hooks are shared across all worktrees and live in the common git
directory (e.g., /repo/.git/hooks), not the worktree-specific directory
(e.g., /repo/.git/worktrees/feature/hooks).

The core issue was in GetGitHooksDir() which used GetGitDir() instead
of GetGitCommonDir(). This caused hooks to be installed to/read from
the wrong location when running in a worktree.

Additionally, several places in the codebase manually constructed
hooks paths using gitDir + "hooks" instead of calling GetGitHooksDir().
These have been updated to use the proper worktree-aware path.

Affected areas:
- GetGitHooksDir() now uses GetGitCommonDir()
- CheckGitHooks() uses GetGitHooksDir()
- installHooks/uninstallHooks use GetGitHooksDir()
- runChainedHook() uses GetGitHooksDir()
- Doctor checks use git-common-dir for hooks paths
- Reset command uses GetGitCommonDir() for hooks and beads-worktrees

Symptoms that this fixes:
- Chained hooks (pre-commit.old) not running in worktrees
- bd hooks install not finding/installing hooks correctly in worktrees
- bd hooks list showing incorrect status in worktrees
- bd doctor reporting incorrect hooks status in worktrees

Co-authored-by: Zain Rizvi <4468967+ZainRizvi@users.noreply.github.com>
2026-01-16 12:01:43 -08:00

156 lines
4.7 KiB
Go

package doctor
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/syncbranch"
)
// CheckSyncBranchQuick does a fast check for sync-branch configuration.
// Returns empty string if OK, otherwise returns issue description.
func CheckSyncBranchQuick() string {
if syncbranch.IsConfigured() {
return ""
}
return "sync-branch not configured in config.yaml"
}
// CheckHooksQuick does a fast check for outdated git hooks.
// Checks all beads hooks: pre-commit, post-merge, pre-push, post-checkout.
// cliVersion is the current CLI version to compare against.
func CheckHooksQuick(cliVersion string) string {
// Get hooks directory from common git dir (hooks are shared across worktrees)
hooksDir, err := git.GetGitHooksDir()
if err != nil {
return "" // Not a git repo, skip
}
// Check if hooks dir exists
if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
return "" // No git hooks directory, skip
}
// Check all beads-managed hooks
hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"}
var outdatedHooks []string
var oldestVersion string
for _, hookName := range hookNames {
hookPath := filepath.Join(hooksDir, hookName)
content, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled
if err != nil {
continue // Hook doesn't exist, skip (will be caught by full doctor)
}
// Look for version marker
hookContent := string(content)
if !strings.Contains(hookContent, "bd-hooks-version:") {
continue // Not a bd hook or old format, skip
}
// Extract version
for _, line := range strings.Split(hookContent, "\n") {
if strings.Contains(line, "bd-hooks-version:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
hookVersion := strings.TrimSpace(parts[1])
if hookVersion != cliVersion {
outdatedHooks = append(outdatedHooks, hookName)
// Track the oldest version for display
if oldestVersion == "" || CompareVersions(hookVersion, oldestVersion) < 0 {
oldestVersion = hookVersion
}
}
}
break
}
}
}
if len(outdatedHooks) == 0 {
return ""
}
// Return summary of outdated hooks
if len(outdatedHooks) == 1 {
return fmt.Sprintf("Git hook %s outdated (%s → %s)", outdatedHooks[0], oldestVersion, cliVersion)
}
return fmt.Sprintf("Git hooks outdated: %s (%s → %s)", strings.Join(outdatedHooks, ", "), oldestVersion, cliVersion)
}
// CheckSyncBranchHookQuick does a fast check for sync-branch hook compatibility.
// Returns empty string if OK, otherwise returns issue description.
func CheckSyncBranchHookQuick(path string) string {
// Check if sync-branch is configured
syncBranch := syncbranch.GetFromYAML()
if syncBranch == "" {
return "" // sync-branch not configured, nothing to check
}
// 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 "" // Not a git repo, skip
}
gitCommonDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitCommonDir) {
gitCommonDir = filepath.Join(path, gitCommonDir)
}
// Find pre-push hook (check shared hooks first via core.hooksPath)
var hookPath string
hooksPathCmd := exec.Command("git", "config", "--get", "core.hooksPath")
hooksPathCmd.Dir = path
if hooksPathOutput, err := hooksPathCmd.Output(); err == nil {
sharedHooksDir := strings.TrimSpace(string(hooksPathOutput))
if !filepath.IsAbs(sharedHooksDir) {
sharedHooksDir = filepath.Join(path, sharedHooksDir)
}
hookPath = filepath.Join(sharedHooksDir, "pre-push")
} else {
// Hooks are in the common git directory, not the worktree-specific one
hookPath = filepath.Join(gitCommonDir, "hooks", "pre-push")
}
content, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled
if err != nil {
return "" // No pre-push hook, covered by other checks
}
// Check if bd hook and extract version
hookStr := string(content)
if !strings.Contains(hookStr, "bd-hooks-version:") {
return "" // Not a bd hook, can't check
}
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 "" // Can't determine version
}
// Check if version < MinSyncBranchHookVersion (when sync-branch bypass was added)
if CompareVersions(hookVersion, MinSyncBranchHookVersion) < 0 {
return fmt.Sprintf("Pre-push hook (%s) incompatible with sync-branch mode (requires %s+)", hookVersion, MinSyncBranchHookVersion)
}
return ""
}