fix(worktree): keep case-different WSL UNC worktree rows distinct

WSL UNC paths matched isWindowsAbsolutePathLike, so the worktree path
comparison key lowercased the whole path and the new row collapse merged
two real WSL worktrees that differ only by case, dropping one from the
resolved catalog. Fold only the alias and distro, which are UNC
server/share names; the Linux filesystem they front is case-sensitive.
This commit is contained in:
Neil
2026-08-21 23:30:41 -07:00
parent 98d94beff5
commit 083aa5de61
4 changed files with 70 additions and 3 deletions
+12 -1
View File
@@ -99,8 +99,19 @@ function looksLikePosixAbsolutePath(pathValue: string): boolean {
return pathValue.startsWith('/') && !pathValue.startsWith('//')
}
/** `\\\\wsl$\\<distro>` / `\\\\wsl.localhost\\<distro>` prefix of an already win32-normalized path. */
const WSL_UNC_COMPARISON_PREFIX = /^\\\\(wsl\.localhost|wsl\$)\\([^\\]+)(\\[\s\S]*)?$/i
function normalizeWindowsWorktreePathForComparison(pathValue: string): string {
return win32.normalize(win32.resolve(pathValue)).toLowerCase()
const normalized = win32.normalize(win32.resolve(pathValue))
const wslUnc = normalized.match(WSL_UNC_COMPARISON_PREFIX)
if (!wslUnc) {
return normalized.toLowerCase()
}
// Why: the WSL UNC aliases front a case-SENSITIVE Linux filesystem, so folding the
// whole path merges two real worktrees that differ only by case and drops one row.
// Only the alias and distro fold, matching the UNC server/share rule.
return `\\\\${wslUnc[1].toLowerCase()}\\${wslUnc[2].toLowerCase()}${wslUnc[3] ?? ''}`
}
function normalizePosixWorktreePathForComparison(
@@ -44,6 +44,27 @@ describe('dedupeWorktreesByPath', () => {
}
)
it.each<NodeJS.Platform>(['darwin', 'linux', 'win32'])(
'keeps case-different WSL UNC worktrees on %s while folding alias and distro case',
(platform) => {
const worktrees = [
{ id: 'wsl-upper', path: String.raw`\\wsl$\Ubuntu\home\dev\wt\Feature` },
{ id: 'wsl-lower', path: String.raw`\\wsl$\Ubuntu\home\dev\wt\feature` },
{ id: 'wsl-alias-case-duplicate', path: '//WSL$/ubuntu/home/dev/wt/./Feature' },
{ id: 'wsl-localhost', path: String.raw`\\wsl.localhost\Ubuntu\home\dev\wt\Feature` }
]
expect(dedupeWorktreesByPath(worktrees, platform).map((entry) => entry.id)).toEqual([
'wsl-upper',
'wsl-lower',
'wsl-localhost'
])
expect(dedupeWorktreesByPath(worktrees, platform)).toEqual(
dedupeWithComparator(worktrees, platform)
)
}
)
it('reads each path once for a large unique list', () => {
let pathReads = 0
const worktrees = Array.from({ length: 1_000 }, (_, index) => ({
@@ -53,6 +53,40 @@ describe('collapsePathEqualWorktreeRows', () => {
).toEqual([String.raw`C:\Work\Feature`])
})
it.each<NodeJS.Platform>(['win32', 'linux', 'darwin'])(
'keeps case-different WSL UNC worktrees apart on %s',
(platform) => {
const rows = [
row(String.raw`\\wsl$\Ubuntu\home\me\wt\Feature`, { branch: 'refs/heads/Feature' }),
row(String.raw`\\wsl$\Ubuntu\home\me\wt\feature`, { branch: 'refs/heads/feature' }),
row(String.raw`\\wsl.localhost\Ubuntu\home\me\wt\Other`),
row(String.raw`\\wsl.localhost\Ubuntu\home\me\wt\other`)
]
expect(
collapsePathEqualWorktreeRows(rows, {
repoPath: String.raw`\\wsl$\Ubuntu\home\me\repo`,
platform
}).map((entry) => entry.path)
).toEqual(rows.map((entry) => entry.path))
}
)
it('collapses WSL UNC rows differing only by separator, dot segment or distro case', () => {
const rows = [
row(String.raw`\\wsl$\Ubuntu\home\me\wt\Feature`),
row('//wsl$/ubuntu/home/me/wt/./Feature/'),
row(String.raw`\\WSL$\Ubuntu\home\me\wt\Feature`)
]
expect(
collapsePathEqualWorktreeRows(rows, {
repoPath: String.raw`\\wsl$\Ubuntu\home\me\repo`,
platform: 'win32'
}).map((entry) => entry.path)
).toEqual([String.raw`\\wsl$\Ubuntu\home\me\wt\Feature`])
})
it('keeps /tmp and /private/tmp apart for rows produced on a non-darwin host', () => {
const rows = [row('/private/tmp/orca/feature'), row('/tmp/orca/feature')]
@@ -31,8 +31,9 @@ export function resolveRepoWorktreePathPlatform(repo: Repo): NodeJS.Platform {
* metadata, else Git's first row) so the derived worktree id stays stable, and adopts the branch,
* head and flags of the peers it absorbs.
*
* POSIX paths are never case-folded, including on macOS: APFS/HFSX can be formatted case-sensitive,
* and silently dropping a real worktree row is worse than showing a duplicate one.
* Case-sensitive roots are never case-folded — POSIX paths (including on macOS, where APFS/HFSX can
* be formatted case-sensitive) and the WSL UNC aliases Windows hosts report worktrees under.
* Silently dropping a real worktree row is worse than showing a duplicate one.
*/
export function collapsePathEqualWorktreeRows(
rows: readonly GitWorktreeInfo[],