Skip to content

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

Merged
brettheap merged 3 commits into
mainfrom
fix/898-bump-leg-reachability
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-1 (openRepoShape-1)
Claim: #898 (comment)

A v1.2.0 draft, authored on the v1.1.1 release commit 5017830. It changes scripts/bump-leg.py and adds tests/test_bump_leg_reachability_words.py. Lane 3 filed #898 in v1.2.0, from its triage of #458: it asks for bump-leg's twins of the three cases #795 (6f64eea) fixed in family.py.

What was wrong (at 5017830)

  • The fetch did not prune. fetch_leg ran git fetch -q origin +refs/heads/*:refs/remotes/origin/*. A branch deleted on the remote still counted in on_a_remote_branch, so a dry run said it would pin a commit no branch reached any more.
  • A configured refspec could move a branch of the leg's own. With refspecs on the command line, git also updates each ref through the remote's configured refspecs. With +refs/heads/*:refs/heads/* configured, a dry run force-moved the leg's local main off an unpushed commit. With the leg on main, the fetch failed (refusing to fetch into branch 'refs/heads/main' checked out at ...), and the run answered from stale refs.
  • A commit only a remote tag reaches was told "somebody committed inside the leg's mount and never pushed it".
  • A leg with no origin (renamed or removed) got the fetch's exit-128 note, then "fix the network or the credential".

What happens now

  • The fetch is git fetch -q --prune --no-prune-tags --refmap= <remote> <refspecs>.
    • _leg_fetch_refspecs mirrors family.py's _origin_fetch_refspecs. It fetches every branch first, then each of the leg's own positive remote.origin.fetch refspecs that write under refs/remotes/origin/, forced. Negative refspecs are skipped.
    • --refmap= stops the configured refspecs from moving anything else. Tags are still auto-followed under --refmap=; that was measured on git for a named remote and for a URL.
  • _has_origin asks git remote get-url origin, only when the fetch of origin fails, and never under --local-remote-dir. Exit 2 means "none"; any other failure is git's, quoted, exit 2.
  • _remote_tag_reaches asks git ls-remote --tags --refs <remote>, comparing by object id, only after a fetch that worked.
  • The refusals live in a new _unreachable_refusal, split out of fetch_leg.
  • No codes are added or renamed. docs/cli.md, AGENTS.md and the exit codes are unchanged. The module docstring's bullet on a commit the remote does not have is updated.

The new words (<mount> is the leg's path, <repository> the manifest's owner/name, such as testorg/Atlas-spec):

note: `git fetch origin` in <leg> exited <code>: <leg> has no remote named `origin`; answering from the remote-tracking refs this checkout already has

REFUSED bump-leg-commit-local-only: <commit> is in the leg's own object store, but the leg's mount, <mount>, has no remote named `origin`, so whether a branch of <repository> reaches it is not known
Remediation: give <mount> back a remote named `origin`, for <repository>, and re-run: this tool fetches the leg's branches from `origin` and asks no other remote. If the commit is not pushed yet, push it to a branch of the leg first.

REFUSED bump-leg-commit-local-only: <commit> is in the leg's own object store, and a tag of <repository> reaches it, but no branch of <repository> does
Remediation: push it to a branch of the leg and re-run — a bump pins only a commit a branch of the leg's remote reaches, and a tag is not a branch.

REFUSED bump-leg-commit-not-on-remote: <commit> is not in the leg's own object store, and the leg's mount, <mount>, has no remote named `origin` to fetch it from, so whether a branch of <repository> reaches it is not known

The plain bump-leg-commit-local-only keeps its detail, with a new remedy: "push it to a branch of the leg and re-run; the commonest such commit was made inside the leg's mount and never pushed."

Measured

On git 2.43.0, with local bare remotes, against 5017830 and against this head:

Case Before After
A commit only a remote tag reaches (through origin and --local-remote-dir) exit 2, "never pushed" exit 2, the tag words
A tag of the same name at another commit (the control) the old plain words the new plain words, not the tag words
origin renamed or removed: a held commit, and an unknown id the note "exited 128", then "fix the network or the credential" the note "has no remote named origin", then the no-origin words; no "network"
A branch deleted on the remote (both ways in) dry run exit 0: it would pin the commit; origin/gone, pr/8 and by-hand kept exit 2, the plain local-only words; those three pruned; pr/7, origin/HEAD, the leg's own mine branch and mine-tag kept (with pruneTags=true)
+refs/heads/*:refs/heads/* configured, leg detached the dry run moved the leg's local main off its unpushed commit main stays
The same, leg on main the fetch exits 128, and the run answers from stale refs (shown by the tests run against 5017830; the probe's own run of this case stopped at bump-leg-root-dirty) the fetch works, with no note; the local unpushed side is unchanged
A narrowed fetch (main only, or a negative refspec) pinned (dry run exit 0) the same

Tests

tests/test_bump_leg_reachability_words.py, 19 cases since the fix round at cea5210a (16 at f4aa32d2), on the model of tests/test_family_bump_reachability_words.py. With bump-leg.py put back at 5017830, 13 of the first 16 fail and 3 pass; those 3 are the narrowing guards, which 5017830 already met. The round's three tests each fail under their mutant (M11, M12, M13).

Run with /usr/bin/python3 -m pytest, a stub gh first on PATH, a bogus GH_TOKEN, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null and GIT_SSH_COMMAND=false:

Files Result
every tests/test_bump_leg*.py, the new file included 254 passed, 1 skipped at cea5210a (251 at f4aa32d2); the skip needs a case-insensitive disk
test_family_bump_reachability_words.py, test_repo_hygiene.py, test_cli_reference.py and test_agents_md_codes.py 160 passed

Notes

Closes #898

🤖 Generated with Claude Code

Summary by Sourcery

Make bump-leg accurately validate remote reachability while safely pruning stale refs and preserving local branch state.

Bug Fixes:

  • Ensure bump-leg fetches prune deleted remote branches and does not move the leg's local branches.
  • Report accurate refusal reasons for commits reachable only by remote tags or when the leg has no origin remote.

Enhancements:

  • Preserve relevant leg-specific remote-tracking refspecs while isolating fetch updates to remote-tracking refs.
  • Add comprehensive coverage for reachability decisions, fetch failures, pruning, refspec handling, and refusal messages.

Tests:

  • Add reachability and fetch-behavior tests for bump-leg's updated remote validation.

…nd 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)

The twins of what #795 (6f64eea) fixed in `family.py bump` (#686), found
by lane 3's triage of #458 at 5017830. The rule is kept: a commit no
branch of the leg's remote reaches is still refused, by both runs, under
the same two codes, before anything is written.

The fetch. `fetch_leg` ran `git fetch -q origin
+refs/heads/*:refs/remotes/origin/*`. It now runs `git fetch -q --prune
--no-prune-tags --refmap= <remote> <refspecs>`, where
`_leg_fetch_refspecs` puts every branch first, then each positive refspec
of the leg's own `remote.origin.fetch` whose destination is under
`refs/remotes/origin/`, forced, and skips a negative one, as
`_origin_fetch_refspecs` does in family.py. A branch the remote deleted
since the leg last fetched no longer counts: measured, a commit only such
a branch reached was pinned, dry run exit 0, by both `origin` and
`--local-remote-dir`; both runs now refuse it `bump-leg-commit-local-only`.
A pull request ref the leg's own refspec keeps under `origin/` stays (and
is pruned against that refspec alone), `origin/HEAD` stays, and the leg's
own branches and tags stay, under `remote.origin.pruneTags` too; a ref put
under `origin/` by hand goes. The leg's config is read before the fetch,
so one git cannot read is git's own failure, quoted.

`--refmap=` (empty). Decided for bump-leg: with refspecs on the command
line, git also writes each ref the fetch of a NAMED remote brings wherever
that remote's configured refspecs map it. Measured on git 2.43 with
`+refs/heads/*:refs/heads/*` configured in the leg: the dry run
force-moved the leg's local `main` off a commit never pushed (and made a
local branch for each remote one), and a leg standing on `main` failed the
fetch, "refusing to fetch into branch 'refs/heads/main'", exit 128, so the
answer came from stale refs. With `--refmap=` neither happens, tags are
still followed (measured, named remote and path alike), and nothing is
lost, since the configured refspecs that write under `origin/` are on the
command line already. family.py's fetch at 5017830 has no `--refmap=`;
its twin of this is left for family.py.

A leg with no `origin` (`git remote rename origin upstream`, or `remote
remove`). Its fetch failed with no network at fault, and both refusals
ended "fix the network or the credential". When the fetch of `origin`
fails, `_has_origin` asks `git remote get-url origin`, whose exit 2 means
"No such remote" and nothing else; any other failure is git's, quoted,
exit 2. With none, the note says `<leg> has no remote named `origin``,
and `bump-leg-commit-local-only` and `bump-leg-commit-not-on-remote` say
the leg's mount has no remote named `origin`, so whether a branch reaches
the commit is not known, and to give it back. Not asked under
`--local-remote-dir`, which fetches from a path.

A commit only a tag of the remote reaches, which the leg holds. It was
told "somebody committed inside the leg's mount and never pushed it",
which the pushed tag disproves. After a fetch that worked,
`_remote_tag_reaches` asks `git ls-remote --tags --refs` of the remote
the fetch asked, by object id, whether it has a tag of the leg that
contains the commit; if so the refusal says a tag of the remote reaches it
and no branch does, and to push it to a branch. A tag only the leg has,
or the remote's tag of the same name at another commit, keeps the plain
words, which now say "the commonest such commit was made inside the leg's
mount and never pushed", as family.py's do: a commit pushed to a branch
since deleted was pushed. The five refusals are built in
`_unreachable_refusal`, split from `fetch_leg`.

The module docstring's bullet on a commit the remote does not have says
so. No code is added or renamed, so docs/cli.md, AGENTS.md and the EXIT
CODES list are unchanged.

Tests. tests/test_bump_leg_reachability_words.py, on the model of
tests/test_family_bump_reachability_words.py, holds each case in both
runs with nothing moved: the tag-held commit through `origin` and
`--local-remote-dir`, typed as the commit and as its annotated tag's own
id, with the way out taken; the same-name control; `origin` renamed and
removed, with the way out taken; the unknown id with no `origin`; a leg
whose `origin` has a `url` with no value, git's own failure quoted; the
branch deleted on the remote through both, with the pull request refspec,
the hand-made ref, a notes refspec, the leg's own branch and tag and
`origin/HEAD`; the refmap case detached and on a branch; and the leg's
own narrowing refspecs (main only, none, negative), which still never
decide reachability. With bump-leg.py put back at 5017830, 13 of its 16
fail and the 3 narrowing guards pass, as they did before.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap added this to the v1.2.0 milestone Oct 18, 2026
@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates bump-leg's fetch to safely prune stale remote-tracking refs without moving local branches, and refines refusal messages for tag-only commits and legs missing origin; comprehensive integration tests cover the new behavior and regression cases.

Sequence diagram for safe leg fetch and reachability refusal

sequenceDiagram
    participant B as bump-leg
    participant G as Git fetch
    participant R as Remote
    participant T as Git tag listing

    B->>B: _leg_fetch_refspecs()
    B->>G: fetch --prune --no-prune-tags --refmap= origin refspecs
    G->>R: Fetch branches and retained origin refspecs
    R-->>G: Remote refs
    G-->>B: Updated remote-tracking refs, local branches unchanged
    B->>B: on_a_remote_branch()
    alt Commit reachable from a remote branch
        B-->>B: Allow bump
    else Commit is held locally
        B->>T: _remote_tag_reaches()
        T->>R: ls-remote --tags --refs origin
        R-->>T: Remote tag object IDs
        T-->>B: Tag reaches commit or not
        B-->>B: _unreachable_refusal()
    end
Loading

Flow diagram for bump-leg remote availability decisions

flowchart TD
    A[Fetch leg with prune and empty refmap] --> B{Fetch succeeds?}
    B -->|No| C{origin exists?}
    B -->|Yes| D[Check remote-tracking branches]
    C -->|No| E[Refusal explains missing origin]
    C -->|Yes| F[Answer from existing remote-tracking refs]
    D --> G{Branch reaches commit?}
    G -->|Yes| H[Allow bump]
    G -->|No| I{Commit is in local object store?}
    I -->|No| J[Refusal: commit not on remote]
    I -->|Yes| K{Remote tag reaches commit?}
    K -->|Yes| L[Refusal: tag reaches it, but no branch does]
    K -->|No| M[Refusal: local-only commit]
Loading

File-Level Changes

Change Details Files
Make leg fetching prune stale remote-tracking branches while preventing configured refspecs from updating local branches.
  • Fetch with pruning, tag-prune protection, and an empty refmap.
  • Always fetch remote branches, while retaining configured positive refspecs targeting the origin-tracking namespace.
  • Ignore negative and non-origin refspecs so local branches and unrelated refs are not modified.
  • Preserve existing narrowing-refspec reachability behavior.
scripts/bump-leg.py
Improve unreachable-commit diagnosis and remediation based on remote availability and reachability type.
  • Detect whether the leg has an origin remote after a failed origin fetch.
  • Distinguish commits reached only by remote tags from commits never pushed.
  • Report missing origin without blaming network or credentials.
  • Move refusal construction into a dedicated helper while preserving exit codes.
scripts/bump-leg.py
Add integration coverage for pruning, refmap safety, tag-only reachability, and missing-origin messaging.
  • Test origin and local-remote-dir paths, annotated and lightweight tags, and same-name tag controls.
  • Test renamed or removed origins, malformed Git configuration, and exact refusal wording.
  • Verify deleted remote branches and stale tracking refs are pruned without removing retained pull-request refs, tags, or local branches.
  • Verify mirrored local refspecs cannot move the leg's branches and narrowing refspecs do not hide reachable commits.
tests/test_bump_leg_reachability_words.py

Assessment against linked issues

Issue Objective Addressed Explanation
#898 Make bump-leg fetch prune remote-deleted branch refs without pruning tags or the leg's own relevant remote-tracking refs, and prevent configured refspecs from moving local branches or deciding branch reachability. ✅
#898 When the leg holds a commit reachable only through a tag of the remote, refuse it with an accurate tag-specific explanation rather than saying it was never pushed. ✅
#898 When fetching fails because the leg has no remote named origin, identify that condition and provide appropriate guidance instead of blaming the network or credentials. ✅

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-1, 2026-10-18T17:58:52Z) Independent review of #937 at f4aa32d2, by lane 1's reviewer r937 (opus), who is not its writer: READY, with no MUST. Its one SHOULD asks for a record, not code, and lane 1 makes it in a short round now.

Lane 1's writer w937f makes SHOULD-1's paragraph and NITs 1, 2, 4 and 5 now. Then r937 re-checks the new head.

Lane: openreposhape-1 (openRepoShape-1)

…at git cannot satisfy fails the fetch, `--no-prune-tags` is belt and braces on git 2.43, the prune under `--local-remote-dir` is deliberate, and tests hold the leg's own refspecs forced, no tag check after a failed fetch, and a note that says nothing of `origin` under a stand-in directory (#898)

The independent review of #937 (r937) found it READY, with one SHOULD and
NITs. This is its fix round, for the items that belong in the code and the
tests:

- SHOULD-1 fix 1: `_leg_fetch_refspecs`'s docstring gains the paragraph
  `family.py`'s `_origin_fetch_refspecs` has and the port dropped. A leg
  refspec naming a ref the remote no longer has, or a tags-into-`origin/`
  refspec where the remote has a branch and a tag of one name, fails the
  fetch with 128 where the fetch of every branch alone worked; the note
  follows and nothing is pruned.
- NIT-1: `--no-prune-tags` is belt and braces. Git 2.43 applies no
  `pruneTags` setting to a fetch with refspecs on its command line
  (measured), in `fetch_leg`'s docstring and in the prune test's.
- NIT-2: three tests for the behaviours mutants M13, M12 and M11 changed.
  Each passes at this head and fails under its mutant:
  * the leg's own refspecs are forced as the first is (M13, no `+`): a
    pull-request refspec written without it, the remote's `pull/7` moved
    back, and no note;
  * the tag check is asked only after a fetch that worked (M12): a fetch
    that fails while `ls-remote` lists a tag that reaches a held commit
    keeps the plain words and the stale-refs sentence;
  * a stand-in directory the leg lacks fails the fetch, not the leg's
    `origin` (M11): a leg with no `origin`, run through
    `--local-remote-dir` at a directory with no `<repo>.git`, gets a note
    that says nothing of a missing `origin`.
- NIT-4: `fetch_leg`'s docstring says that under `--local-remote-dir` the
  prune deletes the `origin/*` tracking refs the stand-in directory lacks,
  and that this is deliberate.
- NIT-5: the 80-column parametrize line is split.

Left for others: NIT-3 (a follow-up for both tools), NIT-6 and the rest of
SHOULD-1 (the comment on #905 and the PR body), which are lane 1's.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-18T18:16:54Z) Re-check of #937 at cea5210a, by r937 (opus), who is not its writer: READY.

#937 asks for a turn once its tests are green at cea5210a, gated over main as it then stands.

Lane: openreposhape-1 (openRepoShape-1)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 18, 2026 19:18
@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

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

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

@brettheap
brettheap merged commit d4d11bf into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/898-bump-leg-reachability branch October 18, 2026 19:18
brettheap added a commit that referenced this pull request Oct 19, 2026
…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) (#946) (#946)

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 source is under `refs/tags/`, wherever under `origin/` it
  writes (`+refs/tags/*:refs/remotes/origin/tags/*` too), and one whose
  destination is `refs/remotes/origin/*` and whose source is not under
  `refs/heads/` (a mirror into `origin/`). The fetch works, and such a
  commit is refused in #686's words for a commit only a tag of the remote
  reaches. The pull-request refspec still rides along. 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, and as bytes, so a refspec whose
  bytes are not UTF-8 reaches the fetch as they are. 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, one call
  more in every 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 landed with #937; no file of it changes
here. The new `tests/test_family_bump_fetch_refspecs.py` holds 13 rows:
each state through `bump --dry-run` and `bump` with no ref of the mount
moved under either, the tag rows over three refspecs, a refspec whose
bytes are not UTF-8, and a control.

Lane 3's independent reviewer RV946 (opus) found it READY in two rounds.
The first, of `4e1aa24a`, had no MUST (#946 issuecomment-6252861452);
`5d5d06bb` takes its two SHOULDs and its N1 and N2. The second found
`5d5d06bb` READY with no MUST and no SHOULD (#946
issuecomment-6266641009). Its NIT N6, two test docstrings, is a
follow-up; N3, N4 and N5 are named in the PR body.
`tests`, `tests-git-bash`, `package` and SonarCloud passed at
`5d5d06bb`. It lands under Brett Heap's v1.2.0 merge word.

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

Labels

None yet

Projects

None yet

1 participant