Skip to content
Merged
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,11 @@ scripts/bootstrap.py`. README's "What setup.sh does" carries the exact commands.

## Adopting an existing repository

`adopt-project.py` converts a repository that already exists, IN PLACE: it keeps
its name, which `--project` must be, its identity and its full history and
becomes the assembly root, and the two legs are new repositories extracted with
`git filter-repo` (a hard dependency: `execute` refuses without it, printing the
install command). Seven steps, in order, and step 4 is the one you must not skip.
`adopt-project.py` converts an existing repository IN PLACE: it keeps its name,
which `--project` must be, its identity and its full history and becomes the
assembly root; its two legs are new repositories extracted with `git filter-repo`
(`execute` refuses without it). `--org` defaults to the org of an `org/repo` or a
url `origin`; a path `origin`, or none, needs it. Seven steps; never skip step 4.

```sh
./adopt-project.py plan --source <org/repo|path> --project <its own name>
Expand Down
368 changes: 291 additions & 77 deletions adopt-project.py

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions tests/test_adopt_check_plan_name.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@

from conftest import ADOPT, git, run_script
from test_adopt_project_is_the_source import ( # noqa: F401 - a fixture
PLAN, PLAN_NOT_THE_SOURCE, RENAME_FIRST, RETYPE, adopter, check, edited,
findings, plan, refused_before_any_remote, source_of)
PLAN, PLAN_NOT_THE_SOURCE, RETYPE, adopter, check, edited, findings,
in_place_remedy, plan, refused_before_any_remote, source_of)
from test_adopt_relative_submodule_url import (
HTTPS_ORIGIN, SIBLING, THING, planned as planned_with_submodule,
source_with as source_with_submodule)
Expand Down Expand Up @@ -140,7 +140,7 @@ def test_check_and_execute_say_the_same_remedy_of_the_sources_own_name(
assert [line.split(":")[0] for line in findings(checked.stderr)] == [
f"FINDING {code}"], checked.stderr
assert refusal.code == code
assert refusal.remediation == f"Remediation: {repository} {RENAME_FIRST}"
assert refusal.remediation == in_place_remedy(repository, code)
# Said as `execute` says it, as a line of its own, after the findings.
assert checked.stderr.splitlines().count(refusal.remediation) == 1, \
checked.stderr
Expand Down
3 changes: 2 additions & 1 deletion tests/test_adopt_manifest_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,9 +443,10 @@ def test_the_derived_ids_words_are_the_tables(adopter, monkeypatch):
an edit to `FLAG_DEFAULTS["id"]` would not have reached it. Its label
and its remedy are both read from the table."""
monkeypatch.setitem(adopter.FLAG_DEFAULTS, "id", "a stand-in")
naming = adopter.NamingPolicy.load(adopter.NAMING_POLICY)
with pytest.raises(adopter.Refusal) as refused:
adopter._refuse_bad_id("north_wind", "North_Wind", set(),
Namespace(repository=None))
Namespace(repository=None), naming, set())
assert refused.value.detail.startswith(
"--id (not given, so a stand-in) is 'north_wind'"), refused.value
assert "The id is a stand-in, and" in refused.value.remediation
Expand Down
30 changes: 19 additions & 11 deletions tests/test_adopt_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,11 @@ def test_a_project_name_that_is_not_a_lowercase_id_is_refused(source_repo,

#: `repo_shape.PROJECT_ID_RE`, as `adopt-bad-id` prints it.
ID_PATTERN = "^[a-z0-9][a-z0-9-]*$"
#: What a passed `--id` is told to do: pass another.
PASS_AN_ID = "Remediation: pass an explicit lowercase --id."
#: What a passed `--id` is told to do: pass another, of what an id may hold
#: (#902), in the scaffold's and `family.py init`'s words (#911). "Pass an
#: explicit lowercase --id" was said to `--id foo_bar`, which is both.
PASS_AN_ID = ("Remediation: pass an --id of lowercase letters, digits and "
"hyphens, starting with a letter or digit.")
#: How `adopt-bad-id` names an id `plan` derived from `--project`:
#: `_flag_named` over `FLAG_DEFAULTS`, as the tracking branch and the
#: elector are named (#529).
Expand Down Expand Up @@ -413,16 +416,16 @@ def test_a_passed_id_that_is_not_a_lowercase_id_is_named_as_passed(
assert not plan.exists()


@pytest.mark.parametrize("project", ["Northwind", "North_Wind"])
def test_an_empty_id_is_refused_as_the_id_passed(source_repo, tmp_path,
project):
def test_an_empty_id_is_refused_as_the_id_passed(source_repo, tmp_path):
"""L2-53: `--id ''` was read as not passed, so a script's `--id "$ID"`
with `ID` unset planned `id: northwind`, exit 0, and beside `--project
North_Wind` was refused as an id derived from it, "(--id was not given)"
(rv601's P4). It is a value typed, held to the id's rule as any other
and refused by name, as `--tracking-branch ''` is since #479."""
and refused by name, as `--tracking-branch ''` is since #479. (Beside
`--project North_Wind` the name is refused first since #902:
`tests/test_adopt_values_as_typed.py`.)"""
plan = tmp_path / "plan.yaml"
result = write_plan(source_repo, plan, project=project,
result = write_plan(source_repo, plan, project="Northwind",
extra=("--id", ""))
assert bad_id(result) == [
f"REFUSED adopt-bad-id: --id '' must match {ID_PATTERN}",
Expand Down Expand Up @@ -479,17 +482,22 @@ def test_a_derived_ids_remedy_names_the_rule_naming_unclassified_does(
f"Remediation: change --project to {NAME_RULE}. "), derived


def test_a_passed_id_equal_to_the_derived_one_is_still_named_as_passed(
def test_a_passed_id_equal_to_the_derived_one_meets_the_name_first(
source_repo, tmp_path):
"""Whether the id was passed is what the person did, not whether it
equals `--project` lowercased: `--id north_wind` beside `--project
North_Wind` is a flag typed, so its refusal names the flag."""
North_Wind` is a flag typed, and was refused as one, "pass an explicit
lowercase --id", whose retype met `naming-unclassified` (L2-90 on #457).
Since #902 the name the naming policy refuses speaks first, and no id
refusal is said; nor is the derived id's."""
plan = tmp_path / "plan.yaml"
result = write_plan(source_repo, plan, project="North_Wind",
extra=("--id", "north_wind"))
assert bad_id(result) == [
f"REFUSED adopt-bad-id: --id 'north_wind' must match {ID_PATTERN}",
PASS_AN_ID], result.stderr
"REFUSED naming-unclassified: 'North_Wind' matches no family in the "
"naming policy", f"Remediation: --project takes {NAME_RULE}."], \
result.stderr
assert "adopt-bad-id" not in result.stderr
assert not plan.exists()


Expand Down
24 changes: 13 additions & 11 deletions tests/test_adopt_plan_flag_refusals_first.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,13 @@ def refused_first(harness: Harness, result, code: str, said: str,
id="id"),
pytest.param(("--id", ""), "adopt-bad-id", "--id", id="id-empty"),
pytest.param(("--org=-evil",), "unsafe-value", "--org", id="org"),
# A flag passed empty is a value passed (#902): refused by name here.
pytest.param(("--org", ""), "unsafe-value", "--org is ''",
id="org-empty"),
pytest.param(("--elected-on", "2026-9-2"), "election-date-malformed",
"--elected-on", id="elected-on"),
pytest.param(("--elected-on", ""), "election-date-malformed",
"--elected-on ''", id="elected-on-empty"),
pytest.param(("--reference", "ref\nx"), "adopt-unwritable-value",
"--reference", id="reference"),
pytest.param(("--elected-by", "a\nb"), "adopt-unwritable-value",
Expand Down Expand Up @@ -423,18 +428,15 @@ def test_control_the_sources_own_name_still_reaches_the_clone(
assert calls[0].startswith(f"repo clone {cloned} "), calls


def test_control_an_empty_org_is_none_given_and_reaches_the_clone(harness):
"""`--org ''` is read as no `--org` (`_planned_org`), so it is the
organisation of `--source` and no value refused for its text: `plan`
goes on to `gh repo clone`, which the stub refuses, once. Passes on
main as well."""
def test_an_empty_org_is_a_value_passed_and_never_reaches_the_clone(
harness):
"""`--org ''` was read as no `--org` (`_planned_org`), so it was the
organisation of `--source`, and `plan` went on to `gh repo clone`,
while `flags_given` counted it passed (L2-86 on #457). Since #902 it is
a value passed, refused by name with the other flags, and no `gh`
runs."""
result = harness.plan(REPOSITORY, "--org", "", work=True)
assert result.returncode == 2, result.stdout + result.stderr
assert result.stderr.startswith("REFUSED source-unclonable: "), \
result.stderr
calls = harness.gh_calls()
assert len(calls) == 1, calls
assert calls[0].startswith(f"repo clone {REPOSITORY} "), calls
refused_first(harness, result, "unsafe-value", "--org is ''", True)


def test_control_a_local_source_is_held_to_its_name_after_git_reads_it(
Expand Down
42 changes: 29 additions & 13 deletions tests/test_adopt_plan_yaml_quoting.py
Original file line number Diff line number Diff line change
Expand Up @@ -1106,9 +1106,11 @@ def test_the_misread_rule_names_an_id_it_derived_from_the_assembly(
#: named the branch by what was READ, with its kind, and then the text
#: `execute` writes (review181's N2); the validator refused the id as `id is
#: 7`. Since #259 the line is quoted and `7` reads back as the text `7`, so
#: nothing names any of them, as on main: `execute` writes `7` for a plan
#: that says `007`. A date or a visibility read as not text is still
#: refused (`LATE_DATES`, `NOT_TEXT_INVALID`), and a pin's product is #273's
#: neither rule named any of them, and `execute` wrote `7` for a plan that
#: says `007` (L1-K3 on #457). Since #902 `check` refuses each by what was
#: read: the branch as `plan-bad-tracking-branch`, the id as `plan-bad-id`.
#: A date or a visibility read as not text is still refused
#: (`LATE_DATES`, `NOT_TEXT_INVALID`), and a pin's product is #273's
#: (`test_a_pin_whose_product_is_not_text_is_273s_finding_alone`). The
#: source has the branch as `execute` takes it, `str` of what was read
#: (#479).
Expand All @@ -1118,31 +1120,45 @@ def test_the_misread_rule_names_an_id_it_derived_from_the_assembly(
(TRACKING_KEY, "1.50", "the number 1.5", "1.5"),
("id", "007", SEVEN_READ, "7"),
]
#: The ones #181's misread rule is still asked about: an id read as not
#: text is `plan-bad-id` since #902, refused before any rule is asked.
MISREAD_AS_NOT_TEXT = [row for row in READ_AS_NOT_TEXT if row[0] != "id"]


@pytest.mark.parametrize("key, spelled, read_as, written", READ_AS_NOT_TEXT,
ids=ids_of(READ_AS_NOT_TEXT, 0, 1))
def test_check_passes_a_plan_value_read_as_not_text_as_main_does(
def test_check_refuses_a_plan_value_read_as_not_text_by_what_was_read(
answered_copy, tmp_path, key, spelled, read_as, written):
"""`check` named each of these, the branch as #181's misread and the id
in the validator's words, while the template wrote its line bare. Since
#259 the line is quoted, the text `execute` writes reads back, and
neither rule has a finding: `check` passes the plan, as main does, and
`execute` writes `str` of what was read, `7` for `007` (a residual this
change shares with main, in its description). `read_as` is the
misread rule's answer for a bare line, asked in the test below."""
neither rule had a finding: `check` passed the plan and `execute` wrote
`str` of what was read, `7` for `007` (L1-K3 on #457). Since #902 each
is refused by what the plan's reader made of it, and the text `execute`
would have taken: the branch is a finding under its key's code, and the
id is `plan-bad-id`, which `check` and `execute` refuse an id as."""
source, text = answered_copy
holding(source, key, written)
plan = tmp_path / PLAN_FILE
carrying(plan, text, key, spelled)
result = checked(plan)
assert result.returncode == 0, result.stdout + result.stderr
assert PLAN_OK in result.stdout, result.stdout
assert ANY_FINDING not in result.stderr, result.stderr
said = (f"`{key}:` was read out of the plan as {read_as}, not as text, "
f"so `execute` would take it as the text `{written}`")
assert PLAN_OK not in result.stdout, result.stdout
if key == "id":
assert result.returncode == 2, result.stdout + result.stderr
assert result.stderr.startswith(
f"REFUSED plan-bad-id: {plan}: {said}\n"), result.stderr
else:
assert result.returncode == 1, result.stdout + result.stderr
assert [line for line in result.stderr.splitlines()
if line.startswith(ANY_FINDING)] == [
f"FINDING plan-bad-tracking-branch: {said}"], result.stderr


@pytest.mark.parametrize("key, spelled, read_as, written", READ_AS_NOT_TEXT,
ids=ids_of(READ_AS_NOT_TEXT, 0, 1))
@pytest.mark.parametrize("key, spelled, read_as, written",
MISREAD_AS_NOT_TEXT,
ids=ids_of(MISREAD_AS_NOT_TEXT, 0, 1))
def test_the_misread_rule_names_a_plan_value_that_is_not_text_by_what_was_read(
adopter, answered, bare_template, tmp_path, key, spelled, read_as,
written):
Expand Down
64 changes: 57 additions & 7 deletions tests/test_adopt_project_is_the_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@
#: before the repository and `RENAME_FIRST` (#658).
DERIVED_OWN_NAME = ("the id is --project lowercased, and --project is the "
f"source's own name, which must be {NAME_RULE}.")
#: What a naming refusal of the source's own name says before the repository
#: and `RENAME_FIRST`, in place, by its code (#902, L2-89 on #457): the rule
#: the new name must follow, the one the same refusal gives `--project` for
#: a name that is not the source's.
OWN_NAME_SAID = {
"naming-unclassified": ("--project is the source's own name, which must "
f"be {NAME_RULE}."),
"naming-role-mismatch": (
"--project is the source's own name, which must be one CamelCase "
"token. Two forms beyond the bare one MAY be the assembly root: a "
"declared descendant (declare the pin with `--pin open<Product>@<40 "
"hex commit>` and it classifies as one, 2026-09-02), and an "
"`open<Product>` neutral product, which may elect the shape and be "
"its own root (2026-09-05). Neither may be the spec or code leg, and "
"an `<X>-Install` may be no leg at all."),
}


def in_place_remedy(repository: str, code: str) -> str:
"""The remediation of naming refusal `code` of `repository`'s own name,
adopted in place: the rule, then renaming first."""
return f"Remediation: {OWN_NAME_SAID[code]} {repository} {RENAME_FIRST}"


#: A source small enough that `plan` asks no question.
TREE = {"README.md": "# here\n", "src/app.py": "print(1)\n",
"specs/001/spec.md": "# Spec\n"}
Expand Down Expand Up @@ -170,8 +194,8 @@ def test_plan_refuses_a_name_that_is_not_the_sources(tmp_path, repository,
else:
assert remediation.endswith(
"The source's own name is no remedy either: 'billing-service' "
"matches no family in the naming policy. acme/billing-service "
f"{RENAME_FIRST}"), remediation
f"matches no family in the naming policy. The name must be "
f"{NAME_RULE}. acme/billing-service {RENAME_FIRST}"), remediation
assert RETYPE not in refused.stderr
assert not (tmp_path / PLAN).exists()

Expand All @@ -193,11 +217,37 @@ def test_the_sources_own_name_the_policy_refuses_is_renamed_first(
assert refused.returncode == 2, refused.stdout + refused.stderr
detail, remediation = refused.stderr.splitlines()[:2]
assert detail.startswith(f"REFUSED {code}: "), refused.stderr
assert remediation == f"Remediation: {repository} {RENAME_FIRST}"
assert remediation == in_place_remedy(repository, code)
assert RETYPE not in refused.stderr
assert not (tmp_path / PLAN).exists()


def test_not_the_source_gives_the_rule_of_the_names_own_refusal(tmp_path):
"""#902, L2-89: `--project ToolsInstall` over a source `acme/Tools-Install`
is not the source, and the source's own name is refused by
`naming-role-mismatch`, an install and no project leg, not by
`naming-unclassified`. The rule it gives the new name is that code's, the
one `plan` gives `--project` for it (`NAME_RULES`), and not `no hyphen,
underscore, dot or space`, which `Tools-Install` as an install form
would be told wrongly."""
source = source_of(tmp_path, "acme/Tools-Install")

refused = plan(tmp_path, source, "--project", "ToolsInstall")

assert refused.returncode == 2, refused.stdout + refused.stderr
detail, remediation = refused.stderr.splitlines()[:2]
assert detail.startswith(
f"REFUSED {NOT_THE_SOURCE}: --project ToolsInstall, {OF_ORIGIN}, "
"names acme/ToolsInstall, and the source is acme/Tools-Install: "), \
detail
rule = OWN_NAME_SAID["naming-role-mismatch"].split("which must be ", 1)[1]
assert remediation.endswith(
f"The name must be {rule} acme/Tools-Install {RENAME_FIRST}"), \
remediation
assert f"The name must be {NAME_RULE}" not in remediation
assert not (tmp_path / PLAN).exists()


def test_a_name_in_another_case_is_the_source(tmp_path):
"""GitHub resolves `acme/atlas` and `ACME/Atlas` to one repository, so
they are the source, and `plan` and `check` pass."""
Expand Down Expand Up @@ -452,8 +502,8 @@ def test_a_plan_older_than_its_sources_origin_is_caught_by_check_and_execute(
"in-place adoption keeps the source's own name, and `execute` acts "
"on the repository `org:` and `legs.assembly:` name"]
rename = (" The source's own name is no remedy either: 'billing-service' "
f"matches no family in the naming policy. acme/billing-service "
f"{RENAME_FIRST}")
f"matches no family in the naming policy. The name must be "
f"{NAME_RULE}. acme/billing-service {RENAME_FIRST}")
assert f"Remediation:{rename}\n" in checked.stderr, checked.stderr
assert refusal.code == PLAN_NOT_THE_SOURCE
assert refusal.remediation == (
Expand Down Expand Up @@ -484,8 +534,8 @@ def test_execute_says_rename_first_of_the_sources_own_name(adopter, tmp_path,
tmp_path / PLAN, tmp_path / "work")

assert refusal.code == "naming-unclassified"
assert refusal.remediation == \
f"Remediation: acme/billing-service {RENAME_FIRST}"
assert refusal.remediation == in_place_remedy("acme/billing-service",
"naming-unclassified")


def test_a_source_with_no_origin_executes_past_the_comparison(
Expand Down
Loading
Loading