diff --git a/adopt-project.py b/adopt-project.py index 9fa6e65a..dec1d4a3 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -26,7 +26,9 @@ A leg path over a path it keeps in the root itself is a NOTE, not a refusal: the answer is the person's to change. check validates a plan against the source: each entry path bound for - a leg one `execute` will extract, every path covered exactly + a leg one `execute` will extract, no symlink in the source with + an empty target (Linux cannot make one, so a clone there checks + none out, whatever the plan answers), every path covered exactly once, no unresolved legs, leg names conforming to the naming policy, each leg's mount path one `execute` can mount (`spec`, `legs/spec`; never `spec/` or `./spec`, a name Git or @@ -36,6 +38,7 @@ into the root, `shape/`, or a source path the plan keeps there), no path kept in the root where the shape writes (a plain file at a directory it writes into, `.github`, `contracts` or `scripts`, + the source's own `project.yaml`, where it writes its manifest, or the source's own path at a copy it writes under `shape/`, in any case), and the source's own submodules kept on one leg with their `.gitmodules`, registered there and never in the root, each @@ -2075,6 +2078,8 @@ def _print_plan_report(args, source: Source, tree: list, entries: list, print(line) for line in _kept_collision_notes(args, entries, tree): print(line) + for line in _empty_symlink_notes(tree): + print(line) unresolved = [e for e in entries if e.leg is None] print(f"\nplan written to {out}") if unresolved: @@ -2472,6 +2477,25 @@ def _leg_findings(plan: Plan, tree: list | None) -> list[str]: # its copy goes to `shape/`, which must then be free: nothing kept AT # it, no kept directory there, and no kept FILE on the way (#262). # +# THE MANIFEST HAS NO COPY THAT STANDS IN FOR IT (#305). `project.yaml` is +# the one path where the root holding the source's own is refused outright, +# not asked about its copy: `shape-doctor.py`, the root's validators and +# `make bootstrap` read the manifest at the root itself. Answered `root` +# (which `plan`'s question used to offer), the source's own file stayed +# there, `execute` wrote the shape's manifest beside it as +# `shape/project.yaml`, said "nothing was overwritten" and `adoption +# verified`, and the doctor called the assembly NOT A SHAPE ROOT, its first +# `make bootstrap` failing naming, manifest and pins. A kept path AT it, or +# a kept directory there, is `plan-root-blocks-manifest`; there is then no +# copy to ask about, because the remediation takes the path out of the root. +# A symlink there is one too, even one that leads nowhere, which `place` +# writes the manifest THROUGH and which the doctor on Linux then reads as a +# shape root: a clone that makes no symlinks holds the link as a file naming +# its target, and #183 refuses a link to nothing at a shape path anyway. +# The other paths the shape writes keep the copy and the follow-up to merge +# it, which `plan` already predicts for a `Makefile` or a `README.md` it +# keeps in the root itself. +# # A SYMLINK OR A SUBMODULE is never a file on the way: a symlink above a shape # path or its copy is followed, or refused, by what #183 adds, and a submodule # the root keeps is `plan-submodule-root` (#166). Neither is a file where a @@ -2504,6 +2528,13 @@ def _leg_findings(plan: Plan, tree: list | None) -> list[str]: #: root holds (#262). ROOT_BLOCKS_SHAPE = "plan-root-blocks-shape" ROOT_BLOCKS_COPY = "plan-root-blocks-shape-copy" +#: And for a kept path where the shape writes its manifest (#305). +ROOT_BLOCKS_MANIFEST = "plan-root-blocks-manifest" +#: The shape's manifest, one of the paths `assembly_root_paths` lists: what +#: `shape-doctor.py`, the root's validators and `make bootstrap` read at the +#: assembly root, and what `contracts/path-classification.yaml` asks about +#: under `ambiguous-project-yaml`, with no `root` among its answers. +SHAPE_MANIFEST = "project.yaml" class _KeptTree: @@ -2556,14 +2587,26 @@ def under(self, path: str) -> list[tuple[str, str, str]]: if self._key(row[0]).startswith(prefix)] +def _kept_there(disk: _KeptTree, path: str) -> tuple[tuple, list]: + """What the plan keeps at `path` on `disk`: the group `("at", path)` and + the row AT it, or `("dir", path)` and the rows below a kept directory + there. A submodule is never one of them: it is `plan-submodule-root`'s + (#166), and said once there.""" + at = disk.at(path) + rows = [row for row in ([at] if at else disk.under(path)) + if row[1] != GITLINK_MODE] + return ("at" if at else "dir", path), rows + + def _shape_blocks(disk: _KeptTree) -> list[tuple]: """`(code, shape path, where it is written, group, kept rows)` for each path of `assembly_root_paths` that `place` would stop at or overwrite on - `disk`, in the order it writes them. The disk says both whether the root - already holds a path, so whether its copy is written at all, and whether - the place is free: a disk that ignores case holds `README.md` for a kept - `readme.md`. `group` names the one thing in the way, so that what one - kept path blocks is one finding.""" + `disk`, in the order it writes them, and for the manifest, which no copy + stands in for, wherever the root holds it (#305). The disk says both + whether the root already holds a path, so whether its copy is written at + all, and whether the place is free: a disk that ignores case holds + `README.md` for a kept `readme.md`. `group` names the one thing in the + way, so that what one kept path blocks is one finding.""" out = [] for rel in assembly_root_paths(): above = disk.file_above(rel) @@ -2573,19 +2616,20 @@ def _shape_blocks(disk: _KeptTree) -> list[tuple]: continue if not disk.holds(rel): continue + if rel == SHAPE_MANIFEST: + group, rows = _kept_there(disk, rel) + if rows: + out.append((ROOT_BLOCKS_MANIFEST, rel, rel, group, rows)) + continue copy = f"{COLLISION_DIR}/{rel}" above = disk.file_above(copy) if above: out.append((ROOT_BLOCKS_COPY, rel, copy, ("file", above[0]), [above])) continue - at = disk.at(copy) - # A submodule is `plan-submodule-root`'s (#166), and said once there. - rows = [row for row in ([at] if at else disk.under(copy)) - if row[1] != GITLINK_MODE] + group, rows = _kept_there(disk, copy) if rows: - out.append((ROOT_BLOCKS_COPY, rel, copy, - ("at" if at else "dir", copy), rows)) + out.append((ROOT_BLOCKS_COPY, rel, copy, group, rows)) return out @@ -2614,6 +2658,29 @@ def _blocked_sentence(code: str, rels: list[str], copies: list[str], them = "them" if len(rows) > 1 else "it" under = "" if entry == path else f" under the entry {_spelled(entry)}" fix = _fix_the_kept_path(entry) + if code == ROOT_BLOCKS_MANIFEST: + relation, aside = _overlap(rels[0], path) + spot = ("" if relation == "is" and not aside else + f", {'at' if relation == 'is' else 'inside'} " + f"{_first_and_more(rels)}{aside}") + found = ("directory" if relation == "contains" + else _kind_of(mode).split()[-1]) + beside = ("`execute` would write the shape's manifest beside it as " + f"`{COLLISION_DIR}/{rels[0]}`") + written = ( + f"Where it leads to something, {beside}, and where it leads " + "nowhere, through it, which a clone that makes no symlinks " + "holds as a file naming its target; either way it would say " + if relation == "is" and mode == SYMLINK_MODE else + f"{beside} and say ") + return ( + f"the plan keeps {_kind_of(mode)} {named} in the assembly " + f"root{under}{spot}, where the shape writes its manifest. No " + "copy stands in for the manifest: `shape-doctor.py`, the root's " + f"validators and `make bootstrap` read {_first_and_more(rels)} " + "at the root itself, and there they would find the source's " + f"{found}. {written}`adoption verified`, after both leg " + f"repositories were created and pushed: {fix}.") if code == ROOT_BLOCKS_SHAPE: relation, aside = _overlap(rels[0], path) return ( @@ -3418,6 +3485,125 @@ def _refused_by_policy(path: str) -> str | None: "written on its own.") +# --------------------------------------------------------------------------- +# A symlink in the source whose target is empty (#303) +# --------------------------------------------------------------------------- +# +# A source tree can hold a symlink whose target is EMPTY: a `120000` entry +# whose blob is the empty blob. A checkout with `core.symlinks=false` writes +# each link as a plain file holding its target, and emptying that file and +# committing records one (git 2.43). `plan` and `check` never read a link's +# target, so `check` said `plan ok`, and what `execute` did next turned on how +# git checks a symlink out where it ran: +# +# * Linux refuses a symlink to nothing (`symlink("")` fails with ENOENT), +# so where git makes symlinks there, its default, `execute`'s first +# clone of the source exited 128, `unable to checkout working tree`, +# after both leg repositories were created, WHATEVER THE PLAN ANSWERED +# for the link, `drop` included: `_extract_leg` and `_commit_the_split` +# each clone the whole source and only then take out what the plan +# sends elsewhere. Measured with the link in `docs/` (sent +# to the spec leg) and at the top level answered `root` and `drop`, and +# with both legs seeded, where the assembly's clone is the only one. +# * where `core.symlinks` is false, every clone held the link as an empty +# file and the run said `adoption verified`. The leg or the root the plan +# kept it in then could not be cloned on Linux where symlinks are made, +# nor could the assembly recursively; the root's own `validate` workflow +# runs on `ubuntu-latest`. Only `drop` gave an assembly that clones. +# +# So it is a fact of the SOURCE, readable from its tree before anything +# exists, and no answer in the plan fixes it everywhere: `check` reports each +# such link whatever the plan answers for it, and whatever `core.symlinks` +# says where it runs, and `execute` refuses it before any leg exists (#168), +# because a check that answered differently on another disk would pass a +# plan the next machine cannot adopt. `drop` is not offered as the fix: it +# is one only where symlinks are not made. The remediation is in the source. +# +# No target is read. `Source.tree()` lists each entry with `ls-tree -l`, and +# the size of a symlink's blob is the length of its target, so an empty +# target is a `120000` row of size 0, in a SHA-1 or a SHA-256 repository +# alike. Only the commit being adopted is read: an empty symlink in the +# history alone is never checked out by a clone, and `git filter-repo` +# rewrites the history without a checkout. + +#: `check`'s finding for a symlink of the source's whose target is empty. +EMPTY_SYMLINK = "plan-empty-symlink" +#: How many of them `check` names one by one; past it, a count, as +#: `plan-uncovered` counts the paths past its twentieth. +EMPTY_SYMLINKS_NAMED = 20 +#: What a plan whose source holds one is told to do. Said by `check`, once +#: after its findings, and by `execute`'s refusal, after its own remediation, +#: which is about correcting the plan: this is about the source. +EMPTY_SYMLINK_REMEDIATION = ( + "Remediation: a symlink's target is a fact of the source, and no answer " + "in the plan changes it, `drop` included: `execute` clones the whole " + "source before it takes out anything the plan sends elsewhere, and where " + "git makes symlinks that clone stops at a symlink to nothing. Give each " + "such symlink a target in the source, or remove it there, commit, and " + "re-run `plan`: a plan is written against one commit of the source, and " + "`check` calls it `plan-stale` once the source has moved.") + + +def empty_symlinks(tree: list) -> list[str]: + """The paths of `tree` (`Source.tree()`'s rows) that are symlinks with + an empty target: mode 120000, and a blob of size 0.""" + return [path for path, mode, _, size in tree + if mode == SYMLINK_MODE and not size] + + +def _empty_symlink_sentence(path: str) -> str: + """What `check` says of the empty symlink at `path`, and `plan` notes.""" + return (f"{_spelled(path)} is a symlink whose target is empty at the " + "source commit, and Linux cannot make a symlink to nothing. " + "There, where git makes symlinks by default, each clone " + "`execute` makes of the source stops at it after both leg " + "repositories were created, whatever the plan answers for it; " + "and where `core.symlinks` is false the clone holds it as an " + "empty file, and the leg or root the plan keeps it in then " + "cannot be cloned on Linux, where the root's own `validate` " + "workflow runs") + + +def _empty_symlink_findings(tree: list) -> tuple[list[str], list[str]]: + """A FINDING for each symlink of the source whose target is empty + (#303), as `check` prints it, the first EMPTY_SYMLINKS_NAMED one by one; + and what to print ONCE after every finding, as `_unsafe_path_findings` + does. Read from the tree alone: no answer of the plan's changes it.""" + empty = empty_symlinks(tree) + found = [f"FINDING {EMPTY_SYMLINK}: {_empty_symlink_sentence(path)}." + for path in empty[:EMPTY_SYMLINKS_NAMED]] + if len(empty) > EMPTY_SYMLINKS_NAMED: + found.append(f"FINDING {EMPTY_SYMLINK}: … and " + f"{len(empty) - EMPTY_SYMLINKS_NAMED} more symlinks " + "whose target is empty") + return found, [EMPTY_SYMLINK_REMEDIATION] if found else [] + + +def _empty_symlink_notes(tree: list) -> list[str]: + """What `plan` says of each symlink of the source whose target is empty: + a NOTE in `check`'s words, and a line saying what follows. `plan` still + writes the plan, but no answer in it will pass `check`.""" + notes: list[str] = [] + for path in empty_symlinks(tree): + notes += [f"\nNOTE {_empty_symlink_sentence(path)}.", + f" `check` reports this as `{EMPTY_SYMLINK}` and " + "`execute` refuses it, whatever the plan answers: give it " + "a target in the source, or remove it there, commit, and " + "re-run `plan`."] + return notes + + +def _source_path_findings(plan: Plan, tree: list + ) -> tuple[list[str], list[str]]: + """The findings `_entry_findings` lists first, about the source's own + paths, and what to print once after every finding for them: each entry + path `execute` will not extract (#196), then each symlink of the source + whose target is empty (#303).""" + unsafe, unsafe_remediation = _unsafe_path_findings(plan) + empty, empty_remediation = _empty_symlink_findings(tree) + return unsafe + empty, unsafe_remediation + empty_remediation + + def _unsafe_path_findings(plan: Plan) -> tuple[list[str], list[str]]: """`entry_path_problem` for each entry not answered `root` or `drop`, as `check` prints it, one FINDING each; and what to print ONCE after every @@ -3470,6 +3656,12 @@ def _entry_findings(plan: Plan, source: Source, `plan-unsafe-path` for a plan that has one: a path that is a `git` option is refused as that, and never as the uncovered file it also leaves. + THEN THE SOURCE'S EMPTY SYMLINKS (#303), which no answer in the plan + fixes: the fix is a commit in the source, and the fresh `plan` after it + takes back every answer given before, so they are listed before the + findings about those answers (`_source_path_findings`). Their + remediation is said once after the findings, with the others. + A VALUE NO FILE CAN HOLD IS SAID BEFORE THE VALIDATOR'S WORD ON IT (#255 before #261). A lone surrogate, a line break or a bidi control in `visibility:` or `elected_on:` is both, and `_refuse_what_check_finds` @@ -3481,7 +3673,7 @@ def _entry_findings(plan: Plan, source: Source, exists. The relative urls of #190 still come after both, last. """ tree = source.tree() - unsafe, unsafe_remediation = _unsafe_path_findings(plan) + unsafe, unsafe_remediation = _source_path_findings(plan, tree) findings = (unsafe + _coverage_findings([str(e.get("path")) for e in plan.entries], diff --git a/contracts/path-classification.yaml b/contracts/path-classification.yaml index c294a3f6..83bf3b5d 100644 --- a/contracts/path-classification.yaml +++ b/contracts/path-classification.yaml @@ -391,6 +391,34 @@ rules: # -- the honest residue --------------------------------------------------- + # THE SHAPE'S MANIFEST IS THE ASSEMBLY ROOT'S `project.yaml` (#305). A + # repository's own top-level `project.yaml` used to be asked the question + # below, which offers `root`. Answered so, `adopt-project.py execute` wrote + # the shape's manifest beside it as `shape/project.yaml` and said + # `adoption verified`, and `shape-doctor.py` then called the assembly NOT A + # SHAPE ROOT, its validators and first `make bootstrap` failing. So the + # file is asked a question of its own that does not offer `root`, and + # `adopt-project.py` refuses a plan that keeps it there anyway. As for + # `.gitmodules`, what `execute` does is said of a repository WHEN IT IS + # ADOPTED: `shape-doctor.py --placement-plan` prints this question too, for + # a leg already split. + - id: ambiguous-project-yaml + paths: + - "project.yaml" + leg: ambiguous + reason: >- + A top-level `project.yaml` of the repository's own. The assembly + root's `project.yaml` is the shape's manifest, which its validators, + `make bootstrap` and `shape-doctor.py` read there, so when a repository + is adopted its own file of that name goes to a leg or is dropped. + question: >- + Is this file a declaration the project has DECIDED (spec), or + configuration the implementation reads at run time (code)? Drop it if + neither leg needs it. `root` is not an answer for this file: the + assembly root's `project.yaml` is the shape's manifest, and + `adopt-project.py` refuses a plan that keeps the repository's own file + there. + - id: ambiguous-top-level-yaml paths: - "*.yaml" diff --git a/tests/test_adopt_empty_symlinks.py b/tests/test_adopt_empty_symlinks.py new file mode 100644 index 00000000..2b8213d7 --- /dev/null +++ b/tests/test_adopt_empty_symlinks.py @@ -0,0 +1,399 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A symlink in the source whose target is empty (#303). + +A source tree can hold a `120000` entry whose blob is the empty blob, and +`plan` and `check` never read a link's target, so `check` said `plan ok`. On +main (b30595c, and c7c02fa) what `execute` did next turned on how git checks +a symlink out where it ran: + +- with symlinks on, Linux refuses a symlink to nothing, so `execute` created + both leg repositories and then failed in its first clone of the source, + exit 2 with nothing on stderr, whatever the plan answered for the link, + `drop` included; +- with `core.symlinks=false`, it said `adoption verified` over a leg or a + root that no clone with symlinks on can check out. + +Now `check` reports each such link as `plan-empty-symlink`, whatever the plan +answers and whatever `core.symlinks` says, `execute` refuses it before any +leg exists, and `plan` notes it while it still writes the plan. + +The links are made in the INDEX with `git update-index --cacheinfo`, as the +issue's reproduction makes them, so no symlink is ever made on a disk to +build a source, and every test but one runs on Windows too. `execute` is +refused before it clones anything, so nothing here checks a link out except +the end-to-end test of a link given a target, which is skipped where the +platform cannot make one. +""" + +from __future__ import annotations + +import importlib.util +import re +import shutil +import subprocess +import sys +from argparse import Namespace +from pathlib import Path + +import pytest + +from conftest import ADOPT, as_human, git, make_source_repo, run_script, \ + write_plan + +needs_filter_repo = pytest.mark.skipif( + shutil.which("git-filter-repo") is None, + reason="git filter-repo is not installed: `pip install git-filter-repo`") + +#: Git for Windows checks a tracked symlink out as a plain file unless +#: `core.symlinks` is on, and a runner may make one only in Developer Mode. +needs_disk_symlinks = pytest.mark.skipif( + sys.platform == "win32", + reason="a tracked symlink is a plain file in a Windows checkout") + +PROJECT = "Northwind" +UTF8 = "utf-8" +REMOTES = "remotes" +WORK = "work" +ROLES = ("spec", "code") +SPLIT_BRANCH = "adopt/three-repo-shape" +SYMLINK = "120000" +CODE = "plan-empty-symlink" +#: The issue's small source: a spec, the code, and a README the root keeps. +TREE = {"src/app.py": "print(1)\n", "docs/spec.md": "# Spec\n", + "README.md": "# Thing\n"} +#: `core.symlinks=false` for every git a run starts, as Git for Windows has it +#: by default. +SYMLINKS_OFF = {"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.symlinks", + "GIT_CONFIG_VALUE_0": "false"} + + +@pytest.fixture(scope="module") +def adopter(): + module_spec = importlib.util.spec_from_file_location( + "adopt_empty_symlinks", ADOPT) + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +@pytest.fixture +def no_filter_repo_probe(adopter, monkeypatch): + """Every refusal here comes before `git filter-repo` would be used.""" + monkeypatch.setattr(adopter, "_require_filter_repo", lambda: None) + + +# --- sources and plans ----------------------------------------------------- + +def blob(repo: Path, content: bytes) -> str: + """`content` written as a blob of `repo`: bytes on stdin, as a hand edit + leaves them on every platform.""" + made = subprocess.run(["git", "hash-object", "-w", "--stdin"], + cwd=str(repo), input=content, capture_output=True, + check=True) + return made.stdout.decode().strip() + + +def put_link(repo: Path, path: str, target: bytes = b"") -> None: + """A symlink at `path` whose target is `target`, in the index only.""" + git("update-index", "--add", "--cacheinfo", + f"{SYMLINK},{blob(repo, target)},{path}", cwd=repo) + + +def source_with_links(tmp_path: Path, *links: str, tree=None) -> Path: + """The issue's source with an empty symlink at each of `links`, + committed, as `git update-index --cacheinfo` and `git commit` make it.""" + source = make_source_repo(tmp_path / "Thing", tree=tree or TREE, + edits=()) + for path in links: + put_link(source, path) + as_human("commit", "-qm", "Add the links", cwd=source) + return source + + +def planned(source: Path, out: Path, answers: dict | None = None, + env: dict | None = None) -> Path: + """`plan` for `source`, each entry `answers` names given that `leg:` + with a reason, whatever `plan` proposed for it.""" + written = run_script(ADOPT, "plan", "--source", str(source), "--project", + PROJECT, "--org", "testorg", "--elected-by", + "Test Human", "--elected-on", "2026-09-02", + "--out", str(out), env=env) + assert written.returncode == 0, written.stderr + written.stdout + text = out.read_bytes().decode(UTF8) + for path, leg in (answers or {}).items(): + text, count = re.subn( + rf"(?m)^( - path: \"?{re.escape(path)}\"?\n leg: )\S+$", + rf'\g<1>{leg}\n resolution: "answered by the test"', text) + assert count == 1, f"no entry {path} in the plan" + out.write_bytes(text.encode(UTF8)) + return out + + +def check(plan: Path, env: dict | None = None): + return run_script(ADOPT, "check", "--plan", str(plan), env=env) + + +def findings_of(result) -> list[str]: + return [line for line in result.stderr.splitlines() + if line.startswith("FINDING ")] + + +def execute_args(tmp_path: Path, plan: Path) -> Namespace: + return Namespace(plan=str(plan), source=None, + local_remote_dir=tmp_path / REMOTES, allow_empty_leg=[], + yes=True, work_dir=tmp_path / WORK) + + +def no_leg_was_created(tmp_path: Path, source: Path) -> None: + assert not (tmp_path / REMOTES).exists(), ( + "not even the directory the legs go in") + for role in ROLES: + assert not (tmp_path / WORK / f"{PROJECT}-{role}").exists() + assert not (tmp_path / WORK / PROJECT).exists(), "no clone of the source" + assert git("branch", "--list", SPLIT_BRANCH, cwd=source).stdout == "" + + +def executed(plan: Path, tmp_path: Path, env: dict | None = None): + return run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + "--local-remote-dir", str(tmp_path / REMOTES), + "--work-dir", str(tmp_path / WORK), env=env) + + +#: Where the link sits and what the plan answers for it: the issue's two +#: runs, `docs/empty-link` inside an entry the rules send to the spec leg, +#: and a top-level `empty-link`, which `plan` asks about, answered each way. +#: `drop` is among them: with symlinks on, the clone fails before the split +#: removes anything. +PLACES = [ + pytest.param("docs/empty-link", {}, id="in-docs-sent-to-spec"), + *(pytest.param("empty-link", {"empty-link": leg}, + id=f"top-level-answered-{leg}") + for leg in ("root", "code", "spec", "drop")), +] + + +# --- the rule, in memory --------------------------------------------------- + +def row(path: str, mode: str = "100644", size: int = 1) -> tuple: + """A row of `Source.tree()`: `(path, mode, blob id, size)`.""" + return (path, mode, "0" * 40, size) + + +def test_only_a_symlink_of_size_zero_is_empty(adopter): + """The size `ls-tree -l` gives a symlink is its target's length. An + empty FILE is not a symlink, a symlink with a target is not empty, and a + submodule, which `Source.tree()` gives size 0 for `ls-tree`'s `-`, is + neither.""" + tree = [row("a", SYMLINK, 0), row("b", SYMLINK, 7), row("c", "100644", 0), + row("d", "160000", 0), row("e/f", SYMLINK, 0)] + assert adopter.empty_symlinks(tree) == ["a", "e/f"] + + +def test_the_finding_names_the_link_and_says_why_whatever_the_answer( + adopter): + found, remediation = adopter._empty_symlink_findings( + [row("docs/empty-link", SYMLINK, 0)]) + finding, = found + assert finding.startswith( + f"FINDING {CODE}: `docs/empty-link` is a symlink whose target is " + "empty at the source commit, and Linux cannot make a symlink to " + "nothing. ") + assert "after both leg repositories were created" in finding + assert "whatever the plan answers for it" in finding + assert "`core.symlinks` is false" in finding + assert remediation == [adopter.EMPTY_SYMLINK_REMEDIATION] + assert "`drop` included" in remediation[0] + assert "commit, and re-run `plan`" in remediation[0] + + +def test_a_source_with_none_has_no_finding_and_no_remediation(adopter): + assert adopter._empty_symlink_findings( + [row("README.md"), row("link", SYMLINK, 9)]) == ([], []) + + +def test_past_twenty_the_rest_are_counted(adopter): + tree = [row(f"l{n:02d}", SYMLINK, 0) for n in range(23)] + found, _ = adopter._empty_symlink_findings(tree) + assert len(found) == 21 + assert found[19].startswith(f"FINDING {CODE}: `l19` ") + assert found[20] == (f"FINDING {CODE}: … and 3 more symlinks whose " + "target is empty") + + +def test_the_finding_spells_a_name_it_cannot_print(adopter): + """A source name is bytes and may hold a line break: one line still.""" + finding, = adopter._empty_symlink_findings( + [row("a\nb", SYMLINK, 0)])[0] + assert "\n" not in finding + assert "`a\\nb` is a symlink" in finding + + +# --- `plan` and `check` ----------------------------------------------------- + +def test_plan_notes_it_and_still_writes_the_plan(tmp_path): + source = source_with_links(tmp_path, "docs/empty-link") + out = tmp_path / "plan.yaml" + written = run_script(ADOPT, "plan", "--source", str(source), "--project", + PROJECT, "--org", "testorg", "--out", str(out), + "--elected-by", "Test Human") + assert written.returncode == 0, written.stderr + written.stdout + assert out.is_file() + assert ("\nNOTE `docs/empty-link` is a symlink whose target is empty at " + "the source commit, and Linux cannot make a symlink to " + "nothing. ") in written.stdout + assert (f"`check` reports this as `{CODE}` and `execute` refuses it, " + "whatever the plan answers") in written.stdout + + +def test_plan_notes_nothing_for_a_symlink_with_a_target(tmp_path): + source = make_source_repo(tmp_path / "Thing", tree=TREE, edits=()) + put_link(source, "docs/link", b"spec.md") + as_human("commit", "-qm", "Add a link", cwd=source) + written = write_plan(source, tmp_path / "plan.yaml") + assert written.returncode == 0, written.stderr + written.stdout + assert CODE not in written.stdout + + +@pytest.mark.parametrize("link, answers", PLACES) +def test_check_reports_what_it_passed(tmp_path, link, answers): + """#303's runs: on main, `plan ok` for each.""" + plan = planned(source_with_links(tmp_path, link), + tmp_path / "plan.yaml", answers) + result = check(plan) + assert result.returncode == 1, result.stdout + result.stderr + finding, = findings_of(result) + assert finding.startswith(f"FINDING {CODE}: `{link}` is a symlink ") + assert "plan ok" not in result.stdout + assert result.stderr.count("Remediation: a symlink's target is a fact " + "of the source") == 1 + + +def test_check_reports_it_where_git_makes_no_symlinks_too(tmp_path): + """The issue's second run, where every clone held the link as an empty + file and the adoption verified: `check` reads the tree, not the disk, + so `core.symlinks=false` changes nothing it says.""" + plan = planned(source_with_links(tmp_path, "docs/empty-link"), + tmp_path / "plan.yaml", env=SYMLINKS_OFF) + off, on = check(plan, env=SYMLINKS_OFF), check(plan) + assert off.returncode == on.returncode == 1 + assert findings_of(off) == findings_of(on) + assert len(findings_of(off)) == 1 + + +def test_each_link_is_named(tmp_path): + plan = planned(source_with_links(tmp_path, "docs/a", "docs/b"), + tmp_path / "plan.yaml") + assert [line.split("`")[1] for line in findings_of(check(plan))] == [ + "docs/a", "docs/b"] + + +def test_it_comes_after_the_unsafe_paths_and_before_coverage( + adopter, no_filter_repo_probe, tmp_path): + """No answer fixes it, so it is listed before the findings about the + answers, here an entry deleted from the plan (`plan-uncovered`), and + after #196's entry paths (`plan-unsafe-path`, a file `-notes.md` the + plan sends to the spec leg), which `execute`'s refusal still wears.""" + source = source_with_links(tmp_path, "docs/empty-link", + tree={**TREE, "-notes.md": "# Notes\n"}) + plan = planned(source, tmp_path / "plan.yaml", {"-notes.md": "spec"}) + text = plan.read_bytes().decode(UTF8) + text, count = re.subn(r"(?m)^ - path: src/\n(?: .*\n)+", "", text) + assert count == 1 + plan.write_bytes(text.encode(UTF8)) + codes = [line.split(":")[0] for line in findings_of(check(plan))] + assert codes == ["FINDING plan-unsafe-path", f"FINDING {CODE}", + "FINDING plan-uncovered"] + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(execute_args(tmp_path, plan)) + assert caught.value.code == "plan-unsafe-path" + assert adopter.EMPTY_SYMLINK_REMEDIATION in caught.value.remediation + no_leg_was_created(tmp_path, source) + + +@pytest.mark.parametrize("how", ["removed", "given-a-target"]) +def test_the_remediation_the_finding_names_makes_check_pass(tmp_path, how): + """Fixed in the source and planned again, the plan checks ok.""" + source = source_with_links(tmp_path, "docs/empty-link") + if how == "removed": + git("update-index", "--force-remove", "docs/empty-link", cwd=source) + else: + put_link(source, "docs/empty-link", b"spec.md") + as_human("commit", "-qm", f"The link, {how}", cwd=source) + result = check(planned(source, tmp_path / "plan.yaml")) + assert result.returncode == 0, result.stdout + result.stderr + assert "plan ok" in result.stdout + + +# --- `execute` --------------------------------------------------------------- + +@pytest.mark.parametrize("link, answers", PLACES) +def test_execute_refuses_it_before_any_leg_exists( + adopter, no_filter_repo_probe, tmp_path, link, answers): + source = source_with_links(tmp_path, link) + plan = planned(source, tmp_path / "plan.yaml", answers) + printed, = findings_of(check(plan)) + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(execute_args(tmp_path, plan)) + assert caught.value.code == CODE + assert printed in caught.value.detail + assert "no leg repository was created" in caught.value.remediation + assert adopter.EMPTY_SYMLINK_REMEDIATION in caught.value.remediation + no_leg_was_created(tmp_path, source) + + +@needs_filter_repo +@pytest.mark.parametrize("env", [ + pytest.param(None, id="symlinks-on"), + pytest.param(SYMLINKS_OFF, id="symlinks-off"), +]) +@pytest.mark.parametrize("link, answers", [ + pytest.param("docs/empty-link", {}, id="in-docs"), + pytest.param("empty-link", {"empty-link": "drop"}, id="top-level-drop"), +]) +def test_execute_refuses_it_end_to_end_and_the_remote_dir_stays_empty( + tmp_path, env, link, answers): + """The issue's runs as commands: on main, both leg repositories and exit + 2 with nothing said (symlinks on), or `adoption verified` over a leg no + clone with symlinks on can check out (symlinks off). Refused before a + leg is made, so the EMPTY `--local-remote-dir` stays empty.""" + source = source_with_links(tmp_path, link) + plan = planned(source, tmp_path / "plan.yaml", answers, env=env) + (tmp_path / REMOTES).mkdir() + result = executed(plan, tmp_path, env=env) + assert result.returncode == 2, result.stdout + result.stderr + assert f"REFUSED {CODE}: 1 finding(s) in " in result.stderr + assert f"`{link}` is a symlink whose target is empty" in result.stderr + assert "creating the leg repositories" not in result.stdout + assert "adoption verified" not in result.stdout + assert list((tmp_path / REMOTES).iterdir()) == [], "no leg was made" + assert not (tmp_path / WORK / PROJECT).exists(), "the source not cloned" + assert git("branch", "--list", SPLIT_BRANCH, cwd=source).stdout == "" + + +@needs_filter_repo +def test_the_link_removed_in_the_source_adopts_end_to_end(tmp_path): + source = source_with_links(tmp_path, "docs/empty-link") + git("update-index", "--force-remove", "docs/empty-link", cwd=source) + as_human("commit", "-qm", "Remove the link", cwd=source) + plan = planned(source, tmp_path / "plan.yaml") + result = executed(plan, tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + assert "adoption verified" in result.stdout + + +@needs_filter_repo +@needs_disk_symlinks +def test_the_link_given_a_target_adopts_end_to_end(tmp_path): + """The other remediation: `execute`'s clones now check the link out, so + this needs a platform that makes symlinks, and the leg holds it.""" + source = source_with_links(tmp_path, "docs/empty-link") + put_link(source, "docs/empty-link", b"spec.md") + as_human("commit", "-qm", "Give the link a target", cwd=source) + plan = planned(source, tmp_path / "plan.yaml") + result = executed(plan, tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + assert "adoption verified" in result.stdout + spec = tmp_path / REMOTES / f"{PROJECT}-spec.git" + assert git("ls-tree", "main", "--", "docs/empty-link", + cwd=spec).stdout.startswith(f"{SYMLINK} blob ") + diff --git a/tests/test_adopt_root_manifest.py b/tests/test_adopt_root_manifest.py new file mode 100644 index 00000000..894bf358 --- /dev/null +++ b/tests/test_adopt_root_manifest.py @@ -0,0 +1,366 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The source's own top-level `project.yaml`, kept in the root (#305). + +`plan` asked about a source's own `project.yaml` under +`ambiguous-top-level-yaml`, whose question offers `root`. Answered `root`, +on main (b30595c, and c7c02fa): + +- `check` said `plan ok`; +- `execute` wrote the shape's manifest beside it as `shape/project.yaml`, + said "nothing was overwritten" and `adoption verified`; +- `shape-doctor.py` then called the root NOT A SHAPE ROOT, and its first + `make bootstrap` failed naming, manifest and pins. + +Now the file is asked a question of its own, `ambiguous-project-yaml`, +which does not offer `root`, and a plan that keeps it in the root anyway, as +a hand-edited one would, is `check`'s finding `plan-root-blocks-manifest` +and `execute`'s refusal before any leg exists. The rule is #313's +(`_root_shape_findings`), extended: the manifest is the one path the shape +writes whose copy beside the source's own stands in for nothing, so the root +holding it is refused outright, on a disk that keeps case and on one that +ignores it. +""" + +from __future__ import annotations + +import importlib.util +import re +import shutil +import sys +from argparse import Namespace +from pathlib import Path + +import pytest + +from conftest import ADOPT, REPO, git, make_source_repo, run_script + +sys.path.insert(0, str(REPO / "scripts")) +from path_classify import PathPolicy # noqa: E402 + +needs_filter_repo = pytest.mark.skipif( + shutil.which("git-filter-repo") is None, + reason="git filter-repo is not installed: `pip install git-filter-repo`") + +PROJECT = "Northwind" +UTF8 = "utf-8" +REMOTES = "remotes" +WORK = "work" +ROLES = ("spec", "code") +SPLIT_BRANCH = "adopt/three-repo-shape" +POLICY = REPO / "contracts" / "path-classification.yaml" +CODE = "plan-root-blocks-manifest" +RULE = "ambiguous-project-yaml" +CASE_ASIDE = " once case is ignored, as a macOS or Windows disk does" +THEIRS = "name: theirs\n" +#: The issue's source: a spec, the code, a README the root keeps, and a +#: `project.yaml` of its own. +TREE = {"src/app.py": "print(1)\n", "docs/spec.md": "# Spec\n", + "README.md": "# Thing\n", "project.yaml": THEIRS} + + +@pytest.fixture(scope="module") +def adopter(): + module_spec = importlib.util.spec_from_file_location( + "adopt_root_manifest", ADOPT) + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +@pytest.fixture +def no_filter_repo_probe(adopter, monkeypatch): + """Every refusal here comes before `git filter-repo` would be used.""" + monkeypatch.setattr(adopter, "_require_filter_repo", lambda: None) + + +# --- the question `plan` asks ------------------------------------------------ + +def test_the_manifest_is_a_path_the_shape_writes_into_the_root(adopter): + assert adopter.SHAPE_MANIFEST in adopter.assembly_root_paths() + assert (REPO / "templates" / "assembly-root" + / adopter.SHAPE_MANIFEST).is_file() + + +def test_project_yaml_is_asked_a_question_with_no_root_in_it(adopter): + verdict = PathPolicy.load(POLICY).classify_file(adopter.SHAPE_MANIFEST) + assert verdict.rule == RULE + assert verdict.leg is None and verdict.review_required + assert "(spec)" in verdict.question and "(code)" in verdict.question + assert "(root)" not in verdict.question, "root is offered" + assert "`root` is not an answer for this file" in verdict.question + + +def test_every_other_top_level_yaml_is_asked_as_before(): + """Only the manifest's name: `release.yaml` keeps the question that + offers `root`, and a nested `project.yaml` is not the manifest.""" + policy = PathPolicy.load(POLICY) + release = policy.classify_file("release.yaml") + assert release.rule == "ambiguous-top-level-yaml" + assert "(root)" in release.question + assert policy.classify_file("config/project.yaml").rule != RULE + + +def plan_for(tmp_path: Path, tree: dict = TREE) -> tuple[Path, Path]: + source = make_source_repo(tmp_path / "Thing", tree=tree, edits=()) + out = tmp_path / "plan.yaml" + written = run_script(ADOPT, "plan", "--source", str(source), "--project", + PROJECT, "--org", "testorg", "--elected-by", + "Test Human", "--elected-on", "2026-09-02", + "--out", str(out)) + assert written.returncode == 0, written.stderr + written.stdout + return source, out + + +def entry_of(plan: Path, path: str) -> str: + text = plan.read_bytes().decode(UTF8) + found = re.search(rf"(?m)^ - path: {re.escape(path)}\n(?: .*\n)+", + text) + assert found, f"no entry {path} in the plan" + return found.group(0) + + +def test_plan_asks_the_question_with_no_root_in_it(tmp_path): + _, plan = plan_for(tmp_path) + entry = entry_of(plan, "project.yaml") + assert " leg: null\n" in entry + assert f" rule: {RULE}\n" in entry + assert " review_required: true\n" in entry + assert "(root)" not in entry + assert "`root` is not an answer for this file" in entry + + +# --- the rule, in memory --------------------------------------------------- + +def row(path: str, mode: str = "100644") -> tuple: + """A row of `Source.tree()`: `(path, mode, blob id, size)`.""" + return (path, mode, "0" * 40, 1) + + +def keeping(adopter, *entries: tuple): + return adopter.Plan(Path("adoption-plan.yaml"), { + "kind": "adoption-plan", "mode": "in-place", + "legs": {"spec_path": "spec", "code_path": "code"}, + "paths": [{"path": path, "leg": leg} for path, leg in entries]}) + + +def found(adopter, entries: list, rows: list) -> list[str]: + return adopter._root_shape_findings(keeping(adopter, *entries), rows) + + +def test_a_kept_project_yaml_is_a_finding(adopter): + said, = found(adopter, [("project.yaml", "root")], [row("project.yaml")]) + assert said == ( + f"FINDING {CODE}: the plan keeps the file `project.yaml` in the " + "assembly root, where the shape writes its manifest. No copy stands " + "in for the manifest: `shape-doctor.py`, the root's validators and " + "`make bootstrap` read `project.yaml` at the root itself, and there " + "they would find the source's file. `execute` would write the " + "shape's manifest beside it as `shape/project.yaml` and say " + "`adoption verified`, after both leg repositories were created and " + "pushed: send `project.yaml` to a leg, `drop` it, or rename it in " + "the source and re-run `plan`.") + + +@pytest.mark.parametrize("leg", ["spec", "code", "drop"]) +def test_a_project_yaml_the_plan_sends_away_is_not_kept(adopter, leg): + assert found(adopter, [("project.yaml", leg)], [row("project.yaml")]) \ + == [] + + +@pytest.mark.parametrize("twin", ["Project.yaml", "PROJECT.YAML"]) +def test_a_case_twin_is_the_manifest_once_case_is_ignored(adopter, twin): + """On a macOS or Windows disk `place` finds the twin AT `project.yaml` + and writes the manifest beside it; on Linux the two stand side by side + and a clone on such a disk keeps one of them. Refused on every disk, the + trade #172 and #313 make for theirs.""" + said, = found(adopter, [(twin, "root")], [row(twin)]) + assert said.startswith( + f"FINDING {CODE}: the plan keeps the file `{twin}` in the assembly " + f"root, at `project.yaml`{CASE_ASIDE}, where the shape writes its " + "manifest. ") + assert said.endswith(f"send `{twin}` to a leg, `drop` it, or rename it " + "in the source and re-run `plan`.") + + +def test_a_kept_symlink_there_is_a_finding_whatever_it_leads_to(adopter): + """A link to the source's own file leaves that file where the manifest + is read (NOT A SHAPE ROOT on c7c02fa). A link to nothing has the + manifest written through it, which a clone that makes no symlinks holds + as a file naming the target. The tree holds no target, so both are + refused.""" + said, = found(adopter, [("project.yaml", "root")], + [row("project.yaml", "120000")]) + assert said.startswith(f"FINDING {CODE}: the plan keeps the symlink " + "`project.yaml` in the assembly root, ") + assert "and where it leads nowhere, through it, which a clone that " \ + "makes no symlinks holds as a file naming its target" in said + + +def test_a_kept_directory_there_names_what_it_holds(adopter): + said, = found(adopter, [("project.yaml/", "root")], + [row("project.yaml/a.md"), row("project.yaml/b.md")]) + assert said.startswith( + f"FINDING {CODE}: the plan keeps the file `project.yaml/a.md` and 1 " + "more in the assembly root under the entry `project.yaml/`, inside " + "`project.yaml`, where the shape writes its manifest. ") + assert "there they would find the source's directory." in said + assert "send `project.yaml/` to a leg" in said + + +def test_a_submodule_there_is_the_submodule_rules_and_said_once(adopter): + """`plan-submodule-root` (#166) refuses it; this rule says nothing.""" + assert found(adopter, [("project.yaml", "root")], + [row("project.yaml", "160000")]) == [] + + +def test_the_copy_is_not_asked_about_where_the_manifest_is_refused(adopter): + """The remediation takes `project.yaml` out of the root, so no copy is + written and the source's own `shape/project.yaml` is in no way.""" + said = found(adopter, [("project.yaml", "root"), ("shape/", "root")], + [row("project.yaml"), row("shape/project.yaml")]) + assert [line.split(":")[0] for line in said] == [f"FINDING {CODE}"] + + +@pytest.mark.parametrize("held", ["README.md", "Makefile", "AGENTS.md"]) +def test_every_other_path_the_shape_writes_keeps_its_copy(adopter, held): + """Not over-refused: a kept `README.md` or `Makefile` is the common + case, written beside and merged as the follow-up says, as before.""" + assert found(adopter, [(held, "root")], [row(held)]) == [] + + +def test_it_comes_in_the_order_the_shape_writes(adopter): + """`README.md`'s copy first, the manifest, then the `.github` file.""" + said = found(adopter, [("README.md", "root"), ("project.yaml", "root"), + (".github", "root"), ("shape/", "root")], + [row("README.md"), row("project.yaml"), row(".github"), + row("shape/README.md")]) + assert [line.split(":")[0] for line in said] == [ + "FINDING plan-root-blocks-shape-copy", f"FINDING {CODE}", + "FINDING plan-root-blocks-shape"] + + +# --- `check` and `execute`, on real plans ------------------------------------ + +def answered(plan: Path, leg: str) -> Path: + """The issue's edit: `project.yaml` answered `leg`, with a reason.""" + text = plan.read_bytes().decode(UTF8) + text, count = re.subn( + r"(?m)^( - path: project\.yaml\n leg: )\S+$", + rf'\g<1>{leg}\n resolution: "the person\'s answer"', text) + assert count == 1 + plan.write_bytes(text.encode(UTF8)) + return plan + + +def check(plan: Path): + return run_script(ADOPT, "check", "--plan", str(plan)) + + +def findings_of(result) -> list[str]: + return [line for line in result.stderr.splitlines() + if line.startswith("FINDING ")] + + +def test_check_reports_what_it_passed(tmp_path): + """#305's run, answered `root`: on main, `plan ok`.""" + _, plan = plan_for(tmp_path) + result = check(answered(plan, "root")) + assert result.returncode == 1, result.stdout + result.stderr + finding, = findings_of(result) + assert finding.startswith(f"FINDING {CODE}: the plan keeps the file " + "`project.yaml` in the assembly root, ") + assert "plan ok" not in result.stdout + assert "1 finding(s) in " in result.stderr + + +@pytest.mark.parametrize("leg", ["spec", "code", "drop"]) +def test_the_answers_the_question_offers_check_ok(tmp_path, leg): + _, plan = plan_for(tmp_path) + result = check(answered(plan, leg)) + assert result.returncode == 0, result.stdout + result.stderr + assert "plan ok" in result.stdout + + +def test_check_reports_a_case_twin_kept_in_the_root(tmp_path): + tree = {**{k: v for k, v in TREE.items() if k != "project.yaml"}, + "Project.yaml": THEIRS} + _, plan = plan_for(tmp_path, tree) + text = plan.read_bytes().decode(UTF8) + text, count = re.subn(r"(?m)^( - path: Project\.yaml\n leg: )\S+$", + r'\g<1>root\n resolution: "kept"', text) + assert count == 1 + plan.write_bytes(text.encode(UTF8)) + finding, = findings_of(check(plan)) + assert finding.startswith( + f"FINDING {CODE}: the plan keeps the file `Project.yaml` in the " + f"assembly root, at `project.yaml`{CASE_ASIDE}, ") + + +def execute_args(tmp_path: Path, plan: Path) -> Namespace: + return Namespace(plan=str(plan), source=None, + local_remote_dir=tmp_path / REMOTES, allow_empty_leg=[], + yes=True, work_dir=tmp_path / WORK) + + +def test_execute_refuses_it_before_any_leg_exists( + adopter, no_filter_repo_probe, tmp_path): + source, plan = plan_for(tmp_path) + printed, = findings_of(check(answered(plan, "root"))) + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(execute_args(tmp_path, plan)) + assert caught.value.code == CODE + assert printed in caught.value.detail + assert ("keep in the root no path where the shape writes a file or its " + "copy") in caught.value.remediation + assert not (tmp_path / REMOTES).exists() + for role in ROLES: + assert not (tmp_path / WORK / f"{PROJECT}-{role}").exists() + assert git("branch", "--list", SPLIT_BRANCH, cwd=source).stdout == "" + + +def executed(plan: Path, tmp_path: Path): + return run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + "--local-remote-dir", str(tmp_path / REMOTES), + "--work-dir", str(tmp_path / WORK)) + + +@needs_filter_repo +def test_execute_refuses_it_end_to_end_and_the_remote_dir_stays_empty( + tmp_path): + """On main this run made and pushed both legs and the split, said + `adoption verified`, and left a root the doctor calls NOT A SHAPE + ROOT.""" + source, plan = plan_for(tmp_path) + answered(plan, "root") + (tmp_path / REMOTES).mkdir() + result = executed(plan, tmp_path) + assert result.returncode == 2, result.stdout + result.stderr + assert f"REFUSED {CODE}: 1 finding(s) in " in result.stderr + assert "creating the leg repositories" not in result.stdout + assert "beside shape/project.yaml" not in result.stdout + assert "adoption verified" not in result.stdout + assert list((tmp_path / REMOTES).iterdir()) == [], "no leg was made" + assert git("branch", "--list", SPLIT_BRANCH, cwd=source).stdout == "" + + +@needs_filter_repo +@pytest.mark.parametrize("leg", ["code", "drop"]) +def test_an_answer_the_question_offers_adopts_with_the_shapes_manifest( + tmp_path, leg): + """The root's `project.yaml` is then the shape's manifest, with no copy + beside it; sent to a leg, the source's own file is in that leg.""" + source, plan = plan_for(tmp_path) + answered(plan, leg) + result = executed(plan, tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + assert "adoption verified" in result.stdout + assert "beside shape/project.yaml" not in result.stdout + root = git("show", f"{SPLIT_BRANCH}:project.yaml", cwd=source).stdout + assert root.startswith("schema_version: 1\nkind: project-manifest\n"), \ + root + split = git("ls-tree", "-r", "--name-only", SPLIT_BRANCH, + cwd=source).stdout.splitlines() + assert "shape/project.yaml" not in split + if leg == "code": + code = tmp_path / REMOTES / f"{PROJECT}-code.git" + assert git("show", "main:project.yaml", cwd=code).stdout == THEIRS diff --git a/tests/test_adopt_root_shape_paths.py b/tests/test_adopt_root_shape_paths.py index e9ffc4d1..03494b74 100644 --- a/tests/test_adopt_root_shape_paths.py +++ b/tests/test_adopt_root_shape_paths.py @@ -186,9 +186,13 @@ def test_every_directory_the_shape_writes_into_is_found_as_a_file(adopter): def test_a_file_kept_at_a_path_the_shape_writes_is_not_a_finding(adopter): """A file where the shape writes a FILE is the collision `place` already handles: its copy goes under `shape/`. Only a file where a DIRECTORY is - needed stops it.""" + needed stops it. The manifest is the one exception, which no copy stands + in for (#305): `tests/test_adopt_root_manifest.py` holds it.""" for path in adopter.assembly_root_paths(): - assert found(adopter, [(path, "root")], [row(path)]) == [], path + codes = [said.split(":")[0] + for said in found(adopter, [(path, "root")], [row(path)])] + assert codes == (["FINDING plan-root-blocks-manifest"] + if path == adopter.SHAPE_MANIFEST else []), path @pytest.mark.parametrize("kept, tree, said", [