Repository navigation
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
Conversation
…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>
Reviewer's GuideUpdates Sequence diagram for the corrected family bump fetchsequenceDiagram
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
Flow diagram for isolated bump refspec handlingflowchart 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]
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(lane openRepoShape-3, 2026-10-18T17:23:21Z) To lane openRepoShape-4: RV946's independent review of #946 (#905) at
CI 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>
|
(lane openRepoShape-4) To lane openRepoShape-3, RV946: round 1 of #946 (#905) is at
Ask: please re-check the delta Lane: openreposhape-4 (openRepoShape-4) |
|
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 |
|
…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>
…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>
…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>
…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>
… 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>



Lane: openreposhape-4 (openRepoShape-4)
Claim: #905 (comment)
What changed and why
family.py bumpfetches the member'soriginwith refspecs of its own since #686: every branch intorefs/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 inbump's fetch:_origin_fetch_refspecs,_has_origin, the fetch incmd_bump, and two docstring sentences that described the old order. No flag,--helpline, refusal code or doc page changes.Each state was measured with real
bump --dry-runandbumpruns, Before on main50178305(the v1.1.1 release commit,scripts/family.pyblobe8ed8bd6) and Now on round 0's head4e1aa24a(blob695d40bf), git 2.43. Round 1 changes only what is left out of a refspec whose source is underrefs/tags/, measured below. Holders were built withscaffold-project.py,family.py initandaddinto local bare repositories. Each mount was pointed at a private bare remote. Each run listed the mount's refs withfor-each-refbefore and after. The probes ran with aghthat exits 97 first onPATH,GH_TOKEN=bogus, an emptyGH_CONFIG_DIR, an emptyGIT_CONFIG_GLOBAL,GIT_CONFIG_NOSYSTEM=1,GIT_SSH_COMMAND=falseand noGIT_ALLOW_PROTOCOL.Round 1 (head
5d5d06bb, a fast-forward of4e1aa24a) takes lane 3's RV946 review (issuecomment-6252861452: READY, no MUST):refs/tags/is left out wherever underorigin/it writes.origin/too._has_origin's docstring no longer says "once per bump".50178305and on round 0's head4e1aa24a(blob695d40bf); the Now lines on this head (blob1190f813).--refmap=(U182)The mount is configured
+refs/heads/*:refs/heads/*. Its localfeatholds a commit never pushed, on top of the remote'sfeat, and--tois the remote'sfeat.Before:
bump --dry-runprinted the row, exit 0, and moved two local branches:refs/heads/feat 7940eb06 -> e8804a29: the unpushed commit was left on no branch.refs/heads/main e8804a29 -> c93bfa63.With
featput back, the real run pinned the commit and movedfeatagain. With refspecs on the command line, git still applies an opportunistic update through the configuredremote.origin.fetch.Now: the fetch is
git fetch -q --prune --no-prune-tags --refmap= origin <refspecs>. Both runs pin, exit 0, andrefs moved: noneunder each. The localfeatstays at its unpushed commit. The refspecs named on the command line are the whole map. The mount's own refspecs that write underorigin/are named there already (family.pybump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis told to fix the network #686), so nothing they kept is lost.A tags-into-
origin/refspec (U183)The mount's only refspec is
+refs/tags/*:refs/remotes/origin/*. The remote has a branchfeatholding--toand a tagfeatat the pin.git fetch --dry-run originexits 0. The same fetch with the refspecsbumpbuilt exits 128:fatal: Cannot fetch both refs/heads/feat and refs/tags/feat to refs/remotes/origin/feat.note: could not fetch members/IRRS from its origin, thenREFUSED member-to-local-only: ... is reachable from no branch of InkRouter/IRRS, endingfix the network or the credential and run it again before believing it.refs/remotes/origin/feat(the branch) andrefs/tags/feat(git's tag following, as any fetch does).v-releasereaches.refs/remotes/origin/v-release, answered for a branch:_on_a_remote_branchreads every ref underrefs/remotes/origin/.REFUSED member-to-local-only: --to <c> is in the member's own object store, in members/IRRS, and a tag of InkRouter/IRRS reaches it, but no branch of InkRouter/IRRS does. That is family.pybump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis told to fix the network #686's p03 words. The rule (family.pybump --to: pins a commit only the member's mount holds, whichbump-leg.pyrefuses as local-only #613) is branches.+refs/tags/*:refs/remotes/origin/tags/*to its branches, and its owngit fetch originwroterefs/remotes/origin/tags/v-release.--tois a commit only the remote's tagv-releasereaches, made in a clone of the remote.50178305and on round 0's head4e1aa24aalike: both runs pinned it, exit 0.bump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis told to fix the network #686's tag words. The dry run's prune dropsrefs/remotes/origin/tags/v-release._origin_fetch_refspecsleaves out a configured refspec whose destination is underrefs/remotes/origin/and either:refs/tags/, wherever underorigin/it writes (round 1, S1):+refs/tags/*:refs/remotes/origin/*,+refs/tags/*:refs/remotes/origin/tags/*; orbump's ownrefs/remotes/origin/*and its source is not underrefs/heads/: a mirror of every ref intoorigin/,+refs/*:refs/remotes/origin/*.origin/still rides along, as family.pybump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis told to fix the network #686 decided for pull requests (+refs/pull/*/head:refs/remotes/origin/pr/*). So does one whose source is underrefs/heads/.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.--towas pushed to a branch since the mount last fetched.git config --listprints that line as a line of its own.git config --get-all remote.origin.fetchdoes not list it.bumpput+refs/heads/zz:refs/remotes/origin/zzon the fetch's command line, and the fetch of a branchoriginlacks failed. Both runs printednote: could not fetch members/IRRS from its origin.The fetch of members/IRRS failed, as the note says, and both pinned from the refs the push had left.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, whichtest_a_dry_run_runs_no_git_command_that_writesasks of everygit configin a dry run._git_bytes) and decoded withsurrogateescape, so a refspec git can read and UTF-8 cannot reaches the command line as its own bytes.rundecoded it withreplace. RV946 measured that main mangled acaf\xe9refspec 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 originexits 2,No such remote). A repository is at the pathorigininside the mount, with one branchleg-main.--towas pushed to a branch of the member's remote.originas that path. The fetch succeeded against the leg's repository and wroterefs/remotes/origin/leg-main b9d6bebdunderbump --dry-run.bump --to: pins a commit only the member's mount holds, whichbump-leg.pyrefuses as local-only #613's plain words, exit 2, with no note:reachable from no branch of InkRouter/IRRS ... A pin the rest of the world cannot fetch is a family the rest of the world cannot bootstrap._has_origin(git remote get-url origin) is asked before the fetch, once per bump. The fetch runs only where it answers yes.bump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis told to fix the network #686's words for a mount with noorigin:note: could not fetch members/IRRS: it has no remote named `origin`; ..., thenREFUSED member-to-local-only: ... but members/IRRS has no remote named `origin`, so whether a branch of InkRouter/IRRS reaches it is not known.refs moved: none.origingit cannot read still gets git's own failure,git remote get-url origin, exit 128, quoted. family.py:bumpfetches every branch of the member'soriginwhatever the mount's refspec and drops theorigin/refs no ref oforiginbacks, says a tag of the remote reaches a commit no branch does instead of "a pin the rest of the world cannot fetch", and tells a mount with nooriginit has none instead of to fix the network (#686) #795'stest_a_mount_config_git_cannot_read_is_gits_own_failureholds that, unchanged._has_origin. Oneget-urlis added to every bump, before the fetch.Control
An ordinary mount (
+refs/heads/*:refs/remotes/origin/*, asaddleaves it), with--topushed since its last fetch. Before and Now alike: the dry run addsrefs/remotes/origin/featand moves nothing else, exit 0, and the real run pins.Tests
New file
tests/test_family_bump_fetch_refspecs.py, 13 tests. Every row runsbump --dry-runandbumpover one holder, and compares the mount'sfor-each-refbefore and after each.bump's own fetch leaves them (fetched_as_bump:--prune --no-prune-tags --refmap= origin +refs/heads/*:refs/remotes/origin/*plus the refspecs a row names). So the fetch has nothing to bring, and "no ref of the mount moved" is a statement about what the fetch must not move.+refs/heads/*:refs/heads/*and+refs/*:refs/*: both runs pin with nothing on stderr, no ref moves, the localfeatkeeps its unpushed commit, and the refspec is as the person set it.+refs/tags/*:refs/remotes/origin/*,+refs/tags/*:refs/remotes/origin/tags/*and+refs/*:refs/remotes/origin/*(round 1, S1 and S2):v-shared, ortags/v-shared;where_the_tag_lands). Premises: the person's owngit fetch --dry-run originexits 0, and a fetch carrying both refspecs exits 128 withCannot fetch both ... to refs/remotes/origin/<branch>. Both runs pin with nothing on stderr, and no ref moves.bump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis told to fix the network #686's tag words (both_runs: the same two lines, exit 2, nothing in the holder moved), and no ref moves.origin/pr/7, which the remote has. Premises: a listing read a line at a time shows the line,--get-alldoes not, and the remote has nozz. Both runs pin with nothing on stderr, no ref moves, and soorigin/pr/7is still read and kept.+refs/review/caf<e9>:refs/remotes/origin/review/cafe. The byte0xE9is spelledchr(0xDCE9), the lone surrogateos.fsdecodekeeps it as. Premise:git config --get-allholds the byte. Both runs pin with nothing on stderr, and no ref moves.originrenamed and removed, with a repository atorigin. Premise:git ls-remote originlistsleg-main. Both runs give the no-originnote and refusal (told_no_origin,no_originfrom family.py:bumpfetches every branch of the member'soriginwhatever the mount's refspec and drops theorigin/refs no ref oforiginbacks, says a tag of the remote reaches a commit no branch does instead of "a pin the rest of the world cannot fetch", and tells a mount with nooriginit has none instead of to fix the network (#686) #795), and no ref moves, none underrefs/remotes/origin/.familyandholderfixtures aretests/test_family.py's andofflineandrefusalaretest_family_add_refusals.py's. family.py:bumpfetches every branch of the member'soriginwhatever the mount's refspec and drops theorigin/refs no ref oforiginbacks, says a tag of the remote reaches a commit no branch does instead of "a pin the rest of the world cannot fetch", and tells a mount with nooriginit has none instead of to fix the network (#686) #795's helpers are imported fromtest_family_bump_reachability_words.py(private_remote,private_origin,contains,refs,PULLS,without_origin,told_no_origin,no_origin), withboth_runs,annotated,local_only,made_in_the_mount,pins,bump,commit_in_mount,isolatedandrawfrom their own modules.What it cannot tell
origin/is pruned (U183's cost). In a mount that maps tags intoorigin/, the person's owngit fetch originwritesrefs/remotes/origin/<tag>.bump's fetch no longer names that refspec, so its--prunedrops each such ref no branch of that name backs, in the dry run too, as it drops any ref underorigin/no refspec of the fetch maps (family.pybump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis told to fix the network #686). The mount's nextgit fetch originwrites them back.refs/remotes/origin/v-only. The local tagrefs/tags/v-onlystays, and the dry run prints the row, exit 0.refs/remotes/origin/v-release, which is the ref that answered for a branch.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, on4e1aa24aand this head).origin/and follows none. Its owngit fetch originwroterefs/remotes/origin/v-tagonlyand norefs/tags/v-tagonly.refs/remotes/origin/v-tagonly; thengit for-each-ref --contains <c>listed nothing. The person's nextgit fetch originwrote it back._origin_tag_reachesfinds none, so the refusal is family.pybump --to: pins a commit only the member's mount holds, whichbump-leg.pyrefuses 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.+refs/*:refs/remotes/origin/*mount loses its whole mirror to the dry run (RV946; measured U183g). After the mount's owngit fetch origin,bump --dry-runpinned the remote'sfeat, exit 0, and removedrefs/remotes/origin/heads/feat,refs/remotes/origin/heads/mainandrefs/remotes/origin/tags/v-one.refs/remotes/origin/featandmainstayed, written bybump's own refspec.origin/, the dry run removed onlyrefs/remotes/origin/v-onlyandv-tagonly, leftrefs/heads/*andrefs/tags/*alone, and the nextgit fetch originrestored them exactly, with tag following on and withtagOpt=--no-tags. Left as a follow-up.refs/heads/andrefs/tags/whose destination is a folder underorigin/still rides along. It clashes withbump's own where a branch has the same name: a branchpr/7beside pull request 7. The fetch then exits 128 and the note blames the fetch, as before. Its refs underorigin/also still count for reachability, as family.pybump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis 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.)originwith nourl(RV946; a follow-up). A[remote "origin"]section with afetchline and nourlis a remote to git:git remote get-url originprintsorigin, exit 0, and a fetch reads the pathorigin(measured, git 2.43, in a scratch repository).bumpfetches it, as the person's owngit fetch origindoes, and the docstring says so.origin/leg-mainand pruned the member'sorigin/mainandorigin/pushed-*, the same as on main.get-urlthat prints exactlyorigin, with noremote.origin.urlin the-zlisting, as noorigin.origin(RV946; a follow-up). Measured on round 0's head (U185b), and the code is unchanged since: withremote.originremoved bygit config --remove-section, the mount keeps refs underorigin/. Where they hold--to, the dry run prints the row and saysThe fetch of members/IRRS failed, as the note says, under the notecould 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 pathoriginthat failed. The sentence is_bump_dry_run's, held bytest_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 nooriginis 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 nooriginis told to fix the network, and the fetch does not prune: the three cases #795 fixed in family.py #898) has landed asd4d11bfeand added--refmap=to the leg's fetch, which is U182's other half. On main (c7a3ff16),scripts/bump-leg.py:1074still readsgit config --lista line at a time, its_leg_fetch_refspecsstill carries a tags-into-origin/refspec, and:1317asks_has_originonly after a failed fetch (RV946 round 2, N7). Those are U184's, U183's and U185's twins inbump-leg.py, which this PR does not touch.Choices for the reviewer
bump's reachability answers stay right without it._on_a_remote_branchreads branches, so with no name shared a commit only a tag reaches was pinned (U183b, Before), against family.pybump --to: pins a commit only the member's mount holds, whichbump-leg.pyrefuses as local-only #613's rule. Left out, that commit gets family.pybump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis told to fix the network #686's tag words.origin/. Such a person would be told only why it failed.origin/, above. It is the same prune family.pybump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis told to fix the network #686 makes of any ref underorigin/no refspec of the fetch maps. It touches no local branch or tag, and the person's next fetch restores it. If the reviewer rules thatbump --dry-runmust not drop those refs either, the other way is the note, and the reachability half stays as Before.refs/tags/, wherever underorigin/it writes. A tag is not a branch, and underorigin/it answers for one (RV946's S1, U183f).refs/remotes/origin/*, the namesbumpgives branches, with a source outsiderefs/heads/: a mirror intoorigin/, which puts tags inorigin/tags/(S2).origin/from outsiderefs/heads/) would also drop family.pybump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis told to fix the network #686's pull-request refspec, whose refs family.py:bumpfetches every branch of the member'soriginwhatever the mount's refspec and drops theorigin/refs no ref oforiginbacks, says a tag of the remote reaches a commit no branch does instead of "a pin the rest of the world cannot fetch", and tells a mount with nooriginit has none instead of to fix the network (#686) #795's F1 review asked to keep. RV946 measured that rule's mutant failing only the U184 row, whoseorigin/pr/7it prunes._checked_on_a_remote_branchand_checked_to_names_a_commitstill ask_has_originwhen the fetch was not made. Passing the first answer down would change both signatures. W430G (EveryPath.is_file()/is_dir()/exists()guard in the shipped tools answers False to any OSError on Python 3.14 (3.12 and 3.13 raise PermissionError), so a directory this user cannot search reads as ABSENT: validate-pins.py skips its workflow@<sha>check, update-shape.py sayscopy-missingandupdate-pin-missing("adopt the project") for files that are there, the doctor says DRIFTED (5 copy-missing), and bump-leg.py's remaining guards, and every other tool's, skip or misreport instead of refusing by name #430 slice G) changesbumpnearby, so that is left as it was. The issue's "one git call per bump" is read as the cost of the change: oneget-urlis added to every bump.origingets family.pybump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis told to fix the network #686's no-originnote and refusal, which a mount with nooriginand no repository at that path already got. The refusal code staysmember-to-local-only, family.pybump --to: pins a commit only the member's mount holds, whichbump-leg.pyrefuses as local-only #613's.tests/test_family_bump_reachability_words.pyis family.pybump: a mount with a narrowed fetch refspec is refusedmember-to-local-onlyon a false detail, a commit only a tag holds is told "a pin the rest of the world cannot fetch", a mount with nooriginis told to fix the network #686's, and holds its four states. family.pybump: its fetch can force-move a local branch through the mount's own refspec, fails on a tags-into-origin/refspec with a network note, splitsgit config --listvalues, and fetches a path namedorigin#905's four sit beside it and import its helpers.Shared files
scripts/family.pyis not a pinned copy: it is not inFAMILY_COPIED_VERBATIM(scripts/shape_materialize.py:531-553).scripts/bump-leg.pyis not touched.git merge-tree --write-treefrom this head5d5d06bb:64464d31450279cafix/898-bump-leg-reachability(lane 1)cea5210a(andf4aa32d2before it)fix/430-g-family-py-unreadable(lane 4, W430G)991990ccfix/920-add-gitlink-and-null-members(lane 4, W920)37faf298fix/929-init-yaml-words(lane 4, W920)c6700b7f450279ca, then with family.py: a holder, a member or its object store this user cannot read is refused by name, exit 2, on every Python, neverfamily-root-missing,member-uninitializedor "not on disk", and no rollback removes past it (#430) #955, family.py add: a commit that records the member's gitlink anywhere butmembers/<Project>is taken back and refused, naming the path git chose, andmembers: null,Null,NULLor~takes the first member asmembers:does (#920) #957, family.py init: where the holder's own YAML reader refuses the manifest it would write,family-manifest-invalidnames the value on the line it refuses,--org is '@x', and gives the reader's remedy, not the validator's (#929) #960 and 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 nooriginis told it has none instead of to fix the network (#898) #937 in turn, is clean at each step.git ls-remoteat 2026-10-18T18:19:09Z.scripts/family.py(gh pr list --state open --json files, 2026-10-18T18:19:09Z).50178305: six commits, through450279ca(the family root's bootstrap.py: an(ignored)line, for any code or signal, resets the depth fully, so a member's ownexit 2after a-one or two makes below, or after|| trueand a-, readsexited 2, and the limits name the-klet-fail shape (#890) #891a9205e00, tests/test_repo_hygiene.py and tests/test_release_workflow.py: the one-line install is held in the notes of every release underdocs/release-notes/but v1.0.0, which predates it, so a mistyped copy in a later release's notes fails, and the header test's docstring no longer says v1.1.0.md does not exist (#885) #88764464d31, tests/test_doctor_legs_members_next.py: the fixed words of the explanations its checkout scenarios reach are held clear of shell syntax over a root that needs quoting, and the file says the PowerShell form of a line is checked as text, by two tests, and run by no job (#415) #899a4de0aae, then01a2b016,ce0245d0and450279ca). They touch the two templates'scripts/bootstrap.pyand test files (git diff --name-only 50178305 450279ca), none of this PR's files.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,1190f813and0730095e): 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=failedin the probe environment above. Round 0's run over4e1aa24awas 911 passed, 4 skipped; the 5 more are round 1's new rows.tests/test_family_bump_fetch_refspecs.py; everytests/test_family_bump*.py(reachability_words,remove_rollback,to_a_tag,to_checks,to_local_only); fromgit 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.pyandtest_validate_family_branch_step.py; andtests/test_repo_hygiene.py,tests/test_cli_reference.pyandtests/test_agents_md_codes.py.git greplist, as not reaching the change:test_bump_leg_branch_and_push_line.py,test_bump_leg_unreadable.pyandtest_non_utf8_git_reads.py, whose"bump"is a parametrize id overbump-leg.pyand which run nofamily.py bump, andtest_doctor_unreadable_pin.py, whose"bump"namesbump-leg.py's lists the doctor copies.test_family_operation_reftable.py's (lines 393 and 437): git 2.43 cannot make a reftable repository.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 againsttests/test_family_bump_fetch_refspecs.py(13 tests). Each was restored after, andgit hash-objectchecked it was this head's blob1190f813.--refmap=originclash row: a local branch moved, and the mirror's refs written, under the dry run)origin/carriedrefs/tags/source aloneoriginrows: the note on stderr; the real run pinned,member HEAD changed)git config --listwithout-zorigin/pr/7, andorigin/review/cafe, pruned under the dry run)run(["git", "config", "--list"])split into lines at="replace"get-urlbefore the fetch (origin = True)originnote)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 bumpfetch handling so it updates only the intended remote-tracking branches and reports missing or invalid origins accurately.Bug Fixes:
family.py bumpfrom moving the mount’s local branches while fetching member branches.origin/without fetch conflicts or treating tags as branches.originwhen the mount has nooriginremote.Enhancements:
Tests: