diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 2138ba0aa..5363cb56c 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -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 () => { diff --git a/src/main/github/conflict-summary-cache.ts b/src/main/github/conflict-summary-cache.ts new file mode 100644 index 000000000..99a2715ce --- /dev/null +++ b/src/main/github/conflict-summary-cache.ts @@ -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() +const summaryCache = new Map() +const inFlightBaseOidResolves = new Map>() +const inFlightSummaryDerivations = new Map>() + +// 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 +): Promise { + return dedupeInFlight(inFlightBaseOidResolves, key, factory) +} + +export function dedupeSummaryDerivation( + key: string, + factory: () => Promise +): Promise { + return dedupeInFlight(inFlightSummaryDerivations, key, factory) +} + +function dedupeInFlight( + map: Map>, + key: string, + factory: () => Promise +): Promise { + const existing = map.get(key) + if (existing) { + return existing + } + const promise = factory().finally(() => { + map.delete(key) + }) + map.set(key, promise) + return promise +} + +function setBoundedMapEntry(map: Map, 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() +} diff --git a/src/main/github/conflict-summary.test.ts b/src/main/github/conflict-summary.test.ts new file mode 100644 index 000000000..a9b5e9aee --- /dev/null +++ b/src/main/github/conflict-summary.test.ts @@ -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 + +const defaultHandlers: Record = { + 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 = {}): 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((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((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((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((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) + }) +}) diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index ce0c4b361..964455504 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -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() -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 { - 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 { + 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 { + 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 { 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 { - 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) {