Skip to content

bootstrap.py and validate-family.py: a member no credential would check out fails the family bootstrap, every row's gitlink is asked before any checkout, and --no-members prints the case-variant move (#648) - #682

Merged
brettheap merged 15 commits into
mainfrom
fix/648-bootstrap-and-gate-order
Oct 17, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

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

Head f74f640. This branch holds #639 (#611), which landed as c6122e8, and main 2fac979, merged in bad6c00. Its diff against main is this PR's own change. git merge-tree --write-tree f74f640c origin/main is clean at 2a33a56.

README PR. It changes one sentence of README.md § Families, which this change makes false, and its twin in docs/handbook.html. The handbook's § Families marker is the segment's new blob, b3d19092. README stays at 1459 lines, its cap, and tests/readme-cap.d/ is not raised. It lands in lane 3's README turn.

Pinned copies. templates/family-root/scripts/bootstrap.py and templates/family-root/scripts/validate-family.py change. Each holder sees them as upstream-changed on its next update-shape.py check. No test pins either file's bytes or digest; the tests that copy them into a holder take them from the template. Rounds 2 and 3 change bootstrap.py alone. By Brett's 13:36:23Z word (R0) the family root's pinned copy is re-pinned once, in v1.1.0's shape bump, so this PR re-pins nothing.

Fix round 3 (rv648's verify at 23e1105: 1 MUST)

One commit, f74f640, on 23e1105, in the same two files.

MM1. FIXED: a .gitmodules a merge left unmerged

In a plain clone, a merge conflicts in .gitmodules, and the file is resolved in the work tree but not added. The index then holds it only at its three conflict stages (git ls-files -u lists 3). Git reads no .gitmodules at all while it is unmerged: repo_read_gitmodules asks is_gitmodules_unmerged before it reads any file. So git's update dies at the first gitlink, No url found for submodule path 'members/Atlas', exit 128. On 23e1105, bootstrap read the work tree's file, found every section with its url, and ended family bootstrap ok, exit 0 (rv648's p10-head.log).

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 before every other reason, since no credential helps while git reads no section:

  NOT CHECKED OUT, and not for want of a credential: members/Atlas (git reads no .gitmodules while a merge leaves it unmerged), members/Nova (git reads no .gitmodules while a merge leaves it unmerged). Resolve the merge in .gitmodules and `git add .gitmodules`, then run this again.

With the file added, the same run checks both members out. That is all the cure needs; committing the merge is the person's own step.

Test. test_a_gitmodules_a_merge_left_unmerged_says_to_add_it builds MM1. The two sides each give OTHER's section another branch: dev, pushed from the holder, and main, committed in the clone. The merge exits 1, ls-files -u lists 3, and the work tree takes stage 2. The run exits 1, with the last two stderr lines exact and git's No url found ... 'members/IRRS'. Then git add .gitmodules, and the run is a good holder's. Against 23e1105's bootstrap.py the test fails on exit 0.

Mutants. Two more, both killed:

mutant result tests that fail
U: the unmerged check dropped, so the work tree's file is read as before 1 failed, 33 passed MM1
U2: the file read as unmerged, but the reason not given, so the rows fall to the next one 1 failed, 33 passed MM1

Round 2's eight were re-run on f74f640's two files. All are still killed, by the same tests.

Results on f74f640.

  • rv648's lean set: 483 passed in 313.92s (0:05:13). That is 482 at 23e1105, plus MM1.
  • The four files that load the family bootstrap.py (tests/test_update_shape_kept_helper.py tests/test_windows_paths.py tests/test_pinned_copy_text.py tests/test_cli_reference.py): 259 passed in 41.20s.
  • rv648's p10.sh: A1T and H2T as on 23e1105, each typed through, with the gate passing in all three modes. MM1 now exits 1 with the line above.

A sentence of this body was false. "Not changed" said "A fresh clone, where bootstrap runs, has the work tree's file and no merge". MM1 is a fresh clone with a merge. That bullet now says what is left.

Candidate 6 (what git dies on while reading .gitmodules) is filed as a v1.1.1 issue (#768), with the approximations below.

Fix round 2 (rv648's verify at 45c512e: 1 MUST, 1 SHOULD, 3 NIT)

One commit, 23e1105, on 45c512e. It changes templates/family-root/scripts/bootstrap.py and tests/test_family_bootstrap_gate_order.py only. README, the handbook and validate-family.py are as they were.

M1. FIXED: .gitmodules is read as git's own parser reads it

init_stop read .gitmodules with git config --file --list. That keeps entries git's submodule parser drops or overrides. So in three states git's init died and bootstrap still said ok:

state (plain clone) git's update 45c512e 23e1105
D1: a second section nova2 gives members/Nova with no url, after Nova's own 128, No url found ... 'members/Nova' family bootstrap ok, exit 0 exit 1, both rows named, --no-members
Q1: Nova's section is named ../Nova 128, ignoring suspicious submodule name, then No url found ok, exit 0 exit 1, both rows named, section written back
O1: Nova's url is -nowhere 128, ignoring ... may be interpreted as a command-line option, then No url found ok, exit 0 exit 1, both rows named, section written back

The parser rules, as built. gitmodules_sections now returns a Gitmodules. Its read ({path: name}) and urls ({name: url}) follow git 2.43's submodule-config.c, entry by entry, in file order:

  • A section whose name is empty, or has a .. component (split on / and on a backslash), is ignored whole. This is check_submodule_name.
  • A path or url value that begins with - is dropped, and the value before it stands. This is looks_like_command_line_option.
  • Where two sections give one path, the later in the file decides (cache_put_path replaces the entry). A section that gives a path again lets go of the path it gave before, even where a later section gave that path too, and then git reads no section there (cache_remove_path).

Its given counts every section that gives each path, as validate-family.py reads them. That count is what member-gitmodules-absent and gitmodules-path-twice see, so the line's cures stay true.

has_url asks the section git reads for a path, then the git config under that section's name. Where git reads no section for the path, there is no url, whatever the config holds: git's init dies on !sub before it looks at the config.

Checked against git 2.43's own source (submodule-config.c, submodule.c, builtin/submodule--helper.c and config.c at v2.43.0) and measured with git 2.43.0 in plain clones (p8.log):

.gitmodules form git 2.43's update
name "", nova\..\x, a/../Nova ignored; No url found, 128
name ..Nova.. (dots that are no .. component) read; exit 0
path = -members/Nova dropped; No url found, 128
a good url, then url = -x the -x dropped, the good url kept; exit 0
a second section with Nova's url first, Nova's own with no url after the later read; No url found, 128
nova1 gives the path, nova2 gives it too, then nova1 gives another path members/Nova maps to nothing; No url found, 128
[submodule.Nova] (the old header) read, under the name nova; exit 0

What else git does with .gitmodules. It drops nothing else. But it DIES reading the file, exit 128 before it checks out any member, on these, which git config --list reads without complaint:

  • a path, url, update or ignore key with no value (path alone): bad config line N;
  • update = !command, or any update value it does not know: invalid value for 'submodule.<name>.update';
  • a shallow value that is no boolean: bad boolean config value;
  • a fetchRecurseSubmodules value it does not know: bad submodule.<name>.fetchrecursesubmodules argument.

init_stop does not model these, and bootstrap still says ok over them where every row's section has a url (ignore = bogus is only a warning). No tool here writes any of them. This is a checklist candidate below, not part of this round.

D1's cure. Writing the section back would add a third section. So 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 names both sections and says to take the others out:

  NOT CHECKED OUT, and not for want of a credential: members/Atlas (git stopped at members/Nova, a gitlink it has no url for), members/Nova (git has no url for that path). `python3 scripts/validate-family.py --no-members` names each path more than one section of .gitmodules gives, and how to take the others out.

With the duplicate taken out and committed, the same run checks both members out.

The stop's sentence was "a gitlink no section of .gitmodules gives a url". That is false in D1, where Nova's own section does give one. It now says "a gitlink it has no url for", which is true in every state here. test_doctor_legs_members_next.py's git stopped at members/Ghost still matches.

S1. FIXED: A1, a member git has no url for, is its own reason

In a subset clone (--recurse-submodules=members/Atlas) where Nova's section has no url, git exits 0. On 45c512e the line printed the exit-0 skip's git submodule update --init --checkout --recursive -- members/Nova, which exits 128 (No url found) when typed.

"git has no url for that path" (has_url: the section git reads for it gives none, and the config has none under its name) is now its own reason. It is asked after "no section gives that path" and before "git exited 0", and its cure is the section written back. In A1:

  NOT CHECKED OUT, and not for want of a credential: members/Nova (git has no url for that path). Write the section of .gitmodules for members/Nova back as `family.py add` writes one, with its path and url, and commit .gitmodules.

Written back and committed, the next run is the exit-0 skip, and its printed update, typed, checks Nova out. In U1, Q1 and O1 the member's own row now gives this reason too, and the other row gives "git stopped at members/Nova". The cure is said once.

NIT 1. FIXED: a stop outside members/ (H2)

A hand-mounted vendor/Zed stops git's init, and the line named --no-members, which reads only members/ and says family ok there. Both cures were measured in H2's clone (p9-head.log):

  • git rm --cached -- vendor/Zed and a commit: bootstrap then checks both members out and ends ok, and the gate passes in all three modes.
  • A section with its path and url written for vendor/Zed and committed: bootstrap ends ok, but the gate refuses gitmodules-path-unnamed in all three modes.

So the line now says: No row names vendor/Zed and no clone can check it out: `git rm --cached -- vendor/Zed` takes its gitlink out, then commit that. "Outside members/" is the validator's own test: the first path component, casefolded, is not members.

NIT 2. FIXED: the cures docstring

It now says that, members checked, the validator refuses member-uninitialized where every row's gitlink is there (a stray, a hand mount), and member-gitlink-absent with the move where a row's is only in another case (#363), which the validator sentence names.

NIT 3. FIXED: this body

This section; the parser rules in Decision 2 and in (a)'s reasons; and a new merge table, against main a27c371, #745 and #747.

Tests (round 2)

tests/test_family_bootstrap_gate_order.py has 33 tests now (23 before):

  • U1, Q1 and O1, one parametrized test (no-url, dotdot-name, empty-name, backslash-name, option-url). It replaces the U1 test. Each exits 1 with the last two stderr lines exact, git's No url found and its warning shown. Each is written back as family.py add wrote it and committed, and the same run ends ok.
  • D1: exits 1 with the --no-members sentence. --no-members names gitmodules-path-twice for both sections; the duplicate is taken out as that finding says and committed, and the run ends ok.
  • What git reads past (duplicate-first, which is D2; dots-in-a-name; option-url-after-a-url): with no credential for the members, each is still the credential's skip and ends ok; with the credential, each is a good holder's run. The no-credential run is what makes a member be asked at all, so these tests catch a rule read too widely.
  • A1: the write-it-back line, then the exit-0 skip's update line, typed as printed, then a good holder's run, stderr empty each time but for the two lines.
  • H2: the git rm --cached line, typed and committed; the run ends ok and the gate passes in every mode. A premise checks that --no-members says family ok there.
  • The variant-plain, stray, hand-mount and round-trip expectations take the new stop sentence.

On 45c512e the new file gives 12 failed, 21 passed. Q1 (three names), O1 and D1 fail on exit 0; A1 and H2 fail on the line; U1, variant-plain, the stray, the hand mount and the round trip fail on the reworded sentences.

Mutation probes (round 2)

Each was applied alone to a throwaway clone holding this commit's two files, and the test file was run against it. All eight are killed:

mutant result tests that fail
L: the first section for a path decides, not the later 2 failed, 31 passed D1; duplicate-first
N: a section with a suspicious name is read 3 failed, 30 passed dotdot-name, empty-name, backslash-name
N2: any .. in a name, component or not, ignores the section 1 failed, 32 passed dots-in-a-name
O: a value that begins with - is kept 1 failed, 32 passed option-url
D: no --no-members sentence where two sections give the path 1 failed, 32 passed D1
A: "git has no url for that path" not asked 7 failed, 26 passed the five no-url cases, D1, A1
A2: that reason asked after "git exited 0" 1 failed, 32 passed A1
H: a stop outside members/ gets the --no-members sentence 1 failed, 32 passed H2

N2 was not killed by a first version of the read-past tests, which ran with the credential: git then checks both members out and bootstrap asks nothing. They now run without it first.

Probes (round 2)

rv648's p6.sh and p7.sh and w648b's p5.sh were re-run on 23e1105, and this round's p9.sh was run on 45c512e and on 23e1105. All use local bare remotes and the gh stub, with git 2.43.0. "ok" is family bootstrap ok, exit 0.

state git's update 45c512e 23e1105
D1 128 ok exit 1, the --no-members cure; the duplicate taken out and committed, then ok
D2 (the duplicate first) 0, both checked out ok ok
D3: the duplicate with Nova's url first, Nova's own with no url after 128 ok exit 1, the --no-members cure
Q1: names ../Nova, "", nova\..\x, a/../Nova 128 ok exit 1, section written back; then ok
Q2: name ..Nova.. 0 ok ok
O1: url -nowhere 128 ok exit 1, section written back; then ok
O2: a good url, then url = -x 0 ok ok
O3: path -members/Nova 128 exit 1 exit 1, the validator's cure, whose member-gitmodules-absent names it
U1 128 exit 1 exit 1; written back, then ok
A1 0 exit 1, the update line, which exits 128 typed exit 1, section written back; then the update line, exit 0 typed; then ok
H2 128 exit 1, --no-members, which says family ok exit 1, git rm --cached -- vendor/Zed; typed and committed, ok, and the gate passes in all three modes
G1, G3, S2 0 ok ok
G2, X1, X2, I1 (no credential), W1 (a wrong url) non-zero the skip, ok the skip, ok
S1, H1, V1 128 exit 1 exit 1, the same rows and cures, with the stop's new sentence
P1, N1 0 exit 1, the update line, exit 0 typed, then ok the same

Every line of p5.sh but S1's, H1's, U1's and V1's reworded sentences is the same on 45c512e and 23e1105, gate lines included.

Fix round 1 (rv648 on 8b39cd1: 1 MUST, 2 SHOULD, 5 NIT)

Round 2 rewords two of round 1's sentences: the stop's why is now "a gitlink it has no url for", and in U1 the member's own row gives "git has no url for that path". The examples below are round 1's.

Three commits: bad6c00 merges main, 1a69505 answers the review, and 45c512e updates one doctor test that pinned the old ok (below).

The merge with main (bad6c00)

One file conflicted with #634 (#610): templates/family-root/scripts/validate-family.py, in two regions. This PR splits _check_member in two, and #634 made THE LOCKSTEP RULE a per-member value, rule = _lockstep(root, project, str(row.get("id") or project)), passed to the gitlink and tree checks.

So each member's gitlink and digest findings end at the rule #634 gives them, naming that member. The closing count's _lockstep(root) is #634's and merged clean. This is rv648's resolution in rv648-x634, plus one line of Gitlinked's docstring. It was built against #634's file, which is the same blob on main (519b71fc). Mutant X1 below checks it.

M1. FIXED: bootstrap no longer says ok where git died at another gitlink

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. It then checks out no member. unfetchable asked only each row's own gitlink and section, so every row passed as the credential skip.

Measured on 8b39cd1 (p5-base.log, rv648's states with local bare remotes and the gh stub):

state git's update 8b39cd1 1a69505
S1: a stray Members/Nova beside members/Nova, plain clone 128, No url found ... 'Members/Nova' family bootstrap ok, exit 0 exit 1, both rows named
H1: members/Zed mounted by hand, no row, no section, plain clone 128, No url found ... 'members/Zed' ok, exit 0 exit 1, both rows named
U1: Nova's section with no url, plain clone 128, No url found ... 'members/Nova' ok, exit 0 exit 1, both rows named
V1: #363's state, plain clone 128, at Members/Nova exit 1, Nova alone exit 1, Atlas and Nova

rv648's prototype, measured before it was taken (p5-proto.log). It fixed S1, H1 and U1, but failed three states that are a missing credential, where 8b39cd1 rightly says ok:

state 8b39cd1 prototype 1a69505
X1: the stray, recursive clone, no credential ok FAILED, "git stopped at Members/Nova" ok
X2: the hand mount, recursive clone, no credential ok FAILED, "git stopped at members/Zed" ok
I1: Nova's url registered by git submodule init, then gone from the work tree's section, no credential ok FAILED, "git stopped at members/Nova" ok

The prototype also said "no section of .gitmodules gives that path" for U1's Nova, whose section gives the path and lacks only the url.

What changed. init_stop asks the holder's records which gitlink git's init stops at, keeping two rules of git 2.43's init:

  • submodule.active. Where the git config sets it, as a recursive clone sets ., git asks only the gitlinks a section gives a path. A gitlink no section gives is never active, so it stops nothing there (X1, X2).
  • The config's url. A section with no url stops git only where the config has no url under the section's name, as an earlier init writes one (I1).

gitmodules_sections replaces gitmodules_paths and reads each section's keys. Where git exited non-zero and init stops at a gitlink, each row not checked out is named git stopped at <gitlink>, a gitlink no section of .gitmodules gives a url, and the run fails. A row with its own reason (no gitlink, no section) keeps that reason.

What the line says cures it. Each reason now has its own sentence:

  • No gitlink, no section: the sentence it had, python3 scripts/validate-family.py names the cause and the commit that cures it.
  • Git stopped at a gitlink no row names (S1, H1, V1): python3 scripts/validate-family.py --no-members names each gitlink in members/ that no row names, and the commit that cures it.
    • The plain validator would not help here: members checked, it refuses member-uninitialized for the first row (rv648's candidate 3, still open).
    • S1 and H1 are typed through: the finding's lines, then a commit. The hand mount's family.py add line is not typed, since it mounts the member again. The same run then checks both members out.
  • Git stopped at a row's own gitlink (U1): no mode of the validator names it. So the line says: Write the section of .gitmodules for members/Nova back as family.py add writes one, with its path and url, and commit .gitmodules. Written back and committed, the same run checks both members out.

S1 now prints, for example:

  NOT CHECKED OUT, and not for want of a credential: members/Atlas (git stopped at Members/Nova, a gitlink no section of .gitmodules gives a url), members/Nova (git stopped at Members/Nova, a gitlink no section of .gitmodules gives a url). `python3 scripts/validate-family.py --no-members` names each gitlink in members/ that no row names, and the commit that cures it.
family bootstrap FAILED: Atlas (not checked out), Nova (not checked out)

Unchanged (p5-head.log): G1 and G3 (good holders, recursive and plain), G2 (plain, no credential), S2 (the stray in a recursive clone) and W1 (a wrong url, which looks like the missing credential).

Tests. The variant-plain case and the round trip now name MEMBER too, with both sentences. One test each covers S1, H1 and U1, and two cover the misfires: X1 and X2 in one parametrized test, and I1.

S1. FIXED: an exit-0 skip prints the update that checks the member out

For "git exited 0 and checked nothing out there", the line now prints the update that checks the member out, in place of the validate-family.py sentence:

  NOT CHECKED OUT, and not for want of a credential: members/Nova (git exited 0 and checked nothing out there). `git submodule update --init --checkout --recursive -- members/Nova` checks it out.
  • --init with the path registers a member a subset clone left out. A pathspec lifts git's submodule.active filter, and init marks the member active.
  • --checkout overrides an update = none.

Measured in P1 (a --recurse-submodules=members/Atlas clone) and N1 (update = none committed for Nova), on 8b39cd1 and on the head:

  • 8b39cd1: the validator sentence, whose member-uninitialized remedy exits 0 and leaves Nova empty.
  • 1a69505: the printed line, typed as printed, exits 0 and checks Nova out (15 entries). Bootstrap then ends every member and leg fetched, ok, and the gate passes in every mode.

test_an_update_that_exits_0_and_skips_a_member_fails is parametrized update-none and subset. It checks that the validator's remedy checks nothing out, types the printed line, and runs bootstrap again: exit 0, stdout exact, stderr empty.

The behaviour change (a subset clone and an update = none member now fail make bootstrap) belongs in v1.1.0's release notes, lane 3's #630 (candidate 4).

S2. FIXED: the credential test asserts no "every member fetched"

test_a_missing_credential_is_still_the_skip_it_was asserts " every member and leg fetched" not in result.stdout.splitlines(). rv648's mutant R2 (elif proc.returncode == 0: made else:) now fails it (mutants below).

NITs

  1. FIXED. README: member's. `make bootstrap` reports and skips one it lacks a credential for. (75 characters, the line count unchanged). Handbook: A member <code>make bootstrap</code> lacks a credential for is reported and skipped, not a failure. § Families marker: c40baa82 → b3d19092, computed with test_repo_hygiene.py's own readme_segments and readme_fingerprint.
  2. FIXED. gitmodules_paths is now gitmodules_sections, whose docstring says "An empty mapping where there is no such file".
  3. FIXED. Bootstrap's (a) docstring says "128 (No url found for submodule path) in a plain clone; a missing credential exits non-zero too". It also names the new reason and each cure.
  4. FIXED. The validator's WHAT IS CHECKED says the move under --no-members is printed "where its row's path is its canonical members/<Project> and its pin a 40-hex commit".
  5. FIXED. The merge table below replaces the stale one.

What changed and why

(a) bootstrap.py no longer ends ok over members it could not check out

After the fetch, bootstrap asks the holder's own records, never git's words, about each row whose member is not checked out. The first reason that holds is the row's:

  1. A merge left .gitmodules unmerged in the index, so git reads none of it (Gitmodules.unmerged; MM1). This is asked first: it holds for every row, and no credential helps.
  2. No gitlink is recorded at the row's path (recorded_gitlink, the reading member-gitlink-absent uses), as in family.py add, under core.ignorecase=true, commits the member's gitlink as Members/<P> when HEAD holds a path under a case variant of members/ that the index no longer does (a git rm -r Members not yet committed), so .gitmodules and family.yaml say members/<P>, the holder's own validator passes in the working clone, and every fresh clone of the pushed holder leaves the member's directory empty and is refused member-gitlink-absent #363's state.
  3. No section of .gitmodules gives that path, as where HEAD lost the file. Every section counts here, as validate-family.py reads them.
  4. Git has no url for that path: the section git reads for it gives none, and the git config has none under that section's name (has_url; U1, A1, D1, Q1, O1). No credential gives a url, whatever git exited.
  5. Git exited 0, so it lacked nothing a credential gives.
  6. Git exited non-zero, and its init stopped at a gitlink it has no url for (init_stop, M1 of both rounds).

Reasons 4 and 6 read .gitmodules as git 2.43's own parser reads it, not as git config --list lists it (gitmodules_sections): a section whose name is empty or has a .. component, split on / and on a backslash, is ignored; a path or url that begins with - is dropped, and the value before it stands; and where two sections give one path, the later in the file decides.

Each such row is named on one line, with what cures each reason, and the run fails. "every member and leg fetched" is printed only where git exited 0 and every row is checked out. A row git could not fetch for another reason is still NOT CHECKED OUT; skipped, and the run still ends ok: that is the state a missing credential leaves. test_family.py's test_bootstrap_degrades_when_a_member_cannot_be_fetched and bootstrap's docstring (c) say so.

(b) The gate asks every row's gitlink before any row's checkout

_check_member asked each row whole, gitlink then checkout, before it asked the next. In a plain clone of #363's state plus git submodule update --init --recursive (CI's and make bootstrap's flow), the update dies at Members/<P> before it checks out any member. So where the variant's row was not first, the gate refused member-uninitialized for the first row, naming that same update.

Now _check_member stops at the gitlink and returns the member (Gitlinked). _check_member_checkout asks each member's digest and identity once every row's gitlink has been asked. The refusal there is now #611's member-gitlink-absent with the move, whichever row it is, members checked and --pins alike.

(c) --no-members prints the move members mode prints

Under --no-members, the mode CI falls back to in (b)'s state, member-gitlink-unnamed for a member's only gitlink in the wrong case printed no command. Where the row's path is its canonical members/<P> and its pin is a 40-hex commit, it now prints the same text and two lines the row's members-mode refusal prints. One helper, _wrong_case_move, builds both. The commit is the row's pin, not the variant's gitlink, and every variant goes on one line.

Decisions and evidence

  1. The holder's records decide, not git's exit or words. Git exits 0 and 128 over the same fault (HEAD lost .gitmodules: 0 after a recursive clone, 128 in a plain one), and a missing credential exits non-zero too. No url found is git's own message, and git translates it.
  2. init_stop keeps git's rules, and no more. rv648's prototype failed X1, X2 and I1, so the submodule.active filter and the config's url are kept (round 1's M1). Round 2 adds the three rules by which git's own parser reads .gitmodules, each checked against git 2.43's source and measured: the suspicious name, the value that begins with -, and the later section for a path (round 2's M1). It does not match submodule.active's pathspecs: under a subset clone's value, a url-less section of a member outside the subset counts as a stop. That is reached only where git also fails for another reason (Not changed, below).
  3. The cure is per reason. The validator's members mode refuses member-uninitialized in S1, H1 and U1, and nothing names U1, so one sentence cannot be true of every reason. Two sections for one path get --no-members, since a section written back would be a third (D1), and a gitlink outside members/ gets git rm --cached, the one cure measured to leave the gate passing (H2). Each sentence was typed through in its state (Tests below).
  4. The README sentence. README is at its 1459-line cap, so the sentence fits its line. It cannot also name the new failures; bootstrap's docstring does.

Tests

tests/test_family_bootstrap_gate_order.py, new, 34 tests. Round 1's 23 are listed here, and rounds 2's ten and 3's one in their own sections above:

Changed: tests/test_validate_family_unnamed_mounts.py, state (ii) under --no-members. It printed no command; it prints the move now.

Results on 1a69505, Linux, /usr/bin/python3 -m pytest through run9.sh (gh stub first on PATH). 45c512e changes one test file, which was run again on it:

  • Batch A, the brief's named files: tests/test_family_bootstrap_gate_order.py tests/test_validate_family_unnamed_mounts.py tests/test_family.py tests/test_pinned_copy_text.py tests/test_validate_family_branch_step.py tests/test_family_members_case_variants.py tests/test_family_head_lost_gitmodules.py tests/test_family_bump_to_local_only.py tests/test_repo_hygiene.py tests/test_readme_sections.py tests/test_template_text_placeholders.py tests/test_cli_reference.py: 698 passed in 638.20s (0:10:38).
  • Batch B, every other test file that names validate-family or the family's bootstrap.py (49 files, git grep -l): 1 failed, 3797 passed, 32 skipped in 2483.22s (0:41:23).
    • The failure was tests/test_doctor_legs_members_next.py::test_414_3_there_the_bootstrap_cures_nothing_and_the_git_line_cures_one. It builds rv648's U1 state (a member, Ghost, whose section gives no url) and asserted bootstrap's exit 0: the false ok M1 ends.
    • 45c512e asserts exit 1 and git stopped at members/Ghost, and its point stands: the bootstrap cures nothing there, and the doctor's git line cures the other member. That file alone on 45c512e: 124 passed in 153.99s (0:02:33).
    • The skips are the Windows-only lines, as on main.
  • Round 2, on 23e1105. rv648's lean set, tests/test_family_bootstrap_gate_order.py tests/test_validate_family_unnamed_mounts.py tests/test_family_head_lost_gitmodules.py tests/test_validate_family_branch_step.py tests/test_family_members_case_variants.py tests/test_doctor_legs_members_next.py tests/test_family_bump_to_local_only.py tests/test_repo_hygiene.py tests/test_readme_sections.py and test_family.py's three bootstrap tests: 482 passed in 263.30s (0:04:23). That is 472 at 45c512e plus round 2's ten. The files that load the family bootstrap.py by path or check its imports and text, tests/test_update_shape_kept_helper.py tests/test_windows_paths.py tests/test_pinned_copy_text.py tests/test_cli_reference.py: 259 passed in 30.69s. Batch B was not re-run in round 2.
  • Not run: the 22 files that name only the assembly root's bootstrap.py (adopt, scaffold, bump-leg.py and setup tests), which this PR does not change. Main's six commits after 55c6ba7 were not run against this head either; merge-tree with them is clean.

Mutation probes (round 1)

Round 2's eight are in its own section above. Each of round 1's mutants was applied alone to a throwaway shared clone at 1a69505 (exact text replacements), and its test file was run against it. Each one is killed:

mutant result tests that fail
M1a the "git stopped at" reason never given 5 failed, 18 passed variant-plain, S1, H1, U1, the round trip
M1b submodule.active not asked 2 failed, 21 passed the recursive clones with no credential, stray and hand mount
M1c the config's url not asked 1 failed, 22 passed the url an earlier init registered
M1d a url-less section counts as having one 1 failed, 22 passed U1
M1e no --no-members sentence 4 failed, 19 passed variant-plain, S1, H1, the round trip
M1f a row's own stop given the --no-members sentence 1 failed, 22 passed U1
S1a the validator sentence for an exit-0 skip 2 failed, 21 passed update-none, subset
S1b no update line 2 failed, 21 passed update-none, subset
S1c the update line without --checkout 2 failed, 21 passed update-none, subset
S1d the update line without --init 2 failed, 21 passed update-none, subset
R2 (rv648's) "every member and leg fetched" over a failed fetch 1 failed, 22 passed the credential skip
X1 the digest finding's rule not the member's (the merge) 1 failed, 22 passed test_validate_family_branch_step.py's test_a_digest_s_rule_names_its_own_member
N1 the § Families marker left at c40baa82 1 failed, 76 passed test_repo_hygiene.py's test_the_handbook_follows_the_readmes_outline

S1c and S1d were also run with the test's expected line edited to match, so that only typing the printed line can catch them. Each is still killed, by the state that needs the flag: without --checkout, update-none fails (1 failed, 1 passed); without --init, subset fails (1 failed, 1 passed).

Merge checks (git merge-tree --write-tree f74f640c <ref>, 2026-10-17 at 00:35Z)

Merged with Head Result
origin/main 2a33a56 clean
lane 5's fix/704-bootstrap-leg-moves (#745: #704, #694), restacked on 23e1105 f4fa602 clean
lane 5's fix/681-validate-family-remedies (#747: #681), restacked on 23e1105 03e0b5a clean
lane 4's fix/690-siblings-inputs 38a40ab clean
lane 5's fix/695-family-docs (#738) c42286d CONFLICT in docs/handbook.html, the § Families marker line only, as before. Rounds 2 and 3 change neither README nor the handbook.
every other fix/* and l3-* branch on origin, 26 more clean

README is 1459 lines at f74f640 and in its merge with main.

Not changed, and why

  • The validator's members mode in S1, H1 and U1. It still refuses member-uninitialized for the first row, with a remedy that dies the same way, and no mode names U1. Bootstrap's line now names the cause in all three and the cure, so the person is no longer sent there. Candidate 3.
  • What git dies on while reading .gitmodules. Git 2.43 exits 128 before it checks out any member over a path, url, update or ignore key with no value, an update of !command or a value it does not know, a shallow that is no boolean, and a fetchRecurseSubmodules value it does not know (p8.log). init_stop does not model these, so where every row's section has a url, bootstrap still ends ok over them. Filed as a v1.1.1 issue (bootstrap.py and validate-family.py: a .gitmodules value git dies on while reading the file fails make bootstrap naming the key, and the family gate names it, so update = None no longer ends family bootstrap ok (#648) #768), which fixes the family validator in the same change.
  • Where git reads .gitmodules from. Git reads the work tree's file; where the work tree has none, the index's, then HEAD's; and none at all while a merge leaves it unmerged. Since round 3 the unmerged file is its own reason (MM1). The fallback to the index's or HEAD's file is still not followed: gitmodules_sections reads the work tree's alone. That is reached only where the work tree lacks the file while the index or HEAD holds it, as a deletion not yet committed or a sparse checkout that leaves it out leaves it. There every row git left not checked out is said to have no section, and the run fails, even where a credential was all git lacked. In the v1.1.1 issue.
  • submodule.<name>.active. Where submodule.active is set, git asks a member's own active key before the pathspec. init_stop asks neither, as round 1 did. In the v1.1.1 issue.
  • submodule.active pathspecs. init_stop takes the key's presence, not its patterns. Under a subset clone's members/Atlas, a url-less section of a member outside the subset counts as a stop, though git's init skips it. That is reached only where git's exit is non-zero for another reason, and then the line names that section, which is a real defect. In the v1.1.1 issue.
  • macOS and Windows were not run (CI is Linux only since ci: run the test suite on Linux only #597).
    • The variant states are index records; the holder's own are asked again with core.ignorecase=true.
    • A clone of S1 gets Members/Nova and members/Nova as two empty mounts. On a disk that folds case, git checks them out as one directory and warns. No assertion reads either directory, and git's init dies at the stray on every disk.
    • Every file is written as bytes, and url_edited reads a CRLF checkout too.
    • The backslash-name case (IRSS\..\x): git splits names on both / and a backslash on every system (is_xplatform_dir_sep), so it is ignored on Windows too.

Checklist candidates

  1. A half-typed move (rv648's 1). After typing the move and before committing, both modes print STRAY's git rm --cached -- Members/Nova, which fails pathspec ... did not match (exit 128). It is a v1.0.0 known limit. validate-family.py: a staged git rm is named again and fails retyped, the member-gitlink-absent remedy ends in a refused add, a shape-pin row with no path: reads None, and #639's mv orphans a live checkout and is stated by OS #681's branch says to commit a staged removal and prints no git rm there.
  2. The state a git mv members/Nova Members/Nova leaves (rv648's 2). The move fixes only the gitlink, and two more rounds follow.
  3. Members mode in S1, H1 and U1 (rv648's 3). It refuses member-uninitialized for the first row, with a remedy that dies the same way. No mode names a section with no url.
  4. v1.1.0's release notes for bootstrap.py and validate-family.py: family bootstrap ok with no member checked out, an earlier row's member-uninitialized hiding a case-variant refusal, and member-gitlink-unnamed with no command under --no-members #648, in lane 3's README, AGENTS.md, docs/cli.md and the handbook: pipx install openreposhape is the second way in and the first on native Windows, git-filter-repo is installed in install.sh's order and never with a bare pip install, and a first draft of the v1.1.0 release notes (#563) #630. A subset clone, an update = none member, and S1, H1 and U1 now fail make bootstrap. The line prints the cure for each.
  5. member-gitlink-unnamed says "a recursive clone of this holder fetches it" for a gitlink no section of .gitmodules gives. A recursive clone fetches nothing there, as S2 and X1 show for the stray.
  6. Bootstrap still says ok where git dies reading .gitmodules. Filed as a v1.1.1 issue (bootstrap.py and validate-family.py: a .gitmodules value git dies on while reading the file fails make bootstrap naming the key, and the family gate names it, so update = None no longer ends family bootstrap ok (#648) #768).

Closes #648

🤖 Generated with Claude Code

Summary by Sourcery

Make family bootstrap accurately distinguish credential-limited members from checkout failures and provide actionable repairs for invalid submodule state.

Bug Fixes:

  • Make family bootstrap fail when members cannot be checked out for repository-state reasons rather than silently reporting success, while continuing to skip members unavailable only because credentials are missing.
  • Detect malformed, ambiguous, or unmerged .gitmodules states and report actionable remediation for each affected member.
  • Ensure validation checks every member gitlink before checking out any member, and provide the case-variant gitlink move under --no-members.

Enhancements:

  • Align family bootstrap's .gitmodules interpretation and submodule-stop detection with Git's behavior, including duplicate sections, ignored names, option-like values, and configured URLs.

Documentation:

  • Update README and handbook family-bootstrap documentation to distinguish credential skips from bootstrap failures.

Tests:

  • Add comprehensive bootstrap, gitlink-ordering, .gitmodules, merge-conflict, credential, and case-variant regression coverage.

Brett Heap and others added 9 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>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-16T13:04Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 16, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens family bootstrap and validation around members that cannot be checked out: bootstrap now uses holder metadata to fail only for non-credential causes, validation checks every row’s gitlink before checkout-dependent checks, and --no-members emits the canonical case-variant index move, with extensive regression coverage and synchronized documentation.

Sequence diagram for credential-aware family bootstrap

sequenceDiagram
    participant Bootstrap as bootstrap.py
    participant Git as git submodule update
    participant Holder as Holder records
    participant Validator as validate-family.py

    Bootstrap->>Git: git submodule update --init --recursive
    Git-->>Bootstrap: exit status and output
    Bootstrap->>Holder: unfetchable(rows, exited_0)
    Holder-->>Bootstrap: rows not checked out with recorded cause
    alt non-credential cause found
        Bootstrap->>Bootstrap: report NOT CHECKED OUT
        Bootstrap->>Validator: name member-gitlink-absent or member-gitmodules-absent
        Bootstrap-->>Bootstrap: family bootstrap FAILED, exit 1
    else only credential-related skips
        Bootstrap-->>Bootstrap: report skipped members, family bootstrap ok
    end
Loading

Sequence diagram for case-variant repair in no-members mode

sequenceDiagram
    participant Validator as validate-family.py
    participant Index as Git index
    participant Operator as Operator

    Validator->>Index: inspect member gitlinks
    Index-->>Validator: case-variant gitlink at variant path
    Validator->>Validator: _wrong_case_move(row_path, pin, variants)
    Validator-->>Operator: print member-gitlink-unnamed and canonical move
    Operator->>Index: git rm -r --cached -- variant
    Operator->>Index: git update-index --add --cacheinfo 160000,pin,row_path
    Operator-->>Validator: commit repaired gitlink
Loading

Flow diagram for ordered family validation checks

flowchart LR
    Rows["Family rows"] --> Gitlinks["Check every row's gitlink"]
    Gitlinks --> Findings["Report gitlink findings"]
    Findings --> Checkouts["Check checkout-dependent state"]
    Checkouts --> Result["Pass or refuse with remediation"]
Loading

File-Level Changes

Change Details Files
Make bootstrap fail when a member is not checked out for a repository-state reason rather than a missing credential.
  • Inspect each unchecked-out row using its recorded gitlink and .gitmodules path entries.
  • Distinguish credential skips from missing gitlinks, missing .gitmodules sections, and successful updates that checked out nothing.
  • Report affected members and remediation guidance, suppress the success message, and return failure while preserving credential-skip behavior.
templates/family-root/scripts/bootstrap.py
README.md
docs/handbook.html
Separate gitlink validation from checkout validation so all rows’ gitlinks are examined before any checkout-dependent checks.
  • Return an intermediate gitlinked-row record from the per-member check.
  • Run digest and identity checks in a second pass after every row’s gitlink has been evaluated.
  • Ensure case-variant gitlink refusals are surfaced even when submodule update stopped earlier in the row list.
templates/family-root/scripts/validate-family.py
Provide the same corrective case-variant gitlink move under --no-members as in members mode.
  • Centralize construction of the index move and disk-cleanup remediation.
  • Use the row’s pinned commit and include all case variants in the removal command.
  • Restrict the command to canonical row paths with valid commit pins; retain no-command behavior otherwise.
templates/family-root/scripts/validate-family.py
tests/test_validate_family_unnamed_mounts.py
Add focused regression coverage for bootstrap failure classification, validation ordering, and remediation output.
  • Cover recursive and plain clones with missing .gitmodules sections, case-variant gitlinks, credential failures, skipped updates, and duplicate rows.
  • Exercise the end-to-end printed remediation through checkout and validation in members, pins, and no-members modes.
  • Verify one- and two-variant cases, case-folding behavior, non-commit pins, exact output, and preserved good-holder behavior.
tests/test_family_bootstrap_gate_order.py
tests/test_validate_family_unnamed_mounts.py

Assessment against linked issues

Issue Objective Addressed Explanation
#648 Make bootstrap fail with a clear explanation and a non-zero exit when a member is left unchecked out for a reason a credential cannot fix, rather than claiming all members were fetched or ending successfully. ✅
#648 Validate every member row's gitlink before checking out any member, so an earlier member-uninitialized refusal cannot hide a later case-variant gitlink refusal. ✅
#648 Under --no-members, give member-gitlink-unnamed the same index-only move command as members mode when a member's gitlink is recorded only in the wrong case. ✅

Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@chatgpt-codex-connector

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

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T13:14:30.341287Z 8b39cd1 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8b39cd195e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +155 to +156
if key.startswith("submodule.") and key.endswith(".path"):
paths.add(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require a URL before treating a submodule path as fetchable

When a .gitmodules section retains path = members/X but loses its url, git submodule update exits 128 and leaves the member unchecked out, yet this helper adds the path to given. Because the Git command did not exit 0, unfetchable() then reports no failure, the member is skipped, and bootstrap exits 0 with family bootstrap ok; this is a structural error that no credential can fix. Track complete path-and-URL sections rather than paths alone.

Useful? React with 👍 / 👎.

This was referenced Oct 16, 2026
…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>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
…h main 2fac979) into fix/704-bootstrap-leg-moves: the family root's bootstrap.py keeps #694's `git_not_started` above #682's `cures` and `gitmodules_sections` (#704 x #648)

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

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

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

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
…eck_member_gitlink` takes both `repository` (#681) and THE LOCKSTEP RULE's `rule` (#610), and the gate-order test keeps `import re`, which the merge dropped with no conflict (#681 x #648)

#682 (fix/648-bootstrap-and-gate-order) moved on from 8b39cd1 to
45c512e: main 2fac979 merged in (bad6c00, which brings #634's
per-member THE LOCKSTEP RULE onto `Gitlinked`), then 1a69505 and
45c512e. Two files conflicted; every other file merged clean.

templates/family-root/scripts/validate-family.py, three regions:
- WHAT IS CHECKED: #682's narrower wrong-case sentence, then #681's
  staged-removal sentence after it.
- `_check_member_gitlink(root, path, commit, project, repository,
  gitlinks, report, rule)`.
- `_check_member` passes `row.get("repository"), gitlinks, report, rule`
  and returns `Gitlinked(row, project, path, pin, commit, rule)`.

tests/test_family_bootstrap_gate_order.py, two regions:
- the module docstring keeps both sentences, the guarded `mv` (#681) and
  the clone of a stray (rv648's S1);
- the import from test_family_head_lost_gitmodules is the union of both
  sides (GITMODULES and committed_gitmodules from #682, GUARDED_MOVE and
  typed_in_sh from #681).
- `import re` is put back by hand: this branch had dropped it, and #682's
  side uses `re.compile` and `re.findall` again (rv681's restack note).
  `import subprocess` stays dropped; neither side uses it.

The resolution equals rv681's model (_lane5-scratch/rv681-restack2, built
on d91fd15) with this branch's fix round d91fd15..dbca113 applied
(`git merge-file`, byte for byte), and the gate-order test is the model's
byte for byte.

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

# Conflicts:
#	templates/family-root/scripts/validate-family.py
#	tests/test_family_bootstrap_gate_order.py
brettheap added a commit that referenced this pull request Oct 17, 2026
… verification row's colon, `run_validator`'s "costs no child an import" and the decoy test's `sitecustomize` claim say what main does (#666) (#730)

Four comments and docstrings said something main no longer does. They now say what the code beneath them does. A fix round after review added two more of the same kind (see "Fix round"). Comments and docstrings only: no behaviour, no `--help` text, no pinned copy, so no README, handbook or `docs/cli.md` change.

- **`adopt-project.py`, the module docstring.** It said the verification table reads `code: 0 of N source paths (seeded from template)`. The printed row has no colon (`  {leg:<6} {count:>5} of {N} source paths{note}`). It now says the table's `code` row reads `0 of N source paths (seeded from template)`, the words `AGENTS.md` and `README.md` use since #505 and the docstring of the verification function uses too. `git grep 'code: 0 of'` finds nothing now.
- **`shape-doctor.py`, `submodule_config`.** The last sentence of the docstring gave a reason that is true of the string and not decisive: "It is also what the row PRINTS, and a lone surrogate is a string no UTF-8 stream can write." The table goes through `ascii_text` and `--json` through `json.dumps`, so neither would raise on a lone surrogate. I dropped the sentence, the issue's first direction. The comparison with `origin_url`'s reading of a clone's remote is the reason, and the paragraph already gives it first. **This hunk carries the bytes of lane 3's #700**, which landed as `55c6ba7d`: the two removed lines and the one added line (`    working clone.`) are the same. So the hunk is on main already and drops out of the squash (see "Evidence").
- **`shape-doctor.py`, `run_validator`.** "It costs no child an import" holds for an unedited copy only. A new paragraph says a root whose own copy of a validator gained an `import yaml` read `passes` while `site` was on, and now reads `crashed before reaching a verdict (exit 1): ModuleNotFoundError: No module named 'yaml'`, the crash its own CI gets, because `validate.yml` installs nothing. It calls that row a fair answer and says `-S` does not ask whether a copy was edited. No code change.
- **`tests/test_shape_doctor.py`, the decoy test's two comments** (the block above `DECOY_REPO_SHAPE` and the inline comment before `reached`). `run_validator` starts every child as `[sys.executable, "-S", script, ...]`, so no child imports a `sitecustomize`. The comments now say that, say that replace-not-prepend and the #306 review S1 case are held by `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given`, and say that the decoy test holds the template row's real verdict under an exported `PYTHONPATH`.

The review of the first two commits approved with nits. Two new commits take S1 and N1: `7f2bb36b` makes both edits, and `426877bf` rewrites the last sentence of the N1 comment, which did not parse ("...a future removal of `-S`, which `test_run_validator_starts_every_child_with_no_site` ... is the test that holds"). It now ends "...a future removal of `-S`; `test_run_validator_starts_every_child_with_no_site` (in `tests/test_doctor_validator_site.py`) is the test that holds `-S`." Both are comments and docstrings only.

- **S1, a third stale sentence.** The docstring of `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given` said that for the callers which pass no `pythonpath`, "only a probe like this one, or (b)'s decoy, can see that they have none". That is false under `-S`: a `run_validator` that keeps the variable for those callers passes the decoy test in all three cases and fails the probe test twice (the second row of the table below). It now says only a probe like this one can see that they have none, and that the decoy of (b) could before `-S` (#342). It is the same defect as the issue's, in a place the issue did not name. "Could" holds for the `manifest` and `pins` cases: with `-S` removed, 2 of the 3 decoy cases fail, and the `family` case never caught it.
- **N1, the inline comment overstated the loop.** The comment above `reached` said "What this loop still holds is that no row quotes the decoy or says it exited 86." Under `-S` the loop cannot fire. It now says so, says the loop is kept as a tripwire for a future removal of `-S`, and names `test_run_validator_starts_every_child_with_no_site` in `tests/test_doctor_validator_site.py` as the test that holds `-S`. That test is on main.
- **N2, this body.** The merge checks and the notes on #630 were stale (they cited `de926142` and a conflict that is gone). They are rerun and rewritten below.

**Only comments and docstrings changed.** `git diff 8062810 426877b` has 74 changed lines (44 added, 30 removed) in three files. A script checked each changed line against the file's comment tokens and docstring spans (both sides of the diff): 0 outside. A second check parsed each file at the base and at the head, blanked every docstring, and compared the ASTs: identical in all three files. For the two fix-round commits together, the AST of `tests/test_shape_doctor.py` with docstrings blanked is identical to the one at `a2113cff`, `git diff --check` is clean, and the longest added line is 75 columns (73 in the second commit).

**What the squash holds.** `git merge-tree --write-tree` of this head with origin/main `20c8873b` gives the tree `6ead2ffc`. `git diff 20c8873 6ead2ffc` is 71 changed lines in three files, and `shape-doctor.py` has 8 of them: the new `run_validator` paragraph and nothing else. `git diff` of the two trees has no hunk that mentions `submodule_config`, `surrogate`, `PRINTS` or `working clone`. On main, `submodule_config`'s docstring has no "PRINTS" sentence and ends in `working clone.`. The same two checks on that tree: 0 lines outside comments and docstrings, ASTs identical. On main `2d988200` with the head `426877bf` the squash is the same: 71 changed lines, 8 of them in `shape-doctor.py`, 0 outside comments and docstrings, ASTs identical, and no hunk that mentions `submodule_config`.

**`python3 scripts/render-cli-reference.py --check`**, at `426877bf`: `[ok] docs/cli.md is what the generator produces`. Only the first line of a module docstring is help text, and none of the first lines changed.

**The decoy comments, measured.** I did not take the issue's reading on trust. In a throwaway clone at the base I put each #306 regression into `run_validator` and ran the two tests (`test_an_exported_pythonpath_does_not_reach_a_template_validator`, three cases, and `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given`, six):

| mutant | with `-S` (main) | with `-S` removed |
|---|---|---|
| the standard's `scripts/` put in FRONT of the operator's value | decoy test: 3 of 3 pass. Probe test: 1 of 6 fails | decoy test: 3 of 3 fail |
| the variable kept for the callers that pass none (S1) | decoy test: 3 of 3 pass. Probe test: 2 of 6 fail | decoy test: 2 of 3 fail |
| the operator's directory AHEAD of the standard's | decoy test: 3 of 3 fail. Probe test: 1 of 6 fails | not run |

So on main the decoy test catches neither #306 regression and the probe test catches both, as lane 4's R342 measured on #636's head. The third row is why the comment says the raising `repo_shape.py` "still bites": it kills an operator-first order. The review re-ran these mutants on `a2113cff` and got the same counts.

**The `run_validator` paragraph, measured.** A root whose own `validate-manifest.py` is `import yaml` plus a print, run through `run_validator` from this tree: exit 1 and `ModuleNotFoundError: No module named 'yaml'`. The same call with `-S` taken out of the child's argv: exit 0, `manifest ok`. `yaml` is importable from the machine's site-packages and not without `site`.

With the real `gh` unreachable (a stub first on PATH, a bogus token, an empty `GH_CONFIG_DIR`).

On `a2113cff`, before the fix round:

- `tests/test_cli_reference.py`, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_scaffold_leg_path_collisions.py`: 250 passed (57.5 s).
- `tests/test_doctor_gitmodules_not_utf8.py`, `tests/test_doctor_validator_rows.py`, `tests/test_doctor_validator_site.py`, `tests/test_doctor_validator_verdicts.py`, `tests/test_adopt_spec_only.py`, `tests/test_adopt_entry_paths.py`, `tests/test_adopt_submodules.py` (the files that drive `run_validator`, `submodule_config` or the seeded-leg row): 334 passed (28 min 18 s).
- `tests/test_shape_doctor.py`, in two halves run side by side (the first 204 test ids, then the last 204): 204 passed in 11 min 31 s, and 201 passed, 3 skipped in 4 min 58 s. That is all 408 tests. The 3 skips are the PowerShell-line tests, which need `powershell.exe` on a Windows host.
- The review ran all of `tests/test_shape_doctor.py` there: 405 passed, 3 skipped (512 s). On the predicted merge with main `2fac9790` it ran `test_shape_doctor.py`, `test_doctor_validator_site.py`, `test_doctor_validator_rows.py`, `test_doctor_validator_verdicts.py`, `test_doctor_gitmodules_not_utf8.py`, `test_repo_hygiene.py`, `test_cli_reference.py` and `test_agents_md_codes.py`: 632 passed, 3 skipped (760 s).

On `426877bf`, after the fix round, which changes comments and docstrings only, so the runs above stand:

- The two tests the fix round touches, `test_an_exported_pythonpath_does_not_reach_a_template_validator` (3 cases) and `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given` (6 cases), run by node id: 9 passed (11.7 s). The same 9 also passed (16.1 s) at `7f2bb36b`.
- `tests/test_repo_hygiene.py`: 77 passed (1.9 s). It also passed (2.0 s) at `7f2bb36b`.

No test fails on the base, because nothing here changes behaviour. The AST comparison above is the proof in their place.

`git merge-tree --write-tree` of this head against origin/main and against every ref on origin that could meet it. The full run was on 2026-10-16, with the head `7f2bb36b` (the one before the last comment commit) and main `20c8873b`: the 41 branches (`fix/*`, `l3-*`, `wip/*`, `docs/*` and `release/v1.0`) and the 55 PR heads from #630 up. That is 96 refs. I ran #731's head `ab55c29f` through the same list. After `426877bf` I re-ran main, now `2d988200`, and the six heads that edit `tests/test_shape_doctor.py`, the one file that commit touches (#705, #737, #740, `l3-669-members-row`, `l3-680-doctor-signals` and `l3-697-crash-cure-next`), and #630: all clean.

- **origin/main: clean.** At `20c8873b`, with the head `7f2bb36b`, the predicted tree is `6ead2ffc`. At `2d988200` (three `adopt-project.py` commits later, with their tests and a change in `scripts/shape_materialize.py`), with the head `426877bf`, it is `7ee5caf4`, and the squash is the same three files (43 added, 28 removed). #731's head `ab55c29f` is clean with `2d988200` too. This head with #731's head: clean.
- **#630's head `6fd33a93`: clean.** It conflicted with main in `docs/handbook.html` when this body was first drafted, and no longer does, with or without this change.
- **#700 and #702 have landed** (`55c6ba7d` and `20c8873b`), and so has #701, and #699, #717 and #719 for `adopt-project.py`. Their heads still merge clean with this head.
- **Every ref that edits `shape-doctor.py`, `tests/test_shape_doctor.py` or `adopt-project.py` merges clean with this head**, except the two old heads named in the bullet after next. They include the heads of PRs not on main yet (#705, #721, #737 and #742, and lane 3's `l3-680`, `l3-683`, `l3-687`, `l3-691` and `l3-697`, which were not on origin when this body was first drafted). #673 has no branch or PR head on origin.
- **Four refs conflict with origin/main on their own**, and so with this head: `docs/release-notes-v1.0.0` and `release/v1.0`, and the pre-squash heads of the landed #637 and #641. Their conflicts do not come from this change.
- **Two landed pre-squash heads conflict with this head only because they are old.** #632 conflicts with main on its own. #636 merges clean with main, but its old head conflicts with this head in `run_validator`'s docstring, next to the text #636 added; main has that text, and this head merges clean with main.
- **Refs that conflict with main on their own are not made worse.** #682, #745, #747, `fix/648`, `fix/681` and `fix/704` conflict with main in `templates/family-root/scripts/validate-family.py`; #742 and `l3-683` in `shape-doctor.py`. Each merges clean with this head.
- **Every other open head and branch: clean.**

Nothing from the issue. Its four lines are done. The decoy test's `sitecustomize` machinery is dead code under `-S` and I left it, because the issue says comment only. The comment above `reached` now says it is kept as a tripwire.

- **The decoy test's `sitecustomize.py` and exit-86 trap never run.** `DECOY_SITECUSTOMIZE`, `os._exit(86)` and the `(exit 86)` and `DECOY` clauses in `reached` can only fire for a child started without `-S`. They are kept as a tripwire and the comment says so. The other choice is to remove them; `test_run_validator_starts_every_child_with_no_site` already holds `-S`. Small, tests only.

Review: rv666, lane 5's independent review, approved a2113cf with nits; 7f2bb36 and 426877b take its S1 and N1 (above), and codex found no major issue at a2113cf. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 426877b passed tests, tests-git-bash and SonarCloud; the head 32722b3 is a clean update-branch merge of main bf1f099, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 55d5d3b over main dcf62ed: the shape_doctor, scaffold_leg_path_collisions, repo_hygiene, agents_md_codes, cli_reference sets, 686 passed, 3 skipped, 1 xfailed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #666

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 17, 2026
…e no bytecode under templates/, the launcher guard reads family.py's siblings child, the quoting-sentence inventory follows an aliased quoter, a suffixless tool and each tool's calling functions, and an S5778 pytest.raises builds its argument first (#665) (#724)

Four guards held less than they said. One commit, `151a0861`, on `8062810d`. No shipped behaviour changes except one environment variable in the generator's `--help` children. No `--help` text changes, so `docs/cli.md` is not regenerated. No quoting changes.

1. **`scripts/render-cli-reference.py`: `child_env()` sets `PYTHONDONTWRITEBYTECODE=1`.** The template's `siblings.py` does `from bootstrap import ...` from its own directory, so the generator's `--help` child wrote `templates/family-root/scripts/__pycache__/bootstrap.cpython-312.pyc` (ignored by git, so silent). A person who runs the generator now gets the same tree as the test that runs it. The docstring says why. `tests/test_cli_reference.py` gets one test: the variable is in `child_env()` whatever the caller had, and an interpreter started under that environment reports `sys.dont_write_bytecode` as true.
2. **`tests/test_validator_child_encoding.py`: the launcher guard sees `family.py`.** `interpreter_runs` accepts `[sys.executable or PYTHON, ...]` as well as `[sys.executable, ...]`, and `LAUNCHERS` gains `"scripts/family.py": "inherited_child_environment"`. Dropping `env=` from `family.py siblings`' child (#214) now fails the guard.
3. **`tests/test_readme_windows_quoting_sentence.py`: the inventory holds what it claims.** It follows `from repo_shape import shell_line as joined` (and `joined = shell_line`, however many steps). It scans every file `git ls-files` lists (tracked, or new and not ignored) that is a `.py` file or starts with a `python` shebang, which adds the root `bootstrap`. It pins functions, not files, for the two tools the sentence names by particular lines: `scripts/family.py` (`_print_next`, `_switch_command`, `_add_command`, `_print_next_steps`, `_checked_mount_path`) and `setup-project.py` (`family_next_command`). `shape-doctor.py` stays every line, because the sentence says "`shape-doctor.py`'s lines". Only the bodies of `shell_quoted` and `shell_line` in `scripts/repo_shape.py` are exempt, by name; the rest of that file is not. Nothing about quoting changes: the Git Bash dialect (option (a)) is its own v1.1.1 issue, and when it moves `NAMED`, this table moves with it.
4. **`tests/test_bump_leg_paste_lines.py`: Sonar S5778.** `placement = bump_leg_module.leg_placement(leg)` before the `with pytest.raises(LegNotAsFound)` in `test_pasting_the_status_line_reads_that_leg`, as the test above it does. Five lines; the hunk is :401-405, away from #657's hunks.

**The reproduction (`.pyc`).** In a fresh shared clone at `8062810d`, with `PYTHONDONTWRITEBYTECODE` unset, `python3 scripts/render-cli-reference.py --check` printed `[ok] docs/cli.md is what the generator produces` and left five `.pyc` files, among them `templates/family-root/scripts/__pycache__/bootstrap.cpython-312.pyc` at 11533 bytes (the size the issue reports). The same command on `151a0861` printed the same `[ok]` line and left none (`find . -name '*.pyc'` found 0).

**Mutants.** Each is applied to a copy at the head's test files; "old" is the same mutant under `8062810d`'s own tests.

| Mutant | New tests | Old tests |
|---|---|---|
| M0: drop `PYTHONDONTWRITEBYTECODE` from `child_env()` (the base's behaviour) | `test_child_env_leaves_no_bytecode_in_the_trees_it_runs` fails: `KeyError: 'PYTHONDONTWRITEBYTECODE'` | none to run |
| M1: drop `env=` from `family.py`'s `siblings` child (`scripts/family.py:6543`) | `test_the_tools_that_run_the_projects_validators_give_them_an_environment[scripts/family.py]` fails: ``scripts/family.py:6543 runs the interpreter without `env=inherited_child_environment()` `` | 55 passed (survives) |
| V2: `from repo_shape import shell_line as joined` and a call, in `update-shape.py` | `test_the_tools_that_quote_for_the_host_...` fails: files are `[family, setup-project, shape-doctor, update-shape]` | 3 passed (survives) |
| V3: `family.py`'s `_switch_command` calls `shell_line(..., "posix")` | `test_the_functions_that_quote_for_the_host_...[scripts/family.py]` fails: `_switch_command` missing from the calling functions | 3 passed (survives) |
| V5: a host-quoter call in the suffixless `bootstrap` | the file test fails: `bootstrap` is now among the files | 3 passed (survives) |
| V6 (the exemption): a third function of `scripts/repo_shape.py` calls `shell_line` | the file test fails: `scripts/repo_shape.py` is now among the files | 3 passed (survives) |

Each new failure is one test, and the other tests of the file stay green (`1 failed, 8 passed`; `1 failed, 55 passed`). V2, V3 and V5 are R588's mutants as the issue describes them.

On `151a0861`, one run, `run9.sh` (gh stub first on PATH, `GH_TOKEN` bogus, empty `GH_CONFIG_DIR`):

`tests/test_cli_reference.py tests/test_validator_child_encoding.py tests/test_readme_windows_quoting_sentence.py tests/test_bump_leg_paste_lines.py tests/test_family_siblings_placement.py tests/test_repo_hygiene.py`: **227 passed in 698.59s**.

Per file: `test_cli_reference.py` 23 (one new), `test_validator_child_encoding.py` 56 (one new case, `[scripts/family.py]`), `test_readme_windows_quoting_sentence.py` 9 (6 new: the `scripts/family.py` and `setup-project.py` function pins, the alias, the method name, the exemption, the suffixless file), `test_bump_leg_paste_lines.py` 33, `test_family_siblings_placement.py` and `test_repo_hygiene.py` 106 together.

- **origin/main `35133f29`** (includes #657 as `78fe3fff`, which edits `tests/test_bump_leg_paste_lines.py` at :53, :235-244 and `setup-project.py`): clean. On the predicted merge commit, `test_readme_windows_quoting_sentence.py` and `test_validator_child_encoding.py` pass (65 passed), and so does `test_bump_leg_paste_lines.py` (33 passed). `family_next_command` is still the only function in `setup-project.py` that calls `shell_word`.
- **#630** (`5b140bd3`): conflicts in `docs/handbook.html` only, and it does so against `8062810d` and `35133f29` without this commit, so not this change's. It changes `scripts/render-cli-reference.py`, `docs/cli.md` and `tests/test_repo_hygiene.py`; none of those hunks overlaps mine, and `merge-tree` reports no conflict in them.
- **Lane 4's `family.py` work:** the heads I could fetch are #670 (`pr670`) and #671; `fix/664-surviving-mutants` and `fix/696-setup-family-folder` are on origin too. Clean with each. Lane 4's other `family.py` branches (#684, #686, #688, #689, #693, #674) are not on origin and not in the shared `.git`, so I could not check them. Predicted merges with #670, #671 and lane 5's `fix/646` pass the two guards' files (65 passed each).
- **Other open heads touching a tool the inventory reads:** #703 and `fix/696` (`setup-project.py`), #699 (`adopt-project.py`), #700, #701, #702 and #705 (`shape-doctor.py`), `fix/664`: clean, and the inventory file passes on each predicted merge (9 passed each). #682, #698 and #706: clean.

- **No quoting change.** The Git Bash dialect (option (a)) is a separate v1.1.1 issue; the inventory only holds what the sentence says today.
- **`docs/release-notes/v1.0.0.md:532`** says the `.pyc` is left by `test_cli_reference.py`'s children. That is true of v1.0.0 and I left it. The v1.1.0 notes (#630) may want one line.
- **`--others` in the file listing.** A new, untracked tool is scanned before it is added, as `rglob` did; a tracked file deleted from the work tree is skipped.

- **A quoter handed on as a value is not a call.** `map(shell_quoted, words)` or `functools.partial(shell_line)` is not seen by the inventory (said in `host_calls`' docstring). Nothing does it today.
- **The function pin counts functions, not calls.** A function with two host-dialect calls where only one is switched to POSIX still passes. Today the one-call functions are `_switch_command`, `_print_next`, `_checked_mount_path`.
- **`_print_next_steps` prints a `cd {shell_quoted(work)}` line** the sentence's list (`NEXT`, `switch -c`, `add`, `git rm -r --cached`) does not name. It belongs to the Git Bash dialect issue.
- **The launcher guard's `LAUNCHERS` still has no row for `src/openreposhape/cli.py` (`run_child([sys.executable, ...], env)`) or `setup-project.py` (its own `run` with a `PYTHON` variable).** Both spell the child differently from `subprocess.run([sys.executable, ...])`.

Review: lane 5's independent review approved 151a086 with nits only, and codex reviewed it. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 151a086 passed tests, tests-git-bash and SonarCloud; the head 949fd4d is a clean update-branch merge of main bf1f099, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 9cd4767 over main 974d482: the bump_leg_paste_lines, cli_reference, readme_windows_quoting_sentence, validator_child_encoding, adopt_plan_leg_path_help, repo_hygiene, agents_md_codes sets, 290 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #665

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 17, 2026
main gained #706 (#664), #703 (#696), #670 (#646), #682 (#648), #726
(#667), #730 (#666) and #724 (#665) since bf1f099. No conflict: #682's
README sentence and its handbook twin (Families) merged as text, with
#682's own Families marker, and every marker reads current. README.md is
1474 lines, its cap; AGENTS.md 494, its cap; `render-cli-reference.py
--check` is ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…d A3 (three SHOULDs, four nits), and notes entries for the seven pull requests landed since bf1f099, current to main 2000488 (#563, #683)

Round A4, from lane 2's review (#630 issuecomment-6233033833, READY):
- SHOULD 1: the doctor's exit-3 list names "a validator of its own that
  crashed or could not be run", so the case of one that cannot be given
  its `PYTHONPATH` (shape-doctor.py's EXIT CODES, #278) is listed too:
  README.md:1208-1222, its handbook twin (`doctor`) and the notes' docs
  line. The paragraph re-wraps to one more line:
  tests/readme-cap.d/683.txt is +2, and its text now says which causes
  the EXIT CODES block names and that #742 added the partial clone and
  #701 the naming policy. README.md is 1475 lines, its cap (1422 + 2 + 5
  + 12 + 22 + 10 + 2). Both `Keeping a project's shape current` markers
  move to c728c970: `doctor` text and marker, `current` marker only.
- SHOULD 2: #727's entry names the leg description, whose em dash stopped
  every `execute` against GitHub at the first leg.
- SHOULD 3: #749's entry names `source-ambiguous`, for an `org/repo` that
  is also a directory here.
- Nits: the doctor section's three near-duplicate pairs are merged (#636
  with #737, #632 with #700 and #705, #586 with #736); #718 has its own
  bullet; #634's entry names `git_out`'s spelling for every caller and
  #744's the gate's `--link-source-label` and `--link-remedy`; the header
  comment says 2026-10-17 at main 2000488.

Wave B1, `git log --first-parent bf1f099..2000488`: #706 (#664), #703
(#696), #670 (#646), #682 (#648), #726 (#667), #730 (#666) and #724
(#665), each from its squash commit; #726's and #682's from lane 5's lines
(#630 issuecomment-6227129879), #682's with the unmerged `.gitmodules`
its last round added, and #724's with lane 5's note that v1.0.0's
test-hygiene line on the generator's `.pyc` files closes. The counts (106
pull requests; 116 of 135 milestone issues closed at 11:53Z), "current to
main" and the open list (19) are at main 2000488, still placeholders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 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 17, 2026
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
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…s the stacked base of #763

#747 moved after the first merge here (4e199e4, its head 58d6c50): #682
landed as dc7d3fb and #747 took #682's final head 5afb24b and main
dc7d3fb (11:41Z). This takes the new head, so the base is the landed #682
and #747's current work. No conflict.
brettheap added a commit that referenced this pull request Oct 17, 2026
… or an ignored file in the way is refused, P9 names the branch it left, and no git is `git-not-runnable`, so `make bootstrap` loses nothing (#704) (#694) (#745)

`make bootstrap` now moves a leg only where nothing the person has is lost, says which branch it left, reads every HEAD one way, and refuses by name when git cannot run. One pull request closes both issues because they edit the same two `bootstrap.py` files.

It is stacked on #682 (#648, `fix/648-bootstrap-and-gate-order`, head `8b39cd19`). The diff is the one commit after `8b39cd19`; main is not merged.

**Pinned files changed** (each one is an `upstream-changed` row in every project or holder, in v1.1.0's one shape bump, per R0):
- `templates/assembly-root/scripts/bootstrap.py`
- `templates/assembly-root/AGENTS-shape.md`
- `templates/family-root/scripts/bootstrap.py` (already re-pinned by #648)

### #704

- **Line 1, an ignored file.** The four moves that change files overwrote an IGNORED file at a path the pin tracks, with no word said. The four are P9's checkout, T7's checkout after `branch -f`, the on-branch `merge --ff-only`, and the missing branch's `checkout -b`. Each now passes `--no-overwrite-ignore`, so git refuses the ignored file as it refuses an untracked one, in its own words.
  - On T7, `branch -f` has already moved `main` to the pin when the checkout is refused. That was already so for an untracked file, and the line already says `branch main moved ... and git refused to check it out:`.
  - The missing-branch path now prints git's lines through `_say_refused`. It also says where the leg stays: `leaving it on other`. It used to say `leaving detached` for a leg that was on a branch.
- **Line 2, an operation in progress (Brett Heap's R1).** `checkout_tracking_branch` now reads the leg's git dir (`git rev-parse --git-dir`) for these markers, in this order:
  - `rebase-apply/applying` (an `am`), `rebase-apply` and `rebase-merge` (a rebase), `MERGE_HEAD`, `CHERRY_PICK_HEAD`, `REVERT_HEAD`, then `sequencer/todo` (see Fix round).
  - It checks them before any path is chosen.
  - The refusal names the operation, says nothing moved, and gives both commands, for example:

        [spec] spec: a merge is in progress in the leg (MERGE_HEAD is in its git dir), so nothing moved
                  finish it (`git -C spec merge --continue`) or abort it (`git -C spec merge --abort`), then run bootstrap again.

  - Why every leg, and not only the moves that leave a branch: git's checkout drops `MERGE_HEAD` whenever it runs. Measured, it ended a merge from HEAD detached at the pin, from HEAD already on `main` at the pin (a checkout that moves nothing), and in the move back of a branch past the pin.
  - `merge --ff-only` refuses over `MERGE_HEAD` and `CHERRY_PICK_HEAD`. It fast-forwards over `REVERT_HEAD` and keeps the staged revert (measured).
- **Line 3.** `place_at_pin` calls `_say_left` when HEAD was on another branch, as `fast_forward_to_pin` does.
- **Line 4 (R2).** `AGENTS-shape.md` now says bootstrap places a leg at the pin *only* where that loses no commit, and otherwise leaves its checkout where it is, saying why. The code is unchanged.
- **Line 5 (the issue's recommended answer).** A HEAD that is a symbolic ref outside `refs/heads/` is now left where it is by `place_at_pin` (`which is neither`, as T7 already said). The missing-branch path leaves it too (`which is not a branch`). A commit made there lands on that ref.
- **Line 7.** The family root's docstring paragraph (b) now uses the issue's sentence: the member puts its legs on their tracking branches at their pins where that loses no commit, and says why where it does not.

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

### #694

With no git on PATH, both roots ended in a `FileNotFoundError` traceback, exit 1.

- **Assembly root:** step (a) runs each leg inside `_git_runs`, and so does the offline upstream notice.
- **Family root:** step (a) has a guard around `git submodule update`.
- **Both:** the refusal is `git-not-runnable`, the validators' code, with their first remedy sentence (`put git on PATH and run this again`), and the exit is 2.
- **The run stops there.** Every later step runs git too (#694 asked this with #648).
- **Which `OSError` counts as git's** is decided by NAME, as `validate-pins.py` decides it. The bootstraps do not settle the validators' two rules (the #539 rider).

All runs used `run9.sh` (gh stub first on PATH, `GH_TOKEN=bogus-l5`, empty `GH_CONFIG_DIR`), with git 2.43.0.

- **New: `tests/test_bootstrap_leg_moves.py`**, 27 tests: 22 from the first round, and 5 from the fix round (see Fix round).
  - Line 1: P9, T7 and the missing branch, each from another branch with an ignored `README.md` in the way, plus the on-branch fast-forward with an ignored `NEW-CONTRACT.md`.
  - Line 2: a merge, a cherry-pick and a revert in progress, each on P9, T7 and the missing branch; a merge from three more states (detached at the pin, on `main` at the pin, `main` past the pin); a rebase (both backends) and an `am`.
  - Line 5: all three paths.
  - Every way out is run as printed, and the next run places the leg.
- **New: `tests/test_bootstrap_without_git.py`**, 19 tests.
  - With `PATH` an empty directory: the assembly root (`bootstrap` and `--skip-validators`), the upstream notice in a plain clone, and the family holder (`bootstrap` and `--skip-members`).
  - The name rule, asked of each root's own function.
- **Edited: `tests/test_bootstrap_after_bump.py`.** The three P9 tests that asserted P9 says nothing of the branch it left now read the line exactly.
- **On base `8b39cd19`:** 43 failed, 47 passed. Every new test fails there, except line 5's T7 case, which is the control: T7 already left that HEAD.
- **On head:** every run is green on `4e2b211b`:
  - The 12 core files (every `tests/test_bootstrap*.py`, `test_family_bootstrap_gate_order.py`, `test_family.py`, `test_template_text_placeholders.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_stdout_encoding.py`): `806 passed, 1 skipped` (the skip is Windows-only).
  - The other 53 files that run or name either `bootstrap.py`, in two halves: `1838 passed, 1 skipped` and `2183 passed, 11 skipped`. Their skip reasons were not printed, because the run passed `-rf` in place of `-rs`.
  - Before the commit, the two new files alone gave `41 passed`, and `tests/test_bootstrap_after_bump.py` alone gave `49 passed`.

Every line of both issues was run on `8b39cd19` and on the head, with a scaffold into local bare remotes and git 2.43.0.
- **Line 1:** on base, all four moves wrote the pin's file over the person's. On head, all four refuse with git's lines, and the file is still the person's.
- **Line 2:** on base, every move ended the merge, cherry-pick or revert, or put the rebase or `am` on `main`. On head, every case is refused and its marker is kept.
- **Line 3:** on base, the line was missing. On head, `the leg was on elsewhere, which keeps its commits; it is on main now`.
- **Line 4:** both lines are as the issue quotes them, on base and on head. Only the document changed.
- **Line 5:** on base, P9 placed the leg and the missing-branch path created `main`. On head, all three paths leave it.
- **#694:** on base, a traceback with exit 1 (assembly root; the notice after `validate-pins.py`'s own `git-not-runnable`; family root). On head, `REFUSED git-not-runnable`, exit 2. `--no-fetch` is unchanged: exit 0 on both.

Each hunk was reverted alone in a throwaway clone of the head. In all 17 cases at least one new or edited test fails, on an assertion:

| Hunk reverted | Test that fails |
|---|---|
| `_check_out`'s `--no-overwrite-ignore` | ignored file, P9 |
| `merge --ff-only`'s `--no-overwrite-ignore` | ignored file, the fast-forward |
| `checkout -b`'s `--no-overwrite-ignore` | ignored file, missing branch |
| the in-progress refusal | operation in progress, merge on P9 |
| `am` asked after `rebase-apply` | rebase or `am`, the `am` case |
| P9's `_say_left` | `test_a_leg_on_another_branch_with_its_own_branch_at_the_pin_is_as_it_was` |
| P9 leaving a HEAD outside `refs/heads/` | remote-tracking HEAD, P9 |
| the missing branch leaving it | remote-tracking HEAD, missing branch |
| `leaving it on <branch>` | ignored file, missing branch |
| the missing branch printing git's lines | ignored file, missing branch (`git names the file`) |
| step (a)'s `_git_runs` | no git, assembly root |
| the notice's `_git_runs` | no git, the upstream notice |
| `main`'s `except Refusal` (assembly) | no git, assembly root |
| the assembly root's name rule | an error that is not git's, assembly root |
| the family root's guard around `git submodule update` | no git, family holder |
| the family `main`'s `except Refusal` | no git, family holder |
| the family root's name rule | an error that is not git's, family root |

The two documents (`AGENTS-shape.md` and the family docstring) are prose that no test reads.

- origin/main `de926142`: clean. Main is now `2fac9790`, with #634 landed as `30676c90`. Against it, the only conflict is `templates/family-root/scripts/validate-family.py`, which comes from the parent #682 (`8b39cd19`) and is not in this diff. This branch's own files merge cleanly. It will be restacked onto #682's next head.
- Lane 2's #634 (`fix/610-pinned-text`, `8f5716ae`): `AGENTS-shape.md` auto-merges. The one conflict is in `templates/family-root/scripts/validate-family.py`, which this diff does not touch. `8b39cd19` (#648) has the same conflict with #634.
- Lane 4's `fix/690-siblings-inputs` (`38a40abf`): clean.
- Lane 5's `fix/646-add-member-variant-remedy` (`2104a3d0`): clean.
- No other `fix/6*`, `fix/7*` or `l3-*` branch on origin touches this diff's files.

The reviewer found no MUST. These are the three SHOULDs and two NITs, all in `templates/assembly-root/scripts/bootstrap.py` and `tests/test_bootstrap_leg_moves.py`:

- **S1:** a cherry-pick or a revert of several commits whose stop was committed by hand.
  - The head file is gone then, and only `sequencer/todo` is left, but `git status` still says the operation is in progress.
  - Bootstrap moved the leg onto `main`, and the `--continue` git names then committed the next pick there.
  - It is now refused as in progress. `sequencer/todo` is asked after the `REVERT_HEAD` row, and its first word picks the label: `revert` is a revert, anything else is a cherry-pick.
- **S2:** the missing-branch path now prints the line naming the branch it left, under `created`, as P9 and T7 do.
- **S3:** `create_at_pin` now judges `checkout -b` by where HEAD is afterwards, as `_check_out` does (#533).
  - Under a post-checkout hook that fails, it says `created` and then git's words.
  - It used to say `could not create ...` and `nothing else is tried` over a leg that was on `main` at the pin.
- **Found while doing S3:** `_head_of` read `symbolic-ref` decoded as strict UTF-8.
  - A branch whose name is not UTF-8 (`--branch` a name holding U+DCFF, the byte 0xff) then raised `UnicodeDecodeError`.
  - The S3 check made the first run end in that traceback. `tests/test_bootstrap_branch_bytes.py::test_a_name_that_is_not_utf8_is_still_carried_and_the_branch_made` failed on the fix round's first try.
  - On base, a second run with that name already ended the same way, in `place_at_pin` (measured).
  - `_head_of` now reads the ref as bytes and decodes it with `os.fsdecode`, the same way the name was decoded when it was given.
- **N1:** docstring (d) now says the exit is 2 where step (a) or the upstream notice cannot start git.
- **N2:** the composite assert in the rebase helper is now two asserts.

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

**Runs on `a1f2433f` (the commit's tree):**

| Files | Result |
|---|---|
| the two new files | `46 passed` |
| `test_bootstrap_after_bump.py`, `test_bootstrap_spelling.py`, `test_bootstrap_branch_bytes.py` | `308 passed` |
| `test_family_bootstrap_gate_order.py`, `test_template_text_placeholders.py`, `test_scaffold_e2e.py`, `test_cli_reference.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_stdout_encoding.py` | `397 passed, 1 skipped` (Windows-only) |

**Mutants:** one per new hunk, 5 of 5 caught, each on an assertion:

| Mutant | Test that fails |
|---|---|
| the sequencer check removed | the sequence test, cherry-pick case |
| every sequence called a cherry-pick | the sequence test, revert case |
| `created`'s left line removed | the ignored-file test, missing branch |
| `checkout -b` judged by its exit code again | the failing-hook test, from detached |
| `_head_of` decoding as before | the non-UTF-8 test |

**Merge checks at `a1f2433f`:**
- origin/main `20c8873b`: the only conflict is `templates/family-root/scripts/validate-family.py`, which comes from the parent #682. `AGENTS-shape.md`, which #634 changed on main, merges cleanly.
- `fix/648-bootstrap-and-gate-order` (`8b39cd19`), `fix/690-siblings-inputs` (`38a40abf`) and `fix/646-add-member-variant-remedy` (`a837a023`): clean.
- Not restacked; it waits for #682's fix round.

- **The merge:** `7bdfa3cc` merges #682's fix round `45c512eb` (`fix/648-bootstrap-and-gate-order`, which carries main `2fac9790`) into this branch. There is no rebase and no force.
- **One conflict,** in `templates/family-root/scripts/bootstrap.py`. Both sides inserted a function just above `def gitmodules_paths`: this branch inserted `git_not_started` (#694), and #682 inserted `cures` and renamed the function to `gitmodules_sections`.
- **The resolution** keeps `git_not_started`, then #682's `cures` and its `gitmodules_sections` header and docstring, and drops the old `gitmodules_paths` header.
  - It is byte for byte rv704's modelled resolution, `family-bootstrap-resolved.py`.
  - The merged tree differs from rv704's model `e1322312` only in `tests/test_doctor_legs_members_next.py`: the one commit #682 added after the model.
  - It differs from `45c512eb` only in this branch's six files.
- **Tests on `7bdfa3cc`:** the six bootstrap files (`test_bootstrap_after_bump`, `_branch_bytes`, `_leg_moves`, `_spelling`, `_without_git` and `test_family_bootstrap_gate_order`) give `377 passed`. rv704 had 370 on `a1f2433f`; the other 7 are #682's fix round's own.
- **Merge check against today's main:** origin/main `3ceabef0` merges cleanly (`merge-tree` exit 0, tree `a6ff00b8`).

`f4fa6025` merges #682's second fix round `23e11056` (`fix/648-bootstrap-and-gate-order`, one commit on `45c512eb`) into this branch, with no rebase and no force. It touches only `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_gate_order.py`, and `git merge` took it with no conflict; `import re` is still in the gate-order test. The six bootstrap files give `387 passed` (`377` at `7bdfa3cc`; the other 10 are the fix round's own tests). The new head merges clean with `origin/main` `2a33a569` (`merge-tree` exit 0, tree `57117de4`).

`f4935e11` merges #682's third fix round `f74f640c` (`fix/648-bootstrap-and-gate-order`, one commit on `23e11056`) into this branch, with no rebase and no force. It touches only `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_gate_order.py`, and `git merge` took it with no conflict; `import re` is still in the gate-order test. The six bootstrap files give `388 passed` (`387` at `f4fa6025`; the one more is the fix round's own test). The new head merges clean with `origin/main` `e3137010` (`merge-tree` exit 0, tree `76165949`).

- rv704: CHANGES NEEDED at `4e2b211b`, with no MUST; the SHOULDs and NITs were taken in the fix round.
- rv704: APPROVE WITH NITS at `a1f2433f`. Its nits were the two body statements above, now fixed.

- **#458's line 6, the case-sensitive branch compare:** deferred, as #704 says (L4-79).
- **The oldest git:** `--no-overwrite-ignore` was measured only on git 2.43.0, the only git on this machine. The scripts name no floor; they only take care with git before 2.27.
- **The Makefile help (`templates/assembly-root/Makefile:34-35`), the holder's `AGENTS-shape.md:63-65` and its `README.md:17`** still say "where that loses no commit, or says why not". R2 ruled only on the assembly root's sentence, and "or says why not" keeps them true.

These are from rv704's review (its C1 to C4), not taken here as the coordinator ruled. The missing-branch path's exit-code judgement, which an earlier draft listed, is fixed in the fix round (S3).

- **C1:** a `SHAPE_UPSTREAM_PATH` directory nobody may enter ends in a `PermissionError` traceback, exit 1, on head and on base. The notice's docstring says it "never fails the bootstrap".
- **C2:** a validator's exit-2 refusal is reported as `bootstrap FAILED: pins (exit 2)`, exit 1 (measured with no git, in a plain clone).
- **C3:** `scripts/render-cli-reference.py:182-184`, which generates `docs/cli.md:47`, still says bootstrap "places each leg on its tracking branch AT the pinned commit", with no condition.
- **C4:** a reftable leg (git 2.45+, `--ref-format=reftable`) keeps `CHERRY_PICK_HEAD` and `REVERT_HEAD` in its ref store, not as files, so the in-progress check would not see them. Reasoned only: git 2.43 has no reftable.

Review: rv704, lane 5's independent review, approved with nits at a1f2433 after its fix round; the branch was then restacked by merges onto each of #682's fix rounds (f4935e1). CI (Brett Heap's ruling 27): H0 f4935e1 passed tests, tests-git-bash and SonarCloud; the head 1945154 merges #682's final head and then main after #682 landed as dc7d3fb, taking the branch's own side for #682's files, with tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 3b3e892 over main 98f1497: the bootstrap_after_bump, bootstrap_leg_moves, bootstrap_without_git, bump_leg_paste_lines, cli_reference, clone_url_bytes, family_add_member_variant, family_add_spellings_remedies, family_members_case_variants, install_sh, local_remote_dir_urls, no_auto_maintenance, openreposhape_command, readme_windows_quoting_sentence, shape_doctor, validator_child_encoding, repo_hygiene, agents_md_codes, cli_reference sets, 1186 passed, 3 skipped, 1 xfailed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #704
Closes #694

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 17, 2026
…README § Families marker takes the merged section's fingerprint, so #738 no longer conflicts with #682 on that one line (#695)

#682 (dc7d3fb) and this branch each changed README.md § Families, so each
set a different `reads README.md § Families` marker on the handbook's
`families` section: main's `b3d19092...`, this branch's `0364c579...`.
Git saw one line changed two ways in docs/handbook.html and stopped.

The merged README § Families is neither text, so the marker takes the
`readme_fingerprint` of the merged section, `82d7f808...`, computed with
`tests/test_repo_hygiene.py`'s own function over the merged README.md.
Lane 2's note on #738 gave the same value.

Nothing else was resolved by hand. README.md, AGENTS.md and the rest of
docs/handbook.html merged clean, and carry exactly each side's own changes.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…s to commit it, `member-gitlink-absent` puts the gitlink back by hand, the variant `mv` is guarded, and a pin row with no `path:` is `shape-pin-row` (#681) (#747)

Every remedy the holder's `validate-family.py` prints for #681's five lines now types as printed in the state it names. A shape-pin row with no `path:` is refused by name in both validators.

**Stacked on #682** (`fix/648-bootstrap-and-gate-order`, head `45c512eb`). The diff is `d91fd15e`, the fix round `dbca1130`, and `031ba75e`, the merge of #682's head `45c512eb`. Retarget to `main` once #682 lands.

**Restacked onto #682's head `45c512eb`** by the merge `031ba75e`, with no rebase and no force. #682 had merged main `2fac9790`, which carries lane 2's #634 (`30676c90`), so the conflicts with #634 are resolved there and here. The new head merges clean with `origin/main` `e634d299`. This branch does not merge main itself.

**Restack 2:** the merge `03e0b5a8` takes #682's second fix round `23e11056` (`fix/648-bootstrap-and-gate-order`, one commit on `45c512eb`), with no rebase and no force. It touches only `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_gate_order.py`; `git merge` took it with no conflict and the gate-order test still has `import re`. The six files named for this PR (the typed-remedies, pin-row-path, lost-`.gitmodules`, branch-step and pinned-copy-text tests, and the gate-order test) give `205 passed`. The new head merges clean with `origin/main` `2a33a569` (`merge-tree` exit 0, tree `a62ac94b`).

**Restack 3:** the merge `58d6c50f` takes #682's third fix round `f74f640c` (`fix/648-bootstrap-and-gate-order`, one commit on `23e11056`), with no rebase and no force. It touches only `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_gate_order.py`; `git merge` took it with no conflict and the gate-order test still has `import re`. The same six files give `206 passed` (`205` at `03e0b5a8`; the one more is the fix round's own test). The new head merges clean with `origin/main` `e3137010` (`merge-tree` exit 0, tree `cace7c06`).

**The L2-38 pick is the issue's recommended direction, words only.** Brett Heap took it at 2026-10-16T14:11:42Z ("take all recommendations for #670 and #681"). There is no `family.py` change.

- `templates/family-root/scripts/validate-family.py`: every holder takes one `upstream-changed` row.
- `templates/assembly-root/scripts/validate-pins.py`: every assembly root takes one `upstream-changed` row (the L2-3 check only).

**L20, a removal staged and not committed.** The gate lists the gitlinks HEAD records as well as the index's. So after the printed `git rm -- members/IRQS`, before the commit, it named the same gitlink and the same `git rm`. Typed again, that `git rm` dies `fatal: pathspec 'members/IRQS' did not match any files`, exit 128. A second path's `git rm --cached -- Members/IRRS` did the same.
- The listing is kept. A new `_staged_removals` names the gitlinks HEAD records where the index holds no entry at that path, at any stage.
- For those, `member-gitlink-unnamed` says the removal is staged and to commit it, and prints no `git rm`. A hand mount keeps its branch and `add` lines. A second path says only "Commit that removal."
- A `.gitmodules` section left by `git rm --cached` now prints its own `--unset-all` lines. Before, it said "the git rm printed above takes this section out too", and no `git rm` is printed there any more.
- No misfire: a path the index holds unmerged is a conflict, not a removal. It keeps its `git rm`, which takes an unmerged path out (tested).

**L2-38, the `member-gitlink-absent` remedy.** Take a row kept after `git rm` took its gitlink and section out. Its remedy named `git switch -c` and `family.py add`, but `add` refuses the row (`member-already-present`). `family.py remove` dies at `git submodule deinit` (`pathspec 'members/IRRS' did not match any file(s) known to git`, exit 2) and removes nothing. The remedy now prints, in words and `git` lines:
- `git update-index --add --cacheinfo 160000,<pin>,members/<P>`, which puts the gitlink back at the commit the row pins. It changes the index only, as #639's case-variant move does.
- The section lines `member-gitmodules-absent` prints, when HEAD's `.gitmodules` gives no section the path. The line builder `_section_lines` is now shared by both.
- Then "commit that", and `git submodule update --init --recursive` (or `make bootstrap`) checks the member out.
- If the member is to go: take its row out of `family.yaml` by hand and commit that.

No branch is needed, so this remedy no longer prints `switch -c`.

**N-1 and N-2, the variant move's `mv`.** The text gave a bare `mv -- Members/<P> ../Members-<P>`, and stated when to type it by system ("On a case-sensitive disk, as Linux's usually is … On macOS and Windows … leave it"). Each `mv` is now:

    [ Members/<P> -ef members/<P> ] || [ -e Members/<P>/.git ] || mv -- Members/<P> ../Members-<P>

- **N-2:** the `mv` does not run where the two names are one directory, such as a case-folding Linux mount like WSL's `/mnt/c`. There the bare `mv` moved the member's own directory out.
- **N-1:** it does not run where the directory holds a `.git` entry. For a live submodule checkout that entry is a file, `gitdir: ../../.git/modules/Members/<P>`, and a move left it saying `fatal: not a git repository`.
- For a submodule checkout, the text says its commits are kept under this holder's `.git/modules/`, in the directory its `.git` file names, whatever happens to the checkout. Only what is not committed is in the checkout alone. `git -C Members/<P> status` shows it, except where a plain `mv` brought the checkout there, which git cannot open. It says to delete such a checkout by hand once nothing only it holds is needed.
- For a `.git` directory, a clone of its own, the text says its commits are there and nowhere else. `git -C Members/<P> log --oneline HEAD --branches --not --remotes` lists the ones not pushed, and the text says to move the clone out whole, by hand, rather than delete it.

**L2-3, a `files:` row with no `path:`.** Both validators read `str(row.get("path"))`. So `- nopath: x` gave `FINDING shape-copy-missing: contracts/shape-pin.yaml: None is pinned but absent`, and `path: ''` and `path: 5` did likewise. A row whose `path:` is absent, empty or not a string now gives:

    FINDING shape-pin-row: contracts/shape-pin.yaml: a files row's `path:` is None, not the path of a file

That is the code each validator's non-mapping branch already says, and the row `update-shape.py` refuses as `update-pin-row`. `shape-pin-row` is not a new code.

- **The N-1 guard is `-e` (any `.git` entry), as the issue's direction says.** A standalone clone (a `.git` directory) is not moved either. Since the fix round (rv681's S2, Direction B), the text tells a clone apart from a submodule checkout: a clone is moved out whole by hand, and a submodule checkout is deleted by hand. `test_family_head_lost_gitmodules.py`'s #611 move test now uses a plain directory, which is moved. All three checkout kinds (submodule, clone, plain `mv`) are typed in the new test file.
- **Both guards are kept, and each is tested by behavior.** On a case-folding disk, the row's own directory is a checkout in a working holder, so the `.git` test alone would keep it there. The `-ef` test is what keeps it where the directory is empty, as a fresh clone leaves it before `git submodule update`. A test layout covers exactly that case (see the mutation probes).
- **"Staged removal" means the index holds no entry at the path at any stage**, not only "no gitlink at stage 0". An unmerged gitlink is a conflict, and committing it fails, so it keeps `git rm`.
- **No def line changed in a function that may already be over Sonar's cognitive-complexity limit.** `_staged_removals` is its own function, not a second return value of `_member_gitlinks`; it costs one more `git ls-files -z` per run. The branch bodies moved into `_second_path_out` and `_mounted_by_hand`.

The same throwaway probe ran on base `8b39cd19` and on head `d91fd15e`. The case-folding disk is **emulated**, with a `Members` symlink to `members`; no case-folding mount was used.

| Line | Base `8b39cd19` | Head `d91fd15e` |
|---|---|---|
| L20, hand mount | after the printed `git rm`, the re-run prints the same `git rm`; typed again it exits 128, `did not match any files` | the re-run says the removal is staged and prints only `switch -c` and `add` |
| L20, second path | the re-run prints `git rm --cached -- Members/IRRS` again, which exits 128 | the re-run says to commit the removal and prints no command |
| L2-38 | the printed `add` is refused `member-already-present`; `remove` exits 2 at `submodule deinit` | the printed `update-index` and section lines, `git add .gitmodules`, a commit and `git submodule update --init --recursive` all exit 0; the gate exits 0 in every mode |
| L2-38, the row taken out by hand | exit 0 | exit 0, unchanged |
| L2-3, holder and assembly root | `FINDING shape-copy-missing: … None is pinned but absent`, and the same for `''` and `5` | `FINDING shape-pin-row: … is None` / `''` / `5`, `not the path of a file` |
| N-1, live submodule checkout at `Members/IRRS` | after the printed `mv`, `git -C ../Members-IRRS status` says `fatal: not a git repository` | the guarded line moves nothing; `git -C Members/IRRS status` exits 0, and its local commit is in `.git/modules/Members/IRRS` |
| N-2, one directory (emulated) | the bare `mv` moves `members/IRRS` out of the holder | `[ Members/IRRS -ef members/IRRS ]` exits 0, and the guarded line moves nothing |
| N-2, plain case-sensitive layout | the bare `mv` moves `Members/IRRS` out | `-ef` exits 1, the guarded line moves `Members/IRRS` out, and the gate exits 0 |

New files:
- `tests/test_validate_family_remedies_typed.py` (15 tests in the first round, 18 after the fix round): L20, L2-38 (including a fresh clone), N-1 (a submodule checkout, a plain clone, and since the fix round a checkout moved there by plain `mv`), N-2 (one directory, one empty directory, apart). Every remedy is typed as printed, and then the gate is run.
- `tests/test_shape_pin_row_path.py` (10 tests): L2-3, five rows in each validator.

Updated to the new words: `tests/test_family_head_lost_gitmodules.py`, `tests/test_family_bootstrap_gate_order.py`, `tests/test_validate_family_branch_step.py`, `tests/test_family_switch_branch_exists.py`, `tests/test_family.py`.

Runs:
- **Targeted set, 83 files, on `55bd67d9`** (every `test_validate_family*.py` and `test_validate_pins*.py`, `test_family_bootstrap_gate_order.py`, `test_template_text_placeholders.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, and every test file naming either validator), in two batches:
  - batch A, 42 files: `2359 passed, 1 skipped in 2540.75s (0:42:20)`;
  - batch B, 41 files: `2879 passed, 23 skipped in 3364.87s (0:56:04)`.
  - The skips need a Windows host.
- **`d91fd15e`** differs from `55bd67d9` only in `tests/test_validate_family_remedies_typed.py` (the empty-directory layout). The two new files on it: `25 passed in 33.44s`.
- **Fail on base:** head's changed tests over `8b39cd19`'s code. With `55bd67d9`'s tests: `30 failed, 3 passed in 47.76s`. The N-2 test's added layout, with `d91fd15e`'s tests: all 3 layouts fail, `3 failed in 7.64s`. The 3 that pass do so by design:
  - the unmerged-gitlink no-misfire test;
  - the row taken out by hand;
  - the BRANCH_LEFT guard test, since the sentence already ended every `switch -c` remedy.

| Mutant | Killed by |
|---|---|
| M1, `_staged_removals` returns nothing | 5 failed: both hand-mount L20 tests, the second-path test (2 disks), the section test |
| M2, the section ignores a staged removal | 1 failed: `test_a_section_git_rm_cached_leaves_keeps_its_own_commands` |
| M3, only stage-0 entries count as indexed | 1 failed: `test_an_unmerged_gitlink_keeps_its_git_rm` |
| M4, the old `add` remedy back | 3 failed: both put-back tests and the fresh-clone test |
| M5, section lines printed even when HEAD gives the section | 1 failed: `…put_back_as_printed[git-rm-cached]` |
| M6, no `-ef` test | 5 failed (the refusal's text pin) |
| M7, no `.git` test | 5 failed (the refusal's text pin) |
| M8, no `path:` check in `validate-family.py` | 5 failed: every holder row |
| M9, no `path:` check in `validate-pins.py` | 5 failed: every assembly-root row |
| M10, an empty `path:` allowed | 1 failed: `[empty-family-holder]` |

M6 and M7 again, with the refusal's text pin switched off in the clone's test helper, so that only behavior can kill them:
- **M6:** 1 failed, `[one-empty-directory]`. The `-ef` test is what keeps an empty row directory in place.
- **M7:** 2 failed, both checkout-at-the-variant tests. The `.git` test is what keeps a checkout in place.
- **Control, the pin switched off and nothing else:** 5 passed.

- **`origin/main` `e634d299` (and `3ceabef0`): clean.**
- **#682's head `45c512eb`:** an ancestor of this head.
- **Clean:** the 30 other open `fix/6*`, `fix/7*` and `l3-*` branches on origin: `fix/645`, `fix/646` (`a837a023`), `fix/652`, `fix/654`, `fix/661`, `fix/662`, `fix/663`, `fix/664`, `fix/665`, `fix/666`, `fix/667`, `fix/672`, `fix/674`, `fix/684`, `fix/686`, `fix/688`, `fix/689`, `fix/690` (`38a40abf`), `fix/692`, `fix/693`, `fix/696`, `fix/704` (`7bdfa3cc`), `l3-563-docs`, `l3-677`, `l3-678`, `l3-680`, `l3-687`, `l3-691`, `l3-697` and `l3-rehearsal-v1.1.0`.
- **Two conflicts, neither in a file this branch changes:**
  - `fix/695-family-docs` (`c42286de`), in `docs/handbook.html`. It is clean with main and conflicts with #682's head `45c512eb` the same way.
  - `l3-683-partial-clone` (`9f24bf31`), in `shape-doctor.py`. It conflicts with `origin/main` the same way.

This is one new commit on `d91fd15e`, not an amend. Only text changes in `validate-family.py`: the guards are the same, and `validate-pins.py` is not touched.

- **S1.** A hand mount taken out with `git rm --cached` leaves its checkout at the path. Git itself suggests `--cached` when the printed `git rm` refuses a checkout with changes. The printed `family.py add` then refused that directory (`member-path-taken`).
  - The staged-removal text now says: "Where git rm --cached took it out, its checkout is still at <path>, and family.py add refuses that directory (member-path-taken): delete it by hand, once nothing only it holds is needed, before the add."
  - The L20 hand-mount test gains a `git rm --cached` case. It is typed through the section's printed lines and the commit; then `add` is refused `member-path-taken`; the checkout is deleted by hand; `add` is refused `member-git-dir-cached`; the store is deleted; `add` exits 0, and the gate exits 0 in every mode.
- **S2, Direction B.** The `-e` guard is kept, as the issue's "no .git entry", which Brett Heap's word took. For a `.git` directory, the text now says its commits are there and nowhere else, names the `git log` above, and says to move the clone out whole, not delete it.
  - The `[clone]` case asserts that the line keeps the clone and that the printed `git log` lists exactly its own commit. It then moves the clone out whole and reads the commit there.
  - Direction A (`-f`) is not taken. The coordinator is asking Brett.
- **N1.** The store is "under this holder's .git/modules/, in the directory its .git file names". It is not always `.git/modules/Members/<P>`: a checkout a plain `mv` brought to the variant names `.git/modules/members/<P>`. The status check now says git cannot open such a checkout.
  - A new `[moved]` kind types this state: the commit is in the store the `.git` file names, `git -C Members/IRRS status` fails, the checkout is deleted by hand, and `git submodule update --init --recursive` checks the member out again.
- **N2.** The guarded line's sentence says "in a POSIX shell", as the section lines do.
- **N3.** A new test writes the section back in the work tree's `.gitmodules` alone. The section lines still print, because HEAD's `.gitmodules` is what a clone gets. Typed, staged and committed, they give a gate that exits 0.

Runs on the fix round:

| Run | Result |
|---|---|
| rv681's four files plus the new tests (`test_validate_family_remedies_typed.py`, `test_shape_pin_row_path.py`, `test_family_head_lost_gitmodules.py`, `test_validate_family_branch_step.py`) | `80 passed in 86.95s` (rv681 had 77 on `d91fd15e`) |
| every other file importing the changed helpers (`test_family_bootstrap_gate_order.py`, `test_family_remove_gitmodules.py`, `test_family_switch_branch_exists.py`, `test_validate_family_member_path_glob.py`, `test_validate_family_unnamed_mounts.py`) | `137 passed in 128.12s` |
| `test_template_text_placeholders.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py` | `266 passed in 50.50s` |
| MO1 (`_gitlink_put_back` reads the work tree's `.gitmodules`), rv681's patch | killed: `1 failed, 94 passed`, `test_a_section_back_in_the_work_tree_alone_still_prints_its_lines` (it survived with 92 passed on `d91fd15e`) |
| M5 (section lines printed even when HEAD gives the section), rv681's patch | still killed: `1 failed, 17 passed`, `[git-rm-cached]` |

Not re-run in this round, to save usage:
- MO2 (`-e` changed to `-f`). By reading: it now moves the clone in the `[clone]` case, which asserts the clone is kept.
- The 83-file targeted set, the fail-on-base run and the other mutants.

The texts this round changes are pinned only in `test_family_head_lost_gitmodules.py` and `test_validate_family_remedies_typed.py` (`git grep` of each changed phrase in `tests/`).

**The restack merge (`031ba75e`).** Two files conflicted; every other file merged clean.
- `validate-family.py`, 3 regions:
  - WHAT IS CHECKED keeps #682's narrower wrong-case sentence, then this branch's staged-removal sentence.
  - The signature is `_check_member_gitlink(root, path, commit, project, repository, gitlinks, report, rule)`.
  - `_check_member` passes `row.get("repository"), gitlinks, report, rule` and returns `Gitlinked(row, project, path, pin, commit, rule)`.
- `tests/test_family_bootstrap_gate_order.py`, 2 regions:
  - the docstring keeps both sentences;
  - the import from `test_family_head_lost_gitmodules` is the union of both sides.
  - `import re` is put back by hand. The merge had dropped it with no conflict marker, and the file calls `re.compile` and `re.findall` (rv681's note). `import subprocess` stays out, since neither side uses it.
- The gate-order test is rv681's model (`_lane5-scratch/rv681-restack2`) byte for byte. `validate-family.py` is that model with this branch's fix round `d91fd15e..dbca113` applied by `git merge-file`, byte for byte.
- Runs on `031ba75e`:
  - the four files plus `test_pinned_copy_text.py`: 172 passed in 65.19s (`$Q/v112/w681/rs/four.log`);
  - the gate-order test and the rest of the family set, plus `test_validate_pins_lockstep_lines.py`: 159 passed in 104.07s (`$Q/v112/w681/rs/more.log`);
  - hygiene, the placeholder test and the AGENTS.md codes test: 266 passed in 38.36s (`$Q/v112/w681/rs/hyg.log`).

- `family.py`'s own `family-members-dir-variant` and `member-path-taken` move remedy (`mv -- Members/<P> <new name>`) has N-1's flaw for a live submodule checkout. Fixing it is a `family.py` change, so it is a checklist candidate below.
- `AGENTS.md`'s "Never hand-edit `family.yaml`'s `members:` block" is not changed. The validator already tells people to put a row back by hand in a repair state (`RESTORE_ROW`), and now `member-gitlink-absent` tells them to take one out by hand. Whether to reconcile the sentence is a checklist candidate.

1. **`family.py`'s case-variant move remedy breaks a live submodule checkout.** Its `family-members-dir-variant` and `member-path-taken` refusals print `mv -- Members/<P> <new name>` for any directory, a live submodule checkout included. That checkout's `.git` file names `.git/modules/` by a path relative to it, which the move leaves pointing nowhere. The same guard, or the same "delete it by hand" words, would fix it.
2. **A member's own staged un-registration passes the gate.** After `git rm --cached members/IRRS`, before the commit, the gate reads the gitlink from HEAD and says ok. `member-gitlink-absent` only fires after the commit, so CI is the first to see it.
3. **`AGENTS.md` and the validator disagree on hand edits.** `AGENTS.md` says never to hand-edit `family.yaml`'s `members:` block. The validator's repair remedies tell people to do so (`RESTORE_ROW`, and now `member-gitlink-absent`). One of the two should change.
4. **`member-commit-unresolvable` names no remedy** (rv681's candidate 3). Take a pin no remote holds: `git submodule update` exits 128 (`not our ref`), the gate refuses `member-commit-unresolvable` with no remedy line, and `--no-members` exits 0 over it.
5. **The v1.0.0 notes list L20 as a known limit** (rv681's candidate 4). `docs/release-notes/v1.0.0.md:466` lists it. The v1.1.0 notes (lane 3's #630) should say #681 fixes it.

**Review.** rv681 re-verified the fix round: APPROVE WITH NITS at `dbca1130`, the nits being three body lines, now fixed. Its P1 probe now ends with `add` exiting 0 when typed as printed, MO1 is killed, and its four files give 80 passed. MO2 (`-f` for `-e`) fails only the `[clone]` case, so that test alone holds the `-e` choice (Direction B); Direction A stays Brett's call.

Review: rv681, lane 5's independent review, approved with nits at dbca113; the branch was then restacked by merges onto #682's fix rounds (58d6c50), with `import re` put back in tests/test_family_bootstrap_gate_order.py. CI (Brett Heap's ruling 27): H0 58d6c50 passed tests, tests-git-bash and SonarCloud; the head ff7c59b merges #682's final head and then main after #682 landed as dc7d3fb, taking the branch's own side for #682's files, with tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 994e716 over main 40e678e: the bootstrap_after_bump, bootstrap_leg_moves, bootstrap_without_git, bump_leg_paste_lines, cli_reference, clone_url_bytes, family, family_add_member_variant, family_add_spellings_remedies, family_bootstrap_gate_order, family_head_lost_gitmodules, family_members_case_variants, family_switch_branch_exists, install_sh, local_remote_dir_urls, no_auto_maintenance, openreposhape_command, readme_windows_quoting_sentence, shape_doctor, shape_pin_row_path, siblings_inputs, validate_family_branch_step, validate_family_remedies_typed, validator_child_encoding, repo_hygiene, agents_md_codes, cli_reference sets, 1495 passed, 3 skipped, 1 xfailed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #681

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 17, 2026
…efusals and `family-operation-in-progress` name a path as `shown_source` names it, so an ESC or a line break in a path no longer reaches the terminal (#692) (#731)

#622's folder refusals printed a path as it was. `family.py init --into <a file named wd-ESC[31mRED> --dry-run` gave `REFUSED family-folder-cannot-be-made: the family folder .../wd-^[[31mRED/Orbit cannot be made: ...` through `cat -v`. A name with a line break and then `WARNING forged-line: pwned` printed that line as a line of its own. R612's `family-operation-in-progress` printed the mark's path across two lines for a holder whose path holds a newline.

Each path is now spelled as `shape_materialize.shown_source` spells a `--pin-source`: as it is, and as Python's `repr` spells it where a character in it is not printable.

- `scripts/shape_materialize.py`: both answers of `folder_cannot_be_made` (a file or a bad stat in the way, and a folder this user cannot write in), the folder in `refuse_unmakeable_folder`, and the folder in `make_folder`.
- `scaffold-project.py`: `scaffold-target-exists`.
- `scripts/family.py`: `family-target-exists`, and the mark's path in `family-operation-in-progress` (`where.as_posix()`).

A printable path is named byte for byte, so no existing row changes and `why.endswith(CANNOT_WRITE_IN)` in `refuse_unmakeable_folder` still holds. No help text changed, so `docs/cli.md` is untouched. No README, handbook or template sentence says how a refusal names a path (`git grep` of `shown_source`, `target-exists` and `folder-cannot-be-made` in AGENTS.md, README.md, `docs/handbook.html` and the templates found none), so no doc changes.

- **`shown_source`, not `shown_path`.** Since #634, `shape_materialize.shown_source` returns `repo_shape.shown_path(source)` (`scripts/shape_materialize.py`: `return shown_path(source)`), so the seven call sites this change adds (four in `shape_materialize.py`, one in `scaffold-project.py` and two in `family.py`) spell a path as `shown_path` does. Renaming them to `shown_path` stays out of this PR: it would only move imports in `scaffold-project.py` and `family.py` and reword docstrings in `family.py`, `shape_materialize.py` and the test file, and the review's N1 left it optional. I did not take it. The predicted merge with main works with the alias: `family.py --help` and `scaffold-project.py --help` exit 0 on it, and `shown_source` and `shown_path` return the same string for an ESC name, a line-break name and a plain path.
- **`shown_source` is imported by name into each file's existing `from shape_materialize import (...)` block.** The first commit put it on a line of its own after the block; lane 4's #660, #688 and #693 add an import at that same place, and `merge-tree` reported add/add conflicts in `scripts/family.py` and `scaffold-project.py`. The second commit moves the name into the block, and those conflicts are gone.
- **"The holder in the other refusals of the same block" is one refusal.** The `# What the holder is in the middle of, and what its sparse checkout leaves out` block holds `family-operation-in-progress`, `family-sparse-excluded` and `member-path-unaddable`. Only the first prints a path of the holder's. `family-sparse-excluded` names `members/<Project>` and `.gitmodules`, and `member-path-unaddable` names `members/<Project>`.
- **No scaffold row for `--local-remote-dir`.** A control character there is refused earlier as `scaffold-unwritable-value`, which already names the path with `repr`. The unit test covers `make_folder`, which only a race reaches by the command line.

`tests/test_dry_run_parity.py`:
- `SCAFFOLD_CASES` gains five rows: ESC and line break in the `--work-dir` name, ESC in a folder name above it, and ESC and line break in a work dir that holds a file named `Atlas` (`scaffold-target-exists`).
- `FAMILY_CASES` gains five rows: ESC and line break in `--work-dir`, ESC in `--into` (`the family folder`), and ESC and line break in a work dir that holds a file named `Orbit` (`family-target-exists`).
- Two rows join `UNWRITABLE_FOLDER_AT` (ESC in the scaffold's and in `init`'s `--work-dir`, a folder this user cannot write in).
- Rows named `ESC-...` and `line-break-...` are skipped on Windows, whose file names cannot hold them (`POSIX_NAMES`, `case_params`). Every expectation spells the path with `repr`, and not with `shown_source`.
- `assert_refused` now fails any stderr line that starts with `WARNING forged-line`.
- Three unit tests: `test_each_folder_question_spells_a_name_that_is_not_printable` (ESC, line break; `folder_cannot_be_made`, `refuse_unmakeable_folder` and `make_folder` by themselves), `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`, and `test_a_printable_name_is_named_byte_for_byte` (a name with a space and an accented letter stays unquoted).

`tests/test_family_refuse_up_front.py`: `test_a_holder_path_with_a_newline_still_shows_its_merge` asserted the raw newline in the refusal (`"Ink\nRouter/.git/worktrees/" in ran.stderr`). It is now `test_a_holder_path_with_a_control_character_still_shows_its_merge`, over a line break, an ESC and a forged line, and asserts the one-line `repr` form (`refusal()` fails unless the stderr is two lines). It is the one existing test this change had to edit.

Results at head `ab55c29f` (Python 3.12.3, Linux):
- `tests/test_dry_run_parity.py tests/test_dry_run_asks_remotes.py tests/test_family_dry_run.py tests/test_family_init_landing.py tests/test_repo_hygiene.py tests/test_pin_source_unreadable.py tests/test_scaffold_leg_path_collisions.py tests/test_scaffold_reuse_seeded_legs.py`: **494 passed**.
- `tests/test_family.py tests/test_family_refuse_up_front.py tests/test_family_init_values.py tests/test_family_add_refusals.py`: **356 passed, 19 skipped** (the skips are `test_family_init_values.py:219` and `:291`, template-quoting premises).
- Not run: the full suite (the coordinator's gate), and macOS and Windows (CI is Linux-only since #597). The new hostile-name rows are POSIX only by design.

On the base code: the review overlaid this head's two test files on main `2fac9790` and ran `tests/test_dry_run_parity.py` and `tests/test_family_refuse_up_front.py` with `-k "dry_run_parity or control_character"`. The result is **20 failed, 87 passed** (76 deselected, 43.5 s). The 20 are exactly the new and edited tests: the 5 scaffold and 5 `init` rows, the 4 unwritable-folder rows, the 3 unit tests (`test_each_folder_question_spells_a_name_that_is_not_printable` in two cases, and `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`), and the 3 cases of the edited `test_a_holder_path_with_a_control_character_still_shows_its_merge`. The one new test that passes on main's code is `test_a_printable_name_is_named_byte_for_byte`, by design: a printable name is named byte for byte on both. An earlier draft of this body said 21 failed, 2 passed. That run was on `8062810d` with a wider selection, and it held a scaffold row for ESC in the local remote dir's name (`ESC-in-the-local-remote-dir-name`) that this head's tests no longer have; its second pass was the existing `test_the_refusal_quotes_gh_s_first_line_and_escapes_what_is_unprintable`, which also passes in the new run, one of the 87.

`family.py init` and `scaffold-project.py --dry-run` against a file named `wd-ESC[31mRED`, a file named `wd-\nWARNING forged-line: pwned`, and a work dir with a file `Atlas` or `Orbit` in it, run with a stub `gh` and no real `gh`:
- On main, through `cat -v`: `... the family folder /.../wd-^[[31mRED/Orbit cannot be made: /.../wd-^[[31mRED is a file, not a folder`, and a stderr line `WARNING forged-line: pwned cannot be made: ...` of its own.
- On this head: `REFUSED family-folder-cannot-be-made: the family folder '.../wd-\x1b[31mRED/Orbit' cannot be made: '.../wd-\x1b[31mRED' is a file, not a folder`, and one line for the forged name: `--work-dir '.../wd-\nWARNING forged-line: pwned' cannot be made: '...' is a file, not a folder`. `scaffold-target-exists` and `family-target-exists` are one line each in the same form.

Each spelled site reverted alone in a throwaway clone at `19d126cd` (the call sites are the same at `ab55c29f`); the new tests that fail for each:
- `folder_cannot_be_made`, the `_not_a_folder` answer: 8 failed (six CLI rows and the two unit tests).
- `folder_cannot_be_made`, the `CANNOT_WRITE_IN` answer: 5 failed (the four unwritable rows and the unit test).
- `refuse_unmakeable_folder`: 12 failed.
- `make_folder`: 2 failed (the unit test only).
- `scaffold-target-exists`: 2 failed (its two rows).
- `family-target-exists`: 2 failed (its two rows).
- `family-operation-in-progress`: 3 failed (line break, ESC, forged line).

`git merge-tree --write-tree` of this head `ab55c29f` against origin/main `20c8873b` and against every ref on origin that could meet it, fetched 2026-10-16: the 41 branches (`fix/*`, `l3-*`, `wip/*`, `docs/*` and `release/v1.0`) and the 55 PR heads from #630 up. That is 96 refs. (The same list was run for #730's head `7f2bb36b`.)
- **origin/main `20c8873b`: clean** (the predicted tree `00b5b24c`, which differs from main in these five files only: 216 lines added, 25 removed).
- **#630's head `6fd33a93`: clean.** It conflicted with main in `docs/handbook.html` when this body was first drafted, and no longer does, with or without this change. This change touches no README or handbook.
- **The refs that edit one of this change's five files**, and so could meet it, all merge clean with this head: `fix/646`, `fix/651`, `fix/654`, `fix/660`, `fix/661`, `fix/684`, `fix/686`, `fix/688`, `fix/689` and `fix/693`, the heads of #670, #725, #728, #743 and #744, and the heads of the landed #634, #638, #639, #640, #643 and #671. #634 has landed (`30676c90`), so the `fix/660` note about a conflict with it is gone.
- **Four refs conflict with origin/main on their own**, and so with this head: `docs/release-notes-v1.0.0` and `release/v1.0`, and the pre-squash heads of the landed #637 and #641. Their conflicts do not come from this change. Every other ref, 92 of the 96 in all, is clean with this head, including the refs that conflict with main in `templates/family-root/scripts/validate-family.py` (#682, #745, #747, `fix/648`, `fix/681`, `fix/704`) or in `shape-doctor.py` (#742, `l3-683`) on their own. So do the old heads of the landed #638 and #639, and lane 3's `l3-677-pins-hand-step-wip` branch, and each of them merges clean with this head too.
- **Tests on the predicted merge**, run by the review on the merge with main `2fac9790`: `test_dry_run_parity`, `test_family_refuse_up_front`, `test_pinned_copy_text`, `test_pin_source_unreadable`, `test_dry_run_asks_remotes`, `test_family_dry_run` and `test_family_init_landing`: 445 passed in 215 s. `test_family`, `test_family_init_values`, `test_family_add_refusals`, `test_derived_id_refusals`, `test_repo_hygiene`, `test_naming_policy`, `test_scaffold_leg_path_collisions` and `test_scaffold_reuse_seeded_legs`: 758 passed, 19 skipped in 243 s. I did not re-run them on `20c8873b`; the merge is clean, and the two commits since `2fac9790` (#700 and #702) change only `shape-doctor.py`, `templates/assembly-root/scripts/validate-pins.py` and four test files, none of them a file of this change.
- Before the second commit, `fix/660`, `fix/688` and `fix/693` each conflicted with this head in the import region of `scripts/family.py` or `scaffold-project.py` (a separate import line each side added at the same place). That is why the import joins the existing block.

#634 is on main, and this change does not edit `shown_source` or its docstring in `scripts/shape_materialize.py`.

The review approved with nits. Its S1, the stale base-run numbers and merge lines, is taken here, in this body: no code changed, and the head is still `ab55c29f`. Its N1, renaming the call sites to `shown_path`, is not taken (see "Decisions and their evidence"). It also re-ran 5 of the 7 mutation probes on `ab55c29f` and got the same failure counts as above (12, 2, 3, 2 and 2), and ran 144 hostile-name dry runs (16 names in 9 scenarios): on main 69 rows put a control character on stderr and 6 print a forged line of their own; on this head all 144 exit 2 with no traceback, no control character and no forged line.

- `scripts/family.py` `init`'s plan prints `folder` and `landing` lines with the path raw (`print(f"landing      {holder}   ...")`, `print(f"folder       {folder}   ...")`). On a dry run or a refused run the plan is on stdout before the refusal, so `WARNING forged-line: pwned/Orbit   (--work-dir: no family folder)` still appears there. It is the plan, not a refusal, and not one of the issue's lines.
- Other refusals that name a holder or folder path raw, none of them in the issue's lines: `family-holder-detached` and `family-on-tracking-branch` (`{family.root}`), `family-index-locked` (`lock.as_posix()` in the detail), `family-landing-in-standard` (`{holder}`, `{checkout}`, `{beside / family / family}`), `family-holder-already-here` (`{into}`), and `family-remote-exists` and `scaffold-remote-exists` (`{target}`, a local remote path).

1. **family.py `init`'s plan prints the holder and folder paths raw.** `scripts/family.py` `landing` and `folder` rows (about :1756-1759) print `holder` and `folder` as they are, so a forged line or an ESC reaches stdout in a `--dry-run` and in a run refused after the plan. Direction: spell them with `shown_source`.
2. **The same raw spelling in the refusals listed above.** `family-holder-detached`, `family-on-tracking-branch`, `family-index-locked`, `family-landing-in-standard`, `family-holder-already-here`, `family-remote-exists` and `scaffold-remote-exists` each print a path the person supplied or git keeps. Direction: the spelling this change gives its sites.

Review: rv692, lane 5's independent review, approved ab55c29 with nits, and its body fix (S1) is published. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 ab55c29 passed tests, tests-git-bash and SonarCloud; the head 13f643d is a clean update-branch merge of main 431c8cf, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 4327947 over main 431c8cf: the dry_run_parity, family_refuse_up_front, repo_hygiene, agents_md_codes, cli_reference sets, 330 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #692

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 17, 2026
…notes entries for the five pull requests landed since 2000488, current to main 40e678e (#563, #683)

RV630's review of rounds A3, A4 and B1 (#630 issuecomment-6233641684,
READY), its four optional nits:
- 1: README.md's drift paragraph ended on the one-word line "finished.".
  "exactly " and "about the shape " are cut, and the paragraph re-wraps
  to seven lines with both bold leads whole. README.md is 1474 lines, its
  cap (1422 + 2 + 5 + 12 + 22 + 10 + 1); tests/readme-cap.d/683.txt is
  +1, and its text says why. Both `Keeping a project's shape current`
  markers move to df780eb5; the other 21 read current.
- 2: #682's lead reads "A holder's `make bootstrap` that left a member
  unchecked-out".
- 3: #730's entry says "Comments and docstrings", not "Four": its squash
  makes three claims, comments only.
- 4: the doctor's `members` row has one bullet, #705's sentence and
  #624's together.

Wave B2 so far, `git log --first-parent 2000488..40e678e`, each entry
from its squash diff:
- #713 (#645): the test session turns git's automatic maintenance off
  (Install, CI and the docs).
- #812 (#693): `add --local-remote-dir`'s member clone URL, the remedies
  and the READMEs' quoted clone URL (the `--local-remote-dir` bullet).
- #767 (#685): `members/` renamed with `git mv` is renamed back in two
  steps, and the git a printed `--sparse` needs (family).
- #745 (#704, #694): lane 5's line (#630 issuecomment-6227129879), with
  v1.0.0's known limit at v1.0.0.md:463 (no git on `PATH`) said closed
  (Bootstrap).
- #805 (#690): `make siblings`'s inputs (family).
The counts (111 pull requests; 122 of 135 milestone issues closed at
16:14Z), "current to main" and the open list (13, each with a pull
request open) are at main 40e678e, still placeholders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…s and #817's entries, #642's upgrade note for a holder's `cd` line, and v1.0.0's `--into` limit kept open, current to main 64ec47c (#563)

RV630's pre-review of the B2 stage (lane 3's session):
- SHOULD-1: the CI bullet said every test that clones a just-pushed
  local remote clones it safely (#554), but five plain-path `git
  submodule update --init` runs are left on main
  (tests/test_doctor_legs_members_next.py:631, :987, :1054;
  tests/test_family_bump_remove_rollback.py:337, :908). It now says
  "runs `git clone` on", which is what #554 changed.
- NIT-1: #812's READMEs quote a local clone URL "that needs it".
- NIT-2: #682's first sentence no longer repeats the lead ("there:").

Landed since 40e678e, each entry checked against its squash:
- #747 (#681), 6626a3c, whose tree equals `merge-tree 40e678e
  ff7c59b`: a new family bullet, with RV630's NIT
  ("`member-gitlink-absent`'s remedy puts the gitlink back by hand");
  v1.0.0's limits at v1.0.0.md:466 and :467 are said closed.
- #817 (#708), 64ec47c, whose tree equals `merge-tree 6626a3c
  d63a267`, and whose own lines equal those of 8fd2fa6, the head
  RV630 read: RV630's text, appended to the YAML reader bullet.

#738 (#695) is v1.1.1 (lane 5, #473 issuecomment-6236564004), so its
docs entry is not taken, and v1.0.0's `--into` limit (v1.0.0.md:492)
is listed as still open under Known limits. Its upgrade note is true of
main since #642 (0ed1b84), so it goes in now, under "Install and
upgrade": lane 5's sentence verbatim, and the README's `Y/Y` layout.

Counts and the open list at main 64ec47c: 113 pull requests since
bfed143; 124 of 135 milestone issues closed at 16:42Z; 11 open, each
with a pull request open, #681 and #708 out, #695 marked as planned for
v1.1.1. All still placeholders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…nd what they tell git to the `GIT_CONFIG_COUNT` entries the environment holds, so a pasted line keeps the session's maintenance settings and the Windows job's two (#758) (#818)

Against main. #748 landed as `431c8cf8`; this branch merged #748's final head and main (see Fix round 1). After that merge, #723 (`bad05d21`) and #746 (`fc867207`) landed. The branch is refreshed over main `fc867207` as `4a553e17`, one merge commit. Its only conflict was `tests/test_non_utf8_git_reads.py`'s `from conftest import` block, resolved by keeping both sides' names, as rv818 asked. The merged tree differs from main in #818's own 27 files, and #818's count scan flags no file. Tests only: 25 files under `tests/`, no shipped code, no pinned file, no README, handbook, `AGENTS.md` or `docs/cli.md` change.

Two new commits on `b6e15591`: the retarget merge `2908e46f`, then the fix commit `eaa72d9e`. Tests only, as before.

### The retarget: merge commit `2908e46f`
#748 landed as `431c8cf8` from its final head `8368d1f8`, and GitHub moved this PR's base to main. The merge commit has three parents: `b6e15591`, `8368d1f8` and main `fa04d2cd`. #748's head goes in first, so git sees the history the squash dropped.

- **#748's head did not merge clean**, as rv818 found. `tests/test_adopt_empty_symlinks.py` conflicts, because `8368d1f8` holds main `741679e7`, which carries #388.
  - I took w758's earlier resolution of that file, which rv818 checked. Main's side of the file is the same at `974d4827`, `8368d1f8` and `fa04d2cd`, so it still fits.
  - Both sides stay: #388's two targets (`with_each_target`, `SAID`, the `target` parameters) and #758's `symlinks_off()`, built at the call.
  - `test_the_link_given_a_target_adopts_end_to_end` calls `symlinks_off()` too. Git does not flag it, and left as it was the file fails at collection.
- **Main then conflicted in three files** (main was `d17564d9` when I resolved them): `tests/test_clone_just_pushed.py`, `tests/test_doctor_legs_members_next.py` and `tests/test_family_bump_remove_rollback.py`.
  - Main's copy of each was `8368d1f8`'s byte for byte.
  - This branch's copy is `8368d1f8`'s plus #758's own hunks, so this branch's side is taken.
  - Main's next two landings, #809 and #816, merged with no conflict.
- **rv818 MUST 1.** `tests/test_local_remote_dir_urls.py` came with #812 and imported `ALLOW_FILE_PROTOCOL` from `test_family`.
  - Without a change, the file fails at collection: `ImportError: cannot import name 'ALLOW_FILE_PROTOCOL' from 'test_family'`, and none of its 27 cases run.
  - It now imports `allow_file_protocol` and calls `pasted(lines, paste, allow_file_protocol())`.
  - `git grep` finds no other file on main that uses a name #758 removes (`SYMLINKS_OFF`, `SYMLINKS_ON`, `CONFIG_COUNT`, `CONFIG_VALUE`, `PASTE_ENV`, `ALLOW_FILE_PROTOCOL`). `NO_MAINTENANCE`, `QUOTE_PATH_OFF` and `tool_env` change shape, and each is used only in its own file.
- **Checked:**
  - Against main, the merged tree differs in #758's 25 files and in `tests/test_local_remote_dir_urls.py`.
  - Each of the 25 differs by exactly #758's own added and removed lines, `tests/test_adopt_empty_symlinks.py` aside.
  - Collecting the whole suite at the head finds 12,320 tests and no error.

### The fix commit `eaa72d9e`
- **SHOULD 2:** `configure` in `tests/test_adopt_reencoded_files.py`, the seventh append from #758's line 2, now sets what `appended_to_command_scope(os.environ, pairs)` returns. It gives the same entries in the same order, so no test can fail on the old form.
- **SHOULD 3:** the scan also reads a name the module binds to `"GIT_CONFIG_COUNT"` (`CONFIG_COUNT = "GIT_CONFIG_COUNT"`, annotated or not) as the count. It does so in a dict key, a setter's first argument and a subscript.
  - rv818's repro: append the base's two lines to `tests/test_adopt_root_symlinks.py`.
  - The old scan passes. The new one names line 2137.
- **SHOULD 4:** `counts_set_to_a_number` is split into one reader per node type: `in_a_dict`, `in_a_call` and `by_subscript`. The walk takes each node's reader from `READERS`. By hand, no function scores more than 5 on S3776. I could not run Sonar.
- **NIT 5:** `conftest.py` is no longer skipped. It scans clean, at the head and on today's main.
- **NIT 6:**
  - New "sees" cases: `os.putenv("GIT_CONFIG_COUNT", "1")` and a bare `setenv("GIT_CONFIG_COUNT", "1")`.
  - The walk is now `files_off_their_count(directory)`. A new test runs it over a folder of its own, and checks three things:
    - `conftest.py` and a helper module are read;
    - a `.txt` file is not;
    - a file `OWN_COUNTS` allows is named for fewer counts as well as for more.
  - Four of rv818's six surviving mutants now fail a case: `putenv` dropped, the bare-name setter dropped, `!=` changed to `>`, and `glob("test_*.py")`.
  - The fifth, `conftest.py` scanned, is now how the code works, and putting the skip back fails the new test.
  - The sixth, the count's value taking any string, still survives: it is a stricter scan. Git refuses a count that is not a number (`bogus count in GIT_CONFIG_COUNT`, exit 128), so a test that holds one fails on its own.
- **NIT 7, in part:**
  - An empty count (`""`) is now a count of its own. Git 2.43 and 2.47.1 read it as no entries at all. Measured: `GIT_CONFIG_COUNT= GIT_CONFIG_KEY_0=a.b GIT_CONFIG_VALUE_0=c git config --get a.b` exits 1.
  - The docstring says the scan reads the count's value as a constant only.
  - Left out:
    - a value built by a call (`str(len(pairs))`) or held in a variable;
    - an int;
    - `dict()`, or `environ={}` by keyword, handed to the helper;
    - `setenv` with keyword arguments;
    - an annotated subscript.

    rv818 judged only `str(len(entries))` likely, and telling it from a count worked out needs what the call means, not how it is spelled.
- **NIT 8:** the paste case deletes `GIT_CONFIG_PARAMETERS` first. Measured on the merge before the fix commit, with `GIT_CONFIG_PARAMETERS="'probe.param'='p'"`, the case failed (`Left contains one more item: 'probe.param=p'`). It passes now.
- **NIT 9, left as it is:** main's three appends built by hand each work the count out right: `tests/test_family_head_lost_gitmodules.py:97`, `tests/test_family_bootstrap_gate_order.py:135` and `tests/test_family_add_spellings_remedies.py:137`. Each of those files is also changed by another open PR (#828 `fix/761-add-move-back-gitmodules`, #820 `fix/762-validate-family-remedies-texts`, `fix/763-holder-docs-agree`), so converting them here would only add conflicts.
- **NIT 10:** the comment on `NO_MAINTENANCE` now says its `maintenance.auto` and `gc.auto` repeat the session's two, which reach these gits in the command scope. Only `gc.autoDetach` adds a setting. The two stay, so the constant alone still keeps maintenance off for these gits.
- **NIT 11:** the count was in lane 5's #473 summary, not in this body. The seven sets held 2,401 distinct passes, not 2,609: the 208-test run repeats three files of the 256-test run.

### Tests at `eaa72d9e`
All runs went through `run9.sh`, with the real `gh` unreachable. All passed, none skipped:
- **The 26 test files the PR changes against main**, in one full-file run each: 2,206 passed (1,108 + 1,098).
  - These are #758's 24 test files, `tests/test_local_remote_dir_urls.py` and `tests/test_adopt_reencoded_files.py`.
  - The count matches the collection exactly.
- **The 43 files that import any of those 26, with `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`:** 1,646 passed (825 + 821).
- **The same merge made on main `d17564d9`** (a local commit, `c16a7e1a`, never pushed), before the fix commit: `tests/test_local_remote_dir_urls.py` and `tests/test_no_auto_maintenance.py`, 55 passed.

### Fails on the base
The new tests were run against the base's scan and walk, as `b6e15591` has them, with `GIT_CONFIG_PARAMETERS` set: 6 failed, 30 passed. The six:
- the paste case;
- the `nothing`, `a-name-bound-to-it`, `an-annotated-name` and `a-name-by-subscript` cases;
- the walk test.

The three other new cases (`a-bare-setenv`, `putenv`, `a-name-bound-to-another-key`) are there to kill mutants, and they pass on any correct scan. MUST 1 fails on main's form at collection, as above. SHOULD 2 and NIT 10 change no behaviour.

### Mutants
I made 26 mutants. 25 are caught, and one survives as rv818 said it would:
- **22 on the scan and the paste case**, each run against `tests/test_no_auto_maintenance.py`:
  - the delenv dropped;
  - `putenv` dropped from `SETTERS`, or the bare-name setter;
  - the name branch of `is_the_count` dropped, or any name taken as the count;
  - `""` no longer a count;
  - the `Assign` names dropped, or the `AnnAssign` names;
  - no names collected;
  - each reader ignoring the names;
  - each of the three readers dropped from `READERS`;
  - `conftest.py` skipped;
  - `!=` changed to `>` or to `<`;
  - `glob("test_*.py")`, or `glob("*")`;
  - `by_subscript` taking any value;
  - the walk pointed at an empty folder.

  The last passes on a clean tree, like any change to an assertion that nothing is found. It is caught by rv818's SHOULD 3 repro, where the real walk fails and the mutant passes.
- **The survivor:** the count's value taking any string. It makes the scan stricter (see NIT 6).
- **`configure`:** with `appended_to_command_scope({}, pairs)`, the scan names the file. With the pairs dropped, three of its tests fail.
- **MUST 1:** main's import put back fails collection with `ImportError`.

### Merge checks at `eaa72d9e`
I ran `git merge-tree --write-tree` against `origin/main` (`fa04d2cd`) and the 53 other `fix/*` and `l3-*` heads on origin:
- **Clean:** `origin/main` and 43 heads.
- **#792 (`l3-785-non-ascii-paths`)** merges clean, but `tests/test_doctor_non_ascii_paths.py` imports `PASTE_ENV`. Whichever PR lands second changes `:50` (the import), the `:266` docstring and `:268` to `paste_env()`.
- **#723 (`fix/663-non-utf8-name-tests`), #746 (`fix/672-bump-leg-branch-and-push-line`, which now holds #723) and #806 (`fix/757-non-utf8-test-dirs`)** conflict only in the `from conftest import` block of `tests/test_non_utf8_git_reads.py` (and of `tests/test_adopt_git_reads.py`, for #806). Both sides add names, so keep them all.
- **Seven heads conflict with main itself, not with this PR:**
  - `l3-732-latin1-section` and `l3-733-ignored-sections` (`tests/test_doctor_legs_members_next.py`);
  - `fix/712-validate-pins-contracts`, `fix/766-spelled-paths` and `fix/798-adopt-legs-asked-first`;
  - `l3-677-pins-hand-step-wip` and `l3-678-naming-checkout-wip`.
- I scanned each clean merged tree's changed test files for a name #758 removes, or a count the scan flags. Only #792 shows up.
- **Main moved after the push.** #723 landed as `bad05d21`, and the two now conflict in one place: the `from conftest import` block of `tests/test_non_utf8_git_reads.py`. The fix is to keep both sides' names: `ORG, PROJECT, REPO, appended_to_command_scope, cloned_just_pushed, git, run_script, skip_message, skip_where_fsencode_gives_a_surrogate_no_byte, windows_fsencode`. Every other change merges in. On that merged tree, with the block resolved that way, the scan flags no file, and no file uses a name #758 removes.

On `deb029c9` a probe test imported `PASTE_ENV` and pasted `git config --show-origin --list` through `paste()`:
- `PASTE_ENV` held `GIT_CONFIG_COUNT=1` (`protocol.file.allow`), a stale `GIT_CONFIG_KEY_1=user.email` left from collection, and no `GIT_CONFIG_GLOBAL`.
- `os.environ` at the call held a count of 4 and the session's global file.
- The pasted git listed one command-line entry, `protocol.file.allow=always`, and `git config --get maintenance.auto` exited 1. So a pasted line ran with git's automatic maintenance on, in both scopes, as the issue said.

`paste_env()` replaces it. It reads `os.environ` at each call (minus `PYTHONPATH`), adds the identity and `PYTHONDONTWRITEBYTECODE`, and appends `protocol.file.allow` with `appended_to_command_scope`. The six uses call it. The import-time copy also predated the session's `gh` guard on `PATH`, as `test_doctor_next_is_a_command.py`'s docstring says of its own environment; a pasted line now gets the `PATH` of the call. That docstring now says `paste_env()` reads at the call too.

- **The seven.** Six are on this base, each now one call to `appended_to_command_scope`: `test_adopt_git_reads.py`, `test_adopt_nested_attributes.py`, `test_adopt_ignored_shape_files.py`, `test_adopt_root_symlinks.py`, `test_bootstrap_after_bump.py`, `test_family_bump_remove_rollback.py`. The seventh, `test_adopt_reencoded_files.py`, came with #743 and is converted in fix round 1. `test_clone_just_pushed.py:96` stays, as the issue says: it is the helper's own test.
- **The other literals.** Each sets a count of its own and is laid over `os.environ`, where the session's two settings and the Windows job's two sit, so each replaced them (`NO_MAINTENANCE` put the two maintenance keys back and dropped the job's two). I read each; each is converted, none left:
  - `test_family.py`: `ALLOW_FILE_PROTOCOL` is now `allow_file_protocol()`; its two importers (`test_siblings_family_name.py`, `test_family_siblings_placement.py`) and the four uses in `test_family.py` call it.
  - `test_family_dry_run.py`: `NO_MAINTENANCE` is a tuple of entries, appended by `isolated()`.
  - `test_non_utf8_git_reads.py`: `QUOTE_PATH_OFF` is a tuple of entries, which `tool_env()` appends; `user_name()` appends too.
  - `test_adopt_empty_symlinks.py`: `SYMLINKS_OFF` is `symlinks_off()`.
  - `test_adopt_root_symlinks.py`: `SYMLINKS_OFF` and `SYMLINKS_ON` are gone; the four tests that set the first call `links_as_files` and the two that set the second call `told_git`, which already appended.
  - one-off environments: `test_scaffold_reuse_seeded_legs.py`, `test_adopt_split_branch.py`, `test_scaffold_free_text_values.py`, `test_adopt_plan_yaml_quoting.py`, `test_setup_project_py.py`, `test_setup_sh.py`, `test_doctor_next_is_a_command.py`.
  - The two tests that build a held environment on purpose to see it kept (`test_adopt_git_reads.py`, `test_clone_just_pushed.py`) now build it with `appended_to_command_scope({}, ...)`.

Measured with a probe on a throwaway bare remote after a push (18 loose objects): the hand-built options and `clone_just_pushed`'s give the same clone, one pack and no loose object, with `upload-pack` and `pack-objects` in `GIT_TRACE`, and `origin` keeps the plain path. A plain local clone of the same remote keeps all 18 loose objects. For a clone that is not recursive the helper's `insteadOf` rewrite is inert, because no submodule is cloned; the helper adds one place for the options, not a different read.
- `test_bump_leg_dry_run_truth.py:98`, the private bare copy of the session's spec remote: a session remote. `cloned_just_pushed(..., bare=True)`; the fixture now asserts the copy is bare.
- `test_bump_leg_dry_run_truth.py:110`, `private_work`, a clone of that private remote, which tests push tags into: `cloned_just_pushed(..., recurse=False)`.
- `test_doctor_legs_members_next.py:1828` and `:1929`, `Ghost`, a clone of the first member's checkout in the test's own holder copy: not a remote, and the hand-built `-c protocol.file.allow=always` was dead weight on a direct clone. Both call `clone_member`, one helper over `cloned_just_pushed(..., recurse=False)` that asserts the legs stay empty.
- `test_family_bump_to_checks.py:235`, a clone of `a_private_remote`, a bare repository filled by a push: `cloned_just_pushed(remote, work, "main", recurse=False, env=env)`.
- `test_doctor_pin_restore_merges.py:714` is **left as it is**: a `--filter=blob:none` clone of a file URI. The helper takes a path, and has no way to pass `--filter`.

- `test_family_dry_run.py`: the comment on `NO_MAINTENANCE` says what the entries are for, the gits `isolated()` hands an environment to, whose global file `excluding()` replaces, and that the session fixture covers the module's own setup commits, which pass no `env=`.
- `tests/conftest.py`: the two i645 replacements, "repacks once its estimate, taken from `objects/17` alone, is above 256 loose objects, so two loose objects there are enough", and the `objects changed` sentence. **#713's body holds the matching sentence and needs the same two replacements; I cannot edit a body.**

`tests/test_no_auto_maintenance.py` has 17 new cases (28 in the file, 11 before):
- `test_a_pasted_line_keeps_the_entries_the_environment_holds_at_the_call`: the environment holds two entries as the Windows job does, then the two settings as the session fixture appends them, set after the doctor tests were imported. `git config --show-origin --list` through `paste()` lists the four, in order, then `protocol.file.allow`, and reads both settings from the session's global file.
- `test_no_test_sets_a_git_config_count_of_its_own`: reads every `tests/*.py` but `conftest.py` with `ast` and fails a `GIT_CONFIG_COUNT` set to a number (dict, keyword, `setenv`/`setdefault`/`putenv`, subscript) and an `appended_to_command_scope` call handed a dict written out. Three files are allowed the ones that build a held environment on purpose, with a count each in `OWN_COUNTS`.
- 15 cases that hold the scan itself: seven spellings it must see, eight it must leave (`env.get("GIT_CONFIG_COUNT", "0")` is a read).

Runs at the head `b6e15591` through `run9.sh` (the real `gh` unreachable), all passed, none skipped:
- `test_no_auto_maintenance.py`, `test_doctor_legs_members_next.py`, `test_doctor_next_is_a_command.py`: 208 passed.
- `test_clone_just_pushed.py` and `test_doctor_validator_rows.py` (it imports the doctor file), with those three: 256 passed, run one commit earlier (the later commit only moved two blank lines and reworded one failure message).
- The sweep files: `test_adopt_git_reads.py`, `test_adopt_nested_attributes.py`, `test_adopt_root_symlinks.py`, `test_adopt_ignored_shape_files.py`, `test_bootstrap_after_bump.py`, `test_family_bump_remove_rollback.py`, `test_adopt_empty_symlinks.py`, `test_adopt_split_branch.py`: 445 passed.
- `test_non_utf8_git_reads.py`, `test_family.py`, `test_siblings_family_name.py`, `test_family_siblings_placement.py`, `test_scaffold_reuse_seeded_legs.py`, `test_scaffold_free_text_values.py`, `test_adopt_plan_yaml_quoting.py`: 999 passed.
- `test_family_dry_run.py`, the five files that import its `isolated` (`test_family_bump_to_a_tag.py`, `test_family_bump_to_checks.py`, `test_family_bump_to_local_only.py`, `test_family_refuse_up_front.py`, `test_family_remove_gitmodules.py`) and `test_bump_leg_dry_run_truth.py`: 216 passed.
- `test_setup_project_py.py`, `test_setup_sh.py` and their importers (`test_adopt_shape_pin_and_work_dir.py`, `test_dry_run_asks_remotes.py`, `test_clone_url_bytes.py`, `test_setup_by_hand_commands.py`): 313 passed.
- `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_doctor_pin_restore_merges.py`, `test_git_helper_identity.py`: 172 passed. `render-cli-reference.py --check` is ok.

**On the base (`deb029c9`)**, with the new `test_no_auto_maintenance.py` copied in: 2 of the 17 fail, the paste case and the scan (it finds 12 files). The other 15 hold the scan itself and pass on any tree.

Each of the new hunks was broken in a throwaway and run against its tests:
- `paste_env`: a literal count of 1, a snapshot taken at import, and `appended_to_command_scope({}, ...)`: each fails the paste case, and the first and third the scan too.
- The 18 sites that call `appended_to_command_scope(os.environ, ...)` (or `env`), each with `{}` in place of the environment: each fails the scan. Sites: the six of line 2, `allow_file_protocol`, `isolated`, `tool_env`, `user_name` (non-UTF-8), `symlinks_off`, `git_reads_github_from`, `github_at`, `user_name` (free text), `test_adopt_plan_yaml_quoting.py`, the two setup tests, `test_doctor_next_is_a_command.py`.
- The scan: eight changes (ignore dicts, keywords, `setenv` calls, subscripts, `appended_to_command_scope({}`; flag `os.environ`; flag a read; allow fewer in `OWN_COUNTS`) each fail a scan case.
- The clone sites: dropping `bare=True` from the private remote, `recurse=False` from `clone_member`, and `"main"` from the checks clone each fail. Two **survive as equivalents**: `recurse=False` in `private_work` (the spec leg has no `.gitmodules`, so a recursive clone of it is the same clone) and `env=env` in the checks clone (a clone reads no config the isolated environment changes).
- The two comments cannot be mutated.

`git merge-tree --write-tree` of this head against `origin/main` (`974d4827`), #748 (`deb029c9`), #713 (`979bb394`) and every `fix/*` and `l3-*` head on origin (46 refs):
- **Clean:** #748, #713, and 24 of the 46 heads: 21 `fix/*` heads (every lane-5 PR but #723 and #756, and most of lane 4's) and `l3-677`, `l3-678`, `l3-rehearsal-v1.1.0`.
- **`origin/main`: one file, `tests/test_adopt_empty_symlinks.py`** (four hunks), against #388's landing (`5c990708`). The parent #748 merges clean, so this arrives with the retarget. Resolve by keeping main's lines and my `symlinks_off()`. Two more spots need the same change and git does not flag them:
  - `tests/test_adopt_empty_symlinks.py::test_the_link_given_a_target_adopts_end_to_end` uses `SYMLINKS_OFF`, which this change removes: give it the `off` parametrization.
  - `tests/test_adopt_reencoded_files.py::configure`, the seventh hand-built append, goes to `appended_to_command_scope(os.environ, pairs)`.
  - `tests/test_family_head_lost_gitmodules.py::on_disk` (on main) is one more hand-built append: `{**env, **appended_to_command_scope(env, [("core.ignorecase", ignorecase)])}`. The scan does not read for hand-built appends, so it is not required, but it is the same thing.

  I made that merge in a scratch tree (main at `bf1f099b`), resolved it as above, and ran the files this change touches that main also changed, with `test_adopt_empty_symlinks.py`, `test_adopt_reencoded_files.py`, `test_family_head_lost_gitmodules.py`, `test_no_auto_maintenance.py` and the files that import `isolated` or the doctor helpers: 653 and 245 passed, in two runs. The scan finds no other file of main's with a count set by hand.
- **#723 (`fix/663-non-utf8-name-tests`):** `tests/test_non_utf8_git_reads.py`, the `from conftest import` block, where both add names.
- **`l3-732`, `l3-733`:** `tests/test_doctor_legs_members_next.py` (the `from conftest import` block) and `tests/test_adopt_empty_symlinks.py`.
- **19 more** (`fix/664`, `fix/729` (#756) and 17 `l3-*` heads): `tests/test_adopt_empty_symlinks.py` only, because they carry main's #388 as main does.
- **Not a textual conflict, but it breaks on merge:** `l3-785-non-ascii-paths` imports `PASTE_ENV` (`tests/test_doctor_non_ascii_paths.py:47` and `:223`); it must call `paste_env()`. #812 (`fix/693-local-remote-dir`) landed first, with `tests/test_local_remote_dir_urls.py` importing `ALLOW_FILE_PROTOCOL` (`:51`, `:349`). This PR's retarget merge makes it call `allow_file_protocol()` (rv818 MUST 1).

- A pasted `git submodule update --init` still clones a local path: `paste()` takes no remotes, so no `insteadOf` rewrite reaches it (rv662's "build it from `read_through_git`"). It runs under the session's maintenance settings now, which is what the issue asks.
- Three more appends built by hand beside `appended_to_command_scope`, in files this base does not hold: `tests/test_family_head_lost_gitmodules.py:97` (main), `tests/test_family_bootstrap_gate_order.py:135` (#682), `tests/test_family_add_spellings_remedies.py:137` (#767). The scan reads for counts set to a number, not for appends worked out by hand.

Review: lane 2's independent review (issuecomment-6236874291) and its re-verification of eaa72d9, READY (issuecomment-6238781272); its new SHOULD is taken in #848, and 4a553e1 merges main over eaa72d9. CI (Brett Heap's ruling 27): H0 is the head 4a553e1 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base fc86720; and the local gate passed on its predicted squash tree 9d6c099 over main d8b8eb3: the adopt_empty_symlinks, adopt_git_reads, adopt_ignored_shape_files, adopt_nested_attributes, adopt_plan_yaml_quoting, adopt_reencoded_files, adopt_root_symlinks, adopt_split_branch, bootstrap_after_bump, bump_leg_dry_run_truth, clone_just_pushed, doctor_legs_members_next, doctor_next_is_a_command, family, family_bump_remove_rollback, family_bump_to_checks, family_dry_run, family_siblings_placement, local_remote_dir_urls, no_auto_maintenance, non_utf8_git_reads, scaffold_free_text_values, scaffold_reuse_seeded_legs, setup_project_py, setup_sh, siblings_family_name, repo_hygiene, agents_md_codes, cli_reference sets, 2359 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #758

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 17, 2026
… `--into` sentence names the `family-landing-in-standard` refusal, `make siblings` is described as it stops before cloning, and `members/` has its one-spelling rule (#695) (#738)

The family docs now say what `family.py init`, `make siblings` and the `members/` directory do since #488, #578 and #574. Base: `8062810d`. Head: `c42286de`: the work (`73fa3ff4`) and one commit for the review's nit on L20.

**The branch now holds `origin/main` at `24a977c0`, through one merge commit, `75e35f14`.** No other commit was added, and nothing was rebased or amended.

### What failed

Since #682 landed (`dc7d3fbb`), `git merge-tree --write-tree HEAD origin/main` against `c42286de` stopped on one line of `docs/handbook.html`:

    Auto-merging docs/handbook.html
    CONFLICT (content): Merge conflict in docs/handbook.html

It is the `families` section's marker, `<!-- reads README.md § Families: ... blob <hash> -->`. #682 changed a sentence of README § Families, so main's marker is `b3d19092...`. This PR changed other sentences of the same section, so its marker is `0364c579...`. Git saw one line changed two ways. Lane 2's note on #738 (2026-10-17T12:47:17Z) named the same line. I reproduced the conflict on the head I started from, then merged by hand.

### The fix

The merged README § Families is neither side's text, so the marker takes its fingerprint, `82d7f8087b9ee4d097d33e15fbfe5ae754a04a79`. I computed it with `tests/test_repo_hygiene.py`'s own `readme_fingerprint` over the merged `README.md`. It is the value lane 2's note gave, and it did not move when main moved on to `24a977c0`.

| marker value | where it came from | handbook outline test |
|---|---|---|
| `b3d19092...` | main's side | fails: "has moved since the page read it" |
| `0364c579...` | this PR's side | fails, same message |
| `82d7f808...` | the merged README § Families | passes |

I re-read both sides before taking the value:

- Main's side brought one sentence into README § Families (line 955, "`make bootstrap` reports and skips one it lacks a credential for."), and #682 had already put the matching sentence on the page.
- This PR's side is its own 55 README lines and 27 handbook lines in that section, which merged clean.
- Both sides' sentences stand in the merged README and in the page.

### Did the merge change README or handbook text beyond the conflict line?

No. I compared each changed file's diff against the parent that did not make it:

- `README.md`: the merge against `origin/main` shows exactly this PR's own change, and the merge against `c42286de` shows exactly main's own change since `8062810d` (one line, #682's). No line differs.
- `AGENTS.md`: the same two comparisons, no line differs.
- `docs/handbook.html`: the same two comparisons differ in the marker line only, in both directions.
- README's handbook markers (23 on the page) and README's line cap are untouched. `tests/test_repo_hygiene.py` passes with the cap and the markers as they are.

This PR still lands only in lane 3's README turn. If another README PR changes § Families first, the marker moves again, and the hygiene test names it.

### Tests

All on `75e35f14`, through the gh stub, with a bogus token and an empty `GH_CONFIG_DIR`.

| run | files | result |
|---|---|---|
| core | `test_readme_sections.py`, `test_family_docs_one_spelling.py` (the one test file this PR changes), `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py` | 240 passed |
| handbook readers | `test_readme_windows_quoting_sentence.py`, `test_setup_by_hand_commands.py`, `test_update_add_codes_documented.py` | 20 passed |
| family-root templates | `test_pinned_copy_text.py`, `test_template_values_single_pass.py`, `test_family_init_values.py`, `test_family_init_landing.py`, `test_siblings_family_name.py`, `test_family_makefile_siblings.py`, `test_family_siblings_placement.py` | 582 passed, 19 skipped |
| bootstrap (main's new files, over the template this PR reworded) | `test_bootstrap_spelling.py`, `test_bootstrap_without_git.py`, `test_bootstrap_leg_moves.py` | 251 passed |

### Mutants

This round adds no test and no code, so its one hunk is the marker value. Each mutant changes that value in a shared scratch clone at `75e35f14` and runs `tests/test_repo_hygiene.py::test_the_handbook_follows_the_readmes_outline`:

| mutant | result |
|---|---|
| none (control) | 1 passed |
| main's marker `b3d19092...` | 1 failed |
| this PR's marker `0364c579...` | 1 failed |
| the right marker with its last digit changed | 1 failed |
| the unresolved conflict block (`<<<<<<<`, both lines, `>>>>>>>`) | 1 failed |

### Merge checks

- `git merge-tree --write-tree HEAD origin/main` is clean at `24a977c0`.
- Main moved to `40e678e6` (#805, `templates/family-root/scripts/siblings.py` and `tests/test_siblings_inputs.py`) after this merge. It touches no README, handbook or AGENTS text, and the merge-tree against it is clean.
- Lane 2's #822 is stacked on this PR. Its reviewer found it merges clean with this PR landed at the same marker value.

1. **The `--into` sentence** (README.md § Families and the handbook). It gave the default, "the directory you are standing in", and not that `init` refuses that default inside a checkout of this standard. It now says so and names the code: `(the directory you are standing in, by default; refused inside a checkout of this standard, \`family-landing-in-standard\`)`. AGENTS.md already said it.
2. **The `make siblings` paragraph** (README.md § Families and the handbook). It said `make siblings` "WARNS and prints the exact `mv`". Since #578 it also clones nothing until the `mv` is run, so the next run clones each member once, and a link of the family's name beside the folder passes. It now says that. Lane 3's two #460 sentences ride in it (L20, L21, which lane 3 marked `-> #695`):
   - the folder must be named EXACTLY after its repository, case included, on every disk;
   - for a holder at `~/projects/InkRouter` the move makes the family folder `~/InkRouter`, beside `~/projects`, and the holder `~/InkRouter/InkRouter`, not `~/projects/InkRouter/InkRouter`.
   I wrote L20 with the paths spelled out. Lane 3's wording was "beside that folder's parent", which two readings can take. My first wording, "beside that folder, not inside it", still left one: the thing beside `~/projects` is the family folder, not the holder (rv695 N1). The printed move for that holder is `mv ~/projects/InkRouter ~/InkRouter.holder`, `mkdir -p ~/InkRouter`, `mv ~/InkRouter.holder ~/InkRouter/InkRouter` (probe below, and `test_family_siblings_placement.py` pins it).
3. **The one-spelling rule for `members/`.** One sentence, the same words in five places: AGENTS.md (family item 2), README.md § Families, the handbook, the holder's README and the holder's `AGENTS-shape.md`:
   > A holder keeps `members/` in ONE spelling on every disk: `add` refuses `family-members-dir-variant` (`member-path-taken` for a variant of the member's own path) until a human renames what sits under another spelling.

`templates/family-root/AGENTS-shape.md` changes by that one sentence. It is in `FAMILY_COPIED_VERBATIM`, so every holder reads `upstream-changed` for it on its next `update-shape.py check`. The issue's third line names it, and lane 3 asked that it ride with the next pinned-file edit. It can share the one v1.1.0 shape bump with the bootstrap fix. `templates/family-root/README.md` is templated and not pinned: an existing holder keeps its own README.

- README.md is 1458 lines, one under its cap of 1459 (1422 + 5 + 22 + 10 in `tests/readme-cap.d/`); it was 1459 at `73fa3ff4`. The brief said 1449; that is the cap before #627 landed. AGENTS.md is 492, its cap.
- The new words come to about nine README lines and three AGENTS.md lines. I fitted them by cutting wording that is stated elsewhere. **If you would rather keep the cut sentences, the alternative is a `tests/readme-cap.d/695.txt` of about +8, which needs Brett's word.** What I cut:
  - README § Families:
    - the aside "the same lands-where-you-were-standing rule projects have";
    - four rewordings that lose no meaning:
      - the intro of "Use a family when the parts deploy separately": "folding them into one assembly root would give them one gate, one release and one pin, which is the opposite of what 'deploy separately' means" is now "one assembly root would give them one gate, one release and one pin, the opposite of 'deploy separately'";
      - "`--work-dir` remains the override" is now "`--work-dir` is the override";
      - "The doubled name is kept on purpose" is now "The doubled name is on purpose";
      - "`family.yaml` is what tells the two apart" is now "`family.yaml` tells the two apart";
    - the caption "Eight services, each its own project, one holder:" under "The InkRouter example" (the section's intro and the block under it say it; this is the one line cut to make room for the L20 rewording, and the handbook keeps its caption);
    - the tool's rationale after "Nothing ever moves a checkout" (the shell, the editor, the agent lanes, "an act by surprise"); it stays in the handbook, the holder's README and the tool's own output, and README keeps the reason that matters (a linked worktree's `.git` file carries an absolute path);
    - ", not merely a repository at the right commit";
    - the private-legs reason in the `make bootstrap` paragraph;
    - "to a policy two live projects already carry a copy of" in the naming paragraph.
  - AGENTS.md:
    - the converse in the family intro ("one project is what parts that ship together already are"; README keeps it);
    - the sentence "`bump` takes the same repeatable `--trailer`" in family item 3 (stated in "Advancing a leg" step 6 and in the holder's `AGENTS-shape.md`);
    - "Do not pass `--yes` on your own initiative" in "Updating" step 2, now "Never pass `--yes` yourself", the words `test_repo_hygiene.py` requires elsewhere in the file.
- The handbook marker for README § Families is recomputed with `readme_segments` / `readme_fingerprint`: `89ef0ab5ec88...` to `0364c5794c46...` (it was `dc85a9260a67...` at `73fa3ff4`, before the L20 rewording and the caption cut). No other README section changed, so no other marker moves.

New file `tests/test_family_docs_one_spelling.py`, 17 tests: the one-spelling sentence in each of the five docs, the two codes held as strings by `scripts/family.py`, and the `--into`, `make siblings`, "named EXACTLY" and "straight in a folder" sentences in README and the handbook, plus the old "WARNS and prints" sentence gone from both. The handbook is read as the text a browser shows (`<code>` as backticks).

- On base `8062810d` (the file copied in): **15 failed, 2 passed**. The two that pass are the code-exists checks.
- On the head `c42286de`, after the L20 rewording: `test_repo_hygiene.py`, `test_readme_sections.py`, `test_agents_md_codes.py` and the new file: **217 passed** in 3.37s. The new file's `STRAIGHT_IN_A_FOLDER` is in the new README and not in the old one.
- Before that, the targeted runs, each at `8062810d` plus the working tree that was then the head (`73fa3ff4`):
  - `test_repo_hygiene.py`, `test_readme_sections.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_readme_windows_quoting_sentence.py`, `test_setup_by_hand_commands.py`, `test_update_add_codes_documented.py` and the new file: **253 passed**.
  - Fifteen files that read AGENTS.md, the holder's docs or the refusals (`test_family_init_landing`, `test_siblings_family_name`, `test_family_switch_branch_exists`, `test_family_tracking_branch`, `test_template_text_placeholders`, `test_template_values_single_pass`, `test_template_quoted_values`, `test_free_plan_hint`, `test_doctor_recorded_answers`, `test_update_shape_add`, `test_update_shape_header_comments`, `test_family_add_refusals`, `test_family_members_case_variants`, `test_family_member_path`, `test_doctor_legs_members_next`): **987 passed**.
  - Eight files: `test_adopt_plan`, `test_adopt_github_exits`, `test_adopt_late_failure`, `test_setup_project_py`, `test_family`, `test_family_siblings_placement`, `test_family_makefile_siblings`, `test_park_resume_targets`: **598 passed, 1 skipped** (`SHAPE_MEDXEHR_CLONE` unset: a plan test needs a read-only clone of MedxSoft/MedxEHR, a cloud tool).
  - `test_update_shape.py -k "shape_line or stale_readme or check_says_what_apply or readme_with_no_shape"`: 7 passed (the tests that read the holder README's last line). The rest of `test_update_shape.py` and `test_shape_doctor.py` were not run: they copy the family-root tree and match none of the lines I changed (`git grep` of the changed sentences under `tests/` finds nothing), and a first run of them crawled under a load of 90-100 on the machine.
- The full suite was not run, as the brief says.

- Row 1, on base: from a checkout of the standard, `python3 scripts/family.py init --org Acme --family Orbit --dry-run` ends `REFUSED family-landing-in-standard ... Remediation: pass \`--into ..\``. The README sentence did not say it would; it now does.
- Row 2, on head: `parent_folder_warning` of the holder's `siblings.py` for a holder at `<t>/home/projects/InkRouter` prints `NOTHING WAS CLONED` and the move `mv .../projects/InkRouter .../InkRouter.holder`, `mkdir -p .../InkRouter`, `mv .../InkRouter.holder .../InkRouter/InkRouter`. For a folder named `inkrouter` on Linux it stops the same way: the name test is exact on every disk.
- Row 3: the refusals the sentence names are the ones `test_family_members_case_variants.py` and `test_family_add_refusals.py` drive (60 + 65 tests, passed above).
- No row needs real GitHub or a cloud tool.

My own run did not happen: a usage checkpoint stopped it before it began. The reviewer (rv695) ran 30 mutants against `73fa3ff4` in a throwaway clone and **all 30 were killed**: five places, four mutants each for the one-spelling sentence, and five each for the README and the handbook sentences. The L20 sentence has changed since, and `STRAIGHT_IN_A_FOLDER` with it; no mutant was run on the new words. What the guards cover there: the sentence has a parametrized case per doc, so reverting it fails that case, and a reverted README hunk leaves the handbook marker stale.

- origin/main `55c6ba7d` (it moved past `2fac9790` by #700): clean. rv695 found `73fa3ff4` clean against `2fac9790`.
- #630 (lane 3), head `6fd33a93`: clean. It merged main `de926142`, and its handbook conflicts at the three markers are gone. On the merged tree README.md is 1472 lines and AGENTS.md 494 (counted here; rv695 found them under #630's own ledger at `73fa3ff4`).
- #682 (lane 5), head `8b39cd19`: conflicts at one line, the handbook marker for README § Families (`0364c5794c46...` here, `c40baa828dca...` there). README.md and the rest of the handbook merge clean, and the merged paragraph reads correctly, because both PRs change that section. Whichever lands second recomputes the marker from the merged README: on this head plus #682 the recomputed value is `da54c8ec2665...` (computed with `readme_fingerprint`, not test-run).
- I dropped the earlier list of clean PRs: it was taken against older heads, and several of them have landed.

(Brett Heap, 2026-10-16T13:36:23Z, on #459 line 141: "document it in the v1.1.0 release notes". `docs/release-notes/v1.1.0.md` exists only on #630, so this PR does not create it.)

> A holder made with `--family X --name Y` before #642 (the fix for #629) keeps `cd Y` in its own README, and `update-shape.py apply` never re-renders a README, so change that one line by hand to `cd X`: the holder's directory is named after its repository, `X`, never after the display name.

Such a README also shows `Y` in its layout block and its doubled-name sentence, as prose; only the `cd` line fails when run.

- The holder's README does not get lane 3's L20 and L21: lane 3 asked for them in the README paragraph, and the holder's README already names the folder after the repository.
- Checklist candidate, not for this PR (rv695 N6): the holder's own docs say the folder is "named after the family" (`templates/family-root/README.md:73`, `templates/family-root/AGENTS-shape.md:99`) and not "EXACTLY, case included", which the standard's README and handbook now say and `siblings.py` tests (`folder.name == family`). Lane 3 asked for L21 only in the standard's README paragraph, so it is not owed here; it could ride in the same re-pin.

Review: lane 5's independent review approved c42286d with its nits fixed; 75e35f1 is its update onto main (lane 2's note, issuecomment-6234057822); Codex found no major issues. CI (Brett Heap's ruling 27): H0 is the head 0b26ace itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 2e476a7; and the local gate passed on its predicted squash tree 68dd413 over main 112eeed: the family_docs_one_spelling, readme_sections, readme_windows_quoting_sentence, setup_by_hand_commands, update_add_codes_documented, family_tracking_branch, family_switch_branch_exists, pinned_copy_text, repo_hygiene, agents_md_codes, cli_reference, family sets, 527 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #695

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…s `--sparse` and moves an entry in the way aside, a row's staged removal and a url-less section are findings, and two refusals name a cure that works (#762) (#820)

Against main. #747 landed as `6626a3c`, and this branch is restacked onto main as `98b0536d`: it merges #747's final head `ff7c59b8`, then main, taking this branch's side in the three files #747 also changed. Over main it carries exactly its own three commits: `git diff 58d6c50 ef17866` (over #747's head `58d6c50f`, where it was written) and `git diff origin/main 98b0536` have the same patch-id.

**Goal (#762): every finding and refusal the holder's `validate-family.py` prints for a member recorded in the wrong case, a gitlink git stops at, or a commit its checkout lacks says what git does and prints a remedy that works in the state it names, and the gate names a member's un-registration before the commit that makes it.**

**Pinned file.** Only `templates/family-root/scripts/validate-family.py` changes among the shipped files. It is pinned in every holder (`FAMILY_COPIED_VERBATIM`), so each holder sees one `upstream-changed` row for it, re-pinned in the next shape bump. The family root's `bootstrap.py` is not changed (line 8 below). No README, handbook, `AGENTS.md` or `docs/cli.md` sentence is made false, and no `--help` text changed.

Every state below was measured on git 2.43.0, in the test family (`tests/test_family.py`'s fixture), on this branch's base `58d6c50f` and then on this head. Each new test types every printed command as printed, in the state it names, and then runs the gate.

rv820 (lane 2) reviewed 1038d7f: NOT READY. 9e2dc7d answered MUST 1. This commit answers SHOULDs 2 to 4 and NITs 5 to 7. It changes `templates/family-root/scripts/validate-family.py` and `tests/test_validate_family_typed_states.py` only.

**SHOULD 2: `member-uninitialized` named a stop git never makes, and a cure that dropped the other member's section.**
- **Reproduced:** in a plain clone with `.gitmodules` deleted and not staged, members checked said `git submodule update --init` stops at members/IRRS, and told the person to write that section back. In fact git read the index's file, and the update exited 0 and checked out both members. With the deletion staged, git read HEAD's file, with the same result. In the url-less state (U1), the old text named members/IRRS where git stops at members/IRSS.
- **The fix:** `_gitmodules_put_back` reads the `.gitmodules` that git's submodule commands read when the work tree's file is gone: `:.gitmodules`, then `HEAD:.gitmodules`, each found with `git rev-parse --verify --quiet`. `_init_stop_said` now reads that file.
- **Where git really stops there,** the refusal names that path and prints the line that puts the file back first: `git checkout -- .gitmodules`, or `git checkout HEAD -- .gitmodules` where the index holds none. Then comes the cure, so the cure keeps every other section. After that line, `--no-members`, which reads the work tree's file, names what the cure says it names.
- **`member-gitmodules-url-absent`** (new in this PR) had the same fault: its `git config -f` line would make a file holding that one key. It now prints the same put-back line first when the file is gone.
- **Left for follow-ups:**
  - `member-gitmodules-absent` (#611, before this PR) prints the same kind of `git config -f` lines;
  - the family bootstrap's `gitmodules_sections` reads the work tree's file alone, as rv820 says.
- **New tests**, each typed as printed. All six fail on 9e2dc7d.
  - A plain clone, with the file deleted, or its deletion staged, is given the plain update, which checks out both members.
  - U1 in a plain clone, both ways: the refusal names members/IRSS and the put-back line. Done as it says, HEAD's file keeps IRRS's section, and the gate passes in every mode.
  - U1 in its own holder, both ways: the url-absent finding with the put-back line, in every mode.

**SHOULD 3: the three wrong-text mutants now each fail a test.**
- `name.startswith(path)` fails the new test of a file committed at `members/IRRS-notes`, beside the row's path.
- Dropping `and "url" in keys` fails the new test of a hand mount whose section's url was taken out.
- Dropping `and name in variants` fails the new test of a non-cone sparse checkout that keeps `notes/` off disk, with the variant on disk.
- These tests pass on 9e2dc7d, whose code is right; they exist to catch the mutants.

**SHOULD 4: S3776.**
- `_init_stop_said` (20 by rv820's count, which a local counter reproduces) is split: the gitlink git stops at is `_init_stopper` (11), and its cure is `_init_stop_cure` (3). `_init_stop_said` is now 4.
- `_check_removals_staged` (18) is now 13. The put-back lines are `_put_back_lines` (2), and the finding's text is `_removal_staged_said` (1).
- Behaviour is unchanged: every existing test still passes, comparing the texts word for word.
- `_check_gitlinks_named` (21) and `_check_gitlinks_registered` (19) are not split here. Main already has them at 18 and 19, and this commit changes neither beyond passing `root` to `_url_absent`.

**NITs**
- **5, taken by narrowing the text.** `_move_context`'s docstring now says what git does with entries a merge left unmerged. At the row's path, the move's second line replaces stages 1 to 3. Under it, it leaves them, and the commit then refuses with `Committing is not possible because you have unmerged files` (exit 128). Measured on git 2.43 and 2.47.1. A new test puts a gitlink at stages 1 to 3 at the row's path: the move as printed replaces it, and the gate passes. Dropping `tag != b"M" and` now fails that test.
- **6, taken.** A case with seven entries under the row's path checks the five-name cap ("and 2 more"). A new test stages a removal where HEAD's `.gitmodules` gives no section for the row: no `git checkout HEAD -- .gitmodules` is printed. Each of the two mutants rv820 named now fails.
- **7, taken in part.** `refused_and_unnamed` replaces the repeated refusal-and-fallback block in two tests, and three new tests use it. The (e) test keeps its own form, because it compares only the first `--no-members` finding, with the fetched wording.

**Tests**
- `tests/test_validate_family_typed_states.py`: 43 tests. On 9e2dc7d's validator, 6 failed (the SHOULD 2 cases) and 37 passed.
- On git 2.47.1: this file, `test_family_head_lost_gitmodules.py`, `test_validate_family_unnamed_mounts.py` and `test_family_bootstrap_gate_order.py`, 155 passed.
- Every test file that runs the validator or calls the gate (58 files, the three standing check files among them), on git 2.43, in six shards on the uncommitted tree: 3021 passed, 24 skipped.
  - 5 failed and 3 errored, all in `update-shape` holder tests: `REFUSED update-conflict ... scripts/validate-family.py: ... not a verbatim copy ... at the pinned commit`. These tests pin `HEAD`, so a change to the validator that is not yet committed makes the holder's copy differ from its pin.
  - With the same two files committed in a scratch clone (the same tree as ef17866), the three files pass: `test_update_shape_pr_base.py` 27 passed; `test_family.py` and `test_update_shape_header_comments.py` 160 passed.

**Mutants** (each run on the whole file with `-x`): all 12 are caught.
- The three from SHOULD 3, the one from NIT 5 and the two from NIT 6 (above).
- The put-back sources swapped: caught by the url-less clone with the file deleted.
- The index's and HEAD's file not read: caught by the plain clone with the file deleted.
- The put-back line left out of the refusal: caught by the url-less clone.
- The put-back line left out of `member-gitmodules-url-absent`: caught by U1 in its holder.
- The two-sections cure checked after the row's own: caught by the plain clone with the `twice` stop.
- The guard for a file on disk dropped: caught by the plain clone with the `stray` stop.

**Merges**
- With #747's head ff7c59b and with #822's head ea86cb6: clean (`git merge-tree`).
- With #819 (c12b5f8): the same 3 conflict hunks in `validate-family.py` as at 9e2dc7d.
- With main (64ec47c): the same conflicts as at 9e2dc7d, from the stack's base.
- **For whichever of #819 and #820 lands second:** #819 sets `sections = {}` when the work tree's `.gitmodules` holds a value git dies on. #820 passes those sections to `_check_removals_staged` and to `_init_stop_said`, as it did before this commit. There, a `git rm --cached` staged removal would also print `git checkout HEAD -- .gitmodules` (rv820's note), and by reading the code, not measured, `_init_stop_said` would find no section there and name the first gitlink as the stop. Whichever lands second should read that state as git does.

CI run 39031276097 (git 2.55.0) was red on one test, `tests/test_validate_family_typed_states.py::test_a_pin_no_remote_holds_is_bumped_as_printed`, at `:527`. The fetch the remedy names exited 0 (`From .../remotes/IRRS`, then `* branch <sha> -> FETCH_HEAD`) where the test expects exit 128 and `not our ref`.

**The cause is the test, not git 2.55 and not the remedy.**
- The three pin tests share one remote, `remotes/IRRS.git`, because `family` is module-scoped (`tests/test_family.py:91`).
- `commit_ahead` built the same commit on every call: the same parent, the same `AHEAD.md` (`ahead\n`), the message `Ahead` and the same identity. Two calls in the same wall-clock second therefore made one sha.
- The on-no-branch test pushes its commit to `refs/w762/ahead`. When the no-remote test's commit was made in the same second, that remote already held it. Protocol v2's upload-pack serves any object its repository holds, so the fetch fetched the "unpushed" commit. The gate had just found the commit missing from the member's own store (`rev-parse --verify`), so the remote was the only place it could have come from.
- **Measured:** with `GIT_AUTHOR_DATE` and `GIT_COMMITTER_DATE` exported fixed, head 1038d7f fails the same way on git 2.43.0 and on git 2.47.1: 1 failed, 2 passed, the same assertion at `:527` and the same stderr. Run normally, it passes on both (3 passed). On this host the two commits are 2.2 s apart (three runs), so only a faster runner puts them in one second. The CI log has no per-test times, so the second itself cannot be read from it.
- The on-no-branch test could pass while proving nothing in the same way: with fixed dates, its commit was the on-a-branch test's, which sits on `refs/heads/w762-ahead`.
- rv820 (lane 2) found the same cause independently.

**What the commit changes** (`tests/test_validate_family_typed_states.py` only):
- `AHEAD.md` now holds `next(AHEAD)`, a module counter. It is not `tmp_path.name`, which `tmp_path_retention_policy=failed` hands to the next test (`test_family_bootstrap_gate_order.py`'s `BRANCHES` gives the same reason). A first try with `tmp_path.name` failed the new premise check under fixed dates for exactly that reason.
- The commit carries fixed dates, `AHEAD_DATES`, so it is the bytes that keep two calls apart in every run, never the clock.
- **The premise is checked before anything is typed:**
  - the remote-holds tests check that `git branch --contains <commit>` in the remote lists their ref alone, or nothing for `refs/w762/ahead`;
  - the no-remote test checks that `git cat-file -e <commit>` in the remote exits 1.
- The remedy text is unchanged. Its fetch is right where a remote holds the commit, as the CI run's remote did. Its `switch` and `bump` lines still type clean as printed on git 2.43.0 and 2.47.1 (the whole file passes on both, below).

**Tests**
- `tests/test_validate_family_typed_states.py`, whole file: 31 passed on git 2.43.0, and 31 passed on git 2.47.1.
- The three pin tests with the dates exported fixed: 3 passed on each git, where head 1038d7f gives 1 failed, 2 passed.
- No other file imports this one (`git grep`).
- The trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`): 146 passed.

**Mutants** (the three pin tests, run normally):
- `AHEAD.md` back to `ahead\n`: 2 failed, on the on-no-branch premise and the no-remote premise. **Caught.**
- The on-a-branch / on-no-branch premise inverted: 2 failed. **Caught.**
- The no-remote premise expecting exit 0: 1 failed. **Caught.**
- `IDENTITY_CONFIG` dropped from the commit: 3 failed, because the runner's identity is blank. **Caught.**
- The fixed dates dropped: 3 passed. This survives by design, since the counter alone keeps the commits apart. The fixed dates are what make the first mutant fail in every run: with both the counter and the dates reverted, the three tests pass on this host.

The merge with #747's current head ff7c59b is clean (`git merge-tree`). No other branch on origin holds this test file.

**What was wrong.** `mkdir Members && git mv members/IRRS Members/IRRS` (git needs the parent; without it `git mv` dies `renaming ... failed: No such file or directory`) moves the gitlink, the checkout and the section's `path` to `Members/IRRS`, under the section name `members/IRRS`. The refusal's two lines put the gitlink back and left the section naming the variant. Typed on base: a second round (`member-gitmodules-absent` and a `gitmodules-path-unnamed` with no command), then a third (`member-uninitialized`).

**What changed.** `_move_context` reads HEAD's `.gitmodules` (what a clone reads). Where no section gives the row's path:

- a section that gives a variant's path is given the row's path back, `git config -f .gitmodules submodule.<name>.path <row path>`;
- where none gives a variant's path either (#363's state with the section gone), the section is written as `family.py add` writes one (`_section_lines`, #747's builder);
- then `git add -- .gitmodules`, and the commit takes all of it.

The same text is printed members checked and under `--no-members` (`member-gitlink-unnamed`), since both build it with `_wrong_case_move`.

**The update now comes last.** A checkout `git mv` moved keeps a `.git` file naming the member's own store, `.git/modules/members/IRRS`, with `core.worktree` pointed at `Members/IRRS`. Measured: `git submodule update --init --recursive` re-points that store at `members/IRRS`. After it, `git -C Members/IRRS status`, which the text gives for "what is not committed is in the checkout alone", reads `members/IRRS` and lists nothing. An uncommitted `WORK.md` in the moved checkout is listed before the update and gone from the listing after it (`test_the_update_comes_last_where_git_mv_moved_the_checkout`). So the sentence about the update moved from before the directory handling to the end, with the reason: "Run it last: a checkout git mv moved to a variant names the member's own store, which that update points at members/IRRS, so `git -C Members/IRRS status` reads members/IRRS after it."

Typed on this head, on both disks (`core.ignorecase` false and true): the lines and their commit, the guarded `mv` (which moves nothing, as the directory is a checkout), the checkout deleted by hand as the text says, then the update. `git submodule update --init` exits 0, HEAD's section says `path = members/IRRS`, and the gate passes in every mode.

**What was wrong.** With `Members/IRRS` skip-worktree, `git rm -r --cached --ignore-unmatch -- Members/IRRS` exits 0 and removes nothing. That held in a cone checkout (`set --cone members scripts contracts .github`) and a non-cone one (`set --no-cone '/*' '!/Members/'`). The next line then leaves two gitlinks, and the gate finds the stray.

**What changed.** `git ls-files -t`'s `S` marks the variants that need it (as `family.py`'s `_skip_worktree` reads it). Only there, the line says `git rm -r --cached --sparse --ignore-unmatch`, and the prose says it "needs git 2.34 or later". Everywhere else the line is unchanged, word for word. Typed in both kinds of checkout: one gitlink, at the row's path, and the gate passes in every mode.

**What was wrong** (measured here; the issue had read it from source). A file the index holds AT `members/IRRS` was replaced by `git update-index --add --cacheinfo` without a word (exit 0). Committed, HEAD then records the gitlink, the file is gone from the index, and the gate refuses `member-uninitialized` over the file left on disk. A file UNDER it stopped that line, `'members/IRRS' appears as both a file and as a directory` (exit 128), after `git rm` had taken the variant out. Committed, the member was un-registered (`member-gitlink-absent`).

**What changed.** The text names the entry and says to move it aside first, with a committed `git mv`: `git mv -- members/IRRS <new name>`, a new name outside `members/`, committed on its own, as `family.py`'s `_members_is_a_file` says for a file at `members`. Only then come the move and its commit. Entries a merge left unmerged are not counted; the move resolves those. Typed with `<new name>` filled in, for a file at the path and a file under it: the file is kept at its new name, the update checks the member out, and the gate passes in every mode.

**Decision: a finding (exit 1), in every mode, not a note.** `recorded_gitlink` reads the index first because "the index is what the NEXT COMMIT will record", and that holds here: the next commit un-registers the member under its row, and the gate then refuses `member-gitlink-absent`. Each question the row asks can still be put to HEAD, so this is not a refusal. A clean checkout (CI's) never holds one, so CI is unaffected.

**What was wrong.** After `git rm --cached members/IRRS`, staged: `family ok` members checked, with `--pins` and with `--no-members`. After a plain `git rm members/IRRS`: members checked and `--pins` refused `member-uninitialized`, whose update died `No url found for submodule path` (the section was gone from the work tree too). `--no-members` said `family ok`.

**What changed.** `_check_removals_staged` names each row whose gitlink HEAD records and the index holds nothing at (`_staged_removals`, #747's reading). It prints:

- to stay: `git reset -q -- members/IRRS`. Where a plain `git rm` also took the section out of the work tree's `.gitmodules`, it adds `git checkout HEAD -- .gitmodules`, and says this drops any other change to that file. Where the checkout is gone, it adds `git submodule update --init --recursive -- members/IRRS`, since that update dies while the section is gone.
- to go: the same, then `family.py remove` from a checkout of openRepoShape, on a branch (`_switch_line`, with `BRANCH_LEFT`, as `member-duplicate-id` prints it). `remove` refuses a staged removal (its `git submodule deinit` finds no gitlink) and a work-tree `.gitmodules` that is not the index's (`gitmodules-unstaged`, measured), so the put-back comes first.

A member not checked out whose removal is staged is not also refused `member-uninitialized`. Both ways were typed for `git rm` and for `git rm --cached`. Staying leaves nothing to commit and passes in every mode. Going leaves one member and passes in every mode. Committed instead, the removal is `member-gitlink-absent` as before.

rv681's P2 states, rebuilt here: a new commit of the member made in a scratch clone of its remote, with the holder's gitlink, pin and digest moved to it and nothing fetched into the member's store.

- **A remote holds it**, on a branch or on no branch (`refs/w762/ahead`). `git -C members/IRRS fetch origin <commit>` brings it in, and the gate passes in every mode. A plain `git -C members/IRRS fetch` did not bring the no-branch one, so the text names the commit.
- **No remote holds it.** That fetch exits 128 `upload-pack: not our ref`, and so does `git submodule update` ("Direct fetching of that commit failed").

The remediation now says both, then THE LOCKSTEP RULE: `family.py bump --family-root <holder> --member IRRS --to <commit>` and the `switch -c family/bump-irrs` before it. Typed from the holder's tracking branch, with a commit the remote holds as `--to`, the gitlink and the pin are back in step and the gate passes in every mode. `--no-members` still says `family ok` over such a pin: that is the mode's contract (it prints `N member(s) SKIPPED`), as the issue says.

**What was wrong.** These states were measured in a plain clone, after `git submodule update --init --recursive` had died 128 `No url found for submodule path '<p>'`:

- S1: a stray `Members/IRSS` beside `members/IRSS`;
- H1: a hand mount at `members/IRQS` with no row and no section;
- U1: IRSS's section with no url;
- H2: a gitlink at `vendor/IRQS`;
- D1: a second section `dup` giving IRSS's path with no url.

In each, members checked and `--pins` refused `member-uninitialized` for the first row, with that same update as the remedy. `--no-members` named S1, H1 and D1 (`gitmodules-path-twice`), and said `family ok` over U1 and H2.

**What changed.** `_init_stop_said` asks, as the family bootstrap's `init_stop` does, which gitlink git's init stops at. It reads the index in index order, the work tree's `.gitmodules`, and the git config (`submodule.active`, `submodule.<name>.url`). The refusal then says so before its update, and gives **bootstrap's own cure for that state, word for word** (the tests import #682's `UNNAMED_CURE`, `SECTION_CURE`, `TWICE_CURE` and `OUTSIDE_CURE`):

- `--no-members` for a gitlink in `members/` that no row names, or for a path two sections give;
- the section written back, for a row's own path;
- `git rm --cached -- <p>`, for a gitlink outside `members/`.

Each was done as it says and committed. The update then checks both members out, and the gate passes in every mode.

**U1 is its own finding**, `member-gitmodules-url-absent`, in every mode. It is made where exactly one section of HEAD's `.gitmodules` gives a row's path and gives no url, and it prints the url line `family.py add` writes. A path two sections give stays `gitmodules-path-twice`'s. Typed, staged and committed in the holder it was made in, the gate passes. In a recursive clone of it nothing is checked out at `members/IRSS`, and the clone's `--no-members` names it.

**No misfire.** In each of these the refusal keeps the plain update:

- a good holder's plain clone;
- a recursive clone whose members were not fetched (`submodule.active` is set, so git's init asks only what a section gives, and a stray stops nothing);
- a clone where an earlier `git submodule init` registered the urls before the work tree's section lost one.

**A simpler reading than bootstrap's.** What bootstrap also keeps of git's parser (a name with `..`, a url starting `-`, a `.gitmodules` a merge left unmerged) is not read here. Where nothing is found, the plain remedy stands, as before.

The finding said "a recursive clone of this holder fetches it" for every gitlink no row names. Measured (w648b's S2 and X1): a recursive clone of a stray or of a hand mount with no section leaves an empty directory there. It now says that where no section of HEAD's `.gitmodules` gives the path a url: "a recursive clone of this holder checks out nothing at it, in a plain clone git submodule update --init stops there (No url found for submodule path, exit 128) before it checks out any member". Where a section gives the path a url, it keeps "fetches it". A hand mount made with `git submodule add` is checked out by a recursive clone (tested). The variant a `git mv` leaves keeps its section's url, so its finding keeps "fetches it".

#682's second fix round, `23e11056`, replaced "members checked, it refuses `member-uninitialized` for the first row there instead" with "...for the first row where every row's gitlink is there, as with a stray or a hand mount, and `member-gitlink-absent` where a row's is only in another case". That is the issue's reword, and it is still true, so `bootstrap.py` is not touched. `git log -S"for the first row there" -- templates/family-root/scripts/bootstrap.py` shows `23e11056` and `1a69505d`.

New: `tests/test_validate_family_typed_states.py`, 31 tests. Changed helpers, each a text the gate now prints:

- `tests/test_family_head_lost_gitmodules.py`: `variant_refused` takes the update sentence at its new place, and keyword arguments for `--sparse`, an entry aside and the section lines;
- `tests/test_validate_family_unnamed_mounts.py`: `GITLINK_SAID` is the no-url text, and `GITLINK_SAID_FETCHED` the old one, used by the three cases whose gitlink has a section with a url;
- `tests/test_validate_family_remedies_typed.py`: the hand mount made with `git submodule add` uses `GITLINK_SAID_FETCHED`.

**Runs**, all through `run9.sh` at head `1038d7fa`, on Linux:

- the new file: 31 passed;
- the issue's Verify files and every file that runs the validator or imports the changed helpers, in two batches. Batch 1 (23 files: the new one, `test_family_head_lost_gitmodules.py`, `test_validate_family_unnamed_mounts.py`, `test_family_bootstrap_gate_order.py`, `test_validate_family_remedies_typed.py`, `test_family_members_case_variants.py`, `test_pinned_copy_text.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, the `test_validate_family_*` files, `test_family_switch_branch_exists.py`, `test_branch_left_bump_leg_setup.py`, `test_family.py` and the family add, remove, dry-run and member-path files): 916 passed and 3 failed on the uncommitted tree. The three were `test_family.py`'s `update-shape` tests, whose fixture pins HEAD while its copies come from the work tree. On the committed head, `test_family.py` alone: 106 passed. Batch 2 (24 files: the doctor's, `test_shape_doctor.py`, the `test_update_shape*` files, the family bump and init files, the template and pin-row files): 1609 passed, 23 skipped.

**Fail on base:** 26 of the 31 new tests fail on `58d6c50f`'s validator (the new test files laid over a clone of it). The other 5 are no-misfire guards and pass there by design: a removal once committed, a holder whose update stops nowhere, a recursive clone not fetched, a url an earlier init registered, and a hand mount with its section.

**Mutants**, one per hunk of the validator, each run against the new file. 26 mutants, all caught:

- the removed-gitlink return;
- no move context, members checked and under `--no-members`;
- sparse not read, and `--sparse` not printed;
- entries in the way not read;
- the variant's section not rewritten, no section written, and no `git add` line;
- the update sentence moved back to where it was;
- no stop said, the stop never computed, and each of its cures dropped (two sections, a row's own, `members/`);
- `submodule.active` not asked, and the config url not asked;
- the commit-unresolvable remedy dropped;
- the row's repository not threaded;
- every unnamed gitlink said fetched, and none said fetched;
- the url-less section not a finding;
- the staged removal not named, the `.gitmodules` and checkout lines dropped, and the staged path not kept from `member-uninitialized`.

`git merge-tree --write-tree` of `1038d7fa` merges clean with:

- origin/main `6794a9f9`;
- #747 `58d6c50f`, #682's branch `5afb24b9` and #745 `f4935e11`;
- every wave-7 branch: `fix/768-gitmodules-values` (`f74f640c`, no commits of its own yet), `fix/761` `bbb961f8`, `fix/764` `271710ba`, `fix/757` `6b5e9074`, `fix/758` `b6e15591`, `fix/759` `052410b6`, `fix/760` `7569ffa8` and `fix/777` `854f6ed9`.

Eight older branches that touch `validate-family.py` or these tests conflict, but each conflicts with main itself (squash-landed or stale): `fix/539-…`, `fix/607-…`, `fix/610-…`, `fix/family-add-ghost-section`, `fix/family-bump-remove-rollback`, `fix/pin-files-not-a-list`, `fix/validate-family-member-duplicate` and `fix/validate-family-unnamed-mounts`. `fix/611-…` merges clean.

- A STRAY that a sparse checkout keeps off disk: its `git rm --cached -- Members/IRRS` (`_second_path_out`) exits 1, with git's own hint "Use the --sparse option". It fails loudly, outside the move back. It is a checklist candidate below.
- The folding disk was emulated with `core.ignorecase=true` for every git the tests type. No macOS or Windows run was made (CI is Linux only, #597). In particular, (g) on a disk where `members/IRRS` resolves to the variant's directory was not run.

- **The skip-worktree STRAY's `git rm --cached`.** `member-gitlink-unnamed`'s line for a second path to a member's directory (`_second_path_out`) has no `--sparse`. Where a sparse checkout keeps that path off disk, it exits 1, "paths ... outside of your sparse-checkout definition", with git's hint "Use the --sparse option" (measured, git 2.43). It is loud, not silent like (f).
- **The section finding in the `git mv` state.** `gitmodules-path-unnamed` for the section `git mv` rewrote says "no command is printed: taking this section out could un-register it" and then RESTORE_ROW. Meanwhile the gitlink's move now gives that section its path back. It could say so, as the hand mount's section says "the git rm printed above ... takes this section out too".
- **`family.py add`'s move back (#761's text).** It prints its update sentence before the directory handling. After a `git mv`, the moved checkout shares the member's store, so `git -C <variant> status` after that update reads the row's path (measured here for the validator's text). This is for #761's writer.
- **`_init_stop_said` reads `.gitmodules` more simply than the family bootstrap's `gitmodules_sections`.** It does not read names with a `..` component, urls starting `-`, or a `.gitmodules` a merge left unmerged (rv648's Q1, O1, MM1). There, the refusal keeps the plain update, which dies. A shared reader cannot be imported across the two pinned files (the skew rule).

Review: lane 2's independent review (issuecomment-6236581089) and its re-verification of 98b0536, READY (issuecomment-6237751102). CI (Brett Heap's ruling 27): H0 is the head 98b0536 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base d66a905; and the local gate passed on its predicted squash tree 7896978 over main 7b21059: the family_head_lost_gitmodules, validate_family_remedies_typed, validate_family_typed_states, validate_family_unnamed_mounts, validate_workflow_gitmodules_urls, repo_hygiene, agents_md_codes, cli_reference sets, 681 passed, 27 skipped. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #762

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…s docs say its parent folder is named EXACTLY after its repository, case included, on every disk, and the never to hand-edit the `members:` block names the row repair `validate-family.py` asks for, whose commit is the person's (#763) (#822)

Two sets of words now agree. Words only: no tool, no remedy and no --help
text changes.

The folder rule (N6 of #695's review). The holder's README and its pinned
AGENTS-shape.md said the parent folder was "not named after the family",
which reads as a match that ignores case. siblings.py compares the name
exactly and has a separate branch for a folder that differs only in case.
Both now say what the standard's README and the handbook say since #738: the
folder must be named EXACTLY after its repository, case included, on every
disk. The README is rendered once at `init`, so it reaches new holders only;
AGENTS-shape.md is in FAMILY_COPIED_VERBATIM, so it rides in the shape bump
as one `upstream-changed` row.

The hand repair of a row. AGENTS.md and AGENTS-shape.md said never to
hand-edit family.yaml's `members:` block, and validate-family.py tells a
person to in two cases: RESTORE_ROW (a row deleted, or its `path:` edited,
by hand is put back as git last had it) and `member-gitlink-absent`'s (#681,
the row of a member that is to go is taken out). The issue's first option is
taken, so no remedy changes: the never stays for the normal path, where
`add`, `bump` and `remove` rewrite the block, and each doc says the
validator may name a repair of a row and that the commit is then the
person's. AGENTS-shape.md, which has the room, names both cases. AGENTS.md
is at its cap of 492 lines, so item 3 was reflowed to cost no line: "on a
branch you make first (see above): the tracking branch is refused (code)"
became "on a branch made first (code refuses the tracking branch)" and "Push
the branch" became "Push it". No other sentence of the file moved.

AGENTS-shape.md also says its layout's folder is named after this holder's
repository (it said "the family", which `--name` makes a display name), and
its folder rule opens "This holder's parent folder", the folder being no
repository.

tests/test_family_docs_one_spelling.py gains 15 tests. Each doc's hand-edit
passage is held whole, from its never to whose commit a repair is, as
ONE_SPELLING is, so "but for a row repair" cannot turn into "not even for"
and pass; the folder rule, subject and "must" included, is read in the
standard's README, the handbook, the holder's README and AGENTS-shape.md;
`member-gitlink-absent` is raised through `_check_member_gitlink` and its
code and remedy are read together; `RESTORE_ROW` is read from the validator,
and so is its inline copy in `gitmodules-path-unnamed`. A remedy that stops
telling a person to edit a row, or a copy that says another thing, fails
there and takes the docs' exception with it.

It follows lane 5's #747 (in v1.1.0, as 6626a3c) and #738 (as
e62b5e1); #682 has landed too.

Closes #763

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

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

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

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

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

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

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

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

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

### Each finding

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

### Tests

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

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

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

### Fails on base

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

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

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

### Mutants

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

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

### Merge checks

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

### Checklist candidates

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

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

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

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

### Line 1: bootstrap

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

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

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

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

### Line 2: the gate

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

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

Decisions:

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

### Line 3: what #682 left approximate

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #768

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…an unreadable upstream path, cache or pin is no answer and a reftable leg's cherry-pick or revert is read from its ref store, so no traceback or ended pick follows (#764) (#815)

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

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

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

**What each NIT came to**

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

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

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

**NIT 1 is not taken, because it changes an exit path on Linux.** The change is `_try_git(["-C", str(Path(upstream).absolute()), "rev-parse", "HEAD"], root)`. Its point (no process starts inside the upstream directory, so a Windows `cwd` that `CreateProcess` refuses cannot be read as git's own `PermissionError`) is reasoned, and cannot be measured on Linux. I measured the Linux side: the old `_clone_tip` and the new one run through `shape_upstream_notice` over 22 values of `SHAPE_UPSTREAM_PATH` and two `PATH`s (git, and an empty directory), 44 rows:
- the values: a clone by absolute path, with a trailing slash, by relative path from the root's directory and from another, through `link/..`, a subdirectory of a clone, a directory named `-x`, a bare repository, an unborn `HEAD`, a plain directory, a file, a missing path, a name with spaces and a newline, a name with a non-UTF-8 byte, an over-long name and path, the empty string, a mode-000 directory, one under it, and a clone under a directory with no search bit;
- 43 rows are byte for byte the same (the notice, or the same refusal);
- **1 row differs:** no git on `PATH` and an upstream directory nobody may enter. The old code answers "no clone" (its `PermissionError` names the directory, which `_git_runs` passes on), so the cache speaks; the new code starts no process there, finds git missing, and refuses `git-not-runnable`, exit 2.

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

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

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

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

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

**Fails on the head this round started from** (`f6f318d1`, the two touched files with the new tests copied in): 2 failed, 24 passed. The two are the NIT 3 cases. NIT 2, 5 and 6 hold behaviour `f6f318d1` already has, so they cannot fail there; they fail on the earlier bases:
- on main (`24a977c0`), both over-long cases fail with `OSError: [Errno 36] File name too long` out of `Path.is_dir`;
- on `271710ba`, the plain-clone-with-no-git case fails: `bootstrap FAILED: pins (exit 2)`, exit 1;
- the no-git-and-a-clone-named case passes on every base, by design: it holds #694's behaviour, which step (d) now describes.

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

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

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

`git merge-tree --write-tree` from `05a1ebb3` on 2026-10-17 at about 17:50Z:
- **origin/main `42aed788`: clean.** It would be tree `1ba653a4`.
- **#824 (`fix/710-non-utf8-branch-names`, `0a5e82bf`): clean.** Both change `bootstrap.py`; the hunks do not overlap.
- **Every `fix/*`, `l3-*` and `docs/*` branch on origin besides this one (55, #824 among them): 51 clean, 4 conflict.** The four, `docs/release-notes-v1.0.0`, `fix/758-git-command-scope`, `l3-677-pins-hand-step-wip` and `l3-678-naming-checkout-wip`, each conflict with main itself, in files none of which this commit changes.

- **NIT 8: `_cached_tip` reads a cache that starts with a byte-order mark as no answer.** Measured: a `.shape-upstream-tip` of `EF BB BF` then a 40-hex commit reads back as `'\ufeff0123...'`, which is not a commit, so the notice stays silent over a cache a Windows editor wrote. `encoding="utf-8-sig"` in `_cached_tip` would accept it. Outside #764; the same decode question as #710's. **Done since by the merge round with main (`61aed8d2`):** #855 (`3059efc1`) reads the cache as `utf-8-sig`, and the merge carries that into `_cached_tip`. #855's tests in `tests/test_bom_reads.py` pin it, and a mutant back to `utf-8` fails three of them.

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

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

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

### C1: the upstream notice and a directory nobody may enter

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

- **The directory itself.** `subprocess` raises the error for the `cwd` of `git rev-parse HEAD`, naming the directory. `_git_runs` passes it on, since it is not git's (#694).
- **A directory under one.** Python 3.12's `Path.is_dir` raises it (3.13 too; 3.14 answers False).

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

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

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

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

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

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

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

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

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

**The defect, reproduced** with that git on `f4935e11`:

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

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

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

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

The refusal now says where git holds the marker:

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

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

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

### C3: the reference row

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

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

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

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

### C2: option B, on Brett Heap's ruling

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

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

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

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

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

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

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

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

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

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

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

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

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

**Runs on head** (`c2977425`):

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

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

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

**Fails on base for C2** (`271710ba`, `tests/test_bootstrap_refusal_exit.py` copied in): **3 failed, 1 passed, 9 errors** of the 13 cases.
- The refusal alone fails: `bootstrap FAILED: pins (exit 2)`, exit 1.
- The mixed run fails: it printed `bootstrap FAILED: manifest (exit 1), pins (exit 2)` and exited 1.
- `make bootstrap` fails: it said `Error 1`.
- The nine `_end_of_run` cases error, since the function is not there.
- The finding alone passes, by design: it is the control.

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

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

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

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

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

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

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

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

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

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

- The three "or says why not" pinned sentences (C3, above).
- C5 (#710's decode).

- **Step (a) and step (c) of the assembly root's bootstrap end in a traceback over a folder nobody may enter**, on Python 3.12 and 3.13.
  - `checkout_tracking_branch`'s `(sub / ".git").exists()` for a leg directory of mode 000 raises `PermissionError: ... spec/.git`. `_git_runs` passes it on, since it names a path.
  - `_register_paths`'s `is_file()` under a `governance/` of mode 000 raises one too, though step (c)'s docstring says the readout "never fails".
  - Both measured on this head. The same class as #430's `Path.is_dir` sweep, which may already hold them.
- **`validate-pins.py` with a `contracts/` nobody may enter** ends in a `PermissionError` traceback at `_load_leg_pin`'s `pin_path.is_file()`, exit 1 (measured on this head). The validators' half of the same class.
- **Two texts still say "failed" over a bootstrap that now REFUSED.** `setup-project.py` step (7) ends `bootstrap failed (see above). The clone is at <path>.`, and `siblings.py` says `<project>: its own bootstrap exited <code>; the clone is there and its legs may not be placed`. Both carry bootstrap's code (now 2 for a refusal), and the REFUSED line is printed above each. Neither is false; a person reading the last line only could take a refusal for a finding. Cosmetic, probably not worth an issue.

Review: lane 3's RV815, READY on f6f318d (issuecomment-6237425616), and its re-check of the NIT round 05a1ebb, READY with nothing left (issuecomment-6238438057). `61aed8d2` is the merge round with main after #855 (one conflict in `bootstrap.py`: `_cached_tip` kept, reading `utf-8-sig`); rv815m's independent re-check found it READY with no MUST or SHOULD (issuecomment-6247998538), and its three body NITs are fixed. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 61aed8d passed tests, tests-git-bash and SonarCloud; the head 9997641 is a clean update-branch merge of main a249910, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree fb09e18 over main a249910: the adopt_manifest_values, bootstrap_placement_texts, bootstrap_ref_store, bootstrap_refusal_exit, bootstrap_upstream_notice, github_limits_init_check_org, reader_refusals_named, scaffold_manifest_values, repo_hygiene, agents_md_codes, cli_reference sets, 498 passed, 3 skipped. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #764

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 23, 2026
…y a member's failed clone ends git's update with 1 before any checkout but a leg's lets it go on, and `killed_said` says git's update was itself killed, not that it went on (#1085) (#1088)

The two texts #1076 (#1056) left in the family root's `bootstrap.py` say what git does. A member's clone that fails ends git's update before any checkout, where a leg's lets it go on, and a run whose git was itself killed is not said to have gone on. This is #1085, found by rv1076, lane 5's independent review of #1076 (its NITs 1 and 2).

Round 1 was one commit, on main `c13c3985` (where #1076 landed); round 2 (below) adds a second. It changes two files: the family root's `templates/family-root/scripts/bootstrap.py` (docstrings, and one line of `killed_said`) and `tests/test_family_bootstrap_stops_named.py` (one row). 2 files, 55 insertions, 14 deletions.

### NIT 1: two docstrings, no behaviour

- **`credential_advice`** said "a clone that fails, a member's or a leg's, gives 1, and git's update goes on". That is true of a leg's clone and not of a member's. It now says:
  - a member's fetch that fails ends the update with 128 (as before);
  - a member's clone that fails a second time, git having retried it, ends the update with 1 before any gitlink at that level is checked out (`quickstop`, git's `update_submodules`);
  - a leg's clone that fails gives its member's update 1, and git's update goes on (rv996's NIT 2 on #1045, kept).
- **`ended_on`** said `128 + N` is the update's code "where a git it ran died of signal N ... and no gitlink after it failed". A member's `git submodule--helper clone` that dies of signal N never gives that code: git retries it, and where the retry fails too the update ends in `quickstop`'s 1, even with nothing after it. The docstring now says so, and what that reads as:
  - **traced (no token in use):** `died_in` names the signal, since `git submodule--helper clone` is a git the update ran, and `signal_met` asks the trace first;
  - **with a token in use** (the update is not traced, #996): the update's code is 1, which reads as the credential's skip, the advice and `ok`, the limit `credential_advice` already says for a signal before a later failure.

### NIT 2: `killed_said`, one line

Where git's trace gave a git's signal (`Died(9, 137)`) and git's update returned `-9` (Python's code for git itself killed), the line said:

```
a git that git's update ran died of signal 9 (SIGKILL, exit 137), and git's update went on and ended with exit -9. Git's own words above say which git and where.
```

git's update did not go on and end: it was killed. It now says:

```
a git that git's update ran died of signal 9 (SIGKILL, exit 137), and git's update was itself killed (exit -9). Git's own words above say which git and where.
```

Every other line is byte for byte as before. I compared main's and this head's `killed_said` over 23,814 `(Died, returncode)` pairs (for each update code from -70 to 299: the update's own `ended_on` signal, where it gives one, and a trace's signal N = 1 to 64 with code `128 + N`), with `ended_on` itself:

- `ended_on` differs for 0 codes of -70..299;
- the pairs where the code is the update's own (the 134 codes that give a signal, #998's texts): 0 differ;
- the pairs where a trace's signal and a positive update code differ: 0;
- 4,480 pairs differ, each with a negative update code and a trace code (70 codes x 64 signals), and each now says "was itself killed (exit -N)".

(A script that loads main's and the head's `bootstrap.py` from `git archive` copies; its output is in lane 5's scratch notes, `v118/w1085/runs/probe-texts.txt`.) The failure line `(a git it ran died of signal N)` is unchanged, as it is true.

### Texts that pin those words

A grep of `tests/`, `README.md`, `docs/` (the handbook included), `AGENTS.md` and `contracts/` for "went on and ended", "killed_said", "went on", "member's or a leg's", "goes on (rv996", "UPDATE KILLED" and "quickstop" finds the words only in `bootstrap.py` and in `tests/test_family_bootstrap_stops_named.py`. There, `WENT_ON` pins "went on and ended with exit {ended}" with `ended` 128 and 1 (real runs, a positive code), which is unchanged and still passes. The README, the docs and the handbook say none of them.

The stops-named file's module docstring gains one paragraph for the new row.

I read, and did not run (no network clone, no real gh, no token):

- **git 2.47.1**: the local build tree of the git the lane's runs use first on PATH (`builtin/submodule--helper.c`, `run-command.c`, `trace2/tr2_tgt_event.c`, `usage.c`, `git-submodule.sh`).
- **git 2.43.0**: upstream's `v2.43.0` tag, the same five files (`builtin/submodule--helper.c` is byte for byte the copy an earlier lane 5 writer kept, w648c on #682, sha256 `9383338c...`; the other four I fetched as raw files from the tag, a read, not a clone). `/usr/bin/git` here is Ubuntu's `2.43.0-1ubuntu7.3`, which I did not read the patches of.

| claim | source (2.47.1 line, 2.43.0 line) |
|---|---|
| a member's clone is a `git` child, `git submodule--helper clone` | `prepare_to_clone_next_submodule`: `child->git_cmd = 1`, args `submodule--helper clone` (:2141-2146, :2053-2058); trace2's `child_start` writes `git` first for a `git_cmd` child (`tr2_tgt_event.c` :358-361, :359-361) |
| a child killed by signal N gives 128 + N | `wait_or_whine` `code += 128` (`run-command.c` :576, :569); `finish_command` hands it on and writes `child_exit` (:986-993, :976-983) |
| that code never reaches the update's own | `pp_collect_finished` passes it to `update_clone_task_finished` (:1700-1703, :1690-1693), which answers 0 ("Retry scheduled", :2265) the first time and, the second, sets `quickstop` and answers 1 ("a second time, aborting", :2276-2280; 2.43.0 :2185-2189) |
| `quickstop` ends the update with 1 before any checkout | `update_submodules`: `if (suc.quickstop) { ret = 1; goto cleanup; }` (:2737-2740, :2621-2624), before the loop that runs `update_submodule` for each gitlink |
| a member's fetch that fails ends it with 128 | `run_update_procedure`: `return die_message(...)`, and `die_message` returns 128 (`usage.c` :230-238, :227-235); the loop stops at 128 only (:2772, :2647) |
| a leg's clone that fails gives its member's update 1 and git's update goes on | the same `update_submodules` runs in the member's own `git submodule--helper update` (`update_submodule`: `ret = run_command(&cp)`, :2700, :2584, which discards `die_message`'s 128 and returns the member's 1); the holder's loop keeps going on any code but 128 |
| `git submodule update` passes the helper's code on | `git-submodule.sh`: the helper is `cmd_update`'s last command, no `\|\| exit` (:380-403, :341-363) |

Two offline probes, local paths only (no network, no gh, no token, `git config` never run, `GIT_CONFIG_GLOBAL=/dev/null`; lane 5's scratch notes, `v118/w1085/probe/quickstop.sh`, with its outputs `out243.txt` and `out247.txt`), on git 2.43.0 and 2.47.1, give the same on both:

| state | git's update | what is in the work trees |
|---|---|---|
| holder with `members/A`, `members/B` (a url that is not there), `members/C` | exit 1, `Failed to clone 'members/B'. Retry scheduled`, then `a second time, aborting` | A and C cloned, **none checked out** (`.git` only), B empty |
| holder with `members/M` (its leg `legs/L2` has a url that is not there) and `members/N` after it | exit 1, `Failed to clone 'legs/L2'. Retry scheduled` | **M and N both checked out** (git's update went on past M's 1); M's leg L1 cloned, not checked out |

**Not run:**
- `bootstrap.py` itself over a member's clone killed by a signal. Git's update over one was measured in round 2 below (rv1088's probe, which I ran again), and the trace's `child_start` argv for it is from `tr2_tgt_event.c`;
- git 2.43.0's remote helper or any network clone;
- Windows and macOS (CI is Linux only, #597).

One thing I read and left alone, as the brief says every other line stays byte for byte: where the trace names a signal and the update's code is a positive 1 from `quickstop`, `killed_said` still says "git's update went on and ended with exit 1". It ended there rather than went on. It is a wording nit of the same kind, not a behaviour; I did not change it here.

`test_an_update_itself_killed_after_a_signal_is_not_said_to_go_on` (new; 48 rows before, 49 after). It loads the family root's `bootstrap.py` as a module (`loaded`, from `tests/test_family_holder_bootstrap_texts.py`, which the stops-named file already imports from) and calls `killed_said(Died(9, 137), -9)`, as `signal_met` hands it its arguments. No run here reaches that line short of the OOM killer taking a checkout and later the top `git`. One assert, `said == KILLED_AFTER.format(...)`, no `and` or `or`.

Main's `bootstrap.py` bytes (sha256 `f1c156e2...`) against this branch's 49-row file, git 2.43.0: **1 failed, 48 passed**. The failure, from `v118/w1085/runs/mut-mainbytes.log`:

```
E         - s update was itself killed (exit -9). Git's own words above say which git and where.
E         + s update went on and ended with exit -9. Git's own words above say which git and where.
```

The other 48 rows pass on main's bytes (the old `WENT_ON` rows say only positive codes). At this head it passes.

Each 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 `run9.sh`, the file put back from the head's bytes and its sha256 checked (`a52483c8...`) after each. Lane 5's scratch notes, `v118/w1085/runs/mutants-summary.txt`.

| | mutant | result |
|---|---|---|
| M1 | the new branch dropped (the ternary replaced by the old wording alone) | killed: 1 failed (the new row), 48 passed |
| M2 | the negative test inverted (`returncode > 0`) | killed: 7 failed, 42 passed: the new row and the six that say "went on" (both killed-checkout rows, P3, the both-named row, `two-members`, `two-legs`) |
| M3 | the old "went on and ended with exit N" kept for negative codes (the branch's text is the old wording, its test kept) | killed: 1 failed (the new row) |
| M4 | "went on" kept beside the new words (`went on and was itself killed (exit -9)`) | killed: 1 failed (the new row) |
| M5 | the test asks the trace's code, not the update's (`died.code < 0`) | killed: 1 failed (the new row) |
| M6 | the code not said (`was itself killed`) | killed: 1 failed (the new row) |

M1 and M3 behave the same (both give the old wording for a negative code) and differ in the text of the mutation only.

All through `run9.sh` (the real `gh` replaced by a stub, `GH_TOKEN` a fake, 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. At this head, `bootstrap.py` sha256 `a52483c8...`, the test file `2fb6b4dd...`.

| run | git | result |
|---|---|---|
| `tests/test_family_bootstrap_stops_named.py` (49 rows) | 2.43.0 (`/usr/bin/git`) | **49 passed** |
| the same | 2.47.1 (`git --version` printed `git version 2.47.1`; the lane's local 2.47.1 build first on PATH) | **47 passed, 2 skipped** (the two P3 rows: that git exits 128 there, where 2.43.0's crashes with 139) |
| `tests/test_family.py`, `tests/test_update_shape_kept_helper.py` (the two files `grep -l 'family-root/scripts/bootstrap' tests/*.py` gives) and `tests/test_family_holder_bootstrap_texts.py` | 2.43.0 | **328 passed** |
| `tests/test_pinned_copy_text.py` and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`), with `test_template_text_placeholders.py` | 2.43.0 | **399 passed** |
| the six mutants and main's bytes | 2.43.0 | see above |

flake8 on `bootstrap.py` shows the 3 findings main has (E501 at line 7 and 2327, E127 at 1168), and on the test file the same 120 F811 findings main's file has (the fixture imports). New lines are 79 characters at most, ASCII only.

`git merge-tree --write-tree` of this head `88af1b7b8` against `origin/main`, fetched again with `-c gc.auto=0 -c maintenance.auto=false` just before the push (it moved once while this was open: `06cbd4789`, #1081, one test file), and against each open PR that touches `bootstrap.py`, read with `gh pr list --state open --json number,headRefName,files` (9 open; #1062 touches the family root's `bootstrap.py`; #1075's files are `siblings.py` and two test files; #1087, new since the first read, touches neither):

| against | result |
|---|---|
| `origin/main` `06cbd4789` (one commit past this branch's base `c13c3985`; it touches only `tests/test_doctor_gitlink_repairs.py`) | clean, tree `a10ffa31c` (against `c13c3985` the merge was this head's own tree, `deeab934f`) |
| #1062 `fix/1040-windows-start-errors` `957ba0386` (touches `bootstrap.py`, in `git_not_started`) | clean, tree `46f25219c`; the merged family root `bootstrap.py` parses, and holds `died_in`, `quickstop`, "was itself killed (exit" and #1062's `4551` |
| #1075 `fix/1055-siblings-untraced` `041f7831b` (lane 5's: `siblings.py`) | clean, tree `02b01b276`; the merged `siblings.py` imports `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path`, `members_of` and `untraced` from `bootstrap.py`, and all six are defined at module level in the merged `bootstrap.py` |

None of the other open PRs touches `bootstrap.py` or the stops-named file. This change defines and renames nothing a caller imports.

`templates/family-root/scripts/bootstrap.py` is in `FAMILY_COPIED_VERBATIM`, so each holder takes one `upstream-changed` row for it, as for #1045 and #1076. Nothing else is pinned. No Windows or macOS run was made.

rv1088 (#1088 issuecomment-6314332205) found the review READY with one NIT: the sentence this PR added to `ended_on` said git's update retries a killed member clone, and it does not always. This round is one commit on top of `88af1b7b8` (the branch was not rebased), docstring only, one file (`templates/family-root/scripts/bootstrap.py`, +8 -4). No behaviour changes, and `killed_said`'s "went on and ended with exit 1" line is left as it is (rv1088 found it true).

**Measured, offline, on git 2.43.0 and 2.47.1** (rv1088's `killprobe.sh`: a remote helper that SIGKILLs the `git submodule--helper clone` above it; I ran both modes again on both gits and got the same):

| the member's clone | git's update | work trees |
|---|---|---|
| killed on both attempts | exit 1: `Retry scheduled`, then `a second time, aborting` | A and C hold `.git` only, B empty: nothing checked out |
| killed once, its own `git clone` child left running (it writes `members/B/.git`) | exit 0: `Retry scheduled`, then `BUG: submodule considered for cloning, doesn't need cloning any more?`, no second attempt | A, B and C all checked out |

`ended_on` now says: git's update retries a killed member clone where it left no `.git` at the member's path, and where the retry fails too ends in `quickstop`'s 1 before any gitlink at that level is checked out; where its own `git clone` lived on and wrote that `.git`, git retries nothing and goes on, and exits 0 with every gitlink checked out (measured, git 2.43.0 and 2.47.1). "Read, not run" is now "measured". `signal_met` already says nothing for an exit of 0, and `credential_advice`'s "fails a second time, git having retried it" stays true, so neither changes.

No other text pins those words: a grep of `tests/`, `README.md`, `docs/` (the handbook included) and `AGENTS.md` for "retries it", "doesn't need cloning", "never gives that code" and "quickstop" finds them only in `bootstrap.py`, and the round-1 words "(read in git 2.43.0's and 2.47.1's submodule--helper.c, not run)" are gone everywhere. "not run" alone appears in other files only in unrelated senses (rv1088's re-check). flake8 shows the same 3 findings as before (the lines moved by 4).

**Runs** at this head, through `run9.sh` with `EMAIL=service@opensoft.dev` (`GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null` set by the runner), the stops-named file and the trio at the same time:

| run | git | result |
|---|---|---|
| `tests/test_family_bootstrap_stops_named.py` | 2.43.0 (`/usr/bin/git`) | **49 passed** |
| `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py` | 2.43.0 | **170 passed** |
| `tests/test_family_bootstrap_stops_named.py` (`git --version` printed `git version 2.47.1`, the lane's 2.47.1 build first on PATH) | 2.47.1 | **47 passed, 2 skipped** (the two P3 rows, as before) |

**Merges**, `git merge-tree --write-tree` of this head against `origin/main` re-fetched with `-c gc.auto=0 -c maintenance.auto=false`:

| against | result |
|---|---|
| `origin/main` `16f9a0e81` (#1087 and the commits since this branch's base; #1062 has landed, so main's `bootstrap.py` moved) | clean, tree `b3b454168`; the merged family root `bootstrap.py` parses and holds `died_in`, `git_not_started`, "was itself killed (exit" and the new BUG sentence |
| #1075 `fix/1055-siblings-untraced` `962ae43c1` (its head moved since round 1) | clean, tree `a4c72197c`; the merged `siblings.py` imports `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path`, `members_of` and `untraced` from `bootstrap.py`, and all six are defined in the merged `bootstrap.py` |

The other open PRs touch neither `bootstrap.py` nor the stops-named file. #1088 stays a draft.

Review: rv1088, lane 5's independent opus reviewer, which wrote none of #1088: READY at `26f49d85`, H0 of this landing (issuecomment-6316937429), with no MUST or SHOULD; its one body NIT is fixed in the body. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 26f49d8 passed tests, tests-git-bash and SonarCloud; the head dedc0da is a clean update-branch merge of main 0d76e4c, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 377691f over main 0d76e4c: the family_bootstrap_stops_named, repo_hygiene, agents_md_codes, cli_reference sets, 220 passed. Landed on Brett Heap's word of 2026-10-23T13:10:26Z, relayed to every lane by lane 3 (#473 issuecomment-6365697123): "merge v1.2.1 PRs once reviewed and green".

Closes #1085

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