diff --git a/cmd/doctor.go b/cmd/doctor.go index 6e837c5..ca9c34b 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -136,7 +136,7 @@ func doctorCmd() *cobra.Command { Short: "Validate bight setup and config", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - _, gitErr := os.Stat(".git/hooks") + _, gitErr := hook.HooksDir() cfg, cfgErr := loadConfig() existing := map[string]bool{} diff --git a/internal/hook/install.go b/internal/hook/install.go index 8fd1374..0750827 100644 --- a/internal/hook/install.go +++ b/internal/hook/install.go @@ -9,15 +9,61 @@ import ( const hookScript = "#!/bin/sh\n%s post-checkout \"$@\"\n" +// HooksDir returns the path to the git hooks directory for the repo at the +// current working directory. In a regular repo this is .git/hooks; in a +// worktree it resolves to the main repo's hooks dir via commondir. +func HooksDir() (string, error) { + return hooksDir(".") +} + +func hooksDir(dir string) (string, error) { + dotGit := filepath.Join(dir, ".git") + info, err := os.Stat(dotGit) + if err != nil { + return "", fmt.Errorf(".git not found — are you in a git repo?") + } + + if info.IsDir() { + return filepath.Join(dotGit, "hooks"), nil + } + + // .git is a file — we're in a worktree. Format: "gitdir: " + data, err := os.ReadFile(dotGit) + if err != nil { + return "", fmt.Errorf("reading .git: %w", err) + } + line := strings.TrimSpace(string(data)) + const prefix = "gitdir: " + if !strings.HasPrefix(line, prefix) { + return "", fmt.Errorf(".git file has unexpected format") + } + worktreeGitDir := strings.TrimPrefix(line, prefix) + + // commondir holds a path (possibly relative) to the main git dir. + commonDirData, err := os.ReadFile(filepath.Join(worktreeGitDir, "commondir")) + if err != nil { + return "", fmt.Errorf("reading commondir: %w", err) + } + commonDir := strings.TrimSpace(string(commonDirData)) + if !filepath.IsAbs(commonDir) { + commonDir = filepath.Join(worktreeGitDir, commonDir) + } + + return filepath.Join(filepath.Clean(commonDir), "hooks"), nil +} + func Install() error { exe, err := os.Executable() if err != nil { return fmt.Errorf("resolving binary path: %w", err) } - hooksDir := filepath.Join(".git", "hooks") + hooksDir, err := HooksDir() + if err != nil { + return err + } if _, err := os.Stat(hooksDir); os.IsNotExist(err) { - return fmt.Errorf(".git/hooks/ not found — are you in a git repo?") + return fmt.Errorf("%s not found — are you in a git repo?", hooksDir) } hookPath := filepath.Join(hooksDir, "post-checkout") @@ -37,7 +83,12 @@ func Check() error { return fmt.Errorf("resolving binary path: %w", err) } - hookPath := filepath.Join(".git", "hooks", "post-checkout") + hooksDir, err := HooksDir() + if err != nil { + return err + } + + hookPath := filepath.Join(hooksDir, "post-checkout") data, err := os.ReadFile(hookPath) if err != nil { return fmt.Errorf("hook not found") diff --git a/internal/hook/install_test.go b/internal/hook/install_test.go new file mode 100644 index 0000000..933b56f --- /dev/null +++ b/internal/hook/install_test.go @@ -0,0 +1,81 @@ +package hook + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestHooksDir_RegularRepo(t *testing.T) { + dir := makeHooksDir(t) + got, err := hooksDir(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := filepath.Join(dir, ".git", "hooks") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestHooksDir_Worktree(t *testing.T) { + // Main repo:
/.git/hooks and
/.git/worktrees//commondir + main := makeHooksDir(t) + worktreeGitDir := filepath.Join(main, ".git", "worktrees", "my-branch") + if err := os.MkdirAll(worktreeGitDir, 0755); err != nil { + t.Fatal(err) + } + // commondir is relative to worktreeGitDir, pointing at
/.git + if err := os.WriteFile(filepath.Join(worktreeGitDir, "commondir"), []byte("../.."), 0644); err != nil { + t.Fatal(err) + } + + // Worktree dir: .git is a file pointing to worktreeGitDir + wt := t.TempDir() + gitFile := "gitdir: " + worktreeGitDir + if err := os.WriteFile(filepath.Join(wt, ".git"), []byte(gitFile), 0644); err != nil { + t.Fatal(err) + } + + got, err := hooksDir(wt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := filepath.Join(main, ".git", "hooks") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestHooksDir_NoDotGit(t *testing.T) { + dir := t.TempDir() + _, err := hooksDir(dir) + if err == nil || !strings.Contains(err.Error(), "are you in a git repo") { + t.Errorf("expected git repo error, got %v", err) + } +} + +func TestHooksDir_BadGitFileFormat(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("not-a-gitdir-line"), 0644); err != nil { + t.Fatal(err) + } + _, err := hooksDir(dir) + if err == nil || !strings.Contains(err.Error(), "unexpected format") { + t.Errorf("expected format error, got %v", err) + } +} + +func TestHooksDir_MissingCommondir(t *testing.T) { + // worktreeGitDir exists but has no commondir file + worktreeGitDir := t.TempDir() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: "+worktreeGitDir), 0644); err != nil { + t.Fatal(err) + } + _, err := hooksDir(dir) + if err == nil || !strings.Contains(err.Error(), "commondir") { + t.Errorf("expected commondir error, got %v", err) + } +}