From 6a1ad4e6beec002ef3eb50f7b3f45f1b67088000 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 11:04:46 +0000 Subject: [PATCH 1/3] WIP (usage checkpoint 2026-10-17; untested on these bytes): tests/test_pin_source_top_level.py, the failing tests for a --pin-source that is no clone's top level (#800) The tests are written first and have not run yet. The fix in scripts/shape_materialize.py and adopt-project.py, the help texts and docs/cli.md are not written yet. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- tests/test_pin_source_top_level.py | 317 +++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 tests/test_pin_source_top_level.py diff --git a/tests/test_pin_source_top_level.py b/tests/test_pin_source_top_level.py new file mode 100644 index 00000000..3f8127f2 --- /dev/null +++ b/tests/test_pin_source_top_level.py @@ -0,0 +1,317 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A `--pin-source` must be a clone's top level (#800). + +`declared_pin_values`, which `adopt-project.py plan` and `scaffold-project.py` +share, reads a `--pin`'s digest with `tree_digest` IN the `--pin-source`, and +`git ls-tree -r` there lists only the part of the tree under the folder it +runs in. On main bf1f099b nothing asked whether the path was a clone's top +level: `plan --pin openGlass@ --pin-source glass/sub` recorded the +digest of `sub/` alone, the scaffold's `--dry-run` printed the same, and +`check` recomputed it there and said `plan ok` (#457's L1-82). A folder the +commit does not hold, inside a partial clone, recorded the sha256 of nothing, +and `check` passed it with the partial-clone NOTE (L1-04). + +Now git is asked first. A working tree's top level, a linked worktree's +among them, has an empty `rev-parse --show-prefix` outside the git +directory, and a bare clone's git directory is the path itself. Any other +path is `pin-source-not-a-clone`, naming the top level git finds, or saying +that it finds none; `check` asks a recorded path the same question before a +partial clone's NOTE and finds `plan-pin-source-not-a-clone`, which +`execute` refuses before any leg. What a top level digests is unchanged. + +NO NETWORK AND NO GITHUB: the product is a local stand-in, and `gh` is the +stub of `test_adopt_pins.py`, which logs each call. No refusal here is +preceded by a `gh` call. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from conftest import (ADOPT, ORG, SCAFFOLD, WINDOWS_SKIP, git, planned, + refused_before_any_leg, run_script) +from test_adopt_pins import ( # noqa: F401 + ENTRY, PIN_FILE, PLANNED, adopter, gh_calls, gh_env, read_from, + with_pins, +) +from test_pin_source_unreadable import RUNS, adoptable, made, run # noqa: F401 +from repo_shape import Refusal, load_yaml, parse_yaml, tree_digest + +NOT_A_CLONE = "pin-source-not-a-clone" +PLAN_NOT_A_CLONE = "plan-pin-source-not-a-clone" +DRY_RUN = "--dry-run" +#: The stand-ins' own names, each used by more than one case below. +GLASS, PARTIAL, BARE = "openGlass", "openGlass-partial", "openGlass.git" + +#: What the refusal says to pass where git names no top level to pass. +NO_TOP_LEVEL = ("Remediation: pass --pin-source the top level of a clone of " + "the pinned product (`git rev-parse --show-toplevel` prints " + "it in that clone's working tree), or drop --pin-source and " + "let `gh api` read the forge.") + +#: Each `--pin-source` that is no clone's top level, under the stand-ins' +#: folder: `(the path, how the refusal says why, the path that says it)`. +REFUSED = [ + pytest.param(f"{GLASS}/sub", "folder", GLASS, id="a-folder-of-a-clone"), + pytest.param(f"{PARTIAL}/sub", "folder", PARTIAL, + id="a-folder-of-a-partial-clone"), + pytest.param(f"{GLASS}/untracked", "folder", GLASS, + id="an-untracked-folder"), + pytest.param(f"{PARTIAL}/notaclone", "folder", PARTIAL, + id="an-untracked-folder-of-a-partial-clone"), + pytest.param(f"{BARE}/refs", "bare-folder", BARE, + id="a-folder-of-a-bare-clone"), + pytest.param(f"{GLASS}/.git", "git-directory", f"{GLASS}/.git", + id="a-git-directory"), + pytest.param("plain", "no-clone", None, id="no-clone"), +] +#: Each clone's top level, all of one tree. +TOP_LEVELS = [ + pytest.param(GLASS, id="a-clone"), + pytest.param(BARE, id="a-bare-clone"), + pytest.param("openGlass-worktree", id="a-linked-worktree"), + pytest.param(PARTIAL, id="a-partial-clone"), +] + + +@pytest.fixture(scope="module") +def stand_ins(tmp_path_factory) -> SimpleNamespace: + """A stand-in for `openGlass` holding `README.md`, `sub/a.txt` and + `lib/b.py`, as the issue's `glass` does, with an untracked folder in + it; a bare clone of it, a linked worktree of it, a clone marked partial + by its config alone (nothing is fetched) with an untracked folder of + its own, and a folder that is no clone.""" + base = tmp_path_factory.mktemp("top-level").resolve() + glass = base / GLASS + glass.mkdir() + git("init", "-q", "-b", "main", cwd=glass) + for name, body in (("README.md", b"# openGlass\n"), ("sub/a.txt", b"a\n"), + ("lib/b.py", b"b = 1\n")): + (glass / name).parent.mkdir(exist_ok=True) + (glass / name).write_bytes(body) + git("add", "-A", cwd=glass) + git("commit", "-qm", "seed", cwd=glass) + commit = git("rev-parse", "HEAD", cwd=glass).stdout.strip() + (glass / "untracked").mkdir() + (glass / "untracked" / "notes.txt").write_bytes(b"not committed\n") + git("clone", "-q", "--bare", str(glass), str(base / BARE), cwd=base) + git("worktree", "add", "-q", "--detach", str(base / "openGlass-worktree"), + commit, cwd=glass) + partial = base / PARTIAL + git("clone", "-q", str(glass), str(partial), cwd=base) + for setting in (("core.repositoryformatversion", "1"), + ("extensions.partialClone", "origin"), + ("remote.origin.promisor", "true")): + git("config", *setting, cwd=partial) + (partial / "notaclone").mkdir() + (base / "plain").mkdir() + return SimpleNamespace(base=base, commit=commit, + digest=tree_digest(glass, commit)) + + +def why(kind: str, named: str) -> str: + """How the refusal, and `check`'s finding, say why a path is no clone's + top level, naming `named`: all of it, or its head where git's own words + follow (`no-clone`).""" + return { + "folder": (f"it is a folder inside the clone at {named}, and a digest " + "read there covers only what the commit holds under that " + "folder"), + "bare-folder": f"it is a folder inside the bare clone at {named}", + "git-directory": (f"it is inside {named}, the git directory of a " + "clone, where git finds no top level"), + "no-clone": "git finds no clone there: ", + }[kind] + + +def has_a_top_level(kind: str) -> bool: + """Whether the refusal names a top level to pass as `--pin-source`.""" + return kind in ("folder", "bare-folder") + + +def assert_refused(result, stand_ins, where: str, kind: str, + named: str | None) -> None: + """`result` is `pin-source-not-a-clone` for `where`, exit 2: the whole + refusal, or, where git's own words follow its head, the head and the + remediation.""" + assert result.returncode == 2, result.stdout + result.stderr + named = str(stand_ins.base / named) if named else "" + head = (f"REFUSED {NOT_A_CLONE}: --pin-source {stand_ins.base / where} is " + f"not a clone's top level: {why(kind, named)}") + if has_a_top_level(kind): + assert result.stderr == ( + f"{head}\nRemediation: pass --pin-source {named}, the clone's top " + "level, where a pin's digest is read over its commit's whole " + "tree.\n"), result.stderr + else: + assert result.stderr.startswith(head), result.stderr + assert result.stderr.endswith(f"\n{NO_TOP_LEVEL}\n"), result.stderr + assert result.stderr.count("REFUSED ") == 1, result.stderr + + +# --- `plan` and the scaffold, `--dry-run` and the real run ------------------- + +@pytest.mark.parametrize("kind", RUNS) +@pytest.mark.parametrize(("where", "said", "named"), REFUSED) +def test_a_path_that_is_no_clone_s_top_level_is_refused( + kind, where, said, named, tmp_path, adoptable, stand_ins, gh_env): + """L1-82 and L1-04, and git's other two answers, through every way in: + exit 2, the one refusal naming the top level git finds, nothing made, + and no `gh` call.""" + result = run(kind, tmp_path, adoptable, str(stand_ins.base / where), + commit=stand_ins.commit, env=gh_env) + assert_refused(result, stand_ins, where, said, named) + assert made(kind, tmp_path) == [], "a refusal comes before anything exists" + assert gh_calls(gh_env) == "" + + +@pytest.mark.parametrize("kind", RUNS) +@pytest.mark.parametrize("where", TOP_LEVELS) +def test_a_top_level_is_read_as_it_always_was(kind, where, tmp_path, + adoptable, stand_ins, gh_env): + """A clone's top level, a bare clone, a linked worktree and a partial + clone each read the one digest `repo_shape.tree_digest` reads at the + clone's top level, which is what every one of them read before.""" + result = run(kind, tmp_path, adoptable, str(stand_ins.base / where), + commit=stand_ins.commit, env=gh_env) + assert result.returncode == 0, result.stdout + result.stderr + assert NOT_A_CLONE not in result.stdout + result.stderr + digest = stand_ins.digest + if kind == "adopt-plan": + pin = load_yaml(tmp_path / "plan.yaml")["pins"][0] + assert pin["tree_sha256"] == digest + elif kind == "scaffold-run": + written = git("show", f"main:{PIN_FILE}", + cwd=tmp_path / "remotes" / "MedxGlass.git").stdout + assert parse_yaml(written)["tree_sha256"] == digest + else: + assert f"tree {digest[:12]}" in result.stdout, result.stdout + assert gh_calls(gh_env) == "" + + +def scaffold_on_github(base: Path, stand_ins, where: str, env: dict): + """The scaffold's `--dry-run` with no `--local-remote-dir`, which asks + GitHub about its three repositories once the pins are read.""" + return run_script(SCAFFOLD, "--org", ORG, "--project", "MedxGlass", + "--elected-by", "Test Human", + "--elected-on", "2026-09-02", + "--pin", f"openGlass@{stand_ins.commit}", + "--pin-source", str(stand_ins.base / where), + "--work-dir", str(base / "work"), DRY_RUN, env=env) + + +@WINDOWS_SKIP # the stub `gh` is a `#!` script; Windows executes none +def test_the_scaffold_refuses_before_it_asks_github(tmp_path, stand_ins, + gh_env): + """Against GitHub the dry run asks `gh` about its repositories after it + reads the pins (the control, a top level, does), so the refusal comes + before any `gh` call.""" + refused = scaffold_on_github(tmp_path, stand_ins, f"{GLASS}/sub", gh_env) + assert refused.returncode == 2, refused.stdout + refused.stderr + assert refused.stderr.startswith(f"REFUSED {NOT_A_CLONE}: ") + assert gh_calls(gh_env) == "" + scaffold_on_github(tmp_path, stand_ins, GLASS, gh_env) + assert gh_calls(gh_env) != "", "the control never reached GitHub" + + +@pytest.mark.skipif(os.name == "nt", + reason="needs a path holding a control character, which " + "a Windows file name cannot hold") +def test_a_path_holding_a_control_character_is_named_spelled( + tmp_path, adoptable, stand_ins, gh_env): + """The folder and the top level git finds are each named as Python's + `repr` spells them, as every `--pin-source` refusal names a path that + is not printable: no raw ESC, and no line but the tool's own.""" + clone = tmp_path / "gl\x1b[31mass\nREFUSED forged-line: not the tool\r" + (clone / "sub").mkdir(parents=True) + git("init", "-q", cwd=clone) + result = run("scaffold-dry-run", tmp_path, adoptable, str(clone / "sub"), + commit=stand_ins.commit, env=gh_env) + assert result.returncode == 2, result.stdout + result.stderr + assert "\x1b" not in result.stderr, result.stderr + assert result.stderr.startswith( + f"REFUSED {NOT_A_CLONE}: --pin-source {str(clone / 'sub')!r} is not " + f"a clone's top level: {why('folder', repr(str(clone)))}\n"), \ + result.stderr + assert result.stderr.count("REFUSED") == 1, result.stderr + + +@pytest.mark.parametrize("tool", [(SCAFFOLD,), (ADOPT, "plan")], + ids=["scaffold", "adopt-plan"]) +def test_each_tool_names_the_refusal_in_its_help(tool): + """`--pin-source`'s help names the refusal, as it names the others.""" + result = run_script(*tool, "--help", env={"COLUMNS": "1000"}) + assert result.returncode == 0, result.stdout + result.stderr + assert re.search(rf"`{NOT_A_CLONE}`", result.stdout), result.stdout + + +# --- `check` asks a recorded path the same question -------------------------- + +def recorded_on_main(path: Path, stand_ins) -> str: + """The digest `plan` on main recorded from `path`: whatever `git + ls-tree` lists there, or, where git cannot answer, the clone's own.""" + try: + return tree_digest(path, stand_ins.commit) + except Refusal: + return stand_ins.digest + + +@pytest.mark.parametrize(("where", "said", "named"), REFUSED) +def test_check_finds_a_recorded_path_that_is_no_clone_s_top_level( + adopter, monkeypatch, source_repo, tmp_path, stand_ins, gh_env, + where, said, named): + """A plan `plan` wrote on main from such a path, with the digest it read + there: `check` passed it, recomputing the same number or, inside a + partial clone, NOTEing that it did not ask. It is now a finding, asked + before that NOTE, and `execute` refuses it before any leg.""" + path = stand_ins.base / where + plan = planned(source_repo, tmp_path, PLANNED) + with_pins(plan, read_from(path, stand_ins.commit, + recorded_on_main(path, stand_ins))) + checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + assert checked.returncode == 1, checked.stdout + checked.stderr + lines = [line for line in checked.stderr.splitlines() + if line.startswith("FINDING ")] + assert len(lines) == 1, checked.stderr + shown = str(stand_ins.base / named) if named else "" + assert lines[0].startswith( + f"FINDING {PLAN_NOT_A_CLONE}: {ENTRY} records that `plan` read its " + f"digest from {path}, which is not a clone's top level: " + f"{why(said, shown)}"), lines[0] + fix = (f"re-run `plan` with `--pin-source {shown}`, its top level." + if has_a_top_level(said) else "re-run `plan` with `--pin " + "opensoft/openGlass@<40 hex commit>`, which reads the digest " + "there (from `--pin-source ` with no network, or `gh " + "api`) and records both, as `scaffold-project.py --pin` takes " + "it.") + assert lines[0].endswith( + f". `plan` refuses such a --pin-source as `{NOT_A_CLONE}`: {fix}"), \ + lines[0] + assert f"NOTE {ENTRY}" not in checked.stdout, checked.stdout + refusal = refused_before_any_leg(adopter, monkeypatch, plan, tmp_path) + assert refusal.code == PLAN_NOT_A_CLONE + assert gh_calls(gh_env) == "" + + +@pytest.mark.parametrize("where", TOP_LEVELS) +def test_check_asks_a_recorded_top_level_as_it_always_did( + source_repo, tmp_path, stand_ins, gh_env, where): + """The controls: the digest recomputes at a clone's top level, a bare + clone's and a linked worktree's, and a partial clone is NOTEd as not + asked, as before; `check` passes each.""" + path = stand_ins.base / where + plan = planned(source_repo, tmp_path, PLANNED) + with_pins(plan, read_from(path, stand_ins.commit, stand_ins.digest)) + checked = run_script(ADOPT, "check", "--plan", str(plan), env=gh_env) + assert checked.returncode == 0, checked.stdout + checked.stderr + said = (f"digest NOT rechecked here: the clone at {path} is a partial " + "clone" if where == PARTIAL else + f"digest recomputes at {stand_ins.commit[:12]} in the clone " + f"`plan` read it from, {path}.") + assert f"NOTE {ENTRY}: {said}" in checked.stdout, checked.stdout + assert gh_calls(gh_env) == "" From a41d13a85f70163255d90911e3d4116634cc3bc2 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 13:48:24 +0000 Subject: [PATCH 2/3] scripts/shape_materialize.py and adopt-project.py: a `--pin-source` that is no clone's top level is refused `pin-source-not-a-clone`, and `check` finds a recorded one before a partial clone's NOTE, so a folder inside a clone no longer records the digest of part of its tree, or of nothing (#800) `tree_digest` runs `git ls-tree -r` in the `--pin-source`, and there git lists only what is under the folder it runs in. On main bf1f099b, `plan --pin-source glass/sub` recorded the digest of `sub/` alone, the scaffold's `--dry-run` printed it, and `check` recomputed it and said `plan ok` (#457's L1-82). A folder the commit does not hold, inside a partial clone, recorded the sha256 of nothing and passed `check` with the partial-clone NOTE (L1-04). - `_digest_from_pin_source` asks git, after its two `stat` checks and before any digest, whether the path is a clone's top level (`pin_source_not_top_level`). A working tree's top level, a linked worktree's included, has an empty `rev-parse --show-prefix` outside the git directory; a bare clone's `--absolute-git-dir` is the path itself. Anything else is `pin-source-not-a-clone`, naming the top level git finds ("pass --pin-source "), or saying git finds none. `pin_digest_from_source` lets that one refusal through instead of wrapping it as `pin-source-unreadable`. - `check` asks a recorded `local-clone ()` the same question first, before `_why_not_rechecked`'s partial-clone NOTE, and finds `plan-pin-source-not-a-clone`, which `execute` refuses before any leg. - Both tools' `--pin-source` help names the refusal; docs/cli.md is regenerated. - tests/test_pin_source_unreadable.py: the `not-a-clone` row and the spelled-reason test now use a `git init` clone that lacks the commit, which is still `pin-source-unreadable` with git's own reason. What a top level digests is unchanged, and scripts/repo_shape.py is not touched. Tests: tests/test_pin_source_top_level.py (48). Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 56 ++++++++++++++-- docs/cli.md | 7 +- scaffold-project.py | 5 +- scripts/shape_materialize.py | 99 ++++++++++++++++++++++++++++- tests/test_pin_source_top_level.py | 6 +- tests/test_pin_source_unreadable.py | 23 ++++--- 6 files changed, 175 insertions(+), 21 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 4e36ec55..dd2f4c71 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -311,6 +311,9 @@ #: How `plan`'s refusals of a `--pin-source` begin their remediation. PLAN_NOT_WRITTEN = "no plan was written." from repo_shape import SHA256_RE, repo_basename # noqa: E402 +# Whether a recorded `--pin-source` is a clone's top level, asked as `plan` +# asks it (#800). +from shape_materialize import pin_source_not_top_level # noqa: E402 # One reading of an `origin`, shared with `shape-doctor.py` (#521). from repo_shape import origin_is_a_path, url_repository # noqa: E402 @@ -335,6 +338,9 @@ PIN_DIGEST_MISMATCH = "plan-pin-digest-mismatch" PIN_SOURCE_UNREADABLE = "plan-pin-source-unreadable" PIN_NOT_A_COMMIT = "plan-pin-not-a-commit" +#: And about a clone `plan` read a digest from that is no clone's top level, +#: which `plan` now refuses as `pin-source-not-a-clone` (#800). +PIN_SOURCE_NOT_A_CLONE = "plan-pin-source-not-a-clone" #: How `plan` records where it read a digest (`_as_planned`): from a clone on #: this machine, `--pin-source` as typed, or from the forge. LOCAL_CLONE_RE = re.compile(r"local-clone \((?P.+)\)") @@ -693,10 +699,17 @@ def _pin_findings(plan: "Plan", tree: list) -> list[str]: # directory `check` or `execute` runs in, as `plan` read it from the one it # ran in, and every sentence naming one says where that is # (`_read_from_here`). Anything else at the path that cannot answer -- a -# commit it lacks, a directory that is no clone, a file -- is -# `plan-pin-source-unreadable`, as `validate-pins.py` finds a source it reads -# that cannot answer. `check` and `execute` ask this through -# `_entry_findings`, the one list. +# commit it lacks, a file -- is `plan-pin-source-unreadable`, as +# `validate-pins.py` finds a source it reads that cannot answer. `check` and +# `execute` ask this through `_entry_findings`, the one list. +# +# 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 +# `pin-source-not-a-clone`. In a folder `git ls-tree` lists only what is +# under it, so the digest recomputed there was the wrong one `plan` read, +# and `check` said it recomputes; inside a partial clone it said only that +# it was not asked. The question reads no object, so it reads no network. #: Why a pin's digest `gh api` read was not rechecked. NOT_RECHECKED_GH = "`plan` read it with `gh api`, and `check` reads no network" @@ -807,6 +820,30 @@ def _recomputed_pin_finding(said: str, values: dict) -> str | None: f"(`neutral-pin-digest-mismatch`): {_pin_fix(repository)}.") +def _not_a_top_level_finding(said: str, how: str, + repository: str) -> str | None: + """The FINDING for a pin whose `digest_source:` names a directory that is + no clone's top level (#800), asked as `plan` asks a `--pin-source` + (`pin_source_not_top_level`); None for a top level, and for a path that + is no directory or that cannot be entered, which the questions after + this one name as they always did.""" + clone = _recorded_clone(how) + try: + found = (pin_source_not_top_level(Path(clone)) + if clone is not None and Path(clone).is_dir() else None) + except OSError: + return None + if found is None: + return None + why, top = found + fix = (f"re-run `plan` with `--pin-source {_shown(str(top))}`, its top " + "level" if top is not None else _pin_fix(repository)) + return (f"FINDING {PIN_SOURCE_NOT_A_CLONE}: {said} records that `plan` " + f"read its digest from {_shown(clone)}{_read_from_here(clone)}, " + f"which is not a clone's top level: {why}. `plan` refuses such a " + f"--pin-source as `pin-source-not-a-clone`: {fix}.") + + def _pin_digest_findings(plan: "Plan") -> tuple[list[str], list[str]]: """`(findings, notes)` for each pin `execute` would write: a FINDING for a digest the clone `plan` read it from recomputes otherwise at the @@ -817,8 +854,10 @@ def _pin_digest_findings(plan: "Plan") -> tuple[list[str], list[str]]: notes: list[str] = [] for said, _, values in _read_pins(plan)[0]: how = values["PIN_DIGEST_SOURCE"] - why = _why_not_rechecked(how) - found = None if why else _recomputed_pin_finding(said, values) + found = _not_a_top_level_finding(said, how, values["PIN_REPOSITORY"]) + why = None if found else _why_not_rechecked(how) + 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 " @@ -13288,7 +13327,10 @@ def main(argv: list[str] | None = None) -> int: "Given with no --pin to read it, the flag is " "refused as `pin-source-unused`; an empty value " "is refused as `pin-source-no-path`, and `.` " - "means this directory.") + "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.") 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 ad2cfa9b..d71575c7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -366,7 +366,9 @@ options: --pin and no --referent-chain to read it, it is refused as `pin-source-unused`; an empty value is refused as `pin-source-no-path`, and `.` means this - directory. + directory. A --pin's digest is read only at a clone's + top level, or a bare clone's: any other path is + refused as `pin-source-not-a-clone`. --work-dir WORK_DIR where the working trees are built (default: a temporary directory) --no-next-steps leave out the closing NEXT STEPS (the clone, the cd @@ -516,6 +518,9 @@ options: Given with no --pin to read it, the flag is refused as `pin-source-unused`; an empty value is refused as `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. --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/scaffold-project.py b/scaffold-project.py index 2806c24d..ea2b8e2e 100755 --- a/scaffold-project.py +++ b/scaffold-project.py @@ -378,7 +378,10 @@ def main(argv: list[str] | None = None) -> int: "--pin and no --referent-chain to read it, it " "is refused as `pin-source-unused`; an empty " "value is refused as `pin-source-no-path`, and " - "`.` means this directory.") + "`.` means this directory. A --pin's digest is " + "read only at a clone's top level, or a bare " + "clone's: any other path is refused as " + "`pin-source-not-a-clone`.") parser.add_argument("--work-dir", type=Path, default=None, help="where the working trees are built " "(default: a temporary directory)") diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 8e032b39..c2472126 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -75,6 +75,9 @@ # How a `--pin-source` that is not printable is named, the one spelling the # scaffold's chain WARNING gives it too (#610). from repo_shape import shown_path # noqa: E402 +# What git answers about where a `--pin-source` is, and why, in its own +# words, where it cannot (#800). +from repo_shape import git_out # noqa: E402 # What the Free-plan secrets hint goes by (#660): GitHub's three # visibilities, and the hint's own words, which stay in the pinned copy. from repo_shape import FREE_PLAN_HINT, VISIBILITY_CHOICES # noqa: E402 @@ -2921,6 +2924,8 @@ def pin_digest_from_source(source: Path, commit: str, repository: str) -> str: try: return _digest_from_pin_source(source, commit) except (Refusal, OSError) as exc: + if isinstance(exc, Refusal) and exc.code == PIN_SOURCE_NOT_A_CLONE: + raise # its own refusal, which names the top level (#800) why = (exc.detail if isinstance(exc, Refusal) else exc.strerror or str(exc)) named = shown_source(source) @@ -2992,8 +2997,10 @@ def shown_source(source) -> str: def _digest_from_pin_source(source: Path, commit: str) -> str: """`tree_digest` over `source`, with a path that is no directory said - in `validate-pins.py`'s words (#339). Any other `OSError`, from `stat()` - or from git's `cwd=`, is left for `pin_digest_from_source` to name.""" + in `validate-pins.py`'s words (#339), and a directory that is no + clone's top level refused `pin-source-not-a-clone` before anything is + digested (#800). Any other `OSError`, from `stat()` or from git's + `cwd=`, is left for `pin_digest_from_source` to name.""" try: mode = source.stat().st_mode except (FileNotFoundError, NotADirectoryError): @@ -3001,9 +3008,97 @@ def _digest_from_pin_source(source: Path, commit: str) -> str: if not stat.S_ISDIR(mode): raise Refusal("pin-source-not-a-directory", "it is a file, not a directory") + _refuse_unless_a_clone_top_level(source) return tree_digest(source, commit) +# --------------------------------------------------------------------------- +# A `--pin-source` is a clone's top level (#800) +# --------------------------------------------------------------------------- +# +# `tree_digest` runs `git ls-tree -r` IN the `--pin-source`, and there, with +# no `--full-tree`, git lists only the part of the tree under the folder it +# runs in. So a folder inside a clone was read as a clone: `glass/sub` +# recorded the digest of `sub/` alone, a folder the commit does not hold +# recorded the sha256 of nothing, and `adopt-project.py plan` and the +# scaffold, `--dry-run` alike, wrote it with no word; `check` recomputed the +# same number there, or, inside a partial clone, NOTEd that it did not ask. +# +# SO GIT IS ASKED FIRST, before any digest is read: a working tree's top +# level, a linked worktree's among them, has an empty `rev-parse +# --show-prefix` outside the git directory, and a bare clone's git directory +# is the path itself. Any other path is `pin-source-not-a-clone`, naming the +# top level git finds, or saying that it finds none. What a top level digests +# is unchanged, byte for byte, and `repo_shape.tree_digest`, a pinned shape +# copy, is not changed. `adopt-project.py check` asks a recorded path the +# same question (`pin_source_not_top_level`) before a partial clone's NOTE. + +#: The refusal of a `--pin-source` that is no clone's top level. +PIN_SOURCE_NOT_A_CLONE = "pin-source-not-a-clone" +#: What it says to pass where git names no top level to pass. +NO_TOP_LEVEL_REMEDIATION = ( + "Remediation: pass --pin-source the top level of a clone of the pinned " + "product (`git rev-parse --show-toplevel` prints it in that clone's " + "working tree), or drop --pin-source and let `gh api` read the forge.") + + +def _rev_parse(source: Path, *asked: str) -> str: + """What `git rev-parse ` prints in `source`, its last line break + dropped and a path in it as `sys.argv` holds one. A git that fails is + `git_out`'s refusal, naming the folder and git's reason, spelled.""" + printed = git_out(["rev-parse", *asked], cwd=source, binary=True) + return os.fsdecode(printed).removesuffix("\n") + + +def pin_source_not_top_level(source: Path) -> tuple[str, Path | None] | None: + """None where `source` is a clone's top level (#800). Otherwise `(why, + top)`: why it is not, and the top level git finds for it, or None where + git finds none to pass. A `source` `subprocess` cannot enter raises the + `OSError` of `cwd=`, which is the caller's to name. + + The answers come in one question where they can: `--show-prefix` is + printed last, so a folder whose name holds a line break is read whole.""" + try: + bare, inside, prefix = _rev_parse( + source, "--is-bare-repository", "--is-inside-git-dir", + "--show-prefix").split("\n", 2) + if bare == "true": + top = Path(_rev_parse(source, "--absolute-git-dir")) + if os.path.samefile(top, source): + return None + return (f"it is a folder inside the bare clone at " + f"{shown_source(top)}", top) + if inside == "true": + gitdir = _rev_parse(source, "--absolute-git-dir") + return (f"it is inside {shown_source(gitdir)}, the git directory " + "of a clone, where git finds no top level", None) + if not prefix: + return None + top = Path(_rev_parse(source, "--show-toplevel")) + except Refusal as exc: + return f"git finds no clone there: {exc.detail}", None + return (f"it is a folder inside the clone at {shown_source(top)}, and a " + "digest read there covers only what the commit holds under that " + "folder", top) + + +def _refuse_unless_a_clone_top_level(source: Path) -> None: + """`pin-source-not-a-clone` for a `source` that is no clone's top level + (`pin_source_not_top_level`), naming the top level to pass instead.""" + found = pin_source_not_top_level(source) + if found is None: + return + why, top = found + raise Refusal( + PIN_SOURCE_NOT_A_CLONE, + f"--pin-source {shown_source(source)} is not a clone's top level: " + f"{why}", + NO_TOP_LEVEL_REMEDIATION if top is None else + f"Remediation: pass --pin-source {shown_source(top)}, the clone's " + "top level, where a pin's digest is read over its commit's whole " + "tree.") + + def _resolved_pin_source(source: Path) -> Path: """`source` made absolute, or AS TYPED when it cannot be (#339). diff --git a/tests/test_pin_source_top_level.py b/tests/test_pin_source_top_level.py index 3f8127f2..405e7fcb 100644 --- a/tests/test_pin_source_top_level.py +++ b/tests/test_pin_source_top_level.py @@ -188,7 +188,7 @@ def test_a_top_level_is_read_as_it_always_was(kind, where, tmp_path, elif kind == "scaffold-run": written = git("show", f"main:{PIN_FILE}", cwd=tmp_path / "remotes" / "MedxGlass.git").stdout - assert parse_yaml(written)["tree_sha256"] == digest + assert parse_yaml(written)["digests"]["tree_sha256"] == digest else: assert f"tree {digest[:12]}" in result.stdout, result.stdout assert gh_calls(gh_env) == "" @@ -238,7 +238,9 @@ def test_a_path_holding_a_control_character_is_named_spelled( f"REFUSED {NOT_A_CLONE}: --pin-source {str(clone / 'sub')!r} is not " f"a clone's top level: {why('folder', repr(str(clone)))}\n"), \ result.stderr - assert result.stderr.count("REFUSED") == 1, result.stderr + lines = result.stderr.splitlines() + assert [line for line in lines if line.startswith("REFUSED")] \ + == lines[:1], result.stderr @pytest.mark.parametrize("tool", [(SCAFFOLD,), (ADOPT, "plan")], diff --git a/tests/test_pin_source_unreadable.py b/tests/test_pin_source_unreadable.py index 177e8c94..568ab90a 100644 --- a/tests/test_pin_source_unreadable.py +++ b/tests/test_pin_source_unreadable.py @@ -1,9 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 """A `--pin-source` that cannot answer is `pin-source-unreadable` (#339). -`pin_digest_from_source` caught only `Refusal`. A source that is a directory -but no clone is git's own failure, a `Refusal`, and was always the refusal -`pin-source-unreadable` with its remediation, exit 2. A source +`pin_digest_from_source` caught only `Refusal`. A clone that cannot answer +for the commit is git's own failure, a `Refusal`, and was always the refusal +`pin-source-unreadable` with its remediation, exit 2. (A directory that is no +clone at all is `pin-source-not-a-clone` since #800, asked before any digest, +in `tests/test_pin_source_top_level.py`.) A source `subprocess.run` cannot enter at all never reaches git: `FileNotFoundError` for one that is not there, `NotADirectoryError` for a file, `PermissionError` for one under a parent this user cannot search, all raised @@ -160,14 +162,16 @@ def __init__(self, typed: str, named: str | None = None, pytest.param("source-not-searchable", marks=SEARCH_DENIED), "name-too-long", pytest.param("symlink-loop", marks=SYMLINKS), - "not-a-clone", + "lacks-the-commit", ]) def unreadable(request, tmp_path): """Each way a source fails to answer, built under `tmp_path`. `missing`, `a-file` and `parent-not-searchable` are the issue's table; the rest are its neighbours, the other ways `stat()` or git's `cwd=` - can raise; `not-a-clone` is the refusal that always held, unchanged. + can raise; `lacks-the-commit`, a clone with no such commit, is the + refusal that always held, unchanged. (A directory that is no clone is + `pin-source-not-a-clone` since #800.) `relative-missing` is typed relative to the working directory the tool runs in and is named absolute, as the not-a-clone refusal always named its source. @@ -213,6 +217,7 @@ def unreadable(request, tmp_path): said=os.strerror(errno.ELOOP)) else: (tmp_path / "plain").mkdir() + git("init", "-q", cwd=tmp_path / "plain") source = Unreadable(str(tmp_path / "plain"), git_says=True) if locked is not None: locked.chmod(0) @@ -248,7 +253,7 @@ def assert_refused(result: subprocess.CompletedProcess, kind: str, base: Path, def test_a_pin_source_that_cannot_answer_is_pin_source_unreadable( kind, unreadable, tmp_path, adoptable): """The issue's table, and its neighbours, through every way in. The one - case that held before is `not-a-clone`, git's own failure, whose + case that held before is `lacks-the-commit`, git's own failure, whose sentence is git's and is asserted as the part of it that does not depend on where the repository is kept.""" result = run(kind, tmp_path, adoptable, unreadable.typed, @@ -382,11 +387,13 @@ def test_a_source_holding_a_control_character_is_named_spelled( @pytest.mark.parametrize("kind", ["scaffold-dry-run", "scaffold-run"]) def test_git_s_own_reason_names_the_source_spelled_too(kind, tmp_path, adoptable): - """A directory that is no clone is git's own failure, and git's reason - names the directory it ran in: spelled there as in the refusal's head.""" + """A clone that cannot answer for the commit is git's own failure, and + git's reason names the directory it ran in: spelled there as in the + refusal's head.""" typed = str(tmp_path / ("plain\x1b[31mRED\nREFUSED forged-line: this " "line was not printed by the tool\r")) Path(typed).mkdir() + git("init", "-q", cwd=Path(typed)) result = run(kind, tmp_path, adoptable, typed) assert_one_refusal_of_the_tools(result, typed) assert result.stderr.startswith( From 321b526fb957ece7744c58dd94b1597a203e9968 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 19:17:17 +0000 Subject: [PATCH 3/3] scripts/shape_materialize.py and tests/test_pin_source_unreadable.py: the git-directory reason names the path as a Path, and three comments say git's own failure, so the refusal no longer spells a git directory with forward slashes on Windows, and #339's comments no longer read as the new code (#800) Review r800's findings 1 and 3, as the lane-1 coordinator took them: - pin_source_not_top_level's git-directory branch wraps git's --absolute-git-dir in Path, as its bare and folder branches do. On Windows git prints forward slashes; this changes no byte on POSIX. - tests/test_pin_source_unreadable.py: three comments that said "the not-a-clone refusal" for #339's old case now say "git's own failure". Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 --- scripts/shape_materialize.py | 2 +- tests/test_pin_source_unreadable.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index c2472126..e829d459 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -3069,7 +3069,7 @@ def pin_source_not_top_level(source: Path) -> tuple[str, Path | None] | None: return (f"it is a folder inside the bare clone at " f"{shown_source(top)}", top) if inside == "true": - gitdir = _rev_parse(source, "--absolute-git-dir") + gitdir = Path(_rev_parse(source, "--absolute-git-dir")) return (f"it is inside {shown_source(gitdir)}, the git directory " "of a clone, where git finds no top level", None) if not prefix: diff --git a/tests/test_pin_source_unreadable.py b/tests/test_pin_source_unreadable.py index 568ab90a..97f6f90b 100644 --- a/tests/test_pin_source_unreadable.py +++ b/tests/test_pin_source_unreadable.py @@ -19,9 +19,10 @@ Both tools reach the one function through `declared_pin_values`, so every case runs three ways: `scaffold-project.py --dry-run`, the same without it, and `adopt-project.py plan`. Each must give exit 2 and the refusal NAMING the -source and the reason, with the remediation the not-a-clone refusal already -had, no traceback, and nothing created: no remote directory and no work -directory for the scaffold, no plan file for adopt. The reasons are the +source and the reason, with the remediation git's own failure +(`lacks-the-commit`) already had, no traceback, and nothing created: no +remote directory and no work directory for the scaffold, no plan file for +adopt. The reasons are the sentences `validate-pins.py` gives the same sources (#295, #312): "no such directory", "it is a file, not a directory", and the system's own for any other `OSError`. @@ -56,7 +57,7 @@ NO_SUCH_DIRECTORY = "no such directory" A_FILE = "it is a file, not a directory" -#: What every refusal is told to do, as the not-a-clone refusal always said it. +#: What every refusal is told to do, as git's own failure always said it. REMEDIATION = ("Remediation: point --pin-source at a clone of that repository " "that HAS the pinned commit (`git fetch --all` first), or drop " "--pin-source and let `gh api` read the forge.") @@ -173,7 +174,7 @@ def unreadable(request, tmp_path): refusal that always held, unchanged. (A directory that is no clone is `pin-source-not-a-clone` since #800.) `relative-missing` is typed relative to the working directory the tool - runs in and is named absolute, as the not-a-clone refusal always named + runs in and is named absolute, as git's own failure always named its source. """ case = request.param