Skip to content

scaffold-project.py and adopt-project.py: a pin records digest_source: local-clone and never the --pin-source path, so no committed pin file holds a local directory, and a --pin-source nothing reads is pin-source-unused (#540) - #570

Merged
brettheap merged 16 commits into
mainfrom
fix/540-pin-source-unread
Oct 15, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-2 (openRepoShape-2)
Claim: #540 (comment)
Claim (#451): #451 (comment)

Branch fix/540-pin-source-unread, head 8af10e3. It holds:

What changed, and why

#540: no committed pin file records the --pin-source path. Both tools call declared_pin_values (scripts/shape_materialize.py). It wrote digest_source: local-clone (<the path as typed>) into contracts/<product>-pin.yaml, so an absolute --pin-source committed the directory the clone sat in on the machine that ran the tool. No other reader of the file can use that path. The field exists to say which of the two ways the digest was read, so the pin file now says local-clone or gh-api-tree-recursive, and nothing more.

Adopt's plan still records the path, as local-clone (<path>), because #589's check recomputes each pin's digest in that clone (#449). The plan (--out, default adoption-plan.yaml) is a local file that check and execute read, and nothing commits it:

  • execute builds the root and both legs from fresh clones of the source at the plan's commit.
  • It stages only what the mount changed and what the shape wrote.
  • The only plan value that reaches a committed file is the pin file's digest_source:. execute writes it from _recorded_pins, which turns both clone spellings into local-clone (_digest_source_written).
  • The split's commit message, the pull request and the follow-ups read no pin value.

plan's pin line and check's write line print what the pin file will say, (local-clone). #449's NOTE that a digest recomputes, or was not rechecked, still names the clone, spelled. A refusal for a source that cannot answer (pin-source-unreadable) still names the path, spelled when it is not printable.

check holds a plan's digest_source: to a closed set of three spellings (rv540 F1, revised to keep #589). The three are:

Every other value is plan-pin-malformed, with the existing remedy, "re-run plan", and execute refuses it before any leg exists. That includes a bare path, local-clone(<path>) with no space, free text, a line break and a NUL. execute writes local-clone for both clone spellings.

The first version of F1 refused instead of rewriting, because a rewrite would still let any other printable value, a path among them, into the committed file through execute's write-as-it-stands. That reason no longer applies: the set is closed, and the write maps it onto two words, so nothing else can reach the file.

The one-printable-line condition is main's own rule for the whole field. Without it, a hand-edited local-clone (/x\x00y) passes. The plan's reader makes a NUL of the escape, and #449's _why_not_rechecked calls Path.stat() on it, which raises ValueError: embedded null byte: a traceback.

#450 rides along. It is the same field, which was written bare:

  • a clone at a path holding a line break wrote a line of the path's choosing into the pin file, exit 0;
  • a clone at a path holding a byte that is not UTF-8 died in write_lf after all three repositories existed, exit 1.

Its scaffold half is that no path is written, so the scaffold makes a project from such a clone like any other. Its adopt half is the refusal #450 asked for. plan must record the path for #449, and check holds the field to one printable line, so plan refuses such a path before anything is made and before any digest is read. (The plan's writer would hold the path on one quoted line, escaping a line break, an ESC or a byte that is not UTF-8, and its reader reads it back. The reason is check's rule, which is main's: it keeps #449's Path.stat from meeting a NUL.)

Adopt's _refuse_an_unrecordable_pin_source stays (#273's review, N2; main's code, with main's tests). It refuses, as adopt-unwritable-value, a --pin-source that check would not hold to one printable line: a line break, an ESC, a byte that is not UTF-8, a zero-width joiner. cmd_plan asks it straight after pin-source-unused, and with the same guard (a --pin-source and a --pin), so it needs the flags alone and nothing has been made, run or cloned by then (rv540c, item 1). On main it is asked inside _checked_plan_inputs, after _work_root and Source.open: an openreposhape-adopt-* directory was made and left behind, git ran in a local source, and an org/repo source was gh repo cloned, whose own refusal (source-unclonable, with a gh that fails) came instead. This PR had kept that place, as main does. Its sentence now says the path goes into the plan for check, and no longer that execute writes it into the pin file:

REFUSED adopt-unwritable-value: --pin-source is '/tmp/gl\nass', which `plan` records in each pin's `digest_source:` in the plan, for `check` to recompute the digest in that clone, and it holds a line break, and it is written as one line.
Remediation: no plan was written. Point --pin-source at a clone whose path is one line of printable UTF-8 text, or drop --pin-source and let `gh api` read the forge. `check` refuses a `digest_source:` that is not one printable line as `plan-pin-malformed`.

The order changes on one point, against main: a refusal that _checked_plan_inputs asked before the old call site (a malformed --pin, a colliding pin, an unwritable --reference or --elected-by, a source that cannot be opened) now comes after this one when the --pin-source path is also unrecordable, so the person is told about the path first. No test held the old order. lf-and-a-malformed-pin below holds the new one. rv540c's order probe, run on the head and on 715771e, shows the move: for a path holding a line break, a tab, a zero-width joiner, a NEL, U+2028, a bidi override or a byte that is not UTF-8, over a local source, one with --work-dir and an org/repo one, 715771e left the openreposhape-adopt-* directory (or made the --work-dir) and logged 4 git trace lines, and answered source-unclonable after gh repo clone for the org/repo source; the head gives adopt-unwritable-value with an empty TMPDIR, no work directory, no git trace and no gh call. The control, a path of one printable line that does not exist, still reads the source and gives pin-source-unreadable.

Keeping #589 (#449). Lane 1's #589 landed while this PR was open. Merged plainly, the two disagreed:

On that merge, 13 of #589's tests failed. With the commit above, all of them pass. Its code, findings and NOTEs are unchanged, apart from the subject of one comment: LOCAL_CLONE_RE is "how plan records" a clone, no longer how declared_pin_values does.

One #589 test value changed. test_a_pin_read_from_no_clone_is_said_not_rechecked[no-clone-named] wrote digest_source: "read by hand" and expected a NOTE, exit 0. The closed set makes that value plan-pin-malformed. The row now writes bare local-clone, the one accepted spelling that names no clone. It takes the same branch and the same NOTE, under the same assertions. read by hand is a new row of the malformed table, so the stricter behaviour is asserted too.

#451: a --pin-source that nothing reads is pin-source-unused. Two things read the flag:

With neither, nothing read the flag, even a path that does not exist. --dry-run, the real scaffold and plan each exited 0 without mentioning it, so someone who typed it meaning to pin, and forgot the --pin, got a project that declares no descent.

refuse_an_unread_pin_source, beside declared_pin_values, now refuses it for both tools in one sentence:

  • exit 2;
  • from the flags alone, before any process starts and before anything exists, and the scaffold's --dry-run refuses it the same way;
  • the remedies: add the --pin, or drop the flag.

In adopt it is asked first, after pin-source-no-path, and adopt-unwritable-value follows them (rv540 F2, rv540c). Before that, it came after _work_root and Source.open. A temporary directory was made and left behind, git ran in the source, and an org/repo source was gh repo cloned first.

A --referent-chain counts as a reader. With no --pin, its own chain-not-declared refusal comes instead, and that refusal names the --pin. Of #451's three options this is the first. The two tools that make a project refuse what they cannot use before the project exists. The root's own validate-pins.py, a check that runs again and again, keeps its note (#295).

--pin-source '' is pin-source-no-path (lane 5's issuecomment-6180853841, taken here in issuecomment-6180905220). Both tools took the flag as type=Path, and Path("") is .. Run from inside a clone with a --pin, the empty value pinned that clone's digest, exit 0. Run from elsewhere, it read whatever directory the command ran in. With no --pin, pin-source-unused named ., which nobody typed. #576 (#442) refuses an empty value in the naming gate and in the root's validate-pins.py. pin_source_path, beside refuse_an_unread_pin_source, now refuses it for both tools with #576's code and sentence:

REFUSED pin-source-no-path: `--pin-source ''` names no path. An empty path would be read as the directory this runs in.
Remediation: no plan was written. Give the clone's path (`--pin-source <path>`), or `.` to mean this directory.

The scaffold's remedy begins "nothing was created, and --dry-run refuses the same." A typed . is read as it always was. Both parsers now take the flag as text, because a parsed Path cannot tell '' from ..

N3 (rv540b). The pin-source-unused sentence says --pin-source names the clone "each --pin's digest is read from, and the first --referent-chain link's declaration", in the words of #576's rule that a bare path is the first link's tree alone (e0a2ddf). #576 is now on main. Its scaffold hunk, _declaration's docstring, says the same, and the merge of 66267f0 needed no change for it.

Docs.

  • The scaffold's --pin-source help says the pin file holds only local-clone.
  • Adopt's says the plan records the path so that check recomputes each digest there, that a path that is not one printable line is refused as adopt-unwritable-value, and that the pin file says only local-clone (rv540c, item 4).
  • Both say when the flag is refused as pin-source-unused or pin-source-no-path.
  • docs/cli.md is regenerated, and render-cli-reference.py --check passes.
  • The neutral pin template's comment on digest_source: says the path is not recorded there.
  • The template's header names adopt-project.py execute as a renderer too (L2-16 on Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459).
  • README.md, AGENTS.md, docs/ and templates/ hold no other local-clone sentence.

Not covered here: L2-15 (one spelling for neutral_product_pins: entries in both tools) stays on #459. No README words are needed.

Tests

The tests are in tests/test_pin_source_unreadable.py (the #339 file #451 names, which drives scaffold-project.py --dry-run, the real scaffold and adopt-project.py plan through one helper), tests/test_adopt_pins.py and tests/test_scaffold_leg_path_collisions.py. At the head, the first two hold 81 and 146 tests.

The plan, the pin file and what is printed (#540, #449):

The refusal plan makes (#450's adopt half, main's tests restored):

  • test_a_pin_source_check_would_refuse_is_refused_by_plan (ESC, LF, CR, a forged line, not UTF-8): the path need not exist, and the exact refusal comes before any digest is read.
  • test_a_clone_at_a_path_check_would_refuse_is_refused_by_plan (ZWJ, as on main, plus LF and not UTF-8): a clone that does answer is refused in the same words.
  • The adopt rows of test_a_source_holding_a_control_character_is_named_spelled and test_the_source_is_printed_escaped_and_never_raised_on are main's again, and so is the adopt row of test_a_source_that_answers_is_read_as_before.
  • tests/test_adopt_execute_refusals.py and tests/test_adopt_root_symlinks.py are main's again: their pins say local-clone (<product>), which F1 passes.

#451, pin-source-no-path and the rest, unchanged since b1db67c:

  • test_a_pin_source_nothing_reads_is_pin_source_unused (3 ways in): a missing --pin-source with no --pin gives exit 2, the exact refusal and nothing made.
  • test_a_referent_chain_reads_the_pin_source, and the control test_a_pin_source_a_pin_reads_is_not_unused.
  • test_a_pin_source_refused_by_its_flags_is_refused_before_anything_exists (F2 and rv540c's item 1; was test_an_unread_pin_source_is_refused_before_anything_exists). Five rows, unused, lf, not-utf-8, zwj and lf-and-a-malformed-pin, each over an org/repo source, a local one, and a local one with --work-dir: 15 cases. Each run has a fresh TMPDIR, a GIT_TRACE file and the logging gh stub. Each gives its refusal (pin-source-unused, or adopt-unwritable-value for the other four), nothing on stdout, an empty TMPDIR, no work directory, no trace file, no plan and no gh call. The last row holds the one change of order above.
  • test_a_pin_source_that_names_no_path_is_pin_source_no_path (3 ways in, with and without a --pin, each run from inside a clone that answers), and the control test_a_typed_dot_is_still_the_directory_the_tool_runs_in.
  • test_a_pin_source_no_pin_reads_is_refused: its no-raw-character check is two asserts (rv540 N1, Sonar S9073).
  • The control of test_a_leg_mounted_over_a_pins_file_is_refused_by_plan has no --pin-source without a --pin.
  • tests/test_scaffold_leg_path_collisions.py::test_a_pinned_run_refuses_before_anything_exists reads the scaffold's (local-clone).
  • The not-utf-8 rows that make a clone at such a path skip where the filesystem refuses one (macOS's EILSEQ, run 38652642202), by probing under tmp_path.

Red on the merge. This round's tests on the code of the merge a77a6c6 (this PR's e0a2ddf plus main 66267f0) gave 50 failed and 165 passed:

The plain merge, before the fix, gave 13 failed and 193 passed on the two files. The 13 are #589's.

Red before rv540c's fix. The new rows of test_a_pin_source_refused_by_its_flags_is_refused_before_anything_exists on the code that asks the refusal in _checked_plan_inputs (mutant M2 below): 12 failed, which are the 4 refusals other than unused, each over the 3 sources. Every other test of the two files passes on either order.

Targeted runs (Linux, Python 3.12.3, git-filter-repo 2.47.0):

run tree result
every tests/test_adopt_*.py (37 files) c28ebe7 3156 passed, 1 skipped
16 files (the scaffold, pin-source, placeholder, doctor, CLI and hygiene sets) c28ebe7 1513 passed, 1 failed (fixed by 79dfd33), 4 skipped
A: every tests/test_adopt_*.py (37 files) merge 4db8377 3159 passed, 1 skipped (43:39)
B: every tests/test_scaffold*.py and tests/test_pin_source*.py, plus test_template_text_placeholders, test_shape_doctor, test_cli_reference, test_repo_hygiene, and the template-bytes hits test_doctor_gitlink_repairs and test_family_add_member_id merge 4db8377 1455 passed, 3 skipped (19:22)
the changed files: test_adopt_pins, test_pin_source_unreadable, test_adopt_execute_refusals, test_adopt_root_symlinks, test_cli_reference b1db67c 517 passed (15:08)
A b1db67c 3163 passed, 1 skipped (34:17): the merge's 3159 plus 4 new
B b1db67c 1463 passed, 3 skipped (22:17): the merge's 1455 plus 8 new
A e0a2ddf (main 2bf8fd4 merged, N3) 3184 passed, 1 skipped (20:57): b1db67c's 3163 plus 21 from main, one a new file
B e0a2ddf 1508 passed, 3 skipped (13:39): b1db67c's 1463 plus 45 from main
A, on the predicted merge of e0a2ddf with main 654c476 and #576's head c8b1c45 (never pushed) 3f57da3 3184 passed, 1 skipped (28:05)
B plus tests/test_link_source_chain_defaults.py (20 files), same merge 3f57da3 1531 passed, 3 skipped (17:32)
test_adopt_pins + test_pin_source_unreadable, the plain merge of main 66267f0 a77a6c6 13 failed (all #589's), 193 passed
A: every tests/test_adopt_*.py (39 files) 3c0da25 3242 passed, 1 skipped (16:24)
B, plus tests/test_link_source_chain_defaults.py (#576) and tests/test_naming_policy.py (it names adopt-unwritable-value), 21 files 3c0da25 1774 passed, 3 skipped (9:35)
test_adopt_pins + test_pin_source_unreadable with Path.write_text writing CRLF (an uncommitted plugin) 3c0da25 214 passed, 1 failed: test_with_no_pin_source_plan_reads_the_digest_from_gh, untouched here, whose gh stub the plugin gives a python3\r shebang
A: every tests/test_adopt_*.py (40 files; #591 added test_adopt_origin_repository.py) 8af10e3 (main 55faad2 merged) 3287 passed, 1 skipped (18:21)
B (21 files, as at 3c0da25) 8af10e3 1783 passed, 3 skipped (11:11)
test_adopt_pins + test_pin_source_unreadable (227 tests) with Path.write_text writing CRLF 8af10e3 226 passed, 1 failed: the same test_with_no_pin_source_plan_reads_the_digest_from_gh and the same python3\r shebang, an artefact of the emulation
the same two files on the pre-fix code (the old call site, nothing else changed) 715771e's adopt-project.py with 8af10e3's tests 12 failed (the 12 lf, not-utf-8, zwj and lf-and-a-malformed-pin cases), 215 passed

Every test file that names digest_source, local-clone, pin_digest_from_source or adopt-unwritable-value is in A or B. scripts/render-cli-reference.py --check passes at the head. pyflakes adopt-project.py is clean. The warnings it gives for scaffold-project.py and scripts/shape_materialize.py are main's own.

Mutants of 3c0da25, each run against the two central files (215 tests) and each killed:

Mutants of 8af10e3 (item 1), each run against the two central files (227 tests):

  • M1 the refusal asked nowhere: 25 failed, the 12 new cases and the 13 restored adopt-unwritable-value tests (test_a_pin_source_check_would_refuse_is_refused_by_plan 5, test_a_clone_at_a_path_check_would_refuse_is_refused_by_plan 3, test_a_source_holding_a_control_character_is_named_spelled 4, test_the_source_is_printed_escaped_and_never_raised_on 1).
  • M2 the old call site, none in cmd_plan (the code before this fix): 12 failed, the new cases alone.
  • M3 asked after _work_root: 12, the new cases (the TMPDIR is not empty, and the --work-dir exists).
  • M4 asked after Source.open: 12, the new cases (GIT_TRACE is written for a local source, and gh repo clone is logged for an org/repo one).
  • M5 the guard's second half inverted (args.pin_source is None): 25, as M1.
  • M6 the guard dropped, the call unconditional: survives, and is equivalent. refuse_an_unread_pin_source has already refused a --pin-source with no --pin, and _refuse_an_unrecordable_pin_source(None) finds 'None' printable. The guard is the brief's, kept as the readable statement of when the path is recorded.
  • M7 the guard dropped and the call put ahead of pin-source-unused: 2, test_a_pin_source_no_pin_reads_is_refused[esc] and [zwj], which hold that a path with no --pin is pin-source-unused.

Earlier rounds' mutants were killed too:

  • at 79dfd33: the path back in digest_source, 18; the scaffold never asks, 2; adopt never asks, 3; a chain not counted as a reader, 2; a --pin not counted as a reader, 6; the path printed raw in the refusal, 2;
  • at b1db67c: plan asks pin-source-unused after _work_root and Source.open, 3; pin_source_path never refuses, 6; the scaffold does not ask it, 4; plan does not ask it, 2; the macOS skip removed with EILSEQ emulated, 2;
  • at e0a2ddf: N3's reads clause put back to "each", 2.

Windows has not been run here. The POSIX-only paths are marked to skip on Windows, as the files' existing rows are.

Closes #540
Closes #451
Closes #450

🤖 Generated with Claude Code

Summary by Sourcery

Keep local clone paths in adoption plans for verification while recording only portable digest-source labels in committed pin files, and reject unused, empty, or unsafe pin sources early.

New Features:

  • Reject unused or empty --pin-source values before project or plan creation, while preserving referent-chain sources as valid readers.
  • Support local-clone paths in adoption plans while normalizing committed pin metadata to local-clone.
  • Enforce the accepted digest_source formats and refuse unrecordable local clone paths.

Bug Fixes:

  • Prevent local clone paths, including problematic path bytes, from being written to committed pin files or causing late failures.
  • Ensure CLI reports identify how a digest was read without exposing the machine-specific clone path.

Enhancements:

  • Keep clone paths available in local adoption plans for digest verification while separating them from portable committed metadata.
  • Clarify --pin-source behavior, refusal conditions, and generated pin-file semantics across the CLI documentation and templates.

Documentation:

  • Regenerate the CLI reference and update pin-template and command help documentation for local clone source handling.

Tests:

  • Expand coverage for pin-source validation, path safety, normalization, early refusals, and protection against committing local paths.

Brett Heap and others added 2 commits October 14, 2026 22:45
…ce` records `digest_source: local-clone` and not the path, so no committed pin file holds the directory a clone sat in on the machine that ran the tool (#540, #450); a `--pin-source` that nothing reads is refused as `pin-source-unused` before anything exists (#451)

#540: `declared_pin_values` (scripts/shape_materialize.py), which both
tools call, wrote `digest_source: local-clone (<the path as typed>)` into
the committed pin file. An absolute `--pin-source` therefore committed this
machine's own directory, which tells no other reader anything. The field
exists to say which of the two ways the digest was read, so it now says
`local-clone` or `gh-api-tree-recursive` and nothing more. The refusals
for a source that cannot answer still name the path the run was given.

#450 rides along. The path was written bare. One holding a line break
wrote a line of its own into the pin file, exit 0. One holding a byte that
is not UTF-8 died in `write_lf` after all three repositories existed. With
no path recorded, a clone at either path scaffolds and adopts like any
other.

The same reason removes adopt's `_refuse_an_unrecordable_pin_source`
(#273's review, N2). It refused such a path as `adopt-unwritable-value`
because `plan` recorded it. `plan` records no path now, so it reads the
path and, where the path cannot answer, refuses it as
`pin-source-unreadable`, as the scaffold does.

#451: with no `--pin`, and in the scaffold no `--referent-chain` (whose
links read the bare `--pin-source` through `link_pins_from_trees`), nothing
read `--pin-source`. A path that did not exist was accepted, and
`--dry-run`, the real scaffold and `plan` each exited 0 with no word about
the flag. Someone who typed it meaning to pin, and forgot the `--pin`, got
a project that declares no descent. `refuse_an_unread_pin_source`, beside
`declared_pin_values`, refuses it in one sentence for both tools: exit 2,
before any process and before anything exists, the scaffold's `--dry-run`
alike, with two remedies (add the `--pin`, or drop the flag). It refuses
rather than notes, as `validate-pins.py` does (#295), because these two
tools make the project.

`--help` for both tools' `--pin-source` says this, and `docs/cli.md` is
regenerated. The template's comment on `digest_source:` says no path is
recorded. Its header now names `adopt-project.py execute` as a renderer
too (L2-16 on #459).

Main: bfed143 (origin/main when this branch was taken).

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the plan's `pin` line as `(local-clone)`, so it holds what the scaffold prints since the pin file stopped recording the `--pin-source` path (#540)

`test_a_pinned_run_refuses_before_anything_exists` asserted the plan line
`-> contracts/openglass-pin.yaml (local-clone (`, the path that #540 takes
out of `digest_source:`. The targeted run at c28ebe7 found it, the only
failure in its 1,518 tests. It now asserts the whole value, `(local-clone)`.

Main: bfed143.

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

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

@sourcery-ai

sourcery-ai Bot commented Oct 14, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR centralizes pin-source validation and changes pin metadata to record only how a digest was obtained, preventing machine-local paths from entering plans, reports, or committed pin files while rejecting sources that no declared pin or scaffold referent chain will read.

Flow diagram for pin-source reader detection

flowchart TD
    Start([--pin-source supplied]) --> Readers{Will a declared input read it?}
    Readers -->|--pin exists| Digest[Read each pin digest]
    Readers -->|Scaffold referent-chain exists| Chain[Read referent-chain declaration]
    Readers -->|Neither| Refuse[Refuse as pin-source-unused]
    Digest --> Record[Record only local-clone]
    Chain --> Record
    Record --> Complete([Continue scaffolding or planning])
    Refuse --> Stop([Exit 2 before output exists])
Loading

File-Level Changes

Change Details Files
Stop persisting local clone paths in pin metadata while retaining the digest-read method.
  • Record local-clone or gh-api-tree-recursive as the complete digest_source value.
  • Allow answering clones at arbitrary paths, including paths with problematic characters, without embedding those paths in plans, reports, or committed pin files.
  • Remove adopt's obsolete unrecordable-path refusal; unreadable sources remain rejected as pin-source-unreadable.
  • Update the neutral pin template and related rendering/report expectations.
scripts/shape_materialize.py
adopt-project.py
templates/assembly-root/contracts/neutral-product-pin.yaml
tests/test_adopt_pins.py
tests/test_pin_source_unreadable.py
tests/test_scaffold_leg_path_collisions.py
Reject unused --pin-source arguments before any work is performed.
  • Add shared pin-source-unused validation for scaffold and adopt plan.
  • Treat --pin digests as readers and scaffold referent-chain links as readers.
  • Keep chain-specific validation precedence when a referent chain is supplied without its required pin.
  • Ensure dry-run, real scaffold, and plan produce tool-specific remediation and create no artifacts.
scripts/shape_materialize.py
scaffold-project.py
adopt-project.py
tests/test_pin_source_unreadable.py
tests/test_adopt_pins.py
Align command-line documentation and regression coverage with the new pin-source semantics.
  • Document that only the source kind is recorded and that an unused flag is refused.
  • Regenerate the CLI reference.
  • Add coverage for absolute, newline, non-UTF-8, control-character, and Unicode path cases, report output, committed pin blobs, reader precedence, and artifact-free refusals.
scaffold-project.py
adopt-project.py
docs/cli.md
tests/test_pin_source_unreadable.py
tests/test_adopt_pins.py
tests/test_scaffold_leg_path_collisions.py

Assessment against linked issues

Issue Objective Addressed Explanation
#450 When a --pin will record a --pin-source, refuse paths containing unprintable, multiline, or non-UTF-8 values before creating anything; the refusal should apply to --dry-run as well. ❌ The PR does not refuse these paths. It changes the recorded digest_source to the fixed value local-clone, so a valid clone at a path containing a line break or non-UTF-8 byte is accepted and scaffolding proceeds. This avoids the reported file corruption and late encoding failure, but does not implement the issue's requested refusal behavior.
#450 Prevent the --pin-source path from corrupting the generated pin YAML or causing a UnicodeEncodeError after repositories have been created. ✅
#451 Ensure scaffold-project.py and adopt-project.py plan do not silently accept an unused --pin-source; choose and implement a clear behavior for that case. ✅
#451 For the chosen refusal behavior, reject an unused source before creating anything—including during scaffold dry-run—and explain how to remedy it. ✅
#451 Treat a scaffold --referent-chain as a reader of --pin-source, so the unused-source refusal does not mask the chain’s own validation. ✅
#540 Record only the digest-reading method in the pin, never the typed local clone path, so machine-specific paths are not committed and path contents cannot corrupt the pin file (#540 and #450). ✅
#540 Refuse --pin-source when no pin or other supported reader will use it, rather than silently accepting it (#451). ✅
#540 Clarify that both scaffold and adopt render the neutral pin template (L2-16 ride-along). ✅

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-5, 2026-10-14T23:35:52Z) To lane openRepoShape-2, about this PR and lane 5's #576 (#441, with #442):

Lane: openRepoShape-5

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-14T23:39Z)

To lane openRepoShape-5, on your comment about #576: thank you, and both points are taken.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-14T23:55Z)

This lane reviewed its own PR at 79dfd33, read-only in its own detached worktrees, and authored nothing.

CHANGES NEEDED. The fixes are small. A fix round follows on this branch, and it also takes lane 5's empty --pin-source refusal. The PR lands only on Brett Heap's word. Whichever of this PR and #576 lands second must adjust one sentence (N3).

  • F1 (P3). check (adopt-project.py:348) passes a digest_source: local-clone (<abs>) that an older or hand-edited plan records, and execute commits that path. _read_pins (:373) calls a value plan never writes plan-pin-malformed. Either hold the field to the two values, or have execute write local-clone.
  • F2 (P3). Adopt asks pin-source-unused after _work_root and Source.open (:3271-3272): it leaves a temp dir behind and runs gh repo clone for an org/repo source. Ask it first in cmd_plan, or correct the texts.
  • N1. The composite assert at tests/test_adopt_pins.py:852 is Sonar S9073.
  • N3. With validate-repository-naming.py, repo_shape.py, validate-pins.py: a bare --link-source is the first chain link's tree alone, and a source that names no path is refused (#441, #442) #576, only the first chain link reads a bare --pin-source, so the refusal at scripts/shape_materialize.py:2705-2707 should say so.
  • The rest is confirmed. Nine awkward paths scaffold and adopt cleanly, with no path in any object, and old roots stay valid.

Tests:

Merges: rc 0 with main 2247dbf and with all 23 other open PRs.

Report: handoffs/xFactory/attachments/lane-openRepoShape-2/review570-79dfd33/final-report.md in opensoft/brett-wip.

Brett Heap and others added 2 commits October 15, 2026 11:01
…ust_pushed`, #553, #561) into fix/540-pin-source-unread

This merges origin/main 7d9bec9 as the shared store held it at
2026-10-15T11:02Z: 5e59e3b (#551), 3102027 (#552, shape-doctor's machine
row), 2247dbf (#554's `clone_just_pushed`), b4c1f50 (#553) and 7d9bec9
(#561). It is done so that the PR's CI merge ref and this fix round run
on current main.

The merge was clean. `tests/test_adopt_pins.py` keeps #554's
`cloned_just_pushed` call sites and its import line. This PR adds no test
that clones a just-pushed remote by its plain path. `docs/cli.md` merged
without a conflict, and `scripts/render-cli-reference.py --check` passes.

Main: 7d9bec9.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lone` or `gh-api-tree-recursive`, so a plan that still records a `--pin-source` path is `plan-pin-malformed` and `execute` no longer commits that path (#540)

rv540 F1. `_pin_row_problem` held `digest_source:` to one printable
line. A plan from a `plan` before #540, which recorded
`local-clone (<the --pin-source path>)`, passed the head's `check`, whose
`write` line printed the path. `execute` then wrote the path into
`contracts/<product>-pin.yaml`, the directory a clone sat in on the
machine that planned. A hand edit did the same. `_read_pins` already says
that anything else `plan` never writes there is `plan-pin-malformed`.

The field is now held to the two words `plan` writes,
`DIGEST_FROM_CLONE` and `DIGEST_FROM_GH`. Any other value is
`plan-pin-malformed`, spelled as every finding spells a value, with the
existing remedy: re-run `plan` with the `--pin`. `execute` refuses it
with `check`'s findings before any leg exists. So no committed object
holds a local path, whatever plan `execute` is given.

The refusal was chosen over having `execute` write `local-clone` for any
`local-clone (...)` value, because a canonical rewrite would still pass
any other printable value into the committed file, a path among them.

Tests. `test_check_spells_a_recorded_source_that_is_not_printable`
becomes `test_check_finds_a_recorded_source_that_names_a_path`: the
no-break-space path is now a finding, spelled. The table of plans
`execute` cannot write gains `source-old-plan`, the value main's `plan`
wrote, which `check` finds and `execute` refuses before any leg. The
pin fixtures that wrote `local-clone (<product>)` in
`tests/test_adopt_pins.py`, `tests/test_adopt_execute_refusals.py` and
`tests/test_adopt_root_symlinks.py` now write `local-clone`, the value
`plan` records since #540.

Main: 7d9bec9.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brett Heap and others added 2 commits October 15, 2026 11:10
…gs alone, so an unread `--pin-source` is refused before a work directory is made, before git runs in the source and before an `org/repo` source is `gh repo clone`d (#451)

rv540 F2. #451 asks for the refusal "before anything exists". The
scaffold asks it before any process starts, but adopt asked it in
`_checked_plan_inputs`, after `cmd_plan` had run `_work_root` and
`Source.open`. So the refusal came after these:
- an `openreposhape-adopt-*` temporary directory, or the `--work-dir`,
  was made and left behind;
- git ran in a local source;
- an `org/repo` source was `gh repo clone`d, and with a `gh` that
  could not clone it, that refusal came instead.

`refuse_an_unread_pin_source` needs only the flags, so `cmd_plan` now
asks it first, in the same words.

Test: `test_an_unread_pin_source_is_refused_before_anything_exists`
(`tests/test_adopt_pins.py`) runs `plan` with no `--pin` in three ways:
- an `org/repo` source;
- a local source;
- a local source with `--work-dir`.
Each run has a fresh `TMPDIR`, a `GIT_TRACE` file and the logging `gh`
stub. Each must refuse `pin-source-unused` with nothing on stdout, an
empty `TMPDIR`, no work directory, no trace file, no plan and no `gh`
call.

Main: 7d9bec9.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ource_no_pin_reads_is_refused` is two asserts, one per character, so a failure names the character and SonarCloud's S9073 has nothing to report (#451)

rv540 N1. `assert "\x1b" not in ... and "<U+200D>" not in ...` was
SonarCloud's only new issue on #570 (python:S9073, a composite
assertion). It is split in two, each with the stderr as its message, and
the zero-width joiner is written as its escape rather than as the
invisible character. The exact-stderr assert above it is unchanged.

Main: 7d9bec9.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brett Heap and others added 2 commits October 15, 2026 11:11
…es a file name that is not UTF-8, so macOS's EILSEQ is a skip and not a failure (#540, #450)

CI on macOS failed at 79dfd33 (run 38652642202, job 116154875090: 2
failed, 8403 passed). Each failure raised `OSError: [Errno 92] Illegal
byte sequence` from `shutil.copytree`, because APFS refuses a file name
that is not UTF-8, so no clone can sit at such a path there:
- `tests/test_adopt_pins.py::test_a_clone_is_read_wherever_it_is_and_its_path_is_not_recorded[not-utf-8]`;
- `tests/test_pin_source_unreadable.py::test_the_pin_file_says_how_the_digest_was_read_and_not_where[not-utf-8]`.

Each of those rows now makes a probe directory with such a name under
`tmp_path` first. On `OSError` EILSEQ the row skips, as the Windows mark
skips a command line that cannot hold the byte. Any other error is
raised. Every other row is unchanged, and on Linux both rows run as
before.

Main: 7d9bec9.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d as `pin-source-no-path` before anything exists, so an empty value is no longer read as the directory the tool runs in (#540)

Lane 5 found this beside #442 (#570 issuecomment-6180853841), and lane 2
took it into this PR (issuecomment-6180905220). Both tools took
`--pin-source` as `type=Path`, and `Path("")` is `Path(".")`. So with
`--pin-source ''`:
- run from inside a clone with a `--pin`, the dry run, the real
  scaffold and `plan` pinned that clone's digest, exit 0;
- run from anywhere else, they read whatever directory they stood in;
- with no `--pin`, `pin-source-unused` named `.`, which nobody typed.

#576 (#442) refuses an empty value in the naming gate
(`link-source-no-path`) and in the root's `validate-pins.py`
(`pin-source-no-path`). The two tools that make a project now refuse it
under the same code and in the same sentence, "`--pin-source ''` names no
path. An empty path would be read as the directory this runs in." Each
tool begins the remedy in its own words, as `pin-source-unused` does:
give the clone's path, or `.` to mean this directory.

`pin_source_path` (`scripts/shape_materialize.py`, beside
`refuse_an_unread_pin_source`) asks this for both tools and hands back
the `Path` they read. Both parsers therefore take the flag as text,
since once parsed as a `Path` an empty value and a typed `.` are one.
It is asked from the flags alone, before `pin-source-unused` and before
any process starts. In the scaffold that is beside `pin-source-unused`,
so `--dry-run` refuses it too. In `plan` it is the first thing
`cmd_plan` asks. A typed `.` is read as it always was.

Both `--pin-source` helps say so, and `docs/cli.md` is regenerated
(`render-cli-reference.py --check` passes).

Tests (`tests/test_pin_source_unreadable.py`):
- `test_a_pin_source_that_names_no_path_is_pin_source_no_path` runs the
  dry run, the real scaffold and `plan`, with and without a `--pin`,
  each from inside a clone that answers: exit 2, the exact refusal,
  nothing on stdout and nothing made;
- `test_a_typed_dot_is_still_the_directory_the_tool_runs_in` is the
  control.

Main: 7d9bec9.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
… edited mount path on a line of its own, so Sonar's S9073 composite-assert rule has nothing to fire on (#522)

The fixture that edits `legs.spec_path` and `legs.code_path` checked both
landed with one `and`. Two asserts say which of them did not, and the
repository's gate has refused a composite assert before (#565, #570).

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-15T14:16Z)

This lane re-verified its own PR at b1db67c, read-only in its own detached worktrees, and authored nothing.

MERGEABLE WITH NITS. It lands only on Brett Heap's word. #576 should land first. This PR then changes "each" to "the first" (N3) at scripts/shape_materialize.py:2714 and :2737-2738, and at tests/test_pin_source_unreadable.py:445 and :456-457.

  • F1 is fixed. An old local-clone (<path>) plan is plan-pin-malformed, and execute refuses it before any leg exists.
  • F2 is fixed. plan refuses an unread --pin-source before any temp dir, git or gh.
  • N1 is fixed. Sonar reports 0 issues.
  • macOS. The rows skip on EILSEQ alone and still run on Linux.
  • --pin-source '' is pin-source-no-path in both tools.
  • Nits (optional). The finding's tail at adopt-project.py:423-424, a one-line remedy, and duplicated skip helpers.

Tests:

The 12 new tests fail on the old code as named. CI is green on all three systems.

Merges: rc 0 with main 2e6c6fa, #576, #572, #566, #565 and #573.

Report: handoffs/xFactory/attachments/lane-openRepoShape-2/review570-79dfd33/reverify-b1db67c.md in opensoft/brett-wip.

Brett Heap and others added 2 commits October 15, 2026 16:53
… fix/540-pin-source-unread

This merges origin/main 2bf8fd4 as the shared store held it at
2026-10-15T16:50Z, thirteen landings past the 7d9bec9 this branch last
merged: #555 (shape-doctor's manifest kinds), #565 (scaffold's
`scaffold-remote-exists`), #581 (adopt's `check` of a kept pin path and
an unwritable work directory), #568, #559, #557, #560, #580, #556, #558,
#567, #579 and #562. It is done so that the PR's CI merge ref and this
round's rewording run on current main.

The merge was clean, with no conflict hunk. `adopt-project.py`,
`scaffold-project.py`, `scripts/shape_materialize.py`, `docs/cli.md` and
`tests/test_adopt_root_symlinks.py` took main's changes beside this PR's
by the textual merge, and `scripts/render-cli-reference.py --check`
passes on the result.

Main: 2bf8fd4.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…-pin-source` is read by each `--pin` and by the FIRST `--referent-chain` link, so the sentence agrees with #576's rule that a bare path is the first link's tree alone (#451)

rv540b N3. The refusal said `--pin-source` names the clone "each
--pin's digest is read from, and each --referent-chain link's
declaration". Lane 5's #576 (#441) makes the scaffold hand a bare
`--pin-source` to a chain's first link and no other, because a later link
read from the same tree answered with the wrong product's declaration. The
wording follows its rule ("the FIRST link alone", docs/cli.md of #576), in
the refusal's `reads` clause and in `refuse_an_unread_pin_source`'s
docstring, where it names #441.

The same claim is reworded in `tests/test_pin_source_unreadable.py`: the
section comment, the `UNUSED` scaffold text the test compares the refusal
with, and the docstring of `test_a_referent_chain_reads_the_pin_source`,
whose chain `openXglass,openGlass` is read through its first link. The
scaffold's own comment at the call (`no --referent-chain link a
declaration`) stays: it says only that a run with no chain has no reader.
The adopt wording is unchanged: `plan` takes no chain.

Main: 2bf8fd4.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-15T17:48:48Z) To lane openRepoShape-5: #570 has made the N3 rewording, as agreed above (#570 lands second, after #576).

Brett Heap and others added 2 commits October 15, 2026 20:08
…gain and `execute` writes `local-clone`, so #449's `check` recomputes each pin in its clone and still no committed pin file holds the path (#540)

#589 (#449, on main since a61cf47) makes `check` recompute each pin's
digest in the clone `plan` read it from, which it finds in the plan's
`digest_source: local-clone (<path>)`. This PR made `plan` record a bare
`local-clone` and `check` refuse any other spelling (F1), so on the merge
with main #449's code never ran: 13 of its tests failed. Both are kept:

- The plan records the path again, `local-clone (<the --pin-source path
  as typed>)`, as main does (`_as_planned`). The plan is a file on the
  machine that planned: `execute` builds the root and both legs from
  fresh clones at the source's commit and writes no plan value but the
  pin file's into any of them.
- Every committed or printed value is bare. `declared_pin_values` keeps
  writing `local-clone` for both tools, so the scaffold's pin file is
  unchanged by this commit; `execute` writes the pin file from
  `_recorded_pins`, which turns both clone spellings into `local-clone`
  (`_digest_source_written`); `check`'s `write` line prints those values
  and `plan`'s `pin` line says the same.
- F1 takes a closed set of three spellings (`_a_digest_source`):
  `gh-api-tree-recursive`, `local-clone`, and `local-clone (<path>)` as
  `LOCAL_CLONE_RE` fullmatches it, on one printable line. Every other
  value is `plan-pin-malformed` with "re-run `plan`", and `execute`
  refuses it as before. The rewrite is safe where F1's first version
  refused instead: the set is closed, so nothing but the two words can
  reach the committed file. The one-printable-line condition is main's
  own rule for the field; without it a hand-edited `local-clone
  (/x\x00y)`, which the plan's reader makes a NUL of, reaches #449's
  `Path(clone).stat()` and raises `ValueError`, a traceback.
- `_refuse_an_unrecordable_pin_source` is back (`adopt-unwritable-value`,
  as on main), for a `--pin-source` `plan` cannot record on one plan line:
  a line break, an ESC, a byte that is not UTF-8, a zero-width joiner.
  Its sentence now says the path goes into the plan for `check`, not into
  the pin file. The scaffold records no path, so it still scaffolds from
  such a clone (#450).
- `--pin-source`'s help says the plan records the path and the pin file
  does not; `docs/cli.md` is regenerated.

#589 is kept whole: its code, findings, NOTEs and tests are unchanged, but
for one test value. `test_a_pin_read_from_no_clone_is_said_not_rechecked
[no-clone-named]` wrote `digest_source: "read by hand"`, which F1's closed
set now refuses as `plan-pin-malformed`; the row now writes bare
`local-clone`, the one accepted spelling that names no clone, and asserts
the same NOTE through the same branch. `read by hand` joins the
malformed table.

Tests: main's `adopt-unwritable-value` tests are restored, with the new
sentence, and the answering-clone one gains a line-break and a
not-UTF-8 row (EILSEQ skip); `tests/test_adopt_execute_refusals.py` and
`tests/test_adopt_root_symlinks.py` are main's again. The plan-records
assertions expect `local-clone (<path>)`; the `pin` and `write` lines
expect `(local-clone)`; a new end-to-end test greps `git log -p --all` of
the root, both legs and the scaffold's three repositories for the path;
the scaffold's test greps its three. F1's table gains a bare path,
`local-clone(<path>)` with no space, `read by hand` and a NUL clone, and
loses `source-old-plan`, which passes now.

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-15T20:58:11Z)

This lane reviewed its own PR at 3c0da25, and at d62a03c (GitHub's merge of it with main 77bb5f0), read-only in its own detached worktree. Nothing was authored.

MERGEABLE WITH NITS. No sequencing constraint.

Lane openRepoShape-1, on #589 (#449): its intent survives.

Findings at 3c0da25:

  1. P3, follow-up. adopt-unwritable-value for --pin-source (adopt-project.py:3301) fires after _work_root and Source.open, as on main. A temporary directory stays behind, git runs in the source, and an org/repo source is cloned first. adopt-project.py: remove the temp work roots it makes without --work-dir, keep a recorded --source clone, and point the tests' TMPDIR at their basetemp #526 (PR adopt-project.py and tests/conftest.py: a run without --work-dir removes the empty work directory it made, and a recorded clone that is gone is cloned again, so no empty openreposhape-adopt-* is left and no source-unclonable follows (#526) #594) covers the directory.
  2. Nit. tests/test_adopt_pins.py:805 wrongly says "nothing is made".
  3. Nit. The PR body gives head 3c0da25. It says one plan line "cannot hold" such a path, and it calls pin-source-unused "the first thing" plan asks.
  4. Nit. Adopt's --help (:10230) does not name adopt-unwritable-value.

Tests at d62a03c: 1704 passed across 11 files.

Merges: 3c0da25 with main 77bb5f0 is clean, tree 930ccc8, d62a03c's. d62a03c merges cleanly with every open PR touching these files.

The report is handoffs/xFactory/attachments/lane-openRepoShape-2/review570-3c0da25/final-report.md in opensoft/brett-wip.

Brett Heap and others added 3 commits October 15, 2026 20:59
…his PR's head carries the newest main before rv540c's fix round (#540, #451, #450)

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

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… `adopt-unwritable-value` straight after the two flag-only refusals, so a path holding a line break, an ESC, a zero-width joiner or a byte that is not UTF-8 no longer leaves an `openreposhape-adopt-*` directory behind, runs git in the source or `gh repo clone`s an `org/repo` one first, and `--help` names the refusal (#450)

rv540c's fix list on PR #570, items 1, 2 and 4. The restored refusal
(`_refuse_an_unrecordable_pin_source`) is asked from `cmd_plan` right after
`refuse_an_unread_pin_source`, guarded by `args.pin and args.pin_source is
not None`, and no longer inside `_checked_plan_inputs`: it needs the flags
alone, and rv540's F2 moved `pin-source-unused` ahead of `_work_root` and
`Source.open` for exactly that reason. By the time `_checked_plan_inputs`
asked it, the work directory had been made, git had run in a local source
(4 `GIT_TRACE` lines), and an `org/repo` source had been `gh repo clone`d, so
a `gh` that fails answered `source-unclonable` in its place.

The order changes on one point: a malformed `--pin` (or any refusal
`_checked_plan_inputs` asks before the old call site: `--reference` or
`--elected-by` unwritable, `adopt-pin-collides`, a source that cannot be
opened) given with such a path now gets `adopt-unwritable-value` first. No
test held the old order; the new rows hold the new one.

`test_a_pin_source_refused_by_its_flags_is_refused_before_anything_exists`
(was `test_an_unread_pin_source_is_refused_before_anything_exists`) gains the
rows `lf`, `not-utf-8`, `zwj` and `lf-and-a-malformed-pin` beside `unused`,
over the `org/repo`, `local` and `local-work-dir` sources: a fresh `TMPDIR`
that stays empty, a `GIT_TRACE` file that is never written, no work
directory, no plan and no `gh` call. The comment above the `--pin-source`
tests ("nothing is made and nothing is read") is true as it stands.

adopt's `--pin-source` help says a path that is not one printable line is
refused as `adopt-unwritable-value`; docs/cli.md is regenerated
(`scripts/render-cli-reference.py --check` ok).

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

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-15T21:39:40Z)

This lane re-verified its own PR's fix round at 8af10e3 (the merge 715771e of main 55faad2, then the fix), read-only in its own detached worktree. Nothing was authored.

MERGEABLE. No sequencing constraint.

rv540c's four items at 3c0da25 are fixed:

  1. adopt-unwritable-value for --pin-source is now asked in cmd_plan (adopt-project.py:3799-3800), before _work_root and Source.open. Eleven unrecordable paths, over local, --work-dir and org/repo sources, leave no directory, no git trace and no gh call.
  2. The test comment at tests/test_adopt_pins.py:805 is now true.
  3. The PR body and the squash message are true of 8af10e3.
  4. --help and docs/cli.md name the refusal, and render-cli-reference.py --check passes.

The order change is right. Such a path now comes before every refusal in Source.open and _checked_plan_inputs, among them pin-malformed, adopt-pin-collides, adopt-no-org and adopt-bad-id. It is rv540 F2's rule for flag-only refusals, and each control without the bad path still gives its own refusal.

M6, dropping the guard, is equivalent. --pin-source with no --pin is already pin-source-unused, and None passes as 'None'. The guard should stay.

#591 changes no pin line, so it does not meet this PR's change. 715771e's tree is git's own merge, e4b08cd. #589's scenario still holds end to end.

Tests at 8af10e3: 1766 passed across 13 files, 0 failed. Under M6, 227 of 227 pass and every order-probe row is unchanged.

The report is handoffs/xFactory/attachments/lane-openRepoShape-2/review570-8af10e3/final-report.md in opensoft/brett-wip.

@brettheap
brettheap merged commit 396c0ff into main Oct 15, 2026
3 checks passed
@brettheap
brettheap deleted the fix/540-pin-source-unread branch October 15, 2026 22:11
brettheap pushed a commit that referenced this pull request Oct 15, 2026
…ll.sh sentences and #606's three sentences both stay in README and the handbook, so the page's seven § Starting markers read the merged section's hash (#606)

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

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

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

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

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
…ix/585-git-bash-windows: README's step 3 keeps #598's pipx-or-uv paragraph and this branch's "in Git Bash it can take none" inside its 1449-line cap, the handbook's consent rule keeps both, and the seven markers move to the merged README (#585)

Main moved from 77bb5f0 to 396c0ff: 55faad2 (#591), fbbbc8d (#572),
7fdec0a (#624), 3fb21d9 (#598, lane 3's #595) and 396c0ff (#570). Only
#598 touches this branch's files: README step 3's install.sh paragraph, the
handbook's masthead and `start`, install.sh and tests/test_install_sh.py, and
it adds tests/test_install_sh_filter_repo.py. Two files conflicted.

README.md, one region: step 3's install.sh paragraph. #598 rewrote it for
uv and the closing readinesses; this branch had added "in Git Bash it can
take none" after "each behind a typed `yes` at your terminal". Side by side
that is nine lines where both have eight, and README is at its cap (1422 + 5
for 550.txt + 22 for 564.txt = 1449). The cap is not raised. Two rewordings
fit it, and no fact is dropped:
- "`install.sh` is attached to every release and installs the newest, with
  no `gh` and no login." became "`install.sh`, attached to every release,
  installs the newest with no `gh` and no login."
- #598's "`install.sh` never runs `sudo`;" became "It never runs `sudo`
  itself;". "It" is install.sh, the subject of the sentence before it, and
  "itself" is #564's own word for the contrast with the preflight.
#598's "It installs `git-filter-repo` with `pipx`, or `uv`, where it can, or
prints the command, runs `openRepoShape --preflight`, and says last if the
machine is ready to adopt or only to scaffold" is word for word, and so is
this branch's "; in Git Bash it can take none". The eight lines rewrap at
80 characters at most, as 78 README lines already are.

The Git Bash clause is still true after #598. Under `curl ... | bash`,
install.sh's `[ -t 0 ] || exec </dev/null` (install.sh:342, which #598 does
not touch) leaves the preflight no terminal on stdin. Under Git Bash
setup-project.py runs on native Windows Python, where `open_terminal()`
returns None (`os.name == "nt"`), so `can_ask()` is False, and `offer()`
takes no `yes`. #598 changes what install.sh does before and after the
preflight, not how the preflight reads an answer.

docs/handbook.html, eight regions:
- The consent rules' install.sh item keeps #598's text, "installs
  `git-filter-repo` with `pipx` or `uv` where one is there, and itself never
  runs `sudo` or installs either of them", and then this branch's clause, word
  for word: "; in Git Bash it can take no `yes`, so it installs nothing."
- The seven markers for README.md section "Starting a project in a new
  organisation" (this branch's 278a1947 against #598's d1666fa6) each record
  badbb6b273215042ba0e8ae167fd760dc25430ee, the merged section's hash from
  tests/test_repo_hygiene.py's own readme_segments and readme_fingerprint.
  All 23 markers match the merged README.
The masthead merged as text: this branch's "macOS, Linux, WSL2, Git Bash"
heading and PowerShell line, beside #598's "where `pipx` or `uv` can install
it ... and whether the machine is ready to adopt or only to scaffold".
The `start` step-3 paragraph is #598's; it is `data-platform="linux mac
wsl"`, as before.

tests/test_install_sh.py merged as text. #598's hunks (`no_pipx` and
`no_pipx_but_apt`'s printed commands, the docstring, and the line before the
printed command, which now starts "there is neither pipx nor uv") sit beside
this branch's `bash_path` in `present_on_path` and in the preflight line of
the same test. tests/test_install_sh_filter_repo.py comes in unchanged. It
builds on `Net`, whose `fake` this branch writes as LF bytes, and it is not
added to the tests-git-bash job.

Against main this merge differs only in this branch's six files:
.github/workflows/tests.yml, README.md, docs/handbook.html,
tests/conftest.py, tests/test_install_sh.py and
tests/test_openreposhape_command.py (436+, 147-). `git merge-tree
--write-tree HEAD origin/main` gives this commit's own tree.
tests/test_repo_hygiene.py, tests/test_readme_sections.py and
tests/test_readme_windows_quoting_sentence.py: 144 passed on the merged tree.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
…s, so PR #620 merges cleanly (#601)

Main moved through #566, #582, #584, #591, #572 and #570 since this branch's
base, 2989b4c. One hunk in adopt-project.py conflicted, in
`_checked_plan_inputs`: this branch replaced the inline `adopt-bad-id` check
with `_refuse_bad_id`, and #591 (#427, #521) replaced the inline organisation
block just below it with `_planned_org` and `org_given`. The resolution keeps
both: `_refuse_bad_id(args.id, args.project, id_passed)` where the id is
checked, and main's `org_given = bool(args.org)` and `_planned_org(...)`
where the organisation is read. Everything else, the two new test files'
hunks included, merged on its own.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
… run on current main

The branch was cut at 2989b4c (#573). origin/main is now 396c0ff: seventeen
commits later, among them #574 (family.py `add`'s refusals, 841 lines of
scripts/family.py), #570 and #576 (scaffold-project.py's `--pin-source`),
#569 and #538 (scaffold-project.py's `--no-next-steps`), and several that
moved docs/cli.md. `git merge-tree` said clean, and the merge made no
conflict: scaffold-project.py, scripts/family.py,
scripts/shape_materialize.py and docs/cli.md each merged by themselves.
`scripts/render-cli-reference.py --check` passes on the result, so
docs/cli.md was not re-rendered, and pyflakes gives on the three .py files
exactly the warnings main's own copies give. No other change; the next
commit on this branch is the fix round (L2-60, L2-67).

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
… the Free-plan hint now named by the root is tested on main's tools (#602)

This branch was made from 2989b4c. Main has moved through #582, #584,
#591, #569, #572, #570 and the rest since, and changed adopt-project.py and
scaffold-project.py. The merge is clean, with no hunk of main's inside this
PR's: main's adopt hunks sit away from `_create_leg_remotes` and the new
`_free_plan_hint_by_the_root` and `_root_visibility`, and its scaffold hunks
(shell_line, pin_source_path, the `--pin-source` text, the NEXT STEPS block)
sit away from `_private_leg_note`. `import json`, `run`, `CommandFailed` and
`VISIBILITY_CHOICES` are still what adopt-project.py imports, and
scripts/repo_shape.py's `free_plan_secret_hint` and `FREE_PLAN_HINT` are
byte for byte as they were, so nothing needs re-pinning.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #585

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
Takes #570, #598, #624 and #625 (main since fbbbc8d, this PR's base) so the
branch is tested against the main it will land on. The merge is clean: the
branch changes adopt-project.py in the follow-up lines and the contracts rule
and main in the pin, `--org` and readiness code, with no hunk in common (#600).

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
…which refuses it as pin-source-no-path since #570 (#608)

#570 made the scaffold take --pin-source as text and refuse an exactly
empty value as pin-source-no-path, so `Path('')` is no longer what an
empty value is read as. Step (4) passed `--link-source=.` for it, and the
naming check warned about a flag the person never typed and a folder the
plan never reads. An empty last --pin-source now gives the naming check
no --link-source, and step (5)'s plan refuses it by name with exit 2. A
blank value is a path and goes as typed. The docstrings, the unit rows
and the end-to-end helper say so.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 16, 2026
… drive that is not there, step (4) verifies a chain from `--pin-source`, `--pin` no longer takes a flag as its value, and step (5) says a printed plan was refused (#608) (#623)

Four defects in setup-project.py's step checks, from the #569 and #573
reviews. Each reproduces on main bcfa364.

- Step (3) asked only that what stands above the clone is a folder. A
  parent at mode 555 passed, all three repositories were created, and
  step (7) then refused `could not be made a folder (Permission
  denied)`; a path whose drive is not there passed too. Step (3) now
  makes a folder in the nearest existing parent and takes it away again,
  as step (7) does, and refuses by name with the system's reason when the
  folder cannot be made; a missing drive is refused naming it. Both come
  before the plan. Taking the probe away is best-effort, and names the
  folder when it cannot.
- Step (4) was given a chain but not the `--pin-source` the scaffold
  reads its links from, so a chain the plan verified showed
  `[declared-unverified]` and a chain its tree broke showed no break. The
  last `--pin-source` now goes to the naming validator as a bare
  `--link-source`, with a chain of two links at least (a chain of one
  has no holder to read a source for). An exactly empty one goes as no
  source at all: since #570 the scaffold refuses `--pin-source ''` as
  `pin-source-no-path`, so step (4) leaves it to the plan, which refuses
  it by name, and the validator is never asked about a flag the person
  never typed (`--link-source=` would end step (4) as
  `link-source-no-path`, and `.` names a folder the plan never reads). A
  blank value is a path and goes as typed.
- `passed_values` took a following word that starts with `-` as the
  flag's value, so `--pin --referent-chain x` gave step (4)
  `--pins=--referent-chain`. It leaves such a word out.
- Step (5) said "the plan could not be produced" under a plan that had
  printed, which #573's dry run does before it refuses an existing
  remote. After a refusal setup asks the plan once more, captured, and
  says "the plan was refused" when it printed; a plan that never printed
  keeps the old words, and a second run that does not refuse says the
  refusal did not repeat and to run setup again.

Closes #608

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 16, 2026
…ot, the scaffold's chain WARNING offers only its own sources and spells `--pin-source`, the root's not-used note spells it too, and THE LOCKSTEP RULE's `bump` runs (#610) (#634)

Issue #610 gathers the pinned shape copies' text into ONE shape bump (the
checklist triage's D6, approved by Brett Heap at 2026-10-15T16:49:38Z), so
every project re-pins once for v1.1.0. Branch made from origin/main
396c0ff (#570's squash; #575 and #576 are in).

The bump. A pinned copy has no digest to edit in this repository: each
root's contracts/shape-pin.yaml holds the sha256 of each copy as it was
written, and `update-shape.py check` calls every copy whose upstream bytes
moved `upstream-changed`, which `apply` re-copies and re-pins. So every
pinned copy this issue changes moves in this one commit:
templates/assembly-root/AGENTS-shape.md, scripts/repo_shape.py,
templates/assembly-root/scripts/validate-pins.py and
scripts/validate-repository-naming.py (assembly roots), and
scripts/repo_shape.py and templates/family-root/scripts/validate-family.py
(family holders).

L2-33. AGENTS-shape.md said "the root Makefile exports
`$(CURDIR)/spec/contracts` once". A scaffolded root's Makefile exports
nothing (`make contracts-dir`: no rule), and since #572 an adoption exports
the spec leg where its plan mounts it. The sentence is path-free: an
ADOPTED root's shape Makefile exports `CONTRACTS_DIR` as the `contracts/`
of the spec leg at the path `project.yaml` mounts it; where the source kept
a Makefile of its own, which stays at the root, that block waits in
shape/Makefile until the two are merged (`make contracts-dir` has no rule
at that root); and a SCAFFOLDED root's exports none, its legs starting
from the scaffold's seed, with no `contracts/`. The file stays
placeholder-free.

576/2. The scaffold's `declared-unverified` WARNING offered "a sibling
checkout, SHAPE_PIN_SOURCE_<LINK>, or --link-source <link>=<path>", the
naming validator's sources. The scaffold has no `--link-source` and reads
no sibling checkout. `LinkPins` now carries a caller's `remedies`, which
`_chain_link_findings` uses for a tree that is not reachable, and the
scaffold's `_link_remedies` names what it reads: the variable (unless
another link shares it, #275) and, for the first link alone (#441), a
`--pin-source` clone. The naming validator and `validate-manifest.py` pass
none and keep their words.

#444 x #610. #638 keys every reader of a chain by `pin_product_key`
(`lower()`, the pin file's own fold), so `_link_remedies` keys a link's
remedy by it too, and `_chain_link_findings` finds it by it. Keyed by
`casefold()`, the chain `openß, openss, openInk`, two pin files and two
links, wrote ONE entry, and `openß`, the first link, was told the scaffold
reads no source for it, the later link's words, where the scaffold reads
its `--pin-source`. Test:
test_two_pin_files_one_casefold_are_each_offered_their_own. The naming
gate's `LinkSources` docstring cited `validate-pins.py` by line numbers
that no longer hold the lines; it names `PinSources.__init__` and
`resolve_neutral_pin_source` now (R444's N2).

L2-30. `link_declarations` named the manifest it reached raw, and the
scaffold prints that reason in the WARNING, so a `--pin-source` holding an
ESC put a raw `\x1b[31m` on stderr. Every reason now names it with
`repo_shape.shown_path`, the spelling `pin-source-unreadable` gives a
`--pin-source` (`shape_materialize.shown_source` is now that function):
`repr` where it is not printable, the same bytes where it is.

L2-31. The root's `validate-pins.py` named an unused `--pin-source`, the
later value that replaced it, and a key read as a PATH as typed. Each is
spelled the same way, by a local `_shown`: a root whose repo_shape.py is
older has no `shown_path` to import. So are its two other raw prints, the
source a `neutral-pin-source-unreadable` finding names and the value a line
about a keyed value that is also a directory names, in the sentence and in
the `./` command.

The naming gate's `link-source-unreadable` WARNING named the tree it asked
raw, beside a reason that names the manifest with `shown_path`. It spells
the tree with its own local `_shown`, for the same reason.

The `git-failed` detail that `validate-pins.py` prints at the end of a
`neutral-pin-source-unreadable` finding, for a `--pin-source` that exists
and is not a repository or lacks the commit, was built in
`repo_shape.git_out` with the folder git ran in as it was and git's stderr
as it came. A folder named with an ESC, a CR or a BEL reached stderr raw,
and a line break in the name began a line of its own (`WARNING
forged-line: ...`). git echoes the paths it reads (`invalid gitfile
format: <folder>/.git`, `not a git repository: <gitdir>`, a dubious
ownership line) and passes a line break through; 2.43, measured,
replaces only an ESC, a CR and a BEL with `?` and passes a C1 control
(U+0080 to U+009F) raw, and another git may replace nothing.
`git_out` now names the folder with `shown_path` and
prints what git said with `spelled`, as ONE line: a control character, a
line break and a C1 control are the escape Python spells them, a line
break git itself printed between two lines of its message is `\n`, and
printable text, UTF-8 included, is unchanged. That is `git_out`'s detail
for every caller, not `validate-pins.py`'s alone. `git_out`'s arguments,
which a caller writes, are named as they were. `repo_shape.py` was already
in this bump, so no pinned file is added.

L2-39. validate-family.py's THE LOCKSTEP RULE named `scripts/family.py bump
--member <Project> --to <commit>`, with no `--family-root`, which `bump`
requires, and no branch, though a real `bump` refuses the holder's tracking
branch (#487). It now names the holder by its path and the `git -C <holder>
switch -c family/bump-<member>` that makes a branch, as #575's
`_switch_line` does for `add` and `remove`: each finding for its member
(the row's `id:` in the branch), and the closing count with `<Project>`.
It ends with #637's `BRANCH_LEFT` sentence, as every remedy that names
`_switch_line` does (#607).

Measured: an assembly root scaffolded at 396c0ff (and an adopted one)
takes this as one `update-shape.py` re-pin with `upstream-changed` for
scripts/validate-pins.py, AGENTS-shape.md, scripts/repo_shape.py and
scripts/validate-repository-naming.py, and a family holder takes it with
`upstream-changed` for scripts/validate-family.py and scripts/repo_shape.py.
`apply` re-copies them and the roots' own gates stay green. No row is a
conflict.

Tests: tests/test_pinned_copy_text.py (new), and in
tests/test_validate_family_branch_step.py (the rule's branch word is the
row's `id:`, through the gate), tests/test_adopt_contracts_dir.py (an
adoption of a source with its own Makefile) and tests/test_naming_policy.py,
whose
test_scaffold_keeps_its_old_warning_for_a_pin_source_with_no_manifest
pinned the old remedies and is now
test_scaffold_offers_its_own_sources_for_a_pin_source_with_no_manifest.
The `git_out` rows in tests/test_pinned_copy_text.py run `validate-pins.py`
over a `--pin-source` that is no repository and over one whose `.git` is no
gitfile, each named with an ESC, a forged line, a CR and a BEL, and run
`git_out` against a stub `git` that prints those bytes, so the result does
not depend on the git on the machine.

Closes #610

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