fix(git): cache unsupported capabilities per host (#8109)

* fix(git): cache unsupported capabilities per host

Old Git worktree, ref-search, and merge-tree fallbacks retried unsupported flags on recurring operations, flooding subprocess traces. Centralize capability probing per native, WSL, and SSH execution host, coalesce concurrent probes, and retry periodically for in-place Git upgrades.

* fix(git): recognize real old-Git merge-tree rejection

* test(git): enforce real binary compatibility matrix

* fix(ci): preserve Git compatibility test ownership

* fix(git): retain supported capability state
This commit is contained in:
Neil 2026-07-10 18:19:36 -07:00 committed by GitHub
parent 73e082b577
commit 533992bdda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1467 additions and 382 deletions

View File

@ -17,7 +17,7 @@ jobs:
uses: actions/checkout@v6
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
- name: Setup Node.js
uses: actions/setup-node@v6
@ -103,6 +103,33 @@ jobs:
- name: Typecheck
run: pnpm typecheck
# Why: real old Git diagnostics differ from mocked errors. Keep the
# fallback predicates executable across the baseline, transition, and
# current command shapes so a newly added flag cannot silently regress.
- name: Verify Git binary compatibility matrix
run: |
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
source="$RUNNER_TEMP/git-2.25.5"
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
| sha256sum --check
mkdir -p "$source"
tar -xzf "$archive" -C "$source" --strip-components=1
make -C "$source" -j2 NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
for spec in \
"alpine/git:edge-2.38.1|2.38.1" \
"alpine/git:v2.49.1|2.49.1"; do
image="${spec%%|*}"
version="${spec#*|}"
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
done
# Why: postinstall rebuilds better-sqlite3 for Electron's ABI via
# @electron/rebuild, but vitest runs under system Node.js. Rebuild
# it for Node so orchestration tests can load the native module.

View File

@ -36,6 +36,18 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
All changes must consider the SSH use case. Don't assume local-only execution.
## Git Binary Compatibility
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
When adding or changing a Git command:
- Check when every subcommand and option was introduced. For newer behavior, keep a baseline-compatible fallback or degrade safely.
- Use `GitCapabilityCache` with a narrow unsupported-error predicate so recurring operations do not retry a known-invalid command. Do not rely only on `git --version`; wrappers such as `simple-git` do not remove host-version differences.
- Scope capability state to the host that executes Git: native, WSL distro, SSH provider, or relay connection. Cover the first fallback, later cached calls, concurrent probes, and relevant host isolation in tests.
- Keep the real-binary compatibility contract in PR CI current. When adopting a newer Git feature, add its version boundary so the preferred command and fallback both run against representative Git releases.
- Preserve commands that begin with global Git options such as `-c` before the subcommand, including auto-maintenance suppression used by worktree-create fetches.
## Git Provider Compatibility
Source-control and review changes must consider GitLab and other supported git providers, not only GitHub. Keep provider-specific behavior behind explicit checks, and avoid GitHub-only naming for generic review concepts.

View File

@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
describe('Git binary compatibility PR gate', () => {
it('runs the real-binary contract at each compatibility boundary', () => {
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const step = workflow.jobs.verify.steps.find(
(candidate) => candidate.name === 'Verify Git binary compatibility matrix'
)
expect(step?.run).toContain('git-2.25.5.tar.gz')
expect(step?.run).toContain('41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf')
expect(step?.run).toContain('ORCA_GIT_COMPAT_BINARY="$source/git"')
expect(step?.run).toContain('alpine/git:edge-2.38.1|2.38.1')
expect(step?.run).toContain('alpine/git:v2.49.1|2.49.1')
expect(step?.run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"')
expect(step?.run).toContain('src/shared/git-binary-compatibility.test.ts')
})
})

View File

@ -0,0 +1,62 @@
# Git Compatibility Policy
## Scope
Orca executes the user's Git binary on three kinds of execution host: native,
WSL, and SSH. Each host can have a different Git version, so compatibility
state must be scoped to the host that actually runs the command.
Git 2.25 is the core-workflow compatibility baseline for command selection. It
is the oldest line that covers Orca's baseline use of porcelain v2, `branch
--show-current`, `restore`, and sparse checkout. Optional features that need a
newer Git must degrade safely and cache the missing capability. Orca does not
currently block older Git at startup, but new command construction should not
assume features introduced after this baseline.
## Capability Rules
When a newer Git feature materially improves correctness or performance:
1. Keep a baseline-compatible command or parser as the fallback.
2. Detect rejection with a narrow predicate for that option or subcommand.
3. Run the preferred command through `GitCapabilityCache` so a rejection is
remembered for the native host, WSL distro, or SSH provider that produced it.
4. Retry after the cache interval so an in-place Git upgrade self-heals without
restarting Orca.
5. Test the first fallback, later calls that skip the rejected probe, concurrent
probe coalescing, and execution-host isolation where applicable.
Do not branch only on a parsed `git --version`. Vendor builds can backport
features, and wrappers can report a host version that differs from the binary
used inside WSL or SSH. A behavior probe plus a precise fallback is the final
authority.
## Current Capabilities
| Capability | Preferred behavior | Compatibility behavior |
| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `worktree-list-z` | NUL-delimited worktree paths | Line-block parser for Git before `worktree list -z` |
| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
## Why Not `simple-git`
`simple-git` is a process wrapper around the installed Git binary. Its custom
options and `raw` API pass arguments through to Git, so it cannot make a newer
flag work on an older binary or choose Orca's semantic fallback automatically.
It provides version reporting and subprocess queueing, but Orca already needs
its own WSL/SSH routing, cancellation, tracing, redaction, process cleanup, and
bounded output handling. Replacing the runner would move—not remove—the
capability problem.
## CI Contract
PR checks run the capability contract against real Git 2.25.5, 2.38.1, and
2.49.1 binaries. This spans the core-workflow baseline, the transitional
`merge-tree --write-tree` behavior before `--merge-base`, and current Git.
Keep the unit tests alongside that matrix. They cover concurrent probes,
native/WSL/SSH/relay isolation, and error-stream shapes that a single real
binary invocation cannot exercise deterministically.

View File

@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
clearGitCapabilityStateForTests,
getLocalGitCapabilityCache,
getSshGitCapabilityCache
} from './git-capability-state'
describe('Git capability execution-host state', () => {
beforeEach(() => {
clearGitCapabilityStateForTests()
})
it('shares native state while isolating each WSL distro', () => {
expect(getLocalGitCapabilityCache({ cwd: '/repo-a' })).toBe(
getLocalGitCapabilityCache({ cwd: '/repo-b' })
)
expect(getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' })).toBe(
getLocalGitCapabilityCache({ cwd: '\\\\wsl.localhost\\Ubuntu\\home\\repo' })
)
expect(getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' })).not.toBe(
getLocalGitCapabilityCache({ wslDistro: 'Debian' })
)
expect(getLocalGitCapabilityCache()).not.toBe(
getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' })
)
})
it('shares one SSH provider lifetime without leaking into a replacement provider', () => {
const provider = {}
const replacementProvider = {}
expect(getSshGitCapabilityCache(provider)).toBe(getSshGitCapabilityCache(provider))
expect(getSshGitCapabilityCache(provider)).not.toBe(
getSshGitCapabilityCache(replacementProvider)
)
})
})

View File

@ -0,0 +1,44 @@
import { GitCapabilityCache } from '../../shared/git-capability-cache'
import { parseWslUncPath } from '../../shared/wsl-paths'
type LocalGitCapabilityTarget = {
cwd?: string
wslDistro?: string
}
const localCapabilitiesByExecutionHost = new Map<string, GitCapabilityCache>()
// Why: reconnecting creates a new provider, while concurrent IPC/runtime users
// of one SSH connection must share the same remote Git capability results.
let sshCapabilitiesByProvider = new WeakMap<object, GitCapabilityCache>()
function getLocalGitExecutionHostKey(target: LocalGitCapabilityTarget): string {
const wslDistro =
target.wslDistro ?? (target.cwd ? parseWslUncPath(target.cwd)?.distro : undefined)
return wslDistro ? `wsl:${wslDistro}` : 'local'
}
export function getLocalGitCapabilityCache(
target: LocalGitCapabilityTarget = {}
): GitCapabilityCache {
const executionHost = getLocalGitExecutionHostKey(target)
let cache = localCapabilitiesByExecutionHost.get(executionHost)
if (!cache) {
cache = new GitCapabilityCache()
localCapabilitiesByExecutionHost.set(executionHost, cache)
}
return cache
}
export function getSshGitCapabilityCache(provider: object): GitCapabilityCache {
let cache = sshCapabilitiesByProvider.get(provider)
if (!cache) {
cache = new GitCapabilityCache()
sshCapabilitiesByProvider.set(provider, cache)
}
return cache
}
export function clearGitCapabilityStateForTests(): void {
localCapabilitiesByExecutionHost.clear()
sshCapabilitiesByProvider = new WeakMap()
}

View File

@ -32,6 +32,8 @@ vi.mock('fs/promises', async () => {
return { ...actual, stat: statMock }
})
import { clearGitCapabilityStateForTests } from './git-capability-state'
import {
addSparseWorktree,
assertWorktreeCleanForRemoval,
@ -40,6 +42,10 @@ import {
removeWorktree
} from './worktree'
beforeEach(() => {
clearGitCapabilityStateForTests()
})
type MockResult = {
error?: Error
stdout?: string

View File

@ -9,10 +9,12 @@ vi.mock('./runner', () => ({
gitExecFileSync: vi.fn()
}))
import { clearGitCapabilityStateForTests } from './git-capability-state'
import { searchBaseRefs } from './repo'
describe('searchBaseRefs git compatibility', () => {
afterEach(() => {
clearGitCapabilityStateForTests()
gitExecFileAsyncMock.mockReset()
})
@ -35,13 +37,15 @@ describe('searchBaseRefs git compatibility', () => {
}
})
await expect(searchBaseRefs('/repo', '', 1)).resolves.toEqual(['origin/main'])
await expect(searchBaseRefs('/repo', '', 1)).resolves.toEqual(['origin/main'])
const forEachRefCalls = gitExecFileAsyncMock.mock.calls.filter(
(call) => (call[0] as string[])[0] === 'for-each-ref'
)
expect(forEachRefCalls).toHaveLength(2)
expect(forEachRefCalls).toHaveLength(3)
expect(forEachRefCalls[0][0]).toContain('--exclude=refs/remotes/**/HEAD')
expect(forEachRefCalls[1][0]).not.toContain('--exclude=refs/remotes/**/HEAD')
expect(forEachRefCalls[1][0]).toContain('--count=104')
expect(forEachRefCalls[2][0]).not.toContain('--exclude=refs/remotes/**/HEAD')
})
})

View File

@ -5,9 +5,11 @@ import { gitExecFileSync, gitExecFileAsync } from './runner'
import type { BaseRefSearchResult } from '../../shared/types'
import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output'
import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path'
import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { toWindowsWslPath } from '../wsl'
import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url'
import { getLocalGitCapabilityCache } from './git-capability-state'
type LocalGitExecOptions = {
wslDistro?: string
@ -788,27 +790,27 @@ async function runSearchBaseRefsGit(
limit: number,
options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup }
): Promise<{ stdout: string }> {
try {
return await gitExecFileAsync(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
remoteNames: options.remoteNames,
patternGroup: options.patternGroup
}),
{ cwd: path }
)
} catch (err) {
if (!isForEachRefExcludeUnsupportedError(err)) {
throw err
}
return gitExecFileAsync(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
excludeRemoteHead: false,
remoteNames: options.remoteNames,
patternGroup: options.patternGroup
}),
{ cwd: path }
)
}
return getLocalGitCapabilityCache({ cwd: path }).runWithFallback(
'for-each-ref-exclude',
() =>
gitExecFileAsync(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
remoteNames: options.remoteNames,
patternGroup: options.patternGroup
}),
{ cwd: path }
),
() =>
gitExecFileAsync(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
excludeRemoteHead: false,
remoteNames: options.remoteNames,
patternGroup: options.patternGroup
}),
{ cwd: path }
),
isForEachRefExcludeUnsupportedError
)
}
export function mergeBaseRefSearchResultGroups(
@ -834,17 +836,7 @@ export function mergeBaseRefSearchResultGroups(
return merged
}
export function isForEachRefExcludeUnsupportedError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false
}
const maybe = error as { message?: unknown; stderr?: unknown; stdout?: unknown }
const text = [maybe.message, maybe.stderr, maybe.stdout]
.filter((value): value is string => typeof value === 'string')
.join('\n')
.toLowerCase()
return text.includes('unknown option') && text.includes('exclude')
}
export { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities'
/**
* Resolve the default push remote for a repo.

View File

@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileSync: vi.fn(),
translateWslOutputPaths: (output: string) => output
}))
import { clearGitCapabilityStateForTests } from './git-capability-state'
import { listWorktrees } from './worktree'
const WORKTREE_LIST_OUTPUT = `worktree /repo
HEAD abc123
branch refs/heads/main
`
describe('worktree Git capabilities', () => {
beforeEach(() => {
clearGitCapabilityStateForTests()
gitExecFileAsyncMock.mockReset()
})
it('does not repeat a known-unsupported -z probe on later scans', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.includes('-z')) {
return Promise.reject(
Object.assign(new Error('git usage error'), {
code: 129,
stderr: 'usage: git worktree list [<options>]\n'
})
)
}
return Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT })
})
await listWorktrees('/repo')
await listWorktrees('/repo')
expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([
['worktree', 'list', '--porcelain', '-z'],
['worktree', 'list', '--porcelain'],
['worktree', 'list', '--porcelain']
])
})
it('keeps native and WSL Git capability results separate', async () => {
gitExecFileAsyncMock.mockImplementation(
(args: string[], options: { cwd: string; wslDistro?: string }) => {
if (args.includes('-z') && !options.wslDistro) {
return Promise.reject(
Object.assign(new Error('git usage error'), {
code: 129,
stderr: 'usage: git worktree list [<options>]\n'
})
)
}
return Promise.resolve({
stdout: `worktree ${options.cwd}\nHEAD abc123\nbranch refs/heads/main\n`
})
}
)
await listWorktrees('/native-repo')
await listWorktrees('/wsl-repo', { wslDistro: 'Ubuntu' })
expect(
gitExecFileAsyncMock.mock.calls.map(([args, options]) => ({
args,
wslDistro: options.wslDistro
}))
).toEqual([
{ args: ['worktree', 'list', '--porcelain', '-z'], wslDistro: undefined },
{ args: ['worktree', 'list', '--porcelain'], wslDistro: undefined },
{ args: ['worktree', 'list', '--porcelain', '-z'], wslDistro: 'Ubuntu' }
])
})
it('does not repeat a known-unsupported rev-parse --path-format probe', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'worktree') {
return Promise.resolve({
stdout: 'worktree /git-store/project.git\nHEAD abc123\nbranch refs/heads/main\n'
})
}
if (args.includes('--path-format=absolute')) {
return Promise.reject(
Object.assign(new Error('unknown option: --path-format=absolute'), {
stderr: 'error: unknown option `path-format=absolute`\n'
})
)
}
return Promise.resolve({ stdout: '/repo\n/git-store/project.git\n' })
})
await listWorktrees('/repo')
await listWorktrees('/repo')
const revParseCalls = gitExecFileAsyncMock.mock.calls.filter(
([args]) => (args as string[])[0] === 'rev-parse'
)
expect(revParseCalls.map(([args]) => args)).toEqual([
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
['rev-parse', '--show-toplevel', '--git-common-dir'],
['rev-parse', '--show-toplevel', '--git-common-dir']
])
})
it('remembers old Git that echoes --path-format while exiting successfully', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'worktree') {
return Promise.resolve({
stdout: 'worktree /git-store/project.git\nHEAD abc123\nbranch refs/heads/main\n'
})
}
if (args.includes('--path-format=absolute')) {
return Promise.resolve({
stdout: '--path-format=absolute\n/repo\n/git-store/project.git\n'
})
}
return Promise.resolve({ stdout: '/repo\n/git-store/project.git\n' })
})
await listWorktrees('/repo')
await listWorktrees('/repo')
const revParseCalls = gitExecFileAsyncMock.mock.calls.filter(
([args]) => (args as string[])[0] === 'rev-parse'
)
expect(revParseCalls.map(([args]) => args)).toEqual([
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
['rev-parse', '--show-toplevel', '--git-common-dir']
])
})
})

View File

@ -18,6 +18,8 @@ vi.mock('./runner', () => ({
translateWslOutputPaths: translateWslOutputPathsMock
}))
import { clearGitCapabilityStateForTests } from './git-capability-state'
import {
addSparseWorktree,
addWorktree,
@ -29,6 +31,10 @@ import {
WORKTREE_ADD_TIMEOUT_MS
} from './worktree'
beforeEach(() => {
clearGitCapabilityStateForTests()
})
describe('listWorktrees in-flight sharing', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()

View File

@ -17,6 +17,12 @@ import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal'
import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path'
import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output'
import { parseWslUncPath } from '../../shared/wsl-paths'
import {
hasUnsupportedRevParsePathFormatEcho,
isUnsupportedRevParsePathFormatError,
isUnsupportedWorktreeListZError
} from '../../shared/git-worktree-command-capabilities'
import { getLocalGitCapabilityCache } from './git-capability-state'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
import { resolveGitDir } from './status'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
@ -111,22 +117,6 @@ function isNotGitRepositoryError(error: unknown): boolean {
return /not a git repository/i.test(getErrorText(error))
}
function isUnsupportedWorktreeListZError(error: unknown): boolean {
// `-z` is this command's only flag older Git (<2.36) lacks, so its usage
// exit 129 signals the rejection in any locale; stderr match is a fallback.
if (getErrorCode(error) === '129') {
return true
}
return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error))
}
function isUnsupportedRevParsePathFormatError(error: unknown): boolean {
return /(?:unknown|invalid|unrecognized).*(?:--path-format|path-format)/i.test(
getErrorText(error)
)
}
function isBranchCheckedOutInWorktreeError(error: unknown): boolean {
return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test(
getErrorText(error)
@ -410,24 +400,34 @@ async function readRepoLocation(
resolveBasePath: string,
options: GitWorktreeExecOptions = {}
): Promise<RepoLocation | undefined> {
const capabilities = getLocalGitCapabilityCache({
cwd: repoPath,
wslDistro: options.wslDistro
})
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
return await capabilities.runWithFallback(
'rev-parse-path-format',
async () => {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
if (hasUnsupportedRevParsePathFormatEcho(stdout)) {
// Why: some old Git versions echo the unknown option and exit zero;
// remember that compatibility signal even though parsing can recover.
capabilities.rememberUnsupported('rev-parse-path-format')
}
return parseRepoLocation(resolveBasePath, stdout)
},
async () => {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
return parseRepoLocation(resolveBasePath, stdout)
},
isUnsupportedRevParsePathFormatError
)
return parseRepoLocation(resolveBasePath, stdout)
} catch (error) {
if (!isUnsupportedRevParsePathFormatError(error)) {
return undefined
}
}
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
return parseRepoLocation(resolveBasePath, stdout)
} catch {
return undefined
}
@ -565,29 +565,34 @@ async function readWorktreeList(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
try {
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], {
cwd: repoPath,
...options
})
return normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout, { nulDelimited: true }),
options
)
} catch (error) {
if (!isUnsupportedWorktreeListZError(error)) {
throw error
}
}
// Why: `-z` is required to preserve worktree paths containing newlines, but
// Git <2.36 rejects it. Keep the old parser as a compatibility fallback.
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], {
const capabilities = getLocalGitCapabilityCache({
cwd: repoPath,
...options
wslDistro: options.wslDistro
})
return normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout), options)
return capabilities.runWithFallback(
'worktree-list-z',
async () => {
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], {
cwd: repoPath,
...options
})
return normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout, { nulDelimited: true }),
options
)
},
async () => {
// Why: `-z` is required to preserve worktree paths containing newlines,
// but Git <2.36 rejects it. Keep the line parser as the fallback.
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], {
cwd: repoPath,
...options
})
return normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout), options)
},
isUnsupportedWorktreeListZError
)
}
async function readTranslatedWorktreeGraph(
@ -1210,7 +1215,14 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure(
// Why: squash merges rewrite commit IDs, so `branch -d` can reject a branch
// whose changes are already on the base ref. Delete only when Git can prove
// the branch contributes no tree changes to that base.
if (!(await branchHasNoUnmergedChangesOnAnyTarget(runGit, branchName, targetRefs))) {
if (
!(await branchHasNoUnmergedChangesOnAnyTarget(
runGit,
branchName,
targetRefs,
getLocalGitCapabilityCache({ cwd: repoPath, wslDistro: options.wslDistro })
))
) {
return false
}
await forceDeleteLocalBranch(repoPath, branchName, branchHead, (args, cwd) =>

View File

@ -328,4 +328,21 @@ describe('getPRConflictSummary caching', () => {
await expect(deriveSummary()).resolves.toEqual(expectedSummary)
expect(spawnCount('merge-base')).toBe(2)
})
it('does not repeat merge-tree --write-tree after an old-Git rejection', async () => {
mockGitDispatch({
'merge-tree': () =>
Promise.reject(
Object.assign(new Error('unknown option'), {
stdout: 'usage: git merge-tree <base-tree> <branch1> <branch2>'
})
)
})
await expect(deriveSummary('head-oid-1')).resolves.toBeUndefined()
await expect(deriveSummary('head-oid-2')).resolves.toBeUndefined()
expect(spawnCount('merge-base')).toBe(2)
expect(spawnCount('merge-tree')).toBe(1)
})
})

View File

@ -1,5 +1,13 @@
import type { PRConflictSummary } from '../../shared/types'
import {
isUnsupportedMergeTreeMergeBaseError,
isUnsupportedMergeTreeWriteTreeError
} from '../../shared/git-merge-tree-capability'
import { gitExecFileAsync } from '../git/runner'
import {
clearGitCapabilityStateForTests,
getLocalGitCapabilityCache
} from '../git/git-capability-state'
import {
__resetPRConflictSummaryDerivationCachesForTests,
buildConflictSummaryCacheKey,
@ -17,10 +25,8 @@ type LocalGitExecOptions = {
wslDistro?: string
}
const mergeTreeMergeBaseUnsupportedRuntimes = new Set<string>()
export function __resetPRConflictSummaryCachesForTests(): void {
mergeTreeMergeBaseUnsupportedRuntimes.clear()
clearGitCapabilityStateForTests()
__resetPRConflictSummaryDerivationCachesForTests()
}
@ -213,7 +219,10 @@ async function loadConflictingFiles(
baseOid: string,
localGitOptions: LocalGitExecOptions
): Promise<string[]> {
const capabilityKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
const capabilities = getLocalGitCapabilityCache({
cwd: repoPath,
wslDistro: localGitOptions.wslDistro
})
const modernArgs = [
'merge-tree',
'--write-tree',
@ -235,32 +244,41 @@ async function loadConflictingFiles(
baseOid
]
if (mergeTreeMergeBaseUnsupportedRuntimes.has(capabilityKey)) {
return loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions)
}
try {
const result = await gitExecFileAsync(modernArgs, {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
return parseMergeTreeNameOnlyOutput(result.stdout)
} catch (error) {
// Why: `git merge-tree --write-tree` exits with status 1 when it finds
// conflicts, but still writes the conflicted file list to stdout. Treat
// that stdout as the useful result instead of dropping the summary.
const stdoutFromError = getGitErrorOutput(error, 'stdout')
if (stdoutFromError) {
return parseMergeTreeNameOnlyOutput(stdoutFromError)
}
if (!isUnsupportedMergeBaseOption(error)) {
throw error
}
mergeTreeMergeBaseUnsupportedRuntimes.add(capabilityKey)
return loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions)
}
return capabilities.runWithFallback(
'merge-tree-write-tree',
() =>
capabilities.runWithFallback(
'merge-tree-merge-base',
async () => {
try {
const result = await gitExecFileAsync(modernArgs, {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
})
return parseMergeTreeNameOnlyOutput(result.stdout)
} catch (error) {
if (isUnsupportedMergeTreeWriteTreeError(error)) {
throw error
}
// Why: `git merge-tree --write-tree` exits 1 for conflicts but still
// writes the useful file list; only option rejection reaches fallback.
const stdoutFromError = getGitErrorOutput(error, 'stdout')
if (stdoutFromError) {
return parseMergeTreeNameOnlyOutput(stdoutFromError)
}
throw error
}
},
() => loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions),
isUnsupportedMergeTreeMergeBaseError
),
async () => {
// Why: Git before 2.38 cannot derive a reliable real-merge conflict list;
// fail closed without respawning the same rejected command every refresh.
throw new Error('Git merge-tree --write-tree is unavailable on this execution host.')
},
isUnsupportedMergeTreeWriteTreeError
)
}
async function loadConflictingFilesWithLegacyMergeTree(
@ -300,12 +318,3 @@ function getGitErrorOutput(error: unknown, key: 'stdout' | 'stderr'): string {
const output = (error as Partial<Record<'stdout' | 'stderr', unknown>>)[key]
return typeof output === 'string' ? output : ''
}
function isUnsupportedMergeBaseOption(error: unknown): boolean {
const output = `${getGitErrorOutput(error, 'stderr')}\n${
error instanceof Error ? error.message : ''
}`
return /(?:unknown|unrecognized) option(?::|\s+)[`']?(?:--?)?merge-base[`']?(?:\s|$)/i.test(
output
)
}

View File

@ -12,6 +12,7 @@ import { join } from 'node:path'
import type * as RepoModule from '../git/repo'
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { getGitRepoRoot, isGitRepo } from '../git/repo'
import { clearGitCapabilityStateForTests } from '../git/git-capability-state'
const {
handleMock,
@ -147,6 +148,10 @@ vi.mock('./ssh', () => ({
import { registerRepoHandlers } from './repos'
beforeEach(() => {
clearGitCapabilityStateForTests()
})
describe('projectGroups IPC validation', () => {
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
const mockWindow = {
@ -2698,15 +2703,22 @@ describe('repos:searchBaseRefs SSH relay', () => {
query: '',
limit: 1
})
const repeatedResult = await handlers.get('repos:searchBaseRefs')!(null, {
repoId: 'r1',
query: '',
limit: 1
})
expect(result).toEqual(['origin/main'])
expect(repeatedResult).toEqual(['origin/main'])
const forEachRefCalls = mockGitProvider.exec.mock.calls.filter(
(call) => (call[0] as string[])[0] === 'for-each-ref'
)
expect(forEachRefCalls).toHaveLength(2)
expect(forEachRefCalls).toHaveLength(3)
expect(forEachRefCalls[0][0]).toContain('--exclude=refs/remotes/**/HEAD')
expect(forEachRefCalls[1][0]).not.toContain('--exclude=refs/remotes/**/HEAD')
expect(forEachRefCalls[1][0]).toContain('--count=104')
expect(forEachRefCalls[2][0]).not.toContain('--exclude=refs/remotes/**/HEAD')
})
it('sends the widened `**` argv so all remotes and slash-named branches are discoverable', async () => {

View File

@ -74,6 +74,7 @@ import {
searchBaseRefDetails
} from '../git/repo'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { getSshGitCapabilityCache } from '../git/git-capability-state'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username'
import { enrichRepoGitUsernames } from '../repo-git-username-enrichment'
@ -2548,32 +2549,33 @@ async function searchBaseRefDetailsForRepo(
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
const capabilities = getSshGitCapabilityCache(provider)
const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise<string> => {
try {
return (
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout
} catch (err) {
if (!isForEachRefExcludeUnsupportedError(err)) {
throw err
}
return (
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
excludeRemoteHead: false,
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout
}
return capabilities.runWithFallback(
'for-each-ref-exclude',
async () =>
(
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout,
async () =>
(
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
excludeRemoteHead: false,
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout,
isForEachRefExcludeUnsupportedError
)
}
// Why: delegate the NUL-parse + HEAD filter + dedup + limit pipeline
// to the shared helper so the SSH and local paths cannot diverge.

View File

@ -22258,19 +22258,22 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(runtimeStore as never)
const result = await runtime.searchRepoRefs('id:remote-repo', '', 1)
const repeatedResult = await runtime.searchRepoRefs('id:remote-repo', '', 1)
expect(result).toEqual({
refs: ['origin/main'],
refDetails: [{ refName: 'origin/main', localBranchName: 'main' }],
truncated: true
})
expect(repeatedResult).toEqual(result)
const forEachRefCalls = provider.exec.mock.calls.filter(
(call) => (call[0] as string[])[0] === 'for-each-ref'
)
expect(forEachRefCalls).toHaveLength(2)
expect(forEachRefCalls).toHaveLength(3)
expect(forEachRefCalls[0][0]).toContain('--exclude=refs/remotes/**/HEAD')
expect(forEachRefCalls[1][0]).not.toContain('--exclude=refs/remotes/**/HEAD')
expect(forEachRefCalls[1][0]).toContain('--count=108')
expect(forEachRefCalls[2][0]).not.toContain('--exclude=refs/remotes/**/HEAD')
})
it('resolves SSH worktrees when manually updating lineage', async () => {

View File

@ -619,6 +619,7 @@ import {
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
import { hasWorktreeBaseCommitRef } from '../git/worktree-base-ref-probe'
import { resolveLocalGitUsername } from '../git/git-username'
import { getSshGitCapabilityCache } from '../git/git-capability-state'
import {
listWorktrees,
listWorktreesStrict,
@ -11809,32 +11810,33 @@ export class OrcaRuntimeService {
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
const capabilities = getSshGitCapabilityCache(provider)
const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise<string> => {
try {
return (
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout
} catch (err) {
if (!isForEachRefExcludeUnsupportedError(err)) {
throw err
}
return (
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
excludeRemoteHead: false,
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout
}
return capabilities.runWithFallback(
'for-each-ref-exclude',
async () =>
(
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout,
async () =>
(
await provider.exec(
buildSearchBaseRefsArgv(normalizedQuery, limit, {
excludeRemoteHead: false,
remoteNames: remotes,
patternGroup
}),
repo.path
)
).stdout,
isForEachRefExcludeUnsupportedError
)
}
const searchTokens = normalizedQuery.split('/').filter((token) => token.length > 0)
if (searchTokens.length > 1) {

View File

@ -1,9 +1,17 @@
import { describe, expect, it, vi } from 'vitest'
import * as path from 'node:path'
import { GitCapabilityCache } from '../shared/git-capability-cache'
import type { GitExec } from './git-handler-ops'
import { removeWorktreeOp } from './git-handler-worktree-ops'
import { forceDeletePreservedRelayBranch } from './git-handler-branch-cleanup'
function removeWorktreeWithCapabilityCache(
git: GitExec,
params: Parameters<typeof removeWorktreeOp>[1]
) {
return removeWorktreeOp(git, params, new GitCapabilityCache())
}
function worktreeList(...entries: { path: string; branch?: string }[]): string {
return entries
.map((entry, index) =>
@ -192,7 +200,9 @@ describe('removeWorktreeOp branch cleanup', () => {
return { stdout: '', stderr: '' }
})
await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({})
await expect(
removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
).resolves.toEqual({})
expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String))
expect(git).toHaveBeenCalledWith(
@ -279,7 +289,9 @@ describe('removeWorktreeOp branch cleanup', () => {
return { stdout: '', stderr: '' }
})
await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({})
await expect(
removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
).resolves.toEqual({})
expect(git).toHaveBeenCalledWith(
['update-ref', '-d', 'refs/heads/feature/test', '1'],
@ -341,7 +353,9 @@ describe('removeWorktreeOp branch cleanup', () => {
return { stdout: '', stderr: '' }
})
await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({})
await expect(
removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
).resolves.toEqual({})
const commandIndex = (expectedArgs: string[]) =>
calls.findIndex(({ args }) => JSON.stringify(args) === JSON.stringify(expectedArgs))
@ -404,7 +418,9 @@ describe('removeWorktreeOp branch cleanup', () => {
return { stdout: '', stderr: '' }
})
await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({
await expect(
removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
).resolves.toEqual({
preservedBranch: { branchName: 'feature/test', head: '1' }
})

View File

@ -3,6 +3,7 @@ import {
getBranchCleanupTargetRefs,
refreshBranchCleanupTargetRefs
} from '../shared/git-branch-cleanup'
import type { GitCapabilityCache } from '../shared/git-capability-cache'
import type { GitExec } from './git-handler-ops'
import { parseWorktreeList } from './git-handler-utils'
@ -10,7 +11,8 @@ export async function deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure(
git: GitExec,
repoPath: string,
branchName: string,
branchHead: string
branchHead: string,
capabilities: GitCapabilityCache
): Promise<boolean> {
const runGit = (args: string[], options?: { stdin?: string }) =>
options ? git(args, repoPath, options) : git(args, repoPath)
@ -19,7 +21,9 @@ export async function deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure(
// Why: SSH worktrees hit the same squash-merge shape as local worktrees.
// Git's no-op merge proof lets us clean up only branches whose changes
// already exist on the saved base ref.
if (!(await branchHasNoUnmergedChangesOnAnyTarget(runGit, branchName, targetRefs))) {
if (
!(await branchHasNoUnmergedChangesOnAnyTarget(runGit, branchName, targetRefs, capabilities))
) {
return false
}
await deleteRelayBranchAtExpectedHead(git, repoPath, branchName, branchHead)

View File

@ -1,9 +1,11 @@
import type { GitExec } from './git-handler-ops'
import { areRelayWorktreePathsEqual, readRelayWorktreeList } from './git-handler-worktree-ops'
import type { GitCapabilityCache } from '../shared/git-capability-cache'
export async function refreshLocalBaseRefForWorktreeCreateOp(
git: GitExec,
params: Record<string, unknown>
params: Record<string, unknown>,
capabilities: GitCapabilityCache
): Promise<void> {
const repoPath = params.repoPath as string
const fullRef = params.fullRef as string
@ -42,7 +44,7 @@ export async function refreshLocalBaseRefForWorktreeCreateOp(
throw new Error('Local base ref is not a fast-forward update.')
}
const worktrees = await readRelayWorktreeList(git, repoPath)
const worktrees = await readRelayWorktreeList(git, repoPath, capabilities)
const ownerWorktree = worktrees.find((worktree) => worktree.branch === fullRef)
if (ownerWorktree) {
if (ownerWorktreePath && !areRelayWorktreePathsEqual(ownerWorktree.path, ownerWorktreePath)) {

View File

@ -10,6 +10,7 @@ import * as path from 'node:path'
import { decodeGitCQuotedPath } from '../shared/git-cquoted-path'
import { isBinaryBuffer } from '../shared/binary-buffer'
import type { GitLineStats } from '../shared/git-uncommitted-line-stats'
export { isUnsupportedWorktreeListZError } from '../shared/git-worktree-command-capabilities'
export function parseBranchStatusChar(char: string): string {
switch (char) {
@ -134,36 +135,6 @@ export function parseBranchDiff(
// ─── Worktree parsing ────────────────────────────────────────────────
function getErrorText(error: unknown): string {
if (typeof error === 'object' && error !== null) {
const parts: string[] = []
if ('message' in error && typeof error.message === 'string') {
parts.push(error.message)
}
if ('stderr' in error && typeof error.stderr === 'string') {
parts.push(error.stderr)
}
return parts.join('\n')
}
return String(error)
}
function getErrorCode(error: unknown): string | undefined {
return typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code)
: undefined
}
export function isUnsupportedWorktreeListZError(error: unknown): boolean {
// `-z` is this command's only flag older Git (<2.36) lacks, so its usage exit
// 129 signals the rejection in any locale; key for SSH remotes on old Git.
if (getErrorCode(error) === '129') {
return true
}
return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error))
}
export function parseWorktreeList(
output: string,
options: { nulDelimited?: boolean } = {}

View File

@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RelayContext } from './context'
import { GitHandler } from './git-handler'
import {
createMockDispatcher,
type MockDispatcher,
type RelayDispatcher
} from './git-handler-test-setup'
type GitSpyTarget = {
git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }>
}
const WORKTREE_LIST_OUTPUT = `worktree /repo
HEAD abc123
branch refs/heads/main
`
describe('relay worktree Git capabilities', () => {
let dispatcher: MockDispatcher
let handler: GitHandler
beforeEach(() => {
dispatcher = createMockDispatcher()
handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext())
})
it('does not repeat a known-unsupported -z probe on later scans', async () => {
const gitSpy = vi
.spyOn(handler as unknown as GitSpyTarget, 'git')
.mockImplementation((args: string[]) => {
if (args.includes('-z')) {
return Promise.reject(
Object.assign(new Error('git usage error'), {
code: 129,
stderr: 'usage: git worktree list [<options>]\n'
})
)
}
return Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' })
})
await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
expect(gitSpy.mock.calls.map(([args]) => args)).toEqual([
['worktree', 'list', '--porcelain', '-z'],
['worktree', 'list', '--porcelain'],
['worktree', 'list', '--porcelain']
])
})
it('re-probes after a relay handler is replaced', async () => {
const mockOldGit = (target: GitHandler) =>
vi.spyOn(target as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) => {
if (args.includes('-z')) {
return Promise.reject(
Object.assign(new Error('git usage error'), {
code: 129,
stderr: 'usage: git worktree list [<options>]\n'
})
)
}
return Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' })
})
const firstGit = mockOldGit(handler)
const replacementDispatcher = createMockDispatcher()
const replacementHandler = new GitHandler(
replacementDispatcher as unknown as RelayDispatcher,
new RelayContext()
)
const replacementGit = mockOldGit(replacementHandler)
await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
await replacementDispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
expect(firstGit.mock.calls.filter(([args]) => args.includes('-z'))).toHaveLength(1)
expect(replacementGit.mock.calls.filter(([args]) => args.includes('-z'))).toHaveLength(1)
})
it('does not repeat a known-unsupported rev-parse --path-format probe', async () => {
const gitSpy = vi
.spyOn(handler as unknown as GitSpyTarget, 'git')
.mockImplementation((args: string[]) => {
if (args[0] === 'worktree') {
return Promise.resolve({
stdout: 'worktree /git-store/project.git\nHEAD abc123\nbranch refs/heads/main\n',
stderr: ''
})
}
if (args.includes('--path-format=absolute')) {
return Promise.reject(
Object.assign(new Error('unknown option: --path-format=absolute'), {
stderr: 'error: unknown option `path-format=absolute`\n'
})
)
}
return Promise.resolve({
stdout: '/repo\n/git-store/project.git\n',
stderr: ''
})
})
await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
const revParseCalls = gitSpy.mock.calls.filter(([args]) => args[0] === 'rev-parse')
expect(revParseCalls.map(([args]) => args)).toEqual([
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
['rev-parse', '--show-toplevel', '--git-common-dir'],
['rev-parse', '--show-toplevel', '--git-common-dir']
])
})
})

View File

@ -0,0 +1,43 @@
import type { GitCapabilityCache } from '../shared/git-capability-cache'
import type { GitExec } from './git-handler-ops'
import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils'
export type RelayWorktreeInfo = {
path: string
branch?: string
head?: string
locked?: boolean
lockReason?: string
}
export async function readRelayWorktreeList(
git: GitExec,
repoPath: string,
capabilities: GitCapabilityCache
): Promise<RelayWorktreeInfo[]> {
return capabilities.runWithFallback(
'worktree-list-z',
async () => {
const { stdout } = await git(['worktree', 'list', '--porcelain', '-z'], repoPath)
return normalizeRelayWorktrees(parseWorktreeList(stdout, { nulDelimited: true }))
},
async () => {
// Why: `-z` preserves newlines; fallback keeps Git <2.36 compatible.
const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath)
return normalizeRelayWorktrees(parseWorktreeList(stdout))
},
isUnsupportedWorktreeListZError
)
}
function normalizeRelayWorktrees(worktrees: Record<string, unknown>[]): RelayWorktreeInfo[] {
return worktrees
.map((worktree) => ({
path: typeof worktree.path === 'string' ? worktree.path : '',
head: typeof worktree.head === 'string' ? worktree.head : undefined,
branch: typeof worktree.branch === 'string' ? worktree.branch : undefined,
locked: worktree.locked === true ? true : undefined,
lockReason: typeof worktree.lockReason === 'string' ? worktree.lockReason : undefined
}))
.filter((worktree) => worktree.path.length > 0)
}

View File

@ -1,8 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import * as path from 'node:path'
import { GitCapabilityCache } from '../shared/git-capability-cache'
import type { GitExec } from './git-handler-ops'
import { addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops'
function removeWorktreeWithCapabilityCache(
git: GitExec,
params: Parameters<typeof removeWorktreeOp>[1]
) {
return removeWorktreeOp(git, params, new GitCapabilityCache())
}
function worktreeList(...entries: { path: string; branch?: string }[]): string {
return entries
.map((entry, index) =>
@ -145,9 +153,9 @@ describe('removeWorktreeOp', () => {
return { stdout: '', stderr: '' }
})
await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).rejects.toThrow(
'Worktree is locked by Git. Lock reason: remote agent.'
)
await expect(
removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
).rejects.toThrow('Worktree is locked by Git. Lock reason: remote agent.')
expect(git).not.toHaveBeenCalledWith(
['worktree', 'remove', '/repo-feature'],
expect.any(String)
@ -178,7 +186,7 @@ describe('removeWorktreeOp', () => {
return { stdout: '', stderr: '' }
})
await removeWorktreeOp(git, { worktreePath: '/repo-feature' })
await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
expect(calls).toEqual([
'/repo-feature$ rev-parse --git-common-dir',
@ -214,7 +222,9 @@ describe('removeWorktreeOp', () => {
})
// The unmerged-branch refusal must be surfaced without failing workspace removal.
await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({
await expect(
removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
).resolves.toEqual({
preservedBranch: { branchName: 'feature/test', head: '1' }
})
@ -244,7 +254,7 @@ describe('removeWorktreeOp', () => {
return { stdout: '', stderr: '' }
})
await removeWorktreeOp(git, {
await removeWorktreeWithCapabilityCache(git, {
worktreePath: '/repo-feature',
force: true,
forceBranchDelete: true
@ -276,7 +286,7 @@ describe('removeWorktreeOp', () => {
})
await expect(
removeWorktreeOp(git, { worktreePath: '/repo-feature', force: true })
removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature', force: true })
).rejects.toThrow('Worktree is locked by Git. Lock reason: remote agent')
expect(git).not.toHaveBeenCalledWith(
@ -304,7 +314,10 @@ describe('removeWorktreeOp', () => {
return { stdout: '', stderr: '' }
})
await removeWorktreeOp(git, { worktreePath: '/repo-feature', deleteBranch: false })
await removeWorktreeWithCapabilityCache(git, {
worktreePath: '/repo-feature',
deleteBranch: false
})
expect(calls).toEqual([
'/repo-feature$ rev-parse --git-common-dir',
@ -343,7 +356,7 @@ describe('removeWorktreeOp', () => {
return { stdout: '', stderr: '' }
})
await removeWorktreeOp(git, { worktreePath: '/repo-feature' })
await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String))
expect(git).toHaveBeenCalledWith(['worktree', 'prune'], expect.any(String))

View File

@ -1,8 +1,8 @@
import * as path from 'node:path'
import { resolveWorktreeAddBaseRef } from '../shared/worktree-base-ref'
import type { GitExec } from './git-handler-ops'
import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils'
export { removeWorktreeOp } from './git-handler-worktree-remove'
export { readRelayWorktreeList } from './git-handler-worktree-list'
async function persistRelayWorktreeCreationBase(
git: GitExec,
@ -111,40 +111,6 @@ export async function addWorktreeOp(git: GitExec, params: Record<string, unknown
}
}
type RelayWorktreeInfo = {
path: string
branch?: string
head?: string
}
export async function readRelayWorktreeList(
git: GitExec,
repoPath: string
): Promise<RelayWorktreeInfo[]> {
try {
const { stdout } = await git(['worktree', 'list', '--porcelain', '-z'], repoPath)
return normalizeRelayWorktrees(parseWorktreeList(stdout, { nulDelimited: true }))
} catch (error) {
if (!isUnsupportedWorktreeListZError(error)) {
throw error
}
}
// Why: `-z` preserves newlines; fallback keeps Git <2.36 compatible.
const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath)
return normalizeRelayWorktrees(parseWorktreeList(stdout))
}
function normalizeRelayWorktrees(worktrees: Record<string, unknown>[]): RelayWorktreeInfo[] {
return worktrees
.map((worktree) => ({
path: typeof worktree.path === 'string' ? worktree.path : '',
head: typeof worktree.head === 'string' ? worktree.head : undefined,
branch: typeof worktree.branch === 'string' ? worktree.branch : undefined
}))
.filter((worktree) => worktree.path.length > 0)
}
function isPosixAbsolutePath(value: string): boolean {
return value.startsWith('/')
}

View File

@ -1,8 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import * as path from 'node:path'
import { GitCapabilityCache } from '../shared/git-capability-cache'
import type { GitExec } from './git-handler-ops'
import { removeWorktreeOp } from './git-handler-worktree-ops'
function removeWorktreeWithCapabilityCache(
git: GitExec,
params: Parameters<typeof removeWorktreeOp>[1]
) {
return removeWorktreeOp(git, params, new GitCapabilityCache())
}
function lineWorktreeList(...entries: { path: string; branch?: string }[]): string {
return entries
.map((entry, index) =>
@ -56,7 +64,7 @@ describe('relay worktree path parsing', () => {
return { stdout: '', stderr: '' }
})
await removeWorktreeOp(git, { worktreePath })
await removeWorktreeWithCapabilityCache(git, { worktreePath })
expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/newline'], resolvedRepoPath())
})
@ -90,7 +98,7 @@ describe('relay worktree path parsing', () => {
return { stdout: '', stderr: '' }
})
await removeWorktreeOp(git, { worktreePath: '/repo-feature' })
await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' })
expect(calls).toEqual([
'/repo-feature$ rev-parse --git-common-dir',

View File

@ -3,15 +3,8 @@ import type { RemoveWorktreeResult } from '../shared/types'
import { assertWorktreeUnlockedForRemoval } from '../shared/worktree-removal'
import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup'
import type { GitExec } from './git-handler-ops'
import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils'
type RelayWorktreeInfo = {
path: string
branch?: string
head?: string
locked?: boolean
lockReason?: string
}
import type { GitCapabilityCache } from '../shared/git-capability-cache'
import { readRelayWorktreeList } from './git-handler-worktree-list'
function getErrorText(error: unknown): string {
if (typeof error === 'object' && error !== null) {
@ -77,36 +70,13 @@ function areRelayWorktreePathsEqual(leftPath: string, rightPath: string): boolea
return compareCaseInsensitive ? left.toLowerCase() === right.toLowerCase() : left === right
}
function normalizeRelayWorktrees(worktrees: Record<string, unknown>[]): RelayWorktreeInfo[] {
return worktrees
.map((worktree) => ({
path: typeof worktree.path === 'string' ? worktree.path : '',
head: typeof worktree.head === 'string' ? worktree.head : undefined,
branch: typeof worktree.branch === 'string' ? worktree.branch : undefined,
locked: worktree.locked === true ? true : undefined,
lockReason: typeof worktree.lockReason === 'string' ? worktree.lockReason : undefined
}))
.filter((worktree) => worktree.path.length > 0)
}
async function readRelayWorktreeList(git: GitExec, repoPath: string): Promise<RelayWorktreeInfo[]> {
async function listRelayWorktreesForRemoval(
git: GitExec,
repoPath: string,
capabilities: GitCapabilityCache
) {
try {
const { stdout } = await git(['worktree', 'list', '--porcelain', '-z'], repoPath)
return normalizeRelayWorktrees(parseWorktreeList(stdout, { nulDelimited: true }))
} catch (error) {
if (!isUnsupportedWorktreeListZError(error)) {
throw error
}
}
// Why: `-z` preserves newlines; fallback keeps Git <2.36 compatible.
const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath)
return normalizeRelayWorktrees(parseWorktreeList(stdout))
}
async function listRelayWorktreesForRemoval(git: GitExec, repoPath: string) {
try {
return await readRelayWorktreeList(git, repoPath)
return await readRelayWorktreeList(git, repoPath, capabilities)
} catch {
return []
}
@ -153,7 +123,8 @@ async function deleteRelayBranchAfterWorktreeRemoval(
export async function removeWorktreeOp(
git: GitExec,
params: Record<string, unknown>
params: Record<string, unknown>,
capabilities: GitCapabilityCache
): Promise<RemoveWorktreeResult> {
const worktreePath = params.worktreePath as string
const force = params.force as boolean | undefined
@ -171,7 +142,7 @@ export async function removeWorktreeOp(
// fall through with worktreePath as repo
}
const worktreesBeforeRemoval = await listRelayWorktreesForRemoval(git, repoPath)
const worktreesBeforeRemoval = await listRelayWorktreesForRemoval(git, repoPath, capabilities)
const removedWorktree = worktreesBeforeRemoval.find((worktree) =>
areRelayWorktreePathsEqual(worktree.path, worktreePath)
)
@ -217,7 +188,8 @@ export async function removeWorktreeOp(
git,
repoPath,
branchName,
branchHead
branchHead,
capabilities
)
) {
return {}

View File

@ -61,6 +61,11 @@ import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message'
import { syncForkDefaultBranch, validateGitForkSyncExpectedUpstream } from '../shared/git-fork-sync'
import { InFlightPromiseDedupe, stableInFlightKey } from '../shared/in-flight-promise-dedupe'
import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../shared/git-fetch-auto-maintenance'
import { GitCapabilityCache } from '../shared/git-capability-cache'
import {
hasUnsupportedRevParsePathFormatEcho,
isUnsupportedRevParsePathFormatError
} from '../shared/git-worktree-command-capabilities'
import { GitResponseStreamRegistry } from './git-response-stream'
import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol'
@ -77,26 +82,6 @@ function resolveSubmoduleStatusArea(
return 'unstaged'
}
function getErrorText(error: unknown): string {
if (typeof error === 'object' && error !== null) {
const parts: string[] = []
if ('message' in error && typeof error.message === 'string') {
parts.push(error.message)
}
if ('stderr' in error && typeof error.stderr === 'string') {
parts.push(error.stderr)
}
return parts.join('\n')
}
return String(error)
}
function isUnsupportedRevParsePathFormatError(error: unknown): boolean {
return /(?:unknown|invalid|unrecognized).*(?:--path-format|path-format)/i.test(
getErrorText(error)
)
}
function isWindowsAbsolutePath(value: string): boolean {
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')
}
@ -173,6 +158,7 @@ function execFileWithStdin(
export class GitHandler {
private dispatcher: RelayDispatcher
private readonly gitDiffReadDedupe = new InFlightPromiseDedupe<unknown>()
private readonly gitCapabilities = new GitCapabilityCache()
// Why: large diff/exec responses are chunked onto the bulk lane so they do
// not head-of-line-block interactive pty.data echo on the shared SSH channel.
private readonly responseStreams = new GitResponseStreamRegistry()
@ -1277,23 +1263,29 @@ export class GitHandler {
private async readRepoLocation(repoPath: string): Promise<RelayRepoLocation | undefined> {
try {
const { stdout } = await this.git(
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
repoPath
return await this.gitCapabilities.runWithFallback(
'rev-parse-path-format',
async () => {
const { stdout } = await this.git(
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
repoPath
)
if (hasUnsupportedRevParsePathFormatEcho(stdout)) {
// Why: some old Git versions echo the unknown option and exit zero;
// remember that signal even though the trailing paths remain usable.
this.gitCapabilities.rememberUnsupported('rev-parse-path-format')
}
return parseRelayRepoLocation(repoPath, stdout)
},
async () => {
const { stdout } = await this.git(
['rev-parse', '--show-toplevel', '--git-common-dir'],
repoPath
)
return parseRelayRepoLocation(repoPath, stdout)
},
isUnsupportedRevParsePathFormatError
)
return parseRelayRepoLocation(repoPath, stdout)
} catch (error) {
if (!isUnsupportedRevParsePathFormatError(error)) {
return undefined
}
}
try {
const { stdout } = await this.git(
['rev-parse', '--show-toplevel', '--git-common-dir'],
repoPath
)
return parseRelayRepoLocation(repoPath, stdout)
} catch {
return undefined
}
@ -1333,30 +1325,33 @@ export class GitHandler {
private async listWorktrees(params: Record<string, unknown>, context?: RequestContext) {
const repoPath = params.repoPath as string
try {
const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, {
signal: context?.signal
})
return this.normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout, { nulDelimited: true })
return this.gitCapabilities
.runWithFallback(
'worktree-list-z',
async () => {
const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, {
signal: context?.signal
})
return this.normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout, { nulDelimited: true })
)
},
async () => {
// Why: `-z` keeps newline-containing SSH worktree paths intact, but
// Git <2.36 requires the line-block parser.
try {
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, {
signal: context?.signal
})
return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout))
} catch {
return []
}
},
isUnsupportedWorktreeListZError
)
} catch (error) {
if (!isUnsupportedWorktreeListZError(error)) {
return []
}
}
// Why: `-z` keeps newline-containing SSH worktree paths intact, but older
// Git rejects it. Fall back to the original line-block parser there.
try {
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, {
signal: context?.signal
})
return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout))
} catch {
return []
}
.catch(() => [])
}
private async addWorktree(params: Record<string, unknown>) {
@ -1364,7 +1359,9 @@ export class GitHandler {
}
private async removeWorktree(params: Record<string, unknown>) {
return this.runWithDiffDedupeClear(() => removeWorktreeOp(this.git.bind(this), params))
return this.runWithDiffDedupeClear(() =>
removeWorktreeOp(this.git.bind(this), params, this.gitCapabilities)
)
}
private async worktreeIsClean(params: Record<string, unknown>) {
@ -1373,7 +1370,7 @@ export class GitHandler {
private async refreshLocalBaseRefForWorktreeCreate(params: Record<string, unknown>) {
return this.runWithDiffDedupeClear(() =>
refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params)
refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities)
)
}
}

View File

@ -0,0 +1,145 @@
import { execFile } from 'node:child_process'
import { mkdtemp, 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'
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[]): 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,
'-v',
`${repoPath}:/repo`,
'-w',
'/repo',
image,
'-c',
'safe.directory=/repo',
...args
],
{ maxBuffer: 2 * 1024 * 1024 }
)
}
return execFileAsync(binary!, args, { cwd: repoPath, 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 ')
})
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('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()
}
})
})

View File

@ -4,6 +4,7 @@ import {
refreshBranchCleanupTargetRefs,
type GitBranchCleanupExec
} from './git-branch-cleanup'
import { GitCapabilityCache } from './git-capability-cache'
function baseProofResponses(
responses: Partial<Record<string, string | Error>> = {}
@ -94,7 +95,12 @@ describe('branchHasNoUnmergedChangesOnAnyTarget', () => {
const runGit = baseProofResponses()
await expect(
branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main'])
branchHasNoUnmergedChangesOnAnyTarget(
runGit,
'feature/test',
['refs/remotes/origin/main'],
new GitCapabilityCache()
)
).resolves.toBe(true)
expect(runGit).toHaveBeenCalledWith(['patch-id', '--stable'], { stdin: 'branch-diff' })
@ -106,7 +112,12 @@ describe('branchHasNoUnmergedChangesOnAnyTarget', () => {
const runGit = baseProofResponses({ squashPatchId: 'other-patch squash\n' })
await expect(
branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main'])
branchHasNoUnmergedChangesOnAnyTarget(
runGit,
'feature/test',
['refs/remotes/origin/main'],
new GitCapabilityCache()
)
).resolves.toBe(false)
})
@ -116,7 +127,12 @@ describe('branchHasNoUnmergedChangesOnAnyTarget', () => {
})
await expect(
branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main'])
branchHasNoUnmergedChangesOnAnyTarget(
runGit,
'feature/test',
['refs/remotes/origin/main'],
new GitCapabilityCache()
)
).resolves.toBe(false)
})
@ -127,7 +143,12 @@ describe('branchHasNoUnmergedChangesOnAnyTarget', () => {
})
await expect(
branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main'])
branchHasNoUnmergedChangesOnAnyTarget(
runGit,
'feature/test',
['refs/remotes/origin/main'],
new GitCapabilityCache()
)
).resolves.toBe(false)
expect(runGit).not.toHaveBeenCalledWith(['show', '--format=', 'commit-0'])
@ -137,7 +158,40 @@ describe('branchHasNoUnmergedChangesOnAnyTarget', () => {
const runGit = baseProofResponses({ branchPatchId: new Error('patch-id failed') })
await expect(
branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main'])
branchHasNoUnmergedChangesOnAnyTarget(
runGit,
'feature/test',
['refs/remotes/origin/main'],
new GitCapabilityCache()
)
).resolves.toBe(false)
})
it('does not repeat a rejected merge-tree --write-tree proof on old Git', async () => {
const unsupported = Object.assign(new Error('unknown option'), {
stderr: 'fatal: unknown rev --write-tree'
})
const runGit = baseProofResponses({
'merge-tree --write-tree target refs/heads/feature/test': unsupported,
'rev-list --right-only --merges --count target...refs/heads/feature/test': '0\n',
'cherry -v target refs/heads/feature/test': '+ branch-only commit\n'
})
const capabilities = new GitCapabilityCache()
await branchHasNoUnmergedChangesOnAnyTarget(
runGit,
'feature/test',
['refs/remotes/origin/main'],
capabilities
)
await branchHasNoUnmergedChangesOnAnyTarget(
runGit,
'feature/test',
['refs/remotes/origin/main'],
capabilities
)
const mergeTreeCalls = vi.mocked(runGit).mock.calls.filter(([args]) => args[0] === 'merge-tree')
expect(mergeTreeCalls).toHaveLength(1)
})
})

View File

@ -1,3 +1,6 @@
import type { GitCapabilityCache } from './git-capability-cache'
import { isUnsupportedMergeTreeWriteTreeError } from './git-merge-tree-capability'
export type GitBranchCleanupExec = (
argv: string[],
options?: { stdin?: string }
@ -102,14 +105,26 @@ async function hasBranchOnlyMergeCommits(
async function branchMergesWithoutTreeChanges(
runGit: GitBranchCleanupExec,
targetOid: string,
branchRef: string
branchRef: string,
capabilities: GitCapabilityCache
): Promise<boolean> {
const mergedTree = await readOptionalGitStdout(runGit, [
'merge-tree',
'--write-tree',
targetOid,
branchRef
])
const args = ['merge-tree', '--write-tree', targetOid, branchRef]
const readMergedTree = async (): Promise<string | null> => {
try {
return await capabilities.runWithFallback(
'merge-tree-write-tree',
async () => (await runGit(args)).stdout.trim() || null,
async () => null,
isUnsupportedMergeTreeWriteTreeError
)
} catch {
return null
}
}
const mergedTree = await readMergedTree()
if (!mergedTree) {
return false
}
const targetTree = await readOptionalGitStdout(runGit, [
'rev-parse',
'--verify',
@ -159,7 +174,8 @@ async function computeStablePatchId(
async function branchNetPatchMatchesTargetSquashCommit(
runGit: GitBranchCleanupExec,
targetOid: string,
branchRef: string
branchRef: string,
capabilities: GitCapabilityCache
): Promise<boolean> {
const mergeBase = await readOptionalGitStdout(runGit, ['merge-base', targetOid, branchRef])
if (!mergeBase) {
@ -198,7 +214,7 @@ async function branchNetPatchMatchesTargetSquashCommit(
// tree merge proves the branch contributes no additional changes there.
if (
commitPatchId === branchPatchId &&
(await branchMergesWithoutTreeChanges(runGit, commitOid, branchRef))
(await branchMergesWithoutTreeChanges(runGit, commitOid, branchRef, capabilities))
) {
return true
}
@ -209,7 +225,8 @@ async function branchNetPatchMatchesTargetSquashCommit(
export async function branchHasNoUnmergedChangesOnAnyTarget(
runGit: GitBranchCleanupExec,
branchName: string,
targetRefs: string[]
targetRefs: string[],
capabilities: GitCapabilityCache
): Promise<boolean> {
const branchRef = `refs/heads/${branchName}`
@ -218,11 +235,13 @@ export async function branchHasNoUnmergedChangesOnAnyTarget(
if (!targetOid) {
continue
}
if (await branchMergesWithoutTreeChanges(runGit, targetOid, branchRef)) {
if (await branchMergesWithoutTreeChanges(runGit, targetOid, branchRef, capabilities)) {
return true
}
if (await hasBranchOnlyMergeCommits(runGit, targetOid, branchRef)) {
if (await branchNetPatchMatchesTargetSquashCommit(runGit, targetOid, branchRef)) {
if (
await branchNetPatchMatchesTargetSquashCommit(runGit, targetOid, branchRef, capabilities)
) {
return true
}
continue

View File

@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest'
import { GIT_CAPABILITY_RETRY_INTERVAL_MS, GitCapabilityCache } from './git-capability-cache'
describe('GitCapabilityCache', () => {
it('retries a capability after the compatibility interval', () => {
const cache = new GitCapabilityCache()
cache.rememberUnsupported('worktree-list-z', 1_000)
expect(cache.shouldTry('worktree-list-z', 1_000 + GIT_CAPABILITY_RETRY_INTERVAL_MS - 1)).toBe(
false
)
expect(cache.shouldTry('worktree-list-z', 1_000 + GIT_CAPABILITY_RETRY_INTERVAL_MS)).toBe(true)
})
it('coalesces concurrent capability probes after an unsupported result', async () => {
const cache = new GitCapabilityCache()
let rejectProbe!: (error: Error) => void
const firstPreferred = vi.fn(
() =>
new Promise<string>((_resolve, reject) => {
rejectProbe = reject
})
)
const secondPreferred = vi.fn(async () => 'unexpected')
const firstFallback = vi.fn(async () => 'first-fallback')
const secondFallback = vi.fn(async () => 'second-fallback')
const isUnsupported = (error: unknown): boolean =>
error instanceof Error && error.message === 'unsupported'
const first = cache.runWithFallback(
'for-each-ref-exclude',
firstPreferred,
firstFallback,
isUnsupported
)
const second = cache.runWithFallback(
'for-each-ref-exclude',
secondPreferred,
secondFallback,
isUnsupported
)
rejectProbe(new Error('unsupported'))
await expect(Promise.all([first, second])).resolves.toEqual([
'first-fallback',
'second-fallback'
])
expect(firstPreferred).toHaveBeenCalledTimes(1)
expect(secondPreferred).not.toHaveBeenCalled()
expect(firstFallback).toHaveBeenCalledTimes(1)
expect(secondFallback).toHaveBeenCalledTimes(1)
})
it('does not serialize calls after a capability is known to be supported', async () => {
const cache = new GitCapabilityCache()
const isUnsupported = vi.fn(() => false)
await cache.runWithFallback(
'for-each-ref-exclude',
async () => 'initial-result',
async () => 'unexpected-fallback',
isUnsupported
)
const releases: (() => void)[] = []
let activeCalls = 0
let maxConcurrentCalls = 0
const runPreferred = vi.fn(
() =>
new Promise<string>((resolve) => {
activeCalls += 1
maxConcurrentCalls = Math.max(maxConcurrentCalls, activeCalls)
releases.push(() => {
activeCalls -= 1
resolve('result')
})
})
)
const first = cache.runWithFallback(
'for-each-ref-exclude',
runPreferred,
async () => 'unexpected-fallback',
isUnsupported
)
const second = cache.runWithFallback(
'for-each-ref-exclude',
runPreferred,
async () => 'unexpected-fallback',
isUnsupported
)
expect(runPreferred).toHaveBeenCalledTimes(2)
expect(maxConcurrentCalls).toBe(2)
for (const release of releases) {
release()
}
await expect(Promise.all([first, second])).resolves.toEqual(['result', 'result'])
})
it('drops known support when a later call reports the capability unsupported', async () => {
const cache = new GitCapabilityCache()
const isUnsupported = (error: unknown): boolean =>
error instanceof Error && error.message === 'unsupported'
await cache.runWithFallback(
'for-each-ref-exclude',
async () => 'supported',
async () => 'unexpected-fallback',
isUnsupported
)
await expect(
cache.runWithFallback(
'for-each-ref-exclude',
async () => {
throw new Error('unsupported')
},
async () => 'fallback',
isUnsupported
)
).resolves.toBe('fallback')
const laterPreferred = vi.fn(async () => 'unexpected-preferred')
await expect(
cache.runWithFallback(
'for-each-ref-exclude',
laterPreferred,
async () => 'cached-fallback',
isUnsupported
)
).resolves.toBe('cached-fallback')
expect(laterPreferred).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,115 @@
// Why: suppress hot-loop failures while still detecting an in-place Git
// upgrade during a long Orca session without requiring a restart.
export const GIT_CAPABILITY_RETRY_INTERVAL_MS = 30 * 60_000
export type GitCapability =
| 'for-each-ref-exclude'
| 'merge-tree-merge-base'
| 'merge-tree-write-tree'
| 'rev-parse-path-format'
| 'worktree-list-z'
type GitCapabilityProbeOutcome = 'supported' | 'unsupported' | 'unknown'
export class GitCapabilityCache {
private readonly retryAfterByCapability = new Map<GitCapability, number>()
private readonly probesByCapability = new Map<GitCapability, Promise<GitCapabilityProbeOutcome>>()
private readonly supportedCapabilities = new Set<GitCapability>()
shouldTry(capability: GitCapability, nowMs = Date.now()): boolean {
const retryAfterMs = this.retryAfterByCapability.get(capability)
if (retryAfterMs === undefined) {
return true
}
if (nowMs < retryAfterMs) {
return false
}
this.retryAfterByCapability.delete(capability)
return true
}
rememberUnsupported(capability: GitCapability, nowMs = Date.now()): void {
// Why: optimistic probes preserve newer Git behavior, but repeating a
// known failure on every poll/search wastes subprocesses and trace space.
this.supportedCapabilities.delete(capability)
this.retryAfterByCapability.set(capability, nowMs + GIT_CAPABILITY_RETRY_INTERVAL_MS)
}
async runWithFallback<T>(
capability: GitCapability,
runPreferred: () => Promise<T>,
runFallback: () => Promise<T>,
isUnsupportedError: (error: unknown) => boolean
): Promise<T> {
if (this.supportedCapabilities.has(capability)) {
// Why: supported commands are real work, not disposable probes. Let
// sibling repo/SSH calls retain their intended concurrency.
return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError)
}
if (!this.shouldTry(capability)) {
return runFallback()
}
const inFlightProbe = this.probesByCapability.get(capability)
if (inFlightProbe) {
const outcome = await inFlightProbe
if (outcome === 'unsupported' || !this.shouldTry(capability)) {
return runFallback()
}
return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError)
}
let settleProbe!: (outcome: GitCapabilityProbeOutcome) => void
const probe = new Promise<GitCapabilityProbeOutcome>((resolve) => {
settleProbe = resolve
})
this.probesByCapability.set(capability, probe)
try {
return await this.runPreferredOrFallback(
capability,
runPreferred,
runFallback,
isUnsupportedError,
settleProbe
)
} finally {
if (this.probesByCapability.get(capability) === probe) {
this.probesByCapability.delete(capability)
}
}
}
clear(): void {
this.retryAfterByCapability.clear()
this.probesByCapability.clear()
this.supportedCapabilities.clear()
}
private async runPreferredOrFallback<T>(
capability: GitCapability,
runPreferred: () => Promise<T>,
runFallback: () => Promise<T>,
isUnsupportedError: (error: unknown) => boolean,
settleProbe?: (outcome: GitCapabilityProbeOutcome) => void
): Promise<T> {
try {
const result = await runPreferred()
// A preferred callback can detect old Git's exit-zero option echo and
// remember it as unsupported, so do not overwrite that stronger signal.
const outcome = this.retryAfterByCapability.has(capability) ? 'unsupported' : 'supported'
if (outcome === 'supported') {
this.supportedCapabilities.add(capability)
}
settleProbe?.(outcome)
return result
} catch (error) {
if (!isUnsupportedError(error)) {
settleProbe?.('unknown')
throw error
}
this.rememberUnsupported(capability)
settleProbe?.('unsupported')
return runFallback()
}
}
}

View File

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import {
isUnsupportedMergeTreeMergeBaseError,
isUnsupportedMergeTreeWriteTreeError
} from './git-merge-tree-capability'
describe('isUnsupportedMergeTreeWriteTreeError', () => {
it.each([
{ stderr: 'fatal: unknown rev --write-tree' },
{ stdout: 'usage: git merge-tree <base-tree> <branch1> <branch2>' },
new Error("error: unknown option 'write-tree'")
])('recognizes old-Git write-tree rejection shapes', (error) => {
expect(isUnsupportedMergeTreeWriteTreeError(error)).toBe(true)
})
it('does not classify an ordinary merge failure as unsupported', () => {
expect(
isUnsupportedMergeTreeWriteTreeError({
stderr: 'fatal: refusing to merge unrelated histories'
})
).toBe(false)
})
it('recognizes only an unsupported merge-base option', () => {
expect(
isUnsupportedMergeTreeMergeBaseError({
stderr: "error: unknown option `merge-base'"
})
).toBe(true)
expect(isUnsupportedMergeTreeMergeBaseError({ stderr: 'fatal: merge-base failed' })).toBe(false)
})
})

View File

@ -0,0 +1,27 @@
function getGitErrorText(error: unknown): string {
if (typeof error !== 'object' || error === null) {
return error instanceof Error ? error.message : String(error)
}
const values = ['message', 'stderr', 'stdout']
.map((key) => (error as Record<string, unknown>)[key])
.filter((value): value is string => typeof value === 'string')
return values.join('\n')
}
export function isUnsupportedMergeTreeWriteTreeError(error: unknown): boolean {
const output = getGitErrorText(error)
return (
/(?:unknown|invalid|unrecognized) option(?::|\s+)[`']?(?:--?)?write-tree[`']?(?:\s|$)/i.test(
output
) ||
/unknown rev [`']?--write-tree[`']?(?:\s|$)/i.test(output) ||
/usage:\s*git merge-tree\s+<base-tree>\s+<branch1>\s+<branch2>/i.test(output)
)
}
export function isUnsupportedMergeTreeMergeBaseError(error: unknown): boolean {
const output = getGitErrorText(error)
return /(?:unknown|invalid|unrecognized) option(?::|\s+)[`']?(?:--?)?merge-base[`']?(?:\s|$)/i.test(
output
)
}

View File

@ -0,0 +1,14 @@
function getGitErrorText(error: unknown): string {
if (typeof error !== 'object' || error === null) {
return error instanceof Error ? error.message : String(error)
}
const values = ['message', 'stderr', 'stdout']
.map((key) => (error as Record<string, unknown>)[key])
.filter((value): value is string => typeof value === 'string')
return values.join('\n')
}
export function isForEachRefExcludeUnsupportedError(error: unknown): boolean {
const output = getGitErrorText(error).toLowerCase()
return output.includes('unknown option') && output.includes('exclude')
}

View File

@ -0,0 +1,34 @@
function getGitErrorText(error: unknown): string {
if (typeof error !== 'object' || error === null) {
return error instanceof Error ? error.message : String(error)
}
const values = ['message', 'stderr', 'stdout']
.map((key) => (error as Record<string, unknown>)[key])
.filter((value): value is string => typeof value === 'string')
return values.join('\n')
}
function getGitErrorCode(error: unknown): string | undefined {
return typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code)
: undefined
}
export function isUnsupportedWorktreeListZError(error: unknown): boolean {
// `-z` is this fixed command's only post-baseline flag, so exit 129 is the
// locale-independent rejection signal on old native, WSL, and SSH Git.
if (getGitErrorCode(error) === '129') {
return true
}
return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getGitErrorText(error))
}
export function isUnsupportedRevParsePathFormatError(error: unknown): boolean {
return /(?:unknown|invalid|unrecognized).*(?:--path-format|path-format)/i.test(
getGitErrorText(error)
)
}
export function hasUnsupportedRevParsePathFormatEcho(output: string): boolean {
return output.split(/\r?\n/).some((line) => line.startsWith('--path-format'))
}