perf(runtime): stop rescanning every repo every 30s with a Git-admin fingerprint

The main-process worktree resolution cache expires on wall-clock time: the
whole-fleet snapshot has a 1s TTL, so any poller faster than 1Hz recomputes it,
and every 30s the per-repo scan cache expires and shells out `git worktree list`
for every registered repo. A production trace recorded 4,272 `git worktree`
invocations over 3h27m across 10 repos -- one full sweep per 30.5s, independent
of which caller happened to arrive first.

In-Orca mutations (create, remove, rename, sparse edits, repo add/update/remove,
SSH reconnect) are already event-driven, so the 30s TTL exists only to discover
changes made outside Orca. That is a filesystem question, and the filesystem can
answer it without a subprocess.

Before re-running an expired scan for a local, non-WSL repo, read a cheap
Git-admin fingerprint (admin dir entries, per-checkout HEAD and its ref tip,
gitdir/locked/existence per entry, packed-refs and reftable stamps). If it
matches the fingerprint captured at the cached scan's start, extend the cache
without spawning Git; otherwise scan as before. A real scan still runs every
5 minutes so anything the probe cannot see -- sparse-checkout edits, a packed
tip moved within one mtime tick -- still reconciles.

Measured on the reported workload (10 idle repos, 1Hz polling, 30 simulated
minutes): 600 -> 60 `git worktree list` spawns, a 90% reduction, with external
worktree add/remove/move/lock/checkout/commit discovery still bounded at 30s.

SSH repos, WSL-routed repos, folder workspaces, and any repo whose layout the
probe cannot read keep today's behaviour exactly. Design: docs/reference/worktree-scan-fingerprint.md
This commit is contained in:
Neil
2026-08-12 22:05:00 -07:00
parent aeb7183afe
commit d021bb4699
6 changed files with 963 additions and 8 deletions
+1
View File
@@ -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/*
+241
View File
@@ -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 `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
| existence of `repoPath` | main checkout deleted |
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
| `<commonDir>/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/<branch>` 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 <worktree>` | ≤ 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 `<commonDir>/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
+73 -8
View File
@@ -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<string | null>
/** When the Git scan behind `result` actually ran, so reconciliation can be bounded. */
scannedAt: number
}
type RuntimeWorktreeScanInFlight = {
generation: number
runtimeKey: string
promise: Promise<RuntimeWorktreeScanResult>
promise: Promise<RuntimeWorktreeScanRefresh>
}
type RuntimeWorktreeScanRefresh = {
result: RuntimeWorktreeScanResult
adminFingerprint: Promise<string | null>
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<RuntimeWorktreeScanRefresh> {
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<string | null> = Promise.resolve(null)
const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000
export function resolveWorktreeScanCacheTtlMs(repo: Pick<Repo, 'path' | 'connectionId'>): number {
@@ -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<string> {
const { stdout } = await execFileAsync('git', args, { cwd })
return stdout
}
async function fingerprint(path = repoPath): Promise<string | null> {
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()
})
})
@@ -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<string | null> {
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<string> {
const entryDir = path.join(adminDir, name)
// `gitdir` holds "<worktree>/.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/<branch>` and leaves HEAD untouched.
*/
async function readHeadStamp(commonDir: string, headDir: string): Promise<string> {
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<string[]> {
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<string | null> {
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<string | null> {
const dotGitPath = path.join(repoPath, '.git')
let dotGitStats: Awaited<ReturnType<typeof stat>> | 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<string | null> {
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<string> {
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<string> {
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'
}
@@ -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<Record<string, unknown>>()),
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<string, unknown> = {}) {
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<string, ReturnType<typeof makeMeta>> = {
[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<string, unknown>) => {
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<unknown> }
function makeRuntime(options: { connectionId?: string; repoCount?: number } = {}): {
runtime: OrcaRuntimeService
list: () => Promise<unknown>
} {
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)
})
})