Uh oh!
There was an error while loading. Please reload this page.
fix(fmt): the formatter gates walked the working tree, not the repo (cli#549) - #550
Conversation
…cli#549) `make fmt-check` ran `gofmt -s -l .` and `goimports -l .`, and `.` is the whole working TREE. Any untracked directory holding Go files — a nested git worktree, a vendored copy, a build sandbox — was reported as drift while every tracked file in the repo was correctly formatted: ==> goimports (import grouping) needed on: <untracked-scratch-dir>/internal/cli/data.go ==> run `make fmt` to fix CI never saw it, because a fresh checkout has no untracked Go files. So this was a local-only FALSE failure in `make check`, the documented pre-push tier — and the remedy it printed was the same bug in write mode: `make fmt` (`gofmt -s -w .`) rewrote files the repo does not track, i.e. someone else's working copy. Both invocations, check and write, now take the file list from `git ls-files '*.go'` — exactly the set a PR can contain. Notably this was the ONLY gate affected: Go's `./...` skips dot-prefixed directories, so `vet`, `test` and `deadcode` never saw `.claude/` at all. `gofmt .` does not skip them, which is why fmt-check alone cried wolf. scripts/format.sh, rather than more backslash-continued shell in the Makefile, because the edge cases want testing and comments: * xargs over a NUL stream from a printf builtin, not a bare argv expansion. Measured: 9000 tracked files is 2.0 MB of argv against a 1 MB ARG_MAX — the naive form dies with "argument list too long", the batched form reports all 9000. * tracked-but-DELETED index entries are filtered out. `git ls-files` reports the index, so a mid-edit deletion would otherwise hard-error the gate. * FAILS CLOSED (exit 2) outside a git work tree, and on an empty file list. The empty case is not cosmetic: bare `gofmt -l` with no path arguments reads STDIN, so an unguarded empty list checks nothing and exits 0 — the inert-verification class of backend#1729. * stderr is deliberately not captured; `go run`'s download progress would otherwise appear as phantom drift filenames on a cold cache. build.yml's Lint job calls `make fmt-check` instead of keeping its own inline copy, so the file set has one definition and cannot drift from local. That also drops the restated `goimports@v0.48.0` pin, and GOIMPORTS_VERSION joins check-tool-pins.sh's TOOLS so it stays dropped on the next bump. Verified on this branch, with a `.claude/worktrees/` directory present holding deliberately misformatted Go: * `make check` — green (was red on the same tree before this change; the old `gofmt -s -l .` flags the scratch file, shown in the ticket) * tracked drift still caught, both gates: a non-simplified slice expression in internal/slug/slug.go fails gofmt -s, a mis-grouped import fails goimports * `make fmt` leaves the scratch file byte-identical (sha unchanged) * fail-closed paths exercised: no work tree, empty list, bad usage — all 2 * a deleted tracked file passes at 218/219 rather than erroring * shellcheck (default severity, not just -error) clean; actionlint clean Refs cli#549. Found while working on cli#548, kept out of it to keep that PR scoped to the offboard telemetry bug (backend#2314).
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit bf70404. Configure here.
Uh oh!
There was an error while loading. Please reload this page.
LukasWodka
left a comment
There was a problem hiding this comment.
Verified against develop and the branch. This is the rare fix that closes the class rather than the instance, and three of the four things I checked were sharper than the description lets on.
The bug, reproduced. On develop all four invocations walk . — check and write:
Makefile:261 gofmt -s -w .
Makefile:262 goimports … -w .
Makefile:268 gofmt -s -l .
Makefile:275 goimports … -l .
So the remedy the gate printed really was the same bug in write mode. Your point that ./... skips dot-prefixed directories — which is why vet/test/deadcode never saw the scratch dir and only this gate broke — is correct and is the detail that makes the diagnosis complete rather than plausible.
My independent count matches yours exactly: 219 tracked .go files. I'd started to question the 9000 files / 2.0 MB argv justification, since this repo's real expansion is 6,813 bytes against a 1,048,576 ARG_MAX — 0.6% of the limit. Then I read your verification table properly: you list "219 tracked" and "large list: 9000 files" as separate rows, so the 9000 is an explicit stress case and not a claim about cli. My objection was unfounded and the table is why. Worth keeping that separation — it's the difference between a measurement and a projection.
The stdin trap is real, and it's the best thing in here.
$ echo "" | gofmt -l
$ echo $?
0
Bare gofmt -l with no path arguments reads stdin, so an unguarded empty file list checks nothing and exits 0 — a gate that reports success while verifying zero files. Guarding it with exit 2 rather than letting it pass is precisely the inert-verification class, and citing backend#1729 for it is the right frame.
And the part I'd have missed: you fixed the guard that failed to catch the duplicate. On develop:
build.yml:157kept its own inlinegofmt -s -l .build.yml:170kept a hardcodedgoimports@v0.48.0scripts/check-tool-pins.sh'sTOOLScontained onlyGOVULNCHECK_VERSION
So GOIMPORTS_VERSION was genuinely uncovered, which is why a second restated pin sat there unnoticed. On this branch build.yml calls make fmt-check (one definition of the file set, so local cannot drift from CI) andGOIMPORTS_VERSION joins TOOLS. Fixing the drift and then extending the check that would have prevented it is the whole difference between a fix and a lesson.
Two smaller things, both right:
- tracked-but-deleted index entries filtered —
git ls-filesreports the index, so a mid-edit deletion would hard-error a gate on a merely half-finished tree. Passing at 218/219 rather than erroring is the correct call. - stderr deliberately not captured, because
go run's module-download progress would surface as phantom drift filenames on a cold cache. That's the kind of thing that gets "fixed" later by someone adding2>&1and reintroducing ghost failures — good that the reason is written down.
And the scope note is honest: an untracked non-dot directory containing a Go package still fails go vet ./..., because that's Go's own view of the module rather than a formatter scoping bug. Naming what this doesn't fix is what stops the next person assuming it did.
Small connection: this is the follow-through on the aside you left me on cli#547 — the make check fmt-check noise from a stale nested worktree. Good that it became a ticket and a fix rather than a footnote.
Not approving yet — CI is pending on this head. I'll approve next pass once green; nothing here needs anything from you.
LukasWodka
left a comment
There was a problem hiding this comment.
Requesting changes. Bugbot's High on scripts/format.sh:90 is real — I didn't take it on trust, I reproduced the mechanism. And it's the same class this PR exists to fix, in the PR's own new code.
The defect, verified three ways
1 · The flags.scripts/format.sh:38 is set -uo pipefail — no -e.
2 · The asymmetry between the two modes. Write mode calls it directly:
run_formatter "gofmt -s -w" gofmt -s -w >/dev/null # exit 2 kills the script ✓Check mode captures it:
drift="$(run_formatter "gofmt -s" gofmt -s -l)"# exit 2 kills only the subshell ✗3 · A minimal repro of exactly that shape:
set -uo pipefail
f() { echo"boom">&2;exit 2; }
out="$(f)"echo"PARENT STILL RUNNING; out='${out}'"exit 0boom
PARENT STILL RUNNING; out=''
parent exit=0
So on a formatter failure — cold go run cache, network blip, a bad GOIMPORTS_VERSION pin, or a non-zero gofmt — drift comes back empty, [ -n "$drift" ] is false, fail stays 0, and the script prints
==> fmt-check: 219 tracked Go file(s) clean
and exits 0. The FAILED (exit N) line goes to stderr and is true, but nothing acts on it. Write mode is unaffected; only check mode — the gate — is.
Why I'm blocking rather than noting it
This is worse than the bug being fixed. The old gofmt -s -l . at least ran and its exit status reached the recipe. The new form can report a clean tree having verified nothing — and build.yml's Lint job now calls make fmt-check, so the false green reaches CI, not just local. That is precisely the inert-verification class you cite backend#1729 for, and the empty-list guard three lines up exists because you were already thinking about it:
bare
gofmt -lwith no path arguments reads stdin, so an unguarded empty list checks nothing and exits 0 — the inert-verification class of backend#1729
Same sentence, same file, different path.
What would fix it
Any shape that keeps the failure status in the parent. Cheapest is to stop swallowing it in a command substitution — capture to a temp file and check rc in the caller's own shell:
run_formatter() { # sets DRIFT, returns 0 on successlocal label="$1";shift
DRIFT="$(printf '%s\0'"${files[@]}"| xargs -0 "$@")"|| {
echo"==> ${label}: FAILED — see the output above">&2return 2
}
}
run_formatter "gofmt -s" gofmt -s -l ||exit 2Whatever the shape, the thing worth pinning afterwards is the failure path, not just the drift path: a case where the formatter itself exits non-zero and fmt-check must exit non-zero too. Your verification table covers empty list, no work tree, bad usage, deleted file and the 9000-file stress — all good — but there's no row for "the formatter died." That's the row that would have caught this, which is the more useful way to see it than as a Bugbot finding.
Unchanged from my earlier read
Everything else stands and I'd approve it once this is fixed: the .-vs-repo diagnosis is right (verified all four invocations on develop), the ./...-skips-dot-dirs explanation is correct and completes it, the 219-file count matches my own, collapsing build.yml to make fmt-check removes the second definition, and adding GOIMPORTS_VERSION to check-tool-pins' TOOLS closes the coverage gap that let the duplicate pin hide. The scope note about untracked non-dot directories is honest.
Not resolving Bugbot's thread — it's theirs and it's correct.
…n (cli#549) Bugbot High on scripts/format.sh:90 and @LukasWodka on #550, both correct, and the defect was the same class this PR exists to fix — in the PR's own new code. `run_formatter` ended with `exit 2` on a non-zero formatter. Write mode called it directly, so that killed the script. Check mode captured it: drift="$(run_formatter "gofmt -s" gofmt -s -l)" A function that exits inside a command substitution ends only the SUBSHELL. With `set -uo pipefail` and no `-e`, the parent read an empty `drift`, found no drift, left `fail` at 0, printed ==> fmt-check: 219 tracked Go file(s) clean and exited 0. So a cold `go run` cache, a network blip, a bad GOIMPORTS_VERSION pin or any non-zero gofmt produced a FALSE GREEN in `make fmt-check` and in CI's Lint step, with the true `FAILED (exit N)` line going to stderr where nothing acted on it. Reproduced standalone before changing anything: f() { echo boom >&2; exit 2; } out="$(f)"; echo "PARENT STILL RUNNING; out=[${out}]" -> boom / PARENT STILL RUNNING; out=[] / parent exit=0 `run_formatter` now writes the formatter's stdout to a temp file and RETURNS the status; callers are `run_formatter ... || exit 2` in the parent shell. It never exits and is never wrapped in `$( )`. A comment cannot hold that shut, so scripts/tests/format-verify.sh does: eight properties, formatters stubbed on PATH and via $GO, hermetic, no network, ~6 s. It asserts the failure propagates in BOTH modes, that the untracked file never reaches a formatter while the tracked one does, that tracked drift still exits 1, and the three fail-closed paths. Proof it is not inert — the harness run against a copy of format.sh with the old subshell shape restored: FAIL: a failing formatter exited 0 (check mode swallowed it — the #550 defect) format-verify: 1 FAILED, 7 passed and against the fix: `format-verify: 8 properties hold`. Wired into `make check`, `make ci`, and build.yml's Installer (shell) job — that job, not Lint, because stubbed formatters need no Go toolchain. `make check` is 10.6 s warm with it, against the 60 s budget. Fixing the harness cost one round of the same trap: `rc="$(run_case ...)"` lost the CASE_OUT the function had set. Call sites are direct now, with a comment naming why, since it is the identical mechanism. Re-verified after the refactor: `make check` green with a `.claude/worktrees/` scratch dir present holding misformatted Go (the old `gofmt -s -l .` still flags it), tracked gofmt drift still exits 1, `make fmt` leaves the scratch file byte-identical, shellcheck clean on both scripts at default severity, actionlint clean. Refs cli#549.
saadqbal
commented
Aug 21, 2026
You and Bugbot are both right, and I reproduced the mechanism before touching anything rather than taking either on trust. Fixed in eb24527. The defect. So The fix. But a comment cannot hold that shut, so Proof it is not inert. Against a copy of Against the fix: One admission worth recording: writing that harness cost me one round of the identical trap — On the Re-verified the original acceptance criteria after the refactor: 🤖 Addressed by Claude Code |
LukasWodka
left a comment
There was a problem hiding this comment.
The defect is fixed, and I proved it rather than reading the diff. I shimmed a failing gofmt onto PATH and ran both versions of the script:
=== eb24527c (this head) ===
simulated gofmt failure
==> gofmt -s: FAILED (exit 1) — see the output above
EXIT=2 ✅
=== bf704048 (the version I blocked) ===
simulated gofmt failure
==> gofmt -s: FAILED (exit 1) — see the output above
==> fmt-check: 219 tracked Go file(s) clean
EXIT=0 ← the false green
The old version printed the failure and then claimed 219 files clean. That's now gone: stdout to $out_file, run_formatterreturns the status, and every call site is || exit 2. Clearing my change-request as soon as CI is green.
And scripts/tests/format-verify.sh is more than I asked for. I suggested a verification row; you wrote a 157-line hermetic self-test, wired it into build.yml:91andmake fmt-selftest, shellcheck'd it, and framed the whole file by the class rather than the instance — "Every case here is a way the gate could report CLEAN while checking nothing." It fails closed if format.sh is missing ("refusing to report pass"), and it carries the lesson forward in a comment: "Call it DIRECTLY, never as $(run_case ...)" — the bug's own shape, written down where the next person will hit it. 8 properties, green.
One gap: the new test is coarse, not vacuous
I mutation-proved the test itself, and it catches the defect only when every propagation path is broken:
| mutation | format-verify result |
|---|---|
swallow only the gofmt -s check-mode call site | 8 properties hold, exit 0 ❌ |
| swallow both check-mode call sites | FAIL: a failing formatter exited 0 (check mode swallowed it — the #550 defect), exit 1 ✅ |
So the test is real — it does redden on the defect — but what it actually asserts is "at least one propagation path works," not "each one does." The explode stub makes both formatters fail, so whichever call site is still || exit 2 carries the script to a non-zero exit and the case reads that as success.
That matters because it's how the original bug arrived: one call-site shape being wrong. A future regression that breaks only the gofmt path, or only goimports, ships green through this test — the precise scenario the file exists to prevent.
Fix is small: make the stub selective, so one case fails only gofmt and another fails only the goimports invocation. Then each propagation path is pinned independently and the mutation I ran above reddens.
Not blocking on it — the shipped defect is genuinely fixed, the test is a real improvement over nothing, and I'd rather this land than sit. But it's worth doing while the file is fresh, because a self-test whose name promises per-formatter coverage and delivers or-coverage is the same shape of problem one level up.
Everything else from my earlier reads still holds: the .-vs-repo diagnosis, the ./...-skips-dot-dirs explanation, 219 tracked files matching my own count, build.yml collapsing to make fmt-check so the file set has one definition, and GOIMPORTS_VERSION joining check-tool-pins' TOOLS.
LukasWodka
left a comment
There was a problem hiding this comment.
Approving — clearing my change-request as I said I would.
Green on eb24527c: 25 pass, 1 skipping, MERGEABLE, Bugbot's thread resolved, zero unresolved threads. And the blocking defect is fixed and proven — my PATH shim with a failing gofmt gives exit 2 on this head where the version I blocked gave exit 0 after printing "219 tracked Go file(s) clean".
The one open item is explicitly non-blocking and stays a follow-up: format-verify.sh reddens only when both check-mode propagation paths are swallowed, not when one is — because the explode stub fails both formatters, so whichever call site still carries || exit 2 takes the script to a non-zero exit and the case reads that as success. A selective stub (fail only gofmt in one case, only the goimports invocation in another) pins each path independently. Worth doing while the file is fresh, since the original bug was exactly one call site being wrong.
Everything else stands as verified: the .-vs-repo diagnosis with all four invocations confirmed on develop, the ./...-skips-dot-dirs explanation, 219 tracked files matching my independent count, build.yml collapsed to make fmt-check so the file set has one definition, and GOIMPORTS_VERSION added to check-tool-pins' TOOLS — which closes the gap that let the duplicate pin hide in the first place.
@LukasWodka on #550, and he mutation-proved it rather than asserting it: swallowing ONLY the gofmt check-mode call site left format-verify green. The `explode` stub failed BOTH formatters, so whichever call site still had `|| exit 2` carried the script to a non-zero exit and the case read that as success. What the suite actually asserted was "at least one propagation path works" — or-coverage under a name that promises per-formatter coverage. A regression breaking only gofmt, or only goimports, shipped green through it. Which is the scenario the file exists to prevent, because ONE call-site shape being wrong is exactly how the original bug arrived. The two formatters now get independent stubs (`write_stub`, mode per tool), and two cases fail exactly one of them, so only that call site can produce the non-zero exit. Mutation-proved in both directions: swallow only the gofmt call site -> FAIL: a failing gofmt alone exited 0 — that call site swallows failures swallow only the goimports call site -> FAIL: a failing goimports alone exited 0 — that call site swallows failures unmutated -> format-verify: 10 properties hold Before this commit the first of those was `10 properties hold`, exit 0. `make check` stays green and inside budget; shellcheck clean at default severity. Refs cli#549.
saadqbal
commented
Aug 21, 2026
You're right, and the mutation table is the part that settles it — I'd have defended that test on the strength of "it reddens on the defect," which was true and beside the point. Fixed in a53d66b. What it was actually asserting. The Fix. The two formatters get independent stubs ( Mutation-proved in both directions, which is the check I owed the first version:
Each failure names the path that broke, so a future regression points at its own call site instead of "something swallowed a failure". For the record on the head you verified: CI went fully terminal on eb24527 with no failures — 26 pass, and Cursor Bugbot flipped from Ready for another look when you have a moment. 🤖 Addressed by Claude Code |
LukasWodka
left a comment
There was a problem hiding this comment.
Approving. a53d66bd implements the per-path pinning exactly as intended, and I re-ran the mutation matrix on the PR head rather than taking it on trust:
mutation on scripts/format.sh | suite |
|---|---|
drop || exit 2 on run_formatter "gofmt -s" gofmt -s -l (:137) | 🔴 FAIL: a failing gofmt alone exited 0 — that call site swallows failures |
drop || exit 2 on run_formatter "goimports" … -l (:142) | 🔴 FAIL: a failing goimports alone exited 0 — that call site swallows failures |
Each check-mode call site now reddens on its own, which is the property the shared explode fixture could not express. Baseline is 10/10 green, and I confirmed each mutation actually applied to the line I meant (an inert mutation and real coverage look identical in a log).
Two things I liked beyond the ask:
write_stubrecords argv into a sharedseen.txtwhile the two stubs behave independently — so splitting the behaviour didn't cost the scoping property, which still passes.- the
*) echo "make_fixture: unknown behaviour" … exit 2arm. A typo'd fixture name now fails the suite instead of silently constructing a fixture with unset modes.
One residual, non-blocking. The same or-coverage still holds in write mode: --write is only exercised with the shared explode fixture, so mutating :121or:122 alone leaves the suite 10/10 green (verified — both mutations pass). Lower stakes than check mode, since a swallowed write failure gets caught by the check gate afterwards rather than shipping. The fixtures you already added cover it; it's the loop bound:
forwhichin gofmt goimports;do
dir="$(make_fixture "explode-${which}")"formodein --check --write;do
run_case "$dir""$mode"; rc=$CASE_RC
[ "$rc"-eq 0 ] && bad "a failing ${which} alone exited 0 in ${mode}"|| ok "a failing ${which} ALONE fails ${mode} (rc=$rc)"done
rm -rf "$dir"done(--write on a fresh fixture is independent of --check having run first, since the stubs never modify files.) Take it here or in a follow-up — your call, not a condition of this approval.
Uh oh!
There was an error while loading. Please reload this page.
Picks up #550 (the formatter gates walking the working tree instead of the repo), so make check here runs the gate CI will run. No file overlap with this branch's changes.
LukasWodka
commented
Aug 22, 2026
/fr-pass |

Closes#549.
The bug
make fmt-check— a prerequisite ofmake check, the documented pre-push tier — ran both formatters over., and.is the whole working tree, not the repo. Any untracked directory holding Go files was reported as drift while every tracked file was correctly formatted:CI never saw it, because a fresh checkout has no untracked Go files. So this was a local-only false failure, which is the kind that teaches people to stop reading
make checkoutput. And the remedy it printed was the same bug in write mode:make fmt(gofmt -s -w .) rewrote files the repo does not track — in the case that surfaced this, another session's working copy.Worth noting because it explains why only this gate broke: Go's
./...pattern skips dot-prefixed directories, sovet,testanddeadcodenever saw the scratch directory at all.gofmt .does not skip them.The fix
Both invocations — check and write — take their file list from
git ls-files '*.go': exactly the set a PR can contain.The logic moved to
scripts/format.shrather than growing more backslash-continued shell in the Makefile, because the edge cases want comments and a place to be tested:printfbuiltin, not a bare argv expansion. Measured: 9000 tracked files is 2.0 MB of argv against a 1 MBARG_MAX— the naive form dies withargument list too long, the batched form reports all 9000.git ls-filesreports the index, so a mid-edit deletion would otherwise hard-error the gate on a tree that is merely half-finished.gofmt -lwith no path arguments reads stdin, so an unguarded empty list checks nothing and exits 0 — the inert-verification class of backend#1729.go run's module-download progress would otherwise show up as phantom drift filenames on a cold cache.build.yml's Lint job now callsmake fmt-checkinstead of keeping its own inline copy, so the file set has one definition and local cannot drift from CI — the invariant the Makefile header exists to protect. That also drops the restatedgoimports@v0.48.0pin, andGOIMPORTS_VERSIONjoinscheck-tool-pins.sh'sTOOLSso it stays dropped on the next bump. (It was previously uncovered by that guard, which is why the second copy went unnoticed.)Verification
Run on this branch with an untracked
.claude/worktrees/…directory present containing deliberately misformatted Go:make checkwith the scratch dir present==> fmt-check: 219 tracked Go file(s) cleangofmt -s -l .on the same treeinternal/slug/slug.go)make fmtwith the scratch dir presentargument list too long)shellcheck --shell=bash scripts/format.sh(default severity, not just-error)actionlint .github/workflows/build.ymlscripts/format.shis added to the Installer (shell) job's shellcheck step, so that stays true.Note
One thing this does not change: an untracked non-dot directory containing a Go package still fails
go vet ./..., because Go legitimately treats it as part of the module. That is the toolchain's own view of the module rather than a formatter scoping bug, so it is out of scope here.Context
Found while working on #548; kept out of that PR to keep it scoped to the offboard telemetry bug (backend#2314).
Note
Low Risk
Dev-tooling and CI formatter gates only; no runtime, auth, or data-path changes. The main risk is a false-green fmt check if the new script’s fail-closed paths regress, which the added harness is meant to catch.
Overview
make fmt/make fmt-checkno longer walk.(the whole working tree). They now format only tracked*.gofiles, so untracked scratch dirs no longer failmake checklocally or get rewritten bymake fmt.Logic lives in
scripts/format.sh(check + write). It fails closed outside a git work tree or on an empty file list, batches viaxargs, and does not swallow formatter failures through command substitution. Lint CI now runsmake fmt-checkinstead of an inline copy, andGOIMPORTS_VERSIONis enforced bycheck-tool-pins.sh.A hermetic
fmt-selftest(scripts/tests/format-verify.sh) is wired intomake check/ciand the installer job so a formatter that never ran cannot report clean.Reviewed by Cursor Bugbot for commit a53d66b. Bugbot is set up for automated code reviews on this repo. Configure here.