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.
This commit is contained in:
Neil
2026-07-10 03:31:59 -07:00
parent c5669cceb5
commit 6f37ef1841
25 changed files with 933 additions and 235 deletions
+59
View File
@@ -0,0 +1,59 @@
# 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.
## Reliability Follow-up
The strongest remaining gate is a small old-Git contract matrix that runs the
capability tests against Git 2.25, a pre-2.36 release, and current Git. Keep the
unit tests regardless: they cover localized error text, WSL/SSH isolation, and
concurrent probes that a single container run does not exercise.
+44
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()
}
+6
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
+5 -1
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')
})
})
+22 -21
View File
@@ -8,6 +8,7 @@ import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path
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 +789,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(
@@ -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']
])
})
})
+6
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()
+65 -38
View File
@@ -15,6 +15,7 @@ import type {
} from '../../shared/types'
import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { getLocalGitCapabilityCache } from './git-capability-state'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
import { resolveGitDir } from './status'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
@@ -403,29 +404,43 @@ function parseRepoLocation(repoPath: string, output: string): RepoLocation | und
}
}
function echoedUnsupportedPathFormatOption(output: string): boolean {
return output.split(/\r?\n/).some((line) => line.startsWith('--path-format'))
}
async function readRepoLocation(
repoPath: string,
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 (echoedUnsupportedPathFormatOption(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
}
@@ -555,29 +570,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(
@@ -1196,7 +1216,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) =>
+17
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'), {
stderr: '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)
})
})
+42 -30
View File
@@ -1,5 +1,10 @@
import type { PRConflictSummary } from '../../shared/types'
import { 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 +22,8 @@ type LocalGitExecOptions = {
wslDistro?: string
}
const mergeTreeMergeBaseUnsupportedRuntimes = new Set<string>()
export function __resetPRConflictSummaryCachesForTests(): void {
mergeTreeMergeBaseUnsupportedRuntimes.clear()
clearGitCapabilityStateForTests()
__resetPRConflictSummaryDerivationCachesForTests()
}
@@ -213,7 +216,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 +241,38 @@ 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) {
// 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),
isUnsupportedMergeBaseOption
),
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(
+13 -1
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 () => {
+27 -25
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.
+4 -1
View File
@@ -21059,19 +21059,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 () => {
+27 -25
View File
@@ -601,6 +601,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,
@@ -10997,32 +10998,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) {
+6 -2
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)
@@ -1,9 +1,11 @@
import type { GitExec } from './git-handler-ops'
import { areRelayWorktreePathsEqual, readRelayWorktreeList } from './git-handler-worktree-ops'
import { GitCapabilityCache } from '../shared/git-capability-cache'
export async function refreshLocalBaseRefForWorktreeCreateOp(
git: GitExec,
params: Record<string, unknown>
params: Record<string, unknown>,
capabilities = new 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)) {
@@ -0,0 +1,86 @@
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('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']
])
})
})
+16 -13
View File
@@ -2,6 +2,7 @@ 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'
import { GitCapabilityCache } from '../shared/git-capability-cache'
export { removeWorktreeOp } from './git-handler-worktree-remove'
async function persistRelayWorktreeCreationBase(
@@ -119,20 +120,22 @@ type RelayWorktreeInfo = {
export async function readRelayWorktreeList(
git: GitExec,
repoPath: string
repoPath: string,
capabilities = new GitCapabilityCache()
): 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))
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[] {
+30 -18
View File
@@ -3,6 +3,7 @@ import type { RemoveWorktreeResult } from '../shared/types'
import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup'
import type { GitExec } from './git-handler-ops'
import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils'
import { GitCapabilityCache } from '../shared/git-capability-cache'
type RelayWorktreeInfo = {
path: string
@@ -84,24 +85,33 @@ function normalizeRelayWorktrees(worktrees: Record<string, unknown>[]): RelayWor
.filter((worktree) => worktree.path.length > 0)
}
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))
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
)
}
async function listRelayWorktreesForRemoval(git: GitExec, repoPath: string) {
async function listRelayWorktreesForRemoval(
git: GitExec,
repoPath: string,
capabilities: GitCapabilityCache
) {
try {
return await readRelayWorktreeList(git, repoPath)
return await readRelayWorktreeList(git, repoPath, capabilities)
} catch {
return []
}
@@ -148,7 +158,8 @@ async function deleteRelayBranchAfterWorktreeRemoval(
export async function removeWorktreeOp(
git: GitExec,
params: Record<string, unknown>
params: Record<string, unknown>,
capabilities = new GitCapabilityCache()
): Promise<RemoveWorktreeResult> {
const worktreePath = params.worktreePath as string
const force = params.force as boolean | undefined
@@ -166,7 +177,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)
)
@@ -210,7 +221,8 @@ export async function removeWorktreeOp(
git,
repoPath,
branchName,
branchHead
branchHead,
capabilities
)
) {
return {}
+54 -41
View File
@@ -61,6 +61,7 @@ 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'
const execFileAsync = promisify(execFile)
const MAX_GIT_BUFFER = 10 * 1024 * 1024
@@ -171,6 +172,7 @@ function execFileWithStdin(
export class GitHandler {
private dispatcher: RelayDispatcher
private readonly gitDiffReadDedupe = new InFlightPromiseDedupe<unknown>()
private readonly gitCapabilities = new GitCapabilityCache()
// Why: configured submodule paths change rarely; an instance-level TTL cache
// avoids re-reading `.gitmodules` on every diff click over SSH, and being
@@ -1223,23 +1225,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 (stdout.split(/\r?\n/).some((line) => line.startsWith('--path-format'))) {
// 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
}
@@ -1279,30 +1287,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>) {
@@ -1310,7 +1321,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>) {
@@ -1319,7 +1332,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)
)
}
}
+59 -5
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: 'usage: git merge-tree <base-tree> <branch1> <branch2>'
})
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)
})
})
+31 -12
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
+53
View File
@@ -0,0 +1,53 @@
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)
})
})
+101
View File
@@ -0,0 +1,101 @@
// 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>>()
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.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.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()
}
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()
settleProbe?.('supported')
return result
} catch (error) {
if (!isUnsupportedError(error)) {
settleProbe?.('unknown')
throw error
}
this.rememberUnsupported(capability)
settleProbe?.('unsupported')
return runFallback()
}
}
}
+18
View File
@@ -0,0 +1,18 @@
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
) || /usage:\s*git merge-tree\s+<base-tree>\s+<branch1>\s+<branch2>/i.test(output)
)
}