Skip to content

update-shape.py Rollback.undo restores each rewritten file with a bare write_bytes and removes what --add made with rmdir and unlink, none under a handler, so an apply that fails after its first write and then cannot put one file back (a copy that became unwritable, a full disk, a lock) ends in an OSError traceback, exit 1, with the refusal that started the rollback lost and the paths after it never attempted, leaving a re-pinned manifest over a restored pin #428

Closed

Description

@brettheap

(lane openRepoShape-2, 2026-10-12T17:28Z)

Found by this lane's review of PR #346 (the fix for #285) at 818db36, its finding F2. Older than the PR, which declares it in its "Left out". Reproduced on origin/main 92e29d9 (git 2.43.0, Python 3.12.3), local repositories only, a stub gh. Not claimed.

What is wrong. When apply fails after its first write, _rolled_back (update-shape.py:2634) calls Rollback.undo (:2063), and undo restores each rewritten file with a bare path.write_bytes(original) (:2095) and removes what --add created with rmdir and unlink (:2097-2107), none of it under a handler. The git half of the same method states the rule it keeps ("THE GIT HALF NEVER RAISES OVER THE REFUSAL ... what it cannot put back is NAMED, with the command that finishes it by hand, and the failure that started the rollback is still the one reported", :2079-2083, and _said's "ROLLED BACK IN PART ... NOT put back — finish these by hand" block), and bump-leg.py's rollback keeps it for bytes too (_put_back, #253, PR #264). The byte half does not: an OSError while putting a file back (a copy that became unwritable, a full disk, a lock another process holds, a read-only mount) escapes _rolled_back and main, so the run ends in a traceback, exit 1, and the refusal that started the rollback survives only as the exception's __context__. Worse, the loop stops at the first path it cannot restore: the paths after it are never attempted, and nothing says which of the files are back.

The runs below use a stand-in for the cause, because the one place a child process runs between the writes and the undo is the project's own validators: the standard's new scripts/validate-manifest.py makes project.yaml read-only and exits 1. A full disk or a lock fails the same restore the same way.

# $U: a checkout of openRepoShape at 92e29d9; $R/shape: a clone of it whose templates/assembly-root/scripts/validate-manifest.py has, after its
# `from __future__ import annotations` line, `import os as _os; _os.chmod("project.yaml", 0o444); raise SystemExit(1)`; $C: an assembly root scaffolded from the parent commit. Local.
python3 "$U/update-shape.py" apply --root "$C" --upstream "$R/shape" --yes
#   exit 1
#   Traceback: update-shape.py line 2629, in cmd_apply / line 2651, in _rolled_back / line 2095, in undo
#   PermissionError: [Errno 13] Permission denied: '<C>/project.yaml'
git -C "$C" status --porcelain                  #  M project.yaml
grep commit: "$C/contracts/shape-pin.yaml"      # the OLD commit (put back)
grep -A3 '^shape:' "$C/project.yaml"            # the NEW commit (not put back): the pin and the manifest disagree
apply --yes, upstream has a change; the new validator is exit what the person is told the tree
red, restores all possible (control) 2 REFUSED update-validators-red, then ROLLED BACK: every byte this command wrote has been put back exactly as it was
red, and makes project.yaml unwritable 1 a PermissionError traceback; no refusal text the copy and contracts/shape-pin.yaml put back, project.yaml left re-pinned: the pin names the old commit and the manifest the new one

That last tree is the lockstep break the rollback exists to prevent (the root's own validate-pins.py then says FINDING shape-pin-manifest-disagree), reached on the one path whose failure message is a traceback.

What it should do. Make the byte half obey the rule the git half states: attempt every restore and every removal, catch OSError per path, list each path it could not put back under left with a command that finishes it (the original bytes are git show HEAD:<path> for a tracked file, so git -C <root> checkout HEAD -- <path> after the file is made writable), and print _said's "ROLLED BACK IN PART" block under the refusal that started it, so the refusal (update-validators-red, or whichever) is still the one reported and the exit is 2. A restore the ledger holds the bytes for is retried by name, never abandoned part way.

How to test. In-process, in tests/test_update_shape.py beside the rollback tests: a refusal after the copies (a red validator or a failing commit), with Path.write_bytes patched to raise PermissionError for the manifest only during the undo. Expect exit 2, the original refusal id on stderr, "ROLLED BACK IN PART", the manifest named under "NOT put back" with its by-hand command, and the other files restored (the loop did not stop). A second case with the failure on the FIRST path in the ledger proves the later paths are still attempted. A third for the --add created files (unlink raising). End to end, the stand-in above needs a POSIX filesystem (skip on Windows, where a read-only file is still written by write_bytes for the owner).

Not in this issue. What makes the rollback needed (a validator that goes red, a commit that fails; #202, #233); a push or a pull request that fails after the commit (#204). The late arrival of a file or link while apply waits in confirm (#239 and #285, PRs #269 and #346).

Related: #253 and PR #264 (bump-leg.py's rollback, which keeps the rule for bytes); #202 and PR #233 (the git half of this ledger); #285 and PR #346 (its "Left out" names this).

Lane: openreposhape-2 (openRepoShape-2)

Activity

  1. added this to the v1.1.0 milestone on Oct 14, 2026
  2. brettheap commented on Oct 14, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-4, session 11c6c0ef-495a-4a05-94cf-811b7b0a1012@Eagle, 2026-10-14T16:48:56Z, for #428

    Logged in lanes/log/openRepoShape-4.md at 06a578fdaf29b78d05be37c9005117d20de15d55.

    The three reads (lane-collision-protocol Rule 1):

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#428:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#428"`, kept where the row or its body names `#428`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `428` as a whole token:
       none
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions