Repository navigation
perf(worktree): keep one worktree list per repo instead of re-running git worktree list - #24085
brennanb2025 wants to merge 23 commits into
Conversation
…f re-running git worktree list Every local listing (sidebar, runtime fleet, file-access roots, PTY hydration, push-target and ownership checks) now reads a per-repo membership model that derives rows from Git's admin files and re-validates them by stat on reads older than 1 s. One `git worktree list` per model supplies the main row and a parity check; repos whose layout the file rules do not cover (reftable, relative gitdir records, core.worktree, config includes, WSL) or that fail parity stay on Git, gated by the same stat signature. A missing repo costs one stat and no Git. The runtime's admin fingerprint is deleted; the detected and runtime row caches keep no rows for model-backed repos; the fleet fan-out is capped at 8. The head-identity reader now shares the model's admin-file primitives, which also makes it report the branch a symref chain ends at, as Git does. Runtime tests that fed the runtime scan through a mocked `listWorktreesStrict` now mock `listWorktreesFromMembershipStrict` too (same mock object where the test reuses it), because the scan reads the model instead of Git. The admin fingerprint gate test keeps its non-fingerprint cases as worktree-scan-runtime-cache.test.ts.
…e waiting for A reader arriving while another reader's derivation held the just-consumed dirty scope saw a clean scope plus a fresh memo and got the old rows; consuming a change now retires the memo so concurrent readers join the derivation. Also: the annotated listing is layered on the shared graph read again, so the Git-answered fallback keeps its one-listing sharing, and model rows (which already carry sparse state) skip the probe. Tests: both readers agree on shared admin-file fixtures, the symref-chain branch fix, the fleet cap of 8, and the file layers split under the line limit. Git-layer unit tests that drive the listing through a mocked Git with made-up paths now route reads through the Git-answered path, since the model would otherwise answer those paths as missing repos with one stat.
… is passed Orca's Git runner treats a \\wsl$ UNC repo path as WSL whether or not the caller names a distro, and translates its rows; the membership model now routes those repos to Git by the same rule and skips its one-stat missing-repo shortcut there, since a stopped distro can make the path read as absent.
… the membership model changed Nine Git-level suites (porcelain forms, capability probes, removal lookups) mock Git with made-up paths, so their listings now stopped at the model's missing-repo stat and never reached the mock. They share one seam that lets Git answer, as the model does for layouts it cannot read from files. Runtime factory mocks of git/worktree lacked listWorktreesFromMembershipStrict, so their fleet scans silently took the degraded path (and three integration suites failed). They now map it to the same mock as listWorktreesStrict, which also makes the SSH routing guard able to fail again. The real-Git parity test compares row paths separator-insensitively (Git for Windows prints `/`) and removes trees through the retrying helper the Windows lane requires.
…ions and bounded on a hung mount - The 1 s memo is now keyed to the generation its derivation started at. An older derivation that finishes after a newer one started can still commit, but can no longer re-open the memo with rows from before a create or remove Orca had already marked. - A mark that lands while a model is first built is replayed onto it when the build finishes. - A cold build that fell back on a transient read failure runs the Git parity check on its next derivation instead of adopting file rows unchecked. - Model reads are bounded by the caller's timeout (default: the listing timeout), and fail the way a Git timeout does. A build or derivation past its deadline stays registered until it settles, so later reads fail fast instead of queueing more fs work on the hung mount. The missing-repo stat moved into the build and derivations, off the memo-hit path. - Models remember their common dir's realpath: a watcher mark (which names the realpath) reaches a repo registered through a symlink, and Orca's own mark reaches every registered repo sharing the changed repo's common dir.
…leet budget per snapshot - Only a model that derives rows from Git's files counts as model-backed. A repo the model leaves to Git (WSL, reftable, config include, failed parity) keeps the detected 5 s and runtime 30 s row caches, so a WSL repo is no longer stat-validated over UNC on every read past the 1 s memo. - The fleet snapshot's per-repo 5 s budget now runs from the snapshot's start. The cap of eight concurrent repo reads no longer turns stalled repos (a half-open SSH connection) into waves of 5 s each; repos still waiting for a slot past the budget answer from stored rows. - Tests: a runtime 1 Hz poll over real repos (one listing, no cached rows for a file-derived repo, cached rows for a Git-answered and an SSH repo, and still fresh), the detected cache's two branches, and the fleet budget under 16 stalled repos.
…er; keep packed refs small - A symlinked HEAD (`core.preferSymlinkRefs`) is read by Git as a symref to the link's name; the file rules followed the link and reported a detached or missing HEAD. It now routes the repo to Git, which also covers the setting coming from global config. - The symref chain budget counts the HEAD read, as Git's does. A four-hop chain now pins to Git instead of reporting a branch Git does not print. The head-identity reader shares the rule. - The parity check compares linked rows regardless of order. Git's order depends on its build and locale (Apple Git folds Latin-1 case under a UTF-8 locale), so non-ASCII names no longer pin a healthy repo to Git; the model's linked-row order is Orca's own. - A model keeps only the packed refs its rows looked up (per packed-refs stamp), not the parsed file: tens of thousands of refs were megabytes per model. A name not looked up yet re-parses.
…s the Git runner does
…mo plus watcher marks The store's 1 s memo was only as fresh as the marks that cleared it, and watcher marks named the common dir by a different spelling than the model (a repo registered through a symlink was never marked). Reuse is now decided by one rule for finished and in-flight derivations alike: a result is reused only if its derivation started at the model's current generation and under 200 ms ago. Every watcher-driven read starts at least one 250 ms trailing debounce after the change, so it can never reuse a derivation that began before the change, whatever path the watcher saw. - The watcher-to-model marks and the four-part dirty scope are gone; the stat signature decides which entries to re-read. Orca's own mutation marks stay: they bump the generation and owe a listing re-read, and reach every registered repo sharing the changed repo's common dir. - A model is registered before its first build starts, so a mark during the build counts; the separate pending-builds map is gone. Each reader awaits shared fs work under its own deadline. - WSL repos get no model: Git answers them as on base, with the upper caches, since a Win32 stat over 9P can read an existing directory as absent. - The unused `fresh` read option is removed, and a row's stat signature is read one file at a time since entries already run eight at once. - The removal chain looks the removed worktree up with Git again (unshared, lenient), as the design keeps removal on Git; the row decides which branch gets deleted.
…-gated Git while files fail - One derivation runs per model. A reader that cannot reuse it (older than the 200 ms window, or from before a mark) shares one queued follow-up that starts when it settles, so it still began after the reader arrived. On a slow or hung disk, readers no longer stack fs passes, and the separate stalled-work gate is gone (a stalled derivation already blocks the next). - The runtime's local scan waits on the model for its own 5 s budget (`waitMs`), not the 30 s listing timeout; Git keeps its own timeout. - The fleet snapshot reads every repo at once again, each with its own 5 s budget, as on base. With the cap, eight stalled repos (a half-open SSH host) held every slot and each later healthy repo answered stored rows on every snapshot. A model runs one derivation at a time and Git spawns go through admission, so the cap bounded nothing the fleet still needs bounded. - A files model with no file state, or whose files just failed, is read like a Git-derived model: Git runs only when a stat moves, a mark owes a listing, or the floor is due, and that listing is a new parity attempt whose baseline is the answer if files still fail. Such a repo is not model-backed, so the upper caches keep their TTLs. A read error no retry cures (a HEAD naming a ref that is a directory) no longer runs Git twice on every read. - Removed a leftover comment of the deleted admin-fingerprint field.
…te' into brennanb2025/worktree-membership-events
…ment at the fingerprint's successors
…ng attached to it Each reader waited on shared model work (the running derivation, the queued follow-up, the first build) with its own `.then`. On a hung mount that work never settles, so every reader that timed out stayed attached to it: about 1.4 KB per read, growing until the mount came back. Shared work now carries one reaction that settles a waiter set, and a reader that times out or is aborted removes itself. A pending follow-up also counts as busy, so a reader arriving between a derivation settling and its follow-up starting joins that follow-up instead of starting a second derivation.
Review status: ready for review (head
|
|
| if ( | ||
| !(await stat(model.commonDir).then( | ||
| () => true, | ||
| () => false | ||
| )) | ||
| ) { | ||
| // The repo's Git dir is gone: drop the model so the next read re-resolves the layout, and let | ||
| // Git give this read its answer (usually "not a git repository", a true empty). | ||
| dropModel() | ||
| return (await readGitWorktreeRows(model.repoPath, gitOptionsFor(options))).rows |
There was a problem hiding this comment.
Repointed gitfile leaves stale listings
If a registered linked worktree’s .git file is repointed to another Git directory while the old directory still exists, this check accepts the old directory. Later reads, including the five-minute full refresh, continue listing worktrees from that directory rather than the one the registered worktree now uses. Change notifications do not correct the mapping, so the wrong listings persist until the model is dropped.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run reviewed the complete diff of #24085 against brennanb2025/create-scan-idle-gate — 112 files, 17 commits, from 48e98bf837 through 297fe960db.
- New worktree-membership model (
src/main/git/worktree-membership/): one stat-validated row model per registered local repo, built from a singlegit worktree listbaseline plus file-derived rows. A one-time byte-for-byte parity check pins any mismatching or unsupported layout to Git; later reads re-validate by amtimeMs:ctimeMs:size:inosignature and a 5 min full-derive floor. - Shared admin-file rules (
worktree-admin-file-reads.ts,git-config-file-entries.ts): one reader for HEAD/refs/packed-refs/config, now backing both the model and the head-identity reader. The symref-chain fix reports the terminal ref, matching Git'sadd_head_info. - Store concurrency (
worktree-membership-store.ts): one derivation per model, a 200 ms reuse window under the watcher's 250 ms debounce, a single shared follow-up, generation +listingOwedmarks, per-reader deadlines viaPromiseSettlementWaiters, and idle/unregistered drops. - Integration: the runtime resolution scan and sidebar detected scan stop storing rows for model-backed repos (
isWorktreeMembershipModelBacked);listWorktreesStrictstill runs Git for removal/pre-mutation; WSL, SSH, folder-workspace, and pinned-to-Git paths keep their prior caching. - Cleanup: the old Git-admin fingerprint module, its reference doc, and its suites are removed and their coverage moved to the new model suites.
I verified the file rules against Git's own source semantics (2.47/2.50 worktree.c should_prune_worktree, builtin/worktree.c add_head_info/symref budget, lock trimming), traced the store's build/inFlight/followUp/mark/reuse interleavings for stale-row, lost-mark, and leaked-waiter outcomes, and traced the SSH/WSL/folder/pinned/removal paths. No divergence from Git is served silently for a state reachable through normal Git commands, and no interleaving loses a marked change or leaks a waiter. Existing tests pin counts and identities rather than restating assumptions.
Two non-blocking notes, neither of which changes the verdict: (1) locked/prunable intentionally follow Git's newer rule on Git < 2.31, which is a deliberate behavior change the PR documents; (2) the freshness tradeoffs that the deleted docs/reference/worktree-scan-fingerprint.md recorded now live only in the PR body and code comments, so if the team wants that contract discoverable in docs/ a replacement would be worth adding in a follow-up.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…te' into brennanb2025/worktree-membership-events # Conflicts: # src/main/git/worktree-add-creation-config.test.ts # src/main/git/worktree-graph-listing.test.ts # src/main/git/worktree-remove-branch-deletion.test.ts # src/main/git/worktree-scan-cache-sharing.test.ts # src/main/runtime/runtime-registered-local-worktree-removal.ts
…te' into brennanb2025/worktree-membership-events
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run reviewed the delta since the prior pullfrog review (297fe960db): two merges of the advanced base brennanb2025/create-scan-idle-gate — which now carries #23837's background worktree removal — plus one new test. No new PR-authored production code.
- Merged the advanced base into the branch, reconciling the membership model's invalidation marks with the base's background-removal finish paths in
remove-registered-local-worktree.tsandruntime-registered-local-worktree-removal.ts;markWorktreeMembershipDirtyis retained after Git's prune, so the next membership read re-derives. - Added
worktree-membership-background-removal.test.ts: an integration test that drives a real repo throughstartBackgroundWorktreeRemoval, asserts the model still lists the row (as Git does, prunable) mid-delete and thatprojectPendingWorktreeRemovalsmarks or hides it, then asserts the row is gone after the delete settles. It freezesDate.nowso the 200 ms reuse window would otherwise still serve the stale listing — proving the delete's generation bump, not elapsed time, invalidates the cached read. I ran the file locally; it passes.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…t a Git run per read A registered folder that still exists but is no longer a repository (its `.git` deleted, or an unmounted volume's mount point) ran `git worktree list` on every read, bounded only by the upper 5 s / 30 s caches: the same polling this change removes for missing repos. Once Git itself answers "not a git repository", that error is kept with stamps of the folder and its `.git`, taken before Git ran. Each later read stats those two paths and re-throws Git's own error, so lenient, strict and true-empty readers classify it exactly as when Git answered. A moved stamp (`git init`, a restored `.git`, an in-place bare init, the folder deleted) or the 5 min floor (a parent directory becoming a repository, the environment) sends the next read to Git again. Readers share one stat check under their own deadlines. WSL and folder workspaces never reach it.
| function verdictDependencies(repoPath: string): AdminStatDependency[] { | ||
| return [{ path: repoPath }, { path: join(repoPath, '.git'), noFollow: true }] |
There was a problem hiding this comment.
Ancestor repositories stay hidden
If a registered folder has already been cached as “not a repository” and its parent is then initialized as a Git repository, neither path checked here changes. Later reads keep returning an empty worktree list for up to five minutes, even though Git can now discover the parent repository from that folder.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run reviewed the incremental delta since the prior pullfrog review (521eb308de): commit 80e93d43, which stops re-running git worktree list for a registered folder Git says is not a repository.
- New
worktree-membership-not-repository.ts: caches Git's "not a git repository" answer per registered folder, keyed by canonical path, with a stat signature over the folder and its.git. Later reads re-check by stat within the 5 min floor, share one stat check across concurrent readers viaPromiseSettlementWaiters, and drop on 30 min idle or unregistration. - Store wiring (
worktree-membership-store.ts):readWorktreeMembershipconsults the verdict before building a model and rethrows the cached error; the model-build-failed path now goes throughreadWithoutModel, which stamps the signature before Git and only caches on a not-a-repository error, so a repo appearing mid-run is seen by the next read. - New test (
worktree-membership-store.test.ts): asserts a plain folder answers strict/lenient/true-empty across three reuse-window rounds with nogit worktree listspawn, then lists the folder once a realgit initappears.
I traced the shared stat-check interleaving, the stale-write race (the pre-Git stamp corrects it), the interaction with the model and MissingRepoPathError paths, and the idle/unregistered drop wiring. The added test fails if the cache is removed — spawns would grow each round. The open Greptile thread on the new file (a parent directory becoming a repository after caching) describes the behavior the module documents as intended and bounds by the 5 min floor.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
…shared model build The stamps a not-a-repository verdict keeps were taken after the build resolved, outside every reader's deadline: on a mount that died in that gap the reader never settled, and each reader that joined the build stamped on its own. The build now takes them before it resolves and hands them out with its result.
…te' into brennanb2025/worktree-membership-events
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run reviewed the delta since the prior pullfrog review (80e93d43): commit 5ac5526c4e plus a merge of the base branch (now fully merged here, merge base = base tip).
- Moved the "not a repository" pre-Git stamp into the shared model build.
buildMembershipModelnow returnsGitWorktreeInfo[] | UnplacedRepoinstead ofGitWorktreeInfo[] | null, and takes the folder's stat signature viastampUnplacedRepobefore Git is asked. The store drops the model and hands the stamps toreadWithoutModel, which caches only on a not-a-repo error. This closes a real hang: previously the stamp ran afterawaitModelWork(building.work)returned, so a stat stalled on a hung mount was outside the reader's deadline and the read never failed. - Added a deadline test (
worktree-membership-store-deadline.test.ts): a plain folder whose<folder>/.gitlstatis hung (the layout probe usesstat, so only the stamp blocks) now fails the read withWorktreeMembershipTimeoutError. I confirmed this fails against the pre-fix shape, where that stamp sat outside the deadline. - Merged the base branch. No net production-code change versus
80e93d43.
I traced the stamp's ordering relative to Git (an earlier stamp is conservative — a folder that becomes a repo mid-run yields a signature mismatch on the next read, or a successful Git listing, never a stale verdict), the !Array.isArray narrowing plus dropModel in startModel, and the single readWithoutModel call site. The stamp happens inside the promise wrapped by awaitModelWork, so every reader is bounded; the build itself is not cancelled, which is the module's documented hung-read limit. worktree-membership-store-deadline.test.ts (7) and worktree-membership-store.test.ts (19) pass locally.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
|
The traces make a strong case (~2,600 s/day of git on an 800-worktree repo), but the branch has drifted ~1,750 commits and touches 195 files, including create-preparation code main still changes. Closing. Could you re-cut just the membership model as a focused PR on current main, measured against the fingerprint gate? |






ELI5
To know which worktrees a repo has, Orca kept asking Git to list every one of them, thousands of times a day, even when nothing had changed. On a repo with ~800 worktrees each answer took 0.2–0.5 s of disk work, and it fought worktree creates for the disk. Now Orca keeps one list per repo. It asks Git once to build it, then reads the same small files Git reads. When something asks for the list, Orca checks whether those files changed and re-reads only the ones that did. A repo whose folder was deleted costs one file check and never runs Git.
What Changed
The problem the user saw. On 2026-09-28 creating a worktree took 80–268 s instead of 5–15 s. Part of the cause was Orca competing with itself for the disk:
git worktree listran over and over, and each run walked the bookkeeping files of every worktree. On the main repo (792 worktrees) the traces show 17,391 listings in 6 days, 3,730 of them on 09-28 costing 2,604 seconds of Git time, in bursts about every 24 s. Three repos whose folders had been deleted were listed, and failed, about every 37 s, with no end. Listings also crowded Orca's background Git queue: on 09-25, 107 of 173 queue waits longer than 5 s were listings.Why it happened. Every worktree change event (a commit, checkout or branch delete in any worktree) and every cache expiry made several independent readers each re-run the full
git worktree list: the sidebar's worktree scan, the scan behindorca worktree list/psand mobile, the rebuild of the folders Orca may access, terminal restore, and a handful of per-action checks. Each reader kept its own short cache, so one change fanned out into several full listings.What is different for the user.
git worktree listfor it, apart from the repo layouts listed below that stay on Git..gitreappears (or after five minutes).orca worktree list/ps, mobile and the PR panels show the same rows as before, and they are at least as fresh. A worktree added, removed, locked or committed to from a terminal shows on the next refresh. That holds when the window is closed or hidden and in headless mode too, where no file watcher runs.lockedandprunablenow follow Git's own rule (see "Deliberate choices and known limits").How it works now.
src/main/git/worktree-membership/keeps one worktree model per registered local repo, owned by the main process. Its rows use the existingGitWorktreeInfoshape: path, head, branch, bare, main, locked and its reason, prunable, sparse.git worktree listonce. Git supplies the main worktree's exact path spelling and whether the repo is bare; worktree ids arerepoId::path, so the spelling must match Git's byte for byte. Every linked worktree's row is then read from Git's own bookkeeping files under.git/worktrees/<name>/:gitdir,HEAD, the branch fileHEADpoints through,packed-refs,locked, the checkout's.git, and the sparse-checkout config. The rows read from files must equal Git's rows exactly (the parity check). If they don't, that repo stays on Git until the next launch and a warning is logged.HEAD, refs andgitdirby writing a new file and renaming it over the old one, so the inode changes even on disks with coarse timestamps. Every five minutes a read re-reads every entry anyway, in case a coarse clock hid a rewrite. An idle repo costs nothing: there are no timers.git worktree pruneruns Orca makes, and Orca's existing worktree-change invalidations all bump the counter of the repo's model. Other registered repos that share the same Git directory are bumped too. The next read reuses nothing from before the change and re-lists the.git/worktrees/folder.gitdirrecords written as relative paths (Git 2.48+),core.worktree, a repo config that usesincludeorincludeIf, a symlinkedHEAD, and any repo that failed the parity check. When Orca cannot find the repo's Git directory from files at all, Git answers every read, as before.[], strict readers reject, and the sidebar's scan accepts the repo as empty. When only the repo's Git directory is gone, the model is dropped and Git answers that read.repo-worktree-admin-fingerprint.ts) and its reference doc are deleted. The stat check replaces the fingerprint for every layout.isSparse, so listings skip the per-row sparse probe. That probe's 512-entry cache evicted on every pass at ~800 worktrees.worktree-admin-file-reads.tsholds the rules for reading these files: absent versus unreadable, safe ref names,packed-refs, and following symbolic refs. The model and the existing head-identity reader both use it, so they cannot disagree about aHEAD. Moving the code here also fixed the head-identity reader for a chain of symbolic refs (HEAD→refs/heads/alias→refs/heads/main). It now reportsrefs/heads/main, as Git does, instead ofalias.Why
git worktree listoutput would be cleared whenever any file it depends on changes. Branch heads change on every commit, checkout and fetch in any worktree. So on a busy repo it would still run hundreds of full 800-worktree listings a day. Reading the files lets a commit in one worktree cost a re-read of that one entry.worktrees/folder yet while hidden, the polling fallback while hidden, theworktrees/folder deleted, and the gap while a watcher restarts. Headless mode has no watcher at all. A stat check on read needs no watcher to be alive. The watcher still triggers refreshes, and the 200 ms reuse window makes sure each of those refreshes re-checks the disk.git worktree listplus the sparse probe took 133 ms per call.Related open PRs. #16352 ("Make local Git metadata observation event-driven") landed as #16404 (merged 2026-08-25). This PR builds on #16404's watcher and does not change it: the watcher still triggers refreshes. #16175 ("stagger and bound metadata polling") is unrelated: this PR adds no polling. This PR neither depends on nor conflicts with either.
Differences from the common pattern
Matches the common pattern in these ways. Each repo's worktree list is held in memory by one long-lived process, one per registered repo. Orca's own creates, removes, moves and prunes bump a per-repo change counter, so no later read reuses older work. Each repo runs one re-read at a time, plus one shared re-read queued behind it. A repo whose folder is gone costs one file check and no Git, and so does a registered folder Git says is no longer a repository, until its
.gitreappears. Linked worktrees are read from the same two Git bookkeeping files (.git/worktrees/<name>/gitdirandHEAD) that the common pattern reads instead of running Git. SSH repos are still listed by Git on the remote host, with nothing cached.git worktree listonce and requires the rows read from files to match it exactly, or that repo stays on Git until the list is rebuilt. The common pattern does not check rows read from files against Git. Safe because any gap in the file rules becomes "this repo uses Git", never a wrong row.gitdirrecords,core.worktree, config includes, a symlinkedHEAD) stay on Git, which re-runs only when a file the listing depends on changes by stat, not on every watcher event or every request. Safe because those stats cover the same files Git reads for the listing, and the five-minute floor still re-runs Git.orca worktree list/psand mobile reads every repo at once, each with its own 5 s budget, as before this PR. Safe because each repo already runs at most one re-read at a time, and running them one after another would let one stalled repo (a half-open SSH host, a hung mount) delay every healthy one.git.listWorktreescall, with no change to what client and host send each other.Where this fits
On 2026-09-28 worktree creates took 80–268 s because Orca competed with itself for the disk. Three things caused it. A spare checkout was built and waited on with no time limit. Background Git (fetches, status sweeps, PR conflict checks) ran during the create. And
git worktree listwas spawned over and over, while three deleted repos were polled forever. This plan fixes those in separate PRs:git worktree list; deleted repos stop being polledmainis refreshed safely, once per repo and branch, alongside the checkoutThis PR is step 2.
Not in this PR
worktrees:changedwould fire only when a row's membership, path, branch, lock, prunable or sparse state changed, and head-only moves would go out asworktrees:headIdentitiesChanged. Any change event still re-publishes the whole worktree list to the renderer, mobile and the metadata cleanup pass. After this PR that is an in-memory read with no Git and no disk work, but it still costs a message and a store comparison sized to the whole list, per event. It gets its own PR because it changes the notification contract that fix(worktree): a create does one fetch and one checkout, and background work waits for it #23699's create gate wraps and that the renderer and mobile consume.packed-refs, and asserts the same heads.provider.watchhas no handler for a fatal error, and nothing re-subscribes after a reconnect (an existing bug). The follow-up runs the same stat-checked model on the remote host, behindgit.listWorktrees. It needs no wire change, and older remote hosts keep running Git.worktrees/folder and each entry folder first, and the deeper files only when those moved. At 811 entries that measured 877 stats in 3.3 ms, against 3,949 stats in 17 ms today. It is deferred until it is checked on a loaded disk and on filesystems other than APFS, because it relies on folder modification times.ssh-relay-session.ts), and the missing watch for WSL repos opened through a\\wsl$path.Deliberate choices and known limits
lockedandprunablefollow Git's own rule on Git older than 2.31. Those versions print neither flag, so Orca used to guessprunableby checking whether the worktree folder exists. The model reads the flags the way newer Git does. A locked worktree shows as locked. A locked worktree whose folder is gone is not prunable, because Git never prunes a locked worktree. A worktree whose checkout.gitis gone is prunable. Orca's refusal to remove a locked worktree and the disk-usage scan's filter now see these rows as Git itself would..git) has no stat from before it was read. So it counts as changed once, rather than risking a missed write. At 812 worktrees that took 70 ms, once per model, with no Git.core.barechanged while Orca runs. The main row's bareness comes from the model's one Git listing, so the change shows at the next launch or model rebuild.core.ignorecaseset only in the global Git config. Linked rows can come out in a different order from Git's. The rows themselves are identical, and the parity check ignores order.gitchild process instead.Linked Issue
N/A (internal). Root cause from the 2026-09-28 create-latency traces.
Visual Proof
N/A: no UI change. The sidebar, CLI and mobile show the same rows; what changes is how often Orca runs Git to get them. Screenshots from the live QA run are under Testing.
Testing
Automated tests (vitest, explicit files):
worktree-membership-real-git.test.ts) builds repos with the real Git binary and asserts the model's rows equalgit worktree listrow for row. It also asserts that the model stayed on file rows, so a silent fall back to Git cannot pass. States covered: main, branch, detached, unborn, locked with a reason, prunable (folder deleted), missing linkedHEAD, a branch only inpacked-refs, a branch shared through--force, a path with a space, sparse, and a bare repo with worktrees. A commit, a sibling'supdate-ref, an outside add, a lock/unlock and an outside remove are each seen by stat alone, with no Git listing. Relativegitdir(Git 2.48+) and reftable (Git 2.45+) repos go to Git and still match it exactly. The test also runs in CI's Windows packaging job, where Git writesC:/…paths.worktree-membership-store.test.ts):git worktree listafter the first build, and every reader sees the new head.worktree-membership-store-deadline.test.ts): a read fails at its own deadline, and nothing new starts behind the stalled work. One re-read runs at a time however many readers arrive. Readers that gave up leave nothing attached to a hung re-read or a hung first build. A read inside the reuse window touches no disk.worktree-admin-file-reads.test.ts,worktree-membership-git-rows.test.ts): the head-identity reader and the model agree on the same fixtures. A symbolic-ref chain ends where Git's does. A symlinkedHEADgoes to Git. Only thepacked-refsentries rows use are kept. Parity ignores linked-row order, which depends on the Git build and locale.worktree-scan-runtime-cache.test.ts, which also runs in the Windows packaging job;worktree-scan-runtime-membership.test.ts):detected-worktree-scan-membership.test.ts): no stored rows for a repo the model reads from files, and an unreported change still shows. The 5 s row cache is kept for a repo that stays on Git.listWorktreesStrictfor the runtime scan also mocklistWorktreesFromMembershipStrict.worktree-membership-store-git-answered-mock.ts, the Git-answered path the model keeps for layouts it cannot read.orca-runtime.test.tsspec harness) passes locally.repos-remote-client-events.test.tscan time out under that full load. It passes alone, and it does the same on the base branch.Live QA in a built app on a second Mac, with an isolated app profile. It compared the base branch's build (A) with this PR's build (B, commit
ed080aa5908) on a repo with 300 worktrees plus a small one. Git was wrapped to count everygit worktree list, and the counts were cross-checked against trace spans.orca worktree listshowed 302/302 rows for the 300-worktree repo and 4/4 for the small one, on both builds. Paths, branches and heads matchedgit worktree list. After creating, renaming and removing a worktree from the UI, B showed 304/304.packed-refstook 29.1 s to reach the CLI on A and 1.2 s on B.git worktree listrunsOn that idle machine, A made one failed pair of runs 17 s after the repo was deleted and then stopped; it did not show the every-37 s loop from the traces.
Seen on both builds, so not caused by this PR: after adding a repo and turning on "show external worktrees" right away, the sidebar shows only the main worktree until a second refresh. A worktree folder deleted from outside Orca drops out of the sidebar while Git still reports it as prunable.
Screenshots (build B), also in this comment:
Re-run on
d8d09fe1048(the head after the last concurrency fix): startup parity 302/302, 9 outside changes all within 3 s (CLI 304/304 after),git worktree list3 at startup then 0 during the burst, the outside changes and the deleted-repo window, and 0 renderer console errors. The final head297fe960dbbonly swaps in the existing promise-settlement-waiters helper for the waiter list; CI is green on it.Review
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
..,\or:). Object ids must be hex of the repo's object-format length. Paths read fromgitdirare only stat'ed, never read.C:/records are used byte for byte, andcore.ignorecasechanges only the sort order. WSL and SSH are routed as described above. Folder workspaces never reach the model.locked/prunableon Git older than 2.31, as described above.prunable/lockedon Git older than 2.31 differ, as described above.locked/prunablemore accurately than old Git's owngit worktree listoutput does.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)