Skip to content

Commit 8cad5eb

Browse files
authored
fix: warn on case-insensitive worktree collisions
Warn before creating worktrees whose computed paths collide on case-insensitive filesystems, make worktree lookup handle case-folded branch names, and document the macOS gotcha with separator guidance.
1 parent 39b8446 commit 8cad5eb

8 files changed

Lines changed: 272 additions & 9 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ wt config path # print the config file path
101101
# Place a .wt.toml in a repo root to override global config for that repo
102102
```
103103

104+
On case-insensitive filesystems such as the default macOS APFS setup, mixed-case branch
105+
prefixes can produce confusing worktree paths. For example, `Feature/foo` and
106+
`feature/bar` both need a first-level directory that macOS treats as the same name.
107+
Set `separator = "-"` to flatten branch paths (`Feature/foo` -> `Feature-foo`) and
108+
avoid that class of collision. See [Configuration](docs/configuration.md#case-insensitive-filesystems).
109+
104110
### Status Dashboard
105111

106112
![wt status](docs/wt-status.gif)

cmd/checkout.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ var checkoutCmd = &cobra.Command{
9292
if err != nil {
9393
return err
9494
}
95+
warnIfCaseInsensitivePathCollision(path)
9596

9697
hookEnv := buildHookEnv(info, branch, path)
9798

cmd/create.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ var createCmd = &cobra.Command{
5151
if err != nil {
5252
return err
5353
}
54+
warnIfCaseInsensitivePathCollision(path)
5455

5556
hookEnv := buildHookEnv(info, branch, path)
5657

cmd/repo.go

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -222,22 +222,33 @@ func getPRNumber(input string) (string, error) {
222222
}
223223

224224
func worktreeExists(branch string) (string, bool) {
225-
cmd := exec.Command("git", "worktree", "list")
226-
output, err := cmd.Output()
225+
entries, err := getWorktreeListPorcelain()
227226
if err != nil {
228227
return "", false
229228
}
230229

231-
lines := strings.Split(string(output), "\n")
232-
searchPattern := fmt.Sprintf("[%s]", branch)
233-
for _, line := range lines {
234-
if strings.Contains(line, searchPattern) {
235-
fields := strings.Fields(line)
236-
if len(fields) > 0 {
237-
return fields[0], true
230+
return findWorktreeByBranch(entries, branch, filesystemCaseInsensitive(".") || filesystemCaseInsensitive(worktreeRoot))
231+
}
232+
233+
func findWorktreeByBranch(entries []worktreeListEntry, branch string, caseInsensitive bool) (string, bool) {
234+
if branch == "" {
235+
return "", false
236+
}
237+
238+
for _, entry := range entries {
239+
if entry.Branch == branch {
240+
return entry.Path, true
241+
}
242+
}
243+
244+
if caseInsensitive {
245+
for _, entry := range entries {
246+
if strings.EqualFold(entry.Branch, branch) {
247+
return entry.Path, true
238248
}
239249
}
240250
}
251+
241252
return "", false
242253
}
243254

cmd/repo_case_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package cmd
2+
3+
import "testing"
4+
5+
func TestFindWorktreeByBranchCaseInsensitiveFallback(t *testing.T) {
6+
entries := []worktreeListEntry{
7+
{Path: "/worktrees/repo/Feature/make-it-work", Branch: "Feature/make-it-work"},
8+
}
9+
10+
if got, ok := findWorktreeByBranch(entries, "feature/make-it-work", false); ok || got != "" {
11+
t.Fatalf("case-sensitive lookup = (%q, %v), want no match", got, ok)
12+
}
13+
14+
got, ok := findWorktreeByBranch(entries, "feature/make-it-work", true)
15+
if !ok {
16+
t.Fatal("case-insensitive lookup did not find worktree")
17+
}
18+
if want := "/worktrees/repo/Feature/make-it-work"; got != want {
19+
t.Fatalf("case-insensitive lookup path = %q, want %q", got, want)
20+
}
21+
}
22+
23+
func TestFindWorktreeByBranchExactMatchWins(t *testing.T) {
24+
entries := []worktreeListEntry{
25+
{Path: "/worktrees/repo/Feature/make-it-work", Branch: "Feature/make-it-work"},
26+
{Path: "/worktrees/repo/feature/make-it-work", Branch: "feature/make-it-work"},
27+
}
28+
29+
got, ok := findWorktreeByBranch(entries, "feature/make-it-work", true)
30+
if !ok {
31+
t.Fatal("lookup did not find exact worktree")
32+
}
33+
if want := "/worktrees/repo/feature/make-it-work"; got != want {
34+
t.Fatalf("lookup path = %q, want exact path %q", got, want)
35+
}
36+
}

cmd/worktree_path.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8+
"runtime"
89
"strings"
910
"text/template"
1011
)
@@ -121,6 +122,116 @@ func cleanupWorktreePath(worktreePath string) error {
121122
return nil
122123
}
123124

125+
func warnIfCaseInsensitivePathCollision(worktreePath string) {
126+
if isJSONOutput() || !filesystemCaseInsensitive(worktreePath) {
127+
return
128+
}
129+
130+
if existingPath, ok := findCaseInsensitivePathCollision(worktreePath); ok {
131+
fmt.Fprintf(os.Stderr, "Warning: worktree path %s collides with existing path %s on this case-insensitive filesystem. Consider setting separator = \"-\" in your wt config or avoiding case-only branch names.\n", worktreePath, existingPath)
132+
}
133+
}
134+
135+
func findCaseInsensitivePathCollision(path string) (string, bool) {
136+
path = filepath.Clean(path)
137+
volume := filepath.VolumeName(path)
138+
rest := strings.TrimPrefix(path, volume)
139+
140+
current := volume
141+
if filepath.IsAbs(path) {
142+
current += string(os.PathSeparator)
143+
rest = strings.TrimPrefix(rest, string(os.PathSeparator))
144+
} else if current == "" {
145+
current = "."
146+
}
147+
148+
for _, part := range strings.Split(rest, string(os.PathSeparator)) {
149+
if part == "" || part == "." {
150+
continue
151+
}
152+
153+
entries, err := os.ReadDir(current)
154+
if err != nil {
155+
return "", false
156+
}
157+
158+
exactPath := filepath.Join(current, part)
159+
foundExact := false
160+
for _, entry := range entries {
161+
name := entry.Name()
162+
if name == part {
163+
foundExact = true
164+
break
165+
}
166+
if strings.EqualFold(name, part) {
167+
return filepath.Join(current, name), true
168+
}
169+
}
170+
if !foundExact {
171+
return "", false
172+
}
173+
174+
current = exactPath
175+
}
176+
177+
return "", false
178+
}
179+
180+
func filesystemCaseInsensitive(path string) bool {
181+
if runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
182+
return false
183+
}
184+
185+
dir := nearestExistingDir(path)
186+
if dir == "" {
187+
return runtime.GOOS == "windows"
188+
}
189+
190+
file, err := os.CreateTemp(dir, ".wt-case-test-")
191+
if err != nil {
192+
return runtime.GOOS == "windows"
193+
}
194+
name := file.Name()
195+
_ = file.Close()
196+
defer func() { _ = os.Remove(name) }()
197+
198+
altName := filepath.Join(dir, strings.ToUpper(filepath.Base(name)))
199+
if altName == name {
200+
altName = filepath.Join(dir, strings.ToLower(filepath.Base(name)))
201+
}
202+
if altName == name {
203+
return false
204+
}
205+
206+
_, err = os.Stat(altName)
207+
return err == nil
208+
}
209+
210+
func nearestExistingDir(path string) string {
211+
if path == "" {
212+
path = "."
213+
}
214+
215+
path = filepath.Clean(path)
216+
if info, err := os.Stat(path); err == nil {
217+
if info.IsDir() {
218+
return path
219+
}
220+
return filepath.Dir(path)
221+
}
222+
223+
for {
224+
parent := filepath.Dir(path)
225+
if parent == path {
226+
return ""
227+
}
228+
if info, err := os.Stat(parent); err == nil && info.IsDir() {
229+
return parent
230+
}
231+
path = parent
232+
}
233+
}
234+
124235
func resolveWorktreePattern() (string, error) {
125236
if worktreePattern != "" {
126237
return worktreePattern, nil

cmd/worktree_path_case_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestFindCaseInsensitivePathCollisionNestedBranchPrefix(t *testing.T) {
10+
tmpDir := t.TempDir()
11+
existing := filepath.Join(tmpDir, "repo", "feature")
12+
if err := os.MkdirAll(existing, 0o755); err != nil {
13+
t.Fatalf("failed to create existing path: %v", err)
14+
}
15+
16+
candidate := filepath.Join(tmpDir, "repo", "Feature", "make-it-work")
17+
got, ok := findCaseInsensitivePathCollision(candidate)
18+
if !ok {
19+
t.Fatal("expected case-insensitive path collision")
20+
}
21+
if got != existing {
22+
t.Fatalf("collision path = %q, want %q", got, existing)
23+
}
24+
}
25+
26+
func TestFindCaseInsensitivePathCollisionFlatDifferentBranches(t *testing.T) {
27+
tmpDir := t.TempDir()
28+
existing := filepath.Join(tmpDir, "repo", "feature-add-logging")
29+
if err := os.MkdirAll(existing, 0o755); err != nil {
30+
t.Fatalf("failed to create existing path: %v", err)
31+
}
32+
33+
candidate := filepath.Join(tmpDir, "repo", "Feature-make-it-work")
34+
if got, ok := findCaseInsensitivePathCollision(candidate); ok {
35+
t.Fatalf("unexpected collision with %q", got)
36+
}
37+
}
38+
39+
func TestFindCaseInsensitivePathCollisionCaseOnlyFlatBranch(t *testing.T) {
40+
tmpDir := t.TempDir()
41+
existing := filepath.Join(tmpDir, "repo", "feature-make-it-work")
42+
if err := os.MkdirAll(existing, 0o755); err != nil {
43+
t.Fatalf("failed to create existing path: %v", err)
44+
}
45+
46+
candidate := filepath.Join(tmpDir, "repo", "Feature-make-it-work")
47+
got, ok := findCaseInsensitivePathCollision(candidate)
48+
if !ok {
49+
t.Fatal("expected case-only flat path collision")
50+
}
51+
if got != existing {
52+
t.Fatalf("collision path = %q, want %q", got, existing)
53+
}
54+
}

docs/configuration.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,49 @@ The `separator` setting controls how `/` and `\` characters in **value variables
8282
| `_` | `feat_foo` (flat) | Alternative flat layout |
8383
| `""` | `featfoo` | Compact (rarely used) |
8484

85+
### Case-Insensitive Filesystems
86+
87+
The default macOS APFS setup is case-insensitive. That means paths such as
88+
`Feature/foo` and `feature/bar` share the same first path component from the
89+
filesystem's point of view, even though Git branch names are case-sensitive.
90+
91+
With the default separator, branch names with slashes become nested directories:
92+
93+
```text
94+
Feature/make-it-work -> ~/dev/worktrees/repo/Feature/make-it-work
95+
feature/add-logging -> ~/dev/worktrees/repo/feature/add-logging
96+
```
97+
98+
On a case-insensitive filesystem, `Feature` and `feature` refer to the same
99+
directory. This can make `wt create`, `wt checkout`, `wt remove`, shell
100+
completion, and manual `git checkout` commands appear to disagree about the
101+
current branch or worktree path. When `wt` detects this before creating a
102+
worktree, it prints a warning with the colliding path component.
103+
104+
If your repositories use mixed-case branch prefixes such as `Feature/...`, prefer
105+
a flat path layout:
106+
107+
```toml
108+
separator = "-"
109+
```
110+
111+
That maps branches to paths like:
112+
113+
```text
114+
Feature/make-it-work -> ~/dev/worktrees/repo/Feature-make-it-work
115+
feature/add-logging -> ~/dev/worktrees/repo/feature-add-logging
116+
```
117+
118+
Changing `separator` affects newly created path calculations. Run `wt migrate`
119+
or recreate existing worktrees if you want current worktrees to use the new
120+
layout.
121+
122+
This avoids collisions between unrelated branch prefixes such as `Feature/...`
123+
and `feature/...`. It does not make case-only branch names safe: `Feature/foo`
124+
and `feature/foo` still map to names that collide on a case-insensitive
125+
filesystem. Avoid case-only branch differences, or place the repository and
126+
worktree root on a case-sensitive filesystem.
127+
85128
## Hooks
86129

87130
Hooks let you run custom commands before or after `wt` operations. Define them in the `[hooks]` section of your config file:

0 commit comments

Comments
 (0)