Skip to content

family.py: add, bump and remove refuse before their first write a merge, cherry-pick, revert, rebase or git am in progress, a .gitmodules, members/ or family.yaml the sparse checkout leaves out, and a file named members, which each failed after writing and was rolled back, or committed in the wrong place (#612) - #626

Merged
brettheap merged 4 commits into
mainfrom
fix/family-verbs-refuse-up-front
Oct 16, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #612 (comment)

What changed and why

add, bump and remove each end in ONE git commit -- <paths>, made after the verb's first writes (add mounts the member, bump checks it out at --to, remove unmounts it) and put back by a rollback if it fails. Three states of the holder made it fail, or land in the wrong place, every time. Each is now asked once, before the first write and under --dry-run alike, so the two runs refuse in the same words. The source lines are on #459 (issuecomment-6192725913). Fix round 1 (R612's review of 54c30a8) is folded in below.

An act git has unfinished (571(e)). _checked_no_operation_in_progress, called by all three verbs beside their other early refusals.

  • It looks for the holder's MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD, rebase-merge/, rebase-apply/ and sequencer/, each where git rev-parse --git-path says git keeps it, so a linked worktree is right too. ONE git rev-parse answers every mark; where the lines and the names disagree (a holder whose path holds a newline) each name is asked alone and its whole answer is kept, so a merge in a linked worktree under such a path is found.

  • It refuses family-operation-in-progress, exit 2, nothing written. The detail names the act and what the verb's commit would do in it. The remediation gives that act's own exit: git commit or git merge --abort; git cherry-pick, git revert, git rebase or git am, each with --continue or --abort. Then: run the same command again.

  • It is asked before the branch refusal and before every refusal that reads the holder, so a rebase is named as a rebase. A rebase is asked before a merge begun inside it (a merge at a rebase's edit stop leaves MERGE_HEAD and rebase-merge/ both; git rebase --abort clears both, git merge --abort would not), and a test holds the order.

  • Measured on git 2.43, each stopped on a conflict (a test makes the verb's partial commit with git itself, so each sentence is tied to git):

    act what git does with git commit -- <paths> what the refusal says
    merge (also --no-commit, with no conflict) refuses: fatal: cannot do a partial commit during a merge., after the writes, then the rollback the same, and that the writes are put back
    cherry-pick refuses, the same way the same
    revert makes the commit on the branch as the verb's own, and git takes it as the END of the revert: REVERT_HEAD goes, git revert --continue and --abort then say no cherry-pick or revert in progress, the revert's changes are left half done in the index and the work tree exactly that
    rebase makes the commit on the HEAD the rebase detached, in the middle of its sequence: git rebase --continue replays the rest on top of it into the rebased branch, git rebase --abort drops it. The real run met family-holder-detached first, which committed nothing, and whose git switch -c is wrong advice there; the dry run printed a plan exactly that
    git am makes the commit on the branch, in the middle of the patches the same
    cherry-pick or revert SERIES, only sequencer/ left makes the commit in the middle of the series: --continue replays the rest on top of it, --abort exits 0 with You seem to have moved HEAD. Not rewinding and keeps it the same
  • A series (fix round 1, F2). git cherry-pick A..B and git revert A..B keep sequencer/ for the steps to come. Once the stopped step is committed by hand no CHERRY_PICK_HEAD or REVERT_HEAD is left, though git status still says the act is in progress. It is named from the first word of sequencer/todo, as git status names it: pick or p, a cherry-pick; revert, a revert; with that act's --continue and --abort. A todo that is missing, empty or no series reads as nothing in progress to git status and --continue fails on it (exit 128), while --abort and --quit exit 0: it is refused as "a cherry-pick or revert series", naming only git cherry-pick --abort (git revert --abort is the same) and git cherry-pick --quit.

  • N4 is NOT taken (falls back). Asking about a merge before Family.open parses family.yaml would make git run before the refusal of a hand-edited path:, which test_family_member_path.py::test_the_refusal_is_made_before_any_git_call[remove|bump] holds to no git command at all (it failed 2 of 2 when tried). The comments that said the check "is asked first" now say what is true. A merge conflicted in family.yaml itself still reads yaml-unparsable (the <<<<<<< is read as a YAML merge key), with no remediation: left for Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459. An option for it: ask only when the parse fails.

A path the sparse checkout leaves out (571(d), 574/5). _checked_inside_sparse_rules.

  • It asks git sparse-checkout check-rules about the paths the verb stages: .gitmodules, members/<Project> and family.yaml for add and remove; members/<Project> and family.yaml for bump. So bump is covered, at no extra cost. remove also stages the gitlink, through git rm, which fails the same way (measured: deinit ran, then git rm refused).
  • family.py remove, in a holder whose work-tree .gitmodules has been deleted and the deletion not committed, commits that deletion with the member's removal, so every other member's section leaves HEAD while remove exits 0 and the holder's validate-family.py passes, and once pushed a recursive clone exits 0 with each remaining member's directory empty and its own validate-family.py refusing member-uninitialized #407's gitmodules-sparse reads the skip-worktree MARK, which git clears for a file put back by hand, so the rules are asked instead. gitmodules-sparse keeps precedence where its mark is set, and every existing test of it passes unchanged.
  • It refuses family-sparse-excluded, naming the command that brings the path back and git sparse-checkout disable.
  • The pattern (fix round 1, F3, N7). The mode is read as a listing, git config -z --list, key core.sparsecheckoutcone (never git config --get: test_family_dry_run.py counts any git config that is not a listing as a write); unset is non-cone.
    • Cone mode: git sparse-checkout add members. members/<Project> is refused there for a gitlink git tracks.
    • Non-cone rules: git sparse-checkout add members/<Project>, no leading slash, anchored by its inner slash. The bare members of round 0 exited 0 and left the path out under rules that name the member (!/members/<P>, !/members/*), so the verb was refused again with the same remedy: a loop.
    • A root file (.gitmodules, family.yaml) can only be out of non-cone rules (in cone mode git keeps every root file inside, measured). It is named UNANCHORED: it wins over !/.gitmodules, !.gitmodules and !/.* (tested) and over !/* (the review), and meets no Git Bash conversion of a leading slash. git prints a warning for it about how far a slashless pattern reaches; nothing else in a holder's root is named so.
  • It is asked only where git sparse-checkout list says sparse checkout is on. git sparse-checkout disable turns the setting off and leaves .git/info/sparse-checkout, and check-rules still reads it: a holder that is no longer sparse would be refused for rules git no longer applies. A test holds this.

A FILE named members (574/5). _members_is_a_file, called by _checked_mount_path before its other questions.

  • It refuses member-path-unaddable, reusing the code of a mount path git will not take. Its remediation already says members/ must be a plain directory, and a file is the third way it is not, after a link and a submodule.
  • It says the file is tracked or untracked, and names the move: git mv with a commit first where git tracks it, mv where it does not. Left staged, the move is not left alone: the add's commit takes the deletion half into itself (A .gitmodules, M family.yaml, D members, A members/<Project>) and leaves A members-file staged alone.
  • Git's own dry run does not refuse it, so the member was read and cloned first.

Fix round 1, the rest. F1 (the revert and rebase sentences, above) and F4 (the two S9073 asserts: one went with the test it sat in, one is split). N1: the merge-only test is folded into assert_refused_alike (which now also checks the act's mark is still there) and a control the control test covers is dropped. N3: _members_is_a_file's account of why the move is committed first. N8: one rev-parse call, with a test that counts it.

Tests

New file tests/test_family_refuse_up_front.py, 77 tests, on the harness test_family.py uses. Nothing existing is edited.

  • Operation in progress. Each of eight acts and states (merge --no-commit, cherry-pick, revert, rebase, am, a cherry-pick series, a revert series, and an unreadable sequencer/), under each verb, real and --dry-run: exit 2 under the code, nothing on stdout (so no member was read), HEAD, index, family.yaml, work tree and config as they were, the act's mark still there, the two runs' words identical. Each act's exit typed as printed (--continue for the two series and the merge's git commit, --abort for the rest), then the same command makes its one commit. The premise test for every effect sentence. A merge begun inside a rebase. A merge in a linked worktree, and in one whose holder's path holds a newline (POSIX only). One git rev-parse for all the marks.
  • Sparse. .gitmodules put back by hand under three rules that leave it out (anchored, unanchored, every dotfile): remove refused before deinit, mount untouched, the printed pattern typed, then remove goes through. The mount path out under cone rules and three non-cone shapes (!/members/, !/members/<P>, !/members/*), for add and for bump and remove, each typing the printed command and then running the verb. Cone mode refusing only the mount path; all three paths out, one command bringing them back; a disabled sparse checkout.
  • A file named members, tracked and untracked, real and dry: refused before the member is read, the move made as printed, then add mounts.
  • Controls. No act and no sparse checkout, each verb runs as before; sparse checkout on and keeping every path.
  • Cannot tell. Rules git cannot read; and a git first on PATH that answers unknown subcommand to check-rules (POSIX only): remove in the 571(d) state is then NOT refused and fails at its commit as before, with its rollback restoring the holder.

Run over the committed merge result (head 207d4e1d, scripts/family.py sha256 442a0f0b…, the test file e1529a4f…): tests/test_family_refuse_up_front.py, tests/test_family.py, the other 17 tests/test_family_*.py and tests/test_family_bump_to_local_only.py (#625), tests/test_commit_trailers.py, tests/test_refusal_default_remediation.py, tests/test_template_text_placeholders.py, tests/test_validate_family_branch_step.py, tests/test_validate_family_unnamed_mounts.py, tests/test_repo_hygiene.py, tests/test_cli_reference.py, tests/test_agents_md_codes.py: 1330 passed, 19 skipped, 0 failed in 13m49s. The skips are all in tests/test_family_init_values.py (a template that quotes its placeholder or id:); none of the new file's tests skipped. And tests/test_shape_doctor.py -k "members or family" (which runs family.py add): 36 passed, 1 skipped (the PowerShell line, Windows only). The full suite was not run.

Fix round 2 (head 04ad53af; scripts/family.py sha256 6d8dc89e…, the test file d708eccc…): tests/test_family_refuse_up_front.py, tests/test_family_bump_to_local_only.py, tests/test_family_member_path.py, tests/test_family_dry_run.py, tests/test_family_remove_gitmodules.py and tests/test_repo_hygiene.py: 289 passed, 0 skipped, 0 failed, 4m02s. Main was not merged this round: 4cf5fb07 touches neither scripts/family.py nor any tests/test_family* file since 8f453d0e.

Mutants, each over the new file (76 tests; 77 for the last row), on the fix's code before its last docstring sentence and before main was merged:

mutant result
remove the operation check 31 failed. The merge tests fail at the commit, as before: fatal: cannot do a partial commit during a merge.
check only MERGE_HEAD 15 failed: every cherry-pick, rebase, revert and am case and the rebase-and-merge order case; the merge cases pass
skip the sparse check 24 failed
skip the members-is-a-file check 4 failed
look for the marks in .git/<name> instead of git rev-parse --git-path 2 failed: the linked worktree and the one-call count
ask the operation check in add's real run only 8 failed, the dry run's refusal among them
drop the git sparse-checkout list gate 3 failed: the disabled sparse checkout, each verb
drop the sequencer mark (F2) 10 failed: every series case
always name members (F3) 13 failed: every non-cone mount row, and the three-paths case
ask MERGE_HEAD before the rebase marks (N2) 3 failed: the rebase-and-merge case, each verb
one rev-parse per mark (N8) 1 failed: the one-call count
the fallback keeps only the first line of a split answer (V-F1), run on fix round 2's code 1 failed: the newline-path linked worktree

What it cannot tell

  • A git with no git sparse-checkout check-rules (an unknown subcommand, exit 129), or one whose check fails on the rules themselves (the file is missing): "cannot tell". Nothing is refused, and the late failure and its rollback stay as they were. The operation check and the members file check need no such git.
  • A git that answers check-rules differently from its own git add or git rm. On git 2.43 they agree for gitlinks, in cone and non-cone rules, with and without a trailing slash on the pattern (measured); that is the one git run here.
  • Windows and macOS were not run (CI is Linux only now). Reasoned instead: paths go to git on stdin as bytes, files are written as bytes, and the git-shim test is POSIX only. The root-file patterns carry no leading slash for that reason.
  • A stale mark. A real stale rebase-merge/ with its files is cleared by git rebase --abort. An EMPTY one is refused as a rebase too (git status agrees), but git rebase --abort FAILS there (could not read '.git/rebase-merge/head-name') and git rebase --quit clears it. The refusal names --abort; recorded for Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459 (R612's E5).
  • A merge conflicted in family.yaml reads yaml-unparsable before this check can name it (N4 above); Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459.

Shared files

Closes #612

🤖 Generated with Claude Code

Summary by Sourcery

Make family add, bump, and remove refuse unsafe repository states before performing any writes.

New Features:

  • Add early validation to prevent family add, bump, and remove operations from running while Git operations are unfinished, required paths are excluded by sparse checkout, or members is a file.

Bug Fixes:

  • Prevent partial writes, misplaced commits, and late rollback failures caused by merges, cherry-picks, reverts, rebases, git am, sparse checkout exclusions, and an invalid members path.
  • Keep real and dry-run refusals consistent by detecting these invalid repository states before any writes or member reads.

Enhancements:

  • Handle operation state correctly across linked worktrees, multi-commit cherry-pick and revert sequences, git am, and sparse checkout cone and non-cone modes.
  • Provide actionable refusals that identify the active Git operation or excluded paths and suggest appropriate recovery commands.

Tests:

  • Add comprehensive coverage for operation-in-progress detection, sparse checkout behavior, linked worktrees, newline-containing paths, invalid members files, dry-run parity, recovery commands, and fallback behavior when Git cannot evaluate sparse rules.

… a merge, cherry-pick, revert, rebase or `git am` in progress, a `.gitmodules`, `members/` or `family.yaml` the sparse checkout leaves out, and a file named `members`, which each failed after writing and was rolled back, or committed in the wrong place (#612)

`add`, `bump` and `remove` each end in ONE `git commit -- <paths>`, made
after the verb's first writes and put back by a rollback if it fails. Three
states of the holder made it fail, or land in the wrong place, every time.
Each is now asked before the first write and under `--dry-run` alike, so the
two runs refuse in the same words.

AN ACT GIT HAS UNFINISHED (571(e)). `_checked_no_operation_in_progress` looks
for the holder's MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD, rebase-merge/ and
rebase-apply/ where `git rev-parse --git-path` says git keeps them, which is
right in a linked worktree too, and refuses `family-operation-in-progress`:
the detail names the act and what the verb's commit would do in it, the
remediation that act's own exit (`git commit`, `--continue` or `--abort`). It
comes before the branch refusal and before every refusal that reads the
holder. Measured on git 2.43: a merge and a cherry-pick fail the partial
commit after the writes and the rollback puts them back, but a revert, a
rebase and a `git am` do NOT fail it, so the commit was made inside the act,
and the real run met a rebase as `family-holder-detached`, whose `git switch
-c` is the wrong advice there, while its dry run printed a plan.

A PATH THE SPARSE CHECKOUT LEAVES OUT (571(d), 574/5).
`_checked_inside_sparse_rules` asks `git sparse-checkout check-rules` about
the paths the verb stages: `.gitmodules`, `members/<Project>` and
`family.yaml` for `add` and `remove`, the last two for `bump`. #407's
`gitmodules-sparse` reads the skip-worktree MARK, which git clears for a file
put back by hand, so the rules are asked instead, and `family-sparse-excluded`
names `git sparse-checkout add members` (or `/.gitmodules`) and `git
sparse-checkout disable`. It is asked only where `git sparse-checkout list`
says sparse checkout is on: `disable` leaves the rules file behind and
check-rules still reads it. In cone mode git keeps every root file inside, so
only the mount path can be refused there. A git whose check-rules fails is
"cannot tell": nothing is refused, and the late failure and its rollback stay.

A FILE NAMED `members` (574/5). `_checked_mount_path` refuses it as
`member-path-unaddable`, the code of a mount path git will not take, before
the member is read, with the move that makes room (`git mv` and a commit
where git tracks the file, `mv` where it does not).

48 tests in tests/test_family_refuse_up_front.py: each verb under a merge,
the five acts, the linked worktree, the sparse cases in cone and non-cone
mode, a disabled sparse checkout, a git that cannot tell, and the file, each
with its remedy typed as printed.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 15, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR moves three classes of known late or misplaced failures ahead of the first write for add, bump, and remove: active Git operations, sparse-excluded staged paths, and a file occupying members; it adds operation-aware remediation and extensive tests confirming real/dry-run parity, linked-worktree handling, rollback safety, and successful recovery.

Sequence diagram for upfront operation-state refusal

sequenceDiagram
    participant User
    participant Family as family.py
    participant Git
    participant Holder as Family root

    User->>Family: add, bump, or remove
    Family->>Git: rev-parse --git-path operation marks
    Git-->>Family: mark location
    Family->>Holder: Check unfinished operation mark
    alt Operation in progress
        Family-->>User: Refusal with finish and abort commands
        Note over Family,Holder: No member read, write, or rollback
    else No operation in progress
        Family->>Family: Continue existing refusal checks
        Family-->>User: Execute or report dry-run plan
    end
Loading

Flow diagram for upfront family command refusals

flowchart TD
    Start["add, bump, or remove"] --> Operation["_checked_no_operation_in_progress"]
    Operation -->|unfinished merge, cherry-pick, revert, rebase, or git am| RefuseOperation["Refuse family-operation-in-progress"]
    Operation --> Mount["_checked_mount_path"]
    Mount -->|members is a file| RefuseMembers["Refuse member-path-unaddable"]
    Mount --> Sparse["_checked_inside_sparse_rules"]
    Sparse -->|staged path excluded| RefuseSparse["Refuse family-sparse-excluded"]
    Sparse --> Continue["Continue to existing branch, member, and write steps"]
    RefuseOperation --> End["No writes; same result in real and dry-run"]
    RefuseMembers --> End
    RefuseSparse --> End
Loading

File-Level Changes

Change Details Files
Add pre-write detection for unfinished Git operations so family mutations cannot commit into or conflict with an active operation.
  • Detect merge, cherry-pick, revert, rebase, and git am state using Git-resolved paths, including linked worktrees.
  • Emit operation-specific refusal codes, effects, continuation commands, abort commands, and consistent dry-run behavior.
  • Run the check before branch and holder refusals for add, bump, and remove.
scripts/family.py
Validate sparse-checkout coverage for every path each mutation stages before performing writes.
  • Query sparse-checkout state and check-rules using NUL-delimited paths, while treating unavailable or unreadable rules as cannot-tell.
  • Cover .gitmodules, the member gitlink, and family.yaml according to each verb’s staged paths.
  • Report excluded paths with patterns or disable guidance and preserve existing gitmodules-sparse behavior.
scripts/family.py
Refuse a top-level members file before add reads or mounts the member.
  • Distinguish tracked and untracked files and provide the appropriate git mv or mv remediation.
  • Reuse member-path-unaddable and integrate the check into existing mount-path validation.
scripts/family.py
Add comprehensive regression coverage for early refusal, remediation, rollback preservation, and compatibility controls.
  • Test all verbs in real and dry-run modes across Git operations, sparse modes, linked worktrees, and tracked or untracked members files.
  • Verify refusal wording, exit status, untouched holder state, typed remediation, and successful retry.
  • Cover sparse-checkout controls, unavailable check-rules, disabled sparse checkout, and existing behavior without the new conditions.
tests/test_family_refuse_up_front.py

Assessment against linked issues

Issue Objective Addressed Explanation
#612 Have add, bump, and remove refuse before their first write when a merge, cherry-pick, or rebase is in progress. ✅
#612 Have remove refuse before writing when sparse-checkout rules exclude .gitmodules, including when the file has been put back on disk. ✅
#612 Have add refuse before reading or mounting a member when members is a file or sparse-checkout rules exclude the member path. ✅

Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

Brett Heap and others added 2 commits October 15, 2026 22:52
… git does, a cherry-pick or revert series with only `sequencer/` left is refused, the mount pattern brings the path back under non-cone rules that name it, and the root-file patterns are unanchored (#612)

R612's review of 54c30a8 asked for four fixes, and the lane took most of
its nits.

F1. Two refusal sentences were false as measured on git 2.43. In a revert
the verb's commit lands on the branch as the verb's own and git takes it as
the END of the revert (`REVERT_HEAD` goes, `--continue` and `--abort` then
say `no cherry-pick or revert in progress`, the revert's changes are left
half done). In a rebase it lands on the detached HEAD, in the middle of the
sequence: `--continue` replays the rest on top of it into the rebased branch
and `--abort` drops it. `test_what_each_refusal_says_the_commit_would_do_is_
what_git_does` ties every sentence to git by making the partial commit with
git itself.

F2. `git cherry-pick A..B` and `git revert A..B` keep `sequencer/` for the
steps to come. Once the stopped step is committed by hand no
`CHERRY_PICK_HEAD` or `REVERT_HEAD` is left, `git status` still says the act
is in progress, and the verb's commit landed in the middle of the series.
`sequencer` is now asked after `REVERT_HEAD`, named from the first word of
`sequencer/todo` as `git status` names it (`pick` or `p`, a cherry-pick;
`revert`, a revert), with that act's `--continue` and `--abort`. A `todo`
that is missing, empty or no series reads as nothing in progress to `git
status` and `--continue` fails on it, while `--abort` and `--quit` exit 0: it
is refused generically, naming only what works.

F3. Under non-cone rules that name the member (`!/members/IRQS`,
`!/members/*`) the printed `git sparse-checkout add members` exited 0 and
left the path out, so the verb was refused again with the same remedy. The
mode is now read as a listing, `git config -z --list`, key
`core.sparsecheckoutcone`, and the pattern is `members/<Project>` under
non-cone rules and `members` in cone mode, where `members/<Project>` is
refused for a gitlink git tracks. N7: `.gitmodules` and `family.yaml` are
named unanchored, which wins over `!/.gitmodules`, `!.gitmodules` and `!/.*`
(measured) and meets no Git Bash conversion of a leading slash.

F4. Two S9073 asserts: one went with the test it sat in, one is split.

N1 folds the merge-only test into `assert_refused_alike` and drops a control
the control test covers. N2 pins that a rebase is asked first (a merge begun
at a rebase's stop leaves `MERGE_HEAD` and `rebase-merge/`, and `git rebase
--abort` clears both). N3 corrects `_members_is_a_file`'s account of why the
move is committed first. N8 asks every mark in ONE `git rev-parse`.

N4 is NOT taken. Asking about a merge before `Family.open` parses
`family.yaml` would make git run before the refusal of a hand-edited
`path:`, which `test_family_member_path.py::test_the_refusal_is_made_before_
any_git_call` holds to no git command at all. The comment that said the
check "is asked first" now says what is true, and a merge conflicted in
`family.yaml` still reads `yaml-unparsable`, left for #459.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brings in #625 (#613), which also changes scripts/family.py and four
tests/test_family_* files, and the commits after 77bb5f0. No conflict: this
branch's hunks in scripts/family.py are the new block after _branch_said,
_members_is_a_file, the mount-path file check and the call lines in cmd_add,
cmd_bump and cmd_remove, which #625's _checked_to_names_a_commit and
cmd_bump changes do not touch.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 15, 2026
…es in Git for Windows' own bash, so README and the handbook say Git Bash runs the one-line install, tested in CI but for a typed yes, which it cannot take there (#585) (#596)

**Fix round 7: main after #598 (3fb21d9) (head 52524fe).** Lane 3's #598 (#595) landed on main as 3fb21d9. It rewrote README step 3's `install.sh` paragraph and the handbook's masthead and `start` for `uv`, and for the two readinesses install.sh now ends with. It also changed `install.sh` and `tests/test_install_sh.py`, and added `tests/test_install_sh_filter_repo.py`. One merge commit, **52524fe3**, merges main 396c0ff: 55faad2 (#591), fbbbc8d (#572), 7fdec0a (#624), 3fb21d9 (#598) and 396c0ff (#570). No other change.
- **Conflicts.** README.md had 1 region (step 3's `install.sh` paragraph). docs/handbook.html had 8: the consent rules' `install.sh` item and the seven § Starting markers. `tests/test_install_sh.py` merged as text, and so did the handbook's masthead and `start`.
- **The head holds all of main.** `git merge-tree --write-tree 52524fe origin/main` gives 52524fe's own tree, 6d8ffea. `git diff --stat origin/main 52524fe` names only this PR's six files (436+, 147-).

**The README and handbook resolution with #598 (52524fe).**
- **README, before.** This PR (4c3b72a) had added "; in Git Bash it can take none" after step 3's "each behind a typed `yes` at your terminal". #598 rewrote the same paragraph: "It installs `git-filter-repo` with `pipx`, or `uv`, where it can, or prints the command, runs `openRepoShape --preflight`, and says last if the machine is ready to adopt or only to scaffold. `install.sh` never runs `sudo`; the preflight offers what is missing, each behind a typed `yes` at your terminal."
- **Fitting the cap.** Keeping both made nine lines where #598 has eight, and README was at its cap. The cap was not raised. Two rewordings fit it, and no fact is dropped:
  - "`install.sh` is attached to every release and installs the newest, with no `gh` and no login." became "`install.sh`, attached to every release, installs the newest with no `gh` and no login."
  - #598's "`install.sh` never runs `sudo`;" became "It never runs `sudo` itself;". "It" is install.sh, the subject of the sentence before it, and "itself" is #564's own word for the contrast with the preflight.
- **README, after** (8 lines, at most 80 characters, as 78 README lines on main already are):
  ```
  `install.sh`, attached to every release, installs the newest with no `gh` and no
  login. Its command is PINNED to the release tag, recorded in `openRepoShape.ref`
  beside it: `setup.sh` and the standard are fetched at that tag, not at main. It
  installs `git-filter-repo` with `pipx`, or `uv`, where it can, or prints the
  command, runs `openRepoShape --preflight`, and says last if the machine is ready
  to adopt or only to scaffold. It never runs `sudo` itself; the preflight offers
  what is missing, each behind a typed `yes` at your terminal; in Git Bash it can
  take none. `| bash -s -- v1.0.1` names a release; the same line again upgrades.
  ```
  #598's `pipx`/`uv` sentence and this PR's clause are word for word. README is **1449** lines, its cap (1422 + 5 for `550.txt` + 22 for `564.txt`).
- **The Git Bash clause is still true after #598.** Under `curl … | bash`, `[ -t 0 ] || exec </dev/null` (install.sh:342; #598 moved it down from :277 and left its text alone) leaves the preflight no terminal on stdin. In Git Bash, setup-project.py runs on native Windows Python, where `open_terminal()` returns None (`os.name == "nt"`, setup-project.py:1372). So `can_ask()` is False, and `offer()` takes no `yes`. #598 changes what install.sh does before and after the preflight, not how the preflight reads an answer.
- **Handbook, consent rules.** The `install.sh` item keeps #598's text, "installs `git-filter-repo` with `pipx` or `uv` where one is there, and itself never runs `sudo` or installs either of them", and then this PR's clause, word for word: "; in Git Bash it can take no `yes`, so it installs nothing."
- **Handbook, masthead and `start`.** They merged as text: this PR's "macOS, Linux, WSL2, Git Bash" heading and PowerShell line, beside #598's "where `pipx` or `uv` can install it … and whether the machine is ready to adopt or only to scaffold". The `start` step-3 paragraph is #598's. It is `data-platform="linux mac wsl"`, as before, so it carries no Git Bash clause.
- **The seven § "Starting a project in a new organisation" markers.** This PR's 278a1947 conflicted with #598's d1666fa6. All seven now record `badbb6b273215042ba0e8ae167fd760dc25430ee`, computed with `tests/test_repo_hygiene.py`'s own `readme_segments` and `readme_fingerprint`. That driver reproduces both parents' hashes (main d1666fa6, 4c3b72a 278a1947). All 23 markers match the merged README.
- **`tests/test_install_sh.py`.** #598's hunks are `no_pipx` and `no_pipx_but_apt`'s printed commands, the test's docstring, and the line before the printed command, which now starts "there is neither pipx nor uv". They sit beside this PR's `bash_path` in `present_on_path` and in the same test's preflight line. Both sides' tests keep their meaning.

**Fix round 6: retarget to main after #587 (22eacee) (head 4c3b72a).** #587 squash-landed on main as 22eacee at 2026-10-15T20:27:52Z, and GitHub retargeted this PR to `main`. 22eacee's tree, 831e71d, is `git merge-tree --write-tree 2b6dbb7 3d8fa05`: main holds exactly #587's final head 3d8fa05. So this round adds two merge commits and no other change:
- **3080ac0d** merges #587's final head, 3d8fa05. It conflicted only in README.md and docs/handbook.html, with lane 4's #588 (709eec1), which 3d8fa05 had merged from main. The resolution keeps both sides' text (below).
- **4c3b72ae** merges main, 77bb5f0: 22eacee, plus 2b6dbb7 (#584), which the squash carried but 3d8fa05 lacked, and #615's 77bb5f0, which landed after it. Four files conflicted, because the squash is not an ancestor of 3d8fa05 (merge base 62ef256). For each of them, main's copy is the same blob as 3d8fa05's, and 77bb5f0 does not touch it. Each therefore takes this branch's copy, which is 3d8fa05's copy plus this PR's change:

  | File | `3d8fa05e:` = `origin/main:` (77bb5f0) = `22eaceed:` |
  |---|---|
  | README.md | d164131 |
  | docs/handbook.html | 59ec34d |
  | tests/test_install_sh.py (add/add) | 34c7de5 |
  | tests/test_openreposhape_command.py | 5e49547 |

  No file was merged by hand. `git merge-tree --write-tree 4c3b72a origin/main` gives 4c3b72a's own tree, 97843ec, so the head holds all of main. `git diff --stat origin/main 4c3b72a` names only this PR's six files: `.github/workflows/tests.yml`, README.md, docs/handbook.html, `tests/conftest.py` and the two suites (430+, 141-).

**The README and handbook resolution with #588 (3080ac0).** #588 ends README's "Windows, natively" and the handbook's Windows panel with one sentence: "Printed commands are quoted for a POSIX shell, so paste them into Git Bash. Only `shape-doctor.py`'s lines, `setup-project.py`'s family lines and `family.py`'s `NEXT`, `switch -c`, `add` and `git rm -r --cached` lines are quoted for PowerShell, on Windows." That sentence now follows this PR's Git Bash sentences, word for word. `tests/test_readme_windows_quoting_sentence.py` holds the handbook to README's words, and it passes.
- **README**, before (3d8fa05): "That two-liner IS the Windows way in; the `openRepoShape` command in step 3 below is a macOS, Linux and WSL2 convenience with no Windows twin, because on Windows there is nothing to install. Printed commands are quoted …"
- **README**, after: "That two-liner IS the Windows way in from PowerShell, which has no twin of step 3's `openRepoShape` command. Git Bash runs that command and step 3's one line, tested in CI but for a typed `yes`, which its preflight cannot take there. Printed commands are quoted …"
- **Fitting the cap.** The two texts side by side came to seven lines where #588 had six, and README was at its cap. So "no twin of the `openRepoShape` command in step 3 below" became "no twin of step 3's `openRepoShape` command". Step 3 is "Install the `openRepoShape` command", so the meaning is the same. The six lines rewrap, and README stays at **1449** lines, its cap (1422 + 5 for `550.txt` + 22 for `564.txt`). No raise.
- **#588's other README hunk**, the tighter `python setup-project.py` and "Two commands rather than one pipe" paragraphs, merged as text.
- **Handbook Windows panel.** It keeps this PR's "There is no `openRepoShape` command to install in PowerShell … so none of its offers can be accepted there." and then #588's sentence, unchanged.
- **The seven § "Starting a project in a new organisation" markers** (this PR's 34723d9a against #588's d77a1e59) now record `278a19479524fe8aae292daaca84492902b140ed`, the merged section's hash. It was computed with `tests/test_repo_hygiene.py`'s own `readme_segments` and `readme_fingerprint`, and all 23 handbook markers match the merged README.
- **Consistency.** Every Windows, Git Bash, PowerShell and WSL2 sentence was read again in the merged text. #588's sentence is true in Git Bash too: the named tools run under native Windows Python there, where `os.name` is `nt`, so their lines are quoted for PowerShell, "on Windows", as it says. See checklist candidate 7.

**Fix round 5 (head fe58d66).** One comments-only commit to `tests.yml`, for the re-review's N1 and N3. The header says the FULL suite runs on Linux only, so read alone it stays true beside the exception. The job's comment gives the measured "about a minute and a half (86 s on the first run, 50 s of it pytest)" where it said about three minutes.

**Fix round 4 (head 5f001de).** Brett Heap merged #597 ("ci: run the test suite on Linux only") at 2026-10-15T16:54:43Z, which removed the Windows job these suites ran in. His word at 2026-10-15T18:20:35Z: "Option 1: keep a small Git Bash job for #596". That round added two commits:
- **f6552862** merged lane 3's `l3-564-carry-to-main` b23b245 (#587's branch, this PR's base until fix round 6), which held main 654c476 and so #597. `tests.yml` became that branch's, byte for byte: the guard step and the macOS comment edit went with the two jobs #597 removed. The handbook keeps #597's macOS panel beside this PR's Git Bash text, and its seven markers were moved.
- **5f001de1** adds the `tests-git-bash` job, and fixes the macOS sentence that #597 made say "CI runs on Linux only".

v1.0.1's one-line install (#564) is meant to work in Git Bash on Windows, but nothing ran it there: `tests/test_openreposhape_command.py` and `tests/test_install_sh.py` wore `WINDOWS_SKIP`. They now run in the `tests-git-bash` job, under Git Bash.

**Which bash.** `BASH` in `tests/conftest.py` is Git for Windows' `usr\bin\bash.exe`, found from `git --exec-path`, never from PATH. Probe 1 on `windows-latest` showed why:
- A bare `bash` through `subprocess` is System32's WSL launcher. It prints "Windows Subsystem for Linux has no installed distributions." in UTF-16. CreateProcess searches System32 before PATH.
- `shutil.which("bash")` finds `Git\bin\bash.EXE`. That launcher puts Git's `mingw64\bin` and `usr\bin` first on PATH, so Git's real `curl` came before the suite's fake one. The old install.sh suite, run that way, reached github.com and printed the real newest release: `release: v1.0.1, the newest release of opensoft/openRepoShape (https://github.com/opensoft/openRepoShape/releases/latest)`.

A `git_bash_session` fixture puts Git's `mingw64\bin` (or `clangarm64\bin` on ARM64), `usr\bin` and `MSYSTEM` where Git Bash puts them, behind any fake a test puts first. `BASH_SKIP` replaces `WINDOWS_SKIP` in these two files only.

**What the tests now do on Windows.**
- **Paths.** Every path handed to bash, or expected in what it prints, is spelled the way Git Bash spells it (`/d/a/...`). The conversion uses Git's own `cygpath` (`bash_path`, `native_path`). Git Bash converts `HOME`, `TMPDIR` and `PATH` itself.
- **Line endings.** Every script a test writes for bash is written as LF bytes. The fake's redirect headers and its `gh` tag are written as bytes: Python on Windows prints `\r\n`, and `$(...)` keeps the `\r`.
- **The executable bit.** Git Bash's drives are mounted `noacl`, so `chmod 755` changes nothing. A file that starts with `#!` runs anyway. `--install`'s output is checked by mode 755 on POSIX, and by bash's own `test -x` on Windows.
- **A test's own PATH.** On Windows it holds `#!/bin/sh` wrappers. A symlink to a Git Bash program cannot load its DLL: with symlinks, `mktemp` failed and install.sh refused `could not make a temporary directory`.
- **An argument holding a newline** reaches Git Bash through the environment. On the Windows command line it was split in two: `v1\nforged` arrived as "two versions given". In Git Bash, bash hands bash the argument whole, so the test now does the same.
- **`ln -s`.** A new test makes a link with Git Bash's own `ln -s`, after dropping any `$MSYS` the caller set. By default that is a copy, which has no `openRepoShape.ref` beside it and so follows main. With `MSYS=winsymlinks:nativestrict` it is a real link, which reads the record.
- **Two anchors (fix round 2).**
  - The fetched-setup.sh test now also asserts that the parent of the stub's directory exists. Its "the directory is gone" line therefore cannot pass on a path Python cannot open. Both asserts read one `ran_dir = native_path(ran_in)` (fix round 3), so the existence check cannot be reverted to `Path(ran_in)` alone.
  - The `gh` shim test already could not pass with the shim unreachable: it asserts that the shim's own log holds exactly `auth status`, and only the shim writes that log. Probe 4 reverted its `os.pathsep` and it still passed: the shim stayed reachable either way, most likely through MSYS's conversion of PATH, which the log does not show.

**The workflow (fix round 4).**
- **The header** keeps #597's text and adds one sentence: "One exception, on Brett Heap's word at 2026-10-15T18:20:35Z, "Option 1: keep a small Git Bash job for #596": the `tests-git-bash` job below runs the launcher's and the installer's suites, and nothing else, on Windows under Git Bash."
- **`tests-git-bash`**: `runs-on: windows-latest`, `timeout-minutes: 15`, and no `if:`, so it runs on every trigger the workflow has: a push to main, a pull request, a manual run. Its steps:
  1. checkout and setup-python, at the ubuntu job's two commits, with `fetch-depth: 0`;
  2. `python -m pip install --disable-pip-version-check pytest==8.3.4`;
  3. the git identity, appended to `GITHUB_ENV` in PowerShell, as the old Windows job did;
  4. "Git Bash is the bash the launcher and installer suites run": fails the job when conftest finds no Git Bash, and prints the bash the suites will run;
  5. `python -m pytest -q -rA tests/test_openreposhape_command.py tests/test_install_sh.py --basetemp=${{ runner.temp }}\t`.
- **Its env** is the old Windows job's: `PYTHONUTF8=1`, `core.autocrlf=false` and `core.longpaths=true` through `GIT_CONFIG_COUNT`.
- **Its comment** says why it exists (#585, the one-line install on Windows), why it runs two suites only (#597), why its bash is Git's `usr\bin\bash.exe` (pointing to conftest), and why it runs on a push to main.

**The docs.**
- **README § "Windows, natively" and the handbook's Windows panel** say: PowerShell has no twin of the command; Git Bash runs it and the one-line install, tested in CI but for a typed `yes`, which the preflight the one-line install ends with cannot take there. #588's sentence about POSIX-quoted printed commands follows (fix round 6).
  - Why: install.sh:342 (:277 before #598) runs `exec </dev/null` under `curl … | bash`, and setup-project.py's `open_terminal()` has no Windows path.
  - Nothing installs without a typed `yes`, so consent holds.
  - Fix round 4: this sentence was left with nothing behind it once #597 removed the Windows job. `tests-git-bash` makes it true again, so its text is unchanged.
- **README's macOS sentence and the handbook's macOS panel (fix round 4).** #597 made them say "though CI runs on Linux only and no run of it checks them against bash 3.2". With this job that is false. They now say "though no CI run, on Linux or in Git Bash on Windows, checks them against bash 3.2". README rewraps inside the same four lines.
- **Step 3's sentence about the preflight's offers** (README) and **the consent rules** (handbook) add that in Git Bash it can take no `yes`. Since fix round 7, both clauses follow #598's `pipx`-or-`uv` text, and README's paragraph is reworded to fit (above).
  - The handbook's own step-3 install paragraph is `data-platform="linux mac wsl"`, so it is already scoped away from Windows, and is left.
- **The WSL2 sentence** says WSL2 "runs every bash entry point in Linux itself", instead of being "still how you run" them.
- **The handbook's masthead** lists Git Bash beside macOS, Linux and WSL2.
- **README keeps its line count**: 1449, the cap (1422 + 5 for `550.txt` + 22 for `564.txt`).
- **The seven handbook markers** for README.md § "Starting a project in a new organisation" record its new hash, `badbb6b273215042ba0e8ae167fd760dc25430ee` (fix round 7, after #598). Fix round 4's merge set them to `a88ac2d0…`, 5f001de moved them to `34723d9a…` for the macOS sentence, and fix round 6 to `278a1947…` after #588. Every hash was computed with `tests/test_repo_hygiene.py`'s own `readme_segments` and `readme_fingerprint`.

- **A separate small job, on Brett Heap's word.** At 2026-10-15T18:20:35Z he chose "Option 1: keep a small Git Bash job for #596". The option read: "A small Windows job for just these two suites. The probe runs took 2-3 minutes end to end, so it finishes well inside the 34-36 minute Linux job and adds no wall-clock time. The README claim stays true. It is an exception to #597's decision, so it needs your word." So the job runs these two suites and nothing else. It is not a matrix entry on the ubuntu job, whose actionlint download is a linux-amd64 binary.
- **Every trigger, a push to main included.** A squash lands a tree no pull-request run tested (#122), and these two suites are the only run of the one-line install on Windows. At about a minute and a half beside the ubuntu job (86 s on its first run), it adds no wall-clock time.
- **What the job keeps from the old Windows job, and why.**
  - `PYTHONUTF8=1`: the suites decode bash's output with `text=True`, which uses the locale's code page (cp1252) outside UTF-8 mode, and the launcher's `--help` prints em dashes (openRepoShape:108, :119-120).
  - `core.autocrlf=false` and `core.longpaths=true`: every Windows run of these suites had both, #585's four probes included, and no run without them exists.
  - `fetch-depth: 0`: `test_a_ref_the_doctor_would_check_out_is_validated` sets `OPENREPOSHAPE_REPO` to this checkout, so the launcher clones it, and every Windows run of it cloned a full one.
  - `--basetemp=${{ runner.temp }}\t`: the old job's short temp root, for MAX_PATH.
- **What it leaves out.**
  - **git-filter-repo.** These suites never reach a real one. The install suite's PATH holds only its own fakes, `git-filter-repo` and `pipx` among them. The launcher's machine report gets a fake `git-filter-repo` on the PATH `machine_path` builds. On Linux, with every PATH directory that held a `git-filter-repo` removed (`filter-repo=none` in the run log), the two suites gave **113 passed**. `test_it_installs_the_git_filter_repo_ci_runs` still finds the ubuntu job's pin.
  - **actionlint.** The ubuntu job lints this file.
- **`usr\bin\bash.exe`, not `bin\bash.exe`, and not PATH.** The probe-1 lines quoted above.
- **`git_bash_session` is needed, though the runner's own PATH hides it.**
  - The runner's PATH already holds Git's `bin`, `usr\bin` and `mingw64\bin`, with `bin` before System32 (probe-1 PATH dump). So reverting the fixture changes nothing there (probe 2: 113 passed).
  - A stock Git for Windows install puts only `Git\cmd` on PATH. Probe 3 ran the two files on exactly that PATH, where `which mktemp` is `None` and `which bash` is `C:\Windows\system32\bash.EXE`:
    - with the fixture: **113 passed**;
    - with it reverted: **59 failed, 15 passed, 39 errors**, for example `bash: line 199: mktemp: command not found` and `this machine has no mktemp, which the install needs`.
- **`bash -s` runs still take their script through a text-mode pipe.** That pipe hands bash CRLF on Windows, but Git Bash's bash drops a carriage return before a newline: a CRLF script piped to it printed `crlf-ok` and exited 0 (probe 1). Sending bytes instead changed no result (mutation `text-stdin`, 113 passed), so that change was dropped.
- **Python can make symlinks on the runner** (`os.symlink: ok`), so the three existing symlink tests run there unchanged.
- **The macOS sentence names Git Bash without a line.** "No CI run, on Linux or in Git Bash on Windows, checks them against bash 3.2" is true with this job, and fits README's four lines. A clause naming the two suites would have needed a fifth line and a cap raise.
- **Not claimed: that setup's own `yes` is asked in Git Bash.** Typed at a Git Bash prompt, `openRepoShape <Project>` reads its `yes` through native Windows Python. Whether that Python sees a console depends on Git Bash's pseudo-console support, and nobody has confirmed it on a Windows machine.

- **Linux, on the head 52524fe** (fix round 7, `dirty=0`):
  - the two suites: **113 passed** in 41.23s;
  - `tests/test_install_sh_filter_repo.py` (#598's, unchanged): **18 passed** in 7.32s;
  - `tests/test_repo_hygiene.py`, `tests/test_readme_sections.py`, `tests/test_cli_reference.py`, `tests/test_setup_by_hand_commands.py` and `tests/test_readme_windows_quoting_sentence.py`: **171 passed** in 25.43s.
  - `git grep -l 'install.sh' tests/` finds `conftest.py`, `readme-cap.d/564.txt`, `test_install_sh.py`, `test_install_sh_filter_repo.py` and `test_repo_hygiene.py`. Every test file among them was in these runs. No skips.
  - The merge, before its commit: `test_repo_hygiene.py`, `test_readme_sections.py` and `test_readme_windows_quoting_sentence.py`, 144 passed.
- **actionlint 1.7.12 on 52524fe**, on `tests.yml` and the two template workflows: exit 0, with shellcheck on PATH. `tests.yml` is the same blob as at 4c3b72a (5fc46de); #598 does not touch it.
- **Linux, on the head 4c3b72a** (fix round 6, `dirty=0`):
  - the two suites: **113 passed** in 28.57s;
  - `tests/test_repo_hygiene.py`, `tests/test_readme_sections.py`, `tests/test_cli_reference.py`, `tests/test_setup_by_hand_commands.py` and `tests/test_readme_windows_quoting_sentence.py`: **171 passed** in 17.09s.
  - Those cover every `tests/test_install_sh*.py` (there is one) and every test file `git grep -l 'install.sh' tests/` finds (`test_install_sh.py`, `test_repo_hygiene.py`; the other two hits are `conftest.py` and `readme-cap.d/564.txt`).
  - 3080ac0's merge, before its commit: `test_repo_hygiene.py`, `test_readme_sections.py` and `test_readme_windows_quoting_sentence.py`, 144 passed.
- **actionlint 1.7.12 on 4c3b72a**, on `tests.yml` and the two template workflows: exit 0, with shellcheck on PATH. The workflow keeps main's #593 `concurrency` block byte for byte (this PR's two `tests.yml` hunks are the header and the new job), the header's "The FULL suite runs on LINUX ONLY" with the exception sentence, and `tests-git-bash`.
- **Linux, on the head 5f001de** (`dirty=0`): the two suites, `tests/test_repo_hygiene.py`, `tests/test_readme_sections.py`, `tests/test_cli_reference.py` and `tests/test_setup_by_hand_commands.py`: **281 passed** in 61.24s.
- **Linux, fix round 4, before its commit:**
  - the two suites: 113 passed;
  - the two suites with no `git-filter-repo` on PATH: **113 passed**;
  - the four docs and hygiene files: 168 passed;
  - the merge alone, before its commit: `test_repo_hygiene.py` and `test_readme_sections.py`, 141 passed.
- **actionlint 1.7.12**, fetched and checksum-checked as the ubuntu job does, on `tests.yml` and the two template workflows: exit 0, with shellcheck on PATH.
- **The YAML parse.** Triggers: `push` to `main`, `pull_request`, `workflow_dispatch`. Jobs: `tests` (ubuntu-latest) and `tests-git-bash` (windows-latest, `timeout-minutes: 15`). Neither has an `if:`, so both run on every trigger.
- **Linux, earlier rounds:** on 01e8c2c, the same five files without `test_setup_by_hand_commands.py`, 276 passed. Lane 5's re-review got the same 276 there. On 649b089, with `tests/test_setup_by_hand_commands.py`, `tests/test_agents_md_codes.py` and `tests/test_gh_guard.py` as well, 334 passed.
- **Windows, `windows-latest`, through probe branches that ran only these suites:**
  - Probe 1 (run 38752845751): 109 passed, 2 failed, 0 skipped. The two failures were the newline arguments, fixed since.
  - Probe 2 (run 38754067543, tree ebba347): **113 passed, 0 skipped**, in 68.83s.
  - Probe 3 (run 38757083883, on 649b089): **113 passed** in 50.97s. On a stock Git for Windows PATH (only `Git\cmd`): 113 passed.
  - Probe 4 (`l5-585-probe-4`, on 01e8c2c): **113 passed** in 77.92s (run 38769166462).
- **The same two files as they were at 5191f0e, with only `WINDOWS_SKIP` lifted** (probe 1): 88 failed, 23 passed. Two causes: the WSL launcher, and the real network through `Git\bin\bash.EXE`.
- **Windows on 5f001de, the PR's own run of `tests-git-bash`:** **113 passed** in 50.28s, with the whole job taking 1 min 26 s (18:37:00Z to 18:38:26Z, check run 116601758252).
  - The guard step printed `C:\Program Files\Git\usr\bin\bash.exe`.
  - Its steps and env are probe 4's, less the git-filter-repo install, and with `-q -rA` and a 15-minute job timeout.

Probe 2 ran these on **ebba347f**, the tree before the squash, which still had a `run_piped` helper; the `text-stdin` row is that helper. The squashed head 649b089 was then re-measured whole by probe 3 (113 passed), and fix round 2's head 01e8c2c by probe 4. Each row reverts one Windows hunk and runs the two files again.

| Hunk reverted | Result |
|---|---|
| `BASH` is the bare word `bash` | 108 failed |
| paths keep Windows' spelling | 25 failed |
| scripts written with `write_text` (CRLF) | 1 failed: `test_a_release_whose_command_records_no_ref_exits_one`, whose launcher's first line became `#!/usr/bin/env bash\r` and was refused as not the command |
| the fake prints its headers and tag in text mode | 22 failed |
| mode 755 checked on Windows too | 2 failed |
| symlinks on a test's own PATH | 28 failed |
| newline arguments on the command line | 2 failed |
| no `git_bash_session` | 113 passed on the runner's PATH; on a stock Git for Windows PATH (probe 3) 59 failed and 39 errors, against 113 passed with it |
| `bash -s` script sent as bytes (`run_piped`) | 113 passed either way; this hunk was dropped |

Fix round 2's anchors (probe 4, on 01e8c2c): 

| Run | Result |
|---|---|
| the fetched-setup.sh test, `native_path(ran_in)` reverted to `Path(ran_in)` in both asserts | exit 1: 1 failed. The new parent-directory anchor catches it. Since fix round 3 both asserts read one `ran_dir`, so this is the only way to revert it. |
| the `gh` shim test, its PATH joined with `:` instead of `os.pathsep` | exit 0: 1 passed. The shim stays reachable, so the hunk is not load-bearing on this runner. The log shows only that outcome; MSYS's conversion of PATH is the likely reason. The test is still not vacuous: it asserts that the shim's own log holds exactly `auth status`, and only the shim writes that log, so an unreachable shim would fail it. |
| both `ln -s` cases with `MSYS=winsymlinks:nativestrict` exported, head | exit 0: 2 passed |
| the same, with the `MSYS` drop reverted | exit 1: 1 failed (`ln-default` made a link), 1 passed |

#590 adds four cases to these two suites, written while they skipped on Windows:
- `test_a_command_that_is_a_symlink_is_refused_before_any_request[live|dangling]`;
- `test_install_refuses_a_target_that_is_a_symlink[live|dangling]`.

Each compares a path bash printed with `str(path)`. Each also compares `os.readlink`'s answer with the target, and on Windows that answer typically starts `\\?\` (Python's `os.readlink` documentation, since 3.8; the probe's failures stop at the first assert, so no log shows it). Linux passes on the merged tree (117 passed), but under Git Bash those four cases fail, and `tests-git-bash` runs them.

**Whichever of #596 and #590 lands second applies these hunks** to the merged tree. Probe 4 (run 38769166462) applied exactly these.

```diff
--- a/tests/test_install_sh.py
+++ b/tests/test_install_sh.py
@@ -668,12 +668,14 @@ def test_a_command_that_is_a_symlink_is_refused_before_any_request(
     result = net.run()
     assert result.returncode == 2, result.stdout + result.stderr
     said = " ".join(result.stderr.split())
-    assert f"{link} is a symbolic link, so nothing was installed" in said, said
+    assert f"{bash_path(link)} is a symbolic link, so nothing was installed" \
+        in said, said
     assert "pipx upgrade openreposhape" in said
     assert "pipx uninstall openreposhape" in said
     assert net.calls() == [], net.calls()
     assert link.is_symlink()
-    assert link.readlink() == packaged
+    # Windows reads a link back with `\\?\` ahead of its target.
+    assert str(link.readlink()).removeprefix("\\\\?\\") == str(packaged)
     if not dangling:
         assert packaged.read_bytes() == original
     assert sorted(path.name for path in net.bin.iterdir()) == \
--- a/tests/test_openreposhape_command.py
+++ b/tests/test_openreposhape_command.py
@@ -407,17 +407,18 @@ def test_install_refuses_a_target_that_is_a_symlink(tmp_path, dangling):
     link = bin_dir / "openRepoShape"
     link.symlink_to(packaged)
     result = run_cmd("--install", home=tmp_path,
-                     env={"OPENREPOSHAPE_BIN_DIR": str(bin_dir),
+                     env={"OPENREPOSHAPE_BIN_DIR": bash_path(bin_dir),
                           "OPENREPOSHAPE_REF": "v9.9.9",
                           "OPENREPOSHAPE_RECORD_REF": "1"})
     assert result.returncode == 2, result.stdout + result.stderr
     said = " ".join(result.stderr.split())
-    assert f"{link} is a symbolic link, so --install will not copy this " \
-        "command through it" in said, said
+    assert f"{bash_path(link)} is a symbolic link, so --install will not " \
+        "copy this command through it" in said, said
     assert "pipx upgrade openreposhape" in said
     assert "pipx uninstall openreposhape" in said
     assert link.is_symlink()
-    assert os.readlink(link) == str(packaged)
+    # Windows reads a link back with `\\?\` ahead of its target.
+    assert os.readlink(link).removeprefix("\\\\?\\") == str(packaged)
     if not dangling:
         assert packaged.read_bytes() == original
     assert not packaged.with_name("openRepoShape.ref").exists()
```

Both files already import `bash_path` once #596 is in. On Linux, the merge of 649b089 with d090f0c, with these hunks: **117 passed**. Fix round 7, on the merge of 52524fe with #590's head 1c8a708, with fix round 6's resolved `tests.yml` (both inputs unchanged since; the result is blob 266c62c): the hunks apply (`git apply --check` exit 0). The two suites (117 by collection), `tests/test_install_sh_filter_repo.py` (18), `tests/test_openreposhape_package.py` and `tests/test_repo_hygiene.py` give **270 passed** in 48.69s. Fix round 6: on the merge of 4c3b72a with #590's current head 1c8a708, with `tests.yml` resolved as below, the hunks apply (`git apply --check` exit 0) and the two suites give **117 passed**; with `tests/test_openreposhape_package.py` and `tests/test_repo_hygiene.py`, 252 passed. Probe 4 runs the merge of 01e8c2c with `l3-563-pypi-package` on Windows: the four cases without the hunks, then both suites with them. In run 38769166462, on Windows:
- **Without the hunks**: exit 1, `4 failed, 113 deselected`. These are exactly the four cases. The launcher pair failed on `REFUSED: D:\a\_temp\…\bin/openRepoShape is a symbol…`, which confirms rv585's inferred launcher failures; the install pair failed on `REFUSED: /d/a/_temp/…/.local/bin/openRepoShape …`.
- **With the hunks**: they apply (exit 0), and both suites give **117 passed** in 75.13s.

**`tests.yml` conflicts with #590 (fix round 6; unchanged in fix round 7, where #590's head is still 1c8a708 and this PR's `tests.yml` is the same blob).** #590 now targets `main` too: at fix round 6 its merge ref's first parent was main 77bb5f0. Its head 1c8a708 has merged #587's final head and main, so it holds #597, and it merges with main cleanly. Both PRs add to the same two places in `tests.yml`, so against 4c3b72a there are three regions:
- **The header.** Keep this PR's "The FULL suite runs on LINUX ONLY" and its exception sentence, then #590's four lines about the `package` job.
- **The end of the file, two regions.** git splits the two new jobs where they share the checkout and setup-python lines. Keep both jobs whole: `tests-git-bash`, then #590's `package`.
- **Checked.** I built that file: this PR's `tests.yml` with #590's header paragraph and its `package` job added. Against #590's file it differs only in this PR's two hunks, and against this PR's file only in #590's two. `yaml.safe_load` gives the jobs `['tests', 'tests-git-bash', 'package']`, and actionlint 1.7.12 exits 0 on it. #590's `package` comment now says "the other jobs" and "on Linux like the rest of the suite since #597", which stay true beside `tests-git-bash`. Fix round 4's advice to drop `tests-windows` and `tests-macos` and to reword "three jobs" no longer applies: #590 has done both.

The open pull requests are the 17 with a `refs/pull/<n>/merge` ref in `git ls-remote origin 'refs/pull/*'`: 583, 590, 594, 596, 599, 614, 617-623 and 625-628. Each head was merged with 52524fe and, separately, with main 396c0ff. The ones not in the table merge cleanly with this head and touch none of its six files: #583, #614, #617, #618, #619, #621, #622, #625, #626 and #628.

| Against | Result |
|---|---|
| origin/main 396c0ff (#591, #572, #624, #598 3fb21d9, #570) | **clean**. The merge gives this head's own tree, 6d8ffea: the head holds all of main. |
| #590 1c8a708 (`l3-563-pypi-package`) | **conflicts** in `.github/workflows/tests.yml`, 3 regions: the header and the end of the jobs. #590 against main alone is clean, so this is the two PRs' own overlap. It also touches `tests/test_install_sh.py` and `tests/test_openreposhape_command.py`, which merge as text. See the #590 section above for the resolution, the four test cases and fix round 7's 270 passed. |
| #599 b0182e2 (lane 3, stacked on #590's old head d090f0c) | **conflicts** in `.github/workflows/tests.yml`, 2 regions. #599 also conflicts with main alone, in six files (`tests.yml`, README.md, the handbook, `install.sh`, `openRepoShape`, `tests/test_install_sh.py`). It is #597's conflict, through #599's stale base, not this job's. |
| #627 fc0656b (`fix/606-readme-followups`, #606; new since fix round 6) | **conflicts** in docs/handbook.html only: 7 regions, each one § Starting marker line (this PR's badbb6b2 against #627's 51702a2b). Its README hunks (the second-run refusal in § Starting, and `CONTRACTS_DIR` and the `single-repository.yaml` question in § Adopting) merge as text with this PR's. #627 against main alone is clean. **Whoever lands second** sets the seven markers to the merged README's hash. On this head plus fc0656b that is `fe0d932b031e4a08fc885f93d6cd978df0d5a25c`. README is then 1456 lines: 1449 plus #627's own `readme-cap.d/606.txt` (+7). |
| #594 be808a7, #623 1e30fa7 | **clean**. Both touch `tests/conftest.py`, which merges as text. |
| #620 4c049a8 | **conflicts** in `adopt-project.py`, and so does #620 against main alone: its own conflict with main, not an overlap with this PR. |

#598 landed as 3fb21d9 and is in the main row; fix round 7 is that merge. Every lane-5 PR and every other PR that has landed is in the main row.

- **`tests/test_setup_sh.py`, and `test_repo_hygiene.py`'s `bash -n` test, still skip on Windows,** and since #597 nothing runs them on Windows at all. #585 names the launcher's and the installer's suites, and Brett Heap's word keeps a job for those two only. The same `BASH` would serve both.
- **Any typed `yes`.** CI has no terminal.
- **`tests/test_install_sh_filter_repo.py` in `tests-git-bash` (fix round 7).** #598's file is not added to the job; that is the coordinator's call. See checklist candidate 8.
- **`docs/release-notes/v1.0.0.md` and `docs/release-notes/v1.0.1.md`** are records of those releases, and #597 left them unedited too. v1.0.1's notes say #585 runs the suites under Git Bash; that stays true. The v1.1.0 text is below.
- **A required check.** This PR does not make `tests-git-bash` a required status check. That is a ruleset setting, and Brett Heap's call.

No v1.1.0 notes file exists on main yet, so this PR writes none. Proposed text, a section of its own:

> **Git Bash on Windows (#585).** Git Bash, the bash that Git for Windows installs, runs the one-line install and the `openRepoShape` command. CI's `tests-git-bash` job runs their suites under Git for Windows' own bash, with the PATH Git Bash makes, and fails if it cannot find that bash. That job is an exception to #597's Linux-only CI, made on Brett Heap's word. In PowerShell the way in is unchanged: `py setup-project.py`. Two things differ from macOS and Linux. `ln -s` in Git Bash makes a COPY unless `MSYS=winsymlinks:nativestrict` is set; a copy has no `openRepoShape.ref` beside it, so it follows main, and `openRepoShape --version` says so. Link it with that setting (Windows lets a non-administrator make a symlink only in Developer Mode), or put `~/.local/bin` on `PATH` instead. And `chmod` changes nothing on Git Bash's drives; a file that starts with `#!` runs as a command anyway. Not tested there: `setup.sh` run on its own (its suite still skips on Windows), and any typed `yes`, for which CI has no terminal. In Git Bash the preflight that the one-line install ends with cannot take a `yes` at all, so it installs nothing for you.

1. **The machine report's `python3` line on Windows.** `--install`'s `machine:` block probes only `python3`. Where Python came from python.org, Git Bash has `python` and no `python3`, or only the Microsoft Store stub. The block then says `python3 MISSING`, or "present" for a stub, while `--preflight` passes with `python`.
2. **A drive-letter `$OPENREPOSHAPE_REPO`.** `shape_clone_url` treats `C:/src/openRepoShape` as an owner/name and builds `https://github.com/C:/src/openRepoShape.git` from it. Git Bash and git itself both accept the drive-letter spelling.
3. **A Windows-spelled `$OPENREPOSHAPE_BIN_DIR`.** `--install` then prints `export PATH="D:\...:$PATH"`, which bash cannot use: the drive's colon splits it.
4. **A stale docstring.** `test_a_ref_the_doctor_would_check_out_is_validated` says the refusal arrives before any clone. `shape_checkout` clones first, then checks the ref.
5. **On Windows, the preflight a piped install ends with cannot take a typed `yes`.**
   - Under `curl … | bash`, install.sh:342 (:277 before #598) hands its children /dev/null for stdin.
   - setup-project.py's `open_terminal()` returns None when `os.name == "nt"`, so it has no Windows path to the person's terminal. Opening `CONIN$`, where POSIX opens `/dev/tty`, might be the analogue.
   - Found by rv585; it is the root of this PR's README wording.
6. **The handbook's step 3 has no Git Bash path.** Its install block and paragraph are `data-platform="linux mac wsl"` (docs/handbook.html:775-784 on 01e8c2c), so a reader on the Windows tab never sees the one-line install that the Windows panel says Git Bash runs. Adding it to the Windows tab is a docs decision.
7. **In Git Bash, the host-quoted lines are still quoted for PowerShell.** `openRepoShape --doctor`, a family `setup-project.py` run and `family.py`, started from Git Bash, run under native Windows Python, where `os.name` is `nt`. So `quote_arg`, `shell_quoted`, `shell_line` and `shell_word` (for example setup-project.py:2331, `if os.name == "nt" and not posix`) quote for PowerShell, even though the person is at a bash prompt. #588's sentence stays true ("on Windows"). But someone who follows "Git Bash runs that command" and pastes such a line back into Git Bash gets the failure that `tests/test_readme_windows_quoting_sentence.py`'s docstring names: `git -C C:\work\Orbit` reads as `C:workOrbit`. Choosing `sh`'s dialect under Git Bash (for example on `MSYSTEM`) would be a #532 decision.
8. **#598's installer cases do not run under Git Bash.** `tests-git-bash` runs `tests/test_install_sh.py`, and since fix round 7 that suite reaches #598's code in two ways: every run now prints `adoption:` and `done:`, and `no_pipx` expects uv's printed command. But the uv install, the readinesses' wording and the pasted next steps are tested in `tests/test_install_sh_filter_repo.py` only, on Linux. README says Git Bash runs step 3's one line, "tested in CI".
   - **What the file uses.** It inherits `test_install_sh.pytestmark` (`BASH_SKIP` and `git_bash_session`), and drives every run through `Net`: `fake` (LF bytes through `write_script`), `run` (`BASH`, `bash_path(INSTALLER)`), `serve`, `installed` and `calls`. Its own `uv` fake writes LF bytes, like `Net.fake`, and names the interpreter as `shlex.quote(sys.executable)`, as `Net.fake` does in the job today. No assert compares a path bash printed with a Python path; each checks a line's start or end.
   - **Under Git Bash it would likely pass.** Its programs see `PATH=net.path` alone, so the runner's own pipx or uv cannot leak in. The launcher's `--install` probes `python3` with `command -v` and never runs it, so the logging `python3`/`python` fakes stay silent. Its `paste()` sends a script through a text-mode pipe, as `Net.run(piped=True)` already does in the job.
   - **Not run on Windows.** On Linux: 18 passed in 7.32s. Adding it to the job is one more path on the job's pytest line.
9. **README still names `pip install git-filter-repo` for adopting.** README:768 (§ Adopting) says "`git filter-repo` is a **hard dependency** — `pip install git-filter-repo`, or the same name from apt or brew." #595 found that `pip install` is refused on an externally managed Python (Debian 12, Ubuntu 23.04, Arch; PEP 668), and #598's install.sh no longer prints it. Neither #598 nor this PR touches that line.

Review: rv585 found 0 blockers, 2 should-fix and 8 nits on 649b089 (CHANGES NEEDED), fixed or answered in 01e8c2c; the re-review rv585b found 5 nits on 01e8c2c (APPROVE WITH NITS), fixed or answered in 3095a9c; rv585c found 4 nits on 5f001de (APPROVE WITH NITS), fixed or answered in fe58d66. CI (Brett Heap's ruling 27, Linux-only since #597): H0 is the head 52524fe, a hand-resolved merge of main 396c0ff, green on tests (Linux), tests-git-bash and SonarCloud; main since then (#625 8f453d0, #583 de428f5) touched none of this PR's files, and the local gate passed on the predicted squash over de428f5: the launcher's, the installer's and install.sh's filter-repo suites, 131 passed; and the hygiene, README-section, CLI-reference, setup-by-hand and Windows-quoting sets, 171 passed. The tests-git-bash job is on Brett Heap's word of 2026-10-15T18:20:35Z, "Option 1: keep a small Git Bash job for #596". Landed on Brett Heap's word of 2026-10-15T22:24:36Z, "Merge #596 when its turn comes".

Closes #585

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 15, 2026
…lds a newline whole, so a merge in a linked worktree under it is found again, `git cherry-pick` is one constant, and two comments say what is true (#612)

R612 verified 207d4e1 and asked for three small things.

V-F1. `_git_paths` asks every mark in ONE `git rev-parse`; where the lines
and the names disagree it asks each name alone. That fallback still split
the one answer on a newline and kept the first line, which cut the very path
it was written for: a linked worktree's answer is absolute, through the
holder's own `.git`, so a holder in a directory whose name holds a newline
gave `.../Ink`, which does not exist. No mark was found, `add --dry-run`
printed a plan, and the real `add` mounted and failed at the commit, #612's
own defect back for that path. The fallback now keeps the whole answer less
git's final newline, as round 0's `_git_path` did. A POSIX-only test (Windows
refuses a newline in a name) puts the holder in `Ink\nRouter`, begins a
`--no-commit` merge in a linked worktree outside it, and requires `add`, in
both runs, to refuse `family-operation-in-progress` naming `MERGE_HEAD`. The
mutant that keeps only the first line fails it.

V-F2. SonarCloud S1192: `"git cherry-pick"` stood three times in the
sequencer's code. It is one constant, `CHERRY_PICK`.

V-F3. The comments in `cmd_add` and `cmd_remove` said the check is asked
before the holder is read and that its commit "fails". It is asked after
`Family.open`, `member-unknown` and the row's `path:` check, which read the
manifest, and the commit goes wrong however the rest is: refused after the
writes, or made inside the act. The docstring that names git's leading-slash
warning now says it shows for an existing member's `members/<Project>` too,
and never in the verb's own output.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 16, 2026 10:29
@sourcery-ai

sourcery-ai Bot commented Oct 16, 2026

Copy link
Copy Markdown

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

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

@brettheap
brettheap merged commit 6f760b7 into main Oct 16, 2026
4 checks passed
@brettheap
brettheap deleted the fix/family-verbs-refuse-up-front branch October 16, 2026 10:30
brettheap pushed a commit that referenced this pull request Oct 16, 2026
Brings in main at fe237ec: #590's squash, whose tree equals
`git merge-tree --write-tree 6f760b7 70955e4` (df113d8), and #626
(6f760b7, scripts/family.py and tests/test_family_refuse_up_front.py).
main and 70955e4 differ in #626's two files alone, and those merged
cleanly.

One conflict, add/add in pyproject.toml. main's blob is 7e1a364, which
equals 70955e4's blob. This branch's side, 4791b54, is that blob plus
#599's 11-line paragraph, with no line removed; it is kept.
.github/workflows/tests.yml merged by itself to this branch's side
(eac5664): main's blob 7928afa, which equals 70955e4's, plus #599's
release.yml actionlint lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
Main gained #626 (`family.py add`, `bump` and `remove` refuse before their
first write), #633, #642, #590 and #641 (`git status --porcelain` and
`git config user.name` read as bytes) since this branch's base 8f453d0.

One conflict, in scripts/family.py: the comment above the single shared
`from shape_materialize import unwritable_reason` line. #433 and #641 each
added a use of that import and each wrote its own comment above it. The
resolution keeps both uses and all three issue numbers (#426 for the creator
read from `git config user.name`, #255 and #433 for the holder's clone URL):

    # Whether a value `init` writes into the holder is one line of text a file
    # can hold: the creator read from `git config user.name` (#426), and the
    # holder's clone URL under `--local-remote-dir` (#255, #433). It is the
    # question the scaffold and `adopt-project.py` ask of theirs.

scaffold-project.py auto-merged. No other hunk conflicted.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 16, 2026
…h, 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)

### 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:

- **S1, the sentence.** It sent a person back to the old branch first. That is right only where the branch holds work not merged yet. `BRANCH_LEFT` now puts another name first and going back second (the text is under "L2-46" below). It is word for word the same in `scripts/family.py`, in the pinned `validate-family.py` and 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: `main` has moved since the earlier act, and a commit was made on a detached HEAD.
- **S2, a guard for every `_switch_line` remedy.** A new test reads the validator's source with `ast` and fails any remedy that names `_switch_line` whose prose does not end with `BRANCH_LEFT`. On a merge of this head with #634 it fails at `_lockstep`, which is the point: whichever of #637 and #634 lands second adds the sentence there. #634's lines are not on this branch and are not touched.
- **N1.** Under "Left out".
- **N2.** The commit body of 6212c31 said neither tool can print the one line that works by asking first. That holds for the validator only. `family.py` could 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.
- **N3.** The merge table is refreshed: current origin/main, #620, #622, #626, #630 to #636, #638, #639 (lane 5's #611) and #634 (S2).
- **N4.** Decision 1 no longer says the typed line works "in every shell". It says what a person types is "the printed line with one word changed (the branch name, or `-c` taken out)", so it keeps the printed quoting, and that the tests type both changes as printed.
- **N5.** Decision 3 now says the case of the branch name "does not matter to `add`", and adds the note about case-insensitive disks.
- **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 (`C:\Users\Jane Doe`, or any `tmp_path` with a space). It now reads the words with `host_words`, as the NEXT STEPS test beside it does. Run with a space in the base temp directory, the old test fails with `AttributeError: 'NoneType' object has no attribute 'group'` and the new one passes.
- **N7.** "Three existing tests take the new text" was wrong. It is two test functions plus `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.

- **Nit 1.** Two comments called another name "right in every state". It is not: where the old branch holds work not merged yet, another name leaves that work behind. The comment above `BRANCH_LEFT` in `scripts/family.py` and the one in the pinned `validate-family.py` now say a new branch starts where the holder stands now, "which works in every state". The three `BRANCH_LEFT` texts are unchanged and still equal.
- **Nit 3.** The guard's docstring said it finds each `_switch_line` call "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".
- **Nit 4.** The N4 line above now quotes decision 1 as written.
- **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, 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.
- **Nit 2, left as written.** The guard's control counts stay: each case fails loudly with a clear message.

Checks on a277695: the diff to 66b1043 adds ten lines and removes seven, in three files, all comments and docstrings, and the `ast` of each file is equal once docstrings are blanked. The four files `test_family_switch_branch_exists.py`, `test_validate_family_branch_step.py`, `test_validate_family_unnamed_mounts.py` and `test_family_tracking_branch.py` give `96 passed in 51.34s`, and `test_repo_hygiene.py` gives `65 passed in 1.39s`.

### L2-46, the fix

`family.py add`, `bump` and `remove` refuse the holder's tracking branch with `git -C <holder> switch -c family/<act>-<member>` (#487). The holder's `validate-family.py` names the same line before each `add` and `remove` it prints (#539, landed as 54041cc). If an earlier act left that branch, the line fails with `fatal: 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_LEFT` in each file):

> 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.

- `scripts/family.py`: the sentence comes on its own line, right under the `switch -c` line, in the `family-on-tracking-branch` and `family-holder-detached` refusals (`_checked_branch`), and in the dry run's `branch` lines (`_branch_said`).
- `templates/family-root/scripts/validate-family.py`: the sentence ends the remedy text of `member-gitlink-absent`, `member-duplicate-id` and `member-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 branch `family/add-<project>`, right beside `--member <org>/<Project>`. All four now say `family/add-<Project>`, the one placeholder the validator already uses for both (`PROJECT_PLACEHOLDER`, #539).

**1. What the remedy prints when the branch exists: one sentence that names the way out, not a second command line.**

- *One line that works in both states* (`git switch X 2>/dev/null || git switch -c X`): rejected. On Windows, `family.py` quotes its lines for PowerShell (`shell_line` uses `os.name`; README.md:155-158 says so). Windows PowerShell 5.1 has no `||`; `&&` and `||` arrived in PowerShell 7, and `family.py`'s own `_print_next_steps` docstring says 5.1 rejects `&&`. PowerShell also has no `/dev/null`. And `2>/dev/null` would hide the real reason a `switch` fails, such as a changed file it would overwrite.
- *The tool checks and prints the one line that works*: rejected for the validator. Its report is read in CI: the holder's `validate.yml` runs `python3 scripts/validate-family.py` (and `--no-members`) in a runner's checkout, which holds none of the person's branches. `family.py` could check, but then the two tools would follow two rules, and one rule in the same words was the aim. So `family.py` says the validator's words; it is not that it cannot ask.
- *Two printed lines, one per state*: rejected. In the validator, each indented line is a step to run in order, and going back to the branch replaces step one; it is not another step. In `family.py`, a new `git switch` line would also be quoted for PowerShell on Windows. README.md:155-158 lists exactly which `family.py` lines 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 -c` gives 7).
- *Chosen*: a sentence that names both ways out. It prints no new command, so nothing new needs quoting, and the text is the same in both tools. What a person types is the printed line with one word changed (the branch name, or `-c` taken 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`, members `Atlas` and `Beacon`, local bare remotes, the `gh` stub first on PATH):

| State of the old branch `family/add-atlas` | `git switch family/add-atlas` (going back) | `git switch -c family/add-atlas-2` (another name) |
|---|---|---|
| No commit on it, `main` has not moved (rv607) | works | works |
| Holds a commit of its own, not merged yet (mine: `probe-atlas.sh`, `probe-b.sh`) | works; `add` then commits on top of it, and the earlier commit is the parent | works, but the earlier commit stays only on the old branch (`git branch --contains` names `family/add-atlas` alone) |
| `main` has moved: a later member (`Beacon`) was squash-merged (mine: `probe-d.sh`; test `test_another_name_works_where_main_has_moved_since`) | `add` exits 0, but `git merge-tree --write-tree main family/add-atlas` exits 1 (`CONFLICT (add/add)` in `.gitmodules`, `CONFLICT (content)` in `family.yaml`), and an untracked `members/Beacon` is left (`warning: unable to rmdir 'members/Beacon'`) | `add` exits 0, `git merge-tree --write-tree main family/add-atlas-2` exits 0, and the validator says `2 member(s)` |
| Merged, then the member removed and that merged too; the old branch is still local (rv607's probe, and mine: `probe-c.sh`) | `add` says `member-already-present`, whose remedy points at `bump` and `remove`: a dead end | `add` exits 0, once the object store `remove` leaves behind is deleted, as its NOTE says (either choice needs that) |
| Detached HEAD with a commit made on it (mine: `probe-detached.sh`; test `test_another_name_keeps_a_commit_made_on_a_detached_head`) | `Warning: you are leaving 1 commit behind, not connected to any of your branches`, and `git branch --contains` names no branch | the commit stays: `git branch --contains` names the new branch |

So 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 names `switch -C` (which resets a branch), `branch -D`, `reset` or `--discard-changes`; a test checks that too.

**4. The placeholder: `<Project>` everywhere.**

- The validator already said `family/add-<Project>` beside `--member <org>/<Project>` (#539's fix round, pinned by `tests/test_validate_family_unnamed_mounts.py`). It is the only spelling that is one placeholder for both values.
- The other option, a sentence saying the branch word is the name in lowercase, does not fit: AGENTS.md is at its cap, 492 of 492 lines (`tests/test_repo_hygiene.py:633`). This change keeps it at 492.
- The case of the branch name does not matter to `add`. `_checked_branch` takes any branch but the tracking one, so `family/add-IRQS`, the doc line filled in as written, is a branch `add` commits on (test). Where the tools know the name, they still print it lowercased (`_suggested_branch`). On a case-insensitive disk (macOS, Windows) `family/add-Atlas` and `family/add-atlas` can 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.py` and `templates/family-root/AGENTS-shape.md` (`scripts/shape_materialize.py` lists the pinned set). Each holder takes them as two `upstream-changed` rows on its next `update-shape.py` run. 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 only `validate-family.py` of the two.

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-irqs` is left at the `main` of its day; a later member (`IRXS`, scaffolded in the test) is added on a branch and squash-merged onto `main`, so `main` has one new commit; the refusal's `switch -c`, typed as printed, fails with `already exists` (exit 128); the same line with another name works; `add` exits 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 is `family-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) and `test_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 `_lockstep` builds 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, the `switch -c` makes one. With a branch an earlier act left, holding a commit of its own, the `switch -c` fails with `fatal: a branch named '<branch>' already exists` (exit 128) and changes nothing, and the same command with `switch` in place of `switch -c` goes 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 the `remove` run as printed) and `test_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 with `host_words` now, N6), and `test_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>`) and `test_a_missing_gitlink_remedy_makes_the_branch_and_names_the_holder` (the remedy line). `GITLINK_REMEDY` in `test_validate_family_unnamed_mounts.py`, a constant four cases share (`test_a_member_mounted_by_hand_is_two_findings` x 3 and `test_a_removed_row_is_named_by_its_gitlink_and_its_section`), ends with the sentence. The sentence is pinned once, as text, in `tests/test_validate_family_branch_step.py`.

Before and after, with the four changed test files copied in (`run9.sh`, the `gh` stub first on PATH):

- On BASE 396c0ff: `30 failed, 66 passed` in 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.
- On 6212c31: `23 failed, 56 passed` in 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.
- On the head 66b1043: the new module alone, `25 passed`.

### Targeted runs on the head 66b1043, BASE 396c0ff

- Set A, 29 files: every `tests/test_family*.py` and `tests/test_validate_family*.py`, plus `test_bump_leg.py`, `test_bump_leg_dry_run_tracking.py`, `test_bump_leg_dry_run_truth.py`, `test_bump_leg_paste_lines.py` and `test_update_shape_branch_any_directory.py`. Result: `1016 passed, 20 skipped in 511.01s`.
- From set B: `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_readme_sections.py` and `test_cli_reference.py`. Result: `210 passed in 15.73s`. The fix round changes no template other than `validate-family.py`'s text, so the rest of set B (the 27 files of the first round) is not re-run.
- Every skip is a platform skip that was there before this change (a case-ignoring disk, a Windows default, powershell.exe), or a template-quoting skip in `test_family_init_values.py`.

A real holder `Orbit`, made with `family.py init`, and a member `Atlas`, scaffolded with `scaffold-project.py --local-remote-dir`.

- **On BASE 396c0ff** (first round): `add` refuses with `switch -c family/add-atlas`. Typed as printed, that gives `fatal: a branch named 'family/add-atlas' already exists`, exit 128, and the remedy says nothing more. The validator's `member-gitlink-absent` remedy prints the same line, with the same result.
- **On the head, an unmerged act** (`probe-atlas.sh`, a branch holding a commit of its own): the refusal prints the new sentence. The same command with `switch` for `switch -c` gives `Switched to branch 'family/add-atlas'`, exit 0, at the earlier commit. `add` then exits 0, its NEXT pushes `family/add-atlas`, and its commit's parent is the earlier commit. The validator carries the same sentence.
- **On the head, `main` moved** (`probe-d.sh`): the printed line fails (128). With another name: `add` exits 0, `git merge-tree --write-tree main family/add-atlas-2` exits 0, `family ok: Orbit (orbit), 2 member(s)`. Going back instead: `add` exits 0, and `git merge-tree --write-tree main family/add-atlas` exits 1 with the two conflicts in decision 2.
- **On the head, a detached HEAD with a commit** (`probe-detached.sh`): the printed line fails (128). With another name, `git branch --contains` names `family/add-atlas-2`. Going back with `switch`: git warns it is leaving 1 commit behind, and no branch contains it.

No row needs real GitHub.

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:

| Change | Result |
|---|---|
| `family.py` `BRANCH_LEFT` back to the first round's words | 12 failed: the six refusal cases, the three dry-run cases, both new S1 tests, `test_the_two_tools_say_the_same_words` |
| `family.py` `BRANCH_LEFT` one clause dropped (`only if`) | 12 failed, the same tests |
| `_checked_branch` remedy with no sentence | 8 failed: the six refusal cases and both new S1 tests |
| sentence printed above the `switch -c` line, not under it | 8 failed: the same eight |
| `_branch_said` (dry run) with no sentence | 3 failed: `test_a_dry_run_on_the_tracking_branch_says_it_too` [add, bump, remove] |
| `_switch_command` prints `switch -C` | 11 failed, both new S1 tests among them: the printed line no longer fails `already exists` |
| validator `BRANCH_LEFT` back to the first round's words | 12 failed in the three files, `test_the_two_tools_say_the_same_words` among them |
| validator `member-gitlink-absent` with no sentence | 5 failed: its two typed-as-printed cases, the remedy test, the guard and its control |
| validator `member-duplicate-id` with no sentence | 4 failed: its two typed-as-printed cases, the guard and its control |
| validator `member-gitlink-unnamed` with no sentence | 8 failed: its two typed-as-printed cases, the four `GITLINK_REMEDY` cases, the guard and its control |
| **S2:** a fourth remedy added that names `_switch_line` and does not end with the sentence (built by a helper, as #634's `_lockstep` does) | 2 failed: the guard and its control, and nothing else in the three files |
| **S2:** the same fourth remedy ending with `{BRANCH_LEFT}` | 79 passed |
| `init` NEXT STEPS branch back to `<project>`; its `--member` lowercased | 1 failed each: `test_init_next_steps_spell_the_branch_as_its_member` |
| AGENTS.md, AGENTS-shape.md, holder README: branch back to `<project>` (and AGENTS.md's `--member`) | 1 failed each: `test_each_doc_spells_the_branch_as_its_member` for that doc |

The 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.

| Ref | Result |
|---|---|
| origin/main f6376d7 (#614 landed; main has moved since BASE through #625, #583, #596) | clean, tree eb91b600. A committed merge of the head with it, e26dca8c, ran set A and #625's `test_family_bump_to_local_only.py`: `1024 passed, 20 skipped in 500.52s` |
| #620 a2b4f89 | clean (it conflicted in `adopt-project.py` in the first round; it no longer does) |
| #622 227bc3b (lane 2, #609, `scripts/family.py`) | clean |
| #626 207d4e1 (lane 4, `scripts/family.py`) | clean |
| #630 82f9403, #631 9733547, #632 86c6b9c, #633 f7f5de5, #635 2c0b157, #636 4aade9f | clean |
| #638 64e6621 | clean |
| #634 40a85e1 (lane 2, #610: `_lockstep` prints a fourth `switch -c`) | clean, and S2 fires on it. A committed merge, 4d1a7161: the new module, `test_validate_family_branch_step.py` and #634's `test_pinned_copy_text.py` give `2 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 gives `306 passed` over 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.py` and the new module). So the second PR to land needs that one line and nothing else. |
| #639 6f938ed (lane 5, #611) | conflicts in the same one sentence, in the same two places: the end of the `member-gitlink-unnamed` remedy text in `validate-family.py` (`_check_gitlinks_named`), and the `GITLINK_REMEDY` constant in `tests/test_validate_family_unnamed_mounts.py`. #611 adds "and commit that removal, since `family.py add` refuses ..." and this PR appends `BRANCH_LEFT`. Resolved as in the first round, keeping #611's clause and appending ` {BRANCH_LEFT}` in the validator and `+ BRANCH_LEFT` in the test constant, in a committed merge (92602308): `scripts/family.py` merged cleanly, and the six test files that either PR changes or pins gave `157 passed`. Neither branch was resolved. |

- No compound `||` line and no state check: see decision 1.
- No README.md edit: no README sentence becomes false, because no new kind of line is printed.
- The docs do not mention the exists case. AGENTS.md is at its cap, and the tools print the sentence where the failure happens.
- The `switch -c` in `init`'s NEXT STEPS gets no sentence: a holder `init` just made has no branch an earlier act could have left.
- **A branch checked out in another worktree (N1).** Where `family/add-atlas` is checked out in another worktree of the holder, `git switch family/add-atlas` gives `fatal: '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-2` exits 0). The sentence is not made longer for it.
- I could not run Windows PowerShell or macOS here. The tests read `family.py`'s lines with `host_words`, which parses PowerShell's quoting on Windows. They make the earlier branch with `commit-tree` and `update-ref`, move `main` with the same two, write no file with `write_text` and compare nothing CRLF-sensitive. `test_another_name_works_where_main_has_moved_since` runs `git merge-tree --write-tree`, which needs git 2.38 or newer. No CI job checks Windows or macOS.

- **bump-leg.py: the `git switch -c bump/<role>-<sha12>` remedy fails `already exists` when 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 #607, it says nothing about a branch that is already there.
- **setup-project.py: the family `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.

- L2-46 ([#459 comment](#459 (comment)))
- L2-45 ([#459 comment](#459 (comment)))

Review: rv607 found 0 blockers, 2 should-fix and 7 nits on 6212c31 (CHANGES NEEDED), fixed in 66b1043; the re-verify rv607b found 5 nits on 66b1043 (APPROVE WITH NITS), of which nits 1, 3 and 4 and the docstring half of nit 5 are fixed in a277695, and nit 2 is left. CI (Brett Heap's ruling 27, with his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 a277695 passed tests (Linux), tests-git-bash and SonarCloud; the head 62263d2 is a clean update-branch merge of main a524542 (over e041867, a clean merge of 5b29ee1), with SonarCloud, tests-git-bash and package green on it, and the local gate passed on the head itself: the family and validator set, 1016 passed (20 skipped), and the hygiene, AGENTS.md-code, README-section and CLI-reference sets, 222 passed. Landed on Brett Heap's word of 2026-10-16T11:21:49Z, "Merge #637 when its turn comes".

Closes #607

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
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>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
… 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>
brettheap added a commit that referenced this pull request Oct 16, 2026
…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>
brettheap added a commit that referenced this pull request Oct 17, 2026
…directory, sparse rules that hold no pattern, a merge conflicted in `family.yaml` and a `members` file HEAD holds and the work tree does not as what each is, with the way out that works there, and refuse a `family.yaml` holding a change HEAD does not (#684) (#816)

#684 holds six lines from R612's review of #626 (#612), on #459: holder states #626's early refusals did not model, where `add`, `bump` and `remove` met a refusal or a remedy that did not fit. Five are fixed; E4 (reftable) needs git 2.45 or later to measure and is not. Brett Heap added #459's line 66, "refuse a dirty `family.yaml`" (relayed by lane 5 on #473 at 2026-10-16T13:56:50Z). Every refusal is made before the verb's first write, under `--dry-run` alike, exit 2.

- E2: a `git bisect` in progress is `family-operation-in-progress`, `BISECT_LOG` asked last, with `git bisect reset` as its exit; the real run said `family-holder-detached` and offered a `git switch -c` from the commit under test, and the dry run printed a plan.
- E5: a `rebase-merge/` or `rebase-apply/` with no `head-name` is named with `git rebase --quit`; `--abort` and `--continue` both fail there.
- E3: non-cone sparse rules that hold no pattern name `git sparse-checkout disable` alone; the `git sparse-checkout add` printed before took every other file out of the work tree.
- N4: a `family.yaml` that fails to parse while an act is unfinished, as a merge conflicted in it leaves it, keeps `yaml-unparsable` and gains a remediation naming the act and its exits; the marks are asked only once the parse has failed, so `member-path` still runs no git command first.
- E1: a `members` file HEAD holds and the work tree no longer does is `member-path-unaddable` before the member is read, so the add's commit no longer takes `D members` into itself. Staged and not committed, it is committed first; its deletion not staged, it is taken out of the index, `git rm --cached -- members`, and the removal committed (the issue's `git mv` fails there, exit 128, and `git checkout -- members` first takes a hand-made `members/` away; the lane's coordinator ruled this remedy). Each remedy says the commit is made "with nothing else staged: a bare `git commit` takes every staged change, and `git status` lists them".
- A `family.yaml` holding a change HEAD does not, staged or not, mode or content, is `family-manifest-uncommitted` (`_checked_manifest_committed`, right after the operation check in each verb): the verbs commit the file as the work tree has it, so a comment line edited by hand, or a staged mode, went into their commit. It reads the index records, HEAD's entry from `git ls-tree -r -z HEAD` and `git ls-files -m`, and no `hash-object`; a holder whose HEAD holds no `family.yaml` is not asked, so `add` still gives a hand-made holder its first commit. The remediation names `git commit -- family.yaml` and `git checkout HEAD -- family.yaml`, or the second alone where a change to it is staged beside a gitlink under `members/` that HEAD does not hold, as #210's half-finished bump leaves both (a gitlink staged by hand beside an unstaged edit is told both). The two `member-path` remedies and `family-members-unrecognised`'s now say to commit the fix by hand. Existing tests that left `family.yaml` changed now commit it first or expect the refusal, #210's half-finished bump among them.

Part of #684: E1, E2, E3, E5 and N4, and #459's line 66. E4 (reftable) stays open on #684 until it is measured on git 2.45 or later.

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

Labels

None yet

Projects

None yet

1 participant