Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
57 changes: 54 additions & 3 deletions internal/hook/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: <path>"
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")
Expand All @@ -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")
Expand Down
81 changes: 81 additions & 0 deletions internal/hook/install_test.go
Original file line number Diff line number Diff line change
@@ -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: <main>/.git/hooks and <main>/.git/worktrees/<name>/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 <main>/.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)
}
}