Skip to content

bootstrap.py: a validator's exit 2 ends bootstrap REFUSED, exit 2, an unreadable upstream path, cache or pin is no answer and a reftable leg's cherry-pick or revert is read from its ref store, so no traceback or ended pick follows (#764) - #815

Merged
brettheap merged 30 commits into
mainfrom
fix/764-bootstrap-notice-exit-texts
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-5 (openRepoShape-5)
Claim: #764 (comment)

Against main. #745 landed as 24a977c, and this branch is restacked onto main as f6f318d1: it merges #745's final head 1945154c, then main, taking this branch's side in bootstrap.py, where git diff 1945154c 24a977c0 over #745's files is empty. The restacked tree is 7884460e, the tree the merge checks below predicted.

Fix round: lane 3's RV815 NITs 2 to 7 (05a1ebb3)

Lane 3's RV815 reviewed f6f318d1: READY, no MUST. One new commit, 05a1ebb3, on f6f318d1, pushed to fix/764-bootstrap-notice-exit-texts as a fast-forward. Nothing was amended and main is not merged in that round. The merge round 61aed8d2 merged it later (#815 issuecomment-6247909102). It changes templates/assembly-root/scripts/bootstrap.py (+13/-8), tests/test_bootstrap_refusal_exit.py (+62) and tests/test_bootstrap_upstream_notice.py (+30/-2), and nothing else. README, the handbook, docs/cli.md and every other pinned file are untouched.

The pinned digest moves again. After the merge round with main (61aed8d2, #815 issuecomment-6247909102), templates/assembly-root/scripts/bootstrap.py is sha256 89ff92e5e469003fb5a1f13a5adbe5be2f9c2913ad152eb3f49e8545f0a8c3cc. Main's is 6a7847f3d78ab6c3a350914bf7f9c81e11e235a6d9f8ad2b5782418e8b794e57, since #855. At the NIT round it was 09238ebd4202193a..., where the body says 0cb334c260493313.... The "Pinned files changed" paragraph below gives the current digests; the rest of that paragraph stands (the file is already upstream-changed for a v1.1.0 project once, and #824 changes it too). In "What changed", the count becomes: three commits after f4935e11 (271710ba, c2977425, 05a1ebb3), the restack's two merges (3aa9a0fe, f6f318d1), and the merge round with main (61aed8d2).

What each NIT came to

NIT taken what changed held by
2: mutant X3 survives yes no shipped change; an over-long SHAPE_UPSTREAM_PATH (a name of 300 characters, and a path of over 5000) now has a test: bootstrap ok, exit 0, no notice, no traceback test_an_over_long_upstream_path_is_an_absent_answer (2 cases)
3: no flush before the ending yes sys.stdout.flush() at the top of _end_of_run, as the git-not-runnable path does test_the_ending_follows_step_c_in_a_log_of_both_streams (2 cases: REFUSED and FAILED)
4: docstring (d) overstated yes step (d) now says a finding is any exit but 2 and reads (exit <code>); both lines are said, the finding first, where the run reaches its end; step (a) or the notice refusing git-not-runnable stops the run with neither line the no-git cases below, and the nine ending cases that were already there
5: != 0 yes the shape-pin list case asserts == 2 test_a_shape_pin_that_is_not_a_mapping_ends_as_the_validators_end_it
6: no test for "a plain clone with no git" yes test_a_plain_clone_with_no_git_ends_refused_with_exit_2: bootstrap REFUSED: pins (exit 2), exit 2, no FAILED line, step (c) still reached. A second test holds the sentence step (d) now says: no git, a manifest finding and a directory named by SHAPE_UPSTREAM_PATH refuse git-not-runnable first, with neither line test_a_plain_clone_with_no_git_ends_refused_with_exit_2, test_no_git_and_a_clone_named_refuses_before_either_line
7: the body's C3 lines yes the paragraph below
1: run git -C <upstream> from the root no see below
8: a BOM in the cache done by the merge round 61aed8d2, with #855's read see the candidates

NIT 3, as measured. With > log 2>&1, stdout is block-buffered and stderr is not. Without the flush, bootstrap REFUSED: pins (exit 2) (and bootstrap FAILED: for a finding, as on main) printed before step (c)'s heading and its lines, and the log did not end on it. The test merges the two streams through run_script(merge_stderr=True) and blanks PYTHONUNBUFFERED, so a runner that exports it cannot hide the fault.

NIT 4, as measured. With no git on PATH, SHAPE_UPSTREAM_PATH naming a directory and a manifest finding, the run ends REFUSED git-not-runnable: the clone SHAPE_UPSTREAM_PATH names ..., exit 2, with neither bootstrap FAILED nor bootstrap REFUSED. That predates this PR (#694), so the docstring said too much, not the code. A validator killed by a signal ends bootstrap FAILED: <name> (exit -9), exit 1 (_end_of_run([("pins", -9)]) returns 1 and says exactly that), which the (exit <code>) wording now covers.

NIT 1 is not taken, because it changes an exit path on Linux. The change is _try_git(["-C", str(Path(upstream).absolute()), "rev-parse", "HEAD"], root). Its point (no process starts inside the upstream directory, so a Windows cwd that CreateProcess refuses cannot be read as git's own PermissionError) is reasoned, and cannot be measured on Linux. I measured the Linux side: the old _clone_tip and the new one run through shape_upstream_notice over 22 values of SHAPE_UPSTREAM_PATH and two PATHs (git, and an empty directory), 44 rows:

  • the values: a clone by absolute path, with a trailing slash, by relative path from the root's directory and from another, through link/.., a subdirectory of a clone, a directory named -x, a bare repository, an unborn HEAD, a plain directory, a file, a missing path, a name with spaces and a newline, a name with a non-UTF-8 byte, an over-long name and path, the empty string, a mode-000 directory, one under it, and a clone under a directory with no search bit;
  • 43 rows are byte for byte the same (the notice, or the same refusal);
  • 1 row differs: no git on PATH and an upstream directory nobody may enter. The old code answers "no clone" (its PermissionError names the directory, which _git_runs passes on), so the cache speaks; the new code starts no process there, finds git missing, and refuses git-not-runnable, exit 2.

The brief for this round asked for NIT 1 only if every exit path stayed byte-identical, so it is left. It is two lines (and _clone_tip takes the root) if lane 3 or Brett Heap wants that row to refuse; the new row's answer is defensible, since the clone cannot be read either way.

NIT 7: the C3 account, one more paragraph. Two more sentences say "AT its pinned commit" with no condition, and both are true where they stand, so neither is changed:

  • scaffold-project.py:1957, the scaffold's NEXT STEPS: "make bootstrap puts each leg on <branch> AT its pinned commit". It is printed once, for a person who is about to take a fresh recursive clone and run make bootstrap for the first time. In that clone every leg is detached AT its pin, with no operation in progress and no commit of its own, so nothing can be lost and the placement happens as said. tests/test_push_branch_refspec.py asserts the sentence for a +x branch and then runs that first bootstrap (the legs end on it at their pins), and tests/test_scaffold_e2e.py runs it on the default branch and checks the pin did not move.
  • templates/assembly-root/project.yaml:64, the comment on tracking_branch: "The branch scripts/bootstrap.py places each leg on, AT its pinned commit". It names which commit the branch is placed at and says the pin stays exact (.gitmodules carries no branch=); where bootstrap leaves a leg alone is AGENTS-shape.md's sentence, which R2 narrowed and which is pinned beside it.

Tests (this round)

All runs used run9.sh (gh stub first on PATH, GH_TOKEN=bogus-l5, empty GH_CONFIG_DIR), on Python 3.12.3, at 05a1ebb3 with a clean tree.

run files git result
the two touched files test_bootstrap_refusal_exit, test_bootstrap_upstream_notice 2.43.0 26 passed (17 + 9 cases)
core the two, test_bootstrap_ref_store, test_bootstrap_placement_texts, test_bootstrap_leg_moves, test_bootstrap_without_git, test_bootstrap_after_bump, test_bootstrap_spelling, test_bootstrap_branch_bytes, and the trio (test_repo_hygiene, test_agents_md_codes, test_cli_reference), test_readme_sections, test_pinned_copy_text 2.43.0 689 passed, 3 skipped (the reftable cases)
core, newer git the seven bootstrap files 2.47.1 127 passed, none skipped
wide 1 16 files that run the assembly root's bootstrap 2.43.0 1100 passed, 9 skipped
wide 2 test_family, 16 test_adopt_* files, test_family_makefile_siblings, test_siblings_family_name 2.43.0 1795 passed
wide 3 test_setup_project_py, test_setup_sh, test_shape_doctor, 8 more 2.43.0 1259 passed, 4 skipped, 1 xfailed (the strict xfail for #735 in test_shape_doctor.py, not this PR's)
sources the four files that read every template's source 2.43.0 204 passed
named only the 9 other files that name bootstrap.py (a path, a docstring, a pinned-file list) 2.43.0 327 passed

The lists are the ones c2977425's runs used; git grep over tests/ for bootstrap.py finds no file outside them and the nine named-only files.

Fails on the head this round started from (f6f318d1, the two touched files with the new tests copied in): 2 failed, 24 passed. The two are the NIT 3 cases. NIT 2, 5 and 6 hold behaviour f6f318d1 already has, so they cannot fail there; they fail on the earlier bases:

Mutants (this round)

One per new hunk that has behaviour, applied to 05a1ebb3's bootstrap.py in a scratch clone and run against the two touched files (26 cases). Each is caught.

mutant result
F1: the flush at the top of _end_of_run removed 2 failed (NIT 3's two cases)
F1b: the flush moved after the two lines the same 2 failed
X3 (lane 3's): _clone_tip catches PermissionError only 2 failed (the two over-long cases); on Python 3.14, where Path.is_dir answers False, it would survive
N4: a refusal exits 1 10 failed, NIT 5's test among them; the old != 0 version of that test passes under N4, the new == 2 does not
M4c: _clone_tip also catches Refusal 1 failed (the no-git-and-a-clone-named case)
N13: the REFUSED line says FAILED 9 failed, the plain-clone-with-no-git case among them

The docstring hunk has no behaviour. The three things it says about the ending are held as above: REFUSED alone (the plain clone with no git), both lines with the finding first (test_a_finding_and_a_refusal_say_both_and_exit_2), and neither line (the no-git-and-a-clone-named case).

Merge checks (this round)

git merge-tree --write-tree from 05a1ebb3 on 2026-10-17 at about 17:50Z:

Checklist candidates (not filed)

What changed and why

rv704's checklist candidates C1 to C4 on #745. Three commits after f4935e11 (271710ba, c2977425, 05a1ebb3), the restack's two merges (3aa9a0fe, f6f318d1), and the merge round with main (61aed8d2).

  • 271710ba: C1, C3 and C4, with C2 measured and put to a ruling.
  • c2977425: C2, option B, on Brett Heap's ruling (below).

Pinned files changed: templates/assembly-root/scripts/bootstrap.py only, and its digest changes: sha256 6a7847f3d78ab6c3... on main, 89ff92e5e469003f... here after the merge round 61aed8d2 (09238ebd4202193a... at the NIT round 05a1ebb3, 0cb334c260493313... before it). #764 is v1.1.1 and lands after the v1.1.0 tag, so a project taken to v1.1.0 sees scripts/bootstrap.py as upstream-changed on update-shape.py check --root ., and re-pins it with update-shape.py apply. (#682 changed the family root's bootstrap.py, not this one; an earlier draft of this paragraph said otherwise, and lane 3's RV815 corrected it.) #824 (#710) changes the same file, so the two should land in one release, for one row, as #764 asks. The Makefile, both roots' AGENTS-shape.md and the holder's README are not changed (see C3).

C1: the upstream notice and a directory nobody may enter

With SHAPE_UPSTREAM_PATH set to a directory this process may not enter, make bootstrap ended in a PermissionError traceback, exit 1, after every validator had passed. There are two ways in:

The new _clone_tip catches OSError around both reads and returns no answer, as an absent answer does. The cache is then asked, as it is when no clone is named.

A git that cannot be STARTED is still git-not-runnable, exit 2. _git_runs raises that as a Refusal, which except OSError does not catch, and tests/test_bootstrap_without_git.py still passes. A mutant that also catches Refusal fails it.

The same function, under the issue's goal that make bootstrap never ends in a traceback over the notice. These go past C1's own line, and each is measured on f4935e11:

state base now
.shape-upstream-tip nobody may read PermissionError, exit 1 no answer (_cached_tip)
.shape-upstream-tip whose bytes are not UTF-8 UnicodeDecodeError, exit 1 no answer
contracts/ nobody may enter PermissionError at the notice's is_file, after the validators' own answer no answer
contracts/shape-pin.yaml that is a YAML list AttributeError, after validate-pins.py refused shape-pin-unreadable no answer; the run ends on that refusal

C4: a cherry-pick or a revert on a reftable leg

The probe's result is on #764 (issuecomment-6237519513), as its Verify asks, and on #684 for lane 4's R612 E4 (issuecomment-6237529261).

The probe. This host's git (2.43.0) has no reftable, so I built git 2.47.1 from its source tag on GitHub, in my scratch directory. In a repository made by git init --ref-format=reftable:

  • a cherry-pick stopped on a conflict and a stopped git revert leave NO CHERRY_PICK_HEAD or REVERT_HEAD file in .git;
  • git show-ref --exists finds each in the ref store, and git status says the operation is in progress;
  • a stopped merge leaves MERGE_HEAD a FILE, as on the files backend.

So the ref store is where the two live, and the issue's second branch applies.

The defect, reproduced with that git on f4935e11:

  1. The scaffold is cloned with GIT_DEFAULT_REF_FORMAT=reftable, so the root and both legs are reftable, and bootstrapped once.
  2. A cherry-pick is stopped on other in the spec leg.
  3. Bootstrap printed on main at <pin> and the leg was on other. The cherry-pick was ENDED, and its staged README was carried onto main.

A revert ended the same way, and its staged removal was gone. That is #704's line 2, which R1 refuses, met again on the other backend. A merge was refused, by its file.

The fix. _in_progress now asks the leg's ref store for CHERRY_PICK_HEAD and REVERT_HEAD too (IN_REF_STORE, _in_ref_store), in the same row order. It asks with git show-ref --exists <name> (git 2.43 and later), which takes the exact name. The issue's git rev-parse -q --verify CHERRY_PICK_HEAD also resolves a branch or a tag of that name. rev-parse --symbolic-full-name was measured too: it answers nothing, with exit 0, when a pseudoref and a branch of the same name both exist.

A git before 2.43 refuses the option and is answered no. Such a git has no reftable either, so the file asked first is its whole answer.

The refusal now says where git holds the marker:

[spec] spec: a cherry-pick is in progress in the leg (CHERRY_PICK_HEAD is in its ref store), so nothing moved

A marker found as a file still says is in its git dir, byte for byte as before.

family.py. The same probe answers #684's R612 E4 for family.py's _operation_in_progress, which is lane 4's: on reftable the two markers are refs, not files. This PR does not touch it.

C3: the reference row

docs/cli.md:47 said bootstrap "places each leg on its tracking branch AT the pinned commit", with no condition.

  • The generator's sentence (scripts/render-cli-reference.py) now carries R2's condition, word for word as the pinned AGENTS-shape.md says it: "only where that loses no commit, and otherwise leaves its checkout where it is, saying why".
  • docs/cli.md is regenerated, and render-cli-reference.py --check passes.

The other three are left, by choice. The Makefile's help (:34-35), the holder's AGENTS-shape.md (:63-65) and its README (:14-17) all say "or says why not". So does AGENTS-shape.md's own table of the five commands (:91-92). Each is true as it stands, which is why R2 left them. Changing them would give every project (the Makefile) and every holder (the other two) an upstream-changed row that fixes nothing.

AGENTS.md's section 3 ("Say that bootstrap put each leg on its tracking branch AT the pinned commit") is true where it stands too: it is the fresh scaffold's first bootstrap.

C2: option B, on Brett Heap's ruling

Brett Heap ruled on this line in lane 5's session at 2026-10-17T13:01:10Z: "take B for #764's C2". The first commit measured the line and left it for that ruling. The second commit, c2977425, is option B.

The defect, measured on 271710ba, in a plain clone (no --recurse-submodules). validate-pins.py refuses pin-submodule-uninitialized, exit 2, and bootstrap said bootstrap FAILED: pins (exit 2) and exited 1, which is a finding's code. Every other refusal in the file exits 2. Any person who forgot --recurse-submodules meets it, not only a machine with no git (git-not-runnable).

What B does:

  • A validator's exit 2 ends the run bootstrap REFUSED: <name> (exit 2), exit 2.
  • Any other non-zero exit is a finding, as it was: bootstrap FAILED: <name> (exit <code>), exit 1. An exit 3 or 127 stays a finding.
  • Where one validator finds and another refuses, both lines are said, FAILED first, and the run exits 2, since a check that could not run outranks a finding. The earlier draft of this body set this out as part of B and asked the ruling to say whether it was right, and the coordinator's note of the ruling states it as part of B. If a finding should outrank a refusal in the exit code instead, it is one branch of _end_of_run; test_a_finding_and_a_refusal_say_both_and_exit_2 and two of the ending cases hold the rule as it is (mutant N5 below).

_run_neutral_validators now returns each failed validator's label and exit code, and the new _end_of_run says them and returns the exit. VALIDATOR_REFUSED = 2 names the code. The step (d) paragraph of the module docstring says what the ending does. The only shipped file changed is bootstrap.py, the pinned file this PR already changes.

before (271710ba) now (c2977425)
scripts/bootstrap.py, plain clone: pins refuse bootstrap FAILED: pins (exit 2), exit 1 bootstrap REFUSED: pins (exit 2), exit 2
the same plus a manifest finding (mixed) bootstrap FAILED: manifest (exit 1), pins (exit 2), exit 1 bootstrap FAILED: manifest (exit 1), then bootstrap REFUSED: pins (exit 2), exit 2
a finding alone, in a recursive clone bootstrap FAILED: manifest (exit 1), exit 1 unchanged
make bootstrap, plain clone exit 2, make: *** [Makefile:45: bootstrap] Error 1 exit 2, ... Error 2
setup-project.py step (7), which runs bootstrap.py directly exit 1 exit 2
siblings.py's finding its own bootstrap exited 1 exited 2
the family root's bootstrap, which runs each member's make bootstrap (make bootstrap exited 2) unchanged

The first four rows are measured, on 271710ba and on c2977425 (the new tests). The last three are read from the code: setup-project.py hands bootstrap's code to shell_status, which keeps a non-negative code; siblings.py prints the code; the family root runs make, which exits 2 for any failing recipe.

The issue says a script testing make bootstrap's exit would see a change under B. It does not: GNU make exits 2 for any failing recipe, as the Makefile's own comment (:12-16) says, and only Error 1 became Error 2. What changes is the exit of bootstrap.py run directly, by setup-project.py, by a person without make, or by a script.

Docs. git grep found no sentence in AGENTS.md, README.md, docs/handbook.html, templates/assembly-root/AGENTS-shape.md, the Makefile or docs/cli.md that says bootstrap exits 1 for a refusal or prints FAILED for one. None is changed, and neither README nor the handbook is touched. render-cli-reference.py --check passes on this head.

Tests

All runs used run9.sh (gh stub first on PATH, GH_TOKEN=bogus-l5, empty GH_CONFIG_DIR), on Python 3.12.3.

New: tests/test_bootstrap_upstream_notice.py, 7 cases in 5 tests.

  • SHAPE_UPSTREAM_PATH at a mode-000 directory and at a directory under one: exit 0, no traceback, bootstrap ok, no notice.
  • The cache answers where the clone cannot be read.
  • A cache nobody may read, and one that is not UTF-8.
  • A shape-pin.yaml that is a list: no traceback, step (c) reached.
  • A contracts/ nobody may enter, asked of the notice in process, since the validators before it have their own answer to that folder.
  • The mode-000 cases skip under root and on Windows.

New: tests/test_bootstrap_ref_store.py, 4 cases in 2 tests.

  • A cherry-pick, a revert and a merge in progress on a reftable leg. Each asserts where git holds the marker first, then the refusal byte for byte, an unchanged snapshot, and the operation still held. The --abort it prints is typed from the root, and the next run places the leg.
  • These skip where git makes no reftable repository (before 2.45). The CI image's git does.
  • A branch called CHERRY_PICK_HEAD and a tag called REVERT_HEAD, with no operation: the leg is placed. This one runs on every git.

New: tests/test_bootstrap_placement_texts.py, 2 tests. The reference row carries R2's condition, and the pinned AGENTS-shape.md still words it so.

New (C2): tests/test_bootstrap_refusal_exit.py, 13 cases in 5 tests.

  • A plain clone: validate-pins.py refuses, and the run ends bootstrap REFUSED: pins (exit 2), exit 2, with no FAILED line. Step (c) is still reached.
  • A recursive clone with a manifest finding: bootstrap FAILED: manifest (exit 1), exit 1, as before. This is the control.
  • A plain clone with that finding too: both lines, the finding first, exit 2.
  • make bootstrap in a plain clone: exit 2, REFUSED, and make's Error 2, not Error 1.
  • _end_of_run asked of nine mixes of codes: none, one finding, two findings, one refusal, two refusals, an exit 3, an exit 127, a refusal then a finding, and all three. Each says its lines and exits by the worst.

Runs on head (c2977425):

run files git result
core the 4 new files, the issue's Verify list (test_bootstrap_leg_moves, test_bootstrap_without_git, test_bootstrap_after_bump, test_cli_reference, test_readme_sections, test_pinned_copy_text, test_repo_hygiene), test_bootstrap_spelling, test_bootstrap_branch_bytes and test_agents_md_codes 2.43.0 682 passed, 3 skipped (the three reftable cases skip)
core, newer git the 4 new files, test_bootstrap_leg_moves, test_bootstrap_without_git, test_bootstrap_after_bump 2.47.1 121 passed
wide 1 16 files that run the assembly root's bootstrap (test_scaffold_e2e, test_update_shape, test_update_shape_kept_helper, test_doctor_pin_restore_merges, test_doctor_legs_members_next, test_family_head_lost_gitmodules, test_family_bootstrap_gate_order, test_family_siblings_placement, test_windows_paths, test_stdout_encoding, test_template_text_placeholders, test_scaffold_ignored_shape_files, test_validate_pins_lockstep_lines, test_push_branch_refspec, test_leg_path_yaml_scalars, test_scaffold_manifest_values) 2.43.0 1084 passed, 9 skipped
wide 2 test_family, 16 test_adopt_* files that run bootstrap, test_family_makefile_siblings, test_siblings_family_name 2.43.0 1782 passed
wide 3 test_setup_project_py, test_setup_sh, test_shape_doctor, test_doctor_next_is_a_command, test_bump_leg, test_bump_leg_rollback, test_update_shape_merged_away, test_update_shape_add, test_naming_policy, test_park_resume_targets, test_scaffold_mount_ignored 2.43.0 1206 passed, 4 skipped
sources the four files that read every template's source or run a holder's bootstrap.py: test_git_output_decoding, test_shape_advisory, test_template_quoted_values, test_validate_family_branch_step 2.43.0 204 passed

The core and wide 1 files hold what the first commit's runs held; test_bootstrap_spelling and test_bootstrap_branch_bytes moved from wide 1 to core. The other test files that name bootstrap only name its path or its output, in comments, docstrings and expected text. The one helper that runs it, in tests/conftest.py, ends in bootstrap ok, and the files that use it are in wide 3.

Fails on base (f4935e11, the new files copied in):

  • test_bootstrap_upstream_notice.py: all 7 cases fail.
  • test_bootstrap_placement_texts.py: the row test fails; the guard on R2's sentence passes, by design.
  • test_bootstrap_ref_store.py on git 2.47.1: the cherry-pick and revert cases fail. The merge case (a file on both backends) and the exact-name case pass on base, by design: they are controls.

Fails on base for C2 (271710ba, tests/test_bootstrap_refusal_exit.py copied in): 3 failed, 1 passed, 9 errors of the 13 cases.

  • The refusal alone fails: bootstrap FAILED: pins (exit 2), exit 1.
  • The mixed run fails: it printed bootstrap FAILED: manifest (exit 1), pins (exit 2) and exited 1.
  • make bootstrap fails: it said Error 1.
  • The nine _end_of_run cases error, since the function is not there.
  • The finding alone passes, by design: it is the control.

render-cli-reference.py --check: [ok] docs/cli.md is what the generator produces.

Mutants

For C1, C3 and C4 (271710ba), each one breaks a single new hunk, in a scratch clone. Every one is caught:

mutant caught by
M1: the ref-store ask removed from _in_progress reftable cherry-pick and revert (git 2.47.1)
M2: the ask made with rev-parse -q --verify the exact-name test (git 2.43)
M3: the refusal says git dir whatever held is reftable cherry-pick and revert
M4: _clone_tip catches nothing both upstream-path cases and the cache-answers test
M4b: is_dir outside the try the directory-under-one case
M4c: _clone_tip also catches Refusal test_the_offline_upstream_notice_with_no_git_refuses_too
M5a / M5b: _cached_tip catches only OSError / only UnicodeDecodeError the not-UTF-8 / unreadable cache case
M6: the pin read catches only Refusal the contracts/ test
M7: pin or {} again the list test
M8: an unreadable clone answers "", so the cache is skipped the cache-answers test
M9: the generator and docs/cli.md both reverted the row test (test_cli_reference.py alone stays green)

For C2 (c2977425), 13 more, one per new hunk or branch of _end_of_run, each caught (each is applied to the pushed file in a scratch clone and run against tests/test_bootstrap_refusal_exit.py):

mutant caught by
N1: VALIDATOR_REFUSED = 1 refusal, finding alone, mixed, make, 6 of the 9 ending cases (10 of 13 fail)
N2: the exit code dropped on the way to the ending (failed.append((label, 1))) refusal, mixed, make (3 of 13 fail)
N3: a refusal also listed as a finding refusal, mixed, 4 of the 9 ending cases (6 of 13 fail)
N4: a refusal exits 1 refusal, mixed, make, 4 of the 9 ending cases (7 of 13 fail)
N5: a finding outranks a refusal in the exit code mixed, 2 of the 9 ending cases (3 of 13 fail)
N6: the FAILED line dropped when something refused mixed, 2 of the 9 ending cases (3 of 13 fail)
N7: the REFUSED line dropped when something found mixed, 2 of the 9 ending cases (3 of 13 fail)
N8: the two lines said in the other order mixed, 2 of the 9 ending cases (3 of 13 fail)
N9: only exit 1 is a finding (an exit 3 vanishes) 3 of the 9 ending cases (3 of 13 fail)
N10: bootstrap ok not said 1 of the 9 ending cases (1 of 13 fail)
N11: a finding alone exits 2 finding alone, 4 of the 9 ending cases (5 of 13 fail)
N12: main discards the ending's code refusal, mixed, make (3 of 13 fail)
N13: the refusal line says FAILED refusal, mixed, make, 4 of the 9 ending cases (7 of 13 fail)

Merge checks

Run on 2026-10-17 at about 16:00Z with git merge-tree --write-tree, from head c2977425, against origin/main 40e678e6 and every fix/*, l3-* and docs/* branch on origin (58 besides this one).

origin/main: conflicts, in templates/assembly-root/scripts/bootstrap.py only. That is the stack, not this change. #682 (dc7d3fbb) and #745 (24a977c0) landed as squashes, and this branch still holds their commits. bootstrap.py on main is byte for byte what f4935e11 has: git diff f4935e11 origin/main -- templates/assembly-root/scripts/bootstrap.py is empty.

The restack, predicted: clean. The two commits after f4935e11, applied onto origin/main with git merge-tree --write-tree --merge-base=f4935e11 origin/main c2977425, give tree 7884460e. Against main it changes seven files: bootstrap.py, docs/cli.md, scripts/render-cli-reference.py and the four new test files.

That predicted head (a scratch commit object, no ref) against the 58 branches:

  • It is clean with the 49 that are clean with main.
  • The other 9 conflict with main in the same files, so none of them is this change's: docs/release-notes-v1.0.0, fix/695-family-docs, fix/758-git-command-scope, fix/759-quoting-launcher-guards, fix/760-bin-dir-control-chars, fix/762-validate-family-remedies-texts, l3-677-pins-hand-step-wip, l3-678-naming-checkout-wip and l3-733-ignored-sections.
  • Six branches touch a file this PR changes, and each merges clean with it: fix/710-non-utf8-branch-names (bootstrap.py), fix/759-quoting-launcher-guards (render-cli-reference.py), fix/796-adopt-source-spellings and fix/799-adopt-plan-out-checked (docs/cli.md), and l3-563-docs and l3-rehearsal-v1.1.0 (both).
  • render-cli-reference.py --check passes on the predicted head and on its merge with each of fix/710, fix/759, fix/796, fix/799 and l3-563-docs.

#710's branch now exists and edits the same bootstrap.py, so whichever lands second re-gates.

Left out

Checklist candidates (not filed)

Closes #764

🤖 Generated with Claude Code

Summary by Sourcery

Make bootstrap report validator refusals accurately, tolerate unreadable upstream state, and preserve in-progress reftable operations.

New Features:

  • Handle cherry-pick and revert operations stored in reftables without moving the affected leg.],
  • bug_fixesเป็น_final blah? no. Need valid JSON.} sop?}
  • bug_fixes:[

Bug Fixes:

  • Prevent bootstrap tracebacks when upstream paths, caches, or pin files are unreadable or malformed.
  • Distinguish validator refusals from validation findings, preserving their exit codes and reporting mixed outcomes correctly.
  • Ensure bootstrap completion messages appear after preceding output when streams are redirected.

Enhancements:

  • Align the CLI reference with conditional leg placement behavior.

Documentation:

  • Update the generated CLI reference to describe when bootstrap places legs at their pinned commits.

Tests:

  • Add coverage for validator refusal and mixed-result exits, unreadable upstream inputs, reftable operation markers, and the updated CLI wording.

Brett Heap and others added 21 commits October 15, 2026 22:59
…HEAD's `.gitmodules` gives is `member-gitmodules-absent`, so a holder whose HEAD lost the file no longer says `family ok`, `remove` refuses it before `deinit` instead of leaving the mount empty, and the gitlink remedies name a case variant and the commit `add` needs (#611)

571(b). In a holder whose HEAD lost `.gitmodules` (the commit #407
describes), `family.py remove --dry-run` exited 0, and the real run did
`git submodule deinit` and `git rm`, then died at its commit's `git
--literal-pathspecs add -f -- .gitmodules family.yaml` (`pathspec
'.gitmodules' did not match any files`); the rollback left `members/<P>`
empty. `_checked_gitmodules_held` now refuses that holder, before any git
command that writes and under `--dry-run` alike: no `.gitmodules` in the
work tree, the index or HEAD while the index records the member's gitlink.
Refused, not committed without the file, as #407 refuses a `.gitmodules`
the work tree lacks. The remedy writes back, with `git config -f
.gitmodules`, the section `family.py add` writes for each member whose
gitlink the index records, then `git add -f .gitmodules`; `remove` then
runs as it always has.

571(c), #407's decision 3. `validate-family.py` reads HEAD's `.gitmodules`
(`_head_gitmodules`, HEAD's root tree listed with no pathspec) and makes
`member-gitmodules-absent` for each row at `members/<project>` whose
gitlink no section gives, in every mode, `--no-members` included. Its two
lines write the section back as `family.py add` writes one, keeping the url
of a section of that name that has one. A member it names that is not
checked out is not refused `member-uninitialized`: that refusal named a `git
submodule update` that exits 0 there and dropped the finding, which is what
a fresh clone and CI met. It reads HEAD, not the work tree, so a deletion
nobody committed, an unstaged edit and a sparse checkout that leaves the
file out are no finding, and a holder with no members is asked nothing.

574/2 (#363's item 3). A row whose gitlink HEAD records only at a case
variant of its path (`Members/<P>`) is refused `member-gitlink-absent`
naming each variant, and the move that puts it back in the index alone:
`git rm -r --cached` of the variants and `git update-index --add
--cacheinfo 160000,<pin>,members/<P>`, then a commit. `family.py add`, the
plain refusal's remedy, cannot help there: the row exists.

574/3. A hand mount's `member-gitlink-unnamed` remedy says to commit the
`git rm` before the `add`: since #574, `add` refuses a path under another
spelling of `members/` that HEAD holds and the index no longer does.

`templates/family-root/scripts/validate-family.py` is a pinned copy: each
holder sees it as `upstream-changed` on its next `update-shape.py check`.

Tests: tests/test_family_head_lost_gitmodules.py (new, 24); the pinned
remedy text and the case-variant states in
tests/test_validate_family_unnamed_mounts.py; a docstring in
tests/test_family_remove_gitmodules.py.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-absent` reads and the members `remove`'s refusal is for are pinned, so a finding for a row with no gitlink or a hand-edited path, or the refusal over a member with no gitlink, no longer passes (#611)

Two tests. In a holder whose HEAD lost `.gitmodules`: a row with no
gitlink (`member-gitlink-absent`'s) and a row whose `path:` was edited by
hand (`member-path`'s, its mount `member-gitlink-unnamed`'s) get no
`member-gitmodules-absent`; and `remove` of a member with no gitlink is
not that refusal, but the dry run's `did not match any file(s) known to
git`, as before. Each holds a condition of the first commit that its
other tests did not: both pass on BASE 396c0ff and on that commit.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….gitmodules` over an unmerged gitlink too, and the case-variant move prints `--ignore-unmatch` and the `mv` a case-sensitive disk needs, so the mount is no longer emptied there and the printed lines work half typed or with both directories on disk (#611)

Fix round 1, from the review of 6f938ed.

`_checked_gitmodules_held` (scripts/family.py) took the member's gitlink
only at stage 0. `git submodule deinit` and `git rm` run on an unmerged
gitlink as on a merged one, so with `members/<P>` held only at stages 1,
2 and 3 in a holder whose HEAD lost `.gitmodules`, the dry run exited 0
and the real run emptied the mount and died at its commit. It now takes a
gitlink at any stage, and both runs refuse `member-gitmodules-absent`
before `deinit`, with the holder byte for byte as it was. The docstring
says so.

The case-variant refusal of `member-gitlink-absent`
(templates/family-root/scripts/validate-family.py, pinned) now prints
`git rm -r --cached --ignore-unmatch -- <variants>`: a variant whose
removal is staged and not committed is still named from HEAD, and the
plain line failed there (`pathspec ... did not match`). Its remediation
also says what is left on disk: where nothing is checked out at the row's
path, as in a fresh clone, `git submodule update --init --recursive` then
checks the member out; on a case-sensitive disk a directory left at a
variant stays, untracked, and `family.py add` refuses it as
`family-members-dir-variant`, so it gives an `mv` of each out of the
holder, to `../<variant with / as ->`, and says to leave it on macOS and
Windows, where it is the row's own directory.

The validator's module docstring names `member-gitmodules-absent`, says
what a staged, uncommitted `.gitmodules` does (HEAD's file is what counts,
as #407's decision 3 says: an uncommitted `add` is the finding for its
member, a staged section removal is none, a staged restore is still the
finding), and gives EXIT CODES' one exception, an unregistered member that
is not checked out (exit 1, not 2).

Tests (tests/test_family_head_lost_gitmodules.py, 12 more, 38 in all):
the unmerged gitlink refused alike and repaired as printed; one test per
condition no test held (a checked-out member still gets its tree check
beside the finding; HEAD's unreadable file refused in every mode; the
`<url>` placeholder; `remove`'s remedy names only members with a gitlink
in the index; one finding per path for a duplicate row); the variant move
half typed (both disks); and, on a case-sensitive disk, every printed
line typed with both directories present, after which the gate passes and
another member is added. tests/test_validate_family_unnamed_mounts.py
pins the `--ignore-unmatch` line.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…modules: the gitlink remedy keeps #611's commit-the-removal clause and ends with BRANCH_LEFT (#611 x #607)

This merges origin/main at 95780dd, which
holds #637's squash (95780dd, "family.py and validate-family.py: where an
earlier act left the branch ...", #607). It adds `BRANCH_LEFT` to
scripts/family.py and templates/family-root/scripts/validate-family.py, and
the guard `test_every_remedy_that_names_a_switch_line_ends_with_the_sentence`
in tests/test_family_switch_branch_exists.py. Lane 2's #634 (#610) is not in
main yet, so `_check_member_gitlink` is untouched here.

Two hand resolutions, one sentence in two places, as #639's body named it:

- templates/family-root/scripts/validate-family.py, the end of
  `member-gitlink-unnamed`'s remedy: #611's "and commit that removal, since
  family.py add refuses a path under another spelling of members/ that HEAD
  holds and the index no longer does, then mount it with the third, ..."
  clause is kept, and #607's `BRANCH_LEFT` ends the prose, as
  "... if one is still there. {BRANCH_LEFT}\n".
- tests/test_validate_family_unnamed_mounts.py, `GITLINK_REMEDY`: the same
  clause, ending ".git/modules if one is still there. " + BRANCH_LEFT.

No other file conflicted. #637's guard test passes on this merge without a
further change: the remedies #611 added name no `_switch_line`, so no
BRANCH_LEFT is appended beyond the one resolved above.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ck out fails the family bootstrap, every row's gitlink is asked before any row's checkout, and `--no-members` prints the case-variant move, so a clone of a holder whose HEAD lost `.gitmodules` or of #363's state no longer ends `family bootstrap ok` or hides the variant behind `member-uninitialized` (#648)

(a) `templates/family-root/scripts/bootstrap.py` said "every member and
leg fetched" and ended `family bootstrap ok`, exit 0, over members it had
not checked out: in a fresh recursive clone of a holder whose HEAD lost
`.gitmodules` (git exits 0), and in a plain clone of that holder or of
#363's state, where git dies `No url found for submodule path` (exit 128).
After the fetch it now asks this holder's own records of each row whose
member is not checked out: no gitlink at the row's path
(`recorded_gitlink`, as `member-gitlink-absent` reads it), or no section
of `.gitmodules` giving that path, or, where git exited 0, any such row.
Each is named on one `NOT CHECKED OUT, and not for want of a credential`
line with why, which names `validate-family.py`, and the run ends
`family bootstrap FAILED: <P> (not checked out)`, exit 1. "every member
and leg fetched" is said only where git exited 0 and every row is checked
out. A row git could not fetch for want of a credential is still the skip
it was (`test_bootstrap_degrades_when_a_member_cannot_be_fetched`), and a
good holder's run prints what it printed.

(b) `validate-family.py` asked each row whole, gitlink then checkout,
before the next. In a plain clone of #363's state plus `git submodule
update --init --recursive`, which dies at `Members/<P>` before it checks
out any member, an earlier row was refused `member-uninitialized` (naming
that same update) and the later row's variant refusal was never reached.
`_check_member` now stops at the gitlink and returns the member
(`Gitlinked`); `_check_member_checkout` asks the digest and identity of
each once every row's gitlink has been asked.

(c) Under `--no-members`, `member-gitlink-unnamed` for a member's only
gitlink, in the wrong case, printed no command. Where the row's path is
its canonical one and its pin a commit, it now prints the index move the
row's members-mode refusal prints, word for word (`_wrong_case_move`,
shared by both), to the pinned commit, every variant on one line.

Docs: the validator's WHAT IS CHECKED docstring (the order; the move
under --no-members) and bootstrap's (a) and (c). README.md's "A member it
cannot fetch is reported and skipped, never a failure" is no longer true,
so it says "One it lacks a credential for is reported and skipped, not a
failure", in the same line count (README is at its 1449-line cap), and
docs/handbook.html says the same, with its § Families marker set to the
segment's new blob.

Pinned copies changed: templates/family-root/scripts/bootstrap.py and
templates/family-root/scripts/validate-family.py.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tstrap-and-gate-order, as lane 5's coordinator asked (#648 x #611)

This merges origin/fix/611-validate-family-head-gitmodules at
08b6245, #639's head after its own merge
of main 95780dd (#637's squash, #607), keeping that merge's one hand
resolution: `member-gitlink-unnamed`'s hand-mount remedy ends with #611's
commit-the-removal clause and then `BRANCH_LEFT`.

No file conflicted. The remedy #648 adds under `--no-members`
(`_wrong_case_move`) names no `_switch_line`, so #637's guard asks no
`BRANCH_LEFT` of it. README.md's § Families segment, which #648 edits, is
the same in both parents, so docs/handbook.html's marker for it stays the
blob #648 set.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…x/648-bootstrap-and-gate-order, before main takes #639's squash (#648 x #611)

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…der, keeping this tree: main's tree a25f083 is #639's final head 159a08f's, merged in the commit before (#648 x #611)

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… or an ignored file in the way is refused, P9 names the branch it left, a HEAD outside `refs/heads/` is left on every path, and no git is `git-not-runnable`, so `make bootstrap` no longer ends a merge, overwrites a person's file or ends in a traceback (#704) (#694)

#704, measured on lane 5's #682 head 8b39cd1 with git 2.43.0:

- Line 1. The four moves that change files (P9's checkout of the branch at
  the pin from another branch, T7's checkout after `branch -f`, the
  on-branch `merge --ff-only` and the missing branch's `checkout -b`)
  overwrote an IGNORED file at a path the pin tracks, silently. Each now
  carries `--no-overwrite-ignore`, so git refuses it as it refuses an
  untracked file, in its own words. The missing-branch path now prints
  git's lines too, through `_say_refused`, and says where the leg stays
  (`leaving it on <branch>`), where it said `leaving detached` of a leg on
  a branch.
- Line 2 (Brett Heap's R1). A checkout ENDED a merge, a cherry-pick or a
  revert in progress (head file gone, staged result carried onto the
  branch) from another branch, from HEAD detached at the pin and from HEAD
  already on the branch at the pin, and put a stopped rebase or `am` on
  the branch. `checkout_tracking_branch` now reads the leg's git dir
  (`rev-parse --git-dir`) for `rebase-apply/applying`, `rebase-apply`,
  `rebase-merge`, `MERGE_HEAD`, `CHERRY_PICK_HEAD` and `REVERT_HEAD`, in
  that order, and refuses before any path is chosen: it names the
  operation, says nothing moved, and prints `git -C <leg> <op> --continue`
  and `--abort`.
- Line 3. `place_at_pin` calls `_say_left` when HEAD was on another
  branch, as `fast_forward_to_pin` does.
- Line 4 (R2). `templates/assembly-root/AGENTS-shape.md` now says bootstrap
  places a leg at the pin ONLY where that loses no commit, and otherwise
  leaves its checkout where it is, saying why. The code is unchanged.
- Line 5 (the issue's recommended answer). A HEAD that is a symbolic ref
  outside `refs/heads/` is left by `place_at_pin` (`which is neither`, as
  T7 already said) and by the missing-branch path (`which is not a
  branch`), since a commit made there lands on that ref.
- Line 7. The family root's docstring paragraph (b) says the member puts
  its legs on their tracking branches at their pins where that loses no
  commit, and says why where it does not.

The missing-branch path moves into its own function, `create_at_pin`, so
`checkout_tracking_branch` stays within Sonar's cognitive-complexity limit.

#694: with no git on PATH both roots ended in a `FileNotFoundError`
traceback, exit 1. The assembly root's step (a) (per leg) and its offline
upstream notice now run git inside `_git_runs`, and the family root's
step (a) inside a guard around `git submodule update`; each refuses
`git-not-runnable`, the validators' code with their first remedy sentence,
and exits 2. The run stops there: every later step runs git too. Which
`OSError` is git's is read by NAME, as `validate-pins.py` reads it.

Tests: tests/test_bootstrap_leg_moves.py (lines 1, 2, 5, every path the
issue names), tests/test_bootstrap_without_git.py (#694, both roots), and
tests/test_bootstrap_after_bump.py's P9 tests now read the line naming the
branch left.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…member THE LOCKSTEP RULE rides on `Gitlinked`, so the two-pass gate (#648) passes it to the digest finding (#648 x #610)

One file conflicted, `templates/family-root/scripts/validate-family.py`,
in two regions, both where this branch split `_check_member` in two
(the gitlink first, the checkout once every row's gitlink is asked) and
#634 (#610) made THE LOCKSTEP RULE a per-member `_lockstep(...)` value
passed to the gitlink and tree checks:

1. `_check_member`'s gitlink call. #634 computes
   `rule = _lockstep(root, project, str(row.get("id") or project))`
   and passes it to `_check_member_gitlink`; this branch returns a
   `Gitlinked` there. Resolved: the call passes `rule`, as #634 has it,
   and `Gitlinked` gains a sixth field, `rule`, which the return
   carries.
2. `_check_member`'s tail, which this branch moved into
   `_check_member_checkout`. #634 passes `rule` to `_check_member_tree`.
   Resolved: `_check_member_checkout` passes `asked.rule`.

So each member's gitlink-mismatch and digest-mismatch findings end at
the same rule #634 gives them, naming that member, and the closing
count's `_lockstep(root)` is #634's, merged clean. `Gitlinked`'s
docstring names the new field. No other file conflicted. The
resolution is the one rv648 built and tested in its scratch clone
(the same blob of #634's file, 519b71f), plus that docstring.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n gives a url fails the run naming every row, and a member git exited 0 over is given the update that checks it out, so a stray case variant, a hand mount or a url-less section no longer ends `family bootstrap ok` (#648, rv648)

The fix round for rv648's review of 8b39cd1 (1 MUST, 2 SHOULD, 5 NIT).

M1. In a plain clone, `git submodule update --init` dies in its init
step at the first gitlink, in index order, that no section of
`.gitmodules` gives a url, and checks out no member. `unfetchable` asked
only each row's own gitlink and section, so a stray `Members/<P>`
beside `members/<P>` (#361's STRAY), a member mounted by hand with no
section, or a row's section that lost its url (rv648's S1, H1 and U1)
passed every row as the credential skip, and the run ended ok.

`init_stop` now asks the holder's records which gitlink git's init
stops at: the index's gitlinks in order, each against the work tree's
sections (`gitmodules_sections`, which replaces `gitmodules_paths`) and
the git config. Measured before it was taken, rv648's prototype failed
three states that are a missing credential: a recursive clone of the
stray or of the hand mount with no credential, and a url-less section
an earlier `git submodule init` registered. So two of git's rules are
kept: where the config sets `submodule.active` (a recursive clone sets
`.`), init asks only the gitlinks a section gives, and a section with no
url does not stop it where the config holds a url under its name. Where
git exited non-zero and init stops at a gitlink, every row not checked
out is named `git stopped at <gitlink>, a gitlink no section of
.gitmodules gives a url`, and the run fails.

What cures it: `validate-family.py --no-members` names a gitlink no row
names and the commit that cures it (members checked, it refuses
`member-uninitialized` for the first row in these states, a checklist
candidate). Where a row names the gitlink, nothing in the validator does,
so the line says to write that section back as `family.py add` writes
one. Each why now carries its own sentence.

S1. For "git exited 0 and checked nothing out there" (a subset clone, an
`update = none`), the line printed the validator sentence, whose
`member-uninitialized` remedy exits 0 and checks nothing out. It prints
`git submodule update --init --checkout --recursive -- <path>` instead;
typed, it checks the member out in both states and the run ends ok.

S2. `test_a_missing_credential_is_still_the_skip_it_was` asserts that
"  every member and leg fetched" is not printed (rv648's mutant R2).

NITs. README and the handbook say `make bootstrap` skips a member it
lacks a credential for, and the § Families marker is the new segment's
blob, b3d19092; README stays at 1459 lines, its cap. The docstrings say
"an empty mapping" and "a missing credential exits non-zero too", and
`validate-family.py`'s WHAT IS CHECKED says the move under
`--no-members` is printed only for a canonical path and a 40-hex pin.

Tests: the variant-plain case and the round trip name MEMBER too; new
tests for S1 and H1 (typed through `--no-members` and committed, the run
checks both out), U1 (the section written back), the two recursive
clones with no credential and the registered url (still the skip), and
the update that exits 0, by `update = none` and by a subset clone, with
its printed line typed.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed by hand is refused as in progress, the missing-branch path names the branch it left and judges `checkout -b` by where HEAD is, and HEAD's ref is read as bytes, so rv704's S1-S3 no longer happen (#704)

The fix round for rv704's review of #745 at 4e2b211. Every change is in
templates/assembly-root/scripts/bootstrap.py and
tests/test_bootstrap_leg_moves.py.

- S1. When `git cherry-pick A B` or `git revert A B` stops and the person
  commits the stop by hand, the head file is gone and only
  `sequencer/todo` is left. Bootstrap moved the leg onto `main`, and the
  `--continue` that `git status` names then committed B there.
  `_in_progress` now asks `sequencer/todo` after the `REVERT_HEAD` row.
  The todo's first word picks the label: `revert` is a revert, anything
  else is a cherry-pick. Docstring (a) and `_say_in_progress` say so.
- S2. `create_at_pin` prints `_say_left` under `created` when HEAD was on
  another branch, as P9 and T7 do.
- S3. `create_at_pin` judges `checkout -b` by where HEAD is afterwards, as
  `_check_out` does (#533). Under a post-checkout hook that fails, it now
  says `created`, with git's words after it. It used to print `could not
  create ...` and `nothing else is tried` over a leg that was on `main`
  at the pin.
- The S3 check reads HEAD back right after `checkout -b`. `_head_of` read
  `symbolic-ref` through `git_out`, which decodes strict UTF-8, so a
  branch whose name is not UTF-8 (`--branch` a name holding U+DCFF) would
  end the first run in a `UnicodeDecodeError`. On base the second run
  already ended that way, in `place_at_pin`. `_head_of` now reads the ref
  as bytes and decodes it with `os.fsdecode`.
- N1. Docstring (d) now names where the exit is 2: step (a) and the
  upstream notice.
- N2. The composite assert in the rebase helper is now two asserts.

New tests: a sequence of each kind whose stop was committed by hand (the
refusal, an unchanged snapshot, then the typed `--continue` finishing it
on `other`), the failing hook from detached and from `other`, and a
non-UTF-8 branch run twice. The ignored-file test now asserts the line
saying the leg left `other`, on every path.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing in #414's url-less member state now fails, as 1a69505 makes it, so the test pins exit 1 and `git stopped at members/Ghost` where it pinned the old `ok` (#648, rv648's U1)

`test_414_3_there_the_bootstrap_cures_nothing_and_the_git_line_cures_one`
builds rv648's U1 state: a member, Ghost, whose `.gitmodules` section
gives its path and no url. Git's init stops the holder's update there,
before it checks out the other member. The test asserted bootstrap's
exit 0, the false `ok` rv648's M1 asked to end. It asserts exit 1 now,
and the line naming where git stopped. Its point is unchanged: the
bootstrap cures nothing, and the doctor's git line cures the other
member.

Found by the targeted run of every test file that drives the family
bootstrap (1 failed, 3797 passed, 32 skipped); the file now passes
alone, 124 passed.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h main 2fac979) into fix/704-bootstrap-leg-moves: the family root's bootstrap.py keeps #694's `git_not_started` above #682's `cures` and `gitmodules_sections` (#704 x #648)

One conflict region, in templates/family-root/scripts/bootstrap.py, where
both sides inserted a function above `def gitmodules_paths`.
- This branch inserted `git_not_started` (#694).
- #682 inserted `cures` and renamed the function to `gitmodules_sections`,
  with a new docstring.

The resolution keeps `git_not_started`, then #682's `cures` and its
`gitmodules_sections` header and docstring, and drops the old
`gitmodules_paths` header. It is byte for byte rv704's modelled
resolution (`family-bootstrap-resolved.py`, e1322312).

Everything else merged clean. The merged tree differs from 45c512e only
in this branch's six files.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ember git has no url for is its own reason, and a gitlink outside members/ gets a cure true for it, so a later duplicate section, a section under a name git ignores or a url git drops no longer ends `family bootstrap ok`, and a subset clone's printed update no longer dies `No url found` (#648, rv648's verify)

rv648's verify of 45c512e (1 MUST, 1 SHOULD, 3 NIT).

M1. `init_stop` read `.gitmodules` with `git config --file --list`, which
keeps entries git's submodule parser drops or overrides. In three plain
clones git's init died (`No url found`, exit 128) and the run still said
ok: a second section for a member's path, with no url, after its own
(D1); the member's section under the name `../Nova` (Q1); and its url
`-nowhere` (O1). `gitmodules_sections` now returns a `Gitmodules` with
git 2.43's own reading beside the one the validator's findings use:

- a section whose name is empty or has a `..` component, split on `/`
  and on a backslash, is ignored whole (`check_submodule_name`);
- a `path` or `url` that begins with `-` is dropped, and the value
  before it stands (`looks_like_command_line_option`);
- where two sections give one path, the later decides, and a section
  that gives a path again lets go of the one it gave, even where a later
  section gave that one too (`cache_put_path`, `cache_remove_path`).

`has_url` asks the section git reads for a path, then the config under
that section's name. `init_stop` and the reasons ask it. Where more than
one section gives the path git has no url for, the line names
`validate-family.py --no-members`, whose `gitmodules-path-twice` says to
take the others out: a section written back would be one more (D1).

S1. In a subset clone of a member whose section has no url (A1), git
exits 0 and the line printed the exit-0 skip's update, which dies `No
url found` when typed. "git has no url for that path" is now its own
reason, asked before "git exited 0", with the write-it-back cure. The
stop's sentence says "a gitlink it has no url for", which is true in D1
too, where a section does give the path a url.

NIT 1. A gitlink outside members/ that git stopped at (H2) was sent to
`--no-members`, which reads only members/ and says `family ok` there. It
now gets `git rm --cached -- <path>` and a commit: measured, that ends
ok and the gate passes in every mode, while a section written for it
leaves `gitmodules-path-unnamed` in every mode.

NIT 2. The `cures` docstring says what members mode refuses for #363's
variant (`member-gitlink-absent`, with the move) as well as for a stray
or a hand mount (`member-uninitialized`).

Tests in tests/test_family_bootstrap_gate_order.py: U1, Q1 (three names)
and O1 in one parametrized test, each written back; D1, typed through
`gitmodules-path-twice`; D2, a name with dots and a url `-x` after a
url, each still the credential's skip and then a true ok; A1, typed
through both rounds; and H2, typed and the gate passing in every mode.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er) into fix/704-bootstrap-leg-moves: the family root's bootstrap.py takes `.gitmodules` as git's own parser reads it, a url-less member as its own reason and a cure true for a gitlink outside members/, with this branch's six files kept (#704 x #648)

23e1105 is one commit on 45c512e, which this branch already holds. It
touches templates/family-root/scripts/bootstrap.py and
tests/test_family_bootstrap_gate_order.py only. `git merge` took it
with no conflict, and `git merge-tree --write-tree` had said the same
before the merge. tests/test_family_bootstrap_gate_order.py still has
`import re`.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ts own reason, since git reads no section from it, so a plain clone mid-merge whose update dies `No url found` no longer ends `family bootstrap ok` (#648, rv648's MM1)

rv648's verify of 23e1105 (1 MUST). In a plain clone, a merge that
conflicts in `.gitmodules`, resolved in the work tree and not added,
leaves the index holding the file only at its three conflict stages.
Git then reads no `.gitmodules` at all (`repo_read_gitmodules` asks
`is_gitmodules_unmerged` first), so its init dies at the first gitlink,
exit 128. `gitmodules_sections` read the work tree's file, found every
section with its url, and the run ended ok.

`gitmodules_sections` now asks `git ls-files -u -z -- .gitmodules`
first. Where the index holds the file at a conflict stage, it returns
nothing, with `Gitmodules.unmerged` set. `unfetchable` asks that first:
every row not checked out gets "git reads no .gitmodules while a merge
leaves it unmerged", and the line says to resolve the merge in
.gitmodules and `git add .gitmodules`, then run this again. Added, the
same run checks both members out.

Test: rv648's MM1 in tests/test_family_bootstrap_gate_order.py, the two
sides of the merge each giving OTHER's section another `branch`; the
cure typed, and the run ends ok.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r) into fix/704-bootstrap-leg-moves: the family root's bootstrap.py takes a `.gitmodules` a merge left unmerged in the index as its own reason, with this branch's six files kept (#704 x #648)

f74f640 is one commit on 23e1105, which this branch already holds. It
touches templates/family-root/scripts/bootstrap.py and
tests/test_family_bootstrap_gate_order.py only. `git merge` took it
with no conflict, and `git merge-tree --write-tree` had said the same
before the merge. tests/test_family_bootstrap_gate_order.py still has
`import re`.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… no answer and a reftable leg's cherry-pick or revert is read from its ref store, so neither ends in a traceback or an ended pick; docs/cli.md says #704's condition (#764)

rv704's checklist candidates C1 to C4 on #745, filed as #764. Stacked on
#745 (fix/704-bootstrap-leg-moves, f4935e1). The assembly root's
bootstrap.py is a pinned copy, already re-pinned in this shape bump.

- C1. `shape_upstream_notice` with `SHAPE_UPSTREAM_PATH` set to a directory
  this process may not enter ended `make bootstrap` in a PermissionError
  traceback, exit 1: `subprocess` raised it for the `cwd` of `git rev-parse
  HEAD`, naming the directory, which `_git_runs` passes on as not git's,
  and for a directory under one, Python 3.12's `Path.is_dir` raised it.
  `_clone_tip` now catches OSError around both reads and returns no
  answer, so the cache is asked next, as for a missing clone. A git that
  cannot be started is still `git-not-runnable`, exit 2: that is a
  Refusal, which `except OSError` does not catch.
- The same notice, under the issue's goal that `make bootstrap` never ends
  in a traceback over it: a `.shape-upstream-tip` nobody may read
  (PermissionError) or whose bytes are not UTF-8 (UnicodeDecodeError) is
  no answer (`_cached_tip`); so is a `contracts/` nobody may enter, at the
  notice's own `is_file`, and a `contracts/shape-pin.yaml` that is a list
  (AttributeError), which `validate-pins.py` has refused by then.
- C4. Measured with git 2.47.1: on a reftable repository a stopped
  cherry-pick and a stopped revert leave no CHERRY_PICK_HEAD or
  REVERT_HEAD file in the git dir; each is in the ref store. A stopped
  merge leaves MERGE_HEAD a file. Reproduced on f4935e1 with that git: a
  scaffold cloned with GIT_DEFAULT_REF_FORMAT=reftable, a cherry-pick (or
  revert) stopped on `other` in the spec leg, then bootstrap, which moved
  the leg onto `main` and ended the operation. `_in_progress` now asks the
  ref store for those two markers too, by the exact name (`git show-ref
  --exists`, git 2.43 and later), so a branch or a tag of that name is no
  operation, and the refusal says `is in its ref store` where the marker
  is there. A git before 2.43 refuses the option and is answered no; it
  has no reftable either.
- C3. The generated `docs/cli.md` row for the assembly root's bootstrap.py
  takes R2's condition from the pinned AGENTS-shape.md ("only where that
  loses no commit, and otherwise leaves its checkout where it is, saying
  why"), through `scripts/render-cli-reference.py`. The three pinned
  sentences that say "or says why not" (the Makefile's help, the family
  holder's AGENTS-shape.md and README) are true as they stand and left,
  as R2 left them, so no project or holder takes another
  `upstream-changed` row for them.
- C2 (a validator's exit-2 refusal reported as `bootstrap FAILED: ...
  (exit 2)`, exit 1) needs a ruling and is measured, not changed.

New tests: tests/test_bootstrap_upstream_notice.py (the notice's reads,
end to end and, for `contracts/`, in process),
tests/test_bootstrap_ref_store.py (a reftable clone, skipped where git
makes none, and a branch and a tag named as markers on every git) and
tests/test_bootstrap_placement_texts.py (the reference row against R2's
sentence).

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-17T11:28Z) @codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-17T16:32:52.969820Z f6f318d Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens bootstrap's upstream notice against unreadable inputs, prevents cherry-pick or revert operations from being accidentally ended on reftable legs by detecting ref-store markers, and updates the generated CLI documentation to describe conditional leg placement.

Sequence diagram for resilient upstream notice lookup

sequenceDiagram
    participant Bootstrap as bootstrap.py
    participant Clone as SHAPE_UPSTREAM_PATH
    participant Cache as .shape-upstream-tip
    participant Notice as Upstream notice

    Bootstrap->>Clone: _clone_tip()
    alt clone is unreadable
        Clone-->>Bootstrap: OSError
        Bootstrap->>Cache: _cached_tip()
    else clone is readable
        Clone-->>Bootstrap: git rev-parse HEAD
    end
    alt cache is unreadable or invalid
        Cache-->>Bootstrap: None
        Bootstrap-->>Notice: no notice, bootstrap continues
    else valid tip is available
        Cache-->>Bootstrap: commit tip
        Bootstrap-->>Notice: compare tip with pinned commit
    end
Loading

Sequence diagram for reftable operation detection

sequenceDiagram
    participant Bootstrap as bootstrap.py
    participant Leg as Leg repository
    participant GitDir as Leg git dir
    participant RefStore as Leg ref store

    Bootstrap->>Leg: _in_progress()
    Bootstrap->>GitDir: Check CHERRY_PICK_HEAD or REVERT_HEAD
    alt marker is a file
        GitDir-->>Bootstrap: operation marker
        Bootstrap-->>Leg: _say_in_progress(..., git dir)
    else marker is not a file
        Bootstrap->>RefStore: _in_ref_store(name)
        RefStore-->>Bootstrap: git show-ref --exists name
        Bootstrap-->>Leg: _say_in_progress(..., ref store)
    end
    Leg-->>Bootstrap: leave checkout unchanged
Loading

Flow diagram for conditional bootstrap leg placement

flowchart TD
    Start([Bootstrap leg]) --> Detect{Operation in progress?}
    Detect -->|Yes| Refuse[Report marker location and leave checkout unchanged]
    Detect -->|No| Loss{Would placement lose a commit?}
    Loss -->|Yes| Explain[Leave checkout where it is and say why]
    Loss -->|No| Place[Place leg on tracking branch at pinned commit]
    Refuse --> End([Bootstrap completes])
    Explain --> End
    Place --> End
Loading

File-Level Changes

Change Details Files
Make the upstream notice fail-safe when configured paths or cached data cannot be read.
  • Handle filesystem errors while checking or reading the upstream clone, cache, and pin file.
  • Treat unreadable, invalid, or non-mapping pin data as an absent notice answer while preserving refusal handling for an unavailable Git executable.
  • Fall back from an unreadable clone to the cache.
templates/assembly-root/scripts/bootstrap.py
tests/test_bootstrap_upstream_notice.py
Detect cherry-pick and revert operations stored as refs on reftable repositories and refuse to move affected legs.
  • Probe CHERRY_PICK_HEAD and REVERT_HEAD with exact-name ref-store checks in addition to git-directory marker files.
  • Track and report whether the operation marker is in the git directory or ref store.
  • Add coverage for reftable operations, exact-name branch/tag collisions, unchanged snapshots, and post-abort placement.
templates/assembly-root/scripts/bootstrap.py
tests/test_bootstrap_ref_store.py
Align the CLI reference with bootstrap's conditional placement behavior.
  • Update the CLI reference generator to state that placement occurs only where it loses no commit.
  • Regenerate docs/cli.md and test the generated wording against the pinned AGENTS-shape.md condition.
scripts/render-cli-reference.py
docs/cli.md
tests/test_bootstrap_placement_texts.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 271710bae0

ℹ️ 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".

Brett Heap and others added 2 commits October 17, 2026 11:41
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	templates/family-root/scripts/bootstrap.py
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-17T13:02:17Z) Brett Heap ruled on #764's line C2, in lane 5's session at 2026-10-17T13:01:10Z: "take B for #764's C2".

  • A validator's exit 2 (a refusal) now prints bootstrap REFUSED: <name> (exit 2), and bootstrap.py exits 2.
  • Findings (exit 1) are unchanged.
  • Where one validator finds and another refuses, both lines are printed and bootstrap exits 2.

Lane 5's writer w764b is adding this to #815 as one new commit, with tests and the body's C2 section updated. Reviewers: please review from the head that commit makes.

…name> (exit 2)`, exit 2, and a finding still ends it `bootstrap FAILED`, exit 1, so a script or a setup run that reads the code can tell a refusal from a finding (#764)

#764 line C2, option B, on Brett Heap's ruling of 2026-10-17T13:01:10Z:
"take B for #764's C2".

A validator that cannot check (`validate-pins.py` in a plain clone, which
refuses `pin-submodule-uninitialized`) exits 2, and bootstrap said
`bootstrap FAILED: pins (exit 2)` and exited 1, the code of a finding. Every
other refusal in the file exits 2.

- A validator's exit 2 now prints `bootstrap REFUSED: <name> (exit 2)` and
  bootstrap exits 2.
- Any other non-zero exit is a finding and stays `bootstrap FAILED: <name>
  (exit <code>)`, exit 1.
- One validator that finds and another that refuses: both lines, the
  findings first, and exit 2, since a check that could not run outranks a
  finding.

`_run_neutral_validators` returns each failed validator's label and exit
code, and the new `_end_of_run` says them and returns the exit. The step (d)
paragraph of the module docstring says what the ending does.

`make bootstrap` exits 2 for any failing recipe, so only its `Error 1` line
becomes `Error 2`. `setup-project.py` step (7) and `siblings.py` print or
pass on the code bootstrap gave, and no text there named exit 1.

`tests/test_bootstrap_refusal_exit.py` runs bootstrap in a plain clone (a
refusal: REFUSED, exit 2), in a recursive one with a manifest finding
(FAILED, exit 1, as before) and in a plain one with both (both lines, exit
2), once through `make bootstrap`, and asks `_end_of_run` of nine mixes of
codes, an exit 3 and an exit 127 included.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-17T18:17:36Z) RV815's re-check of #815's NIT round (head 05a1ebb3): READY, nothing left. Every NIT lane 5 took is done as asked. The only shipped change is the flush and step (d)'s docstring. The core set passes on both gits, every merge is clean, and leaving NIT 1 out is acceptable. The head did not move during the re-check.

The first review's SHOULDs are done:

The taken NITs:

NIT 1 left out: acceptable.

  • The one differing row, measured on the new code: a plain clone, no git on PATH, a commit in the cache, and SHAPE_UPSTREAM_PATH at mode 000. It prints the notice from the cache, then bootstrap REFUSED: pins (exit 2), and exits 2, exactly as a missing directory does.
  • Why keep it: the notice needs no git to learn that a clone it cannot enter is no answer, so treating it like a missing path is what the docstring promises ("an absent answer is silent"). The exit is 2 either way, because validate-pins.py has already refused git-not-runnable in that state.
  • With NIT 1, that row would swap the cache's answer for a second git-not-runnable refusal, which is true but tells the person nothing new.
  • The Windows concern behind NIT 1 was reasoned, never measured, and main behaves the same. It belongs on the checklist.

Nothing else changed: f6f318d1..05a1ebb3 is one commit, touching bootstrap.py (the (d) docstring and the flush only), tests/test_bootstrap_refusal_exit.py and tests/test_bootstrap_upstream_notice.py.

Runs (stub gh first on PATH, GH_TOKEN=bogus, empty GH_CONFIG_DIR):

tree git result
the two touched files 2.43 26 passed
core 2.43 689 passed, 3 skipped
core 2.47.1 127 passed
head + main bad05d21 2.43 132 passed, 3 skipped; render-cli-reference.py --check gives [ok]

Mutants: X3 is killed (2), and F1, the flush removed, is killed (2).

Merges: clean against main bad05d21, #824 0a5e82bf, #823 153bab90, and main + #824.

CI at 05a1ebb3: SonarCloud, package and tests-git-bash pass; Linux tests was pending.

Lane: openreposhape-3 (openRepoShape-3)

brettheap added a commit that referenced this pull request Oct 17, 2026
…t stopped in a holder whose refs are a reftable, asking its ref store for `CHERRY_PICK_HEAD` and `REVERT_HEAD` by their exact names where no file holds them (#684) (#846)

R612 E4, the last item of #684 (#816 landed the other five). Lane 5's probe, on git 2.47.1 with `git init --ref-format=reftable`: a stopped cherry-pick and a stopped revert keep `CHERRY_PICK_HEAD` and `REVERT_HEAD` in the ref store and in no file, and `MERGE_HEAD`, `rebase-merge/` and `sequencer/todo` stay files. `_operation_in_progress` asked for files alone, so `add`, `bump` and `remove` found neither and went on to their commit.

- `_in_ref_store` asks `git show-ref --exists <mark>`, the exact name, in the `CHERRY_PICK_HEAD` and `REVERT_HEAD` rows, where no file is there.
  - Exit 0 is held, and 2 is not.
  - 129 is not held either: that is a git before 2.43, which cannot open a reftable at all (measured on 2.43 and 2.40.4).
  - Any other exit is a failed git command, exit 2, before the first write. It is never read as "nothing in progress".
- The refusal, and `yaml-unparsable`'s remediation, say git keeps the mark "in its ref store, not as a file". A mark that is a file is named at its path, byte for byte as before. Every file check stays. `MERGE_HEAD` is a file on both backends, so its file is the whole answer.
- `tests/test_family_operation_reftable.py` has 18 cases:
  - a stand-in git holds the refusal under both runs, the 129 answer and git's own failure (this host's git, 2.43, makes no reftable);
  - the exact-name rows (a branch `CHERRY_PICK_HEAD` and a tag `REVERT_HEAD`) and the order rows run on every git;
  - the stand-in rows and the order rows check that the ask runs in the holder (rv846's M10, which asked in `Path.cwd()`, is killed on git 2.43);
  - four real-reftable rows skip, saying why, where `git init --ref-format=reftable` fails.
- Mirrors #815's `_in_ref_store` in the assembly root's `bootstrap.py`, without importing it.

Closes #684

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…hild is told the encoding its words are read in, so a cp1252 stdout gets one encoding from setup, and `make bootstrap` passes a project whose name the code page cannot hold (#851)

`setup-project.py`'s `main` reconfigures its stdout and stderr to UTF-8,
and `run` started every child with no `env=`, so a Python child took its
streams' encoding from its own environment. Under
`PYTHONIOENCODING=cp1252:strict` or `cp1252:surrogateescape` (Windows'
ANSI code page on a stdout that is not the console), a `--yes
--local-remote-dir` run exited 0 with 9 of its 129 stdout lines in cp1252
among UTF-8 ones, and the plan run again to read why it was refused, the
one captured child, wrote cp1252 into a pipe `run` decodes as UTF-8. The
assembly root's `scripts/bootstrap.py` started the validators the same
way, so on a project whose `name:` the code page cannot hold,
`validate-manifest.py` raised `UnicodeEncodeError` and `make bootstrap`
ended `bootstrap FAILED: manifest (exit 1)`: #254's defect, in a pinned
copy.

- `setup-project.py`: `child_environment(capture)`, #254's rule as
  `scripts/shape_materialize.py` writes it in
  `inherited_child_environment` and `captured_child_environment`. `run`
  passes it as `env=`, so all six Python children get it, and the next
  one will: `self_bootstrap`, `check_names`, `plan_and_confirm`,
  `why_the_plan_did_not_go_through` (the captured one), `scaffold` and
  `clone_and_bootstrap`. It holds a copy because it runs before any
  checkout of the standard exists.
- `templates/assembly-root/scripts/bootstrap.py`:
  `inherited_child_environment()`, a copy of the library's, which `_run`
  passes. A project does not carry `shape_materialize.py`, and a pinned
  script that imported a new name out of `repo_shape.py` would be refused
  at `update-shape.py apply` (`update-accept-local-missing-name`, #536),
  so the copy keeps `repo_shape.py` untouched. This is a shape bump:
  every project re-pins the copy.
- `tests/test_validator_child_encoding.py`: the `setup-project.py` row
  leaves `NOT_YET`, and `LAUNCHERS` gains the assembly `bootstrap.py`.
  `COPIES` holds both copies to the library's answer for every stream
  `INHERITED` names. `run` is shown to tell each child what it is read
  in, both tools run end to end under cp1252, and `INHERITING` pins that
  `siblings.py`'s child inherits an environment its code page agrees
  with: measured, it needs no change.

It follows lane 5's #808 (#759), landed as 677f9bf, whose `NOT_YET`
row this takes out, and lane 4's #824 (#710), landed as 0dee22b,
which also edits the assembly `bootstrap.py`. Lane 5's #815 (#764)
edits it too; the two merge clean, and whichever lands second re-gates.

Closes #836
Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…e a Windows editor saved with a byte-order mark reads as the text it holds, so the upstream notice names the tip, a marked `project.yaml` is no longer `schema_version is None`, and every reader of a marked pin, the doctor's included, reads it alike (#855) (#868)

Issue #855 has two lines, filed by lane 5 from rv815's NIT 8 on #815.
Both are measured on main 9f235da by loading the shipped scripts and
calling them, and by running them in a scaffold.

Line 1. `shape_upstream_notice` read `.shape-upstream-tip` with
`read_text(encoding="utf-8").strip()`. `strip()` keeps U+FEFF, so a cache
holding EF BB BF and a commit, with LF or CRLF, did not match `COMMIT_40`
and the notice said nothing. The cache is read as `utf-8-sig` now, and
each of the three says `shape: the upstream is at bbbbbbbbbbbb and this
project is pinned at aaaaaaaaaaaa.`

Line 2. `load_yaml` read the mark as the first character of the first key
(U+FEFF and `commit`), and refused a file whose first line is a comment:
`line 1: expected `key: value`, got '\ufeff# a comment'`. In a scaffold a
marked `project.yaml` was `FINDING manifest-schema-version: schema_version
is None, expected 1`, `make bootstrap` ended `bootstrap FAILED: manifest
(exit 1)`, the doctor said `INVALID (manifest)`, and `update-shape.py
apply` re-pinned and rolled back (`REFUSED update-validators-red`).
`parse_yaml` takes off every byte-order mark the text starts with now, as
`shape_advisory.py` already did, and `load_yaml` still decodes as `utf-8`,
so its not-UTF-8 refusal still counts the byte from the file's first byte.
The same scaffold is `manifest ok`, `pins ok`, `bootstrap ok` with the
notice, `COMPLIANT`, and `apply` re-pins and keeps the mark in both files.

WHY THE PARSER, NOT `load_yaml` (the coordinator's ruling, as first given,
was `utf-8-sig` at both reads, on the condition that every reader agrees).
Five readers hand `parse_yaml` a pin's text without `load_yaml`: the
doctor's `pin_at`, `update-shape.py`'s `_committed_pin` and
`_accepted_in_the_pin`, `adopt-project.py`'s `_pinned_shape_paths`, and
`shape_materialize.pin_values_in`. With the mark taken off in `load_yaml`
alone, a committed pin a person saved with the mark above a comment line,
re-pinned by `apply` and read by every validator, was one `pin_at` read as
broken; with the working pin then broken, the doctor's NEXT was `git
checkout '<the hand-saved commit>~1' -- contracts/shape-pin.yaml`: the
pin from before the hand-save, naming the commit `apply` had moved it
off. Taken off in the parser, the NEXT is `checkout HEAD --
contracts/shape-pin.yaml`.

tests/test_bom_reads.py: both lines in each shape the issue gives, with no
mark, one and two; the four readers above against `load_yaml`; the
advisory against `load_yaml`; a marked scaffold bootstrapping ok; the
not-UTF-8 byte counted from the file's start; and the doctor's NEXT after
a re-pin of a hand-saved pin. Round 1 (RV868's SHOULD-1) pins the strip to the
start of the text: a mark inside a quoted value, at the start of a value
or at the start of a later key stays text, as PyYAML reads it, and the
notice says nothing of a pin whose commit holds one. A mutant that took
off every mark anywhere survived 749 tests before it, and is killed now.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…n as it reads the file fails `make bootstrap` naming the key, and the family gate names it in every mode, so `update = None` no longer ends `family bootstrap ok` (#768) (#819)

Written on #682 (`fix/648-bootstrap-and-gate-order`, `f74f640c`), which landed as `dc7d3fbb` while this was written. The commit is `fa69ead9`. The branch then merged #682's final head `5afb24b9` and main, taking its own side for #682's files: those files are the same at `5afb24b9` and `dc7d3fbb`, and main changed none of them after the squash. The head is `c12b5f8a`, and its diff against main is this PR's four files.

**Pinned copies.** This changes the family root's `templates/family-root/scripts/bootstrap.py` and `templates/family-root/scripts/validate-family.py`. Each holder sees them as `upstream-changed` on its next `update-shape.py check`, and they are re-pinned in the next shape bump; this PR re-pins nothing. No test pins either file's bytes or digest. README, the handbook, `AGENTS.md` and `docs/cli.md` are unchanged: no `--help` text changes, and no sentence in them is made false.

CI's run 39031235254 (git 2.55.0) failed one row of the new test file, and lane 2's review rv819 of `c12b5f8a` (NOT READY) found one MUST, four SHOULDs and five NITs. This commit answers each of them. It changes `templates/family-root/scripts/bootstrap.py` and `templates/family-root/scripts/validate-family.py`, both pinned and both already changed by this PR, and `tests/test_family_gitmodules_values.py`. No other pinned file changes.

### What git does with a `branch` that has no value

Measured on git 2.43.0 (the host's) and 2.47.1 (a local build). The probe adds a `branch` line with no value after a submodule's url, commits it, then runs `git submodule update --init --recursive` in a plain clone:

| `.gitmodules` read from | git 2.43.0 | git 2.47.1 |
|---|---|---|
| the work tree's file | crash, exit 139 (`Segmentation fault`) | `error: missing value`, `fatal: bad config line 4 in file`, exit 128 |
| the index's (work tree file lost) | crash, exit 139 | `error: bad config line 4 in blob`; it reads on, checks the submodule out, exit 0 |
| HEAD's (index entry lost too) | crash, exit 139 | the same as the index's |

A `url` with no value gives 2.47.1's answers on both gits. `branch =`, `branch = .` and `branch = -x` are taken by both gits from every source (exit 0), as both tools read them. CI's log shows git 2.55.0 giving 2.47.1's answer for the work tree's file.

From git's source, `submodule-config.c`: the `branch` case has `if (!value) ret = config_error_nonbool(var);` at v2.43.1 and v2.44.0. v2.43.0, v2.42.4 and v2.39.5 go straight to `xstrdup(value)`, which crashes.

### Each finding

1. **MUST: the row pinned git 2.43's crash. Fixed.** Reproduced on git 2.47.1 at `c12b5f8a`: `assert 128 == 139`, as on CI (1 failed, 27 passed). The table's exit column is gone. The premise is now that git's update died and checked nothing out; every other assertion is about the tools' own output. The two `shutil.Error` setup errors in that run are #645's copy race, which #713 fixed on main.
2. **SHOULD: the index and HEAD rule fixed `branch` to git 2.43's crash. Fixed, by measurement.** On 2.47.1 at `c12b5f8a`, with the work tree's file lost and no credential, bootstrap said `NOT CHECKED OUT, and not for want of a credential: ... git cannot read .gitmodules`, right under git's own `bad config line 7 in blob`, and failed. New `crashes_on_branch` reads `git version`. Before 2.43.1, or where it cannot read a version, the key is refused as before. From 2.43.1 on, the line ends the reading of a blob, as a `url` with no value does (`ends_blob_reading`). The gate needs no change: it reads the work tree's file and HEAD's, which a clone checks out as its work tree's file, and every git dies on the key there. The comments that said "git crashes" now say which git does.
3. **SHOULD: with every member checked out, a `.gitmodules` git cannot parse ended `family bootstrap ok`. Fixed.** `Gitmodules` now carries `unreadable`. It is set where the work tree's file is one `git config --list` cannot parse, which git dies reading. With every member checked out, the run prints `NO MEMBER UPDATED, and not for want of a credential: git cannot parse .gitmodules. Mend .gitmodules by hand (`git config -f .gitmodules --list` says where it breaks), and commit it.` and fails on `.gitmodules (git cannot read it)`, as it does for a refused value. A row left not checked out now gets that reason too, where before it was "no section of .gitmodules gives that path". Tested with a conflict marker and with an open quote. A blob is not marked: see the checklist candidates.
4. **SHOULD: mutants survived. Each now fails a test:**
   - Reading HEAD's file in place of the index's: `test_the_index_s_file_is_read_where_it_differs_from_head_s` stages `update = None` without committing it.
   - `update` without `merge` in bootstrap, or without `rebase` in the gate: `update = rebase` and `update = merge` are now in `TAKEN`.
   - The stop-reading rule applied inside ignored sections: `test_a_key_with_no_value_in_a_section_git_ignores_reads_on`.
   - `pattern_names` matching a prefix without the `/`: a new `a-string-prefix` row, with two `submodule.active` patterns, `members/IRRS` and `members/IRS`.
   - `active` reading the first value: OTHER's own `active` is now set twice, `true` then `false`.
   - `_written_back` keeping a url that begins with `-`: a new `url-after-a-dashed-one` row.
   - The `<url>` placeholder: `test_a_url_no_row_gives_one_for_is_written_back_as_a_placeholder`.
   - `_submodule_entries` without the backslash rule: the ignored-section test now runs on `..\x` too.
   - `if refused_here:` made `if refused:`: `test_a_value_head_s_file_alone_gives_leaves_the_work_tree_s_read`.
5. **SHOULD: cognitive complexity. Split.** These are estimates from an AST count of Sonar's rules. At `c12b5f8a` it gives the same figures rv819 gave.
   - `gitmodules_sections`: 33 → 5. Its parts are `gitmodules_source` (8), `gitmodules_entries` (9), `ends_blob_reading` (2) and `read_sections` (13).
   - `unfetchable`: 16 → 10, with `row_reason` (8).
   - `cures`: 24 → 22. It was 23 on main, so this PR no longer raises it; its `.gitmodules` sentences moved to `gitmodules_cures` (3).
   - `_check_gitmodules_values`: 22 → 10, with `_value_said` (2) and `_value_taken_out` (5).
   - `fetch_members`: 10 → 13.
6. **NIT: duplicated test code. Done in this file.** The gate-in-three-modes block is now `found_in_every_mode_and_typed`, and the good-run block is `assert_a_good_holder_s_run`. The 4-line `EXITED_0` assertion it shares with `test_family_bootstrap_gate_order.py` stays: it is shorter than a duplicated block, and sharing it means editing a file #682 landed.
7. **NIT: the rules are written twice. Left.** One copy in `repo_shape.py` would change the module every root imports, and every pinned copy of it, which is more than a fix round should carry. Filed below as a candidate.
8. **NIT: a false sentence in the HEAD-only case. Fixed.** Where HEAD's file alone gives the value, the finding now ends "and git dies reading a file that does, so in a clone neither `git submodule update` nor make bootstrap checks out any member."
9. **NIT: the person's own config could change the boolean answer. Fixed.** Measured: with `probe.value = notabool` in a global config, in `GIT_CONFIG_COUNT`, or given with `-c`, the old probe dies (`bad boolean config value 'notabool'`). Both tools now ask `git config --file <null device> --type=bool --default=<value> --get probe.value`, which reads no config scope (measured on 2.43.0 and 2.47.1). New test: `test_a_probe_value_in_the_person_s_own_config_changes_no_answer`.
10. **NIT: a count in this body. Corrected here.** Under "Fail on base", seven of the 28 tests pass on the base, not six. The seventh is the control `[a-leading-directory]`. The totals, 24 failed and 18 passed, were right.

### Tests

`tests/test_family_gitmodules_values.py` now has 52 tests, up from 28. Every run used `run9.sh`: a `gh` stub, a bogus token and an empty `GH_CONFIG_DIR`.

| run | tree | git | result |
|---|---|---|---|
| the file | e1379a9's | 2.47.1 | `52 passed in 67.32s` |
| the file, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_pinned_copy_text.py`, `test_bootstrap_spelling.py`, `test_validate_family_without_git.py`, `test_family_bootstrap_gate_order.py`, `test_family_head_lost_gitmodules.py` | e1379a9's | 2.43.0 | `573 passed in 252.33s` |
| wide half A: 26 files that load or run the family root's `bootstrap.py` or `validate-family.py`, this file among them | e1379a9's but for three docstrings and one byte match in a test | 2.43.0 | `1474 passed, 22 skipped, 1 xfailed in 1321.42s` |
| wide half B: 29 more such files, the three above among them | the same | 2.43.0 | `1933 passed, 1 skipped in 1097.64s` |
| the file, `test_family_bootstrap_gate_order.py`, `test_bootstrap_after_bump.py`, `test_family_head_lost_gitmodules.py`, `test_repo_hygiene.py` | the predicted merge with main `64ec47c3` | 2.43.0 | `250 passed in 229.25s` |

The wide set is the 49 files w768 found with `git grep`, plus 6 more that name the family root's `bootstrap.py` (`test_family_siblings_placement.py`, `test_park_resume_targets.py`, `test_pins_child_kind_and_siblings_root_spelling.py`, `test_readme_windows_quoting_sentence.py`, `test_setup_project_py.py`, `test_windows_paths.py`). Both halves ran in a clone with that tree committed, since the `update-shape` tests fail on a work tree whose copies are not at HEAD.

### Fails on base

The new file was run against `c12b5f8a`'s scripts:
- on git 2.47.1: 18 failed, 34 passed (`r2-base247.log`);
- on git 2.43.0: 16 failed, 36 passed (`r2-base243.log`).

The tests that fail there:
- the three unparseable-file tests;
- the two blob `branch` tests (2.47.1 only);
- the nine `git version` rows;
- the three HEAD-only texts;
- the probe test.

The tests added for finding 4 pass on the base, as they should: each catches a mutant, not a defect of the base. The original file fails on git 2.47.1 at `c12b5f8a` with CI's assertion: 1 failed, 27 passed.

### Mutants

Each mutant was applied alone to the final tree and caught (`mutants2.log`):

| id | change | caught by |
|---|---|---|
| M1 | a blob's `branch` never ends the reading | blob `branch` tests, git 2.47.1 (2.43.0 passes, as it should) |
| M2 | a blob's `branch` always ends the reading | blob `branch` tests, git 2.43.0 (2.47.1 passes, as it should) |
| M3 | `BRANCH_READ_SINCE` is (2, 44, 0) | `which_git[2.43.1]` |
| M4 | `<` made `<=` | `which_git[2.43.1]` |
| M5 | an unreadable version taken for one that does not crash | `which_git[unread]` |
| M6 | the version read with two numbers | `which_git[2.43.1]` |
| M7 | `unreadable` never set | the three unparseable-file tests |
| M9 | `fetch_members` asks `refused` alone | the every-member unparseable tests |
| M10 | no mend sentence | the three unparseable-file tests |
| M11 | `row_reason` without `unreadable` | the per-row unparseable test |
| M12 | bootstrap's old boolean probe | the probe test |
| M13 | a blob never ends the reading | the `url` index test and the blob `branch` tests, git 2.47.1 |
| M14 | ignored sections asked for refused values | the ignored-section tests |
| M15 | HEAD's file read in place of the index's | the staged-index test |
| M16 | the stop rule inside ignored sections | the ignored-section blob test |
| M17 | `pattern_names` matches a bare prefix | `[a-string-prefix]` |
| M18 | `active` reads the first value | the `active` test |
| M19 | bootstrap's `update` without `merge` | `TAKEN[update-merge]` |
| V1 | the HEAD-only sentence never used | the three HEAD-only texts |
| V2 | the gate's old boolean probe | the probe test |
| V3 | `_written_back` keeps a url that begins with `-` | `[url-after-a-dashed-one]` |
| V4 | no `<url>` placeholder | the placeholder test |
| V5 | `_submodule_entries` without the backslash rule | `[backslash]` |
| V6 | `if refused:` for `if refused_here:` | the HEAD-only `gitmodules-path-twice` test |
| V7 | the gate's `update` without `rebase` | `TAKEN[update-rebase]` |

### Merge checks

`git merge-tree --write-tree` of this head with:
- main `64ec47c3`: clean, and its tests pass (see Tests);
- #747 `ff7c59b8`: clean;
- #822 `ea86cb60`: clean;
- #815 `f6f318d1`: clean;
- #820 `9e2dc7d9`: conflicts in `validate-family.py`, in the same three regions as before. The conflict blocks are byte for byte the ones `c12b5f8a` gives, so the resolution rv819 checked still applies.

### Checklist candidates

- **A blob git cannot fully parse is read as no section at all.** Measured on 2.43.0 and 2.47.1:
  - Where the work tree has no `.gitmodules` and the index's file holds a bad line, git reads the sections before that line, says `error: bad config line 4 in blob`, and checks the members out.
  - `git config --blob <oid> --list` prints those entries but exits 128. So `gitmodules_sections` reads nothing.
  - With no credential, every row is then named "no section of .gitmodules gives that path" and the run fails, where the members are only the credential's.
  - This came with #648's blob reading. This round leaves it as it was.
- **One copy of the `.gitmodules` value rules** (rv819's NIT 7). Bootstrap's `NO_VALUE_REFUSED`, `UPDATE_VALUES`, `refused_value`, `git_bool` and `TAKES` and the gate's `_values_refused`, `_git_takes_bool` and `GIT_TAKES` could live in `repo_shape.py`, so a change to git's rules is made once.

Git 2.43 dies reading a family holder's `.gitmodules` before `git submodule update` checks out any member when a section gives:

- a `path`, `url`, `update`, `ignore` or `branch` with no value (`bad config line`; a `branch` with none crashes git, exit 139);
- an `update` other than `none`, `checkout`, `rebase` or `merge`, a `!command` included (`invalid value`);
- a `shallow` that is no boolean (`bad boolean config value`);
- a `fetchRecurseSubmodules` that is neither a boolean nor `on-demand`.

The holder's bootstrap listed the file with `git config --list`, which reads all of these, and ended `family bootstrap ok`, exit 0. The gate refused `member-uninitialized` members checked and under `--pins`, naming the update that dies, and said `family ok` under `--no-members`.

### Line 1: bootstrap

`gitmodules_sections` checks each `submodule.*` entry against those rules (`refused_value`). Where one would make git die, it returns those entries and no sections, as it returns `unmerged`, and `unfetchable` asks that second, after `unmerged`. Every row not checked out gets one reason:

```
  NOT CHECKED OUT, and not for want of a credential: members/Atlas (git cannot read .gitmodules: `submodule.members/Nova.update` is `None`), members/Nova (git cannot read .gitmodules: `submodule.members/Nova.update` is `None`). Give `submodule.members/Nova.update` a value git takes, one of `none`, `checkout`, `rebase` or `merge`, or take that line out, and commit .gitmodules. `python3 scripts/validate-family.py --no-members` prints the commands.
family bootstrap FAILED: Atlas (not checked out), Nova (not checked out)
```

A key with no value says "has no value". As for every other reason, the holder's records decide, never git's stderr.

**Where every member is checked out**, as in the holder itself, git dies before it updates any of them, so no row is left to name, and the run used to say ok over git's `fatal:` line. It now prints `NO MEMBER UPDATED, and not for want of a credential: git cannot read .gitmodules: ...` with the same cure, and fails on `.gitmodules (git cannot read it)`. That is my reading of the goal, "so neither one says ok over a file git refuses". The issue's direction named only the rows not checked out.

### Line 2: the gate

A new finding, `gitmodules-value-refused`, for each such key in the work tree's `.gitmodules` and in HEAD's (HEAD's as `member-gitmodules-absent` reads it). It is asked before any member check, so `--no-members` makes it too. Where either file gives such a value, a member that is not checked out is not refused `member-uninitialized`: git checked none out, and the finding fails the run, exit 1, in all three modes. The remedy prints one command to a line, as `gitmodules-path-unnamed` does, says which values git takes, and ends with the commit:

```
FINDING gitmodules-value-refused: .gitmodules gives `submodule.members/Nova.update` the value `None` here and in HEAD, which a clone reads, and git dies reading a file that does, before `git submodule update` checks out any member, so neither that nor make bootstrap checks one out. Git takes one of `none`, `checkout`, `rebase` or `merge` for that key. Take the key out by hand, in a POSIX shell, then stage and commit .gitmodules:
    git config -f .gitmodules --unset-all submodule.members/Nova.update
```

Decisions:

- **A member's `path` or `url` is written back.** `--unset-all` takes out every line of the key. For `update`, `ignore`, `shallow`, `fetchRecurseSubmodules` and `branch`, git reads a section without one, so that is the cure. In the issue's `url` state, Nova's section has its url and one more `url` line with no value, and `--unset-all` alone would leave no url (git: `No url found`). So for a member's own section the finding writes the key back on a second line: the value the section last gave that git takes, or, where it gave none, what `family.py add` writes (the section's name for `path`, `https://github.com/<repository>.git` for `url`, `<url>` where the row's `repository:` is not `<org>/<Name>`).
- **Which file gives it.** The finding says "here and in HEAD", "in the work tree, not in HEAD" (nothing to commit), or "in HEAD, not in the work tree" (it says to stage and commit the work tree's file, and prints no command). Where the work tree has no `.gitmodules`, the first such finding prints `git checkout HEAD -- .gitmodules` first, and only the first: a second would put back the key the first took out.
- **The other `.gitmodules` checks wait.** Where the work tree's file gives such a value, `gitmodules-path-twice` and `gitmodules-path-unnamed` read nothing from it, as git reads nothing. Without that, a `path` with no value was read as the empty path, and `gitmodules-path-unnamed` printed `--unset-all` for every key of the member's own section (measured on `f74f640c`, probe c8). Those checks run again once the file is mended.
- **A boolean is git's own.** `shallow` and `fetchRecurseSubmodules` are read by `git -c probe.value=<value> config --type=bool --get probe.value`, which uses the same `git_parse_maybe_bool` the submodule parser does. That covers `0x10`, `010`, `1k` and the int range (`2097152k` and `-2097152k` die, `2097151k` does not) with no copy of git's number parsing.

### Line 3: what #682 left approximate

- **Where git reads the file from.** Where the work tree has no `.gitmodules`, `gitmodules_sections` reads the index's stage-0 blob, then `HEAD:.gitmodules`, with `git config --blob`, as `config_from_gitmodules` does. The unmerged check now uses `git ls-files -s`, which gives both the conflict stages and the stage-0 blob. One difference from a file is measured and kept: in a blob, a key with no value is an error, not a die. Git stops reading at that line and goes on, so `gitmodules_sections` stops there too (probe b2: the last line, git exits 0 and checks both out; b2b: the first section, git dies `No url found` at the second).
- **`submodule.<name>.active`.** `submodule_config` now returns each key's values. Where `submodule.active` is set, `init_stop` asks the section's own `active` first, its last value read as a boolean, as `is_tree_submodule_active` does.
- **`submodule.active`'s patterns.** A plain path pattern, as `git clone --recurse-submodules=<path>` writes one, names that path and what is under it, and `.` names every path. A leading `./` and a trailing `/` are read past. A pattern with pathspec magic (a leading `:`), a glob character or a backslash, or an empty, `.` or `..` component, keeps the old reading: it names every path.

Measured with git 2.43.0 in plain clones of a holder built by this branch's `scaffold-project.py` and `family.py` (Atlas and Nova), each line added to Nova's section and committed (`p1.sh`; 32 states). On `f74f640c` every state git's update dies on ended `family bootstrap ok` with `--no-members` saying `family ok`; on this head, bootstrap fails and the gate finds `gitmodules-value-refused` in all three modes on exactly the states where git's update exits non-zero, and on none of the others:

| added to Nova's section | git's update | `f74f640c` bootstrap / `--no-members` | this head |
|---|---|---|---|
| `update = None`, `update = !true`, `update = Merge` | 128, `invalid value` | ok / `family ok` | exit 1 / finding |
| `url`, `update`, `ignore` (no value) | 128, `bad config line 7` | ok / `family ok` | exit 1 / finding |
| `path` (no value) | 128, `bad config line 7` | exit 1, wrong reasons / `gitmodules-path-unnamed` for the path "" | exit 1 / finding |
| `branch` (no value) | 139, `Segmentation fault` | ok / `family ok` | exit 1 / finding |
| `shallow = maybe`, `08`, `3000000000`, `2097152k`, `-2097152k`, `-2097153k` | 128, `bad boolean config value` | ok / `family ok` | exit 1 / finding |
| `fetchRecurseSubmodules = bogus`, `ON-DEMAND` | 128, `bad ... argument` | ok / `family ok` | exit 1 / finding |
| `update = none` | 0, Nova skipped | exit-0 skip | the same exit-0 skip, no finding |
| `update = checkout`, `ignore = bogus`, `shallow` (no value), `shallow = true`, `""`, `" 1"`, `TRUE`, `1 `, `0x10`, `010`, `1k`, `2097151k`, `fetchRecurseSubmodules` (no value), `= on-demand`, `= 0` | 0 | ok / `family ok` | ok / `family ok` |

`p2.sh` (index and HEAD fallback, `active`), on `f74f640c` then this head:

| state | git | `f74f640c` | this head |
|---|---|---|---|
| work tree's file deleted, not staged, no credential | reads the index's; fails on the fetch | exit 1, "no section of .gitmodules gives that path" | the credential's skip, ok |
| the index's taken out too | reads HEAD's; fails on the fetch | exit 1, the same | the credential's skip, ok |
| `update = None` committed, work tree's file deleted | 128, dies on the index's blob | exit 1, "no section" | exit 1, "git cannot read .gitmodules" |
| Nova's url taken out, subset `members/Atlas`, no credential | inits Atlas alone; fails on its fetch | Atlas "git stopped at members/Nova" | Atlas the credential's; Nova "git has no url" |
| the same with `submodule.active=.` and `submodule.members/Nova.active=false` | inits Atlas alone; fails on its fetch | Atlas "git stopped at members/Nova" | Atlas the credential's; Nova "git has no url" |

A section under a name git ignores whole (`../x`) with `update = None` is ignored by git (exit 0, a warning) and by both tools (`p3.sh`).

`tests/test_family_gitmodules_values.py`, new, beside #682's `tests/test_family_bootstrap_gate_order.py`, whose helpers it imports (28 tests):

- `test_a_value_git_dies_on_fails_bootstrap_and_every_mode_of_the_gate`, one case for each row of the issue's table, `update = None` first, and `branch` with no value: git's update exits 128 (139 for `branch`) and checks nothing out; bootstrap exits 1 with its last two stderr lines exact; the gate finds the whole finding in all three modes, exit 1; its commands are typed as printed and committed; HEAD's `.gitmodules` is then byte for byte the one `family.py add` wrote; the same bootstrap is a good holder's, and the gate passes in every mode.
- No misfire: `update = checkout`, `shallow = true`, `fetchRecurseSubmodules = on-demand`, `ignore = bogus` (a good holder's run and gate); `update = none` (the exit-0 skip as before, no finding); a section git ignores whole.
- Every member checked out: the holder itself fails on `.gitmodules`, and mended as the gate prints it, passes.
- The index's file and HEAD's (no credential: the skip, ok); a key with no value at the end of the index's file (read past, the skip); `update = None` in the index's file (named, and the gate's `git checkout HEAD -- .gitmodules` first); two keys there (one checkout line).
- `submodule.members/<P>.active=false` with `submodule.active=.`; the subset's pattern as `members/<P>`, `./members/<P>/` and `members`.
- Which file gives it (HEAD's alone; the work tree's alone); a `url` or `path` left with no value, written back as `family.py add` writes it.

**Fail on base.** The file run against `f74f640c`'s two scripts: 21 of 28 fail, and the three changed cases of `tests/test_validate_family_without_git.py` fail too: 24 failed, 18 passed (`base1.log`). The six that pass there are the no-misfire cases, which must pass on both.

**Runs on this head** (Linux, git 2.43.0; through `run9.sh`, the real `gh` unreachable):

- Every test file that loads or runs the family root's `bootstrap.py` or `validate-family.py`, found with `git grep` (49 files, the issue's Verify set, `test_family.py` whole, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py` and `tests/test_pinned_copy_text.py` among them), in two halves on `fa69ead9` with a clean tree: `1309 passed, 22 skipped in 1431.96s` (`fa.log`) and `1644 passed, 1 skipped in 1258.67s` (`fb.log`).
- `render-cli-reference.py --check`: `[ok] docs/cli.md is what the generator produces`.

**One existing test changed.** `tests/test_validate_family_without_git.py::test_with_no_gitmodules_the_gitlink_listing_is_named` pins which check meets a missing git first in a holder whose work tree lost `.gitmodules`. That is now the reading of HEAD's `.gitmodules` for a value git dies on, so the `git-not-runnable` refusal says "HEAD's .gitmodules could not be read as git reads it" where it named the gitlink listing. Exit 2, the code and the remedy are as they were. No open branch edits that file.

**A dirty-tree artifact, not a failure.** Run on the uncommitted tree first, `tests/test_update_shape.py`, `tests/test_update_shape_header_comments.py` and `tests/test_update_shape_pr_base.py` failed (`update-conflict`: the copy is not the template at the pinned commit), because their fixtures pin HEAD while the copies came from the edited templates. On a committed tree they pass: `171 passed` (`c1.log`), and in the halves above.

**Mutants.** One for each hunk that changes behaviour, each run against the new file's tests that name it:

30 mutants, all killed (`mutants.log`, `mutants-2.log`). Three survived the first round; each showed a test that did not reach the hunk, and the test was made to reach it:

| file | hunk | mutant | killed by |
|---|---|---|---|
| bootstrap.py | `NO_VALUE_REFUSED` | `branch` dropped | `[branch-no-value]` |
| bootstrap.py | `fetch_members` | the every-member-checked-out branch never taken | `test_a_holder_with_every_member_checked_out_fails_on_it_too` |
| bootstrap.py | `reason` | "has no value" reworded | `[url-no-value]` |
| bootstrap.py | `cures` | the refused sentence dropped | `[update-None]` |
| bootstrap.py | `gitmodules_sections`, source | the work tree's file always read | the index's and HEAD's tests |
| bootstrap.py | `gitmodules_sections`, blob stop | no `break` | `test_a_key_with_no_value_ends_git_s_reading_of_the_index_s_file` |
| bootstrap.py | `gitmodules_sections`, refused | never recorded | `[update-None]` |
| bootstrap.py | `gitmodules_sections`, ignored sections | asked for refused values | `test_a_value_in_a_section_git_ignores_whole_is_no_finding` (survived until it ran bootstrap with no credential first) |
| bootstrap.py | `refused_value`, `update` | every value taken | `[update-None]` |
| bootstrap.py | `refused_value`, `on-demand` | not exempt | `[fetch-on-demand]` (survived until it ran with no credential first) |
| bootstrap.py | `git_bool` | every value a boolean | `[shallow-maybe]` |
| bootstrap.py | `submodule_config` | values dropped | the `active` and pattern tests |
| bootstrap.py | `init_stop` | the old condition | the `active` and pattern tests |
| bootstrap.py | `active` | own key not read | `test_a_member_whose_own_active_is_false_is_not_where_git_stops` |
| bootstrap.py | `pattern_names` | every pattern names every path | `[a-path]` |
| bootstrap.py | `unfetchable`, read with no row left | not read | `test_a_holder_with_every_member_checked_out_fails_on_it_too` |
| bootstrap.py | `unfetchable`, the reason | not given | `[update-None]` |
| validate-family.py | `_gitmodules_listed` | returns nothing | `[update-None]` |
| validate-family.py | `_head_listed` | HEAD's listing empty | `[head-only]` (the first, badly written mutant still read it) |
| validate-family.py | `GIT_TAKES` | `update`'s values reworded | `[update-None]` |
| validate-family.py | HEAD-only branch | never taken | `[head-only]` |
| validate-family.py | `git checkout HEAD -- .gitmodules` | never printed | `test_a_value_git_dies_on_in_the_index_s_file_is_named_and_mended` |
| validate-family.py | the checkout printed once | printed for each key | `test_two_keys_with_no_gitmodules_in_the_work_tree_put_head_s_back_once` |
| validate-family.py | the commit clause | always said | `[work-tree-only]` |
| validate-family.py | `_submodule_entries` | ignored sections kept | `test_a_value_in_a_section_git_ignores_whole_is_no_finding` |
| validate-family.py | `_values_refused` | `branch` dropped | `[branch-no-value]` |
| validate-family.py | `_git_takes_bool` | every value a boolean | `[shallow-maybe]` |
| validate-family.py | `_written_back` | never writes back | `[url-no-value]` |
| validate-family.py | `_check_members`, blanking | sections read all the same | `[path]` of the written-back test (`gitmodules-path-unnamed` for the path "") |
| validate-family.py | `_check_members`, skipped checkouts | `member-uninitialized` again | `[update-None]` (members mode exits 2) |

`git merge-tree --write-tree` of `fa69ead9`:

| against | result |
|---|---|
| `origin/main` `dc7d3fbb` | conflicts in both scripts, because #682 is squashed into main; with `--merge-base=f74f640c`, clean |
| #682's last head `5afb24b9` (`fix/648-bootstrap-and-gate-order`, deleted on merge) | clean |
| #747 `fix/681-validate-family-remedies` `58d6c50f` | clean |
| #745 `fix/704-bootstrap-leg-moves` `f4935e11` | clean (a first draft conflicted twice with it, in `fetch_members`' docstring and where it adds `git_not_started`; both are moved out of its way) |
| w764 `fix/764-bootstrap-notice-exit-texts` `271710ba` | clean |
| w761 `bbb961f8`, w757 `6b5e9074`, w758 `b6e15591`, w759 `052410b6`, w760 `7569ffa8`, w777 `854f6ed9` | clean |
| w762 `fix/762-validate-family-remedies-texts` `1038d7fa` (local) | **conflicts, three regions in `validate-family.py`**: the EXIT CODES sentence (each adds its exit-1 findings), the end of `_check_members`' docstring (each adds a paragraph), and the `_check_member_checkout` call (this adds `set(gitlinks) if refused else unregistered`, w762 adds `stop`). Each resolves by keeping both: `_check_member_checkout(root, asked, report, set(gitlinks) if refused else unregistered, stop)` |

The other `fix/*` branches that touch these files and conflict (`fix/539-...`, `fix/607-...`, `fix/610-...`, `fix/bootstrap-print-name-escaped`, `fix/family-add-ghost-section`, `fix/family-bump-remove-rollback`, `fix/pin-files-not-a-list`, `fix/validate-family-member-duplicate`, `fix/validate-family-unnamed-mounts`) are old branches whose PRs landed long ago; `fix/611-...` is clean. No `l3-*` branch touches them.

- Values in `siblings.py`'s `submodule_urls` (lane 4's `fix/690-siblings-inputs`), which the issue leaves out.
- A `submodule.<name>.active` that is no boolean makes git die (`bad boolean config value`); `init_stop` reads past it to the patterns.
- A blob `.gitmodules` with a config syntax error: git reads the entries before the bad line; `gitmodules_sections` reads none, as before.

1. **The credential advice prints where the line says no credential would help.** Whenever git's update exits non-zero, `fetch_members` prints "If the members are PRIVATE, set SHAPE_LEGS_TOKEN ... then re-run", and then, for a reason found in the holder's records (this PR's `.gitmodules` value, and #648's unmerged file, missing gitlink, missing section, missing url and stopped init), the line "NOT CHECKED OUT, and not for want of a credential". The first sends a person to a token that cannot help. Measured in every refused state above.
2. **`_sections_listed` reads a key with no value as the empty string.** In HEAD's file, Nova's `path` with no value reads as the path "", so `member-gitmodules-absent` also fires for Nova beside `gitmodules-value-refused`. Its line writes the same path the new finding writes back, so typing both is harmless, but it is a second finding for one fault (the `[path]` case of the written-back test pins it as it is). In the work tree's file the new blanking keeps it out.

Review: lane 2's independent review (issuecomment-6236559204) and its re-verification of e1379a9, READY (issuecomment-6237963983). `233a334e` is its merge round after #820 (`e32ed55a`, both sides kept, `stop` only where `not refused`, `as_read` for `_check_removals_staged`) with a regression test; lane 2's re-check found it READY (#819 issuecomment-6244287619). CI (Brett Heap's ruling 27): H0 is the head 233a334 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base b9b7069; and the local gate passed on its predicted squash tree caf6706 over main fe99b8b: the adopt_execute_refusals, adopt_filter_repo_failure, adopt_github_exits, adopt_late_failure, adopt_leg_keys, adopt_pins, adopt_split_branch, adopt_temp_work_roots, family_gitmodules_values, gh_guard, pin_annotated_tag, pin_source_top_level, template_values_single_pass, validate_family_without_git, repo_hygiene, agents_md_codes, cli_reference sets, 1025 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #768

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ip reads .shape-upstream-tip as utf-8-sig, after #855

The one conflict was in shape_upstream_notice of templates/assembly-root/scripts/bootstrap.py: main's inline read of the cache, now `utf-8-sig` (#855), against this branch's `_cached_tip(root)`. The call stays, and `_cached_tip` reads `utf-8-sig`, with #855's comment and a docstring that says the mark is taken off.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

Merge round of #815 (#764) with main: 05a1ebb3..61aed8d2.

The merge commit is 61aed8d2. Its parents are 05a1ebb3 (this branch) and origin/main c5359dfc. It was pushed as a plain fast-forward, and refs/heads/fix/764-bootstrap-notice-exit-texts and refs/pull/815/head both read 61aed8d2 now. Nothing was amended. The merge adds no file to the PR: against c5359dfc the diff is still the seven files #815 changed (docs/cli.md, scripts/render-cli-reference.py, templates/assembly-root/scripts/bootstrap.py, and four test files).

The conflict and how it was resolved

git merge --no-ff origin/main stopped in one file, templates/assembly-root/scripts/bootstrap.py, in one hunk of shape_upstream_notice, under if tip is None:.

The call stays, so the three reads of the answer keep one home. _cached_tip now reads as utf-8-sig and carries #855's comment. Its docstring says the mark is taken off. Everything else in _cached_tip is as it was: a file that is not there, one this process may not read, or one whose bytes are not UTF-8 (a UTF-8 file with a mark is UTF-8, so utf-8-sig still raises UnicodeDecodeError for the rest) is still no answer.

The hunk as merged:

    tip = _clone_tip(os.environ.get(SHAPE_UPSTREAM_ENV))
    if tip is None:
        tip = _cached_tip(root)
    if not tip or not COMMIT_40.match(tip):
        return

and the helper as merged:

def _cached_tip(root: Path) -> str | None:
    """What `.shape-upstream-tip` holds, less a byte-order mark it starts
    with (#855), or `None` where it cannot be read: not there, a file this
    process may not read, or bytes that are not UTF-8, none of which is a
    commit (#764). The last two ended `make bootstrap` in a `PermissionError`
    or a `UnicodeDecodeError` traceback."""
    cache = root / SHAPE_UPSTREAM_CACHE
    try:
        # `utf-8-sig` takes off the byte-order mark a Windows editor may
        # start the file with (#855). `strip()` keeps U+FEFF, so a marked
        # tip read as no commit and the notice said nothing.
        return (cache.read_text(encoding="utf-8-sig").strip()
                if cache.is_file() else None)
    except (OSError, UnicodeDecodeError):
        return None

git show --cc of the merge shows only these lines (the docstring and the read) as the merge's own work. The longest of them is 77 columns.

The rest of #855's squash, as checked

git show 3059efc1 -- templates/assembly-root/scripts/bootstrap.py scripts/repo_shape.py changes two reads, and no more.

  1. The cache read in bootstrap.py: the conflict above. bootstrap.py: a validator's exit 2 ends bootstrap REFUSED, exit 2, an unreadable upstream path, cache or pin is no answer and a reftable leg's cherry-pick or revert is read from its ref store, so no traceback or ended pick follows (#764) #815 had moved it into _cached_tip and would have kept the plain utf-8 read. Fixed in this merge.
  2. parse_yaml in scripts/repo_shape.py: it now takes a mark off the start of the text, so load_yaml and every reader of a pin read a marked YAML file alike. This one needed no change. bootstrap.py: a validator's exit 2 ends bootstrap REFUSED, exit 2, an unreadable upstream path, cache or pin is no answer and a reftable leg's cherry-pick or revert is read from its ref store, so no traceback or ended pick follows (#764) #815's shape_upstream_notice still reads contracts/shape-pin.yaml through load_yaml (now as load_yaml(pin_path) if pin_path.is_file() else None, inside the same try), so it takes bootstrap.py (the assembly root's) and repo_shape.py: a .shape-upstream-tip or a YAML file saved with a byte-order mark reads as no commit, or as a first key spelled with U+FEFF, so make bootstrap's upstream notice stays silent #855's fix as it stands, and a marked pin still names the commit. test_the_notice_reads_a_marked_shape_pin and test_a_root_whose_hand_saved_files_carry_the_mark_bootstraps_ok pass on the merge.

Every other read in the assembly root's bootstrap.py is one of these three kinds, and none of them is a file a person saves with an editor:

.shape-upstream-tip is read nowhere else in shipped code (git grep over SHAPE_UPSTREAM_CACHE and the file name). #815 duplicated nothing of #855 and routes around nothing else. No other fault of the same kind.

Runs

All through run9.sh (the gh stub first on PATH, a bogus token, an empty GH_CONFIG_DIR), at the merge head 61aed8d2, on a clean tree, never more than two pytest processes. Git 2.43.0 unless it says 2.47.1.

run files result
the brief's four sets, git 2.43.0 11: the 4 files #815 changes, test_bom_reads.py (#855), test_update_shape_kept_helper and test_validator_child_encoding (the other two files that name assembly-root/scripts/bootstrap), the trio (test_repo_hygiene, test_agents_md_codes, test_cli_reference), and test_adopt_attributes_eol_block.py (added, see below) 519 passed, 3 skipped in 108.75s
the same ten without that last file, run before it was added 10 473 passed, 3 skipped in 112.87s
supplement, git 2.43.0 9: test_bootstrap_after_bump, _branch_bytes, _leg_moves, _non_utf8_branch, _spelling, _without_git, test_family_bootstrap_gate_order, test_scaffold_e2e, test_update_shape 563 passed in 344.49s (353.31s on the first run, also 563 passed)
#815's four files and #855's, git 2.47.1 5: test_bootstrap_placement_texts (2), _ref_store (4), _refusal_exit (17), _upstream_notice (9), test_bom_reads (25) 57 passed, none skipped, in 23.51s (23.28s on the first run)

The 3 skips on 2.43.0 are the reftable cases in test_bootstrap_ref_store.py; they run on 2.47.1, where all 4 cases of that file passed. test_bom_reads.py is the file that drives _cached_tip through shape_upstream_notice with a mark.

One thing to know about the first full set: a stray .pyc of my own, left by a compile check of the template, sat under templates/assembly-root/scripts/__pycache__/ and made test_adopt_attributes_eol_block.py fail 7 (its template_values() reads every file under the template as UTF-8). That is not a fault of the merge or of #815. I took the one file and its empty directory away (with Python, no rm) and reran every run above on the clean tree; the counts in the table are the clean-tree ones.

The mutant

_cached_tip read back as encoding="utf-8" (the one line, in the worktree, then git checkout -- put it back; the tree is clean at 61aed8d2). tests/test_bom_reads.py and tests/test_bootstrap_upstream_notice.py on it: 3 failed, 31 passed. The three that fail are all #855's:

  • tests/test_bom_reads.py::test_the_notice_names_the_tip_a_marked_cache_holds[mark]
  • tests/test_bom_reads.py::test_the_notice_names_the_tip_a_marked_cache_holds[mark-and-crlf]
  • tests/test_bom_reads.py::test_a_root_whose_hand_saved_files_carry_the_mark_bootstraps_ok

#815's own nine notice tests pass under the mutant: none of them holds a mark. #855's tests are the ones that pin the mark, and they reach _cached_tip through shape_upstream_notice.

git diff --check, widths and main

The PR body

I did not read the body on GitHub (gh is not mine): not checked live. Two places were read instead.

@brettheap

Copy link
Copy Markdown
Contributor Author

Re-check of #815 (#764)'s merge round at 61aed8d2, for lane openRepoShape-5: READY

I found no MUST and no SHOULD. There are three NITs, all in the PR body. I ran as uid 1000, not root, so the mode-000 cases are real and none was skipped. I changed no branch, pushed nothing and posted nothing.

Findings

NIT 1: live-body-new.md:11 and :91. The commit count leaves out 61aed8d2.

  • Line 11 says the count becomes "three commits after f4935e11 ..., then the restack's two merges".
  • After f4935e11 the first-parent chain is 271710ba, c2977425, 3aa9a0fe (merge), f6f318d1 (merge), 05a1ebb3, 61aed8d2 (merge). That is three merges, not two.
  • Line 91 has the same count and was left unchanged.

NIT 2: live-body-new.md:11. "(61aed8d2, below)" points at nothing.

  • No section below describes the merge round or how the conflict was resolved. Only the NIT 8 note (line 87) and the digest sentence (line 96) name 61aed8d2.
  • Line 9's "main is not merged" is true of the 05a1ebb3 push it describes, but a reader of the final body could take it as current.
  • A one-line pointer to issuecomment-6247909102 would close this.

NIT 3: live-body-new.md:24. The NIT table row still reads | 8: a BOM in the cache | candidate | see the candidates |.

  • Line 87 now says the merge round did it. The table's "taken" cell still says "candidate".

Checked and true

1. The resolution against both sides.

2. Behaviour, measured through shape_upstream_notice.

I ran a probe of 19 cache states against bootstrap.py at the merge, at c5359dfc (main) and at 05a1ebb3. The probe is $Q/v118/rv815m/probe.py and its output is $Q/v118/rv815m/probe.out.

cache merge 61aed8d2 main c5359dfc 05a1ebb3
BOM + commit, LF names the tip names the tip silent
BOM + commit, CRLF names the tip names the tip silent
BOM + commit, no newline names the tip names the tip silent
0xff first byte silent UnicodeDecodeError silent
BOM, then an invalid byte silent UnicodeDecodeError silent
UTF-16 LE or BE with its mark silent UnicodeDecodeError silent
latin-1 byte inside the commit silent UnicodeDecodeError silent
mode 000 holding a marked commit silent PermissionError silent
no cache silent silent silent
BOM only, directory, broken symlink silent silent silent

3. Runs at 61aed8d2, through run9.sh.

run files git result
A #815's four test files plus test_bom_reads.py 2.43.0 54 passed, 3 skipped (the reftable cases)
B test_repo_hygiene, test_agents_md_codes, test_cli_reference 2.43.0 148 passed
C test_bom_reads plus test_bootstrap_upstream_notice 2.47.1 34 passed
D test_bootstrap_ref_store 2.47.1 4 passed, 0 skipped (proof that 2.47.1 was in effect)

4. Main since the merge.

5. The PR body's updated sentences.

6. Sonar-style checks.

  • Cognitive complexity against the limit of 15, from a small SonarSource-rule counter (cogc.py, which gave 9 for shape_upstream_notice, matching my hand count):

    function complexity
    _cached_tip 2
    _clone_tip 3
    shape_upstream_notice 9

    Main's inline version of shape_upstream_notice scored 11.

  • The longest line the merge adds is 77 columns (the git show --cc ++ lines). git diff --check origin/main 61aed8d2 is clean.

  • The PR's diff against origin/main has one added line over 80, the generated docs/cli.md row of 333 columns. It is 333 columns at 271710ba, 05a1ebb3 and 61aed8d2 alike, so the merge did not add it.

Could not verify

  • The body as posted on GitHub. I read only live-body-new.md and its diff against live-body.md (lines 11, 87 and 96).
  • SonarCloud and the Linux CI. This was local only, and the sonarqube MCP server failed to connect. The cognitive-complexity figures are from my own counter.
  • The wide suites m815 reports (519 and 563 passed). I ran the brief's sets plus the predicted-merge runs.

Scratch left

Nothing of mine is running. All four worktrees are clean and detached, under /workspace/projects/openRepoShape-worktrees/_lane5-scratch/:

  • rv815m-head, at 61aed8d2;
  • rv815m-mut1 and rv815m-mut2, at 61aed8d2, their mutant edits reverted with git checkout --;
  • rv815m-pred, at 281df263, its merge aborted.

Basetemps are $Q/v110/bt/rv815m-{A,B,C247,D247,M1,M2,PA,PB}. Files are in $Q/v118/rv815m/: probe.py, probe.out, cogc.py, src-{merge,main,nit}/, probe-roots/, and run*.log. I ran no rm, and I left no __pycache__ under templates/.

I created no refs. The only shared-repo write was fetch origin main, which advanced refs/remotes/origin/main to 281df263.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 18, 2026 11:13
@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

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 3 days and 8 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 201c6f0 into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/764-bootstrap-notice-exit-texts branch October 18, 2026 11:14
brettheap pushed a commit that referenced this pull request Oct 18, 2026
…ith #815, #770, #869 and #802 (#883)

Round 5 of #883, a refresh. Each line comes from its squash body and was
checked against the code at main 0d5d2b1:

- #815 (#764): a project's bootstrap ends a validator's exit 2 as
  `bootstrap REFUSED`, exit 2, takes an upstream path, cache or pin it
  cannot read as no answer, and leaves a reftable leg's cherry-pick or
  revert alone. A line in "What changed", one in "What 1.1.1 fixes", and
  a note under "What a script sees" on the direct run's exit code. Its
  body's leftovers, two tracebacks on Python 3.12 and 3.13 and two
  "failed" texts beneath a REFUSED line, are a new limit.
- #770 (#755) and #869 (#853, #854): tests, in the Tests line. The test
  limit for #853 and #854, and the skip #845 left, which #869 removes, are
  retired.
- #802 (#675): update-shape.py's `git show` lines are shell-quoted and its
  kept-copy readers read some Python as Python does. Its one-hop read and
  what it reads as running are a new limit.

67 pull requests; 69 of the milestone's 72 issues closed at 11:31Z, and
the open list is the 3 that read leaves: #707, #879 and #884. The
shape-copy lists stand: the assembly root's bootstrap.py, which #815
changes, is on them. No other file changes.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…e and the innermost command named, so a member whose make runs another no longer reads `exited 2` for a finding, and `make -k` keeps its `Error` line (#884) (#886)

#884's three lines, from rv876's NIT 2, NIT 3 and NIT 4 on #876 (#876 issuecomment-6246717354), with fix round 1 from rv884's review of this pull request (issuecomment-6248806027). The change is in the family holder's pinned `templates/family-root/scripts/bootstrap.py` and in `tests/test_family_holder_bootstrap_texts.py`, and nothing else.

Base: origin/main `281df263`, where #876 has landed as `41baf920`. Each line was probed there first, under the holder's own `make bootstrap`, with GNU Make 4.3, dash 0.5.12 as `/bin/sh` and git 2.43.0. The probes reuse rv876's `p2/ptyrun.py` and its member Makefiles.

**1. Five mutants of #876's code survived its tests, and one docstring claimed what no test checked.** Tests only; no code changes for this line.
- **R10 and R11** (`credential_advice` ignoring `unreadable` or `refused`): `test_no_advice_beside_no_member_updated_with_every_member_checked_out`. Every member is checked out, as in the holder itself, and HEAD's `.gitmodules` is given `update = None` or a conflict marker. Bootstrap's own stderr lines after the fetch line are asserted exactly: the "NO MEMBER UPDATED, and not for want of a credential" line and the failure, with no advice between them. `tests/test_family_gitmodules_values.py` asks only the last two lines, which is why the mutants passed there.
- **R1** (`named[0]`) **and R8** (the regex anchored on `^make: `): `test_the_line_names_the_code_of_the_member_s_innermost_command[last-under-make]`. `bootstrap.py --no-fetch` runs under a real parent `make -k`, as the holder's `make bootstrap` runs it, so the member's make prints `make[1]:`. One member exits 1. Another has two commands that exit 1 and then 2, and the line must name the last.
- **R7** (`passed_on`'s `sink.flush()` dropped): `test_a_member_s_question_shows_before_its_answer_is_typed`. Bootstrap runs on a pseudo-terminal (its stdin, stdout and stderr). A member recipe asks `Continue? ` on stderr and `read`s the answer. The test types `yes` only once the question has shown, and fails if it has not shown within 10 s. It asserts the output before the answer and after it exactly. Measured on the pty: the question showed at 0.13 s at head; with R7 on `281df263` it showed only at 8.1 s, after the blind answer, when the run ended.
- **The store:** `test_a_git_mv_staged_with_work_in_the_checkout_is_undone_as_printed` now asserts what its docstring said. After every line is typed, the checkout's commit (the new commit, or the pin where only an untracked file was added) is still in `.git/modules/members/IRRS`. A helper, `kept`, is shared with `test_a_checkout_moved_on_at_the_variant_is_moved_with_f`'s own store assert.
- On `281df263`, with rv876's own `mut/mutants.py` unchanged, every one of the five is caught by the test named above, and those tests pass on the unmutated code:

| rv876's mutant on `281df263` | caught by |
|---|---|
| R1 `named[0]` | `[last-under-make]` |
| R7 no `sink.flush()` | `test_a_member_s_question_shows_before_its_answer_is_typed` |
| R8 `^make: ` | `[last-under-make]` |
| R10 advice ignores `unreadable` | `test_no_advice_beside_no_member_updated_with_every_member_checked_out[unparsed]` |
| R11 advice ignores `refused` | `test_no_advice_beside_no_member_updated_with_every_member_checked_out[value-refused]` |

**2. The final line misled in three cases** (`run_make`, `MakeErrors`).
- Measured on `281df263` and on this head, each member under the holder's `make bootstrap` and with `bootstrap.py` run straight (`-k` where the case needs it). The last five rows came from rv884's review:

| member | `281df263` | head |
|---|---|---|
| `$(MAKE) inner`, inner command exits 1 | `exited 2` | `exited 1` |
| `$(MAKE) inner`, inner command exits 2 | `exited 2` | `exited 2` |
| `$(MAKE) nothere`, the inner make's own failure | `exited 2` | make printed no `Error <code>` line for a command of the member's |
| `$(MAKE) -s nothere; exit 3` | `exited 3` | `exited 3` |
| `make -k`: `Error 1`, then 5000 bytes on stderr | make printed no `Error <code>` line (false) | `exited 1` |
| `make -k`: two commands exit 1, then 3 | `exited 3` | `exited 3` |
| a command killed by SIGKILL under dash | `exited 137` | `exited 137` |
| `kill -TERM $$` in a recipe | make printed no `Error <code>` line | the same |
| `$(MAKE) -s nothere \|\| exit 1` | `exited 1` | `exited 1` |
| `$(MAKE) -k inner`, whose second target prints after the inner `Error 1` | `exited 2` | `exited 1` |
| `make -k`: a finding, then a command that `kill -KILL $$` ends | `exited 1` | `exited 1` |
| `-$(MAKE) inner` (inner exits 1), then the recipe's own `exit 2` | `exited 2` | `exited 2` (`82a84f88` read `exited 1`) |
| `printf 'x: *** y' >&2; exit 1`, so make's line follows it on one line | `exited 1` | `exited 1` (`82a84f88` named none) |

- **A recursing member: the line names the innermost command's code.** Why: the inner make exits 2 for any failure, which is the very code #839 set out to see past. The command the member's author wrote, and whose code tells a finding (1) from a refusal (2), is the innermost one.
  - The depth comes from make's own prefix (`make[N]:`, or `make:` for 0).
  - An `Error 2` at a shallower depth than the last `***` line before it, whatever lines come between, is taken for that make's parent reporting it, and is passed over.
  - Where the inner make failed on its own (`No rule to make target`), no command's code is named, and the line is the one for make's own failure.
  - A command that runs a failing make and then fails itself, with 3 or with 1, is still named.
  - Two failures at the same depth under `-k` name the last, as before.
  - Where a `-` let the inner make fail, make prints `[Makefile:N: bootstrap] Error 2 (ignored)`, with no `***`. That line now puts the depth back at the parent's, so the parent's own `Error 2` after it is named. rv884 offered this as an option, to adopt only if it changed no other case. Measured: across rv884's 133 case runs (its `tools/cases1.py` to `cases6.py`, at both depths), head with it differs from `82a84f88` only in that case and in the last-match case below. Across 21 more of mine it differs only in that case, in `-$(MAKE) nothere` then `exit 2`, and in the last-match case, each now as `281df263` read it. An inner make that ignores a failure of its own prints the same kind of line at its own, deeper depth; that line changes nothing (measured: `$(MAKE) inner; exit 2`, where inner ignores an `exit 2`, reads `exited 2`).
- **The last match on a line** (rv884's NIT 1). A member's own text with no newline, `x: *** y`, followed by make's `Error 1` on the same line, named no code at `82a84f88`. `said` now takes the last match on a line (`findall`), and that case reads `exited 1`, as `281df263` read it.
- **Where the line is not the innermost command's code.** This is now written in `MakeErrors`' docstring, not only in this body (rv884's NIT 2):
  - a recipe that runs a make, lets its failure pass in the shell (`|| true`, or `;`), or with a `-` in a make run with `-s` (which prints no `(ignored)` line), and then exits 2 itself: that `Error 2` is read as the inner make's report, and the inner command's code is named. Measured: `$(MAKE) inner || true; ...; exit 2` and `-$(MAKE) inner` under the holder's `make -s` read `exited 1`; `281df263` read `exited 2`;
  - an inner make run with `env -u MAKELEVEL`, which prints its parent's depth: the parent's `Error 2` is named, as on `281df263`;
  - under `-j`, a job of the parent's that fails while the inner make is still running: its line comes between, and the parent's `Error 2` is named, as on `281df263`;
  - a member's own line shaped like a deeper make's (`x[3]: *** y`) before it exits 2 itself: its `Error 2` is passed over.
  - The module docstring's (b) now says the code is the innermost command's "as far as make's lines tell it".
- **More than 4 KiB after the `Error` line:** `run_make` kept the last 4096 bytes of make's stderr and read that tail at the end. `MakeErrors` now reads each line as it passes. It keeps only the last 4096 bytes of a line that no newline has ended yet, so memory stays bounded for a progress meter that writes only `\r`. rv884's `tools/chunks.py`, pointed at this head, fed 174 captured streams to `MakeErrors` in 81,406 chunkings; none read differently from the whole.
- **A signal:** the docstring is now true of rv884's 24 cases (`probe2.log`: dash 0.5.12 and bash 5.2.21; a command run with no shell, one command, `kill $$`, a command then `echo`, a command then `exit $?`, `echo` then a command; SIGKILL and SIGTERM; at both depths).
  - Where the signal kills what make started, make prints its name, `Killed` or `Terminated`, and names no code. That is: a command make runs with no shell; the shell a `kill -KILL $$` kills; or a command bash runs in its own place, which is a line's last command, a line of one command included.
  - Where the shell outlives the command, the shell prints the signal's name itself, `Killed` or `Terminated`, and exits 137 or 143, which make names. dash outlives every command; bash outlives one that is not a line's last.
  - Where the line goes on after the command, the shell exits as the rest of the line does: `; echo after` ends the recipe with 0.
  - The line itself was not changed for a signal. The signal's name is printed right above make's `Error 137`, and a command may also exit 137 by itself.
- **Tests:** `test_the_line_names_the_code_of_the_member_s_innermost_command`, with four sets:
  - `[innermost-under-make]` and `[innermost]` run `INNERMOST`'s 11 members, once under a parent make and once with the member's make at depth 0. They are the first five rows of the table, the last five, and an inner make that ignores an `exit 2` of its own, then `exit 2` (`InnerLets`, run without `-s`, which would keep make from saying so);
  - `[last-under-make]` is line 1's R1 and R8 test;
  - `[signal]` runs one of each signal case in a form that does not hang on which shell `/bin/sh` is: `sh -c 'kill -KILL $$'; exit $?`, which the shell outlives (137), and `kill -KILL $$`, which kills the shell (no code).
  - Each set asserts the run's final line exactly. Under the parent make, a premise checks that no member line reads `make:`.
- `test_a_line_that_comes_in_pieces_is_read_whole` (the pieces test) feeds `MakeErrors` an `Error` line cut in two, and a line ended by no newline, which stays at `LINE_KEPT` bytes.
- The existing `test_a_finding_a_refusal_and_make_s_own_failure_are_told_apart` (depth 0, no `-k`) passes unchanged. It now shares `with_members` and its line helpers with the new tests.

**3. What passing make's stderr on changes.** I chose to document it in `run_make`'s docstring, after measuring both ways.

| effect | `281df263` and head (documented) | reading only until make exits (prototype, not committed) |
|---|---|---|
| `test -t 2` in the member | false: the pipe | false |
| stdout and stderr order | out of order on the pty: one run printed `err 1` after `out 4`, another all six `out` lines first | the same |
| recipe `sleep 6 &` | 6.14 s | 0.29 s |
| `( sleep 1; echo late >&2 ) &` | 1.12 s, `late` shown | `late` lost, and the writer died at that write: its status file was never written |
| non-UTF-8 bytes (`caf` and 0xE9) | passed on as written | the same |

- **The background holder.** Stopping once make has exited and nothing is waiting fixes only the hold, and only by closing the pipe. The left-running process's next stderr write then meets a closed pipe and dies of SIGPIPE, silently. Holding the run is visible, ends when the process lets go of stderr, and the recipe can give the process its own stderr (`2>log`), as the docstring says.
- **The terminal.** Giving make the terminal back would lose its `Error <code>` line. Measured: `make --debug=j` prints only "Reaping losing child" on stdout, with no code, and no other output make writes carries the code. That would reopen line 2 of #839, which this change keeps closed: make still runs.
- **The line order and the Windows decoding** cannot be fixed by the reader. The docstring says each, with the Windows case read and not run.
- **Tests that hold the docstring:**
  - `test_what_a_member_s_make_leaves_on_stderr_is_passed_on_as_written` (the leaves test) asserts bootstrap's stderr bytes exactly: the non-UTF-8 line as written, then `late`, before the run ends;
  - the pty test asserts `stderr: no terminal` and `stdout: a terminal` from the member.

**Pinned copy and docs.**
- `bootstrap.py` is in `FAMILY_COPIED_VERBATIM`, so each holder takes one `upstream-changed` row for it on its next update. Its mode stays 100755.
- Its imports from `repo_shape` are unchanged, so the skew rule (`tests/test_pinned_copy_text.py`) and `tests/test_update_shape_kept_helper.py` hold.
- No `--help` text changed, so `docs/cli.md` is unchanged.
- No sentence of `AGENTS.md`, `templates/family-root/AGENTS-shape.md`, `templates/family-root/README.md` or `README.md` speaks of the make line or of stderr (grep).

**Fail on base.** The test file at this head, run on `281df263`'s code: 3 failed, 54 passed. On `82a84f88`'s code (this pull request before fix round 1): 2 failed, 55 passed.
- On `281df263`, the 3 failures are the tests that pin line 2's change: `[innermost-under-make]`, `[innermost]` and `test_a_line_that_comes_in_pieces_is_read_whole`. On `82a84f88`, the 2 failures are both `[innermost*]`.
- These pass on `281df263` by design:
  - the pure mutant-killers `[last-under-make]` (R1, R8), the pty test (R7) and both `test_no_advice_beside_no_member_updated_with_every_member_checked_out` cases (R10, R11);
  - the tests that hold a docstring: `[signal]`, `test_what_a_member_s_make_leaves_on_stderr_is_passed_on_as_written`, and the store assert in both `test_a_git_mv_staged_with_work_in_the_checkout_is_undone_as_printed` cases.
- Each `INNERMOST` member, run straight with `-k` (depth 0):

| member | expected | `281df263` | `82a84f88` | head |
|---|---|---|---|---|
| `Recurse` | 1 | 2 | 1 | 1 |
| `RecurseRefuse` | 2 | 2 | 2 | 2 |
| `InnerOwn` | none named | 2 | none named | none named |
| `InnerThenOwn` | 3 | 3 | 3 | 3 |
| `InnerThenFinding` (X12) | 1 | 1 | 1 | 1 |
| `Long` | 1 | none named | 1 | 1 |
| `KeepsGoing` (X11) | 1 | 2 | 1 | 1 |
| `ThenKilled` (X7) | 1 | 1 | 1 | 1 |
| `LetFail` | 2 | 2 | 1 | 2 |
| `InnerLets` | 2 | 2 | 2 | 2 |
| `OwnStars` | 1 | 1 | none named | 1 |

- rv884's own X7, X11 and X12, its saved copies of `82a84f88` with each mutant, read their member wrong: `ThenKilled` says none, `KeepsGoing` says 2 and `InnerThenFinding` says none.

**Mutants at this head**, each run against the whole test file: 36 made, 33 caught. The 3 survivors are the equivalents rv884 named: X5 (`\s*$` dropped; make's `***` lines carry nothing after the code), X6 (the unended remainder read at EOF; make always ends its line) and X9 (passed over only one level deeper; makes nest one level at a time).

| mutant | caught by |
|---|---|
| R1, first `Error` line kept | `[last-under-make]`, both `[innermost*]` |
| R7, no `sink.flush()` | the pty test |
| R8, regex anchored on `^make()?: ` | `[last-under-make]`, both `[innermost*]`, the pieces test |
| R10, R11 | the matching `test_no_advice_beside_no_member_updated_with_every_member_checked_out` case |
| H1 nothing passed over; H2 any code passed over; H4 depth never read; H6 depth not kept; H8 a `***` line with no code not read; H9 the 4096-byte tail again | both `[innermost*]` |
| H3, `<=` for `<` in the depth test | `[last-under-make]`, both `[innermost*]` |
| H5, `make:` read as depth 9 | `[innermost]` |
| H7, depth starting at 99 | both `told_apart` cases, both `[innermost*]` |
| H10 an unended line dropped; H11 kept whole | the pieces test |
| H12, only a chunk's last line read | `[last-under-make]` |
| D1 reading stopped once make exits; D2 the bytes decoded as UTF-8 | the leaves test |
| rv884's X1 (`!=`), X2 (depth only on coded lines), X4 (depth group dropped), X7, X11, X12 | both `[innermost*]` (X1 also both `told_apart` cases) |
| rv884's X3 (`[:LINE_KEPT]`) | the pieces test |
| rv884's X8 (`splitlines`) | 7 tests |
| rv884's X10 (one-digit code) | `[signal]` |
| N1, the first match on a line | both `[innermost*]` (`OwnStars`) |
| N2, the `(ignored)` line not read | both `[innermost*]` (`LetFail`) |
| N3, the `(ignored)` line always sets the depth; N5, it falls through | both `[innermost*]` (`InnerLets`) |
| N4, `max` for `min` | both `[innermost*]` (`LetFail`, `InnerLets`) |

**Runs**, each through `run9s.sh` with `SHARDS=3` at this head, with no tracked changes. The files: `tests/test_family_holder_bootstrap_texts.py`, `tests/test_family_bootstrap_gate_order.py`, `tests/test_bootstrap_spelling.py` and `tests/test_pinned_copy_text.py`; the two that `git grep -l 'family-root/scripts/bootstrap' -- tests` names, `tests/test_family.py` and `tests/test_update_shape_kept_helper.py`; the trio, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`; and five more that run the holder's bootstrap: `tests/test_family_gitmodules_values.py`, `tests/test_family_head_lost_gitmodules.py`, `tests/test_family_members_not_a_list.py`, `tests/test_bootstrap_without_git.py` and `tests/test_family_makefile_siblings.py`.
- git 2.43.0: 1049 passed, rc 0 (shards 218, 394 and 437), at `dbd3a6dd`.
- git 2.47.1: 1049 passed, rc 0 (shards 218, 394 and 437), at `dbd3a6dd`.

**Merge checks**, `git merge-tree --write-tree` of this head, after a fresh fetch:

| against | result |
|---|---|
| origin/main `71f36fe0`, where lane 3's #877 has landed as `c9ca9a45`, editing other parts of `bootstrap.py` | clean, tree `8b5826db` |
| origin/main `201c6f09`, after #865, #871 and #815 too | clean, tree `8c3d8e13`; on a commit at that tree, this test file, `tests/test_family_gitmodules_values.py` and `tests/test_pinned_copy_text.py` gave 291 passed, rc 0 (git 2.43, rv884's re-check, #886 issuecomment-6249507909) |
| #877's head `35b214b2` | clean, tree `b80a2ba0` |

**Sonar-style checks.**
- No duplicated block: no 10-line window that touches an added line appears anywhere else in `tests/`, `scripts/` or `templates/`. At 6 lines, only the import block matches another file.
- No assert joins conditions with `and`. The one `or` assert in the file is #876's.
- Cognitive complexity, counted by hand with Sonar's rules: no changed function is over 15, so no `def` line had to stay as it was. `said` counts each boolean sequence: `depth_said or 0`, `code == b"2" and ...`, and `code and not ...`.

| function | `281df263` | `82a84f88` | head |
|---|---|---|---|
| `run_make` | 2 | 1 | 1 |
| `MakeErrors.read` | new | 1 | 1 |
| `MakeErrors.said` | new | 5 | 6 |
| `read_until` (test) | new | 9 | 9 |
| `made`, `with_members` (test) | new | 2, 1 | 2, 1 |
| `test_the_line_names_the_code_of_the_member_s_innermost_command` | new | 4 | 4 |
| `test_a_finding_a_refusal_and_make_s_own_failure_are_told_apart` | 3 | 2 | 2 |
| `test_a_git_mv_staged_with_work_in_the_checkout_is_undone_as_printed` | 3 | 3 | 3 |

**Not here.**
- No Windows or macOS run, since CI is Linux only (#597). The Windows decoding is read, not run.
- Make before 4.0 was not run. The prefix and the `Error` line keep make 3.81's form by its source, but the line-2 test already asserts make 4's quoting.
- The innermost rule's limits are in `MakeErrors`' docstring and listed under line 2 above. A make that speaks another language still gets the line for make's own failure.

Review: rv884, lane 5's independent review, READY at `82a84f88` (issuecomment-6248806027), with no MUST, one SHOULD (three surviving mutants) and four NITs. w884's fix round 1, `dbd3a6dd`, answered each (issuecomment-6249230294), and rv884's re-check found it READY with no MUST or SHOULD (issuecomment-6249507909). Its two NITs on the `(ignored)` rule, which cover rarer nested-make shapes, go to a follow-up. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 dbd3a6d passed tests, tests-git-bash and SonarCloud; the head 85c2078 is a clean update-branch merge of main 0d5d2b1, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 5c273a1 over main 0d5d2b1: the family_holder_bootstrap_texts, repo_hygiene, agents_md_codes, cli_reference sets, 205 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #884

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
… the release commit, #778's leg-clone sentence and #802's readers sentence, each checked against runs at v1.1.0 and main (#883 follow-up) (#893)

The v1.1.1 notes landed as the release commit `5017830` (#883), and RV883's round 5 held two SHOULDs and five NITs for after it (#883 issuecomment-6250558821). This applies them to `docs/release-notes/v1.1.1.md`, with two more sentences lane 1's review found:

1. **#778's leg-clone sentence** says what runs at `v1.1.0` and `5017830` show. A leg clone whose own `AGENTS.md` says so keeps its leg row and its Triad row over each fault, where v1.1.0 gave `FINDING the way in refused` over a tab line and a traceback, exit 1, over a pattern that does not compile or a policy that raised on its name. A leg known only by its name gets a repository's two `n/a` rows.
2. **"Is", not "stays", exit 2,** in the pattern case, which was exit 1 in v1.1.0.
3. **#802's clause is dropped:** v1.1.0's `_blocks_inside` already read a `try`'s handlers, `else` and `finally` outside its guard.
4. **"What 1.1.1 fixes"** names "or cannot be used", and `the other way in`.
5. **#815's leftover tracebacks** were measured on Python 3.12; 3.13 is read, not run.
6. **#871 lets `_` through for any owner,** an organisation's name included.
7. **#869 asks the disk** only about a name that is not UTF-8.
8. **#802's readers:** at v1.1.0 only the import reader skipped an `if TYPE_CHECKING:` body; the binding reader skips one now.
9. **A known limit names #940:** under the `C` or POSIX locale, the bash `openRepoShape` and `setup.sh`'s guards refuse a URL, path or ref with a letter past ASCII as "a control character", a regression from v1.1.0 in the launcher through #872 (#866); a UTF-8 locale takes such a URL or path.

The draft-time lines in the sections marked "remove at tag time" say main `2d372bb`, where #778 landed file for file as its reviewed head `8259ea1`, and that #879 moved to v1.2.0. The release body never carries those sections.

Review: lane 1's independent reviewer found `b795ef74` NOT READY, on the leg-clone sentence, and `ca49f125` READY, with each changed sentence checked against runs at `v1.1.0` and `5017830`. Lane 1's second reviewer found `6cdb5551`, which adds the #940 limit, READY, with each clause measured against the launcher at `64464d31` and at v1.1.0.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its reviewed head `6cdb5551` is green on Linux and SonarCloud. The branch merged main once and was never rebased; the last merge is update-branch's, clean, giving f60654e, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: the 5 test files that read the release notes or the workflows (`test_repo_hygiene.py`, `test_release_workflow.py`, `test_workflow_concurrency.py`, `test_agents_md_codes.py` and `test_cli_reference.py`), 221 passed and 0 skipped.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant