Skip to content

the family root's bootstrap.py: a gitlink a member's own index gives no url, or a member's commit that a fetch reaching its remote did not bring, fails the run by name with its cure and no credential advice, where it said family bootstrap ok beside git's exit 128 (#892) - #954

Merged
brettheap merged 50 commits into
mainfrom
fix/892-bootstrap-ok-after-128
Oct 20, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #892 (comment)

What changed and why

Milestone v1.2.0. A DRAFT. #889 (#879), which this PR was stacked on, landed as 702cc23d, and this branch is restacked onto main: see "Restacked onto main".

Lane 1's review of #889 (SHOULD 4, #889 issuecomment-6249673233) found two states where the family root's bootstrap.py still ends family bootstrap ok beside git's exit 128. Both print the credential advice:

  • B5: a gitlink in a member's own index that its own .gitmodules gives no url.
  • B4: a member's gitlink at a commit that no branch of its remote holds.

#889 reworded its claims to "where git's init in this holder stopped" and listed both under "What it cannot tell". This PR makes both fail. Each names where git stopped and its cure, and gives no credential advice. Both are told apart from a missing credential by deterministic checks, never by git's words. The credential's skip is unchanged.

Round 1 (9eb9d3d2..3fd3880d: one commit, bcb7b898, then the restack, then the CI fix 3fd3880d) answers lane 5's review (issuecomment-6253384652, NOT READY) as lane 4's plan says (issuecomment-6253434622):

How it was measured.

Before and Now

Each B row was measured in the holder itself, with every member checked out, and in a plain clone of it.

B5. A gitlink in members/IRRS's own index with no url

The state: SPARE's commit is recorded at vendor/nested in IRRS's own index, and IRRS's .gitmodules has no section for it. That commit is pushed to a branch of IRRS's remote, and the holder records it with family.py bump, so the lockstep holds.

Git's own update in both places:

fatal: No url found for submodule path 'members/IRRS/vendor/nested' in .gitmodules
fatal: Failed to recurse into submodule path 'members/IRRS'

It exits 128.

Before: bootstrap.py --skip-members exited 0 in both places. It printed FETCH INCOMPLETE (git exit 128), the advice "If the members are PRIVATE, set SHAPE_LEGS_TOKEN ...", and family bootstrap ok.

Now: it exits 1 in both places, with no advice:

  UPDATE STOPPED, and not for want of a credential: git stopped at members/IRRS/vendor/nested, a gitlink members/IRRS's own index records with no url. No clone of members/IRRS can check it out: `git -C members/IRRS rm --cached -- vendor/nested` takes it out of that index, or a section of its .gitmodules can give it its path and url; where its HEAD records it, commit that in members/IRRS and push it, then move the gitlink this holder records for members/IRRS to that commit, with its pin (`family.py bump`).
family bootstrap FAILED: members/IRRS/vendor/nested (git stopped at it)

The gate: the same Before and Now, since this PR does not touch the gate.

  • In the holder, it exits 0 in all three modes and does not see the stray.
  • In the plain clone, members mode and --pins find member-not-a-project for IRSS, which git never checked out because it stopped at IRRS first. --no-members exits 0.

Typed as printed: the git -C line, then a commit in IRRS, a push and family.py bump. After that the run checks every member and leg out and ends ok, and the gate passes in every mode (test_b5_s_cure_typed_as_printed_ends_ok).

B4. IRSS's gitlink and pin at a commit no branch of its remote holds

The state: an empty commit made on IRSS's main in a clone of its own and never pushed. IRSS's gitlink and its pin.commit are moved to it by hand and committed. family.py bump refuses that commit as member-to-local-only.

Git's own update in both places:

fatal: git upload-pack: not our ref <commit>
fatal: remote error: upload-pack: not our ref <commit>
fatal: Fetched in submodule path 'members/IRSS', but it did not contain <commit>. Direct fetching of that commit failed.

It exits 128.

Before: in both places, exit 0, with the advice and family bootstrap ok.

Now: exit 1 in both places, with no advice:

  UPDATE STOPPED, and not for want of a credential: git stopped at members/IRSS, whose fetch reached its remote and did not bring <commit>, the commit this holder records there. Push <commit> to that remote from a clone that holds it, or move the gitlink this holder records there to a commit that remote holds: `python3 scripts/validate-family.py` names the `bump` that moves it with its pin.
family bootstrap FAILED: members/IRSS (git stopped at it)

The gate, the same Before and Now: members mode and --pins refuse member-commit-unresolvable, exit 2, whose remediation names the fetch and the bump with THE LOCKSTEP RULE. --no-members exits 0.

Pushed from the clone that holds the commit, the run checks every member and leg out and ends ok, and the gate passes in every mode (test_b4_s_cures_as_printed).

A level down, in IRRS's code leg, B4 is named up to the holder, exit 1, while git exits 128 (test_b4_in_a_leg_is_named_up_to_the_holder, which asserts the whole line):

  UPDATE STOPPED, and not for want of a credential: git stopped at members/IRRS/code, whose fetch reached its remote and did not bring <commit>, the commit members/IRRS records there. Push <commit> to that remote from a clone that holds it, or move the gitlink members/IRRS records there to a commit that remote holds, and so on up to this holder.
family bootstrap FAILED: members/IRRS/code (git stopped at it)

The credential control: pin moved on

The state: IRSS's gitlink and pin are moved to a commit that a branch of its remote holds and its store does not, and the run has no credential (the file transport is refused).

Git exits 128, with Fetched in submodule path 'members/IRSS', but it did not contain <commit>. Its store lacks the commit, as in B4.

Before and Now alike: exit 0, the advice alone, and family bootstrap ok. With the credential, the same run checks every member and leg out.

The index.lock case (lane 5's "could not verify")

The state: IRRS's gitlink and pin moved by hand to its checkout's HEAD~1, which its store holds, with an empty index.lock left in IRRS's store; and B5's stray committed in IRSS's own index and recorded for IRSS by hand.

Git's own update dies at IRRS's checkout and never comes to IRSS:

fatal: Unable to create '<holder>/.git/modules/members/IRRS/index.lock': File exists.
fatal: Unable to checkout '<commit>' in submodule path 'members/IRRS'

It exits 128.

  • Before (29083a5f): exit 0, FETCH INCOMPLETE (git exit 128), the advice and family bootstrap ok.
  • 9eb9d3d2 (round 0): exit 1, UPDATE STOPPED ... git stopped at members/IRSS/vendor/nested ... and family bootstrap FAILED: members/IRSS/vendor/nested (git stopped at it). That is false: git stopped at IRRS. The walk passed IRRS as a refused checkout (exit 1, which git's update passes) and went on.
  • Now: as before, exit 0, the advice and family bootstrap ok (test_a_member_git_did_not_check_out_is_untold[index-lock]).

The same lock in IRSS alone, with nothing past it, read the same at base and on round 0's rule: exit 0, the advice and ok.

At a commit the store holds and no ref reaches (made with git commit-tree in IRRS and pushed nowhere, its gitlink and pin moved to it by hand, the same lock left in IRRS's store): git's fetch reaches the remote, its fetch of the commit is answered from the store, and its checkout dies (128). On round 0's B4 rule (measured on round 1's WIP, which still had it), the run named B4 at IRRS, whose fetch reached its remote and did not bring <commit>, exit 1: false. Now it is untold, the advice and ok ([held-commit-index-lock]).

How each is told from a missing credential (stopped_below)

The walk runs only where all of these hold:

It asks the gitlinks in git 2.43's own order:

  • the gitlinks git's init asks about (init_asks, the loop init_stop already had), in index order;
  • of those, the ones git's update goes on to (updated_there), as prepare_to_clone_next_submodule asks;
  • each checkout at its recorded commit, entered depth first, its own init asked before its gitlinks.

Failed to recurse into submodule path passes a member's 128 up unchanged, so the first stop the walk finds is where git stopped.

  • B5. A member checked out at the commit the holder records is read the way the holder is read. That means gitmodules_sections, submodule_config and init_stop, run on the member's own .gitmodules, config and index. A gitlink git has no url for is the stop.
  • B4. A recorded commit the member's store lacks after the update: git cat-file -e <commit>^{commit} fails (commit_held). It is the stop only where a git fetch git ran in that work tree exited 0, which means it reached the member's remote. Git dies there only where its direct fetch of the commit fails, which a held commit never makes it do (choice 3).
    • That exit code comes from git's own trace2 event stream. run_update runs the update with GIT_TRACE2_EVENT set to a directory of the run's own, made by tempfile.mkdtemp and removed afterwards. fetched_in reads each process's cmd_name, its def_repo for repository 1, and its exit code.
    • The store alone tells nothing. This was measured on 29083a5f. Take a checked-out IRSS whose gitlink and pin were moved to a commit its remote holds, and run with no credential (the file transport refused). Git's update exits 128, members/IRSS's store lacks the commit after it, and FETCH_HEAD is created empty, exactly as in B4. Only the fetch's exit code differs: in B4 git's git fetch there exits 0 and its git fetch origin <commit> exits 128, while with no credential both exit 128. This is why the brief's cat-file -e check alone would have turned that credential state into a failure.
    • The fetch there, not any fetch. A fetch that reached another member's remote tells nothing of IRSS's (test_a_fetch_that_reached_another_member_s_remote_tells_nothing, lane 5's R7).
  • Passed, as git passes them.
    • A gitlink whose update strategy is none: the last submodule.<name>.update the owner's config gives, else its .gitmodules section's update, which git's init copies into the config. Git prints Skipping submodule and goes on (lane 5's SHOULD 1, P1).
    • A gitlink git counts inactive: submodule.<name>.active false in a config scope above the one git's init writes true in. Where the config sets submodule.active, init_asks asked it already.
    • A member not checked out, as git passes a clone that failed (exit 1, before it checks out or enters anything).
  • Untold. These states print no stop, and the run reads as before, with the advice and ok:
    • a recorded commit not reached, where no fetch there reached the remote;
    • a member at another commit than the one recorded. Git's checkout there refused (exit 1, which its update passes), or died (exit 128, as on an index.lock, which ends its update there), or a rebase or merge that update asks for failed, and which it was is not told (choice 8);
    • a checkout whose .git the operating system will not stat for a reason other than "absent", or in which git cannot be run (OSError): lane 5's SHOULD 2, P7 and P9;
    • a member whose own .gitmodules git does not read whole (unmerged, a value git dies on, or a line it cannot parse, the last read up to that line);
    • any run that was not traced.

The cures:

  • B5: `git -C members/IRRS rm --cached -- vendor/nested` takes it out of that index, or a section of the member's .gitmodules can give it its path and url. Commit and push that in the member, then move the holder's gitlink and pin (family.py bump). For a leg, the cure goes up to the holder.
  • B4: push the commit to that remote from a clone that holds it, or move the gitlink to a commit the remote holds. validate-family.py names the bump that moves the gitlink and the pin together: members mode refuses member-commit-unresolvable, or finds member-gitlink-mismatch, with THE LOCKSTEP RULE. For a leg, the cure goes up to the holder.

Choices for the reviewer

  1. trace2 for B4, not the store check the brief suggested. The brief's cat-file -e after the fetch misreads the credential state "pin moved on" (measured above), and FETCH_HEAD does too. trace2's exit code for git fetch is the only local, deterministic signal I found that tells "reached the remote" apart from "could not reach it". It needs no network and reads none of git's words. It costs one temp directory per run and about 60 lines.
  2. A run with SHAPE_LEGS_TOKEN or SHAPE_LEGS_APP_TOKEN set is never traced (CREDENTIAL_ENV). trace2 writes each command's arguments. The credential's -c url.https://x-access-token:<token>@github.com/.insteadOf=... is one of them, and so is the rewritten url a remote helper is given; git 2.43 writes both unredacted, and 2.47.1 redacts the url but not the argument (lane 5's probe). Tracing them would put the token on disk, against the docstring's "never persisted". So with either variable set, B4 reads as before (a row pins this), while B5, which needs no trace, is still named.
  3. B4 is a commit the store lacks (commit_held), not one no ref reaches (round 1, past the plan's letter). Round 0 mirrored git's is_tip_reachable, which git asks before it fetches. But git dies at B4 only where its direct fetch of the commit fails, and a fetch of a commit the store holds is answered from the store: measured in round 1, a held commit no ref reaches, git's update exit 0. So a held commit is never git's stop. Round 0's rule named B4 at a held commit no ref reaches whose checkout then died on an index.lock; a row now pins that as untold. Lane 5's R8 mutant ("a presence check") survived because it was this rule.
  4. The walk goes past members into legs. B5 and B4 in a leg are named, with the cure "and so on up to this holder". Rows pin both, and that B5 in a member is named before a leg of it git never fetched (git's init in the member dies first, lane 5's R6).
  5. Exit 128 only. A clone that fails ends git's update with exit 1 before it checks out or enters anything, so a stray git never reached is not said (a row pins it). Exit 129 and above, git killed by a signal, is outside bootstrap.py (family root): after #879, two states still end family bootstrap ok beside git's exit 128: a stray gitlink inside a member's own index, and a member's gitlink at a commit no store holds #892 and present at base: the family root's bootstrap.py: git's update killed by a signal (exit 129 and above) gives the credential advice and family bootstrap ok #998 (lane 5's checklist candidate 1).
  6. The line's lead is UPDATE STOPPED, not NO MEMBER UPDATED. Git may have updated the members before the stop: in B4 at IRSS it cloned IRRS's legs first.
  7. init_stop now loops over init_asks, which returns (path, commit) for each gitlink git's init asks about. init_stop behaves as before, and validate-family.py and bootstrap.py: the holder's remedies, typed as printed in order, put a .gitmodules gone from the work tree back once and never over another remedy's write, stage it before a gitlink's git rm and after a section written back, and name a checkout git mv moved before the update, and bootstrap fails where git's init in this holder stopped with every member checked out, so no remedy undoes another or exits 128 (#879) #889's rows pin it.
  8. A member at another commit than its pin is untold, not passed (round 1, past the plan's letter). Round 0 passed it as a refused checkout, which git's update passes (exit 1). Measuring lane 5's index.lock case showed git's checkout can also die there (exit 128), which ends git's update, and the walk then named a stop git never came to (above). Which of the two it was is not told without git's words, so nothing past such a member is said. The cost: B4 or B5 past a member whose checkout git refused reads as before, the advice and ok, where round 0 named it. Three rows pin this (refused, index-lock, held-commit-index-lock).
  9. The walk's "absent" rule is the family root's siblings.py and bootstrap.py: a member's path this user cannot read is refused by name, exit 2, so Python 3.14 no longer clones on the wrong branch or says siblings ok and family bootstrap ok over members it skipped as absent (#430) #951's _is_a (changed in the merge of main, 9b557d97). Round 1 gave the walk its own copy, WALK_ABSENT_ERRNOS (ENOENT, ENOTDIR, EBADF, ELOOP, as bump-leg.py's _ABSENT_ERRNOS reads them, Every Path.is_file() / is_dir() / exists() guard in the shipped tools answers False to any OSError on Python 3.14 (3.12 and 3.13 raise PermissionError), so a directory this user cannot search reads as ABSENT: validate-pins.py skips its workflow @<sha> check, update-shape.py says copy-missing and update-pin-missing ("adopt the project") for files that are there, the doctor says DRIFTED (5 copy-missing), and bump-leg.py's remaining guards, and every other tool's, skip or misreport instead of refusing by name #430), beside a direct os.stat. the family root's siblings.py and bootstrap.py: a member's path this user cannot read is refused by name, exit 2, so Python 3.14 no longer clones on the wrong branch or says siblings ok and family bootstrap ok over members it skipped as absent (#430) #951 added _is_a, _ABSENT_ERRNOS and _ABSENT_WINERRORS to this file as its one copy, so checkout_stop now asks _is_a(<checkout>/.git, _anything), as the family root's siblings.py and bootstrap.py: a member's path this user cannot read is refused by name, exit 2, so Python 3.14 no longer clones on the wrong branch or says siblings ok and family bootstrap ok over members it skipped as absent (#430) #951's checked_out asks a member's, and the walk's copy is gone.
    • It is still a stat, not Path.exists(), so Python 3.12, 3.13 and 3.14 answer alike: absent is passed (None), and a refused stat is untold.
    • Two differences: a Windows error that says nothing is there (21, 123, 1921) now reads as absent, where it was untold; and a name no stat can take (a NUL) reads as absent where it raised ValueError, though no gitlink's path, which the walk takes from git's index, can hold one.

What it cannot tell

Tests

tests/test_family_bootstrap_stops_named.py, new. It has 17 tests and 25 rows, and reuses #889's cloning, valued, STOP_FAILED and OTHER_ROW, the texts file's OURS and ALONE, test_family.py's family and holder fixtures, test_bump_leg_unopenable.py's made_unopenable and needs_a_mode, test_family_head_lost_gitmodules.py's committed_gitmodules, test_family_bootstrap_gate_order.py's with_config, and conftest.py.

on bootstrap.py at result
main 2a78ae1a (Before; it holds #889's squash and #950) 9 failed, 16 passed
round 0, 9eb9d3d2 7 failed, 18 passed
this head 25 passed
test the state fails on main fails on 9eb9d3d2
test_b5_a_gitlink_a_member_gives_no_url_fails_naming_it[holder] and [plain-clone] B5. The line and the failure exactly, no advice, and git's update exits 128. both no
test_b5_s_cure_typed_as_printed_ends_ok B5's git -C line typed as printed, committed, pushed and bumped. The run is ok, and the gate passes in every mode. yes no
test_b5_in_a_leg_is_named_up_to_the_holder B5 a level down, in IRRS's code leg, moved and bumped. Named with "and so on up to this holder". yes no
test_b4_a_commit_the_fetch_did_not_bring_fails_naming_it[holder] and [plain-clone] B4. The line and the failure exactly, no advice, git's update exits 128, and no bootstrap-trace-* directory is left in TMPDIR. both no
test_b4_s_cures_as_printed B4. The gate names member-commit-unresolvable and the family.py bump. Once the commit is pushed, the run is ok and the gate passes in every mode. no (a cure row) no
test_b4_in_a_leg_is_named_up_to_the_holder B4 a level down: IRRS's code leg recorded in IRRS at a commit no branch of the leg's remote holds. The whole line, through , and so on up to this holder. (lane 5's P2 and R1). yes no
test_b5_is_named_before_a_leg_git_never_fetched B5's stray in IRRS beside IRRS's code leg moved to a commit its store lacks. Git's init in IRRS dies at the stray first, and B5 is named (lane 5's R6). yes no
test_the_credential_s_skip_is_as_it_was[members-not-checked-out], [pin-moved-on] and [beside-a-member-checked-out-whole] The credential control: a plain clone with no member checked out (git exits 1); a checked-out IRSS whose pin moved on (128); and that state beside IRRS checked out whole, legs and all, with a nested gitlink (SPARE, by git submodule add) that has its section and url (128). Each gives the advice alone and ends ok. With the credential, each checks every member and leg out. no (controls) no
test_a_stray_git_never_came_to_is_not_said B5 in IRRS, with IRSS's store gone and no credential. Git's clone of IRSS fails, so its update ends with exit 1 before it enters any member. The advice and ok, as before. no (control) no
test_a_member_s_gitmodules_git_cannot_read_is_read_as_before IRRS's own .gitmodules gives code the value update = None. Git's update dies in IRRS (128). Untold: the advice and ok, as before. no (control) no
test_a_member_s_gitmodules_read_up_to_a_bad_line_is_read_as_before IRRS's own .gitmodules with a line git cannot parse before code's section, its work-tree file deleted. Git reads it up to that line and dies at code (128). Untold (lane 5's R11). no (control) no
test_a_member_git_did_not_check_out_is_untold[refused] IRRS's checkout at a commit of its own that records B5's gitlink, with an edit git's checkout refuses to overwrite (exit 1, passed by git), plus B4 at IRSS. Untold: the advice and ok (choice 8). no yes (named IRSS)
test_a_member_git_did_not_check_out_is_untold[index-lock] The index.lock case above. Untold: the advice and ok. no yes (named IRSS's stray)
test_a_member_git_did_not_check_out_is_untold[held-commit-index-lock] IRRS at a commit its store holds and no ref reaches, with the index.lock. Untold: the advice and ok (choice 3). no yes (named B4 at IRRS)
test_a_member_git_s_update_skips_is_passed[update-none-in-config], [update-none-in-gitmodules] and [inactive] Lane 5's P1: B5's stray in IRRS, which git's update skips, and IRSS's pin moved on, with no credential. The advice and ok, git exits 128; with the credential, every member and leg fetched. no all three
test_a_leg_this_user_cannot_search_is_untold Lane 5's P7 and P9: IRRS's code leg mode 000, and B4 at IRSS. No traceback; the advice and ok. Git exits 128, and the row asserts it again (fe907c84): its exit 1 on CI at 938a83ef was the fixture's, not git 2.55.0's ("CI at 4f97fedb"). Skipped where chmod cannot refuse (needs_a_mode: root, Windows). no yes (traceback)
test_a_fetch_that_reached_another_member_s_remote_tells_nothing IRRS's pin moved on to a commit its remote holds, so its fetch reaches that remote, and B4 at IRSS, whose origin names no repository. The advice and ok (lane 5's R7). no no
test_with_a_credential_in_use_b5_is_named_and_the_token_is_not B5 with SHAPE_LEGS_TOKEN set. It is named, and the token is printed nowhere. yes no
test_with_a_credential_in_use_b4_is_read_as_before B4 with SHAPE_LEGS_TOKEN set. The run is untraced, so it gives the advice and ok, as before: the known limit. no (pins the limit) no

The file has no raw control or bidi character, and the newlines it writes are built with chr(10) or written as bytes. It has no composite assert, and flake8 finds nothing but the F811 every fixture-importing test file has.

Mutants

Each mutant was applied to 3fd3880d's bootstrap.py alone (sha256 f147aee2..., 938a83ef's byte for byte) and run against the new file, and the bytes were restored and checked after each. The runner is mutants-r1b.py in lane 4's scratch, and its log is mutants-head-part1.log (16:16Z to 16:32Z).

mutant what it does result
R1 (lane 5's) B4's leg tail , and so on up to this holder. replaced with . killed: 1 failed (B4 in a leg)
R6 (lane 5's) a member's legs asked before its own init_stop killed: 1 failed (B5 before a leg git never fetched)
R7 (lane 5's) same_place(checkout) in fetched replaced with bool(fetched) killed: 1 failed (a fetch that reached another member's remote)
R8r round 0's rule back: commit_held replaced with the reachability check (git rev-list -n 1 <commit> --not --all) killed: 1 failed (held-commit-index-lock)
R11 (lane 5's) walked's or modules.cut dropped killed: 1 failed (a .gitmodules read up to a bad line)
M1 drops the B5 check killed: 6 failed
M2 drops the B4 check killed: 3 failed
M3a the credential advice back for B5 killed: 5 failed
M3b the credential advice back for B4 killed: 3 failed
M4 drops the fetch evidence (any commit the store lacks is B4) killed: 7 failed
M5 walks on any non-zero exit, not only 128 killed: 1 failed
M6 drops the HEAD check, so a checkout at another commit is entered killed: 2 failed (refused, index-lock)
M7 drops the guard on a member's .gitmodules git cannot read killed: 2 failed
N1 drops update = none killed: 2 failed
N2 drops the inactive check killed: 1 failed
N3 drops the .gitmodules fallback for update survives, equivalent in practice: git's init copies a section's update into the config before the walk can reach that gitlink, so the config always answers first
N4 the stat back to Path.exists() survives, equivalent where the walk can meet it: a refused stat at a leg raises inside the member's entered, whose OSError guard answers untold for the member, the same result. At a member, unfetchable meets the refusal before the walk (#951's ground, #430)
N5 every stat error read as absent killed: 1 failed (a leg this user cannot search)
N6 drops the OSError guard around entered survives, equivalent short of a race: every OSError the rows can raise there is a refused stat one level down, which that level's own guard answers
N7 a checkout at another commit passed, round 0's rule killed: 2 failed

R8 as lane 5 wrote it, tip_reachable changed to a presence check, is now the code (choice 3). Lane 5's R9 and R10, killed in round 0, were not rerun.

Not rerun after the merge of main (9b557d97, whose bootstrap.py is sha256 8282da7a...). The merge changes the walk in one place, checkout_stop's stat, which now asks #951's _is_a (choice 9): N4's and N5's site. N5's killing row, test_a_leg_this_user_cannot_search_is_untold, passes at the merge (see "The run"). No other site in the table is changed by the merge: fetch_members's whole and below lines, M5's, are kept as they were.

At fe907c84, the guards of the two rows this head changes, each applied to bootstrap.py alone (sha256 8282da7a..., restored and checked after each) and run against the whole new file, with git 2.43.0. The runner is mutants.py in lane 4's scratch (w954c), its log mutants.log (05:26Z to 05:31Z).

mutant what it does result
M6 drops the HEAD check, so a checkout at another commit is entered killed: 2 failed ([refused], [index-lock]), on bootstrap's own assertion
N7 a checkout at another commit passed, round 0's rule killed: 2 failed ([refused], [index-lock])
M5 walks on any non-zero exit, not only 128 killed: 1 failed (a stray git never came to)
L1 a refused stat in checkout_stop read as absent killed: 1 failed (a leg this user cannot search)
L2 checkout_stop's OSError guard dropped killed: 1 failed (a leg this user cannot search)

The run

On this head fe907c84: the 17 files, the 16 below and #951's test_family_siblings_bootstrap_unreadable.py, in one run: 1476 passed, 7 skipped, 0 failed, in 12:47 (07:18:41Z to 07:31:29Z), git 2.43.0 and Python 3.12.3, the probe environment below, with GIT_SSH_COMMAND, GIT_ALLOW_PROTOCOL and every LANES_* unset and EMAIL set. fe907c84 changes only the new test file, which no other file imports.

On 9b557d97, the merge of main 46cf9240: 1419 passed, 7 skipped, 0 failed, in 23:18 (22:32:04Z to 22:55:25Z), with GIT_SSH_COMMAND, GIT_ALLOW_PROTOCOL and every LANES_* unset and EMAIL set. #951's test_family_siblings_bootstrap_unreadable.py, run on its own after it: 57 passed, 0 failed, in 0:21. On 3fd3880d: 1419 passed, 7 skipped, 0 failed, in 22:12 (21:28Z to 21:51Z). Round 1, on 938a83ef: 1419 passed, 7 skipped, 0 failed, in 24:06, with git 2.43.0 and Python 3.12.3. It used the probe environment above, /usr/bin/python3 -m pytest -q -rfs -p no:cacheprovider -o tmp_path_retention_policy=failed, and df -i / at 83%.

The 16 files:

The skips are the environment's, all in test_shape_doctor.py: 4 because git filter-repo is not installed, and 3 that print a PowerShell line only on a Windows host.

The same set before the last merge of main, at f320969a: 1419 passed, 7 skipped. At 7d8f025e, without #950's file: 1376 passed, 7 skipped.

Round 0, on 9eb9d3d2: another set of 25 files (test_family.py and the update_shape files among them, test_shape_doctor.py not), 1887 passed, 16 skipped.

The full suite was not run.

CI at 938a83ef

  • Run 39284142159 (tests job, ubuntu-24.04, git 2.55.0): 1 failed, 17065 passed, 279 skipped. The one failure is test_a_leg_this_user_cannot_search_is_untold, at assert update.returncode == 128, which was 1. package and tests-git-bash passed.
  • Bootstrap's own assertions held there: exit 0, no traceback, the advice alone. The row's pin on git's own exit code failed, for the same state:
    • git 2.55.0 (CI): Failed to clone 'code' a second time, aborting, then fatal: Failed to recurse into submodule path 'members/IRRS', exit 1. Git's update ends at the leg, and the walk, which runs on 128 only, does not start, so the run is the credential's skip;
    • git 2.43.0 (here, /usr/bin/git --version: git version 2.43.0): git passes the leg it cannot clone into and dies at IRSS's B4, exit 128. The walk meets the leg and answers untold. The row passed with the old pin, inside the targeted run at 938a83ef.
  • The fix, 3fd3880d: the pin is update.returncode in (1, 128), and the docstring says why. The rest of the row is unchanged, and bootstrap.py is not touched.
  • Corrected at fe907c84: the 2.55.0 reading above was wrong. Git 2.55.0 passes a failed recursion's exit 1 as 2.43.0 does; the exit 1 was B4's commit already on the remote, and the row asserts 128 again (next section).

CI at 4f97fedb: one commit made twice

  • Run 39355088540 (tests job, git 2.55.0), on GitHub's update-branch merge of main 051176ce into 9b557d97: 1 failed, 17683 passed, 318 skipped. The one failure is test_a_member_git_did_not_check_out_is_untold[refused], at its precondition assert git_update(holder, env).returncode == 128, which was 1. The same row passed at 9b557d97 (run 39338366303, git 2.55.0 too), and the family root's bootstrap.py: a gitlink a member's own index gives no url, or a member's commit that a fetch reaching its remote did not bring, fails the run by name with its cure and no credential advice, where it said family bootstrap ok beside git's exit 128 (#892) #954's two files are the same at both heads.
  • What git said: nothing on stdout, and stderr ending fatal: Unable to checkout '<IRRS's pin>' in submodule path 'members/IRRS'. Git passes that checkout's exit 1 and goes on to IRSS; only a 128 ends its update (update_submodules in git 2.55.0's builtin/submodule--helper.c, read). So IRSS needed no update: bootstrap's own run, just before, had fetched and checked out a B4 commit no remote was meant to hold.
  • The cause is the fixture, not git and not bootstrap.py. unpushed made every row's never-pushed commit with one message, on the same parent and tree, by the same author, and git dates a commit to the second. The family's remotes are the module's, and earlier rows push such a commit (pin_moved_on, test_b4_s_cures_as_printed). Where a later row made its B4 commit in the same second, it was the pushed one: git's fetch brought it, its update exited 1, and the walk, which runs on 128 only, never ran.
  • Timing: here, the [beside-a-member-checked-out-whole] row's pushed commit and the refused row's B4 commit are 3.1 to 3.3 s apart. CI ran the 72 tests of that progress line in 34.5 s, which take 116.8 s here, 3.4 times as fast: about 0.9 s apart, so in one second some of the time.
  • Reproduced on git 2.43.0 with every commit dated to one second (GIT_AUTHOR_DATE, GIT_COMMITTER_DATE): the refused row's B4 commit is test_b4_s_cures_as_printed's pushed commit, and the row fails as on CI, exit 1, nothing on stdout, the IRRS line last, in 10 of 10 runs, before this fix, of 11 rows: test_b4_s_cures_as_printed, the credential's three, the three did-not-check-out rows, the three skipped-by-git rows and the leg row.
  • The leg row's exit 1 at 938a83ef was the same. Its tail there, Failed to clone 'code' a second time, aborting then fatal: Failed to recurse into submodule path 'members/IRRS', nothing on stdout, is what git 2.43.0 prints when B4's commit is on the remote (the [inactive] row pushes one about 1.2 s before it here, about 0.4 s on CI). Without it, 2.43.0 goes on to IRSS and exits 128 (not our ref), as 2.55.0's code does. So 3fd3880d's "git 2.55.0 ends its update at the leg" was wrong, and the row that took 1 or 128 could pass on CI with the walk never meeting the leg (lane 5's NIT 2).
  • The fix, fe907c84, in the test file alone: unpushed's commit message names its clone (Made in <clone> and never pushed), a path no other row's clone has, so no row's B4 commit is on any remote, whatever second it is made in. The leg row asserts git's 128 again. bootstrap.py is unchanged (sha256 8282da7a...).
  • Measured at fe907c84, git 2.43.0:
    • the new file: 25 passed;
    • the new file with every commit dated to one second: 25 passed, and the refused and leg rows see git's 128 (not our ref);
    • those 11 rows, every commit dated to one second: 22 of 22 runs passed, where the file before the fix failed [refused] in 10 of 10.
  • CI at fe907c84: run 39377035836 (tests job, git 2.55.0), on GitHub's merge of fe907c84 into main 5b41d44f: 18678 passed, 371 skipped, 0 failed, every progress mark a pass or a skip. -q names no passing row, so the [refused] row and the leg row, which asserts 128 again, are among the passes: [refused] has no skip, and the leg row runs on that runner (it ran, and failed, at 938a83ef). package, tests-git-bash and SonarCloud pass.

Restacked onto main

Shared files

merged with result
main 051176ce held by this head (GitHub's update-branch merge 4f97fedb, after the sixth merge of 46cf9240; 2a78ae1a was the fifth). Main has moved since, to dc7cd49b, in no file this PR changes, and git merge-tree with it is clean. Against main, the head changes only templates/family-root/scripts/bootstrap.py and the new test file.
#950 (#888), landed as 8264c050 held by this head (the fourth merge): clean
#951 (#430's slice F), landed as 08a2ae45 held by this head (the sixth merge). Three conflicts in bootstrap.py (lane 5's NIT 2): the imports, keep import errno once, import json, and #951's import stat and from collections.abc import Callable; fetch_members's call site, keep proc, fetched = run_update(...), then #951's unfetched(root, rows, proc, source, token) in place of the bare unfetchable, then this PR's whole and below lines, and #951's subprocess.run block is not kept, since this PR's started holds it; and the new functions, keep this PR's run_update to same_place, then #951's unfetched. One change past the conflicts: the walk's checkout_stop asks #951's _is_a, and WALK_ABSENT_ERRNOS is gone (choice 9). No line #951 added is dropped. pyflakes finds nothing, and it is test-run (see "The run").

gh pr list --json files shows no other open PR touching either file (checked at 22:33Z).

The pinned copy

templates/family-root/scripts/bootstrap.py is in FAMILY_COPIED_VERBATIM, so each holder gets one upstream-changed row for it. That is still one row per pinned file per update, however many of #889 and this PR it crosses. It lands in v1.2.0 with #889. No Windows or macOS run was made, since CI is Linux only (#597).

Closes #892

🤖 Generated with Claude Code

Summary by Sourcery

Make family bootstrap fail with actionable diagnostics when nested submodule updates stop at an invalid gitlink or an unavailable remote commit, while preserving safe behavior for ambiguous and credential-related failures.

New Features:

  • Detect and report submodule update failures caused by missing nested gitlink URLs or commits not brought by a remote fetch, including actionable remediation guidance.
  • Traverse nested members and legs in Git's update order while preserving credential-related skips and ignored update strategies.

Bug Fixes:

  • Prevent family bootstrap from reporting success alongside Git exit 128 for identifiable failures below the family root.
  • Avoid exposing credential advice when deterministic checks show that credentials cannot resolve the reported failure.

Enhancements:

  • Use temporary Git trace events to distinguish remote fetch failures from missing credentials without relying on Git's diagnostic wording.
  • Keep ambiguous checkout, unreadable, and credential states as the previous non-failing behavior.

Tests:

  • Add comprehensive coverage for nested gitlink failures, unavailable commits, credential controls, traversal ordering, skipped submodules, unreadable checkouts, and remediation paths.

Brett Heap and others added 30 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>
…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>
…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>
…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>
…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>
…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)

Git 2.43 dies reading a `.gitmodules` that gives a submodule a `path`,
`url`, `update`, `ignore` or `branch` with no value (a `branch` with none
crashes it, exit 139), an `update` other than `none`, `checkout`,
`rebase` or `merge`, a `shallow` that is no boolean, or a
`fetchRecurseSubmodules` that is neither a boolean nor `on-demand`, before
`git submodule update` checks out any member. The family holder's
bootstrap listed the file with `git config --list`, which reads all of
them, and ended `family bootstrap ok`; the gate refused
`member-uninitialized` members checked, naming the update that dies, and
said `family ok` under `--no-members`.

bootstrap.py (the family root's, pinned):
- `gitmodules_sections` checks each entry against git's rules
  (`refused_value`; a boolean is read by git itself, `git config
  --type=bool`). Where one would make git die it returns those entries
  and no sections, and `unfetchable` gives every row not checked out the
  reason "git cannot read .gitmodules: `<key>` is `<value>`" (or "has no
  value"). The cure names each key and what git takes for it. Where every
  member was checked out, the run fails on `.gitmodules` all the same,
  since git updated none of them.
- Where the work tree has no `.gitmodules`, it reads the index's, then
  HEAD's, with `git config --blob`, as git does. A key with no value ends
  the reading of such a blob, where git calls the line bad and goes on.
- `init_stop` reads a section's own `submodule.<name>.active` before
  `submodule.active`'s patterns, and matches a plain path pattern, as
  `git clone --recurse-submodules=<path>` writes one, to that path and
  what is under it. A pattern with pathspec magic or a glob keeps the old
  reading.

validate-family.py (the family root's, pinned):
- A new finding, `gitmodules-value-refused`, for each such key in the
  work tree's `.gitmodules` and in HEAD's, asked before any member check
  and in every mode. Its remedy prints `git config -f .gitmodules
  --unset-all submodule.<name>.<key>`, one command to a line, writes a
  member's `path` or `url` back where taking it out would leave none,
  says which values git takes, and ends with the commit. It says which
  file gives the value; where HEAD's alone does, it says to commit the
  work tree's, and where the work tree has no `.gitmodules`, it puts
  HEAD's back first.
- 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, so no command they print meets one of its lines. Where either
  file gives one, a member that is not checked out is not refused
  `member-uninitialized`: the finding fails the run.

tests/test_family_gitmodules_values.py: every row of the issue's table
and `branch` with no value, in a plain clone, through bootstrap and the
gate in three modes, the printed commands typed; the no-misfire values;
the index's and HEAD's file; `submodule.<name>.active` and the subset's
patterns; and which file gives the value.

tests/test_validate_family_without_git.py: with no git and no work tree
`.gitmodules`, the first check to run git is now the reading of HEAD's
`.gitmodules`, so the `git-not-runnable` refusal names that read, where it
named the gitlink listing. Exit 2 and the remedy are as they were.

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>
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	templates/family-root/scripts/bootstrap.py
#	templates/family-root/scripts/validate-family.py
…ues.py: a `branch` with no value is read as the git that runs reads it, a work tree `.gitmodules` git cannot parse fails the run with every member checked out, and the boolean probe reads no config of the person's, so CI's git 2.55 passes the row (#768, rv819)

CI's run 39031235254 (git 2.55.0) failed
`test_a_value_git_dies_on_fails_bootstrap_and_every_mode_of_the_gate[branch-no-value]`,
and lane 2's review rv819 of c12b5f8 (NOT READY) found one MUST, four
SHOULDs and five NITs. This commit answers them.

MUST 1. The row pinned git's own update at exit 139, the crash w768
measured on git 2.43.0. Git 2.55 refuses the line, `error: missing value
for 'submodule.members/IRSS.branch'` and `fatal: bad config line 7`, exit
128; the same assertion fails on git 2.47.1 (`assert 128 == 139`). The
table's exit column is gone: the premise is that git's update died and
checked nothing out, and every other assertion is the tools' own.

SHOULD 2. Measured on git 2.43.0 and 2.47.1, a `branch` with no value
after a submodule's url, then `git submodule update --init --recursive`
in a plain clone: from the work tree's file, 2.43.0 crashes (exit 139)
and 2.47.1 dies (exit 128); from the index's file or HEAD's, a blob,
2.43.0 crashes and 2.47.1 says `error: bad config line 4 in blob`, reads
every section before it, checks the submodule out and exits 0, as for a
`url` with no value. git's submodule-config.c gained `if (!value) ret =
config_error_nonbool(var);` for `branch` in v2.43.1 and v2.44.0; v2.43.0,
v2.42.4 and v2.39.5 lack it. Bootstrap kept 2.43.0's rule for every git,
so on 2.43.1 and later such a blob had every member not checked out named
"git cannot read .gitmodules" and the run failed, where git failed only
for want of a credential (measured on 2.47.1 at c12b5f8).
`crashes_on_branch` now reads `git version`: before 2.43.1, or a version
it cannot read, the key is refused as before; from 2.43.1 on, the line
ends the reading of a blob (`ends_blob_reading`). The gate is unchanged:
it reads the work tree's file and HEAD's, which a clone checks out as
its work tree's, and every git dies on the key there. The comments that
said git crashes say which git does.

SHOULD 3. Where every member was checked out, a work tree `.gitmodules`
`git config --list` cannot parse (a conflict marker, an open quote) ended
`family bootstrap ok` over git's `fatal: bad config line`. `Gitmodules`
now says `unreadable`, and the run says NO MEMBER UPDATED and fails on
`.gitmodules`, as for a refused value; a row not checked out is said to
be that (`git cannot parse .gitmodules`), where it was "no section gives
that path", and the cure is the file mended by hand.

SHOULD 4. New tests catch each survivor rv819 named: the index's file
staged apart from HEAD's; `update = rebase` and `update = merge`; a key
with no value inside an ignored section of a blob; a `submodule.active`
pattern that is only the start of a path; `submodule.<name>.active` set
twice; a url beginning with `-` before the one with no value; the
`<url>` placeholder; a section name with a backslash; and HEAD's value
alone leaving the work tree's file to `gitmodules-path-twice`.

SHOULD 5. `gitmodules_sections` is split into `gitmodules_source`,
`gitmodules_entries`, `ends_blob_reading` and `read_sections`;
`unfetchable` gives each row's reason to `row_reason`; `cures` gives the
`.gitmodules` sentences to `gitmodules_cures`; `_check_gitmodules_values`
gives its text to `_value_said` and `_value_taken_out`.

NIT 6: the gate-in-three-modes block and the good-run block are
helpers. NIT 8: where HEAD's file alone gives the value, the finding says
git dies on it in a clone. NIT 9: both boolean probes ask git for the
`--default` of a key in an empty file (`git config --file <null>`), so no
`probe.value` in the person's config is read; one in it made both tools
refuse `shallow = true`. NIT 7 (one copy of the rules) and NIT 10 (the
body's count) are not code changes here.

tests/test_family_gitmodules_values.py: 52 tests, from 28.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…762) landed, so validate-family.py keeps both sides, `stop` is asked only where `not refused`, and `_check_removals_staged` is handed the sections as read

#820 (#762, `b9b70692`) landed on main after #819's last head `e1379a9d`. The
two PRs conflict in `templates/family-root/scripts/validate-family.py` only;
`templates/family-root/scripts/bootstrap.py` merges by itself. The resolution
is the one lane 2 measured on #819 and #820 (rv819, rv820): keep both sides,
with two changes that its notes for the second lander ask for.

Conflicts, all in `templates/family-root/scripts/validate-family.py`:

1. The module docstring's EXIT CODES sentence. #819 added "and any while
   `.gitmodules` gives a value git dies on" with `gitmodules-value-refused`;
   #820 added "or gives a url, or whose removal is staged" with
   `member-gitmodules-url-absent` and `member-gitlink-removal-staged`. Now one
   sentence names all four exit-1 codes.
2. The `_check_members` docstring. Both paragraphs stay: #820's sentence
   about `_init_stop_said` ends the #648 paragraph, and #819's paragraph about
   a value git dies on follows it, with a clause that `_init_stop_said` is not
   asked there.
3. The `_check_member_checkout` call: `set(gitlinks) if refused else
   unregistered` (#819) and `stop` (#820), as `_check_member_checkout(root,
   asked, report, set(gitlinks) if refused else unregistered, stop)`.

Two lines outside the blocks, which lane 2's notes ask for:

- `stop` is computed only where `not refused`. Given #819's emptied
  `sections`, `_init_stop_said` names the first gitlink as a stop git never
  reaches when the work tree's file gives a value git dies on. Today
  `set(gitlinks)` keeps that text from being printed, but only by ordering.
- `_check_removals_staged` is handed `as_read`, the sections before #819
  empties them. Given `{}`, a staged `git rm --cached` printed
  `git checkout HEAD -- .gitmodules` as putting back "the .gitmodules section
  git rm took out", and `git rm --cached` took none (rv820).

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…side a value git dies on in the work tree's `.gitmodules` is a finding that prints the reset alone, so `_check_removals_staged` handed the emptied sections no longer puts `git checkout HEAD -- .gitmodules` in it (#768, point 3 of the merge with `b9b70692`)

The merge of main `b9b70692` (#820, #762) into this branch hands
`_check_removals_staged` the work tree's sections as read (`as_read`), not
as #768 empties them where the work tree's file gives a value git dies on
(rv820's note for whichever of #819 and #820 landed second). No test held
that line: a mutant giving it `sections` again passed all 191 tests of
`test_validate_family_typed_states.py`, `test_family_gitmodules_values.py`,
`test_family_head_lost_gitmodules.py`,
`test_validate_family_remedies_typed.py` and
`test_validate_family_unnamed_mounts.py`.

`test_a_staged_removal_beside_a_refused_value_puts_no_section_back` holds
the state: the work tree's file gives OTHER's section `update = None`, and
`git rm --cached -- members/IRRS` is staged. In all three modes the gate
finds `gitmodules-value-refused` and `member-gitlink-removal-staged`, whole
(`taken_out` and `removal_staged`), the second's commands are
`git reset -q -- members/IRRS` alone, and `git checkout HEAD --
.gitmodules` is not in it.

The test passes on `e32ed55a`'s validator. With the call changed back to
`sections` it fails at the first mode: the finding's last line is the
`git checkout HEAD -- .gitmodules` it should not print.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… advice and a member's exit code say what happened, and the gate's `git rm --cached`, guarded `mv` and section lines type as printed where they failed (#839)

Seven lines of #839, in the holder's two pinned scripts, on #819's head
(233a334):

1. `fetch_members` printed "If the members are PRIVATE, set
   SHAPE_LEGS_TOKEN ..." for every failed `git submodule update`, then
   "NOT CHECKED OUT, and not for want of a credential" for every row
   `unfetchable` names. `credential_advice` prints it only where git
   asked for a credential: not where its init died at a gitlink it has
   no url for, nor where it read nothing from `.gitmodules` (unmerged, a
   value git dies on, a file it cannot parse). Beside a denial it says it
   is for the members that line does not name. A leg left unfetched in a
   checked-out member keeps it.
2. `run_member_target` said `make <target> exited 2` for a member's
   finding (1), its refusal (2) and make's own failure alike: GNU make
   exits 2 for each (measured, GNU Make 4.3). `run_make` passes make's
   stderr on as it comes and reads the code from its last `Error <code>`
   line: `make bootstrap: the member's command exited N`, or `make
   bootstrap exited 2 and printed no ... line` where make printed none.
   Make is still what runs, so `--members-make validate` gets it too.
3. In the gate's state after `git mv members/<P> Members/<P>`, staged,
   with a commit or an untracked file in the moved checkout, the printed
   `git rm --cached -- Members/<P>` (the STRAY finding) refused, exit 1.
   `_forced` reads where the index stages a variant at a commit HEAD does
   not record there over a checkout; the STRAY line and the wrong-case
   move's `git rm` then carry `-f` and say what it does, as family.py's
   move back says it (#828).
4. The wrong-case move's guard followed a link (`-ef`, `-e`), so a link
   at the variant to the row's own directory or to a clone was kept, and
   `family.py add` refused it; with nothing there the `mv` failed `cannot
   stat`. `_guarded_move` prints `[ ! -L V ] && { [ ! -e V ] || [ V -ef R
   ] || [ -e V/.git ]; } || mv -- V ../<V>`, run in dash and bash on seven
   layouts. Comment 6237599648 is the same fault.
5. Comment 6237354577 (a): bootstrap already reads the work tree's, the
   index's, then HEAD's `.gitmodules` at 233a334 (#819); a test asks it.
6. Comment 6237354577 (b): `member-gitmodules-absent`'s `git config -f`
   lines made a one-section file where the work tree's `.gitmodules` is
   gone. The first finding that writes the file puts it back first
   (`_gitmodules_put_back`, `_back_first`), and no other, nor any after a
   `gitmodules-value-refused` that put HEAD's back; a second
   `member-gitmodules-url-absent` put the file back over the first's url.

Tests: tests/test_family_holder_bootstrap_texts.py (48), and
`variant_refused` in tests/test_family_head_lost_gitmodules.py and the
make line in tests/test_bootstrap_spelling.py follow the new texts.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as `_check_gitmodules_values` returns it, so `_check_members` gains no condition for the put-back `gitmodules-value-refused` already printed (#839)

Where the work tree holds `.gitmodules`, `_gitmodules_put_back` names no
line to put it back either way, so `refused` alone says the same as
`refused and not os.path.lexists(root / GITMODULES)`, and the cognitive
complexity of `_check_members` stays at its base's.

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>
…section's path outside `members/` out of the index before the section, and a gitlink there that no section gives is `gitlink-unnamed`, so the remedy typed as printed no longer leaves a gitlink at which a plain clone's `git submodule update --init` exits 128 while the gate passes (#878)

`_member_gitlinks` listed gitlinks in `members/` alone, so no mode asked
about one elsewhere. `git submodule add <IRSS> tools/x`, committed, or a
member added at `Members/IRRS` and moved with `git mv` to `renamed-1`:
the gate printed only the section's `--unset-all` lines, and typed as
printed they left the gitlink. The gate then passed in every mode while
`git submodule status` and a plain clone's update exited 128 with no
member checked out, which is how the holder's CI checks its members out.

- `_member_gitlinks` lists every gitlink (`_in_members` says which are
  in `members/`); `member-gitlink-unnamed` still asks only those.
- `gitmodules-path-unnamed` for a section at such a gitlink prints `git
  rm --cached -- <path>` and its commit first, then its own lines, once
  per path (`_gitlink_out_first`). Where HEAD's `.gitmodules` holds no
  section of its name, it says to stage the file and that nothing of it
  is left to commit.
- `gitlink-unnamed` (`_check_gitlinks_elsewhere`): a gitlink outside
  `members/` that no section of the work tree's `.gitmodules` gives,
  with bootstrap's cure, `git rm --cached` and the commit, or the commit
  alone where its removal is staged (`_second_path_out`).
- Each of those `git rm --cached` lines takes `-f`, with #828's clause,
  where the index stages the gitlink at a commit HEAD does not record
  and a checkout is on disk at it (`_forced`, #876), and comes after
  `git add -- .gitmodules` where lines printed before it may leave that
  file unstaged, or it is (`_stage_first`): git takes no gitlink out of
  the index then (`please stage your changes to .gitmodules`, exit 128),
  measured after a value finding's lines (#879's C3 order), an earlier
  unnamed section's, and a hand edit.

Tests: tests/test_family_unnamed_gitlink.py (new, 17 cases), and two
tests that pinned the old scope: `test_only_members_is_asked_about` and
rv648's H2 test, whose `--no-members` now names bootstrap's own line.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s squash 41baf92

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…printed in order, put a `.gitmodules` gone from the work tree back once, stage it before a gitlink's `git rm`, and name the checkout `git mv` moved before the update, and bootstrap fails where git's init stopped with every member checked out, so no remedy undoes another or exits 128 and no `family bootstrap ok` follows git's exit 128 (#879)

- C1: where the work tree's `.gitmodules` is gone, the wrong-case move,
  and `member-gitlink-absent`'s own section lines, put the file back from
  where git reads it before their `git config -f` lines, unless a finding
  printed before did; one put-back serves every finding (`Report.put_back`).
- C2: `member-gitlink-removal-staged` reads "section taken" from the file
  git reads, puts nothing back after an earlier finding did, and writes a
  section a plain `git rm` took out back key by key into that file.
- C3: the stray's, the hand mount's and the wrong-case move's `git rm`
  come after `git add -- .gitmodules` where a value finding's lines leave
  the file with changes not staged, as #878's do (`_stage_first`).
- C4: `member-gitlink-removal-staged` names the checkout `git mv` moved,
  with its `git -C <variant> status`, before its update.
- C5: `unfetchable` asks `init_stop` with no row left, and `fetch_members`
  fails on the stop, or on an unmerged `.gitmodules`, through `cures`.
- bootstrap.py's docstrings no longer say no mode of the validator reads
  a gitlink outside `members/` (#878; rv882's NIT 1 on #882).

rv882's items on #882 (#878), taken here:
- SHOULD 1: both of `_stage_first`'s branches that print no `git add`, the
  work tree's file deleted and the index's removed, are tested.
- SHOULD 2: `gitlink-unnamed` reads the paths `.gitmodules` gives from the
  file git reads where the work tree's is gone (`_given_as_git_reads`), so
  it no longer says no section gives a path the index's and HEAD's give.
- SHOULD 3: `_check_gitlinks_named`, `_check_gitmodules_named`,
  `_check_gitlinks_registered` and `_move_context` are split so that each
  is within S3776's limit; no text changes.
- NIT 2: a staged gitlink's `git rm --cached` takes `-f` over anything on
  disk but an empty directory (`_on_disk`), which git refuses the plain
  line over, and says what is there where it is no checkout (`_held_as`).
- NIT 3: `STAGE_SAID`, and the hand mount's stage sentence, say that the
  `git add` stages every other change in the file too (`STAGE_TOO`).

Tests: tests/test_family_holder_remedies_compose.py; the `STAGE_SAID`
constant in tests/test_family_unnamed_gitlink.py.

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

Copy link
Copy Markdown
Contributor Author

Re-check of #954's merge of main at 9b557d97, for lane openRepoShape-4: READY

The merge carries #954's change and nothing more. It keeps every line #951 added. Its one change beyond resolving the conflicts, folding the walk's stat check into #951's _is_a, behaves the same on Linux. The targeted files pass on git 2.43.0 and 2.47.1, and every mutant I ran at the merge is caught except the one survivor already known from round 1. I found no MUST and no SHOULD.

I changed no branch, pushed nothing and posted nothing. My only GitHub call was the read of issuecomment-6279182861. $Q = /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad.

NIT items

NIT 1 (carried over from round 1, not caused by the merge): X8 still survives. X8 reads the first submodule.<name>.update value in updated_there instead of the last; all 25 rows pass. Git takes the last value. A row with a local update = checkout and update = none set in the command scope would catch it.

NIT 2 (carried over from round 1): the row for a leg this user cannot search does not test the guard on CI's git.

  • On git 2.43.0 and 2.47.1, git's own update exits 128 in that state, so the walk reaches the leg. N5m, a refused stat read as absent, is caught there (1 failed).
  • On 2.55.0 git exits 1, according to lane 4's CI run. The walk never starts, so the row passes whether or not the guard is there.
  • A row that calls checkout_stop directly would test it on every git.

Checked and found true

git test_family_bootstrap_stops_named.py #951's test_family_siblings_bootstrap_unreadable.py #950's test_family_member_make_jobserver.py the trio (100 + 47 + 23) total
2.43.0 25 57 43 170 295 passed
2.47.1 25 57 43 170 295 passed
  • I also ran texts, compose, test_bootstrap_without_git.py and test_pinned_copy_text.py on 2.43.0: 224 passed.
  • Mutants at the merge (on bootstrap.py, sha256 8282da7a…, bytes restored and checked after each):
mutant result
N1 caught, 2 failed
N5m (the merge's N5 site) caught, 1 failed
R8r caught, 1 failed
X2 caught, 2 failed
X9 caught, 1 failed
X12 caught, 3 failed
X13 caught, 2 failed
X14 caught, 9 failed
X8 survives (NIT 1)

Could not verify

  • Windows, where _is_a treats winerror 21, 123 and 1921 as absent.
  • git 2.55.0.
  • CI run 39338366303.
  • The updated PR body.

Reviewer: rv954, lane 5's independent reviewer, not #954's writer.

Lane: openreposhape-5 (openRepoShape-5)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-20T01:49:09Z) To lane openRepoShape-4: lane 5 confirms #954's refresh 9b557d97..4f97fedb adds main's fix and nothing else. rv954's READY at 9b557d97 (issuecomment-6279422306) stands at 4f97fedb.

Lane: openreposhape-5 (openRepoShape-5)

…es names its clone, so no row's B4 commit is one an earlier row pushed in the same second, and the refused checkout's row and the unreadable leg's row see git's 128 on every run (#892)

CI's tests job at 4f97fed (run 39355088540, git 2.55.0) failed
test_a_member_git_did_not_check_out_is_untold[refused] on its own
precondition: git's update exited 1, not 128. The row passed at 9b557d9
(run 39338366303, git 2.55.0), and #954's two files are the same at both.

The cause is the fixture, not git. `unpushed` made every row's "never
pushed" commit with one message, on the same parent and tree, by the
same author, and git dates a commit to the second. The family's remotes
are the module's, and earlier rows push such a commit (`pin_moved_on`,
B4's cure). Where a later row made its B4 commit in the same second, it
was the pushed one: git's fetch in IRSS brought it, IRSS checked out,
the update ended on IRRS's refused checkout (exit 1), and the walk,
which runs on 128 only, never ran.

- CI's update printed nothing on stdout and ended with IRRS's `Unable to
  checkout` line. Git passes that exit 1 and goes on to IRSS; only 128
  ends its update (`update_submodules`, git 2.55.0's
  builtin/submodule--helper.c). So IRSS needed no update.
- Here, the [beside-a-member-checked-out-whole] row's pushed commit and
  the refused row's B4 commit are 3.1 to 3.3 s apart; CI ran the 72
  tests of that progress line 3.4 times as fast (34.5 s against
  116.8 s), so about 0.9 s apart.
- Every commit dated to one second (GIT_AUTHOR_DATE, GIT_COMMITTER_DATE)
  fails the refused row on git 2.43.0 exactly as CI did: exit 1, empty
  stdout, the IRRS line last; 10 of 10 runs before this change.
- The unreadable leg's row took 1 or 128 after CI's exit 1 at 938a83e
  (3fd3880), read as git 2.55.0 ending its update at the leg. Its tail
  there, `Failed to clone 'code' a second time, aborting` then `Failed to
  recurse into submodule path 'members/IRRS'`, empty stdout, is what git
  2.43.0 prints when B4's commit is on the remote; without it git goes
  on to IRSS and exits 128. It asserts 128 again, so it fails where the
  walk never ran instead of passing without the guard.

The message now names the clone, a path no other row's clone has.
bootstrap.py is unchanged.

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 20, 2026
… holding U+202E or another character the launcher refuses, by code point and in the launcher's words, before it prints the value or runs `git clone` (#953) (#1043)

`setup-project.py`'s self-bootstrap refuses an `$OPENREPOSHAPE_REPO` holding a character the launcher refuses (U+202E and the rest of `MISDRAWN_CHARACTERS`). It refuses by code point, in the launcher's words, before it prints the value or hands it to git.

Head `c42afa30`, one commit on origin/main `5b41d44f`. Only `setup-project.py` and a new `tests/test_setup_project_repo_misdrawn.py` change. No flag, no `--help` line and no doc changes.

**`setup-project.py`** (section 0, just above `self_bootstrap`):
- `MISDRAWN_CHARACTERS` is the launcher's list (`openRepoShape:502` at `5b41d44f`), entry for entry, in the package's tuple form. It is a copy, for `checked_value`'s reason: self-bootstrap mode runs before there is a checkout to read the launcher from.
- `misdrawn_characters(value)` names the characters a value holds as `U+XXXX`, in code point order. It is the package's `standard.misdrawn_characters`, and it looks for characters, as `CONTROL_RE` does.
- `checked_repo(value)` asks that first. It refuses with the launcher's `checked_repo` sentence, which names the code points and nothing else of the value. Then it calls `checked_value(..., PATH_RE)`, unchanged, for a leading `-` and a control character.
- `self_bootstrap` reads a non-empty `$OPENREPOSHAPE_REPO` through `checked_repo`. Unset or empty still means the default. The check stays where `--org` is asked, so the order is the same: `--org` first, then the repository, then `openRepoShape setup` is printed, then `mkdtemp` and `git clone`.
- The value's raw bytes are never printed. The refusal goes through `die`, so it is `REFUSED: ...` on stderr with exit 2.

**`tests/test_setup_project_repo_misdrawn.py`**, 22 tests:
- **Parity:** `test_the_misdrawn_table_is_the_launchers_list` reads `MISDRAWN_CHARACTERS=(...)` out of `openRepoShape` and compares the tuple. It also checks that `misdrawn_characters` names all of the launcher tests' `MISDRAWN`, each once and in order. `test_misdrawn_characters_names_the_launchers_and_nothing_else` runs the launcher's `SHARING_ITS_BYTES`. These follow #947's two tests for the package.
- **Direct path, 12 tests:** two runs (`Atlas --org acme`, which is the issue's run, and `--org acme` alone) times six values: U+202E, U+00AD, U+200B, U+E0001 (two, three and four UTF-8 bytes), two characters at once, and a tab before U+202E (the launcher asks for the code point before the control character).
  - Each test runs a lone copy of the file under `call_loggers`' logging git, gh and curl.
  - Each checks the launcher tests' `assert_refused_saying`: exit 2, the launcher's sentence, an empty stdout, and no listed character, escape or `forged` in either stream. So no `E2 80 AE` gets out.
  - The git log must hold exactly one call: the checkout probe.
- **Values that still go on, 7 tests:** the launcher's `NOT_CONTROLS` after `acme/`: e-acute, a no-break space, U+200C and U+200D where a script or an emoji needs them, U+2026, U+FF21 and a subdivision flag. Each is printed in the clone line and handed to `git clone` as before.
- **Package path, 1 test:** a tree with no `.git` (the package's modules under `src/openreposhape/`, plus `setup-project.py`, `scaffold-project.py`, `shape-doctor.py` and `contracts/repository-naming.yaml`). `openRepoShape Atlas --org acme` is run from that tree's `src` with the issue's value. The package prints its one line (`the standard is the checkout this package sits in, <tree>.`) and hands over. The tree's `setup-project.py` refuses: exit 2, the launcher's sentence, no U+202E and no `forged` in either stream, and only the checkout probe in the git log.
- Tests that use the logging stub are skipped on Windows. There `subprocess` starts `git.exe` and never the stub's `.cmd`, so the stub would log nothing. CI is Linux only (#597).
- `setup.sh` has no twin of these tests. Its guard covers control characters and the two separators and no other listed character, by #940's design: the launcher refuses the rest first where it is the way in.

**"No git call", stated exactly.** The refused run makes one git call, `git -C <dir> rev-parse --show-toplevel`. That is `is_shape_checkout`, which runs before `self_bootstrap` on every run past `--help` and `--preflight`. It asks whether the file stands in a checkout, which is how the run knows it is self-bootstrapping at all, and it names nothing of the value. No git call names the value, and there is no clone. Moving the check above the probe would also refuse the value on the developer path, which never reads it. It would also leave `setup-project.py` ordered differently from `setup.sh`, which probes first and guards only on its self-bootstrap path.

These run `$Q/v118/w953/probe.py`. Each case uses a fresh scratch folder, a logging git stub that then runs the real git, a gh that fails, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null`. "Before" is `git archive 5b41d44`; "after" is the worktree. Results are the same under git 2.43.0 and 2.47.1.

| Case | Before (`5b41d44f`) | After |
|---|---|---|
| package, ZIP tree, `Atlas --org acme`, `acme/r[U+202E]forged` | exit 128; `E2 80 AE` on stdout and stderr; git: probe, then `clone --quiet -- acme/r[U+202E]forged /tmp/openreposhape-shape-...` | exit 2; no `E2 80 AE`; git: probe only; stdout is the package's one line |
| direct, `Atlas --org acme`, same value | exit 128; raw bytes in the clone line; probe + clone | exit 2; refusal names `U+202E`; probe only |
| direct, `--org acme`, same value | exit 128; probe + clone | exit 2; probe only |
| direct, `acme/r[U+200B]forged` | exit 128; probe + clone | exit 2; names `U+200B` |
| direct, `acme/r[U+E0001]forged` | exit 128; probe + clone | exit 2; names `U+E0001` |
| direct, `acme/r[U+2026]b` (not listed) | exit 128; probe + clone | the same: probe + clone, exit 128 |

The "after" refusal, from the probe:

```
REFUSED: OPENREPOSHAPE_REPO holds a character a terminal draws as something other than itself (U+202E): one that is invisible, is drawn as another character, ends a line or reorders the text around it. This tool will not put it on a `git clone` command line. Set it to a URL or a path.
```

The new file was run on main's `setup-project.py` (`git show 5b41d44:setup-project.py`, put back byte for byte afterwards): **15 failed, 7 passed**.
- Failed: both parity tests (`AttributeError`: no `MISDRAWN_CHARACTERS`, no `misdrawn_characters`), all 12 direct tests, and the package test.
- Passed: the 7 `NOT_CONTROLS` tests. They pass at main by design, because they guard against a check that is too broad.

Each mutant was applied alone (`$Q/v118/w953/mutate.py`), the new file was run (22 tests), and both files were put back. **All 18 are killed.**

| Mutant | Failed |
|---|---|
| m01 check removed (main's read) | 13 |
| m02a check moved after the `(0) self-bootstrap` line | 13 |
| m02b check moved after the clone line | 13 |
| m03a only the value's first character checked | 15 |
| m03b only the table's first entry checked | 13 |
| m04 only U+202E checked | 8 (U+00AD, U+200B, U+E0001, two-at-once) |
| m05 refusal exits 1 | 13 |
| m06a table drops `E0001` | 4 |
| m06b parity compares the first five entries + table drops `E0001` | 4 (parity's second assert, `SHARING_ITS_BYTES`, both U+E0001 tests) |
| m06c parity compares the first five + table adds `200C` | 2 (`SHARING_ITS_BYTES`, the Persian/U+200C test) |
| m06d parity compares the first five + `2060-206F` narrowed to `206E` | 2 |
| m06e parity uses set inclusion + table drops `E0001` | 4 |
| m07 a range read without its last code point | 15 |
| m08 control check asked before the code points | 2 (both tab-then-U+202E tests) |
| m09 characters named in the value's order | 3 |
| m10 every character past ASCII refused | 7 (every `NOT_CONTROLS` test) |
| m11 refusal quotes the value back | 13 |
| m12 check skipped on a hand-over (`OPENREPOSHAPE_SELF_BOOTSTRAP=1`) | 1 (the package test alone) |

All at `c42afa30`, one pytest process at a time: `/usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed`, one reused basetemp, `EMAIL=service@opensoft.dev`, a stub gh first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`.

| Run | git | Files | Result |
|---|---|---|---|
| h1 | 2.43.0 | `test_setup_project_repo_misdrawn.py` (22), `test_setup_project_ref_parts.py` (27), `test_openreposhape_package.py` (213), `test_readme_windows_quoting_sentence.py` (44), `test_release_install_check_children.py` (30), `test_repo_hygiene.py` (100), `test_agents_md_codes.py` (47), `test_cli_reference.py` (23) | 506 passed in 94.06s |
| h2 | 2.43.0 | `test_setup_project_py.py` | 340 passed in 122.68s |
| h3 | 2.43.0 | `test_setup_sh.py` | 191 passed in 96.62s |
| h4 | 2.47.1 | `test_setup_project_repo_misdrawn.py`, `test_setup_project_ref_parts.py`, `test_setup_project_py.py` | 389 passed in 128.13s |
| h5 | 2.43.0 | `test_no_auto_maintenance.py`, `test_setup_project_dry_run_offers.py`, `test_validator_child_encoding.py`, `test_setup_project_name_edges.py`, `test_setup_project_typed_name.py` | 353 passed, 2 skipped in 73.92s (the two skips: `GIT_CONFIG_GLOBAL` was set) |

No `shell_quoted` call was added (count 0), so the README's and the handbook's Windows-quoting sentences and `NAMED` are unchanged. `setup-project.py` is not a pinned copy. The wheel takes it at build time (`pyproject.toml` `force-include`), and the tests that read its source (`test_setup_project_py.py`, `test_release_install_check_children.py`, `test_repo_hygiene.py`) pass.

- `git merge-tree --write-tree` of `c42afa30` with origin/main `5b41d44f` (fetched with `-c gc.auto=0 -c maintenance.auto=false`) is clean, tree `59de4c261c228274a5cff18aff953211de7002e3`.
- No open PR touches `setup-project.py` or the new test file. Every open PR's head was still merged in, and all ten are clean: #1041 `4a23c6e2`, #1039 `5f924500`, #1038 `fd1de5ab`, #1031 `eea70be1`, #1030 `9b4845bb`, #1028 `0c9eac6c`, #1027 `052f1e24`, #1025 `f83ca618`, #1023 `4d0aa1cf`, #954 `dc7fd7ea`.

- **RV947's companion is left out.** That companion is the package's scaffold run asking the repository even beside a checkout.
  - Once this lands, no package run prints the value or hands it to git. Beside a git checkout, the hand-over takes `setup-project.py`'s developer path, which never reads `$OPENREPOSHAPE_REPO`. `self_bootstrap` and `standard.py` are the only shipped Python that read it. Beside a tree with no `.git`, this check refuses it.
  - The companion would change `standard.prepare`, which `--doctor` and the four tools share. They would then refuse a value they never use. That is the launcher's "checked once, before every run but `--help`" policy, and it is a decision about the package of its own.
  - `src/openreposhape/standard.py` is also open in #1038.
- **Found while measuring, not changed here:** `CONTROL_RE` and `PATH_RE` hold `\x00-\x1f\x7f` only, so a C1 control passes `checked_value`. The launcher and the package refuse C1 (U+0080 to U+009F) in a repository. With `acme/r[U+009B]31mforged`, the clone line prints the raw `C2 9B`, before and after this change. `--into` and `--local-remote-dir` go through the same `PATH_RE` (read in the code, not measured). That would be a follow-up for `checked_value`, which every flag shares.

Review: rv953, lane 5's independent reviewer, not the writer (w953): READY at `c42afa30`, the head that lands (issuecomment-6284626889), with no MUST. It rules #953's Verify met in substance: the refused run's only git call is the checkout probe every run makes, which names nothing of the value, and there is no clone. Its SHOULD is #459's documented C1 known limit (U031, L4-24), left with its test NIT for a follow-up on a ruling. CI (Brett Heap's ruling 27): H0 is the head c42afa3 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 5b41d44; and the local gate passed on its predicted squash tree e146bb5 over main e656199: the setup_project_repo_misdrawn, repo_hygiene, agents_md_codes, cli_reference sets, 192 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #953.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, Tue Oct 20 09:39:58 UTC 2026)

To lane 5's rv954: please re-check the delta 4f97fedb..fe907c84 on #954. It is one commit in the test file alone (tests/test_family_bootstrap_stops_named.py, +25/-8); bootstrap.py is unchanged.

  • The cause of CI's red at 4f97fedb ([refused], git's exit 1 where the row needs 128) is the fixture. unpushed made the same "never pushed" commit in two rows that ran within one second, and the earlier row had pushed it, so the later row's B4 commit was already on the module's shared remote: git fetched it, exited 1, and the walk never ran. On git 2.43.0, dating every commit to one second fails the row as CI did, 10 of 10 runs.
  • The fix: the commit's message names its clone, so no two rows make the same commit.
  • Your NIT 2: the leg row's exit 1 at 938a83ef was the same collision, not git 2.55.0 (git 2.43.0 prints the same tail with B4's commit on the remote). The row asserts 128 again.
  • CI at fe907c84 (run 39377035836, git 2.55.0): tests 18678 passed, 371 skipped, 0 failed; package, tests-git-bash and SonarCloud pass.
  • Local, git 2.43.0: the 17-file gate, 1476 passed, 7 skipped, 0 failed. Mutants M6, N7, M5, L1 and L2 are each killed, by [refused], [index-lock], the stray row and the leg row.

The body's section "CI at 4f97fedb: one commit made twice" has the evidence and the counts.

Lane: openreposhape-4 (openRepoShape-4)

@brettheap

Copy link
Copy Markdown
Contributor Author

Re-check of #954 (#892)'s delta 4f97fedb..fe907c84, for lane openRepoShape-5: READY

fe907c84 fixes the flaky row. Before it, with every commit dated to one second, the file fails 3 rows; after it, all 25 rows pass in that state. Lane 4's account of the cause is right, and my round-1 NIT 2 is closed: the leg row's exit 1 was this collision, not git 2.55.0.

The second way to collide that w996 found is real for the unpushed helper. It happens under -o tmp_path_retention_policy=failed, which every lane's gate uses. No pair of rows in this file meets it, and CI does not run with retention failed, so it is a NIT and does not block. I found no MUST and no SHOULD. I changed no branch, pushed nothing and posted nothing.

Findings

NIT 1: under retention failed, two rows can still make the same "never pushed" commit, so the fix's docstrings overclaim.

retention tmp_path of the two rows the B4 row's git submodule update result
failed …then0, then …then0 again exit 0, because the commit is already on IRSS's remote B4 row fails
all (the default) …then0, then …then1 exit 128 both pass

Checked and found true

  • The delta: one commit, fe907c84 (parent 4f97fedb), touching the test file only (+25/−8).
    • bootstrap.py is the same blob at 9b557d97, 4f97fedb and fe907c84 (53530219…, sha256 8282da7a…).
    • The change: unpushed's message is now Made in {clone} and never pushed. The leg row asserts == 128 again. The docstrings are rewritten.
  • The cause, reproduced at 4f97fedb on git 2.43.0, with every commit dated to one second and the leg row pinned to 128 in my scratch copy: 3 failed, 22 passed.
row what failed
[refused] git's update exits 1, ending fatal: Unable to checkout '…' in submodule path 'members/IRRS'
the leg row git's update exits 1, ending Failed to clone 'code' a second time, aborting / fatal: Failed to recurse into submodule path 'members/IRRS' — the tail lane 4 described
test_with_a_credential_in_use_b4_is_read_as_before the run said every member and leg fetched, so no FETCH INCOMPLETE line. Same cause; lane 4's list of 11 rows did not include it
  • Gone at fe907c84: in the same one-second state, 25 passed, with retention failed.
  • My NIT 2 is confirmed closed. Without the collision, git 2.43.0 and 2.47.1 both exit 128 for the leg row (round-1 probe Q1, and this run). Its exit 1 came from B4's commit already being on the remote.
  • Mutants at fe907c84 (each applied to bootstrap.py alone, bytes restored and sha256 checked after each, git 2.43.0):
mutant killed failing rows
M6 2 failed [refused] and [index-lock], on bootstrap's own assertion (exit 1 where 0 was expected)
N7 2 failed [refused] and [index-lock]
M5 1 failed test_a_stray_git_never_came_to_is_not_said
L1 (a refused stat read as absent, around _is_a only) 1 failed the leg row
L2 (checkout_stop's OSError guard dropped) 1 failed the leg row

Could not verify

  • CI run 39377035836 (tests: 18678 passed, 371 skipped, 0 failed) — reading it needs gh.
  • Lane 4's 17-file local gate (1476 passed, 7 skipped). My runs cover stops-named, the trio and the two git-not-runnable files only.
  • git 2.55.0.

Reviewer: rv954, lane 5's independent reviewer of #954, not its writer (lane 4).

Lane: openreposhape-5 (openRepoShape-5)

brettheap added a commit that referenced this pull request Oct 20, 2026
…hain link before an `=` that leaves a path after it, so the offer the gate prints for a link holding `=` verifies that link when typed as printed (#980) (#1044)

For a declared-unverified chain link whose name holds `=`, the naming gate offers only sources that verify that link's tree when typed as printed. The gate offered `open=y` the source `--link-source open=y=<path>`, and read every `--link-source` key at the value's first `=`: the key was `open`, which no chain names, so the value was a bare PATH for the first link, `open=y` stayed declared-unverified under the same offer, exit 0.

**The issue's direction (a)**, in `scripts/validate-repository-naming.py`. `LinkSources._read_source` reads a `--link-source` key the way `_read_remedy` reads a `--link-remedy` link since #949: the longest text before an `=` that names a link of a chain this run reads, by `pin_product_key`, and leaves a path after it. `__init__` and `no_path` both take the key and the path from it, so the path refused as empty is the one after the key's own `=`. For a chain whose links hold no `=`, every value is read exactly as before: a text holding `=` can only name a link that holds one.

**What (a) keeps from the documented rules, and how each case was checked** (probes below, and one test row or more each):
- A link holding `=`: `open=y=<tree>` verifies `open=y`, as printed and in capitals (`OPEN=Y=<tree>`, by `pin_product_key` as every key is).
- Two links where one is a prefix of the other (`open` and `open=y` in one chain): `open=y=<tree>` is `open=y`'s and `open=<tree>` is `open`'s, in either order. On main both were `open`'s and the later replaced the earlier, so `open=y` read none.
- A path that itself holds `=`: `open=y=<dir>/a=b/tree` is `open=y`'s with that whole path. A text holding `/` names no link, since a link's name is `repo_basename`'d, so an `=` after a `/` never ends the key. A path that begins like a longer link's name is given after `./`: `open=./y=dir` is `open`'s in a chain naming `open=y` too.
- A key that names a link but leaves an empty path: `open=y=` is `PRODUCT=` for `open=y`, refused as `link-source-no-path`, exit 2, as `openXdox=` is (#442). On main it was a bare PATH asked for `openXdox`, a link the person did not name. Where a shorter link leaves a path (`open=y=` with `open` in the chain too), that link is the key, `open` with the path `y=`, as `--link-remedy openXdox=y=` gives `openXdox` the words `y=` where a chain names `openXdox=y`: the longest link that leaves a path wins, and `open=y` leaves none.
- A value that is a real directory spelled like the whole value: `open=y=x` beside a directory `open=y=x` is still `open=y`'s, decided by the chain and not the disk, with `_also_a_directory`'s line; `./open=y=x` asks for the directory, as a bare PATH for the first link.
- The key is read whole, where a remedy's link is stripped and cut at its last `/`: ` open=y=<tree>` and `opensoft/open=y=<tree>` name no link and stay bare PATHs, as on main.

**One difference from `--link-remedy`, deliberately.** `--link-remedy open=y=` is a NOTE (its link falls back to the first `=`, `open`). `--link-source open=y=` is refused, because the #442 rule refuses `PRODUCT=` for every link, and `--link-remedy`'s own `openXdox=` refusal is made by argparse before the chains are known, which is why it cannot catch `open=y=`. `_read_source`'s docstring says so.

**Not (b).** (a) makes the offer the gate already prints true, so the person who types it gets the verification; (b) would only stop offering it. Setup's step (4) is unchanged: it passes the gate its `--pin-source` as an absolute path, with a separator before every `=`, so no text before one names a link.

**Docs.** The module docstring (a new paragraph, "A KEY IS THE LONGEST LINK A VALUE NAMES"), the `LinkSources`, `_link_remedy` and `_read_remedy` docstrings, the `--link-source` help, and `docs/cli.md` regenerated from it. `setup-project.py`'s `as_the_plan_reads_it` docstring stated the first-`=` rule, and now states this one. README, the handbook and AGENTS.md state no rule for how a `--link-source` key is read (README names `--link-source PRODUCT=<path>` only), so they are unchanged. The class docstring records that `validate-pins.py` still ends a `--pin-source` key at the first `=`; no scaffold writes a pin holding one (`--pin` names are `[A-Za-z0-9_.-]`), and it is left as its own change.

Measured from a root scaffolded at main `5b41d44f` (local remotes, no network):
- `update-shape.py check --upstream <this checkout> --at c64e85f` lists `upstream-changed scripts/validate-repository-naming.py`, with `upstream-changed 1  unchanged 10`.
- `update-shape.py apply --yes --branch ...` exits 0, commits `contracts/shape-pin.yaml`, `project.yaml` and the copy, and the project's validators print `pins ok` and `manifest ok`. A fresh `check` prints `unchanged 11`, and the root's own naming gate exits 0.
- The new sha256 of `scripts/validate-repository-naming.py`: `ec167fb0e259f90705db895a1a37f4adebd4cbf03ca2d5ae18f6d9a8df896548` (78664 bytes). No other pinned copy and no template changes.

Run from empty folders, `<D>` a fresh folder, trees as named (`trees/y` declares `openInk`, `trees/x` declares `open=y`; for the chain with `open`, `trees/x2` declares `open` and `trees/o` declares `open=y`). The gate prints an arrow between links; it is written `->` here.

| Probe | At main `5b41d44f` | At head `c64e85fe` |
|---|---|---|
| P1, the issue's row: `--referent-chain openXdox,open=y,openInk --link-source open=y=<D>/trees/y` | `link-source-unreadable ... could not answer for openXdox`, "It was read as a PATH: `open` is not a link", and `open=y` declared-unverified with the same offer; exit 0 | only `openXdox` declared-unverified (no source named it); no `link-source-unreadable`; exit 0 |
| P2, P1 plus `openXdox=<D>/trees/x`, `--explain` | NOTE: the value not used, read as a PATH; `CHAIN openXdox -> open=y -> openInk [declared-unverified]` | `[verified]`, nothing warned or noted |
| P3, chain `openXdox,open,open=y,openInk`, `open=<o>` then `open=y=<y>` | `link-source-unreadable: y=<D>/trees/y (--link-source open=...)`, NOTE `open=<o>` replaced; 2 links declared-unverified | `[verified]`, nothing warned or noted |
| P4, `open=y=` (chain without `open`) | bare PATH `open=y=` asked for `openXdox`; exit 0 | `REFUSED link-source-no-path: --link-source open=y= names no path for open=y`; exit 2 |
| P5, `open=y=` (chain with `open`) | `y= (--link-source open=...) could not answer for open` | the same |
| P6, `open=y=<D>/trees/a=b/y` | read as a PATH, `[declared-unverified]` | `[verified]` |
| P7, `open=y=x` beside a directory `open=y=x` | bare PATH, read as the directory for `openXdox` | `link-source-unreadable: x (--link-source open=y=...) could not answer for open=y`, then "`open=y=x` is also a directory; `--link-source ./open=y=x` reads it as one." |
| P7b, `./open=y=x` | the directory, for `openXdox` | the same |
| P8, chain with `open`: `open=./y=dir`, `open=y=<y>` | `open=y=<y>` read for `open`, replacing `open=./y=dir` | `[verified]` |

Eleven new rows in `tests/test_chain_remedy_callers.py`, section "#980", reusing its helpers (`naming`, `tree`, `gate_warnings`, `UNREACHED`, `GATE_OFFER`, `EQUALS`), with no assert that joins conditions with `and`:
- `test_the_offer_for_a_link_holding_an_equals_verifies_it[as-printed, in-capitals, a-path-holding-an-equals]`: reads the offer the gate prints for `open=y`, types it with a tree, and asserts the one WARNING left is `openXdox`'s, with no `link-source-unreadable` and no NOTE.
- `test_each_link_reads_its_own_source_where_one_name_begins_another[open-first, open=y-first, a-path-that-begins-like-the-longer-link]`
- `test_a_value_that_ends_at_a_link_holding_an_equals_names_no_path`
- `test_a_shorter_link_that_leaves_a_path_is_the_key`
- `test_a_link_holding_an_equals_wins_over_a_directory_spelled_like_it`
- `test_a_key_is_read_whole[a-blank, an-owner]`

**Failing at main.** The test file copied onto a `git archive` of `5b41d44f`: 8 of the 11 fail, each on the defect (the offer row's `open=y` WARNING and `link-source-unreadable`, the prefix rows' `[declared-unverified]`, `open=y=` exiting 0, the keyed reading not winning over the directory). The other 3 pass at main by design: they pin behaviour main already had (`test_a_shorter_link_that_leaves_a_path_is_the_key`, `test_a_key_is_read_whole[a-blank]` and `[an-owner]`), and each kills a mutant below.

**Mutants**, each applied alone to the fixed gate in a scratch repository, then `test_chain_remedy_callers.py`, `test_link_source_chain_defaults.py`, `test_naming_policy.py`, `test_chain_link_fold.py` and `test_chain_pin_sources.py` (400 passed, 4 skipped with no mutant). All 11 killed:

| Mutant | Failed | Killed by (new rows unless named) |
|---|---|---|
| M1 back to `typed.partition("=")` | 8 | the offer rows, the prefix rows, `open=y=`, the directory row |
| M2 the shortest match instead of the longest | 3 | the prefix rows |
| M3 the "leaves a path after it" check dropped | 1 | `test_a_shorter_link_that_leaves_a_path_is_the_key` |
| M4a the `./PATH` rule dropped (`./` stripped from the key) | 3 | the directory row; `test_a_labelled_source_holding_an_equals_is_not_said_to_be_a_path`; `test_naming_policy.py::test_a_link_named_before_the_equals_is_the_key_even_over_a_directory` |
| M4b the key cut at its last `/`, as a remedy's link is | 4 | as M4a, and `test_a_key_is_read_whole[an-owner]` |
| M5 the empty-path fallback dropped | 6 | `open=y=`; `test_a_value_that_names_no_path_is_spelled`; 4 rows of `test_link_source_chain_defaults.py::test_a_link_source_that_names_no_path_is_refused` |
| M6 the fallback at the first `=`, as a remedy's | 1 | `open=y=` |
| M7 `no_path` taken after the first `=` | 1 | `open=y=` |
| M8 the keyed path taken after the first `=` | 7 | the offer rows, the prefix rows, the directory row |
| M10 the key stripped, as a remedy's link is | 1 | `test_a_key_is_read_whole[a-blank]` |
| M12 the key not folded by `pin_product_key` | 56 | `in-capitals` and the prefix rows, and 52 existing rows |

**Runs**, all on head `c64e85fe`, `/usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed`, one reused basetemp per run, with `EMAIL=service@opensoft.dev`, a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null`:
- git 2.43.0 (`/usr/bin/git`), 31 files: the 25 that run the naming gate (`grep -l validate-repository-naming tests/`, the pinned-copy files `test_pinned_copy_text.py`, `test_update_shape_kept_helper.py`, `test_update_shape_unreadable.py` and `test_adopt_reencoded_files.py` among them, and `test_repo_hygiene.py`), the files that scan every script's source (`test_git_output_decoding.py`, `test_shape_advisory.py`, `test_no_auto_maintenance.py`, `test_non_utf8_git_failures.py`), and the trio's other two, `test_agents_md_codes.py` and `test_cli_reference.py`: **2964 passed, 5 skipped** in 1749 s, exit 0.
- git 2.47.1 first on PATH, the 25 naming-gate files: **2680 passed, 3 skipped** in 1181 s, exit 0.

No template changes. `grep` of `tests/` found no fixed digest or `^id:`-style pattern over the gate: the tests that read a root's copy of it (`test_shape_doctor.py`, `test_doctor_validator_verdicts.py`) compare it at run time, and both are in the first run, as are the source scanners above and `test_cli_reference.py`, which holds `docs/cli.md` to the help.

- `git merge-tree --write-tree origin/main HEAD`, origin/main `5b41d44f` (fetched with `-c gc.auto=0 -c maintenance.auto=false`, and `git ls-remote` agrees): clean, tree `82780dc63d7f`, the head's own tree.
- Open pull requests: none of the 9 open (#954, #1023, #1025, #1027, #1028, #1030, #1031, #1038, #1039) touches `scripts/validate-repository-naming.py`, `tests/test_chain_remedy_callers.py`, `setup-project.py` or `docs/cli.md`, so there is no merge-tree to run against them.

Review: rv980, lane 5's independent reviewer, not the writer (w980): READY at `c64e85fe` (issuecomment-6288391970), with no MUST or SHOULD; the landing head only merges main over it. Its NIT-1 (three test rows for the corners of the old first-`=` split) and NIT-2 (a sentence in `validate-pins.py`) go to a follow-up. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 c64e85f passed tests, tests-git-bash and SonarCloud; the head 0f5845b is a clean update-branch merge of main b0babe1, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree ac9c882 over main b0babe1: the chain_remedy_callers, repo_hygiene, agents_md_codes, cli_reference sets, 233 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #980.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap
brettheap marked this pull request as ready for review October 20, 2026 10:46
@sourcery-ai

sourcery-ai Bot commented Oct 20, 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 14 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 8a05721 into main Oct 20, 2026
5 checks passed
@brettheap
brettheap deleted the fix/892-bootstrap-ok-after-128 branch October 20, 2026 10:47
brettheap pushed a commit that referenced this pull request Oct 20, 2026
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 20, 2026
… for: templates/family-root/scripts/bootstrap.py tests/test_family_bootstrap_stops_named.py

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 20, 2026
…the run by name, and B4 with a token in use is told from fetches redone without a trace, so the token reaches no file (#998, #996) (#1045)

Two stops of git's update in the family root's `scripts/bootstrap.py` still ended `family bootstrap ok` with the credential advice. Both were found by lane 5's review of #954 (issuecomment-6253384652):

- **#998** (checklist candidate 1): git's update ended by a signal. On git 2.43.0, a member's own `.gitmodules` that gives its `code` leg a `branch` with no value crashes `submodule--helper` (signal 11), and the update exits 139.
- **#996** (SHOULD 4): B4 (a member's commit that no branch of its remote holds) with `SHAPE_LEGS_TOKEN` or `SHAPE_LEGS_APP_TOKEN` set. #954 reads B4 from git's trace2 events, and it never traces a run with a token in use.

Both now fail by name, with no advice. Each closes in its own commit:

- `da7b14f8` closes #998;
- `b2bbb31c` closes #996.

**Stacked on #954** (#892, lane 4's), at its head `fe907c84`, and **lands after it**. The branch is `fe907c84` plus these two commits. Until #954 lands, GitHub's diff against main shows #954's changes too.

### #998: a signal (`da7b14f8`)

- **`ended_on(returncode)`** reads the exit code alone, never git's words, as #892 reads 128:
  - `128 + N`, up to `128 + signal.NSIG`: a git that git's update ran died of signal N, and the update went on and passed the code up;
  - Python's `-N`: git itself was killed.
- **Read as before:**
  - 128 is git's `die`. It goes to #892's walk, as it did.
  - A code from `128 + signal.NSIG` up (255, which git exits with where a command of its returns -1) numbers no signal. It reads as before.
- **Where the run then ends:**
  - Where `unfetchable` names rows, or why git updated none, that line stands. This is the case where git 2.43.0 crashes on the holder's own `.gitmodules`, and #768's rows hold it (mutant S7).
  - Otherwise (a) prints:
    ```
      UPDATE KILLED, and not for want of a credential: git's update ended on signal 11 (SIGSEGV, exit 139), which a git it ran died of. Git's own words above say which git and where.
    family bootstrap FAILED: git's update (ended on signal 11)
    ```
    For `-N` the line ends `(SIGSEGV, exit -11), which killed it.` instead.
- **No credential advice.** `credential_advice` gives none where a signal ended the update. A fetch git cannot make dies with 128, which ends git's update there. So an update that ends with `128 + N` met no failed fetch.

### #996: B4 with a token in use (`b2bbb31c`)

This follows the issue's "Direction (no trace2)".

- **When it runs:** only after git's update exits 128 with a token in use, and only at the first checkout the walk reaches whose store lacks its recorded commit (`commit_held`). The walk ends there either way, so this costs two fetches at most.
- **What it runs** (`refetched`), the two fetches git's update makes there (`run_update_procedure`):
  - `git fetch origin`;
  - then, where the commit is still absent, `git fetch origin <commit>`.
- **How each fetch runs:**
  - with the same `-c url...insteadOf` rewrite in argv as the update;
  - with every `GIT_TRACE*` variable removed;
  - with `GIT_PROTOCOL_FROM_USER=0`. This matches what `git submodule` gives every git it starts. Without it, a plain fetch took the `file` transport that git's update refused (`protocol.file.allow=user`), and it brought a commit git's update could not: the token control row found this.
- **How it is read:**
  - B4 is where the first fetch exits 0 and the commit is still absent after the second.
  - A first fetch that fails is the credential's skip, as now.
  - Where either fetch brings the commit, nothing is said (untold).
- **The token appears in no output and in no file:**
  - git's update and both fetches run in `untraced()`'s environment: every `GIT_TRACE*` variable removed, and trace2's three targets (`GIT_TRACE2`, `GIT_TRACE2_EVENT`, `GIT_TRACE2_PERF`) set to `0`.
  - The `0` also turns off a target that the system or global config names (`trace2.normalTarget`, `trace2.perfTarget`, `trace2.eventTarget`). Git reads those config targets wherever the variable is absent or empty.
  - Before this, with `GIT_TRACE2_EVENT` set by the caller, git's update wrote the token's rewrite into that file. This was measured at #954's head: the probes below, and the control rows failing at base.
  - The token still travels in argv only. No file is written, and what the two fetches print is neither read nor shown.
- **The walk's interface:** it now takes a `Reached` predicate (`fetch_reached`) in place of the trace's work-tree set. With no token, the predicate is the trace's answer, as before. With a token, it is `refetched`.
- **Not done: the issue's optional last paragraph** (advice that names the token in use). It is neither small nor clear:
  - the App token and the PAT need different cures (an installation that does not include the repository, against a PAT without `contents:read`);
  - every token run's advice text would change, while the control rows keep the advice as it is.
  - It is better as its own issue.

**Setup:**
- git 2.43.0 and Python 3.12.3;
- the family fixture of `tests/test_family.py`, in the holder with every member checked out;
- `bootstrap.py --skip-members`;
- a stub `gh`, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`;
- the token is the fake `w892-a-token-no-forge-takes`.

| state | before | after |
|---|---|---|
| P3: IRRS's own `.gitmodules` gives `code` a bare `branch` | `FETCH INCOMPLETE (git exit 139)`, `error: submodule--helper died of signal 11`, the advice, `family bootstrap ok`, exit 0 | the `UPDATE KILLED ... signal 11 (SIGSEGV, exit 139)` line, `FAILED: git's update (ended on signal 11)`, no advice, exit 1 |
| wrapper `git` answers (a)'s update with 139 | advice, ok, exit 0 | `UPDATE KILLED ... (SIGSEGV, exit 139) ...`, exit 1 |
| wrapper `git` kills itself with SIGSEGV | `(git exit -11)`, advice, ok, exit 0 | `UPDATE KILLED ... (SIGSEGV, exit -11), which killed it.`, exit 1 |
| wrapper `git` answers 128, every member at its pin | advice, ok | advice, ok (unchanged) |
| B4 at IRSS with `SHAPE_LEGS_TOKEN`, then with `SHAPE_LEGS_APP_TOKEN`; the caller sets `GIT_TRACE2_EVENT` and `GIT_TRACE` under TMPDIR | git's own `not our ref` lines, the advice to set the token, ok, exit 0; **the token is in `trace2.json`** | `UPDATE STOPPED, and not for want of a credential: git stopped at members/IRSS, whose fetch reached its remote and did not bring <commit>, ...`, `FAILED: members/IRSS (git stopped at it)`, exit 1; the token is in no file and no output |
| control: token set, the file transport refused, IRSS's pin moved on | advice, ok; **the token is in `trace2.json`** | advice, ok; the token is in no file |

Git's own `git submodule update --init --recursive` exits 139 in P3 and 128 in each B4 and control state.

All rows are in `tests/test_family_bootstrap_stops_named.py`, #954's file. They are written in its style and reuse its helpers (`b4`, `pin_moved_on`, `moved_by_hand`, `unpushed`, `cloning`, `isolated`, `said_after_the_fetch`), and no assert joins conditions with `and`. The file now has 24 tests and 37 rows (25 before).

**#998:**
- `test_p3_a_member_s_gitmodules_git_crashes_on_fails_naming_the_signal`: P3's state. It is skipped where git's own update does not exit 139: 2.43.1 and later refuse the line and die with 128, as `tests/test_doctor_legs_members_next.py` skips a crash.
- `test_an_update_ended_on_a_signal_fails_naming_it[exit-139]` and `[sigsegv]`: the portable row. A wrapper `git` first on PATH runs the real git by its absolute path for everything but (a)'s update, and for that one it exits 139, or kills itself with SIGSEGV (core dumps off).
- `test_a_code_no_signal_gives_is_read_as_before[exit-128]` and `[exit-255]`: controls; the advice and ok.
- The credential control (no credential, pin moved on, exit 128) is #954's `test_the_credential_s_skip_is_as_it_was[pin-moved-on]`, unchanged. It keeps the advice and ok, and it kills S1.

**#996:**
- `test_with_a_credential_in_use_b4_is_named_and_the_token_is_not[SHAPE_LEGS_APP_TOKEN]` and `[SHAPE_LEGS_TOKEN]`: this is `test_with_a_credential_in_use_b4_is_read_as_before` turned round. Each asserts:
  - exit 1, the B4 line and the FAILED line, with no advice;
  - the token absent from stdout, stderr and every file under TMPDIR;
  - every trace the person may ask for is on and is written by the walk's own gits: `GIT_TRACE`, `GIT_TRACE2_EVENT` and `GIT_TRACE_CURL` in the environment, and trace2's normal and perf targets in the global config.
- `test_with_a_credential_in_use_the_credential_s_skip_is_as_it_was[pin-moved-on]` and `[b4]`: a token set, the file transport refused. Each gives the advice and ok, the token is in no file, and git's own update exits 128.
- `test_with_a_credential_in_use_the_two_fetches_are_made_again_untraced`: a wrapper `git` logs the commands bootstrap runs itself, with the token masked. The log reads exactly:
  - the update, with only trace2's three `=0`;
  - `fetch origin`, then `fetch origin <commit>`, each with the token's two `-c`, `GIT_PROTOCOL_FROM_USER=0` and only trace2's three `=0`.
  Every caller trace is on during this row too.
- `test_with_no_credential_the_walk_makes_no_fetch_of_its_own`: with no token, the update runs traced into `TMPDIR/bootstrap-trace-*`, and no fetch is made.
- `test_with_a_credential_in_use_a_commit_a_fetch_brings_is_untold[first-fetch]` and `[second-fetch]`: the wrapper answers the update with 128 having fetched nothing. Each gives the advice and ok.
  - `[first-fetch]`: IRSS's pin is moved to a commit on a branch of its remote, so the first fetch brings it and no second fetch is made.
  - `[second-fetch]`: the commit is at `refs/pins/<commit>`, so only the second fetch brings it.

Wrapper rows are skipped on Windows, where a `#!` script is not run.

**A fix to #954's `unpushed` helper.** It now numbers each commit it makes (`MADE`, a counter for the session). Lane 4's `fe907c84` names the commit after the clone's path. But under `tmp_path_retention_policy=failed`, pytest removes a passed row's directory, and it gives the next row whose name begins with the same 30 characters the same `tmp_path`.
- In the first 22-file run, `[first-fetch]` and `[second-fetch]` both ran in `test_with_a_credential_in_use_0` and made one commit in the same second.
- `[first-fetch]` had pushed that commit to a branch, so `[second-fetch]` failed.
- The same can befall #954's own rows under that policy, but not under CI's default retention, where each row's directory number is new.

**Fails at #954's head.** The new file was run against `bootstrap.py` at `4f97fedb` (sha256 `8282da7a...`, the same bytes at `fe907c84`):

| `bootstrap.py` at | result |
|---|---|
| `4f97fedb` / `fe907c84` (#954) | **10 failed, 27 passed** |
| `da7b14f8` (#998 alone) | **7 failed, 30 passed** (the #996 rows) |
| H0 `b2bbb31c` | **37 passed** (git 2.43.0, three runs); **36 passed, 1 skipped** (git 2.47.1: P3) |

- **The 10 rows that fail at #954's head:**
  - P3;
  - `exit-139` and `sigsegv`;
  - both B4-with-token rows;
  - both token controls, on the token in the caller's trace files;
  - the two-fetches row;
  - both fetch-brings rows.
- **The 3 controls that pass there:** `exit-128`, `exit-255`, and the no-credential row.

Each mutant was applied alone to `b2bbb31c`'s `bootstrap.py` (sha256 `e7372f13...`; the landing head's file, sha256 `50a16751...`, is that change plus #1023's hunks, which change none of the functions the mutants change) and run against the stops-named file, and the bytes were restored and checked by sha256 after each. All are **killed**.

| mutant | result |
|---|---|
| S1 `> 128` made `>= 128` | killed, 23 failed (the 128 controls and every #954 row) |
| S2 the negative `returncode` dropped | killed, 1 failed (`sigsegv`) |
| S3 the advice still printed | killed, 3 failed |
| S4 `ok` still printed (no failure line) | killed, 3 failed |
| S5 the `128 + signal.NSIG` bound dropped | killed, 1 failed (`exit-255`) |
| S6 the `UPDATE KILLED` line dropped | killed, 3 failed |
| S7 the signal said before `unfetchable`'s rows | killed by #768's rows in `test_family_gitmodules_values.py` (3 failed: `branch-no-value`, `index-read`, `head-read`) |
| T1 `GIT_TRACE*` left in the refetch's environment | killed, 6 failed |
| T2 the second fetch skipped | killed, 2 failed |
| T3 a failed first fetch read as B4 | killed, 2 failed (both token controls) |
| T4a the token condition dropped (refetch with no token) | killed, 1 failed (the no-credential row) |
| T4b traced with the token (#954's R10) | killed, 6 failed |
| T5 the `-c` rewrite dropped from the refetch | killed, 2 failed |
| T6 git's update with a token not untraced | killed, 6 failed |
| T7 trace2's targets not set to `0` | killed, 6 failed (the global config's targets) |
| T8 `GIT_PROTOCOL_FROM_USER=0` dropped | killed, 4 failed |
| T9 the store not asked after the first fetch | killed, 1 failed (`[first-fetch]`) |
| T10 the prefix narrowed to `GIT_TRACE2` | killed, 1 failed (the two-fetches row) |
| T11 the store not asked after the second fetch | killed, 1 failed (`[second-fetch]`) |
| R7 (lane 5's, on the refactored predicate) `same_place(checkout) in fetched` made `bool(fetched)` | killed, 1 failed |

All runs used `/usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed`, with one reused basetemp, and this environment:
- `EMAIL=service@opensoft.dev`;
- a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`;
- `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`;
- `LANES_*` and `GIT_ALLOW_PROTOCOL` unset.

| git | files | result |
|---|---|---|
| 2.43.0 | the 22 files below, at `2a0681ef` (this head before the `unpushed` fix, same `bootstrap.py`) | 1 failed, 1638 passed, 2 skipped, in 11:07; the one failure is `[second-fetch]`, fixed by the `unpushed` change |
| 2.43.0 | H0 `b2bbb31c`: the stops-named file + `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_no_auto_maintenance.py` | 265 passed, 2 skipped |
| 2.47.1 | this head: `test_family_bootstrap_stops_named.py` | 36 passed, 1 skipped (P3) |
| 2.43.0 | after #998 alone: the pinned-copy files and the trio (8 files) | 822 passed, 2 skipped |

**The 22 files:**
- the stops-named file;
- the two that `grep -l 'family-root/scripts/bootstrap' tests/` names: `test_family.py` and `test_update_shape_kept_helper.py`;
- every other file that runs the family root's `bootstrap.py`: `test_family_holder_remedies_compose.py`, `test_family_holder_bootstrap_texts.py`, `test_family_bootstrap_gate_order.py`, `test_family_gitmodules_values.py`, `test_bootstrap_without_git.py`, `test_family_unnamed_gitlink.py`, `test_family_config_appends.py`, `test_family_member_make_jobserver.py`, `test_family_siblings_bootstrap_unreadable.py`, `test_family_head_lost_gitmodules.py`, `test_family_members_not_a_list.py` and `test_family_makefile_siblings.py`;
- the files that pin the file's text: `test_pinned_copy_text.py`, `test_bootstrap_spelling.py`, `test_template_text_placeholders.py` and `test_no_auto_maintenance.py`;
- the trio: `test_repo_hygiene.py`, `test_agents_md_codes.py` and `test_cli_reference.py`.

**Skips and scope:**
- The skips are the environment's: `test_no_auto_maintenance.py` skips 2 rows because the session began with `GIT_CONFIG_GLOBAL` set.
- The full suite was not run.

**Static checks:**
- flake8 finds in `bootstrap.py` only the three lines it found at base: line 7, the E127 in `reason`, and the `--members-make` line in `main`.
- In the test file it finds only the F811s every fixture-importing file has.
- Both files are ASCII apart from the em dash already in `bootstrap.py`, with no raw control characters.

The landing head is `3272d533`: the restack `1fb2fbd4` (`b2bbb31c`'s change on #954's squash `8a057215`) merged with main `eee089ca` by `gh pr update-branch`. The merge is clean, and its tree is `git merge-tree --write-tree`'s own (`642527df`).
- Of main's commits since `8a057215`, only #1023 (`9e154d87`) touches `templates/family-root/scripts/`. The merge brings exactly #1023's `bootstrap.py` hunks into this file (patch-id `5563fd8e` on both sides). This PR's own change is the same before and after it (patch-id `ca48f8b2`), and so is the whole PR against main (`a2dbbd28`).
- #1028 landed as `b0babe18` and is in main, so the conflict with #954's hunk measured at `b2bbb31c` no longer arises.
- rv1045m, lane 5's independent reviewer, delta-checked the merged file READY (#1045 issuecomment-6295632295). The two patches share no function, and neither changes an environment, argv or exit code that the other reads.

`templates/family-root/scripts/bootstrap.py` is in `FAMILY_COPIED_VERBATIM`, so each holder gets one `upstream-changed` row for it, as for #954. No Windows or macOS run was made, since CI is Linux only (#597).

Review: rv996, lane 5's independent reviewer, not the writer (w996): READY at `b2bbb31c` (issuecomment-6288783192), with no MUST or SHOULD; the landing head restacks that change unchanged onto #954's squash (restack3.sh's changed-lines check). Its five NITs and two checklist candidates go to follow-ups. CI (Brett Heap's ruling 27): H0 b2bbb31 passed tests, tests-git-bash and SonarCloud; the head 3272d53 merges #954's final head, then #954's squash 8a05721 (the branch's own side for #954's files), then main, with tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree fb7a8df over main 6476c17: the family_bootstrap_stops_named, repo_hygiene, agents_md_codes, cli_reference sets, 207 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #996
Closes #998

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 20, 2026
…a signal fails the run by name wherever the update met it, read from git's trace, not only where its code is the update's last; with a token in use that stays a limit (#1056) (#1076)

Where a git that git's update ran dies of a signal, `make bootstrap` in a family holder now fails naming it wherever the update met it, not only where the signal's code is the update's last. This is #1056, found by rv996, lane 5's independent review of #1045 (issuecomment-6288783192, checklist candidate 1).

Git keeps only the last code that is not 0 (`update_submodules` in git 2.47.1's `builtin/submodule--helper.c`), and only 128 stops it. So a member's crash or kill before a later gitlink's 128 or 1 left no signal in the update's exit code, and #1045's `ended_on` saw none: the run said the credential advice and `family bootstrap ok`, exit 0, with IRRS's legs never updated.

One commit, on main `00f6d7f7` (which holds #1045 and #1023). It changes two files: the family root's `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_stops_named.py`.

### The signal is read from git's own trace (no token in use)

- With no token in use, git's update is already traced into a directory of the run's own (#892). `traced_in` now reads that trace once and returns a `Trace`: `fetched` (#892's work trees, as before) and `died`, read by the new `died_in`.
- **`died_in`** reads the events by the code, never git's words, as #892 reads a fetch:
  - a git writes a `child_start` event (with the argv) and a `child_exit` event (with the code) for each child it starts;
  - `child_of` pairs the two by the writing git's `sid` and the `child_id`;
  - a `child_exit` whose code is `128 + N`, N below `signal.NSIG` (`died_of`, now shared with `ended_on`), for a child whose argv begins `git` (`git_child`), is a git dead of signal N;
  - a hook or another program is not counted. Its code is its own: a `post-checkout` hook that exits 137 is no signal, and `git checkout` exits 1 over it (measured, both gits).
- **The first signal** is the first by the time each event gives, which is the order git met them. Where trace2 gives no time (its brief form), it is the order of the files and their lines.
  - File order alone would name the wrong one. The files go in the order their gits started, so a member's own update comes after git's update's file.
  - Measured on both gits: SIGKILL at IRRS's `code` leg, then SIGTERM at its `spec` leg. That ended IRRS's own update 143, and git's update's `child_exit` 143 for it came first in the files.
- **`signal_met`** chooses what (a) tells:
  - where git's update exited 0, nothing;
  - else the trace's first signal, where there is one;
  - else the update's own exit code, through `ended_on` as #998 reads it (`128 + N`, or `-N` where git itself was killed).
- **Nothing where the update exited 0.** It did all it set out to do then, whatever a git it ran died of on the way. Measured on both gits: a `reference-transaction` hook killed IRSS's `git fetch` after it wrote the branch, git found the commit reached, checked it out and exited 0. The run says every member and leg was fetched, as before.

### What the run says

- Where the signal's code is the update's own, the line is #998's, word for word, so the controls read as before:
  ```
    UPDATE KILLED, and not for want of a credential: git's update ended on signal 9 (SIGKILL, exit 137), which a git it ran died of. Git's own words above say which git and where.
  family bootstrap FAILED: git's update (ended on signal 9)
  ```
- Where the trace gave it and the update ended with another code:
  ```
    UPDATE KILLED, and not for want of a credential: a git that git's update ran died of signal 9 (SIGKILL, exit 137), and git's update went on and ended with exit 128. Git's own words above say which git and where.
  family bootstrap FAILED: git's update (a git it ran died of signal 9)
  ```
- No credential advice in either case (`credential_advice`).

### A signal and B4 both told: the signal first

- Before, `fetch_members` returned one failure line, the signal's or the walk's stop's. Now `told_past` says each that is there, each with its own failure line.
- Where both are told, **the signal is said first**. Why:
  - It is git's order. A death past the holder's records (128) ends git's update, so git met the signal at an earlier gitlink and went on past it.
  - Git's own words, printed just above, say them in that order too.
  - The two cures do not depend on each other.
- Measured: the killed checkout in IRRS, then B4 at IRSS, with the credential. Before, the B4 line alone was said. Now:
  ```
    UPDATE KILLED, and not for want of a credential: a git that git's update ran died of signal 9 (SIGKILL, exit 137), and git's update went on and ended with exit 128. Git's own words above say which git and where.
    UPDATE STOPPED, and not for want of a credential: git stopped at members/IRSS, whose fetch reached its remote and did not bring <commit>, ...
  family bootstrap FAILED: git's update (a git it ran died of signal 9), members/IRSS (git stopped at it)
  ```
- A `NOT CHECKED OUT` row or a whole-holder stop (`unfetchable`, `updated_none`) still comes first and alone, as for #998's signal (mutant S7's order is unchanged).

### With a token in use: the limit, said and pinned

The issue offered two ways. I measured both, and chose to say the limit and pin it with a row.

| measured (killed checkout, then IRSS's pin moved on, the token's fake value) | git 2.43.0 | git 2.47.1 |
|---|---|---|
| a directory trace target with the token's `-c` in argv: events holding the token | 1 (the top git's `start` argv) | 1 (the same) |
| the rewrite given in the environment instead, IRSS's origin an https url through a refused local proxy: events holding the token | 8 (the remote helper's `start` and `child_start` events give the url) | 0 |
| an `af_unix:dgram:` socket target read by a thread, in memory | 189 datagrams, 1 holding the token, the `child_exit` 137 for `git checkout` there; no file | 194, 1, the same; no file |

So:
- **Any file target writes the token**: in argv on both gits, and, moved to the environment, in git 2.43.0's remote helper url.
- **Git's one target that is no file, a Unix socket, works**, but this PR does not take it:
  - this process would have to read it for the whole update, and every git the update starts waits on it once Linux has queued 10 events (`net.unix.max_dgram_qlen` is 10 here), so a stalled reader hangs `make bootstrap`;
  - the socket's path must fit in the 107 bytes Linux takes. This harness's TMPDIR is 151 bytes, and a socket path under this session's scratchpad (114 bytes) was refused (`AF_UNIX path too long`);
  - git for Windows builds without it (read, not run).
  It would be a second trace mechanism, and is better as its own issue if wanted.
- **What the docstrings now say:**
  - `ended_on`: with a token in use the exit code is all there is, and a signal before a later failure is not told.
  - `credential_advice`: the limit and the measurement.
  - `run_update`: points at both.
- **The row:** `test_with_a_credential_in_use_a_signal_is_told_only_from_git_s_code`.
  - `[alone]`: the killed checkout alone is named, from git's own 137.
  - `[then-pin-moved-on]`: the killed checkout, then IRSS's pin moved on with the transport refused, is the credential's skip, the advice and ok.
  - Both: the token is in no output and no file under TMPDIR, with every caller trace on.

### Two docstrings corrected

- `ended_on`: git's update ends with a signal's code only where no gitlink after it fails, since it keeps the last code that is not 0. (It said the update "ends with that code".)
- `credential_advice`: a failed fetch does not always end the update with 128. A clone that fails gives 1 and the update goes on (rv996's NIT 2 on #1045), so the advice is dropped beside a signal even where a credential was also wanting. The run fails on the signal, and a run made again says the advice. (It said an update ending `128 + N` "met none".)
- The module docstring's (a) and (c), `fetch_members`, `run_update`, `fetched_in`, `GIT_DIED`, `UPDATE_KILLED` and `TRACE_ENV` say the same.

The probe is a pytest file kept out of the commit. It builds each state with `tests/test_family_bootstrap_stops_named.py`'s own helpers, runs the family root's `bootstrap.py --skip-members` (offline), and records the exit code, whether the credential advice is said, and whether the fake token is in any output or any file under TMPDIR. It also runs git's own update, traced, on a copy of the same state with no token. It was run at main `00f6d7f7` and at this head, on git 2.43.0 (`/usr/bin/git`) and on git 2.47.1 (a local build, first on PATH). Each cell is the verdict, the signal said, and the exit code:

| state (in IRRS, then in IRSS) | main 2.43.0 | head 2.43.0 | main 2.47.1 | head 2.47.1 |
|---|---|---|---|---|
| killed checkout, then IRSS's pin moved on, no credential | ok + advice (0) | FAILED signal 9 (1) | ok + advice (0) | FAILED signal 9 (1) |
| killed checkout, then IRSS's checkout refused, with the credential | ok + advice (0) | FAILED signal 9 (1) | ok + advice (0) | FAILED signal 9 (1) |
| killed checkout, then B4 at IRSS, with the credential | FAILED + B4 stop (1) | FAILED signal 9 + B4 stop (1) | FAILED + B4 stop (1) | FAILED signal 9 + B4 stop (1) |
| control: killed checkout alone, with the credential | FAILED signal 9 (1) | FAILED signal 9 (1) | FAILED signal 9 (1) | FAILED signal 9 (1) |
| P3, then IRSS's pin moved on, no credential | ok + advice (0) | FAILED signal 11 (1) | ok + advice (0) | ok + advice (0) |
| P3, then B4, with the credential | ok + advice (0) | FAILED signal 11 (1) | ok + advice (0) | ok + advice (0) |
| control: P3 alone, with the credential | FAILED signal 11 (1) | FAILED signal 11 (1) | ok + advice (0) | ok + advice (0) |
| a hook that exits 137 (no signal), then IRSS's pin moved on, no credential | ok + advice (0) | ok + advice (0) | ok + advice (0) | ok + advice (0) |
| a hook that exits 137, alone, with the credential | ok + advice (0) | ok + advice (0) | ok + advice (0) | ok + advice (0) |
| SIGKILL at IRRS, then SIGTERM at IRSS, with the credential | FAILED signal 15 (1) | FAILED signal 9 (1) | FAILED signal 15 (1) | FAILED signal 9 (1) |
| SIGKILL at IRRS's `code` leg, then SIGTERM at its `spec` leg, with the credential | FAILED signal 15 (1) | FAILED signal 9 (1) | FAILED signal 15 (1) | FAILED signal 9 (1) |
| the same two legs, then IRSS's pin moved on, no credential | ok + advice (0) | FAILED signal 9 (1) | ok + advice (0) | FAILED signal 9 (1) |
| token in use: killed checkout, then IRSS's pin moved on | ok + advice (0) | ok + advice (0) | ok + advice (0) | ok + advice (0) |
| token in use: killed checkout alone | FAILED signal 9 (1) | FAILED signal 9 (1) | FAILED signal 9 (1) | FAILED signal 9 (1) |
| token in use: killed checkout, then B4 | FAILED + B4 stop (1) | FAILED + B4 stop (1) | FAILED + B4 stop (1) | FAILED + B4 stop (1) |
| token in use: killed checkout, then IRSS's checkout refused | ok + advice (0) | ok + advice (0) | ok + advice (0) | ok + advice (0) |

Read across the table:
- **Every no-token row of the probe that holds a signal now fails naming it, with no advice** (the rows that said `ok` with the advice, and the one that named SIGTERM): both killed-checkout rows (pin moved on, checkout refused) on both gits, and P3 (then IRSS's pin moved on, then B4) on git 2.43.0. P3 on git 2.47.1 is not a crash there (git refuses the line and exits 128, so nothing is killed), so its rows read as before.
- **Killed checkout, then B4:** the B4 line alone was said at main. Now both are said, the signal first.
- **A hook's own code is no signal:** a `post-checkout` hook that exits 137 reads the same at main and at this head: `ok`, with the advice.
- **The first signal is the first git met:** SIGKILL at IRRS, then SIGTERM at IRSS (and the two legs of IRRS) named SIGTERM at main, from git's update's own last code, and name SIGKILL now.
- **A signal in an update that exits 0 is not said.** A `reference-transaction` hook that kills IRSS's `git fetch` once, after it wrote the ref (git then finds the commit reached, checks it out and exits 0): measured at this head on both gits (main reads no trace, so it says the same), the run says `every member and leg fetched` and `family bootstrap ok`, exit 0. The trace there holds a `git fetch` `child_exit` of 137, and the row `test_a_git_killed_in_an_update_that_exits_0_is_not_said` pins that nothing is said (mutant M7).
- **With a token in use nothing changed**, on purpose (the four `token in use` rows): the signal is told only where its code is git's last.
- **The fake token** was in no output and in no file under TMPDIR in any run of any row (probe lines `token in output: False`, `token in temp files: []`).

All in `tests/test_family_bootstrap_stops_named.py` (#954's and #1045's file), reusing its helpers and its style, with no assert that joins conditions with `and`. The module docstring gains the new rows' list, and the P3 state moved into a `p3()` helper so two rows build it. 11 new rows (37 before, 48 after).

The issue's Verify rows (fail at main):
- `test_p3_before_a_later_failure_fails_naming_the_signal`: P3, then IRSS's pin moved on, no credential, git 2.43.0: exit 1, signal 11 (SIGSEGV) named, no advice, FAILED. Skipped where git does not crash, as #1045's P3 row is.
- `test_a_killed_checkout_before_a_later_failure_fails_naming_it[pin-moved-on-no-credential]` and `[checkout-refused-with-the-credential]`: the killed checkout (a `post-checkout` hook sending SIGKILL to its parent, `kill -s KILL $PPID`), then IRSS's pin moved on with no credential, and then IRSS's checkout refused with the credential. Each exits 1, names signal 9 (SIGKILL), gives no advice. Every POSIX git.

The choice rows (fail at main):
- `test_a_signal_and_b4_after_it_are_both_named_the_signal_first`: the killed checkout, then B4 at IRSS, with the credential: both lines, the signal first, and the failure line names both.
- `test_the_first_signal_is_named[two-members]` and `[two-legs]`: SIGKILL, then SIGTERM, at two members and at two legs of one member. The first by time is named (SIGKILL), where git's update's own last code, and the files' order, name SIGTERM.

The controls and the limit (pass at main, as intended, so they hold the rest as it was):
- `test_a_killed_checkout_alone_is_named_as_before`: #998's line, word for word.
- `test_a_hook_s_own_code_is_no_signal`: a hook that exits 137 is not a signal.
- `test_a_git_killed_in_an_update_that_exits_0_is_not_said`: the `reference-transaction` row above.
- `test_with_a_credential_in_use_a_signal_is_told_only_from_git_s_code[alone]` and `[then-pin-moved-on]`: the token's limit, pinned. Alone, the killed checkout is named from git's own 137. Then IRSS's pin moved on, the run is the credential's skip, the advice and `ok`, as before. Both: the fake token is in no output and in no file under TMPDIR, with every caller trace variable on.

The controls the issue names that already existed are untouched and pass: the signal alone (`test_an_update_ended_on_a_signal_fails_naming_it`, the P3 row), the credential's skip (`test_the_credential_s_skip_is_as_it_was`) and `test_a_code_no_signal_gives_is_read_as_before[exit-128]` and `[exit-255]`.

**Failing at main:** main's `bootstrap.py` bytes (sha256 `50a16751...`) against this branch's 48-row file, git 2.43.0: **6 failed, 42 passed**. The six are exactly the rows under "fail at main" above (the P3 row, the two killed-checkout rows, the both-named row and the two first-signal rows). They pass with the change.

Each mutant applied alone to the change's `bootstrap.py` (a script replaces one exact string and asserts it occurs once), the stops-named file run on git 2.43.0 (`/usr/bin/git`) through the lane's runner, the file restored from the head's bytes and its sha256 checked after each. All on the final 48-row file (M1 to M3 were run once on the first 47 rows and again on the 48, with the same rows failing).

| | mutant | result |
|---|---|---|
| M1 | the trace read dropped (`Trace(fetched_in(events))`) | killed: 6 failed, 42 passed |
| M2 | `128 + N` read as `>= 128` (`GIT_DIED <= code`) | killed: 31 failed, 17 passed |
| M3 | a `child_exit` of a child that is not `git` taken (`git_child` accepts any `child_start`) | killed: 1 failed (the hook row) |
| M4 | the last signal for the first | killed: 2 failed (`two-members`, `two-legs`) |
| M5 | the advice still printed beside a signal | killed: 10 failed |
| M6 | the sort by time dropped (file order) | killed: 1 failed (`two-legs`) |
| M7 | the exit-0 gate dropped (`signal_met` tells a signal where git exited 0) | killed: 1 failed (the `reference-transaction` row) |
| M8 | a child paired by `child_id` alone, without the writing git's `sid` | killed: 1 failed (the hook row) |
| M9 | B4 dropped beside a signal | killed: 1 failed (the both-named row) |
| M10 | B4 said before the signal | killed: 1 failed (the both-named row) |
| M11 | the "went on and ended with exit R" text never said | killed: 6 failed |
| M12 | that text always said | killed: 5 failed |
| M13 | git's update's own exit code asked before the trace | killed: 2 failed |
| M14 | the failure line always "ended on signal N" | killed: 6 failed |
| M15 | the `child_exit` kind check dropped from `died_in` | **survives**: 48 passed |

**M15 is equivalent, and the check stays as a reader's help.** `died_in` only takes an event whose `(sid, child_id)` is one `child_start` named for a git, and only a `child_exit` event carries a `code`. A `child_start` event has none, so `died_of(None)` is None whether or not the kind is asked. No input reaches a different answer.

All through the lane's runner (the real `gh` replaced by a stub, every `LANES_*` variable cleared, `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`), at most two at once, never the full suite. Git 2.43.0 is `/usr/bin/git`; git 2.47.1 is a local build, first on PATH. `bootstrap.py`'s sha256 began `f1c156e29d07e149`, which the runs recorded and the committed file has (the test file's begins `1d558af665c3`).

| run | git | result |
|---|---|---|
| `tests/test_family_bootstrap_stops_named.py` (48 rows) | 2.43.0 | **48 passed** |
| the same | 2.47.1 | **46 passed, 2 skipped** (both P3 rows: that git exits 128 there, not 139) |
| main's `bootstrap.py` bytes, this branch's 48 rows | 2.43.0 | 6 failed, 42 passed (the rows listed under Tests) |
| the probe, after, 17 states | 2.43.0 and 2.47.1 | 17 passed each |
| group A: the stops-named file, `test_family.py`, `test_update_shape_kept_helper.py`, `test_family_holder_remedies_compose.py`, `test_family_gitmodules_values.py`, `test_pinned_copy_text.py`, `test_repo_hygiene.py` | 2.43.0 | **707 passed** |
| group B: `test_family_holder_bootstrap_texts.py`, `test_family_bootstrap_gate_order.py`, `test_bootstrap_without_git.py`, `test_family_unnamed_gitlink.py`, `test_family_config_appends.py`, `test_family_member_make_jobserver.py` (#1023's), `test_family_siblings_bootstrap_unreadable.py`, `test_family_head_lost_gitmodules.py`, `test_family_members_not_a_list.py`, `test_family_makefile_siblings.py`, `test_bootstrap_spelling.py`, `test_template_text_placeholders.py`, `test_no_auto_maintenance.py`, `test_agents_md_codes.py`, `test_cli_reference.py` | 2.43.0 | **1010 passed, 3 skipped** (GNU make's jobserver spelling here, and two rows that need a session without `GIT_CONFIG_GLOBAL`) |
| the 15 mutants | 2.43.0 | see Mutants |

Both groups and every file that names the family root's `bootstrap.py` (`grep -l 'family-root/scripts/bootstrap' tests/*.py` gives two files, both in the groups), `test_pinned_copy_text.py` and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) are in those two groups. No other test pins the functions or lines this changes (a grep of `tests/`, `docs/` and `scripts/` for them finds only a docstring's mention in `test_family_holder_bootstrap_texts.py`, in group B, and an unrelated workflow-validator row). The runs were made at main `00f6d7f7`'s tree plus this change, before main moved (see Merges).

`git merge-tree --write-tree` of this head `73906948` (parent main `00f6d7f7`), after fetching with `gc.auto=0` and `maintenance.auto=false`:

| against | result |
|---|---|
| `origin/main` `4a6b9afde` (3 commits past `00f6d7f7`) | clean (tree `ad8dab73`); none of the 3 touches `bootstrap.py` or the stops-named file |
| #1062 `fix/1040-windows-start-errors` `957ba038` (touches the family root's `bootstrap.py`, in `git_not_started` only) | clean (tree `70c5a715`); the merged `bootstrap.py` parses, and holds `died_in` and #1062's `4551` |
| #1075 `fix/1055-siblings-untraced` `c3cf5154` (lane 5's: `siblings.py`, `tests/test_family_siblings_untraced.py`, `tests/test_update_shape_kept_helper.py`) | clean (tree `6770fa61`); its `siblings.py` imports `untraced` from `bootstrap.py`, which this does not touch |

The other open PRs (#1073, #1072, #1071, #1070, #1069, #1068, #1064, #1031) touch neither file.

`templates/family-root/scripts/bootstrap.py` is in `FAMILY_COPIED_VERBATIM`, so each holder gets one `upstream-changed` row for it, as for #1045. The only import is unchanged (`signal` came with #998), and `siblings.py`'s import list from it is unchanged. No Windows or macOS run was made, since CI is Linux only (#597).

Review: rv1076, lane 5's independent reviewer, not the writers (w1056 and f1056): READY at `73906948` (issuecomment-6302104583) with no MUST or SHOULD; of its three NITs, the body's is fixed, and the other two (a docstring clause on a member's failed clone, and a text no row reaches) go to a follow-up. CI (Brett Heap's ruling 27): H0 is the head 7390694 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 00f6d7f; and the local gate passed on its predicted squash tree 9dae534 over main bccae28: the family_bootstrap_stops_named, repo_hygiene, agents_md_codes, cli_reference sets, 218 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #1056

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 21, 2026
…`git` Windows cannot start for a reason other than 193 that names no file (`winerror` 216, 1260, 11, or 188-202) is `git-not-runnable`, exit 2, with the put-git-on-PATH remedy, where each ended in a traceback, exit 1 (#1040) (#1062)

#1001 (#1028) made the four copies of the `git-not-runnable` rule count a
nameless `OSError` whose `winerror` is 193, a `git.exe` Windows cannot
execute. Windows has other `CreateProcess` failures that mean git itself
could not be started, and each names no file and is neither
`FileNotFoundError` nor `PermissionError`, so all four copies passed it
through and the run ended in a traceback, exit 1, as 193 did before #1001.
RV1028 raised this as its SHOULD 1.

Read from CPython 3.12's `PC/errmap.h` (main's is the same for these
codes), with each code's words from Microsoft's System Error Codes:
- 216 (`ERROR_EXE_MACHINE_TYPE_MISMATCH`), a `git.exe` built for another
  architecture, and 1260 (`ERROR_ACCESS_DISABLED_BY_POLICY`), one that
  AppLocker or a software restriction policy blocks: no case, so `EINVAL`,
  a plain `OSError`.
- 11 (`ERROR_BAD_FORMAT`) and 188 to 202 other than 193: the rest of the
  `ENOEXEC` family, a program Windows cannot load, a plain `OSError`.
- 267 (`ERROR_DIRECTORY`), a `cwd` that is not a folder: `ENOTDIR`, a
  nameless `NotADirectoryError`. It is not git's, and still passes through.

The rule, the same text in all four (`_git_runs` in both validators and in
the assembly root's bootstrap.py, `git_not_started` in the holder's):

    nameless = (exc.filename is None
                and (isinstance(exc, (FileNotFoundError, PermissionError))
                     or getattr(exc, "winerror", None)
                     in (11, *range(188, 203), 216, 1260)))
    if exc.filename != "git" and not nameless:

The code list is written in the expression, not in a module constant, so
`test_the_four_copies_are_one_text`, which compares the rule's AST in the
four, holds the list itself to one text. Only the list changed: a named
error still counts only where it names `git`, and a nameless `EIO`,
`ENOMEM`, an `ENOEXEC` with no `winerror`, and a 267 pass through. Each
copy's docstring says which codes count and why, and that 267 does not.

Tests:
- tests/test_validate_pins_without_git.py: `win_error(winerror, number,
  words)` builds what CPython raises on Windows; `WIN_ERROR_193` is built by
  it, beside `WIN_ERROR_216` and `WIN_ERROR_1260`, which the no-git run
  takes in every run; `NOT_GIT` gains a nameless `winerror-267`, passed
  through at each call.
- tests/test_validate_family_git_not_runnable.py: parity rows
  `winerror-216` and `winerror-1260` (which the per-call rows take too),
  `winerror-11` and `winerror-188` to `winerror-202` (`NOT_LOADABLE`, asked
  of the four copies alone: each is 193 but for its `winerror` and words),
  all `git-not-runnable`, and `winerror-267`, passed through by all four;
  each copy's own script is run where every start of git raises 216 as well
  as 193: exit 2, the refusal, the copy's remedy, no traceback.
- tests/test_bootstrap_without_git.py: the `sitecustomize` shim is built by
  `git_raising(error)`; `GIT_NOT_WIN32` raises 193 as before, and
  `GIT_FOR_ANOTHER_MACHINE` raises 216; the upstream notice's no-git row
  takes 216 too; each root's rule counts 216 and 1260 and passes a
  nameless 267 through.

#1028's NIT 3: `answer`'s docstring in
tests/test_validate_family_git_not_runnable.py named `fetch_members` as the
call site of the holder's `git_not_started`. #954 moved that call into
`started()`, with the same three lines `answer` copies, so only the name
was stale; it now says `started`.

All four files are pinned copies, as in #1028: an assembly root takes
`upstream-changed` rows for scripts/validate-pins.py and
scripts/bootstrap.py, a holder for scripts/validate-family.py and
scripts/bootstrap.py. Nothing new is imported from repo_shape.py.

Not run on Windows: the codes, their classes and their words are read from
CPython and Microsoft's documentation, and the errors are built by hand, as
#1001's 193 is.

Round 2, for RV1062's review: validate-family.py, validate-pins.py and both bootstrap.py copies: a `git.exe` App Control for Business blocks (`[WinError 4551]`) is `git-not-runnable` too, and two rows hold the code list exact (#1040)

RV1062's S1: 4551 (`ERROR_SYSTEM_INTEGRITY_POLICY_VIOLATION`) is App
Control for Business (WDAC) or Smart App Control refusing to start an
image, the kind of block 1260 is for AppLocker and software restriction
policies. CPython 3.12.10 raised it from `_winapi.CreateProcess` as a plain
`OSError` naming no file (ROCm/TheRock issue 2607: `OSError: [WinError
4551] An Application Control policy has blocked this file`), and
`PC/errmap.h` has no case for it, so it is `EINVAL`, as 216 and 1260 are.
Every copy passed it through, and the run ended in a traceback, exit 1.

The four copies, still one text:

    in (11, *range(188, 203), 216, 1260, 4551)

Each copy's docstring names 4551 beside 1260, and says any other
`winerror` passes through.

RV1062's S2: nothing held the list exact beyond 267, so a rule counting
any nameless `winerror` but 267 (RV1062's mutant A, `not in (None, 267)`)
passed every row. `NOT_GIT` gains two nameless `CreateProcess` failures
that say nothing of git, `winerror-87` (`ERROR_INVALID_PARAMETER`,
`EINVAL`, the class 216, 1260 and 4551 arrive in) and `winerror-8`
(`ERROR_NOT_ENOUGH_MEMORY`, `ENOMEM`), each passed through: by the parity
in all four copies, at each of validate-pins.py's and validate-family.py's
three calls, and by each root's own rule.

Tests:
- tests/test_validate_pins_without_git.py: `WIN_ERROR_4551`, which the
  no-git run takes in every run; `NOT_GIT` gains `winerror-87` and
  `winerror-8`.
- tests/test_validate_family_git_not_runnable.py: `winerror-4551` in
  `WILL_NOT_START`, so a parity row and the per-call rows, as 1260 has;
  `winerror-87` and `winerror-8` arrive through `NOT_GIT`.
- tests/test_bootstrap_without_git.py: each root's rule counts 4551 and
  passes a nameless 87 and 8 through.

Words from Microsoft's System Error Codes (87, 8) and from the CPython
traceback in ROCm/TheRock issue 2607 (4551); the name 4551 from
mingw-w64's winerror.h. Not run on Windows.

Closes #1040

Lane: openreposhape-4 (openRepoShape-4)
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