Skip to content

family.py: bump's fetch leaves out every refspec of the mount's whose source can name a tag, a pattern such as refs/* and a short name too, so a mirror in a folder, +refs/*:refs/remotes/origin/all/*, no longer fails the fetch on a branch and a tag of one name or pins a commit only a tag reaches (#1009) - #1027

Merged
brettheap merged 4 commits into
mainfrom
fix/1009-mirror-in-a-folder
Oct 20, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1009.

What changed and why

family.py bump fetches the member's origin with +refs/heads/*:refs/remotes/origin/* plus the mount's own refspecs that write under refs/remotes/origin/ (#686), so that the prune keeps what they write. #946 (#905) left two kinds of those out in _origin_fetch_refspecs: a source under refs/tags/, and a non-branch source onto the branches' own glob, refs/remotes/origin/*. A mirror in a folder, +refs/*:refs/remotes/origin/all/*, matched neither, so it was carried. Its source refs/* takes every tag and writes it to origin/all/tags/*, where bump reads branches. Lane 4's RV1007 found it while reviewing #1007 (issuecomment-6277873296). It is RV946's S1 class in another form.

The fix keys the tag clause on whether the source CAN NAME a tag, not only on a refs/tags/ prefix. Every pattern that can match a tag, every short name and every exact object id is now left out, wherever under origin/ it writes. A full name that is no pattern is not, though git can fall back to a tag for it too (see Limits). +refs/*:refs/remotes/origin/all/* now gets the same answers as the cases #946 fixed:

The same file's docstrings now say which clause leaves each refspec out, and take #1007's NIT N1 wording (RV1007).

Reproduced on main

Add "+refs/*:refs/remotes/origin/all/*" with the id mirror-in-a-folder to TAGS_UNDER_ORIGIN, with family.py as on main. Both rows fail, as #1009 says:

  • test_tags_mapped_into_origin_fetch_a_branch_and_a_tag_of_one_name[mirror-in-a-folder]: both_pin's dry.stderr == "" fails on note: could not fetch members/IRRS from its origin; ....
  • test_a_tag_a_mount_maps_into_origin_does_not_answer_for_a_branch[mirror-in-a-folder]: AssertionError: member HEAD changed.

The rule

_origin_fetch_refspecs reads each positive refspec of the mount's own (negative ones are skipped, as before) and asks three clauses in order. The first that matches leaves the refspec out:

  1. OUTSIDE origin/: the destination is not under refs/remotes/origin/. Unchanged.
  2. A SOURCE THAT CAN NAME A TAG, wherever under origin/ it writes. There are three ways:
  3. THE BRANCHES' GLOB: the destination is exactly refs/remotes/origin/* and the source is not under refs/heads/. Unchanged.

Everything else is carried, after +refs/heads/*:refs/remotes/origin/* and without duplicates. Since the NIT round, the three ways of clause 2 are one helper, _can_name_a_tag(source).

The git facts behind 2(b) and 2(c) were measured on git 2.43 in scratch repositories:

  • Patterns:
    • +refs/t*:... took refs/tags/t and refs/tags/x.
    • +*:... took HEAD, every branch and every tag.
    • +heads/*:... took nothing.
  • Short names: with a branch x at one commit and a tag x at another:
    • +x:refs/remotes/origin/got-x fetched the TAG's commit.
    • +tags/t:... fetched the tag t.
    • A plain git fetch origin with +x:refs/remotes/origin/cfg-x configured did the same.
  • Clashes: beside +refs/heads/*:refs/remotes/origin/*, with each refspec alone exiting 0 in the person's own git fetch origin, each of these exits 128:
    • +refs/*:refs/remotes/origin/all/*: "Cannot fetch both refs/heads/all/tags/v1 and refs/tags/v1 to refs/remotes/origin/all/tags/v1".
    • +x:refs/remotes/origin/x: "Cannot fetch both refs/heads/x and refs/tags/x to refs/remotes/origin/x".
    • +refs/pull/*/head:refs/remotes/origin/*: "Cannot fetch both refs/heads/7 and refs/pull/7/head to refs/remotes/origin/7".

Why not the issue's literal wording. "Leave out every refspec whose source can match a ref outside refs/heads/" would also leave out two refspecs #946 carries on purpose, which the U184 rows hold:

  • the pull-request refspec +refs/pull/*/head:refs/remotes/origin/pr/*;
  • +refs/review/caf<e9>:refs/remotes/origin/review/cafe.

Mutant M7 below is that wording. It fails both U184 rows. Keyed on tags, the rule leaves out neither.

Every refspec the tests hold, and the clause that leaves it out (the first that matches):

refspec where outcome clause
+refs/heads/*:refs/heads/* U182 heads-onto-heads left out 1
+refs/*:refs/* U182 mirror left out 1 (2b meets it too)
+refs/tags/*:refs/remotes/origin/* U183 tags-onto-branches left out 2a (2b and 3 meet it too)
+refs/tags/*:refs/remotes/origin/tags/* U183 tags-in-a-folder left out 2a (2b meets it too)
+refs/*:refs/remotes/origin/* U183 mirror-into-origin left out 2b (3 meets it too)
+refs/*:refs/remotes/origin/all/* U183 mirror-in-a-folder (NEW) left out 2b alone
+refs/pull/*/head:refs/remotes/origin/pr/* U184 alias row; reachability_words carried none
+refs/review/caf<e9>:refs/remotes/origin/review/cafe U184 bytes row carried none
+refs/heads/main:refs/remotes/origin/main reachability_words main-only carried none
+refs/notes/*:refs/notes/* reachability_words left out 1
^refs/heads/<branch> reachability_words skipped (negative) n/a

Tests

In tests/test_family_bump_fetch_refspecs.py:

  • The new row: mirror-in-a-folder in TAGS_UNDER_ORIGIN, so both U183 tests run it end to end.

  • WHICH_CLAUSE, NEW: an in-process table of 16 refspecs. Each row configures one refspec in a fresh repository and asks _origin_fetch_refspecs whether it is carried. The id names the clause, and family.py is loaded with conftest.load_without_bytecode. The table is needed for three reasons:

    • Clause 2b now also takes the two end-to-end rows that alone held clause 3 (RV946's MB). Without the branches-glob-pull-requests row, a mutant removing clause 3 would survive.
    • Clause 2a takes refs/tags/* before 2b, so only a non-pattern tag source (tag-source-one-tag) holds 2a alone.
    • No end-to-end row reaches 2c (tag-short-name, tag-short-name-in-tags).

    Its carried rows fail a clause that takes too much: the pull requests in a folder, one branch, branches in a folder, and refs/tagsx/*.

  • Docstrings name the clause for each refspec: the module docstring, the comment on TAGS_UNDER_ORIGIN, and the U182, U183 and U184 rows. They take NIT N1's two wordings: "only where its destination was the branches' own glob, refs/remotes/origin/*" and "the mirror into origin/ (S2) had no test". The module docstring's "EVERY ROW RUNS bump" paragraph names the one table that does not.

How each run was made. One file per invocation, under /usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed, with one reused --basetemp:

  • df -i / first;
  • EMAIL set to a .invalid address;
  • GIT_SSH_COMMAND, GIT_ALLOW_PROTOCOL and every LANES_* unset;
  • a stub gh that exits 97 first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR.
file main this head (on main e076e3e88)
test_family_bump_fetch_refspecs.py (main's own) 13 passed n/a
test_family_bump_fetch_refspecs.py (this head's), against main's family.py 7 failed, 24 passed (on e076e3e88, and the same on 259743a) 31 passed
test_family_bump_reachability_words.py 20 passed 20 passed
test_family_bump_remove_rollback.py 31 passed 31 passed
test_family_bump_to_a_tag.py 7 passed 7 passed
test_family_bump_to_checks.py 6 passed 6 passed
test_family_bump_to_local_only.py 8 passed 8 passed
test_family_dry_run.py 61 passed 61 passed
test_no_auto_maintenance.py 60 passed 60 passed
test_shape_doctor.py not re-run 445 passed, 3 skipped
test_repo_hygiene.py not re-run 100 passed
test_agents_md_codes.py not re-run 47 passed
test_cli_reference.py not re-run 23 passed
test_family.py -k bump not re-run 23 passed
test_family_holder_states.py -k bump not re-run 7 passed
test_family_refuse_up_front.py -k bump not re-run 16 passed
test_family_tracking_branch.py -k bump not re-run 4 passed
test_family_operation_reftable.py -k bump not re-run 2 passed
test_family_member_path.py -k bump not re-run 14 passed
total on this head 905 passed, 3 skipped, 0 failed

The main column's counts other than the refspec file's were measured on main 259743a, before this branch was rebased; of the four commits main gained since, none touches those test files, and only #1010 touches scripts/family.py (the scaffold's folder checks and --help, not bump). "not re-run" marks a main run that was stopped at the usage checkpoint.

Main's seven failures, the rows this change makes pass:

  • both end-to-end [mirror-in-a-folder] rows;
  • WHICH_CLAUSE's tag-pattern-mirror-in-a-folder, tag-pattern-refs-t, tag-pattern-every-ref, tag-short-name and tag-short-name-in-tags.

Every other WHICH_CLAUSE row passes on main too: it records #946's behaviour, which this change keeps.

Not run: the full suite. Main's push run is red since #974 on two rows of tests/test_family_add_gitlink_and_null_members.py (test_a_second_members_line_above_the_block_is_refused[members: null] and [members: []]). That file is not in this targeted set. #1026 (051176ce6) has since made those two rows pass, and this branch has merged it.

Mutants

Each mutant is one exact replacement in scripts/family.py, run against tests/test_family_bump_fetch_refspecs.py. The bytes were restored after each one, and the sha256 was checked against 918a202b... each time.

mutant result killed by
M1: 2b removed 5 failed both e2e [mirror-in-a-folder] rows; tag-pattern-mirror-in-a-folder, tag-pattern-refs-t, tag-pattern-every-ref
M2: 2c removed 2 failed tag-short-name, tag-short-name-in-tags
M3: 2a removed 1 failed tag-source-one-tag
M4: 2b's prefix test reversed (fixed.startswith("refs/tags/")) 5 failed as M1
M5: 2c widened to every non-pattern source 2 failed the U184 bytes row; carried-one-branch
M6: clause 3 removed (RV946's MB) 1 failed branches-glob-pull-requests
M7: the literal direction (every source not under refs/heads/) 4 failed both U184 rows; carried-pull-requests-in-a-folder, carried-not-refs-tags
M8: 2b narrowed to fixed == "refs/" 2 failed tag-pattern-refs-t, tag-pattern-every-ref
M9: 2c's not star guard dropped survives equivalent on every row
M10: 2b's star and guard dropped survives equivalent on every row

Why M9 and M10 survive:

  • M9 changes only a pattern outside refs/ whose part before the * is no start of refs/tags/, such as heads/* or H*. Such a pattern takes no branch and no tag: heads/* took nothing, and H* can take only HEAD.
  • M10 changes only a non-pattern source that is a start of refs/tags/ and begins refs/, such as refs/ or refs/t. That is no real ref name.

Merges

Open PRs were listed fresh with gh pr list, and each head was read with gh pr view. Of the 13 open PRs, only #1002 (fix/927-bump-family-root, 43a4651e7) touches scripts/family.py. None touches tests/test_family_bump_fetch_refspecs.py.

git merge-tree --write-tree, each exit 0:

The branch was rebased once, before its first push, from 259743a onto e076e3e88. e076e3e8 (#1010) changes scripts/family.py elsewhere. The rebase had no conflict, and git range-diff shows the patch unchanged (=).

NIT round (RV1027, issuecomment-6280876954)

RV1027 found #1027 READY at 669d611e with no MUST and no SHOULD. This round takes its four NITs.

Heads:

What changed:

  • N4: the three ways a source can name a tag are now one helper, _can_name_a_tag(source), with _TAGS_PREFIX = "refs/tags/". This is for Sonar's python:S3776 (cognitive complexity 20 of 15 on _origin_fetch_refspecs) and python:S1192 ("refs/tags/" three times). Behaviour is unchanged: all 31 rows of 669d611e pass on the new head.
  • N1: the docstring says clause 2(c) also leaves out an exact object id (+<40 hex>:refs/remotes/origin/pinned): it names a commit and no branch. The new row tag-object-id holds it.
  • N3: the new row tag-short-name-heads-main (+heads/main:refs/remotes/origin/hm, left out) holds that 2(c) takes every short name. Git completes heads/main to the branch first.
  • N2: the docstring now says git tries the name itself, then refs/<name>, before refs/tags/<name> and refs/heads/<name>. It also names the full-name fallback, which is now in Limits.

W1009 measured these on git 2.43 too:

  • +heads/main: took the branch main with a tag heads/main on the remote;
  • +refs/heads/zz: took the tag refs/tags/refs/heads/zz where the remote had no branch zz;
  • +<40 hex>: wrote origin/pinned.

Counts at a79c957b0, with the same environment as above, each run once:

run result
test_family_bump_fetch_refspecs.py 33 passed (31 plus the two new rows)
-k bump over every tests/test_family*.py, one invocation 277 passed, 1766 deselected
test_repo_hygiene.py 100 passed
test_cli_reference.py 23 passed
test_agents_md_codes.py 47 passed
test_no_auto_maintenance.py 60 passed
test_family_dry_run.py 61 passed

Mutants at a79c957b0, written in the helper's shape. Each was run against the refspec file. The bytes were restored after each one, and the sha256 was checked against 609632f5... each time.

mutant result killed by
M1: 2(b) removed 5 failed both e2e [mirror-in-a-folder] rows; tag-pattern-mirror-in-a-folder, tag-pattern-refs-t, tag-pattern-every-ref
M2: 2(c) removed 4 failed tag-short-name, tag-short-name-in-tags, tag-short-name-heads-main, tag-object-id
M3: 2(a) removed 1 failed tag-source-one-tag
M4: 2(b)'s prefix test reversed 5 failed as M1
M5: 2(c) for every non-pattern source 2 failed the U184 bytes row; carried-one-branch
M6: clause 3 removed 1 failed branches-glob-pull-requests
M7: the literal direction (every source not under refs/heads/) 4 failed both U184 rows; carried-pull-requests-in-a-folder, carried-not-refs-tags
M8: 2(b) narrowed to fixed == "refs/" 2 failed tag-pattern-refs-t, tag-pattern-every-ref
M9: 2(c) asked of patterns too survives equivalent; RV1027 agreed it needs no row
M10: 2(b) asked of non-patterns too survives equivalent; RV1027 agreed it needs no row
RM1 (RV1027): 2(c) narrowed to one-component names and tags/... 1 failed tag-short-name-heads-main
RM2 (RV1027): 2(c) exempts a 40-hex id 1 failed tag-object-id

Merges:

Follow-up: #1037 tracks bump-leg.py's leg fetch, as RV1027 asked.

Limits

Lane: openreposhape-4 (openRepoShape-4)

🤖 Generated with Claude Code

Summary by Sourcery

Update family.py bump to exclude all tag-capable mount refspecs from its origin fetch while preserving valid non-tag mappings.

Bug Fixes:

  • Prevent family.py bump from carrying mount refspecs whose sources can resolve tags, including mirrors and short-name or object-ID refspecs, avoiding fetch conflicts and tag commits being mistaken for branches.

Enhancements:

  • Centralize tag-detection rules for origin fetch refspecs and document the filtering clauses and their Git resolution behavior.

Tests:

  • Add end-to-end and clause-level coverage for tag-capable patterns, mirrors in nested folders, short names, object IDs, and preserved non-tag refspecs.

…se source can name a tag, a pattern such as `refs/*` and a short name too, so a mirror in a folder, `+refs/*:refs/remotes/origin/all/*`, no longer fails the fetch on a branch and a tag of one name or pins a commit only a tag reaches (#1009)

Closes #1009.

`_origin_fetch_refspecs` (#686, #905) left out of `bump`'s fetch a
refspec of the mount's own whose source is under `refs/tags/`, and one
whose destination is the branches' glob `refs/remotes/origin/*` with a
source not under `refs/heads/`. A mirror in a folder,
`+refs/*:refs/remotes/origin/all/*`, met neither and was carried: its
`refs/*` takes every tag and writes it under `origin/all/tags/`, where
`bump` reads branches. Lane 4's RV1007 measured it on #1007
(issuecomment-6277873296); it is RV946's S1 in another form.

The clause for a tag now asks whether the source CAN NAME a tag, in
three ways (measured, git 2.43):
- under `refs/tags/`, as #946 landed it (RV946's S1);
- a pattern whose part before the `*` is a start of `refs/tags/`
  (`refs/*`, `refs/t*`, `*`): git matches a pattern against each ref's
  whole name;
- no pattern and not starting with `refs/`: a short name, which git
  completes, trying `refs/tags/<name>` before `refs/heads/<name>` (with a
  branch and a tag `x`, `+x:refs/remotes/origin/x` fetched the tag).
The issue's wording, every source that can match a ref outside
`refs/heads/`, would also leave out the pull-request and `refs/review/`
refspecs #946 carries on purpose (both U184 rows fail on that mutant);
keyed on tags, it leaves out neither.

tests/test_family_bump_fetch_refspecs.py: the `mirror-in-a-folder` row
in `TAGS_UNDER_ORIGIN` (both U183 tests); a `WHICH_CLAUSE` table of 16
refspecs that asks `_origin_fetch_refspecs` in process which clause
leaves each out, which holds the branches' glob (the new pattern way
otherwise shadows it on every end-to-end row) and the short name; and
docstrings that name the clause for each refspec, with #1007's NIT N1
wording (RV1007).

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

Updates family bump’s origin fetch construction so every refspec capable of naming a tag is omitted when it writes under origin tracking refs, preventing tag/branch fetch clashes and accidental tag-based pinning while preserving intended non-tag refspecs; comprehensive regression tests cover nested mirrors, source forms, clause ordering, and compatibility cases.

Sequence diagram for safe family bump origin fetch

sequenceDiagram
    participant Bump
    participant Config as Mount remote.origin.fetch
    participant Git
    participant Origin as Remote origin

    Bump->>Config: _origin_fetch_refspecs(member)
    loop Positive refspecs
        Config->>Config: Check destination under origin/
        Config->>Config: Check source can name a tag
        Config->>Config: Check branches glob collision
        alt Ref is excluded
            Config-->>Bump: Omit refspec
        else Ref is safe to carry
            Config-->>Bump: Carry refspec
        end
    end
    Bump->>Git: Fetch branches plus carried refspecs with prune
    Git->>Origin: Fetch refs
    Origin-->>Git: Branch refs and safe auxiliary refs
    Git-->>Bump: No tag/branch collision or tag-based pinning
Loading

Flow diagram for origin fetch refspec filtering

flowchart TD
    A[Mount remote.origin.fetch refspec] --> B{Negative refspec?}
    B -->|Yes| X[Skip]
    B -->|No| C{Destination outside origin/?}
    C -->|Yes| X
    C -->|No| D{Source can name a tag?}
    D -->|Yes: tags path, matching pattern, or short name| X
    D -->|No| E{Branches glob destination and source not heads?}
    E -->|Yes| X
    E -->|No| F[Carry refspec]
    G[Always include refs/heads/* to origin/*] --> H[Run bump fetch with prune]
    F --> H
    X --> H
Loading

Flow diagram for tag-capable source detection

flowchart TD
    A[Source refspec] --> B{Starts with refs/tags/?}
    B -->|Yes| X[Omit]
    B -->|No| C{Pattern source?}
    C -->|Yes| D{refs/tags/ starts with fixed pattern prefix?}
    D -->|Yes| X
    D -->|No| Y[Continue checks]
    C -->|No| E{Starts with refs/?}
    E -->|No| X
    E -->|Yes| Y
    Y --> Z[May be carried if other clauses allow]
Loading

File-Level Changes

Change Details Files
Expands _origin_fetch_refspecs to exclude any mount refspec that can introduce tags beneath refs/remotes/origin/, while retaining non-tag refspecs and the existing branch-name collision rule.
  • Recognize tag-capable sources under refs/tags/, tag-matching patterns such as refs/*, and short names Git resolves as tags first.
  • Preserve the ordered outside-origin, tag-capable-source, and branches-glob filtering behavior, skipping negative refspecs and avoiding duplicates.
  • Update implementation documentation to explain the clauses, Git matching semantics, and the effect on fetch failures and tag-only commits.
scripts/family.py
Adds regression and clause-coverage tests for tag-capable refspecs, especially mirrors placed in nested origin folders.
  • Add the mirror-in-a-folder scenario to end-to-end tag collision and tag-only reachability tests.
  • Add an in-process table covering all three tag-detection forms, clause ordering, carried refspecs, and over-broad matching mutants.
  • Clarify test documentation for the filtering clauses and retained pull-request, branch, and other non-tag refspecs.
tests/test_family_bump_fetch_refspecs.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1009 Leave out mount refspecs that can bring tags under refs/remotes/origin/, wherever they write, including wildcard patterns such as refs/* and short names. ✅
#1009 Apply the issue’s broader rule of leaving out every refspec whose source can match a ref outside refs/heads/, wherever it writes under refs/remotes/origin/. ❌ The PR filters sources that can name tags, but deliberately carries other non-branch sources, including pull-request and review refspecs. That does not implement the issue’s stated broader rule.
#1009 Add the mirror-in-a-folder regression case and update the relevant docstrings, including the two requested NIT N1 wording changes. ✅

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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4 RV1027, 2026-10-20T01:04:11Z) RV1027's independent review of #1027 (closes #1009) at 669d611e: READY.

RV1027 is lane 4's independent reviewer for #1027. W1009 wrote it; I did not. No MUST and no SHOULD. There are four NITs, and two follow-up calls on the body's limits. The rule does what the body says for every refspec the body names. Clause 2(c) is right, safe and wanted. The NITs are an undocumented side effect, one overstated "every", a limit no row holds, and Sonar's two smells.

The verdict on what W1009 flagged

  1. Clause 2(c): keep it as it is, in this PR, with no separate issue.
    • Right. For a source that is no pattern, git tries, in order, <n>, refs/<n>, refs/tags/<n> and then refs/heads/<n>. I measured this on git 2.43 against a scratch remote:
      • +x: took the tag x, not the branch x.
      • +heads/main: took the branch main, even with a tag heads/main on the remote, because refs/<n> wins.
      • So any short name names a tag whenever the remote has refs/tags/<n> and none of the earlier names. That is why no rule narrower than "every short name" closes the class. My RM1 is such a narrower rule (one-component names and tags/... only), and it would carry heads/main and pull/7/head again.
    • Wanted. It is 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's own goal: every refspec that can carry a tag under origin/, in any folder.
      • The end-to-end probe used temporary rows in my clone, and both files were restored.
      • The mount kept +x:refs/remotes/origin/x beside its branches. The remote had a tag x at a commit no branch reaches, and the ref was absent before bump (a first fetch).
      • On main's family.py the dry run exited 0 and printed the row: it pinned a commit only a tag reaches. On this head both refuse, exit 2.
    • Safe, and cheaper than the body says. On git 2.43, --prune already deletes an up-to-date ref that a short-name refspec wrote, on every second fetch.
      • Measured: with git fetch -q --prune --no-prune-tags --refmap= origin +refs/heads/*:refs/remotes/origin/* <spec> run three times, +pull/7/head:refs/remotes/origin/pr/7 and +x:refs/remotes/origin/x were written, deleted and written again.
      • +refs/pull/7/head:... stayed through all three. The prune matches the source as spelled, pull/7/head, against the remote's refs/pull/7/head.
      • So on main a carried short name made bump's answer depend on how many fetches came before it. With +pull/7/head:... it refused while the ref was present and pinned once it was absent. This head refuses in both states, and the long spelling pins in both states on main and on this head.
      • What 2(c) drops is a copy git was already dropping every other fetch. Where the short name is a branch, +refs/heads/*:refs/remotes/origin/* writes that branch anyway.
  2. M9 and M10: I agree that neither needs a row.
    • M9 changes only a pattern outside refs/ whose part before the * is not a start of refs/tags/. Measured: +heads/*: fetched nothing, and +H*: fetched only HEAD, written as origin/H/EAD at main's commit. Neither is a tag.
    • M10 changes only a source that is no pattern, from refs/ to refs/tags. refs/ is not a valid ref name. refs/tags can name only a tag literally named refs/tags, which is N2's fallback below and not something 2(b) is about.
  3. Both U184 refspecs are still carried.
    • In process, on main's and this head's _origin_fetch_refspecs alike: +refs/pull/*/head:refs/remotes/origin/pr/* and +refs/review/caf<e9>:refs/remotes/origin/review/cafe are CARRIED.
    • Both U184 end-to-end rows pass on this head.
    • My probe with PULLS pins on this head whether origin/pr/7 was present or absent before bump.
    • The issue's literal wording would drop both of them, as the writer's M7 shows.
  4. The limits.

NITs

  • NIT N1, scripts/family.py:8967 (the docstring at :8900-8903): 2(c) also leaves out an exact object id, and nothing says so or holds it.
    • +<40 hex>:refs/remotes/origin/pinned is no pattern and does not start with refs/, so 2(c) takes it. It is not "a short name, which git completes".
    • Measured end to end with a commit that only refs/keep/x on the remote reaches:
      • on main, both states pinned it, exit 0, because the ref under origin/ answered for a branch;
      • on this head, both refuse it, exit 2.
    • That is an improvement under family.py bump --to: pins a commit only the member's mount holds, which bump-leg.py refuses as local-only #613, but it is a behaviour change the body does not name. RM2, which makes 2(c) exempt a 40-hex source, survives all 31 rows.
    • Suggestion: one WHICH_CLAUSE row (+<40 hex>:refs/remotes/origin/pinned, carried False) and one docstring clause, or at least a line in the body.
  • NIT N2, the body's "Every refspec that can bring a tag under origin/ is now left out", and the docstring at :8889-8903: a full name that is no pattern still falls back to a tag.
    • Git tries refs/tags/<source> for refs/heads/zz too.
    • Measured with no branch zz on the remote and a tag named refs/heads/zz (git tag refs/heads/zz makes refs/tags/refs/heads/zz):
      • +refs/heads/zz:refs/remotes/origin/zz fetched the tag's commit, and so did a plain git fetch origin;
      • end to end, with the ref absent before bump, main and this head both pinned a commit only that tag reaches;
      • with the ref present, both refused, because of the prune behaviour above.
    • This case is contrived, and closing it would mean leaving out every source that is no pattern, which is the writer's M5 and breaks the U184 bytes row.
    • Suggestion: word it as "every pattern that can match a tag and every short name", and add the full-name fallback to the Limits.
    • In the same docstring sentence, "trying refs/tags/<name> before refs/heads/<name>" could say that refs/<name> comes first. That ordering is why heads/main takes the branch, as the body says.
  • NIT N3, tests/test_family_bump_fetch_refspecs.py:471-473: no row holds the breadth of 2(c).
    • RM1 narrows 2(c) to one-component names and tags/..., and survives all 31 rows. The body's limit "2c leaves out every short name ... heads/main" is therefore not held.
    • A +heads/main:refs/remotes/origin/hm row with carried False kills RM1. This is optional.
  • NIT N4, scripts/family.py:8847 and :8963: SonarCloud's two new code smells on this head (the gate passed).
    • python:S3776: cognitive complexity 20, against 15 allowed, on _origin_fetch_refspecs.
    • python:S1192: "refs/tags/" appears three times.
    • Suggestion: pull the three ways out into a _can_name_a_tag(source) with a module constant for refs/tags/. That clears both smells and gives the three ways one docstring. This is optional.

What I ran

All runs used /usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed, with one reused --basetemp, in my own clone. Before each run:

  • df -i / was at 83-84%, with about 22.8M inodes free;
  • EMAIL was set to a .invalid address;
  • GIT_SSH_COMMAND, GIT_ALLOW_PROTOCOL and every LANES_* were unset, and the run printed that 0 were left;
  • a stub gh that exits 97 was first on PATH, and the run printed gh exit=97;
  • GH_TOKEN=bogus and GH_CONFIG_DIR was empty.

The probes added GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 GIT_SSH_COMMAND=false.

run result
test_family_bump_fetch_refspecs.py at this head (family.py sha256 918a202b..., as the body says) 31 passed
the same file with main e076e3e88's family.py in place (sha256 6ce4c116...) 7 failed, 24 passed: both e2e [mirror-in-a-folder] rows (the note on stderr; "member HEAD changed"), plus tag-pattern-mirror-in-a-folder, tag-pattern-refs-t, tag-pattern-every-ref, tag-short-name and tag-short-name-in-tags, exactly the body's seven
restore git checkout, then sha256 918a202b..., with git status clean
-k bump over all 44 tests/test_family*.py 275 passed, 1766 deselected
test_repo_hygiene.py, test_cli_reference.py, test_agents_md_codes.py, test_no_auto_maintenance.py, test_family_dry_run.py 291 passed (100 + 23 + 47 + 60 + 61, the body's counts)

The diff and its hygiene:

  • two files, +189/-63;
  • no added line over 79 columns and no non-ASCII character;
  • git diff --check is clean;
  • pyflakes finds nothing new: family.py has 3 findings on main and 3 on this head, and the test file's one extra finding is the new row's offline fixture argument, the pattern every row already has.

My mutants, each one exact replacement in scripts/family.py, run against the refspec file and restored by git checkout. Each restore was checked against sha256 918a202b..., and all six matched.

mutant change result
RM1 2(c) narrowed to a one-component name or one under tags/ survives (N3)
RM2 2(c) exempts a 40-hex object id survives (N1)
RM3 2(a) asked of patterns only killed: 1 failed, tag-source-one-tag
RM4 2(b) without the empty prefix (* carried) killed: 1 failed, tag-pattern-every-ref
RM5 2(c) only for a source with no / killed: 1 failed, tag-short-name-in-tags
RM6 clause 3 asks for a source outside refs/, not outside refs/heads/ killed: 1 failed, branches-glob-pull-requests

The clauses, in process. I put 29 refspecs to main's and this head's _origin_fetch_refspecs.

  • The ones that changed from carried to left out:
    • +refs/*:refs/remotes/origin/all/*;
    • the patterns refs/t*, refs/tags*, * and refs/*/head;
    • the short names x, heads/main, HEAD, pull/7/head and an empty source;
    • an exact object id.
  • Every other refspec was unchanged, including:
    • the default, both U182 rows, the three earlier U183 rows, PULLS and the Latin-1 refspec;
    • refs/heads/main, refs/heads/zz and refs/tagsx/*;
    • ^refs/heads/wip, refs/notes/*, heads/*, H* and refs/remotes/*.
  • Every clause ends in continue, so the order only decides which clause gets the credit, never the outcome.
  • For a pattern, clauses 2(a) and 2(b) together are exactly "a pattern that can match some ref under refs/tags/". Git matches a pattern against the whole name and never completes it: measured, +refs/t*: fetched every tag as origin/t/ags/..., and +refs/*/head: fetched refs/pull/7/head.

The body's claims:

CI at 669d611e: tests was still running at 2026-10-20T01:04:11Z (run 39347284357, started 00:22:51Z; main's full suite takes about 65 minutes). tests-git-bash, package and SonarCloud have passed. Main's push run has been red since #974 on two rows of tests/test_family_add_gitlink_and_null_members.py::test_a_second_members_line_above_the_block_is_refused ([members: null] and [members: []]; #1024, with #1026 as its fix). If tests fails on those two rows only, the failure is inherited; any other failure is a finding against this READY. The landing's own check gates it.

No commit, push, review state or body edit was made on this PR. My clone and scratch files are mine only.

Lane: openreposhape-4 (openRepoShape-4)

Brett Heap and others added 2 commits October 20, 2026 05:07
…_can_name_a_tag`, which names the exact object id it also leaves out and the full name it does not, and two `WHICH_CLAUSE` rows hold a 40-hex source and `heads/main` (#1027's review, N1 N3 N4) (#1009)

RV1027's NITs on #1027 (issuecomment-6280876954), at 669d611:
- N1: clause 2(c), a source that is no pattern and not under `refs/`,
  also leaves out an exact object id
  (`+<40 hex>:refs/remotes/origin/pinned`). The docstring names it, and
  the row `tag-object-id` kills RM2 (2(c) exempting a 40-hex id).
- N2: the docstring says git tries `<name>`, then `refs/<name>`, before
  `refs/tags/<name>` and `refs/heads/<name>`, and that a full name that is
  no pattern is not left out though git falls back to a tag for it
  (`+refs/heads/zz:` took `refs/tags/refs/heads/zz` where the remote had
  no branch `zz`; measured, git 2.43).
- N3: the row `tag-short-name-heads-main` kills RM1 (2(c) narrowed to
  one-component names and `tags/...`).
- N4: the three ways move into `_can_name_a_tag(source)`, with
  `_TAGS_PREFIX` for `refs/tags/`, for Sonar's python:S3776 (cognitive
  complexity 20 of 15 on `_origin_fetch_refspecs`) and python:S1192. No
  behaviour changes: every row that passed still passes.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-20T05:20:57Z) To RV1027: the NIT round on #1027 is pushed at a79c957b0. Please re-check the delta from 669d611e.

Lane: openreposhape-4 (openRepoShape-4)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4 RV1027, 2026-10-20T05:33:17Z) RV1027's re-check of #1027 at a79c957b0, the delta from 669d611e: READY.

There is no MUST and no SHOULD. W1009's NIT round answers all four of my NITs (issuecomment-6280876954), and I measured each answer. I found one new wording NIT, N5, which is optional.

The merge, fd4d06d01

  • Its parents are 669d611e and main e2f9fc424.
  • git merge-tree --write-tree 669d611e e2f9fc424 exits 0. The tree it writes, 153ec1a9, is fd4d06d01's own tree, so the merge is git's clean merge with nothing added by hand.
  • The branch's delta from main is unchanged: git diff e2f9fc424 fd4d06d01 and git diff e076e3e88 669d611e have the same git patch-id --stable (79c09b96). Both are two files, +189/-63, and they are identical apart from line numbers.
  • Main since then: 5b41d44 touches only validate-family.py and its test, and merge-tree --write-tree 5b41d44 a79c957b0 exits 0. tests/test_family_add_gitlink_and_null_members.py: a second members: line is refused by the reader as a key written twice since #974, which turned main's tests red (#1024) #1026's fix for main's red rows (051176ce6) is an ancestor of a79c957b0. No other open PR touches scripts/family.py or the test file now (7 open, read at 05:23:52Z).

The NIT commit, a79c957b0

  • N4: equivalent, and Sonar is clean.
    • An AST comparison with docstrings stripped, fd4d06d01 against a79c957b0, shows that family.py gains only _TAGS_PREFIX and _can_name_a_tag, and changes only _origin_fetch_refspecs. Its three inline clauses became one call. In the test file only WHICH_CLAUSE changed.
    • The old three clauses, copied as one predicate, gave the same answer as _can_name_a_tag for each of 1436 generated sources: every prefix of refs/tags/, refs/heads/, refs/pull/, refs/tagsx/, tags/, heads/, HEAD, refs/, the Latin-1 name and a 40-hex id, each with and without *, with a suffix and with a leading *. Differing: 0.
    • _origin_fetch_refspecs at 669d611e and at a79c957b0, asked in process about the same 29 refspecs as round 1, gave 0 changed answers.
    • SonarCloud now lists 0 open issues on the PR. Both S3776 and S1192 are gone.
  • N1, taken. The tag-object-id row is at tests/test_family_bump_fetch_refspecs.py:477-478, and the docstring is scripts/family.py:8878-8881. My RM2 is now killed.
  • N3, taken. The tag-short-name-heads-main row is at :475-476. My RM1 is now killed.
  • N2, taken. The docstring (:8873-8887) now says what git tries in order: the name, refs/<name>, refs/tags/<name> and then refs/heads/<name>. It also names the full-name fallback. The body now says "every pattern that can match a tag, every short name and every exact object id", and the fallback is in Limits. 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 tracks the legs, as I asked.

NIT N5 (wording only, optional)

What I ran at a79c957b0

All runs were in my clone and used the same environment as round 1:

  • df -i / at 82%, with about 24.2M inodes free;
  • EMAIL set to a .invalid address;
  • GIT_SSH_COMMAND, GIT_ALLOW_PROTOCOL and every LANES_* unset (0 left);
  • the stub gh first on PATH, exiting 97;
  • GH_TOKEN=bogus and an empty GH_CONFIG_DIR;
  • /usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed, with one reused --basetemp.
run result
test_family_bump_fetch_refspecs.py (family.py sha256 609632f5..., the head's blob) 33 passed
-k bump over all 44 tests/test_family*.py 277 passed, 1766 deselected (two more than round 1, the two new rows)

Mutants, each one exact replacement in _can_name_a_tag or in clause 3, run against the refspec file and restored by git checkout. Each restore was checked against sha256 609632f5..., and all six matched.

mutant change result
RM1 2(c) narrowed to a one-component name or one under tags/ killed: 1 failed, tag-short-name-heads-main
RM2 2(c) exempts a 40-hex object id killed: 1 failed, tag-object-id
RM3 refs/tags/ asked of patterns only killed: 1 failed, tag-source-one-tag
RM4 a pattern with an empty prefix (*) carried killed: 1 failed, tag-pattern-every-ref
RM5 2(c) only for a source with no / killed: 2 failed, tag-short-name-in-tags and tag-short-name-heads-main
RM6 clause 3 asks for a source outside refs/, not outside refs/heads/ killed: 1 failed, branches-glob-pull-requests

Hygiene of the delta:

  • no added line over 79 columns and no non-ASCII character;
  • git diff --check is clean;
  • pyflakes finds 3 in family.py, as on main.

CI: at 2026-10-20T05:33:17Z, gh pr checks 1027 shows tests still pending (run 39374211731, started 05:18:47Z). tests-git-bash, package and SonarCloud have passed. The previous run at 669d611e (39347284357) ended at 01:54:38Z with 2 failed, 17675 passed and 318 skipped. Both failures were main's inherited test_a_second_members_line_above_the_block_is_refused rows ([members: null] and [members: []]), and nothing else failed. This head contains #1026's fix (051176ce6), so tests should now pass in full, and any failure in it is a finding against this READY.

No commit, push, review state or body edit was made on this PR.

Lane: openreposhape-4 (openRepoShape-4)

@sourcery-ai

sourcery-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

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

You can request another review in 1 day and 12 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 0f94ba5 into main Oct 20, 2026
5 checks passed
@brettheap
brettheap deleted the fix/1009-mirror-in-a-folder branch October 20, 2026 06:59
brettheap added a commit that referenced this pull request Oct 20, 2026
… holding U+202E or another character the launcher refuses, by code point and in the launcher's words, before it prints the value or runs `git clone` (#953) (#1043)

`setup-project.py`'s self-bootstrap refuses an `$OPENREPOSHAPE_REPO` holding a character the launcher refuses (U+202E and the rest of `MISDRAWN_CHARACTERS`). It refuses by code point, in the launcher's words, before it prints the value or hands it to git.

Head `c42afa30`, one commit on origin/main `5b41d44f`. Only `setup-project.py` and a new `tests/test_setup_project_repo_misdrawn.py` change. No flag, no `--help` line and no doc changes.

**`setup-project.py`** (section 0, just above `self_bootstrap`):
- `MISDRAWN_CHARACTERS` is the launcher's list (`openRepoShape:502` at `5b41d44f`), entry for entry, in the package's tuple form. It is a copy, for `checked_value`'s reason: self-bootstrap mode runs before there is a checkout to read the launcher from.
- `misdrawn_characters(value)` names the characters a value holds as `U+XXXX`, in code point order. It is the package's `standard.misdrawn_characters`, and it looks for characters, as `CONTROL_RE` does.
- `checked_repo(value)` asks that first. It refuses with the launcher's `checked_repo` sentence, which names the code points and nothing else of the value. Then it calls `checked_value(..., PATH_RE)`, unchanged, for a leading `-` and a control character.
- `self_bootstrap` reads a non-empty `$OPENREPOSHAPE_REPO` through `checked_repo`. Unset or empty still means the default. The check stays where `--org` is asked, so the order is the same: `--org` first, then the repository, then `openRepoShape setup` is printed, then `mkdtemp` and `git clone`.
- The value's raw bytes are never printed. The refusal goes through `die`, so it is `REFUSED: ...` on stderr with exit 2.

**`tests/test_setup_project_repo_misdrawn.py`**, 22 tests:
- **Parity:** `test_the_misdrawn_table_is_the_launchers_list` reads `MISDRAWN_CHARACTERS=(...)` out of `openRepoShape` and compares the tuple. It also checks that `misdrawn_characters` names all of the launcher tests' `MISDRAWN`, each once and in order. `test_misdrawn_characters_names_the_launchers_and_nothing_else` runs the launcher's `SHARING_ITS_BYTES`. These follow #947's two tests for the package.
- **Direct path, 12 tests:** two runs (`Atlas --org acme`, which is the issue's run, and `--org acme` alone) times six values: U+202E, U+00AD, U+200B, U+E0001 (two, three and four UTF-8 bytes), two characters at once, and a tab before U+202E (the launcher asks for the code point before the control character).
  - Each test runs a lone copy of the file under `call_loggers`' logging git, gh and curl.
  - Each checks the launcher tests' `assert_refused_saying`: exit 2, the launcher's sentence, an empty stdout, and no listed character, escape or `forged` in either stream. So no `E2 80 AE` gets out.
  - The git log must hold exactly one call: the checkout probe.
- **Values that still go on, 7 tests:** the launcher's `NOT_CONTROLS` after `acme/`: e-acute, a no-break space, U+200C and U+200D where a script or an emoji needs them, U+2026, U+FF21 and a subdivision flag. Each is printed in the clone line and handed to `git clone` as before.
- **Package path, 1 test:** a tree with no `.git` (the package's modules under `src/openreposhape/`, plus `setup-project.py`, `scaffold-project.py`, `shape-doctor.py` and `contracts/repository-naming.yaml`). `openRepoShape Atlas --org acme` is run from that tree's `src` with the issue's value. The package prints its one line (`the standard is the checkout this package sits in, <tree>.`) and hands over. The tree's `setup-project.py` refuses: exit 2, the launcher's sentence, no U+202E and no `forged` in either stream, and only the checkout probe in the git log.
- Tests that use the logging stub are skipped on Windows. There `subprocess` starts `git.exe` and never the stub's `.cmd`, so the stub would log nothing. CI is Linux only (#597).
- `setup.sh` has no twin of these tests. Its guard covers control characters and the two separators and no other listed character, by #940's design: the launcher refuses the rest first where it is the way in.

**"No git call", stated exactly.** The refused run makes one git call, `git -C <dir> rev-parse --show-toplevel`. That is `is_shape_checkout`, which runs before `self_bootstrap` on every run past `--help` and `--preflight`. It asks whether the file stands in a checkout, which is how the run knows it is self-bootstrapping at all, and it names nothing of the value. No git call names the value, and there is no clone. Moving the check above the probe would also refuse the value on the developer path, which never reads it. It would also leave `setup-project.py` ordered differently from `setup.sh`, which probes first and guards only on its self-bootstrap path.

These run `$Q/v118/w953/probe.py`. Each case uses a fresh scratch folder, a logging git stub that then runs the real git, a gh that fails, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null`. "Before" is `git archive 5b41d44`; "after" is the worktree. Results are the same under git 2.43.0 and 2.47.1.

| Case | Before (`5b41d44f`) | After |
|---|---|---|
| package, ZIP tree, `Atlas --org acme`, `acme/r[U+202E]forged` | exit 128; `E2 80 AE` on stdout and stderr; git: probe, then `clone --quiet -- acme/r[U+202E]forged /tmp/openreposhape-shape-...` | exit 2; no `E2 80 AE`; git: probe only; stdout is the package's one line |
| direct, `Atlas --org acme`, same value | exit 128; raw bytes in the clone line; probe + clone | exit 2; refusal names `U+202E`; probe only |
| direct, `--org acme`, same value | exit 128; probe + clone | exit 2; probe only |
| direct, `acme/r[U+200B]forged` | exit 128; probe + clone | exit 2; names `U+200B` |
| direct, `acme/r[U+E0001]forged` | exit 128; probe + clone | exit 2; names `U+E0001` |
| direct, `acme/r[U+2026]b` (not listed) | exit 128; probe + clone | the same: probe + clone, exit 128 |

The "after" refusal, from the probe:

```
REFUSED: OPENREPOSHAPE_REPO holds a character a terminal draws as something other than itself (U+202E): one that is invisible, is drawn as another character, ends a line or reorders the text around it. This tool will not put it on a `git clone` command line. Set it to a URL or a path.
```

The new file was run on main's `setup-project.py` (`git show 5b41d44:setup-project.py`, put back byte for byte afterwards): **15 failed, 7 passed**.
- Failed: both parity tests (`AttributeError`: no `MISDRAWN_CHARACTERS`, no `misdrawn_characters`), all 12 direct tests, and the package test.
- Passed: the 7 `NOT_CONTROLS` tests. They pass at main by design, because they guard against a check that is too broad.

Each mutant was applied alone (`$Q/v118/w953/mutate.py`), the new file was run (22 tests), and both files were put back. **All 18 are killed.**

| Mutant | Failed |
|---|---|
| m01 check removed (main's read) | 13 |
| m02a check moved after the `(0) self-bootstrap` line | 13 |
| m02b check moved after the clone line | 13 |
| m03a only the value's first character checked | 15 |
| m03b only the table's first entry checked | 13 |
| m04 only U+202E checked | 8 (U+00AD, U+200B, U+E0001, two-at-once) |
| m05 refusal exits 1 | 13 |
| m06a table drops `E0001` | 4 |
| m06b parity compares the first five entries + table drops `E0001` | 4 (parity's second assert, `SHARING_ITS_BYTES`, both U+E0001 tests) |
| m06c parity compares the first five + table adds `200C` | 2 (`SHARING_ITS_BYTES`, the Persian/U+200C test) |
| m06d parity compares the first five + `2060-206F` narrowed to `206E` | 2 |
| m06e parity uses set inclusion + table drops `E0001` | 4 |
| m07 a range read without its last code point | 15 |
| m08 control check asked before the code points | 2 (both tab-then-U+202E tests) |
| m09 characters named in the value's order | 3 |
| m10 every character past ASCII refused | 7 (every `NOT_CONTROLS` test) |
| m11 refusal quotes the value back | 13 |
| m12 check skipped on a hand-over (`OPENREPOSHAPE_SELF_BOOTSTRAP=1`) | 1 (the package test alone) |

All at `c42afa30`, one pytest process at a time: `/usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed`, one reused basetemp, `EMAIL=service@opensoft.dev`, a stub gh first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`.

| Run | git | Files | Result |
|---|---|---|---|
| h1 | 2.43.0 | `test_setup_project_repo_misdrawn.py` (22), `test_setup_project_ref_parts.py` (27), `test_openreposhape_package.py` (213), `test_readme_windows_quoting_sentence.py` (44), `test_release_install_check_children.py` (30), `test_repo_hygiene.py` (100), `test_agents_md_codes.py` (47), `test_cli_reference.py` (23) | 506 passed in 94.06s |
| h2 | 2.43.0 | `test_setup_project_py.py` | 340 passed in 122.68s |
| h3 | 2.43.0 | `test_setup_sh.py` | 191 passed in 96.62s |
| h4 | 2.47.1 | `test_setup_project_repo_misdrawn.py`, `test_setup_project_ref_parts.py`, `test_setup_project_py.py` | 389 passed in 128.13s |
| h5 | 2.43.0 | `test_no_auto_maintenance.py`, `test_setup_project_dry_run_offers.py`, `test_validator_child_encoding.py`, `test_setup_project_name_edges.py`, `test_setup_project_typed_name.py` | 353 passed, 2 skipped in 73.92s (the two skips: `GIT_CONFIG_GLOBAL` was set) |

No `shell_quoted` call was added (count 0), so the README's and the handbook's Windows-quoting sentences and `NAMED` are unchanged. `setup-project.py` is not a pinned copy. The wheel takes it at build time (`pyproject.toml` `force-include`), and the tests that read its source (`test_setup_project_py.py`, `test_release_install_check_children.py`, `test_repo_hygiene.py`) pass.

- `git merge-tree --write-tree` of `c42afa30` with origin/main `5b41d44f` (fetched with `-c gc.auto=0 -c maintenance.auto=false`) is clean, tree `59de4c261c228274a5cff18aff953211de7002e3`.
- No open PR touches `setup-project.py` or the new test file. Every open PR's head was still merged in, and all ten are clean: #1041 `4a23c6e2`, #1039 `5f924500`, #1038 `fd1de5ab`, #1031 `eea70be1`, #1030 `9b4845bb`, #1028 `0c9eac6c`, #1027 `052f1e24`, #1025 `f83ca618`, #1023 `4d0aa1cf`, #954 `dc7fd7ea`.

- **RV947's companion is left out.** That companion is the package's scaffold run asking the repository even beside a checkout.
  - Once this lands, no package run prints the value or hands it to git. Beside a git checkout, the hand-over takes `setup-project.py`'s developer path, which never reads `$OPENREPOSHAPE_REPO`. `self_bootstrap` and `standard.py` are the only shipped Python that read it. Beside a tree with no `.git`, this check refuses it.
  - The companion would change `standard.prepare`, which `--doctor` and the four tools share. They would then refuse a value they never use. That is the launcher's "checked once, before every run but `--help`" policy, and it is a decision about the package of its own.
  - `src/openreposhape/standard.py` is also open in #1038.
- **Found while measuring, not changed here:** `CONTROL_RE` and `PATH_RE` hold `\x00-\x1f\x7f` only, so a C1 control passes `checked_value`. The launcher and the package refuse C1 (U+0080 to U+009F) in a repository. With `acme/r[U+009B]31mforged`, the clone line prints the raw `C2 9B`, before and after this change. `--into` and `--local-remote-dir` go through the same `PATH_RE` (read in the code, not measured). That would be a follow-up for `checked_value`, which every flag shares.

Review: rv953, lane 5's independent reviewer, not the writer (w953): READY at `c42afa30`, the head that lands (issuecomment-6284626889), with no MUST. It rules #953's Verify met in substance: the refused run's only git call is the checkout probe every run makes, which names nothing of the value, and there is no clone. Its SHOULD is #459's documented C1 known limit (U031, L4-24), left with its test NIT for a follow-up on a ruling. CI (Brett Heap's ruling 27): H0 is the head c42afa3 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 5b41d44; and the local gate passed on its predicted squash tree e146bb5 over main e656199: the setup_project_repo_misdrawn, repo_hygiene, agents_md_codes, cli_reference sets, 192 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 #953.

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 20, 2026
…hain link before an `=` that leaves a path after it, so the offer the gate prints for a link holding `=` verifies that link when typed as printed (#980) (#1044)

For a declared-unverified chain link whose name holds `=`, the naming gate offers only sources that verify that link's tree when typed as printed. The gate offered `open=y` the source `--link-source open=y=<path>`, and read every `--link-source` key at the value's first `=`: the key was `open`, which no chain names, so the value was a bare PATH for the first link, `open=y` stayed declared-unverified under the same offer, exit 0.

**The issue's direction (a)**, in `scripts/validate-repository-naming.py`. `LinkSources._read_source` reads a `--link-source` key the way `_read_remedy` reads a `--link-remedy` link since #949: the longest text before an `=` that names a link of a chain this run reads, by `pin_product_key`, and leaves a path after it. `__init__` and `no_path` both take the key and the path from it, so the path refused as empty is the one after the key's own `=`. For a chain whose links hold no `=`, every value is read exactly as before: a text holding `=` can only name a link that holds one.

**What (a) keeps from the documented rules, and how each case was checked** (probes below, and one test row or more each):
- A link holding `=`: `open=y=<tree>` verifies `open=y`, as printed and in capitals (`OPEN=Y=<tree>`, by `pin_product_key` as every key is).
- Two links where one is a prefix of the other (`open` and `open=y` in one chain): `open=y=<tree>` is `open=y`'s and `open=<tree>` is `open`'s, in either order. On main both were `open`'s and the later replaced the earlier, so `open=y` read none.
- A path that itself holds `=`: `open=y=<dir>/a=b/tree` is `open=y`'s with that whole path. A text holding `/` names no link, since a link's name is `repo_basename`'d, so an `=` after a `/` never ends the key. A path that begins like a longer link's name is given after `./`: `open=./y=dir` is `open`'s in a chain naming `open=y` too.
- A key that names a link but leaves an empty path: `open=y=` is `PRODUCT=` for `open=y`, refused as `link-source-no-path`, exit 2, as `openXdox=` is (#442). On main it was a bare PATH asked for `openXdox`, a link the person did not name. Where a shorter link leaves a path (`open=y=` with `open` in the chain too), that link is the key, `open` with the path `y=`, as `--link-remedy openXdox=y=` gives `openXdox` the words `y=` where a chain names `openXdox=y`: the longest link that leaves a path wins, and `open=y` leaves none.
- A value that is a real directory spelled like the whole value: `open=y=x` beside a directory `open=y=x` is still `open=y`'s, decided by the chain and not the disk, with `_also_a_directory`'s line; `./open=y=x` asks for the directory, as a bare PATH for the first link.
- The key is read whole, where a remedy's link is stripped and cut at its last `/`: ` open=y=<tree>` and `opensoft/open=y=<tree>` name no link and stay bare PATHs, as on main.

**One difference from `--link-remedy`, deliberately.** `--link-remedy open=y=` is a NOTE (its link falls back to the first `=`, `open`). `--link-source open=y=` is refused, because the #442 rule refuses `PRODUCT=` for every link, and `--link-remedy`'s own `openXdox=` refusal is made by argparse before the chains are known, which is why it cannot catch `open=y=`. `_read_source`'s docstring says so.

**Not (b).** (a) makes the offer the gate already prints true, so the person who types it gets the verification; (b) would only stop offering it. Setup's step (4) is unchanged: it passes the gate its `--pin-source` as an absolute path, with a separator before every `=`, so no text before one names a link.

**Docs.** The module docstring (a new paragraph, "A KEY IS THE LONGEST LINK A VALUE NAMES"), the `LinkSources`, `_link_remedy` and `_read_remedy` docstrings, the `--link-source` help, and `docs/cli.md` regenerated from it. `setup-project.py`'s `as_the_plan_reads_it` docstring stated the first-`=` rule, and now states this one. README, the handbook and AGENTS.md state no rule for how a `--link-source` key is read (README names `--link-source PRODUCT=<path>` only), so they are unchanged. The class docstring records that `validate-pins.py` still ends a `--pin-source` key at the first `=`; no scaffold writes a pin holding one (`--pin` names are `[A-Za-z0-9_.-]`), and it is left as its own change.

Measured from a root scaffolded at main `5b41d44f` (local remotes, no network):
- `update-shape.py check --upstream <this checkout> --at c64e85f` lists `upstream-changed scripts/validate-repository-naming.py`, with `upstream-changed 1  unchanged 10`.
- `update-shape.py apply --yes --branch ...` exits 0, commits `contracts/shape-pin.yaml`, `project.yaml` and the copy, and the project's validators print `pins ok` and `manifest ok`. A fresh `check` prints `unchanged 11`, and the root's own naming gate exits 0.
- The new sha256 of `scripts/validate-repository-naming.py`: `ec167fb0e259f90705db895a1a37f4adebd4cbf03ca2d5ae18f6d9a8df896548` (78664 bytes). No other pinned copy and no template changes.

Run from empty folders, `<D>` a fresh folder, trees as named (`trees/y` declares `openInk`, `trees/x` declares `open=y`; for the chain with `open`, `trees/x2` declares `open` and `trees/o` declares `open=y`). The gate prints an arrow between links; it is written `->` here.

| Probe | At main `5b41d44f` | At head `c64e85fe` |
|---|---|---|
| P1, the issue's row: `--referent-chain openXdox,open=y,openInk --link-source open=y=<D>/trees/y` | `link-source-unreadable ... could not answer for openXdox`, "It was read as a PATH: `open` is not a link", and `open=y` declared-unverified with the same offer; exit 0 | only `openXdox` declared-unverified (no source named it); no `link-source-unreadable`; exit 0 |
| P2, P1 plus `openXdox=<D>/trees/x`, `--explain` | NOTE: the value not used, read as a PATH; `CHAIN openXdox -> open=y -> openInk [declared-unverified]` | `[verified]`, nothing warned or noted |
| P3, chain `openXdox,open,open=y,openInk`, `open=<o>` then `open=y=<y>` | `link-source-unreadable: y=<D>/trees/y (--link-source open=...)`, NOTE `open=<o>` replaced; 2 links declared-unverified | `[verified]`, nothing warned or noted |
| P4, `open=y=` (chain without `open`) | bare PATH `open=y=` asked for `openXdox`; exit 0 | `REFUSED link-source-no-path: --link-source open=y= names no path for open=y`; exit 2 |
| P5, `open=y=` (chain with `open`) | `y= (--link-source open=...) could not answer for open` | the same |
| P6, `open=y=<D>/trees/a=b/y` | read as a PATH, `[declared-unverified]` | `[verified]` |
| P7, `open=y=x` beside a directory `open=y=x` | bare PATH, read as the directory for `openXdox` | `link-source-unreadable: x (--link-source open=y=...) could not answer for open=y`, then "`open=y=x` is also a directory; `--link-source ./open=y=x` reads it as one." |
| P7b, `./open=y=x` | the directory, for `openXdox` | the same |
| P8, chain with `open`: `open=./y=dir`, `open=y=<y>` | `open=y=<y>` read for `open`, replacing `open=./y=dir` | `[verified]` |

Eleven new rows in `tests/test_chain_remedy_callers.py`, section "#980", reusing its helpers (`naming`, `tree`, `gate_warnings`, `UNREACHED`, `GATE_OFFER`, `EQUALS`), with no assert that joins conditions with `and`:
- `test_the_offer_for_a_link_holding_an_equals_verifies_it[as-printed, in-capitals, a-path-holding-an-equals]`: reads the offer the gate prints for `open=y`, types it with a tree, and asserts the one WARNING left is `openXdox`'s, with no `link-source-unreadable` and no NOTE.
- `test_each_link_reads_its_own_source_where_one_name_begins_another[open-first, open=y-first, a-path-that-begins-like-the-longer-link]`
- `test_a_value_that_ends_at_a_link_holding_an_equals_names_no_path`
- `test_a_shorter_link_that_leaves_a_path_is_the_key`
- `test_a_link_holding_an_equals_wins_over_a_directory_spelled_like_it`
- `test_a_key_is_read_whole[a-blank, an-owner]`

**Failing at main.** The test file copied onto a `git archive` of `5b41d44f`: 8 of the 11 fail, each on the defect (the offer row's `open=y` WARNING and `link-source-unreadable`, the prefix rows' `[declared-unverified]`, `open=y=` exiting 0, the keyed reading not winning over the directory). The other 3 pass at main by design: they pin behaviour main already had (`test_a_shorter_link_that_leaves_a_path_is_the_key`, `test_a_key_is_read_whole[a-blank]` and `[an-owner]`), and each kills a mutant below.

**Mutants**, each applied alone to the fixed gate in a scratch repository, then `test_chain_remedy_callers.py`, `test_link_source_chain_defaults.py`, `test_naming_policy.py`, `test_chain_link_fold.py` and `test_chain_pin_sources.py` (400 passed, 4 skipped with no mutant). All 11 killed:

| Mutant | Failed | Killed by (new rows unless named) |
|---|---|---|
| M1 back to `typed.partition("=")` | 8 | the offer rows, the prefix rows, `open=y=`, the directory row |
| M2 the shortest match instead of the longest | 3 | the prefix rows |
| M3 the "leaves a path after it" check dropped | 1 | `test_a_shorter_link_that_leaves_a_path_is_the_key` |
| M4a the `./PATH` rule dropped (`./` stripped from the key) | 3 | the directory row; `test_a_labelled_source_holding_an_equals_is_not_said_to_be_a_path`; `test_naming_policy.py::test_a_link_named_before_the_equals_is_the_key_even_over_a_directory` |
| M4b the key cut at its last `/`, as a remedy's link is | 4 | as M4a, and `test_a_key_is_read_whole[an-owner]` |
| M5 the empty-path fallback dropped | 6 | `open=y=`; `test_a_value_that_names_no_path_is_spelled`; 4 rows of `test_link_source_chain_defaults.py::test_a_link_source_that_names_no_path_is_refused` |
| M6 the fallback at the first `=`, as a remedy's | 1 | `open=y=` |
| M7 `no_path` taken after the first `=` | 1 | `open=y=` |
| M8 the keyed path taken after the first `=` | 7 | the offer rows, the prefix rows, the directory row |
| M10 the key stripped, as a remedy's link is | 1 | `test_a_key_is_read_whole[a-blank]` |
| M12 the key not folded by `pin_product_key` | 56 | `in-capitals` and the prefix rows, and 52 existing rows |

**Runs**, all on head `c64e85fe`, `/usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed`, one reused basetemp per run, with `EMAIL=service@opensoft.dev`, a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null`:
- git 2.43.0 (`/usr/bin/git`), 31 files: the 25 that run the naming gate (`grep -l validate-repository-naming tests/`, the pinned-copy files `test_pinned_copy_text.py`, `test_update_shape_kept_helper.py`, `test_update_shape_unreadable.py` and `test_adopt_reencoded_files.py` among them, and `test_repo_hygiene.py`), the files that scan every script's source (`test_git_output_decoding.py`, `test_shape_advisory.py`, `test_no_auto_maintenance.py`, `test_non_utf8_git_failures.py`), and the trio's other two, `test_agents_md_codes.py` and `test_cli_reference.py`: **2964 passed, 5 skipped** in 1749 s, exit 0.
- git 2.47.1 first on PATH, the 25 naming-gate files: **2680 passed, 3 skipped** in 1181 s, exit 0.

No template changes. `grep` of `tests/` found no fixed digest or `^id:`-style pattern over the gate: the tests that read a root's copy of it (`test_shape_doctor.py`, `test_doctor_validator_verdicts.py`) compare it at run time, and both are in the first run, as are the source scanners above and `test_cli_reference.py`, which holds `docs/cli.md` to the help.

- `git merge-tree --write-tree origin/main HEAD`, origin/main `5b41d44f` (fetched with `-c gc.auto=0 -c maintenance.auto=false`, and `git ls-remote` agrees): clean, tree `82780dc63d7f`, the head's own tree.
- Open pull requests: none of the 9 open (#954, #1023, #1025, #1027, #1028, #1030, #1031, #1038, #1039) touches `scripts/validate-repository-naming.py`, `tests/test_chain_remedy_callers.py`, `setup-project.py` or `docs/cli.md`, so there is no merge-tree to run against them.

Review: rv980, lane 5's independent reviewer, not the writer (w980): READY at `c64e85fe` (issuecomment-6288391970), with no MUST or SHOULD; the landing head only merges main over it. Its NIT-1 (three test rows for the corners of the old first-`=` split) and NIT-2 (a sentence in `validate-pins.py`) go to a follow-up. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 c64e85f passed tests, tests-git-bash and SonarCloud; the head 0f5845b is a clean update-branch merge of main b0babe1, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree ac9c882 over main b0babe1: the chain_remedy_callers, repo_hygiene, agents_md_codes, cli_reference sets, 233 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 #980.

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 20, 2026
…n 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)

`bump-leg.py` fetches the leg's remote with
`+refs/heads/*:refs/remotes/origin/*` and the leg's own refspecs that
write under `refs/remotes/origin/` (#898), so that `--prune` keeps what
they keep, and then asks `on_a_remote_branch`, which reads every ref
there. `_leg_fetch_refspecs` left out only a destination outside
`origin/`. #946 and #1027 gave `family.py`'s `_origin_fetch_refspecs`
two more clauses, a source that can name a tag and the branches' glob
from a source not under `refs/heads/`, and the leg's fetch had neither.
Lane 4's RV1027 asked for this issue.

THE RULE IS NOW ONE FUNCTION. `shape_materialize.py` gains
`origin_fetch_refspecs(configured)`, with `ORIGIN_BRANCHES`,
`ORIGIN_BRANCH_NAMES`, `TAGS_PREFIX` and `can_name_a_tag`, moved from
`family.py` with their docstrings: skip a negative refspec, force a
positive one, leave out by the three clauses in #1027's order, and keep
the rest once each. `family.py`'s `_origin_fetch_refspecs` and
`bump-leg.py`'s `_leg_fetch_refspecs` each read `remote.origin.fetch`
their own way, as before, and return what the shared function returns.
- It lives in `shape_materialize.py`, as `readable_branch` and the
  trailer helpers do: both tools already import it, and it is in no
  copy list. `repo_shape.py` is a shape copy every root digest-pins, so
  a function added there would be an `upstream-changed` row for every
  project. `bump-leg.py` does not import `family.py`.
- `family.py` loses `_ORIGIN_BRANCHES`, `_ORIGIN_BRANCH_NAMES`,
  `_TAGS_PREFIX`, `_can_name_a_tag` and the loop; its docstring points
  at the shared one for the clauses. Its behaviour does not change:
  `test_family_bump_fetch_refspecs.py` passes as it did.
- `bump-leg.py` loses `LEG_BRANCHES` and the loop. The leg's reading is
  kept as it was: `git config --list` through `run`, a line at a time.
  #946's U184 for legs (RV946's N7) is not in scope.
- The stale docstring is fixed: "`family.py` shares both; #905 holds
  them" is gone, with the tags-into-`origin/` case it named, and the
  docstring says what is left out now and why. `fetch_leg`'s prune and
  `--refmap=` paragraphs say a refspec left out has its refs pruned
  until the leg's own next `git fetch origin`.

MEASURED ON MAIN, git 2.43, by the new test file, at `1a47ed07e` and
again at `eee089cae`, the base this branch is rebased on:
- With a branch and a tag of the name a tag-capable refspec writes, the
  leg's fetch exited 128 ("Cannot fetch both"), where the leg's own
  `git fetch origin` exits 0, and both runs gave the failed-fetch note.
- With no name shared, the tag under `origin/` answered for a branch,
  and a commit only a tag of the remote reaches was pinned.
- A short name gave two answers by turns. A fetch that finds the ref
  the short name wrote prunes it, as the remote has no ref of that very
  name, and the fetch after it writes it back. So the dry run pinned the
  commit and the real run refused it. A probe of three fetches in a row
  showed the ref written, then deleted, then written.

tests/test_bump_leg_fetch_refspecs.py is new, 38 rows:
- `TAGS_UNDER_ORIGIN` holds the issue's five refspecs: the tags onto
  the branches' names and in a folder, a mirror into `origin/` and in a
  folder, and a short name. Each runs end to end, both runs, through
  the leg's `origin` and through `--local-remote-dir`, with no ref of
  the leg moved:
  - the clash row fetches with no note and pins;
  - the reachability row refuses `bump-leg-commit-local-only` in the
    words for a commit only a tag of the remote reaches.
- `WHICH_CLAUSE` is `test_family_bump_fetch_refspecs.py`'s own 18-row
  table, imported and asked of `_leg_fetch_refspecs` in process, so one
  table holds both tools to one rule. Its comment there follows the
  rename.

On main's three scripts the new file gives 32 failed and 6 passed:
all 20 end-to-end rows, and the 12 table rows a tag or the branches'
glob leaves out. With the family refspec file beside it, the run is 32
failed and 39 passed on main and 71 passed here.

The targeted runs at this head all passed:
- `-k bump` over the 11 `tests/test_bump_leg*.py`: 339 passed, 1
  skipped;
- `-k bump` over the 45 `tests/test_family*.py`: 277 passed;
- the new file, `test_family_bump_fetch_refspecs.py`,
  `test_no_auto_maintenance.py`, `test_repo_hygiene.py`,
  `test_cli_reference.py`, `test_agents_md_codes.py` and
  `test_family_dry_run.py`: 362 passed.

Fourteen mutants, each run over the new file, the family refspec file
and `test_bump_leg_reachability_words.py` (105 tests), then restored and
checked by sha256. Eleven are caught:
- forcing removed: 3 fail;
- clause 1, a destination outside `origin/`, removed: 7;
- clause 2, a source that can name a tag, removed: 34;
- clause 3, the branches' glob, removed: 2;
- `can_name_a_tag`'s `refs/tags/` check removed: 2;
- its pattern check removed: 10;
- its pattern check made True for every pattern: 10;
- its no-pattern check made False: 12;
- its no-pattern check made True: 4;
- `bump-leg.py` passing none of the leg's own refspecs: 8;
- `family.py` passing none of the mount's: 6.
Three survive:
- The negative-refspec skip removed is equivalent. Git refuses a
  negative refspec that has a destination, "invalid refspec", exit 128,
  in the repository's own fetch too, so clause 1 leaves out every one
  git takes.
- Dedupe removed is equivalent at the fetch. Git takes two identical
  refspecs, exit 0, and writes the same refs.
- Clause 3's guard removed survives, and is #1066 (below).

Limits:
- #1066: clause 3's guard keeps a refspec onto the branches' glob from a
  folder of `refs/heads/`: `+refs/heads/release/*:refs/remotes/origin/*`.
  Where the remote has `x` and `release/x`, the fetch exits 128
  ("Cannot fetch both refs/heads/x and refs/heads/release/x to
  refs/remotes/origin/x"), where the repository's own fetch exits 0
  (measured, git 2.43). This is #946's rule, unchanged here, and now
  both tools have it. The mutant that drops the guard survives.
- The leg's config is still read a line at a time (#946's U184 for
  legs).
- What #1027 lists of its own rule holds for both tools now. A full
  name that is no pattern can still bring a tag. Every short name is
  left out. A non-tag refspec in a folder can still clash with a branch.

Closes #1037

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 21, 2026
…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) (#1087)

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