perf(git): cache branch-compare merge-base and ahead/behind by commit pair (#39699)

* refactor(git): share common-dir resolution and stat stamps from worktree-diff-stamp

* perf(git): cache branch-compare merge-base and ahead/behind by commit OID pair

* fix(git): only store a commit-pair answer when its scope survived the walk

* refactor(git): type commit-pair stores per answer kind and pin test mtimes

* fix(git): skip the commit-pair cache whenever replace refs, grafts or relocating env could apply
This commit is contained in:
Neil
2026-11-07 19:43:01 -08:00
committed by GitHub
parent 9df0b13901
commit d2506d05d6
5 changed files with 293 additions and 8 deletions
+22 -2
View File
@@ -7,6 +7,12 @@ import { resolveWorktreeAddBaseRef } from '../../../shared/worktree/base-ref'
import type { GitRuntimeOptions } from '../git-runtime-options'
import { resolveWorktreeBaseCommitOid } from '../worktrees/creation/worktree-base-ref-probe'
import { loadBranchChanges } from './branch-change-entries'
import {
commitPairDivergences,
commitPairMergeBases,
readCommitPairScope,
rememberCommitPair
} from './commit-pair-cache'
import {
countCompareDivergence,
resolveCompareRef,
@@ -29,6 +35,8 @@ export async function getBranchCompare(
status: 'loading'
}
// Never rejects; read alongside ref resolution so cache hits add no latency.
const pairScopePromise = readCommitPairScope(worktreePath, options)
// Reuse peeled commit OIDs only for branch refs; remote-tracking refs may hold annotated tags.
const reusableProbedOidByRef = new Map<string, string>()
const { compareRef, headOidResult, baseOidResult } = await readBranchCompareHead({
@@ -83,9 +91,19 @@ export async function getBranchCompare(
return { summary, entries: [] }
}
// Polls re-resolve only the refs; history walks rerun when a tip moves.
const pairScope = await pairScopePromise
const rereadPairScope = () => readCommitPairScope(worktreePath, options)
let mergeBase = ''
try {
mergeBase = await resolveMergeBase(worktreePath, baseOid, headOid, options)
mergeBase = await rememberCommitPair(
commitPairMergeBases,
pairScope,
rereadPairScope,
baseOid,
headOid,
() => resolveMergeBase(worktreePath, baseOid, headOid, options)
)
summary.mergeBase = mergeBase
} catch {
summary.status = 'no-merge-base'
@@ -105,7 +123,9 @@ export async function getBranchCompare(
const [entries, divergence] = await Promise.all([
loadBranchChanges(worktreePath, mergeBase, headOid, options),
countCompareDivergence(worktreePath, baseOid, headOid, options)
rememberCommitPair(commitPairDivergences, pairScope, rereadPairScope, baseOid, headOid, () =>
countCompareDivergence(worktreePath, baseOid, headOid, options)
)
])
summary.changedFiles = entries.length
summary.commitsAhead = divergence.ahead
@@ -0,0 +1,145 @@
import { readFile, stat } from 'node:fs/promises'
import * as path from 'node:path'
import { resolveWorktreeHostPath } from '../../../shared/git/metadata/git-metadata-path'
import type { GitRuntimeOptions } from '../git-runtime-options'
import { resolveGitCommonDir, resolveGitDir } from './resolve-git-dir'
import { canProveUnchangedByStamp, readFileStampComponent } from './worktree-diff-stamp'
/** Opaque object-store identity under which a commit pair's graph answers stay valid. */
export type CommitPairScope = string
/** One bounded LRU per answer kind keeps values typed without casts. */
export type CommitPairStore<T> = Map<string, T>
const MAX_ENTRIES_PER_STORE = 128
export const commitPairMergeBases: CommitPairStore<string> = new Map()
export const commitPairDivergences: CommitPairStore<{ ahead: number; behind: number }> = new Map()
// These move replace refs, grafts or the object store away from the common dir this scope stats.
const GRAPH_RELOCATING_ENV = [
'GIT_REPLACE_REF_BASE',
'GIT_GRAFT_FILE',
'GIT_DIR',
'GIT_COMMON_DIR',
'GIT_OBJECT_DIRECTORY',
'GIT_SHALLOW_FILE'
]
// Replace refs and grafts are rare and their effect depends on per-worktree config, so their presence disables caching.
const GRAPH_REWRITE_PATHS = [path.join('refs', 'replace'), path.join('info', 'grafts'), 'reftable']
// Bounds the one-off packed-refs scan per change; larger ref stores just skip caching.
const MAX_PACKED_REFS_SCAN_BYTES = 8 * 1024 * 1024
const MAX_SCANNED_PACKED_REFS = 64
/** Common dir -> packed-refs stamp last proven to hold no replace refs. */
const packedRefsWithoutReplaceRefs = new Map<string, string>()
/** Read without spawning Git; null when replace refs or grafts could apply, or inputs are not provably stable. */
export async function readCommitPairScope(
worktreePath: string,
options: Pick<GitRuntimeOptions, 'wslDistro'> = {}
): Promise<CommitPairScope | null> {
const capturedAtMs = Date.now()
if (GRAPH_RELOCATING_ENV.some((name) => process.env[name])) {
return null
}
try {
const hostWorktreePath = resolveWorktreeHostPath(worktreePath, options)
if (!hostWorktreePath) {
return null
}
const commonDir = await resolveGitCommonDir(await resolveGitDir(hostWorktreePath, options))
const [objects, rewritePathsPresent, shallow, packedRefs] = await Promise.all([
stat(path.join(commonDir, 'objects')),
Promise.all(GRAPH_REWRITE_PATHS.map((input) => pathExists(path.join(commonDir, input)))),
readFileStampComponent(path.join(commonDir, 'shallow')),
readFileStampComponent(path.join(commonDir, 'packed-refs'))
])
if (!objects.isDirectory() || rewritePathsPresent.some(Boolean)) {
return null
}
const value = JSON.stringify([commonDir, shallow.text])
const newestMtimeMs = Math.max(shallow.mtimeMs, packedRefs.mtimeMs)
// A rewrite inside the mtime bucket could leave the stamp unchanged.
if (!canProveUnchangedByStamp({ value, newestMtimeMs, capturedAtMs })) {
return null
}
if (
Number.isFinite(packedRefs.mtimeMs) &&
!(await isPackedRefsFreeOfReplaceRefs(commonDir, packedRefs.text))
) {
return null
}
return value
} catch {
return null
}
}
async function isPackedRefsFreeOfReplaceRefs(commonDir: string, stamp: string): Promise<boolean> {
if (packedRefsWithoutReplaceRefs.get(commonDir) === stamp) {
return true
}
const packedRefsPath = path.join(commonDir, 'packed-refs')
if ((await stat(packedRefsPath)).size > MAX_PACKED_REFS_SCAN_BYTES) {
return false
}
// Lines are `<oid> <refname>`; a false positive only skips caching.
if ((await readFile(packedRefsPath, 'utf-8')).includes(' refs/replace/')) {
return false
}
packedRefsWithoutReplaceRefs.delete(commonDir)
packedRefsWithoutReplaceRefs.set(commonDir, stamp)
if (packedRefsWithoutReplaceRefs.size > MAX_SCANNED_PACKED_REFS) {
const oldest = packedRefsWithoutReplaceRefs.keys().next()
if (!oldest.done) {
packedRefsWithoutReplaceRefs.delete(oldest.value)
}
}
return true
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await stat(filePath)
return true
} catch (error) {
const code = (error as NodeJS.ErrnoException | null)?.code
if (code === 'ENOENT' || code === 'ENOTDIR') {
return false
}
throw error
}
}
/** Reuse a successful answer for this exact commit pair; failures and aborts are never stored. */
export async function rememberCommitPair<T>(
entries: CommitPairStore<T>,
scope: CommitPairScope | null,
rereadScope: () => Promise<CommitPairScope | null>,
baseOid: string,
headOid: string,
compute: () => Promise<T>
): Promise<T> {
if (scope === null) {
return compute()
}
const key = JSON.stringify([scope, baseOid, headOid])
const cached = entries.get(key)
if (cached !== undefined) {
entries.delete(key)
entries.set(key, cached)
return cached
}
const value = await compute()
// A history rewrite during the walk would file its answer under the pre-rewrite scope.
if ((await rereadScope()) !== scope) {
return value
}
entries.set(key, value)
if (entries.size > MAX_ENTRIES_PER_STORE) {
const oldest = entries.keys().next()
if (!oldest.done) {
entries.delete(oldest.value)
}
}
return value
}
@@ -24,3 +24,17 @@ export async function resolveGitDir(
return dotGitPath
}
/** Linked worktrees name their shared object store in `commondir`; a main gitdir is its own. */
export async function resolveGitCommonDir(gitDir: string): Promise<string> {
try {
const entry = (await readFile(path.join(gitDir, 'commondir'), 'utf-8')).trim()
return entry ? path.resolve(gitDir, entry) : gitDir
} catch (error) {
const code = (error as NodeJS.ErrnoException | null)?.code
if (code === 'ENOENT' || code === 'ENOTDIR') {
return gitDir
}
throw error
}
}
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -88,6 +88,113 @@ describe('getBranchCompare real refs', () => {
})
})
describe('commit-pair cache under history rewrites', () => {
// Fixed old mtimes put every rewrite past the racy-write margin, so only the scope can force a recount.
const aged = new Date('2020-01-01T00:00:00Z')
/** root <- a <- b <- tip on `feature`, with `base` at root (tip is 3 ahead). */
async function createChainRepo(): Promise<{ repo: string; oids: Record<string, string> }> {
// Canonical path so linked worktrees and the main checkout resolve one common dir.
const repo = await realpath(
await mkdtemp(path.join(tmpdir(), 'orca-branch-compare-rewrite-'))
)
tempRoots.push(repo)
git(repo, ['init', '-q', '-b', 'feature'])
git(repo, ['config', 'user.email', 'test@example.com'])
git(repo, ['config', 'user.name', 'Test User'])
git(repo, ['config', 'commit.gpgSign', 'false'])
const oids: Record<string, string> = {}
for (const name of ['root', 'a', 'b', 'tip']) {
git(repo, ['commit', '--allow-empty', '-qm', name])
oids[name] = git(repo, ['rev-parse', 'HEAD'])
if (name === 'root') {
git(repo, ['branch', 'base'])
}
}
return { repo, oids }
}
async function ageReplaceRefs(repo: string): Promise<void> {
const replaceDir = path.join(repo, '.git', 'refs', 'replace')
for (const entry of await readdir(replaceDir)) {
await utimes(path.join(replaceDir, entry), aged, aged)
}
await utimes(replaceDir, aged, aged)
}
async function commitsAhead(worktree: string): Promise<number | undefined> {
return (await getBranchCompare(worktree, 'base')).summary.commitsAhead
}
it('recounts after a replace ref is added', async () => {
const { repo, oids } = await createChainRepo()
expect(await commitsAhead(repo)).toBe(3)
git(repo, ['replace', '--graft', oids.tip, oids.root])
await ageReplaceRefs(repo)
expect(await commitsAhead(repo)).toBe(1)
})
it('recounts after an existing loose replace ref is edited in place', async () => {
const { repo, oids } = await createChainRepo()
git(repo, ['replace', '--graft', oids.tip, oids.a])
const replaceRef = path.join(repo, '.git', 'refs', 'replace', oids.tip)
const tipOntoA = await readFile(replaceRef, 'utf8')
git(repo, ['replace', '--force', '--graft', oids.tip, oids.root])
await ageReplaceRefs(repo)
expect(await commitsAhead(repo)).toBe(1)
await writeFile(replaceRef, tipOntoA)
await utimes(path.dirname(replaceRef), aged, aged)
expect(await commitsAhead(repo)).toBe(2)
})
it('recounts after a replace ref is packed away from refs/replace', async () => {
const { repo, oids } = await createChainRepo()
expect(await commitsAhead(repo)).toBe(3)
git(repo, ['replace', '--graft', oids.tip, oids.root])
git(repo, ['pack-refs', '--all'])
await rm(path.join(repo, '.git', 'refs', 'replace'), { recursive: true, force: true })
await utimes(path.join(repo, '.git', 'packed-refs'), aged, aged)
expect(await commitsAhead(repo)).toBe(1)
})
it('keeps per-worktree core.useReplaceRefs answers apart', async () => {
const { repo, oids } = await createChainRepo()
git(repo, ['replace', '--graft', oids.tip, oids.root])
const linked = path.join(path.dirname(repo), `${path.basename(repo)}-linked`)
tempRoots.push(linked)
git(repo, ['worktree', 'add', '-q', '--detach', linked, oids.tip])
git(repo, ['config', 'extensions.worktreeConfig', 'true'])
git(linked, ['config', '--worktree', 'core.useReplaceRefs', 'false'])
await ageReplaceRefs(repo)
expect(await commitsAhead(repo)).toBe(1)
expect(await commitsAhead(linked)).toBe(3)
})
it('honors a custom GIT_REPLACE_REF_BASE namespace', async () => {
const { repo, oids } = await createChainRepo()
const previous = process.env.GIT_REPLACE_REF_BASE
process.env.GIT_REPLACE_REF_BASE = 'refs/custom-replacements/'
try {
expect(await commitsAhead(repo)).toBe(3)
git(repo, ['replace', '--graft', oids.tip, oids.root])
expect(await commitsAhead(repo)).toBe(1)
} finally {
if (previous === undefined) {
delete process.env.GIT_REPLACE_REF_BASE
} else {
process.env.GIT_REPLACE_REF_BASE = previous
}
}
})
})
it('preserves the raw oid of a remote-tracking ref that stores an annotated tag', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-compare-ref-'))
tempRoots.push(root)
@@ -2,7 +2,7 @@ import { readFile, stat } from 'node:fs/promises'
import * as path from 'node:path'
import { resolveWorktreeHostPath } from '../../../shared/git/metadata/git-metadata-path'
import type { GitRuntimeOptions } from '../git-runtime-options'
import { resolveGitDir } from './resolve-git-dir'
import { resolveGitCommonDir, resolveGitDir } from './resolve-git-dir'
/** Stamp HEAD, index, routing metadata and working-file inputs without Git; null forbids caching. */
export type WorktreeDiffStamp = {
@@ -77,14 +77,13 @@ export async function readWorktreeDiffStamp(
/** Read loose ref content to detect commits without HEAD changes; packed/reftable/unborn tips use file stamps. */
async function readHeadComponent(gitDir: string): Promise<StampComponent | null> {
const [head, commonDirEntry] = await Promise.all([
const [head, commonDir] = await Promise.all([
readTrimmedFile(path.join(gitDir, 'HEAD')),
readTrimmedFile(path.join(gitDir, 'commondir'))
resolveGitCommonDir(gitDir)
])
if (!head) {
return null
}
const commonDir = commonDirEntry ? path.resolve(gitDir, commonDirEntry) : gitDir
const refName = head.match(/^ref:\s*(.+?)\s*$/)?.[1]
if (!refName || !isSafeRefName(refName)) {
// Detached HEAD already holds the object id; an unrecognized HEAD is covered by its own text.
@@ -136,7 +135,7 @@ async function readTrimmedFile(filePath: string): Promise<string | null> {
}
}
async function readFileStampComponent(filePath: string): Promise<StampComponent> {
export async function readFileStampComponent(filePath: string): Promise<StampComponent> {
try {
const stats = await stat(filePath)
return { text: `${requireMtimeMs(stats.mtimeMs)}:${stats.size}`, mtimeMs: stats.mtimeMs }