Skip to content

Commit c4b897d

Browse files
steveyeggeclaude
andcommitted
fix: use absolute path for core.hooksPath so worktree hooks work (GH#2414)
git resolves relative core.hooksPath relative to the working tree root, so in worktrees .beads/hooks resolves to <worktree>/.beads/hooks/ which does not exist. Using an absolute path ensures hooks are found regardless of which worktree the git operation runs in. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f0078a0 commit c4b897d

File tree

2 files changed

+127
-6
lines changed

2 files changed

+127
-6
lines changed

cmd/bd/hooks.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -627,13 +627,16 @@ func installHooksWithOptions(hookNames []string, force bool, shared bool, chain
627627
}
628628

629629
func configureSharedHooksPath() error {
630-
// Set git config core.hooksPath to .beads-hooks
631-
// Note: This may run before .beads exists, so it uses git.GetRepoRoot() directly
630+
// Set git config core.hooksPath to an absolute path pointing to .beads-hooks.
631+
// Using an absolute path is critical for git worktrees (GH#2414):
632+
// git resolves relative core.hooksPath relative to the working tree root.
633+
// Note: This may run before .beads exists, so it uses git.GetRepoRoot() directly.
632634
repoRoot := git.GetRepoRoot()
633635
if repoRoot == "" {
634636
return fmt.Errorf("not in a git repository")
635637
}
636-
cmd := exec.Command("git", "config", "core.hooksPath", ".beads-hooks")
638+
absHooksPath := filepath.Join(repoRoot, ".beads-hooks")
639+
cmd := exec.Command("git", "config", "core.hooksPath", absHooksPath)
637640
cmd.Dir = repoRoot
638641
if output, err := cmd.CombinedOutput(); err != nil {
639642
return fmt.Errorf("git config failed: %w (output: %s)", err, string(output))
@@ -642,12 +645,17 @@ func configureSharedHooksPath() error {
642645
}
643646

644647
func configureBeadsHooksPath() error {
645-
// Set git config core.hooksPath to .beads/hooks
648+
// Set git config core.hooksPath to an absolute path pointing to .beads/hooks.
649+
// Using an absolute path is critical for git worktrees (GH#2414):
650+
// git resolves relative core.hooksPath relative to the working tree root,
651+
// so in a worktree ".beads/hooks" would resolve to <worktree>/.beads/hooks/
652+
// which doesn't exist — the hooks live in the main repo's .beads/hooks/.
646653
repoRoot := git.GetRepoRoot()
647654
if repoRoot == "" {
648655
return fmt.Errorf("not in a git repository")
649656
}
650-
cmd := exec.Command("git", "config", "core.hooksPath", ".beads/hooks")
657+
absHooksPath := filepath.Join(repoRoot, ".beads", "hooks")
658+
cmd := exec.Command("git", "config", "core.hooksPath", absHooksPath)
651659
cmd.Dir = repoRoot
652660
if output, err := cmd.CombinedOutput(); err != nil {
653661
return fmt.Errorf("git config failed: %w (output: %s)", err, string(output))
@@ -734,7 +742,11 @@ func resetHooksPathIfBeadsManaged() error {
734742
}
735743

736744
hooksPath := strings.TrimSpace(string(out))
737-
if hooksPath == ".beads/hooks" || hooksPath == ".beads-hooks" {
745+
// Match both relative (legacy) and absolute (GH#2414) beads hooks paths
746+
absBeadsHooks := filepath.Join(repoRoot, ".beads", "hooks")
747+
absSharedHooks := filepath.Join(repoRoot, ".beads-hooks")
748+
if hooksPath == ".beads/hooks" || hooksPath == ".beads-hooks" ||
749+
hooksPath == absBeadsHooks || hooksPath == absSharedHooks {
738750
cmd = exec.Command("git", "config", "--unset", "core.hooksPath")
739751
cmd.Dir = repoRoot
740752
if output, err := cmd.CombinedOutput(); err != nil {

cmd/bd/init_hooks_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"fmt"
55
"os"
6+
"os/exec"
67
"path/filepath"
78
"strings"
89
"testing"
@@ -687,6 +688,114 @@ func TestUninstallHooksRemovesEmptyFile(t *testing.T) {
687688
})
688689
}
689690

691+
// TestConfigureBeadsHooksPath_AbsolutePath verifies that core.hooksPath is set to
692+
// an absolute path so that git worktrees can find the hooks directory (GH#2414).
693+
func TestConfigureBeadsHooksPath_AbsolutePath(t *testing.T) {
694+
tmpDir := newGitRepo(t)
695+
runInDir(t, tmpDir, func() {
696+
// Create .beads/hooks/ directory
697+
beadsHooksDir := filepath.Join(tmpDir, ".beads", "hooks")
698+
if err := os.MkdirAll(beadsHooksDir, 0750); err != nil {
699+
t.Fatalf("Failed to create .beads/hooks/: %v", err)
700+
}
701+
702+
if err := configureBeadsHooksPath(); err != nil {
703+
t.Fatalf("configureBeadsHooksPath() failed: %v", err)
704+
}
705+
706+
// Read back core.hooksPath
707+
out, err := exec.Command("git", "config", "--get", "core.hooksPath").Output()
708+
if err != nil {
709+
t.Fatalf("git config --get core.hooksPath failed: %v", err)
710+
}
711+
hooksPath := strings.TrimSpace(string(out))
712+
713+
// Must be absolute
714+
if !filepath.IsAbs(hooksPath) {
715+
t.Errorf("core.hooksPath should be absolute, got %q", hooksPath)
716+
}
717+
718+
// Must point to .beads/hooks
719+
if !strings.HasSuffix(hooksPath, filepath.Join(".beads", "hooks")) {
720+
t.Errorf("core.hooksPath should end with .beads/hooks, got %q", hooksPath)
721+
}
722+
})
723+
}
724+
725+
// TestInstallHooksBeads_WorktreeAccess verifies that hooks installed with --beads
726+
// are accessible from a git worktree (GH#2414).
727+
func TestInstallHooksBeads_WorktreeAccess(t *testing.T) {
728+
tmpDir := newGitRepo(t)
729+
runInDir(t, tmpDir, func() {
730+
// Create .beads/ directory with metadata.json (needed for FindBeadsDir)
731+
beadsDir := filepath.Join(tmpDir, ".beads")
732+
if err := os.MkdirAll(beadsDir, 0750); err != nil {
733+
t.Fatalf("Failed to create .beads/: %v", err)
734+
}
735+
if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte(`{}`), 0644); err != nil {
736+
t.Fatalf("Failed to create metadata.json: %v", err)
737+
}
738+
739+
// Install hooks with --beads
740+
if err := installHooksWithOptions(managedHookNames, false, false, false, true); err != nil {
741+
t.Fatalf("installHooksWithOptions(beads=true) failed: %v", err)
742+
}
743+
744+
// Verify hooks exist in .beads/hooks/
745+
for _, hookName := range managedHookNames {
746+
hookPath := filepath.Join(beadsDir, "hooks", hookName)
747+
if _, err := os.Stat(hookPath); err != nil {
748+
t.Errorf("hook %s not found at %s", hookName, hookPath)
749+
}
750+
}
751+
752+
// Read core.hooksPath and verify it's absolute
753+
out, err := exec.Command("git", "config", "--get", "core.hooksPath").Output()
754+
if err != nil {
755+
t.Fatalf("core.hooksPath not set after --beads install: %v", err)
756+
}
757+
hooksPath := strings.TrimSpace(string(out))
758+
if !filepath.IsAbs(hooksPath) {
759+
t.Errorf("core.hooksPath should be absolute for worktree compatibility, got %q", hooksPath)
760+
}
761+
762+
// Create a worktree and verify hooks are accessible from it
763+
worktreeDir := filepath.Join(t.TempDir(), "worktree")
764+
cmd := exec.Command("git", "worktree", "add", worktreeDir, "-b", "test-worktree")
765+
cmd.Dir = tmpDir
766+
if output, err := cmd.CombinedOutput(); err != nil {
767+
t.Fatalf("git worktree add failed: %v\n%s", err, string(output))
768+
}
769+
defer func() {
770+
exec.Command("git", "worktree", "remove", worktreeDir).Run()
771+
}()
772+
773+
// From the worktree, core.hooksPath should resolve to the same hooks
774+
cmd = exec.Command("git", "config", "--get", "core.hooksPath")
775+
cmd.Dir = worktreeDir
776+
wtOut, err := cmd.Output()
777+
if err != nil {
778+
t.Fatalf("core.hooksPath not visible from worktree: %v", err)
779+
}
780+
wtHooksPath := strings.TrimSpace(string(wtOut))
781+
782+
if wtHooksPath != hooksPath {
783+
t.Errorf("worktree core.hooksPath = %q, want %q", wtHooksPath, hooksPath)
784+
}
785+
786+
// The hooks directory must actually exist at the resolved path
787+
if _, err := os.Stat(wtHooksPath); err != nil {
788+
t.Errorf("hooks directory not accessible from worktree at %q: %v", wtHooksPath, err)
789+
}
790+
791+
// Verify a specific hook file exists
792+
preCommitPath := filepath.Join(wtHooksPath, "pre-commit")
793+
if _, err := os.Stat(preCommitPath); err != nil {
794+
t.Errorf("pre-commit hook not accessible from worktree: %v", err)
795+
}
796+
})
797+
}
798+
690799
func TestHooksNeedUpdate(t *testing.T) {
691800
tests := []struct {
692801
name string

0 commit comments

Comments
 (0)