diff --git a/src/main/git/source-control/branch-compare.ts b/src/main/git/source-control/branch-compare.ts index 9bbe9cd6a8f..b7a6b4d69b5 100644 --- a/src/main/git/source-control/branch-compare.ts +++ b/src/main/git/source-control/branch-compare.ts @@ -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() 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 diff --git a/src/main/git/source-control/commit-pair-cache.ts b/src/main/git/source-control/commit-pair-cache.ts new file mode 100644 index 00000000000..c74f732d841 --- /dev/null +++ b/src/main/git/source-control/commit-pair-cache.ts @@ -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 = Map + +const MAX_ENTRIES_PER_STORE = 128 +export const commitPairMergeBases: CommitPairStore = 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() + +/** 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 = {} +): Promise { + 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 { + 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 ` `; 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 { + 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( + entries: CommitPairStore, + scope: CommitPairScope | null, + rereadScope: () => Promise, + baseOid: string, + headOid: string, + compute: () => Promise +): Promise { + 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 +} diff --git a/src/main/git/source-control/resolve-git-dir.ts b/src/main/git/source-control/resolve-git-dir.ts index 8c529c44fc0..70554e42123 100644 --- a/src/main/git/source-control/resolve-git-dir.ts +++ b/src/main/git/source-control/resolve-git-dir.ts @@ -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 { + 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 + } +} diff --git a/src/main/git/source-control/status-branch-compare-real-ref.test.ts b/src/main/git/source-control/status-branch-compare-real-ref.test.ts index c5e865a9bfa..a5f40187594 100644 --- a/src/main/git/source-control/status-branch-compare-real-ref.test.ts +++ b/src/main/git/source-control/status-branch-compare-real-ref.test.ts @@ -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 }> { + // 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 = {} + 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 { + 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 { + 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) diff --git a/src/main/git/source-control/worktree-diff-stamp.ts b/src/main/git/source-control/worktree-diff-stamp.ts index 8de1a246e5b..42bb2f3fc0a 100644 --- a/src/main/git/source-control/worktree-diff-stamp.ts +++ b/src/main/git/source-control/worktree-diff-stamp.ts @@ -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 { - 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 { } } -async function readFileStampComponent(filePath: string): Promise { +export async function readFileStampComponent(filePath: string): Promise { try { const stats = await stat(filePath) return { text: `${requireMtimeMs(stats.mtimeMs)}:${stats.size}`, mtimeMs: stats.mtimeMs }