Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions adopt-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,16 @@ def _pin_findings(plan: "Plan", tree: list,
# `validate-pins.py` finds a source it reads that cannot answer. `check` and
# `execute` ask this through `_entry_findings`, the one list.
#
# NOR IS SUCH A PIN'S COMMIT ASKED, and the NOTE says so (#912). What `plan`
# asks of a commit (#651), whether it is one or an annotated tag's id, `check`
# asks of the clone it recomputes the digest in (`commit_in_clone`). Where
# that clone is gone or partial, or `gh api` read the digest, it was asked
# of nothing, and the NOTE said only that the digest was not rechecked, so a
# tag's id edited into the plan passed `check` and `execute` wrote it under
# `revision_kind: commit`. The forge's `gh api graphql`, which `plan` asks
# where it has no clone, is not asked here, for the reason above, and a
# partial clone's `git` would fetch an id it lacks over the network.
#
# A DIRECTORY THAT IS NO CLONE'S TOP LEVEL is asked about FIRST (#800), before
# a partial clone's NOTE: a folder inside a clone, or a directory that is no
# clone at all, is `plan-pin-source-not-a-clone`, as `plan` refuses it
Expand Down Expand Up @@ -968,7 +978,7 @@ def _pin_digest_findings(plan: "Plan") -> tuple[list[str], list[str]]:
a digest the clone `plan` read it from recomputes otherwise at the
plan's commit, or cannot recompute there; otherwise one NOTE for `check`
to print, that the digest recomputes there, or that it was not
rechecked here and why."""
rechecked here, nor the commit asked what it names (#912), and why."""
findings: list[str] = []
notes: list[str] = []
for said, _, values in _read_pins(plan)[0]:
Expand All @@ -978,9 +988,12 @@ def _pin_digest_findings(plan: "Plan") -> tuple[list[str], list[str]]:
if not (found or why):
found = _recomputed_pin_finding(said, values)
if why:
notes.append(f"NOTE {said}: digest NOT rechecked here: {why}. The "
"root's own `validate-pins.py` recomputes it "
"wherever it can read the product.")
notes.append(f"NOTE {said}: digest NOT rechecked here, nor its "
f"`commit:` asked whether it names a commit: {why}. "
"The root's own `validate-pins.py` recomputes the "
"digest wherever it can read the product; `plan` "
"asks what the commit names where it reads the "
"digest.")
elif found:
findings.append(found)
else:
Expand Down Expand Up @@ -15219,6 +15232,12 @@ def _neutral_product_pins(pins) -> str:
from `plan` as well as from a plan edited by hand: `plan` records it
quoted, and `check` passes it. The list is a block placeholder, so
`render` writes it as it stands, and nothing else quotes it.

AND A NAME A STANDARD YAML READER TAKES FOR NO TEXT IS DOUBLE-QUOTED
HERE, though `y` writes it bare in the plan (#912): `yes`, `on`, `.5`
and `.inf` among them, which the project's reader reads back as typed
and YAML 1.1 or 1.2 reads as a bool or a float. The plan is read by
this tool alone; the manifest by any tool.
"""
if not pins:
return "[]"
Expand Down
9 changes: 9 additions & 0 deletions scripts/bump-leg.py
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,13 @@ def peeled_target(submodule: Path, repository: str, target: str) -> str:
a question asked before it finds nothing and lets the tag's id through to
the reachability check. An id the leg does not hold at all is not asked
here: it is the reachability verdict's, `bump-leg-commit-not-on-remote`.

NOR IS A TAG THE LEG HOLDS WITHOUT WHAT IT NAMES (#912): its peel cannot
be read there (git: "missing object referenced by" the tag), so the leg
cannot say what the tag names, and its id goes on, as typed, to the
same verdict as an id the leg does not hold. It was refused as "an
annotated tag that names no commit", which no tag of a commit is, and
which sent the person looking at a tag that was never wrong.
"""
kind = object_kind(submodule, target)
if kind is None or kind == "commit":
Expand All @@ -1021,6 +1028,8 @@ def peeled_target(submodule: Path, repository: str, target: str) -> str:
encoding="utf-8", errors="replace", check=False)
peeled = proc.stdout.strip().lower() if proc.returncode == 0 else ""
names = object_kind(submodule, peeled) if peeled else None
if names is None:
return target
if names == "commit":
return peeled
if kind != "tag":
Expand Down
64 changes: 53 additions & 11 deletions scripts/shape_materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,12 +520,35 @@ def neutral_pin_path(product: str) -> str:
#: `Icon\n` bare, over two lines of the plan (#199).
PLAIN_SCALAR_RE = re.compile(r"^[A-Za-z_.][A-Za-z0-9_./@:+-]*\Z")

#: WHAT A STANDARD YAML READER TAKES FOR NO TEXT (#912): of the plain scalars
#: `PLAIN_SCALAR_RE` takes, which begin with a letter, `_` or `.`, the ones
#: YAML 1.1 or YAML 1.2's core schema resolves to a bool, a null or a float.
#: The bools are YAML 1.1's (yaml.org/type/bool.html): `y`, `n`, `yes`, `no`,
#: `on` and `off`, which 1.2 dropped, and `true` and `false`, which both
#: keep, each in its lower, capitalised and upper case. The null is `null`,
#: in both and in the same three cases. The floats begin with a point:
#: `.inf` and `.nan` in their three cases, and a point before digits, as
#: YAML 1.1's pattern is written (`\.[0-9.]*`, so `.` and `..` too), and as
#: its own example `685.230_15e+03` and its readers read it, with `_` among
#: the digits (PyYAML takes `.5_` for 0.5, ruamel.yaml `._` for a float), and
#: YAML 1.2's unsigned exponent, `.5e3`. Nothing else either resolves to no
#: text is a plain scalar `PLAIN_SCALAR_RE` takes: `~` and the empty null,
#: YAML 1.1's `<<` and `=`, and an int, a timestamp or a float that begins
#: with a digit or a sign.
YAML_NOT_TEXT_RE = re.compile(
r"^(?:[yYnN]|yes|Yes|YES|no|No|NO|on|On|ON|off|Off|OFF"
r"|true|True|TRUE|false|False|FALSE|null|Null|NULL"
r"|\.(?:inf|Inf|INF|nan|NaN|NAN)"
r"|\.[0-9.]*(?:[eE][-+][0-9]+)?"
r"|\.[0-9_]+(?:[eE][-+]?[0-9]+)?)\Z")


def neutral_pin_entry(product: str) -> str:
"""One `neutral_product_pins:` entry, as `scaffold-project.py` and
`adopt-project.py execute` both write it: `product` bare where it is a
plain scalar (`PLAIN_SCALAR_RE`) that the project's reader reads back as
`product`, and `double_quoted` (#284) where it is not.
`product` and that YAML 1.1 and 1.2 read as text (`YAML_NOT_TEXT_RE`),
and `double_quoted` (#284) where it is not.

ONE SPELLING, WHICHEVER TOOL MADE THE ROOT (#924). The scaffold wrote a
product bare wherever the reader read it back, and adopt wrote it as `y`
Expand All @@ -536,9 +559,17 @@ def neutral_pin_entry(product: str) -> str:
scaffold's `0x1F` for 31 and its `1_000` for 1000. Both tools call this
one function now, and it is adopt's rule: a name that begins with a
digit or `-` is double-quoted, as `plan` quotes it in `product:`, and
`openGlass`, `007` and `yes` are written as both tools wrote them. A
bare `yes`, `on`, `.5` or `.inf` stays bare: the project's reader reads
it back as typed, though PyYAML does not (#457's L2-14, #912).
`openGlass` and `007` are written as both tools wrote them.

A NAME ONLY A STANDARD READER MISREADS IS DOUBLE-QUOTED TOO (#912,
#457's L2-14). `yes`, `no`, `on`, `off`, `.5`, `.inf` and `.nan` were
written bare, because the project's reader reads each back as typed,
and PyYAML's `safe_load` read them as True, False, 0.5, inf and nan: no
gate of the root failed, but any other tool reading the manifest read
a pin on a bool or a number. So a name YAML 1.1 or YAML 1.2's core
schema resolves to a bool, a null or a float (`YAML_NOT_TEXT_RE`) is
double-quoted whatever the project's reader makes of it, and every
other name is written as it was: `openGlass` byte for byte.

THE READER IS ASKED, as `family.py` asks it of a member's id (#259),
rather than a list of the words it takes for something else: in the two
Expand All @@ -547,16 +578,17 @@ def neutral_pin_entry(product: str) -> str:
` - - a` is a list of its own, where `id: - a` is the text (#715;
`id: -` is refused since #393). A spelling the reader refuses is read
back as nothing, so it is double-quoted too. A name it reads back bare,
`openGlass` and every other plain name, is the line it always was;
`007`, `2026`, `1.5`, `true` and `null` are double-quoted, and a
double-quoted name always reads back.
`openGlass` and every other plain name a standard reader reads as text
too, is the line it always was; `007`, `2026`, `1.5`, `true` and `null`
are double-quoted, and a double-quoted name always reads back.
"""
if PLAIN_SCALAR_RE.match(product) and all(
_reads_back(written, read) for written, read in (
if (PLAIN_SCALAR_RE.match(product)
and not YAML_NOT_TEXT_RE.match(product)
and all(_reads_back(written, read) for written, read in (
(f"key: {product}", {"key": product}),
(f"- {product}", [product]),
(f"neutral_product_pins:\n - {product}",
{"neutral_product_pins": [product]}))):
{"neutral_product_pins": [product]})))):
return product
return double_quoted(product)

Expand Down Expand Up @@ -3893,7 +3925,15 @@ def commit_in_clone(clone: Path, oid: str) -> tuple[str, str]:
names no commit, `why` as `not_a_commit` says it. And `(oid, "")` where
the clone cannot say, no object with that id or no clone there, which
the digest's reader then names in its own words
(`pin-source-unreadable`)."""
(`pin-source-unreadable`).

A TAG WHOSE TARGET THE CLONE LACKS IS ONE IT CANNOT SAY (#912). Its peel
cannot be read there (git: "missing object referenced by" the tag), so
nothing in the clone says what the tag names, and it is `(oid, "")`:
the digest's reader then finds no tree at the id and says to point
`--pin-source` at a clone that has the commit, `git fetch --all` first,
as it said before #651. It was refused as "an annotated tag that names
no commit", which no tag of a commit is."""
kind = _git_answer(clone, "cat-file", "-t", oid)
if kind in (None, COMMIT_KIND):
return oid, ""
Expand All @@ -3903,6 +3943,8 @@ def commit_in_clone(clone: Path, oid: str) -> tuple[str, str]:
f"{oid}^{{}}") or "").lower()
names = (_git_answer(clone, "cat-file", "-t", peeled) if peeled
else None)
if names is None:
return oid, ""
if names == COMMIT_KIND:
return peeled, ""
return oid, not_a_commit(kind, names)
Expand Down
27 changes: 19 additions & 8 deletions tests/test_adopt_pins.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,8 +523,12 @@ def test_a_pinned_product_passes_the_adopted_roots_own_gate(adopted_as_text):
NUMBERS = ("007", "2026", "1.5", "-1", "-0.5", "20261011")
KEYWORDS = ("true", "True", "TRUE", "false", "False", "FALSE", "null",
"Null", "NULL")
PLAIN = ("openGlass", "open-glass", "open_glass", "openGlass2", ".5", "x.y",
"_x", "Openglass")
PLAIN = ("openGlass", "open-glass", "open_glass", "openGlass2", "x.y", "_x",
"Openglass")
#: Text to the reader bare, and quoted all the same (#912): YAML 1.1 or
#: 1.2's core schema reads each as a float or a bool, as PyYAML's
#: `safe_load` reads the manifest.
STOCK_MISREAD = (".5", ".inf", "yes", "off")
#: Text to the reader bare, and quoted all the same, as `plan` quotes them in
#: `product:`: `y` writes bare only a name that begins with a letter, `_` or
#: `.`, and a YAML reader other than the project's takes `0x1F` and `1e3`
Expand All @@ -548,6 +552,8 @@ def entries_read_back(entries: str) -> object:
*(pytest.param(name, id=f"keyword-{name}") for name in KEYWORDS),
*(pytest.param(name, id=f"plain-{name}") for name in PLAIN),
*(pytest.param(name, id=f"quoted-by-y-{name}") for name in QUOTED_BY_Y),
*(pytest.param(name, id=f"stock-misread-{name}")
for name in STOCK_MISREAD),
])
def test_each_entry_reads_back_as_the_product(adopter, product):
"""Every product reads back as its text. A name `y` writes bare and the
Expand All @@ -557,7 +563,9 @@ def test_each_entry_reads_back_as_the_product(adopter, product):
reader takes for a bool or None (#181). It reaches `execute` only from a
plan edited by hand, because `plan` writes `product: true` bare and
`check` then refuses it, but the manifest reads back as the plan does,
edited or not."""
edited or not. A name `y` writes bare that YAML 1.1 or 1.2 reads as no
text, `.5` or `yes`, is double-quoted in the manifest though not in the
plan (#912)."""
entries = adopter._neutral_product_pins({product})
assert entries_read_back(entries) == [product]
if product in PLAIN:
Expand All @@ -569,7 +577,7 @@ def test_each_entry_reads_back_as_the_product(adopter, product):
def test_every_entry_together_reads_back_in_order(adopter):
"""All of them in one list, as a plan with many pins writes it: one line
each, sorted, and each read back as its text."""
every = {*NUMBERS, *KEYWORDS, *PLAIN, *QUOTED_BY_Y}
every = {*NUMBERS, *KEYWORDS, *PLAIN, *QUOTED_BY_Y, *STOCK_MISREAD}
entries = adopter._neutral_product_pins(every)
assert len(entries.splitlines()) == len(every) + 1
assert entries_read_back(entries) == sorted(every)
Expand Down Expand Up @@ -1406,10 +1414,13 @@ def test_a_plan_whose_pin_execute_cannot_write_is_found_and_refused(
PIN_SOURCE_UNREADABLE = "plan-pin-source-unreadable"
#: How `check` names the one pin these plans hold.
ENTRY = "`pins:` entry 1 ('openGlass')"
#: Where every NOTE about a pin `check` did not recheck says it is.
NOT_RECHECKED = f"NOTE {ENTRY}: digest NOT rechecked here: "
GATE_RECOMPUTES = ("The root's own `validate-pins.py` recomputes it wherever "
"it can read the product.")
#: Where every NOTE about a pin `check` did not recheck says it is, and
#: that it did not ask what its commit names either (#912).
NOT_RECHECKED = (f"NOTE {ENTRY}: digest NOT rechecked here, nor its "
"`commit:` asked whether it names a commit: ")
GATE_RECOMPUTES = ("The root's own `validate-pins.py` recomputes the digest "
"wherever it can read the product; `plan` asks what the "
"commit names where it reads the digest.")
#: A commit the stand-in does not hold.
NOT_IN_THE_CLONE = "c0ffee" * 6 + "c0ff"
#: How `plan-pin-source-unreadable` says both causes and each way out.
Expand Down
56 changes: 55 additions & 1 deletion tests/test_bump_leg_dry_run_truth.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
`--local-remote-dir` bare repository and from the leg's own `origin` alike
(both are run below). A tag whose commit is on no branch is NOT brought, and
neither is its commit: it is `bump-leg-commit-not-on-remote` in both runs, and
one test holds that. The peel is therefore asked AFTER the fetch.
one test holds that. The peel is therefore asked AFTER the fetch. A tag the
leg holds WITHOUT its commit cannot be peeled there, and gets the same verdict
(#912), not `bump-leg-target-not-a-commit`; one test holds that too.

Every case here runs the dry run AND the real run over the same root and
asserts that they give one answer. The leg's remote is a PRIVATE bare copy of
Expand All @@ -43,6 +45,7 @@
from __future__ import annotations

import re
import subprocess
import sys
from pathlib import Path

Expand Down Expand Up @@ -393,6 +396,57 @@ def test_a_tag_the_fetch_never_brings_is_not_on_the_remote_in_both_runs(
assert "no branch containing" in stderr


@pytest.fixture(scope="module")
def unpushed_tag(leg_remote, tmp_path_factory) -> dict:
"""An annotated tag of a commit pushed NOWHERE, and the tag's own bytes:
a tag the remote does not advertise, so no fetch brings it or its
commit. (A tag the remote does advertise is fetched with its commit
once its object is in the leg: git 2.43 follows a tag whose object it
holds, measured for #912.)"""
work = private_work(leg_remote, tmp_path_factory, "unpushed-tag")
git("checkout", "-q", "--detach", cwd=work)
(work / "UNPUSHED.md").write_text("# pushed nowhere\n")
git("add", "--", "UNPUSHED.md", cwd=work)
git("commit", "-qm", "A commit pushed nowhere", cwd=work)
commit = git("rev-parse", "HEAD", cwd=work).stdout.strip()
tag = tagged(work, "unpushed-w912", commit)
raw = subprocess.run(["git", "cat-file", "tag", tag], cwd=work,
capture_output=True, check=True).stdout
return {"commit": commit, "tag": tag, "raw": raw}


@pytest.mark.parametrize("via", VIA)
def test_a_tag_the_leg_holds_without_its_commit_is_not_on_the_remote(
project, leg_remote, unpushed_tag, via):
"""#912. The tag object is in the leg's own store and the commit it names
is not, so its peel cannot be read there (git: "missing object
referenced by" the tag). On main 6c861635 `peeled_target` refused it
`bump-leg-target-not-a-commit`, "an annotated tag that names no
commit", which no tag of a commit is. The leg cannot say what such a tag
names, so its id goes on as typed to the reachability verdict, as an id
the leg does not hold does: `bump-leg-commit-not-on-remote`, from both
runs, with nothing written."""
tag, commit = unpushed_tag["tag"], unpushed_tag["commit"]
made = subprocess.run(["git", "hash-object", "-t", "tag", "-w",
"--stdin"], cwd=project / "spec",
input=unpushed_tag["raw"], capture_output=True,
check=True)
assert made.stdout.decode().strip() == tag
assert holds(project, tag), "premise: the leg holds the tag"
assert not holds(project, commit), "and not its commit"
branched(project)
run = runner(project, leg_remote, via)
before = snapshot(project)

stderr = assert_one_refusal(project, run, tag,
"bump-leg-commit-not-on-remote", before)

assert f"has no branch containing {tag}" in stderr, stderr
assert "bump-leg-target-not-a-commit" not in stderr
assert "annotated tag" not in stderr
assert not holds(project, commit), "the fetch did not bring it"


# --- the branch a tag's id names, on the tracking branch --------------------

#: The branch a `git switch -c` line creates, wherever the line sits.
Expand Down
Loading
Loading