Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
566 changes: 450 additions & 116 deletions adopt-project.py

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,12 @@ options:
fragment it was ratified from for one dated earlier —
so --elected-on chooses it.
--tracking-branch TRACKING_BRANCH
the branch the assembly root and both legs track, and
the base of the split's pull request (default: the
source's default branch, which the plan records as
`source.default_branch`). `check` finds, and `execute`
refuses before any leg exists, one that is no branch
of the source (`plan-tracking-branch-missing`).
--spec-path SPEC_PATH
where the spec leg is mounted, as a path in the
assembly root (default: spec); the plan records it as
Expand Down
36 changes: 33 additions & 3 deletions scripts/shape_materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,15 +654,31 @@ def readme_trailing_shape_line(text: str) -> "re.Match | None":
#: agents ALREADY read, and telling a human to "merge" two agent instruction
#: files is how the project's own instructions get lost inside the shape's.
ASSISTANT_INSTRUCTIONS = ("AGENTS.md", "CLAUDE.md")
#: The make targets of the shape's own `Makefile` that a person runs. A root
#: that kept the source's `Makefile` has none of them until the shape's copy
#: is merged into it (#480), and the collision follow-up says so.
#: `tests/test_adopt_e2e.py` holds this to the template's own targets.
SHAPE_MAKE_TARGETS = ("bootstrap", "validate", "pins", "park", "resume")


def collision_follow_up(intended: str, actual: str) -> str:
def collision_follow_up(intended: str, actual: str,
beside: dict | None = None) -> str:
"""The follow-up for ONE collision, in one place.

Both callers say it: `adopt-project.py plan` PREDICTS the collisions from
the plan's surviving root paths, and `execute` reports the ones that
actually happened. Two spellings of the same instruction is how the plan a
human approves stops matching the commit message they read afterwards.

THE MAKEFILE'S MERGE IS WHAT BRINGS THE SHAPE'S MAKE TARGETS (#480). Until
it is made, `make bootstrap` in the root exits 2 with `No rule to make
target 'bootstrap'`, so the follow-up says that, and what runs meanwhile:
`python3 scripts/bootstrap.py`, unless that script went aside as well.
`beside` maps each shape file written beside one the source kept to where
it went, the whole list, and only the Makefile's follow-up reads it.
Where the root's `scripts/bootstrap.py` is the source's own, that command
runs the source's script and exits 0, so the follow-up names no command,
and says both merges come first (#493's review, F2).
"""
if intended in ASSISTANT_INSTRUCTIONS:
return (
Expand All @@ -672,10 +688,23 @@ def collision_follow_up(intended: str, actual: str) -> str:
"`contracts/path-classification.yaml` keeps it at the root "
"(`root-assistant-instructions`) because it addresses the whole "
f"project. Then delete {actual}. Nothing was overwritten.")
return (
merge = (
f"merge {actual} into {intended}: the source repository already has "
"that name, so the shape's copy was written beside it and NOTHING was "
"overwritten")
if intended != "Makefile":
return merge
targets = ", ".join(f"`{target}`" for target in SHAPE_MAKE_TARGETS)
script = (beside or {}).get(BOOTSTRAP)
if script is None:
return (f"{merge}. The shape's make targets ({targets}) arrive only "
"with that merge; until then `python3 scripts/bootstrap.py` "
"is what `make bootstrap` runs")
return (f"{merge}. The shape's make targets ({targets}) arrive only with "
f"that merge, and the root's {BOOTSTRAP} is the source's "
f"own until {script} is merged into it as well, so neither `make "
f"bootstrap` nor `python3 {BOOTSTRAP}` runs the shape's "
"bootstrap before both merges are made")


def copy_in_the_way(target: Path, actual: str) -> str | None:
Expand Down Expand Up @@ -1790,7 +1819,8 @@ def __init__(self) -> None:
self.shape_files: list[str] = []

def follow_ups(self) -> list[str]:
return [collision_follow_up(intended, actual)
beside = dict(self.collisions)
return [collision_follow_up(intended, actual, beside)
for intended, actual in self.collisions]


Expand Down
178 changes: 175 additions & 3 deletions tests/test_adopt_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@

from __future__ import annotations

import importlib.util
import re
import shutil
import subprocess
import sys

import pytest

from conftest import (ADOPT, FILE_PROTOCOL, REPO, git, resolve, run_script,
write_plan)
from conftest import (ADOPT, FILE_PROTOCOL, REPO, git, make_source_repo,
resolve, run_script, write_plan)

sys.path.insert(0, str(REPO / "scripts"))
from repo_shape import load_yaml # noqa: E402
Expand All @@ -39,7 +41,6 @@ def adopted(tmp_path_factory) -> dict:
"""One real adoption: plan, answer the questions, check, execute."""
base = tmp_path_factory.mktemp("adopt")
source = base / "Thing"
from conftest import make_source_repo
make_source_repo(source)
plan = base / "adoption-plan.yaml"
written = write_plan(source, plan, project=PROJECT)
Expand Down Expand Up @@ -215,6 +216,177 @@ def test_the_sources_own_agent_files_are_untouched(adopted, adopted_clone):
assert "AGENTS-shape.md" in rows


def next_block(stdout: str) -> list[str]:
"""The lines `execute` prints from `NEXT:` on, to the end of its run."""
return stdout.split("\nNEXT: ", 1)[1].splitlines()


def test_next_names_the_bootstrap_a_root_with_its_own_makefile_can_run(
adopted, adopted_clone):
"""The source kept its own Makefile, so the shape's is `shape/Makefile`
until a person merges the two, and `make bootstrap` in the root exits 2
(`No rule to make target 'bootstrap'`) (#480). The NEXT block names the
command `make bootstrap` runs instead, a line says why, and that command,
run in a recursive clone of the split, exits 0. (It is run with the
interpreter running this test, which is what `python3` names here.)"""
said = next_block(adopted["stdout"])
assert said[:3] == [f"review the pull request on {PROJECT}, then",
f" git clone --recurse-submodules <{PROJECT} url>",
" python3 scripts/bootstrap.py"], said
assert said[3].startswith("NOTE the root keeps its own Makefile and the "
"shape's is shape/Makefile, so `make bootstrap`")
command = said[2].split()
ran = subprocess.run([sys.executable, *command[1:]], cwd=adopted_clone,
capture_output=True, text=True, encoding="utf-8",
errors="replace", check=False)
assert ran.returncode == 0, ran.stdout + ran.stderr
if shutil.which("make"):
made = subprocess.run(["make", "bootstrap"], cwd=adopted_clone,
capture_output=True, text=True,
encoding="utf-8", errors="replace", check=False)
assert made.returncode != 0, made.stdout + made.stderr


def test_the_makefile_follow_up_says_the_shapes_targets_arrive_with_it(
adopted):
"""The follow-up the plan carries, and the split commit lists, names
each of the shape's make targets as arriving only with the merge, and
what runs meanwhile. The targets are read from the template, so a target
the shape gains and the follow-up does not name fails here."""
template = (REPO / "templates" / "assembly-root" / "Makefile").read_text(
encoding="utf-8")
targets = [name for name in re.findall(r"(?m)^([a-z][a-z-]*):", template)
if name != "help"]
assert "bootstrap" in targets, targets
message = git("log", "-1", "--format=%B", "adopt/three-repo-shape",
cwd=adopted["source"]).stdout
(planned,) = [item for item in load_yaml(adopted["plan"])["follow_ups"]
if item.startswith("merge shape/Makefile into Makefile")]
for text in (planned, " ".join(message.split())):
assert ("The shape's make targets ("
+ ", ".join(f"`{target}`" for target in targets)
+ ") arrive only with that merge; until then `python3 "
"scripts/bootstrap.py` is what `make bootstrap` runs") in text


@pytest.fixture(scope="module")
def adopter():
"""`adopt-project.py` as a module, for what it prints and predicts."""
module_spec = importlib.util.spec_from_file_location("adopt_e2e_next",
ADOPT)
module = importlib.util.module_from_spec(module_spec)
module_spec.loader.exec_module(module)
return module


def test_next_keeps_make_bootstrap_where_the_shapes_makefile_is_the_roots(
adopter, capsys):
"""Where the shape's Makefile took its own name, NEXT is what it always
was, byte for byte."""
adopter._next_steps({"assembly": PROJECT}, [])

assert capsys.readouterr().out == (
f"\nNEXT: review the pull request on {PROJECT}, then\n"
f" git clone --recurse-submodules <{PROJECT} url>\n"
" make bootstrap\n")


#: A source that keeps its own `scripts/bootstrap.py` in the root beside its
#: own Makefile. `scripts/` goes to the code leg unless the plan is answered
#: otherwise, as `kept_scripts` answers it.
OWN_BOOTSTRAP_TREE = {
"README.md": "# Northwind\n", "src/app.py": "print(1)\n",
"docs/spec.md": "# Spec\n", "Makefile": "build:\n\t@echo source build\n",
"scripts/bootstrap.py": "print('THE SOURCE OWN BOOTSTRAP')\n"}
#: What NEXT says in that root, in place of a bootstrap command.
BOTH_ASIDE = (
"NOTE the root keeps its own Makefile and scripts/bootstrap.py, and the "
"shape's are shape/Makefile and shape/scripts/bootstrap.py, so neither "
"`make bootstrap` nor `python3 scripts/bootstrap.py` runs the shape's "
"bootstrap yet: merge shape/Makefile into Makefile and "
"shape/scripts/bootstrap.py into scripts/bootstrap.py first, and then "
"run `make bootstrap`.")


def kept_scripts(plan) -> None:
"""The plan's `scripts/` answered `root`, LF as `plan` wrote it."""
text = plan.read_text(encoding="utf-8")
sent = " - path: scripts/\n leg: code\n"
assert sent in text, text
with plan.open("w", encoding="utf-8", newline="\n") as handle:
handle.write(text.replace(sent, (
" - path: scripts/\n leg: root\n"
" resolution: \"the source keeps its scripts in the root\"\n"),
1))


def test_next_names_no_bootstrap_where_the_roots_script_is_the_sources_own(
tmp_path):
"""The source kept its own `scripts/bootstrap.py` in the root as well as
its own Makefile, so the shape's script went to
`shape/scripts/bootstrap.py`, and NEXT's `python3 scripts/bootstrap.py`
ran the SOURCE's script in a recursive clone and exited 0, with no leg
placed and nothing validated (#493's review, F2). NEXT names no
bootstrap command there, and says which merges come first."""
source = make_source_repo(tmp_path / PROJECT, tree=OWN_BOOTSTRAP_TREE,
edits=())
plan = tmp_path / "adoption-plan.yaml"
written = write_plan(source, plan, project=PROJECT)
assert written.returncode == 0, written.stderr + written.stdout
kept_scripts(plan)

result = run_script(ADOPT, "execute", "--plan", str(plan), "--yes",
"--local-remote-dir", str(tmp_path / "remotes"),
"--work-dir", str(tmp_path / "work"))

assert result.returncode == 0, result.stderr + result.stdout
assert " beside shape/scripts/bootstrap.py " in result.stdout
assert next_block(result.stdout) == [
f"review the pull request on {PROJECT}, then",
f" git clone --recurse-submodules <{PROJECT} url>",
BOTH_ASIDE], result.stdout


def test_next_names_no_bootstrap_where_only_the_script_went_aside(
adopter, capsys):
"""With the shape's Makefile in the root and the source's own
`scripts/bootstrap.py`, `make bootstrap` runs the source's script, so
NEXT names no command there either."""
adopter._next_steps({"assembly": PROJECT}, [
("scripts/bootstrap.py", "shape/scripts/bootstrap.py")])

assert capsys.readouterr().out == (
f"\nNEXT: review the pull request on {PROJECT}, then\n"
f" git clone --recurse-submodules <{PROJECT} url>\n"
"NOTE the root keeps its own scripts/bootstrap.py, and the shape's is "
"shape/scripts/bootstrap.py, so neither `make bootstrap` nor `python3 "
"scripts/bootstrap.py` runs the shape's bootstrap yet: merge "
"shape/scripts/bootstrap.py into scripts/bootstrap.py first, and then "
"run `make bootstrap`.\n")


def test_the_makefile_follow_up_waits_for_a_script_that_went_aside_too(
adopter):
"""`plan` predicts the follow-ups from what the plan keeps in the root,
and tells each the whole list: where `scripts/` stays as well, the
root's `scripts/bootstrap.py` is the source's own, so the Makefile's
follow-up does not name `python3 scripts/bootstrap.py` as what runs
meanwhile (#493's review, F2)."""
kept = [adopter.Entry(path, adopter.Verdict(
"root", "test", "kept by the test", "high"), 1, 1)
for path in ("Makefile", "scripts/")]

(makefile,) = [item for item in adopter._predict_collisions(kept)
if item.startswith("merge shape/Makefile into Makefile")]

assert makefile.endswith(
"arrive only with that merge, and the root's scripts/bootstrap.py is "
"the source's own until shape/scripts/bootstrap.py is merged into it "
"as well, so neither `make bootstrap` nor `python3 "
"scripts/bootstrap.py` runs the shape's bootstrap before both merges "
"are made"), makefile


def test_the_agent_file_collision_says_add_a_line_not_merge(adopted):
"""A merge is the wrong instruction for an instruction file: it is how a
project's own agent instructions end up inside the shape's. The follow-up
Expand Down
Loading
Loading