diff --git a/scripts/family.py b/scripts/family.py index 1e57e5d6..98245ee9 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -3645,6 +3645,72 @@ def _print_trailers(trailers: list[str]) -> None: print("trailers none") +def _checked_to_names_a_commit(member: Path, path: str, row: dict, + commit: str) -> None: + """`member-to-not-a-commit` for a `--to` that is 40 hex and names, in the + member's objects, something that is not a commit: an annotated tag, a + tree or a blob (#405). `bump` and `bump --dry-run` both ask it, after + the fetch and before the checkout, so the two runs make one refusal and + the holder is left byte for byte as it was. + + AN ANNOTATED TAG'S OWN ID IS 40 HEX, and it is what `git rev-parse v2` + prints for a release tag. `COMMIT_RE` passed it, the checkout PEELED it + to the commit it names, which the gitlink then recorded, and the row + recorded the tag's id as typed: `bump` exited 0 with a commit the + holder's own `validate-family.py` refused as `member-gitlink-mismatch`, + and the dry run printed the two ids side by side on its `commit` and + `gitlink` lines. + + REFUSED, NOT PEELED, as `bump-leg.py` refuses the same `--to` for a leg: + its `validate-pins.py` goes red on the tag's id in the pin and the bump + is rolled back, exit 2. The pin is the id the person typed and the + `old -> new` line is what they read, so the refusal names the commit the + tag peels to (through a tag of a tag too, `^{}`) and the re-run is + theirs. A tree's or a blob's id, or a tag of one, is named for what it + is, as `add --at` names one (`member-at-unknown`); before, both runs + failed on it in git's words, exit 2, from the dry run's resolve or the + real run's checkout. + + AN ID THE MEMBER DOES NOT HOLD is not asked here. The dry run's resolve + and the real run's checkout fail on it in git's words, exit 2, as they + always have. + """ + try: + kind = run(["git", "cat-file", "-t", commit], cwd=member) + except CommandFailed: + return + if kind == "commit": + return + try: + peeled = run(["git", "rev-parse", "-q", "--verify", f"{commit}^{{}}"], + cwd=member).lower() + names = run(["git", "cat-file", "-t", peeled], cwd=member) + except CommandFailed: + peeled, names = "", "" + repository = row.get("repository") + if names == "commit": + raise Refusal( + "member-to-not-a-commit", + f"--to {commit} is an annotated tag in {repository}, not a " + f"commit; the commit it names is {peeled}", + f"Remediation: pass that commit, `--to {peeled}`. A pin is a " + "commit or it is nothing: checking the tag out would put " + f"{peeled[:12]} in the gitlink at {path} and the tag's own id in " + "the row, which `validate-family.py` refuses as " + "`member-gitlink-mismatch`.") + if kind != "tag": + what = f"names a {kind}" + elif names in ("tree", "blob"): + what = f"is an annotated tag of a {names}" + else: + what = "is an annotated tag" + raise Refusal( + "member-to-not-a-commit", + f"--to {commit} {what} in {repository}, not a commit", + "Remediation: pass the commit itself — `git log --format=%H` in " + f"{path} lists them. A pin is a commit or it is nothing.") + + def _bump_dry_run(family: Family, row: dict, path: str, commit: str, trailers: list[str], fetched: bool) -> int: """`bump --dry-run`, entered after the member is fetched (or the fetch @@ -3884,6 +3950,10 @@ def cmd_bump(args) -> int: fetched = False print(f" note: could not fetch {path} from its origin; the commit " "must already be in its object store", file=sys.stderr) + # WHAT `--to` NAMES IS ASKED OF THE FETCHED OBJECTS (#405), before the + # dry run leaves and before the checkout: a tag, a tree or a blob is + # refused by both runs alike, with nothing in the holder moved. + _checked_to_names_a_commit(member, path, row, commit) if args.dry_run: return _bump_dry_run(family, row, path, commit, args.trailer, fetched) diff --git a/tests/test_family_bump_to_a_tag.py b/tests/test_family_bump_to_a_tag.py new file mode 100644 index 00000000..0788db97 --- /dev/null +++ b/tests/test_family_bump_to_a_tag.py @@ -0,0 +1,226 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`family.py bump --to` an id that is 40 hex and names no commit (#405). + +An annotated tag's own id is 40 hex, and it is what `git rev-parse v2` prints +for a release tag, so `COMMIT_RE` passed it. The checkout peeled it to the +commit the tag names, which the gitlink recorded, while the row recorded the +tag's id as typed: `bump` exited 0 and committed a holder whose own +`validate-family.py` reported `member-gitlink-mismatch`, exit 1, and `bump +--dry-run` printed the tag on its `commit` line and the commit on its +`gitlink` line. + +THE RULING THIS FILE HOLDS `bump` TO. A `--to` that names an annotated tag, a +tree or a blob in the member's objects is refused `member-to-not-a-commit`, +exit 2, by the real run and by its dry run alike, in the same two lines, with +nothing in the holder or the member's checkout moved. A tag of a commit (or +of a tag of one) is refused naming the commit it peels to, and the re-run +with that commit is the person's: it is what `bump-leg.py` does with the same +`--to` for a leg, whose own `validate-pins.py` goes red on the tag's id and +rolls the bump back. A tree, a blob, or a tag of either is named for what it +is. A `--to` the member does not hold at all is still git's to report, as +`test_family_dry_run.py` holds it. + +ASKED AFTER THE FETCH. A release tag is pushed to the member's remote and +reaches the member's mount only through `bump`'s own `git fetch`, which is the +issue's own flow; a check asked before the fetch finds nothing there and lets +the tag through to the checkout. One test holds that order. + +The holder, the offline environment and "changes nothing" are +`test_family_dry_run.py`'s, imported rather than rebuilt. Every tag is made +in the holder's own copy of the member's mount, or in a private bare copy of +the member's remote that the mount is pointed at: the fixture's remotes are +only ever read. +""" + +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 ORG, family, manifest, validate # noqa: F401 +from test_family_add_refusals import holder, offline, refusal # noqa: F401 +#: "Changes nothing", asked as bytes in the order a bump would write. +from test_family_dry_run import ( + BUMP_ORDER, assert_as_it_was, bump, isolated, pinned_at, state, +) +from test_family_member_path import MEMBER, older, raw + +PATH = f"members/{MEMBER}" + + +def annotated(mount: Path, env: dict, name: str, target: str) -> str: + """An ANNOTATED tag `name` on `target`, made in `mount` and nowhere else; + returns the tag object's own id, which is 40 hex and not a commit.""" + raw(mount, env, "tag", "-a", "-m", f"the tag {name}", name, target) + made = raw(mount, env, "rev-parse", name).stdout.decode("ascii").strip() + assert raw(mount, env, "cat-file", "-t", made).stdout.strip() == b"tag" + return made + + +def oid(mount: Path, env: dict, rev: str) -> str: + """The object `rev` names in `mount`, as git resolves it.""" + return raw(mount, env, "rev-parse", "--verify", rev + ).stdout.decode("ascii").strip() + + +def tag_refusal(to: str, target: str) -> tuple[str, str]: + """The two lines `bump --to ` is refused with when `to` is an + annotated tag that peels to the commit `target`.""" + return ( + f"REFUSED member-to-not-a-commit: --to {to} is an annotated tag in " + f"{ORG}/{MEMBER}, not a commit; the commit it names is {target}", + f"Remediation: pass that commit, `--to {target}`. A pin is a commit " + f"or it is nothing: checking the tag out would put {target[:12]} in " + f"the gitlink at {PATH} and the tag's own id in the row, which " + "`validate-family.py` refuses as `member-gitlink-mismatch`.") + + +def both_runs(holder: Path, env: dict, to: str) -> str: + """`bump --to ` with `--dry-run` and then for real, over the same + holder: both refused, exit 2, in the same words, with nothing written to + stdout and nothing in the holder moved. Returns the shared stderr.""" + before = state(holder, env) + + dry = bump(holder, env, to, "--dry-run") + real = bump(holder, env, to) + + # FIRST, WHATEVER THEY PRINTED: nothing moved, the checkout included. + assert_as_it_was(holder, env, before, BUMP_ORDER) + assert dry.returncode == real.returncode == 2, ( + dry.stdout + dry.stderr + real.stdout + real.stderr) + assert dry.stdout == real.stdout == "" + assert dry.stderr == real.stderr + return real.stderr + + +@pytest.mark.parametrize("nested", [False, True], + ids=["tag-of-a-commit", "tag-of-a-tag"]) +def test_bump_to_an_annotated_tag_is_refused_naming_the_commit_it_tags( + family, holder, offline, tmp_path, nested): + """THE ISSUE'S PROBE. `bump --to ` exited 0 and wrote the + tag's id into `pin.commit` over a gitlink at the commit it tags, and the + holder's own gate was red. Now both runs refuse it, name the commit the + tag peels to (through a tag of a tag as well) and move nothing; and the + re-run the refusal names pins that commit in the gitlink and the row + alike, and the gate is green.""" + env = isolated(offline, tmp_path) + mount = holder / PATH + target = older(family) + assert target != pinned_at(holder, env), "premise: a bump with a move" + to = annotated(mount, env, "v-release", target) + if nested: + to = annotated(mount, env, "v-release-again", to) + + stderr = both_runs(holder, env, to) + + assert refusal(stderr) == tag_refusal(to, target) + offline.assert_left_nothing() + + again = bump(holder, env, target) + + assert again.returncode == 0, again.stdout + again.stderr + row = {r["project"]: r for r in manifest(holder)["members"]}[MEMBER] + assert row["pin"]["commit"] == target == pinned_at(holder, env) + assert validate(holder).returncode == 0 + + +def test_a_release_tag_only_the_fetch_brings_is_refused_after_the_fetch( + family, holder, offline, tmp_path): + """THE ISSUE'S OWN FLOW, AND THE ORDER IT NEEDS. The member releases: a + new commit and an annotated tag on it, both pushed, after the holder was + made. Neither is in the member's mount until `bump` fetches, so the + question is asked of what the fetch brought, after it: asked before, it + found no such object, let `--to` through, and the checkout and the row + split the tag from its commit again. + + THE REAL RUN GOES FIRST, so no dry run's fetch has handed the mount the + tag before it. Both runs then refuse in the same two lines and move + nothing, and the re-run with the commit the refusal names pins it in the + gitlink and the row alike.""" + env = isolated(offline, tmp_path) + mount = holder / PATH + # A PRIVATE COPY of the member's remote, which the mount is pointed at: + # the fixture's own remote is read here and never written to. + remote = tmp_path / f"{MEMBER}-private.git" + raw(tmp_path, env, "clone", "-q", "--bare", "--", + str(family["remotes"] / f"{MEMBER}.git"), str(remote)) + work = tmp_path / f"{MEMBER}-release" + raw(tmp_path, env, "clone", "-q", "--", str(remote), str(work)) + (work / "RELEASE.md").write_bytes(b"release 2\n") + raw(work, env, "add", "--", "RELEASE.md") + raw(work, env, "commit", "-q", "-m", "Release 2") + target = oid(work, env, "HEAD") + to = annotated(work, env, "v2", target) + raw(work, env, "push", "-q", "origin", "HEAD:refs/heads/main", + "refs/tags/v2") + raw(mount, env, "remote", "set-url", "origin", str(remote)) + for name in (target, to): + assert raw(mount, env, "cat-file", "-e", name, + check=False).returncode != 0, ( + f"premise: {name} reaches the mount only through the fetch") + before = state(holder, env) + + real = bump(holder, env, to) + + assert_as_it_was(holder, env, before, BUMP_ORDER) + assert real.returncode == 2, real.stdout + real.stderr + assert real.stdout == "" + assert refusal(real.stderr) == tag_refusal(to, target) + assert oid(mount, env, f"{to}^{{commit}}") == target, ( + "the fetch brought the tag, and the refusal was made after it") + + dry = bump(holder, env, to, "--dry-run") + + assert_as_it_was(holder, env, before, BUMP_ORDER) + assert dry.returncode == 2, dry.stdout + dry.stderr + assert dry.stdout == "" + assert dry.stderr == real.stderr + offline.assert_left_nothing() + + again = bump(holder, env, target) + + assert again.returncode == 0, again.stdout + again.stderr + row = {r["project"]: r for r in manifest(holder)["members"]}[MEMBER] + assert row["pin"]["commit"] == target == pinned_at(holder, env) + assert validate(holder).returncode == 0 + + +#: Each id that is in the member's objects and is not a commit's, how a test +#: makes it from a commit of the member, and what the refusal calls it. +NOT_COMMITS = { + "tree": (lambda mount, env, target: oid(mount, env, f"{target}^{{tree}}"), + "names a tree"), + "blob": (lambda mount, env, target: oid(mount, env, + f"{target}:project.yaml"), + "names a blob"), + "tag-of-a-tree": (lambda mount, env, target: annotated( + mount, env, "v-tree", oid(mount, env, f"{target}^{{tree}}")), + "is an annotated tag of a tree"), + "tag-of-a-blob": (lambda mount, env, target: annotated( + mount, env, "v-blob", oid(mount, env, f"{target}:project.yaml")), + "is an annotated tag of a blob"), +} + + +@pytest.mark.parametrize("make, said", NOT_COMMITS.values(), ids=NOT_COMMITS) +def test_bump_to_a_tree_a_blob_or_a_tag_of_one_is_refused_by_name( + family, holder, offline, tmp_path, make, said): + """A tree's or a blob's id, or an annotated tag of one, is 40 hex and in + the member's objects too. Both runs failed on it in git's own words, the + dry run's resolve and the real run's checkout, so the two runs did not + even print the same refusal. Now both name it for what it is, as `add + --at` does, in the same two lines, and move nothing.""" + env = isolated(offline, tmp_path) + to = make(holder / PATH, env, older(family)) + assert len(to) == 40, "premise: 40 hex, which `COMMIT_RE` passes" + + stderr = both_runs(holder, env, to) + + assert refusal(stderr) == ( + f"REFUSED member-to-not-a-commit: --to {to} {said} in " + f"{ORG}/{MEMBER}, not a commit", + "Remediation: pass the commit itself — `git log --format=%H` in " + f"{PATH} lists them. A pin is a commit or it is nothing.") + offline.assert_left_nothing()