Skip to content

shape_materialize.py: the fetch for a branch's answer leaves out every refspec of the repository's own onto the branches' names, whatever its source, and every one that puts a branch under a name not its own, so family.py bump and bump-leg.py no longer fail where the remote has x and release/x and the repository's own git fetch origin works (#1066) - #1087

Merged
brettheap merged 2 commits into
mainfrom
fix/1066-refspec-onto-origin-glob
Oct 21, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1066

What was wrong

Both family.py bump and bump-leg.py fetch with
git fetch -q --prune --no-prune-tags --refmap= origin '+refs/heads/*:refs/remotes/origin/*' <carried>.
<carried> holds the repository's own refspecs that origin_fetch_refspecs
in scripts/shape_materialize.py keeps (one rule for both tools since #1068,
for #1037).

The rule's third clause came from #946. It left out a refspec onto
refs/remotes/origin/* only where the refspec's source is not under
refs/heads/. A plain refs/heads/* refspec is the tools' own first
refspec, so in practice that guard only carried a folder of branches onto
the branches' names. Take a repository whose only refspec is
+refs/heads/release/*:refs/remotes/origin/*, with branches main, x and
release/x on the remote at different commits:

  • its own git fetch origin exits 0;
  • the tools' fetch exits 128 with
    fatal: Cannot fetch both refs/heads/x and refs/heads/release/x to refs/remotes/origin/x;
  • both runs of both tools then print the failed-fetch note and answer from
    stale refs.

The rule now

Git dies with "Cannot fetch both" whenever two refspecs on one command line
map two different remote refs to one local name. The first refspec gives
every branch refs/remotes/origin/<its name>. So a configured refspec can
only be carried safely when it can never put another ref on a branch's
name. For a source under refs/heads/, that means the refspec must map each
branch exactly where the first refspec maps it.

origin_fetch_refspecs asks four clauses, in this order:

  1. OUTSIDE refs/remotes/origin/: unchanged. A row now pins its trailing
    / (NIT 1 of lane 2's review of bump-leg.py: the leg's fetch leaves out every refspec of the leg's own that can bring a tag under origin/, and one onto the branches' names from another namespace, by the rule family.py bump asks, now one function both tools import, so a branch and a tag of one name no longer fail it and a tag no longer answers for a branch (#1037) #1068).
  2. A SOURCE THAT CAN NAME A TAG: unchanged, so the tag rules of family.py bump: a mount's +refs/*:refs/remotes/origin/all/* (a mirror in a folder) is still carried, so a commit only a tag reaches is pinned and a branch and a tag of one name fail the fetch #1009 and
    bump-leg.py: the leg's fetch carries every refspec of the leg's own that can bring a tag under origin/ (tags onto the branches' names or in a folder, a mirror into origin/ or in a folder, a short name), so a branch and a tag of one name fail it and a tag answers for a branch, as family.py bump's did before #946 and #1027 #1037 hold exactly.
  3. THE BRANCHES' GLOB: every refspec onto refs/remotes/origin/* is left
    out, whatever its source. The refs/heads/ guard is gone.
  4. A BRANCH UNDER ANOTHER NAME (new, gives_a_branch_another_name): a
    source under refs/heads/ is left out unless its destination is
    refs/remotes/origin/ followed by the source's own part after
    refs/heads/. This covers one branch onto another's name, x onto
    release/x, every branch into a folder, and a folder into another
    folder. Each one fails the same way where the remote has both branches.

Still carried:

A narrowing refspec is still skipped, and a positive one is still forced.

Why leaving such a refspec out loses nothing

  • The first refspec brings every branch under its own name.
  • A refspec with a source under refs/heads/ only ever names branches.
  • So every commit a branch reaches is still reached under
    refs/remotes/origin/.

What it costs

The cost is the one #905 accepted for the tags onto the branches' names.

  • A ref the repository kept under another name either holds the branch of
    that name or is pruned.
  • The repository's own next git fetch origin writes it back.
  • A refspec written without + is refused there as non-fast-forward, exit 1,
    where the two commits are not in line. The tags onto the branches' names
    give the same result on main today. Both measured below.

Measured (git 2.43, local bare remotes)

The probe ran 31 cases. Each compared the repository's own git fetch origin
with the tools' fetch, on main's rule and on this one, through origin and
through a path (the --local-remote-dir form). After every tools' fetch
that exited 0, it checked that each remote branch's
refs/remotes/origin/<name> holds that branch's commit, which held in every
case.

case                                        own   main(origin/path)  this(origin/path)
issue: release/* onto origin/*              0     128/128            0/0
issue, no branch x on the remote            0     0/0                0/0  (origin/x pruned)
issue, unforced                             0     128/128            0/0
issue beside the default glob               128   128/128            0/0
release/* onto origin/release/* (own name)  0     0/0 carried        0/0 carried
feat-* onto origin/feat-* (own name)        0     0/0 carried        0/0 carried
release/x onto origin/x                     0     128/128            0/0
release/x onto origin/x, no branch x        0     0/0                0/0  (origin/x pruned)
x onto origin/release/x                     0     128/128            0/0
main onto origin/main (own name)            0     0/0 carried        0/0 carried
heads/* onto origin/all/*                   0     128/128            0/0
release/* onto origin/rel/*                 0     128/128            0/0
the glob itself, forced and unforced        0     0/0                0/0
pull/*/head onto origin/*                   0     0/0                0/0
tags onto origin/*                          0     0/0                0/0
negative beside the default glob            0     0/0                0/0
negative beside the issue's refspec         0     128/128            0/0
negative alone                              0     0/0                0/0
tags in a folder, mirror into origin/,
  mirror in a folder, short name, one tag   0     0/0                0/0
pulls in origin/pr/ beside the glob         0     0/0 carried        0/0 carried
  the same, with a branch pr/7              128   128/128            128/128
pulls in origin/pr/ beside main only,
  with a branch pr/7                        0     128/128            128/128  (limit)
trunk onto origin/trunk, no trunk           128   128/128            128/128
trunk onto origin/t, no trunk               128   128/128            0/0
pulls onto originx/pr/*, heads onto
  originx/*                                 0     0/0                0/0

The write-back probe, after the tools' fetch left the refspec out, then the
repository's own fetch again:

  • +refs/heads/release/*:refs/remotes/origin/* exits 0 and puts origin/x
    back;
  • the same refspec without + exits 1 with ! [rejected] release/x -> origin/x (non-fast-forward);
  • refs/tags/*:refs/remotes/origin/* without + exits 1 the same way, so
    main behaves like this today.

A differential over 21,487 single and paired refspecs compared main's rule
with this one. This rule never carries a refspec main's does not. Every
refspec it newly leaves out has a source under refs/heads/ and a
destination that is not the branch's own name.

Tests

Counts and gates

Before and after, over the three changed test files (136 tests):

  • main's scripts with these tests: 21 failed, 115 passed. The 21 are the 9
    end-to-end rows (3 through family.py bump, 6 through bump-leg.py) and
    6 table rows for each tool;
  • this head: 136 passed.

Gates at this head (/usr/bin/python3 -m pytest -q, explicit file lists):

Mutants, run one at a time over the three changed files, each restored and
checked by sha256:

mutant                                         failed of 136
clause 1's `/` removed                          2  (outside-origin-prefix, both tools)
clause 2 (tag source) removed                  34
clause 3 (branches' glob) removed               2  (branches-glob-pull-requests)
#946's guard put back on clause 3               0  equivalent (clause 4 leaves the same out)
clause 4 (branch under another name) removed   14
clause 4's refs/heads/ test removed             9
clause 4's refs/heads/ test inverted           23
clause 4's own-name test negated               21
clause 4 leaves out every branch source         7
clause 4's own-name test off by one             7

Not run (stopped at a usage checkpoint): clause 4 reduced to the glob alone,
the negative skip removed, forcing removed, and dedupe removed. #1068
measured the last three as equivalent or as killed by forcing removed.

Limit (unchanged, already on #1027's list)

A refspec carried into a folder of its own from another namespace, such as
pull requests in origin/pr/, still fails the fetch where the remote has a
branch whose name falls in that folder (pr/7 beside pull request 7).

Notes for the reviewer

🤖 Generated with Claude Code

Lane: openreposhape-4 (openRepoShape-4)

Summary by Sourcery

Prevent bump-related fetches from failing on branch refspec collisions by excluding mappings that rename branches or target the branches' standard remote-tracking names.

Bug Fixes:

  • Prevent branch-answer fetches used by family.py bump and bump-leg.py from failing when configured refspecs map multiple branches to the same remote-tracking name.

Enhancements:

  • Refine shared refspec filtering so branch refs are carried only when preserved under their own names, while retaining safe branch, tag, and pull-request mappings.
  • Align fetch behavior and documentation across member bumps and leg bumps, including local-remote fetches.

Documentation:

  • Update fetch behavior documentation to describe branch-name collisions, pruning implications, and the remaining supported failure cases.

Tests:

  • Add end-to-end and clause-level coverage for branch refspecs mapped onto the branches glob or under alternate names across both bump tools.
  • Extract shared reachability test setup and expand refspec classification cases, including forced and unforced mappings.

…y refspec of the repository's own onto the branches' names, whatever its source, and every one that puts a branch under a name not its own, so `family.py bump` and `bump-leg.py` no longer fail where the remote has `x` and `release/x` and the repository's own `git fetch origin` works (#1066)

Both tools fetch `+refs/heads/*:refs/remotes/origin/*` and the
repository's own refspecs that write under `refs/remotes/origin/`, as
`origin_fetch_refspecs` picks them (#1068). Its third clause, from #946,
left out a refspec onto `refs/remotes/origin/*` only where its source is
not under `refs/heads/`. A plain `refs/heads/*` refspec is the first
refspec itself, so that guard only ever carried a folder of branches onto
the branches' names, such as `+refs/heads/release/*:refs/remotes/origin/*`.
Where the remote has `x` and `release/x`, the fetch exited 128 ("Cannot
fetch both refs/heads/x and refs/heads/release/x to
refs/remotes/origin/x") where `git fetch origin` exits 0. Both runs then
said the fetch had failed and answered from stale refs.

THE RULE. A configured refspec rides beside the first one only where git
cannot put two refs on one name through it:
- THE BRANCHES' GLOB (clause 3) leaves out every refspec onto
  `refs/remotes/origin/*`, whatever its source. The guard is gone.
- A BRANCH UNDER ANOTHER NAME (clause 4, new,
  `gives_a_branch_another_name`) leaves out a source under `refs/heads/`
  whose destination is not `refs/remotes/origin/` and the source's own
  part after `refs/heads/`. That covers one branch onto another's name,
  every branch into a folder, and a folder into another folder. Each one
  fails the same way wherever the remote has the two branches.
- One that gives each branch its own name, a branch
  (`+refs/heads/main:refs/remotes/origin/main`), a folder
  (`+refs/heads/release/*:refs/remotes/origin/release/*`) or a pattern,
  maps every ref where the first refspec does, so it still rides along.
- Clauses 1 and 2 (outside `origin/`; a source that can name a tag) are
  unchanged, so #1009's and #1037's tag rules hold exactly.
- Nothing is lost to the answer. The first refspec brings every branch
  under its own name, and a left-out refspec only ever named branches.

Measured on git 2.43 by a probe of 31 cases, each comparing the
repository's own `git fetch origin` with the tools' fetch on main's rule
and on this one, through `origin` and through a path:
- The issue's case, its unforced form, the same beside a negative
  refspec, one branch onto another's, `x` onto `release/x`, every branch
  into a folder and a folder into another: the repository's own fetch
  exits 0. Main's fetch exits 128 ("Cannot fetch both"). This fetch
  exits 0, with every branch's remote-tracking ref at that branch.
- A folder of branches under its own name, a pattern, one branch under
  its own name, the glob itself, forced or not, negative refspecs and
  every tag case #1009 and #1037 settled: the same refspecs carried and
  the same exits on main and here.
- The cost is the one #905 accepted for the tags onto the branches'
  names. A ref the repository kept under another name holds the branch of
  that name, or is pruned, until its own next fetch writes it back. A
  refspec written without `+` is refused there as non-fast-forward where
  the two commits are not in line, exit 1, as the tags onto the branches'
  names are on main (measured, both).
- A differential over 21,487 single and paired refspecs: this rule never
  carries a refspec main's does not. Each one it newly leaves out has a
  source under `refs/heads/` and a destination that is not the branch's
  own name.

Docstrings: `origin_fetch_refspecs` names four clauses and what can still
clash. `bump-leg.py`'s `_leg_fetch_refspecs` says how the leg's fetch can
still fail, and that #1066's way no longer does (#1068's review, NIT 2).
Its module docstring and `fetch_leg`'s prune and `--refmap=` paragraphs
follow, as does `family.py`'s `_origin_fetch_refspecs`.

Tests:
- `BRANCH_UNDER_ANOTHER_NAME` (in tests/test_family_bump_fetch_refspecs.py)
  holds three refspecs: the issue's, one branch onto another's, and every
  branch into a folder. Each runs end to end through `family.py bump` (both
  runs) and through `bump-leg.py` (both runs, through `origin` and through
  `--local-remote-dir`). Each proves the clash first, then that both runs
  print no note, pin the commit and move no ref.
- `WHICH_CLAUSE`, the table both tools' files ask, goes from 18 to 29
  rows. `outside-origin-prefix` (NIT 1) has a pull request for its source,
  so only clause 1's `/` can leave it out. Four rows are THE BRANCHES'
  GLOB's, four are the new clause's, one is a negative refspec, and two
  more are carried. `carried-branches-in-a-folder`
  (`refs/heads/*:refs/remotes/origin/all/*`) was a #1066 case. It is now
  `another-name-branches-in-a-folder`, left out.
- NIT 3: `only_a_tag_reaches` in tests/test_bump_leg_reachability_words.py
  holds the premise block both files repeated. Each file's clash premise
  and the leg's two-run tail are now one helper each (`clashes`,
  `both_runs_pin`).

Before and after, over the three changed test files (136 tests):
- main's scripts with these tests: 21 failed, 115 passed. The 21 are the
  9 end-to-end rows (3 through `family.py bump`, 6 through `bump-leg.py`)
  and 6 table rows for each tool;
- this head: 136 passed.

Gates at this head:
- A, `-k bump` over the 11 `tests/test_bump_leg*.py`: 356 passed, 1
  skipped;
- B, `-k bump` over the 46 `tests/test_family*.py`: 291 passed;
- C, the two refspec files, `test_no_auto_maintenance.py`,
  `test_repo_hygiene.py`, `test_cli_reference.py`,
  `test_agents_md_codes.py` and `test_family_dry_run.py`: 393 passed.

Mutants, run one at a time over the three changed files, each restored
and checked by sha256:
- clause 1's `/` removed: 2 fail (`outside-origin-prefix`, both tools);
- clause 2 removed: 34;
- clause 3 removed: 2;
- #946's guard put back on clause 3: 0. This one is equivalent: clause 4
  then leaves the same refspecs out, and the only branch source onto the
  glob it lets through is the first refspec itself, which is deduped;
- clause 4 removed: 14;
- clause 4's `refs/heads/` test removed: 9;
- that test inverted: 23;
- its own-name test negated: 21;
- every branch source left out: 7;
- the own-name test off by one: 7.
Four were not run, stopped at a usage checkpoint: clause 4 reduced to the
glob alone, the negative skip removed, forcing removed, and dedupe
removed.

Limit: a refspec carried into a folder of its own from another namespace,
such as pull requests in `origin/pr/`, still fails the fetch where the
remote has a branch whose name falls in that folder (`pr/7` beside pull
request 7). The repository's own fetch fails there too wherever it has the
branches' glob, as a clone's default refspec is. Measured: with `main`
alone plus pull requests, its own fetch exits 0 and this one exits 128, on
main and here. Leaving such a refspec out would prune what it keeps on
every run, which #686 carries it to keep. #1027 already listed this limit.

Closes #1066

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

sourcery-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR fixes branch-refspec collisions in family and leg bump fetches by excluding all mappings that place branches on the default tracking names or under different names, while retaining safe own-name mappings and adding comprehensive shared-rule and end-to-end coverage.

Sequence diagram for collision-free branch fetch

sequenceDiagram
    participant Bump as family.py_or_bump-leg.py
    participant Filter as origin_fetch_refspecs
    participant Git as git_fetch
    participant Remote as Remote

    Bump->>Filter: origin_fetch_refspecs(configured)
    Filter-->>Bump: ORIGIN_BRANCHES plus safe refspecs
    Bump->>Git: fetch --refmap= origin ORIGIN_BRANCHES carried
    Git->>Remote: Fetch every branch
    Remote-->>Git: Branch commits
    Git-->>Bump: Success
    Note over Git,Bump: Branches excluded from conflicting mappings are still available under refs/remotes/origin/<branch>
Loading

Flow diagram for filtering branch fetch refspecs

flowchart TD
    A[Configured refspec] --> B{Destination outside refs/remotes/origin/?}
    B -->|Yes| X[Leave out]
    B -->|No| C{Source can name a tag?}
    C -->|Yes| X
    C -->|No| D{Destination is refs/remotes/origin/*?}
    D -->|Yes| X
    D -->|No| E{Source is under refs/heads/?}
    E -->|No| K[Carry refspec]
    E -->|Yes| F{Destination preserves the branch's own name?}
    F -->|Yes| K
    F -->|No| X
    X --> G[origin_fetch_refspecs]
    K --> G
Loading

File-Level Changes

Change Details Files
Tighten the shared fetch-refspec filtering so branch refspecs that can collide with the default all-branches mapping are omitted, while preserving branch refspecs that map each branch to its own tracking name.
  • Add branch-source detection and a helper that validates destination names against the source branch pattern.
  • Skip every refspec targeting the branches’ glob, regardless of source namespace.
  • Skip branch refspecs whose destination renames or relocates branches; retain own-name branch patterns and existing tag, negative, deduplication, and forcing behavior.
  • Document the new four-clause filtering rule, its pruning/write-back tradeoffs, and the remaining pull-request-folder collision limitation.
scripts/shape_materialize.py
Update both bump workflows to use and explain the corrected shared refspec policy and its remaining failure modes.
  • Clarify that branch-renaming refspecs are no longer carried and therefore no longer cause tool-only fetch clashes.
  • Document that carried refspecs can still fail for missing refs or collisions in independently maintained folders such as origin/pr/.
  • Update fetch/pruning comments for both normal-origin and local-remote-directory execution.
scripts/family.py
scripts/bump-leg.py
Expand and refactor tests to verify branch collision handling end to end for both bump implementations.
  • Add parametrized cases for branch folders onto branch names, individual branch renames, and branches mapped into another folder.
  • Assert the repository’s own fetch succeeds while the old combined fetch clashes, then verify dry-run and real runs fetch successfully, pin the expected commit, emit no failure note, and move no local refs.
  • Extend clause-coverage tables for the broadened branches-glob rule, new branch-renaming clause, own-name patterns, negative refspecs, and the origin-prefix boundary.
  • Extract shared clash and successful-run helpers and reuse reachability setup across tests.
tests/test_family_bump_fetch_refspecs.py
tests/test_bump_leg_fetch_refspecs.py
tests/test_bump_leg_reachability_words.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1066 Prevent the tools’ fetch from carrying configured refspecs that map branches onto another branch’s tracking-ref name, including the reported release/-to-origin/ case, while retaining safe same-name branch refspecs. ✅
#1066 Apply the corrected fetch-refspec rule to both family.py bump and bump-leg.py, and add regression coverage for the reported collision and related branch-name collisions. ✅

Possibly linked issues


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

This was referenced Oct 20, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

RV1087: independent review of #1087 at a9289bd9: READY

No MUST, no SHOULD; five NITs for a follow-up. The rule is right against real git on every case below, it never carries a refspec main's rule does not, and the merged tree is green on the PR's three files and gate C.

1. Measured against real git (2.43, local bare remotes)

Probe: for each case, a bare remote and a repository whose only remote.origin.fetch values are the ones named; the repository's own git fetch origin first (as a user would have it), then a copy of it per run of git fetch -q --prune --no-prune-tags --refmap= <origin|path> <refspecs>, the refspecs from main's origin_fetch_refspecs (2a119f281) and from the merged tree's; then the repository's own git fetch origin again. Wherever a tools' fetch exited 0, every remote branch's refs/remotes/origin/<name> held that branch's commit (all 43 cases, both rules, both ways).

Branches are main, x, release/x at three unrelated commits unless the case says otherwise. Columns: own fetch; main's tools via origin/path; this PR's via origin/path; the repository's own fetch run AFTER this PR's tools' fetch, origin/path.

case                                                 own  main     PR       own-after-PR
C01 release/* onto origin/* (#1066)                  0    128/128  0/0      0/0
C02  same, unforced                                  0    128/128  0/0      1/1  (non-ff)
C02b same, unforced, release/x descends from x       0    128/128  0/0      0/0
C02c same, unforced, x descends from release/x       0    128/128  0/0      1/1  (non-ff)
C03 release/* onto origin/*, no branch x             0    0/0      0/0      0/0  (origin/x pruned, restored)
C04 release/* onto origin/* beside the default glob  128  128/128  0/0      128/128
C05 release/x onto origin/x                          0    128/128  0/0      0/0
C05u same, unforced                                  0    128/128  0/0      1/1  (non-ff)
C06 x onto origin/release/x                          0    128/128  0/0      0/0
C07 heads/* onto origin/all/*, branch all/x          0    128/128  0/0      0/0
C07b heads/* onto origin/all/*, no clash             0    0/0      0/0      0/0  (all/* pruned, restored)
C07c default glob + heads/* onto all/*, no clash     0    0/0      0/0      0/0  (all/* pruned, restored)
C08 release/* onto origin/rel/*, branch rel/x        0    128/128  0/0      0/0
C09 main onto origin/main (own name, carried)        0    0/0      0/0      0/0
C10 release/* onto origin/release/* (carried)        0    0/0      0/0      0/0
C11 glob + main onto origin/upstream-main            0    0/0      0/0      0/0  (pruned, restored)
C11u same, unforced                                  0    0/0      0/0      0/0  (pruned, restored)
C12 negative ^refs/heads/x beside the glob           0    0/0      0/0      0/0
C13 negative beside the #1066 refspec                0    128/128  0/0      0/0
C14 pulls in origin/pr/ beside the glob              0    0/0      0/0      0/0
C15  same, branch pr/7                               128  128/128  128/128  128/128
C16 pulls in origin/pr/ beside main only, pr/7       0    128/128  128/128  0/0  (known limit)
C17 pulls onto origin/* (glob), branch 7             0    0/0      0/0      0/0
C18 glob + refs/pull/7/head onto origin/main         128  128/128  128/128  128/128
C18b refs/pull/7/head onto origin/main alone         0    128/128  128/128  0/0  (NIT 1)
C19 mirror +refs/*:refs/*                            0    0/0      0/0      0/0
C20 tags onto origin/*, branch and tag x             0    0/0      0/0      0/0
C20u same, unforced                                  0    0/0      0/0      1/1  (non-ff; main 1/1 too)
C21 tags into origin/tags/                           0    0/0      0/0      0/0
C22 one tag refs/tags/v1 onto origin/v1              0    0/0      0/0      0/0
C23 mirror into origin/                              0    0/0      0/0      0/0
C24 mirror into origin/all/                          0    0/0      0/0      0/0
C25 refs/heads* onto origin/h*, branch h/x           0    128/128  128/128  0/0  (NIT 1)
C25b glob + refs/he* onto origin/he*, branch heads/x 128  128/128  128/128  128/128
C26 trunk onto origin/t, no trunk                    128  128/128  0/0      128/128
C27 own-name pattern feat-* (unforced, carried)      0    0/0      0/0      0/0
C28 one own-name refspec configured twice            0    0/0      0/0      0/0
C29 default glob alone                               0    0/0      0/0      0/0
C30 glob + #1066 refspec + pulls in origin/pr/       128  128/128  0/0      128/128
C31 refs/changes/* in origin/changes/                0    0/0      0/0      0/0
C32 heads/* onto origin/*-a, branches q and q-a      0    128/128  0/0      0/0
C33 feat-* onto origin/feat-*-old, feat-1, feat-1-old 0   128/128  0/0      0/0
C34 own-name folder, unforced (carried)              0    0/0      0/0      0/0

Mirrors, tags onto the glob, tags in a folder, one tag, the short and object-id forms (C19-C24 and the table rows) are unchanged from main: main's and this rule's carried lists are identical for each.

Prune and overwrite, data loss. No commit is lost in any case. Where this PR leaves out a refspec main carried:

I found no case where main's tools' fetch worked and this PR's fails, and none where the answer (pin or refuse) differs other than by the fetch now working.

Differential, pure function, 9,080 configs (44 sources x 22 destinations, forced and unforced, negatives, 7,140 pairs, every single doubled): this rule carries nothing main's does not; it newly leaves out 144 distinct refspecs, every one with a source under refs/heads/ and a destination that is not the branch's own name.

2. The equivalence claim

Holds. #946's guard put back on clause 3 (M04) gives the same list as the PR on all 9,080 configs. Reason: a refs/heads/ source onto refs/remotes/origin/* that passes the guarded clause 3 reaches clause 4, which leaves it out unless the source is refs/heads/*, whose forced form is the first refspec, which the dedupe drops. Without the dedupe as well (R21 below) the only difference is the first refspec named twice, which git accepts (exit 0, measured). Clause 3 newly leaves out only refs/heads/ sources onto the glob; every other source onto the glob was already left out by main, so there is no refspec clause 3 now leaves out that it should carry.

3. Mutants (merged tree, one at a time over the three changed files, 136 tests; file restored by git checkout and its sha256 checked equal to the merged blob a5d4b429 after each)

mutant                                            failed of 136  result
M11 clause 4 reduced to the glob alone            14   killed (4 another-name rows x2 tools, 6 end-to-end)
M12 negative-refspec skip removed                  0   equivalent (below)
M13 forcing removed                                5   killed (carried-branches-of-a-pattern x2, ...forced_as_the_first_is, ...)
M14 dedupe removed                                 0   equivalent (below)
R15 clause 4 asked of patterns only                7   killed (one-branch rows)
R16 clause 4 compares only the part before `*`     -   NOT RUN (usage window closed); see NIT 2
R17 clause 4 compares only the part after `*`     14   killed
R18 HEADS_PREFIX without its `/`                   7   killed (carried rows)
R19 clause 4 asked before clause 3                 0   equivalent (below)
R20 clause 3 leaves out any folder glob           10   killed (carried rows)
R21 #946's guard back AND dedupe removed           4   killed (branches-glob-the-first-refspec rows)
R22 clause 4 compares the whole source             -   NOT RUN (usage window closed); function-level it drops every carried heads row, so the 3 carried rows x2 tools would kill it

Equivalent, with reasons: M12, a negative refspec has no :, so its destination is empty and clause 1 leaves it out; git refuses ^src:dst ("fatal: invalid refspec", 128, measured), and the function's output is identical on all 9,080 configs. M14, git accepts a refspec named twice (exit 0, measured), and since clause 3 now drops the first refspec only a doubled configured refspec reaches the dedupe. R19, clauses 3 and 4 commute (identical on all 9,080 configs). The writer's M04 (guard back alone) is identical on all 9,080 configs (section 2).

4. Merge with current main

git merge-tree --write-tree origin/main a9289bd96 (main 2a119f281): exit 0, no conflict, tree 2e7a4817dd3d. git diff --stat origin/main <tree> names exactly the PR's six files, 385+/122-, the PR's own stat. scripts/family.py and scripts/shape_materialize.py merged three-way (main changed both since 5e539a878: family.py in #1072 and #1084, shape_materialize.py in #1073; main touched none of the eight test files run below); for each, the lines the merged tree changes against main are byte-identical to the lines the PR changes against its base. The other four files are the PR head's blobs. A real merge commit in a scratch worktree (d1de88f69) has the same tree.

5. Tests on the MERGED tree

/usr/bin/python3 -m pytest -q -p no:cacheprovider, EMAIL set, GIT_ALLOW_PROTOCOL, GIT_SSH_COMMAND and the LANES_* variables unset, one basetemp:

  • the PR's three files plus gate C's seven (8 files, union): 427 passed in 318 s. Per file: test_bump_leg_fetch_refspecs.py 55, test_bump_leg_reachability_words.py 34, test_family_bump_fetch_refspecs.py 47 (136, as claimed), test_no_auto_maintenance.py 60, test_repo_hygiene.py 100, test_cli_reference.py 23, test_agents_md_codes.py 47, test_family_dry_run.py 61 (gate C 393, as claimed).
  • main's scripts (2a119f281) with the PR's three test files: 21 failed, 115 passed, as the PR says (the 9 end-to-end rows and 6 table rows per tool).

The new rows fail for the right reason: on a mutant that carries the refspec, each end-to-end row fails at assert dry.stderr == "" on the note "git fetch origin in spec exited 128; answering from the remote-tracking refs this checkout already has", after its premise (clashes) has proved the own fetch exits 0 and the pair 128 with "Cannot fetch both ... to refs/remotes/origin/".

6. Sonar

origin_fetch_refspecs measures 15, Sonar's limit, by the SonarQube analyzer (analyze_code_snippet, python:S3776): the function as merged raises nothing, and the same function with one more if ...: continue raises "reduce its Cognitive Complexity from 17 to the 15 allowed", so it is 15 exactly. By hand: for 1, six ifs at nesting 1 (2 each) 12, the conditional expression at nesting 1 2. Main's was 14 (one if fewer, one and more). SonarCloud's gate on the head: passed, 0 new issues.

7. Findings

NIT 1 (docs; a clash class the text says cannot happen). gives_a_branch_another_name's docstring ends "A source not under refs/heads/ names no branch, and is not asked here." A pattern whose part before the * is a start of refs/heads/ names every branch: C25, +refs/heads*:refs/remotes/origin/h* with a branch h/x, own 0, main 128/128, this PR 128/128. And a source that is no branch's put on one branch's name still clashes where no folder is involved: C18b, +refs/pull/7/head:refs/remotes/origin/main, own 0, main 128/128, this PR 128/128. origin_fetch_refspecs's "WHAT CAN STILL CLASH" and _leg_fetch_refspecs's "Two ways remain" name only the folder case. Neither is a regression (main is identical) and both refspecs are exotic. Fix in the follow-up: reword the three sentences to name both, and add them to #1027's list; or, for the pattern form, let clause 4 treat a source as naming branches where source.startswith(HEADS_PREFIX) or (star and HEADS_PREFIX.startswith(fixed)), as can_name_a_tag does for tags (such a source is never the branch's own name, so it is left out).

NIT 2 (tests; one surviving mutant of clause 4). R16 (clause 4 comparing only the part of the destination before the *) did not finish before the usage window closed. At the function level it differs from this PR only on refspecs whose * suffix differs, such as +refs/heads/*:refs/remotes/origin/*-a or refs/heads/feat-*:refs/remotes/origin/feat-*-old, and no row uses one, so it would very likely survive. Add a WHICH_CLAUSE row such as pytest.param("+refs/heads/*:refs/remotes/origin/*-old", False, id="another-name-suffix"); C32 and C33 measure that such a refspec clashes (own 0, main 128/128).

NIT 3 (docs, bump-leg.py). The #1066 paragraph of _leg_fetch_refspecs says what the leg kept under another name holds the branch of that name or is pruned "until the leg's own next git fetch origin". For an unforced refspec that is untrue (C02, C02c, C05u: that fetch exits 1, non-fast-forward, every time); shape_materialize's docstring says so and this paragraph does not. Fix: add the unforced exception and the way back, git fetch --force origin (measured: exit 0, the ref restored).

NIT 4 (the accepted cost, for the record). After a run of either tool, a repository with an unforced refspec that puts one branch on another's name, where the two commits are not in line, has its own git fetch origin exit 1 until --force (C02, C02c, C05u); before this PR the tools' fetch failed there instead and left the ref alone. No commit is lost, the PR documents it, and it is the cost #905 accepted (main does the same for unforced tags onto the glob, C20u). If it is wanted, a follow-up could have both tools name the refspec they leave out when it is unforced and writes under another branch's name.

NIT 5 (Sonar headroom). origin_fetch_refspecs is at 15 of 15. The next clause will need the clause chain moved into a helper (for example _left_out(source, destination) -> bool).

8. Docs elsewhere

git grep for origin_fetch_refspecs, _leg_fetch_refspecs, _origin_fetch_refspecs, #946 and refs/remotes/origin/* on the merged tree: outside the three scripts the hits are the PR's own test files and tests unrelated to the rule (test_adopt_split_branch.py, test_bump_leg_dry_run_truth.py, test_family_bump_reachability_words.py, test_family_bump_to_local_only.py, none stating the rule) and adopt-project.py:9413 (branches of check, not the fetch). README, AGENTS.md, docs/ and the handbook do not describe the rule; docs/release-notes/v1.1.0.md:450 is history. Every docstring and comment the PR touches matches the code and the measurements above, except NITs 1 and 3. The untouched bump-leg.py #1037 paragraph ("one onto the branches' names from another namespace") is still true, a subset of the new rule.

Scratch: clone _lane4-scratch/rv1087-clone, worktrees rv1087-merged-wt, rv1087-mut-wt, rv1087-mut2-wt, rv1087-mainscripts-wt; probes and logs under the session scratchpad's rv1087/.

Lane: openreposhape-4 (openRepoShape-4)

@sourcery-ai

sourcery-ai Bot commented Oct 21, 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 2 days and 12 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 16f9a0e into main Oct 21, 2026
5 checks passed
@brettheap
brettheap deleted the fix/1066-refspec-onto-origin-glob branch October 21, 2026 12:22
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>
This was referenced Oct 21, 2026
brettheap added a commit that referenced this pull request Oct 21, 2026
…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)

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

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

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.

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 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:
- L1-K9: the three #262 rows, the case-aside row, `test_a_symlink_at_the_copy_itself_is_in_the_way` and `test_a_link_at_the_copy_is_one_finding_by_the_rule_that_sees_it`.
- L1-J1, refused: the D9 row and the `.git/description` and `.git/hooks` rows (new words), four `J1-at-a-path-to-no-*` rows, `at-a-path-to-no-directory-in-dot-git`, `J1-at-a-path-into-the-object-store` and `at-a-path-into-a-legs-store`.
- L1-J2: three refused `J2-*` rows, two accepted `J2-*` rows, and the end-to-end `J2-back-out-of-refs-and-origins-head`.
- L1-K2: three `HELD_THROUGH` rows and the shape's own pin.
- The ref store: three no-probe rows, the probe's answer, no work directory, eight `ON_REFTABLE` rows, no reflogs, a detached source HEAD, and the probe made once.

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

`git merge-tree --write-tree` of this head against:
- origin/main `c13c3985f`: clean. Main moved after the runs above, which were made on `c13c3985`: #1081 (`06cbd478`) and #1072 (`1e7971cd`) have landed, and #1072 touches `adopt-project.py`. Against origin/main `1e7971cd` the head is clean too, tree `cf4e2e68`.
- #1073 (`l3-914-adopt-record-and-doctor`, `b58a4e2cc`): clean. #1072 (`l3-918-source-and-locale`, `2ffd82356`) was clean too, and has landed since as `1e7971cd` (above).
- #1031 (`fix/910-check-throwaway-repos`, `74c17705e`): conflicts in `adopt-project.py`, 3 hunks. Both moved the one `_SymlinkProbe` in `_entry_findings`, and both added attributes to `_SymlinkProbe.__init__`. To resolve, make the probe before `_leg_findings` as this branch does, keep #1031's `unasked`, `linked` and `asked` lines, and keep both sets of attributes.
- #1070 (`fix/979-named-link-one-finding`, `20d2d4ba6`): conflicts in `adopt-project.py`, 7 hunks. Both added parameters to `_kept_pin_findings` and `_pin_findings` and changed their docstrings, the `_leg_findings` docstring and the "A SYMLINK OR A SUBMODULE" comment. To resolve, take both parameters (`named_links`, then `source` and `probe`) and both texts, and in that comment keep this branch's sentence on what `place` does at the copy, with #979's clause after it.
- #1081 and the other open pull requests touch none of these files; lane 5's #1090 (`fix/912-pin-questions`, `d5389eb6`, for #912) also changes `adopt-project.py`, and the head merges with it clean, tree `0153f3e7`.

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:

- **#1070.**
  - `_kept_pin_findings` and `_pin_findings` take both sets of parameters: `named_links` first, as main passes it, then `source` and `probe`, which the calls pass by keyword.
  - #979's `named_links` check stands in this branch's body as it is. A link `named_links` holds at the pin's file is left out. A walk that meets one through a link above has stopped, so the pin's file is asked at its literal path, where nothing is found, and the walk's own finding is left out too (`walk.empty`).
  - Both docstrings are kept, #979's paragraph first. `_leg_findings` passes `named_links, source=source, probe=probe`.
  - The "A SYMLINK OR A SUBMODULE" comment keeps this branch's sentence, that `place` stops at a link AT the copy and writes nothing through it. Main's "and as a write THROUGH it" is dropped, since it is not true. #979's clause follows.
- **#1031.**
  - In `_entry_findings`, #1031's `_paths_git_refuses` question comes first. The one `_SymlinkProbe` is then made before `_leg_findings`, because the walks of the pins' files ask it. #1031's `linked`, `asked` and `unasked_notes` lines and its `probe.notes()` are kept.
  - `_SymlinkProbe` keeps #1031's `unasked` beside this branch's `_refs`. `ref_store` now does what #1031 has `symlinks` do: where `git init` fails otherwise than with an `OSError`, it takes git's defaults (the files backend, with reflogs) and keeps git's words in `refs_unasked`. Before the merge, that failure raised out of `check` as "a git or gh command failed".
  - `notes()` gives each NOTE. The symlink NOTE keeps #1031's words, and the ref NOTE is new. Both take git's words from one `_init_refused`.
- Nothing else #1031 or #1070 changed is called by this branch's code. #1070's other hunks are the docstrings of `_links_at_a_copy` and `_links_at_a_pin`. #1031 leaves `_PlannedRoot`, `_checks_out_symlinks` and `_refuse_symlinks_checked_out_as_files` as they were.

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):
- git 2.43.0, both own test files: 323 passed, 3 skipped (the reftable rows).
- git 2.47.1 first on PATH, both own test files: 326 passed.
- The two test files on main `2a119f28`'s code: 46 failed, 277 passed, 3 skipped. These are the 42 of before plus the four new ref-probe rows.
- Mutants, re-run where the resolution touched them, each killed:
  - M8, M9, M15, M16, M17, M18 and M22 by an `AssertionError`.
  - New: M21, the ref probe's failed `git init` raising as before. It fails two rows because the `CommandFailed` or `Refusal` it lets out escapes, and one row by an `AssertionError`.
  - M23 removes #979's `named_links` skip at a pin's file. Only #979's own `test_a_named_link_at_a_copy_or_a_pin_is_one_finding` holds it, and that test fails by a `ValueError`, not an `AssertionError`: it unpacks exactly one finding (`tests/test_adopt_symlink_target_bytes.py:667` and `:818`), and the mutant gives two.
- Gate, git 2.43.0: the 125 test files the merged tree has for `adopt-project.py`, with `test_shape_doctor.py`, `test_scaffold_leg_path_collisions.py` and the trio, in two shards. Shard B (62 files): 4507 passed, 15 skipped, `END 2026-10-21T13:14:42Z rc=0`. Shard A (63 files) was running at the checkpoint: at 87% at 13:43Z, with no failure so far.

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.
  - Each return value stands for what the loop did before. None asks the next disk, `[]` is #979's named link, and a list of one line is the finding.
- **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`:
- M17 and M23, re-anchored on the new helpers: the through-link fix becomes the kept entry's, and `_kept_pin_finding`'s `return []` for #979's named link is dropped;
- M24: every guessed directory says what `refs/heads` holds;
- M25: no "the reflog of";
- M26: the K9 sentence's old "writing nothing,";
- M28: rv1091's R8;
- M8, re-run.
rv1091 ran R8 at `160de118`, and the NIT 2 rows kill it (2 of 4 failed).

**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`:
- origin/main `7e00834cf`: clean, tree `0b8a5bc9`. #1087 (`16f9a0e81`) and `7e00834cf` landed after the merge and change none of this branch's files. #1087 changes `scripts/shape_materialize.py`, whose `place` and `copy_in_the_way` the K9 sentence describes, but only the fetch for a branch's answer.
- #1090 (`1ad76055b`): clean, tree `108c3a8e`.
- origin/main `6da757cbe`, re-fetched at about 13:10Z, after #1090 landed: clean, tree `283d7b52`.
  - #1090 changes `adopt-project.py` in the NOTE `check` prints for a pin's digest and in the docstring of `_neutral_product_pins`.
  - Neither is code this branch changes, and no row here pins those words.

Lane: openreposhape-5 (openRepoShape-5)

Review: rv1091, lane 5's independent reviewer, not the writer (w904): NOT READY at `8f5fd0fd` (#1091 issuecomment-6316443939) for two SHOULDs, with the merge `1cc9180f`'s resolution READY; the fix round took both SHOULDs and three NITs, and the re-check is READY at `160de118` (#1091 issuecomment-6317666157); its one open gap, R6's row, is filed as #1116. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 160de11 passed tests, tests-git-bash and SonarCloud; the head 551452b is a clean update-branch merge of main 823624d, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree c1dcef6 over main 823624d: the adopt_root_shape_paths, adopt_root_symlinks, repo_hygiene, agents_md_codes, cli_reference sets, 496 passed, 3 skipped. 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 #904

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
… leaves out a pattern like `refs/heads*` that puts a branch under another name, its clauses move into `_left_out`, and the docstrings name the clashes left and the way back (#1100) (#1157)

#1100 holds five follow-ups from RV1087's review of #1087 (issuecomment-6314349196). This PR does all five:

1. Take the clause-4 fix for a pattern such as `refs/heads*`, and name the clash that is left, `+refs/pull/7/head:refs/remotes/origin/main`, in the three places that list clashes.
2. Add the `WHICH_CLAUSE` row that kills R16.
3. In `bump-leg.py`, name the unforced exception and the way back, `git fetch --force origin`.
4. Write down the accepted cost where the cost is described.
5. Move `origin_fetch_refspecs`' four clauses into a helper, `_left_out`.

Fix round 1 (`baa19972a`) answers the independent review rv1157/rv1157b (issuecomment-6347113460: NOT READY, 2 SHOULD, 4 NIT, no BLOCKER). It does all six. See "Fix round 1" below.

The NIT round (`368b87e9b`) answers rc1157's re-check of fix round 1 (issuecomment-6352215076: READY, 3 NITs, all in this body). It adds one test row, which kills F2, the survivor of fix round 1, and corrects three statements in this body: the reason F2 was left, the longest line's length and the #1131 merge-tree line. The scripts are unchanged. See "NIT round, after the re-check of fix round 1" below.

Measured by a probe on git 2.43 and git 2.47.1, with local bare remotes only. The two gits gave identical tables. For each case the probe ran:
- the repository's own `git fetch origin`;
- the tools' fetch, `git fetch -q --prune --no-prune-tags --refmap= <origin|path> <refspecs>`, with the refspecs from main's `origin_fetch_refspecs`, once through `origin` and once through a path;
- the repository's own fetch again ("own-after").

Where own-after failed, it then ran `git fetch --force origin`, a plain fetch, and the tools' fetch followed by the repository's own fetch ("again"). Branches are `main`, `x` and `release/x` at three unrelated commits unless the case says otherwise.

```
case                                                    own  main o/p  own-after  force, plain, again
C25  +refs/heads*:refs/remotes/origin/h*, branch h/x    0    128/128   0/0        -
C18b +refs/pull/7/head:refs/remotes/origin/main alone   0    128/128   0/0        -
C02  release/* onto origin/*, unforced                  0    0/0       1/1        0 (ref back), 0, 1
C02c same, x descends from release/x                    0    0/0       1/1        0 (ref back), 0, 1
C05u release/x onto origin/x, unforced                  0    0/0       1/1        0 (ref back), 0, 1
C20u tags onto origin/*, unforced, branch and tag x     0    0/0       1/1        0 (ref back), 0, 1
C32  heads/* onto origin/*-a, branches q and q-a        0    0/0       0/0        -
C33  feat-* onto origin/feat-*-old, feat-1, feat-1-old  0    0/0       0/0        -
```

These match the issue's numbers. C32 and C33 exit 0 because #1087 leaves them out. The own-after failure is `! [rejected] release/x -> origin/x (non-fast-forward)`. C02b, where `release/x` descends from `x`, gives 0. So the condition is that the kept commit does not descend from the branch's, not that the two commits are unrelated. The old paragraph's words "not in line" were wrong for C02c, and the new text corrects them.

Main has since moved to `961640c6` (#1155, which touched only `docs/release-notes/v1.2.0.md`). The four files this PR changes are byte-identical there, so nothing above changes.

**`scripts/shape_materialize.py`**

- **Clause 4** (`gives_a_branch_another_name`) now reads a source that is not under `refs/heads/` as naming branches when it is a pattern whose part before the `*` is a start of `refs/heads/`. This is how `can_name_a_tag` reads `refs/*` for tags:

  ```
  fixed, star, _ = source.partition("*")
  if not source.startswith(HEADS_PREFIX):
      return bool(star) and HEADS_PREFIX.startswith(fixed)
  ```

  Such a pattern's `*` also takes what the pattern leaves of `refs/heads/`. So no destination under `refs/remotes/origin/` gives a branch its own name, and every such refspec is left out. The only pattern that keeps the names, `refs/heads*:refs/remotes/origin*`, writes outside `origin/`, and clause 1 leaves it out first. The shorter starts (`refs/`, `*`) can name a tag, and clause 2 leaves them out first.
- **The fix was taken only after measuring it.** A pure-function differential of main against the branch ran over 39,385 configs: 43 sources x 31 destinations, forced and unforced, negatives, pairs, doubles, and each one beside the first refspec.
  - The branch carries nothing that main does not.
  - It newly leaves out 207 distinct refspecs. Every one has a pattern source whose part before the `*` starts `refs/heads/` but neither `refs/heads/` nor `refs/tags/` (`refs/h*` to `refs/heads*`).
  - The helper alone (main plus `_left_out`) gives main's output on all 39,385 configs.
  - The fix alone and the fix plus the helper give the same output as each other on all 39,385.
- **The probe of all 53 cases**, on both gits, changed no outcome outside that class: exits, own-after, force, plain and again are identical for the 46 cases outside the class, C03u, C17u and C32o included. The seven cases in the class changed:

  ```
  case                                                      own  main o/p  branch o/p  own-after (branch)
  C25   refs/heads* onto origin/h*, branch h/x              0    128/128   0/0         0/0
  C25u  same, unforced                                      0    128/128   0/0         1/1; --force 0, plain 0
  C25n  refs/heads* onto origin/h*, no clash                0    0/0       0/0         0/0 (pruned, written back)
  C25g  default glob + refs/heads* onto origin/h*           0    0/0       0/0         0/0 (pruned, written back)
  C25b  default glob + refs/he* onto origin/he*, heads/x    128  128/128   0/0         128/128
  C25c  refs/he* onto origin/he*, branch heads/x            0    128/128   0/0         0/0
  C25h  glob + refs/h* onto origin/hh/*, ref refs/hidden/1  0    0/0       0/0         0/0 (pruned, written back)
  ```

  Wherever a tools' fetch exited 0, every remote branch's `refs/remotes/origin/<name>` held that branch's commit (all cases, both rules, both ways, both gits). A fetch that exited 128 changed no ref.
- **`_left_out(source, destination)`** holds the four clauses, in their order and with their comments. `origin_fetch_refspecs` now asks it once. The SonarQube analyzer (`python:S3776`, `analyze_code_snippet`) raises nothing on an excerpt of the four functions as committed, with their docstrings dropped. With four more `if ...: continue` in the loop it reports "from 17 to the 15 allowed", so `origin_fetch_refspecs` measures 9 (it was 15). By hand, `_left_out` is 3 and `gives_a_branch_another_name` is 2.
- **The docstrings:**
  - `gives_a_branch_another_name` no longer ends "A source not under `refs/heads/` names no branch". It names the pattern and points to WHAT CAN STILL CLASH.
  - The A BRANCH UNDER ANOTHER NAME paragraph names the pattern.
  - A new THE COST, ACCEPTED paragraph says that, after either tool runs, an unforced refspec's own fetch exits 1 on every run until `git fetch --force origin`. Where the refspec was carried, the tools' fetch exited 128 in those same states and left the ref alone. No commit is lost, and main already behaves this way for unforced tags onto the glob.
  - WHAT CAN STILL CLASH now names any carried refspec whose source names no branch, onto a name a branch has: the pull requests in `origin/pr/` and C18b's single name.
  - Fix round 1 changes three of these paragraphs and adds one (below).

**`scripts/bump-leg.py`**, docstrings only. An AST comparison with the docstrings blanked shows the code is identical to main's, at both heads.
- The #1066 paragraph of `_leg_fetch_refspecs` names the `refs/heads*` pattern. It adds NOT WHERE THE LEG'S REFSPEC WAS WRITTEN WITHOUT ITS `+`, with the way back, `git fetch --force origin`.
- "Two ways remain" now names the second way as a source that names no branch, onto a name a branch has, in a folder or onto a single name (C18b).

**`tests/test_family_bump_fetch_refspecs.py`**
- New `WHICH_CLAUSE` rows (both tools' tests ask them):
  - `another-name-suffix`, `+refs/heads/*:refs/remotes/origin/*-old`, left out (item 2);
  - `another-name-heads-pattern`, `+refs/heads*:refs/remotes/origin/h*`, left out;
  - `another-name-heads-pattern-shorter`, `refs/he*:refs/remotes/origin/he*`, left out;
  - `carried-not-refs-heads`, `+refs/headsx/*:refs/remotes/origin/headsx/*`, carried;
  - fix round 1: `another-name-heads-pattern-refs-h` and `another-name-heads-pattern-suffix` (below).
- New `BRANCH_UNDER_ANOTHER_NAME` row `heads-pattern-into-a-folder`, `+refs/heads*:refs/remotes/origin/h*`, with `x-w1066` and `h/x-w1066`. It runs end to end through `family.py bump` and through `bump-leg.py` via `origin` and via `--local-remote-dir`, with the clash premise first.
- Fix round 1: `NO_BRANCH_A_PATTERN_TAKES` and its end-to-end rows (below).
- NIT round: `carried-refs-h-not-a-pattern`, `+refs/h:refs/remotes/origin/hy`, carried (below).

**`tests/test_bump_leg_fetch_refspecs.py`**: one docstring sentence, and in fix round 1 the leg's `NO_BRANCH_A_PATTERN_TAKES` rows.

`scripts/family.py` is unchanged. Its `_origin_fetch_refspecs` paragraph "A mount whose refspec git cannot satisfy" gives examples and claims no complete list, and it stays true.

One new commit, `baa19972a`, on `a35a97ea5`. No merge of main was needed: main's one new commit touches none of these files. The scripts' code is unchanged: `astsame.py` (AST with docstrings blanked) gives SAME CODE for `shape_materialize.py` against `a35a97ea5` and for `bump-leg.py` against main.

**SHOULD 1 (finding 1): two mutants of the fix survived the rows.** Two `WHICH_CLAUSE` rows are added. Both tools' tests ask them (`test_family_bump_fetch_refspecs.py` holds the table, and `test_bump_leg_fetch_refspecs.py` imports it):
- `another-name-heads-pattern-refs-h`, `+refs/h*:refs/remotes/origin/hh/*`, left out. This is the class's lower edge, the shortest start of `refs/heads/` that no earlier clause meets.
- `another-name-heads-pattern-suffix`, `+refs/heads*x:refs/remotes/origin/h*x`, left out. Git accepts it and maps branch `x` onto `origin/h/x` and `ax` onto `origin/h/ax` (probe section B, both gits).

On the pure function (`tools/rows.py`, `logs/rows.log`):
```
row                                     main     head      B1        B9        F2
+refs/h*:refs/remotes/origin/hh/*       carried  left out  carried   left out  left out
+refs/heads*x:refs/remotes/origin/h*x   carried  left out  left out  carried   left out
```
So each new row fails on main. B1 is killed by `-refs-h` and B9 by `-suffix`. F2 was the one survivor of fix round 1. The NIT round's row kills it (see "NIT round" and Mutants).

**SHOULD 2 (finding 2): leaving the class out changes the answer from pin to refuse** for a commit that only a non-branch ref reaches, where the pattern takes that ref too. The three places that said nothing is lost now say so:
- `shape_materialize.py`, `origin_fetch_refspecs`: LEFT OUT BY EITHER OF THE LAST TWO, NO BRANCH IS LOST TO THE ANSWER, followed by a new paragraph, BUT A COMMIT ONLY A REF THAT IS NO BRANCH REACHES IS REFUSED. It covers the pull requests onto the branches' names (left out since #946, refused on main too) and, since #1100, a ref of another namespace that a pattern from a start of `refs/heads/` takes. It says that every run of either tool refuses such a commit alike. It also says that a refspec whose source is that namespace itself is carried, and the commit is pinned through it. This is finding 2(b): one remote ref, two answers by refspec.
- `shape_materialize.py`, `_left_out`'s comment: a ref that is no branch, which a pattern takes too, no longer answers for one, so a commit only it reaches is refused, as one only a tag reaches is.
- `bump-leg.py`, the #1066 paragraph: "so no branch is lost to the answer", and a new paragraph, A COMMIT ONLY A REF OF ANOTHER NAMESPACE THE PATTERN TAKES REACHES IS REFUSED NOW (#1100), with the review's `heads-x-in` case and its `heads-x-out` control.

New end-to-end rows pin the refusal, `NO_BRANCH_A_PATTERN_TAKES` (`+refs/heads*:refs/remotes/origin/h*` with `refs/heads-archive/old-w1100`, and `+refs/h*:refs/remotes/origin/hh/*` with `refs/hidden/old-w1100`):
- `test_a_ref_a_heads_pattern_takes_answers_for_no_branch` runs `family.py bump --dry-run` and `bump`. Both refuse `member-to-local-only` in the plain words, with nothing in the holder moved and no ref of the mount moved.
- `test_a_ref_a_heads_pattern_takes_answers_for_no_branch_of_the_leg` runs `bump-leg.py` via `origin` and via `--local-remote-dir`. Both runs refuse `bump-leg-commit-local-only` in the plain words, with nothing in the root moved and no ref of the leg moved.
- Each first asserts its premises: the ref reaches the commit, no branch of the remote does, and no remote-tracking ref does. All 6 rows fail on main, where both runs pin (Rows failing on main).

The docstrings' claims, measured by `tools/claims.py` (the tools' own code from an exported tree: `fetch_leg` end to end, then `family.py`'s reader and `_on_a_remote_branch`). It ran on main `961640c6` and on `baa19972a`, on git 2.43 and 2.47.1. The two gits gave identical output on each tree (`logs/claims-{main,head}-{243,247}.log`):
```
case              refspec beside the glob                                ref holding C           main    head
C25h-in           +refs/h*:refs/remotes/origin/hh/*                      refs/hidden/1           PIN     REFUSE
C25h-unforced     refs/h*:refs/remotes/origin/hh/*                       refs/hidden/1           PIN     REFUSE
heads-x-in        +refs/heads*:refs/remotes/origin/h*                    refs/heads-archive/old  PIN     REFUSE
heads-x-unforced  refs/heads*:refs/remotes/origin/h*                     refs/heads-archive/old  PIN     REFUSE
hidden-out        +refs/hidden/*:refs/remotes/origin/hidden/*            refs/hidden/1           PIN     PIN
heads-x-out       +refs/heads-archive/*:refs/remotes/origin/h-archive/*  refs/heads-archive/old  PIN     PIN
pulls-out         +refs/pull/*/head:refs/remotes/origin/pr/*             refs/pull/7/head        PIN     PIN
pulls-onto-glob   +refs/pull/*/head:refs/remotes/origin/*                refs/pull/7/head        REFUSE  REFUSE
```
- `family.py`'s answer equals `bump-leg.py`'s in every row, and so do the two readers' lists.
- On the head, the tools' fetch exits 0 and prunes the class ref (`origin/hh/idden/1`, `origin/h-archive/old`). The repository's next own `git fetch origin` exits 0 and writes it back.

**NIT 1 (finding 3), `bump-leg.py`'s tag paragraph.** It now says that such a ref is pruned or holds the branch of that name, and that the leg's own next fetch writes it back. It adds NOT WHERE THE LEG WROTE THAT REFSPEC WITHOUT ITS `+` and the tag's commit does not descend from the branch's: then the leg's own fetch exits 1 on that run and every one after it, until `git fetch --force origin`. Measured (probe section C, both gits, main and head alike):
```
case       refspec                              own  tool  own-after  --force (ref back)  plain
C20        +refs/tags/*:refs/remotes/origin/*   0    0     0,0        -                   -     (origin/x back at the tag)
C20u       refs/tags/*:refs/remotes/origin/*    0    0     1,1        0 (back)            0     ! [rejected] x -> origin/x (non-fast-forward)
C20u-desc  same, the tag's commit a child of x  0    0     0,0        -                   -
```

**NIT 2 (finding 4).** The 109-character line is reflowed. The longest line this PR adds, over `961640c6..368b87e` in the four files, is 78 characters (`test_bump_leg_fetch_refspecs.py`'s `assert contains(bare_of(remotes), made, ref), f"premise: {ref} reaches it"`). In the scripts the longest added lines are 75 (`bump-leg.py`) and 76 (`shape_materialize.py`). `bump-leg.py`'s longest line, 81, is main's. This body said 79 until the re-check measured 78.

**NIT 3 (finding 5), the overclaim in `gives_a_branch_another_name`.** It no longer says each such refspec puts a branch under another name. Where the part before the `*` is all of `refs/heads` and the destination's `*` comes right after a `/`, git refuses the name it gives each branch, and the fetch writes no branch. The docstring now says this, and says that the pattern still takes the refs of another namespace, pointing to the new paragraph. Probe section B, both gits: `+refs/heads*:refs/remotes/origin/h/*` with branches `main`, `x` and `ax` exits 0, says `error: * Ignoring funny ref 'refs/remotes/origin/h//x' locally` (and the same for `h//main` and `h//ax`), and writes only `origin/h/foo/x` for `refs/headsfoo/x`.

**NIT 4 (finding 6), the stale merge-tree list.** Merge-trees were re-run at `baa19972a` against main and every open PR touching these four files (Merge-trees).

One new commit, `368b87e9b`, on `baa19972a`, in `tests/test_family_bump_fetch_refspecs.py` only: one `WHICH_CLAUSE` row, a sentence in the table's comment and one in its test's docstring. The scripts are byte for byte `baa19972a`'s (`git diff --name-only baa1997 368b87e` names that one file). The row is `pytest.param("+refs/h:refs/remotes/origin/hy", True, id="carried-refs-h-not-a-pattern")`. `WHICH_CLAUSE` is one table, which `tests/test_bump_leg_fetch_refspecs.py` imports and asks of `_leg_fetch_refspecs`, so the one row is two test ids, one in each file, and both tools' tests ask it.

**NIT 1 (finding 1): the reason given for leaving F2 was wrong, and the row it said could not be added can be.** The first body said F2 differs from this head only on a source that is no pattern and is a start of `refs/heads/` (`refs/`, `refs/h`, ... `refs/heads`), that no such full name can be a ref beside the remote's branches, and that a row for it would pin an arbitrary answer. Measured by `tools/claims.py` (the tools' own code from an exported tree: `fetch_leg` end to end, then `family.py`'s reader and `_on_a_remote_branch`), on main `961640c6`, on this head and on this head with F2 applied, on git 2.43 and 2.47.1 (`logs/claims-{main,head,f2}-{243,247}.log`; the two gits gave identical output on each tree):

```
case                   refspec beside the glob                     ref holding C   main    head    F2
refs-h-no-pattern      +refs/h:refs/remotes/origin/hy              refs/h          PIN     PIN     REFUSE
refs-head-no-pattern   +refs/head:refs/remotes/origin/hz           refs/head       PIN     PIN     REFUSE
refs-heads-no-pattern  +refs/heads:refs/remotes/origin/hx          refs/heads      the remote cannot hold refs/heads and refs/heads/main
hidden-out             +refs/hidden/*:refs/remotes/origin/hidden/* refs/hidden/1   PIN     PIN     PIN
C25h-in                +refs/h*:refs/remotes/origin/hh/*           refs/hidden/1   PIN     REFUSE  REFUSE
```

- Only `refs/heads` itself cannot be such a ref: `git update-ref` refuses it with "cannot process 'refs/heads/main' and 'refs/heads' at the same time" (exit 128, no ref made). `refs/h` and `refs/head` are made beside `refs/heads/main` with exit 0.
- With `+refs/h:refs/remotes/origin/hy` and a commit C that only `refs/h` reaches, main's code and this head's carry the refspec and PIN C. Under F2 both tools leave it out and REFUSE C (`bump-leg-commit-local-only`, `family_answer=False`): the tools' fetch prunes `origin/hy`, which the repository's own fetch had written, and the next own fetch writes it back.
- So this head's answer is not arbitrary. It is the rule ANY OTHER SOURCE NAMES NO BRANCH, the same as `hidden-out`'s: a source that is no pattern takes the ref of its name. Where the remote has no ref of that name, git completes the name instead: with a branch named `refs/h` (the ref `refs/heads/refs/h`) beside `hy`, the tools' fetch line exits 128 with `Cannot fetch both refs/heads/hy and refs/heads/refs/h to refs/remotes/origin/hy`, on git 2.43.0 and 2.47.1, on main as here. That is the WHAT CAN STILL CLASH shape (lane 5's re-check, issuecomment-6371455922, NIT 1).
- On the pure function (`tools/rows.py`, `logs/rows.log`, `origin_fetch_refspecs` alone):

```
row                                       main      head      B1        B9        F2
+refs/h:refs/remotes/origin/hy            carried   carried   carried   carried   left out
+refs/head:refs/remotes/origin/hz         carried   carried   carried   carried   left out
+refs/h*:refs/remotes/origin/hh/*         carried   left out  carried   left out  left out
+refs/heads*x:refs/remotes/origin/h*x     carried   left out  left out  carried   left out
```

- **The new row passes on main by design,** as `another-name-suffix` does, because main's clause 4 (`gives_a_branch_another_name`) answers `False` for every source not under `refs/heads/` (`961640c6`, `scripts/shape_materialize.py` line 1704), and `refs/h` meets no earlier clause. It passes on this head. It kills F2: with F2 applied to `scripts/shape_materialize.py`, `-k carried-refs-h-not-a-pattern` over both test files gives 2 failed (`test_which_clause_leaves_each_refspec_out[carried-refs-h-not-a-pattern]` and `test_which_clause_leaves_each_refspec_out_of_the_legs_fetch[carried-refs-h-not-a-pattern]`), and over the 90 rows of Mutants, 2 failed and 88 passed. The runs are in `logs/mut-summary.log`: the row alone, 2 passed on main's code (`logs/mut-ROW-main-code.log`), 2 passed on this head (`logs/mut-ROW-head.log`), 2 failed under F2 (`logs/mut-ROW-F2.log`).
- The Mutants section below replaces the F2 sentence with this result.

**NIT 2 (finding 2): the line length.** Fix round 1's NIT 2 sentence now says 78, and names the scripts' 75 and 76 (above). The new commit adds lines of at most 76 characters, all ASCII, and no assert that joins conditions with `and` or `or`.

**NIT 3 (finding 3): the #1131 merge-tree line.** #1131's head is `55c30f20` now (its NIT round), not `a4beec9e`. `git merge-tree --write-tree 55c30f2 368b87e` exits 0 with tree `e036c02d6a12`; at `baa19972a` it gave `bd6ebc5cd3af`, as the re-check measured. See Merge-trees.

Main's production code (`961640c6`: `scripts/shape_materialize.py` and `scripts/bump-leg.py`) was put in the worktree under this head's two changed test files. Then the committed files were put back, with sha256 and dirty=0 checked (`logs/mut-MAIN-code-head-tests.log`). The result is **17 failed, 106 passed** at `baa19972a`, and **17 failed, 108 passed** at `368b87e9b` (`logs/mut-MAIN-code-head-tests.log`, 125 rows): the NIT round's 2 rows pass on main, by design. The failures are exactly the rows the fix decides:

- `WHICH_CLAUSE` `[another-name-heads-pattern]`, `[-shorter]`, `[-refs-h]` and `[-suffix]`, in both files: 8. Each is carried on main ("Left contains one more item").
- `test_a_branch_put_on_another_branchs_name_fails_no_fetch[heads-pattern-into-a-folder]` (member), and `..._of_the_leg[origin-heads-pattern-into-a-folder]` and `[local-remote-dir-heads-pattern-into-a-folder]` (leg): 3. The fetch exits 128 on main, so the runs give the failed-fetch note.
- Fix round 1's `NO_BRANCH_A_PATTERN_TAKES` rows: `test_a_ref_a_heads_pattern_takes_answers_for_no_branch[heads-pattern]` and `[refs-h-pattern]` (member), and `..._of_the_leg[origin-heads-pattern]`, `[origin-refs-h-pattern]`, `[local-remote-dir-heads-pattern]` and `[local-remote-dir-refs-h-pattern]` (leg): 6. On main the pattern is carried, so the real run pins the commit instead of refusing it.

The first round's 7 are among these. Fix round 1 adds 10: `-refs-h` and `-suffix` in both files, and the 6 end-to-end rows. `another-name-suffix` and `carried-not-refs-heads` pass on main by design. They kill mutants (below).

NIT round, on `368b87e9b` with git 2.43, through `tools/mut.sh` (`logs/mut-summary.log`). The scripts are `baa19972a`'s, so these are fix round 1's mutants over the NIT round's tests. Each mutant was applied alone to the committed `scripts/shape_materialize.py`. The runs covered both changed test files with `-k "which_clause or another_branchs_name or heads_pattern_takes"` (90 rows, 35 deselected). After each run the committed files were put back, and their sha256 and dirty=0 were checked: 9 of 9 restores in the script matched (`shape_materialize.py` `16c6ec378c36...`, `bump-leg.py` `8f4af9c5e78c...`).

```
mutant                                                        failed  result
main's code (961640c) under these two files, all 125 rows     17     the rows above (108 passed)
F2 the fix's `bool(star) and` dropped                           2     killed (carried-refs-h-not-a-pattern x2)
B1 the class drawn from `refs/he` up (`refs/h*` carried)        5     killed (-refs-h x2, refs-h-pattern x3)
B9 the fix only where the `*` ends the source                   2     killed (-suffix x2)
B4 the fix undone (`return False` in its place)                17     killed (every row the fix decides)
```

F2 is killed now. At `baa19972a`, with 88 rows, it survived (88 passed); B1 (5 failed), B9 (2) and B4 (17) gave the same counts as here. The earlier sentence said F2 differs from this head only on a source that is no pattern and is a start of `refs/heads/`, and that no such full name can be a ref beside the branches. The first half holds: over the 39,385-config corpus F2 differs from this head only there (`refs/`, `refs/h`, ... `refs/heads`). The second half held for `refs/heads` alone. `refs/h` and `refs/head` are refs a remote can hold beside its branches, and the head carries `+refs/h:refs/remotes/origin/hy` and pins a commit only that ref reaches, where F2 refuses it (NIT round, NIT 1). The row `carried-refs-h-not-a-pattern` holds that answer.

At `a35a97ea5`, whose code this head keeps, the first round measured these over `WHICH_CLAUSE` in both files (66 rows then):

```
mutant                                                failed  result
R16 clause 4 compares only the part before the `*`      2     killed (another-name-suffix x2); on main's own rows it survives (102 passed)
R17 clause 4 compares only the part after the `*`       8     killed (the four another-name rows x2)
F3  only `refs/heads*` itself, not shorter starts       2     killed (another-name-heads-pattern-shorter x2)
F4  any pattern starting `refs/h` (too broad)           2     killed (carried-not-refs-heads x2)
F5  every pattern not under refs/heads/ left out        6     killed (carried pulls, not-refs-tags, not-refs-heads x2)
H1  _left_out's OUTSIDE clause removed                  2     killed
H2  _left_out's tag clause removed                     14     killed
H3  _left_out's branches'-glob clause removed           2     killed (branches-glob-pull-requests x2)
H4  _left_out's last clause removed                    14     killed
H5  `if _left_out(...)` negated                        60     killed
```

Every run went through `run9.sh` on the commit `368b87e9b` (dirty=0 in each START line): gh stub first on PATH, a bogus token, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`, and `EMAIL` set.

- git 2.43, the three files that name the functions (`test_family_bump_fetch_refspecs.py`, `test_bump_leg_fetch_refspecs.py`, `test_bump_leg_reachability_words.py`): **159 passed** (`logs/g243.log`; 157 at `baa19972a`, and the NIT round's 2 rows).
- git 2.47.1 (a git 2.47.1 build first on PATH; `git --version` printed `git version 2.47.1` in the log): the same three files: **159 passed** (`logs/g247.log`).
- git 2.43, `test_path_guard_hygiene.py`, `test_pinned_copy_text.py` and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`): **306 passed** (`logs/hyg243.log`, the same count as at `baa19972a`).

`git merge-tree --write-tree <target> 368b87e`, run at 2026-10-23T12:06:38Z against main and the head of every open PR that touches one of the four files (55 open PRs listed at 12:06:22Z; `logs/merge-trees.log`). Each exits 0 with no conflict:
- `origin/main` `961640c6`: tree `19c8442d96b0`;
- #1104 `de89aa81` (`shape_materialize.py`): tree `b76c36be588a`;
- #1105 `b28f40de` (`shape_materialize.py`): tree `5f2a8b3706e4`;
- #1118 `2307036a` (`shape_materialize.py`): tree `0c21c6b39607`;
- #1131 `55c30f20` (`bump-leg.py`; its head moved from `a4beec9e` in its NIT round): tree `e036c02d6a12`;
- #1160 `0068f5af` (`shape_materialize.py`): tree `bce90eb65c6c`.

The head's own tree is `351a9b73cda8`. Main is still `961640c6`: its one commit past `038dafba`, #1155, touches only `docs/release-notes/v1.2.0.md`.

- C18b, a source that names no branch onto one branch's name, still clashes: own 0, tools 128. Which names the remote's branches have is not known from the refspecs. Leaving out every such refspec would also leave out the pull requests, which #946 carries on purpose. The docstrings now say so. The coordinator adds it and C25 to #1027's list.
- Leaving out the `refs/h*` class prunes what such a refspec kept until the repository's next own fetch, as for #1066's cases (C25n, C25g, C25h). An unforced one held under a branch's name costs the non-fast-forward of THE COST, ACCEPTED (C25u).
- Leaving it out also changes the answer for a commit that only a non-branch ref the pattern takes reaches, from pin to refuse (fix round 1, SHOULD 2). This is the rule #613 and #1037 keep for a commit only a tag reaches: a bump pins only what a branch reaches. Every run gives the same answer, so a dry run and the real run after it never differ. Pinning such a commit instead would be a design question, not part of this fix.

Review: lane 5's independent review (issuecomment-6347113460) and its re-check, READY at `baa19972` (issuecomment-6352215076); then rc1157b, lane 5's independent sonnet re-checker, which wrote none of #1157: READY at `368b87e9`, the head that lands (issuecomment-6371455922), with no MUST or SHOULD; its wording NIT is corrected in the body. CI (Brett Heap's ruling 27): H0 is the head 368b87e 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 44b3b5c over main b055bb7: the bump_leg_fetch_refspecs, family_bump_fetch_refspecs, repo_hygiene, agents_md_codes, cli_reference sets, 296 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 #1100

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
…y a member's failed clone ends git's update with 1 before any checkout but a leg's lets it go on, and `killed_said` says git's update was itself killed, not that it went on (#1085) (#1088)

The two texts #1076 (#1056) left in the family root's `bootstrap.py` say what git does. A member's clone that fails ends git's update before any checkout, where a leg's lets it go on, and a run whose git was itself killed is not said to have gone on. This is #1085, found by rv1076, lane 5's independent review of #1076 (its NITs 1 and 2).

Round 1 was one commit, on main `c13c3985` (where #1076 landed); round 2 (below) adds a second. It changes two files: the family root's `templates/family-root/scripts/bootstrap.py` (docstrings, and one line of `killed_said`) and `tests/test_family_bootstrap_stops_named.py` (one row). 2 files, 55 insertions, 14 deletions.

### NIT 1: two docstrings, no behaviour

- **`credential_advice`** said "a clone that fails, a member's or a leg's, gives 1, and git's update goes on". That is true of a leg's clone and not of a member's. It now says:
  - a member's fetch that fails ends the update with 128 (as before);
  - a member's clone that fails a second time, git having retried it, ends the update with 1 before any gitlink at that level is checked out (`quickstop`, git's `update_submodules`);
  - a leg's clone that fails gives its member's update 1, and git's update goes on (rv996's NIT 2 on #1045, kept).
- **`ended_on`** said `128 + N` is the update's code "where a git it ran died of signal N ... and no gitlink after it failed". A member's `git submodule--helper clone` that dies of signal N never gives that code: git retries it, and where the retry fails too the update ends in `quickstop`'s 1, even with nothing after it. The docstring now says so, and what that reads as:
  - **traced (no token in use):** `died_in` names the signal, since `git submodule--helper clone` is a git the update ran, and `signal_met` asks the trace first;
  - **with a token in use** (the update is not traced, #996): the update's code is 1, which reads as the credential's skip, the advice and `ok`, the limit `credential_advice` already says for a signal before a later failure.

### NIT 2: `killed_said`, one line

Where git's trace gave a git's signal (`Died(9, 137)`) and git's update returned `-9` (Python's code for git itself killed), the line said:

```
a git that git's update ran died of signal 9 (SIGKILL, exit 137), and git's update went on and ended with exit -9. Git's own words above say which git and where.
```

git's update did not go on and end: it was killed. It now says:

```
a git that git's update ran died of signal 9 (SIGKILL, exit 137), and git's update was itself killed (exit -9). Git's own words above say which git and where.
```

Every other line is byte for byte as before. I compared main's and this head's `killed_said` over 23,814 `(Died, returncode)` pairs (for each update code from -70 to 299: the update's own `ended_on` signal, where it gives one, and a trace's signal N = 1 to 64 with code `128 + N`), with `ended_on` itself:

- `ended_on` differs for 0 codes of -70..299;
- the pairs where the code is the update's own (the 134 codes that give a signal, #998's texts): 0 differ;
- the pairs where a trace's signal and a positive update code differ: 0;
- 4,480 pairs differ, each with a negative update code and a trace code (70 codes x 64 signals), and each now says "was itself killed (exit -N)".

(A script that loads main's and the head's `bootstrap.py` from `git archive` copies; its output is in lane 5's scratch notes, `v118/w1085/runs/probe-texts.txt`.) The failure line `(a git it ran died of signal N)` is unchanged, as it is true.

### Texts that pin those words

A grep of `tests/`, `README.md`, `docs/` (the handbook included), `AGENTS.md` and `contracts/` for "went on and ended", "killed_said", "went on", "member's or a leg's", "goes on (rv996", "UPDATE KILLED" and "quickstop" finds the words only in `bootstrap.py` and in `tests/test_family_bootstrap_stops_named.py`. There, `WENT_ON` pins "went on and ended with exit {ended}" with `ended` 128 and 1 (real runs, a positive code), which is unchanged and still passes. The README, the docs and the handbook say none of them.

The stops-named file's module docstring gains one paragraph for the new row.

I read, and did not run (no network clone, no real gh, no token):

- **git 2.47.1**: the local build tree of the git the lane's runs use first on PATH (`builtin/submodule--helper.c`, `run-command.c`, `trace2/tr2_tgt_event.c`, `usage.c`, `git-submodule.sh`).
- **git 2.43.0**: upstream's `v2.43.0` tag, the same five files (`builtin/submodule--helper.c` is byte for byte the copy an earlier lane 5 writer kept, w648c on #682, sha256 `9383338c...`; the other four I fetched as raw files from the tag, a read, not a clone). `/usr/bin/git` here is Ubuntu's `2.43.0-1ubuntu7.3`, which I did not read the patches of.

| claim | source (2.47.1 line, 2.43.0 line) |
|---|---|
| a member's clone is a `git` child, `git submodule--helper clone` | `prepare_to_clone_next_submodule`: `child->git_cmd = 1`, args `submodule--helper clone` (:2141-2146, :2053-2058); trace2's `child_start` writes `git` first for a `git_cmd` child (`tr2_tgt_event.c` :358-361, :359-361) |
| a child killed by signal N gives 128 + N | `wait_or_whine` `code += 128` (`run-command.c` :576, :569); `finish_command` hands it on and writes `child_exit` (:986-993, :976-983) |
| that code never reaches the update's own | `pp_collect_finished` passes it to `update_clone_task_finished` (:1700-1703, :1690-1693), which answers 0 ("Retry scheduled", :2265) the first time and, the second, sets `quickstop` and answers 1 ("a second time, aborting", :2276-2280; 2.43.0 :2185-2189) |
| `quickstop` ends the update with 1 before any checkout | `update_submodules`: `if (suc.quickstop) { ret = 1; goto cleanup; }` (:2737-2740, :2621-2624), before the loop that runs `update_submodule` for each gitlink |
| a member's fetch that fails ends it with 128 | `run_update_procedure`: `return die_message(...)`, and `die_message` returns 128 (`usage.c` :230-238, :227-235); the loop stops at 128 only (:2772, :2647) |
| a leg's clone that fails gives its member's update 1 and git's update goes on | the same `update_submodules` runs in the member's own `git submodule--helper update` (`update_submodule`: `ret = run_command(&cp)`, :2700, :2584, which discards `die_message`'s 128 and returns the member's 1); the holder's loop keeps going on any code but 128 |
| `git submodule update` passes the helper's code on | `git-submodule.sh`: the helper is `cmd_update`'s last command, no `\|\| exit` (:380-403, :341-363) |

Two offline probes, local paths only (no network, no gh, no token, `git config` never run, `GIT_CONFIG_GLOBAL=/dev/null`; lane 5's scratch notes, `v118/w1085/probe/quickstop.sh`, with its outputs `out243.txt` and `out247.txt`), on git 2.43.0 and 2.47.1, give the same on both:

| state | git's update | what is in the work trees |
|---|---|---|
| holder with `members/A`, `members/B` (a url that is not there), `members/C` | exit 1, `Failed to clone 'members/B'. Retry scheduled`, then `a second time, aborting` | A and C cloned, **none checked out** (`.git` only), B empty |
| holder with `members/M` (its leg `legs/L2` has a url that is not there) and `members/N` after it | exit 1, `Failed to clone 'legs/L2'. Retry scheduled` | **M and N both checked out** (git's update went on past M's 1); M's leg L1 cloned, not checked out |

**Not run:**
- `bootstrap.py` itself over a member's clone killed by a signal. Git's update over one was measured in round 2 below (rv1088's probe, which I ran again), and the trace's `child_start` argv for it is from `tr2_tgt_event.c`;
- git 2.43.0's remote helper or any network clone;
- Windows and macOS (CI is Linux only, #597).

One thing I read and left alone, as the brief says every other line stays byte for byte: where the trace names a signal and the update's code is a positive 1 from `quickstop`, `killed_said` still says "git's update went on and ended with exit 1". It ended there rather than went on. It is a wording nit of the same kind, not a behaviour; I did not change it here.

`test_an_update_itself_killed_after_a_signal_is_not_said_to_go_on` (new; 48 rows before, 49 after). It loads the family root's `bootstrap.py` as a module (`loaded`, from `tests/test_family_holder_bootstrap_texts.py`, which the stops-named file already imports from) and calls `killed_said(Died(9, 137), -9)`, as `signal_met` hands it its arguments. No run here reaches that line short of the OOM killer taking a checkout and later the top `git`. One assert, `said == KILLED_AFTER.format(...)`, no `and` or `or`.

Main's `bootstrap.py` bytes (sha256 `f1c156e2...`) against this branch's 49-row file, git 2.43.0: **1 failed, 48 passed**. The failure, from `v118/w1085/runs/mut-mainbytes.log`:

```
E         - s update was itself killed (exit -9). Git's own words above say which git and where.
E         + s update went on and ended with exit -9. Git's own words above say which git and where.
```

The other 48 rows pass on main's bytes (the old `WENT_ON` rows say only positive codes). At this head it passes.

Each applied alone to the change's `bootstrap.py` (a script replaces one exact string and asserts it occurs once), the stops-named file run on git 2.43.0 (`/usr/bin/git`) through `run9.sh`, the file put back from the head's bytes and its sha256 checked (`a52483c8...`) after each. Lane 5's scratch notes, `v118/w1085/runs/mutants-summary.txt`.

| | mutant | result |
|---|---|---|
| M1 | the new branch dropped (the ternary replaced by the old wording alone) | killed: 1 failed (the new row), 48 passed |
| M2 | the negative test inverted (`returncode > 0`) | killed: 7 failed, 42 passed: the new row and the six that say "went on" (both killed-checkout rows, P3, the both-named row, `two-members`, `two-legs`) |
| M3 | the old "went on and ended with exit N" kept for negative codes (the branch's text is the old wording, its test kept) | killed: 1 failed (the new row) |
| M4 | "went on" kept beside the new words (`went on and was itself killed (exit -9)`) | killed: 1 failed (the new row) |
| M5 | the test asks the trace's code, not the update's (`died.code < 0`) | killed: 1 failed (the new row) |
| M6 | the code not said (`was itself killed`) | killed: 1 failed (the new row) |

M1 and M3 behave the same (both give the old wording for a negative code) and differ in the text of the mutation only.

All through `run9.sh` (the real `gh` replaced by a stub, `GH_TOKEN` a fake, every `LANES_*` variable cleared, `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`), at most two at once, never the full suite. At this head, `bootstrap.py` sha256 `a52483c8...`, the test file `2fb6b4dd...`.

| run | git | result |
|---|---|---|
| `tests/test_family_bootstrap_stops_named.py` (49 rows) | 2.43.0 (`/usr/bin/git`) | **49 passed** |
| the same | 2.47.1 (`git --version` printed `git version 2.47.1`; the lane's local 2.47.1 build first on PATH) | **47 passed, 2 skipped** (the two P3 rows: that git exits 128 there, where 2.43.0's crashes with 139) |
| `tests/test_family.py`, `tests/test_update_shape_kept_helper.py` (the two files `grep -l 'family-root/scripts/bootstrap' tests/*.py` gives) and `tests/test_family_holder_bootstrap_texts.py` | 2.43.0 | **328 passed** |
| `tests/test_pinned_copy_text.py` and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`), with `test_template_text_placeholders.py` | 2.43.0 | **399 passed** |
| the six mutants and main's bytes | 2.43.0 | see above |

flake8 on `bootstrap.py` shows the 3 findings main has (E501 at line 7 and 2327, E127 at 1168), and on the test file the same 120 F811 findings main's file has (the fixture imports). New lines are 79 characters at most, ASCII only.

`git merge-tree --write-tree` of this head `88af1b7b8` against `origin/main`, fetched again with `-c gc.auto=0 -c maintenance.auto=false` just before the push (it moved once while this was open: `06cbd4789`, #1081, one test file), and against each open PR that touches `bootstrap.py`, read with `gh pr list --state open --json number,headRefName,files` (9 open; #1062 touches the family root's `bootstrap.py`; #1075's files are `siblings.py` and two test files; #1087, new since the first read, touches neither):

| against | result |
|---|---|
| `origin/main` `06cbd4789` (one commit past this branch's base `c13c3985`; it touches only `tests/test_doctor_gitlink_repairs.py`) | clean, tree `a10ffa31c` (against `c13c3985` the merge was this head's own tree, `deeab934f`) |
| #1062 `fix/1040-windows-start-errors` `957ba0386` (touches `bootstrap.py`, in `git_not_started`) | clean, tree `46f25219c`; the merged family root `bootstrap.py` parses, and holds `died_in`, `quickstop`, "was itself killed (exit" and #1062's `4551` |
| #1075 `fix/1055-siblings-untraced` `041f7831b` (lane 5's: `siblings.py`) | clean, tree `02b01b276`; the merged `siblings.py` imports `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path`, `members_of` and `untraced` from `bootstrap.py`, and all six are defined at module level in the merged `bootstrap.py` |

None of the other open PRs touches `bootstrap.py` or the stops-named file. This change defines and renames nothing a caller imports.

`templates/family-root/scripts/bootstrap.py` is in `FAMILY_COPIED_VERBATIM`, so each holder takes one `upstream-changed` row for it, as for #1045 and #1076. Nothing else is pinned. No Windows or macOS run was made.

rv1088 (#1088 issuecomment-6314332205) found the review READY with one NIT: the sentence this PR added to `ended_on` said git's update retries a killed member clone, and it does not always. This round is one commit on top of `88af1b7b8` (the branch was not rebased), docstring only, one file (`templates/family-root/scripts/bootstrap.py`, +8 -4). No behaviour changes, and `killed_said`'s "went on and ended with exit 1" line is left as it is (rv1088 found it true).

**Measured, offline, on git 2.43.0 and 2.47.1** (rv1088's `killprobe.sh`: a remote helper that SIGKILLs the `git submodule--helper clone` above it; I ran both modes again on both gits and got the same):

| the member's clone | git's update | work trees |
|---|---|---|
| killed on both attempts | exit 1: `Retry scheduled`, then `a second time, aborting` | A and C hold `.git` only, B empty: nothing checked out |
| killed once, its own `git clone` child left running (it writes `members/B/.git`) | exit 0: `Retry scheduled`, then `BUG: submodule considered for cloning, doesn't need cloning any more?`, no second attempt | A, B and C all checked out |

`ended_on` now says: git's update retries a killed member clone where it left no `.git` at the member's path, and where the retry fails too ends in `quickstop`'s 1 before any gitlink at that level is checked out; where its own `git clone` lived on and wrote that `.git`, git retries nothing and goes on, and exits 0 with every gitlink checked out (measured, git 2.43.0 and 2.47.1). "Read, not run" is now "measured". `signal_met` already says nothing for an exit of 0, and `credential_advice`'s "fails a second time, git having retried it" stays true, so neither changes.

No other text pins those words: a grep of `tests/`, `README.md`, `docs/` (the handbook included) and `AGENTS.md` for "retries it", "doesn't need cloning", "never gives that code" and "quickstop" finds them only in `bootstrap.py`, and the round-1 words "(read in git 2.43.0's and 2.47.1's submodule--helper.c, not run)" are gone everywhere. "not run" alone appears in other files only in unrelated senses (rv1088's re-check). flake8 shows the same 3 findings as before (the lines moved by 4).

**Runs** at this head, through `run9.sh` with `EMAIL=service@opensoft.dev` (`GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null` set by the runner), the stops-named file and the trio at the same time:

| run | git | result |
|---|---|---|
| `tests/test_family_bootstrap_stops_named.py` | 2.43.0 (`/usr/bin/git`) | **49 passed** |
| `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py` | 2.43.0 | **170 passed** |
| `tests/test_family_bootstrap_stops_named.py` (`git --version` printed `git version 2.47.1`, the lane's 2.47.1 build first on PATH) | 2.47.1 | **47 passed, 2 skipped** (the two P3 rows, as before) |

**Merges**, `git merge-tree --write-tree` of this head against `origin/main` re-fetched with `-c gc.auto=0 -c maintenance.auto=false`:

| against | result |
|---|---|
| `origin/main` `16f9a0e81` (#1087 and the commits since this branch's base; #1062 has landed, so main's `bootstrap.py` moved) | clean, tree `b3b454168`; the merged family root `bootstrap.py` parses and holds `died_in`, `git_not_started`, "was itself killed (exit" and the new BUG sentence |
| #1075 `fix/1055-siblings-untraced` `962ae43c1` (its head moved since round 1) | clean, tree `a4c72197c`; the merged `siblings.py` imports `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path`, `members_of` and `untraced` from `bootstrap.py`, and all six are defined in the merged `bootstrap.py` |

The other open PRs touch neither `bootstrap.py` nor the stops-named file. #1088 stays a draft.

Review: rv1088, lane 5's independent opus reviewer, which wrote none of #1088: READY at `26f49d85`, H0 of this landing (issuecomment-6316937429), with no MUST or SHOULD; its one body NIT is fixed in the body. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 26f49d8 passed tests, tests-git-bash and SonarCloud; the head dedc0da is a clean update-branch merge of main 0d76e4c, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 377691f over main 0d76e4c: the family_bootstrap_stops_named, repo_hygiene, agents_md_codes, cli_reference sets, 220 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 #1085

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Nov 8, 2026
…s message spells a path byte that is not UTF-8 as `_shown` does, an escape, and no longer says it names it with a `?` in its place, which #900 replaced (#1018) (#1104)

`env_commit`'s docstring in `scripts/shape_materialize.py` says what the split commit's message does with a `root` or `drop` path that holds a byte that is not UTF-8, as the code does since #900. It said the message names such a path "with a `?` in place of that byte, a path that does not exist". That was true before #900, and it is not true now. A reader of the docstring is told about a `caf?.md` the tool no longer writes.

Found by lane 1's writer on #900 and filed as #1018. Lane 5 took it outside v1.2.0, under Brett Heap's ruling of 2026-10-21T10:32:04Z.

Measured on main `2a119f281` and again on `16f9a0e81` (main after #1087), with a small script that calls: `adopt-project.py`'s own `_split_message` and `_shown`, then `env_commit` and a real `git commit`, read back with `cat -A`.

- The issue's claim held: the docstring (about line 2103 when filed, line 2268 on `2a119f281`, line 2349 on this branch) still said the message names the byte with a `?`.
- The code does not. `_shown("caf\udce9.md")` is `` `caf\udce9.md` ``, and the split message for a `root` and a `drop` entry named that holds no lone surrogate. Through `env_commit` and `git cat-file commit HEAD`, `cat -A`:

  ```
  DROPPED (in no leg and no longer here):$
    `caf\udce9.md`$
  STAYS IN THE ASSEMBLY ROOT:$
    README.md$
    `caf\udce9.md`$
  ```
- What stays true of `env_commit` itself: a message that holds the lone surrogate, handed to it directly, is still written as `?`. `cat -A` reads `Name: caf?.md$`. So the `errors="replace"` paragraph is kept, and what changes is who it describes.
- #1087 (main `16f9a0e81`) changed `scripts/shape_materialize.py` by 128 lines, none of them in `env_commit`, `COMMIT_COMMAND` or the message, and changed nothing in `adopt-project.py` (0 diff lines). The rebase onto it was clean and the docstring needed no other change.

One file, `env_commit`'s docstring, in two paragraphs: the `errors="replace"` paragraph (round 1, 11 lines added and 7 removed) and the sentence above it (round 2, below; 3 added and 2 removed). Together 14 added and 9 removed against main `6da757cb`. The `errors="replace"` paragraph now says:

- encoding, a character UTF-8 cannot carry is written as `?`, and nothing raises after both legs are pushed, as before;
- `adopt-project.py` refuses a lone surrogate in a path it extracts (`entry_path_problem`), and a `root` or `drop` entry that holds one is listed in the split message but never raw: the message names each path as `plan`'s report does (`_shown`, #900), so the byte 0xE9 reads as an escape with no surrogate in it, `` `caf\\udce9.md` ``, the path the source holds;
- written raw it would be a `?`, `caf?.md`, a path that does not exist, which is what `env_commit` still writes for a lone surrogate it is handed.

The backslash is doubled in the source, as the neighbouring docstrings write `\\udcc3`. A single `\u` in a non-raw docstring would be an escape that makes a lone surrogate. Measured: the docstring holds no character from U+D800 to U+DFFF and holds the six characters `\udce9`. The longest docstring line is 78 characters.

**The code is unchanged.** A script compares the AST of `scripts/shape_materialize.py` with every docstring blanked: the branch's file is identical to `origin/main`'s after the rebase onto `16f9a0e81` (round 1), and the merge of this head into `origin/main` `6da757cb` is identical to `6da757cb`'s file (round 2).

**No pin moves.** `scripts/shape_materialize.py` is not a pinned copy. `COPIED_VERBATIM`, `COPIED_FROM_SHAPE`, `FAMILY_COPIED_VERBATIM` and `FAMILY_COPIED_FROM_SHAPE` do not list it, and `templates/assembly-root/scripts/validate-pins.py` says of it "which no project carries". So no `upstream-changed` row, no digest, no assembly copy and no `tests/test_pinned_copy_text.py` entry changes.

**No other text pins these words.** Searched in `tests/`, `README.md`, `docs/`, `docs/handbook.html`, `AGENTS.md` and `templates/`: no test reads `env_commit`'s docstring (`__doc__`, `getdoc`), and none of them holds "in place of that byte" or "a path that does not exist" for this. The other places that name the `?` are about this function's behaviour or about history, and are still true: `_split_message`'s docstring says `caf\\udce9.md` "was `caf?.md`" (before #900), `_refused_by_policy` already says the message "lists it as `_shown` does (#900)", and `tests/test_adopt_spelled_names.py` quotes the message as it was on `c7a3ff16`. The `?` in `argv_text`, `trailer_line` and the commit-trailer text is about a `--trailer`, not the split message.

No rows and no mutants: the change is a docstring, the code is the same AST, and no test pins the docstring. So there is no row to fail on main and nothing to mutate.

All with `$Q/v110/run9.sh`, `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`, the stub `gh`, a fake token, git 2.43.0 (`/usr/bin/git`).

Two shards, run before the rebase, at the `2a119f281` base with this one-file change in the tree. Together they are every `tests/*.py` that names `shape_materialize` or `env_commit` (86 files, including `tests/test_adopt_spelled_names.py` and `tests/test_adopt_entry_paths.py`, which run `env_commit` and the #900 message) and `tests/test_agents_md_codes.py`:

| run | files | result |
|---|---|---|
| shard A | 43 | 2873 passed, 4 skipped in 1707.51s, rc=0 |
| shard B | 44 | 4611 passed, 31 skipped in 2462.01s, rc=0 |

After the rebase onto `16f9a0e81`, on the committed head `e32b2703d733f6c60c1b2cba2d62d164d93d7b9a` (`dirty=0`): the trio, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`, **170 passed in 55.67s**, rc=0. The docstring-only change needed no new shards: the shards' counts are runs at the `2a119f281` base, and #1087 did not touch `env_commit`.

Round 2, on the committed head `de89aa81b1533e32f1007f991aeae05cd915b36d` (`dirty=0`): the same trio, **170 passed in 61.77s**, rc=0. The round 2 commit is one docstring sentence, so it needed no new shards either.

git 2.47.1: no run. The change adds no test file of its own, so there is none to run on it.

`git merge-tree --write-tree HEAD <other>` with HEAD at `de89aa81b1533e32f1007f991aeae05cd915b36d`, after a re-fetch of `origin main` with `-c gc.auto=0 -c maintenance.auto=false` (round 2, about 14:03Z on 2026-10-21):

| against | result |
|---|---|
| `origin/main` `6da757cbe9a4eb66d60c34f38dcc2b332d175e8b` | clean, rc 0, tree `15c11b6c959fc4c7540105d5d62e3f961c8e27fd` |
| #1105 `fix/1048-makefile-follow-up-gnu-read-order` `5a4de7f763b5a3c32adce9614a3a539508416eb4`, an open PR that touches `scripts/shape_materialize.py` | clean, rc 0, tree `db05aead67052643942e35a9689d9dd4a0873fda` |
| #1118 `fix/1089-named-link-skip-tests` `c60066cbeec18b8b6c926e254ed4bee6b9a486c2`, the other open PR that touches `scripts/shape_materialize.py` | clean, rc 0, tree `a40df3b63c32c2a3d8f4c40587b094940c8d6567` |

Read with `gh pr list --state open --json number,headRefName,headRefOid,files`: 16 open PRs, three of them (this one, #1105 and #1118) touch `scripts/shape_materialize.py`, the only file this change touches. The other 13 (#1120, #1119, #1117, #1111, #1109, #1107, #1106, #1102, #1101, #1098, #1097, #1091, #1088) touch no file this change touches.

The same command with HEAD at the reviewed head `e32b2703` gives rv1104's trees: `de31d857` against `6da757cb` and `195ffe19` against #1105. The round 1 merge against #1090 is moot: #1090 has landed in `6da757cb`.

Held as a draft until the v1.2.0 tag; it is now on the v1.2.1 milestone.

- **NIT 1, `scripts/shape_materialize.py` lines 2340-2343, a line round 1 did not change.** The paragraph above the reworded one said the adoption's split message "names each entry path as the source spells it". Since #900 that is true of a printable path alone: `_split_message` lists any other through `_shown`, as the escape the next paragraph describes. It now says "as the source spells it (a printable one; see below)". One new commit on top of `e32b2703`, `de89aa81b1533e32f1007f991aeae05cd915b36d`, 3 lines added and 2 removed, docstring only, pushed as a fast-forward (no rebase, the PR is open).
  - Measured: the AST with docstrings blanked is identical to `e32b2703`'s and, merged into `6da757cb`, to `6da757cb`'s. The docstring holds no lone surrogate, still holds the six characters `\udce9`, and its longest line is 78 characters.
  - Searched `tests/`, `README.md`, `docs/`, the handbook mirror, `AGENTS.md`, `templates/` and the scripts for "names each entry path", "as the source spells" and the new words: only `tests/test_adopt_entry_paths.py` line 233 matches, a test's docstring about `execute` handing each path on as the source spells it, which is another sentence and pins nothing here.
- **NIT 2, this body.** It named main as `16f9a0e81` and #1090 as the one open PR that touches the file. #1090 has landed. Main is `6da757cb`, and the merge-trees above are redone against it, against #1105 and against #1118, a draft that opened since and also touches the file.

Review: rv1104-1107, lane 5's independent opus reviewer, which wrote none of #1104: READY at `de89aa81`, H0 of this landing (issuecomment-6318111413), with no finding; NIT 1 and NIT 2 of the round before are fixed. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 de89aa8 passed tests, tests-git-bash and SonarCloud; the head 92285d4 is a clean update-branch merge of main b66d8f0, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 6bd2128 over main b66d8f0: the repo_hygiene, agents_md_codes, cli_reference sets, 171 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 #1018

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