Skip to content

bump-leg.py and setup-project.py: the git switch -c line says under 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

Merged
brettheap merged 4 commits into
mainfrom
fix/647-branch-left-bump-leg-setup
Oct 16, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-5 (openRepoShape-5)
Claim: #647 (comment)

What changed and why

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

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

Decisions and evidence

  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. family.py and validate-family.py: where an earlier act left the branch, the switch -c remedy says to give it another name, or go back only if it holds work not merged yet, and the docs say family/add-<Project> (#607) #637'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 family.py and validate-family.py: where an earlier act left the branch, the switch -c remedy says to give it another name, or go back only if it holds work not merged yet, and the docs say family/add-<Project> (#607) #637'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 family.py and validate-family.py: where an earlier act left the branch, the switch -c remedy says to give it another name, or go back only if it holds work not merged yet, and the docs say family/add-<Project> (#607) #637'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.

Tests

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:

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.

Reproductions (local bare remotes, the gh stub first on PATH)

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.

Mutation probes

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.

Merge checks (git merge-tree --write-tree be1bff90 <ref>)

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

Left out, and why

Checklist candidates (not fixed here)

Closes #647

🤖 Generated with Claude Code

Summary by Sourcery

Make all relevant branch-creation guidance explain how to recover when the target branch already exists.

New Features:

  • Add consistent guidance beneath every relevant git switch -c instruction in bump-leg.py and setup-project.py for handling branches that already exist.

Bug Fixes:

  • Prevent printed branch-creation instructions from leaving users without actionable next steps when an earlier operation created the branch.

Enhancements:

  • Keep branch guidance wording synchronized across the project and holder tools while preserving all existing command lines.
  • Expand coverage to validate printed commands, branch-recovery choices, and real bump/setup workflows.

Tests:

  • Add comprehensive tests for existing-branch guidance in bump-leg and setup-project output, including alternate branch names and switching back to retained work.
  • Update affected output and wording assertions to account for the new guidance.

Brett Heap and others added 3 commits October 16, 2026 11:46
… it what to do where an earlier act left that branch, so `already exists` is no dead end for a bump or a family's `add` (#647)

#607 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): 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 of this standard still printed a `switch -c` and said nothing
more.

- `scripts/bump-leg.py`: the sentence is its own constant, `BRANCH_LEFT`, with
  "the project" where the others say "the holder". It follows the command in
  all three places `_switch_command` is named: after `and re-run.` in the
  `bump-leg-on-tracking-branch` remedy, after the `bump-leg-root-detached`
  remedy, and as the last line of the dry run's hint (#477), under it.
- `setup-project.py`: the same sentence, word for word, as one more comment
  line under the family block's `switch -c` line, again its own constant.

No command line changes, and none is quoted differently: the sentence is
words, not a second command. Four existing tests that pinned the old last
lines of those outputs take the sentence (`test_bump_leg_dry_run_tracking.py`,
`test_bump_leg_paste_lines.py`, `test_bump_leg_dry_run_truth.py`). The new
`tests/test_branch_left_bump_leg_setup.py` types each printed line in a real
local project and a real local holder where an earlier act left the branch,
shows `already exists` (exit 128), and follows each choice the sentence gives.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…up.py: #607's equality test holds all four copies of the sentence, so setup-project.py or bump-leg.py cannot drift from family.py's words unseen (#647)

#607 landed (95780dd) after the first commit, so the sentence is no longer
pinned a second time in the new test module. `test_the_two_tools_say_the_same_words`
becomes `test_the_tools_say_the_same_words` and also asserts that
`setup-project.py`'s `BRANCH_LEFT` equals `family.py`'s and the holder's word
for word, and that `bump-leg.py`'s equals it with "the project" for "the
holder". The new module drops its own copies of that test and of #607's
typing helpers, and imports them from `test_family_switch_branch_exists.py`.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-16T12:22Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 16, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR adds a standardized explanation and tested recovery options wherever bump-leg.py and setup-project.py print git switch -c, while preserving every command line and validating behavior against existing-branch scenarios.

Flow diagram for existing-branch recovery guidance

flowchart TD
    A[Tool prints git switch -c] --> B{Branch already exists?}
    B -->|No| C[Create branch and continue]
    B -->|Yes| D[Use another branch name]
    B -->|Branch contains unmerged work| E[Use git switch instead of git switch -c]
    D --> F[New branch starts where the project or holder stands]
    E --> G[Return to existing branch and keep its commits]
Loading

File-Level Changes

Change Details Files
Add consistent guidance for handling an already-existing branch beneath every affected git switch -c instruction.
  • Define a project-worded BRANCH_LEFT sentence in bump-leg.py.
  • Append the guidance to tracking, detached-root, and dry-run bump-leg outputs without changing command lines.
  • Define the holder-worded sentence in setup-project.py and add it to the family command block.
  • Keep the four tool copies synchronized through cross-tool text assertions.
scripts/bump-leg.py
setup-project.py
tests/test_family_switch_branch_exists.py
Add end-to-end coverage proving the printed commands remain executable and both recovery paths work when a branch was left behind.
  • Test branch creation when no branch exists, alternate-name creation, and switching back to an existing branch for bump-leg and setup flows.
  • Validate command parsing, output placement, quoting, commit ancestry, and unchanged behavior.
  • Reuse existing fixtures and typing helpers rather than duplicating test infrastructure.
tests/test_branch_left_bump_leg_setup.py
Update output and exact-text regression tests for the newly appended guidance.
  • Pin the project-worded sentence and update exact remedy and dry-run expectations.
  • Adjust tests that identify the dry-run hint or compare its output tail.
  • Extend the shared wording consistency test to include both new tools.
tests/test_bump_leg_dry_run_tracking.py
tests/test_bump_leg_dry_run_truth.py
tests/test_bump_leg_paste_lines.py
tests/test_family_switch_branch_exists.py

Assessment against linked issues

Issue Objective Addressed Explanation
#647 Add the agreed branch-already-exists guidance beneath every git switch -c instruction printed by bump-leg.py, using “the project” in place of “the holder.” ✅
#647 Add the same branch-already-exists guidance beneath the family git switch -c instruction printed by setup-project.py, using the agreed “the holder” wording. ✅

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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T12:28:13.794588Z be1bff9 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: be1bff901b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@sonarqubecloud

Copy link
Copy Markdown

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

sourcery-ai Bot commented Oct 16, 2026

Copy link
Copy Markdown

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

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

@brettheap
brettheap merged commit 78fe3ff into main Oct 16, 2026
4 of 5 checks passed
@brettheap
brettheap deleted the fix/647-branch-left-bump-leg-setup branch October 16, 2026 13:47
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
…e no bytecode under templates/, the launcher guard reads family.py's siblings child, the quoting-sentence inventory follows an aliased quoter, a suffixless tool and each tool's calling functions, and an S5778 pytest.raises builds its argument first (#665) (#724)

Four guards held less than they said. One commit, `151a0861`, on `8062810d`. No shipped behaviour changes except one environment variable in the generator's `--help` children. No `--help` text changes, so `docs/cli.md` is not regenerated. No quoting changes.

1. **`scripts/render-cli-reference.py`: `child_env()` sets `PYTHONDONTWRITEBYTECODE=1`.** The template's `siblings.py` does `from bootstrap import ...` from its own directory, so the generator's `--help` child wrote `templates/family-root/scripts/__pycache__/bootstrap.cpython-312.pyc` (ignored by git, so silent). A person who runs the generator now gets the same tree as the test that runs it. The docstring says why. `tests/test_cli_reference.py` gets one test: the variable is in `child_env()` whatever the caller had, and an interpreter started under that environment reports `sys.dont_write_bytecode` as true.
2. **`tests/test_validator_child_encoding.py`: the launcher guard sees `family.py`.** `interpreter_runs` accepts `[sys.executable or PYTHON, ...]` as well as `[sys.executable, ...]`, and `LAUNCHERS` gains `"scripts/family.py": "inherited_child_environment"`. Dropping `env=` from `family.py siblings`' child (#214) now fails the guard.
3. **`tests/test_readme_windows_quoting_sentence.py`: the inventory holds what it claims.** It follows `from repo_shape import shell_line as joined` (and `joined = shell_line`, however many steps). It scans every file `git ls-files` lists (tracked, or new and not ignored) that is a `.py` file or starts with a `python` shebang, which adds the root `bootstrap`. It pins functions, not files, for the two tools the sentence names by particular lines: `scripts/family.py` (`_print_next`, `_switch_command`, `_add_command`, `_print_next_steps`, `_checked_mount_path`) and `setup-project.py` (`family_next_command`). `shape-doctor.py` stays every line, because the sentence says "`shape-doctor.py`'s lines". Only the bodies of `shell_quoted` and `shell_line` in `scripts/repo_shape.py` are exempt, by name; the rest of that file is not. Nothing about quoting changes: the Git Bash dialect (option (a)) is its own v1.1.1 issue, and when it moves `NAMED`, this table moves with it.
4. **`tests/test_bump_leg_paste_lines.py`: Sonar S5778.** `placement = bump_leg_module.leg_placement(leg)` before the `with pytest.raises(LegNotAsFound)` in `test_pasting_the_status_line_reads_that_leg`, as the test above it does. Five lines; the hunk is :401-405, away from #657's hunks.

**The reproduction (`.pyc`).** In a fresh shared clone at `8062810d`, with `PYTHONDONTWRITEBYTECODE` unset, `python3 scripts/render-cli-reference.py --check` printed `[ok] docs/cli.md is what the generator produces` and left five `.pyc` files, among them `templates/family-root/scripts/__pycache__/bootstrap.cpython-312.pyc` at 11533 bytes (the size the issue reports). The same command on `151a0861` printed the same `[ok]` line and left none (`find . -name '*.pyc'` found 0).

**Mutants.** Each is applied to a copy at the head's test files; "old" is the same mutant under `8062810d`'s own tests.

| Mutant | New tests | Old tests |
|---|---|---|
| M0: drop `PYTHONDONTWRITEBYTECODE` from `child_env()` (the base's behaviour) | `test_child_env_leaves_no_bytecode_in_the_trees_it_runs` fails: `KeyError: 'PYTHONDONTWRITEBYTECODE'` | none to run |
| M1: drop `env=` from `family.py`'s `siblings` child (`scripts/family.py:6543`) | `test_the_tools_that_run_the_projects_validators_give_them_an_environment[scripts/family.py]` fails: ``scripts/family.py:6543 runs the interpreter without `env=inherited_child_environment()` `` | 55 passed (survives) |
| V2: `from repo_shape import shell_line as joined` and a call, in `update-shape.py` | `test_the_tools_that_quote_for_the_host_...` fails: files are `[family, setup-project, shape-doctor, update-shape]` | 3 passed (survives) |
| V3: `family.py`'s `_switch_command` calls `shell_line(..., "posix")` | `test_the_functions_that_quote_for_the_host_...[scripts/family.py]` fails: `_switch_command` missing from the calling functions | 3 passed (survives) |
| V5: a host-quoter call in the suffixless `bootstrap` | the file test fails: `bootstrap` is now among the files | 3 passed (survives) |
| V6 (the exemption): a third function of `scripts/repo_shape.py` calls `shell_line` | the file test fails: `scripts/repo_shape.py` is now among the files | 3 passed (survives) |

Each new failure is one test, and the other tests of the file stay green (`1 failed, 8 passed`; `1 failed, 55 passed`). V2, V3 and V5 are R588's mutants as the issue describes them.

On `151a0861`, one run, `run9.sh` (gh stub first on PATH, `GH_TOKEN` bogus, empty `GH_CONFIG_DIR`):

`tests/test_cli_reference.py tests/test_validator_child_encoding.py tests/test_readme_windows_quoting_sentence.py tests/test_bump_leg_paste_lines.py tests/test_family_siblings_placement.py tests/test_repo_hygiene.py`: **227 passed in 698.59s**.

Per file: `test_cli_reference.py` 23 (one new), `test_validator_child_encoding.py` 56 (one new case, `[scripts/family.py]`), `test_readme_windows_quoting_sentence.py` 9 (6 new: the `scripts/family.py` and `setup-project.py` function pins, the alias, the method name, the exemption, the suffixless file), `test_bump_leg_paste_lines.py` 33, `test_family_siblings_placement.py` and `test_repo_hygiene.py` 106 together.

- **origin/main `35133f29`** (includes #657 as `78fe3fff`, which edits `tests/test_bump_leg_paste_lines.py` at :53, :235-244 and `setup-project.py`): clean. On the predicted merge commit, `test_readme_windows_quoting_sentence.py` and `test_validator_child_encoding.py` pass (65 passed), and so does `test_bump_leg_paste_lines.py` (33 passed). `family_next_command` is still the only function in `setup-project.py` that calls `shell_word`.
- **#630** (`5b140bd3`): conflicts in `docs/handbook.html` only, and it does so against `8062810d` and `35133f29` without this commit, so not this change's. It changes `scripts/render-cli-reference.py`, `docs/cli.md` and `tests/test_repo_hygiene.py`; none of those hunks overlaps mine, and `merge-tree` reports no conflict in them.
- **Lane 4's `family.py` work:** the heads I could fetch are #670 (`pr670`) and #671; `fix/664-surviving-mutants` and `fix/696-setup-family-folder` are on origin too. Clean with each. Lane 4's other `family.py` branches (#684, #686, #688, #689, #693, #674) are not on origin and not in the shared `.git`, so I could not check them. Predicted merges with #670, #671 and lane 5's `fix/646` pass the two guards' files (65 passed each).
- **Other open heads touching a tool the inventory reads:** #703 and `fix/696` (`setup-project.py`), #699 (`adopt-project.py`), #700, #701, #702 and #705 (`shape-doctor.py`), `fix/664`: clean, and the inventory file passes on each predicted merge (9 passed each). #682, #698 and #706: clean.

- **No quoting change.** The Git Bash dialect (option (a)) is a separate v1.1.1 issue; the inventory only holds what the sentence says today.
- **`docs/release-notes/v1.0.0.md:532`** says the `.pyc` is left by `test_cli_reference.py`'s children. That is true of v1.0.0 and I left it. The v1.1.0 notes (#630) may want one line.
- **`--others` in the file listing.** A new, untracked tool is scanned before it is added, as `rglob` did; a tracked file deleted from the work tree is skipped.

- **A quoter handed on as a value is not a call.** `map(shell_quoted, words)` or `functools.partial(shell_line)` is not seen by the inventory (said in `host_calls`' docstring). Nothing does it today.
- **The function pin counts functions, not calls.** A function with two host-dialect calls where only one is switched to POSIX still passes. Today the one-call functions are `_switch_command`, `_print_next`, `_checked_mount_path`.
- **`_print_next_steps` prints a `cd {shell_quoted(work)}` line** the sentence's list (`NEXT`, `switch -c`, `add`, `git rm -r --cached`) does not name. It belongs to the Git Bash dialect issue.
- **The launcher guard's `LAUNCHERS` still has no row for `src/openreposhape/cli.py` (`run_child([sys.executable, ...], env)`) or `setup-project.py` (its own `run` with a `PYTHON` variable).** Both spell the child differently from `subprocess.run([sys.executable, ...])`.

Review: lane 5's independent review approved 151a086 with nits only, and codex reviewed it. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 151a086 passed tests, tests-git-bash and SonarCloud; the head 949fd4d is a clean update-branch merge of main bf1f099, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 9cd4767 over main 974d482: the bump_leg_paste_lines, cli_reference, readme_windows_quoting_sentence, validator_child_encoding, adopt_plan_leg_path_help, repo_hygiene, agents_md_codes sets, 290 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #665

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…dows guards on `make_file` and the `--branch a\udcffb` test by emulation, and the UTF-8 promise and the leg finding's role pinned (#663) (#723)

Test only. No tool and no template changes. `templates/assembly-root/scripts/validate-manifest.py` is a pinned copy and is not edited.

**No Windows run was made, and no macOS run.** CI is Linux only since #597. What Windows and macOS answer is emulated: `os.fsencode` is replaced for one test by Windows' (`text.encode("utf-8", "surrogatepass")`, three bytes for U+DCFF), and macOS's EILSEQ by a `mkdir` that answers it.

The review of `0b1d1ddb` found no MUST and one SHOULD and two NITs. All three are taken, in one commit, `6ba8c5d2`, in three test files: `tests/conftest.py`, `tests/test_non_utf8_name_helpers.py` and `tests/test_non_utf8_git_reads.py`. No tool file changed.

- **SHOULD-1: `skip_message` had no test of its own.**
  - A skip that escapes `skip_message` ends the test SKIPPED, and no run fails on a skip. The review's mutant SK1 (a skip escapes) gave `10 passed, 3 skipped` on the helpers file, so seven emulation tests skipped silently.
  - `tests/test_non_utf8_name_helpers.py` now holds it with three tests: it returns the reason a call skips with (`skip_message(lambda: pytest.skip("x")) == "x"`), it returns None where the call does not skip, and it lets any other exception go on (`ZeroDivisionError`, raised by a small function and not by `1 / 0`, so no analyser reads a literal division by zero).
  - The first test catches an escaped skip and fails it with `pytest.fail`. A bare assert would not do: the escaped skip would end that test SKIPPED too, and SK1 would still pass.
  - SK1 now gives `1 failed, 14 passed, 3 skipped` (before: `10 passed, 3 skipped`), and the one failure is `test_skip_message_returns_the_reason_a_call_skips_with`: `Failed: the skip escaped skip_message, which would end the test SKIPPED, and no run fails on that`.
- **NIT-2: `windows_fsencode` took `str` only.** Real `os.fsencode` passes bytes through. `windows_fsencode` in `tests/conftest.py` now does (`if isinstance(text, bytes): return text`). Two tests hold it: a surrogate gives `b"\xed\xb3\xbf"` and text gives UTF-8, and bytes come back as they went in, compared with `os.fsencode` itself. Without the pass-through: `AttributeError: 'bytes' object has no attribute 'encode'`.
- **NIT-1: the `"\\x" not in shown` assertion on a Latin-1 file name encoding.**
  - There `make_file` writes `café.yml` as the single byte 0xE9 and the tool, rightly, prints `caf\xe9.yml`, so the assertion would fail on a tool that is right.
  - It is now `assert_shown_without_hex_escapes(shown, output)` in `tests/test_non_utf8_git_reads.py`. It skips where `os.fsencode(UTF8) != UTF8.encode("utf-8")` and then asserts.
  - The test calls it AFTER its other assertions (`.yml` ending, no traceback, nothing moved). The assertion moved from the middle to the end, so a skip loses none of the others.
  - Two tests hold it: under an emulated Latin-1 `os.fsencode` it skips, and under Windows' (emulated) it does not skip, still fails a line that holds `\xc3\xa9`, and passes `café.yml`.
  - Where the file name encoding cannot write `café.yml` at all (ASCII), `make_file` skipped already.

### Fix-round tests (head `6ba8c5d2`, `/usr/bin/python3`, gh stubbed, targeted files only)

| file | before the round (`0b1d1ddb`) | after |
|---|---|---|
| `tests/test_non_utf8_name_helpers.py` | 13 | **18 passed** |
| `tests/test_non_utf8_git_reads.py` | 50 | **52 passed** |
| `tests/test_bootstrap_branch_bytes.py` | 61 | **61 passed** |
| `tests/test_repo_hygiene.py` | 77 | **77 passed** |

That is 208 passed in two runs (156 in 14.01s and 52 in 19.40s), and no `-rs` line printed a skip. `tests/test_adopt_pins.py` and `tests/test_pin_source_unreadable.py` were not re-run in this round (227 passed on `0b1d1ddb`): the round changes nothing they import, and neither file uses `windows_fsencode` or `skip_message`.

### Fix-round mutants

Each in a throwaway clone, one change at a time, with the new test files in place.

| mutant | result |
|---|---|
| **SK1**: a skip escapes `skip_message` (the `try` removed) | `1 failed, 14 passed, 3 skipped`: `test_skip_message_returns_the_reason_a_call_skips_with` |
| **SK2**: `skip_message` swallows every other exception | `4 failed, 14 passed`: `DID NOT RAISE ZeroDivisionError`, and the three OSError rows |
| **SK3**: `skip_message` returns None where a call skips | `4 failed, 14 passed` |
| **SK4**: `skip_message` returns `""` where a call does not skip | `8 failed, 10 passed` |
| **FE1**: `windows_fsencode` without the bytes pass-through | `1 failed, 17 passed`: `AttributeError: 'bytes' object has no attribute 'encode'` |
| **FE2**: `windows_fsencode` without `surrogatepass` | `3 failed, 15 passed` |
| **HX1**: the skip removed from `assert_shown_without_hex_escapes` | `1 failed, 1 passed` (`-k hex_escape`): the Latin-1 emulation, `'\\x' is contained here: M .github/workflows/caf\xe9.yml` |
| **HX2**: the assert removed from it | `1 failed, 1 passed`: `DID NOT RAISE AssertionError` |
| **HX3**: its condition inverted | `2 failed` |
| **R1** again, with the call in place: `scripts/bump-leg.py:1126` decodes with `"ascii"` | `2 failed, 4 passed` (`-k "utf8_under_quote_path_false or hex_escape"`): `[bump]` and `[dry-run]`, `'\\x' is contained here: M .github/workflows/caf\xc3\xa9.yml` |

Not probed: the real test's call to the helper. Dropping it drops the check, and a Latin-1 host is not here to show it. R1 failing with the call in place is the evidence that the call asserts.

**Also measured**, on a host emulated as ASCII (`PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0`, `sys.getfilesystemencoding()` is `ascii`): the helpers file gives `18 passed`, and the two utf8 rows skip in `make_file` (`'ascii' codec can't encode character '\xe9'`). No Latin-1 locale is installed, so the Latin-1 host itself is emulated by `os.fsencode` only, not run.

### Merge check

`git merge-tree --write-tree HEAD origin/main` (`2fac9790`): clean, rc 0, tree `3e71db8d`. The other branches below were checked at `0b1d1ddb`; this round adds seven tests, one helper function and a small change to `windows_fsencode`, and was not re-run against them.

Base: origin/main `8062810d`. Branch head: `6ba8c5d2` (three commits: `d6d1df23` and `0b1d1ddb`, then the fix round).

1. **L2-52, one helper.** `tests/conftest.py` holds `skip_where_a_name_cannot_be_not_utf_8(directory)`. `tests/test_adopt_pins.py` and `tests/test_pin_source_unreadable.py` each had a copy of the body; both copies are gone and both call the shared one. It asks two questions, in this order:
   - `skip_where_fsencode_gives_a_surrogate_no_byte(text)`: skips where `text` holds a byte-carrying surrogate (U+DC80 to U+DCFF) and `len(os.fsencode("\udcff")) != 1`. That is Windows.
   - The disk: it makes `probe\udcff` under the directory and removes it. EILSEQ (macOS) skips; any other `OSError` is raised.
2. **`make_file` (R423 N1).** `tests/test_non_utf8_git_reads.py` calls the first question before it writes, for a name that holds the byte. A name that is text (`café.yml`) is still written. The `try/except` stays, for APFS. The module docstring and `make_file`'s docstring now say who skips and why.
3. **The `--branch a\udcffb` test (R455 F1).** `tests/test_bootstrap_branch_bytes.py` calls the shared helper first, in place of its own probe of a file. `test_a_branch_the_system_carries_has_no_fault` asked the same `os.fsencode` question inline; it now asks the same function, so the rule is written once. The file's own emulated test `test_where_a_surrogate_is_three_bytes_and_no_byte_it_is_refused` keeps the Windows half, unchanged.
4. **The UTF-8 promise (R423 N2, mutant R1).** `test_a_name_that_is_utf8_under_quote_path_false_is_still_refused` takes the one stderr line that starts `    M .github/workflows/caf` and asserts it holds no literal `\x`, through `assert_shown_without_hex_escapes`, which skips where this system's file name encoding is not UTF-8 (fix round, NIT-1). A locale decode of UTF-8 `é` is mojibake at worst and never a `\x`, so the ASCII-ends caution stays.
5. **The leg finding's name (R455 F3, mutant R2).** A unit test asks `_leg_branch_findings([{"role": "spec", "path": "contracts", "branch": "a\x00b"}])` and holds that the line starts `FINDING manifest-leg-branch: leg 'spec': branch is 'a\x00b', which holds U+0000, a NUL` and does not hold `contracts`. It runs for `spec` and `code`, on `validate-manifest.py` loaded by `load_without_bytecode`. The file's last test now takes the module from a module fixture, `validator_module`, which the new test shares.
6. **New file `tests/test_non_utf8_name_helpers.py`** (18 tests: 13 in the first round, 5 in the fix round) holds the helpers to this by emulation:
   - the system is asked first, and the disk is never touched where `os.fsencode` gives three bytes;
   - EILSEQ skips; EACCES, ENOSPC and EEXIST are raised;
   - the probe is removed;
   - text and a surrogate with no byte are not skipped;
   - Linux is not skipped;
   - (fix round) `skip_message` and `windows_fsencode` are held too.
7. **Wiring tests under emulation.** The `--branch` test and the no-fault test are each called, as they are, under Windows' `os.fsencode`, and must skip. `make_file` is called the same way: it skips and writes nothing, and a UTF-8 name is still written.

Shared helpers in `tests/conftest.py`, all in one block after `a_hook_that_unstages`, away from `fix/645-fixture-copy-maintenance-race`'s hunks at :85 and :137:
`NOT_UTF_8_PROBE`, `skip_where_fsencode_gives_a_surrogate_no_byte`, `skip_where_a_name_cannot_be_not_utf_8`, `windows_fsencode` (which passes bytes through, as `os.fsencode` does) and `skip_message`. `skip_message(call, *args)` returns the reason a call skips with, or None. A bare `pytest.raises(pytest.skip.Exception)` is not enough for a test of a skip helper: a helper that skips where it should not ends the test SKIPPED, and no run fails on a skip.

- **Two functions, not one.** `make_file` has no directory to probe: its write is the probe. So the system's question is its own function, and the directory helper calls it first. A change to what Windows answers is made once.
- **The system first, then the disk.** On Windows the disk takes the name, so a skip that waited for the disk would never come. `test_where_fsencode_makes_three_bytes_the_disk_is_not_asked` passes a directory that does not exist: an `mkdir` under it would raise FileNotFoundError, and the skip is the proof the disk was not asked.
- **I read the Windows facts, I did not run them.** The conftest docstring says "most likely" and "read, not run", as the issue does.
- **`windows_fsencode` is in conftest.** `test_where_a_surrogate_is_three_bytes_and_no_byte_it_is_refused` has the same lambda inline. I left it, to keep the hunks small.
- **A skip test must catch an escaped skip (fix round).** `pytest.fail` in an `except pytest.skip.Exception` is the only way the test for `skip_message` can fail on SK1.
- **The Latin-1 check is a function (fix round).** Inline in a test that needs the scaffold fixtures it could not be shown to skip. As `assert_shown_without_hex_escapes` it is called with an emulated `os.fsencode`, and fails a test when its skip or its assertion is removed.

| file | result |
|---|---|
| `tests/test_non_utf8_git_reads.py` | 50 passed (47 on base) |
| `tests/test_bootstrap_branch_bytes.py` | 61 passed (54 on base) |
| `tests/test_non_utf8_name_helpers.py` | 13 passed (new) |
| `tests/test_adopt_pins.py` + `tests/test_pin_source_unreadable.py` | 227 passed (146 + 81, no change in count) |
| `tests/test_repo_hygiene.py` | 77 passed |

No test skipped on Linux (`-rs` printed none). No test file imports `skip_where_*` besides those four and the new file. The fix round's counts are above.

Each in a throwaway clone at the head, one change at a time. "Base file" is the file as it was on `8062810d`, run under the mutant.

| mutant | the base file | the new tests |
|---|---|---|
| **R1**: `scripts/bump-leg.py:1126` decodes with `"ascii"` | 47 passed (R423's count) | 2 failed: `test_a_name_that_is_utf8_under_quote_path_false_is_still_refused[bump]` and `[dry-run]`, `'\\x' is contained here: M .github/workflows/caf\xc3\xa9.yml` |
| **R2**: `leg.get('path')` in place of `leg.get('role')` | 54 passed (R455's count) | 2 failed: `test_the_leg_finding_names_the_leg_by_its_role_and_not_its_path[spec]` and `[code]`, `leg 'contracts': branch is ...` |
| guard removed from `make_file` | | 1 failed: `test_make_file_skips_where_fsencode_makes_no_byte_and_writes_nothing`, `make_file wrote the name instead of skipping` |
| helper call removed from the `--branch` test | | 1 failed: `test_the_branch_test_skips_where_fsencode_makes_no_byte_of_the_surrogate` |
| guard removed from the no-fault test | | 2 failed: the two skipped rows of `test_the_no_fault_test_skips_where_fsencode_makes_no_byte_of_the_branch` |
| guard removed from the shared helper | | 1 failed: `..._the_disk_is_not_asked` (FileNotFoundError) |
| disk asked before the system | | 1 failed: the same test |
| helper skips on any `OSError` | | 3 failed: EACCES, ENOSPC and EEXIST rows of `test_any_other_error_from_the_disk_is_the_tests_own` (`DID NOT RAISE OSError`) |
| probe not removed | | 1 failed: `test_a_disk_that_takes_the_name_is_not_skipped_and_the_probe_is_removed` |
| guard skips any text | | 5 failed: the five rows of `test_where_fsencode_makes_three_bytes_a_name_that_is_text_is_not_skipped` |
| guard inverted (`== 1`) | | 8 failed |

All rows are local; no `gh`, no network. The issue's rows are about what a test does on Windows or macOS, so they are shown by emulation on Linux, and by the mutants above (a mutant that drops a guard is what base did):

- **L2-52:** `git grep -n 'skip_where_a_name_cannot_be_not_utf_8\|skip_where_no_file_name_is_not_utf_8'` on base shows two bodies; on the head it shows one in `tests/conftest.py` and four call sites.
- **N1, F1:** on base, the guard-removed mutants are what a Windows run did: `make_file` writes `caf\udce9.yml` and the branch test runs on. On the head both skip under Windows' `os.fsencode`.
- **N2, F3:** R1 and R2, above.

`git merge-tree --write-tree HEAD <ref>`, with the refs fetched read-only (and deleted after):

- **origin/main `de926142`: clean.** Main had moved since `8062810d` by #657 (`scripts/bump-leg.py`), the Git Bash change and a doctor test; none touches these files. (The fix round's check against `2fac9790` is above.)
- **`fix/645-fixture-copy-maintenance-race`: clean** (its conftest hunks are at :85 and :141).
- **`fix/651-adopt-annotated-tag-pin`** (lane 1; edits `tests/test_adopt_pins.py`): clean.
- **`fix/664-surviving-mutants`** (lane 4; touches none of these files): clean.
- **`fix/655-adopt-doctor-plan-overrides`** (edits `validate-manifest.py`, which I do not): clean.
- **Every other branch on origin** (`fix/388`, `389`, `417`, `425`, `610`, `646`, `648`, `650`, `653`, `658`, `659`, `696`, `l3-668`, `669`, `677`, `678`, `l3-rehearsal-v1.1.0`): clean.
- `docs/release-notes-v1.0.0` and `l3-563-docs` conflict (`docs/release-notes/v1.0.0.md`, `docs/handbook.html`); they conflict with origin/main alone, so it is not this change.
- **Not checked:** lane 4's `fix/674-update-shape-apply` was not on origin yet, and no `l1-*` branch existed on origin.

- Four more tests make a directory with a non-UTF-8 byte and skip on failure: `tests/test_stdout_encoding.py:209`, `tests/test_adopt_git_reads.py:431`, `tests/test_adopt_template_values.py:296` and `tests/test_clone_url_bytes.py:143` (`directory_named`). They build the name from bytes, which raises on Windows, so they skip there already. They are not in #663's lines.
- `test_where_a_surrogate_is_three_bytes_and_no_byte_it_is_refused` keeps its inline lambda.

- **The other four byte-named-directory probes could use one helper.** The four above each write their own "make a directory with a byte that is not UTF-8, or skip". Folding them into `tests/conftest.py` next to `skip_where_a_name_cannot_be_not_utf_8` would give a fifth name for the same question one place to change. v1.1.1, optional.
- **`test_make_file_writes_a_name_that_is_utf8_where_fsencode_makes_no_byte` fails on an ASCII file name encoding.** Found in the fix round, and it was so at `0b1d1ddb`: on a host with `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0` (`sys.getfilesystemencoding()` is `ascii`), `make_file` cannot write `café.yml`, skips with `this filesystem cannot hold a file name that is not UTF-8`, and the test's `assert reason is None` fails (`1 failed, 2 passed` for `-k make_file`). Nothing in CI has that encoding (Linux, macOS and Windows are UTF-8 by default), and a host needs three opt-outs to get it. The direction is the neighbouring control test's own two lines: `if reason is not None and "filesystem" in reason: pytest.skip(reason)`. v1.1.1, optional.

Review: lane 5's independent review approved 0b1d1dd with nits; 6ba8c5d adds its SHOULD-1, a self-test of skip_message. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 6ba8c5d passed tests, tests-git-bash and SonarCloud; the head 4cdb427 is a clean update-branch merge of main f6d17bc, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree bf8dda2 over main f6d17bc: the adopt_pins, bootstrap_branch_bytes, non_utf8_git_reads, non_utf8_name_helpers, pin_source_unreadable, repo_hygiene, agents_md_codes, cli_reference sets, 505 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #663

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