diff --git a/tests/test_update_shape_add_not_a_directory.py b/tests/test_update_shape_add_not_a_directory.py new file mode 100644 index 00000000..8c8a761b --- /dev/null +++ b/tests/test_update_shape_add_not_a_directory.py @@ -0,0 +1,362 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`apply --add /` where the root holds something at `` that is +NOT A DIRECTORY, or a directory this process cannot write (#285). + +`find_additions` asked only whether something is AT the path, and a path under +a plain file is not there: `lexists` says no, because the kernel answers +`ENOTDIR`. So `check` offered it, with the `--add` to paste, and `apply --add` +took it. `_write_copies` rewrote every `upstream-changed` copy, then +`Rollback.write_added` died making the directory under the file +(`NotADirectoryError`), or creating the file in a directory it cannot write +(`PermissionError`). `cmd_apply` rolled back on `Refusal` and `CommandFailed` +only, so the run printed a traceback over a tree with the copies rewritten and +the pin unmoved, and the next `check` read that copy as `already-at-target`. + +Two changes, and these tests pin both: + + * (1) A path under something that is not a directory is refused BY NAME, + `update-add-not-a-directory`, before anything is written. `check` keeps + reporting the path, since the root holds nothing there, but no longer + prints an `--add` it knows will be refused, and neither does `apply`'s + note. Reverting it fails the plain-file and FIFO tests. + * (2) An `OSError` out of the write phase (the copies, the additions, the + pin and the manifest) is a refusal, `update-write-failed`, that names the + step, the path and the error, and `cmd_apply`'s refusal arm puts back + every byte written before it. Reverting it fails the read-only tests and + the arrives-while-`apply`-waits test. + * THE CONTROLS: nothing at `docs`, or `docs` a directory holding the + project's own file, still adds. + +THE FIXTURE, and what "untouched" means, are +`tests/test_update_shape_add_symlink.py`'s, imported rather than restated: a +clone of this repository at A, `Atlas` cut from it, and a commit B that fixes +`scripts/validate-manifest.py`, a pinned copy (so any run that writes, writes +that first), and adds `docs/shape-guide.md`, in a directory the root does not +have, and `AGENTS-link-test.md` at the top level. +""" + +from __future__ import annotations + +import errno +import os +import sys +from pathlib import Path + +import pytest + +from conftest import REPO, WINDOWS_SKIP +from test_update_shape import CHANGED +from test_update_shape_add import commit_all, validators_are_green, verdicts +#: The fixtures are IMPORTED, which is how pytest finds them for this module +#: too: one scaffold, built the one way, rather than a second copy of it. +from test_update_shape_add_symlink import ( # noqa: F401 (fixtures) + ADDED, BODIES, DIR, DIR_ADDED, ENV, apply, assert_untouched, check, + pin_rows, root, snapshot, standard, update_shape) + +sys.path.insert(0, str(REPO / "scripts")) +from repo_shape import load_yaml # noqa: E402 + +#: A directory's mode stops a write only for an account the kernel holds to +#: it: root writes anyway, and Windows ignores the bits on a directory. +POSIX_UNPRIVILEGED = pytest.mark.skipif( + os.name == "nt" or getattr(os, "geteuid", lambda: -1)() == 0, + reason="a read-only directory or file stops a write only for an " + "unprivileged POSIX account; root and Windows write anyway") + +#: What the operator keeps at `docs`. +THEIRS = b"the operator's own notes, kept in a file named docs\n" + + +def pinned_commit(root: Path) -> str: + return str(load_yaml(root / "contracts" / "shape-pin.yaml")["commit"]) + + +# --------------------------------------------------------------------------- +# The walk +# --------------------------------------------------------------------------- + + +def test_the_walk_names_the_first_directory_the_root_holds_as_something_else( + update_shape, tmp_path): + """`lstat`, one component ABOVE the path at a time. What is AT the path + is `present`'s question, not this one's, and a component that is missing + ends the walk with no answer: nothing below it can be there either.""" + root = tmp_path / "Atlas" + (root / "plain").mkdir(parents=True) + (root / "a").mkdir() + (root / "a" / "b").write_bytes(THEIRS) + (root / DIR).write_bytes(THEIRS) + + walk = update_shape.not_a_directory_on_the_way + assert walk(root, DIR_ADDED) == DIR + assert walk(root, "a/b/c/d.md") == "a/b", "the FIRST one, by its path" + assert walk(root, "plain/x.md") is None + assert walk(root, "missing/x.md") is None + assert walk(root, ADDED) is None + assert walk(root, DIR) is None, "the path itself is `present`'s question" + + +@WINDOWS_SKIP +def test_the_walk_leaves_a_link_to_the_link_walk(update_shape, tmp_path): + """A symlink above the path is `update-add-symlink`'s, asked first: this + walk stops there with no answer of its own, rather than reading through + the link to whatever it leads to.""" + root = tmp_path / "Atlas" + root.mkdir() + (tmp_path / "elsewhere").write_bytes(THEIRS) + (root / DIR).symlink_to(tmp_path / "elsewhere") + + assert update_shape.not_a_directory_on_the_way(root, DIR_ADDED) is None + assert update_shape.symlink_on_the_way(root, DIR_ADDED) == ( + DIR, str(tmp_path / "elsewhere")) + + +# --------------------------------------------------------------------------- +# (1) A path under something that is not a directory +# --------------------------------------------------------------------------- + + +def a_committed_file(root: Path) -> None: + """The issue's: a plain file the project keeps, committed.""" + (root / DIR).write_bytes(THEIRS) + commit_all(root, "a file named docs", [DIR]) + + +def an_untracked_file(root: Path) -> None: + (root / DIR).write_bytes(THEIRS) + + +def a_fifo(root: Path) -> None: + if not hasattr(os, "mkfifo"): + pytest.skip("no FIFOs on this platform") + os.mkfifo(root / DIR) + + +NOT_DIRECTORIES = [ + pytest.param(a_committed_file, id="a-committed-file"), + pytest.param(an_untracked_file, id="an-untracked-file"), + pytest.param(a_fifo, id="a-fifo"), +] + + +def test_check_reports_a_path_under_a_file_without_an_add_for_it( + root, standard): + """The root holds nothing AT `docs/shape-guide.md`, so the path is still + `upstream-added` and still reported. What `check` stops doing is print + the `--add` for it: `apply` refuses that, and a line to paste that is + known to fail is the tool handing a human a wrong answer. The addition + nothing is in the way of keeps its `--add`.""" + an_untracked_file(root) + before = snapshot(root) + + result = check(root, standard) + assert_untouched(before, snapshot(root)) + assert result.returncode == 1, result.stdout + result.stderr + assert "Traceback" not in result.stderr, result.stderr + assert verdicts(result.stdout).get(DIR_ADDED) == "upstream-added", ( + result.stdout) + assert f"--add {DIR_ADDED}" not in result.stdout, ( + "`check` offers an --add it knows `apply` refuses:\n" + result.stdout) + assert f"{DIR} in this root is not a directory" in result.stdout, ( + "the row says why there is no --add for it") + assert f"--add {ADDED}" in result.stdout, ( + "the addition nothing is in the way of is still offered") + + +@pytest.mark.parametrize("make", NOT_DIRECTORIES) +def test_add_refuses_a_path_under_something_that_is_not_a_directory( + root, standard, make): + """(a), THE ISSUE: refused by name, exit 2, no traceback, before the + first write, so there is nothing to roll back. The pinned copy B fixed + is as it was, the pin still names A, and the operator's `docs` holds + what it held.""" + make(root) + pinned = pinned_commit(root) + before = snapshot(root) + + result = apply(root, standard, "--add", DIR_ADDED) + # THE INVARIANT FIRST: on the base the copy was rewritten and left. + assert_untouched(before, snapshot(root)) + assert pinned_commit(root) == pinned + assert DIR_ADDED not in pin_rows(root) + assert result.returncode == 2, result.stdout + result.stderr + assert "Traceback" not in result.stderr, result.stderr + assert "REFUSED update-add-not-a-directory" in result.stderr, ( + result.stderr) + assert f"--add {DIR_ADDED}" in result.stderr + assert f"{DIR} in this root is not a directory" in result.stderr, ( + "the refusal names what is in the way, not only the path") + assert f"move {DIR} out of the way" in result.stderr, ( + "and what the operator does about it") + assert "the same --add" in result.stderr + assert "ROLLED BACK" not in result.stderr, ( + "nothing was written, so nothing is rolled back") + assert f"copied {CHANGED}" not in result.stdout + + +def test_one_blocked_addition_among_several_refuses_them_all(root, standard): + """ALL OR NOTHING, as for a link: the addition that would have been fine + is named FIRST, and is not written either.""" + an_untracked_file(root) + before = snapshot(root) + + result = apply(root, standard, "--add", ADDED, "--add", DIR_ADDED) + assert_untouched(before, snapshot(root)) + assert not (root / ADDED).exists() + assert result.returncode == 2, result.stdout + result.stderr + assert "REFUSED update-add-not-a-directory" in result.stderr, ( + result.stderr) + + +def test_apply_does_not_tell_the_operator_to_pass_an_add_it_refuses( + root, standard): + """`apply`'s note for an addition it was not asked to take said `pass + --add `: for this one that is the line it refuses. The run itself, + taking the other addition, goes ahead.""" + an_untracked_file(root) + + result = apply(root, standard, "--add", ADDED) + assert result.returncode == 0, result.stdout + result.stderr + assert (root / ADDED).read_bytes() == BODIES[ADDED] + assert (root / DIR).read_bytes() == THEIRS + notes = [line for line in result.stdout.splitlines() + if line.strip().startswith(f"note {DIR_ADDED}")] + assert len(notes) == 1, result.stdout + assert f"pass --add {DIR_ADDED}" not in notes[0], notes[0] + assert f"{DIR} in this root is not a directory" in notes[0], notes[0] + validators_are_green(root) + + +# --------------------------------------------------------------------------- +# (2) A write that fails after the copies +# --------------------------------------------------------------------------- + + +def an_unwritable_directory(root: Path): + """(b), the issue's: `docs/` is there, empty, and this process cannot + create a file in it. The walk has nothing to say about a directory, so + the write is where it fails, after the copy.""" + (root / DIR).mkdir() + (root / DIR).chmod(0o555) + return (root / DIR, "writing the copies and the --add files", DIR_ADDED, + []) + + +def an_unwritable_pin(root: Path): + """The re-pin's half: `contracts/shape-pin.yaml` read-only. The copy AND + the addition, with the directory made for it, are written before it, and + all of them have to go.""" + pin = root / "contracts" / "shape-pin.yaml" + pin.chmod(0o444) + return (pin, "re-pinning contracts/shape-pin.yaml and project.yaml", + "contracts/shape-pin.yaml", + [f"removed {DIR_ADDED} (written by --add)", + f"removed {DIR}/ (made to hold an --add file)"]) + + +@POSIX_UNPRIVILEGED +@pytest.mark.parametrize("make", [ + pytest.param(an_unwritable_directory, id="an-unwritable-directory"), + pytest.param(an_unwritable_pin, id="an-unwritable-pin"), +]) +def test_a_write_that_fails_after_the_copies_is_refused_and_rolled_back( + root, standard, make): + """(b): exit 2 with a refusal that names the step, the path and the + error, `ROLLED BACK`, and the tree as it was. On the base this was a + `PermissionError` traceback with the copy left rewritten.""" + stuck, step, where, removed = make(root) + pinned = pinned_commit(root) + before = snapshot(root) + try: + result = apply(root, standard, "--add", DIR_ADDED) + after = snapshot(root) + finally: + stuck.chmod(0o755 if stuck.is_dir() else 0o644) + assert_untouched(before, after) + assert pinned_commit(root) == pinned + assert result.returncode == 2, result.stdout + result.stderr + assert "Traceback" not in result.stderr, result.stderr + assert (f"REFUSED update-write-failed: {step} failed at {where}: " + f"{os.strerror(errno.EACCES)}") in result.stderr, result.stderr + assert "ROLLED BACK: every byte" in result.stderr, result.stderr + assert f"restored {CHANGED}" in result.stderr + for line in removed: + assert line in result.stderr, result.stderr + assert "Remediation:" in result.stderr + + +def test_a_file_that_arrives_where_a_directory_goes_is_refused_and_rolled_back( + update_shape, root, standard, monkeypatch): + """THE SAME WRITE, REACHED THE OTHER WAY. `prepare` found nothing at + `docs`, so (1) has nothing to refuse; the operator's file arrives while an + interactive `apply` waits in `confirm`. The pinned copy is written, then + the addition meets the file where its directory goes. On the base that + was a `NotADirectoryError` out of `cmd_apply` with the copy left + rewritten; now the refusal arm puts it back and says so.""" + seen: dict = {} + + def a_file_arrives(*_args, **_kwargs) -> None: + (root / DIR).write_bytes(THEIRS) + seen["root"] = snapshot(root) + + monkeypatch.setattr(update_shape, "confirm", a_file_arrives) + for key, value in ENV.items(): + monkeypatch.setenv(key, value) + args = update_shape.build_parser().parse_args([ + "apply", "--root", str(root), "--yes", + "--upstream", str(standard["upstream"]), "--at", standard["b"], + "--add", DIR_ADDED]) + + with pytest.raises(update_shape.Refusal) as refused: + update_shape.cmd_apply(args) + assert_untouched(seen["root"], snapshot(root)) + assert (root / DIR).read_bytes() == THEIRS + assert DIR_ADDED not in pin_rows(root) + assert refused.value.code == "update-write-failed", refused.value + assert refused.value.detail.startswith( + "writing the copies and the --add files failed at "), ( + refused.value.detail) + assert DIR_ADDED in refused.value.detail.splitlines()[0] + assert "ROLLED BACK: every byte" in refused.value.detail, ( + refused.value.detail) + assert f"restored {CHANGED}" in refused.value.detail + + +# --------------------------------------------------------------------------- +# (c) The controls +# --------------------------------------------------------------------------- + + +def nothing_at_docs(root: Path) -> dict: + return {} + + +def a_directory_of_the_projects(root: Path) -> dict: + """`docs/` exists and holds the project's own file: a directory, so the + walk has nothing to say, and the addition lands beside that file.""" + (root / DIR).mkdir() + (root / DIR / "notes.md").write_bytes(THEIRS) + commit_all(root, "the project's docs", [f"{DIR}/notes.md"]) + return {f"{DIR}/notes.md": THEIRS} + + +@pytest.mark.parametrize("make", [ + pytest.param(nothing_at_docs, id="nothing-at-docs"), + pytest.param(a_directory_of_the_projects, id="a-directory-of-the-projects"), +]) +def test_a_path_under_a_directory_or_under_nothing_still_adds( + root, standard, make): + """(c): `check` offers the `--add`, and `apply` takes it, writes the + standard's bytes, appends the row and stays green; the project's own + file beside it is as it was.""" + kept = make(root) + + checked = check(root, standard) + assert f"--add {DIR_ADDED}" in checked.stdout, checked.stdout + result = apply(root, standard, "--add", DIR_ADDED) + assert result.returncode == 0, result.stdout + result.stderr + assert (root / DIR_ADDED).read_bytes() == BODIES[DIR_ADDED] + assert DIR_ADDED in pin_rows(root) + for rel, body in kept.items(): + assert (root / rel).read_bytes() == body + validators_are_green(root) diff --git a/update-shape.py b/update-shape.py index 8d5f748c..afb755e2 100755 --- a/update-shape.py +++ b/update-shape.py @@ -48,6 +48,9 @@ * Nothing is left red: the root's OWN `validate-pins.py` and `validate-manifest.py` run after the rewrite, and every byte is rolled back if either refuses. + * A write that fails is a refusal, not a traceback (#285): a copy, an + addition or the pin this process cannot write is `update-write-failed`, + and every byte written before it is put back, as below. * Nothing is left half-done either (#202). With `--branch`, a branch git cannot create, an addition it cannot stage or a commit a hook refuses puts back the copies, the pin, the README line, the index and the @@ -98,6 +101,7 @@ import argparse import ast +import contextlib import hashlib import json import os @@ -108,6 +112,7 @@ import subprocess import sys import tempfile +from collections.abc import Iterator from pathlib import Path SHAPE_ROOT = Path(__file__).resolve().parent @@ -760,20 +765,33 @@ class Addition(Row): Windows junction) on the path's way down from the root — the path itself or a directory above it — and where it points. A write at such a path goes THROUGH the link, to wherever it leads, outside the root included. + + AND A DIRECTORY THE ROOT HOLDS AS SOMETHING ELSE IS IN THE WAY (#285). + `blocked` names the first directory above the path that the root keeps as + a plain file, a FIFO, anything but a directory. The path is NOT present — + nothing can be under a file, and `lexists` says so — so it is still + reported; but `--add` cannot write it without moving the operator's file, + which it never does, so `check` says why instead of offering the flag. """ def __init__(self, copy: Copy, present: bool, target_bytes: bytes, - link: tuple[str, str] | None = None): + link: tuple[str, str] | None = None, + blocked: str | None = None): super().__init__(copy.path, "") self.source = copy.source self.executable = copy.executable self.present = present self.link = link + self.blocked = blocked self.target_bytes = target_bytes self.digest = file_bytes_sha256(target_bytes) self.state = UPSTREAM_ADDED self.detail = ("the standard added this file after this pin; `apply` " "writes it only when --add names that path") + if blocked is not None: + self.detail = ("the standard added this file after this pin, but " + f"{blocked} in this root is not a directory, so " + f"--add refuses it until {blocked} is moved") #: An NTFS junction's reparse tag. `stat` names IO_REPARSE_TAG_MOUNT_POINT on @@ -827,6 +845,35 @@ def symlink_on_the_way(root: Path, rel: str) -> tuple[str, str] | None: return None +def not_a_directory_on_the_way(root: Path, rel: str) -> str | None: + """The first directory `rel` needs that the root holds as something + ELSE — a plain file, a FIFO — as its path inside the root; None when + every one there is a directory (#285). + + `symlink_on_the_way`'s walk, over the components ABOVE `rel` only: what + is AT `rel` is `present`'s question. A file at `docs` makes + `docs/shape-guide.md` absent, since the kernel answers `ENOTDIR`, so + the path was offered and `--add` taken, and `Rollback.write_added` then + died making `docs/` after the copies were written. A link ends the walk + with no answer, because it is `symlink_on_the_way`'s and refused first; + so does a component that cannot be `lstat`ed, as there. + """ + here = root + walked: list[str] = [] + for part in Path(rel).parts[:-1]: + here = here / part + walked.append(part) + try: + info = os.lstat(here) + except (OSError, ValueError): + return None + if is_link_info(info): + return None + if not stat.S_ISDIR(info.st_mode): + return "/".join(walked) + return None + + def find_additions(root: Path, rows: list[Row], upstream: Upstream, target: str, kind: Kind) -> list[Addition]: """Everything the upstream's lists name at `target` that the pin does not. @@ -839,6 +886,7 @@ def find_additions(root: Path, rows: list[Row], upstream: Upstream, A PATH THE ROOT HOLDS ANYTHING AT IS PRESENT (#239): `lexists`, not `exists`, so a symlink whose target is missing, and a symlink above the path, are the operator's and not an absence this tool may write into. + A path under a FILE is absent, and is remembered as `blocked` (#285). """ pinned = {row.path for row in rows} additions: list[Addition] = [] @@ -854,7 +902,7 @@ def find_additions(root: Path, rows: list[Row], upstream: Upstream, link = symlink_on_the_way(root, copy.path) additions.append(Addition( copy, link is not None or os.path.lexists(root / copy.path), - data, link)) + data, link, not_a_directory_on_the_way(root, copy.path))) return additions @@ -904,6 +952,12 @@ def resolve_additions(rows: list[Row], additions: list[Addition], is not a file to merge — a dangling one holds nothing — and a write there goes through the link to wherever it points, so the remedy is the operator's own link, not a merge. + + A FIFTH, ASKED NEXT (#285): a directory the path needs that the root + holds as a file or a FIFO (`Addition.blocked`). Nothing is at the path, + so it is not a merge either, and the write would fail making the + directory — after the copies — so it is refused here, before the first + byte, by the name of what is in the way. """ by_path = {add.path: add for add in additions} pinned = {row.path for row in rows} @@ -930,6 +984,21 @@ def resolve_additions(rows: list[Row], additions: list[Addition], "commit.") if add.link is not None: raise link_refusal(path, add.link) + if add.blocked is not None: + raise Refusal( + "update-add-not-a-directory", + f"--add {path} names a file the upstream's lists add, but " + f"{add.blocked} in this root is not a directory: the file " + f"goes inside {add.blocked}, and the root keeps something " + "else under that name. `--add` never moves or replaces what " + "a root holds", + f"Remediation: {add.blocked} is the project's. To take the " + f"standard's file, move {add.blocked} out of the way and run " + "this command again with the same --add: with nothing at " + f"{add.blocked}, the directory is made and the file written " + f"there, as for any addition. To keep {add.blocked} as it " + f"is, leave the flag off; `check` goes on listing {path}, " + "with no --add for it.") if add.present: raise Refusal( "update-add-would-overwrite", @@ -1578,7 +1647,11 @@ def cmd_check(args) -> int: if conflicts: print("REFUSAL AHEAD: `apply` will refuse while these are " "unresolved — " + ", ".join(row.path for row in conflicts)) - if added: + # NOT AN --add `apply` WOULD REFUSE (#285): a path under a file the + # root keeps is reported above, with why, but a flag to paste that + # is known to fail is the tool handing a human a wrong answer. + offered = [add for add in added if add.blocked is None] + if offered: # Spelled out, but deliberately NOT folded into the NEXT command: # a file the standard has added lands in a project because a human # named it, and a command line that names it for them is the tool @@ -1586,7 +1659,7 @@ def cmd_check(args) -> int: print("ADDED UPSTREAM: the standard has gained these since this " "pin, and `apply` writes none of them unless it is told to, " "one path at a time — " - + " ".join(f"--add {add.path}" for add in added)) + + " ".join(f"--add {add.path}" for add in offered)) accept = "".join(f" --accept-local {row.path}" for row in local) # THE LANE, WHEN THE ENVIRONMENT NAMES ONE (2026-09-11, #111). A run # that pastes this line then lands a commit carrying the `Lane:` @@ -2037,6 +2110,39 @@ def _taken(self, path: Path) -> Refusal: "stops reporting it; if it is not, remove it and run this command " "again with the same --add.") + @contextlib.contextmanager + def writing(self, step: str) -> Iterator[None]: + """`step`, a phase that writes bytes through this ledger, with an + `OSError` out of it turned into a refusal that names the step, the + path and the error (#285). + + A WRITE THAT FAILS IS A REFUSAL, NEVER A TRACEBACK, for the reason + `_rewrite_readme` gives for its own: `cmd_apply` rolls back on + `Refusal` and `CommandFailed` and on nothing else. A directory this + process cannot write, a file where `--add` has to make a directory, + a read-only pin, a disk that filled — each escaped as a traceback + with every copy written before it left in place and the pin + unmoved, so the next `check` read those copies as + `already-at-target`. Named as a refusal, the arm that exists puts + every byte back, and lists what it put back under this detail + (`_rolled_back`), so the detail does not claim it as well. + + `step` is the refusal's own, not `self.step`, which names the git + act a `CommandFailed` is reported under. + """ + try: + yield + except OSError as exc: + name = exc.filename + at = (f" at {self._name(Path(os.fsdecode(name)))}" + if isinstance(name, (str, bytes, os.PathLike)) else "") + raise Refusal( + "update-write-failed", + f"{step} failed{at}: {exc.strerror or exc}", + "Remediation: clear what stopped the write — a permission, " + "a read-only or full disk, a file where a directory has to " + "be — and run the same command again.") from exc + def new_branch(self, branch: str) -> None: """`git checkout -b `, remembered so `undo` can take it back. @@ -2414,12 +2520,15 @@ def _note_untaken_additions(additions: list[Addition], is the report rather than the act: a run that then refuses has still told the human which files the standard has gained and what to pass to take one. An addition the root already HAS a file at is silent here for the - reason `Addition` gives. + reason `Addition` gives, and one under a file the root keeps says what + is in the way rather than name an `--add` that is refused (#285). """ for add in additions: if not add.present and add not in taking: - print(f" note {add.path} is new upstream; not added; " - f"pass --add {add.path}") + take = (f"pass --add {add.path}" if add.blocked is None else + f"{add.blocked} in this root is not a directory, so " + f"--add refuses it until {add.blocked} is moved") + print(f" note {add.path} is new upstream; not added; {take}") def _nothing_to_do(rows: list[Row], pinned: str, target: str, @@ -2626,8 +2735,11 @@ def cmd_apply(args) -> int: # through it, so a classification taken after `_write_copies` would # be made under the standard's NEW attributes (#158). readme = _read_readme(root, upstream, target) - copied = _write_copies(root, rows, taking, ledger) - _repin(root, kind, ledger, rows, pinned, target, target_tree) + with ledger.writing("writing the copies and the --add files"): + copied = _write_copies(root, rows, taking, ledger) + with ledger.writing("re-pinning contracts/shape-pin.yaml and " + f"{kind.manifest}"): + _repin(root, kind, ledger, rows, pinned, target, target_tree) _rewrite_readme(readme, ledger) _refuse_red_validators(root, kind) added = [add.path for add in taking]