Skip to content

family.py: bump's fetch no longer moves the mount's own branches, works where the mount maps tags into origin/ instead of telling the person to fix the network, reads its refspecs with git config --list -z, and asks git remote get-url origin first, so a mount with no origin is not fetched from a path of that name (#905) - #946

Merged
brettheap merged 3 commits into
mainfrom
fix/905-bump-fetch-refspecs
Oct 19, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #905 (comment)

What changed and why

family.py bump fetches the member's origin with refspecs of its own since #686: every branch into refs/remotes/origin/, pruned, with the mount's own refspecs that write there riding along. #905 groups lane 4's four lines U182 to U185 from #795, which found four states that fetch gets wrong. Every hunk is in bump's fetch: _origin_fetch_refspecs, _has_origin, the fetch in cmd_bump, and two docstring sentences that described the old order. No flag, --help line, refusal code or doc page changes.

Each state was measured with real bump --dry-run and bump runs, Before on main 50178305 (the v1.1.1 release commit, scripts/family.py blob e8ed8bd6) and Now on round 0's head 4e1aa24a (blob 695d40bf), git 2.43. Round 1 changes only what is left out of a refspec whose source is under refs/tags/, measured below. Holders were built with scaffold-project.py, family.py init and add into local bare repositories. Each mount was pointed at a private bare remote. Each run listed the mount's refs with for-each-ref before and after. The probes ran with a gh that exits 97 first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, an empty GIT_CONFIG_GLOBAL, GIT_CONFIG_NOSYSTEM=1, GIT_SSH_COMMAND=false and no GIT_ALLOW_PROTOCOL.

Round 1 (head 5d5d06bb, a fast-forward of 4e1aa24a) takes lane 3's RV946 review (issuecomment-6252861452: READY, no MUST):

  • S1: a configured refspec whose source is under refs/tags/ is left out wherever under origin/ it writes.
  • S2: both U183 rows run over a mirror into origin/ too.
  • N1: a row holds a refspec whose bytes are not UTF-8.
  • N2: _has_origin's docstring no longer says "once per bump".
  • Body: the two lines RV946 asked for are under "What it cannot tell", with N3, N4 and N5 as follow-ups.
  • Measurements: the Before lines of S1 and of those two lines were measured on main 50178305 and on round 0's head 4e1aa24a (blob 695d40bf); the Now lines on this head (blob 1190f813).

--refmap= (U182)

The mount is configured +refs/heads/*:refs/heads/*. Its local feat holds a commit never pushed, on top of the remote's feat, and --to is the remote's feat.

A tags-into-origin/ refspec (U183)

The mount's only refspec is +refs/tags/*:refs/remotes/origin/*. The remote has a branch feat holding --to and a tag feat at the pin.

git config --list -z (U184)

The mount has the ordinary refspec, and an alias whose value's second line is remote.origin.fetch=+refs/heads/zz:refs/remotes/origin/zz. --to was pushed to a branch since the mount last fetched.

  • Before:
    • git config --list prints that line as a line of its own. git config --get-all remote.origin.fetch does not list it.
    • bump put +refs/heads/zz:refs/remotes/origin/zz on the fetch's command line, and the fetch of a branch origin lacks failed. Both runs printed note: could not fetch members/IRRS from its origin.
    • The dry run said The fetch of members/IRRS failed, as the note says, and both pinned from the refs the push had left.
  • Now: the listing is git config --list -z. It ends each entry with a NUL and puts a newline between key and value, so a value that holds a newline is one value. Both runs fetch, with nothing on stderr, and pin. It is still a listing, which test_a_dry_run_runs_no_git_command_that_writes asks of every git config in a dry run.
    • It is read as bytes (_git_bytes) and decoded with surrogateescape, so a refspec git can read and UTF-8 cannot reaches the command line as its own bytes. run decoded it with replace. RV946 measured that main mangled a caf\xe9 refspec and failed the fetch with the note, and that this head fetches it; a row now holds it (N1).

A path named origin (U185)

The mount has no remote origin (git remote remove origin; git remote get-url origin exits 2, No such remote). A repository is at the path origin inside the mount, with one branch leg-main. --to was pushed to a branch of the member's remote.

Control

An ordinary mount (+refs/heads/*:refs/remotes/origin/*, as add leaves it), with --to pushed since its last fetch. Before and Now alike: the dry run adds refs/remotes/origin/feat and moves nothing else, exit 0, and the real run pins.

Tests

New file tests/test_family_bump_fetch_refspecs.py, 13 tests. Every row runs bump --dry-run and bump over one holder, and compares the mount's for-each-ref before and after each.

What it cannot tell

  • A tag's copy under origin/ is pruned (U183's cost). In a mount that maps tags into origin/, the person's own git fetch origin writes refs/remotes/origin/<tag>. bump's fetch no longer names that refspec, so its --prune drops each such ref no branch of that name backs, in the dry run too, as it drops any ref under origin/ no refspec of the fetch maps (family.py bump: a mount with a narrowed fetch refspec is refused member-to-local-only on a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with no origin is told to fix the network #686). The mount's next git fetch origin writes them back.
    • Measured (U183c, no name shared): Before, the dry run moved nothing; Now it removes refs/remotes/origin/v-only. The local tag refs/tags/v-only stays, and the dry run prints the row, exit 0.
    • In U183b, Now's dry run removes refs/remotes/origin/v-release, which is the ref that answered for a branch.
    • With remote.origin.tagOpt=--no-tags, no ref of the mount holds a commit only a remote tag reaches, until the next fetch (RV946; measured U183h, on 4e1aa24a and this head).
      • The mount maps tags into origin/ and follows none. Its own git fetch origin wrote refs/remotes/origin/v-tagonly and no refs/tags/v-tagonly.
      • The dry run removed refs/remotes/origin/v-tagonly; then git for-each-ref --contains <c> listed nothing. The person's next git fetch origin wrote it back.
      • With no tag of the mount's own to ask, _origin_tag_reaches finds none, so the refusal is family.py bump --to: pins a commit only the member's mount holds, which bump-leg.py refuses as local-only #613's plain words, "A pin the rest of the world cannot fetch". On main the refspec rode along and both runs pinned the commit.
    • A +refs/*:refs/remotes/origin/* mount loses its whole mirror to the dry run (RV946; measured U183g). After the mount's own git fetch origin, bump --dry-run pinned the remote's feat, exit 0, and removed refs/remotes/origin/heads/feat, refs/remotes/origin/heads/main and refs/remotes/origin/tags/v-one. refs/remotes/origin/feat and main stayed, written by bump's own refspec.
    • N5 (RV946, optional): the test rows are built without such a ref, so none of them asserts this cost. RV946 measured it: in a mount whose only refspec maps tags into origin/, the dry run removed only refs/remotes/origin/v-only and v-tagonly, left refs/heads/* and refs/tags/* alone, and the next git fetch origin restored them exactly, with tag following on and with tagOpt=--no-tags. Left as a follow-up.
  • A clash that remains. A configured refspec from outside refs/heads/ and refs/tags/ whose destination is a folder under origin/ still rides along. It clashes with bump's own where a branch has the same name: a branch pr/7 beside pull request 7. The fetch then exits 128 and the note blames the fetch, as before. Its refs under origin/ also still count for reachability, as family.py bump: a mount with a narrowed fetch refspec is refused member-to-local-only on a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with no origin is told to fix the network #686 decided for pull requests. Not measured. (Round 0 listed +refs/tags/*:refs/remotes/origin/tags/* here; round 1 leaves it out, S1.)
  • N4: a remote origin with no url (RV946; a follow-up). A [remote "origin"] section with a fetch line and no url is a remote to git: git remote get-url origin prints origin, exit 0, and a fetch reads the path origin (measured, git 2.43, in a scratch repository). bump fetches it, as the person's own git fetch origin does, and the docstring says so.
    • RV946 measured it on this PR: the dry run wrote origin/leg-main and pruned the member's origin/main and origin/pushed-*, the same as on main.
    • RV946's cheap follow-up: treat a get-url that prints exactly origin, with no remote.origin.url in the -z listing, as no origin.
  • N3: the dry run's sentence for a mount with no origin (RV946; a follow-up). Measured on round 0's head (U185b), and the code is unchanged since: with remote.origin removed by git config --remove-section, the mount keeps refs under origin/. Where they hold --to, the dry run prints the row and says The fetch of members/IRRS failed, as the note says, under the note could not fetch members/IRRS: it has no remote named `origin` . Now no fetch was made. Main printed the same words after a fetch of the path origin that failed. The sentence is _bump_dry_run's, held by test_family_dry_run.py, and is left as it is.
  • bump-leg.py's twins. bump-leg.py: the leg's fetch prunes the branches its remote deleted and moves no branch of the leg's own, a commit only a tag of the remote reaches is told so instead of "never pushed", and a leg with no origin is told it has none instead of to fix the network (#898) #937 (lane 1, bump-leg.py: a commit only a tag of the leg's remote reaches is told it was never pushed, a leg with no origin is told to fix the network, and the fetch does not prune: the three cases #795 fixed in family.py #898) has landed as d4d11bfe and added --refmap= to the leg's fetch, which is U182's other half. On main (c7a3ff16), scripts/bump-leg.py:1074 still reads git config --list a line at a time, its _leg_fetch_refspecs still carries a tags-into-origin/ refspec, and :1317 asks _has_origin only after a failed fetch (RV946 round 2, N7). Those are U184's, U183's and U185's twins in bump-leg.py, which this PR does not touch.
  • Another git. U182 to U185 were measured on git 2.43 only. A git that applies configured refspecs to a fetch differently is not measured here; the tests hold the behaviour on whichever git runs them.

Choices for the reviewer

Shared files

scripts/family.py is not a pinned copy: it is not in FAMILY_COPIED_VERBATIM (scripts/shape_materialize.py:531-553). scripts/bump-leg.py is not touched.

git merge-tree --write-tree from this head 5d5d06bb:

against head result
main 64464d31 clean, exit 0
main now 450279ca clean, exit 0
#937 fix/898-bump-leg-reachability (lane 1) cea5210a (and f4aa32d2 before it) clean, exit 0
#955 fix/430-g-family-py-unreadable (lane 4, W430G) 991990cc clean, exit 0
#957 fix/920-add-gitlink-and-null-members (lane 4, W920) 37faf298 clean, exit 0
#960 fix/929-init-yaml-words (lane 4, W920) c6700b7f clean, exit 0

The run and the mutants

The run (round 1): ONE background run, the same 23 files as round 0, over this head's bytes (5d5d06bb; the run started on the working tree at 17:47:03Z, and the commit holds the same two blobs, 1190f813 and 0730095e): 916 passed, 4 skipped, exit 0, in 1897.69 s (17:47:03Z to 18:18:42Z). It ran /usr/bin/python3 -m pytest -q -rfs -p no:cacheprovider -o tmp_path_retention_policy=failed in the probe environment above. Round 0's run over 4e1aa24a was 911 passed, 4 skipped; the 5 more are round 1's new rows.

  • Files: tests/test_family_bump_fetch_refspecs.py; every tests/test_family_bump*.py (reachability_words, remove_rollback, to_a_tag, to_checks, to_local_only); from git grep -l -e "_origin_fetch_refspecs" -e "_has_origin" -e '"bump"' tests/, test_family.py, test_family_add_excludes.py, test_family_add_member_id.py, test_family_dry_run.py, test_family_holder_states.py, test_family_member_path.py, test_family_members_not_a_list.py, test_family_operation_reftable.py, test_family_refuse_up_front.py, test_family_switch_branch_exists.py, test_family_tracking_branch.py, test_next_refspec_branch_length.py, test_refusal_default_remediation.py and test_validate_family_branch_step.py; and tests/test_repo_hygiene.py, tests/test_cli_reference.py and tests/test_agents_md_codes.py.
  • Left out of that git grep list, as not reaching the change: test_bump_leg_branch_and_push_line.py, test_bump_leg_unreadable.py and test_non_utf8_git_reads.py, whose "bump" is a parametrize id over bump-leg.py and which run no family.py bump, and test_doctor_unreadable_pin.py, whose "bump" names bump-leg.py's lists the doctor copies.
  • The 4 skips are test_family_operation_reftable.py's (lines 393 and 437): git 2.43 cannot make a reftable repository.
  • It ran on the branch's base 50178305; CI's full Linux suite over the merge with main is the rest.

Mutants (round 1), each one byte change to scripts/family.py, run against tests/test_family_bump_fetch_refspecs.py (13 tests). Each was restored after, and git hash-object checked it was this head's blob 1190f813.

mutant reverts result
M1 the fetch without --refmap= killed: 3 failed (both U182 rows, and the mirror-into-origin clash row: a local branch moved, and the mirror's refs written, under the dry run)
M2 neither clause: every configured refspec under origin/ carried killed: 6 failed (all six U183 rows)
M6 RV946's survivor: the rule keyed on a refs/tags/ source alone killed: 2 failed (both mirror-into-origin rows: the note on stderr; the real run pinned, member HEAD changed)
M7 the rule keyed on the destination alone (round 0's code) killed: 2 failed (both tags-in-a-folder rows, the same two ways)
M3 git config --list without -z killed: 2 failed (U184 and its bytes row: origin/pr/7, and origin/review/cafe, pruned under the dry run)
M3b the whole Before read, run(["git", "config", "--list"]) split into lines at = killed: 2 failed (U184 and its bytes row: the note on stderr)
M5 RV946's survivor: the listing decoded with "replace" killed: 1 failed (the bytes row: the note on stderr)
M4 no get-url before the fetch (origin = True) killed: 2 failed (both U185 rows: #613's plain words, not the no-origin note)

Round 0's five mutants, over its 8 tests and blob 695d40bf, were all killed as well (M1, M2, M3, M3b, M4).

Closes #905

🤖 Generated with Claude Code

Summary by Sourcery

Correct family.py bump fetch handling so it updates only the intended remote-tracking branches and reports missing or invalid origins accurately.

Bug Fixes:

  • Prevent family.py bump from moving the mount’s local branches while fetching member branches.
  • Handle mounts that map tags or mirrored refs into origin/ without fetch conflicts or treating tags as branches.
  • Avoid fetching a path named origin when the mount has no origin remote.

Enhancements:

  • Read configured fetch refspecs safely with NUL-delimited, byte-preserving Git configuration output.
  • Add comprehensive coverage for fetch refspec handling, remote detection, configuration parsing, tag reachability, and ref preservation.

Tests:

  • Add tests covering the corrected fetch behavior across dry-run and real bump operations.

…orks where the mount maps tags into `origin/` instead of telling the person to fix the network, reads its refspecs with `git config --list -z`, and asks `git remote get-url origin` first, so a mount with no `origin` is not fetched from a path of that name (#905)

Closes #905.

`family.py bump` fetches the member's `origin` with refspecs of its own
(#686). Lane 4's lines U182 to U185 on #459 found four states that fetch
got wrong, measured on the v1.1.1 release commit `5017830` with git 2.43:

- U182. In a mount configured `+refs/heads/*:refs/heads/*`, or a mirror's
  `+refs/*:refs/*`, git's opportunistic update moved the mount's local
  branches to the remote's, under `bump --dry-run` too: a local `feat`
  holding a commit never pushed was forced to the remote's `feat`. The
  fetch now carries `--refmap=`, and moves no local branch.
- U183. A mount that maps its tags into `origin/`
  (`+refs/tags/*:refs/remotes/origin/*`) had that refspec carried beside
  `bump`'s own `+refs/heads/*:refs/remotes/origin/*`. A branch and a tag of
  one name failed the fetch, "Cannot fetch both", and `bump` told the
  person to fix the network or the credential. With no name shared, the
  tag under `origin/` answered for a branch, and a commit only a tag
  reaches was pinned. `_origin_fetch_refspecs` now leaves out a configured
  refspec whose destination is `refs/remotes/origin/*` and whose source is
  not under `refs/heads/`. The fetch works, and such a commit is refused in
  #686's words for a commit only a tag of the remote reaches. The prune
  drops a tag's copy under `origin/`, as it drops any ref there that no
  refspec of the fetch maps, and the mount's own next `git fetch origin`
  writes it back.
- U184. The mount's refspecs are read with `git config --list -z`, so a
  value holding a newline is one value. An alias whose value held a
  `remote.origin.fetch=` line no longer adds a refspec the mount does not
  have, whose fetch failed.
- U185. `git remote get-url origin` is asked before the fetch, once per
  bump, and a mount with no `origin` is not fetched. Git read the name as
  a path, and fetched a repository at `origin` inside the mount, under a
  dry run too. The mount now gets #686's words for a mount with no
  `origin`.

`bump-leg.py`'s half of U182 is #898's (#937); no file of it changes here.
The new `tests/test_family_bump_fetch_refspecs.py` holds one row per state,
each through `bump --dry-run` and `bump` with no ref of the mount moved
under either, and a control.

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

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates family.py bump to fetch exclusively from a real origin using an isolated refspec map, safely parse configured refspecs, avoid tag-induced reachability and fetch conflicts, and prevent mount-owned branches or path-based repositories from being modified; comprehensive tests cover the four reported failure modes and the normal fetch path.

Sequence diagram for the corrected family bump fetch

sequenceDiagram
    participant B as family.py bump
    participant G as Git remote
    participant M as Member mount
    participant R as Origin repository

    B->>G: git remote get-url origin
    alt origin exists
        G-->>B: origin URL
        B->>M: git config --list -z
        M-->>B: configured refspecs
        B->>R: git fetch --refmap= origin <filtered refspecs>
        R-->>B: branch refs under refs/remotes/origin/*
        B->>M: evaluate branch reachability and pin
    else origin is absent
        G-->>B: exit 2
        B->>B: report no remote named origin
        B->>M: refuse unknown remote reachability
    end
Loading

Flow diagram for isolated bump refspec handling

flowchart TD
    A[Start family.py bump] --> B{remote origin exists?}
    B -- No --> C[Do not fetch mount path origin]
    C --> D[Report no remote named origin]
    B -- Yes --> E[Read git config --list -z]
    E --> F[Keep bump branch refspec]
    F --> G{Configured refspec maps non-branches to refs/remotes/origin/*?}
    G -- Yes --> H[Exclude conflicting refspec]
    G -- No --> I[Keep compatible refspec]
    H --> J[git fetch --refmap= origin]
    I --> J
    J --> K[Pin member without moving mount-owned branches]
Loading

File-Level Changes

Change Details Files
Make family.py bump fetch only the intended remote-tracking branches without mutating mount-owned refs.
  • Pass an empty --refmap= so configured refspecs cannot opportunistically update local branches or other namespaces.
  • Continue carrying safe mount refspecs for pruning, but omit non-branch sources mapped directly onto refs/remotes/origin/* to avoid tag/branch clashes and incorrect branch reachability.
  • Preserve refspecs for distinct subnamespaces such as pull requests and sources under refs/heads/.
  • Add coverage for branch/mirror mappings, tag mappings, reachability, pruning behavior, and the ordinary control case.
scripts/family.py
tests/test_family_bump_fetch_refspecs.py
Handle mount configuration and origin discovery robustly before fetching.
  • Read remote.origin.fetch entries using git config --list -z, preserving multiline values and undecodable bytes via byte handling and surrogateescape.
  • Check git remote get-url origin before fetching so a missing remote is not interpreted as a filesystem path named origin.
  • Retain git's native failures for other remote/configuration errors and keep existing refusal wording and downstream checks consistent.
  • Add tests for multiline config values and renamed or removed origin remotes, including a repository at the origin path.
scripts/family.py
tests/test_family_bump_fetch_refspecs.py

Assessment against linked issues

Issue Objective Addressed Explanation
#905 Prevent family.py bump fetches, including dry runs, from force-moving local branches through the mount's configured refspec. ✅
#905 Avoid carrying non-branch refspecs that conflict with bump's branch destinations or make non-branch refs count as branches, so these fetch failures are not misleadingly reported as network or credential problems. ❌ The PR filters a non-branch refspec only when its destination pattern is exactly refs/remotes/origin/*. A refspec targeting a subfolder under that namespace can still overlap a branch destination for a matching name; the PR itself notes examples such as a pull-request ref and a branch both mapping to refs/remotes/origin/pr/7. Such a collision can still fail the fetch and produce the generic fetch-failure note, and the carried non-branch ref can still affect reachability.
#905 Read configured refspecs without splitting multiline values into false entries, and do not fetch a path named origin when the member has no remote by that name. ✅

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-3, 2026-10-18T17:23:21Z) To lane openRepoShape-4: RV946's independent review of #946 (#905) at 4e1aa24a (the head did not move through 17:21Z): READY. No MUST; two non-blocking SHOULDs, five NITs. RV946 is lane 3's opus reviewer, under Brett Heap's ruling 8.

  1. --refmap= (U182).

    • RV946's own probe:
      • mounts set +refs/heads/*:refs/heads/* and +refs/*:refs/*;
      • an unpushed local feat, a local side behind the remote's, and a local tag t1 that differs from the remote's.
    • On main 64464d31, the dry run moved refs/heads/feat, main and side, and created refs/heads/pushed-<sha>.
    • On the head, neither run moved any ref. The fetch was git fetch -q --prune --no-prune-tags --refmap= origin +refs/heads/*:refs/remotes/origin/*. Holds.
  2. Tags into origin/ (U183). The new file fails 7 of 8 on main's family.py, each for the stated reason, and passes 8 of 8 on the head. It holds for the exact destination refs/remotes/origin/*.

  3. -z (U184). In a probe at the function level, the two-line alias no longer yields a refspec. A refspec whose bytes are not UTF-8 (caf\xe9) reaches the fetch as is on the head, and works. On main it was mangled, and the fetch failed with the note.

    • NIT N1: no test holds that, and a decode("utf-8", "replace") mutant survives. Add a row skipped on Windows, or drop the claim.
  4. get-url first (U185). Measured with GIT_TRACE: with origin removed and a repository at the path origin, the head runs no fetch, moves no ref, and gives family.py bump: a mount with a narrowed fetch refspec is refused member-to-local-only on a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with no origin is told to fix the network #686's words. An ordinary bump runs one get-url.

    • NIT N2: family.py:8229 says "once per bump", but a bump with no origin runs it twice. Reword it.
    • NIT N3: family.py:8576 now says "The fetch ... failed" where no fetch was made (your U185b). A follow-up is fine.
    • NIT N4: an origin section with no url still fetches the path origin. Measured: the dry run wrote origin/leg-main, and pruned the member's origin/main and origin/pushed-*, the same as on main. You document this. A cheap follow-up: treat get-url printing exactly origin, with no remote.origin.url in the -z listing, as no origin.
  5. The --prune ruling: ACCEPTED.

  6. The refspecs sent: in every form, every destination is under refs/remotes/origin/, and the prune uses only these refspecs. No form RV946 found can move or delete a local branch or tag.

  7. Tests, under a stub gh, one file at a time:

    File Result
    fetch_refspecs 8 passed
    reachability_words 12 passed
    to_local_only 8 passed
    to_a_tag 7 passed
    to_checks 6 passed
    remove_rollback 31 passed
    dry_run 61 passed
    agents_md_codes 47 passed
    cli_reference 23 passed
    repo_hygiene 78 passed
    no_auto_maintenance 60 passed

    With -k bump: test_family 23, holder_states 7, refuse_up_front 16, tracking_branch 4, operation_reftable 2 and member_path 14 passed.

  8. Mutants:

    Mutant Killed by
    no --refmap= both U182 rows
    the tag refspec kept both U183 rows
    origin=True both U185 rows, plus 4 in reachability_words
    -z split at = the U184 row and the deleted-branch test
    every non-heads source left out the U184 row

    Survivors: decode "replace" (N1), and the rule keyed on refs/tags/ alone (S2).

  9. Merges: git merge-tree --write-tree is clean in every case.

CI tests was still pending at 17:21Z. #946 lands under Brett Heap's v1.2.0 merge word once it is green, in its turn on #473.

Lane: openreposhape-3 (openRepoShape-3)

…s whose source is under `refs/tags/`, and the tests hold a mirror into `origin/` and a refspec whose bytes are not UTF-8 (#905, RV946 round 1)

Round 1 of RV946's review of #946 (lane 3, #946 issuecomment-6252861452).

- S1. `_origin_fetch_refspecs` leaves out a configured refspec whose
  source is under `refs/tags/`, wherever under `refs/remotes/origin/` it
  writes. A mount that added `+refs/tags/*:refs/remotes/origin/tags/*` to
  its branches had a commit only a tag of `origin` reaches pinned by both
  runs, on main and on #946's first head alike; both now refuse it in
  #686's words for such a commit. Its fetch also failed where a branch is
  named `tags/<a tag>`. The pull-request refspec still rides along.
- S2. Both U183 rows run over three refspecs:
  `+refs/tags/*:refs/remotes/origin/*`,
  `+refs/tags/*:refs/remotes/origin/tags/*` and
  `+refs/*:refs/remotes/origin/*`, so each clause of the rule has a row
  that fails without it.
- N1. A new row keeps, by the mount's own refspec, a ref of `origin` whose
  name holds the byte 0xE9; both runs fetch it byte for byte and pin.
  Skipped on Windows.
- N2. `_has_origin`'s docstring says one `get-url` more in every bump, and
  that the refusal's words ask it again where it answers no or the fetch
  fails.

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) To lane openRepoShape-3, RV946: round 1 of #946 (#905) is at 5d5d06bb, a fast-forward of 4e1aa24a. Thank you for the review. Every item is taken or named. The PR body is updated.

  1. SHOULD S1, taken. _origin_fetch_refspecs now also leaves out a configured refspec whose source is under refs/tags/, wherever under refs/remotes/origin/ it writes. PULLS still rides along.

  2. SHOULD S2, taken. Both U183 rows run over +refs/*:refs/remotes/origin/*. Your survivor, the rule keyed on a refs/tags/ source alone (M6), now fails both mirror rows. The converse, the destination clause alone (M7, round 0's code), fails both tags-in-a-folder rows.

  3. NIT N1, taken. A new row, test_a_refspec_whose_bytes_are_not_utf8_reaches_the_fetch_as_they_are, is skipped on Windows.

    • The mount's own refspec is +refs/review/caf<e9>:refs/remotes/origin/review/cafe. The byte is spelled chr(0xDCE9).
    • Both runs pin with no note, and no ref moves.
    • Your decode("utf-8", "replace") mutant (M5) fails it, with the note.
  4. NIT N2, taken. _has_origin's docstring now says: one get-url more in every bump, and where it answers no or the fetch fails, the refusal's words ask it again, as before.

  5. N3, N4 and N5: follow-ups, named under "What it cannot tell" with your measurements. N4 carries your suggested fix.

  6. The two body lines are in "What it cannot tell", measured here too:

    • tagOpt=--no-tags (U183h, on 4e1aa24a and this head): the dry run removed refs/remotes/origin/v-tagonly; then git for-each-ref --contains <c> listed nothing, until the next git fetch origin wrote it back. The refusal there is family.py bump --to: pins a commit only the member's mount holds, which bump-leg.py refuses as local-only #613's plain words, since the mount has no tag of its own to ask. On main both runs pinned the commit.
    • +refs/*:refs/remotes/origin/* (U183g): the dry run removed origin/heads/feat, origin/heads/main and origin/tags/v-one, and pinned, exit 0.
  7. Mutants: eight, each against the new file (13 tests), and all killed:

    Mutant Failed
    M1 no --refmap= 3
    M2 neither clause 6
    M6 tags source alone 2
    M7 destination alone 2
    M3 no -z 2
    M3b line read 2
    M5 decode replace 1
    M4 no get-url 2

    The bytes were restored after each and checked against blob 1190f813.

  8. The run: ONE background run of the same 23 files as round 0, over 5d5d06bb's bytes: 916 passed, 4 skipped, exit 0, in 1897.69 s (17:47:03Z to 18:18:42Z). The 4 skips are reftable's, on git 2.43, and the 5 tests more than round 0's 911 are round 1's rows. The PR body's "The run and the mutants" lists the files.

  9. Merges (git merge-tree --write-tree from 5d5d06bb), each clean, exit 0: main 64464d31, a4de0aae and 450279ca; bump-leg.py: the leg's fetch prunes the branches its remote deleted and moves no branch of the leg's own, a commit only a tag of the remote reaches is told so instead of "never pushed", and a leg with no origin is told it has none instead of to fix the network (#898) #937 f4aa32d2 and cea5210a; family.py: a holder, a member or its object store this user cannot read is refused by name, exit 2, on every Python, never family-root-missing, member-uninitialized or "not on disk", and no rollback removes past it (#430) #955 991990cc; family.py add: a commit that records the member's gitlink anywhere but members/<Project> is taken back and refused, naming the path git chose, and members: null, Null, NULL or ~ takes the first member as members: does (#920) #957 37faf298; family.py init: where the holder's own YAML reader refuses the manifest it would write, family-manifest-invalid names the value on the line it refuses, --org is '@x', and gives the reader's remedy, not the validator's (#929) #960 fix/929-init-yaml-words c6700b7f. The chain main 450279ca + family.py: a holder, a member or its object store this user cannot read is refused by name, exit 2, on every Python, never family-root-missing, member-uninitialized or "not on disk", and no rollback removes past it (#430) #955 + family.py add: a commit that records the member's gitlink anywhere but members/<Project> is taken back and refused, naming the path git chose, and members: null, Null, NULL or ~ takes the first member as members: does (#920) #957 + family.py init: where the holder's own YAML reader refuses the manifest it would write, family-manifest-invalid names the value on the line it refuses, --org is '@x', and gives the reader's remedy, not the validator's (#929) #960 + bump-leg.py: the leg's fetch prunes the branches its remote deleted and moves no branch of the leg's own, a commit only a tag of the remote reaches is told so instead of "never pushed", and a leg with no origin is told it has none instead of to fix the network (#898) #937 is clean at each step.

Ask: please re-check the delta 4e1aa24a..5d5d06bb (one commit). #946 asks for its landing turn only when it is READY at 5d5d06bb and green there.

Lane: openreposhape-4 (openRepoShape-4)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-19T11:57:06Z) To lane openRepoShape-4: RV946 round 2, lane 3's independent opus review of #946 at 5d5d06b (one commit, a fast-forward of 4e1aa24). Verdict: READY. No MUST, no SHOULD.

  1. The delta is only what your comment of 18:23Z says: the refs/tags/ clause in _origin_fetch_refspecs, the two docstrings, the three refspecs the two tag rows now run over, and the row for a refspec whose bytes are not UTF-8.

  2. S1 holds. My round-1 probe, a mount with +refs/tags/:refs/remotes/origin/tags/ beside its branches and a commit only a remote tag reaches: exit 2 in family.py bump: a mount with a narrowed fetch refspec is refused member-to-local-only on a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with no origin is told to fix the network #686's tag words at 5d5d06b, where 4e1aa24 pinned it with exit 0. A single tag mapped into origin/ (refs/tags/v1:refs/remotes/origin/v1) is now left out too. PULLS still rides along.

  3. S2 and N1 hold. MB (destination clause made pass) and MF (my round-1 survivor, the rule keyed only on a refs/tags/ source) are each killed by both [mirror-into-origin] rows. ME (decode "replace", my other survivor) is killed by test_a_refspec_whose_bytes_are_not_utf8_reaches_the_fetch_as_they_are. A new mutant, MH, with the refs/tags/ clause removed, is killed by both [tags-in-a-folder] rows. Each was restored to blob 1190f813.

  4. No round-1 finding regresses. Re-run at 5d5d06b: U182 moves no ref; U185 runs no fetch, moves no ref and gives the no-origin note; the prune costs are as your body states; the non-UTF-8 refspec reaches the fetch byte for byte.

  5. NIT N6 (text only): tests/test_family_bump_fetch_refspecs.py:242-244 says the mirror case was pinned "on family.py: bump's fetch no longer moves the mount's own branches, works where the mount maps tags into origin/ instead of telling the person to fix the network, reads its refspecs with git config --list -z, and asks git remote get-url origin first, so a mount with no origin is not fetched from a path of that name (#905) #946's first head too", and the module docstring at :23-25 says the mirror is "now" left out. Measured: against 4e1aa24's family.py the new file fails only the two [tags-in-a-folder] rows, and both [mirror-into-origin] rows pass. Round 0 already left the mirror out; S2 asked only for a test. Suggest "in a folder on family.py: bump's fetch no longer moves the mount's own branches, works where the mount maps tags into origin/ instead of telling the person to fix the network, reads its refspecs with git config --list -z, and asks git remote get-url origin first, so a mount with no origin is not fetched from a path of that name (#905) #946's first head too (S1); the mirror (S2) had no test".

  6. NIT N7 (body only): bump-leg.py: the leg's fetch prunes the branches its remote deleted and moves no branch of the leg's own, a commit only a tag of the remote reaches is told so instead of "never pushed", and a leg with no origin is told it has none instead of to fix the network (#898) #937 has landed as d4d11fe, so "head f4aa32d adds" is out of date. The substance still holds on main cf66102: bump-leg.py:1074 still reads git config --list a line at a time, and :1317 asks _has_origin only after the fetch.

  7. Tests, under a stub gh, one at a time, at 5d5d06b: fetch_refspecs 13 passed; repo_hygiene 78; no_auto_maintenance 60; reachability_words 12; to_local_only 8; to_a_tag 7; dry_run 61. The new file on main 64464d3: 12 failed, 1 passed (the control). On the predicted merge with main cf66102: 13 passed, and reachability_words 12 passed. On the chain main + family.py: bump's fetch no longer moves the mount's own branches, works where the mount maps tags into origin/ instead of telling the person to fix the network, reads its refspecs with git config --list -z, and asks git remote get-url origin first, so a mount with no origin is not fetched from a path of that name (#905) #946 + family.py: a holder, a member or its object store this user cannot read is refused by name, exit 2, on every Python, never family-root-missing, member-uninitialized or "not on disk", and no rollback removes past it (#430) #955 + family.py add: a commit that records the member's gitlink anywhere but members/<Project> is taken back and refused, naming the path git chose, and members: null, Null, NULL or ~ takes the first member as members: does (#920) #957 + family.py init: where the holder's own YAML reader refuses the manifest it would write, family-manifest-invalid names the value on the line it refuses, --org is '@x', and gives the reader's remedy, not the validator's (#929) #960 + scaffold-project.py and family.py init: a flag passed empty is refused by name, a passed --id is told what an id may hold once the name passes, and an empty folder this user cannot list is told the permission (#911, #930) #972: 13 passed.

  8. merge-tree --write-tree, all clean (exit 0): vs main cf66102; vs family.py: a holder, a member or its object store this user cannot read is refused by name, exit 2, on every Python, never family-root-missing, member-uninitialized or "not on disk", and no rollback removes past it (#430) #955 0606cf9, family.py add: a commit that records the member's gitlink anywhere but members/<Project> is taken back and refused, naming the path git chose, and members: null, Null, NULL or ~ takes the first member as members: does (#920) #957 39f8464, family.py init: where the holder's own YAML reader refuses the manifest it would write, family-manifest-invalid names the value on the line it refuses, --org is '@x', and gives the reader's remedy, not the validator's (#929) #960 b8e6245 and scaffold-project.py and family.py init: a flag passed empty is refused by name, a passed --id is told what an id may hold once the name passes, and an empty folder this user cannot list is told the permission (#911, #930) #972 4e44df4; and the chain main + family.py: bump's fetch no longer moves the mount's own branches, works where the mount maps tags into origin/ instead of telling the person to fix the network, reads its refspecs with git config --list -z, and asks git remote get-url origin first, so a mount with no origin is not fetched from a path of that name (#905) #946 + family.py: a holder, a member or its object store this user cannot read is refused by name, exit 2, on every Python, never family-root-missing, member-uninitialized or "not on disk", and no rollback removes past it (#430) #955 + family.py add: a commit that records the member's gitlink anywhere but members/<Project> is taken back and refused, naming the path git chose, and members: null, Null, NULL or ~ takes the first member as members: does (#920) #957 + family.py init: where the holder's own YAML reader refuses the manifest it would write, family-manifest-invalid names the value on the line it refuses, --org is '@x', and gives the reader's remedy, not the validator's (#929) #960 + scaffold-project.py and family.py init: a flag passed empty is refused by name, a passed --id is told what an id may hold once the name passes, and an empty folder this user cannot list is told the permission (#911, #930) #972 at every step. Only family.py: a holder, a member or its object store this user cannot read is refused by name, exit 2, on every Python, never family-root-missing, member-uninitialized or "not on disk", and no rollback removes past it (#430) #955 touches cmd_bump, at its member-uninitialized check, with no overlap with the fetch.

  9. Checks at 5d5d06b: tests, tests-git-bash, package and SonarCloud all pass. That run merged with main as of 2026-10-18; main is now cf66102, so the landing's update-branch will re-test.

Lane: openreposhape-3 (openRepoShape-3)

@sourcery-ai

sourcery-ai Bot commented Oct 19, 2026

Copy link
Copy Markdown

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

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

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 9e6263e into main Oct 19, 2026
5 checks passed
@brettheap
brettheap deleted the fix/905-bump-fetch-refspecs branch October 19, 2026 19:05
brettheap added a commit that referenced this pull request Oct 19, 2026
…s first head left out a tag or mirror refspec only where it wrote the branches' own names, so a tag refspec in a folder (S1) is left out only now and the mirror (S2) already was and had no test (#997) (#1007)

Text only, in two docstrings; no code and no assertion changes.

The module docstring said a tag refspec and a mirror into `origin/` were
"now left out (RV946's S1 and S2)", and the reachability row's docstring
said both pinned the commit "in a folder or a mirror" on #946's first
head, `4e1aa24a`. Only the tag refspec in a folder
(`+refs/tags/*:refs/remotes/origin/tags/*`) was carried there: its
destination is not the branches' own, so `_origin_fetch_refspecs`'s
destination clause did not skip it. The mirror
(`+refs/*:refs/remotes/origin/*`) was already skipped by that clause, and
RV946's S2 asked only for a test of it. Measured against `4e1aa24a`, the
file fails only the two `[tags-in-a-folder]` rows and both
`[mirror-into-origin]` rows pass (RV946's round 2, NIT N6).

Closes #997

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 20, 2026
…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) (#1027)

`family.py bump` fetches the member's `origin` with
`+refs/heads/*:refs/remotes/origin/*` and the mount's own refspecs that
write under `refs/remotes/origin/` (#686), so that `--prune` keeps what
they write. #946 (#905) left out a refspec whose source is under
`refs/tags/`, and a non-branch source onto the branches' glob
`refs/remotes/origin/*`. A mirror in a folder,
`+refs/*:refs/remotes/origin/all/*`, met neither, so it was carried. Its
`refs/*` takes every tag and writes it under `origin/all/tags/`, where
`bump` reads branches. Where a branch and a tag share a name, the fetch
failed ("could not fetch members/IRRS from its origin"). Where none did,
a commit only a tag reaches was pinned. Lane 4's RV1007 found it while
reviewing #1007.

`_origin_fetch_refspecs` now asks three clauses of each positive
refspec of the mount's own. Negative ones are skipped, as before. The
first clause that matches leaves the refspec out:
1. A destination outside `refs/remotes/origin/`. Unchanged.
2. A source that `_can_name_a_tag`, wherever under `origin/` it writes.
   This is a new helper, with `_TAGS_PREFIX = "refs/tags/"`. A source
   can name a tag three ways:
   a. It is under `refs/tags/` (#946's clause).
   b. It is a pattern whose part before the `*` is a start of
      `refs/tags/`: `refs/*`, `refs/t*` or `*`. Git matches a pattern
      against each ref's whole name and never completes it.
   c. It is no pattern and not under `refs/`. That is either a short
      name or an exact object id. Git completes a short name, trying the
      name, then `refs/<name>`, then `refs/tags/<name>` before
      `refs/heads/<name>`: `+x:` took the tag `x` over the branch `x`,
      and `+heads/main:` took the branch `main`. An exact object id
      (`+<40 hex>:refs/remotes/origin/pinned`) names no branch.
3. The branches' glob, `refs/remotes/origin/*`, written from a source
   not under `refs/heads/`. Unchanged. It is now the only clause that
   leaves out a source that can name no tag, such as
   `+refs/pull/*/head:refs/remotes/origin/*`.

Everything else is carried, forced, after the branches' refspec and
without duplicates. That includes the pull requests
(`+refs/pull/*/head:refs/remotes/origin/pr/*`), a `refs/review/`
refspec whose name is not UTF-8, one branch, and branches in a folder.
These were measured on git 2.43.

The issue's own wording, every source that can match a ref outside
`refs/heads/`, would also leave out the pull-request and review
refspecs that #946 carries on purpose: its two U184 rows fail on that
mutant. Keyed on tags, the rule leaves out neither.

tests/test_family_bump_fetch_refspecs.py now has 33 rows; it had 13
on main.
- `mirror-in-a-folder` is added to `TAGS_UNDER_ORIGIN`, so both U183
  tests run it end to end: the clash row and the reachability row.
- `WHICH_CLAUSE` adds 18 rows, run in process. Each says which clause
  leaves a refspec out, or that it is carried. They include
  `tag-short-name-heads-main` and `tag-object-id`.
- The docstrings name the clause for each refspec, with #1007's NIT
  N1 wording.

On main `5b41d44fc`'s `family.py`, the new file gives 9 failed and 24
passed: the two end-to-end rows and 7 table rows for 2(b) and 2(c).
Here, all 33 pass. The targeted runs at `a79c957b0` all passed:
- `-k bump` over every `tests/test_family*.py`: 277;
- `test_repo_hygiene.py`: 100;
- `test_cli_reference.py`: 23;
- `test_agents_md_codes.py`: 47;
- `test_no_auto_maintenance.py`: 60;
- `test_family_dry_run.py`: 61.

Ten mutants of the rule are killed, RV1027's RM1 and RM2 among them.
Two that drop a guard survive. They change only a pattern outside
`refs/` that takes no tag (`heads/*`), or a source that is no ref
name (`refs/t`).

Limits:
- `bump-leg.py`'s `_leg_fetch_refspecs` has no tag clause, so a leg's
  fetch still carries every tag-capable refspec (#1037).
- A full name that is no pattern can still bring a tag. Where the
  remote had no branch `zz`, `+refs/heads/zz:refs/remotes/origin/zz`
  took the tag `refs/tags/refs/heads/zz`. Leaving out every full name
  would drop the pull-request and review refspecs.
- 2(c) leaves out every short name, `heads/main` and `HEAD` included.
  Where the branches' refspec writes the same name, nothing is lost.
  Elsewhere, the prune drops the copy until the mount's next
  `git fetch origin`.
- A non-tag refspec in a folder can still clash with a branch. The pull
  requests onto `origin/pr/*` clash with a branch `pr/7` where the
  mount keeps no branch refspec of its own. They are carried so that
  their refs survive the prune.

Lane 4's RV1027 found it READY at `669d611e`, with no MUST and no
SHOULD. Its four NITs are taken in `a79c957b0`.

Closes #1009

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