mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
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:
@@ -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/*
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user