Skip to content

family-root README.md and AGENTS-shape.md, and AGENTS.md: the holder'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 - #822

Merged
brettheap merged 39 commits into
mainfrom
fix/763-holder-docs-agree
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-2 (openRepoShape-2)
Claim: #763 (comment)

Two sets of words now agree. Words only: no tool, no remedy and no --help text changes, so no docs/cli.md change.

  • The holder's own README and AGENTS-shape.md say the folder rule as the standard's README does: the parent folder must be named EXACTLY after its repository, case included, on every disk (the issue's line N6).
  • AGENTS.md and AGENTS-shape.md keep their "never hand-edit the members: block" for the normal path and name the hand repair the validator may ask for, with the commit then the person's (the issue's FIRST option).

v1.1.1. v1.1.0 is tagged, and both parents have landed: #747 in v1.1.0 (6626a3c5) and #738 after the tag (e62b5e17). #682 has landed too.

Stacked on

Both parents have landed, and this branch is restacked on main by lane 3's recipe (#473 issuecomment-6242331291): each parent's final head, then its squash, then main.

Parent Branch Final head Landed as
lane 5's #747 (#681) fix/681-validate-family-remedies ff7c59b8 6626a3c5
lane 5's #738 (#695) fix/695-family-docs 0b26aced e62b5e17
#682 (#648), which #747 was stacked on fix/648-bootstrap-and-gate-order 5afb24b9 dc7d3fbb (brought by #747's ff7c59b8)

The merges, oldest first, all --no-ff:

Review lane 2's own commits: 1423e5a3, 0e1e0a8d and ea86cb60. git diff origin/main HEAD (main b9b70692) names only this PR's four files, and its -U0 changed lines (235) are exactly those of git diff 4e199e41 ea86cb60 over the four files. Against the two reviewed diffs taken separately (git diff 4e199e41 1423e5a3 and git diff c228e808 ea86cb60), the only extra lines are the 76 that 1423e5a3 wrote and the fix rounds rewrote, each once as + and once as -: the folder rule's first wording in AGENTS-shape.md (6) and the first tests (70).

Tests at the restacked head bfb1cd9d (/usr/bin/python3, an empty GH_CONFIG_DIR, one pytest process at a time): the 63 files that name AGENTS-shape.md, templates/family-root/README.md or AGENTS.md, or join family-root and README.md, with test_readme_sections.py, test_readme_windows_quoting_sentence.py and the trio, in two halves: 1487 passed, 2 skipped and 2596 passed, 3 skipped. The same set on main 5fc049b7: 1472 passed, 2 skipped and 2594 passed, 3 skipped, plus two in test_ruleset_hint_tracking_branch.py that failed only because that run's basetemp path held the word main, which the test asserts its hint does not name (alone, with another basetemp: 23 passed). Nothing was red. The 15 more are this PR's: test_family_docs_one_spelling.py has 17 on main and 32 here. Main has since moved to 5a7b6113, which merges clean; on that merge's tree (80b008d2), the scanners that read every test file (test_git_output_decoding.py, test_no_auto_maintenance.py, test_openreposhape_package.py), the new test_family_config_appends.py, this PR's test file and the trio: 373 passed.

What changed

Four files, in three commits of this PR's own: 1423e5a3; the fix round 0e1e0a8d (below), which changes templates/family-root/AGENTS-shape.md again and the test file; and fix round 2, ea86cb60, which changes the test file only.

The folder rule (templates/family-root/README.md, templates/family-root/AGENTS-shape.md). Both said the parent folder was "not named after the family", which reads as a match that ignores case; siblings.py compares folder.name == family exactly (:1141) and has a separate branch for a folder that differs only in case (:1177). They now carry the standard's words:

must be named EXACTLY after its repository, case included, on every disk
  • README: "Nothing ever moves a checkout. This holder's parent folder must be named EXACTLY after its repository, case included, on every disk. When it is not, and no link of that name beside it leads to it, make siblings WARNS, ..."
  • AGENTS-shape.md: "* It moves nothing. This holder's parent folder must be named EXACTLY after its repository, case included, on every disk. When it is not, and no link of that name beside it leads to it, it WARNS, ..." ("This holder's" came in the fix round: the folder is no repository, so "its" had nothing to refer to, and the standard's README says "The holder's parent folder".)
  • AGENTS-shape.md, the layout paragraph seven lines above (fix round): "a plain folder named after the family" is now "a plain folder named after this holder's repository". For --family Quill --name QuillWorks "the family" reads as the display name, and the holder's README explains at :66-70 that both names are the repository's ({{FAMILY}}); this file has no such paragraph.

The hand repair of a row (AGENTS.md item 3 of "Creating a family", templates/family-root/AGENTS-shape.md). validate-family.py tells a person to edit a row by hand in two cases: RESTORE_ROW ("put the row back as git last had it", printed by member-gitlink-unnamed) and member-gitlink-absent's ("If it is to go, take its row out of family.yaml by hand and commit that", #681). The docs said never. The never stays for the normal path (add, bump and remove rewrite the block) and each doc now says the validator may name a repair:

AGENTS.md:
Never hand-edit `family.yaml`'s `members:` block, which the tool rewrites wholesale, but for a row repair `validate-family.py` names; the commit is then the person's.

AGENTS-shape.md:
Do not hand-edit the `members:` block — the tool rewrites it wholesale — but for a row repair that `validate-family.py` names in a holder already in a repair state: put back, as git last had it, a row deleted or whose `path:` was edited by hand (`git log -p -- family.yaml` shows the change), or take out the row of a member that is to go (`member-gitlink-absent`); the commit is then the person's.

Why the first option. The issue's Goal says words only. The second option changes the remedies and needs L2-38 (family.py remove cannot run once the gitlink is gone) proved first; #747 took its direction on the same ground.

AGENTS.md is at its cap of 494 lines (test_repo_hygiene.py's test_agents_md_is_short_enough_to_be_read), and this PR does not move it (#630, now in main, moved it from 492 to 494; the two edit different sentences). Item 3 is reflowed to cost no line: "on a branch you make first (see above): the tracking branch is refused (family-on-tracking-branch)" is now "on a branch made first (family-on-tracking-branch refuses the tracking branch)", and "Push the branch" is "Push it". The two cases are named in AGENTS-shape.md, which has the room, and not in AGENTS.md.

Pinned copies.

Fix round 1 (0e1e0a8d)

rv763 found the sentences true and the tests weak: 14 of its 19 one-line mutants survived the first tests, two of them putting the issue's contradiction back (the issue's Verify line asks for a test that "fails when one says 'never' and another says to do it"). The words in the docs changed only where the review's SHOULD 4 said (the two AGENTS-shape.md places above); the rest of the round is tests:

  • MUST 1. Each hand-edit passage is held as a WHOLE sentence, as ONE_SPELLING is (PASSAGES), from "Never hand-edit" / "Do not hand-edit" through "the commit is then the person's.". The first tests looked for loose words between the two ends, and "but for a row repair" turned into "not even for a row repair" passed them.
  • SHOULD 2. FOLDER_RULE = "parent folder must be named EXACTLY after its repository, case included, on every disk" is read in all four places (the standard's README, the handbook, the holder's README, AGENTS-shape.md), so a "need not be" or a "should be" fails. It matched all four as they were, so no wording moved to fit it.
  • SHOULD 3. member-gitlink-absent is raised through _check_member_gitlink and its code and its remedy are asserted together (the GIT_CEILING_DIRECTORIES monkeypatch is kept). The first test read a helper and asked whether the code appeared anywhere in the file. That also drops the second copy of README.md, the handbook, AGENTS.md and the family-root docs: init's --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's held = {...} set.
  • NIT 8. validate-family.py's inline copy of RESTORE_ROW's words (in gitmodules-path-unnamed) is read against the constant: every string the validator holds that begins as RESTORE_ROW does must be a prefix of it.
  • NITs 5 and 6 are in this body (the marker, above; and the line numbers: AGENTS-shape.md:93 is :96-97 at the head, and it is changed now).
  • Fix round 2 (ea86cb60, NIT 9). FOLDER_RULE now begins "holder's parent folder must be", so the rule's owner is checked too: a rule given to "A member's parent folder" or to "The parent folder" fails, in each of the four places (N2, N3, N4 and N5, a mutant of the standard's README, plus R1, S1 and S2 again, all killed by test_the_four_places_say_the_folder_rule_in_the_same_words). Tests only; test_family_docs_one_spelling.py 32 passed, with test_repo_hygiene.py 109 passed.
  • NIT 7 (AGENTS.md:399, "Push it" after "the tracking branch") is left: AGENTS.md is at its 494-line cap and "Push the branch" needs a seventh line.

Tests

tests/test_family_docs_one_spelling.py (#738's file; 15 tests added, 17 -> 32):

Test What it pins
test_the_four_places_say_the_folder_rule_in_the_same_words[README.md, handbook, holder README, AGENTS-shape] FOLDER_RULE, subject and "must" included, in each place
test_the_shape_docs_layout_names_the_folder_after_the_repository "a plain folder named after this holder's repository"
test_the_holders_own_docs_no_longer_say_not_named_after_the_family[holder README, AGENTS-shape] the old wording is gone from both
test_each_doc_says_its_hand_edit_passage_whole[AGENTS.md, AGENTS-shape] the whole passage, never and exception and whose commit
test_the_shape_doc_passage_names_both_of_the_validators_cases[as git last had it, is to go, member-gitlink-absent] the shape doc's passage carries the words its two cases take from the remedies
test_member_gitlink_absent_is_raised_telling_the_person_to_edit_the_row the refusal _check_member_gitlink raises has the code member-gitlink-absent and the sentence "If it is to go, take its row out of by hand"
test_restore_row_still_tells_the_person_to_edit_a_row_by_hand RESTORE_ROW still says to put the row back as git last had it, after "edited by hand"
test_every_inline_copy_of_restore_rows_words_is_what_the_constant_says each string the validator holds that begins as RESTORE_ROW is a prefix of it

Runs, on /usr/bin/python3, with the gh guard and an empty GH_CONFIG_DIR:

Set Stacked base 4e199e41 1423e5a3 c228e808 0e1e0a8d (fix round)
The common set (test_family_docs_one_spelling.py, test_readme_sections.py, test_pinned_copy_text.py, test_template_text_placeholders.py, test_repo_hygiene.py, test_cli_reference.py, test_agents_md_codes.py) 464 passed 478 passed 478 passed 479 passed (1m36s)
Five files that read the holder's docs or the validator's remedies (test_family.py, test_family_switch_branch_exists.py, test_family_tracking_branch.py, test_validate_family_remedies_typed.py, test_family_siblings_placement.py) not run not run 195 passed 195 passed (3m24s)
test_family_docs_one_spelling.py alone 31 passed 32 passed
The 42 other files git grep -l -E 'AGENTS-shape|RESTORE_ROW|member-gitlink-absent|hand-edit' -- tests names 2992 passed, 4 skipped, 1 xfailed (43m34s) with the common set: 3470 passed, 4 skipped, 1 xfailed (48m05s) not run not run

The 42-file set was not run after the restack or the fix round: the restack changed none of my four files, and the fix round changes one template doc by words and one test file. Nothing was red before the change. The whole suite was not run; the coordinator gates it in shards.

Mutants of the fix round (each one line, reverted at once; the new file alone, 32 tests; all 20 killed):

Mutant Fails
A1 AGENTS.md: "wholesale, but for" to "wholesale, not even for" test_each_doc_says_its_hand_edit_passage_whole[AGENTS.md]
A2 AGENTS.md: "a row repair" to "a pin repair" the same
A3 AGENTS.md: "validate-family.py names" to "refuses" the same
A4 AGENTS.md: "the person's" to "yours" the same
S3 AGENTS-shape.md: "— but for a" to "— not even for a" test_each_doc_says_its_hand_edit_passage_whole[...AGENTS-shape.md]
S4 AGENTS-shape.md: "or take out the row of" to "or keep the row of" the same
S5 AGENTS-shape.md: "state: put back" to "state: never put back" the same
S6 AGENTS-shape.md: "the tool rewrites it wholesale" to "never rewrites" the same
S7 AGENTS-shape.md: "validate-family.py names" to "refuses" the same
R1 holder README: "must be named" to "need not be named" test_the_four_places_say_the_folder_rule_in_the_same_words[...README.md]
R2 standard README: "must be named EXACTLY" to "need not be named EXACTLY" test_the_four_places_say_the_folder_rule_in_the_same_words[README.md]
S1 AGENTS-shape.md: "must be named EXACTLY" to "should be named EXACTLY" test_the_four_places_say_the_folder_rule_in_the_same_words[...AGENTS-shape.md]
S2 AGENTS-shape.md: "must be named EXACTLY" to "need not be named EXACTLY" the same
SL AGENTS-shape.md layout: "this holder's repository" back to "the family" test_the_shape_docs_layout_names_the_folder_after_the_repository
V1 validator RESTORE_ROW: "by hand" dropped test_restore_row_still_tells_the_person_to_edit_a_row_by_hand and the inline-copy test
V2 validator RESTORE_ROW: "as git last had it" to "as git first had it" the same two
V3 validator member-gitlink-absent remedy: "by hand" to "a command" test_member_gitlink_absent_is_raised_telling_the_person_to_edit_the_row
V4 validator: the plain absent refusal's code renamed member-gitlink-missing the same
V5 validator: the plain absent refusal prints no _gitlink_put_back the same
V6 validator: the inline copy's "last had it" to "first had it" test_every_inline_copy_of_restore_rows_words_is_what_the_constant_says

Not changed

Closes #763

🤖 Generated with Claude Code

Summary by Sourcery

Align family-holder documentation with repository naming rules and validator-approved row repairs, and enforce the guidance with focused tests.

Enhancements:

  • Align holder documentation with the repository’s exact, case-sensitive parent-folder naming rule.
  • Clarify that members: must not be edited during normal operations while documenting validator-approved row repairs and commit ownership.
  • Strengthen documentation tests to enforce consistent wording and preserve the validator’s hand-repair guidance.

Documentation:

  • Update family-root README and shape guidance to describe the exact repository-based folder rule and supported row-repair exceptions.

Tests:

  • Expand family documentation tests to verify wording consistency across holder and standard documentation and to validate the corresponding validator remedies.

Brett Heap and others added 28 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>
… `--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, so three sentences that fell behind #488, #578 and #574 are true of main (#695)

README.md and docs/handbook.html § Families:

- The `--into` sentence says the default (the directory you are standing
  in) is refused inside a checkout of this standard, and names the code.
- The `make siblings` paragraph says what it does since #578: the holder's
  parent folder must be named EXACTLY after its repository, case included,
  on every disk; where it is not, and no link of that name beside it leads to
  it, it WARNS, prints the exact `mv` and clones nothing until it is run, so
  the next run clones each member once. Lane 3's two #460 sentences (L20,
  L21) ride in it: a holder at `~/projects/InkRouter` is moved to
  `~/InkRouter/InkRouter`, beside that folder, not inside it.

The one-spelling rule, one sentence in the same words in AGENTS.md (family
item 2), README.md § Families, the handbook, the holder's README and the
holder's pinned AGENTS-shape.md: a holder keeps `members/` in ONE spelling
on every disk, and `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.

Neither cap is raised. README.md stays at its cap of 1459 lines and
AGENTS.md at 492, by cutting wording that is stated elsewhere: README
§ Families lost the "same rule projects have" aside, the tool's rationale
after "Nothing ever moves a checkout" (the handbook, the holder's README and
the tool's own output keep it), "not merely a repository at the right
commit", the private-legs reason in the `make bootstrap` paragraph and the
policy history in the naming paragraph; AGENTS.md lost the converse in the
family intro, the `bump --trailer` sentence (stated in "Advancing a leg" and
in AGENTS-shape.md) and "on your own initiative" in "Updating" step 2. The
handbook marker for README § Families is recomputed.

templates/family-root/AGENTS-shape.md is pinned: every holder re-pins it
(`upstream-changed`). tests/test_family_docs_one_spelling.py holds the
sentences to the five places and the codes to family.py.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… not committed says to commit it, `member-gitlink-absent` puts the gitlink and its section back by hand, the variant `mv` is guarded by `-ef` and a `.git` test, and a shape-pin row with no `path:` is `shape-pin-row`, so every remedy types as printed in the state it names and `None` is no path (#681)

The holder's validate-family.py (pinned in every holder) and the assembly
root's validate-pins.py (pinned in every assembly root); words and one
check, no family.py change (Brett Heap, 2026-10-16T14:11:42Z, "take all
recommendations for #670 and #681").

- L20. `_staged_removals` names the gitlinks HEAD records where the index
  holds no entry at the path, at any stage. `member-gitlink-unnamed` for
  one says the removal is staged and to commit it, and prints no `git rm`,
  which failed typed again (`pathspec ... did not match any files`, exit
  128): a hand mount keeps its branch and `add` lines, a second path's
  says only to commit. A section `git rm --cached` leaves keeps its own
  `--unset-all` lines, since no `git rm` above takes it out. An unmerged
  path is a conflict, not a removal, and keeps its `git rm`.
- L2-38. `member-gitlink-absent` with no case variant named `family.py
  add`, which refuses the row that is there (`member-already-present`);
  `remove` dies at `git submodule deinit`. It now prints the
  `update-index --cacheinfo` line that puts the gitlink back at the
  commit the row pins, and, where HEAD's `.gitmodules` gives no section
  the path, the section lines `member-gitmodules-absent` prints
  (`_section_lines`, shared); or the row taken out by hand.
- N-1 and N-2. The variant move's `mv` is now `[ <variant> -ef <path> ] ||
  [ -e <variant>/.git ] || mv -- ...`, typed in a POSIX shell: it moves
  nothing where the two names are one directory (a case-folding disk,
  WSL's /mnt/c included) or where the directory is a checkout, whose
  submodule `.git` file a move leaves pointing nowhere. The text says its
  commits are kept in `.git/modules/<variant>` and to delete a checkout by
  hand once nothing it holds is needed. It no longer states the condition
  by system.
- L2-3. A `files:` row whose `path:` is absent, empty or no string is
  `shape-pin-row` in both validators, where it was `shape-copy-missing`
  for `None`.

Tests: tests/test_validate_family_remedies_typed.py (L20, L2-38, N-1,
N-2, each typed as printed; the case-folding disk emulated with a
`Members` symlink) and tests/test_shape_pin_row_path.py (L2-3, both
validators); the pinned texts and typed helpers in
test_family_head_lost_gitmodules.py, test_family_bootstrap_gate_order.py,
test_validate_family_branch_step.py, test_family_switch_branch_exists.py
and test_family.py follow the new words.

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

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

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

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

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e makes "the family folder `~/InkRouter`, beside `~/projects`, and the holder `~/InkRouter/InkRouter`, not `~/projects/InkRouter/InkRouter`", so "beside that folder" no longer reads as the holder itself (#695)

Review nit N1 on 73fa3ff. What sits beside `~/projects` is the family
folder `~/InkRouter`; the holder is one level inside it. The sentence now
says so in README § Families and the handbook, and STRAIGHT_IN_A_FOLDER in
tests/test_family_docs_one_spelling.py holds the new words. The README stays
under its cap (1458 of 1459): the one added line is paid for by cutting the
caption "Eight services, each its own project, one holder:" under "The
InkRouter example", which repeats the section's intro and its block. The
handbook marker for README § Families is recomputed.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 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>
…d-removal text says to delete the checkout before `family.py add`, and the variant move's text says what a clone's own `.git` holds, where a submodule checkout's store is, and "in a POSIX shell", so each step typed as printed reaches a passing gate (#681, rv681)

The fix round for rv681's review of d91fd15 (0 MUST, 2 SHOULD, 3 NIT).
Text only, in the pinned holder copy; no guard changes.

- S1. A hand mount taken out with `git rm --cached` (as git suggests when
  the printed `git rm` refuses a checkout with changes) leaves its checkout
  at the path, and the printed `family.py add` refused it
  (`member-path-taken`). The staged branch of `_mounted_by_hand` now says
  to delete it by hand, once nothing only it holds is needed, before the
  `add`.
- S2, Direction B. The `-e` guard stays, as the issue's "no .git entry"
  that Brett Heap's 2026-10-16T14:11:42Z word took. For a `.git`
  directory, a clone of its own, the text says its commits are there and
  nowhere else, names `git -C <variant> log --oneline HEAD --branches
  --not --remotes` for the ones not pushed, and says to move it out whole
  rather than delete it.
- N1. A submodule checkout's commits are "kept under this holder's
  .git/modules/, in the directory its .git file names", not always
  `.git/modules/<variant>`. The status check no longer promises to work
  where a plain `mv` brought the checkout to the variant.
- N2. The guarded line's sentence says "in a POSIX shell".
- N3. A test kills rv681's mutant MO1: the section written back in the
  work tree alone still prints its lines.

Tests: the hand-mount L20 test gains a `git rm --cached` case typed to
`add` exiting 0; the checkout-at-the-variant test keeps the clone, types
its printed `git log`, moves it out whole, and gains a plain-`mv` kind;
a new test for N3; the pinned variant text follows the new words.

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>
…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>
…ember git has no url for is its own reason, and a gitlink outside members/ gets a cure true for it, so a later duplicate section, a section under a name git ignores or a url git drops no longer ends `family bootstrap ok`, and a subset clone's printed update no longer dies `No url found` (#648, rv648's verify)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s the stacked base of #763

#747 is stacked on #682 (fix/648-bootstrap-and-gate-order, f74f640), so this
merge brings both. One conflict, docs/handbook.html, the marker line of the
families section: #738 and #682 both change README.md's Families section (the
README merged clean, #682's one-sentence change to the make bootstrap
paragraph on top of #738's), so the marker is the blob hash of the merged
section, computed with readme_fingerprint from tests/test_repo_hygiene.py:
82d7f8087b9ee4d097d33e15fbfe5ae754a04a79.
…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)

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.

tests/test_family_docs_one_spelling.py gains 14 tests: the folder sentence
in each holder doc and the old wording gone, each doc's never kept and
followed by the validator's repair and whose commit it is, the shape doc's
two cases, and the validator's two remedies read from the validator itself
(`RESTORE_ROW`, and what `member-gitlink-absent` is given), so a remedy that
stops telling a person to edit a row fails there and takes the docs'
exception with it.

Lands after #738 and #747 (which carries #682), all v1.1.0.

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

# Conflicts:
#	templates/family-root/scripts/validate-family.py
#	tests/test_family_bootstrap_gate_order.py
…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.
@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR synchronizes family documentation with exact on-disk naming and validator repair semantics, while substantially hardening printed gitlink remedies and shape-pin validation for staged changes, malformed rows, case-folding filesystems, and live checkouts; extensive tests execute those remedies end to end.

Sequence diagram for staged gitlink repair validation

sequenceDiagram
    participant Person
    participant Validator as validate-family.py
    participant Members as _check_members
    participant Staged as _staged_removals
    participant Named as _check_gitlinks_named
    participant Remedy as _mounted_by_hand
    participant Report

    Person->>Validator: validate-family.py
    Validator->>Members: _check_members
    Members->>Staged: _staged_removals
    Staged-->>Members: removed paths
    Members->>Named: _check_gitlinks_named
    alt gitlink removal is staged
        Named->>Named: _second_path_out
        Named->>Report: finding member-gitlink-unnamed
        Report-->>Person: commit the staged removal
    else gitlink is absent and row remains
        Named->>Remedy: _mounted_by_hand
        Remedy-->>Named: hand-repair instructions
        Named->>Report: finding member-gitlink-unnamed
        Report-->>Person: restore the row or remove it by hand
    end
Loading

Sequence diagram for malformed shape-pin row validation

sequenceDiagram
    participant Person
    participant Validator as validate-pins.py
    participant ShapeCheck as _check_shape_copy
    participant Report

    Person->>Validator: validate-pins.py
    Validator->>ShapeCheck: _check_shape_copy
    alt path is absent, empty, or not a string
        ShapeCheck->>Report: finding shape-pin-row
        Report-->>Person: report invalid files row path
    else path names a missing file
        ShapeCheck->>Report: finding shape-copy-missing
        Report-->>Person: report pinned file absent
    end
Loading

File-Level Changes

Change Details Files
Align family-holder documentation with exact repository-folder naming and clarify the exception for validator-directed manual row repairs.
  • Require the holder’s parent folder to match its repository name exactly, including case, on every disk.
  • Retain the prohibition on normal manual edits to family.yaml’s members: block while documenting validator-named repair cases and ownership of the repair commit.
  • Update holder templates and add focused tests that enforce the wording and connect it to validator remedies.
AGENTS.md
templates/family-root/README.md
templates/family-root/AGENTS-shape.md
tests/test_family_docs_one_spelling.py
Expand family validation remedies for missing, staged, or case-variant gitlinks and make the printed repairs safe across filesystem layouts.
  • Detect staged-but-uncommitted gitlink removals and instruct users to commit them instead of repeating failing git rm commands.
  • Provide index and .gitmodules restoration commands when a row’s gitlink is absent, or explain removing the row when the member is leaving.
  • Guard case-variant moves against case-folding filesystems and live checkouts, with guidance for preserving submodule or clone data.
  • Validate malformed shape-pin files: rows as shape-pin-row instead of treating invalid path: values as filesystem paths.
templates/family-root/scripts/validate-family.py
templates/assembly-root/scripts/validate-pins.py
Synchronize generated and reference documentation with family initialization, placement, one-spelling, and refusal behavior.
  • Update the standard README and handbook text, including the generated README fingerprint marker.
  • Document refusal codes and the exact relocation example for holders placed directly inside a folder.
  • Describe the members/ spelling invariant and the guarded behavior of printed remediation commands.
README.md
docs/handbook.html
Revise regression coverage to execute validator remedies as printed and cover filesystem and staged-index edge cases.
  • Update existing expectations for index-based missing-gitlink repair and guarded moves.
  • Add tests for staged removals, missing rows and .gitmodules sections, unmerged gitlinks, case-folding layouts, live submodule checkouts, and standalone clones.
  • Add tests for malformed shape-pin paths in both family and assembly-root validators.
tests/test_family.py
tests/test_family_bootstrap_gate_order.py
tests/test_family_head_lost_gitmodules.py
tests/test_family_switch_branch_exists.py
tests/test_validate_family_branch_step.py
tests/test_validate_family_remedies_typed.py
tests/test_shape_pin_row_path.py

Assessment against linked issues

Issue Objective Addressed Explanation
#763 Make the holder's README and AGENTS-shape.md state that the parent folder must be named exactly after the repository, case included, on every disk. ✅
#763 Reconcile the normal-path instruction not to hand-edit family.yaml's members block with the validator's hand-repair remedies, by documenting when a validator-named repair is an exception and who owns its commit. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T12:46:48Z) Independent review of #822 (#763) at c228e808: NOT READY

What I reviewed: the writer's commit 1423e5a3, the conflict resolution in 4e199e41 (git show --remerge-diff), and the restack c228e808. I worked in my own clone at c228e808e7d171f76b187f5562043fd4d638f10a. The new sentences are true, and the handbook marker is right. The new tests, though, check for words, not meaning: 14 of my 19 one-line mutants survive, two of them put the issue's contradiction back, and the issue's Verify line asks for a test that catches exactly that.

MUST

  1. tests/test_family_docs_one_spelling.py:204-240: the hand-edit tests do not catch the contradiction the issue's Verify line names. Verify asks for a test that "fails when one says 'never' and another says to do it". The tests look for separate words between the "never" and "the commit is then the person's": wholesale, validate-family.py, repair, and in the shape doc the three case phrases. They never check that the passage is an exception. Each of these one-line mutants leaves all 31 tests passing:

    • A1, AGENTS.md:399: "rewrites wholesale, but for" becomes "rewrites wholesale, not even for". AGENTS.md says never, even for the validator's repair, while RESTORE_ROW and member-gitlink-absent still say to do it.
    • S3, templates/family-root/AGENTS-shape.md:38: "— but for a" becomes "— not even for a". Same contradiction.
    • A2, AGENTS.md:400: "a row repair" becomes "a pin repair", which would allow hand edits of members[].pin.
    • A3, AGENTS.md:400 (and S7, AGENTS-shape.md:39): "validate-family.py names" becomes "refuses".
    • S4, AGENTS-shape.md:41: "or take out the row of" becomes "or keep the row of".
    • S5, AGENTS-shape.md:40: "state: put back" becomes "state: never put back".
    • S6, AGENTS-shape.md:38: "the tool rewrites it wholesale" becomes "the tool never rewrites it wholesale".

    Fix: check each passage as a whole sentence, the way README.md, the handbook, AGENTS.md and the family-root docs: init's --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's tests in the same file check ONE_SPELLING. Add a dict from doc to its full passage, from "Never hand-edit" / "Do not hand-edit" through "the commit is then the person's.", and assert PASSAGES[path] in shown(path). I tested this in my clone and it kills A1–A4 and S3–S7.

SHOULD

  1. tests/test_family_docs_one_spelling.py:191: the folder-rule test checks the phrase without its subject or its "must". NAMED_EXACTLY begins at "named EXACTLY ...". These survive:

    • R1, templates/family-root/README.md:72: "parent folder must be named" becomes "parent folder need not be named".
    • S1, AGENTS-shape.md:103: "must be" becomes "should be".
    • S2, AGENTS-shape.md:103: "must be" becomes "need not be".

    Fix: FOLDER_RULE = "parent folder must be " + NAMED_EXACTLY, asserted over README_AND_HANDBOOK + HOLDER_DOCS. All four places already carry that exact text, so one constant keeps the three places from drifting, as the issue asks. Tested: it kills R1, R2, S1 and S2.

  2. tests/test_family_docs_one_spelling.py:243-271: the member-gitlink-absent tests read a helper and a set of strings, not the refusal the validator raises. The fixture calls _gitlink_put_back directly, and test_member_gitlink_absent_is_a_code_the_validator_prints only asks whether the string appears anywhere in the file. Two mutants survive:

    • V4, validate-family.py:473: the plain absent refusal's code is renamed to member-gitlink-missing. The test passes, because the case-variant refusal at :567 still holds the string.
    • V5, validate-family.py:477: that refusal stops printing _gitlink_put_back(...).

    Fix: raise the refusal itself and check its code and its remedy together:

    with pytest.raises(module.Refusal) as caught:
        module._check_member_gitlink(tmp_path, "members/IRQS", "0" * 40,
                                     "IRQS", "acme/IRQS", {}, None, "rule")
    assert caught.value.code == GITLINK_ABSENT
    assert re.search(REMEDY_SENTENCES[GITLINK_ABSENT], " ".join(str(caught.value).split()))

    Keep the GIT_CEILING_DIRECTORIES monkeypatch. Tested: it kills V3, V4 and V5. It also replaces the string-set test, which removes the second copy of README.md, the handbook, AGENTS.md and the family-root docs: init's --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's three-line held = {...} set (below Sonar's duplication threshold, but no longer needed).

  3. templates/family-root/AGENTS-shape.md:96-97 and :103: the pinned file still names the folder two ways.

    Fix: :96 "a plain folder named after this holder's repository"; :103 "This holder's parent folder must be named EXACTLY after its repository, ...". No test holds :96's wording (git grep finds none).

NIT

  1. PR body, "For whoever lands second of README.md, the handbook, AGENTS.md and the family-root docs: init's --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 and validate-family.py and validate-pins.py: a staged gitlink removal says 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": the conflict now belongs to README.md, the handbook, AGENTS.md and the family-root docs: init's --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 in either order. 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 has landed on main as dc7d3fbb, and validate-family.py and validate-pins.py: a staged gitlink removal says 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's ff7c59b8 already merges main.
  2. PR body, "templates/family-root/AGENTS-shape.md:93": at the head that line is :96-97. (:93 is the continuation line's number at 4e199e41.)
  3. AGENTS.md:397, "refuses the tracking branch). Push it as its NEXT line says": after the reflow, the nearest noun for "it" is "the tracking branch", so the sentence can be read as "push the tracking branch". The next clause rules that out, so this is minor. "Push the branch" is clearer, but it needs a seventh line unless another word goes.
  4. V6 survives, as the writer disclosed: the inline copy of RESTORE_ROW's words at validate-family.py:1610-1611 (gitmodules-path-unnamed) is not read by any test.

Checked and true

  • The folder sentence is true on every disk.
    • siblings.py:969 compares folder.name == family exactly.
    • family_link (:911-936) refuses a case-only alias ("Never a second spelling").
    • So a folder that differs only in case gets the WARNING and an mv on macOS and Windows too. The case-only branch at :1003 only shapes the printed move (a staged rename). "case included, on every disk" holds.
    • family_folder_name returns the basename of repository:, so "after its repository" holds.
  • Line 1 is done in both files, with the standard README's words at README.md:913.
  • Line 2: the docs and the validator agree.
    • RESTORE_ROW (validate-family.py:1340-1342) is printed by member-gitlink-unnamed (:1406, :1539).
    • The ROW_EDITED advice (:1397-1402) and the inline copy at :1610 are covered by the shape doc's first case.
    • _gitlink_put_back's "If it is to go, take its row out of family.yaml by hand and commit that" is the second case.
    • family.py's write_members/rewritten (:343-380) rewrite everything from members: down, so "wholesale" holds.
    • The shorter AGENTS.md sentence keys the exception to "a row repair validate-family.py names", so an agent can still tell the normal path from a repair state. "the person's" matches the file's own use (:445, :449).
  • The left-alone sites are fine:
  • The marker is right. tests/test_repo_hygiene.py's readme_segments/readme_fingerprint give README § Families 82d7f8087b9ee4d097d33e15fbfe5ae754a04a79 at c228e808, and the handbook marker (docs/handbook.html:1285) says the same. At 1da948fa and c42286de both are 0364c579; at main, ff7c59b8, 58d6c50f and dc7d3fbb both are b3d19092.
  • The 4e199e41 conflict resolution is that one marker line only (--remerge-diff: one file, +1 −5).
  • The restack changes none of the writer's files: git diff 1423e5a3 c228e808 over the four files, README.md and docs/handbook.html is empty.
  • Pinned copies and the cap:
    • No sha256 or blob of either holder doc, at main, at 4e199e41 or at the head, appears anywhere in the tree.
    • AGENTS.md is 492 lines against a cap of 492 (test_repo_hygiene.py:654).
    • The writer's commit leaves README.md, docs/cli.md and tests/readme-cap.d/ alone.
  • Merges: merge-tree --write-tree of c228e808 is clean (exit 0) with each of these:
    • main 741679e7 (tree 69b2e005; marker equals the fingerprint; AGENTS.md 492 against 492);
    • l3-563-docs b2a62ec7 (tree 467c9a66; marker equals the fingerprint; AGENTS.md 494 against its cap of 494);
    • fix/695-family-docs c42286de;
    • fix/681-validate-family-remedies ff7c59b8.
  • No composite asserts in the new tests.
  • Mutants the current tests do kill: R2, A4, V1, V2, V3.
  • Runs, in lane 2's own clone:

Lane 2 is taking MUST 1, SHOULD 2-4 and NITs 5, 6 and 8 in fix round 1 now. NIT 7 needs a line AGENTS.md does not have, so it stays as it is unless a word can go.

Lane: openreposhape-2 (openRepoShape-2)

…ranch changed different README sections, so five handbook markers take main's blobs, the Families marker keeps this branch's, and no line conflicts (#695)

#630 (190133d) changed README.md in five sections and moved each one's
`reads README.md §` marker in docs/handbook.html. This branch changes only
README.md § Families (marker 82d7f808..., set by 75e35f1). Main's own
§ Families is still b3d19092..., what it was before this branch. No marker
line changed on both sides, so git merged README.md, AGENTS.md and
docs/handbook.html without a conflict, and nothing was resolved by hand.

Each marker below is `readme_fingerprint` of its merged section, computed
with tests/test_repo_hygiene.py's own functions over the merged README.md
(`readme_segments`, then `readme_fingerprint`), never typed. Old is this
branch's 75e35f1, new is this merge:

- Starting a project in a new organisation (seven markers)
    old dd174d763a931052478f2fd15a7f22535524f6f4
    new 45a3f886c0203e91f17a7851cf2c1c71a2681b0c
- The three legs (three markers)
    old cce66b56eec9dffdd5a1a23bdd8e58d57f90b12e
    new 1d9f5bf98bddd1f70522c86515de3f15900fe141
- Adopting an existing repository (one marker)
    old ca4b6731cf6de1caeac426eeff5eb7184c53f562
    new b23e9f9819cedc3e6ca6321c811b6d11da8de508
- Keeping a project's shape current (two markers)
    old 545639ebf5e9ab7837df5c012f216832dc7de56f
    new df780eb5a96cdcaf9b30a8ac8162bca00f90405b
- Layout of this repository (one marker)
    old 94701efcccdb2a17e8aa80fbb3343c381ea71e4a
    new 9446cd710bf3a992751e12e318553b9b22914c21

Unchanged, and still equal to their sections: preamble ddadd23a...,
Families: a holder for projects that ship separately 82d7f808...,
The double pin, and the lockstep invariant 57fffa08..., Bootstrap is
COPIED into the project, not fetched 41f2c883..., Carrying in-flight work
to another workstation b84512ad..., The degrade rule e28bf2cd... and
Licence 57e2469b....

The tree of this merge differs from origin/main by exactly #738's own
change: `git diff origin/main <this commit>` and `git diff 24a977c 75e35f1`
have the same patch-id (04fc641d...). #630 brings no new README heading
(still twelve segments), so tests/test_family_docs_one_spelling.py and the
docs edits need no new coverage.

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
… `--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>
Brett Heap and others added 3 commits October 18, 2026 00:20
c228e80)

#747 squash-landed on main as 6626a3c. Its final head ff7c59b is
already merged here at c228e80, and `git diff ff7c59b 6626a3c` over
#747's nine files is empty, so this merge takes the squash for the
history and changes none of #747's files. No conflict.

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

#738's final head merges main 2e476a7 into its reviewed change (the
branch here held its older head c42286d). Between c42286d and 0b26ace
#738 has only two merges of main (75e35f1 and 0b26ace), whose handbook
markers it refreshed. This merge had no conflict: lane 2's sentences in
AGENTS.md and the family-root README and AGENTS-shape compose with it as
they are.

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

#738 squash-landed on main as e62b5e1. Its final head 0b26ace is merged
at 9b38816, and `git diff 0b26ace e62b5e1` is empty over each of #738's
six files. The merge base here is main 2e476a7, older than 0b26ace, so
three of #738's files conflicted: AGENTS.md (one hunk),
templates/family-root/AGENTS-shape.md (one hunk) and
tests/test_family_docs_one_spelling.py (five hunks, add/add). Each takes
ours: no main commit between 2e476a7 and e62b5e1 other than the squash
touches them, so ours already holds all of theirs. This merge changes
none of #738's files; it brings main's commits 2c6543e to 112eeed.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…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>
Brett Heap and others added 2 commits October 18, 2026 00:21
Both parents have landed (#747 as 6626a3c, #738 as e62b5e1, each merged
above). This brings main up to 7b21059 (#842, #814 and #786's
validate.yml change, whose one sentence in templates/family-root/README.md
sits apart from this PR's). No conflict.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main moved while this branch was restacked: #820 (#762) landed as
b9b7069 after the merge of 7b21059 above. It changes validate-family.py
and four of its test files, none of this PR's four files. No conflict.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…appended_to_command_scope`, the scan names a count worked out by hand, and `paste()` gives a pasted `submodule update` the `insteadOf` rewrite (#835) (#848)

Stacked on #818 (`fix/758-git-command-scope`, `4a553e17`); retarget to main once it lands. The fix is one commit, `b9ad1187`, written on #818's `eaa72d9e`. The head `fafba9ec` merges #818's refresh `4a553e17` (main `fc867207`) into it, cleanly.

Tests only. Nothing a user runs changes, and no pinned file changes. No README, handbook, `AGENTS.md` or `docs/cli.md` sentence is touched: none mentions these helpers (`render-cli-reference.py --check` passes).

Both reviews say READY with no MUST: rv848 (issuecomment-6240157020) and lane 2's second review of #848 (issuecomment-6240228302, "rv818" below). One commit on `fafba9ec`, by explicit pathspec, fast-forward pushed. Tests only; no pinned file changes; main and #818 are not merged in. The rv818 numbers are next to rv848's.

### What each finding got

| rv848 | rv818 | taken | what changed |
|---|---|---|---|
| 1 SHOULD: #811's and #838's counts | | the text | the merge section below says who converts it |
| 2 NIT: stale merge checks | | yes | re-run at the head, lines corrected below |
| 3 NIT: the walk counts arguments | 6 NIT: it does not read their values | yes, one change | `paste(row, cwd, None)` now fails the walk |
| 4 NIT: `on_disk` is asked for `"true"` only | | yes | `ADDERS` holds `on_disk-false` |
| 5 NIT: the `upload-pack` docstring | | yes | `paste_env` says what the rewrite adds: `pack-objects` and `index-pack` |
| | 1 SHOULD: two command scopes built at collection | yes | #818's second review's SHOULD (#758's line 1), taken here so that #818 needs no third round |
| | 2 NIT: an expected dict is flagged | yes | the scan leaves a dict that is an operand of a comparison |
| | 3 NIT: what the scan misses | the text | "Known limits" below, no code |
| | 4 NIT: the same lines in two files | yes | three helpers in `conftest.py` |
| | 5 NIT: two surviving mutants | in part | the `count_of(env)` case is added; the `remotes_of` one is left, below |

**rv818 SHOULD 1.** `test_adopt_reencoded_files.py`'s `attr.tree` param and `test_family_init_asks_every_value.py`'s `user.name` param were built at collection, before the session fixture put `gc.auto=0` and `maintenance.auto=false` in the environment. Laid over the environment at the test, each dropped the two from git's command scope. Each is a `lambda` its test calls, as `test_adopt_nested_attributes.py` already did.

New `tests/test_command_scopes_built_at_the_test.py` (2 tests) reads each param off its test's own marks, builds it, and asks git for the command scope (`git config --show-origin --list`, read and never written). It asks for the session's two settings beside the param's entry. **It fails on `fafba9ec`** (the two test modules as they were, with the new `conftest.py`): `2 failed`, git read `['user.name=', 'user.email=', 'attr.tree=4b825dc6...']` and `['user.name=', 'user.email=', 'user.name=Ada Lovelace']`, with no `gc.auto=0`. It passes on the head.

**rv848 3 and rv818 6.** `pastes_in` now returns, for each `paste` call, the fixture it hands the remotes of: `remotes_of(<name>)` as the third argument or as `remotes=`, where the name is `holder` or `scaffolded_here` and a parameter of the nearest function the call is in. `None`, another call, an expression on the call, a name the test does not take, or no third argument gives no fixture. The list of calls with none is `pastes_without_remotes`, a function of its own, so that a test of it can fail. 15 new cases hold the walk: twelve for what a call may hand and three for the function it is in.

**rv818 2.** `counts_set_to_a_number` skips a `Dict` that is an operand of a comparison (`assert added == {"GIT_CONFIG_COUNT": str(held + 1), ...}`, `!=`, a dict on the left). I took the narrower reading of "inside an assert or a comparison": a dict in a call inside an assert (`assert run(env={"GIT_CONFIG_COUNT": "1"}).returncode == 0`), or one assigned before the comparison, still sets a count and is still read. Four cases are still found, three are left, and the `count_of(env)` case of rv818 5 is left.

**rv818 4.** `JOB_ENTRIES`, `put_job_and_session_entries(monkeypatch)`, `command_line_entries(listing)` and `command_scope_at(environ, cwd)` in `conftest.py` replace the `delenv`, `held = ...` and loop lines of `test_no_auto_maintenance.py` and `test_doctor_paste_through_git.py`, and the four-line read of the `command line:` entries in three files. `OWN_COUNTS` follows: `conftest.py` holds two (the sum, and the job's entries counted from nothing), `test_no_auto_maintenance.py` one, and `test_doctor_paste_through_git.py` none. A new test checks that both readers put `GIT_CONFIG_PARAMETERS` aside (rv818's own round-2 NIT 8).

**rv818 5, left.** `remotes_of` returning `fixture["base"]` instead of `fixture["base"] / "remotes"` still survives, and I name it equivalent in practice: the prefix `<base>/` covers every remote under `<base>/remotes`, so all the pasted clones are rewritten either way.

### Merge checks (replace the body's section)

Re-run at the head `79b2eac4` (`git merge-tree --write-tree`), against 152 heads: every `fix/*` and `l3-*` branch, and the pull heads from #700. Main `fc867207` is an ancestor of the head (it came in with #818's refresh `4a553e17`).

- **Main.** `fc867207`: clean. Main is `190133d5` now (two commits on `family.py`, the README and the handbook): clean too.
- **#818's head `4a553e17`:** clean. The body's "one conflict, `tests/test_non_utf8_git_reads.py`" was true only for `b9ad1187` and `eaa72d9e`, before the refresh.
- **The open heads that edit the files here merge clean:** #811 (`050abea6`), #820, #822, #828 (`ce0f2a5f`), #774 (`012a7726`) and #775 (`8c5ad0e8`). #761, #762 and #763 are the same heads as #828, #820 and #822. The body said #774 and #775 conflict; they do not, since the refresh.
- **#838's pushed head `9166f84f`:** clean.
- **Conflicts the other heads show** are the ones #818's head shows too (20 heads, in files this PR does not touch: `tests/test_adopt_git_reads.py`, `tests/test_non_utf8_git_reads.py`, `tests/test_dry_run_parity.py`, `shape-doctor.py`, `scripts/shape_materialize.py` and others). #806 (`76ae6419`) and #845 (`b18b243e`, branch `fix/834-oserror-skips`) conflict in `tests/test_adopt_git_reads.py`, which this PR does not touch.
- **This commit adds two, both stale PR heads whose squash is in main:** #767's `bd6d2afe` (landed as `98f14976`, in `tests/test_family_add_spellings_remedies.py`) and #809's `98561405` (landed as `42aed788`, in `tests/test_family_init_asks_every_value.py`).
- **Whichever of #848, #811 and #838 lands later converts that count to `appended_to_command_scope`.** #811 (`fix/777-typed-name-edges`, `050abea6`) adds `"GIT_CONFIG_COUNT": str(held + 1),` at `tests/test_setup_project_name_edges.py:190`: the scan finds `[190]` where `OWN_COUNTS` allows none, so `test_no_test_sets_a_git_config_count_of_its_own` fails once #848 and #811 are both on main. rv848 measured the same for #838's local `3845fd54` at `tests/test_github_name_limits.py:503`; the head #838 has pushed, `9166f84f`, holds no `GIT_CONFIG_COUNT` in that file. It is the goal of #835, so it takes no `OWN_COUNTS` entry. No file of theirs is changed here.

### Known limits (add to the body)

Shapes the scan does not read, so a test that sets a count this way passes it:

- a count key name imported from another module, held as a class attribute, bound by tuple unpacking or by a walrus (only a plain or annotated assignment of the name in the same module is read);
- a value built by `format()`, `repr()` or `os.fsdecode()` (`str(...)`, an f-string, a `.format(...)` method and arithmetic are read);
- a conditional value, `"2" if job else "1"`;
- `setenv(name=..., value=...)`, with keywords;
- an annotated subscript, `env["GIT_CONFIG_COUNT"]: str = ...`;
- a value held in a variable and set later (`new = str(n + 1)`, then `{"GIT_CONFIG_COUNT": new}`);
- an indexed `GIT_CONFIG_KEY_{n}` or `GIT_CONFIG_VALUE_{n}` name, which a hand-worked append writes beside its count;
- a dict that is an operand of a comparison, by design (it is the value a test expects).

This replaces a separate issue for them.

### Tests, through `run9.sh`

- At the commit `79b2eac4`: **222 passed** (`test_command_scopes_built_at_the_test.py` 2, `test_family_config_appends.py` 8, `test_doctor_paste_through_git.py` 21, `test_no_auto_maintenance.py` 60, `test_adopt_reencoded_files.py` 53, `test_family_init_asks_every_value.py` 78).
- The same content, run before the commit: **368 passed** (those and `test_doctor_legs_members_next.py` 127, `test_clone_just_pushed.py` 19) and **253 passed** (`test_doctor_next_is_a_command.py`, `test_doctor_validator_rows.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`).
- New tests: 24 (2 + 11 + 2 + 9). Before the round: `test_doctor_paste_through_git.py` 10, `test_family_config_appends.py` 6, `test_no_auto_maintenance.py` 51.
- Fail on `fafba9ec`: the command-scope test above, `2 failed`. The walk, `on_disk-false` and the scan cases pass on the code of `fafba9ec`, which is right; what they add shows in the mutants, where four survive the files of `fafba9ec` and none survives these.

### Mutants, 29 in a throwaway clone at the head, each caught

- **rv848 4:** `on_disk` ignoring its value. Caught by `on_disk-false` (2 failures); survives the `test_family_config_appends.py` of `fafba9ec`.
- **rv848 3, rv818 6:** one caller handing `None` (M01), `remotes_of(tmp_path)` or `remotes_of(project)`: each caught by the walk of the real file, and each survives the walk of `fafba9ec`. Eight mutants of the walk itself (the `remotes=` keyword, the parameter check, the remote-fixture check, the argument count, the `remotes_of` name, the nearest-function rule, the call check, and `pastes_without_remotes` always empty): each caught by its own case.
- **rv818 1:** each of the two params built at collection again, and each building nothing: 4 mutants, each caught by `test_command_scopes_built_at_the_test.py`.
- **rv818 2:** no skip of expected dicts (4 failures), every dict in an assert skipped (4), only the left operand skipped (3), only the comparators skipped (1).
- **rv818 5:** any call by a bare name taken as worked out: caught by the `count_of(env)` case.
- **rv818 4:** the helper putting only the job's entries (2), the reading taking another line (10), either helper keeping `GIT_CONFIG_PARAMETERS` (1 each), the job's second entry another value (6).
- **`OWN_COUNTS`:** `conftest.py` allowed one (2 failures), this file allowed two (1), no allowance for `test_family_config_appends.py` (1).

1. **Line 1.** `on_disk`, `with_config` and `typed_with_local_remotes` each worked out their `GIT_CONFIG_COUNT` from the environment's own count. Each is now one call of `appended_to_command_scope(env, [(key, value)])`; `typed_with_local_remotes` sets each key the helper returns, inside its `monkeypatch.context()`. The scan found a fourth: `test_clone_just_pushed.py`'s `env` for `clone.defaultRemoteName` summed its own count too, and goes through the helper.
2. **Line 2.** `counts_set_to_a_number` finds a count worked out in the same four places it finds one written out (a dict, a keyword, a `setenv`-style call, a subscript): a `str(...)` call, an f-string, a `.format(...)` call or arithmetic. `OWN_COUNTS` lets `conftest.py` hold the one sum that remains, in `appended_to_command_scope`.
3. **Line 3.** `paste(row, cwd, remotes=None)` and `paste_env(remotes=None)`. Given the fixture's remotes, they append `insteadof_entries(remotes)` after `protocol.file.allow`, in the same count. Every `paste` in `test_doctor_legs_members_next.py` hands `remotes_of(<fixture>)`, and a walk of that file holds each call to it.

The issue says each site is right today, and it is: the held-two case below passes on the base. What the helper adds is an empty `GIT_CONFIG_COUNT`, which git reads as no entries and `appended_to_command_scope` counts as none (`or 0`). The sums written out by hand died on `int("")`.

`tests/test_family_config_appends.py` (6 tests) starts each helper under `GIT_CONFIG_COUNT=2`, the Windows job's two entries, and asks `git config --show-origin --list`: both held entries and the new key, in order. It starts each under an empty count and checks the entry is the first. `typed_with_local_remotes` is asked from inside `typed_as_printed`, and the environment is checked to be back as it was.

**Yes.** Measured by running the extended scan over every `tests/*.py` at the base: it finds exactly the three sites, `test_clone_just_pushed.py:99`, and `conftest.py:290`, the helper itself. No other file, so no false positive.

- The scan reads the SHAPE of the value and never evaluates it. `str(...)`, an f-string, `.format(...)` and arithmetic are found. A value held in a name, read by subscript (`added["GIT_CONFIG_COUNT"]`) or returned by any other call (`env.get(...)`) is still not read, and so is `int(env.get("GIT_CONFIG_COUNT", "0"))`, which sets nothing. `is_a_written_count` still reads constants only; the new `is_a_worked_out_count` is the one that reads shapes, and both docstrings say so.
- `test_the_scan_leaves_a_count_that_is_worked_out` moved its `worked-out` and `a-call` (`str(held)`) cases to the found list, and is now `test_the_scan_leaves_what_it_does_not_read`, with the cases that are still not read.
- `OWN_COUNTS` gains `conftest.py: 1` and the two new files (`test_family_config_appends.py: 2`, `test_doctor_paste_through_git.py: 1`), each a test that builds an environment on purpose. A second sum in `conftest.py`, or none, is named, as it is for every other file.
- `test_the_scan_names_each_helper_that_worked_its_count_out` holds the three helpers' old source, so the scan names the line of each.

**Yes.** Measured on a pasted `git -C <root> submodule update --init --recursive --checkout -- code` of an empty mount, with `GIT_TRACE`:

| | no rewrite (base) | with the rewrite |
|---|---|---|
| clone's store | 8 loose objects, 0 packs | 0 loose objects, 1 pack |
| link count of the loose objects | 2 (linked to the remote's own files) | none |
| `upload-pack` in the trace | yes (a local clone starts it to list the refs) | yes |
| `pack-objects` and `index-pack` in the trace | no | yes |
| `origin`, and the `submodule.code.url` the root records | the plain path | the plain path |

Across the whole of `test_doctor_legs_members_next.py` (trace2 events of all 56 `sh -c` lines it runs), no pasted line ran `pack-objects`: every pasted clone was a local clone. A pasted fetch needs no rewrite (it asks `upload-pack` whatever the URL is), so the rewrite matters where a mount is cloned: 14 of the 31 tests that paste.

**The issue's Verify names `upload-pack` and `pack-objects` for the `GIT_TRACE` run. `upload-pack` is in both columns,** so the tests ask for `pack-objects` and `index-pack`, and for the store `assert_store_read_through_git` asks of every other clone.

#645 keeps maintenance from starting, so nothing breaks today. #662 put every other read of these remotes through git as a second guard, and `paste` was the one left. The rewrite is for the directory resolved, which is how `.gitmodules` records each url; no pasted line names a remote itself, so `typed=True` is not needed.

`tests/test_doctor_paste_through_git.py` (10 tests):
- a pasted update of an empty mount of a project, and of a holder's member with its legs: `pack-objects` and `index-pack` run, `assert_read_through_git`, `origin` equal to the url `.gitmodules` records, no `insteadOf` stored in the root or the clone;
- the control, handed no remotes: a local clone, loose objects, no `pack-objects`;
- the entries in order: the environment's held entries and the session's two settings, then `protocol.file.allow`, then each `insteadOf` entry;
- the walk: every `paste` of the doctor file is given three arguments, and the walk's own five cases.

Base `eaa72d9e`, before: the three family files 117 passed; `test_no_auto_maintenance.py`, `test_clone_just_pushed.py` and `test_repo_hygiene.py` 132 passed; `test_doctor_legs_members_next.py` 127 passed.

On the head `b9ad1187`, through `run9.sh` (gh stub first, `GH_TOKEN=bogus`, empty `GH_CONFIG_DIR`):
- **340 passed:** `test_family_head_lost_gitmodules.py`, `test_family_bootstrap_gate_order.py`, `test_family_add_spellings_remedies.py`, `test_family_config_appends.py`, `test_no_auto_maintenance.py`, `test_clone_just_pushed.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`.
- **261 passed:** `test_doctor_paste_through_git.py`, `test_doctor_legs_members_next.py`, `test_validate_family_remedies_typed.py`, `test_doctor_next_is_a_command.py`, `test_doctor_validator_rows.py`.

New or changed tests: 31 new (6 + 10 + 15; `test_no_auto_maintenance.py` goes from 36 to 51 tests).

**Fail on the base**, in a tree detached at `eaa72d9e` with the new test files copied in:
- the helpers' tests and the scan: 4 failed, 53 passed. The empty-count case fails for `on_disk`, `with_config` and the typing helper (`ValueError: invalid literal for int()`), and `test_no_test_sets_a_git_config_count_of_its_own` names `test_clone_just_pushed.py [99, 233]`, `test_family_add_spellings_remedies.py [138]`, `test_family_bootstrap_gate_order.py [136]` and `test_family_head_lost_gitmodules.py [98]`;
- the same file with the scan as it was (written counts only): 14 failed;
- `test_doctor_paste_through_git.py`: ImportError (`remotes_of`), 10 not run; with `paste` ignoring the remotes (mutant 3a), the three pasted-clone tests fail.

**Mutants**, 22 in a throwaway clone at the head, each caught:
- 1a-1c each helper back to a hand-worked sum: `test_no_test_sets_a_git_config_count_of_its_own`, and the empty-count case; 1d the clone's `env`: the same scan test; 1e `on_disk` keeping a count of 1 held in a name, which the scan does not read: the held-two case;
- 2a-2d `str`, `.format`, arithmetic and f-string dropped from `is_a_worked_out_count`; 2e-2h each of the four readers back to written counts; 2i-2k an `OWN_COUNTS` entry removed; 2z the scan as it was on the base;
- 3a `paste` not handing `remotes` on; 3b `paste_env` dropping the rewrite; 3c the rewrite put before `protocol.file.allow`; 3d one caller not handing the remotes (the walk); 3e `remotes_of` naming the wrong directory.

- origin/main `fc867207`: one conflict, `tests/test_non_utf8_git_reads.py`, the one #818 has with main at its old head. It is the same with the base `eaa72d9e`, and none of the files here conflicts.
- #818's head now, `4a553e17` (main merged in): clean.
- The 141 other heads (every `fix/*` and `l3-*` branch, and the pull heads from #700): the conflicts they show are the same with the base, in files this commit does not touch (`tests/test_non_utf8_git_reads.py`, `tests/test_adopt_git_reads.py`, `tests/test_dry_run_parity.py` and others). This commit adds one: #767's stale PR head `bd6d2afe`, which landed as `98f14976`.
- The open heads that edit the files here merge clean with it: #820, #822, #828, #748, #747, #761, #762 and #763. #774 and #775 conflict only in `tests/test_non_utf8_git_reads.py`, as they do with the base.

- Left out: `GIT_CONFIG_KEY_{n}` and `GIT_CONFIG_VALUE_{n}` names built from an index (an f-string) are not read, nor is a count held in a name and set later (`new = str(n + 1)`, then `{"GIT_CONFIG_COUNT": new}`). A test that did that would pass the scan. Reading the indexed key names in the same four places would close it.
- `typed=True` is not used for `paste`; the entries are for the resolved directory.

Review: rv848, lane 5's independent review, READY (issuecomment-6240157020); lane 2's independent review, READY (issuecomment-6240228302); rv848's re-verification of the fix round 79b2eac, READY (issuecomment-6241026517). CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 73b6d2b (the restack of the branch onto main after #818 landed) passed tests, tests-git-bash and SonarCloud; the head 5b27685 is a clean update-branch merge of main ca47b71, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 4dbb4a9 over main ca47b71: the adopt_reencoded_files, clone_just_pushed, command_scopes_built_at_the_test, doctor_legs_members_next, doctor_paste_through_git, family_add_spellings_remedies, family_bootstrap_gate_order, family_config_appends, family_head_lost_gitmodules, family_init_asks_every_value, no_auto_maintenance, repo_hygiene, agents_md_codes, cli_reference sets, 645 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 #835

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

Copy link
Copy Markdown

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

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 2 days and 10 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 6fbf330 into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/763-holder-docs-agree branch October 18, 2026 03:50
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
… advice and a member's exit code say what happened, and the gate's `git rm --cached`, guarded `mv` and section lines type as printed where they failed (#839) (#876)

Stacked on #819 (`fix/768-gitmodules-values`, `233a334e`); retarget to main once it lands.

#839's seven lines, in the family holder's two pinned scripts, `templates/family-root/scripts/bootstrap.py` and `templates/family-root/scripts/validate-family.py`. Each line was probed on `233a334e` first; one already held there (5).

**1. The credential advice beside "not for want of a credential"** (`fetch_members`).
- Before: "If the members are PRIVATE, set SHAPE_LEGS_TOKEN ..." was printed for every failed `git submodule update`, and then "NOT CHECKED OUT, and not for want of a credential" for every row `unfetchable` named.
- Now `credential_advice` prints it only where git asked for a credential. It is not printed where git's init died at a gitlink it has no url for (`stopper`), or where git read nothing from `.gitmodules`: a file a merge left unmerged, a value git dies on, or a file it cannot parse. Git fetched nothing in those states.
- Beside a denial, the advice reads "If the members the next line does not name are PRIVATE". This is the Direction's "both kinds" case.
- One step past the Direction's literal rule: where every member is checked out and a leg's fetch failed for want of a credential, no row is denied and the advice stands. The token's name is `SHAPE_LEGS_TOKEN`, and the legs are what it is for.
- Tests: `test_no_advice_is_printed_beside_its_denial` covers nine states, every reason `unfetchable` gives where git exits non-zero (no section, the variant, no url, unmerged, a refused value, an unparsable file, a stray, a hand mount, a gitlink outside `members/`). It asserts bootstrap's own stderr lines exactly: the fetch line, the denial, the failure, and no advice. `test_the_credential_s_skip_prints_the_advice_alone` (three states), `test_a_leg_the_credential_s_keeps_the_advice_with_every_member_out` and `test_beside_a_denial_the_advice_is_for_the_members_it_does_not_name` pin the other side.

**2. A member's refusal and its finding both read `make bootstrap exited 2`** (`run_member_target`). Chosen: tell them apart, by reading make's own `Error <code>` line.
- Measured on GNU Make 4.3: make exits 2 for a command exiting 1, 2, 3 or 126, and 2 again for a failure of its own (`No rule to make target`). It prints `make: *** [Makefile:N: bootstrap] Error <code>` (`make[1]:` under another make) for a failing command, and no such line for its own failure.
- `make siblings` runs the member's `scripts/bootstrap.py` directly and prints "its own bootstrap exited N". The direct run was not chosen: `--members-make validate`, which the holder's own `make validate` runs, has no script to call, and the docs say the holder runs each member's own `make bootstrap`.
- So make still runs. `run_make` passes make's stderr on as it comes and reads the code from the last `Error <code>` line.
- What the person reads now, at the end of the run:
  - a member's finding: `IRRS (make bootstrap: the member's command exited 1)`, under the member's own `bootstrap FAILED: ...` and make's `Error 1`;
  - a member's refusal: `IRRS (make bootstrap: the member's command exited 2)`, under its `REFUSED ...` and make's `Error 2`;
  - make's own failure: ``IRRS (make bootstrap exited 2 and printed no `Error <code>` line for a command of the member's: make's own message above says what failed)``, under make's own message.
  - A make speaking another language, or a command killed by a signal, gets the third line too, which is still true there.
- Test: `test_a_finding_a_refusal_and_make_s_own_failure_are_told_apart`, for `bootstrap` and for `validate`: four members, a finding, a pass, a refusal and no rule, with the final line asserted exactly and the members' and make's own lines asserted on stderr. `tests/test_bootstrap_spelling.py`'s make line follows the new text.

**3. The `git rm --cached` that refuses over a checkout of its own.**
- Reproduced on the gate's own state, as the Direction asks: `mkdir Members && git mv members/IRRS Members/IRRS`, staged, then a commit (or only an untracked file) in the moved checkout.
- In that state the gate does not print the wrong-case move. It prints three findings in every mode:
  - `member-gitlink-unnamed` for `Members/IRRS`, a second path to the member, whose line is `git rm --cached -- Members/IRRS`;
  - `gitmodules-path-unnamed`, which prints no command;
  - `member-gitlink-removal-staged` for the row's own gitlink.
- Typed as printed, the `git rm` refused on git 2.43: `error: the following file has staged content different from both the file and the HEAD`, exit 1. It refused with the commit, with the untracked file alone, and with both.
- `_forced` reads the state git refuses: the index stages the gitlink at a commit HEAD does not record there, and a `.git` entry is on disk at it. There the line takes `-f`, and the finding says what `-f` does, in #828's words: "-f takes out a staged gitlink whose checkout has moved on or holds a file git does not track, which git rm otherwise refuses, and with --cached touches no file".
- Where HEAD records nothing at the variant, the finding also says that nothing of it is left to commit. The row's own removal-staged lines put the member back, and a commit between the two would have un-registered it.
- The wrong-case move's own `git rm -r --cached --ignore-unmatch` takes `-f` the same way. That state is a committed variant whose checkout moved on, staged, with an untracked file in it.
- Tests: `test_a_git_mv_staged_with_work_in_the_checkout_is_undone_as_printed` (commit, untracked) types every printed line as printed, then finds nothing left to commit, the gate passing in every mode and HEAD recording the member at its pin. `test_a_checkout_moved_on_at_the_variant_is_moved_with_f` pins the move's `-f`. `test_a_staged_second_path_with_nothing_on_disk_takes_no_f` checks there is no `-f` where nothing is on disk.

**4. The guard that follows a link, and its missing nothing-there clause** (`_wrong_case_move`, now `_guarded_move`). Comment 6237599648 is the same fault and is fixed here too.
- The guard is now `[ ! -L V ] && { [ ! -e V ] || [ V -ef R ] || [ -e V/.git ]; } || mv -- V ../<V with - for />`, the issue's form. The sentence says it "moves nothing where nothing is there, where the two names are one directory or where it holds a .git entry, a checkout, and moves a symbolic link there wherever it points, the link and never what it points at", typed "in a POSIX shell (Git Bash on Windows)" as #865's text says.
- Typed in dash (this host's `/bin/sh`) and bash 5.2 on the issue's seven layouts:

  | at `Members/IRRS` | base guard | #865's guard | this guard |
  |---|---|---|---|
  | nothing | `mv: cannot stat`, exit 1 | kept, exit 0 | kept, exit 0 |
  | a link to nowhere | moved | moved | moved |
  | a link to the row's directory | kept | kept | moved |
  | a link to a clone (a `.git` file) | kept | kept | moved |
  | `Members` a link to `members` | kept | kept | kept |
  | an empty directory apart | moved | moved | moved |
  | a checkout apart | kept | kept | kept |

- **#865's `family.py` guard still keeps a link at the variant.** `scripts/family.py` at #865's `f7072d08` (`_closing_sentences`) prints `[ ! -e V ] && [ ! -L V ] || [ V -ef R ] || [ -e V/.git ] || mv ...`, which answers as base does on the two link rows above. The validator's text and `family.py`'s therefore differ in the link clause until #865 takes the same form. This pull request does not edit `scripts/family.py`; #865's re-review is asked to rule on it.
- Tests: `test_a_link_or_nothing_at_the_variant_is_typed_as_printed` covers link-to-the-row, link-to-a-clone, a dangling link and nothing, each in sh and in bash, in w840's state. After the move's lines and their commit, the guard is typed as printed: a link is moved with its target untouched, nothing exits 0, the gate passes in every mode and `family.py add` of SPARE goes through. `test_the_guard_moves_a_link_always_and_nothing_where_nothing_is` types the gate's own `_guarded_move` on all seven layouts in both shells. The N-2 test's three layouts in `tests/test_validate_family_remedies_typed.py` pass unchanged. `variant_refused` in `tests/test_family_head_lost_gitmodules.py` follows the new text.

**5. Comment 6237354577 (a), bootstrap's `gitmodules_sections` reading only the work tree's file.** This already held at `233a334e`: #819's `gitmodules_source` reads the work tree's file, then `:.gitmodules`, then `HEAD:.gitmodules`. #819's own `test_where_the_work_tree_has_no_gitmodules_git_reads_the_index_s_or_head_s` pins it, and `test_the_credential_s_skip_prints_the_advice_alone`'s index-read and HEAD-read cases ask it again here. No code change.

**6. Comment 6237354577 (b), `member-gitmodules-absent`'s `git config -f` lines where the work tree's `.gitmodules` is gone.**
- Measured on `233a334e`: OTHER's section was taken out of HEAD's file and the work tree's file deleted (or its deletion staged). The finding's two lines made a file holding OTHER's section alone, and staged and committed, HEAD lost MEMBER's section: a second round.
- Now the first of the url-absent and absent findings (`member-gitmodules-url-absent`, `member-gitmodules-absent`) puts it back first, from where git reads it (`git checkout -- .gitmodules`, or `git checkout HEAD -- .gitmodules` where the index holds none), as #820's `member-gitmodules-url-absent` does.
- No later url-absent or absent finding puts it back. Measured on `233a334e`: with two url-less sections, the second `member-gitmodules-url-absent` put the file back over the url the first had written, and an absent section before a url-less one was lost the same way.
- Other findings that write or put back the file are not ordered with these two. rv876 measured two such states: the wrong-case move's `git config -f .gitmodules` line, and `member-gitlink-removal-staged`'s `git checkout HEAD -- .gitmodules`. Each behaves the same on `233a334e`, and both are filed in #879 (C1, C2).
- No finding puts it back after a `gitmodules-value-refused` that already put HEAD's back (`refused`).
- Tests: `test_a_section_absent_from_head_is_written_into_the_file_put_back` (deleted, deletion staged; every mode; typed and committed, HEAD keeps MEMBER's section). `test_two_findings_that_write_the_file_put_it_back_once` (two url-less, absent then url-less, url-less then absent). `test_a_value_finding_that_put_head_s_back_is_not_put_back_over`.

**7. Comment 6237599648, the move printed unguarded for an absent variant.** Line 4's nothing-there clause: the `nothing` rows above.

- `run9s.sh` with `SHARDS=3` at `77f8a6c8`, on 95 test files: the issue's Verify files, every test file that runs or reads either script (`git grep -l 'bootstrap.py\|validate-family' -- tests`), `tests/test_family.py` and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`):
  - git 2.43.0: 6335 passed, 59 skipped, rc 0 (shards 1596 + 5, 2203 + 27, 2536 + 27).
  - git 2.47.1: 6335 passed, 59 skipped, rc 0, the same in each shard.
  - The 59 skips are the same on both versions, and none is in the new file. 27 wait on #775 (siblings.py's #733 rules). The rest are templates that already quote a value (22), Windows or a disk that ignores case (5), and empty parameter sets (5).
- On the predicted squash of #819 and this branch onto main `33419925` (`9aca0f4d`; see the merge checks), git 2.43.0, `run9.sh`. Main has changed `tests/test_bootstrap_spelling.py` and `tests/test_family.py` since `233a334e`.
  - `tests/test_family.py`: 112 passed.
  - `tests/test_bootstrap_spelling.py`, the new file, `test_family_head_lost_gitmodules.py`, `test_family_docs_one_spelling.py`, `test_bootstrap_after_bump.py`, `test_validate_family_remedies_typed.py`, `test_family_bootstrap_gate_order.py`, `test_pinned_copy_text.py` and the trio: 656 passed.

- New tests: `tests/test_family_holder_bootstrap_texts.py`, 48 cases, all passing at `77f8a6c8`.
- On `233a334e`'s two scripts, with this branch's tests: 27 fail and 14 error. The 14 are the seven-layout guard test, which calls the gate's `_guarded_move`, a function `233a334e` does not have. 7 pass there by design: the three credential-skip cases (line 1's other half, and comment (a), which held), the leg case, url-less-then-absent, the value-refused order and the no-`-f` case. Each guards the fix against going too far.
- Mutants, one or more per new hunk, all 20 caught:
  - the advice always printed;
  - the advice never printed;
  - the advice's "does not name" wording dropped;
  - the advice kept only for rows the credential leaves out (the Direction's literal rule);
  - the exit code always make's;
  - make's stderr not passed on;
  - the first `Error` line read in place of the last;
  - the STRAY `-f` dropped;
  - the STRAY's "nothing left to commit" turned round;
  - the move's `-f` dropped;
  - `_forced` ignoring HEAD;
  - `_forced` ignoring whether a checkout is there;
  - the `-L` dropped;
  - the nothing-there clause dropped;
  - the `-f` clause dropped from the text;
  - the absent finding's put-back dropped;
  - every url-less finding putting back;
  - every absent finding putting back;
  - the value-refused flag ignored;
  - `_check_members` passing no flag.

  `_forced` ignoring HEAD is caught by the existing `test_a_checkout_at_the_variant_is_kept_and_handled_as_the_text_says`.

- Both files are in `FAMILY_COPIED_VERBATIM`, so each holder takes one `upstream-changed` row per file.
- No committed digest or byte comparison names either file: `templates/family-root/contracts/shape-pin.yaml` holds only placeholders, and `git grep` finds no sha256 of them in `tests/`.
- `docs/cli.md` takes only `validate-family.py`'s first docstring line, which is unchanged.
- No finding code or refusal code is new or renamed. `AGENTS.md`, `templates/family-root/AGENTS-shape.md` and both READMEs say nothing these lines change. Each script's module docstring says what its lines now do.
- Cognitive complexity, counted by hand:
  - `fetch_members` drops from 15 to 14, since its failure report moved to `fetch_incomplete`.
  - `_check_gitlinks_registered` was over 15 on base (18) and drops to 17; its `def` line keeps its first line.
  - `_check_members` stays at 14.
  - Every new function is at 6 or under.
- The new tests share their helpers within the file, and no assert joins conditions with `and`.

`git merge-tree --write-tree` of `77f8a6c8`, fetched 2026-10-18 at 05:02Z:
- origin/main `33419925`: clean. Main has moved past `d7befb91`, and #822 landed in it as `6fbf3301`.
- #865 `f7072d08`: clean.
- #870 `c0d8dd16` (lane 4's round 1; it edits both scripts): clean, with no conflicting hunk.
- #822's head `0519f9fd`: conflicts in both scripts, and `233a334e` (#819's head) conflicts with it in `validate-family.py` too.
  - This is a criss-cross with two merge bases, `b9b70692` and `5afb24b9`. Since `b9b70692`, #822 changes neither script.
  - #822 has landed, and its content is in main, which merges clean.
- Predicted squash of #819 and this branch onto main `33419925` (`9aca0f4d`, tree `ee2ae53c`): clean against #865, #870 and #822's head. #822's head leaves the tree unchanged.

- After line 3's lines, typed as printed in the `git mv` state, the checkout `git mv` moved stays at `Members/IRRS`, untracked. Measured: the gate passes in every mode, and `family.py add` then refuses it, `family-members-dir-variant`, with `mv -- Members/IRRS <new name>`. Neither the STRAY finding nor `member-gitlink-removal-staged` says what that directory is, or that its commits are in the member's store. The wrong-case move's closing sentences say both. Filed in #879 (C4).
- `scripts/family.py`'s guard: see line 4.

Review: rv876's independent review, READY at `77f8a6c8` with no MUST (#876 issuecomment-6246717354); its SHOULD 1, a claim in the body, is fixed in the body, and its five candidates are filed as #879. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 a7f6c56 (the restack of the branch onto main after #819 landed) passed tests, tests-git-bash and SonarCloud; the head ad351e4 is a clean update-branch merge of main def45de, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 7715483 over main def45de: the bootstrap_spelling, family_head_lost_gitmodules, family_holder_bootstrap_texts, family_members_not_a_list, validate_family_duplicates, repo_hygiene, agents_md_codes, cli_reference sets, 616 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 #839

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