From a15c660141492b33fb9f52a979b5b00f782c9e0f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 21:07:14 +0000 Subject: [PATCH 1/2] family.py: `bump --to` refuses a commit only the member's mount holds, reachable from no branch of the member's remote, as `bump-leg.py` refuses its twin, so a holder is never pinned to a commit nobody else can fetch (#613) A commit made inside `members/` and never pushed passed the only question `bump` asked of `--to`, `git cat-file -t`, because the object is in the mount's own store. `bump --to` pinned it, exit 0, `validate-family.py` passed, and the holder could then be bootstrapped nowhere else, since nobody else can fetch that commit. `bump-leg.py` refuses its twin as `bump-leg-commit-local-only`. The triage's D2, option 1, which Brett Heap approved at 2026-10-15T16:49:38Z: refuse it as `bump-leg.py` does. In `_checked_to_names_a_commit`, after `--to` is resolved (an annotated tag's own id peeled, as #541 peels one) and before anything is checked out or written, a remote-tracking ref of the member must contain the commit: `git for-each-ref --contains refs/remotes/origin/`, over the refs the fetch has just updated (`_on_a_remote_branch`, bump-leg.py's question under its own name). A commit none contains is `member-to-local-only`, exit 2. The refusal names the commit, and the tag as typed when one was peeled; it says the commit is in the member's own object store but reachable from no branch of the member's remote, to push it to a branch and run the same command again, and why: a pin the rest of the world cannot fetch is a family the rest of the world cannot bootstrap. `bump --dry-run` asks the same function, so both runs refuse in the same two lines and leave the holder byte for byte as it was. The split of codes stays. An id the member does not hold after the fetch is still `member-to-unknown`, and a tree, a blob or a tag of either is still `member-to-not-a-commit`: reachability is asked only of a commit. When the fetch failed, the answer comes from the remote-tracking refs the mount already has, as `bump-leg.py` answers it. A commit they contain is pinned; one they do not is refused with bump-leg.py's stale-refs sentence, adapted. A bump is never refused for being offline. The question is written once in family.py rather than called from bump-leg.py: that file's name has a hyphen, so it is loaded only by path, which would run a whole command-line tool's top level for one `for-each-ref`, and no module both tools import holds it. Tests. The twelve cases in five files that bumped to a commit only the mount held now push it first, through one helper in tests/test_family.py, `pushed_from_the_mount`. It pushes to a branch of a private bare remote beside the holder and points the mount at it, so the fixture's own remotes are still only read. `commit_in_mount` (tests/test_family_dry_run.py, six cases) pushes unless `push=False`, `commit_in_member` (tests/test_family.py, five cases) pushes, and the one inline commit in tests/test_family_add_excludes.py calls the helper. No assertion changed. tests/test_family_bump_to_local_only.py holds the issue's case and its control in both runs, an annotated tag of such a commit, a tree and a blob only the mount holds, and the two offline cases. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 125 +++++++++++-- tests/test_family.py | 33 +++- tests/test_family_add_excludes.py | 9 +- tests/test_family_bump_to_local_only.py | 235 ++++++++++++++++++++++++ tests/test_family_dry_run.py | 18 +- 5 files changed, 393 insertions(+), 27 deletions(-) create mode 100644 tests/test_family_bump_to_local_only.py diff --git a/scripts/family.py b/scripts/family.py index a3eb0e08..bf572f62 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -4637,14 +4637,75 @@ def _print_trailers(trailers: list[str]) -> None: print("trailers none") +def _on_a_remote_branch(member: Path, commit: str) -> bool: + """Whether `commit` is reachable from one of the member's remote-tracking + branches, `refs/remotes/origin/*` (#613): the refs `bump`'s `git fetch + origin` has just updated, or, when that fetch failed, the ones the mount + already had. + + THE QUESTION `bump-leg.py`'s `on_a_remote_branch` ASKS OF A LEG, in the + same git words, `for-each-ref --contains` over `refs/remotes/origin/`, + and under the same name. Written again here rather than called: that + file's name has a hyphen, so it is loaded only by path, which would run a + whole command-line tool's top level for one `git for-each-ref`, and no + module both tools import (`repo_shape`, `shape_materialize`) holds the + question. This file asks it here and nowhere else. + + A FAILURE OF THE QUESTION ITSELF IS GIT'S, never an answer: `run` raises + it as `CommandFailed`, quoted as every failed git command in this file + is, exit 2, before anything is written. It is asked only of an id the + member holds as a commit, so `--contains` has an object to walk from. + """ + return bool(run(["git", "for-each-ref", "--contains", commit, + "--format=%(refname)", "refs/remotes/origin/"], + cwd=member)) + + +def _checked_on_a_remote_branch(member: Path, path: str, repository, + typed: str, commit: str, + fetched: bool) -> str: + """`commit`, the commit `--to` (`typed`) names, when a branch of the + member's remote reaches it, or the refusal `member-to-local-only` (#613), + which `_checked_to_names_a_commit` says why it makes. `typed` is not + `commit` where an annotated tag was peeled: the refusal names both, since + the line saying what the tag names is printed only once this returns. + + The words are `bump-leg.py`'s `bump-leg-commit-local-only`, said of a + member: where the commit is, what it is not reachable from, to push it to + a branch and run the same command again, and why. When the fetch failed + they end with its stale-refs sentence, adapted: the answer came from + remote-tracking refs the fetch did not refresh, so a commit pushed since + may be refused here, and the network or the credential is the thing to + fix before believing it. + """ + if _on_a_remote_branch(member, commit): + return commit + named = (f"--to {commit}" if typed == commit else + f"--to {typed} is an annotated tag, and the commit it names, " + f"{commit},") + stale = ("" if fetched else + f" The fetch of {path} FAILED, as the note above says, so this " + "was answered from stale remote-tracking refs: fix the network " + "or the credential and run it again before believing it.") + raise Refusal( + "member-to-local-only", + f"{named} is in the member's own object store, in {path}, but is " + f"reachable from no branch of {repository}", + f"Remediation: push it to a branch of {repository} and run the same " + f"command again; the commonest such commit was made inside {path} and " + "never pushed. A pin the rest of the world cannot fetch is a family " + "the rest of the world cannot bootstrap." + stale) + + def _checked_to_names_a_commit(member: Path, path: str, row: dict, commit: str, fetched: bool) -> str: """The COMMIT `--to` names in the member's objects: `--to` itself, or the - commit an annotated tag's own id names (#541), or the refusal saying why - there is none. `bump` and `bump --dry-run` both ask it, after the fetch - (`fetched` False when it failed) and before the dry run leaves or the - checkout, so the two runs go on with one commit or make one refusal, - and the holder is left byte for byte as it was. + commit an annotated tag's own id names (#541), a commit a branch of the + member's remote reaches (#613), or the refusal saying why there is none. + `bump` and `bump --dry-run` both ask it, after the fetch (`fetched` False + when it failed) and before the dry run leaves or the checkout, so the two + runs go on with one commit or make one refusal, and the holder is left + byte for byte as it was. AN ANNOTATED TAG'S OWN ID IS PEELED, as `add --at` peels one (Brett Heap, 2026-10-14, on #541). It is 40 hex, and it is what `git rev-parse @@ -4672,15 +4733,39 @@ def _checked_to_names_a_commit(member: Path, path: str, row: dict, mount brings only what the member's remote has. When that fetch failed, the refusal says so and does not claim the remote lacks it. - A TAG WHOSE COMMIT IS ON NO BRANCH is refused `member-to-unknown` too, - and so is that commit, while `add --at` pins the same tag's id. The - fetch here brings every branch and, by git's own rule, only the tags - that point into the history those branches bring; `add`'s bare clone - brings every tag. A `git push origin v2` without the branch makes one, - and the refusal's push to a branch is what lets `bump` pin it. Left as - it is: `bump-leg.py` refuses its twin the same way, as - `bump-leg-commit-not-on-remote`, and fetching every tag would write refs - the person's member did not have, which is a new act. + A COMMIT THE MEMBER HOLDS THAT NO BRANCH OF ITS REMOTE REACHES is + refused `member-to-local-only` (#613: the triage's D2, option 1, which + Brett Heap approved at 2026-10-15T16:49:38Z). The object check alone, + `cat-file -t`, passed a commit made inside `members/` and never + pushed, because the object is in the mount's own store: `bump` pinned + it, exit 0, `validate-family.py` passed, and the holder could then be + bootstrapped nowhere else, since nobody else can fetch that commit. + `bump-leg.py` refuses its twin as `bump-leg-commit-local-only`, and the + question is its question (`_on_a_remote_branch`): REACHABILITY, NOT A + FETCH THAT EXITED 0, which proves nothing about one object. It is asked + after the peel, of the commit a tag names, which the refusal names, and + only of a commit: an id the member does not hold keeps + `member-to-unknown`, and a tree, a blob or a tag of either keeps its own + words. WHEN THE FETCH FAILED it is answered from the remote-tracking + refs the mount already has, as `bump-leg.py` answers it, and a bump is + never refused for being offline: a commit those refs contain is pinned, + after the note that the fetch failed, and one they do not is refused with + the sentence that says the answer came from stale refs. + + A TAG WHOSE COMMIT IS ON NO BRANCH is refused too, and so is that + commit, while `add --at` pins the same tag's id. Where the mount does + not hold it, it is `member-to-unknown`: the fetch here brings every + branch and, by git's own rule, only the tags that point into the + history those branches bring, while `add`'s bare clone brings every tag, + and a `git push origin v2` without the branch makes one. Where the mount + does hold it, a tag made in the mount or one its own clone brought, it + is `member-to-local-only` (#613), naming the commit the tag names: no + branch of the remote reaches it. Either way the refusal's push to a + branch is what lets `bump` pin it. Left as it is: `bump-leg.py` refuses + its twins the same two ways, as `bump-leg-commit-not-on-remote` and + `bump-leg-commit-local-only`, and fetching every tag would write refs the + person's member did not have, which is a new act; reachability from a + tag is not the question either tool asks. """ repository = row.get("repository") try: @@ -4710,7 +4795,8 @@ def _checked_to_names_a_commit(member: Path, path: str, row: dict, "same command again. A pin the rest of the world cannot fetch is " "a family the rest of the world cannot bootstrap.") if kind == "commit": - return commit + return _checked_on_a_remote_branch(member, path, repository, commit, + commit, fetched) peeled, names = "", "" if kind == "tag": try: @@ -4720,7 +4806,8 @@ def _checked_to_names_a_commit(member: Path, path: str, row: dict, except CommandFailed: peeled, names = "", "" if names == "commit": - return peeled + return _checked_on_a_remote_branch(member, path, repository, + commit, peeled, fetched) named = names if kind == "tag" else kind if named in ("tree", "blob"): raise _names_no_commit("member-to-not-a-commit", "--to", commit, @@ -4741,7 +4828,8 @@ def _bump_dry_run(family: Family, row: dict, path: str, commit: str, THE REAL RUN'S QUESTIONS, IN ITS ORDER, ASKED OF THE MEMBER'S OBJECTS. `commit` is the one `_checked_to_names_a_commit` returned, which both runs asked after the fetch: an annotated tag's own id is already peeled, - and an id the member does not hold is already refused (#541). It is + an id the member does not hold is already refused (#541), and so is a + commit it holds that no branch of its remote reaches (#613). It is resolved as the checkout would take it (`^{commit}`, which is what `git checkout` accepts). The manifest is read by `_member_id_at`, its id is asked against the other rows by `_checked_bumped_id` as the real run @@ -4894,7 +4982,8 @@ def _checked_bump_target(family: Family, row: dict, path: str, the rollback is even recorded, the two runs refuse at the same point in the same words, and the holder is byte for byte as it was. A `--to` the member's objects do not hold never gets here: `_checked_to_names_a_commit` - refused it after the fetch (#541). A symbolic link is left to the read + refused it after the fetch (#541), as it refused a commit no branch of + the member's remote reaches (#613). A symbolic link is left to the read through the checkout, as the dry run leaves it, and `_bump_and_pin` asks the id of that reading. """ diff --git a/tests/test_family.py b/tests/test_family.py index 9a4f3efe..960daf90 100644 --- a/tests/test_family.py +++ b/tests/test_family.py @@ -947,13 +947,42 @@ def nothing_to_do_line(project: str, commit: str) -> str: f"{commit[:12]} and every fact agrees.") +def pushed_from_the_mount(member, commit: str) -> None: + """`commit`, made in the holder's own mount `member`, PUSHED to a branch + of the member's remote, as a person pushes one before they bump it + (#613). + + `bump --to` refuses a commit no branch of the member's remote reaches, + `member-to-local-only`, as `bump-leg.py` refuses its twin, so a commit a + test bumps to is pushed first. NOT TO THE FIXTURE'S OWN REMOTE: every + holder is a copy of one module family, and the other tests read its bare + remotes (`older` is their `main~1`). The remote is a bare repository of + the test's own beside the holder, named for the holder and the member, + made on the first push with the member's `main` as the mount last + fetched it; the mount's `origin` is pointed at it, so `bump`'s own fetch + reads it, and each commit goes to a branch named for it. + """ + holder = member.parents[1] + remote = holder.parent / f"{holder.name}-{member.name}-origin.git" + if not remote.exists(): + git("init", "-q", "--bare", str(remote), cwd=holder.parent) + git("push", "-q", str(remote), + "refs/remotes/origin/main:refs/heads/main", cwd=member) + git("remote", "set-url", "origin", str(remote), cwd=member) + git("push", "-q", "origin", f"{commit}:refs/heads/pushed-{commit[:12]}", + cwd=member) + + def commit_in_member(member, message: str = "The member moved again") -> str: """An EMPTY commit on top of the member's HEAD: a new commit whose tree is the old one's, so `pin.tree_sha256` still recomputes and the gitlink is the - only fact that moves. Returns its sha.""" + only fact that moves. PUSHED (`pushed_from_the_mount`, #613), so a bump to + it is a bump to a commit the member's remote has. Returns its sha.""" git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", message, cwd=member) - return git("rev-parse", "HEAD", cwd=member).stdout.strip() + made = git("rev-parse", "HEAD", cwd=member).stdout.strip() + pushed_from_the_mount(member, made) + return made @pytest.mark.parametrize("extra", [ diff --git a/tests/test_family_add_excludes.py b/tests/test_family_add_excludes.py index eb037245..e1d71222 100644 --- a/tests/test_family_add_excludes.py +++ b/tests/test_family_add_excludes.py @@ -48,7 +48,7 @@ #: the modules that own them rather than rebuilt: a holder made any other way #: would test a different holder. from test_family import ( # noqa: F401 - FAMILY, NAME, ORG, SPARE, family, manifest, + FAMILY, NAME, ORG, SPARE, family, manifest, pushed_from_the_mount, ) from test_family_add_refusals import ( # noqa: F401 holder, offline, refusal, snapshot, @@ -219,8 +219,10 @@ def test_bump_moves_the_gitlink_past_the_operators_excludes(holder, offline, """`bump` RIDES WITH THE ISSUE. It stages `[path, family.yaml]` through `_commit_one`, so under `members` it died at the same `git add` with the gitlink already tracked, and left both staged with no commit. The new - commit is made inside the holder's own mount, so the fixture's remote is - never pushed to.""" + commit is made inside the holder's own mount and pushed from there to a + private remote the mount is pointed at (`pushed_from_the_mount`), since + `bump` refuses one no branch of the member's remote reaches (#613), so + the fixture's remote is never pushed to.""" env = under(offline, tmp_path, RULES["members"]) member = holder / "members" / "IRRS" (member / "MOVED.md").write_bytes(b"the member advanced\n") @@ -228,6 +230,7 @@ def test_bump_moves_the_gitlink_past_the_operators_excludes(holder, offline, git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "Advance the member", cwd=member) moved = git("rev-parse", "HEAD", cwd=member).stdout.strip() + pushed_from_the_mount(member, moved) pinned = {r["project"]: r for r in manifest(holder)["members"]}["IRRS"] assert pinned["pin"]["commit"] != moved was = git("rev-parse", "HEAD", cwd=holder).stdout.strip() diff --git a/tests/test_family_bump_to_local_only.py b/tests/test_family_bump_to_local_only.py new file mode 100644 index 00000000..0cba47d7 --- /dev/null +++ b/tests/test_family_bump_to_local_only.py @@ -0,0 +1,235 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`family.py bump --to` refuses a commit only the member's mount holds, +reachable from no branch of the member's remote, as `bump-leg.py` refuses its +twin (#613). + +THE DEFECT. `bump` asked only whether the member's objects hold `--to` +(`cat-file -t`), and a commit made inside `members/` and never +pushed is in the mount's own object store. So `bump --to` pinned it, exit 0, +`validate-family.py` passed, and the holder could then be bootstrapped +nowhere else, since nobody else can fetch that commit. `bump-leg.py` refuses +the same state as `bump-leg-commit-local-only`. + +THE RULING THIS FILE HOLDS `bump` TO (the triage's D2, option 1, which Brett +Heap approved at 2026-10-15T16:49:38Z): refuse it as `bump-leg.py` does. +After `--to` is resolved, an annotated tag peeled as #541 peels one, and +before anything is checked out or written, a remote-tracking ref of the +member (`refs/remotes/origin/*`) must contain the commit. One that none +contains is `member-to-local-only`, exit 2, from the real run and its dry +run alike, in the same two lines, with the holder byte for byte as it was. +The split of codes stays: an id the member does not hold keeps +`member-to-unknown` (`test_family_bump_to_checks.py`), and a tree, a blob or +a tag of either keeps `member-to-not-a-commit`, here for objects only the +mount holds too. OFFLINE IS NOT REFUSED FOR BEING OFFLINE: when the member's +fetch fails, both runs answer from the remote-tracking refs the mount +already has, so a commit those refs contain is pinned and one they do not is +refused with a sentence saying the refs may be stale. + +The family, the holder, the offline environment and "changes nothing" are +`test_family_dry_run.py`'s, imported rather than rebuilt. The fixture's own +remotes are only ever read: a commit a test pushes goes to a private bare +repository the mount is pointed at (`pushed_from_the_mount`). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +#: The family fixture and its names, from the module that owns them. +from test_family import ( # noqa: F401 + ORG, family, manifest, pushed_from_the_mount, validate, +) +from test_family_add_refusals import holder, offline, refusal # noqa: F401 +#: Both runs over one holder, refused alike with nothing moved; an annotated +#: tag, and an object's id as git resolves it. +from test_family_bump_to_a_tag import annotated, both_runs, oid +#: "Changes nothing", asked as bytes in the order a bump would write. +from test_family_dry_run import bump, commit_in_mount, isolated, pinned_at +from test_family_member_path import MEMBER, raw + +PATH = f"members/{MEMBER}" +REPOSITORY = f"{ORG}/{MEMBER}" + +#: The line both runs print first when the member's fetch fails. +FETCH_FAILED = (f" note: could not fetch {PATH} from its origin; the commit " + "must already be in its object store") + + +def local_only(to: str, commit: str = "", *, + fetched: bool = True) -> tuple[str, str]: + """The two lines `bump --to ` is refused with when the commit it + names, `commit` (`to` itself unless an annotated tag was peeled), is in + the mount's objects and no branch of the member's remote reaches it; + `fetched` False when the member's fetch failed.""" + commit = commit or to + named = (f"--to {to}" if commit == to else + f"--to {to} is an annotated tag, and the commit it names, " + f"{commit},") + stale = ("" if fetched else + f" The fetch of {PATH} FAILED, as the note above says, so this " + "was answered from stale remote-tracking refs: fix the network " + "or the credential and run it again before believing it.") + return ( + f"REFUSED member-to-local-only: {named} is in the member's own object " + f"store, in {PATH}, but is reachable from no branch of {REPOSITORY}", + f"Remediation: push it to a branch of {REPOSITORY} and run the same " + f"command again; the commonest such commit was made inside {PATH} and " + "never pushed. A pin the rest of the world cannot fetch is a family " + "the rest of the world cannot bootstrap." + stale) + + +def made_in_the_mount(holder: Path, env: dict, name: str) -> str: + """A commit made inside the holder's own mount and NEVER PUSHED, adding + the file `name`, with the mount put back at the pin: the issue's own + state. Returns its sha.""" + return commit_in_mount( + holder, env, + lambda mount: (mount / name).write_bytes(b"made in the mount\n"), + push=False) + + +def only_in_the_mount(family, holder: Path, env: dict, commit: str) -> None: + """The premise of a refusal: `commit` is a commit in the mount's own + objects, no remote-tracking ref of the mount contains it, and the + member's remote does not hold it.""" + mount = holder / PATH + assert raw(mount, env, "cat-file", "-t", commit).stdout.strip() == \ + b"commit", "premise: the mount holds it, as a commit" + assert raw(mount, env, "for-each-ref", "--contains", commit, + "refs/remotes/").stdout == b"", ( + "premise: no remote-tracking ref contains it") + assert raw(family["remotes"] / f"{MEMBER}.git", env, "cat-file", "-e", + commit, check=False).returncode != 0, ( + "premise: the member's remote does not hold it") + + +def offline_from_here(holder: Path, env: dict, tmp_path: Path) -> None: + """The mount's `origin` pointed at a repository that is not there, so + `bump`'s fetch fails, as for a machine with no network or a private + member with no credential.""" + raw(holder / PATH, env, "remote", "set-url", "origin", + str(tmp_path / "gone.git")) + + +def pins(holder: Path, env: dict, commit: str, result) -> None: + """`result`, a real `bump --to `, pinned `commit` in the row and + the gitlink alike, and the holder passes its own gate.""" + assert result.returncode == 0, result.stdout + result.stderr + row = {r["project"]: r for r in manifest(holder)["members"]}[MEMBER] + assert row["pin"]["commit"] == commit == pinned_at(holder, env) + assert validate(holder).returncode == 0 + + +def test_a_commit_only_the_mount_holds_is_refused_and_pinned_once_pushed( + family, holder, offline, tmp_path): + """THE ISSUE'S CASE. A commit made in `members/` and never + pushed was pinned, exit 0, by a holder its own gate passed and nobody + else could bootstrap. Now `bump --to` it and `bump --to` it `--dry-run` + both refuse `member-to-local-only`, exit 2, in the same two lines, with + nothing on stdout and the holder byte for byte as it was: HEAD, the + index, the gitlink, `family.yaml` and the member's checkout. + + THE CONTROL: the same commit pushed to a branch of the member's remote + is pinned by the same command, exit 0, and the holder passes its gate.""" + env = isolated(offline, tmp_path) + made = made_in_the_mount(holder, env, "LOCAL.md") + only_in_the_mount(family, holder, env, made) + gitlink = pinned_at(holder, env) + + stderr = both_runs(holder, env, made) + + assert refusal(stderr) == local_only(made) + assert pinned_at(holder, env) == gitlink, "the gitlink did not move" + offline.assert_left_nothing() + + pushed_from_the_mount(holder / PATH, made) + pins(holder, env, made, bump(holder, env, made)) + + +def test_an_annotated_tag_of_a_commit_only_the_mount_holds_names_it( + family, holder, offline, tmp_path): + """An annotated tag made in the mount, of a commit made there and never + pushed. Its id is peeled to the commit it names, as #541 peels one, and + that commit is asked: both runs refuse `member-to-local-only`, naming the + tag as typed and the commit it names. The refusal comes before the line + saying what would be pinned, so stdout is empty in both.""" + env = isolated(offline, tmp_path) + made = made_in_the_mount(holder, env, "TAGGED.md") + only_in_the_mount(family, holder, env, made) + to = annotated(holder / PATH, env, "v-local", made) + + stderr = both_runs(holder, env, to) + + assert refusal(stderr) == local_only(to, made) + offline.assert_left_nothing() + + +@pytest.mark.parametrize("kind", ["tree", "blob"]) +def test_a_tree_or_a_blob_only_the_mount_holds_keeps_its_own_words( + holder, offline, tmp_path, kind): + """THE SPLIT OF CODES STAYS. A tree's or a blob's id from a commit only + the mount holds is in the mount alone too, and it is still named for what + it is, `member-to-not-a-commit`: reachability is asked only of a + commit.""" + env = isolated(offline, tmp_path) + made = made_in_the_mount(holder, env, "OBJECTS.md") + to = oid(holder / PATH, env, + f"{made}^{{tree}}" if kind == "tree" else f"{made}:OBJECTS.md") + + stderr = both_runs(holder, env, to) + + assert refusal(stderr)[0] == ( + f"REFUSED member-to-not-a-commit: --to {to} names a {kind} in " + f"{REPOSITORY}, not a commit") + + +def test_offline_a_commit_the_remote_tracking_refs_hold_is_pinned( + holder, offline, tmp_path): + """OFFLINE, ANSWERED FROM THE REFS THE MOUNT HAS. A commit pushed while + the remote could be reached is on a remote-tracking ref of the mount. + Then the fetch fails: both runs print the note that says so and answer + from those refs, as `bump-leg.py` does, so the dry run prints the row + and exits 0 and the real run pins the commit.""" + env = isolated(offline, tmp_path) + made = commit_in_mount( + holder, env, + lambda mount: (mount / "PUSHED.md").write_bytes(b"pushed, then " + b"offline\n")) + assert raw(holder / PATH, env, "for-each-ref", "--contains", made, + "refs/remotes/origin/").stdout != b"", ( + "premise: the push left a remote-tracking ref that contains it") + offline_from_here(holder, env, tmp_path) + + dry = bump(holder, env, made, "--dry-run") + + assert dry.returncode == 0, dry.stdout + dry.stderr + assert dry.stderr.splitlines() == [FETCH_FAILED] + assert f' commit: "{made}"' in dry.stdout.splitlines() + + real = bump(holder, env, made) + + assert real.stderr.splitlines() == [FETCH_FAILED] + pins(holder, env, made, real) + offline.assert_left_nothing() + + +def test_offline_a_commit_only_the_mount_holds_is_refused_as_maybe_stale( + family, holder, offline, tmp_path): + """OFFLINE, AND NO REF CONTAINS IT. The fetch fails and no remote-tracking + ref the mount has contains the commit: both runs refuse it + `member-to-local-only`, after the fetch's note, ending with the sentence + that says the answer came from stale refs and to fix the network or the + credential before believing it.""" + env = isolated(offline, tmp_path) + made = made_in_the_mount(holder, env, "STALE.md") + only_in_the_mount(family, holder, env, made) + offline_from_here(holder, env, tmp_path) + + stderr = both_runs(holder, env, made) + + lines = stderr.splitlines() + assert lines[0] == FETCH_FAILED + assert refusal("\n".join(lines[1:])) == local_only(made, fetched=False) + offline.assert_left_nothing() diff --git a/tests/test_family_dry_run.py b/tests/test_family_dry_run.py index 7ec2a7c1..b024492e 100644 --- a/tests/test_family_dry_run.py +++ b/tests/test_family_dry_run.py @@ -57,7 +57,7 @@ #: would test a different holder. from test_family import ( # noqa: F401 FAMILY, ORG, SPARE, family, family_module, manifest, nothing_to_do_line, - validate, + pushed_from_the_mount, validate, ) from test_family_add_excludes import empty_holder # noqa: F401 #: The pinned project and the name its renamed copy is added under (#330). @@ -201,10 +201,18 @@ def printed_row(stdout: str) -> list[str]: return row -def commit_in_mount(root: Path, env: dict, change) -> str: +def commit_in_mount(root: Path, env: dict, change, *, + push: bool = True) -> str: """A commit in `MEMBER`'s mount that `change(mount)` makes, and the mount - put back at the commit the holder pins: the new commit is in the member's - objects and nowhere else, and the holder is clean. Returns its sha.""" + put back at the commit the holder pins, with the holder clean. Returns + its sha. + + PUSHED unless `push` is False (#613): `bump --to` refuses a commit no + branch of the member's remote reaches, `member-to-local-only`, as + `bump-leg.py` refuses its twin, so a commit a test bumps to goes first to + a branch of the private remote `pushed_from_the_mount` points the mount + at. With `push=False` it is in the member's objects and nowhere else, + the state #613 refuses.""" mount = root / "members" / MEMBER pinned = raw(mount, env, "rev-parse", "HEAD").stdout.decode().strip() change(mount) @@ -212,6 +220,8 @@ def commit_in_mount(root: Path, env: dict, change) -> str: raw(mount, env, "commit", "-q", "-m", "A commit only this mount has") made = raw(mount, env, "rev-parse", "HEAD").stdout.decode().strip() raw(mount, env, "checkout", "-q", pinned) + if push: + pushed_from_the_mount(mount, made) assert raw(root, env, "status", "--porcelain=v1", "-z").stdout == b"", ( "premise: the holder is clean") return made From d845ec01061a899f860efd4d0372003923e09ea7 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 15 Oct 2026 21:52:07 +0000 Subject: [PATCH 2/2] family.py: #625 fix round 1: a test that a commit only another remote's branch holds is refused, the offline note tells the whole rule, and the docstring says a tag-only pin from add --at cannot be bumped to again (#613) R613's review of a15c6601 found the code right as ruled and asked for one test. The coordinator decided its D1 as option (a). F1. A new test in tests/test_family_bump_to_local_only.py: a commit pushed only to a second remote of the mount (`fork`, branch `forked`) is on `refs/remotes/fork/forked` and on no branch of `origin`, and both runs refuse it `member-to-local-only`. It pins ruling 1's `refs/remotes/origin/`. Mutant M7, which asks over `refs/remotes/` (any remote), passed every test before; this one kills it. F2. When the member's fetch fails, `bump`'s note said the commit "must already be in its object store". Since #613 that is half the rule. It now says the commit "must already be in its object store and on a branch of its remote as last fetched". The two tests that assert the note say the same: #541's in tests/test_family_bump_to_checks.py, and `FETCH_FAILED` in the new file. D1, option (a). `_checked_to_names_a_commit`'s paragraph on a tag whose commit is on no branch now says what follows from it. A pin `add --at` made from such a tag cannot be named by `bump --to` again until that commit is pushed to a branch: not for `nothing to do`, and not for `validate-family.py`'s repair of a digest. That is accepted as ruled (D2, option 1), since the refusal's push lets the same command through and `bump-leg.py` refuses its twin alike. Exempting a `--to` equal to the row's own pin would need a new ruling. A test holds it: `add --at` a tag-only release; `bump --to` that pin is refused by both runs with nothing moved; once the commit is pushed to a branch, the same command says `nothing to do`, exit 1. N1: an E128 continuation indent in the new test file. N2: `_checked_on_a_remote_branch`'s `repository` is annotated `str | None`, as `row.get` gives it. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 18 +++- tests/test_family_bump_to_checks.py | 3 +- tests/test_family_bump_to_local_only.py | 120 +++++++++++++++++++++--- 3 files changed, 123 insertions(+), 18 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index bf572f62..fd49d97c 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -4661,9 +4661,9 @@ def _on_a_remote_branch(member: Path, commit: str) -> bool: cwd=member)) -def _checked_on_a_remote_branch(member: Path, path: str, repository, - typed: str, commit: str, - fetched: bool) -> str: +def _checked_on_a_remote_branch(member: Path, path: str, + repository: str | None, typed: str, + commit: str, fetched: bool) -> str: """`commit`, the commit `--to` (`typed`) names, when a branch of the member's remote reaches it, or the refusal `member-to-local-only` (#613), which `_checked_to_names_a_commit` says why it makes. `typed` is not @@ -4766,6 +4766,15 @@ def _checked_to_names_a_commit(member: Path, path: str, row: dict, `bump-leg-commit-local-only`, and fetching every tag would write refs the person's member did not have, which is a new act; reachability from a tag is not the question either tool asks. + + SO A PIN `add --at` MADE FROM SUCH A TAG cannot be named by `bump --to` + again: its clone brought the tag, so the mount holds the commit, and a + re-run that said `nothing to do`, or `validate-family.py`'s own repair + of a digest that does not recompute, is `member-to-local-only` too. + Accepted as ruled (D2, option 1; D1 of #625's review, option (a)): + pushing the commit to a branch of the member lets the same command + through, and `bump-leg.py` refuses its twin alike. Exempting a `--to` + equal to the row's own pin would need a new ruling. """ repository = row.get("repository") try: @@ -5081,7 +5090,8 @@ def cmd_bump(args) -> int: except CommandFailed: fetched = False print(f" note: could not fetch {path} from its origin; the commit " - "must already be in its object store", file=sys.stderr) + "must already be in its object store and on a branch of its " + "remote as last fetched", file=sys.stderr) # WHAT `--to` NAMES IS ASKED OF THE FETCHED OBJECTS (#405, #541), before # the dry run leaves and before the checkout. From here on `commit` is # the one it returns, in both runs: an annotated tag's own id is peeled diff --git a/tests/test_family_bump_to_checks.py b/tests/test_family_bump_to_checks.py index ea27813e..e096c511 100644 --- a/tests/test_family_bump_to_checks.py +++ b/tests/test_family_bump_to_checks.py @@ -277,7 +277,8 @@ def test_a_commit_missing_after_a_failed_fetch_is_not_called_unpushed( lines = stderr.splitlines() assert lines[0] == (f" note: could not fetch {PATH} from its origin; " - "the commit must already be in its object store") + "the commit must already be in its object store and " + "on a branch of its remote as last fetched") assert refusal("\n".join(lines[1:])) == ( f"REFUSED member-to-unknown: --to {missing} is not a commit {PATH} " "holds, and its fetch from its origin failed, so whether a branch of " diff --git a/tests/test_family_bump_to_local_only.py b/tests/test_family_bump_to_local_only.py index 0cba47d7..62013b2f 100644 --- a/tests/test_family_bump_to_local_only.py +++ b/tests/test_family_bump_to_local_only.py @@ -25,6 +25,11 @@ already has, so a commit those refs contain is pinned and one they do not is refused with a sentence saying the refs may be stale. +TWO EDGES FROM #625'S REVIEW. A commit only a second remote's branch holds is +refused too: the question is asked of `origin` alone. And a pin `add --at` +made from a tag whose commit is on no branch cannot be named by `bump --to` +again until that commit is pushed to a branch; that is accepted as ruled. + The family, the holder, the offline environment and "changes nothing" are `test_family_dry_run.py`'s, imported rather than rebuilt. The fixture's own remotes are only ever read: a commit a test pushes goes to a private bare @@ -37,45 +42,57 @@ import pytest +from conftest import git, run_script #: The family fixture and its names, from the module that owns them. from test_family import ( # noqa: F401 - ORG, family, manifest, pushed_from_the_mount, validate, + FAMILY, ORG, SPARE, family, manifest, nothing_to_do_line, + pushed_from_the_mount, validate, +) +#: The holder, the offline environment and its `add`, and a copy of the +#: spare member's remote with an annotated tag on it. +from test_family_add_refusals import ( # noqa: F401 + holder, offline, refusal, tagged_remote, ) -from test_family_add_refusals import holder, offline, refusal # noqa: F401 #: Both runs over one holder, refused alike with nothing moved; an annotated #: tag, and an object's id as git resolves it. from test_family_bump_to_a_tag import annotated, both_runs, oid #: "Changes nothing", asked as bytes in the order a bump would write. -from test_family_dry_run import bump, commit_in_mount, isolated, pinned_at +from test_family_dry_run import ( + bump, commit_in_mount, isolated, pinned_at, state, +) from test_family_member_path import MEMBER, raw PATH = f"members/{MEMBER}" REPOSITORY = f"{ORG}/{MEMBER}" -#: The line both runs print first when the member's fetch fails. +#: The line both runs print first when the member's fetch fails: since #613 +#: it says both halves of the rule the commit is then held to. FETCH_FAILED = (f" note: could not fetch {PATH} from its origin; the commit " - "must already be in its object store") + "must already be in its object store and on a branch of its " + "remote as last fetched") -def local_only(to: str, commit: str = "", *, - fetched: bool = True) -> tuple[str, str]: +def local_only(to: str, commit: str = "", *, fetched: bool = True, + project: str = MEMBER) -> tuple[str, str]: """The two lines `bump --to ` is refused with when the commit it names, `commit` (`to` itself unless an annotated tag was peeled), is in the mount's objects and no branch of the member's remote reaches it; - `fetched` False when the member's fetch failed.""" + `fetched` False when the member's fetch failed. `project` is the member + bumped, `MEMBER` unless a test bumps another.""" commit = commit or to + path, repository = f"members/{project}", f"{ORG}/{project}" named = (f"--to {to}" if commit == to else f"--to {to} is an annotated tag, and the commit it names, " f"{commit},") stale = ("" if fetched else - f" The fetch of {PATH} FAILED, as the note above says, so this " + f" The fetch of {path} FAILED, as the note above says, so this " "was answered from stale remote-tracking refs: fix the network " "or the credential and run it again before believing it.") return ( f"REFUSED member-to-local-only: {named} is in the member's own object " - f"store, in {PATH}, but is reachable from no branch of {REPOSITORY}", - f"Remediation: push it to a branch of {REPOSITORY} and run the same " - f"command again; the commonest such commit was made inside {PATH} and " + f"store, in {path}, but is reachable from no branch of {repository}", + f"Remediation: push it to a branch of {repository} and run the same " + f"command again; the commonest such commit was made inside {path} and " "never pushed. A pin the rest of the world cannot fetch is a family " "the rest of the world cannot bootstrap." + stale) @@ -185,6 +202,83 @@ def test_a_tree_or_a_blob_only_the_mount_holds_keeps_its_own_words( f"{REPOSITORY}, not a commit") +def test_a_commit_only_another_remotes_branch_holds_is_refused( + holder, offline, tmp_path): + """THE RULING NAMES `origin` (#625's review, F1). A commit pushed only to + a second remote of the mount, a fork, is on a remote-tracking branch, + `refs/remotes/fork/forked`, and on no branch of the member's own remote, + the one the holder's recursive clone fetches from: pinned, it breaks + that clone with `not our ref`. Both runs refuse it + `member-to-local-only`, in the same two lines, since the question is + asked of `refs/remotes/origin/` alone, as `bump-leg.py` asks it.""" + env = isolated(offline, tmp_path) + mount = holder / PATH + made = made_in_the_mount(holder, env, "FORK.md") + fork = tmp_path / "fork.git" + raw(tmp_path, env, "init", "-q", "--bare", str(fork)) + raw(mount, env, "remote", "add", "fork", str(fork)) + raw(mount, env, "push", "-q", "fork", f"{made}:refs/heads/forked") + assert raw(mount, env, "for-each-ref", "--contains", made, + "refs/remotes/fork/").stdout != b"", ( + "premise: a remote-tracking branch of the fork contains it") + assert raw(mount, env, "for-each-ref", "--contains", made, + "refs/remotes/origin/").stdout == b"", ( + "premise: no branch of the member's own remote does") + + stderr = both_runs(holder, env, made) + + assert refusal(stderr) == local_only(made) + offline.assert_left_nothing() + + +def test_a_pin_add_at_made_from_a_tag_alone_is_refused_until_pushed( + family, holder, offline, tmp_path): + """D1 OF #625'S REVIEW, ACCEPTED AS RULED. A release commit on no branch + of the member's remote, with only its annotated tag pushed: `add --at + ` pins that commit, and the mount holds it, because its + clone brought every tag. `bump --to` the very commit pinned said + `nothing to do` before #613; now both runs refuse it + `member-to-local-only`, with nothing moved, since no branch of the + remote reaches it. The refusal's own way out works: once the commit is + pushed to a branch of the member, the same command says `nothing to + do`, exit 1.""" + env = isolated(offline, tmp_path) + remotes, at = tagged_remote(family, tmp_path, "main") + bare = remotes / f"{SPARE}.git" + release = git("rev-parse", "main", cwd=bare).stdout.strip() + git("update-ref", "refs/heads/main", "main~1", cwd=bare) + assert git("for-each-ref", "--contains", release, "refs/heads/", + cwd=bare).stdout == "", "premise: only the tag reaches it" + added = offline.add(holder, SPARE, "--at", at, remotes=remotes) + assert added.returncode == 0, added.stdout + added.stderr + row = {r["project"]: r for r in manifest(holder)["members"]}[SPARE] + assert row["pin"]["commit"] == release, "premise: add --at pinned it" + mount = holder / "members" / SPARE + assert raw(mount, env, "cat-file", "-t", release).stdout.strip() == \ + b"commit", "premise: the mount holds it, through its clone's tags" + + def bump_the_pin(*extra: str): + return run_script(FAMILY, "bump", "--family-root", str(holder), + "--member", SPARE, "--to", release, *extra, + env=env) + + before = state(holder, env, SPARE) + dry, real = bump_the_pin("--dry-run"), bump_the_pin() + + assert state(holder, env, SPARE) == before, "nothing moved" + assert dry.returncode == real.returncode == 2, real.stdout + real.stderr + assert dry.stdout == real.stdout == "" + assert dry.stderr == real.stderr + assert refusal(real.stderr) == local_only(release, project=SPARE) + + raw(mount, env, "push", "-q", "origin", f"{release}:refs/heads/release") + again = bump_the_pin() + + assert again.returncode == 1, again.stdout + again.stderr + assert nothing_to_do_line(SPARE, release) in again.stdout + offline.assert_left_nothing() + + def test_offline_a_commit_the_remote_tracking_refs_hold_is_pinned( holder, offline, tmp_path): """OFFLINE, ANSWERED FROM THE REFS THE MOUNT HAS. A commit pushed while @@ -196,7 +290,7 @@ def test_offline_a_commit_the_remote_tracking_refs_hold_is_pinned( made = commit_in_mount( holder, env, lambda mount: (mount / "PUSHED.md").write_bytes(b"pushed, then " - b"offline\n")) + b"offline\n")) assert raw(holder / PATH, env, "for-each-ref", "--contains", made, "refs/remotes/origin/").stdout != b"", ( "premise: the push left a remote-tracking ref that contains it")