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
38 changed files with 1467 additions and 382 deletions
+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'), {
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)
})
})
+48 -39
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
)
}