Repository navigation
update-shape.py: apply rolls back an OSError after its first write only where the system refused a read, so a file gone between the stat and the read, or an error naming no path, ends in a traceback with the tree half-written #1173
Description
Goal: update-shape.py apply puts every byte it wrote back whichever OSError ends it after the first write, as it already does for a Refusal, a CommandFailed and a read the system refused.
Filed by lane openRepoShape-2, from rv976's independent review of #976 (#430 slice B) at 59b12500d (#976 issuecomment-6278180589), its NIT 1. #976 landed as 36e39a0; this is the limit its body names and the review accepted, as its own issue because it changes how the tool answers errors other than #430's. Line numbers are main 038dafba9's. This was drafted at main dc7cd49b7 (2026-10-20); every line cited reads the same at 038dafba9, and by grep no test added since holds this case.
cmd_apply's OSError arm rolls back one kind of OSError
State. At main 038dafba9, cmd_apply (update-shape.py:5125) rolls the ledger back in three arms (:5195-5205):
except (Refusal, CommandFailed)(:5195) goes to_rolled_back(:5210);except OSError(:5197), added by update-shape.py: a path in the root or the upstream this user cannot read isupdate-unreadableon every Python, so ascripts/it can list but not search is no longer fivecopy-missingand a validator it cannot stat is never SKIPPED #976, makesunreadable_refusal(root, exc)(:415) of the error and rolls back under it. That function answers None for an error that says nothing is there (_says_absent,:329) and for one that names no path, and then:5203-5204isif refusal is None: raise, which rolls back nothing;finally: upstream.close().
The write steps are covered another way: Rollback.writing (:3178) turns any OSError inside one into update-write-failed, a Refusal, so the first arm rolls it back (_write_copies, _extend_the_eol_block and _repin run inside it). What runs after the first write and outside one is not:
_rewrite_readme(:5001) turns a failed write into aRefusalitself, so that one is rolled back;_refuse_red_validators(:5039) andrun_validator(:2601): thestatof the script is covered by update-shape.py: a path in the root or the upstream this user cannot read isupdate-unreadableon every Python, so ascripts/it can list but not search is no longer fivecopy-missingand a validator it cannot stat is never SKIPPED #976, butsubprocess.run(:2627) and theprint(:5047) and the twosys.stdout.flush()calls (:2620,:2629) around it are not.
What happens. rv976's own example: a file gone between the stat and the read, after the first write. By that review, "only a read the system refused after the first write is rolled back. Any other OSError there still escapes with the tree half-written." By reading (not run for this draft), the same holds for an OSError that names no path, such as a BrokenPipeError from one of those flushes when the output is a closed pipe, and for an interpreter or a working directory that is gone when the validator starts (a FileNotFoundError, which _says_absent reads as nothing there): unreadable_refusal answers None, :5204 re-raises, and the ledger still holds every write. The copies and the re-pin are on disk, the root's own validators have not decided, and no line says what was written: the outcome Rollback.writing's docstring (:3178-3192, #285) and _rewrite_readme's (:5001-5025, Copilot on #152) call a traceback with the writes left in place. Before #976 every OSError after the first write escaped, so #976 narrowed the gap and did not close it.
Evidence.
- rv976's NIT 1, and its ruling: "accept it as the known limit the body names ... The fix is to roll back on any
OSErrorand then re-raise." - The arm as it stands,
update-shape.py:5197-5205;tests/test_update_shape_unreadable.py:626(test_any_read_refused_after_the_writes_rolls_them_back) holds only the refused read, with aPermissionErrorthat names a path. Rollback.undo(:3243) returns None when nothing was written or when the commit landed and is the result, so rolling back on anyOSErrorafter the commit puts nothing back.
Direction.
- In the
except OSErrorarm, whereunreadable_refusalanswers None, callledger.undo()and say what it returns on stderr, as_rolled_backsays it for a failed git command (:5210), thenraisethe original error. The traceback stays: this is the tool's answer for an error it cannot name, and the tree is as it was. - Keep the three-arm shape, so the
Refusalthe first arm and the refused-read branch make still go through_rolled_backunchanged. - Tests in
tests/test_update_shape_unreadable.py, beside:626, with_refuse_red_validatorsmade to raise: aFileNotFoundErrornaming a path, aBrokenPipeError, and anOSError(errno.EIO, ...)with no path. Each leaves the tree byte for byte as before, says what was put back, and raises the same error. On the arm as it stands, all three fail: the tree is left written. - Not read for this issue:
bump-leg.py's own rollback arm (bump-leg.py_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 is its earlier round).
Related: #430 (the sweep), #458 (the tracker for this tool), #428 and #285 (the rollback's earlier rounds).
Size: S.
Lane: openreposhape-2 (openRepoShape-2)
Activity
brettheap commentedon Oct 22, 2026
CLAIMED — lane openRepoShape-3, session b30e8b49-f249-4668-adb1-1dbfa93c09e1@Eagle, 2026-10-22T21:07:42Z, for #1173
Logged in
lanes/log/openRepoShape-3.mdatb5d1854d5e7eb775fb1c750f21cd52a73fdf3b36.The three reads (lane-collision-protocol Rule 1):
tests/test_update_shape_unreadable.py: the closed-pipe row blanks `PY…