Commit Graph
246 Commits
Author SHA1 Message Date
Neilandhwantage 202d74a8a4 fix(git): enable Windows long paths for worktree creation (local, sparse, and SSH hosts) (#15866)
Co-authored-by: hwantage <hwantagexsw2@gmail.com>
2026-08-22 23:00:31 -07:00
Neilandvam af2e825626 fix(worktree): stop warning about a stale local base branch that does not exist yet (#15331) (#15871)
Co-authored-by: vam <a@a.com>
2026-08-22 23:00:27 -07:00
Neil e9e238c883 refactor(wsl): delete the environment-policy layer the reviews kept failing on (#16007)
* refactor(wsl): delete the environment-policy layer the reviews kept failing on

A design council (Opus, Grok, GPT-5.6-Sol) reviewed the merged runner after it
took eleven review rounds to land. All three reached the same conclusion: the
invocation half is sound, the environment/probe half is not, and every round had
been debugging the second one.

The finding that settled it, from Opus: `environmentResolved` had **54
references, all in tests and the runner itself. Not one production reader.** The
safety mechanism the strict default existed for was never wired to anything, so
all 19 degrading sites reported absence with full confidence anyway -- #9725
live at every one, under comments claiming it was handled. Two of those comments
say so out loud; I wrote them.

Root cause, in one line: every knob existed only because a failed probe was
fatal. So it no longer is.

- `allowDegradedEnvironment` and `WslGuestEnvironmentUnavailableError` are gone.
  A missing login PATH is a fact in the result, not an exception. That deletes
  23 opt-outs, six catch-and-remap blocks, the transient/rejected cooldown
  split, `probedWithBudget`, and the 1.5x re-probe heuristic -- none of which
  had a reason to exist once the case stopped throwing.
- `lane` + `allowDegradedEnvironment` collapse into `loginPath: 'none' |
  'preferred'`. 19 of 23 sites passed the opt-out, and two said in comments that
  they did not want the login PATH at all: the flag had become the `'none'` the
  union was missing.
- The `interactive` lane is deleted. It had zero production callers and kept ~30
  lines of fence plumbing alive for tests only.

Net -98 production lines; the runner itself sheds 86 for 38.

Also carries three fixes from the W3 orphan-PR sweep I had not done:
- `WSL_UTF8=1` in the runner. My relay migration deleted the only place setting
  it, so wsl.exe's own error text arrived UTF-16LE and read as NUL-riddled.
  A regression I introduced. Credit: #9010 (Chang-Jin-Lee).
- `GITLAB_HOST` is now named in WSLENV, so a ported self-hosted host actually
  crosses into a distro-routed glab (#12557). Credit: #12558 (makoto-developer).
- The WSL skill-setup command pipes into `sh` instead of `eval "$(...)"`, whose
  nested quoting produced `word unexpected (expecting "in")` (#14292). Credit:
  #14785 (innocarpe).

* fix(wsl): restore the login PATH for the Codex availability lookup

loginPath:'none' on a PATH lookup reports an nvm-installed codex as absent,
which is #9725. A miss without a resolved environment is now 'could not
check', not 'not installed'.

Also hardens the guards that should have caught it:
- bashism ratchet is per-call, not per-file, and fails closed on lexer desync
- blankStringContents handles regex literals (an apostrophe in /'/g desynced
  the lexer, so the scan silently found zero calls)
- windowsHide allowlist 85 -> 80, stale once the lexer parsed those files

Credit: Grok (P0), GPT-Sol (ratchet gaps).

* test(wsl): close the two ratchet gaps that let planted spawns pass

- variable-indirected wsl.exe (`const b = 'wsl.exe'; spawnProcess(b)`) is now
  tracked, so the 5 files recorded only in a comment become real allowlist
  entries. Three actually spawn that way; the other two never spawned wsl.exe
  at all, so the prose record was wrong by three in the hiding direction.
- promisify(renamedAlias) is now resolved, so `const run = promisify(execFile)`
  behind an `execFile as x` import can no longer skip windowsHide.

Each verified by planting the violation, watching it fail, restoring, watching
it pass. Credit: GPT-Sol.

* fix(source-scan): stop the regex-literal reader from eating block comments

At index 0 there is no preceding token, so a file opening with a banner
comment had its `/*` read as a pattern and swallowed to the next slash --
110k characters of preload/index.ts, in the direction that hides offenders.

Measured across the tree, old lexer vs new: worst-case over-blanking drops
from -110564 to -1116 characters, and files that desync drop from 51 to 22.
The remaining extra blanking is regex interiors, which is the intent.

Regression tests for both lexer bugs, each verified to fail with its fix
reverted. The first draft of the comment test did not bind -- it asserted on
text after the swallowed span.

* fix(wsl): restore the unverifiable signal on the two remaining probe sites

Round 2. Three call sites used to throw when the login-PATH probe failed;
the redesign rewired one (Codex) and left two reporting confident absence.

- skill-wsl-provider-detection: the script ends in `|| true`, so a lookup
  without the login PATH exits 0 with empty stdout -- identical to 'nothing
  installed'. Callers skip the ~/.codex and ~/.claude skill roots on an empty
  list, losing an nvm-installed provider's skills.
- wsl-cli-installer: the dead catch is replaced by an explicit check. Its
  `case ":$PATH:"` probe otherwise answers from the distro default PATH and
  Settings states as fact that the CLI is not on PATH. Timeout is checked
  first, since a timed-out run also leaves the environment unresolved.

Also narrows the regex-literal prev-token set. '!', '+', '-', '>' and '}' are
value terminators as often as operators, so postfix `n-- / 2` and JSX
`<A size={14} /> : <B` were read as patterns and their spans blanked -- 13
live JSX spans, and one swallowed execFile call that left no desync behind.
False negatives only risk a desync, and desync fails closed.

Plus: WSL_UTF8 on the probe spawn (#9010 reached the runner, not the probe),
and the allowlist header I shuffled by sorting comments along with entries.

Credit: Grok (both P1s), Opus (lexer false positives).

* docs(wsl): drop the lane comments the redesign made false

The interactive lane is gone, so 'both lanes' and the fenced-stdout note
described code that no longer exists. Also states plainly that
environmentResolved is always true under loginPath:'none' -- the field cannot
rescue a PATH lookup that was mislabelled, which is how #9725 came back.

Credit: Grok.

* fix(wsl): stop piping user scripts into the shell's stdin

The W3 migration moved hooks from `wsl.exe --exec bash -c <script>` to a
script piped into `bash -s`. Anything the script runs that reads stdin then
drains the rest of the script, bash hits EOF and exits 0, and the caller logs
success -- an orca.yaml hook of `ssh -T git@github.com || true` followed by
`pnpm install` silently never installs.

Scripts now travel in argv by default, which is what the pre-migration code
did and what --exec makes safe. `scriptDelivery: 'stdin'` stays for the one
caller that needs it: the hook-relay installer embeds a base64 JS bundle far
past any command-line limit, and reads no stdin.

A runner test already described this exact EOF hazard -- for the login shell,
not for the guest command it was itself creating.

Credit: code review.

* fix(skills): make the unverifiable check unconditional, and stop double-probing

Round 3.

- provider detection threw only on an EMPTY result, so a degraded partial hit
  slipped through: `claude` visible on the default PATH via Windows interop
  plus an nvm-only `codex` returns a plausible ['claude'], and the caller then
  skips the ~/.codex skill roots for a provider that is installed. The
  installer already got this right with an unconditional throw.
- three sites asked for 'preferred' without needing it. The GROK_HOME probe
  runs its own `"$login_shell" -lc`, so the runner's probe was a second login
  shell eating up to half an 8s budget; the two skill scans are
  find/base64/head/printf/stat over $HOME.
- the indirection binder missed `private readonly x = 'wsl.exe'` (the
  modifier was captured as the name), backtick literals, and
  `spawnProcess(this.x)`. Commit 2bbbd99 claimed that gap closed; it now is,
  verified against all three shapes.

Credit: Grok.

* fix(child-process): keep the tail of output whose failure lands last

Two console-flash bugs the ratchet was carrying on its allowlist rather than
catching: daemon-process-inspection execs powershell.exe and the gemini
extractor execs `where gemini`, both console-subsystem, both without
windowsHide (#10488). Allowlist 80 -> 78.

And a migration regression: the hook-relay install used to keep a rolling
tail of stderr (`slice(-MAX)`), while runProcess's maxOutputBytes keeps the
head. A guest install that fails after pages of apt warnings therefore
reported the warnings instead of `mv: Read-only file system`. runProcess
takes retainOutput: 'tail' for output whose meaning is at the end.

Credit: code review.

* test(wsl): close the last two indirection shapes in the binder

`this.binary = 'wsl.exe'` has no declarator keyword, and a helper that just
returns the literal is a spawn one hop away that no regex can follow. The
return case fails closed only when the file also spawns something --
local-windows-terminal-runtime.ts returns the name as terminal metadata and
never spawns, so a blanket rule flagged it wrongly.

Verified against both shapes: planted, failed, restored, passed.

Credit: Opus.

* fix(preflight): stop reporting installed WSL CLIs as absent (#9725)

The last two probe sites that turned an unresolvable login PATH into a
confident negative. The native branch of detectInstalledAgents already
consults install dirs for exactly this reason ('PATH may still be unhydrated
on a cold GUI launch'); the WSL branch had no equivalent, so a cold distro
made an nvm-installed claude/codex read as not installed and told the user to
install a CLI their own terminal runs.

Ports that fallback to the guest: agent detection checks the version-manager
bin dirs for commands the PATH lookup missed, and the preflight command runner
APPENDS them to PATH -- append, never prepend, so a resolved login PATH stays
authoritative and a stale nvm version cannot shadow the real binary.

Tested by executing the generated scripts through /bin/sh against planted
binaries, since the behaviour is shell globbing and [ -x ]. Both the
nvm-discovery and the no-shadowing tests were verified to fail when reverted.

* fix(codex-accounts): hide the console on the legacy active-home migration

execFileSync('wsl.exe') with no windowsHide flashes a conhost and steals
foreground on a GUI-launched Orca (#10488). Sibling WSL spawns got this in
earlier commits; this one only had its quoting rewritten. Allowlist 75 -> 74.

Credit: code review.

* fix(windows): close the shell:true hole that made windowsHide a no-op

I un-allowlisted the gemini extractor after adding `windowsHide: true` to an
`exec()` call. `exec` implies `shell: true`, which this repo's own chokepoint
documents as silently making windowsHide a no-op (#14543) -- so the site still
flashed a conhost while reading as guarded. Now execFile('where.exe', …),
matching the relay sibling that already did it right.

The ratchet could not see that, which is why it passed. It now treats a call
that resolves to exec/execSync, or any `shell: true`, as unguarded regardless
of windowsHide -- including through renamed imports and a renamed promisify.

Also: a script over 8000 chars now falls back to stdin. Windows caps a command
line at 32767 and a user's orca.yaml hook is the one unbounded script Orca
runs (`run-both` concatenates two; a vendored installer is ~15KB), so argv
would fail to spawn outright. Degrading beats failing.

And the binder now sees `let p: string` ... `p = 'wsl.exe'`.

Each verified by planting. Credit: Grok.

* test(wsl): an opaque payload must declare its interpreter

My per-call bashism guard REPLACED the file-wide one, and that was a strict
regression: the real payloads are built in a separate function and passed as a
bare `script,`, so the bashism is never inside the call literal and the
per-call arm cannot fire. Deleting `shell: 'bash'` from skill-discovery-wsl
-- `done < <(find ...)` and `read -r -d ''`, the #14292 signature -- passed on
this branch and failed on main.

Reading through the identifier is guesswork. Requiring the call to name its
shell when the payload is not a literal is not, so seven POSIX call sites now
say `shell: 'sh'` -- no behaviour change, sh was already the default.

Two earlier attempts at this were wrong and are worth recording: a whole-file
BASHISM test blamed codex-accounts/service.ts, which correctly pins bash on its
four inline payloads and correctly leaves printf/mkdir unpinned; and excluding
call text still caught a bash payload belonging to a non-runner execFileSync.

Also: runProcessSync now refuses retainOutput:'tail' instead of silently
keeping the head, and the union docblock no longer describes stdin delivery.

Verified against both of the plants that exposed this. Credit: Opus.

* test(wsl): judge an opaque payload by the file, not by whether shell is set

Round 5. My previous rule -- opaque payload must have `shell:` -- was the
third guard fix in a row that came out weaker than what it replaced:
`shell: 'sh'` on a bash payload satisfied it, which is #14292 with extra
steps. Flipping skill-discovery-wsl's pin from bash to sh shipped green.

Now: strip the text of every call that already names bash, and if a bashism
survives anywhere in the file while a script-carrying call is not bash-pinned,
flag it. Stripping the bash-pinned calls is what keeps codex-accounts clean.

Also closes four ways to hide a call from the collector, each verified by
planting:
- `script: \`${bashism}\`` -- a template literal read as a visible literal
- `runWslProcess({ ...spec })` -- a spread hides script AND shell
- `Object.assign({ a }, { script })` -- the collector took the first `{`, so it
  now takes the whole argument list
- `import { runWslProcess as runWsl }` -- a renamed callee collected nothing,
  and zero calls read as zero violations

Not fixed, recorded instead: a computed `shell:` in the console guard. Matching
any non-false value also flags `shell: spawnConfig.shell`, a pass-through that
is false in every branch, and a false positive there costs an allowlist entry
that disables the guard for a whole correct file.

Credit: Grok.

* fix(preflight): make the guest fallback match the native one it claims to mirror

Three defects in the #9725 fix from earlier today, all found by executing the
generated scripts under real dash rather than reading them.

- $HOME containing a space word-split the unquoted dir list into a relative
  path, so every CLI read as absent -- the exact symptom the fix exists to
  remove. Each entry is quoted now; the nvm entry quotes only its prefix so the
  glob still expands.
- A directory passes `[ -x ]`, so ~/.local/bin/gemini/ was reported as an
  installed CLI that then fails to launch with EISDIR. The PATH half of the
  same script already guarded this, and so does the native twin.
- The header called this the "guest-side twin" of the native fallback while
  omitting four of its directories: volta, asdf, fnm and mise. A WSL user on
  any of those still had #9725 while the same user on native did not -- and
  asdf and mise are named in the motivating comment. The claim is now true.

Credit: Opus.

* test(wsl): mask bash-pinned calls by position, not by String.replace

`rest.replace(text, '')` with a string pattern removes only the FIRST match,
so two identically-written pinned calls left one behind and its bashism then
counted against an unrelated unpinned call in the same file. A body that also
occurred earlier as a substring would blank the wrong region entirely.

The collector now returns ranges and the mask is applied by index. Verified
both directions: two identical pinned bodies plus one unpinned call flags, and
the same file with all three pinned stays clean.

* test(wsl): fail closed on call shapes a regex cannot attribute

Round 6. Rather than widen the pattern again, treat the shapes it cannot
reason about as unreadable.

A regex cannot tell which object a key belongs to, so every round produced
another way to put the pin in one place and the payload in another:
`cond ? {pinned} : {unpinned}`, `{...} as WslSpec`, `Object.assign({a},{b})`.
A call whose SPEC is chosen by a ternary or spread -- one appearing before the
first `{` -- or which carries an `as` assertion is now flagged whenever the
file has a bashism, with no `shell: 'bash'` escape, because the substring test
that would grant the escape is exactly what cannot be trusted on these shapes.

A ternary INSIDE the object is not exotic: claude-accounts/service.ts:977 uses
one to choose a script line in a call that is already pinned, and treating that
as opaque would demand a second pin it already has. Nor is a nested call --
`script: `x ${shellQuote(p)}`` is how every payload here is built, and flagging
it would demand bash on POSIX payloads that must not have it.

Also follows `const run = runWslProcess`, generics and optional chaining, and
counts collected calls against mentions so a shape that slips the pattern reads
as unreadable rather than clean.

I tried the TypeScript parser first, which would remove the class outright.
TypeScript 7 is the native port and exposes no JS compiler API; oxc-parser
works but is transitive, and declaring it surfaced an unmet peer warning.
Recorded here so the next person does not repeat the detour.

Credit: Grok.

* fix(wsl): fish is a PATH lookup, and my lint check could not fail

Two things Opus caught that I had verified wrongly.

`wsl-fish-history-cleanup` passes `program: 'fish'` -- a bare name, so a PATH
lookup by definition, the exact class the earlier rounds hunted. I mapped it to
'none' and then defended that in an audit, because I read
`allowDegradedEnvironment: true` as "does not need the login PATH". It does not
mean that: it means "do not fail when the probe fails". The old call still USED
the login PATH whenever it got one, which is 'preferred'. Under 'none' a fish
from linuxbrew or nix is invisible and the cleanup throws. The truncated
comment left behind when the flag was deleted is finished too.

And `pnpm lint` has been failing on this branch while I reported it clean: I
grepped for `error eslint|error oxlint`, but oxlint prints the rule category
(`error typescript(array-type)`, `error unicorn(prefer-ternary)`). The grep
could not match, so it never failed. Checking the exit code instead surfaced a
third violation hidden behind the first two.

Credit: Opus.

* chore(wsl): clear the round-7 P2s

- Formatting: the branch owned 22 of the tree's 26 oxfmt failures because I
  never ran the formatter. Branch files now own none.
- resolveScriptDelivery was computed twice, in two places that must agree
  about argv shape and stdin payload. Resolved once and threaded through.
- The allowlist header said the list only shrinks while the branch added three
  entries. It grew because the scanner learned to follow a variable-bound
  'wsl.exe'; those three were previously recorded in prose, so the count was
  wrong by three in the direction that hides offenders. The header now says so.
- Two test comments still explained behaviour via the deleted
  allowDegradedEnvironment flag; a stray triple blank line; two adjacent JSDoc
  blocks where only the second attached.

Not taken: platform-guarding addWslEnvKeys. WSLENV is inert off Windows, and
the guard broke a test that asserts the key directly -- more surface than the
tidy is worth, so the reason is recorded at the call site instead.

Credit: Opus.

* test(preflight): plant a fabricated CLI name, not a real one

CI caught what my local run could not: the runner has a real /usr/bin/gh, so
`command -v gh` resolved to it and the planted nvm stub was never reached. The
fallback APPENDS, so that is the code behaving correctly -- the test was
asserting a property of my machine.

Both real-shell suites now plant `orca-fake-cli`, which exists nowhere.
Re-verified the same way as before: with the PATH fallback disabled the test
fails, with it restored it passes.

I declared this branch merge-ready without looking at CI. Local green is not
the gate.

* refactor(wsl): delete two knobs and a duplicated fallback

Elegance pass. The branch had grown from a deletion into a net addition, and
most of the growth was optional axes with one caller each.

- `retainOutput` is gone. One production caller wanted the tail of a 64KiB
  buffer; head-truncation only hurt because of that cap. The caller drops the
  cap, keeps the default, and slices the tail itself -- which is what the live
  relay next door already does. Two mechanisms for one job became one.
- `scriptDelivery` is gone. The size rule was already the whole design:
  argv unless the script is too long for a Windows command line. The option
  existed so a small Orca script could opt into stdin, and no such caller ever
  appeared. Both behaviours stay pinned: a huge script still goes to stdin, an
  ordinary one still leaves the hook's stdin free.
- Agent detection no longer walks the fallback dirs itself. It prepends the
  same PATH prelude the preflight command runner uses and lets the ordinary
  lookup do the work. Its bespoke walk had duplicated the lookup script's
  `! -d` guard -- and had missed it once, which is how a directory read as an
  installed CLI.

All 17 detection tests still pass unchanged, including the $HOME-with-a-space,
directory-is-not-a-CLI, and volta/asdf/fnm/mise cases, so the collapse is
behaviour-preserving rather than assumed to be.

Credit: Grok.

* fix(wsl): never name a path-shaped variable in WSLENV

`buildHostEnv` forwarded every caller-supplied key into WSLENV. wsl.exe
translates path-shaped variables between Windows and Linux form, so a caller
passing PATH would have replaced the guest's own PATH with a translated
Windows one -- silently, and fatally for every lookup after it.

No caller passes PATH today. The point of a chokepoint is that it does not
depend on that staying true.
2026-08-22 20:35:59 -07:00
NeilandOrcaWin 98c03fe12f fix(win32): hide the console window for agent-browser and git helpers (#15887)
* fix(win32): hide the console window for agent-browser and git helpers

W1 routed most child processes through `runProcess`, which always sets
`windowsHide`. Six call sites still spawn directly, so each one opens a real
console window on Windows: it flashes and steals foreground. For the git status
poll, that is once per poll (#10488).

A ratchet now scans every file that imports `child_process` and fails on a call
without the flag. Its allowlist starts at the 76 files that still offend and can
only shrink — it doubles as the worklist for routing them through the chokepoint,
which is where the flag stops being a per-call-site decision at all.

Diagnosed in #14589; the SSH and cookie-import sites it also covered are already
fixed on main by the W1 migration.

Co-authored-by: OrcaWin <orcawin@users.noreply.github.com>

* test(wsl): stop the exec-mode guard scanning historical release checkouts

The cross-version e2e lane checks whole past releases out under
`tests/e2e/.cross-version-checkouts/`. The guard walked into them, so on any
machine that had run that lane it reported 21 offenders -- every one a copy of
shipped code we cannot edit -- and failed. Skip dot-directories; the >500-file
vacuity assertion still holds.

---------

Co-authored-by: OrcaWin <orcawin@users.noreply.github.com>
2026-08-22 00:18:02 -07:00
6efd4061dc fix(git): run WSL git reads without a shell (#15257)
* fix(git): run WSL git reads without a shell

WSL-routed git ran through the distro user's interactive login shell for
one reason: to inherit their PATH. That shell also runs the distro's
rc/motd and writes it to the stdout callers parse, which is why #10917
reports a shell banner breaking GitHub source detection.

A shell-free route already existed (`--exec /usr/bin/env PATH=... git`,
added for status reads in #13207) but it was opt-in, and only
gitStatusReadOptionsForWorktree opted in. Every other read -- remote
get-url, config --get, log, show, rev-parse -- took the login shell on
every call, so the reported parse never benefited.

Classify reads at the resolver instead of at each caller. A read needs
nothing the login shell provides, so it takes the direct route without
the caller asking. Writes and network operations stay on the login
shell: they can depend on credential helpers and ssh-agent that the
user's profile sets up.

Subcommands that both read and write (config, remote, branch,
submodule) require an explicit read flag before they qualify, so
`config --get` goes direct while `config user.email x` does not.

`git show` blob reads stop forcing the login shell and go direct too.
The fence stays on that path: the login shell is still the fallback when
the environment probe is cold or rejected, and a banner there would
become file content.

Behavior note: the first WSL read per distro now also warms the
environment probe in the background, so a cold read spawns one extra
wsl.exe. Subsequent reads start no shell at all.

* fix(git): keep queried WSL remote reads on login shell

* style: format WSL runner test

* fix(git): match WSL read markers positionally

The conditional read markers were matched anywhere after the subcommand,
so a positional argument that happened to share a marker's name routed a
write shell-free: `worktree remove list` read as a listing, and
`submodule foreach status` as a status query -- the latter can run
arbitrary commands, including network ones.

Split the two kinds of marker apart. `config`/`branch` are flag-marked
and still match anywhere; `remote`/`worktree`/`submodule` are
action-marked and must match the first non-flag argument. The queried
`remote show` rule and the `symbolic-ref` arity rule are unchanged.

Neither case is reachable today -- Orca issues no `submodule foreach`,
and the worktree paths are its own CLI, not git argv -- but the loose
match was the shape of the defect, not those two instances.

* test(git): pin read routing behind global options

Writes hidden behind -c/-C/--git-dir must not reach the shell-free
route, and an unparsed global form must fall back to the login shell
rather than guess at the subcommand. Both directions fail safe.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-08-18 02:54:19 -07:00
OrcaWinandOrcaWin b7f2e17712 fix(git): fence buffered WSL login-shell reads (#15060)
* fix(git): fence buffered WSL login-shell reads

Two git paths force the login shell unconditionally and buffer its whole
stdout, so the distro's rc banner lands in front of the payload:

- gitExecFileAsyncBuffer backs `git show :<path>` blob reads and hands
  the bytes straight to the diff/blob viewer, so the banner is prepended
  to displayed file content.
- buildNetworkSshPolicyEnv probes `core.sshCommand` and treats any
  non-empty answer as a user-configured wrapper. A banner reads as
  configured, so the code skips the `ssh -o BatchMode=yes` fallback and
  silently disarms the guard that keeps non-interactive SSH from
  hanging on a prompt.

Fencing is opt-in per call site rather than applied to the login-shell
branch as a whole: streaming consumers (`git grep`, `ls-files -z`) parse
records as they arrive, so an opening marker would be glued onto their
first record. Only these two, both buffered by construction, opt in.

Blob content can be binary, so the payload is sliced out of the raw
bytes; decoding to find the fence would corrupt it. The markers are
exposed on the captured command because the shared module is bundled for
the renderer and cannot reference Buffer.

Note this path is not a rare fallback: `preferWslDirectGit` is only set
by gitStatusReadOptionsForWorktree, so every other WSL-routed git call
takes the login shell on every invocation.

* test(git): use findLast for the ssh-policy call lookup

Satisfies the code-quality rule that flags filter-then-index.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-18 02:40:06 -07:00
OrcaWinandOrcaWin 3a9f40ed70 fix(wsl): read machine output from a fenced login shell (#15290)
* fix(wsl): read machine output from a fenced login shell

Orca runs WSL reads through the distro's *interactive* login shell so
PATH matches the user's own terminal (nvm, mise and asdf only install
into rc files interactive shells read). An interactive shell also runs
the distro's rc/motd, and stock Ubuntu 24.04 writes its "run a command
as administrator" hint to stdout -- no user customization required.

Every caller parsing that stream was reading the banner as data:

  statPath  -> "To run a command as administrator...\n\ndirectory"
  readPath  -> banner prepended to the contents of every file read
  preflight -> banner prepended to `gh --version` / auth output

`.trim()` cannot recover any of these, so a WSL worktree's file
explorer sees no valid entry types and file reads return junk.

Three call sites had independently grown their own marker to survive
this (`__ORCA_AGENT_PATH__`, `ORCA_WSL_GIT_READ_ENV_V1`, and a
`>/dev/null` fd dance), which is the tell that it belongs in one place.

Fence the payload once, in the shared builder, and hand callers a
reader that returns just their bytes. The fence carries a per-call
nonce so `cat`-ing a file that happens to quote a marker is not
truncated. Exit status is preserved, so the ENOENT mapping still works.

wsl-git-read-environment drops its bespoke marker and parsing.

* test(wsl): fence the login-shell path-lookup boundary test

It asserted a raw interactive login-shell read matched an absolute path,
so the distro rc banner made it fail on any stock Ubuntu. It is part of
the shell-contracts CI gate, where it skips on Linux and hid the break.

* docs(wsl): record the guest command-execution contract

Both failure modes are silent - the command runs, exits 0, and returns
the wrong bytes - so the rules need to live somewhere a reader will
find them before writing the next wsl.exe call site.

* fix(codex): fence the WSL Codex identity probe

buildWslCodexBinaryStamp reads the login shell's stdout positionally --
path before the first newline, version after -- through an interactive
login shell. On a stock Ubuntu the rc banner lands ahead of the payload,
so the first newline falls inside the banner and the stamp becomes
path="To run a command as administrator..." with the rest as version.
Both halves are non-empty, so nothing throws: the stamp is silently
wrong, and an unstable stamp reads as "the Codex binary changed" and
reissues the trust grant.

The identity script ends in `exec`, so it never writes a closing fence;
the reader returns everything after the opening one, which is exactly
this case.

buildWslCodexIdentityArgs becomes buildWslCodexIdentityProbe and returns
the reader with the argv so the two cannot drift apart. The other three
WSL Codex commands are deliberately left unfenced: availability is
exit-code only, and app-server/login hand stdout to a long-running
program.

* fix(wsl): harden the capture fence after review

- readStdout now takes the LAST opening fence, matching the lastIndexOf
  the wsl-git-read-environment marker used deliberately: a login shell
  can echo the command text before running it, repeating the fence.
- local-worktree-filesystem throws instead of falling back to raw stdout
  when the fence is missing. The fallback silently reinstated the bug
  being fixed -- statPath would return the banner as a file type and
  readPath would return banner+contents, with no signal. Preflight keeps
  its fallback; its matchers scan the whole blob and tolerate a prefix.
- The exit-status test asserted only that the script CONTAINS `exit $?`,
  which is true for any input and never executed those lines. It now
  runs a real distro and asserts status 2 reaches the caller, which is
  what statPath's ENOENT mapping depends on.
- Corrected the doc: a sed backreference has no `$`, so `--` never
  rewrote it. Replaced with the positional and shell-local cases that
  were measured to differ.

* fix(wsl): stop running a login shell for filesystem reads

statPath/readPath/rm run coreutils at standard paths and shell builtins.
They need nothing from the user's PATH, so there was never a reason to
start a login shell -- and starting one is what put the distro's rc/motd
on the stdout these callers parse.

Fencing that output treated the symptom. Using a plain `sh -c` removes
the cause: no profile, no rc, no banner, by construction. The fence and
its missing-fence error go away with it.

The fence stays where it is actually needed: the three places that must
run the user's shell to resolve their PATH (the preflight CLI probe, the
WSL git environment probe, and the Codex identity probe).

Net -12 lines.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-18 02:30:02 -07:00
OrcaWinandOrcaWin 6e8da1df8d fix(wsl): pass guest argv verbatim through --exec (#15039)
* fix(wsl): pass guest argv verbatim through --exec

`wsl.exe <...> -- <argv>` expands `$name` in every argument against the
guest environment before the guest ever runs. It does this even when no
shell is involved, so `-- /usr/bin/printf %s '$HOME'` prints /home/you.

Every WSL invocation went through that preprocessor, so scripts arrived
already rewritten: `awk '{print $2}'` lost its field reference, and a
POSIX script asking for the literal `$HOME` got the expanded path.

`escapeWslShCommandForWindows` tried to compensate by escaping `$`, but
it skipped any `$` preceded by a backslash, so a script containing `\$`
was still corrupted -- and half the call sites never applied it at all.

Route every invocation through `--exec`, which passes argv through
untouched, and delete the escaper. The direct-git path already used
`--exec`, so this is not a new compatibility dependency.

A guard test fails if the `--` form reappears anywhere in the tree.

Net -50 lines of production code.

* test(wsl): drop remaining escaped-dollar assertions

* fix(wsl): cover the --exec migration's blind spots

An audit of every wsl.exe invocation found sites the first pass missed,
including two it actively broke:

- config/scripts/wsl-git-shell-benchmark.mjs imported
  escapeWslShCommandForWindows, which no longer exists, so the script
  threw on startup. Its wslShellArgs helper also still used `--`; the
  file already had an --exec helper, so route both call sites there.
- classifySubprocessCommand unwrapped `wsl.exe <...> -- <binary>` by
  breaking on `--` alone. With every Orca spawn now on --exec it never
  found the guest binary and bucketed all WSL subprocesses as plain
  "wsl", losing the git/gh/glab breakdown. Break on either separator,
  since foreign wsl.exe processes still use `--`.

CliSkillRuntimeSetup builds its setup command as a template literal
rather than an argv array, so no array-shaped search could see it. Its
decoder accepts both separators so commands persisted before this
change still decode.

The guard now scans config/ and tests/ as well as src/, and checks the
command-string spelling alongside the argv one — the two shapes that
have each shipped a regression. It skips comment lines so prose about
the old form stays allowed, and asserts it scanned a plausible file
count so a bad root cannot make it vacuous.

* fix(wsl): restore the guard's multi-line sensitivity

The guard matched line by line, so `'--',\s*'bash'` could not span a
newline -- and every argv array in this repo is formatted one element
per line, which is exactly the shape it exists to catch. Measured
against the pre-migration tree it caught 17 files before and 9 fewer
after. It now strips comment lines and matches the rejoined text, with
a case that pins the multi-line shape so this cannot silently return.

The program list is wider than shells now, which surfaced a false
positive: tmux takes a `--` separator followed by a program too
(`split-window ... -- cat`). Matching is scoped to files that mention
WSL rather than narrowing the list back.

Also:
- Replaced the `sed` regression case, which was vacuous. A backreference
  contains no `$`, so it returned `bac` under both separators and would
  have passed without the fix. The block claimed every case proved the
  bug. Swapped in a positional argument and a shell local, both measured
  to differ -- the positional is the shape `wslUncDirectoryExists` uses,
  where `--` blanked `$1` so every existing directory probed as missing.
- windows-shell-args.test.ts derived its expected argv from
  buildWslExecArgs, the helper under test, so six assertions would still
  pass if it regressed to `--`. Spelled the expectation out.
- Dropped two comments citing the removed `--` behavior as rationale.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-18 02:12:21 -07:00
Brennan Benson 8e9b5c908c fix(github): fail closed instead of running client git against a remote repoPath when the SSH provider is unregistered (#14945)
* fix(github): fail closed when the SSH git provider is gone

getCurrentHeadOid and probeTrackedUpstreamBranches only routed through the
SSH provider when one was registered. With connectionId set but the provider
unregistered (dropped connection, not yet reattached) they fell through to
client-side git with cwd pointing at the remote repoPath — on a machine with
a same-named local path that silently answers for the wrong repository.

getCurrentHeadOid feeds shouldHideMergedImplicitPR, so a wrong OID changes
which PR the UI attributes to a worktree.

Both now take their existing unknown path (null / probeFailed) instead,
matching repo-default-branch.ts. Local and WSL routing is unchanged.

* fix(github): preserve PR state when SSH probes fail

* fix(github): keep failed SSH discovery unverifiable

* fix(github): propagate SSH identity failures

* fix(github): scope verified SSH identity probes

* test(github): preserve tolerant resolver calls

* fix(github): preserve indeterminate auth discovery

* fix(github): isolate SSH repository probe generations

* test(github): expose SSH probe generation in mocks
2026-08-17 00:12:14 -07:00
Neil eb3f6838af perf: coalesce git upstream status reads (#11697)
* perf: coalesce git upstream status reads

* fix(git): repair upstream lease key imports and guard its field list

The read owner imported the shared/types barrel deleted by #14447, and its
push-target key hand-enumerated fields, so a new GitPushTarget field would
silently share a lease between two different targets. The destructure now
fails to compile if a field is added. Lease tests moved into their own file
after #14728 split ssh-git-provider.test.ts.

* test(git): enforce native upstream coalescing in CI

The 10-caller benchmark only runs under ORCA_GIT_UPSTREAM_COALESCING_BENCH_JSON,
so nothing in CI failed when the native/WSL lease was bypassed. Route status.ts
through invalidateGitUpstreamStatusReads so the export has a production caller.
2026-08-15 23:24:05 -07:00
Neil 9367169888 refactor(tests): split every oversized test file off the max-lines suppression list (#14728)
* refactor(tests): split oversized test files off the max-lines suppression list

Every `*.test.ts`/`*.spec.ts` that carried an `eslint/oxlint-disable max-lines`
directive is now split into focused, behavior-scoped suites that fit the 800-line
test budget, with shared setup extracted into co-located `*-test-harness.ts` /
`*-test-fixtures.ts` modules (300-line budget). 83 files became ~930; the largest
output is 797 effective lines. `orca-runtime.test.ts` is intentionally untouched.

Test bodies were moved by scripted line-range slicing rather than retyped, so
assertions are byte-identical. The only permitted body edits were mechanical
rebinding where a shared value moved into a harness (e.g. `tmpHome` ->
`homes.tmpHome`).

Registries that enumerate test files were updated in lockstep:
- config/max-lines-baseline.txt: pruned 341 -> 258 entries (all 83 removed).
- config/reliability-gates.jsonc: 33 gates repointed at the split files, with
  assertionRefs split per file where a gate's coverage now spans several.
- .github/workflows/pr.yml: the real-zsh lane now lists the 4 split files that
  actually exercise zsh, so they keep running in the dedicated shell lane.

Also renamed agent-hooks `server-test-fixtures.ts` to `server.test-fixtures.ts`
so the global-fetch call-site audit keeps skipping it, and added `.js` extensions
to the CLI suites' dynamic harness imports (node16 resolution) to unbreak
`build:cli`.

Verification: full suite 52,449 passing vs 52,448 at baseline with zero
assertions lost; `pnpm lint`, `pnpm typecheck`, and `pnpm build:cli` all exit 0;
the terminal-pane e2e spec runs 31/31 headless.

* refactor(tests): split hook-idle arbitration suite that oxfmt pushed over budget

The pre-commit oxfmt pass reflowed pty-connection-hook-idle-arbitration.test.ts
to 811 effective lines, 11 over the test budget. Split the hook-completion side
effect and replacement-agent veto cases into their own suite; both files now sit
well under the cap and the 15 tests are unchanged.

* test: port upstream test changes into the split files after rebase

Rebasing onto main surfaced 27 tests that main had added to files this branch
deleted, plus edits to tests that had already moved. Taking the deletion side of
those modify/delete conflicts would have dropped that coverage silently, so each
upstream change is ported into the split file that now owns the behavior — for
example main's six orchestration mailbox tests land across orchestration-runs,
-send, and -check.

Also repoints `orchestration.notification-mailbox-consistency`, a gate main added
after this branch's gate remap, at those same three split files, and re-prunes
the max-lines baseline against main's (257 entries).

Verified: all 27 upstream test titles present; full suite 52,761 passing with the
only diff vs baseline being 12 tests main itself removed and 3 that moved from
skipped to passing; lint and typecheck exit 0.

* fix(test): flush pending continuations before tearing down terminal test globals

CI shard 5/16 failed on both Node 24 and 26 with `ReferenceError: window is not
defined` from pty-connection.ts, surfacing through
pty-connection-daemon-snapshot-replay.test.ts.

The reattach/settle chains `await` a real promise and then touch `window.api`.
Under fake timers those continuations cannot run, so they only become schedulable
once restoreTerminalTestGlobals() switches back to real timers — which previously
happened immediately before `delete globalThis.window`, so a late continuation
threw and failed the whole file. Flush async ticks in that window instead.

This is latent in the source rather than new: the pre-split 25k-line file kept
running other tests after these, which gave the chains time to settle before
teardown. Splitting the file moved teardown directly behind them.

* fix(test): keep an inert window after terminal test teardown instead of deleting it

The async-tick flush was not enough: the reattach/settle chain can resolve after
teardown regardless of how long we drain, so CI shard 5/16 still failed with
`ReferenceError: window is not defined` from pty-connection.ts.

A real renderer never loses `window`, so deleting it was the artificial part.
Swap in an inert proxy whose properties resolve to callables and whose calls
resolve to undefined, making a late `window.api.pty.*` call a harmless no-op.
The next test replaces it wholesale via installTerminalTestGlobals(), and no test
asserts that `window` is absent.
2026-08-15 00:54:20 -07:00
Brennan Benson ab9d1a29a9 fix(worktree): never reissue a generated workspace name (#14350)
* fix(worktree): never reissue a generated workspace name

Generated workspace names were deduped only against currently-live
worktrees, so deleting a workspace returned its name to the pool. A later
workspace could draw the same name, land on the same directory path, and
inherit the previous occupant's agent conversation history — coding-agent
CLIs key their prompt history and transcripts by cwd.

Names are now retired permanently per repo. The registry is written in
main with the name Git actually used (the create loop can advance past a
requested name on collision), and seeded once per run from workspace
directories and surviving agent transcript buckets so already-spent names
are excluded from the start. Suggestions degrade to -2, -3 variants
instead of recycling, and those variants retire too.

User-typed names are untouched: retirement filters suggestions only.

* fix(mobile): honor retired workspace names, on one shared implementation

Mobile hand-duplicated the desktop name-suggestion algorithm and deduped
only against live workspaces, so a phone could still be offered a name
whose deleted workspace left agent conversation state behind at that path.

Both platforms now call one shared selector in src/shared, so the two can
no longer drift. The host publishes retired names as an optional field on
the existing worktree.list response, and mobile fetches them per selected
repo while the create sheet is open — mirroring the desktop hook.

Mobile never calls worktree.list for its catalog (it uses worktree.ps,
which carries rows only), so this is a targeted request rather than a
change to the catalog or its cache. Hosts predating the field omit it and
mobile falls back to live-only dedupe, which is the pre-change behavior.

* fix(worktree): close retirement consistency gaps

* test(worktree): cover retirement runtime contracts

* fix(worktree): retire generated collision names

* fix(worktree): enforce retired names at creation

* refactor(ai-vault): extract the Claude project-dir encoder

The bucket-name encoder and its scope-boundary check were private to the
session scanner, so a second consumer had to reimplement them — and got the
per-character encoding wrong. Move both to a shared module with direct tests.

* fix(worktree): make the retirement seed scan actually match buckets

The bucket encoder collapsed runs of non-alphanumerics while the real one
emits a dash per character, so every dot-path bucket missed and the Windows
default workspace root (C:\...) matched nothing at all. Reuse the shared
encoder and its boundary check, which also stops a repo absorbing a sibling
whose path merely shares its prefix.

Also:
- Derive the workspace leaf by stripping the known encoded parent instead of
  guessing from trailing dash segments, which retired the parent directory's
  name whenever a workspace was named numerically.
- Reuse isAutoGeneratedCreatureBranchName so the -10 and -100 tiers retire.
- Drop the .codex/sessions root: Codex keeps the cwd inside the transcript
  rather than in a directory name, so the scan could only ever see a year
  folder. Reading transcript contents is not a trade this feature justifies,
  so the gap is documented instead.
- Honor CLAUDE_CONFIG_DIR, which relocates the bucket root.
- Delete the unused retirableLeafName export.

Tests write buckets with the real per-character encoding against a fake home,
covering POSIX, dot-directory, Windows drive and WSL UNC roots; all three
platform cases fail against the previous encoder.

* fix(worktree): retire only generated names, keyed by cwd namespace

Two problems in the host-side registry.

Retirement fired for every create, including names the user typed. The
creature pool contains ordinary words — orca, runner, sole, molly, oscar — so
typing a retired 'nautilus' silently produced directory and branch
'nautilus-2' and burned the name for good. Creates now carry an explicit
nameWasGenerated flag; both the skip and the retire are gated on it, and it
defaults to false so CLI and automation callers are unaffected.

The registry was keyed by repo id, but both readers already discarded the id
and unioned by the cwd collision key, because the collision this prevents is
on the path. Keying by that namespace directly fixes several things at once:
entries no longer orphan when a repo is removed, remove/re-add no longer loses
every retirement for an unchanged path, the missing removeProject prune is
moot, and the backfill promise no longer merges into only the first repo id it
saw. The feature is unreleased, so no migration is needed.

Also:
- Memoize the collision key. It runs computeWorktreePath, which for a WSL repo
  is a blocking execFileSync('wsl.exe') whose failure path is uncached, and
  the previous code recomputed it once per repo on every create and every
  listRetiredNames call.
- Drop retiredNamesByRepo from the worktree list result. It had no readers and
  leaked onto 'orca worktree list --json', and its awaited backfill sat on CLI
  selector resolution. The dedicated listRetiredNames RPC keeps its consumers.
- Make the three RuntimeStore methods required. RuntimeStore is file-private
  with two constructors, so the 'older embedders' the optionality protected do
  not exist, and the optional chain silently returned no retirements.
- Revert the unrelated forceDeleteBranch rewrite, and make room under the
  file's line budget by extracting the create-args mapping instead.

* fix(worktree): send name provenance and stop gating Create on the fetch

Desktop and mobile now mark a create as generated-name only when the user
typed nothing and the composer fell back to the suggestion, so the host knows
which names it may retire.

Remove the retired-names loading gate from every create path. The host already
skips retired candidates before doing any git work, so the client gate bought
nothing while it could disable Create for the length of a full mobile
reconnect ladder (the wait had no timeout) and blank the desktop button
between queued creates. The suggestion still waits; the button never does.

Also make the web client call worktree.listRetiredNames instead of hardcoding
an empty list — the method is registered and mobile-allowlisted, so the
comment claiming no wire call existed was wrong — and filter the mobile
response to strings so a malformed row cannot throw during normalization.

* fix(worktree): key retirement by repo id and prune it with the repo

Reverts the collision-key storage key. It was a function of workspaceDir,
nestWorkspaces, worktreeBasePath and repo.path, so toggling any one of those
orphaned every retirement for every affected repo at once — trading a rare
churn (remove/re-add) for a common one. The read path already unions by cwd
namespace at query time, so cross-repo sharing never depended on the storage
key.

Instead, address the growth and orphaning directly:
- Drop the registry in removeProject, and in removeProjectForHost once the last
  host's copy of the repo id is gone, alongside the sparse-preset deletes that
  already follow this convention.
- Bound each repo's registry. The cap sits far above the 552-name pool because
  evicting inside it would reissue a name whose agent state is still on disk;
  only -2/-3 tier accumulation can ever reach it.
- Carry retirements through profile transfer, re-keyed to the destination repo
  id and dropped from the source, mirroring sparsePresetsByRepo.

Separately, fix the backfill merge: the scan promise is cached per cwd
namespace, but it closed over the first repo id that triggered it, so a second
repo in the same namespace received nothing. The scan stays shared; the merge
moves out of the cached promise and runs for whichever repo asked.

Local repos re-seed on re-add through that backfill. SSH repos do not — the
scan cannot see the execution host — which is now stated in the module.

* docs(worktree): spell out why the retirement bound sits above the pool

Names the trap directly: the neighbouring 50/200 bounds cap histories, so
lowering this one to match them would silently start reissuing names whose
agent state is still on disk. Also states that oldest-first eviction is a
deliberate least-bad choice rather than a neutral one.

* fix(worktree): send name provenance from the web runtime client

This client hand-enumerates worktree.create params, so the new optional field
was silently dropped and typecheck could not see it. On web and paired-desktop
the host therefore never received it: generated names were never retired, and
the host-side skip that backstops a stale suggestion was disabled too. The same
client does fetch retired names for suggestions, so it was filtering against a
registry nothing ever wrote to.

The test asserts both directions, and fails without the fix.

* fix(worktree): retire names that took more than one collision suffix

isAutoGeneratedCreatureBranchName strips exactly one trailing -N, which is
right for auto-rename eligibility but wrong here. Once the pool is spent the
suggester emits nautilus-2, and a collision on that yields nautilus-2-3 —
which a single strip leaves as nautilus-2, not a pool name, so retirement
no-opped at exactly the tier where every base name is already gone. Strip
repeated suffixes locally rather than moving the auto-rename predicate.

* perf(worktree): keep the retirement backfill off the blocking WSL probe

The backfill runs on composer repo-select, not just at create time, and it
derived the probe path synchronously — which for a WSL repo with a mirrored
workspace dir reaches getWslHome and its blocking execFileSync('wsl.exe').
A stopped distro froze the main process for up to 5s on composer open.

Adds an async twin of computeWorktreePath and uses it for the probe. Resolving
the home there also warms the shared cache, so later sync callers are free.

Also stops memoizing the collision key when the WSL home is still unresolved:
only the success path is cached upstream, so caching the fallback namespace
would strand the repo there for the rest of the session.

* fix(worktree): hold retired names across a refresh instead of blanking

refreshKey changes on every workspace-list mutation, so create-multiple
refetches after each create and the hook returned an empty list until the
refetch landed — precisely the window in which resetForNextCreate clears the
name field and a fresh suggestion is drawn. Keep the previous answer while
revalidating and reset only when the repo changes; a failed refresh keeps what
was already loaded rather than un-retiring everything.

Also makes the returned array referentially stable, so the suggestion memo
downstream stops rerunning on every refetch.

* refactor(worktree): put the retired-name cache rules on one implementation

The desktop and mobile hooks that fetch retired names had already drifted
four ways. The transports genuinely differ (IPC vs RPC), but the caching
rules must not, and mobile's copy reset to [] on any error -- which
un-retires every name for the rest of the sheet session, the one outcome
retirement exists to prevent.

Moves the rules into src/shared/worktree/retired-name-cache: response
normalization, the never-leak-across-repos rule, and the hold-previous-on-
failure rule. Pure, no React, because src/shared is on the main process's
import graph. Each platform keeps its own transport and effect.

Mobile moves up to desktop's behavior: it now holds the previous answer
through a failed refresh, and refetches when the workspace list changes
instead of never refetching after mount.

Also drops the unused `loading` return. Neither platform consumed it; its
only consumer was the Create-button gate reviewed out earlier, and removing
it makes that regression unexpressible.

* fix(worktree): import shared types from their real modules

Main dropped the src/shared/types barrel, so the retirement module's import
resolved locally but not against the PR's merge base.

* refactor(worktree): bound the retirement registry by tier compaction, not eviction

Retirement is a correctness guarantee — a spent name's directory may still hold
agent conversation state keyed by that cwd — so the 2000-entry cap was the wrong
shape: reaching it handed a name back. At the owner's measured rate (~6.6 pool
names retired per day in one repo) the cap was ~9 months out.

Names come from a fixed 552-entry pool and the suggester only reaches tier N+1
once every tier-N name is taken, so a completed tier is exactly a set that no
longer needs listing. A row is now a watermark plus the names above it: reads
answer at-or-below the watermark with no lookup, and compaction drops the 552
entries the watermark now covers. Bounded at one pool per repo forever, with no
eviction and nothing un-retired.

Tiers can complete out of order (a create-time collision can spend `nautilus-2`
while tier 1 is open), so compaction loops and higher-tier names simply wait.

The RPC result carries the watermark beside the names as a new field; a client
predating it reads the names only and under-retires the compacted tiers, which
degrades to the pre-retirement behavior rather than breaking.

* fix(worktree): preserve generated name retirement across failures
2026-08-14 22:18:36 -07:00
Wooseong KimandBrennan Benson c0f9dcc8f4 fix(git): allow bounded override of worktree-add timeout (#12823)
* fix(git): allow bounded override of worktree-add timeout

Keep the 180s OneDrive stall guard as the default floor, but accept
ORCA_WORKTREE_ADD_TIMEOUT_MS up to 30 minutes for legitimately slow
checkouts (large repos, git-crypt).

Preserves a closed upper bound; never removes the timeout.

Fixes #12696

* review: read the worktree-add timeout override at the call site

Keeps WORKTREE_ADD_TIMEOUT_MS meaning the 180s default instead of
silently becoming an env-resolved value, drops the redundant third
export, and folds three parse guards into the clamp Number() already
covers.

Adds the missing coverage that addWorktree actually passes the raised
timeout to git — reverting the call-site wiring previously failed no
test.

* review: clamp an infinite override to the max and warn on a discarded value

Number.isFinite sent ORCA_WORKTREE_ADD_TIMEOUT_MS=Infinity — the natural
way to say 'stop killing my checkout' — back to the 180s default, handing
the operator the exact failure they set the variable to escape. Reject
only NaN and let the clamp handle magnitude.

Every discarded or clamped value was silent, so the '=300' seconds/ms
mixup the floor exists for produced an identical 'git timed out.' with no
signal. Warn once, naming the accepted range.

Also pins both bounds as literals and refreshes two comments that no
longer described the code.

* review: name the real problem in the override warning

An unparseable value took the range branch, so ORCA_WORKTREE_ADD_TIMEOUT_MS=600_000
— the literal style this file itself uses — reported a bound violation that had not
happened. Split the two cases and quote the value so trailing whitespace is legible.

Uses the file's [git/worktree] log prefix, drops a #7225 citation that describes a
startup/UI-freeze report rather than a large checkout, and states why the ceiling
is 30 minutes.

* review: give the resolver a contract and stop splitting the timeout block

Moves resolveWorktreeAddTimeoutMs below the constants so the module's five
timeouts read as one group, and replaces the edge-case JSDoc with the actual
contract — what it reads, what range it clamps to, when it warns.

Comments the NaN-comparison the unparseable-value warning depends on, since
'fixing' it with an isNaN guard would silently delete that warning. Test
spy now matches the file's local-spy idiom, the bound literals get their own
test, and the env stub deletes the key instead of setting an empty string.

* review: pin the clamp-up warning text

Every warn assertion covered a value clamped DOWN to the floor, so swapping
the discriminator back to !Number.isFinite passed all 74 tests while telling
an operator that ORCA_WORKTREE_ADD_TIMEOUT_MS=Infinity 'is not a number;
using 1800000ms' — naming the number it just used, and misdirecting exactly
the person this override exists for. That mutation now fails.

Restores the STA-1292 rationale the call-site comment had dropped, and puts
the env var name and issue back on the ceiling constant.

* review: correct two comment claims about the warn path

The JSDoc promised a warning 'whenever the value is not used verbatim',
but trimming and fractional truncation deliberately stay silent — the
suite asserts exactly that for '300000.9', so the contract contradicted
the tests below it.

The condition comment named 'NaN !== NaN', a comparison that never runs:
resolved is the default whenever requested is NaN, so the live comparison
is 180000 !== NaN. Same warning, right mechanism.

* review: fix the ceiling arithmetic and name the default/floor coupling

30 min against a 3.5 min worst case is ~8x, not ~10x — the comment's only
job is justifying that number. States the actual cost too: a genuine stall
now blocks a create for up to 30 min instead of 3.

WORKTREE_ADD_TIMEOUT_MS silently serves as both the default and the clamp
floor, so tightening it to fail faster would also re-admit the
'=300 means seconds' mistake the floor exists to catch. Now said out loud.

Widens 'git-crypt' to 'a slow content filter' so an LFS or large-monorepo
reader does not conclude their case is different, drops a call-site clause
that restated the constant's comment, and corrects a test comment that
claimed an idiom the code does not use.

* review: pin the warning's prefix and variable name

Deleting the [git/worktree] prefix left the suite green — all three warn
assertions started matching after it. A diagnostic nobody can grep for is
not a diagnostic, so one assertion now pins the whole line.

* review: correct the last three comment claims

A blank value is clamped (Number('') is 0) and stays silent, so 'warns when
a value is rejected or clamped' had an exception the test below it already
exercised. Now says non-blank.

The floor-coupling note claimed lowering the default re-admits the
'=300 means seconds' mistake; it does not — a 60s floor still clamps 300.
The actual cost is that the minimum any override can request drops with it.

Drops the spy comment rather than rewriting it a third time; beforeEach and
afterEach say it themselves.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-14 17:00:16 -07:00
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00
Neil 583ab1601b refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.

Move each domain into its own folder and drop the now-redundant prefix:

    src/shared/github-pr-types.ts    -> src/shared/github/pull-request-types.ts
    src/shared/worktree-id.ts        -> src/shared/worktree/id.ts
    src/shared/linear-links.ts       -> src/shared/linear/links.ts

This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.

Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.

Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.

Two things `tsc` cannot catch, handled explicitly:

- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
  entry is REPOINTED to the new path rather than pruned. Pruning would drop the
  bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
  (`mobile/node_modules` is empty). Instead every relative specifier in the repo
  was resolved against the filesystem: 174 unresolved before this change and 174
  after — identical, so nothing broke in mobile either.

The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
2026-08-13 20:44:16 -07:00
Neil 991a3fe963 chore(lint): update oxlint to 1.77 and enable no-op cleanup rules (#13901)
Enable eleven oxlint rules that simplify code without changing behavior, and fix
every existing violation. Each candidate was gated on measured cost rather than
assumption, so rules that regressed runtime performance or type checking were
dropped instead of suppressed.

typescript/no-redundant-type-constituents is the largest addition: 113 sites, no
autofix. Dead constituents are deleted. Where the redundant literal existed to
document intent (`string | 'all'`), it is preserved as `(string & {})`, which
keeps the autocomplete hint the original code was reaching for instead of
flattening it away. The rule also caught a broken import —
remote-shared-control-retirement-probe.ts pulled RuntimeStatus from
src/shared/types, which does not export it, so the type silently degraded to
`any`; no tsconfig covers that file, so tsc never saw it.

oxlint stays at 1.77.0 rather than 1.78.0 because .npmrc sets
minimum-release-age=4320 and 1.78.0 is younger than that window.

Rules evaluated and rejected, with what disqualified each:
- prefer-string-raw: String.raw is a runtime call, not a literal (184x slower)
- prefer-string-replace-all: 26% slower
- text-encoding-identifier-case: ~5% slower, reproducible
- prefer-spread: [...str] is 110% slower than split('') and differs on surrogates
- no-implicit-coercion: `!!x` narrows types and `Boolean(x)` does not (22 tsc errors)
- prefer-arrow-callback: arrows are not constructible, breaking `new` on mocks
- object-shorthand: rewrites source text asserted by a tracked reliability gate
- switch-case-braces: pushes ten files past max-lines, which cannot be suppressed
- no-useless-switch-case: drops `case undefined:` that switch-exhaustiveness-check needs
- arrow-body-style: 115 violations have no fix, and it breaks max-lines
- newline-after-import: false-positives on the leading-semicolon ASI idiom

electron-vite-output-contract asserted on the literal
Object.prototype.hasOwnProperty.call text; retarget it to Object.hasOwn, which
rejects inherited keys identically.
2026-08-11 18:19:43 -07:00
OrcaWin e790266546 fix(windows): show first window before shell PATH hydration (#13799) 2026-08-11 12:14:36 -07:00
Jinjing c1e75477f3 Fix static analysis page stuck in loading state (#13674)
* Fix static analysis page stuck in loading state

- Bound check-details requests with 30s timeout, matching remote RPC budget
- Track request IDs to discard stale responses when context changes
- Propagate githubRepository through store and components for proper routing
- Add retry button for failed check-details loads
- Improve accessibility with ARIA labels for loading and error states

* Fix static analysis page stuck in loading state

When an open check-details tab's repository is removed, the loading
state would continue indefinitely because the fetch was still being
triggered. Prevent the fetch call in this scenario to unblock the UI.
Also migrates translation keys to obfuscated identifiers.

* Fix static analysis page stuck in loading state

Add deadline-based timeouts and request ID tracking to prevent stale responses
from freezing the checks panel. Include abort signal propagation throughout the
request chain and provide retry UI for failed check details loads.

* fix(checks): prevent loading state from getting stuck on retry

- Consolidate mount checks into a helper function
- Details now clear when a new request begins
- Add i18n strings for retry status
2026-08-10 23:11:45 -07:00
OrcaWinandOrcaWin 9deb72f9ed fix(git): support Windows-linked worktrees in WSL projects (#13483)
* fix(git): support Windows-linked worktrees in WSL projects

* fix(git): harden WSL linked worktree routing

* fix(test): defer WSL routing filesystem access

* test(git): type WSL routing probe mock

* fix(git): retry transient WSL route probes safely

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-10 21:19:01 -07:00
Neil 5538584c74 perf(git): overlap status with conflict detection (#13529) 2026-08-10 20:48:00 -07:00
Neil 73efab98f7 perf(orchestration): keep drift Git off main thread (#13440) 2026-08-09 23:26:28 -07:00
OrcaWin 69ca0154b6 fix(git): bypass WSL login shells for status reads (#13207) 2026-08-09 14:41:40 -07:00
Jinjing 46b9d3b13a Break out test and generated lines in branch line total (#13057)
* rm comments

* reduce comment
2026-08-07 09:55:54 -07:00
Jinjing debf4affe7 Display total lines of code change in branch header (#12771)
* Add branch line total chip to source control header

Display the total lines added and removed across a branch from its fork point, measured via `git diff <mergeBase>`. Only computed when the chip is visible (request gate on merge base OID), with 500ms soft deadline to protect status latency and 15s hard timeout. Deduplicated across concurrent pollers and cached alongside line stats. Omitted on failure — always shows exact or nothing, never a partial estimate. Updates throughout the stack: native git status, relay, renderer store/API, and UI components.

* Pin branch line total to app locale

Format line counts using the app's configured locale instead of the system
locale, ensuring consistent cross-platform display and test reliability.

* test: wait for coalescer joins instead of fixed sleep

Hold the diff until the second status pass actually takes the
branch-total coalescer lease instead of using a fixed 400ms sleep.
Fixes timing-dependent flakiness on slow machines.
2026-08-05 14:46:00 -07:00
Brennan Benson de64337c26 fix(worktree-watcher): refresh status after external pushes (#12361)
* fix(worktree-watcher): surface external push -u through the git-common watch

An external-shell 'git push -u' writes only the common .git/config (plus
refs/remotes/<remote>/<branch>), both invisible to the git-common event
filter, so the Checks panel stayed on 'No upstream configured' until the
renderer safety poll. Classify the common config and remote-tracking refs
as status-tier signals, poll config alongside the other primary-checkout
metadata files, and keep FETCH_HEAD/reflog/ref-lock churn ignored.

* fix(worktree-watcher): refresh after subsequent pushes
2026-08-05 11:26:13 -07:00
Brennan Benson 9deee5ad2f perf(worktrees): delete worktree directories after the removal returns (#12416)
* perf(worktrees): delete worktree directories after the removal returns

`git worktree remove` deleted the whole checkout inline, so the remove IPC held the
watcher/PTY gate for the entire recursive delete (prod traces: worktree.remove.git_remove
p50 8-14s, p90 29s, max 34.7s). Local removals now rename the checkout into a hidden
sibling trash root, clear Git's registration for the missing path, and delete the moved
tree in the background. Renames that cannot run (WSL, other volume, Windows open handles)
fall back to the previous in-place removal unchanged.

* test(worktrees): keep no empty trash root when the rename cannot run

* fix(worktrees): harden deferred trash cleanup

* fix(worktrees): keep WSL trash on its owning host
2026-08-04 15:53:11 -07:00
NeilandOrca 73c5009b82 chore(dead-code): drop ~2k lines of unreachable exports and orphan modules (#12077)
* chore(dead-code): drop 2k lines of unreachable exports and orphan modules

Ran knip across every build entry (main, preload, renderer, popout, web,
cli, relay, workers, forked sidecars, config scripts) and removed what no
entry graph can reach.

- 11 orphan modules nothing imported, plus one test that only covered them
- 159 unused exports/types, with their now-dead helpers, imports and tests

Each candidate was verified against dynamic references before deletion.
42 knip hits were false positives and are kept: shared modules consumed by
the mobile/ workspace, the src/shared/plugins/** public API, vendored
shadcn primitives, and relay wire-protocol constants held for compatibility.

Adds knip.json + `pnpm audit:dead-code` so this stays measurable.

Verified: pnpm typecheck, pnpm lint, and 2081 tests across the 73 affected
test files all pass.

* chore(dead-code): move knip config under config/

Root-level additions are blocked by the root directory guard.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-02 00:33:57 -07:00
JinjingandOrca 1562f12f78 fix(P1-D): coalesce remote-ref probes, TTL negatives, and bound unsettled keys (#12065)
* fix(P1-D): coalesce remote-ref probes, TTL negatives, and bound unsettled keys

Keep forge resolution from stampeding git under worktree fan-out, let
remotes added mid-session be discovered without a restart, and refuse
pathological new-branch waves once the unsettled map is full.

* fix(P1-D): stop abandoned probes publishing, and split capacity refusals

A coalesced probe abandoned as stale kept running and still wrote its answer
to the cache, so a late permanent miss could land over the successor's fresher
one. Probes now publish only while they still own the in-flight key.

The hosted-review capacity refusal told brand-new branches that an earlier
attempt of their own never answered when the refusal was really the unsettled
map or the process-wide detached cap; each cap now says what it is.

Also caches stable "no such remote" SSH misses under the negative TTL instead
of re-spawning the probe on every poll.

Co-authored-by: Orca <help@stably.ai>

* Bound SSH remote URL probe with deadline to prevent hangs

The SSH branch of remote URL probes was unbounded — the relay's bounds
are per-phase and reset on every frame, so a relay dribbling output would
outlive them. Pass AbortSignal.timeout to the SSH provider's exec call to
enforce the same 30s deadline as local probes.

Treat AbortError as a transient probe error: it signals unavailable
infrastructure (deadline or cancellation), not a negative answer about
the remote.

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-02 00:31:54 -07:00
Jinjing ced4a2a959 fix(P1-D): bound hosted-review in-flight lookups so a wedged provider cannot pin a branch (#12030)
* fix(P1-D): bound hosted-review lookups with a detachable deadline

The `inflight` map in the hosted-review branch cache was only ever cleared
when the lookup settled, and nothing bounded how long that took. One wedged
provider call pinned its branch for the life of the process: every later poll
joined the same dead promise, so the card loaded forever with no in-session
recovery.

Each lookup now runs under a 120s deadline. Nothing below the funnel can be
cancelled, so the deadline detaches instead: the record is released, the
callers get the last known review (or a timeout error), and the branch enters
the existing failure backoff. The lookup keeps running and its answer is still
adopted if it lands, so a slow-but-alive host converges rather than failing
forever. A token identity keeps a detached lookup from evicting the record
that replaced it, and a wall-clock sweep expires records whose timer never
fired — main's timers are suspended across system sleep. `inflight` is capped
independently of the completed cache.

The failure backoff moves to its own module: it has a different lifetime from
the answer cache and is what a deadline records against.

* fix(P1-D): bound `git remote get-url` on the local/WSL path

`getRemoteUrlForRepo` ran the git child with no timeout, which is the one
unbounded step under the hosted-review lookup funnel: `git/runner.ts` only
arms its kill path when a timeout is passed, so a dead network mount or a
stalled WSL interop hangs the call and everything above it. The SSH branch is
already bounded by the relay mux's 30s request timeout, so it is unchanged.

* rm review doc

* rm review doc

* test(P1-D): add probe tests and transient-failure recovery verification

Add tests for coalesced-probe and remote-url-probe infrastructure. Add integration test verifying that transient Bitbucket API failures don't cache as a definitive no-review result, allowing recovery after cache TTL expiration.

* fix(P1-D): track lookups from start, prevent stale scope adoption

- Count unsettled lookups when they start, not after deadline expires: prevents multiple concurrent lookups for the same branch.
- Add evicted generation floor: prevents adopting stale results when scope is invalidated and evicted from the map.
- Consolidate duplicate repository reference cache logic into createRemoteRefProbeCache utility.
- Fix deadline wrapper in git config signature lookup: bound the caller's deadline only, not the coalesced probe itself.

* feat(P1-D): add remote-ref-probe-cache utility

Cache successful remote URL probes per repo/runtime to avoid duplicate work.
Skip caching transient errors and SSH failures so providers can retry on
reconnect, preventing stale scope adoption during the session.
2026-08-01 22:10:18 -07:00
Neil 6e2a88c091 perf(worktrees): avoid redundant fetch during deletion (#11918) 2026-08-01 21:59:41 -07:00
Jinjing 05206046f6 chore: condense code comments (#12008)
* chore: condense code comments

* chore: shorten more code comments

* clarify PTY agent session descendant cleanup behavior

Refine the comment on ptyAgentSessionIds to more accurately describe
when agent sessions sweep their descendant process trees and note the
exception on immediate Windows shutdown.
2026-08-01 14:24:31 -07:00
Neil d5c4d953ec perf: coalesce cancellable git status reads (#11691) 2026-07-30 22:55:48 -07:00
Jinjing 74563b6498 feat(jira): link Jira issues from the workspace create dialog (#11296)
* Link Jira issues from workspace create dialog

Add Jira issue linking to workspace creation, matching existing GitHub and Linear workflows. Users can paste Jira issue URLs in the smart name field to auto-populate workspace names and link the issue to the created workspace/worktree.

Linked Jira issues appear on workspace cards via the new 'jira-issue' card property. Implements cancellable searches and summary reads to prevent stalled requests from blocking the shared Jira pool. Persists paired issue + source context metadata with validation of provider/site identity.

Fixes git-username rate-limit handling to reject malformed JSON responses so garbage never becomes branch prefixes.

* feat(jira): link issues during workspace creation

- Display linked Jira issues on worktree cards
- Fetch issue summaries and timestamps via Jira API
- Gate Jira linking behind runtime capability check
- Preserve user-typed names during async lookups

* Enforce git check-ref-format rules in login validation

Extend isBranchSafeHostedLogin to reject usernames that git rejects as
invalid branch components: trailing dots, consecutive dots, and .lock
suffix. Prevents invalid branch names from login usernames.

* Enforce filesystem filename cap for branch-safe logins

Loose refs store logins as single filenames, so the real constraint is the
255-byte filesystem cap, not git check-ref-format rules. This allows longer
provider-agnostic logins while staying platform-safe.
2026-07-29 19:50:18 -07:00
Jinjing cbe8635f46 fix(worktrees): prevent deletion from blocking Orca (#11233)
* fix(worktrees): prevent deletion from blocking Orca

* test(worktrees): loosen async history-delete event-loop bound for CI

The main-thread safety check failed on a loaded runner when a single
timer gap hit ~48ms under the prior 30ms threshold. Keep the bound well
below a recursive sync-rm stall without treating CI jitter as a block.

* test(worktrees): measure history-delete critical path, not timer gaps

setInterval gaps during async rm of thousands of files still flake under
CI scheduling. deleteWorktreeHistoryDir is sync and must only rename, so
assert that critical-path wall time stays well below a recursive walk.

* fix(worktrees): prevent deletion from blocking Orca

Add timeout-based draining of watcher closes so SSH round-trip delays don't
indefinitely block the worktree removal path. Also: order durable temp-file
sweeps ahead of writes to reclaim orphans before accumulation, skip own-process
temps to avoid deleting live writes, swallow persistence errors so disk failures
don't cascade to query callers, and measure history-deletion progress by loop
turns rather than timer gaps to detect blocking on CI runners.

* fix(worktrees): prevent deletion from blocking Orca

Worktree deletion can now proceed even if filesystem watchers or history cleanup operations hang, preventing Orca from freezing. Changes:

- Fence install slots with tokens instead of counters so removals can abandon wedged installs without corrupting later removals
- Timeout-bound watcher unsubscribe operations with a shared drain budget
- Move JSON serialization of large usage caches from queue-time to write-time to avoid blocking main thread
- Async tombstone + schedule history tree deletion instead of blocking recursive rmSync during GC, preventing main-thread stalls ~10s after startup

* Extract usage cache writer into reusable durable snapshot class

Consolidates serialized durable-write and generation-veto logic from
three usage stores into UsageCacheSnapshotWriter. Eliminates duplication,
centralizes multi-MB JSON serialization on the main thread via write-queue
serialization, and vetoes superseded snapshots to avoid wasted rewrites.

* fix(worktrees): prevent deletion from blocking Orca

Worktree deletion used to recursively delete large session trees (hundreds
of MB) on the critical path, stalling the event loop. Instead, rename trees
into a `.pending-delete` tombstone queue and reclaim them asynchronously
off the removal's critical path.

Extracted host tree removal into a reusable helper (`removeHostTree`) that
centralizes Windows retry logic. Added usage-cache flush on quit to prevent
data loss when scans complete right before shutdown. Improved watcher
removal deadline management with reserved tail slices for the final
unsubscribe, and added retry logic for tombstone removals that fail once
under transient Windows locking.

* fix(history): retry failed session tree removals

Tombstoned session trees whose removal fails transiently (e.g., EBUSY
under Windows AV) are now re-queued in-process with bounded exponential
backoff instead of sitting until the next HistoryManager construction.
Prevents a single stuck tree from blocking the entire Orca process.
2026-07-29 18:21:26 -07:00
Brennan Benson 6d4e335001 feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml (#10459)
* feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml

Follow-up to #7549: `.worktreeinclude` copies gitignored paths into each new
worktree, which is right for `.env`/`.vscode/` but wrong for large rebuildable
directories. Copying `node_modules` per worktree is slow and duplicates disk,
and each worktree's install then diverges.

Adds `worktree.sharedDirectories` to `orca.yaml` — a versioned, in-repo list of
gitignored directories that are symlinked (shared) into every new local
worktree, so one install serves them all. Adds to, never replaces, the per-user
Worktree Shared Paths setting.

`createWorktreeSharedPaths` uses a new 'share' materialization mode that always
symlinks. The existing 'link' mode APFS clone-copies on macOS, which would give
each worktree an independent node_modules and defeat the point; 'link' and
'copy' behavior are unchanged.

Entries must exist as gitignored directories in the primary checkout; absolute
paths, `..` traversal, and `.git` are rejected. Resolution never throws, so a
malformed orca.yaml cannot block worktree creation. Remote (SSH) creation skips
this, as it does symlink paths and `.worktreeinclude`.

Closes #10451

* fix(worktrees): keep worktrees deletable after sharing a directory

A directory-only ignore rule (`node_modules/`, the common spelling) matches
the primary checkout's real directory, so the shared directory resolves and
gets symlinked — but it never matches the worktree's symlink, so Git reports
that link as untracked. Deletion only tolerated the per-user shared paths, so
every worktree in such a repo became permanently dirty: the clean preflight
threw "uncommitted or untracked changes" and `git worktree remove` refused
without --force.

Feed the configured `orca.yaml` shared directories into the same
tolerate-and-unlink machinery the per-user shared paths already use, at both
deletion call sites. The names are read unfiltered, since the create-time
resolver drops exactly the entry deletion needs most.

* test(worktrees): register createWorktreeSharedPaths in the runtime symlink mock

orca-runtime.ts imports createWorktreeSharedPaths, but the vi.mock factory for
../ipc/worktree-symlinks never listed it. Vitest resolves omitted exports
lazily, so this only stays green because no runtime test configures a repo with
worktree.sharedDirectories — the first one that does would fail on a mock
resolution error rather than on its own assertion.

* fix(source-control): don't count shared symlinks as uncommitted changes

A directory-only ignore rule (`node_modules/`) matches the primary checkout's
real directory but never the worktree's symlink, so Git reports the shared link
as untracked for the life of the worktree. That made every affected worktree
read as dirty: a phantom row in the diff view, and Create PR blocked with
`blockedReason: 'dirty'` telling the user to commit an entry they cannot
commit, because it is a symlink Orca created.

Status and the review-creation preflight now drop untracked entries that are
both declared shared (per-user shared paths or orca.yaml sharedDirectories) and
actually symlinks on disk. Both conditions are required, so a regular file at a
declared name, or a symlink nobody declared, still counts as user work. The
decision fails closed: anything not positively identified stays dirty.

The preflight moves to `--porcelain -z` so paths with spaces or non-ASCII bytes
are compared raw rather than C-quoted, with a parser that consumes the origin
field a rename emits instead of reading it as its own record.

Symlink detection moves to a leaf module: importing it from ipc/worktree-symlinks
would pull APFS cloning, and its child_process dependency, into the status graph.

SSH is unaffected and left alone — remote worktree creation skips the symlink
and shared-directory passes, so a remote worktree never has one.

* fix(source-control): wire shared links into local status

* fix(worktrees): resolve the status repo once and reject uncollapsed shared paths

`git:status` resolved the registered worktree's repo twice per call — once
inside `getLocalGitOptionsForRegisteredWorktree` and again for the shared-link
lookup — walking every repo's worktree meta on a polling path.

`apps/./web` also survived `sharedDirectories` normalization: `resolve()`
collapses it when the symlink is created but Git reports the collapsed path, so
every later comparison misses and the link reads as permanent untracked work.

Also stop resolving shared links for SSH repos in review creation: `repo.path`
names a path on the remote host.

Adds the missing wiring coverage for review creation and runtime status, plus
the untracked-only conjunct in both filters — all four were mutation-verified
to leave the suite green before these tests.

* test(worktrees): pin the resolver-to-status seam for shared directories

The resolver's output and the status filter were only tested apart — status
used a hardcoded `['node_modules']`. Feed the resolved directories back through
`getWorktreeSharedLinkPaths` into a real `getStatus` so a resolver that ever
returned a differently-spelled path can no longer leave the link showing as a
phantom untracked row.

* fix(worktrees): try a directory junction before a symlink on Windows

A plain `fs.symlink` needs Developer Mode or admin on Windows, so an ordinary
Windows user got EPERM, the per-path catch logged and continued, and the
worktree came up with no shared directory and no signal. A directory junction
needs no privilege, and the rest of the codebase already uses one for win32
directory links.

The symlink stays as a fallback rather than being replaced: a junction cannot
target a UNC path, and a WSL project's repo lives behind one, so replacing it
outright would trade the local-volume bug for a WSL regression.

Safe for the removal path either way — Windows reports a junction as both a
symlink and a directory, so the `isSymbolicLink()` unlink that runs before
`git worktree remove` still fires and still refuses to follow it.

* fix(worktrees): keep NUL bytes and tolerated links out of the removal error

The removal preflight switches to `git status --porcelain -z` whenever it has
shared links to tolerate, then attached that raw stdout to the error. `.trim()`
does not strip interior NULs, so the message reached the user as
`?? node_modules<NUL>?? precious.txt<NUL>` — raw control bytes, and it named the
shared link, the one entry that is not the user's work and cannot be committed
away.

Parse the NUL-delimited output once and use it for both the clean verdict and
the error text, so the two can never disagree about what blocks removal. The
`-z` switch stays: it is what keeps paths with spaces or non-ASCII names
comparable against the configured entry.

* chore(worktrees): drop stray reformatting and note why the SSH guard exists

Committing the merge staged 792 files, so lint-staged ran the formatter across
all of them and rewrapped three renderer files that were already unformatted on
main. Nothing was lost — they were byte-identical to main ignoring whitespace —
but they showed up in the pull request as unrelated changed files. Restored to
main's exact bytes.

Committed with --no-verify on purpose: the pre-commit formatter is what
introduced the rewrapping, so letting it run again would simply reapply it.
Every check it would have run was run by hand instead — lint, typecheck, and the
IPC and source-control suites all pass, and the three restored files are
expected to fail a format check because that is main's current state.

Also records why the connection guard on the shared-link lookup is not dead
code: the remote dirty check ignores those paths, so the guard's only effect is
avoiding a stray local read and the bad cache entry it would leave behind.

* refactor(source-control): drop a scan-everything guard and freeze the cached list

The dirty check built a filtered array only to read its length, so it always
scanned every status record; asking whether any record is untracked stops at the
first one and reads the same either way.

The cached shared-directory list was also handhanded out by reference, so a
caller that mutated it would corrupt every read for the rest of the cache
window. Marking the return readonly prevents that at compile time; copying on
return would work too but would allocate on the status-polling path, and there
is exactly one caller, which only spreads it.
2026-07-28 14:04:41 -07:00
NeilandOrca a49d68f8c2 perf(git): overlap getBranchCompare's head-of-chain reads (#10895)
* perf(git): overlap getBranchCompare's head-of-chain reads

Four git spawns ran strictly in series before any compare work began:
branch --show-current, the base-ref probe, rev-parse HEAD, and rev-parse <base>.

Three are independent -- compareRef is display-only metadata and HEAD's oid does
not depend on the base ref -- so they now run concurrently. The fourth was
redundant outright: the probe already runs `rev-parse --verify --quiet
<ref>^{commit}` and discarded the oid it printed, which was then re-resolved by a
second spawn. resolveWorktreeBaseCommitOid returns that oid so it can be reused;
hasWorktreeBaseCommitRef now delegates to it, leaving its other 4 callers
untouched.

3.6-3.7x on a short remote base label (192ms -> 52ms), 1.44x on an
already-qualified refs/... base, which skips the probe by design.

Reuse is keyed by ref: resolveWorktreeAddBaseRef returns at its first successful
candidate, so only that ref's oid is ever read back. Peeling is safe because only
refs/heads and refs/remotes candidates reach the probe, where ^{commit} is a
no-op.

No new git features: this removes a spawn rather than adopting an option.

Co-authored-by: Orca <help@stably.ai>

* fix(git): preserve compare semantics across providers

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:17:03 -07:00
Neil 5a30c5c2ed perf(git): read both diff blobs concurrently (#10781)
* perf(git): read both diff blobs concurrently

The diff loaders awaited their two sides in series, so the second `git show`
could not start until the first had returned. The reads are independent, so
that was pure added latency on every diff the review panel opens: ~47 ms
sequential vs ~24 ms concurrent, a saving of ~23 ms per diff.

Covers the merge-base, commit, and staged loaders, plus the unstaged path where
the working-tree read is independent of the index->HEAD chain. The unstaged
left chain itself stays sequential because its second step depends on the first.

The staged coalescing test asserted the sequential shape (one spawn, then the
next); it now pins the contract that actually matters — eight identical reads
still collapse to two spawns, one per side.

* test(perf): interleave the diff-blob benchmark arms

Running one strategy's whole batch before the other's lets cache warming, CPU
frequency drift, and background load correlate with the strategy being measured.
Alternate the arms per iteration, alternate which goes first, and report medians
so that drift stays common to both.

Also reject malformed env settings rather than truncating them — Number.parseInt
accepts "10foo" and 3.5.

Interleaved result confirms the original: 1.90x-2.03x, ~24 ms saved per diff.
2026-07-26 20:31:52 -07:00
Neil 19d082a164 fix(github): resolve owner/repo through SSH Host aliases (#10284) (#10361)
* fix(github): resolve owner/repo through SSH Host aliases (#10284)

Expand OpenSSH Host → HostName via ssh -G before classifying github.com
identity so PR merge works when origin is git@alias:owner/repo.git.
Transport URLs stay unchanged so IdentityFile selection is preserved.
Do not long-negative-cache indeterminate ssh -G failures.

* fix(github): harden SSH alias resolution
2026-07-25 23:34:17 -07:00
Neil eb545aaa59 fix(worktree): collapse duplicate "Local Mac" run targets in the host picker (#10472)
* fix(worktree): collapse duplicate "Local Mac" run targets in the host picker

A linked worktree added as its own project projects a second ready host
setup on the same project+host, so the run-target picker rendered N
identical "Local Mac" rows differing only by path. Only the first was
reachable — resolveWorkspaceCreationTarget takes the first project+host
match — so the extras pointed at paths that may no longer exist.

- Dedupe ready setup options by host in the picker (display fix for
  profiles that already hold duplicates).
- Canonicalize a stale draft's setup id to the setup the picker shows,
  so the displayed path is the path the workspace is created in.
- Reject a linked worktree at repos:add when its main checkout is
  already tracked, preventing new duplicates.

* fix(worktree): only dedupe a linked worktree against a git main checkout

Review follow-up: the repos:add guard matched any tracked repo on the main
checkout path, including a folder-kind record. A folder repo does not
project onto the same project as the git worktree, so matching it would
suppress a legitimate add without deduping anything.
2026-07-25 15:38:35 -07:00
Brennan Benson b31e9bb03d fix(worktree): bound the .worktreeinclude copy so a huge include can't freeze workspace creation (#10540)
* fix(worktree): bound the .worktreeinclude copy so a huge include can't freeze creation

`.worktreeinclude` copying was bounded in entry count (1000) but unbounded in
bytes and files, and awaited inline during worktree creation. A repo listing
`node_modules` froze creation for minutes behind the create dialog on Linux and
Windows, where the fallback is a full `fs.cp` (macOS gets a cheap APFS clone).

Measure each copy-mode source against a cumulative budget (2 GB / 50k files)
before the first byte is written, and refuse the entries that bust it. Refused
entries ride the existing `CreateWorktreeResult.warning` channel so a workspace
never silently comes up missing its included files.

Pre-measurement rather than mid-copy abort: `fs.cp` ignores its `signal`
option, so a started copy cannot be cancelled and would strand a partial tree.
Refusing up front means there is no partial state to clean up.

* fix(worktree): don't charge bytes for copy-on-write clones, and bound the sizing walk

Two defects in the copy budget, both found by review:

- The byte limit was applied on macOS, where the copy is an APFS clonefile.
  Measured: a 2.7 GB tree clones in 22 ms and consumes no disk. Refusing it on
  a 2 GB byte ceiling denied work that was already free — a regression on the
  one platform this bound was never meant to touch. Bytes are now charged only
  when a byte-for-byte copy will actually run; the volume probe that decides
  this is the same cached df+diskutil pair the clone runs, and writes nothing,
  so the "refuse before the first byte" invariant holds. The entry limit still
  applies everywhere: inodes are real work even on the clone path.

- A refused entry consumed no budget, so a `.worktreeinclude` listing many
  over-budget directories paid a fresh full-limit walk for each one — up to
  1000 x 50,000 lstat calls, re-creating the stall this bounds. The walk is now
  charged against its own ceiling whatever the verdict.

Also documents that `admit()` must be awaited sequentially (CodeRabbit).

* fix(worktree): give the sizing walk headroom so one huge entry can't starve the rest

The walk ceiling added in the previous commit was seeded with maxEntries, the
same number the entry limit uses. Sizing an entry that busts the file-count
limit walks maxEntries + 1, driving the ceiling negative, so every later
`.worktreeinclude` entry was refused without being measured at all.

That regressed the common case: a repo listing `node_modules` plus `.env` used
to get `.env`; it silently got nothing. Reproduced, and now covered by a test
that fails when the headroom is removed.

The walk now gets 5x the entry budget, so total sizing work stays bounded
(<=250k lstat per materialization, vs the 1000 x 50k this ceiling exists to
prevent) while ordinary lists never reach it. Entries refused because earlier
ones exhausted the walk report a distinct 'sizing' reason, so the warning stops
quoting size limits at a 4-byte file that was never measured.

* fix(worktree): bill a failed clone's bytes, and blame the right ceiling

Two follow-on defects from the copy-on-write fix:

- A predicted APFS clone that then failed mid-copy (EPERM, ENOSPC) fell through
  to a real `fs.cp` whose bytes were never charged, because the entry had been
  admitted on the premise that cloning is free. That reopened the unbounded
  copy on macOS. The measured size is already known, so the fallback now bills
  it and refuses if it no longer fits, reporting the entry as skipped instead
  of silently copying gigabytes. A clone that was never viable
  (ApfsCloneUnavailableError) was already charged as a real copy, so that path
  keeps falling back as before.

- The walk ceiling is also applied inside the measurement via
  min(remainingEntries, remainingWalk), and when the walk term bound, the
  refusal was still reported as 'entries' — telling the user a 3-file directory
  busted a 4-file limit. It now attributes to whichever ceiling actually bound.

Also fixes the singular warning text, which said "entry X was not copied ...
copying them would exceed ... Copy them in manually".

* fix(worktree): flag a partial clone leftover, cap the warning, cover two branches

- A clone that fails partway only removes an *empty* reservation, so leftovers
  can survive at the target. Reporting that entry as simply "not copied" sent
  the user to copy it in manually, straight into a half-populated directory.
  Those skips now carry mayBePartial and the warning says to check the path
  first. Cleaning up the leftovers stays the deferred follow-up it already was.

- The warning enumerated every skipped path. `.worktreeinclude` allows 1000
  entries and all of them can be skipped, so it now names five and counts the
  rest — an unbounded string is a poor look in a PR about bounds.

- Two load-bearing branches had no test, both proven by surviving mutants: the
  `bytesAreCopied` short-circuit (reachable when a wedged df/diskutil makes the
  volume probe answer "no clone", so bytes are charged up front and must not be
  billed twice), and chargeBytes actually consuming budget for later entries.

* fix(worktree): only flag directory clones as partial, and cap that list too

- mayBePartial was set for every refused clone fallback, but only a *directory*
  clone can leave anything behind: the file path clones into a temp name and
  publishes with link(2), so a failure leaves nothing at the target. Sending
  the user to inspect a path that does not exist is its own small lie.

- The partial-copy sentence sliced to five names without the "and N more" that
  the other sentence appends, so entries past the fifth were surfaced nowhere.
  Both sentences now share one nameList helper.
2026-07-25 05:11:03 -07:00
Brennan Benson 159057c5d4 test(git): cover the false-positive class the header fix also removes (#10547)
The old anchored regex matched neither branch on a `[section "sub"]key = value`
line, so the parser never left `[core]` and credited the next indented line to
it — reporting sparse for a worktree git says is not. Fails on the pre-fix
parser (returns true where git reports unset).
2026-07-25 04:36:40 -07:00
Brennan Benson 9d02782969 fix(git): read core.sparseCheckout the way git does (#10537)
* fix(git): read core.sparseCheckout the way git does

Sparse-checkout detection parsed git config line-by-line and only accepted a
section header alone on its line, so git's legal same-line form
`[core] sparseCheckout = true` matched neither branch and was silently skipped:
a genuinely sparse worktree lost its badge and partial-checkout warning. It also
read `config.worktree` unconditionally, although git honors that file only while
extensions.worktreeConfig is on, so a stale worktree config could override the
repo's real setting.

Headers are now consumed left-to-right off each line (further headers and one
assignment may follow), and config.worktree is read only behind the extension
gate. Every new expectation was confirmed against real `git config --get`.

* test(git): correct what git actually does with a trailing-junk config value

Git does not reject `[core] sparseCheckout = true bogus = false` outright: it
parses the line and takes the whole tail as one value (`git config --list`
reports `core.sparsecheckout=true bogus = false`), then fails only the boolean
coercion. The expectation is unchanged; the comment now matches the binary.
2026-07-25 03:51:26 -07:00
NeilandOrca 879aad7dd6 oom(foundation): bound shared readers/limits + add BoundedMap primitive (#10299)
* oom(01): A1-shared-readers — reintroduce #10179 subset

Files: 18 applied, 0 deleted (from 6eb70d8370)

Co-authored-by: Orca <help@stably.ai>

* oom(02): A2-shared-image-media — reintroduce #10179 subset

Files: 7 applied, 0 deleted (from 6eb70d8370)

Co-authored-by: Orca <help@stably.ai>

* oom(03): A3-shared-fs-listing — reintroduce #10179 subset

Files: 21 applied, 0 deleted (from 6eb70d8370)

Co-authored-by: Orca <help@stably.ai>

* oom(04): A4-shared-remote-relay — reintroduce #10179 subset

Files: 8 applied, 0 deleted (from 6eb70d8370)

Co-authored-by: Orca <help@stably.ai>

* oom(05): A5-shared-misc — reintroduce #10179 subset

Files: 28 applied, 0 deleted (from 6eb70d8370)

Co-authored-by: Orca <help@stably.ai>

* oom(06): B-shared-wiring — reintroduce #10179 subset

Files: 81 applied, 0 deleted (from 6eb70d8370)

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-24 21:36:57 -07:00
moseohandBrennan Benson 1aaf049a4d fix(rate-limits): keep Codex PTY reset text for weekly-only plans (#8643)
* fix(rate-limits): keep Codex PTY reset text for weekly-only plans

The PTY /status fallback parses '5h limit' and 'Weekly limit' lines by
label, but the extracted reset text was only ever attached to the
session window. Codex plans without a 5h session bucket (e.g. current
Pro) produce a weekly-only parse, so the reset time the CLI printed was
silently dropped. Fall back to the weekly window when no session window
exists.

* review: parse Codex PTY reset text per window into resetsAt

* review: make Codex PTY status fallback work on codex >=0.145

* review: harden PTY status parse against model-scoped rows and styled output

* fix(rate-limits): strip private PTY control sequences

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-07-24 16:26:55 -07:00
Brennan Benson b600e25fa1 feat(worktrees): support project-level .worktreeinclude (literal paths) for copying gitignored files into worktrees (#9791)
* feat(worktrees): copy project-level .worktreeinclude paths into new worktrees

Read .worktreeinclude at the repo root (gitignore syntax) and copy matching
gitignored paths from the primary checkout into each newly created local
worktree, so .env and other local config carry over with zero per-user setup.

- Literal patterns resolve by direct stat; globs match against
  ls-files --others --ignored --exclude-standard --directory (collapsed
  dirs keep huge repos fast); every candidate is re-verified with
  check-ignore so tracked or unignored files are never copied.
- Copy semantics, never symlink: APFS clone-copy on macOS, real copy
  elsewhere, so each worktree owns its files (unlike repo.symlinkPaths,
  which it merges with rather than replaces).
- Failures never block worktree creation.
- Remote (SSH) creation skips it, same as symlinkPaths.
- Split APFS clone helpers into worktree-apfs-clone.ts (max-lines).

Closes #7549

* fix(worktrees): harden worktree include copying

* fix(worktrees): support nested includes on Git 2.25

* fix(worktrees): bound include copy costs

* fix(worktrees): close include correctness and perf gaps

* fix(worktrees): preserve included copy semantics

* fix(worktrees): harden include resolution

* fix(worktrees): preserve bounded include resolution

* fix(worktrees): bound include filesystem resolution

* fix(worktrees): harden include matching

* fix(worktrees): tighten include matching and scan bounds

* fix(types): use concrete filesystem stat types

* fix(worktrees): harden included path materialization

* perf(worktrees): stop include parsing at resolver budgets

* chore(skills): refresh bundled skill manifests

* refactor(worktrees): reduce .worktreeinclude to focused literal-only scope

The reviewed implementation grew well past the ticket (#7549), which asks for a
size-M feature that reuses existing worktree machinery. Trim back to the minimal
change that solves the reported problem safely:

- Resolver now supports literal files and directories only. Glob/negation lines
  are skipped with a warning (documented follow-up), which removes the entire
  user-controlled-regex ReDoS surface, the CPU/byte budgets, the git enumeration
  scan, and the case-sensitivity engine. The filesystem + git check-ignore
  handle existence and case for free.
- Copy layer folded back into worktree-symlinks.ts (link/copy modes share one
  loop); dropped worktree-path-copy.ts, worktree-target-safety.ts, the
  descendant-dedup/realpath/target-parent machinery, and the per-materialization
  APFS filesystem cache. Kept the df/diskutil probe timeout.
- Reverted unrelated changes: check-ignored-paths timeout param and the
  git-binary-compatibility enumeration tests.

Net: -1903/+172 across the include+copy code. Behavior for the ticket's cases
(.env, .env.local, .vscode/, node_modules, config/secrets.json) is unchanged;
gitignored-only + copy-not-symlink semantics preserved.

Closes #7549

* fix(worktrees): dereference symlinked .worktreeinclude entries + cache APFS volume probe

Two issues found by review + perf audit of the copy path:

- Correctness (HIGH): a listed entry that is itself a gitignored symlink was
  copied AS a symlink (fs.cp dereference:false), and the darwin APFS branch was
  skipped for all symlink sources. Editing the worktree's copy then wrote through
  to the shared/primary target — inverting copy-mode's 'each worktree owns its
  files' guarantee, and escaping the worktree entirely if the link pointed
  outside it. Now resolve realpath for a top-level symlink in copy mode so we
  copy content; nested symlinks inside a copied dir stay as-is (cp -R semantics).

- Perf: assertSameApfsVolume ran df+diskutil per copied path (4 subprocesses
  each), so an N-entry include spawned ~4N short-lived processes on the macOS
  create hot path, all re-probing one volume. Add a per-materialization
  device-keyed cache: one probe per distinct volume (4N -> ~4).

Tests: symlinked-file and symlinked-dir dereference regressions (no leak to
primary); APFS volume probed once regardless of copied-path count.
2026-07-24 15:31:23 -07:00
JinjingandOrca 772081577e Fix fork PR/MR worktree creation race via durable review-head refs (#10429)
* Fix fork PR/MR worktree creation race via durable review-head refs

When creating a fork PR/MR worktree, concurrent `git fetch origin` operations
clobber the shared FETCH_HEAD, causing the wrong commit to be checked out.
Fetch PR/MR heads into dedicated per-review refs (`refs/orca/pull/<N>`,
`refs/orca/merge-requests/<N>`) that persist and isolate each head from other
fetches. Gracefully keep the compare-base when the fetch fails but the local
ref already exists, avoiding silent fallback to the wrong branch on transient
network errors.

* Bound PR/MR head fetches with 60s timeout

Prevent PR/MR creation from hanging when a remote is stalled or
unreachable. Both GitHub and GitLab head fetches now enforce a
60-second timeout, matching the bound used in the create-path
fetch. Durable refs (refs/orca/pull/*, refs/orca/merge-requests/*)
decouple the ref from FETCH_HEAD, preserving legacy client semantics.

* test: align CI expectations with main PowerShell/sparse regressions

PR checks merge into main, which recently changed PowerShell launch args
(cwd restore after profiles) and sparse-checkout detection (require
core.sparseCheckout). Derive PowerShell spawn args from the production
resolver, mock the sparse config flag, reset shared worktree list scan
cache between tests, and stop requiring floating polls to avoid getRepos
hydration.

* Address review follow-ups on durable review-head refs

- Unify PR review-head remote selection: local and SSH GitHub paths share
  resolveGitHubReviewHeadRemote, which prefers the remote mapping to the
  hosting GitHub project (upstream before origin, matching work-item/API
  candidate order) so contributor clones fetch refs/pull from the repo
  that actually hosts the PR.
- Soft-keep durable review heads: when the PR/MR head fetch fails but
  refs/orca/pull/<N> / refs/orca/merge-requests/<iid> still resolves,
  keep the pinned SHA (warn) instead of failing resolve, mirroring the
  compare-base fallback. Extracted shared compare-base soft-keep into
  compare-base-ref-fetch.ts.
- Extract fetchGitLabMergeRequestHeadRef (local + SSH) parallel to the
  GitHub helper; bound its local fetch with the shared 60s timeout.
- Share relay-style fetch validation (positive safe-integer id, remote
  not starting with "-") between relay and local helpers via
  review-head-tracking-ref.ts; move REVIEW_HEAD_FETCH_TIMEOUT_MS there.
- Drop the githubPullRequestHeadLocalRef re-export; resolve head SHAs via
  rev-parse --verify <ref>^{commit}.
- Add GitLab anti-FETCH_HEAD regression test plus durable-head soft-keep
  and remote-selection unit tests.

Co-authored-by: Orca <help@stably.ai>

* test: supply live getRepos for terminal-retirement hydrates

Main's headless tab hydrate (#9343) skips worktree keys whose repo is not
in getRepos. Retirement tests that rebuild mobile tabs from a persisted
session now advertise the fixture repo as live so PR Checks merge stays green.

* fix(editor): extract RichMarkdownEditor props to stay under max-lines

Main's SSH external-image wiring (#10323) pushed RichMarkdownEditor.tsx over
the 400-line tsx budget, failing PR Checks lint on every merge into main.
Move the props type into a sibling module so the component stays under the
limit without disabling max-lines.

* Make durable review-head refs remote-identity scoped

Embed remote name + URL hash into refs/orca/pull|merge-requests refs to prevent soft-keep from serving wrong project's PR/MR when FETCH_HEAD is clobbered by concurrent fetch. Fetch functions now return the written ref path (writer-authoritative) so callers rev-parse exactly what was fetched, not re-derive identity. Soft-keep only applies to transient errors (timeout, network); fails hard on missing refs, auth failures, and stale relay. Relay returns localRef so client avoids re-hashing (URL normalization can disagree).

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-24 12:53:18 -07:00
Taras Kornichuk e3cc08f185 fix(worktree): don't flag a disabled sparse checkout as sparse (#9922)
`git sparse-checkout disable` restores the full working tree and sets
core.sparseCheckout=false, but deliberately leaves <gitdir>/info/sparse-checkout
in place so the checkout can be re-enabled with the same patterns.
detectSparseCheckout treated the mere presence of that pattern file as "sparse",
so a fully-populated worktree kept showing the sparse badge and the misleading
"Partial checkout. Files outside these paths are not on disk." tooltip.

Gate the fast-path fs.stat behind a config read that confirms core.sparseCheckout
is actually enabled (shared repo config or per-worktree config.worktree, honoring
git's precedence). The config read runs only when a non-empty pattern file
exists, so it does not reintroduce the per-poll subprocess fan-out PR #1290
removed, and it reads git's config files directly (no subprocess).

Adds a real-git regression test (enable -> disable leaves file -> not sparse) and
unit tests for the git-config boolean parser.
2026-07-24 00:26:34 -07:00
Tingde Liu 1ace87c155 fix(wsl): route global CLI fallbacks to user-pinned terminalWindowsWslDistro (#9734) 2026-07-24 00:26:25 -07:00
NeilandOrca aab112933e Revert "fix(memory): bound OOM-prone accumulators (#10179)" (#10255)
Co-authored-by: Orca <help@stably.ai>
2026-07-23 18:35:31 -07:00
Neil 8f40ddf328 fix(memory): bound OOM-prone accumulators (#10179) 2026-07-23 06:22:56 -07:00