Files
orca/src/main/git/worktree-sparse-checkout.test.ts
T
Neil 84584b61d0 perf(git): cache sparse-checkout annotation on worktree listing (#17859)
* perf(git): cache sparse-checkout annotation on worktree listing

`git worktree list` never reports sparse-checkout state, so every listing paid a
per-worktree fs.stat + config read to detect it -- measured at ~9x the cost of
the `git worktree list` call it decorates on a 1000-worktree repo. Cache the
result per worktree path, invalidated by the existing worktree-change
invalidator registry plus explicit remove/move hooks, with a 5-minute
reconcile window bounding the one unwitnessed edge case (external
`git sparse-checkout` toggle with extensions.worktreeConfig off), matching the
precedent already accepted in readRepoWorktreeAdminFingerprint.

* perf(git): normalize/scope sparse-checkout cache keys, add SWR

Address independent-review follow-ups on the sparse-checkout annotation
cache (#17859):

- Extract canonicalWorktreePath() from areWorktreePathsEqual and key/invalidate
  the cache through it on both read and write, closing the disclosed
  path-spelling P2 outright instead of leaving it as a residual risk.
- Scope cache entries and clears by repo path (derived from the invalidator
  registry's repoId via a store lookup, falling back to a full clear when the
  repo can't be resolved), so churn in one repo no longer evicts a sibling
  repo's warm cache.
- Replace the hard 5-minute cutoff with stale-while-revalidate: past the
  window, callers get the cached value immediately while a deduplicated
  background probe corrects it and, on a flip, drives the existing
  worktrees-changed notification -- collapsing visible staleness from the
  full window to one refresh cycle at zero added listing latency.

Also corrects a stale claim in the original PR description: newer Git does
emit a `sparse` porcelain line (which annotateSparseCheckoutStatus already
skips), but Orca's Git 2.25 compatibility baseline predates it, so the
fallback detection this caches remains necessary.

* fix(git): stop background sparse-checkout revalidation resurrecting invalidated entries

Readiness-loop finding: a stale-while-revalidate probe in flight when a
worktree is removed/moved (or a repo's cache is cleared) would still write
its result back afterward, resurrecting an entry that was deliberately
dropped. Guard the write with a presence check so an invalidated key stays
absent until the next real read.

* fix(git): identity-check the sparse-checkout SWR write-back guard

The has()/presence guard from the previous commit only proved some
entry existed at the key, not that it was the one this revalidation
started from. A worktree removed and re-created at the same path while
a background re-detect was in flight would repopulate the key with a
fresh cold read, and the stale in-flight result would then overwrite
it -- exactly the race greptile (P1) and pullfrog both flagged as
still open. Compare the map's current entry by reference to the entry
captured when the revalidation began; a mismatch means something else
(invalidate, clear, or a fresh cold read) replaced it, and the stale
result must not be written back.

Added a regression test that fails against the old has() guard and
passes with the identity check: invalidate and repopulate the key with
a different value mid-flight, then let the stale revalidation settle
and assert the fresh value survives.
2026-09-01 03:44:50 -07:00

240 lines
9.9 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import { mkdtemp, mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { listWorktrees, parseCoreSparseCheckoutFlag, removeWorktree } from './worktree'
const tempRoots: string[] = []
function git(cwd: string, args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
}
async function createRepoWithTwoDirs(): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), 'orca-sparse-checkout-'))
tempRoots.push(root)
const repoPath = path.join(root, 'repo')
execFileSync('git', ['init', '--quiet', repoPath])
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
git(repoPath, ['config', 'user.email', 'test@example.com'])
git(repoPath, ['config', 'user.name', 'Test User'])
await mkdir(path.join(repoPath, 'keep'), { recursive: true })
await writeFile(path.join(repoPath, 'keep', 'file.txt'), 'keep\n')
await mkdir(path.join(repoPath, 'drop'), { recursive: true })
await writeFile(path.join(repoPath, 'drop', 'file.txt'), 'drop\n')
git(repoPath, ['add', '-A'])
git(repoPath, ['commit', '--quiet', '-m', 'initial'])
return realpath(repoPath)
}
function mainWorktree(worktrees: Awaited<ReturnType<typeof listWorktrees>>) {
const found = worktrees.find((worktree) => worktree.isMainWorktree)
if (!found) {
throw new Error('expected a main worktree in the listing')
}
return found
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('sparse-checkout detection', () => {
it.skipIf(process.platform === 'win32')(
'reports isSparse while sparse checkout is enabled',
async () => {
const repoPath = await createRepoWithTwoDirs()
git(repoPath, ['sparse-checkout', 'set', 'keep'])
expect(mainWorktree(await listWorktrees(repoPath)).isSparse).toBe(true)
}
)
it.skipIf(process.platform === 'win32')(
'does not report isSparse after disable leaves the pattern file behind',
async () => {
const repoPath = await createRepoWithTwoDirs()
git(repoPath, ['sparse-checkout', 'set', 'keep'])
git(repoPath, ['sparse-checkout', 'disable'])
// Regression guard: `git sparse-checkout disable` restores the full
// working tree but deliberately keeps <gitdir>/info/sparse-checkout so the
// checkout can be re-enabled. Detection must not treat the leftover file
// as "still sparse" (that produced a false "files are not on disk" badge).
const patternFile = path.join(repoPath, '.git', 'info', 'sparse-checkout')
await expect(stat(patternFile)).resolves.toMatchObject({})
expect(mainWorktree(await listWorktrees(repoPath)).isSparse).toBeFalsy()
}
)
it.skipIf(process.platform === 'win32')(
'ignores config.worktree while extensions.worktreeConfig is off',
async () => {
const repoPath = await createRepoWithTwoDirs()
git(repoPath, ['sparse-checkout', 'set', 'keep'])
git(repoPath, ['config', 'extensions.worktreeConfig', 'false'])
git(repoPath, ['config', 'core.sparseCheckout', 'false'])
await writeFile(
path.join(repoPath, '.git', 'config.worktree'),
'[core]\n\tsparseCheckout = true\n'
)
expect(git(repoPath, ['config', '--get', 'core.sparseCheckout']).trim()).toBe('false')
expect(mainWorktree(await listWorktrees(repoPath)).isSparse).toBeFalsy()
}
)
it.skipIf(process.platform === 'win32')(
'honors config.worktree while extensions.worktreeConfig is on',
async () => {
const repoPath = await createRepoWithTwoDirs()
git(repoPath, ['sparse-checkout', 'set', 'keep'])
git(repoPath, ['config', 'extensions.worktreeConfig', 'true'])
git(repoPath, ['config', 'core.sparseCheckout', 'false'])
await writeFile(
path.join(repoPath, '.git', 'config.worktree'),
'[core]\n\tsparseCheckout = true\n'
)
expect(git(repoPath, ['config', '--get', 'core.sparseCheckout']).trim()).toBe('true')
expect(mainWorktree(await listWorktrees(repoPath)).isSparse).toBe(true)
}
)
})
describe('sparse-checkout cache invalidation across the worktree lifecycle', () => {
it.skipIf(process.platform === 'win32')(
'does not leak a stale sparse badge onto a worktree created at a removed worktree`s path',
async () => {
const repoPath = await createRepoWithTwoDirs()
const linkedPath = path.join(path.dirname(repoPath), 'linked')
git(repoPath, ['worktree', 'add', '-b', 'sparse-branch', linkedPath])
git(linkedPath, ['sparse-checkout', 'set', 'keep'])
const beforeRemoval = await listWorktrees(repoPath)
const sparseRow = beforeRemoval.find((worktree) => worktree.branch.endsWith('sparse-branch'))
expect(sparseRow?.isSparse).toBe(true)
// Removing through Orca's own removeWorktree (not a raw `git worktree remove`) exercises the
// cache-invalidation hook this listing now relies on instead of a fresh stat every time.
await removeWorktree(repoPath, linkedPath, true)
git(repoPath, ['worktree', 'add', '-b', 'full-branch', linkedPath])
const afterRecreate = await listWorktrees(repoPath)
const fullRow = afterRecreate.find((worktree) => worktree.branch.endsWith('full-branch'))
expect(fullRow?.isSparse).toBeFalsy()
}
)
})
describe('parseCoreSparseCheckoutFlag', () => {
it('reads an enabled flag from the [core] section', () => {
expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = true\n')).toBe(true)
})
it('reads a disabled flag written by `sparse-checkout disable`', () => {
expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = false\n')).toBe(false)
})
it('returns undefined when the flag is absent', () => {
expect(parseCoreSparseCheckoutFlag('[core]\n\tbare = false\n')).toBeUndefined()
expect(parseCoreSparseCheckoutFlag('')).toBeUndefined()
})
it('honors the last assignment when the key repeats', () => {
expect(
parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = true\n\tsparseCheckout = false\n')
).toBe(false)
})
it('is case-insensitive for the section and key names', () => {
expect(parseCoreSparseCheckoutFlag('[CORE]\n\tSPARSECHECKOUT = TRUE\n')).toBe(true)
})
it('treats a valueless boolean as true', () => {
expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout\n')).toBe(true)
})
it('ignores a [core "subsection"] header', () => {
expect(parseCoreSparseCheckoutFlag('[core "sub"]\n\tsparseCheckout = true\n')).toBeUndefined()
})
it('ignores a matching key outside the [core] section', () => {
expect(parseCoreSparseCheckoutFlag('[other]\n\tsparseCheckout = true\n')).toBeUndefined()
})
it('ignores an inline comment after the value', () => {
expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = true # on\n')).toBe(true)
})
// Git's config parser is character- not line-based, so a header may be followed on the same line
// by the assignment. Every expectation below was confirmed against `git config --file --get`.
it('reads an assignment on the same line as the section header', () => {
expect(parseCoreSparseCheckoutFlag('[core] sparseCheckout = true\n')).toBe(true)
expect(parseCoreSparseCheckoutFlag('[core] sparseCheckout = false\n')).toBe(false)
expect(parseCoreSparseCheckoutFlag('[core]sparseCheckout=true\n')).toBe(true)
expect(parseCoreSparseCheckoutFlag('[core] sparseCheckout\n')).toBe(true)
})
it('keeps the section open for later lines after a same-line assignment', () => {
expect(
parseCoreSparseCheckoutFlag('[core] sparseCheckout = false\n\tsparseCheckout = true\n')
).toBe(true)
})
it('lets the last header on a line decide the section', () => {
expect(parseCoreSparseCheckoutFlag('[core "sub"] [core] sparseCheckout = true\n')).toBe(true)
expect(parseCoreSparseCheckoutFlag('[core] [other] sparseCheckout = true\n')).toBeUndefined()
})
it('ignores a same-line assignment under a [core "subsection"] header', () => {
expect(parseCoreSparseCheckoutFlag('[core "sub"] sparseCheckout = true\n')).toBeUndefined()
})
it('leaves [core] when a subsection header carries its own same-line assignment', () => {
// A `[section "sub"]key = value` line matched neither branch of the old anchored regex, so the
// parser never left `[core]` and credited the next indented line to it — a bogus sparse badge.
// Git reports core.sparseCheckout as unset here.
expect(
parseCoreSparseCheckoutFlag(
'[core]\n[core "sub"]worktreeConfig = x\n\tsparseCheckout = true\n'
)
).toBeUndefined()
})
it('honors the last assignment across mixed same-line and indented forms', () => {
expect(
parseCoreSparseCheckoutFlag(
'[core] sparseCheckout = true\n[core]\n\tsparseCheckout = false\n'
)
).toBe(false)
expect(
parseCoreSparseCheckoutFlag(
'[core]\n\tsparseCheckout = false\n[core] sparseCheckout = true\n'
)
).toBe(true)
})
it('handles comments and whitespace around a same-line header', () => {
expect(parseCoreSparseCheckoutFlag('[core]# c\n\tsparseCheckout = true\n')).toBe(true)
expect(parseCoreSparseCheckoutFlag('\t[core] sparseCheckout = true ; c\n')).toBe(true)
})
it('does not treat trailing junk as a second assignment', () => {
// Git parses this line fine and takes the whole tail as one value (`git config --list` reports
// `core.sparsecheckout=true bogus = false`) — a value runs to end of line, so only one
// assignment can share a line. Git then fails the boolean coercion outright, so reading the
// whole tail as the value keeps us on the conservative "not sparse" side.
expect(parseCoreSparseCheckoutFlag('[core] sparseCheckout = true bogus = false\n')).toBe(false)
})
})