diff --git a/adopt-project.py b/adopt-project.py index 5e8cfd75..266c23b9 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -5333,7 +5333,7 @@ def _checked_plan_inputs(args, source: Source, [(_flag_named(ELECTED_BY_KEY, flags_given), args.elected_by, ROOT_MANIFEST_AND_README)]) names = _names(args.project) - pins = _as_planned(declared_pin_values(args), args.pin_source) + pins = _as_planned(_pins_read_unfetched(args), args.pin_source) _check_names(naming, names, set(pins), source) # LAST here, over every value as the plan will record it: `execute` # writes them into the project's own YAML. The project's own validator @@ -6218,6 +6218,120 @@ def _write_the_plan(args, out: Path, text: str) -> None: ) from None +# --------------------------------------------------------------------------- +# `plan` never fetches into a partial clone either (#1162) +# --------------------------------------------------------------------------- +# +# `plan` reads its `--source` with git, as `check` does, and each pin's +# digest in its `--pin-source` with `git ls-tree -r` +# (`pin_digest_from_source`). In a partial clone (`git clone --filter`) +# either read fetched what the clone lacked from its promisor remote, as +# #1145 found `check` and `execute` did. Measured with git 2.43 on #1145's +# head 2c8082a2: a `--filter=blob:none --no-checkout` `--source` ran its +# promisor's `upload-pack` 26 times and took its `.promisor` files from 1 +# to 27; a `--filter=tree:0` `--pin-source`, and a `--filter=blob:none` one +# that lacked the pinned commit, each ran it once and gained a promisor +# pack before the digest was read. So `plan` runs under `_lazy_fetch_off`, +# as `check` and `execute` do, and: +# +# - its `--source` is refused by `_refuse_a_partial_clone_short` as soon +# as it is opened, as theirs is: the partial clone `check` would refuse, +# before git reads an object of it, on every git; +# - its `--pin-source` is NOT refused for being a partial clone. A digest +# is read from the pinned commit's trees, and a `--filter=blob:none` +# clone that holds the commit holds them: measured, it records the digest +# a full clone does and runs no `upload-pack`, checked out or not. Where +# git stops at an object such a clone lacks, the refusal keeps the +# reader's own `pin-source-unreadable` and says that the clone is a +# partial one and how to make it whole (`_pins_read_unfetched`): the +# reader's remediation, a clone that HAS the pinned commit, named no +# cause for a `tree:0` clone that holds the commit and lacks its trees. + +#: The variable git reads to leave out the fetch a partial clone makes for an +#: object it lacks: `shape-doctor.py`'s `NO_LAZY_FETCH` (#787, #1145). +NO_LAZY_FETCH = "GIT_NO_LAZY_FETCH" +#: The refusal `pin_digest_from_source` gives a `--pin-source` that cannot +#: answer for a pin (#339): `plan`'s own, not `check`'s +#: `PIN_SOURCE_UNREADABLE` finding. +PIN_SOURCE_REFUSED = "pin-source-unreadable" + + +@contextlib.contextmanager +def _lazy_fetch_off(): + """This process's environment with `GIT_NO_LAZY_FETCH=1` for as long as + it runs, and as it was afterwards: the doctor's `lazy_fetch_off` (#787), + over `check` and `execute` (#1145), and over `plan` (#1162). + + EVERY GIT THE COMMAND STARTS inherits it, and every git those start: + each read of the source, `plan`'s of its `--pin-source`, the throwaway + repositories `check` makes, the clones and `git filter-repo` of + `execute`. A read of an object a partial clone lacks then fails, as one + fails where the clone's remote is out of reach (measured with git 2.43: + exit 128, and no `upload-pack` ran), and each reader here already + answers a read that failed. Nothing printed for a person's own shell + carries it. + + WHICH GIT HONOURS IT: git 2.45.0 and later, and the security releases of + 2024-05-14 back to 2.39.4, whose fix for CVE-2024-32465 made + `promisor-remote.c` read it, as distributions' own builds of those do + (Ubuntu's 2.43.0-1ubuntu7.1 and later). An older git ignores it, and + there `_refuse_a_partial_clone_short` is what keeps the source whole, + but for the read `_not_a_whole_clone` makes before it, of the commit a + `source.cloned: true` plan names; a partial `--pin-source` short of the + pinned commit or its trees is fetched into there, as before #1162.""" + before = os.environ.get(NO_LAZY_FETCH) + os.environ[NO_LAZY_FETCH] = "1" + try: + yield + finally: + if before is None: + os.environ.pop(NO_LAZY_FETCH, None) + else: + os.environ[NO_LAZY_FETCH] = before + + +def _pins_read_unfetched(args) -> dict[str, dict[str, str]]: + """`declared_pin_values(args)`, read under `plan`'s `_lazy_fetch_off` + (#1162). A `--pin-source` that is a partial clone (`_promisor_remotes`) + and cannot answer for a pin without fetching keeps the reader's own + `pin-source-unreadable` and its words, and the refusal says that the + clone is a partial one, which `plan` never fetches into, how to make it + whole, and, as the source's refusal does, that a later fetch stays + filtered while the clone's `partialclonefilter` is set: a later pin to + a newer commit in a `tree:0` clone lacks its trees again. + + ONLY A READ THAT FAILED IS ASKED ABOUT. A partial clone that holds the + pinned commit and its trees answers as any clone does, and a clone that + is no partial one keeps the reader's refusal as it was. Whether it is + one is read from its config alone, which reads no object.""" + try: + return declared_pin_values(args) + except Refusal as exc: + clone = args.pin_source + remotes = ([] if exc.code != PIN_SOURCE_REFUSED or clone is None + else _promisor_remotes(clone)) + if not remotes: + raise + raise Refusal( + exc.code, + f"{exc.detail}. That clone is a partial clone (`git clone " + "--filter`), whose git fetches an object it lacks from its " + f"promisor remote ({', '.join(map(_spelled, remotes))}) on any " + "read that needs it, and `plan` never fetches into a " + "--pin-source", + f"Remediation: {PLAN_NOT_WRITTEN} Make that clone whole, then " + f"run `plan` again: in {shown_path(clone)}, run " + f"{', then '.join(map(_refetch, remotes))}, which fetches every " + "object again with no filter (git 2.36 or later) where a branch " + "it fetches holds the pinned commit. Or point --pin-source at a " + "full clone (`git clone` with no `--filter`) that has the " + "pinned commit, or drop --pin-source and let `gh api` read the " + "forge. Later fetches stay filtered while " + f"{' or '.join(map(_filter_key, remotes))} is set, so the clone " + "can lack objects again.") from exc + + +@_lazy_fetch_off() def cmd_plan(args) -> int: # A `--pin-source` that names no path, `''`, which as a `Path` is the # directory this runs in, one no `--pin` will read, and one `plan` cannot @@ -6284,6 +6398,10 @@ def cmd_plan(args) -> int: source = Source.of(named, work_root, unwritable, getattr(args, "clones", None), instead=args.tracking_branch) + # As soon as it is opened, as `check` and `execute` ask it (#1162): the + # partial clone they would refuse is refused before git reads an object + # of it, which would fetch what it lacks, and before the plan exists. + _refuse_a_partial_clone_short(source) policy = PathPolicy.load(args.path_policy or PATH_POLICY) # The project's own validator is asked inside, before #181's question. @@ -13079,7 +13197,8 @@ def _grouped_findings(stored: list[str], taken: list[str], copies: list[str], # command. So there are two layers, as the doctor has them (#683, #754, # #787): # -# - `_lazy_fetch_off`, over the whole of both commands: with +# - `_lazy_fetch_off`, over the whole of both commands, and of `plan` +# since #1162, which defines it: with # `GIT_NO_LAZY_FETCH=1`, a read of an object the source lacks fails where # it fetched, whatever reads it and whichever commit it reads, a commit # the plan names included; @@ -13095,12 +13214,9 @@ def _grouped_findings(stored: list[str], taken: list[str], copies: list[str], # `protocol.file.allow=always` outranks it, and the lazy fetch ran. NOR AN # EMPTY `GIT_ALLOW_PROTOCOL` over the whole command, which stops that fetch # too: `check` and `execute` need their transports, for the clone of the -# source, `ls-remote` and the pushes. And `plan`, which reads its source -# the same way, is not changed here. +# source, `ls-remote` and the pushes. `plan`, which reads its source the +# same way, is under both layers since #1162. -#: The variable git reads to leave out the fetch a partial clone makes for an -#: object it lacks: `shape-doctor.py`'s `NO_LAZY_FETCH` (#787, #1145). -NO_LAZY_FETCH = "GIT_NO_LAZY_FETCH" #: Refused: a source that is a partial clone and lacks an object its branches, #: tags or HEAD reach (#1145). SOURCE_PARTIAL_CLONE = "source-partial-clone" @@ -13128,38 +13244,6 @@ def _grouped_findings(stored: list[str], taken: list[str], copies: list[str], HEAD_ID = ("rev-parse", "--verify", "-q", "HEAD") -@contextlib.contextmanager -def _lazy_fetch_off(): - """This process's environment with `GIT_NO_LAZY_FETCH=1` for as long as - it runs, and as it was afterwards: the doctor's `lazy_fetch_off` (#787), - over `check` and `execute` (#1145). - - EVERY GIT THE COMMAND STARTS inherits it, and every git those start: - each read of the source, the throwaway repositories `check` makes, the - clones and `git filter-repo` of `execute`. A read of an object a partial - clone lacks then fails, as one fails where the clone's remote is out of - reach (measured with git 2.43: exit 128, and no `upload-pack` ran), and - each reader here already answers a read that failed. Nothing printed for - a person's own shell carries it. - - WHICH GIT HONOURS IT: git 2.45.0 and later, and the security releases of - 2024-05-14 back to 2.39.4, whose fix for CVE-2024-32465 made - `promisor-remote.c` read it, as distributions' own builds of those do - (Ubuntu's 2.43.0-1ubuntu7.1 and later). An older git ignores it, and - there `_refuse_a_partial_clone_short` is what keeps the source whole, - but for the read `_not_a_whole_clone` makes before it, of the commit a - `source.cloned: true` plan names.""" - before = os.environ.get(NO_LAZY_FETCH) - os.environ[NO_LAZY_FETCH] = "1" - try: - yield - finally: - if before is None: - os.environ.pop(NO_LAZY_FETCH, None) - else: - os.environ[NO_LAZY_FETCH] = before - - def _config_listed(path: Path, *args: str) -> bytes: """What `git config -z ` lists in `path`, as bytes; nothing where it lists nothing (exit 1) or cannot run. Config only: no object is read, @@ -13276,7 +13360,7 @@ def _refuse_a_partial_clone_short(source: Source) -> None: (`_promisor_remotes`) and lacks an object its branches, tags or HEAD reach (`_objects_lacking`), or whose branches, tags and HEAD git cannot walk; asked by `check` and `execute` as soon as the source is opened, - before anything else reads it (#1145). + before anything else reads it (#1145), and by `plan` (#1162). ONLY WHAT IS LACKING IS REFUSED. A partial clone given every object again (`git fetch --refetch --no-filter`) keeps its promisor config, and @@ -13346,11 +13430,11 @@ def _refuse_a_partial_clone_short(source: Source) -> None: f"the source {where} is a partial clone (`git clone --filter`) " f"{short}. Git fetches an object a partial clone lacks from its " f"promisor remote ({', '.join(map(_spelled, remotes))}) on any read " - "that needs it, and `check` and `execute` never fetch into the " - "source: `execute`'s clone of it checks out its HEAD, and its `git " - "filter-repo` reads every branch and tag, and a partial clone short " - "of one of their blobs failed it after both leg repositories were " - "made", + "that needs it, and `plan`, `check` and `execute` never fetch into " + "the source: `execute`'s clone of it checks out its HEAD, and its " + "`git filter-repo` reads every branch and tag, and a partial clone " + "short of one of their blobs failed it after both leg repositories " + "were made", remediation) @@ -18250,7 +18334,14 @@ def main(argv: list[str] | None = None) -> int: "user cannot reach (under a directory it may not " "search, or one it may not enter), which is never " "read as one that is not there, nor cloned as an " - "`org/repo`.") + "`org/repo`. A partial clone (`git clone " + "--filter`) short of an object its branches, " + "tags or HEAD reach is refused as " + "`source-partial-clone`, as `check` refuses it, " + "before anything reads it: git would fetch that " + "object into it, and `plan` never fetches into " + "the source. Run `git fetch --refetch " + "--no-filter ` in it to make it whole.") plan.add_argument("--project", required=True, help="the assembly-root name; for an in-place adoption " "it is the source repository's own name. " @@ -18341,7 +18432,15 @@ def main(argv: list[str] | None = None) -> int: "means this directory. `pin-source-not-a-clone` " "refuses any other path than a clone's top level, " "or a bare clone's, where a --pin's digest is " - "read.") + "read. A partial clone (`git clone --filter`) is " + "read where it holds the pinned commit and its " + "trees. On a git that honours `GIT_NO_LAZY_FETCH` " + "(2.45 or later, or a security release from " + "2.39.4 on), one git could read only by fetching " + "what it lacks is refused as " + "`pin-source-unreadable`, and nothing is fetched " + "into it; an older git fetches what it lacks into " + "it.") plan.add_argument("--allow-empty-leg", action="append", default=[], choices=EXTRACTED_LEGS, help="record in the plan that this leg having NO path " diff --git a/docs/cli.md b/docs/cli.md index 54f8f161..c8cd4d66 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -479,7 +479,14 @@ options: `source-unreadable`, a path this user cannot reach (under a directory it may not search, or one it may not enter), which is never read as one that is not - there, nor cloned as an `org/repo`. + there, nor cloned as an `org/repo`. A partial clone + (`git clone --filter`) short of an object its + branches, tags or HEAD reach is refused as `source- + partial-clone`, as `check` refuses it, before anything + reads it: git would fetch that object into it, and + `plan` never fetches into the source. Run `git fetch + --refetch --no-filter ` in it to make it + whole. --project PROJECT the assembly-root name; for an in-place adoption it is the source repository's own name. `execute` refuses one over 95 characters without --local-remote-dir: @@ -586,7 +593,14 @@ options: `pin-source-no-path`, and `.` means this directory. `pin-source-not-a-clone` refuses any other path than a clone's top level, or a bare clone's, where a --pin's - digest is read. + digest is read. A partial clone (`git clone --filter`) + is read where it holds the pinned commit and its + trees. On a git that honours `GIT_NO_LAZY_FETCH` (2.45 + or later, or a security release from 2.39.4 on), one + git could read only by fetching what it lacks is + refused as `pin-source-unreadable`, and nothing is + fetched into it; an older git fetches what it lacks + into it. --allow-empty-leg {spec,code} record in the plan that this leg having NO path is intended, so it may be SEEDED from the shape's diff --git a/tests/test_adopt_plan_partial_clone.py b/tests/test_adopt_plan_partial_clone.py new file mode 100644 index 00000000..cd36026f --- /dev/null +++ b/tests/test_adopt_plan_partial_clone.py @@ -0,0 +1,318 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`plan` never fetches into a partial-clone `--source` or `--pin-source` +(#1162). + +#1145 stopped `check` and `execute` fetching into a partial-clone source +(`tests/test_adopt_partial_clone_source.py`), and left `plan`. Measured on +#1145's head 2c8082a2 with git 2.43, each clone of a local origin whose +`remote.origin.uploadpack` counts its runs: + +* a `--filter=blob:none --no-checkout` `--source` (28 objects lacking): + `plan` wrote its plan after running that `upload-pack` 26 times, and the + source's `.promisor` files went from 1 to 27; +* a `--filter=blob:none` `--source` that checked its branch out (2 history + blobs lacking): `plan` wrote its plan, fetching nothing, for a source + `check` refuses and `execute` cannot split; +* a `--filter=tree:0 --no-checkout` `--pin-source`, and a + `--filter=blob:none --no-checkout` one that lacks the pinned commit: `plan` + ran that `upload-pack` once, the clone gained a promisor pack, and the + digest was read after it. + +Now `plan` runs with `GIT_NO_LAZY_FETCH=1` (`_lazy_fetch_off`), refuses the +partial `--source` `check` would refuse as `source-partial-clone` before +anything reads it (`_refuse_a_partial_clone_short`), and a partial +`--pin-source` it could read only by fetching keeps the reader's own +`pin-source-unreadable`, which says that the clone is a partial one and how +to make it whole (`_pins_read_unfetched`). A `--filter=blob:none` pin clone +that holds the pinned commit has its trees, and is read as a full clone is. + +The rows reuse #1145's `Source`: its counting `upload-pack` wrapper and its +hash of `objects/`. A pin clone runs the same wrapper, so one log counts the +runs of both. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from conftest import ADOPT, FILE_PROTOCOL, git, make_source_repo, run_script +from repo_shape import tree_digest +from test_adopt_partial_clone_source import ( # noqa: F401 - fixtures + CODE, CURE, IGNORED, LATER, REFUSED, SIDE, VARIABLE, Source, adopter, + made, no_ambient_variable, planned_once, + skip_where_git_ignores_the_variable) + +#: The refusal the pin's reader gives a `--pin-source` that cannot answer. +PIN_CODE = "pin-source-unreadable" +PIN_REFUSED = f"REFUSED {PIN_CODE}:" +#: What `_pins_read_unfetched` adds to it for a partial clone. +PARTIAL = "That clone is a partial clone (`git clone --filter`)" +#: The remediation the reader gives any clone that cannot answer. +FETCH_ALL = "`git fetch --all` first" +#: The product pinned, and the files of its repository. +PRODUCT = "openGlass" +GLASS_TREE = {"README.md": "# glass\n", "lib/a.py": "A = 1\n", + "lib/b/c.txt": "c\n"} +GLASS_EDITS = (("lib/a.py", "A = 2\n", "Two"),) +#: How each pin clone is made from the glass origin, and whether the pin is +#: `SIDE`'s commit, which no clone below takes: `cured` is fetched again +#: with no filter after its clone, as the refusal says to. +PIN_VARIANTS = { + "full": ((), False), + "blobnone": (("--filter=blob:none", "--no-checkout"), False), + "blobnone-checkout": (("--filter=blob:none",), False), + "tree0": (("--filter=tree:0", "--no-checkout"), False), + "tree0-cured": (("--filter=tree:0", "--no-checkout"), False), + "lacks-commit": (("--filter=blob:none", "--no-checkout"), True), + "full-lacks-commit": ((), True), +} + + +@pytest.fixture(scope="module") +def glass(tmp_path_factory) -> dict: + """The pinned product's origin, which lets a clone filter: two commits + on `main`, and one on `SIDE` that adds a file; with the digest each + commit's tree has, read where every object is.""" + base = tmp_path_factory.mktemp("glass") + origin = make_source_repo(base / "glass-origin", tree=GLASS_TREE, + edits=GLASS_EDITS) + git("config", "uploadpack.allowFilter", "true", cwd=origin) + main = git("rev-parse", "HEAD", cwd=origin).stdout.strip() + git("switch", "-q", "-c", SIDE, cwd=origin) + (origin / "lib" / "d.txt").write_text("d\n", encoding="utf-8") + git("add", "-A", cwd=origin) + git("commit", "-q", "-m", "Elsewhere", cwd=origin) + side = git("rev-parse", "HEAD", cwd=origin).stdout.strip() + git("switch", "-q", "main", cwd=origin) + return {"origin": origin, "main": main, "side": side, + "digests": {main: tree_digest(origin, main), + side: tree_digest(origin, side)}} + + +class PinClone: + """A `PIN_VARIANTS` clone of the glass origin beside `source`, run with + `source`'s counting `upload-pack`, so `source.runs()` counts both.""" + + def __init__(self, glass: dict, source: Source, variant: str): + flags, on_the_side = PIN_VARIANTS[variant] + self.path = source.base / PRODUCT + git(*FILE_PROTOCOL, "clone", "-q", "--single-branch", *flags, + "--no-local", glass["origin"].as_uri(), str(self.path), + cwd=source.base) + if variant.endswith("-cured"): + git(*FILE_PROTOCOL, "fetch", "-q", "--refetch", "--no-filter", + "origin", cwd=self.path) + counted = git("config", "--get", "remote.origin.uploadpack", + cwd=source.path).stdout.strip() + git("config", "remote.origin.uploadpack", counted, cwd=self.path) + self.commit = glass["side" if on_the_side else "main"] + self.digest = glass["digests"][self.commit] + self.before = self.store() + + def store(self) -> str: + """`Source.store`, of this clone's `objects/`.""" + return Source.store(SimpleNamespace(path=self.path)) + + def flags(self) -> tuple: + return ("--pin", f"{PRODUCT}@{self.commit}", + "--pin-source", str(self.path)) + + +def plan_argv(source: Source, *extra: str) -> tuple[Path, list[str]]: + """`(where the plan goes, the command line of a `plan` of `source`)`: + written beside it, its work directory there too.""" + out = source.base / "planned" / "adoption-plan.yaml" + out.parent.mkdir(exist_ok=True) + return out, ["plan", "--source", str(source.path), + "--project", "Northwind", "--org", "testorg", + "--elected-by", "Test Human", "--elected-on", "2026-09-02", + "--out", str(out), + "--work-dir", str(source.base / "plan-work"), *extra] + + +def planned(source: Source, *extra: str): + """`(where the plan goes, the run of `plan_argv`'s `plan`)`.""" + out, argv = plan_argv(source, *extra) + return out, run_script(ADOPT, *argv) + + +def skip_unless_the_variable_holds(pin: PinClone, source: Source, + glass: dict) -> None: + """#1145's `skip_where_git_ignores_the_variable`, asked of the pin + clone: a commit it lacks, `SIDE`'s, read with the variable set.""" + skip_where_git_ignores_the_variable(SimpleNamespace( + path=pin.path, planned={"side": glass["side"]}, runs=source.runs)) + + +@pytest.mark.parametrize("variant", ["nocheckout", "checkout"]) +def test_plan_refuses_a_partial_clone_source_that_lacks_objects( + made, variant): + """Base: the plan written, after 26 `upload-pack` runs and `.promisor` + files 1 -> 27 for `nocheckout`, and none for `checkout`, a source + `check` refuses. Head: refused before the plan exists, nothing + fetched.""" + source = made(variant) + assert source.lacking() > 0 + promisor = source.promisor_packs() + out, result = planned(source) + assert result.returncode == 2, result.stderr + result.stdout + assert REFUSED in result.stderr + assert "`plan`, `check` and `execute` never fetch" in result.stderr + assert CURE in result.stderr + assert not out.exists() + source.untouched() + assert source.promisor_packs() == promisor + + +@pytest.mark.parametrize("variant", ["full", "cured"]) +def test_plan_reads_a_full_clone_and_a_cured_partial_clone_as_before( + made, variant): + """A full clone, and a partial clone fetched again with no filter, lack + nothing: the plan is written, and nothing is fetched, on base and head + alike.""" + source = made(variant) + assert source.lacking() == 0 + out, result = planned(source) + assert result.returncode == 0, result.stderr + result.stdout + assert out.exists() + assert CODE not in result.stderr + result.stdout + source.untouched() + + +def test_plan_s_guard_alone_refuses_where_git_ignores_the_variable( + adopter, made, monkeypatch, capsys): + """A git that ignores `GIT_NO_LAZY_FETCH` (stood for by setting another + name) still fetches nothing: the source is refused before it is read. + Base: the plan written, after 26 `upload-pack` runs.""" + monkeypatch.setattr(adopter, "NO_LAZY_FETCH", IGNORED, raising=False) + source = made("nocheckout") + code = adopter.main(plan_argv(source)[1]) + said = capsys.readouterr() + assert code == 2, said.err + said.out + assert REFUSED in said.err + source.untouched() + assert VARIABLE not in os.environ and IGNORED not in os.environ + + +@pytest.mark.parametrize("variant", ["full", "blobnone", "blobnone-checkout", + "tree0-cured"]) +def test_plan_reads_a_pin_clone_that_holds_the_commit_and_its_trees( + glass, made, variant): + """A digest is read from the pinned commit's trees: a full clone, a + `--filter=blob:none` clone that holds the commit, checked out or not, + and a `tree:0` clone fetched again with no filter, as the refusal below + says to, each record the digest the origin has and fetch nothing, on + base and head alike.""" + source = made("full") + pin = PinClone(glass, source, variant) + out, result = planned(source, *pin.flags()) + assert result.returncode == 0, result.stderr + result.stdout + assert pin.digest in out.read_text(encoding="utf-8") + assert source.runs() == 0 + assert pin.store() == pin.before + + +@pytest.mark.parametrize("variant", ["tree0", "lacks-commit"]) +def test_plan_refuses_a_partial_pin_source_it_could_read_only_by_fetching( + glass, made, variant): + """Base: the plan written, after one `upload-pack` run that fetched + the trees, or the commit, into the pin clone, a promisor pack more. + Head: `pin-source-unreadable`, naming the partial clone, the fetch + that makes it whole, and that a later fetch stays filtered, as the + source's refusal says; no plan, nothing fetched. Skipped where this git + ignores the variable, which alone keeps such a read unfetched.""" + source = made("full") + pin = PinClone(glass, source, variant) + skip_unless_the_variable_holds(pin, source, glass) + out, result = planned(source, *pin.flags()) + assert result.returncode == 2, result.stderr + result.stdout + assert PIN_REFUSED in result.stderr + assert f"could not answer for opensoft/{PRODUCT} @ {pin.commit}" \ + in result.stderr + assert PARTIAL in result.stderr + assert "`plan` never fetches into a --pin-source" in result.stderr + assert CURE in result.stderr + assert f"{LATER}, so the clone can lack objects again." in result.stderr + assert FETCH_ALL not in result.stderr + assert not out.exists() + assert source.runs() == 0 + assert pin.store() == pin.before + + +def test_a_partial_source_is_refused_before_a_partial_pin_source_is_read( + glass, made): + """The order: the `--source` guard comes before `_checked_plan_inputs` + reads the pins. A partial `--source` that lacks objects and a `tree:0` + `--pin-source` together are refused as `source-partial-clone`, not as + `pin-source-unreadable`: no plan, and nothing fetched into either + clone, on any git, since the pin clone is never read. With the guard + after the pin read, the pin's refusal came first.""" + source = made("nocheckout") + pin = PinClone(glass, source, "tree0") + out, result = planned(source, *pin.flags()) + assert result.returncode == 2, result.stderr + result.stdout + assert REFUSED in result.stderr + assert PIN_CODE not in result.stderr + assert not out.exists() + source.untouched() + assert pin.store() == pin.before + + +def test_a_full_pin_clone_that_lacks_the_commit_is_refused_as_before( + glass, made): + """A clone that is no partial one keeps the reader's refusal as it was, + on base and head alike: nothing about a partial clone, and its own + remediation.""" + source = made("full") + pin = PinClone(glass, source, "full-lacks-commit") + out, result = planned(source, *pin.flags()) + assert result.returncode == 2, result.stderr + result.stdout + assert PIN_REFUSED in result.stderr + assert FETCH_ALL in result.stderr + assert PARTIAL not in result.stderr + assert not out.exists() + assert source.runs() == 0 + + +def test_a_folder_inside_a_partial_pin_clone_is_no_clone_as_before( + glass, made): + """Only the reader's `pin-source-unreadable` is said otherwise: a + folder inside a partial clone is `pin-source-not-a-clone`, in its own + words, though git there reads the clone's promisor config.""" + source = made("full") + pin = PinClone(glass, source, "blobnone-checkout") + flags = list(pin.flags()) + flags[-1] = str(pin.path / "lib") + out, result = planned(source, *flags) + assert result.returncode == 2, result.stderr + result.stdout + assert "REFUSED pin-source-not-a-clone:" in result.stderr + assert PARTIAL not in result.stderr + assert not out.exists() + assert source.runs() == 0 + + +def test_the_variable_is_what_keeps_a_partial_pin_source_unfetched( + adopter, glass, made, monkeypatch, capsys): + """A `tree:0` pin clone: refused with nothing fetched, and, with the + variable `plan` sets ignored, the same `plan` fetches its trees and + writes the plan. Base: the first run already fetches.""" + source = made("full") + pin = PinClone(glass, source, "tree0") + skip_unless_the_variable_holds(pin, source, glass) + _, argv = plan_argv(source, *pin.flags()) + code = adopter.main(argv) + said = capsys.readouterr() + assert source.runs() == 0 + assert code == 2, said.err + said.out + assert PIN_REFUSED in said.err + monkeypatch.setattr(adopter, "NO_LAZY_FETCH", IGNORED, raising=False) + code = adopter.main(argv) + said = capsys.readouterr() + assert code == 0, said.err + said.out + assert source.runs() >= 1 + assert VARIABLE not in os.environ and IGNORED not in os.environ +