Repository navigation
validate-family.py and validate-pins.py: a staged gitlink removal says to commit it, member-gitlink-absent puts the gitlink back by hand, the variant mv is guarded, and a pin row with no path: is shape-pin-row (#681) - #747
Conversation
…HEAD's `.gitmodules` gives is `member-gitmodules-absent`, so a holder whose HEAD lost the file no longer says `family ok`, `remove` refuses it before `deinit` instead of leaving the mount empty, and the gitlink remedies name a case variant and the commit `add` needs (#611) 571(b). In a holder whose HEAD lost `.gitmodules` (the commit #407 describes), `family.py remove --dry-run` exited 0, and the real run did `git submodule deinit` and `git rm`, then died at its commit's `git --literal-pathspecs add -f -- .gitmodules family.yaml` (`pathspec '.gitmodules' did not match any files`); the rollback left `members/<P>` empty. `_checked_gitmodules_held` now refuses that holder, before any git command that writes and under `--dry-run` alike: no `.gitmodules` in the work tree, the index or HEAD while the index records the member's gitlink. Refused, not committed without the file, as #407 refuses a `.gitmodules` the work tree lacks. The remedy writes back, with `git config -f .gitmodules`, the section `family.py add` writes for each member whose gitlink the index records, then `git add -f .gitmodules`; `remove` then runs as it always has. 571(c), #407's decision 3. `validate-family.py` reads HEAD's `.gitmodules` (`_head_gitmodules`, HEAD's root tree listed with no pathspec) and makes `member-gitmodules-absent` for each row at `members/<project>` whose gitlink no section gives, in every mode, `--no-members` included. Its two lines write the section back as `family.py add` writes one, keeping the url of a section of that name that has one. A member it names that is not checked out is not refused `member-uninitialized`: that refusal named a `git submodule update` that exits 0 there and dropped the finding, which is what a fresh clone and CI met. It reads HEAD, not the work tree, so a deletion nobody committed, an unstaged edit and a sparse checkout that leaves the file out are no finding, and a holder with no members is asked nothing. 574/2 (#363's item 3). A row whose gitlink HEAD records only at a case variant of its path (`Members/<P>`) is refused `member-gitlink-absent` naming each variant, and the move that puts it back in the index alone: `git rm -r --cached` of the variants and `git update-index --add --cacheinfo 160000,<pin>,members/<P>`, then a commit. `family.py add`, the plain refusal's remedy, cannot help there: the row exists. 574/3. A hand mount's `member-gitlink-unnamed` remedy says to commit the `git rm` before the `add`: since #574, `add` refuses a path under another spelling of `members/` that HEAD holds and the index no longer does. `templates/family-root/scripts/validate-family.py` is a pinned copy: each holder sees it as `upstream-changed` on its next `update-shape.py check`. Tests: tests/test_family_head_lost_gitmodules.py (new, 24); the pinned remedy text and the case-variant states in tests/test_validate_family_unnamed_mounts.py; a docstring in tests/test_family_remove_gitmodules.py. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-absent` reads and the members `remove`'s refusal is for are pinned, so a finding for a row with no gitlink or a hand-edited path, or the refusal over a member with no gitlink, no longer passes (#611) Two tests. In a holder whose HEAD lost `.gitmodules`: a row with no gitlink (`member-gitlink-absent`'s) and a row whose `path:` was edited by hand (`member-path`'s, its mount `member-gitlink-unnamed`'s) get no `member-gitmodules-absent`; and `remove` of a member with no gitlink is not that refusal, but the dry run's `did not match any file(s) known to git`, as before. Each holds a condition of the first commit that its other tests did not: both pass on BASE 396c0ff and on that commit. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….gitmodules` over an unmerged gitlink too, and the case-variant move prints `--ignore-unmatch` and the `mv` a case-sensitive disk needs, so the mount is no longer emptied there and the printed lines work half typed or with both directories on disk (#611) Fix round 1, from the review of 6f938ed. `_checked_gitmodules_held` (scripts/family.py) took the member's gitlink only at stage 0. `git submodule deinit` and `git rm` run on an unmerged gitlink as on a merged one, so with `members/<P>` held only at stages 1, 2 and 3 in a holder whose HEAD lost `.gitmodules`, the dry run exited 0 and the real run emptied the mount and died at its commit. It now takes a gitlink at any stage, and both runs refuse `member-gitmodules-absent` before `deinit`, with the holder byte for byte as it was. The docstring says so. The case-variant refusal of `member-gitlink-absent` (templates/family-root/scripts/validate-family.py, pinned) now prints `git rm -r --cached --ignore-unmatch -- <variants>`: a variant whose removal is staged and not committed is still named from HEAD, and the plain line failed there (`pathspec ... did not match`). Its remediation also says what is left on disk: where nothing is checked out at the row's path, as in a fresh clone, `git submodule update --init --recursive` then checks the member out; on a case-sensitive disk a directory left at a variant stays, untracked, and `family.py add` refuses it as `family-members-dir-variant`, so it gives an `mv` of each out of the holder, to `../<variant with / as ->`, and says to leave it on macOS and Windows, where it is the row's own directory. The validator's module docstring names `member-gitmodules-absent`, says what a staged, uncommitted `.gitmodules` does (HEAD's file is what counts, as #407's decision 3 says: an uncommitted `add` is the finding for its member, a staged section removal is none, a staged restore is still the finding), and gives EXIT CODES' one exception, an unregistered member that is not checked out (exit 1, not 2). Tests (tests/test_family_head_lost_gitmodules.py, 12 more, 38 in all): the unmerged gitlink refused alike and repaired as printed; one test per condition no test held (a checked-out member still gets its tree check beside the finding; HEAD's unreadable file refused in every mode; the `<url>` placeholder; `remove`'s remedy names only members with a gitlink in the index; one finding per path for a duplicate row); the variant move half typed (both disks); and, on a case-sensitive disk, every printed line typed with both directories present, after which the gate passes and another member is added. tests/test_validate_family_unnamed_mounts.py pins the `--ignore-unmatch` line. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…modules: the gitlink remedy keeps #611's commit-the-removal clause and ends with BRANCH_LEFT (#611 x #607) This merges origin/main at 95780dd, which holds #637's squash (95780dd, "family.py and validate-family.py: where an earlier act left the branch ...", #607). It adds `BRANCH_LEFT` to scripts/family.py and templates/family-root/scripts/validate-family.py, and the guard `test_every_remedy_that_names_a_switch_line_ends_with_the_sentence` in tests/test_family_switch_branch_exists.py. Lane 2's #634 (#610) is not in main yet, so `_check_member_gitlink` is untouched here. Two hand resolutions, one sentence in two places, as #639's body named it: - templates/family-root/scripts/validate-family.py, the end of `member-gitlink-unnamed`'s remedy: #611's "and commit that removal, since family.py add refuses a path under another spelling of members/ that HEAD holds and the index no longer does, then mount it with the third, ..." clause is kept, and #607's `BRANCH_LEFT` ends the prose, as "... if one is still there. {BRANCH_LEFT}\n". - tests/test_validate_family_unnamed_mounts.py, `GITLINK_REMEDY`: the same clause, ending ".git/modules if one is still there. " + BRANCH_LEFT. No other file conflicted. #637's guard test passes on this merge without a further change: the remedies #611 added name no `_switch_line`, so no BRANCH_LEFT is appended beyond the one resolved above. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ck out fails the family bootstrap, every row's gitlink is asked before any row's checkout, and `--no-members` prints the case-variant move, so a clone of a holder whose HEAD lost `.gitmodules` or of #363's state no longer ends `family bootstrap ok` or hides the variant behind `member-uninitialized` (#648) (a) `templates/family-root/scripts/bootstrap.py` said "every member and leg fetched" and ended `family bootstrap ok`, exit 0, over members it had not checked out: in a fresh recursive clone of a holder whose HEAD lost `.gitmodules` (git exits 0), and in a plain clone of that holder or of #363's state, where git dies `No url found for submodule path` (exit 128). After the fetch it now asks this holder's own records of each row whose member is not checked out: no gitlink at the row's path (`recorded_gitlink`, as `member-gitlink-absent` reads it), or no section of `.gitmodules` giving that path, or, where git exited 0, any such row. Each is named on one `NOT CHECKED OUT, and not for want of a credential` line with why, which names `validate-family.py`, and the run ends `family bootstrap FAILED: <P> (not checked out)`, exit 1. "every member and leg fetched" is said only where git exited 0 and every row is checked out. A row git could not fetch for want of a credential is still the skip it was (`test_bootstrap_degrades_when_a_member_cannot_be_fetched`), and a good holder's run prints what it printed. (b) `validate-family.py` asked each row whole, gitlink then checkout, before the next. In a plain clone of #363's state plus `git submodule update --init --recursive`, which dies at `Members/<P>` before it checks out any member, an earlier row was refused `member-uninitialized` (naming that same update) and the later row's variant refusal was never reached. `_check_member` now stops at the gitlink and returns the member (`Gitlinked`); `_check_member_checkout` asks the digest and identity of each once every row's gitlink has been asked. (c) Under `--no-members`, `member-gitlink-unnamed` for a member's only gitlink, in the wrong case, printed no command. Where the row's path is its canonical one and its pin a commit, it now prints the index move the row's members-mode refusal prints, word for word (`_wrong_case_move`, shared by both), to the pinned commit, every variant on one line. Docs: the validator's WHAT IS CHECKED docstring (the order; the move under --no-members) and bootstrap's (a) and (c). README.md's "A member it cannot fetch is reported and skipped, never a failure" is no longer true, so it says "One it lacks a credential for is reported and skipped, not a failure", in the same line count (README is at its 1449-line cap), and docs/handbook.html says the same, with its § Families marker set to the segment's new blob. Pinned copies changed: templates/family-root/scripts/bootstrap.py and templates/family-root/scripts/validate-family.py. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tstrap-and-gate-order, as lane 5's coordinator asked (#648 x #611) This merges origin/fix/611-validate-family-head-gitmodules at 08b6245, #639's head after its own merge of main 95780dd (#637's squash, #607), keeping that merge's one hand resolution: `member-gitlink-unnamed`'s hand-mount remedy ends with #611's commit-the-removal clause and then `BRANCH_LEFT`. No file conflicted. The remedy #648 adds under `--no-members` (`_wrong_case_move`) names no `_switch_line`, so #637's guard asks no `BRANCH_LEFT` of it. README.md's § Families segment, which #648 edits, is the same in both parents, so docs/handbook.html's marker for it stays the blob #648 set. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not committed says to commit it, `member-gitlink-absent` puts the gitlink and its section back by hand, the variant `mv` is guarded by `-ef` and a `.git` test, and a shape-pin row with no `path:` is `shape-pin-row`, so every remedy types as printed in the state it names and `None` is no path (#681) The holder's validate-family.py (pinned in every holder) and the assembly root's validate-pins.py (pinned in every assembly root); words and one check, no family.py change (Brett Heap, 2026-10-16T14:11:42Z, "take all recommendations for #670 and #681"). - L20. `_staged_removals` names the gitlinks HEAD records where the index holds no entry at the path, at any stage. `member-gitlink-unnamed` for one says the removal is staged and to commit it, and prints no `git rm`, which failed typed again (`pathspec ... did not match any files`, exit 128): a hand mount keeps its branch and `add` lines, a second path's says only to commit. A section `git rm --cached` leaves keeps its own `--unset-all` lines, since no `git rm` above takes it out. An unmerged path is a conflict, not a removal, and keeps its `git rm`. - L2-38. `member-gitlink-absent` with no case variant named `family.py add`, which refuses the row that is there (`member-already-present`); `remove` dies at `git submodule deinit`. It now prints the `update-index --cacheinfo` line that puts the gitlink back at the commit the row pins, and, where HEAD's `.gitmodules` gives no section the path, the section lines `member-gitmodules-absent` prints (`_section_lines`, shared); or the row taken out by hand. - N-1 and N-2. The variant move's `mv` is now `[ <variant> -ef <path> ] || [ -e <variant>/.git ] || mv -- ...`, typed in a POSIX shell: it moves nothing where the two names are one directory (a case-folding disk, WSL's /mnt/c included) or where the directory is a checkout, whose submodule `.git` file a move leaves pointing nowhere. The text says its commits are kept in `.git/modules/<variant>` and to delete a checkout by hand once nothing it holds is needed. It no longer states the condition by system. - L2-3. A `files:` row whose `path:` is absent, empty or no string is `shape-pin-row` in both validators, where it was `shape-copy-missing` for `None`. Tests: tests/test_validate_family_remedies_typed.py (L20, L2-38, N-1, N-2, each typed as printed; the case-folding disk emulated with a `Members` symlink) and tests/test_shape_pin_row_path.py (L2-3, both validators); the pinned texts and typed helpers in test_family_head_lost_gitmodules.py, test_family_bootstrap_gate_order.py, test_validate_family_branch_step.py, test_family_switch_branch_exists.py and test_family.py follow the new words. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…member THE LOCKSTEP RULE rides on `Gitlinked`, so the two-pass gate (#648) passes it to the digest finding (#648 x #610) One file conflicted, `templates/family-root/scripts/validate-family.py`, in two regions, both where this branch split `_check_member` in two (the gitlink first, the checkout once every row's gitlink is asked) and #634 (#610) made THE LOCKSTEP RULE a per-member `_lockstep(...)` value passed to the gitlink and tree checks: 1. `_check_member`'s gitlink call. #634 computes `rule = _lockstep(root, project, str(row.get("id") or project))` and passes it to `_check_member_gitlink`; this branch returns a `Gitlinked` there. Resolved: the call passes `rule`, as #634 has it, and `Gitlinked` gains a sixth field, `rule`, which the return carries. 2. `_check_member`'s tail, which this branch moved into `_check_member_checkout`. #634 passes `rule` to `_check_member_tree`. Resolved: `_check_member_checkout` passes `asked.rule`. So each member's gitlink-mismatch and digest-mismatch findings end at the same rule #634 gives them, naming that member, and the closing count's `_lockstep(root)` is #634's, merged clean. `Gitlinked`'s docstring names the new field. No other file conflicted. The resolution is the one rv648 built and tested in its scratch clone (the same blob of #634's file, 519b71f), plus that docstring. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-5, 2026-10-16T18:46Z) @codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Reviewer's GuideThe PR hardens validator diagnostics and repair guidance by making every printed remedy executable in its named state, safely guarding variant moves, restoring absent gitlinks through direct index operations, and consistently rejecting malformed shape-pin paths, with extensive typed-remediation coverage. Sequence diagram for validating and repairing an absent member gitlinksequenceDiagram
participant Validator
participant Index
participant Gitmodules
participant User
participant Git
Validator->>Index: _member_gitlinks()
Validator->>Index: _staged_removals()
alt staged removal
Validator-->>User: Commit that removal
else gitlink absent after removal
Validator-->>User: git update-index --add --cacheinfo
Validator->>Gitmodules: _section_lines()
Validator-->>User: git config -f .gitmodules
User->>Git: git commit
User->>Git: git submodule update --init --recursive
end
Sequence diagram for rejecting malformed shape-pin pathssequenceDiagram
participant Validator
participant Row
participant Report
Validator->>Row: row.get(path)
alt path is absent, empty, or not a string
Validator->>Report: finding(shape-pin-row)
else valid non-empty string
Validator->>Report: Check pinned file and sha256
end
Flow diagram for safely moving a case-variant member directoryflowchart TD
A[Variant directory found] --> B{Variant and row path are same directory?}
B -- Yes --> D[Leave directory in place]
B -- No --> C{Variant contains .git entry?}
C -- Yes --> D
C -- No --> E[mv -- variant ../variant-name]
D --> F[Delete checkout by hand when no longer needed]
E --> G[Continue validation]
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d91fd15ef9
ℹ️ 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".
| f"out at {path}. If it is to go, take its row out of {MANIFEST} by " | ||
| "hand and commit that.\n" |
There was a problem hiding this comment.
Remove the leftover section when dropping a cached member
When the missing gitlink resulted from a committed git rm --cached, HEAD's .gitmodules still contains the member's section; following this new “to go” instruction removes only the row, so the next validation reports gitmodules-path-unnamed instead of completing the recovery. The remedy must also remove the section or provide a tool-driven removal path, rather than requiring the manual members: edit that the repository procedure forbids.
AGENTS.md reference: AGENTS.md:L394-L400
Useful? React with 👍 / 👎.
…n gives a url fails the run naming every row, and a member git exited 0 over is given the update that checks it out, so a stray case variant, a hand mount or a url-less section no longer ends `family bootstrap ok` (#648, rv648) The fix round for rv648's review of 8b39cd1 (1 MUST, 2 SHOULD, 5 NIT). M1. In a plain clone, `git submodule update --init` dies in its init step at the first gitlink, in index order, that no section of `.gitmodules` gives a url, and checks out no member. `unfetchable` asked only each row's own gitlink and section, so a stray `Members/<P>` beside `members/<P>` (#361's STRAY), a member mounted by hand with no section, or a row's section that lost its url (rv648's S1, H1 and U1) passed every row as the credential skip, and the run ended ok. `init_stop` now asks the holder's records which gitlink git's init stops at: the index's gitlinks in order, each against the work tree's sections (`gitmodules_sections`, which replaces `gitmodules_paths`) and the git config. Measured before it was taken, rv648's prototype failed three states that are a missing credential: a recursive clone of the stray or of the hand mount with no credential, and a url-less section an earlier `git submodule init` registered. So two of git's rules are kept: where the config sets `submodule.active` (a recursive clone sets `.`), init asks only the gitlinks a section gives, and a section with no url does not stop it where the config holds a url under its name. Where git exited non-zero and init stops at a gitlink, every row not checked out is named `git stopped at <gitlink>, a gitlink no section of .gitmodules gives a url`, and the run fails. What cures it: `validate-family.py --no-members` names a gitlink no row names and the commit that cures it (members checked, it refuses `member-uninitialized` for the first row in these states, a checklist candidate). Where a row names the gitlink, nothing in the validator does, so the line says to write that section back as `family.py add` writes one. Each why now carries its own sentence. S1. For "git exited 0 and checked nothing out there" (a subset clone, an `update = none`), the line printed the validator sentence, whose `member-uninitialized` remedy exits 0 and checks nothing out. It prints `git submodule update --init --checkout --recursive -- <path>` instead; typed, it checks the member out in both states and the run ends ok. S2. `test_a_missing_credential_is_still_the_skip_it_was` asserts that " every member and leg fetched" is not printed (rv648's mutant R2). NITs. README and the handbook say `make bootstrap` skips a member it lacks a credential for, and the § Families marker is the new segment's blob, b3d19092; README stays at 1459 lines, its cap. The docstrings say "an empty mapping" and "a missing credential exits non-zero too", and `validate-family.py`'s WHAT IS CHECKED says the move under `--no-members` is printed only for a canonical path and a 40-hex pin. Tests: the variant-plain case and the round trip name MEMBER too; new tests for S1 and H1 (typed through `--no-members` and committed, the run checks both out), U1 (the section written back), the two recursive clones with no credential and the registered url (still the skip), and the update that exits 0, by `update = none` and by a subset clone, with its printed line typed. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d-removal text says to delete the checkout before `family.py add`, and the variant move's text says what a clone's own `.git` holds, where a submodule checkout's store is, and "in a POSIX shell", so each step typed as printed reaches a passing gate (#681, rv681) The fix round for rv681's review of d91fd15 (0 MUST, 2 SHOULD, 3 NIT). Text only, in the pinned holder copy; no guard changes. - S1. A hand mount taken out with `git rm --cached` (as git suggests when the printed `git rm` refuses a checkout with changes) leaves its checkout at the path, and the printed `family.py add` refused it (`member-path-taken`). The staged branch of `_mounted_by_hand` now says to delete it by hand, once nothing only it holds is needed, before the `add`. - S2, Direction B. The `-e` guard stays, as the issue's "no .git entry" that Brett Heap's 2026-10-16T14:11:42Z word took. For a `.git` directory, a clone of its own, the text says its commits are there and nowhere else, names `git -C <variant> log --oneline HEAD --branches --not --remotes` for the ones not pushed, and says to move it out whole rather than delete it. - N1. A submodule checkout's commits are "kept under this holder's .git/modules/, in the directory its .git file names", not always `.git/modules/<variant>`. The status check no longer promises to work where a plain `mv` brought the checkout to the variant. - N2. The guarded line's sentence says "in a POSIX shell". - N3. A test kills rv681's mutant MO1: the section written back in the work tree alone still prints its lines. Tests: the hand-mount L20 test gains a `git rm --cached` case typed to `add` exiting 0; the checkout-at-the-variant test keeps the clone, types its printed `git log`, moves it out whole, and gains a plain-`mv` kind; a new test for N3; the pinned variant text follows the new words. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing in #414's url-less member state now fails, as 1a69505 makes it, so the test pins exit 1 and `git stopped at members/Ghost` where it pinned the old `ok` (#648, rv648's U1) `test_414_3_there_the_bootstrap_cures_nothing_and_the_git_line_cures_one` builds rv648's U1 state: a member, Ghost, whose `.gitmodules` section gives its path and no url. Git's init stops the holder's update there, before it checks out the other member. The test asserted bootstrap's exit 0, the false `ok` rv648's M1 asked to end. It asserts exit 1 now, and the line naming where git stopped. Its point is unchanged: the bootstrap cures nothing, and the doctor's git line cures the other member. Found by the targeted run of every test file that drives the family bootstrap (1 failed, 3797 passed, 32 skipped); the file now passes alone, 124 passed. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eck_member_gitlink` takes both `repository` (#681) and THE LOCKSTEP RULE's `rule` (#610), and the gate-order test keeps `import re`, which the merge dropped with no conflict (#681 x #648) #682 (fix/648-bootstrap-and-gate-order) moved on from 8b39cd1 to 45c512e: main 2fac979 merged in (bad6c00, which brings #634's per-member THE LOCKSTEP RULE onto `Gitlinked`), then 1a69505 and 45c512e. Two files conflicted; every other file merged clean. templates/family-root/scripts/validate-family.py, three regions: - WHAT IS CHECKED: #682's narrower wrong-case sentence, then #681's staged-removal sentence after it. - `_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, two regions: - the module docstring keeps both sentences, the guarded `mv` (#681) and the clone of a stray (rv648's S1); - the import from test_family_head_lost_gitmodules is the union of both sides (GITMODULES and committed_gitmodules from #682, GUARDED_MOVE and typed_in_sh from #681). - `import re` is put back by hand: this branch had dropped it, and #682's side uses `re.compile` and `re.findall` again (rv681's restack note). `import subprocess` stays dropped; neither side uses it. The resolution equals rv681's model (_lane5-scratch/rv681-restack2, built on d91fd15) with this branch's fix round d91fd15..dbca113 applied (`git merge-file`, byte for byte), and the gate-order test is the model's byte for byte. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ember git has no url for is its own reason, and a gitlink outside members/ gets a cure true for it, so a later duplicate section, a section under a name git ignores or a url git drops no longer ends `family bootstrap ok`, and a subset clone's printed update no longer dies `No url found` (#648, rv648's verify) rv648's verify of 45c512e (1 MUST, 1 SHOULD, 3 NIT). M1. `init_stop` read `.gitmodules` with `git config --file --list`, which keeps entries git's submodule parser drops or overrides. In three plain clones git's init died (`No url found`, exit 128) and the run still said ok: a second section for a member's path, with no url, after its own (D1); the member's section under the name `../Nova` (Q1); and its url `-nowhere` (O1). `gitmodules_sections` now returns a `Gitmodules` with git 2.43's own reading beside the one the validator's findings use: - a section whose name is empty or has a `..` component, split on `/` and on a backslash, is ignored whole (`check_submodule_name`); - a `path` or `url` that begins with `-` is dropped, and the value before it stands (`looks_like_command_line_option`); - where two sections give one path, the later decides, and a section that gives a path again lets go of the one it gave, even where a later section gave that one too (`cache_put_path`, `cache_remove_path`). `has_url` asks the section git reads for a path, then the config under that section's name. `init_stop` and the reasons ask it. Where more than one section gives the path git has no url for, the line names `validate-family.py --no-members`, whose `gitmodules-path-twice` says to take the others out: a section written back would be one more (D1). S1. In a subset clone of a member whose section has no url (A1), git exits 0 and the line printed the exit-0 skip's update, which dies `No url found` when typed. "git has no url for that path" is now its own reason, asked before "git exited 0", with the write-it-back cure. The stop's sentence says "a gitlink it has no url for", which is true in D1 too, where a section does give the path a url. NIT 1. A gitlink outside members/ that git stopped at (H2) was sent to `--no-members`, which reads only members/ and says `family ok` there. It now gets `git rm --cached -- <path>` and a commit: measured, that ends ok and the gate passes in every mode, while a section written for it leaves `gitmodules-path-unnamed` in every mode. NIT 2. The `cures` docstring says what members mode refuses for #363's variant (`member-gitlink-absent`, with the move) as well as for a stray or a hand mount (`member-uninitialized`). Tests in tests/test_family_bootstrap_gate_order.py: U1, Q1 (three names) and O1 in one parametrized test, each written back; D1, typed through `gitmodules-path-twice`; D2, a name with dots and a url `-x` after a url, each still the credential's skip and then a true ok; A1, typed through both rounds; and H2, typed and the gate passing in every mode. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er) into fix/681-validate-family-remedies: the family root's bootstrap.py takes `.gitmodules` as git's own parser reads it, a url-less member as its own reason and a cure true for a gitlink outside members/, with this branch's validate-family.py kept (#681 x #648) 23e1105 is one commit on 45c512e, which this branch already holds. It touches templates/family-root/scripts/bootstrap.py and tests/test_family_bootstrap_gate_order.py only. `git merge` took it with no conflict, and `git merge-tree --write-tree` had said the same before the merge. tests/test_family_bootstrap_gate_order.py still has `import re`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ts own reason, since git reads no section from it, so a plain clone mid-merge whose update dies `No url found` no longer ends `family bootstrap ok` (#648, rv648's MM1) rv648's verify of 23e1105 (1 MUST). In a plain clone, a merge that conflicts in `.gitmodules`, resolved in the work tree and not added, leaves the index holding the file only at its three conflict stages. Git then reads no `.gitmodules` at all (`repo_read_gitmodules` asks `is_gitmodules_unmerged` first), so its init dies at the first gitlink, exit 128. `gitmodules_sections` read the work tree's file, found every section with its url, and the run ended ok. `gitmodules_sections` now asks `git ls-files -u -z -- .gitmodules` first. Where the index holds the file at a conflict stage, it returns nothing, with `Gitmodules.unmerged` set. `unfetchable` asks that first: every row not checked out gets "git reads no .gitmodules while a merge leaves it unmerged", and the line says to resolve the merge in .gitmodules and `git add .gitmodules`, then run this again. Added, the same run checks both members out. Test: rv648's MM1 in tests/test_family_bootstrap_gate_order.py, the two sides of the merge each giving OTHER's section another `branch`; the cure typed, and the run ends ok. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y: the shape doc names the layout's folder after this holder's repository and the rule's subject as "This holder's parent folder", and the hand-edit passages, the folder rule, `member-gitlink-absent`'s raised refusal and `RESTORE_ROW`'s inline copy are read whole, so a one-word edit that puts the contradiction back fails a test (#763) Fix round 1 of #822, on rv763's review (NOT READY: 14 of 19 one-line mutants survived the first tests, two of them putting the issue's contradiction back). The sentences were true; the tests looked for words, not meaning. templates/family-root/AGENTS-shape.md, words only, in the pinned copy that already costs every holder one `upstream-changed` row: - the layout paragraph said "a plain folder named after the family". For `--family Quill --name QuillWorks` that reads as the display name. It now says "a plain folder named after this holder's repository", which the holder's README says is what `{{FAMILY}}` is. - the folder rule said "The parent folder must be named EXACTLY after its repository", where the folder is no repository and "its" has nothing to refer to. It now opens "This holder's parent folder must be named EXACTLY after its repository", as the standard's README says "The holder's parent folder". tests/test_family_docs_one_spelling.py: - each doc's hand-edit passage, from "Never hand-edit" or "Do not hand-edit" through "the commit is then the person's.", is held whole (`PASSAGES`), as `ONE_SPELLING` is. The old tests looked for loose words between the two ends, and "but for a row repair" turned into "not even for a row repair" passed them. - `FOLDER_RULE`, "parent folder must be named EXACTLY after its repository, case included, on every disk", is read in the standard's README, the handbook, the holder's README and AGENTS-shape.md, so a "need not be" or a "should be" fails; the shape doc's layout wording is held. - `member-gitlink-absent` is raised through `_check_member_gitlink` and its code and its remedy are read together, where the old test read a helper and asked whether the code appeared anywhere in the file. That drops the second copy of #738's set of string constants. - the inline copy of `RESTORE_ROW`'s words in `gitmodules-path-unnamed` is read against the constant, so the two cannot say different things. Lands after #738 and #747 (v1.1.0). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…its owner, "holder's parent folder must be named EXACTLY after its repository, case included, on every disk", in the standard's README, the handbook and both holder docs, so a rule given to "A member's" or to "The parent folder" fails (#763) Fix round 2 of #822, on rv763's NIT 9. `FOLDER_RULE` began at "parent folder must be", so a doc that gave the rule to another subject, "A member's parent folder" or "The parent folder" (which, in a doc about a plain folder that is no repository, leaves "its repository" nothing to refer to), still passed. It now begins at "holder's parent folder must be". All four places carry that text: "The holder's" in README.md, "holder’s" in the handbook (which `shown()` reads as "holder's") and "This holder's" in the holder's README and AGENTS-shape.md. Words in the tests only; no doc, tool or remedy changes. Lands after #738 and #747 (v1.1.0). Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 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>
Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # templates/family-root/scripts/validate-family.py # tests/test_family_head_lost_gitmodules.py # tests/test_validate_family_remedies_typed.py
main gained #747 (#681, `6626a3c5`) and #817 (#708, `64ec47c3`) since 40e678e. No conflict, and neither touches README.md, the handbook, AGENTS.md, docs/ or the README cap's ledger, so every handbook marker reads as before. README.md is 1474 lines, its cap; AGENTS.md 494, its cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s and #817's entries, #642's upgrade note for a holder's `cd` line, and v1.0.0's `--into` limit kept open, current to main 64ec47c (#563) RV630's pre-review of the B2 stage (lane 3's session): - SHOULD-1: the CI bullet said every test that clones a just-pushed local remote clones it safely (#554), but five plain-path `git submodule update --init` runs are left on main (tests/test_doctor_legs_members_next.py:631, :987, :1054; tests/test_family_bump_remove_rollback.py:337, :908). It now says "runs `git clone` on", which is what #554 changed. - NIT-1: #812's READMEs quote a local clone URL "that needs it". - NIT-2: #682's first sentence no longer repeats the lead ("there:"). Landed since 40e678e, each entry checked against its squash: - #747 (#681), 6626a3c, whose tree equals `merge-tree 40e678e ff7c59b`: a new family bullet, with RV630's NIT ("`member-gitlink-absent`'s remedy puts the gitlink back by hand"); v1.0.0's limits at v1.0.0.md:466 and :467 are said closed. - #817 (#708), 64ec47c, whose tree equals `merge-tree 6626a3c d63a267`, and whose own lines equal those of 8fd2fa6, the head RV630 read: RV630's text, appended to the YAML reader bullet. #738 (#695) is v1.1.1 (lane 5, #473 issuecomment-6236564004), so its docs entry is not taken, and v1.0.0's `--into` limit (v1.0.0.md:492) is listed as still open under Known limits. Its upgrade note is true of main since #642 (0ed1b84), so it goes in now, under "Install and upgrade": lane 5's sentence verbatim, and the README's `Y/Y` layout. Counts and the open list at main 64ec47c: 113 pull requests since bfed143; 124 of 135 milestone issues closed at 16:42Z; 11 open, each with a pull request open, #681 and #708 out, #695 marked as planned for v1.1.1. All still placeholders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main bad05d2 brings #747 (#681), which changed `_check_shape_copy` in templates/assembly-root/scripts/validate-pins.py, and #817 (#708), which changed `repo_shape.load_yaml`'s `yaml-unparsable` clause and added tests/test_reader_refusals_named.py. One conflict, in `_check_shape_copy`: #747 replaced `target = root / str(row.get("path"))` with its `path:` check (a row with no string path is `shape-pin-row`), and this branch changed the `is_file()` line under it. Resolved as the pull request's body has said since round 0: #747's check and `target = root / path`, then this branch's comment, `named = _shown(path)` and `if not _is_file(root, target, "shape-copy-unreadable", named):`. RV827 found it identical to its own resolution. `load_yaml` merged cleanly: #817 changed the `yaml-unparsable` clause and this branch the `OSError` clause and the docstring. One line merged cleanly and is wrong together (RV827's M2): tests/test_reader_refusals_named.py:199, from #817, expected the root's `validate-pins.py` to name the pin in its `yaml-unparsable` line by `pin.resolve()`, the absolute path. This branch names every file the gate names relative to the root (#712), so the line expects `contracts/spec-pin.yaml`. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s `--sparse` and moves an entry in the way aside, a row's staged removal and a url-less section are findings, and two refusals name a cure that works (#762) (#820) Against main. #747 landed as `6626a3c`, and this branch is restacked onto main as `98b0536d`: it merges #747's final head `ff7c59b8`, then main, taking this branch's side in the three files #747 also changed. Over main it carries exactly its own three commits: `git diff 58d6c50 ef17866` (over #747's head `58d6c50f`, where it was written) and `git diff origin/main 98b0536` have the same patch-id. **Goal (#762): every finding and refusal the holder's `validate-family.py` prints for a member recorded in the wrong case, a gitlink git stops at, or a commit its checkout lacks says what git does and prints a remedy that works in the state it names, and the gate names a member's un-registration before the commit that makes it.** **Pinned file.** Only `templates/family-root/scripts/validate-family.py` changes among the shipped files. It is pinned in every holder (`FAMILY_COPIED_VERBATIM`), so each holder sees one `upstream-changed` row for it, re-pinned in the next shape bump. The family root's `bootstrap.py` is not changed (line 8 below). No README, handbook, `AGENTS.md` or `docs/cli.md` sentence is made false, and no `--help` text changed. Every state below was measured on git 2.43.0, in the test family (`tests/test_family.py`'s fixture), on this branch's base `58d6c50f` and then on this head. Each new test types every printed command as printed, in the state it names, and then runs the gate. rv820 (lane 2) reviewed 1038d7f: NOT READY. 9e2dc7d answered MUST 1. This commit answers SHOULDs 2 to 4 and NITs 5 to 7. It changes `templates/family-root/scripts/validate-family.py` and `tests/test_validate_family_typed_states.py` only. **SHOULD 2: `member-uninitialized` named a stop git never makes, and a cure that dropped the other member's section.** - **Reproduced:** in a plain clone with `.gitmodules` deleted and not staged, members checked said `git submodule update --init` stops at members/IRRS, and told the person to write that section back. In fact git read the index's file, and the update exited 0 and checked out both members. With the deletion staged, git read HEAD's file, with the same result. In the url-less state (U1), the old text named members/IRRS where git stops at members/IRSS. - **The fix:** `_gitmodules_put_back` reads the `.gitmodules` that git's submodule commands read when the work tree's file is gone: `:.gitmodules`, then `HEAD:.gitmodules`, each found with `git rev-parse --verify --quiet`. `_init_stop_said` now reads that file. - **Where git really stops there,** the refusal names that path and prints the line that puts the file back first: `git checkout -- .gitmodules`, or `git checkout HEAD -- .gitmodules` where the index holds none. Then comes the cure, so the cure keeps every other section. After that line, `--no-members`, which reads the work tree's file, names what the cure says it names. - **`member-gitmodules-url-absent`** (new in this PR) had the same fault: its `git config -f` line would make a file holding that one key. It now prints the same put-back line first when the file is gone. - **Left for follow-ups:** - `member-gitmodules-absent` (#611, before this PR) prints the same kind of `git config -f` lines; - the family bootstrap's `gitmodules_sections` reads the work tree's file alone, as rv820 says. - **New tests**, each typed as printed. All six fail on 9e2dc7d. - A plain clone, with the file deleted, or its deletion staged, is given the plain update, which checks out both members. - U1 in a plain clone, both ways: the refusal names members/IRSS and the put-back line. Done as it says, HEAD's file keeps IRRS's section, and the gate passes in every mode. - U1 in its own holder, both ways: the url-absent finding with the put-back line, in every mode. **SHOULD 3: the three wrong-text mutants now each fail a test.** - `name.startswith(path)` fails the new test of a file committed at `members/IRRS-notes`, beside the row's path. - Dropping `and "url" in keys` fails the new test of a hand mount whose section's url was taken out. - Dropping `and name in variants` fails the new test of a non-cone sparse checkout that keeps `notes/` off disk, with the variant on disk. - These tests pass on 9e2dc7d, whose code is right; they exist to catch the mutants. **SHOULD 4: S3776.** - `_init_stop_said` (20 by rv820's count, which a local counter reproduces) is split: the gitlink git stops at is `_init_stopper` (11), and its cure is `_init_stop_cure` (3). `_init_stop_said` is now 4. - `_check_removals_staged` (18) is now 13. The put-back lines are `_put_back_lines` (2), and the finding's text is `_removal_staged_said` (1). - Behaviour is unchanged: every existing test still passes, comparing the texts word for word. - `_check_gitlinks_named` (21) and `_check_gitlinks_registered` (19) are not split here. Main already has them at 18 and 19, and this commit changes neither beyond passing `root` to `_url_absent`. **NITs** - **5, taken by narrowing the text.** `_move_context`'s docstring now says what git does with entries a merge left unmerged. At the row's path, the move's second line replaces stages 1 to 3. Under it, it leaves them, and the commit then refuses with `Committing is not possible because you have unmerged files` (exit 128). Measured on git 2.43 and 2.47.1. A new test puts a gitlink at stages 1 to 3 at the row's path: the move as printed replaces it, and the gate passes. Dropping `tag != b"M" and` now fails that test. - **6, taken.** A case with seven entries under the row's path checks the five-name cap ("and 2 more"). A new test stages a removal where HEAD's `.gitmodules` gives no section for the row: no `git checkout HEAD -- .gitmodules` is printed. Each of the two mutants rv820 named now fails. - **7, taken in part.** `refused_and_unnamed` replaces the repeated refusal-and-fallback block in two tests, and three new tests use it. The (e) test keeps its own form, because it compares only the first `--no-members` finding, with the fetched wording. **Tests** - `tests/test_validate_family_typed_states.py`: 43 tests. On 9e2dc7d's validator, 6 failed (the SHOULD 2 cases) and 37 passed. - On git 2.47.1: this file, `test_family_head_lost_gitmodules.py`, `test_validate_family_unnamed_mounts.py` and `test_family_bootstrap_gate_order.py`, 155 passed. - Every test file that runs the validator or calls the gate (58 files, the three standing check files among them), on git 2.43, in six shards on the uncommitted tree: 3021 passed, 24 skipped. - 5 failed and 3 errored, all in `update-shape` holder tests: `REFUSED update-conflict ... scripts/validate-family.py: ... not a verbatim copy ... at the pinned commit`. These tests pin `HEAD`, so a change to the validator that is not yet committed makes the holder's copy differ from its pin. - With the same two files committed in a scratch clone (the same tree as ef17866), the three files pass: `test_update_shape_pr_base.py` 27 passed; `test_family.py` and `test_update_shape_header_comments.py` 160 passed. **Mutants** (each run on the whole file with `-x`): all 12 are caught. - The three from SHOULD 3, the one from NIT 5 and the two from NIT 6 (above). - The put-back sources swapped: caught by the url-less clone with the file deleted. - The index's and HEAD's file not read: caught by the plain clone with the file deleted. - The put-back line left out of the refusal: caught by the url-less clone. - The put-back line left out of `member-gitmodules-url-absent`: caught by U1 in its holder. - The two-sections cure checked after the row's own: caught by the plain clone with the `twice` stop. - The guard for a file on disk dropped: caught by the plain clone with the `stray` stop. **Merges** - With #747's head ff7c59b and with #822's head ea86cb6: clean (`git merge-tree`). - With #819 (c12b5f8): the same 3 conflict hunks in `validate-family.py` as at 9e2dc7d. - With main (64ec47c): the same conflicts as at 9e2dc7d, from the stack's base. - **For whichever of #819 and #820 lands second:** #819 sets `sections = {}` when the work tree's `.gitmodules` holds a value git dies on. #820 passes those sections to `_check_removals_staged` and to `_init_stop_said`, as it did before this commit. There, a `git rm --cached` staged removal would also print `git checkout HEAD -- .gitmodules` (rv820's note), and by reading the code, not measured, `_init_stop_said` would find no section there and name the first gitlink as the stop. Whichever lands second should read that state as git does. CI run 39031276097 (git 2.55.0) was red on one test, `tests/test_validate_family_typed_states.py::test_a_pin_no_remote_holds_is_bumped_as_printed`, at `:527`. The fetch the remedy names exited 0 (`From .../remotes/IRRS`, then `* branch <sha> -> FETCH_HEAD`) where the test expects exit 128 and `not our ref`. **The cause is the test, not git 2.55 and not the remedy.** - The three pin tests share one remote, `remotes/IRRS.git`, because `family` is module-scoped (`tests/test_family.py:91`). - `commit_ahead` built the same commit on every call: the same parent, the same `AHEAD.md` (`ahead\n`), the message `Ahead` and the same identity. Two calls in the same wall-clock second therefore made one sha. - The on-no-branch test pushes its commit to `refs/w762/ahead`. When the no-remote test's commit was made in the same second, that remote already held it. Protocol v2's upload-pack serves any object its repository holds, so the fetch fetched the "unpushed" commit. The gate had just found the commit missing from the member's own store (`rev-parse --verify`), so the remote was the only place it could have come from. - **Measured:** with `GIT_AUTHOR_DATE` and `GIT_COMMITTER_DATE` exported fixed, head 1038d7f fails the same way on git 2.43.0 and on git 2.47.1: 1 failed, 2 passed, the same assertion at `:527` and the same stderr. Run normally, it passes on both (3 passed). On this host the two commits are 2.2 s apart (three runs), so only a faster runner puts them in one second. The CI log has no per-test times, so the second itself cannot be read from it. - The on-no-branch test could pass while proving nothing in the same way: with fixed dates, its commit was the on-a-branch test's, which sits on `refs/heads/w762-ahead`. - rv820 (lane 2) found the same cause independently. **What the commit changes** (`tests/test_validate_family_typed_states.py` only): - `AHEAD.md` now holds `next(AHEAD)`, a module counter. It is not `tmp_path.name`, which `tmp_path_retention_policy=failed` hands to the next test (`test_family_bootstrap_gate_order.py`'s `BRANCHES` gives the same reason). A first try with `tmp_path.name` failed the new premise check under fixed dates for exactly that reason. - The commit carries fixed dates, `AHEAD_DATES`, so it is the bytes that keep two calls apart in every run, never the clock. - **The premise is checked before anything is typed:** - the remote-holds tests check that `git branch --contains <commit>` in the remote lists their ref alone, or nothing for `refs/w762/ahead`; - the no-remote test checks that `git cat-file -e <commit>` in the remote exits 1. - The remedy text is unchanged. Its fetch is right where a remote holds the commit, as the CI run's remote did. Its `switch` and `bump` lines still type clean as printed on git 2.43.0 and 2.47.1 (the whole file passes on both, below). **Tests** - `tests/test_validate_family_typed_states.py`, whole file: 31 passed on git 2.43.0, and 31 passed on git 2.47.1. - The three pin tests with the dates exported fixed: 3 passed on each git, where head 1038d7f gives 1 failed, 2 passed. - No other file imports this one (`git grep`). - The trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`): 146 passed. **Mutants** (the three pin tests, run normally): - `AHEAD.md` back to `ahead\n`: 2 failed, on the on-no-branch premise and the no-remote premise. **Caught.** - The on-a-branch / on-no-branch premise inverted: 2 failed. **Caught.** - The no-remote premise expecting exit 0: 1 failed. **Caught.** - `IDENTITY_CONFIG` dropped from the commit: 3 failed, because the runner's identity is blank. **Caught.** - The fixed dates dropped: 3 passed. This survives by design, since the counter alone keeps the commits apart. The fixed dates are what make the first mutant fail in every run: with both the counter and the dates reverted, the three tests pass on this host. The merge with #747's current head ff7c59b is clean (`git merge-tree`). No other branch on origin holds this test file. **What was wrong.** `mkdir Members && git mv members/IRRS Members/IRRS` (git needs the parent; without it `git mv` dies `renaming ... failed: No such file or directory`) moves the gitlink, the checkout and the section's `path` to `Members/IRRS`, under the section name `members/IRRS`. The refusal's two lines put the gitlink back and left the section naming the variant. Typed on base: a second round (`member-gitmodules-absent` and a `gitmodules-path-unnamed` with no command), then a third (`member-uninitialized`). **What changed.** `_move_context` reads HEAD's `.gitmodules` (what a clone reads). Where no section gives the row's path: - a section that gives a variant's path is given the row's path back, `git config -f .gitmodules submodule.<name>.path <row path>`; - where none gives a variant's path either (#363's state with the section gone), the section is written as `family.py add` writes one (`_section_lines`, #747's builder); - then `git add -- .gitmodules`, and the commit takes all of it. The same text is printed members checked and under `--no-members` (`member-gitlink-unnamed`), since both build it with `_wrong_case_move`. **The update now comes last.** A checkout `git mv` moved keeps a `.git` file naming the member's own store, `.git/modules/members/IRRS`, with `core.worktree` pointed at `Members/IRRS`. Measured: `git submodule update --init --recursive` re-points that store at `members/IRRS`. After it, `git -C Members/IRRS status`, which the text gives for "what is not committed is in the checkout alone", reads `members/IRRS` and lists nothing. An uncommitted `WORK.md` in the moved checkout is listed before the update and gone from the listing after it (`test_the_update_comes_last_where_git_mv_moved_the_checkout`). So the sentence about the update moved from before the directory handling to the end, with the reason: "Run it last: a checkout git mv moved to a variant names the member's own store, which that update points at members/IRRS, so `git -C Members/IRRS status` reads members/IRRS after it." Typed on this head, on both disks (`core.ignorecase` false and true): the lines and their commit, the guarded `mv` (which moves nothing, as the directory is a checkout), the checkout deleted by hand as the text says, then the update. `git submodule update --init` exits 0, HEAD's section says `path = members/IRRS`, and the gate passes in every mode. **What was wrong.** With `Members/IRRS` skip-worktree, `git rm -r --cached --ignore-unmatch -- Members/IRRS` exits 0 and removes nothing. That held in a cone checkout (`set --cone members scripts contracts .github`) and a non-cone one (`set --no-cone '/*' '!/Members/'`). The next line then leaves two gitlinks, and the gate finds the stray. **What changed.** `git ls-files -t`'s `S` marks the variants that need it (as `family.py`'s `_skip_worktree` reads it). Only there, the line says `git rm -r --cached --sparse --ignore-unmatch`, and the prose says it "needs git 2.34 or later". Everywhere else the line is unchanged, word for word. Typed in both kinds of checkout: one gitlink, at the row's path, and the gate passes in every mode. **What was wrong** (measured here; the issue had read it from source). A file the index holds AT `members/IRRS` was replaced by `git update-index --add --cacheinfo` without a word (exit 0). Committed, HEAD then records the gitlink, the file is gone from the index, and the gate refuses `member-uninitialized` over the file left on disk. A file UNDER it stopped that line, `'members/IRRS' appears as both a file and as a directory` (exit 128), after `git rm` had taken the variant out. Committed, the member was un-registered (`member-gitlink-absent`). **What changed.** The text names the entry and says to move it aside first, with a committed `git mv`: `git mv -- members/IRRS <new name>`, a new name outside `members/`, committed on its own, as `family.py`'s `_members_is_a_file` says for a file at `members`. Only then come the move and its commit. Entries a merge left unmerged are not counted; the move resolves those. Typed with `<new name>` filled in, for a file at the path and a file under it: the file is kept at its new name, the update checks the member out, and the gate passes in every mode. **Decision: a finding (exit 1), in every mode, not a note.** `recorded_gitlink` reads the index first because "the index is what the NEXT COMMIT will record", and that holds here: the next commit un-registers the member under its row, and the gate then refuses `member-gitlink-absent`. Each question the row asks can still be put to HEAD, so this is not a refusal. A clean checkout (CI's) never holds one, so CI is unaffected. **What was wrong.** After `git rm --cached members/IRRS`, staged: `family ok` members checked, with `--pins` and with `--no-members`. After a plain `git rm members/IRRS`: members checked and `--pins` refused `member-uninitialized`, whose update died `No url found for submodule path` (the section was gone from the work tree too). `--no-members` said `family ok`. **What changed.** `_check_removals_staged` names each row whose gitlink HEAD records and the index holds nothing at (`_staged_removals`, #747's reading). It prints: - to stay: `git reset -q -- members/IRRS`. Where a plain `git rm` also took the section out of the work tree's `.gitmodules`, it adds `git checkout HEAD -- .gitmodules`, and says this drops any other change to that file. Where the checkout is gone, it adds `git submodule update --init --recursive -- members/IRRS`, since that update dies while the section is gone. - to go: the same, then `family.py remove` from a checkout of openRepoShape, on a branch (`_switch_line`, with `BRANCH_LEFT`, as `member-duplicate-id` prints it). `remove` refuses a staged removal (its `git submodule deinit` finds no gitlink) and a work-tree `.gitmodules` that is not the index's (`gitmodules-unstaged`, measured), so the put-back comes first. A member not checked out whose removal is staged is not also refused `member-uninitialized`. Both ways were typed for `git rm` and for `git rm --cached`. Staying leaves nothing to commit and passes in every mode. Going leaves one member and passes in every mode. Committed instead, the removal is `member-gitlink-absent` as before. rv681's P2 states, rebuilt here: a new commit of the member made in a scratch clone of its remote, with the holder's gitlink, pin and digest moved to it and nothing fetched into the member's store. - **A remote holds it**, on a branch or on no branch (`refs/w762/ahead`). `git -C members/IRRS fetch origin <commit>` brings it in, and the gate passes in every mode. A plain `git -C members/IRRS fetch` did not bring the no-branch one, so the text names the commit. - **No remote holds it.** That fetch exits 128 `upload-pack: not our ref`, and so does `git submodule update` ("Direct fetching of that commit failed"). The remediation now says both, then THE LOCKSTEP RULE: `family.py bump --family-root <holder> --member IRRS --to <commit>` and the `switch -c family/bump-irrs` before it. Typed from the holder's tracking branch, with a commit the remote holds as `--to`, the gitlink and the pin are back in step and the gate passes in every mode. `--no-members` still says `family ok` over such a pin: that is the mode's contract (it prints `N member(s) SKIPPED`), as the issue says. **What was wrong.** These states were measured in a plain clone, after `git submodule update --init --recursive` had died 128 `No url found for submodule path '<p>'`: - S1: a stray `Members/IRSS` beside `members/IRSS`; - H1: a hand mount at `members/IRQS` with no row and no section; - U1: IRSS's section with no url; - H2: a gitlink at `vendor/IRQS`; - D1: a second section `dup` giving IRSS's path with no url. In each, members checked and `--pins` refused `member-uninitialized` for the first row, with that same update as the remedy. `--no-members` named S1, H1 and D1 (`gitmodules-path-twice`), and said `family ok` over U1 and H2. **What changed.** `_init_stop_said` asks, as the family bootstrap's `init_stop` does, which gitlink git's init stops at. It reads the index in index order, the work tree's `.gitmodules`, and the git config (`submodule.active`, `submodule.<name>.url`). The refusal then says so before its update, and gives **bootstrap's own cure for that state, word for word** (the tests import #682's `UNNAMED_CURE`, `SECTION_CURE`, `TWICE_CURE` and `OUTSIDE_CURE`): - `--no-members` for a gitlink in `members/` that no row names, or for a path two sections give; - the section written back, for a row's own path; - `git rm --cached -- <p>`, for a gitlink outside `members/`. Each was done as it says and committed. The update then checks both members out, and the gate passes in every mode. **U1 is its own finding**, `member-gitmodules-url-absent`, in every mode. It is made where exactly one section of HEAD's `.gitmodules` gives a row's path and gives no url, and it prints the url line `family.py add` writes. A path two sections give stays `gitmodules-path-twice`'s. Typed, staged and committed in the holder it was made in, the gate passes. In a recursive clone of it nothing is checked out at `members/IRSS`, and the clone's `--no-members` names it. **No misfire.** In each of these the refusal keeps the plain update: - a good holder's plain clone; - a recursive clone whose members were not fetched (`submodule.active` is set, so git's init asks only what a section gives, and a stray stops nothing); - a clone where an earlier `git submodule init` registered the urls before the work tree's section lost one. **A simpler reading than bootstrap's.** What bootstrap also keeps of git's parser (a name with `..`, a url starting `-`, a `.gitmodules` a merge left unmerged) is not read here. Where nothing is found, the plain remedy stands, as before. The finding said "a recursive clone of this holder fetches it" for every gitlink no row names. Measured (w648b's S2 and X1): a recursive clone of a stray or of a hand mount with no section leaves an empty directory there. It now says that where no section of HEAD's `.gitmodules` gives the path a url: "a recursive clone of this holder checks out nothing at it, in a plain clone git submodule update --init stops there (No url found for submodule path, exit 128) before it checks out any member". Where a section gives the path a url, it keeps "fetches it". A hand mount made with `git submodule add` is checked out by a recursive clone (tested). The variant a `git mv` leaves keeps its section's url, so its finding keeps "fetches it". #682's second fix round, `23e11056`, replaced "members checked, it refuses `member-uninitialized` for the first row there instead" with "...for the first row where every row's gitlink is there, as with a stray or a hand mount, and `member-gitlink-absent` where a row's is only in another case". That is the issue's reword, and it is still true, so `bootstrap.py` is not touched. `git log -S"for the first row there" -- templates/family-root/scripts/bootstrap.py` shows `23e11056` and `1a69505d`. New: `tests/test_validate_family_typed_states.py`, 31 tests. Changed helpers, each a text the gate now prints: - `tests/test_family_head_lost_gitmodules.py`: `variant_refused` takes the update sentence at its new place, and keyword arguments for `--sparse`, an entry aside and the section lines; - `tests/test_validate_family_unnamed_mounts.py`: `GITLINK_SAID` is the no-url text, and `GITLINK_SAID_FETCHED` the old one, used by the three cases whose gitlink has a section with a url; - `tests/test_validate_family_remedies_typed.py`: the hand mount made with `git submodule add` uses `GITLINK_SAID_FETCHED`. **Runs**, all through `run9.sh` at head `1038d7fa`, on Linux: - the new file: 31 passed; - the issue's Verify files and every file that runs the validator or imports the changed helpers, in two batches. Batch 1 (23 files: the new one, `test_family_head_lost_gitmodules.py`, `test_validate_family_unnamed_mounts.py`, `test_family_bootstrap_gate_order.py`, `test_validate_family_remedies_typed.py`, `test_family_members_case_variants.py`, `test_pinned_copy_text.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, the `test_validate_family_*` files, `test_family_switch_branch_exists.py`, `test_branch_left_bump_leg_setup.py`, `test_family.py` and the family add, remove, dry-run and member-path files): 916 passed and 3 failed on the uncommitted tree. The three were `test_family.py`'s `update-shape` tests, whose fixture pins HEAD while its copies come from the work tree. On the committed head, `test_family.py` alone: 106 passed. Batch 2 (24 files: the doctor's, `test_shape_doctor.py`, the `test_update_shape*` files, the family bump and init files, the template and pin-row files): 1609 passed, 23 skipped. **Fail on base:** 26 of the 31 new tests fail on `58d6c50f`'s validator (the new test files laid over a clone of it). The other 5 are no-misfire guards and pass there by design: a removal once committed, a holder whose update stops nowhere, a recursive clone not fetched, a url an earlier init registered, and a hand mount with its section. **Mutants**, one per hunk of the validator, each run against the new file. 26 mutants, all caught: - the removed-gitlink return; - no move context, members checked and under `--no-members`; - sparse not read, and `--sparse` not printed; - entries in the way not read; - the variant's section not rewritten, no section written, and no `git add` line; - the update sentence moved back to where it was; - no stop said, the stop never computed, and each of its cures dropped (two sections, a row's own, `members/`); - `submodule.active` not asked, and the config url not asked; - the commit-unresolvable remedy dropped; - the row's repository not threaded; - every unnamed gitlink said fetched, and none said fetched; - the url-less section not a finding; - the staged removal not named, the `.gitmodules` and checkout lines dropped, and the staged path not kept from `member-uninitialized`. `git merge-tree --write-tree` of `1038d7fa` merges clean with: - origin/main `6794a9f9`; - #747 `58d6c50f`, #682's branch `5afb24b9` and #745 `f4935e11`; - every wave-7 branch: `fix/768-gitmodules-values` (`f74f640c`, no commits of its own yet), `fix/761` `bbb961f8`, `fix/764` `271710ba`, `fix/757` `6b5e9074`, `fix/758` `b6e15591`, `fix/759` `052410b6`, `fix/760` `7569ffa8` and `fix/777` `854f6ed9`. Eight older branches that touch `validate-family.py` or these tests conflict, but each conflicts with main itself (squash-landed or stale): `fix/539-…`, `fix/607-…`, `fix/610-…`, `fix/family-add-ghost-section`, `fix/family-bump-remove-rollback`, `fix/pin-files-not-a-list`, `fix/validate-family-member-duplicate` and `fix/validate-family-unnamed-mounts`. `fix/611-…` merges clean. - A STRAY that a sparse checkout keeps off disk: its `git rm --cached -- Members/IRRS` (`_second_path_out`) exits 1, with git's own hint "Use the --sparse option". It fails loudly, outside the move back. It is a checklist candidate below. - The folding disk was emulated with `core.ignorecase=true` for every git the tests type. No macOS or Windows run was made (CI is Linux only, #597). In particular, (g) on a disk where `members/IRRS` resolves to the variant's directory was not run. - **The skip-worktree STRAY's `git rm --cached`.** `member-gitlink-unnamed`'s line for a second path to a member's directory (`_second_path_out`) has no `--sparse`. Where a sparse checkout keeps that path off disk, it exits 1, "paths ... outside of your sparse-checkout definition", with git's hint "Use the --sparse option" (measured, git 2.43). It is loud, not silent like (f). - **The section finding in the `git mv` state.** `gitmodules-path-unnamed` for the section `git mv` rewrote says "no command is printed: taking this section out could un-register it" and then RESTORE_ROW. Meanwhile the gitlink's move now gives that section its path back. It could say so, as the hand mount's section says "the git rm printed above ... takes this section out too". - **`family.py add`'s move back (#761's text).** It prints its update sentence before the directory handling. After a `git mv`, the moved checkout shares the member's store, so `git -C <variant> status` after that update reads the row's path (measured here for the validator's text). This is for #761's writer. - **`_init_stop_said` reads `.gitmodules` more simply than the family bootstrap's `gitmodules_sections`.** It does not read names with a `..` component, urls starting `-`, or a `.gitmodules` a merge left unmerged (rv648's Q1, O1, MM1). There, the refusal keeps the plain update, which dies. A shared reader cannot be imported across the two pinned files (the skew rule). Review: lane 2's independent review (issuecomment-6236581089) and its re-verification of 98b0536, READY (issuecomment-6237751102). CI (Brett Heap's ruling 27): H0 is the head 98b0536 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base d66a905; and the local gate passed on its predicted squash tree 7896978 over main 7b21059: the family_head_lost_gitmodules, validate_family_remedies_typed, validate_family_typed_states, validate_family_unnamed_mounts, validate_workflow_gitmodules_urls, repo_hygiene, agents_md_codes, cli_reference sets, 681 passed, 27 skipped. 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 #762 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…appended_to_command_scope`, the scan names a count worked out by hand, and `paste()` gives a pasted `submodule update` the `insteadOf` rewrite (#835) (#848) Stacked on #818 (`fix/758-git-command-scope`, `4a553e17`); retarget to main once it lands. The fix is one commit, `b9ad1187`, written on #818's `eaa72d9e`. The head `fafba9ec` merges #818's refresh `4a553e17` (main `fc867207`) into it, cleanly. Tests only. Nothing a user runs changes, and no pinned file changes. No README, handbook, `AGENTS.md` or `docs/cli.md` sentence is touched: none mentions these helpers (`render-cli-reference.py --check` passes). Both reviews say READY with no MUST: rv848 (issuecomment-6240157020) and lane 2's second review of #848 (issuecomment-6240228302, "rv818" below). One commit on `fafba9ec`, by explicit pathspec, fast-forward pushed. Tests only; no pinned file changes; main and #818 are not merged in. The rv818 numbers are next to rv848's. ### What each finding got | rv848 | rv818 | taken | what changed | |---|---|---|---| | 1 SHOULD: #811's and #838's counts | | the text | the merge section below says who converts it | | 2 NIT: stale merge checks | | yes | re-run at the head, lines corrected below | | 3 NIT: the walk counts arguments | 6 NIT: it does not read their values | yes, one change | `paste(row, cwd, None)` now fails the walk | | 4 NIT: `on_disk` is asked for `"true"` only | | yes | `ADDERS` holds `on_disk-false` | | 5 NIT: the `upload-pack` docstring | | yes | `paste_env` says what the rewrite adds: `pack-objects` and `index-pack` | | | 1 SHOULD: two command scopes built at collection | yes | #818's second review's SHOULD (#758's line 1), taken here so that #818 needs no third round | | | 2 NIT: an expected dict is flagged | yes | the scan leaves a dict that is an operand of a comparison | | | 3 NIT: what the scan misses | the text | "Known limits" below, no code | | | 4 NIT: the same lines in two files | yes | three helpers in `conftest.py` | | | 5 NIT: two surviving mutants | in part | the `count_of(env)` case is added; the `remotes_of` one is left, below | **rv818 SHOULD 1.** `test_adopt_reencoded_files.py`'s `attr.tree` param and `test_family_init_asks_every_value.py`'s `user.name` param were built at collection, before the session fixture put `gc.auto=0` and `maintenance.auto=false` in the environment. Laid over the environment at the test, each dropped the two from git's command scope. Each is a `lambda` its test calls, as `test_adopt_nested_attributes.py` already did. New `tests/test_command_scopes_built_at_the_test.py` (2 tests) reads each param off its test's own marks, builds it, and asks git for the command scope (`git config --show-origin --list`, read and never written). It asks for the session's two settings beside the param's entry. **It fails on `fafba9ec`** (the two test modules as they were, with the new `conftest.py`): `2 failed`, git read `['user.name=', 'user.email=', 'attr.tree=4b825dc6...']` and `['user.name=', 'user.email=', 'user.name=Ada Lovelace']`, with no `gc.auto=0`. It passes on the head. **rv848 3 and rv818 6.** `pastes_in` now returns, for each `paste` call, the fixture it hands the remotes of: `remotes_of(<name>)` as the third argument or as `remotes=`, where the name is `holder` or `scaffolded_here` and a parameter of the nearest function the call is in. `None`, another call, an expression on the call, a name the test does not take, or no third argument gives no fixture. The list of calls with none is `pastes_without_remotes`, a function of its own, so that a test of it can fail. 15 new cases hold the walk: twelve for what a call may hand and three for the function it is in. **rv818 2.** `counts_set_to_a_number` skips a `Dict` that is an operand of a comparison (`assert added == {"GIT_CONFIG_COUNT": str(held + 1), ...}`, `!=`, a dict on the left). I took the narrower reading of "inside an assert or a comparison": a dict in a call inside an assert (`assert run(env={"GIT_CONFIG_COUNT": "1"}).returncode == 0`), or one assigned before the comparison, still sets a count and is still read. Four cases are still found, three are left, and the `count_of(env)` case of rv818 5 is left. **rv818 4.** `JOB_ENTRIES`, `put_job_and_session_entries(monkeypatch)`, `command_line_entries(listing)` and `command_scope_at(environ, cwd)` in `conftest.py` replace the `delenv`, `held = ...` and loop lines of `test_no_auto_maintenance.py` and `test_doctor_paste_through_git.py`, and the four-line read of the `command line:` entries in three files. `OWN_COUNTS` follows: `conftest.py` holds two (the sum, and the job's entries counted from nothing), `test_no_auto_maintenance.py` one, and `test_doctor_paste_through_git.py` none. A new test checks that both readers put `GIT_CONFIG_PARAMETERS` aside (rv818's own round-2 NIT 8). **rv818 5, left.** `remotes_of` returning `fixture["base"]` instead of `fixture["base"] / "remotes"` still survives, and I name it equivalent in practice: the prefix `<base>/` covers every remote under `<base>/remotes`, so all the pasted clones are rewritten either way. ### Merge checks (replace the body's section) Re-run at the head `79b2eac4` (`git merge-tree --write-tree`), against 152 heads: every `fix/*` and `l3-*` branch, and the pull heads from #700. Main `fc867207` is an ancestor of the head (it came in with #818's refresh `4a553e17`). - **Main.** `fc867207`: clean. Main is `190133d5` now (two commits on `family.py`, the README and the handbook): clean too. - **#818's head `4a553e17`:** clean. The body's "one conflict, `tests/test_non_utf8_git_reads.py`" was true only for `b9ad1187` and `eaa72d9e`, before the refresh. - **The open heads that edit the files here merge clean:** #811 (`050abea6`), #820, #822, #828 (`ce0f2a5f`), #774 (`012a7726`) and #775 (`8c5ad0e8`). #761, #762 and #763 are the same heads as #828, #820 and #822. The body said #774 and #775 conflict; they do not, since the refresh. - **#838's pushed head `9166f84f`:** clean. - **Conflicts the other heads show** are the ones #818's head shows too (20 heads, in files this PR does not touch: `tests/test_adopt_git_reads.py`, `tests/test_non_utf8_git_reads.py`, `tests/test_dry_run_parity.py`, `shape-doctor.py`, `scripts/shape_materialize.py` and others). #806 (`76ae6419`) and #845 (`b18b243e`, branch `fix/834-oserror-skips`) conflict in `tests/test_adopt_git_reads.py`, which this PR does not touch. - **This commit adds two, both stale PR heads whose squash is in main:** #767's `bd6d2afe` (landed as `98f14976`, in `tests/test_family_add_spellings_remedies.py`) and #809's `98561405` (landed as `42aed788`, in `tests/test_family_init_asks_every_value.py`). - **Whichever of #848, #811 and #838 lands later converts that count to `appended_to_command_scope`.** #811 (`fix/777-typed-name-edges`, `050abea6`) adds `"GIT_CONFIG_COUNT": str(held + 1),` at `tests/test_setup_project_name_edges.py:190`: the scan finds `[190]` where `OWN_COUNTS` allows none, so `test_no_test_sets_a_git_config_count_of_its_own` fails once #848 and #811 are both on main. rv848 measured the same for #838's local `3845fd54` at `tests/test_github_name_limits.py:503`; the head #838 has pushed, `9166f84f`, holds no `GIT_CONFIG_COUNT` in that file. It is the goal of #835, so it takes no `OWN_COUNTS` entry. No file of theirs is changed here. ### Known limits (add to the body) Shapes the scan does not read, so a test that sets a count this way passes it: - a count key name imported from another module, held as a class attribute, bound by tuple unpacking or by a walrus (only a plain or annotated assignment of the name in the same module is read); - a value built by `format()`, `repr()` or `os.fsdecode()` (`str(...)`, an f-string, a `.format(...)` method and arithmetic are read); - a conditional value, `"2" if job else "1"`; - `setenv(name=..., value=...)`, with keywords; - an annotated subscript, `env["GIT_CONFIG_COUNT"]: str = ...`; - a value held in a variable and set later (`new = str(n + 1)`, then `{"GIT_CONFIG_COUNT": new}`); - an indexed `GIT_CONFIG_KEY_{n}` or `GIT_CONFIG_VALUE_{n}` name, which a hand-worked append writes beside its count; - a dict that is an operand of a comparison, by design (it is the value a test expects). This replaces a separate issue for them. ### Tests, through `run9.sh` - At the commit `79b2eac4`: **222 passed** (`test_command_scopes_built_at_the_test.py` 2, `test_family_config_appends.py` 8, `test_doctor_paste_through_git.py` 21, `test_no_auto_maintenance.py` 60, `test_adopt_reencoded_files.py` 53, `test_family_init_asks_every_value.py` 78). - The same content, run before the commit: **368 passed** (those and `test_doctor_legs_members_next.py` 127, `test_clone_just_pushed.py` 19) and **253 passed** (`test_doctor_next_is_a_command.py`, `test_doctor_validator_rows.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`). - New tests: 24 (2 + 11 + 2 + 9). Before the round: `test_doctor_paste_through_git.py` 10, `test_family_config_appends.py` 6, `test_no_auto_maintenance.py` 51. - Fail on `fafba9ec`: the command-scope test above, `2 failed`. The walk, `on_disk-false` and the scan cases pass on the code of `fafba9ec`, which is right; what they add shows in the mutants, where four survive the files of `fafba9ec` and none survives these. ### Mutants, 29 in a throwaway clone at the head, each caught - **rv848 4:** `on_disk` ignoring its value. Caught by `on_disk-false` (2 failures); survives the `test_family_config_appends.py` of `fafba9ec`. - **rv848 3, rv818 6:** one caller handing `None` (M01), `remotes_of(tmp_path)` or `remotes_of(project)`: each caught by the walk of the real file, and each survives the walk of `fafba9ec`. Eight mutants of the walk itself (the `remotes=` keyword, the parameter check, the remote-fixture check, the argument count, the `remotes_of` name, the nearest-function rule, the call check, and `pastes_without_remotes` always empty): each caught by its own case. - **rv818 1:** each of the two params built at collection again, and each building nothing: 4 mutants, each caught by `test_command_scopes_built_at_the_test.py`. - **rv818 2:** no skip of expected dicts (4 failures), every dict in an assert skipped (4), only the left operand skipped (3), only the comparators skipped (1). - **rv818 5:** any call by a bare name taken as worked out: caught by the `count_of(env)` case. - **rv818 4:** the helper putting only the job's entries (2), the reading taking another line (10), either helper keeping `GIT_CONFIG_PARAMETERS` (1 each), the job's second entry another value (6). - **`OWN_COUNTS`:** `conftest.py` allowed one (2 failures), this file allowed two (1), no allowance for `test_family_config_appends.py` (1). 1. **Line 1.** `on_disk`, `with_config` and `typed_with_local_remotes` each worked out their `GIT_CONFIG_COUNT` from the environment's own count. Each is now one call of `appended_to_command_scope(env, [(key, value)])`; `typed_with_local_remotes` sets each key the helper returns, inside its `monkeypatch.context()`. The scan found a fourth: `test_clone_just_pushed.py`'s `env` for `clone.defaultRemoteName` summed its own count too, and goes through the helper. 2. **Line 2.** `counts_set_to_a_number` finds a count worked out in the same four places it finds one written out (a dict, a keyword, a `setenv`-style call, a subscript): a `str(...)` call, an f-string, a `.format(...)` call or arithmetic. `OWN_COUNTS` lets `conftest.py` hold the one sum that remains, in `appended_to_command_scope`. 3. **Line 3.** `paste(row, cwd, remotes=None)` and `paste_env(remotes=None)`. Given the fixture's remotes, they append `insteadof_entries(remotes)` after `protocol.file.allow`, in the same count. Every `paste` in `test_doctor_legs_members_next.py` hands `remotes_of(<fixture>)`, and a walk of that file holds each call to it. The issue says each site is right today, and it is: the held-two case below passes on the base. What the helper adds is an empty `GIT_CONFIG_COUNT`, which git reads as no entries and `appended_to_command_scope` counts as none (`or 0`). The sums written out by hand died on `int("")`. `tests/test_family_config_appends.py` (6 tests) starts each helper under `GIT_CONFIG_COUNT=2`, the Windows job's two entries, and asks `git config --show-origin --list`: both held entries and the new key, in order. It starts each under an empty count and checks the entry is the first. `typed_with_local_remotes` is asked from inside `typed_as_printed`, and the environment is checked to be back as it was. **Yes.** Measured by running the extended scan over every `tests/*.py` at the base: it finds exactly the three sites, `test_clone_just_pushed.py:99`, and `conftest.py:290`, the helper itself. No other file, so no false positive. - The scan reads the SHAPE of the value and never evaluates it. `str(...)`, an f-string, `.format(...)` and arithmetic are found. A value held in a name, read by subscript (`added["GIT_CONFIG_COUNT"]`) or returned by any other call (`env.get(...)`) is still not read, and so is `int(env.get("GIT_CONFIG_COUNT", "0"))`, which sets nothing. `is_a_written_count` still reads constants only; the new `is_a_worked_out_count` is the one that reads shapes, and both docstrings say so. - `test_the_scan_leaves_a_count_that_is_worked_out` moved its `worked-out` and `a-call` (`str(held)`) cases to the found list, and is now `test_the_scan_leaves_what_it_does_not_read`, with the cases that are still not read. - `OWN_COUNTS` gains `conftest.py: 1` and the two new files (`test_family_config_appends.py: 2`, `test_doctor_paste_through_git.py: 1`), each a test that builds an environment on purpose. A second sum in `conftest.py`, or none, is named, as it is for every other file. - `test_the_scan_names_each_helper_that_worked_its_count_out` holds the three helpers' old source, so the scan names the line of each. **Yes.** Measured on a pasted `git -C <root> submodule update --init --recursive --checkout -- code` of an empty mount, with `GIT_TRACE`: | | no rewrite (base) | with the rewrite | |---|---|---| | clone's store | 8 loose objects, 0 packs | 0 loose objects, 1 pack | | link count of the loose objects | 2 (linked to the remote's own files) | none | | `upload-pack` in the trace | yes (a local clone starts it to list the refs) | yes | | `pack-objects` and `index-pack` in the trace | no | yes | | `origin`, and the `submodule.code.url` the root records | the plain path | the plain path | Across the whole of `test_doctor_legs_members_next.py` (trace2 events of all 56 `sh -c` lines it runs), no pasted line ran `pack-objects`: every pasted clone was a local clone. A pasted fetch needs no rewrite (it asks `upload-pack` whatever the URL is), so the rewrite matters where a mount is cloned: 14 of the 31 tests that paste. **The issue's Verify names `upload-pack` and `pack-objects` for the `GIT_TRACE` run. `upload-pack` is in both columns,** so the tests ask for `pack-objects` and `index-pack`, and for the store `assert_store_read_through_git` asks of every other clone. #645 keeps maintenance from starting, so nothing breaks today. #662 put every other read of these remotes through git as a second guard, and `paste` was the one left. The rewrite is for the directory resolved, which is how `.gitmodules` records each url; no pasted line names a remote itself, so `typed=True` is not needed. `tests/test_doctor_paste_through_git.py` (10 tests): - a pasted update of an empty mount of a project, and of a holder's member with its legs: `pack-objects` and `index-pack` run, `assert_read_through_git`, `origin` equal to the url `.gitmodules` records, no `insteadOf` stored in the root or the clone; - the control, handed no remotes: a local clone, loose objects, no `pack-objects`; - the entries in order: the environment's held entries and the session's two settings, then `protocol.file.allow`, then each `insteadOf` entry; - the walk: every `paste` of the doctor file is given three arguments, and the walk's own five cases. Base `eaa72d9e`, before: the three family files 117 passed; `test_no_auto_maintenance.py`, `test_clone_just_pushed.py` and `test_repo_hygiene.py` 132 passed; `test_doctor_legs_members_next.py` 127 passed. On the head `b9ad1187`, through `run9.sh` (gh stub first, `GH_TOKEN=bogus`, empty `GH_CONFIG_DIR`): - **340 passed:** `test_family_head_lost_gitmodules.py`, `test_family_bootstrap_gate_order.py`, `test_family_add_spellings_remedies.py`, `test_family_config_appends.py`, `test_no_auto_maintenance.py`, `test_clone_just_pushed.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`. - **261 passed:** `test_doctor_paste_through_git.py`, `test_doctor_legs_members_next.py`, `test_validate_family_remedies_typed.py`, `test_doctor_next_is_a_command.py`, `test_doctor_validator_rows.py`. New or changed tests: 31 new (6 + 10 + 15; `test_no_auto_maintenance.py` goes from 36 to 51 tests). **Fail on the base**, in a tree detached at `eaa72d9e` with the new test files copied in: - the helpers' tests and the scan: 4 failed, 53 passed. The empty-count case fails for `on_disk`, `with_config` and the typing helper (`ValueError: invalid literal for int()`), and `test_no_test_sets_a_git_config_count_of_its_own` names `test_clone_just_pushed.py [99, 233]`, `test_family_add_spellings_remedies.py [138]`, `test_family_bootstrap_gate_order.py [136]` and `test_family_head_lost_gitmodules.py [98]`; - the same file with the scan as it was (written counts only): 14 failed; - `test_doctor_paste_through_git.py`: ImportError (`remotes_of`), 10 not run; with `paste` ignoring the remotes (mutant 3a), the three pasted-clone tests fail. **Mutants**, 22 in a throwaway clone at the head, each caught: - 1a-1c each helper back to a hand-worked sum: `test_no_test_sets_a_git_config_count_of_its_own`, and the empty-count case; 1d the clone's `env`: the same scan test; 1e `on_disk` keeping a count of 1 held in a name, which the scan does not read: the held-two case; - 2a-2d `str`, `.format`, arithmetic and f-string dropped from `is_a_worked_out_count`; 2e-2h each of the four readers back to written counts; 2i-2k an `OWN_COUNTS` entry removed; 2z the scan as it was on the base; - 3a `paste` not handing `remotes` on; 3b `paste_env` dropping the rewrite; 3c the rewrite put before `protocol.file.allow`; 3d one caller not handing the remotes (the walk); 3e `remotes_of` naming the wrong directory. - origin/main `fc867207`: one conflict, `tests/test_non_utf8_git_reads.py`, the one #818 has with main at its old head. It is the same with the base `eaa72d9e`, and none of the files here conflicts. - #818's head now, `4a553e17` (main merged in): clean. - The 141 other heads (every `fix/*` and `l3-*` branch, and the pull heads from #700): the conflicts they show are the same with the base, in files this commit does not touch (`tests/test_non_utf8_git_reads.py`, `tests/test_adopt_git_reads.py`, `tests/test_dry_run_parity.py` and others). This commit adds one: #767's stale PR head `bd6d2afe`, which landed as `98f14976`. - The open heads that edit the files here merge clean with it: #820, #822, #828, #748, #747, #761, #762 and #763. #774 and #775 conflict only in `tests/test_non_utf8_git_reads.py`, as they do with the base. - Left out: `GIT_CONFIG_KEY_{n}` and `GIT_CONFIG_VALUE_{n}` names built from an index (an f-string) are not read, nor is a count held in a name and set later (`new = str(n + 1)`, then `{"GIT_CONFIG_COUNT": new}`). A test that did that would pass the scan. Reading the indexed key names in the same four places would close it. - `typed=True` is not used for `paste`; the entries are for the resolved directory. Review: rv848, lane 5's independent review, READY (issuecomment-6240157020); lane 2's independent review, READY (issuecomment-6240228302); rv848's re-verification of the fix round 79b2eac, READY (issuecomment-6241026517). CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 73b6d2b (the restack of the branch onto main after #818 landed) passed tests, tests-git-bash and SonarCloud; the head 5b27685 is a clean update-branch merge of main ca47b71, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 4dbb4a9 over main ca47b71: the adopt_reencoded_files, clone_just_pushed, command_scopes_built_at_the_test, doctor_legs_members_next, doctor_paste_through_git, family_add_spellings_remedies, family_bootstrap_gate_order, family_config_appends, family_head_lost_gitmodules, family_init_asks_every_value, no_auto_maintenance, repo_hygiene, agents_md_codes, cli_reference sets, 645 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green". Closes #835 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c228e80) #747 squash-landed on main as 6626a3c. Its final head ff7c59b is already merged here at c228e80, and `git diff ff7c59b 6626a3c` over #747's nine files is empty, so this merge takes the squash for the history and changes none of #747's files. No conflict. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both parents have landed (#747 as 6626a3c, #738 as e62b5e1, each merged above). This brings main up to 7b21059 (#842, #814 and #786's validate.yml change, whose one sentence in templates/family-root/README.md sits apart from this PR's). No conflict. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s docs say its parent folder is named EXACTLY after its repository, case included, on every disk, and the never to hand-edit the `members:` block names the row repair `validate-family.py` asks for, whose commit is the person's (#763) (#822) Two sets of words now agree. Words only: no tool, no remedy and no --help text changes. The folder rule (N6 of #695's review). The holder's README and its pinned AGENTS-shape.md said the parent folder was "not named after the family", which reads as a match that ignores case. siblings.py compares the name exactly and has a separate branch for a folder that differs only in case. Both now say what the standard's README and the handbook say since #738: the folder must be named EXACTLY after its repository, case included, on every disk. The README is rendered once at `init`, so it reaches new holders only; AGENTS-shape.md is in FAMILY_COPIED_VERBATIM, so it rides in the shape bump as one `upstream-changed` row. The hand repair of a row. AGENTS.md and AGENTS-shape.md said never to hand-edit family.yaml's `members:` block, and validate-family.py tells a person to in two cases: RESTORE_ROW (a row deleted, or its `path:` edited, by hand is put back as git last had it) and `member-gitlink-absent`'s (#681, the row of a member that is to go is taken out). The issue's first option is taken, so no remedy changes: the never stays for the normal path, where `add`, `bump` and `remove` rewrite the block, and each doc says the validator may name a repair of a row and that the commit is then the person's. AGENTS-shape.md, which has the room, names both cases. AGENTS.md is at its cap of 492 lines, so item 3 was reflowed to cost no line: "on a branch you make first (see above): the tracking branch is refused (code)" became "on a branch made first (code refuses the tracking branch)" and "Push the branch" became "Push it". No other sentence of the file moved. AGENTS-shape.md also says its layout's folder is named after this holder's repository (it said "the family", which `--name` makes a display name), and its folder rule opens "This holder's parent folder", the folder being no repository. tests/test_family_docs_one_spelling.py gains 15 tests. Each doc's hand-edit passage is held whole, from its never to whose commit a repair is, as ONE_SPELLING is, so "but for a row repair" cannot turn into "not even for" and pass; the folder rule, subject and "must" included, is read in the standard's README, the handbook, the holder's README and AGENTS-shape.md; `member-gitlink-absent` is raised through `_check_member_gitlink` and its code and remedy are read together; `RESTORE_ROW` is read from the validator, and so is its inline copy in `gitmodules-path-unnamed`. A remedy that stops telling a person to edit a row, or a copy that says another thing, fails there and takes the docs' exception with it. It follows lane 5's #747 (in v1.1.0, as 6626a3c) and #738 (as e62b5e1); #682 has landed too. Closes #763 Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Sonnet 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>
…n as it reads the file fails `make bootstrap` naming the key, and the family gate names it in every mode, so `update = None` no longer ends `family bootstrap ok` (#768) (#819) Written on #682 (`fix/648-bootstrap-and-gate-order`, `f74f640c`), which landed as `dc7d3fbb` while this was written. The commit is `fa69ead9`. The branch then merged #682's final head `5afb24b9` and main, taking its own side for #682's files: those files are the same at `5afb24b9` and `dc7d3fbb`, and main changed none of them after the squash. The head is `c12b5f8a`, and its diff against main is this PR's four files. **Pinned copies.** This changes the family root's `templates/family-root/scripts/bootstrap.py` and `templates/family-root/scripts/validate-family.py`. Each holder sees them as `upstream-changed` on its next `update-shape.py check`, and they are re-pinned in the next shape bump; this PR re-pins nothing. No test pins either file's bytes or digest. README, the handbook, `AGENTS.md` and `docs/cli.md` are unchanged: no `--help` text changes, and no sentence in them is made false. CI's run 39031235254 (git 2.55.0) failed one row of the new test file, and lane 2's review rv819 of `c12b5f8a` (NOT READY) found one MUST, four SHOULDs and five NITs. This commit answers each of them. It changes `templates/family-root/scripts/bootstrap.py` and `templates/family-root/scripts/validate-family.py`, both pinned and both already changed by this PR, and `tests/test_family_gitmodules_values.py`. No other pinned file changes. ### What git does with a `branch` that has no value Measured on git 2.43.0 (the host's) and 2.47.1 (a local build). The probe adds a `branch` line with no value after a submodule's url, commits it, then runs `git submodule update --init --recursive` in a plain clone: | `.gitmodules` read from | git 2.43.0 | git 2.47.1 | |---|---|---| | the work tree's file | crash, exit 139 (`Segmentation fault`) | `error: missing value`, `fatal: bad config line 4 in file`, exit 128 | | the index's (work tree file lost) | crash, exit 139 | `error: bad config line 4 in blob`; it reads on, checks the submodule out, exit 0 | | HEAD's (index entry lost too) | crash, exit 139 | the same as the index's | A `url` with no value gives 2.47.1's answers on both gits. `branch =`, `branch = .` and `branch = -x` are taken by both gits from every source (exit 0), as both tools read them. CI's log shows git 2.55.0 giving 2.47.1's answer for the work tree's file. From git's source, `submodule-config.c`: the `branch` case has `if (!value) ret = config_error_nonbool(var);` at v2.43.1 and v2.44.0. v2.43.0, v2.42.4 and v2.39.5 go straight to `xstrdup(value)`, which crashes. ### Each finding 1. **MUST: the row pinned git 2.43's crash. Fixed.** Reproduced on git 2.47.1 at `c12b5f8a`: `assert 128 == 139`, as on CI (1 failed, 27 passed). The table's exit column is gone. The premise is now that git's update died and checked nothing out; every other assertion is about the tools' own output. The two `shutil.Error` setup errors in that run are #645's copy race, which #713 fixed on main. 2. **SHOULD: the index and HEAD rule fixed `branch` to git 2.43's crash. Fixed, by measurement.** On 2.47.1 at `c12b5f8a`, with the work tree's file lost and no credential, bootstrap said `NOT CHECKED OUT, and not for want of a credential: ... git cannot read .gitmodules`, right under git's own `bad config line 7 in blob`, and failed. New `crashes_on_branch` reads `git version`. Before 2.43.1, or where it cannot read a version, the key is refused as before. From 2.43.1 on, the line ends the reading of a blob, as a `url` with no value does (`ends_blob_reading`). The gate needs no change: it reads the work tree's file and HEAD's, which a clone checks out as its work tree's file, and every git dies on the key there. The comments that said "git crashes" now say which git does. 3. **SHOULD: with every member checked out, a `.gitmodules` git cannot parse ended `family bootstrap ok`. Fixed.** `Gitmodules` now carries `unreadable`. It is set where the work tree's file is one `git config --list` cannot parse, which git dies reading. With every member checked out, the run prints `NO MEMBER UPDATED, and not for want of a credential: git cannot parse .gitmodules. Mend .gitmodules by hand (`git config -f .gitmodules --list` says where it breaks), and commit it.` and fails on `.gitmodules (git cannot read it)`, as it does for a refused value. A row left not checked out now gets that reason too, where before it was "no section of .gitmodules gives that path". Tested with a conflict marker and with an open quote. A blob is not marked: see the checklist candidates. 4. **SHOULD: mutants survived. Each now fails a test:** - Reading HEAD's file in place of the index's: `test_the_index_s_file_is_read_where_it_differs_from_head_s` stages `update = None` without committing it. - `update` without `merge` in bootstrap, or without `rebase` in the gate: `update = rebase` and `update = merge` are now in `TAKEN`. - The stop-reading rule applied inside ignored sections: `test_a_key_with_no_value_in_a_section_git_ignores_reads_on`. - `pattern_names` matching a prefix without the `/`: a new `a-string-prefix` row, with two `submodule.active` patterns, `members/IRRS` and `members/IRS`. - `active` reading the first value: OTHER's own `active` is now set twice, `true` then `false`. - `_written_back` keeping a url that begins with `-`: a new `url-after-a-dashed-one` row. - The `<url>` placeholder: `test_a_url_no_row_gives_one_for_is_written_back_as_a_placeholder`. - `_submodule_entries` without the backslash rule: the ignored-section test now runs on `..\x` too. - `if refused_here:` made `if refused:`: `test_a_value_head_s_file_alone_gives_leaves_the_work_tree_s_read`. 5. **SHOULD: cognitive complexity. Split.** These are estimates from an AST count of Sonar's rules. At `c12b5f8a` it gives the same figures rv819 gave. - `gitmodules_sections`: 33 → 5. Its parts are `gitmodules_source` (8), `gitmodules_entries` (9), `ends_blob_reading` (2) and `read_sections` (13). - `unfetchable`: 16 → 10, with `row_reason` (8). - `cures`: 24 → 22. It was 23 on main, so this PR no longer raises it; its `.gitmodules` sentences moved to `gitmodules_cures` (3). - `_check_gitmodules_values`: 22 → 10, with `_value_said` (2) and `_value_taken_out` (5). - `fetch_members`: 10 → 13. 6. **NIT: duplicated test code. Done in this file.** The gate-in-three-modes block is now `found_in_every_mode_and_typed`, and the good-run block is `assert_a_good_holder_s_run`. The 4-line `EXITED_0` assertion it shares with `test_family_bootstrap_gate_order.py` stays: it is shorter than a duplicated block, and sharing it means editing a file #682 landed. 7. **NIT: the rules are written twice. Left.** One copy in `repo_shape.py` would change the module every root imports, and every pinned copy of it, which is more than a fix round should carry. Filed below as a candidate. 8. **NIT: a false sentence in the HEAD-only case. Fixed.** Where HEAD's file alone gives the value, the finding now ends "and git dies reading a file that does, so in a clone neither `git submodule update` nor make bootstrap checks out any member." 9. **NIT: the person's own config could change the boolean answer. Fixed.** Measured: with `probe.value = notabool` in a global config, in `GIT_CONFIG_COUNT`, or given with `-c`, the old probe dies (`bad boolean config value 'notabool'`). Both tools now ask `git config --file <null device> --type=bool --default=<value> --get probe.value`, which reads no config scope (measured on 2.43.0 and 2.47.1). New test: `test_a_probe_value_in_the_person_s_own_config_changes_no_answer`. 10. **NIT: a count in this body. Corrected here.** Under "Fail on base", seven of the 28 tests pass on the base, not six. The seventh is the control `[a-leading-directory]`. The totals, 24 failed and 18 passed, were right. ### Tests `tests/test_family_gitmodules_values.py` now has 52 tests, up from 28. Every run used `run9.sh`: a `gh` stub, a bogus token and an empty `GH_CONFIG_DIR`. | run | tree | git | result | |---|---|---|---| | the file | e1379a9's | 2.47.1 | `52 passed in 67.32s` | | the file, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_pinned_copy_text.py`, `test_bootstrap_spelling.py`, `test_validate_family_without_git.py`, `test_family_bootstrap_gate_order.py`, `test_family_head_lost_gitmodules.py` | e1379a9's | 2.43.0 | `573 passed in 252.33s` | | wide half A: 26 files that load or run the family root's `bootstrap.py` or `validate-family.py`, this file among them | e1379a9's but for three docstrings and one byte match in a test | 2.43.0 | `1474 passed, 22 skipped, 1 xfailed in 1321.42s` | | wide half B: 29 more such files, the three above among them | the same | 2.43.0 | `1933 passed, 1 skipped in 1097.64s` | | the file, `test_family_bootstrap_gate_order.py`, `test_bootstrap_after_bump.py`, `test_family_head_lost_gitmodules.py`, `test_repo_hygiene.py` | the predicted merge with main `64ec47c3` | 2.43.0 | `250 passed in 229.25s` | The wide set is the 49 files w768 found with `git grep`, plus 6 more that name the family root's `bootstrap.py` (`test_family_siblings_placement.py`, `test_park_resume_targets.py`, `test_pins_child_kind_and_siblings_root_spelling.py`, `test_readme_windows_quoting_sentence.py`, `test_setup_project_py.py`, `test_windows_paths.py`). Both halves ran in a clone with that tree committed, since the `update-shape` tests fail on a work tree whose copies are not at HEAD. ### Fails on base The new file was run against `c12b5f8a`'s scripts: - on git 2.47.1: 18 failed, 34 passed (`r2-base247.log`); - on git 2.43.0: 16 failed, 36 passed (`r2-base243.log`). The tests that fail there: - the three unparseable-file tests; - the two blob `branch` tests (2.47.1 only); - the nine `git version` rows; - the three HEAD-only texts; - the probe test. The tests added for finding 4 pass on the base, as they should: each catches a mutant, not a defect of the base. The original file fails on git 2.47.1 at `c12b5f8a` with CI's assertion: 1 failed, 27 passed. ### Mutants Each mutant was applied alone to the final tree and caught (`mutants2.log`): | id | change | caught by | |---|---|---| | M1 | a blob's `branch` never ends the reading | blob `branch` tests, git 2.47.1 (2.43.0 passes, as it should) | | M2 | a blob's `branch` always ends the reading | blob `branch` tests, git 2.43.0 (2.47.1 passes, as it should) | | M3 | `BRANCH_READ_SINCE` is (2, 44, 0) | `which_git[2.43.1]` | | M4 | `<` made `<=` | `which_git[2.43.1]` | | M5 | an unreadable version taken for one that does not crash | `which_git[unread]` | | M6 | the version read with two numbers | `which_git[2.43.1]` | | M7 | `unreadable` never set | the three unparseable-file tests | | M9 | `fetch_members` asks `refused` alone | the every-member unparseable tests | | M10 | no mend sentence | the three unparseable-file tests | | M11 | `row_reason` without `unreadable` | the per-row unparseable test | | M12 | bootstrap's old boolean probe | the probe test | | M13 | a blob never ends the reading | the `url` index test and the blob `branch` tests, git 2.47.1 | | M14 | ignored sections asked for refused values | the ignored-section tests | | M15 | HEAD's file read in place of the index's | the staged-index test | | M16 | the stop rule inside ignored sections | the ignored-section blob test | | M17 | `pattern_names` matches a bare prefix | `[a-string-prefix]` | | M18 | `active` reads the first value | the `active` test | | M19 | bootstrap's `update` without `merge` | `TAKEN[update-merge]` | | V1 | the HEAD-only sentence never used | the three HEAD-only texts | | V2 | the gate's old boolean probe | the probe test | | V3 | `_written_back` keeps a url that begins with `-` | `[url-after-a-dashed-one]` | | V4 | no `<url>` placeholder | the placeholder test | | V5 | `_submodule_entries` without the backslash rule | `[backslash]` | | V6 | `if refused:` for `if refused_here:` | the HEAD-only `gitmodules-path-twice` test | | V7 | the gate's `update` without `rebase` | `TAKEN[update-rebase]` | ### Merge checks `git merge-tree --write-tree` of this head with: - main `64ec47c3`: clean, and its tests pass (see Tests); - #747 `ff7c59b8`: clean; - #822 `ea86cb60`: clean; - #815 `f6f318d1`: clean; - #820 `9e2dc7d9`: conflicts in `validate-family.py`, in the same three regions as before. The conflict blocks are byte for byte the ones `c12b5f8a` gives, so the resolution rv819 checked still applies. ### Checklist candidates - **A blob git cannot fully parse is read as no section at all.** Measured on 2.43.0 and 2.47.1: - Where the work tree has no `.gitmodules` and the index's file holds a bad line, git reads the sections before that line, says `error: bad config line 4 in blob`, and checks the members out. - `git config --blob <oid> --list` prints those entries but exits 128. So `gitmodules_sections` reads nothing. - With no credential, every row is then named "no section of .gitmodules gives that path" and the run fails, where the members are only the credential's. - This came with #648's blob reading. This round leaves it as it was. - **One copy of the `.gitmodules` value rules** (rv819's NIT 7). Bootstrap's `NO_VALUE_REFUSED`, `UPDATE_VALUES`, `refused_value`, `git_bool` and `TAKES` and the gate's `_values_refused`, `_git_takes_bool` and `GIT_TAKES` could live in `repo_shape.py`, so a change to git's rules is made once. Git 2.43 dies reading a family holder's `.gitmodules` before `git submodule update` checks out any member when a section gives: - a `path`, `url`, `update`, `ignore` or `branch` with no value (`bad config line`; a `branch` with none crashes git, exit 139); - an `update` other than `none`, `checkout`, `rebase` or `merge`, a `!command` included (`invalid value`); - a `shallow` that is no boolean (`bad boolean config value`); - a `fetchRecurseSubmodules` that is neither a boolean nor `on-demand`. The holder's bootstrap listed the file with `git config --list`, which reads all of these, and ended `family bootstrap ok`, exit 0. The gate refused `member-uninitialized` members checked and under `--pins`, naming the update that dies, and said `family ok` under `--no-members`. ### Line 1: bootstrap `gitmodules_sections` checks each `submodule.*` entry against those rules (`refused_value`). Where one would make git die, it returns those entries and no sections, as it returns `unmerged`, and `unfetchable` asks that second, after `unmerged`. Every row not checked out gets one reason: ``` NOT CHECKED OUT, and not for want of a credential: members/Atlas (git cannot read .gitmodules: `submodule.members/Nova.update` is `None`), members/Nova (git cannot read .gitmodules: `submodule.members/Nova.update` is `None`). Give `submodule.members/Nova.update` a value git takes, one of `none`, `checkout`, `rebase` or `merge`, or take that line out, and commit .gitmodules. `python3 scripts/validate-family.py --no-members` prints the commands. family bootstrap FAILED: Atlas (not checked out), Nova (not checked out) ``` A key with no value says "has no value". As for every other reason, the holder's records decide, never git's stderr. **Where every member is checked out**, as in the holder itself, git dies before it updates any of them, so no row is left to name, and the run used to say ok over git's `fatal:` line. It now prints `NO MEMBER UPDATED, and not for want of a credential: git cannot read .gitmodules: ...` with the same cure, and fails on `.gitmodules (git cannot read it)`. That is my reading of the goal, "so neither one says ok over a file git refuses". The issue's direction named only the rows not checked out. ### Line 2: the gate A new finding, `gitmodules-value-refused`, for each such key in the work tree's `.gitmodules` and in HEAD's (HEAD's as `member-gitmodules-absent` reads it). It is asked before any member check, so `--no-members` makes it too. Where either file gives such a value, a member that is not checked out is not refused `member-uninitialized`: git checked none out, and the finding fails the run, exit 1, in all three modes. The remedy prints one command to a line, as `gitmodules-path-unnamed` does, says which values git takes, and ends with the commit: ``` FINDING gitmodules-value-refused: .gitmodules gives `submodule.members/Nova.update` the value `None` here and in HEAD, which a clone reads, and git dies reading a file that does, before `git submodule update` checks out any member, so neither that nor make bootstrap checks one out. Git takes one of `none`, `checkout`, `rebase` or `merge` for that key. Take the key out by hand, in a POSIX shell, then stage and commit .gitmodules: git config -f .gitmodules --unset-all submodule.members/Nova.update ``` Decisions: - **A member's `path` or `url` is written back.** `--unset-all` takes out every line of the key. For `update`, `ignore`, `shallow`, `fetchRecurseSubmodules` and `branch`, git reads a section without one, so that is the cure. In the issue's `url` state, Nova's section has its url and one more `url` line with no value, and `--unset-all` alone would leave no url (git: `No url found`). So for a member's own section the finding writes the key back on a second line: the value the section last gave that git takes, or, where it gave none, what `family.py add` writes (the section's name for `path`, `https://github.com/<repository>.git` for `url`, `<url>` where the row's `repository:` is not `<org>/<Name>`). - **Which file gives it.** The finding says "here and in HEAD", "in the work tree, not in HEAD" (nothing to commit), or "in HEAD, not in the work tree" (it says to stage and commit the work tree's file, and prints no command). Where the work tree has no `.gitmodules`, the first such finding prints `git checkout HEAD -- .gitmodules` first, and only the first: a second would put back the key the first took out. - **The other `.gitmodules` checks wait.** Where the work tree's file gives such a value, `gitmodules-path-twice` and `gitmodules-path-unnamed` read nothing from it, as git reads nothing. Without that, a `path` with no value was read as the empty path, and `gitmodules-path-unnamed` printed `--unset-all` for every key of the member's own section (measured on `f74f640c`, probe c8). Those checks run again once the file is mended. - **A boolean is git's own.** `shallow` and `fetchRecurseSubmodules` are read by `git -c probe.value=<value> config --type=bool --get probe.value`, which uses the same `git_parse_maybe_bool` the submodule parser does. That covers `0x10`, `010`, `1k` and the int range (`2097152k` and `-2097152k` die, `2097151k` does not) with no copy of git's number parsing. ### Line 3: what #682 left approximate - **Where git reads the file from.** Where the work tree has no `.gitmodules`, `gitmodules_sections` reads the index's stage-0 blob, then `HEAD:.gitmodules`, with `git config --blob`, as `config_from_gitmodules` does. The unmerged check now uses `git ls-files -s`, which gives both the conflict stages and the stage-0 blob. One difference from a file is measured and kept: in a blob, a key with no value is an error, not a die. Git stops reading at that line and goes on, so `gitmodules_sections` stops there too (probe b2: the last line, git exits 0 and checks both out; b2b: the first section, git dies `No url found` at the second). - **`submodule.<name>.active`.** `submodule_config` now returns each key's values. Where `submodule.active` is set, `init_stop` asks the section's own `active` first, its last value read as a boolean, as `is_tree_submodule_active` does. - **`submodule.active`'s patterns.** A plain path pattern, as `git clone --recurse-submodules=<path>` writes one, names that path and what is under it, and `.` names every path. A leading `./` and a trailing `/` are read past. A pattern with pathspec magic (a leading `:`), a glob character or a backslash, or an empty, `.` or `..` component, keeps the old reading: it names every path. Measured with git 2.43.0 in plain clones of a holder built by this branch's `scaffold-project.py` and `family.py` (Atlas and Nova), each line added to Nova's section and committed (`p1.sh`; 32 states). On `f74f640c` every state git's update dies on ended `family bootstrap ok` with `--no-members` saying `family ok`; on this head, bootstrap fails and the gate finds `gitmodules-value-refused` in all three modes on exactly the states where git's update exits non-zero, and on none of the others: | added to Nova's section | git's update | `f74f640c` bootstrap / `--no-members` | this head | |---|---|---|---| | `update = None`, `update = !true`, `update = Merge` | 128, `invalid value` | ok / `family ok` | exit 1 / finding | | `url`, `update`, `ignore` (no value) | 128, `bad config line 7` | ok / `family ok` | exit 1 / finding | | `path` (no value) | 128, `bad config line 7` | exit 1, wrong reasons / `gitmodules-path-unnamed` for the path "" | exit 1 / finding | | `branch` (no value) | 139, `Segmentation fault` | ok / `family ok` | exit 1 / finding | | `shallow = maybe`, `08`, `3000000000`, `2097152k`, `-2097152k`, `-2097153k` | 128, `bad boolean config value` | ok / `family ok` | exit 1 / finding | | `fetchRecurseSubmodules = bogus`, `ON-DEMAND` | 128, `bad ... argument` | ok / `family ok` | exit 1 / finding | | `update = none` | 0, Nova skipped | exit-0 skip | the same exit-0 skip, no finding | | `update = checkout`, `ignore = bogus`, `shallow` (no value), `shallow = true`, `""`, `" 1"`, `TRUE`, `1 `, `0x10`, `010`, `1k`, `2097151k`, `fetchRecurseSubmodules` (no value), `= on-demand`, `= 0` | 0 | ok / `family ok` | ok / `family ok` | `p2.sh` (index and HEAD fallback, `active`), on `f74f640c` then this head: | state | git | `f74f640c` | this head | |---|---|---|---| | work tree's file deleted, not staged, no credential | reads the index's; fails on the fetch | exit 1, "no section of .gitmodules gives that path" | the credential's skip, ok | | the index's taken out too | reads HEAD's; fails on the fetch | exit 1, the same | the credential's skip, ok | | `update = None` committed, work tree's file deleted | 128, dies on the index's blob | exit 1, "no section" | exit 1, "git cannot read .gitmodules" | | Nova's url taken out, subset `members/Atlas`, no credential | inits Atlas alone; fails on its fetch | Atlas "git stopped at members/Nova" | Atlas the credential's; Nova "git has no url" | | the same with `submodule.active=.` and `submodule.members/Nova.active=false` | inits Atlas alone; fails on its fetch | Atlas "git stopped at members/Nova" | Atlas the credential's; Nova "git has no url" | A section under a name git ignores whole (`../x`) with `update = None` is ignored by git (exit 0, a warning) and by both tools (`p3.sh`). `tests/test_family_gitmodules_values.py`, new, beside #682's `tests/test_family_bootstrap_gate_order.py`, whose helpers it imports (28 tests): - `test_a_value_git_dies_on_fails_bootstrap_and_every_mode_of_the_gate`, one case for each row of the issue's table, `update = None` first, and `branch` with no value: git's update exits 128 (139 for `branch`) and checks nothing out; bootstrap exits 1 with its last two stderr lines exact; the gate finds the whole finding in all three modes, exit 1; its commands are typed as printed and committed; HEAD's `.gitmodules` is then byte for byte the one `family.py add` wrote; the same bootstrap is a good holder's, and the gate passes in every mode. - No misfire: `update = checkout`, `shallow = true`, `fetchRecurseSubmodules = on-demand`, `ignore = bogus` (a good holder's run and gate); `update = none` (the exit-0 skip as before, no finding); a section git ignores whole. - Every member checked out: the holder itself fails on `.gitmodules`, and mended as the gate prints it, passes. - The index's file and HEAD's (no credential: the skip, ok); a key with no value at the end of the index's file (read past, the skip); `update = None` in the index's file (named, and the gate's `git checkout HEAD -- .gitmodules` first); two keys there (one checkout line). - `submodule.members/<P>.active=false` with `submodule.active=.`; the subset's pattern as `members/<P>`, `./members/<P>/` and `members`. - Which file gives it (HEAD's alone; the work tree's alone); a `url` or `path` left with no value, written back as `family.py add` writes it. **Fail on base.** The file run against `f74f640c`'s two scripts: 21 of 28 fail, and the three changed cases of `tests/test_validate_family_without_git.py` fail too: 24 failed, 18 passed (`base1.log`). The six that pass there are the no-misfire cases, which must pass on both. **Runs on this head** (Linux, git 2.43.0; through `run9.sh`, the real `gh` unreachable): - Every test file that loads or runs the family root's `bootstrap.py` or `validate-family.py`, found with `git grep` (49 files, the issue's Verify set, `test_family.py` whole, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py` and `tests/test_pinned_copy_text.py` among them), in two halves on `fa69ead9` with a clean tree: `1309 passed, 22 skipped in 1431.96s` (`fa.log`) and `1644 passed, 1 skipped in 1258.67s` (`fb.log`). - `render-cli-reference.py --check`: `[ok] docs/cli.md is what the generator produces`. **One existing test changed.** `tests/test_validate_family_without_git.py::test_with_no_gitmodules_the_gitlink_listing_is_named` pins which check meets a missing git first in a holder whose work tree lost `.gitmodules`. That is now the reading of HEAD's `.gitmodules` for a value git dies on, so the `git-not-runnable` refusal says "HEAD's .gitmodules could not be read as git reads it" where it named the gitlink listing. Exit 2, the code and the remedy are as they were. No open branch edits that file. **A dirty-tree artifact, not a failure.** Run on the uncommitted tree first, `tests/test_update_shape.py`, `tests/test_update_shape_header_comments.py` and `tests/test_update_shape_pr_base.py` failed (`update-conflict`: the copy is not the template at the pinned commit), because their fixtures pin HEAD while the copies came from the edited templates. On a committed tree they pass: `171 passed` (`c1.log`), and in the halves above. **Mutants.** One for each hunk that changes behaviour, each run against the new file's tests that name it: 30 mutants, all killed (`mutants.log`, `mutants-2.log`). Three survived the first round; each showed a test that did not reach the hunk, and the test was made to reach it: | file | hunk | mutant | killed by | |---|---|---|---| | bootstrap.py | `NO_VALUE_REFUSED` | `branch` dropped | `[branch-no-value]` | | bootstrap.py | `fetch_members` | the every-member-checked-out branch never taken | `test_a_holder_with_every_member_checked_out_fails_on_it_too` | | bootstrap.py | `reason` | "has no value" reworded | `[url-no-value]` | | bootstrap.py | `cures` | the refused sentence dropped | `[update-None]` | | bootstrap.py | `gitmodules_sections`, source | the work tree's file always read | the index's and HEAD's tests | | bootstrap.py | `gitmodules_sections`, blob stop | no `break` | `test_a_key_with_no_value_ends_git_s_reading_of_the_index_s_file` | | bootstrap.py | `gitmodules_sections`, refused | never recorded | `[update-None]` | | bootstrap.py | `gitmodules_sections`, ignored sections | asked for refused values | `test_a_value_in_a_section_git_ignores_whole_is_no_finding` (survived until it ran bootstrap with no credential first) | | bootstrap.py | `refused_value`, `update` | every value taken | `[update-None]` | | bootstrap.py | `refused_value`, `on-demand` | not exempt | `[fetch-on-demand]` (survived until it ran with no credential first) | | bootstrap.py | `git_bool` | every value a boolean | `[shallow-maybe]` | | bootstrap.py | `submodule_config` | values dropped | the `active` and pattern tests | | bootstrap.py | `init_stop` | the old condition | the `active` and pattern tests | | bootstrap.py | `active` | own key not read | `test_a_member_whose_own_active_is_false_is_not_where_git_stops` | | bootstrap.py | `pattern_names` | every pattern names every path | `[a-path]` | | bootstrap.py | `unfetchable`, read with no row left | not read | `test_a_holder_with_every_member_checked_out_fails_on_it_too` | | bootstrap.py | `unfetchable`, the reason | not given | `[update-None]` | | validate-family.py | `_gitmodules_listed` | returns nothing | `[update-None]` | | validate-family.py | `_head_listed` | HEAD's listing empty | `[head-only]` (the first, badly written mutant still read it) | | validate-family.py | `GIT_TAKES` | `update`'s values reworded | `[update-None]` | | validate-family.py | HEAD-only branch | never taken | `[head-only]` | | validate-family.py | `git checkout HEAD -- .gitmodules` | never printed | `test_a_value_git_dies_on_in_the_index_s_file_is_named_and_mended` | | validate-family.py | the checkout printed once | printed for each key | `test_two_keys_with_no_gitmodules_in_the_work_tree_put_head_s_back_once` | | validate-family.py | the commit clause | always said | `[work-tree-only]` | | validate-family.py | `_submodule_entries` | ignored sections kept | `test_a_value_in_a_section_git_ignores_whole_is_no_finding` | | validate-family.py | `_values_refused` | `branch` dropped | `[branch-no-value]` | | validate-family.py | `_git_takes_bool` | every value a boolean | `[shallow-maybe]` | | validate-family.py | `_written_back` | never writes back | `[url-no-value]` | | validate-family.py | `_check_members`, blanking | sections read all the same | `[path]` of the written-back test (`gitmodules-path-unnamed` for the path "") | | validate-family.py | `_check_members`, skipped checkouts | `member-uninitialized` again | `[update-None]` (members mode exits 2) | `git merge-tree --write-tree` of `fa69ead9`: | against | result | |---|---| | `origin/main` `dc7d3fbb` | conflicts in both scripts, because #682 is squashed into main; with `--merge-base=f74f640c`, clean | | #682's last head `5afb24b9` (`fix/648-bootstrap-and-gate-order`, deleted on merge) | clean | | #747 `fix/681-validate-family-remedies` `58d6c50f` | clean | | #745 `fix/704-bootstrap-leg-moves` `f4935e11` | clean (a first draft conflicted twice with it, in `fetch_members`' docstring and where it adds `git_not_started`; both are moved out of its way) | | w764 `fix/764-bootstrap-notice-exit-texts` `271710ba` | clean | | w761 `bbb961f8`, w757 `6b5e9074`, w758 `b6e15591`, w759 `052410b6`, w760 `7569ffa8`, w777 `854f6ed9` | clean | | w762 `fix/762-validate-family-remedies-texts` `1038d7fa` (local) | **conflicts, three regions in `validate-family.py`**: the EXIT CODES sentence (each adds its exit-1 findings), the end of `_check_members`' docstring (each adds a paragraph), and the `_check_member_checkout` call (this adds `set(gitlinks) if refused else unregistered`, w762 adds `stop`). Each resolves by keeping both: `_check_member_checkout(root, asked, report, set(gitlinks) if refused else unregistered, stop)` | The other `fix/*` branches that touch these files and conflict (`fix/539-...`, `fix/607-...`, `fix/610-...`, `fix/bootstrap-print-name-escaped`, `fix/family-add-ghost-section`, `fix/family-bump-remove-rollback`, `fix/pin-files-not-a-list`, `fix/validate-family-member-duplicate`, `fix/validate-family-unnamed-mounts`) are old branches whose PRs landed long ago; `fix/611-...` is clean. No `l3-*` branch touches them. - Values in `siblings.py`'s `submodule_urls` (lane 4's `fix/690-siblings-inputs`), which the issue leaves out. - A `submodule.<name>.active` that is no boolean makes git die (`bad boolean config value`); `init_stop` reads past it to the patterns. - A blob `.gitmodules` with a config syntax error: git reads the entries before the bad line; `gitmodules_sections` reads none, as before. 1. **The credential advice prints where the line says no credential would help.** Whenever git's update exits non-zero, `fetch_members` prints "If the members are PRIVATE, set SHAPE_LEGS_TOKEN ... then re-run", and then, for a reason found in the holder's records (this PR's `.gitmodules` value, and #648's unmerged file, missing gitlink, missing section, missing url and stopped init), the line "NOT CHECKED OUT, and not for want of a credential". The first sends a person to a token that cannot help. Measured in every refused state above. 2. **`_sections_listed` reads a key with no value as the empty string.** In HEAD's file, Nova's `path` with no value reads as the path "", so `member-gitmodules-absent` also fires for Nova beside `gitmodules-value-refused`. Its line writes the same path the new finding writes back, so typing both is harmless, but it is a second finding for one fault (the `[path]` case of the written-back test pins it as it is). In the work tree's file the new blanking keeps it out. Review: lane 2's independent review (issuecomment-6236559204) and its re-verification of e1379a9, READY (issuecomment-6237963983). `233a334e` is its merge round after #820 (`e32ed55a`, both sides kept, `stop` only where `not refused`, `as_read` for `_check_removals_staged`) with a regression test; lane 2's re-check found it READY (#819 issuecomment-6244287619). CI (Brett Heap's ruling 27): H0 is the head 233a334 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base b9b7069; and the local gate passed on its predicted squash tree caf6706 over main fe99b8b: the adopt_execute_refusals, adopt_filter_repo_failure, adopt_github_exits, adopt_late_failure, adopt_leg_keys, adopt_pins, adopt_split_branch, adopt_temp_work_roots, family_gitmodules_values, gh_guard, pin_annotated_tag, pin_source_top_level, template_values_single_pass, validate_family_without_git, repo_hygiene, agents_md_codes, cli_reference sets, 1025 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 #768 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lane: openreposhape-5 (openRepoShape-5)
Claim: #681 (comment)
Every remedy the holder's
validate-family.pyprints for #681's five lines now types as printed in the state it names. A shape-pin row with nopath:is refused by name in both validators.Stacked on #682 (
fix/648-bootstrap-and-gate-order, head45c512eb). The diff isd91fd15e, the fix rounddbca1130, and031ba75e, the merge of #682's head45c512eb. Retarget tomainonce #682 lands.Restacked onto #682's head
45c512ebby the merge031ba75e, with no rebase and no force. #682 had merged main2fac9790, which carries lane 2's #634 (30676c90), so the conflicts with #634 are resolved there and here. The new head merges clean withorigin/maine634d299. This branch does not merge main itself.Restack 2: the merge
03e0b5a8takes #682's second fix round23e11056(fix/648-bootstrap-and-gate-order, one commit on45c512eb), with no rebase and no force. It touches onlytemplates/family-root/scripts/bootstrap.pyandtests/test_family_bootstrap_gate_order.py;git mergetook it with no conflict and the gate-order test still hasimport 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) give205 passed. The new head merges clean withorigin/main2a33a569(merge-treeexit 0, treea62ac94b).Restack 3: the merge
58d6c50ftakes #682's third fix roundf74f640c(fix/648-bootstrap-and-gate-order, one commit on23e11056), with no rebase and no force. It touches onlytemplates/family-root/scripts/bootstrap.pyandtests/test_family_bootstrap_gate_order.py;git mergetook it with no conflict and the gate-order test still hasimport re. The same six files give206 passed(205at03e0b5a8; the one more is the fix round's own test). The new head merges clean withorigin/maine3137010(merge-treeexit 0, treecace7c06).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.pychange.Pinned files changed
templates/family-root/scripts/validate-family.py: every holder takes oneupstream-changedrow.templates/assembly-root/scripts/validate-pins.py: every assembly root takes oneupstream-changedrow (the L2-3 check only).What changed, line by line
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 samegit rm. Typed again, thatgit rmdiesfatal: pathspec 'members/IRQS' did not match any files, exit 128. A second path'sgit rm --cached -- Members/IRRSdid the same._staged_removalsnames the gitlinks HEAD records where the index holds no entry at that path, at any stage.member-gitlink-unnamedsays the removal is staged and to commit it, and prints nogit rm. A hand mount keeps its branch andaddlines. A second path says only "Commit that removal.".gitmodulessection left bygit rm --cachednow prints its own--unset-alllines. Before, it said "the git rm printed above takes this section out too", and nogit rmis printed there any more.git rm, which takes an unmerged path out (tested).L2-38, the
member-gitlink-absentremedy. Take a row kept aftergit rmtook its gitlink and section out. Its remedy namedgit switch -candfamily.py add, butaddrefuses the row (member-already-present).family.py removedies atgit 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 andgitlines: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 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 case-variant move does.member-gitmodules-absentprints, when HEAD's.gitmodulesgives no section the path. The line builder_section_linesis now shared by both.git submodule update --init --recursive(ormake bootstrap) checks the member out.family.yamlby 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 baremv -- 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"). Eachmvis now:mvdoes not run where the two names are one directory, such as a case-folding Linux mount like WSL's/mnt/c. There the baremvmoved the member's own directory out..gitentry. For a live submodule checkout that entry is a file,gitdir: ../../.git/modules/Members/<P>, and a move left it sayingfatal: not a git repository..git/modules/, in the directory its.gitfile names, whatever happens to the checkout. Only what is not committed is in the checkout alone.git -C Members/<P> statusshows it, except where a plainmvbrought the checkout there, which git cannot open. It says to delete such a checkout by hand once nothing only it holds is needed..gitdirectory, a clone of its own, the text says its commits are there and nowhere else.git -C Members/<P> log --oneline HEAD --branches --not --remoteslists 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 nopath:. Both validators readstr(row.get("path")). So- nopath: xgaveFINDING shape-copy-missing: contracts/shape-pin.yaml: None is pinned but absent, andpath: ''andpath: 5did likewise. A row whosepath:is absent, empty or not a string now gives:That is the code each validator's non-mapping branch already says, and the row
update-shape.pyrefuses asupdate-pin-row.shape-pin-rowis not a new code.Decisions
-e(any.gitentry), as the issue's direction says. A standalone clone (a.gitdirectory) 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 validate-family.py andfamily.py remove: a holder whose HEAD lost.gitmodulespasses the gate, andremovefails afterdeinit, leaving the mount empty #611 move test now uses a plain directory, which is moved. All three checkout kinds (submodule, clone, plainmv) are typed in the new test file..gittest alone would keep it there. The-eftest is what keeps it where the directory is empty, as a fresh clone leaves it beforegit submodule update. A test layout covers exactly that case (see the mutation probes).git rm._staged_removalsis its own function, not a second return value of_member_gitlinks; it costs one moregit ls-files -zper run. The branch bodies moved into_second_path_outand_mounted_by_hand.Reproductions (typed as printed, local bare remotes and the
ghstub only)The same throwaway probe ran on base
8b39cd19and on headd91fd15e. The case-folding disk is emulated, with aMemberssymlink tomembers; no case-folding mount was used.8b39cd19d91fd15egit rm, the re-run prints the samegit rm; typed again it exits 128,did not match any filesswitch -candaddgit rm --cached -- Members/IRRSagain, which exits 128addis refusedmember-already-present;removeexits 2 atsubmodule deinitupdate-indexand section lines,git add .gitmodules, a commit andgit submodule update --init --recursiveall exit 0; the gate exits 0 in every modeFINDING shape-copy-missing: … None is pinned but absent, and the same for''and5FINDING shape-pin-row: … is None/''/5,not the path of a fileMembers/IRRSmv,git -C ../Members-IRRS statussaysfatal: not a git repositorygit -C Members/IRRS statusexits 0, and its local commit is in.git/modules/Members/IRRSmvmovesmembers/IRRSout of the holder[ Members/IRRS -ef members/IRRS ]exits 0, and the guarded line moves nothingmvmovesMembers/IRRSout-efexits 1, the guarded line movesMembers/IRRSout, and the gate exits 0Tests
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 plainmv), 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:
55bd67d9(everytest_validate_family*.pyandtest_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:2359 passed, 1 skipped in 2540.75s (0:42:20);2879 passed, 23 skipped in 3364.87s (0:56:04).d91fd15ediffers from55bd67d9only intests/test_validate_family_remedies_typed.py(the empty-directory layout). The two new files on it:25 passed in 33.44s.8b39cd19's code. With55bd67d9's tests:30 failed, 3 passed in 47.76s. The N-2 test's added layout, withd91fd15e's tests: all 3 layouts fail,3 failed in 7.64s. The 3 that pass do so by design:switch -cremedy.Mutation probes (throwaway clone at
d91fd15e, one hunk reverted at a time)_staged_removalsreturns nothingtest_a_section_git_rm_cached_leaves_keeps_its_own_commandstest_an_unmerged_gitlink_keeps_its_git_rmaddremedy back…put_back_as_printed[git-rm-cached]-eftest.gittestpath:check invalidate-family.pypath:check invalidate-pins.pypath:allowed[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:
[one-empty-directory]. The-eftest is what keeps an empty row directory in place..gittest is what keeps a checkout in place.Merge checks (
git merge-tree --write-tree, head031ba75e)origin/maine634d299(and3ceabef0): clean.--no-membersprints the case-variant move (#648) #682's head45c512eb: an ancestor of this head.fix/6*,fix/7*andl3-*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-697andl3-rehearsal-v1.1.0.fix/695-family-docs(c42286de), indocs/handbook.html. It is clean with main and conflicts with bootstrap.py and validate-family.py: a member no credential would check out fails the family bootstrap, every row's gitlink is asked before any checkout, and--no-membersprints the case-variant move (#648) #682's head45c512ebthe same way.l3-683-partial-clone(9f24bf31), inshape-doctor.py. It conflicts withorigin/mainthe same way.Fix round (
dbca1130, rv681's review ofd91fd15e: 0 MUST, 2 SHOULD, 3 NIT)This is one new commit on
d91fd15e, not an amend. Only text changes invalidate-family.py: the guards are the same, andvalidate-pins.pyis not touched.git rm --cachedleaves its checkout at the path. Git itself suggests--cachedwhen the printedgit rmrefuses a checkout with changes. The printedfamily.py addthen refused that directory (member-path-taken).git rm --cachedcase. It is typed through the section's printed lines and the commit; thenaddis refusedmember-path-taken; the checkout is deleted by hand;addis refusedmember-git-dir-cached; the store is deleted;addexits 0, and the gate exits 0 in every mode.-eguard is kept, as the issue's "no .git entry", which Brett Heap's word took. For a.gitdirectory, the text now says its commits are there and nowhere else, names thegit logabove, and says to move the clone out whole, not delete it.[clone]case asserts that the line keeps the clone and that the printedgit loglists exactly its own commit. It then moves the clone out whole and reads the commit there.-f) is not taken. The coordinator is asking Brett..git/modules/Members/<P>: a checkout a plainmvbrought to the variant names.git/modules/members/<P>. The status check now says git cannot open such a checkout.[moved]kind types this state: the commit is in the store the.gitfile names,git -C Members/IRRS statusfails, the checkout is deleted by hand, andgit submodule update --init --recursivechecks the member out again..gitmodulesalone. The section lines still print, because HEAD's.gitmodulesis what a clone gets. Typed, staged and committed, they give a gate that exits 0.Runs on the fix round:
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 ond91fd15e)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.12stest_template_text_placeholders.py,test_repo_hygiene.py,test_agents_md_codes.py266 passed in 50.50s_gitlink_put_backreads the work tree's.gitmodules), rv681's patch1 failed, 94 passed,test_a_section_back_in_the_work_tree_alone_still_prints_its_lines(it survived with 92 passed ond91fd15e)1 failed, 17 passed,[git-rm-cached]Not re-run in this round, to save usage:
-echanged to-f). By reading: it now moves the clone in the[clone]case, which asserts the clone is kept.The texts this round changes are pinned only in
test_family_head_lost_gitmodules.pyandtest_validate_family_remedies_typed.py(git grepof each changed phrase intests/).The restack merge (
031ba75e). Two files conflicted; every other file merged clean.validate-family.py, 3 regions:--no-membersprints the case-variant move (#648) #682's narrower wrong-case sentence, then this branch's staged-removal sentence._check_member_gitlink(root, path, commit, project, repository, gitlinks, report, rule)._check_memberpassesrow.get("repository"), gitlinks, report, ruleand returnsGitlinked(row, project, path, pin, commit, rule).tests/test_family_bootstrap_gate_order.py, 2 regions:test_family_head_lost_gitmodulesis the union of both sides.import reis put back by hand. The merge had dropped it with no conflict marker, and the file callsre.compileandre.findall(rv681's note).import subprocessstays out, since neither side uses it._lane5-scratch/rv681-restack2) byte for byte.validate-family.pyis that model with this branch's fix roundd91fd15e..dbca1130applied bygit merge-file, byte for byte.031ba75e:test_pinned_copy_text.py: 172 passed in 65.19s ($Q/v112/w681/rs/four.log);test_validate_pins_lockstep_lines.py: 159 passed in 104.07s ($Q/v112/w681/rs/more.log);$Q/v112/w681/rs/hyg.log).Left out
family.py's ownfamily-members-dir-variantandmember-path-takenmove remedy (mv -- Members/<P> <new name>) has N-1's flaw for a live submodule checkout. Fixing it is afamily.pychange, so it is a checklist candidate below.AGENTS.md's "Never hand-editfamily.yaml'smembers:block" is not changed. The validator already tells people to put a row back by hand in a repair state (RESTORE_ROW), and nowmember-gitlink-absenttells them to take one out by hand. Whether to reconcile the sentence is a checklist candidate.Checklist candidates (not filed)
family.py's case-variant move remedy breaks a live submodule checkout. Itsfamily-members-dir-variantandmember-path-takenrefusals printmv -- Members/<P> <new name>for any directory, a live submodule checkout included. That checkout's.gitfile 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.git rm --cached members/IRRS, before the commit, the gate reads the gitlink from HEAD and says ok.member-gitlink-absentonly fires after the commit, so CI is the first to see it.AGENTS.mdand the validator disagree on hand edits.AGENTS.mdsays never to hand-editfamily.yaml'smembers:block. The validator's repair remedies tell people to do so (RESTORE_ROW, and nowmember-gitlink-absent). One of the two should change.member-commit-unresolvablenames no remedy (rv681's candidate 3). Take a pin no remote holds:git submodule updateexits 128 (not our ref), the gate refusesmember-commit-unresolvablewith no remedy line, and--no-membersexits 0 over it.docs/release-notes/v1.0.0.md:466lists it. The v1.1.0 notes (lane 3's README, AGENTS.md, docs/cli.md and the handbook:pipx install openreposhapeis the second way in and the first on native Windows, git-filter-repo is installed in install.sh's order and never with a barepip install, and a first draft of the v1.1.0 release notes (#563) #630) should say 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 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 withaddexiting 0 when typed as printed, MO1 is killed, and its four files give 80 passed. MO2 (-ffor-e) fails only the[clone]case, so that test alone holds the-echoice (Direction B); Direction A stays Brett's call.Closes #681
🤖 Generated with Claude Code
Summary by Sourcery
Make repository validators accurately identify malformed pin rows and provide executable recovery guidance for staged, missing, or case-variant gitlinks.
Bug Fixes:
shape-pin-rowfinding.Enhancements:
Tests: