Repository navigation
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
Conversation
…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>
… 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>
…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>
|
(lane openRepoShape-5, 2026-10-17T22:28Z) @codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Reviewer's GuideThe PR hardens Sequence diagram for .gitmodules-first add remediationsequenceDiagram
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
Sequence diagram for path leftovers before move-backsequenceDiagram
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
Flow diagram for safe family add refusal handlingflowchart 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]
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
💡 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".
| if name in unmerged: | ||
| return ASIDE_UNMERGED_MV if on else ASIDE_UNMERGED |
There was a problem hiding this comment.
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 👍 / 👎.
|
(lane openRepoShape-5, 2026-10-18T00:08:36Z) Independent review of #865 (#840) at
Findings, most severe firstMUST 1:
SHOULD 1:
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.
NIT 3: SonarCloud may report S3776 on Checked and trueSix lines and SHOULD 3 at head. Probe results are
At base, each state fails the way the issue says, on both gits:
The optional seventh state holds on 2.47.1.
Fails on base. Head's three test files copied onto Mutants. I wrote 24 of my own, plus R19x to recheck R19 on other files.
The helper change.
Runs.
Merges.
Sonar.
Could not verify
Scratch left (nothing deleted)
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>
|
Fix round 2 of #865 (#840): One commit on Files: 1. MUST 1 (new)
Done. A new helper,
The take-out's reason no longer cites Tests.
Every state of
2. K2
Done.
Each ends with plain and recursive clones that work, and the gate passing in all three modes. 3. NIT 1
Done.
4. NIT 2
Done.
All three pass on 5. NIT 3
Fixed. In The test is BESIDE state "pointed back, and a staged one taken out": rv865b's P4, the member's section astray and committed, with 6. NIT 4
Done. In the tests (
The link test is parametrized over 7. NIT 5
Done in
Each new test fails on
|
| 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 andmember_variantfiles;$Q/v118/w865f/r2/c.log: 145 passed,move_back_sections,case_variantsandspellings_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:
- Clean against main
5c54af20(tree61aab1ad):$Q/v118/w865f/r2/mt-origin-main.txt. - Clean against family.py add: the move back of a member's gitlink points its section of
.gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828's90649e53(tree8c4a385b):$Q/v118/w865f/r2/mt-90649e53efe873628a889f6d941ea64f33647df1.txt.
Main and #828 moved before the push, so both were fetched again at 05:39Z:
- Clean against main
3bf2d3c9(tree32ea287a):$Q/v118/w865f/r2/mt2-origin-main.txt. - Clean against family.py add: the move back of a member's gitlink points its section of
.gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828's head now,54b7699e(tree61aab1ad), a merge of main5c54af20intofix/761-add-move-back-gitmodules, of which90649e53is an ancestor:$Q/v118/w865f/r2/mt2-origin-fix-761-add-move-back-gitmodules.txt.
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_spellingsgoes up 3, and itsdefline is unchanged. - CI's git 2.55 and real macOS and Windows disks were not run.
|
Re-check of #865 (#840) fix round 2 at
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 Findings, most severe firstMUST 1:
SHOULD 1: K2 committed. The round's own update makes
NIT 1: NIT 2: RulingsN2 (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 (
NIT 4 (the guard): it is byte for byte #876's
Checked and trueMy 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:
Apart from K2 reset (refused, MUST 1) and K2 committed's hidden status (SHOULD 1):
Other closures.
Runs.
Restack.
Tree
Sonar-style.
Could not verify
The issue line for check 4The gap is real on main
Scratch left
|
…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>
… 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>
|
Fix round 3 of #865 (#840): There are two commits on There is no merge of #828 or main: the coordinator restacks afterwards.
1. MUST 1
Done.
Tests. All are in
Each then checks:
Two pins came in
The existing K2 reset state ( 2. SHOULD 1, K2 committed
I chose to name it in the same round.
Tests. K2 committed with work and K2 reset with work, in the table above. Their hand step is 3. NIT 1
Done. I measured with rv865b's estimator,
4. NIT 2
Done.
5. N2rv865b rules it acceptable as is, so nothing changed for it. Each new test fails on
|
| 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.
git merge-tree --write-tree 3546118bdebae1fdef5d373b5c82c295bd59f84b 4e92867a: clean, tree923bb23b($Q/v118/w865f/r3/mt-3546118b.txt).3546118bwas already in the object store.origin/main(def45de6), for information: conflicts in family.py add: the move back of a member's gitlink points its section of.gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828's own files,scripts/family.py,tests/test_family_add_member_variant.py,tests/test_family_add_move_back_sections.py(add/add) andtests/test_family_members_case_variants.py($Q/v118/w865f/r3/mt-origin-main.txt).9b0a7038, family.py add: the move back of a member's gitlink points its section of.gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828's squash, is an ancestor ofdef45de6. The conflicts are the squash against this branch's copy of family.py add: the move back of a member's gitlink points its section of.gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828's commits from before the squash, which the restack resolves.
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
$Q/v118/w865f/body3.md: the PR body frombody2.md, with a "Fix round 3" section. The changed tests, counts, mutants, runs, merge checks and sentences are updated, including the stacking line, now that family.py add: the move back of a member's gitlink points its section of.gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828 has landed.- This file.
Both pass /usr/bin/python3 $Q/v113/body-guard.py.
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>
|
Re-check of #865 (#840) fix round 3 and its restack at
Both of my findings from the FindingsNIT 1: C5, the own-spelling guard (
Checked and trueYour MUST 1 and SHOULD 1, closed. I typed 10 states as printed at
What they show:
C5. It survives only because nothing pins it. My ruling is NIT 1 above. The restack.
Runs at
Sonar-style.
Could not verify
Scratch left
|
…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>
|
|
Fix round 4 of #865 (#840): There is one commit on the coordinator's restack NIT 1, mutant C5
Done. The new test is The state:
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 (
It then types the text as printed, which gives C5 is caught by
Runs
Merge checkMain was fetched with
Push
Files
Both pass |
|
Re-check of #865 (#840) fix round 4 at 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.
Checked and true1. The test builds the state I described.
2. Runs at
3. The diff.
Scratch leftClones under
Basetemps are under |
|
Sorry @brettheap, your pull request is larger than the review limit of 150,000 diff characters |
…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>
…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>



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.gitmodulesare named first, so each remedy types clean (#840)Body
Was stacked on #828, which landed on main as
9b0a7038(its final head3546118b). GitHub retargeted this PR to main, and the branch is restacked onto it (6ef6e4c1).Goal (#840): where
addrefuses a member recorded in another case, no printed command leaves a section of.gitmodulesnaming another spelling, moves a live checkout out from under its.gitfile, 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 sameaddrun after. The link states were also typed on git 2.47.1. Every change is inscripts/family.py, which is not a pinned copy, so noupstream-changedrow 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 atMembers/IRRS, committed, with its skip-worktree bit set, andMembersa link tomembers, which makesMembers/IRRSresolve to the member's own checkout, as a disk that folds case would.The test asserts the whole text:
git rm -r --cached --sparse -- Members/IRRSand its commit;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,
adb20242and4e92867a, answer rv865b's findings. The round is two commits because the first was a pause's work in progress, and nothing is amended.git rm -r --cachednow takes-fwhere the move back's would. That is where the index stages the gitlink at a commit HEAD does not record there and a checkout is on disk at it (_forced, family.py add: the move back of a member's gitlink points its section of.gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828's rule). It prints the move back's own words on what-fdoes (FORCED_SAID, now one constant for both), and--sparse -fwhere both apply. At38e0d503, plaingit rm --cachedwas refused there (staged content different from both the file and the HEAD, exit 1), for (a) staged with an untracked file or a commit of its own, and for K2 reset with work in the checkout.git -C <path> status, before the update.38e0d503, K2 committed's update pointed the store that checkout shares with the member atmembers/IRRS, so the status printed nothing whileWIP.txtwas there. The next run then said to delete the checkout._members_spellingsis corrected and recounted (line 4).typed_as_printed'sdefline isf7072d08's again.Fix round 2 (rv865b's re-review, NOT READY at
f7072d08)The fifth commit,
38e0d503, answers rv865b's findings:git rm -r --cached -- <variant>, the gate's own line for a second path. It is no longer renamed withgit mv. Atf7072d08,git mvcarried the section that gives its path along under the same name, and the take-out then stripped it, sorenamed-1was a gitlink no section names. The gate passed, but a plain clone'sgit submodule update --init --recursiveexited 128, and that is the holder's own CI path (validate.yml). The checkout left on disk is the next run'sCHECKOUT, deleted by hand. See line 1._variant_in_membersasksos.path.islinkbeforesamefile, so a linkmembers/irrs -> IRRSis named again on Linux. See line 3..gitmodulesinto HEAD.make bootstrap exited 2, and the wrong-case move'sgit rm --cachedmay refuse #839's form,[ ! -L V ] && { [ ! -e V ] || [ V -ef R ] || [ -e V/.git ]; } || mv -- V ../<V>, with its words. That is the form lane 5's the family root's bootstrap.py and validate-family.py: the credential advice prints beside "not for want of a credential", a member's refusal and finding both readmake bootstrap exited 2, and the wrong-case move'sgit rm --cachedmay refuse #839 prints invalidate-family.py(77f8a6c8), so the two texts agree. A link at the variant is now moved in the first round. See the added state.3bf2d3c9.Fix round 1 (rv865's review, NOT READY at
4268f804)The third and fourth commits,
dde7e9ccandf7072d08, answer rv865's findings:--unset-alllines. Only the section named after the row's path is pointed back, and only where no section gives that path. See line 1._checked_gitmodules_as_stagedhas base'sdefline 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
.gitmodulesinto HEAD. At4268f804, a staged section edit beside a committedMembers/READMEleft the point-back staged, and the gate foundmember-gitmodules-absentin every mode.Line 1: a section that names a variant, with the gitlink at the row's path
Measured at
ce0f2a5f. The gitlink stays atmembers/IRRS, and the section is hand-edited toMembers/IRRS.addexits 0 and commits that.gitmodulesbeside the new member.gitmodules-path-unnamed, plusmember-gitmodules-absentonce committed.gitmodules-unstagedcomes first; typed, it leads to the staged state, andaddgoes through.members/irrsbehaves the same way.What changed.
_sections_astrayfinds 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_astraythen tells two cases apart, as the gate does:member-gitmodules-absentcase. The section named after the row's path, the nameaddgives a member's section, is pointed back with the gate's own line:git config -f .gitmodules submodule.members/IRRS.path members/IRRS, thengit 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.gitmodules-path-unnamedcallsSTRAY. It is taken out, every key it holds, with the gate's own lines:git config -f .gitmodules --unset-all submodule.Members/IRRS.pathand the same forurl, each key quoted for a POSIX shell, thengit add -- .gitmodules. Its place is "in .gitmodules, beside the section giving its row's path".git rm -r --cached -- Members/IRRS, as the gate's own line does (UNSTAGE_SECOND). It takes--sparsewhere the sparse rules leave it out or its bit is set, and-fwhere the index stages it at a commit HEAD does not record there beside a checkout (fix round 3). That comes before the take-out, andgit rm --cachedleaves the section for it. A checkout there thatgit submodule addorgit mvmade, on disk in its own spelling, is named to delete by hand in the same round, with itsgit -C <path> status, before the update (fix round 3). A clone of its own is not named: the next run moves it out whole.The code is
family-members-dir-variant, as for any other path under another spelling ofmembers/. Each part commits where HEAD's copy holds what it changes, or where a commit printed before it took a staged.gitmodulesinto 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, andgit commitwould 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 givesmembers/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: aftergit submodule add <IRSS's remote> Members/IRRS(kept, atmembers/irrs, or with its checkout deleted), or with the sectiongit rm --cachedleaves behind, the remedy typed as printed gavemembers/IRRStwice. 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/IRRSout, and that the gate passes. That held for a recursive clone only. In (a) and (b), the take-out ran aftergit mvof the same gitlink. It stripped the sectiongit mvhad carried along, and leftrenamed-1a gitlink no section names. A plain clone'sgit submodule update --init --recursivethen exited 128 (No url found for submodule path 'renamed-1' in .gitmodules), and so didgit submodule status, in the holder and in the clone. The gate cannot see this, since it asks only gitlinks undermembers/.Since fix round 2, that gitlink is taken out of the index and never renamed. Measured at
4e92867aon git 2.43 and 2.47.1, the five states and two more type clean:git rm --cachedleaves is deleted by hand where the text says (two rounds at38e0d503);members/IRRSout from IRRS's remote at its commit;git submodule update --init --recursiveexits 0 with every member checked out;git submodule statusexits 0 in the holder and in both clones;addgoes through.rv865b's four K2 states (the gitlink at
Members/IRRSbeside 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:
--sparse -f;Where the text says to delete the checkout, its
git -C Members/IRRS statusstill names the untracked file, and its commit is kept in its store.Line 2:
git mvof a directory moves an untracked checkout inside itMeasured at
ce0f2a5f.Members/X/notesis committed, and a checkout thatgit submodule addmade atMembers/X/Yis taken out of the index. #574'sgit mv -- Members/X <new name>typed as printed moved the checkout too.git -C renamed-1/Y statusthen saidfatal: 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 mvwith its.gitfile mended:git -C renamed-1/Y statusexits 0.What changed. Each checkout git does not track, inside a directory that a
git mv(RENAME) or a plainmv(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'sCHECKOUTsays of one found on disk. Its commits stay in the holder's store.git -C <checkout> statusshows what is not committed..gitentry, so a checkout's own legs are not named again..gitis a directory: the move takes it whole..gitentry.mvcase 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 plainMembers/Xholding such a clone and a link to a directory outside the holder. In both, onlymv -- Members/X <new name>is printed. #828'stest_a_checkout_under_an_index_entry_is_the_indexs_to_namedid not pin R7: that directory has no subdirectories.Line 3: a variant of a row's path under
members/itselfMeasured at
ce0f2a5f, beside the rowmembers/IRRS. In each of these statesaddexits 0, so a person meets nothing:members/irrsin the index;add: where another member's own gitlink sits at a case variant (Members/<P>for its rowmembers/<P>), the refusal's remedy printsgit rm -r --cachedorgit mv, which takes that member's gitlink away from its row #646's state undermembers/.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, andmember-gitlink-unnamedunder--no-members.What changed.
_spelled_targettakes the rows' paths. Undermembers/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.addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574's remedy.add: where another member's own gitlink sits at a case variant (Members/<P>for its rowmembers/<P>), the refusal's remedy printsgit rm -r --cachedorgit mv, which takes that member's gitlink away from its row #646's move back..gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828'sIN_GITMODULESput-back.git submodule addmade there, with its own section, is taken out of the index, its section is taken out, and its checkout is named to delete by hand in the same round (line 1, fix rounds 2 and 3). A second gitlink with nothing on disk is taken out of the index as before.The code is
family-members-dir-variant. Its first sentence says what was found (_lead): "a case variant of the path of amembers: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 withdisk_as, gives the same text.On a disk that folds case (rv865's SHOULD 1),
_variant_in_memberslets through an entry ofmembers/that the disk takes for the row's own path (os.path.samefile, as_members_dir_on_diskasks ofmembers/). macOS and Windows list a directory in the spelling it was made in. So aftergit mv members/IRRS members/irrsand line 3's move back, whose closing guard[ members/irrs -ef members/IRRS ]is true there, the member's only checkout is listed asmembers/irrs. At4268f804the nextaddrefused it as on disk and untracked, every time. Now it goes through. On Linux the two are two paths,members/IRRSholds nothing, andmembers/irrsis still named.A link is never the row's own (rv865b's NIT 1).
samefilefollows a link, so atf7072d08a linkmembers/irrs -> IRRSwas let through on Linux andaddwent through beside it;4268f804named it._variant_in_membersnow asksos.path.islinkfirst, as_members_dir_on_disknever takes a link formembers/. 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
.gitmodulesthe work tree also lacksMeasured at
ce0f2a5f.git mv members Membersis committed, then.gitmodulesis left unmerged (stages 1, 2 and 3) and deleted from the work tree._checked_gitmodules_as_stagedleaves a missing copy to git foradd, 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 saysNo url found.What changed.
_gitmodules_firstasks first. For this state it raisesgitmodules-missing(_gitmodules_missing) with the restore git takes there, asremoveprints 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_stagedhas thedefline base had,def _checked_gitmodules_as_staged(root: Path, verb: str) -> None:, and nospelledparameter. The missing-file refusal moved into_gitmodules_missing, which it raises forremoveand_gitmodules_firstraises foradd. By hand, its cognitive complexity went from 35 at4268f804(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_missingabout 12,_row_sections_astrayabout 8 (9 before fix round 2 dropped K2's clause),_variant_in_membersabout 9 (8 before NIT 1'sislink).Fix rounds 2 and 3, measured with rv865b's estimator (
cogc.py, comprehensions not counted):_second_gitlink2,_second_path_part5,_second_checkout_part2,_forced1,_removed2._point_back_part: up 1, to 3._spellings_refusal: down 2, from 38 atf7072d08to 36, since the removal's condition moved into_removed._move_back_line: unchanged at 9._members_spellings: from 41 atf7072d08to 45 at38e0d503, then 46 now. Round 2 added 4: itsif, nesting, itsand, andout = … or …. Round 3 adds 1, theandincheckout = present and …. The fix round 2 body said 3. Itsdefline is unchanged, and it was over 15 before this PR.typed_as_printed: 31, as atf7072d08, and itsdefline is that commit's again.Line 5: a deletion of
.gitmodulesalready stagedMeasured at
ce0f2a5f.git rm .gitmodules, with HEAD holding the file. The printed rename back, or move back, and its commit took.gitmodulesout of HEAD with every member's section. The gate then foundmember-gitmodules-absentfor each member.With
git rm --cached .gitmodules, a copy stays on disk, untracked:git mvstaged that copy again, and the result passed;What changed.
_gitmodules_besidetells the two apart:GITMODULES_DELETEDwhere the work tree has no copy, andGITMODULES_UNTRACKEDwhere it has one.git checkout HEAD -- .gitmodules, asremoveprints it and as family.py add: amembers/renamed withgit mvis renamed back in two steps through a free name, after what the disk lacks under it is put back, and a printed--sparsesays the git it needs, so no remedy fails as typed (#685) #767 does for a copy deleted from disk.gitmodules-unstagedcomes first ("its deletion is staged"), since which copy to keep is the person's.Both apply wherever anything is found, so a lone
Members/READMErename is covered too: its commit took the deletion as well.Line 6:
Membersa link to a directory outside the holderMeasured 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/IRSSexits 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 nextaddrefused again. Through the same link, plaingit mv -- Members/README xmoves 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 thosemember-path-unaddablehas formembersitself: "must be a plain directory of the holder's own"._on_disk_as_namednow 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 agit mvthat would fail once the link is gone (bad source).Typed as printed:
mvmoves nothing;The directory the link named is left where it is. A checkout there was unusable already, since its
.gitfile is relative.Line 7 (rv828's SHOULD 3): an unmerged file in the way of the move back
Measured at
ce0f2a5f. Aftergit mv members/IRRS Members/IRRS, an unmerged file at or undermembers/IRRSleft the row neither blocked nor a stray. So the gitlink kept #574'sgit mv -- Members/IRRS <new name>, which took it away from its row, and the commit failed.An unmerged file at
membersitself, off disk: eachgit update-indexran, and the commit failed. On disk,member-path-unaddableprintedgit mv -- members <new name>, which exits 128 (fatal: conflicted).What changed.
_rows_heldcounts a file or link at any stage as in the way._set_asidetakes an unmerged entry out of the index, every stage, withgit 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 withmv(ASIDE_UNMERGED_MV).mvis 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, whichgit statusthen lists as modified._members_is_a_filegives an unmergedmembersthe 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 linkMembers/IRRSon disk, pointing to the member's checkout or to nowhere. git 2.47.1 refusesgit statusitself:error: expected submodule path 'Members/IRRS' not to be a symbolic link.addreads nogit statuson this path, and gives its spelling refusal first: no gitfatal:and no traceback, foradd --dry-runand the real run alike.At
f7072d08, typed as printed on 2.47.1:[ Members/IRRS -ef members/IRRS ]follows it);addnamed it on disk, with itsmv;addwent 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 nextaddgoes 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_firstpins this. Its refusal check isrefused_untouched(..., status=False), sosnapshotskipsgit 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_printednow 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, withtyped_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 sameaddgoes through.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 atMembers/IRRSwithMembersa link tomembers, 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.gitmodulesgit mv, under anmv, a tracked gitlink's control, and a clone of its own moved whole, alone and beside a linkmembers/irrs -> IRRSmembersoff disk, andmemberson disk with and without HEAD's fileEvery 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):members/IRRSout from IRRS's remote at its commit;git submodule update --init --recursive, which must exit 0 with every member checked out;git submodule statusmust 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> statusstill names its untracked file.Changed:
tests/test_family_add_move_back_sections.py. Itsset_asidebuilds 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_untouchedtakes a keywordstatus=Trueand passes it to both of its snapshots. The link test calls it withstatus=False, and the narrower helper it had (refused_beside_a_link) is gone. In the third commit, its one literalSpelledtuple gains the newkeysfield, and in fix round 3 the newcheckoutfield.Changed, in fix round 2:
tests/test_family_add_member_variant.py.guardedand the closing words take the family root's bootstrap.py and validate-family.py: the credential advice prints beside "not for want of a credential", a member's refusal and finding both readmake bootstrap exited 2, and the wrong-case move'sgit rm --cachedmay refuse #839's form.typed_in_shtypes into the module'sSHELL,shunless a test setsdashorbashwithmonkeypatch(fix round 3). Itsdefline andtyped_as_printed's aref7072d08's, so the remedy's split is their only change (rv865b's NIT 2).;outside backticks (remedy_parts).No test needs a git newer than 2.34, but for the sparse case. The second gitlink left out by cone-mode rules needs git 2.38 or later (
sparse_cone, as #828's tests). So does the work state left out by those rules. The sparse cases skip where git has nogit 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'sfamily.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 against4268f804'sfamily.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)..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 againstf7072d08'sfamily.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).f7072d08: 11 of 16 failed (/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/bh-old243.log)..gitmoduleswas 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 leftstalein HEAD.members/irrswas let through.addwent through, in dash and in bash.38e0d503: 16 passed (/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/bh-head243.log).f7072d08are (c), (d), the point-back beside a committed take-out, the staged name that needs quoting (X1, X6:f7072d08already 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 against38e0d503'sfamily.py:/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)./tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243b.log)./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:38e0d503, all five fail (/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243b.log). Four stop atgit 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.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.shorrun9s.sh, with the realghunreachable and basetemps underv110/bt/./tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/final243.log4e92867a/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/final247.log4e92867a/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/final243.log38e0d503/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/final247.log38e0d503/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/probe-head-243.log38e0d503/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/probe-head-243.jsonl)/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/final243.logf7072d08/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/final247.logf7072d08/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/full243.logdde7e9cctest_family_add_*files, the trio,test_family.py,test_family_remove_gitmodules.py,test_family_head_lost_gitmodules.py,test_family_holder_states.py/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/full247.logdde7e9ccva.log2c97e759test_family_holder_states.py, the trio/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/vb1.log2c97e759family.py/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/vb2.log2c97e759The 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 touchesmember_varianttoo), the trio (test_repo_hygiene.py,test_agents_md_codes.py,test_cli_reference.py),test_family.py, and the three files that drive.gitmodulesrefusals foraddandremove. 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.git submodule statusexits 0 in the holder and in the recursive clone. Rounds 2 and 3 name the checkout left at the variant.addgoes through.stale(head_has_stale: false).members/irrs -> IRRSis refused (code 2), and so ismembers/irrs -> IRSS.--unset-alllines, 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
-xon the test that names its state. All 30 are caught. (The earlier text said 31.)_spelled_target: no row variant undermembers/addexits 0)_on_disk_as_named: on disk beyond a link_disk_spellings: no row variant on disk_sections_restored: no row variant for a staged removal_rows_held: an unmerged file not in the way_in_the_way: nothing unmerged_aside_remedy: an unmerged file on disk not movedaddexits 0)_disk_found: noLINK_OUT_checkouts_under: a tracked gitlink named_checkouts_under: a checkout entered (its legs named)_gitmodules_beside: no staged deletion_gitmodules_first: unmerged not asked first_gitmodules_first: an untracked copy not asked firstaddleaves a missing unmerged copy to git_members_is_a_file: unmerged taken for mergedmemberson disk_members_is_a_file: HEAD's file not committed out_lead: a row variant said as another spelling_point_back_part: the commit always printed_gitmodules_part: no put-back from HEAD_set_aside_part: unmerged not said_link_part: never said_checkout_first_part: never saidrefused_untouchedignoresstatusFix 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, ondde7e9cc'sfamily.py, whichf7072d08keeps, each-xon 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.f7072d08added that test)_committedignores the earlier commitpathkey taken out_variant_in_members: nosamefile_variant_in_members: a row variant on disk never named_gitmodules_first: nogitmodules-missingfor an unmerged copy off disk_gitmodules_missing: a staged copy taken for unmergedtest_family_remove_gitmodules.py_checked_gitmodules_as_staged:addno longer leaves a missing file to gittest_family_dry_run.py_sections_astray: an unreadable.gitmodulesraises there (rv865's R13).gitmodules_checkouts_under: a moved directory that is a repository entered.gita directory) named too.gitdirectory enteredmvalways a change to commit_lead: the row sentence alone_lead: the joined sentence without the row clauseFix round 2: 19 mutants of the round's new hunks in
scripts/family.py(N1 to N18 and N7b), on38e0d503, each run-xon 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_sectionsandcase_variantsfiles, which all passed: see "Not here"._second_gitlink: any entry, not only a gitlink_second_gitlink: the row's path need not hold a gitlink_second_gitlink: any gitlink while a row's gitlink is held, whatever its pathtest_a_gitlink_the_sparse_rules_leave_out_is_renamed_with_sparsegit mvagain--sparse--sparseonly where the rules leave it out--sparseonly where the bit is set_second_path_part:--sparsenever printed_second_path_part: the git--sparseneeds never said_removed: the second path's removal never a change to commit_removed: a pending change committed first no longer makes a removal onetest_every_place_is_named_and_the_pending_change_is_committed_first_removed: a removal said wherever anything is committed_variant_in_members: noislink(NIT 1)members/irrs_closing_sentences: #828's guard again-LtestFix round 3: 13 mutants of the round's new hunks in
scripts/family.py, on4e92867a, each run-xon 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 testtest_a_second_paths_checkout_is_asked_in_its_own_spellingcaught it (on231c404c).-fnever printed_forcedalways true-f-fnever said_forcedwithout the question whether a checkout is on disk_forcedwithout the question whether HEAD records it at that committest_family_add_move_back_sections.pyMembersa link tomembers(fix round 4)Merge checks
git merge-tree --write-treeof4e92867a, after a fetch of main at 08:38Z on 2026-10-18:.gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828's final head3546118b(tree923bb23b), the restack's first merge.def45de6, which holds family.py add: the move back of a member's gitlink points its section of.gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828's squash9b0a7038, conflicts in family.py add: the move back of a member's gitlink points its section of.gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828's own files:scripts/family.py,tests/test_family_add_member_variant.py,tests/test_family_add_move_back_sections.pyandtests/test_family_members_case_variants.py. This PR still holds family.py add: the move back of a member's gitlink points its section of.gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828's commits from before the squash, and the restack resolves those files.Fix round 2's check of
38e0d503was clean against main3bf2d3c9and5c54af20, and against #828's90649e53and54b7699e.Fix round 1's check of
f7072d08, at about 02:55Z, was clean against main4dea85ee,90649e53, and the open PRs that changescripts/family.py: #8144f6e7f83, #8462d0e884f, #849d98df568. 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.mdortemplates/family-root/*.mdstates the changed text or any of these codes.--helpis unchanged, and no finding code is new. The docstrings of the changed functions say what each now does.Not here
_wrong_case_moveis lane 5's the family root's bootstrap.py and validate-family.py: the credential advice prints beside "not for want of a credential", a member's refusal and finding both readmake bootstrap exited 2, and the wrong-case move'sgit rm --cachedmay refuse #839 (fix/839-holder-bootstrap-texts), whose guard this PR's text now matches. The validator is a pinned file and is not touched here. rv865b also found that typing the gate's own--unset-alllines after a hand rename of a second gitlink leaves the same orphan gitlink while the gate passes, since_check_gitlinks_namedasks only gitlinks undermembers/. That gap is the validator's, and this PR no longer prints that rename.-fon a folding disk. On a disk that folds case, a second gitlink marked off disk atMembers/IRRSresolves to the member's own checkout. So_forcedprints a harmless-fthere, as family.py add: the move back of a member's gitlink points its section of.gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828's move back does. The checkout question is asked in the path's own spelling (present), so no line says to delete that checkout. Fix round 4's test pins this withMembersa link tomembers, the folding emulationtests/test_validate_family_remedies_typed.pyuses (rv865b's round-3 NIT 1, mutant C5).members/IRRS, say) rather than a gitlink at it. No rule here decides whose gitlink the variant is there, so it keeps family.py:addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574's remedy. Mutant N2, which drops_second_gitlink's question whether the row's path holds a gitlink, survives for that reason. It differs only there, or where the row's path is an unmerged entry whose first stage is no gitlink, which is R21's state. Each of those is a state no rule rules on, and pinning either remedy would decide it..gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828's test pins.git rm --cachedof it may need--sparse. This was not measured, and no--sparseis printed for one.git mvandgit rmof a gitlink (family.pyadd: #574's spelling remedy gives a generic<new name>aftergit mv members Members,git rm --cachedfor a pulledMembers/README, and agit mv --sparsethat needs git 2.34 or later #685's text). For line 1 alone, the reason is thegit add -- .gitmodulesthat follows. The text is unchanged._members_is_a_filetakes HEAD'smembersof any kind for the file) differs only where HEAD holdsmembersas a tree, a link or a gitlink while the index holds an unmerged filemembers: a directory/file or link/file conflict that a merge may not even leave, and whether a link in HEAD should ask for the commit too is a question of its own. R21 (_rows_heldtakes an entry with any non-gitlink stage as in the way) differs only for an entry at the row's path unmerged with a gitlink at one stage and a file at another, made only byupdate-index; whose gitlink that is, and so which remedy is right, is not ruled.Checklist candidates
make bootstrap exited 2, and the wrong-case move'sgit rm --cachedmay refuse #839's form (rv865b's NIT 4)._sections_restored(family.py add: the move back of a member's gitlink points its section of.gitmodulesback, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closingmvis guarded and leaves a checkout to delete by hand, and--sparsefollows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) #828) points back every variant section of a member it moves back. It has the fault MUST 1 found in_sections_astray, for a member whose gitlink is at a variant: a second variant section, another submodule's, would be pointed back too, and the path given twice. Read from the code, not measured.refused_untouchedandsnapshotin the tests askgit status, which git 2.45.1 and later refuse while a gitlink the index holds is a symbolic link on disk.snapshotalready tookstatus=False.refused_untouchednow takes it too, and the link test passes it, so the full snapshot is kept exceptgit status. Mutant M27 fails the link test on git 2.47.1.Closes #840
Summary by Sourcery
Ensure
family.py addnames 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:
family.py addidentify and remediate additional leftover path, link, checkout,.gitmodules, and unmerged-entry states before printing commands that would fail or leave members inconsistent.Enhancements:
members/, astray.gitmodulessections, symlinks, untracked nested checkouts, staged.gitmodulesdeletions, and unmerged index entries.Tests:
git status.