Skip to content

README.md and the handbook: say that a source's own single-repository.yaml record arrives as leg: drop, that the adopted Makefile's CONTRACTS_DIR follows the spec leg's mount, and that a second setup run is refused at the plan, so the three sentences #566, #572 and #573 left false or incomplete are true of main (#606) - #627

Merged
brettheap merged 8 commits into
mainfrom
fix/606-readme-followups
Oct 16, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-2 (openRepoShape-2)
Claim: #606 (comment)

Branch fix/606-readme-followups: the change itself is one commit (18814b3), made on main fbbbc8d, which holds #566, #572 and #573. Two merge commits then brought main in: fc0656b merged 396c0ff (#570, #598 and #624 since), and a6d4f5f merged bf7b19b (#583, #625 and #596 since), so the PR was up to date with main. A third merge, 0835381, brought in 4cf5fb0 (#614, #619, #594, #617 and #618 since; none touches the README or the handbook), 333095c is the round of 2026-10-16 below, 66b98a2 its second round (rv627n P1), and 83ca2db its third (rv627n N1).

Round of 2026-10-16 (f627n): #621's Free-plan sentence. This PR lands after #621.
The integration review of lane 2's seven PRs together (irv, problem 1; rv602's N3, which #621's body handed to #606) found that README § Starting a project in a new organisation said private legs' CI credential is kept "as REPOSITORY secrets on the GitHub Free plan, where organisation secrets do not reach private repositories". After #621 (issue #602) that is wrong of a public root. The secrets are read by the assembly root's validate workflow, and the tools now decide the Free-plan hint by the ROOT's visibility (adopt-project.py if seen in ("private", "internal"); scaffold-project.py root_is_closed). On the Free plan organisation secrets reach public repositories, so private legs under a PUBLIC root use organisation secrets, and the tools print the per-leg NOTE and no hint (test_a_public_root_over_private_legs_gets_the_note_and_no_hint). The README now describes behaviour that exists only with #621's code, so #627 must land after #621: on main today scaffold-project.py already gates the hint by the root (#542), but adopt-project.py execute still prints it whenever the plan's new legs are private or internal, whatever visibility the adopted root has.

The fix is in README § Starting, in the six lines the paragraph had: "— as REPOSITORY secrets on the assembly root when it is private or internal and the organisation is on the GitHub Free plan (see "Reading private legs in CI" below)." The "where organisation secrets reach only public repositories" clause of #621's suggested wording is not in this sentence, because it would have made a seventh line; the paragraph it points to says it. That paragraph, "On the Free plan, use --repo, not --org" under "Reading private legs in CI", said "on a private one" and "Set both per repository", leaving the reader to guess which repository; it now says validate runs in the root, "so on a private or internal root" the secret is empty, "Set both on the root, or upgrade to Team; a public root takes org secrets" (+1 line; the second round below says on what condition). The handbook's ci part carries the same paragraph and has the same words. No other README or handbook sentence made the claim: the start, partway, legs, doublepin and park parts do not carry the § Starting sentence, and templates/assembly-root/README.md and templates/family-root/README.md say "on a private repository" of their own repository, which is true of the root rule, so they stay.

The markers move with the text, computed with the hygiene test's own readme_segments and readme_fingerprint: § Starting a project in a new organisation fe0d932b to dd174d763a931052478f2fd15a7f22535524f6f4 in the seven page parts that read it (masthead, start, partway, ci, legs, doublepin, park), and § The three legs 39ec2c23 to fd6098f84f128d9adb9bfe27c3ea640e5e4d1565 in the three that read it (masthead, ci, legs), because "Reading private legs in CI" is inside it (the second round below moved it again). No footer clause. The cap: 1456 to 1457 (tests/readme-cap.d/606.txt +7 to +8, with a fourth argument); wc -l README.md was 1457 (1459 after the second round).

Second round of 2026-10-16 (f627n, from rv627n's re-verify of 333095c): when a public root takes org secrets. Still lands after #621.
rv627n P1: "a public root takes org secrets" holds only for organisation secrets whose visibility reaches the root. README's own commands ("Reading private legs in CI", and the handbook's ci part) register them with gh secret set … --org and no --visibility, which defaults to private, and on GitHub Free an organisation secret is not accessible to private repositories; so a secret made by those commands reaches no repository, and a public root that followed the sentence got the silent green with the pin check skipped. The sentence now names the condition: "Set both on the root, or upgrade to Team; a public root takes org secrets registered with --visibility all, or selected with the root in it (the commands above default to private). The creating tools check and say so." The handbook's ci part has the same words. The commands are NOT changed to --visibility all, which would show the App's private key to every repository of the organisation. (This round named all first; the third round below puts selected first.) The paragraph is 7 lines, where it had 5 (+2, the cap +8 to +10; no 6-line wording that kept the default named was found). § Starting is unchanged (dd174d763a931052478f2fd15a7f22535524f6f4); § The three legs fd6098f8… to fbdc04f255931f96cac30978430968084e3d757f in masthead, ci and legs (moved again by the third round). rv627n 5: in 606.txt the opening says the fourth sentence is one #621 "will leave false once it lands" (not "once those pull requests landed"), and paragraph Four carries its true date, 2026-10-16, inline, because the file has one date line and it is the file's first (2026-10-15).

Third round of 2026-10-16 (f627n, rv627n N1): least privilege first. Still lands after #621.
The second round's sentence named --visibility all before selected. all shows the App's private key to every repository of the organisation; selected with the root in --repos is the least-privilege form, so it comes first and all follows with its cost. Before: "a public root takes org secrets registered with --visibility all, or selected with the root in it (the commands above default to private)." After: "a public root takes org secrets registered with --visibility selected and the root in --repos, or all, which shows them to every repository (the commands above default to private)." The paragraph stays 7 lines, so the cap stays 1459 (606.txt +10; its paragraph Four now names the order and the cost). The handbook's ci part has the same words. § Starting is unchanged (dd174d763a931052478f2fd15a7f22535524f6f4); § The three legs fbdc04f2… to cce66b56eec9dffdd5a1a23bdd8e58d57f90b12e in masthead, ci and legs.

The merges (readiness rounds, f627m). Two landed README PRs rewrote text in README § Starting a project in a new organisation, the same section this PR adds a sentence to, and each moved the handbook's marker for it. Lane 3's #598 (3fb21d9) reworded README's step-3 paragraph and the handbook's masthead, start and conventions sentences about install.sh; lane 5's #596 (bf7b19b) reworded the Windows and Git Bash paragraphs and the CI and bash 3.2 sentence in both documents. Each time README.md merged without a conflict and docs/handbook.html conflicted in seven hunks, each the one marker line of a page part that reads that section (masthead, start, partway, ci, legs, doublepin, park). Both sides' text is kept, and the seven markers moved twice: with #598, this branch's 46538e8e and main's d1666fa6 became 51702a2b; with #596, this branch's 51702a2b and main's badbb6b2 became fe0d932b031e4a08fc885f93d6cd978df0d5a25c, the merged section's hash, computed with the hygiene test's own readme_segments and readme_fingerprint. The § Adopting marker (ca4b6731) was never in conflict, because neither PR touches that section. Neither added a cap file, so the cap is still 1422 + 5 + 22 + 7 = 1456, and the merged README is 1456 lines.

What changed, and why

One README and handbook change for the three lines of the v1.1.0 checklist triage that #566, #572 and #573 left false or incomplete (D7: one README pull request, not an edit in each). The wording is the words those pull requests' bodies held ("README follow-up"), each checked against the code on main before it went in. Two of the three sentences differ from the words the bodies suggested, and each difference is explained below; the third is unchanged.

L2-50, README "Ambiguous is an answer" (and the handbook's adopt section). The paragraph said every entry the policy cannot call gets leg: null and that execute refuses while any remain. #566 gives a source's own single-repository.yaml record a default, so one question arrives answered. Added after the paragraph's last sentence:

One question comes answered: a source's own single-repository.yaml record is written leg: drop, its rule's default, still review_required: true with its question beside it, and execute takes it as written, so it drops the record unless a person changes leg: (the question offers root).

Checked on main (a source with a committed record, adopt-project.py plan): the entry is leg: drop, confidence: review, rule: ambiguous-single-repository-record, review_required: true, with the question; plan prints "carry their rule's default answer, which execute takes as written unless it is changed". The source wording was "execute drops it unless a person writes root". That is not the whole of it: check passes the entry with leg: root and with leg: spec alike (plan ok for both), because nothing about that file breaks under any leg. So the sentence says "unless a person changes leg:" and names root as the answer the question offers. The plan's own header and AGENTS.md step 2 say "takes it as written" in the same way.

L2-51, README's worked example. the root Makefile it materializes exports CONTRACTS_DIR ?= $(CURDIR)/spec/contracts is true of the default mount only. It now reads "exports CONTRACTS_DIR, pointing at the contracts/ of the spec leg where the plan mounts it ($(CURDIR)/spec/contracts by default)", the #572 body's words unchanged. Checked: shape_materialize.adopt_makefile_block("spec", "code") renders CONTRACTS_DIR ?= $(CURDIR)/spec/contracts, and ("legs/spec", "impl/code") renders $(CURDIR)/legs/spec/contracts. The handbook never said CONTRACTS_DIR, so its adopt section moves its marker for this line and nothing else.

L2-41, README's "If it fails part-way" box (and the handbook's list item). Now: "A second run is refused while they exist, at the plan in (3), before the yes: the plan is the scaffold's --dry-run, which asks whether each of the three already exists. There is no --force: ...". Two differences from the #573 body's sentence ("at the plan in step (5), before the yes: ... (#537)"):

  • "step (5)" is "(3)", the README's own number. (5) is the number setup-project.py prints for the plan (a run against a leg that already exists prints (1) preflight, (2) organisation, (3) project, (4) naming policy, (5) the plan, then REFUSED: the plan could not be produced, and never This will create THREE repositories). The README's "What Dana sees, in order", which the box sits under and whose "(4)" it already cites for the yes, numbers the plan (3). A "(5)" in the box would name a step the README has no list for.
  • "(family.py init --dry-run and scaffold-project.py --dry-run exit 0 without asking whether the remotes already exist #537)" is left out. The README cites an issue number on ten lines in 1,450, and a reader of the box has no use for this one.
  • "There is no --force" was the tail of the old sentence ("..., and there is no --force: delete ..."); it is a sentence of its own now, so the new clause does not run into it.

The handbook's partway list item says the same without a number (the page numbers nothing).

The cap and the page's records. tests/readme-cap.d/606.txt raises the README's cap by 7 (1449 to 1456: +4, +1, +2 for the three sentences, each argued in the file), and by 10 since the rounds of 2026-10-16 (1449 to 1459: a fourth argument, +1 for naming the root and +2 for the registration condition, +3 in all); the test now prints README.md is 1459 lines; the cap is 1422 + 5 (550.txt) + 22 (564.txt) + 10 (606.txt) = 1459. The handbook's markers move to the new hashes of the two segments that changed: README § Starting a project in a new organisation (main's badbb6b2..., which holds #598's and #596's rewordings, to fe0d932b..., which holds those and this PR's; in the seven page parts that read it: masthead, start, partway, ci, legs, doublepin, park) and § Adopting an existing repository (a768fa1b... to ca4b6731..., in adopt). The six parts that render nothing of the box carry the moved marker alone; partway and adopt also carry their mirror sentence. No footer clause is added. Ruling 29 (#519) retired the footer's "added (#N)" clauses and test_readme_sections.py refuses a new one, so the markers are the page's whole record.

Not in this PR, as the issue says: docs/release-notes/v1.0.0.md:374 stays as written, and the pinned templates/assembly-root/AGENTS-shape.md sentence about $(CURDIR)/spec/contracts is #610's.

Tests

No new test: the change is prose, held by tests that already read the two documents.

Run Result
tests/test_repo_hygiene.py on the head 18814b3 65 passed (the cap test among them)
test_readme_sections.py, test_readme_windows_quoting_sentence.py, test_setup_by_hand_commands.py, test_update_add_codes_documented.py, test_agents_md_codes.py, test_cli_reference.py: the tests the writer's grep found that read the repository's own README.md or docs/handbook.html 159 passed
The reviewer's run (rv606) of the nine README-reader files: the six above, test_repo_hygiene.py, and the two the grep missed, test_free_plan_hint.py::test_the_documents_say_it_too and test_family_tracking_branch.py::test_each_doc_makes_the_branch_before_the_add 255 passed
The tests that prove the sentences, test_adopt_single_repository_record.py (30), test_adopt_contracts_dir.py (32), test_dry_run_asks_remotes.py (28) and the setup wrappers test_the_plan_refuses_a_remote_that_exists_before_the_yes in test_setup_project_py.py and test_setup_sh.py (2) 92 passed, with git-filter-repo 2.47.0 on PATH and a stub gh
scripts/render-cli-reference.py --check ok (no --help text changed)
On the merged head fc0656b (main 396c0ff merged in): the nine README-reader files, namely test_repo_hygiene.py, test_readme_sections.py, test_readme_windows_quoting_sentence.py, test_setup_by_hand_commands.py, test_update_add_codes_documented.py, test_agents_md_codes.py, test_cli_reference.py, test_free_plan_hint.py and test_family_tracking_branch.py 255 passed
On the merged head fc0656b: the sentence-evidence set (test_adopt_single_repository_record.py, test_adopt_contracts_dir.py, test_dry_run_asks_remotes.py and the two setup wrappers), with git-filter-repo 2.47.0 on PATH and a stub gh, after #570 changed adopt-project.py 92 passed
On the merged head a6d4f5f (main bf7b19b merged in): the same nine README-reader files 255 passed
On the merged head a6d4f5f: test_install_sh.py and test_openreposhape_command.py, which #596 changed and whose README text they read 113 passed
On the merged head a6d4f5f: the sentence-evidence set again, after #583 changed adopt-project.py 92 passed
On the fix head 333095c (main 4cf5fb0 merged in as 0835381, then the round of 2026-10-16), one pytest process over the eleven README-reader and launcher files: test_repo_hygiene.py (65), test_readme_sections.py (76), test_readme_windows_quoting_sentence.py (3), test_setup_by_hand_commands.py (5), test_update_add_codes_documented.py (6), test_agents_md_codes.py (47), test_cli_reference.py (22), test_free_plan_hint.py (14), test_family_tracking_branch.py (17), test_install_sh.py (45) and test_openreposhape_command.py (68), with git-filter-repo on PATH 368 passed in 210 s
On the fix head 333095c: the cap line, test_repo_hygiene.py -k readme_is_short -s README.md is 1457 lines; the cap is 1422 + 5 (550.txt) + 22 (564.txt) + 8 (606.txt) = 1457, 1 passed
On the second-round head 66b98a2 (one commit on 333095c), one pytest process over the same eleven files (65, 76, 3, 5, 6, 47, 22, 14, 17, 45 and 68), with git-filter-repo on PATH 368 passed in 50.7 s
On the second-round head 66b98a2: the cap line README.md is 1459 lines; the cap is 1422 + 5 (550.txt) + 22 (564.txt) + 10 (606.txt) = 1459, 1 passed
On the third-round head 83ca2db (one commit on 66b98a2), one pytest process over the same eleven files (65, 76, 3, 5, 6, 47, 22, 14, 17, 45 and 68), with git-filter-repo on PATH 368 passed in 50.9 s
On the third-round head 83ca2db: the cap line README.md is 1459 lines; the cap is 1422 + 5 (550.txt) + 22 (564.txt) + 10 (606.txt) = 1459, 1 passed

The suite's README checks go red when the change is half done. With the new README and the cap file but the old handbook, test_the_handbook_follows_the_readmes_outline fails with "README.md § Starting a project in a new organisation has moved since the page read it ... § Adopting an existing repository has moved since the page read it"; with the new README and no 606.txt, test_readme_is_short_enough_to_be_read fails with "README.md is 1456 lines; the cap is 1422 + 5 (550.txt) + 22 (564.txt) = 1449". Nothing here is red on main, because nothing new is tested; the full suite was not run.

No new test writes a file, so the CRLF plugin has nothing to run against; the README is LF-only (test_readme_sections.py refuses a carriage return in it).

Any other README pull request that changes README § Starting, § The three legs or § Adopting conflicts with this one at the same handbook markers; land them one at a time. #627 lands after #621: the sentences about the Free-plan secrets are true of the tools only once #621's root rule is on main.

Tick L2-50, L2-41 and L2-51 on their tracking issue, #461, when this closes.

Closes #606

🤖 Generated with Claude Code

Summary by Sourcery

Correct the README and handbook guidance so documented project setup, adoption, contracts, and CI secret behavior matches the current implementation.

Enhancements:

  • Update the README and handbook to accurately document adoption defaults, mounted contract paths, setup rerun refusal timing, and Free-plan secret visibility requirements.

Documentation:

  • Synchronize handbook content and section markers with the revised README guidance.

Chores:

  • Raise the README length cap to accommodate the documentation updates.

…y.yaml` record arrives as `leg: drop`, that the adopted Makefile's `CONTRACTS_DIR` follows the spec leg's mount, and that a second setup run is refused at the plan, so the three sentences #566, #572 and #573 left false or incomplete are true of main (#606)

One README and handbook change for three lines of the v1.1.0 checklist
triage (D7, one README PR instead of one edit in each), on main fbbbc8d,
which holds #566, #572 and #573. The wording is the words those pull
requests' bodies held, checked against the code on main first.

- L2-50, README "Ambiguous is an answer": it said every entry the policy
  cannot call gets `leg: null` and that `execute` refuses while any remain.
  #566 gives a source's own `single-repository.yaml` record a default: `plan`
  writes `leg: drop`, still `review_required: true` with its question, and
  `execute` takes it as written. The body's "unless a person writes `root`"
  was not the whole of it: `check` passes `spec` too, so the sentence says
  "unless a person changes `leg:`" and names `root` as the answer the
  question offers. The handbook's adopt section gets the same sentence.
- L2-51, README's worked example: the root Makefile exports `CONTRACTS_DIR`
  at the `contracts/` of the spec leg where the plan mounts it, and
  `$(CURDIR)/spec/contracts` is the default mount only (#572). The handbook
  never said it, so its adopt section moves its marker and nothing else for
  this line.
- L2-41, README's "If it fails part-way" box: a second run is refused at
  the plan, before the `yes`, because the plan is the scaffold's `--dry-run`
  and asks whether each of the three already exists (#573). The body's
  wording said "step (5)", which is `setup-project.py`'s printed number; the
  README numbers the plan (3) and the yes (4) in the paragraph above the
  box, so it says (3). The handbook's list item says the same without a
  number, and "There is no `--force`" is now a sentence of its own.

`tests/readme-cap.d/606.txt` raises the README's cap by the seven lines
(1449 to 1456), each earned in the file. The handbook's markers move to the
new hashes of the two README segments that changed, § Starting (seven
markers) and § Adopting (one); no section it renders is otherwise changed. No
footer clause is added: ruling 29 retired them and a test refuses a new one.

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

sourcery-ai Bot commented Oct 15, 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 22 hours and 32 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 15, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates the README and handbook to accurately document source-owned adoption records, spec-leg-relative CONTRACTS_DIR generation, and pre-confirmation refusal of second setup runs, while refreshing synchronized handbook hashes and the README cap.

Sequence diagram for adoption planning of a source-owned record

sequenceDiagram
    participant Source as Source repository
    participant Plan as adopt-project.py plan
    participant Execute as execute

    Source->>Plan: Read single-repository.yaml
    Plan-->>Plan: Set leg: drop
    Plan-->>Plan: Set review_required: true
    Plan-->>Source: Show question and default answer
    Source->>Execute: Execute plan as written
    Execute-->>Source: Drop the record
    Source->>Plan: Change leg: to root or spec
    Plan-->>Plan: Accept the selected leg
Loading

File-Level Changes

Change Details Files
Document the corrected adoption behavior for a source-owned record, including its default drop leg and human override path.
  • Explain that the source’s own single-repository.yaml record is emitted with leg: drop and review_required: true.
  • Clarify that execute honors the written leg unless a reviewer changes it, with root offered by the question.
README.md
docs/handbook.html
Make the adopted Makefile’s contracts path accurate for non-default spec-leg mounts.
  • Describe CONTRACTS_DIR as following the mounted spec leg’s contracts directory.
  • Retain the default path as an example rather than presenting it as universal.
README.md
Clarify that rerunning setup is rejected during planning before confirmation and has no force option.
  • State that the plan’s dry-run checks whether all three repositories already exist.
  • Align the handbook wording with the README while omitting the README-specific step number.
README.md
docs/handbook.html
Update documentation consistency and README length metadata for the expanded prose.
  • Refresh handbook markers for the changed README sections.
  • Raise the README line-count cap with a dedicated record.
docs/handbook.html
tests/readme-cap.d/606.txt

Assessment against linked issues

Issue Objective Addressed Explanation
#606 Clarify in the README and handbook that a source's own single-repository.yaml record can arrive as leg: drop with a question, and that execute uses that default unless the leg is changed. ✅
#606 Clarify that the adopted Makefile's CONTRACTS_DIR follows the spec leg's mount, with $(CURDIR)/spec/contracts as the default. ✅
#606 Clarify in the README and handbook that a second setup run is refused during the plan, before the user confirms with yes. ✅

Possibly linked issues


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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-15T21:58:36Z)

This lane reviewed its own PR #627 at 18814b3 (one commit on main fbbbc8d), read-only, in its own detached worktrees, and authored nothing.

MERGEABLE WITH NITS. It conflicts with #596 and #598 at the handbook's seven § Starting markers, so the three land one at a time in lane 3's README turn on #473.

Each sentence is true of main. We checked each one by running the code:

  • L2-50. plan writes the record leg: drop with review_required: true and its question. check passes drop, root, spec and code. execute puts the record where leg: says, so "unless a person changes leg:" is exact.
  • L2-51. With --spec-path spec, legs/spec and a/b/c/spec, the root Makefile names $(CURDIR)/<mount>/contracts, and make contracts-dir in a recursive clone finds the contracts.
  • L2-41. The plan refuses an existing leg before the yes. "(3)" is the README's own number for the plan, consistent with the box's "(4)" for the yes.

The README records are right:

Findings:

  1. nit, optional: README.md:471 and :479-480 (handbook :1050 and :1060-1061) say twice that the plan is a dry run.
  2. nit, PR body: the test table leaves out test_free_plan_hint.py::test_the_documents_say_it_too and test_family_tracking_branch.py::test_each_doc_makes_the_branch_before_the_add, which also read README.md. Both pass.

Tests:

  • Head: 255 passed over 9 README-reader files, and 92 passed over the sentence-evidence tests.
  • Mutants: 6 of 7 failed as they should. A reverted handbook mirror sentence stays green, because markers record reads, not text.

Merges:

The report is handoffs/xFactory/attachments/lane-openRepoShape-2/review627-18814b3/final-report.md in opensoft/brett-wip.

…ll.sh sentences and #606's three sentences both stay in README and the handbook, so the page's seven § Starting markers read the merged section's hash (#606)

Merges main 396c0ff (#570, #598 and #624 since this branch's base, fbbbc8d).
Only #598 touches README.md and docs/handbook.html; #570 and #624 change code
and tests, taken as main has them.

README.md merged without a conflict. #598 rewrote its step-3 paragraph in
README § Starting a project in a new organisation, and #606 adds a sentence to
the part-way box in the same section, so that section's hash is neither
branch's (#606: 46538e8e, #598: d1666fa6) but a third one, 51702a2b.

docs/handbook.html conflicted in seven hunks, all of them the one marker line
of the seven page parts that read that section (masthead, start, partway, ci,
legs, doublepin, park). The text around the markers merged without a conflict,
so both sides' sentences are kept: #598's pipx-or-uv and readiness words in the
masthead, start and conventions, and #606's partway and adopt mirror sentences.
Each of the seven markers now names 51702a2b8c5451e2d027943074a836be0ce009e4,
the merged README section's fingerprint, computed with the hygiene test's own
readme_segments and readme_fingerprint. The § Adopting marker, ca4b6731, stays:
#598 does not touch that section.

README is 1456 lines against a cap of 1422 + 5 (550.txt) + 22 (564.txt) + 7
(606.txt) = 1456. #598 added no cap file, so the cap is unchanged by the merge.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-15T22:34:47Z) Head fc0656b merges main 396c0ff (after #598) and was re-verified CLEAN (rv627m).

Still to come: a merge word from Brett Heap, a README turn from lane 3, and green CI at fc0656b. Lane 5's #596 will need the same § Starting marker re-set.

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>
…ash sentences and #606's three sentences both stay in README and the handbook, so the page's seven § Starting markers read the merged section's hash (#606)

Merges main bf7b19b (#583, #625 and #596 since the last merge, 396c0ff).
Only #596 touches README.md and docs/handbook.html; #583 and #625 change code
and tests, taken as main has them.

README.md merged without a conflict. #596 rewords three places in README
§ Starting a project in a new organisation (the Windows and Git Bash paragraphs
of its install section) and #606 adds a sentence to the part-way box in the
same section, so that section's hash is neither branch's (#606: 51702a2b, main:
badbb6b2) but a third one, fe0d932b.

docs/handbook.html conflicted in seven hunks, all of them the one marker line
of the seven page parts that read that section (masthead, start, partway, ci,
legs, doublepin, park). The text around the markers merged without a conflict,
so both sides' sentences are kept: #596's Git Bash words and #606's partway and
adopt mirror sentences. Each of the seven markers now names
fe0d932b031e4a08fc885f93d6cd978df0d5a25c, the merged README section's
fingerprint, computed with the hygiene test's own readme_segments and
readme_fingerprint. The § Adopting marker, ca4b6731, stays: #596 does not touch
that section.

README is 1456 lines against a cap of 1422 + 5 (550.txt) + 22 (564.txt) + 7
(606.txt) = 1456. #596 added no cap file (it rewords within the cap), so the
cap is unchanged by the merge.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-15T23:44:38Z) Head a6d4f5f merges main bf7b19b (after #596) and was re-verified CLEAN (rv627m).

This head is a hand resolution of markers, so it is a new H0 and needs Linux and SonarCloud green. It is waiting for a merge word from Brett Heap and a README turn.

Brett Heap and others added 2 commits October 16, 2026 10:12
…on the assembly root as repository secrets only when the root is private or internal (#606)

#621 (#602) made the Free-plan secrets hint go by the assembly root, where the
`validate` workflow reads SHAPE_LEGS_APP_ID and SHAPE_LEGS_APP_PRIVATE_KEY: the
tools print it only when the root is private or internal, and a public root over
private legs takes the organisation's secrets and gets the per-leg note alone.
README.md still said "as REPOSITORY secrets on the GitHub Free plan" of any
project with private legs (the integration review's problem 1, rv602's N3).

- README.md § Starting a project in a new organisation: the credential sentence
  says "as REPOSITORY secrets on the assembly root when it is private or
  internal and the organisation is on the GitHub Free plan", in the six lines it
  had.
- README.md § The three legs, "Reading private legs in CI": the Free-plan
  paragraph says it is the root that decides, and that a public root takes the
  organisation's secrets (+1 line; tests/readme-cap.d/606.txt +7 -> +8, cap
  1456 -> 1457).
- docs/handbook.html, the `ci` part: the same paragraph, the same words.
- The handbook's § Starting markers (masthead, start, partway, ci, legs,
  doublepin, park) and § The three legs markers (masthead, ci, legs) carry the
  new blobs.

Lands after #621: this describes the behaviour #621 puts in the tools.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
sourcery-ai[bot]
sourcery-ai Bot previously approved these changes Oct 16, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sourcery assessment

Approved.

…on the Free plan only when they were registered `--visibility all`, or `selected` with the root in it (#606)

rv627n P1: "a public root takes org secrets" held only for organisation secrets
whose visibility reaches the root. The `gh secret set --org` commands in
"Reading private legs in CI" give `--visibility private`, and on the Free plan
a secret so made reaches no repository, so a public root that followed the
sentence got the silent green with the pin check skipped. The commands are
not changed to `--visibility all` for everyone: that would show the App key to
every repository of the organisation.

- README.md "Reading private legs in CI", the Free-plan paragraph: the public
  root takes org secrets registered with `--visibility all`, or `selected`
  with the root in it (the commands above default to `private`) (+2 lines).
- docs/handbook.html, the `ci` part: the same words.
- § The three legs markers (masthead, ci, legs) carry the new blob;
  § Starting is unchanged.
- tests/readme-cap.d/606.txt: +8 to +10, paragraph Four dated 2026-10-16 and
  its "once those pull requests landed" now "once #621 lands" (rv627n 5).

Lands after #621.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d `--visibility selected` with the root in `--repos`, or `all` at the cost of showing them to every repository (#606)

rv627n N1: the sentence named `--visibility all` first. `all` shows the App's
private key to every repository of the organisation; `selected` with the root
in `--repos` is the least-privilege form, so it comes first, and `all` follows
with its cost. The commands above still default to `private`, unchanged.

- README.md "Reading private legs in CI", the Free-plan paragraph: "a public
  root takes org secrets registered with `--visibility selected` and the root
  in `--repos`, or `all`, which shows them to every repository (the commands
  above default to `private`)", in the same 7 lines.
- docs/handbook.html, the `ci` part: the same words.
- § The three legs markers (masthead, ci, legs) carry the new blob;
  § Starting is unchanged. tests/readme-cap.d/606.txt stays +10; its
  paragraph Four names the new order.

Lands after #621.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-16T10:44:02Z) Three short rounds since rv627m's CLEAN at a6d4f5f3, re-verified MERGEABLE WITH NITS (rv627n), nits taken. The head is 83ca2db4. It lands after #621, and no merge word has been given.

83ca2db4 is a new H0 and needs Linux and SonarCloud green. It also needs lane 3's README turn. Reports: review627-333095c/ and review627-66b98a2/ in opensoft/brett-wip, handoffs/xFactory/attachments/lane-openRepoShape-2/.

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

Copy link
Copy Markdown

@brettheap
brettheap merged commit 8062810 into main Oct 16, 2026
5 checks passed
@brettheap
brettheap deleted the fix/606-readme-followups branch October 16, 2026 13:28
brettheap added a commit that referenced this pull request Oct 16, 2026
…-letter `$OPENREPOSHAPE_REPO` is a path, a Windows-spelled `$OPENREPOSHAPE_BIN_DIR` is respelled or refused, and `tests-git-bash` runs #598's installer cases (#649) (#656)

#596's Git Bash follow-ups, the five lines of #649. Two commits on main a524542: **d91b3144**, the fix, and **e85573ac**, fix round 1 from rv649's review. Together they change `openRepoShape`, `.github/workflows/tests.yml` and `tests/test_openreposhape_command.py`, and nothing else. `git diff --numstat`, d91b314: `openRepoShape` +42/-4, `tests.yml` +6/-1, the test file +182/-7. e85573a: +9/-6 (comments only), +3/-2 (a comment only), +56/-27. README, the handbook and `docs/cli.md` are unchanged: every sentence about these blocks stays true (below).

### Fix round 1 (rv649)

rv649 found the launcher's code correct and asked for changes: 0 blockers, 1 should, 6 nits. e85573a answers each point. It changes no line of the launcher's code and no command of the workflow: three comments and the tests.
- **SHOULD-1, the `D:/…` arm had no test.** Both BIN_DIR tests now run over `D:\tools\bin` and `D:/tools/bin`, and `fake_cygpath` takes the typed value. On Windows they use `str(...)` and `.as_posix()` of a path under `tmp_path`. With the arm dropped the suite stayed green (91 passed); now 5 cases fail.
- **NIT-1, the refusal's drive-answer arm had no test.** A third refusal case: a `cygpath` that answers `D:/tools/bin` with exit 0. With that arm dropped the suite stayed green; now 2 cases fail.
- **NIT-2, a `python` that is Python 2, or older than 3.9, reads as present.** Named as a cost in decision 1 and in the comment above `machine_report`. No code change: `--preflight` still refuses those machines.
- **NIT-3, the refusal's reason was false in Git Bash with `cygpath` off `PATH`.** Git Bash does read `D:\…` as a drive. The comment in `install_commands` and decision 3 now give the true reason.
- **NIT-4, the job's opening comment named two files.** It names the three.
- **NIT-5, on Windows the refusal test's value was a real `D:` drive path.** It is now a path under `tmp_path`, and the assert names that value, so a refusal that regressed would place the command where the test looks.
- **NIT-6, four claims.** Fixed in this body: "rows 1-3 are checked through the tests" (now says what runs), the clone URL for each slash (`C:\src\…` gave `https://github.com/C:\src\openRepoShape.git`), the Store stub clause (dropped, see decision 1) and the report's diffstat totals (the exact `--numstat` counts are at the top). d91b314's own commit message keeps its `....` typo and its one-URL sentence in history; the squash message is this body.
- **Composition and merge table.** Refreshed against origin/main 879d804 (below). #630 no longer conflicts.
- **Checklist candidate 3**, from rv649, is added below.

### The fix

**1. The `machine:` row for Python** (`machine_report`).
- Before: the block asked `command -v python3` and nothing else. Where Python came from python.org, Git Bash has `python` and no `python3`, so the row said `python3 MISSING` and printed the line pointing at `--preflight`, which then passed with `python`.
- Now: it asks for `python3`, then `python`, in `find_python`'s order. When only `python` answers, the row says `python3          present, as python`, and nothing points at `--preflight` for it. With neither, it says `MISSING`, as before. With `python3`, it says `present`, byte for byte as before.

**2. A drive-letter `$OPENREPOSHAPE_REPO`** (`shape_clone_url`).
- Before: `C:/src/openRepoShape` and `C:\src\openRepoShape` were read as an owner/name, so setup.sh's clone and `--doctor`'s were handed `https://github.com/C:/src/openRepoShape.git` for the first and `https://github.com/C:\src\openRepoShape.git` for the second.
- Now: a letter, a colon and then `/` or `\` is a path, and goes through as it is, as `/…` and `.…` do. No owner/name holds a colon. The package's own `clone_url` (`src/openreposhape/standard.py`, `DRIVE_RE`) already read a drive letter this way.

**3. A Windows-spelled `$OPENREPOSHAPE_BIN_DIR`** (`install_commands`).
- Before: `--install` named the directory as typed. With `D:\tools\bin` it printed `installed at D:\tools\bin/openRepoShape` and `export PATH="D:\tools\bin:$PATH"`, which the drive's colon splits in two. It also never found the directory on a `$PATH` that holds `/d/tools/bin`, so it printed that line every time.
- Now: a drive-letter value, in either slash, is spelled again with `cygpath -u`, which Git Bash has, before anything is checked or placed. Every line then names `/d/tools/bin`, and the PATH note appears only when that directory is not on `$PATH`.
- Where no `cygpath` answers (none on PATH, it fails, or it answers a drive spelling again), the value is refused before anything is placed: "--install would place this command in 'D:\tools\bin', which is spelled for Windows, and no cygpath here spells it the way bash does, so the directory could not go on $PATH: the drive's colon splits it. Nothing was placed. Set $OPENREPOSHAPE_BIN_DIR to the directory as this shell spells it, then run this again."

**4. `tests-git-bash` runs `tests/test_install_sh_filter_repo.py`.** That file holds #598's uv install, the two readinesses and the pasted next steps, which ran on Linux only, while README says Git Bash runs step 3's one line "tested in CI". It is the third path on the job's pytest line, with a comment saying the installer's suite is two files. The job's other lines, the header's "the launcher's and the installer's suites, and nothing else" and #630's release-notes sentence of the same meaning stay true.

**5. The stale docstring.** `test_a_ref_the_doctor_would_check_out_is_validated` said the refusal "has to arrive before any clone does". The command clones first, then checks the ref, and refuses before `git checkout` is handed it. The docstring now says that. The order is unchanged.

- **Line 1: `command -v` for both names, and no version check.**
  - The block promises to execute nothing but `gh auth status`, and `test_the_machine_report_runs_no_installer` holds it to that: every fake on its PATH logs a run. Running `find_python`'s check would break that promise.
  - What answers is therefore never asked its version, and that has a cost. The Microsoft Store's `python3` stub reads as present, as macOS's Command Line Tools stubs for `python3` and `git` already do (setup.sh's comment at :41-47). That is not new: the base asked `command -v python3` too, and the Store installs its `python.exe` and `python3.exe` aliases together, so `python3` answers first.
  - What is new is a `python` that is Python 2, or older than 3.9, where there is no `python3`. On the base such a machine said `python3 MISSING` and printed the `--preflight` pointer. Now it says `present, as python` and prints no pointer. It is rare (CentOS 7, macOS 10.14 or older, Python 2.7 from python.org), and `--preflight` still refuses it with "no Python 3.9 or newer on this machine (tried python3, python)". The comment above `machine_report` says so.
  - The row keeps its name `python3`, and the state carries the name that answered, in the form `gh`'s row already uses (`present, logged in`). So README's sentence ("`git`, `gh` and whether it is logged in, `python3`, and `git-filter-repo` … each `present` or `MISSING`") and the handbook's stay true, and README is not edited. Labelling the row `python` would have made both false in Git Bash.
- **Line 3: respell, and refuse only where respelling is not possible.** Respelling keeps a Git Bash user's value working: `cp`, `mkdir` and the record took the Windows spelling already, and only the printed lines were wrong. Where no `cygpath` answers, nothing in the command can spell the value as bash's `$PATH` needs it: in Git Bash with `cygpath` off `PATH` the drive is real but its colon still splits `$PATH`, and on Linux `D:\tools\bin` is a relative directory with a colon in its name. No `$PATH` line could work there, so it refuses. A `cygpath` that answers a drive spelling again is refused alike.
- **The tests' `install_with_machine` now names its directory with `bash_path`.** Its `$PATH` holds only the programs `--install` runs, and no `cygpath`. On Windows the old `str(bin_dir)` is a drive path, which would now be refused. Off Windows, `bash_path` changes nothing. Other tests that pass `str(...)` keep the full PATH, so on Windows they now go through Git Bash's own `cygpath`.
- **`run_cmd` takes a `cwd`.** On the base, and under a mutation, the new BIN_DIR tests' `D:\tools\bin` is a relative directory. With `cwd=tmp_path` it lands in the test's own directory, never in the checkout.
- **Both BIN_DIR tests run over both slashes** (`BIN_DIR_SPELLINGS`, `typed_bin_dir`). Off Windows the typed value is the fixed `D:\tools\bin` or `D:/tools/bin`. On Windows it is a path under `tmp_path`, so neither test can place a command on the machine's own `D:` drive.

New, in `tests/test_openreposhape_command.py`, 27 cases:
- `test_the_machine_report_finds_a_python_by_either_name`: `python` alone, both, neither (3).
- `test_a_windows_spelled_bin_dir_is_spelled_the_way_bash_spells_it`: not on `$PATH`, on `$PATH`, each in both slashes (4). On Windows it is a real drive path (`str(bin_dir)` or its `as_posix()`) and Git Bash's own `cygpath`; elsewhere a fake `cygpath` answers as that one would.
- `test_a_windows_spelled_bin_dir_is_refused_where_no_cygpath_spells_it`: no `cygpath`, a `cygpath` that fails, a `cygpath` that answers `D:/tools/bin`, each in both slashes (6), on the machine report's own `$PATH`.
- `test_a_clone_is_handed_a_path_or_url_as_it_is`: owner/name, URL, scp, POSIX path, `C:/…`, `C:\…` and `d:\…`, each through setup.sh (a setup.sh that prints `$OPENREPOSHAPE_REPO`) and through `--doctor` (a `git` that prints the URL it is handed and fails) (14). Nothing is cloned or fetched.

**On the base a524542** (this head's test file over the base's command): **17 failed, 80 passed** in 62.32s. The failures are exactly the new cases that need the fix: `python-alone`, the four respelling cases, the six refusal cases and the six drive-letter cases. The other 10 new cases pass there too, as they should.

**On the head e85573a** (Linux, `dirty=0`):
- `tests/test_openreposhape_command.py`, `tests/test_install_sh.py`, `tests/test_install_sh_filter_repo.py`, `tests/test_openreposhape_package.py`: **220 passed** in 175.18s. The package suite imports 13 of the launcher's cases and swaps `run_cmd`, so it is in the set.
- `tests/test_repo_hygiene.py`, `tests/test_workflow_concurrency.py`, `tests/test_release_workflow.py`: **138 passed** in 14.46s.
- The head merged with main 879d804 (a probe commit, b6cfafe3): the launcher's, the two installer files, the package suite and `tests/test_repo_hygiene.py`, **297 passed** in 188.95s.
- **The three files `tests-git-bash` runs, with Windows' `write_text` emulated**: a plugin made every `Path.write_text` in the test process write CRLF, as it does on `windows-latest`. **162 passed** in 121.06s (97 + 47 + 18). A probe test shows the plugin takes effect (it passes with the plugin and fails without it).
- **actionlint 1.7.12**, with shellcheck on PATH, on `tests.yml` alone and on the four files the CI job names: exit 0.

d91b314's own `tests-git-bash` run was green on `windows-latest`. It ran the first round's 21 new cases and `tests/test_install_sh_filter_repo.py` there, so Git Bash's own `cygpath` answered the backslash respelling on a real drive path, and the five existing tests that pass `str(...)` and now go through it passed. What was checked, and what was not:
- **Checked, from #596's Windows runs (113 passed on 5f001de, and its probes):** a fake that Python writes with `#!/bin/sh` runs under Git Bash with no mode bits, and `command -v` finds it. A script piped in text mode reaches Git Bash's bash as CRLF, and bash drops the CR. A `$OPENREPOSHAPE_BIN_DIR` set from Python reaches bash unconverted: #596's probe 4 printed `D:\a\_temp\…\bin/openRepoShape`, which is why the respelling now runs in those tests.
- **Checked on Linux:** LF bytes (the CRLF emulation above). No assert in the test file compares a path bash printed with a Python path.
- **Not checked, until e85573a's own run:** on Windows, the slash spelling (`as_posix()`) through Git Bash's real `cygpath`, the refusal's values under `tmp_path` on the machine report's `$PATH`, and the new drive-answer case. If one fails there, its log shows the typed value and the directory.

One script ran each row against the base's command and the head's, offline, with a `gh` stub first on PATH. Row 3's slash lines ran again for this round.

| Row | Base a524542 | Head e85573a |
|---|---|---|
| 1. PATH with `git`, a logged-in `gh`, `python`, `git-filter-repo`, no `python3` | `python3          MISSING`, then `run: openRepoShape --preflight …`; the log holds only `gh auth status` | `python3          present, as python`, no `run:` line; the log holds only `gh auth status` |
| 2. `OPENREPOSHAPE_REPO=C:/src/openRepoShape` | setup.sh clones `https://github.com/C:/src/openRepoShape.git`; the doctor's `git clone` too | `C:/src/openRepoShape`, both |
| 2. `OPENREPOSHAPE_REPO=C:\src\openRepoShape` | `https://github.com/C:\src\openRepoShape.git`, both | `C:\src\openRepoShape`, both |
| 2. `OPENREPOSHAPE_REPO=acme/openRepoShape` | `https://github.com/acme/openRepoShape.git` | the same |
| 3. `OPENREPOSHAPE_BIN_DIR='D:\tools\bin'`, no `cygpath` | exit 0, `installed at D:\tools\bin/openRepoShape`, `export PATH="D:\tools\bin:$PATH"` | exit 2, the refusal above, nothing placed |
| 3. `OPENREPOSHAPE_BIN_DIR='D:/tools/bin'`, no `cygpath` | exit 0, `installed at D:/tools/bin/openRepoShape`, `export PATH="D:/tools/bin:$PATH"` (on Linux, a relative directory named `D:` in the working directory) | exit 2, the same refusal with `'D:/tools/bin'`, nothing placed |
| 3. the same, a fake `cygpath -u` answering `<dir>/d/tools/bin` | the same as with none: `cygpath` is never asked | exit 0, `installed at <dir>/d/tools/bin/openRepoShape`, `export PATH="<dir>/d/tools/bin:$PATH"` |
| 4. `tests-git-bash`'s pytest line | two files | three files |
| 5. the docstring | "the refusal has to arrive before any clone does" | "clones this checkout first, then checks the ref" |

Rows 1-3 need Windows to be real: there, `python` is a real python.org install, and `cygpath` and the drive letter are Git Bash's own. On Linux they are fakes. The first Windows run runs rows 1-3's tests under Git Bash: row 3 with Git Bash's own `cygpath` on a real drive path, rows 1 and 2 with a fake `python` and a fake `git`, so a real python.org install and git's own reading of `C:/…` stay unchecked.

Each row reverts one hunk of the launcher, or one part of the BIN_DIR hunk, in a throwaway clone, then runs the 27 new cases.

| Hunk reverted | Result | Failing cases |
|---|---|---|
| none (the head) | 27 passed | — |
| `shape_clone_url`'s drive-letter arm | 6 failed | the six drive-letter cases, setup.sh and doctor |
| `machine_report`'s `python` arm | 1 failed | `python-alone` |
| the BIN_DIR block, whole | 10 failed | the four respelling cases and the six refusal cases |
| the `cygpath -u` call (always refuse) | 4 failed | the four respelling cases |
| the refusal (keep the value as typed) | 6 failed | the six refusal cases |
| `\|\| spelled=""` after the `cygpath` call | 2 failed | `cygpath-fails`, both slashes: `set -e` exits with `cygpath`'s status, 1, not the refusal's 2 |
| **the `D:/…` arm of the drive case (SHOULD-1)** | 5 failed | the two slash respelling cases and the three slash refusal cases (was green) |
| the `D:\…` arm of the drive case | 5 failed | the same five, backslash |
| **the refusal's drive-answer arm, `[A-Za-z]:*` (NIT-1)** | 2 failed | `cygpath-answers-a-drive`, both slashes (was green) |
| the refusal's empty-answer arm, `""` | 4 failed | `no-cygpath` and `cygpath-fails`, both slashes |
| `--` dropped from `cygpath -u -- "$dir"` | 4 failed | the four respelling cases: the fake answers only `-u -- <value>` |
| the respelling after the placement, not before | 4 failed | the four respelling cases |

Not probed by a test: the `tests.yml` line (CI configuration, checked by actionlint and by this PR's own Windows run), the docstring, and `install_with_machine`'s `bash_path` (identity off Windows; on Windows it is what keeps four machine-report tests from the new refusal).

| Against | Result |
|---|---|
| origin/main 879d804 (d4a7fab, then 879d804, since the last table's 95780dd) | **clean**. Of this PR's files, the three suites', README and the handbook, only `tests/conftest.py` has moved since the base a524542 (new helpers for setup-project's tests), and nothing since the last table's 95780dd. The head merged with main (a probe commit, b6cfafe3) passes the suites below. |
| lane 3's #630 61b3907 | **clean**. It conflicted at ca5c49f, at the `tests-git-bash` pytest line, because its base was older than main's #590; its head now holds main d4a7fab, and it does not change `tests.yml`. It changes `AGENTS.md`, README, `adopt-project.py`, the handbook, `docs/cli.md`, the release notes and four test or render files, none of which this PR touches. |
| #620 9f2b67c, #621 10dda17, #622 55666f3, #623 fc08a28, #627 83ca2db, #628 d64252a, #631 21bdac8, #634 b7472df, #636 30a52c0, #637 62263d2, #638 b393e1f, #639 08b6245, #642 1a42df4, #643 1fe3764, #644 733f853 | **clean** (#623 changes `tests/conftest.py`; #627 changes README and the handbook) |
| #641 e87fcb0 | conflicts in `scripts/family.py`, and so does #641 against main alone: its own conflict, not an overlap with this PR. |
| lane 5's `fix/645-fixture-copy-maintenance-race` accc7d2, `fix/646-add-member-variant-remedy` 79754b9, `fix/647-branch-left-bump-leg-setup` be1bff9 (#657), `fix/648-bootstrap-and-gate-order` 852c5c4 | **clean**, all four |

- **The Store stub check.** See the decision on line 1. `--preflight` is where a stub is told apart.
- **`install.sh`'s own lines.** It names a Windows-spelled `$OPENREPOSHAPE_BIN_DIR` as typed in its `preflight:` and `done:` lines, though the command it installs now prints `/d/…`. #649 names `--install`'s PATH note, and `install.sh` prints no PATH line. Checklist candidate 1.
- **`--help`.** Its `$OPENREPOSHAPE_REPO` and `$OPENREPOSHAPE_BIN_DIR` lines stay true, so `docs/cli.md` is not regenerated.
- **`D:`, `D:tools` and UNC values.** Checklist candidate 3.

1. **`install.sh` echoes a Windows-spelled `$OPENREPOSHAPE_BIN_DIR` as typed.** It builds `target="$bin_dir/openRepoShape"` from the raw value, so in Git Bash with `D:\tools\bin` its `preflight:` and `done:` lines say `D:\tools\bin/openRepoShape`, while the command's own lines say `/d/tools/bin/openRepoShape`. Both spellings work in Git Bash, so nothing breaks. On a machine with no `cygpath`, the command's `--install` now refuses the value, and `install.sh` stops with "the <version> openRepoShape's --install refused (exit 2); its own refusal above says why", which is right but could refuse earlier, before the fetch.
2. **The PATH note is not quoted for every directory.** `--install` prints `export PATH="$dir:$PATH"` with the directory between double quotes as it is. A directory holding `"`, `$`, a backquote or `\` prints a line that does not do what it says, and one holding `:` cannot be on `$PATH` at all. A drive letter was the one case a person in Git Bash meets by default; the rest need a deliberately odd directory name.
3. **Two kinds of value slip past both new arms** (rv649). `$OPENREPOSHAPE_BIN_DIR` set to `D:` or `D:tools` matches neither respelling arm, so it is kept as typed and prints `export PATH="D:tools:$PATH"`, as on the base. A UNC `\\server\share\…`, as `$OPENREPOSHAPE_BIN_DIR` or `$OPENREPOSHAPE_REPO`, is never respelled (the PATH note's `\\` collapses) or becomes a github.com URL. The package's `clone_url` passes a UNC path through on Windows (`os.path.isabs`).

Review: rv649 found 0 blockers, 1 should-fix and 6 nits on d91b314 (CHANGES NEEDED); the fix round e85573a answers all seven, so both mutations that survived now fail a test, and lane 5's coordinator checked it. CI (Brett Heap's ruling 27, with his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 e85573a passed tests (Linux), tests-git-bash (windows-latest) and SonarCloud; the head 022bd50 is a clean update-branch merge of main 8062810, with SonarCloud, tests-git-bash and package green on it, and the local gate passed on the head itself: the launcher, installer and package set, 220 passed, and the hygiene, workflow and README-section set, 214 passed. Landed on Brett Heap's word of 2026-10-16T13:07:34Z, "merge #656 and #657 when green".

Closes #649

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
 (c6122e8) under #646's move back (#646 x #611)

No conflict. main moved from a524542 to 35133f2 (#623, #637, #622,
#620, #628, #639, #621, #627, #657, #656). Of those, #637, #622 and
#639 change scripts/family.py, in hunks git merged on its own; none
changes this branch's three test files.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
main gained #628 (#600), #639 (#611), #621 (#602), #627 (#606, lane 2's
README and handbook), #657 (#647), #656 (#649) and #698. README.md,
adopt-project.py and every other file merged as text; docs/handbook.html
conflicted at eight marker hunks and nowhere else:

- the seven `Starting a project in a new organisation` markers, and the
  `The three legs` markers in the masthead, `ci` and `legs` that sit in
  the same hunks: this branch and #627 each moved them for their own
  README edits;
- the one `Adopting an existing repository` marker.

Every hunk took this branch's side, and then every marker was recomputed
from the merged README.md with tests/test_repo_hygiene.py's
`readme_segments` and `readme_fingerprint`: Starting (7) 45a3f886, The
three legs (3) 1d9f5bf9, Adopting (1) a7e9e29f. The page's text holds
both sides' edits: #627's handbook changes merged as text beside this
branch's, so each moved marker reads a section whose page text was
amended by the pull request that changed it.

Caps: README.md is 1473 lines, its cap: 1422 + 2 (460) + 5 (550) + 12
(563) + 22 (564) + 10 (606). AGENTS.md is 494, its cap.
test_repo_hygiene.py and test_readme_sections.py pass (154).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
…KEEPS ITS NAME (which `--project` must be), ITS IDENTITY AND ITS FULL HISTORY, and the notes are current to main de92614, with seven more pull requests, lane 2's re-run-`plan` line, lanes 4's and 1's known limits, Developer Mode for a Git Bash symlink and the trusted publishers (#563, L4-47)

- L4-47 (#457, lane 4 on #630 at 14:13:32Z): README.md:771-772 and
  docs/handbook.html:1276-1277 say "it KEEPS ITS NAME (which `--project`
  must be), ITS IDENTITY AND ITS FULL HISTORY, and becomes the assembly
  root". Two lines each, no line added: README.md is still 1473, its cap.
  The § Adopting marker moves to b23e9f98, text and marker; no other
  marker moves.
- The notes, "What changed since v1.0.1": #628 (#600), with lane 2's line
  for a plan written before v1.1.0 (#473, 12:53:40Z) after it, and #621
  (#602) under adopt-project.py; #657 (#647) under update-shape.py,
  bump-leg.py and a project's own scripts; #639 (#611) under scaffold,
  setup, family and the shared readers; #656 (#649), #698 (#679) and #627
  (#606) under install, CI and the docs. Every pull request since bfed143
  is cited (`git log --first-parent bfed143..de92614`, 74).
- Known limits: lane openRepoShape-4's ten lines (#630
  issuecomment-6217406337: L4-05, -06, -07, -08, -17, -43, -45, -54, -24,
  -156) and lane openRepoShape-1's twenty-six D9 lines (#457
  issuecomment-6214768828), as two lists, one sentence each; and the Git
  Bash limit says that Windows lets a non-administrator make a symlink
  only in Developer Mode, as #596 does.
- How this release is cut: the trusted publishers are filled from Brett
  Heap's 13:04:43Z ("the pypi are added") and 13:07:56Z ("they match what
  you listed").
- The counts (74 pull requests; 83 of 135 milestone issues closed at
  17:16Z), the "current to main" sha and the open list (52) are at main
  de92614, still placeholders for tag time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
… `--into` sentence names the `family-landing-in-standard` refusal, `make siblings` is described as it stops before cloning, and `members/` has its one-spelling rule (#695) (#738)

The family docs now say what `family.py init`, `make siblings` and the `members/` directory do since #488, #578 and #574. Base: `8062810d`. Head: `c42286de`: the work (`73fa3ff4`) and one commit for the review's nit on L20.

**The branch now holds `origin/main` at `24a977c0`, through one merge commit, `75e35f14`.** No other commit was added, and nothing was rebased or amended.

### What failed

Since #682 landed (`dc7d3fbb`), `git merge-tree --write-tree HEAD origin/main` against `c42286de` stopped on one line of `docs/handbook.html`:

    Auto-merging docs/handbook.html
    CONFLICT (content): Merge conflict in docs/handbook.html

It is the `families` section's marker, `<!-- reads README.md § Families: ... blob <hash> -->`. #682 changed a sentence of README § Families, so main's marker is `b3d19092...`. This PR changed other sentences of the same section, so its marker is `0364c579...`. Git saw one line changed two ways. Lane 2's note on #738 (2026-10-17T12:47:17Z) named the same line. I reproduced the conflict on the head I started from, then merged by hand.

### The fix

The merged README § Families is neither side's text, so the marker takes its fingerprint, `82d7f8087b9ee4d097d33e15fbfe5ae754a04a79`. I computed it with `tests/test_repo_hygiene.py`'s own `readme_fingerprint` over the merged `README.md`. It is the value lane 2's note gave, and it did not move when main moved on to `24a977c0`.

| marker value | where it came from | handbook outline test |
|---|---|---|
| `b3d19092...` | main's side | fails: "has moved since the page read it" |
| `0364c579...` | this PR's side | fails, same message |
| `82d7f808...` | the merged README § Families | passes |

I re-read both sides before taking the value:

- Main's side brought one sentence into README § Families (line 955, "`make bootstrap` reports and skips one it lacks a credential for."), and #682 had already put the matching sentence on the page.
- This PR's side is its own 55 README lines and 27 handbook lines in that section, which merged clean.
- Both sides' sentences stand in the merged README and in the page.

### Did the merge change README or handbook text beyond the conflict line?

No. I compared each changed file's diff against the parent that did not make it:

- `README.md`: the merge against `origin/main` shows exactly this PR's own change, and the merge against `c42286de` shows exactly main's own change since `8062810d` (one line, #682's). No line differs.
- `AGENTS.md`: the same two comparisons, no line differs.
- `docs/handbook.html`: the same two comparisons differ in the marker line only, in both directions.
- README's handbook markers (23 on the page) and README's line cap are untouched. `tests/test_repo_hygiene.py` passes with the cap and the markers as they are.

This PR still lands only in lane 3's README turn. If another README PR changes § Families first, the marker moves again, and the hygiene test names it.

### Tests

All on `75e35f14`, through the gh stub, with a bogus token and an empty `GH_CONFIG_DIR`.

| run | files | result |
|---|---|---|
| core | `test_readme_sections.py`, `test_family_docs_one_spelling.py` (the one test file this PR changes), `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py` | 240 passed |
| handbook readers | `test_readme_windows_quoting_sentence.py`, `test_setup_by_hand_commands.py`, `test_update_add_codes_documented.py` | 20 passed |
| family-root templates | `test_pinned_copy_text.py`, `test_template_values_single_pass.py`, `test_family_init_values.py`, `test_family_init_landing.py`, `test_siblings_family_name.py`, `test_family_makefile_siblings.py`, `test_family_siblings_placement.py` | 582 passed, 19 skipped |
| bootstrap (main's new files, over the template this PR reworded) | `test_bootstrap_spelling.py`, `test_bootstrap_without_git.py`, `test_bootstrap_leg_moves.py` | 251 passed |

### Mutants

This round adds no test and no code, so its one hunk is the marker value. Each mutant changes that value in a shared scratch clone at `75e35f14` and runs `tests/test_repo_hygiene.py::test_the_handbook_follows_the_readmes_outline`:

| mutant | result |
|---|---|
| none (control) | 1 passed |
| main's marker `b3d19092...` | 1 failed |
| this PR's marker `0364c579...` | 1 failed |
| the right marker with its last digit changed | 1 failed |
| the unresolved conflict block (`<<<<<<<`, both lines, `>>>>>>>`) | 1 failed |

### Merge checks

- `git merge-tree --write-tree HEAD origin/main` is clean at `24a977c0`.
- Main moved to `40e678e6` (#805, `templates/family-root/scripts/siblings.py` and `tests/test_siblings_inputs.py`) after this merge. It touches no README, handbook or AGENTS text, and the merge-tree against it is clean.
- Lane 2's #822 is stacked on this PR. Its reviewer found it merges clean with this PR landed at the same marker value.

1. **The `--into` sentence** (README.md § Families and the handbook). It gave the default, "the directory you are standing in", and not that `init` refuses that default inside a checkout of this standard. It now says so and names the code: `(the directory you are standing in, by default; refused inside a checkout of this standard, \`family-landing-in-standard\`)`. AGENTS.md already said it.
2. **The `make siblings` paragraph** (README.md § Families and the handbook). It said `make siblings` "WARNS and prints the exact `mv`". Since #578 it also clones nothing until the `mv` is run, so the next run clones each member once, and a link of the family's name beside the folder passes. It now says that. Lane 3's two #460 sentences ride in it (L20, L21, which lane 3 marked `-> #695`):
   - the folder must be named EXACTLY after its repository, case included, on every disk;
   - for a holder at `~/projects/InkRouter` the move makes the family folder `~/InkRouter`, beside `~/projects`, and the holder `~/InkRouter/InkRouter`, not `~/projects/InkRouter/InkRouter`.
   I wrote L20 with the paths spelled out. Lane 3's wording was "beside that folder's parent", which two readings can take. My first wording, "beside that folder, not inside it", still left one: the thing beside `~/projects` is the family folder, not the holder (rv695 N1). The printed move for that holder is `mv ~/projects/InkRouter ~/InkRouter.holder`, `mkdir -p ~/InkRouter`, `mv ~/InkRouter.holder ~/InkRouter/InkRouter` (probe below, and `test_family_siblings_placement.py` pins it).
3. **The one-spelling rule for `members/`.** One sentence, the same words in five places: AGENTS.md (family item 2), README.md § Families, the handbook, the holder's README and the holder's `AGENTS-shape.md`:
   > A holder keeps `members/` in ONE spelling on every disk: `add` refuses `family-members-dir-variant` (`member-path-taken` for a variant of the member's own path) until a human renames what sits under another spelling.

`templates/family-root/AGENTS-shape.md` changes by that one sentence. It is in `FAMILY_COPIED_VERBATIM`, so every holder reads `upstream-changed` for it on its next `update-shape.py check`. The issue's third line names it, and lane 3 asked that it ride with the next pinned-file edit. It can share the one v1.1.0 shape bump with the bootstrap fix. `templates/family-root/README.md` is templated and not pinned: an existing holder keeps its own README.

- README.md is 1458 lines, one under its cap of 1459 (1422 + 5 + 22 + 10 in `tests/readme-cap.d/`); it was 1459 at `73fa3ff4`. The brief said 1449; that is the cap before #627 landed. AGENTS.md is 492, its cap.
- The new words come to about nine README lines and three AGENTS.md lines. I fitted them by cutting wording that is stated elsewhere. **If you would rather keep the cut sentences, the alternative is a `tests/readme-cap.d/695.txt` of about +8, which needs Brett's word.** What I cut:
  - README § Families:
    - the aside "the same lands-where-you-were-standing rule projects have";
    - four rewordings that lose no meaning:
      - the intro of "Use a family when the parts deploy separately": "folding them into one assembly root would give them one gate, one release and one pin, which is the opposite of what 'deploy separately' means" is now "one assembly root would give them one gate, one release and one pin, the opposite of 'deploy separately'";
      - "`--work-dir` remains the override" is now "`--work-dir` is the override";
      - "The doubled name is kept on purpose" is now "The doubled name is on purpose";
      - "`family.yaml` is what tells the two apart" is now "`family.yaml` tells the two apart";
    - the caption "Eight services, each its own project, one holder:" under "The InkRouter example" (the section's intro and the block under it say it; this is the one line cut to make room for the L20 rewording, and the handbook keeps its caption);
    - the tool's rationale after "Nothing ever moves a checkout" (the shell, the editor, the agent lanes, "an act by surprise"); it stays in the handbook, the holder's README and the tool's own output, and README keeps the reason that matters (a linked worktree's `.git` file carries an absolute path);
    - ", not merely a repository at the right commit";
    - the private-legs reason in the `make bootstrap` paragraph;
    - "to a policy two live projects already carry a copy of" in the naming paragraph.
  - AGENTS.md:
    - the converse in the family intro ("one project is what parts that ship together already are"; README keeps it);
    - the sentence "`bump` takes the same repeatable `--trailer`" in family item 3 (stated in "Advancing a leg" step 6 and in the holder's `AGENTS-shape.md`);
    - "Do not pass `--yes` on your own initiative" in "Updating" step 2, now "Never pass `--yes` yourself", the words `test_repo_hygiene.py` requires elsewhere in the file.
- The handbook marker for README § Families is recomputed with `readme_segments` / `readme_fingerprint`: `89ef0ab5ec88...` to `0364c5794c46...` (it was `dc85a9260a67...` at `73fa3ff4`, before the L20 rewording and the caption cut). No other README section changed, so no other marker moves.

New file `tests/test_family_docs_one_spelling.py`, 17 tests: the one-spelling sentence in each of the five docs, the two codes held as strings by `scripts/family.py`, and the `--into`, `make siblings`, "named EXACTLY" and "straight in a folder" sentences in README and the handbook, plus the old "WARNS and prints" sentence gone from both. The handbook is read as the text a browser shows (`<code>` as backticks).

- On base `8062810d` (the file copied in): **15 failed, 2 passed**. The two that pass are the code-exists checks.
- On the head `c42286de`, after the L20 rewording: `test_repo_hygiene.py`, `test_readme_sections.py`, `test_agents_md_codes.py` and the new file: **217 passed** in 3.37s. The new file's `STRAIGHT_IN_A_FOLDER` is in the new README and not in the old one.
- Before that, the targeted runs, each at `8062810d` plus the working tree that was then the head (`73fa3ff4`):
  - `test_repo_hygiene.py`, `test_readme_sections.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_readme_windows_quoting_sentence.py`, `test_setup_by_hand_commands.py`, `test_update_add_codes_documented.py` and the new file: **253 passed**.
  - Fifteen files that read AGENTS.md, the holder's docs or the refusals (`test_family_init_landing`, `test_siblings_family_name`, `test_family_switch_branch_exists`, `test_family_tracking_branch`, `test_template_text_placeholders`, `test_template_values_single_pass`, `test_template_quoted_values`, `test_free_plan_hint`, `test_doctor_recorded_answers`, `test_update_shape_add`, `test_update_shape_header_comments`, `test_family_add_refusals`, `test_family_members_case_variants`, `test_family_member_path`, `test_doctor_legs_members_next`): **987 passed**.
  - Eight files: `test_adopt_plan`, `test_adopt_github_exits`, `test_adopt_late_failure`, `test_setup_project_py`, `test_family`, `test_family_siblings_placement`, `test_family_makefile_siblings`, `test_park_resume_targets`: **598 passed, 1 skipped** (`SHAPE_MEDXEHR_CLONE` unset: a plan test needs a read-only clone of MedxSoft/MedxEHR, a cloud tool).
  - `test_update_shape.py -k "shape_line or stale_readme or check_says_what_apply or readme_with_no_shape"`: 7 passed (the tests that read the holder README's last line). The rest of `test_update_shape.py` and `test_shape_doctor.py` were not run: they copy the family-root tree and match none of the lines I changed (`git grep` of the changed sentences under `tests/` finds nothing), and a first run of them crawled under a load of 90-100 on the machine.
- The full suite was not run, as the brief says.

- Row 1, on base: from a checkout of the standard, `python3 scripts/family.py init --org Acme --family Orbit --dry-run` ends `REFUSED family-landing-in-standard ... Remediation: pass \`--into ..\``. The README sentence did not say it would; it now does.
- Row 2, on head: `parent_folder_warning` of the holder's `siblings.py` for a holder at `<t>/home/projects/InkRouter` prints `NOTHING WAS CLONED` and the move `mv .../projects/InkRouter .../InkRouter.holder`, `mkdir -p .../InkRouter`, `mv .../InkRouter.holder .../InkRouter/InkRouter`. For a folder named `inkrouter` on Linux it stops the same way: the name test is exact on every disk.
- Row 3: the refusals the sentence names are the ones `test_family_members_case_variants.py` and `test_family_add_refusals.py` drive (60 + 65 tests, passed above).
- No row needs real GitHub or a cloud tool.

My own run did not happen: a usage checkpoint stopped it before it began. The reviewer (rv695) ran 30 mutants against `73fa3ff4` in a throwaway clone and **all 30 were killed**: five places, four mutants each for the one-spelling sentence, and five each for the README and the handbook sentences. The L20 sentence has changed since, and `STRAIGHT_IN_A_FOLDER` with it; no mutant was run on the new words. What the guards cover there: the sentence has a parametrized case per doc, so reverting it fails that case, and a reverted README hunk leaves the handbook marker stale.

- origin/main `55c6ba7d` (it moved past `2fac9790` by #700): clean. rv695 found `73fa3ff4` clean against `2fac9790`.
- #630 (lane 3), head `6fd33a93`: clean. It merged main `de926142`, and its handbook conflicts at the three markers are gone. On the merged tree README.md is 1472 lines and AGENTS.md 494 (counted here; rv695 found them under #630's own ledger at `73fa3ff4`).
- #682 (lane 5), head `8b39cd19`: conflicts at one line, the handbook marker for README § Families (`0364c5794c46...` here, `c40baa828dca...` there). README.md and the rest of the handbook merge clean, and the merged paragraph reads correctly, because both PRs change that section. Whichever lands second recomputes the marker from the merged README: on this head plus #682 the recomputed value is `da54c8ec2665...` (computed with `readme_fingerprint`, not test-run).
- I dropped the earlier list of clean PRs: it was taken against older heads, and several of them have landed.

(Brett Heap, 2026-10-16T13:36:23Z, on #459 line 141: "document it in the v1.1.0 release notes". `docs/release-notes/v1.1.0.md` exists only on #630, so this PR does not create it.)

> A holder made with `--family X --name Y` before #642 (the fix for #629) keeps `cd Y` in its own README, and `update-shape.py apply` never re-renders a README, so change that one line by hand to `cd X`: the holder's directory is named after its repository, `X`, never after the display name.

Such a README also shows `Y` in its layout block and its doubled-name sentence, as prose; only the `cd` line fails when run.

- The holder's README does not get lane 3's L20 and L21: lane 3 asked for them in the README paragraph, and the holder's README already names the folder after the repository.
- Checklist candidate, not for this PR (rv695 N6): the holder's own docs say the folder is "named after the family" (`templates/family-root/README.md:73`, `templates/family-root/AGENTS-shape.md:99`) and not "EXACTLY, case included", which the standard's README and handbook now say and `siblings.py` tests (`folder.name == family`). Lane 3 asked for L21 only in the standard's README paragraph, so it is not owed here; it could ride in the same re-pin.

Review: lane 5's independent review approved c42286d with its nits fixed; 75e35f1 is its update onto main (lane 2's note, issuecomment-6234057822); Codex found no major issues. CI (Brett Heap's ruling 27): H0 is the head 0b26ace itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 2e476a7; and the local gate passed on its predicted squash tree 68dd413 over main 112eeed: the family_docs_one_spelling, readme_sections, readme_windows_quoting_sentence, setup_by_hand_commands, update_add_codes_documented, family_tracking_branch, family_switch_branch_exists, pinned_copy_text, repo_hygiene, agents_md_codes, cli_reference, family sets, 527 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #695

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

Labels

None yet

Projects

None yet

1 participant