mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Two main-process `resolveGitDir` call sites dropped the WSL distro their caller
already held, so they could only resolve a gitdir pointer whose spelling carries
its own translation: a `//wsl.localhost/<Distro>/...` base, or a `/mnt/<letter>`
drvfs pointer that maps to a drive letter on its own.
The layout that needs the third case is a repo inside the distro's filesystem
with its worktrees on the Windows drive. `git worktree list` reports the worktree
as `/mnt/c/wt/x`, which the listing translates to `C:\wt\x` — a base that no
longer names a distro — while the `.git` gitfile beside it points at
`/home/me/repo/.git/worktrees/x`, which has no drive to derive. Win32 then treats
that pointer as absolute and reads a path that names nothing:
- `detectSparseCheckout` stats `info/sparse-checkout` under the fabricated path,
always misses, and reports the worktree as non-sparse — no sparse badge, and
the file list claims files that are not on disk.
- `readWorktreeDiffStamp` reads HEAD under the same path, gets nothing, and
returns null. Null is the safe answer ("cannot prove unchanged"), but it
retires the settled-diff cache for every file in that worktree, so each diff
respawns Git.
Both callers already have the distro: the listing threads its
`GitWorktreeExecOptions` to `annotateSparseCheckoutStatus`, on through
`detectSparseCheckoutCached` (the annotation cache added by #17859) and its
background revalidation probe, and finally to `detectSparseCheckout`; and
`file-diff` already forwards its `GitRuntimeOptions` to `readWorktreeDiffStamp`,
which now forwards it to `resolveGitDir` as well.
`resolveGitMetadataPath` still prefers a UNC base's distro and still tries drvfs
before the caller-named distro, so nothing that resolved before resolves
differently.
The cache hop matters twice over. It is the only remaining caller of
`detectSparseCheckout`, so without threading it the fix would not reach the
probe at all. And the cache is where the bug turns sticky. #17859 keyed entries
on `repoPath` + `worktreePath` alone, on the reasoning that the distro is a
property of the repo and so every read for a given `repoPath` carries the same
one. That invariant does not hold. `listRepoWorktrees(repo)` is called with no
options at all from the filesystem-auth root rebuild
(`registered-worktree-roots-cache.ts`, reached from `ensureAuthorizedRootsCache`
on any auth check with a dirty cache) and from the local worktree-ownership
check in `filesystem-worktree-helpers.ts`. Both land on the *same* key as the
distro-carrying listing, because `translateWslOutputPaths` derives the distro
from the cwd spelling before falling back to `options.wslDistro`, so a
UNC-spelled repo path yields the identical `C:\...` worktree row either way.
Measured on Windows in one process, branch build: a distro-less read followed by
a distro-carrying read reported the sparse worktree as non-sparse both times.
So `wslDistro` now joins the cache key -- trimmed and lowercased, matching how
the rest of the codebase compares distro names, and appended last so the
repo-scoped prefix delete still matches every variant. The per-path invalidate
becomes a prefix delete for the same reason, dropping every distro variant of a
removed or moved worktree.
Keying on it closes both halves of the defect. A correct caller can no longer be
served an answer derived without the distro it supplied. And because the entry a
reader reaches is now selected by the same distro it would re-probe with,
`revalidateInBackground` can no longer re-derive a warm entry under weaker
options -- which mattered on its own: a distro-less reader crossing the
five-minute window would otherwise flip a correct `true` to `false`, and the
resulting change notification runs the registered invalidator, clearing the
whole repo's cache and re-probing every worktree cold, on a five-minute loop.
Cost of the extra key dimension is bounded by the number of distinct distros a
given repo is actually read under: one where a distro is threaded everywhere,
two while the distro-less callers above still exist. Entries are still
repo-scoped, and both clears already sweep by prefix.
Per-platform delta:
- macOS/Linux: no change. Guest-pointer translation is gated to win32 and a
caller-named distro is ignored off Windows; no caller supplies one there, so
the cache keys and probes exactly as before.
- native Windows, no WSL: no change. `wslDistro` is undefined, so the resolver
takes exactly the branches it took before and every read keys on the same
empty distro component, so the cache behaves exactly as it did.
- Windows + WSL, UNC-spelled worktree: no change. The base already names the
distro and outranks the caller's.
- Windows + WSL, drvfs-spelled worktree with a drvfs pointer: no change. The
drive-letter derivation still runs first.
- Windows + WSL, drvfs-spelled worktree with a non-drvfs pointer: the sparse
badge appears and the settled-diff cache starts hitting. Both previously
failed toward "not sparse" / "do not cache", so neither can now serve a stale
answer, and the distro-less listings no longer share the badge's cache entry.
- SSH/relay: none. Those paths return through the provider branch before
reaching either function.
- folder workspaces, GitLab: none. Neither is on these code paths.
Not in this change:
- `readRepoCommonDirFromDisk` (worktree-listing). Passing the distro there is
inert: a repo root's `.git` is a directory, so the gitfile-pointer branch never
runs, and when `repoPath` itself is guest-spelled the preceding `stat` already
fails — which no `resolveGitDir` option can fix.
- The two `findExistingWorktreeSymlinkPaths` calls on the removal paths. Both
receive `registeredWorktree.path` from `listWorktreesStrict`, which already
translates every row out of the guest namespace, so the distro would be a
no-op. The `removeWorktreeLinkedPaths` unlink beside them is untranslated too,
so a half-threaded fix would only move the refusal from Orca's preflight to
`git worktree remove`.
- An absolute `commondir` payload, which `resolveGitCommonDir` still resolves
untranslated. Git writes that file relative in the layouts above, and the
failure direction is unchanged.
- Giving the two distro-less `listRepoWorktrees(repo)` callers a distro. Neither
reads `isSparse` -- both use only `worktree.path` -- so the distro would buy
them nothing they consume, while resolving a project runtime inside the
filesystem-auth rebuild would put a call that throws on `repair-required`
behind a catch that skips the whole repo's authorized roots. The cache key
makes their reads harmless; skipping the annotation for callers that never
read it is a separate, larger change. The third no-options call in
`hosted-review.ts` is inside the `repo.connectionId` branch and returns
through the SSH provider, so it never reaches this cache.
154 lines
7.2 KiB
TypeScript
154 lines
7.2 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
|
|
|
|
// 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
|
|
|
|
// 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)
|
|
sparseCheckoutStateCache.set(key, { isSparse, cachedAt: Date.now() })
|
|
return isSparse
|
|
}
|
|
if (Date.now() - cached.cachedAt < SPARSE_CHECKOUT_CACHE_RECONCILE_INTERVAL_MS) {
|
|
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) {
|
|
sparseCheckoutStateCache.set(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
|
|
}
|