From 0b13a0df3c4306fab67cb1e00d167707eb2677b4 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sat, 29 Nov 2025 23:19:57 -0800 Subject: [PATCH] fix: support git worktrees in hooks installation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use `git rev-parse --git-dir` instead of hardcoded `.git` path to find the actual git directory. In worktrees, `.git` is a file containing a gitdir pointer, not a directory. Changes: - Add getGitDir() helper in hooks.go - Update installHooks(), uninstallHooks(), CheckGitHooks() to use it - Update hooksInstalled(), detectExistingHooks(), installGitHooks() in init.go - Update checkHooksQuick() in doctor.go - Update GitHooks() in doctor/fix/hooks.go - Update tests to use real git repos via `git init` Fixes bd-63l 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .beads/issues.jsonl | 4 +-- cmd/bd/doctor.go | 16 +++++++-- cmd/bd/doctor/fix/hooks.go | 9 ++--- cmd/bd/hooks.go | 39 +++++++++++++++++---- cmd/bd/hooks_test.go | 69 ++++++++++++++++++++++---------------- cmd/bd/init.go | 20 ++++++++--- cmd/bd/init_hooks_test.go | 28 +++++++++------- 7 files changed, 127 insertions(+), 58 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f32518aa..9053f3da 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -15,9 +15,9 @@ {"id":"bd-4l5","title":"bd prime: Detect ephemeral branches and adjust workflow output","description":"When 'bd prime' runs on a branch with no upstream (ephemeral branch), it should output a different SESSION CLOSE PROTOCOL.\n\n**Current output (wrong for ephemeral branches):**\n```\n[ ] 1. git status\n[ ] 2. git add \u003cfiles\u003e\n[ ] 3. bd sync\n[ ] 4. git commit -m \"...\"\n[ ] 5. bd sync\n[ ] 6. git push\n```\n\n**Needed output for ephemeral branches:**\n```\n[ ] 1. git status\n[ ] 2. git add \u003cfiles\u003e\n[ ] 3. bd sync --from-main (pull updates from main)\n[ ] 4. git commit -m \"...\"\n[ ] 5. (no push - branch is ephemeral)\n```\n\n**Detection:** `git rev-parse --abbrev-ref --symbolic-full-name @{u}` returns error code 128 if no upstream.\n\nAlso update Sync \u0026 Collaboration section to mention `bd sync --from-main` for ephemeral branches.\n\n**Use case:** Gastown polecats work on ephemeral local branches that are never pushed. Their code gets merged to main via local merge, and beads changes stay local (communicated via gm mail to Overseer).","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-11-25T16:55:24.984104-08:00","updated_at":"2025-11-25T17:12:46.604978-08:00","closed_at":"2025-11-25T17:12:46.604978-08:00"} {"id":"bd-4pv","title":"bd export only outputs 1 issue after auto-import corrupts database","description":"When auto-import runs and purges issues (due to git history backfill bug), subsequent 'bd export' only exports 1 issue even though the database should have many.\n\nReproduction:\n1. Have issues.jsonl with 55 issues\n2. Auto-import triggers and purges all issues via git history backfill\n3. Run 'bd export' - only exports 1 issue (the last one created before corruption)\n\nThe database gets into an inconsistent state where most issues are purged but export doesn't realize this.\n\nWorkaround: Rebuild database from scratch with 'rm .beads/beads.db \u0026\u0026 bd init --prefix bd'","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-11-26T22:28:40.828866-08:00","updated_at":"2025-11-28T17:28:55.545056-08:00","closed_at":"2025-11-27T22:50:35.036227-08:00"} {"id":"bd-4t7","title":"Auto-import runs during --no-auto-import operations via stats/ready commands","description":"Even when using --no-auto-import flag, certain commands like 'bd stats' and 'bd ready' still trigger auto-import internally, which can cause the git history backfill bug to corrupt data.\n\nExample:\n bd stats --no-auto-import\n # Still prints 'Purged bd-xxx (recovered from git history...)'\n\nThe flag should completely disable auto-import for the command, but it appears some code paths still trigger it.\n\nWorkaround: Use --allow-stale instead, or --sandbox mode.","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-11-26T22:28:59.305898-08:00","updated_at":"2025-11-27T00:54:20.335013-08:00","closed_at":"2025-11-27T00:54:12.561872-08:00"} -{"id":"bd-53c","title":"bd sync corrupts issues.jsonl in multi-clone environments","description":"During parallel swarm operations on 2025-11-29, bd sync corrupted the issues database. Commit 93718056 purged all 80 issues from issues.jsonl.\n\nReproduction:\n1. Multiple clones of same repo (polecat swarm)\n2. Each clone runs bd sync in parallel\n3. One sync overwrites others, resulting in data loss\n\nEven in single-clone scenarios (~/src/beads), running bd sync after git pull seems to nuke the JSONL.\n\nWorkaround: Use bd import -i .beads/issues.jsonl --no-git-history instead of bd sync.\n\nRoot cause investigation needed. Options:\n1. Pessimistic locking (flock)\n2. Single point of merge (polecats on ephemeral branches)\n3. Beads-level locking (bd sync --lock)","status":"open","priority":0,"issue_type":"bug","created_at":"2025-11-29T16:30:30.592507-08:00","updated_at":"2025-11-29T16:30:30.592507-08:00"} +{"id":"bd-53c","title":"bd sync corrupts issues.jsonl in multi-clone environments","description":"During parallel swarm operations on 2025-11-29, bd sync corrupted the issues database. Commit 93718056 purged all 80 issues from issues.jsonl.\n\nReproduction:\n1. Multiple clones of same repo (polecat swarm)\n2. Each clone runs bd sync in parallel\n3. One sync overwrites others, resulting in data loss\n\nEven in single-clone scenarios (~/src/beads), running bd sync after git pull seems to nuke the JSONL.\n\nWorkaround: Use bd import -i .beads/issues.jsonl --no-git-history instead of bd sync.\n\nRoot cause investigation needed. Options:\n1. Pessimistic locking (flock)\n2. Single point of merge (polecats on ephemeral branches)\n3. Beads-level locking (bd sync --lock)","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-11-29T16:30:30.592507-08:00","updated_at":"2025-11-29T23:19:57.254217-08:00","closed_at":"2025-11-29T23:15:02.435084-08:00"} {"id":"bd-5bj","title":"Registry has cross-process race condition","description":"The global daemon registry (~/.beads/registry.json) can be corrupted when multiple daemons from different workspaces write simultaneously.\n\n**Root cause:**\n- Registry uses an in-process mutex but no file-level locking\n- Register() and Unregister() release the mutex between read and write\n- Multiple daemon processes can interleave their read-modify-write cycles\n\n**Evidence:**\nFound registry.json with double closing bracket: `]]` instead of `]`\n\n**Fix options:**\n1. Use file locking (flock/fcntl) around the entire read-modify-write cycle\n2. Use atomic write pattern (write to temp file, rename)\n3. Both (belt and suspenders)\n\n**Files:**\n- internal/daemon/registry.go:46-64 (readEntries)\n- internal/daemon/registry.go:67-87 (writeEntries)\n- internal/daemon/registry.go:90-108 (Register - the race window)","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-11-27T13:55:50.426188-08:00","updated_at":"2025-11-27T14:07:06.22622-08:00","closed_at":"2025-11-27T14:07:06.22622-08:00"} -{"id":"bd-63l","title":"bd hooks install fails in git worktrees","description":"When bd is used in a git worktree, bd hooks install fails with 'mkdir .git: not a directory' because .git is a file (gitdir pointer) not a directory. Beads should detect and follow the .git gitdir pointer to install hooks in the correct location. This blocks normal worktree workflows.\n\n## Symptoms of this bug:\n- Git hooks don't install automatically\n- Auto-sync doesn't run (5-second debounce)\n- Hash mismatch warnings in bd output\n- Daemon fails to start with 'auto_start_failed'\n\n## Workaround:\nUse `git rev-parse --git-dir` to find the actual hooks directory and copy hooks manually:\n```bash\nmkdir -p $(git rev-parse --git-dir)/hooks\ncp -r .beads-hooks/* $(git rev-parse --git-dir)/hooks/\n```","status":"open","priority":1,"issue_type":"bug","created_at":"2025-11-29T00:27:59.111163003-07:00","updated_at":"2025-11-29T00:38:30.640871318-07:00"} +{"id":"bd-63l","title":"bd hooks install fails in git worktrees","description":"When bd is used in a git worktree, bd hooks install fails with 'mkdir .git: not a directory' because .git is a file (gitdir pointer) not a directory. Beads should detect and follow the .git gitdir pointer to install hooks in the correct location. This blocks normal worktree workflows.\n\n## Symptoms of this bug:\n- Git hooks don't install automatically\n- Auto-sync doesn't run (5-second debounce)\n- Hash mismatch warnings in bd output\n- Daemon fails to start with 'auto_start_failed'\n\n## Workaround:\nUse `git rev-parse --git-dir` to find the actual hooks directory and copy hooks manually:\n```bash\nmkdir -p $(git rev-parse --git-dir)/hooks\ncp -r .beads-hooks/* $(git rev-parse --git-dir)/hooks/\n```","status":"in_progress","priority":1,"issue_type":"bug","created_at":"2025-11-29T00:27:59.111163003-07:00","updated_at":"2025-11-29T23:19:57.254738-08:00"} {"id":"bd-736d","title":"Refactor path canonicalization into helper function","description":"The path canonicalization logic (filepath.Abs + EvalSymlinks) is duplicated in 3 places:\n- beads.go:131-137 (BEADS_DIR handling)\n- cmd/bd/main.go:446-451 (--no-db cleanup)\n- cmd/bd/nodb.go:26-31 (--no-db initialization)\n\nRefactoring suggestion:\nExtract to a helper function like:\n func canonicalizePath(path string) string\n\nThis would:\n- Reduce code duplication\n- Make the logic easier to maintain\n- Ensure consistent behavior across all path handling\n\nRelated to bd-e16b implementation.","status":"closed","priority":3,"issue_type":"chore","created_at":"2025-11-02T18:33:47.727443-08:00","updated_at":"2025-11-25T22:27:33.738672-08:00","closed_at":"2025-11-25T22:27:33.738672-08:00"} {"id":"bd-81a","title":"Add programmatic tip injection API","description":"Allow tips to be programmatically injected at runtime based on detected conditions. This enables dynamic tips (not just pre-defined ones) to be shown with custom priority and frequency.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-11T23:29:46.645583-08:00","updated_at":"2025-11-25T17:52:35.096882-08:00","closed_at":"2025-11-25T17:52:35.096882-08:00","dependencies":[{"issue_id":"bd-81a","depends_on_id":"bd-d4i","type":"blocks","created_at":"2025-11-11T23:29:46.646327-08:00","created_by":"daemon"}]} {"id":"bd-8a5","title":"Refactor: deduplicate FindJSONLInDir and FindJSONLPath","description":"## Background\n\nAfter fixing bd-tqo, we now have two nearly identical functions for finding the JSONL file:\n- `autoimport.FindJSONLInDir(dbDir string)` in internal/autoimport/autoimport.go\n- `beads.FindJSONLPath(dbPath string)` in internal/beads/beads.go\n\nBoth implement the same logic:\n1. Prefer issues.jsonl\n2. Fall back to beads.jsonl for legacy support\n3. Skip deletions.jsonl and merge artifacts\n4. Default to issues.jsonl if nothing found\n\n## Problem\n\nCode duplication means bug fixes need to be applied in multiple places (as we just experienced with bd-tqo).\n\n## Proposed Solution\n\nExtract shared logic to a utility package that both can import. Options:\n1. Create `internal/jsonlpath` package with the core logic\n2. Have `autoimport` import `beads` and call `FindJSONLPath` (but APIs differ slightly)\n3. Move to `internal/utils` if appropriate\n\nNeed to verify no import cycles would be created.\n\n## Affected Files\n- internal/autoimport/autoimport.go\n- internal/beads/beads.go","status":"closed","priority":4,"issue_type":"task","created_at":"2025-11-26T23:45:18.974339-08:00","updated_at":"2025-11-29T22:06:06.330185-08:00","closed_at":"2025-11-28T23:07:08.912247-08:00"} diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index 62e607cc..fa25a062 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -371,9 +371,21 @@ func checkSyncBranchQuickDB(db *sql.DB) string { // checkHooksQuick does a fast check for outdated git hooks. // Checks all beads hooks: pre-commit, post-merge, pre-push, post-checkout (bd-2em). func checkHooksQuick(path string) string { - hooksDir := filepath.Join(path, ".git", "hooks") + // Get actual git directory (handles worktrees where .git is a file) + cmd := exec.Command("git", "rev-parse", "--git-dir") + cmd.Dir = path + output, err := cmd.Output() + if err != nil { + return "" // Not a git repo, skip + } + gitDir := strings.TrimSpace(string(output)) + // Make absolute if relative + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(path, gitDir) + } + hooksDir := filepath.Join(gitDir, "hooks") - // Check if .git/hooks exists + // Check if hooks dir exists if _, err := os.Stat(hooksDir); os.IsNotExist(err) { return "" // No git hooks directory, skip } diff --git a/cmd/bd/doctor/fix/hooks.go b/cmd/bd/doctor/fix/hooks.go index 25ff9a0a..12cc67fc 100644 --- a/cmd/bd/doctor/fix/hooks.go +++ b/cmd/bd/doctor/fix/hooks.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "os/exec" - "path/filepath" ) // GitHooks fixes missing or broken git hooks by calling bd hooks install @@ -14,9 +13,11 @@ func GitHooks(path string) error { return err } - // Check if we're in a git repository - gitDir := filepath.Join(path, ".git") - if _, err := os.Stat(gitDir); os.IsNotExist(err) { + // Check if we're in a git repository using git rev-parse + // This handles worktrees where .git is a file, not a directory + checkCmd := exec.Command("git", "rev-parse", "--git-dir") + checkCmd.Dir = path + if err := checkCmd.Run(); err != nil { return fmt.Errorf("not a git repository") } diff --git a/cmd/bd/hooks.go b/cmd/bd/hooks.go index 32b6853c..7db93af0 100644 --- a/cmd/bd/hooks.go +++ b/cmd/bd/hooks.go @@ -13,6 +13,18 @@ import ( "github.com/spf13/cobra" ) +// getGitDir returns the actual .git directory path. +// In a normal repo, this is ".git". In a worktree, .git is a file +// containing "gitdir: /path/to/actual/git/dir", so we use git rev-parse. +func getGitDir() (string, error) { + cmd := exec.Command("git", "rev-parse", "--git-dir") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("not a git repository: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + //go:embed templates/hooks/* var hooksFS embed.FS @@ -46,13 +58,23 @@ func CheckGitHooks() []HookStatus { hooks := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"} statuses := make([]HookStatus, 0, len(hooks)) + // Get actual git directory (handles worktrees) + gitDir, err := getGitDir() + if err != nil { + // Not a git repo - return all hooks as not installed + for _, hookName := range hooks { + statuses = append(statuses, HookStatus{Name: hookName, Installed: false}) + } + return statuses + } + for _, hookName := range hooks { status := HookStatus{ Name: hookName, } // Check if hook exists - hookPath := filepath.Join(".git", "hooks", hookName) + hookPath := filepath.Join(gitDir, "hooks", hookName) version, err := getHookVersion(hookPath) if err != nil { // Hook doesn't exist or couldn't be read @@ -276,10 +298,10 @@ var hooksListCmd = &cobra.Command{ } func installHooks(embeddedHooks map[string]string, force bool, shared bool) error { - // Check if .git directory exists - gitDir := ".git" - if _, err := os.Stat(gitDir); os.IsNotExist(err) { - return fmt.Errorf("not a git repository (no .git directory found)") + // Get actual git directory (handles worktrees where .git is a file) + gitDir, err := getGitDir() + if err != nil { + return err } var hooksDir string @@ -338,7 +360,12 @@ func configureSharedHooksPath() error { } func uninstallHooks() error { - hooksDir := filepath.Join(".git", "hooks") + // Get actual git directory (handles worktrees) + gitDir, err := getGitDir() + if err != nil { + return err + } + hooksDir := filepath.Join(gitDir, "hooks") hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"} for _, hookName := range hookNames { diff --git a/cmd/bd/hooks_test.go b/cmd/bd/hooks_test.go index 66ef7c0f..5b8194c3 100644 --- a/cmd/bd/hooks_test.go +++ b/cmd/bd/hooks_test.go @@ -32,18 +32,21 @@ func TestGetEmbeddedHooks(t *testing.T) { } func TestInstallHooks(t *testing.T) { - // Create temp directory with fake .git + // Create temp directory and init git repo tmpDir := t.TempDir() - gitDir := filepath.Join(tmpDir, ".git", "hooks") - if err := os.MkdirAll(gitDir, 0755); err != nil { - t.Fatalf("Failed to create test git dir: %v", err) - } // Change to temp directory oldWd, _ := os.Getwd() defer os.Chdir(oldWd) os.Chdir(tmpDir) + // Initialize a real git repo (required for git rev-parse) + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) + } + + gitDir := filepath.Join(tmpDir, ".git", "hooks") + // Get embedded hooks hooks, err := getEmbeddedHooks() if err != nil { @@ -78,18 +81,21 @@ func TestInstallHooks(t *testing.T) { } func TestInstallHooksBackup(t *testing.T) { - // Create temp directory with fake .git + // Create temp directory and init git repo tmpDir := t.TempDir() - gitDir := filepath.Join(tmpDir, ".git", "hooks") - if err := os.MkdirAll(gitDir, 0755); err != nil { - t.Fatalf("Failed to create test git dir: %v", err) - } // Change to temp directory oldWd, _ := os.Getwd() defer os.Chdir(oldWd) os.Chdir(tmpDir) + // Initialize a real git repo (required for git rev-parse) + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) + } + + gitDir := filepath.Join(tmpDir, ".git", "hooks") + // Create an existing hook existingHook := filepath.Join(gitDir, "pre-commit") existingContent := "#!/bin/sh\necho old hook\n" @@ -125,18 +131,21 @@ func TestInstallHooksBackup(t *testing.T) { } func TestInstallHooksForce(t *testing.T) { - // Create temp directory with fake .git + // Create temp directory and init git repo tmpDir := t.TempDir() - gitDir := filepath.Join(tmpDir, ".git", "hooks") - if err := os.MkdirAll(gitDir, 0755); err != nil { - t.Fatalf("Failed to create test git dir: %v", err) - } - // Change to temp directory + // Change to temp directory first, then init oldWd, _ := os.Getwd() defer os.Chdir(oldWd) os.Chdir(tmpDir) + // Initialize a real git repo (required for git rev-parse) + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) + } + + gitDir := filepath.Join(tmpDir, ".git", "hooks") + // Create an existing hook existingHook := filepath.Join(gitDir, "pre-commit") if err := os.WriteFile(existingHook, []byte("old"), 0755); err != nil { @@ -162,18 +171,21 @@ func TestInstallHooksForce(t *testing.T) { } func TestUninstallHooks(t *testing.T) { - // Create temp directory with fake .git + // Create temp directory and init git repo tmpDir := t.TempDir() - gitDir := filepath.Join(tmpDir, ".git", "hooks") - if err := os.MkdirAll(gitDir, 0755); err != nil { - t.Fatalf("Failed to create test git dir: %v", err) - } - // Change to temp directory + // Change to temp directory first, then init oldWd, _ := os.Getwd() defer os.Chdir(oldWd) os.Chdir(tmpDir) + // Initialize a real git repo (required for git rev-parse) + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) + } + + gitDir := filepath.Join(tmpDir, ".git", "hooks") + // Get embedded hooks and install them hooks, err := getEmbeddedHooks() if err != nil { @@ -199,18 +211,19 @@ func TestUninstallHooks(t *testing.T) { } func TestHooksCheckGitHooks(t *testing.T) { - // Create temp directory with fake .git + // Create temp directory and init git repo tmpDir := t.TempDir() - gitDir := filepath.Join(tmpDir, ".git", "hooks") - if err := os.MkdirAll(gitDir, 0755); err != nil { - t.Fatalf("Failed to create test git dir: %v", err) - } - // Change to temp directory + // Change to temp directory first, then init oldWd, _ := os.Getwd() defer os.Chdir(oldWd) os.Chdir(tmpDir) + // Initialize a real git repo (required for git rev-parse) + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) + } + // Initially no hooks installed statuses := CheckGitHooks() diff --git a/cmd/bd/init.go b/cmd/bd/init.go index 97cb4f70..8b1fc437 100644 --- a/cmd/bd/init.go +++ b/cmd/bd/init.go @@ -459,8 +459,12 @@ func init() { // hooksInstalled checks if bd git hooks are installed func hooksInstalled() bool { - preCommit := filepath.Join(".git", "hooks", "pre-commit") - postMerge := filepath.Join(".git", "hooks", "post-merge") + gitDir, err := getGitDir() + if err != nil { + return false + } + preCommit := filepath.Join(gitDir, "hooks", "pre-commit") + postMerge := filepath.Join(gitDir, "hooks", "post-merge") // Check if both hooks exist _, err1 := os.Stat(preCommit) @@ -515,7 +519,11 @@ type hookInfo struct { // detectExistingHooks scans for existing git hooks func detectExistingHooks() []hookInfo { - hooksDir := filepath.Join(".git", "hooks") + gitDir, err := getGitDir() + if err != nil { + return nil + } + hooksDir := filepath.Join(gitDir, "hooks") hooks := []hookInfo{ {name: "pre-commit", path: filepath.Join(hooksDir, "pre-commit")}, {name: "post-merge", path: filepath.Join(hooksDir, "post-merge")}, @@ -569,7 +577,11 @@ func promptHookAction(existingHooks []hookInfo) string { // installGitHooks installs git hooks inline (no external dependencies) func installGitHooks() error { - hooksDir := filepath.Join(".git", "hooks") + gitDir, err := getGitDir() + if err != nil { + return err + } + hooksDir := filepath.Join(gitDir, "hooks") // Ensure hooks directory exists if err := os.MkdirAll(hooksDir, 0750); err != nil { diff --git a/cmd/bd/init_hooks_test.go b/cmd/bd/init_hooks_test.go index 67dc9b8d..54132fa1 100644 --- a/cmd/bd/init_hooks_test.go +++ b/cmd/bd/init_hooks_test.go @@ -2,6 +2,7 @@ package main import ( "os" + "os/exec" "path/filepath" "strings" "testing" @@ -20,12 +21,13 @@ func TestDetectExistingHooks(t *testing.T) { t.Fatal(err) } - // Initialize a git repository + // Initialize a real git repo (required for git rev-parse) + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) + } + gitDir := filepath.Join(tmpDir, ".git") hooksDir := filepath.Join(gitDir, "hooks") - if err := os.MkdirAll(hooksDir, 0750); err != nil { - t.Fatal(err) - } tests := []struct { name string @@ -118,12 +120,13 @@ func TestInstallGitHooks_NoExistingHooks(t *testing.T) { t.Fatal(err) } - // Initialize a git repository + // Initialize a real git repo (required for git rev-parse) + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) + } + gitDir := filepath.Join(tmpDir, ".git") hooksDir := filepath.Join(gitDir, "hooks") - if err := os.MkdirAll(hooksDir, 0750); err != nil { - t.Fatal(err) - } // Note: Can't fully test interactive prompt in automated tests // This test verifies the logic works when no existing hooks present @@ -164,12 +167,13 @@ func TestInstallGitHooks_ExistingHookBackup(t *testing.T) { t.Fatal(err) } - // Initialize a git repository + // Initialize a real git repo (required for git rev-parse) + if err := exec.Command("git", "init").Run(); err != nil { + t.Skipf("Skipping test: git init failed: %v", err) + } + gitDir := filepath.Join(tmpDir, ".git") hooksDir := filepath.Join(gitDir, "hooks") - if err := os.MkdirAll(hooksDir, 0750); err != nil { - t.Fatal(err) - } // Create an existing pre-commit hook preCommitPath := filepath.Join(hooksDir, "pre-commit")