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

Merged
brettheap merged 4 commits into
mainfrom
fix/1048-makefile-follow-up-gnu-read-order
Nov 8, 2026

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

Correct adoption guidance and validation for roots that keep multiple makefile names.

Bug Fixes:

  • Correct Makefile collision guidance so it reflects GNU make's first-file selection and accurately describes when shape targets become available.
  • Align adoption collision predictions with the Makefile used to determine GNU make's entry point.

Enhancements:

  • Document GNU make's file-selection behavior and include requirements in the pinned AGENTS-shape guidance.

Documentation:

  • Update the pinned AGENTS-shape template to explain how GNUmakefile, makefile, and Makefile affect shape targets and contracts-dir.

Tests:

  • Add comprehensive collision, pinned-text, and real-clone integration coverage for GNU make file selection and include behavior.

…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

Data structures

Define the shared GNU make filename precedence tuple that the collision logic and pinned-text tests rely on.

  • scripts/shape_materialize.py L779-786

Implementation

Use the filename precedence when predicting Makefile collisions and describing when the shape’s targets and bootstrap command become available.

  • adopt-project.py L6560-6564, L6574-6579
  • scripts/shape_materialize.py L1006-1017, L1026-1056, L1108-1131 (+1 small edits)

Documentation

Update the pinned adoption guidance to explain GNU make’s file precedence and when the shape’s Makefile is read.

  • templates/assembly-root/AGENTS-shape.md L82-92

Tests

Keep the pinned guidance text synchronized with the template’s updated explanation.

  • tests/test_pinned_copy_text.py L73-89

Exercise collision follow-ups, pinned wording, and real adoption behavior across kept makefile names and include scenarios.

  • tests/test_adopt_collision_make_reads.py L1-396

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>
@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>
brettheap added a commit that referenced this pull request Nov 8, 2026
…s message spells a path byte that is not UTF-8 as `_shown` does, an escape, and no longer says it names it with a `?` in its place, which #900 replaced (#1018) (#1104)

`env_commit`'s docstring in `scripts/shape_materialize.py` says what the split commit's message does with a `root` or `drop` path that holds a byte that is not UTF-8, as the code does since #900. It said the message names such a path "with a `?` in place of that byte, a path that does not exist". That was true before #900, and it is not true now. A reader of the docstring is told about a `caf?.md` the tool no longer writes.

Found by lane 1's writer on #900 and filed as #1018. Lane 5 took it outside v1.2.0, under Brett Heap's ruling of 2026-10-21T10:32:04Z.

Measured on main `2a119f281` and again on `16f9a0e81` (main after #1087), with a small script that calls: `adopt-project.py`'s own `_split_message` and `_shown`, then `env_commit` and a real `git commit`, read back with `cat -A`.

- The issue's claim held: the docstring (about line 2103 when filed, line 2268 on `2a119f281`, line 2349 on this branch) still said the message names the byte with a `?`.
- The code does not. `_shown("caf\udce9.md")` is `` `caf\udce9.md` ``, and the split message for a `root` and a `drop` entry named that holds no lone surrogate. Through `env_commit` and `git cat-file commit HEAD`, `cat -A`:

  ```
  DROPPED (in no leg and no longer here):$
    `caf\udce9.md`$
  STAYS IN THE ASSEMBLY ROOT:$
    README.md$
    `caf\udce9.md`$
  ```
- What stays true of `env_commit` itself: a message that holds the lone surrogate, handed to it directly, is still written as `?`. `cat -A` reads `Name: caf?.md$`. So the `errors="replace"` paragraph is kept, and what changes is who it describes.
- #1087 (main `16f9a0e81`) changed `scripts/shape_materialize.py` by 128 lines, none of them in `env_commit`, `COMMIT_COMMAND` or the message, and changed nothing in `adopt-project.py` (0 diff lines). The rebase onto it was clean and the docstring needed no other change.

One file, `env_commit`'s docstring, in two paragraphs: the `errors="replace"` paragraph (round 1, 11 lines added and 7 removed) and the sentence above it (round 2, below; 3 added and 2 removed). Together 14 added and 9 removed against main `6da757cb`. The `errors="replace"` paragraph now says:

- encoding, a character UTF-8 cannot carry is written as `?`, and nothing raises after both legs are pushed, as before;
- `adopt-project.py` refuses a lone surrogate in a path it extracts (`entry_path_problem`), and a `root` or `drop` entry that holds one is listed in the split message but never raw: the message names each path as `plan`'s report does (`_shown`, #900), so the byte 0xE9 reads as an escape with no surrogate in it, `` `caf\\udce9.md` ``, the path the source holds;
- written raw it would be a `?`, `caf?.md`, a path that does not exist, which is what `env_commit` still writes for a lone surrogate it is handed.

The backslash is doubled in the source, as the neighbouring docstrings write `\\udcc3`. A single `\u` in a non-raw docstring would be an escape that makes a lone surrogate. Measured: the docstring holds no character from U+D800 to U+DFFF and holds the six characters `\udce9`. The longest docstring line is 78 characters.

**The code is unchanged.** A script compares the AST of `scripts/shape_materialize.py` with every docstring blanked: the branch's file is identical to `origin/main`'s after the rebase onto `16f9a0e81` (round 1), and the merge of this head into `origin/main` `6da757cb` is identical to `6da757cb`'s file (round 2).

**No pin moves.** `scripts/shape_materialize.py` is not a pinned copy. `COPIED_VERBATIM`, `COPIED_FROM_SHAPE`, `FAMILY_COPIED_VERBATIM` and `FAMILY_COPIED_FROM_SHAPE` do not list it, and `templates/assembly-root/scripts/validate-pins.py` says of it "which no project carries". So no `upstream-changed` row, no digest, no assembly copy and no `tests/test_pinned_copy_text.py` entry changes.

**No other text pins these words.** Searched in `tests/`, `README.md`, `docs/`, `docs/handbook.html`, `AGENTS.md` and `templates/`: no test reads `env_commit`'s docstring (`__doc__`, `getdoc`), and none of them holds "in place of that byte" or "a path that does not exist" for this. The other places that name the `?` are about this function's behaviour or about history, and are still true: `_split_message`'s docstring says `caf\\udce9.md` "was `caf?.md`" (before #900), `_refused_by_policy` already says the message "lists it as `_shown` does (#900)", and `tests/test_adopt_spelled_names.py` quotes the message as it was on `c7a3ff16`. The `?` in `argv_text`, `trailer_line` and the commit-trailer text is about a `--trailer`, not the split message.

No rows and no mutants: the change is a docstring, the code is the same AST, and no test pins the docstring. So there is no row to fail on main and nothing to mutate.

All with `$Q/v110/run9.sh`, `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`, the stub `gh`, a fake token, git 2.43.0 (`/usr/bin/git`).

Two shards, run before the rebase, at the `2a119f281` base with this one-file change in the tree. Together they are every `tests/*.py` that names `shape_materialize` or `env_commit` (86 files, including `tests/test_adopt_spelled_names.py` and `tests/test_adopt_entry_paths.py`, which run `env_commit` and the #900 message) and `tests/test_agents_md_codes.py`:

| run | files | result |
|---|---|---|
| shard A | 43 | 2873 passed, 4 skipped in 1707.51s, rc=0 |
| shard B | 44 | 4611 passed, 31 skipped in 2462.01s, rc=0 |

After the rebase onto `16f9a0e81`, on the committed head `e32b2703d733f6c60c1b2cba2d62d164d93d7b9a` (`dirty=0`): the trio, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`, **170 passed in 55.67s**, rc=0. The docstring-only change needed no new shards: the shards' counts are runs at the `2a119f281` base, and #1087 did not touch `env_commit`.

Round 2, on the committed head `de89aa81b1533e32f1007f991aeae05cd915b36d` (`dirty=0`): the same trio, **170 passed in 61.77s**, rc=0. The round 2 commit is one docstring sentence, so it needed no new shards either.

git 2.47.1: no run. The change adds no test file of its own, so there is none to run on it.

`git merge-tree --write-tree HEAD <other>` with HEAD at `de89aa81b1533e32f1007f991aeae05cd915b36d`, after a re-fetch of `origin main` with `-c gc.auto=0 -c maintenance.auto=false` (round 2, about 14:03Z on 2026-10-21):

| against | result |
|---|---|
| `origin/main` `6da757cbe9a4eb66d60c34f38dcc2b332d175e8b` | clean, rc 0, tree `15c11b6c959fc4c7540105d5d62e3f961c8e27fd` |
| #1105 `fix/1048-makefile-follow-up-gnu-read-order` `5a4de7f763b5a3c32adce9614a3a539508416eb4`, an open PR that touches `scripts/shape_materialize.py` | clean, rc 0, tree `db05aead67052643942e35a9689d9dd4a0873fda` |
| #1118 `fix/1089-named-link-skip-tests` `c60066cbeec18b8b6c926e254ed4bee6b9a486c2`, the other open PR that touches `scripts/shape_materialize.py` | clean, rc 0, tree `a40df3b63c32c2a3d8f4c40587b094940c8d6567` |

Read with `gh pr list --state open --json number,headRefName,headRefOid,files`: 16 open PRs, three of them (this one, #1105 and #1118) touch `scripts/shape_materialize.py`, the only file this change touches. The other 13 (#1120, #1119, #1117, #1111, #1109, #1107, #1106, #1102, #1101, #1098, #1097, #1091, #1088) touch no file this change touches.

The same command with HEAD at the reviewed head `e32b2703` gives rv1104's trees: `de31d857` against `6da757cb` and `195ffe19` against #1105. The round 1 merge against #1090 is moot: #1090 has landed in `6da757cb`.

Held as a draft until the v1.2.0 tag; it is now on the v1.2.1 milestone.

- **NIT 1, `scripts/shape_materialize.py` lines 2340-2343, a line round 1 did not change.** The paragraph above the reworded one said the adoption's split message "names each entry path as the source spells it". Since #900 that is true of a printable path alone: `_split_message` lists any other through `_shown`, as the escape the next paragraph describes. It now says "as the source spells it (a printable one; see below)". One new commit on top of `e32b2703`, `de89aa81b1533e32f1007f991aeae05cd915b36d`, 3 lines added and 2 removed, docstring only, pushed as a fast-forward (no rebase, the PR is open).
  - Measured: the AST with docstrings blanked is identical to `e32b2703`'s and, merged into `6da757cb`, to `6da757cb`'s. The docstring holds no lone surrogate, still holds the six characters `\udce9`, and its longest line is 78 characters.
  - Searched `tests/`, `README.md`, `docs/`, the handbook mirror, `AGENTS.md`, `templates/` and the scripts for "names each entry path", "as the source spells" and the new words: only `tests/test_adopt_entry_paths.py` line 233 matches, a test's docstring about `execute` handing each path on as the source spells it, which is another sentence and pins nothing here.
- **NIT 2, this body.** It named main as `16f9a0e81` and #1090 as the one open PR that touches the file. #1090 has landed. Main is `6da757cb`, and the merge-trees above are redone against it, against #1105 and against #1118, a draft that opened since and also touches the file.

Review: rv1104-1107, lane 5's independent opus reviewer, which wrote none of #1104: READY at `de89aa81`, H0 of this landing (issuecomment-6318111413), with no finding; NIT 1 and NIT 2 of the round before are fixed. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 de89aa8 passed tests, tests-git-bash and SonarCloud; the head 92285d4 is a clean update-branch merge of main b66d8f0, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 6bd2128 over main b66d8f0: the repo_hygiene, agents_md_codes, cli_reference sets, 171 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 #1018

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap
brettheap marked this pull request as ready for review November 8, 2026 16:43
@sourcery-ai

sourcery-ai Bot commented Nov 8, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 6 days and 21 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

Copy link
Copy Markdown

@brettheap
brettheap merged commit 1212295 into main Nov 8, 2026
5 checks passed
@brettheap
brettheap deleted the fix/1048-makefile-follow-up-gnu-read-order branch November 8, 2026 16:45
brettheap pushed a commit that referenced this pull request Nov 8, 2026
Main at 1212295, which holds lane 4's #1105 squash and tests/test_adopt_collision_make_reads.py.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Nov 8, 2026
…e out the trees that keep case twins, which check names and execute refuses as plan-case-twins (#1161)

`test_the_sentences_are_true_of_what_make_does_in_the_clone` (#1105) ran a
real adoption at `[Makefile+makefile]` and `[all-three]`, and
`test_where_the_file_make_reads_includes_makefile_the_merge_alone_is_enough`
(#1105) at `[makefile]`: sources that keep `Makefile` and `makefile` side by
side in the root, each with its own blob. Since #1161 `check` names that set
of case twins as `plan-case-twins` and `execute` refuses it before any leg,
so there is no clone to run `make` in: at the merge 47478d5 all three failed
at `adopt_with_legs_mounted_away`'s `check` (`FINDING plan-case-twins: ...
`Makefile` and `makefile``, `assert 1 == 0`).

Both parametrizes now leave out the rows `keeps_twins` (from
`test_adopt_make_reads_first`) names, as 7d2f651 did in
`test_adopt_next_make_reads_first.py`, and each docstring says why, and that
`test_execute_refuses_makefile_beside_makefile_before_any_leg` pins the
refusal for those trees. Every other row is as it was: the clone test runs 5
of CLONES, the include test its `[GNUmakefile]` row, and the file's other
functions, which call `_predict_collisions` and `follow_ups_for` and run no
`check`, all their rows, `[Makefile+makefile]` and `[all-three]` among them.

Measured on CPython 3.12, the whole file: at 47478d5 3 failed, 28 passed;
with this change 28 passed, the same 28 node ids.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Nov 8, 2026
…`makefile/` is tested, README says `make` at the root reads a kept `GNUmakefile` or `makefile` instead, and a docstring says the shape's Makefile alone holds `CONTRACTS_DIR` (#1061) (#1123)

#1046 (#977) left three follow-ups, which r1046 named as NIT-C, NIT-D and NIT-E. None is a regression. A kept directory entry `makefile/` had no test: dropping the `rstrip("/")` in `twins`, in `_makefile_follow_up`, left every test passing, though without it `twins` is empty and `twins[0]` raises in `plan`. README did not say that `make` at the root reads a kept `GNUmakefile` or `makefile` instead of the shape's `Makefile`. And the docstring of `contracts_from_code_leg` said "`CONTRACTS_DIR` is the root Makefile's alone", which predates #977.

- (a) `ANSWERED` in `tests/test_adopt_make_reads_first.py` gains a `("makefile/", "root")` row, id `makefile-directory`, which expects the line a kept `makefile` file gets. GNU make 4.3 stops on a directory with `makefile: Is a directory.`, so the case is contrived, and the comment above `ANSWERED` says so.
- (b) README's section "Adopting an existing repository" now ends the sentence on `shape/Makefile` with "which holds the export until a person merges the two; `make` at the root reads a kept `GNUmakefile` or `makefile` instead."
  - README.md is at its cap, so the paragraph is tightened and stays at 12 lines, with README.md at 1482 lines. Three phrasings are shorter, and no fact is lost, only the word "honest".
  - The handbook's `adopt` section says nothing of `CONTRACTS_DIR`, so only its marker moves, to the section's new blob (`a7154cc3`), and `SENTENCE` in the test follows the new text.
- (c) `contracts_from_code_leg`'s docstring now says `CONTRACTS_DIR` is the shape's Makefile's alone: the root's `Makefile`, or, where the source keeps a `Makefile` of its own, `shape/Makefile`. `adopt-project.py` changes only in that docstring.
- Measured, with mutants of `twins` in `_makefile_follow_up`, each in a scratch clone and restored by sha256:
  - both `rstrip("/")` dropped (R10): on main the test file gives 32 passed and 1 failed, README's sentence and nothing to do with the mutant; on head it gives 1 failed and 33 passed, `[makefile-directory]`, with `IndexError: list index out of range`;
  - `rstrip("/")` dropped from the filter only: 1 failed, 33 passed, the same `IndexError`;
  - `rstrip("/")` dropped from the element only: 1 failed, 33 passed, the line names `makefile/` where it names `makefile`.
  - So R10 survived the 32 rows on main, and the new row kills it and both halves of it. `tests/test_adopt_make_reads_first.py` has 33 tests on main and 34 here.
- Known limits:
  - The directory row pins a line that is not true for a directory, since make stops with "Is a directory", so the row works as a crash guard.
  - The new docstring and README clause leave out the disk that ignores case, where a kept `makefile` (or `MAKEFILE`, or the directory `makefile/`) is the `Makefile` path and `place` writes the shape's copy to `shape/Makefile`.
- Not in this PR: #1048 covers the same gap in `collision_follow_up`, `AGENTS-shape.md` and the NOTE in `adopt-project.py`. `adopt_makefile_block`'s docstring in `scripts/shape_materialize.py` says the block is appended "to the assembly root's Makefile", the same older claim. It is filed as #1135, since #1048 and its PR #1105 do not hold it.

Review: lane 1's independent reviewer r1123 found `5c3908f8` READY on condition, a complete review, with no MUST, two SHOULDs and three NITs (#1123 issuecomment-6319888568). The SHOULDs were a hand-off of `adopt_makefile_block`'s docstring that nobody held, and a stale "Merge with main" section. Lane 1 met both in the body: the docstring is filed as #1135, and the section names main `823624d9`. NIT-1 is softened in the body, and NIT-2 and NIT-3 are the known limits above. On the merge with `823624d9` (tree `a317e482`) the review measured README.md at 1489 lines, exactly at its cap of 1489, the `adopt` handbook marker unchanged at `a7154cc3`, and the tests passing. It ran real adoptions and `/usr/bin/make` (GNU Make 4.3) to show the README sentence is true. The new row is the only one that kills R10 and both of its halves, and 7 of 8 mutants were killed (the writer's three, re-measured, and five of the review's own). The survivor, `twins` sorted with `reverse=True`, changes no row, because no row keeps two names that differ only in case, and it is outside this change's scope.

Tests, under a stub `gh` with a bogus token: at `5c3908f8`, with main `6da757cb` merged in, the gate in the PR body gave 1,151 passed and 3 skipped over named files, in three groups:
- `tests/test_adopt_make_reads_first.py`, `tests/test_adopt_contracts_dir.py` and `tests/test_adopt_printed_sentences.py`: 122 passed;
- `tests/test_repo_hygiene.py`, `tests/test_readme_sections.py`, `tests/test_readme_windows_quoting_sentence.py`, `tests/test_family_docs_one_spelling.py`, `tests/test_setup_by_hand_commands.py`, `tests/test_update_add_codes_documented.py`, `tests/test_doctor_plan_overrides.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`, `tests/test_pinned_copy_text.py` and `tests/test_scaffold_leg_path_collisions.py`: 584 passed;
- `tests/test_shape_doctor.py` in 4 shards: 445 passed and 3 skipped, the PowerShell line, which runs on Windows only.

The review reproduced the same numbers on the merge with `823624d9`.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its H0 `5c3908f8` is green on Linux and SonarCloud. The branch was never rebased. After H0, the only commit is update-branch's clean merge of main, giving d22439a, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: 8 pytest calls over `tests/test_adopt_make_reads_first.py`, `tests/test_adopt_next_make_reads_first.py`, `tests/test_adopt_contracts_dir.py`, `tests/test_adopt_printed_sentences.py`, `tests/test_repo_hygiene.py`, `tests/test_readme_sections.py`, `tests/test_readme_windows_quoting_sentence.py`, `tests/test_family_docs_one_spelling.py`, `tests/test_setup_by_hand_commands.py`, `tests/test_update_add_codes_documented.py`, `tests/test_doctor_plan_overrides.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`, `tests/test_pinned_copy_text.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_path_guard_hygiene.py`, `tests/test_no_auto_maintenance.py` and `tests/test_shape_doctor.py` in 4 shards: 1,383 passed, 5 skipped.

Closes #1061

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Nov 8, 2026
…on_follow_up` keeps #1105's `reads_first` lines and #1207's script-aside tail, each naming `python` on Windows, and four pins in #1105's test file move with them

Lane 4's #1105 (#1048) landed as 1212295, and its `collision_follow_up`
conflicted with this branch's, as the body's "Landing order" said it would.
`adopt-project.py` and `templates/assembly-root/AGENTS-shape.md` merged with
no conflict. The resolution keeps both sides' behaviour and does none of
#1240's unification: #1105's second `GNU_MAKE_READS`, its `_predict_collisions`
lookup and its collision line stay, and so do this branch's
`_make_reads_first`, `_make_reads_first_clause` and NEXT.

scripts/shape_materialize.py, three hunks, all in `collision_follow_up`:
- Docstring, the NIT-4 sentence: both sides rewrote the same sentence for
  the same finding. Main's is kept, since it also names
  `Materialized.follow_ups()`, which #1240 item G changes.
- Docstring, the Makefile paragraphs: main's heading and its "BUT GNU MAKE
  READS" paragraph, with this branch's `BOOTSTRAP_COMMAND` and NIT-2
  sentences in the first, and one sentence in the second saying both of its
  commands are named as `BOOTSTRAP_COMMAND` names them.
- The returns: #1105's per-branch `arrive`, `joint` and `meanwhile` stay,
  and its `until` becomes a per-branch `then`. With no `reads_first`, the
  line is this branch's, byte for byte. With one, it is #1105's, byte for
  byte, but the command a person runs is `BOOTSTRAP_COMMAND` in `meanwhile`
  and in "and then ... runs it" (#1240 item C, for #1105's line).

tests/test_adopt_collision_make_reads.py (#1105's): `MERGE_IS_ENOUGH`,
`MEANWHILE`, `BOTH_MERGES` and `SCRIPT_MERGED` move to those words, through a
new `COMMAND`. Before the move exactly the 11 rows that use them failed, and
the other 242 rows of the six adopt and pin files passed.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Nov 8, 2026
…says a full name that is no pattern takes the one ref git completes it to, which may be a branch, where it said any other source names no branch (#1238) (#1244)

#1238 is a wording NIT that the review of #1235 (#1230) found in the code: NIT 4 of rv1235's first review, and NIT 2 of its re-check. The docstring of `gives_a_branch_another_name` in `scripts/shape_materialize.py` ended "ANY OTHER SOURCE NAMES NO BRANCH", but git completes a source that is a full name and no pattern, and completes `refs/h` to the branch `refs/heads/refs/h` where the remote has one. The sentence now says what git does with such a source, as an open list ("such as"), and where the clash is. A reader of the docstring is no longer told that a source outside `refs/heads/` is never a branch.

Wording only, in one docstring of one file: 9 lines added, 3 removed. No code, no row, no other file. The file's syntax tree with every docstring dropped is the same as main's.

The sentence is at `scripts/shape_materialize.py:1720-1723`, the end of the docstring of `gives_a_branch_another_name` (line 1671):

```
1720:     BRANCH REACHES). A shorter start, such as `refs/*` or `*`, can name a
1721:     tag, and that clause has left it out first. ANY OTHER SOURCE NAMES NO
1722:     BRANCH, though a refspec from one can still put a ref on a branch's
1723:     name: `origin_fetch_refspecs` says where (WHAT CAN STILL CLASH).
```

It is true of a source in another namespace such as `refs/pull/7/head`. It is not true of every other source. A source that is a full name and no pattern takes the one ref git completes the name to on the remote: for `refs/h`, `refs/h` itself, the branch `refs/heads/refs/h`, the tag `refs/tags/refs/h`, `refs/refs/h` or `refs/remotes/refs/h`, and where several exist git takes the exact ref over a tag over a branch. Where the remote has the branch, the source is a branch. The code is right: `gives_a_branch_another_name("refs/h", ...)` is False, because the source has no `*` and does not start with `refs/heads/`, so the refspec is carried. What fails afterwards is the clash the docstring's own next clause names. Only the words were untrue.

Measured on this branch's base, with git 2.43.0 (`/usr/bin/git`) and git 2.47.1, which gave the same answers on every row. Local bare remotes, `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`, `GIT_SSH_COMMAND=false`. rv1235's probe scripts were run again here (copied unchanged, new output folders), and their logs are identical to rv1235's. The fetch was `+refs/heads/*:refs/remotes/origin/*` plus the refspec of the row, and the remote had `main` and `hy`:

```
the remote has                    refspec                          result
branch refs/heads/refs/h          +refs/h:refs/remotes/origin/hy   128 Cannot fetch both refs/heads/hy and refs/heads/refs/h to refs/remotes/origin/hy
tag refs/tags/refs/h              +refs/h:refs/remotes/origin/hy   128 Cannot fetch both refs/heads/hy and refs/tags/refs/h to refs/remotes/origin/hy
exact ref refs/h (update-ref)     +refs/h:refs/remotes/origin/hy   128 Cannot fetch both refs/heads/hy and refs/h to refs/remotes/origin/hy
refs/refs/h or refs/remotes/refs/h  +refs/h:refs/remotes/origin/hy 128, the same message naming that ref
each of those five refs, alone    onto origin/not-a-branch         0, holds that ref's commit
branch refs/heads/refs/h          onto origin/refs/h               0 (the branch's own name: no clash)
refs/pull/7/head                  onto origin/main                 128 Cannot fetch both refs/heads/main and refs/pull/7/head to refs/remotes/origin/main
branch refs/heads/main            onto origin/main                 0
```

So a name with no `*` can be a branch that its spelling does not show, and the clash is a ref that is not that branch put on a branch's name, which is what the second half of the sentence has to say. "ANY OTHER" was wrong in a second way: a carried refspec from a source that IS the branch does not clash onto that branch's own name.

The docstring's last sentence, now lines 1721-1729:

```
    tag, and that clause has left it out first. ANY OTHER SOURCE TAKES NO
    BRANCH BY A PATTERN, and a full name that is no pattern, such as
    `refs/h`, takes the one ref git completes it to on the remote: that ref
    itself, or one such as the branch `refs/heads/refs/h` or the tag
    `refs/tags/refs/h` (measured, git 2.43 and 2.47.1). Such a name can
    stand for a branch its spelling does not show, so it is not left out
    here. A refspec from any such source can still put a ref on a branch's
    name, if the ref is not that branch itself: `origin_fetch_refspecs`
    says where (WHAT CAN STILL CLASH).
```

- **"Takes no branch by a pattern"** holds for every source this sentence covers. Git matches a pattern against each ref's whole name, and a ref under `refs/heads/` starts with the part before the `*` only where that part is a start of `refs/heads/` or is under it, the two cases the function reads before this sentence.
- **The list is open** ("one such as"): it names the exact ref, the branch and the tag, and the rows above show two more completions (`refs/refs/h`, `refs/remotes/refs/h`), so no reader takes the three as all.
- **"So it is not left out here"** is what the code does: `gives_a_branch_another_name` is False for `refs/h` onto `origin/hy`, onto `origin/refs/h`, for `refs/pull/7/head` onto `origin/main`, and for the pattern `refs/pull/*/head`, and `origin_fetch_refspecs(["+refs/h:refs/remotes/origin/hy"])` returns it carried. It is True for `refs/heads/main` onto `origin/hy`, as before.
- **"If the ref is not that branch itself"** is the condition the rows show: the clash is two different refs on one name, a branch's.

The checks the issue's Verify lists:

- **The diff is only that docstring.** The diff has one hunk, inside the docstring, which spans lines 1672-1724 on main and 1672-1730 here. The file's tree with every docstring dropped (module, class and function: 168 each side) is equal on main (`50869f384`) and at this head under `python3 -I`, `ast.dump`: 380120 characters of dump on both sides. A control that renames `gives_a_branch_another_name` on the head side gives 380121 and DIFFERENT, and the same trees with the docstrings kept differ (520956 against 521366), so the check can fail. Python 3.12.3. The check compares two dumps made by one interpreter and asserts no text that depends on the version.
- **`git grep -n 'ANY OTHER SOURCE NAMES NO$' -- scripts/`** finds nothing at this head. On main it finds `shape_materialize.py:1721`. With any case and no line end, `git grep -n -i 'other source names no'` also finds nothing here, and nothing but that line on main.
- **No added line is over 79 columns or holds a non-ASCII character.** 9 lines are added, the longest is 75 columns, and none has a tab or trailing space. The file's own width is unchanged: 20 lines over 79 columns (longest 118) and 90 lines with a non-ASCII character, as on main, none of them mine.
- **No test, `README.md`, `docs/` page or `AGENTS.md` line pins the sentence.** The issue's grep holds at this head.

The issue asks whether `shape_materialize.py:1874` (now 1880, in the docstring of `origin_fetch_refspecs`) and `bump-leg.py:1165` (in `_leg_fetch_refspecs`) want the same change. Both paragraphs were read, and both are left as they are:

```
shape_materialize.py:1880:     WHAT CAN STILL CLASH is a refspec carried whose source names no branch,
bump-leg.py:1165:     whose source names no branch, onto a name a branch of the remote has
```

1. **They define a shape by its examples and say nothing about what git completes a name to.** Each goes on at once to the pull requests (`origin/pr/` where a branch `pr/7` is, and `+refs/pull/7/head:refs/remotes/origin/main` beside a branch `main`), and neither names `refs/h`. They were not the sentence that told a reader a source is never a branch.
2. **"Names no branch" is spelling there, as it is in the sentence the function's docstring opens with.** "A SOURCE UNDER `refs/heads/` NAMES BRANCHES" is true by spelling too: `can_name_a_tag`'s docstring measures that `+refs/heads/zz` took the tag `refs/tags/refs/heads/zz` where the remote had no branch `zz`. The sentence changed here now says outright that the spelling of a name with no `*` does not show what it stands for, so the docstring that defines the class carries the caveat the two borrowers need.
3. **The mechanism they state is the one that fails `refs/h`:** "wherever a name it writes is a branch's". The `origin_fetch_refspecs` paragraph ends with why such a refspec is carried at all: "Which names the remote's branches have is not known from its refspecs, and left out, what such a refspec keeps would be pruned on every run". That holds for `refs/h` word for word.
4. **The cost of leaving them is one reading:** a reader who takes "names no branch" in git's sense meets the single case `refs/h` beside a remote branch `refs/heads/refs/h`. If the review wants a word there, it is a few words in each place, and no open PR touches either paragraph (merge-trees below), so it can be a fix round.

None of either. No row is added or changed and no behaviour moved: the syntax tree outside the docstring is main's, so there is nothing for main to fail and nothing to mutate. `gives_a_branch_another_name` returns what it returned for every case in the table above (called in process at this head; the syntax tree outside the docstrings is main's).

All through the lane's runner `run9.sh` (stub `gh` first on PATH, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`), on the committed head `36ec589a6` with the tree clean (`dirty=0`), as one run of seven files at a time: `tests/test_pinned_copy_text.py`, `tests/test_path_guard_hygiene.py`, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py` (the hygiene five), `tests/test_family_bump_fetch_refspecs.py` and `tests/test_bump_leg_fetch_refspecs.py`.

- **The seven files, git 2.43.0** (`/usr/bin/git`): 504 passed in 469.44s (47 `test_agents_md_codes.py`, 68 `test_bump_leg_fetch_refspecs.py`, 23 `test_cli_reference.py`, 57 `test_family_bump_fetch_refspecs.py`, 121 `test_path_guard_hygiene.py`, 87 `test_pinned_copy_text.py`, 101 `test_repo_hygiene.py`; the counts are from `--collect-only -q` on the same head and add up to 504). Log `v131/n1238/gate-g243.log`.
- **The seven files, git 2.47.1** (first on PATH, `git --version` printed into the log): 504 passed in 714.00s. Log `v131/n1238/gate-g247.log`.
- **`-k carried-refs-h-not-a-pattern`** over `tests/test_family_bump_fetch_refspecs.py` and `tests/test_bump_leg_fetch_refspecs.py`: 2 passed, 123 deselected, on 2.43.0 and on 2.47.1 (`run-k-g243.log`, `run-k-g247.log`).
- Both runs start with `dirty=0`. Python 3.12.3 (CI runs 3.14); nothing asserts text that depends on the version.

The measured git behaviour above comes from the probe scripts, not the test files. `-k carried-refs-h-not-a-pattern` over the two fetch-refspec files is part of those runs.

`git merge-tree --write-tree origin/main HEAD` exits 0, with tree `9d874bcf2caf`, equal to this head's tree, against main `50869f384` (the merge base). Against the head of each open PR that touches `scripts/shape_materialize.py` or `scripts/bump-leg.py` (read from `gh pr list --state open --limit 100 --json number,headRefName,headRefOid,files`, 52 open PRs at the last read, 7 of them touch one of the two files), `git merge-tree --write-tree HEAD <head>` exits 0 and the merged tree still holds the new sentence and not the old one:

```
#1223 f50b472  exit 0   #1207 6fa1edd  exit 0   #1160 0068f5a  exit 0   #1131 55c30f2  exit 0
#1118 2307036  exit 0   #1105 b28f40d  exit 0   #1104 de89aa8  exit 0
```

Main moved once while this was gated, to `4829418f0` (`adopt-project.py`, `docs/cli.md` and a new test file: none of the files this change or its runs touch), and `git merge-tree --write-tree origin/main HEAD` against it also exits 0.

None of their hunks is within 100 lines of the sentence changed here.

Review: rv1244 and rv1244b, lane 5's independent sonnet reviewers, which wrote none of #1244: READY at `36ec589a6`, H0 of this landing (issuecomment-6723280477), with no MUST or SHOULD; its four NITs are optional (two wordings, a body note and a follow-up). CI (Brett Heap's ruling 27): H0 is the head 473959f itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 9729315; and the local gate passed on its predicted squash tree fb798f3 over main 9252ef2: the repo_hygiene, agents_md_codes, cli_reference sets, 171 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 #1238

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

Labels

None yet

Projects

None yet

1 participant