|
5 | 5 | "fmt" |
6 | 6 | "os" |
7 | 7 | "path/filepath" |
| 8 | + "runtime" |
8 | 9 | "strings" |
9 | 10 | "text/template" |
10 | 11 | ) |
@@ -121,6 +122,116 @@ func cleanupWorktreePath(worktreePath string) error { |
121 | 122 | return nil |
122 | 123 | } |
123 | 124 |
|
| 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 | + |
124 | 235 | func resolveWorktreePattern() (string, error) { |
125 | 236 | if worktreePattern != "" { |
126 | 237 | return worktreePattern, nil |
|
0 commit comments