Skip to content

family.py add: the move back of a member's gitlink points its section of .gitmodules back, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closing mv is guarded and leaves a checkout to delete by hand, and --sparse follows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) - #828

Merged
brettheap merged 21 commits into
mainfrom
fix/761-add-move-back-gitmodules
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

Written on #767's head bbb961f8, as deb83e2e. #767 has since landed as 98f14976, a squash holding the tree of its final head bd6d2afe. The branch then merged bd6d2afe (clean) and main 40e678e6, taking its own whole file for #767's four files; main changed none of the five files after the squash. The head is d4e8c65a, and its diff against main is this PR's five files. Its file contents equal w761's predicted restack e607c304, which passed 63 and 207 tests; that scratch tree had lost scripts/family.py's executable bit, which the head keeps as main has it (100755).

Goal (#761): every command that the move back of a member's gitlink (#646) and the rename back of members/ (#685) print can be typed as printed, in the state each describes, and leaves the holder passing its gate with each member where its row says. The issue's four lines and rv685's three (issuecomment-6227887960) are all in _members_spellings and _spellings_refusal in scripts/family.py. Each state was first typed as printed through add, on the base and with each candidate direction, on git 2.43 on Linux. A disk that folds case is asked in process (disk_as), as #646 and #685 did; CI is Linux only.

Fix round 2 (commits ef38aad and ce0f2a5 on 6cf02ed)

Lane 2's independent review, rv828, read 6cf02edf and said NOT READY. This round makes two commits:

  • ef38aad0 merges main bad05d21. It is clean as text (tree 75500249, the tree the review tested).
  • ce0f2a5f is the fix.

Main has since moved to fc867207 (#746, bump-leg.py only). It merges cleanly, and it touches none of this PR's files.

MUST 1: the merge with #684 broke two tests, and one state failed again when typed

What failed. #684 (fa04d2cd) added _members_file_left_in_head. _checked_mount_path asks it before _members_spellings. A members file that HEAD and the index hold, with nothing on disk, is now #684's member-path-unaddable, and this PR's set-aside of that file is never reached. On the merged tree, the four test files gave 2 failed and 173 passed (m0-merged.log). The two failures were test_an_entry_at_members_itself_is_set_aside_first, for the file deleted from disk and the file a sparse checkout keeps off disk.

For the sparse file, #684's printed git rm --cached -- members exits 1 on the merged code: matched paths that exist outside of your sparse-checkout definition. It fails the same way where the sparse checkout marks the file and where its rules leave it out (p1-684-sparse-merged-base.log). So the members half of Codex's P2 1 failed again once main was merged.

The fix. _members_file_left_in_head now prints git rm --cached --sparse -- members, which needs git 2.34 or later, wherever a sparse checkout marks the file or its rules leave it out. This is the review's own choice. Its first line then says "as the sparse checkout leaves it out" in place of "its deletion not staged". Everywhere else #684's text is unchanged, byte for byte (tests/test_family_holder_states.py: 42 passed).

Why git rm --cached --sparse and not the git mv --sparse that _set_aside gives an entry marked off disk: #684 allows a directory made by hand at members. git rm --cached touches no file, whatever is there.

The tests.

SHOULD 2: a clone of its own, or a link, found on disk (taken)

New: test_what_is_on_disk_and_no_checkout_keeps_its_mv. It covers an untracked clone at Members/X (its .git a directory) and a link Members/X -> ../members/IRRS. Each gets #574's mv -- Members/X <new name>. Typed as printed, the mv moves it out whole, and the same add goes through. The review's surviving mutants R9 and R10 are now caught.

SHOULD 3: an unmerged file at or under the row's path (stays)

Not fixed. Setting such a file aside is not cheap:

  • git mv refuses a conflicted entry.
  • An unmerged file on disk at the row's path would still stand where the update checks the member out.
  • So it needs a remedy of its own, and possibly a refusal that comes first.

The review's mutant R2b still survives (mut/R2b.log, 78 passed). This is a checklist candidate below. I could not read #840 to say whether it is one of #840's six.

SHOULD 4: S3776 (taken)

The parts this PR grew moved out of the two functions. The text is unchanged: every typed and compared test passes.

  • _spellings_refusal lost:
    • the set-aside part, now _set_aside_part;
    • the move back, now _move_back_parts and _move_back_line;
    • its closing sentences, now _closing_sentences;
    • the each closure, now _each.
  • _members_spellings lost:
    • the row bookkeeping, now _rows_held;
    • the cached sparse questions, now _SparseAsked;
    • the set-aside call, now _in_the_way.

These counts come from my own AST estimate (f828r2/cogc.py), not from Sonar:

Function main bad05d21 6cf02edf ce0f2a5f
_spellings_refusal 52 88 51
_members_spellings 48 71 49

Each new helper scores 13 or less: _members_file_left_in_head 13, _set_aside 12, _move_back_line 11, _set_aside_part 10, _rows_held 9, _move_back_parts 6, _in_the_way 5, _closing_sentences 0.

NIT 5: surviving mutants R3 and R20 (taken)

New: test_only_a_file_or_link_in_a_moves_way_is_set_aside, run in process. A file at members with no gitlink moved back is not set aside (R20). A gitlink at members is never set aside, since taking it out of the index would un-register what it records (R3). Both mutants are now caught.

The unmerged file at members belongs to SHOULD 3's candidate.

NIT 6: duplication (taken)

The three staged set-aside tests are now one table, STAGED_ASIDE, under the first test's name, test_what_is_set_aside_is_committed_with_the_move. Its states are on disk, kept off disk by a sparse checkout, and deleted from disk.

NIT 7: the shell dialect (followed)

The guarded mv is now to be typed "in a POSIX shell (Git Bash on Windows)". This is the review's suggestion, and the same words family.py already uses for its other POSIX-only line (rm -rf). The text stays POSIX-only, per #532. No PowerShell form is printed, since the sentence before it tells a folding Windows disk to type nothing.

Runs

Every run went through run9.sh, with basetemps v110/bt/f828r2-* and logs in v116/f828r2/. The git is 2.43 unless the table says otherwise.

Log Tree Files Result
m0-merged.log the merge ef38aad0 the four test files this PR changes 2 failed, 173 passed (MUST 1)
t1.log ce0f2a5f's contents the four test files this PR changes, and test_family_holder_states.py 224 passed
g247.log ce0f2a5f, git 2.47.1 test_family_add_move_back_sections.py, test_family_add_member_variant.py, test_family_holder_states.py 120 passed
w1.log ce0f2a5f's contents the files that call the changed functions (test_family_add_excludes.py, test_family_add_refusals.py, test_family_dry_run.py, test_family_head_lost_gitmodules.py, test_family_member_path.py, test_family_refuse_up_front.py, test_readme_windows_quoting_sentence.py, test_shape_doctor.py, test_validate_family_remedies_typed.py, test_validate_family_unnamed_mounts.py) and the trio (test_repo_hygiene.py, test_agents_md_codes.py, test_cli_reference.py) 959 passed, 3 skipped, 1 xfailed
w2a.log and w2b.log ce0f2a5f the other 42 files that run family.py 1173 passed, 20 skipped; 1364 passed, 1 skipped

The skips are environmental:

  • PowerShell lines run only on a Windows host.
  • One test needs a disk that ignores case.
  • Templates that quote a value skip some cases.

CI's git 2.55 was not run here.

Fails on the merged base

These are the commit's two changed test files, run against ef38aad0's family.py (base-final-merged.log): 53 failed, 25 passed.

Mutants

These ran through mutate2.py in a scratch clone of ce0f2a5f's contents (mut/summary.txt, one log each). 19 of 20 are caught. R2b survives, as SHOULD 3 says.

Mutant What it undoes Result
N1 #684's --sparse not asked of the mark caught
N2 #684's --sparse not asked of the rules caught
N3c #684's removal without --sparse, the test expecting it caught: git rm --cached -- members refused, exist outside of your sparse-checkout definition
N4 #684's first line unchanged for the sparse file caught, 2 failed
N5 the closing sentence without "(Git Bash on Windows)" caught, 4 failed
R9 a clone of its own taken for a checkout caught: "Delete by hand" printed
R10 a link to a checkout taken for a checkout caught
R3 a gitlink at members set aside caught
R20 the set-aside asked where no gitlink is moved back caught
R2b an unmerged file counted as in the way survives (78 passed); SHOULD 3
M1, M7, M8, M10, M11c, M12c, M13c, M15, M17 round 1's mutants, at the code's new places each caught
M18 the move's commit blind to what is set aside (_move_back_parts) caught

Merge checks

git merge-tree --write-tree of ce0f2a5f:

Checklist candidates

Fix round (commit 6cf02ed, one commit on the head d4e8c65)

Codex reviewed d4e8c65a (review 5558731894) and left two P2 findings. Both hold. Each was reproduced on d4e8c65a by typing the printed remedy, and each is fixed. A third defect in the same function turned up while testing the fix, and it is fixed too. Main is not merged; the branch's diff against main is still this PR's five files.

P2 1: a sparse checkout decided whose the gitlink was (scripts/family.py:3409)

What failed. _members_spellings dropped a row from blocked before building owners if a sparse checkout marked any of the row's files (skip-worktree) or its rules left one out. The member's own gitlink at the variant then stopped counting as the member's own, and got #574's remedy instead. The state: git mv members/IRRS Members/IRRS, committed, plus a committed members/IRRS/notes. Typed as printed on d4e8c65a in four states (marked on disk, marked off disk, left out by the rules on disk, left out off disk; r1-p2-1-base.log):

  • printed: git mv -- Members/IRRS <new name>, and commit the rename;
  • after typing it, .gitmodules said submodule.members/IRRS.path renamed-1, and HEAD held members/IRRS as a tree with no gitlink. The member was un-registered.

The same filter applied to a file at members itself. After git mv members Members, with that file kept off disk by a sparse checkout, the file was not set aside. The move back's git update-index then failed with error: 'members/IRRS' appears as both a file and as a directory, after the git rm had already taken the variants out (r1b-members-marked-base.log).

The fix. Both filters are gone, so the sparse checkout no longer decides whose the gitlink is. _set_aside now names each entry in the way by the same rule the index's own remedies use:

The entry in the way Printed Plain git, measured with 2.43 and 2.47.1
marked, on disk git mv -- <entry> <new name> plain git mv takes it
marked, off disk git mv --sparse -- <entry> <new name>, which needs git 2.38 or later plain git mv and git rm --cached refuse it, exit 1
left out by the rules, its bit cleared, on disk git mv --sparse -- <entry> <new name> plain git mv and git rm --cached refuse it, exit 1
left out by the rules, its bit cleared, off disk git rm -r --cached --sparse -- <entry>, which needs git 2.34 or later plain git rm --cached refuses it, exit 1; git mv, with or without --sparse, says bad source, exit 128

Each refusal says exist outside of your sparse-checkout definition. The probe logs probe1-243.log and probe1-247.log match line for line. A new remedy, ASIDE_SPARSE, prints the git mv --sparse line. ASIDE_OUT takes --sparse where the rules leave its entry out, as UNSTAGE already did.

P2 2: the closing mv failed where nothing was at the variant (scripts/family.py:3931)

What failed. The guard was [ X -ef Y ] || [ -e X/.git ] || mv -- X ../X-flat. Where nothing is at X, both tests are false, and mv exits 1 with mv: cannot stat 'Members/IRRS': No such file or directory, so a pasted remedy stopped before the update. typed_as_printed hid this, because it skipped any mv whose source was absent. With only that helper changed to type every mv as printed, d4e8c65a failed four tests on that line (r2-helper-only-base.log):

  • kept off disk by a sparse checkout (Codex's kept_off_disk);
  • nothing on disk in that spelling;
  • with nothing left to commit;
  • unmerged, with nothing in progress.

The fix. The guard now begins with a test for "nothing there":

[ ! -e X ] && [ ! -L X ] || [ X -ef Y ] || [ -e X/.git ] || mv -- X ../X-flat

Nothing there moves nothing and exits 0. A link there that points nowhere, which -e treats as nothing, is still moved, as the old guard moved it. Measured in dash with nothing there: the old guard exits 1 and the new one exits 0. With a link that points nowhere, the new guard exits 0 and moves the link. The sentence after the command now reads "which moves nothing where nothing is there, where the two names are one directory or where it holds a .git entry, a checkout".

typed_as_printed now types every mv, guarded or not, exactly as printed, and each must exit 0. 13 typed lists in the three test files now include the guarded mv that the helper used to skip.

Found on the way: a commit printed with nothing to commit

Mutant M6 below first survived, and the reason was a defect already in d4e8c65a. _set_aside said a removal leaves a change to commit wherever HEAD holds anything at the entry's path. Take a staged git mv members/IRRS Members/IRRS, then a new file staged at members/IRRS and deleted from disk. HEAD holds the member's gitlink at that path, and the move puts the same gitlink back, so nothing is left to commit. The remedy still printed ", and commit that", and git commit failed with nothing to commit (p2-base.log, on d4e8c65a). Now HEAD's gitlink at a row the move puts back no longer counts. This is outside Codex's two findings. It is one expression in the function this round already changes.

Tests

tests/test_family_add_move_back_sections.py now has 34 cases. The new ones:

  • test_a_blocker_a_sparse_checkout_marks_or_leaves_out_is_set_aside_too, four states (P2 1). The two "left out" states use non-cone rules that leave out members/IRRS/notes alone, and carry needs_check_rules. The "marked, off disk" state skips below git 2.38.
  • test_an_entry_at_members_itself_is_set_aside_first[a file a sparse checkout keeps off disk] (P2 1, the file at members).
  • test_what_a_sparse_checkout_keeps_off_disk_is_committed_with_the_move: a staged new file, marked and off disk, is renamed with git mv --sparse, and the commit is printed.
  • test_a_removal_where_head_records_the_gitlink_leaves_no_commit: the defect found on the way.
  • test_a_link_pointing_nowhere_at_the_variant_is_still_moved_out, for the guard's -L test. It uses the layout where HEAD alone holds the variant. With the variant's gitlink still in the index, git 2.47.1's own git status refuses the link (expected submodule path 'Members/IRRS' not to be a symbolic link). The first version of this test failed that way on 2.47.1 (g247.log).

Changed:

  • In tests/test_family_add_member_variant.py: typed_as_printed, guarded(), the closing sentence in moved_back(), and the typed lists.
  • In tests/test_family_add_move_back_sections.py: set_aside(), which now gives the --sparse forms.
  • In tests/test_family_add_spellings_remedies.py: one typed list.

Runs

Every run went through run9.sh, with the real gh unreachable and basetemps under v110/bt/f828-*. The logs are in v116/f828/. The git is 2.43 unless the table says otherwise.

Log Tree Files Result
r1.log the commit's contents, before the link test was reworked the four test files this PR changes 175 passed
r3.log 6cf02edf test_family_add_move_back_sections.py 34 passed
g247.log as r1.log, git 2.47.1 the sections file, test_family_add_member_variant.py 70 passed, 1 failed (the link test's first layout, since reworked)
g247-link.log, g243-link.log 6cf02edf's contents the reworked link test, git 2.47.1 and git 2.43 1 passed each
w1.log 6cf02edf's contents for every file it reads the files that call the changed functions (test_family_add_excludes.py, test_family_add_refusals.py, test_family_dry_run.py, test_family_head_lost_gitmodules.py, test_family_member_path.py, test_readme_windows_quoting_sentence.py, test_shape_doctor.py, test_validate_family_unnamed_mounts.py) and the trio (test_repo_hygiene.py, test_agents_md_codes.py, test_cli_reference.py) 862 passed, 3 skipped, 1 xfailed
w2a.log 6cf02edf 21 more files that run family.py 1218 passed, 20 skipped
w2b.log 6cf02edf the other 21 files that run family.py 1312 passed, 1 skipped
refresh-predicted.log 6cf02edf merged with main d66a9054 (a scratch tree, not pushed) the four test files this PR changes 175 passed

The skips are environmental: PowerShell lines on a Windows host only, a disk that ignores case, and templates that quote a value.

Fails on d4e8c65

base-final.log: d4e8c65a's family.py with this commit's tests gives 56 failed, 59 passed. All 8 new cases fail. The other 48 failures are tests whose expected text now includes the new guard; 4 of those also fail by behaviour with only the helper changed (r2-helper-only-base.log, above).

Mutants

mutate.py, in a scratch clone. Each mutant undoes one new hunk, runs the test named, and restores the file. Mutants marked "c" change the test's expected text to match, so the failure shows behaviour, not just text. All 20 are caught (mut/summary.txt, one log each). M1-M14, M16 and M17 ran on a tree that differs from 6cf02edf only in docstrings. M15 and M12c ran on 6cf02edf's contents.

Mutant What it undoes Result
M1 the sparse filter on blocked put back (P2 1) caught, 4 failed: git mv -- Members/IRRS <new name> printed again
M2 the sparse filter on a file at members put back caught, 1 failed
M3, M3c on disk, plain git mv though the rules leave it out caught; M3c: git mv refused, exist outside of your sparse-checkout definition
M4, M4c off disk, taken out of the index though marked caught, 2 failed; M4c: git rm --cached refused
M5, M5c no --sparse on the removal though the rules leave it out caught; M5c: git rm --cached refused
M6 a rename aside off disk leaves no change to commit caught by test_what_a_sparse_checkout_keeps_off_disk_is_committed_with_the_move
M7 the git mv --sparse part not printed caught, 2 failed
M8 the removal's --sparse not printed caught
M9 an entry set aside with --sparse listed among the variants caught, 2 failed
M10 the set-aside part blind to ASIDE_SPARSE caught, 2 failed
M11c the guard without [ ! -e X ] caught: the empty directory is left at the variant, and the next add refuses it
M12c the guard without [ ! -L X ] caught: the link is left at the variant
M13c the guard as d4e8c65a printed it caught, 7 failed: mv: cannot stat 'Members/IRRS'
M14 the sentence without "where nothing is there" caught, 4 failed
M15 _set_aside told nothing of the sparse checkout caught, 3 failed
M16 ASIDE_SPARSE left out of REMEDY_ORDER caught, 4 failed
M17 HEAD's gitlink at the row's path counted as a change to commit caught by test_a_removal_where_head_records_the_gitlink_leaves_no_commit

Merge checks

git merge-tree --write-tree of 6cf02edf: clean against main d66a9054 and against every open branch that changes scripts/family.py:

  • fix/684-family-holder-states fdc90a6e
  • fix/686-bump-reachability cf5e64b3
  • fix/688-family-init-values 6958dfbc
  • fix/689-next-push-refspec 15c3b5ab
  • fix/766-spelled-paths ef6db40d

It is also clean against the branches updated during this round: fix/674, 712, 762 and 768, and l3-733. No other open branch changes the four test files.

Left out

(e) A section that a git mv of the mount moved

What was wrong

git mv members/IRRS Members/IRRS moves the checkout, the gitlink and the section's path. The move back put the gitlink back at members/IRRS in the index alone, and left the section naming Members/IRRS. Typed as printed at bbb961f:

  • committed: git submodule update --init --recursive fails with fatal: No url found for submodule path 'members/IRRS' in .gitmodules. The gate exits 1 with gitmodules-path-unnamed in every mode, and add goes through.
  • staged: the same failure, and the gate refuses member-uninitialized, exit 2.
  • a fresh recursive clone of the committed state: the update checks nothing out at members/IRRS, and the gate exits 1.

What changed

  • Each section that gives a member another spelling of its row's path is pointed back. _sections_restored names those sections on the member's move back (Spelled.sections). It reads them from the file that _submodule_paths reads, the work tree's, which is the one git config -f edits. The move's parenthesis gains, per section, git config -f .gitmodules submodule.<name>.path <row path>, then git add -- .gitmodules.
  • The commit is printed only where one is left to make. The move is a change to commit where HEAD's own .gitmodules gives the section another path (_head_section_paths, read with git config --blob). After a staged move, nothing differs from HEAD once the lines are typed, so no commit is printed; git commit would fail there with nothing to commit.
  • .gitmodules is asked first wherever a section is pointed back, not only beside a gitlink still in the index (_checked_mount_path). So gitmodules-sparse comes first where a sparse checkout marks the file, since git config -f would then write a new file holding one line.

A defect found on the way

In the staged state, a commit or an untracked file of its own in the checkout at Members/IRRS makes the move's git rm -r --cached --ignore-unmatch -- Members/IRRS refuse, exit 1: the following file has staged content different from both the file and the HEAD. It takes the gitlink with -f, which with --cached touches no file. The removal is now printed with -f where the index stages the variant at a commit HEAD does not record there and a checkout is on disk at it, with a clause saying what -f does. Everywhere else the line is the validator's word for word, as before.

(g) A file at or under the row's path

What was wrong

After git mv members/IRRS Members/IRRS, a committed file at members/IRRS, or under it, made the gitlink at the variant look like a second gitlink, so it kept #574's remedy, git mv -- Members/IRRS <new name>. Typed as printed, that moves the member's gitlink and its section to renamed-1: the member is un-registered, the gate refuses, exit 2, and add goes through. In #646's own state (the gitlink moved in the index alone, the checkout still at members/IRRS), a file planted at members/IRRS got git rm -r --cached -- Members/IRRS, which takes the member's gitlink out.

What changed

Where the index holds only files and links at or under a row's path, each at stage 0, and none marked or left out by a sparse checkout, a gitlink at a variant is still the member's own. Each of those entries is set aside first (ASIDE, ASIDE_OUT), then the gitlink is moved back, in the same commit. An entry on disk as the index holds it is renamed with git mv; any other is taken out of the index, which touches no file. So the history never holds the member's gitlink beside the file, nor the file gone and the gitlink not back.

The other order fails, measured. With a file at the row's path, the move back first replaced the file without a word, and the git mv of that path then took the member's gitlink away. With a file under it, the move back stopped at exit 128 (appears as both a file and as a directory).

(h) A file or link at members itself

What was wrong

After git mv members Members, a file or link at members was never named: planted, committed and deleted from disk, or a committed link members -> Members. Each git update-index of the move back failed with 'members/IRRS' appears as both a file and as a directory, exit 128, after the git rm had taken the variants out, and the commit printed after it removed both members' gitlinks.

What changed

The same set-aside part names the entry at members wherever a gitlink is moved back. A link is renamed with git mv, as _members_is_a_file moves members; a file deleted from disk is taken out of the index.

Line 4: no plain mv of a live checkout

What was wrong

The closing mv -- Members/IRRS ../Members-IRRS moved out of the holder a checkout that git mv or git submodule add made. Its .git file names its store by a relative path (../../.git/modules/members/IRRS), which the move leaves pointing nowhere: #681's N-1, which #747 fixed in the validator.

What changed

  • Each closing mv is validate-family.py and validate-pins.py: a staged gitlink removal says to commit it, member-gitlink-absent puts the gitlink back by hand, the variant mv is guarded, and a pin row with no path: is shape-pin-row (#681) #747's guarded form, typed in a POSIX shell: [ Members/IRRS -ef members/IRRS ] || [ -e Members/IRRS/.git ] || mv -- Members/IRRS ../Members-IRRS. It moves nothing where the disk takes the two names for one directory (macOS, Windows, WSL's /mnt/c), and it never moves a checkout. The text is the same on every disk, and the old pair of sentences ("on a case-sensitive disk ...", "on macOS and Windows ...") is gone.
  • The text says where a checkout's commits are kept: in the holder's .git/modules/, whatever becomes of the checkout. It names git -C <variant> status to read what is not committed, and says to delete such a checkout by hand once nothing only it holds is needed. A clone of its own (.git a directory) is moved out whole, by hand, and git -C <variant> log --oneline HEAD --branches --not --remotes lists what it has not pushed.
  • The update comes last. Measured: a checkout at the variant that git mv made shares the member's store, and git submodule update --init --recursive, checking the member out at the row's path, points that store's core.worktree there. git -C Members/IRRS status then printed the row's clean checkout, not the variant's file that was never committed. Read before the update, it names that file. This is the one order that differs from the validator's _wrong_case_move.
  • A checkout that add finds on disk and untracked later is never moved. _moved_or_kept tells it apart (a directory, not a link, holding a .git FILE), and a new remedy, CHECKOUT, says to delete it by hand once nothing only it holds is needed. A clone of its own keeps the mv, which moves it whole.
  • MOVE_BACK, for a members/ renamed by hand, is unchanged. Measured, mv -- Members members keeps each checkout working, since each relative .git file then points where it did.

rv685's candidate 1: a member's removal staged beside a staged git mv members Members

What was wrong

git mv members Members, staged, then git rm --cached Members/IRSS. HEAD records members/IRSS and the index holds it nowhere, so nothing named it. The rename back was printed. Typed, it left IRSS's section at Members/IRSS, add committed that .gitmodules, and the gate refused member-commit-unresolvable, exit 2. With the rename committed instead, the move back from HEAD was printed, and it left both sections at Members/ with (e)'s gap.

What changed

A row is found IN_GITMODULES ("in .gitmodules, its gitlink's removal staged") where HEAD records its gitlink at the row's path, the index holds nothing at or under it, and a section gives another spelling of it. The line that moves the other gitlinks back puts it back too, at the commit HEAD records, and its section is pointed back. The rename back is not printed beside it (_renamed_with_git), since it would leave that section naming Members/. The staged state takes -f, since the checkouts are on disk, and no commit; the committed state takes the commit.

rv685's NIT 5: the rename back withheld by a third spelling

What was wrong

git mv members Members, committed, and a committed MEMBERS/notes. The index held two spellings, so _renamed_with_git gave up and the move back was printed, with (e)'s section gap: the update failed, and the gate exited 1.

What changed

The spelling to rename back is the one that holds the members' own gitlinks, and there must be exactly one. What the index holds under a third spelling keeps its own remedy, which runs first in REMEDY_ORDER. On Linux that is git mv -- MEMBERS/notes <new name>. On a folding disk, where the three spellings are one directory and MEMBERS is never on disk in its own spelling, it is git rm -r --cached -- MEMBERS/notes, which touches no file, and the file goes back with the rest. The rename back replaces only the entries under its own spelling.

rv685's candidate 2: git mv in a cone-mode sparse checkout, the bit cleared

What was wrong

A cone-mode sparse checkout leaves Members/ out, and an entry there has its skip-worktree bit cleared. add printed plain git mv for it on disk and plain git rm --cached off disk. git 2.43 asks the sparse rules, not the bit. Measured with plain git:

  • plain git mv refuses such an entry on disk, file or directory (exist outside of your sparse-checkout definition, exit 1), and git mv --sparse takes it;
  • plain git rm --cached refuses one off disk the same way, and --sparse takes it;
  • the move back's git rm --cached --ignore-unmatch passes over such a gitlink with exit 0, as it does one marked;
  • git mv of a directory holding one refuses, exit 1.

What changed

Each --sparse is printed where the rules leave the entry out, as well as where the bit marks it. The rules are asked once, and only where they matter (_sparse_rules_exclude, which asks git sparse-checkout check-rules). So an entry on disk gets git mv --sparse, one off disk gets git rm -r --cached --sparse with the git it needs, the move back's removal gets --sparse, and no rename back is printed where an entry under the spelling is left out. A git that cannot answer check-rules leaves the bit alone to decide, as before. The RENAME_SPARSE wording now says "what the sparse checkout leaves out", in place of "keeps off disk".

Tests

New file tests/test_family_add_move_back_sections.py, 26 cases. Each typing test types every printed command as printed with typed_as_printed. That helper now runs each guarded mv through sh -c, types each git config line, and does each hand step the text names at its place. Each test then checks that every member is registered where its row says, that the gate passes in every mode, and that the same add goes through with the gate passing after.

  • (e): committed and staged, with a commit and an uncommitted file in the checkout, each kept (the commit in the store, the file read by git -C Members/IRRS status before the checkout is deleted); a fresh recursive clone; both disks in process; and gitmodules-sparse named first.
  • (g): a file at the row's path and one under it, renamed aside; and family.py add: where another member's own gitlink sits at a case variant (Members/<P> for its row members/<P>), the refusal's remedy prints git rm -r --cached or git mv, which takes that member's gitlink away from its row #646's state with the checkout at the row's path, the file taken out of the index.
  • (h): a committed file members deleted from disk, and a committed link members -> Members.
  • Line 4: the hand step skipped, which shows git -C Members/IRRS status printing nothing after the update though the uncommitted file is there. The next add names the checkout to delete by hand, with no mv.
  • rv685's candidate 1, staged and committed; the third spelling, typed on Linux and asked in process on both disks; the sparse rules, for a file on disk, a file off disk and a member's own gitlink on disk, for a gitlink renamed with --sparse, and for the rename back withheld. These sparse tests need check-rules and skip on a git without it.
  • What each rule turns on: a section that HEAD moved makes the commit; what is set aside makes the commit; both removals staged with no .gitmodules on disk; and a checkout under an index entry stays the index's to name.

Changed tests:

  • tests/test_family_add_member_variant.py: moved_back builds the new text, with sections, staged removals, -f, and the five closing sentences. It gains guarded, point_back and typed_in_sh. Its layouts type the guarded mv, the hand step and the update last. The table of what is the member's own now expects the move back after the set-aside part for (g)'s two states.
  • tests/test_family_add_spellings_remedies.py: the folding test's Linux branch expects both sections, and "another spelling in the index too" leaves the not-renamed-back table, since that state is renamed back now.
  • tests/test_family_members_case_variants.py: the RENAME_SPARSE wording, and the two new fields in a full-tuple comparison.

Runs

Every run went through run9.sh (the real gh unreachable, the git identity blanked), with basetemps under v110/bt/w761-*, on git 2.43 and Python 3 on Linux.

Run Tree Files Result
batchA.log before the last three small edits 34 more of the files that import or run family.py (test_family.py, test_family_refuse_up_front.py, test_non_utf8_git_reads.py and the rest) 1582 passed, 20 skipped
batchB.log the same test_repo_hygiene.py, test_agents_md_codes.py, test_cli_reference.py, test_shape_doctor.py, test_setup_project_py.py, test_bump_leg.py, the four update_shape files and 8 more 1716 passed, 4 skipped
r2.log the same the issue's Verify files: test_family_add_member_variant.py, test_family_add_spellings_remedies.py, test_family_members_case_variants.py 141 passed
r3.log the same test_family_add_refusals.py, test_family_head_lost_gitmodules.py 103 passed
f1b.log this commit, PYTHONHASHSEED=0 the new file, test_family_add_member_variant.py 63 passed
f2b.log this commit test_family_add_spellings_remedies.py, test_family_members_case_variants.py, test_repo_hygiene.py 181 passed
s1.log this commit, PYTHONHASHSEED=1 the new file 26 passed
s2.log this commit, PYTHONHASHSEED=2 the new file's four order-sensitive cases 4 passed
base2.log bbb961f8's family.py, this commit's tests the new file 25 failed, 1 passed
rs1.log the predicted restack onto main (tree e607c304) the new file, test_family_add_member_variant.py 63 passed
rs2.log the same test_family_add_spellings_remedies.py, test_family_members_case_variants.py, test_family_add_refusals.py, test_family_head_lost_gitmodules.py 207 passed
  • Fail on base: 25 of the new file's 26 cases fail on bbb961f8's code. The one that passes, test_a_checkout_under_an_index_entry_is_the_indexs_to_name, guards the disk filter's new CHECKOUT branch: it keeps what the base already did (M22 shows it catches the branch dropped).
  • The last three small edits, after the two batches: one closing sentence reads "a checkout there that git submodule add or git mv made", a comment says a git without check-rules leaves the bit alone to decide, and the order fix. The order fix: rows is a set, so where two members' removals were staged, the order of the IN_GITMODULES entries, and of the text naming them, followed the set's hash (one run printed Members/IRSS before Members/IRRS); staged_out now walks the rows sorted. The runs at this commit cover the files that compare that text.
  • The three sparse tests carry needs_check_rules from test_family_refuse_up_front.py, so a git without git sparse-checkout check-rules skips them rather than failing.
  • render-cli-reference.py --check: ok. No line of the README, the handbook, AGENTS.md or docs/cli.md names the changed text, so none changed.

Mutants

26 mutants, one per new hunk, each run -x on the test named. Round 1 (mutate.py, mut/mutants.txt) ran M1-M25 on the tree before the last three small edits. M7, M8, M20 and M21 were run again at this commit (mutate2.py, mut2/mutants.txt), since the order fix below touched M7's line and M20's test was the one that showed the order bug; M26 undoes that fix. Round 2 ran with PYTHONHASHSEED=0, the seed whose order the bug printed. All 26 are caught, each by its own assertion (read in its log).

Probe What it undoes Result The test that fails
M1 no section lines caught, 1 failed (round 1) test_a_mount_moved_with_git_mv_is_moved_back_with_its_section
M2 HEAD's section no commit caught, 1 failed (round 1) test_a_section_head_moved_is_a_change_to_commit
M3 no blocked rows caught, 1 failed (round 1) test_a_file_at_or_under_the_rows_path_is_set_aside_first
M4 no members entry set aside caught, 1 failed (round 1) test_an_entry_at_members_itself_is_set_aside_first
M5 aside always out of the index caught, 1 failed (round 1) test_a_file_at_or_under_the_rows_path_is_set_aside_first
M6 aside forces no commit caught, 1 failed (round 1) test_what_is_set_aside_is_committed_with_the_move
M7 no staged removal found caught, 1 failed (this commit) test_a_members_removal_staged_beside_the_rename_is_moved_back
M8 rename back past a staged removal caught, 1 failed (this commit) test_a_members_removal_staged_beside_the_rename_is_moved_back
M9 one spelling of all held again caught, 1 failed (round 1) test_a_third_spelling_keeps_its_own_remedy_beside_the_rename_back
M10 replace every index entry caught, 1 failed (round 1) test_a_third_spelling_keeps_its_own_remedy_beside_the_rename_back
M11 plain git mv though left out caught, 1 failed (round 1) test_an_entry_the_sparse_rules_leave_out_takes_sparse_whatever_its_bit
M12 no --sparse on git rm --cached caught, 1 failed, 1 passed (round 1) test_an_entry_the_sparse_rules_leave_out_takes_sparse_whatever_its_bit
M13 move back's --sparse from the bit alone caught, 1 failed, 2 passed (round 1) test_an_entry_the_sparse_rules_leave_out_takes_sparse_whatever_its_bit
M14 rename back past a left-out entry caught, 1 failed (round 1) test_the_rename_back_is_withheld_where_the_rules_leave_an_entry_out
M15 a checkout moved like any other caught, 1 failed (round 1) test_a_checkout_left_at_the_variant_is_named_to_delete_by_hand
M16 closing mv unguarded caught, 1 failed (round 1) test_each_layout_moves_the_gitlink_back_as_printed
M17 the update first again caught, 1 failed (round 1) test_a_mount_moved_with_git_mv_is_moved_back_with_its_section
M18 no -f caught, 1 failed, 1 passed (round 1) test_a_mount_moved_with_git_mv_is_moved_back_with_its_section
M19 no gitmodules-sparse first caught, 1 failed (round 1) test_a_sparse_gitmodules_is_named_first_where_a_section_is_pointed_back
M20 .gitmodules asked only beside an index gitlink caught, 1 failed (this commit) test_both_removals_staged_with_no_gitmodules_on_disk
M21 staged removal's checkout named twice caught, 1 failed (this commit) test_a_members_removal_staged_beside_the_rename_is_moved_back
M22 the disk filter for a checkout caught, 1 failed (round 1) test_a_checkout_under_an_index_entry_is_the_indexs_to_name
M23 CHECKOUT part dropped caught, 1 failed (round 1) test_a_checkout_left_at_the_variant_is_named_to_delete_by_hand
M24 aside part dropped caught, 1 failed (round 1) test_a_file_where_the_disk_holds_the_checkout_is_taken_out_first
M25 aside left in the first line caught, 1 failed (round 1) test_a_file_where_the_disk_holds_the_checkout_is_taken_out_first
M26 staged rows in the set's order caught, 1 failed (this commit) test_both_removals_staged_with_no_gitmodules_on_disk

Merge checks

git merge-tree --write-tree, after a fetch at 16:02Z on 2026-10-17.

Left out, and why

Closes #761

🤖 Generated with Claude Code

Summary by Sourcery

Make family.py add produce executable, state-correct remedies when moving member gitlinks or members/ paths back to their registered locations.

New Features:

  • Restore moved-back member gitlinks together with their corresponding .gitmodules section paths, including members whose removals are staged.
  • Handle conflicting files, links, sparse-checkout entries, and live checkouts safely while moving member gitlinks back.
  • Guard cleanup moves and provide explicit guidance for preserving or deleting relocated checkouts.

Bug Fixes:

  • Prevent move-back remedies from leaving members unregistered, .gitmodules entries inconsistent, or live checkout metadata broken.
  • Ensure staged removals and sparse-checkout states receive commands that can be executed successfully as printed.

Enhancements:

  • Refine rename-back eligibility and remedy ordering so unrelated spellings retain their own fixes and obstructing index entries are handled first.

Tests:

  • Add comprehensive coverage for moved sections, staged removals, obstructing files and links, checkout handling, case-folding filesystems, and sparse-checkout rules.

Brett Heap and others added 15 commits October 16, 2026 12:11
…another spelling of its row's path moves it back to that path in the index alone, so the printed remedy no longer takes the member's gitlink away from its row (#646)

A member's only gitlink at `Members/<P>`, while its row and `.gitmodules` say `members/<P>` (#363's state after the fact), made `add` of ANOTHER member refuse as #574 rules, with #574's remedy for any path under another spelling of `members/`: `git rm -r --cached -- Members/<P>` where nothing is on disk in that spelling, `git mv -- Members/<P> <new name>` where something is, and `git commit` where only HEAD holds it. Each, typed as printed, left no gitlink at the row's path, and the holder's validator then refused `member-gitlink-absent`.

`_members_spellings` now takes the rows' own paths (`_rows_paths`, the one path `_member_path` gives a row). Where the index holds no gitlink at a row's path, a gitlink at a case variant of it, in the index or in HEAD alone, is that member's: remedy `RESTORE`, placed in `REMEDY_ORDER` after the index's own remedies. Its remedy is the index-only move `validate-family.py` prints for the same state on #639, which Brett Heap accepted on 2026-10-16 ("Accept; merge #639 after #637") in place of the "committed `git mv`" of his "Refuse on every disk" ruling, since `git mv` to the row's path fails or misplaces the gitlink:

- `git rm -r --cached --ignore-unmatch -- <variants>`, which touches no file and is no error where HEAD alone holds the variant;
- `git update-index --add --cacheinfo 160000,<commit>,members/<P>`, one per row, at the commit the variant entry records (the first found where a row has two, the index's before HEAD's), so it is a move and not a bump;
- a commit, printed only where one is left to make: HEAD holds a variant, HEAD's gitlink at the row's path is another commit or none, or a pending change is committed first.

After every other remedy come the validator's own sentences for what the disk holds afterwards: `git submodule update --init --recursive` where nothing is checked out at the row's path, as in a fresh clone; on a case-sensitive disk, `mv -- Members/<P> ../Members-<P>` for the directory left at the variant, which the next `add` would otherwise refuse as `family-members-dir-variant`; on macOS and Windows, leave it, since it is the row's own directory. They come last so that a `members/` moved back (`mv -- Members members`) has taken the checkout home first. A directory on disk at a gitlink moved back from HEAD is the move's to name, as one at an index entry is the index's.

The refusal code stays `family-members-dir-variant` (or `member-path-taken` where a path is also a variant of the new member's mount path): the add is still refused for the path, as for any path under another spelling of `members/`, and only its remedy changes. A gitlink at a variant beside one at the row's path, a file at the variant, and anything under it keep #574's remedies, which take nothing from the row. The text reads the index and HEAD alone, so it is the same on a disk that folds case and on one that does not, and in the dry run and the real run.

Tests: `tests/test_family_add_member_variant.py` (new, 28) types every printed command in each layout of rv611b's review of #639 (nothing on disk in that spelling; the only checkout at the variant; both directories; the removal staged; a fresh recursive clone) and among #574's other remedies; the holder's gate passes after it, the member is where its row says at its commit, and the same `add` goes through. It asks each layout of an emulated folding disk and a Linux one (`disk_as`). Two existing lines follow the new shapes: a seven-field `Spelled` compared in `test_family_members_case_variants.py`, and the three-argument stand-in for `_members_spellings` in `test_family_add_refusals.py`.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… at another commit, with HEAD keeping the row's gitlink at its own, names the index's commit and prints the commit, so the two conditions no other state held are each held by a test (#646)

Two mutation probes of the fix survived the new file: `committed` taken from "HEAD holds the variant" alone, and the commit read from HEAD's gitlink at the row's path before the index's. Neither changes the answer in any state the file held: wherever HEAD lacked the variant, it lacked the row's gitlink too, or held it at the same commit. This state holds the row's gitlink in HEAD at one commit and the variant in the index at another, and nothing in HEAD at the variant. The remedy must name the index's commit, and must print the commit, since HEAD's gitlink at the row's path is another. Each probe now fails it.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 (c6122e8) under #646's move back (#646 x #611)

No conflict. main moved from a524542 to 35133f2 (#623, #637, #622,
#620, #628, #639, #621, #627, #657, #656). Of those, #637, #622 and
#639 change scripts/family.py, in hunks git merged on its own; none
changes this branch's three test files.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t a sparse checkout keeps off disk out with `--sparse`, records an unmerged one at stage 2, and is printed only where the index holds nothing at or under the row's path, so its lines no longer leave two gitlinks, record the merge base or replace a tracked file (#646)

Lane 3's review RV670 of #670 at 79754b9, READY with three SHOULDs and
six NITs. This commit takes S1, S2, S3, N2 and N3; N1, N4, N5 and N6 are
the PR body's.

- S1. A variant marked skip-worktree, as a sparse checkout leaves it:
  `git rm -r --cached --ignore-unmatch` passed it over with exit 0, and
  `update-index --add` then put a second gitlink beside it. `Spelled` gains
  `sparse`, and the removal says `--sparse` where any variant needs it,
  and only there, so the line stays the validator's everywhere else.
- N2. An unmerged variant was moved back at the first record git lists,
  stage 1, the merge base. It is moved back at stage 2, the holder's own
  side; an unmerged variant with no gitlink at stage 2 is no member's own
  and keeps #574's remedy.
- N3. The member's own gitlink was asked only where the index held no
  GITLINK at the row's path. A file there was replaced by the move without
  a word, and a file under it stopped the move's second line after the
  first had taken the variant out. Now the index must hold nothing at or
  under the row's path; otherwise #574's remedy is kept.
- S2 and S3 add tests only: HEAD holding the gitlink in both spellings at
  one commit with the row's removal staged, which tells `name in in_head
  or` apart; and the variant at a commit the row does not pin, typed as
  printed, after which the gate exits 1 with `member-gitlink-mismatch`
  alone and another member's `add` goes through.

The commit the move names is unchanged, the variant entry's: the question
of the row's pin is back with Brett Heap.

tests/test_family_members_case_variants.py's seven-field tuple gains the
new field's `False`.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ant with gitlinks at stages 1 and 3 and a file at stage 2, so a rule that reads the first record git lists and not stage 2 no longer passes unseen, and `add` keeps #574's remedy there (#646)

rv670b's mutant R1 replaced `ours[0] == b"160000"` in `_members_spellings`
with the first record's mode. It survived all 36 tests: in this state git
lists the stage-1 gitlink first, so R1 moved the file's blob back as a
gitlink at the row's path, while the real rule sees that the holder's own
side, stage 2, is a file and keeps #574's `git rm -r --cached`. The new
`OWN` state pins that. `unmerged_at` takes the stages that hold a file, and
`file_blob` writes its blob as bytes. No code changes.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…h `git mv -- Members members`, and a printed `--sparse` says it needs git 2.34 or later, so neither remedy fails as typed (#685)

#685's three #574 lines in `_spellings_refusal`, on #646's move back
(#670, a837a02).

1. `git mv members Members`, staged or committed, renames each member's
   checkout, gitlink and `.gitmodules` section (`path = Members/IRRS`).
   The remedy was #646's move back of each member's own gitlink, which
   leaves the section naming `Members/IRRS`: typed as printed, `git
   submodule update --init --recursive` failed (`No url found for
   submodule path 'members/IRRS' in .gitmodules`) and the gate refused
   (`member-uninitialized` staged, `gitmodules-path-unnamed` committed
   and in a fresh clone). Before #646 it was #574's `git mv --
   Members/IRRS <new name>`. Now, where no `members/` is on this disk, the
   index holds nothing under `members/` itself, everything it holds under
   another spelling is under one spelling on disk as a directory, none of
   it a variant of the mount path or kept off disk, some of it a member's
   own gitlink, and HEAD holds no such gitlink the index no longer does,
   the remedy is `git mv -- Members members` (`RENAME_BACK`), and a commit
   where one is left to make. What the disk holds under that spelling goes
   back with it and is not named. On a disk that folds case `Members` is
   `members/` itself and `git mv` would rename it into itself, so the move
   back is printed there, as before. Measured on Linux, staged, committed
   and in a fresh recursive clone: both `git mv -- Members members` and
   `mkdir members` with a `git mv` per member type clean and leave `add`
   and the gate passing; the directory's rename is one line, keeps every
   checkout, section and gitdir link, leaves no empty `Members/`, and is
   #574's `mv -- Members members` done with git.
2. A folding disk that pulled `Members/README` keeps its remedy, `git rm
   -r --cached -- Members/README` and a commit: typed as printed on the
   emulated disk (`DISKS`, `disk_as`) it touches no file, the file stays at
   `members/README`, untracked, and `add` and the gate pass in every mode.
3. `git mv --sparse` and the move back's `git rm --sparse` say "which needs
   git 2.34 or later" (`SPARSE_GIT`). The older way, `git update-index
   --no-skip-worktree` then `git mv`, says `bad source` on git 2.43, and in
   a cone-mode sparse checkout the matching `git rm --cached` removes
   nothing.

tests/test_family_add_spellings_remedies.py is new (19 tests). The sparse
sentence's helpers in tests/test_family_members_case_variants.py and
tests/test_family_add_member_variant.py say the git it needs, and #574's
sparse test asserts the text on every git and types it on 2.34 or later.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…goes through a free name in two steps, after what the disk lacks under it is put back, and `git mv --sparse` says it needs git 2.38, so no printed line stops at `bad source` or renames a directory into itself (#685)

rv685's review of 70307d2 (2 MUST, 3 SHOULD, 6 NIT), in one commit.

- MUST-1. `git mv --sparse` of the one entry a sparse checkout keeps off
  disk says `bad source` before git 2.38: v2.34.0 to v2.37.7 have the same
  `builtin/mv.c`, and 2.38.0 first looks a single skip-worktree source up.
  The sentence says 2.38 (`SPARSE_MV_GIT`); the move back's `git rm
  --sparse` keeps 2.34 (`SPARSE_RM_GIT`). #574's sparse test types it only
  on 2.38 or later. Read from git's source; only 2.43 is here.
- MUST-2. `git mv` of a directory says `bad source` for any entry under it
  that is not on disk (an empty `Members/`, a checkout gone, a committed
  file deleted), and the refusal repeated. Each such entry is now put back
  first: a file with `git checkout --`, a gitlink with `git submodule
  update --init --recursive --`, where a section with a url gives its path.
  Where none can be put back, or the disk holds the path in another form,
  the move back is printed, as before.
- SHOULD-1. An entry under the spelling at a stage other than 0 keeps the
  move back. A change to `.gitmodules` nobody staged, which `git mv` and
  `git rm` of a gitlink refuse to run beside, is staged first (`git add --
  .gitmodules`) wherever the index holds a gitlink the refusal names;
  `_checked_mount_path` passes `git ls-files -m`'s answer in, and no
  refusal moves.
- SHOULD-2. The comment on the older way says what was measured: git 2.43
  alone.
- SHOULD-3. The rename back is `git mv -- Members members.renaming`, then
  `git mv -- members.renaming members` (`_free_name` finds a free name),
  one text on every disk: on macOS and Windows `Members` is `members/`,
  where a one-step rename goes into itself, and neither step's destination
  exists when it runs. A `members/` on disk is allowed where it is that
  spelling's own directory (`samefile`).
- NITs: a file `members` in the index is a tested exclusion; the fresh
  clone's gate runs after `add`; one state types with
  `core.ignorecase=true`; the sentence's cause no longer says only `git mv`
  made the state.

`Spelled` gains `through` and `put_back`. tests/test_family_add_spellings_remedies.py
has 38 tests.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…told apart as `gitmodules-unstaged` tells it, a missing copy put back and an unmerged one named first, and a link is never renamed back, so no printed line stages the file's deletion or leaves the holder half renamed (#685)

rv685's re-verify of 215e810 (2 MUST, 1 SHOULD, NITs), in one commit.

- MUST-A. The "stage the change to .gitmodules" part fired wherever `git
  ls-files -m` listed the file, which lists a deleted and an unmerged copy
  too. Typed as printed, `git add -- .gitmodules` staged the deletion, the
  printed commit took every member's section out of HEAD, and `add` then
  left only the new member registered; an unmerged copy's conflict markers
  were staged and `git mv` said `bad config line`. `_gitmodules_beside`
  now tells the copy apart as `_checked_gitmodules_as_staged` does:
  unmerged (a stage other than 0), missing (stage 0, no work-tree copy),
  modified (stage 0, both present, listed by `ls-files -m`), else nothing
  (none in the index, or one a sparse checkout keeps off disk). A missing
  copy is put back first (`git checkout -- .gitmodules`), for the rename
  back and #646's move back alike; an unmerged one is
  `gitmodules-unstaged`'s to name first, with its own restore, raised by
  `_checked_mount_path` before this refusal in that state alone.
- MUST-B and SHOULD-A. The rename back is not printed where the spelling
  or `members` is a link: with `members -> Members` the second step said
  `destination already exists` and left the holder half renamed, and with
  `Members` a link to a directory outside, the first step said `not under
  version control` and the refusal repeated. Both get #646's move back, as
  at 70307d2.

tests/test_family_add_spellings_remedies.py has 45 tests.

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:
#	scripts/family.py
#	tests/test_family_add_member_variant.py
#	tests/test_family_members_case_variants.py
… of `.gitmodules` back, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closing `mv` is guarded and leaves a checkout to delete by hand, and `--sparse` follows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761)

- (e) Each section of `.gitmodules` that gives a member another spelling
  of its row's path, as a `git mv` of its mount leaves it, is pointed back
  with `git config -f .gitmodules submodule.<name>.path <row path>` and
  `git add -- .gitmodules`, in the move's commit where HEAD's section
  moved too; the removal takes `-f` where a staged variant's checkout has
  work of its own, which `git rm --cached` otherwise refuses.
- (g), (h) Files and links the index holds at or under a row's path, or at
  `members` itself, are set aside first (`git mv` where on disk, else out
  of the index), and the member's gitlink is moved back in the same
  commit, in place of #574's remedy, which took the gitlink away.
- Line 4: each closing `mv` is #747's guarded form, the text says where a
  checkout's commits are kept and to delete it by hand before the update,
  which now comes last, and a checkout found on disk later is never moved.
- rv685's three: a member whose gitlink's removal is staged is put back
  with its section (`IN_GITMODULES`), a third spelling keeps its own
  remedy beside the rename back, and `--sparse` is printed where the
  sparse rules leave an entry out, whatever its skip-worktree bit says.

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:
#	scripts/family.py
#	tests/test_family_add_member_variant.py
#	tests/test_family_add_spellings_remedies.py
#	tests/test_family_members_case_variants.py
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-17T16:16Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates family.py add so printed move-back remedies leave member gitlinks, .gitmodules sections, sparse-checkout state, conflicting index entries, and checkout stores consistent, while guarded shell commands and extensive typed-as-printed tests validate staged, committed, case-folding, third-spelling, and sparse scenarios.

Sequence diagram for restoring a moved member and its .gitmodules section

sequenceDiagram
    participant Add as family.py add
    participant Gitmodules as .gitmodules
    participant Index as Git index
    participant Shell as POSIX shell
    participant Git as Git

    Add->>Gitmodules: Read submodule paths
    Add->>Index: Detect blocking entries
    Add->>Index: Set aside files and links
    Add->>Index: git rm -r --cached --ignore-unmatch
    Add->>Index: git update-index --add --cacheinfo
    Add->>Gitmodules: git config -f .gitmodules submodule.name.path row_path
    Add->>Gitmodules: git add -- .gitmodules
    Add->>Git: git commit
    Add->>Shell: Run guarded mv when needed
    Add->>Git: git submodule update --init --recursive
Loading

State diagram for checkout handling at a member variant

stateDiagram-v2
    [*] --> VariantCheckout
    VariantCheckout --> KeptInPlace: .git file checkout
    VariantCheckout --> MovedByHand: .git directory clone
    VariantCheckout --> RowCheckout: guarded mv or case-folded path
    KeptInPlace --> HandDelete: delete when unique uncommitted work is no longer needed
    MovedByHand --> Preserved: move clone out whole
    RowCheckout --> Updated: submodule update runs last
    Updated --> [*]
    HandDelete --> [*]
    Preserved --> [*]
Loading

Flow diagram for family.py move-back remedy ordering

flowchart TD
    A[Detect member spelling inconsistency] --> B[Set aside blocking index entries]
    B --> C[Restore member gitlink at row path]
    C --> D[Point matching .gitmodules sections back]
    D --> E[Stage .gitmodules]
    E --> F[Commit when HEAD differs]
    F --> G[Handle variant checkout]
    G --> H[Run guarded move or hand-delete checkout]
    H --> I[Run recursive submodule update last]
Loading

File-Level Changes

Change Details Files
Make member gitlink move-back remedies restore .gitmodules section paths and handle staged member removals.
  • Detect sections whose paths differ from the member row and print git config plus .gitmodules staging commands.
  • Add a distinct staged-removal state that restores the gitlink at HEAD’s recorded commit.
  • Compute commit requirements from HEAD’s .gitmodules paths and preserve deterministic row ordering.
  • Use forced cached removal when staged checkout content would otherwise make git rm fail.
scripts/family.py
tests/test_family_add_move_back_sections.py
tests/test_family_add_member_variant.py
tests/test_family_add_spellings_remedies.py
tests/test_family_members_case_variants.py
Safely restore gitlinks when files, mount entries, or live checkouts conflict with their destination.
  • Set aside indexed files and links at or below the row path, or at members, before restoring the gitlink.
  • Classify submodule checkouts with relative .git files as hand-deletion cases instead of moving them.
  • Guard closing shell mv commands against case-folded paths and live checkouts, and document store/status handling.
  • Run the submodule update after cleanup and hand steps.
scripts/family.py
tests/test_family_add_member_variant.py
tests/test_family_add_move_back_sections.py
Make sparse-checkout decisions follow sparse rules rather than only index bits, and refine rename-back eligibility.
  • Query git sparse-checkout check-rules once and apply --sparse to moves/removals when rules exclude entries.
  • Prevent members/ rename-back when excluded entries or third-spelling remedies would make it unsafe.
  • Update remedy ordering and wording to reflect sparse-rule behavior and set-aside operations.
scripts/family.py
tests/test_family_add_move_back_sections.py
tests/test_family_add_spellings_remedies.py
tests/test_family_members_case_variants.py
Expand command-as-printed coverage for the new remediation paths.
  • Add 26 tests covering section restoration, set-aside ordering, staged removals, checkout handling, guarded moves, third spellings, and sparse-rule cases.
  • Execute guarded commands through a POSIX shell and model hand deletion steps in the typing helper.
  • Verify registration, gate success, and subsequent add success across disk behaviors and supported layouts.
tests/test_family_add_move_back_sections.py
tests/test_family_add_member_variant.py
tests/test_family_add_spellings_remedies.py
tests/test_family_members_case_variants.py

Assessment against linked issues

Issue Objective Addressed Explanation
#761 When moving a member's gitlink back to its row path, also repair any .gitmodules section that names the variant, and include the repair in the appropriate commit. ✅
#761 Provide remedies that set aside indexed files or links at members or at or under the member's row path before moving the gitlink back, without un-registering the member. ✅
#761 Ensure the printed closing move does not orphan a live submodule checkout, while still providing a remedy for other variant directories and explaining how to handle checkout contents. ✅

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-17T17:30:37.730584Z 6cf02ed 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: d4e8c65ab4

ℹ️ 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 thread scripts/family.py Outdated
Comment on lines +3407 to +3409
blocked = {row: held for row, held in blocked.items()
if not any(name in marked() or name in left_out()
for name in held)}

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 Keep sparse blockers in the move-back path

When a wrong-case member gitlink coexists with regular files at its canonical row and any such file is skip-worktree or outside the sparse rules, this filter removes the row from blocked before owners is built. The variant gitlink consequently falls through to the generic rename/removal remedy instead of RESTORE, so following the printed instructions never recreates the member gitlink under its members/<Project> row and leaves the existing family member unregistered. Ownership detection needs to remain independent of whether the blocking files require a sparse-aware set-aside remedy.

AGENTS.md reference: AGENTS.md:L367-L369

Useful? React with 👍 / 👎.

Comment thread scripts/family.py Outdated
Comment on lines 3927 to 3931
f"`[ {shell_quoted(name, 'posix')} -ef "
f"{shell_quoted(rows[name], 'posix')} ] || "
f"[ -e {shell_quoted(name + '/.git', 'posix')} ] || mv -- "
f"{shell_quoted(name, 'posix')} "
f"{shell_quoted('../' + name.replace('/', '-'), 'posix')}`"

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 Guard the closing move when the variant is absent

When the wrong-case gitlink is absent from disk, as in the existing sparse kept_off_disk layout, both tests are false and the command proceeds to mv, which exits nonzero because the source does not exist. Thus copying the newly advertised guarded command aborts the remediation before the subsequent submodule update; the test helper currently hides this by skipping the command whenever its source is absent. Add an explicit absent-source no-op to the guard.

Useful? React with 👍 / 👎.

… the wrong case is, what stands in the way of its move back is set aside with the `--sparse` git asks for, and the closing `mv` moves nothing and exits 0 where nothing is at the variant, so following the remedy never un-registers the member or stops before the update (#761)

Codex's two P2 findings on #828 at d4e8c65 (review 5558731894):

- P2 1 (`scripts/family.py:3409`): a row whose files a sparse checkout
  marks (`skip-worktree`) or whose rules leave out was dropped from the
  rows to set aside before `owners` was built, so a member's own gitlink
  at a variant kept #574's remedy. Typed at d4e8c65, `git mv --
  Members/IRRS renamed-1` and the commit took the gitlink and its section
  to `renamed-1`, and HEAD held no gitlink at `members/IRRS`. A file at
  `members` itself that a sparse checkout keeps off disk was not set aside
  either, and `git update-index` then failed (`'members/IRRS' appears as
  both a file and as a directory`). Ownership no longer turns on the
  sparse checkout, and `_set_aside` names each entry as the index's own
  rule does: `git mv` on disk, `git mv --sparse` where the rules leave it
  out on disk or it is marked off disk, and `git rm -r --cached --sparse`
  where the rules leave it out off disk (`ASIDE_SPARSE`, and `sparse` on
  `ASIDE_OUT`), each measured with plain git 2.43 and 2.47.1.
- P2 2 (`scripts/family.py:3931`): the guarded closing `mv` ran where
  nothing was at the variant and failed, exit 1 (`mv: cannot stat
  'Members/IRRS'`), in four layouts typed at d4e8c65, Codex's sparse
  `kept_off_disk` among them. The guard now begins `[ ! -e <variant> ] &&
  [ ! -L <variant> ] ||`: nothing there moves nothing and exits 0, and a
  link there pointing nowhere is still moved, as before. The test helper
  `typed_as_printed` types every `mv` as printed, where it skipped one
  whose source was absent.
- Found on the way: a file staged at a row's path over a staged `git mv`
  of the mount, then deleted from disk, was said to leave its removal to
  commit, since HEAD records the member's gitlink at that path, and the
  printed `git commit` failed with nothing to commit. HEAD's gitlink at a
  row the move puts back no longer counts.

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

Copy link
Copy Markdown
Contributor Author

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

@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: 6cf02edf6d

ℹ️ 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 thread scripts/family.py
Comment on lines +3097 to +3098
return CHECKOUT if stat.S_ISREG(
(path / ".git").lstat().st_mode) else MOVE

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 Distinguish linked worktrees before advising deletion

When the wrong-case directory is a linked worktree, its .git is also a regular file, so this classifies it as a disposable submodule checkout and the generated remedy tells the user to delete it by hand while claiming its store is under this holder's .git/modules. Linked-worktree .git files instead commonly contain an absolute gitdir: path into another repository; raw deletion discards that working tree and leaves stale worktree metadata in its owning repository. Inspect the .git target and reserve CHECKOUT for stores belonging to this holder, rather than treating every gitfile-backed directory alike.

AGENTS.md reference: AGENTS.md:L409-L416

Useful? React with 👍 / 👎.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T18:50:02Z) Independent review of #828 (#761) at 6cf02edf, for lane openRepoShape-5: NOT READY

Findings, most severe first

  1. MUST: the merge with main bad05d21 is clean as text but breaks two of the PR's tests, and one state this round says it fixed fails again when typed.

  2. SHOULD: a clone of its own found on disk is never tested (_moved_or_kept, scripts/family.py:3095 and :3097).

    • The body says "A clone of its own keeps the mv, which moves it whole", but no test puts an untracked clone (.git a directory) at a variant.
    • Mutant R9 (CHECKOUT for any .git) survives all 175 tests. So does R10 (a link to a checkout counted as CHECKOUT).
    • Fix: add one typed case for each: an untracked clone at Members/<X> expects mv -- Members/<X> <new name>, and a link at the variant expects its mv.
  3. SHOULD: an unmerged file at or under the row's path is not pinned (merged_file, scripts/family.py:3397; blocked, :3418).

  4. SHOULD: S3776, changed functions over 15. These counts come from my own AST counter, not Sonar.

    • _spellings_refusal (:3689): 44 → 75.
    • _members_spellings (:3279): 46 → 64.
    • _renamed_with_git (:3168): 37 → 38.
    • _checked_mount_path (:4170): 35 → 38.
    • The new functions are under 15: _set_aside 11, _sections_restored 13 (22 if comprehension clauses count), _head_section_paths 5, _moved_or_kept 4.
    • Fix: move the set-aside part (:3877), the move-back part and the closing sentences (:3909 to :4021) out of _spellings_refusal, and the sparse and blocked bookkeeping out of _members_spellings.
  5. NIT: two more surviving mutants guard odd states.

    • R3: at_members without merged_file, so a gitlink or an unmerged file at members would be set aside.
    • R20: the set-aside part computed when no gitlink is moved back.
    • Neither has a negative case. Add one, or rule them out of scope.
  6. NIT: duplication in the new test file.

    • tests/test_family_add_move_back_sections.py:641, :667 and :698 repeat the same setup lines (folding_skip, mount_moved(committed=False), the file written and added) and the same tail.
    • Make them one parametrized test with a state table, as SPARSE_ASIDE does.
    • No composite asserts in any of the four files (AST scan: 0).
  7. NIT: the shell dialect (ruling on the brief's key point).

Checked and true

Lane 2's coordinator read main's scripts/family.py at bad05d21. _checked_mount_path calls _members_file_left_in_head at :4409, before _members_spellings at :4461, as MUST 1 says.

Could not verify

Lane: openreposhape-2 (openRepoShape-2)

Clean as text: tree 7550024, no file conflicts.

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-17T20:12:30Z) Re-verification of #828 (#761) at ce0f2a5f, for lane openRepoShape-5: READY

ef38aad0 merges main bad05d21 (its tree is 75500249, the tree I tested), and ce0f2a5f is the fix. The PR's diff against main is still its five files.

Each finding from my review of 6cf02edf

  1. MUST 1 (merge with main breaks two tests; the members file kept off disk fails when typed): fixed.

  2. SHOULD 2 (a clone of its own, or a link, found on disk): fixed.

    • The new test test_what_is_on_disk_and_no_checkout_keeps_its_mv kills R9 (a clone counted as a checkout) and R10 (a link counted as a checkout).
  3. SHOULD 3 (an unmerged file at or under the row's path): not fixed. Deferral accepted for landing, on one condition.

  4. SHOULD 4 (S3776, changed functions over 15): fixed.

    • My own counter:
      • _spellings_refusal: 75 at 6cf02edf, 43 now (main 44).
      • _members_spellings: 64 at 6cf02edf, 46 now (main 46).
    • Every new helper is 13 or under, counting the way Sonar counts. If comprehension clauses counted, _move_back_line would be 26; Sonar does not count them, so it is 9.
    • Lane 5's own numbers differ (88 → 51 and 71 → 49) but agree in direction: both functions are back to main's level.
    • SonarCloud itself was not read.
  5. NIT 5 (mutants R3 and R20 survive): fixed.

    • test_only_a_file_or_link_in_a_moves_way_is_set_aside kills both.
  6. NIT 6 (duplicated tests): fixed.

    • The three staged set-aside tests are one table, STAGED_ASIDE.
  7. NIT 7 (shell dialect): fixed.

    • The guard is now introduced "in a POSIX shell (Git Bash on Windows)". These are the same words family.py already uses for its rm -rf line (scripts/family.py:5141).

Findings this round, none blocking

  1. SHOULD: a gitlink at members is now pinned by a test, but its printed remedy fails.

    • The state: git mv members Members committed, then a gitlink at members.
    • test_only_a_file_or_link_in_a_moves_way_is_set_aside[a gitlink at members] pins that nothing is set aside, and that is right. But add still prints the move back, and nothing types it.
    • Typed as printed, git rm -r --cached --ignore-unmatch -- Members/IRRS Members/IRSS exits 0. Then git update-index --add --cacheinfo 160000,…,members/IRRS exits 128 with 'members/IRRS' appears as both a file and as a directory, and both variants are already out of the index.
    • The behaviour is unchanged since 6cf02edf and the state is unusual, so record it with SHOULD 3's states rather than fix it here. It probably needs a refusal that names the gitlink at members first.
  2. Not this PR's code (main's family.py add, bump and remove: a merge or rebase in progress, an excluded .gitmodules or a members that is a file makes them fail after their first writes #612, _members_is_a_file). A members file on disk that the sparse rules leave out, its bit cleared, gets git mv -- members <new name>. Typed, that exits 1: outside of your sparse-checkout definition. It is the on-disk sibling of MUST 1's state. Candidate for family.py add: six states its case-spelling remedies still miss after #828, from a section that names a variant beside a correct gitlink to a Members that is a link to a directory outside the holder #840 or a follow-up.

Checked and true

Could not verify

Lane: openreposhape-2 (openRepoShape-2)

brettheap added a commit that referenced this pull request Oct 17, 2026
…nd what they tell git to the `GIT_CONFIG_COUNT` entries the environment holds, so a pasted line keeps the session's maintenance settings and the Windows job's two (#758) (#818)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #758

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

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

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

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

### What each finding got

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

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

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

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

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

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

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

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

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

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

### Known limits (add to the body)

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

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

This replaces a separate issue for them.

### Tests, through `run9.sh`

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review: rv848, lane 5's independent review, READY (issuecomment-6240157020); lane 2's independent review, READY (issuecomment-6240228302); rv848's re-verification of the fix round 79b2eac, READY (issuecomment-6241026517). CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 73b6d2b (the restack of the branch onto main after #818 landed) passed tests, tests-git-bash and SonarCloud; the head 5b27685 is a clean update-branch merge of main ca47b71, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 4dbb4a9 over main ca47b71: the adopt_reencoded_files, clone_just_pushed, command_scopes_built_at_the_test, doctor_legs_members_next, doctor_paste_through_git, family_add_spellings_remedies, family_bootstrap_gate_order, family_config_appends, family_head_lost_gitmodules, family_init_asks_every_value, no_auto_maintenance, repo_hygiene, agents_md_codes, cli_reference sets, 645 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #835

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

Copy link
Copy Markdown

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

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Sorry @brettheap, your pull request is larger than the review limit of 150,000 diff characters

@brettheap
brettheap merged commit 9b0a703 into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/761-add-move-back-gitmodules branch October 18, 2026 06:15
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>
brettheap pushed a commit that referenced this pull request Oct 18, 2026
…e move back's would, and its checkout is named to delete by hand before the update (#840)

Fix round 3 of #865, after rv865b's second re-check (NOT READY at
38e0d50), paused for the usage budget. Not pushed.

MUST 1. `UNSTAGE_SECOND` takes #828's `forced` rule (`_forced`): `-f`
where the index stages the gitlink at a commit HEAD does not record there
and a checkout is on disk at it, with the move back's clause, now one
constant (`FORCED_SAID`), and `--sparse -f` where both apply.

SHOULD 1. A checkout a second path leaves on disk in its own spelling
(`Spelled.checkout`) is named to delete by hand in the same round, with
its `git -C <path> status`, before the update (`_second_checkout_part`).

NIT 2. `typed_as_printed` and `typed_in_sh` have their `f7072d08` `def`
lines again; the shell is the module's `SHELL`, which the link test sets
with monkeypatch.

Tests: (a), (b), K2 committed, reset and foreign, and the rules' sparse
state now take one round; five states with work in the checkout of a
second path; the literal `Spelled` tuple gains `checkout`.

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
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
… for: scripts/family.py tests/test_family_add_member_variant.py tests/test_family_add_move_back_sections.py tests/test_family_members_case_variants.py

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
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…section's path outside `members/` out of the index before the section, and a gitlink there that no section gives is `gitlink-unnamed`, so the remedy typed as printed no longer leaves a gitlink at which a plain clone's `git submodule update --init` exits 128 while the gate passes (#878) (#882)

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

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

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

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…w's path under `members/`, a link or checkout in a move's way, an unmerged entry and a staged or unmerged `.gitmodules` are named first, so each remedy types clean (#840) (#865)

# Title

family.py add: a section astray beside its gitlink, a variant of a row's path under `members/`, a link or checkout in a move's way, an unmerged entry and a staged or unmerged `.gitmodules` are named first, so each remedy types clean (#840)

# Body

Was stacked on #828, which landed on main as `9b0a7038` (its final head `3546118b`). GitHub retargeted this PR to main, and the branch is restacked onto it (`6ef6e4c1`).

**Goal (#840):** where `add` refuses a member recorded in another case, no printed command leaves a section of `.gitmodules` naming another spelling, moves a live checkout out from under its `.git` file, or fails through a link.

#840 lists six states, and a seventh came from lane 2's rv828 review (SHOULD 3). The coordinator added an eighth on the way: a link at a variant the index holds as the member's gitlink. Each state was first typed as printed at `ce0f2a5f`, on git 2.43, with the gate run in all three modes and the same `add` run after. The link states were also typed on git 2.47.1. Every change is in `scripts/family.py`, which is not a pinned copy, so no `upstream-changed` row follows. The validator is not touched.

The commit after the restack, `231c404c`, adds one test for rv865b's NIT 1, `test_a_second_paths_checkout_is_asked_in_its_own_spelling`. The state is a gitlink at `Members/IRRS`, committed, with its skip-worktree bit set, and `Members` a link to `members`, which makes `Members/IRRS` resolve to the member's own checkout, as a disk that folds case would.

The test asserts the whole text:
- the link is moved out first;
- then `git rm -r --cached --sparse -- Members/IRRS` and its commit;
- and no line says to delete a checkout.

It then types that text as printed, and the gate passes. Mutant C5, which asks for the checkout whatever the spelling on disk, is caught by it.

The sixth and seventh commits, `adb20242` and `4e92867a`, answer rv865b's findings. The round is two commits because the first was a pause's work in progress, and nothing is amended.
- **MUST 1.** The second path's `git rm -r --cached` now takes `-f` where the move back's would. That is where the index stages the gitlink at a commit HEAD does not record there and a checkout is on disk at it (`_forced`, #828's rule). It prints the move back's own words on what `-f` does (`FORCED_SAID`, now one constant for both), and `--sparse -f` where both apply. At `38e0d503`, plain `git rm --cached` was refused there (`staged content different from both the file and the HEAD`, exit 1), for (a) staged with an untracked file or a commit of its own, and for K2 reset with work in the checkout.
- **SHOULD 1.** The checkout a second path leaves on disk is named to delete by hand in the same round, with its `git -C <path> status`, before the update.
  - At `38e0d503`, K2 committed's update pointed the store that checkout shares with the member at `members/IRRS`, so the status printed nothing while `WIP.txt` was there. The next run then said to delete the checkout.
  - The update is not left to the next run instead, because nothing would print it there once the checkout is gone, and the member would be checked out nowhere.
  - (a), (b), K2 committed, reset and foreign, and the sparse state now each take one round.
- **NIT 1.** The count for `_members_spellings` is corrected and recounted (line 4).
- **NIT 2.** `typed_as_printed`'s `def` line is `f7072d08`'s again.
- **N2.** rv865b ruled it acceptable as is, so nothing changed for it.

The fifth commit, `38e0d503`, answers rv865b's findings:
- **MUST 1.** A gitlink at a case variant of a row's path, beside the member's own gitlink at that path, is now taken out of the index with `git rm -r --cached -- <variant>`, the gate's own line for a second path. It is no longer renamed with `git mv`. At `f7072d08`, `git mv` carried the section that gives its path along under the same name, and the take-out then stripped it, so `renamed-1` was a gitlink no section names. The gate passed, but a plain clone's `git submodule update --init --recursive` exited 128, and that is the holder's own CI path (`validate.yml`). The checkout left on disk is the next run's `CHECKOUT`, deleted by hand. See line 1.
- **K2.** The clause "never point back a section whose path is a gitlink in the index" is dropped, together with the change above, as rv865b rules. Its four states are tests now.
- **NIT 1.** `_variant_in_members` asks `os.path.islink` before `samefile`, so a link `members/irrs -> IRRS` is named again on Linux. See line 3.
- **NIT 2.** rv865b's surviving mutants X1, X2 and X6 are pinned.
- **NIT 3.** A take-out printed after the point-back's own commit is committed too: that commit takes a staged `.gitmodules` into HEAD.
- **NIT 4.** The closing guard takes #839's form, `[ ! -L V ] && { [ ! -e V ] || [ V -ef R ] || [ -e V/.git ]; } || mv -- V ../<V>`, with its words. That is the form lane 5's #839 prints in `validate-family.py` (`77f8a6c8`), so the two texts agree. A link at the variant is now moved in the first round. See the added state.
- **NIT 5.** This body: line 1's claim now carries its caveat, and the merge checks name main `3bf2d3c9`.

The third and fourth commits, `dde7e9cc` and `f7072d08`, answer rv865's findings:
- **MUST 1.** A section beside the member's own section is no longer pointed back. It is taken out with the gate's own `--unset-all` lines. Only the section named after the row's path is pointed back, and only where no section gives that path. See line 1.
- **SHOULD 1.** On a disk that folds case, the member's own checkout listed in another spelling is no longer refused. See line 3.
- **NIT 1.** R7, R8, R9, R10, R13, R23, R25, R25b, R26 and R42 are pinned by tests now. R20 and R21 are not, and "Not here" says why.
- **NIT 2.** This body's mutant count and merge checks are corrected.
- **NIT 3.** `_checked_gitmodules_as_staged` has base's `def` line again, and its complexity went down. See line 4.

A point-back printed after a commit is now committed too. That earlier commit (a pending change, or the index's rename or removal) takes a staged `.gitmodules` into HEAD. At `4268f804`, a staged section edit beside a committed `Members/README` left the point-back staged, and the gate found `member-gitmodules-absent` in every mode.

**Measured at `ce0f2a5f`.** The gitlink stays at `members/IRRS`, and the section is hand-edited to `Members/IRRS`.
- Committed or staged, `add` exits 0 and commits that `.gitmodules` beside the new member.
- The gate exits 1 in every mode: `gitmodules-path-unnamed`, plus `member-gitmodules-absent` once committed.
- With the edit not staged, `gitmodules-unstaged` comes first; typed, it leads to the staged state, and `add` goes through.
- A section giving `members/irrs` behaves the same way.

**What changed.** `_sections_astray` finds each section that gives a case variant of a row's path, where the index holds the member's gitlink at that path. `_row_sections_astray` then tells two cases apart, as the gate does:
- **No section gives the row's path.** This is the gate's `member-gitmodules-absent` case. The section named after the row's path, the name `add` gives a member's section, is pointed back with the gate's own line: `git config -f .gitmodules submodule.members/IRRS.path members/IRRS`, then `git add -- .gitmodules`. Its place is "in .gitmodules, its gitlink at its row's path". A gitlink at its path is taken out of the index first (below), which leaves the section where the point-back finds it, in the same round.
- **A section gives the row's path.** Each other one is a second section beside it, which the gate's `gitmodules-path-unnamed` calls `STRAY`. It is taken out, every key it holds, with the gate's own lines: `git config -f .gitmodules --unset-all submodule.Members/IRRS.path` and the same for `url`, each key quoted for a POSIX shell, then `git add -- .gitmodules`. Its place is "in .gitmodules, beside the section giving its row's path".
- **A gitlink at such a path, beside the member's own gitlink,** is a second path to the member's directory (fix round 2). It is taken out of the index alone, `git rm -r --cached -- Members/IRRS`, as the gate's own line does (`UNSTAGE_SECOND`). It takes `--sparse` where the sparse rules leave it out or its bit is set, and `-f` where the index stages it at a commit HEAD does not record there beside a checkout (fix round 3). That comes before the take-out, and `git rm --cached` leaves the section for it. A checkout there that `git submodule add` or `git mv` made, on disk in its own spelling, is named to delete by hand in the same round, with its `git -C <path> status`, before the update (fix round 3). A clone of its own is not named: the next run moves it out whole.
- Once the member's own section is pointed back, any other variant section is beside it, and is taken out the same way.
- A variant section that is neither is left to the gate, which names it and prints no command for it.

The code is `family-members-dir-variant`, as for any other path under another spelling of `members/`. Each part commits where HEAD's copy holds what it changes, or where a commit printed before it took a staged `.gitmodules` into HEAD: a pending change, the index's rename or removal, or, for a take-out, the point-back's own commit (fix round 2). After a staged edit alone there is nothing to commit, and `git commit` would fail. A change nobody has staged is staged first, as #685 does for the move back.

A point-back closes with the update, `git submodule update --init --recursive`, where nothing is checked out at the row's path. That is the state of a fresh clone: no section gives `members/IRRS`, so nothing is checked out there. Beside a move back of another member, the one closing update names both rows. A take-out needs no update: the member's own section gave its path all along.

**Why the take-out (rv865's MUST 1).** At `4268f804`, every variant section was pointed back, whatever submodule it belonged to. Measured again on that head with the text checks left out, on git 2.43: after `git submodule add <IRSS's remote> Members/IRRS` (kept, at `members/irrs`, or with its checkout deleted), or with the section `git rm --cached` leaves behind, the remedy typed as printed gave `members/IRRS` twice. The gate exited 1 in every mode, and a recursive clone of the pushed branch exited 128: `Fetched in submodule path 'members/IRRS', but it did not contain <commit>`.

**Caveat on fix round 1's claim (rv865b's MUST 1).** The first fix round said that the same four states and a fifth type clean, that the recursive clone checks `members/IRRS` out, and that the gate passes. That held for a recursive clone only. In (a) and (b), the take-out ran after `git mv` of the same gitlink. It stripped the section `git mv` had carried along, and left `renamed-1` a gitlink no section names. A plain clone's `git submodule update --init --recursive` then exited 128 (`No url found for submodule path 'renamed-1' in .gitmodules`), and so did `git submodule status`, in the holder and in the clone. The gate cannot see this, since it asks only gitlinks under `members/`.

**Since fix round 2,** that gitlink is taken out of the index and never renamed. Measured at `4e92867a` on git 2.43 and 2.47.1, the five states and two more type clean:
- the two more are a staged section taken out after the point-back's commit, and a staged one whose name needs quoting;
- each takes one round; in (a) and (b), the checkout `git rm --cached` leaves is deleted by hand where the text says (two rounds at `38e0d503`);
- a recursive clone checks `members/IRRS` out from IRRS's remote at its commit;
- a plain clone's `git submodule update --init --recursive` exits 0 with every member checked out;
- `git submodule status` exits 0 in the holder and in both clones;
- the gate passes in all three modes, and the same `add` goes through.

rv865b's four K2 states (the gitlink at `Members/IRRS` beside the member's own, with the member's section astray: committed, reset, checkout deleted, and beside a foreign section) do the same, in one round each.

**Work in the second path's checkout (fix round 3).** These states type clean in one round too:
- (a) staged with an untracked file;
- (a) staged with a commit of its own;
- (a) staged and left out by the sparse rules, which gives `--sparse -f`;
- K2 reset with work;
- K2 committed with work.

Where the text says to delete the checkout, its `git -C Members/IRRS status` still names the untracked file, and its commit is kept in its store.

**Measured at `ce0f2a5f`.** `Members/X/notes` is committed, and a checkout that `git submodule add` made at `Members/X/Y` is taken out of the index. #574's `git mv -- Members/X <new name>` typed as printed moved the checkout too. `git -C renamed-1/Y status` then said `fatal: not a git repository: .../renamed-1/Y/../../../.git/modules/Members/X/Y`.

A checkout the index still records under that directory is moved by `git mv` with its `.git` file mended: `git -C renamed-1/Y status` exits 0.

**What changed.** Each checkout git does not track, inside a directory that a `git mv` (`RENAME`) or a plain `mv` (`MOVE`) moves, is named first (`CHECKOUT_FIRST`, `_checkouts_in_the_way`). It is to be deleted by hand once nothing only it holds is needed, as #828's `CHECKOUT` says of one found on disk. Its commits stay in the holder's store. `git -C <checkout> status` shows what is not committed.
- The walk does not follow links, and does not enter a directory that holds a `.git` entry, so a checkout's own legs are not named again.
- A gitlink the index holds is not named, and neither is a clone of its own, whose `.git` is a directory: the move takes it whole.
- The moved directory itself is not entered when it holds a `.git` entry.
- The plain `mv` case is the same fault, so it is covered too.

The fix round pins the last three (rv865's R7, R8, R9, R10) with a clone of its own at `Members/X`, legs checked out, and with a plain `Members/X` holding such a clone and a link to a directory outside the holder. In both, only `mv -- Members/X <new name>` is printed. #828's `test_a_checkout_under_an_index_entry_is_the_indexs_to_name` did not pin R7: that directory has no subdirectories.

**Measured at `ce0f2a5f`,** beside the row `members/IRRS`. In each of these states `add` exits 0, so a person meets nothing:
- a file `members/irrs` in the index;
- the same file on disk;
- that file in HEAD alone, its removal staged;
- an untracked directory there;
- a second gitlink there;
- the member's own gitlink moved there, #646's state under `members/`.

The gate passes the first four. It refuses the second gitlink in every mode (`member-gitlink-unnamed`). For the member's own gitlink it exits 2 in members mode, and `member-gitlink-unnamed` under `--no-members`.

**What changed.** `_spelled_target` takes the rows' paths. Under `members/` itself, a case variant of a row's path that is no row's own path is now a target, as a variant of the mount path already was.
- It flows through the index, HEAD and disk checks, so each state gets #574's remedy.
- The member's own gitlink gets #646's move back.
- A section whose gitlink's removal is staged gets #828's `IN_GITMODULES` put-back.
- A second gitlink that `git submodule add` made there, with its own section, is taken out of the index, its section is taken out, and its checkout is named to delete by hand in the same round (line 1, fix rounds 2 and 3). A second gitlink with nothing on disk is taken out of the index as before.

The code is `family-members-dir-variant`. Its first sentence says what was found (`_lead`): "a case variant of the path of a `members:` row under members/, the same path as that row's on a case-insensitive disk". It is joined to the other-spelling sentence where both are found. A folding disk, asked in process with `disk_as`, gives the same text.

**On a disk that folds case (rv865's SHOULD 1),** `_variant_in_members` lets through an entry of `members/` that the disk takes for the row's own path (`os.path.samefile`, as `_members_dir_on_disk` asks of `members/`). macOS and Windows list a directory in the spelling it was made in. So after `git mv members/IRRS members/irrs` and line 3's move back, whose closing guard `[ members/irrs -ef members/IRRS ]` is true there, the member's only checkout is listed as `members/irrs`. At `4268f804` the next `add` refused it as on disk and untracked, every time. Now it goes through. On Linux the two are two paths, `members/IRRS` holds nothing, and `members/irrs` is still named.

**A link is never the row's own (rv865b's NIT 1).** `samefile` follows a link, so at `f7072d08` a link `members/irrs -> IRRS` was let through on Linux and `add` went through beside it; `4268f804` named it. `_variant_in_members` now asks `os.path.islink` first, as `_members_dir_on_disk` never takes a link for `members/`. The link is named on disk, `on disk and untracked: members/irrs (a symlink)`, and moved out of the holder, the link alone.

**Measured at `ce0f2a5f`.** `git mv members Members` is committed, then `.gitmodules` is left unmerged (stages 1, 2 and 3) and deleted from the work tree. `_checked_gitmodules_as_staged` leaves a missing copy to git for `add`, so the rename back was printed. Its commit failed: `Committing is not possible because you have unmerged files`. In #646's state the commit fails the same way, and the update says `No url found`.

**What changed.** `_gitmodules_first` asks first. For this state it raises `gitmodules-missing` (`_gitmodules_missing`) with the restore git takes there, as `remove` prints it: `git checkout --merge -- .gitmodules`, resolve it, `git add .gitmodules`.

The detail says why for `add`. `remove`'s text is unchanged, byte for byte. An unmerged copy is now asked first wherever the spelling refusal is raised, not only beside a gitlink, since any printed commit fails beside it.

**NIT 3.** `_checked_gitmodules_as_staged` has the `def` line base had, `def _checked_gitmodules_as_staged(root: Path, verb: str) -> None:`, and no `spelled` parameter. The missing-file refusal moved into `_gitmodules_missing`, which it raises for `remove` and `_gitmodules_first` raises for `add`. By hand, its cognitive complexity went from 35 at `4268f804` (34 at base) to about 18, so Sonar keeps that issue as the old one, lower. The new functions are under 15 by hand: `_gitmodules_missing` about 12, `_row_sections_astray` about 8 (9 before fix round 2 dropped K2's clause), `_variant_in_members` about 9 (8 before NIT 1's `islink`).

Fix rounds 2 and 3, measured with rv865b's estimator (`cogc.py`, comprehensions not counted):
- **New functions:** `_second_gitlink` 2, `_second_path_part` 5, `_second_checkout_part` 2, `_forced` 1, `_removed` 2.
- **`_point_back_part`:** up 1, to 3.
- **`_spellings_refusal`:** down 2, from 38 at `f7072d08` to 36, since the removal's condition moved into `_removed`.
- **`_move_back_line`:** unchanged at 9.
- **`_members_spellings`:** from 41 at `f7072d08` to 45 at `38e0d503`, then 46 now. Round 2 added 4: its `if`, nesting, its `and`, and `out = … or …`. Round 3 adds 1, the `and` in `checkout = present and …`. The fix round 2 body said 3. Its `def` line is unchanged, and it was over 15 before this PR.
- **`typed_as_printed`:** 31, as at `f7072d08`, and its `def` line is that commit's again.

**Measured at `ce0f2a5f`.** `git rm .gitmodules`, with HEAD holding the file. The printed rename back, or move back, and its commit took `.gitmodules` out of HEAD with every member's section. The gate then found `member-gitmodules-absent` for each member.

With `git rm --cached .gitmodules`, a copy stays on disk, untracked:
- beside the rename back, `git mv` staged that copy again, and the result passed;
- beside the move back, the commit took the deletion as well, and left the copy untracked.

**What changed.** `_gitmodules_beside` tells the two apart: `GITMODULES_DELETED` where the work tree has no copy, and `GITMODULES_UNTRACKED` where it has one.
- Deleted: the refusal's first part puts HEAD's copy back, `git checkout HEAD -- .gitmodules`, as `remove` prints it and as #767 does for a copy deleted from disk.
- Untracked copy: `gitmodules-unstaged` comes first ("its deletion is staged"), since which copy to keep is the person's.

Both apply wherever anything is found, so a lone `Members/README` rename is covered too: its commit took the deletion as well.

**Measured at `ce0f2a5f`, which does not answer it.** #828's set-aside part does not reach this state. `git rm -r --cached --ignore-unmatch -- Members/IRRS Members/IRSS` exits 128: `fatal: not a git repository: Members/IRRS/../../.git/modules/members/IRRS`.

The rest of the line then ran, but the gitlinks stayed under `Members/`, and the next `add` refused again. Through the same link, plain `git mv -- Members/README x` moves the file in from where the link points.

**What changed.** A symbolic link that the index holds entries under is named on disk and moved out of the way first (`LINK_OUT`, `_disk_found`). The words are those `member-path-unaddable` has for `members` itself: "must be a plain directory of the holder's own".

`_on_disk_as_named` now takes nothing beyond a link as on disk. So a file under it is taken out of the index (`git rm -r --cached`), not given a `git mv` that would fail once the link is gone (`bad source`).

Typed as printed:
- the link moves;
- the move back runs with both sections;
- the guarded `mv` moves nothing;
- the update checks both members out at their rows' paths.

The directory the link named is left where it is. A checkout there was unusable already, since its `.git` file is relative.

**Measured at `ce0f2a5f`.** After `git mv members/IRRS Members/IRRS`, an unmerged file at or under `members/IRRS` left the row neither blocked nor a stray. So the gitlink kept #574's `git mv -- Members/IRRS <new name>`, which took it away from its row, and the commit failed.

An unmerged file at `members` itself, off disk: each `git update-index` ran, and the commit failed. On disk, `member-path-unaddable` printed `git mv -- members <new name>`, which exits 128 (`fatal: conflicted`).

**What changed.**
- `_rows_held` counts a file or link at any stage as in the way.
- `_set_aside` takes an unmerged entry out of the index, every stage, with `git rm -r --cached`, which git allows there (`ASIDE_UNMERGED`). Where the entry is on disk as the index holds it, it is then moved out of the way with `mv` (`ASIDE_UNMERGED_MV`).
- Measured why the `mv` is needed: a file left at the row's path made the update fail (`could not create directories for members/IRRS/.git`). One left under it stayed in the member's checkout, which `git status` then lists as modified.
- The removal is a change to commit only where HEAD holds the entry. With the move staged, no commit is printed.
- An entry held at stages 1 and 3 alone, which this side deleted, is unmerged too.
- `_members_is_a_file` gives an unmerged `members` the same two steps, plus the commit on its own where HEAD holds the file.

**Already answered at `ce0f2a5f`.** Measured on git 2.43 and 2.47.1, with #646's state and a link `Members/IRRS` on disk, pointing to the member's checkout or to nowhere. git 2.47.1 refuses `git status` itself: `error: expected submodule path 'Members/IRRS' not to be a symbolic link`. `add` reads no `git status` on this path, and gives its spelling refusal first: no git `fatal:` and no traceback, for `add --dry-run` and the real run alike.

At `f7072d08`, typed as printed on 2.47.1:
- the move back ran;
- the closing guard kept a link to the row's own directory (`[ Members/IRRS -ef members/IRRS ]` follows it);
- the next `add` named it on disk, with its `mv`;
- typed, the gate passed and `add` went through: three rounds.

**Since fix round 2 (rv865b's NIT 4),** the closing guard takes #839's form, which asks `[ ! -L Members/IRRS ]` first, so a link there is always moved, the link and never what it points at. The guard now moves the link in the first round, and the next `add` goes through: two rounds, typed under dash and under bash, on git 2.43 and 2.47.1. A link to nowhere was moved in the first round before, and still is.

`test_a_link_at_a_variant_the_index_holds_is_refused_first` pins this. Its refusal check is `refused_untouched(..., status=False)`, so `snapshot` skips `git status`, which git 2.45.1 and later refuse here, and keeps every other key.

The guard holds `; ` inside its braces, so the tests' `typed_as_printed` now splits the remedy into parts at `; ` outside backticks only (`remedy_parts`).

**New file `tests/test_family_add_spelling_leftovers.py`, 70 cases.** Each typing test types every printed command as printed, with `typed_as_printed`, in the state it describes. Each one then checks that every member is where its row says, that the gate passes in all three modes, and that the same `add` goes through.

| Line | Cases |
|---|---|
| 1 | committed, staged, not staged, under `members/` itself, a fresh clone, beside a move back; a staged edit after a pending change, a rename and a removal; rv865's four states, the point-back beside a take-out, a staged take-out after the point-back's commit, and a staged one whose name needs quoting; rv865b's four K2 states; the second gitlink left out by the sparse rules or marked by hand; five states with work in the second path's checkout; a staged second gitlink with nothing on disk, which takes no `-f`; a clone of its own at a second path, moved whole on the next run; a second gitlink marked at `Members/IRRS` with `Members` a link to `members`, which names no checkout (fix round 4); the section named after the row beside one giving its path; a variant section left to the gate; an unreadable `.gitmodules` |
| 2 | under a `git mv`, under an `mv`, a tracked gitlink's control, and a clone of its own moved whole, alone and beside a link |
| 3 | six states, a section whose gitlink's removal is staged, both disks in process for two of them, the member's checkout a folding disk lists in another case, on both disks, and a link `members/irrs -> IRRS` |
| 4 | one case |
| 5 | beside the rename back, the move back and a file, and the untracked copy |
| 6 | the gitlinks alone, and a file under the link |
| added state | two cases, typed under dash and under bash |
| 7 | five unmerged states, two with the move staged, one at stages 1 and 3 alone; `members` off disk, and `members` on disk with and without HEAD's file |

Every case of line 1 that a section beside the member's own or a second path takes part in (rv865's four, the two staged take-outs, the K2 states, the sparse ones and the work states) ends the same way (`typed_through`):
- HEAD is pushed to a branch of its own;
- a recursive clone checks `members/IRRS` out from IRRS's remote at its commit;
- a plain clone, as the holder's CI makes it, runs `git submodule update --init --recursive`, which must exit 0 with every member checked out;
- `git submodule status` must exit 0 in the holder and in both clones.

Where the text says to delete a checkout by hand, it is deleted there, after the parts before it and before the update. In the work states, the hand step first asserts that `git -C <checkout> status` still names its untracked file.

**Changed:** `tests/test_family_add_move_back_sections.py`. Its `set_aside` builds the unmerged part (`unmerged=`, `moved=`). In fix round 2, one docstring names the guard's new form.

**Changed, in the second commit:** `tests/test_family_members_case_variants.py`. `refused_untouched` takes a keyword `status=True` and passes it to both of its snapshots. The link test calls it with `status=False`, and the narrower helper it had (`refused_beside_a_link`) is gone. In the third commit, its one literal `Spelled` tuple gains the new `keys` field, and in fix round 3 the new `checkout` field.

**Changed, in fix round 2:** `tests/test_family_add_member_variant.py`.
- `guarded` and the closing words take #839's form.
- `typed_in_sh` types into the module's `SHELL`, `sh` unless a test sets `dash` or `bash` with `monkeypatch` (fix round 3). Its `def` line and `typed_as_printed`'s are `f7072d08`'s, so the remedy's split is their only change (rv865b's NIT 2).
- The remedy is split into parts at `; ` outside backticks (`remedy_parts`).

No test needs a git newer than 2.34, but for the sparse case. The second gitlink left out by cone-mode rules needs git 2.38 or later (`sparse_cone`, as #828's tests). So does the work state left out by those rules. The sparse cases skip where git has no `git sparse-checkout check-rules`, as does the staged second gitlink marked by hand. Symlink cases skip on Windows, the added state skips where its shell is missing, and states that need two names differing only in case skip on a disk that folds case, as in #828's files.

**Fails on the base.** The first 35 cases, run against `ce0f2a5f`'s `family.py`: **33 failed and 2 passed** (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/base.log`). The two passes pin what the base already does: the added state and line 2's control.

**Fails on `4268f804`.** The fix round's 16 new cases, run against `4268f804`'s `family.py`: **9 failed and 7 passed**, on git 2.43 and on 2.47.1 (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/oldfinal243.log`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/oldfinal247.log`).
- The 9 that fail are the fixes: rv865's four states and the point-back beside a take-out, the section named after the row, the variant section left to the gate, the staged edit after a rename, and the folding disk's checkout.
- The 7 that pass pin code that was already right, as rv865 found it: the staged edit after a pending change (R26), the unreadable `.gitmodules` (R13), the two clones (R7 to R10), the two new unmerged states (R23, R42), and the Linux side of the folding-disk test.

**Fails on `f7072d08`.** Fix round 2's 17 new or changed cases, run against `f7072d08`'s `family.py`: **16 failed and 1 passed**, on git 2.43 and on 2.47.1 (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/old243.log`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/old247.log`). The one that passes is the staged edit after a removal (X2), which pins code that was already right.

A copy with no check on the text was also run on both heads, git 2.43: each state is typed as printed, round after round, then put through the same checks (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/test_w865f_r2_behaviour.py`).
- At `f7072d08`: 11 of 16 failed (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/bh-old243.log`).
  - The plain clone's update failed for (a), (b) and both sparse states.
  - HEAD's `.gitmodules` was not the holder's for the K2 states committed, reset and foreign, and for the staged take-out after the point-back's commit, which left `stale` in HEAD.
  - The link `members/irrs` was let through.
  - The link at the variant took 2 rounds of typing before `add` went through, in dash and in bash.
- At `38e0d503`: 16 passed (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/bh-head243.log`).
- The 5 that pass at `f7072d08` are (c), (d), the point-back beside a committed take-out, the staged name that needs quoting (X1, X6: `f7072d08` already quoted the key and printed no commit), and K2 with its checkout deleted, which took two rounds there and ended right.

**Fails on `38e0d503`.** Fix round 3's new and changed cases, run against `38e0d503`'s `family.py`:
- **Of the 20 cases with the round's changes, 12 failed and 8 passed**, on git 2.43 and on 2.47.1 (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243.log`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old247.log`).
  - The 12 that fail are (a), (b), K2 committed, reset and foreign, both sparse states, and the five work states.
  - The 8 that pass are the BESIDE states with no second path, K2 with its checkout deleted, and the link test under dash and bash, none of whose text or typing changed.
- **The two cases added last both fail** (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243b.log`).
- **A copy of the five work states with no check on the text** (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/test_w865f_r3_behaviour.py`) types round after round, and its hand step asserts that the status names the untracked file:
  - At `38e0d503`, all five fail (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243b.log`). Four stop at `git rm -r --cached` (`staged content different from both the file and the HEAD`): (a) staged with an untracked file, with a commit of its own, left out by the rules, and K2 reset with work. K2 committed with work fails the hand step: `WIP.txt on disk but not in its status`.
  - At `4e92867a`, all five pass (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/bh-head243.log`).

Every run went through `run9.sh` or `run9s.sh`, with the real `gh` unreachable and basetemps under `v110/bt/`.

| Log | Git | Head | Files | Result |
|---|---|---|---|---|
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/final243.log` | 2.43 | `4e92867a` | the same 15 files, 3 shards | 721 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/final247.log` | 2.47.1 | `4e92867a` | the same 15 files, 3 shards | 721 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/final243.log` | 2.43 | `38e0d503` | the same 15 files, 3 shards | 714 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/final247.log` | 2.47.1 | `38e0d503` | the same 15 files, 3 shards | 714 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/probe-head-243.log` | 2.43 | `38e0d503` | rv865b's probe, which records and never asserts (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/probe-head-243.jsonl`) | 29 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/final243.log` | 2.43 | `f7072d08` | 15 files, 3 shards (below) | 703 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/final247.log` | 2.47.1 | `f7072d08` | the same 15 files, 3 shards | 703 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/full243.log` | 2.43 | `dde7e9cc` | 15 files, 3 shards: the 3 touched test files, the other 5 `test_family_add_*` files, the trio, `test_family.py`, `test_family_remove_gitmodules.py`, `test_family_head_lost_gitmodules.py`, `test_family_holder_states.py` | 702 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/full247.log` | 2.47.1 | `dde7e9cc` | the same 15 files, 3 shards | 702 passed, rc 0 |
| `va.log` | 2.43 | `2c97e759` | the new file, the issue's Verify files, `test_family_holder_states.py`, the trio | 471 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/vb1.log` | 2.43 | `2c97e759` | the first 33 of the 65 other files that run or import `family.py` | 2070 passed, 24 skipped, 1 xfailed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/vb2.log` | 2.43 | `2c97e759` | the other 32 of those 65 files | 1739 passed, rc 0 |

The 15 files are the 3 that fix round 1 touched, rv865's other five `test_family_add_*` files (`excludes`, `member_id`, `member_variant`, `refusals`, `spellings_remedies`; fix round 2 touches `member_variant` too), the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`), `test_family.py`, and the three files that drive `.gitmodules` refusals for `add` and `remove`. CI's git 2.55 was not run here. macOS and Windows disks are emulated in process (`disk_as`) for lines 1 and 3.

**rv865b's probe at `38e0d503`, git 2.43.** Its typing stops after three rounds and never deletes a checkout by hand, so a state that leaves one shows the checkout named in rounds 2 and 3. Each state ends with the gate passing in all three modes.
- **P1 (a) and (b) as printed, and P2:** round 1 takes the gitlink out of the index. HEAD's gitlinks are then the two members alone, and `git submodule status` exits 0 in the holder and in the recursive clone. Rounds 2 and 3 name the checkout left at the variant.
- **P3, K2:** committed, reset and foreign end the same way. "Deleted" ends in one round, and `add` goes through.
- **P4 and P4b:** the take-out commits after the point-back's commit. HEAD no longer holds `stale` (`head_has_stale: false`).
- **P5:** `members/irrs -> IRRS` is refused (code 2), and so is `members/irrs -> IRSS`.
- **P6:** all four link layouts take two rounds, and the link is gone.
- **L7:** all six states are clean.
- **P7 (a), (b) and (c) as printed:** a plain clone's update exits 0, with every member checked out.
- **P7's control,** the rename typed by hand followed by the gate's own `--unset-all` lines, still gives a plain update that exits 128 while the gate passes. That is the validator's gap, which rv865b named; this PR does not print that rename.

**First round: 30 mutants,** M1 to M27 plus M11b, M13b and M15b, at least one per new hunk, each run `-x` on the test that names its state. All 30 are caught. (The earlier text said 31.)

| Mutant | What it undoes | Caught by |
|---|---|---|
| M1 | `_spelled_target`: no row variant under `members/` | line 3 (`add` exits 0) |
| M2 | `_on_disk_as_named`: on disk beyond a link | line 6, a file under the link |
| M3 | `_disk_spellings`: no row variant on disk | line 3, a directory on disk |
| M4 | `_sections_restored`: no row variant for a staged removal | line 3, the section state |
| M5 | `_rows_held`: an unmerged file not in the way | line 7 |
| M6 | `_in_the_way`: nothing unmerged | line 7 |
| M7 | `_aside_remedy`: an unmerged file on disk not moved | line 7 |
| M8 | no section astray | line 1 (`add` exits 0) |
| M9 | `_disk_found`: no `LINK_OUT` | line 6 |
| M10 | no checkout inside a moved directory | line 2 |
| M11 | `_checkouts_under`: a tracked gitlink named | line 2's control |
| M11b | `_checkouts_under`: a checkout entered (its legs named) | line 2 |
| M12 | `_gitmodules_beside`: no staged deletion | line 5 |
| M13 | `_gitmodules_first`: unmerged not asked first | line 4 |
| M13b | `_gitmodules_first`: an untracked copy not asked first | line 5, the untracked copy |
| M14 | `add` leaves a missing unmerged copy to git | line 4 |
| M15 | `_members_is_a_file`: unmerged taken for merged | line 7, `members` on disk |
| M15b | `_members_is_a_file`: HEAD's file not committed out | line 7, HEAD holds the file |
| M16 | `_lead`: a row variant said as another spelling | line 3 |
| M17 | `_point_back_part`: the commit always printed | line 1, staged |
| M18 | no update for a section alone | line 1, a fresh clone |
| M19 | the update names the moved rows alone | line 1 beside a move back |
| M20 | `_gitmodules_part`: no put-back from HEAD | line 5 |
| M21 | `_set_aside_part`: unmerged not said | line 7 |
| M22 | `_link_part`: never said | line 6 |
| M23 | `_checkout_first_part`: never said | line 2 |
| M24 | an unmerged removal always committed | line 7, the move staged |
| M25 | HEAD's row variants not asked | line 3, in HEAD alone |
| M26 | the index's row variants not asked | line 3, a file in the index |
| M27 | `refused_untouched` ignores `status` | the link test, on git 2.47.1 only |

**Fix round:** 19 mutants of this round's new hunks in `scripts/family.py` (K1 to K18 and K3a), and the 8 of rv865's survivors pinned here run again, 27 in all, on `dde7e9cc`'s `family.py`, which `f7072d08` keeps, each `-x` on the tests named (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mutate.py`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mut/summary.txt`). **All caught but K2.**

| Mutant | What it undoes | Caught by |
|---|---|---|
| K1 | point back even where a section gives the row's path | the section named after the row |
| K2 | point back a section whose path is a gitlink in the index | survived; fix round 2 drops the clause, and N14, which puts it back, is caught |
| K3 | any variant section is the member's own, not only the one named after the row | the point-back beside a take-out |
| K3a | the take-out set off by any variant section | the variant section left to the gate (it survived until `f7072d08` added that test) |
| K4 | nothing taken out once the member's own is pointed back | the point-back beside a take-out |
| K5 | nothing taken out beside a section giving the row's path | (a) |
| K6 | a take-out never a change to commit by itself | (d) |
| K7 | a point-back never a change to commit by itself | line 1, committed |
| K8 | `_committed` ignores the earlier commit | the staged edit after a pending change |
| K9 | only the `path` key taken out | (a) |
| K10 | the earlier commit is the pending change alone | the staged edit after a rename |
| K11 | the earlier commit leaves out the pending change (rv865's R26) | the staged edit after a pending change |
| K12 | `_variant_in_members`: no `samefile` | the folding disk's checkout |
| K13 | `_variant_in_members`: a row variant on disk never named | the same test on Linux |
| K14 | `_gitmodules_first`: no `gitmodules-missing` for an unmerged copy off disk | line 4 |
| K15 | `_gitmodules_missing`: a staged copy taken for unmerged | `test_family_remove_gitmodules.py` |
| K16 | `_checked_gitmodules_as_staged`: `add` no longer leaves a missing file to git | `test_family_dry_run.py` |
| K17 | `_sections_astray`: an unreadable `.gitmodules` raises there (rv865's R13) | the unreadable `.gitmodules` |
| K18 | the place "beside the section giving its row's path" not listed | (a) |
| R7 | `_checkouts_under`: a moved directory that is a repository entered | the clone moved whole |
| R8 | a clone (`.git` a directory) named too | the clone beside a link |
| R9 | the walk follows links | the clone beside a link |
| R10 | a directory holding a `.git` directory entered | the clone beside a link |
| R23 | an unmerged entry moved with `mv` always a change to commit | line 7, on disk with the move staged |
| R25 | `_lead`: the row sentence alone | the point-back beside a take-out |
| R25b | `_lead`: the joined sentence without the row clause | the point-back beside a take-out |
| R42 | unmerged only where stage 2 is held | line 7, stages 1 and 3 alone |

**Fix round 2: 19 mutants** of the round's new hunks in `scripts/family.py` (N1 to N18 and N7b), on `38e0d503`, each run `-x` on the tests named (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mutate2.py`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mut2/summary2.txt`). **18 are caught and N2 survives.** N2 was run over all 200 cases of the leftovers, `member_variant`, `move_back_sections` and `case_variants` files, which all passed: see "Not here".

| Mutant | What it undoes | Caught by |
|---|---|---|
| N1 | `_second_gitlink`: any entry, not only a gitlink | line 3, a file on disk |
| N2 | `_second_gitlink`: the row's path need not hold a gitlink | **survives** (200 passed): see "Not here" |
| N3 | `_second_gitlink`: any gitlink while a row's gitlink is held, whatever its path | `test_a_gitlink_the_sparse_rules_leave_out_is_renamed_with_sparse` |
| N4 | the second path renamed with `git mv` again | (a) |
| N5 | a second path with nothing on disk rerouted too | (c) |
| N6 | the second path's removal never takes `--sparse` | the second gitlink left out by the rules |
| N7 | `--sparse` only where the rules leave it out | the second gitlink marked by hand |
| N7b | `--sparse` only where the bit is set | the second gitlink left out by the rules |
| N8 | the second path's part never printed | (a) |
| N9 | `_second_path_part`: `--sparse` never printed | the sparse cases |
| N10 | `_second_path_part`: the git `--sparse` needs never said | the sparse cases |
| N11 | `_removed`: the second path's removal never a change to commit | (a) |
| N12 | `_removed`: a pending change committed first no longer makes a removal one | `test_every_place_is_named_and_the_pending_change_is_committed_first` |
| N13 | `_removed`: a removal said wherever anything is committed | line 3, in HEAD alone |
| N14 | K2's gitlink clause put back | K2 committed |
| N15 | the point-back's own commit not counted for the take-out (NIT 3) | the staged take-out after the point-back's commit |
| N16 | `_variant_in_members`: no `islink` (NIT 1) | the link `members/irrs` |
| N17 | `_closing_sentences`: #828's guard again | the added state |
| N18 | #839's form without its `-L` test | the added state |

**Fix round 3: 13 mutants** of the round's new hunks in `scripts/family.py`, on `4e92867a`, each run `-x` on the tests named (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mutate3.py`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mut3/summary3.txt`). **12 were caught, and C5 survived** until fix round 4's test `test_a_second_paths_checkout_is_asked_in_its_own_spelling` caught it (on `231c404c`).

| Mutant | What it undoes | Caught by |
|---|---|---|
| F1 | the second path's `-f` never printed | (a) staged with an untracked file |
| F2 | `_forced` always true | (a), which takes no `-f` |
| F3 | the clause on `-f` never said | (a) staged with an untracked file |
| F4 | `_forced` without the question whether a checkout is on disk | the staged second gitlink with nothing on disk |
| F5 | `_forced` without the question whether HEAD records it at that commit | (a) |
| F6 | a second path never forced | (a) staged with an untracked file |
| F7 | the move back's clause dropped, now the shared constant | `test_family_add_move_back_sections.py` |
| C1 | the checkout a second path leaves never named | (a) |
| C2 | that checkout named after the update, not before | K2 committed with work |
| C3 | every second path has a checkout | the second gitlink marked by hand |
| C4 | a second path's checkout never noticed | (a) |
| C5 | the checkout asked whatever the spelling on disk | the second gitlink marked with `Members` a link to `members` (fix round 4) |
| C6 | any directory, a clone of its own too, is a checkout | the clone of its own at a second path |

`git merge-tree --write-tree` of `4e92867a`, after a fetch of main at 08:38Z on 2026-10-18:
- **Clean:** #828's final head `3546118b` (tree `923bb23b`), the restack's first merge.
- **For information:** main `def45de6`, which holds #828's squash `9b0a7038`, conflicts in #828's own files: `scripts/family.py`, `tests/test_family_add_member_variant.py`, `tests/test_family_add_move_back_sections.py` and `tests/test_family_members_case_variants.py`. This PR still holds #828's commits from before the squash, and the restack resolves those files.

Fix round 2's check of `38e0d503` was clean against main `3bf2d3c9` and `5c54af20`, and against #828's `90649e53` and `54b7699e`.

Fix round 1's check of `f7072d08`, at about 02:55Z, was clean against main `4dea85ee`, `90649e53`, and the open PRs that change `scripts/family.py`: #814 `4f6e7f83`, #846 `2d0e884f`, #849 `d98df568`. Those three were not checked again in fix round 2.

The restack onto #828's squash comes next. The first commit's wider check, against every open branch of 2026-10-16 and 2026-10-17 that touches these files, found no conflict of its own.

No line of `README.md`, `docs/handbook.html`, `docs/cli.md`, `AGENTS.md` or `templates/family-root/*.md` states the changed text or any of these codes. `--help` is unchanged, and no finding code is new. The docstrings of the changed functions say what each now does.

- **The validator's own text.** `_wrong_case_move` is lane 5's #839 (`fix/839-holder-bootstrap-texts`), whose guard this PR's text now matches. The validator is a pinned file and is not touched here. rv865b also found that typing the gate's own `--unset-all` lines after a hand rename of a second gitlink leaves the same orphan gitlink while the gate passes, since `_check_gitlinks_named` asks only gitlinks under `members/`. That gap is the validator's, and this PR no longer prints that rename.
- **A harmless `-f` on a folding disk.** On a disk that folds case, a second gitlink marked off disk at `Members/IRRS` resolves to the member's own checkout. So `_forced` prints a harmless `-f` there, as #828's move back does. The checkout question is asked in the path's own spelling (`present`), so no line says to delete that checkout. Fix round 4's test pins this with `Members` a link to `members`, the folding emulation `tests/test_validate_family_remedies_typed.py` uses (rv865b's round-3 NIT 1, mutant C5).
- **A second gitlink whose row's path holds index entries under it** (a gitlink below `members/IRRS`, say) rather than a gitlink at it. No rule here decides whose gitlink the variant is there, so it keeps #574's remedy. Mutant N2, which drops `_second_gitlink`'s question whether the row's path holds a gitlink, survives for that reason. It differs only there, or where the row's path is an unmerged entry whose first stage is no gitlink, which is R21's state. Each of those is a state no rule rules on, and pinning either remedy would decide it.
- **A checkout that is itself the moved directory**, beside an index entry inside it, keeps the index's remedy, as #828's test pins.
- **An unmerged entry outside the sparse definition.** `git rm --cached` of it may need `--sparse`. This was not measured, and no `--sparse` is printed for one.
- **The "stage the change" part's reason** names `git mv` and `git rm` of a gitlink (#685's text). For line 1 alone, the reason is the `git add -- .gitmodules` that follows. The text is unchanged.
- **R20 and R21 are not pinned.** R20 (`_members_is_a_file` takes HEAD's `members` of any kind for the file) differs only where HEAD holds `members` as a tree, a link or a gitlink while the index holds an unmerged file `members`: a directory/file or link/file conflict that a merge may not even leave, and whether a link in HEAD should ask for the commit too is a question of its own. R21 (`_rows_held` takes an entry with any non-gitlink stage as in the way) differs only for an entry at the row's path unmerged with a gitlink at one stage and a file at another, made only by `update-index`; whose gitlink that is, and so which remedy is right, is not ruled.

- **The closing guard kept a link to the row's own directory.** Done in fix round 2, in #839's form (rv865b's NIT 4).
- **`_sections_restored` (#828) points back every variant section of a member it moves back.** It has the fault MUST 1 found in `_sections_astray`, for a member whose gitlink is at a variant: a second variant section, another submodule's, would be pointed back too, and the path given twice. Read from the code, not measured.
- **Found in the checklist pass (t114 row 5), and done in the second commit.** `refused_untouched` and `snapshot` in the tests ask `git status`, which git 2.45.1 and later refuse while a gitlink the index holds is a symbolic link on disk. `snapshot` already took `status=False`. `refused_untouched` now takes it too, and the link test passes it, so the full snapshot is kept except `git status`. Mutant M27 fails the link test on git 2.47.1.

Review: rv865, lane 5's independent review, and rv865b's re-checks of each fix round: round 3 and its restack onto #828's squash READY at `6ef6e4c1` (issuecomment-6248254729), and round 4 (`231c404c`, the C5 pin rv865b asked for, tests only) READY with no findings (issuecomment-6248465765). CI (Brett Heap's ruling 27): H0 38e0d503 (fix round 2, the last head before the restack that CI ran in full: GitHub started no tests run on round 3's 4e92867a) passed tests, tests-git-bash and SonarCloud; the restack 6ef6e4c1 merges #828's final head, then #828's squash 9b0a703 (the branch's own side for #828's files), then main; the head 231c404c, 1 commit on the restack, has tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 629aec2b over main c9ca9a45: the family_add_member_variant, family_add_move_back_sections, family_add_spelling_leftovers, family_members_case_variants, repo_hygiene, agents_md_codes, cli_reference sets, 356 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 #840

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