mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
merge: PR #20273 into tier1-rc
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user