Files
orca/src/main/git/worktree-sparse-checkout-cache.ts
T
OrcaWinandm4air 4085e1cf60 fix(memory): release stale session registries (#21734)
* fix(memory): bound session and lifecycle registries

* fix(memory): bound transient filesystem registries

* fix(memory): cap path and locale caches

* fix(memory): bound runtime recovery registries

* fix(memory): bound host mirror gap verdicts

* fix(memory): bound shell startup env cache

* fix(memory): bound gitlab host context cache

* fix(memory): release removed ssh generations

* fix(memory): expire cloud refresh replay guards

* fix(memory): release retired plugin generations

* fix(memory): bound plugin log key retention

* fix(memory): bound automation authority generations

* fix(memory): bound native chat enrichment cache

* fix(memory): bound web session tracking generations

* fix(memory): bound codex credential absence paths

* fix(memory): bound WSL canonical path cache

* fix(memory): bound sparse checkout cache

* fix(memory): bound shared directory cache

* fix(memory): bound advertised URL scan snapshots

* fix(memory): bound automation manager cache

* fix(memory): bound web session reorder intents

* fix(memory): bound web session focus intents

* fix(memory): bound web session handoffs

* fix(memory): bound automation dispatch tokens

* fix(memory): bound host mirror waiters

* fix(memory): bound retained session activity

* fix(memory): bound retained session activity

* fix(memory): bound web session close intents

* fix(memory): bound cloud session cache

* fix(memory): bound WSL home cache

* fix(memory): bound SSH capability cache

* fix(memory): bound trust grant cooldowns

* fix(memory): bound WSL auth drain state

* fix(memory): bound Linear workspace credential cache

* fix(memory): bound local Git capability cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL preflight cache

* fix(memory): keep hot cache entries warm

* fix(memory): preserve generation fences across eviction

* fix(memory): close remaining eviction fences

* fix(memory): align evicted upstream generations

* fix(memory): trim successful capability probes

* fix(auth): retain expired refresh replay evidence

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-20 14:41:50 -07:00

168 lines
7.7 KiB
TypeScript

import type { GitRuntimeOptions } from './git-runtime-options'
import { canonicalWorktreePath } from './worktree-path-comparison'
import { detectSparseCheckout } from './worktree-sparse-state'
// Why: `git worktree list` only emits a `sparse` porcelain line on newer Git (annotateSparseCheckoutStatus
// already skips rows where that's set), but Orca's compatibility baseline is Git 2.25, which predates it —
// so every listing still paid a per-worktree fs.stat + config read on the fallback path, measured at ~9x
// the cost of the `git worktree list` call it decorates on a 1000-worktree repo. Cache the result, scoped
// per repo so churn in one repo can't evict another's warm entries.
//
// Invalidation coverage:
// - Orca-driven remove/move: explicit calls below (worktree-removal.ts, worktree-move.ts).
// - External `git sparse-checkout` toggle while extensions.worktreeConfig is on: it rewrites
// `config.worktree`, which the git-common-dir watcher already classifies as structural and
// routes through notifyWorktreesChanged -> the invalidator this module registers (repo-scoped).
// - External toggle with extensions.worktreeConfig off, or a bare pattern-file edit: unwitnessed
// by the watcher (same blind spot `readRepoWorktreeAdminFingerprint` already documents and
// accepts). Past the reconcile window below, a read still returns instantly from the stale entry
// but also kicks a deduplicated background re-detect; a flip fires the change listener (wired to
// the existing worktrees-changed notification) so the visible staleness window collapses from the
// interval to one refresh cycle instead of blocking the listing that noticed it. That notification
// itself runs the invalidator registered below, so a flip is immediately followed by a full clear
// of the repo's cache (not just the one entry) -- an intentionally forced one-time full re-detect
// on the rare edge that actually flipped, rather than partial state that could quietly diverge.
// - App cold start: the map starts empty, so the first read is always a fresh detect.
const SPARSE_CHECKOUT_CACHE_RECONCILE_INTERVAL_MS = 5 * 60_000
export const MAX_SPARSE_CHECKOUT_CACHE_ENTRIES = 512
// Part of the cache key, not just a probe argument. A distro-less read of a WSL-hosted repo
// resolves the gitdir pointer against a fabricated Win32 path and reports "not sparse"; several
// callers (filesystem-auth root rebuild, worktree ownership checks) list a repo with no options at
// all and would otherwise publish that wrong answer onto the entry the distro-carrying listing
// reads. Keying on it also pins each entry's revalidation to the options that produced it, so the
// background probe can never re-derive a warm entry under weaker options and flip it. Every field
// here must be in the key; widening this type means widening `cacheKey`.
type SparseCheckoutProbeOptions = Pick<GitRuntimeOptions, 'wslDistro'>
type SparseCheckoutCacheEntry = {
isSparse: boolean
cachedAt: number
revalidating?: Promise<void>
}
export type SparseCheckoutChangeListener = (
repoPath: string,
worktreePath: string,
isSparse: boolean
) => void
const sparseCheckoutStateCache = new Map<string, SparseCheckoutCacheEntry>()
let changeListener: SparseCheckoutChangeListener | undefined
function retainSparseCheckoutCacheEntry(key: string, entry: SparseCheckoutCacheEntry): void {
sparseCheckoutStateCache.delete(key)
sparseCheckoutStateCache.set(key, entry)
while (sparseCheckoutStateCache.size > MAX_SPARSE_CHECKOUT_CACHE_ENTRIES) {
const oldest = sparseCheckoutStateCache.keys().next()
if (oldest.done || oldest.value === key) {
break
}
sparseCheckoutStateCache.delete(oldest.value)
}
}
// Distro last so the repo- and worktree-scoped prefix deletes below still match every variant.
function cacheKey(
repoPath: string,
worktreePath: string,
options: SparseCheckoutProbeOptions
): string {
return `${worktreeKeyPrefix(repoPath, worktreePath)}${options.wslDistro?.trim().toLowerCase() ?? ''}`
}
function worktreeKeyPrefix(repoPath: string, worktreePath: string): string {
return `${canonicalWorktreePath(repoPath)}\0${canonicalWorktreePath(worktreePath)}\0`
}
function deleteKeysWithPrefix(prefix: string): void {
for (const key of sparseCheckoutStateCache.keys()) {
if (key.startsWith(prefix)) {
sparseCheckoutStateCache.delete(key)
}
}
}
/** Wired by the ipc/ layer to the shared worktrees-changed notification; last registration wins. */
export function onSparseCheckoutStateChanged(
listener: SparseCheckoutChangeListener | undefined
): void {
changeListener = listener
}
/** Cached wrapper around {@link detectSparseCheckout}; see module doc for invalidation coverage. */
export async function detectSparseCheckoutCached(
repoPath: string,
worktreePath: string,
options: SparseCheckoutProbeOptions = {}
): Promise<boolean> {
const key = cacheKey(repoPath, worktreePath, options)
const cached = sparseCheckoutStateCache.get(key)
if (!cached) {
const isSparse = await detectSparseCheckout(worktreePath, options)
retainSparseCheckoutCacheEntry(key, { isSparse, cachedAt: Date.now() })
return isSparse
}
if (Date.now() - cached.cachedAt < SPARSE_CHECKOUT_CACHE_RECONCILE_INTERVAL_MS) {
retainSparseCheckoutCacheEntry(key, cached)
return cached.isSparse
}
// Stale-while-revalidate: serve the still-cached value now and correct it in the background,
// deduplicated so concurrent readers past the window don't each start their own probe. Whichever
// reader wins the dedupe re-probes with the entry's own distro, because that distro is what
// routed it to this key.
cached.revalidating ??= revalidateInBackground(key, repoPath, worktreePath, cached, options)
return cached.isSparse
}
async function revalidateInBackground(
key: string,
repoPath: string,
worktreePath: string,
startingEntry: SparseCheckoutCacheEntry,
options: SparseCheckoutProbeOptions
): Promise<void> {
try {
const isSparse = await detectSparseCheckout(worktreePath, options)
// Identity guard against a race with an explicit invalidate/clear -- or a remove+recreate at
// the same path that repopulates the key with a fresh cold read -- while this was in flight.
// A `has()`/presence check can't tell "still mine" from "someone else's fresh value" sharing
// the key; comparing the map's current entry object to the one we started from can.
if (sparseCheckoutStateCache.get(key) === startingEntry) {
retainSparseCheckoutCacheEntry(key, { isSparse, cachedAt: Date.now() })
}
if (isSparse !== startingEntry.isSparse) {
changeListener?.(repoPath, worktreePath, isSparse)
}
} catch {
// Leave whatever's there in place; the next read past the window retries.
if (sparseCheckoutStateCache.get(key) === startingEntry) {
startingEntry.revalidating = undefined
}
}
}
/** Drop one worktree's cached state; call when Orca itself removes or moves a worktree path. */
export function invalidateSparseCheckoutState(repoPath: string, worktreePath: string): void {
deleteKeysWithPrefix(worktreeKeyPrefix(repoPath, worktreePath))
}
/** Clear one repo's cached entries; wired to the shared worktree-change invalidator registry in ipc/. */
export function clearSparseCheckoutStateCacheForRepo(repoPath: string): void {
deleteKeysWithPrefix(`${canonicalWorktreePath(repoPath)}\0`)
}
/** Clear every cached entry; fallback for a change notification whose repo can't be resolved to a path. */
export function clearSparseCheckoutStateCache(): void {
sparseCheckoutStateCache.clear()
}
export function __resetSparseCheckoutStateCacheForTests(): void {
sparseCheckoutStateCache.clear()
changeListener = undefined
}
export function __getSparseCheckoutStateCacheSizeForTests(): number {
return sparseCheckoutStateCache.size
}