Skip to content

adopt-project.py: every failure once a leg repository exists flushes stdout, then prints REFUSED <step>., what failed and one notice of what the run left, so a failed git filter-repo no longer exits 2 with an empty stderr (#171, #188, #177) - #445

Merged
brettheap merged 46 commits into
mainfrom
fix/adopt-late-failure-notice
Oct 12, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-1 (openRepoShape-1)
Claim: #188 (comment)
Claim: #177 (comment)
Claim: #171 (comment)

Described at head 92c72c1, which contains main 92e29d9. The pull request's head is 9f19afe: gh pr update-branch merged main cb7063f into 92c72c1 (2534d85), then main dd8d0ea (9f19afe), both cleanly.

What

The change is in adopt-project.py, plus one sentence of AGENTS.md. Everything else is tests.

Every failure once a leg repository exists is reported where it happens (#171, #188). run only raises. On main, _build_the_legs returns None in silence, so a failed git filter-repo ends with exit 2 and an empty stderr, with both leg repositories made (#171). Everything after the legs falls through to a bare REFUSED <step>, or to main's generic handler, printed ahead of the bare lines in a merged log (#188). Now every step that runs once a leg repository exists reports its own failure the same way:

  1. stdout is flushed first;
  2. then REFUSED <step>. and what failed: for a command, the exact command, where it ran, its exit code and its output;
  3. then one notice of what the run left.

The steps are:

  • creating the code leg repository, once the spec leg's exists;
  • extracting or seeding the <role> leg;
  • pushing the <role> leg, or pushing the seeded <role> leg;
  • cloning the source for the split;
  • mounting the legs;
  • committing the split;
  • pushing the split branch;
  • verifying the split.

A closed stdout cannot swallow the report: the flush ignores the BrokenPipeError.

Not only a failed command (#184 P3-1, #188 N3). Three more kinds of failure are reported the same way, and exit 2:

On main, each of these escapes, either as a refusal with no notice or as a traceback with exit 1. A Refusal keeps its own remediation, unless that is the validators' generic remediation about pins. Anything else, a defect or Ctrl-C, gets STOPPED <step> by <Type>. and the notice, and is then re-raised with its traceback.

One notice, _nothing_rolled_back. It:

When the code leg's repository cannot be created after the spec leg's was, the notice names the spec repository alone, "and nothing is pushed to it". main then exits 2 without printing the failure a second time. That holds for a gh that cannot be run at all, too (#188 N4).

execute refuses, before it creates anything, what an earlier run left behind. These are new refusals, in this order:

Every path these messages name is spelled as a finding spells it (#212). Main's 5751460 has check and execute name a path in a FINDING or a refusal with _spelled: between backticks, with a backslash escape for a backslash, a backtick and any character str.isprintable refuses. This pull request's own messages do the same (e507c42):

  • the notice: the leg repositories, the work directory, and what it holds;
  • the notice's split line: the assembly and the branch;
  • adopt-work-dir-occupied: the work directory and what it holds;
  • adopt-branch-exists: the assembly and the branch.

A leg repository under --local-remote-dir, the work directory and a local source are paths a person chose. One whose name holds a line feed, U+0085 or U+2028 split the notice's one line, and a carriage return or an ESC overwrote it. Now a path reads the same in a finding, in the notice and in the two refusals. The branch deletion is not spelled: it is a command to paste, so it stays quoted for sh (shlex.quote). Two texts that this pull request prints but does not write keep main's raw spelling, as 5751460 left them: the REFUSED block's command and its (in <dir>) line (CommandFailed.loudly, scripts/shape_materialize.py), and leg-remote-exists' <bare> already exists.

A failed verification (#188 N1) flushes stdout before its findings, so they come after every line the run printed. Its exit is still to fix the plan and re-run, unless a finding names another exit. It is followed by the notice and by the split branch this run pushed, with the command that deletes it. It still exits 1. The shape-file finding's own exit now points there: "send that path to a leg or drop it, and re-run as the end of this report says". A verification that cannot be made at all (review N3), such as a git that will not list a tree, gets REFUSED verifying the split., the notice and the branch, and exits 2.

#177 and #188 N2: a refused leg push prints the adoption's own hint. Both leg builders print ADOPT_RULESET_HINT, not the scaffold's RULESET_HINT. The scaffold's hint does not fit an adoption:

  • Its second exit, git -C <work> push -u origin main:seed/scaffold, fails in a clone git filter-repo rewrote, with fatal: 'origin' does not appear to be a git repository.
  • It says "re-run this scaffold".
  • It says "listed above" of lines that a merged log prints after it.

The adoption's hint names ONE exit: an operator holding the bypass right re-runs the adoption, once what the notice below names is cleared. It says why the seed exit cannot finish an adoption: the leg is a repository this run created, and its first push is the one refused, so it has no main for a pull request to target. It prints no command. It ends "Under such a ruleset, that bypass is the fix the notice below asks for, never a change to the ruleset." (re-verification R4). The notice follows it. #177 asked for a test that runs the printed push. No push is printed now, so the tests assert that no command line is printed and that the refused leg remote holds no ref, as the hint says. The one command this pull request adds to the output, the branch deletion, is run by two tests exactly as pasted.

AGENTS.md step 7 (review F3) is main's exit-keyed text (#304, a983e6a), and its exit 1 way out says what the tool prints: "so fix the plan, delete the split branch, and re-run into fresh legs and a fresh --work-dir, or none." That makes AGENTS.md 482 lines, one more than main's 481, and c2f3118 raises the cap in tests/test_repo_hygiene.py to 482 with a dated reason. A test reads each half of the step.

Ordering, so the notice is exact. The substitution table is built before the leg repositories are created, so its refusals leave nothing behind (an elected_on that is not a date, for example). Each leg builder reads its commit count and tree digest before it pushes. A read that fails after the push therefore cannot make the notice call a pushed leg unpushed.

After the split is pushed, a gh that cannot be run at all is handled like one that refused, after a stdout flush. The topic then gets its by-hand commands at once. The pull request gets main's by-hand command after the verification table, and only for a split that passed; one that failed or could not be verified gets "No pull request was opened, and none should be" instead (a983e6a).

With #200 (#174), the mount guard's refusals, adopt-mount-occupied and adopt-mount-unaddable, come after both legs are pushed, so they are late failures too. The run prints REFUSED mounting the legs., #200's own remediation, and the notice. The exit is 2, as on main.

Exit codes. A refusal exits 2, a failed verification 1 and a success 0, as on main. What changes is the failures that escaped as a traceback with exit 1 once a leg repository exists, an OSError or a gh that cannot be run among them. They are now reported, and execute exits 2. As on main, a failed verification's 1 outranks a missing topic. A run whose topic will not set and whose verification then cannot be made exits 2 with the topic's IS pushed commands and, after them, the notice; its way out is the notice's re-run, though AGENTS.md step 7 sends an exit 2 that says IS pushed to the printed command (rv188f's N-3, two failures in one run).

With main's own changes since the last round (5751460, d94d155, b30595c, 6d8d88b, 71b58a1, 346079d, a1b3a4b, f795cf7, 161c6c9, 1f90a0f and 92e29d9 are merged; Why, cases 9 to 14):

In this round's eleven merges, the definitions the late failures go through change only by main's own hunks: main() (06b5204), run() (60d08a1), _extract_leg's path listing and _mount_the_legs' git rm (6f45bf9), the materializer (71b58a1), _stage_the_split's git add (346079d), the split clone's --template= and the verification's mount-commit finding (e43fa95), Source.open and a refusal after _repository_urls (f795cf7), and the pull request step, _verify's NEXT and the end of cmd_execute (a983e6a). e507c42 changes how four of them spell a path, and 9be2b05 the reason adopt-work-dir-occupied gives.

Unchanged. scaffold-project.py, scripts/ (so the scaffold's RULESET_HINT and its callers), the setup scripts, templates, shape-doctor.py, update-shape.py, docs and README.md. AGENTS.md changes in step 7 only. git diff 92e29d9 bbdd313 --stat lists AGENTS.md, adopt-project.py, tests/conftest.py and eight test files, and nothing else.

A successful adoption is byte-identical to main's. A branch at the source tip is pushed over and verifies on both trees (case 6).

Commits.

Main was merged nineteen times, never rebased:

Why: before and after, on main 92e29d9

Each case ran on main 92e29d9 and on this branch, from the same driver.

  • Every repository is local: bare remotes under --local-remote-dir, and GitHub only through a simulator (url.<dir>.insteadOf plus a stub gh).
  • Every run has its own GIT_CONFIG_GLOBAL and GIT_ALLOW_PROTOCOL=file.
  • The source is a three-file repository <T>/Thing, and the plan is plan --source <T>/Thing --project Northwind --org testorg --elected-by T --elected-on 2026-09-02 --out <T>/p.yaml.
  • <T> is the run's own directory.

Unless a case says otherwise, the command is:

python3 adopt-project.py execute --plan <T>/p.yaml --yes --local-remote-dir <T>/remotes --work-dir <T>/work

1. git filter-repo fails after both leg repositories exist (#171). A stub git-filter-repo first on PATH prints fatal: stub git-filter-repo refuses and exits 1.

main, exit 2, stderr 0 bytes:

  topics skipped for local remotes

creating the leg repositories
  bare  <T>/remotes/Northwind-spec.git
  bare  <T>/remotes/Northwind-code.git

This branch, exit 2, the same stdout, then on stderr:

REFUSED extracting the spec leg. THE EXACT COMMAND WAS:
    git filter-repo --paths-from-file <T>/work/spec-paths.txt --force
    (in <T>/work/Northwind-spec)
    exit 1
--- output ---
fatal: stub git-filter-repo refuses
--- end output ---
NOTHING has been rolled back: `<T>/remotes/Northwind-spec.git` and `<T>/remotes/Northwind-code.git` exist, and neither leg is pushed. Once what failed above is fixed, re-run the adoption with them deleted or with fresh ones (a new --local-remote-dir, or new leg repositories): a re-run that meets them is refused, as `leg-remote-exists` under --local-remote-dir, and against GitHub by `gh repo create`, which finds the name already taken. Use a fresh --work-dir as well, or none: `<T>/work` holds `Northwind-spec` from this run, and `execute` refuses a --work-dir that holds them.

Then rm -r <T>/remotes, the real git filter-repo back on PATH, and the same command:

  • main: exit 2, both bare lines again, stderr 0 bytes, and both leg repositories made again.
  • This branch: exit 2, stdout 0 bytes, and the remotes directory is not created:
REFUSED adopt-work-dir-occupied: --work-dir `<T>/work` already holds `Northwind-spec`, where this run builds the spec leg, and only after it has made both leg repositories: `git clone` refuses a directory that is not empty
Remediation: no leg repository was created and nothing was pushed. Pass a --work-dir that is new or empty, or none: without one, each run makes a new one.

2. The split push is refused (#188). A pre-receive hook, installed through core.hooksPath, refuses refs/heads/adopt/* on the source. The command is run as ... > log 2>&1.

  • main, exit 2: the log's lines 0-9 are REFUSED pushing the split branch. THE EXACT COMMAND WAS: … --- end output ---. Only then do the run's own lines come, with the bare lines on 13-14. There is no notice.
  • This branch, exit 2: the bare lines are on 3-4 and the REFUSED block on 8-17. Line 18 is the notice, "NOTHING has been rolled back: <T>/remotes/Northwind-spec.git and <T>/remotes/Northwind-code.git exist, and both legs are pushed. Once what failed above is fixed, …".

3. A leg push is refused (#177, #188 N2). The hook refuses refs/heads/main on the leg remotes; > log 2>&1.

main, exit 2, after the REFUSED block:

If the organisation applies a ruleset requiring changes to arrive by pull
request, a direct push to the default branch is refused BY DESIGN and must not
be worked around. Two legitimate exits:

  (1) have an operator holding the bypass right seed the default branch once,
      then re-run this scaffold; or
  (2) push a seed BRANCH and open a pull request:
          git -C <T>/work/Northwind-spec push -u origin main:seed/scaffold
          gh pr create --repo testorg/Northwind-spec --base main --head seed/scaffold \
              --title 'Seed the spec leg' --body 'Scaffolded shape.'

NOTHING has been rolled back. What already exists is listed above; delete it
by hand if you want a clean re-run.

The printed push, run as printed:

$ git -C <T>/work/Northwind-spec push -u origin main:seed/scaffold
fatal: 'origin' does not appear to be a git repository

It exits 128. This branch, exit 2, after the REFUSED block. No git -C line is printed:

If the organisation applies a ruleset requiring changes to arrive by pull
request, a direct push to the default branch is refused BY DESIGN and must not
be worked around. An adoption has ONE legitimate exit: have an operator
holding the bypass right re-run it, once what the notice below names is
cleared. The scaffold's other exit, a seed branch and a pull request, cannot
finish an adoption: testorg/Northwind-spec has no main for that pull request to target,
and nothing would run the rest of this adoption after it was merged. Under
such a ruleset, that bypass is the fix the notice below asks for, never a
change to the ruleset.

NOTHING has been rolled back: `<T>/remotes/Northwind-spec.git` and `<T>/remotes/Northwind-code.git` exist, and neither leg is pushed. Once what failed above is fixed, …

4. The verification fails (#188 N1, review F1). The plan keeps the source's own contracts/shape-pin.yaml in the root; > log 2>&1.

main, exit 1. The FINDING is on line 0 and the footer on line 2, above every line the run printed (the last on 21):

1 verification finding(s). The legs and the branch exist; NOTHING was deleted from the source, so the exit is to fix the plan and re-run into a fresh --local-remote-dir or fresh leg repositories, unless a finding above names another exit.

This branch, exit 1. The run's lines are 0-18, and the FINDING is on 19, ending "… send that path to a leg or drop it, and re-run as the end of this report says. …". Then:

1 verification finding(s). NOTHING was deleted from the source, so the exit is to fix the plan and re-run, unless a finding above names another exit.
NOTHING has been rolled back: `<T>/remotes/Northwind-spec.git` and `<T>/remotes/Northwind-code.git` exist, and both legs are pushed. Once what failed above is fixed, … Use a fresh --work-dir as well, or none: `<T>/work` holds `Northwind-spec`, `Northwind-code` and `Northwind` from this run, and `execute` refuses a --work-dir that holds them.
The split is pushed to `<T>/Thing` too, as `adopt/three-repo-shape`, and a re-run is refused (`adopt-branch-exists`) until that branch is deleted:
    git -C <T>/Thing push <T>/Thing --delete adopt/three-repo-shape

Next, main's exit, followed as written: the plan fixed, then execute --plan <T>/p.yaml --yes --local-remote-dir '<T>/remotes 2' --work-dir '<T>/work 2', with the branch left in place.

  • main: exit 2. It makes and pushes both legs in <T>/remotes 2, then fails with REFUSED pushing the split branch (! [rejected] HEAD -> adopt/three-repo-shape (non-fast-forward)).
  • This branch: exit 2, stdout 0 bytes, and <T>/remotes 2 is not created:
REFUSED adopt-branch-exists: `<T>/Thing` already has `adopt/three-repo-shape`, the branch this adoption pushes its split commit to, at 2a94424d525f: neither the source commit 2a7e80fec913 nor a commit behind it
Remediation: no leg repository was created and nothing was pushed. … If it verified and its pull request is the adoption you want, review that instead of re-running. If the branch is not an earlier run's (the plan's `adopt_branch:` names one of the source's own), give `adopt_branch:` a name no branch has. Otherwise delete it, then run `execute` again:
    git -C <T>/Thing push <T>/Thing --delete adopt/three-repo-shape

The tests follow the rest of the way out as written: the deletion, pasted, then execute again. That ends in adoption verified.

5. A symlink loop the plan keeps in the root (#188 N3). The source holds .github -> .github, answered root, and check exits 0 with plan ok.

  • main, exit 1, a traceback ending OSError: [Errno 40] Too many levels of symbolic links: '<T>/work/Northwind/.github/workflows', with both legs pushed.
  • This branch, exit 2:
REFUSED committing the split. OSError: [Errno 40] Too many levels of symbolic links: '<T>/work/Northwind/.github/workflows'
NOTHING has been rolled back: `<T>/remotes/Northwind-spec.git` and `<T>/remotes/Northwind-code.git` exist, and both legs are pushed. Once what failed above is fixed, …

6. A branch named adopt/three-repo-shape at the source's own tip (review N1). git -C <T>/Thing branch adopt/three-repo-shape, then the command. Both trees exit 0 with adoption verified: every source path is in exactly one place.

7. A defect inside the verification (re-verification R2). main() is run in-process, with _shape_file_findings replaced by a function that raises TypeError.

  • main: the TypeError escapes main(), and nothing is printed to stderr.
  • This branch: the TypeError is re-raised, after:
STOPPED verifying the split by TypeError: stand-in for a defect: 'NoneType' object is not subscriptable.
NOTHING has been rolled back: `<T>/remotes/Northwind-spec.git` and `<T>/remotes/Northwind-code.git` exist, and both legs are pushed. Once what failed above is fixed, re-run the adoption with them deleted or with fresh ones …
The split is pushed to `<T>/Thing` too, as `adopt/three-repo-shape`, and a re-run is refused (`adopt-branch-exists`) until that branch is deleted:
    git -C <T>/Thing push <T>/Thing --delete adopt/three-repo-shape

8. An org/repo source, and the code leg's repository cannot be created (#188 N4, re-verification R3). This runs in simulated GitHub mode, as execute --plan <T>/adoption-plan.yaml --yes --source rv188org/Northwind --work-dir <T>/W, with the code leg's gh repo create refused (HTTP 502).

  • main: exit 2, REFUSED a git or gh command failed. THE EXACT COMMAND WAS: gh repo create rv188org/Northwind-code …, and no notice.
  • This branch: exit 2.
REFUSED creating the code leg repository. THE EXACT COMMAND WAS:
    gh repo create rv188org/Northwind-code --private --description Northwind — code leg, extracted from rv188org/Northwind with history
    exit 1
--- output ---
HTTP 502: stub refuses the code leg
--- end output ---
NOTHING has been rolled back: `https://github.com/rv188org/Northwind-spec.git` exists, and nothing is pushed to it. Once what failed above is fixed, re-run the adoption with it deleted or with a fresh one (…): … Use a fresh --work-dir as well, or none: `<T>/W` holds `source-readonly/Northwind` from this run, and `execute` refuses a --work-dir that holds them.

The same --work-dir <T>/W again gives REFUSED source-unclonable: … destination path '<T>/W/source-readonly/Northwind' already exists, on both trees. This branch's notice followed as written (the spec repository deleted, the cause gone) verifies: with a fresh --work-dir, exit 0, and with none, exit 0.

9. With main's 06b5204: a name stdout cannot encode. Every path is under <T>/日本 run. adopt-project.py's own stdout is cp1252 (strict, and Windows' default surrogateescape), and git-filter-repo keeps UTF-8.

  • The case-1 stub: main exits 2 with stdout escaped (bare <T>/日本 run/remotes/Northwind-spec.git) and stderr 0 bytes. This branch exits 2 with the REFUSED block and the whole notice on stderr.
  • Case 4: this branch exits 1 with the finding, the footer, the notice, "Use a fresh --work-dir as well, or none: <T>/日本 run/work holds Northwind-spec, Northwind-code and Northwind from this run …", the branch line and git -C '<T>/日本 run/Thing' push '<T>/日本 run/Thing' --delete adopt/three-repo-shape. 日本 is printable, so _spelled keeps it, and stderr's backslashreplace escapes it.
  • In both cases, this branch's stderr under cp1252 is identical to its stderr under UTF-8, once 日本 is read back as 日本.
  • Before this merge, at 5a8b649, both runs exited 1 with UnicodeEncodeError at print(f" bare {bare}"), with one leg repository made and no notice.

10. With main's 60d08a1: a refusal that names a non-ASCII path. The hook refuses the spec leg's main with ruleset: Übersicht.md changes must arrive by pull request, and the command runs under LC_ALL=C PYTHONUTF8=0.

  • main: exit 2, REFUSED pushing the spec leg., git's output (remote: ruleset: \xdcbersicht.md …), the scaffold's two exits, and no notice.
  • This branch: exit 2, the same REFUSED block, the one-exit hint and the notice.
  • At 5a8b649, before this merge, it was REFUSED extracting the spec leg. UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 17: ordinal not in range(128) and the notice, with no hint.

11. With main's aaa40eb: a .gitmodules the history makes unreadable. This uses main's own fixtures. The history adds and removes vendor/b, the tip .gitmodules is [submodule "b"] with a bare path, and the plan sends .gitmodules to code.

  • main and this branch alike: check exits 1 with FINDING plan-gitmodules-unreadable: ….
  • execute exits 2 with REFUSED plan-gitmodules-unreadable: 1 finding(s) … and "Remediation: no leg repository was created and nothing was pushed.". Its stdout is 0 bytes and the remotes directory is never made, so there is no notice. The two logs are identical once commit names are masked.
  • At 5a8b649, check printed plan ok, and execute made both legs and died in git filter-repo (error: missing value for 'submodule.b.path'). This branch reported that with the notice.

12. With main's c2ff2b0: a .gitmodules value git's submodule reader refuses. This uses main's own fixtures for #215: a gitlink at the tip and update = bogus in .gitmodules, and the plan sends .gitmodules and vendor/ to code.

  • main and this branch alike: check exits 1 with FINDING plan-gitmodules-unreadable: git cannot read the source's .gitmodules for its submodules (invalid value for submodule.b.update: bogus, which is not one of checkout, rebase, merge, none), ….
  • execute exits 2 with REFUSED plan-gitmodules-unreadable: 1 finding(s) … and "Remediation: no leg repository was created and nothing was pushed.". Its stdout is 0 bytes and the remotes directory is never made, so there is no notice. The two logs are identical once commit names are masked.
  • At e507c42, before this branch merged c2ff2b0, check printed plan ok, and execute made both legs and died in the extraction:
REFUSED extracting the spec leg. THE EXACT COMMAND WAS:
    git filter-repo --paths-from-file <T>/work/spec-paths.txt --force
    (in <T>/work/Northwind-spec)
    exit 1
--- output ---
fatal: invalid value for 'submodule.b.update'
Error: fast-export failed; see above.
…
--- end output ---
NOTHING has been rolled back: `<T>/remotes/Northwind-spec.git` and `<T>/remotes/Northwind-code.git` exist, and neither leg is pushed. Once what failed above is fixed, … Use a fresh --work-dir as well, or none: `<T>/work` holds `Northwind-spec` from this run, and `execute` refuses a --work-dir that holds them.

13. With main's f795cf7: a local source under a directory whose name is not UTF-8. The source is <T>/caf\xe9/Thing, where 0xE9 is a byte that is not UTF-8. plan and check exit 0 on every tree.

  • main (f795cf7 to 92e29d9) and this branch alike: execute exits 2, prints nothing on stdout, and makes no remotes directory:
REFUSED adopt-unwritable-value: under --local-remote-dir the assembly root's clone URL is the source's own path, '<T>/caf\udce9/Thing', which `execute` writes into its `README.md`, and it holds a byte that is not UTF-8
Remediation: nothing was created. Rename or move the source repository so that its path is one line of UTF-8 text with no control character in it, then run `plan --source <its new path>`, `check` and `execute` again; …

It is raised before any leg exists, so there is no notice. The two logs are identical once the source's commit is masked.

  • At 9be2b05, before this branch merged f795cf7, execute made and pushed both legs, and write_lf raised while the split was committed. Exit 2:
REFUSED committing the split. UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9' in position 665: surrogates not allowed
NOTHING has been rolled back: `<T>/remotes/Northwind-spec.git` and `<T>/remotes/Northwind-code.git` exist, and both legs are pushed. Once what failed above is fixed, … Use a fresh --work-dir as well, or none: `<T>/work` holds `Northwind-spec`, `Northwind-code` and `Northwind` from this run, and `execute` refuses a --work-dir that holds them.
  • On main a1b3a4b the same run made and pushed both legs too, then exited 1 with a UnicodeEncodeError traceback from write_lf, and no notice.

14. With main's a983e6a: gh pr create refused. GitHub mode, built with main's own helpers from tests/test_adopt_github_exits.py: a stub gh that refuses pr create, https://github.com/ rewritten to bare repositories, a bogus token and an empty config directory. Stdout and stderr are merged.

  • The split verifies: main and this branch alike exit 2 and end with adoption verified, then The branch IS pushed. Open the pull request by hand: and the command. Here the REFUSED block comes after the split line, because stdout is flushed first; on main it is the log's first line.
  • The verification fails (the source's own pin kept in the root): both exit 1 and end with No pull request was opened, and none should be: the split above did not pass verification. This branch prints its notice and the split branch's git ... --delete before that line.
  • The verification cannot be made (_tree_of raising OSError: [Errno 28] No space left on device once _verify has begun): main exits 1 with a traceback, and says nothing of the pushed legs or split. This branch exits 2 with REFUSED verifying the split. OSError: [Errno 28] No space left on device, the notice, the split branch and its deletion, and then the no-pull-request line.

Tests

New files.

  • tests/test_adopt_filter_repo_failure.py, 11 tests, from adopt-project.py: execute prints the command and its stderr when a leg cannot be built, so a git filter-repo that fails after both leg remotes exist no longer exits 2 with an empty stderr (#171) #184, reworked. They cover:
  • tests/test_adopt_late_failure.py, 86 tests:
    • Each split step fails: the clone and the commit for real, the mount through a stub, the push to a missing remote.
    • The notice's states. Four failures that are not a command, on a leg. The materializer's OSError on the split.
    • Interrupted leg, split and verification (STOPPED). A defect against an interrupt, at four points (R2).
    • The source's own clone named in the work-directory sentence, at four points, after a refusal and after a defect. Also one --source testorg/Thing --work-dir W run through cmd_execute, where the code leg's repository fails (R3).
    • A leg pushed last. What main is left to report. The flushes, and a closed stdout. The gh steps' by-hand exits.
    • The one-exit hint, with its last sentence (R4).
    • adopt-work-dir-occupied for a file, a directory and the split, and never for a default, new or empty work directory.
    • adopt-work-dir-occupied's reason for each builder, through cmd_execute on a code-only source with --allow-empty-leg spec: an extracted leg, the split, the seeded leg, the seeded leg and the split, and an extracted leg and the split (one reason, given once). A seeded leg's directory holding only an ignored .DS_Store, an empty subdirectory or an empty repository is refused by the same rule (N-2).
    • adopt-branch-exists: its printed command run as pasted, from a source path with a space; its precedence below leg-remote-exists; asked of the assembly's URL; not refused for a branch at or behind the source commit.
    • Every path the notice, its split line, adopt-work-dir-occupied and adopt-branch-exists name is spelled (adopt-project.py check and execute print a source path raw in a FINDING, so a \r, \n, \x85, U+2028 or ESC in a name overwrites, splits or escapes the line on a terminal, where plan's questions spell the same path with _spelled #212): three tests, each over the five characters main's tests/test_adopt_finding_spelling.py asks of a finding (a carriage return, a line feed, U+0085, U+2028 and ESC). Each message is one printable line that holds the spelled path, and the deletion stays the command sh reads. They run in-process on POSIX paths that are never made, so every platform runs them.
    • A verification that cannot be made. The substitution table is built before the legs. AGENTS.md step 7.
    • End to end with git filter-repo, in one merged pipe as > log 2>&1: a refused split push and its way out; a refused leg push; a failed verification and its way out as review F1 followed it; and a branch at the source commit that is pushed over and verifies.

Changed.

Counts at bbdd313, both modes, /usr/bin/python3 -m pytest, run in separate clean trees.

  • With git-filter-repo on PATH: 3480 passed, 5 skipped.
  • With it hidden (PATH without ~/.local/bin, so command -v git-filter-repo finds nothing and git's exec-path has none): 3318 passed, 167 skipped.
  • At 92c72c1, which changes one sentence and its tests: tests/test_adopt_late_failure.py, tests/test_adopt_leg_path_collisions.py, tests/test_repo_hygiene.py, tests/test_shape_doctor.py and tests/test_scaffold_leg_path_collisions.py give 590 passed, 3 skipped with git-filter-repo and 580 passed, 13 skipped hidden. Merged with main 3ef059c in a scratch tree, tests/test_agents_md_codes.py, tests/test_adopt_manifest_values.py, tests/test_adopt_late_failure.py and tests/test_repo_hygiene.py give 262 passed and 245 passed, 17 skipped.

The set is 54 files: the 40 test files that load adopt-project.py (by its name, ADOPT or the adopter fixture), and fourteen of main's test files for what else this branch touches (conftest's run_script and its gh guard, shape-doctor.py, which loads adopt-project.py, and repo_shape.py's reader, which loads every plan).

tests/test_adopt_clone_template.py
tests/test_adopt_e2e.py
tests/test_adopt_entry_paths.py
tests/test_adopt_execute_refusals.py
tests/test_adopt_filter_repo_failure.py
tests/test_adopt_finding_spelling.py
tests/test_adopt_github_exits.py
tests/test_adopt_gitmodules_values.py
tests/test_adopt_history_gitlinks.py
tests/test_adopt_ignored_shape_files.py
tests/test_adopt_late_failure.py
tests/test_adopt_leg_path_collisions.py
tests/test_adopt_leg_paths.py
tests/test_adopt_literal_names.py
tests/test_adopt_mount_ignored.py
tests/test_adopt_plan.py
tests/test_adopt_plan_escapes.py
tests/test_adopt_relative_submodule_url.py
tests/test_adopt_root_shape_paths.py
tests/test_adopt_spec_only.py
tests/test_adopt_submodules.py
tests/test_adopt_template_values.py
tests/test_adopt_verify.py
tests/test_cli_reference.py
tests/test_doctor_legs_members_next.py
tests/test_family_add_refusals.py
tests/test_free_plan_hint.py
tests/test_gh_guard.py
tests/test_git_output_decoding.py
tests/test_load_yaml_refusals.py
tests/test_naming_policy.py
tests/test_reference_default.py
tests/test_repo_hygiene.py
tests/test_scaffold_e2e.py
tests/test_scaffold_free_text_values.py
tests/test_scaffold_leg_path_collisions.py
tests/test_scaffold_leg_paths.py
tests/test_scaffold_mount_ignored.py
tests/test_scaffold_pin_and_reuse.py
tests/test_scaffold_pin_collisions.py
tests/test_shape_advisory.py
tests/test_shape_doctor.py
tests/test_stdout_encoding.py
tests/test_template_quoted_values.py
tests/test_template_values_single_pass.py
tests/test_update_shape.py
tests/test_update_shape_add_ignored.py
tests/test_update_shape_landing.py
tests/test_update_shape_pr_base.py
tests/test_windows_paths.py
tests/test_yaml_block_scalar_keys.py
tests/test_yaml_flow_mappings.py
tests/test_yaml_flow_quotes.py
tests/test_yaml_quote_boundaries.py

Revert tables. Every new or changed test fails with its change undone. Each row undoes ONE change in a clean tree, with every anchor asserted to occur exactly once.

This round, measured at 918e8f2. Merges 922f2d8 and e162551 change no line of this branch's own diff, so these rows stood at e162551. Each row runs tests/test_adopt_ignored_shape_files.py, tests/test_adopt_late_failure.py and tests/test_adopt_filter_repo_failure.py: 119 tests, which all pass unmutated. The SP rows undo e507c42 (#212), one path at a time. The others re-measure the rows of 5a30b77 (SFE, FOOT), 48e93bb (R2a, R2b), 0d2256e (R4), and 03b381a and 5a8b649 (R3a-R3j). The tests each row fails are in the lane's log; every row but BASE fails at least one.

row change undone result
BASE nothing undone (baseline) 119 passed
SFE SHAPE_FILE_EXIT back to main's "re-run into fresh legs" 8 failed, 111 passed
FOOT the verification footer printed before the findings 1 failed, 118 passed
R2a R2 undone: an exception outside LATE_FAILURES is told only to re-run, as before 4 failed, 115 passed
R2b an interrupt told to fix what failed first 7 failed, 112 passed
R4 the hint's last sentence removed 1 failed, 118 passed
R3a _cloned_here never counts the clone 9 failed, 110 passed
R3b Source.open never records that it cloned 1 failed, 118 passed
R3c _create_leg_remotes' notice not handed the source 3 failed, 116 passed
R3d _build_the_legs' refusal handler not handed the source 1 failed, 118 passed
R3e _build_the_legs' other handler not handed the source 1 failed, 118 passed
R3f _commit_the_split's refusal handler not handed the source 1 failed, 118 passed
R3g _commit_the_split's other handler not handed the source 1 failed, 118 passed
R3h _left_by_the_split does not hand on the source 2 failed, 117 passed
R3i cmd_execute does not hand _create_leg_remotes the source 1 failed, 118 passed
R3j mutation, not a revert: the clone counted as occupying the work directory 6 failed, 113 passed
SPa the notice's leg repositories printed raw 18 failed, 101 passed
SPb the notice's work directory printed raw 29 failed, 90 passed
SPc what the notice says the work directory holds, printed raw 27 failed, 92 passed
SPd the split line's assembly printed raw 6 failed, 113 passed
SPe the split line's branch printed raw 6 failed, 113 passed
SPf adopt-work-dir-occupied's work directory printed raw 9 failed, 110 passed
SPg adopt-work-dir-occupied's names printed raw 9 failed, 110 passed
SPh adopt-branch-exists' assembly printed raw 6 failed, 113 passed
SPi adopt-branch-exists' branch printed raw 6 failed, 113 passed

9d8aadd, measured at e162551. Each row runs test_with_check_skipped_execute_stops_at_the_copy_and_overwrites_nothing, which passes unmutated:

row change fails
RTa the test as main wrote it 1: DID NOT RAISE Refusal
RTb mutation: _report_late prints the notice before what failed 1: the notice is not the last line
RTc mutation: _failed_loudly drops a refusal's own remediation 1: the reported block

90d6077, measured at the merge's tree 9570d24. With main's verify helper as main wrote it, the 12 tests of tests/test_adopt_clone_template.py that call _verify directly fail with TypeError: _verify() missing 2 required keyword-only arguments: 'urls' and 'branch'.

9be2b05, measured at 9be2b05. Each row runs the occupied-directory tests and the spelling tests of tests/test_adopt_late_failure.py, 17 tests, which all pass unmutated:

row change fails
N1a the reason back to git clone alone 2: the seeded leg, and the seeded leg with the split
N1b cmd_execute hands the refusal no seeded leg 2: the same two
N1c each directory's reason repeated 1: an extracted leg and the split
N1d the reasons sorted, not in the order the run builds 1: the seeded leg and the split

c2f3118, measured in trees at the merge f4ca72a with it applied (the cap test, the gh-step test's four cases and both step 7 tests, 7 passing): the cap back to 481 fails the cap test; the _open_it_by_hand call removed fails the two [...-pull-request] cases. The joined step 7 is held from both sides: main's text alone fails this branch's step 7 test, and this branch's old text alone fails main's.

92c72c1, measured in trees at bbdd313 with it applied (the 13 occupied-directory tests, all passing): the old sentence back fails 5, the two seeded reasons and the three new cases; _holds_something made to skip .DS_Store, .git and empty directories fails the three new cases.

The confirmation check at 918e8f2 (rv188f) added rows of its own. Fourteen targeted rows cover the 28 tests of the two new files that no row above fails: GHSTEP, SPLITOS, LIVELEG, TABLE, GHASK, HOLDS, HOLDS2, SUPPRESS, LEGREPORT, AGENTS, PUSHSEED, REMED, BUILDPRINT and GHFIRST. Every named test fails in every row.

Earlier rounds, as measured then. Counts only; each row undoes one change in a clean tree, and every test it names passes unmutated.

  • fix188e at cdfb308: 27 rows over 74 tests, each failing 1 to 15; the review round at d07e94a and 8d4fef4: 34 rows over 43 tests, each failing 1 to 21; 8d4fef4's F0: 3.

Platforms. CI has not run on this branch yet; it runs on pull requests. The Windows job installs no git-filter-repo, sets PYTHONUTF8=1, and honours WINDOWS_SKIP. With git-filter-repo hidden, this branch's tests give:

  • tests/test_adopt_late_failure.py: 82 of 86 run on windows-latest. All the in-process ones run. Among them are the #!/bin/sh pre-receive hook in test_a_refused_leg_push_offers_an_adoption_its_one_exit, which Git for Windows runs through its own sh, and the pasted git -C … --delete in test_a_split_branch_left_behind_is_refused_before_any_leg_exists. They also include R2's 8, R3's 9, the work-directory refusals (4 refused, 3 never refused), the 2 fast-forward cases, the 15 spelling tests, the 5 occupied-directory reasons and the 3 cases of N-2, and AGENTS.md step 7. The 4 that skip are the git-filter-repo end-to-end tests:
    • test_a_refused_split_push_is_logged_after_the_legs_and_its_way_out_runs;
    • test_a_branch_at_the_source_commit_is_pushed_over_and_verifies;
    • test_a_refused_leg_push_is_logged_after_the_legs_with_one_exit;
    • test_a_failed_verification_is_flushed_and_its_way_out_runs.
  • tests/test_adopt_filter_repo_failure.py: 9 of 11 run. test_a_failed_git_filter_repo_names_itself_on_stderr puts a #!/bin/sh stub on PATH, so it carries WINDOWS_SKIP, like tests/test_free_plan_hint.py's stub gh. test_following_the_notice_after_a_failed_leg_ends_adoption_verified needs git filter-repo.
  • tests/test_adopt_verify.py: the 14 direct _verify tests, whose fixture this branch changed, run. Its 2 end-to-end tests skip.
  • tests/test_adopt_ignored_shape_files.py: the 6 in-process tests that read PLAN_ANSWER run. Its end-to-end test skips.
  • tests/test_adopt_leg_path_collisions.py: the 3 changed mount-guard tests need git filter-repo and skip.
  • tests/test_adopt_root_shape_paths.py: the one changed test needs git filter-repo and skips.
  • tests/test_adopt_clone_template.py (main's): 14 of 31 run. The 12 tests that call the changed verify helper run in-process, and nothing in the file carries WINDOWS_SKIP. The 17 that skip need git filter-repo.

Paths as windows-latest spells them. _spelled escapes a backslash, which is Windows' path separator. So a test that reads a path raw (str(path) in err, or a path between backticks without the escape) passes on Linux and fails on Windows. On Linux a backslash is an ordinary file-name character, so the tests were run again with one in every temporary path (--basetemp '…/bt-bs/head\win'), with git-filter-repo hidden as on windows-latest:

  • the seven changed test files, tests/test_adopt_finding_spelling.py and tests/test_adopt_execute_refusals.py, at 9f1778d and at 9be2b05 alike: 498 passed, 50 skipped and 3 failed. The 3 are main's own tests in tests/test_adopt_clone_template.py, and they fail the same way on main f795cf7 and on a1b3a4b (that file alone: 3 failed, 11 passed, 17 skipped). Its operator helper writes template.as_posix() into a git config file. On Linux as_posix() keeps the backslash, which is a file-name character there, and git reads it as an escape: fatal: bad config line 2. On Windows as_posix() turns the separator into /. All 3 stop at that git call, before the verify helper this branch changes.;
  • every test file that names adopt-project.py, at 918e8f2: the same 54 failures and 4 set-up errors on this branch and on main 6d8d88b, none in a file this branch changes.

That run catches the mistake: with work_dir_named made to expect the raw path between backticks, 8 tests pass with a plain temporary path and the same 8 fail with the backslash.

No added line feeds text to git's stdin. Expected paths are built from Path objects, and the AGENTS.md test reads in text mode.

Not verified.

  • Real GitHub: gh repo create, an organisation ruleset, and git ls-remote against a GitHub URL. A local path or the simulator stands in for them.
  • macOS and Windows. They are covered by inspection and by the hidden-tool run above, until the pull request's CI runs.
  • The full suite. The lane runs the 54-file set above.

Reviews

Lane openRepoShape-2 reviewed PR #184 at f416e13 (comment):

Lane 2 reviewed this branch at 68d4d63 (comment):

  • N1, the unflushed verification and its looping exit: taken in d07e94a, and adopt-branch-exists added.
  • N2, the hint's seed exit: taken in d07e94a. Only exit (1) is offered, and a test reads the hint.
  • N3, non-command failures: taken in d07e94a.
  • N4, the second gh repo create: taken in d07e94a. The existing test now asserts the notice.
  • N5, the unquoted push: taken in d07e94a. The hint prints no push any more. The one command this pull request adds, the branch deletion, is quoted with shlex.quote, and the tests parse printed commands with shlex.split, on paths with spaces.

The lane's adversarial review at 8d4fef4 (CHANGES NEEDED):

  • F1 (BLOCKER), adopt-branch-exists's way out looping on the same --work-dir: 5dbda31 adds adopt-work-dir-occupied, ahead of every other refusal of what an earlier run left, and says "re-run as the end of this report says" in the shape-file finding. cdfb308 adds a test that reads that finding's exit.
  • F2, the notice never said to fix what failed first: 5dbda31. 48e93bb narrows "Re-run" alone to an interrupt.
  • F3, AGENTS.md step 7 naming an exit the tool refuses: b750950.
  • N1, a false claim about a branch the push fast-forwards: 837ccf9, with its adopt_branch: sentence read by a test in cdfb308.
  • N2, "already cloned into" when nothing was cloned: 5dbda31 reads the work directory off the disk. 03b381a and 5a8b649 add the source's own clone.
  • N3, a _verify failure with no notice: 855886d.
  • N4, a leftover code bare makes a spec repository first: left. It is main's behaviour. One repository is made, and the notice names it. Checking both bares first would change leg-remote-exists' precedence.
  • N5, in GitHub mode adopt-branch-exists comes before any leg check: left. It is a true refusal that makes nothing, and its remediation now says to review a verified run's pull request instead of re-running. The re-run it leads to meets gh repo create before anything is made.
  • N6, S1192 in the new tests: bbfcfd9.
  • N7, the mount guard's way out given twice: left. The two agree: MOUNT_REMEDIATION is adopt-project.py: the leg mount passes --force to git submodule add, so a source whose .gitignore is * + !.gitignore, or names spec or code, adopts instead of dying at the mount after both leg remotes were pushed (#174) #200's own, and the notice is common to every late failure.
  • N8, the Platforms paragraph: this body.
  • N9, 41ab8ca's message: left to the squash, as P3-4.

The re-verification at cdfb308 (CHANGES NEEDED on R1):

The confirmation check at 918e8f2 (rv188f: PR-READY, no BLOCKER and no SHOULD-FIX):

  • Its own runs at 918e8f2 gave this branch's numbers: 2610 passed and 2 skipped on 43 files with git-filter-repo, and 372 passed and 23 skipped with a backslash in every temporary path.
  • N-1 (NIT), adopt-work-dir-occupied giving git clone as the reason for a seeded leg, which is never cloned: 9be2b05.
  • Observations O1 to O6 are not findings: O1 and O2 are this branch's limits, described above, and O3 to O6 are main's.

rv188f's round H review (2fc1669, then 918e8f2..bbdd313: CHANGES NEEDED in this body only; its run at bbdd313, 3480 passed and 5 skipped, matched):

  • B1, B2, B3, three passages false at bbdd313 (step 7, the pull request's by-hand command, R5): replaced with its wording.
  • N-2 (NIT), the seeded-leg reason false for a directory holding only what git does not commit: the sentence states the rule, and the refusal is kept (92c72c1).
  • N-3 (NIT), two failures in one run: left, and stated under Exit codes.

Supersedes PR #184

This pull request supersedes PR #184. It carries #184's three commits for #171 (5cd06c7, 41ab8ca, f416e13), then the work for #188 and #177 on top of them.

Lane openRepoShape-2 found that #184 cannot land on its own:

Lane 2 suggested folding #184 into this pull request (comment). PR #184 can be closed in favour of this one.

Closes #171
Closes #188
Closes #177

🤖 Generated with Claude Code

brettheap and others added 30 commits October 9, 2026 03:06
…leg cannot be built, so a `git filter-repo` that fails after both leg remotes exist no longer exits 2 with an empty stderr (#171)

`_build_the_legs` caught `CommandFailed` from the leg extraction and returned
None on the premise that `run` had already said which command failed and why.
`run` only raises, so the human saw the two `bare .../Northwind-*.git` lines,
exit 2 and nothing on stderr, with both leg remotes already created and the
corrected re-run waiting at `leg-remote-exists`.

It now prints `exc.loudly(...)`, naming the verb (extracting or seeding), the
leg, the exact command and its output, the way the file's other callers do.
A refused push is the one failure `_extract_leg` and `_seed_leg` already print
themselves, with the ruleset hint, before they re-raise it; it is not printed a
second time.

tests/test_adopt_filter_repo_failure.py: a stubbed extraction and a stubbed
seeding that raise `CommandFailed` (no filter-repo needed), a real seeded leg
pushed to a remote that does not exist (one REFUSED block, the hint intact),
and, where filter-repo is installed, the issue's `.gitmodules` with a bare
`path` key through `execute` itself.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… leg repositories it leaves behind and stdout flushed first, and a refused push is recognised by a flag the leg builders set rather than by its argv (#171)

The review of 5cd06c7 asked for four changes, decided before this round.

S1. After the REFUSED block, `_build_the_legs` prints one line naming
`urls["spec"]` and `urls["code"]`: they may now exist, one leg may already be
pushed, nothing was rolled back, and a re-run of the corrected plan meets
`leg-remote-exists` unless they are deleted or fresh ones are used. stdout is
flushed first, so a log that merges the two streams keeps the order things
ran in: piped, the two `bare ...` lines were block-buffered and came out
AFTER the failure they explain. On the issue's reproduction, without the
flush REFUSED is line 0 of the merged log and the `bare` lines are 19 and 20;
with it the `bare` lines are 3 and 4 and REFUSED is 5.

N1. The `args_list[:2] == ["git", "push"]` guard is gone. `_extract_leg` and
`_seed_leg` set `exc.reported = True` on the push failure they already print,
with the ruleset hint, and re-raise; `_build_the_legs` prints only when the
flag is absent. A push is no longer special-cased by its argv, so a future
step the builders print themselves needs one line, not a second guard.

N2. The end-to-end test appends the bare `path` section to the `.gitmodules`
that `git submodule add` wrote, instead of hand-writing the url: a Windows
path in a hand-written one needs git's own escaping for the backslash.

N3. The seeding case raises `git commit -q -F -`, the command a seeding leg
actually dies on, not a `git filter-repo`; the parametrisations carry
readable ids.

N4. The `_build_the_legs` docstring no longer mentions `gh` (the leg builders
only run `git`) and the test module's first docstring line fits in 79 columns.

tests/test_adopt_filter_repo_failure.py: the notice (both URLs, `rolled
back`, `leg-remote-exists`, `--local-remote-dir`, one line) for both leg
kinds and through `execute`; the flush, as events recorded on a stand-in for
each stream; and the refused push for BOTH builders, real `git` against a
remote that does not exist (the extraction skips only `git filter-repo`):
one REFUSED, one `NOTHING has been rolled back`, no `leg-remote-exists`. Each
fails with its change reverted; N2, N3 and N4 are about how the tests are
written and have no behaviour to revert on Linux.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…or both kinds of remote, `leg-remote-exists` under --local-remote-dir and a `gh repo create` that finds the name taken against GitHub (#171)

41ab8ca said a re-run of the corrected plan "meets `leg-remote-exists`". That
refusal is `_create_leg_remotes`' local branch only. Against GitHub the same
function runs `gh repo create <org>/<name>` for the spec leg first; `run`
raises when `gh` exits non-zero, nothing in `_create_leg_remotes` or
`cmd_execute` catches it, and `main` prints it as `REFUSED a git or gh command
failed` and exits 2, before the code leg is touched.

The notice now reads: "Re-running the corrected plan is refused until they
are deleted or fresh ones are used (a new --local-remote-dir, or new leg
repositories): under --local-remote-dir it meets `leg-remote-exists`, and
against GitHub `gh repo create` finds the name already taken." One sentence
for both, because `_build_the_legs` is not told which kind it is building for.
The `_build_the_legs` docstring says the same, naming `_create_leg_remotes`.

tests/test_adopt_filter_repo_failure.py: the notice test also asserts
`gh repo create` and `already taken`, and fails against 41ab8ca's text. Two
new tests pin the facts the sentence states from the code that makes them
true: an existing bare leg is a `leg-remote-exists` Refusal, and under a
GitHub remote the first call is `gh repo create` for the spec leg and its
CommandFailed is not swallowed. Nothing runs `gh` and nothing touches GitHub.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hes stdout, names its step and says what it left, and a refused leg push prints a seed push to the leg's own URL, so a refused split push no longer says only `REFUSED pushing the split branch` ahead of the `bare` lines, and the hint's push no longer fails with `'origin' does not appear to be a git repository` (#188, #177)

#188. Reproduced on main f25d805 with local bare remotes and a global
core.hooksPath pre-receive hook. With the split push refused, stderr was
only `REFUSED pushing the split branch`; in a merged `> log 2>&1` the
REFUSED line was line 0 and the two `bare` lines were lines 13-14; and the
re-run met `REFUSED leg-remote-exists` with nothing having said why. A
pre-commit hook refusing the split commit fell through to `main`'s
generic `REFUSED a git or gh command failed`, the same way.

`_commit_the_split` now wraps the clone, the mount, the commit and the
push. A failure names its step (`cloning the source for the split`,
`mounting the legs`, `committing the split`, `pushing the split branch`),
flushes stdout first, and prints the exact command and its output. Then
it prints the notice `_build_the_legs` prints (#171): both leg
repositories exist, both are pushed, and why a re-run is refused. A
refused split push also says that a split branch an earlier run left
behind refuses every re-run until it is deleted, because fresh legs alone
would loop there. The exit is still 2.

The opening of the pull request and the setting of the topic flush
stdout before they report. `main`'s two handlers flush too, for what no
step reports itself, such as a `git_out` Refusal once the legs exist.

One wording. `_nothing_rolled_back(urls, pushed)` replaces #171's inline
notice, so a leg that cannot be built and a split that cannot be made
share one sentence. It says how far the run got (neither leg, the spec
leg, or both legs are pushed) rather than "one leg may already be
pushed". It says "Re-running the adoption" rather than "Re-running the
corrected plan", because the cause is not always the plan.
`_flush_stdout` swallows OSError, so a closed stdout cannot replace the
report with a BrokenPipeError.

#177. A refused leg push printed the scaffold's RULESET_HINT. Its exit
(2), `git -C <work> push -u origin main:seed/scaffold`, exits 128 from
the leg's work clone. `git filter-repo` removes `origin`, and a seeded
leg is a fresh `git init` that never had one. Its exit (1) also said
"re-run this scaffold", and its "What already exists is listed above"
came before the `bare` lines in a merged log. The adoption now has its
own ADOPT_RULESET_HINT. It pushes HEAD to the leg's own URL as
`seed/adopt`, bases the pull request on the plan's tracking branch, and
says "re-run this adoption, once the leg repositories named below are
deleted or replaced". The notice follows it, now also after a refused
push. Both leg builders flush stdout before they report.
RULESET_HINT, scaffold-project.py and family.py are untouched.

Tests: tests/test_adopt_late_failure.py.
- Each split step failing (clone for real, mount stubbed, commit for
  real with nothing staged, push to a missing remote), and a split push
  refused as not a fast-forward.
- The notice's three states.
- stdout flushed before stderr, for the split, a leg push, the pull
  request, the topic, and `main`.
- A closed stdout.
- The printed seed push RUN from a seeded leg against a bare remote
  whose hook refuses only `main`.
- End to end with git filter-repo and a merged stream: lane 2's
  push-split repro, and the printed push RUN from an extracted leg.

tests/conftest.py: `run_script(..., merge_stderr=True)` gives one pipe
for both streams. tests/test_adopt_filter_repo_failure.py: a refused
push now gets the hint and then the notice, once each.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #185 (scaffold and seeded-leg force-staging), #186 and #193.
Textually clean. #185 makes `_seed_leg` pass what `copy_tree` returns to
`git_init_commit`, so a test stub of `copy_tree` must now return the
paths it wrote; that is the next commit.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y wrote, so the seeded-leg push tests no longer die with `TypeError: 'NoneType' object is not iterable` in `stage_written` once #175 is merged (#188, #177)

Since #185 (#175), `_seed_leg` hands what `copy_tree` returns to
`git_init_commit`, whose `stage_written` calls `list(written)`. The
stubs returned None, so after merging origin/main 22c9fdd three tests
failed in `stage_written`, before the push they are about:
`test_stdout_is_flushed_before_a_late_failure_reaches_stderr[leg-push]`,
`test_the_printed_seed_push_runs_from_a_seeded_leg` and #171's
`test_a_refused_push_is_reported_once_with_its_ruleset_hint[seeded-leg-push]`.
Each stub now returns `["README.md"]`, as `copy_tree` would.

PR #184's branch carries the same stub. It will need this line too when
it merges a main that has #185.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-notice

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ted with its step and a notice that names the work directory, a failed verification names the split branch and the command that deletes it, and `execute` refuses that branch before creating any leg, so following the printed way out ends in `adoption verified` instead of creating more leg repositories each time (#171, #188, #177)

Lane openRepoShape-2 reviewed PR #184 at f416e13 and this branch at
68d4d63. These are the findings, and how each was fixed:

- #184 P1-1: the filter-repo e2e test no longer depends on a .gitmodules
  that #166's `check` now refuses. A stub `git-filter-repo` placed first on
  PATH exits 1, a failure `check` cannot foresee. The stub is a POSIX shell
  script, so the test is skipped on Windows (WINDOWS_SKIP).
- #184 P2-1: the notice and the `leg-remote-exists` remediation both name
  the work directory: re-run with a fresh --work-dir, or with none. Two e2e
  tests follow the printed way out word for word to `adoption verified`,
  one after a failed leg build and one after a refused split push.
- #184 P3-1 and #188 N3: CommandFailed, Refusal, OSError and UnicodeError
  (`LATE_FAILURES`) are each reported with a stdout flush, the step name
  and the notice, then exit 2. Anything else, such as Ctrl-C, gets the
  notice as STOPPED and is re-raised. The substitution table is now built
  before the leg repositories exist. Each leg builder reads its commit
  count and tree digest before it pushes.
- #188 N1: a failed verification flushes stdout before printing its
  findings. Its exit names the leg repositories, the work directory and
  the split branch, with the shell-quoted command that deletes the branch.
  `execute` refuses with `adopt-branch-exists` before any leg repository
  exists. That check is skipped while a leg repository from an earlier
  run is still on disk under --local-remote-dir, so `leg-remote-exists`
  is still reported first.
- #188 N2 and #177: `ADOPT_RULESET_HINT` names the one exit an adoption
  has. The seed-branch exit is removed, because it cannot finish an
  adoption.
- #188 N4: if the code leg's repository cannot be created after the spec
  leg's was, the failure gets the notice naming the spec repository.
  `main` then exits 2 without printing it again, including when the
  failure is an OSError (a `gh` that cannot be run).
- #188 N5: the only command still printed, the branch deletion, is quoted
  for sh. The tests parse printed commands with shlex.split, using paths
  that contain spaces.

RULESET_HINT, scaffold-project.py and scripts/ are unchanged.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-failure-notice

One conflict, in adopt-project.py, on the `from shape_materialize import`
block. This branch had dropped `RULESET_HINT` from it (c77cef0: an adoption
prints its own `ADOPT_RULESET_HINT`, and nothing in adopt-project.py uses
the scaffold's any more). Main had added `assembly_root_paths` and
re-flowed the lines. The resolution keeps both changes: `RULESET_HINT` is
not imported, and `assembly_root_paths` is. Nothing in the merged
adopt-project.py names `RULESET_HINT` outside a comment.

Everything else merged cleanly. `_leg_findings(plan, tree)` and
`_entry_findings` are as #198 left them, with `tree` passed. The only
unused import is `COMMIT_RE`, which is unused on main as well.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-notice

origin/main moved from 20e13f0 to 8df21e6 (#201: the leg-path helpers
move into scripts/shape_materialize.py) while this branch was being
finished. This merge brings it in.

It merged with no conflict. #201 removed `SAFE_ARG_RE` from the
`repo_shape` import, and this branch added `REMEDIATION` there; both
changes are kept. The `shape_materialize` import is unchanged from the
previous merge: `assembly_root_paths` is imported and `RULESET_HINT` is
not. `_leg_findings(plan, tree)` is unchanged. The only unused imports
are `COMMIT_RE` and `leg_path_problem`, and main has the same two.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-notice

It merged with no conflict. #200 passes `--force` to `git submodule add`
and adds `_refuse_an_unmountable_path` before each mount. This branch does
not touch `_mount_the_legs`: its `_commit_the_split` already runs it as the
step `mounting the legs`. `_leg_findings(plan, tree)` is unchanged.

One behaviour of #200 meets this branch's. The guard's two refusals,
`adopt-mount-occupied` and `adopt-mount-unaddable`, come after both legs
are pushed. So `_commit_the_split` reports them as it reports every late
failure: `REFUSED mounting the legs.`, #200's own remediation, then the
notice. `execute` exits 2, as it does on main. Three of #200's tests in
tests/test_adopt_leg_path_collisions.py run `cmd_execute` in-process and
expect the refusal to escape it. On this merge they fail with `DID NOT
RAISE Refusal`. The next commit brings them in line with this branch's
report.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he three #200 added no longer fail on this branch with `DID NOT RAISE Refusal` (#188, #174)

#200 (#174) added three tests to tests/test_adopt_leg_path_collisions.py.
Each switches off #172's root-kept half and runs `cmd_execute` in-process
until the mount guard refuses, after both legs are pushed. They expected
the `Refusal` to escape `cmd_execute`, as it does on main, where `main`
prints it and exits 2.

On this branch every failure once a leg repository exists is reported
where it happens (#188). `_commit_the_split` prints `REFUSED mounting the
legs.`, the refusal's code and detail, its own remediation, and then the
notice of what the run left. `cmd_execute` then returns 2. The exit code
and the guard's words are the same as on main. After the merge of a5b09c3
the three tests failed with `DID NOT RAISE Refusal`.

`refused_at_the_mount` now records the refusal the guard raises, by
wrapping `_refuse_an_unmountable_path`. It asserts that `cmd_execute`
returned 2, that stderr carries that refusal word for word under the step,
and that the notice is the last line. The three tests' own assertions are
unchanged. No code changed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-notice

One file conflicted, adopt-project.py, in four hunks, all in or at
`_verify`. Every one keeps both sides:

- `cmd_execute`'s call to `_verify`: main passes the plan's two mount
  paths, `spec_path` and `code_path`, and this branch passes `urls` and
  `branch` by keyword. The call now passes both, in that order.
- `_verify`'s signature: main added `spec_path` and `code_path` as
  positional parameters before `seeded`; this branch added `urls` and
  `branch` as keyword-only. The signature has both:
  `(..., split_commit, spec_path, code_path, seeded=(), *, urls, branch)`.
- `_verify`'s docstring: main's #165 paragraph (only the two mounts are
  passed over) and this branch's #188 N1 paragraph (a failed
  verification is the last late failure) are both kept, #165's first,
  since it says what is checked and #188's says what a failure prints.
- `_verify`'s footer: main added `findings += root_findings` and "unless
  a finding above names another exit", for its findings whose exit is to
  report the split. This branch flushes stdout first, prints the findings
  in one write, and follows them with the notice and the split branch's
  deletion. The footer keeps all of it: the root findings join the list,
  the flush and the notice follow, and the sentence now reads "fix the
  plan and re-run, unless a finding above names another exit".

The merged diff against main differs from this branch's diff against
a5b09c3 in those hunks alone. tests/test_adopt_submodules.py and the new
tests/test_adopt_verify.py merged without conflict.

The merge breaks main's new direct `_verify` tests: they call it without
`urls` and `branch`, which this branch makes required, and one reads the
footer's old last sentence. The next commit fixes them.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… `_verify` names in its way out, and read its exit sentence as merged, so the 14 that call it directly no longer fail on this branch with `TypeError: _verify() missing 2 required keyword-only arguments` (#188, #165)

#165 (via #173) added tests/test_adopt_verify.py, whose `verify` fixture
calls `_verify` positionally with the plan's two mount paths. This
branch makes `urls` and `branch` keyword-only and required, because a
failed verification names the legs, the work directory and the split
branch with the command that deletes it (#188 N1). On the merge, e52f6be,
14 of its 16 tests failed with that TypeError; the two that run
`execute` passed, since `cmd_execute` passes both.

- The fixture passes `urls`, one per repository as `execute` would have
  them against GitHub (the assembly's is the source itself), and
  `branch=SPLIT_BRANCH`. Nothing is pushed to them: `_verify` only names
  them.
- `test_a_spec_gitlink_not_where_the_plan_spells_the_mount_is_two_findings`
  read main's last sentence, "re-run into a fresh --local-remote-dir or
  fresh leg repositories, unless a finding above names another exit.",
  at the end of stderr. As merged, that exit is "fix the plan and re-run,
  unless a finding above names another exit.", and the notice and the
  branch deletion follow it. The test now reads that sentence where it
  stands, right after the count.

No code changed. All 16 pass.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a leg or the split before it creates any leg repository, and the notice says to fix what failed first and names the work directory only once this run built in it, so following `adopt-branch-exists`'s way out no longer makes a new pair of leg repositories and dies at the clone (#188)

The review of #188 at 8d4fef4 found one way out that looped (F1, a
BLOCKER). `adopt-branch-exists` says "delete the branch, then run
`execute` again", with no work-directory clause. It fires only after an
earlier run pushed its split, so that run had built both legs and the
split in its `--work-dir`. Followed as written, the re-run made both leg
repositories (against GitHub, two real ones), then died at `git clone`
with `destination path '<work>/Northwind-spec' already exists`. The
verification's own FINDING exit, "re-run into fresh legs", led there.

F1, the review's better fix. `_refuse_an_occupied_work_dir` refuses,
before `_confirm` and before any leg repository exists, a work directory
that holds anything at `<work>/<spec leg>`, `<work>/<code leg>` or
`<work>/<assembly>` other than an empty directory (`adopt-work-dir-
occupied`). That is what `git clone` refuses, and what a seeded leg's
first commit would take in. Its remediation names a `--work-dir` that is
new or empty, or none. It is the first refusal of what an earlier run
left, ahead of `adopt-branch-exists` and so of `leg-remote-exists`. That
way their own "run `execute` again" meets a free work directory, and
`adopt-branch-exists`'s way out now works as written.

A default run is never refused: `_work_root` makes a new, empty
directory with `mkdtemp`. A new path or an empty one holds nothing either.

The other ways out, made to work as written:
- `SHAPE_FILE_EXIT` said "re-run into fresh legs", which left out the
  work directory and the branch. It now says "re-run as the end of this
  report says". It is printed only in a failed verification's findings,
  and that footer names the legs, the work directory and the branch.
- #200's MOUNT_REMEDIATION already names an empty `--work-dir`, or none.
  Its text is unchanged. Its test now also sees that a re-run which keeps
  the work directory is refused before it makes a leg repository.
- The notice (`_nothing_rolled_back`), below.

F2. The notice never said to fix what failed, so a cause that was still
there (a hook, a ruleset, a symlink loop) made a new pair of legs on
every attempt. It now reads "Once what failed above is fixed, re-run the
adoption with them deleted or with fresh ones". For a run that was
STOPPED, by Ctrl-C say, it reads "Re-run the adoption with ...", since
nothing failed.

N2. The notice said "`git clone` refuses a directory this run has already
cloned into" even when nothing was cloned (the code leg's repository
failing, #188 N4). Its work-directory sentence is now read off the disk.
It is printed only when this run built something there, and names what:
"Use a fresh --work-dir as well, or none: <work> holds Northwind-spec,
Northwind-code and Northwind from this run, and `execute` refuses a
--work-dir that holds them."

Tests (tests/test_adopt_late_failure.py, tests/test_adopt_filter_repo_
failure.py, tests/test_adopt_leg_path_collisions.py):
- New: a work directory holding a leg's clone, the split's, or a file at
  a leg's path is refused before anything exists and ahead of the branch
  left beside it, with the work directory left unchanged. A default, new
  or empty work directory reaches the confirmation.
- The failed-verification e2e now follows the review's chain. A re-run
  into fresh legs that keeps the work directory is refused for it, with
  nothing made. Its way out meets `adopt-branch-exists`, with nothing
  made. That way out as written (the printed deletion, then the same
  command again) ends in `adoption verified`.
- The notice's tests read the fix-first opening, the STOPPED opening, a
  work-directory sentence that names exactly what was built, and none
  when nothing was (N4).

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it push cannot fast-forward, and says so in sentences true of it, so a branch of that name at the source commit or behind it is pushed over and verifies, as on main, instead of being refused with a false reason (#188)

N1 of the review of #188 at 8d4fef4. `_refuse_a_split_branch_left_behind`
refused ANY branch named `adopt_branch:` on the assembly, saying "a new
split commit is never a fast-forward of the old one, so this run would
create and push both legs and then be refused at the split push". For a
branch at the source commit, which is what `git branch adopt/three-repo-
shape` leaves and what the unit tests' fixture made, that is false: the
split commit's one parent is the source commit, the push fast-forwards
the branch, and main a5b09c3 verified over it (the review's case F).

- `_in_the_history_of` asks `git merge-base --is-ancestor <tip> <source
  commit>` in the source. Exit 0, a tip at or behind the source commit, is
  not refused. A tip the source does not hold (exit 128) is not behind its
  commit either, so it is refused. A tip that is not a hex object name
  never reaches `git`.
- The refusal names the tip: "... at <tip>: neither the source commit
  <commit> nor a commit behind it". Its remediation now gives the reason
  in terms that hold for every branch it still refuses: git moves a branch
  to the split commit without force only from the source commit or behind
  it, and this tool never forces a push.
- `adopt_branch:` is a plan value. A person may point it at a branch of
  the source's own, and the old remediation told them to delete it. Now
  "If the branch is not an earlier run's ... give `adopt_branch:` a name
  no branch has", and the deletion is offered only for an earlier run's.
- "nothing was created" becomes "no leg repository was created and
  nothing was pushed", as `_refuse_what_check_finds` says. `_work_root`
  has made the work directory by then, and an `org/repo` source has been
  cloned into it.

Tests (tests/test_adopt_late_failure.py):
- `leave_a_split_branch` now leaves what an earlier run leaves: a commit
  on top of the source commit, not a branch at it. So the refusal tests
  test the real case, and the precedence tests stay meaningful.
- New: a branch at the source commit or two commits behind it is not
  refused. End to end (the review's case F), such a branch is pushed over,
  its new tip's parent is the source commit, and the run says `adoption
  verified`.
- The refusal's detail is read whole.
- `adoption verified` is named once (VERIFIED), as a third use would
  repeat it.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… every other late failure, with its step, the notice and the split branch, so a `git` failure inside `_verify` no longer reaches `main` as `REFUSED git-failed` with the validators' remediation about pins and no word of the two pushed legs (#188)

N3 of the review of #188 at 8d4fef4. "Every failure once a leg
repository exists" did not hold inside `_verify`. A `git_out` refusal
there, from `source.tree()`, `_tree_of` or the shape pin's read,
reached `main`'s generic handler. It printed `REFUSED git-failed: ...`
and `Remediation: run git submodule update --init --recursive, then
python3 scripts/validate-pins.py ...`, which `_failed_loudly` drops
everywhere else. No notice and no branch line followed, although both
legs and the split branch existed (the review's case K; the same on
main).

- `_verify`'s accounting moves, unchanged, into `_accounted_for`, which
  prints the table and returns the findings in the same order.
- `_verify` runs it as every late step runs: `LATE_FAILURES` are reported
  as `REFUSED verifying the split. <what failed>`, then the notice and
  the split branch with its deletion, and `execute` exits 2, as it did.
  Anything else (Ctrl-C) gets `STOPPED verifying the split by ...`, the
  same notice and branch, and is re-raised.
- `_left_by_the_split` says the notice and the branch once, for the
  findings' footer and for this.

So the claim holds as the body states it: every step the run takes once
a leg repository exists reports its own failure. The steps after the
split push, the pull request and the topic, give their by-hand exits.

Tests (tests/test_adopt_late_failure.py): a `_tree_of` that refuses as
`git_out` does gives exit 2 with stdout flushed first, the step, no pins
remediation, the notice (both legs pushed, fix first, the split's clone
named) and the printed deletion. An interrupt gives STOPPED, a notice
with no fix-first clause, and the deletion.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e plan, delete the split branch and re-run into fresh legs and a fresh `--work-dir` or none, so an assistant no longer relays "re-run into fresh legs", which `execute` now refuses before it makes anything (#188)

F3 of the review of #188 at 8d4fef4. Step 7 said the exit of a failed
verification was "to fix the plan and re-run into fresh legs". On this
branch that re-run is refused, before any leg exists, by
`adopt-branch-exists` (the split branch the failed run pushed is still
there), and by `adopt-work-dir-occupied` (5dbda31) when it keeps the
work directory. The tool's own exit has three parts: fix the plan,
delete the split branch with the command it prints, and re-run into
fresh legs and a fresh `--work-dir`, or none. Step 7 now names them.

"The source is untouched -- nothing is deleted, `main` is where it was,
the split is on a branch" becomes "the source lost nothing". The branch
it now says to delete is on the source, and tests/test_repo_hygiene.py
caps AGENTS.md at 464 lines, which it already is. So the step keeps its
five lines.

`scripts/render-cli-reference.py --check`: `[ok] docs/cli.md is what
the generator produces`. No `--help` text changed. README.md and docs/
never named this exit.

Test (tests/test_adopt_late_failure.py): step 7 is read out of AGENTS.md
and must name the three parts. With the old step it fails.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…imes or more is named once, so `'No such file or directory'`, `'work_root'`, `'branch'` and `'utf-8'` no longer trip Sonar's S1192 there (#188)

N6 of the review of #188 at 8d4fef4 found two literals that 8d4fef4
used three times: 'No such file or directory' in
tests/test_adopt_late_failure.py and 'work_root' in
tests/test_adopt_filter_repo_failure.py. This round's tests added two
more: 'branch', the git subcommand that makes the split's branch at a
commit, and 'utf-8', for the spec leg's built tree.

- `NO_SUCH_FILE`, `WORK_ROOT` and `UTF8` are named once each.
- `branch_at(repo, commit)` makes the split's branch, as the three tests
  that need one at a given commit do.

Checked with an AST count of literals of five or more characters,
against main 54aa69a. In every file this branch changes, no literal is
newly at three or more. adopt-project.py's 'assembly' was already at 29
on main. It is at 34 here, one of them `BUILT_IN`'s key for the split.
No test changes what it asserts; the two files give 56 passed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… for a branch of the source's own, and an interrupted split are each read by a test, so reverting any of the three changes fails one (#188)

Measuring the revert table for 5dbda31 and 837ccf9 found three changes
no test read:
- `SHAPE_FILE_EXIT`, the exit inside every `adopt-shape-file-missing`
  finding, now ends "re-run as the end of this report says" instead of
  "re-run into fresh legs" (5dbda31, F1). The failed-verification e2e
  reads it in the finding's own line.
- `adopt-branch-exists` tells a plan whose `adopt_branch:` names a branch
  of the source's own to rename it, not delete it (837ccf9, N1). The
  refusal test reads that sentence.
- `_commit_the_split` gives a STOPPED run (Ctrl-C) the notice with no
  "Once what failed above is fixed" (5dbda31, F2). Only the leg's
  interrupt was tested. A new test interrupts the mount and reads the
  notice: both legs pushed, nothing to fix first, the interrupt
  re-raised.

No code changed. tests/test_adopt_late_failure.py: 46 passed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-notice

It merged with no conflict. Main brings six commits since 54aa69a:

- e7d1720, update-shape.py (#208 via #224);
- 404760f, scripts/repo_shape.py's double-quoted flow items (#213 via
  #244), with tests/test_yaml_flow_quotes.py;
- 4835fb4, adopt-project.py's plan writer escapes (#199 via #247), with
  tests/test_adopt_plan_escapes.py;
- b538015, scaffold-project.py's leg paths against the shape's own
  paths (#197 via #234);
- 150c41d, update-shape.py's `apply --add` (#202 via #233);
- 39ac59c, scaffold-project.py's leg mount `--force` (#229 via #235).

Two of them touch adopt-project.py, and no hunk of either meets this
branch's:

- 4835fb4 changes the plan writer (`y`, `_PLAIN_RE`), adds `_shown` to
  the plan and check reports, and writes the `--paths-from-file` listing
  in `_extract_leg` through `write_paths_listing`. This branch changes
  other lines of `_extract_leg`: it reads the tree digest before the
  push and pushes through `_push_the_leg`. The signature is the same on
  both sides, so main's new direct `_extract_leg` test calls it as
  written.
- b538015 moves `_overlap`, `_first_and_more`, `MOVE_THE_LEG` and
  `CASE_ASIDE` into scripts/shape_materialize.py, and imports the first
  three back under the same names. This branch adds no use of any of
  them. A `symtable` check finds no name that the merged adopt-project.py,
  scaffold-project.py or scripts/shape_materialize.py reads and nothing
  binds.

`git diff -U0` of this merge against 39ac59c equals the branch's against
54aa69a, apart from the hunk headers. On the merged tree,
tests/test_adopt_plan_escapes.py, tests/test_yaml_flow_quotes.py,
tests/test_scaffold_leg_path_collisions.py and
tests/test_adopt_leg_path_collisions.py give 275 passed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the end-to-end test reads that the way out it points at ends the report, so tests/test_adopt_ignored_shape_files.py no longer fails 7 tests on this branch (#188)

5dbda31 reworded `SHAPE_FILE_EXIT`. "re-run into fresh legs" became
"re-run as the end of this report says", because the re-run alone left
out the work directory and the split branch (#188's review, F1). This
file's `PLAN_ANSWER` still held the old words, so 7 of its tests failed.
6 of the 7 run in-process, so they failed on all three operating systems
(the re-verification's R1).

`PLAN_ANSWER` now holds the new words, and its comment says where the
re-run is given. The end-to-end test also reads what those words
promise: after the finding, the footer says to fix the plan and re-run,
and the report ends with the split branch's deletion.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing to fix first, so a defect, which fails the same way on every re-run, is told to fix what failed first and no longer leads to one more pair of leg repositories per attempt (#188)

Four steps run once a leg repository exists: making the code leg's
repository, building a leg, making the split and verifying it. What they
do not anticipate they re-raise, after the notice. Every such exception
got Ctrl-C's notice, "Re-run the adoption with ...", with nothing to fix
first. That fits Ctrl-C, which failed at nothing. A defect, such as a
`TypeError`, fails the same way on every re-run, so following that
notice as written made one more pair of leg repositories per attempt.
That is #188's review's F2 again (the re-verification's R2).

`_interrupted` decides which is which, and only a `KeyboardInterrupt`
counts. The keyword is renamed from `stopped` to `interrupted`, because a
defect stops a run too. A defect's notice now opens "Once what failed
above is fixed, re-run", as a refusal's does. It is still printed as
`STOPPED <step> by <Type>.` and re-raised with its traceback. An
interrupt gets the same notice it got before.

The new test reads both kinds at each of the four points. `"_tree_of"`
is named once, as `TREE_OF`, so that its third use does not trip S1192.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…der such a ruleset, the bypass is the fix the notice below it asks for, so the notice's "Once what failed above is fixed" no longer reads as an invitation to change the ruleset the hint forbids (#188, #177)

On a refused leg push, `ADOPT_RULESET_HINT` says the refusal is BY
DESIGN and must not be worked around. It names one exit: an operator
holding the bypass right re-runs the adoption. The notice printed under
it opens, since #188's review's F2, "Once what failed above is fixed,
re-run the adoption ...". Read alone, "fixed" invites a change to the
ruleset, which the hint forbids. Read with the hint, it means the bypass
holder's re-run. Each text pointed at the other ("what the notice below
names", "what failed above") and neither said this (the
re-verification's R4).

The hint now ends: "Under such a ruleset, that bypass is the fix the
notice below asks for, never a change to the ruleset." As the hint's
last sentence, it sits directly above the notice it explains. The notice
itself is unchanged, because it is printed for every late failure, and
a ruleset has nothing to do with most of them. Every line of the hint
that a test reads is unchanged too.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rce's own clone, which an `org/repo` source makes in `<work-dir>/source-readonly/<repo>`, so a failure before any leg is built still names the work directory that a re-run given it would be refused in (#188)

`execute --source org/repo --work-dir W` clones the source into
`W/source-readonly/<repo>` before anything else. A re-run given the same
W is refused there, because `gh repo clone` will not clone into that
directory again (`source-unclonable`). The notice's work-directory
sentence read only the two legs and the split off the disk (5dbda31, N2
of #188's review). So a run that failed before it built either named no
work directory at all. The re-verification met this case: the code leg's
repository failing after the spec leg's was made (#188 N4; the
re-verification's R3). 8d4fef4 had named the directory, but did so
unconditionally.

`Source.open` now records whether it cloned the source. `_cloned_here`
counts that clone, while it holds something, first in the sentence's
list. Only a clone this run made is counted, because a local path is
read in place. The five places that build the notice hand it the source,
and `_create_leg_remotes` takes the source as a keyword for this. The
clone is not one of `BUILT_IN`: `_refuse_an_occupied_work_dir` runs
after the clone is made, and must not refuse a run for its own clone.

The tests cover the reviewer's case through `cmd_execute`, with
`gh repo clone` replaced by a local clone, and a cloned source at each
of the four points that print the notice. R2's four drivers now take the
source those points are handed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as well as a defect, at each of the four points, so dropping the source from either handler of a leg or the split fails a test (#188)

03b381a hands the source to every place the notice is built. Its
four-point test raised only a defect. A leg and the split report a
defect in their `except BaseException` handler, so their
`except LATE_FAILURES` handlers could lose the source and no test would
fail. The test now also raises a refusal: a `PermissionError`, which is
one of `LATE_FAILURES`. Whatever a point re-raises, the test lets
through.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-notice

It merged with no conflict. Main brings nine commits since 39ac59c:

- 06b5204, six tools escape a name their stdout cannot encode (#214
  via #246), with tests/test_stdout_encoding.py;
- 60d08a1, `shape_materialize.run()` and 26 other git and gh calls
  read the child's output as UTF-8 (#217 via #230), with
  tests/test_git_output_decoding.py;
- 2727b08, update-shape.py's `gh pr create --base` (#209 via #218),
  with tests/test_update_shape_pr_base.py;
- 4b2add8, scripts/repo_shape.py's flow mappings (#252 via #257), with
  tests/test_yaml_flow_mappings.py;
- aaa40eb, adopt-project.py refuses a `.gitmodules` the history's
  gitlinks make unreadable (#195 via #226), with
  tests/test_adopt_history_gitlinks.py;
- d89fffa, family.py `bump` to the current pin (#210 via #219);
- eb6df75, shape-doctor.py's family.yaml beside a project.yaml (#211
  via #220);
- c3f4987, scripts/repo_shape.py's block-scalar records (#248 via
  #265), with tests/test_yaml_block_scalar_keys.py;
- 5751460, adopt-project.py spells every path a FINDING or a refusal
  names (#212 via #223), with tests/test_adopt_finding_spelling.py.

Three files changed on both sides: adopt-project.py,
tests/test_adopt_ignored_shape_files.py and tests/test_adopt_verify.py.
In each, `git diff -U0` of this merge against 5a8b649 equals main's
diff against 39ac59c, and this merge against 5751460 equals the
branch's diff against 39ac59c, apart from the hunk headers. Neither
side's lines are changed.

How main's changes meet this branch's failure handling:

- Every definition the late failures go through is byte-identical to
  5a8b649's: LATE_FAILURES, the notice (`_nothing_rolled_back`),
  `_failed_loudly`, `_report_late`, the three builders' handlers,
  `_verify` and `cmd_execute` among them. Three definitions differ, each
  by main's hunk alone.
- `main()` gains `escape_what_stdout_cannot_encode()` as its first
  statement (06b5204). The branch writes every REFUSED block and notice
  to stderr, which Python opens with `backslashreplace` whatever the
  locale.
- `run()` names its codec (60d08a1). Output that is not UTF-8 now
  reaches the branch's handlers as `CommandFailed`, not as
  `UnicodeDecodeError`. LATE_FAILURES keeps `UnicodeError`, because
  `git_out` in scripts/repo_shape.py still decodes strictly.
- `Source.registered_submodules` reads the history (aaa40eb). Its one
  caller in `execute` is `_refuse_what_check_finds`, before
  `_create_leg_remotes`, so the new refusal comes before any leg
  repository exists and needs no notice.
- 5751460 spells the paths of the verification's findings. The
  branch's footer and notice follow those findings unchanged.

No top-level name is bound twice in adopt-project.py,
scaffold-project.py, scripts/repo_shape.py or
scripts/shape_materialize.py, and a `symtable` check finds no name
they read that nothing binds. On the merged tree, ten test files give
439 passed and 1 skipped (Windows' own stdout default): the two shared
ones, main's four new ones that read adopt-project.py
(finding_spelling, history_gitlinks, stdout_encoding,
git_output_decoding), and test_adopt_late_failure,
test_adopt_filter_repo_failure, test_adopt_execute_refusals and
test_adopt_plan_escapes.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It merged with no conflict. Main brings five commits since 5751460, and
none of them changes a file this branch changes, scripts/repo_shape.py
or scripts/shape_materialize.py:

- 23e16ae, scaffold-project.py's `--referent-chain` links (with
  tests/test_scaffold_referent_chain_links.py);
- 60588d5, family.py's case-variant members and validate-family.py;
- 018ce0d, bump-leg.py rewrites only the workflow files git tracks;
- 67a398a, tests/test_scaffold_pin_and_reuse.py's descendant tests;
- d94d155, shape-doctor.py's manifest that does not load.

shape-doctor.py loads adopt-project.py for its `placement` row (`walk`,
`y`, `emit`, and `PathPolicy` from scripts/path_classify.py), and
tests/test_shape_doctor.py feeds placement plans through its `Plan` and
`_leg_findings`. Each of those is byte-identical to main's on this
branch. tests/test_shape_doctor.py and
tests/test_scaffold_pin_and_reuse.py are in this branch's targeted set.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and `adopt-branch-exists` spell every path they name as a finding does, so a work directory, a leg repository or a source named with a line break or an ESC no longer splits or overwrites the line they print on (#188, #212)

Main's 5751460 (#212) made `check` and `execute` spell each path a
FINDING or a refusal names with `_spelled`: between backticks, with a
backslash escape for a backslash, a backtick and any character
`str.isprintable` refuses. This branch's own messages still printed
their paths raw:
- the notice (`_nothing_rolled_back`): the leg repositories, the work
  directory and what it holds;
- the split line `_left_by_the_split` adds: the assembly and the branch;
- `adopt-work-dir-occupied`: the work directory and what it holds;
- `adopt-branch-exists`: the assembly and the branch.

A leg repository under `--local-remote-dir`, the work directory and a
local source are paths a person chose. One holding a line feed, U+0085
or U+2028 split the notice's one line, and a carriage return or an ESC
overwrote it. Each is now `_spelled`, as a finding spells a path, so a
path reads the same in a finding, in the notice and in this branch's two
refusals. Main's own `leg-remote-exists` still names its repository raw,
as 5751460 left it.

The branch deletion under the split line is not spelled. It is a command
to paste, and `sh` reads a quoted path (`shlex.quote`), not a spelled
one.

Tests:
- tests/conftest.py gains `spelled()`, the expectation for a printable
  path: backticks, with a backslash or a backtick escaped.
- The assertions in test_adopt_late_failure.py and
  test_adopt_filter_repo_failure.py that read these paths expect them
  spelled. `NAMED` reads the legs between the backticks, so the
  end-to-end test still deletes the two repositories the notice names,
  word for word.
- Three new tests, each over the five characters
  test_adopt_finding_spelling.py asks: the notice with its split line,
  `adopt-work-dir-occupied`, and `adopt-branch-exists`. Each is one
  printable line holding the spelled path, and the deletion stays the
  command `sh` reads. They use POSIX paths that are never made, so they
  run on every platform.
- `'--delete'` is named once, as `DELETE_FLAG` (S1192).

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

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-12T19:56:17Z) A v1.0.0 scope notice. Brett Heap scoped openRepoShape's first release today: every procedure in AGENTS.md works end to end for ordinary repositories on Linux, macOS and Windows. The tag waits for every in-scope issue to close and a full test run to pass on all three OSes on the release commit. This PR closes #171 (P2, parent #457), #177 (P1, parent #457), #188 (P2, parent #457): in scope, in the v1.0.0 milestone. Nothing changes in how it lands. Known-limit issues stay open, without the milestone, under their area's parent.

@brettheap
brettheap merged commit 75052cb into main Oct 12, 2026
5 checks passed
@brettheap
brettheap deleted the fix/adopt-late-failure-notice branch October 12, 2026 20:08
brettheap pushed a commit that referenced this pull request Oct 12, 2026
… into fix/adopt-record-submodule-placement

Brings in decff87 (shape-doctor.py: a root's own contracts/shape-pin.yaml
that does not read as a pin), dba5fb0 (bump-leg.py), 245d775
(validate-pins.py) and 75052cb (#188: every failure once a leg repository
exists flushes stdout, then prints `REFUSED <step>.`, what failed and one
notice of what the run left), on top of dd8d0ea, which 136a37f had
merged. Merged by sha, no rebase.

Two conflicts, each kept on both sides:

- adopt-project.py, `_commit_the_split`. #188 puts every step after the
  legs exist inside its late-failure reporting: a `step` name, one `try`,
  and `_report_late` with `_nothing_rolled_back`'s notice. This branch
  appends the `submodules:` record to the split's `project.yaml` there.
  The merge takes #188's function whole and writes the record inside its
  `try`, under the `committing the split` step, where the materializer is
  handed what to append. The record's lines are this branch's, rewrapped
  for the deeper indent, and nothing else in the function differs from
  main's. A failure while the record is built or after it is appended is
  then reported as #188 reports any failure in that step.
- tests/test_repo_hygiene.py, `test_agents_md_is_short_enough_to_be_read`.
  #188 moved the AGENTS.md cap from 481 to 482 for its one line in step 7,
  with a paragraph saying why; this branch had moved it from 481 to 489
  for its eight. AGENTS.md merged cleanly at 490 lines, main's 482 plus
  this branch's lines exactly. The merge keeps #304's and #188's paragraphs
  word for word, then this branch's, renumbered "2026-10-12: 482 -> 490",
  and asserts `<= 490`. Step 7, as #188 words it, and this branch's
  paragraph after it read true together.

One more change, which #189's review planned for whichever of #188 and
#189 landed second: tests/test_adopt_late_failure.py hands
`_commit_the_split` a plain `{}` as its plan, which has no `entries` for
the record to be built from, so its commit and push cases stopped at the
record with an AttributeError (reported by #188's own handler). Those
tests now pass `SplitPlan()`, a dict with `entries = ()`. The sources they
adopt keep no submodule, so no record is written and every assertion
stands as it was. All 86 of its tests pass.

Checked against `git merge-tree --write-tree`, base dd8d0ea: no name bound
twice by the merge; each module's names are the base's plus what each
side added; every definition is the changing side's text, apart from the
three named above; and AGENTS.md differs from main by exactly this
branch's lines, and from this branch by exactly main's.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 12, 2026
…landed: `_commit_the_split` keeps #445's reported steps and reads `follow_ups:` through `_plan_follow_ups`

Main moved from dd8d0ea to 580d7df: decff87 (#267), dba5fb0 (#403),
245d775 (#446), 75052cb (#445), ec8e09a (#406) and 580d7df (#416).
Only #445 touches adopt-project.py, and it conflicted in one hunk of
`_commit_the_split`.

#445 wraps that function's body in a `try` that names each step
(cloning the source for the split, mounting the legs, committing the
split, pushing the split branch), so a failure once both legs are pushed
flushes stdout, prints `REFUSED <step>.`, what failed and one notice of
what the run left. This branch reads `follow_ups:` there through
`_plan_follow_ups`, the one read of it (#293). The resolution keeps
#445's `try`, steps and reports whole, and carries this branch's read
into its "committing the split" step: git had kept main's
`[str(f) for f in plan.get("follow_ups", [])]` inside the `try`.

The two contracts meet without overlap. `plan-bad-follow-ups` and
`election-date-malformed` are findings of `_entry_findings`, which
`execute` refuses through `_refuse_what_check_finds`. That runs before
#445's `_refuse_an_occupied_work_dir`, `_refuse_a_split_branch_left_behind`
and `_create_leg_remotes`, so these are refusals before any leg exists:
exit 2, `REFUSED <code>:` and the remediation that says no leg
repository was created and nothing was pushed, as #445's own refusals
before the legs say, and no notice. #445's report covers the failures
once a leg exists, which these values no longer reach. The guard in
`_manifest_values` is unchanged; #445 does not touch it.

#445's comment over `_template_values` in `cmd_execute` named "an
`elected_on` that is not a date" as a refusal the table can raise. With
#294's finding, `_refuse_what_check_finds` refuses such a date first, so
the comment now says the date no longer reaches the table. #445's test
of a table refusal stubs `_template_values` and is unchanged.

tests/test_adopt_execute_refusals.py: the two end-to-end runs through
`main` (#293, #294) now also assert that the refusal says no leg was
created and that #445's notice ("NOTHING has been rolled back") does not
follow it.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 12, 2026
…eport of a failure after the legs carries the materializer's refusals, and its two early refusals follow this branch's `core.symlinks` probe

origin/main moved dd8d0ea -> 75052cb (four commits), merged by sha:
- decff87 (#237 via #267), shape-doctor.py and update-shape.py;
- dba5fb0 (#383 via #403), scripts/bump-leg.py and docs/handbook.html;
- 245d775 (#412 via #446), the template's scripts/validate-pins.py;
- 75052cb (#171, #188 and #177 via #445), adopt-project.py and AGENTS.md:
  every failure once a leg repository exists is reported at its step as
  `REFUSED <step>.`, what failed, and `_nothing_rolled_back`'s notice, exit
  2; `execute` refuses `adopt-work-dir-occupied` and `adopt-branch-exists`
  before it creates anything.

tests/test_adopt_ignored_shape_files.py and every other file merge with no
conflict. adopt-project.py conflicts in five hunks, each resolved by keeping
both sides:
1. The materializer's import list: this branch's names (BOOTSTRAP,
   SYMLINK_HOPS, TOO_MANY_SYMLINKS, template_files), without RULESET_HINT,
   which #188 replaced by its own ADOPT_RULESET_HINT and nothing here uses.
2-4. `Source`: this branch's `work_root`, where a climb above the root
   leads (#183), and #188's `cloned`, read by `_cloned_here`. Both are
   keyword-defaulted, so the four-argument constructions in the tests and
   #188's `cloned=True` read as before, and `open` hands both on.
5. `_commit_the_split`: main's steps inside #188's report, with this
   branch's `spell=_spelled`, and its comment, on the materializer call.

How #188 meets this branch's code:
- `execute`, all before `_create_leg_remotes`: `_refuse_what_check_finds`
  (with `plan-root-symlink`), this branch's `core.symlinks` probe
  (`adopt-symlinks-off`), `_refuse_unconsented_seeding`, then #188's
  `adopt-work-dir-occupied` and `adopt-branch-exists`, then the
  confirmation and `_template_values`. A plan's own faults come first, then
  whether `git` here can hold what the plan keeps, then what an earlier run
  left, whose way out is "run `execute` again". The probe's throwaway
  repository (`symlinks-probe-*` in the work directory) is removed before
  `_refuse_an_occupied_work_dir` reads the work directory, and it is none of
  the three directories that function asks about.
- The materializer's refusals (`materialize-outside`,
  `materialize-symlink-loop`, `materialize-blocked`) are raised inside the
  step "committing the split", which #188 wraps. They are `Refusal`s, so
  `_failed_loudly` prints `REFUSED committing the split. <code>: <detail>`
  on one line, still spelled by `_spelled`, then their remediation, then the
  notice that both legs are pushed; `execute` exits 2, as it did.
- The two end-to-end tests of that path expected the old one-line
  `REFUSED <code>:`. The next commit makes them expect #188's report.

Checked on the merged tree:
- AST (fin183g/names.py, base dd8d0ea): no top-level name is bound twice
  in adopt-project.py, scaffold-project.py or scripts/repo_shape.py, and
  none is loaded but bound nowhere; scripts/shape_materialize.py binds
  `YamlError` and `parse_yaml` twice, as main does since 3ef059c. Main
  changed the signatures of `Source.__init__` (`cloned=False`),
  `_create_leg_remotes` (`work_root`, `source=None`) and `_verify` (`urls`,
  `branch`); every call of the last two is main's, and every construction
  of `Source` passes four arguments or `cloned=` by keyword.
- Both sides intact (fin183g/sides_intact.py): the only lines that differ
  from either side's own change are those of the five hunks.
- A rehearsal of this merge (tree 59eca4e, the same as this one), with the
  next commit's test change, passed tests/test_adopt_root_symlinks.py,
  tests/test_adopt_late_failure.py, tests/test_adopt_filter_repo_failure.py
  and tests/test_adopt_ignored_shape_files.py: `231 passed`.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 12, 2026
Main 75052cb, merged by sha: #188 with #171 and #177 (PR #445), on top of
decff87 (#237), dba5fb0 (#383) and 245d775 (#412). Only 75052cb touches
this branch's files, in adopt-project.py. It changes neither the reader
(scripts/repo_shape.py), scripts/shape_materialize.py nor a YAML template,
so the gate asks the same reader of the same lines.

One conflict, in adopt-project.py, resolved keeping both sides:
- The `from shape_materialize import (...)` list. This branch added
  PLACEHOLDER_RE, TEMPLATED and render, which `_written` and
  `_as_they_stand` use. Main dropped RULESET_HINT: 75052cb prints the
  adoption's own ADOPT_RULESET_HINT instead (#177), and nothing else uses
  the scaffold's. The list is main's, without RULESET_HINT, plus the three,
  each name once.
The tree is otherwise `git merge-tree`'s.

No function is changed on both sides. The top-level names are exactly
both sides' (this branch's additions, main's 25, RULESET_HINT and
`misread_placeholders` gone), and none is bound twice in adopt-project.py,
scaffold-project.py or scripts/repo_shape.py. scripts/shape_materialize.py
binds YamlError and parse_yaml twice, as main does.

How #188 meets the gate. `_refuse_what_check_finds` is unchanged in place:
it still runs before #188's `_refuse_an_occupied_work_dir` and
`_refuse_a_split_branch_left_behind`, which run before the template table
and the leg repositories. So this branch's `plan-manifest-misread`, and
#261's `plan-manifest-invalid`, still refuse before anything exists,
whatever the work directory or the source's branches hold. Probed: a plan
carrying `id: "true"` or `visibility: "true"`, with a --work-dir that
already holds the assembly's directory, or with a split branch in the
source that the push cannot fast-forward, is refused under the gate's
code, exit 2, with no leg repository made and the source's branches as
they were. With a valid plan, the same two states are refused as
`adopt-work-dir-occupied` and `adopt-branch-exists`. #188's late-failure
reporting covers the steps once a leg repository exists, where this gate
never refuses.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 12, 2026
…king-branch

Conflict in adopt-project.py, resolved to main's file byte for byte. #445
gave adoption its own ADOPT_RULESET_HINT, which already names the tracking
branch (`{repo} has no {tracking} ...`) and prints no command, so this
branch's two adopt-leg edits to RULESET_HINT calls are dropped. The scaffold
and family call edits merged cleanly and are unchanged.

RULESET_HINT's comment no longer names the adopt legs as callers.
tests/test_ruleset_hint_tracking_branch.py drops the adopt push-line cases
and pins instead that ADOPT_RULESET_HINT holds no `main` and that both adopt
legs print it for the run's tracking branch.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 12, 2026
…n left under `execute`'s own `adopt-branch-exists`, and `execute` refuses only a branch in git's way of it, after #445's refusals

#445 (75052cb) refuses in `execute` the split branch an earlier run left,
as `adopt-branch-exists`, before any leg exists, and only where the push
cannot fast-forward it. Its tests pin its order: an occupied work
directory first, then that branch, and a live leg repository under
`--local-remote-dir` keeps `leg-remote-exists`. This branch's refusal of
the same branch came first, in `_refuse_what_check_finds`, and preempted
all three: 8 tests of `tests/test_adopt_late_failure.py` failed on
973d0fe.

- `check` keeps the finding main lacks. For the split branch itself where
  the split is pushed it is now `adopt-branch-exists`, the code `execute`
  refuses it under and `_left_by_the_split` tells the re-run, asked by
  #445's rule (`_in_the_history_of`), so a branch at the source commit or
  behind it, which `execute` pushes over and verifies, is no finding.
- A branch in git's way of the split branch (`adopt`,
  `adopt/three-repo-shape/old`), which main still meets only after both
  legs exist (measured on ec4a7a0: at `git filter-repo`, or at the split
  push), stays `plan-split-branch-taken` in `check` and in `execute`.
- `execute` asks for it in `_refuse_a_branch_in_the_split_branchs_way`,
  right after `_refuse_a_split_branch_left_behind`, skipped like it while a
  leg repository is still there under `--local-remote-dir`.
  `_refuse_what_check_finds` is main's again.
- Dropped: `execute`'s own refusal of the split branch itself, and
  `adopt-split-target-unreadable`, which #445's `git ls-remote` of the
  same repository now always meets first.

Unchanged: #445's functions and `cmd_execute`'s order before the new
call are byte-identical to main's; `tests/test_adopt_late_failure.py` and
`tests/test_adopt_e2e.py` are main's, unedited. `execute` over a split
branch whose commit #226's history scan names is refused for the scan's
finding first, as on main; `check` names the branch first.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 13, 2026
…where it named `main`, as the base of its pull request and as the local ref the scaffold and the family holder pushed, so a `--tracking-branch trunk` run's refused push no longer advises `push -u origin main:seed/scaffold` (a branch its work clone does not have) and `gh pr create --base main` (a base its repository does not have) (#324) (#373)

`RULESET_HINT` is what a person reads when an organisation's ruleset has just
refused a direct push: seed a branch, open a pull request. It hard-coded `main`
as the local branch of `git -C <work> push -u origin main:seed/scaffold` and as
`gh pr create --base main`. A run made with `--tracking-branch trunk` makes its
work clones with `git init -b trunk` and pushes `trunk`, so the first command
died on `error: src refspec main does not match any` and the second named a base
the repository does not have.

Two fields name a branch, and they are not one value:
- `{tracking}` is the remote's branch, the run's `--tracking-branch`, and
  stands in `--base` only. Every caller passes it.
- `{local}` is the ref in `{work}` that the refused push carried, and stands in
  the push command only. The scaffold's two pushes (`_seed_legs`,
  `_build_assembly_root`) and the family holder's (`_create_holder_worktree`)
  push the tracking branch by name from a clone made on it, and pass
  `args.tracking_branch` for both.
- No caller lacked a tracking branch to pass. Each value has been through
  `checked_value` by then (it admits only `[A-Za-z0-9._/@+~-]` and no leading
  `-`) and, since #355, `git check-ref-format --branch`, which must echo it as
  written, so it is printed as it stands, like `{repo}`.

An adoption does not print `RULESET_HINT`. Since #445, both adopt legs print
`adopt-project.py`'s own `ADOPT_RULESET_HINT`, which already names the run's
tracking branch (`<repo> has no <tracking> for that pull request to target`)
and prints no command. `adopt-project.py` is left as `main` has it, byte for
byte; the test pins #324's rule for it instead.

`tests/test_ruleset_hint_tracking_branch.py` runs every caller against local
bare remotes whose `pre-receive` hook refuses everything but `seed/*` (a
ruleset in miniature, installed through `GIT_TEMPLATE_DIR`), once with
`--tracking-branch trunk` and once with `main`:
- the scaffold's spec leg, its assembly root and the family holder: it pins
  the printed block against a literal copy of the hint as it was, asserts that
  the ref the hint names as local IS the commit the refused push carried, as
  the remote's hook recorded it, then runs the printed push and sees
  `seed/scaffold` arrive at that commit;
- an adoption's extracted and seeded leg, from a source on `master`: each
  prints `ADOPT_RULESET_HINT` for that leg's repository and the run's tracking
  branch, with no `main` for `trunk` and no `master`, and the constant itself
  holds no `main`;
- it scans every shipped `RULESET_HINT.format(` and `ADOPT_RULESET_HINT.format(`
  for a call that leaves a field out, which would raise `KeyError` inside the
  failure path that exists to explain a refusal.

Unchanged: for the default `main`, the scaffold's and the family holder's hint
is byte-identical. The three callers' `main` runs pin it against the literal
copy and pass on `main` and on this change alike. What the hint advises is the
same, and so are its two exits (seed by pull request, or ask an operator with
the bypass right; never `--admin`, per AGENTS.md); only the branches it names
move.

Left out:
- `gh pr create --base <tracking>` cannot run in any of the three places, for
  `main` as for any other branch: the repository the push was refused at is
  empty (`gh repo create` is called without a README), so the pull request has
  no base to land on. That is older than this change and is not touched here.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 13, 2026
…t as `execute`'s `adopt-branch-exists`, and a branch in git's way of it as `plan-split-branch-taken`, which `execute` now refuses before any leg exists, so a source branch `adopt` no longer has both legs made before `git filter-repo` fails (#320) (#382)

`execute` pushes its one split commit to `refs/heads/<adopt_branch>` of the
repository the split goes to: the source itself under `--local-remote-dir`,
the assembly repository on GitHub otherwise. It extracts each leg from a
clone of the source with that branch checked out, where `git filter-repo`
makes each source branch a branch. Two kinds of branch stop it, and `check`
passed both as `plan ok`. Measured on main ec4a7a0 with git 2.43 and `git
filter-repo` 2.47.0:

- the split branch an earlier run left, at a commit the push cannot
  fast-forward: `execute` refuses it as `adopt-branch-exists` before any
  leg exists (#188 N1, #445), and pushes over one at the source commit or
  behind it, and verifies;
- a branch in git's way of the split branch, whose name is a directory of
  it or lies under it (`adopt`, `adopt/three-repo-shape/old`): a source
  holding one made both legs (two bare repositories, or two `gh repo
  create`s) and exited 2 at `git filter-repo`; a plan seeding both legs
  from such a source, and a GitHub assembly repository holding `adopt`,
  made both legs and exited 2 at the split push.

Now:

- `check` reports the split branch itself where the split is pushed as
  `adopt-branch-exists`, the code `execute` refuses it under and the one
  `_left_by_the_split` tells the re-run, by that refusal's own rule, so a
  branch the push fast-forwards is no finding. One situation keeps one
  code: `check` and `execute` agree about it as they do about every other
  finding (#168).
- A branch in git's way is `plan-split-branch-taken`. `check` reports it
  for the source, about both runs and said as the extraction's failure
  (about the push alone when the plan seeds both legs), and for the
  assembly repository as the source's `origin` last fetched it.
- `execute` refuses it in `_refuse_a_branch_in_the_split_branchs_way`,
  right after `_refuse_a_split_branch_left_behind`: it lists the
  repository it pushes to with `git ls-remote --heads`, and without
  `--local-remote-dir` the source's own branches for the extraction. Like
  #445's refusal it asks nothing while a leg repository is still there
  under `--local-remote-dir`, which is `leg-remote-exists`'s.
- `check` prints one remediation once after these findings, and names
  them before the entries' findings.

Unchanged: every function but `cmd_check` and `cmd_execute` is
byte-identical to main's (compared function by function), among them
#445's `_refuse_an_occupied_work_dir`,
`_refuse_a_split_branch_left_behind`, `_in_the_history_of` and
`_left_by_the_split`, and `_refuse_what_check_finds`; so is
`cmd_execute` before the one new call.
`tests/test_adopt_late_failure.py` and `tests/test_adopt_e2e.py` are
main's, unedited. `plan` is untouched. A plan with no branch in the way
checks and executes as before.

Left out: a case-only twin of the split branch is not compared. Over a
split branch whose commit #226's history scan names, `execute` still
refuses the scan's finding first, as on main (`_refuse_what_check_finds`
runs before `_refuse_a_split_branch_left_behind`); `check` names the
branch first, and its remediation says that finding stands until the
branch is deleted. A branch the repository hides from `git ls-remote`
(`uploadpack.hideRefs`, `transfer.hideRefs`) is not seen, here or by
#445's refusal: the push of the split then fails loudly, after the legs.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 13, 2026
…pin-file: #409's four files take main's blobs, which are #409's final head's, `scaffold-project.py` keeps the pin code this branch moved out and gains main's own changes, and #445's `try` in `_commit_the_split` carries this branch's `neutral_pins=` argument beside #394's `append`

#409 squash-landed as 9736424 (its final head b70a07d). This branch held
#409 at 6494714, and after it #409 only merged main five times, none with
a hand edit (`git show --remerge-diff` empty for bac7dee, 480c348,
efb7322, 5bdcae3 and b70a07d). `git diff b70a07d 9736424 -- <file>` is
empty for each file #409 touched: scaffold-project.py, scripts/repo_shape.py,
templates/assembly-root/scripts/validate-pins.py and
tests/test_pin_source_env_collisions.py.

Conflicts, four files, one hunk each:

- scaffold-project.py. #409's squash re-adds `PIN_COLLISION_REMEDIATION`,
  `PIN_VARIABLE_REMEDIATION`, `_refuse_colliding_pins`,
  `_colliding_pin_variables` and `_pin_collision_remediation`, which this
  branch moved to scripts/shape_materialize.py (#273). The hunk takes this
  branch's side, empty, and #409's auto-merged import of
  `PIN_SOURCE_ENV_PREFIX, pin_source_env_collisions` (and its two comment
  lines), which nothing in the file uses any more, is dropped. Not the whole
  file `--ours`: main changed scaffold-project.py beyond #409 since this
  branch's last merge (6c2a371's `--tracking-branch` check, a73b4e9's
  `RULESET_HINT`), and those lines stay. The result equals the file
  `git merge-tree` gives for this branch with main before the squash
  (518010b), blob fbe7215, and its diff from this branch's tested file has
  the patch-id of main's own 3ef059c..518010b change to the file.
- templates/assembly-root/scripts/validate-pins.py. Main's side, which is
  #409's final blob (5f83eb7) with main's #359 workflow reader. This branch
  had no change of its own to the file beyond #409 at 6494714.
- adopt-project.py, `_commit_the_split`. #445 wraps the function in its
  late-failure `try` with `step` names, and #394 builds an `append` with the
  plan's submodule record. This branch's only edits there were a comment and
  `neutral_pins=_recorded_pins(plan)` on the materializer call. Resolved
  with main's side, and the comment and the argument on main's call inside
  the `try`: `append=append, neutral_pins=_recorded_pins(plan)`.
- docs/cli.md, generated: regenerated with scripts/render-cli-reference.py
  over the resolved tree (`--check` ok). It differs from main's only in
  `adopt-project.py plan`'s `--pin`, `--pin-owner` and `--pin-source` help.

scripts/repo_shape.py and tests/test_pin_source_env_collisions.py
auto-merged to main's blobs (82346be, 5220787). pyflakes reports the same
four warnings on the merged adopt-project.py, scaffold-project.py and
scripts/shape_materialize.py as before the merge.

#445's tests/test_adopt_late_failure.py hands `_commit_the_split` a
`SplitPlan`, a `dict` that adds `entries`, as its plan, which `_read_pins`
cannot read yet; the next commit makes it read one.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 13, 2026
…mmit_the_split` takes the `SplitPlan` mapping #445's tests hand it as the plan and no longer stops at "committing the split" with an AttributeError, and a loaded plan reads exactly as before (#273)

#445 (75052cb) tests `_commit_the_split` directly. In
tests/test_adopt_late_failure.py, `split_arguments` passes
`plan=SplitPlan()`, a `dict` subclass that adds `entries` (#394). Those
are all the function read before this branch: `plan.get("follow_ups",
[])` and `plan.entries`. Since #273 it also writes each declared pin,
`neutral_pins=_recorded_pins(plan)`, and `_read_pins` read
`plan.data.get("pins")`.

On the merge commit before this one, three of #445's tests failed
(3 failed, 83 passed in that file), each with "STOPPED committing the
split by AttributeError: 'SplitPlan' object has no attribute 'data'":
- test_a_split_that_fails_names_its_step_and_both_pushed_legs[commit]
- test_a_split_that_fails_names_its_step_and_both_pushed_legs[push]
- test_a_split_the_materializer_cannot_write_is_refused_with_the_notice

`Plan.get(key, default=None)` returns `default` when the value is None,
and the value otherwise. With no default it returns
`plan.data.get("pins")` exactly, so a loaded plan's `pins:` reads as
before, absent and `null` alike, and the `rows is None` branch below is
unchanged. A mapping has the same `get`, so the test's plan reads too.
This was the only `plan.data` in the file.

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 plan `follow_ups:` that is not a list of text and an `elected_on:` that `election_date` does not read, with or without a `reference:`, so `follow_ups: 5` is no longer a traceback and `elected_on: someday` is no longer refused under a flag neither command takes (#293, #294) (#356)

`check` did not ask about a hand-edited plan's `follow_ups:` or
`elected_on:`. `follow_ups: 5` ended `check` and `execute` in a `TypeError`
traceback, and `follow_ups: abc` passed both, `execute` writing the
follow-ups `a`, `b` and `c`. A date `election_date` refuses was refused only
on its way to the manifest's reference: with no `reference:`, `check`
stopped at exit 2 on `REFUSED election-date-malformed` naming
`--elected-on`, a flag neither command takes, and said to re-run with it.
Before #314 that refusal came after both legs were created, and a plan that
kept its `reference:` adopted with the word in its manifest.

`_plan_follow_ups` is now the one read of `follow_ups:` for `_plan_values`,
`_print_what_will_happen` and `_commit_the_split`, where it is read inside
#445's `committing the split` step. `_follow_ups_findings` reports
`plan-bad-follow-ups` for a value that is not a list, saying what it holds,
and once for each item that is not text. An empty list and a bare key stay
valid.

`_elected_on_findings` asks `election_date`, the rule `plan` asks of
`--elected-on`, about `str` of the plan's value, with or without a
`reference:`, so `plan`, `check` and `execute` cannot disagree about one
date. A date it refuses is a FINDING `election-date-malformed` in the plan
key's own words, and the remediation says to edit `elected_on:` in the plan
file. Only an absent or null `elected_on:` is "no date": `""`, `false`, `0`,
`[]` and `{}` are findings. Any other refusal of `election_date` is raised
as it is.

Both findings come last in `_entry_findings`, through
`_plan_field_findings`, the date first as `plan` writes the keys, so
`execute` refuses them through `_refuse_what_check_finds`, before #445's
checks of what an earlier run left and before any leg exists. The refusal
says no leg repository was created, and no notice of what a run left follows
it.

`_manifest_values` returns None for a plan whose `elected_on:` is such a
finding, so #314's validator is not asked about a manifest whose reference
that date chooses: the date is one finding, in this rule's words, and not
`plan-manifest-invalid` beside it or a `REFUSED` raised from
`default_reference` before the list is printed. It refuses the plan's `org:`
and reads its `id:` before that guard, so a bad date beside a bad
organisation or id does not make `check` and `execute` name different
problems first. #314's rows for `"2026-W41-6"` and `"20261011"` in
`tests/test_adopt_manifest_values.py` now expect `election-date-malformed`.

`tests/test_adopt_execute_refusals.py` gains 54 tests: `check` and `execute`
for each bad value, controls, a stricter `election_date` stood in by
monkeypatch, both findings in one plan, a bad date beside a bad `org:` or
`id:`, and an end-to-end run per issue that refuses with no leg and no
notice, then adopts from the corrected plan in the same work directory.

Closes #293
Closes #294

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 13, 2026
… writes its pin file as the scaffold does, so an adopted root passes `validate-pins.py` (#273); a `--pin-source` that cannot answer is `pin-source-unreadable` (#339) (#508)

**Brought up to date after #409 squash-landed** by merging origin/main (9736424, #409's squash) as f9ed46f. Its `scaffold-project.py` conflict was resolved to this branch's side once `git diff b70a07d 9736424 -- scaffold-project.py` (#409's final head against its squash) came out empty. So the file is the one tested on this branch plus main's own later changes to it, and holds nothing of #409's; details below.

This branch carries #275's adopt half. It was built on `fix/pin-source-env-collisions`, PR #409 (#275's scaffold half and its fix round), merged at 6494714 as 583f647, over the earlier 5690235 (34dbd86). After 6494714, #409 only merged main, five times, up to its final head b70a07d, and none of those merges has a hand edit (`git show --remerge-diff` is empty for each). Adopt's refusal of two products that share a source variable goes through #409's function, so this branch lands after #409. It was never rebased.

`git diff b70a07d 9736424` is empty for each file #409 touched: `scaffold-project.py`, `scripts/repo_shape.py`, `templates/assembly-root/scripts/validate-pins.py` and `tests/test_pin_source_env_collisions.py`. What f9ed46f resolves, four files with one hunk each:
- **`scaffold-project.py`.**
  - The conflict hunk is main's block from `PIN_COLLISION_REMEDIATION` through `_pin_collision_remediation`, which #409's squash edits: it adds `PIN_VARIABLE_REMEDIATION`, `_colliding_pin_variables` and `_pin_collision_remediation` and changes `_refuse_colliding_pins`. This branch moved all of that to `scripts/shape_materialize.py`. The hunk takes this branch's side. (The merge commit's message says "re-adds"; this is the exact account.)
  - #409's auto-merged import of `PIN_SOURCE_ENV_PREFIX, pin_source_env_collisions` is dropped, because nothing in the file uses either name any more.
  - It is not `git checkout --ours` of the whole file. Main changed the file beyond #409 since this branch last merged it: #355's `--tracking-branch` check (6c2a371) and a73b4e9's `RULESET_HINT`. Those lines stay.
  - The result is the file `git merge-tree` gives for this branch with main before the squash (518010b). Its diff from the file tested here has the same patch-id as main's own 3ef059c..518010b change to it.
- **`templates/assembly-root/scripts/validate-pins.py`.** Main's side, which is #409's final blob, with main's #359 workflow reader in it. This branch had no change of its own to the file beyond #409.
- **`adopt-project.py`, `_commit_the_split`.**
  - Main: #445 wraps the function in its late-failure `try`, and #394 builds an `append` with the plan's submodule record.
  - This branch: its only edits there were a comment and `neutral_pins=_recorded_pins(plan)` on the materializer call.
  - Both now sit on main's call inside the `try`: `append=append, neutral_pins=_recorded_pins(plan)`.
- **`docs/cli.md`.** It is regenerated with `scripts/render-cli-reference.py`, and `--check` is ok. It differs from main's only in the `--pin`, `--pin-owner` and `--pin-source` help of `adopt-project.py plan`.

`scripts/repo_shape.py`, `templates/assembly-root/scripts/validate-pins.py` and `tests/test_pin_source_env_collisions.py` are main's blobs, which are #409's: this branch adds nothing to them. Nothing of #409 is lost. Its scaffold behaviour is `shape_materialize.refuse_colliding_pins` and `pin_variable_collisions`, keyed by #409's `pin_product_key`.

**73d65c6, after the merge.**
- **The change.** `_read_pins` reads `plan.get("pins")` where it read `plan.data.get("pins")`.
- **Why.** #445's `tests/test_adopt_late_failure.py` hands `_commit_the_split` a `SplitPlan` as its plan, a `dict` that adds `entries`. On f9ed46f, three of its tests stopped at "committing the split" with `AttributeError: 'SplitPlan' object has no attribute 'data'` (3 failed, 83 passed in that file).
- **Nothing else changes.** `Plan.get` with no default returns `plan.data.get(key)` exactly, so a loaded plan reads as before.

`adopt-project.py plan --pin` took a NAME only (`--pin openGlass`) and refused a trailing `@<commit>`. `plan`, `check` and `execute` each exited 0, and `execute` printed `adoption verified`. The manifest then listed the name in `neutral_product_pins:` with no `contracts/<product>-pin.yaml` beside it, and on its first run the adopted root's own `validate-pins.py` refused it as `neutral-pin-missing`.

#273's table, reproduced on c7c02fa with the issue's own commands (local bare remotes, and a stub `gh` that exits 97):

| `--pin` | c7c02fa | this branch |
|---|---|---|
| `openGlass` | `execute` exit 0, `- openGlass`; clone's `validate-pins.py` exit 2 `neutral-pin-missing` | `plan` exit 2 `pin-malformed`, with the scaffold's remediation (`--pin openGlass@<40 hex>`); no plan is written |
| `opensoft/openGlass` | exit 0, `- opensoft/openGlass`; exit 2 `neutral-pin-missing` for `contracts/opensoft/openglass-pin.yaml` | `plan` exit 2 `pin-malformed` |
| `spec` | exit 0; `validate-pins.py` exit 0, reading the leg's `contracts/spec-pin.yaml` as the neutral pin | `spec@<sha>` is `adopt-pin-collides` at `plan` |
| `shape` | exit 0; `contracts/shape-pin.yaml` read as the neutral pin | `shape@<sha>` is `adopt-pin-collides` at `plan` |
| `openGlass@<sha>`, `opensoft/openGlass@<sha>` | `plan` exit 2 `adopt-pin-malformed` | `plan`, `check` and `execute` exit 0, and `gh` is called 0 times. `contracts/openglass-pin.yaml` is written and the manifest lists `- openGlass`. In the clone, `validate-manifest.py` exits 0, and `validate-pins.py` exits 0 with `openGlass: pin commit … digest recomputes` |

Brett ruled for decision 1's first option ("go with your recommendation on #273"): adopt takes the scaffold's pin and writes the scaffold's pin file. That reverses the NAME-only `--pin` from #6, and the comment that described it is replaced with the new contract.

- **One implementation.** The scaffold's pin code moved into `scripts/shape_materialize.py`, next to `neutral_pin_path` and the materializer that writes the file. What moved:
  - `PIN_ARG_RE`, `parse_pin`, `pin_digest_from_source` and `pin_digest_from_gh`;
  - `declared_pin_values`, `declared_pin_products` and `PIN_COLLISION_REMEDIATION` (in #309's wording);
  - from #275's branch, `PIN_VARIABLE_REMEDIATION` and the variable check;
  - `_refuse_colliding_pins`, split into the pure `pin_collisions` and `pin_variable_collisions`, and `refuse_colliding_pins(refusal=...)`, which raises one refusal for both.

  The code did not move verbatim. `_refuse_colliding_pins` became the three functions above; `pin_digest_from_source` was rewritten for #339 (below); and fix round 1 changed three of them (below): `pin_variable_collisions` keys each pin by #275's `pin_product_key`, `pin_digest_from_source` spells a path that is not printable, and `pin_digest_from_gh` refuses a missing `gh`. The scaffold's behaviour is otherwise unchanged. `scaffold-project.py` imports what it calls under its old names. Lane 5's review ran 34 scaffold cases (every pin refusal, the `gh` stub's four modes, dry and real successes) on 34dbd86 and on this branch at 7a94b6b. rc, stdout, stderr, the `gh` log and the files made were identical, apart from the shape's own commit and digest and #339's row. Its pin tests and #275's pass as they are. `tests/test_git_output_decoding.py`'s exemption for `pin_digest_from_gh` follows the function to its new file.
- **`plan`.** `--pin`, `--pin-owner` and `--pin-source` mean what they mean for the scaffold. `plan` reads the digest from `--pin-source` or `gh api`, and records product, repository, commit, digest and `digest_source` in the plan, so `check` and `execute` read no network for a pin. `plan` refuses:
  - a name with no `@<commit>`, as `pin-malformed`. That includes an empty `--pin ''`, which adopt used to drop and now refuses, as the scaffold always has;
  - `spec`, `code` and `shape`, in any case, as `adopt-pin-collides` (#273 decision 4), in the scaffold's sentence and remediation (#241, #309);
  - one product given twice, the same way, whether it is a repeat or two names that share one file (decision 5);
  - two products that one `SHAPE_PIN_SOURCE_<PRODUCT>` names, such as `open-glass` and `open_glass`, the same way, through #275's check;
  - a leg path over a pin's file, as `adopt-leg-path-collides`;
  - a `--pin-source` path that `check` would refuse in `digest_source:` (fix round 1), as `adopt-unwritable-value`. It is asked only when a `--pin` records the path;
  - with no `gh` on PATH and no `--pin-source`, `gh-not-found` (fix round 1).
- **`check` and `execute`.** These are new `check` findings, and `execute` refuses each one before any leg exists. They come from `_leg_findings`, the list `check` and `_refuse_what_check_finds` share:
  - `plan-pin-unrecorded`: a name alone (an old plan's `pins: [openGlass]`, or a hand edit), or an entry missing its commit or digest;
  - `plan-pin-malformed`;
  - `plan-pin-collides`, which covers two pins at one file, a pin at a file the shape writes, and two products that share one source variable;
  - `plan-root-holds-pin`: a source path kept in the root at the pin's file. The materializer would write the shape's pin beside it under `shape/`, and `validate-pins.py` would read the source's file instead.

  How each finding ends:
  - `plan-pin-unrecorded` and `plan-pin-malformed` end with the `plan --pin <product>@<40 hex commit>` that records the pin whole.
  - `plan-pin-collides` ends with re-running `plan` with each product once, or, for two products that share a source variable, with one product of each such group.
  - `plan-root-holds-pin` ends with the kept path's own remedy: send that entry to a leg, `drop` it, or rename it in the source and re-run `plan`.

  A leg moved over a pin's file in the plan is `plan-leg-path-collides`.
- **Also part of #386: its `pins:` row.** A hand-edited `pins:` that is not a list (a name such as `pins: openGlass`, a number, a mapping, a quoted string) is now `plan-pin-malformed` in `check` (`` `pins:` is 'openGlass', not a list of pins ``), and `execute` refuses it before any leg exists.
  - Where: `_read_pins` in `adopt-project.py`; the test row is `plan-pin-malformed-not-a-list` in `tests/test_adopt_pins.py`.
  - Before: on main, `check` said `plan ok` for the name, the mapping and the string, and gave a traceback for the number (reproduced by lane 5's review on cb7063f). #386 records that `execute` then wrote the name as eight one-letter pins.
  - Not fixed here: #386's other keys, `allow_empty_legs:`, `paths:`, `legs:` and `source:`, so #386 stays open. Lane 2's #435 takes all of #386, `pins:` included, and its `pins:` rule keeps only text items where this branch writes mappings; whichever lands second reconciles them (#435 (comment), and "Where the meaning meets" below).
- **`execute`.** It writes each pin through `materialize_assembly_root(neutral_pins=...)`. The manifest lists the product with the owner stripped (decision 3), so the entry and the file agree.
- **Docs.** `--help` is updated and `docs/cli.md` regenerated (`render-cli-reference.py --check` ok).

`pin_digest_from_source` caught only `Refusal`. A directory that is no clone is git's own failure, so it was always `pin-source-unreadable`, exit 2. A path `subprocess.run` cannot enter never reaches git: it is a `FileNotFoundError`, `NotADirectoryError` or `PermissionError` out of `cwd=`, and nothing caught it. `scaffold-project.py --pin ... --pin-source <that>` ended `--dry-run` and the real run alike in a traceback, exit 1. Since the move above, `adopt-project.py plan --pin` reads its digest through the same function and ended the same way. Both reach it through `declared_pin_values`, and `check` and `execute` read no source, so that call is the only reader.

The issue's table, run on ab623dd for the scaffold and on d806e8f for `adopt-project.py plan` (adopt has no `--pin-source` on ab623dd), then on this head. `gh` is a stub that logs and exits 97, with `GH_TOKEN` bogus and an empty `GH_CONFIG_DIR`; it was called 0 times in every run:

| `--pin-source` | before | this branch |
|---|---|---|
| a directory that is not a clone | `pin-source-unreadable`, exit 2 | unchanged |
| a path that does not exist | `FileNotFoundError` traceback, exit 1 | `pin-source-unreadable`, exit 2: `<path> could not answer for opensoft/openGlass @ <40 hex>: no such directory` |
| a file | `NotADirectoryError`, exit 1 | exit 2, `... it is a file, not a directory` |
| under a parent this user cannot enter | `PermissionError`, exit 1 | exit 2, `... Permission denied` |
| a symlink loop (not in the issue) | `RuntimeError` out of `Path.resolve()` on 3.12, exit 1 | exit 2, `... Too many levels of symbolic links` |

The real run and `--dry-run` give the same stdout and stderr, and nothing exists afterwards (no remote directory, no work directory; for adopt, no plan file).

What changed, in `scripts/shape_materialize.py` only:
- `pin_digest_from_source` catches `OSError` as well as `Refusal` and raises the existing `pin-source-unreadable` with its remediation unchanged. The message names the path and the reason.
- `stat()` judges the source inside the guard, as #312 does in `validate-pins.py`, and the sentences are that file's: a missing path, or one beneath a file, is "no such directory"; a file is "it is a file, not a directory"; any other `OSError`, from `stat()` or from git's `cwd=`, is the system's own reason (`strerror`). It is not `exists()`/`is_dir()`, which before 3.14 swallow only a missing path. A source `stat()` can read but `cwd=` cannot enter, a directory with no search bit, fails in `tree_digest` and the same guard answers it.
- `declared_pin_values` resolves the path through a guarded `_resolved_pin_source`. `Path.resolve()` raises a `RuntimeError` on a symlink loop before 3.13 (an `OSError` since), before the digest was asked for. As typed, `stat()` meets the loop and says so.
- A source that answers is read exactly as before: a relative path resolves against the working directory, a symlink to a clone is followed, `digest_source` reads `local-clone (<path as typed>)`, and the digest is `tree_digest`'s.

`tests/test_pin_source_unreadable.py` (46 tests in the #339 commit; fix round 1 adds 17 for S1 and moves one, below) runs each case through `scaffold-project.py --dry-run`, its real run and `adopt-project.py plan`: missing, a file, beneath a file, relative and missing, a parent and a source with no search bit (skipped on Windows and as root, by a mark, so before any fixture builds a directory), a 300-character name, a symlink loop (skipped on Windows) and a directory that is no clone. Each asserts exit 2, the refusal naming the path and the reason, its remediation, no traceback and nothing created. It also holds `--dry-run` and the real run to the same stdout and stderr, checks the path is printed escaped under an ASCII stderr and as a surrogate when it is not UTF-8, and checks that a relative source and a symlink to a clone read as before. The session `gh` guard fails any test whose run called `gh`.

origin/main ab623dd reached this branch through implQ's branch: 34dbd86 is a30091f merged with ab623dd. One file conflicted there, `templates/assembly-root/scripts/validate-pins.py`, in seven hunks, because #312 (8cdca5e) replaced `keyed_sources` and `default_source` with `PinSources` and #275 added `env_shared` and `shared`. #312's blob is the same at its PR head, at the squash and at ab623dd, so it landed the file implQ resolved against. The resolution keeps #312's side in all seven and adds #275's on top:

| # | hunk | resolution |
|---|---|---|
| 1 | `resolve_neutral_pin_source` signature | #312's `sources: PinSources` and 3-tuple return, plus #275's `env_shared: bool = False` |
| 2 | its docstring tail | #312's `Returns (path, how, typed)` text, plus #275's "Rule 2 is skipped when `env_shared`" paragraph |
| 3 | `_recompute_neutral_pin_digest` signature | #312's `sources`, plus #275's `shared: frozenset[str]` after `report` |
| 4 | the `resolve_neutral_pin_source(...)` call inside it | #312's 3-tuple unpack, plus `env_shared = product.casefold() in shared` and the extra argument |
| 5 | `_check_one_neutral_product_pin` signature | #312's `sources`, plus #275's `shared` |
| 6 | its call of `_recompute_neutral_pin_digest` | `sources, report, shared` |
| 7 | `_check_neutral_product_pins` docstring and body | #312's `PinSources(...)` and `_note_unused_pin_sources`, plus #275's paragraph, `_report_pin_source_collisions(products, report)` and `shared = frozenset(pin_source_env_shared(products))` |

This branch's own merge of 34dbd86 (5690235) had no conflict, and neither had its merge of #409's fix round, 6494714 (583f647). That fix round made `neutral-pin-source-collides` a note and keyed "one product" by `repo_shape.pin_product_key`. At 421e4e5, `templates/assembly-root/scripts/validate-pins.py`, `scripts/repo_shape.py` and `tests/test_pin_source_env_collisions.py` were byte-identical to the tree `git merge-tree` gives for 6494714 with origin/main 3ef059c (blobs 5604007, 091966b, 5220787), so this branch added nothing to those three files beyond #409. The 34dbd86 merge brought in #335, #312, #317, #270 (f795cf7), #344 and #349 with no conflict. Earlier merges of origin/main (87373a6, for #309's reworded constant, and e43fa95) are unchanged.

Fix round 1 merged origin/main three more times:
- **cd1fc79 (2bcfa69).** It brought #245, #351, #350, #345, #372, #371, #354, #380, #377 and the rest since ab623dd, with no conflict.
- **45e01f4 (4098b61).** It brought #396's `tests/test_agents_md_codes.py`. That test finds `scaffold-pin-collides` as a string constant of `scripts/shape_materialize.py`, which `SHIPPED` lists.
- **3ef059c (421e4e5), #314.** One conflict, one hunk in `adopt-project.py`. This branch's `_refuse_an_unrecordable_pin_source` and #314's section "The project must be able to hold what `execute` writes into it (#261)" were added at the same place; both are kept. Where the two meet, lane 5's earlier recipe applies:
  - `_flag_plan` builds each pin as the `PIN_FIELDS` mapping `plan` writes, not `list(pins)` of products. So the plan `plan` asks the validator about is the plan it writes.
  - #314's `test_plan_asks_what_check_asks_of_the_same_keys` plans `--pin openGlass@<commit> --pin-source <stand-in>` where it planned the name alone, which `plan` now refuses.

Then f9ed46f, after #409 squash-landed, and 73d65c6 after it (top of this description).

On the merged tree exactly one test failed: `tests/test_adopt_template_values.py::test_check_finds_and_execute_refuses_a_hand_edited_value[pins-surrogate]`. It plans, then hand-edits the plan to `pins: ["openX\ud800"]`, a name-only pin with a lone surrogate. Before this branch the refusal's headline was `REFUSED plan-unwritable-value`. A name-only pin in a plan is now `plan-pin-unrecorded` (#273), so `check` has a second, independent finding for that very plan, and the headline is the first finding's:

```
REFUSED plan-pin-unrecorded: 2 finding(s) ...
FINDING plan-pin-unrecorded: `pins:` entry 1, 'openX\ud800', is a name with no commit and no digest ...
FINDING plan-unwritable-value: a `pins:` item is 'openX\ud800', which `execute` writes into the assembly root's `project.yaml`, and it holds a lone surrogate ...
```

Nothing about #270's contract changed. The lone surrogate is still found as `plan-unwritable-value`, listed under the refusal, and `execute` still refuses before any leg exists. `check` builds `_leg_findings`, where `_pin_findings` sits, before it calls `_value_findings`, so the pin finding comes first. d806e8f changes one assertion for that row, `pins` only: the headline is `REFUSED plan-pin-unrecorded: ` where it was `PLAN_REFUSED`. The other 15 rows and the finding assertions, including the `plan-unwritable-value` line, are untouched.

Lane 5's review of 7a94b6b found 3 should-fix and 5 nits. Each is answered here:
- **#409's `pin_product_key` (review N1).** The merge of #409's fix round could not carry it, because this branch had moved the function that used it out of `scaffold-project.py`. `pin_variable_collisions` now keys each pin by that function, where it casefolded. `openß` and `openss` are two pin files and one `SHAPE_PIN_SOURCE_OPENSS`, and a casefold named each of the two pins twice. No `--pin` or recorded `pins:` entry `PIN_ARG_RE` admits can spell either, so the test asks the function directly. `refuse_colliding_pins`' docstring now says, in #409's words, that the root's `validate-pins.py` only notes such a pair.
- **S1, a `--pin-source` path that is not printable.** `pin_digest_from_source` names the path as typed unless it is not printable (`str.isprintable`), and then as Python's `repr` spells it (`shown_source`). That covers the refusal's head and git's own reason, which names the directory it ran in. Printed raw, an ESC reached the terminal, and a line break or a carriage return began a line of the path's choosing, a forged `REFUSED` line among them, from the scaffold and from adopt. The traceback #339 replaced had shown a `repr`. A printable path is named byte for byte as before.
- **S2, no `gh` on PATH.** `pin_digest_from_gh` refuses as `gh-not-found`, exit 2, as `repo_shape.tree_digest_from_gh` names it, and offers `--pin-source`. It was a `FileNotFoundError` traceback, exit 1, from the scaffold and, new with this branch, from `adopt-project.py plan --pin`.
- **S3, a hand-edited `commit:` or `tree_sha256:`.** The lane's decision is to accept it and document it (option (c)); no behaviour changed. A pair that disagrees passes `check` and `execute` (`adoption verified`), because neither reads a network for a pin, by design. The adopted root's own `validate-pins.py` then exits 1 with `neutral-pin-digest-mismatch` wherever it can read the product. `check` now says so once, after the pins it will write: "each pin as `plan` read it, not read again here: the root's own `validate-pins.py` recomputes its digest wherever it can read the product". Options (a), `execute` recomputes before it creates the legs, and (b), `check` recomputes from a clone still on the machine, are filed as #449.
- **N2, a `--pin-source` that `plan` cannot record.** `plan` asks `check`'s question (`one_printable_line`) of the path it records in each pin's `digest_source:`, before any digest is read. It refuses one that fails as `adopt-unwritable-value`, in #270's words (`unwritable_reason`), or as "an invisible formatting character" where those name nothing. Before, it was planned, exit 0, and printed raw in the report. `check` then refused it as `plan-pin-malformed` and said to re-run the same `plan`. The report's `pin` line and `check`'s `write` line now spell the digest's source with `_shown`. The scaffold half is pre-existing (a line break in the path is written into the pin file; a byte that is not UTF-8 dies after all three repositories exist) and is filed as #450.
- **N3, six hunks no test guarded.** Each now has a test that fails when it alone is reverted:
  - the two name guards (a `pins:` name holding an ESC and a forged line);
  - the lower-casing of the commit and the digest (an upper-cased hand edit);
  - the `digest_source` printability check;
  - the not-text check (`commit: 1234567` unquoted);
  - the gitlink filter (a submodule kept where the pin is written is `plan-submodule-root` alone).
- **N4.** This body.
- **N5, #339's third decision.** A `--pin-source` that nothing reads is still silent, now in adopt too. This PR does not decide it. #451, filed, lays out refuse, note and leave, so closing #339 loses nothing.

Lane 5's second review, of 421e4e5, found no blocking item, 2 should-fix items and 3 nits. The two should-fix items are this body: the landing after #409 (above) and #386 (above). The code needed no change for them. f63d1ed adds two tests and no code:
- **n1. `plan` asks nothing of a `--pin-source` no `--pin` records.** With the `args.pin and` guard dropped, such a path holding an ESC or a zero-width joiner was refused in a sentence naming no file ("`execute` writes into , and ..."), and every test still passed. `test_a_pin_source_no_pin_records_is_not_asked_about` holds the plan to exit 0 and `pins: []`, with the path named nowhere in the plan or the report. The ESC row is POSIX-only by the file's `POSIX_NAMES` mark; the zero-width joiner row runs everywhere. It holds #451's answer today, silence, so a decision there changes this test on purpose.
- **n2. A printable `--pin-source` is printed as it is.** With `_spelled` in place of `_shown` in `plan`'s `pin` line, every test still passed. `test_a_printable_pin_source_is_printed_as_it_is` holds `plan`'s `pin` line, and `check`'s `write` line for the plan `plan` wrote, to their exact text, ending `(local-clone (<path>))` with no backtick.
- **n3** needs a code change and is left out (below).

**Fix round 2, on f63d1ed.** Each run had a `gh` stub first on PATH (it logs and exits 97), `GH_TOKEN` bogus, an empty `GH_CONFIG_DIR`, `git-filter-repo` 2.47.0 next on PATH, and the identity blanked with `GIT_CONFIG_COUNT`; the session `gh` guard came first inside each run.
- **The pin set: 812 passed, 2 skipped.** It covers adopt pins, adopt plan, #339's file, #275's tests, the pin and neutral-pin validators, git output decoding, adopt template values, CLI reference, scaffold pin and reuse, scaffold pin collisions, #314's two files and #396's file. The skips are `test_adopt_plan.py:403` (no MedxEHR clone) and `test_adopt_template_values.py:223` (this host reads `git config user.name` from the repository, not from `GIT_CONFIG_GLOBAL`). That is fix round 1's 809 passed, plus the three new rows.
- **Shape doctor and scaffold leg-path collisions: 369 passed, 3 skipped** (the PowerShell line exists only on a Windows host).
- **Mutation probes**, each one line changed in a throwaway worktree, over `tests/test_adopt_pins.py` and `tests/test_pin_source_unreadable.py` less the five end-to-end adoptions:
  - the `args.pin and` guard dropped: 2 failed, both n1 rows;
  - `plan`'s `pin` line `_spelled`: 1 failed, the n2 test;
  - `check`'s `write` line `_spelled`: 2 failed, the n2 test and `test_an_upper_cased_hand_edit_is_written_lower_cased`;
  - `plan`'s `pin` line raw: 1 failed, the no-break-space test that already guarded that hunk.

**On the merged tree, 73d65c6** (the merge f9ed46f and the `_read_pins` commit), with the same harness:
- **The pin set** above, with #445's `test_adopt_late_failure.py`, `test_adopt_git_reads.py` and `test_adopt_filter_repo_failure.py` and #387's `test_adopt_empty_symlinks.py` and `test_adopt_root_manifest.py`: **988 passed, 2 skipped**. The skips are the two above.
- **Shape doctor, scaffold leg-path collisions and the adopt suites:** **967 passed, 3 skipped** (the PowerShell line). The adopt suites are e2e, execute refusals, verify, root shape paths (#387's too), leg-path collisions, clone template, ignored shape files and submodules.
- **On the merge alone, f9ed46f** (the same tree as before the `_read_pins` commit): `tests/test_adopt_late_failure.py` gave 3 failed, 83 passed, the three `AttributeError` rows above.
- `render-cli-reference.py --check` is ok on the merged tree. pyflakes reports the same four warnings on `adopt-project.py`, `scaffold-project.py` and `scripts/shape_materialize.py` as before the merge.

**Fix round 1, on 421e4e5.** Each run had `git-filter-repo` 2.47.0 on PATH and the identity blanked with `GIT_CONFIG_COUNT`. A `gh` stub that logs and exits 97 was first on PATH, with `GH_TOKEN` bogus and an empty `GH_CONFIG_DIR`, and the session `gh` guard came first inside each run.
- **The pin set:** 809 passed, 2 skipped (the same files and skips as above).
- **Doctor and scaffold:** 782 passed, 3 skipped (the PowerShell line exists only on a Windows host). It covers shape doctor, scaffold leg-path collisions, scaffold e2e, referent chain links, naming policy, repo hygiene and #351's free-text values.
- **The adopt suites:** 882 passed. It covers e2e, execute refusals, verify, spec-only, leg paths, root shape paths, plan escapes, leg-path collisions, clone template and submodules.
- **Before the #314 merge,** on 74ce4d9's tree: the first two sets gave 577 passed, 2 skipped and 782 passed, 3 skipped. On 2bcfa69's tree (the merges alone, before the fix) they gave 542 passed, 2 skipped and 782 passed, 3 skipped. After the 45e01f4 merge, `tests/test_agents_md_codes.py`, repo hygiene and CLI reference: 110 passed.
- **Lane 5's second review** also ran 421e4e5 merged with main cb7063f and with 53bee5b: the pin set, the manifest-value set, and the doctor set with #357's `tests/test_doctor_validator_rows.py`. All passed, with only the skips above.

Proof that fix round 1's tests test something:
- **On 2bcfa69's tree,** with this head's two test files laid over it, 31 of the new rows fail:
  - S1: 17;
  - N2 and its report spelling: 7;
  - S2: 3;
  - the `pin_product_key` port: 2;
  - `check`'s S3 line: 1;
  - the not-UTF-8 `adopt plan` row: 1.

  The N3 rows pass there, as they must: they guard hunks this branch already had.
- **Mutation probes.** Each hunk was reverted alone, in a throwaway worktree, and both test files were run: 18 of 18 were caught.
  - **The port:** the casefold keying (2 fail); the casefold lookup alone (2).
  - **S1:** the path named raw (13); git's reason named raw (2); every path `repr`'d, printable or not (27).
  - **S2:** no `except` (3).
  - **N2:** no `plan` check (11); only #270's question asked (1); the formatting-character reason said otherwise (1); the report's `pin` line raw (1); `check`'s `write` line raw (1).
  - **S3:** the line dropped (1).
  - **N3:** the `_pin_fix` name guard (1); the `_read_pins` path guard (1); the lower-casing (1); the `digest_source` check (1); the not-text check (1); the gitlink filter (1).
- **The issue rows reproduced.** S1, S2 and N2 were reproduced on 2bcfa69's tree and shown gone on the fix (a probe driving both tools; `gh` was called 0 times).

**Before fix round 1.** Runs were on 7a94b6b, with `git-filter-repo` 2.47.0 first on PATH and the repository-local `user.name` blanked with `GIT_CONFIG_COUNT` (a workstation whose standard checkout sets one fails `test_the_scaffold_refuses_with_no_elector` on main itself; CI sets none). 7a94b6b differs from ffd1297 in `tests/test_pin_source_unreadable.py` only, so the three suites that do not read that file ran on ffd1297:

- the new file: 46 passed;
- adopt pins, adopt plan, git output decoding: 172 passed, 1 skipped (the MedxEHR clone is absent);
- scaffold pin and reuse, pin collisions, e2e, referent chain links, neutral pin and pin validators, #275's tests, naming policy, repo hygiene, CLI reference, scaffold leg-path collisions: 571 passed;
- shape doctor: 254 passed, 3 skipped (the PowerShell line exists only on a Windows host);
- the new file with repo hygiene, git output decoding and CLI reference, once more after the last edit: 176 passed.

Earlier on 5690235 and d806e8f, before the #339 commit: the adopt suites (e2e, execute refusals, verify, spec-only, leg paths, root shape paths, plan escapes, leg-path collisions, clone template) 740 passed, and `tests/test_adopt_template_values.py` with #273's other adopt tests 489 passed, 2 skipped. They were not re-run on 7a94b6b: the #339 diff touches `pin_digest_from_source` and the call in `declared_pin_values`, which those suites reach through `test_adopt_pins.py` only. Nothing was run on macOS or Windows.

Proof the new tests test something:
- **On d806e8f.** 38 of the 46 fail, each on the traceback and its exit 1. The 8 that pass are the unchanged behaviour: the not-a-clone refusal (4 rows) and the valid-source rows (4).
- **Mutation probes.** Ten hunks reverted one at a time, each failing at least one new test: the `OSError` catch (16 fail), the catch narrowed to `OSError` only (21), the missing-path clause (9), the clause narrowed to `FileNotFoundError` (3), the `S_ISDIR` judgement (3), the loop guard (4), no resolve at all (3), the old `.resolve()` call (4), the reason as `str(exc)` (12), and the file sentence as the OS's (3).
- #273's own 52 tests still fail on c7c02fa (51 fail or error; the one that passes checks the test's own table), and its 18 mutation probes were each caught.

**Re-verified after the merge.** An independent re-verification of f9ed46f and 73d65c6 found the code correct:
- #409 is carried whole. A 30,000-case fuzz of `--pin` lists through #409's final refusal and this branch's port differed in 0 cases.
- `scaffold-project.py` equals the tested file plus main's own changes that are not #409's (same patch-id).
- `_commit_the_split` keeps #445's `try` and #394's `append`.
- `_read_pins` reads a loaded plan as before.

Its findings were all in this description (the #506 bullet and three stale facts), now fixed. Runs on the head and on its merge with main 48585e6: 988 passed, 2 skipped; and 967 passed, 3 skipped.

`git merge-tree --write-tree` was run at 01:48Z on 2026-10-13, comparing hunks per file. Each PR head, fetched from `refs/pull/<n>/head` with no `gh`, was merged with 73d65c6 and with origin/main 9736424 alone. Two sets were asked about:
- every PR this description listed before that main does not now contain;
- every PR numbered above #468 whose head main does not contain: #473, #474, #490, #491, #493, #494, #495, #496, #497, #503, #504 and #505.

**Open state, read with `gh` by the coordinator at 02:00Z and refreshed at 02:25Z after a re-verification on main 48585e6.** Every pull request named below is open. #184, #94, #96, #98, #100 and #161 are closed, and #465 landed as 48585e6. `fix/adopt-plan-quote-yaml-keywords` has no pull request. #506 (`fix/scaffold-reuse-seeded-legs`) opened after this list was made, and the re-verification reads it.

- **Landed since the last draft of this list:** #343, #346, #355, #358, #373, #378, #382, #387, #394, #397, #401, #404, #409, #419, #452, #456, #465, #466 and #484. #394's `_commit_the_split` conflict and #401's `docs/cli.md` conflict are both resolved in f9ed46f, above.
- **Clean, 20:** #266, #347, #356, #374, #376, #384, #395, #453, #467, #468, #473, #474, #490, #491, #493, #494, #495, #497, #503 and #505. #453, #468 and #491 were updated onto main since the first read and now merge clean. #507 (lane 5's #486-#488) shares only the generated `docs/cli.md` with this branch and merges clean with it.
- **Conflicting with main alone, and this branch adds no hunk:**
  - #402: `shape-doctor.py` 5;
  - #496: `shape-doctor.py` 10 and `tests/test_shape_doctor.py` 1.

**Conflicts this branch adds:**
- **#435** (3efcc7a, lane 2's `fix/adopt-plan-field-types`, for #386).
  - It adds 2 hunks in `adopt-project.py`: 5 here, against 3 with main alone. They are `render_plan`'s `pins:` line and `Plan.pins`.
  - Keep this branch's `_pin_lines` and `_pin_products`. The rest is under "Where the meaning meets".
- **#504** (37ef29a, `fix/adopt-root-kept-symlinks`).
  - It adds 1 hunk in `adopt-project.py`: 2 here, against 1 with main alone. It is the materializer call in `_commit_the_split`.
  - Keep both arguments and both comments: `append=append, neutral_pins=_recorded_pins(plan), spell=_spelled`.
- **#506** (119d8c4, `fix/scaffold-reuse-seeded-legs`).
  - It adds 1 hunk in `scaffold-project.py`. With main alone, #506 conflicts only in `docs/handbook.html`.
  - #506 inserts `LEG_TEMPLATES` right after `declared_pin_products`, which this branch moved out, and rewrites `_reusable_remotes` further down. Keeping both sides breaks the file with an unterminated string literal.
  - Keep only #506's `LEG_TEMPLATES` block. Drop #506's `declared_pin_values`/`declared_pin_products`, which moved here, and this branch's three-line `_reusable_remotes` opener, which #506 rewrites below. Resolved that way, pyflakes is clean and #506's scaffold tests pass (88).
- **`fix/adopt-plan-quote-yaml-keywords`** (ed9968f; a branch with no pull request).
  - It adds 2 hunks in `adopt-project.py`: 3 here, against 1 with main alone.
  - One is `render_plan`'s `pins:` line. Keep `_pin_lines`, which already sends product, repository and `digest_source` through `y`.
  - The other is `_refuse_unholdable_values`' signature. Keep `pins: dict`, with that branch's `_validator_judged` and `_named_as_read` above it.
- `fix/adopt-late-failure-notice` is no longer on origin. #445, the late-failure notice, is on main and merged here.

**Where the meaning meets:**
- **#435 and #386.** The coordinator has said this on #435: #435 (comment)
  - #435's `_plan_key_findings` (`plan-bad-pins`) keeps only text items in `pins:`, and its `PINS_REMEDIATION` tells a person to write `pins: [openGlass]`.
  - This branch writes each pin as a mapping, and it makes a name alone `plan-pin-unrecorded`.
  - Read from #435's code, not run: on a tree with both, the plan `plan --pin` writes is `plan-bad-pins` in `check`.
  - Whichever lands second reconciles them, with three changes:
    - `_plan_key_findings` takes this branch's mappings, or leaves `pins:` items to `_read_pins`;
    - a `pins:` that is not a list gives one finding, not both `plan-bad-pins` and `plan-pin-malformed`;
    - `PINS_REMEDIATION` says the mapping `plan` writes.
  - Until that is done, `tests/test_adopt_pins.py::test_a_plan_with_a_whole_pin_checks_ok` fails.
- **#314.** It landed, and it is merged here (above).
- **`fix/adopt-plan-quote-yaml-keywords`.** Its pin tests are written against the name-only `--pin`. They need the `name@commit` form, and their expected `pins: [...]` line becomes `_pin_lines`'s mapping.

- **Two unwritable values are refused in two runs (lane 5's second review, n3).** `plan --elected-by 'A<LF>B' --pin openGlass@<sha> --pin-source <a path holding an ESC>` refuses the elector alone, exit 2. Re-run with the elector retyped, it refuses the source; #270's refusal names every unwritable value at once. Reproduced on 421e4e5, with `gh` called 0 times. One refusal would need a code change, not made here:
  - `_refuse_an_unrecordable_pin_source` would split into a function that returns the sentence;
  - `_checked_plan_inputs` would ask it before #270's `unwritable_values` raise, and raise one `adopt-unwritable-value` that names both, with both remediations;
  - a test would add the two-value row.

  N2's question is `one_printable_line`, not `unwritable_reason`, so it cannot simply join #270's list.
- **Docs.** The README, AGENTS.md and `docs/handbook.html` are unchanged, because none of them calls adopt's `--pin` a name. A sentence saying adopt now takes the scaffold's `--pin` is a follow-up. It costs an AGENTS.md line-cap raise, and a README edit forces the handbook's README blob to change.
- **A product spelled `null`, `true` or `false`.** The scaffold already accepts `--pin null@<sha>`: it exits 0 and builds a root whose `validate-pins.py` exits 1 `neutral-pin-entry-malformed`. Adopt's `plan` writes `product: null` through `y`, and `check` then finds `plan-pin-malformed` and refuses it before any leg exists. The one fix for both tools is in `parse_pin`, and it would change the scaffold's behaviour.
- **Wording shared with the scaffold.** `digest_source` is written bare into the pin file, so a `--pin-source` path holding ` #` is cut short there. `templates/assembly-root/contracts/neutral-product-pin.yaml` and `validate-pins.py`'s `neutral-pin-missing` remediation name only the scaffold as the writer. Two comments in `scripts/repo_shape.py` still name the scaffold's `pin_digest_from_gh`, in a digest-pinned shipped copy.
- **Filed as follow-ups:**
  - #449: S3, a hand-edited `commit:` or `tree_sha256:` that disagrees, with options (a) and (b);
  - #450: the scaffold half of N2, a `--pin-source` path no file can hold;
  - #451: #339's third decision (N5), a `--pin-source` that nothing reads.
- **`validate-pins.py` prints a `--pin-source` path raw** in `neutral-pin-source-unreadable`, the twin of S1. It is a shipped template that #409 and other open PRs edit, so it is left for its own change. No issue is filed for it yet.
- **Windows and macOS** were not run here; CI ran both (below). Two assertions that depend on what Windows answers for a path (a name past the filesystem's limit, a path beneath a file) check only the refusal and its remediation there. Fix round 1's rows that need a control character or a byte that is not UTF-8 in a path, or a symlink to `git`, are skipped on Windows by a mark, and so is fix round 2's ESC row. The rows for a no-break space and a zero-width joiner run everywhere, and so does the in-process `gh-not-found` test.

Reviewed independently by lane 5 at 7a94b6b (3 should-fix, 5 nits: fix round 1) and at 421e4e5 (0 blockers, 2 should-fix, 3 nits: fix round 2). After #409's squash, the re-verification of f9ed46f and 73d65c6 found the code correct; its 1 should-fix and 3 nits were in this description and are fixed. GitHub's update-branch then merged main twice (33bae3f, 2170526), with no hand edit. CI's tests, tests-macos and tests-windows and SonarCloud all passed at 2170526 (main cac6bb2 merged in). On that head merged with main 57c85e9 (#490, #474 and #468, none of which touches this branch's files), the pin and adopt set with `tests/test_update_shape_landing.py` gave 1112 passed, 2 skipped, and the doctor, adopt end-to-end, unfinished-merge and bootstrap set gave 1056 passed, 3 skipped (the doctor's PowerShell line, which exists only on a Windows host).

Closes #273
Closes #339

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 13, 2026
#435 was stacked on #356, which squash-landed on main as 965420b. Its
final head 2fdfbed is a server-side merge of main into #356's 5982bbe, so
it brings #356's last fix (the `org:` refusal and `id:` read ahead of the
election-date guard), #445's REFUSED contract and #401's help text. This
is step 1 of bringing a stacked branch onto main after its parent's
squash: `git merge-tree 2fdfbed 3efcc7a` was rc 0, and this merge is
clean.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 13, 2026
… list of mappings, so #386's gate asks it only whether it is a list, in #273's own finding (#386)

Main since #356's squash 965420b changed `adopt-project.py` three times:
70d0d1b (#384, `_spelled` in three findings), 56713e2 (#493, the
tracking branch from the source's default branch) and aa4e1f5 (#508,
lane 5's #273 and #339). aa4e1f5 makes `plan --pin` take
`product@<40 hex>` and write each pin as a MAPPING of `product:`,
`repository:`, `commit:`, `tree_sha256:` and `digest_source:`, and its
`_read_pins` judges each `pins:` entry beside the entries:
`plan-pin-unrecorded` for a name alone, `plan-pin-malformed` for anything
else `plan` never writes there, and for a `pins:` that is no list.

Two hunks conflicted, both in `adopt-project.py`, both the ones
aa4e1f5's message names: `render_plan`'s `pins:` line takes main's
`_pin_lines`, which writes the product through `y` and the commit and
digest quoted, so this branch's `y(pin)` is superseded; and `Plan.pins`
takes main's `_pin_products`, which reads a list only.

The rest is the reconciliation aa4e1f5 and lane 5's comment on #435
asked of whichever landed second. As this branch stood, its item check
took only text, so every plan `plan --pin` now writes was `plan-bad-pins`
in its own `check`:
- `_plan_key_findings` no longer judges `pins:` entries: they are
  `_read_pins`' alone, in its words and codes.
- A `pins:` that is no list is still asked first, beside the other four
  keys and before the work directory, because the naming policy reads the
  root's products from it. Its finding is `_read_pins`' own
  `plan-pin-malformed`, so it is one finding under one code, and
  `plan-bad-pins` is gone.
- `PINS_REMEDIATION` names the mapping `plan` writes.
- `PLAN_KEY_REFUSAL` begins "no leg repository was created and nothing was
  pushed", the words every refusal before a leg says (#445; conftest's
  `refused_before_any_leg` asserts them, and #273's not-a-list row now
  meets this refusal).

`tests/test_adopt_execute_refusals.py` says the same: the `pins:` key rows
expect `plan-pin-malformed` and gain a single mapping with no list around
it; the entry rows move to a test that the gate is silent and
`_read_pins` names them; the controls and the corrected end-to-end plan
are whole pins; and the round trip runs `plan --pin <name>@<commit>
--pin-source` for 2026, 007, 1.5 and openGlass, and holds `check` to
`plan ok`.

`git diff --stat cc8f2da` of the result lists only `adopt-project.py`
and `tests/test_adopt_execute_refusals.py`. Before this commit:
`tests/test_adopt_execute_refusals.py` 150 passed and
`tests/test_adopt_pins.py` 70 passed.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 13, 2026
… leg that is empty or whose one commit is the scaffold's own seed, and the ruleset hint offers that re-run, by an operator holding the bypass right, as its one exit, so a project a ruleset refused part-way is finished by one re-run, not refused `scaffold-remote-exists` for its own legs or sent to a pull request with no base (#462) (#506)

A run whose push an organisation's ruleset refused left its repositories
behind: all three empty when the first push, the spec leg's, was refused,
and a leg at its one seed commit for each leg pushed before a later push
was. `RULESET_HINT` offered two exits and neither finished. The pull request
had no base, because the refused push was the first into a repository
`gh repo create` had made without a README. The re-run was refused
`scaffold-remote-exists` for the scaffold's own legs, with the flag or
without it, and after an operator had seeded a leg by hand. Brett Heap ruled
on 2026-10-12: "Offer only exits that work", "Re-run reuses empty legs" and
"Accept the scaffold's own seed".

- `--reuse-empty-repo` reuses each leg as it reuses the root: when it exists
  with zero commits, asked as before of `remote_is_empty` or
  `local_remote_is_empty`.
- It also reuses a leg whose ONLY commit has exactly the tree this run would
  seed it with. `only_commit` requires one object name across every ref
  `git ls-remote` lists, fetches that commit alone (`--depth=1`) into a
  scratch bare repository, and requires that it has no parent. `seed_tree`
  builds the leg's template again in a scratch directory and takes
  `git write-tree`. The commit's message, author and date are not compared;
  the leg templates take no date, login or commit.
- A leg reused at its seed is not given a new commit: `_take_held_seed`
  fetches that commit into the work clone and resets to it, so the pin and
  the gitlink name the commit the remote already has, and the push that
  follows changes nothing or creates the tracking branch at it (a seed
  pushed to `seed/scaffold` alone).
- Any other leg with commits is still `scaffold-remote-exists`; its remedy
  says it holds commits other than the one seed this scaffold would push,
  and that there is no `--force`. An empty or seeded leg met without the
  flag is told to re-run with `--reuse-empty-repo`.
- A reused leg keeps its GitHub visibility, and a run whose `--visibility`
  disagrees is warned, as a reused root already is.
- `RULESET_HINT`, the scaffold's, and the new `FAMILY_RULESET_HINT`,
  `family.py init`'s, each offer ONE exit: an operator holding the bypass
  right re-runs the same command with `--reuse-empty-repo` and a new
  `--work-dir` (for the family, `--into` or `--work-dir`), pushing nothing
  first, since a repository pushed into by hand is no longer empty. The seed
  branch and the pull request are named only as what cannot finish the run.
  Both hints take the two fields `{repo}` and `{tracking}`.
- AGENTS.md's ruleset rule, README's paragraph on a partway scaffold, the
  handbook's matching sections, both `--help` texts and docs/cli.md say the
  same. README and AGENTS.md keep their line counts.

Unchanged: the root is reused only when empty, and a root with commits
still names `adopt-project.py`; a leg that does not exist is created as
before; nothing deletes, rewrites or force-pushes a remote; `family.py init
--reuse-empty-repo` reuses an empty holder as before. adopt-project.py is
byte-identical to the base: #445 gave adoption its own `ADOPT_RULESET_HINT`,
whose one exit is a re-run once the legs its notice names are deleted, and
tests/test_ruleset_hint_tracking_branch.py now pins that text beside the
other two and takes each tool's exit through to a finished run.

Left out: a family holder that holds its own seed is still refused, because
the ruling covers the scaffold's legs; and adoption's hint still calls the
pull request "the scaffold's other exit", which this change retires.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 14, 2026
… remedy prints its `REFUSED` line alone, the assembly root's `validate-pins.py` still adds the lockstep remedy to its own, and `add` refuses a `.gitmodules` git cannot read as `gitmodules-unreadable` with the holder gate's remedy, so a family holder, a shape pin or an adoption plan is no longer told to run `validate-pins.py` and move a leg pin (#370) (#494)

`repo_shape.Refusal` defaulted its remediation to `REMEDIATION`, the
assembly root's lockstep remedy: run `git submodule update`, then
`validate-pins.py`, and move a leg's gitlink, `contracts/<leg>-pin.yaml`
and its workflow `@<sha>` refs in one commit. Every refusal built without
a remedy of its own printed it, in every tool and every kind of root,
including those `git_out`, `load_yaml` and `find_repo_root` raise.

- `Refusal`'s remediation defaults to none, and `str()` of a refusal with
  none is its `REFUSED` line alone. `repo_shape` cannot know which tool or
  root it refuses for, so it assumes no remedy rather than one tool's.
- `REMEDIATION` keeps its name and its bytes. `validate-pins.py`, the
  assembly root's gate and the one place it is right, prints it under each
  refusal it makes that names no other, its own and those `repo_shape`
  raises for it. It imports only that name, which every `repo_shape.py`
  a root was ever given defines, and beside one that still defaults to it
  it prints it once.
- `family.py add`, and `add --dry-run`, refuse a `.gitmodules` git cannot
  read as `gitmodules-unreadable`, in the words and under the code the
  holder's `validate-family.py` uses for the same file (#300), where it was
  `git-failed`. With no `.gitmodules` in the working tree git reads the
  index's copy or HEAD's, and the remedy then says which and how to put it
  back.
- `shape-doctor.py`'s `refusal_line` quotes a refusal with no remedy whole,
  and a row a remedy-less refusal ends has no NEXT, where it carried the
  lockstep sentence.
- `adopt-project.py`'s report of a failure once a leg repository exists
  left the lockstep remedy off by comparing against it (#445); it leaves an
  absent one off too, so no empty line follows its `REFUSED` line.

Measured on a scaffolded root, a family holder and three members in local
bare remotes, main against this head, 36 runs. On 22 the lockstep trailer
is gone and the `REFUSED` line stands alone: the holder's
`validate-family.py`, `bootstrap.py` and `family.py siblings`, `family.py`
`add`, `bump` and `init`, `update-shape.py check` on a shape pin with no
`files:` rows, `validate-manifest.py`, the root's `bootstrap.py`,
`bump-leg.py`, `validate-repository-naming.py` and `adopt-project.py
check` on a plan. On `add` and `add --dry-run` it is the two lines the
holder's gate prints.

Unchanged: every remedy a caller passes, and `validate-pins.py`'s output,
byte for byte on the six refusals measured (`pin-tag-only`,
`yaml-unparsable`, `shape-pin-missing`, `pin-submodule-uninitialized`,
`not-a-git-repo`, `manifest-unreadable`); the holder gate's
`gitmodules-unreadable`, `adopt-project.py`'s placement-plan refusal and
the doctor's `manifest` row, also measured. No refusal code changes but
`add`'s, from `git-failed` to `gitmodules-unreadable`, which AGENTS.md does
not name.

`scripts/repo_shape.py` and `templates/assembly-root/scripts/validate-pins.py`
are pinned copies, so `update-shape.py check` reports both as
`upstream-changed` in every assembly root, and `repo_shape.py` in every
holder; `validate-family.py` is not changed.

Left out: per-site remedies for the refusals that now print their line
alone, and narrowing `validate-pins.py`'s lockstep remedy on its refusals
that are not about a leg (`shape-pin-missing`, `manifest-missing`,
`neutral-pin-*`).

Lane: openreposhape-3 (openRepoShape-3)
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