diff --git a/README.md b/README.md index cd62373..2f81d1f 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/cmd/checkout.go b/cmd/checkout.go index 69a72d9..017a954 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -92,6 +92,7 @@ var checkoutCmd = &cobra.Command{ if err != nil { return err } + warnIfCaseInsensitivePathCollision(path) hookEnv := buildHookEnv(info, branch, path) diff --git a/cmd/create.go b/cmd/create.go index 4219dc3..8a71e2b 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -51,6 +51,7 @@ var createCmd = &cobra.Command{ if err != nil { return err } + warnIfCaseInsensitivePathCollision(path) hookEnv := buildHookEnv(info, branch, path) diff --git a/cmd/repo.go b/cmd/repo.go index b8a882d..ba11b7f 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -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 } diff --git a/cmd/repo_case_test.go b/cmd/repo_case_test.go new file mode 100644 index 0000000..ad26925 --- /dev/null +++ b/cmd/repo_case_test.go @@ -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) + } +} diff --git a/cmd/worktree_path.go b/cmd/worktree_path.go index 9a2e24c..e2edd75 100644 --- a/cmd/worktree_path.go +++ b/cmd/worktree_path.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "text/template" ) @@ -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 diff --git a/cmd/worktree_path_case_test.go b/cmd/worktree_path_case_test.go new file mode 100644 index 0000000..2d0225d --- /dev/null +++ b/cmd/worktree_path_case_test.go @@ -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) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index a4b4ea8..bc5ff12 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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: