Skip to content

adopt-project.py: check names what the clone's .git holds as git keeps refs, and asks a pin's file through the links the root keeps, so a link into .git or onto a pin's file is refused before any leg or adopted with the copy beside it (#904) - #1091

Merged
brettheap merged 4 commits into
mainfrom
fix/904-kept-links
Oct 21, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-5 (openRepoShape-5)
Claim: #904 (comment)

Goal

A symlink the plan keeps in the root, where it leads into the clone's own .git or through a kept link to a pin's file, is refused before any leg exists for a reason that is true, or adopted with the shape's copy beside it (#904, lines L1-J1, L1-J2, L1-K2 and L1-K9 of #457, with L1-K8 as the rider).

What still held on main, and what had changed

#904's line numbers are at 5017830. Each claim was measured again by name on main 6c861635, and again on 5e539a87 after #1064 landed (#1071 and #1076 since then touch neither adopt-project.py nor scripts/shape_materialize.py).

  • L1-J1 held. CLONE_GIT_TREES still counted .git/objects, .git/refs, .git/logs and .git/modules whole. CLAUDE.md -> .git/refs/remotes/origin/main, -> .git/logs/refs/heads/nope and -> .git/modules/nope: check 0 (plan ok), execute 0, adoption verified, 2 leg repositories, and the work clone's .git/<target> holds the shape's CLAUDE.md. Changed: -> .git/objects/zz/nothing now ends REFUSED committing the split. materialize-blocked: ... there is no directory there to write it in, still after both legs, where 5017830 printed a bare REFUSED committing the split.
  • L1-J2 held. CLAUDE.md -> .git/refs/../HEAD and -> .git/../AGENTS.md: check 1, plan-root-symlink "it passes through .git/refs (.git), which is not a directory in the assembly root", 0 legs.
  • L1-K2 held, and the same is true of the shape's own pin. contracts -> governance, governance/ kept, --pin openGlass@<commit>: a file governance/openglass-pin.yaml gives check 0, execute 0, beside shape/contracts/openglass-pin.yaml, adoption verified, then the first bootstrap exits 2 with REFUSED yaml-unparsable; a link from it to README.md gives REFUSED neutral-pin-unreadable. With no pin declared and a file governance/shape-pin.yaml: check 0, execute 1 with FINDING adopt-shape-pin-unreadable after both legs.
  • L1-K9 held. shape/README.md -> ../README.md kept: check 1 says "and execute would write the copy through it after both leg repositories were created and pushed" ("over it" for a file), and execute exits 2 before any leg. place asks copy_in_the_way, which stops at a link there, dangling or not, and writes nothing there.
  • L1-K8 is real. Measured with git 2.47.1 and GIT_DEFAULT_REF_FORMAT=reftable: CLAUDE.md -> .git/reftable and -> .git/reftable/tables.list are refused (plan-root-symlink), though the clone holds both; -> .git/packed-refs checks ok and execute writes the shape's CLAUDE.md into .git/packed-refs; -> .git/logs/HEAD checks ok and stops after both legs. The clone's ref format comes from GIT_DEFAULT_REF_FORMAT or init.defaultRefFormat, never from the source, and git 2.43 ignores both.

What the clone's .git holds when the shape is written was listed from inside execute (the materializer wrapped), on git 2.43.0 and 2.47.1, files and reftable. Edge cases were measured too: a source whose HEAD is detached gets a guessed branch, and core.logAllRefUpdates=false (global or GIT_CONFIG_*) leaves no .git/logs.

What changed

All in adopt-project.py; the tests in tests/test_adopt_root_symlinks.py and tests/test_adopt_root_shape_paths.py.

  • Named, not counted whole (L1-J1). _CloneGit replaces CLONE_GIT, CLONE_GIT_FOLDED and CLONE_GIT_TREES. It names what git makes there: .git, HEAD, config, index, objects, objects/info, objects/pack, refs, and modules with each leg's store at modules/<mount path>. On the files backend it adds packed-refs, refs/heads, refs/tags and the three loose refs git clone and execute write (the branch the clone checks out, refs/remotes/origin/HEAD and the split branch), with a log of each and logs/HEAD where git writes reflogs. On the reftable backend it adds reftable, reftable/tables.list and a refs/heads that is a file, and there is no packed-refs and no logs (L1-K8).
    • A name inside the object store, inside a leg's store, inside the reftable tables, or under refs/heads and refs/remotes when the source's HEAD is on no branch is neither there nor not there. check refuses a link to one, saying that it reads nothing there, so whether place writes the copy beside it or the shape's file into .git cannot be told before any leg exists.
    • Any other name in .git is nowhere. The finding says the shape would write its file through the link into .git, where no commit holds it, or would find no directory there and stop after both legs: whichever is true of its directory.
  • How git keeps refs is asked of the machine (L1-K8). _clone_ref_store asks a throwaway repository in the work directory whether it is on the reftable backend and whether it writes reflogs, as _checks_out_symlinks asks about core.symlinks. _SymlinkProbe.ref_store makes that probe the first time a walk is led into the clone's .git, and keeps the answer. check makes it once, and execute twice, as it asks _checks_out_symlinks twice: once for check's findings and once for _refuse_symlinks_checked_out_as_files. Without a work directory, or where no repository can be made, git's defaults are taken. _entry_findings now makes its one probe before _leg_findings, so the pins' walks share it.
  • .. in .git (L1-J2). For a link AT a path the shape writes, a .. steps back out of a directory _CloneGit names, as the end of a target is found there. A link ABOVE one is still never followed into .git (D9); its finding now says so, for a .. and for a target, where it said "not a directory in the assembly root" or "not in the source tree".
  • A pin's file through the kept links (L1-K2). _kept_pin_findings asks each pin's file, the declared pins' and the shape's own, where the root walk reaches it through the links the plan keeps above it (_Walk.reached). Where that walk is stopped, the walk's own plan-root-symlink still names the link (PIN_THROUGH), so one link is one finding. The finding names the link and its remedy: "at contracts/openglass-pin.yaml through the symlink contracts to governance ... send contracts to a leg or drop it, or rename governance/openglass-pin.yaml in the source and re-run plan". The code is still plan-root-holds-pin, and _pin_findings and _leg_findings take source and probe from _entry_findings.
  • What the materializer does (L1-K9). plan-root-blocks-shape-copy now says "the materializer writes no copy through it" (or "over it", "cannot write a file where a directory is", "cannot make a directory where a file is"), then ": it would stop there (materialize-copy-blocked), writing nothing there, after both leg repositories were created and pushed, so execute refuses this plan before either exists." The comment at "A SYMLINK OR A SUBMODULE" and the test's docstring and assertion say the same.
  • Codes kept. No refusal code changed. Only tests pin the changed words: the README, the handbook mirror, docs/cli.md and AGENTS.md name none of these sentences.

Probes, before and after

The probes use #457's setup: a stub gh exiting 97, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null, local remotes, and every null leg: answered root. They ran on git 2.43.0 unless stated.

Case main 5e539a87 this branch
CLAUDE.md -> .git/refs/remotes/origin/main check 0, execute 0, verified, 2 legs, the shape's file in that .git file check 1 plan-root-symlink, execute 2, 0 legs
-> .git/logs/refs/heads/nope, -> .git/modules/nope the same as above check 1, execute 2, 0 legs
-> .git/objects/zz/nothing check 0, execute 2 after 2 legs check 1 (object store not read), execute 2, 0 legs
-> .git/refs/../HEAD, -> .git/../AGENTS.md check 1 "not a directory in the assembly root", 0 legs check 0, execute 0, beside shape/CLAUDE.md, verified
scripts -> .git/refs/.. (above) check 1, 0 legs check 1, 0 legs, saying a link above is never followed into .git
K2: governance/openglass-pin.yaml a file check 0, execute 0, bootstrap yaml-unparsable check 1 plan-root-holds-pin, execute 2, 0 legs
K2: it links to README.md check 0, execute 0, bootstrap neutral-pin-unreadable check 1 plan-root-holds-pin, execute 2, 0 legs
K2: it links nowhere check 1 plan-root-symlink, 0 legs unchanged
K2: nothing kept there (control) check 0, execute 0, bootstrap 0 unchanged
shape's own pin, governance/shape-pin.yaml check 0, execute 1 adopt-shape-pin-unreadable after 2 legs check 1 plan-root-holds-pin, execute 2, 0 legs
K9: shape/README.md -> ../README.md, and a file there check 1 "execute would write the copy through (over) it" check 1 "the materializer writes no copy through (over) it: it would stop there ..."
git 2.47.1 reftable: -> .git/reftable, -> .git/reftable/tables.list check 1, 0 legs check 0, execute 0, beside shape/CLAUDE.md, verified
git 2.47.1 reftable: -> .git/packed-refs check 0, execute 0, the shape's file in .git/packed-refs check 1, execute 2, 0 legs
git 2.47.1 reftable: -> .git/logs/HEAD check 0, execute 2 after 2 legs check 1, execute 2, 0 legs

These names also adopt with the copy beside them, as on main: .git/refs/heads/main, .git/refs/remotes/origin/HEAD, .git/refs/heads/adopt/three-repo-shape, .git/logs/refs/heads/main, .git/modules/code, .git/objects/info and .git/refs/tags.

Rows, mutants and runs

Rows that fail on main. The two test files of this branch were run on a clone of main 5e539a87 (whose adopt-project.py and scripts/shape_materialize.py are those of main c13c3985f): 42 failed, 277 passed, 3 skipped.

The 42 failures are every row this branch adds or changes, except those that hold on main too:

The rows that pass on main too are kept as guards:

  • the ten GIT_NAMED accepted rows and the end-to-end the-cloned-branch-and-the-split-branchs-log, names that were counted whole and are now named;
  • the no-pin-file control;
  • test_the_materializer_stops_at_a_link_at_the_copy, which is L1-K9's evidence that the materializer stops there.

The three rows that need git 2.45 or later were skipped there (git 2.43).

Mutants. Each mutant was applied alone to a copy of this head, and the rows that hold it were run on git 2.43. All 21 were killed, each by an AssertionError, with failed and selected counts as shown. None was equivalent.

Mutant Failed / selected
M1 an unread name counts as there 2/2
M2 the cloned branch is not named 2/2
M3 the split branch is not named 2/2
M4 reflogs always 1/1
M5 reftable never 6/8
M6 a .. above is followed into .git 1/1
M6b no .. out of a .git directory 4/6
M7 a link above is taken as one at 1/2
M8 a pin's file is not asked through links 4/7
M9 asked through a stopped walk too 4/4
M10 K9's old sentence 2/47
M11 the probe says reflogs always 1/1
M12 "written into .git" and "no directory" swapped 5/6
M13 the cloned branch is the branch read 1/1
M14 the clone's .git asked eagerly 3/3
M15 the ref probe's answer not kept 1/1
M16 no work directory gives no defaults 1/1
M17 the through-link remedy is the entry's 4/7
M18 the pins are asked without the source 4/7
M19 reached is taken from any part 1/7
M20 the .git reasons are gone 9/9

Runs (run9.sh, one shared stub gh, EMAIL, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null):

  • git 2.43.0, both own test files: 319 passed, 3 skipped (the reftable rows).
  • git 2.47.1 first on PATH, both own test files: 322 passed.
  • git 2.43.0 gate: every test file that runs adopt-project.py (grep -l in tests/), with test_shape_doctor.py, test_scaffold_leg_path_collisions.py, test_repo_hygiene.py, test_agents_md_codes.py and test_cli_reference.py. That is 122 files in two shards of 61: shard A, 4075 passed, 10 skipped (END 2026-10-20T23:41:03Z rc=0); shard B, 4713 passed, 9 skipped (END 2026-10-20T23:53:03Z rc=0).

Merge-trees

git merge-tree --write-tree of this head against:

Merge with main

Main moved to 2a119f28 while this was in review. Two of the pull requests that landed change adopt-project.py: #1031 (#910, the throwaway repositories check asks) and #1070 (#979, a named link at a copy or a pin is one finding). The branch was merged with main, not rebased, as 1cc9180fd (parents 8f5fd0fd3 and 2a119f281). Ten hunks of adopt-project.py conflicted, seven with #1070 and three with #1031. Each was resolved by reading both sides:

New rows in tests/test_adopt_root_symlinks.py cover the ref probe's failed git init, raised as a CommandFailed and as a Refusal. Further rows check that an OSError gives no NOTE, and that check under GIT_DEFAULT_HASH=bogus says where git would not make the ref probe.

Runs on 1cc9180fd (run9.sh, as before):

Round 2

Commit 160de1186, on top of the merge, answers rv1091's review of 8f5fd0fd. rv1091 re-checked it: SHOULD 1, SHOULD 2 and NITs 1-3 are fixed, and the two test files gave 326 passed, 3 skipped.

  • SHOULD 1: "writing nothing" was false. place writes the shape's files in order and stops only at the blocked copy, so every copy before it is written. rv1091 measured 0, 5, 9 and 12 files written before the stop, at shape/README.md, shape/CLAUDE.md, shape/Makefile and shape/.github/workflows/validate.yml. plan-root-blocks-shape-copy now says "it would stop there (materialize-copy-blocked), writing nothing there, after both leg repositories were created and pushed". That matches the materializer's own "and nothing was written there". The comment above the sentence says the same, and STOPS in tests/test_adopt_root_shape_paths.py pins the new words.

  • SHOULD 2: SonarCloud's two python:S5778. Both are in rows this PR adds, and each made a second call inside pytest.raises. template_values() (the materializer's link-at-the-copy row) and loaded.open_source(None, work) (the row that checks the ref probe is made once) are now made before the block.

  • NIT 1: python:S3776 on _kept_pin_findings. Sonar's analysis of the merged function gives 30. The finding for one pin's file on one disk is now _kept_pin_finding, and the through-link words and their fix are _through_the_kept_link. The nested conditional in _shape_pin_held's argument is now a statement. Sonar's analysis of the new functions (analyze_code_snippet, project opensoft_openRepoShape) finds no issue in them.

  • NIT 2: what a guessed refs directory holds. The case is a source whose HEAD is on no branch. The text "where git clone writes the branch it checks out" was given for .git/refs/remotes, .git/logs/refs/heads and .git/logs/refs/remotes as well as .git/refs/heads. _guessed_refs now says what git clone writes in each one:

    • in refs/heads, the branch it checks out;
    • in refs/remotes, origin/HEAD for that branch;
    • in the two logs/ directories, the reflog of either.

    This was measured on git 2.43.0 and 2.47.1, for a source detached at a branch's tip and for one detached off any tip. In the first case git checks out main and writes refs/remotes/origin/HEAD (ref: refs/remotes/origin/main) and the reflogs of both. In the second it writes neither, and refs/heads holds only the split branch. Each link is still refused, as "cannot be told". The detached-HEAD row is now four rows, one per directory.

  • NIT 3: the ref probe is made "once per command". That was false for execute. Counted in process with CLAUDE.md -> .git/HEAD, check makes the probe once, with or without a kept .gitattributes, and execute makes it twice. The first is for check's findings, which _refuse_what_check_finds asks. The second is for the walks of _refuse_symlinks_checked_out_as_files, which make their own _SymlinkProbe, as they ask _checks_out_symlinks again on main. The code is unchanged. The sentence under "What changed" now says so.

  • NIT 4: two mutants of rv1091's that survived the first round.

    • R8 guesses only refs/heads for a detached source, not refs/remotes. Round 2's detached-HEAD rows for .git/refs/remotes/origin/HEAD and its reflog kill it (M28 below).
    • R6 has the fix rename the kept file rather than the path the walk reaches. It still survives here, because no row keeps a directory at the reached pin file. That is a gap in the tests, not in the code. The row is a follow-up for after the v1.2.0 tag, written and run but not in this PR: contracts -> governance, with governance/openglass-pin.yaml/notes.md kept, gives "inside contracts/openglass-pin.yaml through the symlink ... rename governance/openglass-pin.yaml", and main checks it with exit 0.

Words. A grep of tests/, README.md, docs/ (outside the release notes), AGENTS.md and templates/ finds the changed words only in the two test files. No refusal code changed.

Rows that fail on main. The detached-HEAD rows for .git/refs/remotes/origin/HEAD, .git/logs/refs/heads/main and .git/logs/refs/remotes/origin/HEAD are new. A probe on main 2a119f28 gives check exit 0 for all four links, so main refuses none of them, and every row expects a refusal. The pytest run of these rows on main was not made before the checkpoint. On the merge 1cc9180fd all four are refused, but the three new ones say "the branch it checks out".

Mutants on 160de1186 were not run before the checkpoint. Each is written, and its anchor occurs once in 160de1186's adopt-project.py:

Runs on 160de1186 (run9.sh, as before):

  • git 2.43.0, both own test files: rv1091 ran them at 160de118, 326 passed, 3 skipped (the reftable rows).
    • Mine ran at 3abf3b1f5: 327 passed, 3 skipped, END 2026-10-21T13:32:51Z rc=0.
    • 3abf3b1f5 is 160de118 plus the R6 row alone, the follow-up kept off this branch. Its adopt-project.py and tests/test_adopt_root_shape_paths.py are 160de118's, and its tests/test_adopt_root_symlinks.py adds that one test function.
  • git 2.43.0, test_shape_doctor.py, test_scaffold_leg_path_collisions.py, test_repo_hygiene.py, test_agents_md_codes.py and test_cli_reference.py: running at the checkpoint, at 19% at 13:43Z, with no failure so far.
  • Not started before the checkpoint:
    • the seven other test files that pin plan-root-holds-pin or the K9 sentence (test_adopt_pins.py, test_adopt_shape_pin_and_work_dir.py, test_adopt_symlink_target_bytes.py, test_adopt_ignored_shape_files.py, test_adopt_root_manifest.py, test_adopt_nested_attributes.py and test_adopt_reencoded_files.py), though all seven are in the gate on 1cc9180fd;
    • the two own files on git 2.47.1.
  • CI runs the full suite on the push.

Merge-trees of 160de1186, by git merge-tree --write-tree:

Closes #904

Lane: openreposhape-5 (openRepoShape-5)

🤖 Generated with Claude Code

Summary by Sourcery

Refuse ambiguous root links into clone metadata and pin files while accurately describing Git storage and materializer behavior before adoption begins.

New Features:

  • Model the clone's .git contents by the names and reference storage formats Git actually creates, including files-backend, reftable, reflog, detached-HEAD, and leg-store cases.
  • Detect pin files reached through symlinks retained by the assembly root and report actionable findings before any leg repositories are created.

Bug Fixes:

  • Refuse root symlinks into missing or indeterminate locations in the clone's .git instead of treating entire Git directories as present.
  • Correct path traversal through .git and clarify that links above shape paths are not followed into the clone's Git metadata.
  • Describe materializer behavior accurately when a shape copy is blocked by an existing symlink or other path, including that execution refuses before creating legs.

Enhancements:

  • Add cached Git environment probes for reference storage and reflog behavior, with fallback notes when probing is unavailable.
  • Share symlink and Git reference probes across root, leg, and pin-path analysis.

Tests:

  • Add comprehensive coverage for named and missing .git entries, reftable and reflog configurations, detached HEADs, Git probe failures, .. traversal, pins through kept symlinks, and blocked materializer copies.

…keeps refs, and asks a pin's file through the links the root keeps, so a link into `.git` or onto a pin's file is refused before any leg or adopted with the copy beside it (#904)

A symlink the plan keeps AT a path the shape writes was taken to find
something wherever it led under `.git/objects`, `.git/refs`, `.git/logs`
or `.git/modules`, which were counted whole. `CLAUDE.md ->
.git/refs/remotes/origin/main` checked ok, and `execute` wrote the shape's
CLAUDE.md into that file of the clone's `.git` and said `adoption
verified`; `-> .git/objects/zz/nothing` stopped after both legs. Now
`_CloneGit` names what git makes there, measured with git 2.43.0 and
2.47.1 on the clone `execute` makes: the files backend's loose refs (the
branch the clone checks out, `origin/HEAD`, the split branch) and their
logs, or, on the reftable backend, `reftable` and a `refs/heads` file and
no `packed-refs` or `logs` (L1-K8: a link to `.git/reftable` was refused
and one to `.git/packed-refs` written through). How git keeps refs is
asked of a throwaway repository in the work directory, once, as
`core.symlinks` is. A name in the object store, a leg's store, the
reftable tables, or the branches where the source's HEAD is on no branch
is refused saying `check` reads nothing there.

A `..` in a link AT a path steps back out of a directory git makes in
`.git` (`CLAUDE.md -> .git/refs/../HEAD` was refused as "not a directory
in the assembly root"); a link ABOVE one is still never followed into
`.git`, and now says so.

A declared pin's file, and the shape's own pin, are asked where the walk
reaches them through the links the root keeps above them: `contracts ->
governance` with a `governance/openglass-pin.yaml` of the source's passed
`check`, and the root's first bootstrap refused it.

`plan-root-blocks-shape-copy` said `execute` "would write the copy
through it after both leg repositories were created and pushed"; it now
says the materializer writes no copy through or over what is there,
stops (`materialize-copy-blocked`) writing nothing, and that `execute`
refuses the plan before either leg exists.

Closes #904

Lane: openreposhape-5 (openRepoShape-5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 3 days by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR makes symlink planning reflect the clone’s actual Git layout and materializer behavior, refusing ambiguous .git and pin-through-link cases before any legs are created while preserving valid adoption beside known paths; extensive tests cover files/reftable backends, reflogs, path traversal, and collision semantics.

Sequence diagram for symlink planning against the clone Git layout

sequenceDiagram
    participant Check
    participant EntryFindings
    participant SymlinkProbe
    participant CloneGit
    participant PlannedRoot
    participant Materializer

    Check->>EntryFindings: _entry_findings(plan, source)
    EntryFindings->>SymlinkProbe: ref_store()
    SymlinkProbe->>SymlinkProbe: _clone_ref_store(work_root)
    EntryFindings->>PlannedRoot: walk symlink paths
    PlannedRoot->>CloneGit: _git() when path enters .git
    CloneGit->>CloneGit: _CloneGit.of(plan, source, legs, probe, fold)
    PlannedRoot-->>EntryFindings: finding or valid reached path
    EntryFindings->>Materializer: _leg_findings(plan, tree, source, probe)
    alt ambiguous or unreadable .git path
        Materializer-->>Check: plan-root-symlink, refuse before legs
    else known Git path
        Materializer-->>Check: plan accepted, copy beside path or continue
    end
Loading

Sequence diagram for refusing a materializer-blocked shape copy

sequenceDiagram
    participant Plan
    participant RootFindings
    participant Execute
    participant Materializer

    Plan->>RootFindings: _blocked_sentence(...)
    RootFindings-->>Plan: plan-root-blocks-shape-copy
    Plan->>Execute: execute plan
    Execute->>Materializer: place / copy_in_the_way
    Materializer-->>Execute: materialize-copy-blocked
    Execute-->>Plan: refuse before either leg exists
Loading

Flow diagram for pin files reached through kept symlinks

flowchart TD
    A[_pin_findings] --> B[_kept_pin_findings]
    B --> C[_pin_walks]
    C --> D[_PlannedRoot.walks]
    D --> E[_pin_file_reached]
    E -->|walk stopped| F[Root walk reports plan-root-symlink]
    E -->|link reaches pin file| G[Report plan-root-holds-pin]
    G --> H[Remedy: send link to a leg, drop it, or rename source pin]
    E -->|no link reaches file| I[Use literal pin path behavior]
Loading

File-Level Changes

Change Details Files
Model the assembly clone’s .git contents explicitly and refuse ambiguous or impossible symlink destinations before leg creation.
  • Replace broad .git directory counting with named files/directories, unreadable object/leg/reftable stores, branch/reflog entries, and backend-specific behavior.
  • Probe the local Git ref backend and reflog configuration once per command, lazily when a walk enters .git, including defaults and detached-HEAD cases.
  • Improve .. handling so links at shape paths can leave known Git directories while links above shape paths never enter .git.
  • Add targeted findings explaining whether a destination would be written into .git, lacks a writable directory, or cannot be determined before legs exist.
adopt-project.py
Resolve pin files through kept root symlinks and report the actual blocking link with an actionable remediation.
  • Track each root walk’s reached path and use it when evaluating declared and shape-owned pin files.
  • Emit one plan-root-holds-pin finding naming the symlink and suggesting moving or dropping it, or renaming the source pin.
  • Reuse the shared source and Git probe so pin walks do not perform duplicate environment probes.
adopt-project.py
Align copy-collision messaging and coverage with the materializer’s actual behavior at symlink destinations.
  • State that the materializer stops without writing and that execute refuses before creating legs when a copy path is occupied by a file, directory, or symlink.
  • Add a disk-level regression test confirming dangling symlinks remain unchanged and receive no copied file.
adopt-project.py
tests/test_adopt_root_shape_paths.py
Expand regression coverage for Git storage variants, pin traversal, .git path normalization, and probe caching.
  • Cover named versus missing .git entries, object and leg stores, .. traversal, detached HEADs, disabled reflogs, and reftable layouts.
  • Verify adoption beside valid Git-managed paths, refusal of ambiguous paths, pin findings through links, and one ref probe per command.
tests/test_adopt_root_symlinks.py
tests/test_adopt_root_shape_paths.py

Assessment against linked issues

Issue Objective Addressed Explanation
#904 Model the clone’s own .git contents accurately so a root-kept symlink at a shape path is accepted when the copy can safely sit beside it, and refused before any leg exists when the target is absent or cannot be determined. ✅
#904 Handle .. in a symlink target at a shape path by recognizing directories the clone creates in .git, while continuing to refuse paths above a shape path that would lead into .git, and give refusals reasons that reflect the actual behavior. ✅
#904 Check declared and shape-owned pin files through kept root symlinks, and accurately describe and test that the materializer stops without writing when a kept path blocks a shape copy. ✅

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-20T23:55Z) @codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-21T00:08:37.494848Z 8f5fd0f Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8f5fd0fd3e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread adopt-project.py
head = _cloned_branch(source)
guessed = [] if head else ["refs/heads", "refs/remotes"]
if head:
refs += [f"{BRANCH_REFS}{head}", ORIGIN_HEAD]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor the configured clone remote name

When clone.defaultRemoteName is set (for example, to upstream), _commit_the_split runs git clone without --origin, so Git creates .git/refs/remotes/upstream/HEAD, not the hard-coded origin/HEAD modeled here; git clone -h confirms that --origin <name> controls the upstream name. Consequently, a root symlink to the real upstream/HEAD—which the previous broad refs model allowed and which place would safely treat as occupied—is now incorrectly refused as plan-root-symlink. Either force --origin=origin on the assembly clone or read clone.defaultRemoteName when constructing these paths.

Useful? React with 👍 / 👎.

…y repositories and #979's named links beside #904's clone `.git` and pins through links

Ten hunks in adopt-project.py conflicted, seven with #1070 (#979) and three
with #1031 (#910). Each was resolved by reading both sides.

#1070 (#979):
- `_kept_pin_findings` and `_pin_findings` take both parameter sets,
  `named_links` first, as main passes it, then `source` and `probe`. The
  calls pass the last two by keyword. #979's `if at and path in
  named_links: break` was merged into this branch's body as it stands: a
  link `named_links` holds is left out at the pin's file, and a walk that
  meets one through a link above has stopped, so `_pin_file_reached` asks
  the literal path and finds nothing, and the walk's own finding is left
  out too (`walk.empty`).
- Both docstrings of each are kept, #979's paragraph first. The
  `_kept_pin_findings` docstring says why a walk stopped at such a link is
  asked nowhere.
- `_leg_findings` passes `named_links, source=source, probe=probe`, and its
  docstring keeps both sentences.
- The "A SYMLINK OR A SUBMODULE" comment keeps this branch's wording, that
  `place` stops at a link AT the copy and writes nothing through it, and
  drops main's "and as a write THROUGH it", which is not true. #979's
  clause follows it.

#1031 (#910):
- In `_entry_findings`, #1031's `_paths_git_refuses` question comes first,
  and `refused` reaches `_source_path_findings`. The one `_SymlinkProbe` is
  then made before `_leg_findings`, as this branch makes it, because the
  walks of the pins' files ask it. #1031's `linked`, `asked` and
  `unasked_notes` lines and its `probe.notes()` are kept.
- `_SymlinkProbe.__init__` keeps #1031's `unasked` and this branch's
  `_refs`. `ref_store` now does what #1031 has `symlinks` do: a `git init`
  that fails otherwise than with an `OSError` takes git's defaults and is
  kept in `refs_unasked`, where it raised out of `check`.
- `notes()` says each NOTE. The symlink note's words are #1031's, and the
  ref note is new. Both get their git words from one `_init_refused`.
- New rows in tests/test_adopt_root_symlinks.py cover the ref probe's
  failed `git init`, as a CommandFailed and as a Refusal, an OSError giving
  no note, and `check` under `GIT_DEFAULT_HASH=bogus`.

Nothing else #1031 or #1070 changed is called by this branch's code: the
other #1070 hunks are docstrings of `_links_at_a_copy` and `_links_at_a_pin`,
and #1031 leaves `_PlannedRoot`, `_checks_out_symlinks` and
`_refuse_symlinks_checked_out_as_files` as they were.

Lane: openreposhape-5 (openRepoShape-5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 21, 2026
… fetch that carry it run in bootstrap.py's `untraced()` environment, so `make siblings` and `family.py siblings` write the token to no trace the caller asks git for (#1055) (#1075)

`make siblings` and `family.py siblings` now write the token in `SHAPE_LEGS_TOKEN` or `SHAPE_LEGS_APP_TOKEN` to no trace the caller asked git for. #996 (#1045) did the same for `make bootstrap`.

Lane 5 found this in its independent review of #1045 (rv996, #1045 issuecomment-6288783192) and measured it in #1055.

`templates/family-root/scripts/siblings.py` gives the credential's `-c url.https://x-access-token:<token>@github.com/.insteadOf=...` rewrite, in argv, to two gits:
- the clone in `place`, for a member with no clone beside the holder;
- the fetch in `place`, for a member whose clone is there.

Both ran in the caller's own environment. So every trace2 target the caller named wrote the token in the `start` event of each, one line per member. `scrub` kept it out of the output, and only that part held.

Now:
- **`rewrite_environment(token)`**, a new function in `siblings.py`:
  - with a token in use, it returns `untraced()` from the `bootstrap.py` beside it. That is every `GIT_TRACE*` variable taken out, and `GIT_TRACE2`, `GIT_TRACE2_EVENT` and `GIT_TRACE2_PERF` set to `0`, which also turns off a target the global or the system config names;
  - with no token, it returns None, so the two gits keep the caller's environment and stay traced where the caller asked, as before.
- **The clone and the fetch** each run with `env=rewrite_environment(token)`. Nothing else in the file changes environment.
- **The member's own `scripts/bootstrap.py`**, which `place` runs after a clone, is given no token in its argv, and no `env`, so it keeps the caller's environment and its traces. That environment holds `SHAPE_LEGS_TOKEN` and `SHAPE_LEGS_APP_TOKEN` as before; the shipped member bootstrap (`templates/assembly-root/scripts/bootstrap.py`) reads neither. A test row holds this, and so does `tests/test_validator_child_encoding.py`, which reads that call as handing no `env`.
- **#1023's jobserver handling is untouched**: `dispatch` still runs each member's make with `member_make_environment(make)`, and no `MAKEFLAGS` reading changed.
- **The docstrings** now say the promise in full: `git_prefix` says the token is "never written into `.git/config`, never persisted, never printed, and never traced". `place`'s ABSENT paragraph in the module docstring says the same, and `rewrite_environment` says what was measured.

**One thing the issue did not ask for.** `GIT_TRACE_PERFORMANCE` writes a git's whole command line, `-c` included, on git 2.43.0 and 2.47.1 alike. So at main it held the token too, two lines per run, and the rows below set it. `untraced()` already takes it out: every `GIT_TRACE*` variable goes.

This follows #1045's `untraced()` (#996): the clone and the fetch run in the very environment `make bootstrap` gives every git it hands the token.

`siblings.py` imports `untraced` from the `bootstrap.py` beside it, next to `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path` and `members_of`.

The issue left this to the writer, because a holder may keep a `bootstrap.py` older than its `siblings.py` (`update-shape.py apply --accept-local`), and an import of a name that copy lacks would stop `make siblings`, `park` and `resume` with an `ImportError`. The issue's text was written against main `1a47ed07`, where `siblings.py` still kept its own copy of the `MAKEFLAGS` readings. Since then #1023 (`9e154d87`, lane 4's) has decided the question for this pair of files:
- it replaced that copy with an import of `member_make_environment`, because a copy in each script failed SonarCloud's duplication gate;
- it named the price, in the import's comment and its commit: a holder that keeps its own `bootstrap.py` while `apply` writes `siblings.py` is refused, `update-accept-local-missing-name` (#536, #604), before a byte is written, and merges `bootstrap.py` by hand first.

`untraced` takes the same road for the same reasons:
- **One rule.** Which traces a command carrying the token is kept out of has one answer, for `make bootstrap` and `make siblings` alike. A copy here could drift from `bootstrap.py`'s, and the drift would be a token in a file.
- **A kept old `bootstrap.py` is refused in the one-step case, and not in a two-step one.** The guard reads names. `untraced` is new with #996 (#1045), so a kept `bootstrap.py` from before it is refused by that name, where `--accept-local` names it in the same `apply`. Measured with `update-shape.py`'s own readers, in process:
  - `siblings.py` at this head takes `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path`, `members_of` and `untraced` from `bootstrap`;
  - the holder's `bootstrap.py` at main `00f6d7f7` lacks none of them;
  - the holder's `bootstrap.py` at `6476c170` (main just before #1045) lacks `untraced` alone, which is the line the refusal names.
- **The one-step case, measured end to end (rv1075).** A holder cut at `6476c170`, and `apply --at c3cf515 --accept-local scripts/bootstrap.py`: exit 2, `scripts/bootstrap.py (kept) lacks untraced, which scripts/siblings.py imports from it`, and the holder's files, HEAD and status unchanged.
- **Two applies in a row are not refused (rv1075's SHOULD 1, filed as #1082).** A holder whose earlier apply kept a `bootstrap.py` without `untraced` (rv1075 applied `--at 00f6d7f --accept-local scripts/bootstrap.py`: that target's `siblings.py` imports no `untraced`, so there was nothing to refuse), and then an apply at this head without `--accept-local`, gets `siblings.py` copied. `scripts/siblings.py` then fails with `ImportError: cannot import name 'untraced' from 'bootstrap'`, and so do `make siblings`, `park` and `resume`. The cause is the guard in `update-shape.py` (`:4288-4289`): it checks only the files `--accept-local` names in the same run. That is the gap of #536's guard, and it is on main already for #1023's `member_make_environment`; this change puts `untraced` into it and does not widen it. The text above this paragraph said no `ImportError` could reach a holder, which was too strong; #1082 is the follow-up, and it is not done here.
- **No copy, so no test to hold two copies to one answer.** `tests/test_update_shape_kept_helper.py`'s `test_what_siblings_takes_from_the_holders_bootstrap_is_found_defined` already holds that every name `siblings.py` takes is defined in the holder's `bootstrap.py`. Its docstring now says six names, not five.

**Setup:**
- git 2.43.0 (`/usr/bin/git`) and git 2.47.1, Python 3.12.3;
- the family of `tests/test_family.py` (two members, local bare remotes, the file transport allowed through `GIT_CONFIG_COUNT`), laid out as `<tmp>/InkRouter/InkRouter`;
- offline: a stub `gh` first on PATH, a bogus `GH_TOKEN`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`;
- the holder's own `scripts/siblings.py` was main's (sha256 `86bf57e0...`) or this change's;
- the fake token `w1055-a-token-no-forge-takes`.

**Each case was run three ways:**
- `make siblings`, which clones both members;
- `make siblings` again, which fetches both;
- `family.py siblings --family-root <holder>` in a fresh layout, which clones both.

**Traces were asked for in one of three ways:**
- the seven variables `GIT_TRACE`, `GIT_TRACE2`, `GIT_TRACE2_EVENT`, `GIT_TRACE2_PERF`, `GIT_TRACE_PACKET`, `GIT_TRACE_CURL` and `GIT_TRACE_PERFORMANCE`, each set to a file under the run's TMPDIR;
- `trace2.eventTarget`, `normalTarget` and `perfTarget` in a scratch `GIT_CONFIG_GLOBAL`;
- the same three in a scratch `GIT_CONFIG_SYSTEM`.

Each way was run with `SHAPE_LEGS_TOKEN`, with `SHAPE_LEGS_APP_TOKEN`, and with no token.

That is 108 runs at main and at the change, 54 for each git. Each run's TMPDIR and the whole family folder (the clones' `.git/config` included) were searched for the token.

| traces asked by | main, a token set (both variables, both gits) | this change, a token set | no token, either |
|---|---|---|---|
| the seven variables | `trace2-event.json`, `trace2-normal.txt`, `trace2-perf.txt`, `trace-performance.txt`: 2 lines each, in the clone, the fetch and `family.py` alike | none | no token anywhere; the same files written |
| the global config | `cfg-event.json`, `cfg-normal.txt`, `cfg-perf.txt`: 2 lines each | none | the same |
| the system config | `sys-event.json`, `sys-normal.txt`, `sys-perf.txt`: 2 lines each | none | the same |

**What the table rests on:**
- Every one of the 108 runs (and the 54 runs again on this head's final bytes, sha256 `b1e7bb21...`) exited 0 and ended `siblings ok: 2 member(s) beside the holder in ...`, with the token in neither stdout nor stderr.
- The 2 lines in each file are the `start` (or `performance:`) line of the git given the `-c`, one for each member. Each carries the token twice, once for each spelling the rewrite covers.
- With every member a local path, `GIT_TRACE`'s file was written and held no token, and `GIT_TRACE_CURL` wrote nothing, with no http remote. An https member is a different case, measured below.
- With the change and a token set:
  - every trace file but `GIT_TRACE_PACKET`'s was still written, by the run's other gits and the member's own bootstrap;
  - the packet trace was not written, since only the clone and the fetch write one here.

**An https remote, measured offline (rv1075, then the row of Round 2).** The rewrite reaches only `github.com`, and the probes above are offline, with local paths. A member whose `.gitmodules` url is `https://github.com/<org>/<repo>.git` is reached by it, and can be tried offline behind a dead proxy, `http.proxy=http://127.0.0.1:9`, so that nothing leaves the host:
- rv1075 set one member's url so, with `make siblings`, a fake `SHAPE_LEGS_TOKEN`, and the seven variables set to files, on git 2.43.0 and 2.47.1. Main's `siblings.py` (`86bf57e0`) held the token in `trace.txt`, `trace2-event.json`, `trace2-normal.txt`, `trace2-perf.txt` and `trace-performance.txt`; this head held it in no file and no output. The clone failed cleanly, `git clone exited 128`, and `make siblings` exited 2.
- `GIT_TRACE`'s file holds it too, in the `run_command: git remote-https origin https://x-access-token:<token>@github.com/...` lines: 3 lines on git 2.43.0, 4 on 2.47.1 (rv1075).
- Round 2 below holds this in a test row.

**Still not measured:** a github.com clone or fetch that succeeds, since no run here reaches the network.

`tests/test_family_siblings_untraced.py` is new. It uses `tests/test_family.py`'s `family` and `estate`, `offline` from `tests/test_family_add_refusals.py`, `files_holding` and `CREDENTIALS` from `tests/test_family_bootstrap_stops_named.py`, and `NO_CREDENTIAL` and `needs_make` from `tests/test_siblings_family_name.py`. No assert joins conditions with `and`. The token is the fake `w1055-a-token-no-forge-takes`.

**The rows.** Each entry point is crossed with each way traces are asked for:
- entry points: `make siblings` cloning, `make siblings` again fetching (after a first run with no token and no trace), and `family.py siblings` cloning;
- ways: the seven variables, the three targets in a scratch global config (a copy of the session's, which keeps git's automatic maintenance off), and the three targets in a scratch system config.

**Three tests** (the third is round 2's):
- **`test_with_a_token_in_use_the_token_is_in_no_trace`**: 18 rows (3 entries x 3 ways x 2 variables). Each asserts:
  - exit 0 and `siblings ok: 2 member(s) ...`;
  - the token in neither stdout nor stderr;
  - the token in no file under the test's directory: TMPDIR, every trace and config file, and the clones and their `.git/config`;
  - every trace file the way asks for written: 5 for the variables, 3 for each config;
  - the member's own bootstrap traced where a member was cloned: a git traced in a leg of each clone, which only that bootstrap runs. Where nothing was cloned, nothing ran there.
- **`test_with_no_token_the_clone_and_the_fetch_are_traced_as_asked`**: 9 controls (3 entries x 3 ways). Each asserts exit 0, `siblings ok` and the same trace files written. It also asserts the trace2 event file holds `siblings.py`'s own git at each member, read from the events: `git clone -q --no-local ...` to each member's slot, or `git fetch --quiet origin` in each member's clone.

**Fails on main.** The file was run against main's `siblings.py` (sha256 `86bf57e0...`, put in place and then restored, sha256 checked):
- **18 failed, 9 passed**: every row with a token failed, and the 9 controls passed;
- the variables rows found the token in `trace-performance.txt`, `trace2-event.json`, `trace2-normal.txt` and `trace2-perf.txt`;
- the global-config rows found it in `cfg-event.json`, `cfg-normal.txt` and `cfg-perf.txt`;
- the system-config rows found it in `sys-event.json`, `sys-normal.txt` and `sys-perf.txt`.

- **`test_an_https_member_with_a_token_in_use_is_in_no_trace`**: 3 rows, one for each of `CALLERS_CONFIG` (round 2 and round 3, below).

Each mutant was applied alone to the worktree's file and the new test file run (git 2.43.0). The bytes were restored and checked by sha256 after each:
- `siblings.py` sha256 `b776c69b...`: this head's code; it differs from the head only in two docstring lines;
- `bootstrap.py` sha256 `50a16751...`, main's, unchanged here.

All are **killed**.

| mutant | result |
|---|---|
| M1 the token condition dropped: `untraced()`'s environment always | killed, 9 failed (every control) |
| M1b the condition inverted | killed, 27 failed |
| M1c the clone untraced always, the fetch as it is | killed, 6 failed (the clone controls) |
| M2 the clone left in the caller's environment | killed, 12 failed (every clone row with a token) |
| M3 the fetch left in the caller's environment | killed, 6 failed (every fetch row with a token) |
| M4a `GIT_TRACE2_EVENT` not set to `0` (`TRACE2_OFF`) | killed, 12 failed (the global- and system-config rows) |
| M4b `GIT_TRACE2` not set to `0` | killed, 12 failed (the same) |
| M4c `GIT_TRACE2_PERF` not set to `0` | killed, 12 failed (the same) |
| M5 the `GIT_TRACE*` removal dropped (`env = dict(os.environ)`) | killed, 6 failed (the variables rows, by `GIT_TRACE_PERFORMANCE`) |
| M6 the prefix narrowed to `GIT_TRACE2` | killed, 6 failed (the same) |
| M7 the member's own bootstrap run untraced too | killed, 12 failed (the clone rows' bootstrap check) |

M5 and M6 are killed only because the variables rows set `GIT_TRACE_PERFORMANCE`. With a local-path remote, of the issue's six variables none but trace2's writes the `-c`, and trace2's three are set to `0` whatever the prefix does. With an https remote `GIT_TRACE` writes it too, in the `run_command: git remote-https` lines; that is what the row of Round 2 holds, and the mutant R4 below, which keeps the caller's `GIT_TRACE`, passed all 27 earlier rows.

**How every run was made:**
- through lane 5's runner: `/usr/bin/python3 -m pytest -q -rs -p no:cacheprovider -o tmp_path_retention_policy=failed`, one basetemp per run;
- `LANES_*` cleared, a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`;
- `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null` at the start of the session.

| git | files | result |
|---|---|---|
| 2.43.0 | `test_family_siblings_untraced.py`, on this change before the docstring fix | 27 passed |
| 2.43.0 | the same file against main's `siblings.py` | 18 failed, 9 passed |
| 2.43.0 | gate shard A: 19 of the 41 files below | 1623 passed, 4 skipped, in 19:51 |
| 2.43.0 | gate shard B: the other 22 of the 41 files | 2415 passed, 8 skipped, in 15:03 |
| 2.47.1 | `test_family_siblings_untraced.py` | 27 passed, in 1:14 |

**What the 41 gate files are:**
- the new file;
- `test_family_member_make_jobserver.py`;
- every file `grep -l siblings tests/` names;
- the pinned-copy and upgrade-row files: `test_pinned_copy_text.py`, `test_update_shape.py`, `test_update_shape_add.py`, `test_update_shape_kept_helper.py`, `test_no_auto_maintenance.py`, `test_template_text_placeholders.py`, `test_bootstrap_spelling.py`;
- the trio: `test_repo_hygiene.py`, `test_agents_md_codes.py` and `test_cli_reference.py`.

**The gate runs** (both shards at once, then the 2.47.1 run) were made on this change as it is committed: `siblings.py` sha256 `b1e7bb21...` and the new test file sha256 `7ba7a416...`, at main `00f6d7f7` (this branch's base; no rebase was needed, see Merges). The runner's `START` line read `dirty=2`, the two tracked files this commit changes, and a sha256 check after the runs found `siblings.py` unchanged (neither it nor the test files were modified after the runs started). The first pair of shards was stopped part way and re-run whole; only the re-runs count here.

**The 41 files, by shard:**
- shard A (19): `test_shape_doctor.py`, `test_validator_child_encoding.py`, `test_family_py_unreadable.py`, `test_pinned_copy_text.py`, `test_siblings_inputs.py`, `test_siblings_family_name.py`, `test_family_member_make_jobserver.py`, `test_update_shape_add.py`, `test_adopt_printed_sentences.py`, `test_family_printed_quoting.py`, `test_family_makefile_siblings.py`, `test_template_text_placeholders.py`, `test_park_resume_targets.py`, `test_windows_paths.py`, `test_doctor_gitmodules_not_utf8.py`, `test_local_remote_dir_urls.py`, `test_family_siblings_untraced.py`, `test_setup_family_folder.py`, `test_bootstrap_refusal_exit.py`;
- shard B (22): `test_repo_hygiene.py`, `test_doctor_legs_members_next.py`, `test_update_shape.py`, `test_family.py`, `test_update_shape_kept_helper.py`, `test_yaml_reader_spaces.py`, `test_bootstrap_spelling.py`, `test_agents_md_codes.py`, `test_family_siblings_placement.py`, `test_no_auto_maintenance.py`, `test_cli_reference.py`, `test_family_siblings_bootstrap_unreadable.py`, `test_bootstrap_branch_bytes.py`, `test_family_init_asks_every_value.py`, `test_validate_workflow_gitmodules_urls.py`, `test_adopt_contracts_dir.py`, `test_doctor_unfinished_merge.py`, `test_family_members_not_a_list.py`, `test_family_init_landing.py`, `test_family_docs_one_spelling.py`, `test_doctor_members_spelled.py`, `test_pins_child_kind_and_siblings_root_spelling.py`.

**The skips are the environment's, each named by its reason:**
- shard A: 3 in `test_shape_doctor.py` (the PowerShell line is printed, and `powershell.exe` exists, only on a Windows host) and 1 in `test_family_member_make_jobserver.py` (`make -j2` here writes `--jobserver-auth=3,4`, not GNU make 4.4's named pipe);
- shard B: 2 in `test_doctor_legs_members_next.py` (this git crashes on `submodule init`, exit 139, as git 2.43 does on a bare `branch`), 4 in the same file (this git dies on a `shallow` of -2**31, as git 2.43 does), and 2 in `test_no_auto_maintenance.py` (the session began with `GIT_CONFIG_GLOBAL` set, and its file includes that one);
- the 2.47.1 run of the new file skipped none.

The full suite was not run.

**Static checks:**
- flake8 finds in `siblings.py` only the three lines it finds at main (two E501, one E127, each moved down by the added lines);
- in the new test file it finds only the F811s every fixture-importing file has;
- both files are ASCII, apart from the dashes and the CJK example already in `siblings.py`.

Fetched with `-c gc.auto=0 -c maintenance.auto=false`, on 2026-10-20 at about 21:03Z. Round 1, `git merge-tree --write-tree` of the first head `c3cf5154` against (round 2's are in its own section):
- **origin/main `6c861635`**: clean, exit 0, tree `225b2a99`.
  - Main has moved two commits past this branch's base `00f6d7f7`: `9cbba9d5` (#1063: `scripts/family.py`, `scripts/shape_materialize.py`, `docs/cli.md` and two tests) and `6c861635` (#1065: `adopt-project.py` and three tests).
  - Neither touches `siblings.py`, `bootstrap.py`, `tests/test_update_shape_kept_helper.py` or the new test file, so there was no rebase and the gate runs above stand.
- **#1062** (`fix/1040-windows-start-errors`, head `957ba038`), the one open PR that touches a family-root script this change reads (`templates/family-root/scripts/bootstrap.py`, with the assembly root's and `validate-family.py`, `validate-pins.py` and three tests): clean, exit 0, tree `84d4b54a`.
  - Its diff does not touch `untraced()`.
  - In the merged tree `bootstrap.py` still defines `untraced` (and the five other names `siblings.py` takes from it), and `siblings.py` is byte-identical to this head's (sha256 `b1e7bb21...`).
- **The other 9 open PRs** (#1073, #1072, #1071, #1070, #1069, #1068, #1067, #1064, #1031): none touches `siblings.py`, `tests/test_update_shape_kept_helper.py`, or a file the new test imports from (`conftest.py`, `test_family.py`, `test_family_add_refusals.py`, `test_family_bootstrap_stops_named.py`, `test_siblings_family_name.py`), read from `gh pr list --state open --json number,headRefName,headRefOid,files`.

`templates/family-root/scripts/siblings.py` is in `FAMILY_COPIED_VERBATIM`, so each holder gets one `upstream-changed` row for it, as #1045 gave one for `bootstrap.py`:
- a holder that takes it with `bootstrap.py` from #1045 or later needs nothing more;
- a holder that keeps an older `bootstrap.py` with `--accept-local` in the same apply is refused, `update-accept-local-missing-name`, naming `untraced`, before anything is written;
- a holder whose earlier apply kept a `bootstrap.py` without `untraced`, and that then applies this change without `--accept-local`, is not refused: `siblings.py` is copied, and `make siblings`, `park` and `resume` meet an `ImportError`. That is the gap of #536's guard, which `member_make_environment` has on main already, and it is #1082's.

No Windows or macOS run was made, since CI is Linux only (#597).

rv1075, lane 5's independent reviewer, found `c3cf5154` READY (#1075 issuecomment-6302090874): no MUST, two SHOULDs and a NIT. One commit on top of it, `041f7831`, with no rebase: main has touched none of this PR's files.

**The three findings:**
- **SHOULD 1** (the body said no `ImportError` reaches a holder, and two applies in a row make one): answered in the body above, in "Where the helper comes from" and "The pinned copy". The gap is #536's guard's, it is on main for `member_make_environment` already, and it is #1082's; this PR does not change `update-shape.py`.
- **SHOULD 2** (every row used a local file remote, so a leak through `GIT_TRACE` on an https member had no test, and rv1075's mutant R4 passed all 27 rows): answered by the new row below.
- **NIT** (three places said the member's own bootstrap "is given no token"): `siblings.py`'s module docstring, `rewrite_environment`'s docstring and the test file's docstring now say it is given no token in its argv. It inherits `SHAPE_LEGS_TOKEN` and `SHAPE_LEGS_APP_TOKEN` in its environment, because `run_member_bootstrap` passes no `env`, and the shipped one reads neither. `rewrite_environment`'s docstring also names the https measurement.

**The code of `siblings.py` is unchanged.** Its syntax tree with the docstrings taken out is the same at `c3cf5154` and at `041f7831` (compared with `ast`); the file's sha256 moved from `b1e7bb21...` to `19107ad4...` over comments and docstrings only. So round 1's two gate shards (1623 passed, 4 skipped; 2415 passed, 8 skipped) were not re-run; the targeted runs below are.

**The new row,** `test_an_https_member_with_a_token_in_use_is_in_no_trace` (the file has 28 rows now). Its setup:
- the estate of `tests/test_family.py`, with `IRRS`'s url in the holder's `.gitmodules` given as `https://github.com/InkRouter/IRRS.git`, and `IRSS` still a local path;
- `http.proxy=http://127.0.0.1:9`, a dead proxy, in git's command scope after the entries the session holds, and `NO_PROXY` and `no_proxy` empty, so nothing leaves the host;
- a fake `SHAPE_LEGS_TOKEN` and the seven trace variables set to files;
- `make siblings`, cloning.

It asserts:
- the proxy is set where the row sets it: `http.proxy=http://127.0.0.1:9` is in the command scope the run's git reads (`command_scope_at`, which runs `git config --show-origin --list` and writes nothing);
- exit 2 and `IRRS: git clone exited 128:` on stderr, which is how the head fails (rv1075 measured the same);
- `IRSS` was cloned and `IRRS` was not;
- the token is in neither stdout nor stderr, and in no file under the test's directory;
- the five trace files the variables ask for are written, by the run's other gits.

No assert names the proxy in git's message. The git 2.47.1 this lane builds has no `git-remote-https` (`git: 'remote-https' is not a git command`, `fatal: remote helper 'https' aborted session`), so there the clone fails at once without reaching the proxy, still with 128. `GIT_TRACE` has written the `run_command: git remote-https origin https://x-access-token:<token>@github.com/...` line before that, so the row finds the leak on both gits. A first local version asserted the proxy's message and failed on 2.47.1 for that reason; the assert was dropped, and the commit amended, before anything was pushed.

**It fails on main.** The file was run against main's `siblings.py` (sha256 `86bf57e0...`, put in place and then restored, sha256 checked each time):
- git 2.43.0, the whole file: **19 failed, 9 passed**, the 18 rows of round 1 and the new row. The new row fails at `assert files_holding(tmp_path, TOKEN) == []`, with 5 files holding the token: `temp/trace-performance.txt`, `temp/trace.txt`, `temp/trace2-event.json`, `temp/trace2-normal.txt` and `temp/trace2-perf.txt`. The round 1 rows find 4 of those, since a local path leaves `temp/trace.txt` clean;
- git 2.47.1, the row alone: **1 failed**, the same 5 files.

**It kills R4.** R4 is rv1075's mutant, in `rewrite_environment`: `return {**untraced(), **{k: v for k, v in os.environ.items() if k == "GIT_TRACE"}} if token else None`, which keeps the caller's `GIT_TRACE` for the clone and the fetch. Applied alone, then restored and sha256-checked:
- git 2.43.0, the whole file: **1 failed, 27 passed**: the new row alone, with `temp/trace.txt` the one file holding the token;
- git 2.47.1, the row alone: **1 failed**, the same one file.

**The runs on the pushed head `041f7831`** (the runner's `START` line read `dirty=0`), through the same runner as round 1:

| git | files | result |
|---|---|---|
| 2.43.0 | the new file, `test_update_shape_kept_helper.py`, `test_family_makefile_siblings.py`, `test_family_member_make_jobserver.py`, `test_pinned_copy_text.py`, and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) | 589 passed, 1 skipped (the jobserver file's named-pipe row, as in round 1), in 5:09 |
| 2.47.1 (`git version 2.47.1`, printed from the exact path first on PATH) | the new file | 28 passed, in 2:13 |
| 2.43.0 | the new file against main's `siblings.py` | 19 failed, 9 passed |
| 2.47.1 | the new row against main's `siblings.py` | 1 failed |
| 2.43.0 | the new file with R4 | 1 failed, 27 passed |
| 2.47.1 | the new row with R4 | 1 failed |
| 2.43.0 | on a predicted merge with main (below): the new file, `test_update_shape_kept_helper.py`, `test_pinned_copy_text.py`, `test_family_makefile_siblings.py` | 305 passed, in 2:50 |

The full suite was not run. flake8 finds in `siblings.py` the same three lines as at round 1, and in the test file only F811s, from the fixtures it imports. Both files are ASCII, apart from the dashes and the CJK example already in `siblings.py`.

**The merges of round 2.** Fetched with `-c gc.auto=0 -c maintenance.auto=false` at about 23:00Z; `git merge-tree --write-tree` of `041f7831` against:
- **origin/main `c13c3985`** (#1076's squash, which touches `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_stops_named.py`, among others): clean, exit 0, tree `8ae10d0c`. None of the three files this PR changes is among the files main changed since the base `00f6d7f7`, so there is no rebase. In that tree `siblings.py` is byte-identical to this head's, `untraced()` has the same syntax tree as at the base, and `CREDENTIALS` and `files_holding`, which the new file imports from `test_family_bootstrap_stops_named.py`, are still defined. The 305-passed run above is on that tree;
- **#1062** (`fix/1040-windows-start-errors`, head `957ba038`): clean, exit 0, tree `e6ed1ba2`. Its `untraced()` has the same syntax tree as main's;
- **the other open PRs** (#1031, #1069, #1070, #1072, #1073, #1081, #1084): none touches `siblings.py`, `tests/test_update_shape_kept_helper.py`, `bootstrap.py`, or a file the new test imports from, read from `gh pr list --state open --json number,headRefName,headRefOid,files` at about 23:00Z. #1076 (`fix/1056-signal-anywhere`, head `73906948`) was open when the runs were made, and merged clean with this head then (tree `6ae3e65f`); it has landed since as `c13c3985`, above.

**The failure.** Lane 5's landing gate (log `c1075.log`, #1075 issuecomment-6304830183) failed the https row of round 2 at `041f7831`: 1 failed, 356 passed. The change in `siblings.py` was not at fault; the row depended on the caller's git config.
- The gate ran with neither `GIT_CONFIG_GLOBAL=/dev/null` nor `GIT_SSH_COMMAND=false`, which the writers' runs and round 2's runs had.
- `conftest.py`'s session global file includes the caller's global config (or `~/.gitconfig`, where `GIT_CONFIG_GLOBAL` is unset). The operator's `~/.gitconfig` has `url.git@github.com:.insteadOf=https://github.com/`.
- Git keeps the first of two `insteadOf` prefixes of the same length, and the config files are read before the command line, so that rewrite beat the token's `-c` rewrite of the same prefix. Measured with scratch files and a stand-in token, on git 2.43.0: under a global file with that rewrite, `git -c url.https://x-access-token:T@github.com/.insteadOf=https://github.com/ ls-remote --get-url https://github.com/InkRouter/IRRS.git` printed `git@github.com:InkRouter/IRRS.git`.
- So the row's `https://github.com/InkRouter/IRRS.git` went over ssh to `InkRouter/IRRS`, a real private repository. The dead proxy never applied, the clone succeeded, `make siblings` exited 0 and the placed members were both there, and the row failed `assert result.returncode == 2`.

**The fix** is in `tests/test_family_siblings_untraced.py` only. No other file changed in this round, and `siblings.py` is byte-identical to `041f7831`'s (sha256 `19107ad4...`).
- **`hermetic_config`** gives the https row's git a global file of its own, `session_global_config("")` (`conftest.py`'s own function: automatic maintenance off, and no include of the caller's file), `GIT_CONFIG_NOSYSTEM=1` and `GIT_SSH_COMMAND=false`.
  - It is the least invasive way: only the one row with an https url sets it, `conftest.py` and every other row stay as they were, and the other rows' remotes are local paths, which no rewrite of GitHub's url reaches.
  - `GIT_CONFIG_GLOBAL` set takes git's two user-level files out of the reading, and `GIT_CONFIG_NOSYSTEM` the system file whatever `GIT_CONFIG_SYSTEM` says (checked on git 2.43.0 and 2.47.1 with scratch files).
  - `GIT_SSH_COMMAND=false` is a second wall: a url somehow sent over ssh runs `false`. No mutant can show it needed, since the runner exports it too.
- **`url_after_config`** is a new assert before the clone: `git ls-remote --get-url <url>` (it applies the config's `insteadOf` and asks nothing of the network) must give the url unrewritten under the row's environment. A config that reaches the row now fails by name, before any clone.
- **The row is parametrized by `CALLERS_CONFIG`**: `none` (round 2's row), and `global` and `system`, each a stand-in for a caller's config that would send the url somewhere reachable. `as_the_callers_config` writes a scratch file of the test's own, `url.<file URI of the family's own IRRS remote>.insteadOf=https://github.com/InkRouter/IRRS.git`, into this process's `GIT_CONFIG_GLOBAL` (the session file includes it) or `GIT_CONFIG_SYSTEM`. It is built from tmp files only: no network, and never the real `~/.gitconfig`. The file now has 30 rows.
- With the fix, in each of the three, the clone fails at the proxy with git's 128, `make siblings` exits 2, `IRSS` is placed and `IRRS` is not, and the token is in no output and in no file under tmp_path.

**The variants fail without the fix.** Each was applied alone to the worktree's test file (or `siblings.py`) and run through the runner, with the file's sha256 checked after it was restored:
- **`041f7831`'s file as pushed, under a caller's global config.** Lane 5's runner now honours `L5_GIT_CONFIG_GLOBAL=<scratch file>` as the caller's global config, with ssh impossible. The scratch file rewrote `https://github.com/InkRouter/IRRS.git` to the run's own IRRS remote (a `file://` url under the run's basetemp). The whole file, git 2.43.0: **1 failed, 27 passed**, the https row at `assert 0 == 2`, the gate's failure reproduced with no network.
- **T2, `041f7831`'s row plus the variants:** the new file with `hermetic_config` and the `url_after_config` assert taken out. The https rows, git 2.43.0: **2 failed, 1 passed**, `[global]` and `[system]` at `assert 0 == 2` (the clone went to the local remote and exited 0), and `[none]` passes. On git 2.47.1 the same.
- **T1,** the row's global file dropped and the assert kept: `[global]` fails at the assert, which sees `file:///...IRRS.git` for the url; the other two pass.
- **T3,** `GIT_CONFIG_NOSYSTEM` dropped and the assert kept: `[system]` fails at the assert; the other two pass.

**R4 stays killed.** rv1075's R4 (`rewrite_environment` keeps the caller's `GIT_TRACE`), git 2.43.0, the whole file: **3 failed, 27 passed**, the three https rows.

**The runs on the pushed head `962ae43c`** (`START` read `dirty=0`), through the same runner as before:

| git | files | result |
|---|---|---|
| 2.43.0 | the new file, `test_update_shape_kept_helper.py`, and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) | 359 passed, in 3:05 |
| 2.47.1 (`git version 2.47.1`, printed from the exact path first on PATH) | the new file | 30 passed, in 1:51 |
| 2.43.0 | the new file, with `L5_GIT_CONFIG_GLOBAL` naming that scratch file | 30 passed, in 2:21 |
| 2.43.0 | `041f7831`'s file, with the same | 1 failed, 27 passed |
| 2.43.0 / 2.47.1 | T2, the https rows | 2 failed, 1 passed / 2 failed, 1 passed |
| 2.43.0 | T1, T3, the https rows | 1 failed, 2 passed each |
| 2.43.0 | R4, the whole file | 3 failed, 27 passed |

The full suite was not run. flake8 finds in the test file only F811s from the fixtures it imports, and nothing over 79 columns; the file is ASCII.

**The merge-tree of round 3.** Fetched with `-c gc.auto=0 -c maintenance.auto=false` at about 10:53Z; `git merge-tree --write-tree` of `962ae43c` against:
- **origin/main `2a119f28`** (#1092): clean, exit 0, tree `b5ea0114`. Main has landed #1076 (`c13c3985`) and #1062 (`ff0c72eb`) since the last round, and has changed `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_stops_named.py` among others, but none of this PR's three files. In that tree `siblings.py` is byte-identical to this head's, `untraced()` has the same syntax tree as at the base `00f6d7f7`, and `CREDENTIALS` and `files_holding` are still defined where the new file imports them from.
- **#1088** (`fix/1085-nit-texts`, head `88af1b7b`), which touches the same two files of main's: clean, exit 0, tree `365df092`, with the same findings.
- **The other open PRs** (#1087, #1090, #1091, #1097): none touches `siblings.py`, `bootstrap.py`, `tests/test_update_shape_kept_helper.py` or a file the new test imports from, read from `gh pr list --state open --json number,headRefName,headRefOid,files` at about 10:53Z.

Lane: openreposhape-5 (openRepoShape-5)

Review: rv1075, lane 5's independent reviewer, not the writers (w1055 and f1055): READY at `c3cf5154` (issuecomment-6302090874) with no MUST; its two SHOULDs and its NIT were taken in one new commit, and the re-check is READY at `041f7831` (issuecomment-6303900180), with no MUST or SHOULD; its one NIT, a merge-tree number in the body, is fixed in the body. CI (Brett Heap's ruling 27): H0 is the head 962ae43 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 00f6d7f; and the local gate passed on its predicted squash tree fc2afb3 over main 16f9a0e: the family_siblings_untraced, update_shape_kept_helper, repo_hygiene, agents_md_codes, cli_reference sets, 359 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #1055

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…py` says the materializer writes nothing there, each guessed refs directory says what git writes in it, and `_kept_pin_findings` lifts its per-disk finding into helpers

SHOULD 1. The K9 sentence said the materializer would stop "writing
nothing". `place` writes the shape's files in order and stops only at the
blocked copy, so every copy before it is written; it now says "writing
nothing there", as the materializer's own refusal says "nothing was
written there". The comment above it says so too, and `STOPS` in
tests/test_adopt_root_shape_paths.py pins the new words.

SHOULD 2. SonarCloud's two python:S5778 on rows this PR added: the
materializer's link-at-the-copy row and the ref-probe-made-once row each
called a second function inside `pytest.raises`. `template_values()` and
`loaded.open_source(None, work)` are now made before the block.

NIT 1. python:S3776 on `_kept_pin_findings` (30 after the merge, by
Sonar's own analysis of the function): the finding for one pin's file on
one disk is `_kept_pin_finding`, and the through-link words and fix are
`_through_the_kept_link`. Sonar's analysis of the three finds no issue;
the nested conditional of `_shape_pin_held`'s argument is a statement.
What each returns is what the loop wrote: None asks the next disk, `[]`
is #979's named link, one line is the finding.

NIT 2. Where the source's HEAD is on no branch, `.git/refs/remotes`,
`.git/logs/refs/heads` and `.git/logs/refs/remotes` said they held "the
branch it checks out". `_guessed_refs` says what `git clone` writes in
each: the branch, `origin/HEAD` for it, or the reflog of either. The
detached-HEAD row is now four, one per directory.

NIT 3 changes no code. Counted in process, `check` makes the ref probe
once and `execute` twice: once for `check`'s findings and once for
`_refuse_symlinks_checked_out_as_files`' own walks, which make their own
`_SymlinkProbe`, as they ask `_checks_out_symlinks` again on main. The
body no longer says "once per command".

Lane: openreposhape-5 (openRepoShape-5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

Independent review of #1091 (#904) at 8f5fd0fd, for lane openRepoShape-5: NOT READY
Merge commit 1cc9180fd (parents 8f5fd0fd and 2a119f28): its resolution is READY; the PR still waits on the two SHOULDs below, which the merge does not touch.

Nothing is wrong with the behaviour: every case #904 names is fixed and probed on both gits, and the merge keeps #1031 and #1070 intact. What holds it back is one sentence that is still not true and two new Sonar issues, all small fixes.

Findings

SHOULD 1: the new finding sentence says "writing nothing", which is false for most copies.

  • Where: adopt-project.py:7084 (_blocked_sentence; :7225 at the merge) and the test constant STOPS at tests/test_adopt_root_shape_paths.py:84.

  • The sentence: "...it would stop there (materialize-copy-blocked), writing nothing, after both leg repositories were created and pushed...".

  • Scenario: place writes the shape's files in order and stops only at the blocked copy. I kept the root's file and put a dangling link at its copy, then ran materialize_assembly_root at the head. Files already written before the stop:

    Blocked copy Files written first
    shape/README.md 0
    shape/CLAUDE.md 5 (AGENTS.md, README.md, contracts/code-pin.yaml, contracts/spec-pin.yaml, project.yaml)
    shape/Makefile 9
    shape/.github/workflows/validate.yml 12
  • The materializer's own refusal says "and nothing was written there". L1-K9 exists to make this sentence true, so the fix is "writing nothing there" in both places.

SHOULD 2: SonarCloud reports two new S5778 issues on the PR.

  • Both are open, MAJOR, and in rows this PR adds:
    • tests/test_adopt_root_shape_paths.py:1078: template_values() is called inside pytest.raises.
    • tests/test_adopt_root_symlinks.py:2022: loaded.open_source(None, work) is called inside pytest.raises.
  • Scenario: if either call raised a Refusal of its own, the row would pass without testing what it names.
  • Fix: move each call above its with.
  • The quality gate is still OK on every condition, with 0.0% new duplication.

NIT 1: S3776 on _kept_pin_findings (adopt-project.py:686).

NIT 2: one explanation is used for four places it does not fit.

  • GUESSED_BRANCH (adopt-project.py:9941) says "where git clone writes the branch it checks out". It is attached to .git/refs/heads, .git/refs/remotes, .git/logs/refs/heads and .git/logs/refs/remotes.
  • Scenario: a detached source with CLAUDE.md -> .git/refs/remotes/origin/HEAD prints "inside .git/refs/remotes, where git clone writes the branch it checks out". Git writes origin/HEAD there, and reflogs under logs/.
  • The refusal itself is right: the branch is guessed, so the outcome cannot be told before the clone.

NIT 3: the body says the refs probe runs "once per command".

NIT 4: two mutants survive, so two rows are missing.

  • R6: the remedy's "rename" names the kept row (path) instead of the reached file. No row puts a directory at the pin's reached file.
  • R8: a detached source's refs/remotes is no longer treated as unreadable. The detached row only uses refs/heads.
  • Applied together, both pass the two full files: 319 passed, 3 skipped.

Checked and true, with numbers

Probes, offline. Each ran with a stub gh exiting 97, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null and local remotes, with every null leg: answered root. I compared main c13c3985 with the head, and repeated them on the merge.

  • What a fresh clone's .git holds. I emulated execute's git steps: clone --template=, checkout -B adopt/three-repo-shape, two submodule add --force.
    • Files backend, git 2.43.0 and 2.47.1: the model matches exactly. That is HEAD, config, index, packed-refs, refs/heads, refs/tags, the three loose refs (main, adopt/three-repo-shape, origin/HEAD), logs/HEAD and a log of each ref.
    • Reftable, git 2.47.1 (by GIT_DEFAULT_REF_FORMAT and by init.defaultRefFormat): reftable/ with tables.list and one .ref table; refs/heads is a file; no refs/tags, packed-refs or logs.
    • Git 2.43 ignores the reftable setting.
    • With core.logAllRefUpdates=false there is no logs.
    • A reftable source clones as files: the format never comes from the source.
    • A source detached at a branch tip gets a guessed main and an origin/HEAD. One detached elsewhere gets neither.
  • L1-J1.
    • On main, links to .git/refs/remotes/origin/main, logs/refs/heads/nope and modules/nope give check 0 and execute 0, and the shape's CLAUDE.md lands inside the work clone's .git. A link to objects/zz/nothing gives execute 2 after 2 legs.
    • At the head and the merge, all four give check 1, execute 2, 0 legs.
  • L1-J2.
    • .git/refs/../HEAD and .git/../AGENTS.md give check 1 on main, and check 0, "beside", "adoption verified" at the head and the merge.
    • The refusal for a link ABOVE a shape path is kept (scripts -> .git/refs/.. and scripts -> .git), now saying such a link is never followed into .git.
    • Each name the body lists as "also adopt beside" does so at the head: refs/heads/main, refs/remotes/origin/HEAD, logs/refs/heads/main, modules/code, objects/info and refs/tags.
  • L1-K8, on exactly $Q/v115/w764/git247/bin/git, reftable.
    • Main:
      • .git/reftable and tables.list: check 1.
      • .git/packed-refs: check 0, execute 0, and CLAUDE.md is written into .git/packed-refs.
      • .git/refs/tags: the same, written into .git/refs/tags.
      • logs/HEAD and refs/heads/main: stop after 2 legs.
    • Head and merge: reftable, tables.list, refs/heads and HEAD are adopted beside; packed-refs, logs/HEAD, refs/tags, refs/heads/main and a .ref table are refused before any leg.
  • L1-K2.
    • Main: a file, a link to README.md, a directory, a two-link chain and a nested target at the reached pin file all give check 0, execute 0, "verified". The first bootstrap then fails with yaml-unparsable, neutral-pin-unreadable, neutral-pin-missing, yaml-unparsable and yaml-unparsable.
    • Head: all five give check 1 plan-root-holds-pin, execute 2, 0 legs.
    • The control and the dangling case behave as the body's table says.
  • The writer's second case, the shape's own pin through a kept link. Main gives check 0, then execute 1 with adopt-shape-pin-unreadable after 2 legs. The head and the merge give check 1, execute 2, 0 legs.
  • L1-K9. At the head, a link and a file at shape/README.md give the new sentence. execute refuses before any leg on both. The sentence is true apart from SHOULD 1.

The words.

  • No refusal code changed.
  • The changed sentences appear nowhere outside the two test files: I grepped README.md, docs/ (not the release notes), AGENTS.md and templates/.
  • test_agents_md_codes.py passes.
  • Added lines stay within each file's existing width (79 against 84, 80 against 80, 77 against 79), contain no non-ASCII, and no assert joins conditions with and or or.

Rows and mutants.

Runs (all through run9.sh):

Head Git Files Result END
8f5fd0fd 2.43 the two test files 319 passed, 3 skipped (the reftable rows) 11:05:38Z rc=0
8f5fd0fd 2.43 doctor, collisions and the trio 719 passed, 3 skipped 11:11:03Z rc=0
8f5fd0fd 2.47.1 (git version 2.47.1) the two test files 322 passed, no skips 12:38:08Z rc=0
1cc9180fd 2.43 the two test files 323 passed, 3 skipped 12:47:29Z rc=0
1cc9180fd 2.47.1 the merge's 4 new rows plus the ref-probe and reftable rows (-k) 17 passed 12:48:11Z rc=0

The writer's gate logs say 4075 passed, 10 skipped (END 23:41:03Z rc=0) and 4713 passed, 9 skipped (END 23:53:03Z rc=0), both at 8f5fd0fd, over 122 files. I read them and did not re-run them.

Merges.

The merge commit 1cc9180fd, as a delta.

Title and body.

Could not verify

  • git 2.55 (CI), and anything on Windows or macOS.
  • The 18 mutants of the writer's I did not re-run.
  • The writer's two gate shards (read from their logs only) and its gate on the merge tree.
  • The number of refs probes execute makes (NIT 3's second half is read from the code).

Checklist candidates

  • A sentence that says what the materializer does should say "there" when it means one path: it writes earlier files before stopping.
  • New pytest.raises blocks should hold only the one call that is meant to raise (S5778).

Scratch I leave

  • All four worktrees are removed (rv1091-head, -main, -mut, -merge), and both refs/l5rv/rv1091-* refs are deleted. Nothing of mine is running.
  • Left under /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/rv1091/:
    • the run logs and the mutant logs;
    • the probe scripts and their outputs under probes/;
    • resolution.diff;
    • STATE.md;
    • pristine-adopt-project.py.
  • Basetemps are under /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v110/bt/rv1091-* (own243, near243, own247, onmain, mut, merge243, merge247).

(Posted by lane openRepoShape-5's coordinator. rv1091 is lane 5's independent opus reviewer; it wrote none of #1091. The writer, w904, is taking SHOULD 1, SHOULD 2 and the NITs in one fix round with the merge of main, pushed once, and rv1091 then re-checks the delta.)

Lane: openreposhape-5 (openRepoShape-5)

brettheap added a commit that referenced this pull request Oct 21, 2026
… holds without its commit is one it cannot answer for, `check` says a commit it cannot ask was not asked, and a pinned product YAML 1.1 or 1.2 reads as no text is quoted (#912) (#1090)

Each pin question is answered from the commit actually pinned, with words that say why, and a pinned product is written so that any YAML reader reads it back as text. This is #912, which groups three lines of #457's release-commit triage: L1-81, L1-82's second case, and L2-14.

One commit, on main `5e539a87`. It changes `scripts/shape_materialize.py`, `adopt-project.py` and `scripts/bump-leg.py`, and six test files.

The issue's line numbers are at `5017830`. Each claim was measured again by name, on main `6c861635` first and again on `5e539a87`:

- **L1-81 held.** `commit_in_clone` (`scripts/shape_materialize.py:3650` at `6c861635`, `:3809` at `5e539a87`) asks `rev-parse -q --verify <id>^{}` of an annotated tag. In a clone that holds the tag but not its commit, git answers `error: missing object referenced by '<tag>'` (git 2.43 and 2.47.1), and the id was refused as `is an annotated tag that names no commit`. `bump-leg.py`'s `peeled_target` (`:972`, now `:977`) did the same.
- **L1-82's second case held.** `_recomputed_pin_finding` (`adopt-project.py:872`, now `:871`) asks a recorded `commit:` what it names only of a recorded clone that is still at its path and is no partial clone. Where the clone is gone or partial, or `gh api` read the digest, `check` exits 0 on a tag's id with a NOTE that names only the digest.
- **L2-14 held, in a new place.** `_neutral_pin_entry` is no longer `adopt-project.py`'s own. Since #924 both tools call `shape_materialize.neutral_pin_entry` (`:524`), and it still asked only the project's reader. Its docstring even said that a bare `yes`, `on`, `.5` or `.inf` stays bare, naming this issue.
- **Main moved under the change** while it was being tested. The commit was rebased from `6c861635` onto `5e539a87` with no conflict, and its patch is the same (`git patch-id --stable` `427e9e35fd2464cb` both times):
  - #1068 (`6797ca70`) changes `bump-leg.py`'s `_leg_fetch_refspecs` and `fetch_leg`'s docstring, and moves a refspec helper into `shape_materialize.py`. It does not touch `peeled_target`, and tag following under `refs/tags/`, which the new bump row relies on, is unchanged.
  - #1064 (`5e539a87`) changes `shape_materialize.py`'s manifest readers and `adopt-project.py`'s `_manifest_findings` and one import. It does not touch `commit_in_clone`, `neutral_pin_entry` or `_pin_digest_findings`.
  - Every row and every mutant below was run again on the rebased head, and the new rows were run again on `5e539a87`'s code.

### L1-81: a tag whose commit the clone lacks is a clone that cannot answer

- `commit_in_clone` returns `(oid, "")` where a tag's peel cannot be read (`names is None`), as it already does for an id the clone does not hold. `plan` and the scaffold then read the digest there. `git ls-tree` finds no tree at the id, so they refuse it `pin-source-unreadable`, whose remediation says to point `--pin-source` at a clone that HAS the pinned commit, "`git fetch --all` first". That is what main said before #651. `check` says `plan-pin-source-unreadable`.
- `peeled_target` returns the id as typed there. The reachability verdict answers it, as it answers an id the leg does not hold: `bump-leg-commit-not-on-remote`, "<repository> has no branch containing <id>", from both runs.
- A tag of a tree or a blob, which the clone can peel, is still `pin-not-a-commit` or `bump-leg-target-not-a-commit` by name.

### L1-82: `check` says it did not ask a recorded commit it could not ask

- The forge is not asked. `check` and `execute` read no network for a pin (#273's review S3, #449), `execute --local-remote-dir` needs no `gh`, and the rows that hold that (`gh` never run) stay. In a partial clone, git would fetch an id it lacks from the promisor remote.
- So the NOTE says it, in the words the issue's second option asks for. It was:
  `NOTE <entry>: digest NOT rechecked here: <why>. The root's own `validate-pins.py` recomputes it wherever it can read the product.`
  It is now:
  `NOTE <entry>: digest NOT rechecked here, nor its `commit:` asked whether it names a commit: <why>. The root's own `validate-pins.py` recomputes the digest wherever it can read the product; `plan` asks what the commit names where it reads the digest.`
- `<why>` is unchanged for each of the four cases: gone, partial, `gh api`, and a `digest_source:` that names no clone. No refusal code changes, so `AGENTS.md`'s table is unchanged. README, `docs/` and the handbook quote neither sentence.

### L2-14: a product YAML 1.1 or 1.2 reads as no text is double-quoted

- New `shape_materialize.YAML_NOT_TEXT_RE`. Of the plain scalars `PLAIN_SCALAR_RE` takes, which begin with a letter, `_` or `.`, it matches the ones YAML 1.1 or YAML 1.2's core schema resolves to a bool, a null or a float:
  - YAML 1.1's bools `y`, `n`, `yes`, `no`, `on` and `off`, and both versions' `true` and `false`, in their three cases;
  - `null` in its three cases;
  - `.inf` and `.nan` in their three cases;
  - a point before digits, as YAML 1.1's pattern is written (`\.[0-9.]*` with a signed exponent, so `.`, `..` and `.5.5` too);
  - and as its own example `685.230_15e+03` and its readers read it, with `_` among the digits (PyYAML takes `.5_` for 0.5, ruamel.yaml takes `._` for a float), with YAML 1.2's unsigned exponent `.5e3`.
- Nothing else either version resolves to no text is a plain scalar `PLAIN_SCALAR_RE` takes: `~`, the empty null, `<<`, `=`, and ints, timestamps and floats that begin with a digit or a sign.
- `neutral_pin_entry` writes a name bare only where it is a plain scalar, the new rule does not match it, and the project's reader reads it back. So both tools double-quote these names. Every other name is the line it was, `openGlass` byte for byte.
- The plan's `product:`, which `y` writes and only this tool reads, is unchanged. The pin file's `product:` was always double-quoted.

The probe is `probe912.py` (not committed). It runs with a stub `gh` that exits 97 first on PATH, a bogus token, an empty `GH_CONFIG_DIR`, `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_SSH_COMMAND=false`. The `lacking` clone holds the tags `v1` and `v1-again` but not their commit.

| Case | main `6c861635` and `5e539a87` | This head |
| --- | --- | --- |
| `commit_in_clone(lacking, <tag>)` | `(<tag>, 'is an annotated tag that names no commit')` | `(<tag>, '')` |
| `peeled_target(lacking, <tag>)` | `REFUSED bump-leg-target-not-a-commit: --to <tag> is an annotated tag that names no commit in the leg` | returns `<tag>` |
| scaffold `--dry-run` and `plan`, `--pin openGlass@<tag> --pin-source lacking` | exit 2 `REFUSED pin-not-a-commit: ... is an annotated tag that names no commit in the clone at <lacking>, not a commit` | exit 2 `REFUSED pin-source-unreadable: <lacking> could not answer for opensoft/openGlass @ <tag>: ... exited 128: fatal: not a tree object`, remediation "`git fetch --all` first" |
| `check`, recorded clone `lacking` | exit 1 `FINDING plan-pin-not-a-commit: ... which is an annotated tag that names no commit` | exit 1 `FINDING plan-pin-source-unreadable: ... cannot answer for it` |
| `check`, a tag's id, clone whole (control) | exit 1 `plan-pin-not-a-commit`, "an annotated tag of commit <c>" | the same |
| `check`, a tag's id, clone partial / gone / `gh api` / none named | exit 0, NOTE names only the digest | exit 0, the NOTE above; `gh` never ran |
| names `--pin` admits of up to three characters (278915) | 231714 written bare; PyYAML misreads 132 of them, ruamel.yaml 0.19 (YAML 1.2) 132 | 231543 bare; 0 misread by either |

#457 counted 138 at `3dd806f`. On `6c861635`, PyYAML misread `yes`, `no`, `on` and `off` in three cases each, `.0` to `.99`, and `.0_` to `.9_`. ruamel.yaml misread the dot forms, `._`, `._0` to `._9` and `.__`, and its YAML 1.1 mode also reads `y` and `n` as bools.

**Found while testing.** git 2.43's `fetch` follows a remote tag whose object is already in the store, and brings the tag's commit with it. Its tag-following check counts the tag object itself, not only the commit. So in `bump-leg.py` a tag that the leg holds without its commit is a tag the leg's remote does not advertise, or one met after a fetch that failed. The bump row uses a tag that was never pushed.

New rows, in each file's style and with its helpers:

- `tests/test_pin_annotated_tag.py`:
  - a `lacking` fixture (`git hash-object -t tag -w` of the tags' bytes);
  - `commit_in_clone` answers `(id, "")` (2 rows);
  - `plan` and the scaffold's `--dry-run` refuse `pin-source-unreadable` with no "annotated tag" in the output (4);
  - `check` finds `plan-pin-source-unreadable` and `execute` refuses before any leg (1);
  - the NOTE for a tag's id, clone gone, partial or `gh api`, with `gh` never run (3).
- `tests/test_bump_leg_dry_run_truth.py`: a tag the leg holds without its commit is `bump-leg-commit-not-on-remote` in both runs, with nothing written, through `--local-remote-dir` and `origin` (2).
- `tests/test_neutral_pin_entry_one_spelling.py`:
  - each of 25 YAML 1.1 and 1.2 words and 20 dot floats is double-quoted and reads back (45);
  - the same 45, with the project's reader stubbed to read everything back, are double-quoted by the rule alone (45);
  - 20 look-alikes (`openGlass`, `openYes`, `yEs`, `.e5`, `._.` ...) stay bare (20);
  - the whole enumeration, read by PyYAML, ruamel.yaml 1.2 and ruamel.yaml 1.1 where they are installed (1; skipped without either reader, so CI holds the rule by the rows above).
- Changed rows, whose old expectation was the bug:
  - `SPELLED["yes"]`, and the entry-equals-adopt's-old-spelling row, which now expects exactly `.`, `..`, `.0`, `.9`, `._` and `yes` to differ;
  - `.5`, `.inf`, `yes` and `off` move out of the bare groups in `test_scaffold_pin_and_reuse.py` and `test_adopt_pins.py` into `STOCK_MISREAD`;
  - the NOT-rechecked constants in `test_adopt_pins.py`, and one line in `test_pin_source_top_level.py`.

No assert joins conditions with `and` or `or`.

**The rows fail on main.** The six test files were run on main's code, the scripts as on main with the new tests beside them:

- on `6c861635`: 96 failed, 98 passed, and 16 failed, 232 passed;
- on `5e539a87`: 112 failed, 330 passed.

Every failure is a new or changed row:

- spelling 85;
- tag file 10;
- top-level partial-clone NOTE 1;
- `test_adopt_pins.py` 10;
- bump 2;
- scaffold entry 4.

Each mutant was applied alone to a scratch clone of the head and its rows were run. The results were the same on `6c861635` and on the rebased head.

| Mutant | Result |
| --- | --- |
| M1 `commit_in_clone` without the `names is None` return | killed, 3 failed |
| M2 `peeled_target` without it | killed, 2 failed |
| M3 `if not peeled` in place of `if names is None` | survives: equivalent. The two differ only where `rev-parse <id>^{}` prints an id and `cat-file -t` of that id fails, and rev-parse parsed that object to print it. |
| M4 the NOTE's old words | killed, 3 failed |
| M5 `neutral_pin_entry` without the rule | killed, 85 failed |
| M6 rule without `y`/`n` | killed, 9 |
| M7 without `yes`/`no`/`on`/`off` | killed, 28 |
| M8 without `true`/`false`/`null` | killed, 9 (the reader-stubbed rows) |
| M9 without `.inf`/`.nan` | killed, 12 |
| M10 without YAML 1.1's pattern as written | killed, 9 |
| M11 without the `_`-digits pattern | killed, 14 |
| M12 that pattern's exponent signed only | killed, 4 |
| M13 no end anchor | killed, 15 |

All runs go through lane 5's `run9.sh`: a stub `gh` first on PATH, a bogus token, every `LANES_*` variable cleared, `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`, and one basetemp per run.

- **The six test files on the head `d5389eb6a71e`:**
  - git 2.43: 442 passed;
  - git 2.47.1 (first on PATH): 442 passed.
  - They also passed on `6c861635` before the rebase: 442 on 2.47.1, and on 2.43 in groups of 115, 31, 24, 149 and 123.
- **git 2.43, every other test file that runs a changed script.** These are the files that name `adopt-project`, `ADOPT`, `shape_materialize`, `bump-leg` or `BUMP`, plus `test_shape_doctor.py`, `test_scaffold_leg_path_collisions.py` and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`), less the six. That is 177 test files in two shards, 179 paths with `tests/conftest.py` and `tests/gh_guard.py`:
  - shard a, 88 files: 6371 passed, 33 skipped (`END 2026-10-20T23:52:07Z rc=0`);
  - shard b, 91 files: 6296 passed, 3 skipped (`END 2026-10-20T23:51:58Z rc=0`).

`git merge-tree --write-tree` of the head against origin/main and against each open PR that touches a file this changes. Each merge is clean, and no PR's added lines quote the changed words or name the changed functions:

| Against | Head | Result |
| --- | --- | --- |
| origin/main | `5e539a878fc9` | clean, tree `95f9b53262fc` (the head's own tree) |
| #1073 `l3-914-adopt-record-and-doctor` | `b58a4e2ccae0` | clean, `70f85d401ede` |
| #1072 `l3-918-source-and-locale` | `2ffd82356c7b` | clean, `2e341d250ea1` |
| #1071 `l3-916-adopt-test-strength` | `24273dd47e42` | clean, `0d183517f958` |
| #1070 `fix/979-named-link-one-finding` | `20d2d4ba67f6` | clean, `a5beb7be1408` |
| #1031 `fix/910-check-throwaway-repos` | `74c17705edc2` | clean, `324855e1983d` |
| #1081 `fix/1060-pin-lane-in-process` | `7d4f2d82df4b` | clean, `fc9fe407b03b` (it touches none of these files) |

Main moved after the runs above, which were made on `5e539a87`: #1071 (`bccae280`), #1076 (`c13c3985`), #1081 (`06cbd478`) and #1072 (`1e7971cd`) have landed. #1071 and #1072 touch two of this change's files, `tests/test_adopt_pins.py` and `adopt-project.py`. The head merges clean with origin/main `1e7971cd` (tree `ffb8249e`, from `git merge-tree --write-tree`), and the landing gates the predicted squash tree over the main of the day.

#1068 and #1064 have landed and are under this commit. `fix/904-kept-links` was not on origin when this was pushed; it is now #1091, open at `8f5fd0fd`, and merges with this head clean (tree `0153f3e7e3c9`, measured by rv1090). #1062 touches none of these files.

- rv1090, lane 5's independent reviewer, who wrote none of this: READY at `d5389eb6` (issuecomment-6316009648), with no MUST.
- Its SHOULD is a missing row: no row has an exponent of two or more digits, and the head quotes those names correctly.
- Its two code NITs: the no-`origin` bump refusal for a tag the leg holds, and an `else` nothing reaches. With the SHOULD, they are filed as #1103, to land after the v1.2.0 tag.
- Its body NITs are fixed above: 177 test files, one `Claim:` line, and #1091's state.

Review: rv1090, lane 5's independent reviewer, not the writer (w912): READY at `d5389eb6` (#1090 issuecomment-6316009648) with no MUST; its SHOULD (a missing row for exponents of two or more digits, which the head quotes correctly) and its two code NITs are filed as #1103 for after the v1.2.0 tag, and its body NITs are fixed. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 d5389eb passed tests, tests-git-bash and SonarCloud; the head 1ad7605 is a clean update-branch merge of main 7e00834, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree e182fde over main 7e00834: the adopt_pins, bump_leg_dry_run_truth, neutral_pin_entry_one_spelling, pin_annotated_tag, pin_source_top_level, scaffold_pin_and_reuse, repo_hygiene, agents_md_codes, cli_reference sets, 613 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #912

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

Copy link
Copy Markdown
Contributor Author

Re-check of #1091 (#904) at 160de118, for lane openRepoShape-5: READY

Every SHOULD is fixed as I stated it, nothing else changed, and the round's two test files pass. One small test gap from NIT 4 is still open (R6); it doesn't block this round.

Findings at 160de118 (one commit on the merge 1cc9180fd)

SHOULD 1: fixed.

  • _blocked_sentence now says "writing nothing there", and its comment adds that the copies place writes before it are written. The test constant STOPS says the same.
  • The new sentence at the round, from check's output with a link and with a file at shape/README.md: "it would stop there (materialize-copy-blocked), writing nothing there, after both leg repositories were created and pushed, so execute refuses this plan before either exists."
  • No copy of the old "writing nothing," wording is left anywhere in the repo.

SHOULD 2: fixed.

  • template_values() and loaded.open_source(None, work) are now called before their pytest.raises blocks, so each block holds only the call meant to raise.
  • I could not check SonarCloud: the round is on fix/904-kept-links-wip and has not been analysed on the PR yet.

NIT 1: fixed.

  • _kept_pin_findings now hands each disk's finding to _kept_pin_finding, and the "through the symlink" words and remedy to _through_the_kept_link.
  • I read the old and new bodies side by side and they are equivalent:
    • nothing kept -> None, so the next disk is asked, as continue did;
    • a link named_links holds -> [] and a stop, as break did;
    • otherwise the same line, then a stop.
  • By hand the cognitive complexity is about 7 for _kept_pin_findings, 8 for _kept_pin_finding and 1 for _through_the_kept_link, all under 15.
  • The nested conditional expression in the old written = ... line is gone too, since held = mode if at else None is now its own line.

NIT 2: fixed. _guessed_refs gives each folder its own words. Probed end to end with a detached source:

Link target Words
.git/refs/heads/main "where git clone writes the branch it checks out, ..."
.git/refs/remotes/origin/HEAD "where git clone writes origin/HEAD for the branch it checks out, ..."
.git/logs/refs/heads/main "where git clone writes the reflog of the branch it checks out, ..."
.git/logs/refs/remotes/origin/HEAD "where git clone writes the reflog of origin/HEAD for the branch it checks out, ..."
  • Each of the four gives check 1, execute 2, 0 legs.
  • The words are true to what git writes in those folders, which I measured earlier. The refs/heads wording is unchanged.
  • The detached-HEAD test is now four rows.

NIT 3: body only. The draft body-r2.md now says check makes the probe once and execute twice. That matches my count at the merge (one in check) and my reading of _refuse_symlinks_checked_out_as_files (a second in execute). The code is unchanged, as it says.

NIT 4: half closed, the other half still open.

  • R8 (a detached source's refs/remotes no longer counted as unreadable) is now killed, 2 failed of 4 selected, by NIT 2's new rows.
  • R6 (the remedy's "rename" names the kept row instead of the file the walk reaches) still survives: 24 pin rows passed. The scenario is unchanged: with a directory governance/openglass-pin.yaml/ reached through contracts -> governance, the remedy would name the wrong path and no row would notice.
  • The coordinator said these rows may come as a third commit; nothing past 160de118 was on the branch when I fetched.

No other behaviour changed. The diff from 1cc9180fd is these five items only, in adopt-project.py and the two test files. The added lines are within 79 columns and none is non-ASCII.

Numbers

Run (git 2.43, through run9.sh) Selected Result
The two test files at 160de118 all 326 passed, 3 skipped (the reftable rows), END 13:19:00Z rc=0
R6 (helper given path for reached) -k pin survives, 24 passed
R8 (guessed refs/heads only) -k no_branch killed, 2 failed / 4
R14 (old words for every guessed folder) -k no_branch killed, 3 failed / 4
R15 (#1070's named-link skip removed from the helper), in test_adopt_symlink_target_bytes.py -k named_link_at_a_copy_or_a_pin killed, 5 failed / 15
  • The 326 is the merge's 323 plus the 3 new detached rows.
  • Each mutant ran alone in its own worktree, and the file was restored to sha256 6ba7d43e244fe509 after each one.
  • At most two of my runs were going at once.

Could not verify

  • SonarCloud on the round, which has not been analysed yet.
  • git 2.47.1 and 2.55 on the round. The round changes no reftable or probe code, so I ran only 2.43.
  • The PR body as posted: I read only the draft body-r2.md.
  • A third commit with NIT 4's rows, which did not exist yet.

Scratch

  • Worktrees rv1091-wip and rv1091-mut2 are removed, and refs/l5rv/rv1091-wip is deleted. There are no rv1091 refs or worktrees left, and nothing of mine is running.
  • Logs left in /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/rv1091/: run-wip243.log, mut-batch2.log, mut-R6w-rename-the-row.log, mut-R8w-guessed-heads-only.log, mut-R14w-guessed-words-all-heads.log, mut-R15w-named-link-not-skipped.log, mutants2.py, run-mut2.sh, pristine-wip-adopt-project.py.
  • Basetemps: /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v110/bt/rv1091-wip243 and /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v110/bt/rv1091-mut2.

(Posted by lane openRepoShape-5's coordinator. rv1091 is lane 5's independent opus reviewer; it wrote none of #1091. 160de118 is the landing head; R6's missing row goes to a follow-up after the v1.2.0 tag.)

Lane: openreposhape-5 (openRepoShape-5)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 4ed7c7b into main Oct 21, 2026
5 checks passed
@brettheap
brettheap deleted the fix/904-kept-links branch October 21, 2026 15:54
This was referenced Oct 21, 2026
brettheap pushed a commit that referenced this pull request Oct 21, 2026
…opt-project.py` are `OWN`, since `_PlannedRoot`'s two ask `_CloneGit`, the run's model in memory of the clone's `.git`, and not the disk, and `_clone_ref_store`'s asks `git init`'s own throwaway (#430)

Lane 1's landing chain gated the predicted squash over main `4ed7c7bf`
and failed only the `adopt-project.py` hygiene row: #1091 added three
bare guards no entry covered. This merges main `4ed7c7bf` and gives each
an entry.

- `_PlannedRoot._no_directory` and `_PlannedRoot._into_git` ask
  `self._git().is_dir(...)`. `_git()` returns `_CloneGit`, whose
  `is_dir` looks the name up in sets of the names git makes in the
  clone's own `.git`. Measured with every stat, lstat, listing and
  `os.path` test patched to raise, in a folder with no `.git`: it
  answers True, False and None as its sets say and asks the disk
  nothing.
- `_clone_ref_store` asks `.git/reftable` of `git init`'s throwaway, a
  `mkdtemp` of this user's (0700) in the run's own work directory. A
  work directory at 0500 or 0600 stops at the `mkdtemp` first. A git
  template holding `reftable` as a link into a folder at 000 is the one
  way to a refused stat there: 3.12 raises it into `ref_store`, which
  answers git's defaults, and a 3.14-like shim reads False; both answer
  `(False, True)`, as the readable control does, on git 2.43.0.

BENIGN now holds 179 sites in 175 entries (OWN 57 (58)); the head has
190 bare guards in 18 of 25 files, main `4ed7c7bf` 191.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 21, 2026
…kip-tests

Main at 4ed7c7b (#1091, #1101, #1102). tests/test_adopt_root_shape_paths.py
conflicted where both sides added a test after
test_the_materializer_never_writes_a_copy_over_what_is_there: main's
test_the_materializer_stops_at_a_link_at_the_copy (#904) and this branch's
test_the_materializer_names_a_bad_link_at_the_copy_by_its_own_finding
(#1089, N4). Both are kept whole, main's first. adopt-project.py merged
by itself. On the merge, tests/test_adopt_root_shape_paths.py gave 122
passed and tests/test_adopt_symlink_target_bytes.py 133 passed.

Part of #1089

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 21, 2026
…is_dir/exists guard in a shipped tool fails by file and line unless its `_is_a` or a reasoned allowlist covers it, and a `project.yaml` linked into an unsearchable folder is `yaml-unreadable` (#430) (#1111)

## What

Slice Z of lane 1's plan for #430 (issuecomment-6251069236), the slice that lands last. Slices A to H moved each shipped tool's `Path.is_file()`, `is_dir()`, `exists()` and `is_symlink()` guards onto a local `_is_a` (`is_a` in the doctor). From Python 3.14 those methods answer False to any `OSError`, and `os.path.isfile`, `isdir`, `exists`, `lexists` and `islink` always have. The helper treats ENOENT, ENOTDIR, EBADF and ELOOP (and Windows' 21, 123 and 1921) as absent and raises every other `OSError`, so the caller refuses it by name: `<path>: cannot be read (<reason>)`, exit 2.

| Slice | Files | PR |
| --- | --- | --- |
| A | `scripts/bump-leg.py` | #934 |
| B | `update-shape.py` | #976 |
| C | `shape-doctor.py`, `scripts/shape_advisory.py` | #975 |
| D | the assembly root's `validate-manifest.py` and `bootstrap.py` | #958 |
| E | the family root's `validate-family.py` | #956 |
| F | the family root's `siblings.py` and `bootstrap.py` | #951 |
| G | `scripts/family.py` | #955 |
| H | `adopt-project.py` | #970 |

This PR adds `tests/test_path_guard_hygiene.py`, which stops a new bare guard from bringing the old answer back. It also changes one site the scan found in `shape-doctor.py`.

### The hygiene test

- **Which files.** It reads every shipped tool: the files `tests/test_repo_hygiene.py`'s `SHIPPED` lists, and every `*.py` at the top level, in `scripts/`, in `templates/**/scripts/` and in `src/openreposhape/`. That is 25 files today; the glob adds none that `SHIPPED` lacks.
- **What it finds.** It parses each file with `ast` and finds every call of the four methods and the five `os.path` tests. It catches:
  - a call wrapped over lines;
  - the module reached as `os.path`, `posixpath`, `ntpath` or `genericpath`, or through `import os as o` (`o.path`), `import os.path as p`, `from os import path` or `p = os.path`;
  - a test imported by name (`from os.path import lexists`);
  - a guard passed as a function (`filter(os.path.exists, ...)`, `map(Path.is_dir, ...)`).
- **What it does not catch.** It misses a guard named by a string (`getattr(p, "is_file")`, `operator.methodcaller`), the other questions of the same kind (`os.path.ismount`, `is_fifo`, `os.access` with `F_OK`), and a hand-written `stat` whose `OSError` is False. It also misses an alias bound by tuple unpacking, a walrus, a `for` or `with` target, a default parameter, a class attribute, `importlib` or `sys.modules`, and `o = os; o.path.isfile` or `from os.path import *`. A guard bound once at module level (`EX = os.path.exists`) is flagged at the binding, but its later calls are not. Shipped code has none of these except three Windows-only `getattr(os.path, "isjunction", ...)` lookups: `shape-doctor.py` `unclonable`, and `templates/family-root/scripts/siblings.py` `not_a_clone` and `family_link`. Each sits beside an allowlisted `is_symlink` or `islink`, so each is benign in context. The test's `_os_path_names` docstring still says "in no shipped tool today"; it changes with a follow-up.
- **What fails.** A guard fails unless it is on `BENIGN` or `KNOWN_OPEN`, as often as its function makes it. Those lists key each entry by file, enclosing function and the call's own text, never by line, with a count. The failure names `file:line` and the function, and says what to do: use the file's `_is_a`, or copy validate-pins.py's where the file has none (#370, #610). Where the site is benign, it says to add an entry with a tag and a reason.
- **No guard in a helper.** A guard inside a stat helper fails too: the helper must ask `stat` and nothing else. No entry may name one.
- **Entries must stay true.** An entry whose call has gone, has moved function, or names more calls than its function makes fails as stale. So does an entry with no tag from `TAGS`, or with no reason.
- **The scanner and the counts are checked on their own.**
  - The scanner finds 19 written spellings, and finds no `stat` or `lstat`.
  - An entry for one call does not cover two, and an entry for three, or for a call that has gone, is stale.

### The parity test

- **Where the sets come from.** Each top-level `_is_a` and `is_a` in a shipped tool has its sets read from the AST. That covers the twelve the slices wrote, and any added later. The test follows the helper through every module function it calls (`_absent`, `_mode_of`, `_says_absent`, `refused_read`). It reads each `<exc>.errno in <set>` and each `getattr(<exc>, "winerror", None) in <set>`, through the module constant, as `errno.<NAME>` names and numbers. A set it cannot read that way fails; it is not guessed at.
- **What each helper must hold.** Every set must be {ENOENT, ENOTDIR, EBADF, ELOOP} and {21, 123, 1921}. On the path from its `stat` to its `raise`:
  - it may catch only `OSError` and `ValueError`. A handler for `PermissionError`, `Exception`, or a bare `except:` fails;
  - every `except OSError` must hold a bare `raise`;
  - `errno` and `winerror` may be read only as `in <set>`. An `== errno.EACCES` beside the set fails.
- **Helpers must be plain functions.** A class or an `async def` by a helper's name fails.
- **Each slice's helper must still be there.** That way none is held to nothing because it was renamed.
- **The constants.** Every module constant named `*ABSENT_ERRNOS` or `*ABSENT_WINERRORS` must be the one set. There are 24, two per helper.
- **Results on this head.** All twelve helpers hold the set: `adopt-project.py`, `update-shape.py`, `shape-doctor.py` (`is_a`), `scripts/bump-leg.py`, `scripts/family.py`, `scripts/shape_advisory.py`, and the assembly root's `validate-pins.py`, `validate-manifest.py` and `bootstrap.py`, and the family root's `validate-family.py`, `bootstrap.py` and `siblings.py`.

## What the scan finds

On main `4ed7c7bf` the scan finds 191 bare guards in 18 of the 25 files: the 188 of `16f9a0e8`, `6da757cb` and `823624d9`, and three that #1091 added to `adopt-project.py`. None is inside a helper.

- **1 is the real site below, changed here.** On this head the scan finds 190.
- **179 are on `BENIGN`, in 175 entries.** Each carries the plan's own reason for leaving a site out (lane 1's site table at `5017830`) and a line of its own:

| Tag | Entries (sites) | What it means |
| --- | --- | --- |
| OWN | 57 (58) | the tool's own checkout of the standard, its templates or package, its cache, a temporary or work directory it made, or what this run wrote |
| DELIBERATE | 63 (65) | a deliberate `os.path` call or guard whose False the code relies on and says why: wording, advice, `family.py`'s members-spelling twins, or a check a by-name refusal follows (#608, #609, #798, #735, #784, #793, #764) |
| ASKED | 25 (25) | asked only after the stat helper, a `stat`, `mount_read` or git asked the path or its folder, or inside a `try` that keeps the `OSError` |
| ROOT | 20 (21) | a name directly in the root: asked by `lstat`, or where a link at the name is left out or handled as a link first, in a root the tool refuses by name first where it cannot search it |
| DEST | 9 (9) | the plan's OPEN-DEST, loud: a destination asked before a create or a clone, which then fails by name |
| PINNED | 1 (1) | `scripts/repo_shape.py`'s `find_repo_root`, which the plan left (pinned, low) |

- **#1091's three are `OWN`.** None needs #1110.
  - `_PlannedRoot._no_directory` and `_PlannedRoot._into_git` ask `self._git().is_dir(...)`. `_git()` returns `_CloneGit`, `check`'s model in memory of the names git makes in the clone's own `.git` (#904). Its `is_dir` is a set lookup, not the disk. Measured in a folder with no `.git`, with every `stat`, `lstat`, listing and `os.path` test patched to raise: it answers True, False and None as its sets say, and asks the disk nothing.
  - `_clone_ref_store` asks `(probe / CLONE_GIT_DIR / "reftable").is_dir()` of `git init`'s throwaway, a `mkdtemp` of this user's (0700) in the run's own work directory. A work directory at 0500 or 0600 stops at the `mkdtemp` before the guard is asked, and `ref_store` answers git's defaults. The one way to a refused `stat` there is a git template holding `reftable` as a link into a folder at 000. Then 3.12 raises into `ref_store`, which answers git's defaults, and the shim reads False. Both answer `(False, True)` on git 2.43.0, as the readable control does.
- **11 are on `KNOWN_OPEN`.** These are real sites this slice found and did not change. Each is left to the follow-up issue #1110, which is not in v1.2.0. In each, a name in the root that is a link into a folder this user cannot search is still taken for nothing there. Every "under the shim" result below was measured with a 3.14-like `sitecustomize`.

| Site | What happens |
| --- | --- |
| `scripts/family.py` `_gitmodules_names` | measured in process: on 3.12 a `PermissionError` raised out of `remove`; under the shim `(None, [])`, so `remove --dry-run` says no section is removed. Slice G left it for this slice to flag. A change needs a refusal threaded through `remove` and its rollback. |
| `scripts/family.py` `_sections_giving` | the same file: `[]` under the shim |
| `templates/family-root/scripts/siblings.py` `submodule_urls` | the same, in a pinned copy, which this slice does not edit |
| `shape-doctor.py` `check_agent_files` | an agent file linked that way is `absent` in the `agent files` note, which never moves the verdict |
| `shape-doctor.py` `check_what_is_here` | the same, for the NOT A SHAPE ROOT row's facts |
| `shape-doctor.py` `check_the_way_in` | a `.git` linked that way: under the shim the `way-in` row says there is no repository to adopt, and sends the person to `setup.sh` to scaffold three new ones; on 3.12 the row names `.git: cannot be read` |
| `shape-doctor.py` `check_the_other_way_in` | the same `.git`: the `way-in-scaffold` row is left out |
| `shape-doctor.py` `origin_org` | the same `.git`, measured alone: `None` under the shim, the answer for a folder that is no repository (`'acme'` in the readable control); 3.12 raises `PermissionError` |
| `shape-doctor.py` `repo_local_origin_name` | the same `.git`, measured alone: `None` under the shim (`'Orbit'` in the control), so the `naming` row classifies `Atlas` alone, not `Atlas, Orbit`; 3.12 raises |
| `scripts/shape_advisory.py` `_read` | the same `.git`: `read()` says `not-a-repository` under the shim (`advise` in the control), so the Triad advisory row is left out |
| `scripts/shape_advisory.py` `repository_names` | the same `.git`, measured alone: `['Atlas']` under the shim (`['Atlas', 'Orbit']` in the control); 3.12 raises |

## The site changed here: the doctor's own manifest

`shape-doctor.py`'s `Context.__init__` asked `path.is_file()` of the root's `project.yaml` and `family.yaml`. Slice C's root check in `main` asks only whether the root can be searched, so a `project.yaml` that is a link into a folder this user cannot search got past it.

It is now asked with `is_a`, and a refused `stat` is loaded (`Context._to_load`). `load_yaml` then refuses it as it refuses a file it cannot open, and the `manifest` row names it, as it already did for a `project.yaml` at mode 000 (#205).

Measured on a root holding `project.yaml -> ../locked/project.yaml` with `locked/` at 000, Python 3.12.3, and the 3.14-like shim:

| | Before (`16f9a0e8`) | After |
| --- | --- | --- |
| 3.12 | `PermissionError` traceback at `Context.__init__`, exit 1 | `kind assembly root (project.yaml, which does not load)`, `FINDING manifest ... REFUSED yaml-unreadable: .../project.yaml: cannot be read (Permission denied)`, `INVALID (manifest, manifest-kinds)`, exit 1 |
| 3.14-like | `NOT A SHAPE ROOT`, exit 2, with `adopt-project.py plan --source ...` as the NEXT, over a project | byte for byte the 3.12 report above |

The `manifest kinds` row names the same path with the `chmod` cure, in validate-pins.py's words. Where the `stat` succeeds, `is_a(path, stat.S_ISREG)` answers as `is_file()` did, so every other root reads as it did. An I/O error from the `stat` is loaded and named the same way.

**Out of scope here (r1111's N5).** The row this case now shows carries two older pieces of wording, from #205 and #847, which this PR leaves alone:
- the `manifest` row's WHY for `yaml-unreadable` speaks of "a merge conflict resolved, a hand edit";
- the `chmod` cure says "every directory above it inside the root", where the locked folder lies outside the root.

## Mutants

Each mutant was applied once to a scratch copy of the head (`git archive` of the merge with main, with the new test file laid over it), and the new file was run there. The file was then put back from its bytes and checked by sha256. The worktree was never changed.

- **Round 1: 5 of 5 killed.**

| Mutant | Fails |
| --- | --- |
| M1: `scripts/bump-leg.py` `_validators_readable` back to `path.is_file()` | the hygiene test for that file, naming `scripts/bump-leg.py:1474` |
| M2: `errno.EACCES` added to `validate-pins.py`'s `_ABSENT_ERRNOS` | both parity tests |
| M3: 1921 dropped from `update-shape.py`'s `_ABSENT_WINERRORS` | both parity tests |
| M4: the allowlisted `path.is_file()` in `update-shape.py`'s `_extend_the_eol_block` taken out | the stale-entry test |
| M5: `Context.__init__` back to `path.is_file()` | the doctor tests |

- **Round 2: 17 of 17 killed.**

| Mutant | Fails |
| --- | --- |
| r1111's X3: a guard through `import os as _os` | the hygiene test for that file |
| r1111's X4: `if not path.exists(): return False` inside bump-leg's `_is_a` | the hygiene test (a guard in the helper) |
| r1111's X5: `except PermissionError: return False` before `except OSError` | parity: `catches PermissionError` |
| r1111's X6: `or exc.errno == errno.EACCES` beside the set | parity: `reads exc.errno other than as in <set>` |
| r1111's X15: a class `_is_a` holding a bare guard | hygiene and parity |
| r1111's X18: `except Exception: return False` before `except OSError` | parity: `catches Exception` |
| r1111's T2: the stale check disabled | the count self-test |
| r1111's T3: one entry silences any count | the count self-test |
| round 1's W1 and W4 | hygiene, and the stale-entry test |
| Y1: the handler rule loosened | the parity self-test |
| Y2: an `errno` read other than `in` no longer refused | the parity self-test |
| Y3: `import os as o` no longer followed | the scanner self-test |
| Y4: `p = os.path` no longer followed | the scanner self-test |
| Y5: the helper exemption put back | the scanner self-test |
| Y6: a class or `async def` by a helper's name read as a function | the parity self-test |
| Y7: `except PermissionError` in `_to_load` (r1111's X12) | the doctor test's I/O-error row |

- **Round 3, #1091's sites: 3 of 3 bite.** Each new entry was taken out alone, on the merge with main `4ed7c7bf`. Each run gave 1 failed and 48 passed.

| Entry taken out | Fails |
| --- | --- |
| B1: `_PlannedRoot._no_directory` | the hygiene test for `adopt-project.py`, naming `adopt-project.py:10785` |
| B2: `_PlannedRoot._into_git` | the same test, naming `adopt-project.py:10817` |
| B3: `_clone_ref_store` | the same test, naming `adopt-project.py:11113` |

- **The file's self-tests.** Slice A's helper passes the parity test, and eleven one-line changes to it are each named: three to its sets, the re-raise, a constant's name, three handlers, `==`, an `async def` and a class.

## Tests

- **`tests/test_path_guard_hygiene.py`, new: 49 tests.**
  - 25 hygiene rows, one per tool.
  - The stale-entry, tag, scanner, count and parity tests.
  - Five tests for the doctor change:
    - two in process (`refused_stat` and a refused `read_text`, for a permission and for an I/O error), on every Python and as root;
    - two controls, nothing there and a directory there, which still read as no manifest;
    - one mode case run as a person runs the doctor, skipped on Windows and as root.
- **Against main's tools** (main `4ed7c7bf`, with the new file laid over it), 45 pass and 4 fail. The 4 are the doctor's unlisted `Context.__init__` guard and its three rows: the two in-process rows, the I/O-error row among them, and the mode case. On this head all 49 pass.
- **The gate on head `d286ae9a`.** The head holds main `4ed7c7bf` through the plain merge `8bc4d1b2`, with tree `fc0731ea`; the branch's first merge, `03c16cb9`, took `6da757cb`. Against main, the head changes only `shape-doctor.py` and the new file. The gate ran on the head itself, with Python 3.12.3, git 2.43.0, a stub `gh` first on `PATH`, `GH_TOKEN` bogus, an empty `GH_CONFIG_DIR`, `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_SSH_COMMAND=false`:
  - the new file, `tests/test_repo_hygiene.py`, `tests/test_cli_reference.py`, `tests/test_agents_md_codes.py`, `tests/test_doctor_unreadable_paths.py` and `tests/test_doctor_unreadable_pin.py`: 305 passed;
  - `tests/test_shape_doctor.py`, 448 tests in four shards: 445 passed and 3 skipped (118 passed; 110 passed; 117 passed; 100 passed and 3 skipped);
  - `tests/test_scaffold_leg_path_collisions.py`: 104 passed.
- No `--help` text changes, so `docs/cli.md` is unchanged. No pinned copy changes, so no project or holder gets an `upstream-changed` row.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its reviewed head `d286ae9a` is green on Linux and SonarCloud. The branch merged main twice and was never rebased; the last merge is update-branch's, clean, giving d286ae9, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: the 8 test files (test_path_guard_hygiene, test_shape_doctor, test_scaffold_leg_path_collisions, test_repo_hygiene, test_cli_reference, test_agents_md_codes, test_doctor_unreadable_paths and test_doctor_unreadable_pin), 854 passed and 3 skipped.

Closes #430

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 21, 2026
….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>
brettheap added a commit that referenced this pull request Oct 23, 2026
…eps at a pin's file reached through a kept link, and holds that `check` renames the path the walk reaches, not the file kept in it, so rv1091's R6 no longer survives (#1116) (#1140)

One row that holds the remedy `check` gives where a kept link leads a pin's walk to a directory, so that rv1091's mutant R6 (NIT 4 of its review of #1091, issuecomment-6316443939) is killed. This is a test-only change: `tests/test_adopt_root_symlinks.py` gains one row, and no production file, refusal code or sentence changes. It is a follow-up to #1091 (#904), so Brett Heap's ruling of 2026-10-21T10:32:04Z left it out of v1.2.0, and it is on the v1.2.1 milestone.

- #1091 landed as `4ed7c7bf` and what it ships is right; only this row was missing.
- `_kept_pin_finding` words a kept pin's finding through a kept link with `_through_the_kept_link(link, reached, entry)`. Its remedy says to send the link to a leg or `drop` it, "or rename `<reached>` in the source and re-run `plan`", where `reached` is the file the root walk reaches.
- R6 hands that "rename" the kept row's own path (`path`) in place of `reached` (line 778: `link, reached, entry` becomes `link, path, entry`).
- No row put a directory at a pin's reached file (rv1091's NIT 4), so in each of them `path` and `reached` are the same string and R6 passed all of them. With R6 applied alone, the two adopt-root files give `1 failed, 326 passed, 3 skipped`, and the one failure is the new row, so main's 326 other rows do not notice it.

`test_a_directory_at_a_pins_file_through_a_link_names_what_it_reaches`, at `tests/test_adopt_root_symlinks.py:1173`, 30 added lines, in the file's style and on its helpers (`planned`, `declare`, `check`, `held_pins`, `symlink_findings`, `refused_by`, `execute_args`, `no_leg_was_created`, `ticked`):

- the plan keeps `contracts -> governance`, keeps `governance/`, and puts a directory at `governance/openglass-pin.yaml` holding `notes.md`; a pin is declared on `openGlass`;
- `check` exits 1, with no symlink finding;
- its one `plan-root-holds-pin` line names the file `governance/openglass-pin.yaml/notes.md` kept under `governance/`, inside `contracts/openglass-pin.yaml` through the symlink `contracts` to `governance`, and its remedy renames `governance/openglass-pin.yaml`, the path the walk reaches, not the file kept in it;
- `execute` refuses with `plan-root-holds-pin` and the same line before any leg.

No assert joins conditions with `and` or `or`, the added lines are at most 76 wide (the file's widest is 80) and ASCII. The docstring says that before #1091 `check` passed this plan, measured on main `823624d9`.

R6 alone, in a scratch tree at this head: `adopt-project.py` sha256 `0c1c1f3d7abf41136a61d7d4651f13fef5d3642a3162c024e921429b88bd7c1f` before, `9a921c6a9261234a68ef930e478421dca2d7135ad8674b357f2f55f46f773068` with R6, and restored to the first (checked), tree clean.

| Where | Result |
|---|---|
| the row, with R6 | `1 failed, 208 deselected`: `assert held_pins(checked) == [line]` at `:1196`, the finding reading "rename `governance/openglass-pin.yaml/notes.md`" |
| both adopt-root files, with R6, git 2.43 | `1 failed, 326 passed, 3 skipped`; the failed id is the new row alone |
| the row, on `823624d9`'s `adopt-project.py` (before #1091) | `1 failed, 208 deselected`: `assert 0 == 1`, `check` said `plan ok` |

All through `run9.sh` (stub `gh`, bogus token, `GIT_SSH_COMMAND=false`, no global git config), at head `2b6db57a3`:

| Git | Files | Result | END |
|---|---|---|---|
| 2.43.0 | `tests/test_adopt_root_symlinks.py` and `tests/test_adopt_root_shape_paths.py` | 327 passed, 3 skipped (the reftable rows at 1956, 2011 and 2562) | 2026-10-21T16:21:00Z rc=0 |
| 2.43.0 | `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py` | 170 passed | 2026-10-21T16:23:02Z rc=0 |
| 2.47.1 (`git version 2.47.1`) | `tests/test_adopt_root_symlinks.py` | 209 passed, no skips | 2026-10-21T16:31:33Z rc=0 |

The 2.43 count is 327 with the row, which is the 326 other rows plus it. `tests/test_agents_md_codes.py` passes, as no refusal code changed.

- `origin/main` is `4ed7c7bf`, unmoved when fetched at the start, mid-way and before the push. `git merge-tree --write-tree origin/main HEAD` is clean, tree `d675b07f29edb9a061c3bc542dc2a76334d1e1a0`, the head's own tree: the branch differs from main by this one file, 30 insertions.
- None of the 22 open PRs touched `tests/test_adopt_root_symlinks.py` when this was written.
- **NIT round 1, 2026-10-22:**
  - origin/main `038dafba9dbedd9405b316cc749a04dffe02bb36` (#1111) is merged in as `e7623ed98`, cleanly, with tree `8a5cf7b1`, as rv1140 predicted.
  - The head `94712c3bc076142c16e48af70c218e6e958bd005` merges with main at its own tree, `f8faecc8cbcefbb87453f28647baf7a636ee1825`.
  - Of the 28 open PRs, #1119 (head `bba168bd`) now edits `tests/test_adopt_root_symlinks.py`. Its merge-tree with this head is clean, tree `81a0397437484f52b52789a86a2b5da43f712dae`.
- As a check on the nearest neighbour, #1118 (`c60066cbe`, which edits `tests/test_adopt_root_shape_paths.py` and was cut at `6da757cb`) conflicts in that file with `origin/main` alone, and with this branch the same: that is #1118's, not this row's.

This round is one merge of main and one new commit, after rv1140's review: READY at `2b6db57a`, with NITs (#1140 issuecomment-6341111415).

- **The row now keeps two files** under `governance/openglass-pin.yaml/`: `README.md` and `notes.md`.
  - Git's order puts `README.md` first, so the one `plan-root-holds-pin` line names `governance/openglass-pin.yaml/README.md`. The sections above describe the first commit's one-file row.
  - This kills rv1140's mutant M3, which changes `rows[0]` to `rows[-1]` at `adopt-project.py:770`. M3 survived the one-file row.
  - The docstring names the sort order.
- **Mutants, the row alone,** each applied alone and restored with its sha256 checked:
  - M3: killed (it survived before);
  - R6: still killed;
  - rv1140's M1, M2 and M4 to M7: all killed.
- **Runs at `94712c3bc`:**
  - git 2.43.0, `tests/test_adopt_root_symlinks.py` and `tests/test_path_guard_hygiene.py`: **255 passed, 3 skipped**. The skips are the reftable rows.
  - git 2.47.1, the same files: **258 passed**.

Review: lane 5's independent re-check, by a reviewer that wrote none of #1140: READY at `94712c3b`, the head that lands (issuecomment-6344746584), with no MUST, SHOULD or NIT. It checked the delta from `2b6db57a`, which rv1140 passed (issuecomment-6341111415). CI (Brett Heap's ruling 27): H0 is the head 94712c3 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 038dafb; and the local gate passed on its predicted squash tree 19f483e over main dea964a: the adopt_root_symlinks, repo_hygiene, agents_md_codes, cli_reference sets, 377 passed, 3 skipped. 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 #1116

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 23, 2026
…40` end in `\Z`, not `$`, so a pin's `commit:` or `tree_sha256:` that ends in a line break is refused as not 40 or 64 hex by every validator and `adopt-project.py` (#1017) (#1126)

`scripts/repo_shape.py` ends `COMMIT_RE` and `SHA256_RE` in `$`, and every caller reads them with `.match`. In Python `$` also matches just before a trailing line feed, so a pin's `commit:` or `tree_sha256:` that ends in a line break passed as well formed. This makes both patterns refuse it, wherever a value is decided to be valid, and changes nothing else about them.

Measured on main (`2a119f28`, then re-checked on `6da757cbe`; the use sites of both patterns are the same on `823624d93`, and no new copy of either pattern was added). Python 3.12.3:

| pattern | value | `.match` on main |
|---|---|---|
| `COMMIT_RE` / `SHA256_RE` | 40 / 64 hex | a match |
| same | hex and a line feed | a match (the defect) |
| same | hex and a carriage return and line feed | None |
| same | hex and a space | None |

A line feed reaches the patterns through YAML: the double-quoted escape `"<hex>\n"` and a block scalar both read as the hex and a line feed. On main:

- `validate-manifest.py`: `shape.commit` and `shape.digests.tree_sha256` ending in a line feed -> `manifest ok`, exit 0.
- `validate-family.py`: `shape.digests.tree_sha256` in `family.yaml` ending in a line feed passed (`family ok`, exit 0). `shape.commit` did not pass: it exited 1 with `shape-pin-manifest-disagree`, because `contracts/shape-pin.yaml` and `family.yaml`'s `shape.commit` differ only by the line feed, so the same 40 hex is printed on both sides; the finding that names the commit as not 40 hex, `family-shape-commit`, was not raised. A member pin's `tree_sha256` ended in a line feed gave `member-digest-mismatch`, not the malformed finding, and a member pin's `commit` gave `member-commit-unresolvable`, not `member-pin-commit`.
- `validate-pins.py`: a leg pin's commit gave exit 2 `pin-commit-unresolvable`, not `pin-tag-only`; a leg digest gave `pin-digest-mismatch`, not `pin-digest-malformed`; a shape pin's commit gave only `shape-pin-manifest-disagree`, not `shape-pin-commit`.
- `adopt-project.py check` (`_pin_row_problem`): the row read as well formed, with no `plan-pin-malformed`.
- `templates/assembly-root/scripts/bootstrap.py` holds a copy of the commit pattern, `COMMIT_40`, with the same defect: `shape_upstream_notice` printed its notice for a pin ending in a line feed.
- `shape-doctor.py` `pin_commit` returned `"<hex>\n"` for such a leg pin.

Checked and not affected: `bump-leg.py --to` and `family.py --at/--to` go through `checked_value` (a `fullmatch` that already refuses a line feed); `bump-leg.py` line 2212 strips first; `update-shape.py` line 558 reads `git_out`, which is stripped; `shape-doctor`'s `recorded_gitlink` splits fields; `src/openreposhape/standard.py` uses `fullmatch`. The `@(?P<sha>[0-9a-fA-F]{40})` patterns in `bump-leg.py` and `validate-pins.py` are unanchored workflow-ref patterns.

- `scripts/repo_shape.py`: `COMMIT_RE` and `SHA256_RE` end in `\Z`, not `$` (comment added). `\Z` matches only at the very end of the value, so each caller's `.match` refuses a line feed as it already refused a carriage return and line feed, and a space. 40 and 64 digits, either case, are unchanged.
- `templates/assembly-root/scripts/bootstrap.py`: `COMMIT_40` likewise (comment added).
- Nothing else in production changes. No README, doc, handbook or `AGENTS.md` text pins the old pattern bytes (grepped), and `tests/test_pinned_copy_text.py` and every `tests/test_bootstrap*.py` pass unchanged (shard tB and shard tA).
- Rows:
  - new `tests/test_pin_pattern_line_break.py` (46 rows: 42 at first, and 4 added in fix round 1, below): the three patterns x case, length, after-suffix (line feed, carriage return and line feed, space, two line feeds, carriage return, tab) and before-prefix; the holder's gate (four rows and a baseline); the bootstrap upstream notice (two rows); the doctor's `pin_commit` (two rows);
  - `tests/test_pin_validator.py`: 3 rows (leg commit, leg digest, shape-pin commit);
  - `tests/test_manifest_validator.py`: 2 rows (`shape.commit`, shape digest) and a helper `shape_of`;
  - `tests/test_adopt_pins.py`: 2 `malformed()` cases (`commit-line-break`, `digest-line-break`);
  - `tests/test_shape_doctor.py`: 1 `ROOT_PIN_FAULTS` param (`a-commit-ending-in-a-line-break`).

Run with the two production files of main checked out, on a head with the same tree as this one (git 2.43.0, `EMAIL=service@opensoft.dev`):

- the new file, `test_pin_validator.py`, `test_manifest_validator.py`, `test_adopt_pins.py`: **16 failed, 307 passed** in 294.83s. The 16:
  - new file (9): `line-feed-COMMIT_RE`, `line-feed-SHA256_RE`, `line-feed-bootstrap-COMMIT_40`, `test_a_member_pin_commit_that_ends_in_a_line_break_is_refused`, `test_a_member_pin_digest_that_ends_in_a_line_break_is_malformed`, `test_a_family_shape_commit_that_ends_in_a_line_break_is_a_finding`, `test_a_family_shape_digest_that_ends_in_a_line_break_is_a_finding`, `test_the_upstream_notice_is_silent_over_a_pin_that_ends_in_a_line_break`, `test_the_doctor_reads_a_pins_commit_as_the_validators_do[40-hex-and-a-line-feed]`;
  - `test_pin_validator.py` (3): a commit, a digest and a shape-pin commit that end in a line break;
  - `test_manifest_validator.py` (2): a shape commit and a shape digest;
  - `test_adopt_pins.py` (2): `plan-pin-malformed-commit-line-break`, `plan-pin-malformed-digest-line-break`;
- `test_shape_doctor.py -k a_root_pin_that_does_not_read`: **1 failed, 12 passed, 436 deselected** in 57.84s (`a-commit-ending-in-a-line-break`).

With the change the same files pass (see the runs).

| mutant | failed | passed |
|---|---|---|
| M1: both `repo_shape` patterns back to `$` | 11 | 35 |
| M2: only `COMMIT_RE` back to `$` | 7 | 39 |
| M3: only `SHA256_RE` back to `$` | 4 | 42 |
| M4: only the bootstrap `COMMIT_40` back to `$` | 2 | 44 |

Re-measured in fix round 2, at `4c1f80cf`, on the 46-row file. The first round measured its 42 rows: 7/35, 4/38, 3/39 and 2/40. M1 is M2 and M3 together.

Each is killed by at least one row, and each pattern is killed by a row of its own. Every mutant was undone: the production files are byte for byte (`cmp`) their backups of this change.

git 2.43.0 (`/usr/bin/git`):

- shard tA, 35 files: **2782 passed, 8 skipped** in 4138.49s, rc=0;
- shard tB, 39 files: **2630 passed, 6 skipped** in 4532.76s, rc=0;
- trio (`tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_cli_reference.py`): **170 passed** in 37.48s, rc=0.

The two shards are 74 targeted files: every file that names a pin code (`pin-tag-only`, `pin-digest-*`, `shape-pin-*`, `manifest-shape-*`, `family-shape-*`, `member-pin-*`, `update-pin-*`, `neutral-pin-*`, `plan-pin-*`, `not 40/64 hex`) or one of `COMMIT_RE`, `SHA256_RE`, `COMMIT_40`, every `tests/test_bootstrap*.py` (13 of 13), `test_shape_doctor.py`, 6 of the 19 `test_update_shape*.py` files, `test_family.py`, `test_scaffold_e2e.py`, and the pin tests. They replaced a wide run (226 importer files, about 27k tests) that was killed by PID under load (load average 72 on 32 cores, 2% in 11 minutes); that run is not evidence. The new file and the four edited files are all inside the shards. The other 13 `test_update_shape*.py` files, which name no pin code, ran as a third run (480 passed, 3 skipped in 441.06s, rc=0), and `tests/test_scaffold_leg_path_collisions.py` as a fourth (104 passed in 4.35s, rc=0). No full suite was run.

git 2.47.1 (`git --version` printed first in each log):

- the new file, `test_pin_validator.py`, `test_manifest_validator.py`, `test_adopt_pins.py`: **323 passed** in 208.37s, rc=0.
- `test_shape_doctor.py -k a_root_pin_that_does_not_read`: **13 passed, 436 deselected** in 52.85s, rc=0.

Refreshed after the fix round, against the re-fetched origin/main:

- **Fix round 2, 2026-10-22:**
  - origin/main `038dafba9dbedd9405b316cc749a04dffe02bb36` (#1111) is merged into the branch as `c7da27dc2`, cleanly.
  - The head `4c1f80cf4db6215d8861bd43cd57f72df957d82a` merges with main at its own tree, `9884a99aeba2a55e486a355ae3483d8c932b4f68`: the seven files of this PR.
  - Of the 28 open pull requests, #1125 (head `2a53a8e6`) still touches `tests/test_adopt_pins.py`, and its merge-tree with this head is clean, tree `a026a192669a86abfaed59fbcce2fd1179bb0894`.
  - The bullets below are fix round 1's.
- `git merge-tree --write-tree origin/main HEAD` against `4ed7c7bf581dceb75f816f30f76b776c2b8917f0` (merge base `6da757cbe`), with this PR's head `03b2b97b9d3cccc42b4703cb2f7c978693488266`: clean, rc=0, tree `95d242489e6a8d6ea2be8c454a5e6655922d5712`. The difference between that tree and origin/main is exactly the seven files of this PR.
- The branch stays on `6da757cbe`. Since then three PRs landed. #1101 and #1102 touched `README.md`, `docs/cli.md`, `docs/handbook.html`, `scaffold-project.py`, `templates/assembly-root/project.yaml`, `templates/family-root/family.yaml`, `tests/readme-cap.d/915.txt`, `tests/test_scaffold_reuse_seeded_legs.py` and `tests/test_visibility_field_words.py`. #1091 (`4ed7c7bf5`) touched `adopt-project.py`, `tests/test_adopt_root_shape_paths.py` and `tests/test_adopt_root_symlinks.py`. None is a file of this PR, and `adopt-project.py` holds the same 8 uses of `COMMIT_RE` and `SHA256_RE` as before.
- Of the 23 open pull requests (listed with `gh pr list --state open --json number,headRefName,headRefOid,files`, this one included), one other touches a file of this PR: #1125 (head `2a53a8e64a0cce17ab84cd1bf5949bcb6fbd4484`) touches `tests/test_adopt_pins.py`. `git merge-tree --write-tree 2a53a8e HEAD` is clean, rc=0, tree `5fbd1eaafdf94004ead7142ae9132dc79dc4d1e5`.

One new commit on top, `03b2b97b9d3cccc42b4703cb2f7c978693488266`, after the independent review of `ae30dbd0e0eecfb7092c5ef257da2367d114fc62` (not ready on one must). It changes `tests/test_pin_pattern_line_break.py` and nothing else; no production file changes.

- **Composite asserts (SonarCloud `python:S9073`, the two issues at lines 164 and 173).** `member_pin` and `holder_shape` ended in `assert commit and digest, text`. Each value now has its own assert, with a message that names it (`fixture drift: no member pin commit`, `no member pin tree_sha256`, `no shape commit`, `no shape tree_sha256`).
- **Two callers had no row of their own,** so a `.rstrip("\n")` before the match in either survived every row. The new rows (the file now has 46):
  - `test_a_neutral_pin_commit_that_ends_in_a_line_break_is_refused`: the neutral product's pin in `validate-pins.py`, exit 2, `REFUSED neutral-pin-tag-only`. On main the commit passed `COMMIT_RE` and the run exited 1 with `neutral-pin-source-unreadable`, git refusing the name;
  - `test_a_neutral_pin_digest_that_ends_in_a_line_break_is_malformed`: the same pin's `tree_sha256`, exit 1, `neutral-pin-digest-malformed`. On main it was recomputed and compared (`neutral-pin-digest-mismatch`);
  - `test_a_recorded_source_commit_that_ends_in_a_line_break_is_a_finding`, two rows (`submodules`, `answered-paths`): a record row's `source_commit:` in `validate-manifest.py`, exit 1, `manifest-submodule-commit` and `manifest-answered-commit`. On main: `manifest ok`.
- **The four rows fail on main.** The new file with main's two production files checked out: **13 failed, 33 passed** (the nine of the first round, and these four).
- **Mutants,** each applied alone to the committed head, the file restored afterwards (sha256 `94f125c5f5e4b8a0` for `validate-pins.py` and `260c4742fe422597` for `validate-manifest.py`, the same as before the mutant, and clean against HEAD; `repo_shape.py` and `bootstrap.py` unchanged at `623af12131447f95` and `4102d375f4c9bc38`). The new file only:

  - S1, `.rstrip("\n")` on the neutral pin's commit before the match (`validate-pins.py` line 1790): **1 failed, 45 passed**, killed by the neutral-commit row;
  - S1b, the same on the neutral pin's recorded digest before the match (line 1800): **1 failed, 45 passed**, killed by the neutral-digest row;
  - S2, `.rstrip("\n")` on `source_commit` before the match (`validate-manifest.py` line 1264): **2 failed, 44 passed**, killed by both `source_commit` rows;
  - before this round S1 and S2 survived.
- **Runs, at head `03b2b97b9d3cccc42b4703cb2f7c978693488266`, worktree clean at each START:**
  - git 2.43.0: the new file, `tests/test_pin_validator.py` and `tests/test_manifest_validator.py`: **175 passed** in 58.03s, rc=0;
  - git 2.43.0: the trio: **170 passed** in 39.29s, rc=0;
  - git 2.43.0, extra: `tests/test_neutral_pin_validator.py` (the new rows use its helpers): **39 passed** in 99.62s, rc=0;
  - git 2.47.1 (`git --version` printed first): the new file: **46 passed** in 19.32s, rc=0.
  - The shards, the other git 2.47.1 runs and the extras above ran at `ae30dbd0e`; the two production files and the four other test files are the same at this head.

This round is one merge of main and one new commit, after rc1126's re-check: READY at `03b2b97b`, with two prose NITs (#1126 issuecomment-6340973564).

- **Merge of main:** `038dafba` (#1111), as `c7da27dc2`, with no conflict.
- **NIT 1:** `4c1f80cf4` changes only the module docstring of `tests/test_pin_pattern_line_break.py`. It now says what main did:
  - `validate-manifest.py` passed both a `shape.commit` and a `shape.digests.tree_sha256` that end in a line feed;
  - `validate-family.py` passed only the digest. Its commit gave `shape-pin-manifest-disagree` with the same hex on both sides, because `contracts/shape-pin.yaml` holds the commit without the line feed.
  - No code or row changes.
- **NIT 2:** the Mutants table above is re-measured on the 46-row file.
- **Runs at `4c1f80cf4`, git 2.43.0:**
  - the file and `tests/test_path_guard_hygiene.py`: **95 passed** (46 + 49);
  - with main's two production files checked out: **13 failed, 33 passed**, as before.

Other `^...$` patterns are used with `.match` on values read from files or arguments: `PROJECT_ID_RE`, `DATE_RE` and `QUALIFIED_RE` (`validate-manifest.py`, `validate-family.py`), `REPOSITORY_RE` (`update-shape.py`), `PIN_ARG_RE` (`shape_materialize.py`), and `NAME_RE`, `REF_RE` and `PATH_RE` (`setup-project.py`). They have the same `$` property and are outside the two patterns this issue names, so they are left for #1139, filed from this PR's review.

Review: lane 5's independent re-check, by a reviewer that wrote none of #1126: READY at `4c1f80cf`, the head that lands (issuecomment-6344730185), with no MUST, SHOULD or NIT. It checked the delta from `03b2b97b`, which rc1126 passed (issuecomment-6340973564). CI (Brett Heap's ruling 27): H0 is the head 4c1f80c itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 038dafb; and the local gate passed on its predicted squash tree df676e7 over main 8679c80: the adopt_pins, manifest_validator, pin_pattern_line_break, pin_validator, shape_doctor, repo_hygiene, agents_md_codes, cli_reference sets, 944 passed, 3 skipped. 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 #1017

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 23, 2026
…nt of the commit `--to` names, so a tag above it reads as one that reaches it and a tag below it keeps the plain words, and `--points-at` for `--contains` no longer survives (#1019) (#1107)

No test held that a tag at a DESCENDANT of the refused commit counts as a tag that reaches it, so `--points-at` in place of `--contains` survived in `bump-leg.py` and `family.py` (mutant M9 of #988's review, filed as #1019). This adds the rows the issue asks for to the two reachability test files, and no production code changes. Opened as a draft with no milestone: Brett Heap's ruling of 2026-10-21T10:32:04Z ("leave the follow-ups out of v1.2.0") held it until the v1.2.0 tag, and it is now on the v1.2.1 milestone.

Measured on main `2a119f28` before any change, and checked again on `16f9a0e81` and `7e00834c` (the tag questions did not change, only their line numbers):

- `scripts/bump-leg.py` `_remote_tag_reaches` and `scripts/family.py` `_origin_tag_reaches` list the tags with `git for-each-ref --contains <commit> --format=%(objectname) refs/tags/`. No other copy of either exists (the `--contains` in `on_a_remote_branch` and `_on_a_remote_branch` asks about branches and is not this question).
- The mutant (`--contains` -> `--points-at` on that one line in each script) SURVIVES every row on main: `test_bump_leg_reachability_words.py` 34 passed, `test_family_bump_reachability_words.py` 20 passed, 54 rows, as the issue says.
- The production code is right: the new rows pass on unmutated main. So they do not "fail on main"; they fail on the mutant, which is the issue's own acceptance ("With `--points-at`, that row must fail"). No defect was found.

`tests/test_bump_leg_reachability_words.py`:
- `made_in_the_leg(project, name, on="HEAD")` takes an optional `on`, the commit the new commit is made on top of. Every existing call is unchanged.
- `test_a_tag_at_a_descendant_of_the_commit_reaches_it`: a commit `made` and a child `above` on top of it, on no branch of the leg's remote. The leg and the remote each tag `above` (`v1-w1019`), made apart, in three kinds: both annotated (the reviewer's case J), leg lightweight and remote annotated, and the reverse. Both runs (dry run and real) refuse `made` `bump-leg-commit-local-only` in the TAG words, through `origin` and through `--local-remote-dir` (3 kinds x 2 routes = 6 rows), with nothing moved and the leg's own tag as it was. The premises say the tag names the child, that no tag is AT `made` (`for-each-ref --points-at`), and, through #1087's `only_a_tag_reaches`, that a tag of the remote reaches it, no branch does and no remote-tracking ref of the leg does.
- `test_a_tag_at_an_ancestor_of_the_commit_alone_keeps_the_plain_words`: the control (x 2 routes). The commit named is the child, and the leg and the remote tag only its parent. The plain words stay, "a pin the rest of the world cannot fetch" included.

`tests/test_family_bump_reachability_words.py`:
- `made_on(holder, env, parent, name)`: a commit never pushed, made in the mount on top of `parent`, through `commit_in_mount` (which checks `parent` out first), so the holder stays clean.
- `test_a_tag_at_a_descendant_of_the_commit_reaches_it`: the same three kinds (3 rows), both `bump --dry-run` and the real `bump` refuse `member-to-local-only` in the tag words, with nothing moved and the mount's own tag as it was.
- `test_a_tag_at_an_ancestor_of_the_commit_alone_keeps_the_plain_words`: the control (1 row).

No assert joins conditions with `and` or `or`; each premise is its own assert. No README, docs, AGENTS.md or handbook text names these tests or their row counts (grep), so none changes.

| Mutant | Rows before (main's) | With these rows |
| --- | --- | --- |
| `--points-at` in `bump-leg.py` | survives: 34 of 34 pass | killed: 6 failed, 36 passed (all six descendant rows; the two controls pass, as they should) |
| `--points-at` in `family.py` | survives: 20 of 20 pass | killed: 3 failed, 21 passed (all three descendant rows) |
| `--merged` in `bump-leg.py` (extra: a check read the other way round) | survives: 34 of 34 pass | killed: 8 failed (6 descendant rows and both controls) |
| `--merged` in `family.py` (extra) | survives: 20 of 20 pass | killed: 4 failed (3 descendant rows and the control) |

The failure is the one the issue names: the plain words ("is reachable from no branch of ...") where the tag words ("and a tag of ... reaches it, but no branch ... does") are expected. Logs: `r-mut-points-at-bump.log`, `r-mut-points-at-family.log`, `r-mut-merged-bump.log`, `r-mut-merged-family.log` (and `mut-main-*.log` for the 34 and 20 on `2a119f28`).

On the pushed head `01fcb259`:
- git 2.43.0 (`/usr/bin/git`): the two test files, 66 passed (42 + 24; 34 + 20 before).
- git 2.43.0: the trio `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py` and `tests/test_update_shape_kept_helper.py`, 329 passed.

On `1201680f` (the same two files on top of `16f9a0e8`, before `7e00834c`, whose three files no row of mine touches or runs):
- git 2.43.0: the trio alone, 170 passed; the two test files that import helpers from mine (`test_bump_leg_fetch_refspecs.py`, `test_family_bump_fetch_refspecs.py`), 102 passed; the two test files, 42 and 24 passed.
- git 2.47.1 (`$Q/v115/w764/git247/bin/git` first on PATH, `git --version` printed `git version 2.47.1`): the two test files, 66 passed.

No script changed, so no other test runs a changed script.

- `git merge-tree --write-tree HEAD origin/main` against a re-fetched `origin/main` (`7e00834c`, which is also the merge base): exit 0, tree `3a852e87fcfda41766ece8c1771e47689c622ad6`, equal to this head's tree. Against `16f9a0e8` before the last rebase: exit 0 too.
- Open PRs read with `gh pr list --repo opensoft/openRepoShape --state open --json number,headRefName,isDraft,files`: 8 (#1102, #1101, #1098, #1097, #1091, #1090, #1088, #1075). None touches `tests/test_bump_leg_reachability_words.py` or `tests/test_family_bump_reachability_words.py`, so there is no merge-tree to run against any of them.

- **The NIT.** The docstrings of the two descendant rows said "Every row before this one tagged the commit itself, so ... survived all 54 of them". 54 counts the two files together (34 + 20), several earlier rows tag nothing (the no-`origin` and prune rows), and a count goes stale with the next row.
- **The fix, docstrings only.** Each now says only what holds: "No row before this one tags a descendant of the commit: the tags in them are at the commit itself or below it, so `--points-at` in place of `--contains`, the mutant M9 of #988's review, survived every row of this file." No row, assert or helper changed. The 54 in "What still held on main" above is the two files together, as it says.
- **One commit on top of `01fcb259`, a fast-forward push, no rebase:** head `ee48b5fe9693ebda099d1c0aadaca7bdc16b91c1`, read back with `git ls-remote`. Its tree is `615e571bb62fd1cf481e4152278b0e3628bab059`.
- **Runs** (git 2.43.0, through `run9.sh`, `EMAIL=service@opensoft.dev`): `tests/test_bump_leg_reachability_words.py` 42 passed, `tests/test_family_bump_reachability_words.py` 24 passed. They ran on `7186f3cf`, the same tree as the pushed head; only the commit message was shortened after.
- **Merge-tree** against a re-fetched `origin/main` (`6da757cb`, merge base `7e00834c`): exit 0, tree `453566df33c886cf7fc2b1c39af02acbbff365dc`. Main moved by nine files, none of mine; its `scripts/bump-leg.py` change (9 lines added) leaves `_remote_tag_reaches` and its `--contains` line alone (now line 1214).

Review: rv1104-1107, lane 5's independent opus reviewer, which wrote none of #1107: READY at `ee48b5fe`, the head that lands (issuecomment-6318124100), with no MUST or SHOULD and one optional test NIT that needs no round. CI (Brett Heap's ruling 27): H0 is the head ee48b5f itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 7e00834; and the local gate passed on its predicted squash tree 2f7f587 over main 422a590: the bump_leg_reachability_words, family_bump_reachability_words, repo_hygiene, agents_md_codes, cli_reference sets, 237 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 #1019

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 23, 2026
…he 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` asks this machine's git what the split will make of the source, in throwaway repositories in the work directory (#910). Two questions were still asked another way, and on git 2.43 both stopped short of the answer `execute` meets.

- **The split's own clone.** `check` now makes the split's own `git clone` of the source first, in a throwaway directory in the work directory (`_split_clone_asked`). The clone takes `execute`'s protocol setting (`FILE_PROTOCOL`), no hook of this machine, and the operator's template, copied in as `_extract_leg`'s clone copies it. Its environment is `_split_clone_env()`: `_inherited_env()` plus the `-c` settings a parent git hands this process. Where git will not make the clone, that is one `adopt-git-unasked` finding in git's words. The questions after it are not asked, and `execute` refuses the plan before it makes either leg. Where git makes the clone, nothing changes.
- **`_resolved` and `_answers`** (#1032) now make their repositories the throwaway way, through `_throwaway` and `_throwaway_made`: in the work directory, with no hook, no fsmonitor, `--template=<empty>`, `-b throwaway` and the source's object format, all under `_inherited_env()`. Before, they used `TMPDIR` and a plain `git init` with the caller's whole environment. A git step that fails in one is `adopt-git-unasked` in git's words, never "a git or gh command failed".
- **The ref-probe row** of `tests/test_adopt_root_symlinks.py` (#1091) set `GIT_DEFAULT_HASH=bogus`, which now stops the split's clone first. It now fails only the ref probe's `git init`, through a stub `git` first on PATH. Like the other stub rows, it is skipped on Windows.

Known limit: a shallow source makes the clone run `uploadpack.packObjectsHook` from global or system config; a parent's `-c` did not reach it. `execute`'s clone runs it too. The limits that predate this PR are filed as #1136 and #1145.

Review: lane 1's independent reviewers found `bdcf9952` READY on condition (#1119 issuecomment-6318689112) and `9b998936` NOT READY, with two MUSTs (issuecomment-6321544535). They then found round 3's `bba168bd` READY, with no MUST or SHOULD (issuecomment-6327872442). Lane 1 checked the two later commits (issuecomment-6371557407):
- `195d9936` moves two pairs of repeated row bodies into helpers, for SonarCloud's duplication limit.
- `e0a98f13` merges main. Its one conflict was in a docstring, and both sides are kept.

Tests, under a stub `gh` with a bogus token:
- The reviewer's gate at `bba168bd`: 931 passed, 8 skipped.
- At `e0a98f13`, the writer's runs: the throwaway file gave 89 passed and 1 skipped. The path-guard, no-auto-maintenance and single-repository-record files gave 146 passed and 2 skipped.
- Before the HOLD, lane 1's gate ran on `e0a98f13` merged with main `422a590e` (tree `29f1f3d6`): 8 pytest calls, 1,440 passed and 9 skipped. They cover the PR's three test files, `tests/test_adopt_pins.py`, `tests/test_adopt_single_repository_record.py`, the no-auto-maintenance and path-guard scans, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`, `tests/test_repo_hygiene.py`, and `tests/test_shape_doctor.py` in 4 shards.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its H0 `e0a98f13` is green on Linux and SonarCloud. The branch merged main five times and was never rebased. The last merge is update-branch's, clean, giving 9b85644, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: 2 pytest calls over `tests/test_adopt_check_throwaway_repos.py`, `tests/test_adopt_single_repository_record.py`, `tests/test_no_auto_maintenance.py`, `tests/test_path_guard_hygiene.py`, `tests/test_non_utf8_git_failures.py`, `tests/test_scaffold_leg_path_collisions.py`: 412 passed, 3 skipped.

Closes #1083
Closes #1032

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 23, 2026
…py` where GNU make at the root reads a kept `GNUmakefile` or `makefile`, and its NOTE names that file and what it takes for `make bootstrap` to run there (#1047) (#1121)

GNU make reads the first it finds of `GNUmakefile`, `makefile` and `Makefile`, and only that one. Where the source keeps `GNUmakefile` or `makefile` in the root, `make bootstrap` at the root of the clone reads the source's file, not the shape's `Makefile`, and exits 2 with "No rule to make target 'bootstrap'". `execute`'s NEXT printed it there anyway.

This PR also covers the third box of #1048 (see #1048): the `_next_steps` NOTE that said the shape's targets "arrive only once the follow-up merges the two", which is untrue where GNU make reads a kept `GNUmakefile` or `makefile`. #1105 covers that issue's other two boxes, and leaves this one to this PR.

## Measured

GNU Make 4.3 on Linux, a disk that keeps case. One directory per set of files, each file with a `which` target:

| files | `make which` reads | `make -n bootstrap` |
|---|---|---|
| `GNUmakefile` | `GNUmakefile` | exit 2, no rule |
| `makefile` | `makefile` | exit 2 |
| `Makefile` (with a `bootstrap` rule) | `Makefile` | exit 0 |
| `GNUmakefile` + `makefile` | `GNUmakefile` | exit 2 |
| `GNUmakefile` + `Makefile` | `GNUmakefile` | exit 2 |
| `makefile` + `Makefile` | `makefile` | exit 2 |
| all three | `GNUmakefile` | exit 2 |

A `GNUmakefile` that holds `include Makefile` runs the shape's rule (exit 0), and so does `make -f Makefile bootstrap`.

What NEXT printed on main, from a real `plan`, `check` and `execute --local-remote-dir`, with the printed command run in a recursive clone of the split ("S" is the source's own `scripts/bootstrap.py`, kept with `scripts/`):

| the root keeps | NEXT on main | in the clone |
|---|---|---|
| none | `make bootstrap` | exit 0 |
| `Makefile` | `python3 scripts/bootstrap.py`, #480's NOTE | exit 0 |
| `makefile`, `GNUmakefile`, or both | `make bootstrap` | **exit 2, no rule** |
| `Makefile` + `makefile`, `Makefile` + `GNUmakefile`, all three | `python3 scripts/bootstrap.py`, and a NOTE saying the targets arrive once the two `Makefile`s are merged | the command exits 0; `make bootstrap` exits 2, and still does after `cat shape/Makefile >> Makefile` |
| S + `GNUmakefile`, S + `makefile`, S + `Makefile` + `GNUmakefile` | no command; the NOTE says to merge, "and then run `make bootstrap`" | `make bootstrap` exits 2 |

## What changed

`adopt-project.py`, `_next_steps`:
- It takes `surviving=`, the paths the plan's entries send to the root (`paths_for["root"]`, passed by `cmd_execute`).
- It asks them for a kept `GNUmakefile` or `makefile` in GNU make's order, with `_root_keeps`, as `_makefile_follow_up` asks them. So NEXT names the same file as the plan's `CONTRACTS_DIR` follow-up.
- Where it finds one, the command is `python3 scripts/bootstrap.py`. The NOTE names the file in the follow-up's words, and says `make bootstrap` and the shape's other make targets run there only once that file has them, or includes a Makefile that does:
  - the shape's `Makefile` is the root's: "NOTE the shape's Makefile is the root's `Makefile`, but GNU make at the root reads the source's `GNUmakefile`, the first of `GNUmakefile`, `makefile` and `Makefile` it finds, so `make bootstrap` and the shape's other make targets run there only once `GNUmakefile` has them, or includes a Makefile that does; `python3 scripts/bootstrap.py` is what the shape's `make bootstrap` runs."
  - the source also keeps its own `Makefile`: "NOTE the root keeps its own Makefile and the shape's is shape/Makefile, and GNU make at the root reads ..." (the same clause, and the same ending).
  - the source's own script went aside too: the merges first, as before, "and then run `python3 scripts/bootstrap.py`; GNU make at the root reads ...".
- A root that keeps neither name gets the block it got before, byte for byte. That includes `MAKEFILE`, which GNU make never reads where case is kept.

## Tests

`tests/test_adopt_next_make_reads_first.py`, new, 40 tests (38, and the 2 of the fix round below):
- 13 rows word for word: none, `Makefile`, `makefile`, `GNUmakefile`, `makefile`+`GNUmakefile`, `Makefile`+`makefile`, `Makefile`+`GNUmakefile`, all three, S, S+`Makefile`, S+`GNUmakefile`, S+`makefile`, S+`Makefile`+`GNUmakefile`.
- 3 rows that keep `make bootstrap`: `MAKEFILE`; longer names (`makefile.am`, `Makefile.in`, `GNUmakefile.bak`); and the names below the root.
- 13 rows in which NEXT names the same file as `_makefile_follow_up`, for the same root paths.
- 9 end to end rows, one for each row that keeps `GNUmakefile` or `makefile`. Each is a real `execute`, and its clone is made by pasting NEXT's own clone line. In that clone:
  - NEXT's lines are the row's;
  - `make which` prints the file the NOTE names;
  - `make -n bootstrap` has no rule;
  - the printed command exits 0 and ends `bootstrap ok`. For S rows, it does so once the shape's script is merged, as the NOTE says;
  - where a `Makefile` is kept, the follow-up's merge alone still leaves no rule;
  - an `include Makefile` in the file GNU make reads makes `make -n bootstrap` run the shape's recipe.

On main's `adopt-project.py`, the first 38 all fail. The 9 end to end rows fail on what NEXT prints. The unit rows that guard the unchanged cases fail there only for want of the `surviving=` keyword. M1 below shows the same rows passing and every row that keeps `GNUmakefile` or `makefile` failing. On this head, all 40 pass.

Mutants of the change, each restored and checked by sha256. Each run is the new file plus the five `next` tests of `test_adopt_e2e.py` (43 tests). Every mutant is killed:

| mutant | failed |
|---|---|
| M1 `first` never found | 27 |
| M2 `makefile` asked before `GNUmakefile` | 6 |
| M3 the command ignores `first` | 6 |
| M4 the branch for a `Makefile` kept beside `first` dropped | 6 |
| M5 the branch for a script gone aside ignores `first` | 9 |
| M6 the lookup folds case | 7, two of them existing `test_adopt_e2e.py` rows |
| M7 `cmd_execute` passes no paths | 9, the end to end rows |

Gate on this head, named files only, one basetemp per run. Each run had `EMAIL` set, `GIT_CONFIG_GLOBAL` an empty file, `GIT_CONFIG_NOSYSTEM=1`, a stub `gh` first on PATH, `GH_TOKEN=bogus` and an empty `GH_CONFIG_DIR`:
- the new file, `test_adopt_make_reads_first`, `test_adopt_e2e`: 98 passed, with the new file at 38 tests (at `c04bb710`; the fix round's gate is below).
- `test_adopt_github_exits`, `test_adopt_printed_sentences`, `test_repo_hygiene`, `test_readme_windows_quoting_sentence`, `test_cli_reference`, `test_agents_md_codes`: 291 passed.
- `test_scaffold_leg_path_collisions`: 104 passed.
- `test_adopt_contracts_dir`, `test_adopt_verify`, `test_adopt_pins`, `test_adopt_root_manifest`, `test_adopt_ignored_shape_files`, `test_adopt_nested_attributes`, `test_adopt_shape_pin_and_work_dir`: 378 passed.
- `test_shape_doctor.py` in 4 shards: 112 + 112 + 112 + 109 = 445 passed, and 3 skipped (the PowerShell line, which is printed only on a Windows host).

## Fix round 1 (RV1121's SHOULD-1)

RV1121 found the code right and one test missing: nothing held `cmd_execute` to handing `_next_steps(surviving=)` only the root's paths. Its mutant X4, which passes `paths_for["root"] + paths_for["code"] + paths_for["drop"]`, kept all 43 tests green. The case is a real one: the `root-project-makefile` rule sends a kept `GNUmakefile` to the root, and its reason says that moving a Makefile that only builds the code to the code leg "is a one-line edit of the plan" (`contracts/path-classification.yaml`, lines 236-237).

Two end to end rows, `test_next_keeps_make_bootstrap_where_the_plan_sends_the_makefile_elsewhere[code]` and `[drop]`. Each keeps `GNUmakefile` in the source, edits the plan to send it to `leg: code` (then `leg: drop`), runs a real `execute`, and pastes NEXT's own clone line. Each asserts that NEXT's lines after the clone are `    make bootstrap` and nothing else, that the clone's root has no `GNUmakefile`, and that `make -n bootstrap` there exits 0 and runs `python3 scripts/bootstrap.py`.

The `execute`, paste and clone steps of the nine existing end to end rows moved into one helper, `executed_and_cloned`, which the two new rows share, so no block of the test is written twice. Those rows assert what they asserted. The file now holds 40 tests (38 and 2).

- **X4, on this head's `adopt-project.py` with the one line changed:** the new file and the five `next` rows of `test_adopt_e2e.py` (45 selected): 2 failed, 43 passed. Both new rows fail on NEXT's lines, which are `python3 scripts/bootstrap.py` and the NOTE "the shape's Makefile is the root's `Makefile`, but GNU make at the root reads the source's `GNUmakefile`, ...", naming a file the root does not have. The file was restored and its sha256 read `60bad59a...` again.
- **On main's `adopt-project.py`:** the two rows pass, since main printed `make bootstrap` there. They hold the unchanged case against the new lookup, and X4 is the mutant that tells them apart.
- **On this head `1c5173758`**, same environment as above: the new file 40 passed, `test_adopt_e2e.py` 27 passed, `test_repo_hygiene.py` 100 passed.
- **Merge:** `git merge-tree --write-tree` of this head with main at `823624d93`: clean (tree `73ddafb2`).

RV1121's four NITs are not in this PR; the lane files them.

## Merge

- `git merge-tree --write-tree` of this head with main at `6da757cbe`, where the branch starts: clean (tree `21955cc7`). Main has since moved to `7cd66902a`, which changes `scaffold-project.py`, its test and the docs, and none of this branch's files. The merge with it is clean too (tree `ec4e6c8e`).
- With #1105's head `5a4de7f76`: clean (tree `3394d19d`). #1105's `adopt-project.py` change is in `_predict_collisions`, apart from `_next_steps`. On that merge, built in a `git clone --shared` checkout, `test_adopt_next_make_reads_first`, #1105's `test_adopt_collision_make_reads` and `test_pinned_copy_text` gave 152 passed.
- With the other open pull requests that change `adopt-project.py` (#1120 `c55e55e3`, #1119 `bdcf9952`, #1118 `c60066cb`, #1106 `504a1dd6`, #1091 `160de118`): each clean.

## Not in this PR

- Once #1105 lands, the lookup "which of `GNUmakefile` and `makefile` the root keeps first" is written three times: in `_makefile_follow_up`, `_predict_collisions` and `_next_steps`. Each is the same two lines over `GNU_MAKE_READS` and `_root_keeps`. They are kept apart here so this PR and #1105 touch disjoint lines; one helper could serve all three after both land.
- `templates/assembly-root/AGENTS-shape.md` line 19 ("`git clone --recurse-submodules` this repository and run `make bootstrap`") has the same gap in an adopted root that keeps `GNUmakefile` or `makefile`. It is pinned, and #1105 changes that file, so it is left.
- On a disk that ignores case (#1049), a kept `makefile` is reported as a `Makefile` collision, and NEXT's NOTE says "the root keeps its own Makefile". The new clause is true there too, since GNU make reads that file and merging into it brings the targets. The spelling is #1049's.
- A kept directory named `GNUmakefile/` or `makefile/` is named as the source's file. GNU make stops on it with "Is a directory". `_makefile_follow_up` does the same.

Closes #1047

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant