Skip to content

adopt-project.py: the assembly root is cloned with no git template and the verification refuses a mount whose gitlink is not the commit pushed to its leg (adopt-mount-commit), so an operator's init.templateDir holding modules/spec and modules/code no longer ends adoption verified over a split nobody can clone (#243) - #268

Merged
brettheap merged 5 commits into
mainfrom
fix/adopt-clone-without-template
Oct 12, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

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

What

Two changes to adopt-project.py, which are not alternatives:

  1. _commit_the_split clones the source with git clone --template=, as the scaffold's git init has taken --template= since scaffold-project.py _build_assembly_root runs git submodule add without --force, so a leg path an ignore rule matches (--code-path __pycache__ or .DS_Store under the shape's own .gitignore, or the default spec under an operator's global excludes) passes --dry-run, then dies at the mount AFTER all three repositories exist and both legs are pushed #229 (39ac59c). The assembly's .git/modules is therefore empty when _mount_the_legs runs, and git submodule add --force has nothing to reactivate. The comment there says why, and that the assembly's commit and push therefore no longer run hooks a template installs (see Left out). The comment in _mount_the_legs that argued "no .git/modules/<name> is in this fresh clone" now says what makes it true.
  2. _verify compares each mount's gitlink with the commit pushed to its leg and refuses adopt-mount-commit when they differ, whatever put the other commit there. _mount_commit_findings(tree, mounts, pushed) reads the split tree _verify already reads and the mounts mapping _land_the_root_tree already takes; _pushed_commits is the HEAD of each leg's work directory, which _extract_leg and _seed_leg push and whose tree _verify already accounts for. A mount path that holds no gitlink stays adopt-mount-missing (adopt-project.py: verification passes over the two leg mounts and no other gitlink, so the source's own submodule kept in the root is adopt-root-submodule, any other root gitlink adopt-unexpected-gitlink, one in a leg AND the root adopt-duplicated, and a missing mount adopt-mount-missing (#165) #173), said once. The check does not undo the push, as no finding does; only change 1 prevents the harm.

The other repositories execute creates, and why none needs --template=:

call needs it? why
git clone of the source for the assembly (_commit_the_split) YES, now has it it is the repository git submodule add --force mounts into
git init --bare of a leg remote (_create_leg_remotes, local mode only) no only pushed to and cloned from, never mounted into; with gh the remote is made by GitHub
git clone of the source for a leg (_extract_leg) no git filter-repo then pushes from it; the mount's git submodule add clones it by path and a clone copies objects and refs, never a .git/modules
git init for a seeded leg (shape_materialize.git_init_commit, also the scaffold's two legs) no same: read by the mount's clone only
gh repo clone of an org/repo source (Source.open) no read-only source; the assembly clone made from it does not inherit its .git/modules

Leaving the legs' repositories on the operator's template keeps their hooks and excludes as they run today. The assembly's own template hooks do not run any more (see Left out).

Why

#243 (lane 4, found while verifying #229's review): with init.templateDir pointing at a template that holds modules/spec and modules/code, each a copy of a stranger's non-bare .git, execute exited 0 and printed adoption verified over a split whose two gitlinks were the stranger's commit, and git clone --recurse-submodules of the pushed branch exited 128. Reproduced on 39ac59c: gitlinks c4a30be1705b for both mounts, the legs pushed at 130ce8cf62dd and 2564b5900076, fatal: ... did not contain c4a30be1705b0fa270b35d5de5be8a681d436b92.

Tests

tests/test_adopt_clone_template.py is new and has 31 tests. At 06cb68e on Linux with git filter-repo, all 31 pass. The 17 that run execute skip without git filter-repo, and the Windows job installs none. The other 14 build their legs by hand and run on all three OSes. The operator's configuration reaches git through GIT_CONFIG_GLOBAL, a file each test writes, and the stranger's .git is copied with shutil.copytree; nothing reads or writes a real configuration.

  • 2: the premise, asked of git: a plain clone under the template gets the stranger's store and --template= gets none, for init.templateDir and for GIT_TEMPLATE_DIR.
  • 16: execute end to end in four cases (template by configuration, by environment, stores with no origin, and the no-template control), four tests each: check ok and exit 0 with adoption verified; each gitlink on the PUSHED split branch equals its leg's pushed tip and is never the planted commit; the work directory's .git/modules holds only the two stores the mounts made, each at its leg's tip; a recursive clone under a clean configuration succeeds and validate-manifest.py, validate-pins.py and bootstrap.py pass in it.
  • 10: the verification on its own, no template, on hand-made splits: the control (default and the plan's own mount paths), a swapped spec, code or both mount, at both path spellings, with the exact finding text; a mount at an OLDER commit of its own leg (the comparison is exact); and a missing mount, which stays one adopt-mount-missing.
  • 2: the real _mount_the_legs over a clone made under the template, with no git filter-repo: a plain clone mounts the planted commit and the verification refuses both mounts; cloned with --template= the same mounts are the legs' tips and verify.
  • 1: cmd_execute with the spec mount's checkout moved after the mount: exit 1, the finding, no adoption verified, the split pushed (git add -A restages gitlinks from the work tree, so the seam moves the checkout, not the index).

Windows: at 65073c1 tests-windows failed 12 of the 14 tests that run there (run 38182070760: 12 failed, 2062 passed, 264 skipped). Each failure had one extra adopt-lost finding for the source's README.md. The cause was in the test, not in adopt-project.py. the_source() built that file with write_text, which writes CRLF on Windows, while split_of() writes the split's copy as LF with write_bytes, so they were two blobs. Fix round 1 writes the source's README.md byte-exact too (see Fix round 1). The fix was checked on Linux under an emulation of Windows' text-mode write_text, with no git filter-repo on PATH, as the Windows job runs. At 65073c1 the emulation gives CI's result exactly: 12 failed, 2 passed, 17 skipped, the same node ids and the same blob 9495404d21b8. At 06cb68e it gives 14 passed and 17 skipped. CI on 06cb68e (run 38186199801): tests, tests-macos and tests-windows all passed, tests-windows with 2474 passed, 265 skipped and no failure.

Against main's code (06cb68e's test file in a tree at main d94d155): 19 failed, 12 passed, as 65073c1's gave on 39ac59c and on 23e16ae. The failures are the issue's: the gitlinks are the planted commit (AssertionError: the template's planted commit was ...); the clean recursive clone exits 128 (Fetched in submodule path 'code', but it did not contain ...); the no-origin variant exits 2 (error: No such remote 'origin'); and the swapped or planted mounts still end adoption verified (assert 0 == 1). The 12 that pass there are the premise, the control, and the cases that hold on main.

Mutations of 06cb68e (scratch trees, this test file run in each). Without --template= on the clone, everything else kept: 12 fail and 19 pass. The 12 are the four end-to-end tests of each of the three template cases: the verification now refuses what the clone let in, so execute exits 1 where it exits 0 with the cure, and exits 2 for the stores with no origin. All 12 need git filter-repo, so Linux and macOS guard the cure and Windows does not. Without the _mount_commit_findings call, everything else kept: 9 fail and 22 pass. The 9 are the verification tests (six swapped-mount cases, the older commit, the mount over a planted store and the cmd_execute swap), each assert 0 == 1 over adoption verified.

The control is unchanged: one source repository copied twice, the same no-template adoption by main's adopt-project.py (39ac59c) and by 65073c1's, at one work path. Result: the split branch's tree is 0025586ab8d6 on both, the legs' tips, stdout (but for the split commit's own line), stderr, the assembly's .git/modules names and .git/config are identical. Fix round 1 changes no adopt-project.py code, only a comment, so this holds at 06cb68e.

Targeted at 06cb68e, with git filter-repo 2.47.0: tests/test_adopt_clone_template.py 31, test_adopt_verify.py 16 and test_shape_doctor.py 181 passed, none failed. Merged with main d94d155 (a scratch merge, tree 662b547; this branch is not merged): test_adopt_clone_template.py 31, test_adopt_verify.py 16, test_adopt_finding_spelling.py 128 and test_shape_doctor.py 208 passed, and under the Windows emulation the new file gives 14 passed and 17 skipped. At 65073c1 the implementer's run passed 413 tests over ten adopt and CLI files, and the review's run on the merge with 23e16ae passed 732 over twelve. The full suite was not run here (CI runs it on three OSes). scripts/render-cli-reference.py --check passes at 06cb68e and on the merge, because no --help text changed.

Composes with

git merge-tree --write-tree 06cb68e <head>, for every head on origin (git ls-remote at 00:00Z): 22 heads besides main and this one, and 19 of them are clean (rc 0). main is now d94d155 and merges clean (tree 662b547; 67a398a and 23e16ae did too). fix/adopt-spell-finding-paths has landed as #223 (5751460) and is no longer a head. #270's fix/adopt-template-values-utf8 (aed9190), this lane's other adopt branch, is clean.

A design note for whoever merges next: _land_the_root_tree keeps its four-argument signature on purpose. tests/test_adopt_finding_spelling.py, on main since #223, calls it with four arguments in its helper (:349), and so does every tests/test_adopt_submodules.py call. A fifth argument would merge clean and then fail at runtime, so the comparison is its own function beside it.

Left out

  • The check does not undo the push (no finding does). When it fires, the finding says the split is wrong and not to merge it, and the usual closing paragraph says the legs and the branch exist.
  • The leg remotes, leg work trees and seeded legs keep the operator's template (table above).
  • The assembly's split commit and push no longer run hooks a git template installs, because the assembly now has no .git/hooks (its pre-commit, commit-msg and pre-push ran there before this change), while hooks set with core.hooksPath still run there and the legs keep their template's hooks. The comment at the clone says so.
  • Nothing else in adopt-project.py changes: no --help text, no plan field, no other finding.

Review

Lane openRepoShape-2's own adversarial review of 65073c1 is this comment. It found the production change sound and asked for four changes, which Fix round 1 answers. A review by another lane is welcome. Lane openRepoShape-1's #188 and lane openRepoShape-3's #231 each need the resolution given under Composes with.

Fix round 1

This answers the review of 65073c1 with two commits; the head is 06cb68e.

  1. P2, cdbfb7f: tests-windows failed 12 of this PR's tests. the_source() now writes the source's README.md with write_bytes, as split_of() writes the split's, so the two are one blob on every OS. Its docstring says why. split_of() is unchanged, and write_text(newline=...) is not used because it needs Python 3.10 and the baseline is 3.9. The details are under Tests.
  2. P3, this body only: step (4) of the adopt-project.py: a failure AFTER both legs are pushed (the split-branch push, the mount, the split commit) prints only REFUSED <step> with no notice of what already exists and no stdout flush, so the combined log shows REFUSED before the bare … lines and the re-run meets leg-remote-exists unexplained #188 resolution under Composes with. _verify's signature does not change in this PR.
  3. P3, 06cb68e: the NO TEMPLATE (#243) comment in _commit_the_split gains one sentence on hooks, and Left out gains one bullet. No behaviour changes.
  4. Nit, this body only: the Windows claim, the main sha, the landed adopt-project.py: check and execute spell every path a FINDING or a refusal names as plan's questions spell it, so a source name holding a carriage return, a line feed, U+0085, U+2028 or an ESC no longer overwrites, splits or escapes the line it is printed on (#212) #223 and resolution (1)'s warning are restated at 06cb68e.

Closes #243

🤖 Generated with Claude Code

Summary by Sourcery

Ensure adopted split roots use clean submodule stores and verify that every mount points to the commit pushed to its leg.

Bug Fixes:

  • Prevent adoption from accepting mounts whose gitlinks do not match the commits pushed to their corresponding legs.
  • Prevent operator Git templates from planting stale submodule repositories in the assembly root during adoption.

Enhancements:

  • Add explicit verification findings for mount commits and retain missing-mount reporting without duplication.

Tests:

  • Add comprehensive coverage for template contamination, mount commit mismatches, older leg commits, missing mounts, recursive clones, and end-to-end adoption behavior across supported operating systems.

…d the verification refuses a mount whose gitlink is not the commit pushed to its leg (`adopt-mount-commit`), so an operator's `init.templateDir` holding `modules/spec` and `modules/code` no longer ends `adoption verified` over a split nobody can clone (#243)

`_commit_the_split` cloned the source with a plain `git clone`, which copies the operator's template (`init.templateDir`, or `GIT_TEMPLATE_DIR`) into the assembly's `.git`. A template holding non-bare stores at `modules/spec` and `modules/code`, as a copy of a working Triad root's `.git` does, left `.git/modules/spec` and `.git/modules/code` there, and the mounts' `git submodule add --force` (#174, #200) reactivated them. Both gitlinks pushed on the split branch were a stranger's commit, `adoption verified` was printed over them (the verification checked that a mount holds a gitlink, #173, and never which commit), and `git clone --recurse-submodules` of the branch exited 128.

Two changes, which are not alternatives:
- The clone takes `--template=`, git's documented way to use none, as the scaffold's `git init` has since #229. That is the cure: the mounts meet an empty `.git/modules`. The other repositories `execute` makes (the legs' bare remotes and work trees) are never mounted into, so they keep the operator's template.
- `_verify` compares each mount's gitlink with the commit pushed to its leg, the `HEAD` of its work directory, and refuses `adopt-mount-commit` when they differ, whatever put the other commit there. It does not undo the push; only the first change prevents the harm.

tests/test_adopt_clone_template.py is new. Under a template named by `init.templateDir`, by `GIT_TEMPLATE_DIR`, and with stores that have no `origin` (exit 2 at `git remote set-url origin` before), `execute` exits 0 and each pushed gitlink is its leg's tip, the work directory's `.git/modules` holds only the two stores the mounts made, and a recursive clone under a clean configuration validates and bootstraps; with no template the run is the same, and its split tree, leg tips and output are identical to main's. The verification is also tested alone, on hand-made splits and on the root a real mount leaves over a planted store, and through `cmd_execute` with the mount's checkout moved after the mount.

Closes #243

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

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

@sourcery-ai

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens adoption by cloning the assembly with no Git template so planted .git/modules stores cannot be reactivated, and independently rejects any split whose mount gitlinks do not exactly match the commits pushed to the legs. A comprehensive regression suite covers both the preventive clone behavior and verification defense, including template configuration variants, malformed mounts, and downstream recursive-clone validation.

Sequence diagram for template-safe project adoption

sequenceDiagram
    participant Execute
    participant Source
    participant Assembly
    participant Legs
    participant Git

    Execute->>Source: Source.open()
    Execute->>Git: git clone --template= source assembly
    Git-->>Assembly: Empty .git/modules
    Execute->>Legs: _extract_leg() / _seed_leg()
    Execute->>Assembly: _mount_the_legs()
    Assembly->>Git: git submodule add --force leg-worktree mount
    Git-->>Assembly: Mount leg commit and create module store
    Execute->>Assembly: git add -A and push split
Loading

Sequence diagram for rejecting an incorrect mount commit

sequenceDiagram
    participant Verify
    participant Assembly
    participant LegWorktree
    participant Result

    Verify->>Assembly: _tree_of(assembly, split_commit)
    Verify->>LegWorktree: _pushed_commits(work_root, names)
    Verify->>Verify: _mount_commit_findings(split_tree, mounts, pushed)
    Verify->>Result: Report adopt-mount-commit
    Result-->>Verify: No adoption verified
Loading

Flow diagram for mount commit verification

flowchart TD
    A[Read split tree] --> B[Build mounts mapping]
    B --> C[Read each leg HEAD with _pushed_commits]
    C --> D[_mount_commit_findings]
    D --> E{Mount has gitlink?}
    E -- No --> F[adopt-mount-missing]
    E -- Yes --> G{Gitlink equals pushed leg commit?}
    G -- Yes --> H[Continue verification]
    G -- No --> I[adopt-mount-commit]
    I --> J[Reject adoption verification]
Loading

File-Level Changes

Change Details Files
Prevent operator Git templates from contaminating the assembly repository used for submodule mounting.
  • Clone the assembly with an explicit empty Git template.
  • Document why other repositories retain the operator template and why the assembly does not.
  • Clarify that mounts start without pre-existing module stores.
adopt-project.py
Verify that every root mount points exactly to the commit pushed to its corresponding leg.
  • Build the mount mapping and split tree once during verification.
  • Compare gitlink OIDs against each leg worktree's pushed HEAD.
  • Emit adopt-mount-commit findings while preserving existing missing-mount behavior and push semantics.
adopt-project.py
Add regression coverage for template contamination and incorrect mount commits.
  • Test both init.templateDir and GIT_TEMPLATE_DIR, including planted stores without an origin.
  • Cover end-to-end adoption, clean recursive cloning, validator execution, direct verification, older commits, missing mounts, and post-mount swaps.
  • Assert exact findings, exit codes, pushed gitlinks, and module-store contents.
tests/test_adopt_clone_template.py

Assessment against linked issues

Issue Objective Addressed Explanation
#243 Prevent operator Git templates from planting repositories under the assembly root's .git/modules and being reactivated by the forced submodule mounts. ✅
#243 Make verification confirm that every mounted gitlink exactly matches the commit pushed to its corresponding leg, and refuse verification when it does not. ✅
#243 Add regression coverage for template-based planted stores, incorrect or outdated mount commits, missing mounts, recursive cloning, and the normal no-template control path. ✅

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-11T22:51:58Z) @codex review

@chatgpt-codex-connector

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

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-11T23:04:04.919146Z 65073c1 Manual request
ℹ️ About Codex in GitHub

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

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

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

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: 65073c1770

ℹ️ About Codex in GitHub

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

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

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

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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-11T23:52:19Z)

This lane reviewed its own PR #268 at 65073c1, before any fix round, read-only in its own detached worktrees.

CHANGES NEEDED. The fix works. On main 23e16ae the issue's reproduction says adoption verified over planted gitlinks, and the clean recursive clone exits 128. At 65073c1 the gitlinks are the pushed leg tips and the clone exits 0. No false adopt-mount-commit appeared for nested mount paths, a seeded leg, a trunk tracking branch or GIT_TEMPLATE_DIR.

  1. P2: tests-windows fails 12 of the new tests (run 38182070760), while main passes there. the_source() at tests/test_adopt_clone_template.py:384-388 builds README.md through make_source_repo's write_text, which writes CRLF on Windows (blob 9495404d21b8). split_of() at :391-405 writes it as LF with write_bytes, so _verify rightly reports adopt-lost. Writing the source byte-exact fixes it under a Linux emulation of Windows: 12 failed before, 14 passed and 17 skipped after.
  2. P3: lane 1's adopt-project.py: a failure AFTER both legs are pushed (the split-branch push, the mount, the split commit) prints only REFUSED <step> with no notice of what already exists and no stdout flush, so the combined log shows REFUSED before the bare … lines and the re-run meets leg-remote-exists unexplained #188 makes urls and branch required keywords of _verify. Resolved as the body says, head plus cdfb308 fails 12 tests with TypeError, and the test file shows no textual conflict. The verify() helper at :408-416 needs both keywords; with them, 93 pass. Whichever PR lands second applies this.
  3. P3: --template= (adopt-project.py:2757) also stops hooks installed by the operator's git template from running on the split commit and push. Main ran them, and core.hooksPath hooks still run. The comment and the body should say so.
  4. Nit: the body's Windows test claim, its main sha and its adopt-project.py: a failure AFTER both legs are pushed (the split-branch push, the mount, the split commit) prints only REFUSED <step> with no notice of what already exists and no stdout flush, so the combined log shows REFUSED before the bare … lines and the re-run meets leg-remote-exists unexplained #188 recipe are stale.

Tests: head's test file against 23e16ae's code gives 19 failed and 12 passed, for the issue's reasons. Mutants are killed: without the flag 12 fail, without the check 9 fail. 23e16ae plus head passes 732 tests across twelve adopt, doctor and CLI files, and render-cli-reference.py --check passes.

Merges: head merges clean with main 23e16ae and 67a398a, and with seven adopt heads including #270. #188 (3 hunks) and #231 (1 hunk) resolve as the body says, plus item 2 for #188. fix/adopt-root-kept-symlinks conflicts with main itself in the same hunks.

The full report is handoffs/xFactory/attachments/lane-openRepoShape-2/review268-65073c1/final-report.md in opensoft/brett-wip.

brettheap and others added 2 commits October 11, 2026 23:55
…e split's is, so the 12 #243 tests that run without git filter-repo pass on Windows instead of each finding `adopt-lost` for a CRLF blob (#243)

tests-windows on 65073c1 (run 38182070760, job 114596550686) failed 12 tests in tests/test_adopt_clone_template.py: the 12 that run without git filter-repo, each with one extra `FINDING adopt-lost: README.md (9495404d21b8)`. `the_source` built the source through conftest's `make_source_repo`, which writes with `Path.write_text`; on Windows text mode turns "# Thing\n" into "# Thing\r\n" (blob 9495404d21b8), while `split_of` writes the split's README.md with `write_bytes` as "# Thing\n" (blob f5a9cc15a84c). The source blob was then in no leg and not in the root tree, and `_verify` said so, rightly. The bug was the test's, not adopt-project.py's.

`the_source` now writes the file with `write_bytes`, from `SPLIT_FILES["README.md"]`, as tests/test_adopt_verify.py writes both sides, so the two are one blob on every OS. `split_of` keeps `write_bytes`, so `project.yaml` and the pin stay LF, and `write_text(newline=...)` is not used because it is Python 3.10+ and the baseline is 3.9.

Checked on Linux with the review's emulation of Windows' `write_text` and no git filter-repo on PATH, as the Windows job runs: 65073c1 gives the CI result exactly (12 failed, 2 passed, 17 skipped, the same node ids and blob); this commit gives 14 passed, 17 skipped. With git filter-repo and no emulation, 31 passed. Only tests-windows confirms it on Windows.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… push no longer run hooks a git template installs, while `core.hooksPath` hooks and the legs' template hooks still run, so the one behaviour `--template=` changes besides the cure is written down (#243)

`git clone --template=` gives the assembly no `.git/hooks`, so a template that installs hooks (git-secrets and the pre-commit framework's `init-templatedir` both document that setup) no longer fires `pre-commit` and `commit-msg` on the split commit or `pre-push` on the split push. Before #243 all three ran in the assembly. Hooks set with `core.hooksPath` still run all three there, and the legs, extracted and seeded as before, keep their template's hooks (their pushes still fire `pre-push`). The review of 65073c1 reproduced all three cases. Nothing said so; the comment now does, in one sentence. No behaviour changes.

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-12T00:40:41Z)

This lane re-verified its own PR #268 at 06cb68e after fix round 1, read-only in its own detached worktrees.

MERGEABLE WITH NITS. Before landing, update the branch onto current main ede15ce and get fresh three-OS CI. Whichever of #268 and #188 lands second applies the body's step (4).

  1. Windows fix: verified. Under a Linux emulation of Windows text writes, 06cb68e gives 14 passed and 17 skipped without git-filter-repo, and 31 passed with it. Nothing else in the test file depends on text-mode writes.
  2. adopt-project.py: a failure AFTER both legs are pushed (the split-branch push, the mount, the split commit) prints only REFUSED <step> with no notice of what already exists and no stdout flush, so the combined log shows REFUSED before the bare … lines and the re-run meets leg-remote-exists unexplained #188 step (4): verified as written. 06cb68e merged with cdfb308 and resolved by steps (1) to (4) gives 93 passed.
  3. The comment at adopt-project.py:2756-2760: verified clause by clause. Template hooks no longer run in the assembly, core.hooksPath hooks do, and the legs keep theirs.
  4. Body claims and counts: verified. Nits: the Left out hook bullet's "because" clause is misplaced, and its main sha and head counts date from 00:00Z.

Tests: main be2790e plus 06cb68e passes 749 tests across twelve adopt, doctor and CLI files. Head's tests on main's code give 19 failed and 12 passed. The mutants are killed: 12 failed without --template=, 9 failed without the check.

CI: run 38186199801 is green on Linux, macOS and Windows (2474 passed, 265 skipped). It tested the merge with d94d155.

Merges: clean with main be2790e and ede15ce. #188 cdfb308 has 3 hunks and #231 f38d543 has 1, both as the body says.

The report is handoffs/xFactory/attachments/lane-openRepoShape-2/review268-65073c1/reverify-1.md in opensoft/brett-wip.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit e43fa95 into main Oct 12, 2026
5 checks passed
@brettheap
brettheap deleted the fix/adopt-clone-without-template branch October 12, 2026 12:34
brettheap added a commit that referenced this pull request Oct 12, 2026
…, #253 via #264, #302 via #318, #288 via #309, #279 via #310, #306 via #311, #334 via #336, #239 via #269, #243 via #268, #321 via #335) into fix/adopt-record-submodule-placement

Brings in the twelve commits from c7c02fa to a1b3a4b on top of 71b58a1,
merged by sha. No rebase. Among the files this branch changes:
346079d (#216) and e43fa95 (#243) change adopt-project.py; 34ecbc2 (#260)
changes scripts/shape_materialize.py; 87373a6 (#306) changes
shape-doctor.py and tests/test_shape_doctor.py; 7491530 (#288) changes
AGENTS.md and tests/test_repo_hygiene.py.

Two conflicts, each resolved keeping both sides:

- adopt-project.py, the module docstring's `execute` paragraph. #243 ends
  "the root's only submodules are the two leg mounts, each at the commit
  pushed to its leg."; this branch follows "two leg mounts." with "The
  split's `project.yaml` records the leg each source submodule kept in a
  leg was answered into, under `submodules:`, for `shape-doctor.py` to
  check it against." The merge has #243's sentence, then this branch's,
  re-wrapped to the paragraph's width. The module docstring is not in
  `--help` (docs/cli.md holds neither sentence).
- tests/test_repo_hygiene.py, `test_agents_md_is_short_enough_to_be_read`.
  Each side moved the AGENTS.md cap from 464 and wrote a paragraph
  saying why: #288 to 477, for thirteen lines, and this branch to 472,
  for eight. AGENTS.md itself merged cleanly and holds both: 485 lines,
  main's 477 plus this branch's +9 -1. The merge keeps #288's paragraph
  word for word, then this branch's, renumbered "2026-10-12: 477 -> 485"
  because it now follows #288's, and asserts `<= 485`.

`git merge-tree --write-tree` of the two parents gives the same tree in
every file but these two.

Checked:

- adopt-project.py: `_commit_the_split` was changed by both sides, in
  hunks that do not touch. #243 clones the assembly with `--template=`;
  this branch builds `append` with the `submodules:` record. The merged
  function holds both, and each side's text is unchanged. Every other
  definition one side changed is that side's text.
- shape-doctor.py: #306 changes `validator_row` and `run_validator`.
  This branch's placement functions, F1's
  `gitmodules_the_record_answers` among them, are this branch's text.
- No top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py,
  scripts/shape_materialize.py, shape-doctor.py, validate-manifest.py,
  update-shape.py, scripts/family.py or scripts/bump-leg.py. Each merged
  module's names are the base's plus what either side added.
- AGENTS.md is main's plus exactly this branch's changed lines.
- The branch's diff against main is #189 alone: 12 files, +1694 -157. That
  is +1670 at 27d039b, plus F1's fix and test (+23), plus the one line
  the docstring re-wrap adds.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
… of main's commits merge with no conflict, among them #243's template-less clone of the assembly and #216's literal `git grep` in adopt-project.py, and #260's one-pass `render` in shape_materialize.py

origin/main moved 71b58a1 -> 8cdca5e. It was merged by sha, as the
coordinator asked for e43fa95 or later:
- c7c02fa (#299 via #315), scripts/family.py;
- 346079d (#216 via #228), adopt-project.py: `plan`'s follow-up
  `git grep`;
- 34ecbc2 (#260 via #316), scripts/shape_materialize.py: `render`
  substitutes in one pass;
- 86da808 (#253 via #264), scripts/bump-leg.py;
- 6f4ce47 (#302 via #318), scripts/family.py;
- 7491530 (#288 via #309), AGENTS.md, README, docs and
  scaffold-project.py's --help;
- 7591455 (#279 via #310), tests/conftest.py: `git()` takes its default
  identity as `-c` config;
- 87373a6 (#306 via #311), shape-doctor.py;
- 8a14ac9 (#334 via #336), tests/test_update_shape_landing.py;
- c1b6732 (#239 via #269), update-shape.py;
- e43fa95 (#243 via #268), adopt-project.py: the assembly root is cloned
  with `--template=`, and the verification refuses a mount whose gitlink
  is not the commit pushed to its leg (`adopt-mount-commit`);
- a1b3a4b (#321 via #335), tests/test_git_output_decoding.py;
- 8cdca5e (#295 via #312), the template's scripts/validate-pins.py.

`git merge-tree` and this merge give the same tree, 07e6b49.
adopt-project.py, scripts/shape_materialize.py and
tests/test_adopt_ignored_shape_files.py auto-merge. In the last of these
main's 346079d and this branch's 86a5e30 change different cases.

Checked on the merged tree:
- AST (fin183g/names.py, base 71b58a1): no top-level name is bound twice
  in adopt-project.py, scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, and none is loaded but bound nowhere.
  Main changed the signature of no function this branch calls.
- Every sentence 86a5e30 changed is still there: in `_through`, in
  `_link_parts`, in `_verify`'s `_resolve` (which e43fa95's
  `adopt-mount-commit` leaves as it was), and in the three test files.
- `_commit_the_split` now clones the assembly with `--template=`. The
  next commit, F3, reads what that clone holds in its own `.git`, which
  is why F3 comes after this merge.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…refused `gh pr create`, and step 7 keys its way out to the exit it prints, so an `IS pushed` exit 2 is finished by hand, not re-run into a second pair of legs (#304) (#371)

AGENTS.md step 7 told an assistant that any non-zero exit of `adopt-project.py execute` is "to fix the plan and re-run into fresh legs". That is the way out of a failed verification, exit 1. In GitHub mode `execute` has two more non-zero exits, both exit 2, and both come after both legs and the split branch are pushed:

- `gh pr create` refused: `The branch IS pushed. Open the pull request by hand:` and the command. `execute` returned before `_verify`, so no table was printed and the pull request to open by hand was for a split nothing had checked.
- The topic will not set: after `adoption verified`, `The split IS pushed and the pull request IS open. Set the topic by hand:` and three commands.

On either, the way out is the printed command, and "re-run into fresh legs" abandons a finished adoption for a second pair of legs. No test reached either function, because `--local-remote-dir`, the path every test takes, skips both (`if not local:`).

- **AGENTS.md step 7**, four lines longer (477 -> 481 on origin/main, the cap raised with a dated entry in `tests/test_repo_hygiene.py`; #309 landed first and its `464 -> 477` entry is kept as it is). It keys the advice to the exit the tool prints: exit 1 keeps its content (the source is untouched, fix the plan, re-run into fresh legs); exit 2 saying `IS pushed` (GitHub only, `gh pr create` or the topic refused) comes after both legs and the split are pushed and verified, so finish with the command it prints and never re-run into fresh legs.
- **`adopt-project.py`, behaviour (fix round 1, below):** a refused `gh pr create` no longer returns before the verification. The by-hand command is printed after the table, and only when the table passed.
- **`adopt-project.py`, the module docstring's EXIT CODES paragraph.** It gave exit 2 one meaning, "the question could not be asked". It now names the two exit 2s that come after the push and the verification, in the tool's own words, and says that a failed verification outranks both. `--help` carries none of it, so `docs/cli.md` is unchanged (`scripts/render-cli-reference.py --check`: `docs/cli.md is what the generator produces`).
- **`tests/test_adopt_github_exits.py` (new, 11 tests).** The issue's reproduction as a test: a `#!/bin/sh` `gh` that refuses one command (the pattern of `tests/test_scaffold_leg_paths.py`, `WINDOWS_SKIP`) and a throwaway global git config rewriting `https://github.com/` to bare repositories under `tmp_path`, so no network. Each exit is asserted as exit 2 with its REFUSED block and its by-hand command word for word, a table, both legs and the split branch pushed; the command comes after the table in a merged stream; a verification that really fails gives exit 1 with a refused pull request and no command to open one. Step 7's anchors (`IS pushed`, `gh pr create`, the topic) and the EXIT CODES paragraph are asserted against what the tool prints, so rewording either side alone fails a test.

The issue's second "what would fix it" bullet. Reproduced on d3f71d6 first (a source with its own `contracts/shape-pin.yaml` kept in the root fails the verification for real, nothing in the tool replaced):

| scenario, GitHub mode, merged stdout+stderr | before (d3f71d6) | after |
|---|---|---|
| `gh pr create` refused, split verifies | exit 2; no table; `Open the pull request by hand` (line 19) | exit 2; table (27), `adoption verified` (33), then `Open the pull request by hand` (37), the last thing printed |
| `gh pr create` refused, split FAILS verification | exit 2; `Open the pull request by hand` for a split that is wrong, never verified | exit 1; the finding and the footer; `No pull request was opened, and none should be: the split above did not pass verification.`; no command to open one |
| topic refused, either | unchanged | unchanged (exit 2 / exit 1) |

**The order, and why.** The split is pushed, then `gh pr create` is tried; a refusal prints its REFUSED block at once (what failed); the topic is not tried; `_verify` runs; then the by-hand lines are printed or withheld by what `_verify` said. Evidence for each step:

- *The by-hand text has to come after the verdict.* The command is only right for a split that passed; a failed one must not get it (the brief), and a person who sees only the REFUSED block above, which names the exact `gh pr create ...` that was tried, may paste it. So `_open_it_by_hand` runs after `_verify`, and a failed split gets the sentence above in its place.
- *It flushes stdout first.* Under a pipe stdout is block-buffered and the table is on it. In the merged logs above (stdout to the same file as stderr), every stderr line lands ahead of every stdout line on d3f71d6: the topic's by-hand commands print at line 7 of 26, before the table at 20. A test (`..._comes_after_the_table`) fails without the flush.
- *The topic is not tried after a refused pull request*, as before (it returned early). Its by-hand message says the pull request IS open, so printing it would be false; the stub `gh` lets a topic through, and a test asserts it is never set on this path.
- *Verifying first and opening the pull request only on a pass* was considered and not done: it reorders every successful run's output (the pull request line moves below `adoption verified`, and `_verify`'s `NEXT: review the pull request` is printed first), and it is not "the code path the topic case uses". It would fix something real, below.

The code reuses the topic's path, with no second verification: `cmd_execute` already ran `_verify` after the topic step; the early `return 2` is gone, `_open_the_pull_request` prints the REFUSED block and returns False as before, and the by-hand lines moved to a small `_open_it_by_hand` called once the verdict is known. The exit is `2 if the pull request was refused or the topic failed`, and a failed verification outranks both with exit 1.

**Tests for it** (`tests/test_adopt_github_exits.py`, 7 tests, all end to end through the real CLI):
- `test_each_exit_after_the_push_is_exit_2_with_a_command_to_finish_by_hand[gh-pr-create-refused]` now expects the table, `adoption verified`, no pull request line, the topic never set;
- `..._the_command_to_open_a_refused_pull_request_comes_after_the_table`: merged stream, table < `adoption verified` < by-hand, which is the last thing printed;
- `..._a_failed_verification_outranks_a_refused_pull_request`: exit 1, the finding, no `Open the pull request by hand`, no `IS pushed`;
- `..._the_same_source_verifies_when_gh_opens_the_pull_request`: its control, exit 0.
Driving a real failing verification was practical (a source's own pin kept in the root, the way #188's tests do it), so nothing is injected.

**Fails before, passes after:** the 5 changed or new tests fail on d3f71d6 (the step 7 reading, the EXIT CODES reading, the pr-create run, the order run, the failed-verification run); the topic run and the control pass there, as they should. **Mutation probes of the new hunk**, each failing at least one new test: restoring the early `return 2` (3 fail); ignoring the verdict in `_open_it_by_hand` (the failed-verification test); removing the flush (the order test); trying the topic after a refusal (the pr-create run); returning 0 for a refused pull request (2 fail); AGENTS.md or the docstring back to d3f71d6 (their readings).

**S1, a closed stdout (should).** `_open_it_by_hand` flushed stdout with `sys.stdout.flush()`, which raises BrokenPipeError when nobody reads stdout any more (`execute ... | head`, `| true`), so the run ended in a traceback and the command to open the pull request, which main printed, was never printed. It now calls `_flush_stdout()`, which is lane 1's #188 helper under the same name and shape (`contextlib.suppress(OSError)`), at the same place in the file and with the same text, so the two merge to one definition. A test drives the real CLI with a stdout whose reading end is closed: the command is on stderr, there is no traceback, and the exit is 120, Python's own for a stdout it cannot flush as it exits, which main gave and 16aa9ff gave too (so the exit does not tell them apart; the traceback and the missing command do).

| `pr` scenario, stdout closed (`\| true`) | c7c02fa (main) | 16aa9ff | this head |
|---|---|---|---|
| by-hand command | printed | **not printed, traceback in `_open_it_by_hand`** | printed |
| exit | 120 | 120 (Python's, for the stdout it cannot flush as it exits) | 120 |

**S1, the other case, NOT built here.** If the verification itself raises (a `git` that will not list a tree, a full disk), the by-hand line is still lost: a `CommandFailed` gives exit 2 with two REFUSED blocks and no word that the legs and the split are pushed, an `OSError` a traceback with exit 1, which step 7 reads as "a failed verification". #188's `_verify` catches those, reports them with its notice and the branch to delete, and exits 2; composed, both give `REFUSED verifying the split`, the notice, and "No pull request was opened, and none should be". That needs #188's handler, so it is its to compose, not this PR's to build.

**S2.** origin/main is merged into the branch (merge commits c519f57 and a92fcad, no rebase or force). The one conflict was the AGENTS.md cap: #309's `464 -> 477` entry is kept as it is, this PR's entry is now `477 -> 481` and the cap is 481, the real `wc -l AGENTS.md` after the merge (477 on main, and step 7 grows from 5 lines to 9). The composition section above is refreshed against the current heads.

**Nits.**
- **N1.** The dated entry uses the file's em dash, and the five-versus-four sentence and "(fix round 1)" are gone: it describes the change, not the branch's history.
- **N2.** `NEXT: review the pull request on <root>` moved out of `_verify` into `_next_steps(names)`, which `cmd_execute` calls only when no pull request was refused. After a refused one, the by-hand command is the next step and the last line. Under `--local-remote-dir` it is unchanged (see "Left out").
- **N3.** The printed command now works without a terminal. `gh pr create` without `--title` and `--body` asks for them, and where there is no terminal it exits 1 before any request (`must provide --title and --body ... when not running interactively`; the review ran it offline with a bogus token, an empty config directory and an `.invalid` host, and this fix round did not run `gh` at all), so an assistant told by step 7 to "finish with the command it prints" could not. The tool already knew both, so `_pull_request_command` builds the arguments once, `_open_the_pull_request` runs them, and `_open_it_by_hand` prints the same list, shell-quoted. A test reads back the arguments the stub `gh` was refused with and compares them with the parsed printed command; nothing runs the printed command. Two costs, stated: the body is the split commit's message, which lists every moved path, so for a large repository the printed command is as long as that list (the REFUSED block above it already prints the same text unquoted); and the quoting is POSIX, as #188's printed `git ... --delete` already is. `_open_it_by_hand`'s signature is now `(repositories, names, tracking, branch, message, passed)`, which the #188 note carries.
- **N4.** The EXIT CODES paragraph said `execute` "has two more exit 2s". It now says it has other exit 2s once the leg repositories exist (a refused push, a refused mount) and that two of them are GitHub mode's alone.
- **N5.** The follow-up claiming #188's `_flush_stdout()` covers the topic's by-hand commands printing above the table was false; it is corrected above ("Left out").
- **N6.** Nothing to do: the coordinator lands with an explicit squash message.
- **N7.** Wording only, for the #188 composition: "was not verified" would be exact where #188's `_verify` could not verify at all. It is in the note on #188.
- **N8.** The end-to-end environment keeps the real `gh` unreachable. `subprocess` goes on to the next PATH directory when the program it found cannot be executed (a `noexec` temp directory gives EACCES), and the next `gh` would be the operator's. So the run's environment now carries `GH_TOKEN=bogus-token-for-tests` and `GH_CONFIG_DIR` an empty directory, and PATH is the stub first and then the inherited PATH WITH every directory that holds a `gh` taken out (`without_a_real_gh`), which finds `git` and `python` because the directories are taken out only when each of `git`, `git-filter-repo`, `python3`, `python`, `sh`, `env`, `sed`, `tr`, `uname` and `cat` is found at the same place without them. Where it is not (`/usr/bin` on Linux holds `gh` beside `git`, and Homebrew's `bin` beside the `git` a run is meant to use), PATH is returned whole and the token and the config directory are what keep the real `gh` from acting. The stub records the token and config directory it was run with, and a test holds them to the bogus token and the empty directory.

**Tests.** `tests/test_adopt_github_exits.py` is 11 tests now. On a throwaway worktree at 16aa9ff with this file: **5 failed, 6 passed**. Failing there: the EXIT CODES reading (N4), `[gh-pr-create-refused]` (N2), the order test (N2, N3), the printed-command test (N3) and the closed-stdout test (S1). Passing there, as they should: step 7, `[the-topic-will-not-set]`, the failed-verification test and its control, and the `without_a_real_gh` unit test and the environment test (N8: nothing in 16aa9ff's production code to fail, the environment is the test's). **Mutation probes** (15, each reverted by path in a throwaway worktree): the unguarded flush back, the flush removed, `_flush_stdout` not suppressing, the printed command cut to the old form, unquoted, run without the body, `NEXT` always or never said, the EXIT CODES paragraph reverted, the cap back to 477, the token or the config directory dropped from the environment, and `without_a_real_gh` either keeping every directory or dropping the shared ones: each is caught by at least one test, and a title with the leg names swapped, which no test caught at first, is caught by the title assertion added for it.

- **Reproduction** (the issue's script, unchanged but for temp paths, on c7c02fa, git 2.43.0): both loops exit 2 with every message the issue quotes; the first printed no `VERIFICATION` table.
- **Every non-zero exit of `execute`**, with line numbers, is tabulated in the lane's report. Also run: first `gh repo create` refused (exit 2, nothing created), second refused (spec leg exists, empty), split push refused by a hook (exit 2, legs pushed), and the naive re-run after the topic exit (refused at `gh repo create`, as a taken name is).
- **Targeted tests on the head (c5a8a55, origin/main a1b3a4b merged in):**
  - `tests/test_repo_hygiene.py tests/test_cli_reference.py tests/test_adopt_github_exits.py`: 74 passed.
  - `tests/test_adopt_e2e.py tests/test_adopt_plan.py`: 75 passed, 1 skipped (`SHAPE_MEDXEHR_CLONE` is unset: the MedxEHR-backed plan test needs a read-only clone of MedxSoft/MedxEHR).
  - `tests/test_adopt_execute_refusals.py tests/test_adopt_verify.py`: 53 passed.
  - `tests/test_git_output_decoding.py` (origin/main's text-mode guard, which reads test files like this PR's): 67 passed.
  - `scripts/render-cli-reference.py --check`: `docs/cli.md is what the generator produces`.

- origin/main, #184 (`fix/adopt-report-filter-repo-failure`, f416e13), #245 (`fix/adopt-relative-submodule-url`, d72544c, which now contains #309), and the lane-5 siblings F (69c54c9), K (21fbea4), L (8f43c96), M (14e2d68), Q (a30091f) and U (233dc71): all clean. #309 and #268 have landed; the one conflict with #309, the AGENTS.md cap in `tests/test_repo_hygiene.py`, is resolved in the merge commit (`477 -> 481`, cap 481 = `wc -l AGENTS.md`).
- **#188** (`fix/adopt-late-failure-notice`, 918e8f2): conflicts in AGENTS.md (step 7) and in six regions of `adopt-project.py`, all in code this PR rewrote and #188 edits (nine regions in all, and three of those are #188's own against origin/main since #268 landed, so they are not this PR's). Not resolved in either branch. The note for lane 1, with the joined step 7 (482 lines, cap 482), the join of each region and the one patch #188's tests need, is posted on #188: the two `[...-pull-request]` parametrizations of `test_a_gh_step_after_the_split_gets_its_by_hand_exit` call `_open_the_pull_request` alone and look for the by-hand text, which is `_open_it_by_hand`'s now. Checked on a throwaway tree (87373a6, where #188 merges clean, plus this PR's diff plus #188, joined by hand, never committed): 106 passed over `tests/test_adopt_github_exits.py`, the whole of `tests/test_adopt_late_failure.py`, `tests/test_adopt_verify.py` and the cap test; without the one test patch exactly those two parametrizations fail. `_flush_stdout()` and the two imports are the same text on both branches, at the same place, so they merge to one definition.
- `fix/adopt-record-submodule-placement` (#189, 3e86200), `fix/adopt-root-kept-symlinks` (6c44591), `fix/adopt-plan-quote-yaml-keywords` (e337f44): conflict in `adopt-project.py` (and, for #189, the cap line) in exactly the same regions with this PR absent (`git merge-tree origin/main <ref>`: 1 + 1, 3 and 3 conflict regions either way); none of them is this PR's. With #189 (cap 472, +8) as well the cap would be 489; arithmetic only. `README.md`, `docs/handbook.html` and `docs/cli.md` are not touched here, so the handbook's README-blob footer does not move.

**At opening** (origin/main 8cdca5e, which merges clean): lane 1 has since moved `fix/adopt-plan-quote-yaml-keywords` to d38e143, which now merges clean, and #189 to 27d039b.

- **README.md and docs/handbook.html: unchanged.** Neither states the rule: README's adoption section and the handbook's `#adopt` say that `execute` verifies and never say what a non-zero exit means; their "re-run" / "clean re-run" sentences are the scaffold's (`#partway`, lane 4's #309 territory). Changing the README would also move the handbook's machine-checked `README.md blob` footer for no gain.
- **The verification footer and `--help`.** The footer (`_verify`, exit 1) is true for the exit it describes, and `tests/test_adopt_verify.py` holds its text; #188 rewrites it. `--help` carries no exit-code text.
- **`NEXT: review the pull request on <root>` under `--local-remote-dir`** is still printed, as it always was, with no pull request ever opened. This PR stops saying it after a refused pull request (fix round 2); a local run is a test or rehearsal path, and its NEXT block is the only clone-and-bootstrap guidance it has.
- **Candidate follow-ups, not done:**
  - GitHub mode opens the pull request BEFORE it verifies, so a failed verification leaves a pull request open for the wrong split (probed, a source's own pin kept in the root: `pull request .../pull/1` printed, then the finding, exit 1). Verify, then open, would avoid it, at the cost of reordering every successful run's output.
  - After a refused pull request the topic is not tried and the by-hand text does not list the three `gh repo edit` commands, so a person who opens the pull request by hand still has no topic set.
  - The topic's by-hand commands print above the table in a merged log (line 21 of 26 with #188 composed, line 7 on main): `_set_the_topic` prints them before `_verify` runs, so #188's `_flush_stdout()` only stops them jumping ahead of the leg lines. `_set_the_topic`'s docstring ("the caller reports it after the table") is false on main and stays false with #188. Printing them after the verification, the way this PR does for the pull request, would make it true.
- **No remediation on three exit 2s** (a refused `gh repo create`, a refused split push, a failed `git filter-repo`): on c7c02fa each prints a REFUSED block or, for the last, nothing at all (probed: exit 2, empty stderr, two leg repositories left). #184 and #188 are the open PRs on these.

An adversarial review of 16aa9ff found 2 should-fix items and 8 nits. Fix round 2 addresses all of them. A re-verification of c5a8a55 found 0 blockers, 0 should-fix items and 4 nits:
- the end-to-end environment still passes `GH_HOST` and the enterprise tokens through;
- this body's composition tips had moved (refreshed above);
- two counts in lane 1's composition note were off (corrected before posting);
- the `not verified` half of the `NEXT` condition has no test.

No probe ran the real `gh`: every probe used a stub first on PATH, a bogus `GH_TOKEN` and an empty `GH_CONFIG_DIR`.

Reviewed adversarially: the first review asked for changes (two should-fix, eight nits), and a fix round addressed all ten. The re-verify approved with four nits, left as follow-ups: the end-to-end environment passes `GH_HOST` and the enterprise tokens through, the composition section was stale at that day's heads, two sentences of the #188 note were imprecise, and the `not verified` half of the new `NEXT` condition has no test. CI is green on Linux, macOS and Windows, with SonarCloud passing, at 34b9500 (c5a8a55 merged with main 161c6c9).

Closes #304

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 13, 2026
… exists, a source symlink with an empty target and the source's own `project.yaml` kept in the root, and `plan` no longer offers `root` for that file (#303, #305) (#387)

Two ways `check` said `plan ok` to a source `execute` could not adopt, both reproduced on c7c02fa with the issues' own scripts.

**#303, a symlink whose target is empty.** A `120000` entry whose blob is the empty blob passed `check`, which never reads a link's target.
- With symlinks on (git's default where the disk can hold one), `execute` created both leg repositories, then failed in its first clone of the source: exit 2, nothing on stderr, two empty legs left behind. Linux refuses a symlink to nothing. This happened whatever the plan answered: `docs/empty-link` sent to spec, and a top-level `empty-link` answered `root` or `drop`.
- With `core.symlinks=false`, it said `adoption verified`. The spec leg held the empty link, a clone of that leg exited 128, and the assembly's recursive clone exited 1. The top-level link answered `root` or `code` verified the same way. Only `drop` gave an assembly that clones.

**#305, the source's own `project.yaml` answered `root`.** `plan` asked about it under `ambiguous-top-level-yaml`, whose question offers `root`. Answered `root`, `check` said `plan ok`. `execute` wrote the shape's manifest beside the source's file as `shape/project.yaml`, said "nothing was overwritten" and `adoption verified`. `shape-doctor.py` then said NOT A SHAPE ROOT (exit 2) and pointed at `adopt-project.py plan`. `make bootstrap` failed `naming (exit 2), manifest (exit 1), pins (exit 2)`. Answered `code` or `spec`, the doctor said COMPLIANT and `bootstrap ok`.

**What changed.**

- **`plan-empty-symlink` (#303).** `_empty_symlink_findings` reports each `120000` row of size 0 in `Source.tree()`. The size `ls-tree -l` gives a symlink is the length of its target, so no target is read; a SHA-256 repository reports size 0 too.
  - It is listed right after #196's `plan-unsafe-path`, through `_source_path_findings`, on the line `_entry_findings` already had. So `check` prints it, and `execute` refuses it through `_refuse_what_check_finds` before any leg exists (#168).
  - `EMPTY_SYMLINK_REMEDIATION` is said once after the findings, in `check` and in `execute`'s refusal. The fix is in the source: give the link a target, or remove it, commit, and re-run `plan`.
  - `plan` notes each such link in `check`'s words, and still writes the plan.
- **`ambiguous-project-yaml` (#305).** A new rule in `contracts/path-classification.yaml`, above `ambiguous-top-level-yaml`, for the file `project.yaml`. Its question asks spec, code or drop, and says `root` is not an answer for this file. `release.yaml` keeps the old question.
- **`plan-root-blocks-manifest` (#305).** #313's machinery is extended, not copied: `_shape_blocks`, which `_root_shape_findings` runs over each disk, now refuses the root holding `project.yaml` outright, with no copy question after it. It fires for a kept row at that path (file or symlink), or a kept directory there, on a disk that keeps case or one that ignores it. The finding's remediation is #313's `_fix_the_kept_path`.
  - A submodule there stays `plan-submodule-root`'s, said once.
  - `_kept_there` is the at-or-under lookup the copy branch already did, factored out for both.
  - `execute` refuses it before any leg exists, through the same list.
- **Files.** `adopt-project.py` (+205/-13), `contracts/path-classification.yaml` (+28), the two new test files (399 and 366 lines) and `tests/test_adopt_root_shape_paths.py` (+6/-2).

**Decisions.**

- **#303 is refused whatever the plan answers, and whatever `core.symlinks` says.**
  - Where symlinks are made, no answer adopts. The run fails at the first clone of the source, which checks the whole tree out before anything is taken out. Measured with the link in `docs/` (sent to spec), at the top level answered `root` and `drop`, and with every path rooted or dropped and both legs seeded: there the seeded legs were pushed and the assembly's clone exited 128.
  - `check` cannot know where `execute` will run. A check that passed `drop` with symlinks off would pass a plan the next Linux machine cannot adopt, and the root's own `validate` workflow runs on `ubuntu-latest`.
  - The remediation costs one commit in the source.
- **#305 covers the manifest only.** The issue asked whether other rendered root paths need the same rule. Measured on c7c02fa, with the source's own file kept in the root at each of the 18 `assembly_root_paths()`:
  - `check` passes all 18.
  - These verify over a broken assembly: `contracts/spec-pin.yaml` and `contracts/code-pin.yaml` (DRIFTED, pins fail); `scripts/validate-pins.py`, `scripts/validate-manifest.py`, `scripts/repo_shape.py` and `contracts/repository-naming.yaml` (INVALID); `scripts/validate-repository-naming.py` (COMPLIANT, naming fails); `scripts/bootstrap.py` (COMPLIANT, and `make bootstrap` runs the source's own script).
  - `contracts/shape-pin.yaml` fails verification after both legs exist.
  - README.md, AGENTS.md, CLAUDE.md, .gitignore, .gitattributes, AGENTS-shape.md and the workflow come out COMPLIANT with the copy beside them. `Makefile` is rooted by default, and the follow-up merge is its designed exit.
  - `project.yaml` is the one path `plan`'s own question offered `root` for. The others are reached only by hand-editing a `contracts/` or `scripts/` answer to `root`. They are left for a follow-up issue (under Left out), not folded in here.
- **A symlink at `project.yaml` is refused too.** On c7c02fa:
  - a link to the source's own file verified and was NOT A SHAPE ROOT;
  - a link to nothing had the manifest written through it, and came out COMPLIANT on Linux. A clone that makes no symlinks holds that link as a file naming its target, so it is refused anyway, and #183 refuses a link to nothing at a shape path regardless;
  - a directory `project.yaml/` verified and was NOT A SHAPE ROOT.
- **A case twin `Project.yaml` kept in the root is refused, with the case aside.** It adopts COMPLIANT on Linux. On a disk that ignores case, `place` writes the manifest beside it. This is the trade #172 and #313 already make.

**Evidence.** In this section and in the two below it, "this head" is 69c54c9, the branch's own commit and the one the review approved; main and #314 were merged into the branch afterwards (last paragraphs).

- **Reproductions, on this head** (exits as printed):
  - #303 run 1: `check exit 1`, one `plan-empty-symlink` finding; `execute exit 2`, `REFUSED plan-empty-symlink`, `leg repositories left:` empty.
  - #303 run 2 (`core.symlinks=false`): the same finding and refusal; no leg, no verification.
  - Top-level `empty-link`, symlinks off and answered root, code or drop, and symlinks on and answered root or drop: each `check exit 1`, `execute exit 2`, no leg.
  - #305 root: `check exit 1`, one `plan-root-blocks-manifest` finding; `execute exit 2`, `leg repositories:` empty.
  - #305 code, spec and drop: `plan ok`, `adoption verified`, the root's `project.yaml` begins `schema_version: 1`, doctor COMPLIANT, `bootstrap ok`.
  - The plan's entry for `project.yaml` now reads `rule: ambiguous-project-yaml`, with no `(root)` in its question.
  - `project.yaml` as a link to a file, a link to nothing, a directory, and the twin `Project.yaml`: each `check exit 1` and `REFUSED plan-root-blocks-manifest`.
  - No row needs real GitHub or a cloud tool.
- **New tests.** `tests/test_adopt_empty_symlinks.py` (28) and `tests/test_adopt_root_manifest.py` (27).
  - The symlinks are made with `git update-index --cacheinfo`, so no disk symlink is needed. The one test that checks a link out (a link given a target, end to end) is skipped on Windows by its marker, before it runs.
  - On c7c02fa: 37 failed, 18 passed. The 18 are the controls that must pass on both sides: a symlink with a target, the remediations adopting, `project.yaml` sent to a leg or dropped, a submodule there, other shape paths keeping their copy, and `release.yaml`'s question.
- **One existing test changed.** `test_a_file_kept_at_a_path_the_shape_writes_is_not_a_finding` in `tests/test_adopt_root_shape_paths.py` held that a kept file at any shape path is no finding. It now holds that for every path but the manifest, which is `plan-root-blocks-manifest`.
- **Mutation probes:** 14 of 14 hunks reverted one at a time, each killed by at least one new test.
- **Targeted runs.** The adopt list and the doctor ran on a local state of this branch (7a22a21) whose files were this head's but for `tests/test_adopt_root_shape_paths.py`. That file was re-run on this head, so the adopt list is two runs, not one sum.
  - The 15 adopt files (`test_adopt_empty_symlinks`, `_root_manifest`, `_root_shape_paths`, `_leg_path_collisions`, `_execute_refusals`, `_plan`, `_e2e`, `_submodules`, `_verify`, `_mount_ignored`, `_ignored_shape_files`, `_entry_paths`, `_finding_spelling`, `_history_gitlinks`, `_spec_only`) on 7a22a21: 837 passed, 1 failed, 1 skipped (a MedxEHR clone the plan test needs). The one failure is the #313 test this head changes.
  - `tests/test_adopt_root_shape_paths.py` alone, on this head: 120 passed.
  - `tests/test_shape_doctor.py`, as its own stream: 242 passed, 3 skipped (PowerShell lines only a Windows host prints).
  - `tests/test_scaffold_leg_path_collisions.py`, `tests/test_repo_hygiene.py` and `tests/test_cli_reference.py`: 167 passed.
  - This head merged with origin/main at 87373a6, which brings `tests/conftest.py`'s new `git()` identity: the two new files, `tests/test_adopt_root_shape_paths.py` and the three above, 342 passed.

**The review.** An independent opus review at 69c54c9 approved with nits: 0 blocker, 0 should, 8 nit. It reproduced every row of both issues on c7c02fa and on this head, and killed 6 of 6 mutants: two of the author's, re-run, and four of its own. Its targeted runs on this head merged with origin/main ab623dd (`git merge-tree` clean, with #349's session `gh` guard in place):
- the new files, `tests/test_adopt_root_shape_paths.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_adopt_execute_refusals.py`, `tests/test_repo_hygiene.py` and `tests/test_cli_reference.py`: 379 passed;
- the other adopt files, in two streams: 303 passed, 1 skipped (the MedxEHR clone), and 323 passed;
- the clone-template, literal-names, template-values, plan-escapes, stdout-encoding and Windows-path tests, which cover #268 and #270: 266 passed, 2 skipped (an operator `user.name` set; Windows only);
- `tests/test_shape_doctor.py`: 254 passed, 3 skipped (PowerShell lines only a Windows host prints).

c7c02fa's own `tests/test_adopt_root_shape_paths.py` against this head's code gave 1 failed (the `project.yaml` row, `:191`) and 119 passed, so no other #313 behaviour changed.

- **A cross-lane composition, with lane 1's #183.** On the tree merged with lane 1's `fix/adopt-root-kept-symlinks` (#183, 6c44591), `tests/test_adopt_root_symlinks.py::test_a_link_with_an_empty_target_is_fixed_in_the_source` fails: `AssertionError: assert 'plan-empty-symlink' == 'plan-root-symlink'`. An empty link at or above a shape path is reported by both findings, and `execute` refuses with the first, which is this branch's. The two remediations agree (fix it in the source). Whichever of the two lands second removes the duplicate report. The same file also fails 3 other tests on origin/main with #183 and without this branch (`[a-link-at-the-copy-itself]` and `test_the_materializer_refuses_before_it_writes`, two cases), because #313's `plan-root-blocks-shape-copy` and `materialize-copy-blocked` answer first; those are not this branch's.
- **Two known nits, left for a later round.**
  - An empty link AT `project.yaml` (or at a #313 copy path) gets `plan-root-blocks-manifest`, whose fix offers `drop`, and `plan-empty-symlink`, which says `drop` is no fix.
  - `plan` notes every empty link (25 links print 25 NOTEs), where `check` names 20 and counts the rest.
- The other nits are smaller. A symlink target that begins with a NUL byte passes `check` and fails like #303, and the issue's `_verify` bullet, are follow-up issues; the case twin `Project.yaml` is under Left out. The new tests' `SYMLINKS_OFF` replaces the Windows job's own `GIT_CONFIG_COUNT` settings (`core.autocrlf=false`, `core.longpaths=true`) for those child processes, which is harmless while none of them checks anything out.

**Merge-tree, as of 2026-10-12T15:14Z.** `git merge-tree --write-tree --name-only` of this head (69c54c9), against heads fetched then; every sha below is the one it had then, and main has moved since (see the merge paragraph below). It is a textual merge only; the semantic meeting with #183 was tested by the review (the first item above).
- origin/main: clean at 0f67f7b. That includes #316 (34ecbc2), #268 (e43fa95), #270 (f795cf7) and #245 (161c6c9), which have landed since this branch was cut. It was clean at 161c6c9 and at ab623dd, the review's merge, too.
- Open PRs touching `adopt-project.py`: #184 (f416e13), #353 (dcbc37b), #356 (5a5011d), #371 (34b9500), #373 (5ac03de), #377 (c35c896), #378 (2da8691), #382 (7e55d00) and #384 (a96297e) are all clean. #356 and #382 conflict with origin/main alone (2 and 5 blocks, from #245's landing), not with this head.
- #314 (4b8da3d) was NOT clean: one block, in `_entry_findings`' docstring, where each side adds a paragraph after the same one (this head's "THEN THE SOURCE'S EMPTY SYMLINKS (#303)" and #314's "A VALUE NO FILE CAN HOLD"). Keep both. It had been clean at f45edf4, and it had 3 blocks against origin/main alone since #245 landed, so it needed a merge of main in any case.
- Lane 1's branches without a PR: `fix/adopt-late-failure-notice` (9f1778d), `fix/adopt-record-submodule-placement` (70f4d29) and `fix/adopt-plan-quote-yaml-keywords` (d38e143) were clean. `fix/adopt-plan-quote-yaml-keywords` was 3 blocks against this head at e337f44, and conflicts with origin/main alone in 4.
  - `fix/adopt-root-kept-symlinks` (6c44591, #183) conflicts in 3 blocks of `adopt-project.py`: the module docstring's `check` paragraph, `_entry_findings`' docstring and its return line. They are the same 3 that c7c02fa itself has with that branch, and this branch's one added docstring line sits inside the first. It has 5 blocks against origin/main alone.
- Lane 5's `fix/adopt-pin-writes-pin-file` (d806e8f): clean. `docs/adopt-execute-exits` is #371, above.
- When #183 lands, an empty symlink kept at or above a shape path is reported twice, by `plan-empty-symlink` and by #183's `plan-root-symlink` (the first review item above). A dangling link kept at `project.yaml` is reported twice too, by `plan-root-blocks-manifest` and `plan-root-symlink`, and an empty one three times. Whichever lands second should keep one.

**Brought onto main past #314 (bbbb541).** Since this branch was cut from c7c02fa, main took #245 (relative `.gitmodules` urls), #270 (`plan-unwritable-value`), #371 (`execute` verifies before it exits on a refused `gh pr create`), #377 (`plan` asks about every path `execute` will not extract) and #314 (`plan-manifest-invalid`, 3ef059c), all in `adopt-project.py`. The merge of origin/main 3ef059c has ONE conflict, in `_entry_findings`' docstring, where this branch adds "THEN THE SOURCE'S EMPTY SYMLINKS (#303)" and #314 adds "A VALUE NO FILE CAN HOLD IS SAID BEFORE THE VALIDATOR'S WORD ON IT (#255 before #261)" after the same paragraph: both are kept, this branch's first, because it describes the head of the list and #314's its tail. Nothing in code conflicted. `check` and `execute` read one list, and it composes as each feature says: a source's unsafe paths (#196), its empty symlinks (#303), coverage, `_leg_findings` with `plan-root-blocks-manifest` (#305), #166's split, #270's `plan-unwritable-value`, #314's `plan-manifest-invalid`, then #245's relative urls last. So an empty symlink beside an invalid value is still refused wearing `plan-empty-symlink`, with no leg created; probed on the merged tree with an empty link plus `visibility: secret`, plus a line break in `visibility`, and with `project.yaml` answered `root` plus `visibility: secret`, each listing the findings in that order. No test expectation or order changed: the merged tree was rehearsed before #314 landed (69c54c9 + 45e01f4 + c6f441b) and is the same tree object as the pushed merge, and `git diff 3ef059c bbbb541` is this PR's five files and nothing else (+1004/-15). Targeted runs on bbbb541: the new files, `test_adopt_root_shape_paths`, `test_adopt_manifest_values`, `test_scaffold_leg_path_collisions`, `test_agents_md_codes` and `test_cli_reference` 418 passed; `test_shape_doctor` and #377's, #245's and #270's adopt tests 595 passed, 4 skipped (PowerShell lines only a Windows host prints; one operator `user.name`); the other adopt files 234 passed, 1 skipped (the MedxEHR clone) and the guards, literal-names, clone-template, plan-escapes, gitmodules, leg-paths, stdout and Windows-path tests 1101 passed, 1 skipped.

**Left out.**

- `_verify` and the materializer do not themselves refuse a root `project.yaml` that is not the shape's manifest. This is the issue's second bullet. Once `execute` refuses the plan before any leg, through the same list `check` prints, nothing reaches them with one. It is a follow-up issue.
- A top-level file named `project.yaml` only once case is ignored (`Project.yaml`) is still asked `ambiguous-top-level-yaml`'s question, which offers `root`. `check` refuses that answer with the case aside, before any leg.
- #171 (a failed clone exits 2 silently) is lane 1's PR #184. This PR only keeps the empty-symlink run from reaching that clone.
- Follow-up issues proposed by this PR (two since filed):
  - the source's own file kept in the root at one of the other shape paths the root's tools read (the pins, the validators, `scripts/repo_shape.py`, the naming policy, `scripts/bootstrap.py`, `contracts/shape-pin.yaml`), each measured above;
  - a symlink whose target begins with a NUL byte (`\0spec.md`, size 8), which `plan-empty-symlink`'s size test does not see: it fails like #303 (filed as #388);
  - `_verify` asking whether the split's root `project.yaml` is the shape's manifest (filed as #389);
  - an entry git cannot check out where `execute` runs, other than an empty symlink or a NUL-led one (a target longer than the platform allows, say), which is still found only by the first clone, after both legs exist. #303 leaves this as a note.

Reviewed adversarially at 69c54c9 (APPROVE WITH NITS: 0 blockers, 0 should-fixes, 8 nits). The review reproduced every row of both issues on c7c02fa and on that head, and killed 6 of 6 mutants. No fix round followed: the branch's own commit is as reviewed, and what was added after it is the merge described above. CI is green on Linux, macOS and Windows, with SonarCloud passing, at fce86fa (main 7a8b981 merged in). Its merge with main a73b4e9 passed the targeted set (the three adopt test files, `tests/test_shape_doctor.py` and `tests/test_scaffold_leg_path_collisions.py`): 597 passed, 3 skipped.

Closes #303
Closes #305

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 13, 2026
…d `open_glass` share one `SHAPE_PIN_SOURCE_<PRODUCT>`, so `--pin` refuses the pair and `validate-pins.py` notes a declared one and reads the variable for neither (#275) (#409)

`pin_source_env_name` turns every character outside `[A-Z0-9_]` into `_`, so
`open-glass`, `open_glass` and `open.glass` are three legal product names, with
three pin files (`contracts/<product lowercased>-pin.yaml`), and one variable,
`SHAPE_PIN_SOURCE_OPEN_GLASS`. On ab623dd (this branch's main parent; the same
rows reproduce on c7c02fa), with the issue's own probe against local bare
remotes and a `gh` that exits 97:

| step | ab623dd | this head (6494714) |
|---|---|---|
| `scaffold-project.py --pin open-glass@<A> --pin open_glass@<B>` | exit 0, three repositories | exit 2 `scaffold-pin-collides`, nothing created |
| `validate-pins.py`, no source, on a project that already declares the pair | exit 0, both skips offer `SHAPE_PIN_SOURCE_OPEN_GLASS` | exit 0, one `note neutral-pin-source-collides`; the skips offer `--pin-source <product>=<path>` and a sibling checkout only |
| `SHAPE_PIN_SOURCE_OPEN_GLASS=<open-glass>` | `neutral-pin-source-unreadable` for `open_glass`, exit 1 | exit 0, the note; the variable is read for neither pin, so each pin is read from its own source or skipped |
| `SHAPE_PIN_SOURCE_OPEN_GLASS=<open_glass>` | `neutral-pin-source-unreadable` for `open-glass`, exit 1 | the same |
| `--pin-source open-glass=... --pin-source open_glass=...` | exit 0 | both pins recompute, exit 0, the note |

**What changed.** Every variable name is kept: an operator may have set one and
the docs name them. What is done instead:

- `scripts/repo_shape.py`: `pin_source_env_collisions(products)` is the one
  pure answer, `{variable: [products]}` for each variable two or more distinct
  products share. Distinct means distinct PIN FILES (`pin_product_key`,
  `lower()`, which is what `contracts/<product lowercased>-pin.yaml` is named
  by), so a product typed twice or in two cases is #241's file collision and
  never this one. `pin_source_env_shared` is the set of those keys.
- `scaffold-project.py`: `_refuse_colliding_pins` (left where it is) names each
  variable's `--pin` arguments as typed after #241's file sentences, in the same
  `scaffold-pin-collides` refusal. Dry run and real run say the same no, before
  any process starts and ahead of the leg-path refusal. #241's wording and
  remediation are unchanged when only a file collides. A NEW pair is still
  refused, so the situation is not created anew.
- `templates/assembly-root/scripts/validate-pins.py`: one NOTE per variable,
  `neutral-pin-source-collides`, naming every product and the variable, the
  keyed `--pin-source <product>=<path>` that reads each pin, and that which
  declaration stays is the human's decision. It is never a finding and never
  moves the exit code. A product whose variable is shared does not read it (rule
  2 of `resolve_neutral_pin_source`), and its skip hint offers the keyed
  `--pin-source <product>=<path>` and not the variable or the bare form.
  A variable one product has to itself reads and is offered exactly as before.
- `scripts/repo_shape.py`, the two chain readers: `link_pins_from_trees` does
  not read a variable two links of one chain share, and the
  `declared-unverified` hint stops naming it. Only a hand-written chain can have
  two such links, since scaffold takes every link but the first from the naming
  policy's alphanumeric `open<Product>` form.

Each reader of `pin_source_env_name`, decided:

| site | decision |
|---|---|
| `scaffold-project.py --pin` | refuse (above) |
| `validate-pins.py` rule 2 and the skip hint | a note, and the variable is neither read nor offered for a shared product |
| `repo_shape.py` `resolve_link_source` (via `link_pins_from_trees`) | skip the variable when another link of the chain shares it |
| `repo_shape.py` `_chain_link_findings` hint | omit the variable for a shared link |
| `adopt-project.py --pin`, `plan`, `check`, `execute` | not in this PR, see below |

**Fix round 1 (from the review of 34dbd86).**

**The design change: a declared pair is a NOTE and not a finding. This is the
lane coordinator's decision, and Brett may reverse it.** The first round made
`neutral-pin-source-collides` a FINDING. Main through ab623dd scaffolds such a
project (`--pin open-glass@A --pin open_glass@B` exits 0), and
`update-shape.py apply` runs the NEW `validate-pins.py` over the rewritten root,
so for a project already declaring the pair the finding ended every apply as
`REFUSED update-validators-red`, rolled back, for this update and every later
one, until a person deleted a true declaration of descent. AGENTS.md tells an
agent never to edit "a pinned file, a digest or a manifest to make a row go
green", and the finding's remediation was exactly that, so the person was
locked out. The coordinator chose the review's option (b): it is a NOTE now.
The guard stays (the variable is read for neither pin), each pin still verifies
from its own source, scaffold still refuses a new pair, and `validate-pins.py`
says the rest in one line. To put the finding back, restore 34dbd86's
`report.finding(...)` call in `_report_pin_source_collisions` (it is not a word
swap for `report.advise`: `advise` takes one string, `finding` a code and a
detail), give the finding the remediation 34dbd86 had, and turn the tests that
expect the note (and `test_a_project_declaring_a_colliding_pair_takes_a_shape_update`)
back into exit 1; the first round's versions of all of them are on 34dbd86.

- **Proved on a real old project.** Main (ab623dd) scaffolded the pair into
  local bare remotes; `update-shape.py apply` to the first round's head 34dbd86
  exited 2 `REFUSED update-validators-red` and rolled back (`git branch` shows
  only `main`); to this head it exits 0, the project's validator passes and
  prints the note, and the doctor's `pins` row is `ok` before and after. The
  doctor prints `passes` for a validator that exits 0 and does not echo its
  stdout, so the note itself is visible in `update-shape.py apply`'s output and
  in `validate-pins.py`'s, and the verdict moves for no reason but the update
  (`COMPLIANT, SHAPE BEHIND` to `COMPLIANT`). It is now a test:
  `test_a_project_declaring_a_colliding_pair_takes_a_shape_update`, over local
  stand-ins beside the project, so no `gh` is asked.
- **Collisions by the variable.** Pairs were decided by `casefold()`, but
  the pin file is `lower()`. `openß`/`openss`, `openſ`/`opens`, `openfi`/`openfi`
  and `openς`/`openσ` (the letters are U+00DF, U+017F, U+FB01 and U+03C2 beside
  U+03C3) share a variable and have two pin files, and were never found. They
  are now, from the pin-file fold (`pin_product_key`). A pool of 31
  names is checked pairwise against `neutral_pin_path` and
  `pin_source_env_name` themselves, and no single product is ever a collision.
  They are reachable only from a hand-written `project.yaml`.
- **Windows.** The guard's only test needed the `#!` `gh` stub and was
  skipped on Windows. `test_a_shared_variable_answers_for_neither_pin_with_no_gh`
  puts a checkout of each pin beside the project, names one in the variable, and
  needs no `gh`: on ab623dd it is `neutral-pin-source-unreadable`, here each pin
  reads its own sibling. The hint and the non-ASCII rows are tested in-process,
  with no skip and no filesystem.
- **The hint.** For a product two pins share, the skip hint offers the keyed
  `--pin-source <product>=<path>`: the bare form answers for every pin with no
  more specific source, so with two pins it gives the other the finding this
  issue is about.
- **Trailers and title.** The trailers of the earlier commits (a30091f and
  34dbd86 name Sonnet 5.5) are settled in this message, which carries Opus 5.5.
  The PR title now names `repo_shape.py`.
- `scaffold-project.py` is not touched this round: `fix/adopt-pin-writes-pin-file`
  moves that code, and the change would only conflict with it.

**Left out.**

- `adopt-project.py` is untouched. Its half arrives with #273's PR, which moves
  scaffold's pin machinery into a shared module and routes adopt through
  `pin_source_env_collisions`. Once it lands, the one place that still keys by
  `casefold()` (`pin_variable_collisions` in `scripts/shape_materialize.py`)
  should read `pin_product_key`; it cannot differ for an ASCII `--pin`, which
  is all `PIN_ARG_RE` takes.
- `scaffold-project.py`'s own `_colliding_pin_variables` also keys its
  argument lists by `casefold()` and one docstring there still says the
  project's validator reports the pair "as a finding"; both go with the code
  when #273 moves it. #312's `PinSources` keys `--pin-source` by `casefold()`
  too, so `openß=` and `openss=` are one key there; that is #312's.
- A product in `neutral_product_pins:` and a chain link in
  `naming.referent_chain` that share a variable are read by two validators in
  one process, and neither sees both sets. Not detected here.
- The doctor does not print a passing validator's stdout, so its `pins` row
  does not carry the note. Not changed: it would add a file to this PR.
- No `--help` text changed, so `docs/cli.md` is untouched. `README.md` is at its
  line cap and its blob is pinned by `docs/handbook.html`, so the one README
  sentence this wants needs a regenerated handbook and waits for its own change.
- `validate-pins.py` and `repo_shape.py` ship into projects, so every project's
  `shape-pin.yaml` reads `upstream-changed` for both until `update-shape.py
  apply`, which now succeeds for a project that declares such a pair.

**Evidence.** All on the head (6494714), with git-filter-repo 2.47.0 on PATH, a
`gh` that cannot be reached (a stub first on PATH, `GH_TOKEN` bogus, an empty
`GH_CONFIG_DIR`), and the repository-local `user.name` blanked at command-line
scope, as the standard's checkout sets one that two scaffold tests read.

- `tests/test_pin_source_env_collisions.py` is 137 tests and passes on this
  head. Laid over 34dbd86, 50 fail and 87 pass: the note and update tests, the
  non-ASCII rows, the hint, the sibling guard, and `pin_product_key` itself
  (which 34dbd86 does not have); the scaffold refusals, the chain readers and
  the controls hold on both.
- Targeted, run on ad6f569, whose tree differs from this head in
  `tests/test_pin_source_env_collisions.py` only (the last edits made one
  comparison read the committed blob and wrapped one line): the new file,
  `test_pin_validator`, `test_neutral_pin_validator`, `test_cli_reference`,
  `test_manifest_validator`, `test_naming_policy` and `test_repo_hygiene`
  **445 passed**; the scaffold pin, collision, referent-chain, leg-path and e2e
  files **245 passed**; `tests/test_update_shape*.py` **218 passed, 3 skipped**
  (junctions, NTFS only); `tests/test_shape_doctor.py` **254 passed, 3 skipped**
  (the PowerShell line, a Windows host only).
- On this head itself: the new file, `test_repo_hygiene`, `test_cli_reference`,
  `test_pin_validator` and `test_neutral_pin_validator` **244 passed**.
- This head merged with origin/main a983e6a, in a throwaway worktree: the new
  file, `test_pin_validator`, `test_neutral_pin_validator`, the scaffold pin,
  collision, referent-chain and e2e files, the whole doctor file and
  `test_cli_reference` **590 passed, 3 skipped** (the PowerShell line); the new
  file alone, in its final form, **137 passed**. origin/main had moved twice
  since; the merge-tree below is against it.
- Twelve mutation probes, each hunk reverted alone: all caught. The
  note-to-finding one is caught by the update test (a committed mutant, since the
  test clones the repository's HEAD) and the guards by the sibling test, run with
  every test that needs a `#!` stub deselected as a Windows runner would;
  the hint one by an in-process test.
- Nothing ran on macOS or Windows before this PR's CI.

**Merge-tree, as computed during the fix round on 2026-10-12** (every sha and
head is the one it had then; main and the other branches have moved since).
`git merge-tree --write-tree` of this head (6494714) against origin/main 1f90a0f
is clean, and so is the merge of origin/main a983e6a, which the merged-tree runs
above used. #312, #309, #270, #268, #228, #245 and #351 have landed. The rest
were computed on ad6f569 (the same tree but for one test file) against
a983e6a. Open PRs touching this branch's files, all clean against the head: #314, #355
and #373 (`scaffold-project.py`), #343 (`validate-pins.py`), #390 (`repo_shape.py`)
and #394 (`repo_shape.py`, which conflicts with origin/main by itself). The other 25 open PRs and the lane-5 branches
`fix/adopt-check-link-and-manifest`, `fix/scaffold-free-text-values`,
`fix/family-init-values`, `docs/adopt-execute-exits`,
`fix/yaml-flow-brackets-surrogates` and `fix/adopt-pin-writes-pin-file`
(#273's, 7a94b6b, also clean for this head) are clean too. #356, #382 and
`fix/adopt-record-submodule-placement` conflict with origin/main and not with
this head; #358, `fix/adopt-late-failure-notice` and
`fix/adopt-plan-quote-yaml-keywords` conflict with both, with origin/main by
themselves, so they are not this branch's. Neither are the five PRs below #200
that origin/main has not landed (#94, #96, #98, #100 and #161), which conflict
with it too. The PR set is read from the refs under `refs/pull/` that
origin/main's history has no `(#n)` subject for, not from GitHub, so a closed
one may be in it.

**One conflict, with `fix/link-source-values` (#338, 40762ca) in
`scripts/repo_shape.py`, four hunks.** Not resolved in either branch; take both
sides: (1) `resolve_link_source`'s signature, `default_source=None,
env_shared: bool = False, *, tried: list | None = None`; (2) its docstring, both
paragraphs; (3) `link_pins_from_trees`'s docstring, both paragraphs; (4) its
call, `default_source, pin_product_key(name) in shared, tried=asked)` followed
by #338's `if tried is not None: tried[name] = asked`. The auto-merged
`resolve_link_source` body already composes: a shared variable is never an
`env` candidate and so never appears in #338's `tried`.

**Known limits after the re-verification (APPROVE WITH NITS, 0 blockers, 0 should-fixes, 3 nits, at 6494714).** For the four non-ASCII pairs this change now detects (`ß`/`ss`, `ſ`/`s`, `fi`/`fi`, `ς`/`σ`), the keyed `--pin-source <product>=<path>` form does NOT separate the two pins: #312's source lookup folds both keys into one, so one pin can get a false `unreadable` finding. A sibling checkout does separate them. Only a hand-written `project.yaml` can declare such a pair. The doctor's `pins` row still prints only "passes" for a validator that passes, so it does not carry the collision note; that needs `shape-doctor.py`, and is left for a follow-up. The wording for restoring the finding is under "Fix round 1" above: it is 34dbd86's `report.finding(...)` call, not a word swap.

Reviewed adversarially at 34dbd86 (CHANGES NEEDED: 0 blockers, 2 should-fixes, 5 nits). The first should-fix was the finding itself, which locked a project that already declares the pair out of every `update-shape.py apply`; the second was a description that had gone stale. Fix round 1 (6494714) made the pair a NOTE, which is the coordinator's decision and one Brett may reverse (the way back is above), keyed collisions by the pin file's own fold, gave the guard a test that runs on Windows, and made a shared product's skip hint offer the keyed `--pin-source`. Its re-verification at 6494714 found every finding handled: APPROVE WITH NITS (0 blockers, 0 should-fixes, 3 nits: the keyed form and the non-ASCII pairs, the doctor's silence about the note, and the wording for restoring the finding, each stated above). CI is green on Linux, macOS and Windows, with SonarCloud passing, at b70a07d (main a73b4e9 merged in). Its merge with main 5a25387 passed the targeted set (the six pin and scaffold test files, `tests/test_validate_pins_unreadable_workflow.py`, `tests/test_ruleset_hint_tracking_branch.py` and `tests/test_shape_doctor.py`): 850 passed, 3 skipped.

Closes #275

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