Skip to content

Commit 3d884c9

Browse files
committed
fix: better worktree support
1 parent 84fbb00 commit 3d884c9

File tree

11 files changed

+287
-606
lines changed

11 files changed

+287
-606
lines changed

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.14.4] - 2026-03-31
11+
12+
### Added
13+
14+
- `pkg/gitutils`, containing `GetRepoRoot()` and `GetRepoRootContext()` commands to identify the root of the working copy in a way that works both in regular git clones _and_ in git worktrees.
15+
16+
### Fixed
17+
18+
- Improved reliability of hooks mechanism when working in a git worktree.
19+
- Fixed some intermittent failures in `TestMultiline` and `TestMultilineTag` tests.
20+
1021
## [0.14.3] - 2026-03-28
1122

1223
### Changed
@@ -516,7 +527,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
516527
- Added parallelism-by-default to use of Go tools from inside Stave.
517528
- Parallelized tests where possible, including locking mechanism to prevent parallel tests in same `testdata/(xyz/)` subdir.
518529

519-
[unreleased]: https://github.com/yaklabco/stave/compare/v0.14.3...HEAD
530+
[unreleased]: https://github.com/yaklabco/stave/compare/v0.14.4...HEAD
531+
[0.14.4]: https://github.com/yaklabco/stave/compare/v0.14.3...v0.14.4
520532
[0.14.3]: https://github.com/yaklabco/stave/compare/v0.14.2...v0.14.3
521533
[0.14.2]: https://github.com/yaklabco/stave/compare/v0.14.1...v0.14.2
522534
[0.14.1]: https://github.com/yaklabco/stave/compare/v0.14.0...v0.14.1

go.mod

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ require (
1010
github.com/charmbracelet/x/term v0.2.2
1111
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
1212
github.com/fsnotify/fsnotify v1.9.0
13-
github.com/go-git/go-git/v5 v5.17.0
13+
github.com/go-git/go-git/v5 v5.17.2
1414
github.com/gobwas/glob v0.2.3
1515
github.com/google/uuid v1.6.0
1616
github.com/muesli/reflow v0.3.0
@@ -29,10 +29,10 @@ require (
2929
github.com/bitfield/gotestdox v0.2.2 // indirect
3030
github.com/charmbracelet/colorprofile v0.4.3 // indirect
3131
github.com/charmbracelet/lipgloss v1.1.0 // indirect
32-
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502 // indirect
32+
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b // indirect
3333
github.com/charmbracelet/x/ansi v0.11.6 // indirect
3434
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
35-
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260323091123-df7b1bcffcca // indirect
35+
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260330094520-2dce04b6f8a4 // indirect
3636
github.com/charmbracelet/x/termios v0.1.1 // indirect
3737
github.com/charmbracelet/x/windows v0.2.2 // indirect
3838
github.com/clipperhouse/displaywidth v0.11.0 // indirect
@@ -46,7 +46,7 @@ require (
4646
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
4747
github.com/inconshreveable/mousetrap v1.1.0 // indirect
4848
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
49-
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
49+
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
5050
github.com/mattn/go-colorable v0.1.14 // indirect
5151
github.com/mattn/go-isatty v0.0.20 // indirect
5252
github.com/mattn/go-runewidth v0.0.21 // indirect

go.sum

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF
2020
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
2121
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
2222
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
23-
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502 h1:hzWNs3UQRSUTS6YCbLaQnwqKBFXT5Yh1OOw6+26apqg=
24-
github.com/charmbracelet/ultraviolet v0.0.0-20260316091819-b93f6a3b8502/go.mod h1:mkUCcxn9w9j89JJp3pOza5tmDQZPgIB75UfmQlFYvas=
23+
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b h1:ASDO9RT6SNKTQN87jO2bRfxHFJq8cgeYdFzivY2gCeM=
24+
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b/go.mod h1:Vo8TffMf0q7Uho/n8e6XpBZvOWtd3g39yX+9P5rRutA=
2525
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
2626
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
2727
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
2828
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
29-
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260323091123-df7b1bcffcca h1:62yAoS1Ynbuzwcn1LkNBxi3IMF5p0E0cHCoaLOOmN9w=
30-
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260323091123-df7b1bcffcca/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
29+
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260330094520-2dce04b6f8a4 h1:pIj18ZCZO4WOVj7jwjLoUb1lC7rS/I8oC3fZWXugNaY=
30+
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260330094520-2dce04b6f8a4/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
3131
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
3232
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
3333
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -55,8 +55,8 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D
5555
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
5656
github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
5757
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
58-
github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
59-
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
58+
github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104=
59+
github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
6060
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
6161
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
6262
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
@@ -77,8 +77,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
7777
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
7878
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
7979
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
80-
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
81-
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
80+
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
81+
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
8282
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
8383
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
8484
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

internal/hooks/git.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,13 @@ func findGitDirs(ctx context.Context, absDir string) (gitDirs, error) {
9090
return gitDirs{}, fmt.Errorf("%w: %s", ErrNotGitRepo, absDir)
9191
}
9292

93-
gitDir, err := gitOutput(ctx, absDir, "rev-parse", "--git-dir")
93+
gitDir, err := gitOutput(ctx, absDir, "rev-parse", "--git-common-dir")
9494
if err != nil {
95-
return gitDirs{}, fmt.Errorf("finding git directory: %w", err)
95+
// Fallback to --git-dir if --git-common-dir fails (older git versions)
96+
gitDir, err = gitOutput(ctx, absDir, "rev-parse", "--git-dir")
97+
if err != nil {
98+
return gitDirs{}, fmt.Errorf("finding git directory: %w", err)
99+
}
96100
}
97101

98102
// Make gitDir absolute if it isn't already

internal/hooks/worktree_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package hooks
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestFindGitRepo_Worktree(t *testing.T) {
13+
// Create a temp directory for the main repo
14+
tmpDir := t.TempDir()
15+
tmpDir, err := filepath.EvalSymlinks(tmpDir)
16+
require.NoError(t, err)
17+
18+
mainRepoDir := filepath.Join(tmpDir, "main-repo")
19+
require.NoError(t, os.MkdirAll(mainRepoDir, 0o755))
20+
21+
// Initialize main repo
22+
testGitInit(t, mainRepoDir)
23+
24+
// We need at least one commit to create a worktree
25+
runGit(t, mainRepoDir, "config", "user.email", "test@example.com")
26+
runGit(t, mainRepoDir, "config", "user.name", "Test User")
27+
require.NoError(t, os.WriteFile(filepath.Join(mainRepoDir, "file.txt"), []byte("hello"), 0o644))
28+
runGit(t, mainRepoDir, "add", "file.txt")
29+
runGit(t, mainRepoDir, "commit", "-m", "initial commit")
30+
31+
// Create a worktree
32+
worktreeDir := filepath.Join(tmpDir, "worktree")
33+
runGit(t, mainRepoDir, "worktree", "add", worktreeDir)
34+
35+
// Find repo from worktree
36+
repo, err := FindGitRepo(worktreeDir)
37+
require.NoError(t, err)
38+
39+
// RootDir should be the worktree directory
40+
require.Equal(t, worktreeDir, repo.RootDir)
41+
42+
// HooksPath should point to the main repo's hooks directory
43+
expectedHooksPath := filepath.Join(mainRepoDir, ".git", "hooks")
44+
require.Equal(t, expectedHooksPath, repo.HooksPath(), "HooksPath should point to main repo's hooks")
45+
}
46+
47+
func runGit(t *testing.T, dir string, args ...string) {
48+
t.Helper()
49+
cmd := exec.Command("git", args...)
50+
cmd.Dir = dir
51+
// Use same env overrides as testGitInit to avoid interference
52+
cmd.Env = append(os.Environ(),
53+
"GIT_CONFIG_GLOBAL="+os.DevNull,
54+
"GIT_CONFIG_SYSTEM="+os.DevNull,
55+
)
56+
out, err := cmd.CombinedOutput()
57+
if err != nil {
58+
t.Fatalf("git %v failed: %v\nOutput: %s", args, err, string(out))
59+
}
60+
}

pkg/gitutils/reporoot.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package gitutils
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
)
12+
13+
// ErrNotGitRepo is returned when the directory is not inside a Git repository.
14+
var ErrNotGitRepo = errors.New("not a git repository")
15+
16+
// GetRepoRoot returns the absolute path to the root of the current Git repository.
17+
// It works correctly for both regular Git clones and Git worktrees.
18+
// If the current directory is not within a Git repository, it returns ErrNotGitRepo.
19+
func GetRepoRoot() (string, error) {
20+
return GetRepoRootContext(context.Background())
21+
}
22+
23+
// GetRepoRootContext returns the absolute path to the root of the current Git repository with context.
24+
func GetRepoRootContext(ctx context.Context) (string, error) {
25+
// Use --show-toplevel to find the root of the current working copy.
26+
// In a worktree, this returns the root of the worktree.
27+
// In a regular clone, this returns the root of the clone.
28+
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
29+
30+
// Filter out GIT_DIR and GIT_WORK_TREE to ensure correct behavior in hook contexts.
31+
cmd.Env = filterGitEnv(os.Environ())
32+
33+
out, err := cmd.Output()
34+
if err != nil {
35+
return "", fmt.Errorf("%w: %w", ErrNotGitRepo, err)
36+
}
37+
38+
rootDir := strings.TrimSpace(string(out))
39+
if rootDir == "" {
40+
return "", ErrNotGitRepo
41+
}
42+
43+
// Resolve symlinks (important on macOS where /var is a symlink to /private/var)
44+
resolved, err := filepath.EvalSymlinks(rootDir)
45+
if err != nil {
46+
return filepath.Clean(rootDir), nil //nolint:nilerr // This is an intentional fallback.
47+
}
48+
49+
return filepath.Clean(resolved), nil
50+
}
51+
52+
// filterGitEnv removes GIT_DIR and GIT_WORK_TREE from the environment.
53+
func filterGitEnv(env []string) []string {
54+
var filtered []string
55+
for _, e := range env {
56+
if strings.HasPrefix(e, "GIT_DIR=") || strings.HasPrefix(e, "GIT_WORK_TREE=") {
57+
continue
58+
}
59+
filtered = append(filtered, e)
60+
}
61+
return filtered
62+
}

pkg/gitutils/reporoot_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package gitutils
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestGetRepoRoot(t *testing.T) {
13+
// Create a temp directory for the test
14+
tmpDir := t.TempDir()
15+
tmpDir, err := filepath.EvalSymlinks(tmpDir)
16+
require.NoError(t, err)
17+
18+
t.Chdir(tmpDir)
19+
_, err = GetRepoRoot()
20+
require.Error(t, err)
21+
require.ErrorIs(t, err, ErrNotGitRepo)
22+
23+
// In a regular git repo
24+
mainRepoDir := filepath.Join(tmpDir, "main-repo")
25+
require.NoError(t, os.MkdirAll(mainRepoDir, 0o755))
26+
testGitInit(t, mainRepoDir)
27+
t.Chdir(mainRepoDir)
28+
29+
root, err := GetRepoRoot()
30+
require.NoError(t, err)
31+
require.Equal(t, mainRepoDir, root)
32+
33+
// In a subdirectory of a git repo
34+
subDir := filepath.Join(mainRepoDir, "subdir")
35+
require.NoError(t, os.MkdirAll(subDir, 0o755))
36+
t.Chdir(subDir)
37+
root, err = GetRepoRoot()
38+
require.NoError(t, err)
39+
require.Equal(t, mainRepoDir, root)
40+
41+
// In a worktree
42+
// We need a commit to create a worktree
43+
runGit(t, mainRepoDir, "config", "user.email", "test@example.com")
44+
runGit(t, mainRepoDir, "config", "user.name", "Test User")
45+
require.NoError(t, os.WriteFile(filepath.Join(mainRepoDir, "file.txt"), []byte("hello"), 0o644))
46+
runGit(t, mainRepoDir, "add", "file.txt")
47+
runGit(t, mainRepoDir, "commit", "-m", "initial commit")
48+
49+
worktreeDir := filepath.Join(tmpDir, "worktree")
50+
runGit(t, mainRepoDir, "worktree", "add", worktreeDir)
51+
52+
t.Chdir(worktreeDir)
53+
root, err = GetRepoRoot()
54+
require.NoError(t, err)
55+
require.Equal(t, worktreeDir, root)
56+
}
57+
58+
func TestGetRepoRoot_HookContext(t *testing.T) {
59+
// Create a temp directory for the test
60+
tmpDir := t.TempDir()
61+
tmpDir, err := filepath.EvalSymlinks(tmpDir)
62+
require.NoError(t, err)
63+
64+
// In a regular git repo
65+
mainRepoDir := filepath.Join(tmpDir, "main-repo")
66+
require.NoError(t, os.MkdirAll(mainRepoDir, 0o755))
67+
testGitInit(t, mainRepoDir)
68+
69+
// In a subdirectory of a git repo
70+
subDir := filepath.Join(mainRepoDir, "subdir")
71+
require.NoError(t, os.MkdirAll(subDir, 0o755))
72+
73+
// Simulate a hook context where GIT_DIR and GIT_WORK_TREE are set.
74+
// This usually happens when git runs a hook.
75+
// If we're in 'subdir' and GIT_DIR is '../.git', we want to make sure
76+
// GetRepoRoot still returns the correct root.
77+
78+
// First, let's see what happens without environment variables
79+
t.Chdir(subDir)
80+
root, err := GetRepoRoot()
81+
require.NoError(t, err)
82+
require.Equal(t, mainRepoDir, root)
83+
84+
// Now set environment variables as if we're in a hook
85+
// We'll set GIT_DIR to point to the .git directory relative to the current dir
86+
t.Setenv("GIT_DIR", filepath.Join("..", ".git"))
87+
t.Setenv("GIT_WORK_TREE", "..")
88+
89+
// If we are in 'subdir', but we set GIT_WORK_TREE to '.', we want to see if
90+
// rev-parse returns the current directory instead of the main repo root.
91+
t.Setenv("GIT_WORK_TREE", ".")
92+
93+
root, err = GetRepoRoot()
94+
require.NoError(t, err)
95+
// If the fix is NOT applied, GetRepoRoot() might return 'subDir'
96+
// because git rev-parse --show-toplevel respects GIT_WORK_TREE.
97+
require.Equal(t, mainRepoDir, root)
98+
}
99+
100+
func testGitInit(t *testing.T, dir string) {
101+
t.Helper()
102+
cmd := exec.Command("git", "init", "--template=")
103+
cmd.Dir = dir
104+
cmd.Env = append(os.Environ(),
105+
"GIT_CONFIG_GLOBAL="+os.DevNull,
106+
"GIT_CONFIG_SYSTEM="+os.DevNull,
107+
)
108+
if err := cmd.Run(); err != nil {
109+
t.Fatalf("git init failed: %v", err)
110+
}
111+
}
112+
113+
func runGit(t *testing.T, dir string, args ...string) {
114+
t.Helper()
115+
cmd := exec.Command("git", args...)
116+
cmd.Dir = dir
117+
cmd.Env = append(os.Environ(),
118+
"GIT_CONFIG_GLOBAL="+os.DevNull,
119+
"GIT_CONFIG_SYSTEM="+os.DevNull,
120+
)
121+
out, err := cmd.CombinedOutput()
122+
if err != nil {
123+
t.Fatalf("git %v failed: %v\nOutput: %s", args, err, string(out))
124+
}
125+
}

pkg/stave/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,8 @@ func Stavefiles(stavePath, goos, goarch string, isStavefilesDirectory bool) ([]s
544544
// filter out the non-stave files from the stave files.
545545
var files []string
546546
for _, f := range staveFiles {
547-
if f != "" && !lo.HasKey(exclude, f) {
547+
// exclude any generated mainfiles from previous or concurrent runs.
548+
if f != "" && !lo.HasKey(exclude, f) && !strings.HasPrefix(filepath.Base(f), mainFileBase) {
548549
files = append(files, f)
549550
}
550551
}

0 commit comments

Comments
 (0)