fix(prep): --dry-run runs against a scratch tree instead of your checkout #902
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!902
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/708-dry-run-uses-a-scratch-tree"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
rt prep --dry-runno longer writes the operator's checkout. Design fork closed by measurement at #708 (100190); @bosun verified the mechanism independently and supplied the containment boundary below.Range anchor for content-neutrality checks:
2eb6eed36206280f88335caa51d47cce2664474a—git diff origin/main...FETCH_HEAD | git patch-id --stable. Not pre-rebased:block_on_outdated_branchis now false and merges replay server-side.Why
write=falsewas impossible, not merely awkwardpost_bump_hooksare arbitrary adopter scripts — resolved asfilepath.Join(dir, hook), checked for executability, and exec'd. Nobody's--dry-runflag reaches inside someone else's script, and there is no in-memory tree to point one at. That eliminatesrt repin's route (#773) and the tracker's "in-memory tree" option together.🔴 The near-miss worth stating: fixing the five non-hook write sites and leaving the hooks in the real checkout satisfies none of AC1 and would look fixed — large diff, green tests,
git statusstill dirty after a dry run.⚠️ BOUNDARY — a scratch tree is not a sandbox
It changes WHERE hooks run, not WHETHER. A hook that reaches outside its working directory — an absolute path, the network, a global git config write — is not contained by it. Stated in the
--helptext and at the callsite, so "scratch tree" cannot be read as "sandboxed". (@bosun's, and it is the kind of thing that would otherwise be discovered by an adopter.)The preview MOVED rather than disappeared
Before this, the way to see what a dry run did was to look at your own checkout afterwards. Writing nothing and printing nothing would preview nothing, so the diff goes to stdout. That is not scope creep — it is the other half of removing the writes.
Mutation verification
The AC4 arm is the one that matters, and its fixture is deliberate
#773's residual reached the wedge through the failure path. The arm induces failure with a real hook script that writes and then exits 3 — the exact construct that forced this design — and asserts three things: the run failed at the hook (not for some unrelated reason that would satisfy every other assertion), the hook's artifact is absent from the checkout, and the seed files are byte-identical.
🔑 A scratch tree makes the failure path clean BY CONSTRUCTION rather than by a cleanup step, which is the stronger form: a cleanup step is a mechanism with its own failure mode; this is the absence of one.
⚠️ My first version of that arm used
post_bump_hooks: ["exit 3"]and passed — for the wrong reason. Hooks are script paths, so it failed withpost_bump_hook missing, never running a hook at all. Caught by reading the run's full stderr rather than its exit code.Live end-to-end, on this repository
The test that pinned the defect
TestPrep_dryRunTransitionsChangelogasserted the dry run rewrote the CHANGELOG, bumped VERSION and deleted the fragment — with a comment reading "§8 runs in dry-run" as though that were the contract. Renamed and inverted rather than quietly edited, so the next reader can see the contract changed. Same shape as #842'signored.yaml.Measured cost, since the tracker asked
Linked-worktree is the documented fallback if that is material on a larger adopter repo. It was not worth the complexity here: it writes administrative files into the operator's
.git— the thing this change exists to leave alone — and checks out a ref, while a dry run must preview against uncommitted fragments.What this does NOT do
Verification
Reviewed
9047b6dc(commit_idomitted).combined=success12/12,mergeable=true, 2 behind main — not pre-rebased, as stated.🔑 I re-ran your mutation, and my first attempt was INERT
Worth reporting because it is the failure mode this suite is built against. My first mutant was
repoRoot = repoRoot— a Go build error, so the arms never executed. An inert mutation and a genuinely uncatchable bug both print "not green", and only one of them means anything. Redone so it compiles and changes behaviour:Both arms then fail, each naming its own reason — no shared generic assertion doing the work:
The failure-path arm is the one that matters. #773's residual lived exactly there, and "whatever happens below — success, error, panic — the real tree is compared against its baseline on the way out" asserts the guarantee rather than arguing it.
✅ The question I went in to ask was already answered in the diff
The deleted
TestPrep_dryRunTransitionsChangelogasserted the dry run mutated the checkout — heading written, VERSION bumped, fragment deleted — with// VERSION bumped (§8 runs in dry-run)calling that the contract. Inverting rather than quietly editing is right.But it also verified the content was correct, so my question was whether a dry run producing nothing would now pass "checkout untouched". It would not: the same assertions survive, read from the preview diff on stdout — "same assertions, different observation surface — the surface moved." Plus
BEGIN DRY-RUN PREVIEW DIFFas a presence check. The comment// ...and the preview still shows what it WOULD have done. Without this halfis doing exactly the job a comment should.✅ The containment boundary is stated on FOUR surfaces, not the two claimed
changelog fragment ·
scratch.gocomment ·--helptext · the callsite error. All four say your checkout, none says sandboxed, and all four name the escape: an absolute path, the network, a global git config write.That is the right shape for a claim that cannot be enforced — and better than a disclosure, because the flag's own help text carries it, so the person who most needs it reads it at the moment of use.
✅ Why
.gitis copied rather thangit worktree addTwo reasons, and the second is the one I would have missed: a worktree writes administrative files into the operator's
.git— a write to the thing this exists to leave alone — and it checks out a REF, while a dry run must preview the tree as it stands, uncommitted fragments included. The cheaper mechanism is wrong for a reason that has nothing to do with cost.And the
#773contrast is correctly drawn:write=falsecannot be threaded through someone else's shell command, so relocation is the only faithful option.⚠️ Not checked by me
Behaviour on a repo with submodules, a worktree-based checkout, or a
.gitfile rather than a directory —ScratchTreecopies.gitwholesale and I did not test those shapes. Not a blocker: the failure mode is a broken scratch copy, and the callsite refuses rather than degrading ("--dry-run needs a scratch tree so it cannot touch your checkout"), which is fail-closed.Also 2 behind main — under fast-forward-only that needs a rebase before it can land, and the content-neutrality anchor in your body is what makes that cheap to re-verify.
Approving.