diff --git a/scripts/family.py b/scripts/family.py index a3eb0e08..fd49d97c 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: 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 + `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,48 @@ 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. + + 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: @@ -4710,7 +4804,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 +4815,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 +4837,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 +4991,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. """ @@ -4992,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.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_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 new file mode 100644 index 00000000..62013b2f --- /dev/null +++ b/tests/test_family_bump_to_local_only.py @@ -0,0 +1,329 @@ +# 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. + +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 +repository the mount is pointed at (`pushed_from_the_mount`). +""" + +from __future__ import annotations + +from pathlib import Path + +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 + 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, +) +#: 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, 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: 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 and on a branch of its " + "remote as last fetched") + + +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. `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 " + "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_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 + 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