diff --git a/scripts/family.py b/scripts/family.py index 9f2e6e39..a7660e9c 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -6541,8 +6541,9 @@ def _print_trailers(trailers: list[str]) -> 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. + origin` has just updated, every branch of `origin` whatever refspec the + mount carries and none `origin` has deleted since (#686), 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/`, @@ -6562,6 +6563,134 @@ def _on_a_remote_branch(member: Path, commit: str) -> bool: cwd=member)) +#: What `bump`'s fetch always asks of `origin` (#686): every branch, into the +#: namespace `_on_a_remote_branch` reads, forced as a remote-tracking ref is. +_ORIGIN_BRANCHES = "+refs/heads/*:refs/remotes/origin/*" + + +def _origin_fetch_refspecs(member: Path) -> list[str]: + """The refspecs `bump`'s fetch of `origin` carries (#686): every branch of + `origin`, then what the mount's own `remote.origin.fetch` keeps under + `refs/remotes/origin/`, and none of what it excludes. + + WHY THE MOUNT'S OWN ARE ADDED. `--prune` deletes every ref under + `refs/remotes/origin/` that no refspec on the command line maps a ref of + `origin` to, and a refspec on the command line replaces the configured + ones. A mount that keeps pull requests there (`+refs/pull/*/head: + refs/remotes/origin/pr/*`) lost them to a prune made for a deleted + branch, in a dry run too, where `git fetch origin` kept them, pruning + only what its own refspec maps. So each positive refspec of the mount + whose destination is under `refs/remotes/origin/` goes on the command + line as well, forced as the first is (a remote-tracking ref only mirrors + the remote, and a refspec without `+` would fail the fetch on a branch + force-pushed), and is pruned only against itself. Beyond the branches the + first refspec is there for, that asks `origin` for nothing `git fetch + origin` did not. One whose destination is outside `refs/remotes/origin/`, + a mirror's `+refs/*:refs/*` or `+refs/heads/*:refs/heads/*`, is never + added, so no refspec this fetch names writes outside it. A ref put under + `origin/` by hand, which no refspec maps, is still pruned. + + A NEGATIVE REFSPEC (`^refs/heads/wip`) IS SKIPPED. A narrowing refspec + never decides reachability (#686): a positive one that leaves a branch + out is overridden by the first refspec, as R613 p07 found it must be, and + a negative one is left out, so that it does not decide either. Git + applies the configured negative refspecs only to a fetch with none on the + command line (measured, git 2.43), so skipping is enough. Honouring it + refused a commit only the excluded branch reaches with #613's words, + "reachable from no branch of ", which are untrue there: the + branch is on the remote. + + A mount whose refspec git cannot satisfy, one that names a ref `origin` + lacks or two that map to one destination, fails this fetch as it fails + the person's own `git fetch origin`, and `bump` says so as it says any + failed fetch. One refspec of the mount's own can also clash with the + first, which that `git fetch origin` never meets: a mount whose only + refspec maps tags into `origin/` (`+refs/tags/*:refs/remotes/origin/*`), + and a remote with a branch and a tag of one name, fail this fetch with + 128 ("Cannot fetch both refs/heads/feat and refs/tags/feat to + refs/remotes/origin/feat") where `git fetch origin` exits 0. That is + left for #459's checklist. + + READ BY A LISTING, `git config --list`, not `--get-all`: the dry run's + test holds every `git config` it runs to a listing, the one form that + cannot write, and a listing exits 0 whether or not the mount has a + refspec (an `origin` with a URL and nothing else has none, and gets the + first refspec alone). A config git cannot read fails it with 128, which is + the caller's fetch failure, as the fetch's own would be. + """ + listed = run(["git", "config", "--list"], cwd=member) + configured = [value for key, _, value in + (line.partition("=") for line in listed.splitlines()) + if key == "remote.origin.fetch"] + refspecs = [_ORIGIN_BRANCHES] + for spec in (value.strip() for value in configured): + if spec.startswith("^"): + continue # a narrowing refspec never decides reachability (p07) + spec = spec if spec.startswith("+") else "+" + spec + if not spec.partition(":")[2].startswith("refs/remotes/origin/"): + continue + if spec not in refspecs: + refspecs.append(spec) + return refspecs + + +def _has_origin(member: Path) -> bool: + """Whether the member's mount has a remote named `origin`, the only one + `bump` fetches and asks (#686). Asked once its fetch has failed: a mount + whose `origin` was renamed (to `upstream`, say) or removed fails that + fetch with no network or credential at fault, and was told to fix one. + + `git remote get-url origin` exits 2 for "No such remote" and for nothing + else, so that exit is the answer "none"; any other failure is git's, + raised as `CommandFailed` and quoted, exit 2, before anything is written. + """ + try: + run(["git", "remote", "get-url", "origin"], cwd=member) + except CommandFailed as exc: + if exc.code == 2: + return False + raise + return True + + +def _origin_tag_reaches(member: Path, commit: str) -> bool: + """Whether a tag of the member's `origin` reaches `commit` (#686): a tag + the mount has whose commit contains it, which `origin` lists under the + same id. Asked only for the refusal's words, after a fetch that worked, + of a commit no branch of `origin` reaches; the rule stays branches. + + WHY IT IS ASKED. Such a commit was refused with "A pin the rest of the + world cannot fetch", which is untrue of one a tag of the remote holds: a + fresh clone brings every tag, so it holds the commit, and a fetch by sha + works. The mount's own tags cannot say it alone: a tag made inside the + mount is one only the mount has, so `git ls-remote --tags` asks `origin` + which of them it has, by object id, which names a tag's target for + ever. Only when a tag of the mount contains the commit is the remote + asked, so a commit made in the mount and never tagged asks nothing more. + A listing that fails is no answer, and the words stay the plain ones. + """ + held = set(run(["git", "for-each-ref", "--contains", commit, + "--format=%(objectname)", "refs/tags/"], + cwd=member).lower().split()) + if not held: + return False + try: + listed = run(["git", "ls-remote", "--tags", "--refs", "origin"], + cwd=member) + except CommandFailed: + return False + return any(line.split("\t", 1)[0].lower() in held + for line in listed.splitlines()) + + +def _origin_back(path: str, repository: str | None) -> str: + """What a mount with no `origin` is told to do (#686): give it back, and + why nothing else will do.""" + return (f"give {path} back a remote named `origin`, for {repository}, " + "and run the same command again: `bump` fetches the member's " + "branches from `origin` and asks no other remote") + + def _checked_on_a_remote_branch(member: Path, path: str, repository: str | None, typed: str, commit: str, fetched: bool) -> str: @@ -6578,12 +6707,40 @@ def _checked_on_a_remote_branch(member: Path, path: str, 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. + + TWO STATES THOSE WORDS WERE UNTRUE OF (#686), each said as what it is, + with the rule and the code kept. A commit a TAG of the remote reaches + (`_origin_tag_reaches`) is refused as one no branch reaches, which it + is, with the way out, a push to a branch, and without "a pin the rest of + the world cannot fetch", which a fresh clone disproves. A mount with NO + `origin` (`_has_origin`) failed its fetch with no network at fault: the + words say it has none, that whether a branch reaches the commit is + therefore not known, and to give the mount its `origin` back, since + `bump` asks no other remote, where they said the commit was on no branch + and to fix the network or the credential. """ 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},") + if not fetched and not _has_origin(member): + raise Refusal( + "member-to-local-only", + f"{named} is in the member's own object store, in {path}, but " + f"{path} has no remote named `origin`, so whether a branch of " + f"{repository} reaches it is not known", + f"Remediation: {_origin_back(path, repository)}. If the commit " + f"is not pushed yet, push it to a branch of {repository} first.") + if fetched and _origin_tag_reaches(member, commit): + raise Refusal( + "member-to-local-only", + f"{named} is in the member's own object store, in {path}, and a " + f"tag of {repository} reaches it, but no branch of {repository} " + "does", + f"Remediation: push it to a branch of {repository} and run the " + "same command again: `bump` pins only a commit a branch of the " + "member's remote reaches, and a tag is not a branch.") 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 " @@ -6651,7 +6808,10 @@ def _checked_to_names_a_commit(member: Path, path: str, row: dict, 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. + the sentence that says the answer came from stale refs. A mount with no + `origin` at all is told that instead, in this refusal and in + `member-to-unknown`'s (#686): its fetch failed with no network or + credential at fault. 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 @@ -6666,7 +6826,9 @@ def _checked_to_names_a_commit(member: Path, path: str, row: dict, 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. + tag is not the question either tool asks. Where a tag of the remote + reaches the commit, the refusal says so (#686), and still refuses: that + tag is asked for the words, never for the rule. 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 @@ -6693,6 +6855,16 @@ def _checked_to_names_a_commit(member: Path, path: str, row: dict, "fetch is a family the rest of the world cannot bootstrap. If it " "is pushed, check the commit: it belongs to the member's " "ASSEMBLY ROOT, not to one of its legs.") + if kind is None and not _has_origin(member): + raise Refusal( + "member-to-unknown", + f"--to {commit} is not a commit {path} holds, and {path} has no " + "remote named `origin` to fetch it from, so whether a branch of " + f"{repository} reaches it is not known", + f"Remediation: push it first, to a branch of {repository}, if it " + f"is not pushed yet; {_origin_back(path, repository)}. A pin the " + "rest of the world cannot fetch is a family the rest of the world " + "cannot bootstrap.") if kind is None: raise Refusal( "member-to-unknown", @@ -6990,14 +7162,36 @@ def cmd_bump(args) -> int: commit) branch = None if args.dry_run else _checked_branch(family, "bump", suggested) + # EVERY BRANCH OF `origin`, BY AN EXPLICIT REFSPEC (#686), as + # `bump-leg.py` fetches a leg: a plain `git fetch origin` obeys the + # mount's own `remote.origin.fetch`, and one narrowed by hand (a + # single-branch clone, or an `origin` with a URL and no refspec) left no + # remote-tracking ref for the branch that holds `--to`, so a pushed + # commit was refused `member-to-local-only` as on no branch. + # AND NO REF NOTHING ON `origin` BACKS (#686; #459's line 105, on Brett + # Heap's word "prune the fetch the tool already makes"): a branch deleted + # on the remote after the last fetch kept its remote-tracking ref, which + # passed the reachability check for a commit nothing on the remote may + # keep. `--prune` drops every ref under `refs/remotes/origin/` that no + # refspec of this fetch maps a ref of `origin` to, so the mount's own + # refspecs that write there ride along (`_origin_fetch_refspecs`): a pull + # request ref its configuration keeps is kept and pruned only against its + # own refspec, as `git fetch origin` did. A ref put there by hand, which + # no refspec maps, is dropped, and `origin/HEAD` stays, dangling where it + # named a deleted branch. `--no-prune-tags` keeps a `pruneTags` setting + # from reaching the mount's tags. The prune touches no local branch or + # tag. fetched = True try: - run(["git", "fetch", "-q", "origin"], cwd=member) + run(["git", "fetch", "-q", "--prune", "--no-prune-tags", "origin", + *_origin_fetch_refspecs(member)], cwd=member) except CommandFailed: fetched = False - print(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", file=sys.stderr) + why = (" from its origin" if _has_origin(member) else + ": it has no remote named `origin`") + print(f" note: could not fetch {path}{why}; the commit 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_reachability_words.py b/tests/test_family_bump_reachability_words.py new file mode 100644 index 00000000..6dfdb9ba --- /dev/null +++ b/tests/test_family_bump_reachability_words.py @@ -0,0 +1,536 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Where `family.py bump --to` refuses a commit as unreachable from the +member's remote, the refusal's reason is true of the mount in front of the +person (#686). + +FOUR STATES #613'S CHECK WAS UNTRUE OF, from R613's review of #625: + +- A FETCH REFSPEC NARROWED BY HAND (R613 p07). `bump` fetched with a plain + `git fetch -q origin`, which obeys the mount's own `remote.origin.fetch`. + Narrowed to `main`, as a single-branch clone leaves it, or absent, as an + `origin` with a URL and nothing else is, it left no remote-tracking ref for + the branch holding `--to`, so a pushed commit was refused + `member-to-local-only` as "reachable from no branch". `bump` now fetches + every branch by an explicit `+refs/heads/*:refs/remotes/origin/*`, as + `bump-leg.py` fetches a leg, and pins it; the mount's refspec is left as + the person set it. +- A BRANCH DELETED ON THE REMOTE SINCE THE LAST FETCH (#459's line 105, + issuecomment-6203972266, on Brett Heap's word relayed by lane 5 on #473 + at 13:56:50Z: "prune the fetch the tool already makes"). Its stale + remote-tracking ref counted, so a commit no ref of the remote keeps was + pinned. The same fetch now prunes, with `--no-prune-tags`: a ref under + `refs/remotes/origin/` that no refspec of the fetch maps a ref of `origin` + to goes, and never a branch or a tag of the mount. The mount's own + refspecs that write under `origin/` (pull requests, say) ride along, so + what its configuration keeps there stays. A negative one is skipped: a + narrowing refspec, positive or negative, never decides reachability. +- A COMMIT ONLY A TAG OF THE REMOTE REACHES, which the mount holds too + (R613 p03). Still refused: the rule is branches (the triage's D2, option + 1). The reason was "A pin the rest of the world cannot fetch", which a + fresh clone disproves. It now says a tag of the remote reaches it and no + branch does, and that the way out is a push to a branch. A tag only the + mount has, or a tag of the remote's of the same name at another commit, + keeps the plain words: the remote is asked by id. +- A MOUNT WITH NO `origin`, renamed to `upstream` or removed (R613 p10). + Its fetch failed with no network at fault, and it was told the stale-refs + sentence and to "fix the network or the credential". The fetch note, + `member-to-local-only` and `member-to-unknown` now say it has no remote + named `origin`, and to give it back; a mount config git cannot read, or + cannot parse at all, is git's failure, quoted. + +The family, the holder, the offline environment and "changes nothing" are +the modules' that own them, imported. Every remote a test writes is the +private one `pushed_from_the_mount` points the mount at. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +#: The family fixture, and the push every test's private remote comes from. +from test_family import family, pushed_from_the_mount # noqa: F401 +#: The holder, the offline environment, and a refusal's two lines. +from test_family_add_refusals import holder, offline, refusal # noqa: F401 +#: Both runs over one holder, refused alike with nothing moved, and an +#: annotated tag made in the mount. +from test_family_bump_to_a_tag import annotated, both_runs +#: The refusal's words, and the states #613's tests build. +from test_family_bump_to_local_only import ( + PATH, REPOSITORY, local_only, made_in_the_mount, only_in_the_mount, pins, +) +from test_family_dry_run import ( + bump, commit_in_mount, isolated, objects, pinned_at, work_tree, +) +from test_family_member_path import MEMBER, raw + +#: The note both runs print first when the mount has no remote named +#: `origin`: what failed, and why, in place of " from its origin". +NO_ORIGIN_NOTE = (f" note: could not fetch {PATH}: it has no remote named " + "`origin`; the commit must already be in its object store " + "and on a branch of its remote as last fetched") + +#: What a mount with no `origin` is told to do, in both refusals. +ORIGIN_BACK = (f"give {PATH} back a remote named `origin`, for {REPOSITORY}, " + "and run the same command again: `bump` fetches the member's " + "branches from `origin` and asks no other remote") + + +def private_remote(holder: Path) -> Path: + """Where `pushed_from_the_mount` makes the private bare remote it points + the mount's `origin` at.""" + return holder.parent / f"{holder.name}-{MEMBER}-origin.git" + + +def private_origin(holder: Path, env: dict) -> Path: + """`private_remote`, made by pushing the commit the mount stands on, the + pin, which its `main` already holds. A test then pushes to it what it + chooses, and nothing reaches the fixture's own remotes.""" + mount = holder / PATH + pushed_from_the_mount(mount, raw(mount, env, "rev-parse", "HEAD") + .stdout.decode("ascii").strip()) + return private_remote(holder) + + +def contains(repository: Path, env: dict, commit: str, prefix: str) -> bool: + """Whether a ref under `prefix` in `repository` contains `commit`.""" + return raw(repository, env, "for-each-ref", "--contains", commit, + prefix).stdout != b"" + + +def narrowed(mount: Path, env: dict, refspec: str | None, + branch: str) -> None: + """The mount's `origin` fetching only `refspec` (none at all when None), + with no remote-tracking ref left for `branch`: a single-branch clone, or + an `origin` with a URL and nothing else. (`--unset-all` exits 5 where + there is no refspec left to unset.)""" + raw(mount, env, "config", "--unset-all", "remote.origin.fetch", + check=False) + if refspec is not None: + raw(mount, env, "config", "remote.origin.fetch", refspec) + raw(mount, env, "update-ref", "-d", f"refs/remotes/origin/{branch}") + assert raw(mount, env, "for-each-ref", f"refs/remotes/origin/{branch}" + ).stdout == b"", "premise: no remote-tracking ref for it" + + +@pytest.mark.parametrize("refspec", [ + "+refs/heads/main:refs/remotes/origin/main", None], + ids=["main-only", "no-refspec"]) +def test_a_branch_a_narrowed_refspec_leaves_out_still_counts( + holder, offline, tmp_path, refspec): + """R613 p07. A commit pushed to a branch of the member's remote, in a + mount whose own refspec does not fetch that branch, was refused + `member-to-local-only` as on no branch. Both runs now fetch every + branch, so the dry run prints the row, exit 0, and the real run pins it + and the holder passes its gate, each from the narrowed state. The + mount's refspec is the person's and is left as they set it.""" + env = isolated(offline, tmp_path) + mount = holder / PATH + made = commit_in_mount( + holder, env, + lambda m: (m / "NARROW.md").write_bytes(b"a branch left out\n")) + branch = f"pushed-{made[:12]}" + assert contains(private_remote(holder), env, made, f"refs/heads/{branch}" + ), "premise: a branch of the member's remote holds it" + narrowed(mount, env, refspec, branch) + configured = raw(mount, env, "config", "--get-all", + "remote.origin.fetch", check=False).stdout + + dry = bump(holder, env, made, "--dry-run") + + assert dry.returncode == 0, dry.stdout + dry.stderr + assert dry.stderr == "" + assert f' commit: "{made}"' in dry.stdout.splitlines() + + narrowed(mount, env, refspec, branch) + pins(holder, env, made, bump(holder, env, made)) + assert raw(mount, env, "config", "--get-all", "remote.origin.fetch", + check=False).stdout == configured, "the refspec is as it was" + offline.assert_left_nothing() + + +def refs(mount: Path, env: dict) -> list[str]: + """Every ref of the mount, with the object it names.""" + return raw(mount, env, "for-each-ref", "--format=%(refname) %(objectname)" + ).stdout.decode().splitlines() + + +#: A pull-request refspec, as GitHub's and GitLab's documents give it: the +#: mount's own, keeping under `origin/` refs that no branch backs. +PULLS = "+refs/pull/*/head:refs/remotes/origin/pr/*" + + +#: A refspec of the mount that writes outside `refs/remotes/origin/`. +NOTES = "+refs/notes/*:refs/notes/*" + + +def under_origin(ref: str, *kept: str) -> bool: + """Whether a `refs()` line is under `refs/remotes/origin/` and none of + `kept`, the remote-tracking refs a test expects to survive.""" + return ref.startswith("refs/remotes/origin/") and not any( + ref.startswith(f"refs/remotes/origin/{name} ") for name in kept) + + +def test_a_branch_deleted_on_the_remote_since_the_last_fetch_no_longer_counts( + holder, offline, tmp_path): + """#459's line 105 (issuecomment-6203972266), on Brett Heap's word + relayed by lane 5 (#473, 13:56:50Z): "prune the fetch the tool already + makes". A commit pushed to a branch of the member's remote, and that + branch deleted there after the mount last fetched: the mount's + remote-tracking ref still contained the commit, so `bump` pinned it, + exit 0, with no ref of the remote keeping it. The fetch both runs make + now prunes that ref, and both refuse `member-to-local-only`, with + nothing moved. WHAT NOTHING ON THE REMOTE BACKS GOES, AND NOTHING ELSE: + the mount's own branch and tag on the commit stay, in a mount set to + prune tags, and so does `origin/HEAD` (it names `main`, which the remote + still has). The mount's own refspec keeps pull requests under `origin/`: + `pr/7`, which the remote still has, stays, and `pr/8`, which it deleted, + goes with the branch, pruned against that refspec alone; a ref put + there by hand, which no refspec maps, goes too. A refspec of the mount + that writes OUTSIDE `origin/` (`+refs/notes/*:refs/notes/*`, here) is + not put on the command line: nothing outside `origin/` is written.""" + env = isolated(offline, tmp_path) + mount = holder / PATH + made = commit_in_mount( + holder, env, + lambda m: (m / "DELETED.md").write_bytes(b"a branch deleted since\n")) + stale = f"refs/remotes/origin/pushed-{made[:12]}" + remote = private_remote(holder) + pin = pinned_at(holder, env, MEMBER) + for number in ("7", "8"): + raw(remote, env, "update-ref", f"refs/pull/{number}/head", pin) + raw(mount, env, "config", "--add", "remote.origin.fetch", PULLS) + raw(mount, env, "fetch", "-q", "origin") + raw(mount, env, "config", "--add", "remote.origin.fetch", NOTES) + raw(remote, env, "update-ref", "refs/notes/review", pin) + raw(remote, env, "update-ref", "-d", "refs/pull/8/head") + raw(remote, env, "update-ref", "-d", f"refs/heads/pushed-{made[:12]}") + raw(mount, env, "update-ref", "refs/remotes/origin/by-hand", pin) + assert not contains(remote, env, made, "refs/"), ( + "premise: no ref of the member's remote reaches it") + assert contains(mount, env, made, stale), ( + "premise: the mount's stale remote-tracking ref still does") + raw(mount, env, "branch", "mine", made) + raw(mount, env, "tag", "mine-tag", made) + raw(mount, env, "config", "remote.origin.pruneTags", "true") + before = refs(mount, env) + for name in ("pr/7", "pr/8", "by-hand"): + assert f"refs/remotes/origin/{name} {pin}" in before, ( + f"premise: the mount holds origin/{name}") + kept = [ref for ref in before if not under_origin(ref, "HEAD")] + + stderr = both_runs(holder, env, made) + + assert refusal(stderr) == local_only(made) + after = refs(mount, env) + assert [ref for ref in after if not under_origin(ref, "HEAD") + ] == kept, "no branch, no tag and not `origin/HEAD` was touched" + assert not [ref for ref in after if ref.startswith("refs/notes/")], ( + "a refspec of the mount that writes outside `origin/` is not named") + assert f"refs/remotes/origin/pr/7 {pin}" in after, ( + "the pull request the mount's own refspec keeps, and the remote " + "still has, stays") + for gone in (stale, "refs/remotes/origin/pr/8", + "refs/remotes/origin/by-hand"): + assert not [ref for ref in after if ref.startswith(gone + " ")], ( + f"{gone}, which nothing on the remote backs, is pruned") + offline.assert_left_nothing() + + +def test_a_branch_the_mounts_own_negative_refspec_excludes_still_counts( + holder, offline, tmp_path): + """A NARROWING REFSPEC NEVER DECIDES REACHABILITY, a negative one no more + than a positive one (R613 p07; lane 1's N1 on #795). The mount excludes, + with `^refs/heads/`, the branch a commit was pushed to, and has + no remote-tracking ref for it. Git applies a configured negative refspec + only to a fetch with none on the command line, so `bump`'s own refspec + fetches the branch and both runs find the commit on a branch of the + member's remote: the dry run prints the row, exit 0, and the real run + pins it. Honouring the exclusion refused it `member-to-local-only` with + "reachable from no branch of ", untrue of a branch the remote + has. The exclusion is left as the person set it.""" + env = isolated(offline, tmp_path) + mount = holder / PATH + made = commit_in_mount( + holder, env, + lambda m: (m / "EXCLUDED.md").write_bytes(b"a branch left out\n")) + branch = f"pushed-{made[:12]}" + exclusion = f"^refs/heads/{branch}" + raw(mount, env, "config", "--add", "remote.origin.fetch", exclusion) + raw(mount, env, "update-ref", "-d", f"refs/remotes/origin/{branch}") + configured = raw(mount, env, "config", "--get-all", + "remote.origin.fetch").stdout + assert exclusion in configured.decode().splitlines(), ( + "premise: the mount excludes the branch") + + dry = bump(holder, env, made, "--dry-run") + + assert dry.returncode == 0, dry.stdout + dry.stderr + assert dry.stderr == "" + assert f' commit: "{made}"' in dry.stdout.splitlines() + assert contains(mount, env, made, f"refs/remotes/origin/{branch}"), ( + "the excluded branch was fetched") + + raw(mount, env, "update-ref", "-d", f"refs/remotes/origin/{branch}") + pins(holder, env, made, bump(holder, env, made)) + assert raw(mount, env, "config", "--get-all", "remote.origin.fetch" + ).stdout == configured, "the exclusion is as it was" + offline.assert_left_nothing() + + +@pytest.mark.parametrize("typed", ["commit", "annotated-tag"]) +def test_a_commit_only_a_tag_of_the_remote_reaches_is_told_so( + holder, offline, tmp_path, typed): + """R613 p03. A commit on no branch of the member's remote, pushed with + its tag alone, as a release is: a fresh clone of the remote holds it. It + is still refused `member-to-local-only` by both runs, in the same two + lines, with nothing moved, named as typed (the tag's own id is peeled), + but the reason is the true one: a tag of the remote reaches it and no + branch does. Its way out works: pushed to a branch, it is pinned.""" + env = isolated(offline, tmp_path) + mount = holder / PATH + made = made_in_the_mount(holder, env, "RELEASE.md") + tag = annotated(mount, env, "v-release", made) + remote = private_origin(holder, env) + raw(mount, env, "push", "-q", "origin", "refs/tags/v-release") + assert not contains(remote, env, made, "refs/heads/"), ( + "premise: no branch of the member's remote reaches it") + assert contains(remote, env, made, "refs/tags/"), ( + "premise: a tag of the member's remote does") + assert not contains(mount, env, made, "refs/remotes/"), ( + "premise: no remote-tracking ref of the mount does") + to = made if typed == "commit" else tag + + stderr = both_runs(holder, env, to) + + assert refusal(stderr) == local_only(to, made, tagged=True) + assert "cannot fetch" not in stderr + offline.assert_left_nothing() + + raw(mount, env, "push", "-q", "origin", f"{made}:refs/heads/release") + pins(holder, env, made, bump(holder, env, made)) + + +def test_a_tag_of_the_same_name_at_another_commit_keeps_the_plain_words( + family, holder, offline, tmp_path): + """THE REMOTE IS ASKED BY ID, NOT BY NAME. The mount tags a commit only + it holds `v-same`, and the member's remote has a `v-same` of its own, at + the pin. That tag does not reach the commit, so both runs keep #613's + words, "a pin the rest of the world cannot fetch" included, which are + true of it; the fetch leaves the mount's own tag as it was.""" + env = isolated(offline, tmp_path) + mount = holder / PATH + made = made_in_the_mount(holder, env, "SAME.md") + raw(mount, env, "tag", "v-same", made) + remote = private_origin(holder, env) + raw(remote, env, "tag", "v-same", pinned_at(holder, env, MEMBER)) + only_in_the_mount(family, holder, env, made) + assert raw(remote, env, "cat-file", "-e", made, + check=False).returncode != 0, ( + "premise: the remote does not hold it, so its `v-same` cannot reach " + "it") + + stderr = both_runs(holder, env, made) + + assert refusal(stderr) == local_only(made) + assert raw(mount, env, "rev-parse", "v-same").stdout.decode().strip() \ + == made, "the mount's own tag is as it was" + offline.assert_left_nothing() + + +def no_origin(to: str) -> tuple[str, str]: + """The two lines `bump --to `, a commit the mount holds, is refused + with when the mount has no remote named `origin`.""" + return ( + f"REFUSED member-to-local-only: --to {to} is in the member's own " + f"object store, in {PATH}, but {PATH} has no remote named `origin`, " + f"so whether a branch of {REPOSITORY} reaches it is not known", + f"Remediation: {ORIGIN_BACK}. If the commit is not pushed yet, push " + f"it to a branch of {REPOSITORY} first.") + + +def without_origin(mount: Path, env: dict, gone: str) -> str: + """The mount's `origin` renamed to `upstream`, or removed, as `gone` + says; returns its URL, for the way back.""" + url = raw(mount, env, "remote", "get-url", "origin").stdout.decode() + if gone == "renamed": + raw(mount, env, "remote", "rename", "origin", "upstream") + else: + raw(mount, env, "remote", "remove", "origin") + assert raw(mount, env, "remote", "get-url", "origin", + check=False).returncode == 2, "premise: no remote `origin`" + return url.strip() + + +def told_no_origin(stderr: str) -> tuple[str, str]: + """The refusal after the note that the mount has no `origin`, and + nothing about a network or a credential anywhere in it.""" + lines = stderr.splitlines() + assert lines[0] == NO_ORIGIN_NOTE + assert "network" not in stderr + assert "credential" not in stderr + return refusal("\n".join(lines[1:])) + + +@pytest.mark.parametrize("gone", ["renamed", "removed"]) +def test_a_mount_with_no_origin_is_told_so_and_not_to_fix_the_network( + holder, offline, tmp_path, gone): + """R613 p10. A commit pushed to a branch of the member's remote, in a + mount whose `origin` was renamed to `upstream` (so `upstream/` + holds it) or removed. The fetch of `origin` fails, and both runs said + the commit was on no branch and to fix the network or the credential. + Now, with nothing moved, the note says the mount has no remote named + `origin`, and the refusal that whether a branch reaches the commit is + therefore not known, and to give the mount its `origin` back. That way + out works: given back, the same commit is pinned.""" + env = isolated(offline, tmp_path) + mount = holder / PATH + made = commit_in_mount( + holder, env, + lambda m: (m / "UPSTREAM.md").write_bytes(b"pushed, then renamed\n")) + url = without_origin(mount, env, gone) + if gone == "renamed": + assert contains(mount, env, made, "refs/remotes/upstream/"), ( + "premise: a remote-tracking branch of `upstream` holds it") + + stderr = both_runs(holder, env, made) + + assert told_no_origin(stderr) == no_origin(made) + offline.assert_left_nothing() + + if gone == "renamed": + raw(mount, env, "remote", "rename", "upstream", "origin") + else: + raw(mount, env, "remote", "add", "origin", url) + pins(holder, env, made, bump(holder, env, made)) + + +def test_an_id_a_mount_with_no_origin_lacks_is_not_put_down_to_access( + holder, offline, tmp_path): + """R613 p10, `member-to-unknown`'s half. An id the mount does not hold, + in a mount with no `origin`: both runs said its fetch from its origin + failed and to make the repository readable, "check `gh auth status` or + your SSH key". Now they say it has no remote named `origin` to fetch it + from, and to push it if it is not pushed and give the mount its + `origin` back.""" + env = isolated(offline, tmp_path) + without_origin(holder / PATH, env, "renamed") + missing = "deadbeef" * 5 + + stderr = both_runs(holder, env, missing) + + assert told_no_origin(stderr) == ( + f"REFUSED member-to-unknown: --to {missing} is not a commit {PATH} " + f"holds, and {PATH} has no remote named `origin` to fetch it from, " + f"so whether a branch of {REPOSITORY} reaches it is not known", + f"Remediation: push it first, to a branch of {REPOSITORY}, if it is " + f"not pushed yet; {ORIGIN_BACK}. A pin the rest of the world cannot " + "fetch is a family the rest of the world cannot bootstrap.") + assert "gh auth status" not in stderr + offline.assert_left_nothing() + + +#: A `url` with no value under the mount's `[remote "origin"]`. Git's config +#: parser reads a key with no `=` as a key with no value, so every read of +#: the config that asks no remote works; git's remote reader dies over it +#: (measured, git 2.43): `git remote get-url origin` and the fetch exit 128, +#: "error: missing value for 'remote.origin.url'" and "fatal: bad config +#: variable 'remote.origin.url' in file '' at line ". +VALUELESS_URL = b'[remote "origin"]\n\turl\n' + +#: A section header with no closing `]`, which git's config parser cannot +#: parse: every git command that reads the mount's config exits 128, "fatal: +#: bad config line in file ", the holder's own commands that +#: look into the mount among them (measured, git 2.43). +UNCLOSED_HEADER = b'[remote "origin"\n' + + +def mount_config_ending(holder: Path, env: dict, tail: bytes) -> str: + """A commit pushed from the mount, as `commit_in_mount` makes it, then + `tail` appended to the mount's own git config. Returns the commit.""" + mount = holder / PATH + made = commit_in_mount( + holder, env, lambda m: (m / "CONFIG.md").write_bytes(b"pushed\n")) + config = Path(raw(mount, env, "rev-parse", "--absolute-git-dir") + .stdout.decode().strip()) / "config" + config.write_bytes(config.read_bytes() + tail) + return made + + +def gits_own_failure(stderr: str) -> None: + """A refusal that is git's own failure, quoted, exit 128: never that the + mount has no `origin`, nor the network or a credential to fix.""" + assert "THE EXACT COMMAND WAS:\n git " in stderr + assert " exit 128\n" in stderr + assert "`origin`" not in stderr + assert "network" not in stderr + assert "credential" not in stderr + + +def test_a_mount_config_git_cannot_read_is_gits_own_failure( + holder, offline, tmp_path): + """WHETHER THE MOUNT HAS AN `origin` IS GIT'S ANSWER, never a guess. + `git remote get-url origin` exits 2 for "No such remote" alone; a mount + whose `origin` has a `url` with no value fails its fetch and that + question with 128, and both runs print git's failure, the exact command, + git's own words and exit 2, rather than telling the person the mount has + no `origin` or to fix the network. Nothing in the holder moves. + + WHY A `url` WITH NO VALUE (#816). `bump` first asks the holder whether + `family.yaml` holds a change HEAD does not, and that check's `git + ls-files -m` reads each gitlinked mount's repository, so a mount config + git cannot parse at all is refused there, before the fetch (the next + test). A `url` with no value parses: the holder's check and the mount's + `git config --list` work, and only git's remote reader, the fetch's and + `get-url`'s, dies over it, so the question is asked.""" + env = isolated(offline, tmp_path) + mount = holder / PATH + made = mount_config_ending(holder, env, VALUELESS_URL) + raw(holder, env, "ls-files", "-m", "-z") # premise: the holder reads it + asked = raw(mount, env, "remote", "get-url", "origin", check=False) + assert asked.returncode == 128, "premise: git's own failure, not 2" + said = asked.stderr.decode().strip() + assert "missing value for 'remote.origin.url'" in said + + stderr = both_runs(holder, env, made) + + assert "THE EXACT COMMAND WAS:\n git remote get-url origin\n" in stderr + assert f"--- output ---\n{said}\n--- end output ---" in stderr + gits_own_failure(stderr) + offline.assert_left_nothing() + + +def test_a_mount_config_git_cannot_parse_is_gits_own_failure_where_met( + holder, offline, tmp_path): + """The same answer where git cannot parse the mount's config at all, a + section header with no closing `]`: both runs print git's own failure, + in the same words, the exact command and exit 2, and nothing of a + missing `origin`, the network or a credential. Which command meets it + first is not this test's: since #816 the holder's `git ls-files -m` + reads the mount's repository before the fetch and fails there (git + 2.43), and every git command in the mount fails alike. Nothing in the + holder moves, as far as git can read it: `git status` in the holder and + `rev-parse` in the mount read that config too.""" + env = isolated(offline, tmp_path) + made = mount_config_ending(holder, env, UNCLOSED_HEADER) + + def held() -> dict: + return {"gitlink": pinned_at(holder, env), + "index": raw(holder, env, "ls-files", "-s", "-z").stdout, + "family.yaml": (holder / "family.yaml").read_bytes(), + "work tree": work_tree(holder), + "objects": objects(holder)} + + before = held() + + dry = bump(holder, env, made, "--dry-run") + real = bump(holder, env, made) + + assert held() == before, "nothing in the holder moved" + assert dry.returncode == real.returncode == 2, real.stdout + real.stderr + assert dry.stdout == real.stdout == "" + assert dry.stderr == real.stderr + assert "fatal: bad config line " in real.stderr + gits_own_failure(real.stderr) + offline.assert_left_nothing() diff --git a/tests/test_family_bump_to_local_only.py b/tests/test_family_bump_to_local_only.py index 62013b2f..7867db6a 100644 --- a/tests/test_family_bump_to_local_only.py +++ b/tests/test_family_bump_to_local_only.py @@ -73,17 +73,28 @@ def local_only(to: str, commit: str = "", *, fetched: bool = True, - project: str = MEMBER) -> tuple[str, str]: + project: str = MEMBER, tagged: bool = False + ) -> 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.""" + bumped, `MEMBER` unless a test bumps another. `tagged` where a tag of the + member's remote reaches the commit (#686): the reason is then that no + branch does, with no claim that nobody can fetch it.""" 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},") + if tagged: + return ( + f"REFUSED member-to-local-only: {named} is in the member's own " + f"object store, in {path}, and a tag of {repository} reaches it, " + f"but no branch of {repository} does", + f"Remediation: push it to a branch of {repository} and run the " + "same command again: `bump` pins only a commit a branch of the " + "member's remote reaches, and a tag is not a branch.") 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 " @@ -239,9 +250,9 @@ def test_a_pin_add_at_made_from_a_tag_alone_is_refused_until_pushed( 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.""" + remote reaches it, saying that its tag does (#686). 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" @@ -269,7 +280,8 @@ def bump_the_pin(*extra: str): 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) + assert refusal(real.stderr) == local_only(release, project=SPARE, + tagged=True) raw(mount, env, "push", "-q", "origin", f"{release}:refs/heads/release") again = bump_the_pin()