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 brettheap changed the title 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 or tags reach as source-partial-clone, so neither fetches into the source any more (#1145) 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) Oct 23, 2026
brettheap pushed a commit that referenced this pull request Oct 23, 2026
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
…ached`, so it reads `attr.tree` as the split does (r1169b's SHOULD-1); rows for r1169b's P3 and P5 (check, all forms and a no-setting control; execute, three forms) and #1192's case (P7)

Measured so far at this commit, worktree, git 2.43, probe environment:
the 17 new ids passed; the round's 50 ids plus the 17 gave 67 passed;
tests/test_adopt_reencoded_files.py gave 51 passed. Base runs, the shim,
mutants, the gate and the #1180 merge are not run yet.

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-23T12:09:42Z) Independent re-review r1180c of #1180 at b316f415: provisionally READY, with one NIT. The departure from the ruling, a plain HEAD, is verified. The review stopped at lane 1's STOP before the shim, the mutants, the merge-tree and the gate. Those are named at the end.

Lane 1 takes the NIT: the walk's argv ends with --.

Review r1180c of #1180 at b316f415: provisionally READY, but incomplete (stopped by lane 1)

I found no MUST and no SHOULD in the parts I covered. There is one new NIT. Mutants, the shim run, the merge-tree and the gate were not done before STOP, so don't treat this as a full review.

STOP:

  • My runs: I had nothing to kill. Every run was in the foreground and had ended, the last being a bounded wait loop. No process of mine is running.
  • Review checkout: /workspace/projects/openRepoShape-worktrees/l1-r1180 is detached at b316f415, as the brief asked. git status --short was empty right after the move, and I wrote nothing there afterwards.

Findings

NIT-1: the walk can still read HEAD as a path, now under HEAD's own id.

  • Where: adopt-project.py, the Popen argv in _objects_lacking (about line 13081): ["git", *LACKING_WALK, *([head] if head else [])]. The _objects_lacking docstring says a file named HEAD "would be read as a path", and the row is named "is no path of the walk".
  • Evidence: probe p3 in my scratch clone. The source was a cured partial clone that lacks nothing, with a work-tree file named HEAD's 40-hex id.
    [p3] exit=2 plan_ok=False refused=True runs=0
    REFUSED source-partial-clone: ... whose branches, tags and HEAD git could not walk ... exited 128: fatal: ambiguous argument '0ef000c366ec8179a3e09f6d52fb3cfb221f659b': both revision and filename
    [p3-walk--] exit=0 err=''        (the same walk with `--` appended)
    
    Nothing was fetched or written. The cost is a false refusal, and only for a very unlikely file name.
  • Fix: end the argv with "--", after the id. One -- keeps both a file named HEAD and a file named the id from being read as a path. Then extend test_a_file_named_head_in_the_source_is_no_path_of_the_walk with a file named HEAD's id.

r1180b's findings: each fixed as claimed

  • SHOULD-1 (HEAD not walked): fixed.

    • The new rows: test_a_head_whose_blob_the_source_lacks_is_refused[check] and [execute] fail on base and pass on head.

      • On base, check printed the plan (plan ok).
      • On base, execute gave REFUSED extracting the spec leg.
    • r1180b's own p5 probe, re-run on both trees:

      • head, both commands: exit=2 legs=None runs=0 store_same=True;
      • base: check exit=0 plan_ok=True, and execute legs=['Northwind-code.git', 'Northwind-spec.git'], then REFUSED extracting the spec leg.

      This matches the body's p5 row.

  • The departure (HEAD, not HEAD^{commit}): verified on git 2.43.0-1ubuntu7.3. The probe source is a cured clone with .git/HEAD set to the side commit, which the clone lacks:

    form='HEAD'          nolazy=False exit=0 out='f9a47f025e3a' runs=0 store_same=True has_after=False
    form='HEAD'          nolazy=True  exit=0 out='f9a47f025e3a' runs=0 store_same=True has_after=False
    form='HEAD^{commit}' nolazy=False exit=0 out='f9a47f025e3a' runs=1 store_same=False has_after=True
    form='HEAD^{commit}' nolazy=True  exit=1 out=''  err='warning: lazy fetching disabled; ...' runs=0
    unborn: both forms, either env: exit=1 out='' runs=0
    walk with the ghost id: exit=128 'fatal: bad object f9a47f02...'
    

    So the peeled form fetches where the variable is ignored, and drops HEAD silently where it is set. The plain form never fetches. I agree with the departure.

  • Other HEAD shapes (p4): none fetched (runs=0, store_same=True).

    • HEAD hand-written to a tree or a blob passes the guard. check then exits 1 with adopt-git-unasked (the split's clone refuses a non-commit HEAD), so the guard is not involved.
    • HEAD as a symbolic ref to refs/tags/tagged is refused.
    • HEAD as a symbolic ref to a remote-tracking ref (refs/remotes/origin/feature) that only HEAD reaches is refused, lacking 1.
  • NIT-1 (the fsck sentence): fixed. The new sentence is in the code, and the four git fsck rows pass on base and head; they pin git's behaviour. I did not probe fsck beyond those rows.

  • NIT-2 and NIT-3: fixed in the diff.

    • The count arm now asserts "that lacks at least " and "Remediation: make the source whole".
    • The failing-walk row now asserts the walk as run, exited , the source path after git fsck in, and CURE.

    I ran no mutants to confirm that these assertions kill anything.

  • NIT-4: fixed. The body names 3b11751a and 2c8082a2 for the tests, and 6cd96b8b for the words and the two-remote row.

Measured

All in the probe environment, git 2.43.0, Python 3.12.3.

Not covered (stopped)

  • The 3.14-like shim run of the 37 rows. The shim is built at /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1180c/shim and confirmed active, in-process and in a child, but not run against the file.
  • Mutants: none of mine, and f1180c's 44 of 44 were not re-run.
  • The merge-tree with 25805102 (claimed exit 0, tree 88269a06), and any test of that merged tree.
  • Every gate call: G1 to G8.
  • git 2.45 or later, and real CPython 3.14: neither is on this bench.

Departures from the rules

  • Load rule: two of my two-call pairs started while pgrep read 4 pytest processes. The rule says to wait at 4. They were the base/head pair and the p5 pair, each under 40 s.

  • Config writes: only in repositories under my own basetemps, through the PR's fixtures and my probes.

  • Untracked files: they exist only in my scratch clones under /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1180c:

    • head/tests/test_r1180c_probe.py;
    • head/tests/test_r1180c_p5.py and base/tests/test_r1180c_p5.py;
    • base/tests/test_adopt_partial_clone_source.py.

    The logs are in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1180c/logs.

Lane: openreposhape-1 (openRepoShape-1)

Brett Heap and others added 2 commits October 23, 2026 15:27
…nd execute a default branch git will not take share one helper each, so SonarCloud's new-code duplication on #1119 falls under its 3% gate (#1083)

The parent-git rows and the global-config rows repeated one body each; each body is now a module-level helper that takes the function building the environment, and no row asserts or sets up anything else than before. All test ids keep their names.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ource-partial-clone` walk ends with `--`, so a file of the source's work tree named as HEAD's id is no path of it (#1145)

`_objects_lacking` walked `git rev-list --objects --missing=print --branches --tags <id>`, and a file of the source's work tree named as HEAD's id made git exit 128 (`ambiguous argument '<id>': both revision and filename`), so `check` and `execute` refused a source that lacks nothing as `source-partial-clone`. The walk now ends with `--`, after the id, so no file of the work tree is read as a path of it, and the walk the refusal names ends with `--` too. The new row puts a file named as HEAD's id in a cured partial clone's work tree and expects `plan ok` with nothing fetched; it fails at `b316f415` and passes here, and the failing-walk row, born and unborn, which asserts the walk as named, now expects the `--`.

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s/test_adopt_check_throwaway_repos.py

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`.

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