Files
orca/src/shared/git-fetch-head-lock-key-derivation.test.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

274 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import * as path from 'node:path'
const mocks = vi.hoisted(() => ({
readFile: vi.fn(),
realpath: vi.fn(),
stat: vi.fn(),
runWithGitOperationLock: vi.fn()
}))
vi.mock('node:fs/promises', () => ({
readFile: mocks.readFile,
realpath: mocks.realpath,
stat: mocks.stat
}))
vi.mock('./git-operation-lock', () => ({
runWithGitOperationLock: mocks.runWithGitOperationLock
}))
import { runWithGitFetchHeadLock } from './git-fetch-head-lock'
const GIT_DIR_ENTRY = { isDirectory: () => true, isFile: () => false }
const GIT_FILE_ENTRY = { isDirectory: () => false, isFile: () => true }
function lockKeys(): string[] {
return mocks.runWithGitOperationLock.mock.calls.map(([key]) => key as string)
}
/**
* An on-disk layout keyed by forward-slash spelling: a `gitdir:` payload or `commondir` body for
* every file that exists, and `null` for a `.git` directory. Anything absent throws, so a key
* derived from a path that exists nowhere cannot silently read a sibling's metadata.
*/
function mockLayout(entries: Record<string, string | null>): void {
const spell = (target: unknown): string => String(target).replaceAll('\\', '/')
mocks.stat.mockImplementation(async (target: string) => {
const entry = entries[spell(target)]
if (entry === undefined) {
throw new Error(`ENOENT ${spell(target)}`)
}
return entry === null ? GIT_DIR_ENTRY : GIT_FILE_ENTRY
})
mocks.readFile.mockImplementation(async (target: string) => {
const entry = entries[spell(target)]
if (!entry) {
throw new Error(`ENOENT ${spell(target)}`)
}
return entry
})
}
describe('FETCH_HEAD lock key derivation', () => {
let platformSpy: MockInstance<() => NodeJS.Platform> | undefined
const usePlatform = (platform: NodeJS.Platform): void => {
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
}
beforeEach(() => {
mocks.readFile.mockReset().mockRejectedValue(new Error('no metadata file'))
// Identity realpath: the paths under test are already canonical on their host.
mocks.realpath.mockReset().mockImplementation(async (target: string) => target)
mocks.stat.mockReset().mockResolvedValue(GIT_DIR_ENTRY)
mocks.runWithGitOperationLock
.mockReset()
.mockImplementation(async (_key, _signal, run) => run())
})
afterEach(() => {
platformSpy?.mockRestore()
platformSpy = undefined
})
it('folds the WSL UNC aliases and distro casing of one repo into a single lock key', async () => {
usePlatform('win32')
await runWithGitFetchHeadLock(String.raw`\\wsl$\Ubuntu\home\me\repo`, undefined, async () => 0)
await runWithGitFetchHeadLock(
String.raw`\\wsl.localhost\ubuntu\home\me\repo`,
undefined,
async () => 0
)
expect(lockKeys()).toEqual([
'//wsl.localhost/ubuntu/home/me/repo/.git/FETCH_HEAD',
'//wsl.localhost/ubuntu/home/me/repo/.git/FETCH_HEAD'
])
})
it('leaves the two UNC spellings apart on a non-Windows host', async () => {
usePlatform('darwin')
await runWithGitFetchHeadLock(String.raw`\\wsl$\Ubuntu\home\me\repo`, undefined, async () => 0)
await runWithGitFetchHeadLock(
String.raw`\\wsl.localhost\ubuntu\home\me\repo`,
undefined,
async () => 0
)
const keys = lockKeys()
expect(keys[0]).not.toBe(keys[1])
expect(keys[0]).toBe(path.join(String.raw`\\wsl$\Ubuntu\home\me\repo`, '.git', 'FETCH_HEAD'))
})
it('puts a padded gitfile pointer in the same lane as its bare spelling', async () => {
usePlatform('darwin')
mocks.stat.mockResolvedValue(GIT_FILE_ENTRY)
const gitfile = (payload: string): void => {
mocks.readFile.mockImplementation(async (target: string) => {
if (String(target).endsWith('commondir')) {
throw new Error('no commondir')
}
return `gitdir:${payload}\n`
})
}
gitfile(' ../repo/.git/worktrees/wt')
await runWithGitFetchHeadLock('/work/wt', undefined, async () => 0)
// git's own read_gitfile_gently strips the padding, so both spellings name one repo.
gitfile('\t../repo/.git/worktrees/wt ')
await runWithGitFetchHeadLock('/work/wt', undefined, async () => 0)
const keys = lockKeys()
expect(keys[0]).toBe(path.join('/work/repo/.git/worktrees/wt', 'FETCH_HEAD'))
expect(keys[1]).toBe(keys[0])
})
it("keeps a drive-spelled worktree on its own main checkout's lane", async () => {
usePlatform('win32')
mockLayout({
'C:/repo/.git': null,
'C:/workspace/wt-a/.git': 'gitdir: /mnt/c/repo/.git/worktrees/wt-a\n',
'C:/workspace/wt-b/.git': 'gitdir: /mnt/c/repo/.git/worktrees/wt-b\n',
'C:/repo/.git/worktrees/wt-a/commondir': '../..\n',
'C:/repo/.git/worktrees/wt-b/commondir': '../..\n'
})
await runWithGitFetchHeadLock(String.raw`C:\repo`, undefined, async () => 0)
await runWithGitFetchHeadLock(String.raw`C:\workspace\wt-a`, undefined, async () => 0)
await runWithGitFetchHeadLock(String.raw`C:\workspace\wt-b`, undefined, async () => 0)
// The drvfs prefix must be gone: `C:\mnt\c\repo` exists nowhere, so it dead-ends the walk and
// strands each worktree away from the main checkout it shares FETCH_HEAD with.
expect(lockKeys()).toEqual(Array(3).fill(String.raw`C:\repo\.git\FETCH_HEAD`))
})
it('keeps a WSL UNC repo and its linked worktree on one lane', async () => {
usePlatform('win32')
mockLayout({
'//wsl.localhost/Ubuntu/mnt/c/repo/.git': null,
'//wsl$/ubuntu/home/me/wt/.git': 'gitdir: /mnt/c/repo/.git/worktrees/wt\n',
'//wsl$/ubuntu/mnt/c/repo/.git/worktrees/wt/commondir': '../..\n'
})
// A main worktree's `.git` is a directory, so no pointer is translated and its key stays on the
// UNC spelling. Translating the linked worktree's pointer to `C:\...` would split the repo.
await runWithGitFetchHeadLock(
String.raw`\\wsl.localhost\Ubuntu\mnt\c\repo`,
undefined,
async () => 0
)
await runWithGitFetchHeadLock(String.raw`\\wsl$\ubuntu\home\me\wt`, undefined, async () => 0)
expect(lockKeys()).toEqual(Array(2).fill('//wsl.localhost/ubuntu/mnt/c/repo/.git/fetch_head'))
})
it("merges an absolute guest commondir into the main checkout's lane", async () => {
usePlatform('win32')
mockLayout({
'C:/repo/.git': null,
'C:/workspace/wt/.git': 'gitdir: /mnt/c/repo/.git/worktrees/wt\n',
// git may spell commondir absolutely; written by git-in-WSL it is a guest path.
'C:/repo/.git/worktrees/wt/commondir': '/mnt/c/repo/.git\n'
})
await runWithGitFetchHeadLock(String.raw`C:\repo`, undefined, async () => 0)
await runWithGitFetchHeadLock(String.raw`C:\workspace\wt`, undefined, async () => 0)
expect(lockKeys()).toEqual(Array(2).fill(String.raw`C:\repo\.git\FETCH_HEAD`))
})
it('walks to the drive root with Windows rules for a folder workspace', async () => {
usePlatform('win32')
mockLayout({})
await runWithGitFetchHeadLock(String.raw`C:\notes\inbox`, undefined, async () => 0)
// Nothing up the tree is a repo, so the walk must terminate at the drive root the way Windows
// spells it - not at a POSIX `.` that would put every folder workspace on one lane.
expect(lockKeys()[0]).toBe(String.raw`C:\.git\FETCH_HEAD`)
})
it('falls back to the Windows spelling when realpath fails', async () => {
usePlatform('win32')
mocks.realpath.mockRejectedValue(new Error('ELOOP'))
mockLayout({ 'C:/repo/.git': null })
await runWithGitFetchHeadLock(String.raw`C:\repo`, undefined, async () => 0)
expect(lockKeys()[0]).toBe(String.raw`C:\repo\.git\FETCH_HEAD`)
})
it("keeps today's key for a guest pointer Windows cannot address", async () => {
usePlatform('win32')
mocks.stat.mockResolvedValue(GIT_FILE_ENTRY)
mocks.readFile.mockImplementation(async (target: string) => {
if (String(target).endsWith('commondir')) {
throw new Error('no commondir')
}
return 'gitdir: /home/me/repo/.git/worktrees/wt\n'
})
await runWithGitFetchHeadLock(String.raw`C:\workspace\wt`, undefined, async () => 0)
// Unchanged from main: no distro is known, so the pointer stays on the current drive rather
// than becoming a new rootless `\home\...` lane.
expect(lockKeys()[0]).toBe(String.raw`C:\home\me\repo\.git\worktrees\wt\FETCH_HEAD`)
})
it('never fabricates a Windows drive path on a POSIX host', async () => {
usePlatform('darwin')
mockLayout({ '/work/wt/.git': 'gitdir: /mnt/c/repo/.git/worktrees/wt\n' })
await runWithGitFetchHeadLock('/work/wt', undefined, async () => 0)
// `/mnt/c` is an ordinary directory here, not a drvfs mount, so it must not become `C:\repo`.
expect(lockKeys()[0]).toBe('/mnt/c/repo/.git/worktrees/wt/FETCH_HEAD')
})
it('rejects promptly while a hung realpath is still pending', async () => {
usePlatform('win32')
let settleRealpath!: (value: string) => void
mocks.realpath.mockReturnValue(
new Promise<string>((resolve) => {
settleRealpath = resolve
})
)
const controller = new AbortController()
const pending = runWithGitFetchHeadLock(
String.raw`\\wsl.localhost\Ubuntu\home\me\repo`,
controller.signal,
async () => 0
)
// A caller-supplied reason must not change how callers classify the failure.
controller.abort(new TypeError('cancelled by user'))
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
settleRealpath('never observed')
})
it('rejects promptly while a hung metadata stat is still pending', async () => {
usePlatform('win32')
let settleStat!: (value: typeof GIT_DIR_ENTRY) => void
mocks.stat.mockReturnValue(
new Promise((resolve) => {
settleStat = resolve
})
)
const controller = new AbortController()
const pending = runWithGitFetchHeadLock(
String.raw`\\wsl.localhost\Ubuntu\home\me\repo`,
controller.signal,
async () => 0
)
await vi.waitFor(() => expect(mocks.stat).toHaveBeenCalled())
controller.abort()
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
settleStat(GIT_DIR_ENTRY)
})
})