Skip to content

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) - #865

Merged
brettheap merged 32 commits into
mainfrom
fix/840-spelling-leftovers
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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.

Fix round 4 (rv865b's third re-check, READY at 6ef6e4c1, one NIT)

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.

Fix round 3 (rv865b's second re-check, NOT READY at 38e0d503)

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.

Fix round 2 (rv865b's re-review, NOT READY at f7072d08)

The fifth commit, 38e0d503, answers rv865b's findings:

Fix round 1 (rv865's review, NOT READY at 4268f804)

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.

Line 1: a section that names a variant, with the gitlink at the row's path

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.

Line 2: git mv of a directory moves an untracked checkout inside it

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.

Line 3: a variant of a row's path under members/ itself

Measured at ce0f2a5f, beside the row members/IRRS. In each of these states add exits 0, so a person meets nothing:

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.

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.

Line 4: an unmerged .gitmodules the work tree also lacks

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.

Line 5: a deletion of .gitmodules already staged

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.

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

Line 6: Members a link to a directory outside the holder

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.

Line 7 (rv828's SHOULD 3): an unmerged file in the way of the move back

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.

The coordinator's added state: a link at a variant the index holds

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

Tests

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.

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

Runs

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.

Mutants

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

Merge checks

git merge-tree --write-tree of 4e92867a, after a fetch of main at 08:38Z on 2026-10-18:

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.

Docs

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.

Not here

Checklist candidates

Closes #840

Summary by Sourcery

Ensure family.py add names all known spelling-related leftovers first and emits remedies that can be applied cleanly without failed commits, broken checkouts, or invalid submodule metadata.

Bug Fixes:

  • Make family.py add identify and remediate additional leftover path, link, checkout, .gitmodules, and unmerged-entry states before printing commands that would fail or leave members inconsistent.

Enhancements:

  • Extend spelling detection and remediation ordering for case variants under members/, astray .gitmodules sections, symlinks, untracked nested checkouts, staged .gitmodules deletions, and unmerged index entries.
  • Improve remediation guidance so commands restore, move, remove, or update affected paths in a safe order and correctly handle staged versus committed changes.

Tests:

  • Add comprehensive coverage for the newly handled spelling-leftover states, including typed remediation sequences, cross-platform case-folding behavior, symlinks, unmerged entries, and Git versions that reject symlinked gitlinks.
  • Update existing move-back and case-variant test helpers to model unmerged entries and snapshots that can omit git status.

Brett Heap and others added 20 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
… 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>
Clean as text: tree 7550024, no file conflicts.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s off disk is taken out with `git rm --cached --sparse`, a clone or link found on disk keeps its `mv`, the closing `mv` says Git Bash, and the set-aside and move-back parts leave `_spellings_refusal`, so the merge with #684 types clean (#761)

rv828 (lane 2) on 6cf02ed, after the merge of main bad05d2:

- MUST 1: since #684 (fa04d2c) `_checked_mount_path` asks
  `_members_file_left_in_head` before `_members_spellings`, so a
  `members` file HEAD and the index hold, off disk, is #684's
  `member-path-unaddable` and never reaches the set-aside. Where a sparse
  checkout marks that file or its rules leave it out, #684's printed `git
  rm --cached -- members` exited 1 (`exist outside of your sparse-checkout
  definition`), measured on the merge. It now takes `--sparse`, and says
  git 2.34, as `_set_aside` names such an entry off disk. The (h) tests
  for a file HEAD holds now type #684's removal and its commit, then the
  rename back; the set-aside keeps the cases that still reach it: a link,
  and a file staged new.
- SHOULD 2: an untracked clone of its own at `Members/X`, and a link
  there to a checkout, each keep #574's `mv`, typed.
- SHOULD 4: the set-aside part, the move back and its closing sentences
  move out of `_spellings_refusal` (`_set_aside_part`,
  `_move_back_parts`, `_move_back_line`, `_closing_sentences`), and the
  row bookkeeping, the sparse questions and the set-aside call out of
  `_members_spellings` (`_rows_held`, `_SparseAsked`, `_in_the_way`). The
  text is unchanged.
- NIT 5: a file at `members` with no gitlink moved back, and a gitlink at
  `members`, are never set aside.
- NIT 6: the three staged set-aside tests are one table.
- NIT 7: the guarded `mv` is to be typed "in a POSIX shell (Git Bash on
  Windows)", as `family.py`'s other POSIX-only line says.

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

Stacked on #828 (fix/761-add-move-back-gitmodules, ce0f2a5). Each state
was typed as printed at ce0f2a5 first, on git 2.43 (and the link states on
2.47.1), and each printed command is typed as printed in the new tests.

- A section of .gitmodules that gives a member another spelling of its
  row's path, the gitlink at that path: add went through and committed
  it, and the gate refused it in every mode. It is named now
  (family-members-dir-variant, IN_SECTION), with the gate's own
  `git config -f .gitmodules submodule.<name>.path <row path>`, the commit
  where HEAD's section moved, and the closing update for a fresh clone.
- A checkout git does not track inside a directory #574's `git mv` (or an
  `mv`) moves: the move left its .git file pointing nowhere (measured). It
  is named first, to be deleted by hand (CHECKOUT_FIRST).
- A case variant of a row's path under members/ itself (members/irrs):
  nothing named it and add went through. It is named, and the member's own
  gitlink there is moved back as #646 moves one.
- An unmerged .gitmodules the work tree lacks: the rename back's commit
  failed. gitmodules-missing names it first, as for remove.
- A deletion of .gitmodules already staged: the printed commit took every
  member's section out of HEAD. HEAD's copy is put back first; with an
  untracked copy on disk, gitmodules-unstaged asks which to keep first.
- Members a link outside the holder: the move back's `git rm --cached`
  failed through it (exit 128). The link is moved out of the way first,
  and nothing beyond a link counts as on disk.
- An unmerged file at or under a row's path, or at members (rv828's
  SHOULD 3): the gitlink kept #574's `git mv`, which took it from its row.
  It is taken out of the index, every stage, and moved out of the way
  where it is on disk; member-path-unaddable says the same for an unmerged
  members file on disk, whose `git mv` failed (conflicted).
- A link at a variant the index holds as the member's gitlink: already
  refused first at ce0f2a5 on git 2.43 and 2.47.1; pinned by a test.

New tests in tests/test_family_add_spelling_leftovers.py; set_aside in
tests/test_family_add_move_back_sections.py names the unmerged part.

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

On git 2.45.1 and later `git status` refuses while a gitlink the index
holds is a symbolic link on disk, so the link test could not use
`refused_untouched`, whose `snapshot` asks `git status`. It had its own
helper, `refused_beside_a_link`, which checked only the index bytes and
`assert_left_nothing` and dropped snapshot's HEAD, config, .gitmodules,
family.yaml and listing keys.

`snapshot` already takes `status=False`. `refused_untouched` now takes a
keyword `status=True` and passes it to both snapshots, and the link test
calls it with `status=False`, so every other key of the snapshot is kept.
`refused_beside_a_link` and its now-unused `index_bytes` and `refusal`
imports are gone. Tests only: scripts/family.py is not touched.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-17T22:28Z) @codex review

@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-17T22:32:01.231744Z 4268f80 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.

@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens family.py add against eight classes of path, checkout, link, .gitmodules, and unmerged-entry leftovers by detecting them in a safe order and printing executable cleanup/remediation commands, with extensive tests that type every remedy and verify the resulting repository state.

Sequence diagram for .gitmodules-first add remediation

sequenceDiagram
    participant User
    participant Add as family.py add
    participant Gitmodules as .gitmodules
    participant Git as Git index and HEAD
    User->>Add: add
    Add->>Gitmodules: _gitmodules_first
    alt unmerged .gitmodules
        Add-->>User: gitmodules-unstaged or gitmodules-missing
        User->>Git: git checkout --merge -- .gitmodules
        User->>Git: Resolve conflicts and git add .gitmodules
    else staged deletion
        Add-->>User: gitmodules-deleted
        User->>Git: git checkout HEAD -- .gitmodules
    else unstaged modification
        Add-->>User: gitmodules-unstaged
        User->>Git: git add -- .gitmodules
    else safe to continue
        Add->>Git: _spellings_refusal
        Add-->>User: Executable spelling remediation
    end
Loading

Sequence diagram for path leftovers before move-back

sequenceDiagram
    participant User
    participant Add as family.py add
    participant Disk
    participant Index
    participant Git
    User->>Add: add
    Add->>Index: Detect links, untracked checkouts, and unmerged entries
    alt indexed link contains entries
        Add-->>User: Move link out of the way first
        User->>Disk: mv link
    else untracked checkout inside moved directory
        Add-->>User: Inspect checkout with git -C checkout status
        User->>Disk: Delete checkout by hand
    else unmerged entry blocks move back
        Add-->>User: git rm -r --cached unmerged aside
        User->>Git: Remove every index stage
        Add-->>User: mv unmerged aside
        User->>Disk: mv entry
    end
    Add-->>User: Continue with git mv, mv, or move-back remedies
Loading

Flow diagram for safe family add refusal handling

flowchart TD
    A[family.py add detects spelling or leftover] --> B{.gitmodules needs priority handling?}
    B -->|unmerged or missing| C[Restore .gitmodules with git checkout --merge -- .gitmodules]
    B -->|staged deletion| D[Restore HEAD copy with git checkout HEAD -- .gitmodules]
    B -->|unstaged change| E[Stage .gitmodules with git add -- .gitmodules]
    B -->|no| F{Link or checkout blocks a move?}
    C --> G[Resolve conflicts and git add .gitmodules]
    D --> F
    E --> F
    G --> F
    F -->|indexed link| H[Move link out of the way with mv]
    F -->|untracked checkout inside moved directory| I[Inspect with git -C checkout status and delete by hand]
    F -->|no| J{Unmerged entry blocks move back?}
    H --> J
    I --> J
    J -->|yes| K[Remove all index stages with git rm -r --cached]
    K --> L{Entry remains on disk?}
    L -->|yes| M[Move it aside with mv]
    L -->|no| N[Continue]
    M --> N
    J -->|no| N
    N --> O[Run spelling and move-back remedies]
    O --> P[Run git submodule update --init --recursive]
    P --> Q[Re-run add]
Loading

File-Level Changes

Change Details Files
Expand add spelling/refusal analysis and remediation so every tested leftover state is identified before any unsafe Git command is printed.
  • Detect row-path case variants under members/, astray .gitmodules sections, staged .gitmodules deletion, links containing indexed entries, untracked checkouts inside moved directories, and unmerged entries.
  • Order remedies so links, Git-module state, untracked checkouts, index cleanup, section correction, and move-back operations do not invalidate one another.
  • Add targeted remediation text, commit/update handling, and support for unmerged .gitmodules and mount entries.
scripts/family.py
Add comprehensive behavioral coverage for the new refusal states and verify that printed remedies can be executed successfully.
  • Add 35 tests covering section spelling mismatches, case variants, moved checkouts, staged or unmerged .gitmodules, links, and unmerged files.
  • Type printed commands, then validate member placement, gate behavior, and subsequent add success across disk and Git-version-sensitive cases.
  • Add mutation-oriented and control coverage for tracked checkouts and already-supported link behavior.
tests/test_family_add_spelling_leftovers.py
Update existing move-back and case-variant test helpers to model unmerged entries and Git versions that reject status on symlinked gitlinks.
  • Extend move-back setup and expected remedy construction for unmerged index stages and on-disk moves.
  • Allow refusal snapshots to skip git status while retaining all other state checks.
tests/test_family_add_move_back_sections.py
tests/test_family_members_case_variants.py

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 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: 4268f804ee

ℹ️ 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 +4309 to +4310
if name in unmerged:
return ASIDE_UNMERGED_MV if on else ASIDE_UNMERGED

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 Preserve sparse handling for unmerged entries

When an unmerged entry blocking a move is outside the sparse-checkout definition, this branch discards its left_out/marked state and _set_aside_part consequently prints plain git rm -r --cached. Git refuses that command with “matched paths that exist outside of your sparse-checkout definition”; git rm -h defines --sparse as allowing updates to those entries. Thus the advertised remediation cannot be executed in this supported sparse-checkout case; retain the sparse state and print git rm ... --sparse for the unmerged entry.

Useful? React with 👍 / 👎.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-18T00:08:36Z) Independent review of #865 (#840) at 4268f804, for lane openRepoShape-5: NOT READY

$Q = /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad. All my logs and probe results are under $Q/v118/rv865/. On git 2.43 and 2.47.1, every one of #840's six lines and rv828's SHOULD 3 now types clean and the gate passes. But the new line-1 code (_sections_astray) takes some states the base left clonable and leaves them unclonable.

Findings, most severe first

MUST 1: scripts/family.py:4335-4346 (_sections_astray, called at :4043) points back sections that belong to other submodules. Typed as printed, the remedy breaks every recursive clone of the holder, and add then goes through.

  • The fault. It points back every section whose path is a case variant of a row's path, wherever the row's gitlink is at its path. It never asks:
    • whether another section already gives the row's path exactly;
    • whether the section is the one for a variant gitlink in the index, which the same refusal renames or takes out.
  • What the gate says. For that state the gate's finding is gitmodules-path-unnamed, and its own remedy is "Take the section out by hand … git config -f .gitmodules --unset-all submodule.Members/IRRS.path …". It does not point the section back. Line 1's Direction asked for the gate's own lines.
  • Measured at head, git 2.43 and 2.47.1 (probe-head-e34.jsonl, probe-head-e4b.jsonl, probe-head-e4c.jsonl, probe-head-247.jsonl). The state: git submodule add <IRSS.git> Members/IRRS, committed, checkout kept. add printed git mv -- Members/IRRS <new name>, a commit, then git config -f .gitmodules submodule.Members/IRRS.path members/IRRS. Typed as printed:
    • git mv moves that section to renamed-1, and the point-back then sends it back to members/IRRS with IRSS's url. The gitlink renamed-1 is left with no section.
    • add then exits 0.
    • The gate exits 1 in every mode: FINDING gitmodules-path-twice: .gitmodules gives the path members/IRRS to 2 submodules, members/IRRS (url …/IRRS.git), Members/IRRS (url …/IRSS.git).
    • A recursive clone of the pushed branch exits 128: fatal: Fetched in submodule path 'members/IRRS', but it did not contain 5def5224… Direct fetching of that commit failed.
  • Three more states end the same way, clone exit 128, on both gits:
    • the same submodule added at members/irrs. That is line 3's "a second gitlink" state made the way git submodule add makes it; the PR's test plants that gitlink without a section;
    • Members/IRRS with its checkout deleted, where the remedy is git rm -r --cached and then the same point-back;
    • a leftover section alone, IRSS's url at Members/IRRS. This is what git rm --cached of such a submodule leaves behind.
  • At base ce0f2a5f, the same four states (probe-base-243.jsonl, probe-base-247.jsonl): no point-back is printed, and the recursive clone exits 0 with members/IRRS from IRRS.git. So this is a regression.
  • Fix direction: point back only where no section gives the row's path exactly (the gate's member-gitmodules-absent case), and never a section whose path is a gitlink in the index. Where a stray section sits beside the member's own, print the gate's --unset-all lines or leave it to the gate. Add these states as tests.

SHOULD 1: scripts/family.py:3636-3642 (_disk_spellings, which now passes rows to _spelled_target) refuses the member's own checkout on a disk that folds case.

  • The fault. macOS and Windows list a directory in the spelling it was made with. So a checkout stored as members/irrs for the row members/IRRS is named "on disk and untracked" every time add runs.
  • How a holder gets there. Line 3's own move back does it. Its closing guard [ members/irrs -ef members/IRRS ] is true on such a disk, so nothing moves, and the remedy's own text says "a directory left at members/irrs is the row's own where the disk folds case" (:5011).
  • Measured with disk_as(folding=True), in process, on both gits (test_e1b…). The next add says: REFUSED family-members-dir-variant … on disk and untracked: members/irrs (a directory) / "Delete by hand each checkout on disk and untracked, members/irrs …". That is the member's only checkout.
  • Base goes through in the same state: code 0, E1 folding=True.
  • The test does not cover this. test_every_disk_gives_the_same_answer (tests/test_family_add_spelling_leftovers.py:442-447) says each state reads "never the disk", but this branch does read the disk.
  • Fix direction: skip a row-variant entry that samefiles the row's path, as _members_dir_on_disk does.

NIT 1: mutants that survive (detail under "Checked and true"). The code is right in each case; the tests just don't pin it.

NIT 2: the body.

  • It says "31 mutants … All 31 are caught". The table holds M1–M27 plus M11b, M13b and M15b, which is 30. The writer's mut/summary.txt has 29 lines, plus the verifier's M27.
  • "Merge checks" still names main 190133d5. Main is 2e476a77 now, and the merge is clean.

NIT 3: SonarCloud may report S3776 on scripts/family.py:8166 as new. _checked_gitmodules_as_staged's def line changed. By my hand count its complexity went from 34 to 35 (the added and not spelled, :8267).

Checked and true

Six lines and SHOULD 3 at head. Probe results are probe-head-243.jsonl and probe-head-247.jsonl; the new tests confirm them.

  • Lines 1, 2, 3, 5 and 6 and SHOULD 3: the printed remedy is typed with typed_as_printed, the gate passes in all three modes, and the same add goes through.
  • Line 4 and line 5's untracked copy: gitmodules-missing and gitmodules-unstaged come first, and the tests type what follows.
  • An unmerged members file on disk: the new git rm --cached and mv text is printed.

At base, each state fails the way the issue says, on both gits:

  • lines 1 and 3: add exits 0, and the gate exits 1 or 2 where the issue says;
  • line 4 and SHOULD 3: "Committing is not possible because you have unmerged files";
  • line 5: HEAD loses .gitmodules;
  • line 6: on 2.43 fatal: not a git repository: Members/IRRS/../../.git/modules/members/IRRS; on 2.47.1 expected 'Members' in submodule path 'Members/IRRS' not to be a symbolic link;
  • an unmerged members on disk: git mv gives fatal: conflicted, source=members;
  • line 2: the moved checkout's git -C gives not a git repository.

The optional seventh state holds on 2.47.1.

  • git status gives rc 128: expected submodule path 'Members/IRRS' not to be a symbolic link.
  • add gives its spelling refusal first, at base and at head.
  • It takes three rounds, then the gate passes.
  • The test passes at base on both gits.

Fails on base. Head's three test files copied onto ce0f2a5f give 33 failed, 2 passed on 2.43 (b243.log) and on 2.47.1 (b247.log). The two passes are test_a_checkout_git_tracks_inside_a_directory_moved_is_not_named and test_a_link_at_a_variant_the_index_holds_is_refused_first. The 33 failures are the right assertions, such as add exiting 0 ("checking InkRouter/IRQS before mounting it").

Mutants. I wrote 24 of my own, plus R19x to recheck R19 on other files.

The helper change.

  • Only one call passes status=False: the link test's first refused_untouched. Its second call, and every other caller in 5 files, keeps the default True.
  • The M27 recheck (m27-247.log, m27-243.log): with status ignored, the link test fails on 2.47.1 with "expected submodule path 'Members/IRRS' not to be a symbolic link", and passes on 2.43.

Runs.

  • h243.log: the 8 test_family_add_* / test_family_members_* files plus the hygiene trio, git 2.43, 466 passed, rc 0.
  • h247.log: the 3 touched test files, git 2.47.1, 136 passed, rc 0.
  • merged243.log: the same 11 files on head merged with main 2e476a77 (local probe commit b958fb18), 467 passed, rc 0.

Merges. merge-tree --write-tree of 4268f804 is clean (rc 0) against:

Sonar.

  • Duplication: no duplicated window of 8 or 10 lines touches new lines across scripts/family.py and the tests/test_family*.py files. There is one 6-line window, at tests/test_family_add_spelling_leftovers.py:523-528 and :858-863.
  • Complexity by hand, new functions all 15 or under: _members_is_a_file 12, _spelled_target 13, _set_aside_part 11, _disk_found 9, _gitmodules_first 7, the rest 7 or under.
  • The functions over 15 (_members_spellings, _spellings_refusal, _checked_mount_path, _disk_spellings, _sections_restored) were already over 15 at base. They kept their def lines and their complexity went down or stayed the same.

Could not verify

  • Real macOS and Windows disks. SHOULD 1 rests on in-process emulation (disk_as), plus the known fact that those disks list a name in the spelling they keep.
  • CI's git 2.55.
  • SonarCloud's own analysis, since the SonarQube MCP server failed to connect.

Scratch left (nothing deleted)

  • Clones: /workspace/projects/openRepoShape-worktrees/_lane5-scratch/ rv865-head, rv865-base, rv865-mut, rv865-mut2, rv865-merged.
    • rv865-head and rv865-base each hold an untracked tests/test_rv865_probe.py.
    • rv865-base also holds head's 3 test files copied in.
    • rv865-merged is detached at the local commit b958fb18.
  • Notes and logs: $Q/v118/rv865/.
  • Basetemps: $Q/v110/bt/ rv865-b243, b247, h243, h247, m243, m27a, m27b, pb243, pb247, pe2d, pe34, pe4b, pe4c, pg, ph243, ph247, r2, r4, r6a, r6b, r7, r8, r9, r10b, r11a, r12a, r13a, r16a, r19a, r19xa, r20b, r23b, r25b, r25bb, r26b, r34a, r38b, r44a, r47a.
  • The shared checkout is untouched (on main, ?? handoffs). No push, no post, no real gh, and no process of mine is still running.

Lane 5's plan

Lane: openreposhape-5 (openRepoShape-5)

…e gate's `--unset-all` lines and only the member's own is pointed back, a checkout a folding disk lists in another case is the row's own, and a point-back after an earlier commit is committed, so the typed remedy no longer breaks the recursive clone (#840)

Fix round 1 of #865, after rv865's review (NOT READY).

MUST 1. `_sections_astray` pointed back every section giving a case
variant of a row's path, whatever submodule it belonged to. After `git
submodule add <IRSS> Members/IRRS` (kept, at members/irrs, or with its
checkout deleted) or the section `git rm --cached` leaves behind, the
remedy typed as printed gave members/IRRS twice, the gate refused
`gitmodules-path-twice`, and a recursive clone fetched the member from
IRSS's url and failed (exit 128). Now `_row_sections_astray` follows the
gate: where a section gives the row's path itself, each variant section is
a second one beside it (the gate's STRAY) and is taken out, every key it
holds, with the gate's own `--unset-all` lines (`TAKE_OUT`, found
`BESIDE_SECTION`), after the index's own remedy for a gitlink at its path.
Where none does (the gate's `member-gitmodules-absent`), only the section
named after the row's path is pointed back, never one whose path is a
gitlink the index holds, and any other variant section is then taken out.
`Spelled` gains `keys`, the keys a section holds.

A point-back or take-out after a commit printed before it (a pending
change, or the index's rename or removal) is committed too: that commit
takes a staged `.gitmodules` into HEAD.

SHOULD 1. `_disk_spellings` asks `_variant_in_members`, which lets through
an entry of `members/` that the disk takes for the row's own path
(`samefile`, as `_members_dir_on_disk` asks), so the member's checkout a
folding disk lists as `members/irrs` after line 3's move back is no longer
refused as untracked.

NIT 3. `_checked_gitmodules_as_staged` has base's `def` line again: the
missing-file refusal moved to `_gitmodules_missing`, which
`_gitmodules_first` raises for an unmerged copy off disk before a spelling
remedy.

Tests (tests/test_family_add_spelling_leftovers.py): rv865's four states
and the point-back beside a take-out, each typed as printed, then a
recursive clone of the pushed branch, the gate in every mode and the same
add; the section named after the row beside one giving its path; a staged
point-back after a pending change or a rename; the checkout a folding disk
lists in another case (`disk_as`); an unreadable `.gitmodules` (R13); a
clone of its own in a directory moved (R7, R8, R9, R10); two more unmerged
states (R23, R42). tests/test_family_members_case_variants.py: the one
literal `Spelled` tuple gains the `keys` field.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ointed back beside, with no section giving the row's path, is left to the gate, so mutant K3a, which took it out, is caught (#840)

The state: the member's own section gone, and a section `stale` giving
`Members/IRRS` with the member's url. It may be the member's only
registration, so `add` neither points it back nor takes it out, and the
gate names it with no command (`WRONG_CASE`). `_members_spellings` finds
nothing there now; at `4268f804` it pointed the section back.

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

Copy link
Copy Markdown
Contributor Author

Fix round 2 of #865 (#840): f7072d08..38e0d503.

One commit on f7072d08, 38e0d503: the fix, its tests and pins. There is no merge of #828 or main: the restack onto #828's squash comes later. $Q below is lane 5's scratchpad, /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad.

Files: scripts/family.py, tests/test_family_add_spelling_leftovers.py, tests/test_family_add_member_variant.py (#839's guard, a shell for typing, and the parts split outside backticks), and tests/test_family_add_move_back_sections.py (one docstring). In all, +587/-149.

1. MUST 1 (new)

the take-out runs after git mv of the same gitlink. It strips the section git mv carried along, so renamed-1 is a gitlink no section names, and a plain clone's git submodule update --init --recursive exits 128.

Done. A new helper, _second_gitlink(name, mode, rows, indexed), picks out a gitlink in the index at a case variant of a row's path where the index records a gitlink at that path itself: the gate's STRAY, a second path to the member's directory.

  • In _members_spellings' index loop, such an entry gets a new remedy, UNSTAGE_SECOND, wherever it would have been renamed (RENAME, or RENAME_SPARSE).
  • UNSTAGE_SECOND prints git rm -r --cached -- <variant>, the gate's own line for a second path, through _second_path_part.
  • It takes --sparse, and names the git that needs, where the rules leave the entry out or its skip-worktree bit is set. Measured: plain git rm --cached refuses a marked entry even with no sparse checkout ("outside of your sparse-checkout definition", exit 1, on git 2.43 and 2.47.1).
  • git rm --cached leaves the section, so the take-out after it finds it.
  • The checkout left on disk is the next run's CHECKOUT: "delete by hand each checkout on disk and untracked".
  • With nothing on disk, the entry keeps plain UNSTAGE, whose command is the same.
  • _removed(found, by) now counts both removals for the commit. It replaces the old inline condition, so _spellings_refusal loses 2 of cognitive complexity.

The take-out's reason no longer cites git mv. It now says that "git rm --cached of a gitlink at that path leaves it".

Tests. typed_through types the remedy as printed. Where the next run names a checkout, it asserts that text, with nothing to type, and deletes the checkout by hand. Then it checks:

  • registered;
  • a recursive clone, with IRRS's url and commit;
  • a plain clone through submodule_update_through_git(... "--init", "--recursive"), which must exit 0, holding the same gitlinks as the holder, every one under members/ and checked out;
  • git submodule status, through the git helper, which asserts exit 0, in the holder and in both clones;
  • gate_then_add: the gate in all three modes, and the same add.

Every state of test_a_section_beside_the_members_own_is_taken_out now goes through it:

  • (a), at Members/IRRS: git rm -r --cached -- Members/IRRS, commit, --unset-all of path and url, git add, commit. Round 2 names the checkout Members/IRRS.
  • (b), at members/irrs: the same.
  • (c), (d), and the point-back beside a take-out are unchanged, with the new checks.

2. K2

drop the gitlink clause, together with the change above, as rv865b rules. Add its four K2 states as tests

Done. _row_sections_astray loses the clause, and its indexed parameter with it: back = row not in paths and any(name == row for name, _, _ in astray).

test_a_second_gitlink_beside_the_members_own_is_taken_out_of_the_index[committed|reset|checkout deleted|foreign] each go through typed_through:

  • committed (git mv members/IRRS Members/IRRS committed, then update-index of members/IRRS committed): git rm -r --cached -- Members/IRRS, commit, the point-back, git add, commit, then the update. Round 2 names the checkout.
  • reset (git mv staged, then git reset -- members/IRRS): the same lines with no commit, since nothing HEAD holds changes. Round 2 names the checkout.
  • checkout deleted: UNSTAGE as before, the point-back, commits and the update, in one round. At f7072d08 it took two.
  • foreign (the member's section astray, committed, plus git submodule add <IRSS> Members/IRRS): the removal, the point-back, the take-out of IRSS's section, each with a commit. Round 2 names the checkout.

Each ends with plain and recursive clones that work, and the gate passing in all three modes.

3. NIT 1

in _variant_in_members, ask not os.path.islink(...) before samefile, as _members_dir_on_disk does. Add a test with a link members/irrs -> IRRS.

Done. _variant_in_members asks if os.path.islink(root / name / entry): return True before the samefile loop: a link is always named.

test_a_link_in_members_at_a_rows_variant_is_named covers it:

  • it refuses with on disk and untracked: members/irrs (a symlink);
  • typed, mv -- members/irrs ../moved-1 moves the link, whose readlink is IRRS;
  • registered holds, and gate_then_add passes.

4. NIT 2

pin X1 (a take-out with nothing to commit), X2 (removed in earlier) and X6 (a section name that needs quoting).

Done.

  • X1 and X6: BESIDE state "a staged one whose name needs quoting". A section named a stale one, giving members/irrs, is staged and never committed. Typed, it gives git config -f .gitmodules --unset-all 'submodule.a stale one.path' and the same for .url, then git add -- .gitmodules, with no commit. The tests' unset() quotes with shlex.quote.
  • X2: COMMITTED_BEFORE state "a removal". Members/README is planted in the index and HEAD with nothing on disk: git rm -r --cached -- Members/README and its commit come first, then the staged point-back with its commit.

All three pass on f7072d08 too, since that code was already right; they pin the mutants.

5. NIT 3

_committed should count the point-back's own commit as an earlier commit for the take-out. Fix it, or say why not.

Fixed. In _point_back_part, committed = _committed(found, POINT_BACK, earlier), then earlier = earlier or bool(committed) before the take-out.

The test is BESIDE state "pointed back, and a staged one taken out": rv865b's P4, the member's section astray and committed, with stale at members/irrs staged only. The take-out now commits after the point-back's commit, and registered checks HEAD's .gitmodules against the holder's. At f7072d08, HEAD kept stale.

6. NIT 4

take #839's link form in _closing_sentences [...] Bring test_a_link_at_a_variant_the_index_holds_is_refused_first down to two rounds. Type it under dash and bash.

Done. _closing_sentences now prints [ ! -L V ] && { [ ! -e V ] || [ V -ef R ] || [ -e V/.git ]; } || mv -- V ../<V>, each path shell_quoted. It adds #839's words: "..., a checkout, and moves a symbolic link there wherever it points, the link and never what it points at". _spellings_refusal's docstring and the comment say so.

In the tests (test_family_add_member_variant.py):

  • guarded() and the closing words in moved_back take the same form.
  • typed_in_sh and typed_as_printed take a shell, defaulting to sh.
  • remedy_parts splits the remedy at ; outside backticks only. The guard's ]; } would otherwise split one command in two.

The link test is parametrized over dash and bash, and skips where the shell is missing. Round 1 types the move back and the guard, which moves the link to ../Members-IRRS; the test asserts the link is gone and its readlink. Round 2 is gate_then_add's add, which goes through.

7. NIT 5

the body. Add the caveat to line 1's claim, and name the main of your merge check (now 0905eef5 or later).

Done in $Q/v118/w865f/body2.md:

body-guard.py passes it.

Each new test fails on f7072d08

The 17 new or changed cases are 7 BESIDE states, [a removal], 4 K2 states, 2 sparse states, the link in members/, and the link at the variant under dash and bash. Copied with the round's test files onto a clone at f7072d08 (/workspace/projects/openRepoShape-worktrees/_lane5-scratch/w865f-r2old):

  • 16 failed and 1 passed, on git 2.43 and on 2.47.1 ($Q/v118/w865f/r2/old243.log, $Q/v118/w865f/r2/old247.log).
  • The one that passes is [a removal], X2's pin, which pins code that was already right.

A copy with no check on the text ($Q/v118/w865f/r2/test_w865f_r2_behaviour.py) types each state as printed, round after round, then makes the same checks:

  • At f7072d08: 11 of 16 fail ($Q/v118/w865f/r2/bh-old243.log). The failures:
    • the plain clone's update fails (submodule_update_through_git) for (a), (b) and both sparse states;
    • HEAD's .gitmodules is not the holder's for K2 committed, reset and foreign, and for the staged take-out after the point-back;
    • "the link is left" for members/irrs;
    • "2 rounds typed before add goes through" for the link at the variant, under dash and bash.
  • The 5 that pass there are (c), (d), the point-back beside a committed take-out, the quoting state (X1, X6), and K2 with its checkout deleted, which took 2 rounds and ended right.
  • At 38e0d503: 16 passed ($Q/v118/w865f/r2/bh-head243.log).

Mutants

19 mutants of the round's new hunks in scripts/family.py ($Q/v118/w865f/mutate2.py; results in $Q/v118/w865f/mut2/summary2.txt, logs in $Q/v118/w865f/mut2/). Each was run on 38e0d503 in /workspace/projects/openRepoShape-worktrees/_lane5-scratch/w865f-r2mut, -x on the tests named, and the clone was clean afterwards. 18 are caught; N2 survives.

rv865b's probe at 38e0d503

The probe was copied into /workspace/projects/openRepoShape-worktrees/_lane5-scratch/w865f-r2probe/tests/ and run with RV865B_OUT on git 2.43: 29 passed, rc 0 ($Q/v118/w865f/r2/probe-head-243.log, records in $Q/v118/w865f/r2/probe-head-243.jsonl, summarized by $Q/v118/w865f/r2/probe_sum.py). The probe never deletes a checkout by hand, so states that leave one show it named in rounds 2 and 3. Every state ends with the gate exiting 0 in all three modes.

  • P1 (a) and (b) as printed, and P2's two states: round 1 takes the gitlink out. HEAD's gitlinks are then members/IRRS and members/IRSS alone. git submodule status exits 0 in the holder (it gave 128 at f7072d08) and in the recursive clone, whose members/IRRS is IRRS.git at its commit.
  • P3, K2: committed, reset and foreign end the same way. "Deleted" ends in one round, and add goes through.
  • P4: one round, then add goes through. P4b: head_has_stale: false and index_has_stale: false.
  • P5: a link members/irrs to IRRS gives code 2, where f7072d08 gave 0. A link to IRSS gives 2 as well.
  • P6: all four layouts take two rounds, and link_left is false. The probe's "839 form" regex matches only the old guard, so it typed nothing more.
  • L7: all six states are clean in one round.
  • P7 (a), (b) and (c) as printed: the plain update exits 0, with every member checked out.
  • P7 "(a) rename, then the gate's lines": 128, as rv865b found. That is the validator's gap after a hand rename; this PR no longer prints that rename.

Runs (run9s.sh, SHARDS=3, the same 15 files as round 1)

Log Git Head Result
$Q/v118/w865f/r2/final243.log (shards final243-s1.log to -s3.log) 2.43 38e0d503 714 passed, rc 0 (202 + 239 + 273)
$Q/v118/w865f/r2/final247.log (shards final247-s1.log to -s3.log) 2.47.1 38e0d503 714 passed, rc 0 (202 + 239 + 273)

The 15 files are test_family_add_spelling_leftovers.py, test_family_add_move_back_sections.py, test_family_members_case_variants.py, test_family_add_excludes.py, test_family_add_member_id.py, test_family_add_member_variant.py, test_family_add_refusals.py, test_family_add_spellings_remedies.py, test_repo_hygiene.py, test_agents_md_codes.py, test_cli_reference.py, test_family.py, test_family_remove_gitmodules.py, test_family_head_lost_gitmodules.py and test_family_holder_states.py. Round 1 gave 703. The 11 more are the leftovers file's new cases, 62 now against 51.

For the 2.47.1 run, $Q/v115/w764/git247/bin was first on PATH. With run9.sh's PATH laid over it, command -v git gives that git, git --version says 2.47.1, and gh is the stub. Both runs had a clean tree (dirty=0), and run9s removed its shard worktrees on rc 0.

During the work, before the commit, $Q/v110/run9.sh runs on 2.43 gave:

  • $Q/v118/w865f/r2/a.log: 38 passed, the new and changed leftovers cases;
  • $Q/v118/w865f/r2/b.log: 98 passed, the leftovers and member_variant files;
  • $Q/v118/w865f/r2/c.log: 145 passed, move_back_sections, case_variants and spellings_remedies;
  • $Q/v118/w865f/r2/d.log: 2 passed, the two sparse cases.

Merge checks

Main was fetched at 05:05Z. git merge-tree --write-tree of 38e0d503:

Main and #828 moved before the push, so both were fetched again at 05:39Z:

Push

One commit, made by pathspec with the identity Brett Heap <service@opensoft.dev> and the two trailers ($Q/v118/w865f/commit3-msg.txt). It is a fast-forward of f7072d08 (merge-base --is-ancestor: yes).

git push origin HEAD:refs/heads/fix/840-spelling-leftovers printed f7072d08..38e0d503 HEAD -> fix/840-spelling-leftovers and exited 0. git ls-remote origin refs/heads/fix/840-spelling-leftovers reads 38e0d503a258c42a05283945b627649d52ea8e4a. Nothing was posted.

Doubts

  • N2 survives, for the reason above.
  • Sonar's complexity was counted by hand only, since the SonarQube MCP server failed to connect in this session. _members_spellings goes up 3, and its def line is unchanged.
  • CI's git 2.55 and real macOS and Windows disks were not run.

@brettheap

Copy link
Copy Markdown
Contributor Author

Re-check of #865 (#840) fix round 2 at 38e0d503, for lane openRepoShape-5: NOT READY

$Q = /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad. My logs are under $Q/v118/rv865b/logs/ and my probes under $Q/v118/rv865b/probe/.

My first re-review's MUST 1 (the gitlink no section names) is closed for every state I typed, on both gits, and all five NITs are closed. But the new git rm -r --cached line has no -f. git refuses it for a gitlink that is staged and not committed, wherever its checkout holds work. A plain git submodule add that has not been committed yet is enough. The round's tests miss this because every checkout in them is clean.

Findings, most severe first

MUST 1: scripts/family.py:4856-4883 (_second_path_part, chosen at :4074-4078) prints git rm -r --cached -- <variant> with no -f, and git refuses it.

SHOULD 1: K2 committed. The round's own update makes git status hide the uncommitted files of the checkout that the next round says to delete.

NIT 1: _members_spellings goes up 4 by my count, not 3 as the body says. The new if adds 1, plus 1 for nesting, plus 1 for and, and out = … or … adds 1 more (41 to 45 by my estimator; the def line is unchanged, so it is the existing issue).

NIT 2: tests/test_family_add_member_variant.py:236. typed_as_printed's def line changed again, gaining shell. Its complexity is about 31 by my count, over 15 (33 on main). SonarCloud may report S3776 on it as new, the same class as rv865's NIT 3.

Rulings

N2 (dropping the check that the row's path holds a gitlink): acceptable as is. No test to add, and no new rule needed for this PR. I built both of w865f's states and typed them at the head and with N2 applied, on git 2.43 (logs/n2-head-243.jsonl, logs/n2-mut-243.jsonl):

  • The row's path unmerged, a file at stage 1: both versions' commit fails with Committing is not possible because you have unmerged files. The variant's remedy is irrelevant here.
  • A gitlink under members/IRRS: both versions end with add going through while the gate exits 1 (member-gitlink-unnamed). The head leaves renamed-1 with no section; N2 leaves nothing behind.
  • git rm --cached is never worse in either state, so dropping the check would be a harmless simplification.
  • The real gap is the row's path itself: R21's state, and a gitlink under the row's path, which add does not refuse. Both are older than this PR. That is a checklist candidate, not something to fix here.

NIT 4 (the guard): it is byte for byte #876's _guarded_move, and the clause after it matches #876's wording.

  • I generated both from their own code. They are equal for Members/IRRS, members/irrs, Members/IR RS and Members/-x.
  • They differ only for a path containing '. family.py's shell_quoted writes '\'' there, and shlex.quote writes '"'"'. No path the guard can be given contains one, since it is a case variant of a member's row path.

Checked and true

My re-review's MUST 1 is closed. I ran 8 states on 2.43 and on 2.47.1, with work in each checkout, typed round after round. Each named checkout was deleted by hand, after recording what its status showed. The states:

  • (a)
  • (b)
  • my P2
  • K2 foreign
  • (a) with skip-worktree set and its checkout kept, which gets git rm -r --cached --sparse and types clean
  • K2 deleted
  • K2 committed
  • K2 reset

Apart from K2 reset (refused, MUST 1) and K2 committed's hidden status (SHOULD 1):

  • The gate exits 0 in all three modes.
  • A plain clone plus git submodule update --init --recursive exits 0, with members/IRQS, members/IRRS and members/IRSS checked out.
  • A recursive clone gets members/IRRS from IRRS.git.
  • git submodule status exits 0.
  • The checkout's work commit is kept in its store.

Other closures.

  • K2's clause is dropped, and its four states are tests.
  • NIT 1 to NIT 5 are closed. My X1, X2 and X6 mutants are now caught (mut/summary.txt). The link tests pass under dash and bash.

Runs.

  • logs/r2-head-243.log: the leftovers, member_variant, move_back_sections and trio files at 38e0d503 on 2.43. 287 passed, rc 0.
  • logs/r2-head-247.log: the leftovers file on 2.47.1. 62 passed, rc 0.
  • w865f's final243.log and final247.log: 714 each (202 + 239 + 273), clean tree, at 38e0d503. This matches its account, as does its 18 of 19 mutants caught.

Restack. simrestack.sh 865 38e0d503 828 54b7699e printed:

  • #828: simulated update-branch 755aac81
  • predicted squash dc0c789e (tree 7aa6e875)
  • resolved ours-side: 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
  • #865: simulated restack 515140c8 (tree 32ea287a), clean

Tree 32ea287a equals w865f's merge-tree with main 3bf2d3c9. In a detached scratch worktree at 515140c8:

  • The leftovers file plus test_no_auto_maintenance.py: 120 passed, 2 skipped. The 2 skips came from my own GIT_CONFIG_GLOBAL=/dev/null.
  • test_no_auto_maintenance.py run again without it: 60 passed.

Sonar-style.

  • Duplication: no window of 6, 8 or 10 lines touches round 2's new lines.
  • No assert joins conditions with and; the hits are inside strings.
  • New functions, by my count: _second_gitlink 2, _second_path_part 3, _removed 2.
  • Changed functions: _point_back_part 3, _variant_in_members 9, _row_sections_astray 8. _spellings_refusal goes down 2, to 36, with its def line unchanged.

Could not verify

  • CI's git 2.55, and real macOS or Windows disks.
  • SonarCloud itself.
  • Whether an issue for the validator gap already exists on GitHub: lane 5's notes have none, and I may not run the real gh.

The issue line for check 4

The gap is real on main 3bf2d3c9 (git 2.43 and 2.47.1) and on #876's head 77f8a6c8 (git 2.43). #876 does not change it.

  • The gate's gitmodules-path-unnamed remedy takes out the section of a submodule no row names and leaves its gitlink, which no finding names. A plain clone's git submodule update --init --recursive then exits 128 before it checks out any member, and the holder's CI skips the member half of the gate.
    • Source: rv865b's re-checks of 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) #865.
    • Why the gate misses it: _member_gitlinks (templates/family-root/scripts/validate-family.py:1417, filtered at about :1466) lists only gitlinks under a spelling of members/. So _check_gitlinks_named (:1634) never asks about one anywhere else, and gitmodules-path-unnamed (:1849-1869) prints only the --unset-all lines.
    • Measured:
      • The state: git submodule add <IRSS> tools/x, committed. Or IRSS added at Members/IRRS and then git mv'd by hand to renamed-1.
      • The gate exits 1 in all three modes (gitmodules-path-unnamed) and prints git config -f .gitmodules --unset-all submodule.tools/x.path and .url.
      • After typing those lines, git add .gitmodules and a commit: the gate exits 0 in all three modes.
      • git submodule status exits 128: fatal: no submodule mapping found in .gitmodules for path 'tools/x'.
      • A plain clone's git submodule update --init --recursive exits 128 (fatal: No url found for submodule path 'tools/x' in .gitmodules), with no member checked out.
      • That is the holder's own CI path: templates/family-root/.github/workflows/validate.yml:46 checks out with submodules: false, and :236-238 runs the update. CI then sets members_available=false and runs --no-members alone.
      • bootstrap.py's init_stop (:387) already names this state.
      • A recursive clone exits 0, because it sets submodule.active.
    • Direction:
      • Where a section's path is a gitlink in the index that no row names, print git rm --cached -- <path> and a commit before the --unset-all lines, as member-gitlink-unnamed does for a second path.
      • Give a gitlink outside members/ that no section names a finding of its own.
    • Verify:
      • Both states above, typed as printed, then the gate in every mode.
      • A plain clone plus git submodule update --init --recursive exits 0 with every member checked out, and git submodule status exits 0.
      • A mutant that drops the git rm --cached, caught.
      • The file is pinned: one upstream-changed row per holder.
    • Overlaps: the family root's bootstrap.py and validate-family.py: the credential 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 edits the same validator and does not change this.

Scratch left

  • Removed with git worktree remove: rv865b-restack and rv865b-g876.
  • Clones kept under /workspace/projects/openRepoShape-worktrees/_lane5-scratch/. They are plain clones, and I may not rm -rf:
    • rv865b-head: at 38e0d503, with untracked probe files.
    • rv865b-mut: at 38e0d503, with untracked probe files; family.py restored and clean.
    • rv865b-run: at 38e0d503, clean.
    • rv865b-old: at 3bf2d3c9, with an untracked gap probe.
  • Basetemps: $Q/v110/bt/rv865b-*.
  • Logs: $Q/v118/rv865b/.
  • The shared repo: the simulator wrote objects with no refs, and the checkout is still on main with ?? handoffs.
  • No push, no post, no real gh, and nothing of mine is still running.

Base automatically changed from fix/761-add-move-back-gitmodules to main October 18, 2026 06:15
…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>
…a clone of its own at a second path is not named to delete by hand but moved whole on the next run, so the forced rule's checkout question and the checkout rule's .git-file question are pinned (#840)

Fix round 3 of #865, its second commit, on the WIP commit adb2024 (the
round is two commits, since nothing is amended). Two more cases in
tests/test_family_add_spelling_leftovers.py:
- (a) staged, its checkout deleted, its bit set by hand: `git rm -r
  --cached --sparse` with no `-f`, nothing committed, one round;
- a gitlink at Members/IRRS, committed with no section, whose directory is
  a clone of its own: the removal names no checkout, and the next run moves
  the clone out of the holder whole.
The second-path checkout part in scripts/family.py and its test copy are
rewrapped to 79 columns, with the same words.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
… advice and a member's exit code say what happened, and the gate's `git rm --cached`, guarded `mv` and section lines type as printed where they failed (#839) (#876)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review: rv876's independent review, READY at `77f8a6c8` with no MUST (#876 issuecomment-6246717354); its SHOULD 1, a claim in the body, is fixed in the body, and its five candidates are filed as #879. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 a7f6c56 (the restack of the branch onto main after #819 landed) passed tests, tests-git-bash and SonarCloud; the head ad351e4 is a clean update-branch merge of main def45de, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 7715483 over main def45de: the bootstrap_spelling, family_head_lost_gitmodules, family_holder_bootstrap_texts, family_members_not_a_list, validate_family_duplicates, repo_hygiene, agents_md_codes, cli_reference sets, 616 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #839

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

Copy link
Copy Markdown
Contributor Author

Fix round 3 of #865 (#840): 38e0d503..4e92867a.

There are two commits on 38e0d503. adb20242 (WIP round 3: …) is the fix and its tests, committed at the usage pause. 4e92867a adds two more pins and a rewrap. The round is two commits because nothing is amended.

There is no merge of #828 or main: the coordinator restacks afterwards. $Q below is /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad.

38e0d503..4e92867a changes 4 files, +396/-120:

  • scripts/family.py
  • tests/test_family_add_spelling_leftovers.py
  • tests/test_family_add_member_variant.py
  • tests/test_family_members_case_variants.py, whose literal Spelled tuple gains the new field.

1. MUST 1

_second_path_part [...] prints git rm -r --cached -- <variant> with no -f. git refuses it where the index stages the gitlink at a commit HEAD does not record there and the checkout on disk has moved on or holds an untracked file.

Done.

Tests. All are in test_a_second_paths_checkout_with_work_is_forced_and_named_first, typed as printed through typed_through:

State Removal printed
(a) staged (git submodule add, not committed), only an untracked WIP.txt in the checkout git rm -r --cached -f -- Members/IRRS; the take-out, not committed; the checkout deleted by hand
(a) staged, a commit of its own in the checkout the same; the commit is then asserted kept in .git/modules/Members/IRRS
(a) staged and left out by cone-mode sparse rules, its bit cleared, with WIP.txt git rm -r --cached --sparse -f -- Members/IRRS, the combination
K2 reset (git mv staged, then git reset -- members/IRRS) with a commit and WIP.txt in the checkout -f, the point-back, the checkout by hand, then the update; the commit is kept in .git/modules/members/IRRS
K2 committed with the same work no -f (index == HEAD), the rest as for K2 reset (SHOULD 1 below)

Each then checks:

  • registered;
  • a recursive clone with members/IRRS from IRRS.git;
  • a plain clone plus git submodule update --init --recursive, exit 0, holding the holder's gitlinks, every one checked out;
  • git submodule status exiting 0 in the holder and in both clones;
  • the gate in all three modes, and the same add going through.

Two pins came in 4e92867a:

  • test_a_staged_second_gitlink_with_nothing_on_disk_takes_no_f: (a) staged, its checkout deleted, its bit set. It gets --sparse and no -f, which pins _forced's checkout question.
  • test_a_clone_of_its_own_at_a_second_path_is_moved_whole_next: below, in point 2.

The existing K2 reset state (second_reset) now prints -f too.

2. SHOULD 1, K2 committed

name that checkout, with its git -C … status, in the same round, before the update. Or leave the update to the next run. Choose one, and say why.

I chose to name it in the same round.

  • How. Spelled.checkout is a new field, set for UNSTAGE_SECOND where the entry is on disk in its own spelling and is a directory holding a .git FILE (_moved_or_kept(...) == CHECKOUT). Then _second_checkout_part prints, just before the closing sentences and the update: "delete by hand each checkout that git rm --cached of a second path leaves on disk, Members/IRRS, once nothing only it holds is needed, rather than move it, and before any update below: …, so only what is not committed is in it alone (git -C Members/IRRS status shows it), and the update points a store it may share with the row at the row's path, after which that status shows the row's checkout instead".
  • Why not the alternative. Leaving the update to the next run means a run whose only part is the by-hand deletion, and after it nothing prints the update. add would then go through with the member checked out nowhere, and the gate's members mode exits 2 (member-uninitialized). Naming the checkout first, as 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's move back does (:5235), keeps the update in the same text.
  • Side effect. (a), (b), K2 committed, reset and foreign, and the rules' sparse state each take one round now.
  • Two guards:
    • "On disk in its own spelling" means a disk that folds case never names the member's own checkout, which Members/IRRS resolves to there.
    • A clone of its own, its .git a directory, is not named to delete. The next run moves it out whole, which test_a_clone_of_its_own_at_a_second_path_is_moved_whole_next pins: one round with git rm -r --cached and its commit, then mv -- Members/IRRS ../moved-1, with the clone's .git directory intact.

Tests. K2 committed with work and K2 reset with work, in the table above. Their hand step is deleted_by_hand(VARIANT), which asserts that git -C Members/IRRS status --porcelain lists ?? WIP.txt before deleting. It runs after the checkout part and before the update. Mutant C2, which moves the part after the update, is caught by K2 committed.

3. NIT 1

the body's count for _members_spellings should be +4, not +3 [...] recount after your change and write what you measure.

Done. I measured with rv865b's estimator, $Q/v118/rv865b/cogc.py (comprehensions not counted, then counted):

Function f7072d08 38e0d503 4e92867a
_members_spellings 41 / 56 45 / 60 46 / 61
_spellings_refusal 38 36 36
_point_back_part 2 3 3
_move_back_line 9 9 9
typed_as_printed 31 31 31
  • _members_spellings went up 4 in round 2, as rv865b counted. Round 3 adds 1, the and in checkout = present and …. The and that moved from the if into present = … counts the same.
  • The def lines of _members_spellings, _spellings_refusal and typed_as_printed are unchanged.
  • The new functions measure: _second_path_part 5, _second_checkout_part 2, _forced 1, _second_gitlink 2, _removed 2.
  • body3.md says so.

4. NIT 2

typed_as_printed's def line [...] changed again in round 2. If it is simple, keep that def line byte for byte as it is at f7072d08

Done.

5. N2

rv865b rules it acceptable as is, so nothing changed for it.

Each new test fails on 38e0d503

I copied the four test files from the head into a clone at 38e0d503 (/workspace/projects/openRepoShape-worktrees/_lane5-scratch/w865f-r3old).

  • The 20 cases with the round's changes: 12 failed and 8 passed, on git 2.43 and 2.47.1 ($Q/v118/w865f/r3/old243.log, $Q/v118/w865f/r3/old247.log).
    • The 12 are (a), (b), K2 committed, reset and foreign, both sparse states, and the five work states.
    • The 8 that pass are the five BESIDE states with no second path, K2 with its checkout deleted, and the link test under dash and bash, whose texts did not change.
  • The two pins added in 4e92867a: both fail there ($Q/v118/w865f/r3/old243b.log).
  • Behaviour without the text checks ($Q/v118/w865f/r3/test_w865f_r3_behaviour.py): each work state is typed round after round, and the hand step asserts the status names WIP.txt.
    • At 38e0d503: all five fail ($Q/v118/w865f/r3/old243b.log). Four stop at git rm -r --cached -- Members/IRRS (--sparse for one), which git refuses (staged content different from both the file and the HEAD): (a) staged untracked, (a) staged committed, (a) staged and left out, and K2 reset. K2 committed fails the hand step: WIP.txt on disk but not in its status.
    • At 4e92867a: 5 passed ($Q/v118/w865f/r3/bh-head243.log, run in the clone w865f-r3probe).

Mutants

13 mutants of the round's new hunks, on 4e92867a in /workspace/projects/openRepoShape-worktrees/_lane5-scratch/w865f-r3mut ($Q/v118/w865f/mutate3.py; $Q/v118/w865f/mut3/summary3.txt, logs in $Q/v118/w865f/mut3/). Each was run -x, and the clone was clean afterwards. 12 are caught; C5 survives.

Mutant What it undoes Result
F1 -f dropped from _second_path_part caught (W (a) staged untracked)
F2 the forced rule always true caught ((a), which takes no -f)
F3 the clause dropped caught (W (a) staged untracked)
F4 _forced without the checkout question caught (the staged second gitlink with nothing on disk)
F5 _forced without the HEAD question caught ((a))
F6 a second path never forced caught (W (a) staged untracked)
F7 the move back's clause dropped (the shared constant) caught (test_family_add_move_back_sections.py)
C1 the SHOULD 1 naming dropped caught ((a))
C2 the naming placed after the update caught (W K2 committed)
C3 every second path has a checkout caught (sparse, marked by hand)
C4 a checkout never noticed caught ((a))
C5 the checkout asked whatever the spelling on disk (present dropped) SURVIVES, 69 passed
C6 a clone of its own counted as a checkout caught (the clone at a second path)

The four the brief names are all caught: -f dropped (F1), the forced rule always true (F2), the clause dropped (F3), and the naming dropped (C1).

C5 survives because it only differs on a disk that folds case. There, Members/IRRS resolves to the member's own members/IRRS, and the guard keeps the text from saying to delete that checkout. On Linux the path is not there, so the mutant cannot be told apart. The suite's disk_as emulates os.path.isdir, lexists and samefile but not the Path.lstat and is_dir that _moved_or_kept uses, and a Members link is itself refused first. The body names it under "Not here".

Runs

Log Git Head What Result
$Q/v118/w865f/r3/final243.log (shards final243-s1.log to -s3.log) 2.43 4e92867a run9s.sh, SHARDS=3, the same 15 files 721 passed (216 + 234 + 271), rc 0
$Q/v118/w865f/r3/final247.log (shards final247-s1.log to -s3.log) 2.47.1 4e92867a the same 721 passed (216 + 234 + 271), rc 0
$Q/v118/w865f/r3/old243.log 2.43 adb20242's tests on 38e0d503's family.py 20 new or changed ids 12 failed, 8 passed (intended)
$Q/v118/w865f/r3/old247.log 2.47.1 the same the same 12 failed, 8 passed (intended)
$Q/v118/w865f/r3/old243b.log 2.43 38e0d503's family.py the two 4e92867a pins plus the behaviour copy 7 failed (intended)
$Q/v118/w865f/r3/bh-head243.log 2.43 4e92867a the behaviour copy 5 passed
$Q/v118/w865f/r3/e.log 2.43 adb20242 the literal Spelled tuple test, after the pause 1 passed
$Q/v118/w865f/r3/f.log 2.43 adb20242 plus the two new pins the two new pins 2 passed
$Q/v118/w865f/r3/g.log 2.43 the same, rewrapped the BESIDE, work and pin cases 29 passed

The 15 files are round 1's and 2's. 721 is 714 plus the 7 new cases (5 work states and 2 pins); the leftovers file holds 69. Both gates ran on a clean tree (dirty=0), and run9s removed its shard worktrees on rc 0. For 2.47.1, $Q/v115/w764/git247/bin was first on PATH, as in round 2.

Before the pause (pause-r3.md): a.log (39 passed, 1 failed, then fixed), b.log (6 passed), c.log (104 passed) and d.log (144 passed, 1 failed: the literal tuple, which the run had imported before its fix; e.log above then passed).

Merge checks

Main was fetched with git -c gc.auto=0 -c maintenance.auto=false fetch --no-write-fetch-head origin main at 08:38Z; FETCH_HEAD was never read.

Push

adb20242 and 4e92867a were made by pathspec, with the identity Brett Heap <service@opensoft.dev> and the two trailers ($Q/v118/w865f/commit4-wip-msg.txt, $Q/v118/w865f/commit5-msg.txt). Together they are a fast-forward of 38e0d503.

git push origin HEAD:refs/heads/fix/840-spelling-leftovers printed 38e0d503..4e92867a HEAD -> fix/840-spelling-leftovers, exit 0. git ls-remote origin refs/heads/fix/840-spelling-leftovers reads 4e92867a25fe179118b90ea7ef48f52ef7e69573. Nothing was posted.

Files

Both pass /usr/bin/python3 $Q/v113/body-guard.py.

Brett Heap and others added 3 commits October 18, 2026 09:18
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 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>
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

Re-check of #865 (#840) fix round 3 and its restack at 6ef6e4c1, for lane openRepoShape-5: READY

$Q = /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad. My logs are under $Q/v118/rv865b/logs/ and the round-3 probe is $Q/v118/rv865b/probe/test_rv865b_r3.py.

Both of my findings from the 38e0d503 re-check are closed, on git 2.43 and 2.47.1. No checkout loses work, and the checkout is named before anything hides its untracked file. The restack leaves #865's own changed lines exactly as they were. The only thing left is a NIT: a test for C5 can be written.

Findings

NIT 1: C5, the own-spelling guard (scripts/family.py:4283, checkout = present and …), is pinnable on Linux after all. Add the test.

  • What I measured (logs/r3probe-243.jsonl, logs/c5-mut-243.jsonl):
    • The state: Members a link to members, the folding emulation tests/test_validate_family_remedies_typed.py already uses, plus a second gitlink at Members/IRRS, committed, with skip-worktree set.
    • At the head, no checkout part is printed.
    • With C5 applied, the text gains "delete by hand each checkout that git rm --cached of a second path leaves on disk, Members/IRRS … before any update below". Members/IRRS resolves to the member's own checkout.
  • Why it matters: in this emulation the link's mv comes earlier in the same remedy, so the line deletes nothing. On a real folding disk there is no link, and that line would name the member's own checkout. The guard protects data.
  • Direction: pin it with this state, asserting the full text. It applies only where the variant is routed through skip-worktree (RENAME_SPARSE), so it is narrow and does not block.

Checked and true

Your MUST 1 and SHOULD 1, closed. I typed 10 states as printed at 6ef6e4c1, on 2.43 and 2.47.1, with each named checkout deleted by hand only after recording its git status. Work means a commit made in the checkout plus an untracked WIP.txt. The states:

  • (a) committed, with work
  • (b) committed, with work
  • my P2
  • K2 committed, with work
  • K2 reset, with work
  • K2 foreign
  • (a) staged, an untracked file only
  • (a) staged, with a commit
  • (a) staged and left out by cone-mode sparse rules
  • (a) with skip-worktree set by hand, its checkout kept

What they show:

  • -f is printed where it should be.
    • (a) staged and K2 reset get git rm -r --cached -f -- Members/IRRS.
    • The sparse state gets git rm -r --cached --sparse -f -- ….
    • The committed states get no -f.
    • Every one types clean.
  • Every state ends the same way:
    • one refusal, then add goes through;
    • the gate exits 0 in all three modes;
    • a plain clone plus git submodule update --init --recursive exits 0, with members/IRQS, members/IRRS and members/IRSS checked out;
    • a recursive clone gets members/IRRS from IRRS.git;
    • git submodule status exits 0 in the holder and in the clone.
  • The naming comes first. At the moment the checkout is named, git -C <checkout> status lists ?? WIP.txt in every state. For K2 committed and K2 reset it ran before the update (row_checked_out_yet: False), and its toplevel was the checkout itself.
  • No work is lost. The work commit is on refs/heads/main in its store before the deletion, after it, and after add (logs/r3stores2-243.jsonl). A plain git --git-dir=<store> read exits 128 once the checkout is gone, because the store's core.worktree then names a missing directory. Reading with an explicit --work-tree finds the commit. This is not a loss.

C5. It survives only because nothing pins it. My ruling is NIT 1 above.

The restack.

Runs at 6ef6e4c1 through run9.sh.

  • logs/r3-head-243.log: the leftovers, member_variant, move_back_sections, case_variants and test_no_auto_maintenance.py files plus the trio, on 2.43. 415 passed, 0 skipped, rc 0.
  • logs/r3-head-247.log: the leftovers file on 2.47.1. 69 passed, rc 0.
  • w865f's final243.log and final247.log: 721 each (216 + 234 + 271), clean tree. This matches its account, as do its 12 of 13 mutants caught.

Sonar-style.

  • Duplication in the round's new lines: none at 8 or 10 lines. The only 6-line window is the test's expected copy of _second_checkout_part's text (scripts/family.py:4943-4948 at 4e92867a, and tests/test_family_add_spelling_leftovers.py:212-217), a string mirror below the threshold.
  • No assert joins conditions with and; the one hit is inside a string.
  • By my count, with the comprehension-counted figure after the slash:
    • _members_spellings 46 / 61, def line unchanged; this is the issue that already exists.
    • _forced 1, _second_checkout_part 2, _second_path_part 5, _second_gitlink 2, _removed 2.
    • New test helpers all 5 or under.
    • typed_as_printed and typed_in_sh have f7072d08's def lines again.
  • The body draft passes body-guard.py.

Could not verify

  • CI's git 2.55.
  • A real macOS or Windows disk: C5 was measured only through the link emulation.
  • SonarCloud itself.

Scratch left

  • Clones under /workspace/projects/openRepoShape-worktrees/_lane5-scratch/. I created no worktrees this round, so there was nothing to git worktree remove.
    • rv865b-head: at 6ef6e4c1, with untracked probe files.
    • rv865b-mut: at 6ef6e4c1, with untracked probes; family.py restored and clean.
    • rv865b-run: at 6ef6e4c1, clean.
    • rv865b-old: at 3bf2d3c9, with the untracked gap probe.
  • Basetemps: $Q/v110/bt/rv865b-*.
  • Logs: $Q/v118/rv865b/.
  • No rm was run, nothing was pushed or posted, and nothing of mine is running.

…embers` link to `members` (a folding disk's resolution) names no checkout to delete, and mutant C5, which asked it through the resolution, is caught (#840)

Fix round 4 of #865, after rv865b's re-check of round 3 and the restack
(READY at 6ef6e4c, one NIT). The state is rv865b's: a gitlink at
Members/IRRS, committed, its skip-worktree bit set, and `Members` a link
to `members`, the folding emulation
tests/test_validate_family_remedies_typed.py uses. The test asserts the
whole text: the link moved out first, then `git rm -r --cached --sparse --
Members/IRRS` and its commit, and no line saying to delete a checkout.
Typed as printed, the member is where its row says, the gate passes and
the same add goes through.

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

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

Fix round 4 of #865 (#840): 6ef6e4c1..231c404c.

There is one commit on the coordinator's restack 6ef6e4c1, 231c404c, and it is a fast-forward. It changes one file, tests/test_family_add_spelling_leftovers.py, +38/-0. scripts/family.py is unchanged. $Q below is /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad.

NIT 1, mutant C5

C5, the own-spelling guard at scripts/family.py:4283 (checkout = present and …), can be pinned on Linux.

Done. The new test is test_a_second_paths_checkout_is_asked_in_its_own_spelling (line 1041), built from the file's existing helpers.

The state:

  • the holder's row members/IRRS, as in the other cases;
  • a second gitlink at Members/IRRS at the row's commit, committed, with its skip-worktree bit set, so it routes through RENAME_SPARSE;
  • Members a link to members, so Members/IRRS resolves to the member's own checkout, as on a disk that folds case.

The test skips on Windows and on a disk that folds case, as the file's other link tests do.

What it asserts. The whole refusal (DIR_VARIANT, then link_out("Members") and second_out(VARIANT, commit="the removal", sparse=True)):

  • the link is moved out first;
  • then git rm -r --cached --sparse -- Members/IRRS and its commit;
  • no part says to delete a checkout.

It then types the text as printed, which gives mv -- Members ../moved-1, git rm -r --cached --sparse -- Members/IRRS and git commit. The moved link is still a link, the member is registered at its row, and gate_then_add passes: the gate in every mode, the same add, and the gate again.

C5 is caught by tests/test_family_add_spelling_leftovers.py::test_a_second_paths_checkout_is_asked_in_its_own_spelling.

  • Where. I ran $Q/v118/w865f/mutate3.py C5 with W865F_MUT set to a clone at 231c404c, /workspace/projects/openRepoShape-worktrees/_lane5-scratch/w865f-r4mut. The clone was clean afterwards.
  • Result. Run -x over the leftovers file, it gave 1 failed and 32 passed, rc 1, failing on the new test ($Q/v118/w865f/r4/C5-mut.log, summary line in $Q/v118/w865f/r4/c5.log, $Q/v118/w865f/mut3/C5.log). In round 3, without this test, C5 survived with 69 passed ($Q/v118/w865f/mut3/C5-round3.log).
  • The diff. A -vvv rerun of the new test alone with C5 applied ($Q/v118/w865f/c5probe4.py, $Q/v118/w865f/r4/C5-vv.log; 1 failed, the clone clean afterwards) shows what C5 adds. Before "then re-run.", the text gains "delete by hand each checkout that git rm --cached of a second path leaves on disk, Members/IRRS, once nothing only it holds is needed, rather than move it, and before any update below: …". That is the part asking to delete the member's own checkout, which the guard keeps out.

Runs

Log Git Head What Result
$Q/v118/w865f/r4/a.log 2.43 6ef6e4c1 plus the new test, before the commit the new test 1 passed
$Q/v118/w865f/r4/b247.log 2.47.1 the same the new test 1 passed
$Q/v118/w865f/r4/v243.log 2.43 231c404c, dirty=0 the leftovers file, test_family_add_member_variant.py, and the trio test_repo_hygiene.py, test_agents_md_codes.py, test_cli_reference.py 255 passed, rc 0
$Q/v118/w865f/r4/v247.log 2.47.1 231c404c, dirty=0 the leftovers file 70 passed, rc 0
$Q/v118/w865f/r4/C5-mut.log 2.43 231c404c with C5, in w865f-r4mut the leftovers file, -x 1 failed, 32 passed (intended)
$Q/v118/w865f/r4/C5-vv.log 2.43 the same the new test, -vvv 1 failed (intended)
  • Every run went through $Q/v110/run9.sh, with the gh stub and a bogus token, and no more than two pytest processes at a time.
  • For 2.47.1, $Q/v115/w764/git247/bin was first on PATH, and the log's first line is git version 2.47.1.
  • The leftovers file holds 70 cases now (69 plus this one).

Merge check

Main was fetched with git -c gc.auto=0 -c maintenance.auto=false fetch --no-write-fetch-head origin main, at 09:32Z and again at 09:42Z. FETCH_HEAD was never read, and origin/main was 1ef1a0182d2c0eaeb3c15667021a421a4edba349 both times.

git merge-tree --write-tree origin/main 231c404c: clean, rc 0, tree eb07050652b85dd4f7a0d5ab7e896f51c130b31f ($Q/v118/w865f/r4/mt-origin-main.txt).

Push

231c404c was committed by pathspec, with the identity Brett Heap <service@opensoft.dev> given by -c and the two trailers ($Q/v118/w865f/commit6-msg.txt). git merge-base --is-ancestor 6ef6e4c1 HEAD held, and the remote read 6ef6e4c1a618b2d3cad084bdca40b964c05a7c26 before the push.

git push origin HEAD:refs/heads/fix/840-spelling-leftovers printed 6ef6e4c1..231c404c HEAD -> fix/840-spelling-leftovers, exit 0. git ls-remote origin refs/heads/fix/840-spelling-leftovers reads 231c404c57cfcc7246f3d14001e34cbe8dfcf69b. Nothing was posted.

Files

  • $Q/v118/w865f/body4.md: the PR body, made from body3.md. It has a "Fix round 4" section and the new test in line 1. The count is 70 cases, and the mutant line now says C5 is caught by the new test. The stacking line says the branch is restacked at 6ef6e4c1.
  • This file.

Both pass /usr/bin/python3 $Q/v113/body-guard.py.

@brettheap

Copy link
Copy Markdown
Contributor Author

Re-check of #865 (#840) fix round 4 at 231c404c, for lane openRepoShape-5: READY

The new test pins C5 the way I described it, my own C5 edit fails exactly that test, and the round touches tests only. I have no findings.

$Q = /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad. My logs are in $Q/v118/rv865b/logs/.

Checked and true

1. The test builds the state I described. test_a_second_paths_checkout_is_asked_in_its_own_spelling is at tests/test_family_add_spelling_leftovers.py:1041.

  • It adds a second gitlink at Members/IRRS at the row's commit, sets its skip-worktree bit and commits it. It then makes Members a link to members.
  • It asserts the whole refusal (:1065): the lead, then remedy_of(link_out("Members"), second_out(VARIANT, commit="the removal", sparse=True)). So no checkout part may appear.
  • It then types the text as printed and checks the typed list: mv -- Members ../moved-1, git rm -r --cached --sparse -- Members/IRRS, git commit. After that come the moved link, registered and gate_then_add.
  • My own C5 edit fails it. I dropped present and at scripts/family.py:4283 in my clone rv865b-mut at 231c404c and ran the leftovers file.
    • Result: 1 failed, 69 passed, rc 1 (logs/r4-c5-mut.log).
    • The one failure is the new test, at the full-text assertion (:1065).
    • family.py was restored afterwards and the clone is clean.
  • On 2.43 and 2.47.1 the test ran rather than skipped: neither log has a SKIPPED line.

2. Runs at 231c404c through run9.sh, both on a clean tree:

  • logs/r4-head-243.log: the leftovers file plus the trio, git 2.43. 218 passed (70 + 148), 0 skipped, rc 0.
  • logs/r4-head-247.log: the leftovers file, git 2.47.1. 70 passed, rc 0.
  • w865f's count of 70 cases is right.

3. The diff.

  • git diff 6ef6e4c1 231c404c touches only tests/test_family_add_spelling_leftovers.py, +38/−0. It is a fast-forward of 6ef6e4c1.
  • No duplicated block: zero windows of 6, 8 or 10 lines touch the new lines.
  • No assert joins conditions with and. The new asserts are a tuple equality, a list equality and an islink.
  • The new test's cognitive complexity is 1.
  • merge-tree against main 1ef1a018 is clean, tree eb070506, the same tree w865f reports.

Scratch left

Clones under /workspace/projects/openRepoShape-worktrees/_lane5-scratch/:

  • rv865b-run: at 231c404c, clean.
  • rv865b-mut: at 231c404c, family.py clean, untracked probe files.
  • rv865b-head: at 6ef6e4c1, untracked probe files.
  • rv865b-old: at 3bf2d3c9, untracked gap probe.

Basetemps are under $Q/v110/bt/rv865b-*. No rm was run, I created no worktrees, nothing was pushed or posted, and nothing of mine is running.

@brettheap
brettheap marked this pull request as ready for review October 18, 2026 11:00
@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 71f36fe into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/840-spelling-leftovers branch October 18, 2026 11:01
brettheap pushed a commit that referenced this pull request Oct 18, 2026
…ith #877, #865 and #871 (#883)

Round 4 of #883, a refresh after RV883 found 34e0f5b READY (#883
issuecomment-6248756609). Each line comes from its squash body and was
checked against the code at main a249910:

- #877 (#856): a holder's bootstrap reads an index or HEAD `.gitmodules`
  up to a line git cannot parse, as git does, and a parity test holds the
  value rules both family scripts write to one answer. The #856 limit is
  retired.
- #865 (#840): `family.py add` names first, with a remedy that types as
  printed, the states #840 lists, and its closing guard takes
  `validate-family.py`'s form. The limit on the two guards and on the
  case-spelling states is retired; three states #865's body leaves
  unruled are a narrower limit.
- #871 (#863): `family.py init` asks GitHub's limits before any `gh` call,
  `adopt-project.py check` finds what `execute` refuses from one list, and
  an `--org` GitHub gives no organisation is refused where GitHub is
  asked. The #863 clauses are retired; that the characters are from
  GitHub's documentation, with `_` let by, joins the limit that says none
  was measured.

RV883's optional NIT: the `adopt-work-dir-shared` limit no longer says
"what no owner or mode bit shows", since group write is a mode bit.

63 pull requests; 64 of the milestone's 72 issues closed at 11:05Z, and
the open list is the 8 that read leaves: #675, #707, #755, #764, #853,
#854, #879 and #884. The shape-copy lists stand. No other file changes.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…e and the innermost command named, so a member whose make runs another no longer reads `exited 2` for a finding, and `make -k` keeps its `Error` line (#884) (#886)

#884's three lines, from rv876's NIT 2, NIT 3 and NIT 4 on #876 (#876 issuecomment-6246717354), with fix round 1 from rv884's review of this pull request (issuecomment-6248806027). The change is in the family holder's pinned `templates/family-root/scripts/bootstrap.py` and in `tests/test_family_holder_bootstrap_texts.py`, and nothing else.

Base: origin/main `281df263`, where #876 has landed as `41baf920`. Each line was probed there first, under the holder's own `make bootstrap`, with GNU Make 4.3, dash 0.5.12 as `/bin/sh` and git 2.43.0. The probes reuse rv876's `p2/ptyrun.py` and its member Makefiles.

**1. Five mutants of #876's code survived its tests, and one docstring claimed what no test checked.** Tests only; no code changes for this line.
- **R10 and R11** (`credential_advice` ignoring `unreadable` or `refused`): `test_no_advice_beside_no_member_updated_with_every_member_checked_out`. Every member is checked out, as in the holder itself, and HEAD's `.gitmodules` is given `update = None` or a conflict marker. Bootstrap's own stderr lines after the fetch line are asserted exactly: the "NO MEMBER UPDATED, and not for want of a credential" line and the failure, with no advice between them. `tests/test_family_gitmodules_values.py` asks only the last two lines, which is why the mutants passed there.
- **R1** (`named[0]`) **and R8** (the regex anchored on `^make: `): `test_the_line_names_the_code_of_the_member_s_innermost_command[last-under-make]`. `bootstrap.py --no-fetch` runs under a real parent `make -k`, as the holder's `make bootstrap` runs it, so the member's make prints `make[1]:`. One member exits 1. Another has two commands that exit 1 and then 2, and the line must name the last.
- **R7** (`passed_on`'s `sink.flush()` dropped): `test_a_member_s_question_shows_before_its_answer_is_typed`. Bootstrap runs on a pseudo-terminal (its stdin, stdout and stderr). A member recipe asks `Continue? ` on stderr and `read`s the answer. The test types `yes` only once the question has shown, and fails if it has not shown within 10 s. It asserts the output before the answer and after it exactly. Measured on the pty: the question showed at 0.13 s at head; with R7 on `281df263` it showed only at 8.1 s, after the blind answer, when the run ended.
- **The store:** `test_a_git_mv_staged_with_work_in_the_checkout_is_undone_as_printed` now asserts what its docstring said. After every line is typed, the checkout's commit (the new commit, or the pin where only an untracked file was added) is still in `.git/modules/members/IRRS`. A helper, `kept`, is shared with `test_a_checkout_moved_on_at_the_variant_is_moved_with_f`'s own store assert.
- On `281df263`, with rv876's own `mut/mutants.py` unchanged, every one of the five is caught by the test named above, and those tests pass on the unmutated code:

| rv876's mutant on `281df263` | caught by |
|---|---|
| R1 `named[0]` | `[last-under-make]` |
| R7 no `sink.flush()` | `test_a_member_s_question_shows_before_its_answer_is_typed` |
| R8 `^make: ` | `[last-under-make]` |
| R10 advice ignores `unreadable` | `test_no_advice_beside_no_member_updated_with_every_member_checked_out[unparsed]` |
| R11 advice ignores `refused` | `test_no_advice_beside_no_member_updated_with_every_member_checked_out[value-refused]` |

**2. The final line misled in three cases** (`run_make`, `MakeErrors`).
- Measured on `281df263` and on this head, each member under the holder's `make bootstrap` and with `bootstrap.py` run straight (`-k` where the case needs it). The last five rows came from rv884's review:

| member | `281df263` | head |
|---|---|---|
| `$(MAKE) inner`, inner command exits 1 | `exited 2` | `exited 1` |
| `$(MAKE) inner`, inner command exits 2 | `exited 2` | `exited 2` |
| `$(MAKE) nothere`, the inner make's own failure | `exited 2` | make printed no `Error <code>` line for a command of the member's |
| `$(MAKE) -s nothere; exit 3` | `exited 3` | `exited 3` |
| `make -k`: `Error 1`, then 5000 bytes on stderr | make printed no `Error <code>` line (false) | `exited 1` |
| `make -k`: two commands exit 1, then 3 | `exited 3` | `exited 3` |
| a command killed by SIGKILL under dash | `exited 137` | `exited 137` |
| `kill -TERM $$` in a recipe | make printed no `Error <code>` line | the same |
| `$(MAKE) -s nothere \|\| exit 1` | `exited 1` | `exited 1` |
| `$(MAKE) -k inner`, whose second target prints after the inner `Error 1` | `exited 2` | `exited 1` |
| `make -k`: a finding, then a command that `kill -KILL $$` ends | `exited 1` | `exited 1` |
| `-$(MAKE) inner` (inner exits 1), then the recipe's own `exit 2` | `exited 2` | `exited 2` (`82a84f88` read `exited 1`) |
| `printf 'x: *** y' >&2; exit 1`, so make's line follows it on one line | `exited 1` | `exited 1` (`82a84f88` named none) |

- **A recursing member: the line names the innermost command's code.** Why: the inner make exits 2 for any failure, which is the very code #839 set out to see past. The command the member's author wrote, and whose code tells a finding (1) from a refusal (2), is the innermost one.
  - The depth comes from make's own prefix (`make[N]:`, or `make:` for 0).
  - An `Error 2` at a shallower depth than the last `***` line before it, whatever lines come between, is taken for that make's parent reporting it, and is passed over.
  - Where the inner make failed on its own (`No rule to make target`), no command's code is named, and the line is the one for make's own failure.
  - A command that runs a failing make and then fails itself, with 3 or with 1, is still named.
  - Two failures at the same depth under `-k` name the last, as before.
  - Where a `-` let the inner make fail, make prints `[Makefile:N: bootstrap] Error 2 (ignored)`, with no `***`. That line now puts the depth back at the parent's, so the parent's own `Error 2` after it is named. rv884 offered this as an option, to adopt only if it changed no other case. Measured: across rv884's 133 case runs (its `tools/cases1.py` to `cases6.py`, at both depths), head with it differs from `82a84f88` only in that case and in the last-match case below. Across 21 more of mine it differs only in that case, in `-$(MAKE) nothere` then `exit 2`, and in the last-match case, each now as `281df263` read it. An inner make that ignores a failure of its own prints the same kind of line at its own, deeper depth; that line changes nothing (measured: `$(MAKE) inner; exit 2`, where inner ignores an `exit 2`, reads `exited 2`).
- **The last match on a line** (rv884's NIT 1). A member's own text with no newline, `x: *** y`, followed by make's `Error 1` on the same line, named no code at `82a84f88`. `said` now takes the last match on a line (`findall`), and that case reads `exited 1`, as `281df263` read it.
- **Where the line is not the innermost command's code.** This is now written in `MakeErrors`' docstring, not only in this body (rv884's NIT 2):
  - a recipe that runs a make, lets its failure pass in the shell (`|| true`, or `;`), or with a `-` in a make run with `-s` (which prints no `(ignored)` line), and then exits 2 itself: that `Error 2` is read as the inner make's report, and the inner command's code is named. Measured: `$(MAKE) inner || true; ...; exit 2` and `-$(MAKE) inner` under the holder's `make -s` read `exited 1`; `281df263` read `exited 2`;
  - an inner make run with `env -u MAKELEVEL`, which prints its parent's depth: the parent's `Error 2` is named, as on `281df263`;
  - under `-j`, a job of the parent's that fails while the inner make is still running: its line comes between, and the parent's `Error 2` is named, as on `281df263`;
  - a member's own line shaped like a deeper make's (`x[3]: *** y`) before it exits 2 itself: its `Error 2` is passed over.
  - The module docstring's (b) now says the code is the innermost command's "as far as make's lines tell it".
- **More than 4 KiB after the `Error` line:** `run_make` kept the last 4096 bytes of make's stderr and read that tail at the end. `MakeErrors` now reads each line as it passes. It keeps only the last 4096 bytes of a line that no newline has ended yet, so memory stays bounded for a progress meter that writes only `\r`. rv884's `tools/chunks.py`, pointed at this head, fed 174 captured streams to `MakeErrors` in 81,406 chunkings; none read differently from the whole.
- **A signal:** the docstring is now true of rv884's 24 cases (`probe2.log`: dash 0.5.12 and bash 5.2.21; a command run with no shell, one command, `kill $$`, a command then `echo`, a command then `exit $?`, `echo` then a command; SIGKILL and SIGTERM; at both depths).
  - Where the signal kills what make started, make prints its name, `Killed` or `Terminated`, and names no code. That is: a command make runs with no shell; the shell a `kill -KILL $$` kills; or a command bash runs in its own place, which is a line's last command, a line of one command included.
  - Where the shell outlives the command, the shell prints the signal's name itself, `Killed` or `Terminated`, and exits 137 or 143, which make names. dash outlives every command; bash outlives one that is not a line's last.
  - Where the line goes on after the command, the shell exits as the rest of the line does: `; echo after` ends the recipe with 0.
  - The line itself was not changed for a signal. The signal's name is printed right above make's `Error 137`, and a command may also exit 137 by itself.
- **Tests:** `test_the_line_names_the_code_of_the_member_s_innermost_command`, with four sets:
  - `[innermost-under-make]` and `[innermost]` run `INNERMOST`'s 11 members, once under a parent make and once with the member's make at depth 0. They are the first five rows of the table, the last five, and an inner make that ignores an `exit 2` of its own, then `exit 2` (`InnerLets`, run without `-s`, which would keep make from saying so);
  - `[last-under-make]` is line 1's R1 and R8 test;
  - `[signal]` runs one of each signal case in a form that does not hang on which shell `/bin/sh` is: `sh -c 'kill -KILL $$'; exit $?`, which the shell outlives (137), and `kill -KILL $$`, which kills the shell (no code).
  - Each set asserts the run's final line exactly. Under the parent make, a premise checks that no member line reads `make:`.
- `test_a_line_that_comes_in_pieces_is_read_whole` (the pieces test) feeds `MakeErrors` an `Error` line cut in two, and a line ended by no newline, which stays at `LINE_KEPT` bytes.
- The existing `test_a_finding_a_refusal_and_make_s_own_failure_are_told_apart` (depth 0, no `-k`) passes unchanged. It now shares `with_members` and its line helpers with the new tests.

**3. What passing make's stderr on changes.** I chose to document it in `run_make`'s docstring, after measuring both ways.

| effect | `281df263` and head (documented) | reading only until make exits (prototype, not committed) |
|---|---|---|
| `test -t 2` in the member | false: the pipe | false |
| stdout and stderr order | out of order on the pty: one run printed `err 1` after `out 4`, another all six `out` lines first | the same |
| recipe `sleep 6 &` | 6.14 s | 0.29 s |
| `( sleep 1; echo late >&2 ) &` | 1.12 s, `late` shown | `late` lost, and the writer died at that write: its status file was never written |
| non-UTF-8 bytes (`caf` and 0xE9) | passed on as written | the same |

- **The background holder.** Stopping once make has exited and nothing is waiting fixes only the hold, and only by closing the pipe. The left-running process's next stderr write then meets a closed pipe and dies of SIGPIPE, silently. Holding the run is visible, ends when the process lets go of stderr, and the recipe can give the process its own stderr (`2>log`), as the docstring says.
- **The terminal.** Giving make the terminal back would lose its `Error <code>` line. Measured: `make --debug=j` prints only "Reaping losing child" on stdout, with no code, and no other output make writes carries the code. That would reopen line 2 of #839, which this change keeps closed: make still runs.
- **The line order and the Windows decoding** cannot be fixed by the reader. The docstring says each, with the Windows case read and not run.
- **Tests that hold the docstring:**
  - `test_what_a_member_s_make_leaves_on_stderr_is_passed_on_as_written` (the leaves test) asserts bootstrap's stderr bytes exactly: the non-UTF-8 line as written, then `late`, before the run ends;
  - the pty test asserts `stderr: no terminal` and `stdout: a terminal` from the member.

**Pinned copy and docs.**
- `bootstrap.py` is in `FAMILY_COPIED_VERBATIM`, so each holder takes one `upstream-changed` row for it on its next update. Its mode stays 100755.
- Its imports from `repo_shape` are unchanged, so the skew rule (`tests/test_pinned_copy_text.py`) and `tests/test_update_shape_kept_helper.py` hold.
- No `--help` text changed, so `docs/cli.md` is unchanged.
- No sentence of `AGENTS.md`, `templates/family-root/AGENTS-shape.md`, `templates/family-root/README.md` or `README.md` speaks of the make line or of stderr (grep).

**Fail on base.** The test file at this head, run on `281df263`'s code: 3 failed, 54 passed. On `82a84f88`'s code (this pull request before fix round 1): 2 failed, 55 passed.
- On `281df263`, the 3 failures are the tests that pin line 2's change: `[innermost-under-make]`, `[innermost]` and `test_a_line_that_comes_in_pieces_is_read_whole`. On `82a84f88`, the 2 failures are both `[innermost*]`.
- These pass on `281df263` by design:
  - the pure mutant-killers `[last-under-make]` (R1, R8), the pty test (R7) and both `test_no_advice_beside_no_member_updated_with_every_member_checked_out` cases (R10, R11);
  - the tests that hold a docstring: `[signal]`, `test_what_a_member_s_make_leaves_on_stderr_is_passed_on_as_written`, and the store assert in both `test_a_git_mv_staged_with_work_in_the_checkout_is_undone_as_printed` cases.
- Each `INNERMOST` member, run straight with `-k` (depth 0):

| member | expected | `281df263` | `82a84f88` | head |
|---|---|---|---|---|
| `Recurse` | 1 | 2 | 1 | 1 |
| `RecurseRefuse` | 2 | 2 | 2 | 2 |
| `InnerOwn` | none named | 2 | none named | none named |
| `InnerThenOwn` | 3 | 3 | 3 | 3 |
| `InnerThenFinding` (X12) | 1 | 1 | 1 | 1 |
| `Long` | 1 | none named | 1 | 1 |
| `KeepsGoing` (X11) | 1 | 2 | 1 | 1 |
| `ThenKilled` (X7) | 1 | 1 | 1 | 1 |
| `LetFail` | 2 | 2 | 1 | 2 |
| `InnerLets` | 2 | 2 | 2 | 2 |
| `OwnStars` | 1 | 1 | none named | 1 |

- rv884's own X7, X11 and X12, its saved copies of `82a84f88` with each mutant, read their member wrong: `ThenKilled` says none, `KeepsGoing` says 2 and `InnerThenFinding` says none.

**Mutants at this head**, each run against the whole test file: 36 made, 33 caught. The 3 survivors are the equivalents rv884 named: X5 (`\s*$` dropped; make's `***` lines carry nothing after the code), X6 (the unended remainder read at EOF; make always ends its line) and X9 (passed over only one level deeper; makes nest one level at a time).

| mutant | caught by |
|---|---|
| R1, first `Error` line kept | `[last-under-make]`, both `[innermost*]` |
| R7, no `sink.flush()` | the pty test |
| R8, regex anchored on `^make()?: ` | `[last-under-make]`, both `[innermost*]`, the pieces test |
| R10, R11 | the matching `test_no_advice_beside_no_member_updated_with_every_member_checked_out` case |
| H1 nothing passed over; H2 any code passed over; H4 depth never read; H6 depth not kept; H8 a `***` line with no code not read; H9 the 4096-byte tail again | both `[innermost*]` |
| H3, `<=` for `<` in the depth test | `[last-under-make]`, both `[innermost*]` |
| H5, `make:` read as depth 9 | `[innermost]` |
| H7, depth starting at 99 | both `told_apart` cases, both `[innermost*]` |
| H10 an unended line dropped; H11 kept whole | the pieces test |
| H12, only a chunk's last line read | `[last-under-make]` |
| D1 reading stopped once make exits; D2 the bytes decoded as UTF-8 | the leaves test |
| rv884's X1 (`!=`), X2 (depth only on coded lines), X4 (depth group dropped), X7, X11, X12 | both `[innermost*]` (X1 also both `told_apart` cases) |
| rv884's X3 (`[:LINE_KEPT]`) | the pieces test |
| rv884's X8 (`splitlines`) | 7 tests |
| rv884's X10 (one-digit code) | `[signal]` |
| N1, the first match on a line | both `[innermost*]` (`OwnStars`) |
| N2, the `(ignored)` line not read | both `[innermost*]` (`LetFail`) |
| N3, the `(ignored)` line always sets the depth; N5, it falls through | both `[innermost*]` (`InnerLets`) |
| N4, `max` for `min` | both `[innermost*]` (`LetFail`, `InnerLets`) |

**Runs**, each through `run9s.sh` with `SHARDS=3` at this head, with no tracked changes. The files: `tests/test_family_holder_bootstrap_texts.py`, `tests/test_family_bootstrap_gate_order.py`, `tests/test_bootstrap_spelling.py` and `tests/test_pinned_copy_text.py`; the two that `git grep -l 'family-root/scripts/bootstrap' -- tests` names, `tests/test_family.py` and `tests/test_update_shape_kept_helper.py`; the trio, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`; and five more that run the holder's bootstrap: `tests/test_family_gitmodules_values.py`, `tests/test_family_head_lost_gitmodules.py`, `tests/test_family_members_not_a_list.py`, `tests/test_bootstrap_without_git.py` and `tests/test_family_makefile_siblings.py`.
- git 2.43.0: 1049 passed, rc 0 (shards 218, 394 and 437), at `dbd3a6dd`.
- git 2.47.1: 1049 passed, rc 0 (shards 218, 394 and 437), at `dbd3a6dd`.

**Merge checks**, `git merge-tree --write-tree` of this head, after a fresh fetch:

| against | result |
|---|---|
| origin/main `71f36fe0`, where lane 3's #877 has landed as `c9ca9a45`, editing other parts of `bootstrap.py` | clean, tree `8b5826db` |
| origin/main `201c6f09`, after #865, #871 and #815 too | clean, tree `8c3d8e13`; on a commit at that tree, this test file, `tests/test_family_gitmodules_values.py` and `tests/test_pinned_copy_text.py` gave 291 passed, rc 0 (git 2.43, rv884's re-check, #886 issuecomment-6249507909) |
| #877's head `35b214b2` | clean, tree `b80a2ba0` |

**Sonar-style checks.**
- No duplicated block: no 10-line window that touches an added line appears anywhere else in `tests/`, `scripts/` or `templates/`. At 6 lines, only the import block matches another file.
- No assert joins conditions with `and`. The one `or` assert in the file is #876's.
- Cognitive complexity, counted by hand with Sonar's rules: no changed function is over 15, so no `def` line had to stay as it was. `said` counts each boolean sequence: `depth_said or 0`, `code == b"2" and ...`, and `code and not ...`.

| function | `281df263` | `82a84f88` | head |
|---|---|---|---|
| `run_make` | 2 | 1 | 1 |
| `MakeErrors.read` | new | 1 | 1 |
| `MakeErrors.said` | new | 5 | 6 |
| `read_until` (test) | new | 9 | 9 |
| `made`, `with_members` (test) | new | 2, 1 | 2, 1 |
| `test_the_line_names_the_code_of_the_member_s_innermost_command` | new | 4 | 4 |
| `test_a_finding_a_refusal_and_make_s_own_failure_are_told_apart` | 3 | 2 | 2 |
| `test_a_git_mv_staged_with_work_in_the_checkout_is_undone_as_printed` | 3 | 3 | 3 |

**Not here.**
- No Windows or macOS run, since CI is Linux only (#597). The Windows decoding is read, not run.
- Make before 4.0 was not run. The prefix and the `Error` line keep make 3.81's form by its source, but the line-2 test already asserts make 4's quoting.
- The innermost rule's limits are in `MakeErrors`' docstring and listed under line 2 above. A make that speaks another language still gets the line for make's own failure.

Review: rv884, lane 5's independent review, READY at `82a84f88` (issuecomment-6248806027), with no MUST, one SHOULD (three surviving mutants) and four NITs. w884's fix round 1, `dbd3a6dd`, answered each (issuecomment-6249230294), and rv884's re-check found it READY with no MUST or SHOULD (issuecomment-6249507909). Its two NITs on the `(ignored)` rule, which cover rarer nested-make shapes, go to a follow-up. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 dbd3a6d passed tests, tests-git-bash and SonarCloud; the head 85c2078 is a clean update-branch merge of main 0d5d2b1, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 5c273a1 over main 0d5d2b1: the family_holder_bootstrap_texts, repo_hygiene, agents_md_codes, cli_reference sets, 205 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 #884

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