Skip to content

fs: expose frsize field in statfs results - #62495

Open
juicecultus wants to merge 1 commit into
nodejs:mainfrom
juicecultus:fs-statfs-expose-frsize
Open

juicecultus wants to merge 1 commit into
nodejs:mainfrom
juicecultus:fs-statfs-expose-frsize

Conversation

@juicecultus

Copy link
Copy Markdown

Description

fs.statfs() exposes bsize (optimal I/O block size) but not frsize (fundamental filesystem block size). Per POSIX, block counts (blocks, bfree, bavail) are in units of frsize, not bsize. libuv already reads f_frsize from the kernel and stores it in uv_statfs_t — it was simply never mapped to JavaScript.

On most native filesystems bsize == frsize, so the omission was harmless. However, on FUSE mounts (e.g. Docker Desktop on macOS with VirtioFS or gRPC FUSE), they can diverge by orders of magnitude:

Field Value * blocks result
bsize 2,097,152 (2 MiB) 1.82 PiB
frsize 16,384 (16 KiB) 14.9 TiB

This causes any application computing disk space as bsize * blocks to report wildly inflated values. Real-world example: Immich photo manager shows "602 TiB of 1.8 PiB" on a 15 TB disk when running in Docker on macOS.

Changes

Adds the frsize property to the StatFs object returned by fs.statfs(), fs.statfsSync(), and fsPromises.statfs(), in both normal and bigint modes.

Files changed:

  • src/node_file.h — add kFrSize to FsStatFsOffset enum
  • src/node_file-inl.h — map s->f_frsize in FillStatFsArray()
  • lib/internal/fs/utils.js — add frsize to StatFs class and binding
  • doc/api/fs.md — document the new field
  • test/parallel/test-fs-statfs.js — include frsize in property checks
  • test/parallel/test-fs-promises.js — include frsize in type assertions

Notes

  • On Linux, libuv sets f_frsize from the kernel's statfs.f_frsize
  • On non-Linux (macOS, etc.), libuv falls back to f_frsize = f_bsize, so this is always populated
  • This is a semver-minor addition (new property on existing object)

Node.js statfs() exposes bsize (optimal I/O block size) but not frsize
(fundamental filesystem block size). Per POSIX, block counts (blocks,
bfree, bavail) are in units of frsize, not bsize. libuv already reads
f_frsize from the kernel — it was simply not mapped to JavaScript.

On most native filesystems bsize == frsize, so the omission was
harmless. However, on FUSE mounts (e.g. Docker Desktop on macOS with
VirtioFS or gRPC FUSE), they can differ by orders of magnitude
(bsize=2MiB vs frsize=16KiB), causing applications that compute disk
space as bsize*blocks to report wildly inflated values.

This commit adds the frsize property to the StatFs object returned by
fs.statfs(), fs.statfsSync(), and fsPromises.statfs(), in both normal
and bigint modes.
@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Mar 29, 2026
@codecov

codecov Bot commented Mar 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.71%. Comparing base (bdf75a6) to head (cd87e8a).
⚠️ Report is 1524 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #62495   +/-   ##
=======================================
  Coverage   89.71%   89.71%           
=======================================
  Files         692      692           
  Lines      213988   214042   +54     
  Branches    41054    41062    +8     
=======================================
+ Hits       191976   192026   +50     
- Misses      14086    14090    +4     
  Partials     7926     7926           
Files with missing lines Coverage Δ
lib/internal/fs/utils.js 99.68% <100.00%> (+<0.01%) ⬆️
src/node_file-inl.h 87.71% <100.00%> (+0.05%) ⬆️
src/node_file.h 78.43% <ø> (ø)

... and 35 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jazelly jazelly added the fs Issues and PRs related to file-system APIs and the fs module. label Mar 31, 2026
Comment thread src/node_file-inl.h
SET_FIELD(kBAvail, s->f_bavail);
SET_FIELD(kFiles, s->f_files);
SET_FIELD(kFFree, s->f_ffree);
SET_FIELD(kFrSize, s->f_frsize);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like GHA is complaining about this on some platforms.

@bo0tzz

bo0tzz commented Apr 18, 2026

Copy link
Copy Markdown

Dupe of #62277?

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been marked as stale due to 90 days of inactivity.
It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.

@github-actions github-actions Bot added the stale Issues and PRs marked stale due to inactivity and scheduled for automatic closure. label Jul 28, 2026
hugoforte added a commit to hugoforte/rig that referenced this pull request Sep 23, 2026
Adversarial review (F17): on Linux `fs.statfsSync(dir)` gives `bavail` in
`f_frsize` units, but Node reports only `f_bsize`, so `bavail * bsize` is wrong
wherever the two differ. On a FUSE work root with a 2MiB bsize over 16KiB blocks
(Docker Desktop's virtiofs, nodejs/node#62495) a disk with 5 GB free read as
640 GB, and doctor printed "disk on /mnt/wr 640 GB free" (ok) instead of "only
5 GB free" (counted). Node exposes no frsize to correct it with, so statfs stays
on Windows, where libuv counts blocks in the bsize it reports and where the
202ms PowerShell probe was; POSIX goes back to `df -Pk` and `parseDf`, with its
four output-shape tests. The installation test's no-PATH free-space line is
asserted on Windows only: off it there is no `df` on that PATH, the check is
dropped, and doctor still has to reach its verdict.

Adversarial review (F43): the `df -Pk` format comment `parseDf` left behind was
sitting on top of freeSpace's comment and read as its first paragraph. It
describes `parseDf` again, directly above it.

The rewritten freeSpace comment also stops claiming an unreportable path is the
only way to get null (F18): no `df` on PATH, or a Node without
`fs.statfsSync`, answers null too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
hugoforte added a commit to hugoforte/rig that referenced this pull request Sep 23, 2026
…of them (#144)

* Split close.test.mjs into one file per subject

`node --test` runs files in parallel and the tests within one file in order,
so six subjects in one 1002-line file was six subjects on one core — and that
file alone set the wall clock of the whole suite.

The subjects were independent already: each built its own works, and only the
installation crossed between them. That installation is now
test/billing-install.mjs, which also holds the moves the tests make against it
— publish, the squash merge, cutStage, the issue and PR seeds — and slicedWork,
the two-stage work that stage-cut, pr and plan each need before they have
anything to render.

Six installations cost about twenty seconds more work in total and about
ninety seconds less wall clock. All 69 tests survive, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Read a checkout in two git calls instead of six

`describe` asked git six separate questions — the toplevel, the branch, the
upstream, a `rev-list` per direction, and the tree — and `git status
--porcelain=v2 --branch` answers five of them in one call, which is what the
v2 format's header is for.

It runs at least twice per mutating command, so this is the tool's own latency
as much as the suite's.

`branch.ab` is absent exactly when git could not measure, and `branch.upstream`
says which of the two reasons it is: nothing configured, or a ref that has gone
— the state a squash merge leaves behind. So the null `countCommits` drew
survives, on better evidence than `rev-parse @{u}` could give, which failed
identically for both. One fake in checkouts.test.mjs goes with it: the answer
it existed to produce is one real git now gives on demand.

The six survive as a fallback. `status` reads the index and `rev-list` does
not, so a corrupt index is a tree git cannot read and a distance it still can;
collapsing that into one call would report a checkout nothing is known about
and stop `fastForward` before git gets to refuse in its own words.

`onPath` is memoised for the same reason: PATH cannot change inside a process,
and a tenth of every process rig started across the suite was `git --version`
being told what the last one already said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Read free space from the runtime, not from a subprocess

PowerShell cost 202ms a call — four times a `git`, and by some way the most
expensive thing rig ever started. It ran 88 times across the test suite, for a
number `fs.statfsSync` answers in 0.07ms without starting anything.

It also collapses the platform split. `df -Pk` and `(Get-PSDrive X).Free` both
go on to ask the filesystem the same question, so there is one implementation
now instead of two, and `parseDf` and its four output-shape tests go with the
second one.

Decision 54 stands and is easier to keep: a check rig cannot make is dropped,
never fatal. There is no probe left to be missing, so the only way this answers
null is a path the filesystem will not report on — a work root on a
disconnected share, or one that is not there yet.

The test that pinned the old failure is now the better property: free space is
answered on a machine carrying nothing on PATH but node. That is what
hugoforte/rig#7 was really asking for.

`label` changes off Windows, from `df`'s mount point to the directory that was
measured. statfs cannot name a mount point, and the reader is asking about
their work root rather than about `/`.

Needs Node 18.15, where `fs.statfsSync` arrived; the engines floor and the
README say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Read where a checkout is off the filesystem, not out of a subprocess

A git subprocess costs 55-65ms on Windows and the suite made 4818 of
them, of which `rev-parse --show-toplevel` alone was 918. That question
is walk up from here until something is a repository, which is a handful
of stat calls.

`bin/gitfs.mjs` answers it, along with the branch HEAD names and the two
git-dir paths the walk lands on. Everything it answers is what git would
have said and everything else is null, which means ask git:
`core.worktree`, a ref storage it cannot read, and `GIT_DIR` and its
relatives each hand the question back, because a fast wrong toplevel
would put a work's records in the wrong data root.

4818 spawns to 3457. A third of that is `git --version` probes nothing
needs any more, since the commonest answer -- this is not a checkout --
now needs no git on PATH to give.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Stop asking git the questions rig already has the answers to

A git subprocess is 55-65ms on Windows and `git --version`, which does nothing,
is 55 of them — so the cost of a call is its existence, not its work. Six
places were buying an answer they already held or would never read. 4354 git
spawns across the suite become 3623.

`state()` asked `rev-parse --abbrev-ref @{u}` to find out whether the count it
was about to make would work, then resolved the base ref whether or not
anything ever measured against it. The count is the question, so the count is
what is asked: `@{u}` fails to resolve for both the reasons there are, and the
count fails with it, in git's own words. `rig status` and `rig next` go from
seven spawns to five.

`chain()` asked `rev-parse --verify` twice per stage branch to find out which
of them a repo carries. The branches are all named up front — that is what a
declared stage is — so one `for-each-ref` over the exact refnames answers for
the lot, and ancestry between one pair of commits is asked once however many
callers want it.

`commitDataRoot` re-read the data root that `prepareDataRoot` had read at the
top of the same command. What a command changes there is the tree; what kind of
checkout it is, its branch, its upstream and whether anything was waiting to be
pushed are the same facts they were, so those are carried over and the tree
half arrives null rather than stale. `rig new` goes from ten spawns to seven,
`rig attach` from eighteen to fifteen.

`commitAll` named HEAD after a commit it had not made, on the commonest path
through `rig save`. The precise `diff --cached --quiet` exit code stays exactly
where it was: 0 is "nothing staged", 1 is "something is", and above that is git
failing to say.

The resolution order asks which repo the cwd is in only where some root could
answer — a repo is placed by its catalogue entry, so an installation with none
has already said no, and `rig init` was spending a subprocess to be told so.

The freshness epilogue decided from the cache what it could decide from the
cache before reading HEAD, rather than after.

Not taken: `rig attach`'s `remote set-head -a` followed by `symbolic-ref` could
be one `ls-remote --symref`, but that stops maintaining the mirror's
`origin/HEAD`, and `ensureFirstCommit` throws the hash away that `commitAll`
buys it, which is one caller's worth of reason to put an option on a five-
outcome interface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Let the epilogue find out it has nothing to do without spawning

The freshness epilogue discovered that the copy rig is running from is not a
checkout by spawning a `rev-parse HEAD` and watching it fail — once per
command, and ~370 times across the suite. It is the commonest way for the
check to have nothing to do, and it was the one path paying for a process.

`gitfs.discover` settles it off the filesystem. The reason this could not be
done before is the reason it can be done now: inferring "not a checkout" from
`fs.existsSync` would be the assumption `rebaseUnderway` refuses — asked of
git rather than guessed from a path — and `discover` is not a guess, it is
git's own walk with git's own rules about what makes a directory a repository.

`skipReason` still decides every other reason a checkout is not one to judge.
This is only the half the filesystem can settle.

`notARepository` gives that distinction a name, because acting on it is the
whole point: `discover` answers "ask git", "here it is", or "there is no
repository here", and a caller allowed to end the matter on the third must
never end it on the first. A machine setting GIT_DIR would otherwise have its
freshness check switched off for good. Tested against all three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give an invocation an interface, so a test can run one in-process

The suite drove the CLI as a subprocess 398 times. On the Windows runner
a Node boot is 51ms before rig's module graph is parsed, on four cores
that buy almost no parallelism — spent to get one thing: an environment
isolated from the machine.

So `bin/rig.mjs` exports `run(argv, io)`, where `io` is that environment
named — the installation the run is a run of, its cwd, its environment,
its stdin, and a writer per stream — and the exit code is the return
value. `main` is one line of it.

The five module-level bindings become one per-run value, together with
the two memoised tracker adapters; `run` puts back the one it displaced,
so what used to end when the process ended now ends when the invocation
does. `onPath` is the exception and keys on PATH instead: a run does not
own PATH, the machine does — which is what keeps 398 invocations down to
34 `git --version` calls rather than one each.

`test/harness.mjs`'s `rig()` gets a second adapter, opt in per
installation and per call. 368 of the 398 invocations moved. The 30 that
stayed are the ones whose subject is the process: `rig update`
re-executing the tool that arrived, the previous release on disk, and
`rig check --run`, whose catalogue commands inherit rig's stdio.

Two narrowings of behaviour. Every subprocess rig starts now gets the
run's cwd and environment rather than inheriting the process's, which is
what `bin/checkouts.mjs`'s `opts.env` stopped being spawnSync-shaped for;
that module also takes the run's `env` as a thunk, because `GIT_DIR` and
its four relations are what make `gitfs.discover` hand a question back.
And an unknown command sets `process.exitCode` where it used to
`process.exit(1)`, which a caller in the same process could not have
survived.

DESIGN.md decision 95.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Stop paying for a console nobody sees, and a launcher that only launches

Two process creations were stacked on every git call rig makes, and neither
did any work.

`windowsHide` was set on every spawn. CREATE_NO_WINDOW does not suppress a
console — it gives the child its own hidden one, which is a conhost.exe per
spawn. An ordinary command already has a console its children inherit, so it
was buying a second one to hide. The freshness refresh is the child it was
for and there it is load-bearing: spawned DETACHED_PROCESS it has no console
to inherit, and every git call it makes would pop a visible window. So that
run hides its spawns and no other run does.

`git` on PATH under Git for Windows is a 46KB launcher whose whole job is to
start the 4.4MB binary beside it. Measured here, 60ms through the launcher
against 32ms straight to the binary — on Windows the process creation is the
expensive part of a git call, and this doubles it.

Only that launcher is stepped past. Somebody's own `git` on PATH is a program
they put there deliberately, and going around it would be rig deciding it knew
better. So it is recognised rather than assumed: the file PATH resolves to has
to sit in a Git for Windows layout and have the real binary as a sibling under
mingw64. A shim anywhere else looks like nothing of the sort and is left alone,
which is also the answer for every case this cannot positively identify.

Both are asserted as options rather than behaviour, for the reason the file
already gives: the only symptom of getting them wrong is cost, and cost does
not show on an idle machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Read free space with statfs on Windows only, and with df everywhere else

Adversarial review (F17): on Linux `fs.statfsSync(dir)` gives `bavail` in
`f_frsize` units, but Node reports only `f_bsize`, so `bavail * bsize` is wrong
wherever the two differ. On a FUSE work root with a 2MiB bsize over 16KiB blocks
(Docker Desktop's virtiofs, nodejs/node#62495) a disk with 5 GB free read as
640 GB, and doctor printed "disk on /mnt/wr 640 GB free" (ok) instead of "only
5 GB free" (counted). Node exposes no frsize to correct it with, so statfs stays
on Windows, where libuv counts blocks in the bsize it reports and where the
202ms PowerShell probe was; POSIX goes back to `df -Pk` and `parseDf`, with its
four output-shape tests. The installation test's no-PATH free-space line is
asserted on Windows only: off it there is no `df` on that PATH, the check is
dropped, and doctor still has to reach its verdict.

Adversarial review (F43): the `df -Pk` format comment `parseDf` left behind was
sitting on top of freeSpace's comment and read as its first paragraph. It
describes `parseDf` again, directly above it.

The rewritten freeSpace comment also stops claiming an unreportable path is the
only way to get null (F18): no `df` on PATH, or a Node without
`fs.statfsSync`, answers null too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Label Windows free space with the drive the work root resolves to

Adversarial review (F7): `volumeOf` read the label off the path as written, while
`fs.statfsSync` follows junctions and symlinks, so the label and the number could
name different volumes. A work root junctioned from D: to C:\Users\Public
reported C:'s 54 GB as "disk on D: 54 GB free" while D: had 668 GB free; with the
target nearly full, doctor says "disk on D: only 12 GB free" and the user clears
a drive that is not full. That is exactly the layout of a work root moved off a
full system drive. The label now comes from `fs.realpathSync.native`, falling
back to the path as written when that cannot answer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Pin free space's units to a test that tells bavail from bfree and blocks

Adversarial review (F32): nothing pinned the arithmetic. The freeSpace tests only
asked for a finite positive number, and the installation test's
/disk on .+\d+ GB free/ matches "only 0 GB free" too, so `const bytes =
s.bavail` (blocks, not bytes: doctor reports a false "only 0 GB free" and exits
non-zero) and `s.blocks * s.bsize` (capacity, not free space) both passed the
whole suite. The product is `bytesFree` now, tested over a statfs answer whose
counts all differ, so a count on its own, `bfree` or `blocks` each fail it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Require a Node that has fs.statfsSync, on the 19 line as well as the 18

Adversarial review (F18): `fs.statfsSync` arrived in 19.6.0 and was backported to
18.15.0, but `>=18.15.0` and the README's "Node 18.15 or newer" both admit
19.0.0-19.5.x, which lack it. There the TypeError is caught, freeSpace answers
null, and doctor prints "ok node v19.5.0" with no disk line at all: reproduced
with the real 19.5.0 and 18.14.2 binaries. engines is `^18.15.0 || >=19.6.0`
now, and the README says the same. The freeSpace comment already names a Node
without `fs.statfsSync` as a way to get null; decision 54 keeps the check
dropped rather than fatal, so nothing is rethrown.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say what doctor's disk label and a null disk mean

Adversarial review (F42): doctor's comments still described the probes from
before free space moved to statfs. `disk: null` was "a machine with neither
free-space probe", but it also means a Node without `fs.statfsSync` or a work
root the filesystem will not report on; and the label "comes from the probe, not
from the path", when on Windows it comes from the path the work root resolves
to. A maintainer reading doctor.mjs was told the wrong thing about both. The
comments say what the label and a null mean, and the disk: null test is titled
for any measurement that could not be made rather than a missing probe. The
nearly-full fixture keeps its '/' label: with `df` back off Windows, a mount
point is what production reports there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Record in decision 54 that Windows reads free space with statfs

Adversarial review (F41): decision 54 still said free space was probed with
`Get-PSDrive` through PowerShell on Windows, which rig has not done since
statfs replaced it, and nothing in the log recorded the change. A reader of the
decision log went looking for PowerShell handling that is not there. The
superseded clause is struck through in place, per the log's convention, and the
entry names what replaced it and why POSIX keeps `df`. Its Enforced-by cell names
the four tests that fail if a check rig cannot make stops being dropped: the
disk: null doctor fixture, the unreportable path, unreadable `df` output, and
doctor with nothing on PATH.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test that a save with nothing staged still pushes an earlier commit

Adversarial review (F30): commitDataRoot decides whether to push on the
`ahead` count prepareDataRoot carried over, and no test covered the one
path that count alone decides: nothing staged, an earlier commit still
unpushed. With `ahead` dropped from stillTrueAtTheEnd, `rig save` said
"nothing to commit, nothing to push", left the commit on the machine,
and the full suite still passed. The new smoke test commits by hand
ahead of origin, saves with nothing new, and asserts the push.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Build the sliced work in a before hook, so a failed fixture still cleans up

Adversarial review (F25): plan, pr and stage-cut built the sliced work at
module scope, after registering after(cleanup). When the fixture threw (a
failed `rig attach`), module evaluation aborted, node:test never ran the
after hook, and each file reported one anonymous `not ok - test\<file>` in
place of its tests. Six rig-plan-/rig-pr-/rig-stage-cut- installations had
already leaked into %TEMP% this way. Built in before(), a failing fixture
fails every test by name and cleanup still runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Build the sliced work under its one name, with no id to pass

Adversarial review (F52): slicedWork took an `id` that none of its three
callers passed, and it only worked for the default. `rig new` names the
work branch after the title, so any other id went looking for a
`feat/<id>-work` that did not exist, and the PR numbers were fixed at 10
and 11 whatever the id was. Every caller wants `sliced`, so the helper
now says `sliced` throughout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say each file's subject once, without the old file's section banners

Adversarial review (F51): splitting close.test.mjs gave pr.test.mjs a
header and kept the section's intro under it, so the paragraph "Review is
the phase rig was most obviously absent from..." appeared twice, word for
word, and an edit to one copy would leave the file saying two things.
pr, stage-cut and stages-e2e each kept one section banner above their
first test, dividing nothing, where plan and stage-tickets had already
dropped theirs. stage-tickets' first test repeated its header's point
about closing keywords. Each is now said once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Take from the shared installation only what each file uses

Adversarial review (F53): the split copied close.test.mjs's full
destructuring into each new file. stages-e2e bound `git` and `gitMust`
and imported `path`, stage-cut bound `git` and `github`, and
stage-tickets bound `github`, none of which those files use. A reader of
stage-cut looking for where it inspects GitHub directly found a binding
and no use of it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Give the forced-close check an open pull request it could report

Adversarial review (F50): 'and a forced close is not then reported as a
contradiction' could not fail. Its doctor assertion asked for a rule
doctor never runs (it reads the records alone, with no pull requests),
under a comment about other works' contradictions that this file's own
installation no longer holds. Its status assertion could not fail either,
because the rule reads the work branch's pull request, #51, which had
merged. With `!work.forcedAt` removed from bin/phase.mjs, all 8 tests
still passed. The test now seeds an open pull request on the work branch
before asking status, and it fails without the exemption.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* State why billing-install's subjects are files apart, without guessed figures

Adversarial review (F48): the header gave the split's reason as "that
file alone set the wall clock of the whole suite" and priced it at
"about eight seconds of work and about one and a half of wall clock".
Neither figure was measured, and the work's own measurements showed the
suite bound by process creation and I/O, with concurrency 6 to 24
moving it 6%. A maintainer reading the header would split smoke.test.mjs
next and expect a win the record measured as nothing on CI. The header
now says what the file is, what running subjects apart costs and buys,
and why a full run gains less than that suggests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say that the smoke test runs rig in the test process, not a subprocess

Adversarial review (F47): smoke.test.mjs opened with "Runs a temp copy
of the tool as a subprocess" while setting `inProcess: true`, so its runs
go through this checkout's `run(argv, io)` and the copy only supplies
the installation's files. A contributor who edits code in the copy, or
relies on the process boundary, reads the header and gets neither.
scenarios.test.mjs called the other CLI suites subprocess suites, and
the README's Contributing section said nothing about the in-process
adapter. All three now say how a CLI test runs, and the README names
the rule's home: DESIGN.md decision 98 and test/harness.mjs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Pin that off Windows free space comes from df, and say what the Windows reading means

Verification of the free-space fixes: reverting freeSpace to statfs on every platform passed
every test, because the installation test only asserted the verdict off Windows. It asserts
again that no disk line appears when no df is on PATH, wherever node's own directory holds
none. Also: bytesFree's comment explained bavail by POSIX's reserved blocks, on the one
platform where libuv reports bavail and bfree as the same count; a mapped drive is now named
by its share, which nothing said; the freeSpace comment narrated what it replaced; decision
54 cited the umbrella PR rather than the ticket; and the junction test could leave its temp
directory behind if the junction could not be made.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep the runner's discovery variables out of the gitfs tests

Adversarial review (F33): test/gitfs.test.mjs copied process.env whole, so a runner
with GIT_DISCOVERY_ACROSS_FILESYSTEM=1 or any GIT_CEILING_DIRECTORIES exported made
discover hand every question back, and 14 of the 15 tests failed with "gitfs handed
the question back". gitfs now exports the list it checks, and the tests delete those
names from their environment, so the list is written once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Hand the question back when a discovery variable is set but empty

Adversarial review (F23): discover tested the variables that move git by truthiness,
so `export GIT_DIR=` read as unset. git tests getenv() != NULL: with GIT_DIR='' it
dies "not a git repository: ''", with GIT_WORK_TREE='' "The empty string is not a
valid path", and GIT_COMMON_DIR or GIT_OBJECT_DIRECTORY set empty also fail. gitfs
answered with the checkout in all four, so describe said 'own' and fastForward said
'no-upstream' where main said 'not-a-checkout'. A set variable, empty or not, now
hands the question back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Look for .git before treating a directory as a bare repository

Adversarial review (F6): discover asked whether a directory was itself a bare
repository before looking for a `.git` inside it, which is the reverse of git's
setup_git_directory_gently_1. After a stray `git init --bare .` in a data root's
checkout, git still reported the checkout as its toplevel, but gitfs answered
{top: null, gitDir: data-root}; describe said 'none' and commitDataRoot printed
"is not a git checkout — nothing committed", so records stopped being committed.
The walk now takes git's order, the comments and decision 97 state it the right way
round, and a test pins it with the only shape that tells the two orders apart. A
mirror under a checkout still answers for itself: it has no `.git`, and the walk
reaches it first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Judge a HEAD exactly as git's validate_headref does

Adversarial review (F9): VALID_HEAD was looser than git on one side and stricter on
the other. A nested data root whose HEAD read `ref: main` was accepted, so describe
said 'own' where git said the enclosing checkout was the toplevel, the 'nested'
guard was bypassed, and commitAll staged the outer repository's private.txt. A
nested checkout whose HEAD was its sha in capitals, or the sha with text after it,
was walked past and the outer checkout named as its toplevel. A HEAD now needs
`ref:` naming something under `refs/`, or forty hex digits in either case, which is
git's own test. A HEAD that is a symbolic link (core.preferSymlinkRefs) is read as
git reads it: a link into `refs/` names that ref without being followed, so an
unborn branch is still a repository and a branch, and a link anywhere else is no
HEAD.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Read a .git file as strictly as git, and only a file or a directory

Adversarial review (F12): gitFileTarget matched `gitdir:` on any line, with any run
of whitespace after the colon, and trimmed trailing spaces git keeps. A `.git`
holding `gitdir:../store`, or a comment line before the gitdir line, made git die
with "invalid gitfile format", but gitfs answered {top, gitDir} and identify called
the directory 'own'. It now takes `gitdir: ` exactly, at the very start, with only
the line ending stripped, as read_gitfile_gently does. And every `.git` that was not
a directory went through the gitfile read: on Linux and macOS a `.git` linked to
/dev/null read as empty and ended the walk as "no repository", and a FIFO would have
blocked the read forever, where git sees NOT_A_FILE and walks on. Only a regular
file or a directory is now a `.git` entry; anything else is walked past.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Read config keys wherever git's grammar allows them before trusting a walk

Adversarial review (F10): overridden() matched core.worktree, core.bare and
extensions.refStorage only as `key = value` at the start of a line, the way git
writes them. git's grammar also takes a key on its section header's line, and reads
a bare key with no value, a trailing comment, a quoted "true" and any non-zero
number as true. With `[core] worktree = <elsewhere>` git's toplevel is <elsewhere>
while gitfs answered the directory holding `.git`, so describe said 'own' and got
past commitDataRoot's 'nested' guard; each of the five `bare` spellings left git
saying "must be run in a work tree" while gitfs returned a top. Keys are now found
with the section header stripped and with or without a value, and anything short of
a plain `bare = false` hands the question back to git.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say what handing back a bare mirror's worktree costs

Adversarial review (F39): overridden()'s comment said handing a question back costs
"one subprocess in a layout nobody here has". Every worktree rig makes is a linked
worktree of a `git clone --bare` mirror whose common config says `bare = true`, which
git ignores for a linked worktree unless extensions.worktreeConfig is on; this module
reads it anyway, so discover returns null for rig's own commonest layout and identify
pays for the toplevel, branch and linked-ness again. The comment now says so. The
behaviour is unchanged: answering there is a follow-up, not this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Hand the question back when a walk on Windows leaves its device

Adversarial review (F22): discover copied POSIX git's stop at a filesystem
boundary and answered "no repository" there on every platform. Git for Windows
never compares devices (its st_dev is always 0), and Node's dev on Windows is a
volume's serial for a reparse point and 0 for an ordinary directory on some Node
versions. With C:\repo\data a folder mount of a second volume, git run from
C:\repo\data\x reports C:\repo, but gitfs answered {top: null, gitDir: null}, so
topOf said 'none' and repoAtCwd returned null without asking git. On Windows a
device change now hands the question back; elsewhere it is still the answer POSIX
git gives. The comments that said the walk reproduces git's stop now say where.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Name a branch that is a symbolic ref among headBranch's differences from git

Adversarial review (F40): headBranch's comment listed two ways it differs from
`symbolic-ref --short HEAD` and left out a third. After `git symbolic-ref
refs/heads/master refs/heads/main; git symbolic-ref HEAD refs/heads/master`,
`symbolic-ref` and status's `branch.head` both follow the chain to `main`, but
headBranch reads one level and says `master`, so identify and describe name
different branches for the same checkout. The comment now names that case and says
git's answer is the better one there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Put gitfs's suite measurement in the past tense

Adversarial review (F37, gitfs half): the module header said the test suite
"makes some forty-eight hundred" git spawns, a measurement taken before the module
existed and stated as if it were still true. By the next pull request the suite was
at 4354, then 3623, so a reader sizing the cost of a spawn from this header would
overstate it. It now says what the suite made before this module.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep the checkouts tests off the shell's git discovery variables

Adversarial review (F33): `gitfs.discover` hands the question back to git whenever
GIT_CEILING_DIRECTORIES, GIT_DISCOVERY_ACROSS_FILESYSTEM or another discovery
variable is set, and every `checkouts()` in test/checkouts.test.mjs read the
process's environment. With GIT_DISCOVERY_ACROSS_FILESYSTEM=1 exported, the two
spawn-count tests failed: the fallback's `rev-parse --show-toplevel` appeared in
the counted calls. The test environment now drops those variables, and every
instance is built through `c()`, which hands it that environment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Read an upstream whose ref has gone as no upstream

Adversarial review (F3): describe took `upstream` from the `branch.upstream`
header of `git status --porcelain=v2`, which git prints even after the ref it
names was pruned. A data root whose remote renamed its default branch then read
as tracking origin/main with a distance nobody could measure: `rig new` printed
"the rebase onto origin would not start (fatal: invalid upstream '@{u}')",
every mutating command fetched for it, and `rig update` refused the migrations.
identify and describe's own fallback still read the same directory as having no
upstream. A born HEAD without `branch.ab` now reads as no upstream, as it did
before the v2 reading; an unborn HEAD asks `rev-parse @{u}`, so one tracking a
live branch keeps its name and a null distance.

The unmeasurable test moves onto an unborn branch that tracks a live one, a new
test pins the gone upstream across describe, identify and fastForward, and an
installation test runs `rig new` and `rig update` over such a data root.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Warn in doctor when the data root's distance from its upstream is unknown

Adversarial review (F4): rootFindings read a null `ahead` as no unpushed
commits, so a data root with an upstream named and a distance git could not
count got "✓ data root is committed and pushed". A describe reading whose
counts failed, or a branch with no commit yet that tracks a live one, now
warns that git could not measure the distance and never reaches the green tick.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Read describe's branch the way identify and the fallback read it

Adversarial review (F14, F40): describe's fast path took the branch from the
`branch.head` status header and treated any name starting with "(" as git's
own "(detached)". git accepts a branch named `(wip)`, so a checkout on it read
as detached: `rig update` warned "detached HEAD — not updated" and failed, and
`rig save` would not push from it, while identify and describe's fallback both
named the branch. The fallback's comment claimed the two paths could not
disagree. All three readings now take the branch from `branchOf`, which reads
HEAD through gitfs, or asks git where gitfs declines; the status header still
answers the head, the upstream and the distance.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Pin that describe, its fallback and identify agree on branch and upstream

Adversarial review (F28): nothing compared the three readings of one checkout,
so a gone upstream that only describe still named and a `(wip)` branch that
only describe called detached both passed the suite. The test reads both
checkouts all three ways, the fallback by knocking out the status call, and
fails against the module as it stood before the two fixes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Confirm git will answer before reading a failed @{u} as no upstream

Adversarial review (F15): with the toplevel read off the filesystem, describe's
fallback lost the `rev-parse --show-toplevel` that proved git would answer at
all. A clone with an unparsable line in .git/config, or one safe.directory
refuses, read as a checkout of its own with no upstream and nothing to pull:
fastForward said 'no-upstream' and doctor told a data root whose config names
origin/main that it had none. The fallback now asks `gitTop` first where the
filesystem placed the checkout, and a refusal reads as unversioned again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Give describe's status call room for a mass rewrite's output

Adversarial review (F16, checkouts half): `git status --porcelain=v2` spends
about 110 more bytes per tracked change than v1, and the call ran with
spawnSync's default 1 MiB buffer. Some seven thousand modified files overflowed
it, the runner threw ENOBUFS, and every mutating command, `rig update` and
`rig doctor` died with "git not found on PATH (spawnSync git ENOBUFS)". The
status call now asks for a 64 MiB buffer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Name the branch from the full ref where gitfs hands the question to git

Adversarial review (F39, gitBranch half): where gitfs declines a layout,
gitBranch asked `symbolic-ref --short HEAD`, which abbreviates for display. On a
branch `rel` in a repository with a tag `rel` it printed `heads/rel`, so
identify named one checkout two ways depending on which path answered. It now
reads `symbolic-ref -q HEAD` and strips `refs/heads/`, answering null outside
it, as `headBranch` does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Pin that describe's fallback reads a failed count as an unknown distance

Adversarial review (F29, F38): the fake that made the count towards the upstream
fail was replaced by a real-git case that only reaches the status header, so
nothing guarded describe's fallback. A mutant reading that failed count as
`?? 0` turned an unreadable index plus a failed `rev-list HEAD..@{u}` into
"current", and the whole suite passed. The new fake fails `status` and that one
count, and asserts a null `behind` and an 'unmeasurable' fast-forward; it fails
against the mutant.

The file header listed "an unmeasurable distance" among its fakes, a test that
is real git now, and left out the `git diff --cached` fake. It lists the fakes
the file has, without a count to drift.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say only what gitBranch and headBranch share about a branch's name

Adversarial review (F40, describe half): the comments claimed git's and the
filesystem's branch readings name the same branch. They part on a HEAD that
names a branch which is itself a symbolic ref (`master` aliased to `main`):
`symbolic-ref -q HEAD` follows the chain to `main` and `headBranch` stops at
`master`. `identify`, `describe` and its fallback still agree for any one
checkout, because all three ask `branchOf` with the same place; the gitBranch
comment now claims only what the two share, a branch named by its ref rather
than by `--short`'s display form.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say what describe costs now in the data root's two reading comments

Adversarial review (F37): prepareDataRoot's comment priced describe at "one
`status` and two counts" and stillTrueAtTheEnd's at "two git calls". The status
header carries the distance, so describe makes no count calls, and on a layout
gitfs answers it makes one git call. Both comments now name the one `status`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Read an environment variable from the key a child is actually handed

Adversarial review (F19): an in-process run handed `{ ...process.env, PATH: crippled }` from
PowerShell holds both `Path` and `PATH`. Node's spawn gives a Windows child whichever key sorts
first (`PATH`), but `pathOf` and `pickEnv` took the first in insertion order (`Path`). So
`rig new` with git missing from the child's PATH still committed through a git resolved off the
real one, a crippled `doctor` cached "git not on PATH" under the whole PATH, and the next
ordinary `doctor` in the process reported git missing on a machine that has it.

`pickEnv` now applies Node's rule on Windows and reads the exact name elsewhere, and PATH is
read through it, so the two caches keyed on PATH are keyed on the PATH the child receives.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep the Git for Windows launcher when MSYSTEM is set

Adversarial review (F5): the launcher does more than launch. It sets MSYSTEM and puts Git's
own mingw64\bin and usr\bin at the front of PATH; mingw64\bin\git.exe does that for itself only
when MSYSTEM is unset. With MSYSTEM exported and only Git\cmd on PATH (a non-login bash, an
MSYS2 shell, a user-wide variable), rig ran the binary directly, and `rig new` with a
core.hooksPath pre-commit hook printed "data root: could not commit (error: cannot spawn
.../hooks/pre-commit: No such file or directory)" on every command after; `!` aliases,
core.sshCommand and the manager credential helper exited 0xC0000005.

realGitFor now answers 'git' when the run's environment has a non-empty MSYSTEM, and
gitProgram's cache is keyed on MSYSTEM as well as PATH so a later run with the same PATH is not
handed the other answer. The comment states what the launcher does, and the one difference
kept knowingly when MSYSTEM is unset: the binary puts ~\bin ahead of Git's own directories.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Search PATH for git the way a spawn does, and stop at an entry it cannot read the same way

Adversarial review (F20, F35): with PATH `"<tmp>\A Git\cmd";<tmp>\B\cmd`, spawnSync('git') ran
A's git.exe, because libuv strips the quotes, but programPath kept them, missed A, walked on and
realGitFor returned B's mingw64\bin\git.exe: a different git from the user's, and the same
happened to a quoted corporate shim ahead of C:\Program Files\Git\cmd. programPath also tried
PATHEXT's extensions where a spawn tries only .com and .exe.

programPath now follows libuv: one pair of surrounding quotes stripped, git.com then git.exe.
An entry it cannot read the same way (one resolved against a cwd, or a quote left over, which is
how a quoted entry holding a `;` arrives) ends the search with null, so realGitFor answers 'git'
instead of stepping past a directory the spawn would have used. The cwd-first search of older
libuv is a difference kept knowingly and stated.

The launcher tests now cover the bin\ layout, a shim ahead of the launcher on a multi-entry
PATH, quoted entries, a git.bat, a relative entry and a quoted entry holding a `;`. Dropping
'bin' from GIT_LAUNCHER_DIRS or reversing the PATH walk both passed the old test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* State that hiding only the refresh's spawns assumes rig has a console

Adversarial review (F21): spawnDefaults hides spawns only in the freshness refresh, on the
premise that any other run has a console its children inherit. A host that starts
`node bin/rig.mjs` with DETACHED_PROCESS (or calls run() from a console-less process) breaks
that premise, and each git call rig makes then opens a visible console window; this was
reproduced with `spawn(node, [rig.mjs, 'init', ...], { detached: true })`. The npm shim, a
terminal and any parent with a console, hidden or not, are unaffected.

There is no cheap way to ask Node whether the process has a console, and a stdio heuristic
would both miss detached launches with pipes and re-hide spawns under `node --test`. So the
assumption is written where it is made and in the test that pins it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Name the refresh command once, and tie the command spawned to the one whose spawns are hidden

Adversarial review (F34): spawnDefaults hid the refresh child's git calls by comparing against
'freshness-refresh', a literal repeated independently in the child's argv, cmds and
QUIET_COMMANDS, and the unit test passed that same literal in. Renaming the command in cmds and
the argv left both windowsHide tests green while every git call in the detached refresh went
unhidden: 48 spawns at a median of 3.2s each instead of 80ms. Only the installation tests' 20s
deadline noticed, and only sometimes.

REFRESH_COMMAND in bin/freshness.mjs is now the one spelling for QUIET_COMMANDS, cmds, the
argv and spawnDefaults, and the test asks spawnDefaults about the command refreshArgv actually
spawns. The refresh spawn's own windowsHide is dropped, along with the comment and assertion
crediting it: beside DETACHED_PROCESS Windows ignores the CREATE_NO_WINDOW it asks for, and a
spawn's options never reach its child's spawns, so it hid nothing. The comment now credits
spawnDefaults in the child.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Pin the two cases of PATH's search that nothing else tells apart

Adversarial review (F35): the launcher tests left two of programPath's rules with nothing to
fail. Its refusal of an entry with a quote left over was also caught by the fully-qualified
check for every case the tests tried, so dropping it stayed green; but an entry quoted at one
end only, `<own>";<Git>\cmd`, is stripped by the spawn, which runs <own>\git.exe, and without
the refusal rig walks on and runs the Git for Windows binary instead. Trying only git.exe stayed
green as well, although the spawn starts a git.com ahead of any git.exe further along.

Both are now asserted, and each fails against the code without its rule. The bin\ layout, a
shim ahead of the launcher, a quoted entry and MSYSTEM set were already covered, and each of
those fails against dropping 'bin', reversing the walk, not stripping quotes and ignoring
MSYSTEM respectively.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Show a hook starting through the git a run is handed, with MSYSTEM set and without

Adversarial review (F35): every launcher test asked only which program realGitFor names, never
whether git behaves the same through it, and the parity it assumes is false once MSYSTEM is
set: mingw64\bin\git.exe started straight with MSYSTEM=MINGW64 and only Git\cmd on PATH fails
every commit with "cannot spawn .git/hooks/pre-commit". Nothing in the suite runs a hook, so
the whole suite passed with realGitFor ignoring MSYSTEM. Keying gitProgram's cache on PATH
alone, or reading MSYSTEM from the process rather than the run, also passed everything: a
second run in the process with the same PATH and MSYSTEM set was handed the binary the first
run was.

The test runs two in-process `init`s on Windows, with a pre-commit hook and nothing of Git's
on PATH but the launcher: the first without MSYSTEM, which the binary started straight must
serve as the launcher would, and the second with it, which must get the launcher back. It
fails against each of those three changes, the last only where the test process has no
MSYSTEM of its own (PowerShell, as on CI, but not Git Bash), and is skipped where no launcher
is on PATH.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Move the run out of a worktree before removing it

Adversarial review (F1): standing in <work>/billing, `rig close --force` and
`rig detach billing` removed the worktree the run stood in, and the next git
spawn died with "git not found on PATH (spawnSync git ENOENT)", because every
subprocess now starts in the run's cwd. close left the other worktrees and the
work folder behind with no closedAt; detach left the repo in the record, and
every retry died with "is not a working tree".

close now moves to the tool root before its removal loop rather than after it,
and detach moves out of the worktree before removing it. Both are tested from
inside a worktree, in-process, which reproduces the failure on every OS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Blame PATH for a failed spawn only when the program is what was missing

Adversarial review (F16, F1): exec turned every spawn error into
"<cmd> not found on PATH". A status over a tree with ~7k tracked changes
overflows spawnSync's buffer, and `rig doctor` and every mutating command then
died with "git not found on PATH (spawnSync git ENOBUFS)". A run standing in a
directory git had just removed died the same way, because Node reports a
missing cwd as ENOENT too.

exec now says what failed for anything but ENOENT, and for ENOENT looks at the
directory it was starting in before it blames PATH.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Ask the process for its cwd only when a command needs one

Adversarial review (F13): the process invocation read `process.cwd()` at
module import, and every run read it again before doing anything. On Linux or
macOS, a shell left standing in the folder `rig close` had just deleted then
got "ENOENT: no such file or directory, uv_cwd" with a stack trace from every
command, `rig help`, `rig prompt` and `rig list --data <name>` included, all of
which ran there before.

A run handed no cwd now leaves it unset. `cwd()` asks the process when a
command needs a directory, and exec, the tracker CLIs and the data root lookup
pass the run's cwd on only when one was set, so a child inherits the process's
without anything reading it first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* End the output at a closed pipe instead of crashing on it

Adversarial review (F2): the CLI's writers are bare process.stdout.write and
process.stderr.write, which report a reader that has gone as an 'error' event
nobody listens for. `rig list | head -1` and `rig init ... | head -1` exited 1
with an EPIPE stack trace after the command had already done its work, and
failed any pipeline under `set -o pipefail`. console.log, which they replaced,
swallowed it.

The CLI entry point now ignores EPIPE on both streams and rethrows anything
else. In-process runs hand their own writers and are untouched. The test
closes the reader before rig writes a word, so the race cannot hide it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test the CLI's half of run(argv, io) as a process again

Adversarial review (F26): every test that piped a brief into rig ran
in-process, where the harness supplies its own trimming reader, so replacing
readProcessStdin's body with `return ''` left the full suite green while a real
`<brief> | rig new --ticket` would open a ticket with an empty body. Dropping
`{ chdir }` from the CLI entry line also left the suite green, while `rig close`
run from inside the work folder on Windows then left the folder on disk.

The Jira brief test in smoke runs as a subprocess again, with a trailing
newline that the context doc shows untrimmed if the CLI stops trimming.
invocation.test.mjs gains an in-process close handed a `chdir` spy, which must
be asked to move to the tool root while the process stays put, and a
subprocess close started inside the work folder, which must remove it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Let the freshness check end on a tool copy that is no checkout without spawning

Adversarial review (F8, F31): `notARepository` was added for the freshness
epilogue and never called from it. The epilogue still ran
`git -C <tool root> rev-parse HEAD` and waited for it to fail before it knew a
tool copy with no .git had nothing to measure, and with no cache ever written
the check stayed due, so every command on a tarball install or a suite copy
paid that spawn. No test noticed: the suite stayed green with the call site
missing, and stayed green with it wired as `gitDir == null`, which reads the
null gitfs hands back under GIT_DIR as "no repository" and silences the
behind line for good on every machine that sets it.

The epilogue now returns before the rev-parse when the filesystem walk shows
the tool copy is no checkout, and falls through to git when the walk hands the
question back. The smoke suite pins that `rig help` on its no-.git copy starts
no git at all (GIT_TRACE2 writes nothing), and the installation suite pins
that a checkout found through GIT_DIR still prints the behind line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say what toolState costs the freshness check now

Adversarial review (F37): the epilogue's comment priced `toolState` at eight
git spawns, while the same file says four at the tag lookup and at doctor's
release line, and test/checkouts.test.mjs pins four. Eight is what it costs
only where gitfs hands the path questions back to git, so a maintainer
weighing the epilogue's cheap-first order read twice the ordinary cost.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test the repo a command stands in choosing its data root, end to end

Adversarial review (F27): every test of that resolution step handed `locate` a
fake `repoAt`, and every CLI test run from inside a checkout ran inside a work
folder, where the anchor answers first. Reading the process's folder instead of
the run's, or reading gitfs's "ask git" null as "no checkout here", passed the
full suite, while a command run in a clone of a repo another root catalogues
fell back to the current root.

Two in-process runs from a real checkout outside any work folder now pin it:
one the filesystem walk places, which fails when the process's folder is read,
and one with core.worktree set, which the walk hands back to git, which fails
when that null is read as no checkout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Name the checkout by its folder only when git will open it

Adversarial review (F11): repoAtCwd takes the checkout's place from the
filesystem walk, which answers for repositories git refuses to open — another
user's (safe.directory), or one with an extension git does not know. git then
fails `remote get-url origin` with 128, which was read as "no origin", and the
folder name chose the data root. A clone in a folder named `billing` whose
origin is acme/payments-service put `rig new`'s record in the root that
catalogues billing, where main fell through to the current root.

When get-url fails on a checkout the walk placed, git is now asked whether it
will open it at all, and a refusal leaves this step with no answer. Only that
failure path pays the extra spawn, and a checkout with no origin is still named
by its folder, which a test now pins beside the refused one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Hand back a repository format git would have to interpret, and follow a branch alias as git does

Verification of the gitfs fixes found four answers still not git's. A config key after two
section headers on one line (`[foo] [core] worktree = X`) was missed, so the walk named the
directory holding .git where git names X. A repositoryformatversion past 1, or an extension
git does not know, is a repository git refuses to open, and the walk placed it anyway; any
[extensions] section now hands the question back, which also makes config.worktree unread,
since git reads it only under extensions.worktreeConfig. A branch that is itself a symbolic
ref (HEAD -> master -> main) was named master where git and the checkouts' git reading say
main, so one checkout had two names depending on who asked. And a HEAD that starts with a
sha was named by a `ref:` line after it, where git reads it as detached. The FIFO guard is
pinned on every platform now, by a stat that answers neither file nor directory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Pin describe's parsing of a stash, a rename and a conflict, and say what describe costs

Verification of the checkouts fixes: a parser that counted `# stash` as a change and only
`1 ` entries as modified passed every test, reading a clean tree with a stash as dirty and a
staged rename as unmodified. describe's comment said one git call where a layout gitfs hands
back costs three, and branchOf's said the readings cannot disagree, which is true of the
branch they name, not of every answer. The checkouts tests take gitfs's list of discovery
variables rather than a copy of it, and four comments that told the defect's history now say
what the test is for.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Let close and detach run from a cwd that is gone, and pin exec's cwd and its failure message

Verification of the invocation fixes: `rig detach billing --work t9` from a shell whose folder
was removed threw uv_cwd with a stack trace, because asking whether the run stood in the
worktree read the process's cwd even when --work had named the work; close did the same. A
directory the process cannot report is now standing in nothing about to be removed. Two
fixes had no test that would fail with them reverted: exec reading `cwd()` again (children
no longer inheriting a cwd nothing had to read) and exec blaming PATH for a directory that
is gone. Decision 98 now says the run's cwd reaches a child only when the run was handed one,
names the CLI's half of the seam among the subprocess tests, and names the tests that enforce
the PATH-keyed cache and the unread cwd.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Record the launcher decision, and search PATH for a program the spawn would start

Verification of the launcher fixes: a directory called git.exe was taken for a program, so
with PATH `D\cmd;B\cmd` the spawn ran B's git and rig stepped past to D's binary; the search
now takes a file only, as libuv does. Three fixes had no test that failed with them reverted:
the git a command starts being looked for on a PATH its child is never handed (the doctor
test covered onPath only), MSYSTEM read in one spelling, and that directory. The epilogue
still spelled the refresh command out; refreshSpawn's comment put the cost on a console host
rather than on the visible window a console-less child opens. The stage that stepped past the
launcher and scoped windowsHide had no decision in the log, so decision 99 records it, with
the assumption the scoping rests on: that every rig process but the refresh has a console.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say truthfully how the split files share an installation and when a test starts a process

Verification of the test-hygiene fixes: README said a process-subject test passes
`inProcess: false`, where suites opt in with `inProcess: true` and most such tests leave it
off; billing-install's header said each subject was a file with its own installation, where
close, abandon and next share one file and stage spans three, and then said the installation
crossed between them; smoke's header called the copy both the installation and a thing for
git alone; a smoke comment described neighbours the new test had displaced; and plan's
refresh test shadowed the `before` it now imports.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Hand back a .git that is neither a file nor a directory, unopened

The first POSIX run of the FIFO test disproved it: with a FIFO called .git, git on the
ubuntu runner does not walk past it to the checkout above, where the walk did. What git
does with such an entry is its own to say, so the walk now hands the question back without
opening it — a FIFO would block a read for good. The test standing in for it on Windows
compared its path unresolved, and the runner's temp directory is an 8.3 short name, so its
stub never matched there; it compares the resolved path, as the walk does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot removed the stale Issues and PRs marked stale due to inactivity and scheduled for automatic closure. label Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. fs Issues and PRs related to file-system APIs and the fs module. lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants