fix: support git worktrees in hooks installation

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-29 23:19:57 -08:00
parent fa9285a663
commit 0b13a0df3c
7 changed files with 127 additions and 58 deletions

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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")