Commit Graph
3 Commits
Author SHA1 Message Date
NeilandNeil dff2ff0ec3 fix(git): read the diff working tree and stamp through the host path spelling (#17896)
Git can execute inside a WSL distro against a raw Linux worktree path while Node,
on the Windows side, reads the same files back through Win32. `path.join(
'/home/me/repo/feature', 'src/file.ts')` on win32 produces the drive-relative
`\home\me\repo\feature\src\file.ts`, which resolves against whatever the current
drive happens to be and almost always ENOENTs. The same mis-spelling hits the
drvfs form, where `/mnt/c/repo` should read as `C:\repo`.

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

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

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

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

What changes for users

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

What this does NOT do

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

Costs and residual risk

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

Verification

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

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

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

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

Co-authored-by: Neil <79079362+brennanb2025@users.noreply.github.com>
2026-09-01 02:39:48 -07:00
Neil 96565fe370 perf(source-control): stop re-running every git read on each file selection (#15036) (#16600)
* perf(source-control): stop blocking main on four sync git-dir probes per status poll

detectConflictOperation ran four existsSync calls against the git dir on every
status poll. On a `\\wsl.localhost\...` worktree each one is a 9p round trip, and
being synchronous they landed on the Electron main thread back to back.

Replace them with concurrent fs/promises access probes: same "any failure reads
as absent" semantics existsSync had, one wave instead of four serialized blocking
calls. The outer try/catch went with them -- neither resolveGitDir nor the probes
can throw now, so it was unreachable.

Part of #15036 (source-control latency).

* perf(wsl): let git reads take the shell-free route from a cwd-derived distro

shouldAttemptWslDirectGit required options.wslDistro, so a `\\wsl.localhost\...`
worktree without a resolved WSL project runtime never qualified -- even though the
distro is right there in the cwd and wslDistroForCommand already knew how to read
it. Every `git show` behind a diff therefore ran through the user's login shell,
executing their rc once per blob read.

Three changes:

- Derive the distro from the cwd when no override was supplied. This is the fix;
  the routing decision now depends on where the repo actually lives.
- Wait, bounded, for a cold read-environment probe instead of resolving without it.
  The probe is one wsl.exe call shared per distro, so the wait is paid at most once,
  and past WSL_GIT_READ_ENVIRONMENT_WAIT_MS the shell route runs exactly as before.
  It returns null rather than a settled promise when there is nothing to wait for,
  so a non-WSL git call is not pushed into a later microtask.
- Opt the blob reads into preferWslDirectGit via gitReadOptionsForWorktree (renamed
  from gitStatusReadOptionsForWorktree; it was never status-specific). Belt-and-
  braces only: `show`, `config --get-regexp`, `ls-files` and `rev-parse` were all
  already matched by isWslDirectGitReadCommand, so this changes no routing today --
  it just stops the diff path depending on a heuristic it knows the answer to.

git-blob-read also gains a `failed` flag distinguishing "git ran and reported the
path absent" (exit 128) from "the read never got an answer"; nothing consumes it
yet, the settled diff cache does.

Part of #15036 (source-control latency).

* perf(source-control): give diff reads a settled cache keyed on stamped git state

gitDiffReadDedupe coalesces only while a read is in flight, so every file
selection re-ran the whole read: a `git config --file .gitmodules` spawn, one or
two `git show` spawns, and a working-tree stat+read. On a WSL/UNC worktree each
git spawn is a wsl.exe invocation, which is the ">3s Loading diff..." in #15036.

Correctness first -- a stale diff is worse than a slow one. The cache never
expires on a clock and there is no TTL to tune. Instead:

- worktree-diff-stamp.ts takes a subprocess-free stamp of exactly the inputs a
  file diff is built from: HEAD (by resolved tip *content*, so a commit is
  visible even though HEAD's own bytes never move), `.git/index` (mtime+size),
  `.gitmodules` (submodule routing), and the working-tree file. A linked
  worktree's commondir and the packed-refs/reftable fallback are handled; an
  unborn branch is caught by recording "no loose ref" rather than only the
  packed stamps.
- The stamp is captured BEFORE the read and stored with the result. Anything
  that moves during or after the read leaves the stored stamp behind, so the
  next lookup misses. That, not a freshness window, is why a stale diff cannot
  be served.
- A store is refused unless the stamp was taken a full mtime bucket (2s, FAT's
  granularity) after its newest component. Below that, a second write inside the
  same bucket would be invisible -- git's own racy-index rule.
- `null` stamp means "cannot prove" and never caches: a folder workspace, a repo
  whose layout cannot be read, or a filesystem reporting no usable mtime.
- Submodule routes and reads that failed rather than proved absence are not
  reusable. A wsl.exe hiccup produces the same empty left side a new file does,
  and pinning that would persist a wrong diff.
- invalidateGitReadCaches clears it and bumps a generation, so a read that
  started pre-mutation cannot store its result post-mutation.

`ino` is deliberately optional in the working-tree component: Windows reports 0
for it on the redirector behind `\\wsl.localhost`, and requiring an unstable 0 to
match would make the cache silently never hit on the exact host it exists for.
Cache counters are exposed for the same reason -- a miss storm and a cold start
otherwise look identical.

Also drops gitDiffReadDedupe.clear() from getStatus. A status poll is a read; all
it did was destroy a live coalescing entry so a concurrent identical request
started duplicate git work. Mutations still invalidate through the shared point.

Memory is bounded by retained characters, not entry count -- one diff result can
legitimately hold megabytes.

Fixes the source-control half of #15036.

* perf(source-control): reuse BoundedMap and stop the WSL probe wait from outliving its answer

Review follow-ups on the settled-diff-cache work:

- SettledDiffCache now sits on the shared BoundedMap instead of hand-rolling the
  same Map + character ledger + evict-oldest loop.
- pendingWslDirectGitReadEnvironment returns null once the probe has settled
  either way, so a distro whose direct route was disabled no longer pays for a
  1.5s timer and two microtask hops on every git read.
- That wait now honours the read's abort signal and goes through withTimeout, so
  an aborted read is not held for the full bound and a probe rejection can never
  surface as a read failure.
- The settled-cache generation fence is taken before the stamp read, so a
  mutation that lands entirely inside the stamp's stats can no longer store an
  entry whose stamp is torn across it.
- The cache counters are folded into the main-thread churn probe report, which is
  what tells a permanently-cold cache apart from a cold start in the field.

* fix(source-control): tell WSL clock skew apart from a genuinely fresh write

The racy-write margin compares two clocks: capturedAtMs is this host's, while
the component mtimes come from whatever wrote the files. On a \\wsl.localhost
worktree the guest sets them, so a guest running ahead pushes every
recently-touched file past the margin and the cache refuses to store — for as
long as the skew lasts, on exactly the platform this cache exists for.

Nothing was wrong with the refusal; it was invisible. racyWrites alone cannot
distinguish "the repo was just edited" from "the clocks disagree and this will
never resolve on its own", so a permanently cold cache looked like a cold start.

isDiffStampClockSkewed flags the one thing no local write can produce — an mtime
in this host's future — and the cache counts those separately as
clockSkewedWrites. A nonzero count is the signal that the cache is off for a
reason idling will not fix.

Found by review of #16600; behavior is unchanged, only observability.
2026-08-26 15:44:11 -07:00
Neil 33587fb77d refactor(git): split status.ts into source-control modules (#16393) 2026-08-25 02:24:11 -07:00