Repository navigation
scaffold-project.py --pin spec@<sha> (or code@, shape@, or two --pins whose products differ only by case) passes --dry-run, then creates all three repositories, pushes both legs and dies with REFUSED materialize-collision, because the pin's contracts/<product>-pin.yaml is a file the shape already writes #241
Description
Lane: openreposhape-4 (openRepoShape-4)
Found by this lane's #197 fix-round writer (as out-of-scope) and confirmed by this lane's re-review of the #197 branch (87fd767, PR #234; its check 9 and nit N1), re-verified on main 54aa69a today, and on 87fd767 as well. It is pre-existing on main and not a regression of that branch. Not claimed.
What is wrong. --pin <Product>@<40 hex> declares a descent and the scaffold writes it as contracts/<product lowercased>-pin.yaml beside the manifest. The product is whatever PIN_ARG_RE lets through ([A-Za-z0-9_.-]+), and nothing asks whether the file that name produces is one the shape writes anyway, or one another --pin produces. Three product names are: spec (contracts/spec-pin.yaml, the spec leg's own pin), code (contracts/code-pin.yaml) and shape (contracts/shape-pin.yaml, the copy pin); they are the only three entries of assembly_root_paths() that have the form contracts/<product>-pin.yaml. Two products that differ only by case (openGlass, OpenGlass) are one file on every platform, because the materializer lowercases. The plan says nothing: its own pin line prints the colliding path (-> contracts/spec-pin.yaml; for the case pair, two lines with the same target) and --dry-run exits 0. The real run then creates all three repositories and pushes both legs (_create_remotes, _seed_legs), and only _build_assembly_root reaches the materializer, which refuses with materialize-collision because the scaffold gives it no collision directory. The root is never committed. README's "If it fails part-way" box (README.md:446-458) calls the plan "literally a --dry-run" and says there is no rollback, so the typed yes is given over a plan that cannot succeed, and a corrected re-run is refused as scaffold-remote-exists. Against GitHub the three repositories are real. This is #197's harm (PR #234 refuses a leg path over these files) reached by a different argument: the input is a --pin product, and it needs no leg flag at all.
# $U: a clean checkout at 54aa69a. gh is a PATH shim that logs its argv and exits 97 (never called: its log was never created).
# $C: a local clone of a stand-in neutral product (`openGlass`), $SHA a commit in it, so `--pin-source` reads the digest and no network is touched.
# Every repository is a bare repository under $R/remotes; each row below used a fresh one.
scaffold="python3 $U/scaffold-project.py --org testorg --project Northwind --elected-by 'Test Human' \
--elected-on 2026-09-02 --local-remote-dir $R/remotes --pin-source $C"
$scaffold --work-dir $R/work --pin spec@$SHA --dry-run
# exit 0 pin opensoft/spec @ 26ddb2a53b99 tree 5e5f4610bcaa… -> contracts/spec-pin.yaml (local-clone ($C))
# --dry-run: nothing was created.
$scaffold --work-dir $R/work --pin spec@$SHA
# exit 2 REFUSED materialize-collision: $R/work/Northwind/contracts/spec-pin.yaml already exists and this materializer was given no collision directory
# Remediation: scaffold into an empty directory, or call with collision_dir set (which is what adopt does).
ls $R/remotes # Northwind-code.git Northwind-spec.git Northwind.git
for r in Northwind Northwind-spec Northwind-code; do git --git-dir $R/remotes/$r.git for-each-ref; done
# Northwind: no refs (the root was never committed). Northwind-spec, Northwind-code: refs/heads/main (pushed)
# the corrected re-run, same remotes directory, a pin that does not collide:
$scaffold --work-dir $R/work2 --pin openGlass@$SHA
# exit 2 REFUSED scaffold-remote-exists: $R/remotes/Northwind.git already exists
$scaffold --work-dir $R/work3 --pin openGlass@$SHA --reuse-empty-repo
# exit 2 REFUSED scaffold-remote-exists: $R/remotes/Northwind-spec.git already exists and has commitsThe same on 54aa69a, one row per input (every failing real run left all three bare repositories, both legs pushed and a root with no refs; gh was never called):
--pin arguments (with --pin-source $C) |
--dry-run |
real run | collides with, and where it dies |
|---|---|---|---|
spec@$SHA |
0 | 2 | contracts/spec-pin.yaml, templated for the spec leg (shape_materialize.py:198); the neutral-pin loop (:1163-1167) finds it written |
Spec@$SHA |
0 | 2 | the same file: parse_pin keeps the case, the materializer lowercases (:1164) |
code@$SHA, MedxSoft/code@$SHA |
0 | 2 | contracts/code-pin.yaml (:199), at :1167; the owner prefix is dropped by parse_pin (scaffold-project.py:100) |
shape@$SHA |
0 | 2 | contracts/shape-pin.yaml: the neutral pin is placed first, so the copy pin, written LAST (:1179), finds it |
openGlass@$SHA and OpenGlass@$SHA |
0 | 2 | contracts/openglass-pin.yaml twice; the second dies at :1167 |
openGlass@$SHA (control) |
0 | 0 | none; three repositories, the root holds contracts/openglass-pin.yaml |
openGlass@$SHA1 and openGlass@$SHA2 |
0 | 0 | not a collision, but the same dict: the first pin is silently dropped (see Why) |
Why. PIN_ARG_RE (scaffold-project.py:67) takes any name; parse_pin (:74-102) returns the product as typed, owner stripped (:100); declared_pin_values (:318-340) keys the pins by that product (:335). _scaffold calls _declaration (:906), prints the plan and returns on --dry-run (:946-948); nothing between compares a pin's file with the shape's other paths or with another pin's. The remotes are created at :967 and the legs seeded and pushed at :970, and only then does _build_assembly_root hand the pins to materialize_assembly_root(..., neutral_pins=pins) (:798-799, collision_dir=None on purpose: "a collision here is a defect"). _materialize writes the templated files, the verbatim copies and the copies from the shape first, then one file per pin at contracts/{product.lower()}-pin.yaml (shape_materialize.py:1163-1167), then the copy pin last (:1179), each through place, which raises materialize-collision when the path exists and there is no collision directory (:1129-1139, raised at :1134). Where each dies, from a recorder on Refusal.__init__: it printed shape_materialize.py:1134 called from :1167 for spec@, code@ and the case pair, and called from :1179 for shape@. The identical-product row is the same dict: pins[product] = {...} replaces the earlier entry, so --pin openGlass@$SHA1 --pin openGlass@$SHA2 plans one pin line, exits 0 and writes commit: "$SHA2" into contracts/openglass-pin.yaml; $SHA1 is dropped without a word. A declaration is a claim about what the project descends from, and one the operator typed disappears.
What it should do. Refuse before anything is created and before --dry-run returns, naming the product and the file, so a dry run refuses exactly as the real run would. PR #234 (#197) already provides what a fix needs, and they should be reused rather than respelled: at 87fd767 neutral_pin_path(product) is the one definition of the file (shape_materialize.py:219-232), assembly_root_paths(neutral_pins=()) lists what the materializer writes (:1091-1112), and scaffold-project.py reads each --pin's product by parse_pin alone, before _identity and any gh, in declared_pin_products (:352-363) and _refuse_colliding_leg_paths(...) (call at :951-952). A refusal beside that call would ask, for each --pin: is neutral_pin_path(product) in assembly_root_paths() with no pins, and is it the path of an earlier --pin? neutral_pin_path already lowercases, so Spec and the case pair fall out with no case rule of their own. The decisions are the author's:
- The refusal's wording. A neutral product is named for the repository it pins (
openGlass);spec,codeandshapeare the three names whose file the shape owns, so the remediation can say that, and for the case pair say that the two names are one pin file. - The identical product given twice (
--pin openGlass@$SHA --pin openGlass@$SHA, or with two commits). Today the last one wins, silently. It can be refused as the same collision, or accepted when the commits agree; either way, say it.declared_pin_productsisdict.fromkeys(...)(:363at 87fd767), which collapses a repeat, so a check over its result would not see one. - Order against scaffold-project.py: a leg path that is, holds or lies inside a path the shape writes into the assembly root, a
--pin'scontracts/<product>-pin.yamlincluded, is refused before anything is created, under--dry-runalike, through the one definition adopt-project.py asks, so--spec-path contractsno longer creates three repositories and pushes both legs to die atgit submodule add(#197) #234's leg-path refusal. For these inputsassembly_root_paths(neutral_pins)lists the pin's path twice, so scaffold-project.py: a leg path that is, holds or lies inside a path the shape writes into the assembly root, a--pin'scontracts/<product>-pin.yamlincluded, is refused before anything is created, under--dry-runalike, through the one definition adopt-project.py asks, so--spec-path contractsno longer creates three repositories and pushes both legs to die atgit submodule add(#197) #234's leg-path message miscounts (its re-review's N1: "contracts/spec-pin.yamland 4 more"); a pin refusal placed first removes the case.
adopt-project.py is not involved as far as this issue looked: its --pin is a name written into neutral_product_pins: and no file (re-review check 2). Not examined further here.
How to test. In tests/test_scaffold_leg_path_collisions.py if #234 has landed, otherwise a new file, with product_repo (tests/test_scaffold_pin_and_reuse.py:113), --local-remote-dir, --pin-source and a gh stub that logs: (a) for each of spec@, Spec@, code@, MedxSoft/code@, shape@ and the case pair, --dry-run exits 2 with a refusal that names the product and the colliding path, creates nothing (the remotes directory stays empty, no --work-dir), never asks gh, and the real run refuses with the same sentence and the same empty directory; (b) controls: --pin openGlass@$SHA alone, and two different products (openGlass, openPane), still exit 0 and the pushed root holds both pin files; (c) the refusal comes before _identity and before any gh api (no --pin-source, a stub that must not be called, as #234's ordering tests do); (d) the identical-product decision, whichever way it goes, gets one test; (e) removing the check makes (a) fail, so each case is asserted to reach the refusal and not an earlier or later one.
Related: #197 (PR #234; its re-review confirmed this and asked for it to be filed with or before the merge), #229 (PR #235, the other way the scaffold dies after both legs are pushed)
Activity
brettheap commentedon Oct 11, 2026
CLAIMED — lane openRepoShape-4, session 83338f75-6338-4d51-9749-89179ecaf55f@Eagle, 2026-10-11T21:21:11Z, for #241
Logged in
lanes/log/openRepoShape-4.mdatafe9c94d16dc74a48205b26aaa10cd3be91f7f50.The three reads (lane-collision-protocol Rule 1):
scaffold-project.py: the `--pin` collision refusal's remediation says…
Merge origin/main into fix/scaffold-pin-product-collisions: #271 (23e…
Merge origin/main (c3a9645: #215 via #240, #241 via #256) into fix/ad…
Merge origin/main b30595c into fix/adopt-root-kept-symlinks: #207, #272…
Merge origin/main (b30595c) into fix/adopt-late-failure-notice
Merge origin/main (be2790e) into fix/adopt-plan-quote-yaml-keywords
Merge origin/main (b421e6b) into fix/adopt-template-values-utf8: #231'…
Merge origin/main ede15ce into fix/adopt-relative-submodule-url: #215, …
AGENTS.md, README and scaffold-project.py --help: say that a --spec-p…
scaffold-project.py, validate-pins.py, repo_shape.py: `open-glass` an…
adopt-project.py: `plan --pin` takes `product@<40 hex>` and `execute`…