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. // 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. // This is more reliable than just checking for config files when multiple managers exist.
func DetectActiveHookManager(path string) string { func DetectActiveHookManager(path string) string {
// Get git dir // Get common git dir (hooks are shared across worktrees)
cmd := exec.Command("git", "rev-parse", "--git-dir") cmd := exec.Command("git", "rev-parse", "--git-common-dir")
cmd.Dir = path cmd.Dir = path
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return "" return ""
} }
gitDir := strings.TrimSpace(string(output)) gitCommonDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitDir) { if !filepath.IsAbs(gitCommonDir) {
gitDir = filepath.Join(path, gitDir) gitCommonDir = filepath.Join(path, gitCommonDir)
} }
// Check for custom hooks path (core.hooksPath) // 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 := exec.Command("git", "config", "--get", "core.hooksPath")
hooksPathCmd.Dir = path hooksPathCmd.Dir = path
if hooksPathOutput, err := hooksPathCmd.Output(); err == nil { 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. // CheckGitHooks verifies that recommended git hooks are installed.
func CheckGitHooks() DoctorCheck { func CheckGitHooks() DoctorCheck {
// Check if we're in a git repository using worktree-aware detection // Check if we're in a git repository using worktree-aware detection
gitDir, err := git.GetGitDir() hooksDir, err := git.GetGitHooksDir()
if err != nil { if err != nil {
return DoctorCheck{ return DoctorCheck{
Name: "Git Hooks", Name: "Git Hooks",
@@ -45,8 +45,6 @@ func CheckGitHooks() DoctorCheck {
"post-merge": "Imports updated JSONL after git pull/merge", "post-merge": "Imports updated JSONL after git pull/merge",
"pre-push": "Exports database to JSONL before push", "pre-push": "Exports database to JSONL before push",
} }
hooksDir := filepath.Join(gitDir, "hooks")
var missingHooks []string var missingHooks []string
var installedHooks []string var installedHooks []string
@@ -60,13 +58,7 @@ func CheckGitHooks() DoctorCheck {
} }
// Get repo root for external manager detection // Get repo root for external manager detection
repoRoot := filepath.Dir(gitDir) repoRoot := git.GetRepoRoot()
if filepath.Base(gitDir) != ".git" {
// Worktree case - gitDir might be .git file content
if cwd, err := os.Getwd(); err == nil {
repoRoot = cwd
}
}
// Check for external hook managers (lefthook, husky, etc.) // Check for external hook managers (lefthook, husky, etc.)
externalManagers := fix.DetectExternalHookManagers(repoRoot) externalManagers := fix.DetectExternalHookManagers(repoRoot)
@@ -364,8 +356,8 @@ func CheckSyncBranchHookCompatibility(path string) DoctorCheck {
} }
// sync-branch is configured - check pre-push hook version // sync-branch is configured - check pre-push hook version
// Get actual git directory (handles worktrees where .git is a file) // Get common git directory for hooks (shared across worktrees)
cmd := exec.Command("git", "rev-parse", "--git-dir") cmd := exec.Command("git", "rev-parse", "--git-common-dir")
cmd.Dir = path cmd.Dir = path
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
@@ -375,14 +367,13 @@ func CheckSyncBranchHookCompatibility(path string) DoctorCheck {
Message: "N/A (not a git repository)", Message: "N/A (not a git repository)",
} }
} }
gitDir := strings.TrimSpace(string(output)) gitCommonDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitDir) { if !filepath.IsAbs(gitCommonDir) {
gitDir = filepath.Join(path, gitDir) gitCommonDir = filepath.Join(path, gitCommonDir)
} }
// Use standard .git/hooks location for consistency with CheckGitHooks (issue #799) // Hooks are shared across worktrees and live in the common git directory
// Note: core.hooksPath is intentionally NOT checked here to match CheckGitHooks behavior. hookPath := filepath.Join(gitCommonDir, "hooks", "pre-push")
hookPath := filepath.Join(gitDir, "hooks", "pre-push")
hookContent, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled hookContent, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled
if err != nil { if err != nil {

View File

@@ -24,12 +24,11 @@ func CheckSyncBranchQuick() string {
// Checks all beads hooks: pre-commit, post-merge, pre-push, post-checkout. // Checks all beads hooks: pre-commit, post-merge, pre-push, post-checkout.
// cliVersion is the current CLI version to compare against. // cliVersion is the current CLI version to compare against.
func CheckHooksQuick(cliVersion string) string { func CheckHooksQuick(cliVersion string) string {
// Get actual git directory (handles worktrees where .git is a file) // Get hooks directory from common git dir (hooks are shared across worktrees)
gitDir, err := git.GetGitDir() hooksDir, err := git.GetGitHooksDir()
if err != nil { if err != nil {
return "" // Not a git repo, skip return "" // Not a git repo, skip
} }
hooksDir := filepath.Join(gitDir, "hooks")
// Check if hooks dir exists // Check if hooks dir exists
if _, err := os.Stat(hooksDir); os.IsNotExist(err) { 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 return "" // sync-branch not configured, nothing to check
} }
// Get git directory // Get common git directory for hooks (shared across worktrees)
cmd := exec.Command("git", "rev-parse", "--git-dir") cmd := exec.Command("git", "rev-parse", "--git-common-dir")
cmd.Dir = path cmd.Dir = path
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return "" // Not a git repo, skip return "" // Not a git repo, skip
} }
gitDir := strings.TrimSpace(string(output)) gitCommonDir := strings.TrimSpace(string(output))
if !filepath.IsAbs(gitDir) { if !filepath.IsAbs(gitCommonDir) {
gitDir = filepath.Join(path, gitDir) 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 var hookPath string
hooksPathCmd := exec.Command("git", "config", "--get", "core.hooksPath") hooksPathCmd := exec.Command("git", "config", "--get", "core.hooksPath")
hooksPathCmd.Dir = path hooksPathCmd.Dir = path
@@ -117,7 +116,8 @@ func CheckSyncBranchHookQuick(path string) string {
} }
hookPath = filepath.Join(sharedHooksDir, "pre-push") hookPath = filepath.Join(sharedHooksDir, "pre-push")
} else { } 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 content, err := os.ReadFile(hookPath) // #nosec G304 - path is controlled

View File

@@ -51,8 +51,8 @@ func CheckGitHooks() []HookStatus {
hooks := []string{"pre-commit", "post-merge", "pre-push", "post-checkout", "prepare-commit-msg"} hooks := []string{"pre-commit", "post-merge", "pre-push", "post-checkout", "prepare-commit-msg"}
statuses := make([]HookStatus, 0, len(hooks)) statuses := make([]HookStatus, 0, len(hooks))
// Get actual git directory (handles worktrees) // Get hooks directory from common git dir (hooks are shared across worktrees)
gitDir, err := git.GetGitDir() hooksDir, err := git.GetGitHooksDir()
if err != nil { if err != nil {
// Not a git repo - return all hooks as not installed // Not a git repo - return all hooks as not installed
for _, hookName := range hooks { for _, hookName := range hooks {
@@ -67,7 +67,7 @@ func CheckGitHooks() []HookStatus {
} }
// Check if hook exists // Check if hook exists
hookPath := filepath.Join(gitDir, "hooks", hookName) hookPath := filepath.Join(hooksDir, hookName)
versionInfo, err := getHookVersion(hookPath) versionInfo, err := getHookVersion(hookPath)
if err != nil { if err != nil {
// Hook doesn't exist or couldn't be read // Hook doesn't exist or couldn't be read
@@ -319,19 +319,17 @@ var hooksListCmd = &cobra.Command{
} }
func installHooks(embeddedHooks map[string]string, force bool, shared bool, chain bool) error { func installHooks(embeddedHooks map[string]string, force bool, shared bool, chain bool) error {
// Get actual git directory (handles worktrees where .git is a file)
gitDir, err := git.GetGitDir()
if err != nil {
return err
}
var hooksDir string var hooksDir string
if shared { if shared {
// Use versioned directory for shared hooks // Use versioned directory for shared hooks
hooksDir = ".beads-hooks" hooksDir = ".beads-hooks"
} else { } else {
// Use standard .git/hooks directory // Use common git directory for hooks (shared across worktrees)
hooksDir = filepath.Join(gitDir, "hooks") var err error
hooksDir, err = git.GetGitHooksDir()
if err != nil {
return err
}
} }
// Create hooks directory if it doesn't exist // Create hooks directory if it doesn't exist
@@ -401,12 +399,11 @@ func configureSharedHooksPath() error {
} }
func uninstallHooks() error { func uninstallHooks() error {
// Get actual git directory (handles worktrees) // Get hooks directory from common git dir (hooks are shared across worktrees)
gitDir, err := git.GetGitDir() hooksDir, err := git.GetGitHooksDir()
if err != nil { if err != nil {
return err return err
} }
hooksDir := filepath.Join(gitDir, "hooks")
hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout", "prepare-commit-msg"} hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout", "prepare-commit-msg"}
for _, hookName := range hookNames { for _, hookName := range hookNames {
@@ -442,13 +439,13 @@ func uninstallHooks() error {
// runChainedHook runs a .old hook if it exists. Returns the exit code. // runChainedHook runs a .old hook if it exists. Returns the exit code.
// If the hook doesn't exist, returns 0 (success). // If the hook doesn't exist, returns 0 (success).
func runChainedHook(hookName string, args []string) int { func runChainedHook(hookName string, args []string) int {
// Get the hooks directory // Get the hooks directory from common dir (hooks are shared across worktrees)
gitDir, err := git.GetGitDir() hooksDir, err := git.GetGitHooksDir()
if err != nil { if err != nil {
return 0 // Not a git repo, nothing to chain return 0 // Not a git repo, nothing to chain
} }
oldHookPath := filepath.Join(gitDir, "hooks", hookName+".old") oldHookPath := filepath.Join(hooksDir, hookName+".old")
// Check if the .old hook exists and is executable // Check if the .old hook exists and is executable
info, err := os.Stat(oldHookPath) info, err := os.Stat(oldHookPath)

View File

@@ -21,12 +21,12 @@ var preCommitFrameworkPattern = regexp.MustCompile(`(?i)(pre-commit\s+run|prek\s
// hooksInstalled checks if bd git hooks are installed // hooksInstalled checks if bd git hooks are installed
func hooksInstalled() bool { func hooksInstalled() bool {
gitDir, err := git.GetGitDir() hooksDir, err := git.GetGitHooksDir()
if err != nil { if err != nil {
return false return false
} }
preCommit := filepath.Join(gitDir, "hooks", "pre-commit") preCommit := filepath.Join(hooksDir, "pre-commit")
postMerge := filepath.Join(gitDir, "hooks", "post-merge") postMerge := filepath.Join(hooksDir, "post-merge")
// Check if both hooks exist // Check if both hooks exist
_, err1 := os.Stat(preCommit) _, err1 := os.Stat(preCommit)
@@ -81,11 +81,10 @@ type hookInfo struct {
// detectExistingHooks scans for existing git hooks // detectExistingHooks scans for existing git hooks
func detectExistingHooks() []hookInfo { func detectExistingHooks() []hookInfo {
gitDir, err := git.GetGitDir() hooksDir, err := git.GetGitHooksDir()
if err != nil { if err != nil {
return nil return nil
} }
hooksDir := filepath.Join(gitDir, "hooks")
hooks := []hookInfo{ hooks := []hookInfo{
{name: "pre-commit", path: filepath.Join(hooksDir, "pre-commit")}, {name: "pre-commit", path: filepath.Join(hooksDir, "pre-commit")},
{name: "post-merge", path: filepath.Join(hooksDir, "post-merge")}, {name: "post-merge", path: filepath.Join(hooksDir, "post-merge")},
@@ -137,11 +136,10 @@ func promptHookAction(existingHooks []hookInfo) string {
// installGitHooks installs git hooks inline (no external dependencies) // installGitHooks installs git hooks inline (no external dependencies)
func installGitHooks() error { func installGitHooks() error {
gitDir, err := git.GetGitDir() hooksDir, err := git.GetGitHooksDir()
if err != nil { if err != nil {
return err return err
} }
hooksDir := filepath.Join(gitDir, "hooks")
// Ensure hooks directory exists // Ensure hooks directory exists
if err := os.MkdirAll(hooksDir, 0750); err != nil { if err := os.MkdirAll(hooksDir, 0750); err != nil {

View File

@@ -44,8 +44,8 @@ func runReset(cmd *cobra.Command, args []string) {
force, _ := cmd.Flags().GetBool("force") force, _ := cmd.Flags().GetBool("force")
// Check if we're in a git repo // Get common git directory (for hooks and beads-worktrees, which are shared across worktrees)
gitDir, err := git.GetGitDir() gitCommonDir, err := git.GetGitCommonDir()
if err != nil { if err != nil {
if jsonOutput { if jsonOutput {
outputJSON(map[string]interface{}{ outputJSON(map[string]interface{}{
@@ -73,7 +73,7 @@ func runReset(cmd *cobra.Command, args []string) {
} }
// Collect what would be deleted // Collect what would be deleted
items := collectResetItems(gitDir, beadsDir) items := collectResetItems(gitCommonDir, beadsDir)
if !force { if !force {
// Dry-run mode: show what would be deleted // Dry-run mode: show what would be deleted
@@ -82,7 +82,7 @@ func runReset(cmd *cobra.Command, args []string) {
} }
// Actually perform the reset // Actually perform the reset
performReset(items, gitDir, beadsDir) performReset(items, gitCommonDir, beadsDir)
} }
type resetItem struct { type resetItem struct {
@@ -91,7 +91,7 @@ type resetItem struct {
Description string `json:"description"` Description string `json:"description"`
} }
func collectResetItems(gitDir, beadsDir string) []resetItem { func collectResetItems(gitCommonDir, beadsDir string) []resetItem {
var items []resetItem var items []resetItem
// Check for running daemon // Check for running daemon
@@ -106,9 +106,9 @@ func collectResetItems(gitDir, beadsDir string) []resetItem {
} }
} }
// Check for git hooks // Check for git hooks (hooks are in common git dir, shared across worktrees)
hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"} hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"}
hooksDir := filepath.Join(gitDir, "hooks") hooksDir := filepath.Join(gitCommonDir, "hooks")
for _, hookName := range hookNames { for _, hookName := range hookNames {
hookPath := filepath.Join(hooksDir, hookName) hookPath := filepath.Join(hooksDir, hookName)
if _, err := os.Stat(hookPath); err == nil { if _, err := os.Stat(hookPath); err == nil {
@@ -141,8 +141,8 @@ func collectResetItems(gitDir, beadsDir string) []resetItem {
}) })
} }
// Check for sync branch worktrees // Check for sync branch worktrees (in common git dir, shared across worktrees)
worktreesDir := filepath.Join(gitDir, "beads-worktrees") worktreesDir := filepath.Join(gitCommonDir, "beads-worktrees")
if info, err := os.Stat(worktreesDir); err == nil && info.IsDir() { if info, err := os.Stat(worktreesDir); err == nil && info.IsDir() {
items = append(items, resetItem{ items = append(items, resetItem{
Type: "worktrees", Type: "worktrees",

View File

@@ -118,13 +118,15 @@ func GetGitCommonDir() (string, error) {
} }
// GetGitHooksDir returns the path to the Git hooks directory. // GetGitHooksDir returns the path to the Git hooks directory.
// This function is worktree-aware and handles both regular repos and worktrees. // This function is worktree-aware: hooks are shared across all worktrees
// and live in the common git directory (e.g., /repo/.git/hooks), not in
// the worktree-specific directory (e.g., /repo/.git/worktrees/feature/hooks).
func GetGitHooksDir() (string, error) { func GetGitHooksDir() (string, error) {
gitDir, err := GetGitDir() commonDir, err := GetGitCommonDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(gitDir, "hooks"), nil return filepath.Join(commonDir, "hooks"), nil
} }
// GetGitRefsDir returns the path to the Git refs directory. // GetGitRefsDir returns the path to the Git refs directory.