mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user