Repository navigation
family.py and validate-family.py: where an earlier act left the branch, the switch -c remedy says to give it another name, or go back only if it holds work not merged yet, and the docs say family/add-<Project> (#607) - #637
Conversation
…h, the `git switch -c family/<act>-<member>` remedy says the same command without `-c` switches to it, so `already exists` is no dead end, and the docs say `family/add-<Project>` (#607) L2-46. `family.py add`, `bump` and `remove` refuse the holder's tracking branch with `git -C <holder> switch -c family/<act>-<member>` (#487), and the holder's `validate-family.py` names the same line before each `add` and `remove` it prints (#539, landed as 54041cc). Where an earlier act left that branch the line fails, `fatal: a branch named 'family/add-atlas' already exists`, exit 128, and neither tool said what to do. Both now say it in one sentence, the same words in both (`BRANCH_LEFT`): "If `git switch -c` says that branch already exists, an earlier act left it: the same command without `-c` switches to it and keeps every commit on it, or give the branch another name." `family.py` prints it under the `switch -c` of its refusal and of the dry run's `branch` line; the validator ends the prose of `member-gitlink-absent`, `member-duplicate-id` and `member-gitlink-unnamed` with it. No printed command resets or deletes the branch: `git switch` keeps its commits and refuses to overwrite a changed file. In words, not as a second command or a one-line `||`: `family.py`'s lines are quoted for PowerShell on Windows, where 5.1 has no `||`, and README lists the kinds of `family.py` line quoted that way; the validator's report is read in CI, where the checkout holds none of the person's branches, so neither tool can print the one line that works by asking first. L2-45. AGENTS.md, the holder's README and AGENTS-shape.md, and `init`'s NEXT STEPS spelled the branch `family/add-<project>` beside `--member <org>/<Project>`. All now say `<Project>`, the one placeholder the validator already says for both (#539); `add` takes any branch but the tracking one, and its own refusal names the branch lowercased. AGENTS.md stays at 492 lines. `validate-family.py` and `AGENTS-shape.md` are pinned copies: each holder takes them as `upstream-changed` rows on its next update. New tests in tests/test_family_switch_branch_exists.py type each printed line in both states, with no such branch and with one an earlier act left holding a commit of its own, for the three acts and the three validator remedies; three existing tests take the new text. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-5, 2026-10-15T23:42Z) @codex review |
Reviewer's GuideThe PR teaches family.py and validate-family.py how to guide users when a previously created family branch makes Sequence diagram for recovering an existing family branchsequenceDiagram
actor User
participant Family as family.py
participant Git
User->>Family: add, bump, or remove
Family-->>User: Refusal with git switch -c family/<act>-<member>
User->>Git: git switch -c family/<act>-<member>
Git-->>User: branch already exists
User->>Git: git switch family/<act>-<member>
Git-->>User: switch to existing branch and keep commits
User->>Family: repeat the family command
Family-->>Git: commit act on existing branch
Sequence diagram for validator remediation of an existing family branchsequenceDiagram
actor User
participant Validator as validate-family.py
participant Git
User->>Validator: validate holder
Validator-->>User: Remediation with git switch -c family/add-<Project>
User->>Git: git switch -c family/add-<Project>
Git-->>User: branch already exists
User->>Git: git switch family/add-<Project>
Git-->>User: switch to existing branch and keep commits
User->>Validator: apply the remaining remediation steps
Flow diagram for family branch naming and recovery guidanceflowchart TD
A[Family operation refuses on tracking branch] --> B[Print git switch -c family/<act>-<member>]
B --> C{Branch already exists?}
C -->|No| D[Create and switch to branch]
C -->|Yes| E[Switch using the same command without -c]
E --> F[Keep existing commits or give the branch another name]
D --> G[Run the family operation]
F --> G
H[Documentation and init guidance] --> I[Use family/add-<Project>]
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
…h, the `switch -c` remedy puts another name first and going back second, for a branch that holds work not merged yet, so neither a branch behind a moved main nor a detached commit is the first advice (#607) Fix round 1 on #637, from the review of 6212c31. S1. The sentence sent a person back to the old branch first. That is right only where the branch holds work not merged yet. Probed in a real local holder: with no commits on the old branch and `main` moved on since (a later member squash-merged), going back and running `add` exits 0, but the branch then conflicts with `main` (`CONFLICT (add/add)` in .gitmodules and `CONFLICT (content)` in family.yaml, `git merge-tree --write-tree` exit 1); with a commit made on a detached HEAD, `git switch <branch>` leaves that commit behind (`git branch --contains` names no branch). Another name starts where the holder stands: `add` exits 0, the merge-tree exits 0, and the detached commit stays on the new branch. `BRANCH_LEFT` now reads: "If `git switch -c` says that branch already exists, an earlier act left it: give the branch another name, which starts it where the holder stands, or, only if that branch holds work not merged yet, the same command with `switch` in place of `switch -c` goes back to it and keeps every commit on it." It is word for word the same in scripts/family.py, in the pinned templates/family-root/scripts/validate-family.py and in the pinned constant in tests/test_validate_family_branch_step.py, and the comments that described the old order are rewritten. The sentence still prints no second command and no `||`. New tests in tests/test_family_switch_branch_exists.py, each run as printed in a real local holder: - `test_another_name_works_where_main_has_moved_since`: an earlier act's branch is left behind a `main` that a later member moved. The printed line fails with `already exists`, the same line with another name works, `add` exits 0, and `git merge-tree --write-tree main <branch>` exits 0. - `test_another_name_keeps_a_commit_made_on_a_detached_head`: after the printed line with another name, `git branch --contains <commit>` names the new branch. S2. #610 adds a fourth `_switch_line` to the validator (in `_lockstep`) with no sentence. `test_every_remedy_that_names_a_switch_line_ends_with_the_sentence` reads the template's source with `ast`, finds every call of `_switch_line` however its string is passed on (a `Refusal`, a `report.finding`, or the `return` of a helper), and requires the prose of the string it stands in, up to the first line break, to end with `BRANCH_LEFT`. `test_the_guard_sees_a_remedy_left_without_the_sentence` shows it fails on the source with the sentence taken out of any one remedy, and on a fourth remedy built by a helper as `_lockstep` builds its. Whichever of #637 and #634 lands second must end `_lockstep`'s prose with `BRANCH_LEFT`, and CI fails until it does. N6. The init NEXT STEPS test read the lines with a regex whose `--family-root \S+` breaks on a root with a space in it; it now reads the words with `host_words`, as the NEXT STEPS test beside it does. A correction to the first commit's body, which says neither tool can print the one line that works by asking first: that holds for the validator, whose report is read in CI where the checkout holds none of the person's branches. `family.py` could ask, and says the validator's words so the two tools keep one rule. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
switch -c family/<act>-<member> remedy says the same command without -c switches to it, and the docs spell it family/add-<Project> (#607)switch -c remedy says to give it another name, or go back only if it holds work not merged yet, and the docs say family/add-<Project> (#607)
…: the comments say another name "works in every state", not that it is right in every one, and the guard's and the state-(d) test's docstrings say only what each does, so none of them claims more than it shows (#607) A second fix round on #637, from the re-verify rv607b (APPROVE WITH NITS: 0 blockers, 0 should, 5 nits). Comments and docstrings only; no behaviour changes, and `BRANCH_LEFT` is word for word the same in the three places. 1. Nit 1: the comment above `BRANCH_LEFT` in scripts/family.py and the one in the pinned templates/family-root/scripts/validate-family.py said a new branch starts where the holder stands now, "which is right in every state". In the state where the old branch holds work not merged yet, another name leaves that work behind. Both now say "which works in every state", and the two comments still read the same where they are the same. 2. Nit 3: the docstring of `switch_remedies` in tests/test_family_switch_branch_exists.py said the guard finds the call "however it is passed on", which a switch line moved into a variable would defeat. It now says "wherever the call stands inside the string expression that is passed on". 3. Nit 5, the docstring half: the docstring of `test_another_name_works_where_main_has_moved_since` said going back to the old branch conflicts, which the test does not assert. It now says the test shows the other name works, and that the conflict on the old branch is shown by rv607's probe and decision 2's table in the PR body. No assertion is added. 4. Nit 4 is in the PR body draft, outside this commit: the fix-round line for N4 now quotes decision 1 as written, and a "Fix round 2 (rv607b)" note says what this round did. Nit 2, the guard's control counts, is left as written: each case fails loudly with a clear message. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
|
Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days. You can request another review in 10 hours by commenting |
…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>
main gained #563's own #590 (fe237ec) and #599 (32c22b0), whose trees this branch already held through #599's 750366b (#599's final head, eaa0cda, is 750366b plus a merge of main), and #626, #633, #642, #641, #643, #644, #632, #636, #631, #638, #635, #640, #623, #637 and #622. No conflict: the merged tree is 9be0d0d, the tree `git merge-tree --write-tree` predicted. AGENTS.md, docs/cli.md and tests/test_repo_hygiene.py merged as text; README.md and docs/handbook.html are unchanged by the merge. README.md is 1461 lines, its cap (1449 + 12, tests/readme-cap.d/563.txt), and AGENTS.md 494, its cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tab shows the one-line install for Git Bash, held to the other copies by a test, and the notes are current to main d4a7fab with the fifteen pull requests since 4cf5fb0, the environments, the tag ruleset and the rehearsal (#563, #461) The handbook (#461's line "The handbook's step 3 has no Git Bash path", which lane 3 told lane 5 on #473 that #630 settles): step 3's install block was `data-platform="linux mac wsl"`, so a reader on the Windows tab never saw the one line the Windows panel says Git Bash runs. The Windows tab now has a lead, "In Git Bash, the bash that Git for Windows installs, the one-line install works as on Linux:", the Linux tab's block copied byte for byte as `data-lang="sh"`, and the panel's caveat: the preflight it ends with cannot take a typed `yes` in Git Bash. README.md is unchanged: no test asked for it, and it is at its cap. tests/test_repo_hygiene.py: the test above it finds one copy of the one line per file; `test_every_handbook_copy_of_the_one_line_install_is_the_same` holds every handbook copy (the masthead's and step 3's two) to ONE_LINE_INSTALL, so a copy edited on one tab alone fails. A drifted Windows copy fails it; the page as committed passes. The release notes: - "What changed since v1.0.1" gains one entry per pull request that landed after 4cf5fb0 and is not #563's own, from `git log --first-parent 4cf5fb0..origin/main` at d4a7fab: under update-shape.py and bump-leg.py, #644, #633 and #631; under scaffold, family and the materializer, #622, #643, #640, #641, #623, #638, #635, #626, #637 and #642; under shape-doctor.py, #636 and #632. Each from its squash commit and the issues GitHub says it closes. - The list's placeholder says it is current to main d4a7fab and stays a placeholder for what lands after it; the counts are refreshed inside their placeholder (66 pull requests, 75 of 97 milestone issues closed). - #590 and #599 are cited beside #563, and "Before tagging" says they landed (fe237ec, 32c22b0) and lists the 22 open milestone issues. - "How this release is cut": Brett Heap's word at 2026-10-16T12:06:40Z, the environments testpypi (12:07:31Z) and pypi (12:07:39Z), the tag ruleset 25237399 (12:08:14Z), and the rehearsal, run 38898735926 (12:10:53Z-12:12:00Z, success, both uploads skipped), each read back from the API. The trusted publishers' date stays a placeholder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tmodules` gives is `member-gitmodules-absent`, `remove` refuses a holder with no `.gitmodules` before `deinit`, and two gitlink remedies name what they missed (#611) (#639) A holder whose HEAD lost `.gitmodules` (the commit #407 describes) passed its own gate, and `family.py remove` in it emptied a member and then failed. This PR makes the gate find that state, makes `remove` refuse it before it writes anything, and fixes two of the gate's gitlink remedies. **Pinned copy.** `templates/family-root/scripts/validate-family.py` is pinned in every holder (`scripts/shape_materialize.py` lists it). Each holder will see it as `upstream-changed` on its next `update-shape.py check`. No other pinned file changes. No test pins this file's bytes or digest; the tests that copy it into a holder take it from the template, and they all pass (below). ### Fix round 2: main after #637 Brett Heap's word (2026-10-16T11:21:49Z) was "Accept; merge #639 after #637". #637 (#607) landed as 95780dd, so this branch now holds one merge commit, 08b6245, of origin/main at 95780dd. It changes no line of #611's own work beyond one sentence, in two places: - **The conflict.** #637 appended `BRANCH_LEFT` to the end of `member-gitlink-unnamed`'s hand-mount remedy, the same sentence 574/3 changes. The merge keeps this PR's "and commit that removal, since ..." clause and ends the prose with #607's `BRANCH_LEFT`. It is resolved in `templates/family-root/scripts/validate-family.py` (the last `report.finding` of `_check_gitlinks_named`) and in `tests/test_validate_family_unnamed_mounts.py` (`GITLINK_REMEDY`, now ending `+ BRANCH_LEFT`). No other file conflicted. - **#637's guard** `test_every_remedy_that_names_a_switch_line_ends_with_the_sentence` passes on the merge. The three remedies that name `_switch_line` (`member-gitlink-absent`, `member-duplicate-id` and `member-gitlink-unnamed`) all end with `BRANCH_LEFT`, and no other remedy this PR adds names it, so none was appended beyond the one resolved above. `git diff 6aace42 08b6245` on `validate-family.py` is #637's own change and nothing else. - **#634 (#610) is not in main yet.** `_check_member_gitlink` is untouched by this merge. Its conflict, and the `BRANCH_LEFT` its `_lockstep` remedy needs, are in the merge table and in N-5 below. - **Checks.** `git merge-tree --write-tree 08b6245 origin/main` gives 08b6245's own tree, 6a475a4, and `git diff --stat origin/main 08b6245` names only this PR's five files. The tests are in "Results" below. ### Fix round 1 (rv611b) The review of 6f938ed gave 0 blockers, 3 should and 6 nits. Commit 6aace42 answers each one: - **S1.** `_checked_gitmodules_held` now takes the member's gitlink at any stage, not only stage 0. With HEAD's `.gitmodules` gone and `members/IRRS` held only at stages 1, 2 and 3, the dry run exited 0 and the real run emptied the mount. Both now refuse `member-gitmodules-absent` before `deinit`, with the holder byte for byte as it was. Typed as printed, the remedy then lets `remove` commit. The docstring and the 571(b) text below use the review's wording. - **S2.** One new test for each surviving mutant, each shown to kill it (table below): M1 (a checked-out member still gets its tree check), M2 (HEAD's unreadable file is refused in every mode), M4 (the `<url>` placeholder) and M5 (`remove`'s remedy names only members whose gitlink is in the index). - **S3.** Body only. The 574/2 section now says, in the review's words, how the remedy departs from the letter of Brett Heap's "Refuse on every disk" ruling. The commands are unchanged. It needs Brett's word on that departure. - **N1.** The variant move prints `git rm -r --cached --ignore-unmatch -- <variants>`. Where the variant's removal is staged and not committed, the gate still names it from HEAD, and the plain line failed (`pathspec ... did not match`). Tested on both disks. - **N2.** The variant remedy now says what is left on disk. In a fresh clone, `git submodule update --init --recursive` then checks the member out at the row's path. On a case-sensitive disk the old `Members/<P>` directory stays, and `family.py add` refuses it as `family-members-dir-variant`, so the remedy gives an `mv` of it out of the holder, typed as printed. Tested with both directories on disk: every printed line typed, the gate passes, and another member is added. - **N3.** The validator's docstring and this body say what a staged, uncommitted `.gitmodules` does: nothing, until it is committed (571(c) below). - **N4.** The module docstring names `member-gitmodules-absent` and gives EXIT CODES' one exception. - **N5.** M8 (one finding per path for a duplicate row) has a test. M3 and M7 are still untested; "Not changed, and why" gives the reason. - **N6.** The three inexact claims now read as the review gives them, and the merge table is refreshed against the current heads, #634 included. The other sections below describe the PR as it now stands. They are line by line against #459's comment 6192725913. ### Re-verify (rv611c, on 6aace42): APPROVE WITH NITS The re-verify graded it 0 blockers, 0 should and 5 nits. S1, S2, N1, N3, N4 and N6 are fixed, and N2 and N5 are answered. S3's paragraph is in the body word for word; the departure itself waits for Brett Heap's word. - **S1, confirmed.** A gitlink held at stages 1-3 only is refused before `deinit` in both runs; the holder and the mount's 15 entries are unchanged. - **The mutants.** M1, M2, M4, M5, M8 and M12r are each killed. Three more mutants of this round's new text (the `mv` target, the macOS and Windows sentence, `--ignore-unmatch`) are killed too. - **Typing every printed line works in layouts C1, C2, C2s, C3, C4 and C6:** the gate passes in all three modes, and `add --dry-run` of another member exits 0. - **Left as nits,** since each case keeps every file and can be recovered: - **N-1.** Where the variant directory is a live submodule checkout with an absorbed gitdir, the printed `mv` leaves its `.git` file pointing nowhere. Its files and any unpushed commit stay, the commit in the holder's `.git/modules/<variant>`, and the gate also prints its own `gitmodules-path-unnamed` finding for the stale section. Reaching this needs a hand-mounted live checkout, the 574/3 layout. - **N-2.** The `mv`'s condition is stated by OS ("a case-sensitive disk, as Linux's usually is"; "on macOS and Windows ... leave it"), not by a check a person can run. On a case-folding Linux mount (WSL's `/mnt/c`), typing it moves the member's own checkout out; `git submodule update --init --recursive` then restores it, and the gate passes. - **N-3.** An empty `../<variant>` directory that already exists is nested into. A file or a non-empty directory of that name makes `mv` refuse, and nothing is lost. - **N-4.** M3 and M7 stay untested, as "Not changed, and why" says. - **N-5, composition, not this PR's.** #634 and #637 together fail #637's guard test, on #634's `_lockstep` line. #637 has landed, so #634 appends `BRANCH_LEFT` there when it takes main. ### 571(b): `remove` in a holder whose HEAD lost `.gitmodules` On BASE 396c0ff, with local bare remotes and a `gh` that exits 97: ``` remove --dry-run: exit 0 gitmodules none: git reads no section of .gitmodules in the work tree as giving members/Nova, so none is removed remove: exit 2 REFUSED a git or gh command failed. THE EXACT COMMAND WAS: git --literal-pathspecs add -f -- .gitmodules family.yaml fatal: pathspec '.gitmodules' did not match any files The unmount was undone only in part (...); NOT members/Nova checked out again ... members/Nova holds 0 entries ``` Now `_checked_gitmodules_held` refuses this holder, before any git command that writes, and the dry run refuses it in the same words. It asks only where none of the work tree, the index and HEAD holds a `.gitmodules`, and the index records the member's gitlink at any stage: `deinit` and `git rm` run on a merged or an unmerged gitlink alike, and with no `.gitmodules` anywhere the commit cannot stage one. `_checked_gitmodules_as_staged` (#407) asks nothing there, since there is no copy to compare. ``` REFUSED member-gitmodules-absent: the holder has no .gitmodules: neither the work tree, the index nor HEAD holds one, though the index records a gitlink at members/IRRS. `git rm` edits only a .gitmodules the work tree has, and `remove` commits .gitmodules as `git rm` leaves it, so with none to commit it would fail after `git submodule deinit` and `git rm` had emptied members/IRRS. A recursive clone of the holder checks out none of its members, and its validate-family.py finds member-gitmodules-absent for each. No git command that writes was run and nothing was written. Remediation: write back the section `family.py add` writes for each member the index records, this one included, in a POSIX shell — `git config -f .gitmodules submodule.members/IRRS.path members/IRRS` and `git config -f .gitmodules submodule.members/IRRS.url https://github.com/InkRouter/IRRS.git` and `git config -f .gitmodules submodule.members/IRSS.path members/IRSS` and `git config -f .gitmodules submodule.members/IRSS.url https://github.com/InkRouter/IRSS.git` — then stage it, `git add -f .gitmodules` (`-f`, since an operator's excludes may match it), and commit it on its own first if the command's commit should carry only its own change, and run the same command again. ``` The remedy names only the members whose gitlink the index records (tested). ### 571(c): the gate passes a row no section of HEAD's `.gitmodules` gives (#407's decision 3) On BASE, in that holder: `validate-family.py` exit 0 `family ok`, and `--no-members` exit 0. In a fresh recursive clone of it: the clone exits 0 with every member empty, `bootstrap.py` says `every member and leg fetched` and `NOT CHECKED OUT; skipped` for each, `validate-family.py` refuses `member-uninitialized` (exit 2) and names `git submodule update --init --recursive`, which exits 0 there and checks nothing out, and `--no-members` says `family ok`. Now the finding is `member-gitmodules-absent`, one per row, in every mode (`--no-members` and `--pins` too), exit 1: ``` FINDING member-gitmodules-absent: members/IRRS is a gitlink at <commit> that a row of members: names, and HEAD holds no .gitmodules, so a recursive clone of this holder checks out nothing there, and neither does make bootstrap in that clone, whatever checkout this holder has of it. Write its section back by hand, in a POSIX shell, as family.py add writes one, then stage and commit .gitmodules: git config -f .gitmodules submodule.members/IRRS.path members/IRRS git config -f .gitmodules submodule.members/IRRS.url https://github.com/InkRouter/IRRS.git ``` Where HEAD's file exists but gives no section the path, it says `no section of HEAD's .gitmodules gives that path`. A section of the name `family.py add` gives (`members/<P>`) that already has a url keeps it: only the `path` line is printed. Where the row's `repository:` is not `<org>/<Name>`, the url line prints `<url>` (tested). - **It reads HEAD's file**, as a recursive clone and CI's checkout do. It lists HEAD's root tree with no pathspec, as `_member_gitlinks` reads HEAD. A file git cannot read is `gitmodules-unreadable`, in every mode, with "commit it, then run this again" (tested: HEAD's file broken, the work tree's mended). A HEAD git cannot list (no commit yet) is asked nothing. - **A `.gitmodules` staged and not committed counts for nothing until it is committed.** HEAD's file is what counts, as #407's decision 3 says. The check reads three sources: the gitlinks from the index first, the rows from the work tree's `family.yaml`, and the sections from HEAD. So: - a `family.py add` staged and not committed (an `add`, then `git reset --soft HEAD~1`) is a finding for its own member; - a section taken out and staged, not committed, passes; - a `.gitmodules` put back and staged, not committed, in a holder whose HEAD lost it, is a finding for every member until it is committed. - **Which rows.** Each row at `members/<project>` whose gitlink this repository records (`gitlinks`, read as `recorded_gitlink` reads one). A row with another `path:` is `member-path`'s, and a row with no gitlink is `member-gitlink-absent`'s. A row written twice is one finding (tested). - **A member it names that is not checked out is not refused `member-uninitialized`.** That refusal dropped the finding and named a command that does nothing there. It is what a fresh clone and CI met. The finding still fails the run. A member it names that IS checked out is still asked its tree: a digest edited by hand is `member-digest-mismatch` beside the finding (tested). - **No misfire.** A holder with no members, a work tree whose `.gitmodules` was deleted and not committed, an edit nobody staged, and a sparse checkout that leaves the file out each pass (tested). A `.gitmodules` put back by hand but untracked is still a finding until it is committed: HEAD, which a clone reads, still has none (tested). - **Bootstrap, validate and a fresh recursive clone agree on the members.** In a fresh clone of the damaged holder, the clone leaves both members empty, `bootstrap.py` says `NOT CHECKED OUT; skipped` for each, and the gate names both, members checked or not. With the sections written back as printed and pushed, a fresh clone checks both out and the gate passes in every mode (tested). `bootstrap.py`'s own fetch line still says `every member and leg fetched` there, because `git submodule update` exits 0. That is checklist candidate (a) below; this PR does not change `bootstrap.py`. ### 574/2: a HEAD gitlink at a case variant (#363's item 3) On BASE, with HEAD's gitlink at `Members/IRRS` and the row and section at `members/IRRS`, the gate refused `member-gitlink-absent` for `members/IRRS`, never named `Members/`, and named `family.py add`, which cannot help because the row is there. Now: ``` REFUSED member-gitlink-absent: no gitlink (mode 160000) recorded at 'members/IRRS'. IRRS is named in family.yaml, and this repository records its gitlink only at a case variant of that path, which macOS and Windows hold as one directory with it: Members/IRRS (at <commit>). A recursive clone checks out nothing at members/IRRS. Remediation: the member is recorded in the wrong case. Move its gitlink to the row's path, at the commit the row pins, in the index alone, which touches no file, with the two lines below, and commit that. Where nothing is checked out at members/IRRS, as in a fresh clone, `git submodule update --init --recursive` then checks the member out there. On a case-sensitive disk, as Linux's usually is, a directory left at Members/IRRS stays there, untracked, and family.py add refuses it as family-members-dir-variant: move each one that is there out of the holder, `mv -- Members/IRRS ../Members-IRRS`. On macOS and Windows it is the row's own directory, so leave it. The two lines: git rm -r --cached --ignore-unmatch -- Members/IRRS git update-index --add --cacheinfo 160000,<pin>,members/IRRS ``` This keeps Brett Heap's "Refuse on every disk" ruling in naming the entries, giving one answer on every disk and ending in a commit, but not in the letter of its remedy, "a committed `git mv`": it takes every variant out of the index with `git rm -r --cached`, as #574 does where nothing is on disk in that spelling, and does so even where a checkout is on disk there, where #574 prints `git mv`, because `git mv` to the row's path fails or misplaces the gitlink (below). It needs Brett's word on that departure. What `git mv -- Members/Nova members/Nova` did in a probe on Linux (git 2.43, a gitlink at `Members/Nova` in the index): - the checkout at `members/Nova` and nothing at `Members/Nova` (the holder it happened in): `fatal: bad source, source=Members/Nova, destination=members/Nova/Nova`, exit 128; - both directories there: exit 0, and the gitlink lands at `members/Nova/Nova`; - only the empty `Members/Nova` a fresh clone leaves, where no other member makes a `members/` directory: `fatal: renaming 'Members/Nova' failed: No such file or directory`, exit 128 (where one does, the same `git mv` exits 0). The index-only pair reads no disk, so it works in all three and on a folding disk. Several variants are taken out on one line, and the gitlink goes back once, at the pinned commit. `--ignore-unmatch` lets the first line run where the variant's removal is already staged. `member-gitlink-unnamed` still prints no command for the same gitlink under `--no-members`, since taking it out alone would un-register the member. The pair here takes it out and records it again together, so the two do not contradict. **What is left on disk.** In a fresh clone the gate first refuses `member-uninitialized` once the move is committed; the `git submodule update --init --recursive` the remedy names cures that. On a case-sensitive disk the variant's directory stays: empty in a fresh clone, a full checkout where a holder had both. The gate passes, but `family.py add` of any other member then refused `family-members-dir-variant ... on disk and untracked: Members/<P>`. The `mv` moves it out of the holder, to `../<variant with / as ->`, which no spelling of `members/` meets. On macOS and Windows the variant's directory is the row's own, so the remedy says to leave it. A probe typed every printed line in three layouts on Linux: a fresh recursive clone (the `mv`, then `git submodule update`, then the gate passes and `add --dry-run` of a third member exits 0); both directories on disk (the gate passes and `add --dry-run` exits 0, where before the `mv` it refused); and the half-repaired holder (the `--ignore-unmatch` line exits 0, nothing to move, the gate passes). The same probe on 6f938ed: in the first two layouts `add --dry-run` of the third member still refused `family-members-dir-variant` after the move, and in the third the printed `git rm -r --cached -- Members/Atlas` exited 128 (`pathspec 'Members/Atlas' did not match any files`). ### 574/3: commit the `git rm` before the `add` The hand-mount remedy now reads "take it out with the second, which refuses while its checkout holds changes and takes its .gitmodules section out too, 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", and, since #637, ends with its `BRANCH_LEFT` sentence. On BASE, typing the three lines for a hand mount at `Members/IRQS` gave `REFUSED member-path-taken: ... in HEAD and no longer in the index: Members/IRQS (a submodule)` at the `add`. With the commit between, `add` mounts it. 1. **`remove` refuses; it does not commit without `.gitmodules`.** The issue's approved recommendation (Brett Heap, 2026-10-15T16:49:38Z) is to refuse before `deinit`. The state is damage: the gate now finds every remaining member of such a holder. A `remove` that committed past it would leave a holder that still fails its gate, so the repair comes first, as #407 refuses a `.gitmodules` the work tree lacks. The refusal is narrow. Where HEAD's file lacks only the member's own section, `remove` runs as before and the gate passes after it (tested). Where the member has no gitlink, the run fails at its first git call as before (tested). Where its gitlink is unmerged, it is refused like a merged one (tested). 2. **The repair a person can type** is the section `family.py add` writes, by `git config -f .gitmodules`, with the url `family.py add` gives a member (`https://github.com/<repository>.git`). It is not restored from history, because that would bring back the sections of members removed since. It is not taken from the holder's `.git/config` registration, which is local and, in a `--local-remote-dir` holder, a local path (#540 keeps local paths out of committed files). Each test types it as printed and reaches the fixture's local remotes through a `url.<base>.insteadOf` for the one command. 3. **The code `member-gitmodules-absent`** is the gate's finding and `remove`'s refusal, one fact under one name, as `gitmodules-unreadable` is shared. It is not `gitmodules-*`: `remove`'s #407 refusals use that prefix for a work-tree copy that is not the index's. 4. **Where the new code is documented.** No document names the gate's codes: AGENTS.md (at its 492-line cap), the holder's `AGENTS-shape.md`, `README.md` and the holder's README name none of `member-gitlink-absent`, `member-gitlink-unnamed` or `gitmodules-path-*`. `tests/test_agents_md_codes.py` holds only the codes AGENTS.md names. So the new check is documented where the others are, in the validator's own "WHAT IS CHECKED" docstring, which ships in every holder; it names `member-gitmodules-absent` there and in EXIT CODES. AGENTS-shape.md is not changed, since that would move a second pinned file. Adding the code to AGENTS.md would need a line under the cap. 5. **574/2's refusal is reached in members mode.** In a plain clone followed by `git submodule update --init --recursive`, as CI and `make bootstrap` run it, it is reached when the variant's row is the first row not checked out: `git submodule update` stops at the first gitlink it has no url for, and `Members/` sorts before `members/`. CI then falls back to `--no-members`, where `member-gitlink-unnamed` names the variant. After `clone --recurse-submodules` the refusal is reached whatever the row order. This is checklist candidate (b) below. 6. **The `mv` target** is `../` and the variant with each `/` as `-` (`../Members-IRRS`): out of the holder, where no spelling of `members/` meets it, with nothing to fill in, and distinct for each variant on the disks where the step applies. It moves rather than deletes, since the directory can be a checkout with work in it. `tests/test_family_head_lost_gitmodules.py`, new, 38 tests: - 571(b): the refusal by `remove` and its dry run alike, exit 2, with the holder byte for byte as it was (`test_family_dry_run.state`), the member's checkout included; the remedy typed as printed, with no excludes and under an operator's `*` excludes; `remove` then commits and the gate passes in every mode; the pushed result cloned recursively checks the other member out and its gate passes; a member whose section alone is gone is removed as before; a member with no gitlink is not this refusal; a member whose gitlink is held only at stages 1, 2 and 3 is refused alike, with the mount and the stages kept, and the remedy typed lets `remove` commit (S1); the remedy names only the members whose gitlink the index records (M5). - 571(c): one finding per member where HEAD lost the file, in every mode, typed and committed; one finding where one section is gone, in every mode; a section of `add`'s name keeps its url; a fresh clone says the findings and not `member-uninitialized`, `bootstrap.py` says `NOT CHECKED OUT` for each, and after the remedy a fresh clone passes; no misfire on the three work-tree states and on a holder with no members; an untracked copy put back is still a finding until committed; rows the gitlink checks do not read are asked nothing; a checked-out member it names is still asked its tree, members checked and `--pins` (M1); HEAD's unreadable file is `gitmodules-unreadable` in every mode, and the mended file committed passes (M2); a `repository:` that is not `<org>/<Name>` prints `<url>` (M4); a row written twice is one finding per path (M8). - 574/2: one variant and two, on a Linux disk and an emulated folding disk (`core.ignorecase=true`, index records only), typed as printed and committed, then the gate passes in every mode; a fresh clone says it too; the variant's removal staged and not committed, on both disks, typed as printed and committed (N1); on a case-sensitive disk, a second checkout at `Members/IRRS`, every printed line typed (the two lines, the commit, the `mv`), then the gate passes in every mode and `family.py add` of IRQS exits 0 (N2; skipped on a case-insensitive disk, where the two directories cannot both exist). - 574/3: a hand mount at `Members/IRQS` (index records, no `Members/` on disk), on both disks: the uncommitted `git rm` makes `add` refuse; the commit the text names makes it mount. Changed tests: - `tests/test_validate_family_unnamed_mounts.py`: the pinned hand-mount remedy text; the hand mount run as printed now commits the `git rm`; in members mode the case-variant state's refusal prints the index move, now with `--ignore-unmatch` (the `--no-members` finding still prints no command); the section-path-in-another-case state now also has `member-gitmodules-absent` with its one `path` line. - `tests/test_family_remove_gitmodules.py`: the docstring of the HEAD-lost test, which said the real run "still fails" and the case goes to #459. Results, Linux, `/usr/bin/python3 -m pytest`, `gh` stub first on PATH: - Fix round 2, on the merge 08b6245 (every run through `run9.sh`, committed, clean tree): - #637's guard and the unnamed-mounts file, on the merge as resolved and before it was committed: `tests/test_family_switch_branch_exists.py tests/test_validate_family_unnamed_mounts.py`: `65 passed in 63.49s (0:01:03)`; - batch A: `tests/test_family_head_lost_gitmodules.py tests/test_validate_family_unnamed_mounts.py tests/test_family_switch_branch_exists.py tests/test_validate_family_branch_step.py tests/test_family_remove_gitmodules.py`: `152 passed in 204.98s (0:03:24)` (38 in the new file and 114 in the other four); - batch B: `tests/test_validate_family*.py tests/test_family_members_case_variants.py tests/test_repo_hygiene.py` (the five `test_validate_family*` files are `branch_step`, `duplicates`, `member_path_glob`, `unnamed_mounts` and `without_git`): `246 passed in 165.92s (0:02:45)`; - no skips in either run. Batch A of round 1 (704 tests, 18 files) and batch B of round 1 (194 tests) were not re-run: 08b6245's five files differ from 6aace42's only by what main changed in them, #637's `BRANCH_LEFT` lines included. - Fix round 1, on 6aace42: - the new file and `tests/test_validate_family_unnamed_mounts.py`: `78 passed in 44.40s`; - the same two files on 6f938ed (a throwaway clone with them copied in): `11 failed, 67 passed in 42.52s`. The S1 test fails with `work tree changed` (the mount emptied). The N1 and N2 tests fail at the refusal's text, which lacks `--ignore-unmatch` and the `mv`; the probe below shows what that text did when typed. The other seven pin the refusal text this round changes: the four case-variant moves, the fresh clone of one, and the unnamed-mounts test's two members-mode cases. The five S2 and N5 tests pass there, as they must: they pin behaviour 6f938ed already has, and the mutants below are their proof; - batch A (the list below): `704 passed in 368.87s (0:06:08)`, no skips; - from batch B, `tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_cli_reference.py tests/test_family_members_case_variants.py`: `194 passed in 42.58s`. - Round 0: - the new file on BASE 396c0ff (a throwaway clone with the file copied in): `19 failed, 7 passed in 21.36s`. The 7 that pass pin behaviour this PR keeps: the three work-tree states and the holder with no members (no misfire), the member whose section alone is gone, the member with no gitlink, and the rows the gitlink checks do not read; - the new file on 6f938ed: `26 passed in 33.48s`; - targeted batch A on c2f2884: `tests/test_family_head_lost_gitmodules.py tests/test_family_remove_gitmodules.py tests/test_validate_family_branch_step.py tests/test_validate_family_duplicates.py tests/test_validate_family_member_path_glob.py tests/test_validate_family_unnamed_mounts.py tests/test_validate_family_without_git.py tests/test_family_members_case_variants.py tests/test_family_add_refusals.py tests/test_refusal_default_remediation.py tests/test_family_dry_run.py tests/test_family_member_path.py tests/test_family_bump_remove_rollback.py tests/test_family_tracking_branch.py tests/test_family_printed_quoting.py tests/test_family_add_excludes.py tests/test_family_add_member_id.py tests/test_family.py`: `690 passed in 585.40s (0:09:45)`; - targeted batch B on c2f2884, every other test file that runs the holder's validator, `family.py` or the codes: `tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_shape_doctor.py tests/test_doctor_gitlink_repairs.py tests/test_doctor_legs_members_next.py tests/test_doctor_next_is_a_command.py tests/test_doctor_validator_rows.py tests/test_family_bump_to_a_tag.py tests/test_family_bump_to_checks.py tests/test_family_init_values.py tests/test_family_makefile_siblings.py tests/test_family_member_name.py tests/test_shape_pin_files_not_a_list.py tests/test_stdout_encoding.py tests/test_template_text_placeholders.py tests/test_template_values_single_pass.py tests/test_update_shape.py tests/test_update_shape_add.py tests/test_update_shape_header_comments.py tests/test_update_shape_kept_helper.py tests/test_update_shape_pr_base.py tests/test_cli_reference.py tests/test_validate_pins_without_git.py tests/test_bump_leg_dry_run_truth.py tests/test_bump_leg_unopenable.py tests/test_bump_leg_unreadable.py`: `1746 passed, 23 skipped in 1448.78s (0:24:08)`. The skips are the Windows-only PowerShell and stdout lines and `test_family_init_values.py`'s template-quoting cases, as on main; - on the merge of 6f938ed with origin/main 8f453d0, committed and detached in a throwaway shared clone: batch A plus `tests/test_family_bump_to_checks.py` and `tests/test_family_bump_to_local_only.py`: `706 passed in 732.59s (0:12:12)`; batch B without `test_family_bump_to_checks.py`: `1740 passed, 23 skipped in 1383.61s (0:23:03)`. ### Fix round 1 Each mutant applied alone to a throwaway shared clone at 6aace42, with the new file run against it. M1, M2, M4, M5 and M8 are the review's own text replacements. M12r puts back the stage-0 test that S1 takes out; N1r drops `--ignore-unmatch`. | mutant | result | new tests that fail | |---|---|---| | M1 the tree check skipped even for a checked-out member | 2 failed, 36 passed | `test_a_checked_out_member_it_names_still_has_its_tree_checked[members-checked]`, `[pins]` | | M2 HEAD's `gitmodules-unreadable` swallowed | 3 failed, 35 passed | `test_a_head_gitmodules_git_cannot_read_is_refused` (all three modes) | | M4 the `<url>` placeholder never printed | 1 failed, 37 passed | `test_a_repository_not_org_and_name_prints_the_url_placeholder` | | M5 `remove`'s remedy not filtered to members with a gitlink in the index | 1 failed, 37 passed | `test_removes_remedy_names_only_members_whose_gitlink_is_indexed` | | M8 no dedupe, one finding per row | 1 failed, 37 passed | `test_a_row_written_twice_is_one_finding_per_path` | | M12r the stage-0 test put back | 1 failed, 37 passed | `test_remove_refuses_it_where_the_members_gitlink_is_unmerged` | | N1r `--ignore-unmatch` dropped | 8 failed, 30 passed | every test that pins the variant refusal: the four moves, the half-repaired move on both disks, the case-sensitive disk's `mv`, the fresh clone | M11 (the variant sort) was already killed at 6f938ed (2 failed in the review's run). ### Round 0 Each hunk (or one condition in it) reverted alone in a throwaway clone at 6f938ed, with the new file run against it (`mutate.py`, exact text replacements). Every one fails at least one new test: | probe | result | new tests that fail | |---|---|---| | F1 `remove` does not call `_checked_gitmodules_held` | 3 failed, 23 passed | `test_remove_refuses_a_holder_whose_head_lost_gitmodules[no-excludes]`, `[star]`, `test_the_holder_remove_leaves_clones_with_the_other_member` | | F2 the refusal asked whatever the member's gitlink | 1 failed, 25 passed | `test_remove_of_a_member_with_no_gitlink_is_not_this_refusal` | | V1 `member-gitmodules-absent` never made | 9 failed, 17 passed | the per-member finding (3 modes), the one-section finding (3 modes), the kept url, the fresh clone, the untracked copy | | V2 the work tree's `.gitmodules` read in place of HEAD's | 3 failed, 23 passed | no misfire `[deleted, not committed]`, `[left out by a sparse checkout]`, the untracked copy | | V3 no skip of an unregistered member that is not checked out | 1 failed, 25 passed | `test_a_fresh_clone_of_it_says_the_finding_and_not_uninitialized` | | V4 the url line always printed | 1 failed, 25 passed | `test_a_section_of_the_name_add_gives_keeps_its_url` | | V5 "HEAD holds no .gitmodules" never said | 4 failed, 22 passed | the per-member finding (3 modes), the fresh clone | | V6 a row with a hand-edited `path:` asked too | 1 failed, 25 passed | `test_a_row_the_gitlink_checks_do_not_read_is_asked_nothing` | | V7 a row with no gitlink asked too | 1 failed, 25 passed | `test_a_row_the_gitlink_checks_do_not_read_is_asked_nothing` | | V8 574/2's variant not named | 5 failed, 21 passed | the case-variant move (one and two variants, both disks), the fresh clone of it | | V9 574/3's commit not said | 2 failed, 24 passed | the hand mount under `Members/` (both disks) | The other hunks are docstrings, comments and `_sections_listed`, a split of `_gitmodules_sections` that changes no behaviour (the #300 and #361 tests in batch A pass through it), and the conditions S2 and N5 list, which no new test holds. (That was true of 6f938ed. Fix round 1 adds a test for each of them but M3 and M7; see "Not changed, and why".) Of this head (08b6245, the merge of origin/main 95780dd), run 2026-10-16 about 12:00Z against the PR heads `git ls-remote` gave: | Merged with | Head | Result | |---|---|---| | origin/main | 95780dd | clean: the merge tree is this head's own tree, 6a475a4. This branch now holds the merge of main, which `git diff --stat origin/main 08b6245` shows as these five files only. | | #622 (lane 2, #609) | 227bc3b | clean | | #626 (lane 4, #612) | landed (6f760b7, in main) | not applicable | | #630 (lane 3, README, the handbook, `tests.yml`) | ca5c49f | clean | | #621 (lane 2) | 10dda17 | clean | | #627 | 83ca2db | clean | | #634 (lane 2, #610) | b7472df | CONFLICT in `templates/family-root/scripts/validate-family.py`, two regions, as before: `_check_member_gitlink`'s signature and its one call in `_check_member`. This PR adds `gitlinks`, #634 adds `rule`; keep both: `gitlinks: dict[str, str], report: Report, rule: str`, called as `_check_member_gitlink(root, path, commit, project, gitlinks, report, rule)`. Lane 2's issuecomment-6206200388 says the same. #634 is not on main yet, and its `_lockstep` remedy must end with `{BRANCH_LEFT}` under #637's guard. | | #637 (lane 5, #607) | landed as 95780dd | merged into this branch (Fix round 2): the one sentence, `GITLINK_REMEDY`, kept both. | #607 and #611 landed one at a time: #607 first, so this PR took #607's sentence. - `bootstrap.py` (pinned): its fetch line over members `git submodule update` did not check out. Candidate (a) below. - `add` in a holder whose HEAD lost `.gitmodules`: it commits a new `.gitmodules` with its own section alone, which is its own change. The gate then names each other member. - **M3 and M7 stay untested.** M3 turns the unborn-HEAD return into "no file": no holder is unborn, since `family.py init` commits, so the gate never meets one. M7 drops the blob-type guard on HEAD's `.gitmodules`: it matters only where HEAD holds a `.gitmodules` that is a directory, which only a hand-built tree can make. A test of either would build a holder no tool makes. M9 and M10 (in `_checked_gitmodules_held`) are equivalent mutants: `_checked_gitmodules_as_staged` runs first and, for `remove`, refuses every state in which either return would fire. - macOS and Windows were not run (CI runs the full suite on Linux only since #597). The case-variant tests use index records and `core.ignorecase=true`, every file is written as bytes, git's stdin gets bytes, and nothing is compared against a file written with `write_text`. The one test that needs both `Members/IRRS` and `members/IRRS` on disk skips on a case-insensitive disk. - **(a) `bootstrap.py` says `family bootstrap ok` over members it did not check out.** In a fresh clone of a holder whose HEAD lost `.gitmodules`, `git submodule update --init --recursive` exits 0 and checks nothing out, so step (a) says `every member and leg fetched`, step (b) says `NOT CHECKED OUT; skipped. See the fetch above.` for each, and the run ends `family bootstrap ok`, exit 0. It does the same in a plain clone with a case-variant gitlink, after printing `fatal: No url found for submodule path 'Members/Nova'`. The gate now names the cause; the fetch line should not claim the opposite. - **(b) A clone's gate meets `member-uninitialized` before a later row's `member-gitlink-absent`.** In a plain clone of #363's state followed by `git submodule update --init --recursive` (exit 128), where the variant's row is not first, `git submodule update` dies at `Members/<P>` (`No url found for submodule path`), so the first row is not checked out and the gate refuses `member-uninitialized` for it, naming the same failing command. After `clone --recurse-submodules` this does not happen. Asking every row's gitlink before any row's checkout would name the variant first. - **(c) `member-gitlink-unnamed` for the only record of a member in the wrong case prints no command** under `--no-members`, which is the path CI takes in (b), while members mode now prints the index move. The same pair could be printed there. - **(d) `family.py add` of another member can un-register a member whose only gitlink is at `Members/<P>`** (against landed #574). It prints `git rm -r --cached -- Members/<P>` (nothing on disk in that spelling) or `git mv -- Members/<P> <new name>` (something on disk there). Either one un-registers that member. Review: rv611 stalled before its verdict; rv611b found 0 blockers, 3 should-fix and 6 nits on 6f938ed (CHANGES NEEDED), fixed in 6aace42; the re-verify rv611c found 5 nits on 6aace42 (APPROVE WITH NITS), whose N-1 and N-2 go to a v1.1.0 issue filed from #459. 08b6245 merges main 95780dd by hand, appending #637's `BRANCH_LEFT` to #611's clause. CI (Brett Heap's ruling 27, with his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 08b6245 passed tests (Linux), tests-git-bash and SonarCloud; the head 159a08f is a clean update-branch merge of main 2476992, with SonarCloud, tests-git-bash and package green on it, and the local gate passed on the head itself: the family and validator set, 704 passed, and the hygiene, AGENTS.md-code, CLI-reference, case-variant and README-section sets, 282 passed. Landed on Brett Heap's word of 2026-10-16T11:21:49Z, "Accept; merge #639 after #637", which also accepts its index-only move for a case-variant gitlink in place of the committed `git mv` of his "Refuse on every disk" ruling. Closes #611 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-text: remedies keyed by pin_product_key, the key the chain readers use (#444 x #610) The remedies keying fix is in the merge below, b7472df: `_link_remedies` keys a link's remedy by `pin_product_key`, and `_chain_link_findings` finds it by that key, as #638's chain readers do. Keyed by `casefold()`, the chain `openß, openss, openInk` wrote one entry for its two links, so the first link was offered the later link's words. This merge adds #623 and #637, and git merged them clean. One more change rides with it (#607 x #610): `_lockstep`'s remedy now ends with #637's BRANCH_LEFT sentence, as each remedy that names `_switch_line` does. Without it, #637's guard, test_every_remedy_that_names_a_switch_line_ends_with_the_sentence, and its partner test fail on the merge. Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it what to do where an earlier act left that branch, so `already exists` is no dead end (#607's sentence, in the two tools it left) (#657) #607 (#637) gave `family.py` and the holder's `validate-family.py` one sentence for a `switch -c` that fails `fatal: a branch named '<branch>' already exists` (exit 128): > If `git switch -c` says that branch already exists, an earlier act left it: give the branch another name, which starts it where the holder stands, or, only if that branch holds work not merged yet, the same command with `switch` in place of `switch -c` goes back to it and keeps every commit on it. Two tools still printed a `switch -c` and said nothing more. Both now say the same sentence under that line, each as its own constant, `BRANCH_LEFT`, as #637 does: - **`scripts/bump-leg.py`**, with "the project" where the others say "the holder". It follows the command in all three places `_switch_command` is named: - the `bump-leg-on-tracking-branch` remedy: after `and re-run.`; - the `bump-leg-root-detached` remedy: on the line under it; - the dry run's closing hint (#477): the new last line, under the hint. - **`setup-project.py`**: the same sentence, word for word (the holder), as one more comment line under the family block's `switch -c` line. The family block's two comment lines stay, and the line comes after them. No command line changes, and none is quoted differently. The sentence is words, not a second command, so no PowerShell quoting is added (#532) and no README sentence about quoting becomes false. Pinned files: none. This changes no file under `templates/`. 1. **Where the sentence goes in bump-leg's tracking-branch remedy: after `and re-run.`, not between the command and `and re-run.`** The remedy reads `Run:`, the command on its own line, `and re-run.` With the sentence between, `and re-run.` would hang under a full sentence. After it, the text before the sentence is byte for byte what it was, so the command line and `and re-run.` keep their places. The sentence is still under the `switch -c` line. 2. **setup's sentence is one comment line, unbroken (about 300 columns).** The brief asked for one comment line, word for word. `family.py`'s dry run prints it unbroken too. The block's other comments are hand-wrapped at 78 columns. A wrapped block would be a small change if the coordinator prefers it; the test would then join the comment lines. 3. **Every printed command line is unchanged**, and the tests prove it: bump-leg's line is read back by `shlex.split` and compared to the argv it always meant (`switch_argv`); setup's by `host_words`, on a folder with a space and one with a space beside an apostrophe. The block still has exactly two command lines. 4. **One rule, four copies.** #637's equality test, now `test_the_tools_say_the_same_words`, also asserts that `setup-project.py`'s constant equals `family.py`'s and the validator's, and that bump-leg's equals it with "the project" for "the holder". It was renamed from `test_the_two_tools_say_the_same_words`, because "two" is no longer true. The project's wording is pinned as text once, as `BRANCH_LEFT_PROJECT` in `tests/test_bump_leg_paste_lines.py`, beside the lines it follows; the holder's is #637's pinned copy in `tests/test_validate_family_branch_step.py`. `test_the_two_pinned_texts_differ_in_the_one_word_only` ties the two pinned copies together. 5. **Existing tests that pinned the old last lines take the sentence.** Seven tests in three files (listed under Tests). Each read the last line of an output, or the exact end of a remedy; none changes what it checks. 6. **The new module imports #637's typing helpers** (`leave_branch`, `refused_as_left`, `type_other_name`, `typed`, `without_c`, `head`, `on_branch`) instead of copying them, so the duplication gate sees one body. New module `tests/test_branch_left_bump_leg_setup.py`, 19 tests: - bump-leg, each of three places (`tracking`, `detached`, `dry-run`): the command is the one it always was and what stands under it is the sentence (3); the printed line makes the branch where none is left (3); where an earlier bump left `bump/spec-<id>` holding a commit of its own, the printed line fails `already exists` (exit 128) and changes nothing, then **another name** works (the new branch starts where the root stands, the bump goes through on it, its commit sits on the commit the root stood on, and `HEAD:spec` is the new commit) (3), and **going back** with `switch` for `switch -c` works (back on the branch at its own commit, the bump goes through there on top of it) (3). - setup, the family block: the line, the two comments, the sentence, the `add` line, two command lines (2, one per folder name); a real `setup-project.py` run prints the sentence once, under the right comment (1); in a real local holder (the `family` fixture's, on its tracking branch), with a real member: the printed line makes the branch where none is left, and where an earlier try left `family/add-<project>` with a commit of its own, **another name** works and `family.py add` goes through on it, and **going back** works and `add` goes through on top of the earlier commit (3). - `test_the_two_pinned_texts_differ_in_the_one_word_only` (1). Edited tests: seven cases in six test functions of the three bump-leg files, `test_bump_leg_dry_run_tracking.py` (4: the dry run's report, the difference from a dry run on a branch, the hint pasted for a root with a space, the real refusal byte for byte), `test_bump_leg_paste_lines.py` (1: the exact remedies) and `test_bump_leg_dry_run_truth.py` (2: the hint's branch name, `[local-remote-dir]` and `[origin]`); and, beside them, the extended `test_the_tools_say_the_same_words` in `test_family_switch_branch_exists.py`. Runs, on be1bff9 (BASE a524542 plus main 95780dd), through `run9.sh`, `gh` stub first on PATH: - New module with #637's module: `44 passed in 61.90s`. - Set 1, 21 files: every `tests/test_bump_leg*.py` (7), the new module, `test_bootstrap_after_bump`, `test_stdout_encoding`, `test_non_utf8_git_reads`, `test_agents_md_codes`, `test_commit_trailers`, `test_update_shape_paste_lines`, `test_validate_pins_lockstep_lines`, `test_pin_rewrite_layout`, `test_validator_child_encoding`, `test_validate_pins_unreadable_workflows_dir`, `test_template_text_placeholders`, `test_repo_hygiene`, `test_cli_reference`: `1040 passed, 2 skipped in 459.92s`. Both skips were there before: a case-ignoring disk (`test_bump_leg.py:693`) and a Windows default (`test_stdout_encoding.py:286`). - Set 2, 10 files: every file `git grep -l 'switch -c' tests/` names outside set 1 (`test_dry_run_asks_remotes`, `test_family_printed_quoting`, `test_family_refuse_up_front`, `test_family_switch_branch_exists`, `test_family_tracking_branch`, `test_validate_family_branch_step`, `test_validate_family_unnamed_mounts`), plus `test_setup_by_hand_commands`, `test_setup_project_py`, `test_setup_sh`: `592 passed in 506.74s`. - Before #637 landed, on 6ac545f: set 1 `1042 passed, 2 skipped`, set 2 (9 files) `441 passed`. **Fails on BASE.** The same five test files from the head, run on main 95780dd (no source change): `26 failed, 80 passed in 168.33s`. 18 of the 19 new tests fail, `test_the_tools_say_the_same_words` fails (no `BRANCH_LEFT` in either new place), and the seven edited tests fail. The one new test that passes on BASE is `test_the_two_pinned_texts_differ_in_the_one_word_only`: it reads text only. On the first commit alone, against a524542, the old module copy gave `20 failed, 1 passed`. A real project (`scaffold-project.py` into local remotes, a recursive clone, the spec leg advanced on a branch of its remote) and a real holder (`family.py init`, then a real `setup-project.py Sample --yes --family Orbit` run). An earlier act left the branch with a commit of its own. - **On BASE a524542:** `bump-leg.py` on the tracking branch ends `Run:` / the `switch -c` line / `and re-run.` and nothing more. The line typed as printed gives `fatal: a branch named 'bump/spec-712e4ce5d766' already exists`, exit 128. The detached refusal and the dry run's hint end on the command the same way. setup's block is the `switch -c`, `# a branch first: add refuses the` and `# holder's tracking branch`, then the `add`. Typed, `fatal: a branch named 'family/add-sample' already exists`, exit 128. - **On the head:** each of the four prints the sentence under the line, and the same line gives the same 128. Then: - another name (`<line>-2`): exit 0; `bump-leg.py` goes through on it, on top of the commit the root stood on; `family.py add` goes through on it; - going back (`switch` for `switch -c`): exit 0, at the earlier commit; `bump-leg.py` and `family.py add` go through, their commit on top of it. - Both choices already worked on BASE: the sentence tells a person so. No row needs real GitHub. - **A real earlier bump, not a made-up branch.** I ran a real bump on `bump/spec-<id>`, then went back to `main`. There the root shows ` M spec` (the leg is still at the bump's commit), and the next `bump-leg.py` is refused `bump-leg-root-dirty` before it reaches the `switch -c` line. After `git submodule update` puts the leg back, the line appears, fails `already exists`, and the other name works. See the checklist candidates. Each hunk was reverted or broken alone in a scratch clone of the head, and the new module plus `test_the_tools_say_the_same_words` was run. Every mutation makes new tests fail: | Mutation | Result | |---|---| | bump-leg `BRANCH_LEFT` says "the holder" | 13 failed: the equality test, and the three places in each of four tests | | bump-leg `BRANCH_LEFT` drops `only if` | 13 failed, the same | | `bump-leg-root-detached` remedy without the sentence | 4 failed (the `detached` case of each of four tests) | | `bump-leg-on-tracking-branch` remedy without the sentence | 4 failed (the `tracking` case of each) | | the same remedy with the sentence above `and re-run.` | 4 failed, the same | | dry run's hint without the sentence | 4 failed (the `dry-run` case of each) | | `_switch_command` prints `switch -C` | 9 failed (the "another name" and "going back" cases, and the three "command is the one it was" cases); the control, where no branch is left, still passes, as `-C` makes a new branch too | | setup `BRANCH_LEFT` says "the project" | 7 failed (the equality test and six) | | setup block without the sentence | 6 failed | | setup sentence above the block's two comments | 6 failed | | setup `BRANCH_LEFT` drops `only if` | 7 failed | The other hunks change only docstrings and comments (`_switch_command`, `_branch_to_bump_from`, `family_next_command`, the comment above the dry run's hint). They change no behaviour. | Ref | Result | |---|---| | origin/main d4a7fab (one commit past my merge of 95780dd; touches `scripts/family.py`, `scaffold-project.py`, `docs/cli.md`) | clean | | #637 (landed as 95780dd) | merged into this branch once, clean | | #623 (lane 2, `setup-project.py` steps (3) and (4); landed as abb956e) | merged in with #637, clean: its hunks are far from `family_next_command` | | #639 08b6245 (lane 5, #611) | clean | | #641 e87fcb0 (touches `bump-leg.py` and `family.py`) | conflicts in `scripts/family.py`, and the same conflict arises against main alone: not from this branch. `bump-leg.py` merges clean. | | #615 6ad2303 (touches `bump-leg.py`) | clean | | #631, #636 (lane 4) | clean | | #642, #643, #644 | clean | | lane-5 siblings fix/645 e2110f8, fix/646 2216e1c, fix/648 852c5c4, fix/649 d91b314 | clean | | #561 (lane 4's `fix/bump-leg-tag-dry-run`) | its work is on main already (7d9bec9, `asked = commit`); its stale pull head conflicts with main alone, so it is not an open PR to check | - No README, AGENTS.md or handbook edit. I checked every sentence about these lines: AGENTS.md:337-351 and 374 (the dry run "prints the `git switch -c`"), README.md:157 and 1011, `docs/cli.md` (generated; no `--help` text changes) and the handbook. None becomes false. - Windows and macOS are not checked here, and no CI job checks them since #597. The tests type argvs, run no shell, compare no file that `write_text` wrote, and read setup's lines with `host_words`. - A branch checked out in another worktree makes `git switch <branch>` fail (#637's "Left out", N1); the sentence is not made longer for it. - I did not tick or mark the lines on #458 and #459. - **Review rv647 (APPROVE WITH NITS, 0 blockers, 0 should-fix, 5 nits), and what is left of it:** - Commit 6ac545f's message says "Four existing tests … take the sentence". It is six test functions (seven cases) in three files, as the Tests section says. The squash message is this body. - `scripts/bump-leg.py`'s comment near :1164 still says "the three tools follow one rule"; there are four copies of the sentence now, and `setup-project.py`'s comment names two. Left for the `bump-leg.py` issue filed from #458's lines, which lands after this and edits that file. - No AST guard holds a fourth `_switch_command` caller to the sentence, as #637's `test_every_remedy_that_names_a_switch_line_ends_with_the_sentence` holds the validator's. - Setup's sentence prints as one comment line of about 317 columns in a block wrapped at 78. It is unbroken as `family.py`'s `_branch_said` prints it, which is what lets the byte-for-byte equality tests hold (decision 1). - **bump-leg.py: after a real bump on a branch and a switch back to an older branch, the root is dirty (` M spec`) and `bump-leg-root-dirty`'s remedy, "commit or stash them first", is wrong advice.** The leg is left at the bump's commit and `git switch` does not move it (README says the leg is left detached at the new commit). `git submodule update` (or `make bootstrap`) puts it back and clears the refusal. The remedy could say so where the one dirty path is a leg's mount. It also means the `switch -c` sentence is reached only after that step in this flow. - **The same flow's "go back" choice meets that refusal too** (rv647): `git switch` to the branch that holds the earlier bump leaves the leg behind, so the re-run is refused `bump-leg-root-dirty`; after `git submodule update` it says `nothing to do`, and only the push is left. - **update-shape.py:5279 prints `NEXT git -C <root> checkout -b shape/update-<12> && git … commit`** (rv647, read only): it fails `already exists` the same way, and the `&&` chain then commits nothing. It is `checkout -b`, outside #647. - **family.py, after a real earlier `add` and a switch back:** another name works for the switch, but `family.py add` is then refused `member-path-taken`, because git left the populated `members/<Project>` checkout behind (rv647; identical on the base). It bears on #637's "another name works". Review: rv647 found 0 blockers, 0 should-fix and 5 nits on be1bff9 (APPROVE WITH NITS); nits 1 and 2 are answered in this body, nit 3 (the stale "three tools" comment in `scripts/bump-leg.py`) rides with #672, and nits 4 and 5 are noted above. CI (Brett Heap's ruling 27, with his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 be1bff9 passed tests (Linux), tests-git-bash and SonarCloud; the head ac8a810 is a clean update-branch merge of main 8062810, with SonarCloud, tests-git-bash and package green on it, and the local gate passed on the head itself: the bump-leg set, 1040 passed (2 skipped), and the `switch -c` and setup set, 592 passed. Landed on Brett Heap's word of 2026-10-16T13:07:34Z, "merge #656 and #657 when green". Closes #647 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-letter `$OPENREPOSHAPE_REPO` is a path, a Windows-spelled `$OPENREPOSHAPE_BIN_DIR` is respelled or refused, and `tests-git-bash` runs #598's installer cases (#649) (#656) #596's Git Bash follow-ups, the five lines of #649. Two commits on main a524542: **d91b3144**, the fix, and **e85573ac**, fix round 1 from rv649's review. Together they change `openRepoShape`, `.github/workflows/tests.yml` and `tests/test_openreposhape_command.py`, and nothing else. `git diff --numstat`, d91b314: `openRepoShape` +42/-4, `tests.yml` +6/-1, the test file +182/-7. e85573a: +9/-6 (comments only), +3/-2 (a comment only), +56/-27. README, the handbook and `docs/cli.md` are unchanged: every sentence about these blocks stays true (below). ### Fix round 1 (rv649) rv649 found the launcher's code correct and asked for changes: 0 blockers, 1 should, 6 nits. e85573a answers each point. It changes no line of the launcher's code and no command of the workflow: three comments and the tests. - **SHOULD-1, the `D:/…` arm had no test.** Both BIN_DIR tests now run over `D:\tools\bin` and `D:/tools/bin`, and `fake_cygpath` takes the typed value. On Windows they use `str(...)` and `.as_posix()` of a path under `tmp_path`. With the arm dropped the suite stayed green (91 passed); now 5 cases fail. - **NIT-1, the refusal's drive-answer arm had no test.** A third refusal case: a `cygpath` that answers `D:/tools/bin` with exit 0. With that arm dropped the suite stayed green; now 2 cases fail. - **NIT-2, a `python` that is Python 2, or older than 3.9, reads as present.** Named as a cost in decision 1 and in the comment above `machine_report`. No code change: `--preflight` still refuses those machines. - **NIT-3, the refusal's reason was false in Git Bash with `cygpath` off `PATH`.** Git Bash does read `D:\…` as a drive. The comment in `install_commands` and decision 3 now give the true reason. - **NIT-4, the job's opening comment named two files.** It names the three. - **NIT-5, on Windows the refusal test's value was a real `D:` drive path.** It is now a path under `tmp_path`, and the assert names that value, so a refusal that regressed would place the command where the test looks. - **NIT-6, four claims.** Fixed in this body: "rows 1-3 are checked through the tests" (now says what runs), the clone URL for each slash (`C:\src\…` gave `https://github.com/C:\src\openRepoShape.git`), the Store stub clause (dropped, see decision 1) and the report's diffstat totals (the exact `--numstat` counts are at the top). d91b314's own commit message keeps its `....` typo and its one-URL sentence in history; the squash message is this body. - **Composition and merge table.** Refreshed against origin/main 879d804 (below). #630 no longer conflicts. - **Checklist candidate 3**, from rv649, is added below. ### The fix **1. The `machine:` row for Python** (`machine_report`). - Before: the block asked `command -v python3` and nothing else. Where Python came from python.org, Git Bash has `python` and no `python3`, so the row said `python3 MISSING` and printed the line pointing at `--preflight`, which then passed with `python`. - Now: it asks for `python3`, then `python`, in `find_python`'s order. When only `python` answers, the row says `python3 present, as python`, and nothing points at `--preflight` for it. With neither, it says `MISSING`, as before. With `python3`, it says `present`, byte for byte as before. **2. A drive-letter `$OPENREPOSHAPE_REPO`** (`shape_clone_url`). - Before: `C:/src/openRepoShape` and `C:\src\openRepoShape` were read as an owner/name, so setup.sh's clone and `--doctor`'s were handed `https://github.com/C:/src/openRepoShape.git` for the first and `https://github.com/C:\src\openRepoShape.git` for the second. - Now: a letter, a colon and then `/` or `\` is a path, and goes through as it is, as `/…` and `.…` do. No owner/name holds a colon. The package's own `clone_url` (`src/openreposhape/standard.py`, `DRIVE_RE`) already read a drive letter this way. **3. A Windows-spelled `$OPENREPOSHAPE_BIN_DIR`** (`install_commands`). - Before: `--install` named the directory as typed. With `D:\tools\bin` it printed `installed at D:\tools\bin/openRepoShape` and `export PATH="D:\tools\bin:$PATH"`, which the drive's colon splits in two. It also never found the directory on a `$PATH` that holds `/d/tools/bin`, so it printed that line every time. - Now: a drive-letter value, in either slash, is spelled again with `cygpath -u`, which Git Bash has, before anything is checked or placed. Every line then names `/d/tools/bin`, and the PATH note appears only when that directory is not on `$PATH`. - Where no `cygpath` answers (none on PATH, it fails, or it answers a drive spelling again), the value is refused before anything is placed: "--install would place this command in 'D:\tools\bin', which is spelled for Windows, and no cygpath here spells it the way bash does, so the directory could not go on $PATH: the drive's colon splits it. Nothing was placed. Set $OPENREPOSHAPE_BIN_DIR to the directory as this shell spells it, then run this again." **4. `tests-git-bash` runs `tests/test_install_sh_filter_repo.py`.** That file holds #598's uv install, the two readinesses and the pasted next steps, which ran on Linux only, while README says Git Bash runs step 3's one line "tested in CI". It is the third path on the job's pytest line, with a comment saying the installer's suite is two files. The job's other lines, the header's "the launcher's and the installer's suites, and nothing else" and #630's release-notes sentence of the same meaning stay true. **5. The stale docstring.** `test_a_ref_the_doctor_would_check_out_is_validated` said the refusal "has to arrive before any clone does". The command clones first, then checks the ref, and refuses before `git checkout` is handed it. The docstring now says that. The order is unchanged. - **Line 1: `command -v` for both names, and no version check.** - The block promises to execute nothing but `gh auth status`, and `test_the_machine_report_runs_no_installer` holds it to that: every fake on its PATH logs a run. Running `find_python`'s check would break that promise. - What answers is therefore never asked its version, and that has a cost. The Microsoft Store's `python3` stub reads as present, as macOS's Command Line Tools stubs for `python3` and `git` already do (setup.sh's comment at :41-47). That is not new: the base asked `command -v python3` too, and the Store installs its `python.exe` and `python3.exe` aliases together, so `python3` answers first. - What is new is a `python` that is Python 2, or older than 3.9, where there is no `python3`. On the base such a machine said `python3 MISSING` and printed the `--preflight` pointer. Now it says `present, as python` and prints no pointer. It is rare (CentOS 7, macOS 10.14 or older, Python 2.7 from python.org), and `--preflight` still refuses it with "no Python 3.9 or newer on this machine (tried python3, python)". The comment above `machine_report` says so. - The row keeps its name `python3`, and the state carries the name that answered, in the form `gh`'s row already uses (`present, logged in`). So README's sentence ("`git`, `gh` and whether it is logged in, `python3`, and `git-filter-repo` … each `present` or `MISSING`") and the handbook's stay true, and README is not edited. Labelling the row `python` would have made both false in Git Bash. - **Line 3: respell, and refuse only where respelling is not possible.** Respelling keeps a Git Bash user's value working: `cp`, `mkdir` and the record took the Windows spelling already, and only the printed lines were wrong. Where no `cygpath` answers, nothing in the command can spell the value as bash's `$PATH` needs it: in Git Bash with `cygpath` off `PATH` the drive is real but its colon still splits `$PATH`, and on Linux `D:\tools\bin` is a relative directory with a colon in its name. No `$PATH` line could work there, so it refuses. A `cygpath` that answers a drive spelling again is refused alike. - **The tests' `install_with_machine` now names its directory with `bash_path`.** Its `$PATH` holds only the programs `--install` runs, and no `cygpath`. On Windows the old `str(bin_dir)` is a drive path, which would now be refused. Off Windows, `bash_path` changes nothing. Other tests that pass `str(...)` keep the full PATH, so on Windows they now go through Git Bash's own `cygpath`. - **`run_cmd` takes a `cwd`.** On the base, and under a mutation, the new BIN_DIR tests' `D:\tools\bin` is a relative directory. With `cwd=tmp_path` it lands in the test's own directory, never in the checkout. - **Both BIN_DIR tests run over both slashes** (`BIN_DIR_SPELLINGS`, `typed_bin_dir`). Off Windows the typed value is the fixed `D:\tools\bin` or `D:/tools/bin`. On Windows it is a path under `tmp_path`, so neither test can place a command on the machine's own `D:` drive. New, in `tests/test_openreposhape_command.py`, 27 cases: - `test_the_machine_report_finds_a_python_by_either_name`: `python` alone, both, neither (3). - `test_a_windows_spelled_bin_dir_is_spelled_the_way_bash_spells_it`: not on `$PATH`, on `$PATH`, each in both slashes (4). On Windows it is a real drive path (`str(bin_dir)` or its `as_posix()`) and Git Bash's own `cygpath`; elsewhere a fake `cygpath` answers as that one would. - `test_a_windows_spelled_bin_dir_is_refused_where_no_cygpath_spells_it`: no `cygpath`, a `cygpath` that fails, a `cygpath` that answers `D:/tools/bin`, each in both slashes (6), on the machine report's own `$PATH`. - `test_a_clone_is_handed_a_path_or_url_as_it_is`: owner/name, URL, scp, POSIX path, `C:/…`, `C:\…` and `d:\…`, each through setup.sh (a setup.sh that prints `$OPENREPOSHAPE_REPO`) and through `--doctor` (a `git` that prints the URL it is handed and fails) (14). Nothing is cloned or fetched. **On the base a524542** (this head's test file over the base's command): **17 failed, 80 passed** in 62.32s. The failures are exactly the new cases that need the fix: `python-alone`, the four respelling cases, the six refusal cases and the six drive-letter cases. The other 10 new cases pass there too, as they should. **On the head e85573a** (Linux, `dirty=0`): - `tests/test_openreposhape_command.py`, `tests/test_install_sh.py`, `tests/test_install_sh_filter_repo.py`, `tests/test_openreposhape_package.py`: **220 passed** in 175.18s. The package suite imports 13 of the launcher's cases and swaps `run_cmd`, so it is in the set. - `tests/test_repo_hygiene.py`, `tests/test_workflow_concurrency.py`, `tests/test_release_workflow.py`: **138 passed** in 14.46s. - The head merged with main 879d804 (a probe commit, b6cfafe3): the launcher's, the two installer files, the package suite and `tests/test_repo_hygiene.py`, **297 passed** in 188.95s. - **The three files `tests-git-bash` runs, with Windows' `write_text` emulated**: a plugin made every `Path.write_text` in the test process write CRLF, as it does on `windows-latest`. **162 passed** in 121.06s (97 + 47 + 18). A probe test shows the plugin takes effect (it passes with the plugin and fails without it). - **actionlint 1.7.12**, with shellcheck on PATH, on `tests.yml` alone and on the four files the CI job names: exit 0. d91b314's own `tests-git-bash` run was green on `windows-latest`. It ran the first round's 21 new cases and `tests/test_install_sh_filter_repo.py` there, so Git Bash's own `cygpath` answered the backslash respelling on a real drive path, and the five existing tests that pass `str(...)` and now go through it passed. What was checked, and what was not: - **Checked, from #596's Windows runs (113 passed on 5f001de, and its probes):** a fake that Python writes with `#!/bin/sh` runs under Git Bash with no mode bits, and `command -v` finds it. A script piped in text mode reaches Git Bash's bash as CRLF, and bash drops the CR. A `$OPENREPOSHAPE_BIN_DIR` set from Python reaches bash unconverted: #596's probe 4 printed `D:\a\_temp\…\bin/openRepoShape`, which is why the respelling now runs in those tests. - **Checked on Linux:** LF bytes (the CRLF emulation above). No assert in the test file compares a path bash printed with a Python path. - **Not checked, until e85573a's own run:** on Windows, the slash spelling (`as_posix()`) through Git Bash's real `cygpath`, the refusal's values under `tmp_path` on the machine report's `$PATH`, and the new drive-answer case. If one fails there, its log shows the typed value and the directory. One script ran each row against the base's command and the head's, offline, with a `gh` stub first on PATH. Row 3's slash lines ran again for this round. | Row | Base a524542 | Head e85573a | |---|---|---| | 1. PATH with `git`, a logged-in `gh`, `python`, `git-filter-repo`, no `python3` | `python3 MISSING`, then `run: openRepoShape --preflight …`; the log holds only `gh auth status` | `python3 present, as python`, no `run:` line; the log holds only `gh auth status` | | 2. `OPENREPOSHAPE_REPO=C:/src/openRepoShape` | setup.sh clones `https://github.com/C:/src/openRepoShape.git`; the doctor's `git clone` too | `C:/src/openRepoShape`, both | | 2. `OPENREPOSHAPE_REPO=C:\src\openRepoShape` | `https://github.com/C:\src\openRepoShape.git`, both | `C:\src\openRepoShape`, both | | 2. `OPENREPOSHAPE_REPO=acme/openRepoShape` | `https://github.com/acme/openRepoShape.git` | the same | | 3. `OPENREPOSHAPE_BIN_DIR='D:\tools\bin'`, no `cygpath` | exit 0, `installed at D:\tools\bin/openRepoShape`, `export PATH="D:\tools\bin:$PATH"` | exit 2, the refusal above, nothing placed | | 3. `OPENREPOSHAPE_BIN_DIR='D:/tools/bin'`, no `cygpath` | exit 0, `installed at D:/tools/bin/openRepoShape`, `export PATH="D:/tools/bin:$PATH"` (on Linux, a relative directory named `D:` in the working directory) | exit 2, the same refusal with `'D:/tools/bin'`, nothing placed | | 3. the same, a fake `cygpath -u` answering `<dir>/d/tools/bin` | the same as with none: `cygpath` is never asked | exit 0, `installed at <dir>/d/tools/bin/openRepoShape`, `export PATH="<dir>/d/tools/bin:$PATH"` | | 4. `tests-git-bash`'s pytest line | two files | three files | | 5. the docstring | "the refusal has to arrive before any clone does" | "clones this checkout first, then checks the ref" | Rows 1-3 need Windows to be real: there, `python` is a real python.org install, and `cygpath` and the drive letter are Git Bash's own. On Linux they are fakes. The first Windows run runs rows 1-3's tests under Git Bash: row 3 with Git Bash's own `cygpath` on a real drive path, rows 1 and 2 with a fake `python` and a fake `git`, so a real python.org install and git's own reading of `C:/…` stay unchecked. Each row reverts one hunk of the launcher, or one part of the BIN_DIR hunk, in a throwaway clone, then runs the 27 new cases. | Hunk reverted | Result | Failing cases | |---|---|---| | none (the head) | 27 passed | — | | `shape_clone_url`'s drive-letter arm | 6 failed | the six drive-letter cases, setup.sh and doctor | | `machine_report`'s `python` arm | 1 failed | `python-alone` | | the BIN_DIR block, whole | 10 failed | the four respelling cases and the six refusal cases | | the `cygpath -u` call (always refuse) | 4 failed | the four respelling cases | | the refusal (keep the value as typed) | 6 failed | the six refusal cases | | `\|\| spelled=""` after the `cygpath` call | 2 failed | `cygpath-fails`, both slashes: `set -e` exits with `cygpath`'s status, 1, not the refusal's 2 | | **the `D:/…` arm of the drive case (SHOULD-1)** | 5 failed | the two slash respelling cases and the three slash refusal cases (was green) | | the `D:\…` arm of the drive case | 5 failed | the same five, backslash | | **the refusal's drive-answer arm, `[A-Za-z]:*` (NIT-1)** | 2 failed | `cygpath-answers-a-drive`, both slashes (was green) | | the refusal's empty-answer arm, `""` | 4 failed | `no-cygpath` and `cygpath-fails`, both slashes | | `--` dropped from `cygpath -u -- "$dir"` | 4 failed | the four respelling cases: the fake answers only `-u -- <value>` | | the respelling after the placement, not before | 4 failed | the four respelling cases | Not probed by a test: the `tests.yml` line (CI configuration, checked by actionlint and by this PR's own Windows run), the docstring, and `install_with_machine`'s `bash_path` (identity off Windows; on Windows it is what keeps four machine-report tests from the new refusal). | Against | Result | |---|---| | origin/main 879d804 (d4a7fab, then 879d804, since the last table's 95780dd) | **clean**. Of this PR's files, the three suites', README and the handbook, only `tests/conftest.py` has moved since the base a524542 (new helpers for setup-project's tests), and nothing since the last table's 95780dd. The head merged with main (a probe commit, b6cfafe3) passes the suites below. | | lane 3's #630 61b3907 | **clean**. It conflicted at ca5c49f, at the `tests-git-bash` pytest line, because its base was older than main's #590; its head now holds main d4a7fab, and it does not change `tests.yml`. It changes `AGENTS.md`, README, `adopt-project.py`, the handbook, `docs/cli.md`, the release notes and four test or render files, none of which this PR touches. | | #620 9f2b67c, #621 10dda17, #622 55666f3, #623 fc08a28, #627 83ca2db, #628 d64252a, #631 21bdac8, #634 b7472df, #636 30a52c0, #637 62263d2, #638 b393e1f, #639 08b6245, #642 1a42df4, #643 1fe3764, #644 733f853 | **clean** (#623 changes `tests/conftest.py`; #627 changes README and the handbook) | | #641 e87fcb0 | conflicts in `scripts/family.py`, and so does #641 against main alone: its own conflict, not an overlap with this PR. | | lane 5's `fix/645-fixture-copy-maintenance-race` accc7d2, `fix/646-add-member-variant-remedy` 79754b9, `fix/647-branch-left-bump-leg-setup` be1bff9 (#657), `fix/648-bootstrap-and-gate-order` 852c5c4 | **clean**, all four | - **The Store stub check.** See the decision on line 1. `--preflight` is where a stub is told apart. - **`install.sh`'s own lines.** It names a Windows-spelled `$OPENREPOSHAPE_BIN_DIR` as typed in its `preflight:` and `done:` lines, though the command it installs now prints `/d/…`. #649 names `--install`'s PATH note, and `install.sh` prints no PATH line. Checklist candidate 1. - **`--help`.** Its `$OPENREPOSHAPE_REPO` and `$OPENREPOSHAPE_BIN_DIR` lines stay true, so `docs/cli.md` is not regenerated. - **`D:`, `D:tools` and UNC values.** Checklist candidate 3. 1. **`install.sh` echoes a Windows-spelled `$OPENREPOSHAPE_BIN_DIR` as typed.** It builds `target="$bin_dir/openRepoShape"` from the raw value, so in Git Bash with `D:\tools\bin` its `preflight:` and `done:` lines say `D:\tools\bin/openRepoShape`, while the command's own lines say `/d/tools/bin/openRepoShape`. Both spellings work in Git Bash, so nothing breaks. On a machine with no `cygpath`, the command's `--install` now refuses the value, and `install.sh` stops with "the <version> openRepoShape's --install refused (exit 2); its own refusal above says why", which is right but could refuse earlier, before the fetch. 2. **The PATH note is not quoted for every directory.** `--install` prints `export PATH="$dir:$PATH"` with the directory between double quotes as it is. A directory holding `"`, `$`, a backquote or `\` prints a line that does not do what it says, and one holding `:` cannot be on `$PATH` at all. A drive letter was the one case a person in Git Bash meets by default; the rest need a deliberately odd directory name. 3. **Two kinds of value slip past both new arms** (rv649). `$OPENREPOSHAPE_BIN_DIR` set to `D:` or `D:tools` matches neither respelling arm, so it is kept as typed and prints `export PATH="D:tools:$PATH"`, as on the base. A UNC `\\server\share\…`, as `$OPENREPOSHAPE_BIN_DIR` or `$OPENREPOSHAPE_REPO`, is never respelled (the PATH note's `\\` collapses) or becomes a github.com URL. The package's `clone_url` passes a UNC path through on Windows (`os.path.isabs`). Review: rv649 found 0 blockers, 1 should-fix and 6 nits on d91b314 (CHANGES NEEDED); the fix round e85573a answers all seven, so both mutations that survived now fail a test, and lane 5's coordinator checked it. CI (Brett Heap's ruling 27, with his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 e85573a passed tests (Linux), tests-git-bash (windows-latest) and SonarCloud; the head 022bd50 is a clean update-branch merge of main 8062810, with SonarCloud, tests-git-bash and package green on it, and the local gate passed on the head itself: the launcher, installer and package set, 220 passed, and the hygiene, workflow and README-section set, 214 passed. Landed on Brett Heap's word of 2026-10-16T13:07:34Z, "merge #656 and #657 when green". Closes #649 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(c6122e8) under #646's move back (#646 x #611) No conflict. main moved from a524542 to 35133f2 (#623, #637, #622, #620, #628, #639, #621, #627, #657, #656). Of those, #637, #622 and #639 change scripts/family.py, in hunks git merged on its own; none changes this branch's three test files. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot, the scaffold's chain WARNING offers only its own sources and spells `--pin-source`, the root's not-used note spells it too, and THE LOCKSTEP RULE's `bump` runs (#610) (#634) Issue #610 gathers the pinned shape copies' text into ONE shape bump (the checklist triage's D6, approved by Brett Heap at 2026-10-15T16:49:38Z), so every project re-pins once for v1.1.0. Branch made from origin/main 396c0ff (#570's squash; #575 and #576 are in). The bump. A pinned copy has no digest to edit in this repository: each root's contracts/shape-pin.yaml holds the sha256 of each copy as it was written, and `update-shape.py check` calls every copy whose upstream bytes moved `upstream-changed`, which `apply` re-copies and re-pins. So every pinned copy this issue changes moves in this one commit: templates/assembly-root/AGENTS-shape.md, scripts/repo_shape.py, templates/assembly-root/scripts/validate-pins.py and scripts/validate-repository-naming.py (assembly roots), and scripts/repo_shape.py and templates/family-root/scripts/validate-family.py (family holders). L2-33. AGENTS-shape.md said "the root Makefile exports `$(CURDIR)/spec/contracts` once". A scaffolded root's Makefile exports nothing (`make contracts-dir`: no rule), and since #572 an adoption exports the spec leg where its plan mounts it. The sentence is path-free: an ADOPTED root's shape Makefile exports `CONTRACTS_DIR` as the `contracts/` of the spec leg at the path `project.yaml` mounts it; where the source kept a Makefile of its own, which stays at the root, that block waits in shape/Makefile until the two are merged (`make contracts-dir` has no rule at that root); and a SCAFFOLDED root's exports none, its legs starting from the scaffold's seed, with no `contracts/`. The file stays placeholder-free. 576/2. The scaffold's `declared-unverified` WARNING offered "a sibling checkout, SHAPE_PIN_SOURCE_<LINK>, or --link-source <link>=<path>", the naming validator's sources. The scaffold has no `--link-source` and reads no sibling checkout. `LinkPins` now carries a caller's `remedies`, which `_chain_link_findings` uses for a tree that is not reachable, and the scaffold's `_link_remedies` names what it reads: the variable (unless another link shares it, #275) and, for the first link alone (#441), a `--pin-source` clone. The naming validator and `validate-manifest.py` pass none and keep their words. #444 x #610. #638 keys every reader of a chain by `pin_product_key` (`lower()`, the pin file's own fold), so `_link_remedies` keys a link's remedy by it too, and `_chain_link_findings` finds it by it. Keyed by `casefold()`, the chain `openß, openss, openInk`, two pin files and two links, wrote ONE entry, and `openß`, the first link, was told the scaffold reads no source for it, the later link's words, where the scaffold reads its `--pin-source`. Test: test_two_pin_files_one_casefold_are_each_offered_their_own. The naming gate's `LinkSources` docstring cited `validate-pins.py` by line numbers that no longer hold the lines; it names `PinSources.__init__` and `resolve_neutral_pin_source` now (R444's N2). L2-30. `link_declarations` named the manifest it reached raw, and the scaffold prints that reason in the WARNING, so a `--pin-source` holding an ESC put a raw `\x1b[31m` on stderr. Every reason now names it with `repo_shape.shown_path`, the spelling `pin-source-unreadable` gives a `--pin-source` (`shape_materialize.shown_source` is now that function): `repr` where it is not printable, the same bytes where it is. L2-31. The root's `validate-pins.py` named an unused `--pin-source`, the later value that replaced it, and a key read as a PATH as typed. Each is spelled the same way, by a local `_shown`: a root whose repo_shape.py is older has no `shown_path` to import. So are its two other raw prints, the source a `neutral-pin-source-unreadable` finding names and the value a line about a keyed value that is also a directory names, in the sentence and in the `./` command. The naming gate's `link-source-unreadable` WARNING named the tree it asked raw, beside a reason that names the manifest with `shown_path`. It spells the tree with its own local `_shown`, for the same reason. The `git-failed` detail that `validate-pins.py` prints at the end of a `neutral-pin-source-unreadable` finding, for a `--pin-source` that exists and is not a repository or lacks the commit, was built in `repo_shape.git_out` with the folder git ran in as it was and git's stderr as it came. A folder named with an ESC, a CR or a BEL reached stderr raw, and a line break in the name began a line of its own (`WARNING forged-line: ...`). git echoes the paths it reads (`invalid gitfile format: <folder>/.git`, `not a git repository: <gitdir>`, a dubious ownership line) and passes a line break through; 2.43, measured, replaces only an ESC, a CR and a BEL with `?` and passes a C1 control (U+0080 to U+009F) raw, and another git may replace nothing. `git_out` now names the folder with `shown_path` and prints what git said with `spelled`, as ONE line: a control character, a line break and a C1 control are the escape Python spells them, a line break git itself printed between two lines of its message is `\n`, and printable text, UTF-8 included, is unchanged. That is `git_out`'s detail for every caller, not `validate-pins.py`'s alone. `git_out`'s arguments, which a caller writes, are named as they were. `repo_shape.py` was already in this bump, so no pinned file is added. L2-39. validate-family.py's THE LOCKSTEP RULE named `scripts/family.py bump --member <Project> --to <commit>`, with no `--family-root`, which `bump` requires, and no branch, though a real `bump` refuses the holder's tracking branch (#487). It now names the holder by its path and the `git -C <holder> switch -c family/bump-<member>` that makes a branch, as #575's `_switch_line` does for `add` and `remove`: each finding for its member (the row's `id:` in the branch), and the closing count with `<Project>`. It ends with #637's `BRANCH_LEFT` sentence, as every remedy that names `_switch_line` does (#607). Measured: an assembly root scaffolded at 396c0ff (and an adopted one) takes this as one `update-shape.py` re-pin with `upstream-changed` for scripts/validate-pins.py, AGENTS-shape.md, scripts/repo_shape.py and scripts/validate-repository-naming.py, and a family holder takes it with `upstream-changed` for scripts/validate-family.py and scripts/repo_shape.py. `apply` re-copies them and the roots' own gates stay green. No row is a conflict. Tests: tests/test_pinned_copy_text.py (new), and in tests/test_validate_family_branch_step.py (the rule's branch word is the row's `id:`, through the gate), tests/test_adopt_contracts_dir.py (an adoption of a source with its own Makefile) and tests/test_naming_policy.py, whose test_scaffold_keeps_its_old_warning_for_a_pin_source_with_no_manifest pinned the old remedies and is now test_scaffold_offers_its_own_sources_for_a_pin_source_with_no_manifest. The `git_out` rows in tests/test_pinned_copy_text.py run `validate-pins.py` over a `--pin-source` that is no repository and over one whose `.git` is no gitfile, each named with an ESC, a forged line, a CR and a BEL, and run `git_out` against a stub `git` that prints those bytes, so the result does not depend on the git on the machine. Closes #610 Lane: openreposhape-2 (openRepoShape-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…another spelling of its row's path moves it back to that path in the index alone, so the printed remedy no longer takes the member's gitlink away from its row (#646) (#670) **Goal (#646): no remedy `family.py` prints un-registers a member that is in the holder.** Three states are outside it, each under "Left out": a `.gitmodules` that a `git mv` made name the variant too (checklist candidate (e)); a file the index holds at or under the row's path, where #574's remedy is kept; and a file or link the index holds at `members` itself (candidate (h)). A member's only gitlink sat at `Members/<P>`, while its row and `.gitmodules` said `members/<P>`. This is #363's state after the fact. `family.py add` of ANOTHER member refused, as #574 rules (`family-members-dir-variant`). Its remedy was #574's remedy for any path under another spelling of `members/`: - `git rm -r --cached -- Members/<P>`, and commit the removal, where nothing is on disk in that spelling; - `git mv -- Members/<P> <new name>`, and commit the rename, where something is; - `git commit`, where HEAD alone holds it (the variant's removal staged, not committed). Each one, typed as printed, left no gitlink at the row's path. The holder's validator then refused `member-gitlink-absent`. Reproduced on BASE a524542 in all five layouts below. All in `scripts/family.py`. - `_members_spellings` takes the rows' own paths (`_rows_paths`: the one path `_member_path` gives a row, `members/<project>`; a row it refuses is `member-path`'s). - **Which path is the member's own.** The index must hold nothing at or under the row's path. Then a gitlink at a case variant of that path, in the index or in HEAD alone, is that member's. It gets a new remedy, `RESTORE`, placed in `REMEDY_ORDER` after the index's own remedies. - **The remedy** is the index-only move that `validate-family.py` prints for the same state since #639. Brett Heap accepted it on 2026-10-16T11:21:49Z ("Accept; merge #639 after #637"), in place of the "committed `git mv`" of his "Refuse on every disk" ruling, because `git mv` to the row's path fails or misplaces the gitlink: - `git rm -r --cached --ignore-unmatch -- <variants>`. It touches no file, and it is no error where HEAD alone holds the variant. Where a sparse checkout keeps a variant off disk (`skip-worktree`), the line says `git rm -r --cached --sparse --ignore-unmatch -- …`. - `git update-index --add --cacheinfo 160000,<commit>,members/<P>`, one line per row, at the commit the variant entry records. For an unmerged variant, that is the commit at stage 2. - A commit, where one is left to make. - **What the disk holds afterwards** gets the validator's own sentences, placed after every other remedy: - where nothing is checked out at the row's path, as in a fresh clone, `git submodule update --init --recursive`; - on a case-sensitive disk, `mv -- Members/<P> ../Members-<P>` for the directory left at the variant; - on macOS and Windows, leave it, since it is the row's own directory. - A directory on disk at a gitlink moved back from HEAD is the move's to name. It is not named a second time as untracked. The branch merges main 35133f2, which holds #639 (c6122e8), in one merge commit with no conflict. Example: the issue's state, the checkout at `members/IRRS`: ``` REFUSED family-members-dir-variant: the family root holds a path at or under another spelling of members/, which a case-insensitive disk takes for members/ itself — in the index: `Members/IRRS` (a submodule) Remediation: every member is mounted under members/, and a holder keeps that directory in one spelling on every disk. Move each member's gitlink held only under another spelling of its row's path, `Members/IRRS` for the row members/IRRS, to that path, at the commit it records, in the index alone, which touches no file (`git rm -r --cached --ignore-unmatch -- Members/IRRS`, then `git update-index --add --cacheinfo 160000,<commit>,members/IRRS`), and commit that; where nothing is checked out at members/IRRS, as in a fresh clone, `git submodule update --init --recursive` then checks the member out there; on a case-sensitive disk, as Linux's usually is, a directory left at Members/IRRS stays there, untracked, and add refuses it as family-members-dir-variant: move each one that is there out of the holder, `mv -- Members/IRRS ../Members-IRRS`; on macOS and Windows it is the row's own directory, so leave it; then re-run. ``` No pinned file changes: `scripts/family.py` is the standard's own tool, not a copy in a holder. No `--help` text changes, so `docs/cli.md` is untouched. A `git grep` of AGENTS.md, README.md, `docs/handbook.html` and the family-root templates found no sentence about `add`'s case-variant remedies, so none became false. 1. **The member's own gitlink: one whose path folds to a row's path, where the index holds nothing at or under that path.** The next commit records the index, and every remedy here ends in one. - If the index holds the row's gitlink, a gitlink at a variant is a second one. Taking it out removes nothing from the row, so it keeps #574's remedy, and moving it back would overwrite the row's gitlink. - If the index holds a FILE at the row's path, the move would replace it without a word. If it holds files UNDER the row's path, the move's second line fails (`appears as both a file and as a directory`) after the first has already taken the variant out. Both keep #574's remedy (lane 3's review RV670, N3). - A file at the variant, or anything under it, also keeps #574's remedy. - `test_only_the_members_own_gitlink_is_moved_back` holds all of these, 9 cases. 2. **The commit named is the one the variant entry records, so the remedy is a move, not a bump.** Brett Heap decided this at 2026-10-16T14:11:42Z: "take all recommendations for #670 and #681". That word replaces his 13:07:34Z take of "the row's pin". - Where a row has two variants, the first found is named, the index's before HEAD's. - **This departs from #646's Direction line (`<pin>`) and from #639's rule:** #639's restore in `validate-family.py` names the row's pin. The two tools give different lines where the variant records a commit the row does not pin, and, since S1, where a sparse checkout keeps the variant off disk: `add` prints `--sparse` and the validator does not (candidate (f)). - **The four reasons, as lane 3's review RV670 measured them:** 1. **A pure rename.** With the variant's commit, the move changes only the path. Where the commits differ, the gate then reports `member-gitlink-mismatch`, which has its own LOCKSTEP text and `bump` remedy, and exits 1, and an `add` of another member still goes through. `test_a_commit_the_row_does_not_pin_is_moved_not_bumped` types that end to end. (Where the variant equals the pin, the bytes are the same either way.) 2. **The pin would silently bump.** With the pin, the paste changes the commit the holder records: a roll-back or an advance, a bump done outside `bump`, which the LOCKSTEP text says the gate refuses. 3. **A row with no pin needs a fallback.** The validator never prints its remedy for such a row, because `member-pin` or `member-pin-commit` stops the row first. So #639's rule has no answer there, and a pin-based `add` would need the variant's commit anyway. 4. **A pin absent from the clone fails.** `update-index --cacheinfo` does not check that the object exists, and the printed `git submodule update --init --recursive` then fails with rc=128 (`Fetched in submodule path 'members/IRRS', but it did not contain 3333…`). The variant's commit is already recorded, so this remedy adds no new unreachable commit. - **The validator is not changed.** Naming the variant's commit there too (it already prints it, as `(at <commit>)`) would edit a pinned template, and that needs its own word, which nobody has given. 3. **An unmerged variant is moved back at its stage-2 commit** (RV670, N2). `ls-files -s` lists stage 1, the merge base, first, and the PR's first head took that one. - Stage 2 is HEAD's entry each time, so the move is a rename relative to HEAD, and the validator also answers an unmerged path from HEAD. In a merge and in a stash pop it is the holder's own side. In a rebase it is the upstream's side, which is HEAD while the rebase runs: after `git rebase --quit` the move names the upstream's commit, not the person's own, which is stage 3 (probed by rv670b, the re-verify of this PR). - The removal takes out every stage, and the commit after it goes through (`$Q/v112/w646b/probes/w646b-unmerged-probe.log`). - An unmerged variant with no gitlink at stage 2 is no member's own: HEAD's entry holds no gitlink there, whatever stages 1 and 3 hold. It keeps #574's remedy, where stage 2 is missing and where it is a file. A gitlink at stage 1, a file at stage 2 and a gitlink at stage 3 is an `OWN` state too (rv670b's NIT-3): git lists stage 1 first, and rv670b's mutant R1, which read the first record's mode in place of stage 2's, passed all 36 tests and moved the file's blob back as a gitlink. - The state is reachable only with unmerged entries and no in-progress mark. A conflicted `git stash pop` leaves it, and so do `git merge --quit` and `git rebase --quit` (rv670b probed both). `add` refuses a merge, rebase, cherry-pick, revert or `git am` in progress (`family-operation-in-progress`) before it gets here. 4. **A variant a sparse checkout keeps off disk is taken out with `--sparse`** (RV670, S1). Without it, `git rm -r --cached --ignore-unmatch` exits 0 and removes nothing, so the index then held two gitlinks, and the next `add` refused again. With it, the entry goes, measured on git 2.43 with and without `core.sparseCheckout` (`$Q/v112/w646b/probes/w646b-sparse-probe-*.log`). The flag is printed only where a variant needs it, so the line stays the validator's everywhere else. Git older than 2.34 has no `git rm --sparse`: see "Left out". 5. **The commit is printed only where one is left to make.** That is: - HEAD holds a variant (RV670's S2 state, HEAD holding the gitlink in both spellings at one commit, is one of these); - HEAD's gitlink at the row's path is another commit, or there is none; - or a pending change is committed first. A move to the variant that is only staged, with HEAD still holding the row's gitlink at that commit, leaves nothing to commit, and a printed `git commit` would fail as typed. 6. **The code stays `family-members-dir-variant`** (or `member-path-taken` where some path is also a variant of the new member's mount path). The add is still refused for this path, as for any path under another spelling of `members/`: with `core.ignorecase` set, git would stage the new gitlink as `Members/<new>` (#362). Only the remedy changes. 7. **The closing sentences come last**, after #574's disk remedies. - Where a `members/` renamed by hand is moved back (`mv -- Members members`), the checkout goes home with it. - If they came before that, the `mv` out of the holder would act on `Members/<P>` while it may still be the member's checkout. And `git submodule update` would make a `members/`, inside which `mv -- Members members` would then put `Members` (`members/Members`). - `test_with_the_other_remedies…[members/ renamed by hand]` holds the order: it types the move back and then `git submodule update`, which checks the member out in the `members/` moved back. 8. **One text on every disk.** The move reads the index and HEAD, never the disk. The dry run and the real run print the same two lines. Asked in process of an emulated folding disk and of a Linux one (`disk_as`, as `tests/test_family_members_case_variants.py` emulates it), each layout gives the same text the subprocess gives. 9. **The disk after the move.** - On a case-sensitive disk the variant keeps either a checkout (15 entries, `?? Members/`) or, after a fresh clone, an empty directory. The gate passes either way. - If the printed `mv` is not typed, the next `add` refuses `family-members-dir-variant ... on disk and untracked: Members/Atlas` with #574's `mv -- Members/Atlas <new name>`, and passes once that is typed. - If the printed `mv` is typed, the next `add` passes at once (`$Q/v110/w646/leftover.log`, `repro-head.log`). All runs used `$Q/v110/run9.sh`: a `gh` stub that exits 97 first on PATH, `GH_TOKEN=bogus-l5`, an empty `GH_CONFIG_DIR`, `/usr/bin/python3 -m pytest`, and local bare remotes only. `tests/test_family_add_member_variant.py` is new, with 37 tests. Every printed command is typed as printed. The holder's gate then passes (or, in the one case where the variant records a commit the row does not pin, names that drift), the member is where its row says at its commit, and the same `add` goes through. - `test_each_layout_moves_the_gitlink_back_as_printed` (4 layouts) and `test_a_fresh_clone_moves_the_gitlink_back_as_printed`. - `test_every_disk_gives_the_same_answer` (4 layouts × folding and Linux, in process). - `test_with_the_other_remedies_each_runs_in_its_order` (8 states): - beside a file under `Members/`; - after a pending change in HEAD; - with nothing left to commit; - `members/` renamed by hand; - HEAD alone, with the checkout at the variant; - HEAD holding both spellings at one commit, the row's removal staged (S2); - kept off disk by a sparse checkout (S1; skipped on git older than 2.34, as #574's sparse case is); - unmerged at stages 1, 2 and 3, with nothing in progress (N2). - `test_every_member_held_in_the_wrong_case_is_moved_back` (two rows). - `test_the_gitlink_is_recorded_at_the_commit_it_records` (4 states). - `test_a_commit_the_row_does_not_pin_is_moved_not_bumped` (S3): typed as printed, the gate exits 1 with `member-gitlink-mismatch` alone, and the same `add` goes through. - `test_only_the_members_own_gitlink_is_moved_back` (9 states, four of them new: a file at the row's path, a file under it, an unmerged variant with no gitlink at stage 2, and one with a gitlink at stage 1, a file at stage 2 and a gitlink at stage 3). - `test_the_rows_that_have_a_path_of_their_own`. Layouts that need two directories differing only in case skip on a disk that folds case. No test makes such directories there. Two existing lines change to the new shapes: - `Spelled` has two more fields, `recorded` and `sparse`, so a seven-field tuple compared in `tests/test_family_members_case_variants.py` gains `"", False`; - the three-argument stand-in for `_members_spellings` in `tests/test_family_add_refusals.py` takes `rows`. The S1 case sits in the new file, beside its other move-back states, and not in `test_family_members_case_variants.py` beside #574's sparse test: it needs the new file's helpers, and that file already imports from the other one. Results on this head. a837a02 is 2104a3d plus one test commit: it adds one state to `OWN` and changes no code, so the runs of 2104a3d below stand for every file it does not touch. | Run | Head | Result | |---|---|---| | the new file | a837a02 | `37 passed in 48.55s` | | `tests/test_family_members_case_variants.py` | a837a02 | `60 passed in 53.83s` | | the new file, with `scripts/family.py` put back to the merge's (bc48c2a) | 2104a3d's tests | `5 failed, 31 passed in 170.09s`: the sparse and unmerged states typed, and the OWN cases of a file at the row's path, a file under it, and an unmerged variant with no gitlink at stage 2 | | the new file, `test_family_members_case_variants.py`, `test_family_add_refusals.py` | 2104a3d | `161 passed in 809.30s` | | `test_family.py`, `test_family_member_path.py`, `test_validate_family_unnamed_mounts.py`, `test_readme_windows_quoting_sentence.py`, `test_repo_hygiene.py` | 2104a3d | `269 passed in 803.63s` | The S2 and S3 cases pass on the merge too, since they pin behaviour the PR already had. Lane 3's mutant X2 fails the S2 case (below). The S3 case holds the variant's-commit rule end to end. Earlier, at the PR's first heads (before the merge with main): | Run | Head | Result | |---|---|---| | the new file, on BASE a524542 | a524542 | `29 failed in 48.75s` | | every `tests/test_family*.py`, 21 files | 2216e1c | `851 passed, 19 skipped in 1248.90s` | | `tests/test_validate_family*.py` (5), `test_refusal_default_remediation.py`, `test_repo_hygiene.py`, `test_siblings_family_name.py`, `test_template_text_placeholders.py`, `test_clone_url_bytes.py`, `test_update_shape_header_comments.py`, `test_update_shape_pr_base.py`, `test_shape_doctor.py` | 2216e1c | `1014 passed, 3 skipped in 1323.30s` | Holder `Fam` with members Atlas and Nova, Atlas's gitlink moved to `Members/Atlas` in the index at its own commit and committed; then `family.py add` of Zed (`$Q/v110/w646/probes/repro.sh`), at the PR's first head. | Layout | BASE a524542 (`repro-base.log`) | head 79754b9 (`repro-head.log`) | |---|---|---| | L1, nothing on disk in that spelling | `git rm -r --cached -- Members/Atlas`, commit: no gitlink for Atlas; gate `member-gitlink-absent` | rm, `update-index`, commit: Atlas at `members/Atlas` at its commit; status clean; gate exit 0 in all three modes; next `add --dry-run` exit 0 | | L2, the only checkout at `Members/Atlas` | `git mv -- Members/Atlas renamed-1`, commit: gitlink at `renamed-1`; gate `member-gitlink-absent` | rm, `update-index`, commit, `git submodule update --init --recursive`, `mv` out: the same end state | | L3, both directories | `git mv`, commit: gitlink at `renamed-1`; `?? members/Atlas/`; gate `member-gitlink-absent` | rm, `update-index`, commit, `mv` out: the same end state | | L4, a fresh recursive clone (an empty `Members/Atlas`) | `git mv`, commit: gitlink at `renamed-1`; gate `member-gitlink-absent` | rm, `update-index`, commit, `git submodule update`, `mv` out: the same end state | | L5, the variant's removal staged, not committed | `git commit`: the removal committed; gate `member-gitlink-absent` | rm (`--ignore-unmatch`, no error), `update-index`, commit: the same end state | In every layout, on both sides, the dry run and the real run printed the same stderr, and the in-process run under a folding disk and under a Linux disk printed the same two lines as the subprocess. This round's states (S1, S2, S3, N2, N3) are reproduced by the tests above, which type each printed line. Not reproduced: a real macOS or Windows disk. The folding disk is emulated. CI runs Linux only (#597). **This round** (`$Q/v112/w646b/probes/w646b-mutate.py`, in throwaway clones at 2104a3d). Each probe changed one hunk or condition, and the new file was then run with `-x`. Every hunk of this round fails a test, and so do lane 3's X1 and X2 and rv670b's R1, which needed a new state (last row). | Probe | What it undoes | Result | First failing test | |---|---|---|---| | M1 | a variant never marked `sparse` | 1 failed, 19 passed | `test_with_the_other_remedies…[kept off disk by a sparse checkout]` | | M2 | `--sparse` never printed | 1 failed, 19 passed | the same | | M3 | back to "no GITLINK at the row's path" (the first heads' rule) | 1 failed, 31 passed | `test_only_the_members_own_gitlink…[a file at the row's path]` | | M4 = lane 3's X1 | "no entry AT the row's path", under it not asked | 1 failed, 32 passed | `…[a file under the row's path]` | | M5 | the first record git lists (stage 1), as the first heads had it | 1 failed, 20 passed | `test_with_the_other_remedies…[unmerged, with nothing in progress]` | | M6 | stage 0 alone: an unmerged variant is never the member's own | 1 failed, 20 passed | the same | | M7 | the last record (stage 3) | 1 failed, 20 passed | the same | | lane 3's X2 | `name in in_head or` deleted | 1 failed, 18 passed | `test_with_the_other_remedies…[HEAD holding both, the row's removal staged]` | | lane 3's X3 | `'posix'` dropped from the move back's three quotings | 36 passed (survives) | none on Linux: `shell_quoted` quotes for `os.name`, POSIX here (see "PowerShell" under "Left out") | | rv670b's R1 | `ours[0] == b"160000"` replaced by the mode of the first record git lists (`mode == 0o160000`), in place of stage 2's | 36 passed (survived) at 2104a3d. At a837a02: `1 failed, 34 passed in 44.07s` (`-x`), and `1 failed, 8 passed, 28 deselected` for `-k test_only_the_members_own_gitlink` | `test_only_the_members_own_gitlink…[unmerged, with a file at stage 2 and gitlinks at 1 and 3]` | The S3 test types the remedy end to end and asserts the gate's finding, so it holds the variant's-commit rule itself; no mutant of this round touches that rule. **The first heads** (`$Q/v110/w646/probes/mutate.py`, in a throwaway clone at 2216e1c). The new file was run with `-x`. | Probe | What it undoes | Result at 2216e1c | First failing test | |---|---|---|---| | H1 | `RESTORE` in `REMEDY_ORDER` | 1 failed | `test_each_layout…[nothing on disk in that spelling]` | | H2 | no member's own gitlink found | 1 failed | the same | | H3 | a gitlink at the row's path not asked | 1 failed, 22 passed | `test_only_the_members_own_gitlink…[a second gitlink beside the row's]` | | H4 | any index entry, not a gitlink alone | 1 failed, 23 passed | `…[a file at the variant]` | | H5 | the index's branch | 1 failed | `test_each_layout…[nothing on disk in that spelling]` | | H6 | the commit always printed | 1 failed, 15 passed | `test_with_the_other_remedies…[with nothing left to commit]` | | H7 | the commit printed only where HEAD holds the variant | 28 passed (survived) | killed at 79754b9: `test_the_gitlink_is_recorded…[staged at another commit, HEAD keeping the row's]` | | H8 | the commit read from HEAD's gitlink at the row's path first | 28 passed (survived) | killed at 79754b9, by the same case | | H9 | HEAD's branch | 1 failed, 3 passed | `test_each_layout…[the removal staged, not committed]` | | H10 | HEAD's file taken for a gitlink | 1 failed, 25 passed | `test_only_the_members_own_gitlink…[a file at the variant in HEAD alone]` | | H11 | the disk named again at a gitlink moved back from HEAD | 1 failed, 17 passed | `test_with_the_other_remedies…[HEAD alone, the checkout at the variant]` | | H12 | the commit not printed after a pending change | 1 failed, 14 passed | `…[after a pending change in HEAD]` | | H13 | no `--ignore-unmatch` | 1 failed | `test_each_layout…[nothing on disk in that spelling]` | | H14 | the last variant found names the commit | 1 failed, 20 passed | `test_the_gitlink_is_recorded…[two variants in the index]` | | H15 | the closing sentences before the disk's remedies | 1 failed, 16 passed | `test_with_the_other_remedies…[members/ renamed by hand]` | | H16 | the closing sentences dropped | 1 failed | `test_each_layout…[nothing on disk in that spelling]` | | H17 | `add` passes no rows | 1 failed | the same | | H18 | `_checked_mount_path` drops the rows | 1 failed | the same | | H19 | `_rows_paths` takes any row's path | 1 failed, 27 passed | `test_the_rows_that_have_a_path_of_their_own` | | H20 | `_rows_paths` does not check the project's type | 1 failed, 27 passed | the same | | H21 | the move's part dropped from the text | 1 failed | `test_each_layout…[nothing on disk in that spelling]` | The branch merges main 35133f2 in bc48c2a, with no conflict: its tree, 088aa26, is the one `merge-tree` predicted. Only #637, #622 and #639 had changed `scripts/family.py` since BASE. `git merge-tree --write-tree` of the head a837a02, last after a fetch at about 18:45Z. Every one is clean. - origin/main 2fac979 (tree 042d309). Since the last check (main de92614) it has landed #634 (30676c9), #659/#671 (6e37d0e), #658/#699 (b618ec1), #650/#719 (3dc34b8), #678/#701 (5c392dd) and #425/#717 (2fac979). The other rows are the heads as fetched now. - Lane 4's five branches that change `scripts/family.py`: `fix/684-family-holder-states` 7015386, `fix/686-bump-reachability` 7567e60, `fix/688-family-init-values` aa95b6b, `fix/689-next-push-refspec` fa51384 (it also changes `tests/test_family_add_refusals.py`) and `fix/693-local-remote-dir` 96ebe45. - Lane 4's other open heads: `fix/674-update-shape-apply` d069902, `fix/690-siblings-inputs` 38a40ab, #698 b069951, #700 fca40df, #702 48bac6e, #703 (`fix/696-setup-family-folder`) 4054ffd, #705 1bc63e8 and #706 (`fix/664-surviving-mutants`) 3e408b1. - Lane 2's #671 and #699 and lane 4's #701 are on main now, so the main row covers them. - Lane 5's #682 (#648, 8b39cd1), `fix/692-raw-folder-paths` ab55c29 (it changes `scripts/family.py` too) and `fix/704-bootstrap-leg-moves` 4e2b211. - **A `.gitmodules` that names the variant too** (a `git mv members/<P> Members/<P>`, committed or staged; checklist candidate (e) below). The move puts the gitlink back at the row's path, but the section still gives `Members/<P>`. So `git submodule update` fails with `No url found for submodule path 'members/Atlas'`, and the gate refuses `member-uninitialized` (`$Q/v110/w646/gitmv-head.log`). On BASE the same remedy moved the gitlink and the section to `renamed-1` (`gitmv-base.log`). #639's validator prints the same incomplete lines for this state. A fix needs a `.gitmodules` edit that the validator does not print. - **A file the index holds at or under the row's path, beside the variant's gitlink** (decision 1). #574's remedy is kept: it takes the variant out, and the file stays at the row's path. For a file at the row's path, typed, it leaves HEAD with `100644 blob … members/IRRS`, the gate refuses `member-gitlink-absent` (exit 2), and `add` of another member then goes through (exit 0), as rv670b ran it; a file under the row's path was not run. The move would replace that file, or stop half done, so no remedy here can both keep the file and put the member back. This state predates this PR. - **A file or link the index holds at `members` itself** (a file `members`, or a committed link `members -> Members` left after a hand rename; checklist candidate (h) below). The rule asks about entries at or under a row's path, not above it. With every member at a variant and `members` a file in the index, `add` prints the move back, and rv670b typed it line by line: `git rm` takes out both variants (exit 0), then each `git update-index --add --cacheinfo` fails with exit 128, `'members/IRRS' appears as both a file and as a directory`. The next `add` prints the same move again, now from HEAD. A committed link behaves the same. #574's remedy also un-registered the member here, so it is not a regression. Adding `or row.startswith(name + "/")` to the rule would keep #574's remedy there, which leaves the member with no gitlink, as the entry above does. `scripts/family.py` is not changed for it here. - **Git older than 2.34 and `--sparse`.** `git rm` has no `--sparse` option before 2.34 (git v2.33.0's `builtin/rm.c`), and it skips every `skip-worktree` entry (`if (ce_skip_worktree(ce)) continue;`), so there the line without the flag was the same silent no-op. The printed `--sparse` stops with an unknown-option usage error, exit 129 (from reading that source; not run). #574's `git mv --sparse` has the same limit. The S1 test is skipped on git older than 2.34, as #574's sparse test is. - **PowerShell.** The printed lines are quoted for a POSIX shell (#532), so paste them into Git Bash. RV670 notes (N4) that in PowerShell the bare `160000,<commit>,members/IRRS` becomes an array passed as three arguments, which `--cacheinfo` also accepts. Not run, by RV670 or here. On Linux, dropping `'posix'` from these lines changes nothing (lane 3's mutant X3), since `shell_quoted` quotes for `os.name`, which is POSIX there. - **The closing `mv` inherits #639's N-1 and N-2** (rv611c), and its N-3: - N-1: where the variant directory is a live checkout with an absorbed gitdir, the `mv` leaves its `.git` file pointing nowhere; - N-2: the `mv`'s condition is stated by OS, not by a check a person can run, so on a case-folding Linux mount (WSL's `/mnt/c`) typing it moves the member's own checkout out, and `git submodule update --init --recursive` then puts it back; - N-3: an empty `../<variant>` directory that already exists is moved into. - **A variant under `members/` itself.** `members/irrs` for the row `members/IRRS` is invisible to `add`'s `_spelled_target`, while the validator's casefold check names it (RV670, N6). This predates this PR. - **Case folding beyond ASCII**, as in #574: project names are ASCII, and git's `icase` folds ASCII only. - **(e) `family.py add` and `validate-family.py`: the move back of a member's gitlink leaves `.gitmodules` naming the variant after a `git mv`.** After `git mv members/<P> Members/<P>`, git rewrites the section's `path` to `Members/<P>`. The index-only move both tools print puts the gitlink back at `members/<P>` and leaves the section where it is. `git submodule update --init` then fails with `No url found`, and the conditional `mv` would move the member's only checkout out of the holder. Both could also print `git config -f .gitmodules submodule.<name>.path members/<P>` and `git add .gitmodules` where a section gives a variant's path (evidence `$Q/v110/w646/gitmv-head.log`). - **(f) `validate-family.py`: its move back of a member's gitlink is a silent no-op where a sparse checkout keeps the variant off disk.** Its `git rm -r --cached --ignore-unmatch -- <variant>` has no `--sparse`, so it exits 0 and removes nothing, and its `git update-index --add` then leaves two gitlinks in the index. This is the validator's half of RV670's S1, which RV670 assigns to #459. The validator is a pinned template. - **(g) `validate-family.py` and `family.py add`: a file the index holds at or under a row's path, beside the member's gitlink at a variant, has no remedy that keeps the member.** `add` now keeps #574's remedy there, which takes the variant out. The validator asks `recorded_gitlink`, which skips any entry that is not mode 160000, so by reading its code (not run) it prints its move back for this state too: `update-index --add` would replace a file at the row's path without a word, and fail on files under it after its first line has taken the variant out. This state predates #670. - **(h) `family.py add` (and probably the validator, not run): a file or link the index holds at `members` itself is never named.** The move printed beside it stops at its `update-index` line after `git rm` has taken every variant out (above, under "Left out"). Nothing here fixes it; the coordinator places it. Review: lane 3's RV670 found 79754b9 READY with three SHOULDs; the fix round took S1-S3, N2 and N3, rv670b's re-review found it ready with nits, and a837a02 takes Brett Heap's ruling of 2026-10-16T14:11:42Z that the restore keeps the variant's own commit. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 a837a02 passed tests, tests-git-bash and SonarCloud; the head 6d7e4ff is a clean update-branch merge of main bf1f099, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree b46e560 over main 6794a9f: the family_add_member_variant, family_add_refusals, family_members_case_variants, repo_hygiene, agents_md_codes, cli_reference sets, 308 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 #646 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… verification row's colon, `run_validator`'s "costs no child an import" and the decoy test's `sitecustomize` claim say what main does (#666) (#730) Four comments and docstrings said something main no longer does. They now say what the code beneath them does. A fix round after review added two more of the same kind (see "Fix round"). Comments and docstrings only: no behaviour, no `--help` text, no pinned copy, so no README, handbook or `docs/cli.md` change. - **`adopt-project.py`, the module docstring.** It said the verification table reads `code: 0 of N source paths (seeded from template)`. The printed row has no colon (` {leg:<6} {count:>5} of {N} source paths{note}`). It now says the table's `code` row reads `0 of N source paths (seeded from template)`, the words `AGENTS.md` and `README.md` use since #505 and the docstring of the verification function uses too. `git grep 'code: 0 of'` finds nothing now. - **`shape-doctor.py`, `submodule_config`.** The last sentence of the docstring gave a reason that is true of the string and not decisive: "It is also what the row PRINTS, and a lone surrogate is a string no UTF-8 stream can write." The table goes through `ascii_text` and `--json` through `json.dumps`, so neither would raise on a lone surrogate. I dropped the sentence, the issue's first direction. The comparison with `origin_url`'s reading of a clone's remote is the reason, and the paragraph already gives it first. **This hunk carries the bytes of lane 3's #700**, which landed as `55c6ba7d`: the two removed lines and the one added line (` working clone.`) are the same. So the hunk is on main already and drops out of the squash (see "Evidence"). - **`shape-doctor.py`, `run_validator`.** "It costs no child an import" holds for an unedited copy only. A new paragraph says a root whose own copy of a validator gained an `import yaml` read `passes` while `site` was on, and now reads `crashed before reaching a verdict (exit 1): ModuleNotFoundError: No module named 'yaml'`, the crash its own CI gets, because `validate.yml` installs nothing. It calls that row a fair answer and says `-S` does not ask whether a copy was edited. No code change. - **`tests/test_shape_doctor.py`, the decoy test's two comments** (the block above `DECOY_REPO_SHAPE` and the inline comment before `reached`). `run_validator` starts every child as `[sys.executable, "-S", script, ...]`, so no child imports a `sitecustomize`. The comments now say that, say that replace-not-prepend and the #306 review S1 case are held by `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given`, and say that the decoy test holds the template row's real verdict under an exported `PYTHONPATH`. The review of the first two commits approved with nits. Two new commits take S1 and N1: `7f2bb36b` makes both edits, and `426877bf` rewrites the last sentence of the N1 comment, which did not parse ("...a future removal of `-S`, which `test_run_validator_starts_every_child_with_no_site` ... is the test that holds"). It now ends "...a future removal of `-S`; `test_run_validator_starts_every_child_with_no_site` (in `tests/test_doctor_validator_site.py`) is the test that holds `-S`." Both are comments and docstrings only. - **S1, a third stale sentence.** The docstring of `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given` said that for the callers which pass no `pythonpath`, "only a probe like this one, or (b)'s decoy, can see that they have none". That is false under `-S`: a `run_validator` that keeps the variable for those callers passes the decoy test in all three cases and fails the probe test twice (the second row of the table below). It now says only a probe like this one can see that they have none, and that the decoy of (b) could before `-S` (#342). It is the same defect as the issue's, in a place the issue did not name. "Could" holds for the `manifest` and `pins` cases: with `-S` removed, 2 of the 3 decoy cases fail, and the `family` case never caught it. - **N1, the inline comment overstated the loop.** The comment above `reached` said "What this loop still holds is that no row quotes the decoy or says it exited 86." Under `-S` the loop cannot fire. It now says so, says the loop is kept as a tripwire for a future removal of `-S`, and names `test_run_validator_starts_every_child_with_no_site` in `tests/test_doctor_validator_site.py` as the test that holds `-S`. That test is on main. - **N2, this body.** The merge checks and the notes on #630 were stale (they cited `de926142` and a conflict that is gone). They are rerun and rewritten below. **Only comments and docstrings changed.** `git diff 8062810 426877b` has 74 changed lines (44 added, 30 removed) in three files. A script checked each changed line against the file's comment tokens and docstring spans (both sides of the diff): 0 outside. A second check parsed each file at the base and at the head, blanked every docstring, and compared the ASTs: identical in all three files. For the two fix-round commits together, the AST of `tests/test_shape_doctor.py` with docstrings blanked is identical to the one at `a2113cff`, `git diff --check` is clean, and the longest added line is 75 columns (73 in the second commit). **What the squash holds.** `git merge-tree --write-tree` of this head with origin/main `20c8873b` gives the tree `6ead2ffc`. `git diff 20c8873 6ead2ffc` is 71 changed lines in three files, and `shape-doctor.py` has 8 of them: the new `run_validator` paragraph and nothing else. `git diff` of the two trees has no hunk that mentions `submodule_config`, `surrogate`, `PRINTS` or `working clone`. On main, `submodule_config`'s docstring has no "PRINTS" sentence and ends in `working clone.`. The same two checks on that tree: 0 lines outside comments and docstrings, ASTs identical. On main `2d988200` with the head `426877bf` the squash is the same: 71 changed lines, 8 of them in `shape-doctor.py`, 0 outside comments and docstrings, ASTs identical, and no hunk that mentions `submodule_config`. **`python3 scripts/render-cli-reference.py --check`**, at `426877bf`: `[ok] docs/cli.md is what the generator produces`. Only the first line of a module docstring is help text, and none of the first lines changed. **The decoy comments, measured.** I did not take the issue's reading on trust. In a throwaway clone at the base I put each #306 regression into `run_validator` and ran the two tests (`test_an_exported_pythonpath_does_not_reach_a_template_validator`, three cases, and `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given`, six): | mutant | with `-S` (main) | with `-S` removed | |---|---|---| | the standard's `scripts/` put in FRONT of the operator's value | decoy test: 3 of 3 pass. Probe test: 1 of 6 fails | decoy test: 3 of 3 fail | | the variable kept for the callers that pass none (S1) | decoy test: 3 of 3 pass. Probe test: 2 of 6 fail | decoy test: 2 of 3 fail | | the operator's directory AHEAD of the standard's | decoy test: 3 of 3 fail. Probe test: 1 of 6 fails | not run | So on main the decoy test catches neither #306 regression and the probe test catches both, as lane 4's R342 measured on #636's head. The third row is why the comment says the raising `repo_shape.py` "still bites": it kills an operator-first order. The review re-ran these mutants on `a2113cff` and got the same counts. **The `run_validator` paragraph, measured.** A root whose own `validate-manifest.py` is `import yaml` plus a print, run through `run_validator` from this tree: exit 1 and `ModuleNotFoundError: No module named 'yaml'`. The same call with `-S` taken out of the child's argv: exit 0, `manifest ok`. `yaml` is importable from the machine's site-packages and not without `site`. With the real `gh` unreachable (a stub first on PATH, a bogus token, an empty `GH_CONFIG_DIR`). On `a2113cff`, before the fix round: - `tests/test_cli_reference.py`, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_scaffold_leg_path_collisions.py`: 250 passed (57.5 s). - `tests/test_doctor_gitmodules_not_utf8.py`, `tests/test_doctor_validator_rows.py`, `tests/test_doctor_validator_site.py`, `tests/test_doctor_validator_verdicts.py`, `tests/test_adopt_spec_only.py`, `tests/test_adopt_entry_paths.py`, `tests/test_adopt_submodules.py` (the files that drive `run_validator`, `submodule_config` or the seeded-leg row): 334 passed (28 min 18 s). - `tests/test_shape_doctor.py`, in two halves run side by side (the first 204 test ids, then the last 204): 204 passed in 11 min 31 s, and 201 passed, 3 skipped in 4 min 58 s. That is all 408 tests. The 3 skips are the PowerShell-line tests, which need `powershell.exe` on a Windows host. - The review ran all of `tests/test_shape_doctor.py` there: 405 passed, 3 skipped (512 s). On the predicted merge with main `2fac9790` it ran `test_shape_doctor.py`, `test_doctor_validator_site.py`, `test_doctor_validator_rows.py`, `test_doctor_validator_verdicts.py`, `test_doctor_gitmodules_not_utf8.py`, `test_repo_hygiene.py`, `test_cli_reference.py` and `test_agents_md_codes.py`: 632 passed, 3 skipped (760 s). On `426877bf`, after the fix round, which changes comments and docstrings only, so the runs above stand: - The two tests the fix round touches, `test_an_exported_pythonpath_does_not_reach_a_template_validator` (3 cases) and `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given` (6 cases), run by node id: 9 passed (11.7 s). The same 9 also passed (16.1 s) at `7f2bb36b`. - `tests/test_repo_hygiene.py`: 77 passed (1.9 s). It also passed (2.0 s) at `7f2bb36b`. No test fails on the base, because nothing here changes behaviour. The AST comparison above is the proof in their place. `git merge-tree --write-tree` of this head against origin/main and against every ref on origin that could meet it. The full run was on 2026-10-16, with the head `7f2bb36b` (the one before the last comment commit) and main `20c8873b`: the 41 branches (`fix/*`, `l3-*`, `wip/*`, `docs/*` and `release/v1.0`) and the 55 PR heads from #630 up. That is 96 refs. I ran #731's head `ab55c29f` through the same list. After `426877bf` I re-ran main, now `2d988200`, and the six heads that edit `tests/test_shape_doctor.py`, the one file that commit touches (#705, #737, #740, `l3-669-members-row`, `l3-680-doctor-signals` and `l3-697-crash-cure-next`), and #630: all clean. - **origin/main: clean.** At `20c8873b`, with the head `7f2bb36b`, the predicted tree is `6ead2ffc`. At `2d988200` (three `adopt-project.py` commits later, with their tests and a change in `scripts/shape_materialize.py`), with the head `426877bf`, it is `7ee5caf4`, and the squash is the same three files (43 added, 28 removed). #731's head `ab55c29f` is clean with `2d988200` too. This head with #731's head: clean. - **#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. - **#700 and #702 have landed** (`55c6ba7d` and `20c8873b`), and so has #701, and #699, #717 and #719 for `adopt-project.py`. Their heads still merge clean with this head. - **Every ref that edits `shape-doctor.py`, `tests/test_shape_doctor.py` or `adopt-project.py` merges clean with this head**, except the two old heads named in the bullet after next. They include the heads of PRs not on main yet (#705, #721, #737 and #742, and lane 3's `l3-680`, `l3-683`, `l3-687`, `l3-691` and `l3-697`, which were not on origin when this body was first drafted). #673 has no branch or PR head on origin. - **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. - **Two landed pre-squash heads conflict with this head only because they are old.** #632 conflicts with main on its own. #636 merges clean with main, but its old head conflicts with this head in `run_validator`'s docstring, next to the text #636 added; main has that text, and this head merges clean with main. - **Refs that conflict with main on their own are not made worse.** #682, #745, #747, `fix/648`, `fix/681` and `fix/704` conflict with main in `templates/family-root/scripts/validate-family.py`; #742 and `l3-683` in `shape-doctor.py`. Each merges clean with this head. - **Every other open head and branch: clean.** Nothing from the issue. Its four lines are done. The decoy test's `sitecustomize` machinery is dead code under `-S` and I left it, because the issue says comment only. The comment above `reached` now says it is kept as a tripwire. - **The decoy test's `sitecustomize.py` and exit-86 trap never run.** `DECOY_SITECUSTOMIZE`, `os._exit(86)` and the `(exit 86)` and `DECOY` clauses in `reached` can only fire for a child started without `-S`. They are kept as a tripwire and the comment says so. The other choice is to remove them; `test_run_validator_starts_every_child_with_no_site` already holds `-S`. Small, tests only. Review: rv666, lane 5's independent review, approved a2113cf with nits; 7f2bb36 and 426877b take its S1 and N1 (above), and codex found no major issue at a2113cf. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 426877b passed tests, tests-git-bash and SonarCloud; the head 32722b3 is a clean update-branch merge of main bf1f099, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 55d5d3b over main dcf62ed: the shape_doctor, scaffold_leg_path_collisions, repo_hygiene, agents_md_codes, cli_reference sets, 686 passed, 3 skipped, 1 xfailed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts". Closes #666 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… in command scope and in a global config that includes the person's own, so a `copytree` of a fixture no longer meets a detached repack deleting its loose objects (#645) (#713) **What changed.** One session fixture in `tests/conftest.py`, `no_auto_maintenance` (autouse), and one new test file, `tests/test_no_auto_maintenance.py`. No shipped file and no other test file changes. Four commits: the fix and its tests (e2110f8), a comment (accc7d2), and the fix round below (8cfc8eb, and 979bb39, which only shares a test helper). The fixture sets `gc.auto=0` and `maintenance.auto=false` for every git the session starts, in two scopes: - **Command scope** (`GIT_CONFIG_COUNT`), appended after the entries the environment already holds (the Windows job holds two). Every git the suite starts, every git a tool under test starts, and every git a `git submodule` starts inherits it. It outranks every config file. - **A global config file** (`GIT_CONFIG_GLOBAL`, in the basetemp). It includes the global config git would read without it (the file `GIT_CONFIG_GLOBAL` already named, or `$XDG_CONFIG_HOME/git/config` and then `~/.gitconfig`) and sets the two keys after it. This one is for the `receive-pack` a push starts in a local remote: git starts it with `GIT_CONFIG_COUNT` and `GIT_CONFIG_PARAMETERS` removed, so command scope never reaches it. Where `HOME` is unset the `~/` paths are left out, and an empty `GIT_CONFIG_GLOBAL` includes nothing (fix round, below). A comment beside #440's note in `clone_just_pushed` says why, names both sites, and says what is left. **Why.** `shutil.copytree` reads a repository's `objects/` one file at a time. The suite copies a repository a fixture has just committed in, fetched into or pushed to in 139 places. Each `git commit`, `merge` and `fetch`, and the `receive-pack` in a remote after a push, runs `git maintenance run --auto` as it finishes. From git's source: - git 2.46: the auto run stays in the foreground. - git 2.47: it is started with `--detach`, takes `objects/maintenance.lock` and then detaches, so the command returns while it runs. - git 2.52 and 2.53: the default strategy is still `gc`, which does nothing below 6700 loose objects. - git 2.54: the default strategy is `geometric`. Its repack runs when packs are out of geometric order, or once its estimate, taken from `objects/17` alone, is above 256 loose objects, so two loose objects there are enough (`maintenance.geometric-repack.auto`, 100, rounded up). It deletes the loose objects it packed. So on a current git, a copy taken just after a commit can find a loose object gone. It happened twice on the Linux runner: - `holder` (`tests/test_family.py:129`), in `test_family_member_name.py` (lane 1's report on #473); - `the_standard_moved` (`tests/test_doctor_pin_restore_merges.py:99`), a copy of the session's `standard` with its `.git`, in `test_copies_neither_side_edited_are_taken_whole_with_the_pin` on #623's run 38895011713: `shutil.Error ... standard5/openRepoShape/.git/objects/1e ... [Errno 2]` (lane 2's #473 comment 6215422231). The same function copies `moved` at :102 just after a commit in it. Both are covered by the same session setting: the commits before each copy run under `os.environ` (`commit_all` passes `{**os.environ, ...}`). A listing of `.git/objects` taken before and after such a repack also differs: lane 3's `objects changed` in `tests/test_family_bump_remove_rollback.py` (#736's Linux run 38981191418), 37 loose objects packed into two. The same setting covers it (i645 on #645). **Decisions and evidence.** - *Option A (the suite's environment), not B or C.* - B (config in each copied repository) would have to reach repositories the tools under test create themselves, such as the bare remotes `scaffold-project.py --local-remote-dir` makes and pushes into before a test sees them. A config written after that first push is too late. - C (a copy that survives a repack) cannot be made safe. A copy can list `objects/pack/` before the new pack lands, and then a loose-object directory after it was emptied. That copy succeeds and is missing objects. - A covers all 139 places from one place. - *Two scopes, not one.* Probe on git 2.43, pushing into a local bare remote with `receive.denyDeletes=true` given each way: - with `GIT_CONFIG_COUNT`, the branch was deleted; - with `-c`, deleted; - through a `file://` URL with `GIT_CONFIG_COUNT`, deleted; - with `GIT_CONFIG_GLOBAL`, `remote: error: denying ref deletion` (refused). So command scope alone misses the remotes, and ten copies are of bare remotes. A global file alone would be dropped wherever a test gives git a global file of its own (14 test files set `GIT_CONFIG_GLOBAL`, five more through `excluding()`). No test file replaces both scopes and also copies a repository. rv645, the reviewer, repeated the `receive.denyDeletes` probe and got the same answers, and instrumented the git calls of the batches it finished before the checkpoint: the only receive-packs that ran without both settings were in `test_family_add_excludes.py` and `test_adopt_clone_template.py`, neither of which copies a remote. - *Which verbs start auto maintenance* (git 2.43 trace2): `commit`, `fetch`, `pull` and `submodule update --remote` (its fetch in the submodule) start `git maintenance run --auto`. A push's `receive-pack` starts `git gc --auto --quiet`. `clone`, `submodule add` and `submodule update --init` start none. - *Behaviour no test checks is unchanged.* The person's own global config is still read: it is included, and it comes first. A test of that is part of the new file. The tests that read or set a global config (`test_git_helper_identity.py`, `test_adopt_git_reads.py`, `test_tracking_branch_ref.py` and others) set their own, which replaces the session's. **Fix round (8cfc8eb), on rv645's review** (no MUST; two SHOULD, five NIT). - **SHOULD 1: with `HOME` unset every git was fatal.** The file's include of `~/.config/git/config` and `~/.gitconfig` made git exit 128, `error: could not expand include path '~/.config/git/config'`, in every command the session started. The estate runs with `HOME` set, so nothing failed here; an `env -i` or hermetic runner would have lost the whole suite. - `session_global_config` now writes a `~/` path only where `home_is_known()` (`HOME` is set, or `os.name == "nt"`, where Git for Windows finds a home itself), and writes no `[include]` header when nothing is included. An `XDG_CONFIG_HOME` that is set is still included, because it needs no `HOME`. - The two `test_a_persons_own_global_config...` cases skip where `HOME` was unset. They skip on the environment and not on the file's bytes, so a fixture that dropped its includes still fails them (probe M5 below). - A new test, `test_where_home_is_unset_the_file_is_one_git_can_read` (with and without `XDG_CONFIG_HOME`), reads the file back through `git config --global --includes`. It is skipped on Windows. It and the empty-value test share one helper, `probe_read_from_the_session_file`, so the new tests do not repeat a body for SonarCloud's duplication gate. - Measured under `env -u HOME -u XDG_CONFIG_HOME`, `run9.sh`, git 2.43.0: | file | before (accc7d2) | after (8cfc8eb) | |---|---|---| | `tests/test_clone_just_pushed.py` | 1 passed, 6 errors | 7 passed | | `tests/test_no_auto_maintenance.py` | 3 failed, 2 passed, 2 errors (7 tests) | 9 passed, 2 skipped (11 tests) | | `tests/test_family_member_name.py` | not run | 13 passed | The 2 skips are the two `test_a_persons_own_global_config...` cases. - Where `HOME` is set and the file is written as before, nothing changes: across 27 combinations of `HOME` (set, unset, empty), `XDG_CONFIG_HOME` (set, unset, empty) and `GIT_CONFIG_GLOBAL` (unset, a file, empty), the file's bytes are the same as accc7d2's in the 12 where `GIT_CONFIG_GLOBAL` names a file or `HOME` is set with `GIT_CONFIG_GLOBAL` unset, and differ in the 15 where `HOME` is not set or `GIT_CONFIG_GLOBAL` is empty. - **SHOULD 2: nothing guarded the fixture's scope.** At function scope all seven tests passed (rv645's probe), and the session fixtures `scaffolded` and `family` would have been built before the setting existed. A new test, `test_the_settings_are_in_place_before_a_session_fixture_builds_a_repo`, takes a session fixture as its FIRST argument, which reads `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_COUNT` as that fixture sees them, and compares them with what the test sees and with the fixture's file. Pytest builds the higher scope first, and then an autouse fixture, so the fixture is only in place early enough if it is session scope and autouse. - **NIT 1: the receive-pack test failed under a person's global `core.hooksPath`**, which hid the remote's own `post-receive` hook (`FileNotFoundError` on `read-by-receive-pack`). The test now sets `core.hooksPath` to the remote's own `hooks` directory in the remote's local config, which outranks a global one. Measured with `GIT_CONFIG_GLOBAL` naming a file that holds `core.hooksPath`: before, `1 failed, 4 passed, 2 skipped`; after, `9 passed, 2 skipped` (11 tests). - **NIT 2: an empty `GIT_CONFIG_GLOBAL=` was read as unset.** Git 2.43 reads it as "no global config" (checked here: with `HOME` holding a `.gitconfig` probe, `GIT_CONFIG_GLOBAL=` does not read it). The fixture now includes nothing for an empty value: `if named is not None: included = [...] if named else []`. A new test, `test_an_empty_GIT_CONFIG_GLOBAL_is_no_global_config_and_includes_nothing`, fails on the old `if named:`. - **NITs 3 to 5: no code change.** - NIT 3: `test_family_dry_run.py`'s own `NO_MAINTENANCE` is redundant now. It lives in another test file that this PR leaves alone, and it is harmless. - NIT 4: a `git config --global` read without `--includes` sees only the session's two keys. The only reader is `setup-project.py:1105`, behind `os.name == "nt"`, and no test reads it. `_credential_helper_configured` uses `--get-all` with no scope, so it still sees the person's config. The conftest comment already says so. - NIT 5: the version facts in the conftest comment ("2.47 detaches", "2.54 default geometric") are from git's source tags and are not run here: only git 2.43.0 is installed. The comment is unchanged. **Tests** (`run9.sh`: gh stub first on PATH, identity blanked; git 2.43.0, Python 3.12, pytest 9.1.1). - On the fix round (8cfc8eb; 979bb39 differs from it in the new test file alone, and the batches that read that file were run on it): - `tests/test_no_auto_maintenance.py`: 11 passed (7 from the first commit, 4 new: the `HOME` test twice, the empty value, the scope). - `tests/test_no_auto_maintenance.py`, `tests/test_clone_just_pushed.py`, `tests/test_repo_hygiene.py` and `tests/test_git_helper_identity.py` together, on 979bb39: 105 passed. With `tests/test_family_dry_run.py` added, on 8cfc8eb: 166 passed. - `tests/test_adopt_git_reads.py`, `tests/test_tracking_branch_ref.py`, `tests/test_adopt_late_failure.py`, `tests/test_adopt_github_exits.py` and `tests/test_family_add_excludes.py` (the tests that read or set a global config or a hooks path): 314 passed. - On the first two commits (e2110f8 for the first four, accc7d2 for the rest; the second commit changes comment lines only). The fix round changes nothing where `HOME` is set and `GIT_CONFIG_GLOBAL` is not empty (the byte comparison above), so these were not run again: - Family, `tests/test_family*.py` (20 files): 823 passed, 19 skipped. - Adopt, `tests/test_adopt*.py` (46 files): 3463 passed, 2 skipped. - Doctor, `tests/test_shape_doctor.py` and `tests/test_doctor*.py` (12 files): 741 passed, 3 skipped. - Scaffold, `tests/test_scaffold*.py`: 634 passed. - Update-shape and bump-leg, `tests/test_update_shape*.py` and `tests/test_bump_leg*.py` (24 files): 857 passed, 4 skipped. - The rest of the files that copy a repository (`test_bootstrap_after_bump.py`, `test_non_list_project_fields.py`, `test_pin_source_env_collisions.py`, `test_pin_source_unreadable.py`, `test_shape_pin_files_not_a_list.py`, `test_validator_child_encoding.py`, `test_siblings_family_name.py`, `test_bootstrap_spelling.py`, `test_non_utf8_git_reads.py`), with `test_repo_hygiene.py`, `test_git_helper_identity.py`, `test_clone_just_pushed.py` and the new file: 924 passed. - On a merge of accc7d2 with main c6122e8: `test_no_auto_maintenance.py`, `test_git_helper_identity.py`, `test_repo_hygiene.py`, `test_clone_just_pushed.py` and `test_family_member_name.py`, 114 passed. **Reproductions.** - *The mechanism, on git 2.43.* A repository of 300 files plus two loose objects named `17…`, committed under `gc.auto=1`, then copied at once with `shutil.copytree`. This is what git 2.54 does to such a repository by default. With the base's settings, 10 of 10 copies failed, then 9 of 10 (`[Errno 2]` on a loose object directory or file). With the fix's two entries appended after it, 0 of 10 failed. - *The emulation of the race* (rv645's recipe, run on 8cfc8eb: a global `gc.auto=1` that includes the person's `~/.gitconfig`, plus a `core.hooksPath` pre-commit hook that leaves two `17…` objects and drops `gc.log`, so git 2.43 repacks in the background after each commit; `GIT_TRACE2_EVENT` over the whole run). Base figures are rv645's, on a524542; head figures are the fix round's own, on 8cfc8eb: | file | git | auto-maintenance children | repack runs | result | |---|---|---|---|---| | `test_doctor_pin_restore_merges.py` | base | 78 `maintenance run --auto`, 77 `gc --auto` | 65 | 1 failed, 14 passed, 1 error | | | head | 4 `gc --auto` (from `receive-pack`, inert under `gc.auto=0`) | 0 | 16 passed | | `test_family_member_name.py` | base | 19 `maintenance run --auto`, 29 `gc --auto` | 15 | 13 passed | | | head | 14 `gc --auto` (inert) | 0 | 13 passed | On the base, rv645 reproduced the symptom itself: `shutil.Error ... moved/openRepoShape/.git/objects/pack/tmp_rev_...`, at `the_standard_moved`'s copy of `moved` (`tests/test_doctor_pin_restore_merges.py:102`). On head, 0 maintenance runs and 0 repacks in both files. - *The first writer's runs on the issue's file* (`test_family_member_name.py`, base then head, 19 maintenance and 15 repacks against 0 and 0) agree. **Mutation probes.** Each reverts one hunk of the fix in a throwaway clone; the new file then fails. First round (against the first seven tests): | probe | fails | |---|---| | fixture sets nothing | 6 of 7 (all but the append test) | | no command scope | `test_a_commit_a_fetch_and_a_merge_start_no_maintenance[a-tests-own-global]` | | no global file | the receive-pack test, both person's-config cases | | count overwritten, not appended | the append test | | global file without the includes | both person's-config cases | | the two keys before the includes | both person's-config cases | | no `maintenance.auto` | 5 tests | | no `gc.auto` | the receive-pack test, the append test | Fix round (against the eleven tests): | probe | result | what fails | |---|---|---| | S1, `no_auto_maintenance` at function scope | 1 failed, 10 passed | the scope test, alone (the first seven passed in rv645's run of the same mutant) | | S2, not autouse | 4 failed, 7 passed | the two commit/fetch/merge cases, the tool test, the receive-pack test | | H1, `home_is_known()` always true | 2 failed | `test_where_home_is_unset_the_file_is_one_git_can_read`, both variants | | H2, the XDG include needs `HOME` | 1 failed | the same test, the `xdg` variant | | E1, an empty value is read as unset (`if named:`) | 1 failed | the empty-value test | | M5, the file includes nothing (the first round's probe, again) | 3 failed, 8 passed | both `test_a_persons_own_global_config...` cases, and the `xdg` variant of the `HOME` test | | K1, the receive-pack test without its own `core.hooksPath`, under a global `core.hooksPath` | 1 failed, 8 passed, 2 skipped | the receive-pack test | S2 is caught by tests that ask for the setting by itself; the scope test does not catch it, because earlier tests in the file build the fixture on request. K1 only fails where a global `core.hooksPath` is set, as NIT 1 says. On the base itself the new file does not import (`cannot import name 'NO_AUTO_MAINTENANCE'`). **Merge checks** (`git merge-tree --write-tree` on 979bb39): clean against origin/main 2fac979 and against lane 5's `fix/662-insteadof-helper` (4149cbb; #662 is stacked on accc7d2 and also edits `tests/conftest.py`: the fix round's hunks are in `session_global_config`, its helper `home_is_known`, and the comment above them). Earlier, on e2110f8 and accc7d2: clean against origin/main d4a7fab and c6122e8, #621, #622, #630, #631, #634, #635, #636, #637, #638 and #639, and the sibling branches for #646, #647, #648 and #649. **What it leaves.** - A git whose environment drops both scopes (a `GIT_CONFIG_COUNT` overwritten, not appended to, together with a test's own `GIT_CONFIG_GLOBAL`, or an environment built from nothing), and a push made under a test's own global file. No repository the suite copies is made or pushed to that way today. - A git older than 2.32 ignores `GIT_CONFIG_GLOBAL`. Its `receive-pack` runs `gc --auto`, which does not detach below 6700 loose objects. - `clone_just_pushed` keeps reading through git. A test with its own global file still needs it. - `test_family_dry_run.py`'s own `NO_MAINTENANCE` is now redundant, and is left alone (NIT 3). - Not run: a real Windows job (none runs the full suite since #597, and the `HOME` test is skipped on Windows), and a git newer than 2.43. **Copies found** (`git grep -n 'shutil.copytree(' -- tests`, 152 calls): - 127 copy a repository the suite built (non-bare, many holding `.git/modules`): `conftest.py` 1, `test_family.py` 18, `test_shape_doctor.py` 32, `test_doctor_legs_members_next.py` 8, the `test_update_shape*` files 30, and 38 more in 28 files. Four of them copy a subtree that holds no store: `standard / name` twice, and a member's work tree twice. - 10 copy a bare remote, or a folder of them: `test_bump_leg_paste_lines.py:95`, `test_doctor_unfinished_merge.py:166`, `test_family_add_member_id.py:239`, `test_family_add_refusals.py:487`, `:788` and `:1983`, `test_family_dry_run.py:1446`, `test_family_tracking_branch.py:279`, `test_update_shape_landing.py:156` and `test_update_shape_next_quoting.py:116`. - 2 copy a `.git` directory: `test_adopt_clone_template.py:190` and `test_scaffold_mount_ignored.py:381`. - 13 hold no repository: a template, or this checkout with `.git` ignored. - `test_adopt_plan.py:449` copies a clone named by `SHAPE_MEDXEHR_CLONE`. It is outside the suite and skipped without the variable. No shipped script copies a repository. **Checklist candidates** (rv645's; the coordinator places them): - The import-time git probe at `tests/test_family_refuse_up_front.py:75-96` runs in a throwaway directory before any session setting. It is harmless today. - Tests that overwrite `GIT_CONFIG_COUNT` (14 files) lose command scope and rely on the global file alone. That holds today, and nothing guards it. Review: rv645, lane 5's independent review, found no MUST, two SHOULDs and five NITs on accc7d2; the fix rounds above take them, and its re-verification approved 979bb39. CI (Brett Heap's ruling 27): H0 979bb39 passed tests, tests-git-bash and SonarCloud; the head a9f36ce is a clean update-branch merge of main bf1f099, with tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 760892f over main 2000488: every test file that #706, #703 and lane 5's six round-1 PRs change, run under #713's conftest, with the two copytree-race files and the hygiene trio, 1332 passed, 3 skipped, 1 xfailed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts". Closes #645 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efusals and `family-operation-in-progress` name a path as `shown_source` names it, so an ESC or a line break in a path no longer reaches the terminal (#692) (#731) #622's folder refusals printed a path as it was. `family.py init --into <a file named wd-ESC[31mRED> --dry-run` gave `REFUSED family-folder-cannot-be-made: the family folder .../wd-^[[31mRED/Orbit cannot be made: ...` through `cat -v`. A name with a line break and then `WARNING forged-line: pwned` printed that line as a line of its own. R612's `family-operation-in-progress` printed the mark's path across two lines for a holder whose path holds a newline. Each path is now spelled as `shape_materialize.shown_source` spells a `--pin-source`: as it is, and as Python's `repr` spells it where a character in it is not printable. - `scripts/shape_materialize.py`: both answers of `folder_cannot_be_made` (a file or a bad stat in the way, and a folder this user cannot write in), the folder in `refuse_unmakeable_folder`, and the folder in `make_folder`. - `scaffold-project.py`: `scaffold-target-exists`. - `scripts/family.py`: `family-target-exists`, and the mark's path in `family-operation-in-progress` (`where.as_posix()`). A printable path is named byte for byte, so no existing row changes and `why.endswith(CANNOT_WRITE_IN)` in `refuse_unmakeable_folder` still holds. No help text changed, so `docs/cli.md` is untouched. No README, handbook or template sentence says how a refusal names a path (`git grep` of `shown_source`, `target-exists` and `folder-cannot-be-made` in AGENTS.md, README.md, `docs/handbook.html` and the templates found none), so no doc changes. - **`shown_source`, not `shown_path`.** Since #634, `shape_materialize.shown_source` returns `repo_shape.shown_path(source)` (`scripts/shape_materialize.py`: `return shown_path(source)`), so the seven call sites this change adds (four in `shape_materialize.py`, one in `scaffold-project.py` and two in `family.py`) spell a path as `shown_path` does. Renaming them to `shown_path` stays out of this PR: it would only move imports in `scaffold-project.py` and `family.py` and reword docstrings in `family.py`, `shape_materialize.py` and the test file, and the review's N1 left it optional. I did not take it. The predicted merge with main works with the alias: `family.py --help` and `scaffold-project.py --help` exit 0 on it, and `shown_source` and `shown_path` return the same string for an ESC name, a line-break name and a plain path. - **`shown_source` is imported by name into each file's existing `from shape_materialize import (...)` block.** The first commit put it on a line of its own after the block; lane 4's #660, #688 and #693 add an import at that same place, and `merge-tree` reported add/add conflicts in `scripts/family.py` and `scaffold-project.py`. The second commit moves the name into the block, and those conflicts are gone. - **"The holder in the other refusals of the same block" is one refusal.** The `# What the holder is in the middle of, and what its sparse checkout leaves out` block holds `family-operation-in-progress`, `family-sparse-excluded` and `member-path-unaddable`. Only the first prints a path of the holder's. `family-sparse-excluded` names `members/<Project>` and `.gitmodules`, and `member-path-unaddable` names `members/<Project>`. - **No scaffold row for `--local-remote-dir`.** A control character there is refused earlier as `scaffold-unwritable-value`, which already names the path with `repr`. The unit test covers `make_folder`, which only a race reaches by the command line. `tests/test_dry_run_parity.py`: - `SCAFFOLD_CASES` gains five rows: ESC and line break in the `--work-dir` name, ESC in a folder name above it, and ESC and line break in a work dir that holds a file named `Atlas` (`scaffold-target-exists`). - `FAMILY_CASES` gains five rows: ESC and line break in `--work-dir`, ESC in `--into` (`the family folder`), and ESC and line break in a work dir that holds a file named `Orbit` (`family-target-exists`). - Two rows join `UNWRITABLE_FOLDER_AT` (ESC in the scaffold's and in `init`'s `--work-dir`, a folder this user cannot write in). - Rows named `ESC-...` and `line-break-...` are skipped on Windows, whose file names cannot hold them (`POSIX_NAMES`, `case_params`). Every expectation spells the path with `repr`, and not with `shown_source`. - `assert_refused` now fails any stderr line that starts with `WARNING forged-line`. - Three unit tests: `test_each_folder_question_spells_a_name_that_is_not_printable` (ESC, line break; `folder_cannot_be_made`, `refuse_unmakeable_folder` and `make_folder` by themselves), `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`, and `test_a_printable_name_is_named_byte_for_byte` (a name with a space and an accented letter stays unquoted). `tests/test_family_refuse_up_front.py`: `test_a_holder_path_with_a_newline_still_shows_its_merge` asserted the raw newline in the refusal (`"Ink\nRouter/.git/worktrees/" in ran.stderr`). It is now `test_a_holder_path_with_a_control_character_still_shows_its_merge`, over a line break, an ESC and a forged line, and asserts the one-line `repr` form (`refusal()` fails unless the stderr is two lines). It is the one existing test this change had to edit. Results at head `ab55c29f` (Python 3.12.3, Linux): - `tests/test_dry_run_parity.py tests/test_dry_run_asks_remotes.py tests/test_family_dry_run.py tests/test_family_init_landing.py tests/test_repo_hygiene.py tests/test_pin_source_unreadable.py tests/test_scaffold_leg_path_collisions.py tests/test_scaffold_reuse_seeded_legs.py`: **494 passed**. - `tests/test_family.py tests/test_family_refuse_up_front.py tests/test_family_init_values.py tests/test_family_add_refusals.py`: **356 passed, 19 skipped** (the skips are `test_family_init_values.py:219` and `:291`, template-quoting premises). - Not run: the full suite (the coordinator's gate), and macOS and Windows (CI is Linux-only since #597). The new hostile-name rows are POSIX only by design. On the base code: the review overlaid this head's two test files on main `2fac9790` and ran `tests/test_dry_run_parity.py` and `tests/test_family_refuse_up_front.py` with `-k "dry_run_parity or control_character"`. The result is **20 failed, 87 passed** (76 deselected, 43.5 s). The 20 are exactly the new and edited tests: the 5 scaffold and 5 `init` rows, the 4 unwritable-folder rows, the 3 unit tests (`test_each_folder_question_spells_a_name_that_is_not_printable` in two cases, and `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`), and the 3 cases of the edited `test_a_holder_path_with_a_control_character_still_shows_its_merge`. The one new test that passes on main's code is `test_a_printable_name_is_named_byte_for_byte`, by design: a printable name is named byte for byte on both. An earlier draft of this body said 21 failed, 2 passed. That run was on `8062810d` with a wider selection, and it held a scaffold row for ESC in the local remote dir's name (`ESC-in-the-local-remote-dir-name`) that this head's tests no longer have; its second pass was the existing `test_the_refusal_quotes_gh_s_first_line_and_escapes_what_is_unprintable`, which also passes in the new run, one of the 87. `family.py init` and `scaffold-project.py --dry-run` against a file named `wd-ESC[31mRED`, a file named `wd-\nWARNING forged-line: pwned`, and a work dir with a file `Atlas` or `Orbit` in it, run with a stub `gh` and no real `gh`: - On main, through `cat -v`: `... the family folder /.../wd-^[[31mRED/Orbit cannot be made: /.../wd-^[[31mRED is a file, not a folder`, and a stderr line `WARNING forged-line: pwned cannot be made: ...` of its own. - On this head: `REFUSED family-folder-cannot-be-made: the family folder '.../wd-\x1b[31mRED/Orbit' cannot be made: '.../wd-\x1b[31mRED' is a file, not a folder`, and one line for the forged name: `--work-dir '.../wd-\nWARNING forged-line: pwned' cannot be made: '...' is a file, not a folder`. `scaffold-target-exists` and `family-target-exists` are one line each in the same form. Each spelled site reverted alone in a throwaway clone at `19d126cd` (the call sites are the same at `ab55c29f`); the new tests that fail for each: - `folder_cannot_be_made`, the `_not_a_folder` answer: 8 failed (six CLI rows and the two unit tests). - `folder_cannot_be_made`, the `CANNOT_WRITE_IN` answer: 5 failed (the four unwritable rows and the unit test). - `refuse_unmakeable_folder`: 12 failed. - `make_folder`: 2 failed (the unit test only). - `scaffold-target-exists`: 2 failed (its two rows). - `family-target-exists`: 2 failed (its two rows). - `family-operation-in-progress`: 3 failed (line break, ESC, forged line). `git merge-tree --write-tree` of this head `ab55c29f` against origin/main `20c8873b` and against every ref on origin that could meet it, fetched 2026-10-16: the 41 branches (`fix/*`, `l3-*`, `wip/*`, `docs/*` and `release/v1.0`) and the 55 PR heads from #630 up. That is 96 refs. (The same list was run for #730's head `7f2bb36b`.) - **origin/main `20c8873b`: clean** (the predicted tree `00b5b24c`, which differs from main in these five files only: 216 lines added, 25 removed). - **#630's head `6fd33a93`: clean.** It conflicted with main in `docs/handbook.html` when this body was first drafted, and no longer does, with or without this change. This change touches no README or handbook. - **The refs that edit one of this change's five files**, and so could meet it, all merge clean with this head: `fix/646`, `fix/651`, `fix/654`, `fix/660`, `fix/661`, `fix/684`, `fix/686`, `fix/688`, `fix/689` and `fix/693`, the heads of #670, #725, #728, #743 and #744, and the heads of the landed #634, #638, #639, #640, #643 and #671. #634 has landed (`30676c90`), so the `fix/660` note about a conflict with it is gone. - **Four refs conflict with origin/main on their own**, and so with this head: `docs/release-notes-v1.0.0` and `release/v1.0`, and the pre-squash heads of the landed #637 and #641. Their conflicts do not come from this change. Every other ref, 92 of the 96 in all, is clean with this head, including the refs that conflict with main in `templates/family-root/scripts/validate-family.py` (#682, #745, #747, `fix/648`, `fix/681`, `fix/704`) or in `shape-doctor.py` (#742, `l3-683`) on their own. So do the old heads of the landed #638 and #639, and lane 3's `l3-677-pins-hand-step-wip` branch, and each of them merges clean with this head too. - **Tests on the predicted merge**, run by the review on the merge with main `2fac9790`: `test_dry_run_parity`, `test_family_refuse_up_front`, `test_pinned_copy_text`, `test_pin_source_unreadable`, `test_dry_run_asks_remotes`, `test_family_dry_run` and `test_family_init_landing`: 445 passed in 215 s. `test_family`, `test_family_init_values`, `test_family_add_refusals`, `test_derived_id_refusals`, `test_repo_hygiene`, `test_naming_policy`, `test_scaffold_leg_path_collisions` and `test_scaffold_reuse_seeded_legs`: 758 passed, 19 skipped in 243 s. I did not re-run them on `20c8873b`; the merge is clean, and the two commits since `2fac9790` (#700 and #702) change only `shape-doctor.py`, `templates/assembly-root/scripts/validate-pins.py` and four test files, none of them a file of this change. - Before the second commit, `fix/660`, `fix/688` and `fix/693` each conflicted with this head in the import region of `scripts/family.py` or `scaffold-project.py` (a separate import line each side added at the same place). That is why the import joins the existing block. #634 is on main, and this change does not edit `shown_source` or its docstring in `scripts/shape_materialize.py`. The review approved with nits. Its S1, the stale base-run numbers and merge lines, is taken here, in this body: no code changed, and the head is still `ab55c29f`. Its N1, renaming the call sites to `shown_path`, is not taken (see "Decisions and their evidence"). It also re-ran 5 of the 7 mutation probes on `ab55c29f` and got the same failure counts as above (12, 2, 3, 2 and 2), and ran 144 hostile-name dry runs (16 names in 9 scenarios): on main 69 rows put a control character on stderr and 6 print a forged line of their own; on this head all 144 exit 2 with no traceback, no control character and no forged line. - `scripts/family.py` `init`'s plan prints `folder` and `landing` lines with the path raw (`print(f"landing {holder} ...")`, `print(f"folder {folder} ...")`). On a dry run or a refused run the plan is on stdout before the refusal, so `WARNING forged-line: pwned/Orbit (--work-dir: no family folder)` still appears there. It is the plan, not a refusal, and not one of the issue's lines. - Other refusals that name a holder or folder path raw, none of them in the issue's lines: `family-holder-detached` and `family-on-tracking-branch` (`{family.root}`), `family-index-locked` (`lock.as_posix()` in the detail), `family-landing-in-standard` (`{holder}`, `{checkout}`, `{beside / family / family}`), `family-holder-already-here` (`{into}`), and `family-remote-exists` and `scaffold-remote-exists` (`{target}`, a local remote path). 1. **family.py `init`'s plan prints the holder and folder paths raw.** `scripts/family.py` `landing` and `folder` rows (about :1756-1759) print `holder` and `folder` as they are, so a forged line or an ESC reaches stdout in a `--dry-run` and in a run refused after the plan. Direction: spell them with `shown_source`. 2. **The same raw spelling in the refusals listed above.** `family-holder-detached`, `family-on-tracking-branch`, `family-index-locked`, `family-landing-in-standard`, `family-holder-already-here`, `family-remote-exists` and `scaffold-remote-exists` each print a path the person supplied or git keeps. Direction: the spelling this change gives its sites. Review: rv692, lane 5's independent review, approved ab55c29 with nits, and its body fix (S1) is published. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 ab55c29 passed tests, tests-git-bash and SonarCloud; the head 13f643d is a clean update-branch merge of main 431c8cf, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 4327947 over main 431c8cf: the dry_run_parity, family_refuse_up_front, repo_hygiene, agents_md_codes, cli_reference sets, 330 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts". Closes #692 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Lane: openreposhape-5 (openRepoShape-5)
Claim: #607 (comment)
What changed and why
Fix round 1 (rv607)
Review rv607 of 6212c31 said CHANGES NEEDED: 0 blockers, 2 should, 7 nits. This round is one new commit, 66b1043, on top of 6212c31. No amend, and main is not merged in. Each point:
BRANCH_LEFTnow puts another name first and going back second (the text is under "L2-46" below). It is word for word the same inscripts/family.py, in the pinnedvalidate-family.pyand in the pinned test constant, and the comments that described the old order are rewritten. Two new tests type the printed line in the states the review probed:mainhas moved since the earlier act, and a commit was made on a detached HEAD._switch_lineremedy. A new test reads the validator's source withastand fails any remedy that names_switch_linewhose prose does not end withBRANCH_LEFT. On a merge of this head with Pinned shape copies: one bump, so AGENTS-shape.md is true of every root, the scaffold's chain WARNING offers only its own sources and spells--pin-source, the root's not-used note spells it too, and THE LOCKSTEP RULE'sbumpruns (#610) #634 it fails at_lockstep, which is the point: whichever of family.py and validate-family.py: where an earlier act left the branch, theswitch -cremedy says to give it another name, or go back only if it holds work not merged yet, and the docs sayfamily/add-<Project>(#607) #637 and Pinned shape copies: one bump, so AGENTS-shape.md is true of every root, the scaffold's chain WARNING offers only its own sources and spells--pin-source, the root's not-used note spells it too, and THE LOCKSTEP RULE'sbumpruns (#610) #634 lands second adds the sentence there. Pinned shape copies: one bump, so AGENTS-shape.md is true of every root, the scaffold's chain WARNING offers only its own sources and spells--pin-source, the root's not-used note spells it too, and THE LOCKSTEP RULE'sbumpruns (#610) #634's lines are not on this branch and are not touched.family.pycould ask; it says the validator's words so the two tools keep one rule. Decision 1 says so, and the new commit's body corrects the old one.adopt-bad-idnames an id derived from--projectas derived, so--project North_Windno longer says--id 'north_wind' must matchover a flag nobody passed (#601) #620, scaffold-project.py and family.py: both runs ask whether each folder they make can be made and take only gh's not-found answer as "absent", so--dry-runrefuses a file where a folder goes, or agh repo viewthat fails otherwise, by name as the real run does, and a refusedinitleaves no empty family folder (#609) #622, family.py:add,bumpandremoverefuse before their first write a merge, cherry-pick, revert, rebase orgit amin progress, a.gitmodules,members/orfamily.yamlthe sparse checkout leaves out, and a file namedmembers, which each failed after writing and was rolled back, or committed in the wrong place (#612) #626, 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 to shape-doctor.py: every validator child starts with-S, so no.pthin the user's or a virtualenv's site-packages stands in for therepo_shape.pya root lost, and themanifestrow is the gate's ownModuleNotFoundError(#342) #636, repo_shape.py and validate-repository-naming.py: a chain link, a declared pin and a--link-sourcekey are one product bypin_product_key, the pin file's own fold, so a tree declaringopenssno longer verifies a chain that saysopenß, a chain naming both is two links and not a cycle, and--link-source openss=no longer answers foropenß(#444) #638, 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 (lane 5's validate-family.py andfamily.py remove: a holder whose HEAD lost.gitmodulespasses the gate, andremovefails afterdeinit, leaving the mount empty #611) and Pinned shape copies: one bump, so AGENTS-shape.md is true of every root, the scaffold's chain WARNING offers only its own sources and spells--pin-source, the root's not-used note spells it too, and THE LOCKSTEP RULE'sbumpruns (#610) #634 (S2).-ctaken out)", so it keeps the printed quoting, and that the tests type both changes as printed.add", and adds the note about case-insensitive disks.initNEXT STEPS test read the lines with a regex whose--family-root \S+breaks on a root with a space in it (C:\Users\Jane Doe, or anytmp_pathwith a space). It now reads the words withhost_words, as the NEXT STEPS test beside it does. Run with a space in the base temp directory, the old test fails withAttributeError: 'NoneType' object has no attribute 'group'and the new one passes.GITLINK_REMEDY, a constant four cases share. Fixed under "Tests". The one new test that passes on BASE is disclosed there too.Fix round 2 (rv607b)
The re-verify rv607b of 66b1043 said APPROVE WITH NITS: 0 blockers, 0 should, 5 nits. This round is one new commit, a277695, on top of 66b1043. No amend, and main is not merged in. It changes comments and docstrings only, so no behaviour moves and no mutation row changes.
BRANCH_LEFTinscripts/family.pyand the one in the pinnedvalidate-family.pynow say a new branch starts where the holder stands now, "which works in every state". The threeBRANCH_LEFTtexts are unchanged and still equal._switch_linecall "however it is passed on". A switch line moved into a variable would defeat that. It now says "wherever the call stands inside the string expression that is passed on".test_another_name_works_where_main_has_moved_sincesaid going back to the old branch conflicts, but the test does not assert that. It now says the test shows the other name works, and that the conflict on the old branch is shown by rv607's probe and decision 2's table. No assertion is added.Checks on a277695: the diff to 66b1043 adds ten lines and removes seven, in three files, all comments and docstrings, and the
astof each file is equal once docstrings are blanked. The four filestest_family_switch_branch_exists.py,test_validate_family_branch_step.py,test_validate_family_unnamed_mounts.pyandtest_family_tracking_branch.pygive96 passed in 51.34s, andtest_repo_hygiene.pygives65 passed in 1.39s.L2-46, the fix
family.py add,bumpandremoverefuse the holder's tracking branch withgit -C <holder> switch -c family/<act>-<member>(#487). The holder'svalidate-family.pynames the same line before eachaddandremoveit prints (#539, landed as 54041cc). If an earlier act left that branch, the line fails withfatal: a branch named 'family/add-atlas' already exists(exit 128), and neither tool said what to do next.Both tools now add one sentence, in the same words (
BRANCH_LEFTin each file):scripts/family.py: the sentence comes on its own line, right under theswitch -cline, in thefamily-on-tracking-branchandfamily-holder-detachedrefusals (_checked_branch), and in the dry run'sbranchlines (_branch_said).templates/family-root/scripts/validate-family.py: the sentence ends the remedy text ofmember-gitlink-absent,member-duplicate-idandmember-gitlink-unnamed, the three remedies that print_switch_line. The command lines under them are unchanged.L2-45, rides along
AGENTS.md, the holder's README, its AGENTS-shape.md and
init's NEXT STEPS spelled the branchfamily/add-<project>, right beside--member <org>/<Project>. All four now sayfamily/add-<Project>, the one placeholder the validator already uses for both (PROJECT_PLACEHOLDER, #539).Decisions and evidence
1. What the remedy prints when the branch exists: one sentence that names the way out, not a second command line.
git switch X 2>/dev/null || git switch -c X): rejected. On Windows,family.pyquotes its lines for PowerShell (shell_lineusesos.name; README.md:155-158 says so). Windows PowerShell 5.1 has no||;&&and||arrived in PowerShell 7, andfamily.py's own_print_next_stepsdocstring says 5.1 rejects&&. PowerShell also has no/dev/null. And2>/dev/nullwould hide the real reason aswitchfails, such as a changed file it would overwrite.validate.ymlrunspython3 scripts/validate-family.py(and--no-members) in a runner's checkout, which holds none of the person's branches.family.pycould check, but then the two tools would follow two rules, and one rule in the same words was the aim. Sofamily.pysays the validator's words; it is not that it cannot ask.family.py, a newgit switchline would also be quoted for PowerShell on Windows. README.md:155-158 lists exactly whichfamily.pylines are quoted that way (NEXT,switch -c,add,git rm -r --cached). That sentence would become false, and fixing it would mean editing README § "Starting a project in a new organisation". Seven handbook parts record that section's hash (grep -cgives 7).-ctaken out), so it keeps the printed quoting. The tests type both changes as printed (tests below); none of them runs a shell, and Windows and macOS are not checked (see "Left out").2. Another name first, going back second (S1). The reviewer's probes, and mine, in real local holders (a holder
Orbit, membersAtlasandBeacon, local bare remotes, theghstub first on PATH):family/add-atlasgit switch family/add-atlas(going back)git switch -c family/add-atlas-2(another name)mainhas not moved (rv607)probe-atlas.sh,probe-b.sh)addthen commits on top of it, and the earlier commit is the parentgit branch --containsnamesfamily/add-atlasalone)mainhas moved: a later member (Beacon) was squash-merged (mine:probe-d.sh; testtest_another_name_works_where_main_has_moved_since)addexits 0, butgit merge-tree --write-tree main family/add-atlasexits 1 (CONFLICT (add/add)in.gitmodules,CONFLICT (content)infamily.yaml), and an untrackedmembers/Beaconis left (warning: unable to rmdir 'members/Beacon')addexits 0,git merge-tree --write-tree main family/add-atlas-2exits 0, and the validator says2 member(s)probe-c.sh)addsaysmember-already-present, whose remedy points atbumpandremove: a dead endaddexits 0, once the object storeremoveleaves behind is deleted, as its NOTE says (either choice needs that)probe-detached.sh; testtest_another_name_keeps_a_commit_made_on_a_detached_head)Warning: you are leaving 1 commit behind, not connected to any of your branches, andgit branch --containsnames no branchgit branch --containsnames the new branchSo going back is right only where the branch holds work not merged yet, and another name is right in every state but that one. The sentence says so, in that order.
3. The person's earlier work on that branch is never discarded.
git switch <branch>keeps the branch's commits. It refuses to switch when that would overwrite a changed file. The act then commits on top of what the branch holds; the tests check that the earlier commit is the parent of the act's commit. "Give the branch another name" leaves the old branch alone. No printed line namesswitch -C(which resets a branch),branch -D,resetor--discard-changes; a test checks that too.4. The placeholder:
<Project>everywhere.family/add-<Project>beside--member <org>/<Project>(Pinned validators: THE LOCKSTEP RULE only where it applies, and remedies that name thegit switch -cstep (one shape bump) #539's fix round, pinned bytests/test_validate_family_unnamed_mounts.py). It is the only spelling that is one placeholder for both values.tests/test_repo_hygiene.py:633). This change keeps it at 492.add._checked_branchtakes any branch but the tracking one, sofamily/add-IRQS, the doc line filled in as written, is a branchaddcommits on (test). Where the tools know the name, they still print it lowercased (_suggested_branch). On a case-insensitive disk (macOS, Windows)family/add-Atlasandfamily/add-atlascan be one loose ref there. That is untested: CI is Linux only.5. Pinned copies. This changes two pinned holder files:
templates/family-root/scripts/validate-family.pyandtemplates/family-root/AGENTS-shape.md(scripts/shape_materialize.pylists the pinned set). Each holder takes them as twoupstream-changedrows on its nextupdate-shape.pyrun. The holder's README is rendered once and is not pinned. No test pins those files' bytes; the tests that compare them read the template at run time. Fix round 1 changes onlyvalidate-family.pyof the two.Tests
New file
tests/test_family_switch_branch_exists.py, 25 tests (21 before the fix round). Four are new in the fix round:test_another_name_works_where_main_has_moved_since(S1, state (d)):family/add-irqsis left at themainof its day; a later member (IRXS, scaffolded in the test) is added on a branch and squash-merged ontomain, somainhas one new commit; the refusal'sswitch -c, typed as printed, fails withalready exists(exit 128); the same line with another name works;addexits 0,git merge-tree --write-tree main <branch>exits 0, and the validator passes.test_another_name_keeps_a_commit_made_on_a_detached_head(S1): the refusal isfamily-holder-detached; after the printed line with another name,git branch --contains <the commit>names the new branch and no other.test_every_remedy_that_names_a_switch_line_ends_with_the_sentence(S2, the guard) andtest_the_guard_sees_a_remedy_left_without_the_sentence(its control: the source with the sentence taken out of any one remedy fails it, and so does a fourth remedy built by a helper as_lockstepbuilds its, until the sentence ends it).The other 21:
test_the_refusal_typed_as_printed_puts_the_act_on_its_branch[add, bump, remove] x [no-branch, branch-left]: the refusal, then its line typed as printed. With no branch, theswitch -cmakes one. With a branch an earlier act left, holding a commit of its own, theswitch -cfails withfatal: a branch named '<branch>' already exists(exit 128) and changes nothing, and the same command withswitchin place ofswitch -cgoes back to that branch. The act then commits on top of the earlier commit, and NEXT pushes that branch.test_a_dry_run_on_the_tracking_branch_says_it_too[add, bump, remove].test_a_missing_gitlinks_remedy_typed_as_printed,test_a_duplicate_ids_remedy_typed_as_printed(with theremoverun as printed) andtest_an_unnamed_gitlinks_remedy_typed_as_printed, each in both states.test_the_two_tools_say_the_same_words.test_each_doc_spells_the_branch_as_its_member[AGENTS.md, AGENTS-shape.md, holder README],test_init_next_steps_spell_the_branch_as_its_member(read withhost_wordsnow, N6), andtest_the_branch_filled_in_as_written_is_one_add_commits_on.Two existing test functions take the new text:
test_init_next_steps_make_the_branch_before_the_add(<Project>) andtest_a_missing_gitlink_remedy_makes_the_branch_and_names_the_holder(the remedy line).GITLINK_REMEDYintest_validate_family_unnamed_mounts.py, a constant four cases share (test_a_member_mounted_by_hand_is_two_findingsx 3 andtest_a_removed_row_is_named_by_its_gitlink_and_its_section), ends with the sentence. The sentence is pinned once, as text, intests/test_validate_family_branch_step.py.Before and after, with the four changed test files copied in (
run9.sh, theghstub first on PATH):30 failed, 66 passedin the four files. 24 of the 25 new-module tests fail. The one that passes,test_the_branch_filled_in_as_written_is_one_add_commits_on, is the evidence for decision 4: it shows behaviour this PR keeps.23 failed, 56 passedin the three files that pin the sentence. Both new S1 tests fail there, where they assert the new wording. The two guard tests pass there: they check where the sentence stands, not its words.25 passed.Targeted runs on the head 66b1043, BASE 396c0ff
tests/test_family*.pyandtests/test_validate_family*.py, plustest_bump_leg.py,test_bump_leg_dry_run_tracking.py,test_bump_leg_dry_run_truth.py,test_bump_leg_paste_lines.pyandtest_update_shape_branch_any_directory.py. Result:1016 passed, 20 skipped in 511.01s.test_repo_hygiene.py,test_agents_md_codes.py,test_readme_sections.pyandtest_cli_reference.py. Result:210 passed in 15.73s. The fix round changes no template other thanvalidate-family.py's text, so the rest of set B (the 27 files of the first round) is not re-run.test_family_init_values.py.Reproductions (local bare remotes, the
ghstub first on PATH,GH_TOKEN=bogus-l5)A real holder
Orbit, made withfamily.py init, and a memberAtlas, scaffolded withscaffold-project.py --local-remote-dir.addrefuses withswitch -c family/add-atlas. Typed as printed, that givesfatal: a branch named 'family/add-atlas' already exists, exit 128, and the remedy says nothing more. The validator'smember-gitlink-absentremedy prints the same line, with the same result.probe-atlas.sh, a branch holding a commit of its own): the refusal prints the new sentence. The same command withswitchforswitch -cgivesSwitched to branch 'family/add-atlas', exit 0, at the earlier commit.addthen exits 0, its NEXT pushesfamily/add-atlas, and its commit's parent is the earlier commit. The validator carries the same sentence.mainmoved (probe-d.sh): the printed line fails (128). With another name:addexits 0,git merge-tree --write-tree main family/add-atlas-2exits 0,family ok: Orbit (orbit), 2 member(s). Going back instead:addexits 0, andgit merge-tree --write-tree main family/add-atlasexits 1 with the two conflicts in decision 2.probe-detached.sh): the printed line fails (128). With another name,git branch --containsnamesfamily/add-atlas-2. Going back withswitch: git warns it is leaving 1 commit behind, and no branch contains it.No row needs real GitHub.
Mutation probes
Each change below was made alone in a scratch clone of the head, then the new module was run (and, for the validator changes, the two other files that pin the sentence), and the file was put back. Every change that alters behaviour makes at least one new test fail:
family.pyBRANCH_LEFTback to the first round's wordstest_the_two_tools_say_the_same_wordsfamily.pyBRANCH_LEFTone clause dropped (only if)_checked_branchremedy with no sentenceswitch -cline, not under it_branch_said(dry run) with no sentencetest_a_dry_run_on_the_tracking_branch_says_it_too[add, bump, remove]_switch_commandprintsswitch -Calready existsBRANCH_LEFTback to the first round's wordstest_the_two_tools_say_the_same_wordsamong themmember-gitlink-absentwith no sentencemember-duplicate-idwith no sentencemember-gitlink-unnamedwith no sentenceGITLINK_REMEDYcases, the guard and its control_switch_lineand does not end with the sentence (built by a helper, as #634's_lockstepdoes){BRANCH_LEFT}initNEXT STEPS branch back to<project>; its--memberlowercasedtest_init_next_steps_spell_the_branch_as_its_member<project>(and AGENTS.md's--member)test_each_doc_spells_the_branch_as_its_memberfor that docThe other hunks change only docstrings and comments (
_checked_branch,_branch_said,_print_next_steps,_switch_line, and the comments above the two constants). They change no behaviour, so no test can catch reverting them.Merge checks (
git merge-tree --write-tree 66b10431 <ref>, refs fetched read-only)test_family_bump_to_local_only.py:1024 passed, 20 skipped in 500.52sadopt-project.pyin the first round; it no longer does)scripts/family.py)scripts/family.py)_lockstepprints a fourthswitch -c)test_validate_family_branch_step.pyand #634'stest_pinned_copy_text.pygive2 failed, 86 passed. The two failures are the guard,assert [124] == [](line 124 of the merged file is_lockstep's return), and its control. With one line added to_lockstep,f"moving one alone is what this refuses. {BRANCH_LEFT}"in place of the plain string, the same merge gives306 passedover the nine files that read the validator or the lockstep rule (test_validate_family*.py,test_family.py,test_family_tracking_branch.py,test_pinned_copy_text.pyand the new module). So the second PR to land needs that one line and nothing else.member-gitlink-unnamedremedy text invalidate-family.py(_check_gitlinks_named), and theGITLINK_REMEDYconstant intests/test_validate_family_unnamed_mounts.py. #611 adds "and commit that removal, sincefamily.py addrefuses ..." and this PR appendsBRANCH_LEFT. Resolved as in the first round, keeping #611's clause and appending{BRANCH_LEFT}in the validator and+ BRANCH_LEFTin the test constant, in a committed merge (92602308):scripts/family.pymerged cleanly, and the six test files that either PR changes or pins gave157 passed. Neither branch was resolved.Left out, and why
||line and no state check: see decision 1.switch -cininit's NEXT STEPS gets no sentence: a holderinitjust made has no branch an earlier act could have left.family/add-atlasis checked out in another worktree of the holder,git switch family/add-atlasgivesfatal: 'family/add-atlas' is already used by worktree at '<path>'(exit 128), so the going-back half of the sentence does not work there. Git names the worktree, and another name works (probe-n1.sh:switch -c family/add-atlas-2exits 0). The sentence is not made longer for it.family.py's lines withhost_words, which parses PowerShell's quoting on Windows. They make the earlier branch withcommit-treeandupdate-ref, movemainwith the same two, write no file withwrite_textand compare nothing CRLF-sensitive.test_another_name_works_where_main_has_moved_sincerunsgit merge-tree --write-tree, which needs git 2.38 or newer. No CI job checks Windows or macOS.Checklist candidates (not fixed here)
git switch -c bump/<role>-<sha12>remedy failsalready existswhen an earlier bump left that branch._switch_command(scripts/bump-leg.py:1124) prints the line for the two refusals and the dry run's closing hint. Like family.py and validate-family.py: thegit switch -c family/<act>-<member>remedy fails when an earlier act left that branch, and the docs spell the branch word two ways #607, it says nothing about a branch that is already there.git -C <holder> switch -c family/add-<project>line has the same gap. The NEXT lines a scaffold into a family folder ends with (setup-project.py:2358) print it with no word on an existing branch. It matters only when an earlier attempt left that branch.#459 lines this covers
Closes #607
🤖 Generated with Claude Code
Summary by Sourcery
Clarify recovery from previously existing family branches and align the associated tooling, templates, documentation, and tests.
Bug Fixes:
Enhancements:
Documentation:
Tests: