From 083aa5de61b82f7e7f85de490758a9a9bc38de82 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:30:41 -0700 Subject: [PATCH] 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. --- src/main/ipc/worktree-path-comparison.ts | 13 ++++++- .../ipc/worktree-path-deduplication.test.ts | 21 ++++++++++++ .../path-equal-worktree-row-collapse.test.ts | 34 +++++++++++++++++++ .../path-equal-worktree-row-collapse.ts | 5 +-- 4 files changed, 70 insertions(+), 3 deletions(-) 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[],