Files
orca/src/shared/git-binary-compatibility.test.ts
T
Brennan Benson 9deee5ad2f perf(worktrees): delete worktree directories after the removal returns (#12416)
* perf(worktrees): delete worktree directories after the removal returns

`git worktree remove` deleted the whole checkout inline, so the remove IPC held the
watcher/PTY gate for the entire recursive delete (prod traces: worktree.remove.git_remove
p50 8-14s, p90 29s, max 34.7s). Local removals now rename the checkout into a hidden
sibling trash root, clear Git's registration for the missing path, and delete the moved
tree in the background. Renames that cannot run (WSL, other volume, Windows open handles)
fall back to the previous in-place removal unchanged.

* test(worktrees): keep no empty trash root when the rename cannot run

* fix(worktrees): harden deferred trash cleanup

* fix(worktrees): keep WSL trash on its owning host
2026-08-04 15:53:11 -07:00

230 lines
9.0 KiB
TypeScript

import { execFile } from 'node:child_process'
import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
isUnsupportedMergeTreeMergeBaseError,
isUnsupportedMergeTreeWriteTreeError
} from './git-merge-tree-capability'
import { isForEachRefExcludeUnsupportedError } from './git-ref-command-capabilities'
import {
hasUnsupportedRevParsePathFormatEcho,
isUnsupportedWorktreeListZError
} from './git-worktree-command-capabilities'
import { gitCredentialPromptGuardEnv } from './git-credential-prompt-env'
import {
githubPullRequestHeadLocalRef,
gitlabMergeRequestHeadLocalRef,
reviewHeadRemoteRefComponent
} from './review-head-tracking-ref'
const execFileAsync = promisify(execFile)
const image = process.env.ORCA_GIT_COMPAT_IMAGE
const binary = process.env.ORCA_GIT_COMPAT_BINARY
const expectedVersion = process.env.ORCA_GIT_COMPAT_VERSION
const describeBinaryCompatibility = image || binary ? describe : describe.skip
type GitResult = { stdout: string; stderr: string }
describeBinaryCompatibility('real Git binary compatibility', () => {
let repoPath = ''
let version = { major: 0, minor: 0 }
async function runGit(args: string[], env?: NodeJS.ProcessEnv): Promise<GitResult> {
if (image) {
const dockerUser =
typeof process.getuid === 'function' && typeof process.getgid === 'function'
? ['--user', `${process.getuid()}:${process.getgid()}`]
: []
return execFileAsync(
'docker',
[
'run',
'--rm',
'--network=none',
...dockerUser,
...Object.entries(env ?? {}).flatMap(([key, value]) =>
value === undefined ? [] : ['--env', `${key}=${value}`]
),
'-v',
`${repoPath}:/repo`,
'-w',
'/repo',
image,
'-c',
'safe.directory=/repo',
...args
],
{ maxBuffer: 2 * 1024 * 1024 }
)
}
return execFileAsync(binary!, args, {
cwd: repoPath,
env: env ? { ...process.env, ...env } : undefined,
maxBuffer: 2 * 1024 * 1024
})
}
function supports(major: number, minor: number): boolean {
return version.major > major || (version.major === major && version.minor >= minor)
}
async function expectPreferredOrRecognizedFallback(
args: string[],
expectedSupport: boolean,
recognizesUnsupported: (error: unknown) => boolean
): Promise<void> {
try {
await runGit(args)
expect(expectedSupport).toBe(true)
} catch (error) {
expect(expectedSupport).toBe(false)
expect(recognizesUnsupported(error)).toBe(true)
}
}
beforeAll(async () => {
repoPath = await mkdtemp(join(tmpdir(), 'orca-git-binary-compat-'))
const versionOutput = await runGit(['--version'])
expect(versionOutput.stdout).toContain(`git version ${expectedVersion}`)
const match = versionOutput.stdout.match(/git version (\d+)\.(\d+)/)
expect(match).not.toBeNull()
version = { major: Number(match![1]), minor: Number(match![2]) }
await runGit(['init', '-q'])
await runGit(['config', 'user.email', 'compatibility@example.invalid'])
await runGit(['config', 'user.name', 'Compatibility Test'])
await writeFile(join(repoPath, 'tracked.txt'), 'compatibility\n')
await runGit(['add', 'tracked.txt'])
await runGit(['commit', '-qm', 'initial'])
})
afterAll(async () => {
if (repoPath) {
await rm(repoPath, { recursive: true, force: true })
}
})
it('recognizes worktree-list and rev-parse compatibility boundaries', async () => {
await expectPreferredOrRecognizedFallback(
['worktree', 'list', '--porcelain', '-z'],
supports(2, 36),
isUnsupportedWorktreeListZError
)
await expect(runGit(['worktree', 'list', '--porcelain'])).resolves.toMatchObject({
stdout: expect.stringContaining('worktree ')
})
// Why: the `prunable` porcelain annotation landed in Git 2.31 — five
// releases before `-z` (2.36) — so only Git <2.31 emits neither and needs
// Orca's path-existence fallback (issue #8389).
await runGit(['worktree', 'add', '-b', 'compat-stale', 'stale-wt'])
await rm(join(repoPath, 'stale-wt'), { recursive: true, force: true })
const staleList = await runGit(['worktree', 'list', '--porcelain'])
expect(staleList.stdout.includes('prunable')).toBe(supports(2, 31))
const preferred = await runGit([
'rev-parse',
'--path-format=absolute',
'--show-toplevel',
'--git-common-dir'
])
expect(hasUnsupportedRevParsePathFormatEcho(preferred.stdout)).toBe(!supports(2, 31))
await expect(
runGit(['rev-parse', '--show-toplevel', '--git-common-dir'])
).resolves.toBeDefined()
})
it('deregisters a worktree whose directory was renamed away', async () => {
// Orca renames the checkout into a trash directory and then clears the registration, so every
// supported Git must accept `worktree remove --force` on the now-missing path.
await runGit(['worktree', 'add', '-b', 'compat-deferred', 'deferred-wt'])
await rename(join(repoPath, 'deferred-wt'), join(repoPath, 'deferred-trash'))
await expect(runGit(['worktree', 'remove', '--force', 'deferred-wt'])).resolves.toBeDefined()
const remaining = await runGit(['worktree', 'list', '--porcelain'])
expect(remaining.stdout).not.toContain('deferred-wt')
await rm(join(repoPath, 'deferred-trash'), { recursive: true, force: true })
})
it('recognizes ref and merge-tree compatibility boundaries', async () => {
await expectPreferredOrRecognizedFallback(
['for-each-ref', '--format=%(refname)', '--exclude=refs/remotes/**/HEAD', '--count=10'],
supports(2, 42),
isForEachRefExcludeUnsupportedError
)
await expect(
runGit(['for-each-ref', '--format=%(refname)', '--count=10'])
).resolves.toBeDefined()
await expectPreferredOrRecognizedFallback(
['merge-tree', '--write-tree', 'HEAD', 'HEAD'],
supports(2, 38),
isUnsupportedMergeTreeWriteTreeError
)
if (supports(2, 38)) {
const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim()
const legacyArgs = ['merge-tree', '--write-tree', '--name-only', '-z', '--no-messages']
await expectPreferredOrRecognizedFallback(
[...legacyArgs, '--merge-base', head, head, head],
supports(2, 40),
isUnsupportedMergeTreeMergeBaseError
)
await expect(runGit([...legacyArgs, head, head])).resolves.toBeDefined()
}
})
it('fetches hosted review heads into dedicated refs', async () => {
const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim()
await runGit(['update-ref', 'refs/pull/42/head', head])
await runGit(['update-ref', 'refs/merge-requests/42/head', head])
// Why: exercise the exact remote-identity-scoped ref shape the app generates.
const component = reviewHeadRemoteRefComponent('origin', 'git@github.com:org/repo.git')
const pullRef = githubPullRequestHeadLocalRef(component, 42)
const mergeRequestRef = gitlabMergeRequestHeadLocalRef(component, 42)
await expect(
runGit(['fetch', '--no-tags', '.', `+refs/pull/42/head:${pullRef}`])
).resolves.toBeDefined()
await expect(
runGit(['fetch', '--no-tags', '.', `+refs/merge-requests/42/head:${mergeRequestRef}`])
).resolves.toBeDefined()
await expect(runGit(['rev-parse', '--verify', pullRef])).resolves.toMatchObject({
stdout: `${head}\n`
})
await expect(runGit(['rev-parse', '--verify', mergeRequestRef])).resolves.toMatchObject({
stdout: `${head}\n`
})
})
it('degrades indexed credential config safely at the Git 2.31 boundary', async () => {
const guardEnv = gitCredentialPromptGuardEnv({}, 'linux')
await expect(runGit(['status', '--short'], guardEnv)).resolves.toBeDefined()
try {
const result = await runGit(['config', '--get', 'credential.interactive'], guardEnv)
expect(supports(2, 31)).toBe(true)
expect(result.stdout.trim()).toBe('false')
} catch {
// Git 2.25 ignores the indexed variables rather than rejecting commands;
// the scalar prompt guards still provide the baseline fail-fast behavior.
expect(supports(2, 31)).toBe(false)
}
})
// Why pin this: --verify swallows --end-of-options but --symbolic-full-name echoes
// it deliberately, on every version tested (2.25 through 2.49). git-history.ts skips
// that line; if a future git stopped emitting it, the skip stays correct, but if this
// assertion ever flips the reason for the skip is worth re-reading.
it('echoes the option marker from rev-parse --symbolic-full-name', async () => {
const result = await runGit(['rev-parse', '--symbolic-full-name', '--end-of-options', 'HEAD'])
const lines = result.stdout.trim().split(/\r?\n/).filter(Boolean)
expect(lines[0]).toBe('--end-of-options')
expect(lines.find((line) => line !== '--end-of-options')).toMatch(/^refs\//)
})
})