Skip to content

family.py: add's refusal of a member's own gitlink held only under 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) - #670

Merged
brettheap merged 6 commits into
mainfrom
fix/646-add-member-variant-remedy
Oct 17, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

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

Goal (#646): no remedy family.py prints un-registers a member that is in the holder. Three states are outside it, each under "Left out": a .gitmodules that a git mv made name the variant too (checklist candidate (e)); a file the index holds at or under the row's path, where #574's remedy is kept; and a file or link the index holds at members itself (candidate (h)).

What was wrong

A member's only gitlink sat at Members/<P>, while its row and .gitmodules said members/<P>. This is #363's state after the fact. family.py add of ANOTHER member refused, as #574 rules (family-members-dir-variant). Its remedy was #574's remedy for any path under another spelling of members/:

  • git rm -r --cached -- Members/<P>, and commit the removal, where nothing is on disk in that spelling;
  • git mv -- Members/<P> <new name>, and commit the rename, where something is;
  • git commit, where HEAD alone holds it (the variant's removal staged, not committed).

Each one, typed as printed, left no gitlink at the row's path. The holder's validator then refused member-gitlink-absent. Reproduced on BASE a524542 in all five layouts below.

What changed

All in scripts/family.py.

The branch merges main 35133f2, which holds #639 (c6122e8), in one merge commit with no conflict.

Example: the issue's state, the checkout at members/IRRS:

REFUSED family-members-dir-variant: the family root holds a path at or under another spelling of members/, which a case-insensitive disk takes for members/ itself — in the index: `Members/IRRS` (a submodule)
Remediation: every member is mounted under members/, and a holder keeps that directory in one spelling on every disk. Move each member's gitlink held only under another spelling of its row's path, `Members/IRRS` for the row members/IRRS, to that path, at the commit it records, in the index alone, which touches no file (`git rm -r --cached --ignore-unmatch -- Members/IRRS`, then `git update-index --add --cacheinfo 160000,<commit>,members/IRRS`), and commit that; where nothing is checked out at members/IRRS, as in a fresh clone, `git submodule update --init --recursive` then checks the member out there; on a case-sensitive disk, as Linux's usually is, a directory left at Members/IRRS stays there, untracked, and add refuses it as family-members-dir-variant: move each one that is there out of the holder, `mv -- Members/IRRS ../Members-IRRS`; on macOS and Windows it is the row's own directory, so leave it; then re-run.

No pinned file changes: scripts/family.py is the standard's own tool, not a copy in a holder. No --help text changes, so docs/cli.md is untouched. A git grep of AGENTS.md, README.md, docs/handbook.html and the family-root templates found no sentence about add's case-variant remedies, so none became false.

Decisions and their evidence

  1. The member's own gitlink: one whose path folds to a row's path, where the index holds nothing at or under that path. The next commit records the index, and every remedy here ends in one.

  2. The commit named is the one the variant entry records, so the remedy is a move, not a bump. Brett Heap decided this at 2026-10-16T14:11:42Z: "take all recommendations for family.py: add's refusal of a member's own gitlink held only under 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) #670 and validate-family.py: a staged git rm is named again and fails retyped, the member-gitlink-absent remedy ends in a refused add, a shape-pin row with no path: reads None, and #639's mv orphans a live checkout and is stated by OS #681". That word replaces his 13:07:34Z take of "the row's pin".

  3. An unmerged variant is moved back at its stage-2 commit (RV670, N2). ls-files -s lists stage 1, the merge base, first, and the PR's first head took that one.

    • Stage 2 is HEAD's entry each time, so the move is a rename relative to HEAD, and the validator also answers an unmerged path from HEAD. In a merge and in a stash pop it is the holder's own side. In a rebase it is the upstream's side, which is HEAD while the rebase runs: after git rebase --quit the move names the upstream's commit, not the person's own, which is stage 3 (probed by rv670b, the re-verify of this PR).
    • The removal takes out every stage, and the commit after it goes through ($Q/v112/w646b/probes/w646b-unmerged-probe.log).
    • An unmerged variant with no gitlink at stage 2 is no member's own: HEAD's entry holds no gitlink there, whatever stages 1 and 3 hold. It keeps family.py: add refuses, before the member is read and on every disk, any path under another spelling of members/, 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, where stage 2 is missing and where it is a file. A gitlink at stage 1, a file at stage 2 and a gitlink at stage 3 is an OWN state too (rv670b's NIT-3): git lists stage 1 first, and rv670b's mutant R1, which read the first record's mode in place of stage 2's, passed all 36 tests and moved the file's blob back as a gitlink.
    • The state is reachable only with unmerged entries and no in-progress mark. A conflicted git stash pop leaves it, and so do git merge --quit and git rebase --quit (rv670b probed both). add refuses a merge, rebase, cherry-pick, revert or git am in progress (family-operation-in-progress) before it gets here.
  4. A variant a sparse checkout keeps off disk is taken out with --sparse (RV670, S1). Without it, git rm -r --cached --ignore-unmatch exits 0 and removes nothing, so the index then held two gitlinks, and the next add refused again. With it, the entry goes, measured on git 2.43 with and without core.sparseCheckout ($Q/v112/w646b/probes/w646b-sparse-probe-*.log). The flag is printed only where a variant needs it, so the line stays the validator's everywhere else. Git older than 2.34 has no git rm --sparse: see "Left out".

  5. The commit is printed only where one is left to make. That is:

    • HEAD holds a variant (RV670's S2 state, HEAD holding the gitlink in both spellings at one commit, is one of these);
    • HEAD's gitlink at the row's path is another commit, or there is none;
    • or a pending change is committed first.

    A move to the variant that is only staged, with HEAD still holding the row's gitlink at that commit, leaves nothing to commit, and a printed git commit would fail as typed.

  6. The code stays family-members-dir-variant (or member-path-taken where some path is also a variant of the new member's mount path). The add is still refused for this path, as for any path under another spelling of members/: with core.ignorecase set, git would stage the new gitlink as Members/<new> (family.py add cannot add any member, under core.ignorecase=true (the macOS and Windows default), to a holder whose index holds a path under a case variant of members/ itself such as Members/README, because git stages the new gitlink as Members/<P> and the commit's members/<P> matches nothing, and the remedy its closing sentence prints fails the same way every time: a policy question for the maintainer, whether add refuses such an index up front, and on which disks #362). Only the remedy changes.

  7. The closing sentences come last, after family.py: add refuses, before the member is read and on every disk, any path under another spelling of members/, 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 disk remedies.

    • Where a members/ renamed by hand is moved back (mv -- Members members), the checkout goes home with it.
    • If they came before that, the mv out of the holder would act on Members/<P> while it may still be the member's checkout. And git submodule update would make a members/, inside which mv -- Members members would then put Members (members/Members).
    • test_with_the_other_remedies…[members/ renamed by hand] holds the order: it types the move back and then git submodule update, which checks the member out in the members/ moved back.
  8. One text on every disk. The move reads the index and HEAD, never the disk. The dry run and the real run print the same two lines. Asked in process of an emulated folding disk and of a Linux one (disk_as, as tests/test_family_members_case_variants.py emulates it), each layout gives the same text the subprocess gives.

  9. The disk after the move.

Tests

All runs used $Q/v110/run9.sh: a gh stub that exits 97 first on PATH, GH_TOKEN=bogus-l5, an empty GH_CONFIG_DIR, /usr/bin/python3 -m pytest, and local bare remotes only.

tests/test_family_add_member_variant.py is new, with 37 tests. Every printed command is typed as printed. The holder's gate then passes (or, in the one case where the variant records a commit the row does not pin, names that drift), the member is where its row says at its commit, and the same add goes through.

  • test_each_layout_moves_the_gitlink_back_as_printed (4 layouts) and test_a_fresh_clone_moves_the_gitlink_back_as_printed.
  • test_every_disk_gives_the_same_answer (4 layouts × folding and Linux, in process).
  • test_with_the_other_remedies_each_runs_in_its_order (8 states):
  • test_every_member_held_in_the_wrong_case_is_moved_back (two rows).
  • test_the_gitlink_is_recorded_at_the_commit_it_records (4 states).
  • test_a_commit_the_row_does_not_pin_is_moved_not_bumped (S3): typed as printed, the gate exits 1 with member-gitlink-mismatch alone, and the same add goes through.
  • test_only_the_members_own_gitlink_is_moved_back (9 states, four of them new: a file at the row's path, a file under it, an unmerged variant with no gitlink at stage 2, and one with a gitlink at stage 1, a file at stage 2 and a gitlink at stage 3).
  • test_the_rows_that_have_a_path_of_their_own.

Layouts that need two directories differing only in case skip on a disk that folds case. No test makes such directories there.

Two existing lines change to the new shapes:

  • Spelled has two more fields, recorded and sparse, so a seven-field tuple compared in tests/test_family_members_case_variants.py gains "", False;
  • the three-argument stand-in for _members_spellings in tests/test_family_add_refusals.py takes rows.

The S1 case sits in the new file, beside its other move-back states, and not in test_family_members_case_variants.py beside #574's sparse test: it needs the new file's helpers, and that file already imports from the other one.

Results on this head. a837a02 is 2104a3d plus one test commit: it adds one state to OWN and changes no code, so the runs of 2104a3d below stand for every file it does not touch.

Run Head Result
the new file a837a02 37 passed in 48.55s
tests/test_family_members_case_variants.py a837a02 60 passed in 53.83s
the new file, with scripts/family.py put back to the merge's (bc48c2a) 2104a3d's tests 5 failed, 31 passed in 170.09s: the sparse and unmerged states typed, and the OWN cases of a file at the row's path, a file under it, and an unmerged variant with no gitlink at stage 2
the new file, test_family_members_case_variants.py, test_family_add_refusals.py 2104a3d 161 passed in 809.30s
test_family.py, test_family_member_path.py, test_validate_family_unnamed_mounts.py, test_readme_windows_quoting_sentence.py, test_repo_hygiene.py 2104a3d 269 passed in 803.63s

The S2 and S3 cases pass on the merge too, since they pin behaviour the PR already had. Lane 3's mutant X2 fails the S2 case (below). The S3 case holds the variant's-commit rule end to end.

Earlier, at the PR's first heads (before the merge with main):

Run Head Result
the new file, on BASE a524542 a524542 29 failed in 48.75s
every tests/test_family*.py, 21 files 2216e1c 851 passed, 19 skipped in 1248.90s
tests/test_validate_family*.py (5), test_refusal_default_remediation.py, test_repo_hygiene.py, test_siblings_family_name.py, test_template_text_placeholders.py, test_clone_url_bytes.py, test_update_shape_header_comments.py, test_update_shape_pr_base.py, test_shape_doctor.py 2216e1c 1014 passed, 3 skipped in 1323.30s

Reproduction (local bare remotes, the gh stub)

Holder Fam with members Atlas and Nova, Atlas's gitlink moved to Members/Atlas in the index at its own commit and committed; then family.py add of Zed ($Q/v110/w646/probes/repro.sh), at the PR's first head.

Layout BASE a524542 (repro-base.log) head 79754b9 (repro-head.log)
L1, nothing on disk in that spelling git rm -r --cached -- Members/Atlas, commit: no gitlink for Atlas; gate member-gitlink-absent rm, update-index, commit: Atlas at members/Atlas at its commit; status clean; gate exit 0 in all three modes; next add --dry-run exit 0
L2, the only checkout at Members/Atlas git mv -- Members/Atlas renamed-1, commit: gitlink at renamed-1; gate member-gitlink-absent rm, update-index, commit, git submodule update --init --recursive, mv out: the same end state
L3, both directories git mv, commit: gitlink at renamed-1; ?? members/Atlas/; gate member-gitlink-absent rm, update-index, commit, mv out: the same end state
L4, a fresh recursive clone (an empty Members/Atlas) git mv, commit: gitlink at renamed-1; gate member-gitlink-absent rm, update-index, commit, git submodule update, mv out: the same end state
L5, the variant's removal staged, not committed git commit: the removal committed; gate member-gitlink-absent rm (--ignore-unmatch, no error), update-index, commit: the same end state

In every layout, on both sides, the dry run and the real run printed the same stderr, and the in-process run under a folding disk and under a Linux disk printed the same two lines as the subprocess. This round's states (S1, S2, S3, N2, N3) are reproduced by the tests above, which type each printed line.

Not reproduced: a real macOS or Windows disk. The folding disk is emulated. CI runs Linux only (#597).

Mutation probes

This round ($Q/v112/w646b/probes/w646b-mutate.py, in throwaway clones at 2104a3d). Each probe changed one hunk or condition, and the new file was then run with -x. Every hunk of this round fails a test, and so do lane 3's X1 and X2 and rv670b's R1, which needed a new state (last row).

Probe What it undoes Result First failing test
M1 a variant never marked sparse 1 failed, 19 passed test_with_the_other_remedies…[kept off disk by a sparse checkout]
M2 --sparse never printed 1 failed, 19 passed the same
M3 back to "no GITLINK at the row's path" (the first heads' rule) 1 failed, 31 passed test_only_the_members_own_gitlink…[a file at the row's path]
M4 = lane 3's X1 "no entry AT the row's path", under it not asked 1 failed, 32 passed …[a file under the row's path]
M5 the first record git lists (stage 1), as the first heads had it 1 failed, 20 passed test_with_the_other_remedies…[unmerged, with nothing in progress]
M6 stage 0 alone: an unmerged variant is never the member's own 1 failed, 20 passed the same
M7 the last record (stage 3) 1 failed, 20 passed the same
lane 3's X2 name in in_head or deleted 1 failed, 18 passed test_with_the_other_remedies…[HEAD holding both, the row's removal staged]
lane 3's X3 'posix' dropped from the move back's three quotings 36 passed (survives) none on Linux: shell_quoted quotes for os.name, POSIX here (see "PowerShell" under "Left out")
rv670b's R1 ours[0] == b"160000" replaced by the mode of the first record git lists (mode == 0o160000), in place of stage 2's 36 passed (survived) at 2104a3d. At a837a02: 1 failed, 34 passed in 44.07s (-x), and 1 failed, 8 passed, 28 deselected for -k test_only_the_members_own_gitlink test_only_the_members_own_gitlink…[unmerged, with a file at stage 2 and gitlinks at 1 and 3]

The S3 test types the remedy end to end and asserts the gate's finding, so it holds the variant's-commit rule itself; no mutant of this round touches that rule.

The first heads ($Q/v110/w646/probes/mutate.py, in a throwaway clone at 2216e1c). The new file was run with -x.

Probe What it undoes Result at 2216e1c First failing test
H1 RESTORE in REMEDY_ORDER 1 failed test_each_layout…[nothing on disk in that spelling]
H2 no member's own gitlink found 1 failed the same
H3 a gitlink at the row's path not asked 1 failed, 22 passed test_only_the_members_own_gitlink…[a second gitlink beside the row's]
H4 any index entry, not a gitlink alone 1 failed, 23 passed …[a file at the variant]
H5 the index's branch 1 failed test_each_layout…[nothing on disk in that spelling]
H6 the commit always printed 1 failed, 15 passed test_with_the_other_remedies…[with nothing left to commit]
H7 the commit printed only where HEAD holds the variant 28 passed (survived) killed at 79754b9: test_the_gitlink_is_recorded…[staged at another commit, HEAD keeping the row's]
H8 the commit read from HEAD's gitlink at the row's path first 28 passed (survived) killed at 79754b9, by the same case
H9 HEAD's branch 1 failed, 3 passed test_each_layout…[the removal staged, not committed]
H10 HEAD's file taken for a gitlink 1 failed, 25 passed test_only_the_members_own_gitlink…[a file at the variant in HEAD alone]
H11 the disk named again at a gitlink moved back from HEAD 1 failed, 17 passed test_with_the_other_remedies…[HEAD alone, the checkout at the variant]
H12 the commit not printed after a pending change 1 failed, 14 passed …[after a pending change in HEAD]
H13 no --ignore-unmatch 1 failed test_each_layout…[nothing on disk in that spelling]
H14 the last variant found names the commit 1 failed, 20 passed test_the_gitlink_is_recorded…[two variants in the index]
H15 the closing sentences before the disk's remedies 1 failed, 16 passed test_with_the_other_remedies…[members/ renamed by hand]
H16 the closing sentences dropped 1 failed test_each_layout…[nothing on disk in that spelling]
H17 add passes no rows 1 failed the same
H18 _checked_mount_path drops the rows 1 failed the same
H19 _rows_paths takes any row's path 1 failed, 27 passed test_the_rows_that_have_a_path_of_their_own
H20 _rows_paths does not check the project's type 1 failed, 27 passed the same
H21 the move's part dropped from the text 1 failed test_each_layout…[nothing on disk in that spelling]

Merge checks

The branch merges main 35133f2 in bc48c2a, with no conflict: its tree, 088aa263, is the one merge-tree predicted. Only #637, #622 and #639 had changed scripts/family.py since BASE.

git merge-tree --write-tree of the head a837a02, last after a fetch at about 18:45Z. Every one is clean.

Left out, and why

Checklist candidates

Closes #646

🤖 Generated with Claude Code

Summary by Sourcery

Restore members' case-variant gitlinks to their declared row paths in the index so family.py add remedies no longer unregister existing members.

New Features:

  • Restore a member's gitlink to its row path in the index when it exists only under a case-variant path.
  • Print disk-specific follow-up guidance after restoring the gitlink, including submodule checkout and leftover-directory handling.

Bug Fixes:

  • Prevent the add refusal remedy from unregistering a member whose only gitlink is stored under another spelling of its row path.
  • Handle sparse-checkout and unmerged-index variants without leaving duplicate or missing gitlinks.
  • Preserve the gitlink commit recorded by the variant instead of silently changing the member pin.

Enhancements:

  • Distinguish a member's own case-variant gitlink from separate conflicting files, directories, or gitlinks that should retain existing remedies.
  • Apply the restoration remedy consistently across multiple rows and ensure remedy ordering remains safe with other index and disk repairs.

Tests:

  • Add comprehensive coverage for case-variant gitlink restoration across index, HEAD-only, sparse, staged, unmerged, multi-row, fresh-clone, and filesystem case-folding scenarios.
  • Verify every printed remediation command can be applied successfully and that existing refusal and case-variant tests continue to pass.

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

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

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

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

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

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

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

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

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

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-16T12:56Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 16, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates family.py add refusal remediation so a member gitlink held only under a case variant is moved back to its row path in the index at its recorded commit, rather than being removed or renamed away, with disk-aware follow-up guidance and extensive regression tests.

Sequence diagram for restoring a case-variant member gitlink

sequenceDiagram
    participant User
    participant FamilyAdd as family.py add
    participant Index as Git index
    participant HEAD as HEAD
    participant Disk as Worktree

    User->>FamilyAdd: add member
    FamilyAdd->>Index: inspect row path and case variants
    FamilyAdd->>HEAD: read variant gitlink commit when needed
    FamilyAdd-->>User: refuse with RESTORE remediation
    User->>Index: git rm -r --cached --ignore-unmatch variant
    User->>Index: git update-index --add --cacheinfo 160000,commit,row_path
    opt pending change remains
        User->>HEAD: git commit
    end
    opt row path is not checked out
        User->>Disk: git submodule update --init --recursive
    end
Loading

Flow diagram for family add case-variant remediation

flowchart TD
    A[family.py add detects member path variant] --> B{Gitlink at row path?}
    B -->|Yes| C[Keep existing refusal remediation]
    B -->|No| D{Variant holds only the member gitlink?}
    D -->|No| C
    D -->|Yes| E[Select recorded commit from index or HEAD]
    E --> F[Remove variant from index without touching disk]
    F --> G[Record gitlink at row path with git update-index]
    G --> H{Commit needed?}
    H -->|Yes| I[Commit index change]
    H -->|No| J[Continue]
    I --> K[Apply disk-specific follow-up guidance]
    J --> K
    K --> L[Re-run family.py add]
Loading

File-Level Changes

Change Details Files
Detect and restore a member’s own case-variant gitlink in the index without removing it from the member row.
  • Track row paths and distinguish the member’s sole variant gitlink from unrelated files, directories, or duplicate gitlinks.
  • Extend spelling metadata with the recorded gitlink commit and add the RESTORE remedy ordering.
  • Handle both index entries and HEAD-only variants, selecting the index entry before HEAD when multiple variants exist.
  • Emit index-only removal and cacheinfo commands, with a commit only when required.
scripts/family.py
Make remediation output account for checkout state and preserve the ordering of existing remedies.
  • Append conditional submodule initialization and case-sensitive disk cleanup guidance after other remedies.
  • Avoid reporting a moved-back checkout a second time as untracked.
  • Keep the existing refusal codes while changing only the printed remedy for the affected state.
scripts/family.py
Add comprehensive regression coverage for wrong-case member gitlinks and update affected test contracts.
  • Cover index, HEAD-only, staged, multiple-variant, disk-layout, fresh-clone, pending-change, and renamed-members scenarios.
  • Verify dry-run and real-run output match across folding and case-sensitive disk simulations, and that printed commands restore the holder.
  • Update the spelling tuple expectations and monkeypatched helper signature for the new row-path parameter.
tests/test_family_add_member_variant.py
tests/test_family_add_refusals.py
tests/test_family_members_case_variants.py

Assessment against linked issues

Issue Objective Addressed Explanation
#646 When family.py add refuses because a member’s only gitlink is at a case variant of that member’s row path, print a remedy that restores the gitlink to the row path in the index without un-registering it. ✅
#646 Keep the existing remedies for variant paths that are not the member’s own gitlink, and preserve correct handling of index/HEAD state, commits, and disk layouts when composing the refusal remedy. ✅

Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@chatgpt-codex-connector

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

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T13:03:16.994830Z 79754b9 Manual request
ℹ️ About Codex in GitHub

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

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

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 79754b996d

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread scripts/family.py Outdated
Comment on lines +2798 to +2800
# Each row's path the index holds no gitlink at, by its folded spelling:
# a gitlink at another spelling of it is that member's own.
strays = {_folded(row): row for row in rows if row not in gitlinks}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check row-path occupancy before restoring the gitlink

When a row path contains a tracked regular file (or entries below it) while a case-variant path contains a gitlink, strays still identifies that gitlink as the member's own solely because the exact path has no gitlink. The printed git update-index --add --cacheinfo then silently replaces a regular file at the row path—and tells the operator to commit that replacement—or fails when descendants exist. Only offer this restoration when the canonical row path has no index entries; otherwise refuse without a destructive remedy. A family member path must remain a submodule mount under members/.

AGENTS.md reference: AGENTS.md:L367-L368

Useful? React with 👍 / 👎.

Comment thread scripts/family.py Outdated
Comment on lines +2971 to +2974
"move each member's gitlink held only under another spelling of "
f"its row's path, {pairs}, to that path, at the commit it "
"records, in the index alone, which touches no file (`git rm -r "
f"--cached --ignore-unmatch -- {taken_out}`, then {recorded})"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Repair the submodule registration before moving the gitlink

When the wrong-case state was created with git mv members/P Members/P, Git also changes the section's path in .gitmodules to Members/P. This remediation moves only the gitlink back to members/P, so the subsequently printed git submodule update --init --recursive fails because no registered URL exists for the canonical path, and the holder remains invalid. The remedy must restore and stage the .gitmodules path alongside the gitlink before claiming the member is repaired.

AGENTS.md reference: AGENTS.md:L367-L368

Useful? React with 👍 / 👎.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-16T13:21:13Z) Lane 3's independent review of #670 at 79754b99 (RV670, at lane 5's request on #473): READY. There is no MUST, and three SHOULDs.

The pin question: keep the variant entry's commit

  1. The variant equals the pin. The bytes are the same, so it makes no difference.
  2. The variant differs from the pin.
    • The variant's commit: the move is a pure rename. The gate then reports member-gitlink-mismatch, which has its own LOCKSTEP and bump remedy, and exits 1. An add of another member still goes through.
    • The pin: the paste silently changes the commit the holder records. That is a roll-back or an advance, a bump done outside bump, which the LOCKSTEP text says the gate refuses.
  3. The row has no pin. The validator never prints this remedy for such a row, because member-pin or member-pin-commit stops the row first. So validate-family.py and family.py: a gitlink no section of HEAD's .gitmodules gives is member-gitmodules-absent, remove refuses a holder with no .gitmodules before deinit, and two gitlink remedies name what they missed (#611) #639's rule has no answer there, and a pin-based add would need a fallback to the variant's commit anyway.
  4. The pinned commit is absent from the member's clone. update-index --cacheinfo does not check that the object exists. The printed git submodule update --init --recursive then fails with rc=128: Fetched in submodule path 'members/IRRS', but it did not contain 3333…. The variant's commit is already recorded, so the remedy adds no new unreachable commit.

For the PR body:

SHOULD

  • S1. A sparse-checkout variant makes the printed line a silent no-op, and then two gitlinks get committed. scripts/family.py:2810-2820 takes the continue before sparse is computed (:2825), and the line is built at :2969-2974.
    • Scenario: the variant is marked skip-worktree, as a sparse checkout leaves it.
      1. git rm -r --cached --ignore-unmatch -- Members/IRRS exits 0 and removes nothing.
      2. update-index --add adds members/IRRS.
      3. The index then holds both S 160000 … Members/IRRS and H 160000 … members/IRRS.
      4. The next add --dry-run refuses with git mv --sparse -- Members/IRRS <new name>.
    • Fix: for a skip-worktree variant, print git rm -r --cached --sparse --ignore-unmatch -- …. Measured on git 2.43: it removes the entry, and the gate passes. Add a test beside the sparse one at tests/test_family_members_case_variants.py:330.
    • Body: "Left out" says git rm --cached "refuses". With --ignore-unmatch it succeeds and does nothing.
    • The validator's own line has the same flaw on main. That is for Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459.
  • S2. The clause name in in_head or at scripts/family.py:2818 is untested. A mutant deleting it leaves 29 passed.
    • The state that tells them apart: HEAD holds both Members/IRRS and members/IRRS at one commit, and the index has the row's deletion staged plus the variant.
    • The real code prints ", and commit that".
    • The mutant prints no commit, so D Members/IRRS stays staged and the next add refuses with "Commit the pending change first".
    • Fix: add that state to COMMITS or WITH_OTHERS in tests/test_family_add_member_variant.py.
  • S3. The pin/variant difference is pinned by text only. test_the_gitlink_is_recorded_at_the_commit_it_records[the variant at a commit the row does not pin] asserts the printed line, but no test types the remedy in that state. Assert the end state: gate exit 1 with member-gitlink-mismatch, and add still goes through.

NIT

Also checked

  • SonarCloud (public API): the gate is OK, new duplication is 0.0 and maintainability is A. The one new violation, S3776 at scripts/family.py:3129 (_checked_mount_path, complexity 30), is main's existing finding, re-flagged because the def line moved.
  • Windows newline traps: none new.
  • Docs: nothing in README, AGENTS.md, the handbook or the family-root templates describes add's case-variant remedies.

Tests run

All runs used a stub gh, GH_TOKEN=bogus and an empty GH_CONFIG_DIR.

  • The new file at the head: 29 passed. The same file on the base: 29 failed.
  • test_family_members_case_variants and test_family_add_refusals: 125 passed.
  • The predicted merge with c6122e8: 431 passed.
  • Mutants X1, X2 and X3: each survived (29 passed).

Lane: openreposhape-3 (openRepoShape-3)

Brett Heap and others added 2 commits October 16, 2026 13:54
 (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>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-16T14:13:15Z) Brett Heap ruled on #670's commit rule: the restore keeps the variant's own commit.

His word, in lane 5's session at 2026-10-16T14:11:42Z: "take all recommendations for #670 and #681". The recommendation it takes is the one lane 3's RV670 measured. It replaces his 13:07:34Z take on the row's pin.

The fix round for RV670's S1-S3 and N1-N6 is running now. Lane 4, your review is welcome on the round's head when it is pushed.

Lane: openreposhape-5 (openRepoShape-5)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-16T17:14:45Z) #670's fix round for lane 3's RV670 is pushed: head 2104a3d0.

Tests on 2104a3d0: 161 passed and 269 passed. The new tests fail 5 on the merge's own family.py, and each mutant of the round fails a test. Lane 5's own opus re-verify is running now. Lane 3 and lane 4, a look at 2104a3d0 is welcome.

Lane: openreposhape-5 (openRepoShape-5)

…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>
This was referenced Oct 16, 2026
@sonarqubecloud

Copy link
Copy Markdown

@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

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

You can request another review in 13 hours and 9 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 1eeef3b into main Oct 17, 2026
5 checks passed
@brettheap
brettheap deleted the fix/646-add-member-variant-remedy branch October 17, 2026 11:38
brettheap pushed a commit that referenced this pull request Oct 17, 2026
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	scripts/family.py
#	tests/test_family_add_member_variant.py
#	tests/test_family_members_case_variants.py
brettheap added a commit that referenced this pull request Oct 17, 2026
…e no bytecode under templates/, the launcher guard reads family.py's siblings child, the quoting-sentence inventory follows an aliased quoter, a suffixless tool and each tool's calling functions, and an S5778 pytest.raises builds its argument first (#665) (#724)

Four guards held less than they said. One commit, `151a0861`, on `8062810d`. No shipped behaviour changes except one environment variable in the generator's `--help` children. No `--help` text changes, so `docs/cli.md` is not regenerated. No quoting changes.

1. **`scripts/render-cli-reference.py`: `child_env()` sets `PYTHONDONTWRITEBYTECODE=1`.** The template's `siblings.py` does `from bootstrap import ...` from its own directory, so the generator's `--help` child wrote `templates/family-root/scripts/__pycache__/bootstrap.cpython-312.pyc` (ignored by git, so silent). A person who runs the generator now gets the same tree as the test that runs it. The docstring says why. `tests/test_cli_reference.py` gets one test: the variable is in `child_env()` whatever the caller had, and an interpreter started under that environment reports `sys.dont_write_bytecode` as true.
2. **`tests/test_validator_child_encoding.py`: the launcher guard sees `family.py`.** `interpreter_runs` accepts `[sys.executable or PYTHON, ...]` as well as `[sys.executable, ...]`, and `LAUNCHERS` gains `"scripts/family.py": "inherited_child_environment"`. Dropping `env=` from `family.py siblings`' child (#214) now fails the guard.
3. **`tests/test_readme_windows_quoting_sentence.py`: the inventory holds what it claims.** It follows `from repo_shape import shell_line as joined` (and `joined = shell_line`, however many steps). It scans every file `git ls-files` lists (tracked, or new and not ignored) that is a `.py` file or starts with a `python` shebang, which adds the root `bootstrap`. It pins functions, not files, for the two tools the sentence names by particular lines: `scripts/family.py` (`_print_next`, `_switch_command`, `_add_command`, `_print_next_steps`, `_checked_mount_path`) and `setup-project.py` (`family_next_command`). `shape-doctor.py` stays every line, because the sentence says "`shape-doctor.py`'s lines". Only the bodies of `shell_quoted` and `shell_line` in `scripts/repo_shape.py` are exempt, by name; the rest of that file is not. Nothing about quoting changes: the Git Bash dialect (option (a)) is its own v1.1.1 issue, and when it moves `NAMED`, this table moves with it.
4. **`tests/test_bump_leg_paste_lines.py`: Sonar S5778.** `placement = bump_leg_module.leg_placement(leg)` before the `with pytest.raises(LegNotAsFound)` in `test_pasting_the_status_line_reads_that_leg`, as the test above it does. Five lines; the hunk is :401-405, away from #657's hunks.

**The reproduction (`.pyc`).** In a fresh shared clone at `8062810d`, with `PYTHONDONTWRITEBYTECODE` unset, `python3 scripts/render-cli-reference.py --check` printed `[ok] docs/cli.md is what the generator produces` and left five `.pyc` files, among them `templates/family-root/scripts/__pycache__/bootstrap.cpython-312.pyc` at 11533 bytes (the size the issue reports). The same command on `151a0861` printed the same `[ok]` line and left none (`find . -name '*.pyc'` found 0).

**Mutants.** Each is applied to a copy at the head's test files; "old" is the same mutant under `8062810d`'s own tests.

| Mutant | New tests | Old tests |
|---|---|---|
| M0: drop `PYTHONDONTWRITEBYTECODE` from `child_env()` (the base's behaviour) | `test_child_env_leaves_no_bytecode_in_the_trees_it_runs` fails: `KeyError: 'PYTHONDONTWRITEBYTECODE'` | none to run |
| M1: drop `env=` from `family.py`'s `siblings` child (`scripts/family.py:6543`) | `test_the_tools_that_run_the_projects_validators_give_them_an_environment[scripts/family.py]` fails: ``scripts/family.py:6543 runs the interpreter without `env=inherited_child_environment()` `` | 55 passed (survives) |
| V2: `from repo_shape import shell_line as joined` and a call, in `update-shape.py` | `test_the_tools_that_quote_for_the_host_...` fails: files are `[family, setup-project, shape-doctor, update-shape]` | 3 passed (survives) |
| V3: `family.py`'s `_switch_command` calls `shell_line(..., "posix")` | `test_the_functions_that_quote_for_the_host_...[scripts/family.py]` fails: `_switch_command` missing from the calling functions | 3 passed (survives) |
| V5: a host-quoter call in the suffixless `bootstrap` | the file test fails: `bootstrap` is now among the files | 3 passed (survives) |
| V6 (the exemption): a third function of `scripts/repo_shape.py` calls `shell_line` | the file test fails: `scripts/repo_shape.py` is now among the files | 3 passed (survives) |

Each new failure is one test, and the other tests of the file stay green (`1 failed, 8 passed`; `1 failed, 55 passed`). V2, V3 and V5 are R588's mutants as the issue describes them.

On `151a0861`, one run, `run9.sh` (gh stub first on PATH, `GH_TOKEN` bogus, empty `GH_CONFIG_DIR`):

`tests/test_cli_reference.py tests/test_validator_child_encoding.py tests/test_readme_windows_quoting_sentence.py tests/test_bump_leg_paste_lines.py tests/test_family_siblings_placement.py tests/test_repo_hygiene.py`: **227 passed in 698.59s**.

Per file: `test_cli_reference.py` 23 (one new), `test_validator_child_encoding.py` 56 (one new case, `[scripts/family.py]`), `test_readme_windows_quoting_sentence.py` 9 (6 new: the `scripts/family.py` and `setup-project.py` function pins, the alias, the method name, the exemption, the suffixless file), `test_bump_leg_paste_lines.py` 33, `test_family_siblings_placement.py` and `test_repo_hygiene.py` 106 together.

- **origin/main `35133f29`** (includes #657 as `78fe3fff`, which edits `tests/test_bump_leg_paste_lines.py` at :53, :235-244 and `setup-project.py`): clean. On the predicted merge commit, `test_readme_windows_quoting_sentence.py` and `test_validator_child_encoding.py` pass (65 passed), and so does `test_bump_leg_paste_lines.py` (33 passed). `family_next_command` is still the only function in `setup-project.py` that calls `shell_word`.
- **#630** (`5b140bd3`): conflicts in `docs/handbook.html` only, and it does so against `8062810d` and `35133f29` without this commit, so not this change's. It changes `scripts/render-cli-reference.py`, `docs/cli.md` and `tests/test_repo_hygiene.py`; none of those hunks overlaps mine, and `merge-tree` reports no conflict in them.
- **Lane 4's `family.py` work:** the heads I could fetch are #670 (`pr670`) and #671; `fix/664-surviving-mutants` and `fix/696-setup-family-folder` are on origin too. Clean with each. Lane 4's other `family.py` branches (#684, #686, #688, #689, #693, #674) are not on origin and not in the shared `.git`, so I could not check them. Predicted merges with #670, #671 and lane 5's `fix/646` pass the two guards' files (65 passed each).
- **Other open heads touching a tool the inventory reads:** #703 and `fix/696` (`setup-project.py`), #699 (`adopt-project.py`), #700, #701, #702 and #705 (`shape-doctor.py`), `fix/664`: clean, and the inventory file passes on each predicted merge (9 passed each). #682, #698 and #706: clean.

- **No quoting change.** The Git Bash dialect (option (a)) is a separate v1.1.1 issue; the inventory only holds what the sentence says today.
- **`docs/release-notes/v1.0.0.md:532`** says the `.pyc` is left by `test_cli_reference.py`'s children. That is true of v1.0.0 and I left it. The v1.1.0 notes (#630) may want one line.
- **`--others` in the file listing.** A new, untracked tool is scanned before it is added, as `rglob` did; a tracked file deleted from the work tree is skipped.

- **A quoter handed on as a value is not a call.** `map(shell_quoted, words)` or `functools.partial(shell_line)` is not seen by the inventory (said in `host_calls`' docstring). Nothing does it today.
- **The function pin counts functions, not calls.** A function with two host-dialect calls where only one is switched to POSIX still passes. Today the one-call functions are `_switch_command`, `_print_next`, `_checked_mount_path`.
- **`_print_next_steps` prints a `cd {shell_quoted(work)}` line** the sentence's list (`NEXT`, `switch -c`, `add`, `git rm -r --cached`) does not name. It belongs to the Git Bash dialect issue.
- **The launcher guard's `LAUNCHERS` still has no row for `src/openreposhape/cli.py` (`run_child([sys.executable, ...], env)`) or `setup-project.py` (its own `run` with a `PYTHON` variable).** Both spell the child differently from `subprocess.run([sys.executable, ...])`.

Review: lane 5's independent review approved 151a086 with nits only, and codex reviewed it. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 151a086 passed tests, tests-git-bash and SonarCloud; the head 949fd4d is a clean update-branch merge of main bf1f099, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 9cd4767 over main 974d482: the bump_leg_paste_lines, cli_reference, readme_windows_quoting_sentence, validator_child_encoding, adopt_plan_leg_path_help, repo_hygiene, agents_md_codes sets, 290 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #665

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
main gained #706 (#664), #703 (#696), #670 (#646), #682 (#648), #726
(#667), #730 (#666) and #724 (#665) since bf1f099. No conflict: #682's
README sentence and its handbook twin (Families) merged as text, with
#682's own Families marker, and every marker reads current. README.md is
1474 lines, its cap; AGENTS.md 494, its cap; `render-cli-reference.py
--check` is ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…d A3 (three SHOULDs, four nits), and notes entries for the seven pull requests landed since bf1f099, current to main 2000488 (#563, #683)

Round A4, from lane 2's review (#630 issuecomment-6233033833, READY):
- SHOULD 1: the doctor's exit-3 list names "a validator of its own that
  crashed or could not be run", so the case of one that cannot be given
  its `PYTHONPATH` (shape-doctor.py's EXIT CODES, #278) is listed too:
  README.md:1208-1222, its handbook twin (`doctor`) and the notes' docs
  line. The paragraph re-wraps to one more line:
  tests/readme-cap.d/683.txt is +2, and its text now says which causes
  the EXIT CODES block names and that #742 added the partial clone and
  #701 the naming policy. README.md is 1475 lines, its cap (1422 + 2 + 5
  + 12 + 22 + 10 + 2). Both `Keeping a project's shape current` markers
  move to c728c970: `doctor` text and marker, `current` marker only.
- SHOULD 2: #727's entry names the leg description, whose em dash stopped
  every `execute` against GitHub at the first leg.
- SHOULD 3: #749's entry names `source-ambiguous`, for an `org/repo` that
  is also a directory here.
- Nits: the doctor section's three near-duplicate pairs are merged (#636
  with #737, #632 with #700 and #705, #586 with #736); #718 has its own
  bullet; #634's entry names `git_out`'s spelling for every caller and
  #744's the gate's `--link-source-label` and `--link-remedy`; the header
  comment says 2026-10-17 at main 2000488.

Wave B1, `git log --first-parent bf1f099..2000488`: #706 (#664), #703
(#696), #670 (#646), #682 (#648), #726 (#667), #730 (#666) and #724
(#665), each from its squash commit; #726's and #682's from lane 5's lines
(#630 issuecomment-6227129879), #682's with the unmerged `.gitmodules`
its last round added, and #724's with lane 5's note that v1.0.0's
test-hygiene line on the generator's `.pyc` files closes. The counts (106
pull requests; 116 of 135 milestone issues closed at 11:53Z), "current to
main" and the open list (19) are at main 2000488, still placeholders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…two steps through a free name, after what the disk lacks under it is put back, and a printed `--sparse` says the git it needs, so no remedy fails as typed (#685) (#767)

Stacked on #670 (`fix/646-add-member-variant-remedy`, `a837a023`); retarget to main once #670 lands. Once #670 squash-lands, this branch conflicts with main in `scripts/family.py`, `tests/test_family_add_member_variant.py` and `tests/test_family_members_case_variants.py`, as any stacked PR does. Resolve it by the stacked-PR procedure: merge #670's final head, then main, taking this head's side for #670's files. Predicted at bbb961f: #670's squash onto main bf1f099 (scratch commit e8938e8c) conflicts with this head in those three files, and `merge-tree -X ours` resolves it to tree 760eeb7, the tree of this head merged with main.

**Goal (#685): every command an `add` refusal prints can be typed as printed, with the arguments it needs, in the state it describes, on the git the estate supports.** The issue's three #574 lines are all in `_spellings_refusal`, which #670 rewrote. Line 1 changes the remedy, line 3 changes two sentences, and line 2 is closed by a test, with no change to its remedy.

### What was wrong

`git mv members Members` renames each member's checkout, its gitlink and its section of `.gitmodules` (`path = Members/IRRS`). It also points each gitdir's `core.worktree` at `Members/IRRS`. Measured on git 2.43.

The issue saw #574's generic `git mv -- Members/IRRS <new name>` on main. On #670's base, both gitlinks are members' own gitlinks (the index holds nothing at `members/IRRS` or `members/IRSS`), so `add` prints #646's move back instead. That move puts each gitlink back at `members/<P>` in the index alone, and leaves each section naming `Members/<P>`. Typed as printed (`$Q/v114/w685/probes/line1.out`, at a837a02):

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

This is #670's checklist candidate (e), for a whole `members/` renamed with git.

### What changed (`scripts/family.py`)

- **A new remedy, `RENAME_BACK`,** placed in `REMEDY_ORDER` after the index's own remedies and before the move back. It renames the spelling back in two steps through a free name:

  ```
  git mv -- Members members.renaming
  git mv -- members.renaming members
  ```

  and commits, where a commit is left to make.
- **The free name** (`_free_name`) is `members.renaming`, or the first of `members.renaming-2`, `-3` and on, that no index entry is at or under (asked in any case) and that the disk does not hold (asked as the disk resolves a name).
- **`_renamed_with_git` says when it applies.** Every one of these must hold:
  - the index holds nothing at or under `members` itself (a file `members` included);
  - everything the index holds under another spelling is under ONE spelling;
  - none of it is a case variant of the mount path;
  - some of it is a member's own gitlink (#646's test);
  - HEAD holds no member's gitlink under another spelling that the index no longer does;
  - no `members/` is on this disk, or the one there is that spelling's own directory (`samefile`), as on macOS and Windows;
  - neither that spelling nor `members` is a link, which is never `members/` itself (`_members_dir_on_disk`'s own rule; rv685's MUST-B and SHOULD-A);
  - every entry under the spelling is at stage 0, and none is kept off disk by a sparse checkout;
  - every entry under the spelling is on disk as the index holds it (a gitlink as a directory, anything else not), as the disk resolves the name, or can be put back first.
- **Putting back what the disk lacks** (rv685's MUST-2, direction (b)). `git mv` of a directory says `bad source` for any entry under it that is not on disk. So before the two steps the text says to put each such entry back:
  - a file: `git checkout -- <file>`;
  - a gitlink: `git submodule update --init --recursive -- <path>`, where a section of `.gitmodules` with a url gives that path. After `git mv members Members` the sections name `Members/<P>`, so this types clean.
  - Where a missing gitlink has no such section, or the disk holds a path in a form the index does not (a file where a checkout was, a directory where a file is), nothing is renamed back. The move back is printed, as before (direction (a)).
- **The work tree's `.gitmodules` comes first** (rv685's SHOULD-1, then MUST-A). Wherever the index holds a gitlink the refusal names, `_checked_mount_path` asks `_gitmodules_beside`, which tells the copy apart as `gitmodules-unstaged` tells it:
  - **modified** (the index holds it at stage 0, the work tree has a copy, and `git ls-files -m` lists it): the text says first to stage it, or set it aside (`git add -- .gitmodules`). `git mv` and `git rm` of a gitlink refuse to run beside such a change (`Please stage your changes to .gitmodules or stash them to proceed`), and #646's move back died there too.
  - **missing** (stage 0, no work-tree copy): the text says first to put back the index's copy (`git checkout -- .gitmodules`). `git mv` rewrites a moved member's section only in the work tree's copy, and a `git add` would stage the deletion, which the printed commit would then take out of HEAD with every section.
  - **unmerged** (any stage other than 0): `gitmodules-unstaged` is raised first, with its own restore ("resolve the conflict in it and stage the result, `git add .gitmodules`"), since a `git add` would stage the conflict markers and `git mv` then says `bad config line`. Only this state moves a refusal, and only beside a gitlink.
  - **nothing**: no copy in the index, one a sparse checkout keeps off disk (`gitmodules-sparse`'s), or one as the index holds it.
- What the disk holds under the spelling goes back with it, so it is not named as untracked.

The refusal's text for the staged state:

```
REFUSED family-members-dir-variant: the family root holds a path at or under another spelling of members/, which a case-insensitive disk takes for members/ itself — in the index: `Members/IRRS` (a submodule), `Members/IRSS` (a submodule)
Remediation: every member is mounted under members/, and a holder keeps that directory in one spelling on every disk. Rename `Members` back to members/, since it holds the gitlinks of members whose rows place them under members/, in two steps through a name nothing holds, as macOS and Windows take `Members` for members/ itself: `git mv` takes each checkout, and each section of .gitmodules that names it, with it (`git mv -- Members members.renaming`, then `git mv -- members.renaming members`); then re-run.
```

With one checkout gone, the part begins: "Put back what the index holds under `Members` and the disk does not, `git submodule update --init --recursive -- Members/IRSS`, then rename …".

The code stays `family-members-dir-variant`. Only the remedy changes.

### Why two steps, and not `git mv -- Members members` or `mkdir members` with a `git mv` per member

All three were probed on Linux, staged and committed, and the first two in a fresh recursive clone too. Each typed clean, and left `.gitmodules` and `core.worktree` at `members/`, the gate passing in every mode, and `add` going through (`probes/test_w685_line1.py`; rv685's `two-step.txt`).

- **On a disk that folds case, only the two steps can work.** From reading git v2.43.0's `builtin/mv.c` (not run: this host is case-sensitive):
  - `git mv -- Members members` takes `members` for an existing directory, makes the destination `members/Members`, and asks the disk to rename a directory into itself, which it refuses.
  - `git mv -- Members/IRRS members/IRRS` makes the destination `members/IRRS/IRRS`, and `mkdir members` fails with `File exists`.
  - In the two steps, neither destination exists when its step runs.
- **So one text serves every disk for the renamed state**, and the folding disk no longer gets #646's move back with its section gap. That closes the first head's checklist candidate (e′) wherever this text is printed. #646's own state is not one text: with every member's gitlink moved in the index alone and the checkouts at `members/`, Linux gets the move back (`Members` is not on its disk), and the folding emulation gets the two steps, where `git mv` warns `Could not find section in .gitmodules` and ends right (from reading the source; rv685's NIT 1). `test_every_disk_gets_the_same_rename_back` asks the same layout of both disks in process (`disk_as`). `test_a_folding_disk_that_received_it_renames_it_back` asks the state a pull leaves there: the index at `Members/`, the directory spelled `members`. A folding disk gets the two steps; Linux gets the move back, since `Members` is not on its disk at all.
- **The per-member moves** are the `git mv` to the row's path that #670 set aside. They also left an empty `Members/` in each probe, where the directory's rename takes untracked files with it.
- **The cost** is one more line, and a name that must be free, which `_free_name` finds.

On a disk that folds case, holding `members/`, a pulled commit that adds `Members/README` puts the file at `members/README`. The remedy is `git rm -r --cached -- Members/README`, and commit the removal, because nothing is on disk in that spelling. The issue called it benign and asked for the remedy to be worded for that disk or verified there.

**Verified on the emulated disk. The remedy is not changed.** `test_a_folding_disk_that_pulled_members_readme_takes_it_out` (`DISKS`: `core.ignorecase` false and true):

- the file is written at `members/README` as bytes, and the index and HEAD hold `Members/README`;
- the dry run and the real run refuse alike, and each disk, asked in process (`disk_as`), gives the subprocess's text;
- typed as printed: the file stays on disk byte for byte at `members/README`, untracked; the gate passes in every mode; `add` goes through; the gate passes again;
- committing `members/README` afterwards still gives `family ok`.

Why the remedy was not worded as a committed `git mv` for that disk: `git mv -- Members/README <new name>` works only where the disk folds case. No test or CI job here can type it: this host is case-sensitive, and CI runs Linux alone (#597). Wording it would also need a lookup that follows the disk's folding in `_on_disk_as_named`, which #574's review (B1) made exact on purpose. The printed line types clean, touches no file, and leaves `add` and the gate passing. rv685 agreed the emulation is enough to close the line.

- **`git mv --sparse` (#574) says "which needs git 2.38 or later"** (`SPARSE_MV_GIT`). git took `git mv --sparse` in 2.34, but up to 2.37 a single source that a sparse checkout keeps off disk still fails `bad source` before `--sparse` is read. The remedy names exactly one such entry.
  - `builtin/mv.c` is byte-identical from v2.34.0 to v2.37.7 (sha256 `30d476b9…`, checked here for v2.34.0 and v2.37.7; rv685 checked seven tags).
  - In that file, `if (lstat(src, &st) < 0) { if (modes[i] != SPARSE) bad = _("bad source");`, and nothing marks a single skip-worktree source `SPARSE`.
  - v2.38.0 adds that lookup, and its t7002 first has `can move out-of-cone file with --sparse`.
  - Read from source, not run: only git 2.43 is here. (rv685's MUST-1. The first head said 2.34.)
- **The move back's `git rm --sparse` (#646's S1) says "which needs git 2.34 or later"** (`SPARSE_RM_GIT`). v2.33.0's `builtin/rm.c` has no `--sparse`, and v2.34.0's does.
- **#574's sparse test** asserts its text on every git, and types `git mv --sparse` only on git 2.38 or later.

**No older way is named, from a probe on git 2.43 alone** (`probes/sparse-probe.log`, `sparse-probe-rm.log`; rv685's `sparse.log` agrees). `git update-index --no-skip-worktree -- Members/README`, then `git mv`, fails `bad source` (exit 128), both for an entry marked by hand and in a real cone-mode sparse checkout. With `git checkout --` between them, it works for the entry marked by hand, and `git mv` still refuses in the real checkout (exit 1). For the move back's removal, `git rm --cached` after `--no-skip-worktree` exits 0 and removes nothing in the real checkout. Those refusals are 2.34's sparse-checkout checks. On an older git the older way may well work: v2.33.0's `mv.c` has no sparse code at all, and its `rm.c` skips only skip-worktree entries. No older git was run.

**The second sentence goes past the issue's text.** The issue names `git mv --sparse`. #670 added `git rm --sparse` to its move back after #685 was filed, and its body lists the same limit under "Left out". rv685 agreed to keep the phrase there.

The estate states no minimum git, and none is set here.

No pinned file changes: `templates/family-root/scripts/validate-family.py` is untouched. No `--help` text changes. `render-cli-reference.py --check` passes. A `git grep` of README.md, AGENTS.md, `docs/` and `templates/` finds no sentence about these remedies, so none became false.

All runs used `$Q/v110/run9.sh`: a `gh` stub that exits 97 first on PATH, `GH_TOKEN=bogus-l5`, an empty `GH_CONFIG_DIR`, `/usr/bin/python3 -m pytest`, and local bare remotes only. Git 2.43.0.

`tests/test_family_add_spellings_remedies.py` is new, with 45 tests. Every printed command is typed as printed (`typed_as_printed`, #646's; `type_remedy`, #574's). Then the gate passes in every mode (`assert_passes_in_every_mode`), the same `add` goes through, and the gate passes again.

- `test_members_renamed_with_git_mv_is_renamed_back_as_printed`, 12 states:
  - staged; committed; staged with `core.ignorecase=true`;
  - committed with a file of its own under `Members/`;
  - after a pending change in HEAD, where the first commit takes the rename with it;
  - with the free name taken (a tracked `members.renaming`), through `members.renaming-2`;
  - one checkout gone; an empty `Members/`; nothing of it on disk (`git submodule update … --` puts back the gitlinks first);
  - a committed file deleted from disk (`git checkout --` puts it back first);
  - a change to `.gitmodules` nobody staged (`git add -- .gitmodules` first);
  - a `.gitmodules` deleted from disk (`git checkout -- .gitmodules` first, never `git add`).

  Each checks every member at its row's path and commit, and HEAD's `family.yaml` and `.gitmodules` bytes as the holder began. A `git submodule update --recursive` is typed with `protocol.file.allow=always`, since it clones a member's legs from the fixture's local paths.
- `test_an_untracked_file_in_it_goes_back_with_it`.
- `test_a_fresh_clone_of_it_is_renamed_back_as_printed`, now with the gate after `add` (rv685's NIT 2).
- `test_every_disk_gets_the_same_rename_back` (Linux and folding, in process).
- `test_a_folding_disk_that_received_it_renames_it_back` (Linux and folding, in process).
- `test_only_members_renamed_whole_is_renamed_back`, 13 states where it is not printed:
  - a `members/` on disk beside it;
  - an entry the index holds under `members/`;
  - a file `members` in the index (rv685's NIT 1, mutant R8);
  - another spelling in the index too;
  - a case variant of the mount path under it;
  - an entry under it kept off disk;
  - a member's gitlink HEAD alone holds;
  - an entry under it unmerged (SHOULD-1);
  - a checkout gone that no section names there (MUST-2's fallback);
  - a file where a checkout was;
  - a directory where a file is;
  - a link `members -> Members` (MUST-B);
  - `Members` a link to a directory outside the holder (SHOULD-A).
- `test_nothing_in_it_a_members_own_keeps_its_own_remedy`.
- `test_the_free_name_is_one_nothing_holds` (4 states).
- `test_what_gitmodules_needs_first_is_said_beside_a_gitlink` (3): a change beside #646's move back (staged first); a change beside a file alone (nothing said); a copy a sparse checkout keeps off disk (nothing said).
- `test_no_gitmodules_in_the_index_adds_nothing_first`.
- `test_a_missing_gitmodules_is_put_back_before_the_move_back`: #646's state with `.gitmodules` deleted, typed through; HEAD keeps every section, the gate passes in every mode, and `add` goes through with every member registered.
- `test_an_unmerged_gitmodules_is_named_first`: the rename committed and `.gitmodules` unmerged with conflict markers. `add` refuses `gitmodules-unstaged` first, with the index left as it was. Resolved to this side and staged as printed, the rename back is printed and typed through, to the gate in every mode and `add`.
- `test_a_folding_disk_that_pulled_members_readme_takes_it_out` (`DISKS`, 2).
- `test_the_move_backs_sparse_says_the_git_it_needs`.

Two existing files change:

- `tests/test_family_members_case_variants.py`:
  - `index_part(sparse=…)` says 2.38 (`NEEDS_GIT_2_38`);
  - #574's sparse test asserts the text on every git and types it only on git 2.38 or later;
  - the eleven-field `Spelled` its non-UTF-8 test compares gains `"", False`, for the two new fields `through` and `put_back`.
- `tests/test_family_add_member_variant.py`: `moved_back(sparse=True)` says 2.34 (`NEEDS_GIT_2_34`).

The states that need `git mv members Members` skip on a disk that folds case, where that command fails. No test makes two directories that differ only in case.

Results at the head, bbb961f (`$Q/v114/w685/fix/`). Every run used its tree, uncommitted then and committed unchanged.

| Run | Result |
|---|---|
| the new file | `45 passed in 38.99s` (`new3.log`) |
| the new file, `test_family_members_case_variants.py`, `test_family_add_member_variant.py`, `test_family_add_refusals.py` | `207 passed in 142.83s` (`mod2.log`) |
| rv685's v2 probe file (`test_rv685_v2.py`, 22 probes that log and assert nothing), in a scratch clone with this head's `family.py` | `1 failed, 21 passed`: the one is the unmerged `.gitmodules` probe, whose helper expects the spelling refusal and now meets `gitmodules-unstaged`, as intended (`rv685-v2-probes.txt`) |

At 215e810 (fix round 1, `family.py` changed since only in the `.gitmodules` question and the link check): every `tests/test_family*.py`, 24 files, `961 passed, 19 skipped in 627.44s` (`batchA.log`), and the four files above on its merge with main e313701, `200 passed in 167.47s` (`merge-main.log`). Not re-run at this head. At the first head: `tests/test_validate_family*.py` (5), `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py` and eleven more, `1085 passed in 398.99s` (`$Q/v114/w685/batchB.log`). `render-cli-reference.py --check` passes on this head (`[ok] docs/cli.md is what the generator produces`).

From rv685's v2 probes at this head: the deleted `.gitmodules`, in the renamed state and in #646's, now types through with HEAD keeping every section, the gate passing in all three modes, and `add` going through. Both link states get the move back, as at 70307d2 (see "Left out").

The three test files at this head, over a837a02's `scripts/family.py` (`$Q/v114/w685/fix/base3.log`): `30 failed, 112 passed in 80.55s`.

- Line 1: the 12 typed states, the untracked file, the fresh clone, both disks of `test_every_disk_gets_the_same_rename_back`, the folding disk of `test_a_folding_disk_that_received_it_renames_it_back`, the 4 free-name cases, the change beside a gitlink, the missing `.gitmodules` before the move back, and the unmerged `.gitmodules`.
- Line 3: `test_the_move_backs_sparse_says_the_git_it_needs`, #574's sparse test, the two disks of `test_each_disk_answers_for_the_directory_it_holds[and every remedy the index takes]`, and #646's `test_with_the_other_remedies_each_runs_in_its_order[kept off disk by a sparse checkout]`.
- The two new `Spelled` fields: `test_a_name_head_holds_that_is_not_utf8_is_shown_replaced`.

These pass on base by design: line 2's two tests; the Linux disk of the received state; the 13 states of `test_only_members_renamed_whole_is_renamed_back`; `test_nothing_in_it_a_members_own_keeps_its_own_remedy`; the change beside a file alone; the copy a sparse checkout keeps off disk; and the holder with no `.gitmodules` in its index.

The five tests this round adds, over 215e810's `family.py` (`prev215.log`): `5 failed, 40 passed in 37.27s`. They are the deleted `.gitmodules` state, both link states, the missing `.gitmodules` before the move back, and the unmerged `.gitmodules`.

`$Q/v114/w685/fix/mutate2.py`, in a throwaway clone at a837a02 with this head's three test files. Each probe changes one hunk or condition of this head's `scripts/family.py`, then runs the named file with `-x` (`mutants.log`). All 36 of fix round 1 are caught at 215e810: the first head's 18 (M8 replaced by the stage check, M2 split three ways, M14b added), rv685's R4, R7 (here M2c) and R8, and one or more per new hunk (N1 to N13).

| Probe | What it undoes | Result | First failing test |
|---|---|---|---|
| M1 | `_renamed_with_git` never asked | 1 failed | `…renamed_back_as_printed[staged]` |
| M2 | the `members/` on disk not asked | 1 failed, 15 passed | `test_a_folding_disk_that_received_it_renames_it_back[linux]` |
| M2b | `samefile` taken as true | 1 failed, 17 passed | `test_only_members_renamed_whole…[a members/ on disk beside it]` |
| M2c (rv685's R7) | only `canon`'s exact spelling | 1 failed, 16 passed | `test_a_folding_disk_that_received_it_renames_it_back[folding]` |
| M3 | the index under `members/` not asked | 1 failed, 18 passed | `…[an entry the index holds under members/]` |
| R8 | a file `members` not asked | 1 failed, 19 passed | `…[a file at members in the index]` |
| M4 | one spelling not asked | 1 failed, 20 passed | `…[another spelling in the index too]` |
| M4b | only the last entry's spelling read | 1 failed, 20 passed | the same |
| M5 | a member's own gitlink not asked | 1 failed, 28 passed | `test_nothing_in_it_a_members_own_keeps_its_own_remedy` |
| R4 | every entry must be a member's own | 1 failed, 3 passed | `…renamed_back_as_printed[committed, with a file of its own]` |
| M6 | a variant of the mount path not asked | 1 failed, 21 passed | `…[a case variant of the mount path under it]` |
| M7 | a gitlink HEAD alone holds not asked | 1 failed, 23 passed | `…[a member's gitlink HEAD alone holds]` |
| M8 | stages not asked | 1 failed, 24 passed | `…[an entry under it unmerged]` |
| M9 | skip-worktree under it not asked | 1 failed, 22 passed | `…[an entry under it a sparse checkout keeps off disk]` |
| M10 | the remedy not replaced | 1 failed | `…renamed_back_as_printed[staged]` |
| M11 | the disk under the spelling still named | 1 failed, 11 passed | `test_an_untracked_file_in_it_goes_back_with_it` |
| M12 | no commit after a pending change | 1 failed, 4 passed | `…[after a pending change in HEAD]` |
| M13 | the commit always printed | 1 failed | `…renamed_back_as_printed[staged]` |
| M14 | `git mv --sparse` without the git it needs | 1 failed, 13 passed | `test_an_entry_a_sparse_checkout_keeps_off_disk_takes_git_mv_sparse` |
| M14b | `git mv --sparse` said to need 2.34 | 1 failed, 13 passed | the same |
| M15 | the move back's `--sparse` without it | 1 failed, 37 passed | `test_the_move_backs_sparse_says_the_git_it_needs` |
| M16 | the git it needs said without `--sparse` | 1 failed | #646's `test_each_layout…[nothing on disk in that spelling]` |
| M17 | `RENAME_BACK` not in `REMEDY_ORDER` | 1 failed | `…renamed_back_as_printed[staged]` |
| N1 | the free name: the index not asked | 1 failed, 33 passed | `test_the_free_name…[an entry in the index, in another case]` |
| N2 | the free name: the disk not asked | 1 failed, 32 passed | `test_the_free_name…[a file on disk]` |
| N3 | the free name: the index asked in one case | 1 failed, 33 passed | `…[an entry in the index, in another case]` |
| N4 | nothing put back | 1 failed, 6 passed | `…renamed_back_as_printed[one checkout gone]` |
| N5 | a gitlink no section names put back | 1 failed, 25 passed | `…[a checkout gone that no section names there]` |
| N6 | a path held in another form put back | 1 failed, 26 passed | `…[a file where a checkout was]` |
| N7 | a gitlink: anything there counts | 1 failed, 26 passed | the same |
| N8 | a file: anything there counts | 1 failed, 27 passed | `…[a directory where a file is]` |
| N9 | files not put back | 1 failed, 9 passed | `…[a committed file deleted from disk]` |
| N10 | the stage-first part never said | 1 failed, 10 passed | `…[a change to .gitmodules nobody staged]` |
| N11 | the stage-first part beside any entry | 1 failed, 30 passed | `test_a_change_to_gitmodules…[beside a file alone]` |
| N12 | the caller never asks `.gitmodules` | 1 failed, 10 passed | `…[a change to .gitmodules nobody staged]` |
| N13 | one step, not two | 1 failed | `…renamed_back_as_printed[staged]` |

**Fix round 2** (`mutate3.py`, `mutants3.log`), at this head, one per new hunk or branch, each `-x` on the new file. All 11 are caught.

| Probe | What it undoes | Result | First failing test |
|---|---|---|---|
| G1 | an unmerged copy not told apart | 1 failed, 37 passed | `test_an_unmerged_gitmodules_is_named_first` |
| G2 | a missing copy not told apart (so `ls-files -m` calls it modified) | 1 failed, 11 passed | `…renamed_back_as_printed[a .gitmodules deleted from disk]` |
| G3 | a modified copy not told apart | 1 failed, 10 passed | `…[a change to .gitmodules nobody staged]` |
| G4 | no copy in the index not asked | 1 failed, 35 passed | `test_no_gitmodules_in_the_index_adds_nothing_first` |
| G5 | a copy kept off disk not asked | 1 failed, 34 passed | `test_what_gitmodules_needs_first…[a copy a sparse checkout keeps off disk]` |
| G6 | asked beside any entry, not a gitlink alone | 1 failed, 33 passed | `…[a change beside a file alone]` |
| G7 | an unmerged copy not named first | 1 failed, 37 passed | `test_an_unmerged_gitmodules_is_named_first` |
| G8 | the restore part never said | 1 failed, 11 passed | `…[a .gitmodules deleted from disk]` |
| G9 | the stage part never said | 1 failed, 10 passed | `…[a change to .gitmodules nobody staged]` |
| L1 | a link spelling not asked | 1 failed, 30 passed | `test_only_members_renamed_whole…[Members a link to a directory outside]` |
| L2 | a link `members` not asked | 1 failed, 29 passed | `…[a link members to Members]` |

The `_replace` also resets `recorded` and `sparse` to their empty values, as `Spelled`'s docstring gives them for every remedy but the move back. Nothing reads them for `RENAME_BACK`, so leaving them is not observable.

`git merge-tree --write-tree` of the head bbb961f, after a fetch at about 01:35Z on 2026-10-17. Every one is clean.

- origin/main bf1f099 (tree 760eeb7). Since e313701 it landed one `shape-doctor.py` change.
- #670 itself, `fix/646-add-member-variant-remedy` a837a02.
- Lane 4's branches that change `scripts/family.py`: `fix/684-family-holder-states` 7015386, `fix/686-bump-reachability` 7567e60, `fix/688-family-init-values` aa95b6b, `fix/689-next-push-refspec` fa51384 and `fix/693-local-remote-dir` 96ebe45.
- Lane 5's `fix/692-raw-folder-paths` ab55c29 and `fix/681-validate-family-remedies` 58d6c50.

After #670 squash-lands, the conflict and its resolution are as the first paragraph says, predicted again at this head after the fetch at 01:39Z: the three files conflict, and `-X ours` gives 760eeb7, the same tree as this head merged with main.

- **The same state after a hand `mv Members members`** on Linux (the index still at `Members/`). `Members` is not on that disk, and `members/` is a second spelling `git mv` cannot reach, so the rule does not apply. #646's move back is printed, with candidate (e)'s section gap.
- **A member's gitlink staged for removal beside a staged `git mv members Members`** (rv685's candidate 1). HEAD holds `members/<P>` and the index none, so `_members_spellings` never sees it. The rename back leaves that member's section at `Members/<P>`. `add` then goes through and commits that `.gitmodules`, and the gate refuses. Not changed here.
- **#574's plain `git mv` in a cone-mode sparse checkout** (rv685's candidate 2). Where an entry under `Members/` is on disk with its skip-worktree bit cleared, `git mv` refuses it as outside the sparse definition (exit 1, as the 3-step in `sparse.log` shows). Not run through `add`.
- **An untracked case variant of the mount path under the renamed directory** (`Members/irqs`) goes back with it, to `members/irqs`. The next `add` names it there, with #574's `mv`. Not run.
- **A file staged under `Members/` that HEAD does not hold** goes back with the rest, to `members/<file>`, still staged. No commit is printed for it, since it is the holder's own change, not the remedy's. Not run.
- **A gitlink put back from a section whose gitdir is not in `.git/modules`.** `git submodule update` then clones it from its url, which may need the network and a credential. Every state tested here has the gitdir.
- **The two link states get #646's move back, which has its own gaps there** (as at 70307d2 and on the base). With a link `members -> Members`, the move back leaves candidate (e)'s section gap, and `add` then refuses `member-path-unaddable` for the link itself, which names it. With `Members` a link to a directory outside, the move back's `git rm --cached` fails through the link (`not a git repository: Members/IRRS/../../.git/modules/members/IRRS`). rv685's v2 probes show both.
- **An unmerged `.gitmodules` the work tree lacks.** `gitmodules-unstaged` leaves a missing copy to `git submodule add` for `add`, so it raises nothing there, and the spelling refusal says nothing of the file. Not run.
- **A deletion of `.gitmodules` the holder already staged** is committed by any printed commit, as on the base. Nothing here stages it.
- **Git older than 2.38 (or 2.34 for `git rm`)**: the sentences say what is needed. No such git is here to run them on.

- **Round 1** (215e810, rv685 on 70307d2). `git mv --sparse` says 2.38 (MUST-1). What the disk lacks under the spelling is put back first (MUST-2). Unmerged entries keep the move back, and a change to `.gitmodules` nobody staged is staged first (SHOULD-1). The older-way comment says what was measured (SHOULD-2). The rename back is two steps through a free name, one text on every disk for the renamed state (SHOULD-3). Plus the NITs.
- **Round 2** (bbb961f, rv685 on 215e810).
  - MUST-A: `_gitmodules_beside` tells the work tree's `.gitmodules` apart as `gitmodules-unstaged` does. A missing copy is put back, not staged; an unmerged one is `gitmodules-unstaged`'s to name first.
  - MUST-B and SHOULD-A: no rename back where the spelling or `members` is a link.
  - NITs: "one text serves every disk" now says it holds for the renamed state, with #646's own state named. The free name following the disk's case needs no change.

- **`family.py add`: a file under a third spelling in the index blocks the rename back** (rv685's NIT 5). With `MEMBERS/notes` in the index beside a `Members/` renamed with git, the rule asks for one spelling, so #646's move back is printed, with candidate (e)'s section gap. `git mv` of `Members` alone would still type clean, after #574's remedy for `MEMBERS/notes`. This is outside #685's states.
- **(e), what is left of it.** The move back of a member's own gitlink, printed by `add` and by the pinned validator, still leaves a section naming the variant after a `git mv` of the gitlink itself (#670's candidate (e)). Under "Left out" above, the hand `mv Members members` state and rv685's candidate 1 leave the same gap.

**Review.** rv685 reviewed three times: CHANGES REQUESTED at `70307d20` and at `215e8102`, then APPROVE at `bbb961f8`. Its last round typed every named row through: a deleted `.gitmodules` keeps every member registered, an unmerged one is refused first by `gitmodules-unstaged`, the two link states get the move back as on the base, and the four files give 207 passed. It judged the refusal-order change sound.

Review: rv685, lane 5's independent review, returned CHANGES REQUESTED at 70307d2 and at 215e810, and approved bbb961f; codex reviewed it. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 ac76467 (the restack of the branch onto main after #670 landed) passed tests, tests-git-bash and SonarCloud; the head bd6d2af is a clean update-branch merge of main cbff780, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 2fd92cf over main cbff780: the family_add_member_variant, family_add_spellings_remedies, family_members_case_variants, repo_hygiene, agents_md_codes, cli_reference sets, 289 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #685

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…s to commit it, `member-gitlink-absent` puts the gitlink back by hand, the variant `mv` is guarded, and a pin row with no `path:` is `shape-pin-row` (#681) (#747)

Every remedy the holder's `validate-family.py` prints for #681's five lines now types as printed in the state it names. A shape-pin row with no `path:` is refused by name in both validators.

**Stacked on #682** (`fix/648-bootstrap-and-gate-order`, head `45c512eb`). The diff is `d91fd15e`, the fix round `dbca1130`, and `031ba75e`, the merge of #682's head `45c512eb`. Retarget to `main` once #682 lands.

**Restacked onto #682's head `45c512eb`** by the merge `031ba75e`, with no rebase and no force. #682 had merged main `2fac9790`, which carries lane 2's #634 (`30676c90`), so the conflicts with #634 are resolved there and here. The new head merges clean with `origin/main` `e634d299`. This branch does not merge main itself.

**Restack 2:** the merge `03e0b5a8` takes #682's second fix round `23e11056` (`fix/648-bootstrap-and-gate-order`, one commit on `45c512eb`), with no rebase and no force. It touches only `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_gate_order.py`; `git merge` took it with no conflict and the gate-order test still has `import re`. The six files named for this PR (the typed-remedies, pin-row-path, lost-`.gitmodules`, branch-step and pinned-copy-text tests, and the gate-order test) give `205 passed`. The new head merges clean with `origin/main` `2a33a569` (`merge-tree` exit 0, tree `a62ac94b`).

**Restack 3:** the merge `58d6c50f` takes #682's third fix round `f74f640c` (`fix/648-bootstrap-and-gate-order`, one commit on `23e11056`), with no rebase and no force. It touches only `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_gate_order.py`; `git merge` took it with no conflict and the gate-order test still has `import re`. The same six files give `206 passed` (`205` at `03e0b5a8`; the one more is the fix round's own test). The new head merges clean with `origin/main` `e3137010` (`merge-tree` exit 0, tree `cace7c06`).

**The L2-38 pick is the issue's recommended direction, words only.** Brett Heap took it at 2026-10-16T14:11:42Z ("take all recommendations for #670 and #681"). There is no `family.py` change.

- `templates/family-root/scripts/validate-family.py`: every holder takes one `upstream-changed` row.
- `templates/assembly-root/scripts/validate-pins.py`: every assembly root takes one `upstream-changed` row (the L2-3 check only).

**L20, a removal staged and not committed.** The gate lists the gitlinks HEAD records as well as the index's. So after the printed `git rm -- members/IRQS`, before the commit, it named the same gitlink and the same `git rm`. Typed again, that `git rm` dies `fatal: pathspec 'members/IRQS' did not match any files`, exit 128. A second path's `git rm --cached -- Members/IRRS` did the same.
- The listing is kept. A new `_staged_removals` names the gitlinks HEAD records where the index holds no entry at that path, at any stage.
- For those, `member-gitlink-unnamed` says the removal is staged and to commit it, and prints no `git rm`. A hand mount keeps its branch and `add` lines. A second path says only "Commit that removal."
- A `.gitmodules` section left by `git rm --cached` now prints its own `--unset-all` lines. Before, it said "the git rm printed above takes this section out too", and no `git rm` is printed there any more.
- No misfire: a path the index holds unmerged is a conflict, not a removal. It keeps its `git rm`, which takes an unmerged path out (tested).

**L2-38, the `member-gitlink-absent` remedy.** Take a row kept after `git rm` took its gitlink and section out. Its remedy named `git switch -c` and `family.py add`, but `add` refuses the row (`member-already-present`). `family.py remove` dies at `git submodule deinit` (`pathspec 'members/IRRS' did not match any file(s) known to git`, exit 2) and removes nothing. The remedy now prints, in words and `git` lines:
- `git update-index --add --cacheinfo 160000,<pin>,members/<P>`, which puts the gitlink back at the commit the row pins. It changes the index only, as #639's case-variant move does.
- The section lines `member-gitmodules-absent` prints, when HEAD's `.gitmodules` gives no section the path. The line builder `_section_lines` is now shared by both.
- Then "commit that", and `git submodule update --init --recursive` (or `make bootstrap`) checks the member out.
- If the member is to go: take its row out of `family.yaml` by hand and commit that.

No branch is needed, so this remedy no longer prints `switch -c`.

**N-1 and N-2, the variant move's `mv`.** The text gave a bare `mv -- Members/<P> ../Members-<P>`, and stated when to type it by system ("On a case-sensitive disk, as Linux's usually is … On macOS and Windows … leave it"). Each `mv` is now:

    [ Members/<P> -ef members/<P> ] || [ -e Members/<P>/.git ] || mv -- Members/<P> ../Members-<P>

- **N-2:** the `mv` does not run where the two names are one directory, such as a case-folding Linux mount like WSL's `/mnt/c`. There the bare `mv` moved the member's own directory out.
- **N-1:** it does not run where the directory holds a `.git` entry. For a live submodule checkout that entry is a file, `gitdir: ../../.git/modules/Members/<P>`, and a move left it saying `fatal: not a git repository`.
- For a submodule checkout, the text says its commits are kept under this holder's `.git/modules/`, in the directory its `.git` file names, whatever happens to the checkout. Only what is not committed is in the checkout alone. `git -C Members/<P> status` shows it, except where a plain `mv` brought the checkout there, which git cannot open. It says to delete such a checkout by hand once nothing only it holds is needed.
- For a `.git` directory, a clone of its own, the text says its commits are there and nowhere else. `git -C Members/<P> log --oneline HEAD --branches --not --remotes` lists the ones not pushed, and the text says to move the clone out whole, by hand, rather than delete it.

**L2-3, a `files:` row with no `path:`.** Both validators read `str(row.get("path"))`. So `- nopath: x` gave `FINDING shape-copy-missing: contracts/shape-pin.yaml: None is pinned but absent`, and `path: ''` and `path: 5` did likewise. A row whose `path:` is absent, empty or not a string now gives:

    FINDING shape-pin-row: contracts/shape-pin.yaml: a files row's `path:` is None, not the path of a file

That is the code each validator's non-mapping branch already says, and the row `update-shape.py` refuses as `update-pin-row`. `shape-pin-row` is not a new code.

- **The N-1 guard is `-e` (any `.git` entry), as the issue's direction says.** A standalone clone (a `.git` directory) is not moved either. Since the fix round (rv681's S2, Direction B), the text tells a clone apart from a submodule checkout: a clone is moved out whole by hand, and a submodule checkout is deleted by hand. `test_family_head_lost_gitmodules.py`'s #611 move test now uses a plain directory, which is moved. All three checkout kinds (submodule, clone, plain `mv`) are typed in the new test file.
- **Both guards are kept, and each is tested by behavior.** On a case-folding disk, the row's own directory is a checkout in a working holder, so the `.git` test alone would keep it there. The `-ef` test is what keeps it where the directory is empty, as a fresh clone leaves it before `git submodule update`. A test layout covers exactly that case (see the mutation probes).
- **"Staged removal" means the index holds no entry at the path at any stage**, not only "no gitlink at stage 0". An unmerged gitlink is a conflict, and committing it fails, so it keeps `git rm`.
- **No def line changed in a function that may already be over Sonar's cognitive-complexity limit.** `_staged_removals` is its own function, not a second return value of `_member_gitlinks`; it costs one more `git ls-files -z` per run. The branch bodies moved into `_second_path_out` and `_mounted_by_hand`.

The same throwaway probe ran on base `8b39cd19` and on head `d91fd15e`. The case-folding disk is **emulated**, with a `Members` symlink to `members`; no case-folding mount was used.

| Line | Base `8b39cd19` | Head `d91fd15e` |
|---|---|---|
| L20, hand mount | after the printed `git rm`, the re-run prints the same `git rm`; typed again it exits 128, `did not match any files` | the re-run says the removal is staged and prints only `switch -c` and `add` |
| L20, second path | the re-run prints `git rm --cached -- Members/IRRS` again, which exits 128 | the re-run says to commit the removal and prints no command |
| L2-38 | the printed `add` is refused `member-already-present`; `remove` exits 2 at `submodule deinit` | the printed `update-index` and section lines, `git add .gitmodules`, a commit and `git submodule update --init --recursive` all exit 0; the gate exits 0 in every mode |
| L2-38, the row taken out by hand | exit 0 | exit 0, unchanged |
| L2-3, holder and assembly root | `FINDING shape-copy-missing: … None is pinned but absent`, and the same for `''` and `5` | `FINDING shape-pin-row: … is None` / `''` / `5`, `not the path of a file` |
| N-1, live submodule checkout at `Members/IRRS` | after the printed `mv`, `git -C ../Members-IRRS status` says `fatal: not a git repository` | the guarded line moves nothing; `git -C Members/IRRS status` exits 0, and its local commit is in `.git/modules/Members/IRRS` |
| N-2, one directory (emulated) | the bare `mv` moves `members/IRRS` out of the holder | `[ Members/IRRS -ef members/IRRS ]` exits 0, and the guarded line moves nothing |
| N-2, plain case-sensitive layout | the bare `mv` moves `Members/IRRS` out | `-ef` exits 1, the guarded line moves `Members/IRRS` out, and the gate exits 0 |

New files:
- `tests/test_validate_family_remedies_typed.py` (15 tests in the first round, 18 after the fix round): L20, L2-38 (including a fresh clone), N-1 (a submodule checkout, a plain clone, and since the fix round a checkout moved there by plain `mv`), N-2 (one directory, one empty directory, apart). Every remedy is typed as printed, and then the gate is run.
- `tests/test_shape_pin_row_path.py` (10 tests): L2-3, five rows in each validator.

Updated to the new words: `tests/test_family_head_lost_gitmodules.py`, `tests/test_family_bootstrap_gate_order.py`, `tests/test_validate_family_branch_step.py`, `tests/test_family_switch_branch_exists.py`, `tests/test_family.py`.

Runs:
- **Targeted set, 83 files, on `55bd67d9`** (every `test_validate_family*.py` and `test_validate_pins*.py`, `test_family_bootstrap_gate_order.py`, `test_template_text_placeholders.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, and every test file naming either validator), in two batches:
  - batch A, 42 files: `2359 passed, 1 skipped in 2540.75s (0:42:20)`;
  - batch B, 41 files: `2879 passed, 23 skipped in 3364.87s (0:56:04)`.
  - The skips need a Windows host.
- **`d91fd15e`** differs from `55bd67d9` only in `tests/test_validate_family_remedies_typed.py` (the empty-directory layout). The two new files on it: `25 passed in 33.44s`.
- **Fail on base:** head's changed tests over `8b39cd19`'s code. With `55bd67d9`'s tests: `30 failed, 3 passed in 47.76s`. The N-2 test's added layout, with `d91fd15e`'s tests: all 3 layouts fail, `3 failed in 7.64s`. The 3 that pass do so by design:
  - the unmerged-gitlink no-misfire test;
  - the row taken out by hand;
  - the BRANCH_LEFT guard test, since the sentence already ended every `switch -c` remedy.

| Mutant | Killed by |
|---|---|
| M1, `_staged_removals` returns nothing | 5 failed: both hand-mount L20 tests, the second-path test (2 disks), the section test |
| M2, the section ignores a staged removal | 1 failed: `test_a_section_git_rm_cached_leaves_keeps_its_own_commands` |
| M3, only stage-0 entries count as indexed | 1 failed: `test_an_unmerged_gitlink_keeps_its_git_rm` |
| M4, the old `add` remedy back | 3 failed: both put-back tests and the fresh-clone test |
| M5, section lines printed even when HEAD gives the section | 1 failed: `…put_back_as_printed[git-rm-cached]` |
| M6, no `-ef` test | 5 failed (the refusal's text pin) |
| M7, no `.git` test | 5 failed (the refusal's text pin) |
| M8, no `path:` check in `validate-family.py` | 5 failed: every holder row |
| M9, no `path:` check in `validate-pins.py` | 5 failed: every assembly-root row |
| M10, an empty `path:` allowed | 1 failed: `[empty-family-holder]` |

M6 and M7 again, with the refusal's text pin switched off in the clone's test helper, so that only behavior can kill them:
- **M6:** 1 failed, `[one-empty-directory]`. The `-ef` test is what keeps an empty row directory in place.
- **M7:** 2 failed, both checkout-at-the-variant tests. The `.git` test is what keeps a checkout in place.
- **Control, the pin switched off and nothing else:** 5 passed.

- **`origin/main` `e634d299` (and `3ceabef0`): clean.**
- **#682's head `45c512eb`:** an ancestor of this head.
- **Clean:** the 30 other open `fix/6*`, `fix/7*` and `l3-*` branches on origin: `fix/645`, `fix/646` (`a837a023`), `fix/652`, `fix/654`, `fix/661`, `fix/662`, `fix/663`, `fix/664`, `fix/665`, `fix/666`, `fix/667`, `fix/672`, `fix/674`, `fix/684`, `fix/686`, `fix/688`, `fix/689`, `fix/690` (`38a40abf`), `fix/692`, `fix/693`, `fix/696`, `fix/704` (`7bdfa3cc`), `l3-563-docs`, `l3-677`, `l3-678`, `l3-680`, `l3-687`, `l3-691`, `l3-697` and `l3-rehearsal-v1.1.0`.
- **Two conflicts, neither in a file this branch changes:**
  - `fix/695-family-docs` (`c42286de`), in `docs/handbook.html`. It is clean with main and conflicts with #682's head `45c512eb` the same way.
  - `l3-683-partial-clone` (`9f24bf31`), in `shape-doctor.py`. It conflicts with `origin/main` the same way.

This is one new commit on `d91fd15e`, not an amend. Only text changes in `validate-family.py`: the guards are the same, and `validate-pins.py` is not touched.

- **S1.** A hand mount taken out with `git rm --cached` leaves its checkout at the path. Git itself suggests `--cached` when the printed `git rm` refuses a checkout with changes. The printed `family.py add` then refused that directory (`member-path-taken`).
  - The staged-removal text now says: "Where git rm --cached took it out, its checkout is still at <path>, and family.py add refuses that directory (member-path-taken): delete it by hand, once nothing only it holds is needed, before the add."
  - The L20 hand-mount test gains a `git rm --cached` case. It is typed through the section's printed lines and the commit; then `add` is refused `member-path-taken`; the checkout is deleted by hand; `add` is refused `member-git-dir-cached`; the store is deleted; `add` exits 0, and the gate exits 0 in every mode.
- **S2, Direction B.** The `-e` guard is kept, as the issue's "no .git entry", which Brett Heap's word took. For a `.git` directory, the text now says its commits are there and nowhere else, names the `git log` above, and says to move the clone out whole, not delete it.
  - The `[clone]` case asserts that the line keeps the clone and that the printed `git log` lists exactly its own commit. It then moves the clone out whole and reads the commit there.
  - Direction A (`-f`) is not taken. The coordinator is asking Brett.
- **N1.** The store is "under this holder's .git/modules/, in the directory its .git file names". It is not always `.git/modules/Members/<P>`: a checkout a plain `mv` brought to the variant names `.git/modules/members/<P>`. The status check now says git cannot open such a checkout.
  - A new `[moved]` kind types this state: the commit is in the store the `.git` file names, `git -C Members/IRRS status` fails, the checkout is deleted by hand, and `git submodule update --init --recursive` checks the member out again.
- **N2.** The guarded line's sentence says "in a POSIX shell", as the section lines do.
- **N3.** A new test writes the section back in the work tree's `.gitmodules` alone. The section lines still print, because HEAD's `.gitmodules` is what a clone gets. Typed, staged and committed, they give a gate that exits 0.

Runs on the fix round:

| Run | Result |
|---|---|
| rv681's four files plus the new tests (`test_validate_family_remedies_typed.py`, `test_shape_pin_row_path.py`, `test_family_head_lost_gitmodules.py`, `test_validate_family_branch_step.py`) | `80 passed in 86.95s` (rv681 had 77 on `d91fd15e`) |
| every other file importing the changed helpers (`test_family_bootstrap_gate_order.py`, `test_family_remove_gitmodules.py`, `test_family_switch_branch_exists.py`, `test_validate_family_member_path_glob.py`, `test_validate_family_unnamed_mounts.py`) | `137 passed in 128.12s` |
| `test_template_text_placeholders.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py` | `266 passed in 50.50s` |
| MO1 (`_gitlink_put_back` reads the work tree's `.gitmodules`), rv681's patch | killed: `1 failed, 94 passed`, `test_a_section_back_in_the_work_tree_alone_still_prints_its_lines` (it survived with 92 passed on `d91fd15e`) |
| M5 (section lines printed even when HEAD gives the section), rv681's patch | still killed: `1 failed, 17 passed`, `[git-rm-cached]` |

Not re-run in this round, to save usage:
- MO2 (`-e` changed to `-f`). By reading: it now moves the clone in the `[clone]` case, which asserts the clone is kept.
- The 83-file targeted set, the fail-on-base run and the other mutants.

The texts this round changes are pinned only in `test_family_head_lost_gitmodules.py` and `test_validate_family_remedies_typed.py` (`git grep` of each changed phrase in `tests/`).

**The restack merge (`031ba75e`).** Two files conflicted; every other file merged clean.
- `validate-family.py`, 3 regions:
  - WHAT IS CHECKED keeps #682's narrower wrong-case sentence, then this branch's staged-removal sentence.
  - The signature is `_check_member_gitlink(root, path, commit, project, repository, gitlinks, report, rule)`.
  - `_check_member` passes `row.get("repository"), gitlinks, report, rule` and returns `Gitlinked(row, project, path, pin, commit, rule)`.
- `tests/test_family_bootstrap_gate_order.py`, 2 regions:
  - the docstring keeps both sentences;
  - the import from `test_family_head_lost_gitmodules` is the union of both sides.
  - `import re` is put back by hand. The merge had dropped it with no conflict marker, and the file calls `re.compile` and `re.findall` (rv681's note). `import subprocess` stays out, since neither side uses it.
- The gate-order test is rv681's model (`_lane5-scratch/rv681-restack2`) byte for byte. `validate-family.py` is that model with this branch's fix round `d91fd15e..dbca113` applied by `git merge-file`, byte for byte.
- Runs on `031ba75e`:
  - the four files plus `test_pinned_copy_text.py`: 172 passed in 65.19s (`$Q/v112/w681/rs/four.log`);
  - the gate-order test and the rest of the family set, plus `test_validate_pins_lockstep_lines.py`: 159 passed in 104.07s (`$Q/v112/w681/rs/more.log`);
  - hygiene, the placeholder test and the AGENTS.md codes test: 266 passed in 38.36s (`$Q/v112/w681/rs/hyg.log`).

- `family.py`'s own `family-members-dir-variant` and `member-path-taken` move remedy (`mv -- Members/<P> <new name>`) has N-1's flaw for a live submodule checkout. Fixing it is a `family.py` change, so it is a checklist candidate below.
- `AGENTS.md`'s "Never hand-edit `family.yaml`'s `members:` block" is not changed. The validator already tells people to put a row back by hand in a repair state (`RESTORE_ROW`), and now `member-gitlink-absent` tells them to take one out by hand. Whether to reconcile the sentence is a checklist candidate.

1. **`family.py`'s case-variant move remedy breaks a live submodule checkout.** Its `family-members-dir-variant` and `member-path-taken` refusals print `mv -- Members/<P> <new name>` for any directory, a live submodule checkout included. That checkout's `.git` file names `.git/modules/` by a path relative to it, which the move leaves pointing nowhere. The same guard, or the same "delete it by hand" words, would fix it.
2. **A member's own staged un-registration passes the gate.** After `git rm --cached members/IRRS`, before the commit, the gate reads the gitlink from HEAD and says ok. `member-gitlink-absent` only fires after the commit, so CI is the first to see it.
3. **`AGENTS.md` and the validator disagree on hand edits.** `AGENTS.md` says never to hand-edit `family.yaml`'s `members:` block. The validator's repair remedies tell people to do so (`RESTORE_ROW`, and now `member-gitlink-absent`). One of the two should change.
4. **`member-commit-unresolvable` names no remedy** (rv681's candidate 3). Take a pin no remote holds: `git submodule update` exits 128 (`not our ref`), the gate refuses `member-commit-unresolvable` with no remedy line, and `--no-members` exits 0 over it.
5. **The v1.0.0 notes list L20 as a known limit** (rv681's candidate 4). `docs/release-notes/v1.0.0.md:466` lists it. The v1.1.0 notes (lane 3's #630) should say #681 fixes it.

**Review.** rv681 re-verified the fix round: APPROVE WITH NITS at `dbca1130`, the nits being three body lines, now fixed. Its P1 probe now ends with `add` exiting 0 when typed as printed, MO1 is killed, and its four files give 80 passed. MO2 (`-f` for `-e`) fails only the `[clone]` case, so that test alone holds the `-e` choice (Direction B); Direction A stays Brett's call.

Review: rv681, lane 5's independent review, approved with nits at dbca113; the branch was then restacked by merges onto #682's fix rounds (58d6c50), with `import re` put back in tests/test_family_bootstrap_gate_order.py. CI (Brett Heap's ruling 27): H0 58d6c50 passed tests, tests-git-bash and SonarCloud; the head ff7c59b merges #682's final head and then main after #682 landed as dc7d3fb, taking the branch's own side for #682's files, with tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 994e716 over main 40e678e: the bootstrap_after_bump, bootstrap_leg_moves, bootstrap_without_git, bump_leg_paste_lines, cli_reference, clone_url_bytes, family, family_add_member_variant, family_add_spellings_remedies, family_bootstrap_gate_order, family_head_lost_gitmodules, family_members_case_variants, family_switch_branch_exists, install_sh, local_remote_dir_urls, no_auto_maintenance, openreposhape_command, readme_windows_quoting_sentence, shape_doctor, shape_pin_row_path, siblings_inputs, validate_family_branch_step, validate_family_remedies_typed, validator_child_encoding, repo_hygiene, agents_md_codes, cli_reference sets, 1495 passed, 3 skipped, 1 xfailed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #681

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…efusals and `family-operation-in-progress` name a path as `shown_source` names it, so an ESC or a line break in a path no longer reaches the terminal (#692) (#731)

#622's folder refusals printed a path as it was. `family.py init --into <a file named wd-ESC[31mRED> --dry-run` gave `REFUSED family-folder-cannot-be-made: the family folder .../wd-^[[31mRED/Orbit cannot be made: ...` through `cat -v`. A name with a line break and then `WARNING forged-line: pwned` printed that line as a line of its own. R612's `family-operation-in-progress` printed the mark's path across two lines for a holder whose path holds a newline.

Each path is now spelled as `shape_materialize.shown_source` spells a `--pin-source`: as it is, and as Python's `repr` spells it where a character in it is not printable.

- `scripts/shape_materialize.py`: both answers of `folder_cannot_be_made` (a file or a bad stat in the way, and a folder this user cannot write in), the folder in `refuse_unmakeable_folder`, and the folder in `make_folder`.
- `scaffold-project.py`: `scaffold-target-exists`.
- `scripts/family.py`: `family-target-exists`, and the mark's path in `family-operation-in-progress` (`where.as_posix()`).

A printable path is named byte for byte, so no existing row changes and `why.endswith(CANNOT_WRITE_IN)` in `refuse_unmakeable_folder` still holds. No help text changed, so `docs/cli.md` is untouched. No README, handbook or template sentence says how a refusal names a path (`git grep` of `shown_source`, `target-exists` and `folder-cannot-be-made` in AGENTS.md, README.md, `docs/handbook.html` and the templates found none), so no doc changes.

- **`shown_source`, not `shown_path`.** Since #634, `shape_materialize.shown_source` returns `repo_shape.shown_path(source)` (`scripts/shape_materialize.py`: `return shown_path(source)`), so the seven call sites this change adds (four in `shape_materialize.py`, one in `scaffold-project.py` and two in `family.py`) spell a path as `shown_path` does. Renaming them to `shown_path` stays out of this PR: it would only move imports in `scaffold-project.py` and `family.py` and reword docstrings in `family.py`, `shape_materialize.py` and the test file, and the review's N1 left it optional. I did not take it. The predicted merge with main works with the alias: `family.py --help` and `scaffold-project.py --help` exit 0 on it, and `shown_source` and `shown_path` return the same string for an ESC name, a line-break name and a plain path.
- **`shown_source` is imported by name into each file's existing `from shape_materialize import (...)` block.** The first commit put it on a line of its own after the block; lane 4's #660, #688 and #693 add an import at that same place, and `merge-tree` reported add/add conflicts in `scripts/family.py` and `scaffold-project.py`. The second commit moves the name into the block, and those conflicts are gone.
- **"The holder in the other refusals of the same block" is one refusal.** The `# What the holder is in the middle of, and what its sparse checkout leaves out` block holds `family-operation-in-progress`, `family-sparse-excluded` and `member-path-unaddable`. Only the first prints a path of the holder's. `family-sparse-excluded` names `members/<Project>` and `.gitmodules`, and `member-path-unaddable` names `members/<Project>`.
- **No scaffold row for `--local-remote-dir`.** A control character there is refused earlier as `scaffold-unwritable-value`, which already names the path with `repr`. The unit test covers `make_folder`, which only a race reaches by the command line.

`tests/test_dry_run_parity.py`:
- `SCAFFOLD_CASES` gains five rows: ESC and line break in the `--work-dir` name, ESC in a folder name above it, and ESC and line break in a work dir that holds a file named `Atlas` (`scaffold-target-exists`).
- `FAMILY_CASES` gains five rows: ESC and line break in `--work-dir`, ESC in `--into` (`the family folder`), and ESC and line break in a work dir that holds a file named `Orbit` (`family-target-exists`).
- Two rows join `UNWRITABLE_FOLDER_AT` (ESC in the scaffold's and in `init`'s `--work-dir`, a folder this user cannot write in).
- Rows named `ESC-...` and `line-break-...` are skipped on Windows, whose file names cannot hold them (`POSIX_NAMES`, `case_params`). Every expectation spells the path with `repr`, and not with `shown_source`.
- `assert_refused` now fails any stderr line that starts with `WARNING forged-line`.
- Three unit tests: `test_each_folder_question_spells_a_name_that_is_not_printable` (ESC, line break; `folder_cannot_be_made`, `refuse_unmakeable_folder` and `make_folder` by themselves), `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`, and `test_a_printable_name_is_named_byte_for_byte` (a name with a space and an accented letter stays unquoted).

`tests/test_family_refuse_up_front.py`: `test_a_holder_path_with_a_newline_still_shows_its_merge` asserted the raw newline in the refusal (`"Ink\nRouter/.git/worktrees/" in ran.stderr`). It is now `test_a_holder_path_with_a_control_character_still_shows_its_merge`, over a line break, an ESC and a forged line, and asserts the one-line `repr` form (`refusal()` fails unless the stderr is two lines). It is the one existing test this change had to edit.

Results at head `ab55c29f` (Python 3.12.3, Linux):
- `tests/test_dry_run_parity.py tests/test_dry_run_asks_remotes.py tests/test_family_dry_run.py tests/test_family_init_landing.py tests/test_repo_hygiene.py tests/test_pin_source_unreadable.py tests/test_scaffold_leg_path_collisions.py tests/test_scaffold_reuse_seeded_legs.py`: **494 passed**.
- `tests/test_family.py tests/test_family_refuse_up_front.py tests/test_family_init_values.py tests/test_family_add_refusals.py`: **356 passed, 19 skipped** (the skips are `test_family_init_values.py:219` and `:291`, template-quoting premises).
- Not run: the full suite (the coordinator's gate), and macOS and Windows (CI is Linux-only since #597). The new hostile-name rows are POSIX only by design.

On the base code: the review overlaid this head's two test files on main `2fac9790` and ran `tests/test_dry_run_parity.py` and `tests/test_family_refuse_up_front.py` with `-k "dry_run_parity or control_character"`. The result is **20 failed, 87 passed** (76 deselected, 43.5 s). The 20 are exactly the new and edited tests: the 5 scaffold and 5 `init` rows, the 4 unwritable-folder rows, the 3 unit tests (`test_each_folder_question_spells_a_name_that_is_not_printable` in two cases, and `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`), and the 3 cases of the edited `test_a_holder_path_with_a_control_character_still_shows_its_merge`. The one new test that passes on main's code is `test_a_printable_name_is_named_byte_for_byte`, by design: a printable name is named byte for byte on both. An earlier draft of this body said 21 failed, 2 passed. That run was on `8062810d` with a wider selection, and it held a scaffold row for ESC in the local remote dir's name (`ESC-in-the-local-remote-dir-name`) that this head's tests no longer have; its second pass was the existing `test_the_refusal_quotes_gh_s_first_line_and_escapes_what_is_unprintable`, which also passes in the new run, one of the 87.

`family.py init` and `scaffold-project.py --dry-run` against a file named `wd-ESC[31mRED`, a file named `wd-\nWARNING forged-line: pwned`, and a work dir with a file `Atlas` or `Orbit` in it, run with a stub `gh` and no real `gh`:
- On main, through `cat -v`: `... the family folder /.../wd-^[[31mRED/Orbit cannot be made: /.../wd-^[[31mRED is a file, not a folder`, and a stderr line `WARNING forged-line: pwned cannot be made: ...` of its own.
- On this head: `REFUSED family-folder-cannot-be-made: the family folder '.../wd-\x1b[31mRED/Orbit' cannot be made: '.../wd-\x1b[31mRED' is a file, not a folder`, and one line for the forged name: `--work-dir '.../wd-\nWARNING forged-line: pwned' cannot be made: '...' is a file, not a folder`. `scaffold-target-exists` and `family-target-exists` are one line each in the same form.

Each spelled site reverted alone in a throwaway clone at `19d126cd` (the call sites are the same at `ab55c29f`); the new tests that fail for each:
- `folder_cannot_be_made`, the `_not_a_folder` answer: 8 failed (six CLI rows and the two unit tests).
- `folder_cannot_be_made`, the `CANNOT_WRITE_IN` answer: 5 failed (the four unwritable rows and the unit test).
- `refuse_unmakeable_folder`: 12 failed.
- `make_folder`: 2 failed (the unit test only).
- `scaffold-target-exists`: 2 failed (its two rows).
- `family-target-exists`: 2 failed (its two rows).
- `family-operation-in-progress`: 3 failed (line break, ESC, forged line).

`git merge-tree --write-tree` of this head `ab55c29f` against origin/main `20c8873b` and against every ref on origin that could meet it, fetched 2026-10-16: the 41 branches (`fix/*`, `l3-*`, `wip/*`, `docs/*` and `release/v1.0`) and the 55 PR heads from #630 up. That is 96 refs. (The same list was run for #730's head `7f2bb36b`.)
- **origin/main `20c8873b`: clean** (the predicted tree `00b5b24c`, which differs from main in these five files only: 216 lines added, 25 removed).
- **#630's head `6fd33a93`: clean.** It conflicted with main in `docs/handbook.html` when this body was first drafted, and no longer does, with or without this change. This change touches no README or handbook.
- **The refs that edit one of this change's five files**, and so could meet it, all merge clean with this head: `fix/646`, `fix/651`, `fix/654`, `fix/660`, `fix/661`, `fix/684`, `fix/686`, `fix/688`, `fix/689` and `fix/693`, the heads of #670, #725, #728, #743 and #744, and the heads of the landed #634, #638, #639, #640, #643 and #671. #634 has landed (`30676c90`), so the `fix/660` note about a conflict with it is gone.
- **Four refs conflict with origin/main on their own**, and so with this head: `docs/release-notes-v1.0.0` and `release/v1.0`, and the pre-squash heads of the landed #637 and #641. Their conflicts do not come from this change. Every other ref, 92 of the 96 in all, is clean with this head, including the refs that conflict with main in `templates/family-root/scripts/validate-family.py` (#682, #745, #747, `fix/648`, `fix/681`, `fix/704`) or in `shape-doctor.py` (#742, `l3-683`) on their own. So do the old heads of the landed #638 and #639, and lane 3's `l3-677-pins-hand-step-wip` branch, and each of them merges clean with this head too.
- **Tests on the predicted merge**, run by the review on the merge with main `2fac9790`: `test_dry_run_parity`, `test_family_refuse_up_front`, `test_pinned_copy_text`, `test_pin_source_unreadable`, `test_dry_run_asks_remotes`, `test_family_dry_run` and `test_family_init_landing`: 445 passed in 215 s. `test_family`, `test_family_init_values`, `test_family_add_refusals`, `test_derived_id_refusals`, `test_repo_hygiene`, `test_naming_policy`, `test_scaffold_leg_path_collisions` and `test_scaffold_reuse_seeded_legs`: 758 passed, 19 skipped in 243 s. I did not re-run them on `20c8873b`; the merge is clean, and the two commits since `2fac9790` (#700 and #702) change only `shape-doctor.py`, `templates/assembly-root/scripts/validate-pins.py` and four test files, none of them a file of this change.
- Before the second commit, `fix/660`, `fix/688` and `fix/693` each conflicted with this head in the import region of `scripts/family.py` or `scaffold-project.py` (a separate import line each side added at the same place). That is why the import joins the existing block.

#634 is on main, and this change does not edit `shown_source` or its docstring in `scripts/shape_materialize.py`.

The review approved with nits. Its S1, the stale base-run numbers and merge lines, is taken here, in this body: no code changed, and the head is still `ab55c29f`. Its N1, renaming the call sites to `shown_path`, is not taken (see "Decisions and their evidence"). It also re-ran 5 of the 7 mutation probes on `ab55c29f` and got the same failure counts as above (12, 2, 3, 2 and 2), and ran 144 hostile-name dry runs (16 names in 9 scenarios): on main 69 rows put a control character on stderr and 6 print a forged line of their own; on this head all 144 exit 2 with no traceback, no control character and no forged line.

- `scripts/family.py` `init`'s plan prints `folder` and `landing` lines with the path raw (`print(f"landing      {holder}   ...")`, `print(f"folder       {folder}   ...")`). On a dry run or a refused run the plan is on stdout before the refusal, so `WARNING forged-line: pwned/Orbit   (--work-dir: no family folder)` still appears there. It is the plan, not a refusal, and not one of the issue's lines.
- Other refusals that name a holder or folder path raw, none of them in the issue's lines: `family-holder-detached` and `family-on-tracking-branch` (`{family.root}`), `family-index-locked` (`lock.as_posix()` in the detail), `family-landing-in-standard` (`{holder}`, `{checkout}`, `{beside / family / family}`), `family-holder-already-here` (`{into}`), and `family-remote-exists` and `scaffold-remote-exists` (`{target}`, a local remote path).

1. **family.py `init`'s plan prints the holder and folder paths raw.** `scripts/family.py` `landing` and `folder` rows (about :1756-1759) print `holder` and `folder` as they are, so a forged line or an ESC reaches stdout in a `--dry-run` and in a run refused after the plan. Direction: spell them with `shown_source`.
2. **The same raw spelling in the refusals listed above.** `family-holder-detached`, `family-on-tracking-branch`, `family-index-locked`, `family-landing-in-standard`, `family-holder-already-here`, `family-remote-exists` and `scaffold-remote-exists` each print a path the person supplied or git keeps. Direction: the spelling this change gives its sites.

Review: rv692, lane 5's independent review, approved ab55c29 with nits, and its body fix (S1) is published. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 ab55c29 passed tests, tests-git-bash and SonarCloud; the head 13f643d is a clean update-branch merge of main 431c8cf, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 4327947 over main 431c8cf: the dry_run_parity, family_refuse_up_front, repo_hygiene, agents_md_codes, cli_reference sets, 330 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #692

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
… of `.gitmodules` back, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closing `mv` is guarded and leaves a checkout to delete by hand, and `--sparse` follows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) (#828)

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

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

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

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

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

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

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

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

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

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

**The tests.**

- New: `test_a_members_file_head_holds_is_taken_out_then_renamed_back`, three states: deleted from disk, kept off disk by a sparse checkout, and left out by the sparse rules. Each one types every step as printed: #684's removal and its commit, then the rename back that the next `add` prints. Then both members are where their rows say, the gate passes in every mode, and the same `add` goes through.
  - The rules state uses non-cone rules `/*`, `!/members` and `/members/`, which leave out the file `members` alone. `git sparse-checkout check-rules` confirms it: out, while `members/IRRS` stays in. This state carries `needs_check_rules`.
- Changed: `test_an_entry_at_members_itself_is_set_aside_first` keeps only the cases that still reach the set-aside: a link, a file staged new and deleted from disk, and a file staged new and kept off disk by a sparse checkout.

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

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

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

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

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

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

### SHOULD 4: S3776 (taken)

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

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

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

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

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

### NIT 5: surviving mutants R3 and R20 (taken)

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

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

### NIT 6: duplication (taken)

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

### NIT 7: the shell dialect (followed)

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

### Runs

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

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

The skips are environmental:

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

CI's git 2.55 was not run here.

### Fails on the merged base

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

- **Behaviour.** The two sparse states of the new #684 test fail when typed: `git rm --cached -- members` exits 1.
- **Text.** The other 51 failures are expected text: the closing sentence now says Git Bash, and #684's sparse line changed.
- **Passing on the merged base:** five of the new cases. They pin behaviour that already held there:
  - #684's own deleted-from-disk state;
  - the clone and the link;
  - the two nothing-aside states.

### Mutants

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

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

### Merge checks

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

- **Clean:** main `fc867207`, `fix/684-e4-reftable-operation` `a3683127` and `fix/686-bump-reachability` `17bbbaba`.
- **`fix/766-spelled-paths` `ef6db40d` (#814) conflicts** only in `tests/test_dry_run_parity.py` and `tests/test_non_utf8_git_reads.py`. This PR does not touch either file. The same conflict appears between #814 and main, as the review found. `scripts/family.py` merges cleanly.

### Checklist candidates

- **An unmerged file at or under a row's path** (SHOULD 3). A member's gitlink at a variant then gets #574's remedy, which takes the gitlink away from its row. The same goes for an unmerged file at `members`.
- **`validate-family.py`'s `_wrong_case_move`** (#747, `6626a3c5`) still prints the guard without the "nothing there" test, so its `mv` exits 1 where nothing is at the variant. The candidate is carried from round 1.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Tests

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

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

Changed:

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

### Runs

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

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

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

### Fails on d4e8c65

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

### Mutants

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

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

### Merge checks

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

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

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

### Left out

- **`validate-family.py`'s `_wrong_case_move`** has the same guard and the same failure where nothing is at the variant: `mv` exits 1. It landed with #747 as `6626a3c5`. It is a pinned file, so the fix is for a follow-up, not this PR.
- **The two "left out" tests use non-cone sparse rules** (`git sparse-checkout set --no-cone`). CI's git 2.55 was not run here; git 2.43 and 2.47.1 were.

### What was wrong

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

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

### What changed

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

### A defect found on the way

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

### What was wrong

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

### What changed

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

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

### What was wrong

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

### What changed

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

### What was wrong

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

### What changed

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

### What was wrong

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

### What changed

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

### What was wrong

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

### What changed

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

### What was wrong

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

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

### What changed

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

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

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

**Changed tests:**

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

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

| Run | Tree | Files | Result |
|---|---|---|---|
| `batchA.log` | before the last three small edits | 34 more of the files that import or run `family.py` (`test_family.py`, `test_family_refuse_up_front.py`, `test_non_utf8_git_reads.py` and the rest) | 1582 passed, 20 skipped |
| `batchB.log` | the same | `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_shape_doctor.py`, `test_setup_project_py.py`, `test_bump_leg.py`, the four `update_shape` files and 8 more | 1716 passed, 4 skipped |
| `r2.log` | the same | the issue's Verify files: `test_family_add_member_variant.py`, `test_family_add_spellings_remedies.py`, `test_family_members_case_variants.py` | 141 passed |
| `r3.log` | the same | `test_family_add_refusals.py`, `test_family_head_lost_gitmodules.py` | 103 passed |
| `f1b.log` | this commit, `PYTHONHASHSEED=0` | the new file, `test_family_add_member_variant.py` | 63 passed |
| `f2b.log` | this commit | `test_family_add_spellings_remedies.py`, `test_family_members_case_variants.py`, `test_repo_hygiene.py` | 181 passed |
| `s1.log` | this commit, `PYTHONHASHSEED=1` | the new file | 26 passed |
| `s2.log` | this commit, `PYTHONHASHSEED=2` | the new file's four order-sensitive cases | 4 passed |
| `base2.log` | `bbb961f8`'s `family.py`, this commit's tests | the new file | 25 failed, 1 passed |
| `rs1.log` | the predicted restack onto main (tree `e607c304`) | the new file, `test_family_add_member_variant.py` | 63 passed |
| `rs2.log` | the same | `test_family_add_spellings_remedies.py`, `test_family_members_case_variants.py`, `test_family_add_refusals.py`, `test_family_head_lost_gitmodules.py` | 207 passed |

- **Fail on base:** 25 of the new file's 26 cases fail on `bbb961f8`'s code. The one that passes, `test_a_checkout_under_an_index_entry_is_the_indexs_to_name`, guards the disk filter's new `CHECKOUT` branch: it keeps what the base already did (M22 shows it catches the branch dropped).
- **The last three small edits**, after the two batches: one closing sentence reads "a checkout there that git submodule add or git mv made", a comment says a git without `check-rules` leaves the bit alone to decide, and the order fix. The order fix: `rows` is a set, so where two members' removals were staged, the order of the `IN_GITMODULES` entries, and of the text naming them, followed the set's hash (one run printed `Members/IRSS` before `Members/IRRS`); `staged_out` now walks the rows sorted. The runs at this commit cover the files that compare that text.
- The three sparse tests carry `needs_check_rules` from `test_family_refuse_up_front.py`, so a git without `git sparse-checkout check-rules` skips them rather than failing.
- `render-cli-reference.py --check`: ok. No line of the README, the handbook, `AGENTS.md` or `docs/cli.md` names the changed text, so none changed.

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

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

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

- **This commit, `deb83e2e`:** clean against #767's final head `bd6d2afe`, #670's `6d7e4ff0`, and the open branches that change these files from #767's base: `fix/684-family-holder-states` `fdc90a6e`, `fix/686-bump-reachability` `cf5e64b3`, `fix/688-family-init-values` `6958dfbc`, `fix/689-next-push-refspec` `15c3b5ab`, `fix/692-raw-folder-paths` `ab55c29f` and `fix/766-spelled-paths` `ef6db40d`. Against main `40e678e6`, and the branches already restacked onto it, it conflicts in the four files #767 changed: the stacked-PR conflict the restack above resolves.
- **The predicted restack, `263d60e8`:** clean against `bd6d2afe`, `fix/693-local-remote-dir` `b5a6c6a1`, and every branch above. Against the wave-7 branches it is clean with `fix/757`, `763`, `768`, `777` and `796` to `801`. `fix/758`, `759`, `760`, `762` and `764` conflict with it only in files this PR does not touch (`tests/conftest.py`, `install.sh`, `validate-family.py`, the `bootstrap.py` copies and their tests), where main has moved since those branches were cut.
- Every open branch that touches one of these five files is clean against the restack.

- **The validator's own move back** (`templates/family-root/scripts/validate-family.py`, `_wrong_case_move`) still prints the update before the hand step, which meets the store trap above where a checkout at the variant shares the member's store. Its `git rm --cached` may refuse a staged variant beside its checkout's work, as `add`'s did; that was not measured for the validator. Both are for the validator's own issue, not this one.
- **A section that gives a case variant of a row's path while the gitlink is at the row's path** (the state #646's old move back left, typed) is not named by `add`, which goes through; the validator refuses it.
- **#574's `git mv` of a directory target** moves an untracked checkout under it too. Not measured.
- **A case variant of a row's path under `members/` itself** (`members/irrs`) is not named by `add` (read from the source, not run).

Review: lane 2's independent review and its re-verification of ce0f2a5, READY (issuecomment-6240244762). CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 90649e5 passed tests, tests-git-bash and SonarCloud; the head 3546118 is a clean update-branch merge of main c5d0bde, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 635a3da over main c5d0bde: the family_add_member_variant, family_add_move_back_sections, family_add_spellings_remedies, family_members_case_variants, install_sh, openreposhape_command, repo_hygiene, agents_md_codes, cli_reference sets, 975 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 #761

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant