perf(source-control): cache PR conflict-summary derivation and throttle base fetch (#7606)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-07-06 22:02:59 -07:00 committed by GitHub
parent e33f31689b
commit 46a67cb2eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 597 additions and 26 deletions

View File

@ -122,7 +122,7 @@ import {
_resetMergeQueueCacheForTests,
__resetTrackedUpstreamBranchCacheForTests
} from './client'
import { __resetPRConflictSummaryGitCapabilityCacheForTests } from './conflict-summary'
import { __resetPRConflictSummaryCachesForTests } from './conflict-summary'
import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership'
describe('checkOrcaStarred', () => {
@ -194,7 +194,7 @@ describe('getPRForBranch', () => {
_resetOwnerRepoCache()
_resetMergeQueueCacheForTests()
__resetTrackedUpstreamBranchCacheForTests()
__resetPRConflictSummaryGitCapabilityCacheForTests()
__resetPRConflictSummaryCachesForTests()
resetMergedPRCommitMembershipCacheForTest()
})
@ -2987,11 +2987,16 @@ describe('getPRForBranch', () => {
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
// Why a second head OID: identical inputs now hit the summary result
// cache outright; a pushed head re-derives and must still skip the
// unsupported --merge-base retry via the capability cache.
const pushedBranchLookup = { ...branchLookup, head: { ref: 'feature/test', sha: 'head-oid-2' } }
const pushedExactLookup = { ...exactLookup, headRefOid: 'head-oid-2' }
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) })
.mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) })
.mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) })
.mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) })
.mockResolvedValueOnce({ stdout: JSON.stringify([pushedBranchLookup]) })
.mockResolvedValueOnce({ stdout: JSON.stringify(pushedExactLookup) })
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
@ -2999,8 +3004,6 @@ describe('getPRForBranch', () => {
.mockResolvedValueOnce({ stdout: '2\n' })
.mockRejectedValueOnce({ stderr: "error: unknown option `merge-base'" })
.mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' })
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({ stdout: '2\n' })
.mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' })
@ -3567,6 +3570,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
_resetMergeQueueCacheForTests()
__resetPRConflictSummaryCachesForTests()
})
it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => {

View File

@ -0,0 +1,150 @@
import type { PRConflictSummary } from '../../shared/types'
// Why 60s: the hottest coordinator cadences that re-derive a CONFLICTING PR
// (10s mergeability-pending, 2.5s manual-pending) previously each ran a
// network fetch; one fetch per base branch per minute matches the 60s minimum
// background refresh cadence, so the tracked base tip is never staler than the
// PR data around it. Manual refresh intentionally shares the window: GitHub's
// own mergeability recompute is async too, and the card self-corrects within
// a minute.
export const CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS = 60_000
// Why bounded: the main process is long-lived; cap the maps so repos and PRs
// that stop refreshing can't accumulate entries forever.
const BASE_OID_CACHE_MAX = 64
const SUMMARY_CACHE_MAX = 128
export type FreshBaseTipResolution =
| { kind: 'resolved'; oid: string }
| { kind: 'fallback-unresolved' }
type CachedBaseTipResolution = {
oid: string | null
resolvedAt: number
}
type CachedSummary = {
value: PRConflictSummary | undefined
// Why: a successful summary is a pure function of two immutable commit OIDs
// and never goes stale; a failed derivation depends on which objects exist
// locally, which the next fetch window can change, so it carries an expiry.
staleAt: number | null
}
const baseOidCache = new Map<string, CachedBaseTipResolution>()
const summaryCache = new Map<string, CachedSummary>()
const inFlightBaseOidResolves = new Map<string, Promise<FreshBaseTipResolution>>()
const inFlightSummaryDerivations = new Map<string, Promise<PRConflictSummary | undefined>>()
// Why: WSL distros have their own git binary, filesystem view, and remote
// access, so cached state must never leak across the host/distro boundary.
export function getConflictSummaryGitRuntimeKey(wslDistro: string | undefined): string {
return wslDistro ? `wsl:${wslDistro}` : 'local:host'
}
// Why JSON: repo paths and git ref names may contain any printable joiner
// character (git allows `|` in branch names), so a delimiter-joined key could
// alias distinct identities onto one cache entry.
export function buildConflictSummaryCacheKey(...parts: string[]): string {
return JSON.stringify(parts)
}
export function readFreshBaseTipResolution(baseKey: string): FreshBaseTipResolution | null {
const entry = baseOidCache.get(baseKey)
if (!entry) {
return null
}
if (Date.now() - entry.resolvedAt >= CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS) {
baseOidCache.delete(baseKey)
return null
}
return entry.oid ? { kind: 'resolved', oid: entry.oid } : { kind: 'fallback-unresolved' }
}
export function storeResolvedBaseTip(baseKey: string, oid: string): void {
setBoundedMapEntry(baseOidCache, baseKey, { oid, resolvedAt: Date.now() }, BASE_OID_CACHE_MAX)
}
export function rememberUnresolvedBaseTip(baseKey: string): void {
setBoundedMapEntry(
baseOidCache,
baseKey,
{ oid: null, resolvedAt: Date.now() },
BASE_OID_CACHE_MAX
)
}
export function readCachedSummary(summaryKey: string): CachedSummary | null {
const entry = summaryCache.get(summaryKey)
if (!entry) {
return null
}
if (entry.staleAt !== null && Date.now() >= entry.staleAt) {
summaryCache.delete(summaryKey)
return null
}
return entry
}
export function storeCachedSummary(summaryKey: string, value: PRConflictSummary | undefined): void {
setBoundedMapEntry(
summaryCache,
summaryKey,
{
value,
staleAt: value === undefined ? Date.now() + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS : null
},
SUMMARY_CACHE_MAX
)
}
export function dedupeBaseOidResolve(
key: string,
factory: () => Promise<FreshBaseTipResolution>
): Promise<FreshBaseTipResolution> {
return dedupeInFlight(inFlightBaseOidResolves, key, factory)
}
export function dedupeSummaryDerivation(
key: string,
factory: () => Promise<PRConflictSummary | undefined>
): Promise<PRConflictSummary | undefined> {
return dedupeInFlight(inFlightSummaryDerivations, key, factory)
}
function dedupeInFlight<T>(
map: Map<string, Promise<T>>,
key: string,
factory: () => Promise<T>
): Promise<T> {
const existing = map.get(key)
if (existing) {
return existing
}
const promise = factory().finally(() => {
map.delete(key)
})
map.set(key, promise)
return promise
}
function setBoundedMapEntry<K, V>(map: Map<K, V>, key: K, value: V, maxEntries: number): void {
if (map.has(key)) {
map.delete(key)
}
map.set(key, value)
while (map.size > maxEntries) {
const oldest = map.keys().next()
if (oldest.done) {
return
}
map.delete(oldest.value)
}
}
export function __resetPRConflictSummaryDerivationCachesForTests(): void {
baseOidCache.clear()
summaryCache.clear()
inFlightBaseOidResolves.clear()
inFlightSummaryDerivations.clear()
}

View File

@ -0,0 +1,331 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Why these tests exist: the conflict-summary derivation used to re-run a
// network `git fetch` plus a four-subprocess chain on every PR refresh tick
// (measured ~490 full derivations in 2.1h on one machine). They pin the
// regression contract by counting subprocess spawns: unchanged inputs must
// cost zero subprocesses, and the base fetch must run at most once per
// throttle window.
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
import { CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS } from './conflict-summary-cache'
import { __resetPRConflictSummaryCachesForTests, getPRConflictSummary } from './conflict-summary'
type GitResult = { stdout: string }
type GitHandler = (argv: string[]) => Promise<GitResult>
const defaultHandlers: Record<string, GitHandler> = {
fetch: async () => ({ stdout: '' }),
'rev-parse': async () => ({ stdout: 'base-tip-1\n' }),
'merge-base': async () => ({ stdout: 'merge-base-1\n' }),
'rev-list': async () => ({ stdout: '3\n' }),
'merge-tree': async () => ({ stdout: 'tree-oid\u0000src/conflict.ts\u0000' })
}
function mockGitDispatch(overrides: Record<string, GitHandler> = {}): void {
gitExecFileAsyncMock.mockImplementation((argv: string[]) => {
const handler = overrides[argv[0]] ?? defaultHandlers[argv[0]]
if (!handler) {
return Promise.reject(new Error(`unexpected git command: ${argv.join(' ')}`))
}
return handler(argv)
})
}
function spawnCount(command?: string): number {
const calls = gitExecFileAsyncMock.mock.calls
if (!command) {
return calls.length
}
return calls.filter(([argv]) => Array.isArray(argv) && argv[0] === command).length
}
const expectedSummary = {
baseRef: 'main',
baseCommit: 'base-ti',
commitsBehind: 3,
files: ['src/conflict.ts']
}
function deriveSummary(headRefOid = 'head-oid-1', wslDistro?: string) {
return getPRConflictSummary(
'/repo-root',
'main',
'github-base-oid',
headRefOid,
wslDistro ? { wslDistro } : {}
)
}
describe('getPRConflictSummary caching', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_750_000_000_000)
gitExecFileAsyncMock.mockReset()
__resetPRConflictSummaryCachesForTests()
})
afterEach(() => {
vi.useRealTimers()
})
it('runs exactly one fetch and one derivation chain, then zero subprocesses on repeat calls', async () => {
mockGitDispatch()
const first = await deriveSummary()
expect(first).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(1)
expect(spawnCount()).toBe(5)
const second = await deriveSummary()
const third = await deriveSummary()
expect(second).toEqual(expectedSummary)
expect(third).toEqual(expectedSummary)
expect(spawnCount()).toBe(5)
})
it('re-derives when headRefOid changes without re-fetching inside the throttle window', async () => {
mockGitDispatch()
await deriveSummary('head-oid-1')
const afterPush = await deriveSummary('head-oid-2')
expect(afterPush).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(1)
expect(spawnCount('rev-parse')).toBe(1)
expect(spawnCount('merge-base')).toBe(2)
expect(spawnCount('rev-list')).toBe(2)
expect(spawnCount('merge-tree')).toBe(2)
})
it('fetches again after the throttle window and skips derivation when the base tip is unchanged', async () => {
mockGitDispatch()
await deriveSummary()
vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000)
const afterExpiry = await deriveSummary()
expect(afterExpiry).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(2)
expect(spawnCount('rev-parse')).toBe(2)
// Why: same (headRefOid, latestBaseOid) pair — the summary cache still hits.
expect(spawnCount('merge-base')).toBe(1)
expect(spawnCount('rev-list')).toBe(1)
expect(spawnCount('merge-tree')).toBe(1)
})
it('re-derives after the throttle window when the base tip moved', async () => {
mockGitDispatch()
await deriveSummary()
vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000)
mockGitDispatch({
'rev-parse': async () => ({ stdout: 'base-tip-2\n' }),
'rev-list': async () => ({ stdout: '5\n' })
})
const afterBaseMove = await deriveSummary()
expect(afterBaseMove).toEqual({ ...expectedSummary, baseCommit: 'base-ti', commitsBehind: 5 })
expect(spawnCount('fetch')).toBe(2)
expect(spawnCount('merge-base')).toBe(2)
expect(spawnCount('rev-list')).toBe(2)
expect(spawnCount('merge-tree')).toBe(2)
})
it('dedupes concurrent identical calls onto one in-flight subprocess chain', async () => {
let releaseFetch: ((value: GitResult) => void) | undefined
mockGitDispatch({
fetch: () =>
new Promise<GitResult>((resolve) => {
releaseFetch = resolve
})
})
const firstCall = deriveSummary()
const secondCall = deriveSummary()
releaseFetch?.({ stdout: '' })
const [first, second] = await Promise.all([firstCall, secondCall])
expect(first).toEqual(expectedSummary)
expect(second).toEqual(expectedSummary)
expect(spawnCount()).toBe(5)
})
it('dedupes overlapping derivations after resolving the same live base tip', async () => {
let releaseFetch: ((value: GitResult) => void) | undefined
mockGitDispatch({
fetch: () =>
new Promise<GitResult>((resolve) => {
releaseFetch = resolve
})
})
const backgroundRefresh = getPRConflictSummary(
'/repo-root',
'main',
'older-github-base-oid',
'head-oid-1',
{}
)
const manualRefresh = getPRConflictSummary(
'/repo-root',
'main',
'newer-github-base-oid',
'head-oid-1',
{}
)
releaseFetch?.({ stdout: '' })
const [backgroundSummary, manualSummary] = await Promise.all([backgroundRefresh, manualRefresh])
expect(backgroundSummary).toEqual(expectedSummary)
expect(manualSummary).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(1)
expect(spawnCount('rev-parse')).toBe(1)
expect(spawnCount('merge-base')).toBe(1)
expect(spawnCount('rev-list')).toBe(1)
expect(spawnCount('merge-tree')).toBe(1)
})
it('shares one base fetch across concurrent PRs on the same base branch', async () => {
let releaseFetch: ((value: GitResult) => void) | undefined
mockGitDispatch({
fetch: () =>
new Promise<GitResult>((resolve) => {
releaseFetch = resolve
})
})
const prA = deriveSummary('head-oid-a')
const prB = deriveSummary('head-oid-b')
releaseFetch?.({ stdout: '' })
const [summaryA, summaryB] = await Promise.all([prA, prB])
expect(summaryA).toEqual(expectedSummary)
expect(summaryB).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(1)
expect(spawnCount('rev-parse')).toBe(1)
expect(spawnCount('merge-base')).toBe(2)
})
it('keeps concurrent fallback base OIDs isolated per PR when the base ref is unavailable', async () => {
let releaseFetch: ((value: GitResult) => void) | undefined
mockGitDispatch({
fetch: () =>
new Promise<GitResult>((resolve) => {
releaseFetch = resolve
}),
'rev-parse': () => Promise.reject(new Error('missing remote-tracking ref')),
'rev-list': async (argv) => ({
stdout: argv[2]?.includes('bbbb2222-base') ? '7\n' : '4\n'
}),
'merge-tree': async (argv) => ({
stdout: argv.includes('bbbb2222-base')
? 'tree-oid\u0000src/b.ts\u0000'
: 'tree-oid\u0000src/a.ts\u0000'
})
})
const prA = getPRConflictSummary('/repo-root', 'main', 'aaaa1111-base', 'head-oid-a', {})
const prB = getPRConflictSummary('/repo-root', 'main', 'bbbb2222-base', 'head-oid-b', {})
releaseFetch?.({ stdout: '' })
const [summaryA, summaryB] = await Promise.all([prA, prB])
expect(summaryA).toEqual({
baseRef: 'main',
baseCommit: 'aaaa111',
commitsBehind: 4,
files: ['src/a.ts']
})
expect(summaryB).toEqual({
baseRef: 'main',
baseCommit: 'bbbb222',
commitsBehind: 7,
files: ['src/b.ts']
})
expect(spawnCount('fetch')).toBe(1)
expect(spawnCount('merge-base')).toBe(2)
})
it('falls back to the local remote-tracking ref when fetch fails', async () => {
mockGitDispatch({
fetch: () => Promise.reject(new Error('offline'))
})
const summary = await deriveSummary()
expect(summary).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(1)
// Why: a failed fetch attempt must also be throttled — offline machines
// were the worst case, paying the 10s fetch timeout on every tick.
const repeat = await deriveSummary()
expect(repeat).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(1)
expect(spawnCount()).toBe(5)
})
it("falls back to GitHub's baseRefOid when fetch and remote-tracking refs are unavailable", async () => {
mockGitDispatch({
fetch: () => Promise.reject(new Error('offline')),
'rev-parse': () => Promise.reject(new Error('unknown revision'))
})
const summary = await deriveSummary()
expect(summary).toEqual({ ...expectedSummary, baseCommit: 'github-' })
expect(
gitExecFileAsyncMock.mock.calls.some(
([argv]) => argv[0] === 'merge-base' && argv.includes('github-base-oid')
)
).toBe(true)
})
it('keeps WSL-distro derivations isolated from host derivations', async () => {
mockGitDispatch()
await deriveSummary()
await deriveSummary('head-oid-1', 'Ubuntu')
expect(spawnCount('fetch')).toBe(2)
expect(
gitExecFileAsyncMock.mock.calls.filter(
([argv, options]) =>
argv[0] === 'fetch' && (options as { wslDistro?: string })?.wslDistro === 'Ubuntu'
)
).toHaveLength(1)
})
it('keeps identities distinct when paths or ref names contain a joiner character', async () => {
mockGitDispatch()
// Why: these two calls alias under naive pipe-joined keys — the segment
// boundary shifts between repoPath and baseRefName.
await getPRConflictSummary('/repo|x', 'main', 'github-base-oid', 'head-oid-1', {})
await getPRConflictSummary('/repo', 'x|main', 'github-base-oid', 'head-oid-1', {})
expect(spawnCount('fetch')).toBe(2)
expect(spawnCount('merge-base')).toBe(2)
})
it('caches failed derivations only until the throttle window expires', async () => {
mockGitDispatch({
'merge-base': () => Promise.reject(new Error('bad object'))
})
await expect(deriveSummary()).resolves.toBeUndefined()
const failureSpawns = spawnCount()
// Within the window the failure is negative-cached: zero subprocesses.
await expect(deriveSummary()).resolves.toBeUndefined()
expect(spawnCount()).toBe(failureSpawns)
// After expiry the derivation retries (a fetch may have brought objects in).
vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000)
mockGitDispatch()
await expect(deriveSummary()).resolves.toEqual(expectedSummary)
expect(spawnCount('merge-base')).toBe(2)
})
})

View File

@ -1,5 +1,17 @@
import type { PRConflictSummary } from '../../shared/types'
import { gitExecFileAsync } from '../git/runner'
import {
__resetPRConflictSummaryDerivationCachesForTests,
buildConflictSummaryCacheKey,
dedupeBaseOidResolve,
dedupeSummaryDerivation,
getConflictSummaryGitRuntimeKey,
readCachedSummary,
readFreshBaseTipResolution,
rememberUnresolvedBaseTip,
storeResolvedBaseTip,
storeCachedSummary
} from './conflict-summary-cache'
type LocalGitExecOptions = {
wslDistro?: string
@ -7,8 +19,9 @@ type LocalGitExecOptions = {
const mergeTreeMergeBaseUnsupportedRuntimes = new Set<string>()
export function __resetPRConflictSummaryGitCapabilityCacheForTests(): void {
export function __resetPRConflictSummaryCachesForTests(): void {
mergeTreeMergeBaseUnsupportedRuntimes.clear()
__resetPRConflictSummaryDerivationCachesForTests()
}
export async function getPRConflictSummary(
@ -18,45 +31,122 @@ export async function getPRConflictSummary(
headRefOid: string,
localGitOptions: LocalGitExecOptions = {}
): Promise<PRConflictSummary | undefined> {
try {
// Why: the renderer only needs a read-only merge-conflict snapshot. We
// derive it from local git state so the PR card can show GitHub-style
// detail without spending additional gh API calls on every refresh. We use
// GitHub's head OID directly because the registered repo path may not have
// a matching local branch name for the PR head. For the base side, prefer a
// freshly-fetched remote-tracking ref so Orca matches GitHub's portal,
// which compares against the latest base branch tip rather than the PR's
// older pinned baseRefOid snapshot.
const latestBaseOid = await resolveLatestBaseOid(
// Why: the renderer only needs a read-only merge-conflict snapshot. We
// derive it from local git state so the PR card can show GitHub-style
// detail without spending additional gh API calls on every refresh. We use
// GitHub's head OID directly because the registered repo path may not have
// a matching local branch name for the PR head. For the base side, prefer a
// freshly-fetched remote-tracking ref so Orca matches GitHub's portal,
// which compares against the latest base branch tip rather than the PR's
// older pinned baseRefOid snapshot.
const latestBaseOid = await resolveLatestBaseOidThrottled(
repoPath,
baseRefName,
baseRefOid,
localGitOptions
)
// Why: the summary is a pure function of the two commit OIDs, so a key hit
// can skip the whole merge-base/rev-list/merge-tree subprocess chain.
const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
const summaryKey = buildConflictSummaryCacheKey(
runtimeKey,
repoPath,
baseRefName,
headRefOid,
latestBaseOid
)
const cached = readCachedSummary(summaryKey)
if (cached) {
return cached.value
}
// Why: different GitHub reads can report different pinned baseRefOid values
// while still resolving to the same live base tip; dedupe the expensive
// local derivation on the actual summary identity.
return dedupeSummaryDerivation(summaryKey, () =>
derivePRConflictSummary(
repoPath,
baseRefName,
baseRefOid,
headRefOid,
latestBaseOid,
summaryKey,
localGitOptions
)
)
}
async function derivePRConflictSummary(
repoPath: string,
baseRefName: string,
headRefOid: string,
latestBaseOid: string,
summaryKey: string,
localGitOptions: LocalGitExecOptions
): Promise<PRConflictSummary | undefined> {
const cached = readCachedSummary(summaryKey)
if (cached) {
return cached.value
}
try {
const mergeBase = await resolveMergeBase(repoPath, headRefOid, latestBaseOid, localGitOptions)
const [commitsBehind, files] = await Promise.all([
countCommits(repoPath, `${headRefOid}..${latestBaseOid}`, localGitOptions),
loadConflictingFiles(repoPath, mergeBase, headRefOid, latestBaseOid, localGitOptions)
])
return {
const summary = {
baseRef: baseRefName,
baseCommit: latestBaseOid.slice(0, 7),
commitsBehind,
files,
...(files.length === 0 ? { localMergeState: 'clean' as const } : {})
}
storeCachedSummary(summaryKey, summary)
return summary
} catch {
storeCachedSummary(summaryKey, undefined)
return undefined
}
}
async function resolveLatestBaseOid(
async function resolveLatestBaseOidThrottled(
repoPath: string,
baseRefName: string,
fallbackBaseOid: string,
localGitOptions: LocalGitExecOptions
): Promise<string> {
const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
const baseKey = buildConflictSummaryCacheKey(runtimeKey, repoPath, baseRefName)
const cachedResolution = readFreshBaseTipResolution(baseKey)
if (cachedResolution) {
return cachedResolution.kind === 'resolved' ? cachedResolution.oid : fallbackBaseOid
}
return dedupeBaseOidResolve(baseKey, async () => {
// Why re-check inside the dedupe slot: a sibling caller may have finished
// resolving between our cache read and this factory starting.
const freshResolution = readFreshBaseTipResolution(baseKey)
if (freshResolution) {
return freshResolution
}
const oid = await resolveLatestBaseOid(repoPath, baseRefName, localGitOptions)
if (oid) {
storeResolvedBaseTip(baseKey, oid)
return { kind: 'resolved', oid }
}
// Why cache the unresolved probe, not the caller fallback: the fetch
// attempt is branch-wide expensive work, but GitHub's baseRefOid is
// PR-specific and must not leak to sibling PRs on the same base branch.
rememberUnresolvedBaseTip(baseKey)
return { kind: 'fallback-unresolved' }
}).then((resolution) => (resolution.kind === 'resolved' ? resolution.oid : fallbackBaseOid))
}
async function resolveLatestBaseOid(
repoPath: string,
baseRefName: string,
localGitOptions: LocalGitExecOptions
): Promise<string | null> {
const remoteName = 'origin'
try {
@ -88,7 +178,7 @@ async function resolveLatestBaseOid(
}
}
return fallbackBaseOid
return null
}
async function resolveMergeBase(
@ -123,7 +213,7 @@ async function loadConflictingFiles(
baseOid: string,
localGitOptions: LocalGitExecOptions
): Promise<string[]> {
const capabilityKey = getMergeTreeCapabilityKey(localGitOptions)
const capabilityKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
const modernArgs = [
'merge-tree',
'--write-tree',
@ -193,10 +283,6 @@ async function loadConflictingFilesWithLegacyMergeTree(
}
}
function getMergeTreeCapabilityKey(localGitOptions: LocalGitExecOptions): string {
return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'local:host'
}
function parseMergeTreeNameOnlyOutput(stdout: string): string[] {
const entries = stdout.split('\0').filter(Boolean)
if (entries.length === 0) {