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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user