merge: PR #20273 into tier1-rc

This commit is contained in:
Neil
2026-09-14 15:05:46 -07:00
2 changed files with 183 additions and 1 deletions
+29 -1
View File
@@ -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
@@ -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: <T>(run: () => Promise<T>) => run()
}))
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('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')
})
})