Repository navigation
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
Conversation
…` 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>
Reviewer's GuideThe PR makes Sequence diagram for partial-clone source validationsequenceDiagram
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
Flow diagram for safe check and execute handlingflowchart 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
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(lane openRepoShape-1, 2026-10-22T21:24:10Z) Independent review r1180 of #1180 at
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 State at stop: all my runs had finished before STOP arrived, so nothing was killed. The mutant harness put FindingsSHOULD-1: no row pins
NIT-1: mutant
NIT-2: the known limit is narrower than what happens.
NIT-3: the remedy does not last.
NIT-4: the wording and remedy for a walk that fails do not fit.
NIT-5:
What I measured and confirmedThe new test file, base against head.
Real partial clones (
The decorator restores the environment on every path.
Promisor detection, all three keys and a
The known limit's git versions are right. I read git's own sources with WebFetch:
Overlap with #1169: clean.
Mutants: 17 of 19 killed. The survivors are
PR body form.
Two departures from the rules. In probe repositories I created under the scratch dir, I set Not covered (STOP arrived)
Lane: openreposhape-1 (openRepoShape-1) |
…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>
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>
|
(lane openRepoShape-1, 2026-10-23T02:49:54Z) Independent re-review r1180b of #1180 at Review r1180b of #1180 at
|
| 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:
- title 231 characters;
- exactly one closing form,
Closes #1145, and no other close, fix or resolve form before a number; - the base branch is at
bba168bd, which is adopt-project.py: check asks the split's own clone of the source in the work directory, so a clone this git will not make is adopt-git-unasked before any leg, and _resolved and _answers make their repositories as check's other throwaways do (#1083) #1119's head; adopt-project.py:check's rehearsal and_stored_as_writtentake a parent's-csettings,attr.treetoo, and the source's objects,GIT_ATTR_SOURCEis refused by name before any question, and each throwaway reads its environment first (#1136) #1169's branch is atdcf2751d; - merge-tree
6cd96b8b×b8e63bcf→ exit 0,3f0c984c;6cd96b8b×dcf2751d→ exit 0,99c4cefd;2c8082a2×b8e63bcf→3a74e27a; - both quoted refusals match my p1 and p2 output word for word, apart from the path;
- the row groups add up to 26.
closingIssuesReferencesis[]only because the base is notmain.
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.pyshards,test_scaffold_leg_path_collisions.py,test_adopt_check_throwaway_repos.py, and the tree merged withdcf2751d. The diff since2c8082a2is 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) andgit 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
pgrepread 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)
…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>
|
(lane openRepoShape-1, 2026-10-23T18:31:43Z) Independent review r1180d of #1180 at
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
|
| 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:
- Closing forms (scripted scan): exactly one,
Closes #1145, with no "Covers" or "Part of". - Required lines: the ruling (2026-10-21T10:32:04Z) is named, and the body ends with the Lane line, a blank line, and the generated line.
- Title: 237 characters.
- Stacking: "lands after adopt-project.py: check asks the split's own clone of the source in the work directory, so a clone this git will not make is adopt-git-unasked before any leg, and _resolved and _answers make their repositories as check's other throwaways do (#1083) #1119" is right; only the head id is stale (NIT-4).
Left (not covered)
-
Step 4, the gate at
a4ec51d2, except the 38-row file above. The other calls were not run:tests/test_doctor_partial_clone.py;tests/test_scaffold_leg_path_collisions.py;tests/test_path_guard_hygiene.pywithtests/test_no_auto_maintenance.py;- the doctor's 4 shards.
A clean gate clone is ready at
r1180d/gate, detached ata4ec51d2.r1180d/shards.py <gate> <outdir>does one collect and writesids_00toids_03, asserting each is non-empty. Then pass each file aspytest ... @<outdir>/ids_0N; pytest 9.1.1 reads it one id per line. -
The merged trees (
c4495b59with adopt-project.py:check's rehearsal and_stored_as_writtentake a parent's-csettings,attr.treetoo, and the source's objects,GIT_ATTR_SOURCEis refused by name before any question, and each throwaway reads its environment first (#1136) #1169,73a3dd1fwith adopt-project.py: check asks the split's own clone of the source in the work directory, so a clone this git will not make is adopt-git-unasked before any leg, and _resolved and _answers make their repositories as check's other throwaways do (#1083) #1119's live head) were not tested. -
git 2.45 or later, and real CPython 3.14: neither is on this bench.
Outside this PR (observations, not findings)
adopt-project.py:14412:git rev-list --count HEAD, run in a leg's work clone aftergit filter-repo, has no--. A leg that tracks a root file namedHEADmakes it exit 128. P6 gaverev-list --count HEAD exit=128 ... both revision and filename, whileHEAD --exits 0 andrev-parse HEADexits 0. This is the same class of bug as r1180c's NIT-1, and it predates adopt-project.py check: a partial-clone source missing blobs that check reads is fetched into, so check's own reads run the upload-pack its config names and write a promisor pack into the source #1145. Lane 1 may want an issue for it.shape-doctor.py:5864and:6166: these pass a commit id torev-listwith no--. Not measured.
Departures from the rules
findings.md: not written, because the harness refused it (see above).- Load rule:
- Driver A's start line logged 5 pytest processes, although my check just before read 4. Driver B waited 40 s, until the count was below 5.
- My two concurrent pairs, head with the shim and
b316f415withe0a98f13, started at 2 and 4 processes, so the second call of each pair began at the limit. Each call ran under 40 s.
git config: set only in my own probe repositories underr1180d/p1(uploadpack.allowFilterandremote.origin.uploadpack), and through the PR's fixtures under my basetemps. Never at global or system scope, and never in a shared repository.gh: read-only only, as the brief allowed: oneapiGET of r1180c's comment, andpr viewof adopt-project.py:checkandexecuterun withGIT_NO_LAZY_FETCH=1and refuse a partial-clone source that lacks an object its branches, tags or HEAD reach assource-partial-clone, so neither fetches into the source any more (#1145) #1180, adopt-project.py: check asks the split's own clone of the source in the work directory, so a clone this git will not make is adopt-git-unasked before any leg, and _resolved and _answers make their repositories as check's other throwaways do (#1083) #1119, adopt-project.py:check's rehearsal and_stored_as_writtentake a parent's-csettings,attr.treetoo, and the source's objects,GIT_ATTR_SOURCEis refused by name before any question, and each throwaway reads its environment first (#1136) #1169 and adopt-project.py:planruns withGIT_NO_LAZY_FETCH=1, refuses a partial-clone--sourcethat lacks objects assource-partial-clone, and names a partial--pin-sourceit could read only by fetching and the fetch that makes it whole (#1162) #1190.- Files changed in scratch clones only:
r1180d/dbase/tests/test_adopt_partial_clone_source.py(overwritten with the head's copy);r1180d/pbase/tests/test_adopt_partial_clone_source.py(untracked copy).
Lane: openreposhape-1 (openRepoShape-1)
…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>
|
(lane openRepoShape-1, 2026-10-23T20:49:33Z) Lane 1's delta check of #1180 since r1180d's READY at
Lane: openreposhape-1 (openRepoShape-1) |
|
There was a problem hiding this comment.
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
| 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"" |
There was a problem hiding this comment.
🟡 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`.#1180's squash 4829418 has the same tree as its final head 58a2175, which the previous merge brought in, so the content it brings for #1180's own files (adopt-project.py, docs/cli.md, tests/test_adopt_partial_clone_source.py) is already on this branch. Those paths, and the paths main brought into 58a2175 (a0e8cde..58a2175), take this branch's side; the merge changes no file. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main moved past #1180's squash 4829418 by e9a1650 (#1109) and bd3e9c1 (#1235), which touch shape-doctor.py, tests/test_doctor_plan_overrides.py, tests/test_family_bump_fetch_refspecs.py and tests/test_path_guard_hygiene.py. None of them is a file #1190 changes. This is the third of the three merges that restack #1190 after #1180 landed. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two conflicts, both from #1180's partial-clone sentence beside this branch's unreadable-.git sentence in SOURCE_OVERRIDE_HELP: - adopt-project.py, SOURCE_OVERRIDE_HELP: the "one this user cannot reach, or one whose `.git` this user cannot read, a clone `plan` made included, as `source-unreadable`, never cloned again." sentence is kept, and #1180's "Either source, where it is a partial clone ..." sentence follows it. - docs/cli.md, the --source help of check and execute: regenerated with scripts/render-cli-reference.py, not edited by hand. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…artial-clone `--source` that lacks objects as `source-partial-clone`, and names a partial `--pin-source` it could read only by fetching and the fetch that makes it whole (#1162) (#1190) #1180 (for #1145) made `check` and `execute` never fetch into a partial-clone `--source`. `adopt-project.py plan` reads the same source with git, and reads each `--pin`'s digest from `--pin-source` with `git ls-tree -r`, and it was left as it was. In a partial clone (`git clone --filter`), git fetches an object the clone lacks from its promisor remote on any read that needs it. So `plan` made network calls it never announced, and wrote into a clone the person only meant to read. - `plan` now runs with `GIT_NO_LAZY_FETCH=1` (`@_lazy_fetch_off()` on `cmd_plan`; the environment is put back afterwards). Right after `Source.of(...)`, as in `check` and `execute`, `_refuse_a_partial_clone_short(source)` refuses a partial-clone `--source` that lacks an object its branches, tags or HEAD reach as `source-partial-clone`. Its detail now says "`plan`, `check` and `execute` never fetch into the source". - A partial `--pin-source` is not refused for being partial. A `--filter=blob:none` clone that holds the commit holds its trees, and gives the digest a full clone does, with no run. `_pins_read_unfetched` asks only about a read that failed. Where it failed as `pin-source-unreadable` and the pin clone is a partial clone, the refusal keeps its code and the reader's words. It adds that the clone is a partial clone, whose git fetches what it lacks, and that `plan` never fetches into a `--pin-source`. It names the cure, `git fetch --refetch --no-filter <remote>`, or a full clone, or dropping `--pin-source`. It ends with "Later fetches stay filtered while `remote.origin.partialclonefilter` is set, so the clone can lack objects again." - A partial `--source` is refused before a partial `--pin-source` is read, so `plan` names `source-partial-clone` first, as `check` and `execute` do. - `plan`'s `--source` help names `source-partial-clone` and the cure. Its `--pin-source` help says which partial clones are read and which are refused, and that on a git that honours `GIT_NO_LAZY_FETCH` (2.45 or later, or a security release from 2.39.4 on) a partial pin clone is refused with nothing fetched into it, while an older git fetches what it lacks. `docs/cli.md` is regenerated. - `tests/test_adopt_plan_partial_clone.py` holds 15 rows. - Measured with git 2.43.0 and Python 3.12.3 (the promisor's `upload-pack` replaced by a counting stub), `plan` at #1180's head `2c8082a2` against this PR's head: - A `--source` that is a `--filter=blob:none --no-checkout` clone (28 objects lacking) went from exit 0, a plan written, 26 `upload-pack` runs and `.promisor` files 1 to 27, to exit 2 `source-partial-clone`, no plan, 0 runs and `objects/` unchanged. A checked-out `--filter=blob:none` clone with 2 history blobs lacking went from exit 0 to exit 2. - A `--pin-source` that is a `--filter=tree:0 --no-checkout` clone, or a `blob:none` clone that lacks the pinned commit, went from exit 0 with 1 run and `.promisor` 1 to 2, to exit 2 `pin-source-unreadable` with the partial-clone sentence and the cure, 0 runs, and the clone unchanged. - A full `--source`, a partial clone cured by `git fetch --refetch --no-filter`, a full `--pin-source`, and a `blob:none` pin clone that holds the commit all plan as before, with 0 runs. A full pin clone that lacks the commit, and a folder inside a partial clone, keep the reader's words. - Against #1180's `a0e8cded` as base, the new file fails 7 rows and passes 8. At head all 15 pass. - Mutants of the changed lines, each run against the new file in a scratch clone and restored after: 8 of 8 killed in the first round. In the NIT round, the guard moved after the pin read (r1190's M3) and the pin cure's later-fetch sentence dropped are both killed, 2 of 2. - Known limits: - A git that ignores `GIT_NO_LAZY_FETCH` still fetches into a partial `--pin-source`, as before. Git honours the variable from 2.45.0 and in the security releases of 2024-05-14 back to 2.39.4. There is no guard for pins, by design: a clone that holds the commit and its trees must stay readable. The `--source` is kept whole on every git, by the guard. - A partial `--pin-source` that git cannot read for a reason other than a missing object keeps the reader's own refusal, with the partial-clone sentence added, because the clone is partial. The cure it names is then not what fails it. - `plan` does not walk a `--source` that is not a partial clone: the guard reads its config alone. - `scaffold-project.py --pin-source` reads its pin with the same `declared_pin_values` and has the same fetch. It is not changed here. Review: lane 1's independent reviewers reviewed each round. r1190 found `7f3e1280` READY, with no MUST, no SHOULD and 3 NITs (#1190 issuecomment-6372019066). It killed 13 of its 14 mutants. The survivor, M3, is the order the new row now pins. Lane 1's delta check of the NIT round `359a749c` found it sound: the order row kills M3, the help is qualified and `docs/cli.md` regenerated, and the pin cure ends with the filter sentence, which a mutant that drops it fails (issuecomment-6374465346). The one NIT lane 1 raised there, on the help's "2.45 or later", was withdrawn: git's own documentation for 2.45.0 documents `GIT_NO_LAZY_FETCH`. READY carries to `359a749c` (issuecomment-6374696951). After #1180 landed as `4829418f`, the branch was restacked onto main by three merges, with no rebase, and its changed lines equal the reviewed delta (sha256 `cec9aed8`). Lane 1's delta check carries READY to `e42134e1` (issuecomment-6722224429). Tests, under a stub `gh` with a bogus token: at `359a749c`, lane 1's gate gave 953 passed and 5 skipped over 7 calls. They covered the PR's test file with `tests/test_adopt_partial_clone_source.py` in one call, since the new file imports the other's fixtures, the doc trio, `tests/test_scaffold_leg_path_collisions.py` with the path-guard and no-auto-maintenance scans, and `tests/test_shape_doctor.py` in 4 shards. At the restacked head `e42134e1`, the PR's two partial-clone files, the two scans with `tests/test_scaffold_leg_path_collisions.py`, and the doc trio gave 507 passed and 2 skipped over 3 calls. It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its H0 `e42134e1` is green on Linux and SonarCloud. The branch was never rebased. After H0, the only commit is update-branch's clean merge of main, giving 45dbf40, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: 7 pytest calls over `tests/test_adopt_plan_partial_clone.py`, `tests/test_adopt_partial_clone_source.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`, `tests/test_repo_hygiene.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_path_guard_hygiene.py`, `tests/test_no_auto_maintenance.py` and `tests/test_shape_doctor.py` in 4 shards: 953 passed, 5 skipped. Closes #1162 Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…: a kept `GNUmakefile/` is a directory GNU make stops at, `python3 scripts/bootstrap.py` names `python` on Windows, and the template says what to run where `make` reads another file (#1207) **#1132, the parts that hold on main: the assembly-root template says what to run where GNU make reads a kept `GNUmakefile` or `makefile` first; a kept DIRECTORY of either name is named as one GNU make stops at, in the `CONTRACTS_DIR` line and in `execute`'s NEXT; every sentence that names `python3 scripts/bootstrap.py` for a person to run names `python` on Windows too; and two follow-up texts no longer say more than is true (RV1105b's NIT-2 and NIT-3a).** This PR was built on main `961640c6`. Lane 4's #1121 (#1047) landed as `0ed3e57f8` at 21:37Z, and this round merged it in (`6fa1edd56`): NEXT now words a kept file (#1121's) and a kept directory (this PR's) through one lookup and one clause. #1105 landed as `12122952b` on 2026-11-08, and this PR merges it in (see "Main merge after #1105"). The items that live only in #1105's code, and the parts of #1121's that this merge does not do, are in their own issue, #1240 (see "Left"). Item 5 is #1206. Lane 4's #1105 (#1048) landed as `12122952b` at 16:45Z. This round merges main into the branch with `git merge origin/main`, never a rebase, and does it twice: - main `46fe6ac19` as merge `7b0192bde`; - then NIT-A in its own commit, `e5e077940`; - then main `9729315f1` as merge `01e93b6d1`, the head. Lane 1's #1123 had landed meanwhile, and one of its rows meets this PR's directory line (see "The second merge, after #1123"). The first merge conflicted in `scripts/shape_materialize.py` only: 3 hunks, all in `collision_follow_up`, as "Landing order" said. `adopt-project.py` and `templates/assembly-root/AGENTS-shape.md` merged with no conflict. Both sides' behaviour stays: #1105's second `GNU_MAKE_READS`, its `_predict_collisions` lookup and its collision line, and this PR's `_make_reads_first`, `_make_reads_first_clause` and NEXT. None of #1240's unification is done here (one tuple, one lookup). ### The three hunks | hunk | this PR (`6fa1edd56`) | main (`46fe6ac19`, #1105) | resolution | |---|---|---|---| | 1. The docstring's NIT-4 sentence | "This said "both callers say it", and `execute` calls it nowhere" | "`Materialized.follow_ups()` would say this of them, and nothing calls it" | **Main's.** Both sides rewrote the same sentence for the same finding. Main's also names `Materialized.follow_ups()`, which #1240's item G changes. So this PR no longer touches that sentence, and the last `scripts/shape_materialize.py` bullet under "What changed" is #1105's text now. | | 2. The docstring's Makefile paragraphs | the `BOOTSTRAP_COMMAND` sentence and NIT-2's tail | the heading "WHERE `make` READS THE MERGED FILE", and the "BUT GNU MAKE READS" paragraph | **Both.** Main's heading and both paragraphs. This PR's sentences on `BOOTSTRAP_COMMAND` and on NIT-2's tail go in the first paragraph, and one new sentence goes in the second: "Both name that command as `BOOTSTRAP_COMMAND` does, `python` on Windows as well (#1132)." | | 3. The returns | two `return`s, each one sentence | `arrive`, `joint`, `meanwhile` and `until` set per branch, then one pair of `return`s | **#1105's structure, with a per-branch `then` in place of `until`.** With no `reads_first`, the line is this PR's, byte for byte. With one, it is #1105's, byte for byte, except that the command a person runs is `BOOTSTRAP_COMMAND`, both in `meanwhile` and in "and then ... runs it". | - **Hunks 1 and 2 carry no code.** In the merged file they sit at lines 1022-1023 and 1032-1070, inside `collision_follow_up`'s docstring (lines 1014-1084). - **The rendered lines.** I loaded the function from `6fa1edd56`, from main, and from the merge, and rendered each `reads_first` (none, `GNUmakefile`, `makefile`) with and without `scripts/bootstrap.py` aside. - With no `reads_first`, the merge equals `6fa1edd56` (2 of 2). - With one, it equals main with " (`python` on Windows)" after `python3 scripts/bootstrap.py` in those two places (4 of 4). For a root that keeps `Makefile` and `GNUmakefile`, the line now ends "...; until then `python3 scripts/bootstrap.py` (`python` on Windows) is what the shape's `make bootstrap` runs". - **NIT-2's tail where `make` reads another file** stays #1105's own: "neither `make bootstrap` nor `python3 scripts/bootstrap.py` runs the shape's bootstrap until then, and then `python3 scripts/bootstrap.py` (`python` on Windows) runs it". #1105's review fixed that branch for the same finding, so it needs nothing more. - **Of #1240's list,** this round does item C for #1105's collision line, as #1240 asks a resolution to say. So the "with `python3`" part of "Left"'s second bullet is done. Item B, the kept directory, stays: #1105's lookup asks `_root_keeps`, so a root that keeps `Makefile` and `GNUmakefile/` is told GNU make "reads the source's `GNUmakefile`". Mutant U3 below shows that no row pins this line for a directory. ### #1105's test pins `tests/test_adopt_collision_make_reads.py` is #1105's file, and it pins the texts this PR changes, as "Landing order" expected. - **Four pins move.** `MERGE_IS_ENOUGH` and `BOTH_MERGES` (no `reads_first`) move to this PR's words. `MEANWHILE` and `SCRIPT_MERGED` (with one) move to `python` on Windows. All four go through a new `COMMAND`. - **One comment changes.** The comment on the `Makefile+scripts` row now says what NIT-2's tail says. - **Measured:** before the move, the six adopt and pin files gave 11 failed and 242 passed, and the 11 are exactly the rows that use those four pins. After the move, 253 passed. - **On main's code:** main `46fe6ac19`'s four production files (`adopt-project.py`, `scripts/shape_materialize.py`, `templates/assembly-root/AGENTS-shape.md` and `README.md`) were swapped into the mutant worktree at `e5e077940`. 64 of the three files' 106 rows fail: - in this file, exactly those 11 rows, and the other 20 pass; - in the new file, 34 of 35, as before (NIT-4's row passes); - in `tests/test_adopt_next_make_reads_first.py`, 19 of 40, as before. The four files were then restored, each matched the head by sha256 (4 of 4), and the tree was clean. ### NIT-A `tests/test_adopt_next_make_reads_first.py:64` called #480's NOTE "unchanged", but the merge of #1121 moved that pin to `python` on Windows. The comment now says the NOTE "names `python` on Windows too since #1132". The change is a comment only, in its own commit. ### The second merge, after #1123 Lane 1's #1118 and #1123 landed while the first merge was being gated: `9ec0dc79c` at 17:30Z and `9729315f1` at 17:47Z. Both merge with `e5e077940` with no conflict in the text. The predicted merge with `9ec0dc79c` passed (gate P below). The predicted merge with `9729315f1` failed 1 row of 254 (gate P2): - **The row:** #1123 adds `test_a_name_only_a_disk_that_ignores_case_takes_for_makefile[makefile-directory]` to `tests/test_adopt_make_reads_first.py`. For a kept DIRECTORY `makefile/`, it expects the `CONTRACTS_DIR` line a kept `makefile` FILE gets ("only once `makefile` does"). #1123's own message says that line is not true for a directory, and that the row works as a crash guard for `twins` in `_makefile_follow_up`. This PR changes exactly that line: a kept directory is one GNU make stops at. - **The fix, in the merge `01e93b6d1`:** the row now expects `SHAPES + written_as("makefile")`, as #1123 wrote it, which is the part that guards `twins`. This PR's directory clause follows, through a new `stops_at` helper beside `reads`. The comment above `ANSWERED` and the row's docstring now say so. Nothing else changes in the merge. - **Mutants on the row.** Each ran alone in the mutant worktree at `01e93b6d1` against `tests/test_adopt_make_reads_first.py` (34 rows). Each was restored with `git checkout` and matched by sha256 (`adopt-project.py` `3c2cb5f5bea5`), and the tree was clean after each. | id | mutant | rows failing | result | |---|---|---|---| | D1 | `_make_reads_first` drops a directory's `/`, so the line is the one #1123's row pinned | 1 of 34, the row | killed | | D2 | #1123's R10: `twins` reads the entry with its slash | 1 of 34, the row, with `IndexError` | killed | ### Mutants on the resolution H3a-H3g and U1-U3b are the first merge's. All ran in the mutant worktree at `e5e077940`. - **Method:** each mutant was applied alone, then restored with `git checkout`. Each restore matched by sha256 (`shape_materialize.py` `b18f09d7c396`, `adopt-project.py` `2055a81acdc6`), and the tree was clean after each. - **Rows:** H3a-H3g, U3 and U3b ran against 93 rows: `tests/test_adopt_collision_make_reads.py` (31), the new file (35) and `tests/test_adopt_e2e.py` (27). U1 ran against the new file, `tests/test_adopt_make_reads_first.py` (33) and `tests/test_adopt_collision_make_reads.py`, 99 rows. U2 ran against the new file and `tests/test_adopt_next_make_reads_first.py` (40), 75 rows. - **Unmutated,** every row passes (gate A below). | id | mutant | rows failing | result | |---|---|---|---| | H3a | hunk 3, no `reads_first`: `meanwhile` back to main's plain `python3` | 4 of 93 | killed | | H3b | hunk 3, no `reads_first`: the script tail back to main's "before both merges are made" | 4 of 93 | killed | | H3c | hunk 3, with `reads_first`: `meanwhile` without `BOOTSTRAP_COMMAND` (main's) | 5 of 93 | killed | | H3d | hunk 3, with `reads_first`: "and then ... runs it" without `BOOTSTRAP_COMMAND` (main's) | 3 of 93 | killed | | H3e | hunk 3, with `reads_first`: this PR's tail in place of #1105's | 3 of 93 | killed | | H3f | hunk 3: `reads_first` ignored, so the resolution is this PR's side whole | 15 of 93 | killed | | H3g | hunk 3, with `reads_first`: #1105's `joint` ("; and") lost | 3 of 93 | killed | | U1 | `_makefile_follow_up` drops `_make_reads_first` (`first = None`) | 31 of 99 | killed | | U2 | `_next_steps` drops `_make_reads_first` (`first = None`) | 35 of 75 | killed | | U3 | `_predict_collisions` asks `_make_reads_first` in place of #1105's lookup | 0 of 93 | **survived** | | U3b | `_predict_collisions` passes no `reads_first`, so #1105's lookup is lost in the merge | 15 of 93 | killed | - **H3c and H3d** are killed only by rows that use the moved `MEANWHILE` and `SCRIPT_MERGED`. Those pins are what hold `BOOTSTRAP_COMMAND` in #1105's branch. - **U3 survives,** and that is #1240's item B. For a kept file, both lookups give the same name. They differ only for a kept directory, where `_make_reads_first` adds the `/`, and no row runs `_predict_collisions` on a root that keeps `Makefile` and `GNUmakefile/`. A row now would pin the line #1240 is to correct, so this round adds none. ### Runs Each run went through the lane's runner, with git-filter-repo 2.47.0 (CI's pin) on PATH, on a commit with a clean tree. | run | git | files | passed | failed | skipped | |---|---|---|---|---|---| | gate A2 | 2.43.0 | `01e93b6d1`, the head: the 12 files below | 1096 | 0 | 3: the PowerShell rows (`test_shape_doctor.py:10516`), which run only on a Windows host | | gate B2 | 2.47.1 | `01e93b6d1`: the same 12 files | 1096 | 0 | 3: the same | | gate A | 2.43.0 | `e5e077940`, after the first merge: the 12 files below | 1095 | 0 | 3: the same | | gate B | 2.47.1 | `e5e077940`: the same 12 files | 1095 | 0 | 3: the same | | gate P | 2.43.0 | the predicted merge of `e5e077940` with main `9ec0dc79c` (#1118; tree `ba3f3c1f3`, a local commit object, not pushed): the six adopt and pin files, plus #1118's `tests/test_adopt_root_shape_paths.py` and `tests/test_adopt_symlink_target_bytes.py` | 509 | 0 | 0 | | gate P2 | 2.43.0 | the predicted merge of `e5e077940` with main `9729315f1` (#1123; tree `0eb290ae2`, a local commit object): the six adopt and pin files | 253 | 1: #1123's `makefile-directory` row, which the second merge moves | 0 | | gate P4 | 2.43.0 | the predicted merge of `01e93b6d1` with main `801a7d6d0` (#1106; tree `25bc27d39`, a local commit object): the six adopt and pin files, plus #1106's `tests/test_adopt_one_byte_locale.py` and `tests/test_adopt_unenterable_source_and_locale.py` | 277 | 0 | 0 | The files are the six adopt and pin files (`tests/test_adopt_collision_make_reads.py`, `tests/test_pinned_copy_text.py`, the new file, `tests/test_adopt_next_make_reads_first.py`, `tests/test_adopt_e2e.py` and `tests/test_adopt_make_reads_first.py`, 253 rows), plus `tests/test_shape_doctor.py` and `tests/test_scaffold_leg_path_collisions.py`, which adopt changes need, plus the hygiene five (`test_path_guard_hygiene.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py` and `test_pinned_copy_text.py`). That is 1098 rows in one run at `e5e077940`, and 1099 at `01e93b6d1`, where #1123 adds one row. All of this was measured on main `961640c68` with GNU make 4.3, offline. | item | on main | |---|---| | 1, the template | `AGENTS-shape.md` (the "Work from here" paragraph and the five-commands table) and `README.md` (Get started, and the parking steps) give `make bootstrap` with no caveat. With the template's own `Makefile` beside a plain `GNUmakefile` or `makefile`, `make -n bootstrap` exits 2, `No rule to make target 'bootstrap'`. | | 2, a kept directory | With `GNUmakefile/` or `makefile/` beside `Makefile`, `make bootstrap` exits 2 with `make: *** GNUmakefile: Is a directory. Stop.`, or the same with `makefile`. It does this for every target, and for each directory alone too. Main's `CONTRACTS_DIR` line says "`make` there exports `CONTRACTS_DIR` only once `GNUmakefile` does". NEXT prints `make bootstrap` for a root that keeps only `GNUmakefile/`. Beside a kept `Makefile`, it prints #480's NOTE that the targets "arrive only once the follow-up merges the two". Rendered by the lane's `probe_texts.py` on main. | | 3, `python3` | The template's `scripts/bootstrap.py` says `python3 scripts/bootstrap.py (python on Windows)`. #480's NOTE and the Makefile collision follow-up's "until then" name `python3` alone. | | NIT-2 | The no-`reads_first` script-aside tail says "neither `make bootstrap` nor `python3 scripts/bootstrap.py` runs the shape's bootstrap before both merges are made". In a root that keeps its own `Makefile` and `scripts/bootstrap.py`, merging the script alone makes `python3 scripts/bootstrap.py` print the shape's `SHAPE-BOOT`, while `make bootstrap` still exits 2. Measured again by a row below. | | NIT-3a | `_predict_collisions` says the wording comes from "the same function `execute` reports its actual collisions through", and `collision_follow_up` says "Both callers say it". `execute` never calls `collision_follow_up`: it carries the plan's stored `follow_ups:` into the split message. | `adopt-project.py`: - **`_make_reads_first(surviving)`** gives the first of `GNUmakefile` and `makefile` the root keeps. It reads `GNU_MAKE_READS`' order, asks the way `_root_keeps` asks, and adds a `/` for a DIRECTORY. **`_make_reads_first_clause`** words the clause. For a file, the words are main's, byte for byte. For a directory, it says: "GNU make at the root stops at the source's directory `GNUmakefile/`, the first of `GNUmakefile`, `makefile` and `Makefile` it finds (`GNUmakefile: Is a directory.`), so ... only once the root keeps no directory of that name". - **`_makefile_follow_up`** (the `CONTRACTS_DIR` line) asks both. A kept file's line is unchanged: `tests/test_adopt_make_reads_first.py` passes as it is. - **`_next_steps`** takes `surviving=`, the plan's root paths, which `cmd_execute` passes as `paths_for["root"]`, as #1121 does. - Where a kept DIRECTORY stops make, the command is `python3 scripts/bootstrap.py`. The NOTE says GNU make stops there. Beside a kept `Makefile`, it also says the targets run there once the follow-up merges the two, and only once the directory is gone. Where the script went aside too, alone or with the `Makefile`, the NOTE names that command after the merges. - Since the merge of #1121, a kept FILE goes through the same lookup and the same clause. For a file the clause gives #1121's words byte for byte, and its NOTEs end in `BOOTSTRAP_COMMAND` too. - **`BOOTSTRAP_COMMAND`** is in every NOTE: #480's, #1121's and the directory's. Each command line a person pastes stays `python3 scripts/bootstrap.py`. - **`_predict_collisions`' docstring** says what the plan stores and what `execute` carries (NIT-3a). `scripts/shape_materialize.py`: - **`BOOTSTRAP_COMMAND`** = "`python3 scripts/bootstrap.py` (`python` on Windows)", as the template's script names it. - **The Makefile collision follow-up** says "until then `python3 scripts/bootstrap.py` (`python` on Windows) is what `make bootstrap` runs". - **Where the script went aside too,** the follow-up says "so `python3 scripts/bootstrap.py` (`python` on Windows) runs the shape's bootstrap once the script is merged, and `make bootstrap` once both merges are made" (NIT-2). - **The docstring's "Both callers say it"** paragraph says what is true (NIT-3a's twin, #1105's review NIT-4). #1105 made the same fix, and the merge keeps main's text (see "Main merge after #1105"). `templates/assembly-root/`: - **`AGENTS-shape.md`:** the "Work from here" paragraph adds "; where the root keeps a `GNUmakefile` or a `makefile`, which GNU make reads before `Makefile`, run `python3 scripts/bootstrap.py` (`python` on Windows), which is what the shape's `make bootstrap` runs." A sentence after the five-commands table says they are the shape's Makefile's targets, which `make` at the root reaches only where the file it reads first holds them, or includes a Makefile that does. - **`README.md`:** one sentence after Get started covers `make bootstrap` "here and below". A second says `make park` and `make resume` below work there only where that file holds them, or includes a Makefile that does. - **This is a template byte change, so every project re-pins.** No digest of either file is stored in this repository. `tests/` was grepped for every changed line, and only this PR's own rows pin them (see "Template pins"). Tests: - **`tests/test_adopt_e2e.py`:** two pins move to the new words, at :312 (the "until then" sentence) and at :447-450 (NIT-2's tail). - **`tests/test_adopt_next_make_reads_first.py`** (#1121's): the merge moves three pins, `OWN_MAKEFILE_NOTE`, `RUNS` and `script_aside`, to `python` on Windows. #480's NOTE merged with no textual conflict, so without them its rows fail. - **The new file `tests/test_adopt_make_bootstrap_followups.py`** has 35 rows: - the template's sentences, held to its `Makefile` (`PYTHON ?= python3`, `bootstrap:` runs `$(PYTHON) scripts/bootstrap.py`) and to `scripts/bootstrap.py`'s own words; - `make -n bootstrap` against the template's `Makefile` beside each kept name, each row asserting the `AGENTS-shape.md` caveat that measurement backs; - `_make_reads_first` (10 roots); - the `CONTRACTS_DIR` line for 4 directory roots; - NEXT for a directory, beside a kept `Makefile` (both names), after both merges, and after the script's merge alone; - #480's NOTE, called both as main's signature allows and with `surviving=`; - the collision follow-up's two sentences; - NIT-2 measured with the template's `Makefile` and stand-in scripts, and tied to the follow-up's new tail; - three real adoptions of a source holding `GNUmakefile/rules.mk`. One is kept by a `--path-policy` rule, so `plan` writes the line, `check` prints it and the split message carries it. One is answered `root`. In both, NEXT is asserted, `make -n bootstrap` in the pasted clone exits 2 with `GNUmakefile: Is a directory.`, and the printed command exits 0. In the third, the question is answered `code`: NEXT names `make bootstrap`, and that command runs in the pasted clone. ### This round (#1207's review, issuecomment-6374516382) | finding | what changed | |---|---| | SHOULD-1 | A row pins NEXT where only `scripts/bootstrap.py` went aside beside a kept `GNUmakefile/` (`test_next_after_the_script_merge_names_python3_where_make_stops`). It fails under the review's mutant X1. | | SHOULD-2 | The "Left" list is one issue, #1240. | | SHOULD-3 | "Landing order", below. #1121 landed while this round ran, and is merged and resolved here. | | NIT-1 | `AGENTS-shape.md`'s sentence after the table loses "(above)": nothing above names a Makefile that includes another. | | NIT-2 | `README.md` names `make park` and `make resume`. | | NIT-3 | Beside a kept `Makefile`, the directory NOTE names the merge as well as the directory. Its row is parametrized over `GNUmakefile/` and `makefile/`. | | NIT-4 | An adoption sends `GNUmakefile/` to the code leg, and NEXT names `make bootstrap`. It fails under the review's mutant X6. | | NIT-5 | The notes for the squash message at landing, below. | Measured at the merge head `6fa1edd56`, in a scratch worktree: - main `0ed3e57f8`'s bytes of the four production files (`adopt-project.py`, `scripts/shape_materialize.py`, `templates/assembly-root/AGENTS-shape.md`, `templates/assembly-root/README.md`) were checked out over the head; - the new file (35 rows) and `tests/test_adopt_next_make_reads_first.py` (40 rows) ran through the lane's runner; - the four files were restored with `git checkout`, and each matched the head by sha256 (`c94bd19b`, `12555f39`, `8660bafb`, `b3f9409b`). | file | fail on main | pass on main | |---|---|---| | the new file | 34 | 1: `test_an_adoption_sending_a_gnumakefile_directory_to_a_leg` | | `tests/test_adopt_next_make_reads_first.py` | 19 | 21 | Main now has #1121, so its `_next_steps` takes `surviving=`. The NEXT rows for a kept directory therefore fail on their words, not on a `TypeError`. Main reads `GNUmakefile/` as a file: "then run `python3 scripts/bootstrap.py`; GNU make at the root reads the source's `GNUmakefile`, ...". This is the case for the SHOULD-1 row, the after-the-merges row, both directory rows, and NIT-3's `makefile/` row. The new file's other rows fail on main for these reasons: - the template rows: main's `AGENTS-shape.md` and `README.md` lack the sentences; - `_make_reads_first`: main has no such helper; - the `CONTRACTS_DIR` line: "only once `GNUmakefile` does"; - the NOTE and collision rows: plain `python3`, and NIT-2's old tail; - the two adoptions that keep the directory. The one new row that passes on main is NIT-4's. It sends `GNUmakefile/` to the code leg, and there main's NEXT is already `make bootstrap`. It guards what main has: under the review's mutant X6, which hands NEXT every leg's paths, it is the row that fails. The 19 rows of #1121's file that fail on main are exactly the rows that use the three pins this merge moved (`OWN_MAKEFILE_NOTE`, `RUNS`, `script_aside`). The 21 that pass use none of them. Earlier heads, on main `961640c68` and with the same method: `1c7ca5c6d` failed 29 of 35 rows, and `8b499842f` failed 34 of 35 (the new file plus the three `tests/test_adopt_e2e.py` rows the mutants use). **This round**, at `75c90b6db`, in a scratch worktree. Each mutant was applied alone and restored, and each file's sha256 was checked against the commit (4/4 every time). Each ran against 95 rows: the new file (35), `tests/test_adopt_e2e.py` (27) and `tests/test_adopt_make_reads_first.py` (33). Unmutated, all 95 pass. | id | mutant | rows failing (of 95) | result | |---|---|---|---| | X1 | the review's: the script-only NOTE names `make bootstrap` after the merge (`if stops and makefile`) | 1 | killed | | X6 | the review's: `cmd_execute` passes every leg's paths | 1 | killed | | N1 | NIT-3 undone: beside a kept `Makefile`, no merge named | 2 | killed | | N2 | the merge named where the script went aside too | 1 | killed | | N3 | the merge named where no `Makefile` was kept | 4 | killed | | T1 | NIT-1 undone: "(above)" back | 1 | killed | | T2 | NIT-2 undone: README's `make park` / `make resume` sentence removed | 1 | killed | The merge with #1121 rewrote the clause code that N1-N3 mutate (`stopped` became `reads`, and the merge condition gained `stops`). The mutants were not run again at `6fa1edd56`. **Earlier,** 18 mutants at `1c7ca5c6d` against 43 rows, each applied alone in a scratch worktree and restored with its sha256 checked: | id | mutant | rows failing (of 43) | result | |---|---|---|---| | M1 | `_make_reads_first` drops the `/` of a directory | 14 | killed | | M2 | the clause's directory branch never taken | 10 | killed | | M3 | NEXT ignores a kept directory (`stops = None`) | 6 | killed | | M4 | NEXT's command stays `make bootstrap` for a directory | 4 | killed | | M5 | `cmd_execute` passes `surviving=()` | 2 (the two adoptions) | killed | | M6 | `BOOTSTRAP_COMMAND` without `(python on Windows)` | 12 | killed | | M7 | NIT-2's tail back to "neither ... before both merges are made" | 2 | killed | | M8 | `AGENTS-shape.md`'s "Work from here" caveat removed | 1 | killed | | M9 | the sentence after the five-commands table removed | 1 | killed | | M10 | `README.md`'s sentence removed | 1 | killed | | M11 | #480's NOTE back to plain `python3` | 1 | killed | | M12 | the collision follow-up's "until then" back to plain `python3` | 2 | killed | | M13 | after the merges, NEXT says `make bootstrap` where a directory stops make | 1 | killed | | M14 | the lookup takes the LAST kept name (`reversed`) | 4 | killed | | M15 | the directory NOTE's lead ignores a kept `Makefile` | 1 | killed | | M16 | the lookup folds case (`fold=True`) | 4 | killed | | M17 | the clause drops GNU make's own words (`GNUmakefile: Is a directory.`) | 10 | killed | | M18 | the file clause says `does` whatever `has` is | 0 | **survived** | M18 was equivalent before the merge: the file clause's only caller was the `CONTRACTS_DIR` line. Since the merge, #1121's NOTE calls it with `has them`, and `tests/test_adopt_next_make_reads_first.py` pins that wording. M18 was not run again. Each run went through the lane's runner: `gh` stubbed first on PATH with a bogus token, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`, `EMAIL` set, `-p no:cacheprovider`, and `tmp_path_retention_policy=failed`. Each run is on a commit, with a clean tree. | run | commit | git | files | passed | failed | |---|---|---|---|---|---| | gate M | `6fa1edd56`, the merge | 2.43.0 | the new file, `test_adopt_next_make_reads_first.py`, `test_adopt_e2e.py`, `test_adopt_make_reads_first.py`, `test_path_guard_hygiene.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_pinned_copy_text.py`, `test_scaffold_leg_path_collisions.py` | 618 | 0 | | gate N | `6fa1edd56` | 2.47.1 | the new file and `test_adopt_next_make_reads_first.py` (75), then `test_adopt_e2e.py` (27), run one after the other | 102 | 0 | | gate H | `6fa1edd56` | 2.47.1 | the hygiene five | 379 | 0 | | gate A2 | `75c90b6db` | 2.43.0 | the new file, `test_adopt_e2e.py`, the hygiene five (`test_path_guard_hygiene.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_pinned_copy_text.py`), `test_adopt_make_reads_first.py`, `test_adopt_contracts_dir.py`, `test_adopt_printed_sentences.py`, and the template's readers: `test_scaffold_e2e.py`, `test_scaffold_free_text_values.py`, `test_free_plan_hint.py`, `test_template_quoted_values.py`, `test_template_values_single_pass.py`, `test_template_text_placeholders.py`, `test_bootstrap_placement_texts.py`, `test_park_resume_targets.py`, `test_update_shape_hardlink.py` | 1128 | 0 | | gate A | `1c7ca5c6d` | 2.43.0 | 17 files (the new file, `test_adopt_e2e.py`, the hygiene five, `test_scaffold_leg_path_collisions.py`, `test_adopt_make_reads_first.py`, `test_adopt_contracts_dir.py`, `test_adopt_printed_sentences.py`, `test_adopt_attributes_eol_block.py`, `test_adopt_leg_path_collisions.py`, `test_scaffold_e2e.py`, `test_bootstrap_placement_texts.py`, `test_park_resume_targets.py`, `test_template_text_placeholders.py`) | 966 | 0 | | gate B | `1c7ca5c6d` | 2.47.1 | the new file, `test_adopt_e2e.py`, `test_path_guard_hygiene.py`, `test_repo_hygiene.py` | 209 | 0 | | doctor | `1c7ca5c6d` | 2.43.0 | `test_shape_doctor.py`, `test_scaffold_leg_path_collisions.py` | 549 (3 skipped: the PowerShell rows, which run only on a Windows host) | 0 | `tests/` was grepped for every line this PR changes in `templates/assembly-root/` ("They are the shape", "reads first holds", "includes a Makefile that", "(above)", "here and below", "Where this root keeps", "it is what the shape", "before `Makefile`", "`python` on Windows"). Only `tests/test_adopt_make_bootstrap_followups.py` pins them. Four other files read the README as a whole, and each passes in gate A2: - `test_scaffold_free_text_values.py` counts its lines against the template; - `test_free_plan_hint.py` looks for "Free plan" and `--repo`; - `test_template_quoted_values.py` and `test_template_values_single_pass.py` render it. `git merge-tree --write-tree` of the head `01e93b6d1`, on git 2.43.0, at 18:11Z. - **Main** `9729315f1`, fetched again at 18:11Z, is the main this round merged last. The merge-tree is clean, and its tree is the head's own, `f6488542f`. - **Main moved to `801a7d6d0`** at 18:14Z, when lane 3's #1106 landed (`adopt-project.py` and two locale test files). The merge with it is clean, tree `25bc27d39`, and gate P4 passed on it. It was not merged in: it touches none of the code this round resolves, and landing refreshes the branch anyway. - **The open PRs:** one `gh pr list --state open --limit 100 --json number,headRefName,headRefOid,files` read at 18:11Z returned 51 open PRs. 26 of them touch one of this PR's paths. That includes `tests/test_adopt_collision_make_reads.py` and `tests/test_adopt_make_reads_first.py`, which this PR now changes. 25 merge cleanly: #1106, #1120, #1122, #1125, #1141, #1143, #1153, #1154, #1170, #1171, #1177, #1178, #1186, #1189, #1194, #1198, #1210, #1225, #1244, #1249, #1251, #1252, #1253, #1265 and #1268. #1106 has landed since (above). - **Lane 1's #1189** (`472944b5d`, read fresh at 18:11Z) edits three test files that this round changes too: `tests/test_adopt_next_make_reads_first.py`, `tests/test_adopt_collision_make_reads.py` and `tests/test_adopt_make_reads_first.py`. The merge is clean, tree `7fd030b29`. #1189's hunks are in the imports, in the clone rows' parameter lists and docstrings, and in a new refusal row, away from the moved pins, the `makefile-directory` row and NIT-A's comment. On that predicted merge, a local commit object, the three shared files pass on git 2.43.0: `tests/test_adopt_make_reads_first.py` and `tests/test_adopt_collision_make_reads.py` give 62 passed, and `tests/test_adopt_next_make_reads_first.py` gives 38 passed. There are fewer rows than at this head because #1189 leaves out the clone rows that keep case twins. - **#1237** (`b557859fd`) conflicts in `tests/test_adopt_check_throwaway_repos.py`, a file this PR does not change. Main conflicts with #1237 in that file too, while `6fa1edd56` did not, so the conflict is main's, carried in by the merge. - **Main moved again after the round** (rv1207e, at 19:25Z): lane 1's #1143 landed as `81eaf757c` (`adopt-project.py`) and lane 4's #1117 as `9252ef208` (README, the handbook and `scripts/family.py`). The head merges clean with both (trees `e40373967` and `279f8c6ce`), and rv1207e's gate P passed on the predicted merge with `9252ef208` (359 passed). At 19:22Z, 48 PRs were open, 24 touch this PR's files, and 23 merge clean; #1237's conflict is main's, as above. - **No longer on the list:** #1105, #1121, #1118 and #1123 are on main. Since `6fa1edd56`, #1104, #1160, #1169, #1180, #1190 and #1223 landed too. The conflicts are the same in every order, measured offline both ways round and with predicted squashes: - **#1121:** 4 hunks, 84 lines, in `adopt-project.py`'s `_next_steps` and `cmd_execute`'s comment. - **#1105:** 3 hunks, 65 lines, in `scripts/shape_materialize.py`'s `collision_follow_up`: its docstring's NIT-4 sentence, its paragraph on the Makefile's merge, and its two `return`s. - #1121 and #1105 merge with each other cleanly, and so do #1104 and this PR. So the order decides only who resolves them. **Both have landed**, and lane 5 resolved both on this branch: - **#1121** (merge `6fa1edd56`). The resolution is one lookup and one clause, `BOOTSTRAP_COMMAND` in #1121's NOTEs, and #1121's three test pins moved. - **#1105** (merge `7b0192bde`, see "Main merge after #1105"). The resolution keeps #1105's `reads_first` branch and runs `BOOTSTRAP_COMMAND` through it. NIT-2's tail is this PR's where `make` reads the merged `Makefile`, and #1105's where `make` reads another file. Four of #1105's test pins moved: `MERGE_IS_ENOUGH`, `BOTH_MERGES`, `MEANWHILE` and `SCRIPT_MERGED`. Main was merged in once more after lane 1's #1123 (merge `01e93b6d1`): one of #1123's rows pinned the line this PR changes for a kept `makefile/` directory, and it moved (see "The second merge, after #1123"). No other PR has to land first now. `6fa1edd56` was re-checked READY (rv1207b, rv1207c and rv1207d, issuecomment-6722602375). The head, `01e93b6d1`, needs a check of its delta since then: the two merges' resolutions, the four moved pins in #1105's file, #1123's moved row, and NIT-A. #1240 tracks what this PR leaves: - #1132's item 4 (one `GNU_MAKE_READS`, one lookup); - #1105's collision line, for a kept directory (its `python3` part is done here: see "Main merge after #1105"); - #1121's file NOTE beside a kept `Makefile`, which does not name the merge (as this PR's directory NOTE now does); - #1105's second review's NIT-1, NIT-3b, NIT-3c, NIT-4 and NIT-5; - `_make_reads_first_clause`'s `has` argument, now live; - rv1207e's NIT-1: the docstring sentence "With none, the Makefile's follow-up is what it was, byte for byte" (`scripts/shape_materialize.py` about :1067), kept from #1105, is no longer true, since this PR changes the line with no `reads_first`. It sits beside #1240's items F and G, which reword the same docstring. The local branch `fix/1132-template-bootstrap-stacked` (`9eb5cce0b`, never pushed and never tested) is a model of the end state on #1105 + #1121 + main. - **Item 5,** follow-ups that go stale after a hand edit of the plan, is its own issue, filed as #1206. It also holds the three related cases this work found: - a kept `makefile/` directory gets "until the two are merged"; - a directory kept only through deeper entries is not seen by `_root_keeps`; - a kept `Makefile/` directory is treated as a Makefile. - **This PR's `answered-root` row meets item 5 too.** `plan` asks about `GNUmakefile/`, and its stored `CONTRACTS_DIR` line, written before the answer, names no directory. The row asserts what NEXT and `make` do there, and not that line. - **Not measured:** a disk that ignores case, and GNU make other than 4.3. Review: rv1207b and rv1207c, lane 5's independent opus reviewers, and rv1207d, lane 5's independent sonnet reviewer, none of which wrote #1207: READY at `6fa1edd5` (issuecomment-6722602375). Then rv1207e, lane 5's independent opus reviewer, which wrote none of it: READY at `01e93b6d`, H0 of this landing, after the main merges past #1105 and #1123 (issuecomment-6726033282), with no MUST or SHOULD; its three NITs are answered in the body. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 01e93b6 passed tests, tests-git-bash and SonarCloud; the head c9be952 is a clean update-branch merge of main 9cb156a, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 0a01d35 over main 9cb156a: the adopt_collision_make_reads, adopt_e2e, adopt_make_bootstrap_followups, adopt_make_reads_first, adopt_next_make_reads_first, repo_hygiene, agents_md_codes, cli_reference sets, 333 passed. Landed on Brett Heap's word of 2026-10-23T13:10:26Z, relayed to every lane by lane 3 (#473 issuecomment-6365697123): "merge v1.2.1 PRs once reviewed and green". Closes #1132 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…not text as `_read_as` says what the plan's reader read, "records `commit:` as the number 7", where it spelled it as Python does, `commit: 7` and `True`, so `check` and `execute` name it in the words the plan's other refusals use under the same `plan-pin-malformed` (#1114) (#1198) This PR lands only after the v1.2.0 tag, on Brett's word. ## What `_pin_row_problem` holds each of a pin's five fields (`product`, `repository`, `commit`, `tree_sha256`, `digest_source`) to text. A value that is not text it spelled with Python's `repr`: a number as `commit: 7`, a boolean as `True`, a list as `['a', 'b']` and a mapping as `{'k': 'v'}`. Every other refusal of a plan's value says what the reader read, with `_read_as`: "the number 7", "the boolean true", "a list of 2 item(s)", "a mapping of 1 key(s)". W1020 found the gap while working on #1020. It says it that way now, field by field, in `adopt-project.py`'s `_pin_row_problem`, in the `not_text` branch only: - one field: ``records `commit:` as the number 7, which is not text`` - two or more: ``records `commit:` as the number 7, `tree_sha256:` as the boolean true, which are not text`` What stays as it was: - the code, `plan-pin-malformed`, the remedy, and the sentence's ending, `which is not text`; - every text value's spelling, byte for byte (`commit: '7'`, which is not 40 hex); - a null or a blank: it is "records no `commit:`" under the code it has always had, and no value is spelled there. Every field takes the one site (`PIN_FIELDS`). The docstring and a comment say so. The change is 13 lines added and 4 removed in `adopt-project.py`. ## Behaviour change A pin field that is a number, a boolean, a list or a mapping is named by what it was read as, where it was named as Python spells it. A text value, a null and a blank print as they did. ## Rows `tests/test_adopt_pin_row_values.py` is new, ASCII only, with 45 rows. Each runs `check` and then `execute --yes`, with a stub `gh` first on PATH and `--local-remote-dir`, and asserts one finding, `execute` refusing with that same finding, an empty `gh` log, no remotes directory made and no Traceback. | Group | Rows | On main's code | On this head | | --- | --- | --- | --- | | The matrix: 5 fields by number, boolean, list, mapping | 20 | 20 failed | 20 passed | | The whole sentence: `commit: 7`, and a boolean `digest_source` | 2 | 2 failed | 2 passed | | Edge forms: 0, false, 7.5, -3, 007, `[]`, `{}`, `[a]` | 8 | 8 failed | 8 passed | | Two fields at once, a non-text commit beside bad text, entry 2 | 3 | 3 failed | 3 passed | | Null and blank controls | 6 | 6 passed | 6 passed | | Text controls (`commit: "7"` and `"true"`, another product, no owner, short digest, source by hand) | 6 | 6 passed | 6 passed | | **All** | **45** | **33 failed, 12 passed** | **45 passed** | Every one of the 33 failures is the finding text holding the repr spelling (``records `commit: 7` ``, `True`, `['a', 'b']`, `{'k': 'v'}`, `{}`, `[]`). There is no crash or other cause. The 12 that pass on main are the controls, which pin what must not change. Two existing rows pinned the old spelling, and no others did. Each now expects the new one, and both pass on this head (2 passed): - `tests/test_adopt_pins.py`, the `a-number-commit` row: `` `commit:` as the number 1234567 ``; - `tests/test_adopt_plan_value_order.py`, the `not-text` row: `` `digest_source:` as the number 7 ``. ## Mutants 19 mutants of this head's `adopt-project.py`, all killed. Each was run against the new file (first failure with `-x`, or a whole run where the table says so), and each run was restored with its sha256 checked. | Mutant | Result | | --- | --- | | M1: main's repr form put back | killed, 33 failed and 12 passed | | M2: `!r` inside the new "as" form | killed, 33 failed and 12 passed | | M3: `str()` in place of `_read_as` | killed | | F-repr-product, F-repr-repository, F-repr-commit, F-repr-tree_sha256, F-repr-digest_source: repr put back for one field only | 5 of 5 killed, each by that field's own rows | | M4: "is" in place of "as" | killed | | M5: singular and plural swapped | killed | | M6: only the first field named | killed | | M7: `missing` takes any falsy value (an existing guard; the zero, false and empty rows pin it) | killed | | M8: only a list and a mapping counted as not text | killed | | M9: the ending without "how it was read" | killed | | M10: the code `plan-pin-unrecorded` | killed | | T1 to T4: the text branches unquoted (commit, product and repository, digest, source) | 4 of 4 killed | ## Gate A scratch merge of this head `d38e4e36d` with main `961640c68`: commit `1ef3f0783`, tree `b12197610`, which is this head's own tree. Targeted files only, in the landing gate's environment (a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, an empty global git config). The set is 47 test files: every one that names `plan-pin-malformed`, `plan-pin-unrecorded`, `_pin_row_problem`, `_read_pins` or `pins:`, the new file, and `test_path_guard_hygiene.py`, `test_repo_hygiene.py` and `test_scaffold_leg_path_collisions.py`; and `test_shape_doctor.py` (448 ids, each in exactly one shard, run from a script that reads each list into an array because 25 ids hold spaces). Every chunk exited 0, with 0 failed. | Chunk | Files | Result | | --- | --- | --- | | f01 | `test_adopt_pins.py` | 150 passed | | f02 | `test_adopt_plan_yaml_quoting.py`, `test_adopt_plan.py` | 606 passed, 1 skipped | | f03 | `test_adopt_execute_refusals.py`, `test_adopt_pin_row_values.py`, `test_adopt_plan_escapes.py`, `test_adopt_plan_value_order.py`, `test_adopt_reencoded_files.py`, `test_adopt_root_symlinks.py`, `test_adopt_shape_pin_and_work_dir.py`, `test_adopt_template_values.py` | 661 passed, 3 skipped | | f04 | `test_bump_remedies_family_root.py`, `test_chain_link_fold.py`, `test_chain_pin_sources.py`, `test_chain_remedy_callers.py`, `test_doctor_gitlink_repairs.py`, `test_doctor_recorded_answers.py`, `test_family_add_member_id.py`, `test_family_makefile_siblings.py` | 255 passed | | f05 | `test_family_tracking_branch.py`, `test_legs_not_a_list.py`, `test_link_source_chain_defaults.py`, `test_manifest_validator.py`, `test_naming_policy.py`, `test_neutral_pin_entry_one_spelling.py`, `test_neutral_pin_validator.py`, `test_non_list_project_fields.py` | 757 passed | | f06 | `test_path_guard_hygiene.py`, `test_pin_annotated_tag.py`, `test_pin_source_env_collisions.py`, `test_pinned_copy_text.py`, `test_reader_refusals_named.py`, `test_repo_hygiene.py`, `test_scaffold_e2e.py`, `test_scaffold_leg_path_collisions.py` | 599 passed | | f07 | `test_scaffold_pin_and_reuse.py`, `test_scaffold_pin_collisions.py`, `test_scaffold_referent_chain_links.py`, `test_setup_project_py.py`, `test_template_text_placeholders.py`, `test_update_shape_add_ignored.py`, `test_update_shape_landing.py`, `test_validate_pins_named_and_spelled.py` | 1078 passed | | f08 | `test_yaml_flow_brackets.py`, `test_yaml_flow_quotes.py`, `test_yaml_indicator_openers.py`, `test_yaml_reader_spaces.py` | 813 passed | | d01 | `test_shape_doctor.py`, 56 ids | 56 passed | | d02 | the same, 56 ids | 56 passed | | d03 | the same, 56 ids | 56 passed | | d04 | the same, 56 ids | 56 passed | | d05 | the same, 56 ids | 56 passed | | d06 | the same, 56 ids | 56 passed | | d07 | the same, 56 ids | 56 passed | | d08 | the same, 56 ids | 53 passed, 3 skipped | The file chunks hold 4919 passed and 4 skipped (f02: `SHAPE_MEDXEHR_CLONE` is unset; f03: the reftable backend needs git 2.45). The doctor shards hold 445 passed and 3 skipped (d08: the PowerShell line is printed only on a Windows host). In all, **5364 passed, 7 skipped, 0 failed.** **Run on the merge with #1125's head.** A scratch merge of this head with #1125's head `9549006fe`: commit `8a291d8b2`, tree `88530bb7`. The new file (45), `test_adopt_pins.py` (150), `test_adopt_plan_value_order.py` (40), `test_adopt_execute_refusals.py` (181) and `test_adopt_check_plan_name.py` (45): **461 passed.** ## Merge-trees `git merge-tree --write-tree`, this head against each, all clean (exit 0), at the heads of 2026-10-23T08:20Z: - `origin/main` `961640c68`; - the 23 open PRs that touch `adopt-project.py`: #1194 `900f77af9`, #1190 `00f959c61`, #1189 `4d40ce9fd`, #1186 `07486a498`, #1180 `b316f4157`, #1178 `c4beba193`, #1177 `5208e874e`, #1171 `8162b874e`, #1170 `24ee133aa`, #1169 `258051022`, #1154 `8a7828332`, #1153 `ff33ba12a`, #1143 `7af5fabc3`, #1141 `7a2711d03`, #1125 `9549006fe`, #1123 `5c3908f8d`, #1122 `c01c4d73d`, #1121 `1c5173758`, #1120 `c55e55e33`, #1119 `bba168bdd`, #1118 `2307036ab`, #1106 `504a1dd65`, #1105 `b28f40de0`; - #1126 `4c1f80cf4`, which touches `tests/test_adopt_pins.py`. ## This PR needs no #1125 #1125 (for #1020) makes `_read_pins` say an entry that is not a pin as `_read_as` says it. Its `adopt-project.py` hunks are at the `_read_pins` docstring and at the non-dict entry's `{row!r}` (about line 625 on main), and they do not touch `_pin_row_problem`. Its test hunks do not overlap the lines this change edits. The two are textually independent: this PR is based on main, not stacked, the merge-tree with #1125's head is clean, and the run above on the merge with its head passed. An entry that is neither a name nor a mapping stays #1020's, in #1125. ## Not in this PR The other `repr` in the pin rows are on a value that is text by then: the later branches of `_pin_row_problem` (spelled as before), a text product in an entry's name, and an entry that is a name. They are not gaps. `docs/release-notes/v1.2.0.md` is left alone. Its lines 297 to 299 mention #901's pins wording, and the release owner may want a line there. ## Out of scope Four other places in `adopt-project.py` put a plan value of any type into a refusal with `!r`, as the pin rows did: `plan-wrong-kind`, `plan-unsupported-mode`, `plan-bad-leg` and `plan-bad-id`. They are not pin rows, so this change leaves them. They were found by reading the code, not by running it. They are filed as #1199, with each site's line and what it would print. Closes #1114 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…blems against its source, in the order a person mends them (another repository, a refused name, a moved source, an unanswered entry), so `check`'s first finding is `execute`'s refusal for each pair (#1113) (#1210) **Stacked on #1125.** This PR's base is `fix/1020-bad-id-first-problem` (#1125's branch, head `9549006f`). It lands after #1125 and stays a draft until then. #1125 makes `check`'s first finding `execute`'s first refusal for a bad `id:` or `org:`. This PR extends that rule to the four pairs #1113 measured, which have neither. **Fix round.** The first gate failed six rows of `tests/test_adopt_relative_submodule_url.py` that pinned `check`'s old order on a plan hand-edited into another organisation, the relative url before `plan-not-the-source`, a plan `execute` already refused `plan-not-the-source` first on #1125's head; they now expect `plan-not-the-source` first, and a new row asserts that both commands name it first. ## The rule and the order One generator, `_against_its_source`, asks a plan's four problems against its source in one order, and both commands read it. `execute` refuses its first problem. `check` lists each one, in that order and in its own words, before the split branch and the entries. The order is the one a person mends them in: 1. a plan for another repository (`plan-not-the-source`); 2. a name the naming policy refuses (`naming-unclassified`, or `naming-role-mismatch`); 3. a source that has moved on since `plan` (`plan-stale`); 4. an unanswered entry (`plan-unresolved`). Why this order: - **An unanswered entry last.** Each of the first three is mended by running `plan` again, as its remediation says, and a fresh plan takes back every answer given before. An answer given first would be lost. - **Another repository first.** Nothing in such a plan is this source's own: it is mended by `plan` under the source's own name, or from the source it is about. And `execute` must ask the naming policy nothing about the plan's names, and no remote anything, before it refuses it (#499). `_against_its_source` is a generator, so `execute` asks nothing after the first problem, and that still holds. - **A refused name before a moved source.** The name is mended by `plan` given another `--project`, or after the source is renamed on GitHub. That run reads the source as it is now, so it mends the moved source too. `plan` run again as it was, for the moved source, would leave the name to mend. - **The least change to each command.** The order keeps `execute`'s order for the plan's names (#499, #716) and `check`'s order of a moved source before its entries. Only the pairs that disagreed change. ## Before and after Measured on #1125's head `9549006f` and on this head, with a stub `gh` (exit 97), `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`, a proxy that goes nowhere, an empty global git config and `--local-remote-dir`. No run made a leg. `check` exits 1 and `execute` exits 2 in every row. | The plan holds | `check` before | `execute` before | both, here | |---|---|---|---| | a moved source and an unanswered entry (#1113) | `plan-stale` | `plan-unresolved` | `plan-stale` | | another repository and an unanswered entry (#1113) | `plan-unresolved` | `plan-not-the-source` | `plan-not-the-source` | | another repository and a moved source (#1113) | `plan-stale` | `plan-not-the-source` | `plan-not-the-source` | | a refused name and an unanswered entry (#1113) | `plan-unresolved` | `naming-unclassified` | `naming-unclassified` | | a refused name and a moved source (not in #1113's table) | `plan-stale` | `naming-unclassified` | `naming-unclassified` | | all four | `plan-stale` | `plan-not-the-source` | `plan-not-the-source` | | another repository and a refused name | `plan-not-the-source` | `plan-not-the-source` | `plan-not-the-source` (unchanged) | The fifth row is not in #1113's table, but the two commands disagreed there too, and the same rule decides it. The same probe, comparing each command's output before and after, with paths and hashes masked: - A plan with one of the four problems: both commands print the same stderr and stdout before and after. - Both commands' stdout is the same before and after in every row. - `check`'s stderr in each pair row holds the same lines as before, reordered: the same findings and the same remediation lines. - `execute`'s stderr changes in one row only, a moved source beside an unanswered entry: `REFUSED plan-stale`, in its own words, where it said `REFUSED plan-unresolved`. No refusal's code or wording changes, and no code is added. Re-run on this head after the fix round, the probe gives the same: the 24 plans (these 11, and the 13 of the next section) each get one first problem from both commands, no run makes a leg, and the comparison with #1125's head is the one above, line for line. ## #1113's suspected case: a `--work-dir` that cannot be written Measured on #1125's head and on this head, the two commands agree in every row, and nothing here changes it: - **A bad `id:` or `org:` beside a `--work-dir` this user cannot write in** (`chmod 500`): `plan-bad-id` or `unsafe-value` to both. #1125's review round 1 (RV1125's SHOULD-1) made both commands ask the two before `_work_root`, which settled this case. - **Each of the 11 plans above beside the same `--work-dir`:** `adopt-work-dir-unwritable` first to both. ## What changed, in `adopt-project.py` - **`_against_its_source` and `_Unmended` (new).** `_against_its_source` is the rule. It asks: - `_refuse_a_plan_for_another_repository`; - `_check_names`, given the source only where the plan names no other repository, as `check` gave it (#716); - `_refuse_an_unrunnable_plan`. It yields each problem as an `_Unmended`: the `Refusal` `execute` refuses it with, and `check`'s FINDING lines and remediation for it, in `check`'s words as they were. `check` lists an unanswered entry with the entries, each by its path (`_leg_findings`), after the rule's problems. - **`cmd_execute`.** It refuses the first problem, `next(_against_its_source(...))`. Before, it called `_refuse_a_plan_for_another_repository`, `_check_names` and `_refuse_an_unrunnable_plan` in turn. - **`cmd_check`.** It lists the rule's findings right after the Triad advisory, where it listed a moved source. Another repository and the names are no longer listed after the entries. Three things are still asked where they were, and only where no name was refused: - the names' classification lines on stdout; - the findings about what the disk or GitHub will not hold (#863); - the leg question (#798). The remediation lines it prints after its findings follow the findings' order, so the rule's come first. - **`_refuse_an_unrunnable_plan`.** It asks a moved source before an unanswered entry. Its docstring says why. - **`_refuse_a_plan_for_another_repository`.** Unchanged. The rule calls it. ## Tests `tests/test_adopt_plan_against_its_source.py` (new, ASCII only), 11 rows. Each runs `check` and then `execute --yes` on one hand-edited plan, both with the same `--local-remote-dir`, and asserts that no leg is made. - **7 pair rows** (the six pairs and all four). Each asserts: - `check` exits 1 and `execute` exits 2; - both name first the same code, the rule's first; - `check` lists every problem the plan holds, in the rule's order. - **4 controls**, one per code: the plan holds that problem alone, and both commands name it. - **Against #1125's head `9549006f`:** - the 6 pair rows that disagreed fail, each because the two commands named different codes, for example `('plan-stale', 'plan-unresolved') != ('plan-stale', 'plan-stale')`; - the `another-and-name` guard row and the 4 controls pass. - **Here:** 11 passed. `tests/test_adopt_relative_submodule_url.py`, 6 rows changed and 1 new (the fix round): - **The 6 rows** are `test_check_keeps_a_relative_url_its_github_leg_resolves_alike` for `[https]`, `[scp]`, `[scp-other-case]`, `[ssh-port]` and `[ssh-github-443]`, and `test_a_credential_in_the_source_remote_is_never_printed`. - Each hand-edits the plan's `org:` to `otherorg`, so the plan names `otherorg/Thing` and the source is `testorg/Thing`: a plan for another repository. - Each expected `check` to list the relative url first and `plan-not-the-source` after it, `check`'s old order. They now expect `plan-not-the-source` first. Their other assertions are unchanged; the credential is still printed nowhere. - **The new row,** `test_check_names_another_repository_first_as_execute_refuses_it`, runs one such plan. It asserts that `check`, with and without `--local-remote-dir`, lists `plan-not-the-source` and then the url, that `execute` refuses `plan-not-the-source`, and that no leg is made. - **Measured on #1125's head** for the six source remotes: `execute` already refused each such plan `plan-not-the-source`, and its output is the same on both heads. Only `check`'s order changed. No spelling of the source's `origin` reads as another repository: with `org:` left as the source's, `check` passes on both heads, as the rows assert. Both files together, 46 rows: - **Against #1125's head:** 13 failed, 33 passed. The 13 are the 6 pair rows above and the 7 relative-url rows, each on the order: `plan-not-the-source` where the url was expected first, or the url first in the new row. - **Here:** 46 passed. ## Mutants Each was applied to `adopt-project.py`, run against both files (46 rows), and restored, with its sha256 checked (`4c8c1c9e`, the same after each one). Unmutated, the 46 pass. M1-M6 are the first round's; M7 and M8 are the fix round's. | Mutant | Killed by | |---|---| | M1, `execute`: `_refuse_an_unrunnable_plan` asks an unanswered entry before a moved source (pair 1) | 2: `moved-and-unanswered`, `all-four` | | M2, `check`: lists another repository after the entries (pair 2) | 11: `another-and-unanswered`, `another-and-moved`, `another-and-name`, `all-four`, and the 7 relative-url rows | | M3, `check`: lists a moved source before another repository (pair 3) | 3: `another-and-moved`, `name-and-moved`, `all-four` | | M4, `check`: lists a refused name after the entries (pair 4) | 3: `name-and-unanswered`, `name-and-moved`, `all-four` | | M5, `check`: lists a moved source before a refused name (pair 5) | 2: `name-and-moved`, `all-four` | | M6, the rule: asks a refused name before another repository (both commands) | 2: `another-and-name`, `all-four` | | M7, `execute`: asks `_refuse_what_check_finds` before the rule | 8: the pair rows but `another-and-name`, the `plan-stale` control, and the new relative-url row | | M8, `check`: lists all the rule's problems after the entries | 14: the pair rows but `another-and-name`, the `plan-stale` control, and the 7 relative-url rows | ## Gate Run on the scratch merge `743ebe176`: `origin/main` `961640c68` plus this head `90eca4144` (tree `d71d6f00a`, never pushed). It ran in the landing gate's own environment (a stub `gh`, `GH_TOKEN=bogus`, an empty global git config, `EMAIL` set, the `LANES_*` and `GIT_SSH_COMMAND` variables unset), one shard at a time, each started with at least 19G free. The ids were collected from that tree, 4405 + 448 = 4853, and each is in exactly one shard. The file list is the 46 test files W1113's gate named and four more, added in the fix round (50 in all): `test_adopt_c_locale_bytes.py` and `test_adopt_submodule_url_answers.py` (they import helpers and the module-scoped `adopter` fixture from the changed test file, so they sit in its shard, r03), `test_reader_refusals_named.py` (it hand-edits a plan's `org:` and checks `check`'s exact findings) and `test_github_name_limits.py`. Every shard exited 0. | Shard | Result | |---|---| | r01 | 600 passed | | r02 | 613 passed | | r03 | 464 passed, 1 skipped (`test_adopt_plan.py::test_plan_on_medxehr_matches_the_rulings`) | | r04 | 547 passed | | r05 | 599 passed, 19 skipped (all in `test_family_init_values.py`) | | r06 | 542 passed | | r07 | 489 passed | | r08 | 531 passed | | d01 to d07 | 56 passed each (`test_shape_doctor.py`) | | d08 | 53 passed, 3 skipped (`test_shape_doctor.py`) | - **r01 to r08:** 4385 passed, 20 skipped (4405 ids). - **d01 to d08:** 445 passed, 3 skipped (448 ids). - **Total:** 4830 passed, 23 skipped, none failed, none errored. Main has moved since this gate: `origin/main` is `ac5f28d2a` (#1188, #1130 and #1098 landed). They change `scaffold-project.py`, `scripts/family.py` and four test files. None of them is a file this PR changes, a module `adopt-project.py` imports, or one of the 50 gated test files, so the gate was not run again on that tip. The merge-trees below were. ## Merge-trees `git merge-tree --write-tree`, run again on 2026-10-23 at 15:31Z against the current `origin/main`, every one clean: - **`origin/main` `ac5f28d2a`:** clean, tree `1a65ab9bd`. - **#1125 `9549006fe`:** clean. This branch is on it. - **#1177 `5208e874e`** (`fix/1152-plan-and-remote-paths-spelled`, which edits a line of `_refuse_a_plan_for_another_repository`): clean, with this head and three-way on the scratch merge. - **Every open PR that touches `adopt-project.py` or either test file, 25 of them, #1125 and #1177 among them:** clean, both with this head and three-way on the scratch merge of `ac5f28d2a` and this head, `98afb128a` (`git merge-tree --write-tree <that merge> <its head>`). Since the first run, #1207 is new, and #1119, #1189 and #1190 have moved; all four are clean. No open PR touches either test file. The list is in a code block, so that it does not mention each PR: ``` PR head with this head on the scratch merge #1105 b28f40d clean (tree 32fce74c6) clean (tree 490f555f9) #1106 504a1dd clean (tree 3abfa1b6a) clean (tree db57682e9) #1118 2307036 clean (tree 79a3888bf) clean (tree 629f167ca) #1119 195d993 clean (tree a2926127e) clean (tree 6aec4c863) #1120 c55e55e clean (tree 6171a52f8) clean (tree 0cb310664) #1121 1c51737 clean (tree 3acb80467) clean (tree 2771fbfd0) #1122 c01c4d7 clean (tree af9a4c414) clean (tree ef00f52f7) #1123 5c3908f clean (tree eb7cb34b3) clean (tree 4c311748b) #1125 9549006 clean (tree 239df6d) clean (tree 1a65ab9bd) #1141 7a2711d clean (tree e1c8f7169) clean (tree 3afeda790) #1143 7af5fab clean (tree 35cd165de) clean (tree aae00cfc7) #1153 ff33ba1 clean (tree 57da668e8) clean (tree f91ce3d8d) #1154 8a78283 clean (tree 28699ba0c) clean (tree 3e6b1ae98) #1169 1e958ca clean (tree e3ef802be) clean (tree f4566b2c4) #1170 24ee133 clean (tree 94b9291c8) clean (tree 848bb0ed0) #1171 8162b87 clean (tree 65ca8196d) clean (tree f15571ef3) #1177 5208e87 clean (tree 08e3d6a62) clean (tree 111f3c238) #1178 c4beba1 clean (tree 646f02266) clean (tree eae3b5996) #1180 b316f41 clean (tree 25b3d4b6f) clean (tree 85a559171) #1186 07486a4 clean (tree d0fe77836) clean (tree 849f0ef41) #1189 a131fc2 clean (tree 5b1ab0620) clean (tree 9851d22b7) #1190 91a937e clean (tree aa83fae05) clean (tree 18101f50b) #1194 900f77a clean (tree 9d74efa35) clean (tree 46317beae) #1198 d38e4e3 clean (tree 87ca21b8c) clean (tree 7fc45a875) #1207 1c7ca5c clean (tree 1dbab659d) clean (tree 1e44c98f0) ``` ## Left for a follow-up (outside #1113) - **An unanswered entry beside an uncovered path still gives two first problems,** filed as #1197. `check` lists `plan-uncovered` first and `execute` refuses `plan-unresolved` first. Measured the same on #1125's head and on this head. `execute` refuses an unanswered entry before `_refuse_what_check_finds`, while `check` lists it in `_leg_findings`, after the paths and the coverage. Other entry findings ahead of it in `_entry_findings` are likely the same (`plan-unsafe-path` among them), and so is the taken split branch, but those were not measured. Closes #1113 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cter a terminal acts on in the command, its directory and what it printed, as `_first_said` spells gh's, so `--local-remote-dir` no longer reaches the terminal raw there; a NOTE before `NEXT:` says when the source's path in its clone command holds a character a terminal does not show as itself; and rows pin six sites #1177's 148 left to chance (#1195) (#1225) This PR was stacked on #1177, which landed as `8bcdb2c`. RS1225 then restacked it onto `main` with three merges (#1177's final head, its squash, then `main`), which carry exactly the reviewed change (#1225 issuecomment-6752574422). ## What #1195 follows RV1177's review of #1177 (for #1152). It names two raw routes, N3 and N4, and six mutants, K1, K2, K4, K5, K6 and K7, that all 148 rows of #1177's `tests/test_adopt_plan_and_remote_paths_spelled.py` let through. Three files change: `adopt-project.py`, that test file, and `tests/test_adopt_github_exits.py`. ### N3: a refused command no longer names `--local-remote-dir` raw `CommandFailed.loudly` belongs to `scripts/shape_materialize.py`, and every tool shares it. It printed the command, its `(in <cwd>)` line and its output as they were. So when a `pre-receive` hook refused a leg push under `--local-remote-dir <D>/re<U+202E>mote`, the raw U+202E was printed three times: in the command, and in git's own `To` and `error:` lines. The change is in `adopt-project.py` only. `shape_materialize.py` is unchanged. - `_unacted(text, kept="")` writes each character that `str.isprintable` refuses, other than those in `kept`, as `_spelled_character` spells it, with no backticks. That is what `_first_said` does to the line gh said. - `_loudly(exc, step)` builds the same REFUSED block from the command's arguments, its directory and its output, each passed through `_unacted`. - The output keeps its line breaks (`kept="\n"`), so it still reads one line per line. - This settles the point the issue left open: `loudly` prints the whole output, while `_first_said` keeps one line. - The leg push (`_push_the_leg`), `_failed_loudly` and `main`'s catch-all now print a refused command through `_loudly`, not `exc.loudly`. - A printable character prints as it is, so a command with plain paths prints the same block as before. I measured it with a real `execute --allow-empty-leg spec` and a global `pre-receive` hook that refuses every push: - **U+202E.** On `5208e874e`, U+202E in `--local-remote-dir` reaches stderr raw 3 times. On this head it reaches stderr 0 times. The command, the `(in ...)` line and git's `To` line each name it as `\u202e`, six ASCII characters. - **ESC.** ESC behaves the same way. git itself writes ESC as `?` in its `error:` line. - **Byte 0xff.** It was already printed as `\udcff` before this change, because stderr is `backslashreplace`. git's own lines hold U+FFFD for it, the character `run` decodes that byte to. ### N4: a NOTE says when `NEXT:`'s clone command holds a character a terminal does not show #1195 asks for a ruling: refuse such a source, as the README's clone line does, or add a note under `NEXT:`. The code settles it in favour of the note: - **Such a source is writable.** `_refuse_an_unwritable_clone_url` asks `unwritable_reason`. Since #255, that lets through a `Cf` character other than the nine bidi controls. Its docstring in `scripts/shape_materialize.py` says such a character "is invisible, not unwritable", and that a Persian name joins its parts with U+200C. - **The tests pin it.** `tests/test_adopt_template_values.py` lists `Ali<U+200C>Reza`, `a<U+200E>b`, `a<U+200F>b` and `a<U+061C>b` as `WRITABLE`. Refusing the source would reverse #255. - **Spelling breaks pasting.** Spelling the command instead would break `_branch_deletion`'s rule that a printed command pastes as it is. `sh` reads a quoted path, not a spelled one. I measured the old behaviour on `5208e874e`, with a full `execute --local-remote-dir` of a source under `<D>/so<c>urce/Thing`. U+200B, U+200F, U+200C and U+00A0 each pass, and the `split` line spells them. `NEXT:` prints each one raw, once, inside `shlex.quote`'s single quotes. On this head, the clone command is unchanged. When the url is not `str.isprintable`, this line prints just before the `NEXT:` block: ``` NOTE the path in the clone command below holds a character a terminal does not show as itself, so it can read as another path: it is `<D>/so\u200burce/Thing`, as the `split` line names it. ``` A url whose every character is printable gets no NOTE. A source name that ends in a space shows between the quotes, so it gets none either. **Where the NOTE goes differs from the issue's wording; please weigh it.** - The issue says a note *under* `NEXT:`. - #1121 and #1207 each add lines right after the `print` of the `NEXT:` block. With the NOTE printed under the block, this branch conflicted with #1121 (`git merge-tree` exit 1). - Printed just before the block, the NOTE merges cleanly with both. It is also read before the command a person would paste. - Mutant M14 (the NOTE after the block) fails 2 rows, so the placement is pinned. - Moving it under the block means moving one `print` and flipping the row's order check. It would then need a rebase over whichever of #1121 and #1207 lands first. ### The new rows The new file goes from 148 to 188 rows, 40 of them new. `tests/test_adopt_github_exits.py` keeps its 23 rows, with one assertion tightened. - **K1, K2.** `test_a_printable_plan_is_named_as_typed_unless_900_spells_it` runs `check` on the uncovered plan, in the plan's own directory, four ways. `run_adopt` now takes `cwd=` and `over=`. | `--plan` given | Named as | |---|---| | absolute, ending in a space | `` `<D>/plan.yaml ` `` | | `` `plan.yaml` ``, relative, between backticks | `` `\x60plan.yaml\x60` `` | | `plan.yaml`, relative | `plan.yaml` | | `./plan.yaml` | `plan.yaml` | - **K4, K5.** Two new `ROWS` entries. Each runs under `check` and `execute`, with the plan at each of the five path forms. - `plan-wrong-kind-a-placement-plan` expects `kind is 'placement-plan', expected 'adoption-plan'`. - `org-too-long`, with an `org:` of 40 `o`, expects `` `org:` is oooooooooooo...oooooooooooo, 40 characters ``. - The issue read both expected lines from the code. Both rows pass on `5208e874e`, which confirms them. - **K6.** `printable-U+00E9` is added to the remote-dir test. `re<U+00E9>mote` prints as typed, with no backticks. - **K7.** In `tests/test_adopt_github_exits.py`, the GitHub-mode `split` line must now match in full: ` split <12 hex> on adopt/three-repo-shape -> https://github.com/testorg/Northwind.git`. - **N4.** `SPLIT_SOURCES` gains `right-to-left-mark` (U+200F). Every split row checks either that the NOTE comes before `NEXT:`, or that there is no NOTE. - **N3.** Three tests: - `test_a_refused_command_names_the_remote_and_work_dirs_spelled` is the probe above as a test. The character goes in both `--local-remote-dir` and `--work-dir`, for U+202E, ESC, byte 0xff and a plain control. The line feed is left out, because git's own output names the path and the output's line breaks are kept. - `test_a_late_refused_command_is_said_spelled` checks `_failed_loudly`, in process, for all five `CHARACTERS`. - `test_a_refused_command_no_step_said_is_said_spelled` checks `main`'s own block, in process, with `cmd_check` replaced, for all five. ## Proof ### Before and after | Code | The new file (188 rows) and `tests/test_adopt_github_exits.py` (23 rows) | |---|---| | #1177's head `5208e874e` | 12 failed, 199 passed | | This head `73c9f57e8` | 211 passed | - **What fails on `5208e874e`.** The 12 failures are all N3 and N4 rows: - 2 split rows (U+200B and U+200F); - 2 refused-push rows (U+202E and ESC); - 4 `_failed_loudly` rows; - 4 `main` rows. - **The K rows** pass on `5208e874e`, because its code is right there. Each one fails on its mutant (below). - **Two N3 controls** pass on both heads: byte 0xff, because stderr already wrote `\udcff`, and the plain control. ### Mutants All 20 were killed. Each was applied to `adopt-project.py` on this head and restored with `git checkout`. After every restore, the file's sha256 was `163c692aff5b076410bde27740439070f2b9a4c62b4a7dc9510199cb0a7f890a`. - For K1 to K6, the whole new file was run (188 rows). For K7, `tests/test_adopt_github_exits.py` was run too (211 rows). - Under every K mutant, only new rows failed, so the old 148 stayed green. - M1 to M14 ran the N3 or N4 rows. | Mutant | What it does | Rows failed | |---|---|---| | K1 | `check`'s last count line names `--plan` by `isprintable()` alone | 2 of 188 | | K2 | that line names `plan.path.absolute()` | 3 of 188 | | K4 | the PLACEMENT plan's `plan-wrong-kind` names the plan raw | 8 of 188 | | K5 | `adopt-org-too-long` names the plan raw | 8 of 188 | | K6 | the extracted leg line spells every url outside ASCII | 1 of 188 | | K7 | the `split` line names the source's own path | 2 of 211 | | M1 | `_loudly` leaves the arguments raw | 10 of 14 | | M2 | `_loudly` leaves the working directory raw | 10 of 14 | | M3 | `_loudly` leaves the output raw | 8 of 14 | | M4 | the output's line breaks are spelled too | 14 of 14 | | M5 | the arguments are wrapped in backticks | 14 of 14 | | M6 | the leg push goes back to `exc.loudly` | 2 of 14 | | M7 | `_failed_loudly` goes back to `exc.loudly` | 4 of 14 | | M8 | `main` goes back to `exc.loudly` | 4 of 14 | | M9 | `_unacted` keeps every whitespace character | 2 of 14 | | M10 | no NOTE | 2 of 4 | | M11 | a NOTE wherever the `split` line spells the path | 1 of 4 | | M12 | the NOTE names the path raw | 2 of 4 | | M13 | the clone command is spelled, not quoted | 4 of 4 | | M14 | the NOTE prints after the block, not before it | 2 of 4 | ### Gate The gate ran on the scratch merge `37c65a609` of `origin/main` `961640c68` and this head `73c9f57e8`, in the landing gate's environment: | Shards | Tests | Result | |---|---|---| | g0 to g5: the 52 files listed below | 3,250 | 3,246 passed, 4 skipped | | d0 to d7: `tests/test_shape_doctor.py` | 448 | 445 passed, 3 skipped | - The shard lists hold each collected id exactly once. Every shard exited 0. - **The 7 skips are environment skips.** Three `tests/test_adopt_root_symlinks.py` rows need git 2.45 for the reftable backend. One `tests/test_stdout_encoding.py` row and the three doctor rows run only on Windows. - **g1 ran twice.** A host reboot killed its first run part way. It was re-run whole: 542 passed. <details><summary>The 52 files in g0 to g5</summary> `tests/test_adopt_attributes_eol_block.py`, `tests/test_adopt_c_locale_bytes.py`, `tests/test_adopt_clone_template.py`, `tests/test_adopt_contracts_dir.py`, `tests/test_adopt_e2e.py`, `tests/test_adopt_entry_paths.py`, `tests/test_adopt_execute_refusals.py`, `tests/test_adopt_filter_repo_failure.py`, `tests/test_adopt_git_reads.py`, `tests/test_adopt_git_rm_bytes.py`, `tests/test_adopt_github_exits.py`, `tests/test_adopt_late_failure.py`, `tests/test_adopt_leg_keys.py`, `tests/test_adopt_mount_ignored.py`, `tests/test_adopt_pins.py`, `tests/test_adopt_plan_and_remote_paths_spelled.py`, `tests/test_adopt_plan_escapes.py`, `tests/test_adopt_reencoded_files.py`, `tests/test_adopt_relative_submodule_url.py`, `tests/test_adopt_root_manifest.py`, `tests/test_adopt_root_shape_paths.py`, `tests/test_adopt_root_symlinks.py`, `tests/test_adopt_spec_only.py`, `tests/test_adopt_spelled_names.py`, `tests/test_adopt_split_branch.py`, `tests/test_adopt_submodule_url_answers.py`, `tests/test_adopt_submodules.py`, `tests/test_adopt_temp_work_roots.py`, `tests/test_adopt_template_values.py`, `tests/test_adopt_tracking_branch.py`, `tests/test_adopt_unsearchable_source.py`, `tests/test_adopt_values_as_typed.py`, `tests/test_adopt_verify.py`, `tests/test_doctor_next_is_a_command.py`, `tests/test_dry_run_asks_remotes.py`, `tests/test_family_add_refusals.py`, `tests/test_git_output_decoding.py`, `tests/test_github_limits_init_check_org.py`, `tests/test_github_name_limits.py`, `tests/test_non_utf8_name_callers.py`, `tests/test_path_guard_hygiene.py`, `tests/test_refusal_default_remediation.py`, `tests/test_repo_hygiene.py`, `tests/test_ruleset_hint_tracking_branch.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_scaffold_manifest_values.py`, `tests/test_scaffold_reuse_seeded_legs.py`, `tests/test_stdout_encoding.py`, `tests/test_template_values_single_pass.py`, `tests/test_update_shape.py`, `tests/test_update_shape_add.py`, `tests/test_update_shape_merged_away.py` </details> **`main` has moved since the gate's base, and it touched `adopt-project.py`.** - `origin/main` is now `50ce8ab15`. Of the 15 commits since `961640c68`, one touches `adopt-project.py`: `b7edd8816` (#1119, for #1083), with 279 lines added and 78 removed. None touches either test file. - `git merge-tree` of this head with `50ce8ab15` is clean. In the merged `adopt-project.py`: - the file parses; - `_unacted` and `_loudly` are each defined once; - `.loudly(` is called only inside `_loudly`, so #1119 adds no new `exc.loudly(` call. - The gate above did not run on `50ce8ab15`. The landing gate re-tests this head on its own merge. ### Merge-trees (2026-10-23T20:14Z) `git merge-tree --write-tree <X> 73c9f57` exits 0 (clean) for every `<X>` below: - `origin/main` at `50ce8ab15`; - #1177's head, `5208e874e`; - each of the 24 open PRs that touch `adopt-project.py`: #1210, #1207, #1198, #1194, #1190, #1189, #1186, #1180, #1178, #1171, #1170, #1169, #1154, #1153, #1143, #1141, #1125, #1123, #1122, #1121, #1120, #1118, #1106 and #1105. No other open PR touches either test file. #1121, which adds lines after `NEXT:`, has not landed, and it merges cleanly. Each of the 24 was also merged against a scratch merge of `origin/main` and this head: - 21 merge cleanly. - #1169, #1180 and #1190 conflict in `tests/test_adopt_check_throwaway_repos.py`, and #1169 also conflicts in `adopt-project.py`. - Those three conflict with `origin/main` alone, in the same hunks byte for byte: 6 in `adopt-project.py` for #1169, and 13, 8 and 8 in the test file. So this head adds no conflict. ## Not in this PR These are outside #1195, which covers `adopt-project.py`'s `--local-remote-dir`: - `scaffold-project.py`, `update-shape.py`, `scripts/family.py` and `scripts/bump-leg.py` still print `CommandFailed.loudly` as it is. - `_init_refused` prints `git init`'s argument list raw, and those arguments hold the `--work-dir` path. - The `adopt-remote-unreadable` and `source-unclonable` refusals print git's or gh's output raw. - `_unacted` and the NOTE test `str.isprintable`, as `_first_said` does. So the default-ignorable characters that #1171 (#1128) adds to `_shown` get no NOTE, and are not spelled in a REFUSED block. They belong with `_first_said` under #1165. Closes #1195 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



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 headbba168bd, and it was restacked through three merges: #1119's final head9b856443(c3f8a2b7, clean); #1119's squashb7edd881(ef73f75e: no conflict, and it changed no file, sincegit diff 9b856443 b7edd881is empty); then main50ce8ab1(b18d2e8d, clean).git diff --name-only origin/main b18d2e8dlists exactly this PR's three files, and the changed lines ofgit diff -U0 origin/main b18d2e8dequal those ofgit diff -U0 bba168bd a4ec51d2, the reviewed delta (985+/-lines, the same sha256). Main'sadopt-project.py,docs/cli.mdandtests/conftest.pyare byte for bytebba168bd'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, asfe25bb14, through the same three merges after #1119's squash.git merge-tree --write-tree a0e8cded fe25bb14is clean, treeac442d4c. 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
checkpromises 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 theupload-packthe source's ownremote.origin.uploadpacknames, and writes what it fetched into the source'sobjects/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
originthat setsuploadpack.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.uploadpackwas set to a wrapper that counts its runs, andobjects/was hashed before and after. Base is #1119's headbba168bd. The rows down to the unborn HEAD were first measured at179b58ef, and their test rows pass at this PR's heada0e8cded; the HEAD rows were measured atb316f415with a scratch probe. The commits:3b11751aand2c8082a2added the tests,70f32485added the tag-only and extension rows,6cd96b8bchanged the refusal's words and added the two-remote row,41230f64walks HEAD, names whatgit fsckshows and pins the words of both arms,b316f415adds two assertions and the file-named-HEADrow, anda4ec51d2ends 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, anda0e8cded(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.bba168bdcheck--filter=blob:none --no-checkout(28 objects lacking)plan ok. 26upload-packruns;objects/CHANGED;.promisorfiles 1 -> 27REFUSED source-partial-clone. 0 runs;objects/unchanged;.promisor1 -> 1check--filter=blob:none, checked out (2 history blobs lacking)plan ok. 0 runssource-partial-clone. 0 runs; unchangedcheckprotocol.file.allow=alwaysplan ok(the test row)source-partial-clone. 0 runs; unchangedcheckplan ok. 0 runs; unchangedcheckgit fetch --refetch --no-filter origin; lacks nothing)plan ok. 0 runs; unchanged.promisor1 -> 1execute--no-checkoutgit filter-repodied withBlob not found. 26 runs; CHANGEDsource-partial-clone, and no leg remote made. 0 runs; unchangedexecuteBlob not found). 0 runssource-partial-clone, no leg. 0 runs; unchangedexecuteadoption verified. 0 runs;objects/changed by the split's push onlyexecuteadoption verified. 0 runs.promisor1 -> 1checkplan ok. 0 runs; unchangedsource-partial-clone, "lacks at least 1 object(s) its branches, tags and HEAD reach". 0 runs; unchangedexecuteREFUSED extracting the spec leg, the split'sgit cloneexit 128 (unable to read sha1 file,Clone succeeded, but checkout failed). 0 runssource-partial-clone, no leg remote made. 0 runs; unchangedcheck,executecheckexit 0,plan ok;executemakes both legs, thenREFUSED extracting the spec leg. 0 runssource-partial-clone; no leg. 0 runs; unchangedcheck,executegit switch --orphan) (control)checkexit 0,plan ok;executeexit 0,adoption verified. 0 runsThe refusal, for the first row:
HEAD.
execute'sgit clone <source> <work>checks out the source's HEAD beforecheckout -B, and a HEAD detached at a commit no branch or tag reaches can name a blob the clone lacks. r1180b measured it:checksaidplan ok, andexecutemade both legs and then died in that clone's checkout. So_objects_lackingwalks HEAD as well, by its id, wheregit rev-parse --verify -q HEADgives one (rows above). Four shapes it must not get wrong, each a test row:git switch --orphan) gives no id, and HEAD is left out of the walk. A plainHEADin 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.HEADin the source's work tree: withHEADin the argument list and no--, git exits 128,ambiguous argument 'HEAD': both revision and filename. With the--the walk now ends with,HEADby 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 againstHEADwith no--. The walk keeps the id.ambiguous argument '<id>': both revision and filename, exit 128, andcheckrefused 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..git/HEADby hand:git update-refwould fetch it). The refusal iscould not walkon git 2.43:rev-list --missing=printexits 128,fatal: bad object <id>, and runs noupload-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: 1upload-packrun, measured. UnderGIT_NO_LAZY_FETCH=1it 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 namesgit 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; noupload-packran), 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:A refetch does not last.
git fetch --refetch --no-filter originleavesremote.origin.partialclonefilter blob:nonein 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:
cmd_check, on a stale or edited plan;_not_a_whole_clone(git cat-file -e <commit>^{commit}), for asource.cloned: trueplan, insidePlan.open_source, before the guard.Take a cured partial clone and a plan whose
commit:names a commitoriginholds and the source lacks. On base, each command ranupload-packonce, and the commit was fetched into the source. On head, each runs it 0 times, andobjects/is unchanged.checkgivesFINDING plan-stale, exit 1. Inexecute, the cloneplanmade is no longer whole, so it is cloned again (from a GitHub that does not have it, in the row).check --sourceandexecute --source(SOURCE_OVERRIDE_HELP, anddocs/cli.md, regenerated) namesource-partial-cloneand the fetch that makes the source whole.Why
Two layers, as
shape-doctor.pyhas them (#683, #754, #787):_lazy_fetch_off(), a decorator overcmd_checkandcmd_execute. It setsGIT_NO_LAZY_FETCH=1for the whole command and putsos.environback afterwards. Every git the command starts inherits it: each read of the source,check's throwaway repositories, andexecute's clones andgit 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 onels-tree -r -l: exit 128, and noupload-packran. 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._refuse_a_partial_clone_short(source), called right after the source is opened. Incheckit comes after the open's try/except, so the unwritable-work-dir refusal is not masked. Inexecuteit 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 whosepromisoris true (--type=bool), a remote with apartialclonefilter, and the remoteextensions.partialclonenames, that one last.promisor = falsealone is none. A full clone stops here, unchanged._objects_lacking: it streamsgit 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, noupload-packran, with HEAD at a commit the clone lacks included. A walk that fails isNone, and refused, naming the walk as run and how git exited.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
checkreads andexecutesplits,execute'sgit filter-reporewrites a clone of the source, which copies every branch and tag, and that clone'sgit clonechecks out the source's HEAD. The checked-out partial clone lacked only history blobs. It passedcheck, and failedexecuteafter 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--tagsout of the walk,checksaidplan okandexecutemade both legs, then died ingit filter-repowithBlob 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,checksaidplan okandexecutemade 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
checkstops before it reads the source.Rejected:
-c protocol.allow=never: measured, the source's ownprotocol.file.allow=alwaysoutranks it, and the lazy fetch ran 4 times;GIT_ALLOW_PROTOCOLover the whole command: it stops the fetch too, butcheckandexecuteneed their transports, for the clone of the source,ls-remoteand the pushes;_a_partial_clone, whichcheckasks of a pin's recorded clone, keeps its older rule.Tests
tests/test_adopt_partial_clone_source.py, 38 rows. Each counts the promisor'supload-packruns through a countingremote.origin.uploadpack(a Python script started withsys.executable), and hashesobjects/:checkrefusesnocheckout,checkoutandfileallow, 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);checkreadsfullandcuredas before (2);executerefusesnocheckoutandcheckoutbefore the leg directory exists (2);executesplits a cured partial clone,adoption verified, 0 runs (1; skipped wheregit-filter-repois absent);NO_LAZY_FETCHset to a name no git reads (1);check(the advisory) and throughexecute(_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 underGIT_NO_LAZY_FETCH=1(2);tagged, an annotated tag on a commit no branch reaches that changesspecs/001-feature/spec.md, with its own filter; refused assource-partial-clonethroughcheckand throughexecute, no leg directory, 0upload-packruns (2);update-ref --no-derefand the tag deleted, so branches lack 0, tags 0 and HEAD 1. Refused throughcheckand throughexecute, the refusal naming HEAD in "its branches, tags and HEAD reach" and sayingexecute's clone checks out its HEAD, no leg directory, 0 runs (2);.git/HEAD, withNO_LAZY_FETCHset to a name no git reads: refused, 0 runs,objects/unchanged. Only this row tells the id asked asHEADfromHEAD^{commit}(1);refs/heads/unbornis read as before, throughcheck(plan ok, nothing fetched) and throughexecute(adoption verified, 0 runs; skipped wheregit-filter-repois absent) (2);HEADin the source's work tree is no path of the walk:checksaysplan ok(1);git rev-list <id>alone exits 128, thenchecksaysplan ok, 0 runs,objects/unchanged (1);partialclonefilterkey (1);rev-list --missing=printexits 128; from 2.45 it lists the commit. Refused either way, asserted by its code alone (1);LACKING_WALKwith an option no git has), HEAD born and unborn: refused on any git, naming the walk as run (HEADwhere 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, naminggit fsckin the source with what it shows, and the cure (2);git fsckshows 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 noupload-pack(4);_promisor_remotes:promisortrue andyes,partialclonefilteralone,extensions.partialclonealone and last, the remote it names last where it is also a promisor (extensions.partialclone = o, thenoandppromisors:['p', 'o']),promisor = falsealone, none (8);_lazy_fetch_offsets 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 ofa0e8cdedwith main50ce8ab1'sadopt-project.pyanddocs/cli.mdput 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:checkfullandcured,executecured, the unborn HEAD throughcheckandexecute, the file namedHEAD, the file named as HEAD's own id, and the fourgit fsckrows. The two rows round 4 changes fail on base, as they did: the threecheckrefusals and the failing walk, born and unborn. Heada0e8cded: 38 passed in 24.30 s, and 38 passed in 34.83 s under the 3.14-likesitecustomizeshim (exists,is_fileandis_diranswer False to anyOSError; it loaded 44 times in the run).Base vs head, round 2 (
b316f415). The 37-row file, copied into a scratch clone ofbba168bd(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 commitb316f415, with the same bytes), and 37 passed in 19.43 s onb316f415under the 3.14-likesitecustomizeshim. The 10 that pass on base are the rows base answers as head does:checkfullandcured,executecured, the unborn HEAD control throughcheckandexecute, the file namedHEAD, and the fourgit fsckrows. Of the 11 rows new in round 2, 4 fail on base: the HEAD alone throughcheck(exit 0,plan ok) and throughexecute(both legs made, thenREFUSED 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 of6cd96b8bwere 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_walkand the edited failing-walk row (it names the walk as run, so it now expects the--), withb316f415's ownadopt-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-HEADrow, 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, thecould not walkarm, where 0 andplan okare asserted); the failing-walk row fails born and unborn, because base names the walk without the--; the file-named-HEADrow 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'se0a98f13; and round 4's run at main above). Heada4ec51d2: the whole file, 38 passed in 22.91 s.tests/test_adopt_plan_partial_clone.pyis #1162's and is not in this PR's tree; in a scratch merge ofa4ec51d2and #1162's tipd4d6e2a7(tree0117a9d7, clean), that file andtest_adopt_partial_clone_source.pytogether: 52 passed in 38.69 s. That file asserts no text of the walk.Mutants. At
b316f415, 44 of 44 killed, againstb316f415'sadopt-project.pyand the 37-row file, in two scratch clones, in four runs of 11, withadopt-project.pyrestored after each mutant in afinally. At the end of each rungit statusshowed the file unchanged, and afterwards the blob of each clone and of the worktree equalled HEAD's,b1e44868. The 44:muts_f1180_all.py): each applies once to this head's text, butlater-dropped-none, whose anchor moved with the wording and was re-pointed atwith --source, and run this command again. {later}. Each killed, by at least 1 row;count-worded-as-none(if lacking is None or lacking:) killed by 5 rows (the threecheckrefusals and the two HEAD rows);none-no-said(the walk andexiteddropped from the refusal) by 2 (the failing walk, born and unborn);none-fsck-no-whereby 2;none-no-refetchby 2;head-not-walked(HEAD left out of the argv)head-unverified(head = "HEAD"always: a plainHEAD)checkandexecute, the file namedHEADhead-peeled(HEAD_IDwithHEAD^{commit})head-by-name(the argv takesHEAD, not its id)HEADhead-shown-never(the walk named withoutHEAD)head-shown-always(named withHEADeven when unborn)none-arm-no-head("branches and tags git could not walk")count-arm-no-head("tags reach")fsck-old-claim(the sentence says fsck "names each object the repository lacks")none-no-exit(exited N:dropped)execute-words-no-head(the sentence aboutexecute's clone checking out HEAD dropped)Round 3's mutants (r1180c's NIT-1), 3 of 3 killed, against
a4ec51d2'sadopt-project.pyand the 38-row file, in a scratch clone,adopt-project.pycopied back from the worktree after each:argv-no-dashes(the walk's argv without--)shown-no-dashes(the named walk without--)dashes-before-head(--before the id, so the refs and HEAD are read as paths)checkrefusals, the twoexecuterefusals, the guard alone, the two-remote row, the tag rows (2), the HEAD rows (2), the HEAD at a commit the source lacksRound 4's mutants (r1180d's NIT-2 and NIT-3), 2 of 2 killed. r1180d ran 17 mutants against
a4ec51d2and 14 were killed. Of the 3 survivors, M05 (the argv walksHEADby name, with the--) is an equivalent mutant: with the--, the name and the id walk the same. So round 2'shead-by-namerow, killed atb316f415, is equivalent froma4ec51d2on, and the file-named-HEADrow now kills onlyHEADby name with no--(r1180d's M06). The other two are killed now. Each was applied toa0e8cded'sadopt-project.pyin a scratch clone, its row run and failed, then the file was restored, its blob checked equal to HEAD's76b860ff, and the row run again and passed:f"{spelled_as_git_out(words)}"->"")lacking = max(lacking, int(line.startswith(b"?"))))checkrefusals: 3 failedHistory: round 1 measured, by the author on
2c8082a2, 11 of 11 killed; the review (r1180) ran 19 and 2 survived,walk-no-tagsandext-not-last, which the tag rows and the extension row now kill; lane 1 ran all 29 against6cd96b8b: 29 of 29; r1180b ran 16 on6cd96b8band 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:cmd_checkcheckcmd_executeexecutecmd_checkcheckrefusals, the guard alone, the hand-written branch, the failing walkcmd_executeexecuterefusals_lazy_fetch_offdoes not restorelacking == 0returncheckcured,executecured, the plan-named commit throughcheck_promisor_remotesreturns[]PROMISOR_KEYSwithoutpromisorpromisor,promisor-yes,extension-lastPROMISOR_KEYSwithoutpartialclonefilterfilterPROMISOR_KEYSwithoutextensions.partialcloneextensionNonewalk read as 0 (if not lacking)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):test_path_guard_hygiene.py,test_no_auto_maintenance.pytest_no_auto_maintenance.py:222: the probe setsGIT_CONFIG_GLOBALtest_agents_md_codes.py,test_cli_reference.py,test_repo_hygiene.pytest_adopt_check_throwaway_repos.pytest_scaffold_leg_path_collisions.pytest_shape_doctor.py, 4 shards (112 node ids each, passed line by line)test_shape_doctor.py:10510, which runs only on a Windows hostNo call failed.
docs/cli.mdwas regenerated byscripts/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:test_adopt_partial_clone_source.pytest_path_guard_hygiene.py,test_no_auto_maintenance.pyNot rerun at
a4ec51d2: the doctor shards,test_adopt_check_throwaway_repos.py,test_scaffold_leg_path_collisions.pyand the doc trio. They ran atb316f415(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
b18d2e8dand ata0e8cded, in the probe environment, under 3.12, each under 9 minutes and at most 2 at once:b18d2e8dtest_adopt_partial_clone_source.pyb18d2e8dtest_adopt_check_throwaway_repos.pya0e8cdedtest_adopt_partial_clone_source.pya0e8cdedscripts/render-cli-reference.py --checkata0e8cded:[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:test_path_guard_hygiene.py,test_no_auto_maintenance.pytest_shape_doctor.py, 4 shardstest_scaffold_leg_path_collisions.pytest_adopt_check_throwaway_repos.py,test_adopt_reencoded_files.py,test_adopt_root_symlinks.pytest_adopt_partial_clone_source.pyandtest_adopt_pins.py;test_adopt_plan_yaml_quoting.pyandtest_adopt_single_repository_record.py;test_doctor_no_lazy_fetch.py,test_doctor_pin_restore_merges.pyandtest_doctor_partial_clone.pytest_agents_md_codes.py,test_cli_reference.py,test_repo_hygiene.pyNo 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.pyin 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 runsrev-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 insidePlan.open_source, for asource.cloned: trueplan, before the guard, so in ANY partial clone, a cured one or not. r1180 measured an uncurednocheckoutclone, withNO_LAZY_FETCHswapped for another name:checkandexecuteeach ranupload-packonce,objects/changed, thenREFUSED source-partial-clone. Low likelihood:planmakes that clone itself, as a full clone.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_cloneis 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_FETCHare 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_fileandis_diranswer False to anyOSError): 38 passed ata0e8cded(and 38 ata4ec51d2, r1180d).Not in this PR
plan, which reads its--sourcethe same way: 26upload-packruns and.promisor1 -> 27, measured onbba168bd. It is adopt-project.py plan: a partial-clone--sourceis fetched into, ascheckandexecutewere before #1145 #1162, stacked on this PR, because puttingplanunder the variable also changes how it reads a partial--pin-source._a_partial_clone, the older rulecheckasks of a pin's recorded clone. It is not changed.Lane: openreposhape-1 (openRepoShape-1)
🤖 Generated with Claude Code
Summary by Sourcery
Refuse incomplete partial-clone sources before adoption reads them and prevent
checkorexecutefrom fetching into the source.New Features:
checkandexecutefrom fetching missing objects into partial-clone sources by detecting incomplete history across branches, tags, and HEAD.source-partial-clonerefusals with remediation for restoring a complete source clone.Bug Fixes:
Enhancements:
Documentation:
Tests: