Commit Graph
311 Commits
Author SHA1 Message Date
Jinjing 99062ed80b fix(worktrees): preserve unverifiable disk witness (#20713)
* fix(worktrees): preserve unverifiable disk witness

* fix(worktrees): follow gitdir/commondir markers in disk witness

The disk witness validates created worktrees by reading the repo's common directory from disk. Previously it only checked for a direct .git directory and returned a status object that conflated different failure modes.

Now it properly follows .gitdir and commondir pointer files to locate the true common directory, fixing detection on repos with linked git directories (worktrees, submodules) and WSL scenarios. Error handling is simplified: definitive absence returns undefined, other read failures throw with proper cause chains, eliminating the ambiguous "unverifiable" state that would mask real errors.

* fix: validate gitdir marker targets are directories

When a .git marker points to a missing or non-directory path, that's
unverifiable—not the same as an absent .git file (bare repo). Validate
accessibility before reading commondir to catch these errors clearly.
2026-09-14 21:03:51 -07:00
Neil 41e42beab4 fix(worktrees): safely remove prunable git-file registrations (#20617)
Preserve checkout files and the named branch when removing a positively attested malformed Git-file registration. Reject file/symlink targets in deferred directory deletion.

Verified exact head with 75 focused tests including actual Git malformation, preserved marker/file bytes and branch HEAD. Independent review and complete product CI passed. WSL routing is covered by unit tests; direct SSH fails safely without local recovery.

Fixes #17316
2026-09-14 14:52:45 -07:00
Neil ee1a0a4e2d fix(git): avoid Windows tree kills after the command has exited (#20606)
Validated and independently reviewed OMP integration fix.
2026-09-14 13:56:25 -07:00
Wooseong KimandNeil 31db2774f8 fix(git): skip upstream remote probes when the remote is absent (#18455)
* fix(git): skip upstream remote probes when the remote is absent

Issue and PR resolvers listed remotes by probing `git remote get-url
upstream` on every poll, including origin-only clones where that remote
cannot exist. List remotes once, cache against git config, and skip the
probe unless `upstream` is present.

* fix(git): avoid stale remote probe cache entries

* fix(github): observe origin repository probe failures

* fix(github): observe verified origin probe failures

* fix(github): skip missing upstream probe for PR lists

* test(github): scope the #9171 lazy-resolution guard to default-branch commands

The guard asserted that no git command runs for an open PR, using "no git at
all" as a proxy for "no default-branch resolution". Remote-name listing is a
separate concern, so allow it and keep every other command forbidden; the
symbolic-ref/rev-parse resolution this issue is about stays unreachable.

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-13 20:18:22 -07:00
Neil 471463f4ce perf(git): normalize tracked discard paths once per operation (#20299)
* perf(git): normalize tracked discard paths once per operation

* chore(git): drop the now-dead tracked-pathspec re-export
2026-09-12 20:05:54 -07:00
Neil 490937a043 perf(git): classify bulk discard paths once (#20274)
* perf(git): classify bulk discard paths once

* style: format bulk discard partition
2026-09-12 18:16:41 -07:00
OrcaWinandOrca Worker afb43ea914 perf: avoid duplicate repository directory probes (#20253)
Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-12 18:05:23 -07:00
Jinwoo Hong c84007c541 feat(rpc): generate a shared params catalog from the host registry, gated on parse parity (#19961) 2026-09-10 21:18:39 -07:00
Neil 75c1f32f81 fix(gh): log when gh/glab is killed at its deadline (#18555) 2026-09-06 16:17:54 -07:00
Neil 15dabf8d0b perf(worktree): overlap base refresh with prepared checkout (#18998)
* Stop obsolete worktree preparations when evicted or expired

* Let worktree preparation proceed during stale reclamation

* Verify creation during stalled stale worktree reclamation

* Preserve preparation ownership until Git removal starts

* test: keep artifact share fixtures unexpired across calendar dates (#18955)

* perf(worktree): overlap base refresh with prepared checkout
2026-09-05 22:42:06 -07:00
Neil a63a4579cf Let worktree creation proceed during stale preparation reclamation (#18967)
* Stop obsolete worktree preparations when evicted or expired

* Let worktree preparation proceed during stale reclamation

* Verify creation during stalled stale worktree reclamation

* Preserve preparation ownership until Git removal starts

* test: keep artifact share fixtures unexpired across calendar dates (#18955)
2026-09-05 22:30:01 -07:00
Neil e2270fe94d Stop evicted and expired worktree preparations (#18951)
* Stop obsolete worktree preparations when evicted or expired

* fix(worktree): skip discard retries for registrations an aborted checkout already removed

An evicted or expired preparation now aborts its checkout, which self-discards
the registration before the pool's own discard runs. That second discard failed
with "is not a working tree" and was enrolled for up to three retries on later
preparations for the same host, spawning Git only to fail again and warning that
the path stays registered when it was already gone.

Also accept fs.watch events without a filename in the abort real-Git test, and
add an opt-in bench (ORCA_WORKTREE_PREPARATION_CANCEL_BENCH=1) that measures a
fresh checkout's wall time with obsolete checkouts left running versus aborted.
2026-09-05 22:16:57 -07:00
Neil 295684dc6d perf: skip unrelated shared symlink probes during Git status (#18918) 2026-09-05 20:03:02 -07:00
Neil d7767fb196 perf(worktree): remove redundant creation and terminal startup work (#18793)
* perf(worktree): remove redundant creation and terminal startup work

* test(worktree): cover optimized creation call signatures

Preserve explicit branch adoption, WSL callback routing and sparse cleanup expectations.

* perf: preserve user Git checkout worker settings

* perf(git): skip malformed remote base probes

* perf(cli): avoid loading other agent hooks for Codex preflight

* fix(build): retain Codex preflight entry for packaged CLI

* test(ssh): wait for replacement PTY before lease recovery input

* test(ssh): verify recovered shell execution and lease ownership

* test(electron): reap isolated macOS crash reporters on teardown

* test: allow either observed self-exit snapshot ordering

* test: capture frozen-host input recovery evidence
2026-09-05 15:08:22 -07:00
Neil a823f97d63 perf(worktree): skip remote probes with no possible result (#18821) 2026-09-05 03:01:12 -07:00
Neil 06ca54ae7c fix(test): stop detached git maintenance racing the divergence fixture teardown (#18810)
`worktree-base-divergence-real-git.test.ts` builds cap-sized histories (100 and
101 commits). Every `git commit` detaches `git maintenance run --auto`, whose
commit-graph task arms at 100 new commits, so the fixture reliably spawns a
background `git commit-graph write --split` that keeps creating
`.git/objects/info/commit-graphs` entries after the synchronous exec returns.
The `afterEach` recursive remove is then deleting `.git/objects` underneath a
live writer and dies with ENOTEMPTY — which is how "counts drift in both
directions" failed on main.

Reuse the existing `GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS` (it already
covers modern maintenance and legacy auto-gc, so it holds at the Git 2.25
baseline) in the fixture's git helper. Traced spawns of
`git commit-graph write` over a full run of this file: 4 before, 0 after.

The production path under test only runs `rev-list` and `merge-base`, neither of
which triggers auto-maintenance, so there is nothing to fix outside the fixture.
2026-09-05 01:49:48 -07:00
Neil 0a821e5bc8 fix(crash-reporting): make the own-Chromium gate a real choke point, and stop a refusal leaking the root (#18459)
* fix(crash-reporting): make the own-Chromium gate a real choke point

Round-3 review found the guard was not the choke point its own comments
claimed: six pid-addressed `taskkill /pid <pid> /t /f` families in main were
ungated and uninstrumented, so the stale-pid shape stayed producible and a
`selfInitiatedTreeKillCount: 0` could read as exculpatory when it was not.

- Gate the remaining main-process families: the git command-runner abort, the
  notebook-cell and automation-precheck timeouts.
- Turn the `src/shared` seam into the gate itself (`process-tree-kill-gate`), so
  the runProcess choke point, the codex app-server deadline kill and the
  ephemeral-VM recipe kill ask the same decision. Those three are compiled into
  the CLI/relay too and cannot import main; main installs the guard at preflight.
- Ratchet (`main-process-tree-kill-gate.test.ts`): a new pid-addressed taskkill
  in main that skips the gate fails, and the allowlist entries must still exist.
- Give pid-addressed kills eviction priority in the 32-entry ring: 32 routine
  `win-pty-job` teardowns from a window-close burst no longer evict the one
  entry that discriminates a self-kill from an external one.
- Correct the coverage doc, which described the uninstrumented Windows sites as
  POSIX `process.kill(-pid)` group kills and omitted the git and codex paths.

* fix(crash-reporting): keep a refused tree-kill from leaking the root it owns

A refusal must block the pid-addressed tree walk, not the termination. Five of
the six gated sites returned on refusal with no fallback, so a refused
`taskkill /pid /t /f` left git.exe, a timed-out notebook cell, an automation
precheck or an ephemeral-VM recipe running while the caller reported it stopped.
The root kill is addressed by the child handle, which cannot reach the recycled
pid the refusal is about, so it stays correct and required on that path.

Also fixes the ring eviction the scope preference introduced: with the ring
saturated by pid-addressed kills, the only non-pid-addressed entry is the one
just pushed, so the splice evicted itself and the detail came back `{}` --
byte-identical to the external-kill arm, in the window-close case the guard
exists for. Eviction now excludes the newest entry and falls back to FIFO.

Tests: refusal now asserts the root kill at all six sites, and the ring covers
the saturated-pid ordering as well as round 3's group-burst ordering.

* fix(crash-reporting): stop a refused tree-kill leaking the commit-message agent, and count call sites

Two round-5 blocking findings, both open on main and on both branches.

`killSourceControlAgentProcess` had no root-kill fallback on its win32 arm: the
taskkill was the only termination, so once the own-Chromium gate could refuse it
the promise resolved having killed nothing. Both callers do
`terminationComplete ??= killSourceControlAgentProcess(child)` and then release
the managed-home lock on that promise, so a refusal left the local Codex/Claude
commit-message agent running while the caller reported it stopped -- the
lock-contention failure the taskkill was added for. Same fix as the six sibling
sites: the handle-addressed root kill cannot reach the recycled pid the refusal
is about, so it stays correct and required on that path.

The ratchet was file-granular, not call-site granular: one gate mention anywhere
in a file exempted every taskkill in it, which left the six files that now ask
the gate ratchet-blind -- the inverse of what it is for. It now counts `/pid`
call sites against gate admissions per file, so a second ungated kill inside an
existing family fails. Keying on the `/pid` argument rather than a quoted
`taskkill` also catches a kill whose program name comes from a constant. The
three comments that claimed more than the old scan enforced now state the rule
and its two remaining blind spots.

Also: the recording in `admitSelfInitiatedTreeKill` is now wrapped the way the
`admitProcessTreeKill` seam already wraps it, with the refusal decision taken
before anything that can throw so a diagnostics failure cannot flip it; and
`orca-chromium-process-pids` documents the false-positive direction (a stale
`getAppMetrics()` entry plus pid reuse refuses a live unrelated child), which is
the mechanism the root-kill fallback exists to bound.

Tests: refusal now asserts the root kill at all seven sites; the ratchet asserts
call-site counting and the constant-program form.

* test(crash-reporting): run the own-Chromium gate against real Windows trees

Nothing on this branch had ever executed on Windows. The unit tests pin the
gate's decision against a mocked taskkill, which cannot show that the decision
does anything to a real process: that `/T /F` reaps a detached grandchild, that
a refusal leaves that tree standing, or that the handle-addressed root kill the
refusal path falls back to reaps the root while orphaning descendants.

Adds a win32-gated live test covering all four, registered in both the
`package_windows` CI lane and `WINDOWS_PACKAGE_TESTS` as
`win32-test-lane-registration` requires.

Also completes the coverage doc's "never instrumented" list, which omitted the
macOS keyboard-input-source probe's POSIX group kill in `ipc/app.ts`.

* fix(crash-reporting): pin the commit-message root kill on the Windows arm

The first Windows run of this branch found nine failures the macOS suite
cannot see: `commit-message-text-generation-test-harness` asserts
`expect(child.kill).not.toHaveBeenCalled()` on `process.platform === 'win32'`,
which is the contract the previous commit deliberately replaced — and it
branches on the real platform, so it is dead code everywhere CI runs today.

The harness now asserts the handle-addressed root kill on every platform. On
win32 it lands after the tree walk, so the expectation waits rather than reading
one tick early, and its ten call sites await it. Red against the pre-fix arm at
all seven sites; the production code is unchanged.

* test(crash-reporting): remove the Windows lane marker tree through the retrying helper

The new win32 spec teardown used a raw rmSync, which the windows-lane-tree-removal
boundary ratchet rejects — and which is exactly the EPERM the ratchet exists to
prevent, since this spec's marker directory is written by processes it has just
force-killed.

* fix(crash-reporting): only refuse pid-addressed tree walks, disclose the handle-less codex site

The own-Chromium gate refused the POSIX process-group arm of
signalProcessTree as well, which was new macOS/Linux behaviour: a stale
getAppMetrics() entry plus pid reuse would orphan a group that main reaps
today. A POSIX group only holds what Orca put in it, so the refusal is now
scoped to win-taskkill-tree and the POSIX arm is recorded and admitted like
the other group kills in main. That also drops the synchronous
getAppMetrics() read from every POSIX termination.

codex-turn-added-roots kills roots found by a table walk, so a refusal has
no handle to fall back to. Pin that the refusal is visible - crumb written,
turn reported as not cancelled - rather than fixing what cannot be fixed.

* test(crash-reporting): detach the Windows survival fixture and observe real spawns
2026-09-04 16:42:42 -07:00
Neil d3501f7ad6 fix(worktrees): stop a failed worktree scan from being recorded as an authoritative empty listing (#18456)
* fix(worktrees): stop a failed worktree scan from pruning as an authoritative empty listing

A `git worktree list` that could not run at all — a WSL distro that stopped
resolving, a hung mount, a git binary that errored — was softened to `[]` by the
lenient listing path, so the detected scan published it as
`authoritative: true, worktrees: []`. That disables the #1158 retention guard,
drops every persisted tab for the repo, and the pruned session is written back to
disk on the next launch. The loss is permanent, not a transient glitch.

Route the detected scan through a strict listing that still reports the two
genuinely empty states (repo path gone, not a Git repo) as `[]`. Everything else
rejects, so the existing catch answers `authoritative: false` and the destructive
halves (`rememberLocalWorktreeRoots`, `pruneLineageForMissingRepoWorktrees`)
never see a failed scan.

Also make the failure readable: wsl.exe reports its own launch failures as exit
0xFFFFFFFF with an EMPTY stderr and the `Wsl/Service/WSL_E_*` line on stdout as
UTF-16LE, which is why the field bundle carried a git error with no text. Set
WSL_UTF8 for WSL-routed git (matching the wsl runner, #9010) and attach that
stdout diagnostic to the error so `git.exec` spans name the cause.

* fix(worktrees): surface a failed worktree scan's cause on the repo header with a retry

A failed scan now travels with its reason (optional unavailableReason on
DetectedWorktreeListResult), the repo header shows it with click-to-retry,
and the WSL deleted-guest-directory shape measured on a real Windows host is
pinned as retained-not-pruned.
2026-09-04 15:23:09 -07:00
Neil c5ed53a1f5 perf(git): overlap the three independent submodule status reads (#18623)
getSubmoduleStatus awaited the inner status, the parent gitlink oid and the
submodule HEAD one after another, though none depends on another's result. The
Source Control panel re-runs this on every parent status poll while a submodule
row is expanded, and the same function backs the git.submoduleStatus RPC, so
over SSH or WSL each read was a separate round trip.

Total latency drops from the sum of the round trips to the longest one.
2026-09-04 14:31:54 -07:00
Neil 949c9d3353 perf(worktrees): classify each worktree once, defer the SSH meta index, drop the conflict-path probe (#18433)
* perf(worktrees): classify each worktree once, defer the SSH meta index, unserialise conflict probes

Three redundancies on the worktree-catalog and git-status read paths:

- buildDetectedGitWorktrees ran mergeWorktree + toDetectedWorktree twice for
  every visible row. Discovery backfill returns the same meta object when it
  wrote nothing, and both builders are pure over it, so skip the second pass on
  identity.
- The SSH worktree-meta index parsed every worktree id on the host, then threw
  it away whenever the provider was connected. Build it lazily, memoised.
- Unmerged `u` records were resolved one fs.access at a time. Resolve the prefix
  the cap can reach with 8-way concurrency, keyed by record index so Git's
  output order and error precedence are unchanged.

* perf(git): read the porcelain worktree mode instead of probing conflicted paths

Every porcelain-v2 `u` record already carries `mW`, the working-tree mode Git
stat'ed for that row: `000000` means the conflicted path is absent. Reading it
replaces the per-conflict `fs.access`, so the bounded-concurrency resolver, its
`= 8` cap, and the order/error-precedence invariant are unnecessary rather than
cheaper. `access()` stays only as a fallback for a malformed `mW`, so
`parseUnmergedEntry` keeps its signature and neither status-read.ts nor the
relay loop changes.

Also corrects two fixtures that encoded `mW=100644` for a file that does not
exist, which real Git never emits.

* fix(test): import the conflict parser statically so the CJS cli project compiles
2026-09-03 21:02:11 -07:00
Neil 5a626dcdf4 refactor(git): share push-target resolution between local and the SSH relay (#18406)
`src/relay/git-handler-push-target.ts` and `src/main/git/remote.ts` carried
identical ~160-line copies of the resolver that decides which remote a plain
`git push` hits. Identical today is exactly when to share it: the cost of a
future divergence is pushing to the wrong remote, which retrying does not undo.

Move the resolver to src/shared/git-push-target-resolution.ts, parameterized on
a `(args) => Promise<{ stdout }>` runner — the only thing the two hosts actually
differ in — and delete both copies. The relay entry point keeps only the work
that is genuinely relay-side: re-validating an explicit target that arrived over
the wire and running `check-ref-format` on it.

No behavior change on either path, and nothing new or different is published, so
this engages no rule in remote-wire-compatibility. No git command changes.

src/relay/git-push-target-local-parity.test.ts scripts one repository's config
and requires `git.push` over the real relay dispatcher and the desktop's
`gitPush` to emit the same push argv, plus the argv each case should produce.
2026-09-03 14:42:58 -07:00
Neil 53adf5e2e6 fix(git): share one failed-command error-text reader between local and the SSH relay (#18398)
* fix(git): share one error-text reader between the local and relay branch-delete fallbacks

The relay and the desktop each carried their own `getErrorText`, and they had
drifted: the relay read `message` + `stderr` + `stdout`, the desktop only
`message` + `stderr`. A `git branch -d` refusal arriving on `stdout` therefore
routed the SSH removal through prune-and-retry while the local removal gave up
and preserved the branch.

Against a real binary the two agree, because Git prints the refusal through
`error()` on every supported version — verified on 2.25.1, 2.38.1, 2.49.1 and
2.55.0, none of which put a byte of it on stdout. What the desktop copy actually
missed is that Orca classifies errors it built itself, with the Git output on
`.stdout`: `worktree remove`'s submodule retry attaches `git status --porcelain`
that way on both paths. The stdout-reading form is also already the shared
spelling — `isSubmoduleWorktreeRemovalRefusal` uses it for both hosts — so this
converges on it rather than on the shorter one.

Move the reader to src/shared/git-command-failure-text.ts and the predicate it
feeds to src/shared/git-branch-delete-refusal.ts, and delete all three copies.
The predicate carries both refusal wordings live in the supported range: Git
through 2.40 says "checked out at", 2.43+ says "used by worktree at".

The real-binary contract now pins that boundary: the refusal is recognized, it
lands on stderr, and stdout stays empty on every Git in the matrix.

* fix(test): consolidate the duplicate worktree import in the parity test
2026-09-03 14:42:54 -07:00
Neil 860ee73a11 fix(git): parse sparse and cquoted paths on the SSH relay (#18389)
The relay carried its own copies of the worktree-list and unmerged-entry
porcelain parsers, and both had drifted from the desktop originals: the
relay copy had no `sparse` branch, so SSH sparse checkouts were never
marked, and it never C-quote-decoded a conflict path, so a conflicted file
with a space or non-ASCII byte was published under its raw quoted name and
probed as missing.

Move both parsers into src/shared and delete the relay copies, so there is
one implementation each. Type the relay's worktree-list plumbing on
GitWorktreeInfo instead of Record<string, unknown> so a field-copying step
can no longer silently drop a newly parsed field.

`isSparse` is a new optional field on the git.listWorktrees result
(remote-wire-compatibility Rule 1); Git <2.28 omits the porcelain line and
the field stays absent. No new git subcommand or option.

Closes #18280
2026-09-03 02:11:55 -07:00
Neil fb48a9771b fix(gh): reap the whole gh/glab process tree at the deadline on POSIX (#18258)
`gh` and `glab` on PATH are routinely shims — mise, asdf, volta, or a
hand-written wrapper — so a timed-out invocation has a chain to stop, not
one process. `execFileCapture`'s POSIX kill path signals only the direct
child; the descendants are orphaned to init and keep running. #18234 is
exactly that shape: `bash ~/.local/bin/gh` -> `mise x gh` -> `gh`, where
the reporter found the tail reparented to `systemd --user` and still at
100% CPU nearly two hours later. The 15s deadline #18239 added bounds
Orca's semaphore slot and its promise; it does not bound the CPU burn.

Route both CLIs through `execFileCaptureToTermination`, the primitive
git's barrier path already uses: POSIX children spawn `detached`, the
deadline signals `-pgid` and escalates to SIGKILL, and the promise waits
for verified termination. Windows behaviour is unchanged (`taskkill /t`
either way).

Switching primitives also swapped execFile's hard maxBuffer failure for
`runProcess`'s silent clipping, which would have turned an oversized gh
response into a shorter valid-looking one. `ProcessResult` now reports
truncation and the capture rejects on it, restoring the old contract and
closing the same latent gap on git's barrier path.
2026-09-02 15:12:21 -07:00
Neil 02a417c04b perf(renderer): stop six timers from ticking behind a hidden window (#18134)
* perf(renderer): stop six timers from ticking behind a hidden window

IntensiveWakeUpThrottling is disabled in this app, so a renderer interval
really does fire at full rate with the window hidden. Six of them had
nothing to observe them:

- NativeChatWorkingStatus ran a 1s interval + setState per in-flight turn
  purely to advance an elapsed-seconds counter. Deleted the effect and
  derived elapsed during render from the shared, visibility-gated
  useNow(1_000) clock, so N turns collapse onto one tick.
- The chromium-error fallback poll (250ms) kept probing a stuck-loading
  guest to write a loadError nobody could see.
- The contextual-tour full-pass interval (500ms) woke twice a second to
  queue a rAF a hidden window never paints.
- Three feature-wall animation timers (3600/2400/2400ms) kept committing
  React renders for animations nobody was watching.
- The landing preflight poll (30s) kept forcing IPC refreshes.

All five gated timers reuse installWindowVisibilityInterval. Each either
resumes where it left off (animations) or re-derives from durable state on
the becoming-visible run, so hiding and re-showing is observationally
identical to never hiding.

* test(git): stop two empty commits in the divergence fixture from hashing alike

`counts drift in both directions` builds 100 empty commits, resets to the fork
point, then adds one more — expecting 100 ahead + 1 behind to clear the cap of
100. An empty commit's hash covers only parent, tree, message and a
one-second-granularity timestamp, and every commit in the fixture reuses
`commit ${index}` starting from 0. On a runner fast enough to finish the whole
build inside one wall-clock second (CI: 1059ms for the case, ~7ms per commit),
the post-reset `commit 0` hashed identically to the first `commit 0` of the
chain, so Git handed back that same object and left the branch 99/0 apart
instead of 100/1 — `within`, not `exceeded`.

Numbering the empty commits across calls makes the fixture build the 101
distinct commits it already claimed to. Reproduced deterministically by pinning
GIT_AUTHOR_DATE/GIT_COMMITTER_DATE, which forces the timestamp collision the
fast runner hits by chance: fails with the exact CI assertion before, passes
after.
2026-09-02 14:35:48 -07:00
Neil 1910ab9c9c fix(github): bound and coalesce the Orca star check so gh children cannot pile up (#18239)
`checkOrcaStarred`, `starOrca` and `getAuthenticatedViewer` were the only gh
call sites that reached for the legacy `execFileAsync` instead of
`ghExecFileAsync`, so they ran with no deadline, no process-tree kill and no
coalescing. A `gh` that never exits therefore ran forever and never released
its slot in the 4-wide GitHub semaphore in gh-utils.

Route all three through `ghExecFileAsync`, coalesce concurrent star checks onto
one child, and hoist the Landing star-state effect out of the conditionally
rendered footer so a repo-catalog rewrite no longer remounts it and re-forks gh.

Adds a ratchet test asserting no file outside the command runner names `gh` as
a spawned program.

Fixes #18234
2026-09-02 13:29:12 -07:00
Neil 104f9655e4 perf(git): answer remote-URL questions from one subprocess, not one per remote (#18158)
Four copies of the same loop ran `git remote` and then a serial
`git remote get-url <name>` per remote to answer "which remote has this
URL". On a repo with 58 remotes that is 59 subprocesses -- measured at
1083 ms -- for one question, and worktree create asks it several times.
`git remote -v` answers for every remote from one child, reporting the
same insteadOf-expanded first fetch URL `get-url` prints.

The batched `cat-file --batch-check` branch-conflict probe decides from
stdout, but its WSL route was unfenced, so a login-shell fallback printed
the distro banner onto the stream it parses. That broke the
one-line-per-ref contract, made every batch undecided, and fell straight
back to one `show-ref` per remote -- the cost the batch exists to remove.

Measured at 58 remotes / 4346 branches, spawns and wall time:
  push-target remote scan      59 -> 1  (1083 ms -> 8 ms)
  branch-conflict probe        60 -> 3  (984 ms -> 43 ms)
  configured push target      123 -> 6  (2707 ms -> 157 ms)
2026-09-02 12:53:48 -07:00
Neil 4bc20cb842 fix(wsl): name an explicit Windows cwd for wsl.exe spawns (#17834)
* fix(wsl): name an explicit Windows cwd for wsl.exe spawns

Removing the worktree Orca was launched from broke every wsl.exe spawn for
the rest of the session. The WSL command builders passed `cwd: undefined`
meaning "the directory is inside the command" -- but CreateProcessW reads
NULL as "inherit the parent's", and the parent's was a \\wsl.localhost path
Linux had just deleted.

Fixes #16463

* fix(wsl): name the spawn directory at the six remaining wsl.exe sites

The first commit fixed the WSL command builders. Six spawn sites were left
inheriting the process cwd, which is the same deletable `\\wsl.localhost`
worktree: `wsl-availability` (both probes), the WSL filesystem watcher, the
agent-hook relay launch, the UNC delete, and the local worktree filesystem.

`wsl-availability` is the one that matters most, and it turns the bug into a
latching false negative. `isRetryableWslProbeFailure` returns false for ENOENT,
so a spawn that failed only because the inherited cwd was gone is cached as
"WSL is not installed" on the 10-minute definitive TTL with exponential
backoff up to 30 minutes. Git keeps working and Orca reports WSL unavailable --
worse than the bug being fixed.

ENOENT stays non-retryable. It is answer-shaped for the reason it is meant to
be -- wsl.exe is not on PATH -- and naming the directory is what removes the
one cause that was not. Making it retryable would instead re-probe every
non-WSL Windows machine on the short window, and would leave the false ENOENT
in place for the other five sites, which have no cache to correct.

Three of these are also on the `runWslProcess` W3 migration allowlist; this is
the interim until they move, and matches what #17837 does inside the runner.
2026-09-02 01:39:48 -07:00
Neil b4ba3e97ff perf(worktree): defer fork-PR remote creation from create-time to first use (#17922)
* perf(worktree): defer fork-PR remote creation from create-time to first use

Fork-PR review worktrees eagerly ran `git remote add` + `git fetch` for the
contributor's fork (and pinned branch.<x>.remote) at create time, even for a
read-only review. That grows remote count unboundedly with review volume and
pays a network fetch nobody asked for yet.

Defer prepareWorktreePushTarget(Ssh) and the --set-upstream-to configure step
at create time (local + SSH, IPC + runtime create paths); persist the
pushTarget metadata untouched. Materialize the remote on demand the first
time push/pull/fetch/fast-forward actually needs it, via two shared
functions (materializeWorktreePushTargetRemote(Ssh)) reused across the
legacy IPC handlers and the RPC runtime sync commands. A cheap
`remote get-url <name>` probe keeps steady-state calls down to one extra
subprocess once materialized, instead of repeating the O(remotes) scan.

Add repo-local `remote.<name>.orca-created` config provenance, written when
the remote is added, so cleanup can recognize ownership of a remote that was
lazily materialized (and therefore never round-tripped through the store's
`remoteCreated` flag).

Refs #17828

* perf(worktree): materialize a deferred fork-PR remote on terminal spawn

An agent running raw git in a freshly opened fork-PR review terminal has no
usable upstream until an Orca-driven sync happens -- "sync through Orca
first" isn't available mid-task, and git pull/log @{u}.. hard-fail without
one (verified against real git). Fire the same on-demand materialization
used by push/pull/fetch/fast-forward from the single terminal-spawn
resolver (resolveTerminalWorkspaceLaunchTarget), fire-and-forget, so a
newly opened terminal gets a working upstream without blocking spawn.

* fix(worktree): retest deferred fork-remote CI failures, fix SSH provenance-marker RPC

Rewrites the 5 CI failures on the deferred fork-remote change (#17828) as
evidence, not fixtures: the SSH relay-upgrade/rollback/sibling-ownership
tests move to materializeWorktreePushTargetRemoteSsh, where that
unchanged logic now actually runs (create defers it to first sync).

While writing a stricter test that routes its mock exec through the
relay's real validateGitExecArgs, found that the SSH provenance-marker
write (`git config remote.<name>.orca-created true`) was unconditionally
rejected by the relay's generic git.exec (it blocks all non-read-only
config writes) -- a real bug that would break every SSH fork-remote
materialization against a live relay. Fixes it with a narrow
git.markRemoteOrcaCreated RPC, mirroring renameCurrentBranch, with a
graceful no-op fallback for relays that predate it.

* fix(worktree): scope post-#17887 test assertions past narrow-refspec config calls

Rebasing onto #17887's narrow-refspec `remote add` broke two broad `['config']`
call-filters into false positives/negatives, and the local materialize test still
asserted the pre-#17887 wide `remote add`/fetch-refspec forms.

* fix(worktree): restructure upstream restore, persist provenance, widen short-circuit refspec (#17828 review)

- Move upstream restoration to the materializer level so it runs on both the
  remoteAlreadyMatchesUrl short-circuit and the full-prepare path, not just
  buried inside prepare*.
- Persist {remoteCreated, remoteName} to the store on materialize so #17842's
  orphan sweep can see a lazily-created remote, including via desktop IPC,
  terminal-spawn, and the RPC host-callback paths.
- Widen the refspec on the local short-circuit path too (SSH's bare `remote
  add` refspec gap remains a documented, pre-existing limitation).
- Fetch the branch's tracking ref before restoring upstream when the
  short-circuit widens onto a *new* branch on an already-existing remote --
  a bare refspec-config widen never itself imports anything, so
  `branch --set-upstream-to` was hard-failing for a sibling worktree's first
  materialize (found via a real-git fixture, not just mocked unit tests).
  Skipped when the ref already exists so the common repeat-call case stays a
  local-only probe with no network round-trip.

* fix(worktree): merge duplicate shared/worktree/types import

oxlint --deny-warnings flags the split import as no-duplicates; full pnpm lint
was failing on it after the #17828 review restructuring.

* fix(worktree): scope the deferred fetch timeout to fetch calls, retarget stale create-time assertions

CI on the previous push failed 3 shards, all argument-shape mismatches:

- worktrees-wsl-runtime-routing.test.ts: the "restructure upstream restore" commit
  wrapped every call `prepareWorktreePushTarget` makes (remote, remote add, config,
  fetch) with DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS, not just the network fetch. Local
  git subprocesses never need a timeout; scope it to `args[0] === 'fetch'` only,
  matching the short-circuit path's existing pattern. Updated the test to expect the
  timeout on the fetch call specifically (point 5 legitimately adds it there), while
  every other call stays untimed.

- worktrees-create-metadata-persistence.test.ts (2 tests): stale from before this
  session -- create no longer mints a fork remote at all (#17828 deferred that to
  first sync), so asserting `remote add`/`fetch`/`remoteCreated: true` at create time
  no longer matches reality. Retargeted both tests to assert the deferred contract
  (no remote add at create, pushTarget persisted unmaterialized); minting itself
  stays covered by worktree-remote-push-target-materialization.test.ts and
  worktree-push-target-setup.test.ts.

Re-verified all 5 fixture points (mint upstream, store persistence, single-flight,
short-circuit refspec widen + fetch-missing-ref for local and SSH, finite timeout)
against a real git fixture after this fix -- all still pass.

* fix(worktree): hook pty:spawn into deferred push-target materialization (#17828)

triggerTerminalSpawnPushTargetMaterialization only fired for agent/background/
mobile terminals; the desktop GUI's own pty:spawn path (new tab, split,
reattach) never materialized a deferred fork-PR remote before raw git
commands could run there. Add a small wrapper that resolves the worktree's
push target and owning repo from args.worktreeId via the store, and
fire-and-forget delegates to the existing materializer, wired as the first
statement of runPtyIpcSpawn. Degrades silently (optional chaining + catch)
so a partial/fake Store in existing spawn tests can't turn this into a
spawn-blocking throw.

* test(worktree): retarget stale editor-remote-branch assertions for worktreeId threading

runtime-git-sync-client's local-path fetch/pull/fastForward/push calls now
forward context.worktreeId (needed by the main-process handlers to key
deferred push-target materialization). Update the 17 call-site mocks across
15 tests in editor-remote-branch-actions.test.ts to expect worktreeId: 'wt-1',
matching the already-correct source behavior -- no assertion was loosened.

* fix(worktree): give a materialize joiner its own branch wiring

The materialize single flight is keyed on the remote, but everything after
the remote add is per-branch. A sibling worktree joining an in-flight mint
for a different branch received the minter's target and skipped its own
refspec widen, tracking-ref fetch, and upstream link, so its branch ended
with no upstream at all.

Wait for the remote, then run the per-branch work against the joiner's own
target -- the same path the already-exists short-circuit takes, now shared
rather than duplicated. Adopting a remote a sibling minted also stamps
ownership, so removing the minter cannot strand the survivor's metadata
outside the orphan sweep's reach.

* fix(worktree): stop a failed mint from leaving a config-only fork remote

Review of the joiner fix found it made things worse in three ways.

Swallowing the mint's rejection let a joiner adopt a remote the rollback
had already removed, writing remote.<name>.fetch with no URL. Verified on
real git: that ghost section breaks `git fetch --all`, forces every later
mint to a `-2` name, and cannot be removed by `git remote remove`.
Propagate instead; the in-flight map is already cleared, so a retry
re-mints.

The SSH twin still returned the minter's target to a joiner, so the
original per-branch bug survived there. It now adopts against its own
target through a twin helper.

The ownership stamp was unreachable: it required both a store and a repo
id, and no caller passes both. Derive the repo id from the worktree id.

Adopters also write remote config, and concurrent `git config --add` has
no lock retry -- 135 of 160 writes failed at 8-way concurrency, and equal
values duplicate the refspec. Chain adoptions per remote.
2026-09-01 22:44:05 -07:00
Neil 6c8eea5ebe perf(worktree): fix the prepared-checkout hit rate and make misses visible (#17863) 2026-09-01 20:26:00 -07:00
Neil a7db6c336b perf(git): skip the sparse probe for worktree listings that never read it (#18050)
Three main-process call sites list a repo's worktrees to read `worktree.path`
and nothing else, but went through the annotated listing, so each one paid a
sparse-checkout probe per worktree and cached the result nobody consumed:

- `registered-worktree-roots-cache.ts` rebuilds the filesystem-auth authorized
  roots. `invalidateAuthorizedRootsCache()` fires on every worktree create and
  remove, plus repo add/clone/settings changes, so this reruns constantly.
- `filesystem-source-control-ai-targets.ts` checks whether a local repo owns a
  worktree path.
- `hosted-review.ts` verifies a worktree belongs to the repo before granting
  access.

The probe is an `fs.stat` of the per-worktree `info/sparse-checkout` plus, when
that file is non-empty, a git config read. On a WSL-hosted repo both cross 9p.
#17859 cached it and #17932 keyed that cache on the distro, which fixed a wrong
answer but also meant the distro-less callers above populate a second entry per
worktree — probed cold, revalidated on their own five-minute loop, and read by
nobody. Worktree create/remove clears the sparse cache and dirties the roots
cache together, so both variants go cold at once and the discarded half is
re-probed in full on the next auth check.

`listRepoWorktreeGraph` routes those callers to `listWorktreeGraph`, which
already existed as the annotation-free listing (#17655).

Doing only that would have cost a second `git worktree list`. The scan cache
keys in-flight scans on a `kind`, and graph and lenient were separate kinds, so
a roots rebuild overlapping a sidebar refresh would spawn its own subprocess
where the two previously coalesced. That is a real regression on macOS, Linux
and native Windows, where `getLocalProjectWorktreeGitOptions` returns `{}` and
both callers land on the identical key; on WSL they already differ by distro and
never shared.

So the annotated listing is now the graph listing plus annotation, rather than a
parallel scan of its own: `listWorktrees` awaits `listWorktreeGraph` and
annotates the rows it returns. Both soften a Git failure to `[]`, so they can
share one listing; strict keeps its own because it must be able to reject. The
two kinds ran Git twice before and now run it once, so the overlap case gets
strictly faster instead of paying for the opt-out.

An annotated scan holds two in-flight entries now (its own, plus the graph
listing it shares). Keeping its own entry matters: `detectSparseCheckoutCached`
dedupes revalidation but not the initial fill, so two concurrent badge readers
sharing only the graph scan would both probe.

Per-platform delta:
- macOS/Linux: fewer probes on the three call sites; one `git worktree list`
  instead of two when a graph and an annotated scan overlap.
- native Windows, no WSL: same, and the saved subprocess is the expensive half.
- Windows + WSL: the largest win. The discarded probes were 9p round-trips
  re-paid cold after every worktree create/remove.
- SSH/relay: none. `listRepoWorktreeGraph` returns through the same provider
  branch as `listRepoWorktrees` before reaching local Git.
- folder workspaces: none. Both return the same synthetic folder worktree.

Not in this change:
- The badge listing itself. It still probes, still annotates, and still keys on
  the distro exactly as #17932 left it.
- The remaining `listRepoWorktrees` callers. They read `isSparse`, or feed rows
  to something that does.
2026-09-01 20:19:56 -07:00
Neil d7123591ce perf(git): pack the loose refs Orca's own fetches leave behind (#17857)
* perf(git): pack the loose refs Orca's own fetches leave behind

Orca strips git's auto-maintenance off every fetch it issues
(GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS) and never compensated, so
nothing in an Orca-driven checkout ever packs refs. One real machine
reached 36,574 loose refs, where `git show-ref -- main` costs 5.2s and
every worktree create pays for it.

Add an idle-time, per-repo `git pack-refs --all --prune`, armed by the
fetches that create the debt. It runs only after ten minutes of quiet on
that repo, only above 1000 loose refs (probed with a walk bounded by that
threshold, not by the backlog), one at a time across the whole app, at
the background admission tier, and never while an agent is working, a
create is prepared or in flight, a worktree removal is deleting refs, the
app is quitting, or the machine is on battery. A user who set
`maintenance.auto=false` or `gc.auto=0` has opted out.

Measured on a 36,001-loose-ref fixture (macOS/APFS, git 2.44):
`show-ref` 5.5-12.2s -> 30-49ms, `for-each-ref` 4.0-10.8s -> 43-48ms.

Also fixes a pre-existing bug the split exposed: `--path-format=absolute`
is ignored before git 2.31, and taking rev-parse's stdout raw collapsed
every repo on such a host onto one fetch-serialization key.

Refs #17828

* perf(git): make idle ref maintenance preemptible and cheaper to probe

The idle veto was one-directional: it stopped a pack from starting during
a create, removal, or agent work, but nothing stopped those from starting
during a pack. A user-clicked Fetch, a branch delete, or a worktree
removal that needed `packed-refs.lock` mid-rewrite could fail with
`unable to create packed-refs.lock` -- a git error with no visible cause.

Make the pack cancellable end to end. An AbortSignal now reaches the
`pack-refs` child and both pre-pack probes, and `pause()` aborts what is
running, waits for it to actually stop, and holds a suspension count so
nothing new starts until the caller releases. Every entry point that
deletes a ref takes that pause: gitFetch, gitPull, gitFastForward,
removeWorktree, forceDeleteLocalBranch, prepareWorktreeCreateCheckout,
addWorktree. Five more triggers close the rest of the window: battery
drop, window focus, quit, the attempt deadline, and any other git command
queueing for an admission slot.

Judge a pack by re-probing the backlog rather than by the child's exit
code. Measured in the field: another Orca session moved a branch
mid-pack, git reported `cannot lock ref`, skipped that ref and packed the
rest -- 36,688 loose refs down to 3. On a machine running several
sessions that is the normal case, and retrying it would be wrong.

Probe with one batched `readdir` per directory instead of streaming
`opendir`, which issues a thread-pool round trip every 32 entries: 177ms
-> 23ms on a real 36,600-ref repository, with half the event-loop lag.
The walk stays strictly sequential so it can never occupy more than one
of libuv's four filesystem threads.

`PackRefsLockOwnership` makes a lock left by SIGKILL attributable, and
only reclaims one when a marker exists, the lock is older than any
pack-refs could run for, and the recorded process is gone.

Refs #17828

* fix(git): wait out the packed-refs lock instead of killing the pack

Measured on Git 2.55/APFS with 37k loose refs: a full `pack-refs --all
--prune` takes 23-32s but holds `packed-refs.lock` for only 0.03-1.37s of
it. The other ~95% is the prune phase, during which a concurrent `fetch
--prune`, `branch -D` or `update-ref` succeeds every time -- per-ref locks
last microseconds and git retries for `core.filesRefLockTimeout`.

So the abort-on-everything design was strictly harmful. SIGTERM into the
prune loop strands an empty `refs/**/*.lock` about one time in five
(9/30, 5/40, 6/30 kills): `tempfile.c` opens the lock O_EXCL before
`activate_tempfile()` links it into the list the signal handler walks,
and a pack does ~36k lock cycles. Afterwards `update-ref -d` on that ref
fails with `cannot lock ref ... File exists`, permanently. On Windows
`taskkill /f` never runs git's handlers at all, so an abort inside the
rewrite strands `packed-refs.lock` every time.

Never signal the child. `packRefs` no longer takes an abort signal; it
polls `packed-refs.lock` and reports the window through a
`PackedRefsLockReporter`. `pause()` resolves when the lock is released --
bounded, and free during the prune -- while the suspension counter still
blocks new attempts. Battery and window-focus become do-not-start rather
than stop-what-is-running, and quit waits for the lock and lets the child
finish orphaned.

For strands that already exist, `PackRefsLockOwnership` now also reclaims
`refs/**/*.lock` under the same three conditions plus a 0-byte check, and
a lock carrying our own not-yet-reclaimable marker records `locked` with
a 30min retry instead of the 6h failure cooldown -- so a Windows strand
self-heals in half an hour rather than six.

Reverts the git admission-scheduler event bus, which existed only to
drive the abort this removes.

Refs #17828

* test(git): make the ref-maintenance waits survive a loaded runner

CI shard 4/8 failed on `restarts every armed countdown when the user does
ref work themselves`, which passes locally. The `until()` helper spun a
fixed 200 event-loop turns and then returned silently, so on a contended
runner the filesystem probe had not finished and the assertion that
followed failed with an unrelated message.

Bound the wait by wall clock instead and throw a named error, which
immediately exposed a second latent bug: the single-flight test's second
wait could never succeed, because the deferred repo's retry is on a faked
`setTimeout` that spinning the real loop never advances. It had been
passing only because the old helper gave up quietly. Add a timer-aware
variant for those, and have the countdown test await a signal the fake
pack resolves rather than polling at all.

Verified stable across five sequential runs and once under load average
32 with six concurrent suites.

Refs #17828
2026-09-01 19:06:44 -07:00
Neil e89321192a perf(worktree): batch remote conflict probes, re-arm the prepared checkout (#17829)
* perf(worktree): batch remote conflict probes, re-arm the prepared checkout

A repo with many remotes paid one `git show-ref --verify` subprocess per
remote on every branch-conflict check during create. Ask one
`git cat-file --batch-check` over stdin instead; it reports a missing ref
as data rather than a failed exit, so a batch stays as decidable as the
per-ref probe. Hosts that cannot feed stdin, and undecided batches, still
fall back to the per-ref path.

The prepared checkout was single-use, so the second create in a row paid
the full cold `git worktree add`. Re-arm it in the background after one is
consumed; the existing TTL and preparation limit still bound it.

The create timing recorder existed but its phases were never emitted and
did not cover preflight, leaving a multi-second gap in the trace with no
attribution. Add `resolve_name`/`prepare_push_target` phases and record the
breakdown, plus the unattributed remainder, on the create span.

* fix(worktree): format the conflicting review number eagerly for the create error

* perf(worktree): re-arm a prepared checkout only for a burst of creates

Re-arming after every consumed preparation spends a full checkout and
~200MB of disk on a user who created one worktree and stopped, then pays
an unexplained delete when the TTL expires five minutes later. Track when
each preparation key was last consumed and only replace it when a second
create lands inside the burst window, so the warm second create is still
free and an isolated create costs nothing.

* fix(worktree): address review findings on the create-path batching

Three findings from PR review:

The `batched.found` fallback in the remote-conflict probe was unreachable
— a present ref is decisive, so `found` never survives with `unknown`
set, and the guard above already returns that case.

`rearmPreparation` checked for an existing preparation before recording
the consume, so a prefetch that re-armed the key while create finalized
swallowed the timestamp and made the next create look isolated when it
was really mid-burst.

Create runs some phases concurrently, so summing phase durations
double-counted overlap and understated `unattributed_ms` — the one
number that matters when a create is slow for no visible reason. Measure
the union of the phase intervals instead.

* refactor(worktree): move stale-preparation cleanup into its own module

The preparation module crossed the 300-line budget. Crash recovery is a
separate concern from the pool itself — it discards preparations another
process left registered, single-flighted per repo and runtime so a burst
of arming calls shares one worktree listing.

* test(worktree): make the re-arm test able to fail

The burst test armed a preparation manually after the second consume, so
the third checkout appeared whether or not the re-arm produced it — the
assertion passed with re-arming disabled. Drop that arming call so the
third checkout can only come from the re-arm, and assert the consume
results rather than discarding them.
2026-09-01 19:01:59 -07:00
Neil 26dfa46aa9 fix(wsl): resolve sparse-checkout and diff-stamp gitdirs through the caller's distro (#17932)
Two main-process `resolveGitDir` call sites dropped the WSL distro their caller
already held, so they could only resolve a gitdir pointer whose spelling carries
its own translation: a `//wsl.localhost/<Distro>/...` base, or a `/mnt/<letter>`
drvfs pointer that maps to a drive letter on its own.

The layout that needs the third case is a repo inside the distro's filesystem
with its worktrees on the Windows drive. `git worktree list` reports the worktree
as `/mnt/c/wt/x`, which the listing translates to `C:\wt\x` — a base that no
longer names a distro — while the `.git` gitfile beside it points at
`/home/me/repo/.git/worktrees/x`, which has no drive to derive. Win32 then treats
that pointer as absolute and reads a path that names nothing:

- `detectSparseCheckout` stats `info/sparse-checkout` under the fabricated path,
  always misses, and reports the worktree as non-sparse — no sparse badge, and
  the file list claims files that are not on disk.
- `readWorktreeDiffStamp` reads HEAD under the same path, gets nothing, and
  returns null. Null is the safe answer ("cannot prove unchanged"), but it
  retires the settled-diff cache for every file in that worktree, so each diff
  respawns Git.

Both callers already have the distro: the listing threads its
`GitWorktreeExecOptions` to `annotateSparseCheckoutStatus`, on through
`detectSparseCheckoutCached` (the annotation cache added by #17859) and its
background revalidation probe, and finally to `detectSparseCheckout`; and
`file-diff` already forwards its `GitRuntimeOptions` to `readWorktreeDiffStamp`,
which now forwards it to `resolveGitDir` as well.

`resolveGitMetadataPath` still prefers a UNC base's distro and still tries drvfs
before the caller-named distro, so nothing that resolved before resolves
differently.

The cache hop matters twice over. It is the only remaining caller of
`detectSparseCheckout`, so without threading it the fix would not reach the
probe at all. And the cache is where the bug turns sticky. #17859 keyed entries
on `repoPath` + `worktreePath` alone, on the reasoning that the distro is a
property of the repo and so every read for a given `repoPath` carries the same
one. That invariant does not hold. `listRepoWorktrees(repo)` is called with no
options at all from the filesystem-auth root rebuild
(`registered-worktree-roots-cache.ts`, reached from `ensureAuthorizedRootsCache`
on any auth check with a dirty cache) and from the local worktree-ownership
check in `filesystem-worktree-helpers.ts`. Both land on the *same* key as the
distro-carrying listing, because `translateWslOutputPaths` derives the distro
from the cwd spelling before falling back to `options.wslDistro`, so a
UNC-spelled repo path yields the identical `C:\...` worktree row either way.
Measured on Windows in one process, branch build: a distro-less read followed by
a distro-carrying read reported the sparse worktree as non-sparse both times.

So `wslDistro` now joins the cache key -- trimmed and lowercased, matching how
the rest of the codebase compares distro names, and appended last so the
repo-scoped prefix delete still matches every variant. The per-path invalidate
becomes a prefix delete for the same reason, dropping every distro variant of a
removed or moved worktree.

Keying on it closes both halves of the defect. A correct caller can no longer be
served an answer derived without the distro it supplied. And because the entry a
reader reaches is now selected by the same distro it would re-probe with,
`revalidateInBackground` can no longer re-derive a warm entry under weaker
options -- which mattered on its own: a distro-less reader crossing the
five-minute window would otherwise flip a correct `true` to `false`, and the
resulting change notification runs the registered invalidator, clearing the
whole repo's cache and re-probing every worktree cold, on a five-minute loop.

Cost of the extra key dimension is bounded by the number of distinct distros a
given repo is actually read under: one where a distro is threaded everywhere,
two while the distro-less callers above still exist. Entries are still
repo-scoped, and both clears already sweep by prefix.

Per-platform delta:
- macOS/Linux: no change. Guest-pointer translation is gated to win32 and a
  caller-named distro is ignored off Windows; no caller supplies one there, so
  the cache keys and probes exactly as before.
- native Windows, no WSL: no change. `wslDistro` is undefined, so the resolver
  takes exactly the branches it took before and every read keys on the same
  empty distro component, so the cache behaves exactly as it did.
- Windows + WSL, UNC-spelled worktree: no change. The base already names the
  distro and outranks the caller's.
- Windows + WSL, drvfs-spelled worktree with a drvfs pointer: no change. The
  drive-letter derivation still runs first.
- Windows + WSL, drvfs-spelled worktree with a non-drvfs pointer: the sparse
  badge appears and the settled-diff cache starts hitting. Both previously
  failed toward "not sparse" / "do not cache", so neither can now serve a stale
  answer, and the distro-less listings no longer share the badge's cache entry.
- SSH/relay: none. Those paths return through the provider branch before
  reaching either function.
- folder workspaces, GitLab: none. Neither is on these code paths.

Not in this change:
- `readRepoCommonDirFromDisk` (worktree-listing). Passing the distro there is
  inert: a repo root's `.git` is a directory, so the gitfile-pointer branch never
  runs, and when `repoPath` itself is guest-spelled the preceding `stat` already
  fails — which no `resolveGitDir` option can fix.
- The two `findExistingWorktreeSymlinkPaths` calls on the removal paths. Both
  receive `registeredWorktree.path` from `listWorktreesStrict`, which already
  translates every row out of the guest namespace, so the distro would be a
  no-op. The `removeWorktreeLinkedPaths` unlink beside them is untranslated too,
  so a half-threaded fix would only move the refusal from Orca's preflight to
  `git worktree remove`.
- An absolute `commondir` payload, which `resolveGitCommonDir` still resolves
  untranslated. Git writes that file relative in the layouts above, and the
  failure direction is unchanged.
- Giving the two distro-less `listRepoWorktrees(repo)` callers a distro. Neither
  reads `isSparse` -- both use only `worktree.path` -- so the distro would buy
  them nothing they consume, while resolving a project runtime inside the
  filesystem-auth rebuild would put a call that throws on `repair-required`
  behind a catch that skips the whole repo's authorized roots. The cache key
  makes their reads harmless; skipping the annotation for callers that never
  read it is a separate, larger change. The third no-options call in
  `hosted-review.ts` is inside the `repo.connectionId` branch and returns
  through the SSH provider, so it never reaches this cache.
2026-09-01 04:25:19 -07:00
Neil f176e49478 fix(git): narrow fork-remote fetch refspecs to tracked branches (#17887)
* fix(git): narrow fork-remote fetch refspecs to tracked branches

git remote add with no -t writes the wide +refs/heads/*:refs/remotes/<name>/*
refspec, so any later plain `git fetch` (user, agent, or Orca's own Fetch
action) re-imports a fork's entire branch set and its tags -- one real
machine had ~50 leaked/wide fork remotes producing 59,716 remote-tracking
refs. Mint and reuse now pin -t <branch> --no-tags; a rate-limited sweep
narrows and cleans up remotes minted before this fix; gitFetch self-heals
when a narrowed remote's tracked branch is later deleted upstream.

Refs #17828

* fix(git): soften narrow fork-remote refspec against deleted upstream branches

A bare `git fetch` in a worktree checked out on a fork-PR branch resolves to
the pr-* remote via branch.<name>.remote -- not origin -- making it the
dominant fetch shape in Orca's terminal-centric, agent-driven usage. The
previous literal-refspec design hard-failed that fetch ("couldn't find
remote ref") the moment the tracked branch was deleted/renamed upstream,
which is not the narrow edge case it was first described as.

Switch to a trailing-`*`-suffixed refspec source/destination
(refs/heads/<branch>*:refs/remotes/<name>/<branch>*). Verified against real
git: this restores wildcard zero-match tolerance (silent no-op instead of a
hard failure) and lets plain `git fetch --prune` reclaim the stale ref once
the branch disappears, at the cost of also matching sibling branches that
share the literal name as a prefix -- a materially smaller widening than the
original unbounded-import bug.

Also close a race with #17842's orphaned-pr-remote reconciliation sweep:
both sweeps read the same worktree-metadata store to pick candidate remotes,
so reconciliation can `remote remove` a remote this migration is
concurrently narrowing. `ensureRemoteTracksBranchNarrowly`'s plain `config
--add` would silently resurrect a url-less config section in that case;
re-check `remote.<name>.url` (via the new `remoteHasUrl`, plumbing rather
than porcelain `remote get-url`, which falls back to echoing the remote name
as a bogus URL) after the narrowing writes and remove the section if it's
gone.

* fix(git): update stale fork-remote mint assertions for -t/--no-tags and wildcard-suffix refspec

Four test files still asserted the pre-#17828 remote-add shape or the
literal (non-wildcard-suffixed) fetch refspec from before the
deleted-upstream-branch softening commit, so CI went red on that HEAD:

- worktree-push-target-refspec-real-git.test.ts: the migration fixture
  asserted a hardcoded tracked-ref count before narrowing. Under git
  >= 2.44, `followRemoteHEAD` auto-creates a `refs/remotes/<name>/HEAD`
  symref on the first fetch matching the full wildcard refspec, adding
  one untracked ref. Made the count/assertions robust to that ref's
  presence instead of hand-tuning the constant per git version.
- worktrees-wsl-runtime-routing.test.ts: assertions predated both the
  `-t <branch> --no-tags` mint change and the wildcard-suffix refspec
  change; updated to the full, correct call sequence and confirmed the
  WSL routing options (cwd, wslDistro) are threaded to every call.
- worktrees-create-metadata-persistence.test.ts and
  orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts: same
  class of staleness, found via CI job log cross-referencing rather
  than being explicitly flagged.

Verified out of scope: the SSH fork-remote mint path
(prepareWorktreePushTargetSsh) is untouched by this PR -- it never
persists a `remote.<name>.fetch` refspec at all, using
provider.fetchRemoteTrackingRef for a targeted per-branch fetch
instead -- so worktrees-ssh-fork-push-target-remote.test.ts needed no
change.

* fix(git): migrate pr-* remotes with zero worktree-metadata trace too

The migration sweep's candidate discovery was purely metadata-driven
(store.getAllWorktreeMeta()), so a pr-* remote whose every referencing
worktree was removed outside preserve-on-delete (metadata purged, not
just the worktree) was permanently invisible to it and stayed on the
wide default forever.

Field data from a manual migration run against a real user's repo (31
pr-* remotes, 34,637 tracking refs, only 18 actually needed) found
exactly this: 15 of 31 remotes had no branch pinning them at all.

Widen discovery to every pr-* remote git reports on disk, in addition
to metadata-derived candidates. For a remote with no branch provenance
from either metadata or surviving branch.*.remote/.pushRemote config,
there's nothing to narrow *to* -- clear its fetch refspec entirely
instead (stays pushable, imports nothing on a plain fetch), gated on
it still carrying the untouched stock wide default so a user's own
custom pr-*-named remote isn't touched. Removing the remote outright
stays #17842's job.

Adds clearForkRemoteFetchRefspec (fork-remote-refspec.ts), 3 new
mocked-exec tests, and a real-git integration test proving a
subsequent plain `git fetch` on the cleared remote imports nothing.
2026-09-01 03:45:09 -07:00
Neil 84584b61d0 perf(git): cache sparse-checkout annotation on worktree listing (#17859)
* perf(git): cache sparse-checkout annotation on worktree listing

`git worktree list` never reports sparse-checkout state, so every listing paid a
per-worktree fs.stat + config read to detect it -- measured at ~9x the cost of
the `git worktree list` call it decorates on a 1000-worktree repo. Cache the
result per worktree path, invalidated by the existing worktree-change
invalidator registry plus explicit remove/move hooks, with a 5-minute
reconcile window bounding the one unwitnessed edge case (external
`git sparse-checkout` toggle with extensions.worktreeConfig off), matching the
precedent already accepted in readRepoWorktreeAdminFingerprint.

* perf(git): normalize/scope sparse-checkout cache keys, add SWR

Address independent-review follow-ups on the sparse-checkout annotation
cache (#17859):

- Extract canonicalWorktreePath() from areWorktreePathsEqual and key/invalidate
  the cache through it on both read and write, closing the disclosed
  path-spelling P2 outright instead of leaving it as a residual risk.
- Scope cache entries and clears by repo path (derived from the invalidator
  registry's repoId via a store lookup, falling back to a full clear when the
  repo can't be resolved), so churn in one repo no longer evicts a sibling
  repo's warm cache.
- Replace the hard 5-minute cutoff with stale-while-revalidate: past the
  window, callers get the cached value immediately while a deduplicated
  background probe corrects it and, on a flip, drives the existing
  worktrees-changed notification -- collapsing visible staleness from the
  full window to one refresh cycle at zero added listing latency.

Also corrects a stale claim in the original PR description: newer Git does
emit a `sparse` porcelain line (which annotateSparseCheckoutStatus already
skips), but Orca's Git 2.25 compatibility baseline predates it, so the
fallback detection this caches remains necessary.

* fix(git): stop background sparse-checkout revalidation resurrecting invalidated entries

Readiness-loop finding: a stale-while-revalidate probe in flight when a
worktree is removed/moved (or a repo's cache is cleared) would still write
its result back afterward, resurrecting an entry that was deliberately
dropped. Guard the write with a presence check so an invalidated key stays
absent until the next real read.

* fix(git): identity-check the sparse-checkout SWR write-back guard

The has()/presence guard from the previous commit only proved some
entry existed at the key, not that it was the one this revalidation
started from. A worktree removed and re-created at the same path while
a background re-detect was in flight would repopulate the key with a
fresh cold read, and the stale in-flight result would then overwrite
it -- exactly the race greptile (P1) and pullfrog both flagged as
still open. Compare the map's current entry by reference to the entry
captured when the revalidation began; a mismatch means something else
(invalidate, clear, or a fresh cold read) replaced it, and the stale
result must not be written back.

Added a regression test that fails against the old has() guard and
passes with the identity check: invalidate and repopulate the key with
a different value mid-flight, then let the stale revalidation settle
and assert the fresh value survives.
2026-09-01 03:44:50 -07:00
Neil 9542b45d99 fix(wsl): resolve conflict and working-tree probes in the host path namespace (#17895)
Git running inside a WSL distro writes `.git` gitdir pointers, and answers
`status --porcelain`, in the guest namespace. Node reads both back in the
Windows main process, where `/mnt/c/repo/.git` resolves to `C:\mnt\c\repo\.git`
and `/home/me/wt` names nothing at all. Four fs probes were built on those
fabricated paths and always came back "absent":

- `detectConflictOperation`'s four marker probes, so merge/rebase/cherry-pick
  badges silently went missing.
- `parseUnmergedEntry`'s compat existence check, so every `deleted_by_us` /
  `added_by_them` conflict rendered as 'deleted' regardless of the working tree.
- `findExistingWorktreeSymlinkPaths`' `lstat` from status, so Orca's own shared
  symlinks (node_modules and friends) showed as user changes.
- the same `lstat` from the hosted-review dirty preflight, which fails closed:
  an unreadable shared symlink read as uncommitted work and blocked PR/MR
  creation outright.

`resolveGitDir` computes the host spelling of the worktree once and uses it for
both the gitfile read and the pointer resolve, so a guest-spelled worktree path
is reached at all, and a relative pointer (`worktree.useRelativePaths`, git
2.48+) resolves against a spelling Win32 understands. The pointer itself now
goes through the already-landed `resolveGitMetadataPath`, and the function gains
an optional `{ wslDistro }` for a caller whose base path does not encode a
distro. `detectConflictOperation` forwards it, and the three callers that reach
it -- status-read, the runtime RPC, the `git:conflictOperation` IPC -- pass the
git options they already hold. The return type stays `Promise<string>`.

`resolveWorktreeHostPath` is the same rule applied to a worktree path, used by
status-read for the two working-tree probes and by the review preflight. Both it
and `resolveGitMetadataPath` now treat only a single-leading-slash path as guest
namespace: `//wsl.localhost/...` is already a host UNC spelling, and translating
it prepended a second share prefix.

`readWorktreeDiffStamp` needed the same one-namespace guarantee, since moving
translation inside `resolveGitDir` would otherwise make its HEAD and index real
while the working-tree stat stayed fabricated, letting a settled diff survive
every edit. #17896 landed that change first, so it is no longer in this diff;
its version is a superset and all four components already resolve from one
`hostWorktreePath`. What remains here is the `resolveGitDir` gitfile-pointer
fix that #17896 explicitly deferred, which `worktree-diff-stamp-host-paths.test.ts`
pins.

`getConflictCompatibilityStatus` moves from `existsSync` to async `access`, for
the same reason `detectConflictOperation` did: once these paths are real they
are `\\wsl.localhost\...` shares, and a sync probe per asymmetric conflict
blocks the Electron main thread for a 9p round trip on every status poll.

Per-platform delta:
- native Windows, no WSL: no behavioral change. Nothing here starts with a
  single `/`, so no path is translated. An absolute pointer is now returned
  verbatim rather than separator-normalized; every consumer re-joins or
  normalizes it before use.
- macOS/Linux: no change. Guest-pointer translation is gated to win32, and a
  caller-named distro is ignored off Windows.
- Windows + WSL: drvfs pointers and drvfs-spelled worktrees now resolve to their
  drive spelling instead of `C:\mnt\...`; a non-drvfs guest path resolves
  through the named distro's UNC share, or stays verbatim (ENOENT -> existing
  fail-safe) when none is named.
- SSH/relay: none. Those paths return before any of this via the provider
  branch; `src/relay/git-handler-status-ops.ts` keeps its own resolveGitDir.
- folder workspaces, GitLab: none. Neither is on these code paths.
2026-09-01 03:17:34 -07:00
NeilandNeil dff2ff0ec3 fix(git): read the diff working tree and stamp through the host path spelling (#17896)
Git can execute inside a WSL distro against a raw Linux worktree path while Node,
on the Windows side, reads the same files back through Win32. `path.join(
'/home/me/repo/feature', 'src/file.ts')` on win32 produces the drive-relative
`\home\me\repo\feature\src\file.ts`, which resolves against whatever the current
drive happens to be and almost always ENOENTs. The same mis-spelling hits the
drvfs form, where `/mnt/c/repo` should read as `C:\repo`.

Two consequences, both on the Node side only (git already works, because it gets
the Linux path as its cwd and resolves it inside the distro):

- getDiff's unstaged working-tree read missed, `readWorkingTreeFile` mapped ENOENT
  to `exists: false`, and an existing file rendered as DELETED in the diff view.
- `readWorktreeDiffStamp` could not find `.git`, so the stamp was null, the settled
  diff cache neither hit nor stored, and every diff respawned `git show` - two
  `wsl.exe` spawns the cache exists specifically to avoid.

Both now spell the worktree directory for the reading host first, via a new
`resolveWorktreeHostPath` wrapper around the resolver that landed in #17804.
The wrapper exists because `resolveGitMetadataPath` trims: a gitfile payload
carries a trailing newline, but a directory name may legally begin or end with
whitespace on POSIX, so the wrapper keeps the caller's spelling whenever the
resolver only trimmed it. The stamp's opaque `value` still embeds the caller's
original `worktreePath`, so settled-cache identity is byte-identical and no cache
key moves.

`readWorktreeDiffStamp` was already `Promise<WorktreeDiffStamp | null>` with one
caller that treats null as a cache miss, so no new nullability enters the type
system and the resolver's never-null-for-a-non-empty-pointer contract is
untouched. The only unspellable input is an empty worktree path, handled locally
as "not provably unchanged" in the stamp and as a read *failure* (not a proven
deletion) in file-diff.

What changes for users

| Platform | Delta |
|---|---|
| macOS | No change. An absolute POSIX path is returned verbatim, including one whose directory name carries leading or trailing whitespace. |
| Linux | No change. Same reason. |
| Native Windows (no WSL) | No change. A `C:\...` or `\\server\share\...` path is already absolute for win32 and passes through verbatim. |
| Windows + WSL, UNC worktree path (`\\wsl.localhost\Ubuntu\...`) | No change. Already absolute for win32; passes through verbatim. This is today's common case. |
| Windows + WSL, drvfs worktree path (`/mnt/c/repo`) | Fixed. Reads as `C:\repo` instead of the drive-relative `\mnt\c\repo`. Needs no distro name. |
| Windows + WSL, Linux worktree path with a named distro (`/home/me/repo`) | Fixed. Reads as `\\wsl.localhost\Ubuntu\home\me\repo`. The deleted-file misrender goes away and the diff cache starts hitting. |
| Windows, POSIX path, no distro and not a drvfs mount | No change. Passes through verbatim, same ENOENT, same existing fallback. |
| SSH | No change. `runtime-git-diff-commands.ts` and the `git:diff` IPC both route to `provider.getDiff` for a connection, so this local code is never reached. |
| Relay / remote | No change. No RPC param, wire field, stream opcode, or published content is touched; the relay host runs the same local code and gets the same fix. |
| Folder workspace (non-git) | No change. `.git` is absent either way, `resolveGitDir` returns the same fallback, and the stamp stays null exactly as today. |
| GitLab / other providers | Not applicable. No provider-specific or review code is touched. |

What this does NOT do

- It does not fix `resolveGitDir` itself. For a drvfs repo whose worktree Orca
  already spells `C:\repo\feature`, the gitfile payload `gitdir: /mnt/c/repo/.git/
  worktrees/feature` is still mis-resolved by `path.resolve` to
  `C:\mnt\c\repo\.git\...`, so the stamp still returns null in that shape. Separate
  change, separate PR; this one neither fixes nor regresses it.
- It does not touch submodule path resolution. `resolveSubmoduleWorktreePath` is
  the path-escape guard and has a near-identical twin in the relay; changing it
  without escape tests on both is out of scope.
- It does not change `readHeadComponent`'s `commondir` resolution. The relative
  `../..` git actually writes takes the identical `path.resolve` branch, and an
  absolute POSIX `commondir` under a WSL UNC `gitDir` already resolves correctly
  because the UNC root is `\\wsl.localhost\<distro>\`.
- It does not reorder drvfs-before-UNC inside the shared resolver. That changes the
  identity of returned strings and needs a real Windows+WSL box.
- It does not add any Git command, option, or version dependency.

Costs and residual risk

- One extra pure function call per diff read. No I/O added or removed on the
  unaffected paths.
- Translation still trims. `resolveWorktreeHostPath` preserves whitespace only when
  no translation happened; a guest directory named `/home/me/repo ` loses its
  trailing space on a Windows reader. Reachable only on win32, where such a name is
  not addressable anyway, and the previous behavior for that shape was a
  drive-relative miss.
- A relative worktree path (no caller passes one) is now resolved against the
  process cwd instead of joined relative to it. Same file in every case except a
  relative name that itself ends in whitespace.
- `UNSPELLABLE_WORKING_TREE_READ`'s `exists`/`failed` fields are correct but not
  observable today: the stamp is null for the same input, so nothing can be cached
  and `reusable` cannot be read back. They are there so the branch stays right if
  `loadDiff` ever gains a second caller. The test pins the observable part - that no
  read lands on a cwd-relative path.
- Every test here mocks `node:fs/promises` and spoofs `process.platform`. They prove
  which path string reaches `stat`/`readFile`, which is the right assertion, but
  none of this has executed against a real 9p mount on a Windows+WSL box and this
  repo's CI has no such runner.
- Honest framing of the trigger: I could not demonstrate a mainline path that hands
  `getDiff` an untranslated POSIX worktree path on Windows today -
  `translateWslOutputPaths` UNC-translates worktree paths whenever a distro is
  known, `getWslHome` returns the UNC spelling, and `resolveWslRepoWorktreeBasePath`
  normalizes a configured Linux base. The drvfs case is the most plausible live one.
  Treat this as defense-in-depth that is a strict no-op on every configuration above
  except the two marked Fixed.

Verification

- `npx vitest run src/main/git src/shared/git-metadata-path.test.ts` -> 196 files /
  2241 tests passed, 2 files and 5 tests skipped. One failure,
  `git-admission-storm-measurement.test.ts > reports bounded-concurrency before and
  after measurements` (ENOENT scandir on its own temp state dir), is pre-existing
  and environmental: it fails identically in isolation and spawns real git children
  without touching any changed module.
- `npx vitest run src/main/git/status-diff-settled-cache.test.ts` -> 21/21 (16
  pre-existing, 5 new). `npx vitest run src/shared/git-metadata-path.test.ts` ->
  25/25 (19 pre-existing, 6 new cases across 3 new tests).
- `npx oxfmt --write` then `npx oxlint` on all five changed files -> clean.

Mutation checks - all eight production substitutions were reverted one at a time
and the suite re-run. Each fails at least one test, and no new test survives its
own mutation:

| Reverted | Failing test |
|---|---|
| file-diff working-tree read -> `worktreePath` | reads the working tree through the host spelling instead of reporting a deletion; invalidates when the working tree file is edited under the host spelling |
| stamp working-tree component -> `worktreePath` | invalidates when the working tree file is edited under the host spelling |
| stamp `.gitmodules` stat -> `worktreePath` | invalidates when .gitmodules appears under the host spelling |
| stamp `resolveGitDir` -> `worktreePath` | stamps through the host spelling so the second read does not respawn git |
| `options` threading at the `readWorktreeDiffStamp` call | stamps through the host spelling...; invalidates when .gitmodules appears... |
| wrapper's untrimmed preservation -> return the resolver's value | keeps whitespace that belongs to the directory name (both cases) |
| `UNSPELLABLE_WORKING_TREE_READ` -> a cwd-relative `readWorkingTreeFile` | reads nothing relative to the cwd when the worktree path has no host spelling |
| stamp's null early return -> `hostWorktreePath ?? worktreePath` | reads nothing relative to the cwd when the worktree path has no host spelling |

The settled-cache tests seed the fake filesystem through the platform-bound `path`
module rather than `path.win32`, so they assert real behavior on a POSIX CI host as
well as on Windows and are not gated on the host platform.

Co-authored-by: Neil <79079362+brennanb2025@users.noreply.github.com>
2026-09-01 02:39:48 -07:00
NeilandNeil e4a708da17 fix(wsl): read untracked line counts through the host's worktree spelling (#17897)
Untracked line counts, and the untracked share of the branch line total, come
from direct lstat/open calls rather than from git. When git executes inside a
WSL distro the worktree path can be a guest path, which Win32 reads as
drive-relative (`/home/me/repo`) or as a literal `C:\mnt\c\repo`. Every lstat
then fails, countFileAdditions swallows the error into `{}`, the file renders
with no +N, and the branch total silently undercounts it.

Route only those two filesystem reads through resolveWorktreeFilesystemPath,
which translates a guest-rooted path via the shared resolveGitMetadataPath.
Both call sites produce the same string, so the stat-keyed untracked cache
still hits across them. resolveGitMetadataPath itself is not modified, so its
other callers are untouched.

The wrapper translates only when the host is win32 and the path is a guest
spelling: exactly one leading slash, and unchanged by trim(). Each condition
is load-bearing.

- `//wsl.localhost/...` and `//wsl$/...` are UNC spellings that also start with
  `/`, and translating one prepends the distro root a second time, so a
  worktree that reads fine today would ENOENT on every untracked file. The same
  single-leading-slash guard is used, for the same reason, by
  resolveWslRepoWorktreeBasePath in src/shared/wsl-paths.ts.
- resolveGitMetadataPath returns `rawPath.trim()`, so a worktree directory name
  with leading or trailing whitespace (legal on ext4) would be re-spelt onto a
  different directory. Leaving those verbatim keeps them exactly as they read
  today.
- Off win32 the resolver is already an identity for these inputs; the platform
  check makes the macOS/Linux no-op structural rather than derived.

Per platform:
- Windows + WSL, worktree spelled as a guest path: untracked +N appears and the
  branch total includes it.
- Windows + WSL, worktree already spelled `\\wsl.localhost\...`,
  `//wsl.localhost/...` or `//wsl$/...`: unchanged, returned verbatim.
- Native Windows: `C:\...` is unchanged. One shape does change: a
  `/mnt/<drive>/...` worktree now resolves to `<Drive>:\...` instead of being
  passed through to a guaranteed lstat failure. Native Windows does not produce
  that spelling, and if it were reached the new result is the correct file.
- macOS / Linux: unchanged; not win32, returned verbatim, whitespace included.
- SSH / relay: unchanged. Remote status runs in the relay, which builds its own
  branch-total input and does not pass filesystemWorktreePath.
- Folder workspaces / GitLab: unaffected, no workspace-kind or provider
  behavior is touched.

No fail-closed degradation: attachLineStats still returns
`stagedStats !== null && unstagedStats !== null`, and createBranchLineTotalInput
still has no early return. The wrapper returns `string`, never null, so an
unmappable worktree keeps its old spelling and its old (missing) untracked
counts rather than dropping the staged/unstaged counts as well.

The `filesystemWorktreePath` field on computeGitBranchLineTotal is optional and
does not touch the lease key, so the coalescing/cooldown identity is unchanged.

Co-authored-by: Neil <neil@orca.local>
2026-09-01 02:39:31 -07:00
Neil a9babde9a3 refactor(git): accept a caller-named WSL distro on the metadata path resolver (#17804)
Two small changes to the Git metadata read path. Neither has a user-visible
effect on any platform except for a malformed `.git` gitfile, described below.

1. resolveGitMetadataPath's third parameter becomes an options object
   `{ platform?, wslDistro? }`. A caller that knows which distro wrote a pointer
   can now say so, where previously only a WSL UNC base path could. The distro
   encoded in the base path still outranks the caller's, and translation only
   happens when the reading host is win32, so a caller-named distro cannot make
   a POSIX host fabricate a Windows path. The UNC-base branch is exempt from
   that gate because that spelling only exists on Windows. Main's other
   contracts are verbatim: never null for a non-empty pointer, and a drvfs
   pointer keeps its drive spelling even when a distro is named. Both production
   call sites (repo-git-marker-scan.ts) pass no options, so they are unchanged.

2. The `.git` gitfile marker parse moves into one shared function,
   parseGitdirMarkerPayload: `gitdir:` at the start of the file, payload
   trimmed, empty payload rejected — git's own read_gitfile_gently rule.
   resolve-git-dir.ts and repo-git-marker-scan.ts both call it; the latter had a
   near-identical private copy and is behaviorally identical after the swap
   (verified across twelve marker spellings; the only divergence, a
   whitespace-only payload, already resolved to null one call further down).
   Main's `/^gitdir:\s*(.+)\s*$/m` in resolve-git-dir captured trailing padding
   into the path and honored a `gitdir:` line anywhere in the file.

Per-platform delta: none on macOS, Linux, native Windows, WSL, SSH, relay, or
folder workspaces. The wslDistro option is inert; this change adds no caller.
For a malformed `.git` gitfile, padding is now stripped (strict improvement), a
whitespace-only payload falls back to `<worktree>/.git`, and a `gitdir:` line
that is not the first line is no longer honored — a narrowing, since main could
return a working gitdir there. All four resolveGitDir consumers already degrade
through a catch, so that case reports no sparse state / conflict operation /
diff stamp rather than failing.

Six other hand-rolled `gitdir:` parsers remain, including the relay's SSH copy;
converging them is its own change.
2026-08-31 22:37:09 -07:00
Neil a4cd00ed18 fix(wsl): drop the linked-worktree Git route cache after worktree mutations (#17791)
On Windows with a WSL distro configured, `prepareWslLinkedWorktreeGitRouting`
caches for 30s which Git owns a drive-letter checkout, by reading that checkout's
`.git` marker. `git worktree add/move/remove` rewrites exactly that marker, so
the verdict could stay authoritative for up to 30s after it stopped being true.

- Add `invalidateWslLinkedWorktreeGitRouting(cwd)`: drops the cached route and
  the probe retry backoff for that path and for anything under it (a submodule
  inside the worktree derived its route from the same marker walk). Eight calls
  at six sites: `worktree add`, `worktree move` (both paths), `worktree remove`,
  the prepared checkout's add, the finalize move (both paths), and the
  prepared-worktree discard. Five sites invalidate from a `finally`, because a
  Git failure can still have rewritten the marker; the prepared checkout's add
  invalidates on the success path only, since its failure path runs the discard,
  which has its own `finally`.
- Split the parent-directory marker walk into
  `wsl-linked-worktree-git-route-probe.ts` (the routing module was at the
  `max-lines` ceiling) and have it report whether the walk settled. A `.git`
  file with no `gitdir:` line is a half-written marker mid `worktree add`: it is
  now retried under the existing backoff instead of cached for 30s. The route it
  yields is unchanged (distro); only the number of parent walks changes.

An invalidation only drops cached state; a probe already in flight is left to
finish and cache normally, so a mutation landing mid-probe is no worse off than
main's 30s TTL. The cost is that a failed mutation also drops a still-correct
route: `gitExecFileAsync` and `gitStreamStdout` re-resolve the route after their
own `prepareWslLinkedWorktreeGitRouting`, and `gitSpawn` resolves again after the
git-admission wait, so a command already in flight for that path can take the
empty-cache default and run a host-owned checkout under `wsl.exe git`. It fails
once and self-heals on the next command.

Reachable only on win32 + configured WSL distro + drive-letter cwd; every other
platform and configuration never populates this cache, so the new calls scan two
empty maps.
2026-08-31 20:35:49 -07:00
Neil abc099e4c7 fix(worktree): run the create-base warm-up on the routed git host (#17794)
The speculative warm-up that runs while the create composer is open resolved
refs and fetched with host Git even when the project's runtime is a WSL distro,
while both the checkout preparation it feeds (`prepareWorktreeCreateForRepo`,
which already resolves `{ wslDistro }` itself) and the real create path run
inside the distro.

The concrete cost was a discarded fetch: `getCanonicalFetchKey` namespaces the
runtime's remote-fetch cache `wsl:<distro>` vs `local`, so the warm-up's fetch
landed in a namespace create never looks at, and create fetched again. On a
Windows host with no usable host-side Git the probes also failed outright, so
that cohort got no warm-up at all.

Thread the project's worktree Git options through the prefetch (resolved by a
non-throwing helper, because an optimistic warm-up must not surface a
repair-required runtime as a failure) so every probe and fetch runs where create
runs. `gitOptions` is a required argument, so a caller cannot drop the routing
silently. Host-routed calls keep their original arity, so macOS, Linux,
native-Windows-host projects, SSH repos and folder workspaces are unchanged.

Narrower than it looks: for a repo under \\wsl.localhost\<distro>\... the probes
were already routed by cwd, and for a repo on a Windows drive letter host Git
and WSL Git read the same on-disk repository, so the answers were already
correct there. What those cohorts gain is a fetch create can reuse; what they
pay is that the probes now run inside the distro (over /mnt/c for drive-letter
repos, which also newly arms the linked-worktree routing probe) and the
speculative fetch now shares create's per-remote fetch queue, as it always has
on native platforms.

Also collapse the three byte-equivalent copies of `hasLocalWorktreeBaseRef`
(create, prefetch, remote-repo create) into one in
git/worktree-base-ref-probe.ts, drop the host-only `hasLocalCommitObject` that
caused the routing bug, and add the first routing assertions on the create-path
consumers of the now-shared probe.
2026-08-31 20:32:59 -07:00
NeilandNeil 7f63db7d7a fix(git): resolve WSL drvfs Git metadata pointers on a Windows host (#17790)
When Orca's runtime is a WSL distro but the repo sits on a Windows drive, git
inside the distro writes `/mnt/c/...` into a worktree's `.git` gitfile and its
`commondir`, while Orca reads those files back through Win32.
`repo-git-marker-scan` returned the pointer verbatim, Windows read it as
drive-relative `C:\mnt\c\...`, and the worktree was reported `invalid`.

Move that resolver out of `repo-git-marker-scan` into
`src/shared/git-metadata-path.ts` and give it exactly one new case: on win32, a
drvfs pointer resolved against a base path that is not a WSL UNC path now gets
its drive spelling. Every other base/pointer/platform combination is
byte-identical to the deleted helper, verified differentially across a
base x pointer x platform matrix — macOS, Linux and native Windows are unchanged.

`toWindowsWslDrivePath` is factored out of `toWindowsWslPath` so the drvfs
matcher has one home; `toWindowsWslPath` itself is unchanged for all inputs,
including the line terminators JS `.` excludes (fuzzed 2M inputs, 0 divergences).

This changes the marker scan's verdict only. `resolve-git-dir.ts` and the relay's
own copy still `path.resolve` the same `/mnt/c/...` pointer in the Win32
namespace, so a worktree that is now accepted still degrades quietly in conflict
detection, sparse-checkout detection, the diff stamp and worktree listing. Those
parsers are deliberately untouched here; see the PR description.

Co-authored-by: Neil <neil@example.com>
2026-08-31 20:32:46 -07:00
NeilandNeil ad760c8b92 fix(worktree): reject Windows drive-qualified shared paths in the symlink guard (#17793)
getSafeRelativePath strips leading `/` and `\` before testing absoluteness, so
the only rooted spelling that can still reach the guard is a Windows drive
designator. It tested that with the host `path.isAbsolute`, which left two gaps:
the drive-absolute form `C:/payload` was refused on Windows but admitted as an
ordinary relative filename on macOS/Linux, and the drive-RELATIVE form
`C:payload` was admitted on every host including Windows, where
`win32.resolve(root, 'C:payload')` discards the worktree root and lands under
C:'s current directory. That holds for a drive root (`D:\wt`) and for the
`\\wsl.localhost\<Distro>\...` UNC root a WSL project uses, both verified.

The value reaches the guard from two configs: the per-user Worktree Shared Paths
setting alone on the create path (createWorktreeLinkedPaths, called with
`repo.symlinkPaths` from orca-runtime.ts:27820 and worktree-remote.ts:2636), and
that setting merged with the repo's checked-in `orca.yaml`
`worktree.sharedDirectories` on the removal and detection paths
(getWorktreeSharedLinkPaths). No repo config is required to reach it.

Replace both `isAbsolute` calls with a `/^[a-zA-Z]:/` test, verified by fuzz to
be a strict superset of `posix.isAbsolute || win32.isAbsolute` for every
post-strip input. No filesystem escape is closed on macOS/Linux, where such an
entry resolves to a literal in-worktree filename.

Cost: on POSIX, `:` is a legal filename character, so a shared/linked path whose
first segment is `<letter>:...` is now refused where it previously worked — it
stops being created, and if a worktree already holds an Orca-created symlink
there it stops being excluded from the untracked-file filters in all four
findExistingWorktreeSymlinkPaths callers, which means a refused non-force
worktree removal (remove-registered-local-worktree.ts:91, orca-runtime.ts:30205),
a phantom untracked row in Source Control (status-read.ts:90), and a blocked
hosted-review creation (hosted-review-creation-git-state.ts:290) — and
removeWorktreeLinkedPaths no longer unlinks it, so nothing cleans it up.
Accepted because a per-host verdict would defeat the point of judging the same
config identically on every host it is evaluated on.

Co-authored-by: Neil <neil@example.com>
2026-08-31 20:32:23 -07:00
Neil 8ac1c6e2ac perf(git): bound ref and worktree scans (#17655)
* perf(git): bound ref and worktree scans

* fix(repo-search): clamp oversized ref limits

* fix(worktree): keep strict worktree listing unshared

The shared-scan re-export flipped every `listWorktreesStrict` caller from an
isolated subprocess to the coalesced scan. `git worktree prune` in the removal
recovery path does not bump the scan generation, so a post-prune verification
could join a pre-prune scan, see the stale row, and report a successful removal
as a stale registration. The same gap defeats the post-archive-hook rechecks
that exist to catch an external Git client locking the row.

Restore the unshared export and make coalescing opt-in via
`listWorktreesSharedStrict`, which existing callers already use deliberately.

* fix(git): separate a proven absent ref from a failed probe

`show-ref --verify --quiet` exits 1 for a missing ref, but so does `wsl.exe`
when its own launch fails, so reading any exit 1 as absence collapsed
`unverifiable` into `exited`. A genuine miss prints nothing while a wrapper
failure always explains itself, so require empty stderr alongside the exit
code; a runner that reports no stderr at all keeps its exit-code contract.

That same signal removes a spawn regression: `show-ref` is a direct-git read
under WSL, and the runner retried any numeric exit through the user's
interactive login shell. The replaced `for-each-ref` exited 0 on a miss, so
absence never retried; every absent probe now would. Treat a quiet exit 1 as
Git control flow and skip the fallback.

Also narrow the hosted-review suffix fallback: the replaced
`refs/remotes/*/<base>` could not cross a slash, but `show-ref -- <base>`
matches at any depth, so `origin/feature/main` answered a query for `main`
and submitted a review against a base the provider rejects.

Refresh the real-binary compatibility contract to the shipped excludes, and
assert exact probe concurrency rather than an upper bound so a regression to
serial probing fails.
2026-08-31 16:27:28 -07:00
Neil ca516a4306 test(git): pin FETCH_HEAD lock order in the admission lifetime test (#17641)
`serializes FETCH_HEAD callers before they enter admission` assumed that two
same-repo fetches join the FETCH_HEAD lock lane in call order. They do not.

`runWithGitFetchHeadLock` first `await`s `fetchLockPath`, which walks the
filesystem (`realpath`, `stat` per parent directory, `readFile` of `commondir`,
`realpath` again) before it calls `runWithGitOperationLock`, and the lane is
registered only after that walk resolves. For a non-existent `/repo` that is
five libuv threadpool round-trips per caller. Two callers issued back to back
run their chains concurrently, so lane order is threadpool completion order,
not call order.

When the `interactive` fetch won that race it entered the lane ahead of the
`background` fetch. On the first caller's release it reached admission
immediately and, being interactive, took the free network headroom slot instead
of queueing, while the background fetch stayed parked on the lock. `queued`
therefore settled at 0 and never reached the asserted 1. Measured inversion
rate for the bare lock-path walk was 54/500 on an idle machine; the test itself
failed 5/20 locally, always at the same assertion, matching the two CI failures
on unrelated PRs (#17530, #17630) at the same line.

Fix the premise rather than the symptom: stub only the key derivation, keeping
the real FIFO `runWithGitOperationLock` that the test actually exercises, so the
lane is registered synchronously with the call. Key derivation keeps its own
coverage in `src/shared/git-fetch-head-lock.test.ts`. This also stops the fetch
tests in this file from sharing one global `/.git/FETCH_HEAD` lane with each
other and from touching the real filesystem.

Verified deterministic: 40/40, then 30/30 clean runs, plus 25/25 with twelve CPU
hogs and a concurrent `src/main/git/command-runner/` run saturating the box.
2026-08-31 00:09:07 -07:00
Neil 1e4c56baa1 perf(git): classify status line-stat inputs once (#17461) 2026-08-30 23:04:51 -07:00
Neil 2dd67e83b2 perf(worktree): overlap configured path filesystem probes (#17453) 2026-08-30 23:04:13 -07:00
Neil 976e05c0c8 perf(worktree): overlap finalization head probes (#17443) 2026-08-30 14:54:31 -07:00
Neil 1268fb56f1 fix(worktree): complete a create Git can confirm but cannot list (#17388)
* fix(worktree): complete a create Git can confirm but cannot list

`worktree.create` verified against `listWorktrees`, which softens every git
failure to `[]`. Any listing failure therefore failed a create whose worktree
and branch `git worktree add` had already written, orphaning both, and reported
only 'Worktree created but not found in listing' — the real cause reached the
main-process console and never the user.

Verify against the error-propagating listing instead, and when that fails or
omits the row, rebuild the row by asking Git about the worktree itself. The
direct read returns nothing unless Git resolves the path into this repo's
object store with the expected branch checked out, so an unrelated or half-made
checkout still fails the create.

Fixes #16520

* fix(worktree): authorize a recovered create and reject an unreadable HEAD

Review follow-ups on the create-verification fallback:
- register the recovered worktree's own root, additively, so the create the
  user just made is not rejected by filesystem/git-status IPC
- treat an unreadable HEAD as no recovery instead of a blank OID
- keep the direct read's failure when the listing merely omitted the row
- skip the symlink cases on Windows and reset the new harness mock

* fix(worktree): bound the create-recovery disk read and keep WSL paths case-sensitive

Readiness-scan follow-ups:
- deadline the filesystem common-dir read; a .git on a hung mount left the whole
  create IPC pending where it used to fail after the Git deadline
- offer no disk candidate for a bare repo instead of a fabricated <repo>/.git
- compare POSIX common dirs case-sensitively, so two WSL repos differing only in
  case are not accepted as one object store on a Windows desktop
- move toGitOutputSpace to shared/wsl-paths as toWslExecutionSpace, next to the
  parseWslUncPath callers that already open-code it

* fix(worktree): share one budget for create verification and keep recovered roots

Three follow-ups from review of the create-recovery path:

- The recovery no longer starts a fresh 30s deadline after the listing already
  burned one, so worst-case create verification stays at ~30s instead of ~60s.
  A 5s floor keeps the direct read a chance to answer when the listing spent
  the whole budget.
- rebuildAuthorizedRootsCache now carries a repo's previously registered roots
  forward when its listing throws. A rebuild running while Git is still broken
  could otherwise un-authorize the worktree a create just recovered.
- Corrected the scan-cache doc comment: it claimed strict and lenient listings
  coalesce, but the cache key includes the runner name precisely to keep them
  apart, so a strict joiner can never inherit a lenient scan's softened [].

Each change has a negative control: reverting the hunk fails exactly its own
test and nothing else.

* fix(worktree): keep a recovered worktree authorized across roots-cache rebuilds

The previous approach registered a recovered create into the same per-repo set
the rebuild recomputes from `git worktree list`. That set is derived from the
very listing that failed, so a rebuild would re-deny the worktree — either by
overlapping the registration, or by simply listing again and omitting the row.
Carrying old roots forward on a thrown listing did not cover either case.

Recovered roots now live in their own additive layer that rebuilds union in
rather than replace. The layer is retired on evidence, not on a timer:

- the listing can see the worktree again (Git recovered), or
- the listing succeeded and the directory is gone (worktree removed).

A repo whose listing threw is left untouched, because a dead mount fails both
the listing and the stat, and treating that as "removed" would revoke the
worktree in exactly the outage this layer exists for. The layer is capped so it
cannot grow unbounded, and survives cache invalidation deliberately: repo
mutations are frequent and would otherwise re-deny a recovered worktree.

Three tests cover the healthy-rebuild-omits-the-row case, the in-flight rebuild
race, and retirement once the listing sees it again. Removing the union fails
exactly the two keep-tests and nothing else.

* perf(worktree): only read the repo's .git from disk when Git's own answer disagrees

The disk read is a second opinion on Git's reading of the common dir, but it ran
unconditionally as part of the same Promise.all. A deadline bounds the IPC, not
the syscall: Promise.race cannot cancel an in-flight fs operation, and a `.git`
on a hung mount (dead NFS/SSHFS, stalled WSL 9p) pins a libuv threadpool thread
that no timeout can reclaim. AbortSignal would not help either — fsPromises.stat
takes no signal, and a blocked syscall is not interruptible from userland.

So stop paying it on the happy path: read from disk only when Git's own reading
did not already confirm the common dir. Same accept/reject outcome, but the
threadpool exposure now requires both a failed listing and Git disagreeing about
the repo, instead of every recovered create.

* fix(worktree): compare the disk common-dir witness in Git's execution space

Exercising the fix on a real Windows host against WSL Ubuntu-24.04 found the
filesystem second opinion is inert there. Node reads `.git` in the caller's
space and answers `\\wsl.localhost\<Distro>\home\...\.git`, while Git-in-the-
distro answers `/home/...`. isSameCommonDirPath refuses to compare a POSIX path
against a Windows one, and canonicalizeLocalPath cannot bridge them because
realpath on a Linux path from a Windows process is ENOENT.

So the candidate could never match, and the one case that depends on this
witness alone — a symlinked repo root on the Git 2.25 fallback — declined a
worktree Git had already confirmed. Run the disk result through
toWslExecutionSpace, the same translation readRepoLocation already uses.

This is a false reject, not a false accept: it made recovery give up, never
adopt the wrong repo. Verified on awin; the modern --path-format=absolute
branch was unaffected because Git answers both sides itself there.

* fix(worktree): retire a recovered root only on proof, never on a stalled probe

The prune ran an unbounded stat and read every failure as removal. Two consequences, both in
the outage the recovered layer exists for: a hung mount stalled the rebuild that gates
filesystem auth, and a transient EACCES/EIO revoked a live worktree. The listingFailed guard
did not cover either, because listWorktrees softens Git failures to [] and never throws.

Prune now retires on definitive ENOENT only, probes in parallel under a deadline, and treats a
stall as inconclusive. The capacity bound refuses a new root instead of evicting an authorized
one, so an over-cap create is merely unauthorized rather than a live worktree being revoked.
2026-08-30 14:42:07 -07:00