Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions cmd/bd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,91 @@
}
fmt.Printf(" Preserving existing %s hook from %s\n", entry.Name(), currentDir)
}

// GH#3132: Fix husky hook layout after copying.
fixHuskyHookLayout(currentDir, targetDir)
}

// fixHuskyHookLayout handles two husky-specific issues when hooks are copied
// from a husky-managed directory into .beads/hooks/.
//
// Bug 1 (v8): Husky v8 hooks source "$(dirname "$0")/_/husky.sh", but the
// _/ subdirectory is not copied because preservePreexistingHooks skips
// directories. Fix: create a relative symlink to the original _/ directory.
//
// Bug 2 (v9): Husky v9 uses a "h" dispatcher that resolves user hooks via
// dirname(dirname($0)), which breaks when relocated. The shims in .husky/_/
// are wrappers, not actual user hooks. Fix: replace copied shims with the
// real user hook content from the parent directory (.husky/).
func fixHuskyHookLayout(sourceDir, targetDir string) {
// Bug 1: Symlink _/ helper directory for husky v8 compatibility.
// Husky v8 hooks source $(dirname "$0")/_/husky.sh — the _/ directory
// must be reachable from the target hooks directory.
srcHelper := filepath.Join(sourceDir, "_")
if info, err := os.Stat(srcHelper); err == nil && info.IsDir() {
tgtHelper := filepath.Join(targetDir, "_")
if _, err := os.Lstat(tgtHelper); os.IsNotExist(err) {
relPath, relErr := filepath.Rel(targetDir, srcHelper)
if relErr == nil {
if symlinkErr := os.Symlink(relPath, tgtHelper); symlinkErr != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to symlink husky helper directory: %v\n", symlinkErr)
}
}
}
}

// Bug 2: Replace husky v9 shims with actual user hook content.
// Husky v9 sets core.hooksPath=.husky/_/ where each hook is a shim that
// sources "h" (the dispatcher). The dispatcher uses dirname(dirname($0))
// to find user hooks in the parent .husky/ directory — this path math
// breaks when the shim is relocated to .beads/hooks/.
hPath := filepath.Join(targetDir, "h")
hContent, err := os.ReadFile(hPath) // #nosec G304 -- path is in known hooks directory
if err != nil {
return // No h dispatcher — not a husky v9 source directory
}
if !strings.Contains(string(hContent), `dirname "$(dirname`) {
return // Not the husky v9 dispatcher
}

// Source is .husky/_/ — user hooks live in the parent .husky/ directory.
userHooksDir := filepath.Dir(sourceDir)

entries, readErr := os.ReadDir(targetDir)
if readErr != nil {
return
}
for _, entry := range entries {
if entry.IsDir() || entry.Name() == "h" {
continue
}
hookPath := filepath.Join(targetDir, entry.Name())
content, readErr := os.ReadFile(hookPath) // #nosec G304 -- constrained to hooks dir
if readErr != nil {
continue
}
// Only replace husky v9 shims (files that source the h dispatcher)
if !strings.Contains(string(content), `. "$(dirname "$0")/h"`) {
continue
}
userHookPath := filepath.Join(userHooksDir, entry.Name())
userContent, readErr := os.ReadFile(userHookPath) // #nosec G304 -- constrained to husky dir
if readErr != nil {
continue // No corresponding user hook — leave shim as-is
}
// Ensure the content has a shebang (user hooks in .husky/ often omit it)
replacement := string(userContent)
if !strings.HasPrefix(replacement, "#!") {
replacement = "#!/usr/bin/env sh\n" + replacement
}
// #nosec G306 -- git hooks must be executable
if writeErr := os.WriteFile(hookPath, []byte(replacement), 0755); writeErr != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to replace husky v9 shim %s: %v\n", entry.Name(), writeErr)
}
}

// Remove the h dispatcher from target — it's not useful after shim replacement
os.Remove(hPath)

Check failure on line 806 in cmd/bd/hooks.go

View workflow job for this annotation

GitHub Actions / Lint

Error return value of `os.Remove` is not checked (errcheck)
}

func configureSharedHooksPath() error {
Expand Down
222 changes: 222 additions & 0 deletions cmd/bd/init_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1078,3 +1078,225 @@ func TestHooksNeedUpdate(t *testing.T) {
})
}
}

// TestInstallHooksBeads_HuskyV8Helper verifies that the husky v8 _/ helper
// directory is symlinked when hooks are preserved from a husky-managed directory.
// GH#3132 Bug 1: without this, hooks that source $(dirname "$0")/_/husky.sh fail.
func TestInstallHooksBeads_HuskyV8Helper(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(fakeHome, ".config"))
t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(fakeHome, ".gitconfig"))

// Create a husky v8-style hooks directory
huskyDir := filepath.Join(fakeHome, "husky-hooks")
huskyHelperDir := filepath.Join(huskyDir, "_")
if err := os.MkdirAll(huskyHelperDir, 0755); err != nil {
t.Fatalf("mkdir husky helper: %v", err)
}
huskyShContent := "#!/usr/bin/env sh\n# husky v8 helper\n"
if err := os.WriteFile(filepath.Join(huskyHelperDir, "husky.sh"), []byte(huskyShContent), 0755); err != nil {
t.Fatalf("write husky.sh: %v", err)
}
// Hook that sources the helper via relative path
hookContent := "#!/usr/bin/env sh\n. \"$(dirname -- \"$0\")/_/husky.sh\"\nnpx lint-staged\n"
if err := os.WriteFile(filepath.Join(huskyDir, "pre-commit"), []byte(hookContent), 0755); err != nil {
t.Fatalf("write pre-commit: %v", err)
}

// Set as global hooks path (simulating husky v8)
setGlobal := exec.Command("git", "config", "--global", "core.hooksPath", huskyDir)
if out, err := setGlobal.CombinedOutput(); err != nil {
t.Fatalf("set global core.hooksPath: %v (%s)", err, strings.TrimSpace(string(out)))
}

repoDir := t.TempDir()
initCmd := exec.Command("git", "init", "--initial-branch=main")
initCmd.Dir = repoDir
if err := initCmd.Run(); err != nil {
t.Fatalf("git init: %v", err)
}
for _, args := range [][]string{
{"config", "user.email", "test@test.com"},
{"config", "user.name", "Test"},
} {
cmd := exec.Command("git", args...)
cmd.Dir = repoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git config %v: %v", args, err)
}
}

runInDir(t, repoDir, func() {
beadsDir := setupBeadsDir(t, repoDir)

if err := installHooksWithOptions(managedHookNames, false, false, false, true); err != nil {
t.Fatalf("installHooksWithOptions: %v", err)
}

// Verify the _/ symlink was created
tgtHelper := filepath.Join(beadsDir, "hooks", "_")
info, err := os.Lstat(tgtHelper)
if err != nil {
t.Fatalf("expected _/ symlink in .beads/hooks/: %v", err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Errorf("expected _/ to be a symlink, got mode %v", info.Mode())
}

// Verify the symlink target resolves to the original helper
target, err := os.Readlink(tgtHelper)
if err != nil {
t.Fatalf("readlink: %v", err)
}
resolved := filepath.Join(filepath.Dir(tgtHelper), target, "husky.sh")
if _, err := os.Stat(resolved); err != nil {
t.Errorf("symlink does not resolve to husky.sh: %v (target=%s)", err, target)
}

// Verify the hook content was preserved
content, err := os.ReadFile(filepath.Join(beadsDir, "hooks", "pre-commit"))
if err != nil {
t.Fatalf("read pre-commit: %v", err)
}
if !strings.Contains(string(content), "npx lint-staged") {
t.Errorf("hook content not preserved.\nGot:\n%s", string(content))
}
})
}

// TestInstallHooksBeads_HuskyV9Shims verifies that husky v9 shims are replaced
// with actual user hook content when preserved.
// GH#3132 Bug 2: husky v9's h dispatcher uses dirname(dirname($0)) which breaks
// when hooks are relocated from .husky/_/ to .beads/hooks/.
func TestInstallHooksBeads_HuskyV9Shims(t *testing.T) {
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(fakeHome, ".config"))
t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(fakeHome, ".gitconfig"))

// Create husky v9 directory structure:
// .husky/
// pre-commit <- user's actual commands
// _/
// h <- dispatcher
// pre-commit <- shim that sources h
huskyBase := filepath.Join(fakeHome, "project", ".husky")
huskyInner := filepath.Join(huskyBase, "_")
if err := os.MkdirAll(huskyInner, 0755); err != nil {
t.Fatalf("mkdir .husky/_: %v", err)
}

// User's actual hook commands (in .husky/)
userHookContent := "npm run minify-templates\nnpx lint-staged --allow-empty\n"
if err := os.WriteFile(filepath.Join(huskyBase, "pre-commit"), []byte(userHookContent), 0644); err != nil {
t.Fatalf("write user hook: %v", err)
}

// Husky v9 dispatcher (in .husky/_/)
hDispatcher := `#!/usr/bin/env sh
n=$(basename "$0")
s=$(dirname "$(dirname "$0")")/$n
[ ! -f "$s" ] && exit 0
. "$s"
`
if err := os.WriteFile(filepath.Join(huskyInner, "h"), []byte(hDispatcher), 0755); err != nil {
t.Fatalf("write h dispatcher: %v", err)
}

// Husky v9 shim (in .husky/_/)
shimContent := "#!/usr/bin/env sh\n. \"$(dirname \"$0\")/h\"\n"
if err := os.WriteFile(filepath.Join(huskyInner, "pre-commit"), []byte(shimContent), 0755); err != nil {
t.Fatalf("write shim: %v", err)
}

// Set core.hooksPath to .husky/_/ (husky v9 style)
setGlobal := exec.Command("git", "config", "--global", "core.hooksPath", huskyInner)
if out, err := setGlobal.CombinedOutput(); err != nil {
t.Fatalf("set global core.hooksPath: %v (%s)", err, strings.TrimSpace(string(out)))
}

repoDir := t.TempDir()
initCmd := exec.Command("git", "init", "--initial-branch=main")
initCmd.Dir = repoDir
if err := initCmd.Run(); err != nil {
t.Fatalf("git init: %v", err)
}
for _, args := range [][]string{
{"config", "user.email", "test@test.com"},
{"config", "user.name", "Test"},
} {
cmd := exec.Command("git", args...)
cmd.Dir = repoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git config %v: %v", args, err)
}
}

runInDir(t, repoDir, func() {
beadsDir := setupBeadsDir(t, repoDir)

if err := installHooksWithOptions(managedHookNames, false, false, false, true); err != nil {
t.Fatalf("installHooksWithOptions: %v", err)
}

// Verify the h dispatcher was removed
hTarget := filepath.Join(beadsDir, "hooks", "h")
if _, err := os.Stat(hTarget); !os.IsNotExist(err) {
t.Error("h dispatcher should have been removed from .beads/hooks/")
}

// Verify the shim was replaced with actual user hook content
content, err := os.ReadFile(filepath.Join(beadsDir, "hooks", "pre-commit"))
if err != nil {
t.Fatalf("read pre-commit: %v", err)
}
contentStr := string(content)

// Should contain the user's actual commands, not the shim
if strings.Contains(contentStr, `. "$(dirname "$0")/h"`) {
t.Error("shim content should have been replaced with user hook content")
}
if !strings.Contains(contentStr, "npx lint-staged --allow-empty") {
t.Errorf("user hook content not found.\nGot:\n%s", contentStr)
}
if !strings.Contains(contentStr, "npm run minify-templates") {
t.Errorf("user hook content not found.\nGot:\n%s", contentStr)
}

// Should have a shebang (added since user hooks in .husky/ often omit it)
if !strings.HasPrefix(contentStr, "#!") {
t.Error("preserved hook should have a shebang")
}

// Beads section should also be present (injected by installHooksWithOptions)
if !strings.Contains(contentStr, hookSectionBeginPrefix) {
t.Errorf("beads section marker missing.\nGot:\n%s", contentStr)
}
})
}

// TestFixHuskyHookLayout_NoHusky verifies the fix is a no-op for non-husky directories.
func TestFixHuskyHookLayout_NoHusky(t *testing.T) {
sourceDir := t.TempDir()
targetDir := t.TempDir()

// Write a normal hook (no husky)
if err := os.WriteFile(filepath.Join(sourceDir, "pre-commit"), []byte("#!/bin/sh\necho hi\n"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(targetDir, "pre-commit"), []byte("#!/bin/sh\necho hi\n"), 0755); err != nil {
t.Fatal(err)
}

fixHuskyHookLayout(sourceDir, targetDir)

// No _/ symlink should be created
if _, err := os.Lstat(filepath.Join(targetDir, "_")); !os.IsNotExist(err) {
t.Error("_/ should not exist for non-husky directories")
}
// No h file to remove
if _, err := os.Stat(filepath.Join(targetDir, "h")); !os.IsNotExist(err) {
t.Error("h should not exist")
}
}
Loading