Repository navigation
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
brettheap wants to merge 3 commits into
Conversation
…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>
Reviewer's GuideThe PR fixes Makefile collision guidance by modeling GNU make’s Sequence diagram for collision planning and Makefile follow-upsequenceDiagram
participant Plan as adopt-project.py plan
participant Predict as _predict_collisions
participant FollowUp as collision_follow_up
participant Docs as AGENTS-shape.md
Plan->>Predict: _predict_collisions(entries)
Predict->>Predict: Select first kept name from GNU_MAKE_READS
Predict->>FollowUp: collision_follow_up(reads_first)
FollowUp-->>Plan: Follow-up names file GNU make reads
FollowUp-->>Docs: Guidance matches precedence and merge behavior
Flow diagram for GNU make file precedenceflowchart TD
Start["make without -f"] --> GNU{GNUmakefile exists?}
GNU -->|Yes| ReadGNU["Read GNUmakefile"]
GNU -->|No| Lower{makefile exists?}
Lower -->|Yes| ReadLower["Read makefile"]
Lower -->|No| ReadMake["Read Makefile"]
ReadGNU --> Targets["Shape targets available only if this file holds them or includes a Makefile"]
ReadLower --> Targets
ReadMake --> Targets
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
RV1105: independent review of #1105 at One SHOULD: the new collision follow-up adds a claim, "and not with that merge alone", that is untrue where the kept What I measuredGNU make 4.3 (
"Only that one" also holds. With a The three texts, rendered for the same ten roots on a scratch merge of main
Pins.
FindingsSHOULD-1: "and not with that merge alone" is untrue where the file A
A The clause before it, "only once The same over-claim appears in four other places:
Fix:
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 The tail says: "neither #1121's NOTE says "then run NIT-3: the template's "and never the shape's Makefile". This is false where the source keeps a 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.
Fix in a follow-up: delete the method and that sentence. NIT-5: NIT-6: two template lines still say NIT-7: the order of landing against #1121. GitHub links only #1048 to this PR, but #1048's third box (the NIT-8 (existing before this PR): stale follow-ups after a hand edit. Writer's claims
The 12 fail for the right reason:
On main, the 3 clone rows still show MutantsI ran my own on the new guard, on the scratch merge. Each mutant was run against
Merge-tree
#1104 is the only one of them that touches Gate (scratch merge commit of main
|
| 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>
|
|
RV1105b: independent review of #1105 at I re-checked the fix round ( What I measuredGNU make 4.3 (
The shape's copy's
The three texts on a scratch merge of main
W1048b's claims
The 17 fail for the right reason:
#1111's rule. #1105 adds no MutantsI wrote my own, on the changed sentences, on the scratch merge
Merge-tree
#1118 conflicts in Gate (scratch merge commit
|
| 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 withmakefile),make bootstrapexits 2 before the merge. After the merge alone it runs the shape's recipe, andmake contracts-dirprints 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_TARGETScomment; - the
collision_follow_updocstring ("exits 2 with the sameNo rulewhere it does not"); - the
ADOPTED_SENTENCEScomment; - the test module's docstring.
- the
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.
- I measured it. In a root that keeps only
Makefileand has the script aside, merging the script alone makespython3 scripts/bootstrap.pyprintSHAPE-BOOT, whilemake bootstrapstill exits 2. - This is the same reasoning W1048b used to keep "before both merges" out of the other branch.
- The text is main's (adopt-project.py: the tracking branch defaults to the source's default branch, topics are set before the pull request, and NEXT names scripts/bootstrap.py when the shape's Makefile went aside (#479, #480, #481) #493's F2), and this round kept it byte for byte on purpose.
test_adopt_e2e.pypins it.
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.
_predict_collisions, a docstring this PR adds a paragraph to, still says the wording comes from "the same functionexecutereports its actual collisions through". This is the claim NIT-4 removed fromcollision_follow_up.collision_follow_up's docstring says the follow-up uses "the wordsexecute's NEXT uses (adopt-project.py:execute's NEXT printsmake bootstrapwhere GNU make at the root reads a keptmakefileorGNUmakefile, which has nobootstraprule #1047)". The test module says "The words are those ofexecute's NEXT". On main today, NEXT's NOTE still says "arrive only once the follow-up merges the two;python3 scripts/bootstrap.pyis whatmake bootstrapruns". The words come with adopt-project.py:execute's NEXT printspython3 scripts/bootstrap.pywhere GNU make at the root reads a keptGNUmakefileormakefile, and its NOTE names that file and what it takes formake bootstrapto run there (#1047) #1121, so the claim is true only once adopt-project.py:execute's NEXT printspython3 scripts/bootstrap.pywhere GNU make at the root reads a keptGNUmakefileormakefile, and its NOTE names that file and what it takes formake bootstrapto run there (#1047) #1121 lands. RV1105's NIT-7 already asks for adopt-project.py:execute's NEXT printspython3 scripts/bootstrap.pywhere GNU make at the root reads a keptGNUmakefileormakefile, and its NOTE names that file and what it takes formake bootstrapto run there (#1047) #1121 to land first, and that makes this moot.- Smaller: "
Materialized.follow_ups()would say this of them". It passes noreads_first, so it would not say this.
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)
… 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>
…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>



Two sentences said a kept
Makefileis what holds the shape's block back. GNU make reads the first ofGNUmakefile,makefileandMakefileit finds, and only that one, so where the root also keepsGNUmakefileormakefile,makereads that file, and reaches the mergedMakefileonly where that file includes it. Closes #1048.Measured (a real
execute, thenmake -nin the clone, for each kept name; r1046's NIT-B, repeated; the include rows are RV1105's SHOULD-1):makereadscat shape/Makefile >> Makefile,make -n bootstrapMakefileMakefileMakefile+makefilemakefileMakefile+GNUmakefileGNUmakefileGNUmakefileMakefile+ amakefileorGNUmakefilethat ends ininclude Makefilepython3 scripts/bootstrap.py(exit 2 before the merge)makefileorGNUmakefilealoneMakefileis the root's, andmake contracts-dirhas no rule until that file holds the block or includesMakefileWhat changed
scripts/shape_materialize.py,collision_follow_up: takesreads_first, the file GNU make reads before the shape'sMakefile. 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 reachmakethere only once that file has them, or includes a Makefile that does; until thenpython3 scripts/bootstrap.pyis what the shape'smake bootstrapruns. Wherescripts/bootstrap.pywent aside too, it says neither command runs the shape's bootstrap until the script is merged, and thenpython3 scripts/bootstrap.pyruns it. Where the root keeps only the source'sMakefile, the sentence is what it was, byte for byte.GNU_MAKE_READSjoinsSHAPE_MAKE_TARGETS.adopt-project.py,_predict_collisions: reads the file from the plan's root paths as_makefile_follow_updoes, so theCONTRACTS_DIRline and the collision line of one plan name the same file.templates/assembly-root/AGENTS-shape.md: saysmake contracts-dirprints the block wherevermakereads the shape's Makefile, names the three files in the order the code reads them, and says that where aGNUmakefileormakefileis kept, beside aMakefileor instead of one,makereads it, and not the shape's Makefile unless that file includes it. It is one verbatim copy: the digest in every project'scontracts/shape-pin.yamlis rendered from its bytes at adopt and scaffold time, and no digest ortree_sha256of it is stored in this repository, so there is no pin to move here; each project re-pins throughupdate-shape.pyas for any template change.tests/test_pinned_copy_text.py:ADOPTED_SENTENCESholds 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 noMakefileis kept, the two name tuples equal, the pinned file naming every file the code reads first, the script-aside tail wheremakereads another file, and, in a real adoption's clone,make -nheld to both sentences for each kept name and for a keptGNUmakefileormakefilethat ends ininclude Makefile.Fix round 1 (RV1105's review,
b28f40de0on top of5a4de7f76)make"only onceXholds them, or includes a Makefile that does, and not with that merge alone". The last clause is untrue whereXalready ends ininclude Makefile: the merge alone brings them. The clause is gone, and the docstring paragraph, theSHAPE_MAKE_TARGETScomment and theADOPTED_SENTENCEScomment now say the merge alone brings the targets only where that file includesMakefile.execute's NEXT printspython3 scripts/bootstrap.pywhere GNU make at the root reads a keptGNUmakefileormakefile, and its NOTE names that file and what it takes formake bootstrapto run there (#1047) #1121's NOTE: "has them", and "is what the shape'smake bootstrapruns"._next_stepsis not touched.reads_firstis set, said "neithermake bootstrapnorpython3 scripts/bootstrap.pyruns the shape's bootstrap before both merges are made", which reads as ifmake bootstrapruns it after them. It now says neither runs it until the script is merged, and thenpython3 scripts/bootstrap.pyruns it. I did not keep "before both merges": whereXis given the targets itself, as the sentence before invites,make bootstrapruns the shape's bootstrap once the script is merged, with noMakefilemerge at all.ADOPTED_SENTENCESchanges with it. No other copy or regex intests/pins that sentence (git grepfor "never the shape" and "reads the first of them").collision_follow_updocstring no longer saysexecutereports through it. It saysplanpredicts the follow-ups and stores them infollow_ups:, the split commit carries those,executenames its collisions by path only (itsbesidelines and the "WRITTEN BESIDE" list), andMaterialized.follow_ups()has no caller. The method stays.Rows of this round:
tests/test_adopt_collision_make_reads.pyandtests/test_pinned_copy_text.py, 118 rows. With this round's test files on5a4de7f76's code, 17 fail and 101 pass; onb28f40de0all 118 pass. The 17 fail for the right reason:5a4de7f76Makefile+makefile,Makefile+GNUmakefile, all three,Makefile+GNUmakefile+scripts)GNUmakefile,makefile)GNUmakefile,makefile)make -n bootstrapexits 2 before the merge and 0 after it alone (both asserted first, and passing), then the split message holds "not with that merge alone"test_a_scaffolded_root_is_told_its_makefile_exports_noneMutants of this round, each run against the 118 rows on
b28f40de0, each file restored and its sha256 checked:not in messageinverted toin messagereturncode == 0invertedbootstraptarget runs" put backGate on
b28f40de0: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_collisionstest_shape_doctor, 8 shards (62 + 56 + 54 + 56 + 60 + 57 + 58 + 45 collected)Merge-tree:
b28f40de0against main4ed7c7bf5is 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, main6da757cbemerged 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 intest_shape_doctor.py, 1 MedxEHR row intest_adopt_plan.pythat needs a cloneSHAPE_MEDXEHR_CLONEnames); nine mutants each failed 1 to 11 of the then 114 rows.Not here
Makefileholds the shape's block back, and leave out a keptmakefileorGNUmakefile, which GNU make reads first #1048, the_next_stepsNOTE inadopt-project.py, is covered by the change adopt-project.py:execute's NEXT printsmake bootstrapwhere GNU make at the root reads a keptmakefileorGNUmakefile, which has nobootstraprule #1047 makes to it: see adopt-project.py:execute's NEXT printsmake bootstrapwhere GNU make at the root reads a keptmakefileorGNUmakefile, which has nobootstraprule #1047 and adopt-project.py:execute's NEXT printspython3 scripts/bootstrap.pywhere GNU make at the root reads a keptGNUmakefileormakefile, and its NOTE names that file and what it takes formake bootstrapto run there (#1047) #1121.templates/assembly-root/AGENTS-shape.mdline 19 and the five-commands table saymake bootstrapwithout this caveat. They say nothing about a kept file, so they are outside shape_materialize.py and the pinned AGENTS-shape.md: two sentences say a keptMakefileholds the shape's block back, and leave out a keptmakefileorGNUmakefile, which GNU make reads first #1048's two sentences.Lane 4 lands nothing outside v1.2.0 until after the v1.2.0 tag, on Brett's later word, so this is a draft.
🤖 Generated with Claude Code
Lane: openreposhape-4 (openRepoShape-4)
Summary by Sourcery
Clarify GNU make file precedence throughout adoption guidance so collision follow-ups and pinned documentation accurately describe when shape targets become available.
Bug Fixes:
Enhancements:
Documentation:
Tests: