Repository navigation
bump-leg.py: a bump that fails for any reason once the leg has moved, a commit git refuses included, rolls the pin, the workflow files, the leg's checkout and the index back and says whether all of it went back, so a workflow file it cannot write, a validator line the stdout cannot encode, a signer that cannot sign or an interrupt no longer leaves the pin rewritten and the leg on the new commit (#253) - #264
Conversation
…rolls the pin, the workflow files, the leg's checkout and the index back, so a workflow file it cannot write, a validator line the stdout cannot encode or an interrupt no longer leaves the pin rewritten and the leg on the new commit behind a traceback (#253) `_apply_rewrite` rolled back on `except (Refusal, CommandFailed)` and nothing else. A `PermissionError` on a workflow file, a `UnicodeError` from echoing a validator's output (the crash #230's `errors="replace"` moves the decode error to) or a `KeyboardInterrupt` passed through it: `main()` catches the same two types, so the run ended in a traceback with the pin rewritten, the leg on the new commit and, once staged, the index changed. The handler is now `except BaseException`, and the rollback it runs is three helpers instead of a loop and a `try`. `_put_back` attempts each step (every restored file, the leg's checkout, the index) whatever the steps before it did, and prints, never raises, a step that fails; `_restore_file` writes a file back only when it no longer holds its original bytes, so a read-only file the write never opened is not reported as unrestorable. A `Refusal` or a `CommandFailed` is re-raised as it was. An `OSError` or a `UnicodeError` leaves as `REFUSED bump-leg-rewrite-failed` naming the exception and saying whether the tree is back, the way `update-shape.py` turns an unwritable README into `update-readme-unwritable`; anything else, an interrupt included, is re-raised as itself once the tree is back. tests/test_bump_leg_rollback.py: a read-only workflow file, a stdout that cannot encode a validator's line, a write that dies half way, an interrupt while writing and while validating, a defect in the tool, and a rollback step that itself fails. Each compares the root's HEAD, the pin, every workflow, the gitlink, `git diff --cached --quiet` and the leg's HEAD and branch with what they were before the bump. All eight fail on the code before this change. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…m, not from a codec setting, so they keep meaning the same thing once `main` escapes what a strict stdout cannot encode (#253) The first cut of the echo test ran `bump-leg.py` with `PYTHONIOENCODING=cp1252`. #214's `escape_what_stdout_cannot_encode` reconfigures exactly that stream to `backslashreplace` at the top of `main`, so that test would have started to see a bump that succeeds the day #214 landed. The echo test is now in-process with a stdout whose `write` raises `UnicodeEncodeError` for what cp1252 cannot hold, beside a `UnicodeDecodeError` raised from the validator read, so both ways a validator's output can fail the statement after the staging are covered whichever of #214 and #230 is in. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…while the bump was being applied, so an `OSError` from the leg's checkout itself is no longer described as happening after the leg was moved (#253) The refusal said "after the leg was moved", which is false for an `OSError` raised by the checkout that moves it (a `git` that cannot be started). The rollback it follows is unchanged; only the sentence is. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-2, 2026-10-11T22:39:49Z) @codex review |
|
Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days. You can request another review in 1 day and 1 hour by commenting |
Reviewer's GuideThe PR hardens Sequence diagram for bump-leg failure rollbacksequenceDiagram
participant Apply as _apply_rewrite
participant Rollback as _roll_back
participant File as _restore_file
participant Leg as leg checkout
participant Git as git index
participant Report as Refusal or original failure
Apply->>Apply: write pin and workflow files
Apply->>Leg: restore_leg(submodule, placement, was)
Apply->>Git: run(git reset -q HEAD -- staged)
Apply-->>Rollback: failure after first byte moved
Rollback->>File: _restore_file(target, original)
Rollback->>Leg: restore_leg(submodule, placement, was)
Rollback->>Git: run(git reset -q HEAD -- staged)
Rollback-->>Apply: rolled_back
alt OSError or UnicodeError
Apply->>Report: raise Refusal(bump-leg-rewrite-failed)
else other BaseException
Apply->>Report: re-raise failure
end
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a8d31ee1b5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| run(["git", "reset", "-q", "HEAD", "--", *staged], cwd=root) | ||
| except CommandFailed as undo: | ||
| print(undo.loudly("rolling the bump back"), file=sys.stderr) | ||
| except BaseException as failure: |
There was a problem hiding this comment.
Extend rollback through commit creation
When validation succeeds but commit_once() subsequently fails—for example because a commit hook rejects the commit or .git becomes unwritable—_apply_rewrite has already returned, so this handler cannot run; the rewritten pin, workflows, and gitlink remain staged and the leg stays detached. The new guarantee that no later failure leaves a half-applied bump therefore excludes a routine final write step; keep commit creation inside the same rollback transaction.
AGENTS.md reference: AGENTS.md:L300-L303
Useful? React with 👍 / 👎.
| rolled_back = _roll_back(root, submodule, placement, was, staged, | ||
| written, moved_leg) | ||
| if isinstance(failure, (OSError, UnicodeError)): |
There was a problem hiding this comment.
Honor failed rollback status for validator refusals
When a validator goes red and an undo step then fails (for example, restore_leg raises an OSError), _roll_back returns False, but that result only affects failures that were originally OSError or UnicodeError. A validator failure is a Refusal, so it is re-raised with the existing claim that every byte was restored and the tree is exact, despite the preceding rollback error and the still-moved leg; report the incomplete rollback for these failures too so the operator knows to inspect the tree.
AGENTS.md reference: AGENTS.md:L322-L325
Useful? React with 👍 / 👎.
|
(lane openRepoShape-2, 2026-10-11T23:47:11Z) This lane reviewed its own PR #264 at a8d31ee, before any fix round, read-only in its own detached worktrees, and authored nothing in this pass. CHANGES NEEDED. There is no sequencing constraint. Findings, file:line at a8d31ee:
Each finding is the same on main. Head fixes the issue's read-only reproduction. Tests:
Merges: clean (rc 0) against main (c3f4987 and 60588d5), #222, 5757e29 and #266 to #270. The full report is |
…then failed, and a second interrupt during the rollback are all put back, and no report claims a rollback that was not complete, so a signing key that cannot sign no longer leaves the bump staged for the re-run to refuse as a dirty root (#253) Lane openRepoShape-2's review of a8d31ee found the rollback stopped short of the PR's own promise in four places, and one report said too little: - The commit ran after `_apply_rewrite` returned, outside the rollback. A commit git refuses (`commit.gpgsign` with a signer that fails, a `commit-msg` hook) left the pin, the workflows and the gitlink staged and the leg on the new commit, and the same command run again once the signer was fixed was refused as `bump-leg-root-dirty`. The commit is now the last statement inside the `try`. One `git rev-parse HEAD` is read just before it, and `_head_guard` compares the root's HEAD with it after any failure: unmoved, every byte goes back; moved (or unreadable), the bump is a commit, nothing is rolled back under it, and the report says it was committed. - A `bump-leg-validators-red` refusal said every byte was rolled back even when a rollback step had failed. The sentence is now one constant, `ROLLED_BACK`, and the handler replaces it with `NOT_ROLLED_BACK` (THE ROLLBACK WAS NOT COMPLETE) when the rollback did not earn it, in every `Refusal`; a `CommandFailed` gets the same sentence on a line before `main()` prints it. - `_put_back` caught `Exception` only, so a second Ctrl-C during the leg's checkout skipped the index step and left `MM` on the pin, the workflow and the gitlink. It now catches `KeyboardInterrupt` too, names the step as interrupted, and the steps after it still run. No signal handler: this is a library function. - The leg was restored only if the forward checkout returned, but git moves HEAD before `post-checkout` runs, so a failing hook (git-lfs's without `git-lfs` on PATH) or an interrupt while the checkout was being waited on left the leg on the new commit. `_restore_leg_if_moved` reads the leg's branch and HEAD and restores only when either differs, so an unmoved leg is not checked out again through the hook that refused. - An interrupt or a defect was re-raised with nothing saying whether the tree was back. One stderr line now says so before the traceback. tests/test_bump_leg_rollback.py: a refused commit (a signer that does not exist, and a `commit-msg` hook), a failure after the commit landed (an interrupt, and a refusal), a forward checkout that moved the leg (an interrupt, and a `post-checkout` hook that refuses the new commit only), an interrupt inside a rollback step, a `git add` that fails beside a failing rollback step, and the line before an interrupt's or a defect's traceback. Twelve cases fail on a8d31ee's script, each for its finding's reason. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-2, 2026-10-12T00:31:43Z) This lane re-verified its own PR #264 at 67a1163 after fix round 1, read-only in its own detached worktrees. CHANGES NEEDED Items, re-run with the first pass's real-git reproductions:
New P2. A terminal Ctrl-C cutting off the forward checkout before git moves HEAD is reported as "the tree is exactly as it was", yet the leg holds 851 files the checkout wrote, and the re-run fails with Nits. Three mutants survive (unreadable HEAD, leg compared by HEAD only, Tests:
Merges: rc 0 against origin/main b30595c, 5757e29 and #281's 754803d. The full report is |
…e read before the checkout, so a checkout cut off before it moved the leg is reported as THE ROLLBACK WAS NOT COMPLETE, not as a tree exactly as it was (#253) Re-verification 1 of #264 found it with a real Ctrl-C. git writes the new commit's files before it moves the leg's index and HEAD, and a terminal's SIGINT reaches git and this tool together, so a large forward checkout cut off between the two leaves the leg on its branch at its commit with the files it wrote in it. `_restore_leg_if_moved` read only the branch and the HEAD, found the leg where it stood, and `_roll_back` returned True: the report said every byte was back and the tree was exactly as it was, while the leg held 851 files of the cut-off checkout, and the operator's re-run failed on "untracked working tree files would be overwritten". Main prints only the traceback there, so the false claim was new in fix round 1. `_apply_rewrite` now reads the leg's `git status --porcelain -z --untracked-files=all` as bytes, next to `placement` and before the checkout. `_restore_leg_if_moved` restores the leg when its branch or HEAD moved, as before, and then compares the leg's status with that one. When it differs the step raises `LegNotAsFound`, `_put_back` names the step with a sentence sending the operator to `git -C <leg> status`, and the verdict is THE ROLLBACK WAS NOT COMPLETE. The comparison runs after a restore as well, so a checkout that moved the leg and left a file the checkout back cannot remove is reported the same way. Nothing in the leg is removed. An untracked file of the operator's own is allowed there (`_ensure_root_clean` ignores untracked leg content), and removing only the paths that appeared and that the new commit's tree holds is not provably safe: git checkout overwrites an IGNORED file without asking, and a new `.gitignore` written before the cut can make that file show as untracked, so such a path could be a file the operator had. Tests: the cut-off checkout, interrupted and failing with exit 130, and a checkout that moved the leg, left a file and was interrupted. Each asserts the line says THE ROLLBACK WAS NOT COMPLETE, nothing says the tree is exactly as it was, the step is named, and the operator's own untracked file and the checkout's file are both still there. One more puts an operator's untracked file in the leg beside a red validator, and shows the rollback still says every byte is back and leaves the file alone. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r the commit, an `OSError` after it, and a leg detached at the commit it stood on, so the three rules re-verification 1 could break without a test failing are now held by one each (#253) Re-verification 1 of #264 ran 17 mutants of 67a1163 and three survived: - 9: `_head_guard` rolls back when HEAD cannot be read after the commit was asked for. `test_a_head_that_cannot_be_read_after_the_commit_rolls_nothing_back` makes every `git rev-parse HEAD` fail once the commit has been asked for (`commit_once`'s read-back and the guard's), as the reviewer's real `post-commit` reproduction did, and asserts the commit landed, the tree agrees with it, and the refusal says the commit could not be read. - 12: the leg compared by its HEAD alone. `test_a_leg_detached_at_the_commit_it_stood_on_is_put_back_on_its_branch` bumps `--to` the commit already pinned with a workflow reference left behind, so the forward checkout detaches the leg at the same commit; a red validator follows, and the leg must be back ON `main`. - 16: an `OSError` after a landed commit made into `bump-leg-rewrite-failed`, whose remediation says nothing was committed. `test_a_failure_after_the_commit_landed_rolls_nothing_back` gains an `oserror` case: the error is re-raised as itself after a line saying the bump was committed. The landed-commit assertions are one helper, shared by the guard's tests. No code changes. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-2, 2026-10-12T01:12:44Z) This lane re-verified its own PR #264 at 5269aac after fix round 2, read-only in its own detached worktrees. MERGEABLE WITH NITS, once tests-windows passes.
Tests:
CI: tests, macOS and SonarCloud pass; tests-windows was pending at 01:11Z. Round 1's Windows run passed. Merge with b421e6b: rc 0. The full report is |
|
…le-files The one conflicted file is scripts/bump-leg.py, in three hunks in `plan_workflows`, all against #222's tracked-files change (#207): the helper block keeps main's `_tracked_workflows` and this branch's `_not_utf8`, `_leg_pin_text` and `_workflow_texts`; the docstring keeps both paragraphs; the loop head reads `tracked`, `untracked`, then `for path, text in _workflow_texts(root, workflows)`, main's own `text = path.read_text(...)` line going with the walk it belonged to. The net diff against main is scripts/bump-leg.py and tests/test_bump_leg_unreadable.py, nothing else. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…, #253 via #264, #302 via #318, #288 via #309, #279 via #310, #306 via #311, #334 via #336, #239 via #269, #243 via #268, #321 via #335) into fix/adopt-record-submodule-placement Brings in the twelve commits from c7c02fa to a1b3a4b on top of 71b58a1, merged by sha. No rebase. Among the files this branch changes: 346079d (#216) and e43fa95 (#243) change adopt-project.py; 34ecbc2 (#260) changes scripts/shape_materialize.py; 87373a6 (#306) changes shape-doctor.py and tests/test_shape_doctor.py; 7491530 (#288) changes AGENTS.md and tests/test_repo_hygiene.py. Two conflicts, each resolved keeping both sides: - adopt-project.py, the module docstring's `execute` paragraph. #243 ends "the root's only submodules are the two leg mounts, each at the commit pushed to its leg."; this branch follows "two leg mounts." with "The split's `project.yaml` records the leg each source submodule kept in a leg was answered into, under `submodules:`, for `shape-doctor.py` to check it against." The merge has #243's sentence, then this branch's, re-wrapped to the paragraph's width. The module docstring is not in `--help` (docs/cli.md holds neither sentence). - tests/test_repo_hygiene.py, `test_agents_md_is_short_enough_to_be_read`. Each side moved the AGENTS.md cap from 464 and wrote a paragraph saying why: #288 to 477, for thirteen lines, and this branch to 472, for eight. AGENTS.md itself merged cleanly and holds both: 485 lines, main's 477 plus this branch's +9 -1. The merge keeps #288's paragraph word for word, then this branch's, renumbered "2026-10-12: 477 -> 485" because it now follows #288's, and asserts `<= 485`. `git merge-tree --write-tree` of the two parents gives the same tree in every file but these two. Checked: - adopt-project.py: `_commit_the_split` was changed by both sides, in hunks that do not touch. #243 clones the assembly with `--template=`; this branch builds `append` with the `submodules:` record. The merged function holds both, and each side's text is unchanged. Every other definition one side changed is that side's text. - shape-doctor.py: #306 changes `validator_row` and `run_validator`. This branch's placement functions, F1's `gitmodules_the_record_answers` among them, are this branch's text. - No top-level name is bound twice in adopt-project.py, scaffold-project.py, scripts/repo_shape.py, scripts/shape_materialize.py, shape-doctor.py, validate-manifest.py, update-shape.py, scripts/family.py or scripts/bump-leg.py. Each merged module's names are the base's plus what either side added. - AGENTS.md is main's plus exactly this branch's changed lines. - The branch's diff against main is #189 alone: 12 files, +1694 -157. That is +1670 at 27d039b, plus F1's fix and test (+23), plus the one line the docstring re-wrap adds. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… of main's commits merge with no conflict, among them #243's template-less clone of the assembly and #216's literal `git grep` in adopt-project.py, and #260's one-pass `render` in shape_materialize.py origin/main moved 71b58a1 -> 8cdca5e. It was merged by sha, as the coordinator asked for e43fa95 or later: - c7c02fa (#299 via #315), scripts/family.py; - 346079d (#216 via #228), adopt-project.py: `plan`'s follow-up `git grep`; - 34ecbc2 (#260 via #316), scripts/shape_materialize.py: `render` substitutes in one pass; - 86da808 (#253 via #264), scripts/bump-leg.py; - 6f4ce47 (#302 via #318), scripts/family.py; - 7491530 (#288 via #309), AGENTS.md, README, docs and scaffold-project.py's --help; - 7591455 (#279 via #310), tests/conftest.py: `git()` takes its default identity as `-c` config; - 87373a6 (#306 via #311), shape-doctor.py; - 8a14ac9 (#334 via #336), tests/test_update_shape_landing.py; - c1b6732 (#239 via #269), update-shape.py; - e43fa95 (#243 via #268), adopt-project.py: the assembly root is cloned with `--template=`, and the verification refuses a mount whose gitlink is not the commit pushed to its leg (`adopt-mount-commit`); - a1b3a4b (#321 via #335), tests/test_git_output_decoding.py; - 8cdca5e (#295 via #312), the template's scripts/validate-pins.py. `git merge-tree` and this merge give the same tree, 07e6b49. adopt-project.py, scripts/shape_materialize.py and tests/test_adopt_ignored_shape_files.py auto-merge. In the last of these main's 346079d and this branch's 86a5e30 change different cases. Checked on the merged tree: - AST (fin183g/names.py, base 71b58a1): no top-level name is bound twice in adopt-project.py, scaffold-project.py, scripts/repo_shape.py or scripts/shape_materialize.py, and none is loaded but bound nowhere. Main changed the signature of no function this branch calls. - Every sentence 86a5e30 changed is still there: in `_through`, in `_link_parts`, in `_verify`'s `_resolve` (which e43fa95's `adopt-mount-commit` leaves as it was), and in the three test files. - `_commit_the_split` now clones the assembly with `--template=`. The next commit, F3, reads what that clone holds in its own `.git`, which is why F3 comes after this merge. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Lane: openreposhape-2 (openRepoShape-2)
Claim: #253 (comment)
What
scripts/bump-leg.py's_apply_rewritenow rolls every byte back on ANY failure once the leg has been asked to move, up to and including the commit, and then reports the failure. No report claims more than the rollback did, as far as git can show it. The leg is judged by its branch, its HEAD and itsgit status, so a report cannot vouch for whatgit statusdoes not show, a file git ignores above all (see "Left out").except BaseException, so an interrupt is covered as well as anOSErroror aUnicodeError.try, andcmd_bumpbuilds the message first and hands it in. A commit git refuses (a signing key that cannot sign, acommit-msghook) is therefore put back like any other failure.git rev-parse HEADis read just before the commit. After any failure,_head_guardcompares the root's HEAD with it:rev-parsethat reads the new HEAD back): nothing is rolled back under the commit, and the report saysThe bump was committed as <sha>;try._roll_backputs back, in the order it always did, every file written, the leg's checkout and the index._put_backattempts each of those steps whatever the steps before it did. It prints, never raises, a step that fails. A second Ctrl-C names its step asinterrupted, and the steps after it still run; no signal handler is installed._restore_filewrites a file back only when it no longer holds its original bytes, so a read-only file whose write never opened it is not reported as unrestorable._restore_leg_if_movedreads the leg's branch and HEAD and restores the leg when either differs from where it stood. A forward checkout that moved the leg and then failed (apost-checkouthook) or was interrupted is put back, and a leg that never moved is not checked out again.git status --porcelain -z --untracked-files=all, read as bytes before the checkout, with the leg's status now. git writes the new commit's files before it moves the leg's index and HEAD, so a checkout cut off between the two leaves the leg on its branch at its commit with those files in it. When the status differs, the step fails._put_backnames it and sends the operator togit -C <leg> status, and the report saysTHE ROLLBACK WAS NOT COMPLETE.ROLLED_BACK),THE ROLLBACK WAS NOT COMPLETE(NOT_ROLLED_BACK), or the commit landed. Where that goes depends on the failure:Refusalcarries it in its own text, andbump-leg-validators-red's sentence is replaced when the rollback did not earn it;OSErroror aUnicodeErrorthat nothing committed leaves asREFUSED bump-leg-rewrite-failednaming the exception (exit 2);CommandFailedleaves asmain()always printed it (REFUSED advancing a leg, exit 2), after one stderr line saying what became of the tree;main(). One bullet in the module docstring's refusal list. No--helptext changes (render-cli-reference.py --checkok), andmain()is not touched.Why
_apply_rewrite's docstring promised "or roll every byte back and re-raise", but its handler wasexcept (Refusal, CommandFailed), andmain()catches the same two. So aPermissionErroron a workflow file, aUnicodeErrorfrom echoing a validator's output, or an interrupt ended in a traceback, with the pin rewritten, the leg on the new commit and the index changed, and no finding about any of it.Round 1 reproduced this on main 39ac59c with the issue's own steps:
The same script, re-run in fix round 1 with a signing case added (
commit.gpgsign trueand agpg.programthat does not exist), at the reviewed head and at fix round 1's head 67a1163:At 67a1163 the signing run's stderr opens with
The command below failed while the bump was being applied. Every byte this command wrote has been rolled back — the pin, the workflow files, the leg's checkout and the index — and the tree is exactly as it was.The same command run again once the signer is fixed exits 0 and commits. At a8d31ee that second run was refused asbump-leg-root-dirty.The review measured the decode run on main c3f4987, with #230 and #214 landed: it no longer fails at all there. The handler still covers what the echo can still raise (an
OSErrorsuch asEPIPEfromsys.stdout.write).The issue also notes the rollback's own writes need a guard.
written[target]is set beforewrite_lf, so for the read-only file the restore would meet the samePermissionErrorover the original failure._restore_fileand_put_backare that guard. A test has the leg's own restore fail, and shows the files and the index are still put back.Decision for the coordinator: a clean
REFUSED(exit 2) forOSErrorandUnicodeError, and the traceback for everything else, after a line saying whether the tree is back.update-shape.py_rewrite_readmeturns the write'sOSErrorintoupdate-readme-unwritable("A FAILED WRITE IS A REFUSAL, NEVER A TRACEBACK"), so the one rollback arm that exists puts every byte back.family.py bumphas no rollback and lets such an error trace back, which is the weaker precedent.bump-leg-validators-reddoes"._apply_rewrite, after the rollback, not inmain(), because only there is the claim "every byte rolled back" known to be true or false.main()catchingOSErrorwould also dress up a failure that came before the first write.main()'sREFUSED advancing a leg, after the same line.Decision: a HEAD that cannot be read after the commit was asked for counts as landed. Rolling the files and the leg back under a commit that did land would leave a tree that contradicts the commit, and that is the worse of the two mistakes.
Tests
New
tests/test_bump_leg_rollback.py: 24 cases in 13 functions. Each case compares what the bump could change with what it was before the bump: the root's HEAD, the pin's bytes, every workflow's bytes, the gitlink,git diff --cached --quiet,git status, the leg's HEAD and the branch the leg stood on. The leg is put ON its branch first, asbootstrap.pyleaves it. There are three exceptions:In the four cases where the leg's own restore is made to fail, the leg cannot be put back and
git statusshows it. Those cases compare the root's HEAD, the pin, every workflow, the gitlink andgit diff --cached --quiet.In the three cases where the forward checkout leaves a file in the leg,
git statusshows that file, because nothing removes it. Those cases compare the same five, plus the leg's HEAD and its branch.The commit-landed cases assert that the commit and the tree agree.
A read-only tracked workflow file, with
bump-leg.pyrun as a subprocess (the issue's reproduction). Exit 2,bump-leg-rewrite-failed,PermissionError, and no traceback. There is no "rolling the bump back" noise for a file never opened. (1 case; skipped where the account can write a read-only file, i.e. root.)A write that dies half way (
ENOSPCafter the file was truncated and 20 bytes written): exit 2. (1)A validator's output that cannot be read (
UnicodeDecodeErrorfromrun_validator), and one the stdout cannot echo (a stdout whosewriteraisesUnicodeEncodeErrorfor U+FFFD): exit 2. (2)An interrupt while writing the second file (with the pin already rewritten), an interrupt in the validators (with the index staged), and a
RuntimeErrorin the validators. Each is re-raised as itself, the tree is back, and stderr says<Exception> while the bump was being appliedand that every byte is back. (3)The leg's own restore failing, beside a red validator, an unwritable file, or a
git addthat fails. The original failure is the one reported, and the failed step is named. The files and the index are still put back. Each report saysTHE ROLLBACK WAS NOT COMPLETE, and none says the tree is exactly as it was. (3)A second interrupt inside the rollback's leg step. The step is named
interrupted, the index step after it still runs, and the refusal says the rollback was not complete. (1)A forward checkout that moved the leg and then failed: an interrupt after the real checkout, and a leg
post-checkouthook that exits 1 for the new commit only. The leg is back on its branch. (2)A forward checkout that left files in the leg. Two are cut off before the leg moved: one is interrupted, and one fails with exit 130. Each writes a file and leaves HEAD where it was. The third moves the leg, leaves a file the checkout back cannot remove, and is interrupted. In each, the line before the re-raise or the refusal says
THE ROLLBACK WAS NOT COMPLETE, nothing says the tree is exactly as it was, and the leg step is named. The operator's own untracked file and the checkout's file are both still there. (3)An untracked file of the operator's own in the leg, beside a red validator. The report still says every byte is back, and the file is untouched. (1)
--tothe commit already pinned, with a workflow reference a hand-made bump left behind. The forward checkout detaches the leg at the commit it stood on, and a red validator follows. The leg is back ONmain. (1)A commit git refuses: a
gpg.programthat does not exist, and acommit-msghook. Exit 2, the refusal namesgit commit, the tree is back, and stderr says every byte is back. (2)A failure after the commit landed: an interrupt, a
Refusalfrom reading HEAD back, and anOSError. Nothing is rolled back, the tree agrees with the new commit, and the report sayscommitted as <sha>. TheOSErroris re-raised as itself, not made intobump-leg-rewrite-failed, whose remediation says nothing was committed. (3)A HEAD that cannot be read once the commit was asked for. Every
git rev-parse HEADthen fails,commit_once's and the guard's. Nothing is rolled back, the tree agrees with the commit that landed, and the refusal says whether the commit landed could not be read. (1)The echo test uses a stream that raises, not
PYTHONIOENCODING=cp1252. #214'sescape_what_stdout_cannot_encode, now on main, reconfigures that exact stream tobackslashreplaceat the top ofmain(), so a test built on it would stop meaning anything. The hook tests setcore.hooksPathin the repository's own config to a#!/bin/shscript, astests/test_update_shape_add_ignored.pydoes. Git for Windows runs such a script through its ownsh, and tests-windows passed them at 67a1163 (00:31Z).Run against main's code, the new tests fail for the issue's reasons and the review's:
scripts/bump-leg.py(bump-leg.py and update-shape.py hand the project's own validators an explicit PYTHONIOENCODING, so a real bump orapplyon a project named日本is no longer refused asbump-leg-validators-redorupdate-validators-redunder a cp1252 stdout (#254) #281 for bump-leg.py (a real bump) and update-shape.pyapplyrun the project's own validators as children that cannot print its display name on a cp1252 stdout, so a project named日本is refused and rolled back asbump-leg-validators-redorupdate-validators-redunderPYTHONIOENCODING=cp1252:surrogateescape, and the refusal blames the validators #254 included): 22 of 24 fail. The same 22 fail on ede15ce's, from before bump-leg.py and update-shape.py hand the project's own validators an explicit PYTHONIOENCODING, so a real bump orapplyon a project named日本is no longer refused asbump-leg-validators-redorupdate-validators-redunder a cp1252 stdout (#254) #281 landed.PermissionErrortraceback.ENOSPC, decode, echo and unwritable cases have theirOSError,UnicodeDecodeError,UnicodeEncodeErrororPermissionErrorescapemain().validators-redandcommand-failedrollback-step cases have the leg'sOSErrorescapemain()over the original failure.pin is not as it was found.status is not as it was found(M spec).REFUSED advancing a leg.pin is not as it was found.committed asline, and the unreadable HEAD has nocould not be read.Targeted, at head 5269aac (all passed):
scripts/render-cli-reference.py --checkok._restore_leg_if_movedre-spelled for its new shape, and three of this round's own:moved-then-interrupted);_put_backhas no clause of its own for the status step (3 failed).OSErrorafter a landed commit made intobump-leg-rewrite-failed).Composes with
This branch was cut from 39ac59c. Main is now b421e6b. Four changes to
scripts/bump-leg.pyhave landed there since the cut: #246 for #214, #230 for #217, #222 for #207 (018ce0d), and #281 for #254 (b421e6b, 00:55Z).git merge-tree --write-tree <this head> b421e6b(current main, with bump-leg.py and update-shape.py hand the project's own validators an explicit PYTHONIOENCODING, so a real bump orapplyon a project named日本is no longer refused asbump-leg-validators-redorupdate-validators-redunder a cp1252 stdout (#254) #281): rc 0, no conflicting files. In a scratch checkout of that merge, 124 passed:test_bump_leg.py33,test_bump_leg_rollback.py24,test_validator_child_encoding.py(bump-leg.py and update-shape.py hand the project's own validators an explicit PYTHONIOENCODING, so a real bump orapplyon a project named日本is no longer refused asbump-leg-validators-redorupdate-validators-redunder a cp1252 stdout (#254) #281's) 51,test_git_output_decoding.py3 andtest_cli_reference.py13.render-cli-reference.py --checkok.git merge-tree --write-tree <this head> ede15ce(main just before bump-leg.py and update-shape.py hand the project's own validators an explicit PYTHONIOENCODING, so a real bump orapplyon a project named日本is no longer refused asbump-leg-validators-redorupdate-validators-redunder a cp1252 stdout (#254) #281 landed): rc 0, no conflicting files. In a scratch checkout of that merge, 120 passed and 1 skipped (a Windows-only default intest_stdout_encoding.py):git addafter the rewrite: both are refused by name asbump-leg-workflow-untracked, before anything is written and under--dry-runas well (#207) #222's 10 new tests)render-cli-reference.py --checkwas ok there too. Main'stest_no_shipped_text_mode_call_decodes_with_the_locale_codecpasses on that tree, because this branch adds no text-modesubprocesscall: the three new git reads (the root's HEAD, the leg's HEAD and the leg's status) usegit_out, which reads bytes.git merge-tree --write-tree <this head> 6d8d88b(main when fix round 2 began): rc 0.git merge-tree --write-tree <this head> 754803d(bump-leg.py and update-shape.py hand the project's own validators an explicit PYTHONIOENCODING, so a real bump orapplyon a project named日本is no longer refused asbump-leg-validators-redorupdate-validators-redunder a cp1252 stdout (#254) #281's head, now landed as b421e6b): rc 0, no conflicting files. That branch changesrun_validator, the import line, a fewsubprocess.rundecodings (commit_once's among them) and the first line ofmain(). This one changes_apply_rewrite, its helpers andcmd_bump's tail.git merge-tree --write-tree <this head> 7c41d98(bump-leg.py rewrites and commits only the workflow files git tracks, so an untracked draft workflow that names the leg is no longer added whole to the lockstep commit and an ignored one no longer dies atgit addafter the rewrite: both are refused by name asbump-leg-workflow-untracked, before anything is written and under--dry-runas well (#207) #222's head before it landed): rc 0, no conflicting files. Its newRefusalis raised fromplan_workflows, before_apply_rewrite, so nothing is written and nothing needs rolling back.The review's scan of 28 open heads at a8d31ee found only those two touching
scripts/bump-leg.pyor the bump-leg tests. It was not repeated this round.Left out
README.mdanddocs/handbook.htmlsay a red validator rolls the tree back and that a refusal leaves the tree as it was found. That is now true for a refused commit as well. In the two corners where it is not true, the refusal says THE ROLLBACK WAS NOT COMPLETE instead: a rollback step itself fails, or a cut-off checkout left files in the leg. bump-leg.py rewrites and commits only the workflow files git tracks, so an untracked draft workflow that names the leg is no longer added whole to the lockstep commit and an ignored one no longer dies atgit addafter the rewrite: both are refused by name asbump-leg-workflow-untracked, before anything is written and under--dry-runas well (#207) #222, now on main, edited the same README paragraph, so it is left alone here.untracked working tree files would be overwritten). That re-run wrote nothing, so its own line, that every byte it wrote is back, is true. Removing only the paths that appeared and that the new commit's tree holds was considered and left out, because it is not provably safe.git checkoutoverwrites an IGNORED file without asking, and a new.gitignorewritten before the cut can make that file show as untracked, so such a path could be a file the operator had.git statusdoes not list ignored files. A cut-off checkout that wrote only paths the leg's.gitignorematches therefore leaves files that no report names. git overwrites ignored files without asking, so they do not block the re-run. Listing ignored files (--ignored) would read every build output in the leg twice per bump, so it is not done.post-checkouthook refuses the restoring checkout too, that step is reported as not put back, even when git did move the leg back. The report then says THE ROLLBACK WAS NOT COMPLETE and sends the operator togit status. This is conservative, not wrong. Re-verification 1 suggested re-reading the branch and HEAD after such a failure, and counting the step done when they match (its nit B). That is declined: a hook that failed can leave work undone that neither the HEAD norgit statusshows. git-lfs'spost-checkout, for one, sets the read-only bit on lockable files, and git does not track that bit._put_backis not caught, for example between two rollback steps or while the handler itself prints. By design, no signal handler is installed from a library function.KeyboardInterruptis not turned into a refusal. The operator gets one line saying whether the tree is back, then the interpreter's own traceback and exit status.update-shape.py applystill handlesexcept (Refusal, CommandFailed)incmd_apply(at ede15ce). Whether anOSErroror an interrupt there leaves a half-applied re-pin was not probed here. It is a follow-up for a lane that wants it._apply_rewriterolls back only onRefusalandCommandFailed, so aPermissionErroron a workflow file it rewrites, or theUnicodeEncodeErrorthat #230'serrors="replace"moves the decode crash to, leaves the pin rewritten and the leg on the new commit, with a traceback and no rollback #253's rollback promise, and the coordinator files it.Review
Lane openRepoShape-2's own adversarial review of a8d31ee found CHANGES NEEDED: #264 (comment). Fix round 1 below answers it. The lane's re-verification of fix round 1's head 67a1163 found CHANGES NEEDED again: #264 (comment). Fix round 2 below answers that. A review by another lane is welcome.
Fix round 1
This round answers #264 (comment). Every item is in commit 67a1163, and every test named failed at a8d31ee and passes at 67a1163.
try, under the HEAD guard. Tests:test_a_commit_git_refuses_puts_every_byte_back, andtest_a_failure_after_the_commit_landed_rolls_nothing_backfor the guard.Refusal, and a failed git command gets a line saying the same. Test:test_a_rollback_step_that_fails_is_named_and_the_others_still_run, which now has 3 cases and an unconditional assertion.test_an_interrupt_inside_a_rollback_step_is_named_and_the_steps_after_it_still_run.test_a_forward_checkout_that_moved_the_leg_and_then_failed_puts_it_back.test_any_other_failure_puts_every_byte_back_and_is_re_raised_as_itself, which now asserts it.Fix round 2
This round answers #264 (comment).
The leg's
git statusis read before the checkout and compared in the rollback, after any restore. When it differs, the leg step is named, the operator is sent togit -C <leg> status, and the report says THE ROLLBACK WAS NOT COMPLETE. Nothing in the leg is removed.Tests:
test_a_forward_checkout_that_left_files_in_the_leg_is_not_called_put_back(3 cases, all failing at 67a1163 on a line that says every byte is back), andtest_an_untracked_file_the_operator_keeps_in_the_leg_is_left_alone_and_not_counted.The re-verification's real-SIGINT reproduction (
partial.sh: a 40000-file leg commit, SIGINT to the whole process group 0.15 s after the checkout starts), re-run at 5269aac:At 67a1163 both runs said every byte was back and the tree was exactly as it was.
test_a_head_that_cannot_be_read_after_the_commit_rolls_nothing_back.test_a_leg_detached_at_the_commit_it_stood_on_is_put_back_on_its_branch.oserrorcase oftest_a_failure_after_the_commit_landed_rolls_nothing_back.git statusand the one corner it cannot see. "A leg that never moved is not checked out again" is followed by the comparison that is still made. Main is b421e6b, which holds bump-leg.py and update-shape.py hand the project's own validators an explicit PYTHONIOENCODING, so a real bump orapplyon a project named日本is no longer refused asbump-leg-validators-redorupdate-validators-redunder a cp1252 stdout (#254) #281's head 754803d.Closes #253
🤖 Generated with Claude Code
Summary by Sourcery
Make bump-leg restore all changes after any pre-commit failure and accurately report the resulting repository state.
Bug Fixes:
Enhancements:
Documentation:
Tests: