mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
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.
204 lines
6.4 KiB
TypeScript
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)
|
|
}
|