Skip to content

adopt-project.py: check's rehearsal and _stored_as_written take a parent's -c settings, attr.tree too, and the source's objects, GIT_ATTR_SOURCE is refused by name before any question, and each throwaway reads its environment first (#1136) - #1169

Merged
brettheap merged 29 commits into
mainfrom
fix/1136-throwaways-parent-config
Nov 8, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1136

#1169 targets main, after #1119 squash-landed there as b7edd881, through three merges: #1119's final head 9b856443 (ce1938a8), then its squash b7edd881 (93a561c9), then origin/main at 50ce8ab1 (fe25bb14, no conflict). The second merge took this branch's side (git checkout --ours) for the two files that conflicted, adopt-project.py and tests/test_adopt_check_throwaway_repos.py, both #1119's; git diff 9b856443 b7edd881 is empty over the whole tree, so that merge changes no file. The first merge had one conflict, in the module docstring of tests/test_adopt_check_throwaway_repos.py, where #1119's #1033 paragraph and this PR's #1136 paragraph stand at the same place: both stay, #1033 first. The restack is proved under "The restack" in Tests. It was left out of v1.2.0 by Brett Heap's ruling of 2026-10-21T10:32:04Z. It is now on milestone v1.2.1, and lands under Brett Heap's v1.2.1 merge word in its held slot.

What

#910's throwaways (the rehearsal, _stored_as_written, _AttributesProbe) are made with _inherited_env(), which leaves out GIT_CONFIG_PARAMETERS (a parent git's -c), GIT_ATTR_SOURCE, and any attr.tree in GIT_CONFIG_COUNT; _stored_as_written held none of the source's objects; and the rehearsal chose the files it asks about with check-attr --cached, which reads no attr.tree. So check answered as a git with none of them would, and execute stopped after both legs existed, or refused a plan its own git would carry out. Measured with git 2.43; "base" is bba168bd (#1119's head when this PR was written), "head" is this branch before its restack (76c9b767, a commit that changes one comment only: with docstrings emptied, its AST is equal to 1e958ca6's; the tests, mutants and gate below ran at 1e958ca6, but for the one call marked at 76c9b767; the restack's head, fe25bb14, carries the same changed lines, as "The restack" in Tests shows):

Case Base Head
parent -c core.protectHFS=true over a source path .g + U+200C + it/x check exit 0 plan ok; execute exit 2 REFUSED extracting the spec leg (invalid path, unable to checkout working tree); both legs exist check exit 1 plan-path-git-refuses; execute exit 2 REFUSED plan-path-git-refuses; remotes/ absent
the same setting in GIT_CONFIG_COUNT (control) plan-path-git-refuses; refused; remotes/ absent same
no setting (control) plan ok; adoption verified same
parent -c filter.<name>.clean=false, smudge=cat, required=true, over a root .gitattributes that names the filter for itself plan ok; execute exit 2 extracting the spec leg (clean filter '<name>' failed); both legs exist plan-attributes-reencoded; refused; remotes/ absent
the same filter in GIT_CONFIG_COUNT (control) plan-attributes-reencoded; refused same
a parent's attr.tree naming the empty tree, in GIT_CONFIG_PARAMETERS or GIT_CONFIG_COUNT, over a CRLF notes.txt the plan keeps under a root * text eol=lf (r1169's probe p8) check exit 1 plan-file-reencoded; execute exit 2 REFUSED plan-file-reencoded before any leg, where its own git reads no rule either check exit 0 plan ok; execute exit 0 adoption verified
the same in a global config, and no attr.tree (controls) global: plan ok, adoption verified; none: plan-file-reencoded, refused, remotes/ absent same
a parent's attr.tree naming by its object id a tree the source holds on a side branch, whose .gitattributes says /.gitattributes filter=w1136, with that failing filter, over a root *.png binary (probes p2, p7), in either form or a global config check exit 0 plan ok in all three; execute made both legs and stopped in the spec leg, fatal: .gitattributes: clean filter 'w1136' failed, nothing rolled back (measured on b8e63bcf with check's refusal stubbed, in all three) plan-attributes-reencoded naming clean filter 'w1136' failed, in all three; execute refuses before any leg; remotes/ absent
the same filter with no attr.tree (control) plan ok same
the same side tree and filter over a root *.md text in place of *.png binary (r1169b's P7), in each form global: plan-file-reencoded alone, naming specs/001-feature/spec.md; parameters and count: plan ok. On 25805102: plan-attributes-reencoded and also plan-file-reencoded naming specs/001-feature/spec.md, in all three plan-attributes-reencoded alone, naming clean filter 'w1136' failed, in all three
a parent's attr.tree naming by its object id a side-branch tree whose .gitattributes says * text eol=lf, over a CRLF notes.txt that no rule of the source's own reaches, sent to the spec leg (r1169b's P3) or kept under a root *.png binary (P5), in the global, parameters and count forms check exit 0 plan ok in all six; execute is not refused before any leg: P3 exit 2 REFUSED mounting the legs (the following file has local modifications), P5 exit 1 adopt-lost naming notes.txt; both legs pushed, nothing rolled back (r1169b's real runs, the same on bba168bd and 25805102) check exit 1 plan-file-reencoded naming notes.txt, in all six; execute refuses plan-file-reencoded before any leg; remotes/ absent
the same with no attr.tree (control) plan ok same
GIT_ATTR_SOURCE=bogus plan ok; execute exit 2 extracting the spec leg (fatal: bad --attr-source or GIT_ATTR_SOURCE); both legs exist adopt-git-unasked; refused; remotes/ absent
GIT_ATTR_SOURCE= (empty) plan ok; execute exit 2 extracting the spec leg; both legs exist adopt-git-unasked; refused
GIT_ATTR_SOURCE=HEAD, code leg seeded plan ok; execute exit 2 REFUSED seeding the code leg; both legs exist adopt-git-unasked; refused
GIT_ATTR_SOURCE= the source tree's abbreviated oid, code leg seeded plan ok; execute exit 2 REFUSED pushing the spec leg; both legs exist adopt-git-unasked; refused
GIT_ATTR_SOURCE=HEAD, no seeded leg; or a full tree oid, seeded or not (KNOWN LIMIT, below) plan ok; adoption verified adopt-git-unasked; refused
no GIT_ATTR_SOURCE (control) plan ok; verified same

The first round (b8e63bcf) answered the attr.tree rows as base does (r1169's SHOULD-1). The second (25805102) still said plan ok for P3 and P5, as base does, and gave P7 a second, misnamed finding (r1169b's SHOULD-1). Then 1e958ca6 answers r1169b's ruling X1, with item 4 below (one argument and the two docstrings that explain it, +16/-5 against 25805102) and 17 new rows, (j) and (k) in Tests. The head was then 76c9b767, which changes comments only: w1169b found that the comment above the rehearsal's attribute read still said check-attr --cached, and it now says the read is made without it (+3/-2 against 1e958ca6; with docstrings emptied, its AST is equal to 1e958ca6's). The restack that followed makes the head fe25bb14 and writes no line of its own. The change is adopt-project.py (+211/-57 against bba168bd, and the same against main at fe25bb14; no --help, doc or code change), and the tests below:

  1. A parent's -c, attr.tree too. The rehearsal (_SplitRehearsal) and _stored_as_written take _split_checkout_env(): this process's environment less _NOT_INHERITED but GIT_CONFIG_PARAMETERS, so a parent's settings in GIT_CONFIG_PARAMETERS and GIT_CONFIG_COUNT reach them as they stand, attr.tree among them, as they reach execute's checkout, git rm and git add. _stored_as_written now borrows the source's objects as the rehearsal does (_objects_borrowed, which both use), so a tree's object id names there the tree the split's clones read.
  2. GIT_ATTR_SOURCE. _attr_source_unasked(source) raises _Unasked, the existing adopt-git-unasked, whatever the value is, the empty one included, with an ending of its own ("execute refuses it too, whatever it names, before it makes either leg. Unset GIT_ATTR_SOURCE, ... and run check again.") in place of the shared one, which said execute refuses "while git cannot answer there" and offered another --work-dir (r1169's NIT-3). It runs first in _entry_findings' try, before the split's own clone is asked, so check gives one finding and asks nothing after it, and execute refuses before either leg.
  3. Environment first. _AttributesProbe.__init__, _stored_as_written and _SplitRehearsal.__init__ read their environment before _throwaway makes the directory, so a read that raises leaves nothing behind, as adopt-project.py: check asks the split's own clone of the source in the work directory, so a clone this git will not make is adopt-git-unasked before any leg, and _resolved and _answers make their repositories as check's other throwaways do (#1083) #1119 made _resolved and _answers. _probe_env now takes (nothing, inherited); _AttributesProbe is its only caller.
  4. No --cached. _SplitRehearsal.reached, which both _rules_reaching calls use (the files a rule reaches as the source holds its rules, and as the split's root will), asks check-attr without --cached, which reads the index's rules and no attr.tree. A file only a parent's attr.tree reaches is now asked about.

Why

  • Why a parent's -c is handed to the rehearsal and to _stored_as_written. execute's checkout, git rm and git add run in the whole environment, so they take a parent's settings; the throwaways must answer as they do, as the same settings in GIT_CONFIG_COUNT already did. A throwaway's own -c still wins (git reads a command's own -c after the parent's and takes a key's last value), so a parent's core.hooksPath or core.fsmonitor runs nothing there; a row holds that.
  • Why attr.tree is handed on too (r1169's SHOULD-1; lane 1's ruling: fix (b)). The first round dropped it from both forms, and r1169 measured check answering otherwise than execute both ways on b8e63bcf: under the empty tree check said plan-file-reencoded while execute's own git read no rule either (probe p8), and under the side tree check said plan ok while the split's git add of the root fails (probes p2, p7). A global config's attr.tree reached the rehearsal all along, but _stored_as_written held none of the source's objects, so a tree id named nothing there, and the side tree in a global config was plan ok too. Measured with git 2.43 in a commitless throwaway: without the source's objects a full tree id read no rule and an abbreviated one was ignored; borrowing them, both read that tree's rules, as the split's clones do; the empty tree read no rule either way; HEAD, a branch name and bogus were ignored either way. So _stored_as_written borrows the source's objects as the rehearsal does, and nothing of a parent's settings is dropped, which retires the first round's GIT_CONFIG_PARAMETERS reader (_parent_settings, _parameter_entries, _single_quoted), and with it r1169's NIT-2 and NIT-6.
  • Why check-attr is asked without --cached (r1169b's SHOULD-1; ruling X1). r1169b measured with git 2.43, in a repository whose side tree says * text eol=lf: under attr.tree, check-attr --cached text eol -- notes.txt gives unspecified for both, and without --cached it gives text: set, eol: lf; the same in a commitless throwaway that borrows the source's objects, and in the parameters form. So a file only that tree's rules reach was left out of the files the rehearsal asks about, and check said plan ok where the split's checkout and git add, which do read attr.tree, change the file (P3, P5). Without --cached, git's check-in direction reads a work-tree .gitattributes first; _rules_reaching asks before any checkout or place() writes one, so git falls back to the index, and the answers with no setting do not change (the no-setting controls; the gate below). --cached was also the cause of P7's second, misnamed finding: with it restored, (k) fails in all three forms (M1 below), as it does on 25805102.
  • Why GIT_ATTR_SOURCE is refused, not inherited. Measured on 2.43, no throwaway can answer for it. An abbreviated source-tree oid gave plan ok and execute stopped at pushing the spec leg after both legs existed. A commitless repository that borrows the source's objects, as the rehearsal does, takes that value (check-attr exits 0); one that does not borrow them refuses it (exit 128). So inheriting would still say plan ok where execute stops. HEAD resolves in the source yet stops execute at seeding the code leg, so a "bad value" question put to the source would fail too. The existing code adopt-git-unasked carries it, so there is no new code and no doc trio.
  • Why _AttributesProbe takes no parent settings. It models a clone on another machine, which has none of them. It still keeps GIT_CONFIG_COUNT: that predates this PR and is left alone (a known limit only).

Tests

At head 1e958ca6 (the head was then 76c9b767, a comment-only commit that touches no test; the restack's fe25bb14 is under "The restack"). In tests/test_adopt_check_throwaway_repos.py (+630/-5 against bba168bd, and the same against main at fe25bb14; +674/-5 with row (l) below, at d9224ecf), the section # #1136 holds 56 ids, and the existing test_an_environment_read_that_raises_leaves_no_throwaway gains 3 params: 59 new ids, 17 of them from this round, (j) and (k); row (l), added after r1169d's review, makes 62. Rows that need a feature of git skip, with a reason, where probing git itself shows it is missing (a git that reads no attr.tree; one that holds the U+200C path under core.protectHFS); the real-execute rows skip where git filter-repo is not installed.

  • (a) core.protectHFS=true over the U+200C path: plan-path-git-refuses from a parent's -c, and execute refuses before any leg; with the no-setting control.
  • (b) a parent's clean filter: plan-attributes-reencoded, and execute refuses before any leg.
  • (c) test_a_parent_gits_attr_tree_is_read_as_the_split_reads_it: under attr.tree=<empty tree>, plan ok in the global, parameters and count forms, and plan-file-reencoded with no setting. test_execute_reads_a_parent_gits_attr_tree_as_check_does (p8) runs a real execute under the same: adoption verified in the three forms, and REFUSED plan-file-reencoded with no setting, remotes/ absent. Each first asks the source's own check-attr eol: unspecified under the setting, lf without.
  • (h) p2/p7: test_a_parent_gits_attr_tree_is_read_where_the_root_is_stored (global, parameters, count): one plan-attributes-reencoded naming clean filter 'w1136' failed; test_execute_refuses_a_parent_gits_attr_tree_filter_before_any_leg (the three forms); and the control test_the_failing_filter_alone_is_read_for_no_file, plan ok. Each first asks the source's own check-attr filter -- .gitattributes: w1136 under the setting. The source's root says *.png binary, which names none of its files; (k) has *.md text there.
  • (j) r1169b's P3 and P5: test_a_file_only_a_parent_gits_attr_tree_reaches_is_asked_about (no setting, global, parameters, count; each for a CRLF notes.txt sent to the spec leg and kept under a root *.png binary; 8 ids): plan-file-reencoded naming notes.txt under the setting, plan ok with none. test_execute_refuses_a_file_only_a_parent_gits_attr_tree_reaches (the three forms, both cases; 6 ids): execute refuses plan-file-reencoded before any leg, and remotes/ is absent. The side tree says * text eol=lf; each row first asks the source's own check-attr eol -- notes.txt: lf under the setting, unspecified without.
  • (k) adopt-project.py: under a failing clean filter that a rule names for .gitattributes, check also prints plan-file-reencoded for a file the split stores as its blob, beside the right plan-attributes-reencoded (#1136) #1192's case, r1169b's P7: test_a_parent_gits_attr_tree_filter_is_one_finding_over_an_own_rule (the three forms; 3 ids): the side tree of (h) over a root *.md text, one plan-attributes-reencoded naming clean filter 'w1136' failed and no plan-file-reencoded.
  • (l) r1169d's SHOULD-1, added at d9224ecf: test_a_pinned_copy_under_a_parent_gits_attr_tree_unsets_the_trees_rule (the three forms; 3 ids). A side tree says /scripts/*.py filter=w1136upper, an upper-casing clean filter, over a source root that says /scripts/*.py ident, and attr.tree names the tree. Each row first asks the source's own check-attr filter ident -- scripts/bootstrap.py: w1136upper and unspecified under the setting. The copies finding (plan-file-reencoded, "store 5 pinned shape copies") then names -filter for each of the 5 copies and no -ident: the split's git add of the copies reads the tree's rule and not the root's. Head: 3 passed, no skip; with M3 below, 3 failed (assert '/scripts/validate-pins.py -filter' in "... -ident ..."). r1169d measured it on 25805102, which names -ident as M3 does: 3 failed; and on bba168bd: the global form failed the same way, the parameters and count forms said plan ok.
  • (d) a parent's core.hooksPath and core.fsmonitor run 0 times where the rehearsal and _stored_as_written ask (global config, parameters and count), with the git add control in the same row.
  • (e) GIT_ATTR_SOURCE is one adopt-git-unasked finding that names it, for 7 values (bogus, empty, HEAD seeded, the abbreviated tree seeded, HEAD and the full tree as the known limit, and the empty tree), ending with its own words (ATTR_SOURCE_ENDS, r1169's NIT-3); execute refuses the first 3 before any leg, with the same ending.
  • (i) r1169's NIT-1: test_git_attr_source_is_refused_before_the_splits_own_clone_is_asked records _split_clone_asked: under GIT_ATTR_SOURCE, execute refuses adopt-git-unasked, the recorder stays empty, the work directory holds nothing, and remotes/ is absent.
  • (f) the three new params, _AttributesProbe, _stored_as_written and _SplitRehearsal, with _inherited_env and _split_checkout_env both made to raise.
  • (g) test_the_checkout_questions_take_the_settings_and_no_attr_source: _split_checkout_env() is this process's environment less the six variables of ANOTHER_REPOSITORY (GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_COMMON_DIR, GIT_OBJECT_DIRECTORY, GIT_ATTR_SOURCE), with GIT_CONFIG_PARAMETERS and the GIT_CONFIG_COUNT entries as they stand, attr.tree in each; with no GIT_CONFIG_PARAMETERS there is none. The first round's 13 parser rows went with _parent_settings. Every setting a row gives is appended after what the environment holds, never overwritten, so tests/test_no_auto_maintenance.py still holds.

Older rows changed in other files (outside the one test file the plan named; the change forces them):

  • The first round dropped six GIT_ATTR_SOURCE params (HEAD, and the empty tree's oid) from test_adopt_nested_attributes.py (two functions) and test_adopt_reencoded_files.py (one): the head refuses GIT_ATTR_SOURCE first, so each found adopt-git-unasked where the older finding stood. Each docstring points to test_git_attr_source_is_one_finding_that_names_it, which holds HEAD and the empty tree.
  • The second round flipped the two attr-tree-in-the-command-scope rows, which asked that the count-form empty tree NOT be read and found the file: test_adopt_reencoded_files.py's, now test_a_tree_this_machine_names_steers_the_question_as_it_steers_the_split, and test_adopt_nested_attributes.py's split-question row, now test_a_tree_this_machine_names_steers_the_split_question_as_the_split. Each now expects plan ok, with a no-setting control in the same row that finds the file. Measured on the head tool with a real execute, under that setting each source gives adoption verified, and with none execute refuses before any leg. test_command_scopes_built_at_the_test.py's SITES names the reencoded row by its new name. The nested-question row (test_no_tree_this_machine_names_steers_the_question) is unchanged: _AttributesProbe still takes no attr.tree.

Base vs head, 67 ids: the 50 of the earlier rounds (the 42 new then, row (f)'s 3 older params, the clone row (its six variables are now the shared ANOTHER_REPOSITORY), the 2 flipped rows and SITES' 2) and the 17 of (j) and (k). The head's tests ran over each tool in a scratch clone:

  • bba168bd: 49 failed, 18 passed (101.9 s). Over the 50 ids: 34 failed, 16 passed (58.6 s). Failed: (f) all 6, at the new monkeypatch.setattr(adopter, "_split_checkout_env", ...), which finds nothing to set there, so bba168bd does not measure (f) (the first round measured its 3 new params leaving a throwaway behind there, and its 3 older ones passing); (a) and (b), the parameters form, check and execute (4); (c) and p8, parameters and count (4: plan-file-reencoded, refused); p2/p7 in all three forms (3: plan ok) and their execute rows (3: no refusal); (e) 7 + 3; (i); (g), with no _split_checkout_env; the two flipped rows (each found the file). Passed: the clone row; (a) and (b) count, check and execute (4), and the (a) control; (c) and p8, no-setting and global (4); the filter control; (d) all 3; SITES 2. Over the 17 ids: 15 failed, 2 passed (the 2 no-setting controls of (j)); P7: global plan-file-reencoded alone, parameters and count plan ok; P3 and P5: check plan ok in all six, and execute not refused (both legs pushed).
  • b8e63bcf (the first round), over the 50 ids: 23 failed, 27 passed (56.0 s). Failed: (c) and p8, parameters and count (4, r1169's p8); p2/p7 and their execute rows (6, r1169's p2 and p7, the global form included); (e)'s 7 on the ending and its 3 execute rows on the same (r1169's NIT-3); (g), whose GIT_CONFIG_PARAMETERS lacked attr.tree; the two flipped rows. Passed: (f) all 6, and (i), which is a guard there (b8e63bcf asks in this order already; M6 below is what it kills). The 17 were not run on b8e63bcf.
  • 25805102: 15 failed, 52 passed (92.9 s). The 50 pass. Of the 17, the same 15 fail and the 2 controls pass: P7 gives plan-attributes-reencoded and also plan-file-reencoded in all three forms; P3 and P5: check plan ok in all six, and execute not refused (both legs pushed).
  • Head: 67 passed (inside G2 and Gf2 below), and 67 passed under a 3.14-like shim (88.4 s; Path.exists.__qualname__ read _quiet.<locals>.quiet, so the shim was active).

Mutants at head 1e958ca6: 2 killed of 3, and M3 survived; with row (l), M3 is killed too, 3 of 3. The head was then 76c9b767, which changes a comment and no code, and the restack's fe25bb14 writes no line of this PR, so they were not run again. Run in a scratch clone over the 67 ids, without -x; each file was restored in a finally, and its blob was HEAD's after each:

  • M1, --cached restored in _SplitRehearsal.reached (both calls): killed, 15 failed (the 15 rows of (j) and (k) with a setting).
  • M2, --cached for the first _rules_reaching call only (the source's own rules): killed, 9 failed (P7 in three forms; P3, the file sent to a leg, in three forms, check and execute). P5, a kept file, is still found by the second call.
  • M3 (r1169d's K3), --cached for the second call only (the split's root): survived the 67 ids, and it is not equivalent (r1169d's SHOULD-1). Under an attr.tree the first call already reads that tree for every file, so reached | (kept & at_the_root) can only grow, by files asked about needlessly, which the real git add then stores as their blobs; with no attr.tree the work tree is empty when _rules_reaching asks, so both forms read the index. But M3 does change at_the_root, which _copies_finding reads for the unset line of a pinned copy under an attr.tree (r1169c's 33 probes per tool: 12 differ, 21 are byte-equal): with the side tree saying /scripts/*.py filter=... and the source's own root saying nothing or a neutral rule, head names /scripts/validate-pins.py -filter for the copy and M3 says "a line that keeps the rule off scripts/validate-pins.py"; with the own root saying /scripts/*.py ident, head names -filter, the tree's rule, which the split's git add reads, and M3 names -ident, as 25805102 does, which git does not read there. Row (l) holds it: M3 fails its 3 ids of 3 (measured at fe25bb14 plus the row, in a scratch clone, by adding a cached argument to _SplitRehearsal.reached, which puts --cached in the check-attr call, and passing cached=True at the AS_SPLIT call; adopt-project.py's blob was HEAD's after the restore).
  • r1169d's K4 to K6, --cached only where GIT_CONFIG_PARAMETERS, a GIT_CONFIG_KEY_<n> or a global config file names attr.tree: killed by (k), in [git-config-parameters], [git-config-count] and [global-config].
  • r1169d's K7, every .gitattributes checked out before _rules_reaching runs, which breaks the precondition reached's docstring names: not equivalent, and killed by two rows, though it survived the 129 ids of the throwaway file. Over the nested and reencoded files it gave 2 failed, 124 passed: test_adopt_nested_attributes.py::test_an_encoding_the_root_attributes_is_not_in_is_found (assert set() == {'plan-file-reencoded'}: the root's working-tree-encoding=UTF-16LE copy, checked out, gives git no rules) and test_adopt_reencoded_files.py::test_every_file_is_read_again_whatever_the_clock (.gitattributes ctime).
  • r1169d's K8, --cached where attr.tree is unset: equivalent. It passed all 255 ids (129, and 126 over the nested and reencoded files). With no attribute source, check-attr without --cached reads a .gitattributes in the work tree first, and the index otherwise; the work tree holds none when _rules_reaching asks (the precondition K7 breaks, which those two rows hold). The only other attribute source, GIT_ATTR_SOURCE, never reaches the rehearsal: _NOT_INHERITED names it, _split_checkout_env drops it (row (g)), and _attr_source_unasked refuses it before any throwaway is made (rows (e) and (i)).
  • Tally with r1169d's K1 to K8: 6 of 8 killed by the rows before row (l), K3 by row (l), K8 equivalent: 7 of 7 non-equivalent killed.
  • f1169's 13 older mutants were not re-run at this head. They ran at 948d1771, whose tool is 25805102's, so with --cached in reached; since then the tool changes by that argument, two docstrings and, at 76c9b767, one comment.

The 13 older mutants: 13 killed of 13, no survivor. Run in a scratch clone at 948d1771 (the tests there differ from 25805102 in one row's name, one docstring and the ATTR_SOURCES comment) over the 50 ids with -x; each file was restored in a finally, and its blob was HEAD's after each. Each was killed by the first row named:

  • r1169's M6, _attr_source_unasked after _split_clone_asked: (i). r1169's M10 (_PARAMETER_SPACE) is moot: that reader is gone.
  • the count form's attr.tree dropped again (_split_checkout_env as _split_clone_env()): (c) [git-config-count];
  • no _objects_borrowed in _stored_as_written: p2/p7 [global-config];
  • GIT_CONFIG_PARAMETERS dropped from _split_checkout_env: (a) [git-config-parameters];
  • the environment read after _throwaway, in _stored_as_written, _SplitRehearsal and _AttributesProbe (three mutants): (f) [stored-as-written], [split-rehearsal], [attributes-probe];
  • the shared ending for GIT_ATTR_SOURCE: (e) [bogus];
  • extras: no _attr_source_unasked call: (e) [bogus]; an empty GIT_ATTR_SOURCE allowed: (e) [empty]; no _objects_borrowed in the rehearsal: (a)'s control; _split_checkout_env keeping GIT_ATTR_SOURCE, and returning the whole environment (two mutants): (g).

The gate at 1e958ca6 (the head was then 76c9b767, a comment-only commit; the one call marked below ran at it; the calls at the restack's fe25bb14 are under "The restack"), in the worktree, CPython 3.12.3, git 2.43, in the probe environment (a stub gh, GH_TOKEN=bogus-f1169d, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false), each call under 9 minutes and at most two at once, node ids passed line by line. The last row ran in a scratch clone at the merge with #1180:

Call Files Result
G2 test_adopt_check_throwaway_repos.py 129 passed (182.9 s), no skip
Gf2 test_adopt_nested_attributes.py, test_adopt_reencoded_files.py, test_command_scopes_built_at_the_test.py, test_adopt_printed_sentences.py (it imports from the reencoded file), test_path_guard_hygiene.py, test_no_auto_maintenance.py, test_validator_child_encoding.py 452 passed, 2 skipped (338.4 s). Both skips are test_no_auto_maintenance.py:222: "the session began with GIT_CONFIG_GLOBAL set, and its file includes that one", which the probe environment's GIT_CONFIG_GLOBAL=/dev/null causes; they do not come from this PR
G4 test_scaffold_leg_path_collisions.py 104 passed (4.9 s)
G3 test_adopt_root_symlinks.py 205 passed, 3 skipped (350.4 s). The 3 skips: "the reftable backend needs git 2.45 or later" (this bench has git 2.43)
D0 test_shape_doctor.py, shard 0 of 4 (112 ids) 111 passed, 1 skipped (175.9 s), the PowerShell skip: "the PowerShell line is printed, and powershell.exe exists, only on a Windows host"
D1 test_shape_doctor.py, shard 1 of 4 (112 ids) 112 passed (158.3 s)
D2 test_shape_doctor.py, shard 2 of 4 (112 ids) 111 passed, 1 skipped (201.1 s), the same PowerShell skip
D3 test_shape_doctor.py, shard 3 of 4 (112 ids) 111 passed, 1 skipped (184.8 s), the same PowerShell skip
G9 test_adopt_submodule_url_answers.py, test_adopt_manifest_values.py, test_adopt_relative_submodule_url.py (the three that call _entry_findings directly) 138 passed (225.7 s), no skip
H2 test_repo_hygiene.py and test_path_guard_hygiene.py at 76c9b767, in the same probe environment (GH_TOKEN=bogus-w1169c); test_repo_hygiene.py is one of the doc trio, and the other two did not run, as no docstring that reaches --help changed 149 passed (14.1 s)
merged with #1180 the 67 ids and test_adopt_partial_clone_source.py (37 ids), on the merge of this head with #1180's b316f415 (Known limits) 104 passed (115.0 s)

The doctor's 4 shards are 448 ids (from a fresh collect): 445 passed, 3 skipped.

The gate files named by what the change touches: adopt-project.py (the doctor shards and the scaffold collisions file), the symbols it names (GIT_ATTR_SOURCE, _inherited_env, _AttributesProbe, _SplitRehearsal, plan-path-git-refuses: a git grep finds only the files above), the three files that call _entry_findings directly, before which _attr_source_unasked now runs, the file that imports from a changed test file, and the hygiene and config gates. No doc, code or --help text changed, so the doc trio did not run.

The restack, at fe25bb14 (three merges and no line written; w1169e, Claude Sonnet 5.5). The real base is bba168bd (git merge-base 76c9b767 e0a98f13, where e0a98f13 is a later head of #1119, between bba168bd and its final head 9b856443); the old head is 76c9b767.

Call Files Result
R1 the four test files of the diff: test_adopt_check_throwaway_repos.py, test_adopt_nested_attributes.py, test_adopt_reencoded_files.py, test_command_scopes_built_at_the_test.py 276 passed, 1 skipped (372.7 s). The skip is test_on_a_disk_that_ignores_case_each_twin_is_held_or_named, "needs a disk that ignores case" (a row #1119 added; this bench's disk keeps case); it does not come from this PR
R2 test_no_auto_maintenance.py 58 passed, 2 skipped (12.4 s). The 2 skips are the GIT_CONFIG_GLOBAL ones the gate above explains

The doctor's shards, test_scaffold_leg_path_collisions.py, test_adopt_root_symlinks.py and the hygiene files did not run again at fe25bb14: their calls are above, at 1e958ca6. Row (l) then makes the head d9224ecf, one commit on fe25bb14: +44/-0 in tests/test_adopt_check_throwaway_repos.py and no other file; its 3 ids ran at fe25bb14 plus the row (and under M3, below), and R1 did not run again with it.

The full suite did not run here, and CI's CPython 3.14 did not run (this bench has 3.12.3 only; the 3.14-like shim ran the 67 ids). A grep of the new rows finds no ast.dump, repr( or Python version test; the git answers they compare with are read at run time (check-attr, git add in a repository of their own).

Known limits

  • A valid GIT_ATTR_SOURCE is refused too (lane 1's ruling: accepted). HEAD with no seeded code leg, and a full tree oid, took execute to adoption verified on base (rows (e) hold them); now check says adopt-git-unasked and execute refuses. It is the only rule with no plan ok where execute stops, for a variable the split has no use for; the way out is to run without it, which the finding says (and that a git --attr-source running this tool also sets it).
  • _AttributesProbe keeps GIT_CONFIG_COUNT (lane 1's ruling: a known limit only). It models a clone on another machine, yet still reads GIT_CONFIG_COUNT; that predates adopt-project.py: #910's throwaways leave out a parent's -c settings and GIT_ATTR_SOURCE, so check says plan ok where execute stops after both legs, and three read the environment after making the throwaway (follow-up of #1083) #1136 and is not changed here.
  • A name in attr.tree. HEAD or a branch names nothing in a throwaway, which holds no ref, so git reads the files' own rules there, as with no setting (measured with git 2.43). In the split's clones HEAD, a tag and the split's own branch resolve; another source branch is held there only as origin/<name>, so attr.tree=<that branch> names nothing there either (r1169b's NIT-1: measured in a plain local clone, side read no rule and origin/side read them; HEAD and a tag were not run through the tool). An object id, the empty tree included, is read as the split reads it (rows (c), (h), (j), (k)).
  • The findings point at the source's own rules. Under a parent's attr.tree, plan-file-reencoded still ends "Commit it in the source as git stores it ..., or change the rule that reaches it", where the rule is in the tree attr.tree names and the source holds none for the file (r1169b's NIT-2). The finding is right; the advice does not name the setting. Under a parent's attr.tree the copies finding (plan-file-reencoded, "store 5 pinned shape copies") names the right attribute but puts the lines in the source's root .gitattributes, which git does not read there; following it gives the same finding again (r1169d's NIT-1: with the own root holding *.png binary plus the five /scripts/<copy> -filter lines the finding asks for, and the side tree's filter rule read through attr.tree, head gives exit=1 with the same finding, asking for the same five lines, in all three forms; with the same rule in the own root, followed by the five lines, and no attr.tree, plan ok). 25805102 loops the same way, with the generic line, so it is not new here. Row (l) checks only which attribute is named, so it holds after any rewording of this ending. Not changed here.
  • A misnamed second finding wherever both rules are read from one place. With both rules in the source's own root .gitattributes, which says /.gitattributes filter=w1136 and *.md text (either order), the failing filter in the global config, GIT_CONFIG_PARAMETERS or GIT_CONFIG_COUNT, and no attr.tree, check says the right plan-attributes-reencoded and also plan-file-reencoded naming specs/001-feature/spec.md, a file the split stores as its blob. Measured on this head and on 25805102, in all six (three forms, two orders); on bba168bd the global and count forms gave the same two findings, and the parameters form plan ok. With the filter alone, plan-attributes-reencoded comes alone everywhere (plan ok on bba168bd's parameters form). The same comes when both rules are in the side tree attr.tree names (r1169d, measured at 76c9b767 with git 2.43: the source's own root says *.png binary, the side tree says /.gitattributes filter=w1136 and *.md text, and attr.tree names that tree with the failing filter): in each of the three forms, head gives exit=1 with plan-attributes-reencoded and also plan-file-reencoded naming specs/001-feature/spec.md; 25805102 gives plan-attributes-reencoded alone; bba168bd gives exit=0, plan ok. The control, the tree's *.md text alone with no filter, is plan ok on head, 25805102 and bba168bd in all three forms, so spec.md (# The feature, LF only) is stored as its blob and the second finding names a file that is not reencoded. This case comes with ruling X1: the read without --cached now reaches spec.md through the tree's *.md text, so the file is asked about, where base said plan ok. Either way check refuses with the right first finding, and execute refuses before any leg, so it is no false plan ok. The cause is traced (r1169c's expB, git 2.43): under the failing required filter, the rehearsal's asked.git(["update-index", "-q", "--refresh"], TAKEN), whose exit code is never read, exits 128 with fatal: .gitattributes: clean filter 'w1136' failed; the diff-files --name-only after it exits 0 and lists .gitattributes specs/spec.md, the files it did not refresh; with no filter, the refresh exits 0 and diff-files lists nothing. The own-rules case predates this PR, which only lets its parameters form reach it; the side-tree case does not predate it, because this PR's read without --cached brings it in. adopt-project.py: under a failing clean filter that a rule names for .gitattributes, check also prints plan-file-reencoded for a file the split stores as its blob, beside the right plan-attributes-reencoded (#1136) #1192's attr.tree case (P7: the filter in the side tree and *.md text in the source's own root) is the two-places case, not this one: --cached made the check read the own root's *.md text, which git does not read under attr.tree, so spec.md was asked about and the refresh's list named it; without --cached it is not asked about, and the one finding stays (row (k)).
  • An org/repo source not cloned yet (r1169's NIT-4). gh repo clone checks out in the whole environment, so under GIT_ATTR_SOURCE=bogus or an empty value the clone is refused as source-unclonable, whose remediation names gh auth status, before _entry_findings asks anything; HEAD clones (r1169's probe p5, with plain git clone; the tool path read, not run). It is no false plan ok.
  • adopt-project.py check: a partial-clone source missing blobs that check reads is fetched into, so check's own reads run the upload-pack its config names and write a promisor pack into the source #1145 overlap. adopt-project.py: check and execute run with GIT_NO_LAZY_FETCH=1 and refuse a partial-clone source that lacks an object its branches, tags or HEAD reach as source-partial-clone, so neither fetches into the source any more (#1145) #1180 (fix/1145-partial-clone-source-no-fetch) was also stacked on bba168bd, at head b316f415; it has been restacked onto main since, and its head is a0e8cded. Before the restacks: git merge-tree --write-tree --merge-base=bba168bd 1e958ca6 b316f415 exits 0 with tree 1a9ec825 (no conflict: adopt-project.py: check and execute run with GIT_NO_LAZY_FETCH=1 and refuse a partial-clone source that lacks an object its branches, tags or HEAD reach as source-partial-clone, so neither fetches into the source any more (#1145) #1180 touches adopt-project.py, docs/cli.md and its own test file). On that tree, in a scratch clone, this PR's 67 ids and adopt-project.py: check and execute run with GIT_NO_LAZY_FETCH=1 and refuse a partial-clone source that lacks an object its branches, tags or HEAD reach as source-partial-clone, so neither fetches into the source any more (#1145) #1180's test_adopt_partial_clone_source.py (37 ids) gave 104 passed (115.0 s) (r1169b's NIT-3). The head was then 76c9b767: the same git merge-tree command with it in place of 1e958ca6 exits 0 with tree ee59fb02, which differs from 1a9ec825 only in the one comment of adopt-project.py that this commit changes (git diff --stat of the two trees: 1 file, +2/-3); the 104 were not run again on it. After both restacks, with both branches on main 50ce8ab1: git merge-tree --write-tree fe25bb14 a0e8cded exits 0 with tree ac442d4c (no conflict: adopt-project.py: check and execute run with GIT_NO_LAZY_FETCH=1 and refuse a partial-clone source that lacks an object its branches, tags or HEAD reach as source-partial-clone, so neither fetches into the source any more (#1145) #1180 touches adopt-project.py, docs/cli.md and tests/test_adopt_partial_clone_source.py); the 104 were not run again on it either.

Not in this PR

  • No _AttributesProbe change for a parent's settings (it models another machine), and no change to _split_clone_env, which hands the split's clone the settings whole and was only reworded in one docstring sentence.
  • No inheriting of GIT_ATTR_SOURCE into any throwaway, and no new code, doc, --help text or doc-trio row.
  • No change to execute's own commands: they already run in the whole environment.
  • No change to the rehearsal's refresh under a failing filter (the misnamed second finding above, wherever both rules are read from one place: the source's own root, or the side tree attr.tree names, which this PR's read without --cached brings in).
  • No change to the findings' wording under a parent's attr.tree (the limit above).

Lane: openreposhape-1 (openRepoShape-1)

🤖 Generated with Claude Code

Summary by Sourcery

Make adoption planning match execution under inherited Git configuration and refuse unsupported attribute-source settings before any repository work begins.

Bug Fixes:

  • Make check accurately model the parent Git settings and attribute trees used by execute, preventing plans that would fail during checkout or staging.
  • Refuse any GIT_ATTR_SOURCE setting before asking further questions or creating adoption legs.

Enhancements:

  • Align attribute reachability checks with Git's non-cached checkout and add behavior, including source-object access for attr.tree object IDs.
  • Ensure throwaway repositories read their environment before creation so environment failures leave no temporary repositories behind.

Tests:

  • Add coverage for inherited configuration, attr.tree, GIT_ATTR_SOURCE, environment-read ordering, and early execution refusal behavior.

Brett Heap and others added 12 commits October 21, 2026 12:58
…` of the source in the work directory, and `_resolved` and `_answers` make their repositories as #910's throwaways are made (#1083)

Work in progress for #1083, stopped at a usage checkpoint.

Built: `_split_clone_asked` (asked first in `_entry_findings`; a failure is
`adopt-git-unasked`, which `execute` refuses before any leg); `_resolved`
and `_answers` made with `_throwaway`/`_throwaway_made` in the work
directory, no hooks, `--template=<empty>`, `-b`, the source's format,
under `_inherited_env()`; `_throwaway_git`; `_submodule_findings` skips the
url questions once one throwaway could not be asked. 16 rows added or
changed in tests/test_adopt_check_throwaway_repos.py (45 rows: all pass on
this head; 14 fail and 31 pass over 2a119f2's adopt-project.py).

Left: the TMPDIR docstring in tests/test_adopt_reencoded_files.py, the
gate, and the PR body.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e_checkout_writes_is_never_written_through` says the throwaway repository is made in the work directory, as #910 makes it, and no longer in `TMPDIR` (#1083)

The last item of #1083's (c): the docstring still said the throwaway
repository the link is asked in is in `TMPDIR`. Since #910 it is made in
the work directory, which is as deep below `tmp_path` as the `TMPDIR` the
row sets, so the row asks the same question and only its words change.
(The issue's `adopt-project.py:466` names `_pin_row_problem` at 74c1770;
this docstring is the one PR #1031's body named.)

Gate on the merged tree (main 6da757c): see the pull request body.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… parent git hands it, as execute's does, and no variable naming another repository; _resolved and _answers read their environment before their throwaway (#1083)

r1119's fix round on #1119.

SHOULD-2: `_split_clone_asked` makes the clone in `_split_clone_env()`:
`_inherited_env()` plus `GIT_CONFIG_PARAMETERS` where it is set. Measured on
bdcf995 with git 2.43, `GIT_CONFIG_PARAMETERS="'init.defaultbranch'='a..b'"`
gave `plan ok` from `check`, and `execute` made both legs and stopped
extracting the spec leg. Now `check` says `adopt-git-unasked` and `execute`
refuses before any leg, as for the setting in a config file or in
`GIT_CONFIG_COUNT`. A parent's `-c core.hooksPath` runs no hook: the clone's
own `-c` comes after it.

SHOULD-1: a row runs `check` under `GIT_OBJECT_DIRECTORY` naming the source's
own objects and holds the source's object directory unchanged. It kills M6:
in the whole environment the clone wrote `objects/info/alternates` into the
source. A row under `GIT_DIR` naming the source, whose own config rewrites
the on-disk url, kills M14 (`_answers` in the whole environment) and holds
the source's config unchanged.

NIT-1: the docstring says git ignores `--shared` for a shallow source, so
that clone is a full copy, a cost `execute`'s own clones pay too.
NIT-2: the unmade-work-directory NOTE says it asked nothing about the urls of
the submodules the plan keeps either.
NIT-3: `_resolved`, `_answers` and the clone read their environment before
they make their throwaway, so a raise there leaves none behind.

tests/test_adopt_check_throwaway_repos.py: 63 rows (45 before), 18 new.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….fsmonitor=false`, and the rows r1119b named pass on main: the clone-env row appends to the environment's count, and the ref-probe row fails only the probe's `git init`

r1119b's second review of #1119, after the merge of main 038dafb (#1091, #1111):

- MUST-1: `test_the_clone_takes_a_parent_gits_settings_and_no_other_repository`
  built its `GIT_CONFIG_COUNT` from `{}`, which
  `test_no_test_sets_a_git_config_count_of_its_own` refuses. It now appends
  to `os.environ`, and asserts the last entry kept and no `attr.tree`.
- MUST-2: main's `test_check_says_where_git_would_not_make_the_ref_probe`
  set `GIT_DEFAULT_HASH=bogus`, which now stops the split's own clone
  first (`adopt-git-unasked`, exit 1). A stub `git` first on PATH fails
  only `git init` of a `refs-probe-*` directory, with git's words; skipped
  on Windows.
- SHOULD-A: `_throwaway_made` returns `-c core.fsmonitor=false` beside
  `core.hooksPath`, as the rehearsal and `_stored_as_written` give theirs.
  A counting fsmonitor program ran 8 times in `_resolved`'s two
  repositories in one `check`; now 0, in the global config and in
  `GIT_CONFIG_COUNT`. New rows: that one and its control.
- NIT-A: a row with `--work-dir` inside the source, whose config rewrites
  the url (kills R8).
- NIT-B: a row with that rewrite handed down by a parent git in
  `GIT_CONFIG_PARAMETERS`, which `_answers` leaves out (kills A18).
- NIT-C: `_split_clone_asked`'s docstring says a shallow source makes the
  clone run the `uploadpack.packObjectsHook` of protected config, as
  `execute`'s clones do.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inside the source also rewrites the url to a network, so a `--get-url` asked around the throwaway, which names it in a NOTE as not asked, fails it

f1119c's mutant R8a, which moved only `_answers`' `git ls-remote
--get-url` out of its throwaway repository into the throwaway's own
directory, survived the row that kills r1119b's R8: the expansion there
only decides between the disk and a network, and the url itself was
still asked in the throwaway. A source config that rewrites the on-disk
url to `https://f1119c.invalid/Thing` makes R8a print the NOTE of a url
on a network it did not ask; on this head `check` prints none, inside
the source or outside it. Now killed by the row's `a-network` case.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r system config as where the `uploadpack.packObjectsHook` a shallow source's clone runs comes from, since a parent's `-c` did not reach it

cea2553's NIT-C sentence said "protected config ... or a `-c`". f1119c
measured it on git 2.43, with a shallow source and a counting hook:

- in the global config, 1 run in `check` and 1 in a clone made as
  `_extract_leg` makes it;
- in the source's own config, 0 and 0;
- in `GIT_CONFIG_PARAMETERS`, as a parent `git -c` hands it down, 0 and 0.

So the `-c` is dropped. The commit message of cea2553 says "of
protected config" for the same sentence; this is the measured form.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd _stored_as_written, refuses GIT_ATTR_SOURCE by name, and reads each throwaway's environment first (#1136), no tests yet

Work in progress for #1136, stacked on #1119 (fix/1083-check-asks-the-split-clone).

Holds the tool change for all three items:
- _split_checkout_env(): _inherited_env() plus a parent's GIT_CONFIG_PARAMETERS
  less any attr.tree (_parent_settings, _parameter_entries, _single_quoted,
  read as git's parse_config_env_list reads it); taken by _SplitRehearsal
  and _stored_as_written.
- _attr_source_unasked(): any GIT_ATTR_SOURCE in the environment is
  adopt-git-unasked, by name, before the split's clone and every other
  throwaway question, so execute refuses it before either leg.
- _AttributesProbe, _stored_as_written and _SplitRehearsal read their
  environment before _throwaway (_probe_env now takes it as an argument).

Measured on git 2.43 with the scratch harness, base bba168b vs this tool
(check / execute): the parent protectHFS case and the parent clean-filter case
went from plan ok / stop after both legs to plan-path-git-refuses and
plan-attributes-reencoded, refused before any leg; every GIT_ATTR_SOURCE
value went to adopt-git-unasked / refused before any leg; controls unchanged.

NOT YET: no new test rows, no mutants, no gate run, no pr-body. No test
has been run on this commit.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ows (45, passing on the head tool); the older GIT_ATTR_SOURCE rows in two other files are not updated yet

The new `# #1136` section covers a parent's `core.protectHFS` and failing
clean filter in both forms (check and execute), a parent's `attr.tree`
left out of the rehearsal, a parent's hooks and fsmonitor running
nothing, `GIT_ATTR_SOURCE` as one `adopt-git-unasked` (seven values) and
execute's refusal before any leg (three values), `_parent_settings`
against git's own `--list`, and three more environment-read rows. They
passed on 3b308d3's tool: 45 passed in 52 s.

Not yet done: the GIT_ATTR_SOURCE params of
`test_no_tree_this_machine_names_steers_the_question` (two files) and
`..._steers_the_split_question` still expect a finding; base runs,
mutants and the gate have not run.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t by name before any question; the `attr.tree` rows stay, and each docstring points to `test_git_attr_source_is_one_finding_that_names_it`

`test_adopt_nested_attributes.py`'s two `test_no_tree_this_machine_names_*`
functions and `test_adopt_reencoded_files.py`'s one each set
`GIT_ATTR_SOURCE` to `HEAD` and to the empty tree and expected the finding
their question gives. The head tool answers each with one
`adopt-git-unasked` first, so all six failed there (measured at b3b48b3:
6 failed, each on `FINDING adopt-git-unasked`). Both values are held by
`test_adopt_check_throwaway_repos.py`'s
`test_git_attr_source_is_one_finding_that_names_it`.

Run after the change: test_adopt_nested_attributes.py 75 passed,
test_adopt_reencoded_files.py 51 passed.

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

sourcery-ai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Implementation

Make the rehearsal and stored-as-written checks mirror the split’s checkout environment, borrow the source’s objects, refuse GIT_ATTR_SOURCE before asking questions, and read throwaway environments before creating them.

  • adopt-project.py L11418-11429, L11646-11755, L12745-12756 (+21 small edits)

Tests

Update regression coverage for inherited settings, attribute trees, early GIT_ATTR_SOURCE refusal, environment-read ordering, and related existing attribute-question expectations.

  • tests/test_adopt_check_throwaway_repos.py L54-75, L1491-1499, L1686-2318 (+3 small edits)
  • tests/test_adopt_nested_attributes.py L433-437, L639-660 (+2 small edits)
  • tests/test_adopt_reencoded_files.py L436 (removed lines), L438-462
  • tests/test_command_scopes_built_at_the_test.py L33-34

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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-22T21:25:15Z) Independent review r1169 of #1169 at b8e63bcf, partial, stopped at the shared window's STOP: NOT READY, for one SHOULD.

Lane 1 rules on SHOULD-1 and runs the fix round in a later window.

PR #1169 review by r1169: verdict NOT READY

I stopped early on lane 1's STOP. No run of mine is still live: every run was a foreground call and all of them finished. The mutant clone's adopt-project.py was put back after each mutant, and its blob matched HEAD's after the last one (M11, restored=True). The review checkout /workspace/projects/openRepoShape-worktrees/l1-r1169 was never written to.

The verdict rests on SHOULD-1. If lane 1 rules that gap a known limit, and the body and the row (c) docstring are corrected, I would expect READY on the parts I covered.

What I measured that matches the PR body

  • Base against head, the 45 ids. On head, 45 passed (31.7 s). With the base tool (bba168bd) under the head tests, 31 failed and 14 passed (42.6 s).

    • The failures group exactly as the body says: (f) the 3 new params; (a) and (b) in the parameters form, check and execute; (e) all 7 values plus the 3 execute rows; (g) all 14 (AttributeError).
    • The rows that pass on base are the ones the body names: the 3 older (f) params, the (a) and (b) count forms, the (a) control, and all of (c) and (d).
  • Six older rows. At bba168bd, tool and tests together, the 6 git-attr-source-* params passed. The same old rows run against the head tool failed, all 6, each with FINDING adopt-git-unasked: ... where the older finding stood. git grep finds no other GIT_ATTR_SOURCE row in tests/. Lane 1's ruling on these rows is honest and complete.

  • The 3.14-like shim. It makes exists, is_file and is_dir answer False to any OSError. Under it the 45 ids passed (33.3 s).

  • The -c reader against git's own reading. I fuzzed 3000 random GIT_CONFIG_PARAMETERS values (seed 1169) with /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1169/fuzz.py. They mixed:

    • quotes, '\'' and '\!' escapes, case, subsections, empties and implicit bools;
    • separators of space, tab, LF and CR, plus VT and FF, which git does not take as spaces.

    Results:

    • Of the 489 values git reads (205 of them had an attr.tree dropped), git's config -z --list under _parent_settings() matched the raw list less attr.tree every time.
    • Of the 2511 values git refuses, the reader made none readable. 66 changed when an attr.tree entry was dropped, and git still refused all of them (an invalid key).
    • Total: 0 mismatches.
  • A boolean -c attr.tree (with no value) makes every git command die (missing value for 'attr.tree'). check stops with REFUSED git-failed at git rev-parse --is-inside-git-dir in the source, on base and on head. So it gives no false plan ok.

  • adopt-project.py check: a partial-clone source missing blobs that check reads is fetched into, so check's own reads run the upload-pack its config names and write a promisor pack into the source #1145 overlap. adopt-project.py: check and execute run with GIT_NO_LAZY_FETCH=1 and refuse a partial-clone source that lacks an object its branches, tags or HEAD reach as source-partial-clone, so neither fetches into the source any more (#1145) #1180's head is now 2c8082a2. git merge-tree --write-tree --merge-base=bba168bd b8e63bcf 2c8082a2 exits 0 with tree 3a74e27a. On that merged tree, the 45 ids plus adopt-project.py: check and execute run with GIT_NO_LAZY_FETCH=1 and refuse a partial-clone source that lacks an object its branches, tags or HEAD reach as source-partial-clone, so neither fetches into the source any more (#1145) #1180's 22 ids gave 67 passed (46.3 s).

  • Body facts.

    • Line counts: adopt-project.py +242/-39, the main test file +455/-4, the other two 11/11 and 7/8.
    • 112 ids in the main test file.
    • The title is 240 characters.
    • There is exactly one closing line, Closes #1136.
    • The base is fix/1083-check-asks-the-split-clone at bba168bd (read back with gh api). closingIssuesReferences is [] only because the base is not main.
  • Known limit 1, a valid GIT_ATTR_SOURCE refused. The [head] and [the-tree] rows fail on base and pass on head, so the refusal is measured. It is honest, and "whatever the value is" covers branch names and commit ids too. I did not re-measure adoption verified on base.

  • Known limit 2, _AttributesProbe keeping GIT_CONFIG_COUNT. This is honest, by reading only. The probe's own -c for core.attributesFile, core.ignorecase, core.hooksPath and core.fsmonitor comes after GIT_CONFIG_COUNT, and attr.tree is dropped. I found no key in GIT_CONFIG_COUNT that steers its answer. Not measured.

  • Can the environment read still leave a directory behind? By reading, no. All six throwaway makers (_resolved, _answers, _split_clone_asked, _AttributesProbe, _stored_as_written, _SplitRehearsal) read their environment before _throwaway. Nothing that can raise sits between _throwaway and the try/__enter__ cleanup. The (f) rows pass on head and the 3 new ones fail on base; I did not run my own mutants of the reorder (M15–M17).

Findings

SHOULD-1: dropping attr.tree makes the rehearsal and _stored_as_written answer differently from execute, in both directions, and row (c) pins one wrong answer as correct.

Where: adopt-project.py:11708-11733 (_parent_settings), adopt-project.py:11736-11764 (_split_checkout_env), and row (c) at tests/test_adopt_check_throwaway_repos.py:1648-1679. It is not a regression: base drops GIT_CONFIG_PARAMETERS whole, and the GIT_CONFIG_COUNT drop dates from #910.

  1. A false refusal (probe p8, head). I used row (c)'s source and ran cmd_execute with _refuse_what_check_finds stubbed, so that execute's own git shows.

    • With a parent's 'attr.tree'='<empty tree>': check gives FINDING plan-file-reencoded, exit 1, yet execute goes on to adoption verified: every source path is in exactly one place.
    • Control, with no setting: the same finding, and execute fails after both legs ("NOTHING has been rolled back: .../Northwind-spec.git and .../Northwind-code.git exist").

    So the body's reason "the empty tree's oid silences every rule" applies to execute as well. Row (c)'s premise, "which in the rehearsal would silence what it finds", is wrong for this machine's split.

  2. A false plan ok (probes p2 and p7, base and head alike). The parent sets -c attr.tree=<a tree on a side branch whose .gitattributes says /.gitattributes filter=w1136>, plus a failing required filter, in either the parameters or the count form.

    • check exits 0 with plan ok.
    • The split's git add of .gitattributes, run in a clone of the source under the same environment, exits 128: clean filter 'w1136' failed. Without the attr.tree entry the same add exits 0.
  3. The drop is not consistent across config scopes (probe p7). The same settings in a global config file give plan-file-reencoded, on base and head. The rehearsal borrows the source's objects, so it does read a global attr.tree; only the command-scope forms are dropped.

Fix: at minimum, add a measured Known-limits bullet. Then correct the body's "Why attr.tree is dropped" paragraph, row (c)'s docstring and _parent_settings' docstring, and open a follow-up issue. The better fix is to hand attr.tree on to the rehearsal, which resolves an object id as the split's clones do, and to settle _stored_as_written the same way.

NIT-1: nothing pins "before any question". Mutant M6 moves _attr_source_unasked(source) after _split_clone_asked(source) at adopt-project.py:9530, and it survives: 45 passed. The split's clone is --no-checkout and removes itself, so nothing observable changes. Fix: a row asserting that _split_clone_asked is never called under GIT_ATTR_SOURCE.

NIT-2: no row separates entries with a tab, LF or CR. Mutant M10 (_PARAMETER_SPACE = " ") survives: 45 passed. The fuzz shows the code itself is right. Fix: a READABLE param built with appended_by_hand that uses \t and \n before an attr.tree entry.

NIT-3: the GIT_ATTR_SOURCE finding reuses _Unasked's generic text. See adopt-project.py:11773-11809 and the test's UNASKED_ENDS at tests/test_adopt_check_throwaway_repos.py:121. Probe p4 on head prints ... and it could not: GIT_ATTR_SOURCEis set in its environment, tobogus, ..., and the finding then ends with:

  • "execute asks the same ... and refuses while git cannot answer there", though execute refuses whatever git answers;
  • "... or pass another --work-dir", which does not help.

Fix: give _Unasked an optional ending of its own for this case.

NIT-4: an org/repo source that is not cloned yet is refused before _entry_findings, under another code. gh repo clone (adopt-project.py:2765) checks out in the whole environment. Under GIT_ATTR_SOURCE=bogus or an empty value, a plain git clone exits 128 (fatal: bad --attr-source or GIT_ATTR_SOURCE / warning: Clone succeeded, but checkout failed.); with HEAD it exits 0 (probe p5). So that path would refuse as source-unclonable, with a remediation about gh auth status. That is not a false plan ok. Measured only with plain git clone; the tool path was read, not run. Fix: add a Known-limits line, or move the check to the start of the command.

NIT-5: the #1145 overlap line is stale. It names 179b58ef and tree 87d0289c. #1180's head is now 2c8082a2, with merged tree 3a74e27a, which I tested as above.

NIT-6, by reading only: a branch in _parameter_entries changes nothing. The elif parameters.startswith("'", at): return None branch can be removed without effect, because the check that follows already returns None. Mutant M14 was not run.

Mutants

11 were run, in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1169/mut: 9 killed, 2 survived (M6 and M10, above).

  • Killed: M1 (the rehearsal's environment), M2 (_stored_as_written's environment), M3 (no attr.tree drop), M4 (case-sensitive key), M5 (no call), M7 (empty value allowed), M8 (the fallback returns None), M9 (old-style key not trimmed), M11 (the '\!' escape not read).
  • Defined but not run (M12–M21): no escape read at all, None versus "", the redundant branch (M14), the three environment-order mutants (M15–M17), GIT_CONFIG_PARAMETERS handed whole, the old-style key not cut at =, the message for the empty value, and GIT_ATTR_SOURCE kept in _split_checkout_env.

Not covered

  • Mutants M12–M21.
  • The gate, G1–G9: the doctor shards, the root-symlink file, collisions, hygiene, no-auto-maintenance and the G9 files. I re-ran none of them.
  • A re-measure of the body's execute rows on base. Only p8 ran execute.
  • An org/repo source end to end.
  • CPython 3.14 itself.

Logs, probe outputs and scripts are in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1169/, under logs/, probe-out/, fuzz.py, mutants.py and logs/mutants-1-11.txt.

Lane: openreposhape-1 (openRepoShape-1)

Brett Heap and others added 7 commits October 22, 2026 21:52
…ehearsal and `_stored_as_written` (r1169's SHOULD-1, fix (b)), and `_Unasked` takes an ending of its own for `GIT_ATTR_SOURCE` (NIT-3); tests not updated yet

`_split_checkout_env()` is now this process's environment but for what
`_NOT_INHERITED` names other than `GIT_CONFIG_PARAMETERS`: a parent's
`-c` settings, in `GIT_CONFIG_PARAMETERS` and `GIT_CONFIG_COUNT`, are
taken as they stand, `attr.tree` among them. `_parent_settings`,
`_parameter_entries`, `_single_quoted` and `_PARAMETER_SPACE` go, since
nothing is dropped any more. `_stored_as_written` borrows the source's
objects through `_objects_borrowed`, which the rehearsal now uses too, so
an `attr.tree` object id names there the tree it names in the split's
clones (measured with git 2.43: without alternates a full tree id read
no rule and an abbreviated one was ignored; with them both read the
tree's rules; `HEAD`, a branch name and `bogus` were ignored either way).

UNFINISHED, by lane 1's LAST STEP at 45%: no test of this commit has
run. Expected to fail until the tests are updated:
test_adopt_check_throwaway_repos.py's (g) rows (no `_parent_settings`),
row (c), the (f) stored-as-written and split-rehearsal params (they patch
`_inherited_env`, which `_split_checkout_env` no longer calls), the
`GIT_ATTR_SOURCE` rows' expected ending, and the count-form empty-tree
rows in test_adopt_reencoded_files.py and test_adopt_nested_attributes.py.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eads it, and new rows hold `execute` verifying under it (p8) and a tree the source holds naming a failing filter for the root (p2, p7) in every form (r1169's SHOULD-1)

Row (c) is renamed `test_a_parent_gits_attr_tree_is_read_in_the_rehearsal_as_the_split_reads_it`
and gains the global-config form: under the empty tree `check` says
`plan ok` in each form, and with no setting it finds the CRLF file.
`test_execute_reads_a_parent_gits_attr_tree_as_check_does` runs a real
`execute` under the same settings. `side_tree_handed` builds the p2/p7
source with a neutral root (`*.png binary`), so the rehearsal's own
refresh reads no failing rule.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…since `_parent_settings` is gone under fix (b), and `test_the_checkout_questions_take_the_settings_and_no_attr_source` asserts both forms whole, `attr.tree` kept

`handed_by_a_parent`, `appended_by_hand`, `listed`, `READABLE`,
`UNREADABLE` and their two rows are removed, and the unused
`command_line_entries` import with them. The variables naming another
repository are one constant, `ANOTHER_REPOSITORY`, read by the clone's
row and the checkout's.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as_written` and the rehearsal read it now, before each makes its throwaway

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…OURCE_ENDS`, r1169's NIT-3), and a new row holds that `execute` refuses it before the split's own clone is asked (NIT-1)

`ATTR_SOURCE_WHY` now ends at "can answer for them"; `ATTR_SOURCE_ENDS`
replaces `UNASKED_ENDS` in `unasked_attr_source`, and the execute row
asserts the refusal ends with it.
`test_git_attr_source_is_refused_before_the_splits_own_clone_is_asked`
records `_split_clone_asked` and finds it never called.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… `plan ok` under the empty tree, as the split's own git reads no rule there and `execute` verifies, each with its no-setting control; `SITES` follows the reencoded row's new name

`test_adopt_reencoded_files.py`'s row is now
`test_a_tree_this_machine_names_steers_the_question_as_it_steers_the_split`,
and `test_adopt_nested_attributes.py`'s split-question row is now
`test_a_tree_this_machine_names_steers_the_split_question_as_the_split`.
Measured with git 2.43 on the head tool: under the count-form empty tree
each source is `plan ok`, and a real `execute` says `adoption verified`;
with no setting each is found and `execute` refuses before any leg.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reads_it`, so its `def` line fits 79 columns; the reencoded row's docstring is rewrapped, and the `ATTR_SOURCES` comment names the older rows as they are called now

No behaviour changes.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brett Heap and others added 3 commits October 23, 2026 20:26
…fig, step 1 of 3 to restack #1169 onto main after #1119's squash

One conflict, in the module docstring of tests/test_adopt_check_throwaway_repos.py: #1119 added its #1033 paragraph and #1136 added its own at the same place. Both stay, #1033 first.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… step 2 of 3 to restack #1169 onto main after #1119's squash

Two conflicts, adopt-project.py and tests/test_adopt_check_throwaway_repos.py, both #1119's files; both took this branch's side with git checkout --ours. git diff 9b85644 b7edd88 is empty over the whole tree, so the merge changes no file: its tree is the tree of the commit before it.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ep 3 of 3 to restack #1169 onto main after #1119's squash b7edd88

No conflict.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-23T20:43:13Z) r1169d, lane 1's independent reviewer (not the writer), finished r1169c's review of #1169 at 76c9b767: NOT READY, with no MUST and two SHOULDs. Each has a measured fix, and no code change is asked for.

The full review

r1169d: review of PR #1169 at 76c9b767 (this finishes r1169c's partial)

Verdict: NOT READY. There is no MUST. There are two SHOULDs, and both are cheap to fix:

  • a test row is missing for a mutant that survives on the line this round changed;
  • one Known-limits bullet is wrong, as measured, for one attr.tree case.

No code change is asked for. w1169e's restack is not reviewed here.

How this was measured. Everything ran at 76c9b767, in scratch clones under _lane1-scratch/r1169d/, with git 2.43.0 and CPython 3.12.3, in the probe environment: a stub gh first on PATH, GH_TOKEN=bogus-r1169d, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false, and env -u LANES_LANE. The review worktree l1-r1169 is still clean: git status --short prints nothing. r1169c's partial and lane 1's chain results are folded in below and were not run again.

What holds (r1169c's results, unchanged)

  • Ruling X1 is fully in. _SplitRehearsal.reached (adopt-project.py:12756) runs check-attr -z --stdin with no --cached, and both _rules_reaching calls (12791 and 12803) go through it. A logging git wrapper over the 11 check rows of (j) and (k) saw 22 such calls, none with --cached.
  • The 17 new rows:
    • bba168bd: 15 failed, 2 passed (the 2 no-setting controls of (j)).
    • 25805102: 15 failed, 2 passed.
    • Head: 17 passed.
  • The body's sizes, checked with git diff --numstat:
    • bba168bd..76c9b767: 211 57 adopt-project.py and 630 5 tests/test_adopt_check_throwaway_repos.py.
    • 25805102..1e958ca6: 16 5.
    • 1e958ca6..76c9b767: 3 2. It changes one comment only, and ast.dump of the two tools is equal: AST equal: True.
  • The gate at 76c9b767 (lane 1's chain):
  • Exactly one closing form. Grepping the body for close, fix and resolve forms before an issue number finds only 1:Closes #1136, and closingIssuesReferences is [1136].

Findings

SHOULD-1: M3 (K3) is not equivalent. It changes the copies finding's unset line under attr.tree, and no row holds that.

  • Where:
    • adopt-project.py:12803-12805 (at_the_root = asked.reached(AS_SPLIT, ...)), read by _copy_unset_line (12922-12930) through _copies_finding (12933-12949).
    • The body's Mutants paragraph, which says M3's "effect on that line was not measured".
  • Evidence: r1169c's 33 probes per tool (outputs in r1169c/probe-out/, compared with cmp):
    K3: same=21 differ: advice-tree-{count,global,parameters} copies-{count,global,parameters}-{neutral,none,own-ident}
    
    • Own root empty or neutral. The side tree says /scripts/*.py filter=w654upper, and attr.tree names it.
      • Head gives /scripts/validate-pins.py -filter, and the same for the other four copies.
      • K3 gives a line that keeps the rule off scripts/validate-pins.py``.
    • Own root /scripts/*.py ident.
      • Head names -filter, the tree's rule, which the split's git add reads.
      • K3 and 25805102 both name /scripts/validate-pins.py -ident. Under attr.tree git does not read that rule (r1169c's expA).
    • Everything else is equal. The codes and the paths named are the same, and the other 21 probes are byte-equal. So the body's "can only grow" holds for reached | (kept & at_the_root).
    • K3 survives the other attribute files too: nr-K3 ENDED exit=0 126 passed in 205.87s over test_adopt_nested_attributes.py and test_adopt_reencoded_files.py. That is on top of the 129 ids of the throwaway file.
  • Fix: add this row to tests/test_adopt_check_throwaway_repos.py after row (k). I tested it as a separate module that imports the file's helpers. Inside the file it needs no new import.
    #: A clean filter that stores a file as other bytes, and two rules for the
    #: pinned copies the split writes under `scripts/`: the source's own root's,
    #: and a side tree's.
    COPY_UPPER = "w1136upper"
    COPY_UPPER_CLEAN = [(f"filter.{COPY_UPPER}.clean", "tr a-z A-Z")]
    OWN_COPY_RULE = b"/scripts/*.py ident\n"
    TREE_COPY_RULE = f"/scripts/*.py filter={COPY_UPPER}\n".encode()
    COPIES = ("validate-pins.py", "validate-manifest.py", "bootstrap.py",
              "repo_shape.py", "validate-repository-naming.py")
    
    
    @pytest.mark.parametrize("handed", EVERY_FORM)
    def test_a_pinned_copy_under_a_parent_gits_attr_tree_unsets_the_trees_rule(
            tmp_path, handed):
        """A parent's `attr.tree` names a side tree that says `/scripts/*.py
        filter=<an upper-casing clean>`, over a source root that says
        `/scripts/*.py ident`. The split's `git add` of the pinned copies reads
        the tree's `filter` and not the root's `ident`, so the copies finding
        names `-filter` for each copy, and no `-ident`. `_rules_reaching` asks
        the split's root without `--cached`; with it there (M3) each line said
        `-ident`, as on 25805102."""
        source = a_source(tmp_path, files={ROOT_ATTRIBUTES: OWN_COPY_RULE})
        env = handed(tmp_path, [*COPY_UPPER_CLEAN,
                                ("attr.tree",
                                 on_a_side_branch(source, TREE_COPY_RULE))])
        read = subprocess.run(["git", "check-attr", "filter", "ident", "--",
                               "scripts/bootstrap.py"], cwd=str(source),
                              env={**os.environ, **env}, capture_output=True,
                              text=True, check=True)
        if "filter: unspecified" in read.stdout:
            pytest.skip("this git reads no `attr.tree`")
        assert read.stdout.splitlines() == [
            f"scripts/bootstrap.py: filter: {COPY_UPPER}",
            "scripts/bootstrap.py: ident: unspecified"]
        result = checked(planned(source, tmp_path, {}), env)
        assert result.returncode == 1, result.stderr
        assert codes(result) == [REENCODED_FILES], result.stderr
        line = finding_lines(result)[0]
        assert "store 5 pinned shape copies" in line, line
        for name in COPIES:
            assert f"`/scripts/{name} -filter`" in line, line
        assert "-ident" not in line, line
    • Measured. Each tool was swapped into a scratch clone and restored, and its blob was HEAD's after each one.
      • Head: 3 passed, no skip (9 passed in 18.89s together with the probes).
      • K3: 3 failed: assert '/scripts/validate-pins.py -filter' in "... -ident ..."`.
      • 25805102: 3 failed, the same way.
      • bba168bd: the global form failed the same way; the parameters and count forms failed with assert 0 == 1 (plan ok).
    • Then update the body's Mutants paragraph: M3 is killed by this row, 3 of 3.

SHOULD-2 (r1169c's NIT-1, now measured): the misnamed second finding also comes when both rules are in the attr.tree tree, and this PR is what brings that case in. The bullet says otherwise.

  • Where:
    • The body's Known-limits bullet "A misnamed second finding where both rules are the source's own", and the matching "Not in this PR" line.
    • The cause is at adopt-project.py:13021, asked.git(["update-index", "-q", "--refresh"], TAKEN), whose exit code is never read, and then diff-files at 13022-13023.
  • Evidence: the both-rules-in-the-tree probe. The own root says *.png binary. The side tree says /.gitattributes filter=w1136 and *.md text. attr.tree names that tree, with the failing filter, in each form.
    • Head: exit=1, plan-attributes-reencoded and also plan-file-reencoded naming specs/001-feature/spec.md, in all three forms.
    • 25805102: exit=1, plan-attributes-reencoded alone, in all three forms.
    • bba168bd: exit=0 | plan ok in all three forms.
  • Control: the tree's *.md text alone, with no filter, gives exit=0 | plan ok on head, K3, 25805102 and bba168bd, in all three forms. So spec.md (# The feature\n, LF only) is stored as its blob, and the second finding names a file that is not reencoded.
  • The own-rules case is as the bullet says. Head and 25805102 both give two findings in all six cases (three forms, two orders), and the filter alone gives one finding.
  • The cause is traced (r1169c's expB, git 2.43).
    • Under the failing required filter, update-index -q --refresh exits 128 with fatal: .gitattributes: clean filter 'w1136' failed.
    • diff-files --name-only then exits 0 and lists .gitattributes specs/spec.md.
    • With no filter, the refresh exits 0 and diff-files lists nothing.
  • Three of the bullet's claims are wrong as measured:
  • This is not wrong behaviour. check refuses with the right first finding, where the base said plan ok.
  • Fix (the body only):
    1. Retitle the bullet "wherever both rules are read from one place".
    2. Add the side-tree results: head two findings, 25805102 one, bba168bd plan ok, in all three forms.
    3. Replace "Probably ... (not traced)" with the traced cause.
    4. Say that the side-tree case comes with X1.
    5. Make the same change in "Not in this PR".
    6. Lane 1: post the side-tree case and expB's trace on adopt-project.py: under a failing clean filter that a rule names for .gitattributes, check also prints plan-file-reencoded for a file the split stores as its blob, beside the right plan-attributes-reencoded (#1136) #1192.

NIT-1 (r1169c's NIT-2, now measured): under attr.tree, the copies finding asks for lines that are already there.

  • Where:
    • adopt-project.py:12946-12949, which says "these lines after them in the source's root .gitattributes".
    • The body's bullet "The findings point at the source's own rules", which names only the kept and moved endings.
  • Evidence (the advice probes):
    • Under attr.tree. The own root holds *.png binary plus the five /scripts/<copy> -filter lines the finding asks for, and the side tree's filter rule is read through attr.tree. Head gives exit=1 with the same finding, asking for the same five lines, in all three forms.
    • Control: the same rule in the own root, then the five lines, with no attr.tree. Head, K3 and 25805102 all give exit=0 plan ok in all three forms.
    • Not new in this PR. 25805102 loops the same way, with the generic line.
  • Fix: extend the bullet: "under a parent's attr.tree, the copies finding names the right attribute but puts the lines in the source's root .gitattributes, which git does not read there; following it gives the same finding again". SHOULD-1's row checks only which attribute is named, so it still holds after any rewording of this ending.

NIT-2 (r1169c's NIT-3, updated): the stacking line and the #1145 bullet are out of date.

r1169c's NIT-4 is withdrawn. The row at tests/test_adopt_check_throwaway_repos.py:1903 has no docstring. Neither do the section's other three execute rows, so it follows the section's own pattern. An AST listing of the section's rows gives 1602 NO DOCSTRING, 1644 NO DOCSTRING, 1804 NO DOCSTRING and 1903 NO DOCSTRING, and every check row there has one.

K7 and K8

  • K7 is not equivalent, and it is killed. K7 checks out every .gitattributes before _rules_reaching runs, which breaks the precondition reached's docstring names. It survived the 129 ids of the throwaway file. Over the nested and reencoded files it gave nr-K7 ENDED exit=1 2 failed, 124 passed:

    • test_adopt_nested_attributes.py::test_an_encoding_the_root_attributes_is_not_in_is_found: assert set() == {'plan-file-reencoded'}. The root's working-tree-encoding=UTF-16LE copy, checked out, gives git no rules, as the row's own docstring explains.
    • test_adopt_reencoded_files.py::test_every_file_is_read_again_whatever_the_clock: AssertionError: .gitattributes ctime: 0:0 ....

    No row is missing for K7.

  • K8 is equivalent. K8 adds --cached wherever git config --get attr.tree exits non-zero. It passed all 255 ids: 129 in the partial's run and 126 passed in mine.

    • With no attribute source, check-attr without --cached reads a .gitattributes in the work tree first, and the index otherwise (expA).
    • The work tree holds none when _rules_reaching asks. That is the precondition K7 breaks, and the two rows above hold it.
    • The only other attribute source, GIT_ATTR_SOURCE, never reaches the rehearsal. _NOT_INHERITED (adopt-project.py:11972-11973) names it and _split_checkout_env drops it (row (g)). _attr_source_unasked also refuses it before any throwaway is made (rows (e) and (i)).

Mutant table

Mutant Change Ids Result
K1 (M1) --cached at both calls throwaway file, -x killed: (k) [global-config]
K2 (M2) --cached at the AS_HELD call same killed: (k) [global-config]
K3 (M3) --cached at the AS_SPLIT call 129 + 126 survives, not equivalent (SHOULD-1); the row kills it, 3 of 3
K4 --cached where GIT_CONFIG_PARAMETERS names attr.tree throwaway file, -x killed: (k) [git-config-parameters]
K5 --cached where a GIT_CONFIG_KEY_<n> is attr.tree same killed: (k) [git-config-count]
K6 --cached where a global config file is named same killed: (k) [global-config]
K7 every .gitattributes checked out first 129 (survived) + 126 killed by 2 rows
K8 --cached where attr.tree is unset 129 + 126 survives, equivalent

6 of 8 are killed, which is 6 of the 7 non-equivalent ones, and 7 of 7 with SHOULD-1's row. Every run restored the tool, with restored to HEAD's blob: True after each mutant.

Not covered

The evidence is in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1169c/: probe-out/, logs/, expA.sh and expB.sh. My probes, including the proposed row as test_r1169d_probes.py, are in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1169d/: out/ and logs/ (status.txt, nr-K3/K7/K8.log, pr-*.log).

Lane: openreposhape-1 (openRepoShape-1)

Lane: openreposhape-1 (openRepoShape-1)

… `attr.tree`, which names `-filter` for the tree's rule and not `-ident` for the root's, so `--cached` on the `AS_SPLIT` call of `_rules_reaching` (r1169d's SHOULD-1, M3) fails all 3 ids

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-23T20:53:09Z) The fix round for r1169d's review is pushed at d9224ecf. Lane 1's delta check of it holds; r1169d re-checks it in lane 1's next window.

  • The code: d9224ecf adds exactly r1169d's row (l) and its constants.
    • Its 40 non-blank lines equal the review's block, in order.
    • It is one test file, +44/-0, with nothing removed.
    • The row passes at head, 3 of 3. With K3 applied (--cached at the AS_SPLIT call) it fails, 3 of 3, and the tool's blob was HEAD's after the restore.
  • The body, posted at 20:48Z:
    • SHOULD-2: the bullet is retitled, the side-tree results are added, the traced cause is in, and the side-tree case is said to come with X1, here and in "Not in this PR".
    • NIT-1: the copies-finding sentence is added.
    • The mutant tally: K3 is killed by row (l), K7 by the two rows the review names, and K8 is equivalent.
  • Before landing, still owed:
    • r1169d's re-check;
    • a merge of main, since d9224ecf is one behind e04826db;
    • tests and SonarCloud green on the H0. That is d9224ecf, because the restack kept both sides of one docstring conflict by hand.
    • Lane 1's gate on d9224ecf is running now.

Lane: openreposhape-1 (openRepoShape-1)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-23T21:34:31Z) r1169e, lane 1's independent re-checker (not the writer), re-checked #1169's fix round at d9224ecf: READY, with no MUST, no SHOULD and two wording NITs in the body.

The full re-check

r1169e: re-check of PR #1169's fix round at d9224ecfa69fb7a8df9e4f25988afca1e078d180

Verdict: READY. There is no MUST and no SHOULD. r1169d's two SHOULDs are fixed and r1169d's NIT-1 is in the body. There are two NITs, both wording in the body. Neither blocks.

How this was measured.

  • Environment: git 2.43.0, CPython 3.12.3, the probe environment of the rules, env -u LANES_LANE.
  • Probe environment: stub gh first on PATH, GH_TOKEN=bogus-r1169e, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false.
  • Per lane 1's disk rule, TMPDIR and every basetemp sat in my scratch folder, and I checked df on / and /workspace before each call.
  • The review worktree l1-r1169e is detached at d9224ecf. git status --short is empty and its adopt-project.py blob is a530fdfa, equal to HEAD's.
  • One gap: one small pytest call ran at 4982M free on /, just under the 5G line. It was the 3-id run against the bba168bd tool in item 1. / then fell to 3.5G while other lanes were using it, and I paused until it read 7.5G. Every later call passed the check first.

1. SHOULD-1: row (l) passes at head and K3 fails it. Fixed.

  • Head. I ran the three ids once with a $(cat) list and once through @ids_l, an args file. Both runs, with -rs:

    ...                                                                      [100%]
    3 passed in 5.10s
    

    There is no skip. Under a 3.14-like shim (Path.exists.__qualname__ read _quiet.<locals>.quiet) it was also 3 passed in 4.95s.

  • K3 as r1169d describes it. I added a cached argument to _SplitRehearsal.reached, which puts --cached in its check-attr call. I passed cached=True at at_the_root = asked.reached(AS_SPLIT, ...). The edit was in a clone under my scratch folder.

    K3: mutated; blob now a85f3d3ed918 vs HEAD a530fdfacc9b
     adopt-project.py | 6 ++++--   (4 insertions, 2 deletions)
    K3: pytest exit=1
    FFF
    E   assert '`/scripts/validate-pins.py -filter`' in "FINDING plan-file-reencoded: ... Keep the rules off them with these lines ...: `/scripts/validate-pins.py -ident`, ... -ident` ..."
    3 failed in 6.24s
    K3: restored to HEAD's blob: True (a530fdfacc9b)
    

    All three ids failed, at test_adopt_check_throwaway_repos.py:2048, with -ident named in place of -filter.

  • Row (l) against other tools. The tool was swapped into a clone and restored each time. It matches the body's sentence.

    Tool Result
    25805102 3 failed, all -ident at :2048
    bba168bd (blob b17f7a41) global form failed at :2048 with -ident; parameters and count failed at :2043 with assert 0 == 1 (plan ok)
  • The block equals r1169d's. git diff -U0 fe25bb14 d9224ecf adds 44 lines, 40 of them non-blank. They are identical to the python block in r1169d-on-1169.md: review non-blank: 40 added non-blank: 40, equal (non-blank, exact): True.

  • Placement and names. The row sits after row (k), at lines 2008-2051. COPY_UPPER, COPY_UPPER_CLEAN, OWN_COPY_RULE, TREE_COPY_RULE and COPIES each have one definition, and no other test file uses them.

  • Whole file at head. tests/test_adopt_check_throwaway_repos.py: 151 passed, 1 skipped in 198.60s. The skip is :482, "needs a disk that ignores case". It is adopt-project.py: check asks the split's own clone of the source in the work directory, so a clone this git will not make is adopt-git-unasked before any leg, and _resolved and _answers make their repositories as check's other throwaways do (#1083) #1119's row and not this PR's.

  • Id count. Collecting the file at main 50ce8ab1 gives 90 ids and at head 152. Head has 62 ids not in main, and none removed. That matches the body's "59 ... row (l) makes 62".

2. SHOULD-2 and NIT-1: each of the five body items, and the NIT-1 sentence, is in and says what r1169d measured.

The "Known limits" bullet is now titled "A misnamed second finding wherever both rules are read from one place." (the old title was "where both rules are the source's own").

r1169d's item In the body Matches
1. Retitle the bullet The title above yes
2. Side-tree results "in each of the three forms, head gives exit=1 with plan-attributes-reencoded and also plan-file-reencoded naming specs/001-feature/spec.md; 25805102 gives plan-attributes-reencoded alone; bba168bd gives exit=0, plan ok", with the *.md text-alone control as plan ok yes
3. The traced cause The refresh's exit code is never read; it exits 128 with fatal: .gitattributes: clean filter 'w1136' failed; diff-files exits 0 and lists .gitattributes specs/spec.md; with no filter the refresh exits 0 and diff-files lists nothing yes (see below)
4. "Comes with X1" "This case comes with ruling X1: the read without --cached now reaches spec.md through the tree's *.md text ... where base said plan ok" yes
5. Same change in "Not in this PR" "... wherever both rules are read from one place: the source's own root, or the side tree attr.tree names, which this PR's read without --cached brings in" yes
  • Item 3: one phrase goes past r1169d's wording, and it holds. The body says the files are "the files it did not refresh". I reproduced r1169c's expB in the probe environment, with the same rows, the same filter, update-index -q --refresh against a fresh index, then diff-files.
    == failing: refresh
    fatal: .gitattributes: clean filter 'w1136' failed
    refresh exit=128
    .gitattributes   ctime: 0:0
    specs/spec.md    ctime: 0:0
    diff-files exit=0 out: .gitattributes specs/spec.md
    == none: refresh
    refresh exit=0
    .gitattributes   ctime: 1792790432:171663157
    specs/spec.md    ctime: 1792790432:171663157
    diff-files exit=0 out:
    
    ctime: 0:0 on specs/spec.md after the failed refresh shows it was never refreshed, so the phrase holds.
  • Item 4 and the own-rules sentences. The body keeps "The own-rules case predates this PR, which only lets its parameters form reach it". r1169d agreed with this for the own-rules case. The body also corrects the adopt-project.py: under a failing clean filter that a rule names for .gitattributes, check also prints plan-file-reencoded for a file the split stores as its blob, beside the right plan-attributes-reencoded (#1136) #1192 claim to the two-places case (row (k)), as r1169d asked.
  • adopt-project.py: under a failing clean filter that a rule names for .gitattributes, check also prints plan-file-reencoded for a file the split stores as its blob, beside the right plan-attributes-reencoded (#1136) #1192. Lane 1's comment with the trace is there, posted 2026-10-23T20:44:08Z.
  • The NIT-1 sentence. It is in the "The findings point at the source's own rules" bullet and matches r1169d's measurement. I measured it independently with a probe module that imports the file's helpers:
    attr.tree, own root `*.png binary` (no five lines): exit=1 plan-file-reencoded, the 5 `-filter` lines  (3 forms)
    attr.tree, own root `*.png binary` + the five `-filter` lines: exit=1 plan-file-reencoded, the SAME 5 lines  (global, parameters, count)
    no attr.tree, own root `/scripts/*.py filter` + the five lines: exit=0 plan ok
    no attr.tree, own root `/scripts/*.py filter` alone: exit=1 plan-file-reencoded, the same 5 lines
    
    "25805102 loops the same way, with the generic line" is r1169d's, and I did not re-measure it. "Row (l) checks only which attribute is named" is true of the row's code: store 5 pinned shape copies, -filter per copy, no -ident.

3. The mutant paragraph

All mutants ran in clones of d9224ecf, with the file restored in a finally and its blob checked against HEAD's after each.

Mutant Ids Result Claim
K3 row (l), 3 3 failed (item 1) killed by (l): holds
K7 test_adopt_nested_attributes.py and test_adopt_reencoded_files.py (126) 2 failed, 124 passed in 188.50s killed by the two named rows: holds
K8 the throwaway file 151 passed, 1 skipped equivalent: holds
K8 nested and reencoded files 126 passed in 188.68s equivalent: holds
K1 (j) and (k), 17 15 failed, 2 passed body's "M1: 15 failed": holds
K2 (j) and (k), 17 9 failed, 8 passed body's "M2: 9 failed": holds

Head over the same 17 ids: 17 passed.

  • K7's two failures are exactly the two rows the body names:
    • test_adopt_nested_attributes.py::test_an_encoding_the_root_attributes_is_not_in_is_found: assert set() == {'plan-file-reencoded'}.
    • test_adopt_reencoded_files.py::test_every_file_is_read_again_whatever_the_clock: .gitattributes ctime: 0:0, assert 1 > 1.
  • K7 and K8 are r1169d's mutants. My tool blobs equal r1169d's status.txt: K7 62b23082..., K8 e1f78c0a....
  • K8's reason holds in the code.
    • _NOT_INHERITED names GIT_ATTR_SOURCE at 11972-11973.
    • _split_checkout_env() returns only names that are CONFIG_PARAMETERS or not in _NOT_INHERITED.
    • _attr_source_unasked raises _Unasked before any throwaway whenever the variable is set.
    • The work tree holds no .gitattributes when _rules_reaching asks, which is the precondition K7 breaks.
  • The tally "6 of 8 killed before row (l), K3 by (l), K8 equivalent: 7 of 7 non-equivalent killed" follows from the table.

4. Nothing else changed.

  • git diff fe25bb14 d9224ecf: 1 file changed, 44 insertions(+), tests/test_adopt_check_throwaway_repos.py, 44 0, one commit.
  • adopt-project.py's blobs.
    d9224ecf a530fdfacc9be36995080ba0cdae051698ab751e
    fe25bb14 a530fdfacc9be36995080ba0cdae051698ab751e
    76c9b767 a530fdfacc9be36995080ba0cdae051698ab751e
    50ce8ab1 b17f7a41b2d74d3b31d1c9ff1c7b8f8ecbeb15b7
    bba168bd b17f7a41b2d74d3b31d1c9ff1c7b8f8ecbeb15b7
    
    So the tool at d9224ecf is byte-equal to 76c9b767's, and main's tool is byte-equal to bba168bd's.
  • The two diffs you named. I compared them by changed lines (the + and - lines, headers left out).
    git diff -U0 50ce8ab1 d9224ecf -- adopt-project.py  268 lines  sha256 b9d14b4f43f6...ddf
    git diff -U0 bba168bd 76c9b767  -- adopt-project.py  268 lines  sha256 b9d14b4f43f6...ddf
    numstat: 211 57 and 211 57
    
  • The whole tree. The changed lines of 50ce8ab1..fe25bb14 and of bba168bd..76c9b767 are both 985 lines with sha256 5c59dd53e8c0...5c81. That is the value lane 1 recorded. git diff --numstat 50ce8ab1 d9224ecf lists the same 5 files, with 674/5 in the throwaway file (630/5 plus row (l)'s 44).
  • The restack statement.

5. Exactly one closing form. Holds.

3:Closes #1136
closingIssuesReferences: 1136
  • A grep for close/fix/resolve forms before an issue reference finds only line 3.
  • "fix (b)" at line 42 and the branch name fix/1145-... at line 136 are not closing forms.
  • The body has no "Part of" or "Covers".

New findings

NIT-1: the NIT-1 control omits "then the five lines". The body's wording is ambiguous.

  • Where: the body's bullet "The findings point at the source's own rules", its sentence "with the same rule in the own root and no attr.tree, plan ok". It sits in the restack-era body, where r1169d's text sits.
  • Evidence (the probe above): the rule in the own root with no attr.tree is plan ok only when the five -filter lines follow it. With the rule alone and no attr.tree, the copies finding comes (exit=1, the same five lines). r1169d's control was "the same rule in the own root, then the five lines".
  • Fix: write "with the same rule in the own root, followed by the five lines, and no attr.tree, plan ok".

NIT-2: the mutant paragraph names the restack's fe25bb14 but not row (l)'s head for M3.

  • Where: the body's M3 bullet. It says M3 was measured "at fe25bb14 plus the row".
  • Evidence: I measured at d9224ecf, and adopt-project.py's blob is the same, a530fdfa, at both commits. So the claim is true.
  • Fix: none needed. Optionally say "at d9224ecf".

What I did not cover

  • Not measured at d9224ecf.
    • The doctor's four shards, G3, G4, G9 and the hygiene gates. The tool and the other four test files are byte-equal to what lane 1's and w1169e's calls ran, and row (l) adds only tests.
    • R1's other three test files: they are unchanged since fe25bb14, where R1 ran.
    • The doc trio, which this round does not touch.
  • Not re-measured. The older body claims from earlier rounds, which I did not touch. These include f1169's 13 older mutants and the own-rules both-rules table: the bba168bd global and count forms giving two findings, and the parameters form plan ok. A real execute run of the side-tree both-rules case and of the copies advice: only check was run.
  • No CPython 3.14. I used the 3.12 shim for row (l) only. The changed lines have no path check.
  • The full suite.

Evidence is in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1169e/:

  • logs/: mut-K3-x.log, run-K7-nr.out, run-K8-nr.out, run-K8-thr.out, head-thr.log, swap-p25805102.log, swap-pbba168bd.log
  • probe/: out.txt, test_r1169e_probes.py
  • expB/run-2/
  • ids-main.txt, ids-head.txt
  • mutate.py, swap_tool.py

Lane: openreposhape-1 (openRepoShape-1)

Lane: openreposhape-1 (openRepoShape-1)

@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review November 8, 2026 14:03
@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 23 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 93d2931 into main Nov 8, 2026
5 checks passed
@brettheap
brettheap deleted the fix/1136-throwaways-parent-config branch November 8, 2026 14:03
brettheap pushed a commit that referenced this pull request Nov 8, 2026
Merges main at 11560eb into
fix/1161-check-names-case-twins at 58aa9d2
(merge-base ef619c7, #1158's squash).

One conflict, tests/test_adopt_check_throwaway_repos.py, in the module
docstring: #1189 and #1169 each added a paragraph after #1033's. Both are
kept, #1161's first, because it opens "so `check` passed twins", which
follows from #1033's measurement, then #1169's "#1136 followed". Every row
and helper of both sides auto-merged untouched.

adopt-project.py auto-merged: both sides change `_entry_findings` (#1189 its
case twins after the source path findings; main the `GIT_ATTR_SOURCE` and
split-clone questions before `_paths_git_refuses`, and `_submodule_findings`'
`unasked`) and the `check` paragraph of the module docstring, in separate
lines.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 8, 2026
brettheap pushed a commit that referenced this pull request Nov 8, 2026
Main 0d5704b is five commits past #1144's squash b1f45fa (#1169, #1223,
#1166, #1133, #1104). This is the third of the three merges that restack
#1159 after #1144 landed.

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 9, 2026
… `--local-remote-dir` path in `execute`'s `bare`, leg and `split` lines, are named with `_shown`, as #1154 names `--out` and `--work-dir`, so a bidi control, an escape, a line feed or a byte that is not UTF-8 in either no longer reaches the terminal as it was typed (#1152) (#1177)

## What

#1154 (#1115) names `--out` and `--work-dir` in `plan`'s and `check`'s report with `_shown`. #1152 lists the two groups of paths it leaves raw: the `--plan` path in `check` and `execute`, and the `--local-remote-dir` path in `execute`. A bidi control (U+202E), an ESC or a line break in either reached the terminal as it was typed, where the other plan lines name a path with `_shown`.

This change spells each of the 21 sites with `_shown(str(...))`, in `adopt-project.py` only. A path with a character that is not printable is named as `_spelled` names it, between backticks. Every other path prints byte for byte as it did, and no refusal code is new.

The `--plan` path, 16 sites:
- the five `N finding(s) in <plan>` lines: `_refuse_a_mistyped_plan`, `_refuse_what_check_finds`, the `plan-split-branch-taken` refusal, and `cmd_check`'s two branches;
- the `<plan>: ...` refusals: `plan-wrong-kind`, `plan-unsupported-mode`, `plan-unreadable`, `plan-no-source`, the three `plan-bad-id` ones, `plan-not-the-source`, `adopt-topic-too-long`, and the `legs.assembly` and `org:` ones.

The `--local-remote-dir` path, 5 sites:
- the `<bare> already exists` refusal;
- the `  bare  <bare>` line;
- the two leg lines, in `_extract_leg` and `_seed_leg`, which end `-> <url>`;
- the `split <commit> on <branch> -> <url>` line. It names the source's directory under `--local-remote-dir`.

The `NEXT:` clone command is not touched. It already quotes the url with `shlex.quote`, and a row pins that it still does.

## Behaviour change

A path that is not UTF-8 now prints `\udcff` between backticks, as #1154's paths do, where it printed raw before. A plain path prints as it did.

## Names

#1152 calls the wrong-kind refusal `plan-bad-kind`. The code's name for it is `plan-wrong-kind`, and that is the code this change spells. No refusal was renamed.

## Not in this PR

`repo_shape.load_yaml`'s own refusals, `yaml-unreadable`, still print a path raw. They live in `scripts/repo_shape.py`, and they are a separate follow-up: see #1168.

## Proof

`tests/test_adopt_plan_and_remote_paths_spelled.py` is new, ASCII only and POSIX only, with every non-ASCII character written as an escape.

- **Rows: 148.**
  - 135 plan rows: 14 refusal families (27 family and command pairs) by 5 path forms (U+202E, ESC, a line feed, the byte `\xff` through `surrogateescape`, and a plain control).
  - 5 `--local-remote-dir` rows for the `bare` and leg lines (the code leg is extracted, the spec leg seeded).
  - 3 `split` rows. `execute` refuses a source path that holds U+202E, ESC, a line feed or `\xff` as `adopt-unwritable-value` before any leg exists, so these rows use U+200B in the parent directory and a name that ends in a space, and a plain control.
  - 5 in-process rows for `{bare} already exists`, which call `_create_leg_remote`.
- **On main's `adopt-project.py`: 118 failed, 30 passed.** Each failure is the raw path where the spelled one was expected. The 30 that pass are exactly the 30 plain controls.
- **On this head: 148 passed.**
- **Mutants: 23, all killed.** 21 put one spelling back to raw, one at a time, and 2 swap `_shown` for `_spelled` at one site. Each was restored with its sha256 checked.
- **Gate:** a scratch merge of this head with main `961640c68` (tree `61063bb76`, which is the tree of this PR merged into that main). Targeted files only: the new file, `test_stdout_encoding.py`, `test_non_utf8_name_callers.py`, `test_adopt_plan_escapes.py`, `test_path_guard_hygiene.py`, `test_repo_hygiene.py`, `test_scaffold_leg_path_collisions.py`, and every test file that greps for `finding(s) in` or `already exists` (47 files), in 6 shards, and `test_shape_doctor.py` (448 ids) in 8 shards. Every shard exited 0, with 0 failed.

  | Shard | Result |
  | --- | --- |
  | g0 | 617 passed |
  | g1 | 344 passed |
  | g2 | 439 passed |
  | g3 | 605 passed |
  | g4 | 902 passed |
  | g5 | 764 passed, 1 skipped |
  | d0 | 56 passed |
  | d1 | 56 passed |
  | d2 | 56 passed |
  | d3 | 55 passed, 1 skipped |
  | d4 | 55 passed, 1 skipped |
  | d5 | 55 passed, 1 skipped |
  | d6 | 56 passed |
  | d7 | 56 passed |

  That is 3671 passed and 1 skipped in the file shards, and 445 passed and 3 skipped in the doctor shards. The skips are Windows-only rows.
- **Merge-trees** (`git merge-tree --write-tree`, this head against each), all clean:
  - `origin/main` `961640c68`;
  - #1154's head `8a782733`: clean, so this PR is based on main and not stacked on it;
  - the other 15 open PRs that touch `adopt-project.py`, at their heads: #1171 `159815d75`, #1170 `4df784ac4`, #1169 `b8e63bcf0`, #1153 `ff33ba12a`, #1143 `7af5fabc3`, #1141 `7a2711d03`, #1125 `9549006fe`, #1123 `5c3908f8d`, #1122 `c01c4d73d`, #1121 `1c5173758`, #1120 `c55e55e33`, #1119 `bba168bdd`, #1118 `2307036ab`, #1106 `504a1dd65`, #1105 `b28f40de0`.

  The other open PRs do not touch `adopt-project.py`. None of the 16 that do adds a line that names `plan.path`, `{bare}`, `finding(s) in` or `already exists`, apart from a tuple in #1121 that is not printed.

Closes #1152

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 9, 2026
…repository as `plan-case-twins` on every disk, a one-blob set as a NOTE in a leg and, in the root, two paths in one folder, and `execute` refuses the finding before any leg (#1161) (#1189)

`adopt-project.py check` printed `plan ok` for a plan that keeps case twins in one repository: source paths that differ only in case (`A` and `a`, `D/x` and `d/x`), or a path that names in another case a folder above another (`F/x` and `f`). The plan is written on one disk and the clone that loses a twin is made on another, and a clone on a macOS or Windows disk checks out one path of each set. On a disk that ignores case, `execute` could then stop with `check` having said `plan ok`: as `adopt-lost` or at `git rm` mounting the legs, after both legs were pushed, or at git's `will not add file alias`, before any leg existed.

- `check` now exits 1 and names `plan-case-twins`, one finding per repository the split makes (the root, the spec leg, the code leg). A set is every source path that one answered entry sends into one repository, whose paths differ only in case, or where one path names in another case a folder above another. It is asked on every disk, since `core.ignorecase` where `check` runs says nothing about the reader's disk. `execute` runs the same checks first, so it refuses the finding as `plan-case-twins` before it makes either leg.
- A set whose paths all fold to one name and hold one blob in one mode is a NOTE, and `check` still says `plan ok` for it. `execute` prints no NOTE and does not refuse it.
  - In a leg that holds for every such set, folder twins (`src/D/x`, `src/d/x`) and sets of three or more included.
  - In the root the NOTE takes only two paths whose names differ only in their last part, `A` and `a` or `D/A` and `D/a`. Folder twins of one blob there (`D/x`, `d/x`), `D/A` beside `d/a` and sets of three or more (`AB`, `Ab`, `ab`) stay in the finding, since on the FUSE disk `execute` met git's `will not add file alias` for them.
- The remediation says to rename all but one twin of each set in the source, commit, and re-run `plan`. It no longer offers to answer the twins into different repositories, because on a disk that ignores case `execute` loses those too (#1183).
- Paths git refuses (`plan-path-git-refuses` names them), `drop` and unanswered entries are left out. At most 10 paths are named per repository, and the rest are counted.
- Behaviour change: a source that keeps `Makefile` and `makefile` side by side in the root is now `plan-case-twins`. Before, `check` printed `plan ok`, and on a disk that keeps case `execute` split it and verified it. Two params of `tests/test_adopt_make_reads_first.py` changed, and `test_execute_refuses_makefile_beside_makefile_before_any_leg` asserts the refusal: no bare repository, no assembly or leg clone in the work directory, no split branch in the source.
- Measured with git 2.43, on the bench disk (keeps case) and on a FUSE disk that ignores case and keeps it (`casefold` lookups), against base `4c7b8acd`:
  - On base, `check` printed `plan ok` for every scenario on the bench disk. On the FUSE disk `execute` stopped on three of them: `F/x` and `f` in the root, exit 1, `adopt-lost: F/x`, both legs and the split already pushed; `src/A`, `src/a`, `src/D/x`, `src/d/x` with own blobs in the code leg, exit 2 in 3 of 5 runs at `git rm -r -q -- src` after both legs were pushed; and `D/x` and `d/x` in the root with own blobs, refused before any leg as `plan-file-reencoded`.
  - On head, `check` names `plan-case-twins` for each of those three (then `plan-file-reencoded` where git refuses the alias).
  - The NOTE sets verified in `execute` on the FUSE disk: `A` and `a` in the root (3 of 3 runs), `D/A` and `D/a` in the root (2 of 2), `src/A` and `src/a` in the code leg (3 of 3), `specs/A` and `specs/a` in the spec leg (1 run).
  - In the root, folder twins and three paths of one blob (2 runs each, at `a131fc25`): `check` printed `plan-case-twins` and `plan-file-reencoded`, and `execute` refused both before any leg as `plan-case-twins`.
  - The whole of `tests/test_adopt_check_throwaway_repos.py`, CPython 3.12: at the head `58aa9d2b`, 91 passed and 2 skipped; on base 38 of its ids fail (r1189c). Mutants of the new code: 36 of 36 killed, each file restored and its blob checked against HEAD's.
- Known limits:
  - `casefold` is full case folding, so `ß` and `ss` are named as a set, though a disk may hold them apart.
  - Unicode normalization is not asked: a precomposed `é` beside `e` and a combining accent is no finding, though a macOS disk takes them as one name.
  - The cut at 10 named paths can fall inside a set.
  - The one-blob NOTE, and the root's narrower rule for it, rest on the FUSE disk with git 2.43; real APFS and NTFS were not measured. The row for a disk that ignores case skips on every CI disk, which keeps case.
- Not in this PR, each a follow-up: case twins the plan sends to different repositories, or a twin it drops beside one it keeps (#1183); the rehearsal's alias refusal, which `check` reports as `plan-file-reencoded` though its words say the bytes were re-encoded (#1184); twins a kept path makes with a file the shape writes into the root (#1191).

Review: lane 1's independent reviewers reviewed each round. r1189 found `8684af86` NOT READY, for two MUSTs and four SHOULDs (#1189 issuecomment-6356435838). r1189b found `4d40ce9f` NOT READY for one SHOULD, that the one-blob NOTE covered root shapes that do not verify on a disk that ignores case (issuecomment-6364543347). The next round narrowed the root's rule and the sentence, and r1189c, lane 1's independent re-checker, found `58aa9d2b` READY, with no MUST, no SHOULD and two notes that do not block (issuecomment-6375603307). It re-ran 15 of the 36 mutants, and all 15 were killed. After #1158 landed as `ef619c74`, the branch was restacked onto it by merges of #1158's final head and of its squash, and its changed lines equal the reviewed delta (sha256 `04b2909a`). After #1169 and #1223 landed, main `11560eb9` was merged in as `5c4fcd9d`; its one conflict, a docstring both sides added to, keeps both, and the changed lines equal the reviewed delta (issuecomment-6722938949). `7d2f651e` leaves out of lane 4's #1121 rows the two trees that keep case twins, which `execute` now refuses, with lane 4's agreement (issuecomment-6722980082). After #1105 landed, main `12122952` was merged in cleanly as `47478d51`, whose 787 changed lines equal the delta before it (sha256 `637968e3`), and `472944b5` makes the cut agreed on #473 (issuecomment-6723000456) of the three #1105 rows that keep case twins. Lane 1's check carries READY to `472944b5` (issuecomment-6724542222).

Tests, under a stub `gh` with a bogus token: at `58aa9d2b`, r1189c's re-check gave 91 passed and 2 skipped for `tests/test_adopt_check_throwaway_repos.py` (the same under a 3.14-like shim) and 33 passed for `tests/test_adopt_make_reads_first.py`. Main touched both files after the restack, so lane 1's gate ran on the refreshed tree itself, below: the PR's two test files, `tests/test_scaffold_leg_path_collisions.py`, the doc trio, the path-guard and no-auto-maintenance scans, and `tests/test_shape_doctor.py` in 4 shards. At `472944b5`: `tests/test_adopt_check_throwaway_repos.py` 194 passed and 2 skipped (the disk that ignores case), `tests/test_adopt_make_reads_first.py` with `tests/test_adopt_next_make_reads_first.py` 71 passed, `tests/test_adopt_collision_make_reads.py` 28 passed, `tests/test_adopt_single_repository_record.py` with `tests/test_scaffold_leg_path_collisions.py` 143 passed, the two scans 179 passed and 2 skipped, the doc trio 171 passed, and `tests/test_shape_doctor.py` in 4 shards 446 passed and 3 skipped.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its H0 `472944b5` 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 dae7250, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: 9 pytest calls over `tests/test_adopt_check_throwaway_repos.py`, `tests/test_adopt_make_reads_first.py`, `tests/test_adopt_next_make_reads_first.py`, `tests/test_adopt_collision_make_reads.py`, `tests/test_adopt_single_repository_record.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`, `tests/test_repo_hygiene.py`, `tests/test_path_guard_hygiene.py`, `tests/test_no_auto_maintenance.py` and `tests/test_shape_doctor.py` in 4 shards: 1,233 passed, 7 skipped.

Closes #1161

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 9, 2026
…: a kept `GNUmakefile/` is a directory GNU make stops at, `python3 scripts/bootstrap.py` names `python` on Windows, and the template says what to run where `make` reads another file (#1207)

**#1132, the parts that hold on main: the assembly-root template says what to run where GNU make reads a kept `GNUmakefile` or `makefile` first; a kept DIRECTORY of either name is named as one GNU make stops at, in the `CONTRACTS_DIR` line and in `execute`'s NEXT; every sentence that names `python3 scripts/bootstrap.py` for a person to run names `python` on Windows too; and two follow-up texts no longer say more than is true (RV1105b's NIT-2 and NIT-3a).**

This PR was built on main `961640c6`. Lane 4's #1121 (#1047) landed as `0ed3e57f8` at 21:37Z, and this round merged it in (`6fa1edd56`): NEXT now words a kept file (#1121's) and a kept directory (this PR's) through one lookup and one clause. #1105 landed as `12122952b` on 2026-11-08, and this PR merges it in (see "Main merge after #1105"). The items that live only in #1105's code, and the parts of #1121's that this merge does not do, are in their own issue, #1240 (see "Left"). Item 5 is #1206.

Lane 4's #1105 (#1048) landed as `12122952b` at 16:45Z. This round merges main into the branch with `git merge origin/main`, never a rebase, and does it twice:
- main `46fe6ac19` as merge `7b0192bde`;
- then NIT-A in its own commit, `e5e077940`;
- then main `9729315f1` as merge `01e93b6d1`, the head. Lane 1's #1123 had landed meanwhile, and one of its rows meets this PR's directory line (see "The second merge, after #1123").

The first merge conflicted in `scripts/shape_materialize.py` only: 3 hunks, all in `collision_follow_up`, as "Landing order" said. `adopt-project.py` and `templates/assembly-root/AGENTS-shape.md` merged with no conflict.

Both sides' behaviour stays: #1105's second `GNU_MAKE_READS`, its `_predict_collisions` lookup and its collision line, and this PR's `_make_reads_first`, `_make_reads_first_clause` and NEXT. None of #1240's unification is done here (one tuple, one lookup).

### The three hunks

| hunk | this PR (`6fa1edd56`) | main (`46fe6ac19`, #1105) | resolution |
|---|---|---|---|
| 1. The docstring's NIT-4 sentence | "This said "both callers say it", and `execute` calls it nowhere" | "`Materialized.follow_ups()` would say this of them, and nothing calls it" | **Main's.** Both sides rewrote the same sentence for the same finding. Main's also names `Materialized.follow_ups()`, which #1240's item G changes. So this PR no longer touches that sentence, and the last `scripts/shape_materialize.py` bullet under "What changed" is #1105's text now. |
| 2. The docstring's Makefile paragraphs | the `BOOTSTRAP_COMMAND` sentence and NIT-2's tail | the heading "WHERE `make` READS THE MERGED FILE", and the "BUT GNU MAKE READS" paragraph | **Both.** Main's heading and both paragraphs. This PR's sentences on `BOOTSTRAP_COMMAND` and on NIT-2's tail go in the first paragraph, and one new sentence goes in the second: "Both name that command as `BOOTSTRAP_COMMAND` does, `python` on Windows as well (#1132)." |
| 3. The returns | two `return`s, each one sentence | `arrive`, `joint`, `meanwhile` and `until` set per branch, then one pair of `return`s | **#1105's structure, with a per-branch `then` in place of `until`.** With no `reads_first`, the line is this PR's, byte for byte. With one, it is #1105's, byte for byte, except that the command a person runs is `BOOTSTRAP_COMMAND`, both in `meanwhile` and in "and then ... runs it". |

- **Hunks 1 and 2 carry no code.** In the merged file they sit at lines 1022-1023 and 1032-1070, inside `collision_follow_up`'s docstring (lines 1014-1084).
- **The rendered lines.** I loaded the function from `6fa1edd56`, from main, and from the merge, and rendered each `reads_first` (none, `GNUmakefile`, `makefile`) with and without `scripts/bootstrap.py` aside.
  - With no `reads_first`, the merge equals `6fa1edd56` (2 of 2).
  - With one, it equals main with " (`python` on Windows)" after `python3 scripts/bootstrap.py` in those two places (4 of 4). For a root that keeps `Makefile` and `GNUmakefile`, the line now ends "...; until then `python3 scripts/bootstrap.py` (`python` on Windows) is what the shape's `make bootstrap` runs".
- **NIT-2's tail where `make` reads another file** stays #1105's own: "neither `make bootstrap` nor `python3 scripts/bootstrap.py` runs the shape's bootstrap until then, and then `python3 scripts/bootstrap.py` (`python` on Windows) runs it". #1105's review fixed that branch for the same finding, so it needs nothing more.
- **Of #1240's list,** this round does item C for #1105's collision line, as #1240 asks a resolution to say. So the "with `python3`" part of "Left"'s second bullet is done. Item B, the kept directory, stays: #1105's lookup asks `_root_keeps`, so a root that keeps `Makefile` and `GNUmakefile/` is told GNU make "reads the source's `GNUmakefile`". Mutant U3 below shows that no row pins this line for a directory.

### #1105's test pins

`tests/test_adopt_collision_make_reads.py` is #1105's file, and it pins the texts this PR changes, as "Landing order" expected.
- **Four pins move.** `MERGE_IS_ENOUGH` and `BOTH_MERGES` (no `reads_first`) move to this PR's words. `MEANWHILE` and `SCRIPT_MERGED` (with one) move to `python` on Windows. All four go through a new `COMMAND`.
- **One comment changes.** The comment on the `Makefile+scripts` row now says what NIT-2's tail says.
- **Measured:** before the move, the six adopt and pin files gave 11 failed and 242 passed, and the 11 are exactly the rows that use those four pins. After the move, 253 passed.
- **On main's code:** main `46fe6ac19`'s four production files (`adopt-project.py`, `scripts/shape_materialize.py`, `templates/assembly-root/AGENTS-shape.md` and `README.md`) were swapped into the mutant worktree at `e5e077940`. 64 of the three files' 106 rows fail:
  - in this file, exactly those 11 rows, and the other 20 pass;
  - in the new file, 34 of 35, as before (NIT-4's row passes);
  - in `tests/test_adopt_next_make_reads_first.py`, 19 of 40, as before.

  The four files were then restored, each matched the head by sha256 (4 of 4), and the tree was clean.

### NIT-A

`tests/test_adopt_next_make_reads_first.py:64` called #480's NOTE "unchanged", but the merge of #1121 moved that pin to `python` on Windows. The comment now says the NOTE "names `python` on Windows too since #1132". The change is a comment only, in its own commit.

### The second merge, after #1123

Lane 1's #1118 and #1123 landed while the first merge was being gated: `9ec0dc79c` at 17:30Z and `9729315f1` at 17:47Z. Both merge with `e5e077940` with no conflict in the text. The predicted merge with `9ec0dc79c` passed (gate P below). The predicted merge with `9729315f1` failed 1 row of 254 (gate P2):
- **The row:** #1123 adds `test_a_name_only_a_disk_that_ignores_case_takes_for_makefile[makefile-directory]` to `tests/test_adopt_make_reads_first.py`. For a kept DIRECTORY `makefile/`, it expects the `CONTRACTS_DIR` line a kept `makefile` FILE gets ("only once `makefile` does"). #1123's own message says that line is not true for a directory, and that the row works as a crash guard for `twins` in `_makefile_follow_up`. This PR changes exactly that line: a kept directory is one GNU make stops at.
- **The fix, in the merge `01e93b6d1`:** the row now expects `SHAPES + written_as("makefile")`, as #1123 wrote it, which is the part that guards `twins`. This PR's directory clause follows, through a new `stops_at` helper beside `reads`. The comment above `ANSWERED` and the row's docstring now say so. Nothing else changes in the merge.
- **Mutants on the row.** Each ran alone in the mutant worktree at `01e93b6d1` against `tests/test_adopt_make_reads_first.py` (34 rows). Each was restored with `git checkout` and matched by sha256 (`adopt-project.py` `3c2cb5f5bea5`), and the tree was clean after each.

| id | mutant | rows failing | result |
|---|---|---|---|
| D1 | `_make_reads_first` drops a directory's `/`, so the line is the one #1123's row pinned | 1 of 34, the row | killed |
| D2 | #1123's R10: `twins` reads the entry with its slash | 1 of 34, the row, with `IndexError` | killed |

### Mutants on the resolution

H3a-H3g and U1-U3b are the first merge's. All ran in the mutant worktree at `e5e077940`.
- **Method:** each mutant was applied alone, then restored with `git checkout`. Each restore matched by sha256 (`shape_materialize.py` `b18f09d7c396`, `adopt-project.py` `2055a81acdc6`), and the tree was clean after each.
- **Rows:** H3a-H3g, U3 and U3b ran against 93 rows: `tests/test_adopt_collision_make_reads.py` (31), the new file (35) and `tests/test_adopt_e2e.py` (27). U1 ran against the new file, `tests/test_adopt_make_reads_first.py` (33) and `tests/test_adopt_collision_make_reads.py`, 99 rows. U2 ran against the new file and `tests/test_adopt_next_make_reads_first.py` (40), 75 rows.
- **Unmutated,** every row passes (gate A below).

| id | mutant | rows failing | result |
|---|---|---|---|
| H3a | hunk 3, no `reads_first`: `meanwhile` back to main's plain `python3` | 4 of 93 | killed |
| H3b | hunk 3, no `reads_first`: the script tail back to main's "before both merges are made" | 4 of 93 | killed |
| H3c | hunk 3, with `reads_first`: `meanwhile` without `BOOTSTRAP_COMMAND` (main's) | 5 of 93 | killed |
| H3d | hunk 3, with `reads_first`: "and then ... runs it" without `BOOTSTRAP_COMMAND` (main's) | 3 of 93 | killed |
| H3e | hunk 3, with `reads_first`: this PR's tail in place of #1105's | 3 of 93 | killed |
| H3f | hunk 3: `reads_first` ignored, so the resolution is this PR's side whole | 15 of 93 | killed |
| H3g | hunk 3, with `reads_first`: #1105's `joint` ("; and") lost | 3 of 93 | killed |
| U1 | `_makefile_follow_up` drops `_make_reads_first` (`first = None`) | 31 of 99 | killed |
| U2 | `_next_steps` drops `_make_reads_first` (`first = None`) | 35 of 75 | killed |
| U3 | `_predict_collisions` asks `_make_reads_first` in place of #1105's lookup | 0 of 93 | **survived** |
| U3b | `_predict_collisions` passes no `reads_first`, so #1105's lookup is lost in the merge | 15 of 93 | killed |

- **H3c and H3d** are killed only by rows that use the moved `MEANWHILE` and `SCRIPT_MERGED`. Those pins are what hold `BOOTSTRAP_COMMAND` in #1105's branch.
- **U3 survives,** and that is #1240's item B. For a kept file, both lookups give the same name. They differ only for a kept directory, where `_make_reads_first` adds the `/`, and no row runs `_predict_collisions` on a root that keeps `Makefile` and `GNUmakefile/`. A row now would pin the line #1240 is to correct, so this round adds none.

### Runs

Each run went through the lane's runner, with git-filter-repo 2.47.0 (CI's pin) on PATH, on a commit with a clean tree.

| run | git | files | passed | failed | skipped |
|---|---|---|---|---|---|
| gate A2 | 2.43.0 | `01e93b6d1`, the head: the 12 files below | 1096 | 0 | 3: the PowerShell rows (`test_shape_doctor.py:10516`), which run only on a Windows host |
| gate B2 | 2.47.1 | `01e93b6d1`: the same 12 files | 1096 | 0 | 3: the same |
| gate A | 2.43.0 | `e5e077940`, after the first merge: the 12 files below | 1095 | 0 | 3: the same |
| gate B | 2.47.1 | `e5e077940`: the same 12 files | 1095 | 0 | 3: the same |
| gate P | 2.43.0 | the predicted merge of `e5e077940` with main `9ec0dc79c` (#1118; tree `ba3f3c1f3`, a local commit object, not pushed): the six adopt and pin files, plus #1118's `tests/test_adopt_root_shape_paths.py` and `tests/test_adopt_symlink_target_bytes.py` | 509 | 0 | 0 |
| gate P2 | 2.43.0 | the predicted merge of `e5e077940` with main `9729315f1` (#1123; tree `0eb290ae2`, a local commit object): the six adopt and pin files | 253 | 1: #1123's `makefile-directory` row, which the second merge moves | 0 |
| gate P4 | 2.43.0 | the predicted merge of `01e93b6d1` with main `801a7d6d0` (#1106; tree `25bc27d39`, a local commit object): the six adopt and pin files, plus #1106's `tests/test_adopt_one_byte_locale.py` and `tests/test_adopt_unenterable_source_and_locale.py` | 277 | 0 | 0 |

The files are the six adopt and pin files (`tests/test_adopt_collision_make_reads.py`, `tests/test_pinned_copy_text.py`, the new file, `tests/test_adopt_next_make_reads_first.py`, `tests/test_adopt_e2e.py` and `tests/test_adopt_make_reads_first.py`, 253 rows), plus `tests/test_shape_doctor.py` and `tests/test_scaffold_leg_path_collisions.py`, which adopt changes need, plus the hygiene five (`test_path_guard_hygiene.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py` and `test_pinned_copy_text.py`). That is 1098 rows in one run at `e5e077940`, and 1099 at `01e93b6d1`, where #1123 adds one row.

All of this was measured on main `961640c68` with GNU make 4.3, offline.

| item | on main |
|---|---|
| 1, the template | `AGENTS-shape.md` (the "Work from here" paragraph and the five-commands table) and `README.md` (Get started, and the parking steps) give `make bootstrap` with no caveat. With the template's own `Makefile` beside a plain `GNUmakefile` or `makefile`, `make -n bootstrap` exits 2, `No rule to make target 'bootstrap'`. |
| 2, a kept directory | With `GNUmakefile/` or `makefile/` beside `Makefile`, `make bootstrap` exits 2 with `make: *** GNUmakefile: Is a directory.  Stop.`, or the same with `makefile`. It does this for every target, and for each directory alone too. Main's `CONTRACTS_DIR` line says "`make` there exports `CONTRACTS_DIR` only once `GNUmakefile` does". NEXT prints `make bootstrap` for a root that keeps only `GNUmakefile/`. Beside a kept `Makefile`, it prints #480's NOTE that the targets "arrive only once the follow-up merges the two". Rendered by the lane's `probe_texts.py` on main. |
| 3, `python3` | The template's `scripts/bootstrap.py` says `python3 scripts/bootstrap.py (python on Windows)`. #480's NOTE and the Makefile collision follow-up's "until then" name `python3` alone. |
| NIT-2 | The no-`reads_first` script-aside tail says "neither `make bootstrap` nor `python3 scripts/bootstrap.py` runs the shape's bootstrap before both merges are made". In a root that keeps its own `Makefile` and `scripts/bootstrap.py`, merging the script alone makes `python3 scripts/bootstrap.py` print the shape's `SHAPE-BOOT`, while `make bootstrap` still exits 2. Measured again by a row below. |
| NIT-3a | `_predict_collisions` says the wording comes from "the same function `execute` reports its actual collisions through", and `collision_follow_up` says "Both callers say it". `execute` never calls `collision_follow_up`: it carries the plan's stored `follow_ups:` into the split message. |

`adopt-project.py`:
- **`_make_reads_first(surviving)`** gives the first of `GNUmakefile` and `makefile` the root keeps. It reads `GNU_MAKE_READS`' order, asks the way `_root_keeps` asks, and adds a `/` for a DIRECTORY. **`_make_reads_first_clause`** words the clause. For a file, the words are main's, byte for byte. For a directory, it says: "GNU make at the root stops at the source's directory `GNUmakefile/`, the first of `GNUmakefile`, `makefile` and `Makefile` it finds (`GNUmakefile: Is a directory.`), so ... only once the root keeps no directory of that name".
- **`_makefile_follow_up`** (the `CONTRACTS_DIR` line) asks both. A kept file's line is unchanged: `tests/test_adopt_make_reads_first.py` passes as it is.
- **`_next_steps`** takes `surviving=`, the plan's root paths, which `cmd_execute` passes as `paths_for["root"]`, as #1121 does.
  - Where a kept DIRECTORY stops make, the command is `python3 scripts/bootstrap.py`. The NOTE says GNU make stops there. Beside a kept `Makefile`, it also says the targets run there once the follow-up merges the two, and only once the directory is gone. Where the script went aside too, alone or with the `Makefile`, the NOTE names that command after the merges.
  - Since the merge of #1121, a kept FILE goes through the same lookup and the same clause. For a file the clause gives #1121's words byte for byte, and its NOTEs end in `BOOTSTRAP_COMMAND` too.
- **`BOOTSTRAP_COMMAND`** is in every NOTE: #480's, #1121's and the directory's. Each command line a person pastes stays `python3 scripts/bootstrap.py`.
- **`_predict_collisions`' docstring** says what the plan stores and what `execute` carries (NIT-3a).

`scripts/shape_materialize.py`:
- **`BOOTSTRAP_COMMAND`** = "`python3 scripts/bootstrap.py` (`python` on Windows)", as the template's script names it.
- **The Makefile collision follow-up** says "until then `python3 scripts/bootstrap.py` (`python` on Windows) is what `make bootstrap` runs".
- **Where the script went aside too,** the follow-up says "so `python3 scripts/bootstrap.py` (`python` on Windows) runs the shape's bootstrap once the script is merged, and `make bootstrap` once both merges are made" (NIT-2).
- **The docstring's "Both callers say it"** paragraph says what is true (NIT-3a's twin, #1105's review NIT-4). #1105 made the same fix, and the merge keeps main's text (see "Main merge after #1105").

`templates/assembly-root/`:
- **`AGENTS-shape.md`:** the "Work from here" paragraph adds "; where the root keeps a `GNUmakefile` or a `makefile`, which GNU make reads before `Makefile`, run `python3 scripts/bootstrap.py` (`python` on Windows), which is what the shape's `make bootstrap` runs." A sentence after the five-commands table says they are the shape's Makefile's targets, which `make` at the root reaches only where the file it reads first holds them, or includes a Makefile that does.
- **`README.md`:** one sentence after Get started covers `make bootstrap` "here and below". A second says `make park` and `make resume` below work there only where that file holds them, or includes a Makefile that does.
- **This is a template byte change, so every project re-pins.** No digest of either file is stored in this repository. `tests/` was grepped for every changed line, and only this PR's own rows pin them (see "Template pins").

Tests:
- **`tests/test_adopt_e2e.py`:** two pins move to the new words, at :312 (the "until then" sentence) and at :447-450 (NIT-2's tail).
- **`tests/test_adopt_next_make_reads_first.py`** (#1121's): the merge moves three pins, `OWN_MAKEFILE_NOTE`, `RUNS` and `script_aside`, to `python` on Windows. #480's NOTE merged with no textual conflict, so without them its rows fail.
- **The new file `tests/test_adopt_make_bootstrap_followups.py`** has 35 rows:
  - the template's sentences, held to its `Makefile` (`PYTHON ?= python3`, `bootstrap:` runs `$(PYTHON) scripts/bootstrap.py`) and to `scripts/bootstrap.py`'s own words;
  - `make -n bootstrap` against the template's `Makefile` beside each kept name, each row asserting the `AGENTS-shape.md` caveat that measurement backs;
  - `_make_reads_first` (10 roots);
  - the `CONTRACTS_DIR` line for 4 directory roots;
  - NEXT for a directory, beside a kept `Makefile` (both names), after both merges, and after the script's merge alone;
  - #480's NOTE, called both as main's signature allows and with `surviving=`;
  - the collision follow-up's two sentences;
  - NIT-2 measured with the template's `Makefile` and stand-in scripts, and tied to the follow-up's new tail;
  - three real adoptions of a source holding `GNUmakefile/rules.mk`. One is kept by a `--path-policy` rule, so `plan` writes the line, `check` prints it and the split message carries it. One is answered `root`. In both, NEXT is asserted, `make -n bootstrap` in the pasted clone exits 2 with `GNUmakefile: Is a directory.`, and the printed command exits 0. In the third, the question is answered `code`: NEXT names `make bootstrap`, and that command runs in the pasted clone.

### This round (#1207's review, issuecomment-6374516382)

| finding | what changed |
|---|---|
| SHOULD-1 | A row pins NEXT where only `scripts/bootstrap.py` went aside beside a kept `GNUmakefile/` (`test_next_after_the_script_merge_names_python3_where_make_stops`). It fails under the review's mutant X1. |
| SHOULD-2 | The "Left" list is one issue, #1240. |
| SHOULD-3 | "Landing order", below. #1121 landed while this round ran, and is merged and resolved here. |
| NIT-1 | `AGENTS-shape.md`'s sentence after the table loses "(above)": nothing above names a Makefile that includes another. |
| NIT-2 | `README.md` names `make park` and `make resume`. |
| NIT-3 | Beside a kept `Makefile`, the directory NOTE names the merge as well as the directory. Its row is parametrized over `GNUmakefile/` and `makefile/`. |
| NIT-4 | An adoption sends `GNUmakefile/` to the code leg, and NEXT names `make bootstrap`. It fails under the review's mutant X6. |
| NIT-5 | The notes for the squash message at landing, below. |

Measured at the merge head `6fa1edd56`, in a scratch worktree:
- main `0ed3e57f8`'s bytes of the four production files (`adopt-project.py`, `scripts/shape_materialize.py`, `templates/assembly-root/AGENTS-shape.md`, `templates/assembly-root/README.md`) were checked out over the head;
- the new file (35 rows) and `tests/test_adopt_next_make_reads_first.py` (40 rows) ran through the lane's runner;
- the four files were restored with `git checkout`, and each matched the head by sha256 (`c94bd19b`, `12555f39`, `8660bafb`, `b3f9409b`).

| file | fail on main | pass on main |
|---|---|---|
| the new file | 34 | 1: `test_an_adoption_sending_a_gnumakefile_directory_to_a_leg` |
| `tests/test_adopt_next_make_reads_first.py` | 19 | 21 |

Main now has #1121, so its `_next_steps` takes `surviving=`. The NEXT rows for a kept directory therefore fail on their words, not on a `TypeError`. Main reads `GNUmakefile/` as a file: "then run `python3 scripts/bootstrap.py`; GNU make at the root reads the source's `GNUmakefile`, ...". This is the case for the SHOULD-1 row, the after-the-merges row, both directory rows, and NIT-3's `makefile/` row.

The new file's other rows fail on main for these reasons:
- the template rows: main's `AGENTS-shape.md` and `README.md` lack the sentences;
- `_make_reads_first`: main has no such helper;
- the `CONTRACTS_DIR` line: "only once `GNUmakefile` does";
- the NOTE and collision rows: plain `python3`, and NIT-2's old tail;
- the two adoptions that keep the directory.

The one new row that passes on main is NIT-4's. It sends `GNUmakefile/` to the code leg, and there main's NEXT is already `make bootstrap`. It guards what main has: under the review's mutant X6, which hands NEXT every leg's paths, it is the row that fails.

The 19 rows of #1121's file that fail on main are exactly the rows that use the three pins this merge moved (`OWN_MAKEFILE_NOTE`, `RUNS`, `script_aside`). The 21 that pass use none of them.

Earlier heads, on main `961640c68` and with the same method: `1c7ca5c6d` failed 29 of 35 rows, and `8b499842f` failed 34 of 35 (the new file plus the three `tests/test_adopt_e2e.py` rows the mutants use).

**This round**, at `75c90b6db`, in a scratch worktree. Each mutant was applied alone and restored, and each file's sha256 was checked against the commit (4/4 every time). Each ran against 95 rows: the new file (35), `tests/test_adopt_e2e.py` (27) and `tests/test_adopt_make_reads_first.py` (33). Unmutated, all 95 pass.

| id | mutant | rows failing (of 95) | result |
|---|---|---|---|
| X1 | the review's: the script-only NOTE names `make bootstrap` after the merge (`if stops and makefile`) | 1 | killed |
| X6 | the review's: `cmd_execute` passes every leg's paths | 1 | killed |
| N1 | NIT-3 undone: beside a kept `Makefile`, no merge named | 2 | killed |
| N2 | the merge named where the script went aside too | 1 | killed |
| N3 | the merge named where no `Makefile` was kept | 4 | killed |
| T1 | NIT-1 undone: "(above)" back | 1 | killed |
| T2 | NIT-2 undone: README's `make park` / `make resume` sentence removed | 1 | killed |

The merge with #1121 rewrote the clause code that N1-N3 mutate (`stopped` became `reads`, and the merge condition gained `stops`). The mutants were not run again at `6fa1edd56`.

**Earlier,** 18 mutants at `1c7ca5c6d` against 43 rows, each applied alone in a scratch worktree and restored with its sha256 checked:

| id | mutant | rows failing (of 43) | result |
|---|---|---|---|
| M1 | `_make_reads_first` drops the `/` of a directory | 14 | killed |
| M2 | the clause's directory branch never taken | 10 | killed |
| M3 | NEXT ignores a kept directory (`stops = None`) | 6 | killed |
| M4 | NEXT's command stays `make bootstrap` for a directory | 4 | killed |
| M5 | `cmd_execute` passes `surviving=()` | 2 (the two adoptions) | killed |
| M6 | `BOOTSTRAP_COMMAND` without `(python on Windows)` | 12 | killed |
| M7 | NIT-2's tail back to "neither ... before both merges are made" | 2 | killed |
| M8 | `AGENTS-shape.md`'s "Work from here" caveat removed | 1 | killed |
| M9 | the sentence after the five-commands table removed | 1 | killed |
| M10 | `README.md`'s sentence removed | 1 | killed |
| M11 | #480's NOTE back to plain `python3` | 1 | killed |
| M12 | the collision follow-up's "until then" back to plain `python3` | 2 | killed |
| M13 | after the merges, NEXT says `make bootstrap` where a directory stops make | 1 | killed |
| M14 | the lookup takes the LAST kept name (`reversed`) | 4 | killed |
| M15 | the directory NOTE's lead ignores a kept `Makefile` | 1 | killed |
| M16 | the lookup folds case (`fold=True`) | 4 | killed |
| M17 | the clause drops GNU make's own words (`GNUmakefile: Is a directory.`) | 10 | killed |
| M18 | the file clause says `does` whatever `has` is | 0 | **survived** |

M18 was equivalent before the merge: the file clause's only caller was the `CONTRACTS_DIR` line. Since the merge, #1121's NOTE calls it with `has them`, and `tests/test_adopt_next_make_reads_first.py` pins that wording. M18 was not run again.

Each run went through the lane's runner: `gh` stubbed first on PATH with a bogus token, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`, `EMAIL` set, `-p no:cacheprovider`, and `tmp_path_retention_policy=failed`. Each run is on a commit, with a clean tree.

| run | commit | git | files | passed | failed |
|---|---|---|---|---|---|
| gate M | `6fa1edd56`, the merge | 2.43.0 | the new file, `test_adopt_next_make_reads_first.py`, `test_adopt_e2e.py`, `test_adopt_make_reads_first.py`, `test_path_guard_hygiene.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_pinned_copy_text.py`, `test_scaffold_leg_path_collisions.py` | 618 | 0 |
| gate N | `6fa1edd56` | 2.47.1 | the new file and `test_adopt_next_make_reads_first.py` (75), then `test_adopt_e2e.py` (27), run one after the other | 102 | 0 |
| gate H | `6fa1edd56` | 2.47.1 | the hygiene five | 379 | 0 |
| gate A2 | `75c90b6db` | 2.43.0 | the new file, `test_adopt_e2e.py`, the hygiene five (`test_path_guard_hygiene.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_pinned_copy_text.py`), `test_adopt_make_reads_first.py`, `test_adopt_contracts_dir.py`, `test_adopt_printed_sentences.py`, and the template's readers: `test_scaffold_e2e.py`, `test_scaffold_free_text_values.py`, `test_free_plan_hint.py`, `test_template_quoted_values.py`, `test_template_values_single_pass.py`, `test_template_text_placeholders.py`, `test_bootstrap_placement_texts.py`, `test_park_resume_targets.py`, `test_update_shape_hardlink.py` | 1128 | 0 |
| gate A | `1c7ca5c6d` | 2.43.0 | 17 files (the new file, `test_adopt_e2e.py`, the hygiene five, `test_scaffold_leg_path_collisions.py`, `test_adopt_make_reads_first.py`, `test_adopt_contracts_dir.py`, `test_adopt_printed_sentences.py`, `test_adopt_attributes_eol_block.py`, `test_adopt_leg_path_collisions.py`, `test_scaffold_e2e.py`, `test_bootstrap_placement_texts.py`, `test_park_resume_targets.py`, `test_template_text_placeholders.py`) | 966 | 0 |
| gate B | `1c7ca5c6d` | 2.47.1 | the new file, `test_adopt_e2e.py`, `test_path_guard_hygiene.py`, `test_repo_hygiene.py` | 209 | 0 |
| doctor | `1c7ca5c6d` | 2.43.0 | `test_shape_doctor.py`, `test_scaffold_leg_path_collisions.py` | 549 (3 skipped: the PowerShell rows, which run only on a Windows host) | 0 |

`tests/` was grepped for every line this PR changes in `templates/assembly-root/` ("They are the shape", "reads first holds", "includes a Makefile that", "(above)", "here and below", "Where this root keeps", "it is what the shape", "before `Makefile`", "`python` on Windows"). Only `tests/test_adopt_make_bootstrap_followups.py` pins them. Four other files read the README as a whole, and each passes in gate A2:
- `test_scaffold_free_text_values.py` counts its lines against the template;
- `test_free_plan_hint.py` looks for "Free plan" and `--repo`;
- `test_template_quoted_values.py` and `test_template_values_single_pass.py` render it.

`git merge-tree --write-tree` of the head `01e93b6d1`, on git 2.43.0, at 18:11Z.
- **Main** `9729315f1`, fetched again at 18:11Z, is the main this round merged last. The merge-tree is clean, and its tree is the head's own, `f6488542f`.
- **Main moved to `801a7d6d0`** at 18:14Z, when lane 3's #1106 landed (`adopt-project.py` and two locale test files). The merge with it is clean, tree `25bc27d39`, and gate P4 passed on it. It was not merged in: it touches none of the code this round resolves, and landing refreshes the branch anyway.
- **The open PRs:** one `gh pr list --state open --limit 100 --json number,headRefName,headRefOid,files` read at 18:11Z returned 51 open PRs. 26 of them touch one of this PR's paths. That includes `tests/test_adopt_collision_make_reads.py` and `tests/test_adopt_make_reads_first.py`, which this PR now changes. 25 merge cleanly: #1106, #1120, #1122, #1125, #1141, #1143, #1153, #1154, #1170, #1171, #1177, #1178, #1186, #1189, #1194, #1198, #1210, #1225, #1244, #1249, #1251, #1252, #1253, #1265 and #1268. #1106 has landed since (above).
- **Lane 1's #1189** (`472944b5d`, read fresh at 18:11Z) edits three test files that this round changes too: `tests/test_adopt_next_make_reads_first.py`, `tests/test_adopt_collision_make_reads.py` and `tests/test_adopt_make_reads_first.py`. The merge is clean, tree `7fd030b29`. #1189's hunks are in the imports, in the clone rows' parameter lists and docstrings, and in a new refusal row, away from the moved pins, the `makefile-directory` row and NIT-A's comment. On that predicted merge, a local commit object, the three shared files pass on git 2.43.0: `tests/test_adopt_make_reads_first.py` and `tests/test_adopt_collision_make_reads.py` give 62 passed, and `tests/test_adopt_next_make_reads_first.py` gives 38 passed. There are fewer rows than at this head because #1189 leaves out the clone rows that keep case twins.
- **#1237** (`b557859fd`) conflicts in `tests/test_adopt_check_throwaway_repos.py`, a file this PR does not change. Main conflicts with #1237 in that file too, while `6fa1edd56` did not, so the conflict is main's, carried in by the merge.
- **Main moved again after the round** (rv1207e, at 19:25Z): lane 1's #1143 landed as `81eaf757c` (`adopt-project.py`) and lane 4's #1117 as `9252ef208` (README, the handbook and `scripts/family.py`). The head merges clean with both (trees `e40373967` and `279f8c6ce`), and rv1207e's gate P passed on the predicted merge with `9252ef208` (359 passed). At 19:22Z, 48 PRs were open, 24 touch this PR's files, and 23 merge clean; #1237's conflict is main's, as above.
- **No longer on the list:** #1105, #1121, #1118 and #1123 are on main. Since `6fa1edd56`, #1104, #1160, #1169, #1180, #1190 and #1223 landed too.

The conflicts are the same in every order, measured offline both ways round and with predicted squashes:
- **#1121:** 4 hunks, 84 lines, in `adopt-project.py`'s `_next_steps` and `cmd_execute`'s comment.
- **#1105:** 3 hunks, 65 lines, in `scripts/shape_materialize.py`'s `collision_follow_up`: its docstring's NIT-4 sentence, its paragraph on the Makefile's merge, and its two `return`s.
- #1121 and #1105 merge with each other cleanly, and so do #1104 and this PR.

So the order decides only who resolves them. **Both have landed**, and lane 5 resolved both on this branch:
- **#1121** (merge `6fa1edd56`). The resolution is one lookup and one clause, `BOOTSTRAP_COMMAND` in #1121's NOTEs, and #1121's three test pins moved.
- **#1105** (merge `7b0192bde`, see "Main merge after #1105"). The resolution keeps #1105's `reads_first` branch and runs `BOOTSTRAP_COMMAND` through it. NIT-2's tail is this PR's where `make` reads the merged `Makefile`, and #1105's where `make` reads another file. Four of #1105's test pins moved: `MERGE_IS_ENOUGH`, `BOTH_MERGES`, `MEANWHILE` and `SCRIPT_MERGED`.

Main was merged in once more after lane 1's #1123 (merge `01e93b6d1`): one of #1123's rows pinned the line this PR changes for a kept `makefile/` directory, and it moved (see "The second merge, after #1123").

No other PR has to land first now. `6fa1edd56` was re-checked READY (rv1207b, rv1207c and rv1207d, issuecomment-6722602375). The head, `01e93b6d1`, needs a check of its delta since then: the two merges' resolutions, the four moved pins in #1105's file, #1123's moved row, and NIT-A.

#1240 tracks what this PR leaves:
- #1132's item 4 (one `GNU_MAKE_READS`, one lookup);
- #1105's collision line, for a kept directory (its `python3` part is done here: see "Main merge after #1105");
- #1121's file NOTE beside a kept `Makefile`, which does not name the merge (as this PR's directory NOTE now does);
- #1105's second review's NIT-1, NIT-3b, NIT-3c, NIT-4 and NIT-5;
- `_make_reads_first_clause`'s `has` argument, now live;
- rv1207e's NIT-1: the docstring sentence "With none, the Makefile's follow-up is what it was, byte for byte" (`scripts/shape_materialize.py` about :1067), kept from #1105, is no longer true, since this PR changes the line with no `reads_first`. It sits beside #1240's items F and G, which reword the same docstring.

The local branch `fix/1132-template-bootstrap-stacked` (`9eb5cce0b`, never pushed and never tested) is a model of the end state on #1105 + #1121 + main.

- **Item 5,** follow-ups that go stale after a hand edit of the plan, is its own issue, filed as #1206. It also holds the three related cases this work found:
  - a kept `makefile/` directory gets "until the two are merged";
  - a directory kept only through deeper entries is not seen by `_root_keeps`;
  - a kept `Makefile/` directory is treated as a Makefile.
- **This PR's `answered-root` row meets item 5 too.** `plan` asks about `GNUmakefile/`, and its stored `CONTRACTS_DIR` line, written before the answer, names no directory. The row asserts what NEXT and `make` do there, and not that line.
- **Not measured:** a disk that ignores case, and GNU make other than 4.3.

Review: rv1207b and rv1207c, lane 5's independent opus reviewers, and rv1207d, lane 5's independent sonnet reviewer, none of which wrote #1207: READY at `6fa1edd5` (issuecomment-6722602375). Then rv1207e, lane 5's independent opus reviewer, which wrote none of it: READY at `01e93b6d`, H0 of this landing, after the main merges past #1105 and #1123 (issuecomment-6726033282), with no MUST or SHOULD; its three NITs are answered in the body. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 01e93b6 passed tests, tests-git-bash and SonarCloud; the head c9be952 is a clean update-branch merge of main 9cb156a, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 0a01d35 over main 9cb156a: the adopt_collision_make_reads, adopt_e2e, adopt_make_bootstrap_followups, adopt_make_reads_first, adopt_next_make_reads_first, repo_hygiene, agents_md_codes, cli_reference sets, 333 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 #1132

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 10, 2026
…e `.gitattributes` it leaves and reads its exit code, so the root's failing filter is `plan-attributes-reencoded` alone, and a refused compare is named in git's words (#1192) (#1253)

On main at `93d29314` (#1169's squash), `adopt-project.py check` asked the mount's `git rm` as a content compare: `update-index -q --refresh` over its `TAKEN` index, then `diff-files --name-only`. That index holds what the plan sends to a leg or drops, and also every `.gitattributes` of the tree, the root's own kept one included, so the refresh ran the clean filter of rules `git rm` never compares, and nothing read its exit code. Under a required clean filter that fails for the root's `.gitattributes`, the refresh died there and `diff-files` listed every file it had not reached. `check` then added a false `plan-file-reencoded`, naming a file the split stores as its blob (`specs/001-feature/spec.md`), to the one true finding, `plan-attributes-reencoded`.

Outside v1.2.0 by Brett Heap's ruling of 2026-10-21T10:32:04Z

- The compare now asks what `git rm` asks. Each `.gitattributes` the mount leaves stays in `TAKEN`, so git still reads the rules from the index, and is marked with `update-index -z --assume-unchanged --stdin`, which `update-index --refresh` and `diff-files` both skip. A `.gitattributes` the plan sends to a leg or drops is in `moved` and is still compared. The root's own failing filter is then `plan-attributes-reencoded` alone (#530's D5(a) question), and a kept nested `.gitattributes` whose filter fails is named by the split's `git add -u` (`STORE_REFUSED`).
- The refresh's exit code is read. `-q` makes a file that needs an update no error, so a nonzero exit is git refusing the compare. Its list is then not read, and the finding is git's last line (`COMPARE_REFUSED`, through `_refused_finding`), in the groups' order: the store's, the compare's, the copies'. The plainest reading, "read the code and name nothing on a failure", was not taken: for a filter that fails for a file the plan sends to a leg or drops, `execute` with `check`'s refusal patched out made both leg repositories and stopped extracting one, so naming nothing would have been a false `plan ok`.
- `STOPS_AT_A_READ` says where `execute` stops for such a refusal: "at the first step that reads that file again: extracting the spec leg, extracting the code leg once the spec leg is pushed, or, once both legs are pushed, cloning the source for the split or mounting the legs". Which of them it is, the clock decides (`READ_AGAIN`). `STOPS_AT_A_CLONE`'s comment now says each leg is extracted and then pushed in turn, the spec leg first.
- Not changed: D5(a)'s question (`_stored_as_written`), the split's side (`git add -u`), the pinned copies' question, `execute`'s own commands, what `TAKEN` holds, and any doc, `--help` text or doc-trio row. `tests/test_adopt_check_throwaway_repos.py` holds 27 new ids in 10 functions: 18 by `18c2aaef`, and 9 in the review round (3 in `898e9a5e`, 6 in `ad70474d`).
- Measured with git 2.43 and CPython 3.12.3, base `93d29314` against head `18c2aaef` (the review round changes the stop text, comments and rows, not the compare):
  - #1192's case (a side-branch tree named by `attr.tree` says `/.gitattributes filter=w1136` and `*.md text`; `filter.w1136` is `clean=false`, `smudge=cat`, `required=true`, set in a global config, in `GIT_CONFIG_PARAMETERS` and in `GIT_CONFIG_COUNT`) went from exit 1 with `plan-attributes-reencoded` and the false `plan-file-reencoded`, to exit 1 with `plan-attributes-reencoded` alone. In process on base, the refresh gave exit 128 (`fatal: .gitattributes: clean filter 'w1136' failed`) and `diff-files` listed `.gitattributes specs/001-feature/spec.md`; at head the `--assume-unchanged` call and the refresh give exit 0 and `diff-files` lists nothing.
  - A root rule `/notes.txt filter=w1136`, with `notes.txt` sent to the spec leg, the code leg or dropped, went from `plan-file-reencoded` read off the files the dead compare never reached, to the refusal in git's words (`fatal: notes.txt: clean filter 'w1136' failed`). `execute` refuses before any leg on both.
  - The 18 rows: 11 failed and 7 passed on base, 18 passed at head. The 7 are the controls, which give the same result on both: a spec committed with CRLF (the compare runs to its end and names it), a CRLF `docs/.gitattributes` sent to the spec leg under the root's `* text eol=lf` (only the rules `git rm` leaves are skipped), and the side tree's `*.md text` alone (`plan ok`). The 9 round rows: 9 failed on base, 3 failed at `18c2aaef` (the `execute` ids that run as if each extraction trusted the file's stat data), 9 passed at `ad70474d`. On base, a kept `.github/<folder>/.gitattributes` whose filter fails, its folder named in UTF-8 or not, got a second finding, a false `plan-file-reencoded` naming `specs/001-feature/spec.md`: a second form of #1192, cured by the change and held by no row until the round.
  - Mutants of the changed lines, each run in a scratch clone and restored after (the blob read back): 10 of 10 killed at `18c2aaef`, and 11 of 11 at `ad70474d`. r1253's mutants R2 and R9 survived every row at `18c2aaef`; the kept row kills both. r1253b's own 14 are killed too.
  - Where `execute` stops with `check`'s refusal patched out, over 72 runs at `ad70474d`: extracting the spec leg 47, extracting the code leg once the spec leg was pushed 1, and, with the filter kept out of both extractions, cloning the source for the split with both legs pushed 24 of 24. All 72 passed, none skipped. r1253b's 144 runs under load all passed, none skipped (72 at the spec leg, 72 at the split's clone).
- Known limits:
  - Where git refuses the compare, only its first refusal is named. The files it did not reach are not named until it no longer refuses, as with the split's refused `git add -u`.
  - Where `execute` stops for such a refusal is the clock's. The mount's `git rm` was never the first to read the file in any run: after a checkout that trusted the file's stat data, no later index write falls in the second it was written, unless the clock steps back. In r1253b's probe the mount was the first read only with the index's mtime set by hand (r1253b's NIT-A). In a run where no step reads the file again, `execute` may go on: the finding names a source that can fail, as the moved-files finding does (`READ_AGAIN`).
  - git's words are its last line in this machine's locale, as for every refusal the rehearsal names. Measured with git 2.43 only: that `update-index --refresh` and `diff-files` skip an `--assume-unchanged` entry is git's documented behaviour, and CI's git was not measured.
  - A path that is not UTF-8 prints as U+FFFD in git's words (pre-existing, r1253's NIT-2). `_SplitRehearsal.refused` decodes git's stderr with `replace`, so the round's row holds every word of that finding but the folder's name. It reaches every refusal the rehearsal names. Follow-up text, if wanted: "adopt-project.py: `_SplitRehearsal.refused` decodes git's stderr with `replace`, so a path that is not UTF-8 in a refusal `check` names prints as U+FFFD, not as `_spelled`'s escape (measured on #1253's branch with git 2.43: `fatal: .github/caf<U+FFFD>/.gitattributes: clean filter 'w1136' failed`)".

Review: lane 1's independent reviewers reviewed each round. r1253 found `18c2aaef` NOT READY, for two SHOULDs and two NITs with no MUST (#1253 issuecomment-6723519114): SHOULD-1, the stop text left out cloning the source for the split, and SHOULD-2, no row for a kept nested `.gitattributes` or a name that is not UTF-8. The fix round `ad70474d` (`845666aa`, `898e9a5e`, `ad70474d`) answered both and NIT-1, the `STOPS_AT_A_CLONE` comment, and left NIT-2 as a Known limit (issuecomment-6724083501). r1253b found `ad70474d` READY, with no MUST or SHOULD and two NITs (issuecomment-6724522433). NIT-A's sentence is applied to the body and to this message. NIT-B, an optional rewrite of the optional row so that it forces each read instead of waiting for the clock, is left as a follow-up idea.

Tests, under a stub `gh` with a bogus token, at `ad70474d` with CPython 3.12.3 and git 2.43, each call under 9 minutes and at most two at once: the #1192 section (10 functions) 27 passed; `tests/test_adopt_check_throwaway_repos.py` 178 passed and 1 skipped (the row that needs a disk that ignores case); `tests/test_adopt_reencoded_files.py` with `tests/test_adopt_nested_attributes.py` 126 passed; `tests/test_adopt_printed_sentences.py` with `tests/test_adopt_single_repository_record.py` 93 passed; `tests/test_scaffold_leg_path_collisions.py` 104 passed; `tests/test_path_guard_hygiene.py` with `tests/test_no_auto_maintenance.py` 179 passed and 2 skipped (both `test_no_auto_maintenance.py:222`, which the probe environment's `GIT_CONFIG_GLOBAL=/dev/null` causes); the doc trio 171 passed; `tests/test_shape_doctor.py` in 4 shards 112 passed and 1 skipped, 111 passed and 1 skipped, 112 passed, and 111 passed and 1 skipped (the skip is the PowerShell row, which runs only on a Windows host). r1253b's summary lines at `ad70474d` equal every one of those counts but the shards, which its brief forbade; on a clean merge of `origin/main` at `12122952` it got 178 passed and 1 skipped for the throwaway file. At `18c2aaef` the same files gave the same counts, summed over each pair, but for the throwaway file's 169 passed and 1 skipped, the 9 round ids being the difference.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its first H0, `ad70474d`, ran `tests` on 2026-11-08; after #1293 mended main, the branch was refreshed over it, and that head, `dcd5d8b0`, is its H0. Its H0 `dcd5d8b0` is green on Linux and SonarCloud. The branch was never rebased. Any commit after H0 is update-branch's clean merge of main, and the head that lands, 91c8932, is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: 23 pytest calls over `tests/test_adopt_check_throwaway_repos.py`, `tests/test_adopt_reencoded_files.py`, `tests/test_adopt_nested_attributes.py`, `tests/test_adopt_printed_sentences.py`, `tests/test_adopt_single_repository_record.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_path_guard_hygiene.py`, `tests/test_no_auto_maintenance.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`, `tests/test_repo_hygiene.py`, `tests/test_adopt_tracking_branch.py`, `tests/test_adopt_unreadable_git_parts.py`, `tests/test_chain_link_fold.py`, `tests/test_naming_policy_line_break.py`, `tests/test_pinned_copy_columns.py`, `tests/test_siblings_family_name.py`, `tests/test_update_shape_apply_lines_and_headers.py`, `tests/test_update_shape_header_comments.py`, `tests/test_update_shape_messages.py`, `tests/test_update_shape_unreadable.py`, `tests/test_validate_family_member_id_pattern.py`, `tests/test_validate_family_unreadable.py`, `tests/test_validate_pins_spelled_and_named.py`, `tests/test_value_pattern_line_break.py` and `tests/test_shape_doctor.py` in 4 shards: 2,257 passed, 7 skipped.

Closes #1192

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 10, 2026
…not text as `_read_as` says what the plan's reader read, "records `commit:` as the number 7", where it spelled it as Python does, `commit: 7` and `True`, so `check` and `execute` name it in the words the plan's other refusals use under the same `plan-pin-malformed` (#1114) (#1198)

This PR lands only after the v1.2.0 tag, on Brett's word.

## What

`_pin_row_problem` holds each of a pin's five fields (`product`, `repository`, `commit`, `tree_sha256`, `digest_source`) to text. A value that is not text it spelled with Python's `repr`: a number as `commit: 7`, a boolean as `True`, a list as `['a', 'b']` and a mapping as `{'k': 'v'}`. Every other refusal of a plan's value says what the reader read, with `_read_as`: "the number 7", "the boolean true", "a list of 2 item(s)", "a mapping of 1 key(s)". W1020 found the gap while working on #1020.

It says it that way now, field by field, in `adopt-project.py`'s `_pin_row_problem`, in the `not_text` branch only:

- one field: ``records `commit:` as the number 7, which is not text``
- two or more: ``records `commit:` as the number 7, `tree_sha256:` as the boolean true, which are not text``

What stays as it was:

- the code, `plan-pin-malformed`, the remedy, and the sentence's ending, `which is not text`;
- every text value's spelling, byte for byte (`commit: '7'`, which is not 40 hex);
- a null or a blank: it is "records no `commit:`" under the code it has always had, and no value is spelled there.

Every field takes the one site (`PIN_FIELDS`). The docstring and a comment say so. The change is 13 lines added and 4 removed in `adopt-project.py`.

## Behaviour change

A pin field that is a number, a boolean, a list or a mapping is named by what it was read as, where it was named as Python spells it. A text value, a null and a blank print as they did.

## Rows

`tests/test_adopt_pin_row_values.py` is new, ASCII only, with 45 rows. Each runs `check` and then `execute --yes`, with a stub `gh` first on PATH and `--local-remote-dir`, and asserts one finding, `execute` refusing with that same finding, an empty `gh` log, no remotes directory made and no Traceback.

| Group | Rows | On main's code | On this head |
| --- | --- | --- | --- |
| The matrix: 5 fields by number, boolean, list, mapping | 20 | 20 failed | 20 passed |
| The whole sentence: `commit: 7`, and a boolean `digest_source` | 2 | 2 failed | 2 passed |
| Edge forms: 0, false, 7.5, -3, 007, `[]`, `{}`, `[a]` | 8 | 8 failed | 8 passed |
| Two fields at once, a non-text commit beside bad text, entry 2 | 3 | 3 failed | 3 passed |
| Null and blank controls | 6 | 6 passed | 6 passed |
| Text controls (`commit: "7"` and `"true"`, another product, no owner, short digest, source by hand) | 6 | 6 passed | 6 passed |
| **All** | **45** | **33 failed, 12 passed** | **45 passed** |

Every one of the 33 failures is the finding text holding the repr spelling (``records `commit: 7` ``, `True`, `['a', 'b']`, `{'k': 'v'}`, `{}`, `[]`). There is no crash or other cause. The 12 that pass on main are the controls, which pin what must not change.

Two existing rows pinned the old spelling, and no others did. Each now expects the new one, and both pass on this head (2 passed):

- `tests/test_adopt_pins.py`, the `a-number-commit` row: `` `commit:` as the number 1234567 ``;
- `tests/test_adopt_plan_value_order.py`, the `not-text` row: `` `digest_source:` as the number 7 ``.

## Mutants

19 mutants of this head's `adopt-project.py`, all killed. Each was run against the new file (first failure with `-x`, or a whole run where the table says so), and each run was restored with its sha256 checked.

| Mutant | Result |
| --- | --- |
| M1: main's repr form put back | killed, 33 failed and 12 passed |
| M2: `!r` inside the new "as" form | killed, 33 failed and 12 passed |
| M3: `str()` in place of `_read_as` | killed |
| F-repr-product, F-repr-repository, F-repr-commit, F-repr-tree_sha256, F-repr-digest_source: repr put back for one field only | 5 of 5 killed, each by that field's own rows |
| M4: "is" in place of "as" | killed |
| M5: singular and plural swapped | killed |
| M6: only the first field named | killed |
| M7: `missing` takes any falsy value (an existing guard; the zero, false and empty rows pin it) | killed |
| M8: only a list and a mapping counted as not text | killed |
| M9: the ending without "how it was read" | killed |
| M10: the code `plan-pin-unrecorded` | killed |
| T1 to T4: the text branches unquoted (commit, product and repository, digest, source) | 4 of 4 killed |

## Gate

A scratch merge of this head `d38e4e36d` with main `961640c68`: commit `1ef3f0783`, tree `b12197610`, which is this head's own tree. Targeted files only, in the landing gate's environment (a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, an empty global git config). The set is 47 test files: every one that names `plan-pin-malformed`, `plan-pin-unrecorded`, `_pin_row_problem`, `_read_pins` or `pins:`, the new file, and `test_path_guard_hygiene.py`, `test_repo_hygiene.py` and `test_scaffold_leg_path_collisions.py`; and `test_shape_doctor.py` (448 ids, each in exactly one shard, run from a script that reads each list into an array because 25 ids hold spaces). Every chunk exited 0, with 0 failed.

| Chunk | Files | Result |
| --- | --- | --- |
| f01 | `test_adopt_pins.py` | 150 passed |
| f02 | `test_adopt_plan_yaml_quoting.py`, `test_adopt_plan.py` | 606 passed, 1 skipped |
| f03 | `test_adopt_execute_refusals.py`, `test_adopt_pin_row_values.py`, `test_adopt_plan_escapes.py`, `test_adopt_plan_value_order.py`, `test_adopt_reencoded_files.py`, `test_adopt_root_symlinks.py`, `test_adopt_shape_pin_and_work_dir.py`, `test_adopt_template_values.py` | 661 passed, 3 skipped |
| f04 | `test_bump_remedies_family_root.py`, `test_chain_link_fold.py`, `test_chain_pin_sources.py`, `test_chain_remedy_callers.py`, `test_doctor_gitlink_repairs.py`, `test_doctor_recorded_answers.py`, `test_family_add_member_id.py`, `test_family_makefile_siblings.py` | 255 passed |
| f05 | `test_family_tracking_branch.py`, `test_legs_not_a_list.py`, `test_link_source_chain_defaults.py`, `test_manifest_validator.py`, `test_naming_policy.py`, `test_neutral_pin_entry_one_spelling.py`, `test_neutral_pin_validator.py`, `test_non_list_project_fields.py` | 757 passed |
| f06 | `test_path_guard_hygiene.py`, `test_pin_annotated_tag.py`, `test_pin_source_env_collisions.py`, `test_pinned_copy_text.py`, `test_reader_refusals_named.py`, `test_repo_hygiene.py`, `test_scaffold_e2e.py`, `test_scaffold_leg_path_collisions.py` | 599 passed |
| f07 | `test_scaffold_pin_and_reuse.py`, `test_scaffold_pin_collisions.py`, `test_scaffold_referent_chain_links.py`, `test_setup_project_py.py`, `test_template_text_placeholders.py`, `test_update_shape_add_ignored.py`, `test_update_shape_landing.py`, `test_validate_pins_named_and_spelled.py` | 1078 passed |
| f08 | `test_yaml_flow_brackets.py`, `test_yaml_flow_quotes.py`, `test_yaml_indicator_openers.py`, `test_yaml_reader_spaces.py` | 813 passed |
| d01 | `test_shape_doctor.py`, 56 ids | 56 passed |
| d02 | the same, 56 ids | 56 passed |
| d03 | the same, 56 ids | 56 passed |
| d04 | the same, 56 ids | 56 passed |
| d05 | the same, 56 ids | 56 passed |
| d06 | the same, 56 ids | 56 passed |
| d07 | the same, 56 ids | 56 passed |
| d08 | the same, 56 ids | 53 passed, 3 skipped |

The file chunks hold 4919 passed and 4 skipped (f02: `SHAPE_MEDXEHR_CLONE` is unset; f03: the reftable backend needs git 2.45). The doctor shards hold 445 passed and 3 skipped (d08: the PowerShell line is printed only on a Windows host). In all, **5364 passed, 7 skipped, 0 failed.**

**Run on the merge with #1125's head.** A scratch merge of this head with #1125's head `9549006fe`: commit `8a291d8b2`, tree `88530bb7`. The new file (45), `test_adopt_pins.py` (150), `test_adopt_plan_value_order.py` (40), `test_adopt_execute_refusals.py` (181) and `test_adopt_check_plan_name.py` (45): **461 passed.**

## Merge-trees

`git merge-tree --write-tree`, this head against each, all clean (exit 0), at the heads of 2026-10-23T08:20Z:

- `origin/main` `961640c68`;
- the 23 open PRs that touch `adopt-project.py`: #1194 `900f77af9`, #1190 `00f959c61`, #1189 `4d40ce9fd`, #1186 `07486a498`, #1180 `b316f4157`, #1178 `c4beba193`, #1177 `5208e874e`, #1171 `8162b874e`, #1170 `24ee133aa`, #1169 `258051022`, #1154 `8a7828332`, #1153 `ff33ba12a`, #1143 `7af5fabc3`, #1141 `7a2711d03`, #1125 `9549006fe`, #1123 `5c3908f8d`, #1122 `c01c4d73d`, #1121 `1c5173758`, #1120 `c55e55e33`, #1119 `bba168bdd`, #1118 `2307036ab`, #1106 `504a1dd65`, #1105 `b28f40de0`;
- #1126 `4c1f80cf4`, which touches `tests/test_adopt_pins.py`.

## This PR needs no #1125

#1125 (for #1020) makes `_read_pins` say an entry that is not a pin as `_read_as` says it. Its `adopt-project.py` hunks are at the `_read_pins` docstring and at the non-dict entry's `{row!r}` (about line 625 on main), and they do not touch `_pin_row_problem`. Its test hunks do not overlap the lines this change edits. The two are textually independent: this PR is based on main, not stacked, the merge-tree with #1125's head is clean, and the run above on the merge with its head passed.

An entry that is neither a name nor a mapping stays #1020's, in #1125.

## Not in this PR

The other `repr` in the pin rows are on a value that is text by then: the later branches of `_pin_row_problem` (spelled as before), a text product in an entry's name, and an entry that is a name. They are not gaps.

`docs/release-notes/v1.2.0.md` is left alone. Its lines 297 to 299 mention #901's pins wording, and the release owner may want a line there.

## Out of scope

Four other places in `adopt-project.py` put a plan value of any type into a refusal with `!r`, as the pin rows did: `plan-wrong-kind`, `plan-unsupported-mode`, `plan-bad-leg` and `plan-bad-id`. They are not pin rows, so this change leaves them. They were found by reading the code, not by running it. They are filed as #1199, with each site's line and what it would print.

Closes #1114

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 10, 2026
…blems against its source, in the order a person mends them (another repository, a refused name, a moved source, an unanswered entry), so `check`'s first finding is `execute`'s refusal for each pair (#1113) (#1210)

**Stacked on #1125.** This PR's base is `fix/1020-bad-id-first-problem` (#1125's branch, head `9549006f`). It lands after #1125 and stays a draft until then. #1125 makes `check`'s first finding `execute`'s first refusal for a bad `id:` or `org:`. This PR extends that rule to the four pairs #1113 measured, which have neither.

**Fix round.** The first gate failed six rows of `tests/test_adopt_relative_submodule_url.py` that pinned `check`'s old order on a plan hand-edited into another organisation, the relative url before `plan-not-the-source`, a plan `execute` already refused `plan-not-the-source` first on #1125's head; they now expect `plan-not-the-source` first, and a new row asserts that both commands name it first.

## The rule and the order

One generator, `_against_its_source`, asks a plan's four problems against its source in one order, and both commands read it. `execute` refuses its first problem. `check` lists each one, in that order and in its own words, before the split branch and the entries.

The order is the one a person mends them in:
1. a plan for another repository (`plan-not-the-source`);
2. a name the naming policy refuses (`naming-unclassified`, or `naming-role-mismatch`);
3. a source that has moved on since `plan` (`plan-stale`);
4. an unanswered entry (`plan-unresolved`).

Why this order:
- **An unanswered entry last.** Each of the first three is mended by running `plan` again, as its remediation says, and a fresh plan takes back every answer given before. An answer given first would be lost.
- **Another repository first.** Nothing in such a plan is this source's own: it is mended by `plan` under the source's own name, or from the source it is about. And `execute` must ask the naming policy nothing about the plan's names, and no remote anything, before it refuses it (#499). `_against_its_source` is a generator, so `execute` asks nothing after the first problem, and that still holds.
- **A refused name before a moved source.** The name is mended by `plan` given another `--project`, or after the source is renamed on GitHub. That run reads the source as it is now, so it mends the moved source too. `plan` run again as it was, for the moved source, would leave the name to mend.
- **The least change to each command.** The order keeps `execute`'s order for the plan's names (#499, #716) and `check`'s order of a moved source before its entries. Only the pairs that disagreed change.

## Before and after

Measured on #1125's head `9549006f` and on this head, with a stub `gh` (exit 97), `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`, a proxy that goes nowhere, an empty global git config and `--local-remote-dir`. No run made a leg. `check` exits 1 and `execute` exits 2 in every row.

| The plan holds | `check` before | `execute` before | both, here |
|---|---|---|---|
| a moved source and an unanswered entry (#1113) | `plan-stale` | `plan-unresolved` | `plan-stale` |
| another repository and an unanswered entry (#1113) | `plan-unresolved` | `plan-not-the-source` | `plan-not-the-source` |
| another repository and a moved source (#1113) | `plan-stale` | `plan-not-the-source` | `plan-not-the-source` |
| a refused name and an unanswered entry (#1113) | `plan-unresolved` | `naming-unclassified` | `naming-unclassified` |
| a refused name and a moved source (not in #1113's table) | `plan-stale` | `naming-unclassified` | `naming-unclassified` |
| all four | `plan-stale` | `plan-not-the-source` | `plan-not-the-source` |
| another repository and a refused name | `plan-not-the-source` | `plan-not-the-source` | `plan-not-the-source` (unchanged) |

The fifth row is not in #1113's table, but the two commands disagreed there too, and the same rule decides it.

The same probe, comparing each command's output before and after, with paths and hashes masked:
- A plan with one of the four problems: both commands print the same stderr and stdout before and after.
- Both commands' stdout is the same before and after in every row.
- `check`'s stderr in each pair row holds the same lines as before, reordered: the same findings and the same remediation lines.
- `execute`'s stderr changes in one row only, a moved source beside an unanswered entry: `REFUSED plan-stale`, in its own words, where it said `REFUSED plan-unresolved`.

No refusal's code or wording changes, and no code is added. Re-run on this head after the fix round, the probe gives the same: the 24 plans (these 11, and the 13 of the next section) each get one first problem from both commands, no run makes a leg, and the comparison with #1125's head is the one above, line for line.

## #1113's suspected case: a `--work-dir` that cannot be written

Measured on #1125's head and on this head, the two commands agree in every row, and nothing here changes it:
- **A bad `id:` or `org:` beside a `--work-dir` this user cannot write in** (`chmod 500`): `plan-bad-id` or `unsafe-value` to both. #1125's review round 1 (RV1125's SHOULD-1) made both commands ask the two before `_work_root`, which settled this case.
- **Each of the 11 plans above beside the same `--work-dir`:** `adopt-work-dir-unwritable` first to both.

## What changed, in `adopt-project.py`

- **`_against_its_source` and `_Unmended` (new).** `_against_its_source` is the rule. It asks:
  - `_refuse_a_plan_for_another_repository`;
  - `_check_names`, given the source only where the plan names no other repository, as `check` gave it (#716);
  - `_refuse_an_unrunnable_plan`.

  It yields each problem as an `_Unmended`: the `Refusal` `execute` refuses it with, and `check`'s FINDING lines and remediation for it, in `check`'s words as they were. `check` lists an unanswered entry with the entries, each by its path (`_leg_findings`), after the rule's problems.
- **`cmd_execute`.** It refuses the first problem, `next(_against_its_source(...))`. Before, it called `_refuse_a_plan_for_another_repository`, `_check_names` and `_refuse_an_unrunnable_plan` in turn.
- **`cmd_check`.** It lists the rule's findings right after the Triad advisory, where it listed a moved source. Another repository and the names are no longer listed after the entries. Three things are still asked where they were, and only where no name was refused:
  - the names' classification lines on stdout;
  - the findings about what the disk or GitHub will not hold (#863);
  - the leg question (#798).

  The remediation lines it prints after its findings follow the findings' order, so the rule's come first.
- **`_refuse_an_unrunnable_plan`.** It asks a moved source before an unanswered entry. Its docstring says why.
- **`_refuse_a_plan_for_another_repository`.** Unchanged. The rule calls it.

## Tests

`tests/test_adopt_plan_against_its_source.py` (new, ASCII only), 11 rows. Each runs `check` and then `execute --yes` on one hand-edited plan, both with the same `--local-remote-dir`, and asserts that no leg is made.
- **7 pair rows** (the six pairs and all four). Each asserts:
  - `check` exits 1 and `execute` exits 2;
  - both name first the same code, the rule's first;
  - `check` lists every problem the plan holds, in the rule's order.
- **4 controls**, one per code: the plan holds that problem alone, and both commands name it.
- **Against #1125's head `9549006f`:**
  - the 6 pair rows that disagreed fail, each because the two commands named different codes, for example `('plan-stale', 'plan-unresolved') != ('plan-stale', 'plan-stale')`;
  - the `another-and-name` guard row and the 4 controls pass.
- **Here:** 11 passed.

`tests/test_adopt_relative_submodule_url.py`, 6 rows changed and 1 new (the fix round):
- **The 6 rows** are `test_check_keeps_a_relative_url_its_github_leg_resolves_alike` for `[https]`, `[scp]`, `[scp-other-case]`, `[ssh-port]` and `[ssh-github-443]`, and `test_a_credential_in_the_source_remote_is_never_printed`.
  - Each hand-edits the plan's `org:` to `otherorg`, so the plan names `otherorg/Thing` and the source is `testorg/Thing`: a plan for another repository.
  - Each expected `check` to list the relative url first and `plan-not-the-source` after it, `check`'s old order. They now expect `plan-not-the-source` first. Their other assertions are unchanged; the credential is still printed nowhere.
- **The new row,** `test_check_names_another_repository_first_as_execute_refuses_it`, runs one such plan. It asserts that `check`, with and without `--local-remote-dir`, lists `plan-not-the-source` and then the url, that `execute` refuses `plan-not-the-source`, and that no leg is made.
- **Measured on #1125's head** for the six source remotes: `execute` already refused each such plan `plan-not-the-source`, and its output is the same on both heads. Only `check`'s order changed. No spelling of the source's `origin` reads as another repository: with `org:` left as the source's, `check` passes on both heads, as the rows assert.

Both files together, 46 rows:
- **Against #1125's head:** 13 failed, 33 passed. The 13 are the 6 pair rows above and the 7 relative-url rows, each on the order: `plan-not-the-source` where the url was expected first, or the url first in the new row.
- **Here:** 46 passed.

## Mutants

Each was applied to `adopt-project.py`, run against both files (46 rows), and restored, with its sha256 checked (`4c8c1c9e`, the same after each one). Unmutated, the 46 pass. M1-M6 are the first round's; M7 and M8 are the fix round's.

| Mutant | Killed by |
|---|---|
| M1, `execute`: `_refuse_an_unrunnable_plan` asks an unanswered entry before a moved source (pair 1) | 2: `moved-and-unanswered`, `all-four` |
| M2, `check`: lists another repository after the entries (pair 2) | 11: `another-and-unanswered`, `another-and-moved`, `another-and-name`, `all-four`, and the 7 relative-url rows |
| M3, `check`: lists a moved source before another repository (pair 3) | 3: `another-and-moved`, `name-and-moved`, `all-four` |
| M4, `check`: lists a refused name after the entries (pair 4) | 3: `name-and-unanswered`, `name-and-moved`, `all-four` |
| M5, `check`: lists a moved source before a refused name (pair 5) | 2: `name-and-moved`, `all-four` |
| M6, the rule: asks a refused name before another repository (both commands) | 2: `another-and-name`, `all-four` |
| M7, `execute`: asks `_refuse_what_check_finds` before the rule | 8: the pair rows but `another-and-name`, the `plan-stale` control, and the new relative-url row |
| M8, `check`: lists all the rule's problems after the entries | 14: the pair rows but `another-and-name`, the `plan-stale` control, and the 7 relative-url rows |

## Gate

Run on the scratch merge `743ebe176`: `origin/main` `961640c68` plus this head `90eca4144` (tree `d71d6f00a`, never pushed). It ran in the landing gate's own environment (a stub `gh`, `GH_TOKEN=bogus`, an empty global git config, `EMAIL` set, the `LANES_*` and `GIT_SSH_COMMAND` variables unset), one shard at a time, each started with at least 19G free. The ids were collected from that tree, 4405 + 448 = 4853, and each is in exactly one shard. The file list is the 46 test files W1113's gate named and four more, added in the fix round (50 in all): `test_adopt_c_locale_bytes.py` and `test_adopt_submodule_url_answers.py` (they import helpers and the module-scoped `adopter` fixture from the changed test file, so they sit in its shard, r03), `test_reader_refusals_named.py` (it hand-edits a plan's `org:` and checks `check`'s exact findings) and `test_github_name_limits.py`. Every shard exited 0.

| Shard | Result |
|---|---|
| r01 | 600 passed |
| r02 | 613 passed |
| r03 | 464 passed, 1 skipped (`test_adopt_plan.py::test_plan_on_medxehr_matches_the_rulings`) |
| r04 | 547 passed |
| r05 | 599 passed, 19 skipped (all in `test_family_init_values.py`) |
| r06 | 542 passed |
| r07 | 489 passed |
| r08 | 531 passed |
| d01 to d07 | 56 passed each (`test_shape_doctor.py`) |
| d08 | 53 passed, 3 skipped (`test_shape_doctor.py`) |

- **r01 to r08:** 4385 passed, 20 skipped (4405 ids).
- **d01 to d08:** 445 passed, 3 skipped (448 ids).
- **Total:** 4830 passed, 23 skipped, none failed, none errored.

Main has moved since this gate: `origin/main` is `ac5f28d2a` (#1188, #1130 and #1098 landed). They change `scaffold-project.py`, `scripts/family.py` and four test files. None of them is a file this PR changes, a module `adopt-project.py` imports, or one of the 50 gated test files, so the gate was not run again on that tip. The merge-trees below were.

## Merge-trees

`git merge-tree --write-tree`, run again on 2026-10-23 at 15:31Z against the current `origin/main`, every one clean:
- **`origin/main` `ac5f28d2a`:** clean, tree `1a65ab9bd`.
- **#1125 `9549006fe`:** clean. This branch is on it.
- **#1177 `5208e874e`** (`fix/1152-plan-and-remote-paths-spelled`, which edits a line of `_refuse_a_plan_for_another_repository`): clean, with this head and three-way on the scratch merge.
- **Every open PR that touches `adopt-project.py` or either test file, 25 of them, #1125 and #1177 among them:** clean, both with this head and three-way on the scratch merge of `ac5f28d2a` and this head, `98afb128a` (`git merge-tree --write-tree <that merge> <its head>`). Since the first run, #1207 is new, and #1119, #1189 and #1190 have moved; all four are clean. No open PR touches either test file. The list is in a code block, so that it does not mention each PR:

```
PR     head       with this head              on the scratch merge
#1105  b28f40d  clean (tree 32fce74c6)   clean (tree 490f555f9)
#1106  504a1dd  clean (tree 3abfa1b6a)   clean (tree db57682e9)
#1118  2307036  clean (tree 79a3888bf)   clean (tree 629f167ca)
#1119  195d993  clean (tree a2926127e)   clean (tree 6aec4c863)
#1120  c55e55e  clean (tree 6171a52f8)   clean (tree 0cb310664)
#1121  1c51737  clean (tree 3acb80467)   clean (tree 2771fbfd0)
#1122  c01c4d7  clean (tree af9a4c414)   clean (tree ef00f52f7)
#1123  5c3908f  clean (tree eb7cb34b3)   clean (tree 4c311748b)
#1125  9549006  clean (tree 239df6d)   clean (tree 1a65ab9bd)
#1141  7a2711d  clean (tree e1c8f7169)   clean (tree 3afeda790)
#1143  7af5fab  clean (tree 35cd165de)   clean (tree aae00cfc7)
#1153  ff33ba1  clean (tree 57da668e8)   clean (tree f91ce3d8d)
#1154  8a78283  clean (tree 28699ba0c)   clean (tree 3e6b1ae98)
#1169  1e958ca  clean (tree e3ef802be)   clean (tree f4566b2c4)
#1170  24ee133  clean (tree 94b9291c8)   clean (tree 848bb0ed0)
#1171  8162b87  clean (tree 65ca8196d)   clean (tree f15571ef3)
#1177  5208e87  clean (tree 08e3d6a62)   clean (tree 111f3c238)
#1178  c4beba1  clean (tree 646f02266)   clean (tree eae3b5996)
#1180  b316f41  clean (tree 25b3d4b6f)   clean (tree 85a559171)
#1186  07486a4  clean (tree d0fe77836)   clean (tree 849f0ef41)
#1189  a131fc2  clean (tree 5b1ab0620)   clean (tree 9851d22b7)
#1190  91a937e  clean (tree aa83fae05)   clean (tree 18101f50b)
#1194  900f77a  clean (tree 9d74efa35)   clean (tree 46317beae)
#1198  d38e4e3  clean (tree 87ca21b8c)   clean (tree 7fc45a875)
#1207  1c7ca5c  clean (tree 1dbab659d)   clean (tree 1e44c98f0)
```

## Left for a follow-up (outside #1113)

- **An unanswered entry beside an uncovered path still gives two first problems,** filed as #1197. `check` lists `plan-uncovered` first and `execute` refuses `plan-unresolved` first. Measured the same on #1125's head and on this head. `execute` refuses an unanswered entry before `_refuse_what_check_finds`, while `check` lists it in `_leg_findings`, after the paths and the coverage. Other entry findings ahead of it in `_entry_findings` are likely the same (`plan-unsafe-path` among them), and so is the taken split branch, but those were not measured.

Closes #1113

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 10, 2026
…cter a terminal acts on in the command, its directory and what it printed, as `_first_said` spells gh's, so `--local-remote-dir` no longer reaches the terminal raw there; a NOTE before `NEXT:` says when the source's path in its clone command holds a character a terminal does not show as itself; and rows pin six sites #1177's 148 left to chance (#1195) (#1225)

This PR was stacked on #1177, which landed as `8bcdb2c`. RS1225 then restacked it onto `main` with three merges (#1177's final head, its squash, then `main`), which carry exactly the reviewed change (#1225 issuecomment-6752574422).

## What

#1195 follows RV1177's review of #1177 (for #1152). It names two raw routes, N3 and N4, and six mutants, K1, K2, K4, K5, K6 and K7, that all 148 rows of #1177's `tests/test_adopt_plan_and_remote_paths_spelled.py` let through.

Three files change: `adopt-project.py`, that test file, and `tests/test_adopt_github_exits.py`.

### N3: a refused command no longer names `--local-remote-dir` raw

`CommandFailed.loudly` belongs to `scripts/shape_materialize.py`, and every tool shares it. It printed the command, its `(in <cwd>)` line and its output as they were. So when a `pre-receive` hook refused a leg push under `--local-remote-dir <D>/re<U+202E>mote`, the raw U+202E was printed three times: in the command, and in git's own `To` and `error:` lines.

The change is in `adopt-project.py` only. `shape_materialize.py` is unchanged.
- `_unacted(text, kept="")` writes each character that `str.isprintable` refuses, other than those in `kept`, as `_spelled_character` spells it, with no backticks. That is what `_first_said` does to the line gh said.
- `_loudly(exc, step)` builds the same REFUSED block from the command's arguments, its directory and its output, each passed through `_unacted`.
  - The output keeps its line breaks (`kept="\n"`), so it still reads one line per line.
  - This settles the point the issue left open: `loudly` prints the whole output, while `_first_said` keeps one line.
- The leg push (`_push_the_leg`), `_failed_loudly` and `main`'s catch-all now print a refused command through `_loudly`, not `exc.loudly`.
- A printable character prints as it is, so a command with plain paths prints the same block as before.

I measured it with a real `execute --allow-empty-leg spec` and a global `pre-receive` hook that refuses every push:
- **U+202E.** On `5208e874e`, U+202E in `--local-remote-dir` reaches stderr raw 3 times. On this head it reaches stderr 0 times. The command, the `(in ...)` line and git's `To` line each name it as `\u202e`, six ASCII characters.
- **ESC.** ESC behaves the same way. git itself writes ESC as `?` in its `error:` line.
- **Byte 0xff.** It was already printed as `\udcff` before this change, because stderr is `backslashreplace`. git's own lines hold U+FFFD for it, the character `run` decodes that byte to.

### N4: a NOTE says when `NEXT:`'s clone command holds a character a terminal does not show

#1195 asks for a ruling: refuse such a source, as the README's clone line does, or add a note under `NEXT:`. The code settles it in favour of the note:
- **Such a source is writable.** `_refuse_an_unwritable_clone_url` asks `unwritable_reason`. Since #255, that lets through a `Cf` character other than the nine bidi controls. Its docstring in `scripts/shape_materialize.py` says such a character "is invisible, not unwritable", and that a Persian name joins its parts with U+200C.
- **The tests pin it.** `tests/test_adopt_template_values.py` lists `Ali<U+200C>Reza`, `a<U+200E>b`, `a<U+200F>b` and `a<U+061C>b` as `WRITABLE`. Refusing the source would reverse #255.
- **Spelling breaks pasting.** Spelling the command instead would break `_branch_deletion`'s rule that a printed command pastes as it is. `sh` reads a quoted path, not a spelled one.

I measured the old behaviour on `5208e874e`, with a full `execute --local-remote-dir` of a source under `<D>/so<c>urce/Thing`. U+200B, U+200F, U+200C and U+00A0 each pass, and the `split` line spells them. `NEXT:` prints each one raw, once, inside `shlex.quote`'s single quotes.

On this head, the clone command is unchanged. When the url is not `str.isprintable`, this line prints just before the `NEXT:` block:

```
NOTE the path in the clone command below holds a character a terminal does not show as itself, so it can read as another path: it is `<D>/so\u200burce/Thing`, as the `split` line names it.
```

A url whose every character is printable gets no NOTE. A source name that ends in a space shows between the quotes, so it gets none either.

**Where the NOTE goes differs from the issue's wording; please weigh it.**
- The issue says a note *under* `NEXT:`.
- #1121 and #1207 each add lines right after the `print` of the `NEXT:` block. With the NOTE printed under the block, this branch conflicted with #1121 (`git merge-tree` exit 1).
- Printed just before the block, the NOTE merges cleanly with both. It is also read before the command a person would paste.
- Mutant M14 (the NOTE after the block) fails 2 rows, so the placement is pinned.
- Moving it under the block means moving one `print` and flipping the row's order check. It would then need a rebase over whichever of #1121 and #1207 lands first.

### The new rows

The new file goes from 148 to 188 rows, 40 of them new. `tests/test_adopt_github_exits.py` keeps its 23 rows, with one assertion tightened.
- **K1, K2.** `test_a_printable_plan_is_named_as_typed_unless_900_spells_it` runs `check` on the uncovered plan, in the plan's own directory, four ways. `run_adopt` now takes `cwd=` and `over=`.

  | `--plan` given | Named as |
  |---|---|
  | absolute, ending in a space | `` `<D>/plan.yaml ` `` |
  | `` `plan.yaml` ``, relative, between backticks | `` `\x60plan.yaml\x60` `` |
  | `plan.yaml`, relative | `plan.yaml` |
  | `./plan.yaml` | `plan.yaml` |

- **K4, K5.** Two new `ROWS` entries. Each runs under `check` and `execute`, with the plan at each of the five path forms.
  - `plan-wrong-kind-a-placement-plan` expects `kind is 'placement-plan', expected 'adoption-plan'`.
  - `org-too-long`, with an `org:` of 40 `o`, expects `` `org:` is oooooooooooo...oooooooooooo, 40 characters ``.
  - The issue read both expected lines from the code. Both rows pass on `5208e874e`, which confirms them.
- **K6.** `printable-U+00E9` is added to the remote-dir test. `re<U+00E9>mote` prints as typed, with no backticks.
- **K7.** In `tests/test_adopt_github_exits.py`, the GitHub-mode `split` line must now match in full: `  split <12 hex> on adopt/three-repo-shape -> https://github.com/testorg/Northwind.git`.
- **N4.** `SPLIT_SOURCES` gains `right-to-left-mark` (U+200F). Every split row checks either that the NOTE comes before `NEXT:`, or that there is no NOTE.
- **N3.** Three tests:
  - `test_a_refused_command_names_the_remote_and_work_dirs_spelled` is the probe above as a test. The character goes in both `--local-remote-dir` and `--work-dir`, for U+202E, ESC, byte 0xff and a plain control. The line feed is left out, because git's own output names the path and the output's line breaks are kept.
  - `test_a_late_refused_command_is_said_spelled` checks `_failed_loudly`, in process, for all five `CHARACTERS`.
  - `test_a_refused_command_no_step_said_is_said_spelled` checks `main`'s own block, in process, with `cmd_check` replaced, for all five.

## Proof

### Before and after

| Code | The new file (188 rows) and `tests/test_adopt_github_exits.py` (23 rows) |
|---|---|
| #1177's head `5208e874e` | 12 failed, 199 passed |
| This head `73c9f57e8` | 211 passed |

- **What fails on `5208e874e`.** The 12 failures are all N3 and N4 rows:
  - 2 split rows (U+200B and U+200F);
  - 2 refused-push rows (U+202E and ESC);
  - 4 `_failed_loudly` rows;
  - 4 `main` rows.
- **The K rows** pass on `5208e874e`, because its code is right there. Each one fails on its mutant (below).
- **Two N3 controls** pass on both heads: byte 0xff, because stderr already wrote `\udcff`, and the plain control.

### Mutants

All 20 were killed. Each was applied to `adopt-project.py` on this head and restored with `git checkout`. After every restore, the file's sha256 was `163c692aff5b076410bde27740439070f2b9a4c62b4a7dc9510199cb0a7f890a`.
- For K1 to K6, the whole new file was run (188 rows). For K7, `tests/test_adopt_github_exits.py` was run too (211 rows).
- Under every K mutant, only new rows failed, so the old 148 stayed green.
- M1 to M14 ran the N3 or N4 rows.

| Mutant | What it does | Rows failed |
|---|---|---|
| K1 | `check`'s last count line names `--plan` by `isprintable()` alone | 2 of 188 |
| K2 | that line names `plan.path.absolute()` | 3 of 188 |
| K4 | the PLACEMENT plan's `plan-wrong-kind` names the plan raw | 8 of 188 |
| K5 | `adopt-org-too-long` names the plan raw | 8 of 188 |
| K6 | the extracted leg line spells every url outside ASCII | 1 of 188 |
| K7 | the `split` line names the source's own path | 2 of 211 |
| M1 | `_loudly` leaves the arguments raw | 10 of 14 |
| M2 | `_loudly` leaves the working directory raw | 10 of 14 |
| M3 | `_loudly` leaves the output raw | 8 of 14 |
| M4 | the output's line breaks are spelled too | 14 of 14 |
| M5 | the arguments are wrapped in backticks | 14 of 14 |
| M6 | the leg push goes back to `exc.loudly` | 2 of 14 |
| M7 | `_failed_loudly` goes back to `exc.loudly` | 4 of 14 |
| M8 | `main` goes back to `exc.loudly` | 4 of 14 |
| M9 | `_unacted` keeps every whitespace character | 2 of 14 |
| M10 | no NOTE | 2 of 4 |
| M11 | a NOTE wherever the `split` line spells the path | 1 of 4 |
| M12 | the NOTE names the path raw | 2 of 4 |
| M13 | the clone command is spelled, not quoted | 4 of 4 |
| M14 | the NOTE prints after the block, not before it | 2 of 4 |

### Gate

The gate ran on the scratch merge `37c65a609` of `origin/main` `961640c68` and this head `73c9f57e8`, in the landing gate's environment:

| Shards | Tests | Result |
|---|---|---|
| g0 to g5: the 52 files listed below | 3,250 | 3,246 passed, 4 skipped |
| d0 to d7: `tests/test_shape_doctor.py` | 448 | 445 passed, 3 skipped |

- The shard lists hold each collected id exactly once. Every shard exited 0.
- **The 7 skips are environment skips.** Three `tests/test_adopt_root_symlinks.py` rows need git 2.45 for the reftable backend. One `tests/test_stdout_encoding.py` row and the three doctor rows run only on Windows.
- **g1 ran twice.** A host reboot killed its first run part way. It was re-run whole: 542 passed.

<details><summary>The 52 files in g0 to g5</summary>

`tests/test_adopt_attributes_eol_block.py`, `tests/test_adopt_c_locale_bytes.py`, `tests/test_adopt_clone_template.py`, `tests/test_adopt_contracts_dir.py`, `tests/test_adopt_e2e.py`, `tests/test_adopt_entry_paths.py`, `tests/test_adopt_execute_refusals.py`, `tests/test_adopt_filter_repo_failure.py`, `tests/test_adopt_git_reads.py`, `tests/test_adopt_git_rm_bytes.py`, `tests/test_adopt_github_exits.py`, `tests/test_adopt_late_failure.py`, `tests/test_adopt_leg_keys.py`, `tests/test_adopt_mount_ignored.py`, `tests/test_adopt_pins.py`, `tests/test_adopt_plan_and_remote_paths_spelled.py`, `tests/test_adopt_plan_escapes.py`, `tests/test_adopt_reencoded_files.py`, `tests/test_adopt_relative_submodule_url.py`, `tests/test_adopt_root_manifest.py`, `tests/test_adopt_root_shape_paths.py`, `tests/test_adopt_root_symlinks.py`, `tests/test_adopt_spec_only.py`, `tests/test_adopt_spelled_names.py`, `tests/test_adopt_split_branch.py`, `tests/test_adopt_submodule_url_answers.py`, `tests/test_adopt_submodules.py`, `tests/test_adopt_temp_work_roots.py`, `tests/test_adopt_template_values.py`, `tests/test_adopt_tracking_branch.py`, `tests/test_adopt_unsearchable_source.py`, `tests/test_adopt_values_as_typed.py`, `tests/test_adopt_verify.py`, `tests/test_doctor_next_is_a_command.py`, `tests/test_dry_run_asks_remotes.py`, `tests/test_family_add_refusals.py`, `tests/test_git_output_decoding.py`, `tests/test_github_limits_init_check_org.py`, `tests/test_github_name_limits.py`, `tests/test_non_utf8_name_callers.py`, `tests/test_path_guard_hygiene.py`, `tests/test_refusal_default_remediation.py`, `tests/test_repo_hygiene.py`, `tests/test_ruleset_hint_tracking_branch.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_scaffold_manifest_values.py`, `tests/test_scaffold_reuse_seeded_legs.py`, `tests/test_stdout_encoding.py`, `tests/test_template_values_single_pass.py`, `tests/test_update_shape.py`, `tests/test_update_shape_add.py`, `tests/test_update_shape_merged_away.py`

</details>

**`main` has moved since the gate's base, and it touched `adopt-project.py`.**
- `origin/main` is now `50ce8ab15`. Of the 15 commits since `961640c68`, one touches `adopt-project.py`: `b7edd8816` (#1119, for #1083), with 279 lines added and 78 removed. None touches either test file.
- `git merge-tree` of this head with `50ce8ab15` is clean. In the merged `adopt-project.py`:
  - the file parses;
  - `_unacted` and `_loudly` are each defined once;
  - `.loudly(` is called only inside `_loudly`, so #1119 adds no new `exc.loudly(` call.
- The gate above did not run on `50ce8ab15`. The landing gate re-tests this head on its own merge.

### Merge-trees (2026-10-23T20:14Z)

`git merge-tree --write-tree <X> 73c9f57` exits 0 (clean) for every `<X>` below:
- `origin/main` at `50ce8ab15`;
- #1177's head, `5208e874e`;
- each of the 24 open PRs that touch `adopt-project.py`: #1210, #1207, #1198, #1194, #1190, #1189, #1186, #1180, #1178, #1171, #1170, #1169, #1154, #1153, #1143, #1141, #1125, #1123, #1122, #1121, #1120, #1118, #1106 and #1105.

No other open PR touches either test file. #1121, which adds lines after `NEXT:`, has not landed, and it merges cleanly.

Each of the 24 was also merged against a scratch merge of `origin/main` and this head:
- 21 merge cleanly.
- #1169, #1180 and #1190 conflict in `tests/test_adopt_check_throwaway_repos.py`, and #1169 also conflicts in `adopt-project.py`.
- Those three conflict with `origin/main` alone, in the same hunks byte for byte: 6 in `adopt-project.py` for #1169, and 13, 8 and 8 in the test file. So this head adds no conflict.

## Not in this PR

These are outside #1195, which covers `adopt-project.py`'s `--local-remote-dir`:
- `scaffold-project.py`, `update-shape.py`, `scripts/family.py` and `scripts/bump-leg.py` still print `CommandFailed.loudly` as it is.
- `_init_refused` prints `git init`'s argument list raw, and those arguments hold the `--work-dir` path.
- The `adopt-remote-unreadable` and `source-unclonable` refusals print git's or gh's output raw.
- `_unacted` and the NOTE test `str.isprintable`, as `_first_said` does. So the default-ignorable characters that #1171 (#1128) adds to `_shown` get no NOTE, and are not spelled in a REFUSED block. They belong with `_first_said` under #1165.

Closes #1195

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