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>
This commit is contained in:
Zain Rizvi
2026-01-16 14:01:43 -06:00
committed by GitHub
parent 493b7008a2
commit e723417168
7 changed files with 58 additions and 70 deletions

View File

@@ -108,20 +108,20 @@ var hookManagerPatterns = []hookManagerPattern{
// DetectActiveHookManager reads the git hooks to determine which manager installed them.
// This is more reliable than just checking for config files when multiple managers exist.
func DetectActiveHookManager(path string) string {
// Get git dir
cmd := exec.Command("git", "rev-parse", "--git-dir")
// Get common git dir (hooks are shared across worktrees)
cmd := exec.Command("git", "rev-parse", "--git-common-dir")
cmd.Dir = path
output, err := cmd.Output()
if err != nil {
return ""
}
gitDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(path, gitDir)
gitCommonDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitCommonDir) {
gitCommonDir = filepath.Join(path, gitCommonDir)
}
// Check for custom hooks path (core.hooksPath)
hooksDir := filepath.Join(gitDir, "hooks")
hooksDir := filepath.Join(gitCommonDir, "hooks")
hooksPathCmd := exec.Command("git", "config", "--get", "core.hooksPath")
hooksPathCmd.Dir = path
if hooksPathOutput, err := hooksPathCmd.Output(); err == nil {

View File

@@ -30,7 +30,7 @@ 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
gitDir, err := git.GetGitDir()
hooksDir, err := git.GetGitHooksDir()
if err != nil {
return DoctorCheck{
Name: "Git Hooks",
@@ -45,8 +45,6 @@ func CheckGitHooks() DoctorCheck {
"post-merge": "Imports updated JSONL after git pull/merge",
"pre-push": "Exports database to JSONL before push",
}
hooksDir := filepath.Join(gitDir, "hooks")
var missingHooks []string
var installedHooks []string
@@ -60,13 +58,7 @@ func CheckGitHooks() DoctorCheck {
}
// Get repo root for external manager detection
repoRoot := filepath.Dir(gitDir)
if filepath.Base(gitDir) != ".git" {
// Worktree case - gitDir might be .git file content
if cwd, err := os.Getwd(); err == nil {
repoRoot = cwd
}
}
repoRoot := git.GetRepoRoot()
// Check for external hook managers (lefthook, husky, etc.)
externalManagers := fix.DetectExternalHookManagers(repoRoot)
@@ -364,8 +356,8 @@ func CheckSyncBranchHookCompatibility(path string) DoctorCheck {
}
// sync-branch is configured - check pre-push hook version
// Get actual git directory (handles worktrees where .git is a file)
cmd := exec.Command("git", "rev-parse", "--git-dir")
// 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 {
@@ -375,14 +367,13 @@ func CheckSyncBranchHookCompatibility(path string) DoctorCheck {
Message: "N/A (not a git repository)",
}
}
gitDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(path, gitDir)
gitCommonDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitCommonDir) {
gitCommonDir = filepath.Join(path, gitCommonDir)
}
// Use standard .git/hooks location for consistency with CheckGitHooks (issue #799)
// Note: core.hooksPath is intentionally NOT checked here to match CheckGitHooks behavior.
hookPath := filepath.Join(gitDir, "hooks", "pre-push")
// 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 {

View File

@@ -24,12 +24,11 @@ func CheckSyncBranchQuick() string {
// 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 actual git directory (handles worktrees where .git is a file)
gitDir, err := git.GetGitDir()
// 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
}
hooksDir := filepath.Join(gitDir, "hooks")
// Check if hooks dir exists
if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
@@ -94,19 +93,19 @@ func CheckSyncBranchHookQuick(path string) string {
return "" // sync-branch not configured, nothing to check
}
// Get git directory
cmd := exec.Command("git", "rev-parse", "--git-dir")
// 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
}
gitDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(path, gitDir)
gitCommonDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitCommonDir) {
gitCommonDir = filepath.Join(path, gitCommonDir)
}
// Find pre-push hook (check shared hooks first)
// 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
@@ -117,7 +116,8 @@ func CheckSyncBranchHookQuick(path string) string {
}
hookPath = filepath.Join(sharedHooksDir, "pre-push")
} else {
hookPath = filepath.Join(gitDir, "hooks", "pre-push")
// 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