fs: expose frsize field in statfs results - #62495
Open
juicecultus wants to merge 1 commit into
Open
juicecultus wants to merge 1 commit into
juicecultus wants to merge 1 commit into
Conversation
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.
Codecov Report✅ All modified and coverable lines are covered by tests. 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
🚀 New features to boost your workflow:
|
jazelly
reviewed
Mar 31, 2026
| SET_FIELD(kBAvail, s->f_bavail); | ||
| SET_FIELD(kFiles, s->f_files); | ||
| SET_FIELD(kFFree, s->f_ffree); | ||
| SET_FIELD(kFrSize, s->f_frsize); |
Member
There was a problem hiding this comment.
looks like GHA is complaining about this on some platforms.
|
Dupe of #62277? |
Contributor
|
This pull request has been marked as stale due to 90 days of inactivity. |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
fs.statfs()exposesbsize(optimal I/O block size) but notfrsize(fundamental filesystem block size). Per POSIX, block counts (blocks,bfree,bavail) are in units offrsize, notbsize. libuv already readsf_frsizefrom the kernel and stores it inuv_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:* blocksresultbsizefrsizeThis causes any application computing disk space as
bsize * blocksto 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
frsizeproperty to theStatFsobject returned byfs.statfs(),fs.statfsSync(), andfsPromises.statfs(), in both normal and bigint modes.Files changed:
src/node_file.h— addkFrSizetoFsStatFsOffsetenumsrc/node_file-inl.h— maps->f_frsizeinFillStatFsArray()lib/internal/fs/utils.js— addfrsizetoStatFsclass and bindingdoc/api/fs.md— document the new fieldtest/parallel/test-fs-statfs.js— includefrsizein property checkstest/parallel/test-fs-promises.js— includefrsizein type assertionsNotes
f_frsizefrom the kernel'sstatfs.f_frsizef_frsize = f_bsize, so this is always populated