Files
orca/src/main/git/worktree-path-comparison.ts
T
Neil 8edec28a55 fix(worktree): keep a WSL checkout case so delete cannot take the twin branch (#20273)
* fix(worktree): let a POSIX path keep its case on a Windows desktop

`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.

* fix(worktree): keep a WSL checkout's case through the UNC spelling too

The first commit gave POSIX-absolute paths POSIX case rules, which is right but
does not reach the WSL case it claimed. `listWorktreesStrict` runs every listed
path through `translateWorktreePath`, so git-in-the-distro's
`/home/alice/ws/Feature` arrives as `\\wsl.localhost\Ubuntu\home\alice\ws\Feature`
and the POSIX branch never sees it. The removal suite mocks
`translateWslOutputPaths` to identity, which is why the end-to-end test passed
without exercising the translation production always applies.

Driving the real translator, the original defect survived unchanged: a request
naming `...\ws\feature` ran `git worktree remove --force ...\ws\feature` and then
`git branch -d -- Feature`.

The filesystem behind `\\wsl.localhost` is ext4, so the UNC spelling is
case-sensitive for the same reason the Linux spelling is — except where Windows
genuinely folds: the `\\wsl$` share alias, the distro name, and a drvfs
`/mnt/<letter>` tail, which really is a Windows volume.
`foldWslUncPathCaseInsensitiveParts` already draws exactly that line and
`git-fetch-head-lock` already depends on it, so this reuses it rather than
writing a fourth copy of the rule. Windows drive paths keep folding whole.

The end-to-end case now drives the real translator instead of the mock, so the
translation cannot go missing again without the test noticing.
2026-09-15 16:01:32 -07:00

100 lines
4.4 KiB
TypeScript

import { posix, win32 } from 'node:path'
import { foldWslUncPathCaseInsensitiveParts } from '../../shared/wsl-paths'
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.
*
* Why the path's own syntax outranks `platform`: whose filesystem a path names is a property of the
* path, not of the desktop reading it, and folding a case-sensitive filesystem merges two real
* checkouts into one row — enough for `removeWorktree` to pick the twin and delete its branch.
*
* Two syntaxes name a case-sensitive filesystem. A POSIX-absolute path is one. The other is the WSL
* UNC alias, which is the shape that actually reaches removal: `listWorktreesStrict` runs every
* listed path through `translateWorktreePath`, so git-in-the-distro's `/home/alice/Feature` arrives
* as `\\wsl.localhost\Ubuntu\home\alice\Feature` and a plain `toLowerCase` folded the ext4 tail.
* `foldWslUncPathCaseInsensitiveParts` already draws that line — Windows folds the share, the distro
* and a drvfs `/mnt/<letter>` tail, and nothing else — and `git-fetch-head-lock` already relies on
* it. `isSameCommonDirPath` and `ipc/worktree-path-comparison` carry local copies of the POSIX half;
* this is both halves at the source.
*/
export function canonicalWorktreePath(pathValue: string, platform = process.platform): string {
if (looksLikePosixAbsolutePath(pathValue)) {
return posix.normalize(posix.resolve(pathValue))
}
const wslKey = wslUncComparisonKey(pathValue)
if (wslKey) {
return wslKey
}
return platform === 'win32' || looksLikeWindowsPath(pathValue)
? win32.normalize(win32.resolve(pathValue)).toLowerCase()
: posix.normalize(posix.resolve(pathValue))
}
/**
* The comparison key for a WSL UNC path, or null when it is not one.
*
* Normalized through `win32` first so `..`/`.` segments and slash style collapse, then folded only
* where Windows really folds. The fold is unconditional on platform: a `\\wsl.localhost\...` string
* names the same distro filesystem whichever desktop is reading it.
*/
function wslUncComparisonKey(pathValue: string): string | null {
const folded = foldWslUncPathCaseInsensitiveParts(pathValue)
if (!folded) {
return null
}
return foldWslUncPathCaseInsensitiveParts(win32.normalize(pathValue)) ?? folded
}
export function areWorktreePathsEqual(
leftPath: string,
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')
}
return canonicalWorktreePath(leftPath, platform) === canonicalWorktreePath(rightPath, platform)
}
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
}
// Old git ignores `--path-format=absolute`, so resolve a relative toplevel/git-dir against the scanned repo path.
return looksLikeWindowsPath(repoPath)
? win32.resolve(repoPath, value)
: posix.resolve(repoPath, value)
}
export function translateWorktreePath(
worktreePath: string,
repoPath: string,
options: GitWorktreeExecOptions = {}
): string {
const prefix = 'worktree '
const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath, options)
return translated.startsWith(prefix) ? translated.slice(prefix.length) : worktreePath
}