diff --git a/adopt-project.py b/adopt-project.py index cdd45383..b8f0c5d4 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -56,12 +56,18 @@ any case), the source's own submodules kept on one leg with their `.gitmodules`, registered there and never in the root, each relative url naming the same repository from the leg as from the - source, the tracking branch a branch of the source, which the - pull request is opened against, and the split branch one - `execute` can make: none of its name where the split is pushed - that the push cannot fast-forward, and none git cannot hold - beside it there or in the source. It prints what will happen and - changes nothing. + source, no symlink kept in the root that the shape would write + one of its files, or that file's copy under `shape/`, through + where it cannot (one that dangles, loops, or leads out of the + root or into a leg, one that leads the copy onto something + already there, or one that carries the shape's scripts anywhere + but one directory directly under the root), whether the disk + keeps case or ignores it, the tracking branch a branch of the + source, which the pull request is opened against, and the split + branch one `execute` can make: none of its name where the split + is pushed that the push cannot fast-forward, and none git cannot + hold beside it there or in the source. It prints what will happen + and changes nothing. execute creates the two legs, extracts them with `git filter-repo`, makes the one split commit on a branch of the source, sets the `xf-project-` topic on all three and then opens the split's @@ -80,7 +86,9 @@ any `leg:` is still null, and on every other finding `check` reports, before it has created a leg repository or pushed anything -- a source submodule's relative url resolved again from the leg remotes it is about to create, which -under `--local-remote-dir` are not the GitHub ones `check` resolves from. +under `--local-remote-dir` are not the GitHub ones `check` resolves from, and +the place outside the assembly that a symlink kept at a path the shape writes +leads to asked again of the disk, a climb from its own work directory. THE NEW PROJECT MUST BE ABLE TO HOLD WHAT `execute` WRITES INTO IT (#261). `execute` puts the plan's values into the root's `project.yaml`, and the @@ -143,7 +151,7 @@ import subprocess import sys import tempfile -from pathlib import Path +from pathlib import Path, PurePosixPath SHAPE_ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(SHAPE_ROOT / "scripts")) @@ -158,10 +166,11 @@ ) from shape_materialize import ( # noqa: E402 ADOPT_MAKEFILE_BLOCK, SHAPE_REPOSITORY, - CommandFailed, Materialized, assembly_root_paths, collision_follow_up, - copy_tree, default_reference, election_date, env_commit, - escape_what_stdout_cannot_encode, git_init_commit, - materialize_assembly_root, naming_block, run, write_lf, + SYMLINK_HOPS, TOO_MANY_SYMLINKS, CommandFailed, Materialized, + assembly_root_paths, + collision_follow_up, copy_tree, default_reference, election_date, + env_commit, escape_what_stdout_cannot_encode, git_init_commit, + materialize_assembly_root, naming_block, run, template_files, write_lf, write_paths_listing, ) # The leg-path rule (#179) is `scaffold-project.py`'s as much as adopt's, so it @@ -489,9 +498,6 @@ def _pin_findings(plan: "Plan", tree: list) -> list[str]: #: The modes of a regular file in a tree, executable or not: what a #: checkout's `is_file()` finds, so the only thing a pinned path may lead to. FILE_MODES = ("100644", "100755") -#: How many symlinks one lookup follows before it is a loop: Linux's own -#: limit (MAXSYMLINKS), where `open()` in a checkout gives up with ELOOP. -SYMLINK_HOPS = 40 LEG_VALUES = ("spec", "code", "root", "drop") FILE_PROTOCOL = ["-c", "protocol.file.allow=always"] #: The file `git submodule add` records a mount in. `_mount_the_legs` checks, @@ -502,6 +508,10 @@ def _pin_findings(plan: "Plan", tree: list) -> list[str]: #: The mode `git ls-tree` reports for a SUBMODULE: a commit of another #: repository, recorded in this tree as a gitlink rather than as a file. GITLINK_MODE = "160000" +#: How git and YAML both spell a false boolean: what `git config --type=bool` +#: prints for one, one of the words git reads as false (BOOLEAN_WORDS), and +#: what `y` writes for Python's False. +FALSE_WORD = "false" #: THE SPEC-ONLY CASE. A repository can honestly have nothing for one leg — #: InkRouter's IRRS and IRSS are specifications with no implementation yet @@ -555,7 +565,7 @@ def y(value) -> str: if value is None: return "null" if isinstance(value, bool): - return "true" if value else "false" + return "true" if value else FALSE_WORD if isinstance(value, int): return str(value) if isinstance(value, float): @@ -583,8 +593,9 @@ def emit(lines: list[str], key: str, value, indent: int = 0) -> None: #: The `git` subcommand that reads and writes a config file: a source's #: `.gitmodules`, a leg mount's url, the elector's name. GIT_CONFIG = "config" -#: The `git` subcommand that prints one object: a source's `.gitmodules`, -#: and in the split commit a symlink's target and the shape pin. +#: The `git` subcommand that prints one object: a source's `.gitmodules` +#: and the target of a symlink it keeps in the root, and in the split commit +#: a symlink's target and the shape pin. GIT_CAT_FILE = "cat-file" @@ -698,7 +709,7 @@ def _move(self, name: str, path: str) -> None: UPDATE_STRATEGIES = ("checkout", "rebase", "merge", "none") #: The words git reads as a boolean, in any ASCII case (config.c / parse.c, #: `git_parse_maybe_bool_text`). -BOOLEAN_WORDS = ("true", "yes", "on", "false", "no", "off") +BOOLEAN_WORDS = ("true", "yes", "on", FALSE_WORD, "no", "off") #: An integer as `git_parse_signed` reads one: `strtoimax(value, &end, 0)`, #: which skips C whitespace and takes a sign and a hex, octal or decimal #: number -- and, where the C library reads C23 binary numbers (glibc 2.38 @@ -1007,11 +1018,17 @@ class Source: """A repository being read, and nothing more. Nothing here writes to it.""" def __init__(self, path: Path, repository: str | None, branch: str, - commit: str, cloned: bool = False): + commit: str, work_root: Path | None = None, + cloned: bool = False): self.path = path self.repository = repository self.branch = branch self.commit = commit + #: The work directory this run opened the source in: where a named + #: repository is cloned to be read, and where `execute` makes the + #: assembly's clone, so where a symlink the plan keeps in the root + #: leads when it climbs above the root (#183). + self.work_root = work_root #: Whether `open` CLONED it, into the work directory, rather than #: reading a local path in place: a re-run given the same --work-dir #: cannot clone into that directory again (`_cloned_here`). @@ -1065,7 +1082,7 @@ def open(cls, spec: str, work_root: Path) -> "Source": cloned = True branch = _default_branch(path) commit = git_out(["rev-parse", branch], cwd=path).lower() - return cls(path, repository, branch, commit, cloned) + return cls(path, repository, branch, commit, work_root, cloned) def tree(self) -> list[tuple[str, str, str, int]]: """(path, mode, blob sha, size) for every file at the pinned commit.""" @@ -4613,13 +4630,15 @@ def _entry_findings(plan: Plan, source: Source, what it prints once after them, and the NOTEs it prints beside them: each entry's path, then coverage, then each entry's `leg:`, the two mount paths and what the root keeps where the shape writes, then the - source's own submodules, then each value `execute` writes into a file - that no file can hold (#255), then what the project's own - `validate-manifest.py` would refuse of the values `execute` writes into - its manifest (#261), then a tracking branch that is no branch of the - source (#479), then each relative url of a kept submodule that names - another repository from its leg (#190), and last each plan value - `execute` reads late and `_plan_field_findings` asks about (#293, #294). + source's own submodules, then each symlink the plan keeps in the root + that a shape file cannot be written through (#178, #183), then each + value `execute` writes into a file that no file can hold (#255), then + what the project's own `validate-manifest.py` would refuse of the + values `execute` writes into its manifest (#261), then a tracking + branch that is no branch of the source (#479), then each relative url + of a kept submodule that names another repository from its leg + (#190), and last each plan value `execute` reads late and + `_plan_field_findings` asks about (#293, #294). ONE LIST, read by `check` and by `execute`'s `_refuse_what_check_finds`. #168 and #166 each added findings to `check` and a refusal to `execute` @@ -4627,10 +4646,15 @@ def _entry_findings(plan: Plan, source: Source, same lines; three compositions then disagreed, and a plan `check` rejected as uncovered AND split was refused by `execute` for the split alone, to be refused again for the rest once that was fixed. Composed - once, the two commands cannot differ about a plan -- but in the one - place a remote decides it: `local_remote_dir` is `execute`'s, and a - relative submodule url is resolved from the leg remotes it names, where - `check` has none and resolves from GitHub's (#190). + once, the two commands cannot differ about a plan -- but in the two + places something outside the plan decides it. `local_remote_dir` is + `execute`'s, and a relative submodule url is resolved from the leg + remotes it names, where `check` has none and resolves from GitHub's + (#190). And two things about a symlink AT a shape path are asked of the + disk by each command when it runs (#183): whether anything is at the + place outside the assembly it leads to, a climb above the root from its + own work directory; and whether `git` there checks it out as a symlink at + all, or as a plain file the shape's copy goes beside. `_leg_findings` is handed the TREE, because the paths the plan keeps in the root are read out of it (#172, #191): without it a mount path at, or @@ -4674,8 +4698,9 @@ def _entry_findings(plan: Plan, source: Source, field_findings, field_remediation = _plan_field_findings(plan) tracking_findings, tracking_remediation = _tracking_branch_findings( plan, source) - return (findings + split + unwritable_findings + value_findings - + tracking_findings + moved + field_findings, + return (findings + split + _root_symlink_findings(plan, source, tree) + + unwritable_findings + value_findings + tracking_findings + + moved + field_findings, unsafe_remediation + split_remediation + unwritable_remediation + value_remediation + tracking_remediation + moved_remediation + field_remediation, notes) @@ -5087,6 +5112,762 @@ def _print_what_will_happen(plan: Plan, source: Source, names: dict, print(f" follow-up: {_shown(item)}") +# --------------------------------------------------------------------------- +# A symlink the plan keeps in the root, and the shape writes through (#178) +# --------------------------------------------------------------------------- +# +# The shape's files are written into the assembly by plain path, so a symlink +# the plan keeps in the root at or above one of those paths is followed. +# Before #178, `check` asked nothing about such a link. One that led out of +# the tree made `execute` write the shape's `validate.yml` outside the +# assembly. One that led to nothing, into a leg, or round a loop made it die +# in a traceback. Both happened AFTER both leg remotes had been pushed, so +# the corrected plan then met `leg-remote-exists`. +# +# WHAT THE MATERIALIZER WILL DO decides each path, in the order it writes +# them (#183's review, B1). `place` first asks whether something is already +# at the path, following every symlink on the way. Where something is, the +# shape's copy goes beside it under `shape/`, and the link is never written +# through: `CLAUDE.md` linked to a personal file by its absolute path, on a +# machine that has that file, is adopted exactly as `main` adopts it. Where +# nothing is, the shape writes through every link on the way, so a link +# that dangles, loops or leads out of the assembly is refused. A symlink +# ABOVE a path the shape writes is a directory the shape writes into, so it +# must lead to a directory the root holds, whatever this machine holds +# elsewhere. And `place` asks where the shape's copy under `shape/` lands +# only of the disk, after both legs exist (#262's `copy_in_the_way`), while +# #262's `check` rule follows no link, so a symlink that leads the COPY onto +# something already there is refused here too (B2). +# +# Whether something is at a place OUTSIDE the assembly is a fact of the +# machine, so `check` and `execute` each ask their own disk: an absolute +# target as it stands, and a climb above the root from the work directory, +# where `execute` makes the assembly's clone. Each says what is true where it +# runs, and `execute` asks before any leg exists. Whether `git` checks a +# symlink out as a symlink at all is asked the same way. Where it does not +# (`core.symlinks` false, Git for Windows' default), a link AT a path the +# shape writes is a plain file in the clone, which `place` finds there and +# writes the copy beside, so nothing about where that link leads is refused +# (B-OFF of #183's re-verify); a link ABOVE one is `execute`'s +# `adopt-symlinks-off`, since nothing can be written beneath a file. A +# target is absolute as this machine's `open()` reads it: `C:/x`, a drive +# letter's path, is a place on this disk only on Windows. On Linux and macOS +# it is a relative path, into a directory named `C:`, and it is followed as +# one, as `open()` and the materializer follow it there. +# +# EACH DECLARED PIN'S FILE IS ONE OF THOSE PATHS (#273). `execute` hands the +# materializer `_recorded_pins`, and `place` writes each +# `contracts/-pin.yaml` after the copies and before the shape's own +# pin, so the walk follows `assembly_root_paths` of the same products, read +# as `_leg_collision_findings` reads them (`_declared_pins`). Walked without +# them, a link that only a pin's file runs through passed `check`, and +# `execute` stopped at it after both legs; and a link to a pin's file was +# taken to lead nowhere, though `place` meets the pin there. A symlink AT a +# pin's file is #273's `plan-root-holds-pin`, said once there. + +#: The finding for a symlink the plan keeps in the root that the shape cannot +#: write through. +ROOT_SYMLINK = "plan-root-symlink" +#: The directory the shape's scripts are written to, read off the +#: materializer's own name for one of them. +SHAPE_SCRIPTS = PurePosixPath(BOOTSTRAP).parent.as_posix() +#: The `leg:` answers that take a path out of the assembly root. +NOT_IN_THE_ROOT = (*EXTRACTED_LEGS, "drop") +#: What a symlink with an absolute target leads to. +ON_THIS_MACHINE = ("an absolute path: a place on this machine, not in the " + "assembly") +#: The parts of a path that name no entry, and leave a walk where it is. +HERE = ("", ".") +#: What the assembly's clone holds in its own `.git` when the shape is +#: written, on git's default configuration (F3 and 2b of #183's re-verify): +#: `.git` and the files git makes in every clone it checks out, and, in +#: CLONE_GIT_TREES, the directories that hold the source's objects and refs, +#: their logs, and the stores of the two legs `execute` has mounted by then, +#: each counted with whatever is under it, which the source and the plan +#: decide and this check does not read. A symlink AT a path the shape writes +#: that leads to one of them, or to anything under one of CLONE_GIT_TREES, is +#: taken to find something there, so `place` writes the shape's copy beside +#: it and keeps the link, as on `main`. Of a name under those directories +#: that is NOT there, that is false, and the link is not refused here: the +#: shape's file is written through it into `.git`, or the run stops there +#: after both legs exist, as on `main` (J1 of #183's re-verify). Nothing +#: else is in that `.git` before the split's commit: a template makes +#: `description`, `hooks` and `info`, and `execute` clones with none (#243). +#: A link that leads the shape to anything else there writes its file INTO +#: `.git`, where no commit holds it. (On a git configured otherwise -- the +#: reftable backend, `core.logAllRefUpdates=false` -- some of these are +#: missing, and a link to one is not refused here: the shape is written +#: through it, or stops there after both legs exist.) +CLONE_GIT = frozenset({".git", ".git/HEAD", ".git/config", ".git/index", + ".git/packed-refs"}) +#: `CLONE_GIT` as a disk that ignores case finds it. +CLONE_GIT_FOLDED = frozenset(path.casefold() for path in CLONE_GIT) +#: The directories of the clone's `.git` counted with all they hold. +CLONE_GIT_TREES = (".git/objects", ".git/refs", ".git/logs", ".git/modules") + + +def _absolute_here(target: str) -> bool: + """Whether this machine's `open()` reads the symlink target `target` as + an absolute path: one from `/` anywhere, and a drive letter's (`C:/x`) + on Windows only.""" + return target.startswith("/") or ( + os.name == "nt" and WINDOWS_ABSOLUTE_RE.match(target) is not None) + + +class _Walk: + """One path the shape writes, followed through a `_PlannedRoot` as the + materializer's `open()` will follow it: the symlinks met on the way, in + order, as `(path, target)`; where it lands, and whether that is outside + the assembly; whether something is already there, so that `place` + writes the shape's copy beside it under `shape/`; the symlink with an + empty target it met, if any; and, once a symlink has been met, what + stops it, or None.""" + + def __init__(self, path: str) -> None: + self.path = path + self.links: list[tuple[str, str]] = [] + self.landed = "" + self.outside = False + self.taken = False + self.empty = "" + self.problem: str | None = None + + +class _PlannedRoot: + """The assembly root as the materializer will find it, read out of the + plan and the source tree BEFORE any leg exists (#178, #183). + + It holds every source path the plan keeps in the root, each symlink with + the target its blob holds; the `.gitmodules` the mounts write; each leg's + mount, holding what that leg will hold (the source paths the plan sends + to it, at their own paths, or the shape's template for a seeded leg); and + an empty directory for each submodule, because the assembly's clone is + not recursive. It does not hold a path the plan sends to a leg or drops: + the mount's `git rm` has removed it by the time the shape is written. + A symlink AT a path the shape writes also finds what the clone's own + `.git` holds by then (`CLONE_GIT`, and `CLONE_GIT_TREES` counted whole), + and nothing else in `.git`. Where `git` checks a symlink out as a plain + file, a symlink AT a path the shape writes is that file, wherever it + leads: `probe` asks this command's work directory, and only for a link + a walk cannot follow (`_a_file_here`). + `walks` adds what the shape itself writes, path by path, as `place` does, + so `CLAUDE.md -> AGENTS.md` finds the `AGENTS.md` the shape wrote first. + The paths are those of the pins the plan declares too (`pins`), each + pin's file written before the shape's own pin (#273). + + A path is followed the way `open()` follows it in a checkout: each + symlink from its own directory, `..` only from a directory, and a link + out of the assembly onto this machine's disk. A file the shape writes + through a dangling link is only reachable through that link, and Git + reads `.gitattributes` and `.gitignore` only as files, never through a + symlink, so the shape's copy there would do nothing: such a link is + refused, as one that leads out of the assembly is. + + `fold` follows paths as a macOS or Windows disk does, where `.GitHub` + and `.github` are one name. `_root_symlink_findings` asks both ways, so + a case twin is refused on every disk, as #169, #198 and #200 refuse + theirs. + """ + + def __init__(self, plan: Plan, source: Source, tree: list, *, + probe: _SymlinkProbe, fold: bool = False) -> None: + self.source = source + self.probe = probe + self.fold = fold + self.entries: dict[str, tuple[str, str]] = { + GITMODULES: (FILE_MODES[0], "")} + self.gone: dict[str, str] = {} + self.mounts: dict[str, str] = {} + self.legs: dict[str, str] = {} + self._keep(plan, tree) + self._mount(plan, tree) + self.dirs: set[str] = set() + self._folded: dict[str, str] = {} + for path in [*self.entries, *self.mounts]: + self._add(path) + self.dirs.update(self.mounts) + self.dirs.update(path for path, (mode, _) in self.entries.items() + if mode == GITLINK_MODE) + self.assembly, self.beside = _work_dir_names(plan) + # The products whose pin files `execute` writes, each once, in the + # plan's order: `_recorded_pins` hands the materializer a mapping of + # the same pins. Only the entries `_read_pins` takes as pins, so a + # `pins:` that is no list, or an entry that is no pin, adds no path: + # those are `check`'s findings, and `execute` writes no file for them. + self.pins = list(dict.fromkeys(_declared_pins(plan))) + self._targets: dict[str, str] = {} + self._walks: list[_Walk] | None = None + + def _keep(self, plan: Plan, tree: list) -> None: + """Each source path: kept in the root, or gone to a leg or `drop`.""" + entry_paths = [str(e.get("path")) for e in plan.entries] + legs = {str(e.get("path")): str(e.get("leg")) for e in plan.entries} + for path, mode, oid, _ in tree: + covering = _covering(entry_paths, path) + leg = legs[covering[0]] if len(covering) == 1 else "" + if leg in NOT_IN_THE_ROOT: + self.gone[path] = leg + else: + self.entries[path] = (mode, oid) + # A kept entry that is neither a file nor a symlink is a gitlink: a + # directory in a checkout, holding another repository. + self.mounts = {path: f"it enters {_spelled(path)}, a submodule the " + "plan keeps in the root" + for path, (mode, _) in self.entries.items() + if mode not in (*FILE_MODES, SYMLINK_MODE)} + + def _mount(self, plan: Plan, tree: list) -> None: + """Each leg's mount, and what the leg will hold under it.""" + spec, code = _plan_leg_paths(plan) + if leg_path_problems(spec, code): + return + seeded = seeded_legs(assigned_paths(plan.entries)) + for (_, mount), role in ((spec, "spec"), (code, "code")): + self.mounts[mount] = (f"it enters {_spelled(mount)}, where the " + f"plan mounts the {role} leg") + self.legs[mount] = role + held = ([(rel, FILE_MODES[0], "") for rel in template_files( + SHAPE_ROOT / SEED_TEMPLATE[role])] if role in seeded else + [(path, mode, oid) for path, mode, oid, _ in tree + if self.gone.get(path) == role]) + for path, mode, oid in held: + self.entries[f"{mount}/{path}"] = (mode, oid) + + def _add(self, path: str) -> None: + """`path` and the directories above it, as lookups find them.""" + parts = path.split("/") + for end in range(1, len(parts) + 1): + here = "/".join(parts[:end]) + if end < len(parts): + self.dirs.add(here) + self._folded.setdefault(here.casefold(), here) + + def walks(self) -> list[_Walk]: + """Each path the shape writes, each declared pin's file among them, + followed, in the order `place` writes them: the path itself, and its + copy under the collision directory where the path is already + taken.""" + if self._walks is None: + self._walks = [] + for path in assembly_root_paths(self.pins): + self._place(path) + return self._walks + + def _place(self, path: str) -> None: + """What `place` will do with `path`: follow it, or, where something + is already there, follow the shape's copy of it under the collision + directory. What it writes is added to this tree.""" + walk = self.walk(path) + self._walks.append(walk) + if walk.problem is None and walk.taken: + walk = self.walk(f"{COLLISION_DIR}/{path}") + self._walks.append(walk) + if walk.problem is None and walk.links and walk.taken: + walk.problem = self._over(walk) + if walk.problem is None and not walk.taken: + self.entries[walk.landed] = (FILE_MODES[0], "") + self._add(walk.landed) + + def walk(self, path: str) -> _Walk: + """`path` followed through this tree. Each part on the queue carries + the symlink whose target it came from; whether that symlink is the + LAST thing the path is followed through, so that the shape writes AT + its destination rather than beneath it; and, on the last part of a + target, the symlinks whose destination it completes. A symlink AT + `path` that cannot be followed may be a plain file in the clone + (`_a_file_here`): then `path` is taken, and nothing stops it.""" + walk = _Walk(path) + queue = [(part, "", False, ()) for part in path.split("/")] + done: list[str] = [] + while queue and walk.problem is None: + done, queue = self._next(walk, done, queue.pop(0), queue) + if not walk.outside: + walk.landed = "/".join(done) + walk.taken = self._has(walk.landed) + if walk.problem is not None and self._a_file_here(walk): + walk.problem, walk.empty, walk.outside = None, "", False + walk.landed, walk.taken = walk.links[0][0], True + return walk + + def _a_file_here(self, walk: _Walk) -> bool: + """Whether the clone holds the symlink AT `walk`'s own path as a + plain file. Where `git` checks a symlink out as one (`core.symlinks` + false, Git for Windows' default), `place` finds that file there and + writes the shape's copy beside it, whatever the link leads to, as on + `main` (B-OFF of #183's re-verify). A link the walk meets first + ABOVE its path is `execute`'s `adopt-symlinks-off`. The disk is + asked only for a link the walk could not follow, so a plan with none + makes no probe.""" + return (bool(walk.links) + and walk.links[0][0].casefold() == walk.path.casefold() + and not self.probe.symlinks()) + + def _next(self, walk: _Walk, done: list[str], item: tuple, + queue: list) -> tuple[list[str], list]: + """`done` and the queue after one part of the path.""" + part, origin, last, ends = item + link = self._link("/".join([*done, part]), part) + if link is not None: + return done, self._through(walk, link, ends, queue) + if part == ".." and not done: + return done, self._above(walk, origin, last, queue) + done = self._step(walk, done, part, last, bool(queue)) + if ends and walk.problem is None: + walk.problem = self._arrival(done, ends[0][0], queue) + return done, queue + + def _real(self, path: str) -> str | None: + """How this tree spells `path`, or None where nothing is there: the + path itself, or, folding case, the twin a disk that ignores case + would find.""" + if not path or path in self.entries or path in self.dirs: + return path + return self._folded.get(path.casefold()) if self.fold else None + + def _has(self, path: str) -> bool: + """Whether `place` is taken to find something at `path`: in this + tree, as `Path.exists()` would say, or in the clone's own `.git`, + one of `CLONE_GIT` or anything under one of `CLONE_GIT_TREES`, which + is not read, so a name there that is missing counts too (J1). Only + this asks the clone's `.git`; `_is_dir` does not, so a link ABOVE a + path the shape writes still cannot lead it into `.git` (D9).""" + if self._real(path) is not None or any( + self._under(path, top) for top in CLONE_GIT_TREES): + return True + return (path.casefold() in CLONE_GIT_FOLDED if self.fold + else path in CLONE_GIT) + + def _is_dir(self, path: str) -> bool: + real = self._real(path) + return real is not None and (not real or real in self.dirs) + + def _is_file(self, path: str) -> bool: + mode = self.entries.get(self._real(path) or "", ("", ""))[0] + return mode in FILE_MODES + + def _link(self, at: str, part: str) -> tuple[str, str] | None: + """The symlink this tree holds at `at`, as `(its path, its target)`, + or None.""" + if part in (*HERE, ".."): + return None + real = self._real(at) + mode, oid = self.entries.get(real or "", ("", "")) + if mode != SYMLINK_MODE: + return None + if real not in self._targets: + self._targets[real] = _git_path(git_out( + [GIT_CAT_FILE, "blob", oid], cwd=self.source.path, + binary=True)) + return real, self._targets[real] + + def _through(self, walk: _Walk, link: tuple[str, str], ends: tuple, + queue: list) -> list: + """The parts of the symlink's target, to be walked next; none, and + a problem or a place outside, when it is one hop more than `open()` + follows, has nothing to lead to, or leaves the assembly.""" + at, target = link + walk.links.append(link) + if len(walk.links) > SYMLINK_HOPS: + walk.problem = (f"it follows too many symlinks at {_spelled(at)}: " + f"{TOO_MANY_SYMLINKS}") + return [] + if not target: + walk.empty = at + walk.problem = (f"the symlink {_spelled(at)} has an empty target, " + "which leads nowhere") + return [] + last = not queue + if _absolute_here(target): + leads = (f"the symlink {_spelled(at)} leads to " + f"{_spelled(target)}, {ON_THIS_MACHINE}") + if last: + self._outside(walk, target, f"{leads}, and nothing is there") + else: + walk.problem = leads + return [] + parts = target.split("/") + return ([(part, at, last, ()) for part in parts[:-1]] + + [(parts[-1], at, last, ((at, target), *ends))] + queue) + + @staticmethod + def _outside(walk: _Walk, place: str, missing: str) -> None: + """`walk` leaves the assembly for `place` on this machine's disk: + taken where something is there, else `missing` is what stops it. + Windows may spell a place with backslashes; it is named with + slashes, as every other path is.""" + walk.outside, walk.landed = True, Path(place).as_posix() + walk.taken = os.path.exists(place) + if not walk.taken: + walk.problem = missing + + def _above(self, walk: _Walk, origin: str, last: bool, + queue: list) -> list: + """The queue after a `..` that climbs above the assembly root, from + the target of the symlink `origin`. Beneath a path the shape writes, + that is a directory outside the assembly. AT one, it leads into the + work directory: back into the assembly's own clone by its name, into + something else `execute` makes there after this check, or onto the + disk.""" + climbs = f"at the symlink {_spelled(origin)} it climbs above the " \ + "assembly root" + work = self.source.work_root + if not last or work is None: + walk.problem = climbs + return [] + names = [part for part, *_ in queue] + skip = next((n for n, part in enumerate(names) if part not in HERE), + len(names)) + name = names[skip] if skip < len(names) else "" + if self._named(name, self.assembly): + return queue[skip + 1:] + made = next((what for made, what in self.beside.items() + if self._named(name, made)), None) + if made: + walk.problem = (f"{climbs} into " + f"{_spelled((work / name).as_posix())}, {made}") + return [] + # Joined as text, not as a `Path`: a `Path` drops a trailing `/`, + # which `open()` takes only from a directory. + place = os.path.join(str(work), *names) + self._outside(walk, place, f"{climbs}, to " + f"{_spelled(Path(place).as_posix())}, where nothing is") + return [] + + def _named(self, name: str, made: str) -> bool: + """Whether `name` is `made`, as this tree compares names.""" + if not name or not made: + return False + return name == made or (self.fold + and name.casefold() == made.casefold()) + + def _step(self, walk: _Walk, done: list[str], part: str, last: bool, + more: bool) -> list[str]: + """`done` after one part that is not a symlink.""" + if part in HERE: + return done + if part == "..": + here = "/".join(done) + if not self._is_dir(here): + walk.problem = (f"it passes through {_spelled(here)}, which " + "is not a directory in the assembly root") + return done[:-1] + done = [*done, part] + if walk.links: + walk.problem = self._entered("/".join(done), last, more) + return done + + def _entered(self, here: str, last: bool, more: bool) -> str | None: + """What is wrong with going on beneath `here` through a symlink, or + None: a submodule, a file, or a leg's mount, which only a symlink the + shape writes AT may enter, since the shape then writes nothing in + it where something is already there.""" + if not more: + return None + real = self._real(here) or "" + if real in self.mounts and not (last and real in self.legs): + return self.mounts[real] + if self._is_file(here): + return f"it passes through {_spelled(here)}, which is a file" + return None + + def _arrival(self, done: list[str], link: str, + queue: list) -> str | None: + """None when the destination of the symlink `link` is in this tree, + a directory where the path goes on beneath it; else where it is + instead.""" + here = "/".join(done) + held = self._is_dir(here) if queue else self._has(here) + return None if held else self._dangling(link, here) + + def _dangling(self, link: str, here: str) -> str: + """Where the destination `here` of the symlink `link` is, instead of + in this tree: past what a leg's mount holds, in a leg, dropped, or + nowhere.""" + lead = (f"the symlink {_spelled(link)} leads to {_spelled(here)}, " + "which ") + mount = next((mount for mount in self.legs + if self._under(here, mount)), None) + if mount: + return (f"{lead}the {self.legs[mount]} leg, mounted at " + f"{_spelled(mount)}, does not hold") + legs = sorted({leg for path, leg in self.gone.items() + if self._under(path, here)}) + where = [f"to the {leg} leg" if leg in EXTRACTED_LEGS + else f"to `{leg}`" for leg in legs] + if where: + return (f"{lead}the plan sends " + + (f"{', '.join(where[:-1])} and {where[-1]}" + if len(where) > 1 else where[0])) + return f"{lead}is not in the source tree" + + def _under(self, path: str, top: str) -> bool: + """Whether `path` is `top` or lies beneath it, as this tree compares + paths.""" + if self.fold: + path, top = path.casefold(), top.casefold() + return f"{path}/".startswith(f"{top}/") + + def _over(self, walk: _Walk) -> str: + """Why the shape's copy cannot be written where a symlink leads it: + onto something already there. `place` stops there (#262's + `copy_in_the_way`), after both legs exist; before #262 it wrote the + copy over a file, and raised IsADirectoryError on a directory.""" + landed = _spelled(walk.landed) + is_dir = (os.path.isdir(walk.landed) if walk.outside + else self._is_dir(walk.landed)) + what = "a directory" if is_dir else "a file" + return (f"it lands on {landed}, {what} that is already there, where " + "the shape's copy cannot be written") + + +def _work_dir_names(plan: Plan) -> tuple[str, dict[str, str]]: + """The name of the assembly's clone in `execute`'s work directory, and + of everything else `execute` makes there after `check` and before the + shape is written, with what it is: each leg's clone, and the path list + each leg is extracted with. Nothing where the plan's names are not ones + `execute` takes: `check` refuses those on its own.""" + try: + names = plan.names() + except Refusal: + return "", {} + made: dict[str, str] = {} + for role in EXTRACTED_LEGS: + made[names[role]] = (f"where `execute` makes the {role} leg's clone " + "after this check") + made[f"{role}-paths.txt"] = (f"where `execute` writes the {role} " + "leg's path list after this check") + return names["assembly"], made + + +def _misplaced_script(walk: _Walk) -> str | None: + """Why a shape script carried through a symlink cannot run where it + lands, or None. + + Each script looks for the assembly root, and for + `contracts/repository-naming.yaml`, from the directory above its own + resolved location (`Path(__file__).resolve().parents[1]`). From two + directories down, `validate-repository-naming.py` reads the policy from + the wrong `contracts/`; from the root itself, `bootstrap.py` looks for + `.git` above the clone. A script whose path is taken is not written + there at all: its copy goes under `shape/`. + """ + if (not walk.links or walk.taken + or not walk.path.startswith(f"{SHAPE_SCRIPTS}/")): + return None + where = PurePosixPath(walk.landed).parent + if len(where.parts) == 1: + return None + named = _spelled(where.as_posix()) if where.parts else "the root itself" + return (f"it lands at {_spelled(walk.landed)}, and the shape's scripts " + "look for the assembly root, and for " + "contracts/repository-naming.yaml, from the directory above " + "their own, so they run only from a directory directly under " + f"the root, which {named} is not") + + +def _links_at_a_copy(plan: Plan, tree: list) -> set[str]: + """Each symlink the plan keeps AT the copy under `shape/` of a path the + root holds: #262's `plan-root-blocks-shape-copy` names it, the shape's + copy written THROUGH it, earlier in `_entry_findings`' one list; and one + AT `project.yaml`, which #305's `plan-root-blocks-manifest` names. Asked + as `_leg_findings` asks it, of a plan whose `legs` it can read, on a + disk that keeps case and on one that ignores it.""" + if not isinstance(plan.legs, dict): + return set() + kept = _kept_in_root(plan.entries, tree) + named: set[str] = set() + for fold in (False, True): + for _, _, copy, group, rows in _shape_blocks(_KeptTree(kept, fold)): + if group == ("at", copy) and rows[0][1] == SYMLINK_MODE: + named.add(rows[0][0]) + return named + + +def _links_at_a_pin(plan: Plan, tree: list) -> set[str]: + """Each symlink the plan keeps AT the file a declared pin is written to, + `contracts/-pin.yaml`, that #273's `plan-root-holds-pin` names + (`_kept_pin_findings`): the walks follow each pin's file too, and the + link is not said again by them. Asked as `_kept_pin_findings` asks it, + in `_leg_findings`, of a plan whose `legs` it can read: of each declared + pin, on a disk that keeps case and, where that finds nothing there, on + one that ignores it.""" + products = list(dict.fromkeys(_declared_pins(plan))) + if not products or not isinstance(plan.legs, dict): + return set() + kept = _kept_in_root(plan.entries, tree) + disks = (_KeptTree(kept, fold=False), _KeptTree(kept, fold=True)) + named: set[str] = set() + for rel in map(neutral_pin_path, products): + # The disk `_kept_pin_findings` names its row from. + disk = next((one for one in disks if _holds_a_pin(one, rel)), None) + at = disk.at(rel) if disk else None + if at and at[1] == SYMLINK_MODE: + named.add(at[0]) + return named + + +def _holds_a_pin(disk: _KeptTree, rel: str) -> bool: + """Whether `_kept_pin_findings` finds, on `disk`, something the plan + keeps at the pin's file `rel` or under it, but a submodule.""" + at = disk.at(rel) + return any(row[1] != GITLINK_MODE + for row in ([at] if at else disk.under(rel))) + + +def _root_symlink_findings(plan: Plan, source: Source, + tree: list) -> list[str]: + """A FINDING for each symlink the plan keeps in the root that a path the + shape writes runs through and cannot be written through (#178, #183). + + One finding per symlink, naming the first shape path that meets it. A + symlink no shape path runs through is not this check's business: the + split keeps it exactly as the source had it. The paths are followed as a + disk that keeps case does, and again as one that ignores it; what only + the second refuses says so. `tree` is `Source.tree()`, read once by + `_entry_findings` for every finding it composes. + + A symlink AT the copy of a path the root holds is #262's to say + (`_links_at_a_copy`), and is not said again here: one link, one + finding. The copy is still walked, and a link the walk meets ABOVE it, + or AT the copy of a path the root holds only through a symlink, is + this check's, because #262's rule follows no link. A symlink AT + `project.yaml` is #305's `plan-root-blocks-manifest`, which + `_shape_blocks` reports the same way, and is not said again either; nor + is a symlink AT a declared pin's file, #273's `plan-root-holds-pin` + (`_links_at_a_pin`). + + A walk that stops at a symlink with an empty target is #303's to say: + `plan-empty-symlink` names every such symlink in the source, first in + `_entry_findings`' list, with the fix in the source, so it is not said + again here, wherever in the walk's chain that symlink is. + """ + findings: list[str] = [] + if not any(mode == SYMLINK_MODE for _, mode, _, _ in tree): + return findings + seen = _links_at_a_copy(plan, tree) | _links_at_a_pin(plan, tree) + probe = _SymlinkProbe(source.work_root) + for fold in (False, True): + for walk in _PlannedRoot(plan, source, tree, probe=probe, + fold=fold).walks(): + why = walk.problem or _misplaced_script(walk) + if why is None or walk.empty or walk.links[0][0] in seen: + continue + seen.add(walk.links[0][0]) + findings.append(_root_symlink_finding( + walk, why, _CASE_ASIDE if fold else "")) + return findings + + +def _root_symlink_finding(walk: _Walk, why: str, aside: str) -> str: + """The line `check` prints for a symlink `walk` cannot be written + through, and what to do. A walk that stopped at a symlink with an empty + target never comes here: that symlink is #303's `plan-empty-symlink`.""" + link, target = walk.links[0] + fix = (f"Send {_spelled(link)} to a leg or `drop` it, or change where it " + "points in the source and re-run `plan`.") + return (f"FINDING {ROOT_SYMLINK}: the plan keeps {_spelled(link)} in the " + f"root, a symlink to {_spelled(target)}, and " + f"{_spelled(walk.path)}, a path the shape writes, runs through " + f"it{aside}, but {why}. {fix}") + + +def _checks_out_symlinks(work_root: Path) -> bool: + """Whether `git` checks a symlink out AS a symlink in a repository made + in `work_root`, which is where the assembly's clone is made (#183). + + It asks a throwaway repository rather than the configuration alone. + `git init` writes `core.symlinks = false` into a repository whose + filesystem cannot hold a symlink, and a global, system or `GIT_CONFIG_*` + setting can say false on one that can. + """ + probe = Path(tempfile.mkdtemp(prefix="symlinks-probe-", dir=work_root)) + try: + run(["git", "init", "-q", str(probe)]) + answer = subprocess.run( + ["git", "config", "--type=bool", "--get", "core.symlinks"], + cwd=str(probe), capture_output=True, text=True, + encoding="utf-8", errors="replace", check=False) + finally: + shutil.rmtree(probe, ignore_errors=True) + return answer.stdout.strip() != FALSE_WORD + + +class _SymlinkProbe: + """`_checks_out_symlinks` of one command's work directory, asked the + first time a walk needs the answer and then kept, so a plan that never + needs it makes no probe (B-OFF of #183's re-verify). With no work + directory -- a `Source` that no command opened -- or one where no + repository can be made, a symlink is taken to be checked out as a + symlink.""" + + def __init__(self, work_root: Path | None) -> None: + self.work_root = work_root + self._answer: bool | None = None + + def symlinks(self) -> bool: + """Whether `git` checks a symlink out AS a symlink there.""" + if self._answer is None: + try: + self._answer = (self.work_root is None + or _checks_out_symlinks(self.work_root)) + except OSError: + # No repository can be made there, as in a `--work-dir` this + # user cannot write (J4 of #183's re-verify). Taken as a disk + # that holds symlinks, `check` prints the finding it printed + # before B-OFF for a link AT a path that a walk cannot follow, + # and `execute` refuses with it, where both would otherwise + # end in a traceback. + self._answer = True + return self._answer + + +def _refuse_symlinks_checked_out_as_files(plan: Plan, source: Source, + work_root: Path) -> None: + """Refuse, before any leg exists, a plan whose shape files are written + BENEATH a symlink the plan keeps, where `git` would check that symlink + out as a plain file (#183). + + `core.symlinks=false` is Git for Windows' default, and what `git init` + writes on a filesystem that cannot hold a symlink. The assembly's clone + would then hold the link as a file, and the materializer cannot make a + directory where a file is. It used to die there in a traceback after + both legs were pushed. A symlink AT a shape path is unaffected: as a + file it is still taken, and the shape's copy goes beside it, as `check` + counts it too (`_PlannedRoot._a_file_here`). The probe is made only when + a plan needs it, so an adoption with no such link runs exactly as before. + """ + tree = source.tree() + if not any(mode == SYMLINK_MODE for _, mode, _, _ in tree): + return + probe = _SymlinkProbe(work_root) + # A case twin is met only by the walk that ignores case, and is named + # with `check`'s own aside: on a disk that keeps case the shape path + # does not run through it at all. + beneath = [(walk, _CASE_ASIDE if fold else "") for fold in (False, True) + for walk in _PlannedRoot(plan, source, tree, probe=probe, + fold=fold).walks() + if walk.links + and walk.links[0][0].casefold() != walk.path.casefold()] + if not beneath or probe.symlinks(): + return + walk, aside = beneath[0] + link, target = walk.links[0] + raise Refusal( + "adopt-symlinks-off", + f"the plan keeps {_spelled(link)} in the root, a symlink to " + f"{_spelled(target)}, and {_spelled(walk.path)}, a path the shape " + f"writes, runs through it{aside}, but `git` here checks a symlink out " + "as a plain file: " + f"`core.symlinks` is false for a repository made in {work_root}. The " + f"assembly's clone would hold {_spelled(link)} as a file, and nothing " + "can be written beneath a file.", + "Remediation: no leg repository was created and nothing was pushed. " + "Run `execute` where `git` checks a symlink out as a symlink -- " + "`core.symlinks` true, on a filesystem that can hold one -- or send " + f"{_spelled(link)} to a leg or `drop` it in the plan and run `check` " + "again.") + + def cmd_check(args) -> int: plan = Plan.load(Path(args.plan)) # FIRST, before anything is opened: the keys the rest is read from (#386). @@ -6160,7 +6941,12 @@ def _refuse_what_check_finds(plan: Plan, source: Source, skipped, so it says no to the same findings. THE FINDINGS ARE `check`'S OWN: `_entry_findings`, the one list `check` - prints, so the two commands cannot disagree about what a plan covers. + prints, so the two commands cannot disagree about what a plan covers, or + about a symlink the plan keeps in the root and the shape writes through + (#178) -- but what the disk says of such a link AT a shape path is asked + again here, before any leg exists, from this run's own work directory: + whether anything is where it leads out of the assembly, and whether + `git` checks it out as a symlink at all (#183). The refusal's code is the FIRST finding's own (`plan-uncovered`, ...); its detail is every finding as `check` prints it, capped at `REFUSED_FINDINGS_SHOWN` with an "and N more" tail; its remediation is @@ -6696,9 +7482,12 @@ def _commit_the_split(plan: Plan, source: Source, assembly: Path, # values the plan records (#273); `_refuse_what_check_finds` has # refused a pin it does not record whole, and one whose file a # kept path holds. + # A materializer refusal names the source's own symlinks and where + # they lead, which can hold any character: spelled as every finding + # is (#212). materialized = materialize_assembly_root( SHAPE_ROOT, assembly, values, collision_dir=COLLISION_DIR, - append=append, neutral_pins=_recorded_pins(plan)) + append=append, neutral_pins=_recorded_pins(plan), spell=_spelled) for intended, actual in materialized.collisions: print(f" beside {actual} (the source already has {intended}; " "nothing was overwritten)") @@ -6748,6 +7537,7 @@ def cmd_execute(args) -> int: _refuse_an_unwritable_clone_url(urls["assembly"]) paths_for = _leg_paths(plan) _refuse_what_check_finds(plan, source, args.local_remote_dir) + _refuse_symlinks_checked_out_as_files(plan, source, work_root) seeded = seeded_legs(paths_for) _refuse_unconsented_seeding(args, plan, seeded) @@ -7569,7 +8359,7 @@ def _resolve(self, path: str) -> str: hops, via = hops + 1, f" at the symlink {_spelled(at)}" if hops > SYMLINK_HOPS: raise _NotInTheSplitError( - f"runs round a symlink loop at {_spelled(at)} in the " + f"follows too many symlinks at {_spelled(at)} in the " f"split commit {self.commit}") parts = self._parts(target, via) + parts return "/".join(done) diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index cbf22478..925cbe30 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -1863,6 +1863,22 @@ def _local_strays(src: Path) -> frozenset[str]: if name) +def template_files(src: Path) -> list[str]: + """Every file `copy_tree` copies out of the template tree `src`, in its + order, relative to `src` in POSIX spelling: what a leg seeded from `src` + holds. + + ONE LISTING, read by `copy_tree` and by `adopt-project.py check`, which + follows a symlink the plan keeps into a seeded leg's mount and must find + there what `execute` will have written (#183). + """ + strays = _local_strays(src) + return [rel for rel in (path.relative_to(src).as_posix() + for path in sorted(src.rglob("*")) + if not path.is_dir()) + if rel not in strays] + + def copy_tree(src: Path, dst: Path, values: dict[str, str]) -> list[str]: """Copy a template tree, substituting placeholders in every text file. @@ -1873,12 +1889,9 @@ def copy_tree(src: Path, dst: Path, values: dict[str, str]) -> list[str]: neither copied nor returned, so an operator's `.vscode/settings.json` in `templates/spec-root/` is never forced into a new leg. """ - strays = _local_strays(src) written: list[str] = [] - for path in sorted(src.rglob("*")): - if path.is_dir() or path.relative_to(src).as_posix() in strays: - continue - target = dst / path.relative_to(src) + for rel in template_files(src): + path, target = src / rel, dst / rel target.parent.mkdir(parents=True, exist_ok=True) write_lf(target, render(path.read_text(encoding="utf-8"), values, str(path))) @@ -1980,7 +1993,7 @@ def materialize_assembly_root(shape_root: Path, target: Path, collision_dir: str | None = None, append: dict[str, str] | None = None, neutral_pins: dict[str, dict] | None = None, - ) -> Materialized: + spell=None) -> Materialized: """Write the assembly-root skeleton into `target`. `collision_dir` — where a template file goes when the target path is @@ -1992,12 +2005,18 @@ def materialize_assembly_root(shape_root: Path, target: Path, `neutral_pins` maps a neutral product name to the values for its pin file, each rendered from `contracts/neutral-product-pin.yaml`. + + `spell` writes each path a refusal names (#183's `materialize-*`), as + `first_and_more` takes one: between backticks when it is None, which is + all the scaffold's own paths need. `adopt-project.py` passes its + `_spelled`, because the source it adopts can hold a symlink to any name. """ return _materialize( shape_root, shape_root / "templates" / "assembly-root", target, values, templated=TEMPLATED, verbatim=COPIED_VERBATIM, from_shape=COPIED_FROM_SHAPE, executable=EXECUTABLE, - collision_dir=collision_dir, append=append, neutral_pins=neutral_pins) + collision_dir=collision_dir, append=append, neutral_pins=neutral_pins, + spell=spell) def assembly_root_paths(neutral_pins=()) -> tuple[str, ...]: @@ -2010,13 +2029,16 @@ def assembly_root_paths(neutral_pins=()) -> tuple[str, ...]: declared pin is written to (`neutral_pin_path`) and the pin it writes last, never kept by hand: `adopt-project.py` (#172) and `scaffold-project.py` (#197) refuse a leg mount path that overlaps one of - these, and a second list would let the shape gain a file the refusal - does not know about. `EXECUTABLE` adds no path (it marks paths the lists - already write), and a collision moves a path under the caller's - `collision_dir`, which is the caller's to name. - `tests/test_adopt_leg_path_collisions.py` and + these, and `adopt-project.py check` follows each of them through the + symlinks a plan keeps in the root before any leg exists (#178). A second + list would let the shape gain a file the refusal does not know about, + or one that `check` does not follow. `EXECUTABLE` adds no path (it + marks paths the lists already write), and a collision moves a path + under the caller's `collision_dir`, which is the caller's to name. + `tests/test_adopt_leg_path_collisions.py`, + `tests/test_adopt_root_symlinks.py` and `tests/test_scaffold_leg_path_collisions.py` materialize into an empty - directory and hold this to what was written, the second with pins too. + directory and hold this to what was written, the last with pins too. """ return (*TEMPLATED, *COPIED_VERBATIM, *(rel for _, rel in COPIED_FROM_SHAPE), @@ -2595,11 +2617,217 @@ def materialize_family_root(shape_root: Path, target: Path, from_shape=FAMILY_COPIED_FROM_SHAPE, executable=FAMILY_EXECUTABLE) +# --------------------------------------------------------------------------- +# Writing only where the shape's files belong (#183) +# --------------------------------------------------------------------------- +# +# An adoption materializes INTO a clone of the source, and the plan can keep a +# symlink in the root at or above a path the shape writes. `.github -> ci`, +# with `ci/` in the same tree, is followed, and the workflow lands in `ci/`. +# A link that led out of the clone was followed too, and the shape's +# `validate.yml` went into a directory nobody named. A link round a loop, a +# link to nothing, or a checkout with `core.symlinks=false` (which writes each +# symlink as a plain FILE) ended in a Python traceback after both legs had +# been pushed. `adopt-project.py check` reports such a link before any leg +# exists. The refusals below apply the same rule where the files are +# written, for a caller that skipped `check`. +# +# A symlink AT a path the shape writes, whose destination is already there, +# is never written through: `place` sees the path taken and writes the +# shape's copy beside it, under the collision directory. A symlink at or above +# that COPY was followed, and the copy landed on whatever it led to: over the +# source's own `README.md` for `shape -> .`, or into a directory, in a +# traceback. Where a copy lands is `copy_in_the_way`'s question (#262), which +# `place` asks first, a symlink on the way or not; what is asked here is +# what still holds for the copy after it, as for any other path. + +#: How many symlinks one lookup follows: Linux's own limit (MAXSYMLINKS), +#: past which `open()` gives up with ELOOP, whether the links go round a loop +#: or only chain that far. `adopt-project.py` follows the plan's tree and the +#: split commit's by the same count. macOS gives up after 32, so a chain of 33 +#: to 40 passes here and fails there, as it did before #183. +SYMLINK_HOPS = 40 +#: What a lookup that follows more than `SYMLINK_HOPS` symlinks has met. It +#: cannot tell a loop from a long chain, and need not: `open()` gives up on +#: both. `adopt-project.py`'s finding says it in the same words. +TOO_MANY_SYMLINKS = (f"more than {SYMLINK_HOPS}, round a loop or along a " + "chain longer than Linux's `open()` follows") + +#: What each refusal below says to do. It is raised BEFORE the `mkdir` and the +#: write, so nothing was written at the path it names. It does not send the +#: reader to `adopt-project.py check`: `check` reports what is in the way +#: before any leg exists (a symlink as `plan-root-symlink`, #183; a plain +#: FILE where the shape needs a directory as `plan-root-blocks-shape`, #263), +#: and `execute` refuses it there, so this refusal is met only by a caller +#: that skipped `check`. +#: It names the work directory as #200's mount remediation does: by now +#: `execute` has cloned both legs into it, and a re-run cannot clone there. +UNWRITABLE_REMEDIATION = ( + "Remediation: nothing was written at that path. The shape writes its " + "files only inside the directory it is given, only beneath directories, " + "and through a symlink only when its relative target stays inside and " + "leads where nothing is yet. What is in the way is the source " + "repository's own: when adopting, send it to a leg or `drop` it in the " + "plan, and run `execute` again with fresh leg repositories and an empty " + "`--work-dir`, or none: a `--work-dir` this run was given still holds " + "its clones of both legs, and the next run cannot clone into them.") + +#: The parts of a path that name no entry of their own: `open()` takes each +#: of them only from a directory that is there. +NOT_A_NAME = ("", ".", "..") + +#: The refusal for a path something in the way stops the shape writing. +BLOCKED = "materialize-blocked" + + +def _ticked(name: str) -> str: + """`name` between backticks: how a refusal below names a path when its + caller passes no `spell`, as `first_and_more` names one. The shape's own + paths need nothing more. `adopt-project.py` passes its `_spelled`, + because a source's symlink can lead to a name holding a line break or an + ESC, and #212 keeps every refusal on its one line.""" + return f"`{name}`" + + +def _link_parts(here: Path, at: str, rel: str, target: Path, hops: int, + name) -> list[tuple[str, str]]: + """The target of the symlink `here` (spelled `at`) as path parts, each + with `at`, or a refusal when it is one hop more than `open()` follows, + it is empty, or it is absolute. `name` writes each path a refusal names. + + An empty target is not `.`: `open()` finds nothing through it, and the + parts below would read it as the directory the link is in.""" + if hops > SYMLINK_HOPS: + raise Refusal("materialize-symlink-loop", + f"{name(rel)} follows too many symlinks at {name(at)} " + f"in {target}: {TOO_MANY_SYMLINKS}", + UNWRITABLE_REMEDIATION) + link = os.readlink(here) + if not link: + raise Refusal(BLOCKED, + f"{name(rel)} cannot be written: the symlink " + f"{name(at)} has an empty target, which leads nowhere", + UNWRITABLE_REMEDIATION) + if os.path.isabs(link) or os.path.splitdrive(link)[0]: + raise Refusal( + "materialize-outside", + f"{name(rel)} would be written through the symlink {name(at)}, " + f"which leads to the absolute path {name(link)}; the shape " + f"follows only a symlink whose relative target stays inside " + f"{target}", + UNWRITABLE_REMEDIATION) + # Windows reads a link back in its own separator; a POSIX name may hold + # a backslash, so only the platform's own separator is translated. + return [(part, at) for part in link.replace(os.sep, "/").split("/")] + + +def _from_a_directory(target: Path, rel: str, done: list[str], part: str, + via: str, name) -> None: + """Refuse `part`, a `..`, `.` or empty part of the target of the symlink + `via`, where `open()` cannot take it: `..` above `target`, or any of + them after something that is not a directory. `open()` takes them + physically, so `a/..` fails where `a` is missing (FileNotFoundError) or + a file (NotADirectoryError), and `ci/` where `ci` is missing + (IsADirectoryError): each was a traceback after both legs.""" + if not done: + if part == "..": + raise Refusal( + "materialize-outside", + f"{name(rel)} would be written outside {target}: at the " + f"symlink {name(via)} it climbs above it", + UNWRITABLE_REMEDIATION) + return + here = target.joinpath(*done) + if not here.is_dir(): + raise Refusal( + BLOCKED, + f"{name(rel)} cannot be written: the target of the symlink " + f"{name(via)} goes on from {name(root_key(here, target))}, which " + "is not a directory here", + UNWRITABLE_REMEDIATION) + + +def _followed(target: Path, rel: str, name) -> list[str]: + """`rel` under `target` with each symlink on the way followed, the way + `open()` follows them: the parts it lands on, relative to `target`. A + refusal when one leads out of `target`, when there are more of them than + `open()` follows, or when one leads on from something that is not a + directory.""" + parts = [(part, "") for part in rel.split("/")] + done: list[str] = [] + hops = 0 + while parts: + part, via = parts.pop(0) + if part in NOT_A_NAME: + _from_a_directory(target, rel, done, part, via, name) + if part == "..": + done.pop() + continue + here = target.joinpath(*done, part) + if here.is_symlink(): + hops += 1 + parts = _link_parts(here, root_key(here, target), rel, target, + hops, name) + parts + else: + done.append(part) + return done + + +def _blocker(target: Path, rel: str, landed: list[str], + name) -> str | None: + """What stands where `rel` needs a directory, or where a symlink at it + leads, or None. + + `mkdir(parents=True)` makes a MISSING directory and everything below it, + and passes through a directory or a symlink to one. It cannot pass a + file, or a symlink that leads to no directory, and it raised a traceback + there. A symlink at `rel` itself is written through only into a + directory that is already there. No path reaches here with something + already where it lands: `place` writes the shape's copy beside a path + that is taken, and asks `copy_in_the_way` (#262) where the copy lands + before it asks this. + """ + parts = rel.split("/") + for end in range(1, len(parts)): + above = target.joinpath(*parts[:end]) + if above.is_dir(): + continue + if above.is_symlink(): + return (f"{name(root_key(above, target))} is a symlink to " + f"{name(os.readlink(above))}, which is not a directory " + "here") + if above.exists(): + return (f"{name(root_key(above, target))} is a file, not a " + "directory") + return None + path = target / rel + if path.is_symlink() and not target.joinpath(*landed[:-1]).is_dir(): + return (f"the symlink {name(rel)} leads to {name(os.readlink(path))}, " + "and there is no directory there to write it in") + return None + + +def _refuse_unwritable(target: Path, rel: str, spell=None) -> None: + """Refuse, before the `mkdir` and the write, a path the shape must not + write at (outside `target`, or through too many symlinks) or cannot + (#183). `spell` writes each path the refusal names, `_ticked` where it + is None. + `target` itself is the directory the caller gave, and is named as it + is, as `materialize-collision` names it.""" + name = spell or _ticked + blocker = _blocker(target, rel, _followed(target, rel, name), name) + if blocker: + raise Refusal(BLOCKED, + f"{name(rel)} cannot be written: {blocker}", + UNWRITABLE_REMEDIATION) + + def _materialize(shape_root: Path, template_root: Path, target: Path, values: dict[str, str], *, templated, verbatim, from_shape, executable, collision_dir: str | None = None, append: dict[str, str] | None = None, - neutral_pins: dict[str, dict] | None = None) -> Materialized: + neutral_pins: dict[str, dict] | None = None, + spell=None) -> Materialized: """The shared body. Four lists in, one `Materialized` out. The order is load-bearing and is the same for every root: templated files @@ -2641,6 +2869,7 @@ def place(rel: str, write) -> str: "given.") result.collisions.append((rel, actual)) path = target / actual + _refuse_unwritable(target, root_key(path, target), spell) path.parent.mkdir(parents=True, exist_ok=True) write(path) rel_written = root_key(path, target) @@ -2785,9 +3014,11 @@ def copy_out(src: Path, dst: Path) -> None: #: `D:\work\Thing` or `D:/work/Thing` — a Windows absolute path, in either #: spelling. `_spelling_reason` below asks it whether a leg path is a drive -#: letter's. adopt-project.py imports it too, for its own `--source` flag: -#: `Source.open` there tells a path the operator got wrong from a repository -#: name. `--source` and `Source.open` are adopt-project.py's, not this file's. +#: letter's. adopt-project.py imports it too, for two questions of its own: +#: `Source.open` tells a `--source` path the operator got wrong from a +#: repository name, and `_PlannedRoot` asks, on Windows only, whether a +#: symlink the plan keeps leads to an absolute place on that machine. Both +#: are adopt-project.py's, not this file's. WINDOWS_ABSOLUTE_RE = re.compile(r"^[A-Za-z]:[\\/]") #: Git's own names, casefolded: a leg cannot be mounted at a segment spelled diff --git a/tests/test_adopt_finding_spelling.py b/tests/test_adopt_finding_spelling.py index d4a12cda..19911ff9 100644 --- a/tests/test_adopt_finding_spelling.py +++ b/tests/test_adopt_finding_spelling.py @@ -454,7 +454,7 @@ def assert_names_are_in(repo: Path, commit: str, names: list[str]) -> None: "climbs above the root of the split commit {commit} at " "the symlink `u{s}p`, so "), "a-loop": ("lo{c}op/x", "lo{c}op", "lo{c}op", - "runs round a symlink loop at `lo{s}op` in the split commit " + "follows too many symlinks at `lo{s}op` in the split commit " "{commit}, so "), "through-a-non-directory": ("t{c}hru/x", "t{c}hru", "n{c}owhere/../x", "passes through `n{s}owhere`, which is not a " diff --git a/tests/test_adopt_ignored_shape_files.py b/tests/test_adopt_ignored_shape_files.py index 10c4381a..706eb480 100644 --- a/tests/test_adopt_ignored_shape_files.py +++ b/tests/test_adopt_ignored_shape_files.py @@ -438,10 +438,10 @@ def findings_within(adopter, repo: Path, commit: str, "climbs above the root of the split commit {c} at the " "symlink `.github`", id="a-link-above-the-root"), pytest.param({".github": ".github"}, - "runs round a symlink loop at `.github` in the split commit " + "follows too many symlinks at `.github` in the split commit " "{c}", id="a-link-to-itself"), pytest.param({".github": "loop", "loop": ".github"}, - "runs round a symlink loop at ", id="a-loop-of-two"), + "follows too many symlinks at ", id="a-loop-of-two"), pytest.param({".github": "nowhere/../ci"}, "passes through `nowhere`, which is not a directory in the " "split commit {c}", diff --git a/tests/test_adopt_root_symlinks.py b/tests/test_adopt_root_symlinks.py new file mode 100644 index 00000000..f3f56ab4 --- /dev/null +++ b/tests/test_adopt_root_symlinks.py @@ -0,0 +1,2059 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A symlink the plan keeps in the root, at or above a path the shape writes +(#178, #183). + +The shape's files are written into the assembly by plain path, so such a link +is followed. Before this, `check` asked nothing about one, and `execute` made +and pushed both legs first. Then, as lane 2's table for #178 recorded on +main: a link that dangles, enters a leg, enters a mount or runs round a loop +ended in a Python traceback; one that leaves the tree had the shape's +`validate.yml` WRITTEN OUTSIDE the assembly. Either way the corrected plan met +`leg-remote-exists`. + +The groups of tests: + +- `check` reports each such link, and `execute` refuses it under #168's one + list of `check`'s findings, before any leg exists. The links are put in + the source's TREE (`update-index --cacheinfo`), which is all either command + reads, so these need no symlink privilege and run on Windows too. +- What must NOT be refused: a link whose target the root keeps (followed end + to end since #167), one whose destination is there by the time the shape + writes through it, and one no shape path runs through. +- A link AT a path the shape writes is decided by what `place` will find + (#183's review, B1): where something is already there, the shape's copy + goes beside it under `shape/` and the link is kept, as on main; where + nothing is, the shape would write through it, and it is refused. Outside + the assembly that is a question for this machine's disk, which `check` and + `execute` each ask. +- `place` asks where the shape's copy under `shape/` lands only of the disk, + after both legs exist (#262's `copy_in_the_way`), so `check` refuses a + link that leads the copy onto something already there (B2). A link AT the + copy is #262's own finding, `plan-root-blocks-shape-copy`. +- A case twin, `.GitHub` for `.github`, is followed as a macOS or Windows + disk follows it, and refused on every disk (S1). +- The file of each pin the plan declares, `contracts/-pin.yaml`, is + a path the shape writes, before its own pin (#273), and is followed too; + a link AT one is #273's own finding, `plan-root-holds-pin`. +- `core.symlinks=false` (#183): `execute` refuses, before any leg, a link the + shape would write beneath, because the assembly's clone holds it as a file. + A link AT a path the shape writes is then that file, which `place` writes + the copy beside, so neither `check` nor `execute` refuses it, wherever it + leads (B-OFF). The tests of a link `check` follows tell `git` to check a + symlink out as one, since Git for Windows' own default is a file. +- The materializer's own refusals, for a caller that skipped `check`. + +The end-to-end tests at the bottom need `git filter-repo` and a disk that +holds symlinks. +""" + +from __future__ import annotations + +import importlib.util +import os +import re +import shutil +import subprocess +import sys +from argparse import Namespace +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from conftest import (ADOPT, FILE_PROTOCOL, REPO, clear_ambient_pin_sources, + git, make_source_repo, run_script, write_plan) + +sys.path.insert(0, str(REPO / "scripts")) +from shape_materialize import ( # noqa: E402 + PLACEHOLDER_RE, Refusal, assembly_root_paths, materialize_assembly_root, +) + +PROJECT = "Northwind" +UTF8 = "utf-8" +REMOTES, WORK = "remotes", "work" +ROOT = "root" +ROOT_SYMLINK = "plan-root-symlink" +FINDING = f"FINDING {ROOT_SYMLINK}: " +BRANCH = "adopt/three-repo-shape" +#: The mode `git ls-tree` reports for a symlink, and for a submodule. +SYMLINK, GITLINK = "120000", "160000" +WORKFLOW = ".github/workflows/validate.yml" +ATTRIBUTES = ".gitattributes" +VALIDATE_PINS = "scripts/validate-pins.py" +README = "README.md" +CI_FILE = "ci/workflows/ci.yml" +ON_PUSH = "on: [push]\n" +RUN_SH = "tools/run.sh" +ECHO = "echo\n" +#: Paths, links and values the cases below share. +GITHUB = ".github" +SCRIPTS = "scripts" +CONTRACTS = "contracts" +SHAPE_DIR = "shape" +SHAPE_README = f"{SHAPE_DIR}/{README}" +TOOLS = "tools" +TOOLS_DIR = "tools/" +TOOLS_UP = "tools/up" +TOOLS_SUB = "tools/sub" +CI_DIR = "ci/" +LOOP = "loop" +CONFIG_DIR = "config/" +CONFIG_ATTRS = "config/attrs" +NOWHERE_ATTRS = "nowhere/attrs" +TOOLING_GH = "tooling/gh" +NOTES_README = "notes/readme.md" +NOTES_UPSTREAM = "notes/upstream" +ETC = "/etc" +OUTSIDE_CI = "../outside-ci" +LATEST = "latest" +BOOTSTRAP_PY = "scripts/bootstrap.py" +AGENTS = "AGENTS.md" +CLAUDE = "CLAUDE.md" +MAKEFILE = "Makefile" +DOCS_DIR = "docs/" +DOCS_AGENTS = "docs/agents.md" +VENDOR_DIR = "vendor/" +SPEC = "spec" +CODE = "code" +THING = "Thing" +PLAN_FILE = "adoption-plan.yaml" +PLAN_FLAG = "--plan" +WORK_FLAG = "--work-dir" +READ = "read" +ASSEMBLY = "assembly" +OUTSIDE_DIR = "outside" +BLOCKED = "materialize-blocked" +#: #262's refusal, in `place`, of a copy whose place is taken. +COPY_BLOCKED = "materialize-copy-blocked" +#: #262's finding for a path the plan keeps where the shape writes its copy. +COPY_IN_THE_WAY = "plan-root-blocks-shape-copy" +COPY_FINDING = f"FINDING {COPY_IN_THE_WAY}: " +#: A file of the source's own that a link at the copy leads onto. +NOTES_MD = "notes.md" +OUTSIDE = "materialize-outside" +#: How `execute` reports a refusal met once both legs exist, the +#: materializer's among them (#188): the step it stopped at, with the refusal +#: on the same line, then the notice of what the run left. +SPLIT_STEP = "REFUSED committing the split." +LEFT_BEHIND = "NOTHING has been rolled back: " +BOTH_PUSHED = "and both legs are pushed." +#: The materializer's refusal of a path that follows too many symlinks. +SYMLINK_LOOP = "materialize-symlink-loop" +CONFIG_VALUE = "GIT_CONFIG_VALUE_0" +#: Case twins of paths the shape writes, as a macOS or Windows disk reads +#: them (S1), and the rest of what several cases name. +GITHUB_TWIN = ".GitHub" +GITIGNORE_TWIN = ".GITIGNORE" +NOWHERE_GITIGNORE = "nowhere/gitignore" +BUILD_MAKEFILE = "build/Makefile" +GIT_INFO = ".git/info" +GIT_HEAD = ".git/HEAD" +GIT_DESCRIPTION = ".git/description" +GIT_HOOKS = ".git/hooks" +NOWHERE_MD = "nowhere.md" +#: The probe B-OFF makes, as tests put a stand-in in its place. +PROBE = "_checks_out_symlinks" +SYMLINKS_OFF_CODE = "adopt-symlinks-off" +#: What the assembly's clone holds in its own `.git` when the shape is +#: written, besides `HEAD`: the files git makes in every clone it checks +#: out, and places in the directories of its objects, its refs, their logs, +#: and the stores of the legs `execute` has mounted (2b of #183's re-verify). +GIT_KEPT = (".git/index", ".git/packed-refs", ".git/logs/HEAD", + ".git/refs/heads", ".git/objects/pack", ".git/modules/spec") +SPEC_OTHER = "spec/docs/other.md" +VENDOR = "vendor" +PERSONAL = "a personal file\n" +VERIFIED = "adoption verified" +MODULE = "module" +#: `plan` cannot classify a `notes/` holding a link and a readme as one, and +#: lists the two; the readme is answered here and the link with the rest. +NOTES_KEPT = {NOTES_README: ROOT} +#: The arguments every case of a planned source takes. +FILES, LINKS = "files", "links" +PLANNED = (FILES, LINKS, "legs") +#: What `check` adds to a finding that holds only on a disk that ignores +#: case: #198's `CASE_ASIDE`, word for word. +CASE_ASIDE = " once case is ignored, as a macOS or Windows disk does" + +#: The source every case starts from. `make_source_repo`'s later commits edit +#: the specification and `src/app/main.py`, so both are here. +BASIC = {README: "# Thing\n", + "specs/001-feature/spec.md": "# The feature\n", + "src/app/main.py": "import contracts_reader\n"} + +#: `git` told to check a symlink out as a plain file, the way Git for +#: Windows' default does, or `git init` on a filesystem without symlinks. +#: `GIT_CONFIG_*` outranks every configuration file. +CONFIG_COUNT = "GIT_CONFIG_COUNT" +CORE_SYMLINKS = "core.symlinks" +SYMLINKS_OFF = {CONFIG_COUNT: "1", "GIT_CONFIG_KEY_0": CORE_SYMLINKS, + CONFIG_VALUE: "false"} +SYMLINKS_ON = {**SYMLINKS_OFF, CONFIG_VALUE: "true"} + +#: What this runner lacks, if anything, for the end-to-end tests. +NO_FILTER_REPO = shutil.which("git-filter-repo") is None +ON_WINDOWS = sys.platform == "win32" + +needs_filter_repo = pytest.mark.skipif( + NO_FILTER_REPO, + reason="git filter-repo is not installed: `pip install git-filter-repo`") + +#: What the tests with a symlink ON THE DISK need, as in +#: `test_adopt_ignored_shape_files.py`: 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 or elevated. +needs_disk_symlinks = pytest.mark.skipif( + ON_WINDOWS, + reason="a tracked symlink is a plain file in a Windows checkout") + + +def symlink(link: Path, target: str) -> None: + """`link -> target`, or a skip where this runner cannot make one.""" + link.parent.mkdir(parents=True, exist_ok=True) + try: + os.symlink(target, link) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"this platform cannot create a symlink here: {exc}") + + +def told_git(monkeypatch, value: str) -> None: + """`git` told `core.symlinks=` through `GIT_CONFIG_*`, after + whatever entries the runner's job sets there (the Windows job sets two), + which stay as they were.""" + held = int(os.environ.get(CONFIG_COUNT) or 0) + monkeypatch.setenv(CONFIG_COUNT, str(held + 1)) + monkeypatch.setenv(f"GIT_CONFIG_KEY_{held}", CORE_SYMLINKS) + monkeypatch.setenv(f"GIT_CONFIG_VALUE_{held}", value) + + +def links_as_files(monkeypatch) -> None: + """`git` told to check a symlink out as a plain file, after the runner's + own `GIT_CONFIG_*`, as Git for Windows' default does.""" + told_git(monkeypatch, SYMLINKS_OFF[CONFIG_VALUE]) + + +def no_probe(_work_root): + """A stand-in for `_checks_out_symlinks` where no probe may be made.""" + raise AssertionError("the probe was made") + + +@pytest.fixture +def links_as_links(monkeypatch): + """`git` told to check a symlink out AS a symlink. `check` follows a + link AT a path the shape writes only where `git` does that, and Git for + Windows' own default is a plain file (B-OFF).""" + told_git(monkeypatch, "true") + + +@pytest.fixture(scope=MODULE) +def adopter(): + spec = importlib.util.spec_from_file_location("adopt_root_symlinks", ADOPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# --------------------------------------------------------------------------- +# Sources and plans +# --------------------------------------------------------------------------- + + +def commit_index(repo: Path, message: str) -> None: + git("-c", "user.name=Source Human", "-c", + "user.email=source@invalid.example", "commit", "-qm", message, + cwd=repo) + + +def commit_tree_entries(repo: Path, links: dict, gitlinks: tuple = ()) -> None: + """Each `links` path as a symlink (its target the blob, text or bytes) + and each `gitlinks` path as a submodule, in the source's TREE only.""" + for link, target in links.items(): + raw = target if isinstance(target, bytes) else target.encode(UTF8) + blob = subprocess.run( + ["git", "hash-object", "-w", "--stdin"], cwd=str(repo), + input=raw, capture_output=True, check=True) + git("update-index", "--add", "--cacheinfo", + f"{SYMLINK},{blob.stdout.decode().strip()},{link}", cwd=repo) + head = git("rev-parse", "HEAD", cwd=repo).stdout.strip() + for path in gitlinks: + git("update-index", "--add", "--cacheinfo", f"{GITLINK},{head},{path}", + cwd=repo) + commit_index(repo, "Link") + + +def set_leg(plan: Path, path: str, leg: str) -> None: + """Answer the entry for `path` with `leg`, whatever `plan` said.""" + text = plan.read_text(encoding=UTF8) + entry = re.compile(rf"(?m)^ - path: {re.escape(path)}\n leg: .*$") + assert entry.search(text), f"no plan entry for {path}" + plan.write_text(entry.sub( + lambda _: f" - path: {path}\n leg: {leg}\n" + " resolution: \"answered by the test\"", text, count=1), + encoding=UTF8) + + +def answer(plan: Path, legs: dict) -> None: + """Each of `legs` answered, and every other question answered `root`.""" + for path, leg in legs.items(): + set_leg(plan, path, leg) + plan.write_text(plan.read_text(encoding=UTF8).replace( + " leg: null\n", " leg: root\n resolution: \"kept\"\n"), + encoding=UTF8) + + +def planned(base: Path, files: dict, links: dict, legs: dict, + gitlinks: tuple = ()) -> tuple[Path, Path]: + """`(source, plan)`: a source with `files` and, in its tree, `links`; + each of `legs` answered, and every other question answered `root`.""" + source = make_source_repo(base / THING, tree={**BASIC, **files}) + if links or gitlinks: + commit_tree_entries(source, links, gitlinks) + plan = base / PLAN_FILE + written = write_plan(source, plan, project=PROJECT) + assert written.returncode == 0, written.stderr + written.stdout + answer(plan, legs) + return source, plan + + +def check(plan: Path, *extra: str): + return run_script(ADOPT, "check", PLAN_FLAG, str(plan), *extra) + + +def symlink_findings(result) -> list[str]: + return [line for line in result.stderr.splitlines() + if line.startswith(FINDING)] + + +def execute_args(base: Path, plan: Path) -> Namespace: + """What `main` hands `cmd_execute`, with `--yes`.""" + return Namespace(plan=str(plan), source=None, + local_remote_dir=base / REMOTES, allow_empty_leg=[], + yes=True, work_dir=base / WORK) + + +def refused_by(adopter, args): + """The `Refusal` `cmd_execute` raises. Failing to raise one means it went + on to make the legs.""" + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(args) + return caught.value + + +def no_leg_was_created(base: Path) -> None: + assert not (base / REMOTES).exists() + for role in (SPEC, CODE): + assert not (base / WORK / f"{PROJECT}-{role}").exists() + + +def ticked(path: str) -> str: + """A path as a finding spells it (`_spelled`): between backticks.""" + return f"`{path}`" + + +def finding(link: str, target: str, shape_path: str, why: str, + aside: str = "") -> str: + """The line `check` prints, and `execute`'s refusal carries.""" + return (f"{FINDING}the plan keeps {ticked(link)} in the root, a symlink " + f"to {ticked(target)}, and {ticked(shape_path)}, a path the shape " + f"writes, runs through it{aside}, but {why}. Send {ticked(link)} " + "to a leg or `drop` it, or change where it points in the source " + "and re-run `plan`.") + + +def leads(link: str, here: str, which: str) -> str: + """Why a symlink's destination is not in the planned root.""" + return f"the symlink {ticked(link)} leads to {ticked(here)}, which {which}" + + +def climbs(link: str) -> str: + return f"at the symlink {ticked(link)} it climbs above the assembly root" + + +def refused_alike(adopter, base: Path, plan: Path, line: str, + *extra: str) -> None: + """`check` exits 1 with exactly `line`, and `execute` refuses with the + same line, word for word, before any leg exists.""" + checked = check(plan, *extra) + assert checked.returncode == 1, checked.stderr + checked.stdout + assert symlink_findings(checked) == [line], checked.stderr + refusal = refused_by(adopter, execute_args(base, plan)) + assert refusal.code == ROOT_SYMLINK + assert f" {line}" in refusal.detail.splitlines() + assert "no leg repository was created" in refusal.remediation + no_leg_was_created(base) + + +def passed_alike(adopter, base: Path, plan: Path, *extra: str) -> None: + """`check` says `plan ok`, and `execute`'s refusal of `check`'s + findings passes.""" + checked = check(plan, *extra) + assert checked.returncode == 0, checked.stderr + checked.stdout + assert "plan ok" in checked.stdout + loaded = adopter.Plan.load(plan) + # Made as `execute`'s `_work_root` makes it: the disk is asked there. + (base / WORK).mkdir(exist_ok=True) + source = loaded.open_source(None, base / WORK) + assert adopter._refuse_what_check_finds(loaded, source) is None + + +@pytest.fixture +def no_filter_repo_probe(adopter, monkeypatch): + """Every refusal under test comes before `git filter-repo` is used.""" + monkeypatch.setattr(adopter, "_require_filter_repo", lambda: None) + + +# --------------------------------------------------------------------------- +# Refused by `check`, and by `execute` before any leg exists +# --------------------------------------------------------------------------- + +NOT_IN_THE_TREE = "is not in the source tree" +SPEC_MOUNT = "it enters `spec`, where the plan mounts the spec leg" +ON_THIS_MACHINE = ("an absolute path: a place on this machine, not in the " + "assembly") +#: Why the shape's scripts cannot run from where a link puts them; `{}` is +#: the directory they land in. +SCRIPTS_MISPLACED = ( + "and the shape's scripts look for the assembly root, and for " + "contracts/repository-naming.yaml, from the directory above their own, " + "so they run only from a directory directly under the root, which {} is " + "not") +ON_A_FILE = ("a file that is already there, where the shape's copy cannot be " + "written") +#: Why a path that follows more than 40 symlinks cannot be written: the +#: lookup cannot tell a loop from a long chain, and `open()` gives up on both. +TOO_MANY = ("more than 40, round a loop or along a chain longer than Linux's " + "`open()` follows") +#: 41 symlinks and no loop, `.github -> l1`, `l1 -> l2` ... `l40 -> ci`: +#: `open()` gives up at `l40` all the same (F1 of #183's re-verify). +CHAIN = {GITHUB: "l1", **{f"l{n}": f"l{n + 1}" for n in range(1, 40)}, + "l40": "ci"} + + +def too_many(at: str) -> str: + """How a refusal says that a path follows more than 40 symlinks, the + last at `at`.""" + return f"follows too many symlinks at {ticked(at)}" + +LINK_TO_GH = {"tooling/gh/workflows/build.yml": "on: push\n"} + +#: `(*PLANNED, (link, target, shape path, why[, aside]))`. The first ten are +#: lane 2's table for #178 (D3-D12) and #183's loop; the rest are the other +#: ways a link the shape writes through cannot be written through. +REFUSED = [ + pytest.param({}, {ATTRIBUTES: NOWHERE_ATTRS}, {ATTRIBUTES: ROOT}, + (ATTRIBUTES, NOWHERE_ATTRS, ATTRIBUTES, + leads(ATTRIBUTES, NOWHERE_ATTRS, NOT_IN_THE_TREE)), + id="D4-a-dangling-file-link"), + pytest.param({"config/readme.md": "c\n"}, {ATTRIBUTES: CONFIG_ATTRS}, + {CONFIG_DIR: ROOT, ATTRIBUTES: ROOT}, + (ATTRIBUTES, CONFIG_ATTRS, ATTRIBUTES, + leads(ATTRIBUTES, CONFIG_ATTRS, NOT_IN_THE_TREE)), + id="D3-a-dangling-file-link-in-a-kept-directory"), + pytest.param(LINK_TO_GH, {GITHUB: TOOLING_GH}, + {"tooling/": CODE, GITHUB: ROOT}, + (GITHUB, TOOLING_GH, WORKFLOW, + leads(GITHUB, TOOLING_GH, "the plan sends to the code leg")), + id="D5-into-a-path-sent-to-a-leg"), + pytest.param(LINK_TO_GH, {GITHUB: TOOLING_GH}, + {"tooling/": "drop", GITHUB: ROOT}, + (GITHUB, TOOLING_GH, WORKFLOW, + leads(GITHUB, TOOLING_GH, "the plan sends to `drop`")), + id="into-a-dropped-path"), + pytest.param({}, {GITHUB: OUTSIDE_CI}, {GITHUB: ROOT}, + (GITHUB, OUTSIDE_CI, WORKFLOW, climbs(GITHUB)), + id="D6-a-relative-link-out"), + pytest.param({}, {GITHUB: "/srv/ci"}, {GITHUB: ROOT}, + (GITHUB, "/srv/ci", WORKFLOW, + f"the symlink `.github` leads to `/srv/ci`, " + f"{ON_THIS_MACHINE}"), id="D6b-an-absolute-link"), + pytest.param({}, {GITHUB: "spec/ci"}, {GITHUB: ROOT}, + (GITHUB, "spec/ci", WORKFLOW, SPEC_MOUNT), + id="D7-into-a-mount"), + pytest.param({}, {GITHUB: SPEC}, {GITHUB: ROOT}, + (GITHUB, SPEC, WORKFLOW, SPEC_MOUNT), + id="D7b-at-a-mount"), + pytest.param({}, {GITHUB: "code/ci"}, {GITHUB: ROOT}, + (GITHUB, "code/ci", WORKFLOW, + "it enters `code`, where the plan mounts the code leg"), + id="into-the-other-mount"), + pytest.param({}, {GITHUB: GIT_INFO}, {GITHUB: ROOT}, + (GITHUB, GIT_INFO, WORKFLOW, + leads(GITHUB, GIT_INFO, NOT_IN_THE_TREE)), + id="D9-into-dot-git"), + pytest.param({}, {GITHUB: "../Northwind/ci"}, {GITHUB: ROOT}, + (GITHUB, "../Northwind/ci", WORKFLOW, climbs(GITHUB)), + id="D12-out-and-back-by-name"), + pytest.param({}, {GITHUB: GITHUB}, {GITHUB: ROOT}, + (GITHUB, GITHUB, WORKFLOW, + f"it {too_many(GITHUB)}: {TOO_MANY}"), + id="a-link-to-itself"), + pytest.param({}, {GITHUB: LOOP, LOOP: GITHUB}, + {GITHUB: ROOT, LOOP: ROOT}, + (GITHUB, LOOP, WORKFLOW, + f"it {too_many(GITHUB)}: {TOO_MANY}"), + id="a-loop-of-two"), + pytest.param({CI_FILE: ON_PUSH}, CHAIN, + {CI_DIR: ROOT, **{link: ROOT for link in CHAIN}}, + (GITHUB, "l1", WORKFLOW, f"it {too_many('l40')}: {TOO_MANY}"), + id="a-chain-of-41-links"), + pytest.param({}, {GITHUB: README}, {GITHUB: ROOT}, + (GITHUB, README, WORKFLOW, + f"it passes through {ticked(README)}, which is a file"), + id="to-a-file-the-shape-writes-beneath"), + pytest.param({CI_FILE: ON_PUSH}, {GITHUB: "nowhere/../ci"}, + {CI_DIR: ROOT, GITHUB: ROOT}, + (GITHUB, "nowhere/../ci", WORKFLOW, + "it passes through `nowhere`, which is not a directory in " + "the assembly root"), id="dot-dot-from-nowhere"), + pytest.param({RUN_SH: ECHO}, {GITHUB: TOOLS_UP, + TOOLS_UP: "../../x"}, + {TOOLS_DIR: ROOT, GITHUB: ROOT}, + (GITHUB, TOOLS_UP, WORKFLOW, climbs(TOOLS_UP)), + id="a-chain-whose-second-link-leaves"), + pytest.param({}, {SHAPE_DIR: "../elsewhere"}, {SHAPE_DIR: ROOT}, + (SHAPE_DIR, "../elsewhere", SHAPE_README, climbs(SHAPE_DIR)), + id="the-collision-directory-leaves"), + pytest.param({"tools/sub/run.sh": ECHO}, {SCRIPTS: TOOLS_SUB}, + {TOOLS_DIR: ROOT, SCRIPTS: ROOT}, + (SCRIPTS, TOOLS_SUB, VALIDATE_PINS, + "it lands at `tools/sub/validate-pins.py`, " + + SCRIPTS_MISPLACED.format(ticked(TOOLS_SUB))), + id="R2-scripts-two-directories-down"), + pytest.param({}, {SCRIPTS: "."}, {SCRIPTS: ROOT}, + (SCRIPTS, ".", VALIDATE_PINS, + "it lands at `validate-pins.py`, " + + SCRIPTS_MISPLACED.format("the root itself")), + id="R6-scripts-in-the-root-itself"), + pytest.param({RUN_SH: ECHO}, {SCRIPTS: "tools/.."}, + {TOOLS_DIR: ROOT, SCRIPTS: ROOT}, + (SCRIPTS, "tools/..", VALIDATE_PINS, + "it lands at `validate-pins.py`, " + + SCRIPTS_MISPLACED.format("the root itself")), + id="R9-scripts-in-the-root-by-dot-dot"), + pytest.param({"scripts/own.sh": ECHO, RUN_SH: ECHO}, + {BOOTSTRAP_PY: "../tools/bootstrap.py"}, + {TOOLS_DIR: ROOT, "scripts/": ROOT}, + (BOOTSTRAP_PY, "../tools/bootstrap.py", BOOTSTRAP_PY, + leads(BOOTSTRAP_PY, "tools/bootstrap.py", NOT_IN_THE_TREE)), + id="R10-one-script-apart-from-the-rest"), + # The shape writes AGENTS.md before CLAUDE.md, so AGENTS.md still + # dangles when the shape writes through it. + pytest.param({}, {AGENTS: CLAUDE}, {AGENTS: ROOT}, + (AGENTS, CLAUDE, AGENTS, + leads(AGENTS, CLAUDE, NOT_IN_THE_TREE)), + id="a-link-to-what-the-shape-writes-only-later"), + pytest.param({DOCS_AGENTS: "a\n"}, {AGENTS: SPEC_OTHER}, + {DOCS_DIR: SPEC, AGENTS: ROOT}, + (AGENTS, SPEC_OTHER, AGENTS, + leads(AGENTS, SPEC_OTHER, + "the spec leg, mounted at `spec`, does not hold")), + id="at-a-path-into-a-leg-that-lacks-it"), + # Only a template puts `description` in `.git`, and `execute` clones + # with none: the shape would write through the link into the clone's own + # `.git`, where no commit holds it. + pytest.param({}, {CLAUDE: GIT_DESCRIPTION}, {CLAUDE: ROOT}, + (CLAUDE, GIT_DESCRIPTION, CLAUDE, + leads(CLAUDE, GIT_DESCRIPTION, NOT_IN_THE_TREE)), + id="at-a-path-to-what-only-a-template-puts-in-dot-git"), + pytest.param({}, {CLAUDE: GIT_HOOKS}, {CLAUDE: ROOT}, + (CLAUDE, GIT_HOOKS, CLAUDE, + leads(CLAUDE, GIT_HOOKS, NOT_IN_THE_TREE)), + id="at-a-path-to-the-hooks-only-a-template-makes"), + # B2: the copy under `shape/` lands on something already there. + pytest.param({}, {SHAPE_DIR: "."}, {SHAPE_DIR: ROOT}, + (SHAPE_DIR, ".", SHAPE_README, + f"it lands on {ticked(README)}, {ON_A_FILE}"), + id="the-collision-directory-is-the-root"), + pytest.param({"vendor/README.md": "vendor's own\n"}, {SHAPE_DIR: VENDOR}, + {VENDOR_DIR: ROOT, SHAPE_DIR: ROOT}, + (SHAPE_DIR, VENDOR, SHAPE_README, + f"it lands on `vendor/README.md`, {ON_A_FILE}"), + id="the-collision-directory-holds-the-name"), + pytest.param({"sub/README.md/x.txt": "x\n"}, {SHAPE_DIR: "sub"}, + {"sub/": ROOT, SHAPE_DIR: ROOT}, + (SHAPE_DIR, "sub", SHAPE_README, + "it lands on `sub/README.md`, a directory that is already " + "there, where the shape's copy cannot be written"), + id="the-collision-directory-holds-a-directory-of-the-name"), + # S1: a case twin is followed as a disk that ignores case follows it. + pytest.param({}, {GITHUB_TWIN: OUTSIDE_CI}, {GITHUB_TWIN: ROOT}, + (GITHUB_TWIN, OUTSIDE_CI, WORKFLOW, climbs(GITHUB_TWIN), + CASE_ASIDE), id="a-case-twin-above-a-shape-path"), + pytest.param({}, {GITIGNORE_TWIN: NOWHERE_GITIGNORE}, + {GITIGNORE_TWIN: ROOT}, + (GITIGNORE_TWIN, NOWHERE_GITIGNORE, ".gitignore", + leads(GITIGNORE_TWIN, NOWHERE_GITIGNORE, NOT_IN_THE_TREE), + CASE_ASIDE), id="a-case-twin-at-a-shape-path"), +] + + +@pytest.mark.parametrize((*PLANNED, "expected"), REFUSED) +def test_check_reports_the_link_and_execute_refuses_it_before_any_leg( + adopter, no_filter_repo_probe, links_as_links, tmp_path, files, links, + legs, expected): + """One finding naming the link, its target and the shape path that runs + through it; `execute` refuses with the same line, word for word, and + makes no leg, so the corrected plan does not meet `leg-remote-exists`.""" + _, plan = planned(tmp_path, files, links, legs) + refused_alike(adopter, tmp_path, plan, finding(*expected)) + + +def test_a_link_into_a_submodule_kept_in_the_root_is_reported(tmp_path): + """A gitlink is a directory in a checkout, holding another repository: + a link into it is not a link into `a file`.""" + _, plan = planned(tmp_path, {}, {GITHUB: "vendor/lib/ci"}, + {GITHUB: ROOT, VENDOR_DIR: ROOT}, + gitlinks=("vendor/lib",)) + checked = check(plan) + assert finding(GITHUB, "vendor/lib/ci", WORKFLOW, + "it enters `vendor/lib`, a submodule the plan keeps in the " + "root") in symlink_findings(checked), checked.stderr + + +def test_one_finding_per_link_however_many_shape_paths_run_through_it( + tmp_path): + """`contracts -> /srv` carries four shape paths; the finding names the + first one the materializer writes.""" + _, plan = planned(tmp_path, {}, {CONTRACTS: "/srv"}, + {CONTRACTS: ROOT}) + findings = symlink_findings(check(plan)) + assert findings == [finding( + CONTRACTS, "/srv", "contracts/spec-pin.yaml", + f"the symlink `contracts` leads to `/srv`, {ON_THIS_MACHINE}")] + + +def test_a_link_at_the_copy_is_one_finding_by_the_rule_that_sees_it( + adopter, no_filter_repo_probe, tmp_path): + """A symlink kept AT the copy under `shape/` of a path the root holds is + #262's: `plan-root-blocks-shape-copy` names it, the shape's copy written + THROUGH it, and this rule does not say the same link again. `execute` + refuses with that line, before any leg. Where the root holds the path + only through a symlink above it (`.github -> ci`, the project's own + `ci/workflows/validate.yml` kept), #262's rule, which follows no link, + does not see the copy, and the link at it is this rule's.""" + held = tmp_path / "held" + _, plan = planned(held, {NOTES_MD: "n\n"}, + {SHAPE_README: f"../{NOTES_MD}"}, + {NOTES_MD: ROOT, "shape/": ROOT}) + checked = check(plan) + assert checked.returncode == 1, checked.stderr + checked.stdout + assert symlink_findings(checked) == [], checked.stderr + said = [line for line in checked.stderr.splitlines() + if line.startswith(COPY_FINDING)] + assert len(said) == 1, checked.stderr + assert f"That path is the symlink {ticked(SHAPE_README)}." in said[0] + assert "`execute` would write the copy through it " in said[0] + refusal = refused_by(adopter, execute_args(held, plan)) + assert refusal.code == COPY_IN_THE_WAY + assert f" {said[0]}" in refusal.detail.splitlines() + no_leg_was_created(held) + + through = tmp_path / "through" + copy = f"{SHAPE_DIR}/{WORKFLOW}" + out_of_the_copy = f"../../../{NOTES_MD}" + _, plan = planned(through, {"ci/workflows/validate.yml": ON_PUSH, + NOTES_MD: "n\n"}, + {GITHUB: "ci", copy: out_of_the_copy}, + {CI_DIR: ROOT, GITHUB: ROOT, NOTES_MD: ROOT, + "shape/": ROOT}) + refused_alike(adopter, through, plan, finding( + copy, out_of_the_copy, copy, + f"it lands on {ticked(NOTES_MD)}, {ON_A_FILE}")) + + +#: Two targets that are not plain text: a byte that is not UTF-8, and a +#: newline that would end the one-line FINDING. +SPELLED = [ + pytest.param(b"nowhere\xff", "nowhere\\udcff", id="not-utf-8"), + pytest.param(b"a\nb", "a\\nb", id="a-newline"), +] + + +@pytest.mark.parametrize(("raw", "spelled"), SPELLED) +def test_a_target_that_is_not_plain_text_is_spelled(tmp_path, raw, spelled): + """As #166's findings spell a path: between backticks, a byte that is + not UTF-8 as `\\udcff`, the plan's own spelling of it (#329), and a + newline as `\\n`, so the finding is one line that reads one way.""" + _, plan = planned(tmp_path, {}, {GITHUB: raw}, {GITHUB: ROOT}) + checked = check(plan) + assert symlink_findings(checked) == [finding( + GITHUB, spelled, WORKFLOW, leads(GITHUB, spelled, NOT_IN_THE_TREE))] + + +#: A drive letter's paths, as a source written on Windows can hold them: +#: absolute there, and on Linux and macOS a relative path into a directory +#: named `C:` (F2 of #183's re-verify). Nothing on a Windows runner is at +#: either. +DRIVE_CLAUDE = "C:/no-such-dir-183/CLAUDE.md" +DRIVE_CI = "C:/no-such-dir-183/ci" + + +def drive_reading(link: str, target: str, at_the_path: bool) -> str: + """Why `link -> target`, a drive letter's path, cannot be written + through, read as this machine's `open()` reads it.""" + if not ON_WINDOWS: + return leads(link, target, NOT_IN_THE_TREE) + where = (f"the symlink {ticked(link)} leads to {ticked(target)}, " + f"{ON_THIS_MACHINE}") + return f"{where}, and nothing is there" if at_the_path else where + + +@pytest.mark.parametrize(("link", "target", "path"), [ + pytest.param(CLAUDE, DRIVE_CLAUDE, CLAUDE, id="at-a-shape-path"), + pytest.param(GITHUB, DRIVE_CI, WORKFLOW, id="above-a-shape-path"), +]) +def test_a_drive_letter_target_is_read_as_this_machine_reads_it( + adopter, no_filter_repo_probe, links_as_links, tmp_path, link, target, + path): + """`C:/x` is absolute only on Windows. There it is a place on this + machine, not in the assembly; on Linux and macOS it is a relative path, + and nothing in this source is at it. Refused on every disk, and each + sentence is true where it prints: on Linux it used to say "an absolute + path: a place on this machine".""" + _, plan = planned(tmp_path, {}, {link: target}, {link: ROOT}) + refused_alike(adopter, tmp_path, plan, finding( + link, target, path, drive_reading(link, target, link == path))) + + +@pytest.mark.skipif(ON_WINDOWS, + reason="Windows cannot hold a directory named `C:`") +def test_a_drive_letter_target_into_the_root_is_followed_off_windows( + adopter, tmp_path): + """On Linux and macOS, `.github -> C:/ci` leads into the root's own + `C:/ci`, as it does on main, which adopts it and bootstraps: `open()` + and the materializer follow it there, and so does `check`.""" + _, plan = planned(tmp_path, {"C:/ci/keep.txt": "k\n"}, + {GITHUB: "C:/ci"}, {"C:/": ROOT, GITHUB: ROOT}) + passed_alike(adopter, tmp_path, plan) + + +#: `main`'s finding (#303) for a symlink of the source with an empty target. +EMPTY_SYMLINK = "plan-empty-symlink" + + +@pytest.mark.parametrize("links, legs, empty", [ + pytest.param({GITHUB: ""}, {GITHUB: ROOT}, GITHUB, id="above"), + pytest.param({CLAUDE: ""}, {CLAUDE: ROOT}, CLAUDE, id="at"), + pytest.param({GITHUB: "ci", "ci": ""}, {GITHUB: ROOT, "ci": ROOT}, + "ci", id="second-in-a-chain"), + pytest.param({"scripts": ""}, {"scripts": ROOT}, "scripts", + id="above-the-scripts"), + pytest.param({"shape": ""}, {"shape": ROOT}, "shape", + id="above-the-copies"), +]) +def test_a_link_with_an_empty_target_is_mains_to_report( + adopter, no_filter_repo_probe, tmp_path, links, legs, empty): + """Linux cannot hold a symlink to nothing, and every clone `execute` + makes of the source checks it out, wherever the plan sends it. `main` + (#303) names each such symlink in the source as `plan-empty-symlink`, + with the fix in the source. A shape path that runs through one, at, + above or along a chain, gets no `plan-root-symlink` beside it: one link, + one finding, and `execute` refuses with `main`'s. Above `scripts` and + above `shape`, the empty target walked on as the root would be a fault + of its own (the scripts land in the root; a copy lands on the root's own + `README.md`), so those two cases hold the walk to marking the link + empty.""" + _, plan = planned(tmp_path, {}, links, legs) + checked = check(plan) + assert checked.returncode == 1, checked.stderr + checked.stdout + assert symlink_findings(checked) == [], checked.stderr + said = [line for line in checked.stderr.splitlines() + if line.startswith(f"FINDING {EMPTY_SYMLINK}: ")] + assert len(said) == 1, checked.stderr + assert said[0].startswith(f"FINDING {EMPTY_SYMLINK}: {ticked(empty)} ") + refusal = refused_by(adopter, execute_args(tmp_path, plan)) + assert refusal.code == EMPTY_SYMLINK + assert f" {said[0]}" in refusal.detail.splitlines() + assert ROOT_SYMLINK not in refusal.detail + no_leg_was_created(tmp_path) + + +# --------------------------------------------------------------------------- +# A declared pin's file (#273) +# --------------------------------------------------------------------------- +# +# Since #273, `execute` writes `contracts/-pin.yaml` for each pin the +# plan declares, through `place`, after the copies and before the shape's own +# pin. The walk follows those files too, so a link that only a pin's file +# runs through, and that it cannot be written through, is refused before any +# leg; walked without them, `check` passed such a link, and `execute` stopped +# at one after both legs (rv183g's P1). A link AT a pin's file stays #273's +# `plan-root-holds-pin`, one finding. + +GOVERNANCE = "governance" +GLASS, LENS = "openGlass", "openLens" +#: The name of the file `execute` writes the pin the plan declares on +#: openGlass to, in `contracts/`; and where it lands through +#: `contracts -> governance`. +PIN_NAME = "openglass-pin.yaml" +PIN_FILE = f"{CONTRACTS}/{PIN_NAME}" +GOVERNANCE_PIN = f"{GOVERNANCE}/{PIN_NAME}" +ROOT_HOLDS_PIN = "plan-root-holds-pin" +PIN_MALFORMED = "plan-pin-malformed" +PIN_UNRECORDED = "plan-pin-unrecorded" +SHAPE_PIN = "contracts/shape-pin.yaml" +#: What `plan` writes for a plan that declares no pin. +NO_PIN = "pins: []\n" +#: An absolute path where nothing is; `{b}` is the test's own directory. +ABSOLUTE_PIN = "{b}/abs/pin.yaml" +#: A `contracts/` the root keeps, with nothing at a pin's file. +REAL_CONTRACTS = ({"contracts/README.md": "c\n"}, {"contracts/": ROOT}) +#: rv183g's source: `contracts -> governance`, a directory the root keeps. +THROUGH_GOVERNANCE = ({"governance/README.md": "# governance\n"}, + {CONTRACTS: ROOT, "governance/": ROOT}) + + +def pin_row(product: str, commit: bool = True) -> str: + """One `pins:` entry as `plan --pin @` records it, or, + without `commit`, the same entry with its commit left out.""" + lines = [f" - product: {product}", + f" repository: opensoft/{product}", + f" commit: \"{'0123456789abcdef' * 2}01234567\"", + f" tree_sha256: \"{'0123456789abcdef' * 4}\"", + f" digest_source: \"local-clone ({product})\""] + return "\n".join(lines if commit else lines[:2] + lines[3:]) + "\n" + + +def declare(plan: Path, block: str) -> None: + """The plan's `pins: []` replaced by `block`, which starts with `pins:`, + as a hand edit would leave it: bytes, so LF everywhere. It is read as + text, which reads `\\r\\n` as `\\n`, because `answer` writes the plan + back with `write_text`, and on Windows that ends every line `\\r\\n`.""" + text = plan.read_text(encoding=UTF8) + assert text.count(NO_PIN) == 1, "the plan was written with no pin" + plan.write_bytes(text.replace(NO_PIN, block, 1).encode(UTF8)) + + +def through_governance(base: Path, target: str, block: str) -> Path: + """The plan for rv183g's source, whose `governance/openglass-pin.yaml` + leads to `target`, with `block` as its `pins:`.""" + files, legs = THROUGH_GOVERNANCE + _, plan = planned(base, files, {CONTRACTS: GOVERNANCE, + GOVERNANCE_PIN: target}, legs) + declare(plan, block) + return plan + + +def planned_root(adopter, base: Path, plan: Path): + """`(the plan, its source, the walks' `_PlannedRoot`)`, as `check` reads + them, with symlinks taken to be checked out as symlinks.""" + loaded = adopter.Plan.load(plan) + (base / WORK).mkdir(exist_ok=True) + source = loaded.open_source(None, base / WORK) + return loaded, source, adopter._PlannedRoot( + loaded, source, source.tree(), probe=adopter._SymlinkProbe(None)) + + +A_PIN = "pins:\n" + pin_row(GLASS) + +#: `(where governance/openglass-pin.yaml leads, why)`: rv183g's three, and +#: one that dangles inside, which `place` writes the pin through and this +#: branch refuses as it refuses AT-dangle-dir-present. `{b}` is the test's +#: own directory and `{w}` the work directory. +PIN_THROUGH = [ + pytest.param(ABSOLUTE_PIN, + f"the symlink {ticked(GOVERNANCE_PIN)} leads to " + f"{ticked(ABSOLUTE_PIN)}, {ON_THIS_MACHINE}, and " + "nothing is there", id="an-absolute-path-where-nothing-is"), + pytest.param("../../outside-pin.yaml", + f"{climbs(GOVERNANCE_PIN)}, to " + f"{ticked('{w}/outside-pin.yaml')}, where nothing is", + id="out-of-the-root"), + pytest.param(PIN_NAME, + f"it {too_many(GOVERNANCE_PIN)}: {TOO_MANY}", id="itself"), + pytest.param("nowhere.yaml", + leads(GOVERNANCE_PIN, "governance/nowhere.yaml", + NOT_IN_THE_TREE), id="nowhere-inside"), +] + + +def where(text: str, base: Path) -> str: + """`text` with `{b}` and `{w}` filled in for the test's directory.""" + return text.format(b=base.as_posix(), + w=(base / WORK).resolve().as_posix()) + + +@pytest.mark.parametrize(("target", "why"), PIN_THROUGH) +def test_a_link_only_a_pins_file_runs_through_is_refused_before_any_leg( + adopter, no_filter_repo_probe, links_as_links, tmp_path, target, + why): + """rv183g's P1. No other path the shape writes meets + `governance/openglass-pin.yaml`; with `--pin openGlass@`, + `execute` writes the pin's file through it. It stopped there after both + legs on the first three (`materialize-outside`, + `materialize-symlink-loop`), and wrote the fourth through the link, which + this branch refuses as it refuses AT-dangle-dir-present. Now `check` + says each once, naming the first link and the pin's file, and `execute` + refuses with the same line before any leg.""" + plan = through_governance(tmp_path, where(target, tmp_path), A_PIN) + refused_alike(adopter, tmp_path, plan, + finding(CONTRACTS, GOVERNANCE, PIN_FILE, + where(why, tmp_path)), + WORK_FLAG, str(tmp_path / WORK)) + + +@pytest.mark.parametrize("target", [ + pytest.param(case.values[0], id=case.id) for case in PIN_THROUGH]) +def test_without_a_pin_no_path_the_shape_writes_meets_that_link( + adopter, links_as_links, tmp_path, target): + """The control: the same source with no pin declared. No path the shape + writes runs through `governance/openglass-pin.yaml`, so it is not this + check's business.""" + plan = through_governance(tmp_path, where(target, tmp_path), NO_PIN) + passed_alike(adopter, tmp_path, plan, WORK_FLAG, str(tmp_path / WORK)) + + +@needs_filter_repo +@needs_disk_symlinks +def test_without_a_pin_that_source_adopts(tmp_path): + """The control end to end: `contracts -> governance`, with + `governance/openglass-pin.yaml` leading to an absolute path where + nothing is, and no pin, adopts, the link kept as the source had it.""" + target = where(ABSOLUTE_PIN, tmp_path) + plan = through_governance(tmp_path, target, NO_PIN) + result = execute(tmp_path, plan) + assert result.returncode == 0, result.stderr + result.stdout + assert VERIFIED in result.stdout + source = tmp_path / THING + assert split_modes(source)[GOVERNANCE_PIN] == SYMLINK + assert git("show", f"{BRANCH}:{GOVERNANCE_PIN}", + cwd=source).stdout == target + + +@pytest.mark.parametrize(("link", "aside"), [ + pytest.param(PIN_FILE, "", id="at-the-pins-file"), + pytest.param("contracts/OpenGlass-pin.yaml", CASE_ASIDE, + id="a-case-twin-at-it"), +]) +def test_a_link_at_a_pins_file_is_one_finding_by_the_rule_that_sees_it( + adopter, no_filter_repo_probe, links_as_links, tmp_path, link, + aside): + """A symlink kept AT a pin's file is #273's: `plan-root-holds-pin` names + it, on a disk that keeps case or, for a case twin, on one that ignores + it. The walk, which follows the pin's file now, meets the same link + leading nowhere and does not say it again: one link, one finding, and + `execute` refuses with #273's line.""" + files, legs = REAL_CONTRACTS + _, plan = planned(tmp_path, files, {link: "nowhere.yaml"}, legs) + declare(plan, A_PIN) + checked = check(plan) + assert checked.returncode == 1, checked.stderr + checked.stdout + assert symlink_findings(checked) == [], checked.stderr + said = [line for line in checked.stderr.splitlines() + if line.startswith(f"FINDING {ROOT_HOLDS_PIN}: ")] + assert len(said) == 1, checked.stderr + assert said[0].startswith(f"FINDING {ROOT_HOLDS_PIN}: the plan keeps the " + f"symlink {ticked(link)} "), said[0] + assert f"at {ticked(PIN_FILE)}{aside}, where `execute` writes" in said[0] + refusal = refused_by(adopter, execute_args(tmp_path, plan)) + assert refusal.code == ROOT_HOLDS_PIN + assert f" {said[0]}" in refusal.detail.splitlines() + assert ROOT_SYMLINK not in refusal.detail + no_leg_was_created(tmp_path) + + +#: `pins:` blocks `execute` writes no pin file from, with the code `check` +#: names each by. +NOT_A_PIN = [ + pytest.param("pins: 7\n", PIN_MALFORMED, id="a-number"), + pytest.param(f"pins: {GLASS}\n", PIN_MALFORMED, id="text"), + pytest.param("pins:\n - 7\n", PIN_MALFORMED, id="an-entry-7"), + pytest.param(f"pins:\n - {GLASS}\n", PIN_UNRECORDED, id="a-name-alone"), + pytest.param("pins:\n" + pin_row(GLASS, commit=False), PIN_UNRECORDED, + id="no-commit"), +] + + +@pytest.mark.parametrize(("block", "code"), NOT_A_PIN) +def test_a_pins_block_execute_writes_no_file_from_adds_no_path( + adopter, no_filter_repo_probe, links_as_links, tmp_path, block, + code): + """The walk reads the pins as `_leg_collision_findings` reads them, + `_declared_pins`: only the entries `execute` writes a file from. So a + `pins:` that is no list, or an entry that is no pin, adds no path to + walk and raises nothing, and the link only a pin's file would meet gets + no finding of its own: `check` names the `pins:` alone, with no + traceback, and `execute` refuses with that, before any leg. The walk is + asked directly too, as `_root_symlink_findings` makes it.""" + plan = through_governance(tmp_path, where(ABSOLUTE_PIN, tmp_path), block) + checked = check(plan) + assert checked.returncode == 1, checked.stderr + checked.stdout + assert "Traceback" not in checked.stderr, checked.stderr + said = [line for line in checked.stderr.splitlines() + if line.startswith("FINDING ")] + assert said, checked.stderr + assert all(line.startswith(f"FINDING {code}: ") for line in said), said + refusal = refused_by(adopter, execute_args(tmp_path, plan)) + assert refusal.code == code + assert ROOT_SYMLINK not in refusal.detail + no_leg_was_created(tmp_path) + loaded, source, walked = planned_root(adopter, tmp_path, plan) + assert walked.pins == [] + assert [walk.path for walk in walked.walks() + if not walk.path.startswith(f"{SHAPE_DIR}/")] == list( + assembly_root_paths()) + assert adopter._root_symlink_findings(loaded, source, + source.tree()) == [] + + +def test_the_walk_follows_each_pins_file_where_the_materializer_writes_it( + adopter, tmp_path): + """The pins the walk follows are those `execute` hands the materializer + (`_recorded_pins`), each product once, in the plan's order; and the + paths it follows are those the materializer writes when it is handed + them, in its order: each pin's file after the copies, and the shape's + own pin last.""" + _, plan = planned(tmp_path, {}, {}, {}) + declare(plan, "pins:\n" + pin_row(LENS) + pin_row(GLASS) + pin_row(LENS)) + loaded, _, walked = planned_root(adopter, tmp_path, plan) + assert walked.pins == [LENS, GLASS] + assert walked.pins == list(adopter._recorded_pins(loaded)) + target = tmp_path / ASSEMBLY + target.mkdir() + written = materialize_assembly_root( + REPO, target, template_values(), collision_dir=SHAPE_DIR, + neutral_pins={product: {} for product in walked.pins}).written + assert [walk.path for walk in walked.walks() + if not walk.path.startswith(f"{SHAPE_DIR}/")] == written + assert written == list(assembly_root_paths(walked.pins)) + assert written[-3:] == ["contracts/openlens-pin.yaml", PIN_FILE, + SHAPE_PIN] + + +def test_a_link_to_a_pins_file_finds_the_pin_place_wrote_first( + adopter, links_as_links, tmp_path): + """rv183g's side effect: with a pin, `contracts/shape-pin.yaml -> + openglass-pin.yaml`. `place` writes the pin's file before the shape's + own pin, so it finds a file there and writes the shape's pin beside it, + under `shape/`, as it does for any path the root holds. The walk now + says the same, where it said the link led to what is not in the source + tree, and `check` prints no link finding for it. (The root's + `contracts/shape-pin.yaml` is then not the shape's pin, and `execute`'s + verification fails after both legs, as on `main` and as for a file the + source keeps there: a question of what the root keeps at the shape's + pin, not of where this link leads.)""" + files, legs = REAL_CONTRACTS + _, plan = planned(tmp_path, files, {SHAPE_PIN: PIN_NAME}, legs) + declare(plan, A_PIN) + checked = check(plan) + assert symlink_findings(checked) == [], checked.stderr + _, _, walked = planned_root(adopter, tmp_path, plan) + walks = {walk.path: walk for walk in walked.walks()} + shape_pin = walks[SHAPE_PIN] + assert (shape_pin.problem, shape_pin.landed, shape_pin.taken) == ( + None, PIN_FILE, True) + copy = walks[f"{SHAPE_DIR}/{SHAPE_PIN}"] + assert (copy.problem, copy.links, copy.taken) == (None, [], False) + + +@needs_disk_symlinks +def test_the_materializer_writes_the_shapes_pin_beside_that_link(tmp_path): + """What the walk above predicts, on the disk: the pin's file is written + first, and the shape's pin beside the link, which is left as it was.""" + target = tmp_path / ASSEMBLY + symlink(target / SHAPE_PIN, PIN_NAME) + result = materialize_assembly_root( + REPO, target, template_values(), collision_dir=SHAPE_DIR, + neutral_pins={GLASS: {}}) + assert PIN_FILE in result.written + assert (SHAPE_PIN, f"{SHAPE_DIR}/{SHAPE_PIN}") in result.collisions + assert os.readlink(target / SHAPE_PIN) == PIN_NAME + + +# --------------------------------------------------------------------------- +# A link AT a path the shape writes, leading out of the assembly (B1) +# --------------------------------------------------------------------------- +# +# `place` writes the shape's copy beside such a link when its destination is +# there, and keeps the link as the source had it: `CLAUDE.md` linked to a +# personal global file. Where nothing is there, the shape would write its +# file THROUGH the link, outside the assembly, as main does. Whether +# something is there is a fact of the machine running the command. + +#: `(links, legs, files, (link, target, culprit, culprit's target, path))`, +#: each a link AT a shape path whose destination is an absolute path: `{o}` +#: is a directory outside the source, and the destination is +#: `{o}/`. The culprit is the link whose target is absolute. +AT_OUTSIDE = [ + pytest.param({CLAUDE: f"{{o}}/{CLAUDE}"}, {CLAUDE: ROOT}, {}, + (CLAUDE, f"{{o}}/{CLAUDE}", CLAUDE, f"{{o}}/{CLAUDE}", + CLAUDE), id="direct"), + pytest.param({MAKEFILE: BUILD_MAKEFILE, + BUILD_MAKEFILE: f"{{o}}/{MAKEFILE}"}, + {MAKEFILE: ROOT, BUILD_MAKEFILE: ROOT}, + {"build/x.txt": "x\n"}, + (MAKEFILE, BUILD_MAKEFILE, BUILD_MAKEFILE, + f"{{o}}/{MAKEFILE}", MAKEFILE), id="a-two-link-chain"), +] + + +#: The arguments every `AT_OUTSIDE` case takes. +AT_OUTSIDE_ARGS = (LINKS, "legs", FILES, "names") + + +def outside_plan(base: Path, links: dict, legs: dict, + files: dict) -> tuple[Path, Path]: + """`(the directory outside, the plan)` for an `AT_OUTSIDE` case.""" + outside = base / OUTSIDE_DIR + outside.mkdir() + _, plan = planned(base, files, {link: target.format(o=outside.as_posix()) + for link, target in links.items()}, legs) + return outside, plan + + +@pytest.mark.parametrize(AT_OUTSIDE_ARGS, AT_OUTSIDE) +def test_a_link_at_a_path_to_a_place_outside_that_is_there_is_kept( + adopter, tmp_path, links, legs, files, names): + """(b) Main writes the shape's copy beside it as `shape/`, and + keeps the link exactly as the source had it.""" + outside, plan = outside_plan(tmp_path, links, legs, files) + (outside / names[-1]).write_text(PERSONAL, encoding=UTF8) + passed_alike(adopter, tmp_path, plan) + + +@pytest.mark.parametrize(AT_OUTSIDE_ARGS, AT_OUTSIDE) +def test_a_link_at_a_path_to_a_place_outside_that_is_not_there_is_refused( + adopter, no_filter_repo_probe, links_as_links, tmp_path, links, legs, + files, names): + """(a) Where nothing is there, main writes the shape's file THROUGH the + link, onto this machine outside the assembly.""" + outside, plan = outside_plan(tmp_path, links, legs, files) + link, target, culprit, place, path = ( + name.format(o=outside.as_posix()) for name in names) + refused_alike(adopter, tmp_path, plan, finding( + link, target, path, f"the symlink {ticked(culprit)} leads to " + f"{ticked(place)}, {ON_THIS_MACHINE}, and nothing is there")) + assert list(outside.iterdir()) == [] + + +def test_execute_asks_its_own_disk_before_any_leg(adopter, + no_filter_repo_probe, + links_as_links, tmp_path): + """`check` passed while the file was there; `execute` finds it gone and + refuses before any leg, rather than trusting what `check` saw.""" + outside, plan = outside_plan(tmp_path, {CLAUDE: f"{{o}}/{CLAUDE}"}, + {CLAUDE: ROOT}, {}) + personal = outside / CLAUDE + personal.write_text(PERSONAL, encoding=UTF8) + assert check(plan).returncode == 0 + personal.unlink() + refusal = refused_by(adopter, execute_args(tmp_path, plan)) + assert refusal.code == ROOT_SYMLINK + assert "and nothing is there" in refusal.detail + no_leg_was_created(tmp_path) + + +def test_a_link_at_a_path_that_climbs_into_the_work_directory( + adopter, no_filter_repo_probe, links_as_links, tmp_path): + """`execute` makes the assembly's clone in its work directory, so `..` + above the root leads there: refused while nothing is there, kept, as on + main, once something is. `check` asks the same directory when it is + given the same `--work-dir`.""" + _, plan = planned(tmp_path, {}, {MAKEFILE: "../shared/Makefile"}, + {MAKEFILE: ROOT}) + work = tmp_path / WORK + place = f"{work.resolve().as_posix()}/shared/Makefile" + refused_alike(adopter, tmp_path, plan, finding( + MAKEFILE, "../shared/Makefile", MAKEFILE, + f"{climbs(MAKEFILE)}, to {ticked(place)}, where nothing is"), + WORK_FLAG, str(work)) + (work / "shared").mkdir() + (work / "shared" / MAKEFILE).write_text("all:\n", encoding=UTF8) + passed_alike(adopter, tmp_path, plan, WORK_FLAG, str(work)) + + +def test_a_link_at_a_path_into_what_execute_makes_beside_the_assembly( + adopter, no_filter_repo_probe, links_as_links, tmp_path): + """The spec leg's clone is made in the work directory only after this + check, so the disk cannot answer for it yet.""" + _, plan = planned(tmp_path, {}, {AGENTS: "../Northwind-spec/agents.md"}, + {AGENTS: ROOT}) + work = tmp_path / WORK + made = f"{work.resolve().as_posix()}/Northwind-spec" + refused_alike(adopter, tmp_path, plan, finding( + AGENTS, "../Northwind-spec/agents.md", AGENTS, + f"{climbs(AGENTS)} into {ticked(made)}, where `execute` makes the " + "spec leg's clone after this check"), WORK_FLAG, str(work)) + + +# --------------------------------------------------------------------------- +# Not refused +# --------------------------------------------------------------------------- + +#: `PLANNED` cases: plans whose links the shape writes through, or +#: past, safely: each target is something the root keeps, and the shape's +#: scripts still land directly under the root. Main adopts and bootstraps +#: every one of them (lane 2's 4c; the R rows of this branch's report). +ACCEPTED = [ + pytest.param({RUN_SH: ECHO}, {SCRIPTS: TOOLS}, + {TOOLS_DIR: ROOT, SCRIPTS: ROOT}, + id="R1-scripts-directly-under-the-root"), + pytest.param({"governance/policy.md": "p\n"}, {CONTRACTS: "governance"}, + {"governance/": ROOT, CONTRACTS: ROOT}, + id="R3-contracts-elsewhere"), + pytest.param({}, {GITHUB: "."}, {GITHUB: ROOT}, + id="R4-github-to-the-root"), + pytest.param({}, {CONTRACTS: "."}, {CONTRACTS: ROOT}, + id="R5-contracts-to-the-root"), + pytest.param({"vendor/keep.txt": "k\n"}, {SHAPE_DIR: VENDOR}, + {VENDOR_DIR: ROOT, SHAPE_DIR: ROOT}, + id="R7-the-collision-directory-elsewhere"), + pytest.param({"ci/workflows/validate.yml": ON_PUSH}, {GITHUB: "ci"}, + {CI_DIR: ROOT, GITHUB: ROOT}, + id="R8-a-link-to-the-projects-own-workflow"), + pytest.param({CI_FILE: ON_PUSH}, {GITHUB: "ci"}, + {CI_DIR: ROOT, GITHUB: ROOT}, id="C14c-github-to-ci"), + pytest.param({CONFIG_ATTRS: "* text=auto\n"}, + {ATTRIBUTES: CONFIG_ATTRS}, + {CONFIG_DIR: ROOT, ATTRIBUTES: ROOT}, + id="D2-a-live-file-link-the-shape-writes-beside"), + pytest.param({CI_FILE: ON_PUSH, RUN_SH: ECHO}, + {GITHUB: TOOLS_UP, TOOLS_UP: "../ci/"}, + {CI_DIR: ROOT, TOOLS_DIR: ROOT, GITHUB: ROOT}, + id="D10-a-chain-through-dot-dot"), + # Where the shape has written the destination itself by the time it + # writes the path: main writes CLAUDE.md's copy beside the link, and + # the workflow into the `scripts/` the shape made. + pytest.param({}, {CLAUDE: AGENTS}, {CLAUDE: ROOT}, + id="a-link-to-what-the-shape-writes-first"), + pytest.param({}, {GITHUB: SCRIPTS}, {GITHUB: ROOT}, + id="a-directory-the-shape-makes-first"), + # A link AT a path into a mounted leg that holds its destination, or + # onto the mount itself: the copy goes beside, as on main. + pytest.param({DOCS_AGENTS: "a\n"}, {AGENTS: "spec/docs/agents.md"}, + {DOCS_DIR: SPEC, AGENTS: ROOT}, + id="at-a-path-into-a-leg-that-holds-it"), + pytest.param({}, {AGENTS: SPEC}, {AGENTS: ROOT}, + id="at-a-path-onto-a-mount"), + pytest.param({}, {CLAUDE: "code/CLAUDE.md"}, + {CLAUDE: ROOT, "src/": "drop"}, + id="at-a-path-into-a-seeded-leg"), + pytest.param({DOCS_AGENTS: "a\n"}, {AGENTS: "../Northwind/docs/agents.md"}, + {DOCS_DIR: ROOT, AGENTS: ROOT}, + id="at-a-path-back-into-the-assembly-by-its-name"), + pytest.param({CI_FILE: ON_PUSH}, {GITHUB_TWIN: "ci"}, + {CI_DIR: ROOT, GITHUB_TWIN: ROOT}, + id="a-case-twin-that-stays-inside"), + # A link AT a path to what the clone's own `.git` holds by the time the + # shape is written: `place` finds it there and writes the copy beside, + # as on main (F3 and 2b of #183's re-verify). + pytest.param({}, {CLAUDE: GIT_HEAD}, {CLAUDE: ROOT}, + id="at-a-path-to-the-clones-own-head"), + *(pytest.param({}, {CLAUDE: kept}, {CLAUDE: ROOT}, + id="at-a-path-to-the-clones-own-" + + kept[5:].replace("/", "-")) + for kept in GIT_KEPT), +] + +#: Links no path the shape writes runs through: main keeps each exactly as +#: the source had it, and so must this. The last is D6's link out, sent to a +#: leg instead of kept: the mount's `git rm` takes it out of the root before +#: the shape is written, so where it leads is the leg's business, and a +#: `check` that followed it would refuse a plan main adopts. +UNRELATED = [ + pytest.param({NOTES_README: "n\n"}, {NOTES_UPSTREAM: ETC}, + NOTES_KEPT, id="an-absolute-link"), + pytest.param({NOTES_README: "n\n"}, {"notes/old": "../gone"}, + NOTES_KEPT, id="a-dangling-link-out"), + pytest.param({}, {LATEST: LATEST}, {LATEST: ROOT}, + id="a-link-to-itself-no-shape-path-meets"), + pytest.param({}, {GITHUB: OUTSIDE_CI}, {GITHUB: CODE}, + id="the-link-itself-sent-to-a-leg"), +] + + +@pytest.mark.parametrize(PLANNED, ACCEPTED + UNRELATED) +def test_a_link_the_shape_can_write_through_or_never_meets_is_not_refused( + adopter, tmp_path, files, links, legs): + _, plan = planned(tmp_path, files, links, legs) + passed_alike(adopter, tmp_path, plan) + + +# --------------------------------------------------------------------------- +# core.symlinks=false (#183) +# --------------------------------------------------------------------------- + + +def github_to_ci(base: Path) -> tuple[Path, Path]: + return planned(base, {CI_FILE: ON_PUSH}, {GITHUB: "ci"}, + {CI_DIR: ROOT, GITHUB: ROOT}) + + +def test_execute_refuses_a_link_it_would_check_out_as_a_file( + adopter, no_filter_repo_probe, tmp_path, monkeypatch): + """The assembly's clone would hold `.github` as a plain file, and the + materializer died there with NotADirectoryError after both legs were + pushed. It is refused before them, and the probe leaves nothing.""" + _, plan = github_to_ci(tmp_path) + for key, value in SYMLINKS_OFF.items(): + monkeypatch.setenv(key, value) + refusal = refused_by(adopter, execute_args(tmp_path, plan)) + assert refusal.code == SYMLINKS_OFF_CODE + work = (tmp_path / WORK).resolve() + assert refusal.detail == ( + f"the plan keeps `.github` in the root, a symlink to `ci`, and " + f"{ticked(WORKFLOW)}, a path the shape writes, runs through it, but " + "`git` here checks a symlink out as a plain file: `core.symlinks` is " + f"false for a repository made in {work}. The assembly's clone would " + "hold `.github` as a file, and nothing can be written beneath a " + "file.") + assert "no leg repository was created" in refusal.remediation + assert "send `.github` to a leg or `drop` it" in refusal.remediation + no_leg_was_created(tmp_path) + assert list(work.iterdir()) == [] + + +def test_a_case_twin_checked_out_as_a_file_is_named_as_one( + adopter, no_filter_repo_probe, tmp_path, monkeypatch): + """On a disk that ignores case, `.github/workflows/` runs through the + kept `.GitHub`, which this checkout would hold as a file. On a disk that + keeps case it does not run through it at all, so the refusal says which + disk it means, as `check`'s finding does (S1).""" + _, plan = planned(tmp_path, {CI_FILE: ON_PUSH}, {GITHUB_TWIN: "ci"}, + {CI_DIR: ROOT, GITHUB_TWIN: ROOT}) + for key, value in SYMLINKS_OFF.items(): + monkeypatch.setenv(key, value) + refusal = refused_by(adopter, execute_args(tmp_path, plan)) + assert refusal.code == SYMLINKS_OFF_CODE + assert refusal.detail.startswith( + f"the plan keeps `.GitHub` in the root, a symlink to `ci`, and " + f"{ticked(WORKFLOW)}, a path the shape writes, runs through " + f"it{CASE_ASIDE}, but `git` here checks a symlink out as a plain " + "file: "), refusal.detail + no_leg_was_created(tmp_path) + + +def test_the_probe_answers_what_git_would_do(adopter, tmp_path, monkeypatch): + for key, value in SYMLINKS_ON.items(): + monkeypatch.setenv(key, value) + assert adopter._checks_out_symlinks(tmp_path) is True + monkeypatch.setenv(CONFIG_VALUE, "false") + assert adopter._checks_out_symlinks(tmp_path) is False + assert list(tmp_path.iterdir()) == [] + + +def test_with_symlinks_on_the_same_plan_passes_the_probe(adopter, tmp_path, + monkeypatch): + _, plan = github_to_ci(tmp_path) + for key, value in SYMLINKS_ON.items(): + monkeypatch.setenv(key, value) + loaded = adopter.Plan.load(plan) + opened = loaded.open_source(None, tmp_path / READ) + assert adopter._refuse_symlinks_checked_out_as_files( + loaded, opened, tmp_path) is None + + +#: Plans for which the probe is never made: no link; a link no shape path +#: meets; a live link AT a shape path, which a checkout without symlinks +#: still takes as a file the shape's copy goes beside. +NO_PROBE = [ + pytest.param({}, {}, {}, id="no-link"), + pytest.param({NOTES_README: "n\n"}, {NOTES_UPSTREAM: ETC}, + NOTES_KEPT, id="an-unrelated-link"), + pytest.param({CONFIG_ATTRS: "* text=auto\n"}, + {ATTRIBUTES: CONFIG_ATTRS}, + {CONFIG_DIR: ROOT, ATTRIBUTES: ROOT}, id="a-link-at-a-path"), +] + + +@pytest.mark.parametrize(PLANNED, NO_PROBE) +def test_an_adoption_with_no_link_beneath_a_shape_path_makes_no_probe( + adopter, tmp_path, monkeypatch, files, links, legs): + """An ordinary adoption runs exactly as before: nothing is made in the + work directory to ask `git` a question this plan does not raise.""" + monkeypatch.setattr(adopter, PROBE, no_probe) + for key, value in SYMLINKS_OFF.items(): + monkeypatch.setenv(key, value) + _, plan = planned(tmp_path, files, links, legs) + loaded = adopter.Plan.load(plan) + opened = loaded.open_source(None, tmp_path / READ) + assert adopter._refuse_symlinks_checked_out_as_files( + loaded, opened, tmp_path) is None + # Nor does `check`'s list, which asks only of a link it cannot follow. + assert adopter._refuse_what_check_finds(loaded, opened) is None + + +#: Links AT a path the shape writes that `check` cannot follow, which a +#: checkout without symlinks holds as plain files (B-OFF of #183's +#: re-verify): `CLAUDE.md` linked to a personal file on another machine, +#: one that dangles, and D3's. `place` finds each file there and writes the +#: shape's copy beside it, and main adopts and bootstraps each. +AT_A_FILE = [ + pytest.param({}, {CLAUDE: "/nonexistent-183/.claude/CLAUDE.md"}, + {CLAUDE: ROOT}, id="absolute-and-nothing-there"), + pytest.param({}, {CLAUDE: NOWHERE_MD}, {CLAUDE: ROOT}, + id="dangling"), + pytest.param({"config/readme.md": "c\n"}, {ATTRIBUTES: CONFIG_ATTRS}, + {CONFIG_DIR: ROOT, ATTRIBUTES: ROOT}, + id="D3-dangling-in-a-kept-directory"), +] + + +@pytest.mark.parametrize(PLANNED, [ + *AT_A_FILE, + pytest.param({}, {GITIGNORE_TWIN: NOWHERE_GITIGNORE}, + {GITIGNORE_TWIN: ROOT}, id="a-case-twin"), +]) +def test_with_symlinks_off_a_link_at_a_path_is_a_file_passed_by_both( + adopter, tmp_path, monkeypatch, files, links, legs): + """Where `git` checks a symlink out as a plain file (Git for Windows' + default), nothing about where a link AT a path the shape writes leads is + refused: `check` and `execute` each ask their own work directory, and + neither the list of `check`'s findings nor the probe for a link the + shape writes beneath refuses it. A case twin is that file once case is + ignored, and a second name where it is kept.""" + _, plan = planned(tmp_path, files, links, legs) + links_as_files(monkeypatch) + passed_alike(adopter, tmp_path, plan) + loaded = adopter.Plan.load(plan) + work = tmp_path / WORK + opened = loaded.open_source(None, work) + assert adopter._refuse_symlinks_checked_out_as_files( + loaded, opened, work) is None + assert list(work.iterdir()) == [] + + +def test_with_symlinks_off_a_link_above_a_path_is_still_followed( + tmp_path, monkeypatch): + """Only a link AT a path the shape writes is the file a checkout without + symlinks makes of it (`_a_file_here`). A link the shape writes BENEATH + is still followed by `check`, and its finding stands.""" + _, plan = planned(tmp_path, {}, {GITHUB: OUTSIDE_CI}, {GITHUB: ROOT}) + links_as_files(monkeypatch) + checked = check(plan) + assert checked.returncode == 1, checked.stderr + checked.stdout + assert symlink_findings(checked) == [ + finding(GITHUB, OUTSIDE_CI, WORKFLOW, climbs(GITHUB))] + + +def git_reads_symlinks_as(directory: Path) -> str: + """What `git config --type=bool --get core.symlinks` says in a + repository made in `directory`, as the probe asks it ("" for nothing).""" + repo = directory / "asked" + git("init", "-q", str(repo), cwd=directory) + said = subprocess.run( + ["git", "config", "--type=bool", "--get", CORE_SYMLINKS], + cwd=str(repo), capture_output=True, text=True, check=False) + shutil.rmtree(repo) + return said.stdout.strip() + + +def test_each_command_asks_its_own_work_directory(adopter, tmp_path, + monkeypatch): + """B-OFF asks the disk where the command makes its clone. A global + configuration that turns `core.symlinks` off only for repositories made + under one directory (`includeIf "gitdir:"`) is met by a work directory + there and by no other, both by `check`'s list and by `execute`'s own + refusal of a link the shape writes beneath.""" + base = tmp_path.resolve() + off, on = base / "off", base / "on" + off.mkdir() + on.mkdir() + included = base / "links-as-files.gitconfig" + included.write_text("[core]\n\tsymlinks = false\n", encoding=UTF8) + config = base / "global.gitconfig" + config.write_text(f"[includeIf \"gitdir:{off.as_posix()}/\"]\n" + f"\tpath = {included.as_posix()}\n", encoding=UTF8) + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) + told = (git_reads_symlinks_as(off), git_reads_symlinks_as(on)) + if told != (SYMLINKS_OFF[CONFIG_VALUE], ""): + pytest.skip(f"git here does not tell the two directories apart: " + f"{told}, from this runner's own configuration") + _, at = planned(base / "at", {}, {CLAUDE: NOWHERE_MD}, {CLAUDE: ROOT}) + loaded = adopter.Plan.load(at) + assert adopter._refuse_what_check_finds( + loaded, loaded.open_source(None, off)) is None + with pytest.raises(adopter.Refusal) as caught: + adopter._refuse_what_check_finds(loaded, loaded.open_source(None, on)) + assert caught.value.code == ROOT_SYMLINK + _, above = github_to_ci(base / "above") + loaded = adopter.Plan.load(above) + opened = loaded.open_source(None, base / READ) + with pytest.raises(adopter.Refusal) as caught: + adopter._refuse_symlinks_checked_out_as_files(loaded, opened, off) + assert caught.value.code == SYMLINKS_OFF_CODE + assert adopter._refuse_symlinks_checked_out_as_files( + loaded, opened, on) is None + + +def test_with_no_work_directory_a_symlink_is_taken_as_one(adopter, + monkeypatch): + """A `Source` that no command opened has no work directory to ask: no + probe is made, and `git` is taken to check a symlink out as one.""" + monkeypatch.setattr(adopter, PROBE, no_probe) + assert adopter._SymlinkProbe(None).symlinks() is True + + +def test_the_probe_is_made_once_however_many_walks_ask(adopter, tmp_path, + monkeypatch): + """The answer is kept: two links AT paths the shape writes that a walk + cannot follow, each met by the walk that keeps case and the one that + folds it, make one probe.""" + asked = [] + + def probe(work_root): + asked.append(work_root) + return False + + monkeypatch.setattr(adopter, PROBE, probe) + _, plan = planned(tmp_path, {}, + {CLAUDE: NOWHERE_MD, AGENTS: "nowhere-agents.md"}, + {CLAUDE: ROOT, AGENTS: ROOT}) + loaded = adopter.Plan.load(plan) + work = tmp_path / WORK + work.mkdir() + assert adopter._refuse_what_check_finds( + loaded, loaded.open_source(None, work)) is None + assert len(asked) == 1 + + +def cannot_write_in(directory: Path) -> bool: + try: + (directory / "made").mkdir() + except PermissionError: + return True + (directory / "made").rmdir() + return False + + +@pytest.fixture +def unwritable_work(tmp_path): + """A work directory this user cannot write in, or a skip where `chmod` + cannot make one so (root, Windows).""" + work = tmp_path / WORK + work.mkdir() + work.chmod(0o500) + if not cannot_write_in(work): + work.chmod(0o700) + pytest.skip("chmod cannot keep this user from writing a directory") + yield work + work.chmod(0o700) + + +def test_a_work_directory_no_probe_can_be_made_in_reports_as_before( + adopter, tmp_path, unwritable_work): + """J4 of #183's re-verify: where this user cannot write the work + directory, no repository can be made there to ask `git`, and a symlink + is taken to be checked out as one, as before B-OFF asked. `check` + prints its finding for a link AT a path that the walk cannot follow, and + `execute`'s list of `check`'s findings refuses with it, where both ended + in a PermissionError traceback.""" + _, plan = planned(tmp_path, {}, {CLAUDE: NOWHERE_MD}, {CLAUDE: ROOT}) + line = finding(CLAUDE, NOWHERE_MD, CLAUDE, + leads(CLAUDE, NOWHERE_MD, NOT_IN_THE_TREE)) + checked = check(plan, WORK_FLAG, str(unwritable_work)) + assert "Traceback" not in checked.stderr, checked.stderr + assert checked.returncode == 1, checked.stderr + checked.stdout + assert symlink_findings(checked) == [line] + loaded = adopter.Plan.load(plan) + with pytest.raises(adopter.Refusal) as caught: + adopter._refuse_what_check_finds( + loaded, loaded.open_source(None, unwritable_work)) + assert caught.value.code == ROOT_SYMLINK + assert f" {line}" in caught.value.detail.splitlines() + + +# --------------------------------------------------------------------------- +# The materializer's own refusals, for a caller that skipped `check` +# --------------------------------------------------------------------------- + + +def template_values() -> dict: + """A value for every placeholder the assembly-root templates hold.""" + names = set() + for path in (REPO / "templates" / "assembly-root").rglob("*"): + if path.is_file(): + names.update(PLACEHOLDER_RE.findall(path.read_text(encoding=UTF8))) + return {name[2:-2]: "x" for name in names} + + +def materialize(target: Path): + return materialize_assembly_root(REPO, target, template_values(), + collision_dir=SHAPE_DIR) + + +def test_the_materializer_writes_the_paths_check_follows_in_order(tmp_path): + """`check` follows `assembly_root_paths()`; this is the list the + materializer writes, in its order.""" + target = tmp_path / ASSEMBLY + target.mkdir() + assert materialize(target).written == list(assembly_root_paths()) + + +def refused_materializing(target: Path) -> Refusal: + with pytest.raises(Refusal) as caught: + materialize(target) + return caught.value + + +def test_a_file_where_the_shape_needs_a_directory_is_a_refusal(tmp_path): + """What a `core.symlinks=false` checkout makes of `.github -> ci`: a + plain FILE holding `ci`. It used to be a NotADirectoryError traceback. + + A source that keeps a plain file named `.github` is #263's + `plan-root-blocks-shape` in `check`, and `execute` refuses it before any + leg exists, so only a caller that skipped `check` meets this refusal. + The remediation does not send that caller to `check`; it names the work + directory, which by then holds both legs' clones.""" + target = tmp_path / ASSEMBLY + target.mkdir() + (target / GITHUB).write_text("ci", encoding=UTF8) + refusal = refused_materializing(target) + assert refusal.code == BLOCKED + assert refusal.detail == (f"{ticked(WORKFLOW)} cannot be written: " + f"{ticked(GITHUB)} is a file, not a directory") + assert "nothing was written at that path" in refusal.remediation + assert "check" not in refusal.remediation + assert ("an empty `--work-dir`, or none: a `--work-dir` this run was " + "given still holds its clones of both legs, and the next run " + "cannot clone into them") in refusal.remediation + + +#: `(links, files, code, detail)` of a target holding `links` and `files`. +#: `{t}` is the target directory and `{o}` the directory beside it that a +#: link out of it reaches. Each path the refusal names is between backticks, +#: as the materializer writes one for a caller that passes no `spell`; `{t}` +#: is the directory the caller gave, named as it is. +UNWRITABLE = [ + pytest.param({GITHUB: "../outside"}, (), OUTSIDE, + f"{ticked(WORKFLOW)} would be written outside {{t}}: at the " + f"symlink {ticked(GITHUB)} it climbs above it", + id="a-relative-link-out"), + pytest.param({GITHUB: "{o}"}, (), OUTSIDE, + f"{ticked(WORKFLOW)} would be written through the symlink " + f"{ticked(GITHUB)}, which leads to the absolute path `{{o}}`; " + "the shape follows only a symlink whose relative target " + "stays inside {t}", id="an-absolute-link"), + pytest.param({SHAPE_DIR: "../outside"}, (README,), OUTSIDE, + f"{ticked(SHAPE_README)} would be written outside {{t}}: at " + f"the symlink {ticked(SHAPE_DIR)} it climbs above it", + id="the-collision-directory-out"), + pytest.param({GITHUB: GITHUB}, (), SYMLINK_LOOP, + f"{ticked(WORKFLOW)} {too_many(GITHUB)} in {{t}}: {TOO_MANY}", + id="a-link-to-itself"), + pytest.param({GITHUB: LOOP, LOOP: GITHUB}, (), SYMLINK_LOOP, + f"{ticked(WORKFLOW)} {too_many(GITHUB)} in {{t}}: {TOO_MANY}", + id="a-loop-of-two"), + pytest.param(CHAIN, ("ci/keep.txt",), SYMLINK_LOOP, + f"{ticked(WORKFLOW)} {too_many('l40')} in {{t}}: {TOO_MANY}", + id="a-chain-of-41-links"), + pytest.param({GITHUB: "nowhere"}, (), BLOCKED, + f"{ticked(WORKFLOW)} cannot be written: {ticked(GITHUB)} is " + "a symlink to `nowhere`, which is not a directory here", + id="a-dangling-directory-link"), + pytest.param({GITHUB: README}, (README,), BLOCKED, + f"{ticked(WORKFLOW)} cannot be written: {ticked(GITHUB)} is " + f"a symlink to {ticked(README)}, which is not a directory " + "here", id="a-link-to-a-file"), + pytest.param({ATTRIBUTES: NOWHERE_ATTRS}, (), BLOCKED, + f"{ticked(ATTRIBUTES)} cannot be written: the symlink " + f"{ticked(ATTRIBUTES)} leads to {ticked(NOWHERE_ATTRS)}, and " + "there is no directory there to write it in", + id="a-dangling-file-link"), + # S2: `open()` takes `..`, `.` and a trailing `/` only from a directory. + pytest.param({MAKEFILE: "ci/"}, (), BLOCKED, + f"{ticked(MAKEFILE)} cannot be written: the target of the " + f"symlink {ticked(MAKEFILE)} goes on from `ci`, which is not " + "a directory here", id="a-file-link-spelled-as-a-directory"), + pytest.param({MAKEFILE: "a/.."}, (), BLOCKED, + f"{ticked(MAKEFILE)} cannot be written: the target of the " + f"symlink {ticked(MAKEFILE)} goes on from `a`, which is not " + "a directory here", id="dot-dot-after-nothing"), + pytest.param({MAKEFILE: "README.md/.."}, (README,), BLOCKED, + f"{ticked(MAKEFILE)} cannot be written: the target of the " + f"symlink {ticked(MAKEFILE)} goes on from {ticked(README)}, " + "which is not a directory here", id="dot-dot-after-a-file"), + # B2: the copy under `shape/` led onto something already there. Where a + # copy lands is #262's `copy_in_the_way`, which `place` asks first. + pytest.param({SHAPE_DIR: "."}, (README,), COPY_BLOCKED, + f"the shape's copy of {ticked(README)} cannot be written to " + f"{ticked(SHAPE_README)}: {ticked(SHAPE_README)} is already " + "there, and nothing was written there", + id="the-copy-onto-a-file"), + pytest.param({SHAPE_DIR: ".", MAKEFILE: "docs"}, ("docs/x.md",), + COPY_BLOCKED, + f"the shape's copy of {ticked(MAKEFILE)} cannot be written to " + f"{ticked(SHAPE_DIR + '/' + MAKEFILE)}: " + f"{ticked(SHAPE_DIR + '/' + MAKEFILE)} is already there, a " + "directory, and nothing was written there", + id="the-copy-into-a-directory"), +] + + +@needs_disk_symlinks +@pytest.mark.parametrize((LINKS, FILES, CODE, "detail"), UNWRITABLE) +def test_the_materializer_refuses_before_it_writes(tmp_path, links, files, + code, detail): + """A symlink in the way is a named refusal: never a write outside the + target, never one over what is already there, never a traceback. Before + this rule the two links out wrote `validate.yml` into `outside/`, and the + rest raised OSError, FileExistsError, FileNotFoundError or + NotADirectoryError; before #262 the copies wrote over README.md or raised + IsADirectoryError, and #262's `copy_in_the_way` refuses them now.""" + target, outside = tmp_path / ASSEMBLY, tmp_path / OUTSIDE_DIR + target.mkdir() + outside.mkdir() + for name in files: + (target / name).parent.mkdir(parents=True, exist_ok=True) + (target / name).write_text("the source's own\n", encoding=UTF8) + for link, to in links.items(): + symlink(target / link, to.format(o=outside)) + refusal = refused_materializing(target) + assert (refusal.code, refusal.detail) == ( + code, detail.format(t=target, o=outside)) + assert list(outside.iterdir()) == [], "written outside the target" + for name in files: + assert (target / name).read_text(encoding=UTF8) == "the source's own\n" + + +#: `(link, the path the refusal names)`: a symlink with an empty target +#: above a path the shape writes, and at one. +TO_NOTHING = [ + pytest.param(GITHUB, WORKFLOW, id="above-a-shape-path"), + pytest.param(ATTRIBUTES, ATTRIBUTES, id="at-a-shape-path"), +] + + +@needs_disk_symlinks +@pytest.mark.parametrize(("link", "path"), TO_NOTHING) +def test_the_materializer_refuses_a_link_to_nothing(tmp_path, monkeypatch, + link, path): + """Linux cannot make a symlink with an empty target, so the link is made + to a placeholder and read back empty, as a disk that holds one answers. + `open()` finds nothing through such a link. Read as `.`, it was taken + for the directory it is in, and the refusal named the wrong thing.""" + target = tmp_path / ASSEMBLY + target.mkdir() + symlink(target / link, "placeholder") + real = os.readlink + monkeypatch.setattr(os, "readlink", + lambda here: "" if Path(here).name == link + else real(here)) + refusal = refused_materializing(target) + assert (refusal.code, refusal.detail) == ( + BLOCKED, f"{ticked(path)} cannot be written: the symlink " + f"{ticked(link)} has an empty target, which leads nowhere") + + +@needs_disk_symlinks +@pytest.mark.parametrize("inside", [ + {GITHUB: "ci"}, + {GITHUB: TOOLS_UP, TOOLS_UP: "../ci/"}, +], ids=["a-link-to-a-directory", "a-chain-through-dot-dot"]) +def test_the_materializer_still_writes_through_a_link_that_stays_inside( + tmp_path, inside): + target = tmp_path / ASSEMBLY + (target / "ci").mkdir(parents=True) + (target / TOOLS).mkdir() + for link, to in inside.items(): + symlink(target / link, to) + written = materialize(target).written + assert WORKFLOW in written + assert (target / "ci" / "workflows" / "validate.yml").is_file() + + +@needs_disk_symlinks +def test_the_materializer_writes_beside_a_link_to_something_there(tmp_path): + """A link AT a path whose destination is there is never written + through: the copy goes beside it, and what it leads to is untouched.""" + target, outside = tmp_path / ASSEMBLY, tmp_path / OUTSIDE_DIR + target.mkdir() + outside.mkdir() + (outside / CLAUDE).write_text(PERSONAL, encoding=UTF8) + symlink(target / CLAUDE, str(outside / CLAUDE)) + result = materialize(target) + assert (CLAUDE, f"{SHAPE_DIR}/{CLAUDE}") in result.collisions + assert (outside / CLAUDE).read_text(encoding=UTF8) == PERSONAL + + +#: A symlink's target that no terminal shows as it is: a line break, which +#: would split a refusal's one line, and an ESC, which a terminal acts on. +UNPRINTABLE_TARGET = "no\nwhere\x1b" +#: How `_spelled` writes the refusal for a `.github` that leads there. +SPELLED_REFUSAL = (f"{ticked(WORKFLOW)} cannot be written: {ticked(GITHUB)} " + "is a symlink to `no\\nwhere\\x1b`, which is not a " + "directory here") + + +@needs_disk_symlinks +def test_a_refusal_spells_each_name_as_its_caller_asks(adopter, tmp_path): + """`adopt-project.py` hands the materializer its `_spelled`, as #212 + spells every path a finding or a refusal names. A caller that passes no + `spell`, the scaffold, gets each name between backticks, as above: the + shape's own paths need nothing more.""" + target = tmp_path / ASSEMBLY + target.mkdir() + symlink(target / GITHUB, UNPRINTABLE_TARGET) + with pytest.raises(Refusal) as caught: + materialize_assembly_root(REPO, target, template_values(), + collision_dir=SHAPE_DIR, + spell=adopter._spelled) + assert (caught.value.code, caught.value.detail) == (BLOCKED, + SPELLED_REFUSAL) + + +# --------------------------------------------------------------------------- +# End to end, through `execute` +# --------------------------------------------------------------------------- + + +def disk_source(base: Path, files: dict, links: dict, legs: dict) -> tuple: + """`(source, plan)` with each link a real symlink in the source, and a + plan `check` passes.""" + source = make_source_repo(base / THING, tree={**BASIC, **files}) + for link, target in links.items(): + symlink(source / link, target) + git("add", "-A", "--", ".", cwd=source) + commit_index(source, "Link") + plan = base / PLAN_FILE + written = write_plan(source, plan, project=PROJECT) + assert written.returncode == 0, written.stderr + written.stdout + answer(plan, legs) + checked = check(plan) + assert checked.returncode == 0, checked.stderr + checked.stdout + return source, plan + + +def execute(base: Path, plan: Path): + return run_script(ADOPT, "execute", PLAN_FLAG, str(plan), "--yes", + "--local-remote-dir", str(base / REMOTES), + WORK_FLAG, str(base / WORK)) + + +def split_modes(source: Path) -> dict: + listing = git("ls-tree", "-r", BRANCH, cwd=source).stdout + return {line.split("\t", 1)[1]: line.split(" ", 1)[0] + for line in listing.splitlines()} + + +def bootstraps(source: Path, base: Path) -> None: + """A recursive clone of the split, and its bootstrap, exit 0.""" + clone = base / PROJECT + git(*FILE_PROTOCOL, "clone", "-q", "--recurse-submodules", "-b", BRANCH, + str(source), str(clone), cwd=base) + booted = run_script(clone / SCRIPTS / "bootstrap.py", cwd=clone) + assert booted.returncode == 0, booted.stderr + booted.stdout + + +@pytest.fixture(scope=MODULE) +def scripts_through_a_link(tmp_path_factory) -> SimpleNamespace: + """One real adoption of a source that keeps `scripts -> tools`.""" + if ON_WINDOWS or NO_FILTER_REPO: + pytest.skip("needs git filter-repo and a disk that holds symlinks") + base = tmp_path_factory.mktemp("scripts-link") + source, plan = disk_source(base, {RUN_SH: ECHO}, {SCRIPTS: TOOLS}, + {TOOLS_DIR: ROOT, SCRIPTS: ROOT}) + return SimpleNamespace(base=base, source=source, + result=execute(base, plan)) + + +@needs_filter_repo +@needs_disk_symlinks +def test_a_link_the_root_keeps_and_the_shape_writes_through_adopts( + scripts_through_a_link): + """Rule 3 of the brief: a link whose target the root keeps is followed, + end to end. The split holds the link, and the shape's scripts where it + leads; `_verify` finds each pinned script through it.""" + result = scripts_through_a_link.result + assert result.returncode == 0, result.stderr + result.stdout + assert VERIFIED in result.stdout + modes = split_modes(scripts_through_a_link.source) + assert modes[SCRIPTS] == SYMLINK + assert {"tools/bootstrap.py", "tools/repo_shape.py", RUN_SH} <= set(modes) + assert VALIDATE_PINS not in modes + + +@needs_filter_repo +@needs_disk_symlinks +def test_that_assembly_bootstraps(scripts_through_a_link, tmp_path): + assert scripts_through_a_link.result.returncode == 0 + bootstraps(scripts_through_a_link.source, tmp_path) + + +@pytest.fixture(scope=MODULE) +def claude_md_outside(tmp_path_factory) -> SimpleNamespace: + """One real adoption of a source that keeps `CLAUDE.md` linked to a + personal file outside it, which this machine has (B1).""" + if ON_WINDOWS or NO_FILTER_REPO: + pytest.skip("needs git filter-repo and a disk that holds symlinks") + base = tmp_path_factory.mktemp("claude-outside") + personal = base / OUTSIDE_DIR / CLAUDE + personal.parent.mkdir() + personal.write_text("a personal global CLAUDE.md\n", encoding=UTF8) + source, plan = disk_source(base, {}, {CLAUDE: str(personal)}, + {CLAUDE: ROOT}) + return SimpleNamespace(base=base, source=source, personal=personal, + result=execute(base, plan)) + + +@needs_filter_repo +@needs_disk_symlinks +def test_a_link_at_a_path_to_a_file_outside_adopts_beside_it( + claude_md_outside): + """As on main: the copy goes beside the link as `shape/CLAUDE.md`, the + split keeps the link exactly as the source had it, and the file it + leads to is untouched.""" + result = claude_md_outside.result + assert result.returncode == 0, result.stderr + result.stdout + assert VERIFIED in result.stdout + assert (f" beside {SHAPE_DIR}/{CLAUDE} (the source already has " + f"{CLAUDE}; nothing was overwritten)") in result.stdout + source = claude_md_outside.source + modes = split_modes(source) + assert modes[CLAUDE] == SYMLINK + assert modes[f"{SHAPE_DIR}/{CLAUDE}"] == "100644" + assert git("show", f"{BRANCH}:{CLAUDE}", + cwd=source).stdout == str(claude_md_outside.personal) + assert claude_md_outside.personal.read_text(encoding=UTF8) == ( + "a personal global CLAUDE.md\n") + + +@needs_filter_repo +@needs_disk_symlinks +def test_that_assembly_bootstraps_too(claude_md_outside, tmp_path): + assert claude_md_outside.result.returncode == 0 + bootstraps(claude_md_outside.source, tmp_path) + + +@needs_filter_repo +@needs_disk_symlinks +def test_a_link_at_a_path_into_the_clones_own_git_adopts_beside_it(tmp_path): + """`CLAUDE.md -> .git/HEAD`: every clone has that file, so `place` + writes the shape's copy beside the link and keeps it, and the adoption + verifies, as on main (F3 of #183's re-verify).""" + source, plan = disk_source(tmp_path, {}, {CLAUDE: GIT_HEAD}, + {CLAUDE: ROOT}) + result = execute(tmp_path, plan) + assert result.returncode == 0, result.stderr + result.stdout + assert VERIFIED in result.stdout + assert (f" beside {SHAPE_DIR}/{CLAUDE} (the source already has " + f"{CLAUDE}; nothing was overwritten)") in result.stdout + assert split_modes(source)[CLAUDE] == SYMLINK + assert git("show", f"{BRANCH}:{CLAUDE}", cwd=source).stdout == GIT_HEAD + + +def adopted_beside(result, source: Path, links: dict) -> None: + """`execute` verified the adoption, each link is kept as the source had + it, and the shape's copy of each is beside it under `shape/`.""" + assert result.returncode == 0, result.stderr + result.stdout + assert VERIFIED in result.stdout + modes = split_modes(source) + for link, target in links.items(): + assert (f" beside {SHAPE_DIR}/{link} (the source already has " + f"{link}; nothing was overwritten)") in result.stdout + assert modes[link] == SYMLINK + assert modes[f"{SHAPE_DIR}/{link}"] == "100644" + assert git("show", f"{BRANCH}:{link}", cwd=source).stdout == target + + +#: Two links, each AT a path the shape writes, into what the assembly's +#: clone holds in its own `.git` by then (2b of #183's re-verify). +INTO_THE_CLONES_GIT = [ + pytest.param({CLAUDE: ".git/index", AGENTS: ".git/packed-refs"}, + id="the-index-and-the-packed-refs"), + pytest.param({CLAUDE: ".git/logs/HEAD", AGENTS: ".git/objects/pack"}, + id="a-log-and-the-packs"), + pytest.param({CLAUDE: ".git/refs/heads", AGENTS: ".git/modules/spec"}, + id="the-branches-and-a-legs-store"), +] + + +@needs_filter_repo +@needs_disk_symlinks +@pytest.mark.parametrize(LINKS, INTO_THE_CLONES_GIT) +def test_links_into_what_the_clones_own_git_holds_adopt_beside_them( + tmp_path, links): + """Git makes each in the clone, or `execute` does before the shape is + written, so `place` finds it there and writes the copy beside the link, + as on main. Before 2b only five such names counted, and these were + refused.""" + source, plan = planned(tmp_path, {}, links, {link: ROOT for link in links}) + adopted_beside(execute(tmp_path, plan), source, links) + + +@needs_filter_repo +@pytest.mark.parametrize(PLANNED, AT_A_FILE) +def test_with_symlinks_off_execute_adopts_beside_the_file( + tmp_path, monkeypatch, files, links, legs): + """B-OFF of #183's re-verify, end to end: where `git` checks a symlink + out as a plain file, the assembly's clone holds the link AT the shape + path as one, `place` writes the copy beside it, and the split keeps the + link as the source had it, as main does. Before, `check` refused it, and + so did `execute`, before any leg.""" + source, plan = planned(tmp_path, files, links, legs) + for key, value in SYMLINKS_OFF.items(): + monkeypatch.setenv(key, value) + adopted_beside(execute(tmp_path, plan), source, links) + + +@needs_filter_repo +@needs_disk_symlinks +def test_an_unrelated_absolute_link_in_the_root_still_adopts(tmp_path): + """Rule 2 of the brief: no shape path runs through it, so main's + behaviour is kept: the split carries the link exactly as it was.""" + source, plan = disk_source(tmp_path, {NOTES_README: "n\n"}, + {NOTES_UPSTREAM: ETC}, NOTES_KEPT) + result = execute(tmp_path, plan) + assert result.returncode == 0, result.stderr + result.stdout + assert VERIFIED in result.stdout + assert split_modes(source)[NOTES_UPSTREAM] == SYMLINK + assert git("show", f"{BRANCH}:notes/upstream", + cwd=source).stdout == ETC + + +def refused_past_check(adopter, base: Path, target: str, monkeypatch, + capsys) -> str: + """What `execute` prints to stderr, exiting 2, for a source that keeps + `.github -> target` in the root, with #168's refusal of `check`'s + findings taken away so that the link reaches the materializer.""" + source = make_source_repo(base / THING, tree=BASIC) + symlink(source / GITHUB, target) + git("add", "-A", "--", ".", cwd=source) + commit_index(source, "Keep .github as a link") + plan = base / PLAN_FILE + assert write_plan(source, plan, project=PROJECT).returncode == 0 + set_leg(plan, GITHUB, ROOT) + monkeypatch.setenv("LANES_LANE", "") + clear_ambient_pin_sources(monkeypatch) + monkeypatch.setattr(adopter, "_refuse_what_check_finds", + lambda *_: None) + code = adopter.main(["execute", PLAN_FLAG, str(plan), "--yes", + "--local-remote-dir", str(base / REMOTES), + WORK_FLAG, str(base / WORK)]) + seen = capsys.readouterr() + assert code == 2, seen.err + seen.out + return seen.err + + +@needs_filter_repo +@needs_disk_symlinks +def test_a_caller_that_skips_check_meets_the_materializer_refusal( + adopter, tmp_path, monkeypatch, capsys): + """Defence in depth: with #168's refusal taken away, the link out (D6) + reaches the materializer, which refuses it by name -- no traceback, and + nothing written into the directory beside the work directory. Both legs + are pushed by then, so `execute` reports it as #188 reports every + failure after them: at the step, with the notice of what the run left.""" + outside = tmp_path / WORK / "outside-ci" + outside.mkdir(parents=True) + said = refused_past_check(adopter, tmp_path, OUTSIDE_CI, monkeypatch, + capsys) + assert f"{SPLIT_STEP} {OUTSIDE}: " in said + notice = said.split(LEFT_BEHIND, 1)[1] + assert BOTH_PUSHED in notice.split("\n", 1)[0] + assert "Traceback" not in said + assert list(outside.iterdir()) == [] + + +@needs_filter_repo +@needs_disk_symlinks +def test_execute_hands_the_materializer_its_spelling(adopter, tmp_path, + monkeypatch, capsys): + """The refusal `execute` prints for a link no shape file can be written + through names its target as `check` would: on one line, the line break + and the ESC spelled (#212), after the step #188 names. Printed raw, the + line break split the refusal in two.""" + said = refused_past_check(adopter, tmp_path, UNPRINTABLE_TARGET, + monkeypatch, capsys) + assert f"{SPLIT_STEP} {BLOCKED}: {SPELLED_REFUSAL}" in said.splitlines() + assert LEFT_BEHIND in said