diff --git a/adopt-project.py b/adopt-project.py index ffb4df0d..70f7e56e 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -9138,9 +9138,11 @@ def _root_attributes(source: Source, walks: list, disk: _KeptTree, """The `.gitattributes` the split puts at the root, and under `shape/`, as `{path: bytes}`. Where the root keeps none, the shape's own. Where it keeps one, the shape's goes beside it and the split appends the block - for the copies `names` outside `shape/` (#471); a kept one that is not a - regular file gets no block, so it is asked as the block alone, the lines - #471's follow-up asks a regular file in its place to hold.""" + for the copies `names` outside `shape/` (#471), each named where `walks` + land it, the path git tracks, and by its own name where that differs + (#807); a kept one that is not a regular file gets no block, so it is + asked as the block alone, the lines #471's follow-up asks a regular file + in its place to hold.""" shapes = (SPLIT_TEMPLATE / ATTRIBUTES).read_text( encoding="utf-8").encode("utf-8") walk = next((walk for walk in walks if walk.path == ATTRIBUTES), None) @@ -9150,8 +9152,8 @@ def _root_attributes(source: Source, walks: list, disk: _KeptTree, blobs = {path: oid for path, _, oid, _ in tree} own = (_blob_bytes(source.path, blobs[row[0]]) if row and row[1] in FILE_MODES else b"") - return {ATTRIBUTES: with_eol_block(own, eol_block_paths(names, - COLLISION_DIR)), + return {ATTRIBUTES: with_eol_block(own, eol_block_paths( + names, COLLISION_DIR, dict(_pinned_landings(walks)))), f"{COLLISION_DIR}/{ATTRIBUTES}": shapes} @@ -9730,12 +9732,14 @@ def _rules_reaching(asked: _SplitRehearsal, source: Source, tree: list, def _as_the_split_writes(asked: _SplitRehearsal, rules: list, kept: set[str], own: str | None, written: dict, - names: list[str]) -> None: + landings: list[tuple[str, str]]) -> None: """The root's rules as the split's `git add` reads them: the moved `.gitattributes` gone; the root's own followed by #471's block, or the shape's own where the root keeps none; and the shape's beside it, where the root keeps nothing there (`plan-root-blocks-shape-copy` names what - it keeps, #262).""" + it keeps, #262). The block names each pinned copy of `landings` + (`_pinned_landings`') where it lands, the path git tracks, and by its own + name where that differs (#807).""" for path, _, _ in rules: if path not in kept: asked.work.joinpath(*path.split("/")).unlink(missing_ok=True) @@ -9746,7 +9750,8 @@ def _as_the_split_writes(asked: _SplitRehearsal, rules: list, kept: set[str], if own is not None: asked.place(ATTRIBUTES, with_eol_block( (asked.work / ATTRIBUTES).read_bytes(), - eol_block_paths(names, COLLISION_DIR))) + eol_block_paths([name for name, _ in landings], COLLISION_DIR, + dict(landings)))) if beside not in kept: asked.place(beside, written[beside]) @@ -9926,7 +9931,7 @@ def _reencoded_files_findings(plan: Plan, source: Source, tree: list, ["diff-files", "--name-only", "-z"], TAKEN) if path in moved] # The split's side: its `git add`, over the root it writes. try: - _as_the_split_writes(asked, rules, kept, own, written, names) + _as_the_split_writes(asked, rules, kept, own, written, landings) except OSError: return [_moved_finding(taken)] if taken else [] held = {*[row for row in wanted if row[0] in kept], @@ -11989,10 +11994,9 @@ def _commit_the_split(plan: Plan, source: Source, assembly: Path, print(f" beside {actual} (the source already has {intended}; " "nothing was overwritten)") if materialized.eol_block: - print(f" appended to {ATTRIBUTES}: a `text eol=lf` line for " - f"each of {len(materialized.eol_block)} pinned copies, so " - "every checkout keeps their bytes (#471); none of its own " - "lines was changed") + print(f" appended to {ATTRIBUTES}: " + f"{_appended_lines(materialized)}, so every checkout keeps " + "their bytes (#471); none of its own lines was changed") follow_ups = _plan_follow_ups(plan) message = _split_message(names, paths_for, leg_commits, spec_path, @@ -12582,6 +12586,20 @@ def _accounted_for(source: Source, assembly: Path, work_root: Path, names, return findings + root_findings +def _appended_lines(materialized: Materialized) -> str: + """What `execute` says it appended to the source's `.gitattributes`: a + line for each pinned copy outside `shape/`, and the second line each + copy written through a symlink has under its pinned path (#807).""" + copies = len(eol_block_paths(materialized.shape_files, COLLISION_DIR)) + said = f"a `text eol=lf` line for each of {copies} pinned copies" + second = len(materialized.eol_block) - copies + if second: + said += (f", and {second} more under the pinned path of each copy " + "written through a symlink, which governs it once the link " + "is replaced") + return said + + def _land_the_eol_block(assembly: Path, split_commit: str, tree: list, before: dict, after: dict) -> int: """Count the source's `.gitattributes` as landed in the root when the @@ -12606,7 +12624,10 @@ def _land_the_eol_block(assembly: Path, split_commit: str, tree: list, a lookup among the tree's plain files found none, so the appended file was `adopt-lost` where main verified it (#471's review, SHOULD-3). A pin neither can read counts nothing here, and `_shape_file_findings` reports - it. + it. Each copy the block names is named where the split's symlinks lead + its pinned path (`_SplitTree.tracked_at`), the path git tracks, and then + by that pinned path where it differs, as the materializer named it + (#807). """ source_oid = before.get(ATTRIBUTES) landed = after.get(ATTRIBUTES, []) @@ -12615,11 +12636,13 @@ def _land_the_eol_block(assembly: Path, split_commit: str, tree: list, or root[0] == f"root:{source_oid}": return 0 blobs = {path: oid for path, mode, oid, _ in tree if mode in FILE_MODES} - pin = _pin_through_links(_SplitTree(assembly, split_commit)) + split = _SplitTree(assembly, split_commit) + pin = _pin_through_links(split) pinned = _pinned_shape_paths(assembly, pin) if pin else None if pinned is None or blobs.get(ATTRIBUTES) != root[0].split(":", 1)[1]: return 0 - paths = eol_block_paths(pinned, COLLISION_DIR) + paths = eol_block_paths(pinned, COLLISION_DIR, + {path: split.tracked_at(path) for path in pinned}) expected = with_eol_block(_blob_bytes(assembly, source_oid), paths) if _blob_bytes(assembly, blobs[ATTRIBUTES]) != expected: return 0 @@ -13031,6 +13054,15 @@ def blob_at(self, path: str) -> str: f"is not in the split commit {self.commit}") return oid + def tracked_at(self, path: str) -> str | None: + """Where the split tracks what `path` leads to, each symlink on the + way followed (#807); None where the way leads out of the tree, above + it, round a loop, or through what is no directory in it.""" + try: + return self._resolve(path) + except _NotInTheSplitError: + return None + def _resolve(self, path: str) -> str: """`path` with each symlink on the way followed, as `open()` does.""" parts, done, via, hops = self._parts(path, ""), [], "", 0 diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py index 1171227d..1aed8ab7 100644 --- a/scripts/shape_materialize.py +++ b/scripts/shape_materialize.py @@ -705,6 +705,25 @@ def readme_trailing_shape_line(text: str) -> "re.Match | None": # block's. `adopt-project.py`'s verification counts the file as the source's # bytes followed by exactly this block, and `update-shape.py apply --add` # extends it when the standard adds a copy at the root later. +# +# EACH COPY IS NAMED WHERE GIT TRACKS IT (#807). A source may keep a +# symlinked directory in the root that the shape writes through (`.github -> +# ci`, a layout #504 accepts), and git tracks a copy written through it where +# the link leads: `ci/workflows/validate.yml`. A line naming only the link's +# spelling, `/.github/workflows/validate.yml`, governed nothing, so under the +# source's `* text=auto eol=crlf` a clone wrote that copy with CRLF, and +# `validate-pins.py` reported shape-copy-drift after `execute` had said +# `adoption verified`. So each copy is named where the symlinks inside the +# root lead it, a chain of them followed to its end, as `open()` followed +# them when the copy was written, and then, where that differs, by its pinned +# path on the next line. That second line governs nothing while the link is +# kept. It governs the copy once a person replaces the link with what it +# points at, which `update-shape.py` asks for before it rewrites a copy +# reached through one: without it that clone wrote the copy with CRLF, where +# main's had healed (#844's review). Where the links lead out of the root or +# cannot be followed, or to a name no line can spell literally, the copy has +# its pinned line alone, as before. The pin still names each copy by the path +# it was written at, which a clone opens through the same link. #: The `.gitattributes` a source may already hold at its root. ATTRIBUTES = ".gitattributes" @@ -734,12 +753,26 @@ def eol_line(path: str) -> str: return f"/{path} text eol=lf" -def eol_block_paths(shape_files, collision_dir: str) -> list[str]: - """The pinned copies a source's own root `.gitattributes` governs: each - one not under `collision_dir`, where the shape's own copy of the file - governs, in the pin's order.""" - return [path for path in shape_files - if not path.startswith(f"{collision_dir}/")] +def eol_block_paths(shape_files, collision_dir: str, + tracked: dict | None = None) -> list[str]: + """The paths the block names for the pinned copies a source's own root + `.gitattributes` governs: each one not under `collision_dir`, where the + shape's own copy of the file governs, in the pin's order. + + Each copy is named first as `tracked` maps its pinned path, where git + tracks it through the root's symlinks, then by its pinned path where + that differs (#807). One `tracked` maps to nothing, or to a name no line + can spell literally, is named by its pinned path alone.""" + tracked = tracked or {} + return list(dict.fromkeys( + name for path in shape_files + if not path.startswith(f"{collision_dir}/") + for name in (_tracked_or_pinned(path, tracked.get(path)), path))) + + +def _tracked_or_pinned(pinned: str, tracked: str | None) -> str: + """`tracked` where a block line can name it, else `pinned`.""" + return tracked if tracked and EOL_PATH_RE.fullmatch(tracked) else pinned def eol_block(paths) -> str: @@ -841,11 +874,25 @@ def _append_eol_block(target: Path, result: "Materialized", if path.is_symlink() or not path.is_file() \ or _tracked_as_link(target, ATTRIBUTES): return - paths = eol_block_paths(result.shape_files, collision_dir) + paths = eol_block_paths(result.shape_files, collision_dir, + {rel: _tracked_at(target, rel) + for rel in result.shape_files}) path.write_bytes(with_eol_block(path.read_bytes(), paths)) result.eol_block = paths +def _tracked_at(target: Path, rel: str) -> str | None: + """Where git tracks the file written at `rel` under `target`: `rel` with + each symlink on the way followed as `open()` followed them when it was + written (`_followed`), so `ci/workflows/validate.yml` for a copy written + through `.github -> ci` (#807). None where they lead out of `target` or + cannot be followed.""" + try: + return "/".join(_followed(target, rel, _ticked)) + except Refusal: + return None + + def collision_follow_up(intended: str, actual: str, beside: dict | None = None, *, attributes_link: bool = False) -> str: @@ -2722,8 +2769,9 @@ class Materialized: a refusal in `validate-pins.py`, and rightly. `eol_block` is the copies named in the block appended to the source's own - `.gitattributes` (#471), and empty when none was: no source file of that - name, one the plan did not keep in the root, or one that is a symlink. + `.gitattributes` (#471), each where git tracks it and, where that + differs, by its pinned path too (#807), and empty when none was: no source file of that name, one the plan did not + keep in the root, or one that is a symlink. That file is the source's, so it is in neither list above. """ diff --git a/tests/test_adopt_attributes_eol_block.py b/tests/test_adopt_attributes_eol_block.py index 79c609ef..be7d9797 100644 --- a/tests/test_adopt_attributes_eol_block.py +++ b/tests/test_adopt_attributes_eol_block.py @@ -20,6 +20,12 @@ network, so every CI job runs it. `tests/test_adopt_ignored_shape_files.py` adopts such a source end to end and clones it with a checkout that writes CRLF; `tests/test_update_shape_add.py` extends the block with `--add`. + +Each copy is named by the path git tracks: one written through a symlinked +directory the root keeps, `.github -> ci`, is named where the link leads, +then by its pinned path (#807), and +`tests/test_adopt_eol_block_through_links.py` adopts such a source end to +end. """ from __future__ import annotations @@ -56,6 +62,12 @@ "/.github/workflows/validate.yml text eol=lf\n").encode(UTF8) SHAPE_PIN = "contracts/shape-pin.yaml" TEMPLATE = REPO / "templates" / "assembly-root" +#: The shape's workflow, and where git tracks it when the root keeps +#: `.github -> ci`, or a chain of links that ends there (#807). +WORKFLOW = ".github/workflows/validate.yml" +TRACKED_WORKFLOW = "ci/workflows/validate.yml" +THROUGH_LINKS = [pytest.param({".github": "ci"}, id="a-link"), + pytest.param({".github": "l1", "l1": "ci"}, id="a-chain")] #: A symlink made ON THE DISK, as the other symlink tests of the adoption #: suites skip one on Windows: there a runner may make one only in Developer @@ -116,6 +128,27 @@ def test_copies_under_shape_are_left_to_the_shapes_own_file(): "scripts/bootstrap.py", "Makefile", "shape-notes/x.md"] +def test_each_copy_is_named_where_git_tracks_it(): + """A copy written through `.github -> ci` is tracked at `ci/`, where a + line naming only the link's spelling governs nothing; that spelling + follows, and governs the copy once the link is replaced with what it + points at (#807).""" + pinned = ["Makefile", WORKFLOW, f"{COLLISION_DIR}/{ATTRIBUTES}"] + tracked = {WORKFLOW: TRACKED_WORKFLOW, + f"{COLLISION_DIR}/{ATTRIBUTES}": ATTRIBUTES} + assert eol_block_paths(pinned, COLLISION_DIR, tracked) == [ + "Makefile", TRACKED_WORKFLOW, WORKFLOW] + + +@pytest.mark.parametrize("tracked", [ + {}, {WORKFLOW: None}, {WORKFLOW: "my ci/workflows/validate.yml"}], + ids=["not-mapped", "led-nowhere", "no-literal-name"]) +def test_a_copy_tracked_nowhere_nameable_keeps_its_pinned_path(tracked): + """Where the links lead out of the root or cannot be followed, or to a + name no line can spell, the pinned line alone is today's.""" + assert eol_block_paths([WORKFLOW], COLLISION_DIR, tracked) == [WORKFLOW] + + @pytest.mark.parametrize("path", ["notes and more.md", "scripts/*.py", "docs/[a].md", "dir\\file", "", "/Makefile", "a//b", "!x"]) @@ -249,6 +282,34 @@ def test_an_attributes_symlink_is_not_written_through(tmp_path): assert (target / "config" / "attributes").read_bytes() == OWN +@needs_disk_symlinks +@pytest.mark.parametrize("links", THROUGH_LINKS) +def test_a_copy_written_through_a_link_is_named_where_git_tracks_it(tmp_path, + links): + """The root keeps `.github -> ci`, or a chain to it: the shape writes its + workflow through the link, git tracks it at `ci/workflows/validate.yml`, + and that is its line, before the one for the path it was written at. The + pin's row keeps that path, which a clone opens through the same link + (#807).""" + target = target_holding(tmp_path, {ATTRIBUTES: OWN, + "ci/workflows/ci.yml": b"on: [push]\n"}) + for link, to in links.items(): + try: + os.symlink(to, target / link) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"this platform cannot create a symlink here: {exc}") + result = materialize_assembly_root(REPO, target, template_values(), + collision_dir=COLLISION_DIR) + assert WORKFLOW in result.shape_files + assert (target / TRACKED_WORKFLOW).is_file() + named = eol_block_paths(result.shape_files, COLLISION_DIR, + {WORKFLOW: TRACKED_WORKFLOW}) + assert result.eol_block == named + at = named.index(TRACKED_WORKFLOW) + assert named[at + 1] == WORKFLOW + assert (target / ATTRIBUTES).read_bytes() == with_eol_block(OWN, named) + + def index_only_link(repo: Path, link: str, target: str) -> None: """`link -> target` in git's INDEX only, mode 120000 with the target as its blob, as a tree records it: no symlink on the disk, so no privilege @@ -400,21 +461,25 @@ def commit_files(repo: Path, files: dict, message: str, def accounted(adopter, tmp_path: Path, attributes: bytes, - pin_path: str = SHAPE_PIN, links: dict | None = None) -> tuple: + pin_path: str = SHAPE_PIN, links: dict | None = None, + workflow: str | None = None) -> tuple: """A source commit holding `OWN`, and a split commit over it whose root `.gitattributes` is `attributes`: `(lines counted, findings, counts, the source's oid, after)` from the verification's own steps. `links` - go into the split's tree only (`index_only_link`).""" + go into the split's tree only (`index_only_link`). With `workflow`, the + pin names `WORKFLOW` too, and the split holds it at `workflow`.""" repo = tmp_path / "repo" repo.mkdir() git("init", "-q", "-b", "main", ".", cwd=repo) source = commit_files(repo, {ATTRIBUTES: OWN, "README.md": b"# Thing\n"}, "The source") + copies = {workflow: b"on: [push]\n"} if workflow else {} split = commit_files(repo, { - ATTRIBUTES: attributes, pin_path: pin_of(PINNED), + ATTRIBUTES: attributes, + pin_path: pin_of([*PINNED, WORKFLOW] if workflow else PINNED), f"{COLLISION_DIR}/{ATTRIBUTES}": (TEMPLATE / ATTRIBUTES).read_bytes(), - "Makefile": b"all:\n", "scripts/validate-pins.py": b"print()\n"}, - "The split", links=links) + "Makefile": b"all:\n", "scripts/validate-pins.py": b"print()\n", + **copies}, "The split", links=links) before = {path: oid for path, _, oid, _ in adopter._tree_of(repo, source)} tree = adopter._tree_of(repo, split) after: dict = {} @@ -457,6 +522,31 @@ def test_the_pin_is_read_through_a_link_the_root_kept(adopter, tmp_path): assert after[ATTRIBUTES] == [f"root:{oid}"] +@pytest.mark.parametrize("links", THROUGH_LINKS) +def test_a_copy_written_through_a_link_is_counted_where_git_tracks_it( + adopter, tmp_path, links): + """The split keeps `.github -> ci`, or a chain to it, and the workflow + at `ci/`: the block the materializer wrote names it there, then by its + pinned path, and the verification reads the same path through the + split's links (#807).""" + lines, findings, _, oid, after = accounted( + adopter, tmp_path, with_eol_block(OWN, [*ROOT_COPIES, + TRACKED_WORKFLOW, WORKFLOW]), + links=links, workflow=TRACKED_WORKFLOW) + assert (lines, findings) == (4, []) + assert after[ATTRIBUTES] == [f"root:{oid}"] + + +def test_a_copy_whose_link_leads_out_of_the_split_keeps_its_pinned_path( + adopter, tmp_path): + """A link out of the split tracks nothing there: the line is the one the + pin names, as before, and `_shape_file_findings` reports the copy.""" + lines, findings, _, _, _ = accounted( + adopter, tmp_path, with_eol_block(OWN, [*ROOT_COPIES, WORKFLOW]), + links={".github": "/etc"}, workflow=TRACKED_WORKFLOW) + assert (lines, findings) == (3, []) + + def test_the_sources_own_bytes_untouched_need_no_exception(adopter, tmp_path): """No block (no collision, or a symlink): the blob is the source's, and the accounting is the ordinary one.""" diff --git a/tests/test_adopt_eol_block_through_links.py b/tests/test_adopt_eol_block_through_links.py new file mode 100644 index 00000000..b04ad29f --- /dev/null +++ b/tests/test_adopt_eol_block_through_links.py @@ -0,0 +1,312 @@ +# SPDX-License-Identifier: Apache-2.0 +"""#471's line-ending block names each pinned copy by the path git tracks +(#807). + +A source may keep a symlinked directory in the root that the shape writes +through, `.github -> ci`, a layout #504 accepts. Git then tracks the shape's +workflow at `ci/workflows/validate.yml`, and the block's line named the +link's spelling, `/.github/workflows/validate.yml`, which governs nothing: +under the source's `* text=auto eol=crlf` a clone wrote that copy with CRLF, +and `validate-pins.py` exited 1 with `shape-copy-drift` after `execute` had +said `adoption verified`. + +Every place that writes or reads the block now names the tracked path, and +then the pinned one where they differ: the materializer's append and the +verification's count of the file (`tests/test_adopt_attributes_eol_block.py` +holds both on hand-made trees), and the root `check` asks git about, for a +nested `.gitattributes` and for what this machine's `git add` stores, both +here. The pinned line governs the copy once a person replaces the link with +what it points at, as `update-shape.py` asks: a fresh clone of that is LF +too, as on main (#844's review). A source with no symlinked directory gets +the same block, byte for byte, and one with `core.symlinks=false` is still +refused first. + +`update-shape.py` writes no line of the block through a link: `apply` +refuses a pinned copy reached through one (`update-rewrite-symlink`, #286) +and `--add` a path with one on its way (`update-add-symlink`, #239), so each +path it names is the path git tracks, and on such a clone it writes nothing. + +`check`'s tests put the links in the source's TREE only, which is all it +reads. The end-to-end tests make them on the disk, need `git filter-repo`, +and skip on Windows. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from conftest import (ADOPT, REPO, appended_to_command_scope, + cloned_just_pushed, git, make_source_repo, run_script, + write_plan) + +sys.path.insert(0, str(REPO / "scripts")) +from repo_shape import parse_yaml # noqa: E402 +from shape_materialize import eol_block # noqa: E402 + +UTF8 = "utf-8" +PROJECT = "Northwind" +BRANCH = "adopt/three-repo-shape" +ATTRIBUTES = ".gitattributes" +PIN = "contracts/shape-pin.yaml" +README = "README.md" +#: The pinned copy the shape writes through the link, and where git tracks it. +WORKFLOW = ".github/workflows/validate.yml" +TRACKED = "ci/workflows/validate.yml" +#: The source's own rule: every text file checks out with CRLF, everywhere. +CRLF_RULE = b"* text=auto eol=crlf\n" +#: `.github -> ci`, the chain `.github -> l1 -> ci`, and no link at all. +LINK = {".github": "ci"} +CHAIN = {".github": "l1", "l1": "ci"} +#: A source's files, with a `ci/` the links lead to. +SOURCE = {README: "# Thing\n", + "specs/001-feature/spec.md": "# The feature\n", + "src/app/main.py": "import contracts_reader\n", + "ci/workflows/ci.yml": "on: [push]\n"} +SYMLINK_MODE = "120000" + +needs_filter_repo = pytest.mark.skipif( + shutil.which("git-filter-repo") is None, + reason="git filter-repo is not installed: `pip install git-filter-repo`") + + +def committed(source: Path, message: str) -> None: + git("-c", "user.name=Source Human", "-c", + "user.email=source@invalid.example", "commit", "-qm", message, + cwd=source) + + +def source_keeping(base: Path, links: dict, files: dict, + on_disk: bool) -> Path: + """A source with `SOURCE` and `files` (bytes, committed as they are), + and `links`: real symlinks where `on_disk`, else in its tree alone.""" + source = make_source_repo(base / "Thing", tree=SOURCE, edits=()) + for name, data in files.items(): + (source / name).parent.mkdir(parents=True, exist_ok=True) + (source / name).write_bytes(data) + if on_disk: + for link, target in links.items(): + try: + os.symlink(target, source / link) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"this platform cannot create a symlink: {exc}") + git("add", "-A", "--", ".", cwd=source) + if not on_disk: + for link, target in links.items(): + blob = subprocess.run(["git", "hash-object", "-w", "--stdin"], + cwd=str(source), input=target.encode(UTF8), + capture_output=True, check=True) + git("update-index", "--add", "--cacheinfo", + f"{SYMLINK_MODE},{blob.stdout.decode(UTF8).strip()},{link}", + cwd=source) + committed(source, "Keep a link, and our own attributes") + return source + + +def planned(source: Path, base: Path) -> Path: + """The plan for `source`, every question answered `root`.""" + plan = base / "adoption-plan.yaml" + written = write_plan(source, plan, project=PROJECT) + assert written.returncode == 0, written.stderr + written.stdout + text = plan.read_text(encoding=UTF8) + with plan.open("w", encoding=UTF8, newline="\n") as handle: + handle.write(text.replace(" leg: null\n", + " leg: root\n resolution: \"kept\"\n")) + return plan + + +def check(plan: Path, env: dict | None = None): + return run_script(ADOPT, "check", "--plan", str(plan), env=env) + + +def execute(base: Path, plan: Path, env: dict | None = None): + return run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + "--local-remote-dir", str(base / "remotes"), + "--work-dir", str(base / "work"), env=env) + + +def finding_lines(result) -> list[str]: + return [line for line in result.stderr.splitlines() + if line.startswith("FINDING ")] + + +# --------------------------------------------------------------------------- +# `check` asks git about the root the split writes, with the tracked line +# --------------------------------------------------------------------------- + + +def test_check_finds_a_nested_file_that_outranks_the_tracked_line(tmp_path): + """`ci/.gitattributes`'s `*.yml eol=crlf` outranks the root's line for + the copy git tracks at `ci/workflows/validate.yml`, so a clone writes it + with CRLF on every platform, and `check` says so (#530's question). It + asked git with the link's line, which left the copy to `* text=auto` + already, so it found nothing to outrank, and `execute` went on.""" + source = source_keeping(tmp_path, LINK, { + ATTRIBUTES: b"* text=auto\n", + "ci/.gitattributes": b"*.yml eol=crlf\n"}, on_disk=False) + result = check(planned(source, tmp_path)) + assert result.returncode == 1, result.stdout + result.stderr + found = finding_lines(result) + assert len(found) == 1, found + assert found[0].startswith( + "FINDING plan-nested-attributes: the plan keeps `ci/.gitattributes` " + "in the assembly root"), found[0] + assert f"the pinned copy `{TRACKED}`" in found[0], found[0] + assert "`/workflows/validate.yml text eol=lf`" in found[0], found[0] + + +def test_check_asks_git_add_about_the_copy_where_git_tracks_it(tmp_path): + """Under `core.safecrlf=true` this machine's `git add` refuses an LF + file that `eol=crlf` reaches. The root's own rule says that of every + file, and the block's line takes the copy out of it: named by the link's + spelling, it did not, and `check` reported `plan-file-reencoded` for + `ci/workflows/validate.yml`, which the split's add of the copy would not + meet. The source's `.gitattributes` keeps itself LF, so it is no finding + of its own. (`execute` still stops later under this setting, at `git + submodule add` over `.gitmodules`, as main does for the same source with + no link.)""" + source = source_keeping(tmp_path, LINK, { + ATTRIBUTES: CRLF_RULE + b"/.gitattributes text eol=lf\n"}, + on_disk=False) + plan = planned(source, tmp_path) + result = check(plan, env=appended_to_command_scope( + os.environ, (("core.safecrlf", "true"),))) + assert finding_lines(result) == [] + assert result.returncode == 0, result.stdout + result.stderr + + +# --------------------------------------------------------------------------- +# End to end: `execute`, a clone, its pins, and `update-shape.py` +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module", params=[ + pytest.param(LINK, id="a-link"), pytest.param(CHAIN, id="a-chain"), + pytest.param({}, id="no-link")]) +def adopted(request, tmp_path_factory) -> SimpleNamespace: + """One real adoption of a source with `* text=auto eol=crlf` that keeps + `.github -> ci`, the chain `.github -> l1 -> ci`, or no link.""" + if sys.platform == "win32" or shutil.which("git-filter-repo") is None: + pytest.skip("needs git filter-repo and a disk that holds symlinks") + base = tmp_path_factory.mktemp("tracked-spelling") + source = source_keeping(base, request.param, {ATTRIBUTES: CRLF_RULE}, + on_disk=True) + plan = planned(source, base) + checked = check(plan) + assert checked.returncode == 0, checked.stderr + checked.stdout + return SimpleNamespace(source=source, links=request.param, + result=execute(base, plan)) + + +def pinned_rows(source: Path) -> list[str]: + pin = parse_yaml(git("show", f"{BRANCH}:{PIN}", cwd=source).stdout) + return [row["path"] for row in pin["files"]] + + +@needs_filter_repo +def test_execute_verifies_a_block_that_names_the_tracked_path(adopted): + """The source's bytes, then one line per pinned copy outside `shape/` + in the pin's order, the workflow named where git tracks it and then by + its pin row. With no link that is each pin row's own path: the block + main writes, byte for byte. The pin row keeps the path the copy was + written at.""" + result = adopted.result + assert result.returncode == 0, result.stderr + result.stdout + assert "adoption verified" in result.stdout + rows = pinned_rows(adopted.source) + assert WORKFLOW in rows + lines = [TRACKED, WORKFLOW] if adopted.links else [WORKFLOW] + named = [name for row in rows if not row.startswith("shape/") + for name in (lines if row == WORKFLOW else [row])] + shown = git("show", f"{BRANCH}:{ATTRIBUTES}", cwd=adopted.source) + assert shown.stdout == CRLF_RULE.decode(UTF8) + eol_block(named) + copies = sum(not row.startswith("shape/") for row in rows) + said = f"a `text eol=lf` line for each of {copies} pinned copies" + if adopted.links: + said += (", and 1 more under the pinned path of each copy written " + "through a symlink, which governs it once the link is " + "replaced") + assert f" appended to {ATTRIBUTES}: {said}, so every checkout keeps " \ + "their bytes (#471)" in result.stdout, result.stdout + + +@needs_filter_repo +def test_a_clone_checks_every_copy_out_as_pinned_and_its_pins_pass(adopted, + tmp_path): + """The source's `README.md` IS checked out with CRLF, so the rule + reached the checkout; every pinned copy is not, the workflow read + through the link included; and the clone's `validate-pins.py` exits 0, + where it exited 1 with `shape-copy-drift: .github/workflows/ + validate.yml`.""" + assert adopted.result.returncode == 0 + clone = cloned_just_pushed(adopted.source, tmp_path / PROJECT, BRANCH) + assert b"\r\n" in (clone / README).read_bytes(), "no CRLF: tests nothing" + crlf = [row for row in pinned_rows(adopted.source) + if b"\r\n" in (clone / row).read_bytes()] + assert crlf == [] + pins = run_script(clone / "scripts" / "validate-pins.py", cwd=clone) + assert pins.returncode == 0, pins.stdout + pins.stderr + + +@needs_filter_repo +def test_update_shape_writes_no_line_through_the_link(adopted, tmp_path): + """`apply` on such a clone refuses the copy reached through the link + before it writes anything, so the block keeps the line `execute` wrote, + and no line of the link's spelling is ever added to it.""" + if not adopted.links: + pytest.skip("only a root that keeps a link has one to write through") + clone = cloned_just_pushed(adopted.source, tmp_path / PROJECT, BRANCH) + before = (clone / ATTRIBUTES).read_bytes() + commit = parse_yaml((clone / PIN).read_text(encoding=UTF8))["commit"] + applied = run_script(REPO / "update-shape.py", "apply", "--root", + str(clone), "--upstream", str(REPO), "--at", + str(commit), "--yes") + assert applied.returncode != 0, applied.stdout + assert "update-rewrite-symlink" in applied.stderr, applied.stderr + assert (clone / ATTRIBUTES).read_bytes() == before + + +@needs_filter_repo +def test_the_clone_stays_lf_once_the_link_is_replaced(adopted, tmp_path): + """`update-shape.py` refuses to rewrite a copy reached through the link + until it is replaced with what it points at. A person who does that, + commits and clones afresh gets the workflow as a file at + `.github/workflows/validate.yml`, which the block's pinned line governs: + LF, and `validate-pins.py` exits 0, as on main (#844's review). Naming + the tracked path alone, the clone wrote it with CRLF and drifted.""" + if not adopted.links: + pytest.skip("only a root that keeps a link has one to replace") + clone = cloned_just_pushed(adopted.source, tmp_path / PROJECT, BRANCH) + for link in adopted.links: + git("rm", "-q", "--", link, cwd=clone) + git("mv", "--", "ci", ".github", cwd=clone) + committed(clone, "Replace the link with what it points at") + fresh = cloned_just_pushed(clone, tmp_path / "fresh") + assert b"\r\n" in (fresh / README).read_bytes(), "no CRLF: tests nothing" + assert b"\r\n" not in (fresh / WORKFLOW).read_bytes() + pins = run_script(fresh / "scripts" / "validate-pins.py", cwd=fresh) + assert pins.returncode == 0, pins.stdout + pins.stderr + + +@needs_filter_repo +def test_with_symlinks_off_execute_still_refuses_first(tmp_path): + """Where `git` checks a symlink out as a plain file, the assembly's + clone would hold `.github` as a file, and `execute` refuses before any + leg exists, as it did (#183).""" + if sys.platform == "win32": + pytest.skip("needs a disk that holds symlinks") + source = source_keeping(tmp_path, LINK, {ATTRIBUTES: CRLF_RULE}, + on_disk=True) + plan = planned(source, tmp_path) + result = execute(tmp_path, plan, env=appended_to_command_scope( + os.environ, (("core.symlinks", "false"),))) + assert result.returncode == 2, result.stdout + result.stderr + assert "REFUSED adopt-symlinks-off: the plan keeps `.github` in the " \ + "root, a symlink to `ci`" in result.stderr, result.stderr + assert not (tmp_path / "remotes").exists()