Skip to content

adopt-project.py: the Makefile block execute appends and the cross-leg follow-up name each leg where the plan mounts it, so --spec-path legs/spec exports a CONTRACTS_DIR that exists and a nested --code-path gets the right ../ path (#522) - #572

Merged
brettheap merged 15 commits into
mainfrom
fix/522-adopt-nondefault-leg-paths
Oct 15, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

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

What changed

Both lines of #522. Each rendered a leg's mount path as if it were the default.

  • The Makefile block execute appends. ADOPT_MAKEFILE_BLOCK was one constant that exported CONTRACTS_DIR ?= $(CURDIR)/spec/contracts. So an adoption with --spec-path legs/spec pointed the code leg's tooling at a directory that does not exist, while plan's own follow-up said the Makefile wires $(CURDIR)/legs/spec/contracts. The block is now shape_materialize.adopt_makefile_block(spec_path, code_path), rendered from execute's checked mount paths. Those paths are canonical and limited to checked_value's alphabet, so none can carry $, #, a space or a line break into make.
  • auA-13, the cross-leg follow-up. It built ../<spec_path>/<dir> "relative to <code_path>/" with a single .. at any depth, so --spec-path legs/spec --code-path legs/code printed ../legs/spec/contracts/. The new spec_leg_from_code_leg adds one .. for each segment of the code leg's path below the part the two paths share. That case now prints ../spec/contracts/, and spec beside impl/code prints ../../spec/contracts/.
  • The Makefile follow-up names the key, not a path (the review's F1). plan writes its follow-ups once, and execute renders the Makefile block from the plan's CURRENT legs.spec_path. The tool's own remediations ask for a hand edit of that key (MOUNT_REMEDIATION, plan-leg-path-collides). The follow-up named the plan-time path, so after that edit the split commit's message, which is also the pull request's body, said $(CURDIR)/spec/contracts over a Makefile in the same commit that said $(CURDIR)/legs/spec/contracts. It now says the Makefile wires CONTRACTS_DIR to the contracts/ of the spec leg, where the plan's legs.spec_path mounts it, as adopt-project.py: the tracking branch defaults to the source's default branch, topics are set before the pull request, and NEXT names scripts/bootstrap.py when the shape's Makefile went aside (#479, #480, #481) #493-F3 names tracking_branch.

Why this does not hand-edit a pinned copy

The Makefile is a pinned verbatim copy. The block, though, is the adoption's own append: only execute writes it, through materialize_assembly_root(append=...), and _materialize digests the copy after the append. So the pin records the rendered bytes. For update-shape.py, an adopted Makefile is never verbatim at the pin (verbatim_at_pin is false for it), whatever the block says. Its row is unchanged until the upstream's Makefile template changes, and both from then on. A default adoption (spec, code) gets the same bytes as before, and ADOPT_MAKEFILE_BLOCK stays as that default rendering. tests/test_update_shape.py still imports it.

Tests

New file: tests/test_adopt_contracts_dir.py, 32 tests. Each path is checked by where it leads, not only by its spelling. In a recursive clone of a real split with the legs at legs/spec and impl/code:

  • the exported directory holds the source's contract;
  • the follow-up's read-across, followed from the code leg, reaches it;
  • make contracts-dir prints that directory (POSIX, where make is installed);
  • the shape pin's row matches the split's Makefile blob, and the project's own validate-pins.py passes;
  • after the hand edit of legs.spec_path and legs.code_path that the tool asks for, the split commit's message names no $(CURDIR) path and the Makefile names the edited one.

The five end-to-end tests need git-filter-repo, as tests/test_adopt_e2e.py does. The end-to-end clone goes through conftest.clone_just_pushed, as #554 moved every such clone.

No --help text changed, so docs/cli.md is unchanged.

README follow-up (after #519)

README.md lines 765-766 say "the root Makefile it materializes exports CONTRACTS_DIR ?= $(CURDIR)/spec/contracts". That is true of the default mount only. Suggested words: "the root Makefile it materializes exports CONTRACTS_DIR, pointing at the contracts/ of the spec leg where the plan mounts it ($(CURDIR)/spec/contracts by default)".

Closes #522

🤖 Generated with Claude Code

Summary by Sourcery

Fix adopted-project contract paths and cross-leg guidance so configured leg mounts remain valid through planning, execution, and later edits.

Bug Fixes:

  • Ensure adopted projects export CONTRACTS_DIR for the spec leg’s configured mount path, including non-default nested layouts.
  • Generate cross-leg follow-up paths that correctly account for shared and nested mount-path segments.
  • Keep Makefile follow-ups aligned with later plan mount-path edits by naming the configurable plan key instead of a stale path.

Enhancements:

  • Preserve the default Makefile rendering while supporting per-adoption leg paths and pinning the rendered output consistently.

Tests:

  • Add coverage for default, nested, shared-prefix, case-differing, and ambiguous leg paths, plus end-to-end Makefile, pin, follow-up, and hand-edit scenarios.

…leg follow-up name each leg where the plan mounts it, so `--spec-path legs/spec` exports a `CONTRACTS_DIR` that exists and a nested `--code-path` is told the `../` path that reaches the spec leg (#522)

Both rendered a leg's mount path as if it were the default (#522, from
the v1.1.0 triage of #457's known limits):

- `ADOPT_MAKEFILE_BLOCK` was one constant exporting
  `CONTRACTS_DIR ?= $(CURDIR)/spec/contracts`, so an adoption with
  `--spec-path legs/spec` pointed the code leg's tooling at a directory
  that does not exist, while `plan`'s own follow-up said the Makefile
  wires `$(CURDIR)/legs/spec/contracts`. The block is now
  `shape_materialize.adopt_makefile_block(spec_path, code_path)`,
  rendered with `execute`'s checked mount paths. It stays the adoption's
  own append, digested with the copy by `_materialize`, so no pinned
  copy is edited by hand; a default adoption gets byte-identical bytes,
  and `ADOPT_MAKEFILE_BLOCK` remains as that default rendering.
- auA-13: the follow-up for a code file naming a spec directory built
  `../<spec_path>/<dir>` "relative to `<code_path>/`", one `..` at any
  depth; `--spec-path legs/spec --code-path legs/code` printed
  `../legs/spec/contracts/`. `spec_leg_from_code_leg` climbs once per
  segment of the code leg's path below what the two paths share.

tests/test_adopt_contracts_dir.py checks each path by where it leads:
the exported directory and the follow-up's read-across hold the
source's contract in a recursive clone of a real split, and
`make contracts-dir` prints that directory.

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 1 day and 21 hours 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 makes adoption output mount-aware: execute renders the Makefile’s CONTRACTS_DIR from the selected spec leg, while plan computes the correct relative path from nested code legs. It preserves default adoption bytes and pin semantics, and adds broad unit and end-to-end coverage validating that both paths resolve to the actual contracts directory.

Sequence diagram for mount-aware adoption execution

sequenceDiagram
    participant User
    participant Execute as adopt-project.py
    participant Materialize as shape_materialize
    participant Makefile
    User->>Execute: execute --spec-path spec_path --code-path code_path
    Execute->>Execute: checked_value(spec_path)
    Execute->>Execute: checked_value(code_path)
    Execute->>Materialize: adopt_makefile_block(spec_path, code_path)
    Materialize-->>Execute: rendered Makefile block
    Execute->>Makefile: materialize_assembly_root(append=rendered block)
    Makefile-->>User: CONTRACTS_DIR=$(CURDIR)/spec_path/contracts
Loading

Flow diagram for nested cross-leg contract resolution

flowchart LR
    Spec["spec_path/contracts"]
    Code["code_path"]
    Shared["shared path segments"]
    Relative["spec_leg_from_code_leg(spec_path, code_path)"]
    Contracts["code_path-relative contracts path"]
    Spec --> Shared
    Code --> Shared
    Shared --> Relative
    Relative --> Contracts
    Contracts --> Spec
Loading

File-Level Changes

Change Details Files
Render the adopted Makefile’s contracts directory from the plan’s checked spec-leg mount path while preserving the default rendering and pin behavior.
  • Replace the fixed appended block with adopt_makefile_block(spec_path, code_path).
  • Export $(CURDIR)/<spec_path>/contracts and include the rendered mount paths in the explanatory comment.
  • Retain ADOPT_MAKEFILE_BLOCK as the byte-for-byte default spec/code rendering.
adopt-project.py
scripts/shape_materialize.py
tests/test_adopt_contracts_dir.py
Generate cross-leg follow-up paths relative to arbitrarily nested code-leg mounts.
  • Add spec_leg_from_code_leg to compare canonical path segments and emit one .. per non-shared code segment.
  • Use the helper when rendering cross-leg follow-up guidance.
  • Cover sibling, nested, disjoint, shared-prefix, and case-different mount paths.
adopt-project.py
tests/test_adopt_contracts_dir.py
Add end-to-end and regression coverage proving rendered paths lead to the real contracts directory and remain consistent with shape pins.
  • Verify Makefile export, make contracts-dir, follow-up paths, default-byte compatibility, and pin digest consistency.
  • Exercise non-default mounts through plan and execute flows, with optional skips for unavailable external tools.
tests/test_adopt_contracts_dir.py

Assessment against linked issues

Issue Objective Addressed Explanation
#522 Render the adopted root Makefile’s CONTRACTS_DIR using the spec leg’s configured mount path, rather than always pointing to spec/contracts. ✅
#522 Generate the cross-leg follow-up path from the configured code-leg mount to the configured spec-leg mount, accounting for nested paths and shared path segments. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-15T10:56Z)

Lane openRepoShape-2 reviewed its own PR #572 at 9ddb7ef, read-only in its own detached worktrees, and authored nothing.

CHANGES NEEDED, for one small P3 the PR adds. Nothing lands before Brett's word.

Both lines of #522 are fixed:

  • An adoption at legs/spec and impl/code exports a CONTRACTS_DIR that exists.
  • The follow-up path reaches the spec leg for all 694 admitted mount pairs.
  • A default adoption keeps main's bytes.
  • The validators, bootstrap, the doctor, update-shape.py (check and apply) and bump-leg.py report nothing that main does not report for a default adoption.

Findings:

Tests:

  • At 9ddb7ef: 4332 passed, 7 skipped.
  • On bfed143, the new file gives 22 failed and 3 passed, as expected.
  • On 3102027 merged with this PR: 778 passed.

Merges: clean with main 2247dbf, and with #570, #565, #566, #567, #556, #573, #569 and #576.

Report: handoffs/xFactory/attachments/lane-openRepoShape-2/review572-9ddb7ef/final-report.md in opensoft/brett-wip.

Brett Heap and others added 7 commits October 15, 2026 10:59
…e-doctor adoption line and `clone_just_pushed`) so the PR's CI merge ref and this fix round run on current main

The brief named main 2247dbf; the shared store's origin/main now reads b4c1f50, which is 2247dbf plus #553 (shape-doctor.py and its tests only). The merge is clean: no file of this PR is touched by either side.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ey and no path, so a hand edit of the spec leg's mount no longer leaves the split commit's message saying `$(CURDIR)/spec/contracts` over a Makefile that says `$(CURDIR)/legs/spec/contracts` (#522)

`plan` writes the follow-ups once. `execute` renders the Makefile's
`CONTRACTS_DIR` block from the plan's CURRENT `legs.spec_path`, and the
tool's own remediations ask for a hand edit of it (`MOUNT_REMEDIATION`,
`plan-leg-path-collides`). The follow-up named the plan-time path, so after
that edit the split commit's message, which is the pull request's body, and
the Makefile in the same commit disagreed. It now says the Makefile wires
`CONTRACTS_DIR` to the `contracts/` of the spec leg, where the plan's
`legs.spec_path` mounts it, the way #493-F3 names `tracking_branch`.

tests/test_adopt_contracts_dir.py holds the plan test to that one line for
every mount and adds an end-to-end test: a default plan, edited to
`legs/spec` and `impl/code`, then `check` and `execute`; the commit message
carries no `$(CURDIR)` path and the Makefile names the edited one. The
end-to-end fixture is now one helper for the flag and the hand-edit
adoption. Review of PR #572 at 9ddb7ef, F1.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…levels a case-only difference climbs, so it no longer says one more where `A/B/spec` beside `a/b/code` climbs two (#522)

The docstring said two segments that differ only in case climb "one level
more". The comparison stops at the first such segment, so the path climbs one
more level than a disk that folds case needs for every segment from there to
the end of what that disk would call shared: `A/B/spec` beside `a/b/code`
climbs two more, and so does `A/b/spec`. The printed path still leads to the
spec leg. Review of PR #572 at 9ddb7ef, N4. Wording only.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…se names of the `shape_materialize` import, so the list reads in the order it did before #522 (#522)

The import kept `ADOPT_MAKEFILE_BLOCK` first among the upper-case constants;
the new function took its place there, ahead of `TEMPLATED`. It belongs with
the lower-case names, in order. Review of PR #572 at 9ddb7ef, N5. No
behaviour change.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d prefix and a prefix of the text that is not a prefix of a segment, so the follow-up's `..` count is checked where `legs` sits beside `legsX` too (#522)

`a/b/spec` beside `a/b/code` shares two segments and climbs once;
`legs/spec` beside `legsX/code` shares none, though one path's text begins
with the other's first segment, and climbs twice. Each runs the spec-leg
helper test and the `plan` test, which checks that the printed path leads to
the spec leg. Both pass on the head as it was. Review of PR #572 at
9ddb7ef, N6.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…split branch with `clone_just_pushed`, so the macOS `failed to copy file` race of #440 cannot reach it (#522)

`execute` pushes the split branch into the source, and the fixture then
cloned the source by its plain path: git's local clone walks `objects/`
while the maintenance the push left running can repack it. #554 moved every
such clone onto `conftest.clone_just_pushed` and no guard enforces it for a
test added after, so this PR's own fixture is moved too. `FILE_PROTOCOL`,
which only that clone used, leaves the import. Review of PR #572, N7.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… 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>
… that extends the code leg's (`legsX/spec` beside `legs/code`), so the follow-up's segment compare is held from both sides (#522)

A mutant that compared segments by `code[i].startswith(spec[i])` was killed by
`legs/spec` beside `legsX/code`; the same compare the other way round,
`spec[i].startswith(code[i])`, survived, because no row had the spec leg's
segment extend the code leg's. `legsX/spec` beside `legs/code` shares no
segment and climbs twice: `../../legsX/spec`. Review of PR #572 at 9ddb7ef,
N6 (beyond the two rows it named).

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-15T13:36Z)

Lane openRepoShape-2 re-verified its own PR #572 at 22ce69b, read-only in its own detached worktrees, and authored nothing.

MERGEABLE WITH NITS, with no order needed.

  • F1 is fixed. After the hand edit of legs.spec_path, the Makefile follow-up names the key (adopt-project.py:1766-1770). The split commit, which is also the PR body, now agrees with its Makefile.
  • N1-N7 and the S9073 split are done. The merge ca3c611 has an empty remerge-diff.

Nits:

  • Put pr-body.md on the PR. The live body is still the first round's. Also fix the doubled "On the head:".
  • The 11 plan tests that are red on 9ddb7ef fail only at tests/test_adopt_contracts_dir.py:209, the Makefile line, and their name does not say so.

Tests at 22ce69b: 4351 passed, 7 skipped. On main dd3f23a merged with this PR: 912 passed. 5 of 5 mutants killed.

Merges: clean with main f5fc705, #566 and #570, in any order.

Report: handoffs/xFactory/attachments/lane-openRepoShape-2/review572-9ddb7ef/reverify-22ce69b.md in opensoft/brett-wip.

brettheap and others added 5 commits October 15, 2026 14:04
#582 rewrote what `adopt-project.py`'s `plan` and `execute` say around a
run, so this branch conflicted with main in `adopt-project.py` and
`gh pr update-branch` refused. The merge also takes main's bcfa364 (#538),
709eec1 (#532) and 40b476d (#407), which touch no file this branch
changes.

One conflicted hunk, in the docstring of `follow_ups_for`. Both sides
appended a paragraph after the #493 F3 paragraph that ends "(#493's review,
F3)": #582's about the last follow-up being a review, and this branch's
about the Makefile follow-up naming `legs.spec_path` and no path (#522, F1).
Both are kept. #582's comes first because "It is a review" continues the
paragraph above it, and this branch's follows, reworded from "does the
same" to "names the key too", which would otherwise point at #582's
paragraph. No code was in conflict.

The code from the two sides merged by itself and does not overlap: #582
changed the last follow-up's text, `_finish_by_hand`, `_next_steps`,
`cmd_execute` and the ruleset hint; this branch changed
`cross_leg_references` (via `spec_leg_from_code_leg`), the Makefile
follow-up and `_commit_the_split`'s Makefile block. pyflakes on the merged
`adopt-project.py` prints nothing, as on both parents.

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

Copy link
Copy Markdown

@brettheap
brettheap merged commit fbbbc8d into main Oct 15, 2026
3 checks passed
@brettheap
brettheap deleted the fix/522-adopt-nondefault-leg-paths branch October 15, 2026 21:30
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
… 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 added a commit that referenced this pull request Oct 16, 2026
…y.yaml` record arrives as `leg: drop`, that the adopted Makefile's `CONTRACTS_DIR` follows the spec leg's mount, and that a second setup run is refused at the plan, so the three sentences #566, #572 and #573 left false or incomplete are true of main (#606) (#627)

One README and handbook change for three lines of the v1.1.0 checklist
triage (D7), with the words #566, #572 and #573's bodies held, checked
against the code on main first, and for one sentence #621 (#602) leaves
false.

- L2-50, "Ambiguous is an answer": a source's own `single-repository.yaml`
  record is written `leg: drop`, its rule's default, still
  `review_required: true` with its question beside it, and `execute` takes
  it as written. It drops the record unless a person changes `leg:`; the
  question offers `root`, and `check` passes `spec` too, which the #566
  body's "unless a person writes `root`" left out.
- L2-51, the worked example: the root Makefile exports `CONTRACTS_DIR` at
  the `contracts/` of the spec leg where the plan mounts it,
  `$(CURDIR)/spec/contracts` by default (#572).
- L2-41, the "If it fails part-way" box: a second run is refused at the
  plan, before the `yes`, because the plan is the scaffold's `--dry-run`
  and asks whether each of the three already exists (#573). The #573 body
  said step (5), which is the number `setup-project.py` prints; the README
  numbers the plan (3), so it says (3).
- The Free-plan secrets (#621's rule): the README said a private leg's CI
  credential is kept "as REPOSITORY secrets on the GitHub Free plan",
  which is false of a public root, because the assembly root's `validate`
  reads the secrets and on Free the organisation's secrets reach public
  repositories. README § Starting now says "on the assembly root when it
  is private or internal and the organisation is on the GitHub Free plan",
  in the six lines it had; "Reading private legs in CI" says the root
  decides and that a public root takes org secrets registered with
  `--visibility selected` and the root in `--repos`, or `all`, which
  shows them to every repository, since the commands above it default
  to `private`, which on Free reaches no repository (+3 lines); the
  handbook's `ci` part says the same.

`tests/readme-cap.d/606.txt` raises the cap by the ten lines (1449 to
1459). The handbook's list item and adopt paragraph carry the same
sentences, and its markers move to the new hashes of README § Starting a
project in a new organisation (seven page parts), § The three legs (three)
and § Adopting an existing repository (one). No footer clause: ruling 29
retired them.

Lands after #621: the Free-plan sentences describe the root rule #621 puts
in the tools.

Closes #606

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

adopt-project.py: a non-default --spec-path or --code-path is ignored by the adopted Makefile's CONTRACTS_DIR and by the cross-leg follow-up path

1 participant