Skip to content

shape_materialize.py and the pinned AGENTS-shape.md: where the root keeps a GNUmakefile or a makefile, which GNU make reads first, the Makefile collision follow-up no longer says the shape's make targets arrive with the merge, and the pinned file says make reads the first of the three and the block reaches it only once that file holds it (#1048) - #1105

Draft
brettheap wants to merge 3 commits into
mainfrom
fix/1048-makefile-follow-up-gnu-read-order

Conversation

@brettheap

@brettheap brettheap commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Two sentences said a kept Makefile is what holds the shape's block back. GNU make reads the first of GNUmakefile, makefile and Makefile it finds, and only that one, so where the root also keeps GNUmakefile or makefile, make reads that file, and reaches the merged Makefile only where that file includes it. Closes #1048.

Measured (a real execute, then make -n in the clone, for each kept name; r1046's NIT-B, repeated; the include rows are RV1105's SHOULD-1):

the source keeps file make reads after cat shape/Makefile >> Makefile, make -n bootstrap
Makefile Makefile exit 0
Makefile + makefile makefile exit 2, "No rule to make target 'bootstrap'"
Makefile + GNUmakefile GNUmakefile exit 2, same
all three GNUmakefile exit 2, same
Makefile + a makefile or GNUmakefile that ends in include Makefile that file exit 0, runs python3 scripts/bootstrap.py (exit 2 before the merge)
makefile or GNUmakefile alone that file no merge to make: the shape's Makefile is the root's, and make contracts-dir has no rule until that file holds the block or includes Makefile

What changed

  • scripts/shape_materialize.py, collision_follow_up: takes reads_first, the file GNU make reads before the shape's Makefile. Where it is set, the Makefile's follow-up says GNU make reads that file, the first of the three it finds, and that the shape's make targets reach make there only once that file has them, or includes a Makefile that does; until then python3 scripts/bootstrap.py is what the shape's make bootstrap runs. Where scripts/bootstrap.py went aside too, it says neither command runs the shape's bootstrap until the script is merged, and then python3 scripts/bootstrap.py runs it. Where the root keeps only the source's Makefile, the sentence is what it was, byte for byte. GNU_MAKE_READS joins SHAPE_MAKE_TARGETS.
  • adopt-project.py, _predict_collisions: reads the file from the plan's root paths as _makefile_follow_up does, so the CONTRACTS_DIR line and the collision line of one plan name the same file.
  • templates/assembly-root/AGENTS-shape.md: says make contracts-dir prints the block wherever make reads the shape's Makefile, names the three files in the order the code reads them, and says that where a GNUmakefile or makefile is kept, beside a Makefile or instead of one, make reads it, and not the shape's Makefile unless that file includes it. It is one verbatim copy: the digest in every project's contracts/shape-pin.yaml is rendered from its bytes at adopt and scaffold time, and no digest or tree_sha256 of it is stored in this repository, so there is no pin to move here; each project re-pins through update-shape.py as for any template change.
  • tests/test_pinned_copy_text.py: ADOPTED_SENTENCES holds the new text.
  • tests/test_adopt_collision_make_reads.py (new, 31 rows): the follow-up per kept set, the two lines of one plan naming one file, no collision line where no Makefile is kept, the two name tuples equal, the pinned file naming every file the code reads first, the script-aside tail where make reads another file, and, in a real adoption's clone, make -n held to both sentences for each kept name and for a kept GNUmakefile or makefile that ends in include Makefile.

Fix round 1 (RV1105's review, b28f40de0 on top of 5a4de7f76)

  • SHOULD-1: the follow-up said the targets reach make "only once X holds them, or includes a Makefile that does, and not with that merge alone". The last clause is untrue where X already ends in include Makefile: the merge alone brings them. The clause is gone, and the docstring paragraph, the SHAPE_MAKE_TARGETS comment and the ADOPTED_SENTENCES comment now say the merge alone brings the targets only where that file includes Makefile.
  • NIT-1 (and RV1121's NIT-3): the follow-up now uses the words of adopt-project.py: execute's NEXT prints python3 scripts/bootstrap.py where GNU make at the root reads a kept GNUmakefile or makefile, and its NOTE names that file and what it takes for make bootstrap to run there (#1047) #1121's NOTE: "has them", and "is what the shape's make bootstrap runs". _next_steps is not touched.
  • NIT-2: the script-aside tail, where reads_first is set, said "neither make bootstrap nor python3 scripts/bootstrap.py runs the shape's bootstrap before both merges are made", which reads as if make bootstrap runs it after them. It now says neither runs it until the script is merged, and then python3 scripts/bootstrap.py runs it. I did not keep "before both merges": where X is given the targets itself, as the sentence before invites, make bootstrap runs the shape's bootstrap once the script is merged, with no Makefile merge at all.
  • NIT-3: the template's "and never the shape's Makefile" is now ", and not the shape's Makefile unless that file includes it". ADOPTED_SENTENCES changes with it. No other copy or regex in tests/ pins that sentence (git grep for "never the shape" and "reads the first of them").
  • NIT-4: the collision_follow_up docstring no longer says execute reports through it. It says plan predicts the follow-ups and stores them in follow_ups:, the split commit carries those, execute names its collisions by path only (its beside lines and the "WRITTEN BESIDE" list), and Materialized.follow_ups() has no caller. The method stays.
  • NITs 5 to 8 are left to the lane.

Rows of this round: tests/test_adopt_collision_make_reads.py and tests/test_pinned_copy_text.py, 118 rows. With this round's test files on 5a4de7f76's code, 17 fail and 101 pass; on b28f40de0 all 118 pass. The 17 fail for the right reason:

rows why they fail on 5a4de7f76
4 collision rows (Makefile+makefile, Makefile+GNUmakefile, all three, Makefile+GNUmakefile+scripts) the line holds "not with that merge alone"
2 new script-tail rows (GNUmakefile, makefile) the line holds "before both merges are made"
2 new include rows in a real clone (GNUmakefile, makefile) make -n bootstrap exits 2 before the merge and 0 after it alone (both asserted first, and passing), then the split message holds "not with that merge alone"
7 clone rows, the pinned-file row and test_a_scaffolded_root_is_told_its_makefile_exports_none the pinned file still says "and never the shape's Makefile"

Mutants of this round, each run against the 118 rows on b28f40de0, each file restored and its sha256 checked:

id mutant rows failing result
MA ", and not with that merge alone" put back 6 killed
MB the script-aside tail put back ("before both merges are made") 3 killed
MC the template's "and never the shape's Makefile" put back 9 killed
MD1 include rows: not in message inverted to in message 2 killed
MD2 include rows: after the merge, returncode == 0 inverted 2 killed
ME "holds them" put back 6 killed
MF "the shape's bootstrap target runs" put back 5 killed

Gate on b28f40de0:

files passed failed skipped
test_adopt_collision_make_reads, test_pinned_copy_text, test_adopt_make_reads_first, test_adopt_contracts_dir, test_adopt_printed_sentences, test_adopt_e2e, test_repo_hygiene, test_scaffold_leg_path_collisions 470 0 0
test_shape_doctor, 8 shards (62 + 56 + 54 + 56 + 60 + 57 + 58 + 45 collected) 445 0 3 (PowerShell rows, Windows host only)
total 915 0 3

Merge-tree: b28f40de0 against main 4ed7c7bf5 is clean, and so is it against each open PR that touches these files: #1104, #1106, #1118, #1119, #1120, #1121 (1c5173758), #1122, #1123 and #1125.

Round 0 (5a4de7f76, main 6da757cbe merged in): on main's code (plus the constant alone), 12 of the then 27 rows failed and 15 passed; the 30 test files that run the changed code or read the template gave 2109 passed, 0 failed and 4 skipped (3 PowerShell rows in test_shape_doctor.py, 1 MedxEHR row in test_adopt_plan.py that needs a clone SHAPE_MEDXEHR_CLONE names); nine mutants each failed 1 to 11 of the then 114 rows.

Not here

Lane 4 lands nothing outside v1.2.0 until after the v1.2.0 tag, on Brett's later word, so this is a draft.

🤖 Generated with Claude Code
Lane: openreposhape-4 (openRepoShape-4)

Summary by Sourcery

Clarify GNU make file precedence throughout adoption guidance so collision follow-ups and pinned documentation accurately describe when shape targets become available.

Bug Fixes:

  • Correct Makefile collision guidance to account for GNU make selecting the first available file among GNUmakefile, makefile, and Makefile.
  • Align adoption collision predictions and pinned agent guidance with the file GNU make actually reads.

Enhancements:

  • Clarify when shape make targets and bootstrap commands become available, including files that include Makefile.

Documentation:

  • Update the pinned AGENTS-shape.md guidance to document GNU make file precedence and inclusion behavior.

Tests:

  • Add coverage for collision messages, pinned text, real adoption clones, and Makefile inclusion scenarios.

…eeps a `GNUmakefile` or a `makefile`, which GNU make reads first, the Makefile collision follow-up no longer says the shape's make targets arrive with the merge, and the pinned file says `make` reads the first of the three and the block reaches it only once that file holds it (#1048)

r1046 measured it in a clone that keeps all three names: after `cat
shape/Makefile >> Makefile`, `make -n bootstrap` and `make contracts-dir` at
the root still exit 2, "No rule to make target", for GNU make reads
`GNUmakefile`, then `makefile`, then `Makefile`, and only that one.

- `collision_follow_up` takes `reads_first`, the file GNU make reads before the
  shape's `Makefile`, and `_predict_collisions` reads it from the plan's root
  paths as `_makefile_follow_up` does. Where it is set, the follow-up names the
  file and says the targets reach `make` only once it holds them or includes a
  Makefile that does. Where the root keeps only the source's `Makefile`, the
  sentence is what it was, byte for byte.
- `templates/assembly-root/AGENTS-shape.md` says `make contracts-dir` prints the
  block wherever `make` reads the shape's Makefile, names the three files in
  the order the code reads them, and says a kept `GNUmakefile` or `makefile`,
  beside a `Makefile` or instead of one, is the one `make` reads. The pin of
  every copy is rendered from these bytes at adopt and scaffold time, so no
  digest is stored in this repository; each project re-pins through
  `update-shape.py` as for any template change.
- `tests/test_pinned_copy_text.py` holds the new text, and
  `tests/test_adopt_collision_make_reads.py` holds both sentences to `make -n`
  in a real adoption's clone, for each kept name.

The `_next_steps` NOTE in adopt-project.py, the third box of #1048, is left to
#1047.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR fixes Makefile collision guidance by modeling GNU make’s GNUmakefile → makefile → Makefile precedence consistently in planning, follow-up text, and pinned documentation, with broad unit and end-to-end coverage for affected root layouts.

Sequence diagram for collision planning and Makefile follow-up

sequenceDiagram
    participant Plan as adopt-project.py plan
    participant Predict as _predict_collisions
    participant FollowUp as collision_follow_up
    participant Docs as AGENTS-shape.md
    Plan->>Predict: _predict_collisions(entries)
    Predict->>Predict: Select first kept name from GNU_MAKE_READS
    Predict->>FollowUp: collision_follow_up(reads_first)
    FollowUp-->>Plan: Follow-up names file GNU make reads
    FollowUp-->>Docs: Guidance matches precedence and merge behavior
Loading

Flow diagram for GNU make file precedence

flowchart TD
    Start["make without -f"] --> GNU{GNUmakefile exists?}
    GNU -->|Yes| ReadGNU["Read GNUmakefile"]
    GNU -->|No| Lower{makefile exists?}
    Lower -->|Yes| ReadLower["Read makefile"]
    Lower -->|No| ReadMake["Read Makefile"]
    ReadGNU --> Targets["Shape targets available only if this file holds them or includes a Makefile"]
    ReadLower --> Targets
    ReadMake --> Targets
Loading

File-Level Changes

Change Details Files
Aligned collision follow-up generation with GNU make’s file-selection order.
  • Added a shared GNU_MAKE_READS precedence tuple.
  • Detected the first kept root makefile in _predict_collisions and passed it into follow-up rendering.
  • Updated wording to explain that shape targets become visible only when the selected file contains or includes them, while preserving unchanged Makefile-only text.
scripts/shape_materialize.py
adopt-project.py
Updated the pinned adoption guidance to describe GNU make behavior accurately.
  • Documented that GNU make reads only the first existing file among GNUmakefile, makefile, and Makefile.
  • Explained the required merge/include behavior for roots retaining higher-precedence makefiles.
  • Kept the scaffolded-root guidance unchanged.
templates/assembly-root/AGENTS-shape.md
tests/test_pinned_copy_text.py
Added coverage for generated wording, shared precedence, pinned text, and real clone behavior.
  • Added parameterized collision tests across kept-file combinations, including root-versus-leg placement.
  • Verified the CONTRACTS_DIR and collision follow-ups identify the same file.
  • Ran adoption-clone checks using make -n, including files that contain or include the shape’s Makefile.
tests/test_adopt_collision_make_reads.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1048 Correct the Makefile collision follow-up in scripts/shape_materialize.py so it does not claim the shape's make targets arrive with the merge when GNU make reads a kept GNUmakefile or makefile first. ✅
#1048 Correct the pinned AGENTS-shape.md guidance to account for GNU make's file-selection order and explain when the shape's block becomes available. ✅
#1048 Correct the retained _next_steps NOTE in adopt-project.py that says targets arrive once the follow-up merges the files. ❌ The supplied diff does not change the _next_steps NOTE. The PR body says this is covered by #1047, but that change is not included in the PR changes provided for assessment.

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This was referenced Oct 21, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

RV1105: independent review of #1105 at 5a4de7f7: NOT READY

One SHOULD: the new collision follow-up adds a claim, "and not with that merge alone", that is untrue where the kept GNUmakefile or makefile already includes Makefile. I measured it in a real adoption. Main's CONTRACTS_DIR line and #1121's NOTE do not make this claim. Dropping those five words makes all three texts agree. Everything else checks out: the read order, the template, the pins, the 12/15 split on main, the merge-trees and the gate. The other findings are NITs.

What I measured

GNU make 4.3 (/usr/bin/make), in scratch folders. Each kept file had a who: rule that echoes its own name, and I ran make -s who:

the folder keeps make reads
GNUmakefile GNUmakefile
makefile makefile
Makefile Makefile
GNUmakefile + makefile GNUmakefile
GNUmakefile + Makefile GNUmakefile
makefile + Makefile makefile
all three GNUmakefile

"Only that one" also holds. With a bootstrap rule only in Makefile, and a GNUmakefile or makefile beside it that has none, make -n bootstrap exits 2 with "No rule".

The three texts, rendered for the same ten roots on a scratch merge of main 823624d9 + #1105 + #1121 (c04bb710). The roots were Makefile; Makefile+makefile; Makefile+GNUmakefile; all three; makefile; GNUmakefile; makefile+GNUmakefile; Makefile+GNUmakefile+scripts/; Makefile+scripts/; GNUmakefile+scripts/.

Pins.

  • No digest of AGENTS-shape.md is stored in this repository. I grepped the old and new sha256 and blob ids (f410515d, 941835a3) and found no match.
  • ADOPTED_SENTENCES matches the template.
  • The test_adopt_contracts_dir.py rows that import it pass on the merge.

Materialized.follow_ups() has no caller anywhere. git grep finds no follow_ups() call and no .follow_ups attribute read in scripts, tests or docs. execute uses materialized.collisions only for its beside lines and the split message's "WRITTEN BESIDE" list, and the split commit carries the plan's stored follow-ups. So the missing reads_first there cannot be reached (NIT-4).

Findings

SHOULD-1: "and not with that merge alone" is untrue where the file make reads already includes Makefile, or is a link to it.

A GNUmakefile that adds GNU-only rules and then has include Makefile is a common idiom. For such a root, _predict_collisions passes reads_first='GNUmakefile', and the follow-up says the targets reach make "only once GNUmakefile holds them, or includes a Makefile that does, and not with that merge alone". I measured this in a real plan + execute on the scratch merge, using an untracked probe built on adopt_with_legs_mounted_away and tree_keeping. The source kept Makefile plus a GNUmakefile (and, in a second row, a makefile) that ends in include Makefile.

step result
the split commit's message (the PR body) holds "..., and not with that merge alone; until then ..."
before the merge, make -n bootstrap exit 2, "No rule"
after the merge alone (shape/Makefile appended to Makefile), make -n bootstrap exit 0, python3 scripts/bootstrap.py
after the merge alone, make contracts-dir exit 0, prints <clone>/legs/spec/contracts

A GNUmakefile -> Makefile symlink gives the same result: in a scratch folder, make -n bootstrap exits 0 after the merge.

The clause before it, "only once X holds them, or includes a Makefile that does", is already true for every root, including these. The added clause is the only part that is false. Neither main's line nor #1121's NOTE has it.

The same over-claim appears in four other places:

  • the new docstring paragraph: "the merge makes no target arrive", and "a root whose only Makefile is the source's is the one where the merge is all it takes";
  • the SHAPE_MAKE_TARGETS comment: "not even then";
  • the ADOPTED_SENTENCES comment in tests/test_pinned_copy_text.py: "merging into Makefile brings nothing".

Fix:

  1. Drop , and not with that merge alone from arrive in collision_follow_up.
  2. Reword the four places above as "the merge alone brings them only where that file includes Makefile".
  3. Update reads_instead in the new test.
  4. Add a clone row whose kept GNUmakefile already ends in include Makefile. After the merge, make -n bootstrap exits 0 and the message holds no "not with that merge alone".

Mutant R11 below drops the clause and shows which rows pin it now.

NIT-1: wording drift among the three texts once #1121 lands.

All three name the same file, so nothing is wrong. Pick one verb and one phrase in whichever PR lands second.

NIT-2: the script-aside tail, where reads_first is set.

The tail says: "neither make bootstrap nor python3 scripts/bootstrap.py runs the shape's bootstrap before both merges are made". That is strictly true, but it reads as if make bootstrap works after both merges. Where make reads the kept file, it does not.

#1121's NOTE says "then run python3 scripts/bootstrap.py" there. Fix: in that branch, end with "..., and then python3 scripts/bootstrap.py runs it".

NIT-3: the template's "and never the shape's Makefile".

This is false where the source keeps a GNUmakefile or makefile, no Makefile of its own, and that file has -include Makefile. make then reads the shape's root Makefile through the include. I measured it in a scratch folder: make contracts-dir exits 0.

The case is rare. The rest of the sentence ("until that file holds the block, or includes a Makefile that does") is true for every root. Fix, if the template is touched again: "and not the shape's Makefile unless that file includes it".

NIT-4: dead code and a stale docstring.

  • Materialized.follow_ups() (scripts/shape_materialize.py:3348) has no caller. It is unreachable, so the missing reads_first is not a defect.
  • The collision_follow_up docstring still says "Both callers say it: ... and execute reports the ones that actually happened". execute never calls it.

Fix in a follow-up: delete the method and that sentence.

NIT-5: GNU_MAKE_READS is defined twice. adopt-project.py already imports from shape_materialize (line 245), so it can import GNU_MAKE_READS too. The second tuple, and the test that holds the two equal, would then go.

NIT-6: two template lines still say make bootstrap with no caveat. AGENTS-shape.md line 19 ("git clone --recurse-submodules this repository and run make bootstrap") and the five-commands table say it, which is untrue at the root of an adopted project whose make reads a kept GNUmakefile or makefile. It is also untrue before the Makefile merge. The PR body says this is out of scope, and no open issue tracks it. Each template byte change makes every project re-pin, so file it now. Bundling it with the next template change would save a pin round.

NIT-7: the order of landing against #1121. GitHub links only #1048 to this PR, but #1048's third box (the _next_steps NOTE) is done by #1121, which is linked only to #1047. If this lands first, #1048 shuts while main still prints that NOTE. Land #1121 first, or have #1121's body name #1048's third box.

NIT-8 (existing before this PR): stale follow-ups after a hand edit. contracts/path-classification.yaml (root-project-makefile) says moving a Makefile to the code leg "is a one-line edit of the plan". The collision follow-ups are written once, by plan: _predict_collisions has one caller, at adopt-project.py:6236. So a hand move of GNUmakefile leaves the new line, like #977's line, naming a file the root no longer keeps. This is not introduced here. It is for a follow-up.

Writer's claims

claim result
collision_follow_up(reads_first=...), passed by _predict_collisions confirmed by reading and by rendering
template names the three files in order; ADOPTED_SENTENCES updated confirmed
two GNU_MAKE_READS tuples, held equal by a test confirmed
27 rows: 12 fail and 15 pass on main's code plus the constant reproduced: on main 823624d9 plus only the constant, 12 failed and 15 passed
all 27 pass on the head 27 passed on main + PR
Materialized.follow_ups() is dead confirmed

The 12 fail for the right reason:

  • the old "arrive only with that merge" text (4 rows);
  • a missing clause in the collision line (4 rows);
  • the template's missing GNU make sentence (1 row);
  • the clone's split message still says "arrive only with that merge" (3 rows).

On main, the 3 clone rows still show make -n bootstrap exiting 2 after the merge, which is #1048's measurement.

Mutants

I ran my own on the new guard, on the scratch merge. Each mutant was run against test_adopt_collision_make_reads.py and test_pinned_copy_text.py (114 rows). After each one, the file was restored and its sha256 checked against the base.

id mutant rows failing (of 114) result
R1 _predict_collisions lookup drops name != SHAPE_MAKEFILE 4 (Makefile, Makefile+scripts, leg row, clone Makefile) killed
R2 lookup asks _root_keeps(..., fold=True) 4 (same rows) killed
R3 lookup reads every entry, not the root's 1 (test_a_name_in_a_leg_...) killed
R4 lookup takes the last kept name (reversed) 3 (all-three: collision, two lines, clone) killed
R5 materializer tuple reordered, adopt's kept 2 (tuples equal, pinned file) killed
R6 adopt's tuple reordered 4 (all-three x3, tuples equal) killed
R7 follow-up names GNU_MAKE_READS[0], not reads_first 3 (Makefile+makefile x3) killed
R8 the reads_first branch's "meanwhile" says make bootstrap runs it 3 (collision rows) killed
R9 the list of names joined with commas only, no "and" 4 (collision rows) killed
R10 lookup reads only GNUmakefile 3 (Makefile+makefile x3) killed
R11 the SHOULD-1 fix: drop "and not with that merge alone" 4 (collision rows; no clone row pins it) killed: these are the rows to update

Merge-tree

merge result
git merge-tree --write-tree origin/main(823624d9) 5a4de7f7 clean; the merged tree differs from main only in the PR's 5 files
PR head with #1121, #1106, #1104, #1120, #1122, each clean
main+#1105 with each of those five clean

#1104 is the only one of them that touches scripts/shape_materialize.py, and only env_commit's docstring.

Gate (scratch merge commit of main 823624d9 + 5a4de7f7)

Run with EMAIL set, an empty global git config, a stub gh first on PATH and GH_TOKEN=bogus:

files passed failed skipped
test_adopt_collision_make_reads.py (new) 27 0 0
test_pinned_copy_text.py, test_adopt_make_reads_first.py, test_adopt_contracts_dir.py, test_adopt_printed_sentences.py, test_repo_hygiene.py, test_scaffold_leg_path_collisions.py 412 0 0
test_adopt_e2e.py 27 0 0
test_shape_doctor.py, 4 shards (118 + 110 + 117 + 103 collected) 445 0 3 (PowerShell rows, Windows host only)
total 911 0 3

On a second scratch merge, main + #1105 + #1121, the new file, #1121's test_adopt_next_make_reads_first.py and test_pinned_copy_text.py gave 152 passed and 0 failed.

CI on 5a4de7f7 is green: tests, tests-git-bash, package and SonarCloud.

Lane: openreposhape-4 (openRepoShape-4)

…NU make reads first ends in `include Makefile`, the Makefile collision follow-up no longer says the merge alone does not bring the shape's make targets, its script-aside tail names only what the script's merge lets run, and the pinned file says `make` reads the shape's Makefile there only where that file includes it (#1048)

#1105's review (RV1105) measured it in a real adoption: where the kept
`GNUmakefile` or `makefile` ends in `include Makefile`, `make -n bootstrap`
at the root exits 2 before the merge and 0 after `cat shape/Makefile >>
Makefile` alone, so "and not with that merge alone" was untrue there.

- `collision_follow_up`, where `reads_first` is set: says the targets reach
  `make` only once that file "has them, or includes a Makefile that does",
  and no more (SHOULD-1). It uses the words of `execute`'s NEXT (#1047):
  "has them", and "what the shape's `make bootstrap` runs" (NIT-1). Where
  `scripts/bootstrap.py` went aside too, it says neither command runs the
  shape's bootstrap until the script is merged, and then `python3
  scripts/bootstrap.py` runs it, and no longer reads as if `make bootstrap`
  runs it after both merges (NIT-2). Where the root keeps only the source's
  `Makefile`, the sentence is what it was, byte for byte.
- Its docstring, the `SHAPE_MAKE_TARGETS` comment and the `ADOPTED_SENTENCES`
  comment say the merge alone brings the targets only where that file
  includes `Makefile`. The docstring's opening says what is so: `plan`
  predicts the follow-ups, the split commit carries the plan's, `execute`
  names its collisions by path only, and `Materialized.follow_ups()` has no
  caller (NIT-4).
- `templates/assembly-root/AGENTS-shape.md`: `make` at the root reads the
  first of the kept files, "and not the shape's Makefile unless that file
  includes it" (NIT-3). `ADOPTED_SENTENCES` holds the new text. No digest of
  the file is stored in this repository.
- `tests/test_adopt_collision_make_reads.py`: the rows hold the new text, two
  rows hold the script-aside tail where `make` reads another file, and two
  rows in a real adoption's clone, for a `GNUmakefile` and a `makefile` that
  end in `include Makefile`, hold that the merge alone brings the shape's
  recipe and that the split message says nothing untrue of it.

Lane: openreposhape-4 (openRepoShape-4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

RV1105b: independent review of #1105 at b28f40de: READY

I re-checked the fix round (b28f40de0, on top of 5a4de7f76) against RV1105's review, and took over from RV1105. SHOULD-1 is fixed: the clause is gone. I measured 174 GNU make states, and every sentence the follow-up and the template now print is true in each of them that a real source can reach. NITs 1 to 4 are done as W1048b says. Its reason for not using "before both merges are made" holds, and I measured it. I found no MUST and no SHOULD. The findings below are NITs: four are about comments and docstrings, and one is in the branch this round kept byte for byte, which is older than this PR.

What I measured

GNU make 4.3 (/usr/bin/make), in scratch folders: 174 states. For each folder:

  • the root keeps the source's Makefile or not ("beside" or "instead of");
  • the kept GNUmakefile or makefile is plain, ends in include Makefile, -include Makefile or sinclude Makefile, starts with include Makefile, or is a symlink to Makefile;
  • scripts/bootstrap.py went aside or not, and is merged or not;
  • the route is no edit, the Makefile merge alone (cat shape/Makefile >> Makefile), or X given the targets directly (shape's text appended to X, no Makefile merge).

The shape's copy's bootstrap runs $(PYTHON) scripts/bootstrap.py, the source's script prints SOURCE-BOOT, and the shape's prints SHAPE-BOOT. In each state I ran make -s bootstrap, python3 scripts/bootstrap.py and make -s contracts-dir.

claim measured
reads_first set: the targets reach make "only once X has them, or includes a Makefile that does" true. In the 34 beside states with no script aside, make bootstrap runs the shape's recipe only where X has them (given, or through a link) or includes a Makefile that has them. A plain X after the merge alone exits 2, No rule
the merge alone brings them where X includes Makefile (SHOULD-1's case) include, -include, sinclude and include-at-top all exit 0 after the merge alone, so the dropped clause was untrue in all four, and nothing printed says it now
script-aside tail: "neither make bootstrap nor python3 scripts/bootstrap.py runs the shape's bootstrap until then, and then python3 scripts/bootstrap.py runs it" true on every route. In 34 states with the script unmerged, neither command prints SHAPE-BOOT. In 34 with it merged, python3 scripts/bootstrap.py always does
W1048b's reason for not using "before both merges are made" there holds. With X given the targets, the script merged and no Makefile merge, make bootstrap prints SHAPE-BOOT (GNUmakefile and makefile alike). With the script merged and nothing else, python3 scripts/bootstrap.py does too
template: make reads the first kept file, "and not the shape's Makefile unless that file includes it", and make contracts-dir has no rule until that file holds the block or includes a Makefile that does true beside a Makefile and instead of one, for every include form. The one exception is a kept GNUmakefile or makefile that is a symlink to Makefile where the source has no Makefile of its own. There make reads the shape's root Makefile through the link, and it includes nothing. That source holds a broken link, so this is not a real case

The three texts on a scratch merge of main 4ed7c7bf5 + #1105 + #1121 (9f8d9060f). I rendered them for 11 roots: Makefile; +makefile; +GNUmakefile; all three; makefile; GNUmakefile; makefile+GNUmakefile; Makefile+GNUmakefile+scripts/; Makefile+makefile+scripts/; Makefile+scripts/; GNUmakefile+scripts/.

W1048b's claims

claim result
SHOULD-1: the clause is gone, and only the reads_first branch changed confirmed. I called collision_follow_up on 5a4de7f76 and on main+PR for every assembly_root_paths() path, with and without the script aside and attributes_link, and with reads_first None, GNUmakefile or makefile: 202 calls give the same bytes and 8 differ. All 8 are the Makefile line with reads_first set, so the branch with no reads_first is byte for byte as before. git grep finds "not with that merge alone" only in the test's MERGE_ALONE_FAILS
NIT-1: #1121's words confirmed by rendering (above)
NIT-2: the new tail true on every route, and the reason holds (above)
NIT-3: the template clause and ADOPTED_SENTENCES; no other pin confirmed. "unless that file includes it" is only in the template and ADOPTED_SENTENCES, and git grep finds no "never the shape's Makefile" left
NIT-4: the docstring confirmed. plan writes follow_ups:, and execute puts _plan_follow_ups(plan) in the split message. No script calls Materialized.follow_ups(); the only hit is its definition at shape_materialize.py:3361
31 rows in the test file; 118 with test_pinned_copy_text.py confirmed by --collect-only: 31 + 87
17 of 118 fail on 5a4de7f76, and all 118 pass on the head reproduced: with this round's two test files on 5a4de7f76's code, 17 failed and 101 passed. On main+PR, 118 passed
merge-trees clean confirmed (below)

The 17 fail for the right reason:

  • the 2 script-tail rows fail at "before both merges are made" not in line (line 166);
  • the 4 collision rows fail at MERGE_ALONE_FAILS not in line (line 183);
  • the 2 new include rows fail at line 395, MERGE_ALONE_FAILS not in message. This comes after their make asserts passed: before the merge, make -n bootstrap was not 0; after the merge alone it was 0 and ran python3 scripts/bootstrap.py; and make contracts-dir printed the spec leg's contracts/;
  • the other 9 fail at ADOPTED_SENTENCES in ..., because the template still said "never".

#1111's rule. #1105 adds no is_file, is_dir, exists or os.path guard: the diff of main..main+PR in adopt-project.py and scripts/shape_materialize.py has no such added line. I ran tests/test_path_guard_hygiene.py anyway on main + #1105 + #1111 (b4484647): 48 passed and 1 failed, the adopt-project.py row. It fails the same way on main + #1111 without this PR. Both runs name the same three sites, _PlannedRoot._no_directory, _PlannedRoot._into_git and _clone_ref_store, and none of them is in this PR. That is #1111 against current main, and lane 1's to settle.

Mutants

I wrote my own, on the changed sentences, on the scratch merge edb1cdb1d (main + PR). Each one ran against the 118 rows. After each, the tree was restored with git checkout -- ., and the sha256 of all four mutated files matched the base.

id mutant rows failing (of 118) result
K1 ", and not with the merge alone" put back, in a new spelling 6 (4 collision, 2 include) killed
K2 the reads_first tail back to "before both merges are made" 3 killed
K3 the tail says "and then make bootstrap runs it" 3 killed
K4 main's verb: "X does so" in place of "has them" 6 killed
K5 the meanwhile clause loses "the shape's" 5 killed
K6 ", or includes a Makefile that does" dropped from the reads_first clause 6 killed
K7 the branch with no reads_first takes the new tail 1 (Makefile+scripts) killed
K8 template: "unless that file names it" 9 killed
K9 template AND ADOPTED_SENTENCES put back to "and never the shape's Makefile", together 0 survived, as expected (NIT-4 below)
K10 _predict_collisions passes reads_first=None 15 killed
K11 the tail's joint "; and" becomes ", and" 3 killed

Merge-tree

merge result
git merge-tree --write-tree of b28f40de0 and main 4ed7c7bf5 clean; the merged tree differs from main only in the PR's 5 files
b28f40de0 with #1104, #1106, #1118, #1119, #1120, #1121 (1c5173758), #1122, #1123, #1125, each clean
main+#1105 with each of those nine, and with #1111 clean, except #1118

#1118 conflicts in tests/test_adopt_root_shape_paths.py, a file this PR does not touch. It conflicts the same way with main alone (git merge-tree origin/main #1118), so that is #1118 being behind main.

Gate (scratch merge commit edb1cdb1d = main 4ed7c7bf5 + b28f40de0)

Run with EMAIL set, an empty global git config, GIT_CONFIG_NOSYSTEM=1, a stub gh first on PATH, GH_TOKEN=bogus and an empty GH_CONFIG_DIR:

files passed failed skipped
test_adopt_collision_make_reads.py, test_pinned_copy_text.py, test_adopt_make_reads_first.py, test_adopt_contracts_dir.py, test_adopt_printed_sentences.py, test_repo_hygiene.py, test_scaffold_leg_path_collisions.py 443 0 0
test_adopt_e2e.py (pins the branch with no reads_first) 27 0 0
test_shape_doctor.py, 4 shards by node id (118 + 110 + 117 + 103 collected) 445 0 3 (PowerShell rows, Windows host only)
total 915 0 3

On the triple merge 9f8d9060f (main + #1105 + #1121), the new file, #1121's test_adopt_next_make_reads_first.py and test_pinned_copy_text.py gave 158 passed and 0 failed.

Findings

NIT-1: the comments still say the merge alone brings the targets "only where that file includes Makefile". That is untrue where the file is a symlink to Makefile.

  • RV1105's SHOULD-1 named this case: "or is a link to it".
  • I measured it. With Makefile + GNUmakefile -> Makefile (and the same with makefile), make bootstrap exits 2 before the merge. After the merge alone it runs the shape's recipe, and make contracts-dir prints the path. The file includes nothing.
  • The printed follow-up is true there: through the link, X "has them".
  • The places are:
    • the SHAPE_MAKE_TARGETS comment;
    • the collision_follow_up docstring ("exits 2 with the same No rule where it does not");
    • the ADOPTED_SENTENCES comment;
    • the test module's docstring.

Fix, in a follow-up: "only where that file includes Makefile or is a link to it".

NIT-2 (older than this PR): the branch with no reads_first still says "neither make bootstrap nor python3 scripts/bootstrap.py runs the shape's bootstrap before both merges are made". That is untrue of python3 scripts/bootstrap.py.

Fix, in a follow-up: "... so python3 scripts/bootstrap.py runs the shape's bootstrap once the script is merged, and make bootstrap once both merges are made". Move the pin with it.

NIT-3: two docstrings are left with claims that are not true on main.

Fix: reword the _predict_collisions sentence as NIT-4 did, and land #1121 first.

NIT-4: no row ties the template's new clause to what make does. Mutant K9 puts both the template and ADOPTED_SENTENCES back to "and never the shape's Makefile" together, and all 118 rows pass. The clone rows already show make reading the shape's Makefile through include Makefile where the source keeps makefile or GNUmakefile alone. Fix, optional: in that branch, also assert that the pinned file does not say "never the shape's Makefile".

NIT-5 (cosmetic):

  • The template paragraph was not rewrapped: it leaves a short line, "does. A SCAFFOLDED".
  • The new test's docstring breaks mid-sentence: "The tail says what the / script's merge does".

Every byte change to the template makes each project re-pin, so leave the template one for the next template change.

Lane: openreposhape-4 (openRepoShape-4)

This was referenced Oct 22, 2026
@brettheap brettheap added this to the v1.2.1 milestone Oct 23, 2026
brettheap added a commit that referenced this pull request Oct 23, 2026
… leaves out a pattern like `refs/heads*` that puts a branch under another name, its clauses move into `_left_out`, and the docstrings name the clashes left and the way back (#1100) (#1157)

#1100 holds five follow-ups from RV1087's review of #1087 (issuecomment-6314349196). This PR does all five:

1. Take the clause-4 fix for a pattern such as `refs/heads*`, and name the clash that is left, `+refs/pull/7/head:refs/remotes/origin/main`, in the three places that list clashes.
2. Add the `WHICH_CLAUSE` row that kills R16.
3. In `bump-leg.py`, name the unforced exception and the way back, `git fetch --force origin`.
4. Write down the accepted cost where the cost is described.
5. Move `origin_fetch_refspecs`' four clauses into a helper, `_left_out`.

Fix round 1 (`baa19972a`) answers the independent review rv1157/rv1157b (issuecomment-6347113460: NOT READY, 2 SHOULD, 4 NIT, no BLOCKER). It does all six. See "Fix round 1" below.

The NIT round (`368b87e9b`) answers rc1157's re-check of fix round 1 (issuecomment-6352215076: READY, 3 NITs, all in this body). It adds one test row, which kills F2, the survivor of fix round 1, and corrects three statements in this body: the reason F2 was left, the longest line's length and the #1131 merge-tree line. The scripts are unchanged. See "NIT round, after the re-check of fix round 1" below.

Measured by a probe on git 2.43 and git 2.47.1, with local bare remotes only. The two gits gave identical tables. For each case the probe ran:
- the repository's own `git fetch origin`;
- the tools' fetch, `git fetch -q --prune --no-prune-tags --refmap= <origin|path> <refspecs>`, with the refspecs from main's `origin_fetch_refspecs`, once through `origin` and once through a path;
- the repository's own fetch again ("own-after").

Where own-after failed, it then ran `git fetch --force origin`, a plain fetch, and the tools' fetch followed by the repository's own fetch ("again"). Branches are `main`, `x` and `release/x` at three unrelated commits unless the case says otherwise.

```
case                                                    own  main o/p  own-after  force, plain, again
C25  +refs/heads*:refs/remotes/origin/h*, branch h/x    0    128/128   0/0        -
C18b +refs/pull/7/head:refs/remotes/origin/main alone   0    128/128   0/0        -
C02  release/* onto origin/*, unforced                  0    0/0       1/1        0 (ref back), 0, 1
C02c same, x descends from release/x                    0    0/0       1/1        0 (ref back), 0, 1
C05u release/x onto origin/x, unforced                  0    0/0       1/1        0 (ref back), 0, 1
C20u tags onto origin/*, unforced, branch and tag x     0    0/0       1/1        0 (ref back), 0, 1
C32  heads/* onto origin/*-a, branches q and q-a        0    0/0       0/0        -
C33  feat-* onto origin/feat-*-old, feat-1, feat-1-old  0    0/0       0/0        -
```

These match the issue's numbers. C32 and C33 exit 0 because #1087 leaves them out. The own-after failure is `! [rejected] release/x -> origin/x (non-fast-forward)`. C02b, where `release/x` descends from `x`, gives 0. So the condition is that the kept commit does not descend from the branch's, not that the two commits are unrelated. The old paragraph's words "not in line" were wrong for C02c, and the new text corrects them.

Main has since moved to `961640c6` (#1155, which touched only `docs/release-notes/v1.2.0.md`). The four files this PR changes are byte-identical there, so nothing above changes.

**`scripts/shape_materialize.py`**

- **Clause 4** (`gives_a_branch_another_name`) now reads a source that is not under `refs/heads/` as naming branches when it is a pattern whose part before the `*` is a start of `refs/heads/`. This is how `can_name_a_tag` reads `refs/*` for tags:

  ```
  fixed, star, _ = source.partition("*")
  if not source.startswith(HEADS_PREFIX):
      return bool(star) and HEADS_PREFIX.startswith(fixed)
  ```

  Such a pattern's `*` also takes what the pattern leaves of `refs/heads/`. So no destination under `refs/remotes/origin/` gives a branch its own name, and every such refspec is left out. The only pattern that keeps the names, `refs/heads*:refs/remotes/origin*`, writes outside `origin/`, and clause 1 leaves it out first. The shorter starts (`refs/`, `*`) can name a tag, and clause 2 leaves them out first.
- **The fix was taken only after measuring it.** A pure-function differential of main against the branch ran over 39,385 configs: 43 sources x 31 destinations, forced and unforced, negatives, pairs, doubles, and each one beside the first refspec.
  - The branch carries nothing that main does not.
  - It newly leaves out 207 distinct refspecs. Every one has a pattern source whose part before the `*` starts `refs/heads/` but neither `refs/heads/` nor `refs/tags/` (`refs/h*` to `refs/heads*`).
  - The helper alone (main plus `_left_out`) gives main's output on all 39,385 configs.
  - The fix alone and the fix plus the helper give the same output as each other on all 39,385.
- **The probe of all 53 cases**, on both gits, changed no outcome outside that class: exits, own-after, force, plain and again are identical for the 46 cases outside the class, C03u, C17u and C32o included. The seven cases in the class changed:

  ```
  case                                                      own  main o/p  branch o/p  own-after (branch)
  C25   refs/heads* onto origin/h*, branch h/x              0    128/128   0/0         0/0
  C25u  same, unforced                                      0    128/128   0/0         1/1; --force 0, plain 0
  C25n  refs/heads* onto origin/h*, no clash                0    0/0       0/0         0/0 (pruned, written back)
  C25g  default glob + refs/heads* onto origin/h*           0    0/0       0/0         0/0 (pruned, written back)
  C25b  default glob + refs/he* onto origin/he*, heads/x    128  128/128   0/0         128/128
  C25c  refs/he* onto origin/he*, branch heads/x            0    128/128   0/0         0/0
  C25h  glob + refs/h* onto origin/hh/*, ref refs/hidden/1  0    0/0       0/0         0/0 (pruned, written back)
  ```

  Wherever a tools' fetch exited 0, every remote branch's `refs/remotes/origin/<name>` held that branch's commit (all cases, both rules, both ways, both gits). A fetch that exited 128 changed no ref.
- **`_left_out(source, destination)`** holds the four clauses, in their order and with their comments. `origin_fetch_refspecs` now asks it once. The SonarQube analyzer (`python:S3776`, `analyze_code_snippet`) raises nothing on an excerpt of the four functions as committed, with their docstrings dropped. With four more `if ...: continue` in the loop it reports "from 17 to the 15 allowed", so `origin_fetch_refspecs` measures 9 (it was 15). By hand, `_left_out` is 3 and `gives_a_branch_another_name` is 2.
- **The docstrings:**
  - `gives_a_branch_another_name` no longer ends "A source not under `refs/heads/` names no branch". It names the pattern and points to WHAT CAN STILL CLASH.
  - The A BRANCH UNDER ANOTHER NAME paragraph names the pattern.
  - A new THE COST, ACCEPTED paragraph says that, after either tool runs, an unforced refspec's own fetch exits 1 on every run until `git fetch --force origin`. Where the refspec was carried, the tools' fetch exited 128 in those same states and left the ref alone. No commit is lost, and main already behaves this way for unforced tags onto the glob.
  - WHAT CAN STILL CLASH now names any carried refspec whose source names no branch, onto a name a branch has: the pull requests in `origin/pr/` and C18b's single name.
  - Fix round 1 changes three of these paragraphs and adds one (below).

**`scripts/bump-leg.py`**, docstrings only. An AST comparison with the docstrings blanked shows the code is identical to main's, at both heads.
- The #1066 paragraph of `_leg_fetch_refspecs` names the `refs/heads*` pattern. It adds NOT WHERE THE LEG'S REFSPEC WAS WRITTEN WITHOUT ITS `+`, with the way back, `git fetch --force origin`.
- "Two ways remain" now names the second way as a source that names no branch, onto a name a branch has, in a folder or onto a single name (C18b).

**`tests/test_family_bump_fetch_refspecs.py`**
- New `WHICH_CLAUSE` rows (both tools' tests ask them):
  - `another-name-suffix`, `+refs/heads/*:refs/remotes/origin/*-old`, left out (item 2);
  - `another-name-heads-pattern`, `+refs/heads*:refs/remotes/origin/h*`, left out;
  - `another-name-heads-pattern-shorter`, `refs/he*:refs/remotes/origin/he*`, left out;
  - `carried-not-refs-heads`, `+refs/headsx/*:refs/remotes/origin/headsx/*`, carried;
  - fix round 1: `another-name-heads-pattern-refs-h` and `another-name-heads-pattern-suffix` (below).
- New `BRANCH_UNDER_ANOTHER_NAME` row `heads-pattern-into-a-folder`, `+refs/heads*:refs/remotes/origin/h*`, with `x-w1066` and `h/x-w1066`. It runs end to end through `family.py bump` and through `bump-leg.py` via `origin` and via `--local-remote-dir`, with the clash premise first.
- Fix round 1: `NO_BRANCH_A_PATTERN_TAKES` and its end-to-end rows (below).
- NIT round: `carried-refs-h-not-a-pattern`, `+refs/h:refs/remotes/origin/hy`, carried (below).

**`tests/test_bump_leg_fetch_refspecs.py`**: one docstring sentence, and in fix round 1 the leg's `NO_BRANCH_A_PATTERN_TAKES` rows.

`scripts/family.py` is unchanged. Its `_origin_fetch_refspecs` paragraph "A mount whose refspec git cannot satisfy" gives examples and claims no complete list, and it stays true.

One new commit, `baa19972a`, on `a35a97ea5`. No merge of main was needed: main's one new commit touches none of these files. The scripts' code is unchanged: `astsame.py` (AST with docstrings blanked) gives SAME CODE for `shape_materialize.py` against `a35a97ea5` and for `bump-leg.py` against main.

**SHOULD 1 (finding 1): two mutants of the fix survived the rows.** Two `WHICH_CLAUSE` rows are added. Both tools' tests ask them (`test_family_bump_fetch_refspecs.py` holds the table, and `test_bump_leg_fetch_refspecs.py` imports it):
- `another-name-heads-pattern-refs-h`, `+refs/h*:refs/remotes/origin/hh/*`, left out. This is the class's lower edge, the shortest start of `refs/heads/` that no earlier clause meets.
- `another-name-heads-pattern-suffix`, `+refs/heads*x:refs/remotes/origin/h*x`, left out. Git accepts it and maps branch `x` onto `origin/h/x` and `ax` onto `origin/h/ax` (probe section B, both gits).

On the pure function (`tools/rows.py`, `logs/rows.log`):
```
row                                     main     head      B1        B9        F2
+refs/h*:refs/remotes/origin/hh/*       carried  left out  carried   left out  left out
+refs/heads*x:refs/remotes/origin/h*x   carried  left out  left out  carried   left out
```
So each new row fails on main. B1 is killed by `-refs-h` and B9 by `-suffix`. F2 was the one survivor of fix round 1. The NIT round's row kills it (see "NIT round" and Mutants).

**SHOULD 2 (finding 2): leaving the class out changes the answer from pin to refuse** for a commit that only a non-branch ref reaches, where the pattern takes that ref too. The three places that said nothing is lost now say so:
- `shape_materialize.py`, `origin_fetch_refspecs`: LEFT OUT BY EITHER OF THE LAST TWO, NO BRANCH IS LOST TO THE ANSWER, followed by a new paragraph, BUT A COMMIT ONLY A REF THAT IS NO BRANCH REACHES IS REFUSED. It covers the pull requests onto the branches' names (left out since #946, refused on main too) and, since #1100, a ref of another namespace that a pattern from a start of `refs/heads/` takes. It says that every run of either tool refuses such a commit alike. It also says that a refspec whose source is that namespace itself is carried, and the commit is pinned through it. This is finding 2(b): one remote ref, two answers by refspec.
- `shape_materialize.py`, `_left_out`'s comment: a ref that is no branch, which a pattern takes too, no longer answers for one, so a commit only it reaches is refused, as one only a tag reaches is.
- `bump-leg.py`, the #1066 paragraph: "so no branch is lost to the answer", and a new paragraph, A COMMIT ONLY A REF OF ANOTHER NAMESPACE THE PATTERN TAKES REACHES IS REFUSED NOW (#1100), with the review's `heads-x-in` case and its `heads-x-out` control.

New end-to-end rows pin the refusal, `NO_BRANCH_A_PATTERN_TAKES` (`+refs/heads*:refs/remotes/origin/h*` with `refs/heads-archive/old-w1100`, and `+refs/h*:refs/remotes/origin/hh/*` with `refs/hidden/old-w1100`):
- `test_a_ref_a_heads_pattern_takes_answers_for_no_branch` runs `family.py bump --dry-run` and `bump`. Both refuse `member-to-local-only` in the plain words, with nothing in the holder moved and no ref of the mount moved.
- `test_a_ref_a_heads_pattern_takes_answers_for_no_branch_of_the_leg` runs `bump-leg.py` via `origin` and via `--local-remote-dir`. Both runs refuse `bump-leg-commit-local-only` in the plain words, with nothing in the root moved and no ref of the leg moved.
- Each first asserts its premises: the ref reaches the commit, no branch of the remote does, and no remote-tracking ref does. All 6 rows fail on main, where both runs pin (Rows failing on main).

The docstrings' claims, measured by `tools/claims.py` (the tools' own code from an exported tree: `fetch_leg` end to end, then `family.py`'s reader and `_on_a_remote_branch`). It ran on main `961640c6` and on `baa19972a`, on git 2.43 and 2.47.1. The two gits gave identical output on each tree (`logs/claims-{main,head}-{243,247}.log`):
```
case              refspec beside the glob                                ref holding C           main    head
C25h-in           +refs/h*:refs/remotes/origin/hh/*                      refs/hidden/1           PIN     REFUSE
C25h-unforced     refs/h*:refs/remotes/origin/hh/*                       refs/hidden/1           PIN     REFUSE
heads-x-in        +refs/heads*:refs/remotes/origin/h*                    refs/heads-archive/old  PIN     REFUSE
heads-x-unforced  refs/heads*:refs/remotes/origin/h*                     refs/heads-archive/old  PIN     REFUSE
hidden-out        +refs/hidden/*:refs/remotes/origin/hidden/*            refs/hidden/1           PIN     PIN
heads-x-out       +refs/heads-archive/*:refs/remotes/origin/h-archive/*  refs/heads-archive/old  PIN     PIN
pulls-out         +refs/pull/*/head:refs/remotes/origin/pr/*             refs/pull/7/head        PIN     PIN
pulls-onto-glob   +refs/pull/*/head:refs/remotes/origin/*                refs/pull/7/head        REFUSE  REFUSE
```
- `family.py`'s answer equals `bump-leg.py`'s in every row, and so do the two readers' lists.
- On the head, the tools' fetch exits 0 and prunes the class ref (`origin/hh/idden/1`, `origin/h-archive/old`). The repository's next own `git fetch origin` exits 0 and writes it back.

**NIT 1 (finding 3), `bump-leg.py`'s tag paragraph.** It now says that such a ref is pruned or holds the branch of that name, and that the leg's own next fetch writes it back. It adds NOT WHERE THE LEG WROTE THAT REFSPEC WITHOUT ITS `+` and the tag's commit does not descend from the branch's: then the leg's own fetch exits 1 on that run and every one after it, until `git fetch --force origin`. Measured (probe section C, both gits, main and head alike):
```
case       refspec                              own  tool  own-after  --force (ref back)  plain
C20        +refs/tags/*:refs/remotes/origin/*   0    0     0,0        -                   -     (origin/x back at the tag)
C20u       refs/tags/*:refs/remotes/origin/*    0    0     1,1        0 (back)            0     ! [rejected] x -> origin/x (non-fast-forward)
C20u-desc  same, the tag's commit a child of x  0    0     0,0        -                   -
```

**NIT 2 (finding 4).** The 109-character line is reflowed. The longest line this PR adds, over `961640c6..368b87e` in the four files, is 78 characters (`test_bump_leg_fetch_refspecs.py`'s `assert contains(bare_of(remotes), made, ref), f"premise: {ref} reaches it"`). In the scripts the longest added lines are 75 (`bump-leg.py`) and 76 (`shape_materialize.py`). `bump-leg.py`'s longest line, 81, is main's. This body said 79 until the re-check measured 78.

**NIT 3 (finding 5), the overclaim in `gives_a_branch_another_name`.** It no longer says each such refspec puts a branch under another name. Where the part before the `*` is all of `refs/heads` and the destination's `*` comes right after a `/`, git refuses the name it gives each branch, and the fetch writes no branch. The docstring now says this, and says that the pattern still takes the refs of another namespace, pointing to the new paragraph. Probe section B, both gits: `+refs/heads*:refs/remotes/origin/h/*` with branches `main`, `x` and `ax` exits 0, says `error: * Ignoring funny ref 'refs/remotes/origin/h//x' locally` (and the same for `h//main` and `h//ax`), and writes only `origin/h/foo/x` for `refs/headsfoo/x`.

**NIT 4 (finding 6), the stale merge-tree list.** Merge-trees were re-run at `baa19972a` against main and every open PR touching these four files (Merge-trees).

One new commit, `368b87e9b`, on `baa19972a`, in `tests/test_family_bump_fetch_refspecs.py` only: one `WHICH_CLAUSE` row, a sentence in the table's comment and one in its test's docstring. The scripts are byte for byte `baa19972a`'s (`git diff --name-only baa1997 368b87e` names that one file). The row is `pytest.param("+refs/h:refs/remotes/origin/hy", True, id="carried-refs-h-not-a-pattern")`. `WHICH_CLAUSE` is one table, which `tests/test_bump_leg_fetch_refspecs.py` imports and asks of `_leg_fetch_refspecs`, so the one row is two test ids, one in each file, and both tools' tests ask it.

**NIT 1 (finding 1): the reason given for leaving F2 was wrong, and the row it said could not be added can be.** The first body said F2 differs from this head only on a source that is no pattern and is a start of `refs/heads/` (`refs/`, `refs/h`, ... `refs/heads`), that no such full name can be a ref beside the remote's branches, and that a row for it would pin an arbitrary answer. Measured by `tools/claims.py` (the tools' own code from an exported tree: `fetch_leg` end to end, then `family.py`'s reader and `_on_a_remote_branch`), on main `961640c6`, on this head and on this head with F2 applied, on git 2.43 and 2.47.1 (`logs/claims-{main,head,f2}-{243,247}.log`; the two gits gave identical output on each tree):

```
case                   refspec beside the glob                     ref holding C   main    head    F2
refs-h-no-pattern      +refs/h:refs/remotes/origin/hy              refs/h          PIN     PIN     REFUSE
refs-head-no-pattern   +refs/head:refs/remotes/origin/hz           refs/head       PIN     PIN     REFUSE
refs-heads-no-pattern  +refs/heads:refs/remotes/origin/hx          refs/heads      the remote cannot hold refs/heads and refs/heads/main
hidden-out             +refs/hidden/*:refs/remotes/origin/hidden/* refs/hidden/1   PIN     PIN     PIN
C25h-in                +refs/h*:refs/remotes/origin/hh/*           refs/hidden/1   PIN     REFUSE  REFUSE
```

- Only `refs/heads` itself cannot be such a ref: `git update-ref` refuses it with "cannot process 'refs/heads/main' and 'refs/heads' at the same time" (exit 128, no ref made). `refs/h` and `refs/head` are made beside `refs/heads/main` with exit 0.
- With `+refs/h:refs/remotes/origin/hy` and a commit C that only `refs/h` reaches, main's code and this head's carry the refspec and PIN C. Under F2 both tools leave it out and REFUSE C (`bump-leg-commit-local-only`, `family_answer=False`): the tools' fetch prunes `origin/hy`, which the repository's own fetch had written, and the next own fetch writes it back.
- So this head's answer is not arbitrary. It is the rule ANY OTHER SOURCE NAMES NO BRANCH, the same as `hidden-out`'s: a source that is no pattern takes the ref of its name. Where the remote has no ref of that name, git completes the name instead: with a branch named `refs/h` (the ref `refs/heads/refs/h`) beside `hy`, the tools' fetch line exits 128 with `Cannot fetch both refs/heads/hy and refs/heads/refs/h to refs/remotes/origin/hy`, on git 2.43.0 and 2.47.1, on main as here. That is the WHAT CAN STILL CLASH shape (lane 5's re-check, issuecomment-6371455922, NIT 1).
- On the pure function (`tools/rows.py`, `logs/rows.log`, `origin_fetch_refspecs` alone):

```
row                                       main      head      B1        B9        F2
+refs/h:refs/remotes/origin/hy            carried   carried   carried   carried   left out
+refs/head:refs/remotes/origin/hz         carried   carried   carried   carried   left out
+refs/h*:refs/remotes/origin/hh/*         carried   left out  carried   left out  left out
+refs/heads*x:refs/remotes/origin/h*x     carried   left out  left out  carried   left out
```

- **The new row passes on main by design,** as `another-name-suffix` does, because main's clause 4 (`gives_a_branch_another_name`) answers `False` for every source not under `refs/heads/` (`961640c6`, `scripts/shape_materialize.py` line 1704), and `refs/h` meets no earlier clause. It passes on this head. It kills F2: with F2 applied to `scripts/shape_materialize.py`, `-k carried-refs-h-not-a-pattern` over both test files gives 2 failed (`test_which_clause_leaves_each_refspec_out[carried-refs-h-not-a-pattern]` and `test_which_clause_leaves_each_refspec_out_of_the_legs_fetch[carried-refs-h-not-a-pattern]`), and over the 90 rows of Mutants, 2 failed and 88 passed. The runs are in `logs/mut-summary.log`: the row alone, 2 passed on main's code (`logs/mut-ROW-main-code.log`), 2 passed on this head (`logs/mut-ROW-head.log`), 2 failed under F2 (`logs/mut-ROW-F2.log`).
- The Mutants section below replaces the F2 sentence with this result.

**NIT 2 (finding 2): the line length.** Fix round 1's NIT 2 sentence now says 78, and names the scripts' 75 and 76 (above). The new commit adds lines of at most 76 characters, all ASCII, and no assert that joins conditions with `and` or `or`.

**NIT 3 (finding 3): the #1131 merge-tree line.** #1131's head is `55c30f20` now (its NIT round), not `a4beec9e`. `git merge-tree --write-tree 55c30f2 368b87e` exits 0 with tree `e036c02d6a12`; at `baa19972a` it gave `bd6ebc5cd3af`, as the re-check measured. See Merge-trees.

Main's production code (`961640c6`: `scripts/shape_materialize.py` and `scripts/bump-leg.py`) was put in the worktree under this head's two changed test files. Then the committed files were put back, with sha256 and dirty=0 checked (`logs/mut-MAIN-code-head-tests.log`). The result is **17 failed, 106 passed** at `baa19972a`, and **17 failed, 108 passed** at `368b87e9b` (`logs/mut-MAIN-code-head-tests.log`, 125 rows): the NIT round's 2 rows pass on main, by design. The failures are exactly the rows the fix decides:

- `WHICH_CLAUSE` `[another-name-heads-pattern]`, `[-shorter]`, `[-refs-h]` and `[-suffix]`, in both files: 8. Each is carried on main ("Left contains one more item").
- `test_a_branch_put_on_another_branchs_name_fails_no_fetch[heads-pattern-into-a-folder]` (member), and `..._of_the_leg[origin-heads-pattern-into-a-folder]` and `[local-remote-dir-heads-pattern-into-a-folder]` (leg): 3. The fetch exits 128 on main, so the runs give the failed-fetch note.
- Fix round 1's `NO_BRANCH_A_PATTERN_TAKES` rows: `test_a_ref_a_heads_pattern_takes_answers_for_no_branch[heads-pattern]` and `[refs-h-pattern]` (member), and `..._of_the_leg[origin-heads-pattern]`, `[origin-refs-h-pattern]`, `[local-remote-dir-heads-pattern]` and `[local-remote-dir-refs-h-pattern]` (leg): 6. On main the pattern is carried, so the real run pins the commit instead of refusing it.

The first round's 7 are among these. Fix round 1 adds 10: `-refs-h` and `-suffix` in both files, and the 6 end-to-end rows. `another-name-suffix` and `carried-not-refs-heads` pass on main by design. They kill mutants (below).

NIT round, on `368b87e9b` with git 2.43, through `tools/mut.sh` (`logs/mut-summary.log`). The scripts are `baa19972a`'s, so these are fix round 1's mutants over the NIT round's tests. Each mutant was applied alone to the committed `scripts/shape_materialize.py`. The runs covered both changed test files with `-k "which_clause or another_branchs_name or heads_pattern_takes"` (90 rows, 35 deselected). After each run the committed files were put back, and their sha256 and dirty=0 were checked: 9 of 9 restores in the script matched (`shape_materialize.py` `16c6ec378c36...`, `bump-leg.py` `8f4af9c5e78c...`).

```
mutant                                                        failed  result
main's code (961640c) under these two files, all 125 rows     17     the rows above (108 passed)
F2 the fix's `bool(star) and` dropped                           2     killed (carried-refs-h-not-a-pattern x2)
B1 the class drawn from `refs/he` up (`refs/h*` carried)        5     killed (-refs-h x2, refs-h-pattern x3)
B9 the fix only where the `*` ends the source                   2     killed (-suffix x2)
B4 the fix undone (`return False` in its place)                17     killed (every row the fix decides)
```

F2 is killed now. At `baa19972a`, with 88 rows, it survived (88 passed); B1 (5 failed), B9 (2) and B4 (17) gave the same counts as here. The earlier sentence said F2 differs from this head only on a source that is no pattern and is a start of `refs/heads/`, and that no such full name can be a ref beside the branches. The first half holds: over the 39,385-config corpus F2 differs from this head only there (`refs/`, `refs/h`, ... `refs/heads`). The second half held for `refs/heads` alone. `refs/h` and `refs/head` are refs a remote can hold beside its branches, and the head carries `+refs/h:refs/remotes/origin/hy` and pins a commit only that ref reaches, where F2 refuses it (NIT round, NIT 1). The row `carried-refs-h-not-a-pattern` holds that answer.

At `a35a97ea5`, whose code this head keeps, the first round measured these over `WHICH_CLAUSE` in both files (66 rows then):

```
mutant                                                failed  result
R16 clause 4 compares only the part before the `*`      2     killed (another-name-suffix x2); on main's own rows it survives (102 passed)
R17 clause 4 compares only the part after the `*`       8     killed (the four another-name rows x2)
F3  only `refs/heads*` itself, not shorter starts       2     killed (another-name-heads-pattern-shorter x2)
F4  any pattern starting `refs/h` (too broad)           2     killed (carried-not-refs-heads x2)
F5  every pattern not under refs/heads/ left out        6     killed (carried pulls, not-refs-tags, not-refs-heads x2)
H1  _left_out's OUTSIDE clause removed                  2     killed
H2  _left_out's tag clause removed                     14     killed
H3  _left_out's branches'-glob clause removed           2     killed (branches-glob-pull-requests x2)
H4  _left_out's last clause removed                    14     killed
H5  `if _left_out(...)` negated                        60     killed
```

Every run went through `run9.sh` on the commit `368b87e9b` (dirty=0 in each START line): gh stub first on PATH, a bogus token, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`, and `EMAIL` set.

- git 2.43, the three files that name the functions (`test_family_bump_fetch_refspecs.py`, `test_bump_leg_fetch_refspecs.py`, `test_bump_leg_reachability_words.py`): **159 passed** (`logs/g243.log`; 157 at `baa19972a`, and the NIT round's 2 rows).
- git 2.47.1 (a git 2.47.1 build first on PATH; `git --version` printed `git version 2.47.1` in the log): the same three files: **159 passed** (`logs/g247.log`).
- git 2.43, `test_path_guard_hygiene.py`, `test_pinned_copy_text.py` and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`): **306 passed** (`logs/hyg243.log`, the same count as at `baa19972a`).

`git merge-tree --write-tree <target> 368b87e`, run at 2026-10-23T12:06:38Z against main and the head of every open PR that touches one of the four files (55 open PRs listed at 12:06:22Z; `logs/merge-trees.log`). Each exits 0 with no conflict:
- `origin/main` `961640c6`: tree `19c8442d96b0`;
- #1104 `de89aa81` (`shape_materialize.py`): tree `b76c36be588a`;
- #1105 `b28f40de` (`shape_materialize.py`): tree `5f2a8b3706e4`;
- #1118 `2307036a` (`shape_materialize.py`): tree `0c21c6b39607`;
- #1131 `55c30f20` (`bump-leg.py`; its head moved from `a4beec9e` in its NIT round): tree `e036c02d6a12`;
- #1160 `0068f5af` (`shape_materialize.py`): tree `bce90eb65c6c`.

The head's own tree is `351a9b73cda8`. Main is still `961640c6`: its one commit past `038dafba`, #1155, touches only `docs/release-notes/v1.2.0.md`.

- C18b, a source that names no branch onto one branch's name, still clashes: own 0, tools 128. Which names the remote's branches have is not known from the refspecs. Leaving out every such refspec would also leave out the pull requests, which #946 carries on purpose. The docstrings now say so. The coordinator adds it and C25 to #1027's list.
- Leaving out the `refs/h*` class prunes what such a refspec kept until the repository's next own fetch, as for #1066's cases (C25n, C25g, C25h). An unforced one held under a branch's name costs the non-fast-forward of THE COST, ACCEPTED (C25u).
- Leaving it out also changes the answer for a commit that only a non-branch ref the pattern takes reaches, from pin to refuse (fix round 1, SHOULD 2). This is the rule #613 and #1037 keep for a commit only a tag reaches: a bump pins only what a branch reaches. Every run gives the same answer, so a dry run and the real run after it never differ. Pinning such a commit instead would be a design question, not part of this fix.

Review: lane 5's independent review (issuecomment-6347113460) and its re-check, READY at `baa19972` (issuecomment-6352215076); then rc1157b, lane 5's independent sonnet re-checker, which wrote none of #1157: READY at `368b87e9`, the head that lands (issuecomment-6371455922), with no MUST or SHOULD; its wording NIT is corrected in the body. CI (Brett Heap's ruling 27): H0 is the head 368b87e itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 038dafb; and the local gate passed on its predicted squash tree 44b3b5c over main b055bb7: the bump_leg_fetch_refspecs, family_bump_fetch_refspecs, repo_hygiene, agents_md_codes, cli_reference sets, 296 passed. Landed on Brett Heap's word of 2026-10-23T13:10:26Z, relayed to every lane by lane 3 (#473 issuecomment-6365697123): "merge v1.2.1 PRs once reviewed and green".

Closes #1100

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 23, 2026
…py` where GNU make at the root reads a kept `GNUmakefile` or `makefile`, and its NOTE names that file and what it takes for `make bootstrap` to run there (#1047) (#1121)

GNU make reads the first it finds of `GNUmakefile`, `makefile` and `Makefile`, and only that one. Where the source keeps `GNUmakefile` or `makefile` in the root, `make bootstrap` at the root of the clone reads the source's file, not the shape's `Makefile`, and exits 2 with "No rule to make target 'bootstrap'". `execute`'s NEXT printed it there anyway.

This PR also covers the third box of #1048 (see #1048): the `_next_steps` NOTE that said the shape's targets "arrive only once the follow-up merges the two", which is untrue where GNU make reads a kept `GNUmakefile` or `makefile`. #1105 covers that issue's other two boxes, and leaves this one to this PR.

## Measured

GNU Make 4.3 on Linux, a disk that keeps case. One directory per set of files, each file with a `which` target:

| files | `make which` reads | `make -n bootstrap` |
|---|---|---|
| `GNUmakefile` | `GNUmakefile` | exit 2, no rule |
| `makefile` | `makefile` | exit 2 |
| `Makefile` (with a `bootstrap` rule) | `Makefile` | exit 0 |
| `GNUmakefile` + `makefile` | `GNUmakefile` | exit 2 |
| `GNUmakefile` + `Makefile` | `GNUmakefile` | exit 2 |
| `makefile` + `Makefile` | `makefile` | exit 2 |
| all three | `GNUmakefile` | exit 2 |

A `GNUmakefile` that holds `include Makefile` runs the shape's rule (exit 0), and so does `make -f Makefile bootstrap`.

What NEXT printed on main, from a real `plan`, `check` and `execute --local-remote-dir`, with the printed command run in a recursive clone of the split ("S" is the source's own `scripts/bootstrap.py`, kept with `scripts/`):

| the root keeps | NEXT on main | in the clone |
|---|---|---|
| none | `make bootstrap` | exit 0 |
| `Makefile` | `python3 scripts/bootstrap.py`, #480's NOTE | exit 0 |
| `makefile`, `GNUmakefile`, or both | `make bootstrap` | **exit 2, no rule** |
| `Makefile` + `makefile`, `Makefile` + `GNUmakefile`, all three | `python3 scripts/bootstrap.py`, and a NOTE saying the targets arrive once the two `Makefile`s are merged | the command exits 0; `make bootstrap` exits 2, and still does after `cat shape/Makefile >> Makefile` |
| S + `GNUmakefile`, S + `makefile`, S + `Makefile` + `GNUmakefile` | no command; the NOTE says to merge, "and then run `make bootstrap`" | `make bootstrap` exits 2 |

## What changed

`adopt-project.py`, `_next_steps`:
- It takes `surviving=`, the paths the plan's entries send to the root (`paths_for["root"]`, passed by `cmd_execute`).
- It asks them for a kept `GNUmakefile` or `makefile` in GNU make's order, with `_root_keeps`, as `_makefile_follow_up` asks them. So NEXT names the same file as the plan's `CONTRACTS_DIR` follow-up.
- Where it finds one, the command is `python3 scripts/bootstrap.py`. The NOTE names the file in the follow-up's words, and says `make bootstrap` and the shape's other make targets run there only once that file has them, or includes a Makefile that does:
  - the shape's `Makefile` is the root's: "NOTE the shape's Makefile is the root's `Makefile`, but GNU make at the root reads the source's `GNUmakefile`, the first of `GNUmakefile`, `makefile` and `Makefile` it finds, so `make bootstrap` and the shape's other make targets run there only once `GNUmakefile` has them, or includes a Makefile that does; `python3 scripts/bootstrap.py` is what the shape's `make bootstrap` runs."
  - the source also keeps its own `Makefile`: "NOTE the root keeps its own Makefile and the shape's is shape/Makefile, and GNU make at the root reads ..." (the same clause, and the same ending).
  - the source's own script went aside too: the merges first, as before, "and then run `python3 scripts/bootstrap.py`; GNU make at the root reads ...".
- A root that keeps neither name gets the block it got before, byte for byte. That includes `MAKEFILE`, which GNU make never reads where case is kept.

## Tests

`tests/test_adopt_next_make_reads_first.py`, new, 40 tests (38, and the 2 of the fix round below):
- 13 rows word for word: none, `Makefile`, `makefile`, `GNUmakefile`, `makefile`+`GNUmakefile`, `Makefile`+`makefile`, `Makefile`+`GNUmakefile`, all three, S, S+`Makefile`, S+`GNUmakefile`, S+`makefile`, S+`Makefile`+`GNUmakefile`.
- 3 rows that keep `make bootstrap`: `MAKEFILE`; longer names (`makefile.am`, `Makefile.in`, `GNUmakefile.bak`); and the names below the root.
- 13 rows in which NEXT names the same file as `_makefile_follow_up`, for the same root paths.
- 9 end to end rows, one for each row that keeps `GNUmakefile` or `makefile`. Each is a real `execute`, and its clone is made by pasting NEXT's own clone line. In that clone:
  - NEXT's lines are the row's;
  - `make which` prints the file the NOTE names;
  - `make -n bootstrap` has no rule;
  - the printed command exits 0 and ends `bootstrap ok`. For S rows, it does so once the shape's script is merged, as the NOTE says;
  - where a `Makefile` is kept, the follow-up's merge alone still leaves no rule;
  - an `include Makefile` in the file GNU make reads makes `make -n bootstrap` run the shape's recipe.

On main's `adopt-project.py`, the first 38 all fail. The 9 end to end rows fail on what NEXT prints. The unit rows that guard the unchanged cases fail there only for want of the `surviving=` keyword. M1 below shows the same rows passing and every row that keeps `GNUmakefile` or `makefile` failing. On this head, all 40 pass.

Mutants of the change, each restored and checked by sha256. Each run is the new file plus the five `next` tests of `test_adopt_e2e.py` (43 tests). Every mutant is killed:

| mutant | failed |
|---|---|
| M1 `first` never found | 27 |
| M2 `makefile` asked before `GNUmakefile` | 6 |
| M3 the command ignores `first` | 6 |
| M4 the branch for a `Makefile` kept beside `first` dropped | 6 |
| M5 the branch for a script gone aside ignores `first` | 9 |
| M6 the lookup folds case | 7, two of them existing `test_adopt_e2e.py` rows |
| M7 `cmd_execute` passes no paths | 9, the end to end rows |

Gate on this head, named files only, one basetemp per run. Each run had `EMAIL` set, `GIT_CONFIG_GLOBAL` an empty file, `GIT_CONFIG_NOSYSTEM=1`, a stub `gh` first on PATH, `GH_TOKEN=bogus` and an empty `GH_CONFIG_DIR`:
- the new file, `test_adopt_make_reads_first`, `test_adopt_e2e`: 98 passed, with the new file at 38 tests (at `c04bb710`; the fix round's gate is below).
- `test_adopt_github_exits`, `test_adopt_printed_sentences`, `test_repo_hygiene`, `test_readme_windows_quoting_sentence`, `test_cli_reference`, `test_agents_md_codes`: 291 passed.
- `test_scaffold_leg_path_collisions`: 104 passed.
- `test_adopt_contracts_dir`, `test_adopt_verify`, `test_adopt_pins`, `test_adopt_root_manifest`, `test_adopt_ignored_shape_files`, `test_adopt_nested_attributes`, `test_adopt_shape_pin_and_work_dir`: 378 passed.
- `test_shape_doctor.py` in 4 shards: 112 + 112 + 112 + 109 = 445 passed, and 3 skipped (the PowerShell line, which is printed only on a Windows host).

## Fix round 1 (RV1121's SHOULD-1)

RV1121 found the code right and one test missing: nothing held `cmd_execute` to handing `_next_steps(surviving=)` only the root's paths. Its mutant X4, which passes `paths_for["root"] + paths_for["code"] + paths_for["drop"]`, kept all 43 tests green. The case is a real one: the `root-project-makefile` rule sends a kept `GNUmakefile` to the root, and its reason says that moving a Makefile that only builds the code to the code leg "is a one-line edit of the plan" (`contracts/path-classification.yaml`, lines 236-237).

Two end to end rows, `test_next_keeps_make_bootstrap_where_the_plan_sends_the_makefile_elsewhere[code]` and `[drop]`. Each keeps `GNUmakefile` in the source, edits the plan to send it to `leg: code` (then `leg: drop`), runs a real `execute`, and pastes NEXT's own clone line. Each asserts that NEXT's lines after the clone are `    make bootstrap` and nothing else, that the clone's root has no `GNUmakefile`, and that `make -n bootstrap` there exits 0 and runs `python3 scripts/bootstrap.py`.

The `execute`, paste and clone steps of the nine existing end to end rows moved into one helper, `executed_and_cloned`, which the two new rows share, so no block of the test is written twice. Those rows assert what they asserted. The file now holds 40 tests (38 and 2).

- **X4, on this head's `adopt-project.py` with the one line changed:** the new file and the five `next` rows of `test_adopt_e2e.py` (45 selected): 2 failed, 43 passed. Both new rows fail on NEXT's lines, which are `python3 scripts/bootstrap.py` and the NOTE "the shape's Makefile is the root's `Makefile`, but GNU make at the root reads the source's `GNUmakefile`, ...", naming a file the root does not have. The file was restored and its sha256 read `60bad59a...` again.
- **On main's `adopt-project.py`:** the two rows pass, since main printed `make bootstrap` there. They hold the unchanged case against the new lookup, and X4 is the mutant that tells them apart.
- **On this head `1c5173758`**, same environment as above: the new file 40 passed, `test_adopt_e2e.py` 27 passed, `test_repo_hygiene.py` 100 passed.
- **Merge:** `git merge-tree --write-tree` of this head with main at `823624d93`: clean (tree `73ddafb2`).

RV1121's four NITs are not in this PR; the lane files them.

## Merge

- `git merge-tree --write-tree` of this head with main at `6da757cbe`, where the branch starts: clean (tree `21955cc7`). Main has since moved to `7cd66902a`, which changes `scaffold-project.py`, its test and the docs, and none of this branch's files. The merge with it is clean too (tree `ec4e6c8e`).
- With #1105's head `5a4de7f76`: clean (tree `3394d19d`). #1105's `adopt-project.py` change is in `_predict_collisions`, apart from `_next_steps`. On that merge, built in a `git clone --shared` checkout, `test_adopt_next_make_reads_first`, #1105's `test_adopt_collision_make_reads` and `test_pinned_copy_text` gave 152 passed.
- With the other open pull requests that change `adopt-project.py` (#1120 `c55e55e3`, #1119 `bdcf9952`, #1118 `c60066cb`, #1106 `504a1dd6`, #1091 `160de118`): each clean.

## Not in this PR

- Once #1105 lands, the lookup "which of `GNUmakefile` and `makefile` the root keeps first" is written three times: in `_makefile_follow_up`, `_predict_collisions` and `_next_steps`. Each is the same two lines over `GNU_MAKE_READS` and `_root_keeps`. They are kept apart here so this PR and #1105 touch disjoint lines; one helper could serve all three after both land.
- `templates/assembly-root/AGENTS-shape.md` line 19 ("`git clone --recurse-submodules` this repository and run `make bootstrap`") has the same gap in an adopted root that keeps `GNUmakefile` or `makefile`. It is pinned, and #1105 changes that file, so it is left.
- On a disk that ignores case (#1049), a kept `makefile` is reported as a `Makefile` collision, and NEXT's NOTE says "the root keeps its own Makefile". The new clause is true there too, since GNU make reads that file and merging into it brings the targets. The spelling is #1049's.
- A kept directory named `GNUmakefile/` or `makefile/` is named as the source's file. GNU make stops on it with "Is a directory". `_makefile_follow_up` does the same.

Closes #1047

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant