Skip to content

bootstrap.py: a leg on another branch whose tracking branch is behind the pin is fast-forwarded and checked out as one at the pin already is, and the family holder's AGENTS-shape.md and bump-leg.py say bootstrap places a leg where that loses no commit (#603) - #615

Merged
brettheap merged 4 commits into
mainfrom
fix/bootstrap-behind-pin-other-branch
Oct 15, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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

What changed and why

fast_forward_to_pin moved a tracking branch BEHIND the pin, and put the leg on it, from two checkouts: HEAD on the branch itself, and HEAD detached at the pin. HEAD on ANOTHER branch (T7) fell to _say_neither ("which is neither") and nothing moved, while place_at_pin checked the same checkout out for a tracking branch AT the pin (P9). R556 probed both at 26c1600, and the P9 test left the difference "#458's to rule on". The family holder's AGENTS-shape.md and bump-leg.py's leg_placement docstring still promised placement unconditionally, which #556 had already taken out of the assembly root's copy.

D1, option A of the checklist triage (Brett Heap, 2026-10-15T16:49:38Z): the code follows the sentence. T7 is placed as P9 is.

The case table

Tracking branch (rows) by where HEAD is (columns), measured on a bumped scaffold (leg detached at the new pin P over main at the old pin O) before and after this change, with the real bootstrap.py. "no commit yet" is git switch --orphan; git checkout --orphan, which leaves the files staged, gave the same result in every row. Bold cells changed (and now also print the one line above); every other cell is byte-identical before and after.

main is HEAD on it detached at the pin detached elsewhere on another branch on a branch with no commit yet
at the pin placed placed left, which is neither placed (P9) placed
behind the pin fast-forwarded fast-forwarded left, which is neither fast-forwarded (was left) fast-forwarded (was left)
ahead, no commit of its own left, not moving moved back left, not moving left, not moving left, not moving
ahead, one of its own left, not moving left, not moving left, not moving left, not moving left, not moving
missing created, on it created, on it left, does not exist created, on it created, on it

The assembly copy's sentence, now "places each leg on its tracking branch AT the pin where that loses no commit, and leaves one detached at any other commit, saying why", is true of all three changed cells (it was false of them before), and of the ignored-file and merge-in-progress states, where "loses nothing" was not. It still disagrees with the code in three states, left for #458 with no code changed: ahead with no commit of its own (its tip IS its upstream's remote-tracking ref), with HEAD on the branch, on another branch, or on a branch with no commit yet. The commits are held by the remote-tracking ref, as for the "moved back" cell, but move_back_to_pin starts only from HEAD detached at the pin, so these keep not moving an existing branch.

Tests

tests/test_bootstrap_after_bump.py, on its own harness, lines asserted byte for byte (49 tests, 11 of them new, and three existing ones touched):

  • T7: elsewhere with a commit of its own (its file is taken out, the pin's files put in, elsewhere untouched and holding its commit, main and HEAD at the pin, the pin not moved), with the fast-forwarded line followed by the new line;
  • T7 with elsewhere exactly at the pin: the same, and no file in the leg is rewritten (bytes and mtimes compared);
  • T7 from a branch that is itself behind the pin: the tracking branch moves, elsewhere stays at the old pin;
  • T7 refused: a local edit on elsewhere to a file the pin changes. Moved-and-refused lines and no new line, HEAD on elsewhere with the edit intact, main at the pin; the second run is P9's refusal; with the edit gone it places;
  • T7 with branch -f refused (another worktree holds main): the not-moved refusal, no new line, nothing moves;
  • a branch with no commit yet, behind or at the pin, free or with an untracked file in the way (four cases): the orphan wording on the free T7 case, no line on the rest;
  • new in round 1: a T7 move with a failing post-checkout hook: the fast-forward line, the new line, then checked out, and then git said: and the hook's words, HEAD on main at the pin, no refused;
  • new in round 1: a branch named with U+2028 is spelled in the new line;
  • the detached-at-the-pin and HEAD-on-the-branch starts assert the new line is absent;
  • the P9 test's docstring no longer leaves the difference "Tracking: update-shape.py and bump-leg.py, keeping a project's shape copies and leg pins current #458's to rule on" (its asserts stay);
  • the control, a leg detached off the pin still saying "which is neither", is the existing test_a_commit_made_on_the_detached_leg_is_not_left_behind.

Runs, from a standalone clone, /usr/bin/python3 -m pytest -rs over test_bootstrap_after_bump, test_bootstrap_spelling, test_scaffold_e2e, test_bump_leg, test_bump_leg_dry_run_tracking, test_family, test_family_siblings_placement, test_family_tracking_branch, test_doctor_legs_members_next, test_template_text_placeholders, test_repo_hygiene, test_cli_reference, test_agents_md_codes, test_doctor_next_is_a_command, test_scaffold_mount_ignored, test_adopt_gitmodules_values and test_shape_doctor (the files git grep finds the changed phrases in):

  • round 1's commit f8989b56: 1589 passed, 4 skipped, exit 0;
  • the head 088c7728 (the merge of main below), with test_bump_leg_paste_lines and test_bump_leg_rollback added: 1646 passed, 4 skipped, exit 0 (17 min 28 s); the skips of those two runs: test_bump_leg.py:693 (needs a disk that ignores case) and three at test_shape_doctor.py:9878 (the PowerShell line, a Windows host only);
  • round 2's commit 6ad23038, over test_bootstrap_after_bump, test_bootstrap_spelling, test_template_text_placeholders, test_template_values_single_pass, test_free_plan_hint, test_family_tracking_branch, test_family, test_bump_leg, test_bump_leg_paste_lines, test_repo_hygiene, test_cli_reference and test_doctor_legs_members_next: 992 passed, 1 skipped, exit 0 (7 min 24 s), the skip being test_bump_leg.py:693.

The full suite was not run here.

Mutants, each in a scratch clone with this change's files copied in, against all 49 tests of the module:

  • M1, remove the new path: 9 fail, all this PR's.
  • M2, merge --ff-only on the branch HEAD is on: 9 fail, including "elsewhere is not moved".
  • M5, drop _say_after in fast_forward_to_pin: it survived round 0; the hook test now fails (1 fail).
  • M8, drop the new line: 6 fail.
  • M9, print the new line for every start: 2 fail (the detached-at-the-pin and HEAD-on-the-branch starts).
  • M10, the orphan wording is the other branch's: 1 fails.
  • M11, the new line printed before the fast-forwarded line: 6 fail; M13, the new line naming the tracking branch instead of the branch left: 6 fail (both over round 2's bytes).
  • R603 killed M3 (check out before moving the branch), M4 (an orphan HEAD treated as detached), M6 (also move from a leg detached elsewhere) and M7 (branch -f on the wrong branch) against round 0's tests.

Shared files

#588 has landed (709eec16), and it changed scripts/bump-leg.py, which this PR changes too: the one docstring hunk in leg_placement, and none of #588's. Under ruling 27 this branch merged that main in round 1: merge commit 088c7728, tree 8943978679611f825262b7d8bd85387eda9d96ac, a clean merge ("Auto-merging scripts/bump-leg.py", no conflict). Round 2 is one commit on top of it, with no merge of main: 6ad23038. git merge-tree --write-tree of the head with main 66267f0a (current when this was written) is clean, tree 165f2711867f0ab2a4797ef202383dcc01a7c3e0, and main has touched none of the PR's files since 709eec16.

The re-pin, measured

Measured at round 2's commit with this PR's own changes only: update-shape.py check --upstream <a clone of base 3f768a6 plus this PR's diff>, on an assembly root and a family holder scaffolded from base 3f768a66. (scripts/bump-leg.py in that clone carries only this PR's docstring hunk; #588's lines are main's.) At the branch tip, which has main's #575 validators merged in through 709eec16, the same roots also show scripts/validate-pins.py (assembly: upstream-changed 4 unchanged 7) and scripts/validate-family.py (family: upstream-changed 2 unchanged 8); those rows are main's, not this PR's.

  • assembly root: upstream-changed scripts/bootstrap.py, upstream-changed Makefile, upstream-changed AGENTS-shape.md; upstream-changed 3 unchanged 8;
  • family holder: upstream-changed AGENTS-shape.md only; upstream-changed 1 unchanged 9.

A pinned-copy change: an assembly root takes it as one upstream-changed re-pin of scripts/bootstrap.py, Makefile and AGENTS-shape.md, a family holder as one of AGENTS-shape.md; the two README templates are rendered at scaffold time, not pinned, so they reach new projects only.

Closes #603

🤖 Generated with Claude Code

… the pin is fast-forwarded and checked out as one at the pin already is, and the family holder's AGENTS-shape.md and bump-leg.py say bootstrap places a leg where that loses nothing (#603)

THE DEFECT. `fast_forward_to_pin` moved a tracking branch BEHIND the pin, and
put the leg on it, from two checkouts: HEAD on the branch itself, and HEAD
detached at the pin. HEAD on ANOTHER branch (T7) was left, under `which is
neither`, while `place_at_pin` checked the same checkout out for a tracking
branch AT the pin (P9). R556 probed both at 26c1600, and the P9 test left the
difference "#458's to rule on". The family holder's AGENTS-shape.md and
`leg_placement`'s docstring in bump-leg.py still promised placement
unconditionally, which #556 had taken out of the assembly root's copy.

THE DECISION. D1, option A of the checklist triage, approved by Brett Heap at
2026-10-15T16:49:38Z: the code follows the sentence. T7 is placed as P9 is.

THE MECHANISM. HEAD on a `refs/heads/` ref that is not the tracking branch
takes the path HEAD detached at the pin takes: `_move_and_check_out`, which
is `branch -f <branch> <pin>`, then `_check_out`, judged by where HEAD is
afterwards. Nothing is lost: the branch tip is an ancestor of the pin, and the
other branch is not touched, so it keeps its commits. The line printed is the
other two starts' `branch main fast-forwarded <local> -> <pin>, the pin`, and
nothing is said of the branch HEAD left, as P9 says nothing. From another
branch the checkout changes files, so git can refuse it where one from the pin
cannot (a local edit to a file that differs, an untracked file): the branch
stays at the pin and is never put back, HEAD stays on the other branch, the
existing moved-and-refused lines are printed, and the next run meets P9. A
`branch -f` git refuses (another worktree holds the tracking branch) prints
the existing not-moved refusal. A leg detached at a commit that is not the pin
still gets `which is neither`: a commit made there is on no branch. A HEAD on
a branch with no commit yet is placed as P9 places it. The docstrings of
`fast_forward_to_pin`, `_move_and_check_out` and `_say_neither` say why and
name #603 and D1.

THE DOCS. templates/family-root/AGENTS-shape.md says what the assembly copy has
said since #556, in the same three lines: each member's legs go on their
tracking branch AT their pinned commits where that loses nothing, with
bootstrap saying why where it does not, by running the member's own
bootstrap. `leg_placement`'s docstring gains `where that loses nothing`; no
printed line of bump-leg.py changes. The assembly copy's sentence stays: read
against a 5 x 6 table of tracking branch (at the pin, behind, ahead with no
commit of its own, ahead with one, missing) by HEAD (on it, detached at the
pin, detached elsewhere, on another branch, on a branch with no commit yet,
and that with its index staged) before and after, exactly three cells change
(behind x on another branch, and behind x each form of no commit yet), and the
sentence is true of each. The states where it and the code still differ are
ahead-with-no-commit-of-its-own x HEAD on it, on another branch, or on a
branch with no commit yet, which keep `not moving an existing branch` though
the remote-tracking ref holds their commits: left for #458, no code changed.

THE RE-PIN. bootstrap.py and both AGENTS-shape.md files are pinned copies. An
assembly root takes this as one `upstream-changed` re-pin of
`scripts/bootstrap.py` and `AGENTS-shape.md`, a family holder as one of
`AGENTS-shape.md`.

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

sourcery-ai Bot commented Oct 15, 2026

Copy link
Copy Markdown

Reviewer's Guide

Bootstrap now places a bumped leg onto its tracking branch when HEAD is on another local or unborn branch, using a force-and-checkout path that preserves the other branch, reports checkout failures consistently, and is backed by extensive behavioral tests and documentation updates.

State diagram for bumped leg placement outcomes

stateDiagram-v2
    [*] --> HeadLocation
    HeadLocation --> TrackingBranch: HEAD on tracking branch
    HeadLocation --> PinDetached: detached at pin
    HeadLocation --> OtherBranch: HEAD on another or unborn branch
    HeadLocation --> ElsewhereDetached: detached elsewhere
    TrackingBranch --> Placed: merge --ff-only succeeds
    PinDetached --> Placed: _move_and_check_out
    OtherBranch --> Placed: branch -f and _check_out succeed
    OtherBranch --> TrackingAtPin: checkout refused after branch -f
    OtherBranch --> NotMoved: branch -f refused
    ElsewhereDetached --> LeftDetached: _say_neither
    Placed --> [*]
    TrackingAtPin --> [*]
    NotMoved --> [*]
    LeftDetached --> [*]
Loading

Flow diagram for bootstrap leg placement after a bump

flowchart TD
    A["bootstrap fast_forward_to_pin"] --> B{"Where is HEAD?"}
    B -->|tracking branch| C["merge --ff-only pin"]
    B -->|detached at pin| D["_move_and_check_out"]
    B -->|another local or unborn branch| D
    B -->|detached elsewhere| E["_say_neither; leave leg"]
    D --> F["branch -f branch pin"]
    F -->|success| G["_check_out tracking branch"]
    F -->|refused| H["report not moved"]
    G -->|success| I["leg placed at pin"]
    G -->|refused| J["branch remains at pin; HEAD stays put"]
Loading

File-Level Changes

Change Details Files
Extend bootstrap placement to recover legs checked out on another local branch without moving that branch.
  • Detect non-tracking local branches and route them through branch-forcing plus checkout logic.
  • Fast-forward the tracking branch to the pin while preserving the branch currently holding HEAD.
  • Handle checkout conflicts and worktree-held branches with existing refusal behavior and leave recoverable state for the next run.
  • Keep detached work at non-pin commits classified as neither.
templates/assembly-root/scripts/bootstrap.py
Align documentation with conditional leg placement semantics.
  • Document that placement occurs where it loses nothing and explain when bootstrap reports why it cannot place a leg.
  • Update implementation docstrings for the added checkout path and preserved detached-commit behavior.
templates/family-root/AGENTS-shape.md
scripts/bump-leg.py
templates/assembly-root/scripts/bootstrap.py
Add end-to-end coverage for branch, orphan, refusal, and preservation cases.
  • Verify behind-tracking-branch legs on another branches are placed while the other branch remains unchanged.
  • Cover already-at-pin branches, checkout conflicts, worktree refusal, unborn branches, and detached non-pin controls.
  • Assert exact output, HEAD/branch positions, file preservation, and subsequent-run behavior.
tests/test_bootstrap_after_bump.py

Assessment against linked issues

Issue Objective Addressed Explanation
#603 When a leg's tracking branch is behind the pin and HEAD is on another branch, fast-forward the tracking branch to the pin and check it out without moving or losing commits on the other branch; preserve refusal behavior when Git cannot check out the tracking branch. ✅
#603 Correct the family holder's AGENTS-shape.md and bump-leg.py documentation so they promise placement only where it loses nothing and explain that bootstrap says why when it cannot place a leg. ✅

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

…mit" names the ignored file and the merge in progress a checkout ends, a post-checkout hook after the T7 move is tested, and the Makefile help and both README templates stop promising placement (#603)

Fix round 1 of PR #615, on R603's review of 160f44d and the coordinator's
rulings on it.

F3, THE LEG IS TOLD. When HEAD started on ANOTHER branch, the T7 path prints
one more indented line under `fast-forwarded` and before git's own lines:
`the leg was on <other>, which keeps its commits; it is on <tracking> now`, or
`the leg was on <other>, a branch with no commit yet; it is on <tracking> now`
for an orphan HEAD. P9 had said nothing of the branch it left, and parity with
P9 was a ruling about the MOVE, never a ruling to say nothing: a person whose
HEAD left their branch must be told, since their next commit lands on the
tracking branch. The branch name is spelled (#391). The `fast-forwarded` line
is byte-identical for every other start, P9's lines are not touched (P9's own
matching line goes to #458), and the refused paths print no extra line: HEAD
stays on the other branch there, and git's lines already say why.

F2, "LOSES NOTHING" BECOMES "LOSES NO COMMIT". Docstrings only. A checkout git
makes from another branch overwrites an IGNORED file at a path the pin tracks,
and moving off a branch with a merge in progress ends that merge: `MERGE_HEAD`
goes and the staged result is carried onto the tracking branch. Both are
already true of `place_at_pin`, of the missing-branch path and, for the
ignored file, of the on-branch `merge --ff-only`; they are #458's to rule on.
The docstrings of `fast_forward_to_pin` and `_move_and_check_out` and the #603
paragraph of the test module's docstring say so.

F4, THE HOOK TEST. A T7 test with `failing_post_checkout`, from the bumped
state with `elsewhere` at the old pin, asserts the fast-forward line, the new
line, then `checked out, and then git said:` and the hook's words, HEAD on
`main` at the new pin, and no `refused`. R603's mutant M5, which drops
`_say_after` in `fast_forward_to_pin`, survived every test and is killed by it.
The new line is asserted byte for byte in the T7 tests and the orphan free
case, and asserted absent from the HEAD-on-the-branch and detached-at-the-pin
starts, the refused paths and P9; a test spells a branch named with U+2028.

F5, THREE UNCONDITIONAL PROMISES. `templates/assembly-root/Makefile`'s help
(a pinned file) and `templates/family-root/README.md` and
`templates/assembly-root/README.md` (both rendered, not pinned) say bootstrap
puts the legs on their tracking branch at the pin where that loses no commit,
and says why where it does not. The two READMEs keep their line counts; the
Makefile help takes one more line, aligned as its other lines are.
`templates/family-root/scripts/bootstrap.py:31-32` is left for #458: changing
it would add a file to the family holder's re-pin.

THE RE-PIN, MEASURED (F1; `update-shape.py check --upstream <this branch>`
on roots scaffolded from base 3f768a6): an assembly root takes it as one
`upstream-changed` re-pin of `scripts/bootstrap.py` and `Makefile` (the totals
read `upstream-changed 2` and `unchanged 9`, and its `AGENTS-shape.md` does not
change), a family holder as one of `AGENTS-shape.md` (`upstream-changed 1`,
`unchanged 9`). The two README templates are rendered at scaffold time, not
pinned, so they reach new projects only. The first commit's body said an
assembly root re-pins `AGENTS-shape.md` too; it does not.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brett Heap and others added 2 commits October 15, 2026 19:08
… bump-leg.py lines, a clean merge

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rward_to_pin stays within Sonar's complexity limit, and both AGENTS-shape.md copies and bump-leg.py say bootstrap places a leg where that loses no commit (#603)

Fix round 2 of PR #615, on R603's verification of 088c772 and the
coordinator's rulings on it.

S3776. Round 1's `if other_branch:` block, with the ternary in it, took
`fast_forward_to_pin`'s cognitive complexity to 16 against Sonar's 15. The
print is now `_say_left(head, current, named)`, next to `_place_of`, with a
docstring that says why it is a function of its own. The printed bytes do not
change: the tests that pin them are untouched. The caller is 14 by the same
count that gave the helper-less version 16.

"LOSES NO COMMIT". Round 1 measured two states where placement loses a file or
a merge but no commit (an ignored file at a path the pin tracks, and a merge in
progress on the branch HEAD leaves), so "where that loses nothing" is false of
them. `templates/assembly-root/AGENTS-shape.md` (#556's sentence, which this PR
now changes), `templates/family-root/AGENTS-shape.md` and `leg_placement`'s
docstring in `scripts/bump-leg.py` say "where that loses no commit". Both
AGENTS-shape.md lines grow from 75 to 77 characters and keep their line counts;
the docstring is re-wrapped in the same three lines. Left alone: the
docstrings in bootstrap.py about moving the branch REF, which does lose
nothing, the move-back line's `nothing is lost, <upstream> still holds those
commits`, which its own clause scopes to commits, and shape-doctor.py.

README. In `templates/assembly-root/README.md`, "(so you are not staring at a
detached HEAD)" follows "at its pinned commit" directly again; the paragraph is
still five lines. The template is rendered at scaffold time, not pinned.

THE RE-PIN, MEASURED AT THIS COMMIT with the PR's own changes only
(`update-shape.py check --upstream <a clone of base 3f768a6 plus this PR's
diff>`, on roots scaffolded from base 3f768a6; the branch tip also carries
main's #575 validators, merged in through 709eec1, which would add
`scripts/validate-pins.py` and `scripts/validate-family.py` rows): an assembly
root takes it as one `upstream-changed` re-pin of `scripts/bootstrap.py`,
`Makefile` and `AGENTS-shape.md` (the totals read `upstream-changed 3` and
`unchanged 8`), a family holder as one of `AGENTS-shape.md` (`upstream-changed
1`, `unchanged 9`). The two README templates are rendered, not pinned, so they
reach new projects only.

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

Copy link
Copy Markdown

@brettheap brettheap changed the title bootstrap.py: a leg on another branch whose tracking branch is behind the pin is fast-forwarded and checked out as one at the pin already is, and the family holder's AGENTS-shape.md and bump-leg.py say bootstrap places a leg where that loses nothing (#603) bootstrap.py: a leg on another branch whose tracking branch is behind the pin is fast-forwarded and checked out as one at the pin already is, and the family holder's AGENTS-shape.md and bump-leg.py say bootstrap places a leg where that loses no commit (#603) Oct 15, 2026
@brettheap
brettheap marked this pull request as ready for review October 15, 2026 20:32
@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 1 day and 2 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 77bb5f0 into main Oct 15, 2026
3 checks passed
@brettheap
brettheap deleted the fix/bootstrap-behind-pin-other-branch branch October 15, 2026 20:33
brettheap pushed a commit that referenced this pull request Oct 15, 2026
…fter it) into fix/585-git-bash-windows: the four files that conflict take this branch's copy, because main's copy of each is byte for byte #587's final head 3d8fa05, which the merge before this one already holds (#585)

#587 squash-landed on main as 22eacee, whose tree is #587's final head
3d8fa05 merged onto main 2b6dbb7. The previous commit merged 3d8fa05, so
this branch already holds #587's every change; main merges the commits
3d8fa05 did not have: 2b6dbb7 (#584, adopt-project.py's symlink finding) and
77bb5f0 (#615, bootstrap.py and its templates, landed after 22eacee).

The squash is not an ancestor of 3d8fa05, so the merge base is 62ef256 and
four files conflict although both sides hold the same #587 text:
README.md, docs/handbook.html, tests/test_install_sh.py (add/add) and
tests/test_openreposhape_command.py. For each, `git show 3d8fa05:<file>` and
`git show origin/main:<file>` are the same blob, and 77bb5f0 does not touch
it:
- README.md                            d164131
- docs/handbook.html                   59ec34d
- tests/test_install_sh.py             34c7de5
- tests/test_openreposhape_command.py  5e49547
So main's copy is 3d8fa05's copy, and ours is 3d8fa05's plus this branch's
change: each takes ours (`git checkout --ours`). No file was merged by hand.

Every other file merged as text. Against main this merge differs only in this
branch's six files: .github/workflows/tests.yml, README.md,
docs/handbook.html, tests/conftest.py, tests/test_install_sh.py and
tests/test_openreposhape_command.py (430+, 141-), and
`git merge-tree --write-tree HEAD origin/main` gives this commit's own tree.

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 15, 2026
…e-doctor.py and test lines, a clean merge

Main moved from 62ef256 to 55faad2 under this branch, four commits: #587 (22eacee), #615 (77bb5f0), #584 (2b6dbb7) and #591 (55faad2, for #521 and #427). #591 changed `shape-doctor.py` (the `origin` reading, `:363-369` and `:7339+`) and `tests/test_shape_doctor.py` (`:9122+`), the two files this branch changes, so ruling 27 asks for this merge before it lands. The other three touch neither file.

No conflict: this branch's hunks are in `members_verdict` (`shape-doctor.py:6975-6992` and `:7163-7183`) and after `test_a_sibling_with_this_members_id_but_another_origin_is_not_counted` in the tests, and #591's are far from both. Nothing was resolved by hand.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
…his PR's head carries the newest main before rv540c's fix round (#540, #451, #450)

#591 is the only commit main gained since d62a03c (GitHub's update-branch
merge of main 77bb5f0, #574, #584, #587, #615): adopt-project.py and
shape-doctor.py read `origin` one way (#521, #427). It touches this PR's
adopt-project.py, in the `_checked_plan_inputs` neighbourhood and elsewhere,
and no other file this PR changes: git merge-tree --write-tree says the merge
is clean, so this commit holds the textual resolution only (none was needed)
and every change of behaviour comes after it. #589 (#449) and #582 stay as
f540n merged them.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #585

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 16, 2026
… it what to do where an earlier act left that branch, so `already exists` is no dead end (#607's sentence, in the two tools it left) (#657)

#607 (#637) gave `family.py` and the holder's `validate-family.py` one sentence for a `switch -c` that fails `fatal: a branch named '<branch>' already exists` (exit 128):

> If `git switch -c` says that branch already exists, an earlier act left it: give the branch another name, which starts it where the holder stands, or, only if that branch holds work not merged yet, the same command with `switch` in place of `switch -c` goes back to it and keeps every commit on it.

Two tools still printed a `switch -c` and said nothing more. Both now say the same sentence under that line, each as its own constant, `BRANCH_LEFT`, as #637 does:

- **`scripts/bump-leg.py`**, with "the project" where the others say "the holder". It follows the command in all three places `_switch_command` is named:
  - the `bump-leg-on-tracking-branch` remedy: after `and re-run.`;
  - the `bump-leg-root-detached` remedy: on the line under it;
  - the dry run's closing hint (#477): the new last line, under the hint.
- **`setup-project.py`**: the same sentence, word for word (the holder), as one more comment line under the family block's `switch -c` line. The family block's two comment lines stay, and the line comes after them.

No command line changes, and none is quoted differently. The sentence is words, not a second command, so no PowerShell quoting is added (#532) and no README sentence about quoting becomes false.

Pinned files: none. This changes no file under `templates/`.

1. **Where the sentence goes in bump-leg's tracking-branch remedy: after `and re-run.`, not between the command and `and re-run.`** The remedy reads `Run:`, the command on its own line, `and re-run.` With the sentence between, `and re-run.` would hang under a full sentence. After it, the text before the sentence is byte for byte what it was, so the command line and `and re-run.` keep their places. The sentence is still under the `switch -c` line.
2. **setup's sentence is one comment line, unbroken (about 300 columns).** The brief asked for one comment line, word for word. `family.py`'s dry run prints it unbroken too. The block's other comments are hand-wrapped at 78 columns. A wrapped block would be a small change if the coordinator prefers it; the test would then join the comment lines.
3. **Every printed command line is unchanged**, and the tests prove it: bump-leg's line is read back by `shlex.split` and compared to the argv it always meant (`switch_argv`); setup's by `host_words`, on a folder with a space and one with a space beside an apostrophe. The block still has exactly two command lines.
4. **One rule, four copies.** #637's equality test, now `test_the_tools_say_the_same_words`, also asserts that `setup-project.py`'s constant equals `family.py`'s and the validator's, and that bump-leg's equals it with "the project" for "the holder". It was renamed from `test_the_two_tools_say_the_same_words`, because "two" is no longer true. The project's wording is pinned as text once, as `BRANCH_LEFT_PROJECT` in `tests/test_bump_leg_paste_lines.py`, beside the lines it follows; the holder's is #637's pinned copy in `tests/test_validate_family_branch_step.py`. `test_the_two_pinned_texts_differ_in_the_one_word_only` ties the two pinned copies together.
5. **Existing tests that pinned the old last lines take the sentence.** Seven tests in three files (listed under Tests). Each read the last line of an output, or the exact end of a remedy; none changes what it checks.
6. **The new module imports #637's typing helpers** (`leave_branch`, `refused_as_left`, `type_other_name`, `typed`, `without_c`, `head`, `on_branch`) instead of copying them, so the duplication gate sees one body.

New module `tests/test_branch_left_bump_leg_setup.py`, 19 tests:

- bump-leg, each of three places (`tracking`, `detached`, `dry-run`): the command is the one it always was and what stands under it is the sentence (3); the printed line makes the branch where none is left (3); where an earlier bump left `bump/spec-<id>` holding a commit of its own, the printed line fails `already exists` (exit 128) and changes nothing, then **another name** works (the new branch starts where the root stands, the bump goes through on it, its commit sits on the commit the root stood on, and `HEAD:spec` is the new commit) (3), and **going back** with `switch` for `switch -c` works (back on the branch at its own commit, the bump goes through there on top of it) (3).
- setup, the family block: the line, the two comments, the sentence, the `add` line, two command lines (2, one per folder name); a real `setup-project.py` run prints the sentence once, under the right comment (1); in a real local holder (the `family` fixture's, on its tracking branch), with a real member: the printed line makes the branch where none is left, and where an earlier try left `family/add-<project>` with a commit of its own, **another name** works and `family.py add` goes through on it, and **going back** works and `add` goes through on top of the earlier commit (3).
- `test_the_two_pinned_texts_differ_in_the_one_word_only` (1).

Edited tests: seven cases in six test functions of the three bump-leg files, `test_bump_leg_dry_run_tracking.py` (4: the dry run's report, the difference from a dry run on a branch, the hint pasted for a root with a space, the real refusal byte for byte), `test_bump_leg_paste_lines.py` (1: the exact remedies) and `test_bump_leg_dry_run_truth.py` (2: the hint's branch name, `[local-remote-dir]` and `[origin]`); and, beside them, the extended `test_the_tools_say_the_same_words` in `test_family_switch_branch_exists.py`.

Runs, on be1bff9 (BASE a524542 plus main 95780dd), through `run9.sh`, `gh` stub first on PATH:

- New module with #637's module: `44 passed in 61.90s`.
- Set 1, 21 files: every `tests/test_bump_leg*.py` (7), the new module, `test_bootstrap_after_bump`, `test_stdout_encoding`, `test_non_utf8_git_reads`, `test_agents_md_codes`, `test_commit_trailers`, `test_update_shape_paste_lines`, `test_validate_pins_lockstep_lines`, `test_pin_rewrite_layout`, `test_validator_child_encoding`, `test_validate_pins_unreadable_workflows_dir`, `test_template_text_placeholders`, `test_repo_hygiene`, `test_cli_reference`: `1040 passed, 2 skipped in 459.92s`. Both skips were there before: a case-ignoring disk (`test_bump_leg.py:693`) and a Windows default (`test_stdout_encoding.py:286`).
- Set 2, 10 files: every file `git grep -l 'switch -c' tests/` names outside set 1 (`test_dry_run_asks_remotes`, `test_family_printed_quoting`, `test_family_refuse_up_front`, `test_family_switch_branch_exists`, `test_family_tracking_branch`, `test_validate_family_branch_step`, `test_validate_family_unnamed_mounts`), plus `test_setup_by_hand_commands`, `test_setup_project_py`, `test_setup_sh`: `592 passed in 506.74s`.
- Before #637 landed, on 6ac545f: set 1 `1042 passed, 2 skipped`, set 2 (9 files) `441 passed`.

**Fails on BASE.** The same five test files from the head, run on main 95780dd (no source change): `26 failed, 80 passed in 168.33s`. 18 of the 19 new tests fail, `test_the_tools_say_the_same_words` fails (no `BRANCH_LEFT` in either new place), and the seven edited tests fail. The one new test that passes on BASE is `test_the_two_pinned_texts_differ_in_the_one_word_only`: it reads text only. On the first commit alone, against a524542, the old module copy gave `20 failed, 1 passed`.

A real project (`scaffold-project.py` into local remotes, a recursive clone, the spec leg advanced on a branch of its remote) and a real holder (`family.py init`, then a real `setup-project.py Sample --yes --family Orbit` run). An earlier act left the branch with a commit of its own.

- **On BASE a524542:** `bump-leg.py` on the tracking branch ends `Run:` / the `switch -c` line / `and re-run.` and nothing more. The line typed as printed gives `fatal: a branch named 'bump/spec-712e4ce5d766' already exists`, exit 128. The detached refusal and the dry run's hint end on the command the same way. setup's block is the `switch -c`, `# a branch first: add refuses the` and `#   holder's tracking branch`, then the `add`. Typed, `fatal: a branch named 'family/add-sample' already exists`, exit 128.
- **On the head:** each of the four prints the sentence under the line, and the same line gives the same 128. Then:
  - another name (`<line>-2`): exit 0; `bump-leg.py` goes through on it, on top of the commit the root stood on; `family.py add` goes through on it;
  - going back (`switch` for `switch -c`): exit 0, at the earlier commit; `bump-leg.py` and `family.py add` go through, their commit on top of it.
- Both choices already worked on BASE: the sentence tells a person so. No row needs real GitHub.
- **A real earlier bump, not a made-up branch.** I ran a real bump on `bump/spec-<id>`, then went back to `main`. There the root shows ` M spec` (the leg is still at the bump's commit), and the next `bump-leg.py` is refused `bump-leg-root-dirty` before it reaches the `switch -c` line. After `git submodule update` puts the leg back, the line appears, fails `already exists`, and the other name works. See the checklist candidates.

Each hunk was reverted or broken alone in a scratch clone of the head, and the new module plus `test_the_tools_say_the_same_words` was run. Every mutation makes new tests fail:

| Mutation | Result |
|---|---|
| bump-leg `BRANCH_LEFT` says "the holder" | 13 failed: the equality test, and the three places in each of four tests |
| bump-leg `BRANCH_LEFT` drops `only if` | 13 failed, the same |
| `bump-leg-root-detached` remedy without the sentence | 4 failed (the `detached` case of each of four tests) |
| `bump-leg-on-tracking-branch` remedy without the sentence | 4 failed (the `tracking` case of each) |
| the same remedy with the sentence above `and re-run.` | 4 failed, the same |
| dry run's hint without the sentence | 4 failed (the `dry-run` case of each) |
| `_switch_command` prints `switch -C` | 9 failed (the "another name" and "going back" cases, and the three "command is the one it was" cases); the control, where no branch is left, still passes, as `-C` makes a new branch too |
| setup `BRANCH_LEFT` says "the project" | 7 failed (the equality test and six) |
| setup block without the sentence | 6 failed |
| setup sentence above the block's two comments | 6 failed |
| setup `BRANCH_LEFT` drops `only if` | 7 failed |

The other hunks change only docstrings and comments (`_switch_command`, `_branch_to_bump_from`, `family_next_command`, the comment above the dry run's hint). They change no behaviour.

| Ref | Result |
|---|---|
| origin/main d4a7fab (one commit past my merge of 95780dd; touches `scripts/family.py`, `scaffold-project.py`, `docs/cli.md`) | clean |
| #637 (landed as 95780dd) | merged into this branch once, clean |
| #623 (lane 2, `setup-project.py` steps (3) and (4); landed as abb956e) | merged in with #637, clean: its hunks are far from `family_next_command` |
| #639 08b6245 (lane 5, #611) | clean |
| #641 e87fcb0 (touches `bump-leg.py` and `family.py`) | conflicts in `scripts/family.py`, and the same conflict arises against main alone: not from this branch. `bump-leg.py` merges clean. |
| #615 6ad2303 (touches `bump-leg.py`) | clean |
| #631, #636 (lane 4) | clean |
| #642, #643, #644 | clean |
| lane-5 siblings fix/645 e2110f8, fix/646 2216e1c, fix/648 852c5c4, fix/649 d91b314 | clean |
| #561 (lane 4's `fix/bump-leg-tag-dry-run`) | its work is on main already (7d9bec9, `asked = commit`); its stale pull head conflicts with main alone, so it is not an open PR to check |

- No README, AGENTS.md or handbook edit. I checked every sentence about these lines: AGENTS.md:337-351 and 374 (the dry run "prints the `git switch -c`"), README.md:157 and 1011, `docs/cli.md` (generated; no `--help` text changes) and the handbook. None becomes false.
- Windows and macOS are not checked here, and no CI job checks them since #597. The tests type argvs, run no shell, compare no file that `write_text` wrote, and read setup's lines with `host_words`.
- A branch checked out in another worktree makes `git switch <branch>` fail (#637's "Left out", N1); the sentence is not made longer for it.
- I did not tick or mark the lines on #458 and #459.
- **Review rv647 (APPROVE WITH NITS, 0 blockers, 0 should-fix, 5 nits), and what is left of it:**
  - Commit 6ac545f's message says "Four existing tests … take the sentence". It is six test functions (seven cases) in three files, as the Tests section says. The squash message is this body.
  - `scripts/bump-leg.py`'s comment near :1164 still says "the three tools follow one rule"; there are four copies of the sentence now, and `setup-project.py`'s comment names two. Left for the `bump-leg.py` issue filed from #458's lines, which lands after this and edits that file.
  - No AST guard holds a fourth `_switch_command` caller to the sentence, as #637's `test_every_remedy_that_names_a_switch_line_ends_with_the_sentence` holds the validator's.
  - Setup's sentence prints as one comment line of about 317 columns in a block wrapped at 78. It is unbroken as `family.py`'s `_branch_said` prints it, which is what lets the byte-for-byte equality tests hold (decision 1).

- **bump-leg.py: after a real bump on a branch and a switch back to an older branch, the root is dirty (` M spec`) and `bump-leg-root-dirty`'s remedy, "commit or stash them first", is wrong advice.** The leg is left at the bump's commit and `git switch` does not move it (README says the leg is left detached at the new commit). `git submodule update` (or `make bootstrap`) puts it back and clears the refusal. The remedy could say so where the one dirty path is a leg's mount. It also means the `switch -c` sentence is reached only after that step in this flow.
- **The same flow's "go back" choice meets that refusal too** (rv647): `git switch` to the branch that holds the earlier bump leaves the leg behind, so the re-run is refused `bump-leg-root-dirty`; after `git submodule update` it says `nothing to do`, and only the push is left.
- **update-shape.py:5279 prints `NEXT git -C <root> checkout -b shape/update-<12> && git … commit`** (rv647, read only): it fails `already exists` the same way, and the `&&` chain then commits nothing. It is `checkout -b`, outside #647.
- **family.py, after a real earlier `add` and a switch back:** another name works for the switch, but `family.py add` is then refused `member-path-taken`, because git left the populated `members/<Project>` checkout behind (rv647; identical on the base). It bears on #637's "another name works".

Review: rv647 found 0 blockers, 0 should-fix and 5 nits on be1bff9 (APPROVE WITH NITS); nits 1 and 2 are answered in this body, nit 3 (the stale "three tools" comment in `scripts/bump-leg.py`) rides with #672, and nits 4 and 5 are noted above. CI (Brett Heap's ruling 27, with his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 be1bff9 passed tests (Linux), tests-git-bash and SonarCloud; the head ac8a810 is a clean update-branch merge of main 8062810, with SonarCloud, tests-git-bash and package green on it, and the local gate passed on the head itself: the bump-leg set, 1040 passed (2 skipped), and the `switch -c` and setup set, 592 passed. Landed on Brett Heap's word of 2026-10-16T13:07:34Z, "merge #656 and #657 when green".

Closes #647

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit to opensoft/openRepoTools that referenced this pull request Oct 18, 2026
…he leg row's own branch:, else the root's tracking_branch (#235) (#238)

* resume and status hold each leg to the branch bootstrap puts it on: the leg row's own branch:, else the root's tracking_branch (#235)

root_legs_ok compared every leg with the ROOT's tracking_branch, but the root's
bootstrap.py places a leg on str(leg.get("branch") or branch). So a leg that
declares its own branch: develop was let through while bootstrap moved it (exit
0), and refused while it sat on develop. status passed the root's branch to
read_leg and had the same blind spot.

A shared leg_branch_of reads a leg row's own branch: the way legs_of reads the
rows. resume refuses a leg off that branch (naming the leg, where it is, where
it belongs and whether that is its own branch: or the root's tracking_branch);
status gives each leg's row, and so read_leg, that branch. A leg with no
branch: of its own is held to the root's branch exactly as before. The comment
above root_legs_ok now says what bootstrap does with a leg's own branch:,
including openRepoShape v1.1.0's fast-forward and checkout of a branch behind
the pin (opensoft/openRepoShape#615).

Fixes #235

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Apply the independent review of #238: test leg_branch_of's parsing, find a leg by either spelling of its path, and fix the stale text (#235)

Review 5561239715 of #238:

1. leg_branch_of's parsing claims (branch: on either side of path:, an empty
   value, null and ~, a trailing # comment, a row with one branch: beside one
   without, prefix-sharing paths) are now a table of 22 cases run against the
   real function cut out of resume, plus a two-leg resume case.
2. status looked a leg up by .gitmodules' path and resume by the manifest's
   text, so a row spelling its path "./spec" or "spec/" was a leg on its
   tracking branch to status. leg_branch_of, in the shared block, drops a
   leading ./ and a trailing / from both sides before it compares them.
3. resume --help, AGENTS.md rule 1 and the comments in status and park no
   longer say a leg is held to the root's tracking branch; park's report_repos
   keeps that assumption, filed as #242.

Also: a row that does not open with `- role:` clears the row before it, and
the comment says where the loader and this reader differ.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brett Heap <service@opensoft.dev>
Co-authored-by: Claude Sonnet 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