diff --git a/cmd/bd/hooks.go b/cmd/bd/hooks.go index 46ad9c9251..30df8d0354 100644 --- a/cmd/bd/hooks.go +++ b/cmd/bd/hooks.go @@ -719,6 +719,91 @@ func preservePreexistingHooks(targetDir string) { } 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) } func configureSharedHooksPath() error { diff --git a/cmd/bd/init_hooks_test.go b/cmd/bd/init_hooks_test.go index 2911325812..20727dfc2c 100644 --- a/cmd/bd/init_hooks_test.go +++ b/cmd/bd/init_hooks_test.go @@ -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") + } +}