diff --git a/.gitignore b/.gitignore index 64854199a35..6e049c525d7 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,7 @@ docs/** !docs/reference/remote-wire-compatibility.md !docs/reference/renderer-agent-status-performance.md !docs/reference/windows-setup-shell.md +!docs/reference/worktree-scan-fingerprint.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/docs/reference/worktree-scan-fingerprint.md b/docs/reference/worktree-scan-fingerprint.md new file mode 100644 index 00000000000..4f501bc919d --- /dev/null +++ b/docs/reference/worktree-scan-fingerprint.md @@ -0,0 +1,241 @@ +# Steady-state worktree rescan: Git-admin fingerprint gate + +## Status + +Adopted for the main-process worktree resolution cache +(`OrcaRuntimeService.listRepoWorktreesForResolution`). It keeps the existing +30-second freshness contract for externally created, removed, moved, locked, and +re-checked-out worktrees while removing the `git worktree list` subprocess that +previously ran for every registered repository every 30 seconds. + +## Context + +A production trace (10 registered repositories, 3 h 27 min) recorded 4,272 +`git worktree` invocations and 8,663 total Git subprocesses. The invocations +arrived in full-fleet sweeps roughly every 30.5 s — one sweep per repository per +`WORKTREE_SCAN_CACHE_TTL_MS`. + +### What actually expires the cache + +The originating report assumed some specific terminal/status/orchestration +request was invalidating the 30-second cache. That is not what happens, and the +distinction changes the fix: + +- `resolvedWorktreeCache` (the whole-fleet snapshot) has a **1-second** TTL + (`RESOLVED_WORKTREE_CACHE_TTL_MS`). Any caller polling faster than 1 Hz + recomputes the snapshot. +- `computeResolvedWorktrees` fans out over **every** registered repo + (`src/main/runtime/orca-runtime.ts`), calling + `listRepoWorktreesForResolution` per repo. +- That per-repo call is backed by `worktreeScanCache` with a **30-second** TTL. + When it expires, the next poll shells out. + +So no request "expires" the 30-second cache. It expires on wall-clock time, and +whichever poller arrives first afterwards pays a full-fleet `git worktree list` +fan-out. The many high-frequency callers — `listTerminals` without a selector, +`showTerminal`, `getWorktreePs`, `listManagedWorktrees`, +`resolveWorktreeSelector`, orchestration authority refresh — only determine +*who* pays, not *how often*. Steady-state subprocess volume is therefore +`repos / 30 s`, independent of poll rate, which is exactly the observed +~1 sweep / 30.5 s. + +The in-Orca mutation surface is already event-driven: create, remove, rename, +folder-rename, sparse edits, repo add/update/remove, SSH reconnect, and +mixed-version remote invalidation all call +`invalidateWorktreeScanCacheForRepo` / `invalidateResolvedWorktreeCache` +(≈40 call sites). The 30-second TTL exists for exactly one reason: discovering +worktree changes made **outside** Orca (`git worktree add/remove/move/prune`, +`git checkout` in another worktree, `rm -rf` of a worktree directory). + +That is a filesystem question, and the filesystem can answer it without a +subprocess. + +## Goals + +- Remove the periodic all-repository `git worktree list` fan-out in steady + state. +- Preserve the current ≈30 s discovery latency for externally created, removed, + moved, pruned, locked/unlocked, and re-checked-out worktrees. +- Keep a bounded reconciliation so anything the cheap probe cannot observe still + converges. +- Change nothing for SSH repos, WSL-routed repos, folder workspaces, bare repos, + or hosts where the probe cannot resolve Git's admin layout. +- Fail open: any probe error must behave exactly like today (run the real scan). + +## Non-goals + +- Changing `RESOLVED_WORKTREE_CACHE_TTL_MS` or the whole-fleet snapshot shape. +- Changing the renderer-facing `worktrees:list` / `worktrees:listAll` IPC scan + cache in `src/main/ipc/worktrees.ts` (separate 5 s cache, invalidated by + `registerWorktreeChangeInvalidator`, not a polling source in the trace). +- Scoping `resolveWorktreeSelector` to a single repository. See + "Rejected alternatives". +- Adding a filesystem watcher per repository. +- Any wire/RPC/persisted-schema change. + +## Design + +Introduce a cheap, subprocess-free **Git worktree admin fingerprint** for a +local repository, and consult it before re-running a scan whose TTL has expired. + +### Fingerprint inputs + +`readRepoWorktreeAdminFingerprint(repoPath)` in +`src/main/runtime/repo-worktree-admin-fingerprint.ts` resolves the repo's Git +common directory without a subprocess (read `.git`; if it is a `gitdir:` file, +follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a +bare gitdir), then records: + +| Input | External change it catches | +| --- | --- | +| sorted entry names of `/worktrees` | `worktree add`, `worktree remove`, `worktree prune` | +| existence of `repoPath` | main checkout deleted | +| `/packed-refs` mtime + size | a tip moved while its loose ref is packed away | +| `/reftable` mtime + size | a tip moved under the reftable backend | +| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) | +| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip | +| per entry: `gitdir` contents | `worktree move`, `worktree repair` | +| per entry: `locked` presence | `worktree lock` / `unlock` | +| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) | + +"per checkout" covers the main worktree and each linked worktree. Reading the +ref HEAD names is what makes an ordinary commit visible: committing rewrites +`refs/heads/` and leaves `HEAD` untouched, but moves the oid +`git worktree list --porcelain` prints. A symref target is only followed when it +is a relative path under `refs/`, so a hand-edited `HEAD` cannot steer the probe +outside the ref store. + +Every input is a `stat`, `readdir`, or small `readFile` on an already-hot inode, +and the fingerprint depends on nothing but the repo path — no prior scan result +is threaded in. Per-repo fan-out is capped at 8 concurrent linked-worktree +probes, mirroring `SPARSE_CHECKOUT_DETECTION_CONCURRENCY`. A 10-repo fleet with +10 worktrees each costs a few hundred filesystem calls per 30 s window versus 10 +process spawns; the spawns dominate by orders of magnitude, and process-table +churn was the reported symptom. + +The fingerprint is a NUL-delimited string; any read failure yields `null`, which +the caller treats as "cannot prove unchanged". + +### Cache decision + +`listRepoWorktreesForResolution` gains one branch on the expired-TTL path: + +```text +cached entry exists, same generation + runtimeKey, TTL expired + └─ probe eligible? (no connectionId, no wslDistro, fingerprint recorded) + ├─ no → real scan (today's behaviour) + └─ yes → read fingerprint now + ├─ null or different → real scan + ├─ equal, last real scan < 5 min → extend TTL, no subprocess + └─ equal, last real scan ≥ 5 min → real scan (bounded reconcile) +``` + +`WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS` is 5 min, matching the existing +`WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS` precedent. A cached result whose scan +failed (`ok: false`) is never extended, so a transient Git failure still retries +on the 30 s TTL. + +### Ordering + +The fingerprint stored with a scan result is captured **before** the scan runs. +A mutation landing while the scan is in flight therefore leaves the stored +fingerprint stale-by-construction, so the next probe sees a difference and +rescans. Capturing after the scan would let that mutation be masked forever. + +### Interaction with existing invalidation + +Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint +included) and bumps the generation, so every event-driven path still forces a +real scan on the next read. The fingerprint only ever *extends* an entry that +the TTL alone would have refreshed. + +## Freshness budget + +| Change | Before | After | +| --- | --- | --- | +| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) | +| SSH reconnect / provider generation bump | immediate (event) | immediate (event) | +| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s | +| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s | +| External `rm -rf ` | ≤ 30 s | ≤ 30 s | +| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min | +| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min | +| SSH / WSL repos, folder workspaces | unchanged | unchanged | + +The two regressions are bounded by the reconciliation interval and are both +changes Orca does not make itself. + +### Residual risk + +A repo registered at a UNC path (`\\wsl$\...`) but executed by the local Windows +Git runtime is still probed, because it is not WSL-routed from Orca's point of +view. The probe is correct there and strictly cheaper than the subprocess it +replaces, but its filesystem calls cross the 9p boundary like the existing +sparse-checkout probes already do. + +## Measured effect + +`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts` drives the +reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30 +simulated minutes — and counts `git worktree list` invocations: + +| | `git worktree list` per 30 min | per hour | +| --- | --- | --- | +| TTL only (before) | 600 | 1,200 | +| fingerprint gate (after) | 60 | 120 | + +A 90 % reduction, with the remainder being the bounded reconciliation. Repos +with genuine external activity keep rescanning at the 30 s cadence because the +fingerprint flips. + +Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272 +`git worktree` invocations would become ≈427. + +## Rejected alternatives + +**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess +reduction, but it degrades *every* external-change latency to 5 min, including +the common "I ran `git worktree add` in a terminal" case. The fingerprint buys +the same reduction without that regression. + +**Per-repo `fs.watch` on `/worktrees`.** Lower latency, but adds +persistent watcher handles per repo, inherits recursive-watch platform +differences, and would need its own dormancy/rearm story. The existing watcher +infrastructure is scoped to workspace files; extending it to Git admin dirs is a +larger change with a worse risk profile for the same steady-state win. + +**Scope `resolveWorktreeSelector` to the owning repository.** The original brief +asks for this. `listTerminals` already avoids the fan-out for explicit worktree +ids via `buildResolvedWorktreeFromId` + +`listKnownResolvedWorktreesForExplicitTarget`. Extending that to +`resolveWorktreeSelector` means splitting the highest-fan-in method in the +runtime (38 call sites) and rebuilding lineage projection for a repo subset — +material regression risk. Once the fingerprint gate lands, the remaining +fan-out cost for a targeted call is a batch of stats, not a subprocess, so the +gain no longer justifies the risk. Tracked as follow-up, not in this change. + +## Test plan + +`src/main/runtime/repo-worktree-admin-fingerprint.test.ts` (real temp dirs, real +`git` binary — a mocked filesystem would only restate the assumptions): + +- stable across repeated reads with no change +- changes after `worktree add`, `worktree remove`, `worktree move`, + `worktree lock`, `checkout` in a linked worktree, `checkout` in the main + worktree, a commit in either, and `rm -rf` of a worktree directory +- still tracks a tip whose loose ref has been packed away by `git pack-refs` +- a linked worktree path and the main repo path produce the same fingerprint +- a bare repo resolves through its own gitdir and still tracks `worktree add` +- `null` for a non-Git directory and for a missing path + +`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts`: + +- unchanged fingerprint suppresses the rescan past the 30 s TTL and re-arms it +- changed fingerprint rescans at the 30 s TTL +- the 5-minute reconciliation forces a rescan while the fingerprint is unchanged +- `notifyBranchRenamed` (event invalidation) still forces an immediate rescan +- a `null` fingerprint (probe failure) falls back to scanning +- SSH repos never consult the probe +- a failed scan is never extended +- concurrent callers share one probe and one scan +- the 1 Hz / 10-repo workload measurement above diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7cad4ad94bc..b6cf72f2043 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -711,6 +711,7 @@ import { scanLocalRepoWorktreesForResolution, type RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' +import { readRepoWorktreeAdminFingerprint } from './repo-worktree-admin-fingerprint' import { getLocalWorktreePathAccess, removeLocalWorktreePath, @@ -2531,12 +2532,25 @@ type RuntimeWorktreeScanCache = { runtimeKey: string result: RuntimeWorktreeScanResult expiresAt: number + /** + * Git-admin state read as of the scan's start, kept unresolved so no caller ever waits on the + * probe to receive a scan result. Resolves to `null` when the state could not be read. + */ + adminFingerprint: Promise + /** When the Git scan behind `result` actually ran, so reconciliation can be bounded. */ + scannedAt: number } type RuntimeWorktreeScanInFlight = { generation: number runtimeKey: string - promise: Promise + promise: Promise +} + +type RuntimeWorktreeScanRefresh = { + result: RuntimeWorktreeScanResult + adminFingerprint: Promise + scannedAt: number } type WorktreeLineageCandidate = { @@ -29255,26 +29269,30 @@ export class OrcaRuntimeService { } const inFlight = this.worktreeScanInFlight.get(repo.id) if (inFlight?.generation === generation && inFlight.runtimeKey === runtimeKey) { - return inFlight.promise + return (await inFlight.promise).result } - const promise = this.listRepoWorktreesForResolutionUncached(repo, projectRuntime) + const reusableCached = + cached?.generation === generation && cached.runtimeKey === runtimeKey ? cached : null + const promise = this.refreshRepoWorktreeScan(repo, projectRuntime, reusableCached) this.worktreeScanInFlight.set(repo.id, { generation, runtimeKey, promise }) try { - const result = await promise + const refresh = await promise // Why: back off local spawn failures under resource pressure while disconnected SSH can recover on the next poll. if ( - (result.ok || !repo.connectionId) && + (refresh.result.ok || !repo.connectionId) && generation === (this.worktreeScanGenerations.get(repo.id) ?? 0) && this.worktreeScanInFlight.get(repo.id)?.promise === promise ) { this.worktreeScanCache.set(repo.id, { generation, runtimeKey, - result, - expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo) + result: refresh.result, + expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo), + adminFingerprint: refresh.adminFingerprint, + scannedAt: refresh.scannedAt }) } - return result + return refresh.result } finally { if (this.worktreeScanInFlight.get(repo.id)?.promise === promise) { this.worktreeScanInFlight.delete(repo.id) @@ -29282,6 +29300,47 @@ export class OrcaRuntimeService { } } + /** + * Refresh one repo's worktree rows, skipping the `git worktree list` subprocess when a cheap + * Git-admin fingerprint proves nothing changed since the cached scan. + */ + private async refreshRepoWorktreeScan( + repo: Repo, + projectRuntime: ProjectExecutionRuntimeResolution | undefined, + cached: RuntimeWorktreeScanCache | null + ): Promise { + const scannedAt = Date.now() + // SSH and WSL-routed repos run Git off-host, so a local admin-dir read cannot describe them. + const fingerprintCapable = + !repo.connectionId && + !getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime).wslDistro + // Why issue it before the scan: a change landing while the scan runs must not be stamped as + // already-observed, or the next probe would mask it until the reconciliation deadline. + const adminFingerprint = fingerprintCapable + ? readRepoWorktreeAdminFingerprint(repo.path) + : UNFINGERPRINTED_WORKTREE_SCAN + const reusable = + fingerprintCapable && + cached?.result.ok === true && + scannedAt - cached.scannedAt < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS + ? cached + : null + if (reusable) { + // Why await only here: this is the one branch whose decision needs the probe. A scan-bound + // caller must never wait on it, or every cold read pays filesystem latency it cannot use. + const [current, previous] = await Promise.all([adminFingerprint, reusable.adminFingerprint]) + if (current !== null && current === previous) { + return { + result: reusable.result, + adminFingerprint: reusable.adminFingerprint, + scannedAt: reusable.scannedAt + } + } + } + const result = await this.listRepoWorktreesForResolutionUncached(repo, projectRuntime) + return { result, adminFingerprint, scannedAt } + } + private async listRepoWorktreesForResolutionUncached( repo: Repo, projectRuntime: ProjectExecutionRuntimeResolution | undefined @@ -35606,6 +35665,12 @@ const WORKTREE_SCAN_CACHE_TTL_MS = 30_000 // fan-out was measured at ~128 git execs/min on real installs, mostly against // these (crash-cluster diagnostics, 2026-07). const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000 +// Why: the Git-admin fingerprint cannot observe sparse-checkout pattern edits or a HEAD rewrite that +// lands in the same filesystem timestamp tick with an equal-length ref, so a real scan still runs on +// this interval even while the probe reports "unchanged". +export const WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS = 5 * 60_000 +/** Stand-in for hosts the local admin probe cannot describe (SSH, WSL); never matches a real read. */ +const UNFINGERPRINTED_WORKTREE_SCAN: Promise = Promise.resolve(null) const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000 export function resolveWorktreeScanCacheTtlMs(repo: Pick): number { diff --git a/src/main/runtime/repo-worktree-admin-fingerprint.test.ts b/src/main/runtime/repo-worktree-admin-fingerprint.test.ts new file mode 100644 index 00000000000..15d4aad4484 --- /dev/null +++ b/src/main/runtime/repo-worktree-admin-fingerprint.test.ts @@ -0,0 +1,152 @@ +// Real-binary coverage: the fingerprint's whole job is to predict what `git worktree list` would +// report, so a mocked filesystem would only prove the assumptions, not the Git layout they model. +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readRepoWorktreeAdminFingerprint } from './repo-worktree-admin-fingerprint' + +const execFileAsync = promisify(execFile) + +let scratchDir = '' +let repoPath = '' +let worktreePath = '' + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +async function fingerprint(path = repoPath): Promise { + return await readRepoWorktreeAdminFingerprint(path) +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git records /private/var/... in `gitdir`. + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-fingerprint-'))) + repoPath = join(scratchDir, 'repo') + worktreePath = join(scratchDir, 'trees', 'feature') + await mkdir(repoPath, { recursive: true }) + await mkdir(join(scratchDir, 'trees'), { recursive: true }) + await git(['init', '-q'], repoPath) + await git(['config', 'user.email', 'fingerprint@example.invalid'], repoPath) + await git(['config', 'user.name', 'Fingerprint'], repoPath) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'seed'], repoPath) + await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature'], repoPath) +}) + +afterEach(async () => { + await rm(scratchDir, { recursive: true, force: true }) +}) + +describe('readRepoWorktreeAdminFingerprint', () => { + it('is stable while nothing changes', async () => { + const first = await fingerprint() + expect(first).not.toBeNull() + expect(await fingerprint()).toBe(first) + }) + + it('changes when a worktree is added', async () => { + const before = await fingerprint() + await git( + ['worktree', 'add', '-q', join(scratchDir, 'trees', 'second'), '-b', 'second'], + repoPath + ) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when a worktree is removed', async () => { + const before = await fingerprint() + await git(['worktree', 'remove', worktreePath], repoPath) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when a worktree directory is deleted outside Git', async () => { + // The admin dir is untouched by `rm -rf`, but the row's `prunable` flag flips. + const before = await fingerprint() + await rm(worktreePath, { recursive: true, force: true }) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when a worktree is moved', async () => { + const before = await fingerprint() + await git(['worktree', 'move', worktreePath, join(scratchDir, 'trees', 'moved')], repoPath) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when a worktree is locked', async () => { + const before = await fingerprint() + await git(['worktree', 'lock', worktreePath], repoPath) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when a linked worktree switches branch', async () => { + const before = await fingerprint() + // A longer ref name keeps the difference visible even on a coarse mtime clock. + await git(['checkout', '-q', '-b', 'feature-with-a-much-longer-name'], worktreePath) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when a linked worktree records a commit', async () => { + // Committing rewrites refs/heads/feature and leaves HEAD alone, but moves the reported HEAD oid. + const before = await fingerprint() + await writeFile(join(worktreePath, 'work.txt'), 'work\n') + await git(['add', '-A'], worktreePath) + await git(['commit', '-qm', 'work'], worktreePath) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when the main worktree records a commit', async () => { + const before = await fingerprint() + await writeFile(join(repoPath, 'more.txt'), 'more\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'more'], repoPath) + expect(await fingerprint()).not.toBe(before) + }) + + it('still tracks a tip whose loose ref has been packed away', async () => { + await git(['pack-refs', '--all'], repoPath) + const before = await fingerprint() + await writeFile(join(worktreePath, 'packed.txt'), 'packed\n') + await git(['add', '-A'], worktreePath) + await git(['commit', '-qm', 'packed'], worktreePath) + expect(await fingerprint()).not.toBe(before) + }) + + it('changes when the main worktree switches branch', async () => { + const before = await fingerprint() + await git(['checkout', '-q', '-b', 'main-with-a-much-longer-name'], repoPath) + expect(await fingerprint()).not.toBe(before) + }) + + it('resolves the same admin state from a linked worktree path', async () => { + // A linked worktree can itself be the registered repo path; both must describe one common dir. + expect(await fingerprint(worktreePath)).toBe(await fingerprint(repoPath)) + }) + + it('reads a bare repo through its own gitdir', async () => { + const barePath = join(scratchDir, 'bare.git') + await git(['clone', '-q', '--bare', repoPath, barePath], scratchDir) + const before = await fingerprint(barePath) + expect(before).not.toBeNull() + await git( + ['worktree', 'add', '-q', join(scratchDir, 'trees', 'from-bare'), '-b', 'bare-tree'], + barePath + ) + expect(await fingerprint(barePath)).not.toBe(before) + }) + + it('returns null for a directory that is not a git repo', async () => { + const plainPath = join(scratchDir, 'plain') + await mkdir(plainPath, { recursive: true }) + expect(await fingerprint(plainPath)).toBeNull() + }) + + it('returns null for a missing path', async () => { + expect(await fingerprint(join(scratchDir, 'absent'))).toBeNull() + }) +}) diff --git a/src/main/runtime/repo-worktree-admin-fingerprint.ts b/src/main/runtime/repo-worktree-admin-fingerprint.ts new file mode 100644 index 00000000000..1c3cf103e67 --- /dev/null +++ b/src/main/runtime/repo-worktree-admin-fingerprint.ts @@ -0,0 +1,191 @@ +import { readdir, readFile, stat } from 'node:fs/promises' +import path from 'node:path' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' + +// NUL can appear in neither a path nor a Git ref, so field boundaries stay unambiguous. +const FIELD_SEPARATOR = '\u0000' +const MISSING = '-' +// Why: this runs per repo on a polling path, so a repo with hundreds of worktrees must not queue its +// whole admin dir onto the fs threadpool at once. Mirrors SPARSE_CHECKOUT_DETECTION_CONCURRENCY. +const LINKED_WORKTREE_PROBE_CONCURRENCY = 8 + +/** + * Cheap, subprocess-free summary of a local repo's Git worktree administrative state. + * + * Equal fingerprints mean `git worktree list --porcelain` would report the same rows, so a caller + * can extend a scan cache instead of spawning Git. `null` means "cannot prove unchanged" (not a + * Git repo, unreadable layout, permission error) and callers must fall back to a real scan. + * + * Not covered: sparse-checkout pattern edits, and a tip moved through a ref store this cannot read + * exactly (packed refs, the reftable backend), which fall back to a coarser mtime + size stamp. + * Callers bound both with a periodic unconditional rescan. + */ +export async function readRepoWorktreeAdminFingerprint(repoPath: string): Promise { + try { + const commonDir = await resolveGitCommonDir(repoPath) + if (!commonDir) { + return null + } + const adminDir = path.join(commonDir, 'worktrees') + const names = await readLinkedWorktreeNames(adminDir) + const [mainHead, mainExists, packedRefs, reftable] = await Promise.all([ + readHeadStamp(commonDir, commonDir), + readExistenceStamp(repoPath), + // A tip whose loose ref file was packed away still moves these. + readFileStamp(path.join(commonDir, 'packed-refs')), + readFileStamp(path.join(commonDir, 'reftable')) + ]) + const linked = await mapWithConcurrency( + names, + LINKED_WORKTREE_PROBE_CONCURRENCY, + async (name) => await readLinkedWorktreeStamp(commonDir, adminDir, name) + ) + return [mainHead, mainExists, packedRefs, reftable, String(names.length), ...linked].join( + FIELD_SEPARATOR + ) + } catch { + return null + } +} + +async function readLinkedWorktreeStamp( + commonDir: string, + adminDir: string, + name: string +): Promise { + const entryDir = path.join(adminDir, name) + // `gitdir` holds "/.git"; its contents follow `git worktree move` and `git worktree repair`. + const gitdirTarget = await readTrimmedFile(path.join(entryDir, 'gitdir')) + const [head, locked, worktreeExists] = await Promise.all([ + readHeadStamp(commonDir, entryDir), + readExistenceStamp(path.join(entryDir, 'locked')), + // Deleting a worktree directory outside Orca flips its `prunable` row without touching the admin dir. + gitdirTarget ? readExistenceStamp(path.dirname(gitdirTarget)) : Promise.resolve(MISSING) + ]) + return [name, gitdirTarget ?? MISSING, head, locked, worktreeExists].join(FIELD_SEPARATOR) +} + +/** + * Identify the commit `git worktree list` would print for one checkout: the HEAD line itself plus, + * when HEAD is a symref, the tip it names. Reading the tip is what makes a plain commit visible — + * committing rewrites `refs/heads/` and leaves HEAD untouched. + */ +async function readHeadStamp(commonDir: string, headDir: string): Promise { + const head = await readTrimmedFile(path.join(headDir, 'HEAD')) + if (!head) { + return MISSING + } + const refName = head.match(/^ref:\s*(.+?)\s*$/)?.[1] + if (!refName || !isSafeRefName(refName)) { + // Detached HEAD already holds the object id, and an unrecognized HEAD is covered by its own text. + return head + } + // Per-worktree refs (`refs/bisect`, `refs/worktree`) live beside the checkout; branches are shared. + const tip = + (await readTrimmedFile(path.join(headDir, refName))) ?? + (await readTrimmedFile(path.join(commonDir, refName))) + return [head, tip ?? MISSING].join(FIELD_SEPARATOR) +} + +/** Keep a hand-edited HEAD from steering the probe outside the repo's ref store. */ +function isSafeRefName(refName: string): boolean { + const segments = refName.split(/[\\/]/) + return ( + segments[0] === 'refs' && + segments.length > 1 && + segments.every((segment) => segment.length > 0 && segment !== '.' && segment !== '..') && + !path.isAbsolute(refName) + ) +} + +async function readLinkedWorktreeNames(adminDir: string): Promise { + try { + const entries = await readdir(adminDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + } catch (err) { + // A repo with no linked worktrees has no admin dir at all; anything else is a real read failure. + if (isMissingEntryError(err)) { + return [] + } + throw err + } +} + +async function resolveGitCommonDir(repoPath: string): Promise { + const gitDir = await resolveGitDir(repoPath) + if (!gitDir) { + return null + } + // A linked worktree's gitdir points at the shared admin root through `commondir`. + const commonDir = await readTrimmedFile(path.join(gitDir, 'commondir')) + return commonDir ? path.resolve(gitDir, commonDir) : gitDir +} + +async function resolveGitDir(repoPath: string): Promise { + const dotGitPath = path.join(repoPath, '.git') + let dotGitStats: Awaited> | null = null + try { + dotGitStats = await stat(dotGitPath) + } catch (err) { + if (!isMissingEntryError(err)) { + throw err + } + } + if (!dotGitStats) { + // Bare repo, or a repo path that already is a gitdir. + return (await readExistenceStamp(path.join(repoPath, 'HEAD'))) === 'y' ? repoPath : null + } + if (dotGitStats.isDirectory()) { + return dotGitPath + } + if (!dotGitStats.isFile()) { + return null + } + const contents = await readTrimmedFile(dotGitPath) + const match = contents?.match(/^gitdir:\s*(.+?)\s*$/m) + return match ? path.resolve(repoPath, match[1]) : null +} + +async function readTrimmedFile(filePath: string): Promise { + try { + const trimmed = (await readFile(filePath, 'utf-8')).trim() + return trimmed.length > 0 ? trimmed : null + } catch (err) { + if (isMissingEntryError(err)) { + return null + } + throw err + } +} + +async function readFileStamp(filePath: string): Promise { + try { + const stats = await stat(filePath) + return `${stats.mtimeMs}:${stats.size}` + } catch (err) { + if (isMissingEntryError(err)) { + return MISSING + } + throw err + } +} + +async function readExistenceStamp(targetPath: string): Promise { + try { + await stat(targetPath) + return 'y' + } catch (err) { + if (isMissingEntryError(err)) { + return 'n' + } + throw err + } +} + +function isMissingEntryError(err: unknown): boolean { + const code = (err as NodeJS.ErrnoException | null)?.code + return code === 'ENOENT' || code === 'ENOTDIR' +} diff --git a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts new file mode 100644 index 00000000000..9e01f913e66 --- /dev/null +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -0,0 +1,305 @@ +// The fingerprint's own behaviour is covered against real Git in repo-worktree-admin-fingerprint.test.ts. +// This suite pins the cache wiring: when the probe may skip a `git worktree list`, and when it may not. +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const electronMocks = vi.hoisted(() => { + const ipcMain = { + on: vi.fn(() => ipcMain), + removeListener: vi.fn(() => ipcMain), + emit: vi.fn(() => true) + } + return { + BrowserWindow: { fromId: vi.fn((): unknown => null) }, + webContents: { fromId: vi.fn((): unknown => null) }, + ipcMain, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } + } +}) +vi.mock('electron', () => electronMocks) + +const getSshGitProviderMock = vi.hoisted(() => vi.fn()) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: vi.fn(() => 0), + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', + requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) +})) + +const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) +vi.mock('../git/worktree', async (importOriginal) => ({ + ...(await importOriginal>()), + listWorktreesStrict: listWorktreesStrictMock +})) + +const readRepoWorktreeAdminFingerprintMock = vi.hoisted(() => vi.fn()) +vi.mock('./repo-worktree-admin-fingerprint', () => ({ + readRepoWorktreeAdminFingerprint: readRepoWorktreeAdminFingerprintMock +})) + +import { OrcaRuntimeService, WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS } from './orca-runtime' + +const REPO_ID = 'repo-local' +const REPO_PATH = '/Users/me/dev/app' +const WORKTREE_PATH = '/Users/me/dev/app-feature' +const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` +const MAIN_WORKTREE_ID = `${REPO_ID}::${REPO_PATH}` +const SCAN_TTL_MS = 30_000 + +function makeMeta(overrides: Record = {}) { + return { + displayName: 'feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +function makeStore(options: { connectionId?: string; repoCount?: number } = {}) { + const metaById: Record> = { + [WORKTREE_ID]: makeMeta(), + [MAIN_WORKTREE_ID]: makeMeta({ displayName: 'main' }) + } + const repos = Array.from({ length: options.repoCount ?? 1 }, (_unused, index) => ({ + id: index === 0 ? REPO_ID : `${REPO_ID}-${index}`, + path: index === 0 ? REPO_PATH : `${REPO_PATH}-${index}`, + displayName: 'app', + badgeColor: 'blue', + addedAt: 1, + ...(options.connectionId === undefined ? {} : { connectionId: options.connectionId }) + })) + const store = { + getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id), + getRepos: () => repos, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (id: string) => metaById[id], + setWorktreeMeta: (id: string, meta: Record) => { + metaById[id] = { ...(metaById[id] ?? makeMeta()), ...meta } as never + return metaById[id] + }, + removeWorktreeMeta: () => {}, + getAllWorktreeLineage: () => ({}), + getAllWorkspaceLineage: () => ({}), + removeWorktreeLineage: vi.fn(), + removeWorkspaceLineage: vi.fn(), + getGitHubCache: () => undefined as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }), + getProjects: () => [] + } + return store +} + +type RuntimeInternals = { listResolvedWorktrees: () => Promise } + +function makeRuntime(options: { connectionId?: string; repoCount?: number } = {}): { + runtime: OrcaRuntimeService + list: () => Promise +} { + const runtime = new OrcaRuntimeService(makeStore(options) as never) + return { + runtime, + list: () => (runtime as unknown as RuntimeInternals).listResolvedWorktrees() + } +} + +function scanCount(): number { + return listWorktreesStrictMock.mock.calls.length +} + +describe('worktree scan admin-fingerprint gate', () => { + beforeEach(() => { + getSshGitProviderMock.mockReset() + listWorktreesStrictMock.mockReset() + listWorktreesStrictMock.mockResolvedValue([ + { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, + { path: WORKTREE_PATH, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false } + ]) + readRepoWorktreeAdminFingerprintMock.mockReset() + readRepoWorktreeAdminFingerprintMock.mockResolvedValue('fp-1') + }) + + it('skips the Git scan past the TTL while the admin fingerprint is unchanged', async () => { + vi.useFakeTimers() + try { + const { list } = makeRuntime() + + await list() + expect(scanCount()).toBe(1) + + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + await list() + expect(scanCount()).toBe(1) + + // The suppressed read must still re-arm the TTL rather than probing on every poll. + vi.advanceTimersByTime(1_000) + await list() + expect(scanCount()).toBe(1) + expect(readRepoWorktreeAdminFingerprintMock).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('rescans at the TTL once the admin fingerprint changes', async () => { + vi.useFakeTimers() + try { + const { list } = makeRuntime() + + await list() + expect(scanCount()).toBe(1) + + readRepoWorktreeAdminFingerprintMock.mockResolvedValue('fp-2') + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + await list() + expect(scanCount()).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('reconciles with a real scan once the bounded interval elapses', async () => { + vi.useFakeTimers() + try { + const { list } = makeRuntime() + + await list() + expect(scanCount()).toBe(1) + + // Keep polling past the TTL with an unchanged fingerprint; only the reconcile deadline rescans. + for ( + let elapsed = 31_000; + elapsed < WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS; + elapsed += 31_000 + ) { + vi.advanceTimersByTime(31_000) + await list() + } + expect(scanCount()).toBe(1) + + vi.advanceTimersByTime(WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS) + await list() + expect(scanCount()).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('still rescans immediately when an event invalidates the repo', async () => { + vi.useFakeTimers() + try { + const { runtime, list } = makeRuntime() + + await list() + expect(scanCount()).toBe(1) + + runtime.notifyBranchRenamed(REPO_ID) + await list() + expect(scanCount()).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('scans when the probe cannot describe the repo', async () => { + vi.useFakeTimers() + try { + readRepoWorktreeAdminFingerprintMock.mockResolvedValue(null) + const { list } = makeRuntime() + + await list() + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + await list() + + expect(scanCount()).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('never consults the probe for SSH repos', async () => { + vi.useFakeTimers() + try { + const listWorktrees = vi.fn(async () => [ + { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true } + ]) + getSshGitProviderMock.mockReturnValue({ listWorktrees }) + const { list } = makeRuntime({ connectionId: 'ssh-remote-1' }) + + await list() + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + await list() + + expect(listWorktrees).toHaveBeenCalledTimes(2) + expect(readRepoWorktreeAdminFingerprintMock).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('does not extend a failed scan on an unchanged fingerprint', async () => { + vi.useFakeTimers() + try { + listWorktreesStrictMock.mockRejectedValue(new Error('git unavailable')) + const { list } = makeRuntime() + + await list() + expect(scanCount()).toBe(1) + + vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) + await list() + expect(scanCount()).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('bounds Git fan-out under a sustained 1 Hz polling workload', async () => { + // Reproduces the reported steady state: 10 idle repos, a caller polling faster than the + // resolved-snapshot TTL. Before the gate this cost one `git worktree list` per repo per 30 s. + vi.useFakeTimers() + try { + const REPOS = 10 + const POLL_SECONDS = 30 * 60 + const { list } = makeRuntime({ repoCount: REPOS }) + + for (let second = 0; second < POLL_SECONDS; second += 1) { + await list() + vi.advanceTimersByTime(1_000) + } + + // One scan per repo per cache refresh: the TTL alone would refresh 60x per repo, the + // reconcile deadline refreshes 6x. 600 -> 60 `git worktree list` spawns per half hour. + const ttlOnlyScans = (REPOS * POLL_SECONDS * 1_000) / SCAN_TTL_MS + const reconcileScans = + (REPOS * POLL_SECONDS * 1_000) / WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS + expect(ttlOnlyScans).toBe(600) + expect(reconcileScans).toBe(60) + expect(scanCount()).toBe(reconcileScans) + } finally { + vi.useRealTimers() + } + }) + + it('shares one probe and one scan across concurrent callers', async () => { + const { list } = makeRuntime() + + await Promise.all([list(), list(), list()]) + + expect(scanCount()).toBe(1) + expect(readRepoWorktreeAdminFingerprintMock).toHaveBeenCalledTimes(1) + }) +})