Repository navigation
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
Conversation
…another spelling of its row's path moves it back to that path in the index alone, so the printed remedy no longer takes the member's gitlink away from its row (#646) A member's only gitlink at `Members/<P>`, while its row and `.gitmodules` say `members/<P>` (#363's state after the fact), made `add` of ANOTHER member refuse as #574 rules, with #574's remedy for any path under another spelling of `members/`: `git rm -r --cached -- Members/<P>` where nothing is on disk in that spelling, `git mv -- Members/<P> <new name>` where something is, and `git commit` where only HEAD holds it. Each, typed as printed, left no gitlink at the row's path, and the holder's validator then refused `member-gitlink-absent`. `_members_spellings` now takes the rows' own paths (`_rows_paths`, the one path `_member_path` gives a row). Where the index holds no gitlink at a row's path, a gitlink at a case variant of it, in the index or in HEAD alone, is that member's: remedy `RESTORE`, placed in `REMEDY_ORDER` after the index's own remedies. Its remedy is the index-only move `validate-family.py` prints for the same state on #639, which Brett Heap accepted on 2026-10-16 ("Accept; merge #639 after #637") in place of the "committed `git mv`" of his "Refuse on every disk" ruling, since `git mv` to the row's path fails or misplaces the gitlink: - `git rm -r --cached --ignore-unmatch -- <variants>`, which touches no file and is no error where HEAD alone holds the variant; - `git update-index --add --cacheinfo 160000,<commit>,members/<P>`, one per row, at the commit the variant entry records (the first found where a row has two, the index's before HEAD's), so it is a move and not a bump; - a commit, printed only where one is left to make: HEAD holds a variant, HEAD's gitlink at the row's path is another commit or none, or a pending change is committed first. After every other remedy come the validator's own sentences for what the disk holds afterwards: `git submodule update --init --recursive` where nothing is checked out at the row's path, as in a fresh clone; on a case-sensitive disk, `mv -- Members/<P> ../Members-<P>` for the directory left at the variant, which the next `add` would otherwise refuse as `family-members-dir-variant`; on macOS and Windows, leave it, since it is the row's own directory. They come last so that a `members/` moved back (`mv -- Members members`) has taken the checkout home first. A directory on disk at a gitlink moved back from HEAD is the move's to name, as one at an index entry is the index's. The refusal code stays `family-members-dir-variant` (or `member-path-taken` where a path is also a variant of the new member's mount path): the add is still refused for the path, as for any path under another spelling of `members/`, and only its remedy changes. A gitlink at a variant beside one at the row's path, a file at the variant, and anything under it keep #574's remedies, which take nothing from the row. The text reads the index and HEAD alone, so it is the same on a disk that folds case and on one that does not, and in the dry run and the real run. Tests: `tests/test_family_add_member_variant.py` (new, 28) types every printed command in each layout of rv611b's review of #639 (nothing on disk in that spelling; the only checkout at the variant; both directories; the removal staged; a fresh recursive clone) and among #574's other remedies; the holder's gate passes after it, the member is where its row says at its commit, and the same `add` goes through. It asks each layout of an emulated folding disk and a Linux one (`disk_as`). Two existing lines follow the new shapes: a seven-field `Spelled` compared in `test_family_members_case_variants.py`, and the three-argument stand-in for `_members_spellings` in `test_family_add_refusals.py`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… at another commit, with HEAD keeping the row's gitlink at its own, names the index's commit and prints the commit, so the two conditions no other state held are each held by a test (#646) Two mutation probes of the fix survived the new file: `committed` taken from "HEAD holds the variant" alone, and the commit read from HEAD's gitlink at the row's path before the index's. Neither changes the answer in any state the file held: wherever HEAD lacked the variant, it lacked the row's gitlink too, or held it at the same commit. This state holds the row's gitlink in HEAD at one commit and the variant in the index at another, and nothing in HEAD at the variant. The remedy must name the index's commit, and must print the commit, since HEAD's gitlink at the row's path is another. Each probe now fails it. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-5, 2026-10-16T12:56Z) @codex review |
Reviewer's GuideUpdates 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 gitlinksequenceDiagram
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
Flow diagram for family add case-variant remediationflowchart 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]
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
| # 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} |
There was a problem hiding this comment.
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 👍 / 👎.
| "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})" |
There was a problem hiding this comment.
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 👍 / 👎.
|
(lane openRepoShape-3, 2026-10-16T13:21:13Z) Lane 3's independent review of #670 at
The pin question: keep the variant entry's commit
For the PR body:
SHOULD
NIT
Also checked
Tests runAll runs used a stub
Lane: openreposhape-3 (openRepoShape-3) |
(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>
|
(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) |
|
(lane openRepoShape-5, 2026-10-16T17:14:45Z) #670's fix round for lane 3's RV670 is pushed: head
Tests on 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>
|
|
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 |
Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
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>
…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>
…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>
…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>
…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>
… 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>



Lane: openreposhape-5 (openRepoShape-5)
Claim: #646 (comment)
Goal (#646): no remedy
family.pyprints un-registers a member that is in the holder. Three states are outside it, each under "Left out": a.gitmodulesthat agit mvmade 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 atmembersitself (candidate (h)).What was wrong
A member's only gitlink sat at
Members/<P>, while its row and.gitmodulessaidmembers/<P>. This is #363's state after the fact.family.py addof ANOTHER member refused, as #574 rules (family-members-dir-variant). Its remedy was #574's remedy for any path under another spelling ofmembers/: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._members_spellingstakes the rows' own paths (_rows_paths: the one path_member_pathgives a row,members/<project>; a row it refuses ismember-path's).RESTORE, placed inREMEDY_ORDERafter the index's own remedies.validate-family.pyprints for the same state since validate-family.py and family.py: a gitlink no section of HEAD's.gitmodulesgives ismember-gitmodules-absent,removerefuses a holder with no.gitmodulesbeforedeinit, and two gitlink remedies name what they missed (#611) #639. Brett Heap accepted it on 2026-10-16T11:21:49Z ("Accept; merge validate-family.py and family.py: a gitlink no section of HEAD's.gitmodulesgives ismember-gitmodules-absent,removerefuses a holder with no.gitmodulesbeforedeinit, and two gitlink remedies name what they missed (#611) #639 after family.py and validate-family.py: where an earlier act left the branch, theswitch -cremedy says to give it another name, or go back only if it holds work not merged yet, and the docs sayfamily/add-<Project>(#607) #637"), in place of the "committedgit mv" of his "Refuse on every disk" ruling, becausegit mvto the row's path fails or misplaces the gitlink:git rm -r --cached --ignore-unmatch -- <variants>. It touches no file, and it is no error where HEAD alone holds the variant. Where a sparse checkout keeps a variant off disk (skip-worktree), the line saysgit rm -r --cached --sparse --ignore-unmatch -- ….git update-index --add --cacheinfo 160000,<commit>,members/<P>, one line per row, at the commit the variant entry records. For an unmerged variant, that is the commit at stage 2.git submodule update --init --recursive;mv -- Members/<P> ../Members-<P>for the directory left at the variant;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:No pinned file changes:
scripts/family.pyis the standard's own tool, not a copy in a holder. No--helptext changes, sodocs/cli.mdis untouched. Agit grepof AGENTS.md, README.md,docs/handbook.htmland the family-root templates found no sentence aboutadd's case-variant remedies, so none became false.Decisions and their evidence
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.
addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574's remedy, and moving it back would overwrite the row's gitlink.appears as both a file and as a directory) after the first has already taken the variant out. Both keep family.py:addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574's remedy (lane 3's review RV670, N3).addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574's remedy.test_only_the_members_own_gitlink_is_moved_backholds all of these, 9 cases.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 stagedgit rmis named again and fails retyped, themember-gitlink-absentremedy ends in a refusedadd, a shape-pin row with nopath:readsNone, and #639'smvorphans a live checkout and is stated by OS #681". That word replaces his 13:07:34Z take of "the row's pin".add: where another member's own gitlink sits at a case variant (Members/<P>for its rowmembers/<P>), the refusal's remedy printsgit rm -r --cachedorgit mv, which takes that member's gitlink away from its row #646's Direction line (<pin>) and from validate-family.py and family.py: a gitlink no section of HEAD's.gitmodulesgives ismember-gitmodules-absent,removerefuses a holder with no.gitmodulesbeforedeinit, and two gitlink remedies name what they missed (#611) #639's rule: validate-family.py and family.py: a gitlink no section of HEAD's.gitmodulesgives ismember-gitmodules-absent,removerefuses a holder with no.gitmodulesbeforedeinit, and two gitlink remedies name what they missed (#611) #639's restore invalidate-family.pynames the row's pin. The two tools give different lines where the variant records a commit the row does not pin, and, since S1, where a sparse checkout keeps the variant off disk:addprints--sparseand the validator does not (candidate (f)).member-gitlink-mismatch, which has its own LOCKSTEP text andbumpremedy, and exits 1, and anaddof another member still goes through.test_a_commit_the_row_does_not_pin_is_moved_not_bumpedtypes that end to end. (Where the variant equals the pin, the bytes are the same either way.)bump, which the LOCKSTEP text says the gate refuses.member-pinormember-pin-commitstops the row first. So validate-family.py and family.py: a gitlink no section of HEAD's.gitmodulesgives ismember-gitmodules-absent,removerefuses a holder with no.gitmodulesbeforedeinit, and two gitlink remedies name what they missed (#611) #639's rule has no answer there, and a pin-basedaddwould need the variant's commit anyway.update-index --cacheinfodoes not check that the object exists, and the printedgit submodule update --init --recursivethen fails with rc=128 (Fetched in submodule path 'members/IRRS', but it did not contain 3333…). The variant's commit is already recorded, so this remedy adds no new unreachable commit.(at <commit>)) would edit a pinned template, and that needs its own word, which nobody has given.An unmerged variant is moved back at its stage-2 commit (RV670, N2).
ls-files -slists stage 1, the merge base, first, and the PR's first head took that one.git rebase --quitthe move names the upstream's commit, not the person's own, which is stage 3 (probed by rv670b, the re-verify of this PR).$Q/v112/w646b/probes/w646b-unmerged-probe.log).addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574's remedy, 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 anOWNstate 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.git stash popleaves it, and so dogit merge --quitandgit rebase --quit(rv670b probed both).addrefuses a merge, rebase, cherry-pick, revert orgit amin progress (family-operation-in-progress) before it gets here.A variant a sparse checkout keeps off disk is taken out with
--sparse(RV670, S1). Without it,git rm -r --cached --ignore-unmatchexits 0 and removes nothing, so the index then held two gitlinks, and the nextaddrefused again. With it, the entry goes, measured on git 2.43 with and withoutcore.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 nogit rm --sparse: see "Left out".The commit is printed only where one is left to make. That is:
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 commitwould fail as typed.The code stays
family-members-dir-variant(ormember-path-takenwhere 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 ofmembers/: withcore.ignorecaseset, git would stage the new gitlink asMembers/<new>(family.pyaddcannot add any member, undercore.ignorecase=true(the macOS and Windows default), to a holder whose index holds a path under a case variant ofmembers/itself such asMembers/README, because git stages the new gitlink asMembers/<P>and the commit'smembers/<P>matches nothing, and the remedy its closing sentence prints fails the same way every time: a policy question for the maintainer, whetheraddrefuses such an index up front, and on which disks #362). Only the remedy changes.The closing sentences come last, after family.py:
addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574's disk remedies.members/renamed by hand is moved back (mv -- Members members), the checkout goes home with it.mvout of the holder would act onMembers/<P>while it may still be the member's checkout. Andgit submodule updatewould make amembers/, inside whichmv -- Members memberswould then putMembers(members/Members).test_with_the_other_remedies…[members/ renamed by hand]holds the order: it types the move back and thengit submodule update, which checks the member out in themembers/moved back.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, astests/test_family_members_case_variants.pyemulates it), each layout gives the same text the subprocess gives.The disk after the move.
?? Members/) or, after a fresh clone, an empty directory. The gate passes either way.mvis not typed, the nextaddrefusesfamily-members-dir-variant ... on disk and untracked: Members/Atlaswith family.py:addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574'smv -- Members/Atlas <new name>, and passes once that is typed.mvis typed, the nextaddpasses at once ($Q/v110/w646/leftover.log,repro-head.log).Tests
All runs used
$Q/v110/run9.sh: aghstub that exits 97 first on PATH,GH_TOKEN=bogus-l5, an emptyGH_CONFIG_DIR,/usr/bin/python3 -m pytest, and local bare remotes only.tests/test_family_add_member_variant.pyis 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 sameaddgoes through.test_each_layout_moves_the_gitlink_back_as_printed(4 layouts) andtest_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):Members/;members/renamed by hand;addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574's sparse case is);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 withmember-gitlink-mismatchalone, and the sameaddgoes 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:
Spelledhas two more fields,recordedandsparse, so a seven-field tuple compared intests/test_family_members_case_variants.pygains"", False;_members_spellingsintests/test_family_add_refusals.pytakesrows.The S1 case sits in the new file, beside its other move-back states, and not in
test_family_members_case_variants.pybeside #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
OWNand changes no code, so the runs of 2104a3d below stand for every file it does not touch.37 passed in 48.55stests/test_family_members_case_variants.py60 passed in 53.83sscripts/family.pyput back to the merge's (bc48c2a)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 2test_family_members_case_variants.py,test_family_add_refusals.py161 passed in 809.30stest_family.py,test_family_member_path.py,test_validate_family_unnamed_mounts.py,test_readme_windows_quoting_sentence.py,test_repo_hygiene.py269 passed in 803.63sThe 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):
29 failed in 48.75stests/test_family*.py, 21 files851 passed, 19 skipped in 1248.90stests/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.py1014 passed, 3 skipped in 1323.30sReproduction (local bare remotes, the
ghstub)Holder
Famwith members Atlas and Nova, Atlas's gitlink moved toMembers/Atlasin the index at its own commit and committed; thenfamily.py addof Zed ($Q/v110/w646/probes/repro.sh), at the PR's first head.repro-base.log)repro-head.log)git rm -r --cached -- Members/Atlas, commit: no gitlink for Atlas; gatemember-gitlink-absentupdate-index, commit: Atlas atmembers/Atlasat its commit; status clean; gate exit 0 in all three modes; nextadd --dry-runexit 0Members/Atlasgit mv -- Members/Atlas renamed-1, commit: gitlink atrenamed-1; gatemember-gitlink-absentupdate-index, commit,git submodule update --init --recursive,mvout: the same end stategit mv, commit: gitlink atrenamed-1;?? members/Atlas/; gatemember-gitlink-absentupdate-index, commit,mvout: the same end stateMembers/Atlas)git mv, commit: gitlink atrenamed-1; gatemember-gitlink-absentupdate-index, commit,git submodule update,mvout: the same end stategit commit: the removal committed; gatemember-gitlink-absent--ignore-unmatch, no error),update-index, commit: the same end stateIn 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).sparsetest_with_the_other_remedies…[kept off disk by a sparse checkout]--sparsenever printedtest_only_the_members_own_gitlink…[a file at the row's path]…[a file under the row's path]test_with_the_other_remedies…[unmerged, with nothing in progress]name in in_head ordeletedtest_with_the_other_remedies…[HEAD holding both, the row's removal staged]'posix'dropped from the move back's three quotingsshell_quotedquotes foros.name, POSIX here (see "PowerShell" under "Left out")ours[0] == b"160000"replaced by the mode of the first record git lists (mode == 0o160000), in place of stage 2's1 failed, 34 passed in 44.07s(-x), and1 failed, 8 passed, 28 deselectedfor-k test_only_the_members_own_gitlinktest_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.RESTOREinREMEDY_ORDERtest_each_layout…[nothing on disk in that spelling]test_only_the_members_own_gitlink…[a second gitlink beside the row's]…[a file at the variant]test_each_layout…[nothing on disk in that spelling]test_with_the_other_remedies…[with nothing left to commit]test_the_gitlink_is_recorded…[staged at another commit, HEAD keeping the row's]test_each_layout…[the removal staged, not committed]test_only_the_members_own_gitlink…[a file at the variant in HEAD alone]test_with_the_other_remedies…[HEAD alone, the checkout at the variant]…[after a pending change in HEAD]--ignore-unmatchtest_each_layout…[nothing on disk in that spelling]test_the_gitlink_is_recorded…[two variants in the index]test_with_the_other_remedies…[members/ renamed by hand]test_each_layout…[nothing on disk in that spelling]addpasses no rows_checked_mount_pathdrops the rows_rows_pathstakes any row's pathtest_the_rows_that_have_a_path_of_their_own_rows_pathsdoes not check the project's typetest_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-treepredicted. Only #637, #622 and #639 had changedscripts/family.pysince BASE.git merge-tree --write-treeof the head a837a02, last after a fetch at about 18:45Z. Every one is clean.--pin-source, the root's not-used note spells it too, and THE LOCKSTEP RULE'sbumpruns (#610) #634 (30676c9), scaffold-project.py andfamily.py init: a derived id's refusal names--idand its remedy leads to a naming refusal #659/scaffold-project.py and family.py:scaffold-bad-idandfamily-bad-idname an id derived from--projector--familyas derived and say to change that flag, so--project North_Windno longer says--id 'north_wind' must matchand sends the person to an--idthe naming policy then refuses (#659) #671 (6e37d0e), adopt-project.pyplan: an empty--idor--elected-byis read as not passed, and a derived id's refusal sends the person to an--idthe naming policy then refuses #658/adopt-project.py:planrefuses--id ''and--elected-by ''by name, a derived id's refusal says to change--project, or in place to rename the source first, and an org read from a url is named as not given (#658) #699 (b618ec1), adopt-project.py: a--work-dirthat is a file or cannot be made endsplan,checkandexecutein a traceback, and--helpsays nothing of--work-diror of where--orgcomes from #650/adopt-project.py: a--work-dirthat is a file or cannot be made is refused by name inplan,checkandexecute, and--helpsays what--work-diris and where--orgcomes from (#650) #719 (3dc34b8), shape-doctor.py: a checkout of the standard whose owncontracts/repository-naming.yamldoes not load makes a compliant projectINVALID (naming), exit 1, "the naming policy is not satisfied (exit 2)", so a person is failed for the checkout's fault, and the module's EXIT CODES list leaves out an unfinished merge #678/shape-doctor.py: a checkout whose owncontracts/repository-naming.yamldoes not load is CANNOT ANSWER, exit 3, for a project, where it readINVALID (naming), exit 1; any other naming exit 2 "refuses"; and the EXIT CODES list names an unfinished merge (#678) #701 (5c392dd) and adopt-project.py_default_branchreads the source's branch name throughgit_out, which decodes strictly, so a source whose HEAD (ororigin/HEAD) names a branch that is not UTF-8 endsplan,checkandexecutein a UnicodeDecodeError traceback, exit 1, fromSource.open, with no plan written and no refusal naming the branch #425/adopt-project.py:_default_branchkeeps a branch name's byte that is not UTF-8, so a source whose HEAD ororigin/HEADis oncaf<e9>is refused or adopted past it, and no longer endsplan,checkandexecutein a traceback (#425) #717 (2fac979). The other rows are the heads as fetched now.scripts/family.py:fix/684-family-holder-states7015386,fix/686-bump-reachability7567e60,fix/688-family-init-valuesaa95b6b,fix/689-next-push-refspecfa51384 (it also changestests/test_family_add_refusals.py) andfix/693-local-remote-dir96ebe45.fix/674-update-shape-applyd069902,fix/690-siblings-inputs38a40ab, tests/test_doctor_next_is_a_command.py: the comment-tail guard reads each string expression whole, across+and an f-string's pieces, and flags a#led by two or more blanks or a tab with or without a blank after it and a#that opens text after a part that is not a constant, socmd #x,cmd + " " + "# x"andf"{cmd}{pad}# x"no longer reach a NEXT unseen (#679) #698 b069951, shape-doctor.py:submodule_configandgitmodules_entryread a holder's.gitmodulesthrough onegit config -z --list, so a section name holding a space or a byte that is not UTF-8, or a url holding NEL or U+2028, gives the doctor the mount url git andmake siblingsuse, under any locale (#668) #700 fca40df, validate-pins.py and shape-doctor.py: one leg's gitlink off its pin beside another leg's digest or workflow reference names the step that comes first, that gitlink put back by hand, and then the other leg'sbump-leg.pyline, so neither finding says "First clear that finding" over a finding no line can clear first (#677) #702 48bac6e, setup-project.py: a family folder is named after the holder's REPOSITORY, so theQuill/Quillthatfamily.py init --name QuillWorksmakes is detected with no flag, and theQuillWorks/QuillWorksthatmake siblingsrefuses is not (#696) #703 (fix/696-setup-family-folder) 4054ffd, shape-doctor.py: themembersrow says, member by member, whatmake siblingsdoes with the directory beside the holder, in its own word and inplace's order, so it no longer says it places a member it leaves exactly as it is, and it spells every value it quotes, so an ESC or a CR in a url no longer reaches the terminal (#669, #673) #705 1bc63e8 and tests: validate-pins.py's workflow scan skips a directory, a dangling symlink and a looping symlink named.yml, and both of siblings.py's headers print the holder's root and folder spelled, so theS_ISREGchild check madelambda mode: True, or either path printed raw, now fails a test (#664) #706 (fix/664-surviving-mutants) 3e408b1.scaffold-bad-idandfamily-bad-idname an id derived from--projector--familyas derived and say to change that flag, so--project North_Windno longer says--id 'north_wind' must matchand sends the person to an--idthe naming policy then refuses (#659) #671 and adopt-project.py:planrefuses--id ''and--elected-by ''by name, a derived id's refusal says to change--project, or in place to rename the source first, and an org read from a url is named as not given (#658) #699 and lane 4's shape-doctor.py: a checkout whose owncontracts/repository-naming.yamldoes not load is CANNOT ANSWER, exit 3, for a project, where it readINVALID (naming), exit 1; any other naming exit 2 "refuses"; and the EXIT CODES list names an unfinished merge (#678) #701 are on main now, so the main row covers them.--no-membersprints the case-variant move (#648) #682 (bootstrap.py and validate-family.py:family bootstrap okwith no member checked out, an earlier row'smember-uninitializedhiding a case-variant refusal, andmember-gitlink-unnamedwith no command under--no-members#648, 8b39cd1),fix/692-raw-folder-pathsab55c29 (it changesscripts/family.pytoo) andfix/704-bootstrap-leg-moves4e2b211.Left out, and why
.gitmodulesthat names the variant too (agit mv members/<P> Members/<P>, committed or staged; checklist candidate (e) below). The move puts the gitlink back at the row's path, but the section still givesMembers/<P>. Sogit submodule updatefails withNo url found for submodule path 'members/Atlas', and the gate refusesmember-uninitialized($Q/v110/w646/gitmv-head.log). On BASE the same remedy moved the gitlink and the section torenamed-1(gitmv-base.log). validate-family.py and family.py: a gitlink no section of HEAD's.gitmodulesgives ismember-gitmodules-absent,removerefuses a holder with no.gitmodulesbeforedeinit, and two gitlink remedies name what they missed (#611) #639's validator prints the same incomplete lines for this state. A fix needs a.gitmodulesedit that the validator does not print.addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574's remedy is kept: it takes the variant out, and the file stays at the row's path. For a file at the row's path, typed, it leaves HEAD with100644 blob … members/IRRS, the gate refusesmember-gitlink-absent(exit 2), andaddof another member then goes through (exit 0), as rv670b ran it; a file under the row's path was not run. The move would replace that file, or stop half done, so no remedy here can both keep the file and put the member back. This state predates this PR.membersitself (a filemembers, or a committed linkmembers -> Membersleft after a hand rename; checklist candidate (h) below). The rule asks about entries at or under a row's path, not above it. With every member at a variant andmembersa file in the index,addprints the move back, and rv670b typed it line by line:git rmtakes out both variants (exit 0), then eachgit update-index --add --cacheinfofails with exit 128,'members/IRRS' appears as both a file and as a directory. The nextaddprints the same move again, now from HEAD. A committed link behaves the same. family.py:addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574's remedy also un-registered the member here, so it is not a regression. Addingor row.startswith(name + "/")to the rule would keep family.py:addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574's remedy there, which leaves the member with no gitlink, as the entry above does.scripts/family.pyis not changed for it here.--sparse.git rmhas no--sparseoption before 2.34 (git v2.33.0'sbuiltin/rm.c), and it skips everyskip-worktreeentry (if (ce_skip_worktree(ce)) continue;), so there the line without the flag was the same silent no-op. The printed--sparsestops with an unknown-option usage error, exit 129 (from reading that source; not run). family.py:addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574'sgit mv --sparsehas the same limit. The S1 test is skipped on git older than 2.34, as family.py:addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574's sparse test is.160000,<commit>,members/IRRSbecomes an array passed as three arguments, which--cacheinfoalso accepts. Not run, by RV670 or here. On Linux, dropping'posix'from these lines changes nothing (lane 3's mutant X3), sinceshell_quotedquotes foros.name, which is POSIX there.mvinherits validate-family.py and family.py: a gitlink no section of HEAD's.gitmodulesgives ismember-gitmodules-absent,removerefuses a holder with no.gitmodulesbeforedeinit, and two gitlink remedies name what they missed (#611) #639's N-1 and N-2 (rv611c), and its N-3:mvleaves its.gitfile pointing nowhere;mv's condition is stated by OS, not by a check a person can run, so on a case-folding Linux mount (WSL's/mnt/c) typing it moves the member's own checkout out, andgit submodule update --init --recursivethen puts it back;../<variant>directory that already exists is moved into.members/itself.members/irrsfor the rowmembers/IRRSis invisible toadd's_spelled_target, while the validator's casefold check names it (RV670, N6). This predates this PR.addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574: project names are ASCII, and git'sicasefolds ASCII only.Checklist candidates
family.py addandvalidate-family.py: the move back of a member's gitlink leaves.gitmodulesnaming the variant after agit mv. Aftergit mv members/<P> Members/<P>, git rewrites the section'spathtoMembers/<P>. The index-only move both tools print puts the gitlink back atmembers/<P>and leaves the section where it is.git submodule update --initthen fails withNo url found, and the conditionalmvwould move the member's only checkout out of the holder. Both could also printgit config -f .gitmodules submodule.<name>.path members/<P>andgit add .gitmoduleswhere a section gives a variant's path (evidence$Q/v110/w646/gitmv-head.log).validate-family.py: its move back of a member's gitlink is a silent no-op where a sparse checkout keeps the variant off disk. Itsgit rm -r --cached --ignore-unmatch -- <variant>has no--sparse, so it exits 0 and removes nothing, and itsgit update-index --addthen leaves two gitlinks in the index. This is the validator's half of RV670's S1, which RV670 assigns to Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459. The validator is a pinned template.validate-family.pyandfamily.py add: a file the index holds at or under a row's path, beside the member's gitlink at a variant, has no remedy that keeps the member.addnow keeps family.py:addrefuses, before the member is read and on every disk, any path under another spelling ofmembers/, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) #574's remedy there, which takes the variant out. The validator asksrecorded_gitlink, which skips any entry that is not mode 160000, so by reading its code (not run) it prints its move back for this state too:update-index --addwould replace a file at the row's path without a word, and fail on files under it after its first line has taken the variant out. This state predates 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.family.py add(and probably the validator, not run): a file or link the index holds atmembersitself is never named. The move printed beside it stops at itsupdate-indexline aftergit rmhas taken every variant out (above, under "Left out"). Nothing here fixes it; the coordinator places it.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 addremedies no longer unregister existing members.New Features:
Bug Fixes:
Enhancements:
Tests: