Skip to content

adopt-project.py: check follows each symlink the plan keeps where the shape writes its files, and execute refuses one the shape cannot write through, or one it writes beneath that git checks out as a file, before any leg exists, so such a link no longer breaks the run after both legs are pushed or has the shape's files written outside the assembly (#183, #178) - #504

Merged
brettheap merged 52 commits into
mainfrom
fix/adopt-root-kept-symlinks
Oct 14, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-1 (openRepoShape-1)
Claim: #183 (comment)
Claim: #178 (comment)

One defect, two reports: #183 (lane 1) and #178 (lane 2, its N3 and section 4c). This PR closes both.

The last merge of main made and tested here is 9e8c672, main 8beb3d9 into 64b2cc0, the merge of cf118ff into 3872e3c, after gh pr update-branch had merged main 751cb2f (0b6c6f4), 89c6a6c (053e725) and 173c00e (3872e3c), which touch none of this PR's files; any later update-branch merges only such commits, and the coordinator checks that before each one. main was merged in twenty-seven times up to 9e8c672 (see "Merges"), and every comparison with main below is against 8beb3d9, at 9e8c672, unless it names another. The head is 2460548: one commit on 9e8c672 that changes only a test helper, declare(), so what was measured at 9e8c672 holds for it.

What

adopt-project.py writes the shape's files into the assembly by plain path. So a symlink the plan keeps in the root, at or above one of those paths, gets followed.

On main, check never asks where such a link leads. #262 (71b58a1) has it report a path the plan keeps AT the shape's copy under shape/, a symlink there included, but it follows no link. So execute creates both leg repositories before it finds out. What happens next depends on the link, in the cases under "Why":

adopt-project.py: check reports the link, and execute refuses it before any leg.

core.symlinks=false (#183): a link a shape path is written BENEATH is refused, adopt-symlinks-off, before any leg; a link AT a shape path is the file git makes of it.

  • In such a checkout the assembly's clone holds a symlink as a plain file. On main the materializer fails at one a shape path is written beneath, with NotADirectoryError or FileExistsError, after both legs are pushed; at one AT a shape path it finds the file and writes the copy beside it.
  • The disk is asked through a throwaway repository that git init makes in the command's work directory, whose core.symlinks is read as UTF-8 (scripts/shape_materialize.py run() decodes every git and gh message with the locale codec, so a push rejection or any other failure that names a non-ASCII path raises UnicodeDecodeError in place of REFUSED and the ruleset hint under a non-UTF-8 locale, after both leg remotes exist #217). It asks a real repository because git init writes false on a filesystem that cannot hold a symlink, and a global, system or GIT_CONFIG_* setting can say false on one that can. It is asked only when a walk needs the answer, a link AT a shape path the walk cannot follow, or, in execute's own check, a link a shape path is written beneath, and at most once by each check (_SymlinkProbe), so an adoption with no such link makes no probe.
  • execute refuses a link a shape path is written beneath, rather than forcing core.symlinks=true, which would fail after the legs on a filesystem that cannot hold a symlink. A case twin is named with check's aside.
  • A link AT a shape path that the walk cannot follow (it dangles, leads out, loops) is, where git checks it out as a file, that file: the path is taken, place() writes the copy beside it, the copy under shape/ is walked, and neither command refuses anything about where the link leads (B-OFF of the re-verify of e48df71). check, _refuse_what_check_finds and _refuse_symlinks_checked_out_as_files each ask their own work directory. So CLAUDE.md linked by absolute path to a file on the machine it was written on adopts beside it on Git for Windows' default, as on main. Where no repository can be made in the work directory (a --work-dir this user cannot write), the disk is taken to hold symlinks, as before B-OFF, so the finding prints instead of a traceback (J4).

scripts/shape_materialize.py: defence in depth, for a caller that skipped check.

Kept and refused, decided with evidence. The row names are the driver cases under "Why". Each refusal below of a link AT a shape path holds where git checks a symlink out as one (B-OFF), and covers no name under the four .git trees (J1).

  • Accepted end to end, as on main:
    • a link whose target the root keeps, where the shape's scripts still land one directory under the root. This covers:
      • scripts -> tools, contracts -> governance, .github -> . and contracts -> .;
      • shape -> vendor, where vendor/ holds none of the copies' names;
      • .github -> ci, with and without its own validate.yml;
      • a chain through ..;
      • on Linux and macOS, .github -> C:/ci into the source's own C:/ci (G-drive-above-present);
    • a link AT a shape path to something that is there, but at project.yaml, where adopt-project.py check passes a plan that keeps the source's own project.yaml in the root, and execute writes the shape's manifest beside it as shape/project.yaml and says adoption verified, so shape-doctor.py calls the adopted assembly NOT A SHAPE ROOT and its first make bootstrap fails #305 refuses any link on both. In each case the copy goes beside the link, and the split keeps the link as the source had it. This covers:
      • CLAUDE.md, .gitignore or Makefile linked to a file outside by its absolute path;
      • AGENTS.md linked to a directory outside;
      • CLAUDE.md -> AGENTS.md, which works because the shape writes AGENTS.md first;
      • AGENTS.md -> spec/docs/agents.md, where the spec leg holds that file;
      • CLAUDE.md -> .git/HEAD (G-git-at-head), and links to the 13 other places in the clone's .git that the I3-GIT- cases name (see "Why");
    • where git checks a symlink out as a file, a link AT a shape path wherever it leads: the copy goes beside the file (I3-OFF-at-abs-missing, -at-dangle, -D3, -at-twin).
  • Refused: the shape's scripts in any other directory. Each script imports repo_shape from beside itself and takes Path(__file__).resolve().parents[1] as the root. So with scripts -> tools/sub, scripts -> . or scripts -> tools/.., main says adoption verified and the first bootstrap fails (R2, R6, R9, D11).
  • Refused where git checks a symlink out as one: a link AT a shape path to where nothing is, even where main writes through it and bootstraps (D3, AT-dangle-dir-present, P-agents-to-claude-dangling, I3-ON-at-dangle). The shape's file lands at the link's destination, and the shape path stays a link.
  • Refused: a link AT a shape path into what the clone's .git does not hold (G-git-at-description, I3-GIT-at-hooks, -info, -head-lower). main says adoption verified with the shape's CLAUDE.md inside .git, and no shape/CLAUDE.md.
  • Refused, though main adopts beside it: a target that takes .. inside .git (J2 of the re-verify of 95f6fc7, for Tracking: adopt-project.py, converting an existing repository in place #457's checklist), such as CLAUDE.md -> .git/refs/../HEAD or -> .git/../AGENTS.md. The finding says it "passes through .git/refs, which is not a directory in the assembly root", which is false there: a walk takes .. only from a directory the planned root holds, and counts none in .git, so that a link ABOVE a shape path cannot lead into it (D9).
  • Refused: a case twin that leads out of the tree (E-case-out, E-case-scripts-abs, P-case-above-out, P-case-at-out). .GitHub -> ../outside-ci, .GITIGNORE -> ../outside-gitignore and Scripts -> <absolute dir> adopt on main on Linux. A macOS or Windows disk follows them as .github, .gitignore and scripts, out of the tree.
  • Asked of the disk: a link AT a shape path that climbs out (AT-rel-out-exists, P-at-rel-out-exists). Take Makefile -> ../shared/Makefile.
    • check with no --work-dir asks its own temporary directory. Nothing is there, so it refuses, and its sentence names the place it asked.
    • execute --work-dir work, where work/shared/Makefile exists, adopts beside the link, verified and bootstrapped, as main does.
    • The same --work-dir gives check the same answer.

What did not change:

  • A link no shape path runs through, such as an unrelated notes/upstream -> /etc, is not looked at. The split keeps it exactly as the source had it.
  • A link the plan sends to a leg is not followed. The root no longer holds it when the shape is written.
  • _verify's lookup in the split commit, which is main's, is unchanged but for one line: it too says "follows too many symlinks at X" where it said "runs round a symlink loop at X".

docs/cli.md is unchanged: it renders --help, which takes only the module docstring's first line. The docstring's check paragraph names this check.

Why (repro on main)

The review's drivers and lane 2's were run on main 8beb3d9 and on this branch at 9e8c672, with nine more cases (G-) for the re-verify's findings of 6c44591 and the merge, and 25 (I3-) for its findings of e48df71, read apart below. Each of the 114 cases runs check, execute with local remotes, a recursive clone and make bootstrap. Since 75052cb, main reports a failure once the leg repositories exist as #188 does, REFUSED <step>. with the exception or failed command and a notice, exit 2.

class cases main 8beb3d9 this branch
the same 58 identical
fixed 45 breaks once both leg repositories exist, writes outside the assembly, or verifies and its first bootstrap fails refused before any leg: plan-root-symlink (40), adopt-symlinks-off (5)
refused on purpose 8 verifies and bootstraps refused before any leg (see "Kept and refused")
asked of the disk 2 verifies and bootstraps check in its own temporary directory refuses; execute --work-dir where the target is verifies
refused by both 1 plan-root-blocks-shape-copy before any leg the same, and this branch's own finding beside it (G-dangle-at-with-copy, see "Reviews")

On this branch, none of the 54 cases it refuses left a leg repository or wrote outside the assembly. On main, every one of the 45 fixed cases left both leg remotes behind, which a re-run into the same --local-remote-dir meets as leg-remote-exists, and seven also wrote the shape's files outside the assembly ("wrote outside" in the table below).

Rows worth reading against main as it is now:

Every case this branch changes (56), with its class
case class main 8beb3d9 (check 0 unless said) this branch (a code alone: check 1; execute 2)
D3 refused on purpose execute 0; verified; bootstrap 0 plan-root-symlink
D4 fixed execute 2; FileNotFoundError plan-root-symlink
D5 fixed execute 2; FileExistsError plan-root-symlink
D6 fixed execute 1; adopt-shape-file-missing, shape-copy-missing; bootstrap 1; wrote outside: validate.yml plan-root-symlink
D6b fixed execute 1; adopt-shape-file-missing; bootstrap 0; wrote outside: validate.yml plan-root-symlink
D7 fixed execute 2; FileExistsError plan-root-symlink
D7b fixed execute 2; a git command failed plan-root-symlink
D9 fixed execute 2; FileExistsError plan-root-symlink
D11 fixed execute 0; not-a-git-repo; verified; bootstrap 2 plan-root-symlink
D12 fixed execute 1; adopt-shape-file-missing, shape-copy-missing; bootstrap 1 plan-root-symlink
R2 fixed execute 0; yaml-unreadable; verified; bootstrap 1 plan-root-symlink
R6 fixed execute 0; not-a-git-repo; verified; bootstrap 2 plan-root-symlink
R9 fixed execute 0; not-a-git-repo; verified; bootstrap 2 plan-root-symlink
R10 fixed execute 0; verified; bootstrap 1 plan-root-symlink
AT-dangle fixed execute 2; FileNotFoundError plan-root-symlink
AT-dangle-dir-present refused on purpose execute 0; verified; bootstrap 0 plan-root-symlink
AT-abs-missing fixed execute 1; adopt-shape-file-missing; bootstrap 0; wrote outside: Makefile plan-root-symlink
AT-rel-out fixed execute 1; adopt-shape-file-missing, shape-copy-missing; bootstrap 1; wrote outside: work/Makefile plan-root-symlink
AT-rel-out-exists asked of the disk execute 0; verified; bootstrap 0 check 1; execute 0; plan-root-symlink; verified; bootstrap 0
AT-into-mount fixed execute 2; a git command failed plan-root-symlink
AT-into-leg-path fixed execute 2; FileNotFoundError plan-root-symlink
AT-loop fixed execute 2; OSError plan-root-symlink
AB-contracts-dangle fixed execute 2; FileExistsError plan-root-symlink
AB-scripts-abs fixed execute 1; adopt-shape-file-missing, not-a-git-repo; bootstrap 2; wrote outside: pycache/repo_shape.cpython-312.pyc, bootstrap.py, repo_shape.py, validate-manifest.py, validate-pins.py, validate-repository-naming.py plan-root-symlink
E-case-out refused on purpose execute 0; verified; bootstrap 0 plan-root-symlink
E-case-scripts-abs refused on purpose execute 0; verified; bootstrap 0 plan-root-symlink
E-trailing-out fixed execute 2; FileExistsError plan-root-symlink
E-dotdot-from-file fixed execute 2; NotADirectoryError plan-root-symlink
E-shape-root fixed execute 2; materialize-copy-blocked plan-root-symlink
E-shape-dir-with-readme fixed execute 2; materialize-copy-blocked plan-root-symlink
E-link-mid-dotdot fixed execute 2; FileExistsError plan-root-symlink
E-hops-40 fixed execute 2; OSError plan-root-symlink
E-nonutf8-dangling fixed execute 2; FileExistsError plan-root-symlink
AB-deeper-link-out2 fixed execute 2; FileExistsError plan-root-symlink
N1-env fixed execute 2; NotADirectoryError check 0; execute 2; adopt-symlinks-off
N1-global fixed execute 2; NotADirectoryError check 0; execute 2; adopt-symlinks-off
N1-system fixed execute 2; NotADirectoryError check 0; execute 2; adopt-symlinks-off
N6-global-scripts fixed execute 2; FileExistsError check 0; execute 2; adopt-symlinks-off
N7-global-github-root fixed execute 2; NotADirectoryError check 0; execute 2; adopt-symlinks-off
S1-shape-root-makefile-dir fixed execute 2; materialize-copy-blocked plan-root-symlink
P-agents-to-claude-dangling refused on purpose execute 0; verified; bootstrap 0 plan-root-symlink
P-at-into-extracted-leg-missing fixed execute 2; a git command failed plan-root-symlink
P-at-rel-out-exists asked of the disk execute 0; verified; bootstrap 0 check 1; execute 0; plan-root-symlink; verified; bootstrap 0
P-at-abs-missing-claude fixed execute 0; verified; bootstrap 0; wrote outside: CLAUDE.md plan-root-symlink
P-at-chain-abs-missing fixed execute 1; adopt-shape-file-missing; bootstrap 0; wrote outside: Makefile plan-root-symlink
P-case-above-out refused on purpose execute 0; verified; bootstrap 0 plan-root-symlink
P-case-at-out refused on purpose execute 0; verified; bootstrap 0 plan-root-symlink
P-shape-root fixed execute 2; materialize-copy-blocked plan-root-symlink
P-shape-vendor-readme fixed execute 2; materialize-copy-blocked plan-root-symlink
P-shape-root-makefile-dir fixed execute 2; materialize-copy-blocked plan-root-symlink
G-drive-at fixed execute 2; FileNotFoundError plan-root-symlink
G-drive-above fixed execute 2; FileExistsError plan-root-symlink
G-git-at-description refused on purpose execute 0; verified; bootstrap 0 plan-root-symlink
G-git-above fixed execute 1; adopt-shape-file-missing, shape-copy-missing; bootstrap 1 plan-root-symlink
G-copy-link-through fixed execute 2; materialize-copy-blocked plan-root-symlink
G-dangle-at-with-copy refused by both check 1; execute 2; plan-root-blocks-shape-copy check 1; execute 2; plan-root-blocks-shape-copy, plan-root-symlink

The materializer alone (mathole), where a caller skipped check: the three links "What" names raise the exceptions it names on main 8beb3d9, and here each is materialize-blocked: `Makefile` cannot be written: the target of the symlink `Makefile` goes on from `ci`, which is not a directory here (the other two from a and from README.md); Makefile -> ci writes ci inside on both.

D6b's absolute target is one fixed directory, which main writes workflows/validate.yml into; it was empty when each run began.

Tests

tests/test_adopt_root_symlinks.py, new, 149 tests.

Targeted run at 9e8c672: 114 files. They are this file; every adopt, scaffold and family test file, and every update-shape one but the two among the nine below; test_shape_doctor.py, test_windows_paths.py, test_cli_reference.py, test_repo_hygiene.py and test_manifest_validator.py; test_pin_validator.py, test_setup_project_py.py, test_setup_sh.py and test_openreposhape_command.py, which main changed since af01068; and every test file main added since 39ac59c. Since 6c44591 that adds 79.

Both runs started and ended on 9e8c672 with a clean worktree. The nine other test files that import the materializer (test_bump_leg, test_commit_trailers, test_naming_policy, test_park_resume_targets, test_reference_default, test_shape_advisory, test_update_shape_add, test_update_shape_add_ignored, test_yaml_flow_quotes): 623 passed, 1 skipped both with and without git filter-repo, exit 0. The skip needs a disk that ignores case.

Revert table at 6b53a6d; M1 at e48df71, 16 rows at 95f6fc7, M44 at 19a5ba5, and M0, M50 and M70-M74 at f8e7176. Each mutation was applied in its own clone, and tests/test_adopt_root_symlinks.py run (M59: main's two test files of _verify). M0 is no change; M1-M23 are the 04792d2 table's; M24-M48 take back each review answer; M49-M52 the spelling of the materializer's refusals; M53-M63 answer the re-verify of 6c44591, M64-M69 that of e48df71, and M70-M74 that of 95f6fc7. M35 went with its code in f2696f3. Each row ran again wherever a merge or a fix before 1535590 changed its line or its kills, and none lost a kill; since #329, M44 fails 3, not 4; M1, M4 and M16 still kill the fourth. The table is f8e7176's; 1535590's, 77fcdb7's and a79936f's revert lines are below.

mutation what it takes back result
M0 nothing 126 passed
M1 the symlink findings left out of _entry_findings, for both commands 43 failed
M2 execute drops the symlink findings while check keeps them 39 failed
M3 the shape's scripts accepted in any directory 3 failed
M4 a dangling destination, or one the plan sends away, accepted 15 failed
M5 a link into a mount, or through a file, accepted 5 failed
M6 an absolute target followed as a relative one 7 failed, 2 errors
M7 climbing above the root ignored 8 failed
M8 the materializer's _refuse_unwritable call removed 18 failed
M9 no core.symlinks probe in execute 2 failed
M10 the probe always says symlinks work 10 failed
M11 every kept link a shape path meets refused 43 failed, 4 errors
M12 the probe made even with no link beneath a shape path 2 failed
M13 the materializer's blocker check removed 6 failed
M14 the materializer follows an absolute link 1 failed
M15 the collision copy under shape/ not walked 5 failed
M16 one finding per shape path instead of per link 39 failed
M17 assembly_root_paths() without the copy pin 1 failed
M18 the probe always says symlinks are off 20 failed
M19 a link AT a shape path triggers the probe 8 failed
M20 the materializer refuses links that stay inside 4 failed
M21 every kept link walked, not only those shape paths meet 8 failed
M22 a link the plan sends to a leg followed 4 failed
M23 the remediation claims check names what is in the way 1 failed
M24 B1: a destination outside is never there 4 failed, 2 errors
M25 B1: a destination outside is always there 4 failed
M26 B1: a link AT a shape path walked as one above it 11 failed, 2 errors
M27 B1: the shape's own earlier writes not simulated 2 failed
M28 B1: a leg mount holds nothing 2 failed
M29 B1: a seeded leg holds nothing 1 failed
M30 B1: a mount entered AT a shape path refused 3 failed
M31 B1: a climb back in by the assembly's name not followed 1 failed
M32 B1: a climb into what execute makes asked of the disk 1 failed
M33 B1: every climb refused 3 failed
M34 B2: a copy led onto something there accepted 4 failed
M36 S1: no case-folding walk 2 failed
M37 S1: the case-folding walk finds no twin 3 failed
M38 S1: no case aside in check's finding 2 failed
M39 S1: no case aside in adopt-symlinks-off 1 failed
M40 S2: .. and . taken from anything 3 failed
M41 an empty target walked as the root 1 failed
M42 an empty target given the usual fix 1 failed
M43 the materializer reads an empty target as . 2 failed
M44 N6: _spelled puts the backticks round a name and escapes nothing 3 failed
M45 N1: the remediation names no work directory 1 failed
M46 the materializer's place writes through a link AT the path 5 failed
M47 every kept link walked, and each one refused 55 failed, 4 errors
M48 the probe made even when the tree holds no symlink 1 failed
M49 _ticked returns the name raw 15 failed
M50 execute hands the materializer no spell 1 failed
M51 spell not handed on to _refuse_unwritable 2 failed
M52 a link's target named raw in _blocker 4 failed
M53 fix A: seen starts empty, so a link AT a copy is said by both rules 1 failed
M54 fix A: every link under shape/ left to #262 1 failed
M55 fix B: #262's copy_in_the_way switched off in place() 2 failed
M56 fix C: the old sentence, "the shape's copy would be written over it" 3 failed
M57 F1: check calls 41 links "a symlink loop" 3 failed
M58 F1: the materializer calls them "a symlink loop" 3 failed
M59 F1: _verify calls them "a symlink loop" (main's two test files) 7 failed
M60 F2: a drive letter's path absolute on every OS 3 failed
M61 F3: the clone's own .git not counted 11 failed
M62 F3: everything under .git/ counted 2 failed
M63 F3: .git counted as a directory 1 failed
M64 B-OFF: a link AT a shape path followed even where git makes it a file 7 failed
M65 B-OFF: every link AT a shape path taken as a file 14 failed
M66 B-OFF: the link and the shape path compared keeping case 1 failed
M67 B-OFF: check's list probes every plan with a link 2 failed
M68 2b: only round F's five places in .git counted 9 failed
M69 2b: CLONE_GIT_TREES not counted 6 failed
M70 J4: the probe's OSError not caught 1 failed
M71 J4 pins: no work directory means symlinks off 1 failed
M72 J4 pins: the probe made in the system temp, not the work directory 1 failed
M73 J4 pins: _a_file_here without its AT test 1 failed
M74 J4 pins: the probe's answer not kept 1 failed

P1 and P2 run the six links_as_links functions (38 cases) on a runner whose own core.symlinks is false: with the pin, all pass; with it removed, 14 fail, M65's 14.

Every one of f8e7176's 126 tests fails under at least one row; the kill table showing which is in this body's edit history (the versions posted at f8e7176 and 81071ae), cut for length.

The errors under M6, M11, M24, M26 and M47 are kills too. They come from the module fixtures behind the end-to-end tests, which assert that check exits 0 on their source, and the mutated check refuses there. M59's 7 failures are in main's two test files.

Revert lines of this round's commits, each run before its commit (the commit messages give each run):

Byte identity. The same adoption was run twice, by main 8beb3d9 and by 8beb3d9 with this branch's adopt-project.py and scripts/shape_materialize.py from 9e8c672 laid over it, at the same paths with fixed dates. For each of six sources (an ordinary one, .github -> ci, scripts -> tools, an unrelated notes/upstream -> /etc, and, with --pin openGlass@<commit>, an ordinary one and contracts -> governance), execute exits 0 with adoption verified on both, and the split, spec and code commit shas are equal.

Fuzz runs at d11cf9f, 6c44591 and e48df71: in this body's edit history.

Platforms

Reviews

review183, of 04792d2: 2 BLOCKERs, 2 SHOULD-FIXes and 11 NITs.

item answered in how
B1 d11cf9f; feecd8c, cf2afb7, 95f6fc7 A link AT a shape path is decided by what place() will find; a destination outside is asked of each command's own disk, execute's before any leg (see "What").
B2 d11cf9f; d8039c6, f2696f3, 34e20c6 The copy under shape/ is walked, and a link leading it onto something there refused; shape -> vendor (R7) adopts. #262 covers the rest (d8039c6, f2696f3).
S1 d11cf9f, b37159a A second walk folds case; its findings carry #198's aside.
S2 d11cf9f, c275456 .., . and an empty part are taken only from a directory, so mathole's three tracebacks are named refusals.
N1-N6, N9, N10 d11cf9f; main, #263; c275456, cf2afb7 Each answered; how is in this body's edit history, cut for length.
N7, N8, N11 left source.tree() is read once more; with check's list off, a link into a mounted leg fails at git add, after the legs, as #188 reports; each plan-root-symlink line carries its own fix.

rv183f, the re-verify of 6c44591: every item above answered, with 3 BLOCKERs by its gate, false and stale sentences in this body, one refusal put up for a ruling, and NITs.

item answered in how
F1: 41 links and no loop called "a symlink loop" 86a5e30 "follows too many symlinks at l40: more than 40, ...", in check, the materializer and main's _verify.
F2: a drive letter's target called "an absolute path" on Linux and macOS cf2afb7 Absolute only on Windows; elsewhere followed as the relative path it is, so W-drive-above-present adopts as on main.
F3: CLAUDE.md -> .git/HEAD refused where main completes feecd8c, 95f6fc7 Accepted beside it, as on main (2b below).
body sentences false at 6c44591, or since main moved this body Corrected; every comparison is against e6fbd74.
AT-dangle-dir-present, for a ruling kept Brett ruled on 2026-10-12 to keep this refusal.
the merge of 71b58a1: a link AT the copy said twice, _CASE_ASIDE bound twice d8039c6; 2f0ca85 See "Merges".
NIT: "false" 3 times, two of them main's 95f6fc7 FALSE_WORD, used by y, BOOLEAN_WORDS and the probe: once.
NIT: macOS gives up after 32 symlinks 86a5e30 SYMLINK_HOPS' comment says so (no macOS here).
NIT: DANGLE-at-with-copy left #262's over-approximation prints beside this branch's true plan-root-symlink; 71b58a1's message documents it.

rv183f, the re-verify of e48df71: one BLOCKER by its gate, a residue put up for a ruling, four sentences of this body false, and NITs.

item answered in how
B-OFF: with core.symlinks=false, check refused a link AT a shape path that dangles or leads out, though git makes it a file and main writes the copy beside it 95f6fc7 Each command asks its own work directory whether git checks a symlink out as one, only for such a link; where not, the path is taken (see "What").
2b: links to .git/index, .git/packed-refs, .git/logs/HEAD and .git/refs/heads refused where main adopts beside them 95f6fc7 Ruled: no false refusal. CLONE_GIT and CLONE_GIT_TREES count what the clone's .git holds by then, as measured; description, hooks, info and any other name stay refused.
four body sentences: 2f0ca85's tree, dangling links, the clone's .git, core.symlinks=false this body Corrected.
NIT: #262's sentence for a link AT the copy says execute would write through it, false since copy_in_the_way left main's text.
NIT: adopt-name-too-long named as #261's this body It is #314's.

rv183f, the re-verify of 95f6fc7: one SHOULD-FIX new in it, a residue for a ruling, NITs and test NITs.

item answered in how
J4: a --work-dir this user cannot write ended check and execute in a PermissionError traceback, where e48df71 printed the finding 168f474 Where the probe raises OSError, the disk is taken to hold symlinks: the finding again, and no traceback.
J1: four directories of .git counted whole, so a link to a missing name under them gets main's outcome 168f474, this body Ruled: no code change; the comments and "What" say what is counted and what that misses.
J2: .. inside a .git target refused with a false sentence where main adopts left Ruled: no code change here; disclosed under "Kept and refused", for #457's checklist.
J3: under core.symlinks=false, check's reason for a link ABOVE a shape path left Ruled the same; under "Not in this PR".
test NITs: four points unpinned, SYMLINKS_OFF over the runner's GIT_CONFIG_*, "GIT_CONFIG_COUNT" 3 times 168f474 Four tests (M71-M74); the B-OFF check tests append; the literal once.

rv183f, the delta confirm of 81071ae: K1, SHOULD-FIX (no test pinned walk.empty, so M41 killed nothing), is answered by 77fcdb7's two cases; K2 and K3, NITs (#303's place in check's list; which CI jobs failed at 37ef29a), are corrected in this body.

rv183g, the re-verify of d92b747: P1, SHOULD-FIX, check did not follow #273's pin files, so a link only a pin's file runs through stopped execute after both legs: a79936f. N1, NIT, the 1535590 line read as Brett's ruling: reworded below. N2, NIT: this body is current to 2460548.

The other commits:

Merges

main was merged in twenty-seven times up to 9e8c672, never rebased:

main merged as brought in (issues) conflicts, each resolved by keeping both sides
22c9fdd a2bd407 #175, #176, #192 none
e4decfb 7b5af45 #166 2: _refuse_what_check_finds, now _entry_findings carrying the symlink findings, and the module docstring's check paragraph
20e13f0 a61ccbe #172 4: assembly_root_paths()'s docstring, the module docstring, the import list, _entry_findings's docstring
a5b09c3 04792d2 #179, #174 none
54aa69a 9621949 #165 none
39ac59c b37159a #199, #213, #208, #197, #202, #229 2: the import list (the union) and assembly_root_paths()'s docstring. Also _CASE_ASIDE, which #197 moved and the plain merge left unbound.
60588d5 c53acc1 #214, #217, #209, #252, #195, #210, #211, #248, #212, #251, #206 1: the import list, the union. Then 8f624aa for #217's guard.
b30595c 6c44591 #207, #272, #205, #215, #241, #249 and #250 none
71b58a1 2f0ca85 #204, #203, #238, #196, #254, #242, #262 and #263 3, in adopt-project.py: the module docstring's check paragraph, _entry_findings' docstring and its return line, with the symlink findings appended. 71b58a1 imports _CASE_ASIDE, so this branch's line goes.
8cdca5e cd4d8b3 #299, #216, #260, #253, #302, #288, #279, #306, #334, #239, #243, #321, #295 none
a2cb7d3 222ae7b #300, #255, #332 2, in _entry_findings: its docstring and its return; #255's plan-unwritable-value findings come after the symlink findings.
92e29d9 bfdd6bf #296, #190, #282, #331, #326, #337, #304, #276, #323, #319 4, in adopt-project.py: the module docstring's check paragraph, _entry_findings' docstring and return, and cmd_execute's order. Then a3a4da9 and 6b53a6d.
dd8d0ea e48df71 #330, #333, #327, #348, #258 and #261, #366, #301 and #280, #278, #297 and #298, #289, #369, #326, #292 2, in _entry_findings: its docstring and its return; #261's plan-manifest-invalid findings come after #255's values and before #190's relative urls.
75052cb 6fd068f #237, #383, #412, #171, #188 and #177 5, in adopt-project.py: the import list; Source, with this branch's work_root and #188's cloned; and _commit_the_split, whose #188 steps keep spell=_spelled. Then 91ac26b.
437a360 e6ed890 #361, #367 and #368, #365 and #360, #385, #285, #341, #329, #283, #340, #391, #290 none. #329 spells a byte that is not UTF-8 \udcNN in every finding, this branch's too. Then 19a5ba5 and 168f474.
7a8b981 f8e7176 #307, #189 1, in _commit_the_split: the one materializer call takes #189's append and this branch's spell=_spelled.
5a25387 37ef29a #359, #324, #303 and #305, #477, #291 none (gh pr update-branch). #303 named an empty-target link a second time beside plan-root-symlink; 1535590 leaves it to #303.
48585e6 81071ae #405, #320, #275, #411 1, in the module docstring's check paragraph: this branch's symlink clause, then #320's split-branch clause.
965420b 108a54c #440, #293 and #294 2, in _entry_findings, its docstring and return: plan-root-symlink after the submodules, #293/#294's findings last.
af01068 d92b747 #338, #475, #436, #424, #273 and #339, #478, #479, #480 and #481, #476 and #492, #325, #328, #322, #483 4, in adopt-project.py: the module docstring's check paragraph, _entry_findings' docstring and return (#479's finding after #261), and the split's materializer call (#273's neutral_pins=, spell=_spelled). BOOTSTRAP, imported again by #480, leaves this branch's import list.
d41cc29 1bdffca #259, #489, #225, #498, #485, #386, #462, #286, #277 none. #386's type gate on the plan's keys comes first in both commands, before the walk.
e6fbd74 a49a0f8 #514 none. Each pin entry in project.yaml reads back as its text; the walk asks the same pin files.
751cb2f 0b6c6f4 #488, #486, #487 none (gh pr update-branch); none of this PR's files
89c6a6c 053e725 #516 none (gh pr update-branch); none of this PR's files
173c00e 3872e3c #500 none (gh pr update-branch); none of this PR's files
cf118ff 64b2cc0 #499 none. plan-not-the-source is a finding of its own beside plan-root-symlink.
8beb3d9 9e8c672 #501, #502 none. A pinned copy's follow-up now says how its row goes; the walk asks the same paths.

Checked on each merge's tree:

Four corrections to old merge messages: in this body's edit history.

Which refusal comes first. execute refuses in this order:

  1. adopt-project.py: Plan.__init__, allowed_empty_legs, names and open_source iterate or call .get on a hand-edited plan's pins:, allow_empty_legs:, paths:, legs: and source: without asking their type, so a scalar ends check and execute in a TypeError or AttributeError traceback, exit 1, and pins: openGlass passes check, adopts, and writes eight one-letter neutral-product pins that the project's own validate-pins.py refuses #386's findings (PR adopt-project.py: check names, and execute refuses before the work directory is made, a plan whose source: or legs: is not a mapping or whose allow_empty_legs: or paths: is not a list of what plan writes, and asks the same first of a pins: that is not a list, so a number or a boolean in any of the four no longer ends both in a traceback and allow_empty_legs: code is no longer read as four legs (#386) #435) on the plan's keys (plan-bad-source, -legs, -allow-empty-legs and -paths, and plan-pin-malformed for a pins: that is no list), with adopt-project.py: check and execute iterate a plan's follow_ups: without asking for a list, so a hand-edited plan with follow_ups: 5 (or true) ends check in a TypeError: ... is not iterable traceback, and execute in the same one after both leg repositories are created and pushed #293/adopt-project.py: check never reads a plan's elected_on: when the plan has no reference:, so plan ok is followed by an execute that creates both leg repositories and only then refuses election-date-malformed, naming --elected-on, a flag neither command takes #294's, before the work directory is made or the source opened; check prints them alone, so such a pins: never reaches the walk. Next, once the source is open, adopt-project.py plan never compares --project (or --org) with the repository it adopts in place, so a source named billing-service is refused with a remediation that steers to --project BillingService, which plans and checks plan ok against acme/billing-service, and execute then acts on acme/BillingService: a raw git ls-remote failure when no such repository exists, and, when a different repository of that name does, both legs, the split branch, the pull request and the topic on that repository #499's plan-not-the-source (PR adopt-project.py: --org and --project must name the repository the source names, compared without case, so plan refuses --project BillingService for acme/billing-service as adopt-not-the-source instead of writing a plan whose execute acts on acme/BillingService, check finds and execute refuses a plan edited to name another as plan-not-the-source, and a source whose own name the naming policy refuses is told to rename it first, never to retype --project (#499) #513), which check prints after the findings in 2.
  2. check's findings, in check's order, after its path findings (adopt-project.py check accepts plan paths execute refuses as unsafe-value (a space or a non-ASCII character), so plan ok is followed by a refusal and #166's split instructions can name entries execute will refuse #196's, then adopt-project.py check passes a source whose tree holds a symlink with an empty target, so with symlinks on execute creates both leg repositories and then fails cloning the source (on main with exit 2 and nothing on stderr, #171), and with core.symlinks=false it says adoption verified over a leg or root that no clone with symlinks on can check out #303's plan-empty-symlink): plan-leg-path-collides (adopt-project.py: a leg's mount path must not be, hold or lie inside a path the assembly root holds, so --spec-path contracts no longer checks ok and dies staging the split after both legs are pushed, and --code-path project.yaml no longer says adoption verified over an assembly with no manifest (#172) #198), plan-root-blocks-shape, plan-root-blocks-shape-copy and plan-root-blocks-manifest (adopt-project.py check passes a plan that keeps the source's own shape/ in the root, and place() then writes the shape's collision copy onto it after both legs are pushed: a real shape/README.md is overwritten while execute prints "nothing was overwritten" and ends adopt-lost, and a shape/Makefile/ directory or a file named shape is a traceback #262, adopt-project.py check passes a plan that keeps a plain file at a directory the shape writes into (.github, contracts, scripts), and execute dies in a NotADirectoryError or FileExistsError traceback after both legs are pushed #263, adopt-project.py check passes a plan that keeps the source's own project.yaml in the root, and execute writes the shape's manifest beside it as shape/project.yaml and says adoption verified, so shape-doctor.py calls the adopted assembly NOT A SHAPE ROOT and its first make bootstrap fails #305), adopt-project.py --pin <name> is accepted by plan, check and execute ("adoption verified"), but writes only the name into neutral_product_pins: and no contracts/<name>-pin.yaml, so the adopted root's own validate-pins.py (and its validate.yml) refuses with neutral-pin-missing from the first run #273's pin findings, the source's own submodules (adopt-project.py: a plan that sends .gitmodules to one leg and a submodule it registers to another passes check and execute, says adoption verified, and the assembly's recursive clone then fails: plan should ask, check should find, execute should refuse #166), plan-root-symlink, plan-unwritable-value (adopt-project.py: since #247 a byte that is not UTF-8 in --elected-by, --reference or the --source directory passes plan and check, and execute pushes both legs before it dies with UnicodeEncodeError in write_lf #255), plan-manifest-invalid (adopt-project.py and scaffold-project.py accept values the new project's own validate-manifest.py refuses (a blank --elected-by or --reference, --elected-on 2026-W41-6 or 20261011 on Python 3.11+, --org test+org, a plan's visibility: secret), so adoption verified or a scaffold exit 0 is followed by a failed first bootstrap; and plan admits a 251-255-character --project that check refuses #261), plan-tracking-branch-missing (adopt-project.py: a source whose default branch is not main is adopted with both legs pushed, then its pull request is refused (--base main), and the printed way out repeats --base main #479), then plan-submodule-relative-url (adopt-project.py keeps a source submodule's relative url = ../x unchanged in the leg, where git resolves it against the leg's remote, so execute says adoption verified and the assembly's recursive clone fails unless the dependency happens to sit beside the leg #190), resolved from the remotes execute would create, then election-date-malformed and plan-bad-follow-ups (adopt-project.py: check never reads a plan's elected_on: when the plan has no reference:, so plan ok is followed by an execute that creates both leg repositories and only then refuses election-date-malformed, naming --elected-on, a flag neither command takes #294, adopt-project.py: check and execute iterate a plan's follow_ups: without asking for a list, so a hand-edited plan with follow_ups: 5 (or true) ends check in a TypeError: ... is not iterable traceback, and execute in the same one after both leg repositories are created and pushed #293). All come before any leg, and just before them come scaffold-project.py and adopt-project.py ask the project's YAML reader and validate-manifest.py about the manifest's values before anything exists, so --id null, --tracking-branch true and a blank --elected-by are refused (#258, #261) #314's adopt-name-too-long, of a repository name too long to be a directory's, then adopt-project.py: since #247 a byte that is not UTF-8 in --elected-by, --reference or the --source directory passes plan and check, and execute pushes both legs before it dies with UnicodeEncodeError in write_lf #255's adopt-unwritable-value, of a local clone URL no file can hold.
  3. adopt-symlinks-off; adopt-project.py: a failure AFTER both legs are pushed (the split-branch push, the mount, the split commit) prints only REFUSED <step> with no notice of what already exists and no stdout flush, so the combined log shows REFUSED before the bare … lines and the re-run meets leg-remote-exists unexplained #188's adopt-work-dir-occupied; the tracking branch asked of the remote (adopt-project.py: a source whose default branch is not main is adopted with both legs pushed, then its pull request is refused (--base main), and the printed way out repeats --base main #479); adopt-project.py: a failure AFTER both legs are pushed (the split-branch push, the mount, the split commit) prints only REFUSED <step> with no notice of what already exists and no stdout flush, so the combined log shows REFUSED before the bare … lines and the re-run meets leg-remote-exists unexplained #188's adopt-branch-exists; then adopt-project.py: plan, check and execute never look for the plan's split branch (adopt/three-repo-shape) in the repository the split is pushed to, so a re-run after an earlier run left it passes check, creates both legs, and dies at a non-fast-forward push of the split #320's plan-split-branch-taken, which check prints with adopt-branch-exists after plan-stale and before every entry finding. All before any leg.
  4. 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 mount guard, adopt-mount-occupied or adopt-mount-unaddable, at the mount, after both legs are pushed; since adopt-project.py: a failure AFTER both legs are pushed (the split-branch push, the mount, the split commit) prints only REFUSED <step> with no notice of what already exists and no stdout flush, so the combined log shows REFUSED before the bare … lines and the re-run meets leg-remote-exists unexplained #188 printed as REFUSED mounting the legs. with the notice.
  5. The materializer's refusals, after the mount: materialize-copy-blocked (adopt-project.py check passes a plan that keeps the source's own shape/ in the root, and place() then writes the shape's collision copy onto it after both legs are pushed: a real shape/README.md is overwritten while execute prints "nothing was overwritten" and ends adopt-lost, and a shape/Makefile/ directory or a file named shape is a traceback #262) in place(), then _refuse_unwritable's, each printed as REFUSED committing the split. with the notice.

Four plans that more than one of these refuses were run on 6c44591, and again on 6b53a6d, e48df71, 91ac26b, 19a5ba5, f8e7176, 37ef29a, 1535590, 81071ae, 108a54c, d92b747, 1bdffca, a49a0f8, 64b2cc0 and 9e8c672. check and execute ran as commands, then execute ran in-process with earlier refusals switched off. Every cell below is the same on all fifteen heads. What was printed differs only in main's own words: #262/#263's remediation, #216's literal pathspec, and #188's REFUSED <step>. and notice.

plan check (exit 1), in order execute with #198's root-kept half off with check's list off (REFUSED mounting the legs., the notice)
keeps .github -> ci and ci/; spec_path: ci plan-leg-path-collides (ci holds the kept ci/workflows/ci.yml), then plan-root-symlink (it enters ci, where the plan mounts the spec leg) REFUSED plan-leg-path-collides carrying both; no leg remote REFUSED plan-root-symlink; no leg remote adopt-mount-occupied, after both legs were pushed
keeps .github -> ../outside-ci; spec_path: .github plan-leg-path-collides twice (it holds the shape's validate.yml; it is the kept symlink), then plan-root-symlink (it climbs above the root) REFUSED plan-leg-path-collides carrying all three; no leg remote REFUSED plan-leg-path-collides (the shape's half), with plan-root-symlink; no leg remote adopt-mount-occupied (.github, a symlink)
keeps .github -> spec/ci and ci/; spec_path: ci plan-leg-path-collides, then plan-root-symlink (spec/ci is not in the source tree) REFUSED plan-leg-path-collides; no leg remote REFUSED plan-root-symlink; no leg remote adopt-mount-occupied
keeps .github -> ../outside-ci; spec_path: .github/spec plan-leg-path-collides (it is inside the symlink .github), then plan-root-symlink (it climbs above the root) REFUSED plan-leg-path-collides; no leg remote REFUSED plan-root-symlink; no leg remote adopt-mount-unaddable (beyond a symbolic link)

Not in this PR

Drafted as its own follow-up issue:

  • D13: contracts -> governance, where governance/ already holds a file at the copy pin's path. main and this branch both say adoption verified, and the first bootstrap exits 1. The pin is a collision, not a link problem; it is lane 2's separate P3-1.

What this PR listed as (i) and (ii) are now main's, through #262 and #263 (see "Merges").

Known limits, for #457's checklist:

  • J3: with core.symlinks=false, check's reason for a link ABOVE a shape path is the one for a disk that holds symlinks (scripts -> tools/deep: "it lands at tools/deep/validate-pins.py, ..."), though the clone holds scripts as a file there. The refusal is right; its "change where it points" leads on to adopt-symlinks-off.
  • J1 (see "What") and J2 (see "Kept and refused").
  • A link AT the shape's pin to a pin's file: with a pin, contracts/shape-pin.yaml -> openglass-pin.yaml in a kept contracts/ passes check on both. place() writes the pin first, so the shape's pin goes beside the link, under shape/, and execute fails verification after both legs (adopt-shape-pin-unreadable; bootstrap 1), as on main: like D13, a kept path at the shape's pin.
  • Reftable (git 2.45 and later): .git/reftable is not counted, so a link to it would be refused where main adopts beside it. Untested here, on git 2.43.

Closes #183
Closes #178

🤖 Generated with Claude Code

brettheap and others added 30 commits October 9, 2026 19:01
…eg exists, a symlink the plan keeps in the root that a shape file cannot be written through, so a link that dangles, enters a leg, loops or leaves the tree no longer ends in a traceback, or in `validate.yml` written outside the assembly, after both legs are pushed (#183, #178)

The shape's files are written into the assembly by plain path, so a
symlink the plan keeps in the root at or above one of them is followed.
`check` asked nothing about such a link and `execute` made both legs first.
On main f25d805 (lane 2's table, re-run): a dangling link, a link into a
path sent to a leg or into a mount, and a loop died in a Python traceback;
`.github -> ../outside-ci` or an absolute target wrote the shape's
`validate.yml` OUTSIDE the assembly and still said `adoption verified`.
Every case left both leg remotes behind.

`check` now follows every path the shape writes, and its copy under
`shape/` where the path is taken, through the root the plan keeps (the
source's root-kept paths, their symlinks, the two mounts). It reports
`plan-root-symlink`, naming the link, its target and the shape path, for a
link that:

- dangles (its target is not in that tree), or leads to a path the plan
  sends to a leg or drops;
- leaves the tree (an absolute target, or `..` above the root);
- enters a leg mount, or a submodule kept in the root;
- runs round a loop;
- carries the shape's scripts anywhere but ONE directory directly under the
  root. Each script imports `repo_shape` from beside itself and takes
  `Path(__file__).resolve().parents[1]` as the root, so `scripts -> tools`
  bootstraps and `scripts -> tools/sub` or `scripts -> .` fails the first
  bootstrap.

`execute` refuses the same findings through #168's `_refuse_what_check_finds`,
before any leg exists. The paths come from the materializer's own lists,
through `assembly_root_paths()`, which has #172's name and shape so the two
branches merge mechanically.

A link no shape path runs through is not looked at. A link whose target
the root keeps is still followed end to end (`.github -> ci`,
`scripts -> tools`, `contracts -> governance`, `shape -> vendor`, chains
through `..`). A DANGLING link is refused even where main wrote through it
and bootstrapped: behind `.gitattributes -> config/attrs`, git 2.43 says
"unable to access '.gitattributes': Too many levels of symbolic links" and
applies no attributes, so the shape's LF guarantee is lost.

`core.symlinks=false` (#183): the assembly's clone holds a link as a plain
file, and the materializer died there with NotADirectoryError after the
legs. `execute` now asks a throwaway repository in the work directory, only
when the plan keeps a link the shape writes beneath. It refuses before any
leg as `adopt-symlinks-off`. That is a refusal rather than a forced
`core.symlinks=true`, because the false value usually means the filesystem
cannot hold a symlink, and there the clone would fail after the legs.

Defence in depth: the materializer refuses, before the `mkdir` and the
write, a path that leads out of its target (`materialize-outside`), runs
round a loop (`materialize-symlink-loop`), or meets a file or a dangling
link where it needs a directory (`materialize-blocked`). An ordinary tree
is written exactly as before. `SYMLINK_HOPS` now lives in the materializer
and adopt imports it.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and the `load_yaml` non-UTF-8 refusal, with no conflict

Main touches `_seed_leg`, `copy_tree`, `stage_written` and `git_init_commit`;
this branch touches `place()`, `check`'s findings and `execute`'s refusals.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…D6's link out, so the case fails when `check` follows a link the root no longer holds (#183, #178)

`the-link-itself-sent-to-a-leg` sent `.github -> ci` to the code leg
with `ci/` kept in the root. A `check` that followed the link anyway
reached a directory the root keeps and found nothing wrong, so the case
passed under every mutation of the revert table, M22 included (`a link
sent to a leg is followed`).

It is now D6's own link, `.github -> ../outside-ci`, sent to the code leg
instead of kept. Main's `check` and this branch's both say `plan ok` for
it; with M22 applied it is a `plan-root-symlink` finding, so the case now
fails when the rule it pins is broken. It moves from ACCEPTED, whose
targets the root keeps, to UNRELATED, the links no shape path runs
through.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…symlink check, as it names #169's mount-path check, so it again lists everything `check` validates (#183, #178)

The paragraph lists what `check` validates, and #169 extended it when it
added the mount-path check. This branch added `plan-root-symlink` without
a word there. No other document names `check`'s findings: docs/cli.md
renders each tool's `--help`, which takes only the docstring's first line,
so it is unchanged and `tests/test_cli_reference.py` still passes.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…` names what is in the way, so a plain file kept where the shape needs a directory is not told a check that said `plan ok` would have caught it (#183, #178)

`materialize-blocked`, `materialize-outside` and `materialize-symlink-loop`
share one remediation, and it said "`adopt-project.py check` names it
before any leg exists". That holds for a symlink, which `check` now
follows. It does not hold for a plain FILE the plan keeps where the shape
needs a directory. Take a source holding a file named `.github`, kept in
the root: `check` says `plan ok`, `execute` makes both legs, and then
prints `REFUSED materialize-blocked: .github/workflows/validate.yml
cannot be written: .github is a file, not a directory` with that
sentence under it (main 22c9fdd dies there in a NotADirectoryError
traceback, also after both legs).

The remediation now says only what is true in every case: nothing was
written at that path; send what is in the way to a leg or `drop` it, and
re-run into fresh legs. A comment beside it says why it names no `check`.
`test_a_file_where_the_shape_needs_a_directory_is_a_refusal` asserts the
remediation names no `check`, and fails with the old sentence.

A plain file in the way is still found only after the legs. Reporting it
in `check` is a new finding, beyond this issue's symlinks.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…_entry_findings`, the one list `check` and `execute` share, now carries the root-symlink findings

#166 (via #194) replaced the separate compositions in `check` and in
#168's `_refuse_what_check_finds` with `_entry_findings`. This branch had
added `_root_symlink_findings` to each of the two old compositions, so
the merge conflicted in `_refuse_what_check_finds` and in the module
docstring's `check` paragraph.

Resolved keeping both sides' intent:
- `_entry_findings` returns coverage, legs, the source's own submodules,
  then the root-symlink findings, so `check` prints them and `execute`
  refuses them from one list. `check`'s own extra call is gone.
  `_root_symlink_findings` takes the tree `_entry_findings` already read,
  as `_submodule_findings` does.
- `_refuse_what_check_finds` and its docstring are main's, which name
  `_entry_findings`, plus one clause for the symlinks.
- The module docstring's `check` paragraph keeps #166's submodule clause,
  then this branch's symlink clause.
- `_PlannedRoot` reads a link's target with main's `GIT_CAT_FILE`, so
  `cat-file` stays spelled once; that constant's comment names this use.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…findings` hands `_leg_findings` the tree #198 requires and still ends with the root-symlink findings, so `check` and `execute` report both from one list

#198 (#172 + #191) made `_leg_findings(plan, tree)` take the source tree
as a REQUIRED argument, and added `assembly_root_paths()` and
`SHAPE_PIN` to the materializer, the same function and constant this
branch had added. The merge conflicted in four hunks. Each is resolved
keeping both sides:

- scripts/shape_materialize.py, `assembly_root_paths()`'s docstring. The
  body and `SHAPE_PIN` were identical on both sides. The docstring now
  names both of its readers in adopt-project.py, the mount-path refusal
  (#172) and `check`'s walk through the symlinks a plan keeps (#178),
  and both test files that hold the list to an empty-target run.
- adopt-project.py, the module docstring's `check` paragraph: #198's
  mount-path clause inside the parenthesis, then #166's submodule
  clause, then this branch's symlink clause.
- adopt-project.py, the `shape_materialize` import list: this branch's
  side. It already held `assembly_root_paths`, the one name #198 added,
  beside `BOOTSTRAP` and `SYMLINK_HOPS`.
- adopt-project.py, `_entry_findings`'s docstring: coverage, then each
  entry's `leg:` and the two mount paths (#198), then the source's own
  submodules, then the root-symlink findings (this branch).

Merged without a conflict, and read after the merge:
- `_entry_findings` calls `_leg_findings(plan, tree)` and appends
  `_root_symlink_findings(plan, source, tree)` after the submodule
  findings.
- `_refuse_what_check_finds` keeps #198's remediation clause ("mount
  neither leg over a path the assembly root holds") followed by #166's
  `*remediation`. Its docstring keeps this branch's symlink clause and
  #198's `plan-leg-path-collides` sentence.
- `execute` still runs `_refuse_symlinks_checked_out_as_files` right
  after `_refuse_what_check_finds`, before any leg.
- This branch's own remediation sentences (the `plan-root-symlink`
  finding's, `adopt-symlinks-off`'s, and the materializer's
  `UNWRITABLE_REMEDIATION`) are in code #198 did not touch.

Where both fire for one plan (`.github -> ci` kept with `ci/` kept and
`legs.spec_path: ci`; `.github -> ../outside-ci` with
`legs.spec_path: .github`), `check` prints `plan-leg-path-collides`
first and `plan-root-symlink` after it. Each sentence is true of the
plan as written. `execute` refuses under the first code with all of
them, before any leg exists.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…200 merge with no conflict, `_entry_findings` still hands `_leg_findings` the tree, and a symlink the plan keeps and the shape writes through is still refused before any leg, ahead of #200's mount guard

This brings in 8df21e6 (#201, for #179) and a5b09c3 (#200, for #174).
#201 moved the leg-path rule verbatim from adopt-project.py into
scripts/shape_materialize.py, and adopt-project.py now imports it back
as one unit. #200 added `_refuse_an_unmountable_path`
(`adopt-mount-occupied`, `adopt-mount-unaddable`), which runs before
each `git submodule add --force`.

There was no conflict. `git merge-tree` and the merge both resolved
every file without help. Read after the merge:

- `_entry_findings` calls `_leg_findings(plan, tree)`, then appends
  `_root_symlink_findings(plan, source, tree)` after the submodule
  findings, as at a61ccbe.
- `_PlannedRoot` reads `WINDOWS_ABSOLUTE_RE` and `leg_path_problems`.
  Both now live in shape_materialize.py and arrive through #201's
  second import block. The first block keeps this branch's `BOOTSTRAP`
  and `SYMLINK_HOPS`.
- In shape_materialize.py, this branch's block sits before
  `_materialize` and #201's sits at the end of the file. They share no
  name.
- This branch's diff against main in adopt-project.py and
  scripts/shape_materialize.py has the same lines after the merge as
  a61ccbe's diff against 20e13f0.
- Every remediation sentence is kept: #198's clause in
  `_refuse_what_check_finds`, #200's `MOUNT_REMEDIATION`, and this
  branch's `plan-root-symlink`, `adopt-symlinks-off` and
  `UNWRITABLE_REMEDIATION`.

Which fires first. `execute` refuses `check`'s findings in their order
before any leg exists: `plan-leg-path-collides` from `_leg_findings`,
then `plan-root-symlink`. `adopt-symlinks-off` comes next, also before
any leg. #200's guard comes at the mount, after both legs are pushed,
and the materializer's refusals come after that.

Four plans are refused by more than one of these:
- `.github -> ci`, with `ci/` kept and `spec_path: ci`;
- `.github -> ../outside-ci`, with `spec_path: .github`;
- `.github -> spec/ci`, with `ci/` kept and `spec_path: ci`;
- `.github -> ../outside-ci`, with `spec_path: .github/spec`.

For each, `check` lists `plan-leg-path-collides` first and
`plan-root-symlink` after it. `execute` refuses under the first code
with all of them, before any leg exists.

With #198's root-kept half switched off, as #200's tests do,
`plan-root-symlink` still refuses three of the four before any leg.
#198's shape half refuses the fourth. Only with `check`'s list switched
off does #200's guard meet them, after both legs are pushed, and it
refuses each by name. Each sentence is true of its plan.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erification merges with no conflict, and `_entry_findings` still hands `_leg_findings` the tree and ends with the root-symlink findings

54aa69a is #165 (#173): `_verify` passes over the two leg mounts and no
other gitlink, so a submodule the source keeps in the root is
`adopt-root-submodule`, any other root gitlink `adopt-unexpected-gitlink`,
one in a leg AND the root `adopt-duplicated`, and a missing mount
`adopt-mount-missing`.

Conflicts: none. `git merge-tree --write-tree HEAD origin/main` gave tree
02f87a1 with no conflicted file, and `git merge --no-ff --no-commit`
staged the same tree: adopt-project.py, tests/test_adopt_submodules.py
and tests/test_adopt_verify.py (new).

Checked on the staged merge:
- `_entry_findings` still calls `_leg_findings(plan, tree)` (#198's
  required argument), then appends `_root_symlink_findings(plan, source,
  tree)` last, after the submodule findings.
- tests/test_adopt_root_symlinks.py and tests/test_adopt_verify.py:
  74 passed. test_adopt_verify.py switches `_refuse_what_check_finds`
  off with `lambda *_: None`, so it takes whatever `execute` passes.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hat `place` will find, so `CLAUDE.md -> /home/me/CLAUDE.md` adopts beside it as on main while one to nothing there is refused before any leg, and a link that leads the shape's copy onto something already there, or a case twin, is refused too (#183, #178)

The review of 04792d2 found the walker refusing plans main adopts, and
passing plans main breaks after both legs exist. This answers each item.

B1. A link AT a path the shape writes is decided by what the materializer
will do with it. `place` asks whether something is already at the path,
following every link on the way. Where something is, it writes the shape's
copy beside it under `shape/` and never writes through the link; where
nothing is, it writes through. `_PlannedRoot` now simulates `place` in
`assembly_root_paths()` order. The simulation holds what the shape has
written by then, `.gitmodules`, and what each mounted leg will hold: the
paths the plan sends to it, or the seeded template, read through the new
`template_files`, which `copy_tree` now uses too.

A destination outside the assembly is asked of this machine's disk: an
absolute target as it stands, and a `..` climb from the work directory,
where `execute` makes the assembly's clone. A climb that comes back into
the assembly by its own name goes on inside. A climb into a leg's clone or
path list is refused, because `execute` makes those only after this check.
`check` and `execute` each ask their own disk, `execute` before any leg
exists. So `CLAUDE.md -> <abs>/CLAUDE.md` adopts, verified and
bootstrapped, when the file is there, and is refused ("..., and nothing is
there") when it is not; a two-link chain to an absolute target behaves the
same.

A link ABOVE a path the shape writes is a directory the shape writes into,
so it must still lead to a directory the root holds.

B2. The shape's copy under `shape/` is written without asking again. So a
link that leads a copy onto something already there is refused:
`shape -> .` would write over the source's README.md, and a copy that
lands on a directory raised IsADirectoryError. `shape -> vendor`, where
vendor/ holds none of the copies' names (R7), is still adopted, as on
main. The materializer refuses the same landing, for a caller that skipped
`check`.

S1. The paths are followed twice, keeping case and then folding it. A
finding only the folded walk makes carries #198's CASE_ASIDE. So
`.GitHub -> ../outside-ci` is refused like `.github -> ../outside-ci`, on
every disk, as #169, #198 and #200 refuse their twins. `adopt-symlinks-off`
names a case twin with the same aside.

S2. The materializer takes `..`, `.` and an empty part of a link's target
only from a directory that is there, as `open()` does. So `Makefile -> ci/`,
`a/..` and `README.md/..` are named `materialize-blocked` refusals, where
main raised IsADirectoryError, FileNotFoundError and NotADirectoryError. A
link whose target is empty is a refusal of its own, not `.`.

NITs:
- N6: findings spell links and targets with `_spelled`.
- N4: `_git_path` decodes a target.
- N3: no `" and "` literal.
- N5: the test's unused `source` is gone.
- N1: `UNWRITABLE_REMEDIATION` names the work directory as #200's does.
- N9: the `WINDOWS_ABSOLUTE_RE` comment names `_PlannedRoot`.
- N10: a link with an empty target is a finding of its own, to be fixed in
  the source. Linux cannot hold one, and both of `execute`'s clones of the
  source check it out, wherever the plan sends it.

tests/test_adopt_root_symlinks.py goes from 58 to 94 tests. Every old name
is kept, with its wording moved to the new form, and the new ones cover:
- B1 both ways (a direct link and a two-link chain), `execute` asking its
  own disk, a climb into the work directory both ways, into a leg's clone,
  and back by the assembly's name;
- the shape's own earlier writes, and leg mounts (holding, lacking, seeded,
  onto the mount);
- B2 (four cases), S1 (at, above, inside, and with symlinks off);
- spelled non-UTF-8 and newline targets, and the empty target in `check`
  and in the materializer;
- S2's three shapes, and the copy onto a file and into a directory;
- the end to end of B1: verified, the split keeps the link, recursive
  clone and bootstrap.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a home directory, so tests/test_repo_hygiene.py's check that no tracked file names a host-absolute path passes again (#183, #178)

The comment in the #178 section illustrated B1 with
`CLAUDE.md -> /home/me/CLAUDE.md`. `/home/<user>/` is exactly what #61's
hygiene check refuses in a tracked file, and the targeted set at the
previous commit failed on it alone:
`test_no_committed_file_names_a_host_absolute_path`, "committed file(s)
name a host-absolute path: {'adopt-project.py': ['/home/me/']}". The
example now says what the link is, a personal file by its absolute path,
without naming a home directory. The paragraph is reflowed; nothing else
changes.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, #208, #197, #202 and #194's scaffold half merge with two hunks kept on both sides, `_entry_findings` still hands `_leg_findings` the tree, and the walker says #198's case aside through #197's one phrase

origin/main moved 54aa69a -> 39ac59c: 4835fb4 (#199), 404760f (#213),
e7d1720 (#208), b538015 (#197), 150c41d (#202), 39ac59c.

Conflicts, each resolved by keeping both sides:
- adopt-project.py, the `shape_materialize` import list. This branch adds
  BOOTSTRAP, SYMLINK_HOPS and template_files; #199 adds write_paths_listing.
  The resolution is the union.
- scripts/shape_materialize.py, `assembly_root_paths()`'s docstring. #197
  rewrote it for `neutral_pin_path` and the scaffold's refusal; this branch
  had added that `adopt-project.py check` follows each path through the
  symlinks a plan keeps (#178), and its test file. The resolution keeps
  #197's text and adds both of those.

Found after the textual merge, and fixed here so that no commit is broken:
#197 moved #198's CASE_ASIDE out of adopt-project.py into
shape_materialize.py. Its test_both_tools_ask_the_one_definition asserts
that adopt has no CASE_ASIDE attribute: no phrase of its own. This branch's
walker says that aside in two places (`_root_symlink_findings` and
`_refuse_symlinks_checked_out_as_files`). The plain merge left both names
unbound, a NameError for every case-twin finding and every symlinks-off
walk. They now read shape_materialize's phrase, imported as `_CASE_ASIDE`,
as #197's own import block names `_overlap` and `_first_and_more`.

Checked on the merged tree:
- `_entry_findings` still calls `_leg_findings(plan, tree)` and ends with
  `_root_symlink_findings(plan, source, tree)`.
- No name in adopt-project.py or scripts/shape_materialize.py is loaded but
  bound nowhere (AST). Every line over 79 columns is main's own.
- Both sides are intact by +/- line sets. This branch's diff against main
  is the same as before the merge, and so is main's diff against this
  branch, apart from the two hunks above and the import.
- The same resolution, rehearsed in a scratch clone (tree e914b55, the same
  tree as this commit's), passed tests/test_adopt_root_symlinks.py,
  test_adopt_leg_path_collisions.py, test_adopt_plan_escapes.py,
  test_adopt_verify.py, test_scaffold_leg_path_collisions.py and
  test_repo_hygiene.py: 418 passed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, #209, #252, #195, #210, #211, #248, #212, #251 and #206 merge with one hunk, the materializer import list, kept on both sides, and `_entry_findings` still hands `_leg_findings` the tree and ends with the root-symlink findings

origin/main moved 39ac59c -> 60588d5: 06b5204 (#214 via #246), 60d08a1
(#217 via #230), 2727b08 (#209 via #218), 4b2add8 (#252 via #257),
aaa40eb (#195 via #226), d89fffa (#210 via #219), eb6df75 (#211 via
#220), c3f4987 (#248 via #265), 5751460 (#212 via #223), 23e16ae (#251
via #271) and 60588d5 (#206; its subject names no pull request).

Conflict, resolved by keeping both sides:
- adopt-project.py, the `shape_materialize` import list. This branch
  adds BOOTSTRAP, SYMLINK_HOPS and template_files; #214 adds
  escape_what_stdout_cannot_encode. The resolution is the union.

Auto-merged: scripts/shape_materialize.py, where #214's escaping, #217's
UTF-8 `run()` and #212's `first_and_more(paths, spell=None)` sit apart
from this branch's #183 section, and adopt-project.py's other hunks.
#195 adds `import os` at the line where this branch added it, and the
merge keeps it once.

Checked on the merged tree:
- `_entry_findings` still calls `_leg_findings(plan, tree)` and ends
  with `_root_symlink_findings(plan, source, tree)`. `main` still calls
  `escape_what_stdout_cannot_encode()` first.
- No top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, and none is loaded but bound nowhere
  (AST). `first_and_more` gives its new `spell` a default, so this
  branch's call is unchanged.
- Both sides are intact by +/- line sets: each side's change against the
  base reappears against the other side, apart from the import hunk and
  the shared `import os`.
- #212 says this branch's `plan-root-symlink` names its paths raw, and
  that whichever pull request lands second should spell them. That was
  04792d2. Since d11cf9f every path the finding names goes through
  `_spelled`.

One clash the textual merge does not show is fixed in the next commit:
#217's tests/test_git_output_decoding.py refuses a shipped text-mode
`subprocess` call that names no codec, and this branch's
`_checks_out_symlinks` probe names none.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… UTF-8 whatever the locale, as #217 has every shipped git call read it, so its guard no longer fails on this branch's one text-mode call (#183, #178)

The merge of main 60588d5 brought #217's
tests/test_git_output_decoding.py. Its
`test_no_shipped_text_mode_call_decodes_with_the_locale_codec` refuses a
shipped `subprocess` call in text mode that names no codec, because the
locale's codec (ASCII under a C locale with UTF-8 mode off, the ANSI
code page on Windows) raises on a byte git writes as UTF-8. The probe
this branch adds for #183, `_checks_out_symlinks`, asked `git config
--type=bool --get core.symlinks` with `text=True` alone. On the merge
the guard failed: "adopt-project.py:2727 (_checks_out_symlinks) asks for
text and does not name ['encoding', 'errors']".

It now names `encoding="utf-8", errors="replace"`, as #217 gives
`run()` and `_require_filter_repo`. No answer changes. Measured with
LC_ALL=C and UTF-8 mode off (codec ANSI_X3.4-1968), under a HOME named
`hömé`, the probe answers True with symlinks on and False with
`core.symlinks=false` in GIT_CONFIG_COUNT, before and after.
`--type=bool` writes only `true` or `false`, and no run here made this
call write anything else, so this keeps #217's rule rather than fixing
a failure seen.

Revert line: with this change reverted, the guard fails as quoted above
(1 failed, 2 passed); with it, 3 passed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…een backticks, and `adopt-project.py execute` has them spelled with `_spelled` as #212 spells every path a finding or refusal names, so a symlink's target holding a line break no longer splits `REFUSED materialize-blocked` in two (#183, #178)

#212 (5751460, merged in c53acc1) has `check` and `execute` spell every
path a FINDING or a refusal names. The path goes between backticks, and
a line break, an ESC or any other character `str.isprintable` refuses is
written as an escape. This branch's `plan-root-symlink` and
`adopt-symlinks-off` already spelled theirs, since d11cf9f. Its
materializer refusals did not. `materialize-outside`,
`materialize-symlink-loop` and `materialize-blocked` named the shape
path, each symlink, its target and where it led raw, and a target is
whatever the source's link holds. With `check`'s list switched off, as
the defence-in-depth test runs it, a source keeping
`.github -> "no<LF>where<ESC>"` made `execute` print, at 8f624aa:

  REFUSED materialize-blocked: .github/workflows/validate.yml cannot be written: .github is a symlink to no
  where<ESC>, which is not a directory here

on two lines, with the ESC raw.

The materializer is shared with `scaffold-project.py`, and `_spelled` is
adopt's. So the refusals take a `spell`, the way #212's `first_and_more`
does. `materialize_assembly_root(..., spell=None)` hands it through
`_materialize` to `_refuse_unwritable`, which writes each name with it,
or between backticks (`_ticked`) where it is None. Backticks are all the
scaffold's own paths need. `_commit_the_split` passes `_spelled`. The
same run now prints one line:

  REFUSED materialize-blocked: `.github/workflows/validate.yml` cannot be written: `.github` is a symlink to `no\nwhere\x1b`, which is not a directory here

The directory the materializer was given is named as it is. It is the
operator's location, not a name from the source, and main names it the
same way in `materialize-collision`, as adopt names its plan file.

Tests (tests/test_adopt_root_symlinks.py, 94 -> 96):
- The 16 cases that pin a materializer refusal's text gain the
  backticks: 13 of `test_the_materializer_refuses_before_it_writes`, 2
  of `test_the_materializer_refuses_a_link_to_nothing`, and
  `test_a_file_where_the_shape_needs_a_directory_is_a_refusal`.
- New: `test_a_refusal_spells_each_name_as_its_caller_asks` (the
  materializer given `_spelled`), and
  `test_execute_hands_the_materializer_its_spelling` (end to end, with
  `_refuse_what_check_finds` off).
- `test_a_caller_that_skips_check_meets_the_materializer_refusal` keeps
  its assertions. Its setup moves into `refused_past_check`, which both
  end-to-end tests use.

Revert lines, each on this commit's tree in a scratch clone:
- `_ticked` writing a name raw: 16 failed, 80 passed (the 16 above).
- `_commit_the_split` passing no `spell`: 1 failed, 95 passed
  (`test_execute_hands_the_materializer_its_spelling`).
- The code at 8f624aa with these tests: the end-to-end test fails on the
  two lines quoted above, and the unit test with "TypeError:
  materialize_assembly_root() got an unexpected keyword argument
  'spell'".

No line is over 79 columns that was not before, and Sonar's cognitive
complexity estimate is unchanged for every function but
`_refuse_unwritable` (1 -> 2).

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, #205, #215, #241 and #249/#250 merge with no conflict, and `_entry_findings` still hands `_leg_findings` the tree and ends with the root-symlink findings

origin/main moved 60588d5 -> b30595c while the targeted runs of 8f624aa
went: 018ce0d (#207 via #222), 67a398a (#272 via #274), d94d155 (#205
via #232), c2ff2b0 (#215 via #240), c3a9645 (#241 via #256) and b30595c
(#249 and #250 via #284). It is merged again because c2ff2b0 changes
adopt-project.py and b30595c changes scripts/shape_materialize.py's
`render`.

Conflicts: none. `git merge-tree --write-tree` gave tree be093a5, and
the merge stages the same tree.

Checked on that tree:
- `_entry_findings` still calls `_leg_findings(plan, tree)` and ends
  with `_root_symlink_findings(plan, source, tree)`.
- No top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, none is loaded but bound nowhere (AST),
  and main changed the signature of no function this branch calls.
- Both sides are intact by +/- line sets: each side's change against the
  merge base 60588d5 reappears, line for line, against the other side.
- c2ff2b0 adds helpers to tests/conftest.py (`planned`, `GITLINK`,
  `findings` and others) and no fixture. tests/test_adopt_root_symlinks.py
  imports its conftest names one by one and none of these, so its own
  `planned` and `GITLINK` are unaffected.
- b30595c's `render` spells a double-quoted YAML value with
  `double_quoted`; this branch's walker reads `assembly_root_paths()` and
  never renders.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, #238, #196, #254, #242 and #262/#263 merge with three adopt-project.py hunks kept on both sides, and #262's `_CASE_ASIDE` import kept as the name's one binding

origin/main moved b30595c -> 71b58a1:
- be2790e (#204 via #227), update-shape.py;
- 6d8d88b (#203 via #236), family.py;
- ede15ce (#238 via #287), shape-doctor.py;
- 6f45bf9 (#196 via #231): adopt-project.py `entry_path_problem`,
  scripts/shape_materialize.py;
- b421e6b (#254 via #281): scripts/shape_materialize.py, bump-leg.py,
  update-shape.py;
- c0ed615 (#242 via #308), family.py;
- 71b58a1 (#262 and #263 via #313): adopt-project.py
  `_root_shape_findings` and scripts/shape_materialize.py
  `copy_in_the_way`. It fixes what this branch's pull request listed as
  follow-ups (i) and (ii): a source's own `shape/` where the shape
  writes its copy, and a plain FILE where the shape needs a directory.

Conflicts, all in adopt-project.py, each resolved by keeping both sides:
- The module docstring's `check` paragraph. 71b58a1 adds "no path kept
  in the root where the shape writes ..." before the submodule clause,
  and this branch adds the symlink clause after it. Both are kept, in
  that order.
- `_entry_findings`' docstring. 6f45bf9 and 71b58a1 give the order
  "each entry's path, then coverage, then each entry's `leg:`, the two
  mount paths and what the root keeps where the shape writes, then the
  source's own submodules". This branch adds "then each symlink the plan
  keeps in the root that a shape file cannot be written through".
- `_entry_findings`' return line. 6f45bf9 returns
  `unsafe_remediation + remediation`, and this branch appends
  `_root_symlink_findings(plan, source, tree)` to the findings. The
  merge returns both.

Found after the textual merge, and resolved here so that no commit binds
a name twice: 71b58a1 (through 65a9262) imports #198's phrase as
`_CASE_ASIDE` in the "#172's shape half" import block, and b37159a had
imported it on a line of its own. Main's import is kept; this branch's
line and its five-line comment go.

scripts/shape_materialize.py merges with no conflict. `place` gets
71b58a1's `copy_in_the_way` inside its collision block and this branch's
`_refuse_unwritable` after it, as 71b58a1's message foresaw.

Checked on the merged tree:
- `_entry_findings` still hands `_leg_findings` the tree, and the
  symlink findings still come last in its list.
- AST: no top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, and none is loaded but bound nowhere.
  Main changed the signature of no function this branch calls.
- Both sides are intact by +/- line sets, apart from the three hunks and
  the import above.

The next two commits fix what this merge breaks, each with a revert
line. #262's rule and this branch's both name a symlink kept AT the
copy under `shape/`. And where the shape's copy lands is now
`copy_in_the_way`'s question, asked before this branch's own check of
it.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the root holds is one finding, #262's `plan-root-blocks-shape-copy`, so `check` no longer names the same link twice and `execute` refuses it with that line (#183, #178)

After the merge of 71b58a1 (#262), two rules name a symlink the plan
keeps AT the copy of a path the root holds. One is #262's
`plan-root-blocks-shape-copy` ("That path is the symlink
`shape/README.md`. ... `execute` would write the copy through it"). The
other is this branch's `plan-root-symlink`, from the walk of the copy
(d11cf9f). For `shape/README.md -> ../notes.md` with `README.md` and
`notes.md` kept, `check` printed both. `execute` refused with #262's
code, which comes first in `_entry_findings`' one list:
`test_check_reports_the_link_and_execute_refuses_it_before_any_leg[a-link-at-the-copy-itself]`
failed on the merge with
`assert 'plan-root-blocks-shape-copy' == 'plan-root-symlink'`.

It is #262's to say. Main's rule already covers what both did, and the
coordinator's direction for this merge is to keep main's version of
that. 71b58a1's message offered the other way, skipping `SYMLINK_MODE`
in `_shape_blocks`; that was not taken.
`_links_at_a_copy(plan, tree)` asks `_shape_blocks`, on both disks as
`_root_shape_findings` does and only for a plan whose `legs` it reads,
for each symlink row it names AT a copy. `_root_symlink_findings` starts
its `seen` set with them, so one link is one finding.

The walk of the copy stays, and so does every other finding it makes.
#262's rule follows no link, so this branch still names:
- a link ABOVE the copy (`shape -> .` onto the source's README.md,
  `shape -> vendor` onto `vendor/README.md`);
- a link AT the copy of a path the root holds only THROUGH a symlink.
  With `.github -> ci` and the project's own
  `ci/workflows/validate.yml` kept, `place` writes the copy of
  `.github/workflows/validate.yml` to
  `shape/.github/workflows/validate.yml`. #262's tree, which counts no
  link, does not hold `.github/workflows/validate.yml`, so it does not
  ask about that copy.

tests/test_adopt_root_symlinks.py:
- The `a-link-at-the-copy-itself` case leaves the `plan-root-symlink`
  table (29 -> 28 cases).
- New: `test_a_link_at_the_copy_is_one_finding_by_the_rule_that_sees_it`.
  - For that link, `check` prints no `plan-root-symlink` line and
    exactly one `plan-root-blocks-shape-copy` line, which names the
    symlink and the write through it. `execute` refuses with that code
    and line, and no leg exists.
  - For the link at the copy of a path held through `.github -> ci`,
    `check` prints exactly one `plan-root-symlink` line, and `execute`
    refuses with it. This guards against a fix that drops every link at
    a copy.
- `NOTES_MD` names the file the links lead onto, which the test spells
  four times.

Revert line: with `seen` starting empty again (`seen: set[str] =
set()`, the line this commit replaces),
`test_a_link_at_the_copy_is_one_finding_by_the_rule_that_sees_it` fails
at `assert symlink_findings(checked) == []`, because `check` names
`shape/README.md` twice, as `plan-root-blocks-shape-copy` and as
`plan-root-symlink` (1 failed, 28 passed). With this commit it passes,
and so do the 28 cases of
`test_check_reports_the_link_and_execute_refuses_it_before_any_leg`
(29 passed). Both runs were at 2f0ca85 plus this commit
(fin183g/revA-run.sh).

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s `copy_in_the_way` question, asked first in `place`, so this branch's own check of that landing, which no path can reach after the merge, goes (#183, #178)

d11cf9f made `_blocker` refuse "a symlink on the way leads it to X, a
file (or a directory) that is already there". Only a collision COPY
could reach that check: `place` writes a copy without asking whether its
own path is free. The merge of 71b58a1 (#262) makes `place` ask that
question first. `copy_in_the_way(target, actual)` walks the copy's path
as `open()` does, following any symlink on the way, and
`materialize-copy-blocked` refuses a copy whose place is taken, before
`_refuse_unwritable` runs. So with `shape -> .`, the copy of README.md
is refused with "`shape/README.md` is already there", and this branch's
check never runs.

Measured that nothing reaches the check (fin183g/m35-run.sh). At the
merge, with fix A applied, the materializer tests and both end-to-end
refusal tests were run twice: as they are, and with the check switched
off (`if hops and there.exists():` -> `if False:`, mutation M35 of the
revert table). Both runs gave the same outcome for every test:
`2 failed, 19 passed`. The 2 are this commit's two cases, which still
expected this branch's words. At 6c44591, before the merge, M35 failed
those same 2 tests.

So the check goes, as a duplicate of main's, which the coordinator asked
to be removed:
- `_blocker` loses that branch and its `hops` parameter, which only it
  read.
- `_followed` returns the parts it lands on alone.
- `_blocker`'s docstring, the section comment and
  UNWRITABLE_REMEDIATION's comment now say where a copy's landing is
  decided. The last of these said a plain FILE where the shape needs a
  directory reaches this refusal with `check` silent, which #263
  (`plan-root-blocks-shape`) has made false. The printed remediation is
  unchanged.

Everything else `_refuse_unwritable` refuses still holds for a copy.
`copy_in_the_way` lets a symlink to a directory pass, so `shape ->
../outside` still meets `materialize-outside`, as
`test_the_materializer_refuses_before_it_writes[the-collision-directory-out]`
asserts.

tests/test_adopt_root_symlinks.py:
- `the-copy-onto-a-file` and `the-copy-into-a-directory` now expect
  #262's refusal, `materialize-copy-blocked` with
  "the shape's copy of `README.md` cannot be written to
  `shape/README.md`: `shape/README.md` is already there, and nothing was
  written there" (and ", a directory" for `Makefile`). They still assert
  that nothing was written outside and that the source's own files are
  unchanged.
- `test_the_materializer_refuses_before_it_writes`' docstring says which
  rule refuses which, and
  `test_a_file_where_the_shape_needs_a_directory_is_a_refusal`'s says
  that a plain FILE `.github` is now #263's `plan-root-blocks-shape` in
  `check`.

Revert lines. These ran at d8039c6 plus this commit, over the
materializer tests and both end-to-end refusal tests, 21 in all
(fin183g-revB-run.sh):
- As committed: `21 passed`.
- With this commit's test change reverted: `2 failed, 19 passed`.
  `[the-copy-onto-a-file]` and `[the-copy-into-a-directory]` fail with
  `'materialize-copy-blocked' != 'materialize-blocked'`.
- With #262's `copy_in_the_way` switched off in `place`
  (`in_the_way = None`): the same 2 fail. One fails with `DID NOT RAISE
  Refusal`, because the copy is written over README.md; the other with
  `IsADirectoryError`. Nothing else refuses that landing now, and these
  two tests pin #262's refusal of it.
- The code change only removes what nothing reaches: M35 above.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onto a file is where the copy cannot be written, as #262's `place` now refuses it, instead of that the copy would be written over it (#183, #178)

The merge of 71b58a1 (#262) changed what happens to the copy.
`place` now asks `copy_in_the_way` where the shape's copy under
`shape/` lands, and refuses it as `materialize-copy-blocked` when
something is already there. It no longer writes the copy over a file.

This branch's `check` finding for a symlink that leads the copy onto a
file still said "it lands on `README.md`, which is already there, so
the shape's copy would be written over it". That became false with the
merge.

`_over` now says the same for a file as it already said for a
directory: "it lands on `README.md`, a file that is already there,
where the shape's copy cannot be written". The B2 comment above the
walker says why `check` still refuses such a link. `place` asks only
of the disk, after both legs exist, and #262's `check` rule follows no
link. The finding still comes before any leg exists; only its sentence
changes.

tests/test_adopt_root_symlinks.py: `OVER_IT` becomes `ON_A_FILE`, with
the new words, in its three uses:
- `[the-collision-directory-is-the-root]`;
- `[the-collision-directory-holds-the-name]`;
- `test_a_link_at_the_copy_is_one_finding_by_the_rule_that_sees_it`.

Revert lines. These ran at f2696f3 plus this commit, over the
`plan-root-symlink` table and the one-finding test, 29 in all
(fin183g-revC-run.sh):
- As committed: `29 passed`.
- With the code reverted (the old `_over`): `3 failed, 26 passed`. The
  three fails are the uses above, each printing "..., which is already
  there, so the shape's copy would be written over it".
- With the test change reverted (the old `OVER_IT`): the same 3 fail,
  each printing the new words.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…han 40 symlinks is said to follow too many, round a loop or along a longer chain, so a chain of 41 links with no loop is no longer called a symlink loop (#183, #178)

This is rv183f's F1. The walk of a path the shape writes stops at the
41st symlink it meets, as Linux's `open()` does (MAXSYMLINKS, ELOOP).
Its finding said "it runs round a symlink loop at `l40`" for
`.github -> l1`, `l1 -> l2` ... `l40 -> ci`, where nothing loops: `l40`
leads to `ci`, a directory the root keeps. The materializer's
`materialize-symlink-loop` refusal said the same, for a caller that
skipped `check`. The refusal itself is right, because `open()` gives up
on both, and main dies with OSError after both legs. Only the word
"loop" was false.

Both now say what is true of a loop and of a chain:
- `check`: "it follows too many symlinks at `l40`: more than 40, round a
  loop or along a chain longer than Linux's `open()` follows";
- the materializer: "`<path>` follows too many symlinks at `l40` in
  <target>: ", then the same words.

The words are one constant, `TOO_MANY_SYMLINKS`, in shape_materialize.py
beside `SYMLINK_HOPS`; adopt-project.py imports the two together. The
refusal code `materialize-symlink-loop` stays: it names the case as
ELOOP does, which the OS raises for both.

Main's `_verify` said the same of the split commit: "runs round a
symlink loop at ... in the split commit ...". The coordinator asked for
it to be fixed only if the same one-line change does it, and it does:
"follows too many symlinks at" fits that line as it stands. Main's three
expectations of it change with it:
- tests/test_adopt_ignored_shape_files.py `[a-link-to-itself]` and
  `[a-loop-of-two]`;
- tests/test_adopt_finding_spelling.py's `a-loop` case.

Also:
- `SYMLINK_HOPS`' comment no longer calls the 41st hop a loop. It now
  says that macOS gives up after 32, so a chain of 33 to 40 links passes
  here and fails there, as it did before #183. Nothing here runs on
  macOS; that is read off the constant.
- The docstrings of `_through`, `_link_parts`, `_followed` and
  `_refuse_unwritable` now describe the limit the same way.

tests/test_adopt_root_symlinks.py:
- `[a-chain-of-41-links]` is new in both tables. It is rv183f's
  E-hops-40 (`CHAIN`); `check` names `l40`, and so does the
  materializer.
- `[a-link-to-itself]` and `[a-loop-of-two]` in both tables expect the
  new words.
- `TOO_MANY`, `too_many()` and `SYMLINK_LOOP` hold what the cases share.

Revert lines. These ran at 34e20c6 plus this commit, over every loop and
chain case in the three test files, 47 in all (fin183g-revF1-run.sh):
- As committed: `47 passed`.
- With the code reverted under these tests: `13 failed, 34 passed`. The
  13 are:
  - the three cases in each of this branch's two tables;
  - main's two `_verify` cases;
  - the spelling test's `a-loop` case, once for each of its 5
    characters.
  The chain prints "it runs round a symlink loop at `l40`", which is
  F1's own repro.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…x`) as this machine's `open()` reads it, so on Linux and macOS it is no longer called "an absolute path: a place on this machine" (#183, #178)

This is rv183f's F2. `_PlannedRoot` took a target such as
`C:/Users/me/CLAUDE.md` for an absolute path on every OS, and the
finding said "an absolute path: a place on this machine, not in the
assembly, and nothing is there". On Linux and macOS that is false.
There, `C:/...` is a relative path into a directory named `C:`: Git
checks the link out with that text, the OS resolves it inside the
assembly (main's traceback named `work/Northwind/CLAUDE.md`), and this
branch's own materializer follows it as one. Its `_link_parts` takes a
target as absolute only on `os.path.isabs` or a drive from
`os.path.splitdrive`, and both are false there.

The coordinator's ruling: keep refusing it, and make the sentence true
on every OS. So the walk now reads a target as `open()` and the
materializer read it where `check` runs. That is the principle this
walk is built on: what the materializer will do decides each path.
- `_absolute_here(target)` holds a target from `/` absolute everywhere,
  and a drive letter's only on Windows. `_through` asks it.
- On Windows nothing changes. A drive letter's target is a place on that
  machine, and it is refused or kept as B1 decides.
- On Linux and macOS, `CLAUDE.md -> C:/Users/me/CLAUDE.md` is still
  refused before any leg: "the symlink `CLAUDE.md` leads to
  `C:/Users/me/CLAUDE.md`, which is not in the source tree". That is
  true there, and `.github -> C:/ci` is refused the same way.
- `_outside` loses `elsewhere`, which only a drive letter's place on a
  disk that is not Windows could set. No such place reaches it now.

One run changes outcome, rv183f's contrived W-drive-above-present. Its
source keeps its own `C:/ci/keep.txt` and `.github -> C:/ci`. On Linux
and macOS `check` now follows the link into that directory and passes,
as main does: main adopts it, verifies and bootstraps (rv183f). A
Windows checkout cannot hold a directory named `C:` at all.

The comment above the walk, and `WINDOWS_ABSOLUTE_RE`'s comment in
shape_materialize.py, say when a drive letter's path is absolute.

tests/test_adopt_root_symlinks.py:
- `test_a_drive_letter_target_is_read_as_this_machine_reads_it`, at and
  above a shape path, is tree-only, so it runs on Windows too.
  `drive_reading()` gives the sentence each OS prints. The targets are
  under `C:/no-such-dir-183/`, which no runner has.
- `test_a_drive_letter_target_into_the_root_is_followed_off_windows` is
  W-drive-above-present. It skips on Windows, which cannot hold `C:/`.

Revert lines. These ran at 86a5e30 plus this commit, over this branch's
whole test file (fin183g-revF2-run.sh):
- As committed: `101 passed`.
- With the code reverted under these tests: `3 failed, 98 passed`. The
  3 are the new tests. The two refusals print "an absolute path: a
  place on this machine, not in the assembly[, and nothing is there]",
  which is F2's repro, and the third is refused with that sentence.

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

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

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

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

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…here a symlink AT a shape path leads, so `CLAUDE.md -> .git/HEAD` adopts with the shape's copy beside it, as on main, instead of being refused (#183, #178)

This is rv183f's F3. `_PlannedRoot` holds the source paths the plan
keeps, the mounts, `.gitmodules` and what the shape writes. It did not
hold the `.git` every clone has, which `place`'s `exists()` sees. So
`CLAUDE.md -> .git/HEAD` was refused: "the symlink `CLAUDE.md` leads to
`.git/HEAD`, which is not in the source tree". Main completes that run:
`place` finds `.git/HEAD` there and writes the shape's copy beside the
link as `shape/CLAUDE.md`, the split keeps the link, and the adoption
verifies, clones recursively and bootstraps (rv183f).

The coordinator's ruling: accept it as main does, unless that makes
something else a false accept. Accepting it does not, because what is
now counted is exact:
- `CLONE_GIT` is what git makes in every repository's own `.git`,
  whatever its version or template: `.git`, `.git/HEAD`,
  `.git/config`, `.git/objects` and `.git/refs`. The assembly's clone
  has each of them when the shape is written.
- Only `_has` asks it. `_has` is what a symlink AT a path finds at its
  destination, and whether `place` then writes the copy beside it.
  Folding case, it asks `CLONE_GIT_FOLDED`, as a disk that ignores case
  would.
- `_is_dir` does not ask it. A link ABOVE a path the shape writes still
  cannot lead the shape into `.git`, where no commit holds what it
  writes. D9 (`.github -> .git/info`) is refused as before.
- Nothing else under `.git` is counted. What else is there depends on
  git's version, its configuration and the clone's template, and the
  merge before this commit brought #243 (e43fa95): `execute` clones the
  assembly with `--template=`. So `CLAUDE.md -> .git/description`, which
  only a template would make, is still refused. There the shape would
  write its file through the link into the clone's own `.git`.

This commit follows that merge because it relies on #243: before it,
the clone took the operator's template, which holds `description`,
`hooks/` and `info/`.

tests/test_adopt_root_symlinks.py:
- `[at-a-path-to-the-clones-own-head]` is new in the table of links
  that are not refused, and runs on every OS.
- `[at-a-path-to-what-only-a-template-puts-in-dot-git]` is new in the
  table of refusals. It is the guard against counting too much.
- `test_a_link_at_a_path_into_the_clones_own_git_adopts_beside_it` runs
  end to end. `execute` exits 0 with `adoption verified` and the
  `beside shape/CLAUDE.md` line, and the split keeps `CLAUDE.md` as a
  symlink to `.git/HEAD`.

Revert lines. These ran at cd4d8b3 plus this commit, over this branch's
whole test file (fin183g-revF3-run.sh):
- As committed: `104 passed`.
- With the code reverted under these tests: `2 failed, 102 passed`. The
  2 are the new case that is not refused and the end-to-end test, both
  refused with "the symlink `CLAUDE.md` leads to `.git/HEAD`, which is
  not in the source tree", which is F3's repro.
- With every path under `.git/` counted as there: `1 failed, 103
  passed`. The 1 is the `.git/description` refusal.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e shape's copy under `shape/` is asked about since #262, instead of that it is written without asking again (#183, #178)

The docstring's B2 bullet said "The shape's copy under `shape/` is
written without asking again". That stopped being true at the merge of
71b58a1 (2f0ca85). `place` now asks #262's `copy_in_the_way` where the
copy lands, after both legs exist, and #262's `check` rule names a link
AT the copy (d8039c6). The bullet now says both.

This changes a docstring only, so no test changes with it and there is
no revert line.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…plan-unwritable-value` joins `_entry_findings` after this branch's `plan-root-symlink`, and both are refused by `execute` before any leg

origin/main moved 8cdca5e -> a2cb7d3 while this branch's runs were going.
f795cf7 changes adopt-project.py and scripts/shape_materialize.py, so main
is merged once more, by sha, at its tip:
- db19187 (#300 via #317), scripts/family.py and the template's
  scripts/validate-family.py: `add` refuses `member-path-registered`
  when another `.gitmodules` section already names the mount path;
- f795cf7 (#255 via #270), adopt-project.py and
  scripts/shape_materialize.py: `unwritable_reason` asks whether a file
  can hold a value `execute` writes; `plan` refuses
  `adopt-unwritable-value`, `check` reports `plan-unwritable-value`
  through `_entry_findings`, and `execute` refuses an unwritable local
  clone URL right after `_repository_urls`;
- a2cb7d3 (#332 via #344), scripts/bump-leg.py: a tracked workflow the
  disk spells another way by case is rewritten under git's spelling.

scripts/shape_materialize.py, scripts/family.py, scripts/bump-leg.py and
the tests merge with no conflict. adopt-project.py conflicts in two hunks
of `_entry_findings`, where both sides added to the same end of the one
list:
1. The docstring. This branch added "then each symlink the plan keeps in
   the root that a shape file cannot be written through (#178, #183)",
   main "then each value `execute` writes into a file that no file can
   hold (#255)". Both are kept, in the list's order.
2. The return. Both are kept: the symlink findings stay after the
   source's own submodules, and #255's value findings come after them,
   with #255's remediation after the others. The symlinks are read out
   of the source's tree, as the submodules before them are; the values
   are the plan's own fields. A plan with neither prints what it printed
   before.

`execute` now refuses, before `_create_leg_remotes`: an unwritable clone
URL (#255), then `_refuse_what_check_finds` (this list), then this
branch's `core.symlinks` probe. That order merged with no conflict.

Checked on the merged tree:
- adopt-project.py and scripts/shape_materialize.py are byte for byte
  the ones a scratch merge of f795cf7 alone produced, on which
  tests/test_adopt_template_values.py, tests/test_adopt_root_symlinks.py
  and main's two tests of `_verify`'s sentence passed (340).
- AST (fin183g/names.py, base 8cdca5e): no top-level name is bound twice
  in adopt-project.py, scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, and none is loaded but bound nowhere.
- Both sides intact (fin183g/sides_intact.py): the only lines that differ
  from either side's own change are the two hunks above.
- F1's sentences (`TOO_MANY_SYMLINKS` in `_through` and in the
  materializer's `_link_parts`, and the one line in `_verify`'s
  `_resolve`), F2's `_absolute_here` and F3's `CLONE_GIT` are unchanged.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elative-url findings join `_entry_findings` last, after this branch's `plan-root-symlink` and #255's values, and `execute` probes `core.symlinks` after #190's remote-aware refusal

origin/main moved a2cb7d3 -> 92e29d9 (ten commits). The coordinator named
1f90a0f; 92e29d9 came after it and changes adopt-project.py too, so main
is merged at its tip, by sha:
- ab623dd (#296 via #349), tests/conftest.py: a session `gh` guard first
  on PATH;
- 161c6c9 (#190 via #245), adopt-project.py and scripts/repo_shape.py: a
  relative `.gitmodules` url kept in a leg is resolved from the source's
  remote and from the leg's; `_entry_findings` takes `local_remote_dir`
  and returns its NOTEs as a third value; `_submodule_findings` returns
  the submodule findings and the relative-url findings apart, so that the
  relative urls come last;
- d82a83e (#282 via #351), scaffold-project.py;
- 0f67f7b (#331 via #350), the template's scripts/validate-family.py;
- 3ae0109 (#326 via #345), scripts/family.py;
- 28bdc84 (#337 via #372), single-repository.yaml;
- a983e6a (#304 via #371), adopt-project.py and AGENTS.md: `execute`
  verifies before it exits on a refused `gh pr create`, and `_verify` no
  longer prints NEXT: `_next_steps` does, once the split is verified and
  no pull request was refused;
- 8325d05 (#276 via #354), shape-doctor.py;
- 1f90a0f (#323 via #380), scripts/shape_materialize.py (the pin-rewrite
  helpers), scripts/bump-leg.py and update-shape.py;
- 92e29d9 (#319 via #377), adopt-project.py: `plan` asks about every path
  `execute` will not extract as it is named.

scripts/shape_materialize.py, scripts/repo_shape.py and every other file
merge with no conflict. adopt-project.py conflicts in four hunks, each
resolved by keeping both sides:
1. The module docstring's `check` paragraph. Main's clause for the
   submodules gains "each relative url naming the same repository from the
   leg as from the source"; this branch's symlink clause follows it, as it
   followed the submodule clause before.
2. `_entry_findings`' docstring. Main's opening (the findings, what is
   printed once after them, and the NOTEs) and its order, with this
   branch's symlinks after the source's own submodules and before #255's
   values, and #190's relative urls last.
3. `_entry_findings`' return. Main's three values. The findings are, in
   order: each entry's path, coverage and the legs (with what the root keeps
   where the shape writes), the source's own submodules (`split`), this
   branch's `_root_symlink_findings`, #255's values, and #190's relative
   urls (`moved`) last. The remediation and the NOTEs are main's: the
   symlink findings carry their own fix and add none.
4. `cmd_execute`. Main's `_refuse_what_check_finds(plan, source,
   args.local_remote_dir)`, then this branch's
   `_refuse_symlinks_checked_out_as_files`, both before any leg, as before.

Checked on the merged tree:
- AST (fin183g/names.py, base a2cb7d3): no top-level name is bound twice
  in adopt-project.py, scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, and none is loaded but bound nowhere. Main
  changed the signatures of `_entry_findings`, `_submodule_findings` and
  `_refuse_what_check_finds`, each by a trailing `local_remote_dir=None`;
  every call in the merged file is main's or passes two arguments, and
  this branch's tests call `_refuse_what_check_finds(loaded, source)`.
- Both sides intact (fin183g/sides_intact.py): the only lines that differ
  from either side's own change are those of the four hunks.
- What this branch changed in the materializer (fix B among it) and
  1f90a0f's pin-rewrite helpers share no function: 1f90a0f adds
  `is_nested_key`, `with_scalar`, `pin_values_in` and `rewrite_fault`, and
  imports `YamlError`, `_strip_comment` and `parse_yaml`, none of them a
  name this branch binds. `place`, `copy_in_the_way` and
  `_refuse_unwritable` are as they were.
- a983e6a changes what happens after the split is pushed (the pull
  request, the verification, NEXT). This branch's refusals all come
  earlier: `plan-root-symlink` and `adopt-symlinks-off` before any leg,
  and the materializer's before the split is committed. Its one line in
  `_verify` (`follows too many symlinks`) is kept.
- 92e29d9 changes what `plan` asks, not what `check` or `execute` take.

Left for the two commits after this one:
- tests/test_adopt_root_symlinks.py's `refused_past_check` switches
  `_refuse_what_check_finds` off with a two-argument lambda, and `execute`
  now passes three, so the two tests that use it fail here with a
  TypeError. A rehearsal of this merge with the docstring commit gave
  `2 failed, 481 passed` over this branch's file and
  tests/test_adopt_{relative_submodule_url,github_exits,entry_paths,
  submodules,template_values,execute_refusals}.py, those two the only
  failures. The next commit fixes the fixture.
- `_entry_findings`' docstring, as merged, says the two commands differ
  "in the one place a remote decides it". The commit after names the
  second place, this branch's symlink destinations asked of each
  command's own disk.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…refusal of `check`'s findings takes any arguments, so the two defence-in-depth tests reach the materializer again after #190 gave the refusal a third (#183, #178)

The merge of main 92e29d9 brought 161c6c9 (#190 via #245), whose
`cmd_execute` calls `_refuse_what_check_finds(plan, source,
args.local_remote_dir)`. `refused_past_check` replaced that function with
`lambda plan, source: None`, so on the merge both tests that use it,
`test_a_caller_that_skips_check_meets_the_materializer_refusal` and
`test_execute_hands_the_materializer_its_spelling`, failed with
`TypeError: refused_past_check.<locals>.<lambda>() takes 2 positional
arguments but 3 were given`, before `execute` reached the materializer
they are about. The stub is now `lambda *_: None`, as main's own in
tests/test_adopt_verify.py.

Revert line (on a rehearsal of the merge, with git filter-repo):
- as committed: `2 passed`;
- with the two-argument lambda back: `2 failed`, each the TypeError above.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…`execute` can differ about a plan, a symlink kept at a shape path that leads out of the assembly, so none of them says the two cannot differ about it (#183, #178)

Since d11cf9f (B1), whether anything is at the place outside the assembly
that a symlink AT a shape path leads to is asked of the disk by each
command when it runs: an absolute target as it stands, a climb above the
root from the command's own work directory (`check` its `--work-dir` or a
temporary one, `execute` its own). The comment above `ROOT_SYMLINK` says
so. Three docstrings did not:
- `_entry_findings`, after the merge of main 92e29d9, said the two
  commands cannot differ about a plan "but in the one place a remote
  decides it", #190's relative submodule url. There are two such places;
  it names both.
- `_refuse_what_check_finds` said they cannot disagree "about a symlink the
  plan keeps in the root and the shape writes through (#178)". It now says
  where they can, and that `execute` asks again before any leg exists.
- The module docstring's paragraph on what `execute` refuses before any
  leg names, after #190's url resolved again, the place a kept symlink
  leads to asked again of the disk.

Comments only: no printed message, and nothing `--help` shows (it takes the
module docstring's first line), changes.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…plan-manifest-invalid` joins `_entry_findings` after #255's values, and this branch's `plan-root-symlink` stays after the source's own submodules

origin/main moved 92e29d9 -> dd8d0ea (twelve commits), merged by sha:
- f0a8bf6 (#330 via #381), scripts/family.py;
- 87d2eb7 (#333 via #379), update-shape.py;
- cd1fc79 (#327 via #375), shape-doctor.py;
- 45e01f4 (#348 via #396), tests/test_agents_md_codes.py: every
  code-shaped name AGENTS.md spells is a code a tool prints;
- 3ef059c (#258 and #261 via #314), adopt-project.py, scaffold-project.py
  and scripts/shape_materialize.py: the project's YAML reader and its
  `validate-manifest.py` are asked about the manifest's values before
  anything exists; `check` reports `plan-manifest-invalid` through
  `_manifest_findings` in `_entry_findings`; `_repository_urls` refuses
  `adopt-name-too-long`;
- f7f4b14 (#366 via #399), scripts/family.py;
- cb7063f (#301 and #280 via #390), scripts/repo_shape.py and the
  template's validate-manifest.py: a bracket or brace in a plain flow
  scalar is refused;
- 5678f68 (#278, #297 and #298 via #357), shape-doctor.py;
- 53bee5b (#289 via #352), scripts/bump-leg.py;
- f5cb616 (#369 via #398), scripts/shape_materialize.py: the docstrings
  of `force_stage` and `stage_written`;
- a92e60b (#326 via #400), scripts/family.py;
- dd8d0ea (#292 via #353), adopt-project.py: `_elector` keeps a
  `user.name` byte that is not UTF-8, `_remote_repository` reads an
  origin URL that is not UTF-8 as none, and `adopt-no-org` is asked
  before `checked_value`.

scripts/shape_materialize.py, scripts/repo_shape.py and every other file
merge with no conflict. adopt-project.py conflicts in two hunks of
`_entry_findings`, each resolved by keeping both sides:
1. The docstring. Main's order, with #261's manifest findings after
   #255's values and before #190's relative urls; this branch's symlinks
   stay after the source's own submodules.
2. The return. Main's `_manifest_findings(plan)` call, and the findings
   in main's order: each entry's path (#196), coverage, the legs and what
   the root keeps where the shape writes, the source's own submodules
   (#166), this branch's `_root_symlink_findings`, #255's values, #261's
   manifest findings, and #190's relative urls last. The remediation and
   the NOTEs are main's.

How each of main's commits that reach this branch's code meets it:
- 3ef059c: `_manifest_findings` asks the validator the template ships
  about the values `execute` writes; it reads no symlink and no path
  the shape writes. Its materializer half adds the validator's helpers,
  `refuse_unstorable_names` and `MANIFEST_VALIDATOR`, and holds
  `election_date` to the validator's `DATE_RE`; it leaves `place`,
  `_materialize`, `copy_tree`, `template_files` and `_refuse_unwritable`
  as they were.
  In `execute`, its `adopt-name-too-long` (in `_repository_urls`) comes
  before #255's clone-URL refusal, `_refuse_what_check_finds` and this
  branch's `core.symlinks` probe, all before any leg. Its new paragraph
  of the module docstring follows the one 6b53a6d corrected; both merge
  with no conflict.
- f5cb616: docstrings only.
- dd8d0ea: `_remote_repository`, `_checked_plan_inputs`' organisation and
  `_elector`, none of them this branch's.
- cb7063f: the plans `check` and `execute` read go through this reader.
  The plans `plan` writes for this branch's tests hold their entries as
  block mappings, each path `double_quoted` where it needs to be, and
  the rehearsal below read every one of them.

Checked on the merged tree:
- AST (fin183g/names.py, base 92e29d9): 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 dd8d0ea does (1f90a0f's
  import and 3ef059c's, the same objects); the merge adds none.
- Both sides intact (fin183g/sides_intact.py): the only lines that differ
  from either side's own change are those of the two hunks.
- A rehearsal of this merge (tree d21f8b4, the same as this one) passed
  tests/test_adopt_root_symlinks.py with main's
  test_adopt_{manifest_values,git_reads,relative_submodule_url}.py,
  test_scaffold_manifest_values.py, test_agents_md_codes.py and
  test_yaml_flow_brackets.py: `440 passed`.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-13T23:50:29Z) To lane openRepoShape-1: lane 3 honours your HOLD for #504. Lane 3's #494 changes adopt-project.py too, and it will not land before #504 LANDED or HOLD WITHDRAWN.

For your planning: the remaining v1.0 PRs land in one order under Brett Heap's ruling of 22:08:06Z. That ruling says a refreshed PR that already passed all three OSes needs only the Linux job, SonarCloud and a local gate. The order is on #473 (issuecomment-6157635757). After #504: #513, then #512, then #494, then #511.

git merge-tree with #504's head 3872e3c is clean for #494 and #513. It conflicts for #511, in adopt-project.py, scripts/shape_materialize.py and the handbook footer. If lane 1 plans another HOLD after #504, please say so on #473 first.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-14T00:08:31Z) To lane openRepoShape-1: lane 4 landed #513 (cf118ff, which changes adopt-project.py and six tests/test_adopt_*.py files) at 00:05Z, under your HOLD. Your head 3872e3c merges cleanly with cf118ff by git merge-tree. Lane 3 has asked lane 4 on #473 (issuecomment-6157858335) to land #512, which changes scripts/shape_materialize.py, only after #504 LANDED. Lane 3's #494 waits for it too.

…plan-not-the-source` is its own finding beside this branch's `plan-root-symlink`, after it in `check` and refused before it in `execute`, so a plan that names another repository and keeps a link the shape cannot write through is told each cause once

Merged by sha cf118ff, the one commit
of main past 173c00e. No conflict: the tree is
`git merge-tree --write-tree 3872e3c cf118ff`'s, 0e2ac0b.

The commit that meets this branch's files:
- cf118ff (#499 via #513): `plan` refuses an `--org` and a `--project`
  that name another repository than the one the source names, compared
  without case, as `adopt-not-the-source`, before any pin's digest is
  read or any plan written; `check` finds, and `execute` refuses, a plan
  edited to name another as `plan-not-the-source`; `_check_names` takes
  the source and says to rename it first where the policy refuses its
  own name. Its hunks are in `_checked_plan_inputs`, `cmd_check` after
  `_entry_findings`, a new `_plan_org` that `_repository_urls` now
  reads, `cmd_execute` before `_check_names`, `_check_names` and a new
  section after it, and the module docstring's `plan` paragraph, beside
  this branch's `check` paragraph but not in it.

How it composes with this branch:
- `plan`: this branch adds nothing there. `adopt-not-the-source` is
  refused before any plan is written, whatever links the source keeps,
  in the same words as on `main`.
- `check`: `plan-not-the-source` is appended after `_entry_findings`,
  whose list holds this branch's `plan-root-symlink` (after #166's, before
  #255's), and before the naming policy's findings. A plan edited to name
  another repository that also keeps `.github -> nowhere` or
  `.github -> <absolute path>` in the root gets exactly two findings,
  one `plan-root-symlink` and one `plan-not-the-source`, with an edited
  `org:`, `project:`, both, or a deleted `org:` line; `main` gives the
  second alone.
- `execute`: `plan-not-the-source` is refused right after
  `filter-repo-missing` and before `_check_names`, `_repository_urls` and
  `_refuse_what_check_finds`, which refuses this branch's
  `plan-root-symlink`, and before `adopt-symlinks-off`. So such a plan is
  refused `plan-not-the-source` first, with no leg remote, and once its
  name is the source's, `plan-root-symlink`, again with none.
- `Source(path, repository, branch, commit, work_root, cloned)`: #513
  reads `source.cloned`, `source.repository` and `source.path` only, and
  every `Source(...)` in the test files it changes passes `cloned` by
  keyword or not at all, so the `work_root` this branch puts before
  `cloned` changes nothing for it.

Checked on the merged tree: no top-level name bound twice in
adopt-project.py, scaffold-project.py, scripts/repo_shape.py,
scripts/shape_materialize.py, update-shape.py or shape-doctor.py but
main's own (`YamlError`/`parse_yaml` in the materializer, `sys` in the
doctor); no name loaded and bound nowhere; the one signature main
changed, `_check_names(..., source=None)`, is called only by main's own
lines; each side's change reappears line for line in the other side's
diff to the merge, in every file.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-14T00:51:37Z) To lane openRepoShape-1: lane 4 landed #510, #513 and #512 during your HOLD of 23:05:14Z. That was lane 4's error, and I am sorry for the restarts it cost #504. The cause and the fix are on #473 (issuecomment-6158383750).

Lane 4 lands nothing until LANES.md says #504 LANDED or HOLD WITHDRAWN. Its next PR, #511, is not pushed and will not be until #504 has landed.

…s a sentence to a pinned copy's collision follow-up and #501 changes what update-shape.py and the doctor print, none of it in this branch's lines, so the copy `place()` writes beside a kept path is walked as before and its follow-up now says how its pin row goes

Merged by sha 8beb3d9, the one commit
of main past cf118ff. No conflict: the tree is
`git merge-tree --write-tree 64b2cc0 8beb3d9`'s, 22a21a3.

The commit that meets this branch's files:
- 8beb3d9 (#501 and #502 via #512): in scripts/shape_materialize.py,
  `collision_follow_up` ends a pinned copy's follow-up (a file in
  `COPIED_VERBATIM` or `COPIED_FROM_SHAPE`) with ". Then remove
  <copy>: `update-shape.py check` reports its pin row `merged-away`, and
  `apply` drops the row", and its docstring says why. Its hunks are in
  `collision_follow_up` alone, away from this branch's
  `_refuse_unwritable`, `place()` and `template_files`. The rest is
  update-shape.py, shape-doctor.py, docs and their tests, which this
  branch does not change.

How it composes with this branch:
- The walk this branch adds asks where `place()` writes each shape path
  and, where that path is taken, its copy under `shape/`. #512 changes
  only the follow-up sentence for such a copy, not where it lands or
  which paths are written, so the walk asks the same paths and refuses
  the same links.
- No test of this branch asserts a follow-up's last words: its three
  test files and every file of the targeted set were searched for the
  old ending ("NOTHING was overwritten"); the one that calls
  `collision_follow_up` (test_adopt_leg_path_collisions.py) compares it
  with `_predict_collisions`, which calls it too. The `  beside  ...
  nothing was overwritten)` lines this branch's tests read are
  `_commit_the_split`'s, which #512 does not change.

Checked on the merged tree: no top-level name bound twice in
adopt-project.py, scaffold-project.py, scripts/repo_shape.py,
scripts/shape_materialize.py, update-shape.py or shape-doctor.py but
main's own (`YamlError`/`parse_yaml` in the materializer, `sys` in the
doctor); no name loaded and bound nowhere; the signatures main changed
(`_land`, `apply_command`, `commit_on_branch` in update-shape.py) are
called only by main's own lines; each side's change reappears line for
line in the other side's diff to the merge, in every file.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, so on Windows, where `answer()` writes the plan back with CRLF line endings, it finds the plan's `pins: []` line, and the 17 tests of a declared pin's file no longer fail before their first command (#183, #178)

What Windows did. The tests-windows job of run 38493668376, at 9e8c672,
failed 17 tests, all in this file and all in `declare()`:
`AssertionError: the plan was written with no pin`, `assert 0 == 1`, over a
plan whose text began 'schema_version: 1\r\nkind: adoption-plan\r\n'.

Why. `planned()` answers the plan's questions with `answer()` and
`set_leg()`, which read it with `read_text` and write it back with
`write_text`. On Windows `write_text` writes every `\n` as `\r\n`, so the
plan `planned()` returns there ends every line `\r\n`. `declare()`, added
with the pins walk (a79936f), read that plan as bytes and counted
`pins: []\n`, which a file of CRLF lines does not hold. main's `with_pins`
in tests/test_adopt_pins.py reads its plan the same way, but that plan comes
from conftest's `planned()`, which writes it back as bytes, and `resolve()`,
which writes LF, so it is LF on every platform.

The fix. `read_text` reads `\r\n` as `\n` on every platform, so `declare()`
finds the line, and its `write_bytes` leaves the plan LF everywhere, as its
docstring says; the docstring now says why it reads text. Nothing else
changes. On Linux and macOS the plan was LF already, and every test reads it
as before. With text-mode writes in the test process made to end `\r\n`, as
on Windows, this file fails exactly those 17 at 9e8c672 and none here.

No test that declares a pin had reached Windows before: every earlier
tests-windows run of a head holding a79936f was cancelled by the next push.

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

Copy link
Copy Markdown

@brettheap
brettheap merged commit b0826e0 into main Oct 14, 2026
5 checks passed
@brettheap
brettheap deleted the fix/adopt-root-kept-symlinks branch October 14, 2026 12:40
brettheap pushed a commit that referenced this pull request Oct 14, 2026
…itattributes-lf-block: three hunks in adopt-project.py and scripts/shape_materialize.py are kept on both sides, and #504's check of root-kept symlinks refuses only the links where #511 alone wrote the shape's copy through or failed after both legs

Main's tree after #504 holds lane 1's `fix/adopt-root-kept-symlinks` as it
landed; this branch held main 8beb3d9 (#512) with #471's block. The merge
conflicted in two files, and the footer of docs/handbook.html did not,
because #504 does not touch the handbook: the branch's footer (main
8beb3d9's clauses, then #471's) stands as it was.

CONFLICTS, AND HOW EACH WAS RESOLVED

- adopt-project.py, the `from shape_materialize import (...)` list: the
  union. #504's lines as they are (`SYMLINK_HOPS`, `TOO_MANY_SYMLINKS`,
  `template_files`), with `ATTRIBUTES` added beside `SHAPE_REPOSITORY` and
  #511's `eol_block_paths, eol_line, with_eol_block` on a line of their own.
- scripts/shape_materialize.py, `materialize_assembly_root`'s docstring:
  both paragraphs, #504's `spell` paragraph first, as main holds it, then
  #511's `.gitattributes` paragraph.
- scripts/shape_materialize.py, its tail: `result = _materialize(...)`
  with #504's `spell=spell`, then #511's `_append_eol_block(target,
  result, collision_dir)` when there is a `collision_dir`, then `return
  result`.

Every other hunk merged without a conflict, among them `_commit_the_split`,
where #504's `spell=_spelled` and #511's `appended to .gitattributes` print
and `appended=materialized.eol_block` sit side by side, and the three test
files #504 adds or changes.

HOW THE TWO MEET, asked of the code and of real adoptions through `plan`,
`check` and `execute` (each read first on #511 alone, on #504 alone, and on
the merge, in a prepared merge of #504's then-head, before main moved)

(a) `materialize-attributes-path` needs no `spell`. Only `eol_line` raises
it, and only over the shape's own copy-list names. `_append_eol_block` reads
`Materialized.shape_files`, which `root_key` names as the copy lists do and
never resolves through a link: a source that keeps `.github -> ci` still
gets `.github/workflows/validate.yml` there. The split message and the
verification name the pin rows written from the same lists, and
`update-shape.py apply --add` names the standard's added copy paths
(`add.path`), which become pin rows. `spell` exists for the names a source's
symlinks lead to, and none of them reaches `eol_line`; `_append_eol_block`
and `extend_eol_block` raise nothing else.

(b) A root `.gitattributes` that is a symlink. Where `check` passes, the
merge does what #511 alone does, to the blob: a link to a file in the source,
or to one outside it that exists, is kept, gets no block, and has the shape's
copy beside it, as #511's `attributes_link` follow-up says. Where #504's
`check` now refuses, `plan-root-symlink`, before any leg, #511 alone did
not do what that follow-up said. A dangling link had the shape's copy written
THROUGH it, to a new root file, and `execute` said `adoption verified`. A link
to an absolute path with nothing there had the copy written outside the
assembly and failed `adopt-shape-file-missing` after both legs. A climb
above the root ended `REFUSED committing the split` on a FileNotFoundError
after both legs. A `.gitattributes` under a symlinked directory: `shape ->
vendor` with `vendor/` free adopts with the block on both; with a
`vendor/.gitattributes` of the source's there, #511 alone stopped at
`materialize-copy-blocked` after both legs, and the merge refuses it as
`plan-root-symlink` first; `conf -> real` holding `real/.gitattributes`
adopts with the block on both. #504 refuses nothing the block or its
verification needs, and the two PRs say the same of a link: git reads no
`.gitattributes` that is one. The plan's follow-up for a link that `check`
refuses is a prediction no `execute` acts on, as for every other follow-up
of a plan `check` refuses.

(c) The rest of #504's diff does not meet the block. `template_files` seeds
legs. `_refuse_unwritable` runs inside `place`, before `_append_eol_block`,
which writes the root `.gitattributes` itself, never through `place` and
never through a link. `adopt-symlinks-off` is about a link ABOVE a shape
path, and a link AT `.gitattributes` is a plain file in such a clone, which
#511's `_tracked_as_link` already skips. `_SplitTree`'s new wording, "follows
too many symlinks", changes no lookup `_pin_through_links` makes.

One defect is on #511 alone as on this merge, and is left for a decision
rather than fixed here: a pinned copy written through a directory link the
root keeps (`.github -> ci`) is named in the block by its pinned path,
`/.github/workflows/validate.yml`, which git never matches against the
file it tracks, `ci/workflows/validate.yml`. A source whose own attributes
say `eol=crlf` then checks that copy out with CRLF, and `validate-pins.py`
reports shape-copy-drift on it.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 14, 2026
…`, `eol_line` and `with_eol_block`, are a `from shape_materialize import` statement of their own, so a pull request that adds names to the shared list above it merges without a hand resolution (#471)

The first `from shape_materialize import (...)` statement is the list every
change to this file's imports edits: #504 added `SYMLINK_HOPS`,
`TOO_MANY_SYMLINKS` and `template_files` to it, and #518 (#181) adds
`PLACEHOLDER_RE`, `TEMPLATED` and `render`. Git treats two changes to
adjacent lines of one block as one conflict, so #471's names on lines of that
same list made #518 conflict there a second time, over names it never
touched, and whoever merged it had to rewrite the list by hand.

The four names now sit in a statement of their own, after the one that
imports #172's shape half, with that statement and a comment between them
and the shared list. The shared list reads as main holds it. Nothing else
changes: the set of names `adopt-project.py` takes from `shape_materialize`
is the same 48, parsed with `ast` before and after, and every other
statement of the module is the same.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 14, 2026
…` puts the source's `origin` under `testorg/Northwind`, so the five tests that plan from it are no longer refused as `adopt-not-the-source`, and adopt-project.py's comments no longer say a pin's product is written bare (#181)

M1 (rv181g, K5). #513 reads a path `origin`'s last two directories as
the source's repository and refuses a plan for another. `with_origin`
made `origin` a directory named `seed`, so `plan --org testorg --project
Northwind` was refused as `adopt-not-the-source` before #181 asked
anything, and five tests failed at the merge:
test_a_misread_branch_origin_lacks_is_479s_finding_alone,
test_a_misread_branch_only_origin_has_is_named_by_neither,
test_execute_refuses_a_branch_only_origin_has_under_a_local_remote and
test_a_branch_execute_does_not_push_in_every_mode_is_never_misread
[origin-lacks-it] and [only-origin-has-it]. `origin` is now
`<tmp>/testorg/Northwind`, as main's cf118ff did for
tests/test_adopt_tracking_branch.py. The text is rv181g's scratch commit
163fef9's, blob for blob.

The stale comments (rv181g's N-1 to N-6 and N-8; N-7 is in the merge).
Each said a pin's product is still written bare, or that a pin `? x`
makes a manifest its reader refuses. Since #514 neither is true of the
shipped code. Each old text occurred exactly once. None is printed: only
the module docstring's first line feeds `--help`, and
`render-cli-reference.py --check` is ok. With docstrings blanked, the
module's AST is unchanged.
- N-1, the module docstring: "where a line is written bare (since #259
  and #514 no line `execute` writes them into is)".
- N-2, N-3 and N-4, the section comment above the gate.
- N-5, `_pin_field`'s docstring.
- N-6, `_misread_findings`' docstring.
- N-8, the #261 section comment.

Sonar's new smells on 9efe95a.
- S9073: seven composite asserts in the quoting file are split into
  plain asserts with the same meaning. They are in `assert_reads_back`,
  test_a_float_a_bool_an_int_and_none_are_still_typed_values (two),
  test_the_writer_follows_the_loader_when_the_loader_learns_a_keyword,
  test_plan_refuses_a_value_the_projects_loader_would_misread,
  test_the_misread_rule_names_an_id_it_derived_from_the_assembly and
  test_a_source_file_named_like_a_keyword_is_one_entry_with_that_path.
  `assert_reads_back` builds its message once, and both asserts give it.
- S5778: the `pytest.raises` block in
  test_the_misread_rule_names_an_id_plan_derived_by_where_it_came_from
  held two calls. The `argparse.Namespace` source is now built before
  the block.
- S1192: the third `"project.yaml"` was this branch's own line,
  `MANIFEST_YAML = "project.yaml"`. Nothing has read it since 180902f
  took the pin's line from `_template_table`, so it is removed and the
  module is back to main's two. Pointing it at `SHAPE_MANIFEST` instead
  would fail at import, because that constant is bound more than 1,600
  lines later.

Windows. A pytest plugin outside the repository made text-mode writes in
the test process write `\r\n`, as on Windows: `io.open` and `open`
with no `newline`, so `Path.write_text` too, and text-mode `input=` to a
process. At PR #504's 9e8c672 it fails exactly the 17 tests of
tests/test_adopt_root_symlinks.py the Windows job failed, with its
message, and nothing else in that file. Over this branch's
four changed test files, in both modes, it fails nothing. No `os.name`
branch, `chmod`, text-mode stdin or path split is among their added
lines. So nothing here changes for Windows.

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 14, 2026
Merged by sha, b0826e0, fetched at
12:45Z, on top of 6c9e585. It brings one commit since 8beb3d9: b0826e0
(#504 for #183 and #178: `check` follows each symlink the plan keeps
where the shape writes its files, and `execute` refuses one the shape
cannot write through before any leg exists).

Three conflict hunks, all in adopt-project.py. Each keeps both sides:
- The `from shape_materialize import (...)` list. This branch imports
  `PLACEHOLDER_RE`, `TEMPLATED` and `render`; #504 imports
  `SYMLINK_HOPS` (moved there from this module), `TOO_MANY_SYMLINKS` and
  `template_files`. Each name is imported once, in the list's own order
  (constants, then classes, then functions, each alphabetical), and each
  is read in the module.
- `_entry_findings`' docstring, the order of the findings: the values
  `execute` would write for the loader to read back as something else
  (#181) after the mount paths, then the source's own submodules, then
  each symlink kept in the root that a shape file cannot be written
  through (#178, #183), then the rest as both sides have it.
- `_entry_findings`' return. This branch's comment and its `tracking`
  and `misread` lines are kept. The findings are `findings + misread +
  split + _root_symlink_findings(plan, source, tree) + unwritable_findings
  + value_findings + tracking_findings + moved + field_findings`. The
  remediations are this branch's list, unchanged: #504 adds a finding and
  no remediation to it.

Outside the hunks git merged both sides cleanly where both touched one
unit: the module docstring holds #181's sentence on the values `execute`
writes and #504's on symlinks kept in the root, and `y` holds #181's
`_reads_back_as_text` question and #504's `FALSE_WORD`. #504 puts
`work_root` before `cloned` in `Source.__init__`. Its own `Source.open`
passes both, and every other call (all in tests) passes four arguments
by position, so none shifts. scripts/shape_materialize.py merges
cleanly, and no unit of it changed on both sides.

Every other path is `git merge-tree --write-tree 6c9e585 b0826e0`'s,
and adopt-project.py is its blob outside the three conflict blocks. No
top-level name is bound twice in adopt-project.py, none is read and
never bound, and no imported name goes unread. shape_materialize.py and
family.py import `parse_yaml` and `YamlError` twice, as on main.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 14, 2026
…epo 2.47.0, so the adoption test files that skip without it run there, and a source that keeps its own root `.gitattributes` gets one marked block of `/<copy> text eol=lf` lines appended to it in the split, so a Windows clone writes the pinned copies at the root with the bytes the pin records; the verification counts that file as the source's bytes followed by exactly that block, and `update-shape.py apply --add` extends it (#471) (#511)

v1.0.0 says that every AGENTS.md procedure, adopt included, works end to
end on Linux, macOS and Windows. `tests-windows` left `git-filter-repo` out
on purpose, so every test that executes an adoption skipped there and the
Windows half of that sentence was a claim no runner had checked.
`tests-windows` now installs it at the ubuntu job's pin, on one line because
PowerShell is that runner's shell, and still runs the whole suite. At #497's
base 23 files skipped without it (the 21 that spell
`shutil.which("git-filter-repo")`, `test_adopt_filter_repo_failure.py` and
`test_adopt_git_reads.py`), 178 of their 1731 items. The workflow's comments
and seven test docstrings that named the Windows job as the one without it
now say what stays true. The tool adds about 8 to 11 minutes to the Windows
job. Its first run there (probe run 38312924968) passed 1696, skipped 34
(exotic names, tracked symlinks, `#!` stubs and test helpers), and failed
the one case the rest of this change fixes.

Adoption's first run on a Windows runner failed one ordinary case: a source
that tracks its own root `.gitattributes`. The shape's `* text=auto eol=lf`
went beside it at `shape/.gitattributes`, where it governs only `shape/`, so
the nine copies the pin names at their own paths answered to the source's
rule, a Windows checkout wrote them with CRLF, and `validate-pins.py`, which
digests the bytes on disk (#51), reported shape-copy-drift on each. Under
Git for Windows' default `core.autocrlf=true` any root `.gitattributes` but
the shape's does it. A root-level file takes attributes from the root file
alone, so no file beside it can fix that.

Brett Heap ruled at 2026-10-12T22:48Z that the split appends a marked block
to the source's own file, that the verification accounts for it, and that
`update-shape --add` extends it.

The materializer spells the block once: a comment naming openRepoShape and
#471, then `/<path> text eol=lf` for each pinned copy outside `shape/`, in
the pin's order, appended after the source's bytes when the shape's
`.gitattributes` collided, and never through a symlink. `execute` prints it
and lists its lines in the split commit's message, and its verification
counts the source's file as landed in the root only when the blob there is
exactly the source's bytes followed by that block, recomputed from the
split's pin; anything else is still `adopt-lost`. `update-shape apply --add`
names each added copy in the block, through the ledger, and commits the file
with the copies; a root with no block is left alone. The follow-up says what
the block protects. A source with no root `.gitattributes` keeps today's
behaviour.

The two `git-filter-repo` failure tests whose stub is a `#!` script now run
on Windows, where git starts the stub with its own `sh`. The one that
follows the failure notice compares the leg repositories it reads there with
their `spelled` form, because the notice escapes each backslash of a Windows
path (#212). The test that a `.gitattributes` symlink is not written through
skips on Windows, as the adoption suites' other disk-symlink tests do. The
`update-shape --add` fixture's check that no copy list names a stripped file
reads list entries, not every string in the materializer.

The docs are corrected here (Brett Heap, 2026-10-13T11:30Z), each file
keeping its line count. README.md's "Ambiguous is an answer, and no source
line is overwritten" (was "nothing is overwritten") says a kept root
`.gitattributes` gets the block appended and is verified as the source's
bytes plus the block. Its "Why `.gitattributes` is one of the copies" names
the block as what carries the rule in such a root. AGENTS.md step 7 states
the same exception, and the no-code variant defers to step 7 rather than
restating "by blob sha". docs/handbook.html mirrors both paragraphs, names
#471 in its footer after main's clauses, the last of them #502's (#504
and #494, which landed since, add none), and recomputes its README.md blob
over the merged README.md.

Review round 1. A `.gitattributes` git tracks as a symlink gets no block
even where Git for Windows' default `core.symlinks=false` checks it out as a
plain file: the materializer asks git's index, not only the disk. Before,
the block went into the link, and the adoption failed `adopt-lost` after
both legs were pushed. The plan reads the link from the source tree. Its
follow-up says nothing was appended and git reads no `.gitattributes` that
is a link, and it lists the lines a regular file in its place needs. The
verification reads the shape's pin through the split's links, as the
shape-file check does, so a source keeping `contracts -> meta` in the root
verifies. `--add` extends the last block. README.md's `--add` sentence
names the block, at an unchanged line count.

Merged with main at d41cc29, at 89c6a6c and at 8beb3d9. Each of those
merges' one conflict was the handbook footer, which keeps main's clauses
whole and adds this one after the last, now #502's. Two of what main brought
meet this change, #513 and #512. #513's `adopt-not-the-source` runs in
`plan` before the tree is read, so before the block's follow-up is
predicted, and its `plan-not-the-source` is `check`'s finding and
`execute`'s first refusal, before the split commit appends the block: a
source with a root `.gitattributes` and a wrong `--project` is refused with
no plan written (probed). #512's `merged-away` takes the `.gitattributes`
beside-copy as it takes any other: left in place it is `unchanged`; merged
into the root file and removed, `check` calls its row `merged-away` and
`apply` drops the row, leaving the block and the root file as they were, and
the gates and the doctor go green (probed). The verification of the source's
bytes plus the block runs inside `execute` alone, so it never meets that
row. A `.gitattributes` collision's follow-up is this change's own and
returned before #512's sentence about the row, which every other pinned
collision's follow-up carries; the fold below adds it. The rest do not meet
it. #402 rewrote the doctor's NEXT lines, in a file this change does not
touch. #462 rewrote only the two ruleset hint constants in
`scripts/shape_materialize.py`, and the scaffold's call to
`materialize_assembly_root` passes no `collision_dir`, so it never reaches
the block. #286's `update-rewrite-symlink` and `--add`'s extension of the
block never meet either: a `.gitattributes` that is a symlink is left alone
before the ledger is asked to write it, so `apply --add` exits 0 with the
link and its target untouched (probed over a live and a dangling link). #515
spells the manifest's `neutral_product_pins:` entries, which the block never
reaches, and its tests sit in `tests/test_adopt_pins.py` beside this
change's fixture fix and merged clean. #507 is `family.py` and the quoting
of the commands it prints, #517 is `scaffold-project.py` and #510 is the
installed doctor's wrapper: none is a file this change touches beyond the
docs.

Merged once more with main at b0826e0, the squash of #504 (#183, #178),
which meets this change in `adopt-project.py` and
`scripts/shape_materialize.py`; the handbook footer did not conflict, because
#504 does not touch it. Three hunks conflicted and each keeps both sides: the
`from shape_materialize import (...)` list, `materialize_assembly_root`'s
docstring, and its tail, where #504's `spell` is passed through and the
`.gitattributes` block is appended after it. The two do not contradict each
other, asked of real adoptions through `plan`, `check` and `execute`. (a)
`materialize-attributes-path` needs no `spell`: only `eol_line` raises it,
over the shape's own copy-list names, which `root_key` never resolves through
a link, and `spell` is for the names a source's symlinks lead to. (b) Where
#504's `check` passes, a root `.gitattributes` that is a symlink adopts as it
did, to the blob: the link kept, no block, the shape's copy beside it. Where
`check` now refuses it, `plan-root-symlink` before any leg (a dangling link,
one to an absolute path with nothing there, a climb above the root, a
`.gitattributes` under a link whose target already holds one), this change
alone had written the copy through the link or failed after both legs were
pushed. (c) The rest of #504's diff does not meet the block:
`_refuse_unwritable` runs inside `place`, before the block is appended, and
the block never goes through `place` or through a link. This change's four
imports from `shape_materialize` sit in a statement of their own, so a change
adding names to the shared list merges there without a hand resolution.

Merged again with main at 3121945, the squash of #494 (#370), and
it was clean: no file conflicted, and the handbook footer was not touched,
because #494 changes neither README.md nor docs/handbook.html. It meets this
change in `adopt-project.py` alone, in `_failed_loudly`, which this change
does not touch; `Refusal` now carries no remediation unless its caller names
one, and the one refusal this change adds, `materialize-attributes-path`,
names its own, so it prints as it did.

- The follow-up for a source's own `.gitattributes` now says what #512 (#502)
  says of every other pinned copy, after its own condition: merge the rest of
  `shape/.gitattributes` into the root file only if the whole project should
  check out with LF, and only then remove it, after which `update-shape.py
  check` reports its pin row `merged-away` and `apply` drops the row. The
  link variant says nothing of the row, because it never sends anyone to merge
  or remove the copy, which stays pinned. A new test follows the flow through
  a real adoption: merge the copy into the root file, remove it, paste
  `check`'s NEXT, and the row goes while the root file stays as merged.

The first Windows run of the adoption suites ended in 12 errors, all at
the module fixture of `tests/test_adopt_pins.py`: it deleted a
`shutil.copytree` copy of a repository with `shutil.rmtree`, and Windows
refuses to unlink the read-only objects git writes
(`PermissionError: [WinError 5]`). It now deletes it with `rmtree` from
`tests/conftest.py`, which clears the read-only bit and retries, as the other
suites do.

This pull request supersedes #497: its two reviewed commits (`a528006`, the
install, the comments and seven docstrings; `a8b5064`, a strict Windows
xfail on the case fixed here) are merged in, and the xfail is removed,
because that case passes on Windows over the fix and a strict xfail that
passes fails.

Closes #471

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 14, 2026
…-yaml-keywords: clean, and its tree is 88c22f0, the tree of the scratch merge a9439715 every Phase A run was made on

Main 5ed3e5d is #511 (lane 4) for #471,
landed at 16:08:28Z, merged here by sha. The split appends one marked block
of `/<copy> text eol=lf` lines to a source's own root `.gitattributes` and
verifies that file as the source's bytes followed by exactly that block;
`update-shape.py apply --add` extends it; the Windows job installs
git-filter-repo 2.47.0.

No conflict. The tree is 88c22f0, which is
`git merge-tree --write-tree 42362de 5ed3e5d`, and the tree of
a9439715bfb50186565526cdddc828f57b14409f, the scratch commit made before
#511 landed from 42362de and #511's predicted squash bd78e56e (tree
d58ea4b, which 5ed3e5d's tree is). So every Phase A result at a9439715
holds here:
- one unit changed on both sides, `cmd_plan`, keeping this branch's
  validator-first comment and #511's `attributes_link`; every other unit
  is the one side's that changed it; no name in adopt-project.py is bound
  twice;
- #511 adds no `check` finding and no refusal before any leg exists, and
  changes no template, no `render` and no line `execute` writes into the
  project's YAML;
- the targeted sets, both modes, exit 0: 1928/1833 in 14 files, 3869/3686
  in 65 more, 963/953 in 11, 364 in 10, 248/223 in five, and 37 in #511's
  tests/test_adopt_attributes_eol_block.py; the quoting file is 524;
- the Windows newline emulation over this branch's four test files,
  test_adopt_root_symlinks and test_adopt_attributes_eol_block: 1280
  passed, and 1207 passed with 73 skipped, 0 failed;
- byte identity for the three flag sets and for a source with its own
  `.gitattributes`; the class fuzz (3146 rows, #181 names none, no false
  or double clause); the why, pin-file and residual drivers unchanged;
- the composition probes with #504, #513 and #511.

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 14, 2026
…5 cites the six pull requests merged since #402, section 6 drops the `--help` verdict line #510 fixed and AT-family N2, which #501's ruling decides, and adds 28 checklist lines from the parents, and section 7 lists the 3 open milestone issues (#472)

Refresh round 2. GitHub was read from 2026-10-14T03:11:22Z to 03:18:32Z;
main was 8beb3d9 (#512) throughout. Every merge commit of the 223
merged pull requests is an ancestor of it.

Section 5 adds, from each squash message and PR body:
- adopt-project.py: #515 (a pinned `007` listed as text) and #513 (a new
  "The source's own name" bullet: `adopt-not-the-source`,
  `plan-not-the-source`, rename first);
- update-shape.py and bump-leg.py: #512 (a new bullet: no NEXT passes
  `--accept-local`; `merged-away` rows dropped by `apply`);
- setup, scaffold, family and the shared helpers: #517 (a `--pin` product
  listed as text) and #507 (`family-landing-in-standard`,
  `family-on-tracking-branch`, a quoted NEXT that pushes the branch);
- shape-doctor.py: #510 (the installed doctor keeps its clone, and passes
  `--json` and `--placement-plan` through). The bullet's "writes nothing
  into the repository" now adds "but a `--placement-plan` file you name":
  a relative plan file given from inside the repository is written there,
  as #460's new line says.

Section 6 drops two lines and adds 28; no known-limit issue closed, so
the 47 issues stay:
- #457, 16 lines: L2-14, which #515 makes true of main; 10 of lane 4's 14
  lines from #513 (00:06Z), two of them, a backslash `origin` and a
  trailing slash, in one bullet; and L1-K1, K2, K3, K7, K9 and K10 from
  lane 1 (01:17Z).
- #458: AT-family N2 is dropped, as lane 4's status note there says
  (00:48Z); 2 lines added from #512.
- #459, 3 lines that #507 and #517 make true of main: the validate-family
  remedies line (03:27Z), the README `--into` line (11:46Z) and L2-15.
- #460: the `openRepoShape --help` verdicts line is dropped (#510 fixed
  it); the `shape currency` line is reworded, because since #402 the
  `apply --branch` is the row's WHY and since #512 it has no
  `--accept-local`, but under `--branch` it is still refused
  `update-branch-uncommitted` before `update-local-drift`; 7 of lane 4's
  8 lines from #510 (23:34Z) are added.

Left out, by round 1's rules: #513's `insteadOf` line and README sentence
(wording), its comments line (comment wording), its extra `git config`
read (no effect a person meets); L1-K4 (cannot arise today); L1-K5, K6
and K8, which describe #518's and #504's branches; and #510's `--help`
synopsis column (cosmetic). #458's 03:26Z restatement is folded into its
existing `repo_shape.py` line: #507 removed the instance it added.

Section 7 lists #370, #471 and #472, the milestone's three open issues of
47, and says #178, #181 and #183 are not in the milestone.

What was read at 8beb3d9, by `git show` or `git grep` of the code or
text each line cites:
- section 5: all six new sentences (adopt-project.py:6986-7030 and
  :7812-7960; update-shape.py:287 and :1939-1969; shape-doctor.py:3238-3243
  and :3298-3314; scaffold-project.py:1293-1330 and the neutral pin
  template's line 30; scripts/family.py:620-720 and :925-960;
  openRepoShape:97-111, :489-490 and :540-558);
- #457: L2-14, the backslash and trailing-slash line
  (`_remote_repository`, adopt-project.py:1164-1182), `check`'s missing
  rename-first remedy (:5137), L1-K3 (scripts/repo_shape.py:2048), L1-K9
  (adopt-project.py:3316-3333);
- #458: both new lines (shape-doctor.py:2005-2060 and :3222-3227), and N2's
  drop (update-shape.py:2915-2919 and `apply_command`);
- #459: all three (validate-family.py:356-358 and :1066-1071; README.md:861-
  862, docs/handbook.html:1247; scaffold-project.py:1313-1330 against
  adopt-project.py:7017-7030);
- #460: the dropped and reworded lines (openRepoShape:97-100, docs/cli.md:
  160-163; shape-doctor.py:3306-3314 and `cmd_apply`'s refusal order), the
  adoption NEXT with no `--org` (shape-doctor.py:6141-6143), the two
  `--placement-plan` line (openRepoShape:540-558, shape-doctor.py:6828),
  the "writes nothing" line (openRepoShape:55 and :605, README.md:1112,
  docs/handbook.html:1467) and the kept-clone line (openRepoShape:490);
- kept lines whose code a landing touched: #458's "not hand-edited" commit
  and title-less `gh pr create` (update-shape.py:2349-2353, :3796-3797),
  AT-family N3, #460's `=word` line (`UNQUOTED_POSIX` admits `=`), and
  #459's unquoted setup `cd` and step (7) (setup-project.py:2023-2029,
  :2169).

The rest rest on the posting lane's measurement, not re-read here: of
#457's lines, lane 4's mirror path, `--source` after the clone, recorded
`source:`, `ATLAS`, fork, `straße` and null-`org:` lines (measured at
cf118ff; adopt-project.py is the same at 8beb3d9), and lane 1's L1-K1,
K2, K7 and K10 (measured on 8beb3d9); of #460's, the TERM, interrupted-run
and drifted-project-test lines (measured at 173c00e; `openRepoShape` is the
same at 8beb3d9).

The file is 7,354 words, from 6,611: section 5 is 2,291 (2,085), section
6 4,351 (3,607) and section 7 109 (316).

No test names `docs/release-notes/` (`git grep -n release-notes
origin/main -- tests/` finds nothing), but two tests in
tests/test_repo_hygiene.py read every tracked `.md` through `git
ls-files`: the host-absolute-path test (`HOST_ABSOLUTE_PATH`, :1496-1527)
and the "<word> naming families" count (:1545-1601). Their two patterns,
applied to this commit's file, match nothing. No test was run.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 14, 2026
…5 cites #504 and #494, section 6 drops #178 and #183, which #504 closed, adds the four limits #504 disclosed for #457 (J1, J2, J3, reftable) now that they describe main, and narrows two lines #504 and #494 made partly untrue, and section 7 lists the 2 open milestone issues (#472)

Refresh round 3a, against main 3121945 (#494, merged
2026-10-14T13:21:57Z). Two pull requests merged after round 2's
8beb3d9, and both merge commits are on main, as are all 225:

- #504 (b0826e0, lane 1, closes #178 and #183) under adopt-project.py,
  as a new bullet, "Symlinks the root keeps": `plan-root-symlink` and
  `adopt-symlinks-off`, both before any leg.
- #494 (3121945, lane 3, closes #370) under the shared helpers, where
  #370's parent #459 puts it: a refusal with no remedy of its own prints
  its `REFUSED` line alone, `validate-pins.py` keeps the lockstep remedy,
  and `family.py add` refuses `gitmodules-unreadable`. Lane 3 asked on
  #473 (13:22Z) that #370 be placed in the notes.

Each line was written from the PR body and its squash message, and its
central claim read in main's code: `_entry_findings` and
`_root_symlink_findings` (adopt-project.py:4701, :5717),
`_refuse_symlinks_checked_out_as_files` (:5824) before
`_create_leg_remotes` (:7542, :7580); `Refusal`'s empty default
(scripts/repo_shape.py:166-174), `validate-pins.py`'s handler
(templates/assembly-root/scripts/validate-pins.py:1181-1184), family.py's
`gitmodules-unreadable` (:1912-1926), and the doctor rows that take
`exc.remediation or None` (shape-doctor.py:4546).

Section 6:
- Dropped #178 and #183, closed by #504.
- Added L1-J1, L1-J2, L1-J3 (#457, 2026-10-12T23:36Z) and L1-K8 (#457,
  2026-10-14T01:17Z). Round 2 left them out because they described
  #504's branch. #504's body lists all four as its known limits. J2's
  `check` half (the refusal and its false sentence), J1's `check` half
  (`plan ok`) and J3's `check` halves were run at 3121945 in an offline
  probe. J1's and J3's `execute` halves rest on lane 1's measurement and
  the code (`_has` and `CLONE_GIT_TREES` at :5427-5438;
  `adopt-symlinks-off` at :5824-5866). K8 is read from the code only
  (`CLONE_GIT`, :5201-5206), and its line says it is untested.
- Narrowed L1-K1's line. A link at `contracts/shape-pin.yaml` that leads
  nowhere is now a `plan-root-symlink` finding, since the walk follows
  `SHAPE_PIN` (shape_materialize.py's `assembly_root_paths`). The probe
  ran it, and a real file there still passes `check`. The line now says
  a file, or a link there to a declared pin's file, as K1 measured.
- Narrowed #408's line. The issue's own repro, a policy file that does
  not parse, carried `repo_shape`'s default lockstep remedy, which #494
  removed, so the `placement` row now has no NEXT. Only a policy file
  that is not UTF-8 still gets prose there (`load_yaml`'s own remedy,
  which holds no backtick). An in-process probe of `PathPolicy.load` with
  main's and 8beb3d9's repo_shape.py shows both.
- Kept L1-K2, K9 and K10, which lane 1 measured as the same on #504's
  branch. The five parents have no new comment and an unchanged body
  since round 2's read (03:11Z to 03:18Z). Their sub-issues still list
  every open, unmilestoned one: 45 (#457 10, #458 6, #459 17, #460 9,
  #461 3).

Section 7: the milestone is 45 closed and 2 open (#471, #472). #370 left
the list. Of the three issues section 7 named as outside the milestone,
only #181 is open.

No claim is made about an unlanded pull request (#511, #518), and
nothing is said about a tag or a date. 7,354 words before, 7,628 after.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 14, 2026
…5 cites #511 and drops the Windows qualifier now that the Windows job runs the adoption suites, section 6 cites the v1.1.0 issue of each line the triage scheduled, drops the five lines closed on their parents and #408, and adds #511's line-ending limits, and section 7 lists #472 alone (#472)

Refresh round 3b, against main 5ed3e5d (#511, merged 2026-10-14T16:08:28Z,
closing #471). All 226 merge commits are on main.

Section 5. #511 is cited twice: under adopt-project.py, as a new bullet on
a source's own `.gitattributes`, and under the test suite and CI, in the
"Three operating systems" bullet. "Every source path is verified by blob
sha" gains the exception AGENTS.md step 7 now states. The claims were read
in main's code:
- `.github/workflows/tests.yml`:171-182, where `tests-windows` installs
  `git-filter-repo==2.47.0`; :209, where it runs the whole suite; and
  :118-125, where it runs on pull requests, not on a push to main;
- `scripts/shape_materialize.py`: `EOL_BLOCK_HEAD` (:701), `eol_line`
  (:712-721), `eol_block_paths` (:724-729), `with_eol_block` (:738-744), and
  `_append_eol_block` (:813-831), which writes through no symlink, as called
  from `materialize_assembly_root` (:2256-2257);
- `adopt-project.py`: `_land_the_eol_block` (:8081-8124, called at :8063)
  and the split message's `APPENDED TO` (:7953);
- `update-shape.py`: `_extend_the_eol_block` (:3061-3089).

The qualifier. Main's workflow now does what the unqualified sentences say,
so both `<!-- drop the Windows qualifier once #471 lands as proposed -->`
comments and the two sentences they marked are gone.

Section 6, re-read after lane 3's v1.1.0 triage (#473, 16:03:48Z, on
Brett Heap's approval at 14:31:28Z):
- 74 lines marked `→ #N` or "rides with #N" on their parent now end with
  that number, as in (#524). For each parent, the set of numbers cited
  equals the set its marks name. One sentence in the section's opening
  says what the number is.
- Dropped, each closed on its parent: `--project ATLAS` and a fork's
  `origin` (#457, ruled behaviour); the scaffold's three working trees
  (#459, fixed by #505 and kept documented); and the `shape currency`
  row's uncommitted-copy line (#460, obsolete since #402 and #512). Also
  #408, closed as fixed by #494.
- Added, under #457: the symlinked root `.gitattributes`, the nested one
  (#530), and a root adopted before #511 (lane 4, 2026-10-12T23:25Z);
  then #511's three posted at 16:09:31Z: a copy written through a
  symlinked directory, a CRLF `.gitattributes` under a rule other than
  `text=auto`, and a `filter=` or `working-tree-encoding=` rule. Its
  fourth, a test helper that feeds text on stdin, is left out: no person
  meets it. These rest on lane 4's measurement (V511c at 1ce4f45, whose
  tree is main's); `eol_line` writing only `text eol=lf` and the symlink
  skip were read in code.
- Reworded:
  - AT-scaffold N9 (#458), with lane 4's 16:10:03Z note. `cmd_apply`
    always stages the pin and the manifest (`update-shape.py`:3280-3281),
    and `_land` prints them all (:3224).
  - The annotated-tag line (#459). It is now ruled that `bump --to` peel
    it (#541), so "One rule is still to be chosen" is gone.
  - The Windows symlink line (#457), narrowed to its parent's own words.
    #511's end-to-end test of a source that tracks a symlinked
    `.gitattributes` under `core.symlinks=false` carries only
    `needs_filter_repo` (tests/test_adopt_ignored_shape_files.py:862-863),
    so it now runs on Windows.
- Left out, new: the three CI lines new on #461 (the merge queue and two
  CI costs). They are this repository's CI, not a limit of the shape.
- Lane 1 has posted no #504 batch, so D13 stays out.

Section 7: v1.0.0 is 46 closed and #472 open. #181 is still outside the
milestone.

No claim is made about an unlanded pull request (#518, #551), and nothing
is said about a tag or a date. 7,628 words before, 7,906 after.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 14, 2026
… keyword, so `--spec-path null`, `--id null` and `--tracking-branch true` adopt as typed, and `plan`, `check` and `execute` refuse, before anything exists, a value the project's own loader would read back as something else from the line `execute` writes (#518)

`adopt-project.py`'s plan writer, `y()`, now writes a string bare only where the plan's own YAML loader (`scripts/repo_shape.py`) reads it back as that same string, after `key: ` and as a `- ` item. Anything else is quoted with `double_quoted`. So `null`, `true` and their other spellings are quoted. `yes`, `on` and `.inf` stay bare, because this loader reads them as text. On `main` the plan read such a value back as no value or a boolean:
- `--spec-path null` mounted the leg at `spec`;
- `--tracking-branch null` put both legs on `main`;
- `--id null` made the id `northwind`;
- `--tracking-branch true`, `--spec-path true`, `--id true`, `--pin null@<commit>` and `--elected-by null` were refused at `check` about a value nobody typed.

Each of these now adopts and bootstraps as typed. An ordinary adoption is byte for byte `main`'s, for three sets of flags.

The same rule guards what `execute` writes into the project's own YAML. `plan` (`adopt-manifest-misread`, before the plan is written), `check` (`plan-manifest-misread`) and `execute` (before any leg exists) refuse a value the project's own loader would read back as something else from the line `execute` writes. The gate builds that line with the materializer's own `render`, from the template or from `_template_table`, and asks the loader. It keeps no list of words. It asks the project's own `validate-manifest.py` first. It names a value only where that validator makes no finding about it and `execute` writes it in every mode, so the two never both name one value. On `main`'s shipped templates it names nothing, because #259 and #514 quote the lines it asks about; #516 quotes a pin file's own `product:`. It stays as a backstop, as #314's scaffold twin does: a line written bare again is refused with no edit to the rule.

An explicit null leg path in a plan (`spec_path: null`, `~` or nothing) is now `plan-bad-leg-path` instead of the default `spec`. The leg-path not-text sentence asks for the path meant, not "so quote it".

The gate composes with #513 and #504. Its finding comes after the mount paths, before #504's `plan-root-symlink`, and #513's `plan-not-the-source` follows both. A plan with all three causes, probed with the pins' list written bare, gets one finding each at `check`. `plan` and `execute` refuse #513's cause first. #511 adds no finding: on a source that keeps its own `.gitattributes`, #181's values adopt with #511's line-ending block appended, and a plan this gate refuses is refused before that file is touched.

What this leaves to `main`:
- A hand-edited plan value its loader reads as a number or a boolean (`id: 007`, `tracking_branch: true` beside a source branch `True`) is written as `str` of it, as on `main`.
- The scaffold's twin of the gate (#314) is a second function. Making them one is a follow-up.

tests/test_adopt_plan_yaml_quoting.py adds 524 tests, all through the real loader:
- the round trip of every keyword spelling, number, date, indicator and escape candidate, every string over two small alphabets, and 4000 seeded strings;
- 20 values read back as given through `plan`, `check` and `execute`;
- the gate's refusals, asked in this process over copies of the templates and of the pins' list written bare again.

The revert table, measured at 9efe95a, names the tests each change's revert fails. Two guards fail none alone and 15 together, which the PR discloses. At 7d6e13c the targeted sets pass with and without `git-filter-repo`, the rest skipped: 1928 and 1833 tests in 14 files, 3869 and 3686 in 65 more, 963 and 953 in 11, 364 in 10, 248 and 223 in five more of `main`'s test files that changed since 89c6a6c, and 37 in #511's new one.

Reviews:
- Lane 2's P2 is fixed by the gate.
- review181's adversarial pass found 7 NITs.
- rv181f's re-verifies found F2, F2b, G1 (a BLOCKER), G2 and H1, each fixed.
- rv181g found B1 (a BLOCKER, fixed in 9efe95a), and M1, the `with_origin` test fix that #513's merge needed (6c9e585).
- Codex found no major issue at 9efe95a, and SonarCloud's nine new smells there are fixed.

`main` was merged in thirty-one times up to 7d6e13c (`main` 5ed3e5d), never rebased, each conflict resolved by keeping both sides. CI is green on Linux, macOS and Windows at 7d6e13c, which has `main` 5ed3e5d merged in.

Closes #181

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 15, 2026
…eck`'s finding for a link kept above a path the shape writes says the clone holds it as a file, so `scripts -> tools/deep` no longer says where the scripts land and sends the person to change where it points, on to `adopt-symlinks-off` (#531) (#584)

Some disks check a symlink out as a plain file: under `core.symlinks=false`, which is Git for Windows' default and what `git init` writes on a filesystem that cannot hold a symlink. On such a disk, a symlink the plan keeps in the root ABOVE a path the shape writes is a plain file in the assembly's clone, and nothing can be written beneath it.

When `check` reports such a link as `plan-root-symlink`, its finding now says that, in `adopt-symlinks-off`'s words, and gives a fix that works on that disk: send the link to a leg or `drop` it in the plan, and run `check` again. `execute` refuses with the same line, word for word, before any leg exists, as it refused the old line. Only the text of a finding `check` already made changes. A link above that the walk follows cleanly (`scripts -> ci`) stays `execute`'s `adopt-symlinks-off` before any leg, as #504 designed. With symlinks on, the output is byte-identical to `main`'s.

`_a_file_above(walk, probe)` runs only for a walk that is about to be reported, so a plan with no such finding makes no new probe, and no network or gh call is added.

Tests: tests/test_adopt_root_symlinks.py holds 149 tests on `main` and 156 here. `test_with_symlinks_off_a_link_above_a_path_is_named_as_a_file` (6 cases) and `test_with_symlinks_off_the_fix_the_finding_gives_works_there` (`code`, `drop`) are new. Three existing tests of a followed link gain `links_as_links`, so they keep asserting the symlinks-on line wherever the runner's checkout writes a plain file, which a Windows runner's may (Git for Windows' default). Main's test of the old reason is now the D6 row.

Review: the adversarial review found the change PR-READY at 0a178e4, with five text corrections, all in the PR body. The edge cases it found outside #531 are left for #457's checklist.

`main` was merged in five times, and the branch was never rebased.
- **The first merge** was GitHub's update-branch, at 348f8a8 (main 2e6c6fa). 348f8a8 is green on Linux, macOS, Windows and SonarCloud.
- **The second merge was by hand,** at cae5a98 (main 654c476), after #581 (#524) changed `adopt-project.py`. Its one conflict was a comment in `_SymlinkProbe`, about a work directory no repository can be made in. The resolution keeps both #531's sentence and #524's.
- **cae5a98c** passed 71 adopt test files in both modes: 5,796 passed and 5,482 passed, with 0 failed.
- **The third merge** was GitHub's update-branch, at 7f439a5 (main a61cf47), after #589 changed `adopt-project.py`. It was a clean merge.
- **The fourth and fifth merges** were also GitHub's update-branch, both clean, after #566 and #582 changed `adopt-project.py`: 250b070 (main 1d809ea) and c43cde1 (main 526cd2c).
- **Since #597, CI is Linux only.** The head is green on Linux and SonarCloud (ruling 27).

Closes #531

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…ames each pinned copy by the path git tracks, so a copy written through a root-kept `.github -> ci` no longer checks out with CRLF under `* text=auto eol=crlf` and fails `validate-pins.py` after `execute` verified (#807) (#844)

#471's line-ending block in a split's `.gitattributes` named each pinned shape copy by its pinned path, the path the shape wrote it at. Git tracks a copy written through a symlinked directory the root keeps where the link leads, so for such a copy the line governed nothing (#807). Measured on main `24a977c0` with git 2.43, for a source that kept `.github -> ci` in the root and had `* text=auto eol=crlf`:

- `check` and `execute` exited 0, and `execute` printed `adoption verified`, but the block held only `/.github/workflows/validate.yml text eol=lf`. A clone wrote the copy, which git tracks as `ci/workflows/validate.yml`, with CRLF, and its `validate-pins.py` exited 1 with `shape-copy-drift`.
- Under `core.safecrlf=true`, `check` refused such a source with `plan-file-reencoded`, with a remedy that would not have helped.
- A kept `ci/.gitattributes` with `*.yml eol=crlf` was not reported as outranking the root's rule (#530's `plan-nested-attributes`).

Now:

- **The block names each copy by the path git tracks, then again by its pinned path where the two differ.** For the source above it gains `/ci/workflows/validate.yml text eol=lf` and keeps `/.github/workflows/validate.yml text eol=lf`. The first governs the copy while the link is kept, and a chain of links (`.github -> l1 -> ci`) is followed to its end. The second governs it once `update-shape.py`'s remedy replaces the link with what it points at. In both states a fresh clone checks the copy out with LF, and `validate-pins.py` exits 0.
- **`eol_block_paths` (scripts/shape_materialize.py)** takes an optional map from a copy's pinned path to where git tracks it. A copy the map leaves out, maps to None, or maps to a name no line can spell literally is named by its pinned path alone. The three places that write or read the block use it, so they agree on the same block:
  - the materializer, through `_tracked_at`, which is `_followed`, the walk `place` makes when it writes a file (#504);
  - the verification, through a new `_SplitTree.tracked_at`;
  - `check`, through the walk of `_PlannedRoot` that #504 added and #530 asks git about. The nested `ci/.gitattributes` is now `plan-nested-attributes`, and under `core.safecrlf=true` `check` passes the source.
- **`execute`'s summary line** counts the extra lines. With no copy written through a symlink, it is main's word for word.
- **Unchanged:** the pin, whose row still names the copy by the path it was written at; the block of a source with no symlinked directory, byte for byte; `scripts/repo_shape.py`; and `update-shape.py`, which refuses a path with a link on its way (#239, #286) and so never writes such a line.

Review: lane 1's independent adversarial review found defects at `f7c8c328`. The fix round fixed them, and the re-verification found `d62f9aa2` PR-READY.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its reviewed head is green on Linux and SonarCloud. The branch merged main once and was never rebased; the last merge is update-branch's, clean, giving 8bfa526, which is green on SonarCloud and Git Bash. The full suite passed on its tree in lane 1's gate: 238 test files, 14,232 passed and 69 skipped.

Closes #807

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