Repository navigation
update-shape.py: apply puts its REFUSED and ROLLED BACK report, and a failed push's account of the commit that stands, on stderr when nobody reads stdout any more, and a broken pipe on a copied line is rolled back and raised as itself, not called update-write-failed (#1218) - #1241
brettheap wants to merge 1 commit into
Conversation
… a failed push's account of the commit that stands, on stderr when nobody reads stdout any more, and a broken pipe on a `copied` line is rolled back and raised as itself, not called `update-write-failed` (#1218) `_rolled_back` and `landing_failed` flushed stdout bare before writing to stderr. Into a pipe whose reader had gone, the flush raised `BrokenPipeError` over the whole report: a read-only pin under `apply --yes | head -1` put the tree back and exited 120 with only a traceback on stderr. Both now flush through `_flush_stdout`, a `contextlib.suppress(OSError)` around the flush, as `adopt-project.py`'s does; `_roll_back_before_raising` (#1187) uses it in place of its own inline guard. `Rollback.writing` turned every `OSError` out of its block into `update-write-failed`, so under `PYTHONUNBUFFERED` a broken pipe on the `copied` print was refused as a failed write of the copy. A `BrokenPipeError` is let through as itself, to `cmd_apply`'s arm for an `OSError` no refusal can name, which puts every byte back and raises it (#1187, for #1173). A run whose stdout is read prints byte for byte as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideThe PR hardens Sequence diagram for apply failure reporting with closed stdoutsequenceDiagram
participant Apply as cmd_apply
participant Rollback as Rollback.writing
participant Stdout
participant Stderr
participant Flush as _flush_stdout
Apply->>Rollback: write copies and add files
Rollback->>Stdout: print copied line
Stdout-->>Rollback: BrokenPipeError
Rollback-->>Apply: re-raise BrokenPipeError
Apply->>Flush: _flush_stdout()
Flush->>Stdout: flush()
Stdout-->>Flush: suppressed OSError
Apply->>Stderr: print rollback report
Apply-->>Apply: raise BrokenPipeError
Sequence diagram for refused push reporting after commitsequenceDiagram
participant Apply as cmd_apply
participant Git
participant Stdout
participant Flush as _flush_stdout
participant Stderr
Apply->>Git: push_branch()
Git-->>Apply: CommandFailed
Apply->>Flush: _flush_stdout()
Flush->>Stdout: flush()
Stdout-->>Flush: suppressed OSError
Apply->>Stderr: print refusal and commit-stands report
Apply-->>Apply: raise LandingIncomplete
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
|
(lane openRepoShape-3, 2026-10-24T14:44:38Z) RV1241, independent review of #1241 at ab137eb: READY. Measured with my own probe at main 50869f3 and at the head (Python 3.12.3, local --upstream, stub gh), stdout a pipe whose reader has gone:
Tests at the head, in one pytest: the two PR files with test_update_shape.py, test_update_shape_header_comments.py and the three hygiene files: 507 passed, 2 skipped. SHOULD (body only): delete the "Summary by Sourcery" block after the robot line. Lane: openreposhape-3 (openRepoShape-3) |



What changed
A v1.2.1 follow-up from lane 3's independent reviews (#1218). It lands under Brett Heap's v1.2.1 merge word (2026-10-23T13:10:26Z) once reviewed and green.
update-shape.py applynow puts its failure report on stderr whatever stdout is. Its three parts are all in scope: #1187 has landed (8899c69b), socmd_apply'sOSErrorarm rolls back through_roll_back_before_raisingon main.Two flushes that raised over the report (
_rolled_back,landing_failed)BrokenPipeErrorover the whole report._rolled_back, the tree was put back, and stderr held a traceback instead of the REFUSED line and the ROLLED BACK list.landing_failed, stderr said neither which step failed nor that the commit stands._flush_stdout(): acontextlib.suppress(OSError)around the flush, asadopt-project.py's_flush_stdoutdoes.A broken pipe on a
copiedline was called a failed write (Rollback.writing)writingturned everyOSErrorout of its block intoupdate-write-failed.PYTHONUNBUFFERED, thecopiedprint inside that block breaks the pipe first, so the refusal blamed the write of a copy for an error of stdout's.OSErrorarm, aBrokenPipeErroris now raised again as itself. It reachescmd_apply's arm for anOSErrorno refusal can name (update-shape.py:applyputs every byte back under anyOSErrorafter its first write, says what it put back on stderr, and raises the error as it was, so a file gone between itsstatand its read, a broken pipe or anEIOno longer leaves the tree half-written behind a traceback (#1173) #1187), which puts every byte back, says so on stderr, and raises it.What I decided where the Direction left it open, and why
_roll_back_before_raising(update-shape.py:applyputs every byte back under anyOSErrorafter its first write, says what it put back on stderr, and raises the error as it was, so a file gone between itsstatand its read, a broken pipe or anEIOno longer leaves the tree half-written behind a traceback (#1173) #1187) had its own inline guard; it now calls_flush_stdout()too. One guard and one docstring now cover all three failure reports, and a mutant on the helper is killed by three rows (below).if isinstance(exc, BrokenPipeError): raiseinside the oneexcept OSErrorarm, rather than a separateexcept BrokenPipeError: raiseclause above it. Same behaviour, with one arm, and no clause whose only act is a bare re-raise.BrokenPipeErroris let throughwriting. Every write in that block is to a file of the root's, never a pipe: a pinned copy that is not a regular file, a FIFO included, iscopy-missing, refused before the first write. So a broken pipe out of it can only be stdout's. Any otherOSErrorthere is stillupdate-write-failed.landing_failedprints it to stdout, so a closed pipe has nowhere to take it whatever is done. The guard saves the stderr report, which is printed before the NEXT line.Tests (in
tests/test_update_shape_unreadable.py, beside #1187's pipe row, and intests/test_update_shape_messages.py, besidetest_the_bare_form_a_failed_push_prints_carries_the_words_too)test_a_refusal_after_the_writes_reaches_stderr_when_stdout_is_unread: runs the real tool, with a read-only pin and stdout a closed pipe, block-buffered.REFUSED update-write-failed: ...and the ROLLED BACK list, and the tree is as it was.needs_a_mode: a mode root writes through is skipped as root and on Windows.test_a_broken_pipe_at_a_copied_line_is_not_a_failed_write: runs the real tool withPYTHONUNBUFFERED=1and a closed pipe.update-write-failed. It is the ROLLED BACK list, then the traceback, which endsBrokenPipeError: [Errno 32] Broken pipe, and the tree is as it was.test_a_refused_push_is_reported_on_stderr_whatever_stdout_is: callspush_branchwith an origin that does not exist and a stdout whoseflushraises.LandingIncomplete.REFUSED pushing shape/x.and holdsthat commit stands and nothing was rolled back.applyputs every byte back under anyOSErrorafter its first write, says what it put back on stderr, and raises the error as it was, so a file gone between itsstatand its read, a broken pipe or anEIOno longer leaves the tree half-written behind a traceback (#1173) #1187'stest_a_stdout_nobody_reads_is_rolled_back_under_its_broken_pipekeeps its assertions. Its subprocess call moved into a shared helper,apply_b_into_a_closed_pipe, so the three closed-pipe rows are not three copies.ROLLED_BACKbecamerolled_back(*paths), with the old constant kept as its three-path call.No command, flag or refusal code is added; no documentation names any of this.
Measured before and after
All runs on Python 3.12.3, uid 1000. Each is
apply --at B --yesin a scaffolded root with a local--upstream, under a stubgh(exit 97) with no network.origin/main50ce8ab1, from a detached worktree. Itsupdate-shape.pyis the same blob (601da0d6) ate04826db, this branch's base: the one commit between them (test_validate_family_index_gitmodules.py and validate-family.py: a row pins path-twice's wording of whose file it puts back, the index's or HEAD's as the index holds none, a row pins the value check's reading of HEAD's where the deletion of.gitmodulesis staged, and_listed_as_git_reads's docstring says it returns nothing where the work tree's file is on disk (#1181) #1208) touches onlyvalidate-family.pyand its test.1. A read-only pin, stdout a closed pipe, block-buffered
Before: exit 120, and the tree is as it was. Stderr is only tracebacks: the refusal shows inside the chain,
During handling of the above exception, another exception occurred:, ending withAfter: exit 120, and the tree is as it was. Stderr is below; the Remediation line that follows the list is left out of this quote.
2.
PYTHONUNBUFFERED=1, stdout a closed pipeBefore: exit 2, and the tree is as it was. Stderr, without its Remediation line:
After: exit 1, and the tree is as it was. Stderr:
3. A refused push after the commit, stdout a stream whose
flushraises (in process,push_branchagainst a bare origin whosepre-receivehook refuses)Before:
BrokenPipeErrorraised, and stderr empty.After:
LandingIncompleteraised. Stderr isREFUSED pushing shape/x. THE EXACT COMMAND WAS:, the push, git's! [remote rejected] shape/x -> shape/x (pre-receive hook declined), then4. A stdout that is read prints byte for byte as before
Each case was run by the tool at main and by this branch's tool, on two copies of one root with fixed git dates. Stdout and stderr were compared apart and merged (
2>&1), with the root's path replaced:--branch shape/b, landsupdate-write-failed--branch shape/b --push, push refusedOut of scope
run_validator's two flushes, around the root's validators, stay bare. A broken pipe there is update-shape.py:applyputs every byte back under anyOSErrorafter its first write, says what it put back on stderr, and raises the error as it was, so a file gone between itsstatand its read, a broken pipe or anEIOno longer leaves the tree half-written behind a traceback (#1173) #1187's case: rolled back, said on stderr and raised (its pipe row). The issue does not ask to change it.Tests
Each command ran alone, under a stub
ghfirst on PATH (exit 97),GH_TOKEN=bogus, an emptyGH_CONFIG_DIR,GIT_SSH_COMMAND=false, a scratchGIT_CONFIG_GLOBALandEMAIL=tests@openreposhape.invalid, as/usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch> -o tmp_path_retention_policy=failed, on Python 3.12.3:tests/test_update_shape*.py, all 19 files, at base50ce8ab1, where the three changed files are the same blobs as ate04826db:826 passed, 3 skipped in 878.91s (0:14:38).-rs, at basee04826db:3221 passed, 6 skipped in 1529.21s (0:25:29). The skips are a case-insensitive disk, Windows' default encoding and a sessionGIT_CONFIG_GLOBAL.tests/test_adopt_attributes_eol_block.py,tests/test_adopt_eol_block_through_links.py,tests/test_bom_reads.py,tests/test_bom_rewrites.py,tests/test_bom_tool_io.py,tests/test_bootstrap_without_git.py,tests/test_agents_md_codes.py,tests/test_bump_leg_branch_and_push_line.py,tests/test_bump_leg_rollback.py,tests/test_chain_pin_sources.py,tests/test_chain_remedy_callers.py,tests/test_commit_trailers.py,tests/test_bump_leg.py,tests/test_doctor_partial_clone.py,tests/test_doctor_no_lazy_fetch.py,tests/test_doctor_unreadable_paths.py,tests/test_doctor_next_is_a_command.py,tests/test_load_yaml_refusals.py,tests/test_legs_readers_not_a_list.py,tests/test_family_py_unreadable.py,tests/test_family_member_name.py,tests/test_family_makefile_siblings.py,tests/test_pin_pattern_line_break.py,tests/test_pin_source_env_collisions.py,tests/test_doctor_pin_restore_merges.py,tests/test_refusal_default_remediation.py,tests/test_path_guard_hygiene.py,tests/test_doctor_unreadable_pin.py,tests/test_openreposhape_package.py,tests/test_pin_rewrite_layout.py,tests/test_pinned_copy_text.py,tests/test_shape_pin_row_path.py,tests/test_family.py,tests/test_ruleset_hint_tracking_branch.py,tests/test_scaffold_e2e.py,tests/test_shape_pin_files_not_a_list.py,tests/test_update_add_codes_documented.py,tests/test_family_printed_quoting.py,tests/test_repo_hygiene.py,tests/test_validate_pins_lockstep_lines.py,tests/test_stdout_encoding.py,tests/test_validator_child_encoding.py,tests/test_readme_windows_quoting_sentence.py,tests/test_windows_paths.py,tests/test_yaml_reader_spaces.py,tests/test_no_auto_maintenance.py,tests/test_cli_reference.py.test_update_shape*.pyfiles that read the family-root templates, rerun at basee04826dbonce test_validate_family_index_gitmodules.py and validate-family.py: a row pins path-twice's wording of whose file it puts back, the index's or HEAD's as the index holds none, a row pins the value check's reading of HEAD's where the deletion of.gitmodulesis staged, and_listed_as_git_reads's docstring says it returns nothing where the work tree's file is on disk (#1181) #1208 changedvalidate-family.py:tests/test_update_shape.py,tests/test_update_shape_add.py,tests/test_update_shape_header_comments.py,tests/test_update_shape_kept_helper.py,tests/test_update_shape_pr_base.py:352 passed in 302.81s (0:05:02).Mutants
Each mutant was applied to
update-shape.pyand run against the four closed-pipe rows. The file was then copied back and checked withcmpagainst the saved change; everycmpreported identical.sys.stdout.flush()back in_rolled_backtest_a_refusal_after_the_writes_reaches_stderr_when_stdout_is_unreadsys.stdout.flush()back inlanding_failedtest_a_refused_push_is_reported_on_stderr_whatever_stdout_isBrokenPipeErrorre-raise inRollback.writingremovedtest_a_broken_pipe_at_a_copied_line_is_not_a_failed_write_flush_stdoutflushing bare (nosuppress)test_a_stdout_nobody_reads_is_rolled_back_under_its_broken_pipesys.stdout.flush()back in_roll_back_before_raisingtest_a_stdout_nobody_reads_is_rolled_back_under_its_broken_pipeAt main's
update-shape.py, the three new rows fail and #1187's row passes:Traceback;REFUSED update-write-failed: writing the copies and the --add files failed: Broken pipe;BrokenPipeErrorout oflanding_failed.Merges
git merge-tree --write-treeof this head (ab137eb5f0976a7c6c2bf40abaa31c6d86d63bca) with each target. The open PRs are the ones that touch this change's files, listed live withgh pr list --state openand fetched asrefs/pr/<n>, each head checked against the list. Every merge is clean (exit 0, no conflict).origin/main0ed3e57f821c474cf1cb97dc580d2d931c54b1a5dee17ee209fef82cdd9f89e404f2b7ed007222c1update-shape.py)aca00dfd918d78797001ef380602ebd372f558204634619a4b03cbe33b1c66c4af0e3603fcf6e863update-shape.py,tests/test_update_shape_unreadable.py)5102b42945baf3682bc51244a64050e654d25e32fc07c0905455373e971d6e6e87869d302faa9d16update-shape.py)a7bfee1bdb164eeca7e231aaa819acaf52cfbcbbb265030ecc8cac6488676ef79a8fd8f2677b8ef4The branch is cut from
e04826db;origin/mainhas since moved to0ed3e57f8(#1121), which touches onlyadopt-project.pyandtests/test_adopt_next_make_reads_first.py, none of this change's files, so no gate is stale.Files
Only
update-shape.py,tests/test_update_shape_unreadable.pyandtests/test_update_shape_messages.py.Closes #1218
Lane: openreposhape-3 (openRepoShape-3)
🤖 Generated with Claude Code
Summary by Sourcery
Ensure update-shape.py reports failures reliably when stdout is unreadable and accurately preserves broken-pipe errors.
Bug Fixes:
Enhancements:
Tests: