diff --git a/src/main/ipc/worktree-path-comparison.ts b/src/main/ipc/worktree-path-comparison.ts index 71b4055752e..8eaa0135efb 100644 --- a/src/main/ipc/worktree-path-comparison.ts +++ b/src/main/ipc/worktree-path-comparison.ts @@ -99,8 +99,19 @@ function looksLikePosixAbsolutePath(pathValue: string): boolean { return pathValue.startsWith('/') && !pathValue.startsWith('//') } +/** `\\\\wsl$\\` / `\\\\wsl.localhost\\` 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( diff --git a/src/main/ipc/worktree-path-deduplication.test.ts b/src/main/ipc/worktree-path-deduplication.test.ts index 70ebbbc4004..2927e61dcf0 100644 --- a/src/main/ipc/worktree-path-deduplication.test.ts +++ b/src/main/ipc/worktree-path-deduplication.test.ts @@ -44,6 +44,27 @@ describe('dedupeWorktreesByPath', () => { } ) + it.each(['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) => ({ diff --git a/src/main/runtime/path-equal-worktree-row-collapse.test.ts b/src/main/runtime/path-equal-worktree-row-collapse.test.ts index ef491118ac3..bafb0c2cbf0 100644 --- a/src/main/runtime/path-equal-worktree-row-collapse.test.ts +++ b/src/main/runtime/path-equal-worktree-row-collapse.test.ts @@ -53,6 +53,40 @@ describe('collapsePathEqualWorktreeRows', () => { ).toEqual([String.raw`C:\Work\Feature`]) }) + it.each(['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')] diff --git a/src/main/runtime/path-equal-worktree-row-collapse.ts b/src/main/runtime/path-equal-worktree-row-collapse.ts index 014ad8d13cc..bf7d9fffca6 100644 --- a/src/main/runtime/path-equal-worktree-row-collapse.ts +++ b/src/main/runtime/path-equal-worktree-row-collapse.ts @@ -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[],