Files
orca/src/shared/git-fetch-head-lock.ts
T
Neil 0443d42638 fix(git): give WSL-hosted repos one FETCH_HEAD lock lane (#17898)
The fetch lock key is the worktree's resolved common Git directory. On a Windows
host two derivations split one repo into several lanes, so sibling fetches on the
same repo race the FETCH_HEAD write the lock exists to serialize.

- Windows aliases \\wsl$ to \\wsl.localhost and folds the distro name and any
  drvfs tail case-insensitively, so two spellings of one repo produced two keys.
  The finished key now goes through foldWslUncPathCaseInsensitiveParts. This is a
  pure function of the finished key, so equal keys stay equal: it can only merge.
- Under a drive-spelled base, git-in-WSL's `/mnt/c/repo/.git` gitfile and
  commondir pointers are read by path.resolve as the non-existent
  C:\mnt\c\repo\.git. The commondir read then fails and every linked worktree got
  its own dead-end key while the main checkout keyed on C:\repo\.git. Such a
  pointer now goes through toWindowsWslDrivePath.
- realpath and stat take no AbortSignal, so cancelling a fetch still blocked
  behind a hung 9P/UNC lookup. Both are wrapped in waitForPromiseWithSignal. The
  rejection keeps today's synthetic AbortError shape, including when the caller
  aborts with its own reason, because callers classify on error.name.
- The hand-rolled gitfile regex is replaced by the shared
  parseGitdirMarkerPayload, matching git's own read_gitfile_gently.

A WSL UNC base is deliberately excluded from the pointer translation. win32
path.resolve already carries such a base's distro onto a guest-rooted pointer
(\\wsl.localhost\Ubuntu\home\me\wt + /mnt/c/repo/.git ->
\\wsl.localhost\Ubuntu\mnt\c\repo\.git), and a main worktree's `.git` is a
directory with no pointer to translate, so its key stays on that UNC spelling.
Rewriting only the linked worktrees to C:\... would have split one repo across
two lanes - the opposite of the intent. A test now pins that layout.

Direction of every key change, re-derived over a ten-layout matrix that runs both
this code and an emulation of the pre-change derivation under Win32 path rules:
nine layouts either merge or are byte-identical. The fold is merge-only by
construction; the pointer translation's sole delta is C:\mnt\c\X -> C:\X, and
C:\mnt\c\X is derived from bytes that live at C:\X, so it can only join a
worktree to its own common dir. The tenth layout is the one narrowing: the shared
parser accepts `gitdir:` only at offset 0 where the old regex accepted it on any
line, so a `.git` file with a leading blank line falls through to the parent walk
(C:\repo\.git\FETCH_HEAD -> C:\.git\FETCH_HEAD). Nothing can race there - git
2.44 refuses that same file with `fatal: invalid gitfile format`, so no fetch
runs in such a worktree at all. Native Windows repos with no WSL, SSH, relay and
folder workspaces are byte-identical.

hostPath() returns the same node:path submodule Node itself selects, so it is a
no-op on every real host; it exists so the Win32 derivation is testable off
Windows. resolveGitFetchHeadCommand's argument parsing is untouched, and
--git-dir gitfile dereferencing is deliberately not added: it would make N
worktrees of one repo serialize fetches that run in parallel today.
2026-09-01 02:39:17 -07:00

204 lines
6.4 KiB
TypeScript

import * as path from 'node:path'
import { readFile, realpath, stat } from 'node:fs/promises'
import { waitForPromiseWithSignal } from './abort-signal-reason'
import { parseGitdirMarkerPayload } from './gitdir-marker-payload'
import { runWithGitOperationLock } from './git-operation-lock'
import {
foldWslUncPathCaseInsensitiveParts,
isWslUncPath,
toWindowsWslDrivePath
} from './wsl-paths'
function abortError(): Error {
const error = new Error('The operation was aborted.')
error.name = 'AbortError'
return error
}
const GLOBAL_OPTIONS_WITH_VALUE = new Set([
'-c',
'-C',
'--git-dir',
'--work-tree',
'--namespace',
'--super-prefix',
'--config-env',
'--exec-path'
])
type GitFetchHeadCommand = { needsLock: boolean; cwd: string; gitDir?: string }
export function resolveGitFetchHeadCommand(
args: readonly string[],
initialCwd: string
): GitFetchHeadCommand {
let cwd = initialCwd
let gitDir: string | undefined
let subcommandIndex = -1
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '-C' && args[index + 1]) {
cwd = path.resolve(cwd, args[index + 1])
index += 1
continue
}
if (arg.startsWith('-C') && arg.length > 2) {
cwd = path.resolve(cwd, arg.slice(2))
continue
}
if (arg === '--git-dir' && args[index + 1]) {
gitDir = path.resolve(cwd, args[index + 1])
index += 1
continue
}
if (arg.startsWith('--git-dir=')) {
gitDir = path.resolve(cwd, arg.slice('--git-dir='.length))
continue
}
if (GLOBAL_OPTIONS_WITH_VALUE.has(arg)) {
index += 1
continue
}
if (arg.startsWith('-')) {
continue
}
subcommandIndex = index
break
}
const subcommand = args[subcommandIndex]
if (subcommand === 'pull') {
return { needsLock: true, cwd, gitDir }
}
if (subcommand !== 'fetch') {
return { needsLock: false, cwd, gitDir }
}
let writesFetchHead = true
let updatesRemoteTrackingRef = false
for (const arg of args.slice(subcommandIndex + 1)) {
if (arg === '--no-write-fetch-head') {
writesFetchHead = false
} else if (arg === '--write-fetch-head') {
writesFetchHead = true
} else if (arg.includes(':refs/remotes/')) {
updatesRemoteTrackingRef = true
}
}
// Why: explicit tracking-ref updates race sibling-worktree fetch transactions even without FETCH_HEAD.
return { needsLock: writesFetchHead || updatesRemoteTrackingRef, cwd, gitDir }
}
/**
* `node:path` itself, spelled so a test can drive the Win32 rules on a POSIX runner. Node picks the
* same submodule for the default export, so this is the host's own behaviour, not an emulation.
*/
function hostPath(): typeof path.posix {
return process.platform === 'win32' ? path.win32 : path.posix
}
/**
* Where a Git metadata pointer (a `.git` gitfile payload or a `commondir`) lands in the reading
* host's namespace.
*
* Why: git running inside WSL writes these in the guest namespace, and under a drive-spelled base
* `path.resolve` reads `/mnt/c/repo/.git` as the non-existent `C:\mnt\c\repo\.git`, so the commondir
* walk dead-ends and every linked worktree of one repo gets its own fetch lane.
*
* Why the UNC base is excluded rather than handed to `resolveGitMetadataPath`: win32 `path.resolve`
* already carries a WSL UNC base's distro onto a guest-rooted pointer, and a main worktree's `.git`
* is a directory with no pointer to translate, so its key stays on that UNC spelling. Rewriting only
* the linked worktrees to `C:\...` would split one repo across two lanes.
*/
function resolveMetadataPointer(basePath: string, rawPointer: string): string {
if (process.platform === 'win32' && !isWslUncPath(basePath)) {
const drivePath = toWindowsWslDrivePath(rawPointer)
if (drivePath) {
return drivePath
}
}
return hostPath().resolve(basePath, rawPointer)
}
/**
* Windows aliases `\\wsl$` to `\\wsl.localhost` and folds the distro name and any drvfs tail
* case-insensitively, so two spellings of one WSL repo must not open two fetch lanes.
*/
function canonicalizeFetchHeadLockKey(key: string): string {
if (process.platform !== 'win32') {
return key
}
return foldWslUncPathCaseInsensitiveParts(key) ?? key
}
/** `realpath` takes no signal, so a hung 9P/UNC lookup outlives the cancelled fetch without this. */
async function realpathOrResolve(target: string, signal: AbortSignal | undefined): Promise<string> {
try {
return await waitForPromiseWithSignal(realpath(target), signal)
} catch {
// Why the synthetic error rather than the signal's reason: callers classify on `name`.
if (signal?.aborted) {
throw abortError()
}
return hostPath().resolve(target)
}
}
async function fetchLockPath(
worktreePath: string,
signal: AbortSignal | undefined,
explicitGitDir?: string
): Promise<string> {
let current = await realpathOrResolve(worktreePath, signal)
let gitDir = explicitGitDir
while (!gitDir) {
const dotGitPath = hostPath().join(current, '.git')
try {
const metadata = await waitForPromiseWithSignal(stat(dotGitPath), signal)
if (metadata.isDirectory()) {
gitDir = dotGitPath
break
}
const contents = await readFile(dotGitPath, { encoding: 'utf-8', signal })
const marker = parseGitdirMarkerPayload(contents)
if (marker) {
gitDir = resolveMetadataPointer(current, marker)
break
}
} catch {
if (signal?.aborted) {
throw abortError()
}
}
const parent = hostPath().dirname(current)
if (parent === current) {
gitDir = hostPath().join(current, '.git')
break
}
current = parent
}
let commonGitDir = gitDir
try {
const contents = await readFile(hostPath().join(gitDir, 'commondir'), {
encoding: 'utf-8',
signal
})
if (contents.trim()) {
commonGitDir = resolveMetadataPointer(gitDir, contents.trim())
}
} catch {
if (signal?.aborted) {
throw abortError()
}
}
const canonicalGitDir = await realpathOrResolve(commonGitDir, signal)
return canonicalizeFetchHeadLockKey(hostPath().join(canonicalGitDir, 'FETCH_HEAD'))
}
export async function runWithGitFetchHeadLock<T>(
worktreePath: string,
signal: AbortSignal | undefined,
run: () => Promise<T>,
explicitGitDir?: string
): Promise<T> {
const key = await fetchLockPath(worktreePath, signal, explicitGitDir)
return runWithGitOperationLock(key, signal, run)
}