diff --git a/.gitignore b/.gitignore index 2fb4cee..2060e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ venv/ *.swp .idea/ .vscode/ + +# What scripts/composed_placements.py places for a composed run (plan 038 +# T022): linked from the composed tree, never committed. +/docs/ideation-dashboard-session-runbook.md diff --git a/scripts/composed_placements.py b/scripts/composed_placements.py new file mode 100755 index 0000000..69e1a78 --- /dev/null +++ b/scripts/composed_placements.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""Place, for a COMPOSED run, the files this checkout's suites read from the +composed tree (plan 038 T022, U-3; R2Q8 (a); P4F-3, ruled with plan 038 on +opensoft/openxFactory#656 comment 6013547504). + +WHY IT EXISTS. 12.5's falsifier runs openxFactory's governed suites in this +checkout, composed: openxFactory checked out with its submodules initialized +recursively, and its `scripts/` on `PYTHONPATH` (R1Q23 (a)). Two of those +suites read the session runbook at this checkout's +`docs/ideation-dashboard-session-runbook.md`, which is where it sat before the +carve: `tests/test_session_runbook.py` (its module constant `RUNBOOK`) and +`tests/test_session_notebook.py` (inline). The carve sent the runbook to +openDox-spec (`docs/opendox-carve-manifest.yaml`, in openxFactory), and this +checkout carries none. Neither suite may be edited outside an allow-list +entry, and a module constant lies inside no test. + +WHY A PLACEMENT AND NOT A COPY. openXdox-code's copy record +(`src/openxdox/contracts/copies.yaml`) is single-source: openXdox-spec, at one +commit, and the consumer validator's three kinds. An openDox-spec document +cannot be a row in it, and a second copy of it here would be one more copy to +keep in step. So the runbook is LINKED, for the run, from the composed tree, +where it is openDox-spec's own file at the commit the openDox root pins. The +placed path is in `.gitignore`, so the checkout stays clean and nothing placed +can be committed. + +WHAT IT CHECKS BEFORE IT PLACES ANYTHING, refusing (exit 2) and naming the +remedy otherwise: + * each source is a regular file (not a link) openDox-spec TRACKS whose + bytes are the pinned blob, in a spec checkout whose HEAD is the commit + the openDox root's `spec` gitlink names, each answered by its own + repository and work tree. So the run reads the pinned document, and not + whatever a working tree or an index flag says; + * each destination is ignored by this checkout's own `.gitignore`, not + only by a local exclude (`.git/info/exclude`, `core.excludesFile`); + * a destination already present is this script's own link to that same + source. Anything else there is refused, never overwritten. +It places all or nothing: every check passes before the first link is made. + +USAGE, from anywhere: + + python3 scripts/composed_placements.py --openxfactory "$OPENXFACTORY" + python3 scripts/composed_placements.py --openxfactory "$OPENXFACTORY" --remove + +`--remove` takes back this script's own links and nothing else. The composed +workflow (T029) and the documented local composed run both call it before +they run the suites. The standard library and `git` are all it needs. + +A CREATED file: no row in openxFactory's `docs/opendox-carve-manifest.yaml` +(RULED OQ-C). +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +CHECKOUT = Path(__file__).resolve().parents[1] + +#: The openDox root inside the openxFactory checkout, and its `spec` leg. +OPENDOX_ROOT = PurePosixPath("openDox") +SPEC_LEG = PurePosixPath("spec") + + +@dataclass(frozen=True) +class Placement: + """One file the composed run places: `source`, a path inside openDox-spec, + linked to `destination`, a path inside this checkout.""" + + source: PurePosixPath + destination: PurePosixPath + + +#: Every placement, and the only ones. A new one comes with its reader's +#: reason here and its path in `.gitignore`. +PLACEMENTS: tuple[Placement, ...] = ( + # The session runbook: read by tests/test_session_runbook.py (`RUNBOOK`) + # and tests/test_session_notebook.py, at this checkout's `docs/`. + Placement(PurePosixPath("docs/ideation-dashboard-session-runbook.md"), + PurePosixPath("docs/ideation-dashboard-session-runbook.md")), +) + + +class PlacementRefused(Exception): + """A placement this script will not make, with the reason and the remedy.""" + + +#: Git variables that select a repository, work tree or index. Git honours +#: them over the directory each check runs in, and it sets `GIT_DIR` and +#: `GIT_WORK_TREE` for every hook it runs, so a hook running this script would +#: point the pin, clean and ignore checks at another repository. They are +#: dropped for every call, as `openxdox.domain_corpus_adapter`'s +#: `AMBIENT_GIT_VARIABLES` drops them (Copilot r4200975244). +AMBIENT_GIT_VARIABLES = ( + "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CEILING_DIRECTORIES", "GIT_PREFIX", +) + + +def _git(*args: str, cwd: Path) -> subprocess.CompletedProcess: + environment = {key: value for key, value in os.environ.items() + if key not in AMBIENT_GIT_VARIABLES} + try: + return subprocess.run(["git", *args], cwd=cwd, env=environment, + capture_output=True, text=True, check=False) + except FileNotFoundError as exc: + raise PlacementRefused( + "git is not on PATH, and the placements are checked with it") from exc + + +def _is_its_own_work_tree(path: Path) -> bool: + """Git answers for `path` from `path`'s own repository, whose work tree is + `path`: not from a repository above it (an uninitialized submodule is an + empty directory) and not from a work tree its configuration puts + elsewhere (`core.worktree`). As `openxdox.domain_corpus_adapter` checks + `--show-toplevel` before it trusts a revision (Copilot r4201032886).""" + if not (path / ".git").exists(): + return False + top = _git("rev-parse", "--show-toplevel", cwd=path) + return (top.returncode == 0 + and Path(top.stdout.strip()).resolve() == path.resolve()) + + +def _pinned_spec(openxfactory: Path) -> Path: + """The spec checkout, once its HEAD is the commit the openDox root pins.""" + root = openxfactory / OPENDOX_ROOT + spec = root / SPEC_LEG + pinned = _git("rev-parse", "--verify", f"HEAD:{SPEC_LEG}", cwd=root) \ + if _is_its_own_work_tree(root) else None + if pinned is None or pinned.returncode != 0: + raise PlacementRefused( + f"{root} is not an openDox root checkout with a `spec` gitlink. " + "Check openxFactory out with its submodules initialized " + "recursively (`git submodule update --init --recursive`).") + # Its own work tree: an uninitialized leg is an empty directory, and git + # would answer for the openDox root above it. + head = _git("rev-parse", "--verify", "HEAD", cwd=spec) \ + if _is_its_own_work_tree(spec) else None + if head is None or head.returncode != 0: + raise PlacementRefused( + f"{spec} is not checked out. Initialize openxFactory's submodules " + "recursively (`git submodule update --init --recursive`).") + if head.stdout.strip() != pinned.stdout.strip(): + raise PlacementRefused( + f"{spec} is at {head.stdout.strip()}, and the openDox root pins " + f"{pinned.stdout.strip()}. The run must read the pinned document: " + "run `git submodule update --init --recursive` in the openxFactory " + "checkout.") + return spec + + +def _check(placement: Placement, spec: Path) -> tuple[Path, Path]: + """The placement's source and destination, once every check has passed.""" + source = spec / placement.source + tracked = _git("ls-files", "--error-unmatch", "--", str(placement.source), cwd=spec) + if tracked.returncode != 0 or not source.is_file(): + raise PlacementRefused( + f"openDox-spec does not track {placement.source} at its pinned " + "commit, so there is nothing pinned to place") + # No link in ANY component below the spec leg, the file's or a parent + # directory's: a link's target is not fixed by the pin (Copilot + # r4201424060, r4201912214). + for depth in range(1, len(placement.source.parts) + 1): + part = spec.joinpath(*placement.source.parts[:depth]) + if part.is_symlink(): + raise PlacementRefused( + f"{part} is a symbolic link. The run links only the regular " + "file openDox-spec tracks at its pinned commit, reached " + "through no link, whose bytes a link does not fix.") + # The bytes themselves against the pinned blob. `git diff` can call a file + # clean without reading it, where the index marks it `assume-unchanged` or + # `skip-worktree` (Copilot r4201032934). And `--no-filters`: by default + # `hash-object` applies the path's clean filters and EOL normalization, + # which can turn edited bytes back into the pinned blob (Copilot + # r4201850292). The raw bytes are what the placed link exposes. + pinned_blob = _git("rev-parse", "--verify", f"HEAD:{placement.source}", + cwd=spec) + on_disk = _git("hash-object", "--no-filters", "--", str(placement.source), + cwd=spec) + if (pinned_blob.returncode != 0 or on_disk.returncode != 0 + or pinned_blob.stdout.strip() != on_disk.stdout.strip()): + raise PlacementRefused( + f"{source} differs from openDox-spec's pinned commit. The run must " + "read the pinned document; restore it with `git checkout -- " + f"{placement.source}` in {spec}.") + destination = CHECKOUT / placement.destination + # Ignored by the repository-root `.gitignore` itself, never by a local + # exclude (`.git/info/exclude`, `core.excludesFile`), which no other + # checkout carries. `-v` prints the deciding pattern as + # `::`, its source relative to the + # repository root, and exits 0 for a negated (`!`) pattern too. + verdict = _git("check-ignore", "-v", "--", str(placement.destination), + cwd=CHECKOUT) + ignored_by, _, rest = verdict.stdout.partition(":") + pattern = rest.partition(":")[2].partition("\t")[0] + if verdict.returncode != 0 or pattern.startswith("!"): + raise PlacementRefused( + f"{placement.destination} is not ignored by this checkout's " + ".gitignore, so a placed file could be committed. Name it there.") + if ignored_by != ".gitignore": + raise PlacementRefused( + f"{placement.destination} is ignored only by {ignored_by}, a local " + "exclude that no other checkout carries, and not by this " + "checkout's .gitignore, so a placed file could be committed " + "elsewhere. Name it in .gitignore.") + if os.path.lexists(destination): + if not (destination.is_symlink() + and Path(os.readlink(destination)) == source): + raise PlacementRefused( + f"{placement.destination} already exists and is not this " + f"script's link to {source}. It is not overwritten; move it " + "away first.") + return source, destination + + +def place(openxfactory: Path) -> list[str]: + """Make every placement, all or nothing. Answers what was done.""" + spec = _pinned_spec(openxfactory.resolve()) + checked = [(placement, *_check(placement, spec)) for placement in PLACEMENTS] + done = [] + for placement, source, destination in checked: + if destination.is_symlink(): + done.append(f"already placed {placement.destination} -> {source}") + continue + destination.parent.mkdir(parents=True, exist_ok=True) + destination.symlink_to(source) + done.append(f"placed {placement.destination} -> {source}") + return done + + +def remove(openxfactory: Path) -> list[str]: + """Take back this script's own links, and nothing else.""" + spec = openxfactory.resolve() / OPENDOX_ROOT / SPEC_LEG + done = [] + for placement in PLACEMENTS: + destination = CHECKOUT / placement.destination + if not os.path.lexists(destination): + continue + if not (destination.is_symlink() + and Path(os.readlink(destination)) == spec / placement.source): + raise PlacementRefused( + f"{placement.destination} is not this script's link, so it " + "is not removed") + destination.unlink() + done.append(f"removed {placement.destination}") + return done + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Place, for a composed run, the files this checkout's " + "suites read from the composed tree (plan 038 T022).") + parser.add_argument("--openxfactory", required=True, type=Path, + help="the openxFactory checkout of the composed run, " + "its submodules initialized recursively") + parser.add_argument("--remove", action="store_true", + help="take back this script's own links") + args = parser.parse_args(argv) + try: + done = (remove if args.remove else place)(args.openxfactory) + except PlacementRefused as exc: + print(f"composed_placements: refused: {exc}", file=sys.stderr) + return 2 + for line in done: + print(f"composed_placements: {line}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_composed_placements.py b/tests/test_composed_placements.py new file mode 100644 index 0000000..741743c --- /dev/null +++ b/tests/test_composed_placements.py @@ -0,0 +1,463 @@ +"""`scripts/composed_placements.py`, held in CI (plan 038 T022, U-3; option (a) +of the holder's ruling on opensoft/openxFactory#656, comment 6016356225). + +WHY HERE. The script makes links in this checkout's working tree for a +composed run, and refuses (exit 2) whatever would let that run read anything +but the pinned document, or overwrite or take away anything but its own link. +A script that changes the file system keeps its guards in the suite CI runs, +so each guard below is a case. + +HOW. Each case runs the script as the composed run calls it: in a child +interpreter, against a scratch openxFactory tree whose openDox root pins +commit A of a scratch spec repository, where commit B changes the runbook. +The script it runs is a COPY of this checkout's script, inside a scratch +checkout that also carries a copy of this checkout's own `.gitignore`. The +script places relative to its own location, so no case writes into this +checkout. And the `.gitignore` the cases read is the one this checkout +commits, so a placement it stops ignoring turns the first case red. + +The standard library, `git` and pytest are all it needs; no case skips. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import pytest + +CHECKOUT = Path(__file__).resolve().parents[1] +SCRIPT = Path("scripts") / "composed_placements.py" + +#: The one placement: openDox-spec's session runbook, at the same path here. +RUNBOOK = "docs/ideation-dashboard-session-runbook.md" + + +def _environment(**overrides: str) -> dict[str, str]: + """The child's environment: no inherited `GIT_*` (a hook's `GIT_DIR` + would aim every call at another repository), no user or system git + configuration, and a fixed identity for the scratch commits.""" + environment = {key: value for key, value in os.environ.items() + if not key.startswith("GIT_")} + environment.update( + GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1", + GIT_AUTHOR_NAME="composed placements", GIT_AUTHOR_EMAIL="cases@invalid", + GIT_COMMITTER_NAME="composed placements", + GIT_COMMITTER_EMAIL="cases@invalid") + environment.update(overrides) + return environment + + +def _git(*args: str, cwd: Path) -> str: + return subprocess.run(["git", *args], cwd=cwd, env=_environment(), + check=True, capture_output=True, text=True).stdout.strip() + + +@dataclass +class Composed: + """The scratch checkout, the scratch composed tree, and the script.""" + + checkout: Path + openxfactory: Path + root: Path + spec: Path + + @property + def destination(self) -> Path: + return self.checkout / RUNBOOK + + @property + def source(self) -> Path: + return self.spec / RUNBOOK + + @property + def pinned(self) -> str: + return _git("rev-parse", "HEAD:spec", cwd=self.root) + + def run(self, *extra: str, **environment: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(self.checkout / SCRIPT), + "--openxfactory", str(self.openxfactory), *extra], + capture_output=True, text=True, env=_environment(**environment)) + + def refused(self, *extra: str, **environment: str) -> str: + """Run the script, hold it to a refusal, and answer what it said.""" + result = self.run(*extra, **environment) + assert result.returncode == 2, (result.returncode, result.stdout, + result.stderr) + assert result.stdout == "", result.stdout + assert result.stderr.startswith("composed_placements: refused: "), \ + result.stderr + return result.stderr + + +def _spec_repository(spec: Path) -> str: + """A spec repository whose commit A holds runbook A and whose commit B + changes it; checked out at A. Answers A.""" + spec.mkdir(parents=True) + _git("init", "-q", cwd=spec) + (spec / RUNBOOK).parent.mkdir(parents=True) + (spec / RUNBOOK).write_text("runbook A\n", encoding="utf-8") + _git("add", RUNBOOK, cwd=spec) + _git("commit", "-q", "-m", "A", cwd=spec) + commit_a = _git("rev-parse", "HEAD", cwd=spec) + (spec / RUNBOOK).write_text("runbook B\n", encoding="utf-8") + _git("commit", "-q", "-a", "-m", "B", cwd=spec) + _git("checkout", "-q", commit_a, cwd=spec) + return commit_a + + +def _opendox_root(root: Path, pinned: str) -> None: + """An openDox root whose `spec` gitlink pins `pinned`.""" + root.mkdir(parents=True, exist_ok=True) + _git("init", "-q", cwd=root) + _git("update-index", "--add", "--cacheinfo", f"160000,{pinned},spec", + cwd=root) + _git("commit", "-q", "-m", "the openDox root", cwd=root) + + +@pytest.fixture +def composed(tmp_path: Path) -> Composed: + checkout = tmp_path / "openXdox-code" + (checkout / SCRIPT).parent.mkdir(parents=True) + shutil.copyfile(CHECKOUT / SCRIPT, checkout / SCRIPT) + shutil.copyfile(CHECKOUT / ".gitignore", checkout / ".gitignore") + _git("init", "-q", cwd=checkout) + _git("add", ".gitignore", str(SCRIPT), cwd=checkout) + _git("commit", "-q", "-m", "the scratch checkout", cwd=checkout) + openxfactory = tmp_path / "openxFactory" + root = openxfactory / "openDox" + spec = root / "spec" + _opendox_root(root, _spec_repository(spec)) + return Composed(checkout, openxfactory, root, spec) + + +# --- what it places ------------------------------------------------------- + + +def test_a_pinned_unmodified_runbook_is_linked_and_leaves_the_checkout_clean( + composed): + result = composed.run() + assert result.returncode == 0, result.stderr + assert result.stdout == f"composed_placements: placed {RUNBOOK} -> " \ + f"{composed.source.resolve()}\n" + assert composed.destination.is_symlink() + assert Path(os.readlink(composed.destination)) == composed.source.resolve() + assert composed.destination.read_text(encoding="utf-8") == "runbook A\n" + # Ignored by the checkout's own `.gitignore`: nothing placed can be committed. + assert _git("status", "--porcelain", "--untracked-files=all", + cwd=composed.checkout) == "" + + +def test_placing_again_is_a_no_op(composed): + assert composed.run().returncode == 0 + result = composed.run() + assert result.returncode == 0, result.stderr + assert result.stdout.startswith(f"composed_placements: already placed {RUNBOOK}") + assert composed.destination.read_text(encoding="utf-8") == "runbook A\n" + + +def test_remove_takes_back_its_own_link_and_nothing_else(composed): + assert composed.run().returncode == 0 + result = composed.run("--remove") + assert result.returncode == 0, result.stderr + assert result.stdout == f"composed_placements: removed {RUNBOOK}\n" + assert not os.path.lexists(composed.destination) + assert composed.source.read_text(encoding="utf-8") == "runbook A\n" + # And with nothing placed, there is nothing to take back. + again = composed.run("--remove") + assert (again.returncode, again.stdout) == (0, ""), again.stderr + + +# --- what it refuses, placing nothing -------------------------------------- + + +def test_a_spec_checkout_off_the_opendox_roots_pin_is_refused(composed): + _git("checkout", "-q", "-", cwd=composed.spec) # commit B + said = composed.refused() + assert f"and the openDox root pins {composed.pinned}" in said + assert "git submodule update --init --recursive" in said + assert not os.path.lexists(composed.destination) + + +def test_a_modified_runbook_is_refused(composed): + composed.source.write_text("edited in the working tree\n", encoding="utf-8") + said = composed.refused() + assert "differs from openDox-spec's pinned commit" in said + assert not os.path.lexists(composed.destination) + + +def test_a_runbook_the_spec_does_not_track_is_refused(composed): + _git("rm", "-q", "--cached", RUNBOOK, cwd=composed.spec) + said = composed.refused() + assert f"openDox-spec does not track {RUNBOOK}" in said + assert not os.path.lexists(composed.destination) + + +def test_an_uninitialized_spec_leg_is_refused_as_not_checked_out(composed): + # What a composed checkout without `--recursive` holds: an empty directory, + # where git would answer for the openDox root above it. + shutil.rmtree(composed.spec) + composed.spec.mkdir() + said = composed.refused() + assert "is not checked out" in said + assert "git submodule update --init --recursive" in said + assert not os.path.lexists(composed.destination) + + +def test_an_uninitialized_opendox_root_is_refused(composed): + shutil.rmtree(composed.root) + composed.root.mkdir() + # An openxFactory checkout above it, whose own tree has no `spec`. + _git("init", "-q", cwd=composed.openxfactory) + (composed.openxfactory / "README.md").write_text("openxFactory\n", encoding="utf-8") + _git("add", "README.md", cwd=composed.openxfactory) + _git("commit", "-q", "-m", "openxFactory", cwd=composed.openxfactory) + said = composed.refused() + assert "is not an openDox root checkout with a `spec` gitlink" in said + assert not os.path.lexists(composed.destination) + + +def _drop_the_gitignore_line(composed: Composed) -> None: + gitignore = composed.checkout / ".gitignore" + text = gitignore.read_text(encoding="utf-8") + assert f"/{RUNBOOK}\n" in text, "this checkout's .gitignore names the runbook" + gitignore.write_text(text.replace(f"/{RUNBOOK}\n", ""), encoding="utf-8") + + +def _exclude_locally(composed: Composed, where: str, tmp_path: Path) -> None: + """Name the runbook in a local exclude, which no other checkout carries.""" + if where == "info/exclude": + exclude = composed.checkout / ".git" / "info" / "exclude" + exclude.parent.mkdir(parents=True, exist_ok=True) + with exclude.open("a", encoding="utf-8") as handle: + handle.write(f"/{RUNBOOK}\n") + else: + excludes = tmp_path / "excludes" + excludes.write_text(f"/{RUNBOOK}\n", encoding="utf-8") + _git("config", "core.excludesFile", str(excludes), cwd=composed.checkout) + + +def test_a_destination_the_gitignore_does_not_name_is_refused(composed): + _drop_the_gitignore_line(composed) + said = composed.refused() + assert "is not ignored by this checkout's .gitignore" in said + assert not os.path.lexists(composed.destination) + + +@pytest.mark.parametrize("where", ["info/exclude", "core.excludesFile"]) +def test_a_destination_only_a_local_exclude_names_is_refused(composed, tmp_path, + where): + # Copilot r4196244480; the holder's ruling on opensoft/openxFactory#656, + # comment 6026275158, item 2: the root .gitignore must be the source. + _drop_the_gitignore_line(composed) + _exclude_locally(composed, where, tmp_path) + said = composed.refused() + assert "a local exclude that no other checkout carries" in said + assert not os.path.lexists(composed.destination) + + +def test_a_local_exclude_beside_the_gitignore_line_still_places(composed, + tmp_path): + # The root .gitignore outranks a local exclude, so it stays the source. + _exclude_locally(composed, "info/exclude", tmp_path) + result = composed.run() + assert result.returncode == 0, result.stderr + assert composed.destination.is_symlink() + + +def test_a_gitignore_that_negates_the_destination_is_refused(composed): + # `git check-ignore -v` exits 0 for a negated pattern too. + with (composed.checkout / ".gitignore").open("a", encoding="utf-8") as handle: + handle.write(f"!/{RUNBOOK}\n") + said = composed.refused() + assert "is not ignored by this checkout's .gitignore" in said + assert not os.path.lexists(composed.destination) + + +def test_without_git_on_path_it_refuses(composed, tmp_path): + nothing = tmp_path / "an-empty-PATH" + nothing.mkdir() + said = composed.refused(PATH=str(nothing)) + assert "git is not on PATH" in said + assert not os.path.lexists(composed.destination) + + +@pytest.mark.parametrize("edited", [False, True], ids=["clean", "edited"]) +def test_ambient_git_repository_variables_do_not_redirect_the_checks( + composed, tmp_path, edited): + # Copilot r4200975244: git sets GIT_DIR and GIT_WORK_TREE for every hook, + # and honours them over the directory each check runs in. Pointed at a + # decoy repository, they must change nothing. + decoy = tmp_path / "decoy" + decoy.mkdir() + _git("init", "-q", cwd=decoy) + (decoy / "README.md").write_text("decoy\n", encoding="utf-8") + _git("add", "README.md", cwd=decoy) + _git("commit", "-q", "-m", "decoy", cwd=decoy) + ambient = {"GIT_DIR": str(decoy / ".git"), "GIT_WORK_TREE": str(decoy), + "GIT_INDEX_FILE": str(decoy / ".git" / "index")} + if edited: + composed.source.write_text("edited in the working tree\n", + encoding="utf-8") + said = composed.refused(**ambient) + assert "differs from openDox-spec's pinned commit" in said + assert not os.path.lexists(composed.destination) + else: + result = composed.run(**ambient) + assert result.returncode == 0, result.stderr + assert composed.destination.read_text(encoding="utf-8") == "runbook A\n" + + +@pytest.mark.parametrize("flag", ["--assume-unchanged", "--skip-worktree"]) +def test_an_edit_the_index_is_told_to_overlook_is_refused(composed, flag): + # Copilot r4201032934: with either flag `git diff` calls the file clean + # without reading it. The check compares the bytes with the pinned blob. + _git("update-index", flag, RUNBOOK, cwd=composed.spec) + composed.source.write_text("edited in the working tree\n", encoding="utf-8") + said = composed.refused() + assert "differs from openDox-spec's pinned commit" in said + assert not os.path.lexists(composed.destination) + + +@pytest.mark.parametrize("attribute", ["filter=hide", "text"], + ids=["clean-filter", "eol-normalization"]) +def test_an_edit_a_clean_filter_would_hide_is_refused(composed, attribute): + # Copilot r4201850292: `git hash-object` applies the path's clean filters + # by default, which can turn edited bytes back into the pinned blob. The + # check hashes the raw bytes the placed link exposes. + attributes = composed.spec / ".git" / "info" / "attributes" + attributes.parent.mkdir(parents=True, exist_ok=True) + attributes.write_text(f"/{RUNBOOK} {attribute}\n", encoding="utf-8") + if attribute == "filter=hide": + _git("config", "filter.hide.clean", "printf 'runbook A\\n'", + cwd=composed.spec) + edited = b"edited in the working tree\n" + else: + edited = b"runbook A\r\n" + composed.source.write_bytes(edited) + said = composed.refused() + assert "differs from openDox-spec's pinned commit" in said + assert not os.path.lexists(composed.destination) + + +def _pin(composed: Composed, commit: str) -> None: + """Move the openDox root's `spec` gitlink to `commit`, and check it out.""" + _git("checkout", "-q", commit, cwd=composed.spec) + _git("update-index", "--cacheinfo", f"160000,{commit},spec", cwd=composed.root) + _git("commit", "-q", "-m", "pin", cwd=composed.root) + + +def test_a_runbook_the_pinned_commit_tracks_as_a_link_is_refused(composed): + # Copilot r4201424060: the pinned "document" is a link to a file whose + # bytes `git diff` on the link never reads. + real = composed.spec / "docs" / "real-runbook.md" + real.write_text("runbook A\n", encoding="utf-8") + composed.source.unlink() + composed.source.symlink_to("real-runbook.md") + _git("add", "-A", "docs", cwd=composed.spec) + _git("commit", "-q", "-m", "the runbook as a link", cwd=composed.spec) + _pin(composed, _git("rev-parse", "HEAD", cwd=composed.spec)) + real.write_text("edited behind the link\n", encoding="utf-8") + said = composed.refused() + assert "is a symbolic link" in said + assert not os.path.lexists(composed.destination) + + +def test_a_runbook_replaced_by_a_link_to_identical_bytes_is_refused(composed, + tmp_path): + # Identical bytes today; a link can be repointed after the check. + copy = tmp_path / "identical-runbook.md" + copy.write_text("runbook A\n", encoding="utf-8") + composed.source.unlink() + composed.source.symlink_to(copy) + said = composed.refused() + assert "is a symbolic link" in said + assert not os.path.lexists(composed.destination) + + +def test_a_runbook_reached_through_a_linked_directory_is_refused(composed, + tmp_path): + # Copilot r4201912214: a link in ANY component below the spec leg. Here + # `docs/` points at an outside copy whose bytes are identical today. + external = tmp_path / "external-docs" + shutil.copytree(composed.spec / "docs", external) + shutil.rmtree(composed.spec / "docs") + (composed.spec / "docs").symlink_to(external, target_is_directory=True) + said = composed.refused() + assert "is a symbolic link" in said + assert not os.path.lexists(composed.destination) + + +def test_an_opendox_root_answered_by_an_enclosing_repository_is_refused( + composed): + # Copilot r4201032886: without its own repository, git would answer + # `HEAD:spec` from the repository above, if its tree had a `spec` entry. + pinned = composed.pinned + shutil.rmtree(composed.root / ".git") + _git("init", "-q", cwd=composed.openxfactory) + _git("update-index", "--add", "--cacheinfo", f"160000,{pinned},spec", + cwd=composed.openxfactory) + _git("commit", "-q", "-m", "an enclosing repository", cwd=composed.openxfactory) + said = composed.refused() + assert "is not an openDox root checkout with a `spec` gitlink" in said + assert not os.path.lexists(composed.destination) + + +def test_an_opendox_root_without_a_spec_gitlink_is_refused(composed): + _git("rm", "-q", "--cached", "spec", cwd=composed.root) + _git("commit", "-q", "-m", "no spec leg", cwd=composed.root) + said = composed.refused() + assert "is not an openDox root checkout with a `spec` gitlink" in said + assert not os.path.lexists(composed.destination) + + +def test_a_spec_leg_whose_work_tree_is_configured_elsewhere_is_refused( + composed, tmp_path): + # Copilot r4201032886: `core.worktree` points the checks at another tree. + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + _git("config", "core.worktree", str(elsewhere), cwd=composed.spec) + said = composed.refused() + assert "is not checked out" in said + assert not os.path.lexists(composed.destination) + + +# --- what it never overwrites or takes away --------------------------------- + + +def _a_file_of_someone_elses(composed: Composed) -> None: + composed.destination.parent.mkdir(parents=True) + composed.destination.write_text("someone's own file\n", encoding="utf-8") + + +def _a_link_elsewhere(composed: Composed, tmp_path: Path) -> Path: + elsewhere = tmp_path / "another-runbook.md" + elsewhere.write_text("another runbook\n", encoding="utf-8") + composed.destination.parent.mkdir(parents=True) + composed.destination.symlink_to(elsewhere) + return elsewhere + + +@pytest.mark.parametrize("verb", [(), ("--remove",)], ids=["place", "remove"]) +def test_a_file_at_the_destination_is_refused_and_kept(composed, verb): + _a_file_of_someone_elses(composed) + said = composed.refused(*verb) + assert ("is not this script's link, so it is not removed" if verb + else "already exists and is not this script's link") in said + assert not composed.destination.is_symlink() + assert composed.destination.read_text(encoding="utf-8") == "someone's own file\n" + + +@pytest.mark.parametrize("verb", [(), ("--remove",)], ids=["place", "remove"]) +def test_a_link_elsewhere_at_the_destination_is_refused_and_kept(composed, + tmp_path, verb): + elsewhere = _a_link_elsewhere(composed, tmp_path) + said = composed.refused(*verb) + assert ("is not this script's link, so it is not removed" if verb + else "already exists and is not this script's link") in said + assert Path(os.readlink(composed.destination)) == elsewhere