Skip to content

repo_shape.py, setup-project.py, shape-doctor.py, README and the handbook: under Git Bash (MSYSTEM set) the lines that follow the host are quoted for sh, through one question, host_shell_dialect, so the doctor's lines, setup's family lines and family.py's NEXT, switch -c, add and cd lines paste whole in the shell that printed them, and README's Windows sentence says so - #821

Merged
brettheap merged 14 commits into
mainfrom
fix/711-git-bash-dialect
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

v1.1.1. A draft until the v1.1.0 tag. Stacked on #724, which has landed (below). #630, which rewrote the README and handbook around the sentence this changes, has landed too (190133d5), so the docs wording is in this PR now. #792 (#785), which writes into the same paragraph and into quote_arg, landed after that, and main d7befb91 is merged here with it ("Merge round after #792 (#785)", below).

Stacked on

What changed

shape-doctor.py's lines, setup-project.py's family lines and the host-quoted lines of scripts/family.py chose PowerShell's dialect wherever os.name was nt, and os.name is nt under Git Bash too, because Git Bash runs the Windows Python. So a line printed in Git Bash was quoted for a shell the person was not in. Brett Heap chose option (a) of #532's Git Bash question at 2026-10-16T13:07:34Z: sh quoting under Git Bash, keyed on MSYSTEM.

  1. One question, scripts/repo_shape.py's host_shell_dialect(name=None, environ=None). It answers "nt" (PowerShell) on a Windows host whose environment has no MSYSTEM, or an empty one, and "posix" (sh) everywhere else, Git Bash and every MSYS2 shell included. shell_quoted and shell_line ask it when they get no platform; an explicit platform keeps its meaning. The docstring gives the rule, why MSYSTEM, and the limit: a PowerShell started from inside Git Bash inherits MSYSTEM and is answered sh's, and nothing tries to detect it. It answers nothing about the machine: PYTHON stays python under Git Bash.
  2. setup-project.py carries a copy (host_shell_dialect, beside its copy of the quoting), because it runs before any checkout of the standard exists. SHELL_BARE is gone, and the decision moved to call time. It was decided at import from os.name, while the escape asked os.name at each call: two reads of one question. shell_word now asks the question once per call and takes both the alphabet (SHELL_BARE_NT or SHELL_BARE_POSIX) and the escape from that one answer, so MSYSTEM reaches it, whenever it is set. Setup's family lines (family_next_command, L2-29) follow it.
  3. shape-doctor.py. quote_arg, restore_from_head, validator_command, bump_leg_command, checkout_notes, submodule_update_next and copy_command ask the question (imported from repo_shape, as PYTHON is). Under Git Bash that gives sh's quoting, cp and not Copy-Item, and env PYTHONPATH=... and not $env:PYTHONPATH = ...;. scaffold_command keeps its entry point and interpreter on the machine (os.name): a Windows machine is handed python setup-project.py from PowerShell and from Git Bash alike, and only its quoting follows the question. The interpreter on every line is still python_command(platform), PYTHON with no platform.
  4. scripts/family.py is unchanged. Its host-dialect lines (_print_next, _switch_command, _add_command, _print_next_steps with the cd {shell_quoted(work)} line lane 5 added to the issue, _checked_mount_path) call shell_quoted and shell_line with no platform, so they follow the question.

Measured by a probe that loads each tool in process and gives each module an os whose name is the host's, with MSYSTEM set or unset. PowerShell and POSIX hosts print exactly what they printed before. Under Git Bash (nt, MSYSTEM=MINGW64), before and after:

before  doctor copy_command:  Copy-Item -LiteralPath 'C:\Users\Jane O''Neill\open Repo Shape\templates\x.md' -Destination 'C:\Users\Jane O''Neill\work\Atlas\x.md'
after   doctor copy_command:  cp 'C:\Users\Jane O'\''Neill\open Repo Shape\templates\x.md' 'C:\Users\Jane O'\''Neill\work\Atlas\x.md'
before  doctor validator:     $env:PYTHONPATH = 'C:\Users\Jane O''Neill\open Repo Shape\scripts'; python3 'C:\Users\Jane O''Neill\open Repo Shape\templates\assembly-root\scripts\validate-pins.py' --root 'C:\Users\Jane O''Neill\work\Atlas'
after   doctor validator:     env PYTHONPATH='C:\Users\Jane O'\''Neill\open Repo Shape\scripts' python3 'C:\Users\Jane O'\''Neill\open Repo Shape\templates\assembly-root\scripts\validate-pins.py' --root 'C:\Users\Jane O'\''Neill\work\Atlas'
before  doctor scaffold:      python3 'C:\Users\Jane O''Neill\open Repo Shape\setup-project.py' --org <your-org> --project Atlas
after   doctor scaffold:      python3 'C:\Users\Jane O'\''Neill\open Repo Shape\setup-project.py' --org <your-org> --project Atlas
before  doctor restore:       git -C 'C:\Users\Jane O''Neill\work\Atlas' checkout HEAD -- scripts/repo_shape.py
after   doctor restore:       git -C 'C:\Users\Jane O'\''Neill\work\Atlas' checkout HEAD -- scripts/repo_shape.py
before  setup family switch:  git -C 'C:\Users\Jane O''Neill\work\Atlas' switch -c family/add-sample
after   setup family switch:  git -C 'C:\Users\Jane O'\''Neill\work\Atlas' switch -c family/add-sample
before  family NEXT STEPS:    cd 'C:\Users\Jane O''Neill\work\Atlas'
after   family NEXT STEPS:    cd 'C:\Users\Jane O'\''Neill\work\Atlas'

(python3 because the probe ran on Linux, where PYTHON is decided at import; a Windows host prints python. bump_leg_command, submodule_update_next, quote_arg, setup's add line and family.py's switch -c and add lines move the same way.) Git Bash reads the before apostrophe as C:\Users\Jane ONeill, and a bare C:\work\Atlas as C:workAtlas.

README and the handbook are in 5035128e (below). No --help text changes, so docs/cli.md is not regenerated.

README and the handbook (5035128e)

#630 owned README and the handbook until it landed, as 190133d5, so the wording waited in docs-wording.md, measured at #630's head b2a62ec7. It is recomputed here on main b9b70692 (the merge 7564e916). Today's old text is byte-identical to the plan's "Old", at the same lines, and the section's old blob is the same 45a3f886.

README.md, "Windows, natively". The first sentence, "Printed commands are quoted for a POSIX shell, so paste them into Git Bash", is kept word for word. The second names init's cd line among family.py's, and its tail changes.

before, README.md:158-161
typed `yes`, which its preflight cannot take there. 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.

after, README.md:158-162
typed `yes`, which its preflight cannot take there. 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`,
`cd` and `git rm -r --cached` lines are quoted for the shell they are printed
in, PowerShell or Git Bash, on Windows.

docs/handbook.html, panel-windows. The same words, and one sentence README leaves out: the limit host_shell_dialect's docstring names, plus cmd.exe. The widest new line is 100 columns; the panel already runs to 129.

before, docs/handbook.html:737-741
          Printed commands are quoted for a POSIX shell, so paste them into Git Bash. Only
          <code>shape-doctor.py</code>&rsquo;s lines, <code>setup-project.py</code>&rsquo;s family
          lines and <code>family.py</code>&rsquo;s <code>NEXT</code>, <code>switch -c</code>,
          <code>add</code> and <code>git rm -r --cached</code> lines are quoted for PowerShell, on
          Windows.</p>

after, docs/handbook.html:737-744
          Printed commands are quoted for a POSIX shell, so paste them into Git Bash. Only
          <code>shape-doctor.py</code>&rsquo;s lines, <code>setup-project.py</code>&rsquo;s family
          lines and <code>family.py</code>&rsquo;s <code>NEXT</code>, <code>switch -c</code>,
          <code>add</code>, <code>cd</code> and <code>git rm -r --cached</code> lines are quoted for
          the shell they are printed in, PowerShell or Git Bash, on Windows. A shell started from
          inside Git Bash inherits its <code>MSYSTEM</code>, which is how a tool tells Git Bash
          apart, so a PowerShell started there is handed Git Bash&rsquo;s quoting, and cmd.exe is
          handed PowerShell&rsquo;s.</p>

tests/test_readme_windows_quoting_sentence.py's SENTENCE matches the new README sentence once, and the panel holds it once, so the test does not change.

The seven markers. The § Starting a project in a new organisation markers move together, 45a3f886 → 4c523ffe, the blob the plan computed on b2a62ec7. They are at :475, :615, :1093, :1130, :1193, :1449 and :1748; before the panel's three new lines the last five were at :1090, :1127, :1190, :1446 and :1745. The other sections that read this README section (masthead, partway, ci, legs, doublepin, park) were re-read. None says which shell a printed line is quoted for, so only their markers move.

The cap. README goes 1474 → 1475. On main b9b70692 the cap is 1422 + 2 (460.txt) + 5 (550.txt) + 12 (563.txt) + 22 (564.txt) + 10 (606.txt) + 1 (683.txt) + 1 (786.txt) = 1475. tests/readme-cap.d/711.txt adds +1, the line this adds, so the cap is 1476:

+1
2026-10-18
One line for #711, Git Bash's quoting. README's Windows sentence said
`shape-doctor.py`'s lines, setup's family lines and `family.py`'s NEXT,
switch -c, add and git rm -r --cached lines are quoted for PowerShell on
Windows. Since #711 they, and `init`'s cd line, are quoted for the shell
they are printed in: `sh`'s under Git Bash (`MSYSTEM` set) and PowerShell's
in PowerShell. A reader in Git Bash who followed the old sentence would
paste them into PowerShell. Reworded within the sentence, whose first
half, "paste them into Git Bash", is kept word for word; the one line is
the clause that names both shells and `cd`. README goes from 1474 lines to
1475.

#785 (lane 3's #792) had not landed when this was written. It landed next, as 5cbec222, and the merge round below composes the two. It writes "Off Windows, a path shape-doctor.py cannot print as itself is written $'…'" into this paragraph, and under #711 that is true in Git Bash too. If this lands first, #785 lands its sentence as "Off Windows and in Git Bash", which is three README lines (docs-wording.md section 4, which also gives the handbook words and the final blob, e0d2b672). Its cap file is then computed on the README it lands on.

#808's review, NIT 6 (e15c237b)

#808 (#759) landed first, as 677f9bfa, so NIT 6 of lane 2's review of it (issuecomment-6236457891) falls to this PR.

Tests

tests/test_git_bash_dialect.py (new) emulates each host on any host: each module under test gets an os whose name is the host's (HostOs, through the function's own __globals__), and MSYSTEM is set, empty or unset in the real environment. The real os.name is never patched, so pathlib never sees it. Each line is read back by shlex.split or powershell_words and must give back its words. Hosts: PowerShell (nt, no MSYSTEM), Git Bash (MINGW64), MSYS2 (UCRT64), nt with an empty MSYSTEM, POSIX, and POSIX with MSYSTEM set.

File Test Pins
test_git_bash_dialect.py test_the_question_answers_each_host[6 hosts] the answer, asked by its two facts and asked on the emulated host; empty MSYSTEM is PowerShell
test_git_bash_dialect.py test_setups_copy_of_the_question_gives_the_same_answer setup's copy answers as repo_shape's over 3 os.names x 8 environments, and the two alphabets are the same patterns
test_git_bash_dialect.py test_shell_quoted_spells_a_word_for_the_hosts_shell[2 values x 6 hosts] shell_quoted and shell_line with no platform: the exact spelling per dialect, read back whole
test_git_bash_dialect.py test_an_explicit_platform_keeps_its_meaning_under_git_bash[2] shell_quoted and quote_arg with "nt"/"posix" under Git Bash
test_git_bash_dialect.py test_setup_quotes_by_the_same_rule_on_each_host[6] setup's shell_word equals shell_quoted on every value of the old agreement test, on every host; posix=True stays sh's
test_git_bash_dialect.py test_setups_family_lines_paste_whole_in_the_hosts_shell[6] L2-29: family_next_command's switch -c and add
test_git_bash_dialect.py test_familys_next_steps_paste_whole_in_the_hosts_shell[6] family.py init's switch -c, add and cd
test_git_bash_dialect.py test_the_doctors_lines_paste_whole_in_the_hosts_shell[7 lines x 6 hosts] quote_arg, restore_from_head, bump_leg_command (with its lane trailer), submodule_update_next, checkout_notes, copy_command (cp vs Copy-Item), scaffold_command (python setup-project.py on a Windows machine in either shell)
test_git_bash_dialect.py test_the_doctors_validator_line_sets_pythonpath_in_the_hosts_shell[6] $env: for PowerShell, env PYTHONPATH= for sh and Git Bash
test_readme_windows_quoting_sentence.py test_every_host_dialect_quoter_and_caller_asks_the_one_question #724's inventory extended: no shipped function that calls a host-dialect quoter, or is one, reads os.name itself, but those READS_OS_NAME_FOR_THE_MACHINE names (shape-doctor.py's scaffold_command). On the base it names shell_quoted, shell_word and the seven doctor functions above
test_readme_windows_quoting_sentence.py test_reading_os_name_beside_a_host_quoter_skips_the_question the guard on a synthetic source: an os.name platform, a quoter body, a method, and the exemption by file

Changed: tests/test_family_printed_quoting.py's host_words and the apostrophe test read the host through the question (they read os.name). tests/test_setup_project_py.py::test_the_changed_paste_lines_are_posix_quoted_on_windows_too patched SHELL_BARE, which is gone; it unsets MSYSTEM instead, which with os.name patched is what makes the host PowerShell's.

Mutants, each one line, applied in a scratch clone, run against the two files above (98 tests) and reverted. All 18 are killed:

Mutant Line at 8d074f1 Killed by
M1 the question ignores MSYSTEM scripts/repo_shape.py:3315 27 tests, among them test_the_question_answers_each_host[git-bash], test_shell_quoted_spells_a_word_for_the_hosts_shell[...-git-bash], every doctor line [...-git-bash]
M2 an empty MSYSTEM counts as Git Bash ("MSYSTEM" not in environ) scripts/repo_shape.py:3315 14, among them test_the_question_answers_each_host[empty-msystem], test_setups_copy_of_the_question_gives_the_same_answer
M3 shell_quoted back on os.name scripts/repo_shape.py:3351 9: test_shell_quoted_spells_a_word_for_the_hosts_shell[...-git-bash], test_familys_next_steps_paste_whole_in_the_hosts_shell[git-bash], the guard test_every_host_dialect_quoter_and_caller_asks_the_one_question
M4 setup's copy ignores MSYSTEM setup-project.py:2605 5: test_setups_copy_of_the_question_gives_the_same_answer, test_setups_family_lines_paste_whole_in_the_hosts_shell[git-bash], test_setup_quotes_by_the_same_rule_on_each_host[git-bash]
M5 shell_word back on os.name setup-project.py:2629 5: test_setups_family_lines_...[git-bash], test_setup_quotes_...[git-bash], the guard
M6 shell_word drops posix=True setup-project.py:2629 2: test_setup_quotes_by_the_same_rule_on_each_host[powershell], [empty-msystem]
M7 quote_arg back on os.name shape-doctor.py:822 5: test_the_doctors_lines_...[quote_arg-git-bash], [copy_command-git-bash], the guard
M8 restore_from_head back on os.name shape-doctor.py:1364 3: [restore_from_head-git-bash], [restore_from_head-msys2-ucrt64], the guard
M9 validator_command back on os.name shape-doctor.py:1825 3: test_the_doctors_validator_line_sets_pythonpath_in_the_hosts_shell[git-bash], [msys2-ucrt64], the guard
M10 bump_leg_command back on os.name shape-doctor.py:2444 3: [bump_leg_command-git-bash], [...-msys2-ucrt64], the guard
M11 checkout_notes back on os.name shape-doctor.py:5477 3: [checkout_notes-git-bash], [...-msys2-ucrt64], the guard
M12 submodule_update_next back on os.name shape-doctor.py:5599 3: [submodule_update_next-git-bash], [...-msys2-ucrt64], the guard
M13 scaffold_command's quoting back on os.name shape-doctor.py:9108 2: [scaffold_command-git-bash], [...-msys2-ucrt64] (the guard names scaffold_command as reading os.name for the machine, so it is the line test that holds this one)
M14 copy_command's branch back on os.name shape-doctor.py:5911 3: [copy_command-git-bash], [...-msys2-ucrt64], the guard
M15 scaffold_command picks its entry point by the shell (if target == "nt") shape-doctor.py:9109 2: [scaffold_command-git-bash], [...-msys2-ucrt64] (Git Bash would be handed setup.sh)
M16 the guard forgets the quoters' own bodies tests/test_readme_windows_quoting_sentence.py:326 test_reading_os_name_beside_a_host_quoter_skips_the_question
M17 the guard forgets its exemption table tests/test_readme_windows_quoting_sentence.py:328 the guard on the tools (it names scaffold_command) and test_reading_os_name_...
M18 the guard names every os.name read <module> tests/test_readme_windows_quoting_sentence.py:312 test_reading_os_name_beside_a_host_quoter_skips_the_question

The mutants ran in a scratch clone holding the change on bf1f099 (c2d27f48), whose +/- lines are byte-identical to 8d074f1's; the line numbers above are 8d074f1's. Every mutant left the other tests of the two files passing. On Linux no test that existed before can see M1-M15: each reads the real os.name, posix, and none sets MSYSTEM with Windows emulated.

Runs (targeted sets, one pytest process at a time; the gh guard first on PATH, GH_TOKEN unset, an empty GH_CONFIG_DIR):

Set Files Before: base 7fedbb0 After: the change on 7fedbb0 Final: 8d074f1 (on main 2000488)
A test_repo_hygiene, test_cli_reference, test_agents_md_codes, test_family_printed_quoting, test_readme_windows_quoting_sentence, test_install_sh, test_dry_run_parity, test_bump_leg_paste_lines, test_pinned_copy_text, test_branch_left_bump_leg_setup, test_bootstrap_after_bump, test_commit_trailers, test_family_tracking_branch, test_update_shape_next_quoting (+ test_git_bash_dialect after; + test_setup_family_folder, test_family_switch_branch_exists, test_validator_child_encoding in final) 781 passed (275.94s) 870 passed (346.86s) 984 passed (380.33s)
B test_doctor_gitlink_repairs, test_doctor_legs_members_next, test_doctor_next_is_a_command, test_doctor_recorded_answers, test_doctor_validator_rows, test_doctor_validator_verdicts, test_openreposhape_command, test_openreposhape_package, test_setup_project_py 699 passed (731.13s) 699 passed (846.66s) 738 passed (769.05s; main added tests to these files)
C test_shape_doctor, test_doctor_unfinished_merge 446 passed, 3 skipped, 1 xfailed (623.62s) 446 passed, 3 skipped, 1 xfailed (676.54s) 446 passed, 3 skipped, 1 xfailed (617.70s)

Nothing was red before the change. "After" ran the working tree whose tree, committed, is 73966df1; "Final" ran 8d074f16 itself (A and B started at f578cc02, the same tree before a message-only amend). The list is the brief's: the common set, the five named files, and every test file that names shell_quoted, shell_line, shell_word, quote_arg, MSYSTEM, git_bash_session or a function this changes. tests/test_openreposhape_command.py's test_a_ref_the_doctor_would_check_out_is_validated clones this checkout's HEAD, and is refused at the ref before any doctor runs. merge-tree of 8d074f16 with main 741679e7 (one later commit, tests/conftest.py and a new test file) is clean.

Fix round 1 (7581ffb9)

From rv711's review of 8d074f16: READY, no MUST. This round takes its three SHOULDs and NITs 1-7. One commit on 8d074f16; no rebase.

  • SHOULD 1, pinned copies. tests/test_family_printed_quoting.py strips host_shell_dialect from the older library too (ADDED_BY_711, beside ADDED_BY_486). tests/test_pinned_copy_text.py's skew test now reads every pinned script that imports from repo_shape. That adds siblings.py, both bootstrap.py files and validate-manifest.py to validate-pins.py, validate-repository-naming.py and validate-family.py. It refuses host_shell_dialect as it refuses shown_path. validate-manifest.py is a fourth addition beyond the three named, because it too is pinned and imports repo_shape.

  • SHOULD 2, the guard. tests/test_readme_windows_quoting_sentence.py's line_builders finds, per file, every function that takes a platform and hands it to a host-dialect quoter or to another such function. In the doctor that is the nine: restore_from_head, validator_command, bump_leg_command, checkout_notes, kept_branch_line, submodule_update_next, lane_trailer_command, copy_command and scaffold_command. skips_the_question counts calls to them as it counts the four quoters, and names a quoter's body by Class.method.

    • copy_command(s, d, os.name) added to shape-doctor.py as a new row is now flagged.
    • The same snippet alone, where no copy_command is defined, is not, because builders are found per file. The test asserts both.
    • Today's 25 tools raise no false positive.
    • The docstring names what it still does not see: a sys.platform branch; from os import name; a module constant built from os.name; a builder imported from another file; and a branch on os.name in a function that calls no quoter and no builder.
    • A quoter that is a method, NIT 6's fourth gap, is now seen rather than named, because bodies are matched by their qualified name.
  • SHOULD 3 and NIT 7, docs. docs-wording.md is recomputed at README, AGENTS.md, docs/cli.md and the handbook: pipx install openreposhape is the second way in and the first on native Windows, git-filter-repo is installed in install.sh's order and never with a bare pip install, and a first draft of the v1.1.0 release notes (#563) #630's head b2a62ec7:

    • the old text, byte-identical at README :158-161 and handbook :737-741;
    • the blob, 45a3f886 → 4c523ffe (it was b4fd89e5 before cd was named);
    • the seven markers at :475, :615, :1090, :1127, :1189, :1434, :1733;
    • README 1475 → 1476 against a cap of 1475, hence 711.txt +1.

    It adds init's cd line to family.py's list, and the handbook says cmd.exe is handed PowerShell's quoting. A section on shape-doctor.py: every non-ASCII character in a path it prints becomes ?, so a NEXT or keep line naming such a path (a member café, a holder under Projets/Équipe) cannot be pasted as printed #785 gives the words for both orders. Whichever lands second rewords shape-doctor.py: every non-ASCII character in a path it prints becomes ?, so a NEXT or keep line naming such a path (a member café, a holder under Projets/Équipe) cannot be pasted as printed #785's "Off Windows" to "Off Windows and in Git Bash", not "Outside PowerShell", because cmd.exe is handed PowerShell's quoting. Both orders end at README 1479 and blob e0d2b672.

  • NIT 1. tests/test_git_bash_dialect.py's checkout_notes row passes a kept repository, so kept_branch_line's git --git-dir=… branch NAME <commit> is read back per host.

    • A lane name cannot hold an apostrophe: LANE_NAME_RE at scripts/shape_materialize.py:2256 refuses one, and lane_trailer returns None for Dana's lane. So every real trailer reads 'Lane: …' alike in both dialects, and the asked-for LANES_LANE with an apostrophe would print no trailer at all.
    • Instead, test_the_lane_trailer_is_spelled_by_the_lines_own_quoter hands the trailer's quoter Lane: Dana's lane through a stand-in lane_trailer_argument. The bump_leg_command row keeps the real lane 2.
  • NITs 2-5, comments only.

    • setup-project.py's SHELL_BARE_NT comment names tests/test_git_bash_dialect.py.
    • tests/test_shape_doctor.py:9585-9588 says quote_arg(value, None) asks host_shell_dialect.
    • tests/test_git_bash_dialect.py says no CI job runs these lines under a real Git Bash: tests-git-bash runs only the launcher and installer suites.
    • scripts/repo_shape.py's module docstring no longer says siblings.py uses shell_quoted.

Mutants of this round, each one line, applied to the worktree by a copy of rv711's mutate.py (P1, P2, D5, D6 are rv711's), run and reverted with a sha256 check. All 7 are killed:

Mutant Line Killed by
P1 siblings.py imports host_shell_dialect templates/family-root/scripts/siblings.py:188 test_siblings_runs_beside_a_repo_shape_without_the_quoter, test_no_pinned_script_imports_what_an_older_library_lacks[siblings]
P2 validate-pins.py imports it templates/assembly-root/scripts/validate-pins.py:81 test_no_pinned_script_imports_what_an_older_library_lacks[validate-pins]
D5 kept_branch_line(entry, "nt") shape-doctor.py:5483 test_the_doctors_lines_paste_whole_in_the_hosts_shell[checkout_notes-git-bash], [-msys2-ucrt64], [-posix], [-posix-with-msystem]
D6 lane_trailer_command("nt") shape-doctor.py:2449 test_the_lane_trailer_is_spelled_by_the_lines_own_quoter[git-bash], [msys2-ucrt64], [posix], [posix-with-msystem]
B1 no line builder is a quoter tests/test_readme_windows_quoting_sentence.py:360 test_os_name_handed_to_a_line_builder_skips_the_question
B2 a builder handing its platform to another builder is not one tests/test_readme_windows_quoting_sentence.py:346 test_os_name_handed_to_a_line_builder_skips_the_question
B3 a quoter's body named without its class tests/test_readme_windows_quoting_sentence.py:362 test_reading_os_name_beside_a_host_quoter_skips_the_question

Runs of this round, on 7581ffb9: set A (the 18 files of the final A above) 995 passed (300.73s), 984 before plus 11 new: the trailer test's 6 hosts, the builder test, and the skew test's 4 new scripts. Before committing, the changed tests alone: 113 passed. tests/test_shape_doctor.py, setup-project.py and scripts/repo_shape.py changed only in comments and a docstring, so B and C were not re-run; the three parse.

Merge round after #792 (#785) (4ed7d2e0)

Lane 3's #792 (#785) landed as 5cbec222. It writes into the README paragraph, the handbook panel and the quote_arg this PR changes. Main d7befb91 holds it, with #826, #781, #833, #851, #756 and #825, and is merged as 4ed7d2e0 ("Merge origin/main (d7befb9) into fix/711-git-bash-dialect after #792 (#785) landed"). Three files conflicted, and each hunk is composed with no change to either side's behaviour:

File Hunk This PR (#711) Main (#792) Resolution
shape-doctor.py the repo_shape import adds host_shell_dialect adds POWERSHELL_SINGLE_QUOTES both names
shape-doctor.py the head of quote_arg windows from host_shell_dialect(); text still the base's ascii_text(str(value)) text = str(value), windows from os.name, and a value that is not ASCII goes to quoted_beyond_ascii #792's text and its branch, with #711's windows
README.md the Windows sentence the new sentence the old sentence, then #785's two lines, "Off Windows, a path …" #711's sentence, then #785's as "Off Windows and in Git Bash, …", three lines (docs-wording.md section 4)
docs/handbook.html panel-windows the new sentence and the MSYSTEM limit the old sentence and #785's both, #785's reworded the same way, and "a cmd.exe started anywhere else is handed PowerShell's"
docs/handbook.html the seven markers of § Starting a project in a new organisation 4c523ffe 3d4a01ff e0d2b672, the merged section's blob (docs-wording.md computed the same)

The "cmd.exe" wording comes from the review of 5035128e. "And cmd.exe is handed PowerShell's" read as a cmd.exe started inside Git Bash too, and that one inherits MSYSTEM and is answered sh's. README names no cmd.exe, so it does not change there. SENTENCE matches once in README's section and once in the panel, and handbook_marker_problems finds nothing. The markers are at :475, :615, :1095, :1132, :1195, :1451 and :1750.

README.md:157-165, after the merge
Git Bash runs step 3's bash command and its one line, tested in CI but for a
typed `yes`, which its preflight cannot take there. 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`,
`cd` and `git rm -r --cached` lines are quoted for the shell they are printed
in, PowerShell or Git Bash, on Windows.
Off Windows and in Git Bash, a path `shape-doctor.py` cannot print as itself is
written `$'…'`: bash, zsh and ksh read that; dash (`/bin/sh` on Debian and
Ubuntu) does not.

docs/handbook.html:737-746, after the merge
          Printed commands are quoted for a POSIX shell, so paste them into Git Bash. Only
          <code>shape-doctor.py</code>&rsquo;s lines, <code>setup-project.py</code>&rsquo;s family
          lines and <code>family.py</code>&rsquo;s <code>NEXT</code>, <code>switch -c</code>,
          <code>add</code>, <code>cd</code> and <code>git rm -r --cached</code> lines are quoted for
          the shell they are printed in, PowerShell or Git Bash, on Windows. A shell started from
          inside Git Bash inherits its <code>MSYSTEM</code>, which is how a tool tells Git Bash
          apart, so a PowerShell started there is handed Git Bash&rsquo;s quoting, and a cmd.exe
          started anywhere else is handed PowerShell&rsquo;s. Off Windows and in Git Bash, a path
          <code>shape-doctor.py</code> cannot print as itself is written <code>$'&hellip;'</code>:
          bash, zsh and ksh read that; dash (<code>/bin/sh</code> on Debian and Ubuntu) does not.</p>

quote_arg, composed. A probe called the real host_shell_dialect with each host's os.name and environment, then the merged quote_arg. The report's encoding was ASCII, where the path cannot be printed as itself, or UTF-8, where it can:

Host host_shell_dialect() C:\Users\Zoë\work, ASCII report the same, UTF-8 report C:\work\Atlas
PowerShell (nt, no MSYSTEM) nt ('C:\Users\Zo' + [char]0x00EB + '\work') 'C:\Users\Zoë\work' C:\work\Atlas
cmd.exe (nt, no MSYSTEM) nt ('C:\Users\Zo' + [char]0x00EB + '\work') 'C:\Users\Zoë\work' C:\work\Atlas
Git Bash (nt, MSYSTEM=MINGW64) posix $'C:\134Users\134Zo\303\253\134work' 'C:\Users\Zoë\work' 'C:\work\Atlas'
POSIX posix $'C:\134Users\134Zo\303\253\134work' 'C:\Users\Zoë\work' 'C:\work\Atlas'

That is what README now says: $'…' off Windows and in Git Bash, and PowerShell's quoting in PowerShell and cmd.exe. A path the report can show is quoted as itself in every dialect, and an ASCII value is quoted exactly as before.

The cap.

+2
2026-10-18
Two lines for #711, Git Bash's quoting. README's Windows sentence said
`shape-doctor.py`'s lines, setup's family lines and `family.py`'s NEXT,
switch -c, add and git rm -r --cached lines are quoted for PowerShell on
Windows. Since #711 they, and `init`'s cd line, are quoted for the shell
they are printed in: `sh`'s under Git Bash (`MSYSTEM` set) and PowerShell's
in PowerShell. A reader in Git Bash who followed the old sentence would
paste them into PowerShell. Reworded within the sentence, whose first
half, "paste them into Git Bash", is kept word for word; one line is the
clause that names both shells and `cd`. The other is #785's sentence after
it, which said a path `shape-doctor.py` cannot print as itself is written
`$'...'` "Off Windows": under #711 Git Bash is handed `sh`'s quoting, so
that spelling too, and the sentence says "Off Windows and in Git Bash",
three lines where it was two. README goes from 1476 lines to 1478.

Runs of the merge round (the same settings as above). The set is #711's own tests, #792's three (test_doctor_non_ascii_paths.py, test_shape_doctor.py, test_stdout_encoding.py), the 21 README and handbook readers, the quoting test, the trio, and every test file that names quote_arg or host_shell_dialect: 29 files.

Run Tree Files Result
before origin/main ed052f2a 28 (no test_git_bash_dialect.py on main) 2421 passed, 4 skipped (940 + 1481)
the resolution, before its commit 4ed7d2e0's tree 7: the README and handbook checks, the quoting test, the trio, test_git_bash_dialect.py, test_doctor_non_ascii_paths.py 487 passed
after 4ed7d2e0 29 2522 passed, 4 skipped (1033 + 1489). That is 101 more than main's 28 files: the 93 tests of test_git_bash_dialect.py, and 8 that #711 adds to the quoting test (4) and to the pinned-copy skew test (4)

Main moved from ed052f2a to d7befb91 before the merge. Its four new commits (#833, #851, #756, #825) touch none of the three conflicted files; setup-project.py, which #851 and #756 change, merged clean.

Main moved again while the "after" pass ran, to d6475f96 (#788 0905eef5, which changes shape-doctor.py and siblings.py; #822 6fbf3301; #844 d6475f96). None of the three touches README, the handbook, tests/readme-cap.d/ or the quoting test, and git merge-tree of 4ed7d2e0 with it is clean. It is not merged here.

Runs of the README turn

Targeted sets, one pytest process at a time, with GH_TOKEN unset, an empty GH_CONFIG_DIR and S/frbin first on PATH.

Run Tree Files Result
before 0357e7f4 (main 677f9bfa) the brief's set, 91, three batches 6139 passed, 6 skipped, 1 xfailed (2267 + 1543 + 2329; 44:39, 28:49, 21:20)
before 1a285512 (main 5fc049b7) the direct set 1910 passed, 4 skipped (19:45)
before 7564e916 (main b9b70692) the direct set 1910 passed, 4 skipped (18:02)
the docs change 5035128e's tree test_readme_sections.py, test_readme_windows_quoting_sentence.py and the trio 267 passed
NIT 6 e15c237b's tree test_readme_windows_quoting_sentence.py and the trio 192 passed (191 + the new test)
NIT 6 mutant return called at :554 the same 1 failed (the new test), 191 passed
after e15c237b the brief's set + #711's three, 97, three batches 6665 passed, 6 skipped (2543 + 1511 + 2611; 29:42, 19:10, 27:55)

Nothing was red before. The cap test on 5035128e prints: README.md is 1475 lines; the cap is 1422 + 2 (460.txt) + 5 (550.txt) + 12 (563.txt) + 22 (564.txt) + 10 (606.txt) + 1 (683.txt) + 1 (711.txt) + 1 (786.txt) = 1476.

#818 removed PASTE_ENV and ALLOW_FILE_PROTOCOL. Neither name is in any of #821's nine files after the merges; the one mention left in tests/ is a docstring in main's tests/test_no_auto_maintenance.py:533.

Main moved to ca47b71d while the "after" pass ran (#774 52ff5962, #830 51414d2e, #841 ca47b71d). None of the three touches README, the handbook, tests/readme-cap.d/ or the quoting test, and git merge-tree of e15c237b with it is clean. It is not merged here.

Not changed

  • os.name sites that are not the shell, left as they are (line numbers at 8d074f16): scripts/repo_shape.py:135 and setup-project.py:126 (PYTHON, PYTHON_CMD: Git Bash runs the Windows Python, whose command is python); the doctor's python_command, offline (shape-doctor.py:1405, a Windows environment variable is one name whatever its case) and scaffold_command's entry point; setup-project.py:841 (the install offer's argv, cmd /c or bash -c), :1098 (the core.autocrlf preflight), :1372 (the controlling terminal), :2537 and :2558 (the clone's core.autocrlf); adopt-project.py:1042, :1060, :1094, :1128 (path and argv encoding), :5899, :7693 (drive-letter paths); scripts/shape_materialize.py:1994 (a commit message's argv encoding); src/openreposhape/standard.py:275 (the cache directory), :310 (PATHEXT), :446 (rename retries); scripts/render-cli-reference.py:750 (whether there is a bash to render with).
  • POSIX already, unchanged: update-shape.py, adopt-project.py, scripts/bump-leg.py, templates/family-root/scripts/validate-family.py, templates/assembly-root/scripts/validate-pins.py and bootstrap.py quote with shlex; scaffold-project.py passes "posix"; scripts/family.py's rm -rf, git mv --sparse, mv and shlex lines are sh's on every host. None calls a host-dialect quoter with no platform.
  • templates/family-root/scripts/siblings.py imports the holder's own repo_shape.py and quotes its mv lines with shlex.join, POSIX on every host. It must not import host_shell_dialect, because a holder may keep an older repo_shape.py. Until fix round 1 nothing held that (rv711's SHOULD 1): test_siblings_runs_beside_a_repo_shape_without_the_quoter ran it beside a copy that still had the new name. It now strips host_shell_dialect too (ADDED_BY_711), and tests/test_pinned_copy_text.py's skew test refuses the name in every pinned script that imports from repo_shape.
  • src/openreposhape/ has no quoter; cli.py and standard.py print no host-quoted line.
  • Pinned copies. No test pins the bytes or digests of scripts/repo_shape.py or siblings.py: a project's contracts/shape-pin.yaml digests are computed from the live file at scaffold time (scripts/shape_materialize.py's COPIED_FROM_SHAPE), and tests/test_pinned_copy_text.py's skew rule (no pinned script imports what an older library lacks) holds, because no pinned script changes; since fix round 1 it reads all seven pinned scripts that import from repo_shape and refuses host_shell_dialect as it refuses shown_path. The copied scripts/repo_shape.py changes, so it rides in v1.1.1's shape bump; no digest is edited.
  • README and the handbook: in this PR now (above). AGENTS.md and docs/cli.md say nothing about which shell a printed line is quoted for, and do not change.
  • Tests that equate the host default with os.name, left alone: tests/test_shape_doctor.py:3638, :9285, :9436, :9590 (its comment at :9585-9588 now says quote_arg(value, None) asks host_shell_dialect), :9613-9622, :11037, :11125 (line numbers at 7581ffb9), and tests/test_doctor_unfinished_merge.py:54. They hold wherever MSYSTEM is unset, which is every CI job that runs them (Linux), and pass here. A whole-suite run from inside Git Bash would fail them, and the validator and scaffold ones cannot be fixed by swapping the question in, because Git Bash is a third pair (Windows machine, sh shell) that no explicit platform names. A checklist candidate, not this change: tests/test_shape_doctor.py is a hot file.

Closes #711

🤖 Generated with Claude Code

Summary by Sourcery

Make generated Windows commands use the quoting rules of the shell that prints them, including POSIX quoting under Git Bash and MSYS2.

New Features:

  • Detect Git Bash and MSYS2 environments through MSYSTEM so generated commands use POSIX quoting in those shells while retaining Windows machine behavior.
  • Apply host-shell-aware quoting consistently to repository shape, setup, family, and doctor command output.

Bug Fixes:

  • Ensure commands printed under Git Bash paste and execute as complete commands instead of being interpreted with PowerShell quoting.

Enhancements:

  • Add coverage for shell dialect selection, command round-tripping across supported hosts, pinned-script compatibility, and static checks that host quoting uses the centralized dialect decision.

Documentation:

  • Update the README and handbook to describe shell-specific quoting for PowerShell and Git Bash, including the handling of nested shells and non-ASCII paths.

Tests:

  • Add comprehensive emulated-host tests for Git Bash, MSYS2, PowerShell, and POSIX command quoting.

Brett Heap and others added 6 commits October 16, 2026 14:01
…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, so each guard fails when what it names breaks (#665)

Four guards held less than they said.

- scripts/render-cli-reference.py: `child_env()` sets PYTHONDONTWRITEBYTECODE=1,
  so the template's `siblings.py` (`from bootstrap import ...` from its own
  directory) leaves no `bootstrap.cpython-312.pyc` under
  templates/family-root/scripts/ when the generator runs its `--help`
  children. No `--help` text changes, so docs/cli.md is not regenerated
  (`render-cli-reference.py --check` still says it is what the generator
  produces). tests/test_cli_reference.py holds the variable in the
  environment and holds that an interpreter started under it writes none.
- tests/test_validator_child_encoding.py: `interpreter_runs` accepts
  `[sys.executable or PYTHON, ...]` as well as `[sys.executable, ...]`, and
  LAUNCHERS gains scripts/family.py with `inherited_child_environment`, so
  dropping `env=` from `family.py siblings`' child (#214) fails the guard.
- tests/test_readme_windows_quoting_sentence.py: the inventory follows
  `from repo_shape import shell_line as joined` (and `joined = shell_line`),
  scans every file `git ls-files` lists that is a `.py` file or has a python
  shebang (the root `bootstrap`), pins the functions that call a host-dialect
  quoter for scripts/family.py and setup-project.py instead of the file
  (shape-doctor.py stays every line, as the sentence says), and exempts the
  bodies of `shell_quoted` and `shell_line` in scripts/repo_shape.py by name,
  not the whole file. No quoting changes: the Git Bash dialect is a separate
  issue.
- tests/test_bump_leg_paste_lines.py: `leg_placement(leg)` is evaluated
  before the `with pytest.raises(LegNotAsFound)` in
  `test_pasting_the_status_line_reads_that_leg` (Sonar S5778), as the test
  above it does.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…`MSYSTEM` set) the lines that follow the host are quoted for `sh`, through one question, `host_shell_dialect`, so the doctor's lines, setup's family lines and `family.py`'s `NEXT`, `switch -c`, `add` and `cd` lines paste whole in the shell that printed them (#711)

The tools that quote a printed line for the host chose PowerShell's
dialect wherever `os.name` was `nt`, and it is `nt` under Git Bash too,
which runs the Windows Python. So Git Bash was handed
`'C:\Users\Jane O''Neill'`, which it reads as `C:\Users\Jane ONeill`, a
bare `C:\work\Atlas`, which it reads as `C:workAtlas`, and `Copy-Item`
and `$env:PYTHONPATH = ...;`, which it has no command for. Brett Heap
chose option (a) of #532's Git Bash question at 2026-10-16T13:07:34Z.

- scripts/repo_shape.py: `host_shell_dialect(name=None, environ=None)`
  answers "nt" (PowerShell) on a Windows host whose environment has no
  `MSYSTEM`, or an empty one, and "posix" (`sh`) everywhere else, Git
  Bash and every MSYS2 shell included. `shell_quoted` and `shell_line`
  ask it when they are given no `platform`; an explicit one keeps its
  meaning. Its docstring names the limit: a PowerShell started from
  inside Git Bash inherits `MSYSTEM` and is answered `sh`'s.
- setup-project.py carries a copy, as it does of the quoting, because it
  runs before any checkout of the standard exists. `SHELL_BARE`, decided
  at import from `os.name`, is gone: `shell_word` asks the question at
  each call and takes the alphabet and the escape from one answer, which
  the old code read twice. Setup's family lines follow it (L2-29).
- shape-doctor.py: `quote_arg`, `restore_from_head`, `validator_command`,
  `bump_leg_command`, `checkout_notes`, `submodule_update_next` and
  `copy_command` ask it, so Git Bash gets `sh`'s quoting, `cp` and
  `env PYTHONPATH=...`. `scaffold_command` keeps its entry point and
  interpreter on the machine (`python setup-project.py` on Windows, from
  either shell) and takes only its quoting from the question. The
  interpreter is `PYTHON`, the machine's, on every line.
- scripts/family.py is unchanged: its host-dialect lines go through
  `shell_quoted` and `shell_line`, `_print_next_steps`'s `cd` included.

Tests: tests/test_git_bash_dialect.py emulates PowerShell, Git Bash, an
empty `MSYSTEM` and POSIX on any host (each module under test gets an
`os` whose `name` is the host's; the real `os.name` is never touched)
and reads each line back with `shlex.split` or `powershell_words`: the
question's answers, setup's copy held to them, `shell_quoted` and
`shell_word` held to one answer on every host, and the lines of setup,
family.py and the doctor. #724's inventory in
tests/test_readme_windows_quoting_sentence.py now fails a function that
quotes for the host and reads `os.name` itself.
tests/test_family_printed_quoting.py reads the host through the
question, and tests/test_setup_project_py.py unsets `MSYSTEM` where it
patched the gone `SHELL_BARE`.

Stacked on #724, whose final head 949fd4d is merged here and which has
landed as 2000488, merged here with main. Lands after #630, which
rewrites the README and handbook sentence this changes: the new wording
waits for it. The copied `scripts/repo_shape.py` changes, so it rides in
v1.1.1's shape bump.

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

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR makes generated host-specific commands pasteable in Git Bash and other MSYS2 shells by centralizing shell-dialect detection around non-empty MSYSTEM, propagating that decision through setup, doctor, and family output while retaining Windows machine-specific interpreter and entry-point behavior, with extensive emulated-host regression tests.

Sequence diagram for host shell dialect command generation

sequenceDiagram
    participant Caller
    participant Tool as setup/doctor/family
    participant Dialect as host_shell_dialect
    participant Quoter as shell_quoted_or_shell_word

    Caller->>Tool: Generate a command
    Tool->>Dialect: host_shell_dialect()
    Dialect-->>Tool: nt or posix
    Tool->>Quoter: Quote command words using dialect
    Quoter-->>Tool: PowerShell or sh spelling
    Tool-->>Caller: Pasteable command line

    alt Windows with non-empty MSYSTEM
        Dialect-->>Tool: posix
        Tool-->>Caller: sh quoting, cp, env PYTHONPATH
    else Windows without MSYSTEM
        Dialect-->>Tool: nt
        Tool-->>Caller: PowerShell quoting, Copy-Item, $env:PYTHONPATH
    end
Loading

File-Level Changes

Change Details Files
Select the shell dialect from the Windows host plus non-empty MSYSTEM, separating machine detection from the shell used to read printed commands.
  • Add host_shell_dialect() to distinguish PowerShell from Git Bash/MSYS2 while preserving explicit platform overrides.
  • Make host-default quoting use the new runtime decision instead of os.name alone.
  • Keep machine-specific Python and scaffold entry-point selection independent from shell quoting.
scripts/repo_shape.py
setup-project.py
shape-doctor.py
Propagate Git Bash-aware quoting through all affected generated commands.
  • Update setup family commands and family next-step switch, add, and cd lines.
  • Update doctor commands, including cp versus Copy-Item and POSIX versus PowerShell PYTHONPATH syntax.
  • Leave explicitly POSIX-quoted commands and unrelated os.name decisions unchanged.
setup-project.py
shape-doctor.py
scripts/family.py
Add cross-host behavioral and source-level regression coverage for the single dialect decision.
  • Emulate PowerShell, Git Bash, MSYS2, empty-MSYSTEM, and POSIX environments and round-trip generated lines through the corresponding shell parser.
  • Verify setup's copied dialect logic stays identical to repo_shape.py and explicit platform arguments retain their meaning.
  • Guard against host-quoting callers bypassing host_shell_dialect() while allowing machine-specific exceptions.
tests/test_git_bash_dialect.py
tests/test_family_printed_quoting.py
tests/test_readme_windows_quoting_sentence.py
tests/test_setup_project_py.py

Assessment against linked issues

Issue Objective Addressed Explanation
#711 Use one host-shell dialect decision so Windows tools run under Git Bash/MSYS2 print POSIX-compatible paste lines, while other Windows hosts retain PowerShell quoting; include the doctor, setup family lines, and family.py lines. ✅
#711 Update the README and handbook to explain that Git Bash receives sh quoting and PowerShell receives PowerShell quoting. ❌ The PR explicitly defers both documentation changes until after #630, and the supplied diff contains no README or handbook edits.

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T12:58:21Z) Independent review of #821 (#711) at 8d074f16: READY

Reviewed 2f2928dd..8d074f16 against #711, in my own clone. There are no MUST findings. Take the three SHOULDs before the PR leaves draft.

SHOULD 1. Nothing stops a pinned script from importing host_shell_dialect.

SHOULD 2. The new guard misses os.name handed to one of the doctor's line builders.

  • skips_the_question (tests/test_readme_windows_quoting_sentence.py:318-328) counts only functions that call one of the four HOST_QUOTERS.
  • The doctor builds its lines through nine functions that take a platform and pass it to quote_arg: restore_from_head, validator_command, bump_leg_command, checkout_notes, kept_branch_line, submodule_update_next, lane_trailer_command, copy_command and scaffold_command.
  • So a new row doing copy_command(src, dst, os.name) hands Git Bash Copy-Item and still passes the guard. Run on "import os\ndef a(s, d):\n return copy_command(s, d, os.name)\n", skips_the_question returns set().
  • The docstring at :334-335 says a caller that "passed it as the platform" is caught.
  • Fix: per file, treat each function that has a platform parameter and calls a host quoter as a quoter, and count calls to it the way host_calls counts the four. Otherwise, narrow the docstring to the four quoters.

SHOULD 3. Recompute docs-wording.md against the README it is committed on. Lane 3's #785 writes into the same paragraph.

NIT 1. Two lines are not checked per host; their mutants survive.

  • kept_branch_line: tests/test_git_bash_dialect.py:240 passes no kept, so the git --git-dir=… branch NAME <commit> line is never read back per host. The mutant kept_branch_line(entry, "nt") at shape-doctor.py:5483 survives this file and tests/test_doctor_legs_members_next.py (127 passed). Fix: pass kept=[(root / ".git" / "modules" / "spec", COMMIT, None)] and add its words to the row.
  • The lane trailer: :288 sets LANES_LANE to lane 2, which both dialects spell 'Lane: lane 2'. So lane_trailer_command("nt") at shape-doctor.py:2449 survives, including against tests/test_shape_doctor.py -k "trailer or bump_leg" plus tests/test_bump_leg_paste_lines.py (36 passed). A lane name with an apostrophe would catch it.
  • quote_arg(relative, "nt") in restore_from_head (:1365) also survives, but every caller passes a shape file path that is bare in both dialects, so it changes nothing in practice.

NIT 2. setup-project.py:2586-2589 says tests/test_family_printed_quoting.py holds setup's SHELL_BARE_NT to repo_shape's, but on Linux that file never reaches it. Dropping % from setup's SHELL_BARE_NT survives that file (84 passed) and is killed by tests/test_git_bash_dialect.py (3 failed). Fix: name tests/test_git_bash_dialect.py in the comment.

NIT 3. tests/test_shape_doctor.py:9585-9587 says "quote_arg(value, None) resolves os.name exactly as quote_arg(value, os.name) does". After this PR that is false: it asks host_shell_dialect. The assertion still holds on Linux. Fix: reword the comment, or carry it with the checklist candidate the PR body already names for the other tests that read os.name as the dialect.

NIT 4. tests/test_git_bash_dialect.py:25-26 says "CI's tests-git-bash job is where a real Git Bash runs", which reads as if that job covers these lines. It does not. That job (.github/workflows/tests.yml:233) runs only the launcher and installer suites, from PowerShell, and none of them asserts a host-quoted line. Fix: say that no CI job runs these lines under a real Git Bash.

NIT 5. scripts/repo_shape.py:37-38 is the sentence this PR extends. It still says a holder's scripts/siblings.py spells paths with shell_quoted/shell_line, but siblings.py:1016-1022 uses shlex.join on purpose. This was already wrong before the PR. Fix: drop "and a holder's scripts/siblings.py" while the sentence is being edited.

NIT 6. The guard misses four more patterns; on synthetic sources each returns set():

  • a branch on sys.platform;
  • from os import name;
  • a module-level constant built from os.name;
  • a quoter that is a method (os_name_readers calls it Q.quote_arg, the body set calls it quote_arg).

The behaviour rows cover every site that exists today. Fix: name these in the docstring's stated limit.

NIT 7. Two wording points in the docs draft:

Checked and true

Could not verify

  • Real Git Bash behaviour. I could not run Git Bash here; these points are reasoned from MSYS2's documented rules:

    • MSYS2 path conversion leaves single-quoted C:\… values unchanged, with or without an apostrophe, both as arguments and in the environment, for env PYTHONPATH=… python, cp and git -C.
    • Git for Windows' launchers export MSYSTEM to children.
    • python resolves to the Windows install rather than the Store stub, and runs these lines without winpty.

    No CI job runs these lines in a real Git Bash.

  • The full suite; the writer's A/B/C counts are theirs.

  • Which l3-* branches are open PRs. I had no gh, so I classified them by content against main.

  • SonarCloud's own analysis of the head.

Lane 2 is taking the three SHOULDs and the NITs in fix round 1 now.

Lane: openreposhape-2 (openRepoShape-2)

…stion guard counts the doctor's line builders as quoters, and the Git Bash rows read back a kept repository's line and the lane trailer, so the mutants rv711 found survive no longer (#711)

Fix round 1 on #821, from rv711's review of 8d074f1 (three SHOULDs,
NITs 1-6; no MUST).

- SHOULD 1. tests/test_family_printed_quoting.py strips
  `host_shell_dialect` from the older library too (`ADDED_BY_711`), so
  siblings.py is run beside a copy no pre-#711 holder could have.
  tests/test_pinned_copy_text.py's skew test reads every pinned script
  that imports from `repo_shape` (adding siblings.py, both bootstrap.py
  and validate-manifest.py) and refuses `host_shell_dialect` as it
  refuses `shown_path`.
- SHOULD 2. tests/test_readme_windows_quoting_sentence.py's guard finds,
  per file, every function that takes a `platform` and hands it to a
  host-dialect quoter or to another such function (the doctor's nine
  line builders), counts calls to them as it counts the four quoters,
  and names a quoter's body by `Class.method`. So a new doctor row's
  `copy_command(s, d, os.name)` fails it, and so does a quoter that is a
  method. Its docstring names what it still does not see: a
  `sys.platform` branch, `from os import name`, a module constant built
  from `os.name`, a builder imported from another file.
- NIT 1. tests/test_git_bash_dialect.py's `checkout_notes` row passes a
  kept repository, so `kept_branch_line` is read back per host. A lane
  name cannot hold an apostrophe (`LANE_NAME_RE`), so every real lane
  trailer reads alike in both dialects; a new test hands the trailer's
  quoter one through a stand-in `lane_trailer_argument`, so a trailer
  quoted for a fixed shell fails.
- NITs 2-5, comments only: setup-project.py names the test that holds
  its `SHELL_BARE_NT` to repo_shape's on every host;
  tests/test_shape_doctor.py says `quote_arg(value, None)` asks
  `host_shell_dialect`; tests/test_git_bash_dialect.py says no CI job
  runs these lines under a real Git Bash; scripts/repo_shape.py's
  docstring no longer says siblings.py uses `shell_quoted`.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T13:23:43Z) Re-verification of #821 at 7581ffb9: READY

Fix round 1 is one commit on 8d074f16. It changes tests, comments and docstrings only; shape-doctor.py is unchanged. It was checked read-only in lane 2's own clone, by re-running the saved mutants and probes.

SHOULD 1 (nothing stopped a pinned script importing host_shell_dialect): FIXED

  • ADDED_BY_711 is now stripped beside ADDED_BY_486 (tests/test_family_printed_quoting.py:461-465).
  • The skew test (tests/test_pinned_copy_text.py:776-811) refuses shown_path and host_shell_dialect in PINNED_IMPORTERS. That list holds all seven pinned scripts that import repo_shape; git grep finds no others.
  • P1 (siblings.py imports it) is now killed by the siblings test and by [siblings]. P2 (validate-pins.py imports it) is killed by [validate-pins].

SHOULD 2 (the guard missed os.name handed to a line builder): FIXED

  • line_builders finds exactly the doctor's nine builders, plus shell_line in scripts/repo_shape.py, and flags nothing on the 25 tools.
  • The review's snippet appended to shape-doctor.py's source is flagged as a_row. In a file that does not define copy_command it is not flagged, which the docstring now states.
  • It terminates on recursive builders. It handles a self-recursive builder, two mutually recursive ones, and a cycle that never reaches a quoter (no builders found), and takes 0.52s on a 150-deep chain. Each pass adds a name from a finite set or stops.
  • A method builder called through self is caught (P.row).
  • Mutants of the new guard die: dropping all builders (L1) and dropping transitivity (L2) are each killed by test_os_name_handed_to_a_line_builder_skips_the_question.

SHOULD 3 (docs plan): FIXED. Every number in docs-wording.md was reproduced on b2a62ec7's own files with its own readme_segments and readme_fingerprint:

NIT 1 (two lines not checked per host): FIXED

  • D5 (kept_branch_line(entry, "nt")) is killed by the checkout_notes row on four hosts.
  • D6 (the trailer always PowerShell's) is killed by test_the_lane_trailer_is_spelled_by_the_lines_own_quoter. Two new trailer mutants also die: D6b (quote_arg(value, "nt")) and D6c (quote_arg(value, "posix")) inside lane_trailer_command.
  • The stand-in mirrors the real contract. lane_trailer_argument ends spelled = quote(line) → " --trailer " + spelled, and the stand-in replaces only the lane read and the LANE_NAME_RE gate ([A-Za-z0-9 ()._:/@+-]+, no apostrophe). So the test checks the quoter the doctor hands in, not the stand-in.
  • D7 still survives. As noted in round 0, it is equivalent in practice.

NITs 2-7: FIXED.

  • 2: setup-project.py:2589-2590 names tests/test_git_bash_dialect.py, and N1 is killed there.
  • 3: tests/test_shape_doctor.py:9585-9588 is accurate.
  • 4: tests/test_git_bash_dialect.py says no CI job runs these lines under a real Git Bash.
  • 5: scripts/repo_shape.py:37-39 no longer names siblings.py.
  • 6: the guard's docstring lists the remaining gaps (sys.platform, other spellings of os.name, a module constant, a builder from another file), and a method quoter is now caught (Q.quote_arg).
  • 7: cd is named, and the handbook covers cmd.exe.

New, optional NIT. PINNED_IMPORTERS (tests/test_pinned_copy_text.py:776) is a hand list. A future pinned script that imports repo_shape would not be checked until someone adds it. Deriving the list from templates/*/scripts/*.py plus scripts/validate-repository-naming.py would keep it complete.

Merges.

Runs:

  • At 7581ffb9, the dialect, inventory, family-quoting, setup paste-line, skew and scaffold-quoting tests: 200 passed.
  • 10 mutants: 9 killed; D7 survives, and is equivalent in practice.

Could not verify. As in round 0:

  • real Git Bash behaviour;
  • the full suite (the writer's Set A count of 995 is theirs);
  • which lane 3 branches are open PRs;
  • SonarCloud's analysis of the head.

Lane: openreposhape-2 (openRepoShape-2)

This was referenced Oct 17, 2026
brettheap added a commit that referenced this pull request Oct 17, 2026
…alue and pins each function's host-dialect calls, and the launcher guard reads `run_child` and `run([PYTHON, ...])`, with rows for `cli.py` and `setup-project.py` (#759) (#808)

**Based on main.** #724 landed as `20004887`; this branch holds it and was restacked onto main as `84ce67c9`.

**Head `08a6ed39`.** The branch holds #759's commit `052410b6`, two merges that took #724's final head and main, and one fix round (`08a6ed39`, lane 2's rv808). Tests only: `tests/test_readme_windows_quoting_sentence.py` and `tests/test_validator_child_encoding.py`. No shipped file changes, and no pinned copy. README, the handbook, `AGENTS.md` and `docs/cli.md` are untouched, and `render-cli-reference.py --check` passes.

One commit, tests only: `tests/test_validator_child_encoding.py` and `tests/test_readme_windows_quoting_sentence.py`. No shipped file changes, so no pinned copy, README, handbook, `AGENTS.md` or `docs/cli.md` text moves. Pushed to `fix/759-quoting-launcher-guards` as a fast-forward of `84ce67c9`, the coordinator's restack onto main (which answers MUST 1, the conflict with main). Nothing was merged, rebased or amended.

### SHOULD 1: the `setup-project.py` row now flips on the fix it names, and the finding says six children

**What failed, measured.** On `84ce67c9`, with the real `setup-project.py` changed in a scratch clone, one change per row. The row is `test_the_tools_that_run_the_projects_validators_give_them_an_environment[setup-project.py]`: `xfailed` means it is still red, `FAILED (XPASS strict)` means it says the finding is fixed. Log: `v116/f808/toolmut/`.

| change to `setup-project.py` | `84ce67c9`'s guard | the new guard |
|---|---|---|
| none (control) | xfailed | xfailed |
| `env=child_environment()` in `run`'s own `subprocess.run`, which reaches all six children | **xfailed: no signal, and `NOT_YET`'s reason stays false** | FAILED (XPASS strict): the row comes out |
| an `env` parameter on `run`, passed at all six calls | FAILED (XPASS strict) | FAILED (XPASS strict) |
| the same, but the call in `why_the_plan_did_not_go_through` left out | **FAILED (XPASS strict), with a child still bare** | xfailed |
| `env=` at the five calls the old guard read, no parameter | **FAILED (XPASS strict), with a child still bare** | xfailed |
| `env=` at four of the calls | xfailed | xfailed |
| a seventh bare child added | xfailed | xfailed, and `test_setup_project_starts_six_children_and_the_guard_reads_each` fails |

Lane 2's two mutants are the bold rows. The first is (a): the guard read the call sites and never `run`. The second is (b): there are six Python children, not five. `why_the_plan_did_not_go_through` runs the plan again, `again = run(plan, cwd=shape_root, capture=True)`, and `plan` is a parameter, so the old `argv_of` could not read the list.

**The sixth child, measured.** I called the real `why_the_plan_did_not_go_through` with a plan that prints a `project      ...` line holding an ellipsis and exits 2, under three encodings (`v116/f808/probe6.txt`):

| `PYTHONIOENCODING` | what the second run's captured stdout holds | the words returned |
|---|---|---|
| `utf-8:strict` | `project      Sample ` and U+2026 | `the plan was refused (see above).` |
| `cp1252:strict` | `project      Sample ` and U+FFFD | the same |
| `cp1252:surrogateescape` | `project      Sample ` and U+FFFD | the same |

So the first five children write into the stream this process reconfigured to UTF-8, which is the two-encodings finding above. The sixth is captured and read as UTF-8 whatever it wrote: a cp1252 byte becomes U+FFFD. Nothing shows today, because the only test on those lines is `startswith(PLAN_STARTS)` and that prefix is ASCII. It is the same defect class, and the fix has to reach it too.

**The fix** (`tests/test_validator_child_encoding.py`):

- `interpreter_runs` reads a list a caller hands a function as a parameter, one level up, and a name bound in each branch of an `if`. A starter's own parameter is left to its callers, which are the runs, so `run`'s own `subprocess.run` is not counted as a seventh. On the shipped files this changes exactly two reads (`v116/f808/probe/survey.py`): `setup-project.py` gains the sixth child (`:2312` on `84ce67c9`), and `scripts/render-cli-reference.py` gains `:403` (NIT 1).
- `environment_of` reads, where a call hands no `env=`, the `env=` of the function it calls when this module defines that function (`run`). A name is read as the LAST thing its function bound it to above the call.
- `NOT_YET` names the six children, says five write into the reconfigured stdout and the sixth is captured, and names both fixes the row reads: an `env=` in `run`'s own `subprocess.run`, or an `env` parameter that all six calls pass.
- `test_the_row_flips_on_the_change_the_finding_names` runs seven changes to a model of `setup-project.py` (the table's rows, on a source string). `test_setup_project_starts_six_children_and_the_guard_reads_each` pins the six functions of the real file: `check_names`, `clone_and_bootstrap`, `plan_and_confirm`, `scaffold`, `self_bootstrap` and `why_the_plan_did_not_go_through`. While the row is an `xfail`, a seventh bare child changes nothing in it, so the count is what would show it.

### NITs 1 to 6: which are taken, which stay

| NIT | outcome |
|---|---|
| **1** `argv_of` read only the first list | **Taken.** Any list the name is bound to above the call counts; the first-or-last choice is gone. `SPELLINGS` has the name bound to two lists in each order (the `render-cli-reference.py` shape), and `NOT_RUNS` has a name bound to the interpreter only after the call. |
| **2** three `host_values` behaviours without a case | **Taken.** `VALUE_USES` gains `self.quote = shell_line`, `a = b = shell_quoted` and a name read in an annotation; the value use in a method sits beside `test_a_method_is_named_with_its_class`. |
| **3** `environment_of` joined every binding | **Taken.** The last binding above the call is read, with cases for a later rebinding, a rebinding after a branch, and one after the call. |
| **4** spellings the launcher guard does not read | **Taken in part.** `interpreter_runs`' docstring lists them, as #821's guard lists "WHAT IT DOES NOT SEE", and `SPELLINGS` gains `subprocess.call`, `check_call` and `check_output`. **Stays unread:** a tuple argv, `[sys.executable, ...] + rest`, a function-local `exe = sys.executable`, `import subprocess as sp` and `from subprocess import Popen`, `os.exec*`. None is in shipped code, and each needs a resolver of its own. |
| **5** aliases counted at the alias | **Taken in part.** `alias_of` follows `q: object = shell_quoted` as it follows `q = shell_quoted`, so `q(x) + q(y)` counts 2 and one switched to `'posix'` counts 1. `names_posix` reads a `'posix'` argument only after the value the quoter would quote, so `functools.partial(quote_arg, 'posix')`, which binds the value, is a host use. **Stays unfollowed:** an alias made by a tuple, a chained assignment, a walrus or a conditional. `host_calls`' docstring says so, and `NOT_FOLLOWED` pins what is counted instead (the statement, once for each quoter it names). None is in shipped code. |
| **6** #821's `calls_to` reads `host_call` only | **Not changed here, and not #821.** See below. |

**NIT 6, for whichever of #808 and #821 lands second.** #821's `calls_to` counts `host_call`'s calls and not `host_values`' uses, so on the merge `skips_the_question` does not see a quoter handed on as a value. Measured on a scratch merge of `08a6ed39` and #821's head `7581ffb9` (`v116/f808/nit6.txt`):

| source | `skips_the_question("scripts/x.py", ...)` as merged | with the fix below |
|---|---|---|
| `' '.join(map(shell_quoted, w)) if os.name == 'nt' else ''` in `def a(w)` | `set()` | `{'a'}` |
| `' '.join(shell_quoted(x) for x in w) if os.name == 'nt' else ''` in `def a(w)` | `{'a'}` | `{'a'}` |

The fix is one expression, which the second lander adds to `calls_to`'s return: `| {function for function, _ in host_values(tree, names)}`. `host_values` names functions as `calls_to` does (`<module>`, `Class.method`). With it, `tests/test_readme_windows_quoting_sentence.py` on the scratch merge gives 43 passed (`v116/f808/nit6-run.log`). The merge itself is clean and needs no other change.

### Tests

On `08a6ed39` (`dirty=0`), through `run9.sh` (gh stub first on PATH, bogus token, empty `GH_CONFIG_DIR`), `v116/f808/final-2.log`:

`tests/test_validator_child_encoding.py tests/test_readme_windows_quoting_sentence.py tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_cli_reference.py`: **297 passed, 1 xfailed in 59.36s.**

Per file: `test_validator_child_encoding.py` 82 to 111 cases (29 new), `test_readme_windows_quoting_sentence.py` 28 to 40 (12 new), the other three 147 together. On `84ce67c9` the two files gave 109 passed, 1 xfailed (`v116/f808/base-head.log`). No other test file imports or drives either one: `git grep` finds no use of `interpreter_runs`, `environment_of`, `LAUNCHERS`, `host_calls`, `host_quoting` or `quoter_names` outside these two files. On the scratch merge with #821 (`4386e5fe`, not pushed), these two files and `test_git_bash_dialect.py`, `test_family_printed_quoting.py` and `test_pinned_copy_text.py` give 417 passed, 1 xfailed (`v116/f808/merge821.log`).

### The new tests on `84ce67c9`

I put the new test files over `84ce67c9` with its helpers back in place of the new ones (`v116/f808/mkbase.py`; a helper that is new is dropped, so a test that uses it fails with a `NameError`): **19 of the 41 new tests fail, 131 pass, 1 xfails** (`v116/f808/basefail-3.log`). The failures: 7 changes to the model of `setup-project.py` and the six-children pin; 3 `SPELLINGS` (a list a caller hands a parameter, by position and by keyword, and a name bound to two lists with the interpreter second); 5 `ENVIRONMENTS` (the last binding, three ways, and the environment `run` hands, two ways); and in the quoting file the `partial(quote_arg, 'posix')` use, the annotated alias statement, and the annotated alias counted at its calls.

The other 22 pass there, because the base code already did what they say. They close mutants that survive `84ce67c9`'s own tests, measured below (six of them: NIT 2's three and NIT 4's three), or pin a limit the new code names.

### Mutants

One mutant per new hunk, each a text change to the head's test files (`v116/f808/mutate.py`, `v116/f808/mut/results.jsonl`). `failed` counts the tests that fail, among the launcher guard's own tests for a mutant of `tests/test_validator_child_encoding.py` and in the whole file for one of `tests/test_readme_windows_quoting_sentence.py`. All 32 are caught.

| hunk | mutant | failed |
|---|---|---|
| `bound_values`, above the call | the filter dropped / line number only / no sort | 2 / 1 / 1 |
| `environment_of`, last binding | the first binding / every binding joined (the old code) | 2 / 2 |
| `argument_for` | positional dropped / keyword dropped / always the first argument | 9 / 1 / 1 |
| `lists_of`, callers | not read / callers of callers read / a starter's own parameter resolved | 10 / 1 / 15 |
| `lists_of`, held lists | first only / last only | 1 / 1 |
| `interpreter_runs`, any list | first only / last only | 1 / 1 |
| `own_environment_of` | returns nothing / the call's own `env=` loses to it / recurses into a starter that calls itself | 3 / 4 / 1 |
| `NOT_YET` row | unmarked / made to pass (`LAUNCHERS` value `""`, the strict `xfail` fires) | 1 / 1 |
| `alias_of` | no annotated assignment / chained assignment read / any target kind read | 2 / 1 / 1 |
| `names_posix` | every argument read / one slot after the quoter / a keyword quoter reads none | 1 / 1 / 1 |
| `host_values`, alias statement | any child of one exempt / the statement a use | 1 / 5 |
| `function_of` | `ClassDef` dropped | 1 |
| `STARTERS` | `subprocess.call` / `check_call` / `check_output` dropped | 1 / 1 / 1 |

Six of these survive `84ce67c9`'s tests, run on its own files with the same text change: `STARTERS` (three; 28 passed, 1 xfailed in the launcher guard's tests) and, in the quoting file (28 passed each), `host_values`' target-count check (`a = b = shell_quoted`), its target-kind check (`self.quote = shell_line`) and `function_of`'s `ClassDef`. On the new files each is caught, as the table says.

### Merge checks (`git merge-tree --write-tree HEAD <ref>`, main at `64ec47c3`)

- **origin/main:** clean.
- **#821 (`fix/711-git-bash-dialect`, `7581ffb9`):** clean, and the only other head that changes `tests/test_readme_windows_quoting_sentence.py`. This branch alone changes `tests/test_validator_child_encoding.py`.
- **Every `fix/*` and `l3-*` head on origin (59, this branch's own among them):** 55 clean. Four conflict: `fix/758-git-command-scope`, `fix/762-validate-family-remedies-texts`, `l3-677-pins-hand-step-wip` and `l3-678-naming-checkout-wip`. Each conflicts the same way with origin/main and with `84ce67c9`, in files this change does not touch.

### Checklist candidates

None new. NIT 6 is the one follow-up, and it belongs to whichever of #808 and #821 lands second.

**1. The inventory sees a quoter handed on as a value, and counts calls.** `tests/test_readme_windows_quoting_sentence.py`:

- `host_calls` also returns a host quoter that is read and not called: an argument or keyword of another call (`map(shell_quoted, words)`, `functools.partial(shell_line)`, `key=shell_quoted`), a list entry, a default or a return value. It is named under its own function (`host_quoting` keys by function), in source order.
- A call told `'posix'` is `sh`'s, as for a direct call: `functools.partial(shell_line, platform="posix")` is not a use.
- `NAMED` maps each function to its number of host-dialect uses (`_add_command` 3, `_print_next_steps` 2, `family_next_command` 2, the other three 1), and the per-function pin compares the counts. One of `_add_command`'s three calls switched to `'posix'` is 3 to 2 and fails, where the set of names it compared before still held `_add_command`. The failure prints the counts it found next to the table's.

**2. The launcher guard reads two more spellings and has two more rows.** `tests/test_validator_child_encoding.py`:

- `interpreter_runs` reads a call to `subprocess.run` and its kin, to the package's `run_child`, and to `run` (`setup-project.py`'s wrapper), whose argv is `[sys.executable, ...]`, `[sys.executable or PYTHON, ...]`, or a list starting with a name the module binds to `sys.executable` (`PYTHON = sys.executable`). The list may be spelled out, held in a local name (`plan = [PYTHON, ...]`, once or in each branch of an `if`), or handed to the calling function as a parameter by a caller in the module. A function that is itself a starter (`run`) is not a run; its callers are. A `PYTHON` imported from `repo_shape` is `python3` as a person types it, and is not read as the interpreter.
- `environment_of` reads an `env=` keyword, or `run_child`'s second argument, through a local name (`cli.py`'s `scaffold` builds `env` and adds a key before it passes it on). Where the call hands none, it reads the `env=` of the function it calls when the module defines that function (`run`). A local name is read as its LAST binding above the call.
- `LAUNCHERS` gets `src/openreposhape/cli.py`: its four `run_child` calls (`:436`, `:462`, `:474`, `:564`) each hand `standard.child_environment()`, which is what the file's docstring promises ("EVERY CHILD GETS `standard.child_environment()`"). That is the package environment's PATH and not an encoding, so it is the helper this row is held to. The row passes.
- `LAUNCHERS` gets `setup-project.py`: its six Python children hand no environment, since `run` has no parameter for one: five `run([PYTHON, ...])` calls (in `self_bootstrap`, `check_names`, `plan_and_confirm`, `scaffold` and `clone_and_bootstrap`) and the sixth, in `why_the_plan_did_not_go_through`, which runs the plan again with `plan` a parameter. That row fails, and it is the finding below.
- A separate test says each row finds at least one run. That keeps a spelling the guard cannot read from passing as "nothing to check".

**Measured, not read.** `PYTHONIOENCODING=cp1252:strict` and `cp1252:surrogateescape` are what the other encoding tests here emulate for Windows' ANSI code page on a stdout that is not the console. A local run, `setup-project.py --project Sample --yes --org demoorg --local-remote-dir <dir> --into <dir>`, with the `gh` stub first on PATH:

| `PYTHONIOENCODING` | exit | lines of stdout | lines that are not valid UTF-8 |
|---|---|---|---|
| `cp1252:strict` | 0 | 129 | 9 (24, 31, 38, 45, 64, 79, 80, 100, 102) |
| `cp1252:surrogateescape` | 0 | 129 | 9 (the same) |
| `utf-8:strict` (control) | 0 | 129 | 0 |

`setup-project.py` reconfigures its own stdout to UTF-8 (`main`, `:2790`), and five of its children inherit that stdout with an environment of their own, so each writes in the code page its environment gives, and the sixth is captured and read as UTF-8 whatever it wrote (the table under "The sixth child, measured"). The lines are the naming validator's `family ...` rows with their em dash (3 lines), the scaffold's `shape ...` and `spec`/`code` lines with their ellipsis (4), and the validators `scripts/bootstrap.py` runs (2). Nothing raises, because `scaffold-project.py` and `scripts/bootstrap.py` escape what their stdout cannot hold. The stream is just two encodings. Log: `v115/w759/probe5/summary.txt`.

**What the test does about it.** The row cannot pass without a change to `setup-project.py`, and this issue changes no tool. It is a strict `xfail` (`NOT_YET`): it passes while the row fails, and it fails the day the row passes, so whoever gives `run` an environment has to take the row out of the table. The mark names `AssertionError`, so any other error in the guard still fails. The fix is an environment from a copy of `inherited_child_environment` (reading `sys.stdout.encoding` after the reconfigure, so `utf-8:replace`), given either as an `env=` in `run`'s own `subprocess.run` or as an `env` parameter that all six calls pass. The row flips on either, and a change that leaves any one of the six bare leaves it red. `setup-project.py` cannot import the helper: it runs before there is a checkout to import from.

- **The use rule is wider than the issue's wording, by one case.** The issue says "an argument of any call, or the value of an assignment the alias walk already follows". The rule here is: any read of a host quoter that is not the `func` of a call and not the alias statement itself (`joined = shell_line`, the assignment `quoter_names` follows). That covers the issue's two forms and also a list entry, a default, a return value and `self.quote = shell_line` (an assignment the walk does not follow). It differs on one point only: an alias that is assigned and never read. The alias statement is not counted, because every use of `joined` is, and counting it would make a module-level alias a `<module>` row that `NAMED` would have to name.
- **A call that takes the quoter as an argument and is told `'posix'` is not a use.** `host_values` has its own small `names_posix` rather than reusing `host_call`'s inline test. `host_call` is left as it is, since #711 (the Git Bash dialect) edits the rule that decides what forces `sh`.
- **The new cases sit in the middle of the file and `NAMED` changes only in its values.** #711 changes `tests/test_readme_windows_quoting_sentence.py` too, so these hunks are additive, and none of them is at the end of the file.
- **The callee set is `subprocess.run/Popen/call/check_call/check_output`, `run` and `run_child`.** A call that only names the interpreter in a list (`shlex.join([sys.executable, ...])`) is not a run. Another spelling is a new entry in `STARTERS`, and `test_every_spelling_of_a_child_of_the_interpreter_is_a_run` is where it is shown.

**On the shipped tools nothing changes.** The new `host_calls` returns what the old one did on every file `git ls-files` lists (`host_quoting` compared across both: identical), so no host-dialect line escaped the sentence before, as the issue says. `NAMED`'s counts match today's code: `_add_command` 3, `_print_next_steps` 2, `_switch_command`, `_print_next` and `_checked_mount_path` 1, `family_next_command` 2.

**The tool mutants,** each run against `151a0861`'s own two test files and then against these. Logs under `v115/w759/mut/`.

| Mutant | base tests | new tests |
|---|---|---|
| M1: `def _w759_mutant(words): return list(map(shell_quoted, words))` added to `update-shape.py` | 9 passed (survives) | `test_the_tools_that_quote_for_the_host_are_the_ones_the_sentence_names` fails: `update-shape.py` is among the files |
| M2: `shell_quoted(org, 'posix')` in `_add_command` (one of three) | 9 passed (survives) | `test_the_functions_that_quote_for_the_host_...[scripts/family.py]` fails: `_add_command` 2, the table 3 |
| M3a-d: `env` dropped from one of `cli.py`'s four `run_child` calls (`:436`, `:462`, `:474`, `:564`), one mutant each | 3 passed (survives) | the `[src/openreposhape/cli.py]` row fails, naming the line: ``src/openreposhape/cli.py:474 runs the interpreter without `env=standard.child_environment()` `` |
| M3e: `env = dict(os.environ)` for `scaffold`'s `env` | 3 passed (survives) | the same row fails |

**Against the base's helpers.** The new cases appended to `151a0861`'s files, without the new helpers: 12 of 19 fail in the quoting file and 12 of 19 in the launcher file (a `NameError` where the helper is new, an assertion where it is not). The 14 that pass are the negative cases (what is not a use, what is not a run) and the two `subprocess.run` spellings the guard already read. The `cli.py` and `setup-project.py` rows have no base row to run. Logs `v115/w759/basefail-quote.log` and `basefail-enc.log`.

**One mutant per new hunk,** applied to the new test files only, each killed (`failed` counts the tests that fail):

| Hunk | Mutant | failed |
|---|---|---|
| `host_calls` adds `host_values` | the value uses left out | 11 of 28 |
| `host_values`, `names_posix` call | a call told `'posix'` still a use | 4 |
| `host_values`, keyword | `ast.keyword` not climbed | 1 |
| `host_values`, alias statement | the alias statement a use | 3 |
| `names_posix` keyword | `platform=`/`posix=` ignored | 3 |
| `names_posix` argument | a `'posix'` argument ignored | 1 |
| `function_of` | every use named `<module>` | 11 |
| source order | no sort by line | 1 |
| `host_use_counts` | a count of 1 for every function | 3 |
| `STARTERS` | `run_child` left out | 6 |
| `STARTERS` | `run` left out | 6 |
| `interpreter_names` | a name bound to `sys.executable` not followed | 6 |
| `argv_of` (now `lists_of` and `argv_lists`; the fix round's own mutants table covers them) | a list held in a name not resolved | 1 |
| `environment_of`, `run_child` | its second argument ignored | 4 |
| `environment_of`, local name | a name not read as what it was bound to | 2 |
| `NOT_YET` | the `xfail` mark dropped (the row is red) | 1 |
| `NOT_YET` | the row made to pass (`LAUNCHERS` value `""`): the strict `xfail` fails | 1 |

**These counts are `052410b6`'s.** At `08a6ed39` they are the fix round's: 297 passed, 1 xfailed over the same five files; the encoding file grows from 82 to 111 cases and the quoting file from 28 to 40.

On `052410b6`, `run9.sh` (gh stub first on PATH, `GH_TOKEN` bogus, empty `GH_CONFIG_DIR`; `v115/w759/final-2.log`):

`tests/test_validator_child_encoding.py tests/test_readme_windows_quoting_sentence.py tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_cli_reference.py`: **256 passed, 1 xfailed in 63.09s.**

Per file: `test_readme_windows_quoting_sentence.py` 9 to 28 cases (19 new), `test_validator_child_encoding.py` 56 to 82 (26 new, one of them the `xfail` row), the other three 147 together. No other test file imports or drives what changed: `git grep` finds no use of `interpreter_runs`, `LAUNCHERS`, `host_calls` or `host_quoting` outside these two files.

- **origin/main `64ec47c3`:** clean. **#724** landed as `20004887`, and nothing is stacked. The fix round's merge checks above are at `08a6ed39`; the rest of this section is `052410b6`'s.
- **Every `fix/*` and `l3-*` head on origin (47):** clean, and none other than #724 touches the two test files. The branches that touch a tool the guards read (`scripts/family.py`: `fix/646`, `fix/684`, `fix/685`, `fix/686`, `fix/688`, `fix/689`, `fix/692`, `fix/693`; `update-shape.py`: `fix/674`; `setup-project.py`: `fix/696`, `fix/729`) are clean too.
- **The wave-7 branches' committed tips** (`fix/757` to `fix/777`; `fix/760` is on origin): clean.
- **Lane 2's #711 (the Git Bash dialect):** no branch on origin at 10:52Z (`git ls-remote origin 'refs/heads/*711*' 'refs/pull/711/head'` is empty), so it is not checked. It changes `NAMED` and this file; whichever lands second re-gates against the other.
- Three older local branches (`fix/bootstrap-fast-forwards-tracking-branch`, `fix/scaffold-mount-ignored-legs`, `fix/scaffold-reuse-seeded-legs`) conflict, in files this does not touch, exactly as they do with origin/main.

- **No change to a tool.** The `setup-project.py` row is the finding, held as a strict `xfail`; fixing `run` is a separate change.
- **Dynamic access is not seen.** `getattr(repo_shape, "shell_line")` and `globals()["shell_line"]` hand a quoter on by name at run time. The inventory reads names in the source.
- **The guard still reads only the tools in `LAUNCHERS`.** A survey with the new `interpreter_runs` over every shipped file found five more: `shape-doctor.py:1697` (sets its own UTF-8, as the table's comment says), `src/openreposhape/standard.py:560` (a digest program with captured output), `scripts/render-cli-reference.py:403` (passes `env=child_env()`, found once NIT 1 was fixed), `templates/assembly-root/scripts/bootstrap.py:148` and `templates/family-root/scripts/siblings.py:567` (neither passes an environment; see below).

- **`templates/assembly-root/scripts/bootstrap.py`'s `_run` (`:148`) starts the validators with no environment, so a manifest name the code page cannot hold ends bootstrap.** Measured on a scaffolded project (a local `setup-project.py` run, then `project.yaml` `name: "日本"`), `PYTHONIOENCODING=cp1252:strict python3 scripts/bootstrap.py`: `validate-manifest.py` raises `UnicodeEncodeError` at `print(f"manifest ok: {manifest.get('name')} ...")` (`:972`) and bootstrap ends `bootstrap FAILED: manifest (exit 1)`, exit 1. With `utf-8:strict`: `manifest ok: 日本 (sample), 3 legs`, exit 0. It is #254's defect in a pinned copy, so a fix is a shape bump, and `make bootstrap` and `setup-project.py` step (7) both run it.
- **`setup-project.py`'s `run` gives its children no environment** (the finding above). If the coordinator files it, the `NOT_YET` entry names the fix and the measurement.
- **`templates/family-root/scripts/siblings.py:567`** starts each member's own `scripts/bootstrap.py` with no environment. Its stdout and the child's agree (neither reconfigures), so it is the first candidate's class and not the second's, and it has no row because it is a pinned copy.

Review: lane 2's independent review (issuecomment-6236457891) and its re-verification of 08a6ed3, READY (issuecomment-6237256937). CI (Brett Heap's ruling 27): H0 is the head 08a6ed3 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 40e678e; and the local gate passed on its predicted squash tree 817a2ff over main 2c6543e: the readme_windows_quoting_sentence, validator_child_encoding, repo_hygiene, agents_md_codes, cli_reference sets, 298 passed, 1 xfailed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #759

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brett Heap and others added 3 commits October 18, 2026 00:38
… that follow the host are quoted for the shell they are printed in, PowerShell or Git Bash, and names `init`'s `cd` line among `family.py`'s (#711)

#711 quotes `shape-doctor.py`'s lines, setup's family lines and
`family.py`'s NEXT, switch -c, add, cd and git rm -r --cached lines for
the shell they are printed in: `sh`'s under Git Bash (`MSYSTEM` set),
PowerShell's in PowerShell. README's Windows sentence and the handbook's
Windows panel still said PowerShell's. The wording waited in
docs-wording.md while #630 owned both files. #630 has landed (190133d)
and is merged here with main b9b7069, and the plan is recomputed on
it: the old text is byte-identical at README.md:158-161 and
docs/handbook.html:737-741.

- README.md: "Printed commands are quoted for a POSIX shell, so paste
  them into Git Bash" is kept word for word. The exceptions name
  `init`'s `cd` line too, and are "quoted for the shell they are
  printed in, PowerShell or Git Bash, on Windows." One line more.
- docs/handbook.html: the same words in `panel-windows`, and the limit
  `host_shell_dialect`'s docstring names, which README leaves out: a
  shell started from inside Git Bash inherits `MSYSTEM`, so a
  PowerShell started there is handed Git Bash's quoting, and cmd.exe is
  handed PowerShell's.
- The seven markers of README's § Starting a project in a new
  organisation move together, 45a3f886 -> 4c523ffe. The page's other
  sections that read it (masthead, partway, ci, legs, doublepin, park)
  were re-read: none says which shell a printed line is quoted for.
- tests/readme-cap.d/711.txt: +1, the line this adds. README goes 1474
  -> 1475 and the cap 1475 -> 1476 (1422 + 2 + 5 + 12 + 22 + 10 + 1 +
  1 + 1), one over README as main has it since #738 cut a line.

tests/test_readme_windows_quoting_sentence.py's SENTENCE matches the new
sentence once, and the handbook's panel holds it once, so the test does
not change.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… counts a quoter handed on as a value, as #808's inventory does, so `map(shell_quoted, w)` beside a branch on `os.name` is seen (#711)

#808 (#759) landed first, as 677f9bf, so NIT 6 of lane 2's review of
it falls to this pull request. #808's `host_calls` counts a host quoter
handed on as a value (`host_values`: `map(shell_quoted, w)`,
`key=shell_quoted`, a `functools.partial`). But #711's `calls_to`, which
`line_builders` and `skips_the_question` read, counted `host_call`'s
calls only. So `' '.join(map(shell_quoted, w)) if os.name == 'nt' else
''` in `def a(w)` skipped the question unseen, while the same line
written `shell_quoted(x) for x in w` was caught.

`calls_to` now adds the functions `host_values` names, which it names
as `scopes` does. On the 25 shipped files nothing changes: none hands a
quoter on as a value, and each file's line builders, uses and skips are
the same before and after. The new test pins the value use, the call
beside it, and a value use told 'posix', which is not one. With the
`host_values` term dropped, it fails at the value use.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…785) landed

Three files conflicted. Each is composed, with no change to either
side's behaviour:

- shape-doctor.py, the `repo_shape` import: both names are kept,
  `host_shell_dialect` (#711) and `POWERSHELL_SINGLE_QUOTES` (#792).
- shape-doctor.py, `quote_arg`: #792's `text = str(value)` and its branch
  for a value that is not ASCII (`quoted_beyond_ascii`), with #711's
  dialect, `host_shell_dialect()` where no platform is given. So that
  value is `sh`'s `'...'` or `$'...'` off Windows and under Git Bash,
  and PowerShell's verbatim string or `('...' + [char]0x...)` in
  PowerShell and cmd.exe, as README now says.
- README.md: docs-wording.md section 4, "#785 first, then #711": #711's
  sentence, then #785's, which says "Off Windows and in Git Bash", three
  lines where it was two.
- docs/handbook.html, `panel-windows`: the same two sentences, and "a
  cmd.exe started anywhere else is handed PowerShell's" for "cmd.exe is
  handed PowerShell's", which read as a cmd.exe started inside Git Bash
  too, and that one inherits `MSYSTEM`.
- docs/handbook.html, the seven markers of README's § Starting a project
  in a new organisation: neither side's blob (4c523ffe, 3d4a01ff) but the
  merged section's, e0d2b672, the blob docs-wording.md computed for this
  order.

And tests/readme-cap.d/711.txt, which did not conflict, says +2: on top
of main #711 now adds two lines, its own clause and the third line of
#785's reworded sentence. README is 1478 lines; the cap is 1422 + 2 + 5
+ 12 + 22 + 10 + 1 + 2 (785) + 1 (786) + 2 (711) = 1479.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap changed the title repo_shape.py, setup-project.py and shape-doctor.py: under Git Bash (MSYSTEM set) the lines that follow the host are quoted for sh, through one question, host_shell_dialect, so the doctor's lines, setup's family lines and family.py's NEXT, switch -c, add and cd lines paste whole in the shell that printed them repo_shape.py, setup-project.py, shape-doctor.py, README and the handbook: under Git Bash (MSYSTEM set) the lines that follow the host are quoted for sh, through one question, host_shell_dialect, so the doctor's lines, setup's family lines and family.py's NEXT, switch -c, add and cd lines paste whole in the shell that printed them, and README's Windows sentence says so Oct 18, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-18T05:26:15Z) Review of #821 (#711)'s new commits (5035128e, e15c237b and the merge round 4ed7d2e0): READY

This verdict is for head 4ed7d2e0, which is now fix/711-git-bash-dialect on origin. No MUST, no SHOULD. Lane 2 lands #821 as reviewed. The four NITs are left on record here; NIT 1 and NIT 2 are test gaps for a later change.

NIT 1. NIT 6's test checks only a value use inside a plain function (tests/test_readme_windows_quoting_sentence.py:643-658). Two one-line mutants of calls_to at :554 leave the file green (44 passed each):

  • Builders stop counting. host_values(tree, quoter_names(tree)) in place of host_values(tree, names) drops a line builder passed as a value.
    • The committed guard flags def a(ss, ds): return list(map(functools.partial(copy_command, platform=os.name), ss, ds)), next to a copy_command(s, d, platform=None) builder, as {'a'}.
    • The mutant gives set().
  • Methods lose their class. function.rsplit(".", 1)[-1] in place of function names a value use in a method without its class.
    • The committed guard flags class P: def line(self, w): return ' '.join(map(shell_quoted, w)) if os.name == 'nt' else '' as {'P.line'}.
    • The mutant gives set().
  • Fix: add those two sources to the test, asserting {'a'} and {'P.line'}.

NIT 2. No test prints a non-ASCII path under an emulated Git Bash or PowerShell host (tests/test_git_bash_dialect.py:248, :282).

  • What rests on it. The merged README.md:163-165 and handbook.html:744-746 say such a path is written $'…' "in Git Bash". That holds because quote_arg passes quoted_beyond_ascii the windows it took from host_shell_dialect() (shape-doctor.py:919-923).
  • The gap. This mutant at :923 is caught only by the one-question guard (2 failed, 261 passed):
    quoted_beyond_ascii(text, (os.name if platform is None else platform) == "nt", printed_encoding(encoding))
    No line test sees Git Bash handed ('C:\Users\Zo' + [char]0x00EB + '\work').
  • Fix: one row asserting the exact spelling of doctor.quote_arg(r"C:\Users\Zoë\work", encoding="ascii") per host:
    • $'C:\134Users\134Zo\303\253\134work' for git-bash and msys2;
    • ('C:\Users\Zo' + [char]0x00EB + '\work') for powershell and empty-msystem.
    • It has to be exact text, because shlex.split cannot read $'…' back; test_785_a_value_is_spelled_for_sh does the same.

NIT 3 (optional). The handbook's cmd.exe clause (docs/handbook.html:742-744). The sentence is accurate: the probe below gives a cmd.exe outside Git Bash nt, and one inside it posix. A reader still has to infer two things:

  • that a cmd.exe started inside Git Bash gets Git Bash's quoting;
  • that cmd.exe does not read PowerShell's quoting once a word is quoted ('C:\my dir' reaches git as two words with the apostrophes in them).

A possible rewording: "so a PowerShell or cmd.exe started there is handed Git Bash's quoting. A cmd.exe started anywhere else is handed PowerShell's, which it does not read once a word is quoted, so paste those lines into PowerShell."

NIT 4 (optional). One stale word in a docstring (shape-doctor.py:867, in #792's paragraph of quote_arg's docstring). "On Windows that is rare" speaks of the PowerShell expression. Since the merge, Git Bash is on Windows too and takes the sh branch. "In PowerShell that is rare" says what it means.

Checked and true

Could not verify

  • No real Windows host: nothing ran in PowerShell, cmd.exe or Git Bash.
  • That a PowerShell or cmd.exe started from inside Git Bash inherits MSYSTEM. It is the docstring's and the handbook's premise, and it is not run here.

Lane: openreposhape-2 (openRepoShape-2)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 18, 2026 05:49
@sourcery-ai

sourcery-ai Bot commented Oct 18, 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 5 days and 5 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 3d81259 into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/711-git-bash-dialect branch October 18, 2026 05:50
brettheap added a commit that referenced this pull request Oct 18, 2026
…light's offers and `--shape-ref` is held to git's rule part by part, so a rehearsal no longer writes git's global config and `a.lock/b` is refused before the clone (#857) (#873)

Stacked on #811 (`fix/777-typed-name-edges`); retarget to main once it lands.

#756 has landed as `4dea85ee`, and #811's head is now `b6f56cad`: #756's squash merged in, and a test-only change to `tests/test_setup_project_name_edges.py`. This branch stays on `050abea6`, the merge base of the two, so its diff against #811's branch is its two commits: the fix (`79a33f11`) and fix round 1 (`ac570069`), which changes two test files and nothing else. Lane 5 restacks it after #811 lands.

**Direction: the silent reading.** A run passed `-- --dry-run` reports what is missing and asks nothing that would change the machine. Where a terminal would have been offered a command, it is told `not offered: --dry-run was passed through. A run without it offers this here, and so does --preflight.`, and asked nothing. The plan's last line now ends: ``The preflight made none of its offers; a run without --dry-run makes each one marked `not offered` above.`` `--preflight` still makes every offer, whatever follows `--`: it is the run that exists for them (#59), and it returns before anything reads the passthrough.

**Why.** Measured at `050abea6`, what each offer does on a typed `yes`, and all three change the machine:

- an install (`offer_missing`): git or gh, through the package manager;
- `gh auth login` (`_offer_login`): gh's own sign-in;
- `gh auth setup-git` (`_check_credential_helper`): gh writes a credential helper into the person's global git config.

A pty run of `setup-project.py Sample --org demoorg --elected-by Tester --into <dir> -- --dry-run`, under a signed-in stub `gh` and a git with no helper, asked ``Run `gh auth setup-git` now? Type yes to continue:``. The typed `yes` ran it, and the stub, which writes what the real one writes, added `[credential "https://github.com"]` to the file `GIT_CONFIG_GLOBAL` names. The run then ended at the plan saying `nothing was created and nothing was cloned`. With gh signed out, the same run asked ``Run `gh auth login` now?``, and the `yes` signed the stub in. The help said a `--dry-run` there "ends this run at the plan, creating nothing". And #811's CI run 39027235147 showed five rehearsals that typed only the project name waiting at that question until they were killed. Nothing argued for the offering reading: the offers stay one flag away (`--preflight`), and the real run makes them.

**What changed.** `preflight` passes `offering = not passes_a_dry_run(opts.passthrough)` to `preflight_checks`, and it goes down through `_check_git`, `_check_gh`, `offer_missing`, `offer_install`, `_offer_login` and `_check_credential_helper` to `offer`. `offer` tests it after `can_ask()`, so a run with no terminal prints what it printed before, and it is a bool, never `Options`, so `test_the_offer_helpers_cannot_see_the_yes_flag` still holds. The help says it, and `docs/cli.md` is regenerated. `AGENTS.md`, `README.md` and the handbook were grepped for `dry-run`, `creating nothing` and the offers: no sentence there is made false, so none is touched.

The git install offer cannot be reached by a dry run end to end: `_main` refuses a machine with no git (`GIT_MISSING`) before the preflight runs. It is held in process, through `preflight`, as the other two are.

**Tests** (`tests/test_setup_project_dry_run_offers.py`):

- `test_a_dry_run_makes_none_of_the_offers_preflight_makes[install|login|helper]`, once for `--dry-run` and once for its short spelling `--dry` (`passes_a_dry_run` reads both), in process on every platform. A fake terminal types `yes` to anything and a recorder runs nothing. `preflight` on `Sample --org demoorg -- <flag>` makes no prompt and runs no offered command, says `not offered` once per offer (2, 1, 1), and refuses or passes as before. `preflight_only` on `--preflight -- <flag>` then makes the same 2, 1, 1 offers.
- `test_a_dry_run_with_no_terminal_says_what_a_run_without_it_says[install|login|helper]`, in process: with no terminal (stdin is not one and there is none to open), `preflight` on `-- --dry-run` prints no `not offered` line, and what it prints is what the same run prints without `--dry-run`. It holds the order in `offer`, `can_ask()` before `offering`.
- `test_a_dry_run_leaves_git_with_no_helper_as_it_found_it[py|piped|py with --dry]`, a pty run (`PTY_SKIP`). A signed-in stub `gh`, an empty system config and a global config of the run's own, with no `GIT_CONFIG_COUNT` and run from a folder that is no repository. The run asks nothing, exits 0 at the plan, prints the `[??]` warning with the `not offered` line under it and the new last line, never calls `gh auth setup-git`, and leaves the global config the bytes it was.
- `test_a_dry_run_does_not_sign_gh_in`: the same with gh signed out (git has a helper, so the login is the one offer at stake). It asks nothing, the `[!!]` line has the `not offered` line under it, it refuses `one or more prerequisites are missing`, and gh is still signed out.

**Direction: finish the rule,** in a new `why_not_a_ref` beside `why_refused`, which `checked_ref` asks after `REF_RE`. Git's rule is per part: no part is empty (`//`, a trailing `/`), none starts with `.`, none ends in `.lock`, and the whole holds no `..` and does not end in `.`. Each failure has its own sentence (`WHY_EMPTY_PART`, `WHY_DOT_PART`, `WHY_LOCK_PART`, `WHY_DOT_END`). `WHY_RANGE` and `WHY_LOCK` keep #811's words, so its `ref-range` and `ref-lock` cases are unchanged. `REF_RE`'s comment and `checked_ref`'s docstring say what is asked.

**Evidence.** At `050abea6`, `checked_ref` beside `git check-ref-format --branch` disagreed on seven of the issue's ten names (`a.lock/b`, `x/y.lock/z`, `release.lock/v1`, `a/.b`, `a//b`, `a/b/`, `a.lock/`), and on `a.` and `a/b.`, under git 2.43.0 and 2.47.1 alike. Over all 3110 names of up to five parts from {`a`, `0`, `.`, `/`, `-`, `.lock`} that `REF_RE` takes, they disagreed on 1122 before and on none now, under both versions. In self-bootstrap mode `--shape-ref a.lock/b` cloned the standard and ended in `error: pathspec 'a.lock/b' did not match any file(s) known to git`, exit 1. Now it is refused before `(0) self-bootstrap` prints.

**Tests** (`tests/test_setup_project_ref_parts.py`):

- `test_the_tool_and_git_give_one_answer[<name>]`: the issue's ten names and fourteen around them (`a.`, `a/b.`, `a./b`, `a.lockb`, `a/b.lock/`, `a/.lock`, `a..b`, `refs/tags/v1.0`, `v1.0.0`, and five from fix round 1). Each is fed to `checked_ref` and to `git check-ref-format --branch`, which must give one answer, and the tool's reason is pinned. No network. Fix round 1's five are `a/.b/c` and `a/b/.c/d`, a part that starts with `.` that is not the last part; and three names that break two rules, which hold the order `why_not_a_ref` asks them in (its docstring says the sentence names the first one the value failed): `a//..b` is a range, not an empty part; `a//.b` an empty part, not a dot part; `a.lock/b.` a lock part, not a `.` at the end. With `a/.lock` and `a/b.lock`, that holds each of the five pairs of neighbours in the order.
- `test_a_ref_git_refuses_is_refused_before_the_clone[a.lock/b|a/.b|a//b]`: a self-bootstrap run exits 2 with the reason, prints nothing on stdout and clones nothing.

Through `run9s.sh`, `SHARDS=3`, at `79a33f11`: the two setup files, every other test file that names `setup-project` (`git grep -l 'setup-project' -- tests`), the two new files and the trio, 25 files.

- git 2.43.0: 1606 passed, 3 skipped, 1 xfailed in 746.00s, rc 0 (shards 428 passed, 3 skipped, 1 xfailed; 546 passed; 632 passed)
- git 2.47.1: 1606 passed, 3 skipped, 1 xfailed in 677.22s, rc 0 (shards 428 passed, 3 skipped, 1 xfailed; 546 passed; 632 passed)

Before the commit, unsharded on git 2.43.0: `test_setup_project_py.py`, `test_setup_project_name_edges.py`, `test_setup_project_typed_name.py` and the two new files, 377 passed. `test_setup_sh.py` and the trio, 235 passed.

Fix round 1, at `ac570069` (`setup-project.py` is unchanged, so every other file's result stands):

- git 2.43.0, `SHARDS=3`: `test_setup_project_py.py`, `test_setup_project_name_edges.py`, `test_setup_project_typed_name.py`, the two new files and the trio, 535 passed in 174.38s, rc 0.
- the two new files, 40 tests: 40 passed on git 2.43.0 (3.74s) and 40 passed on git 2.47.1 (2.64s).

The two new files, run against `050abea6`'s code: at `ac570069`, 40 tests, 28 failed and 12 passed (at `79a33f11`, 28 tests: 20 failed, 8 passed). All ten dry-run cases that make an offer fail: the three machines in both spellings, the three terminal runs and the signed-out run. Line 2's fifteen table rows that `050abea6` gets wrong fail: ten names git refuses that it passed, `a/.lock`, which it refused for the `.lock` and not for the part that starts with `.`, and fix round 1's `a/.b/c`, `a/b/.c/d`, `a//.b` and `a.lock/b.`. So do the three self-bootstrap cases. The 12 that pass are the three no-terminal cases (`050abea6` made no offer there either), and nine table rows: eight where `050abea6` already agreed with git (`a.lock`, `a/b.lock`, `topic.locked`, `a./b`, `a.lockb`, `a..b`, `refs/tags/v1.0`, `v1.0.0`) and `a//..b`, which it refused for its `..`, with the sentence the order gives. They hold the table to both directions, `topic.locked` passing among them.

23 mutants, each run against its tests, and all 23 caught:

- the gate in `offer` removed;
- `offering` not passed on in each of `offer_install`, `offer_missing`, `_check_git`, `_check_gh` (install, login), `_offer_login`, `_check_credential_helper`, and each of `preflight_checks`'s three calls;
- `preflight` always offering;
- `preflight_checks` silencing `--preflight -- --dry-run` too;
- the plan's new sentence removed;
- the `not offered` words changed;
- the help reverted (`tests/test_cli_reference.py`);
- `checked_ref`'s old two checks put back (`text.endswith(".lock")`);
- each of the four new parts of the rule dropped;
- `why_not_a_ref`'s answer ignored;
- the `.lock` part sentence changed.

The comment and docstring hunks have no mutant: they are prose. None survived.

An independent review then made 14 mutants of its own at `79a33f11`. Nine were caught. Five survived the tests as first pushed (the two new files and the setup tests): R1, the dry-run check put before `can_ask()` in `offer`; R3, the `..` and empty-part checks swapped; R4, `parts[-1].startswith(".")` only; R7, a literal `"--dry-run" not in opts.passthrough` in `preflight`; and R13, `parts[:2]` only. Fix round 1 closes each, rebuilt from the review's descriptions and run at `ac570069` against the two new files on git 2.43.0:

- R1 fails the no-terminal test on all three machines (3);
- R3 fails `a//..b` (1);
- R4 fails `a/.b/c` and `a/b/.c/d` (2);
- R7 fails the three `--dry` cases in process and the `--dry` terminal run (4);
- R13 fails `a/b/.c/d` (1).

Run against the two files as first pushed (`79a33f11`), the same five all survived, which is what the review found. Two more mutants for the new order rows are caught: `a//.b` fails with an empty part checked after a dot part, and `a.lock/b.` fails with a `.` at the end checked before a lock part. The other nine of the review's mutants were caught before and were not rerun.

`git merge-tree --write-tree` of `79a33f11`, and of `ac570069` for the first line:

- **#811's head `b6f56cad`: clean**, for both (`git merge-tree --write-tree b6f56ca ac57006`, tree `3e369754`). Fix round 1 changes only the two test files this PR added, so the other lines below stand for it: they were checked at `79a33f11` and not run again.
- **origin/main `d7befb91`: conflicts in `setup-project.py` and `tests/test_setup_project_typed_name.py`.** The conflict is inherited: `050abea6` alone conflicts with `d7befb91` in the same two files (#756 landed as a squash, and #811's branch held its history). A commit-tree prediction of `79a33f11` merged onto `b6f56cad` is clean against `d7befb91`.
- #859 `495d4b33` (`fix/838-github-name-limits`): clean. `setup-project.py` and `docs/cli.md` merge by themselves, with this head and with the predicted restack.
- #821 `e15c237b` (`fix/711-git-bash-dialect`): clean, `setup-project.py` merging by itself. The predicted restack conflicts with it only in `README.md`, `docs/handbook.html` and `shape-doctor.py`, where `d7befb91` itself conflicts with `e15c237b`. None of the three is this branch's.
- `fix/764` `05a1ebb3`, `fix/796` `2d51f181`, `fix/798` `70801c91` and `fix/803` `08f7a283` (`docs/cli.md`): clean.
- No other `fix/*` or `l3-*` branch touches these four files.
- #848's scan on main (`counts_set_to_a_number`) finds no count set in either new test file.

- `setup.sh`'s shim keeps its own copy of the old rule (`*..*|*.lock`), and its comment says it mirrors `setup-project.py`. Under `cat setup.sh | bash -s -- --shape-ref a.lock/b` the shim clones the standard and ends in git's `pathspec 'a.lock/b' did not match`, exit 1, before this file's check runs. Measured at this head, it is outside #857's lines and is reported to lane 5 as a checklist candidate.
- The plan's last line prints on every dry run, including where the preflight marked no offer `not offered`, where "each one marked `not offered` above" points at none. It is true as written. Printing it only where an offer was marked would take a count that `offer` writes and the plan reads, held in the module: `offer` is three to five calls below `preflight`, and each caller between returns a bool that the next one reads. That is state in a file whose `offer` takes its inputs as arguments and nothing else, for a sentence most dry runs would then not print, so it is left.
- No Windows or macOS run: CI is Linux only (#597). The in-process test runs on every platform; the pty tests are POSIX only.

Review: rv873, lane 5's independent review, READY at `79a33f11` with S1 (issuecomment-6245576863); f873's fix round 1 `ac570069` re-checked READY with no findings (issuecomment-6245715704). S2 is filed as #874. `0774ad60` restacks it onto #811's squash `bc362333` with three merges and the same `-U0` lines. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 0774ad6 (the restack of the branch onto main after #811 landed) passed tests, tests-git-bash and SonarCloud; the head e41a38a is a clean update-branch merge of main 85aa8a1, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree fa52066 over main 85aa8a1: the doctor_next_is_a_command, doctor_validator_rows, setup_project_dry_run_offers, setup_project_ref_parts, repo_hygiene, agents_md_codes, cli_reference sets, 320 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #857

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