Skip to content

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

Merged
brettheap merged 26 commits into
mainfrom
fix/1145-partial-clone-source-no-fetch
Oct 24, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1145.

Now on main, after #1119. #1119 squash-landed on main as b7edd881, and GitHub retargeted this PR to main. Its branch was stacked on #1119's head bba168bd, and it was restacked through three merges: #1119's final head 9b856443 (c3f8a2b7, clean); #1119's squash b7edd881 (ef73f75e: no conflict, and it changed no file, since git diff 9b856443 b7edd881 is empty); then main 50ce8ab1 (b18d2e8d, clean). git diff --name-only origin/main b18d2e8d lists exactly this PR's three files, and the changed lines of git diff -U0 origin/main b18d2e8d equal those of git diff -U0 bba168bd a4ec51d2, the reviewed delta (985 +/- lines, the same sha256). Main's adopt-project.py, docs/cli.md and tests/conftest.py are byte for byte bba168bd's, so "base" below is main's too.

Outside v1.2.0. Brett Heap ruled at 2026-10-21T10:32:04Z: "leave the follow-ups out of v1.2.0". This PR lands after the v1.2.0 tag, on a later word.

Beside #1169 (#1136). Both PRs change adopt-project.py. #1169 is restacked onto main too, as fe25bb14, through the same three merges after #1119's squash. git merge-tree --write-tree a0e8cded fe25bb14 is clean, tree ac442d4c. That merged tree is not tested here: whichever of the two lands second takes a merge of main, and lane 1 gates it then.

What

check promises never to write into the source. A partial clone (git clone --filter) broke that through git itself. Git fetches an object such a clone lacks from its promisor remote on any read that needs one. That fetch runs the upload-pack the source's own remote.origin.uploadpack names, and writes what it fetched into the source's objects/ as a promisor pack.

How it was measured. git 2.43.0-1ubuntu7.3 and Python 3.12.3. The source is conftest's synthetic tree: 26 files, 3 commits, cloned from a local origin that sets uploadpack.allowFilter. The plan was written from a full clone and answered. Then the source was made again at the same path and commit, in each form below. remote.origin.uploadpack was set to a wrapper that counts its runs, and objects/ was hashed before and after. Base is #1119's head bba168bd. The rows down to the unborn HEAD were first measured at 179b58ef, and their test rows pass at this PR's head a0e8cded; the HEAD rows were measured at b316f415 with a scratch probe. The commits: 3b11751a and 2c8082a2 added the tests, 70f32485 added the tag-only and extension rows, 6cd96b8b changed the refusal's words and added the two-remote row, 41230f64 walks HEAD, names what git fsck shows and pins the words of both arms, b316f415 adds two assertions and the file-named-HEAD row, and a4ec51d2 ends the walk with -- and adds the row for a file named as HEAD's own id (r1180c's NIT-1). Then the three merges above restack it on main, and a0e8cded (r1180d's NIT-1 to NIT-3) makes the walk's comments and two row docstrings say what git says with the --, and pins git's own words in the could-not-walk refusal and the exact count in the count refusal.

command source base bba168bd head
check partial, --filter=blob:none --no-checkout (28 objects lacking) exit 0, plan ok. 26 upload-pack runs; objects/ CHANGED; .promisor files 1 -> 27 exit 2, REFUSED source-partial-clone. 0 runs; objects/ unchanged; .promisor 1 -> 1
check partial, --filter=blob:none, checked out (2 history blobs lacking) exit 0, plan ok. 0 runs exit 2, source-partial-clone. 0 runs; unchanged
check as the first, plus the source's own protocol.file.allow=always exit 0, plan ok (the test row) exit 2, source-partial-clone. 0 runs; unchanged
check full clone (control) exit 0, plan ok. 0 runs; unchanged the same
check partial, cured (git fetch --refetch --no-filter origin; lacks nothing) exit 0, plan ok. 0 runs; unchanged the same; .promisor 1 -> 1
execute partial, --no-checkout exit 2 after both leg repositories were made: git filter-repo died with Blob not found. 26 runs; CHANGED exit 2, source-partial-clone, and no leg remote made. 0 runs; unchanged
execute partial, checked out exit 2 after both legs were made (Blob not found). 0 runs exit 2, source-partial-clone, no leg. 0 runs; unchanged
execute full clone exit 0, adoption verified. 0 runs; objects/ changed by the split's push only the same
execute partial, cured exit 0, adoption verified. 0 runs the same; .promisor 1 -> 1
check cured partial clone, HEAD detached at a commit no branch or tag reaches, whose changed blob is lacking (r1180b's p3: a tag fetched with the filter the clone keeps, HEAD moved to its commit, the tag deleted; branches lack 0, tags 0, HEAD 1) exit 0, plan ok. 0 runs; unchanged exit 2, source-partial-clone, "lacks at least 1 object(s) its branches, tags and HEAD reach". 0 runs; unchanged
execute the same exit 2 after both leg repositories were made: REFUSED extracting the spec leg, the split's git clone exit 128 (unable to read sha1 file, Clone succeeded, but checkout failed). 0 runs exit 2, source-partial-clone, no leg remote made. 0 runs; unchanged
check, execute a sparse, cured clone detached at a fetched remote branch that changes a file outside its cone (r1180b's p5, a probe with no row) check exit 0, plan ok; execute makes both legs, then REFUSED extracting the spec leg. 0 runs both exit 2, source-partial-clone; no leg. 0 runs; unchanged
check, execute cured partial clone, HEAD unborn (git switch --orphan) (control) check exit 0, plan ok; execute exit 0, adoption verified. 0 runs the same

The refusal, for the first row:

REFUSED source-partial-clone: the source <base>/Thing is a partial clone (`git clone --filter`) that lacks at least 28 object(s) its branches, tags and HEAD reach. Git fetches an object a partial clone lacks from its promisor remote (`origin`) on any read that needs it, and `check` and `execute` never fetch into the source: `execute`'s clone of it checks out its HEAD, and its `git filter-repo` reads every branch and tag, and a partial clone short of one of their blobs failed it after both leg repositories were made
Remediation: make the source whole, then run this command again: in <base>/Thing, run `git fetch --refetch --no-filter origin`, which fetches every object again with no filter (git 2.36 or later). Or pass a full clone (`git clone` with no `--filter`) with --source. Later fetches stay filtered while `remote.origin.partialclonefilter` is set, so the source can lack objects again.

HEAD. execute's git clone <source> <work> checks out the source's HEAD before checkout -B, and a HEAD detached at a commit no branch or tag reaches can name a blob the clone lacks. r1180b measured it: check said plan ok, and execute made both legs and then died in that clone's checkout. So _objects_lacking walks HEAD as well, by its id, where git rev-parse --verify -q HEAD gives one (rows above). Four shapes it must not get wrong, each a test row:

  • An unborn HEAD (git switch --orphan) gives no id, and HEAD is left out of the walk. A plain HEAD in the walk, which ends with --, exits 128 there (fatal: bad revision 'HEAD'; without the --, fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree) and would refuse a clone that lacks nothing.
  • A file named HEAD in the source's work tree: with HEAD in the argument list and no --, git exits 128, ambiguous argument 'HEAD': both revision and filename. With the -- the walk now ends with, HEAD by name walks as the id does (measured with git 2.43, and r1180d's M05 is an equivalent mutant), so this row now holds the walk only against HEAD with no --. The walk keeps the id.
  • A file named as HEAD's own id (r1180c's NIT-1): the id in the argument list is read as a path too, ambiguous argument '<id>': both revision and filename, exit 128, and check refused a source that lacks nothing. The walk now ends with --, after the id, so no file of the work tree is a path of it, whatever it is named, and the walk the refusal names ends with -- too. A commit cannot hold a file named as its own id, so the row's file is in the work tree only.
  • A HEAD that names a commit the source lacks (written into .git/HEAD by hand: git update-ref would fetch it). The refusal is could not walk on git 2.43: rev-list --missing=print exits 128, fatal: bad object <id>, and runs no upload-pack, with the variable ignored too. Asking HEAD for a commit (git rev-parse --verify -q 'HEAD^{commit}') read the object instead, and on a git that ignores the variable it fetched it: 1 upload-pack run, measured. Under GIT_NO_LAZY_FETCH=1 it exits 1, as an unborn HEAD does, and HEAD would be left out. So the id is asked without the peel, which printed it and ran nothing in both cases.

Where git cannot walk the refs (lacking is None), the refusal says that and names git fsck, with what it shows in a partial clone (r1180b's NIT-1): it names a branch, tag or HEAD whose object the repository lacks (error: refs/heads/ghost: invalid sha1 pointer, error: HEAD: invalid sha1 pointer; no upload-pack ran), and it does not list what a promisor remote holds. It no longer says the source "lacks what its branches and tags reach, as far as git can tell", and it no longer leads with a refetch, which does not bring in a ref outside the remote's refspec (r1180's hand-written branch). The refusal for a HEAD at a commit the source lacks, on git 2.43:

REFUSED source-partial-clone: the source <base>/Thing is a partial clone (`git clone --filter`) whose branches, tags and HEAD git could not walk, so what it lacks is not known: `git rev-list --objects --missing=print --branches --tags HEAD --`, which lists what a clone lacks without fetching it, exited 128: fatal: bad object <id>. Git fetches an object a partial clone lacks ...
Remediation: run `git fsck` in <base>/Thing: it names a branch, tag or HEAD whose object the repository lacks, and does not list what a partial clone's promisor remote holds. Restore it, or fetch every object again with `git fetch --refetch --no-filter origin` (git 2.36 or later), or pass a full clone (`git clone` with no `--filter`) with --source, and run this command again. Later fetches stay filtered while `remote.origin.partialclonefilter` is set, so the source can lack objects again.

A refetch does not last. git fetch --refetch --no-filter origin leaves remote.origin.partialclonefilter blob:none in place (r1180: after a cure that brought lacking objects to 0, the next plain fetch left a branch lacking 2). So both refusals end with a sentence saying so. Unsetting the key was not measured, and the sentence does not say to.

A commit the plan names, which the source lacks. Two reads take a commit from the plan rather than from the source:

  • the Triad advisory in cmd_check, on a stale or edited plan;
  • _not_a_whole_clone (git cat-file -e <commit>^{commit}), for a source.cloned: true plan, inside Plan.open_source, before the guard.

Take a cured partial clone and a plan whose commit: names a commit origin holds and the source lacks. On base, each command ran upload-pack once, and the commit was fetched into the source. On head, each runs it 0 times, and objects/ is unchanged. check gives FINDING plan-stale, exit 1. In execute, the clone plan made is no longer whole, so it is cloned again (from a GitHub that does not have it, in the row).

check --source and execute --source (SOURCE_OVERRIDE_HELP, and docs/cli.md, regenerated) name source-partial-clone and the fetch that makes the source whole.

Why

Two layers, as shape-doctor.py has them (#683, #754, #787):

  1. _lazy_fetch_off(), a decorator over cmd_check and cmd_execute. It sets GIT_NO_LAZY_FETCH=1 for the whole command and puts os.environ back afterwards. Every git the command starts inherits it: each read of the source, check's throwaway repositories, and execute's clones and git filter-repo. A read of an object the source lacks then fails where it fetched before. This holds whatever reads it and whichever commit it reads, the plan's included. Measured with git 2.43 on one ls-tree -r -l: exit 128, and no upload-pack ran. The gits that honour the variable are 2.45.0 and later, the security releases of 2024-05-14 back to 2.39.4 (CVE-2024-32465), and distributions' builds of those, this bench's among them.
  2. _refuse_a_partial_clone_short(source), called right after the source is opened. In check it comes after the open's try/except, so the unwritable-work-dir refusal is not masked. In execute it comes before _require_filter_repo() and before any leg. It asks:
    • _promisor_remotes: the doctor's rule, read from config alone, so no object is read. A remote whose promisor is true (--type=bool), a remote with a partialclonefilter, and the remote extensions.partialclone names, that one last. promisor = false alone is none. A full clone stops here, unchanged.
    • _objects_lacking: it streams git rev-list --objects --missing=print --branches --tags, and HEAD's id where _head_id (git rev-parse --verify -q HEAD) gives one, then -- (so no file of the work tree is a path of the walk), and counts the ? lines. git's words go to a temporary file, so neither pipe can stall the other. This walk never fetches on any git that has --missing: with git 2.43 and the variable unset, no upload-pack ran, with HEAD at a commit the clone lacks included. A walk that fails is None, and refused, naming the walk as run and how git exited.
    • 0 lacking: not refused. A cured partial clone keeps its promisor config, lacks nothing a read could fetch, and is read as a full clone is.

Why both layers. The guard alone would leave the plan-named commits fetchable. The variable alone does nothing on a git that ignores it. On such a git, the guard keeps the 26 fetches from happening: the test row stands for that git by setting another name.

Why branches, tags and HEAD. The source's branch is what check reads and execute splits, execute's git filter-repo rewrites a clone of the source, which copies every branch and tag, and that clone's git clone checks out the source's HEAD. The checked-out partial clone lacked only history blobs. It passed check, and failed execute after both legs existed. Tags too: a cured clone that fetches one annotated tag with the filter it keeps lacks the blob the tag's commit changes, and only the tag reaches it (branches lack 0, tags 1). With --tags out of the walk, check said plan ok and execute made both legs, then died in git filter-repo with Blob not found. HEAD too: the same clone with the tag deleted and HEAD left on its commit (branches 0, tags 0, HEAD 1). With HEAD out of the walk, check said plan ok and execute made both legs and died in the clone's checkout. A test row pins each.

A refusal, exit 2, rather than a FINDING (lane 1's ruling). A source that lacks objects cannot be read without fetching, so check stops before it reads the source.

Rejected:

  • -c protocol.allow=never: measured, the source's own protocol.file.allow=always outranks it, and the lazy fetch ran 4 times;
  • an empty GIT_ALLOW_PROTOCOL over the whole command: it stops the fetch too, but check and execute need their transports, for the clone of the source, ls-remote and the pushes;
  • refusing every partial clone by its config: it refuses a cured one for nothing.

_a_partial_clone, which check asks of a pin's recorded clone, keeps its older rule.

Tests

tests/test_adopt_partial_clone_source.py, 38 rows. Each counts the promisor's upload-pack runs through a counting remote.origin.uploadpack (a Python script started with sys.executable), and hashes objects/:

  • check refuses nocheckout, checkout and fileallow, and fetches nothing; each refusal gives the cure, says a later fetch stays filtered, and gives as the count it lacks at least exactly the ? lines of its own walk, which the row counts (git rev-list --objects --missing=print --branches --tags HEAD --) and asserts is more than 1, so a count held at 1 is told (r1180d's NIT-3) (3);
  • check reads full and cured as before (2);
  • execute refuses nocheckout and checkout before the leg directory exists (2);
  • execute splits a cured partial clone, adoption verified, 0 runs (1; skipped where git-filter-repo is absent);
  • the guard alone, in-process, with NO_LAZY_FETCH set to a name no git reads (1);
  • the plan-named commit, through check (the advisory) and through execute (_not_a_whole_clone, source.cloned: true, gh stood in for). 0 runs on head, then at least 1 with the variable ignored, which proves the row's read fetches. Each skips, with the reason, on a git that fetches under GIT_NO_LAZY_FETCH=1 (2);
  • a tag alone reaches a blob the source lacks: a cured clone fetches tagged, an annotated tag on a commit no branch reaches that changes specs/001-feature/spec.md, with its own filter; refused as source-partial-clone through check and through execute, no leg directory, 0 upload-pack runs (2);
  • a HEAD alone reaches a blob the source lacks (r1180b's p3): the same fetch, then HEAD moved to that commit with update-ref --no-deref and the tag deleted, so branches lack 0, tags 0 and HEAD 1. Refused through check and through execute, the refusal naming HEAD in "its branches, tags and HEAD reach" and saying execute's clone checks out its HEAD, no leg directory, 0 runs (2);
  • a HEAD that names a commit the source lacks, written into .git/HEAD, with NO_LAZY_FETCH set to a name no git reads: refused, 0 runs, objects/ unchanged. Only this row tells the id asked as HEAD from HEAD^{commit} (1);
  • an unborn HEAD, the control: a cured clone with HEAD on refs/heads/unborn is read as before, through check (plan ok, nothing fetched) and through execute (adoption verified, 0 runs; skipped where git-filter-repo is absent) (2);
  • a file named HEAD in the source's work tree is no path of the walk: check says plan ok (1);
  • a file named as HEAD's own id in the source's work tree is no path of the walk (r1180c's NIT-1): a cured clone, the file written untracked. The row first asserts its premise, git rev-list <id> alone exits 128, then check says plan ok, 0 runs, objects/ unchanged (1);
  • two promisor remotes: the refusal names each refetch and each remote's partialclonefilter key (1);
  • a hand-written branch at a commit the source lacks: before 2.45, rev-list --missing=print exits 128; from 2.45 it lists the commit. Refused either way, asserted by its code alone (1);
  • a walk made to fail in-process (LACKING_WALK with an option no git has), HEAD born and unborn: refused on any git, naming the walk as run (HEAD where HEAD has an id, then -- last), how git exited and git's own words after it (exited <rc>: <words>, asked of this git by running the same walk: git 2.43 prints its usage, exit 129, which does not name the option; r1180d's NIT-2), saying git could not walk the refs, with no object count, naming git fsck in the source with what it shows, and the cure (2);
  • what git fsck shows in a partial clone, which that refusal names: a ghost branch, tag or HEAD is named, and the blobs a promisor holds are not listed; it runs no upload-pack (4);
  • _promisor_remotes: promisor true and yes, partialclonefilter alone, extensions.partialclone alone and last, the remote it names last where it is also a promisor (extensions.partialclone = o, then o and p promisors: ['p', 'o']), promisor = false alone, none (8);
  • _lazy_fetch_off sets the variable and puts it back, unset and set, an exception included (2).

Base vs head at main (round 4, a0e8cded). The 38-row file, in a scratch clone of a0e8cded with main 50ce8ab1's adopt-project.py and docs/cli.md put in, in the probe environment under git 2.43.0 and Python 3.12.3: 27 failed, 11 passed in 63.37 s. The 11 are the controls: check full and cured, execute cured, the unborn HEAD through check and execute, the file named HEAD, the file named as HEAD's own id, and the four git fsck rows. The two rows round 4 changes fail on base, as they did: the three check refusals and the failing walk, born and unborn. Head a0e8cded: 38 passed in 24.30 s, and 38 passed in 34.83 s under the 3.14-like sitecustomize shim (exists, is_file and is_dir answer False to any OSError; it loaded 44 times in the run).

Base vs head, round 2 (b316f415). The 37-row file, copied into a scratch clone of bba168bd (with the base's own conftest), in the probe environment under git 2.43.0 and Python 3.12.3: 27 failed, 10 passed in 34.53 s. Head: 37 passed in 16.32 s (run before the commit b316f415, with the same bytes), and 37 passed in 19.43 s on b316f415 under the 3.14-like sitecustomize shim. The 10 that pass on base are the rows base answers as head does: check full and cured, execute cured, the unborn HEAD control through check and execute, the file named HEAD, and the four git fsck rows. Of the 11 rows new in round 2, 4 fail on base: the HEAD alone through check (exit 0, plan ok) and through execute (both legs made, then REFUSED extracting the spec leg, exit 128, unable to read sha1 file); the HEAD at a commit the source lacks (FINDING adopt-git-unasked, exit 1, where 2 is asserted); and the failing walk with an unborn HEAD. The other 7 are the controls. The 26 rows of 6cd96b8b were 23 failed and 3 passed on base (lane 1's run), and 27 = 23 + 4.

Base vs head, r1180c's NIT-1 (round 3). The new row test_a_file_named_as_the_head_id_is_no_path_of_the_walk and the edited failing-walk row (it names the walk as run, so it now expects the --), with b316f415's own adopt-project.py, in a scratch clone, in the probe environment under git 2.43.0 and Python 3.12.3. Four rows were selected: the new row, the file-named-HEAD row, and the failing-walk row born and unborn. 3 failed, 1 passed. The new row fails with the refusal (exit 2, REFUSED source-partial-clone, the could not walk arm, where 0 and plan ok are asserted); the failing-walk row fails born and unborn, because base names the walk without the --; the file-named-HEAD row passes, as it did. That base is round 3's, b316f415: against #1119's head, and against main, the new row is a control row and passes (r1180d: 27 failed, 11 passed with #1119's e0a98f13; and round 4's run at main above). Head a4ec51d2: the whole file, 38 passed in 22.91 s. tests/test_adopt_plan_partial_clone.py is #1162's and is not in this PR's tree; in a scratch merge of a4ec51d2 and #1162's tip d4d6e2a7 (tree 0117a9d7, clean), that file and test_adopt_partial_clone_source.py together: 52 passed in 38.69 s. That file asserts no text of the walk.

Mutants. At b316f415, 44 of 44 killed, against b316f415's adopt-project.py and the 37-row file, in two scratch clones, in four runs of 11, with adopt-project.py restored after each mutant in a finally. At the end of each run git status showed the file unchanged, and afterwards the blob of each clone and of the worktree equalled HEAD's, b1e44868. The 44:

  • the lane's 29 (muts_f1180_all.py): each applies once to this head's text, but later-dropped-none, whose anchor moved with the wording and was re-pointed at with --source, and run this command again. {later}. Each killed, by at least 1 row;
  • r1180b's four survivors, written against the new text: count-worded-as-none (if lacking is None or lacking:) killed by 5 rows (the three check refusals and the two HEAD rows); none-no-said (the walk and exited dropped from the refusal) by 2 (the failing walk, born and unborn); none-fsck-no-where by 2; none-no-refetch by 2;
  • 11 for HEAD and the new words:
mutant killed by
head-not-walked (HEAD left out of the argv) 3: the two HEAD rows, the HEAD at a commit the source lacks
head-unverified (head = "HEAD" always: a plain HEAD) 4: the failing walk (unborn), the unborn control through check and execute, the file named HEAD
head-peeled (HEAD_ID with HEAD^{commit}) 1: the HEAD at a commit the source lacks
head-by-name (the argv takes HEAD, not its id) 1: the file named HEAD
head-shown-never (the walk named without HEAD) 1: the failing walk (born)
head-shown-always (named with HEAD even when unborn) 1: the failing walk (unborn)
none-arm-no-head ("branches and tags git could not walk") 2: the failing walk, born and unborn
count-arm-no-head ("tags reach") 2: the two HEAD rows
fsck-old-claim (the sentence says fsck "names each object the repository lacks") 2: the failing walk, born and unborn
none-no-exit (exited N: dropped) 2: the failing walk, born and unborn
execute-words-no-head (the sentence about execute's clone checking out HEAD dropped) 2: the two HEAD rows

Round 3's mutants (r1180c's NIT-1), 3 of 3 killed, against a4ec51d2's adopt-project.py and the 38-row file, in a scratch clone, adopt-project.py copied back from the worktree after each:

mutant killed by
argv-no-dashes (the walk's argv without --) 1: the new row
shown-no-dashes (the named walk without --) 2: the failing walk, born and unborn
dashes-before-head (-- before the id, so the refs and HEAD are read as paths) 12: the three check refusals, the two execute refusals, the guard alone, the two-remote row, the tag rows (2), the HEAD rows (2), the HEAD at a commit the source lacks

Round 4's mutants (r1180d's NIT-2 and NIT-3), 2 of 2 killed. r1180d ran 17 mutants against a4ec51d2 and 14 were killed. Of the 3 survivors, M05 (the argv walks HEAD by name, with the --) is an equivalent mutant: with the --, the name and the id walk the same. So round 2's head-by-name row, killed at b316f415, is equivalent from a4ec51d2 on, and the file-named-HEAD row now kills only HEAD by name with no -- (r1180d's M06). The other two are killed now. Each was applied to a0e8cded's adopt-project.py in a scratch clone, its row run and failed, then the file was restored, its blob checked equal to HEAD's 76b860ff, and the row run again and passed:

mutant mutated restored
M15: git's words dropped (f"{spelled_as_git_out(words)}" -> "") the failing walk, born and unborn: 2 failed 2 passed
M17: the count held at 1 (lacking = max(lacking, int(line.startswith(b"?")))) the three check refusals: 3 failed 3 passed

History: round 1 measured, by the author on 2c8082a2, 11 of 11 killed; the review (r1180) ran 19 and 2 survived, walk-no-tags and ext-not-last, which the tag rows and the extension row now kill; lane 1 ran all 29 against 6cd96b8b: 29 of 29; r1180b ran 16 on 6cd96b8b and 4 survived, the four above, which this round kills.

The first round's table, each mutant in a scratch clone of 2c8082a2, restored after each:

mutant killed by
no decorator on cmd_check the plan-named commit through check
no decorator on cmd_execute the plan-named commit through execute
no guard call in cmd_check 6 rows: the three check refusals, the guard alone, the hand-written branch, the failing walk
no guard call in cmd_execute the two execute refusals
_lazy_fetch_off does not restore 5 rows, the two unit rows among them
no lacking == 0 return check cured, execute cured, the plan-named commit through check
_promisor_remotes returns [] 13 rows
PROMISOR_KEYS without promisor promisor, promisor-yes, extension-last
PROMISOR_KEYS without partialclonefilter filter
PROMISOR_KEYS without extensions.partialclone extension
a None walk read as 0 (if not lacking) the hand-written branch (git 2.43), the failing walk (any git)

The gate. On b316f415, in the probe environment, under 3.12, each call under 9 minutes and 2 at once (the two doctor shards in each pair ran together):

Call Files Result
G1 test_path_guard_hygiene.py, test_no_auto_maintenance.py 107 passed, 2 skipped, 17.11 s. Both skips are at test_no_auto_maintenance.py:222: the probe sets GIT_CONFIG_GLOBAL
G2 the doc trio: test_agents_md_codes.py, test_cli_reference.py, test_repo_hygiene.py 170 passed, 32.30 s
G3 test_adopt_check_throwaway_repos.py 70 passed, 68.01 s
G4 test_scaffold_leg_path_collisions.py 104 passed, 3.60 s
G5 to G8 test_shape_doctor.py, 4 shards (112 node ids each, passed line by line) 111 passed, 1 skipped, 153.97 s; 112 passed, 143.83 s; 111 passed, 1 skipped, 146.39 s; 111 passed, 1 skipped, 152.00 s. The 3 skips are the PowerShell row at test_shape_doctor.py:10510, which runs only on a Windows host

No call failed. docs/cli.md was regenerated by scripts/render-cli-reference.py (the help now says "its branches, tags or HEAD reach"), and G2 holds it. The full suite and CI's CPython 3.14 did not run here.

Round 3's gate (a4ec51d2), the two calls the change needs, in the probe environment, under 3.12, each under 9 minutes:

Call Files Result
R3-1 test_adopt_partial_clone_source.py 38 passed, 22.91 s
R3-2 test_path_guard_hygiene.py, test_no_auto_maintenance.py 107 passed, 2 skipped, 20.19 s (G1's counts)

Not rerun at a4ec51d2: the doctor shards, test_adopt_check_throwaway_repos.py, test_scaffold_leg_path_collisions.py and the doc trio. They ran at b316f415 (G2 to G8); this change touches two lines of _objects_lacking, its comments and one test file, and no flag, code or help text.

Round 4's runs, at the restack b18d2e8d and at a0e8cded, in the probe environment, under 3.12, each under 9 minutes and at most 2 at once:

Call Head Files Result
R4-1 b18d2e8d test_adopt_partial_clone_source.py 38 passed, 22.49 s
R4-2 b18d2e8d test_adopt_check_throwaway_repos.py 89 passed, 1 skipped, 85.63 s (the skip is the file's own guard)
R4-3 a0e8cded test_adopt_partial_clone_source.py 38 passed, 24.30 s
R4-4 a0e8cded the same, under the 3.14-like shim 38 passed, 34.83 s
R4-5 main's tool the same 38 rows (base) 27 failed, 11 passed, 63.37 s

scripts/render-cli-reference.py --check at a0e8cded: [ok] docs/cli.md is what the generator produces. Round 4 changes comments, two docstrings and the assertions of two rows, and no flag, code or help text. Lane 1 runs the full landing gate.

Round 1's gate, on 2c8082a2, with each call under 9 minutes and at most 2 at once:

Call Files Result
G1 test_path_guard_hygiene.py, test_no_auto_maintenance.py 107 passed, 2 skipped
G2 to G5 test_shape_doctor.py, 4 shards 111 passed, 1 skipped; 112 passed; 111 passed, 1 skipped; 111 passed, 1 skipped
G6 test_scaffold_leg_path_collisions.py 104 passed
G7 #1119's three files: test_adopt_check_throwaway_repos.py, test_adopt_reencoded_files.py, test_adopt_root_symlinks.py 328 passed, 3 skipped
G8 the 7 adopt and doctor files that name partial or promisor. The first call reached its 540 s limit at about 93% with no failure, so it was rerun as three calls: test_adopt_partial_clone_source.py and test_adopt_pins.py; test_adopt_plan_yaml_quoting.py and test_adopt_single_repository_record.py; test_doctor_no_lazy_fetch.py, test_doctor_pin_restore_merges.py and test_doctor_partial_clone.py 172 passed; 579 passed; 92 passed
G9, G10 the other 16 files that name partial or promisor, in 2 calls 373 passed; 381 passed
G11 the doc trio: test_agents_md_codes.py, test_cli_reference.py, test_repo_hygiene.py 170 passed

No call failed in round 1 either. The adopt and doctor files that name partial or promisor (round 1's G7 to G10) were not run again this round: this round changed adopt-project.py in the walk, the refusal's words and one help sentence, and the rows above.

Known limits

  • A promisor on a network was not measured (lane 1's ruling: a known limit, measured only by reasoning). The same reads go through the same lazy git fetch <promisor>, with the transport its url names: https, with the credential helper of whoever runs the command, or ssh, with their keys. The fix stops it at the same place. Under the variable no fetch is started, and the guard reads config and runs rev-list --missing=print, which fetches nothing, before it refuses.

  • On a git that ignores GIT_NO_LAZY_FETCH (before 2.39.4, and unpatched 2.40 to 2.44 builds), two reads of a commit the plan names can still fetch, and they differ in which clone they reach:

    • _not_a_whole_clone (git cat-file -e <commit>^{commit}) runs inside Plan.open_source, for a source.cloned: true plan, before the guard, so in ANY partial clone, a cured one or not. r1180 measured an uncured nocheckout clone, with NO_LAZY_FETCH swapped for another name: check and execute each ran upload-pack once, objects/ changed, then REFUSED source-partial-clone. Low likelihood: plan makes that clone itself, as a full clone.
    • The Triad advisory in cmd_check, on a stale plan, runs after the guard, so it reaches a partial clone only where the guard let it pass: a cured one, which lacks nothing its branches, tags or HEAD reach but may lack a commit the plan names. The guard walks branches, tags and HEAD only.

    The two rows that pin the cured case skip on such a git, with the reason. The uncured case through _not_a_whole_clone is pinned by no row.

  • What the guard counts is a least, "at least N object(s)": a missing tree hides what is under it.

  • git 2.45 or later was not measured (this bench has git 2.43.0). The rows of a branch and of a HEAD at a commit the source lacks assert the refusal's code alone: on 2.43 the walk fails and the refusal is the "could not walk" arm, and from 2.45 the walk lists that commit as lacking and the refusal would be the count arm. The gits that honour GIT_NO_LAZY_FETCH are as the Why section says.

  • CPython 3.14 did not run here: the bench has 3.12.3. The file ran under a 3.14-like sitecustomize (exists, is_file and is_dir answer False to any OSError): 38 passed at a0e8cded (and 38 at a4ec51d2, r1180d).

Not in this PR

Lane: openreposhape-1 (openRepoShape-1)

🤖 Generated with Claude Code

Summary by Sourcery

Refuse incomplete partial-clone sources before adoption reads them and prevent check or execute from fetching into the source.

New Features:

  • Prevent check and execute from fetching missing objects into partial-clone sources by detecting incomplete history across branches, tags, and HEAD.
  • Report actionable source-partial-clone refusals with remediation for restoring a complete source clone.

Bug Fixes:

  • Avoid source mutations and late failures caused by Git’s lazy fetching during adoption checks and execution.

Enhancements:

  • Run adoption commands with lazy fetching disabled and preserve the caller’s environment afterward.
  • Handle promisor-remote discovery, missing-object walks, unborn or detached HEADs, and unwalkable references safely.

Documentation:

  • Update CLI reference documentation with partial-clone source refusal behavior and recovery guidance.

Tests:

  • Add comprehensive coverage for partial, full, cured, tagged, detached-HEAD, unborn-HEAD, malformed-reference, multi-promisor, and lazy-fetch environment scenarios.

Brett Heap and others added 13 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>
…` and refuse a partial-clone source that lacks an object its branches or tags reach as `source-partial-clone`, so neither fetches into the source any more (#1145)

Measured with git 2.43 on a `--filter=blob:none --no-checkout` source:
`check` ran the source's promisor `upload-pack` 26 times and wrote 26
promisor packs into it; on this commit it runs it 0 times and leaves the
source's `objects/` unchanged. A full clone and a partial clone given
every object again (`git fetch --refetch --no-filter origin`) still
check `plan ok`.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rce` name `source-partial-clone`, the source it refuses, and the fetch that makes it whole (#1145)

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e a partial-clone source that lacks objects as `source-partial-clone` and run no `upload-pack`, and read no commit the plan names that the source lacks (#1145)

Each row counts the promisor's `upload-pack` runs through a counting
`remote.origin.uploadpack` and hashes the source's `objects/`. On #1119's
head bba168b, 18 of the 21 rows fail: `check` says `plan ok` for a
`--no-checkout` partial clone, a checked-out one, and one whose own
config sets `protocol.file.allow=always`; `execute` dies in `git
filter-repo` after both legs; the guard alone is not there; and the
plan's commit, read by the Triad advisory and by `_not_a_whole_clone`,
is fetched once. The full clone, the cured clone and the cured
`execute` pass on both.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fails is refused on any git, and not read as lacking nothing (#1145)

The hand-written branch at a commit the source lacks makes `git rev-list
--missing=print` exit 128 only before git 2.45; from 2.45 it lists the
commit as lacking, so a `None` read as `0` would pass that row there. A
walk made to fail in-process (`LACKING_WALK` with an option no git has)
holds the `None` path on every git. It fails on bba168b (`plan ok`).

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

The PR makes check and execute preserve their no-write contract by disabling Git lazy fetches for the command lifetime and proactively rejecting partial-clone sources whose branches, tags, or HEAD reach missing objects, with actionable diagnostics, CLI documentation, and extensive regression tests.

Sequence diagram for partial-clone source validation

sequenceDiagram
    participant User
    participant Command as check_or_execute
    participant Git as Git
    participant Source as PartialCloneSource
    participant Remote as PromisorRemote

    User->>Command: run check or execute
    Command->>Command: _lazy_fetch_off()
    Command->>Source: open source
    Command->>Git: read promisor configuration
    Git-->>Command: promisor remotes
    Command->>Git: rev-parse --verify -q HEAD
    Git-->>Command: HEAD id or unborn
    Command->>Git: rev-list --objects --missing=print --branches --tags HEAD --
    Git-->>Command: missing-object count or walk failure
    alt partial clone has missing reachable objects
        Command-->>User: Refusal source-partial-clone
    else source is full or complete
        Command->>Command: continue check or execute
    end
    Note over Git,Remote: GIT_NO_LAZY_FETCH=1 prevents lazy fetches into the source
Loading

Flow diagram for safe check and execute handling

flowchart TD
    A[Run check or execute] --> B[Set GIT_NO_LAZY_FETCH=1]
    B --> C[Open source]
    C --> D{Promisor remote configured?}
    D -->|No| G[Continue command]
    D -->|Yes| E[Walk branches tags and reachable HEAD objects]
    E --> F{Missing objects or walk failure?}
    F -->|Yes| H[Refuse as source-partial-clone]
    H --> I[Suggest git fetch --refetch --no-filter]
    F -->|No| G
    G --> J{Command}
    J -->|check| K[Read and validate plan]
    J -->|execute| L[Create legs and perform adoption]
    K --> M[Restore environment]
    L --> M
Loading

File-Level Changes

Change Details Files
Prevent source mutation caused by Git lazy-fetching missing objects from partial clones.
  • Wrap check and execute in temporary GIT_NO_LAZY_FETCH=1 environment handling, restoring the prior environment even on failure.
  • Detect promisor remotes from Git configuration without reading repository objects.
  • Walk branches, tags, and a safely handled HEAD with git rev-list --missing=print, counting missing objects without fetching; refuse sources when the walk fails or finds missing objects.
  • Add source-partial-clone refusals with remediation for one or multiple promisor remotes, including git fsck, refetch, full-clone alternatives, and filtered-fetch caveats.
  • Run the refusal before source reads or execute leg creation while preserving existing writable-work-directory ordering.
  • Cover plan-referenced missing commits and edge cases including detached/unborn HEADs, missing refs, filenames colliding with revisions, and Git versions that ignore the environment variable.
adopt-project.py
Document partial-clone source refusal and the remediation command in generated CLI help.
  • Explain that incomplete partial-clone sources are refused before reads as source-partial-clone.
  • Document git fetch --refetch --no-filter <remote> as the way to make the source whole.
docs/cli.md
Add comprehensive regression coverage for no-fetch behavior and partial-clone validation.
  • Create synthetic filtered, full, and cured source variants with counted upload-pack invocations and object-store hashing.
  • Verify check and execute refuse incomplete sources without creating legs or modifying source objects, while full and cured sources retain existing behavior.
  • Test branch, tag, HEAD, unborn HEAD, missing-commit, failed-walk, promisor-configuration, multi-remote, filesystem-name collision, and environment-restoration scenarios.
  • Validate refusal text, missing-object counts, remediation guidance, and compatibility behavior when Git ignores GIT_NO_LAZY_FETCH.
tests/test_adopt_partial_clone_source.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1145 Prevent check from lazily fetching missing objects from a partial-clone source and writing fetched packs into that source. ✅
#1145 Detect partial-clone sources missing objects reachable from their branches, tags, or HEAD and refuse them before normal source reads, with actionable remediation. ✅

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:24:10Z) Independent review r1180 of #1180 at 2c8082a2: NOT READY, for one SHOULD.

Lane 1 runs the fix round in a later window.

Review of #1180 (r1180, lane openRepoShape-1)

Verdict: NOT READY, for one SHOULD. The code does what the PR says, and every count and claim I re-measured matches the body. I'm holding it because one design claim, "why branches and tags", has no test behind it. A mutant that drops --tags from the walk survives the PR's test file, and I measured that --tags matters. The fix is one test row.

State at stop: all my runs had finished before STOP arrived, so nothing was killed. The mutant harness put adopt-project.py back: its blob after the last mutant was ce02c50f…, the same as HEAD. The review checkout /workspace/projects/openRepoShape-worktrees/l1-r1180 was not touched. Everything I made is under /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1180. That includes an untracked probe file, head/tests/test_r1180_probe2.py, in my scratch clone of the head.

Findings

SHOULD-1: no row pins --tags in LACKING_WALK; mutant walk-no-tags survives.

  • Where: adopt-project.py LACKING_WALK (about line 12958), and tests/test_adopt_partial_clone_source.py, which has no row with a tag.
  • Mutants: walk-no-tags: SURVIVED [22 passed in 11.49s].
  • Evidence (test_tag_only_lacking): I took a cured clone and fetched one annotated tag with the filter the clone keeps. The tag's commit changes specs/001-feature/spec.md. Branches then lacked 0 objects and tags lacked 2.
    • As shipped, execute exits 2 with REFUSED source-partial-clone. No legs are made and upload-pack runs 0 times.
    • With LACKING_WALK minus --tags, execute exits 2 with REFUSED extracting the spec leg. It hits Blob not found after the legs are made (legs_made: True). check says plan ok.
  • Fix: add this tag-only row, through execute (and check), asserting source-partial-clone and that no leg directory exists. On the head it passes; with the mutant it fails.

NIT-1: mutant ext-not-last survives.

  • Where: the return at the end of _promisor_remotes, and test lines 396–399.
  • The mutant return list(dict.fromkeys(names + last)) passes all 22 rows. In the extension-last row the config order already puts first last, so the row never tests the "extension last" rule. My edge case ext-also-promisor would kill it: the config [extensions] partialClone = o, then [remote "o"] promisor, then [remote "p"] promisor gives ['p', 'o'] on the head.
  • Impact: only the order in which the remediation names remotes.
  • Fix: add that case to the parametrize.

NIT-2: the known limit is narrower than what happens.

  • The body says "Take a cured partial clone". But _not_a_whole_clone runs before the guard, so on a git that ignores the variable an uncured clone also fetches.
  • Evidence (test_known_limit_cloned_plan_on_an_ignoring_git): nocheckout clone, a cloned: true plan naming a commit the source lacks, and NO_LAZY_FETCH swapped for another name.
    • check: exit 2, runs: 1, store_same: False, then REFUSED source-partial-clone.
    • execute: the same.
  • This is low-likelihood: plan makes that clone itself as a full clone.
  • Fix: reword to "any partial clone for _not_a_whole_clone, which runs before the guard; a cured one for the Triad advisory".

NIT-3: the remedy does not last.

  • git fetch --refetch --no-filter origin leaves remote.origin.partialclonefilter blob:none in place. In my hand-made clone, after a cure that brought lacking objects to 0, the next plain fetch left a branch lacking 2 objects (fetch exit 0, after a plain fetch, lacking on branches: 2), so check would refuse again.
  • Fix: say in the remediation that later fetches stay filtered while that key is set. I did not measure unsetting it.

NIT-4: the wording and remedy for a walk that fails do not fit.

  • Where: the lacking is None branch of _refuse_a_partial_clone_short.
  • The message says the source "lacks what its branches and tags reach, as far as git can tell", when git could not tell.
  • For the hand-written branch row, the named remedy does not work. The refetch exited 0 and the second check still exited 2 with REFUSED source-partial-clone, because side is outside the clone's refspec.
  • Fix: for None, say git could not walk the refs and name git fsck, as Source.tree() does.

NIT-5: extensions.partialclone is read from every config scope. I did not measure this against git; it comes from reading git's setup code.

  • With a global config holding remote.origin.promisor and extensions.partialclone = elsewhere, _promisor_remotes returned ['origin', 'elsewhere'].
  • Git reads extensions.* only from the repository's own config, so a full clone pays for one extra walk and is still not refused.
  • Fix (optional): read that key with --local.

What I measured and confirmed

The new test file, base against head.

  • Base bba168bd with the head's test file: 19 failed, 3 passed. The 3 that pass are the controls: full, cured and execute cured.
  • Head: 22 passed in 13.07 s.
  • Both match the body.

Real partial clones (probes/probe1.py). I built my own local bare origin with an extra commit and a tag. Each run was counted twice: through the counting uploadpack wrapper and through git's own trace2 events.

variant base head
nocheckout, check plan ok, 26 wrapper runs, 26 trace2 lazy fetches, objects/ changed, .promisor files 1→27 REFUSED source-partial-clone, 0 runs, unchanged
nocheckout, execute 26 runs, legs made, Blob not found refused, 0 runs, no legs
checkout check plan ok; execute made legs, then Blob not found both refused, 0 runs
treeless (--filter=tree:0, my extra) check plan ok; execute made legs, then REFUSED extracting both refused, 0 runs
cured, full plan ok / adoption verified, 0 lazy fetches the same

objects/ changed in the cured and full execute rows only because of the split's push. The cured clone still holds remote.origin.promisor true and partialclonefilter blob:none, as the body says.

The decorator restores the environment on every path.

  • It put the environment back after SystemExit, KeyboardInterrupt, GeneratorExit and BaseException, with the variable unset, "0" or "" beforehand. A child process saw 1. It is correct nested and called again.
  • main() leaves no variable behind after check and execute refusals.
  • Every environment the tool builds starts from os.environ: _inherited_env, _split_clone_env, _probe_env and no_grafts at line 1758. So every git it starts inherits the variable.
  • No "lazy fetching disabled" warning reached stderr in the plan-named-commit row.

Promisor detection, all three keys and a None walk.

  • These all came out right: a value-less promisor, on, 1, Promisor = TRUE on remote Up, a dotted name a.b, true-then-false, and false-then-true.
  • A walk that cannot run (git not found) is refused with could not run (No such file or directory).
  • One contrived miss, which is not a finding: an invalid bool on another remote returns [], but only when the real promisor has no partialclonefilter. Real --filter clones set that key.

The known limit's git versions are right. I read git's own sources with WebFetch:

  • v2.44.0: no reference to the variable.
  • v2.44.1 and v2.39.4, promisor-remote.c: if (git_env_bool("GIT_NO_LAZY_FETCH", 0)).
  • v2.45.0, environment.c setup_git_env: if (git_env_bool(NO_LAZY_FETCH_ENVIRONMENT, 0)) fetch_if_missing = 0;.
  • Commit 7b70e9ef ("upload-pack: disable lazy-fetching by default") is designated CVE-2024-32465 and also names CVE-2024-32004. The body's CVE is correct.

Overlap with #1169: clean.

Mutants: 17 of 19 killed. The survivors are walk-no-tags and ext-not-last, above. Killed:

  • dec-check, dec-execute, guard-check, guard-execute;
  • no-restore, restore-set-pops, value-0, setdefault;
  • none-as-zero, no-zero-return, walk-no-branches, wait-ignored, count-zero;
  • no-type-bool, any-promisor-value, ext-ignored, promisor-empty.

PR body form.

Two departures from the rules. In probe repositories I created under the scratch dir, I set uploadpack.allowFilter and remote.origin.uploadpack, through the conftest helper and directly, as the PR's own tests do. No shared or global config was touched. I also wrote one probe commit (commit-tree) in my scratch clone merged.

Not covered (STOP arrived)

  • The gate files were not re-run: test_path_guard_hygiene, the 4 test_shape_doctor shards, test_scaffold_leg_path_collisions, the doc trio, and G8–G10.
  • No run under the 3.14-like sitecustomize shim. The PR's code adds no path check; only the tests use exists/is_file.
  • The rejected -c protocol.allow=never "4 fetches" claim was not re-measured.
  • No real git older than 2.39.4. The "ignoring git" case was simulated by renaming the variable.
  • A promisor on a network was not measured.

Lane: openreposhape-1 (openRepoShape-1)

Brett Heap and others added 2 commits October 22, 2026 21:46
…blob the source lacks is refused through `check` and `execute` as `source-partial-clone`, and the remote `extensions.partialclone` names comes last where it is a promisor too, so neither the `--tags` of the walk nor that order is left unpinned (#1145)

A cured partial clone that fetches an annotated tag with the filter it keeps
lacks the blob its commit changes, and only the tag reaches it: branches lack
0 objects, tags 1. With `--tags` out of `LACKING_WALK`, `check` said `plan
ok` and `execute` made both legs, then died in `git filter-repo`.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ource-partial-clone` refusal says a later fetch stays filtered while `remote.<name>.partialclonefilter` is set, says git could not walk the refs, and names `git fsck`, where the walk fails, and the comments say `_not_a_whole_clone` reads a cloned plan's commit before the guard in any partial clone (#1145)

The refetch the refusal names leaves `remote.<name>.partialclonefilter` as it
was, so the next plain fetch can leave the source lacking again. Where
`git rev-list` cannot walk the branches and tags, nothing is known of what
is lacking: the refusal said "lacks what its branches and tags reach, as far
as git can tell", and named a refetch that does not bring in a ref outside
the remote's refspec. It now says git could not walk them and names `git
fsck`, as `Source.tree` does, with the refetch as a second way. The code
stays `source-partial-clone`, so the doc trio is unchanged.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 23, 2026
One conflict hunk in adopt-project.py per side, both resolved by taking both
changes. `_lazy_fetch_off` moved above `cmd_plan` on this branch, and #1180
reworded its docstring where it stood: the reworded sentence now sits in the
moved copy, ahead of this branch's pin-source sentence. The `Refusal` of
`_refuse_a_partial_clone_short` keeps this branch's "`plan`, `check` and
`execute` never fetch into the source" and takes #1180's `remediation`.

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-23T02:49:54Z) Independent re-review r1180b of #1180 at 6cd96b8b: NOT READY, for one SHOULD. Every r1180 finding is fixed as claimed. The new SHOULD is a gap of the same kind (not a regression).

Review r1180b of #1180 at 6cd96b8b: NOT READY, for one SHOULD

Every r1180 finding is fixed as claimed. Base against head reproduces exactly, and the body's counts and quoted refusals match what I measured. I'm holding it for one SHOULD, a gap of the same kind as r1180's tag finding:

  • The guard's walk does not reach the source's HEAD. execute's clone checks HEAD out.
  • A sparse, cured partial clone detached at a fetched branch gets plan ok from check. execute then makes both legs and dies.
  • It is not a regression: base does the same, and nothing is fetched on either side.

There are also four NITs.

Findings

SHOULD-1: LACKING_WALK misses the source's HEAD, which _extract_leg's clone checks out.

  • Where:
    • adopt-project.py:12961 (LACKING_WALK);
    • :13089-13091 (the "WHY BRANCHES AND TAGS" docstring, "which copies every branch and tag");
    • :14344: git clone -q <source> <work> checks out the source's HEAD before checkout -B.
  • Evidence: probe test_r1180b_probe2.py, in my scratch clone probe at 6cd96b8b.
    • Setup: a --filter=blob:none clone, checked out, then cured with git fetch --refetch --no-filter origin, then git sparse-checkout set specs.
    • origin gains a branch feature that changes .claude/commands/ship.md.
    • The source runs git fetch origin feature:refs/remotes/origin/feature (the filter it keeps), then git switch --detach origin/feature.
    [p5-pre] outside='.claude/commands/ship.md' fetch_exit=0 switch_exit=0 branches=0 tags=0 head=1 status=''
    [p5] command='check' exit=0 legs=None runs=0 store_same=True plan_ok=True
    [p5] command='execute' exit=2 legs=['Northwind-code.git', 'Northwind-spec.git'] runs=0 store_same=True plan_ok=False
    REFUSED extracting the spec leg. THE EXACT COMMAND WAS:
        git -c protocol.file.allow=always clone -q <base>/Thing <base>/work/Northwind-spec
        exit 128
    
    • git's own words come from the simpler shape p3: error: unable to read sha1 file of specs/001-feature/spec.md (ffd7f5b6…), warning: Clone succeeded, but checkout failed., then "NOTHING has been rolled back: … Northwind-spec.git and … Northwind-code.git exist". In p3, HEAD is moved by update-ref --no-deref to a tag fetched with the filter, and the tag is then deleted.
    • Base bba168bd, same probe: identical. check exit 0 with plan ok; execute makes both legs, then REFUSED extracting the spec leg. In every run runs=0 and store_same=True, so the no-fetch promise holds.
  • Fix: walk HEAD as well, but only where it resolves. A plain HEAD breaks an unborn HEAD:
    (after git switch --orphan unborn)
    with HEAD: exit 128
    fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.
    without HEAD: exit 0
    verify HEAD: exit 1      # git rev-parse --verify -q 'HEAD^{commit}'
    
    • Add HEAD's id where git rev-parse --verify -q resolves it.
    • Name HEAD in "its branches and tags reach" and in the "why" docstring.
    • Add the p3 shape as a row through check and execute: refused, no leg directory, 0 upload-pack runs.

NIT-1 (new in 6cd96b8b): the None remedy says git fsck "names each object the repository lacks". In a partial clone it names none of the objects the promisor remote holds.

  • Where: adopt-project.py:13126.
  • Evidence: probes p1 and p2, git 2.43, GIT_NO_LAZY_FETCH unset.
    [p1-fsck] lacking_branches=28 fsck_exit=0 fsck_lines=0 missing_lines=0 runs=0 store_same=True
    [p2-check] walk_exit=128 exit=2 runs=0 store_same=True        (fatal: bad object ghost)
    [p2-fsck] lacking_main=28 fsck_exit=2 fsck_lines=1 missing_lines=0 invalid_lines=1 runs=0 store_same=True
    error: refs/heads/ghost: invalid sha1 pointer 405dbc4a50cb0ef6a208eee68b5c1b03f3ba8728
    
    • The 28 lacking objects are never named. fsck skips promised objects.
    • Where the sentence came from: Source.tree()'s wording is right where it was written, a clone that lost an object. It is wrong here.
    • fsck itself fetches nothing: 0 runs, objects/ unchanged.
  • Fix: say what fsck shows here, for example: "it names a branch or tag whose object the repository lacks (it does not list what a partial clone's promisor remote holds)".

NIT-2: no row pins the count arm's words; mutant count-worded-as-none survives.

  • Where: adopt-project.py:13121, and tests/test_adopt_partial_clone_source.py:268-274. The three check refusal rows assert REFUSED, CURE and LATER, and both arms print all three.
  • Evidence: the mutant if lacking is None: → if lacking is None or lacking:.
    • Result: count-worded-as-none: SURVIVED [26 passed in 13.19s] restored=True.
    • Under it, the ordinary 28-object refusal says git "could not walk" the refs and what the source lacks "is not known", and leads with git fsck.
    • The lane's 29 mutants (read from muts_f1180_all.py, never imported) include none-as-count but not this reverse direction. So "29 of 29" is true, but only for that set.
  • Fix: in test_check_refuses_…, assert "that lacks at least " and "Remediation: make the source whole".

NIT-3: the rest of the None arm's words are unpinned.

  • Where:
    • adopt-project.py:13124 ({said}) and :13126-13128 ({where}, {refetch});
    • tests/test_adopt_partial_clone_source.py:439: the docstring says "naming how git exited", but line 449 asserts only the command.
  • Evidence:
    none-no-said: SURVIVED [26 passed in 13.34s]
    none-fsck-no-where: SURVIVED [26 passed in 13.29s]
    none-no-refetch: SURVIVED [26 passed in 13.53s]
    
  • Fix: in the failing-walk row, assert "exited " after the walk command, the source's path after git fsck in, and CURE.

NIT-4: two attribution slips in the body.

  • "2c8082a2 added the tests":
    • git show --stat 3b11751a shows tests/test_adopt_partial_clone_source.py | 416 +++, a new file with 10 test functions;
    • 2c8082a2 is 17 + lines, one row.
  • "6cd96b8b changed the refusal's words": it also added the 26th row, two promisor remotes. The Tests section does say so.
  • Fix: "3b11751a and 2c8082a2 added the tests", and "6cd96b8b changed the refusal's words and added the two-remote row".

r1180's findings: fixed as claimed

  • SHOULD-1 (tags): fixed.
    • The walk-no-tags mutant is killed by both tag rows: [2 failed, 24 passed].
    • On base, the check tag row fails with assert 0 == 2 after plan ok. The execute tag row fails with REFUSED extracting the spec leg … git filter-repo.
  • NIT-1: fixed. ext-not-last is killed by test_promisor_remotes_reads_git_s_rule[extension-also-promisor] [1 failed, 25 passed].
  • NIT-2: fixed. The comment at :12934-12938, the docstring at :12983-12985, and the body's known limit now say "before the guard, so in ANY partial clone".
  • NIT-3: fixed, and the sentence is true. Probe p4 measured it:
    [p4] kept_filter='blob:none' cured_lacking=0 pull_exit=0 lacking_after_pull=1 check_exit=2 runs=0 store_same=True
    
  • NIT-4: fixed as asked: the refusal says git could not walk the refs, gives no count, and names git fsck. But see the new NIT-1.
  • NIT-5: not taken, as the brief says. It is still open, and optional.

Measured

  • Base against head, 26-row file, git 2.43, Python 3.12, probe environment:

    • base bba168bd: 23 failed, 3 passed (the controls: check full and cured, and execute cured);
    • head: 26 passed in 11.32 s.
    • All five new rows fail on base: two remotes, tag[check], tag[execute], the failing walk, and extension-also-promisor.
  • Under a 3.14-like sitecustomize shim (exists/is_file/is_dir answer False to any OSError, confirmed active): 26 passed in 13.51 s.

  • Gate on 6cd96b8b:

    • test_path_guard_hygiene.py with test_no_auto_maintenance.py: 107 passed, 2 skipped, both at test_no_auto_maintenance.py:222;
    • the doc trio: 170 passed.
    • Both match the body.
  • Mutants: 16 run, in my clone r1180b/head. Each was restored in a finally, and the blob equalled HEAD's after every one. 12 of 16 killed.

    mutant result
    filter-key-promisor killed by 5 rows (the 3 check refusals, two remotes, failing walk)
    filter-key-unspelled killed by the same 5
    later-and killed by two remotes
    later-first-only killed by two remotes
    refetch-first-only killed by two remotes
    none-no-later killed by the failing walk
    count-no-later killed by 4 rows
    none-worded-as-count killed by the failing walk
    none-read-as-zero killed by the hand-written branch and the failing walk
    walk-no-tags killed by both tag rows
    walk-no-branches killed by 8 rows
    ext-not-last killed by extension-also-promisor
    count-worded-as-none survived
    none-no-said survived
    none-fsck-no-where survived
    none-no-refetch survived

    My equivalents of this round's lane mutants give the body's kill counts exactly: 5, 4, 1, 1 and 1.

  • The body's other claims all check out:

Not covered

  • git 2.45 or later, which CI likely runs. The PR's own docstring (tests:422-423) says the hand-written branch is counted there. Its refusal would then lead with the refetch that r1180 found does not cure it. Not measured: this bench has only git 2.43.
  • Gate files not run: the 4 test_shape_doctor.py shards, test_scaffold_leg_path_collisions.py, test_adopt_check_throwaway_repos.py, and the tree merged with dcf2751d. The diff since 2c8082a2 is message text, one helper and comments.
  • Real CPython 3.14, and a promisor on a network.

Departures from the rules

  • Config writes: my probes wrote config only in repositories under my own basetemps, through the PR's test helper (remote.origin.uploadpack) and git sparse-checkout set.
  • Untracked files exist only in my scratch clones (head, probe, base).
  • Load rule: one 2.4 s single-row pytest call started while pgrep read 5. The rule says wait at 4 or more.

The review checkout /workspace/projects/openRepoShape-worktrees/l1-r1180 is clean at 6cd96b8b. Everything I made is under /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1180b (probes, mut_r1180b.py, mut-results.txt, the logs).

Lane: openreposhape-1 (openRepoShape-1)

Brett Heap and others added 2 commits October 23, 2026 07:48
…ce.py: the `source-partial-clone` walk also reaches the source's HEAD where `git rev-parse --verify -q HEAD` gives an id, so a HEAD detached at a commit no branch or tag reaches is refused through `check` and `execute` and an unborn HEAD is read as before, and the `git fsck` sentence says what fsck names in a partial clone, and the count and walk-failure words are pinned (#1145)

SHOULD-1 of r1180b: `LACKING_WALK` ended at `--branches --tags`, but `execute`'s `git clone` checks out the source's HEAD. HEAD is walked by its id, asked with `rev-parse --verify -q HEAD` and not `HEAD^{commit}`, which fetched a commit the clone lacks (measured with git 2.43, 1 `upload-pack` run) and exits 1 under `GIT_NO_LAZY_FETCH`. A plain `HEAD` fails on an unborn HEAD, exit 128, and on a file named HEAD in the work tree.

NIT-1: the `None` remedy names what `git fsck` shows, a branch, tag or HEAD whose object is lacking, and no object a promisor remote holds. NIT-2 and NIT-3: the count arm's words and the failing walk's command, `exited`, the source's path after `git fsck` in, and the cure are asserted.

Lane: openreposhape-1 (openRepoShape-1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… blob the source lacks names HEAD in what its branches and tags reach, and a file of the source named HEAD is no path of the walk (#1145)

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-23T18:31:43Z) Independent review r1180d of #1180 at a4ec51d2: READY, with 4 NITs. No MUST and no SHOULD. The reviewer stopped at lane 1's LAST STEP before its step 4, the gate, so lane 1 ran that gate afterwards with its own runner, at a4ec51d2 in the probe environment, and every call passed:

  • tests/test_doctor_partial_clone.py: 30 passed
  • tests/test_scaffold_leg_path_collisions.py: 104 passed
  • tests/test_path_guard_hygiene.py with tests/test_no_auto_maintenance.py: 107 passed, 2 skipped (test_no_auto_maintenance.py:222, from the probe environment)
  • tests/test_shape_doctor.py, 4 shards of one collect: 445 passed, 3 skipped

Lane 1 takes the 4 NITs in a fix round. The bug the reviewer found outside this PR is filed as #1215.

Review r1180d of #1180 at a4ec51d2: provisionally READY, with 4 NITs. PARTIAL: stopped at LAST STEP before the gate (step 4)

I found no MUST and no SHOULD in what I covered (steps 1 to 3). The -- is right on the walk's only call path. The printed walk is true to what runs, and the new row proves what its name says. 14 of my 17 mutants were killed. Of the 3 survivors, one is an equivalent mutant and two are gaps in what the rows assert. All four merge-trees are clean. Step 4, the gate, was not run, apart from the 38-row file, so this is not a full review.

LAST STEP:

  • Check in hand: the mutants. They had finished: driver A ended at 18:16:11 and driver B at 18:16:41, both rc=0.
  • Runs: none of mine is going. There was nothing to kill and nothing to record.
  • findings.md: not written. The harness refused the write ("Subagents should return findings as text, not write report files"), and I did not work around it. This message is the whole record.
  • Trees:
    • The review worktree /workspace/projects/openRepoShape-worktrees/l1-r1180 is detached at a4ec51d2, and git status --short is empty.
    • Both mutant clones are back at blob c98cc216, the head's adopt-project.py, with an empty status.

Findings

NIT-1: text next to the delta still describes the walk as it was before the --.

  • Where:
    • adopt-project.py:12968-12969, the HEAD_ID comment: "A plain HEAD in the walk fails there, ambiguous argument 'HEAD', exit 128."
    • adopt-project.py:13071-13074, the _objects_lacking docstring. It gives as a reason for walking the id that "a file of the source's work tree named HEAD would be read as a path".
    • tests/test_adopt_partial_clone_source.py:539-540, the unborn row's docstring: "ambiguous argument 'HEAD'".
    • tests/test_adopt_partial_clone_source.py:557-559, the file-named-HEAD row's docstring: "with HEAD in the walk, a file ... named HEAD made git exit 128".
    • The PR body's lines 39 and 40 ("A plain HEAD in the walk exits 128 there (fatal: ambiguous argument 'HEAD': unknown revision ...)", and "with HEAD in the argument list git exits 128 ... The walk takes the id").
  • Evidence: probe r1180d/probe1.py on git 2.43.0 with the variable unset. The log is r1180d/logs/probe1.log.
    [P1 unborn] argv-tail=['HEAD'] exit=128 ... err=["fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree."]
    [P1 unborn] argv-tail=['HEAD', '--'] exit=128 ... err=["fatal: bad revision 'HEAD'"]
    [P2 file HEAD] argv-tail=['HEAD'] exit=128 ... err=["fatal: ambiguous argument 'HEAD': both revision and filename"]
    [P2 file HEAD] argv-tail=['HEAD', '--'] exit=0 ...
    [P3 file id] argv-tail=['HEAD', '--'] exit=0 ...
    
    • My mutant M05, which walks HEAD by name and keeps the --, survives all 38 rows. With the --, the id and the name walk the same.
    • The file-named-HEAD row now kills only the pair, HEAD by name with no -- (M06).
    • The body's round-2 row head-by-name was right at b316f415, as its table says, but is an equivalent mutant at a4ec51d2.
  • Fix: say bad revision 'HEAD' where the text describes a plain HEAD in today's walk. Where the text gives ambiguous argument ... both revision and filename as a reason, say it holds without the --. For example, for the docstring: "a plain HEAD fails on an unborn HEAD (bad revision 'HEAD', exit 128). Without the --, a file of the work tree named HEAD, or named as the id, is read as a path." Keep walking the id, or say that the name would now do as well. Update body lines 39 and 40 to match.

NIT-2: no row pins git's own words in the could-not-walk refusal.

  • Where:
    • adopt-project.py:13095-13096 (f"{spelled_as_git_out(words)}");
    • the failing-walk row, tests/test_adopt_partial_clone_source.py:473-478, which asserts only exited .
  • Evidence: mutant M15 replaces git's words with "". Result: 38 passed in 19.39s (r1180d/logs/mutB.tsv).
  • Fix: in test_a_partial_clone_git_cannot_walk_is_refused_on_any_git, assert that git's words follow the exit code. For example, re.search(r"exited \d+: .*no-such-option", said.err); re is already imported. I did not measure git's exact wording for that option, so check it first.

NIT-3: the count of the count arm is not pinned. The row's docstring (tests/...:280) and body line 86 say the refusal "gives the count it lacks at least".

  • Where: adopt-project.py:13091 (the counting line), and tests/test_adopt_partial_clone_source.py:288, which asserts only "that lacks at least ".
  • Evidence: mutant M17, lacking = max(lacking, int(line.startswith(b"?"))), caps the count at 1. Result: 38 passed in 20.16s.
  • Fix: in the row, count ? lines from the same walk and assert f"that lacks at least {n} object(s)". The walk is git rev-list --objects --missing=print --branches --tags HEAD --. That walk fetches nothing: probe P5 gave runs=0 store_same=True.

NIT-4: the body's live facts are stale.

Measured

All runs were in the probe environment r1180d/env.sh: stub gh, GH_TOKEN=bogus-r1180d, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false, EMAIL=r1180d@openreposhape.invalid, and env -u LANES_LANE. The bench has git 2.43.0 and Python 3.12.3.

Step 1: the delta b316f415..a4ec51d2.

  • One call path.
    • _objects_lacking (13087) is the walk's only Popen.
    • It is called only from _refuse_a_partial_clone_short (13162).
    • That function is called from cmd_check (13229) and cmd_execute (15843).
    • The argv and shown both end with --, born and unborn.
  • The -- works (P3):
    • <id>, with a file named as the id, exits 128 with both revision and filename.
    • <id> -- exits 0.
  • The body's line-47 refusal text holds (P4, ghost HEAD): <id> -- exits 128 with fatal: bad object <id>, with runs=0 store_same=True.
  • The LACKING_WALK comment's "never fetches" holds with -- (P5, HEAD at a commit whose blob is lacking): exit=0 lacking=1 runs=0 store_same=True.
  • The new row proves its name.
    • It asserts its premise: rev-list <id> exits 128.
    • Then check gives plan ok, without CODE, and the source is untouched.
  • The added lines have nothing over 79 columns, no trailing space, and no non-ASCII.

Step 2: base against head, the shim and the mutants.

mutant killed by
M01 argv without -- 1: the new row
M02 shown without -- 2: the failing walk, born and unborn
M03 argv ends with --end-of-options instead of -- 1: the new row
M04 -- before the id 12: the same 12 as the body's dashes-before-head
M05 argv walks HEAD by name, with -- survived: equivalent (P2, P3)
M06 argv walks HEAD by name, no -- 1: the file-named-HEAD row
M07 HEAD not walked 3: the HEAD-blob row (check, execute), the ghost HEAD
M08 head = _head_id(path) or "HEAD" 3: the failing walk (unborn), the unborn row (check, execute)
M09 HEAD_ID peeled to HEAD^{commit} 1: the ghost HEAD
M10 shown names the id, not HEAD 1: the failing walk (born)
M11 exit 128 read as success 2: the ghost branch, the ghost HEAD
M12 no --tags 2: the tag row (check, execute)
M13 no --branches 1: the ghost branch
M14 --missing=allow-any 11
M15 git's words dropped survived (NIT-2)
M16 if not lacking: (None read as 0) 4: the ghost branch, the failing walk (born, unborn), the ghost HEAD
M17 count capped at 1 survived (NIT-3)

The writer's 3 round-3 mutants match M01, M02 and M04, with the same killing rows.

Step 3: merge-tree (merge-tree --write-tree, run in a scratch clone):

with merge-base result
#1169 76c9b767 bba168bd clean, exit 0, tree c4495b59 (auto-merging adopt-project.py)
#1190 7f3e1280 a4ec51d2 (an ancestor) clean, tree 6f740ccb, which is 7f3e1280's own tree
#1119 195d9936 bba168bd clean, tree 6971b2c9
#1119 e0a98f13 (live) bba168bd clean, tree 73a3dd1f

The body's three tree ids are confirmed: a4ec51d2+1e958ca6 gives 71bb1e8c, a4ec51d2+25805102 gives daab3eca, and b316f415+25805102 gives 88269a06.

The body:

Left (not covered)

Outside this PR (observations, not findings)

Departures from the rules

Lane: openreposhape-1 (openRepoShape-1)

Brett Heap and others added 4 commits October 23, 2026 20:25
…o-fetch

Step 1 of the three-merge restack after #1119 squash-landed on main as b7edd88.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 2 of the three-merge restack. git diff 9b85644 b7edd88 is empty for
#1119's files, so any conflict there takes ours, the branch's version.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 3 of the three-merge restack after #1119's squash b7edd88.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xt beside the `source-partial-clone` walk says a plain `HEAD` fails on an unborn HEAD as `bad revision 'HEAD'` now that the walk ends with `--`, and the rows pin git's own words after the failed walk's exit and the exact count the source lacks (#1145)

r1180d's NIT-1 to NIT-3.

- NIT-1: the `HEAD_ID` comment, the `_objects_lacking` docstring and two
  row docstrings described the walk as it was before the `--`. Measured
  with git 2.43: `rev-list ... HEAD --` on an unborn HEAD says
  `fatal: bad revision 'HEAD'` (exit 128); a file named `HEAD` is read as
  a path (`both revision and filename`) only without the `--`.
- NIT-2: the failing-walk row asks this git for its own words, running the
  same walk, and asserts `exited <rc>: <words>`. Git 2.43 prints its usage
  (exit 129) for `--no-such-option`, which does not name the option.
  Kills M15 (git's words dropped).
- NIT-3: the count row counts the `?` lines of the tool's own walk
  (`--branches --tags HEAD --`), asserts more than 1, and asserts
  `that lacks at least <n> object(s) `. Kills M17 (count held at 1).

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 23, 2026
#1180 restacked on main after #1119's squash b7edd88 through three merges
(c3f8a2b, ef73f75, b18d2e8), then a0e8cde fixed r1180d's NIT-1 to NIT-3.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-23T20:49:33Z) Lane 1's delta check of #1180 since r1180d's READY at a4ec51d2: READY carries to a0e8cded, once tests and SonarCloud are green there and lane 1's gate passes.

Lane: openreposhape-1 (openRepoShape-1)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 24, 2026 14:37
@brettheap
brettheap merged commit 4829418 into main Oct 24, 2026
5 checks passed
@brettheap
brettheap deleted the fix/1145-partial-clone-source-no-fetch branch October 24, 2026 14:39

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue

Fixed security issues:

  • Command injection from untrusted input passed to OS command execution (link)
Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="adopt-project.py" line_range="13013-13019" />
<code_context>
+    `source.cloned: true` plan names."""
+    before = os.environ.get(NO_LAZY_FETCH)
+    os.environ[NO_LAZY_FETCH] = "1"
+    try:
+        yield
+    finally:
+        if before is None:
+            os.environ.pop(NO_LAZY_FETCH, None)
+        else:
+            os.environ[NO_LAZY_FETCH] = before
+
+
+def _config_listed(path: Path, *args: str) -> bytes:
+    """What `git config -z <args>` lists in `path`, as bytes; nothing where
+    it lists nothing (exit 1) or cannot run. Config only: no object is read,
+    so nothing is fetched."""
+    try:
+        listed = subprocess.run(["git", GIT_CONFIG, "-z", *args],
+                                cwd=str(path), capture_output=True,
+                                check=False)
+    except OSError:
+        return b""
+    return listed.stdout if listed.returncode == 0 else b""
+
+
</code_context>
<issue_to_address>
**Config lookup failure skips guard**

When A `git config` query cannot run or errors, but a later Git invocation in the command succeeds, `_config_listed` treats a failed `git config` invocation as an empty config result, so `_promisor_remotes` returns no remotes and the guard exits. If later Git commands succeed, they can read the partial clone and lazily fetch into its object store without the promised refusal.

Distinguish the normal no-match exit from configuration-query failures and refuse when promisor status cannot be determined.

Also at `adopt-project.py:13162-13163`.
</issue_to_address>

Sourcery assessment

Approval pending. 1 finding to address first.

Blocking findings: adopt-project.py:13019


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread adopt-project.py
Comment on lines +13013 to +13019
try:
listed = subprocess.run(["git", GIT_CONFIG, "-z", *args],
cwd=str(path), capture_output=True,
check=False)
except OSError:
return b""
return listed.stdout if listed.returncode == 0 else b""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Config lookup failure skips guard

When A git config query cannot run or errors, but a later Git invocation in the command succeeds, _config_listed treats a failed git config invocation as an empty config result, so _promisor_remotes returns no remotes and the guard exits. If later Git commands succeed, they can read the partial clone and lazily fetch into its object store without the promised refusal.

Distinguish the normal no-match exit from configuration-query failures and refuse when promisor status cannot be determined.

Also at adopt-project.py:13162-13163.

Prompt for AI agents
In `adopt-project.py` at lines 13013-13019:

**Config lookup failure skips guard**

When A `git config` query cannot run or errors, but a later Git invocation in the command succeeds, `_config_listed` treats a failed `git config` invocation as an empty config result, so `_promisor_remotes` returns no remotes and the guard exits. If later Git commands succeed, they can read the partial clone and lazily fetch into its object store without the promised refusal.

Distinguish the normal no-match exit from configuration-query failures and refuse when promisor status cannot be determined.

Also at `adopt-project.py:13162-13163`.

brettheap pushed a commit that referenced this pull request Nov 8, 2026
#1180's final head is update-branch's merge of main 50869f3 into
a0e8cde, the tree that squash-landed as 4829418. This is the first of
the three merges that restack #1190 after #1180 landed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Nov 8, 2026
#1180's squash 4829418 has the same tree as its final head 58a2175,
which the previous merge brought in, so the content it brings for
#1180's own files (adopt-project.py, docs/cli.md,
tests/test_adopt_partial_clone_source.py) is already on this branch.
Those paths, and the paths main brought into 58a2175 (a0e8cde..58a2175),
take this branch's side; the merge changes no file.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Nov 8, 2026
Main moved past #1180's squash 4829418 by e9a1650 (#1109) and bd3e9c1
(#1235), which touch shape-doctor.py, tests/test_doctor_plan_overrides.py,
tests/test_family_bump_fetch_refspecs.py and tests/test_path_guard_hygiene.py.
None of them is a file #1190 changes. This is the third of the three
merges that restack #1190 after #1180 landed.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Nov 8, 2026
Two conflicts, both from #1180's partial-clone sentence beside this
branch's unreadable-.git sentence in SOURCE_OVERRIDE_HELP:

- adopt-project.py, SOURCE_OVERRIDE_HELP: the "one this user cannot
  reach, or one whose `.git` this user cannot read, a clone `plan` made
  included, as `source-unreadable`, never cloned again." sentence is
  kept, and #1180's "Either source, where it is a partial clone ..."
  sentence follows it.
- docs/cli.md, the --source help of check and execute: regenerated with
  scripts/render-cli-reference.py, not edited by hand.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Nov 8, 2026
…artial-clone `--source` that lacks objects as `source-partial-clone`, and names a partial `--pin-source` it could read only by fetching and the fetch that makes it whole (#1162) (#1190)

#1180 (for #1145) made `check` and `execute` never fetch into a partial-clone `--source`. `adopt-project.py plan` reads the same source with git, and reads each `--pin`'s digest from `--pin-source` with `git ls-tree -r`, and it was left as it was. In a partial clone (`git clone --filter`), git fetches an object the clone lacks from its promisor remote on any read that needs it. So `plan` made network calls it never announced, and wrote into a clone the person only meant to read.

- `plan` now runs with `GIT_NO_LAZY_FETCH=1` (`@_lazy_fetch_off()` on `cmd_plan`; the environment is put back afterwards). Right after `Source.of(...)`, as in `check` and `execute`, `_refuse_a_partial_clone_short(source)` refuses a partial-clone `--source` that lacks an object its branches, tags or HEAD reach as `source-partial-clone`. Its detail now says "`plan`, `check` and `execute` never fetch into the source".
- A partial `--pin-source` is not refused for being partial. A `--filter=blob:none` clone that holds the commit holds its trees, and gives the digest a full clone does, with no run. `_pins_read_unfetched` asks only about a read that failed. Where it failed as `pin-source-unreadable` and the pin clone is a partial clone, the refusal keeps its code and the reader's words. It adds that the clone is a partial clone, whose git fetches what it lacks, and that `plan` never fetches into a `--pin-source`. It names the cure, `git fetch --refetch --no-filter <remote>`, or a full clone, or dropping `--pin-source`. It ends with "Later fetches stay filtered while `remote.origin.partialclonefilter` is set, so the clone can lack objects again."
- A partial `--source` is refused before a partial `--pin-source` is read, so `plan` names `source-partial-clone` first, as `check` and `execute` do.
- `plan`'s `--source` help names `source-partial-clone` and the cure. Its `--pin-source` help says which partial clones are read and which are refused, and that on a git that honours `GIT_NO_LAZY_FETCH` (2.45 or later, or a security release from 2.39.4 on) a partial pin clone is refused with nothing fetched into it, while an older git fetches what it lacks. `docs/cli.md` is regenerated.
- `tests/test_adopt_plan_partial_clone.py` holds 15 rows.
- Measured with git 2.43.0 and Python 3.12.3 (the promisor's `upload-pack` replaced by a counting stub), `plan` at #1180's head `2c8082a2` against this PR's head:
  - A `--source` that is a `--filter=blob:none --no-checkout` clone (28 objects lacking) went from exit 0, a plan written, 26 `upload-pack` runs and `.promisor` files 1 to 27, to exit 2 `source-partial-clone`, no plan, 0 runs and `objects/` unchanged. A checked-out `--filter=blob:none` clone with 2 history blobs lacking went from exit 0 to exit 2.
  - A `--pin-source` that is a `--filter=tree:0 --no-checkout` clone, or a `blob:none` clone that lacks the pinned commit, went from exit 0 with 1 run and `.promisor` 1 to 2, to exit 2 `pin-source-unreadable` with the partial-clone sentence and the cure, 0 runs, and the clone unchanged.
  - A full `--source`, a partial clone cured by `git fetch --refetch --no-filter`, a full `--pin-source`, and a `blob:none` pin clone that holds the commit all plan as before, with 0 runs. A full pin clone that lacks the commit, and a folder inside a partial clone, keep the reader's words.
  - Against #1180's `a0e8cded` as base, the new file fails 7 rows and passes 8. At head all 15 pass.
  - Mutants of the changed lines, each run against the new file in a scratch clone and restored after: 8 of 8 killed in the first round. In the NIT round, the guard moved after the pin read (r1190's M3) and the pin cure's later-fetch sentence dropped are both killed, 2 of 2.
- Known limits:
  - A git that ignores `GIT_NO_LAZY_FETCH` still fetches into a partial `--pin-source`, as before. Git honours the variable from 2.45.0 and in the security releases of 2024-05-14 back to 2.39.4. There is no guard for pins, by design: a clone that holds the commit and its trees must stay readable. The `--source` is kept whole on every git, by the guard.
  - A partial `--pin-source` that git cannot read for a reason other than a missing object keeps the reader's own refusal, with the partial-clone sentence added, because the clone is partial. The cure it names is then not what fails it.
  - `plan` does not walk a `--source` that is not a partial clone: the guard reads its config alone.
  - `scaffold-project.py --pin-source` reads its pin with the same `declared_pin_values` and has the same fetch. It is not changed here.

Review: lane 1's independent reviewers reviewed each round. r1190 found `7f3e1280` READY, with no MUST, no SHOULD and 3 NITs (#1190 issuecomment-6372019066). It killed 13 of its 14 mutants. The survivor, M3, is the order the new row now pins. Lane 1's delta check of the NIT round `359a749c` found it sound: the order row kills M3, the help is qualified and `docs/cli.md` regenerated, and the pin cure ends with the filter sentence, which a mutant that drops it fails (issuecomment-6374465346). The one NIT lane 1 raised there, on the help's "2.45 or later", was withdrawn: git's own documentation for 2.45.0 documents `GIT_NO_LAZY_FETCH`. READY carries to `359a749c` (issuecomment-6374696951). After #1180 landed as `4829418f`, the branch was restacked onto main by three merges, with no rebase, and its changed lines equal the reviewed delta (sha256 `cec9aed8`). Lane 1's delta check carries READY to `e42134e1` (issuecomment-6722224429).

Tests, under a stub `gh` with a bogus token: at `359a749c`, lane 1's gate gave 953 passed and 5 skipped over 7 calls. They covered the PR's test file with `tests/test_adopt_partial_clone_source.py` in one call, since the new file imports the other's fixtures, the doc trio, `tests/test_scaffold_leg_path_collisions.py` with the path-guard and no-auto-maintenance scans, and `tests/test_shape_doctor.py` in 4 shards. At the restacked head `e42134e1`, the PR's two partial-clone files, the two scans with `tests/test_scaffold_leg_path_collisions.py`, and the doc trio gave 507 passed and 2 skipped over 3 calls.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its H0 `e42134e1` 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 45dbf40, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: 7 pytest calls over `tests/test_adopt_plan_partial_clone.py`, `tests/test_adopt_partial_clone_source.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`, `tests/test_repo_hygiene.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_path_guard_hygiene.py`, `tests/test_no_auto_maintenance.py` and `tests/test_shape_doctor.py` in 4 shards: 953 passed, 5 skipped.

Closes #1162

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
…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