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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ wt config path # print the config file path
# Place a .wt.toml in a repo root to override global config for that repo
```

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

### Status Dashboard

![wt status](docs/wt-status.gif)
Expand Down
1 change: 1 addition & 0 deletions cmd/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ var checkoutCmd = &cobra.Command{
if err != nil {
return err
}
warnIfCaseInsensitivePathCollision(path)

hookEnv := buildHookEnv(info, branch, path)

Expand Down
1 change: 1 addition & 0 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ var createCmd = &cobra.Command{
if err != nil {
return err
}
warnIfCaseInsensitivePathCollision(path)

hookEnv := buildHookEnv(info, branch, path)

Expand Down
29 changes: 20 additions & 9 deletions cmd/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,22 +222,33 @@ func getPRNumber(input string) (string, error) {
}

func worktreeExists(branch string) (string, bool) {
cmd := exec.Command("git", "worktree", "list")
output, err := cmd.Output()
entries, err := getWorktreeListPorcelain()
if err != nil {
return "", false
}

lines := strings.Split(string(output), "\n")
searchPattern := fmt.Sprintf("[%s]", branch)
for _, line := range lines {
if strings.Contains(line, searchPattern) {
fields := strings.Fields(line)
if len(fields) > 0 {
return fields[0], true
return findWorktreeByBranch(entries, branch, filesystemCaseInsensitive(".") || filesystemCaseInsensitive(worktreeRoot))
}

func findWorktreeByBranch(entries []worktreeListEntry, branch string, caseInsensitive bool) (string, bool) {
if branch == "" {
return "", false
}

for _, entry := range entries {
if entry.Branch == branch {
return entry.Path, true
}
}

if caseInsensitive {
for _, entry := range entries {
if strings.EqualFold(entry.Branch, branch) {
return entry.Path, true
}
}
}

return "", false
}

Expand Down
36 changes: 36 additions & 0 deletions cmd/repo_case_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package cmd

import "testing"

func TestFindWorktreeByBranchCaseInsensitiveFallback(t *testing.T) {
entries := []worktreeListEntry{
{Path: "/worktrees/repo/Feature/make-it-work", Branch: "Feature/make-it-work"},
}

if got, ok := findWorktreeByBranch(entries, "feature/make-it-work", false); ok || got != "" {
t.Fatalf("case-sensitive lookup = (%q, %v), want no match", got, ok)
}

got, ok := findWorktreeByBranch(entries, "feature/make-it-work", true)
if !ok {
t.Fatal("case-insensitive lookup did not find worktree")
}
if want := "/worktrees/repo/Feature/make-it-work"; got != want {
t.Fatalf("case-insensitive lookup path = %q, want %q", got, want)
}
}

func TestFindWorktreeByBranchExactMatchWins(t *testing.T) {
entries := []worktreeListEntry{
{Path: "/worktrees/repo/Feature/make-it-work", Branch: "Feature/make-it-work"},
{Path: "/worktrees/repo/feature/make-it-work", Branch: "feature/make-it-work"},
}

got, ok := findWorktreeByBranch(entries, "feature/make-it-work", true)
if !ok {
t.Fatal("lookup did not find exact worktree")
}
if want := "/worktrees/repo/feature/make-it-work"; got != want {
t.Fatalf("lookup path = %q, want exact path %q", got, want)
}
}
111 changes: 111 additions & 0 deletions cmd/worktree_path.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"text/template"
)
Expand Down Expand Up @@ -121,6 +122,116 @@ func cleanupWorktreePath(worktreePath string) error {
return nil
}

func warnIfCaseInsensitivePathCollision(worktreePath string) {
if isJSONOutput() || !filesystemCaseInsensitive(worktreePath) {
return
}

if existingPath, ok := findCaseInsensitivePathCollision(worktreePath); ok {
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)
}
}

func findCaseInsensitivePathCollision(path string) (string, bool) {
path = filepath.Clean(path)
volume := filepath.VolumeName(path)
rest := strings.TrimPrefix(path, volume)

current := volume
if filepath.IsAbs(path) {
current += string(os.PathSeparator)
rest = strings.TrimPrefix(rest, string(os.PathSeparator))
} else if current == "" {
current = "."
}

for _, part := range strings.Split(rest, string(os.PathSeparator)) {
if part == "" || part == "." {
continue
}

entries, err := os.ReadDir(current)
if err != nil {
return "", false
}

exactPath := filepath.Join(current, part)
foundExact := false
for _, entry := range entries {
name := entry.Name()
if name == part {
foundExact = true
break
}
if strings.EqualFold(name, part) {
return filepath.Join(current, name), true
}
}
if !foundExact {
return "", false
}

current = exactPath
}

return "", false
}

func filesystemCaseInsensitive(path string) bool {
if runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
return false
}

dir := nearestExistingDir(path)
if dir == "" {
return runtime.GOOS == "windows"
}

file, err := os.CreateTemp(dir, ".wt-case-test-")
if err != nil {
return runtime.GOOS == "windows"
}
name := file.Name()
_ = file.Close()
defer func() { _ = os.Remove(name) }()

altName := filepath.Join(dir, strings.ToUpper(filepath.Base(name)))
if altName == name {
altName = filepath.Join(dir, strings.ToLower(filepath.Base(name)))
}
if altName == name {
return false
}

_, err = os.Stat(altName)
return err == nil
}

func nearestExistingDir(path string) string {
if path == "" {
path = "."
}

path = filepath.Clean(path)
if info, err := os.Stat(path); err == nil {
if info.IsDir() {
return path
}
return filepath.Dir(path)
}

for {
parent := filepath.Dir(path)
if parent == path {
return ""
}
if info, err := os.Stat(parent); err == nil && info.IsDir() {
return parent
}
path = parent
}
}

func resolveWorktreePattern() (string, error) {
if worktreePattern != "" {
return worktreePattern, nil
Expand Down
54 changes: 54 additions & 0 deletions cmd/worktree_path_case_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package cmd

import (
"os"
"path/filepath"
"testing"
)

func TestFindCaseInsensitivePathCollisionNestedBranchPrefix(t *testing.T) {
tmpDir := t.TempDir()
existing := filepath.Join(tmpDir, "repo", "feature")
if err := os.MkdirAll(existing, 0o755); err != nil {
t.Fatalf("failed to create existing path: %v", err)
}

candidate := filepath.Join(tmpDir, "repo", "Feature", "make-it-work")
got, ok := findCaseInsensitivePathCollision(candidate)
if !ok {
t.Fatal("expected case-insensitive path collision")
}
if got != existing {
t.Fatalf("collision path = %q, want %q", got, existing)
}
}

func TestFindCaseInsensitivePathCollisionFlatDifferentBranches(t *testing.T) {
tmpDir := t.TempDir()
existing := filepath.Join(tmpDir, "repo", "feature-add-logging")
if err := os.MkdirAll(existing, 0o755); err != nil {
t.Fatalf("failed to create existing path: %v", err)
}

candidate := filepath.Join(tmpDir, "repo", "Feature-make-it-work")
if got, ok := findCaseInsensitivePathCollision(candidate); ok {
t.Fatalf("unexpected collision with %q", got)
}
}

func TestFindCaseInsensitivePathCollisionCaseOnlyFlatBranch(t *testing.T) {
tmpDir := t.TempDir()
existing := filepath.Join(tmpDir, "repo", "feature-make-it-work")
if err := os.MkdirAll(existing, 0o755); err != nil {
t.Fatalf("failed to create existing path: %v", err)
}

candidate := filepath.Join(tmpDir, "repo", "Feature-make-it-work")
got, ok := findCaseInsensitivePathCollision(candidate)
if !ok {
t.Fatal("expected case-only flat path collision")
}
if got != existing {
t.Fatalf("collision path = %q, want %q", got, existing)
}
}
43 changes: 43 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,49 @@ The `separator` setting controls how `/` and `\` characters in **value variables
| `_` | `feat_foo` (flat) | Alternative flat layout |
| `""` | `featfoo` | Compact (rarely used) |

### Case-Insensitive Filesystems

The default macOS APFS setup is case-insensitive. That means paths such as
`Feature/foo` and `feature/bar` share the same first path component from the
filesystem's point of view, even though Git branch names are case-sensitive.

With the default separator, branch names with slashes become nested directories:

```text
Feature/make-it-work -> ~/dev/worktrees/repo/Feature/make-it-work
feature/add-logging -> ~/dev/worktrees/repo/feature/add-logging
```

On a case-insensitive filesystem, `Feature` and `feature` refer to the same
directory. This can make `wt create`, `wt checkout`, `wt remove`, shell
completion, and manual `git checkout` commands appear to disagree about the
current branch or worktree path. When `wt` detects this before creating a
worktree, it prints a warning with the colliding path component.

If your repositories use mixed-case branch prefixes such as `Feature/...`, prefer
a flat path layout:

```toml
separator = "-"
```

That maps branches to paths like:

```text
Feature/make-it-work -> ~/dev/worktrees/repo/Feature-make-it-work
feature/add-logging -> ~/dev/worktrees/repo/feature-add-logging
```

Changing `separator` affects newly created path calculations. Run `wt migrate`
or recreate existing worktrees if you want current worktrees to use the new
layout.

This avoids collisions between unrelated branch prefixes such as `Feature/...`
and `feature/...`. It does not make case-only branch names safe: `Feature/foo`
and `feature/foo` still map to names that collide on a case-insensitive
filesystem. Avoid case-only branch differences, or place the repository and
worktree root on a case-sensitive filesystem.

## Hooks

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