From 672cc45aa8683f542bea211f1b0ee9cc1b6e8685 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 11 Sep 2026 22:41:34 -0700 Subject: [PATCH] fix(worktree): let a POSIX path keep its case on a Windows desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `canonicalWorktreePath` folded case whenever `process.platform` was win32, without asking what the path itself was. A WSL or SSH checkout is spelled `/home/alice/ws/feature` on a Windows desktop too, and ext4 is case-sensitive, so `/home/alice/ws/Feature` and `/home/alice/ws/feature` — two real checkouts on two real branches — collapsed into one row. `removeWorktree` picks the row it is about to remove with that comparison and reads the branch off it. Requesting `/home/alice/ws/feature` removed the right directory (the path rides in argv) and then ran `git branch -d -- Feature`. The same wrong row feeds `assertWorktreeUnlockedForRemoval`, so a locked twin blocks an unlocked delete and an unlocked twin lets a locked one through. Whose filesystem a path names is a property of the path, not of the desktop reading it, so a POSIX-absolute path now takes POSIX rules at any platform and a POSIX/Windows pair is never equal — `win32.resolve` would otherwise give the POSIX path a drive root and manufacture the equality. Windows drive and UNC paths, including WSL UNC aliases, keep folding case as before. Two call sites already carried private copies of this rule (`isSameCommonDirPath`, `ipc/worktree-path-comparison`); this is the same rule at the source. The removal path is the one that never got one. --- src/main/git/worktree-path-comparison.ts | 30 +++- ...rktree-posix-path-case-sensitivity.test.ts | 154 ++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/main/git/worktree-posix-path-case-sensitivity.test.ts diff --git a/src/main/git/worktree-path-comparison.ts b/src/main/git/worktree-path-comparison.ts index 96d423c3caf..acfca67b927 100644 --- a/src/main/git/worktree-path-comparison.ts +++ b/src/main/git/worktree-path-comparison.ts @@ -2,8 +2,21 @@ import { posix, win32 } from 'node:path' import type { GitWorktreeExecOptions } from './worktree-operation-options' import { translateWslOutputPaths } from './runner' -/** Normalize a worktree path for cross-platform comparison/keying: resolved, and case-folded on Windows syntax. */ +/** + * Normalize a worktree path for cross-platform comparison/keying: resolved, and case-folded on + * Windows syntax. + * + * Why a POSIX-absolute path outranks `platform`: whose filesystem a path names is a property of the + * path, not of the desktop reading it. A WSL or SSH checkout is spelled `/home/...` on a Windows + * desktop too, and folding its case there merged two case-variant checkouts into one row — enough + * for `removeWorktree` to pick the twin and delete its branch. `isSameCommonDirPath` and + * `ipc/worktree-path-comparison` each carry a local copy of this rule; this is the same rule at the + * source. + */ export function canonicalWorktreePath(pathValue: string, platform = process.platform): string { + if (looksLikePosixAbsolutePath(pathValue)) { + return posix.normalize(posix.resolve(pathValue)) + } return platform === 'win32' || looksLikeWindowsPath(pathValue) ? win32.normalize(win32.resolve(pathValue)).toLowerCase() : posix.normalize(posix.resolve(pathValue)) @@ -14,6 +27,16 @@ export function areWorktreePathsEqual( rightPath: string, platform = process.platform ): boolean { + const leftIsPosix = looksLikePosixAbsolutePath(leftPath) + if (leftIsPosix || looksLikePosixAbsolutePath(rightPath)) { + // Why not fall through: `win32.resolve` gives a POSIX path a drive root, manufacturing an + // equality with a Windows path that names a different filesystem. + return ( + leftIsPosix && + looksLikePosixAbsolutePath(rightPath) && + canonicalWorktreePath(leftPath, platform) === canonicalWorktreePath(rightPath, platform) + ) + } if (platform === 'win32' || looksLikeWindowsPath(leftPath) || looksLikeWindowsPath(rightPath)) { return canonicalWorktreePath(leftPath, 'win32') === canonicalWorktreePath(rightPath, 'win32') } @@ -24,6 +47,11 @@ function looksLikeWindowsPath(pathValue: string): boolean { return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') } +// One leading slash only: `//server/share` and WSL UNC aliases are Windows roots, not POSIX paths. +function looksLikePosixAbsolutePath(pathValue: string): boolean { + return pathValue.startsWith('/') && !pathValue.startsWith('//') +} + export function resolveRevParsePath(repoPath: string, value: string): string { if (posix.isAbsolute(value) || win32.isAbsolute(value)) { return value diff --git a/src/main/git/worktree-posix-path-case-sensitivity.test.ts b/src/main/git/worktree-posix-path-case-sensitivity.test.ts new file mode 100644 index 00000000000..8c732a05a05 --- /dev/null +++ b/src/main/git/worktree-posix-path-case-sensitivity.test.ts @@ -0,0 +1,154 @@ +/** + * A POSIX path is case-sensitive wherever it lives, including when a Windows desktop is the one + * looking at it. `git/worktree-path-comparison` decided that from `process.platform`, so on Windows + * every WSL/Linux worktree path was case-folded and two distinct checkouts read as one row. + */ +import type * as FsPromises from 'node:fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + gitExecFileAsyncMock, + gitExecFileSyncMock, + translateWslOutputPathsMock, + statMock, + readFileMock, + resolveGitDirMock, + moveWorktreeDirectoryToTrashMock, + restoreWorktreeDirectoryFromTrashMock, + scheduleWorktreeTrashDeletionMock +} = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output), + statMock: vi.fn(), + readFileMock: vi.fn(), + resolveGitDirMock: vi.fn(), + moveWorktreeDirectoryToTrashMock: vi.fn(), + restoreWorktreeDirectoryFromTrashMock: vi.fn(), + scheduleWorktreeTrashDeletionMock: vi.fn() +})) + +vi.mock('../worktree-trash', () => ({ + moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, + restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, + scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: gitExecFileSyncMock, + translateWslOutputPaths: translateWslOutputPathsMock +})) + +vi.mock('./status', () => ({ + resolveGitDir: resolveGitDirMock, + runWithGitReadCacheInvalidation: (run: () => Promise) => run() +})) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') + return { ...actual, stat: statMock, readFile: readFileMock } +}) + +import { + createGitCallReader, + createGitCommandMocker, + resetWorktreeGitMocks, + resetWorktreeRemovalState +} from './remove-worktree-test-harness' +import { areWorktreePathsEqual, canonicalWorktreePath } from './worktree-path-comparison' +import { removeWorktree } from './worktree' + +const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) +const getGitCalls = createGitCallReader(gitExecFileAsyncMock) + +describe('worktree path comparison across path syntaxes', () => { + it('keeps two POSIX worktrees that differ only in case distinct on a Windows desktop', () => { + expect(areWorktreePathsEqual('/home/alice/ws/Feature', '/home/alice/ws/feature', 'win32')).toBe( + false + ) + expect(canonicalWorktreePath('/home/alice/ws/Feature', 'win32')).not.toBe( + canonicalWorktreePath('/home/alice/ws/feature', 'win32') + ) + }) + + it('still matches the same POSIX worktree spelled with dot segments on a Windows desktop', () => { + expect( + areWorktreePathsEqual('/home/alice/ws/./feature', '/home/alice/ws/x/../feature', 'win32') + ).toBe(true) + }) + + it('still folds Windows drive paths by case and slash style', () => { + expect(areWorktreePathsEqual('C:/Users/Bob/wt', 'c:\\Users\\bob\\wt', 'win32')).toBe(true) + expect(areWorktreePathsEqual('C:/Users/Bob/wt', 'c:\\Users\\bob\\wt', 'darwin')).toBe(true) + }) + + it('still folds a WSL UNC path by case, which Windows resolves case-insensitively', () => { + expect( + areWorktreePathsEqual( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\wt', + '\\\\WSL.LOCALHOST\\ubuntu\\home\\alice\\wt', + 'win32' + ) + ).toBe(true) + }) + + it('never equates a POSIX path with a Windows path', () => { + expect( + areWorktreePathsEqual('/home/alice/wt', '\\\\wsl.localhost\\Ubuntu\\home\\alice\\wt', 'win32') + ).toBe(false) + expect(areWorktreePathsEqual('/Users/bob/wt', 'C:\\Users\\bob\\wt', 'win32')).toBe(false) + }) +}) + +describe('removeWorktree branch selection on a Windows desktop', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + resetWorktreeGitMocks({ + gitExecFileAsyncMock, + gitExecFileSyncMock, + translateWslOutputPathsMock, + statMock, + resolveGitDirMock, + readFileMock + }) + resetWorktreeRemovalState({ + moveWorktreeDirectoryToTrashMock, + restoreWorktreeDirectoryFromTrashMock, + scheduleWorktreeTrashDeletionMock + }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + }) + + it('deletes the branch of the requested WSL worktree, not its case twin', async () => { + const listing = `worktree /home/alice/repo +HEAD aaa111 +branch refs/heads/main + +worktree /home/alice/ws/Feature +HEAD bbb222 +branch refs/heads/Feature + +worktree /home/alice/ws/feature +HEAD ccc333 +branch refs/heads/feature +` + mockGitCommands({ + 'git worktree list --porcelain -z': { stdout: listing }, + 'git worktree list --porcelain': { stdout: listing } + }) + + await removeWorktree('/home/alice/repo', '/home/alice/ws/feature', true, { + wslDistro: 'Ubuntu' + }) + + const calls = getGitCalls() + expect(calls).toContain('git branch -d -- feature') + expect(calls).not.toContain('git branch -d -- Feature') + }) +})