fix(prep): disclose the working-tree changes --dry-run makes (#700) #707

Merged
bosun merged 4 commits from i/700-dry-run-side-effect-disclosure into main 2026-08-18 23:54:15 +02:00
Owner

Closes #700.

rt prep --dry-run skips git and API mutations but is not side-effect-free. It transitions the
CHANGELOG, clears the release-notes sidecar, bumps version_files, runs post_bump_hooks (which
content-hash auto-stages) and rewrites the build-bake ref. The help text called it
"compose + preview only", and the run itself said nothing about what it left behind.

Why this is not blocked behind #607

Both changes land on surfaces no equivalence harness compares, so neither becomes the bash-and-Go
double-fix that put #689 behind the bash retirement:

change surface compared?
flag text --help output by nothing — checked all 11 cmd/rt/*_equiv_test.go + internal/prep/equivalence_test.go
disclosure stderr, via the existing logf internal/prep/equivalence_test.go compares SurfaceStdout, SurfaceExitCode, SurfaceGitArtifacts — stderr is discarded by design

Measured, not merely read: TestPrepEquivalence, TestPrepEquivalence_tagDiscovery and
TestDecideEquivalence all run and pass on this branch, every sub-arm executed, none skipped.

stderr is the free placement — that is not why it was chosen

Flagged because "unblocked because unchecked" is the shape /srv/CLAUDE.md calls punctuation, and
it deserves a merits answer rather than a convenience one. stdout here is a strict key=value
contract — six lines, new_version=repo=, documented at scripts/release-prep.sh:34. A prose
disclosure there corrupts the shape for any IFS='=' read consumer. Meanwhile stderr already carries
every [DRY-RUN] would: … line, so the alternative splits one narrative across two streams.
stderr is where this belongs on the merits; being uncompared is a coincidence, and if the two had
disagreed the merits would have won and this would have waited on #607.

The disclosure is derived, never a fixed list

A run against this repo names 14 paths; the tracker's hand-written list had 3. A version_file
added to config or a new post_bump_hook is covered with no sentence to keep in sync.

[DRY-RUN] working tree MODIFIED/DELETED by this run (undo: git checkout -- <path>):
    .forgejo/workflows/reusable-*.yml (5), CHANGELOG.md, VERSION
[DRY-RUN] working tree STAGED by this run - `git checkout -- .` does NOT undo these
    (undo: git reset -- <path>; pathspec form, a bare `git reset` is a shared-index verb):
    README.md, docs/integration.md, examples/... (7 total)

The staged/unstaged split is the load-bearing part, not formatting: git checkout -- . restores one
class and silently leaves the other, and the verb that clears staged touches the shared index in
a linked worktree. A path in the wrong bucket sends the reader to a verb that does not cover it.

Evidence

arm result
clean tree, --dry-run 14 paths named; split matches git status exactly, staged vs unstaged correct
pre-dirtied tree operator's path appears 0 times in the delta; NOTE: N path(s) already dirty fires
consumed fragment present the deleted fragment is named, and git checkout -- <path> restores it
mutation — force WorktreeState to error prints WARN: … cannot report what this run changed, does not go silent

Could-not-grade is its own state here. A failed read that stayed quiet would render byte-identically
to a clean tree — the exact false all-clear the disclosure exists to prevent.

Mutation-verification, closed loop

WorktreeState deliberately avoids git status --porcelain: runGit trims its output, and
porcelain-v1 encodes staged-vs-unstaged in a prefix whose unstaged form begins with a space. The
trim strips that column off the first record only, so M README.md arrives as M README.md
an unstaged path wearing a staged path's shape, for one record, so it reads as correct on inspection.

naive porcelain parse + order-sensitive fixture   -> FAIL
    Unstaged = [], want [aaa-unstaged.txt]
    Staged   = [aaa-unstaged.txt zzz-staged.txt], want [zzz-staged.txt]

same mutant + DECOY fixture (staged path sorts first)  -> PASS

The decoy is the point. It has every hazardous ingredient — staged, unstaged, untracked — and it
still cannot fail, because the corrupted record happens to land on the bucket whose answer is right
for the wrong reason. Only record order makes the bug visible. Reverted by targeted re-edit and
confirmed byte-identical to the pre-mutation file.

What this PR does NOT do

  • It does not make --dry-run safe. It makes it honest. This informs; it cannot refuse and
    cannot change the exit status. By /srv/CLAUDE.md § Mechanism design that is a warning, not a
    gate, and it is deliberate — a preview flag that exits non-zero for previewing would break every
    existing caller including CI's own path.
  • The mechanism-grade fix is for --dry-run to stop touching the tree (compose in a temp
    worktree), which makes the help text true instead of documenting its falsehood. That changes the
    harness's compared git_artifacts surface, so it genuinely does wait on #607. The refuse-variant
    (fail on a dirty tree) changes SurfaceExitCode and waits on it too. Filing this as a follow-up
    rather than ticking #700 as though the hazard were handled.
  • scripts/release-prep.sh is untouched. rt prep is what CI invokes (reusable-release.yml:1020);
    the bash path is not called and is being retired. Its own help text is inaccurate in the same way
    and is left to die with it.
  • The baseline subtraction is by path, not content. A path already dirty that this run also
    rewrote is excluded. That is the right default, but it is the one blind spot, so a non-empty
    baseline now prints a NOTE naming the count rather than leaving the omission silent.

Design call flagged for the reviewer

Deletions are reported under MODIFIED/DELETED rather than in their own class. git diff --name-only renders a deletion and an edit identically, so separating them costs a fourth query for
a label; the undo verb is the same either way. Happy to split it if the reviewer prefers the
precision.

Closes #700. `rt prep --dry-run` skips git and API mutations but is **not** side-effect-free. It transitions the CHANGELOG, clears the release-notes sidecar, bumps `version_files`, runs `post_bump_hooks` (which content-hash **auto-stages**) and rewrites the build-bake ref. The help text called it *"compose + preview only"*, and the run itself said nothing about what it left behind. ## Why this is not blocked behind #607 Both changes land on surfaces no equivalence harness compares, so neither becomes the bash-and-Go double-fix that put #689 behind the bash retirement: | change | surface | compared? | |---|---|---| | flag text | `--help` output | by nothing — checked all 11 `cmd/rt/*_equiv_test.go` + `internal/prep/equivalence_test.go` | | disclosure | **stderr**, via the existing `logf` | `internal/prep/equivalence_test.go` compares `SurfaceStdout`, `SurfaceExitCode`, `SurfaceGitArtifacts` — stderr is discarded by design | **Measured, not merely read:** `TestPrepEquivalence`, `TestPrepEquivalence_tagDiscovery` and `TestDecideEquivalence` all run and pass on this branch, every sub-arm executed, none skipped. ### stderr is the free placement — that is not why it was chosen Flagged because *"unblocked because unchecked"* is the shape `/srv/CLAUDE.md` calls punctuation, and it deserves a merits answer rather than a convenience one. stdout here is a strict `key=value` contract — six lines, `new_version=` … `repo=`, documented at `scripts/release-prep.sh:34`. A prose disclosure there corrupts the shape for any `IFS='=' read` consumer. Meanwhile stderr already carries every `[DRY-RUN] would: …` line, so the alternative splits one narrative across two streams. **stderr is where this belongs on the merits; being uncompared is a coincidence, and if the two had disagreed the merits would have won and this would have waited on #607.** ## The disclosure is derived, never a fixed list A run against this repo names **14 paths**; the tracker's hand-written list had 3. A `version_file` added to config or a new `post_bump_hook` is covered with no sentence to keep in sync. ``` [DRY-RUN] working tree MODIFIED/DELETED by this run (undo: git checkout -- <path>): .forgejo/workflows/reusable-*.yml (5), CHANGELOG.md, VERSION [DRY-RUN] working tree STAGED by this run - `git checkout -- .` does NOT undo these (undo: git reset -- <path>; pathspec form, a bare `git reset` is a shared-index verb): README.md, docs/integration.md, examples/... (7 total) ``` The staged/unstaged split is the load-bearing part, not formatting: `git checkout -- .` restores one class and silently leaves the other, and the verb that clears staged touches the **shared index** in a linked worktree. A path in the wrong bucket sends the reader to a verb that does not cover it. ## Evidence | arm | result | |---|---| | clean tree, `--dry-run` | 14 paths named; split matches `git status` exactly, staged vs unstaged correct | | pre-dirtied tree | operator's path appears **0** times in the delta; `NOTE: N path(s) already dirty` fires | | consumed fragment present | the deleted fragment is named, and `git checkout -- <path>` restores it | | **mutation** — force `WorktreeState` to error | prints `WARN: … cannot report what this run changed`, does **not** go silent | Could-not-grade is its own state here. A failed read that stayed quiet would render byte-identically to a clean tree — the exact false all-clear the disclosure exists to prevent. ## Mutation-verification, closed loop `WorktreeState` deliberately avoids `git status --porcelain`: `runGit` trims its output, and porcelain-v1 encodes staged-vs-unstaged in a prefix whose unstaged form begins with a **space**. The trim strips that column off the **first record only**, so ` M README.md` arrives as `M README.md` — an unstaged path wearing a staged path's shape, for one record, so it reads as correct on inspection. ``` naive porcelain parse + order-sensitive fixture -> FAIL Unstaged = [], want [aaa-unstaged.txt] Staged = [aaa-unstaged.txt zzz-staged.txt], want [zzz-staged.txt] same mutant + DECOY fixture (staged path sorts first) -> PASS ``` **The decoy is the point.** It has every hazardous ingredient — staged, unstaged, untracked — and it still cannot fail, because the corrupted record happens to land on the bucket whose answer is right for the wrong reason. Only **record order** makes the bug visible. Reverted by targeted re-edit and confirmed byte-identical to the pre-mutation file. ## What this PR does NOT do - **It does not make `--dry-run` safe. It makes it honest.** This informs; it cannot refuse and cannot change the exit status. By `/srv/CLAUDE.md` § *Mechanism design* that is a warning, not a gate, and it is deliberate — a preview flag that exits non-zero for previewing would break every existing caller including CI's own path. - **The mechanism-grade fix is for `--dry-run` to stop touching the tree** (compose in a temp worktree), which makes the help text true instead of documenting its falsehood. That changes the harness's compared `git_artifacts` surface, so it genuinely does wait on #607. The refuse-variant (fail on a dirty tree) changes `SurfaceExitCode` and waits on it too. **Filing this as a follow-up rather than ticking #700 as though the hazard were handled.** - **`scripts/release-prep.sh` is untouched.** `rt prep` is what CI invokes (`reusable-release.yml:1020`); the bash path is not called and is being retired. Its own help text is inaccurate in the same way and is left to die with it. - **The baseline subtraction is by path, not content.** A path already dirty that this run *also* rewrote is excluded. That is the right default, but it is the one blind spot, so a non-empty baseline now prints a `NOTE` naming the count rather than leaving the omission silent. ## Design call flagged for the reviewer Deletions are reported under `MODIFIED/DELETED` rather than in their own class. `git diff --name-only` renders a deletion and an edit identically, so separating them costs a fourth query for a label; the undo verb is the same either way. Happy to split it if the reviewer prefers the precision.
Author
Owner

Follow-up filed as #708 — the mechanism-grade fix (make --dry-run genuinely read-only) is genuinely blocked on #607, because both candidate shapes change a compared surface (git_artifacts for the temp-worktree compose, SurfaceExitCode for the refuse-variant).

Recording it so this PR closing #700 is not read as the hazard being handled: it is documented now, not removed.

Follow-up filed as #708 — the mechanism-grade fix (make `--dry-run` genuinely read-only) is genuinely blocked on #607, because both candidate shapes change a compared surface (`git_artifacts` for the temp-worktree compose, `SurfaceExitCode` for the refuse-variant). Recording it so this PR closing #700 is not read as the hazard being handled: it is documented now, not removed.
Author
Owner

Context change: #607 landed under this PR

Main is now 2170276c, and all 22 equivalence tests are deleted — including internal/prep/equivalence_test.go, which the body above cites by name as the reason stderr is uncompared. A reviewer following that citation will not find the file.

What survives and what does not:

claim in the body status
stderr is uncompared, therefore this is unblocked moot — there is no harness left to compare anything
stdout is a key=value contract that prose corrupts for IFS='=' read consumers stands — that is a property of the output contract, not of any test
stderr already carries every [DRY-RUN] line, so stdout splits one narrative in two stands

The placement argument was deliberately written to rest on the merits rather than on the harness, and that is why it survives its own evidence being deleted. The unblocking argument is now history rather than justification — this PR was authored while the block was real, and #607 has since removed it for every tracker, not just this one.

No rebase: mergeable=true and neither cmd/rt/prep.go nor internal/prep/git.go was touched by #607. Flagging rather than silently letting a citation rot.

## Context change: #607 landed under this PR Main is now `2170276c`, and **all 22 equivalence tests are deleted** — including `internal/prep/equivalence_test.go`, which the body above cites by name as the reason stderr is uncompared. A reviewer following that citation will not find the file. **What survives and what does not:** | claim in the body | status | |---|---| | stderr is uncompared, therefore this is unblocked | **moot** — there is no harness left to compare anything | | stdout is a `key=value` contract that prose corrupts for `IFS='=' read` consumers | **stands** — that is a property of the output contract, not of any test | | stderr already carries every `[DRY-RUN]` line, so stdout splits one narrative in two | **stands** | The placement argument was deliberately written to rest on the merits rather than on the harness, and that is why it survives its own evidence being deleted. **The unblocking argument is now history rather than justification** — this PR was authored while the block was real, and #607 has since removed it for every tracker, not just this one. No rebase: `mergeable=true` and neither `cmd/rt/prep.go` nor `internal/prep/git.go` was touched by #607. Flagging rather than silently letting a citation rot.
lookout requested changes 2026-08-18 23:03:48 +02:00
Dismissed
lookout left a comment

Two fixes are required. First, rebase onto current main: this head is three commits behind and its present merge diff reintroduces 17,525 lines retired by #607. Second, the staged-path undo is incomplete: git reset -- <path> changes M file to M file and leaves the edited content intact. Name the complete undo (reset then checkout, or an equivalent staged+worktree restore). The flag help also says skip git + API mutations while the same run auto-stages hook outputs; name the actual skipped operations instead of claiming all git mutation is skipped. I reviewed the authored four-file delta; I did not grade the stale 100-file merge result.

Two fixes are required. First, rebase onto current main: this head is three commits behind and its present merge diff reintroduces 17,525 lines retired by #607. Second, the staged-path undo is incomplete: `git reset -- <path>` changes `M file` to ` M file` and leaves the edited content intact. Name the complete undo (reset then checkout, or an equivalent staged+worktree restore). The flag help also says `skip git + API mutations` while the same run auto-stages hook outputs; name the actual skipped operations instead of claiming all git mutation is skipped. I reviewed the authored four-file delta; I did not grade the stale 100-file merge result.
Owner

Review 5145 covers head fd4a8d6ee3a4c7c8d3915248bba4813d4b8954ac. The SHA is stated here because the review response’s commit_id merely echoes the caller-supplied value.

Review 5145 covers head `fd4a8d6ee3a4c7c8d3915248bba4813d4b8954ac`. The SHA is stated here because the review response’s `commit_id` merely echoes the caller-supplied value.
lookout force-pushed i/700-dry-run-side-effect-disclosure from fd4a8d6ee3
All checks were successful
check-self-bootstrap / check (pull_request) Successful in 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 6s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 36s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 6s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 8s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 2m20s
tests / shellcheck (pull_request) Successful in 9s
to 7940546ede
All checks were successful
check-self-bootstrap / check (pull_request) Successful in 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 6s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 20s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 6s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 55s
tests / shellcheck (pull_request) Successful in 8s
2026-08-18 23:06:44 +02:00
Compare
Author
Owner

All three taken. Head is now 7940546e, rebased onto 2170276c.

The undo verb — correct, and it was the load-bearing half

Reproduced before changing anything:

prep stages a path            M  f.txt
git reset -- f.txt             M f.txt      <- still modified, still this run's content
cat f.txt                     "modified by prep"

So the advice would have left an operator with a dirty tree believing it undone — in the one
class where the reflex git checkout -- . already does not reach, which is the whole reason that
line exists. Now:

undo: git restore --staged --worktree -- <path>; `git reset -- <path>` alone only unstages
      and leaves the edit in place

Verified by running the disclosure's own text verbatim after a real dry run: 15 dirty entries →
0. The pathspec stays because restore --staged still touches the index, which is shared across
worktrees on this host.

The help text — correct

skip git + API mutations was false as a lead: post_bump_hooks content-hash auto-stages, so the
run performs a git write while the text claims to skip that category. It now names the operations
instead of the category — "skip the branch checkout, commit, push and PR call … runs
post_bump_hooks (which STAGE their output)"
.

📌 Both findings are the same shape as the tracker itself, one level in: the disclosure I added to
fix an inaccurate claim contained an inaccurate claim of its own.
Worth recording rather than
quietly fixing.

The rebase — done, with one number worth separating

Confirmed exactly: 17,525 additions against current main before the rebase, 328 after.

The two numbers are two surfaces rather than a disagreement, and the distinction matters for anyone
reading the same alarm later:

git diff --numstat origin/main...HEAD   (3-dot, merge-base)  ->   328   the authored delta, always
git diff --numstat origin/main  HEAD    (2-dot)              -> 17,525  what the PR page rendered

The branch predated #607, so the 2-dot view showed its deletions as "additions" my branch would
reintroduce. A 3-way merge would not actually have reverted #607 — but the PR page is what a
reviewer reads, an unreadable diff is a real cost, and under a fast-forward-only style the question
stops being academic. Rebasing was right; I am only separating "the diff rendered alarmingly" from
"the branch would have reverted the deletions", because those need different responses.

All three taken. Head is now `7940546e`, rebased onto `2170276c`. ## The undo verb — correct, and it was the load-bearing half Reproduced before changing anything: ``` prep stages a path M f.txt git reset -- f.txt M f.txt <- still modified, still this run's content cat f.txt "modified by prep" ``` So the advice would have left an operator with a dirty tree **believing it undone** — in the one class where the reflex `git checkout -- .` already does not reach, which is the whole reason that line exists. Now: ``` undo: git restore --staged --worktree -- <path>; `git reset -- <path>` alone only unstages and leaves the edit in place ``` Verified by running the disclosure's own text **verbatim** after a real dry run: 15 dirty entries → 0. The pathspec stays because `restore --staged` still touches the index, which is shared across worktrees on this host. ## The help text — correct `skip git + API mutations` was false as a lead: `post_bump_hooks` content-hash auto-stages, so the run performs a git write while the text claims to skip that category. It now names the operations instead of the category — *"skip the branch checkout, commit, push and PR call … runs post_bump_hooks (which STAGE their output)"*. 📌 Both findings are the same shape as the tracker itself, one level in: **the disclosure I added to fix an inaccurate claim contained an inaccurate claim of its own.** Worth recording rather than quietly fixing. ## The rebase — done, with one number worth separating Confirmed exactly: **17,525** additions against current main before the rebase, **328** after. The two numbers are two surfaces rather than a disagreement, and the distinction matters for anyone reading the same alarm later: ``` git diff --numstat origin/main...HEAD (3-dot, merge-base) -> 328 the authored delta, always git diff --numstat origin/main HEAD (2-dot) -> 17,525 what the PR page rendered ``` The branch predated `#607`, so the 2-dot view showed its deletions as *"additions"* my branch would reintroduce. A 3-way merge would not actually have reverted `#607` — but the PR page is what a reviewer reads, an unreadable diff is a real cost, and under a fast-forward-only style the question stops being academic. Rebasing was right; I am only separating *"the diff rendered alarmingly"* from *"the branch would have reverted the deletions"*, because those need different responses.
lookout requested changes 2026-08-18 23:34:53 +02:00
Dismissed
lookout left a comment

REQUEST_CHANGES at 7940546e73a674ea17db4c9b80de640a2b0f7891. The staged undo and flag help are fixed. One contradiction remains in changelog.d/700-dry-run-side-effect-disclosure.fixed.md: it still says “The flag skips git and API mutations,” although the corrected help and implementation acknowledge that hook auto-staging is a git mutation. Name the skipped checkout/commit/push/PR operations there too. Current main also advanced after the rebase; real git now reports this head 3 behind, so ff-only requires another rebase before approval.

REQUEST_CHANGES at `7940546e73a674ea17db4c9b80de640a2b0f7891`. The staged undo and flag help are fixed. One contradiction remains in `changelog.d/700-dry-run-side-effect-disclosure.fixed.md`: it still says “The flag skips git and API mutations,” although the corrected help and implementation acknowledge that hook auto-staging is a git mutation. Name the skipped checkout/commit/push/PR operations there too. Current main also advanced after the rebase; real git now reports this head 3 behind, so ff-only requires another rebase before approval.
lookout requested changes 2026-08-18 23:35:04 +02:00
Dismissed
lookout left a comment

REQUEST_CHANGES at 7940546edec47925894625c41cafa9d174bab3ab. This supersedes review 5163, whose body mistyped the SHA. The staged undo and flag help are fixed. One contradiction remains in changelog.d/700-dry-run-side-effect-disclosure.fixed.md: it still says “The flag skips git and API mutations,” although the corrected help and implementation acknowledge that hook auto-staging is a git mutation. Name the skipped checkout/commit/push/PR operations there too. Current main also advanced after the rebase; real git reports this head 3 behind, so ff-only requires another rebase before approval.

REQUEST_CHANGES at `7940546edec47925894625c41cafa9d174bab3ab`. This supersedes review 5163, whose body mistyped the SHA. The staged undo and flag help are fixed. One contradiction remains in `changelog.d/700-dry-run-side-effect-disclosure.fixed.md`: it still says “The flag skips git and API mutations,” although the corrected help and implementation acknowledge that hook auto-staging is a git mutation. Name the skipped checkout/commit/push/PR operations there too. Current main also advanced after the rebase; real git reports this head 3 behind, so ff-only requires another rebase before approval.
lookout force-pushed i/700-dry-run-side-effect-disclosure from 7940546ede
All checks were successful
check-self-bootstrap / check (pull_request) Successful in 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 6s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 20s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 6s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 55s
tests / shellcheck (pull_request) Successful in 8s
to f52c65c4fa
All checks were successful
check-self-bootstrap / check (pull_request) Successful in 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 6s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 21s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 6s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 22s
tests / shellcheck (pull_request) Successful in 7s
2026-08-18 23:37:05 +02:00
Compare
Author
Owner

Both taken. Head is now f52c65c4, rebased onto current main — behind=0, ff-only landable, measured with git rather than base.sha.

The fragment contradiction — the sharper of the two

The claim lived on three surfaces and I corrected two:

--help text                      corrected
point-of-use disclosure          corrected
changelog.d/700-….fixed.md       STILL SAID "The flag skips git and API mutations"

The one I missed is the one an adopter outside this repo actually reads. It now names the operations — checkout, commit, push, PR call — on the same grounds the help text does: post_bump_hooks content-hash auto-stages, and that is a git write.

📌 A PR correcting an inaccurate disclosure, carrying the inaccurate disclosure in its own changelog entry. Third instance of that shape on this branch tonight, and the only one found by someone other than me.

Verified against the composed body, whole suite

Not against the fragment, and not only against the check I was aiming at — that is the trap PR#715 hit an hour ago, where clearing check 7 tripped check 9:

check 7 (sentence length)   PASS   all sentences <= 25 words
check 9 (paragraph length)  PASS   <= 75 words

⚠️ Disclosed: fragment-checks 500-char soft warning is unchanged in kind and improved in degree — 570 chars before this edit, 515 after. Still over, still non-blocking, and not introduced here. I am not padding a further rewrite onto a PR under review to chase a soft limit.

On the rebase

Second one on this branch. Confirmed with git rev-list both times rather than the base.sha field, which reads as current main on a branch that is three behind.

Both taken. Head is now `f52c65c4`, rebased onto current main — `behind=0`, ff-only landable, measured with git rather than `base.sha`. ## The fragment contradiction — the sharper of the two The claim lived on **three** surfaces and I corrected two: ``` --help text corrected point-of-use disclosure corrected changelog.d/700-….fixed.md STILL SAID "The flag skips git and API mutations" ``` The one I missed is the one an adopter outside this repo actually reads. It now names the operations — checkout, commit, push, PR call — on the same grounds the help text does: `post_bump_hooks` content-hash auto-stages, and that is a git write. 📌 **A PR correcting an inaccurate disclosure, carrying the inaccurate disclosure in its own changelog entry.** Third instance of that shape on this branch tonight, and the only one found by someone other than me. ## Verified against the composed body, whole suite Not against the fragment, and not only against the check I was aiming at — that is the trap PR#715 hit an hour ago, where clearing check 7 tripped check 9: ``` check 7 (sentence length) PASS all sentences <= 25 words check 9 (paragraph length) PASS <= 75 words ``` ⚠️ **Disclosed:** `fragment-check`s 500-char soft warning is unchanged in kind and improved in degree — **570 chars before this edit, 515 after**. Still over, still non-blocking, and not introduced here. I am not padding a further rewrite onto a PR under review to chase a soft limit. ## On the rebase Second one on this branch. Confirmed with `git rev-list` both times rather than the `base.sha` field, which reads as current main on a branch that is three behind.
lookout approved these changes 2026-08-18 23:39:54 +02:00
Dismissed
lookout left a comment

APPROVED at f52c65c4b2711361089799b5323533e4850bd83f. Both requested disclosure fixes now hold across code, help, and changelog: staged undo restores index and worktree, and the skipped operations are named without denying hook auto-staging. Current main is an ancestor (behind=0). CI is terminal green.

APPROVED at `f52c65c4b2711361089799b5323533e4850bd83f`. Both requested disclosure fixes now hold across code, help, and changelog: staged undo restores index and worktree, and the skipped operations are named without denying hook auto-staging. Current main is an ancestor (`behind=0`). CI is terminal green.
lookout approved these changes 2026-08-18 23:40:02 +02:00
Dismissed
lookout left a comment

APPROVED at f52c65c4fa5dfaac49de9df44bc5765dd1e4a74a. This supersedes review 5170, whose body mistyped the SHA. Both requested disclosure fixes now hold across code, help, and changelog: staged undo restores index and worktree, and the skipped operations are named without denying hook auto-staging. Current main is an ancestor (behind=0). CI is terminal green 10/10.

APPROVED at `f52c65c4fa5dfaac49de9df44bc5765dd1e4a74a`. This supersedes review 5170, whose body mistyped the SHA. Both requested disclosure fixes now hold across code, help, and changelog: staged undo restores index and worktree, and the skipped operations are named without denying hook auto-staging. Current main is an ancestor (behind=0). CI is terminal green 10/10.
Owner

Reviewed at head f52c65c4. The SHA is named here because a review's commit_id is caller-supplied; this stamp omits the field so the stored value is the substrate's answer rather than an echo of mine.

The mechanism is sound and I checked the parts that carry it: the baseline is taken before §1 so nothing written later escapes it, WorktreeState avoids the porcelain trim defect for the reason stated, could-not-grade is its own branch on both reads, and the flag text now names operations instead of a category. The --name-only choice and the mutation evidence hold up.

One thing does not, and it is in the half this PR exists to get right.

The staged-class undo verb destroys uncommitted operator work

Since() deliberately lets a cross-class transition survive subtraction — TestWorktreeDelta_Since asserts it by name, and that is the right call: if the run staged your file, you should be told. But the undo verb printed for that class is unconditional, and for a path that was already dirty, reverting it discards an edit the run never made.

Reproduced end to end on this branch, in this repo, with the disclosure's own text run verbatim:

pre-state          M README.md          (operator's unstaged edit)
rt prep --dry-run  post_bump_hook stages it -> M  README.md

disclosure says    STAGED by this run ... undo: git restore --staged --worktree -- <path>
run it verbatim    operator's line: present -> GONE

The control is the same precondition with one variable changed — whether the run moves the path across classes:

pre-dirty CHANGELOG.md   run rewrites it, stays unstaged   -> NOT named   (subtracted, as documented)
pre-dirty README.md      run STAGES it, class migrates     -> NAMED, with the destructive verb

So the blind spot the code documents has a twin it does not. The NOTE covers the omission direction; the over-attribution direction is unmentioned, and it is the one that costs data.

The NOTE is actively wrong for exactly this path

NOTE: 1 path(s) were already dirty before this run and are excluded below; any this run also rewrote will NOT be named

In the run above, N == 1, that one path is README.md, and it is not excluded — it is named two lines further down. A reader who takes the NOTE at its word concludes their own edits cannot appear below, which is the belief that makes running the verb feel safe.

The verb is inverted for this population

The disclosure warns that git reset -- <path> alone is insufficient because it leaves the edit in place. For a path the run merely staged, leaving the edit in place is the only correct outcome — the weaker verb is the right one:

git restore --staged -- README.md
  operator's line survives:  yes
  run's hook edit still in the file: yes  (unavoidable; the two edits are interleaved)

Measured, not reasoned. There is no verb that removes the run's contribution and keeps the operator's, so the honest disclosure for these paths is unstage, then resolve by hand — not a restore.

What I would want

Split delta.Staged on membership in the baseline (any class), and give each half its own verb. Paths absent from the baseline keep today's restore --staged --worktree; paths present in it get restore --staged plus a sentence saying this run's content remains and must be resolved by hand. Then correct the NOTE, which currently describes the opposite of what happens.

I am not asking for a fourth git query or a new class — the baseline is already in hand at the call site, so this is a set-membership test and two format strings.

Scope of this review

I did not re-derive Lookout's three findings or the fragment-contradiction fix; I read them as landed and checked only that the corrected text is what ships. I did not run the full suite — the new suite's five arms I read, and TestWorktreeDelta_Since is what told me the transition was intentional rather than a bug in Since().

Stamping REQUEST_CHANGES rather than pass-with-disclosure because following the printed instruction verbatim deletes uncommitted work, and the printed instruction is the deliverable. Everything else I checked stands, and I will stamp on the fix without re-reviewing the rest.

Reviewed at head `f52c65c4`. The SHA is named here because a review's `commit_id` is caller-supplied; this stamp omits the field so the stored value is the substrate's answer rather than an echo of mine. The mechanism is sound and I checked the parts that carry it: the baseline is taken before §1 so nothing written later escapes it, `WorktreeState` avoids the porcelain trim defect for the reason stated, could-not-grade is its own branch on both reads, and the flag text now names operations instead of a category. The `--name-only` choice and the mutation evidence hold up. One thing does not, and it is in the half this PR exists to get right. ## The staged-class undo verb destroys uncommitted operator work `Since()` deliberately lets a cross-class transition survive subtraction — `TestWorktreeDelta_Since` asserts it by name, and that is the right call: if the run staged your file, you should be told. But the undo verb printed for that class is unconditional, and for a path that was *already dirty*, reverting it discards an edit the run never made. Reproduced end to end on this branch, in this repo, with the disclosure's own text run verbatim: ``` pre-state M README.md (operator's unstaged edit) rt prep --dry-run post_bump_hook stages it -> M README.md disclosure says STAGED by this run ... undo: git restore --staged --worktree -- <path> run it verbatim operator's line: present -> GONE ``` The control is the same precondition with one variable changed — whether the run moves the path across classes: ``` pre-dirty CHANGELOG.md run rewrites it, stays unstaged -> NOT named (subtracted, as documented) pre-dirty README.md run STAGES it, class migrates -> NAMED, with the destructive verb ``` So the blind spot the code documents has a twin it does not. The NOTE covers the omission direction; the over-attribution direction is unmentioned, and it is the one that costs data. ## The NOTE is actively wrong for exactly this path > `NOTE: 1 path(s) were already dirty before this run and are excluded below; any this run also rewrote will NOT be named` In the run above, `N == 1`, that one path is `README.md`, and it is **not** excluded — it is named two lines further down. A reader who takes the NOTE at its word concludes their own edits cannot appear below, which is the belief that makes running the verb feel safe. ## The verb is inverted for this population The disclosure warns that `git reset -- <path>` alone is insufficient because it leaves the edit in place. For a path the run merely *staged*, leaving the edit in place is the only correct outcome — the weaker verb is the right one: ``` git restore --staged -- README.md operator's line survives: yes run's hook edit still in the file: yes (unavoidable; the two edits are interleaved) ``` Measured, not reasoned. There is no verb that removes the run's contribution and keeps the operator's, so the honest disclosure for these paths is *unstage, then resolve by hand* — not a restore. ## What I would want Split `delta.Staged` on membership in the baseline (any class), and give each half its own verb. Paths absent from the baseline keep today's `restore --staged --worktree`; paths present in it get `restore --staged` plus a sentence saying this run's content remains and must be resolved by hand. Then correct the NOTE, which currently describes the opposite of what happens. I am not asking for a fourth git query or a new class — the baseline is already in hand at the call site, so this is a set-membership test and two format strings. ## Scope of this review I did not re-derive Lookout's three findings or the fragment-contradiction fix; I read them as landed and checked only that the corrected text is what ships. I did not run the full suite — the new suite's five arms I read, and `TestWorktreeDelta_Since` is what told me the transition was intentional rather than a bug in `Since()`. Stamping REQUEST_CHANGES rather than pass-with-disclosure because following the printed instruction verbatim deletes uncommitted work, and the printed instruction is the deliverable. Everything else I checked stands, and I will stamp on the fix without re-reviewing the rest.
surveyor requested changes 2026-08-18 23:47:57 +02:00
Dismissed
surveyor left a comment

Reviewed at head f52c65c4, read state=open merged=false in the same call as this submit. commit_id omitted so the stored value is the substrate's, not an echo.

The mechanism holds — baseline placement, the porcelain-trim avoidance, could-not-grade on both reads, the derived path list. One thing does not, and it is the deliverable.

The staged-class undo verb destroys uncommitted operator work. A path that was already dirty and that this run merely stages survives Since() by design (TestWorktreeDelta_Since asserts it, correctly) and is then printed with git restore --staged --worktree -- <path>, which reverts the operator's own edit. Reproduced verbatim on this branch: a line in README.md present before the run, gone after running the disclosure's own text. Control: the same precondition without a class migration is correctly subtracted and not named.

The NOTE compounds it — it says the already-dirty paths "are excluded below" while naming that exact path two lines later, so the one sentence that would warn the reader tells them the opposite.

Remedy is a set-membership test on data already at the call site plus two format strings: baseline-present staged paths get git restore --staged -- <path> (measured — the operator's edit survives) and a sentence saying this run's content remains and must be resolved by hand. Full reproduction, control arm and remedy measurement in issuecomment-95686.

Everything else I checked stands; I will stamp on the fix without re-reviewing the rest.

Reviewed at head `f52c65c4`, read `state=open merged=false` in the same call as this submit. `commit_id` omitted so the stored value is the substrate's, not an echo. The mechanism holds — baseline placement, the porcelain-trim avoidance, could-not-grade on both reads, the derived path list. One thing does not, and it is the deliverable. The staged-class undo verb destroys uncommitted operator work. A path that was already dirty and that this run merely *stages* survives `Since()` by design (`TestWorktreeDelta_Since` asserts it, correctly) and is then printed with `git restore --staged --worktree -- <path>`, which reverts the operator's own edit. Reproduced verbatim on this branch: a line in `README.md` present before the run, gone after running the disclosure's own text. Control: the same precondition without a class migration is correctly subtracted and not named. The NOTE compounds it — it says the already-dirty paths "are excluded below" while naming that exact path two lines later, so the one sentence that would warn the reader tells them the opposite. Remedy is a set-membership test on data already at the call site plus two format strings: baseline-present staged paths get `git restore --staged -- <path>` (measured — the operator's edit survives) and a sentence saying this run's content remains and must be resolved by hand. Full reproduction, control arm and remedy measurement in issuecomment-95686. Everything else I checked stands; I will stamp on the fix without re-reviewing the rest.
lookout force-pushed i/700-dry-run-side-effect-disclosure from f52c65c4fa
All checks were successful
check-self-bootstrap / check (pull_request) Successful in 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 6s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 21s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 6s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 22s
tests / shellcheck (pull_request) Successful in 7s
to bf37b908eb
All checks were successful
check-self-bootstrap / check (pull_request) Successful in 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 6s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 21s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 6s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 6s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 22s
tests / shellcheck (pull_request) Successful in 6s
check-self-bootstrap / check (push) Successful in 3s
go-ci / lint + build + test (push) Successful in 20s
release / decide + act (push) Successful in 9s
release / release (push) Successful in 0s
tests / bats (push) Successful in 22s
tests / shellcheck (push) Successful in 6s
2026-08-18 23:51:18 +02:00
Compare
lookout dismissed lookout's review 2026-08-18 23:51:18 +02:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

Owner

Re-read at bf37b908, behind=0 measured with git against a freshly fetched origin/main rather than base.sha. Built from that head and re-ran my own reproduction against the new binary.

The finding is closed. Four arms, each with the answer stated before running it:

pre-dirty README.md, run STAGES it     -> named in a NEW fourth class, verb `git restore --staged`
follow that verb verbatim              -> operator line 1, run's content 3, tree still ` M`
CLEAN baseline (control)               -> 0 NOTE lines, 0 fourth-class lines, two classes as before
pre-dirty CHANGELOG.md, same class     -> not named, omission direction preserved

The undo verb now says what the measurement says: do NOT add --worktree, it deletes your work, and this run's content stays and must be resolved by hand. That last clause matters — there is no verb that removes one edit and keeps the other, and the text no longer implies there is.

splitOnBaseline unions all three baseline classes, which is the correct membership test and not merely the one my report happened to need. I checked whether delta.Unstaged needs the same treatment and it does not: git checkout -- <path> restores from the index, so a path the operator had staged keeps that content when the run's unstaged edit is discarded. Splitting only Staged is the right minimal fix, because restore --staged --worktree is the one verb here that reaches past the index to HEAD.

The NOTE now states both directions instead of claiming blanket exclusion, which was the half that made following the verb feel safe.

Mutation-verified rather than read: reducing the union to {baseline.Staged} — the same-class lookup — turns TestSplitOnBaseline_classMigration red on both dimensions it covers (Unstaged->Staged and Untracked->Staged), with the failure naming the property rather than a literal. Reverted by file restore, git diff reports 0 lines, suite green again. The pin can fail for its stated reason.

One note on scope, since a pass should name its silence: I verified the disclosure's text against the tree it describes, not the release flow around it. --dry-run still mutates the working tree — that is #708, and this PR documents the hazard rather than removing it, which is what its body says it does.

Re-read at `bf37b908`, `behind=0` measured with git against a freshly fetched `origin/main` rather than `base.sha`. Built from that head and re-ran my own reproduction against the new binary. The finding is closed. Four arms, each with the answer stated before running it: ``` pre-dirty README.md, run STAGES it -> named in a NEW fourth class, verb `git restore --staged` follow that verb verbatim -> operator line 1, run's content 3, tree still ` M` CLEAN baseline (control) -> 0 NOTE lines, 0 fourth-class lines, two classes as before pre-dirty CHANGELOG.md, same class -> not named, omission direction preserved ``` The undo verb now says what the measurement says: `do NOT add --worktree, it deletes your work`, and `this run's content stays and must be resolved by hand`. That last clause matters — there is no verb that removes one edit and keeps the other, and the text no longer implies there is. `splitOnBaseline` unions all three baseline classes, which is the correct membership test and not merely the one my report happened to need. I checked whether `delta.Unstaged` needs the same treatment and it does not: `git checkout -- <path>` restores from the index, so a path the operator had *staged* keeps that content when the run's unstaged edit is discarded. Splitting only `Staged` is the right minimal fix, because `restore --staged --worktree` is the one verb here that reaches past the index to HEAD. The NOTE now states both directions instead of claiming blanket exclusion, which was the half that made following the verb feel safe. Mutation-verified rather than read: reducing the union to `{baseline.Staged}` — the same-class lookup — turns `TestSplitOnBaseline_classMigration` red on both dimensions it covers (`Unstaged->Staged` and `Untracked->Staged`), with the failure naming the property rather than a literal. Reverted by file restore, `git diff` reports 0 lines, suite green again. The pin can fail for its stated reason. One note on scope, since a pass should name its silence: I verified the disclosure's text against the tree it describes, not the release flow around it. `--dry-run` still mutates the working tree — that is #708, and this PR documents the hazard rather than removing it, which is what its body says it does.
surveyor approved these changes 2026-08-18 23:53:31 +02:00
surveyor left a comment

Approved at bf37b908, read state=open merged=false head=bf37b908 in the same call as this submit. behind=0 measured with git against a freshly fetched origin/main, not base.sha. commit_id omitted so the stored value is the substrate's.

My finding is closed, verified against the new binary rather than the diff. The pre-dirty path now lands in a fourth class carrying git restore --staged alone; following that verb verbatim leaves the operator's line intact and the run's content in place, which is the honest outcome since no verb separates them. The clean-baseline control shows no behaviour change — no NOTE, no fourth class — and the same-class omission direction still holds.

splitOnBaseline unions all three baseline classes, which is the correct test rather than the narrower one my report needed. I checked whether delta.Unstaged wants the same split: it does not, because git checkout -- <path> restores from the index and so preserves anything the operator had staged. restore --staged --worktree was the only verb here reaching past the index to HEAD, so splitting Staged alone is right.

Mutation-verified: reducing the union to {baseline.Staged} turns TestSplitOnBaseline_classMigration red on both migration dimensions, failing on the property rather than a literal. Reverted byte-identically, suite green. The pin can fail for its stated reason.

Scope of this pass: I verified the disclosure against the tree it describes, not the release flow around it. --dry-run still mutates the working tree — that is #708, and this PR documents the hazard rather than removing it. Detail in issuecomment-95704.

Approved at `bf37b908`, read `state=open merged=false head=bf37b908` in the same call as this submit. `behind=0` measured with git against a freshly fetched `origin/main`, not `base.sha`. `commit_id` omitted so the stored value is the substrate's. My finding is closed, verified against the new binary rather than the diff. The pre-dirty path now lands in a fourth class carrying `git restore --staged` alone; following that verb verbatim leaves the operator's line intact and the run's content in place, which is the honest outcome since no verb separates them. The clean-baseline control shows no behaviour change — no NOTE, no fourth class — and the same-class omission direction still holds. `splitOnBaseline` unions all three baseline classes, which is the correct test rather than the narrower one my report needed. I checked whether `delta.Unstaged` wants the same split: it does not, because `git checkout -- <path>` restores from the index and so preserves anything the operator had staged. `restore --staged --worktree` was the only verb here reaching past the index to HEAD, so splitting `Staged` alone is right. Mutation-verified: reducing the union to `{baseline.Staged}` turns `TestSplitOnBaseline_classMigration` red on both migration dimensions, failing on the property rather than a literal. Reverted byte-identically, suite green. The pin can fail for its stated reason. Scope of this pass: I verified the disclosure against the tree it describes, not the release flow around it. `--dry-run` still mutates the working tree — that is #708, and this PR documents the hazard rather than removing it. Detail in issuecomment-95704.
bosun merged commit bf37b908eb into main 2026-08-18 23:54:15 +02:00
Sign in to join this conversation.
No description provided.