From 10dc011c9ea5c0c72e108d14a107a7574d930960 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Wed, 21 Oct 2026 12:43:38 +0000 Subject: [PATCH 1/4] family.py: the `remove` that both `member-duplicate-id` remedies name and the `add` that `bump`'s `member-unknown` names carry `--family-root `, quoted for the host, so each runs as printed (#1005) `remove` and `add` require `--family-root`, as `bump` does. #927 gave every remedy that names `family.py bump` the holder and left these three, which typed as printed ended in `the following arguments are required: --family-root`: - `add`'s `member-duplicate-id`: `family.py remove --member

`. - `bump`'s own `member-duplicate-id`: `family.py remove --member

`. - `bump`'s `member-unknown`: `family.py add --member /

`. Each now names `--family-root` and the resolved `family.root`, spelled by `shell_quoted` as #927's `bump` line and `_add_command`'s are. `` stays a blank for the person to fill in. README's and the handbook's "Windows, natively" sentence names `remove` among the lines `family.py` quotes for the shell they are printed in, and the handbook's markers carry README's new section hash. The table in `tests/test_readme_windows_quoting_sentence.py` counts the new host-dialect calls in `_checked_member_id`, `_checked_bumped_id` and `cmd_bump`. `tests/test_remove_add_remedies_family_root.py` types each printed line from another folder, over holders in a plain folder, under `my work` and `Dana's work`, reached by a relative `--family-root` and through a symlink: the line names the holder whole, `family.py`'s own parser accepts it, and it runs as printed and does what the remedy says. `tests/test_family_add_member_id.py` and `tests/test_family_bump_to_checks.py` follow the new words, and the former now runs the `remove` line exactly as printed. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 +- docs/handbook.html | 19 +- scripts/family.py | 27 +- tests/test_family_add_member_id.py | 14 +- tests/test_family_bump_to_checks.py | 16 +- tests/test_readme_windows_quoting_sentence.py | 10 +- tests/test_remove_add_remedies_family_root.py | 282 ++++++++++++++++++ 7 files changed, 337 insertions(+), 35 deletions(-) create mode 100644 tests/test_remove_add_remedies_family_root.py diff --git a/README.md b/README.md index 824842fb..a236a217 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,8 @@ Git Bash runs step 3's bash command and its one line, tested in CI but for a typed `yes`, which its preflight cannot take there. Printed commands are quoted for a POSIX shell, so paste them into Git Bash. Only `shape-doctor.py`'s lines, `setup-project.py`'s family lines and `family.py`'s `NEXT`, `switch -c`, `add`, -`cd`, `git rm -r --cached` and `bump` lines are quoted for the shell they are -printed in, PowerShell or Git Bash, on Windows. +`cd`, `git rm -r --cached`, `bump` and `remove` lines are quoted for the shell +they are printed in, PowerShell or Git Bash, on Windows. Off Windows and in Git Bash, a path `shape-doctor.py` cannot print as itself is written `$'…'`: bash, zsh and ksh read that; dash (`/bin/sh` on Debian and Ubuntu) does not. diff --git a/docs/handbook.html b/docs/handbook.html index 75b04198..566dfd67 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -472,7 +472,7 @@ - + @@ -612,7 +612,7 @@

The one thing to read before electing it

- +

Setup

Start here

@@ -737,8 +737,9 @@

The tool offers the rest

Printed commands are quoted for a POSIX shell, so paste them into Git Bash. Only shape-doctor.py’s lines, setup-project.py’s family lines and family.py’s NEXT, switch -c, - add, cd, git rm -r --cached and bump - lines are quoted for the shell they are printed in, PowerShell or Git Bash, on Windows. + add, cd, git rm -r --cached, bump and + remove lines are quoted for the shell they are printed in, PowerShell or Git + Bash, on Windows. A shell started from inside Git Bash inherits its MSYSTEM, which is how a tool tells Git Bash apart, so a PowerShell started there is handed Git Bash’s quoting, and a cmd.exe started anywhere else is handed PowerShell’s. Off Windows and in Git Bash, a path @@ -1092,7 +1093,7 @@

What you have afterwards — the same on every platform

- +

Setup

If it fails part-way

@@ -1133,7 +1134,7 @@

If it fails part-way

- + @@ -1196,7 +1197,7 @@

Reading private legs in CI

- + @@ -1452,7 +1453,7 @@

The InkRouter example

- + @@ -1751,7 +1752,7 @@

Is this repository compliant? openRepoShape --doctor

- + diff --git a/scripts/family.py b/scripts/family.py index db710aba..bea9abd4 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -6581,8 +6581,9 @@ def _checked_member_id(family: Family, repository: str, project: str, row = _row_recording(family, project_id) if row is not None: name = row["project"] - # THE `bump` IT NAMES RUNS AS PRINTED (#927), with this holder's - # `--family-root`, as `member-already-present`'s does. + # THE `remove` AND THE `bump` IT NAMES RUN AS PRINTED (#1005, #927), + # each with this holder's `--family-root`, which both verbs require, + # as `member-already-present`'s `bump` does. raise Refusal( "member-duplicate-id", f"{project} is already a member of {family.data.get('name')} " @@ -6592,9 +6593,10 @@ def _checked_member_id(family: Family, repository: str, project: str, "project twice, which `validate-family.py` refuses", "Remediation: a family pins each project once. If " f"{repository} is {row.get('repository')} renamed at the forge, " - f"`family.py remove --member {name}` takes the old row out and " - "this `add` then pins it under the new name; `family.py bump " - f"--family-root {shell_quoted(family.root)} --member {name} " + "`family.py remove --family-root " + f"{shell_quoted(family.root)} --member {name}` takes the old row " + "out and this `add` then pins it under the new name; `family.py " + f"bump --family-root {shell_quoted(family.root)} --member {name} " "--to ` moves the row that is there instead. Two " "projects that record one id are a fault in one of their " "`project.yaml` files, mended in that project, not in the " @@ -8628,6 +8630,9 @@ def _checked_bumped_id(family: Family, row: dict, commit: str, if held is None: return repository = row.get("repository") + # THE `remove` IT NAMES RUNS AS PRINTED (#1005): `remove` requires + # `--family-root`, so the line names this holder, spelled by + # `shell_quoted` as `member-already-present`'s `bump` is (#927). raise Refusal( "member-duplicate-id", f"{repository} at {commit[:12]} is already a member of " @@ -8638,7 +8643,8 @@ def _checked_bumped_id(family: Family, row: dict, commit: str, "Remediation: a family pins each project once. Pass `--to` a commit " f"of {repository} whose project.yaml records an id no other row " f"records. If {repository} and {held.get('repository')} are one " - f"project, `family.py remove --member {held['project']}` takes the " + "project, `family.py remove --family-root " + f"{shell_quoted(family.root)} --member {held['project']}` takes the " "other row out first. Two projects that record one id are a fault in " "one of their `project.yaml` files, mended in that project, not in " "the family.") @@ -9324,11 +9330,16 @@ def cmd_bump(args) -> int: project = checked_value("--member", repo_basename(args.member)) row = family.member(project) if row is None: + # THE `add` IT NAMES RUNS AS PRINTED (#1005): `add` requires + # `--family-root`, so the line names this holder, spelled by + # `shell_quoted` as `_add_command`'s is (#486). `` is left for + # the person to fill in: nothing here says whose repository it is. raise Refusal( "member-unknown", f"{project} is not a member of {family.data.get('name')}", - "Remediation: `family.py add --member /" + project - + "` puts it in the family first.") + "Remediation: `family.py add --family-root " + f"{shell_quoted(family.root)} --member /{project}` puts it " + "in the family first.") project = str(row["project"]) commit = checked_value("--to", args.to).lower() if not COMMIT_RE.match(commit): diff --git a/tests/test_family_add_member_id.py b/tests/test_family_add_member_id.py index f39dbbc7..f5eb1f39 100644 --- a/tests/test_family_add_member_id.py +++ b/tests/test_family_add_member_id.py @@ -71,7 +71,8 @@ DUPLICATE_ID_REMEDIATION = ( f"Remediation: a family pins each project once. If {ORG}/{{who}} is " - f"{ORG}/{PINNED} renamed at the forge, `family.py remove --member " + f"{ORG}/{PINNED} renamed at the forge, `family.py remove --family-root " + "{holder} --member " f"{PINNED}` takes the old row out and this `add` then pins it under the " "new name; `family.py bump --family-root {holder} --member " f"{PINNED} --to ` moves the row that is there instead. Two " @@ -81,8 +82,8 @@ def duplicate_id_remediation(who: str, holder: Path) -> str: """What `add` tells the human to do about `member-duplicate-id`: the - `bump` it names carries `--family-root` and the holder, as `bump` - requires (#927).""" + `remove` and the `bump` it names each carry `--family-root` and the + holder, as both verbs require (#927, #1005).""" return DUPLICATE_ID_REMEDIATION.format( who=who, holder=shell_quoted(holder.resolve())) @@ -168,8 +169,8 @@ def test_each_command_the_refusal_names_runs_on_the_row_it_names( for ``, `remove` takes the old row out and the refused `add` then pins the project under its new name, and `bump` moves the row that is there; the gate is green after either, with one row recording id `irrs`. - `bump` is run exactly as printed, its own `--family-root` and all - (#927); `remove` is still given the `--family-root` it does not name.""" + Each is run exactly as printed, its own `--family-root` and all (#927, + #1005).""" remotes = renamed_copy(family, tmp_path) refused = offline.add(holder, RENAMED, remotes=remotes) assert refused.returncode == 2, refused.stdout + refused.stderr @@ -179,8 +180,7 @@ def test_each_command_the_refusal_names_runs_on_the_row_it_names( cwd=family["remotes"] / f"{PINNED}.git").stdout.strip() argv = host_words(commands[0].replace("", older)) assert argv[argv.index("--member") + 1] == PINNED - unnamed = [] if verb == "bump" else ["--family-root", str(holder)] - done = run_script(FAMILY, *argv, *unnamed, env=offline.env) + done = run_script(FAMILY, *argv, env=offline.env) assert done.returncode == 0, done.stdout + done.stderr if verb == "remove": added = offline.add(holder, RENAMED, remotes=remotes) diff --git a/tests/test_family_bump_to_checks.py b/tests/test_family_bump_to_checks.py index d8b7652f..59cb1121 100644 --- a/tests/test_family_bump_to_checks.py +++ b/tests/test_family_bump_to_checks.py @@ -41,6 +41,7 @@ import pytest from conftest import cloned_just_pushed +from repo_shape import shell_quoted #: The family fixture and its names, from the module that owns them. from test_family import ( # noqa: F401 NAME, ORG, family, manifest, validate, @@ -79,9 +80,11 @@ def records_the_neighbours_id(manifest_path: Path) -> None: OWN_ID_LINE, f'\nid: "{OTHER_ID}"\n'.encode("ascii"))) -def duplicate_id_refusal(to: str) -> tuple[str, str]: +def duplicate_id_refusal(to: str, holder: Path) -> tuple[str, str]: """The two lines `bump --to ` is refused with when the member's - `project.yaml` at `to` records the neighbour's id.""" + `project.yaml` at `to` records the neighbour's id. The `remove` the + remedy names carries `--family-root` and the holder, as `remove` + requires (#1005).""" return ( f"REFUSED member-duplicate-id: {ORG}/{MEMBER} at {to[:12]} is " f"already a member of {NAME} under another name: its project.yaml " @@ -91,7 +94,8 @@ def duplicate_id_refusal(to: str) -> tuple[str, str]: "Remediation: a family pins each project once. Pass `--to` a commit " f"of {ORG}/{MEMBER} whose project.yaml records an id no other row " f"records. If {ORG}/{MEMBER} and {ORG}/{OTHER} are one project, " - f"`family.py remove --member {OTHER}` takes the other row out first. " + f"`family.py remove --family-root {shell_quoted(holder.resolve())} " + f"--member {OTHER}` takes the other row out first. " "Two projects that record one id are a fault in one of their " "`project.yaml` files, mended in that project, not in the family.") @@ -111,7 +115,7 @@ def test_bump_to_a_commit_recording_another_rows_id_is_refused_alike( stderr = both_runs(holder, env, to) - assert refusal(stderr) == duplicate_id_refusal(to) + assert refusal(stderr) == duplicate_id_refusal(to, holder) assert validate(holder).returncode == 0 offline.assert_left_nothing() @@ -143,7 +147,7 @@ def link(mount: Path) -> None: assert_same(before, snapshot(holder, env), BUMP_PUT_BACK) assert real.returncode == 2, real.stdout + real.stderr assert real.stdout == "" - said, remedy = duplicate_id_refusal(to) + said, remedy = duplicate_id_refusal(to, holder) assert refusal(real.stderr) == ( f"{said}. {bump_undone(f'{PATH} checked out at {at[:12]} again')}", remedy) @@ -165,7 +169,7 @@ def test_a_duplicate_id_behind_a_tag_is_refused_after_the_peel_line( stderr = both_runs(holder, env, to, peeled_line(to, made) + "\n") - assert refusal(stderr) == duplicate_id_refusal(made) + assert refusal(stderr) == duplicate_id_refusal(made, holder) assert validate(holder).returncode == 0 offline.assert_left_nothing() diff --git a/tests/test_readme_windows_quoting_sentence.py b/tests/test_readme_windows_quoting_sentence.py index ef8fa025..695c2803 100644 --- a/tests/test_readme_windows_quoting_sentence.py +++ b/tests/test_readme_windows_quoting_sentence.py @@ -61,8 +61,8 @@ "shape-doctor.py": EVERY_LINE, # "`setup-project.py`'s family lines": the `switch -c` and `add` it prints. "setup-project.py": {"family_next_command": 2}, - # "`family.py`'s `NEXT`, `switch -c`, `add`, `cd`, `git rm -r --cached` - # and `bump` lines". + # "`family.py`'s `NEXT`, `switch -c`, `add`, `cd`, `git rm -r --cached`, + # `bump` and `remove` lines". "scripts/family.py": { "_print_next": 1, # the `NEXT` line: `git push -u origin ...` "_switch_command": 1, # the `git switch -c ` line @@ -70,7 +70,11 @@ "_print_next_steps": 2, # `init`'s block: its `switch -c` and `cd` "_checked_mount_path": 1, # the `git rm -r --cached -- ` remedy "_checked_add_target": 1, # `member-already-present`'s `bump` line - "_checked_member_id": 1, # `member-duplicate-id`'s `bump` line (#927) + # `add`'s `member-duplicate-id`: its `bump` line (#927) and its + # `remove` line (#1005) + "_checked_member_id": 2, + "_checked_bumped_id": 1, # `bump`'s `member-duplicate-id`: `remove` + "cmd_bump": 1, # `bump`'s `member-unknown`: its `add` line }, } diff --git a/tests/test_remove_add_remedies_family_root.py b/tests/test_remove_add_remedies_family_root.py new file mode 100644 index 00000000..adfd0ad5 --- /dev/null +++ b/tests/test_remove_add_remedies_family_root.py @@ -0,0 +1,282 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A remedy that names `family.py remove` or `family.py add` names +`--family-root`, so the line runs as printed (#1005, which follows #927). + +`remove` and `add` require `--family-root` (their usage lines, `family.py`'s +head), as `bump` does. #927 gave every remedy that names `bump` the holder; +three more named another verb without it. Typed as printed, each ended in +`family.py : error: the following arguments are required: +--family-root`: + +* `add`'s `member-duplicate-id`, which says `remove` takes the old row out, + and `bump`'s own `member-duplicate-id`, which says `remove` takes the other + row out first. Both KNOW the holder, so each names it, in the one word + `shell_quoted` makes of its path, as #927's `bump` line does. +* `bump`'s `member-unknown`, which says `add` puts the member in the family + first. It names the holder the same way, and leaves `` for the person + to fill in, as the verbs' usage lines do: nothing there says whose + repository the project is. + +Each case is measured two ways, over a holder in a plain folder, under two +with a space in their names (one of them with an apostrophe too: the paths a +shell would tear in half), reached by a RELATIVE `--family-root`, and +reached through a SYMLINK. The refused run names the holder as it likes; the +line it prints names the holder resolved, as ONE word. First, the printed +words are the holder whole and `family.py`'s own parser accepts them. Then +the line is RUN as printed, from a folder that is not the holder's, and does +what the remedy says it does: `remove` takes the row out and the refused act +then goes through; `add` pins the member the refused `bump` named. + +The fixtures are the ones the tests of these codes already use: +`tests/test_family_add_member_id.py`'s renamed copy, +`tests/test_family_bump_to_checks.py`'s member that records its neighbour's +id, and `tests/test_family.py`'s family, whose spare member `IRQS` is in its +remotes and not in the holder. No network: `gh` is a shim that exits non-zero. +""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +import pytest + +from conftest import git, run_script +from repo_shape import shell_quoted +#: The fixtures and helpers of the tests that already trigger these codes, +#: imported from the files that own them rather than rebuilt: a holder or a +#: member made any other way would test a different one. +from test_bump_remedies_family_root import ( # noqa: F401 + PLACES, family_module, parsed, +) +from test_family import ( # noqa: F401 + FAMILY, MEMBERS, ORG, SPARE, family, manifest, validate, +) +from test_family_add_member_id import RENAMED, renamed_copy +from test_family_add_refusals import offline # noqa: F401 +from test_family_bump_to_checks import records_the_neighbours_id +from test_family_dry_run import commit_in_mount, isolated +from test_family_member_path import MEMBER, OTHER +from test_family_printed_quoting import copied_holder, host_words + +#: A commit id for a `--to` that is never read: `member-unknown` is said +#: before `--to` is. +COMMIT = "0123456789abcdef0123456789abcdef01234567" + +#: How the refused run reaches the holder: the three places a holder stands +#: (`test_bump_remedies_family_root.PLACES`), then a `--family-root` that is +#: RELATIVE to where the run stands, and one reached through a SYMLINK. The +#: line a remedy prints names the holder resolved, whichever it was. +REACHED = [*PLACES, "relative", pytest.param( + "symlink", marks=pytest.mark.skipif( + sys.platform == "win32", + reason="a symbolic link needs privileges on Windows that a runner " + "does not grant"))] + + +@dataclass(frozen=True) +class Reach: + """A holder, and how a run that is refused is pointed at it.""" + + holder: Path # the holder, resolved: the path a remedy prints + spec: str # what that run's `--family-root` says + cwd: Path | None # where that run stands: None, anywhere + + +@pytest.fixture(params=REACHED) +def reach(request, family, tmp_path) -> Reach: + """A private copy of the family's holder, clean, reached as + `request.param` says.""" + where = request.param + holder = copied_holder( + family, tmp_path / (where if where in PLACES else "holders")) + if where == "relative": + return Reach(holder, holder.name, holder.parent) + if where == "symlink": + via = tmp_path / "via link" + via.mkdir() + (via / "the holder").symlink_to(holder, target_is_directory=True) + assert (via / "the holder").resolve() == holder, "premise: a link" + return Reach(holder, str(via / "the holder"), None) + return Reach(holder, str(holder), None) + + +@pytest.fixture +def elsewhere(tmp_path) -> Path: + """A folder that is not the holder's, which every printed line is typed + from: its `--family-root` is the only thing that says where the holder + is.""" + stand = tmp_path / "elsewhere" + stand.mkdir() + return stand + + +@dataclass(frozen=True) +class Case: + """One refusal, the verb its remedy names, and how to tell the line + does what the remedy says.""" + + code: str + verb: str # the verb the remedy names + member: str # the `--member` its line carries + refuse: Callable # (reach, family, env, tmp_path) -> (stderr, kept) + fill: Callable # (line, kept) -> the line with its blanks filled + extra: Callable # (family, kept) -> arguments only a test adds + mended: Callable # (reach, family, env, kept) -> asserts the repair + + +def add_the_renamed_copy(reach, remotes, env): + """`add` of the pinned member's copy under a new name, whose + `project.yaml` records the id the row `IRRS` pins: + `member-duplicate-id`.""" + return run_script(FAMILY, "add", "--family-root", reach.spec, "--member", + f"{ORG}/{RENAMED}", "--local-remote-dir", str(remotes), + cwd=reach.cwd, env=env) + + +def refuse_add_duplicate_id(reach, family, env, tmp_path): + remotes = renamed_copy(family, tmp_path) + return add_the_renamed_copy(reach, remotes, env).stderr, remotes + + +def mend_add_duplicate_id(reach, family, env, remotes): + """The old row is out, and the `add` that was refused pins the project + under its new name: one row records the id.""" + assert MEMBER not in {row["project"] + for row in manifest(reach.holder)["members"]} + added = add_the_renamed_copy(reach, remotes, env) + assert added.returncode == 0, added.stdout + added.stderr + rows = {row["project"]: row for row in manifest(reach.holder)["members"]} + assert sorted(rows) == sorted([RENAMED, *MEMBERS[1:]]) + assert [name for name, row in rows.items() if row["id"] == "irrs"] == [ + RENAMED] + + +def refuse_bump_duplicate_id(reach, family, env, tmp_path): + """`bump` of `IRRS` to a commit whose `project.yaml` records the id of + its neighbour `IRSS`: `member-duplicate-id`.""" + to = commit_in_mount( + reach.holder, env, lambda mount: records_the_neighbours_id( + mount / "project.yaml")) + refused = run_script(FAMILY, "bump", "--family-root", reach.spec, + "--member", MEMBER, "--to", to, cwd=reach.cwd, + env=env) + return refused.stderr, to + + +def mend_bump_duplicate_id(reach, family, env, to): + """The other row is out, and the `bump` that was refused moves the row + that is there.""" + assert OTHER not in {row["project"] + for row in manifest(reach.holder)["members"]} + bumped = run_script(FAMILY, "bump", "--family-root", str(reach.holder), + "--member", MEMBER, "--to", to, env=env) + assert bumped.returncode == 0, bumped.stdout + bumped.stderr + rows = {row["project"]: row for row in manifest(reach.holder)["members"]} + assert sorted(rows) == [MEMBER] + assert rows[MEMBER]["pin"]["commit"] == to + + +def refuse_bump_unknown(reach, family, env, tmp_path): + """`bump` of `IRQS`, which is in the family's remotes and not in the + holder: `member-unknown`.""" + refused = run_script(FAMILY, "bump", "--family-root", reach.spec, + "--member", SPARE, "--to", COMMIT, cwd=reach.cwd, + env=env) + return refused.stderr, None + + +def mend_bump_unknown(reach, family, env, kept): + """The member the refused `bump` named is in the family now, under the + row `add` writes.""" + rows = {row["project"]: row for row in manifest(reach.holder)["members"]} + assert sorted(rows) == sorted([*MEMBERS, SPARE]) + assert rows[SPARE]["path"] == f"members/{SPARE}" + assert rows[SPARE]["repository"] == f"{ORG}/{SPARE}" + + +CASES = { + "add-member-duplicate-id": Case( + "member-duplicate-id", "remove", MEMBER, refuse_add_duplicate_id, + lambda line, kept: line, lambda family, kept: [], + mend_add_duplicate_id), + "bump-member-duplicate-id": Case( + "member-duplicate-id", "remove", OTHER, refuse_bump_duplicate_id, + lambda line, kept: line, lambda family, kept: [], + mend_bump_duplicate_id), + "bump-member-unknown": Case( + "member-unknown", "add", f"/{SPARE}", refuse_bump_unknown, + lambda line, kept: line.replace("", ORG), + # `add` reads the member from the family's own bare repositories, + # as every `add` in this suite does: nothing here reaches a forge. + lambda family, kept: ["--local-remote-dir", str(family["remotes"])], + mend_bump_unknown), +} + + +def the_line(stderr: str, case: Case) -> str: + """The one `family.py ...` the refusal prints, from the verb to + the closing backtick, and the refusal checked to be the case's code.""" + assert f"REFUSED {case.code}: " in stderr, stderr + found = re.findall(rf"`family\.py ({case.verb} [^`]*)`", stderr) + assert len(found) == 1, stderr + return found[0] + + +def expected(case: Case, reach: Reach) -> str: + return (f"{case.verb} --family-root {shell_quoted(reach.holder)} " + f"--member {case.member}") + + +@pytest.mark.parametrize("case", CASES.values(), ids=CASES) +def test_the_line_a_remedy_names_carries_the_holder_whole( + case, family, reach, offline, tmp_path): + """The line is ` --family-root --member `, and the + holder is ONE word that is the holder's own path, as the host's shell + reads it, however many spaces and apostrophes it has and however the + refused run was pointed at it.""" + stderr, kept = case.refuse(reach, family, isolated(offline, tmp_path), + tmp_path) + line = the_line(stderr, case) + assert line == expected(case, reach) + assert host_words(line) == [case.verb, "--family-root", + str(reach.holder), "--member", case.member] + + +@pytest.mark.parametrize("case", CASES.values(), ids=CASES) +def test_the_line_a_remedy_names_parses( + case, family, reach, offline, tmp_path, family_module, capsys): + """Fed to `family.py`'s own parser, with `` filled in, the printed + words are accepted: no `the following arguments are required`.""" + stderr, kept = case.refuse(reach, family, isolated(offline, tmp_path), + tmp_path) + words = host_words(case.fill(the_line(stderr, case), kept)) + capsys.readouterr() + args = parsed(family_module, words, capsys) + assert args.family_root == str(reach.holder) + assert args.member == case.fill(case.member, kept) + assert args.func is getattr(family_module, f"cmd_{case.verb}") + + +@pytest.mark.parametrize("case", CASES.values(), ids=CASES) +def test_the_line_a_remedy_names_runs_as_printed_and_does_what_it_says( + case, family, reach, elsewhere, offline, tmp_path): + """The remediation is the repair. Typed as printed, from a folder that + is not the holder's, the line exits 0 and leaves the holder clean and + its own gate green, and the refused act then goes through, or the member + is in the family: whichever the remedy says it is for.""" + env = isolated(offline, tmp_path) + stderr, kept = case.refuse(reach, family, env, tmp_path) + words = host_words(case.fill(the_line(stderr, case), kept)) + done = run_script(FAMILY, *words, *case.extra(family, kept), + cwd=elsewhere, env=env) + assert "the following arguments are required" not in done.stderr + assert done.returncode == 0, done.stdout + done.stderr + assert git("status", "--porcelain", cwd=reach.holder).stdout == "" + assert validate(reach.holder).returncode == 0 + case.mended(reach, family, env, kept) + assert git("status", "--porcelain", cwd=reach.holder).stdout == "" + assert validate(reach.holder).returncode == 0 From eac177af934b9606db83e5a31e62cd5a98b5270c Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Thu, 22 Oct 2026 21:56:04 +0000 Subject: [PATCH 2/4] family.py: the `add`, `bump` and `remove` lines its remedies name begin with the interpreter and the script's path, quote the row's name, fill in the family's org, say the branch they run on, and `member-already-present` prints its `remove` (#1127) RV1117's follow-ups on #1117 (#1005), stacked on #1117 at 10dc011c9: the lines `bump`'s `member-unknown`, `add`'s `member-already-present` and both `member-duplicate-id` remedies print are now ONE spelling, `_family_line`: the interpreter, this file's path from where the run stood, the verb, `--family-root ` and each value, every word quoted by `shell_quoted` for the reader's shell, as `init`'s NEXT STEPS' `add` already was (N2, N4). `member-unknown`'s `add` names the owner `--member` was typed with, else `family.yaml`'s `org:`, and leaves the `` blank only where neither is a `--member` `add` takes (N3). Both refusals said before `_checked_branch` now say the line runs on a branch other than the holder's tracking branch, with the `git switch -c` that makes one and BRANCH_LEFT (N1, and N1b: `member-already-present`'s `bump` was refused there too). A command holding a backtick is printed without backticks around it (N6). `member-already-present` prints the `remove` line beside the `bump` line (N7). The README sentence's list of `family.py` lines is tied to NAMED and to the verbs `_family_line` is called with (N5). New tests/test_family_remedy_lines.py types each line as printed, from the tracking branch, and the existing tests that pinned a bare `family.py` read the new lead. Closes #1127 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 166 ++++++-- tests/test_bump_remedies_family_root.py | 18 +- tests/test_family.py | 8 +- tests/test_family_add_member_id.py | 15 +- tests/test_family_bump_to_checks.py | 8 +- tests/test_family_printed_quoting.py | 11 + tests/test_family_remedy_lines.py | 373 ++++++++++++++++++ tests/test_readme_windows_quoting_sentence.py | 85 +++- tests/test_remove_add_remedies_family_root.py | 28 +- 9 files changed, 638 insertions(+), 74 deletions(-) create mode 100644 tests/test_family_remedy_lines.py diff --git a/scripts/family.py b/scripts/family.py index bea9abd4..ab41b42e 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -2276,15 +2276,95 @@ def _family_script() -> str: return str(here) +def _family_line(verb: str, holder: Path, *words: str) -> str: + """` --family-root `: ONE line + of this file's own, as `init`'s NEXT STEPS and every remedy that names a + verb print it, each value spelled by `shell_quoted` for the reader's + shell (#486, #1127). + + THE ONE SPELLING OF THESE LINES (#1127, RV1117's N2 and N4 on #1117). + `_add_command` printed the interpreter and this file's path from where + the person stands (`_family_script`); the remedies that named `bump`, + `remove` and `add` said a bare `family.py`, which runs only from + `scripts/` and only where the file is executable, and printed a row's + `project:` raw, two words where it held a space. A placeholder + (``, `/...`) is the caller's to add after the line, bare: + quoted, it would read as a value. + + THE VERB IS A STRING AT EVERY CALL, and + `tests/test_readme_windows_quoting_sentence.py` reads each one: README + lists the verbs whose lines are quoted for the shell they are printed + in, and a call naming a verb it does not list fails there.""" + return " ".join([PYTHON, shell_quoted(_family_script()), verb, + "--family-root", shell_quoted(holder), + *(shell_quoted(word) for word in words)]) + + +def _cited(command: str) -> str: + """`command` between backticks, as a remedy's prose names a command, or + bare where the command holds a backtick of its own (#1127, RV1117's + N6): a holder under a folder named with one closed the quotation inside + its path, so the text between the marks was half the command. Every + shell reads the line the same either way.""" + return command if "`" in command else f"`{command}`" + + +def _off_the_tracking_branch(family: "Family", refuses: str, + suggested: str) -> str: + """The clause a remedy puts before the line of this file it names, where + the refusal is said before `_checked_branch` asks the holder's branch + (#1127, RV1117's N1): `bump`'s `member-unknown`, and `add`'s + `member-already-present`. On the tracking branch, where a fresh clone + stands, the line that followed was refused `family-on-tracking-branch`. + So the clause says where the line runs and names the `switch -c` that + makes a branch, as the holder's `validate-family.py` does before the + `remove` its `member-duplicate-id` prints; the remedy ends with + `BRANCH_LEFT` (#607). In words true on any branch: the refusal is said + before the branch is read, and reading it here could refuse in its + place. `refuses` names the verb or verbs the branch is refused by and + says so: "`add` refuses", or "`bump` and `remove` refuse".""" + switch = _cited(_switch_command(family.root, suggested)) + return ("on a branch other than the holder's tracking branch, which " + f"{refuses} ({switch} makes one), ") + + def _add_command(holder: Path, org: str) -> str: """The `add` that grows the holder at `holder`, as `init` prints it: ONE line, so it pastes into PowerShell too, with this file's path, the holder's and the organisation each spelled by `shell_quoted` for the - reader's shell (#486). A holder under `my work/` used to print - `--family-root .../my work/Orbit`, two arguments to argparse. - `` is left for the person to fill in.""" - return (f"{PYTHON} {shell_quoted(_family_script())} add --family-root " - f"{shell_quoted(holder)} --member {shell_quoted(org)}/") + reader's shell (#486), by `_family_line`, as every remedy's line is + (#1127). A holder under `my work/` used to print `--family-root .../my + work/Orbit`, two arguments to argparse. `` is left for the + person to fill in.""" + return (f"{_family_line('add', holder)} --member " + f"{shell_quoted(org)}/") + + +def _unknown_members_repository(family: "Family", typed: str, + project: str) -> str: + """The `--member` value of the `add` that `bump`'s `member-unknown` + names: `/` with the owner filled in where it is known + (#1127, RV1117's N3), as ONE word for the reader's shell. + + THE OWNER, IN THIS ORDER: the one the person typed, where `bump + --member` was given as `/`; else `family.yaml`'s `org:`, + the organisation `init` made the holder in and its NEXT STEPS' `add` + names; else the blank ``, which is left only where neither is + there. The blank pasted unfilled is a redirection in every shell, not a + value: `family.py` never runs, and where a file named `org` is in the + working directory the shell tries to create the path after it. A member + may live in another organisation than the family's (`MEMBER_RE`), so + the family's is the likeliest owner, not a certain one; an `add` of a + repository that is not there refuses before it touches the holder. + Either owner is used only where `/` is a `--member` + `add` takes.""" + typed = typed.strip() + org = family.data.get("org") + for owner in (typed.rpartition("/")[0], org): + if isinstance(owner, str) and MEMBER_RE.fullmatch( + f"{owner}/{project}"): + return shell_quoted(f"{owner}/{project}") + return f"/{shell_quoted(project)}" def _standard_checkout(start: Path) -> Path | None: @@ -6454,17 +6534,25 @@ def _checked_add_target(family: Family, repository: str, project: str, """ held = family.holding(repository, project) if held is not None: - # THE `bump` IT NAMES RUNS AS PRINTED (#927): `bump` requires - # `--family-root`, so the line names this holder, spelled by - # `shell_quoted` as `_add_command`'s is. + # THE `bump` AND THE `remove` IT NAMES RUN AS PRINTED (#927, #1127), + # each a `_family_line` with this holder's `--family-root` and the + # row's own name, quoted. Said before `_checked_branch`, so a person + # on the tracking branch is told the branch both verbs need first. + name = str(held.get("project")) + suggested = _suggested_branch("bump", str(held.get("id") or name)) raise Refusal( "member-already-present", f"{project} is already a member of {family.data.get('name')}: " f"the row {held.get('project')} pins {held.get('repository')}", - "Remediation: `family.py bump --family-root " - f"{shell_quoted(family.root)} --member {held.get('project')} " - "--to ` moves an existing member; `remove` takes it " - "out. Adding it twice is not a way to move it.") + "Remediation: " + + _off_the_tracking_branch(family, "`bump` and `remove` refuse", + suggested) + + _cited(_family_line("bump", family.root, "--member", name) + + " --to ") + + " moves an existing member and " + + _cited(_family_line("remove", family.root, "--member", name)) + + " takes it out. Adding it twice is not a way to move it. " + + BRANCH_LEFT) # Everything the mount's `--force` would otherwise let past git, but the # cached store, which is asked below (#242). _checked_mount_path(family.root, project, path, @@ -6582,8 +6670,9 @@ def _checked_member_id(family: Family, repository: str, project: str, if row is not None: name = row["project"] # THE `remove` AND THE `bump` IT NAMES RUN AS PRINTED (#1005, #927), - # each with this holder's `--family-root`, which both verbs require, - # as `member-already-present`'s `bump` does. + # each a `_family_line` with this holder's `--family-root`, which + # both verbs require, and the row's own name, quoted (#1127). A real + # `add` asks this after `_checked_branch`, so no branch is named. raise Refusal( "member-duplicate-id", f"{project} is already a member of {family.data.get('name')} " @@ -6593,14 +6682,14 @@ def _checked_member_id(family: Family, repository: str, project: str, "project twice, which `validate-family.py` refuses", "Remediation: a family pins each project once. If " f"{repository} is {row.get('repository')} renamed at the forge, " - "`family.py remove --family-root " - f"{shell_quoted(family.root)} --member {name}` takes the old row " - "out and this `add` then pins it under the new name; `family.py " - f"bump --family-root {shell_quoted(family.root)} --member {name} " - "--to ` moves the row that is there instead. Two " - "projects that record one id are a fault in one of their " - "`project.yaml` files, mended in that project, not in the " - "family.") + + _cited(_family_line("remove", family.root, "--member", name)) + + " takes the old row out and this `add` then pins it under the " + "new name; " + + _cited(_family_line("bump", family.root, "--member", name) + + " --to ") + + " moves the row that is there instead. Two projects that " + "record one id are a fault in one of their `project.yaml` files, " + "mended in that project, not in the family.") def _probe_member(url: str, protocol: list[str], repository: str, @@ -8631,8 +8720,9 @@ def _checked_bumped_id(family: Family, row: dict, commit: str, return repository = row.get("repository") # THE `remove` IT NAMES RUNS AS PRINTED (#1005): `remove` requires - # `--family-root`, so the line names this holder, spelled by - # `shell_quoted` as `member-already-present`'s `bump` is (#927). + # `--family-root`, so the line is a `_family_line` naming this holder and + # the other row's own name, quoted, as every remedy's line is (#1127). A + # real `bump` asks this after `_checked_branch`, so no branch is named. raise Refusal( "member-duplicate-id", f"{repository} at {commit[:12]} is already a member of " @@ -8643,11 +8733,12 @@ def _checked_bumped_id(family: Family, row: dict, commit: str, "Remediation: a family pins each project once. Pass `--to` a commit " f"of {repository} whose project.yaml records an id no other row " f"records. If {repository} and {held.get('repository')} are one " - "project, `family.py remove --family-root " - f"{shell_quoted(family.root)} --member {held['project']}` takes the " - "other row out first. Two projects that record one id are a fault in " - "one of their `project.yaml` files, mended in that project, not in " - "the family.") + "project, " + + _cited(_family_line("remove", family.root, "--member", + held["project"])) + + " takes the other row out first. Two projects that record one id " + "are a fault in one of their `project.yaml` files, mended in that " + "project, not in the family.") def _head_oid(root: Path, path: str) -> str | None: @@ -9331,15 +9422,20 @@ def cmd_bump(args) -> int: row = family.member(project) if row is None: # THE `add` IT NAMES RUNS AS PRINTED (#1005): `add` requires - # `--family-root`, so the line names this holder, spelled by - # `shell_quoted` as `_add_command`'s is (#486). `` is left for - # the person to fill in: nothing here says whose repository it is. + # `--family-root`, so the line is a `_family_line` naming this + # holder (#1127), with the member's owner where it is known + # (`_unknown_members_repository`). Said before `_checked_branch`, so + # a person on the tracking branch is told the branch `add` needs. raise Refusal( "member-unknown", f"{project} is not a member of {family.data.get('name')}", - "Remediation: `family.py add --family-root " - f"{shell_quoted(family.root)} --member /{project}` puts it " - "in the family first.") + "Remediation: " + + _off_the_tracking_branch(family, "`add` refuses", + _suggested_branch("add", project)) + + _cited(_family_line("add", family.root) + " --member " + + _unknown_members_repository(family, args.member, + project)) + + " puts it in the family first. " + BRANCH_LEFT) project = str(row["project"]) commit = checked_value("--to", args.to).lower() if not COMMIT_RE.match(commit): diff --git a/tests/test_bump_remedies_family_root.py b/tests/test_bump_remedies_family_root.py index e7499657..7c61e760 100644 --- a/tests/test_bump_remedies_family_root.py +++ b/tests/test_bump_remedies_family_root.py @@ -48,7 +48,7 @@ from test_family import FAMILY, family, manifest # noqa: F401 from test_family_add_member_id import PINNED, RENAMED, renamed_copy from test_family_add_refusals import offline # noqa: F401 -from test_family_printed_quoting import copied_holder, host_words +from test_family_printed_quoting import copied_holder, family_lead, host_words #: A commit id to put where a line says `` or `` when nothing is #: run with it. It is NOT a fixture's `advanced`: that one pushes a branch to @@ -108,12 +108,14 @@ def refused_as(code, family, holder, offline, tmp_path): return result.stderr -def the_bump_line(stderr: str, lead: str = "") -> str: - """The one `family.py bump ...` a remedy prints, from `bump` to the - closing backtick, with `lead` before `family.py` (a path, for - `bump-leg.py`'s).""" - found = re.findall(re.escape(f"`{lead}family.py ") + r"(bump [^`]*)`", - stderr) +def the_bump_line(stderr: str, lead: str | None = None) -> str: + """The one `bump ...` a remedy prints, from `bump` to the closing + backtick. `lead` is what comes before `bump`: `bump-leg.py`'s + `scripts/family.py `, or by default `family.py`'s own, the interpreter + and the path to the file from where the run stood (`family_lead`, + #1127).""" + lead = family_lead() if lead is None else lead + found = re.findall(re.escape(f"`{lead}") + r"(bump [^`]*)`", stderr) assert len(found) == 1, stderr return found[0] @@ -218,7 +220,7 @@ def test_a_bump_leg_remedy_that_names_family_bump_names_the_holder( result = BUMP_LEG_CASES[code](project, scaffolded, tmp_path) assert result.returncode == 2, result.stdout + result.stderr assert f"REFUSED {code}: " in result.stderr - line = the_bump_line(result.stderr, "scripts/") + line = the_bump_line(result.stderr, "scripts/family.py ") assert line == "bump --family-root --member --to " # The holder as a shell is handed it: quoted, so that a basetemp whose # path holds a space (`--basetemp="my work/bt"`) is still one word. diff --git a/tests/test_family.py b/tests/test_family.py index c4e24926..a4c32b08 100644 --- a/tests/test_family.py +++ b/tests/test_family.py @@ -48,7 +48,7 @@ sys.path.insert(0, str(REPO / "scripts")) from repo_shape import ( # noqa: E402 - Refusal, load_yaml, shell_quoted, tree_digest, + PYTHON, Refusal, load_yaml, shell_quoted, tree_digest, ) from shape_materialize import ( # noqa: E402 SHAPE_REPOSITORY, readme_shape_lines, readme_trailing_shape_line, @@ -540,7 +540,11 @@ def test_add_refuses_a_spelling_of_a_member_the_family_already_pins( assert result.returncode == 2, result.stderr + result.stdout assert "REFUSED member-already-present" in result.stderr assert "the row IRRS pins InkRouter/IRRS" in result.stderr - assert ("`family.py bump --family-root " + # The line as every remedy prints one of `family.py`'s own: the + # interpreter and the path that reaches the file from where the run + # stood, which is this process's (#1127). + script = shell_quoted(os.path.relpath(FAMILY.resolve())) + assert (f"`{PYTHON} {script} bump --family-root " f"{shell_quoted(holder.resolve())} --member IRRS --to " "`") in result.stderr assert "mounting" not in result.stdout diff --git a/tests/test_family_add_member_id.py b/tests/test_family_add_member_id.py index f5eb1f39..244182f6 100644 --- a/tests/test_family_add_member_id.py +++ b/tests/test_family_add_member_id.py @@ -53,7 +53,7 @@ ) #: The shell's reading of a printed line on this host: the holder `bump` #: names is one quoted word of it, whatever its path holds (#927). -from test_family_printed_quoting import host_words +from test_family_printed_quoting import family_lead, host_words #: The project the renamed copy is of, and the name it is added under. PINNED = "IRRS" @@ -71,10 +71,10 @@ DUPLICATE_ID_REMEDIATION = ( f"Remediation: a family pins each project once. If {ORG}/{{who}} is " - f"{ORG}/{PINNED} renamed at the forge, `family.py remove --family-root " + f"{ORG}/{PINNED} renamed at the forge, `{{lead}}remove --family-root " "{holder} --member " f"{PINNED}` takes the old row out and this `add` then pins it under the " - "new name; `family.py bump --family-root {holder} --member " + "new name; `{lead}bump --family-root {holder} --member " f"{PINNED} --to ` moves the row that is there instead. Two " "projects that record one id are a fault in one of their " "`project.yaml` files, mended in that project, not in the family.") @@ -83,9 +83,11 @@ def duplicate_id_remediation(who: str, holder: Path) -> str: """What `add` tells the human to do about `member-duplicate-id`: the `remove` and the `bump` it names each carry `--family-root` and the - holder, as both verbs require (#927, #1005).""" + holder, as both verbs require (#927, #1005), and each begins with the + interpreter and the path to `family.py` from where the run stood + (#1127).""" return DUPLICATE_ID_REMEDIATION.format( - who=who, holder=shell_quoted(holder.resolve())) + who=who, holder=shell_quoted(holder.resolve()), lead=family_lead()) def renamed_copy(family, tmp_path: Path) -> Path: @@ -174,7 +176,8 @@ def test_each_command_the_refusal_names_runs_on_the_row_it_names( remotes = renamed_copy(family, tmp_path) refused = offline.add(holder, RENAMED, remotes=remotes) assert refused.returncode == 2, refused.stdout + refused.stderr - commands = re.findall(rf"`family\.py ({verb} [^`]*)`", refused.stderr) + commands = re.findall(rf"`{re.escape(family_lead())}({verb} [^`]*)`", + refused.stderr) assert len(commands) == 1, refused.stderr older = git("rev-parse", "main~1", cwd=family["remotes"] / f"{PINNED}.git").stdout.strip() diff --git a/tests/test_family_bump_to_checks.py b/tests/test_family_bump_to_checks.py index 59cb1121..ea27ca98 100644 --- a/tests/test_family_bump_to_checks.py +++ b/tests/test_family_bump_to_checks.py @@ -60,7 +60,7 @@ from test_family_bump_to_a_tag import annotated, both_runs, peeled_line from test_family_member_path import MEMBER, OTHER, older, raw #: The shell's reading of a printed line, and of a refusal's `switch`. -from test_family_printed_quoting import host_words +from test_family_printed_quoting import family_lead, host_words from test_family_tracking_branch import remedy_switch PATH = f"members/{MEMBER}" @@ -84,7 +84,8 @@ def duplicate_id_refusal(to: str, holder: Path) -> tuple[str, str]: """The two lines `bump --to ` is refused with when the member's `project.yaml` at `to` records the neighbour's id. The `remove` the remedy names carries `--family-root` and the holder, as `remove` - requires (#1005).""" + requires (#1005), after the interpreter and the path to `family.py` + from where the run stood (#1127).""" return ( f"REFUSED member-duplicate-id: {ORG}/{MEMBER} at {to[:12]} is " f"already a member of {NAME} under another name: its project.yaml " @@ -94,7 +95,8 @@ def duplicate_id_refusal(to: str, holder: Path) -> tuple[str, str]: "Remediation: a family pins each project once. Pass `--to` a commit " f"of {ORG}/{MEMBER} whose project.yaml records an id no other row " f"records. If {ORG}/{MEMBER} and {ORG}/{OTHER} are one project, " - f"`family.py remove --family-root {shell_quoted(holder.resolve())} " + f"`{family_lead()}remove --family-root " + f"{shell_quoted(holder.resolve())} " f"--member {OTHER}` takes the other row out first. " "Two projects that record one id are a fault in one of their " "`project.yaml` files, mended in that project, not in the family.") diff --git a/tests/test_family_printed_quoting.py b/tests/test_family_printed_quoting.py index 787fc46b..896e40ce 100644 --- a/tests/test_family_printed_quoting.py +++ b/tests/test_family_printed_quoting.py @@ -105,6 +105,17 @@ def host_words(line: str) -> list[str]: else shlex.split(line)) +def family_lead(cwd: Path | None = None) -> str: + """How a line of `family.py`'s own begins where the tool prints one from + `cwd` (this process's where None): the interpreter, then the path that + reaches the file from there as one word, each followed by a space. It is + how `init`'s NEXT STEPS print their `add` (#488), and how every remedy + that names a verb prints its line (#1127).""" + here = Path.cwd() if cwd is None else Path(cwd) + script = os.path.relpath(FAMILY.resolve(), here.resolve()) + return f"{PYTHON} {shell_quoted(script)} " + + # --- the quoter --------------------------------------------------------------- #: What must survive UNQUOTED, on both platforms: a line whose whole job is diff --git a/tests/test_family_remedy_lines.py b/tests/test_family_remedy_lines.py new file mode 100644 index 00000000..b2f2ee20 --- /dev/null +++ b/tests/test_family_remedy_lines.py @@ -0,0 +1,373 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The lines of `family.py`'s own that `bump`'s `member-unknown` and `add`'s +`member-already-present` print run as printed, from the branch a fresh clone +stands on (#1127, RV1117's follow-ups on #1117). + +RV1117's review of #1117 found seven things about these remedies, and each +is measured here as the remedy now prints it: + +* N1: on the holder's tracking branch, where `init` and a fresh clone leave + a person, the `add` that `member-unknown` names was refused + `family-on-tracking-branch`. So was the `bump` that `member-already-present` + names (found by measuring it): both refusals are said before + `_checked_branch` reads the branch. Each remedy now says the line runs on + a branch other than the tracking branch and names the `git switch -c` that + makes one, as the holder's `validate-family.py` says before its `remove`, + then `BRANCH_LEFT` (#607). Typed in order from the tracking branch, the + switch, then the line, each act goes through; the line alone is refused. +* N2: the lines began with a bare `family.py`, which runs only from + `scripts/`. Each now begins as `init`'s NEXT STEPS' `add` does: the + interpreter, then the path that reaches the file from where the refused + run stood, and is typed here from that folder by that path. +* N3: `member-unknown`'s `add` left `` blank where `family.yaml`'s + `org:` is known, a redirection in every shell when pasted. The owner is + now the one `--member` was typed with, else the family's `org:`, else the + blank, each only where `/` is a `--member` `add` takes. +* N4: the row's `--member` name was printed raw: a row `IR RS` was two + words. It is now one, quoted for the reader's shell. +* N6: a holder under a folder whose name holds a backtick closed the + remedy's backticks inside its path. A command that holds one is now + printed bare. +* N7: `member-already-present` said "`remove` takes it out" with no line to + type. It now prints the `remove` line beside the `bump` line, and both are + typed here. + +N5, the README sentence's list of verbs tied to `NAMED`, is +`tests/test_readme_windows_quoting_sentence.py`'s. The holder is +`tests/test_family.py`'s family, copied and put back on its tracking branch +as `tests/test_family_tracking_branch.py` does; its spare member `IRQS` is +in its remotes and not in the holder. No network: `gh` is a shim that exits +non-zero. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from conftest import git, run_script +from repo_shape import PYTHON, shell_quoted +#: The family fixture and its names, the offline environment and the +#: helpers of the tests that already trigger these codes, imported from the +#: modules that own them rather than rebuilt. +from test_family import ( # noqa: F401 + FAMILY, MEMBERS, NAME, ORG, SPARE, family, manifest, validate, +) +from test_family_add_refusals import offline, refusal # noqa: F401 +from test_family_dry_run import isolated +from test_family_printed_quoting import copied_holder, family_lead, host_words +from test_family_tracking_branch import TRACKING, older_commit +#: The sentence every remedy that names a `switch -c` ends with (#607). +from test_validate_family_branch_step import BRANCH_LEFT + +#: A commit id for a `--to` that is never read: `member-unknown` is said +#: before `--to` is. +COMMIT = "0123456789abcdef0123456789abcdef01234567" + +#: The pinned member `add` is refused for, by the row's name. +PINNED = MEMBERS[0] + +#: What the remedies say before the line of `family.py` they name, with the +#: verbs the tracking branch is refused by. +OFF_TRACKING = ("on a branch other than the holder's tracking branch, which " + "{refuses} ({switch} makes one), ") + + +def on_tracking(family, base: Path) -> Path: + """A private copy of the holder under `base`, back on its tracking + branch with every member the fixture added: where `init` leaves a + person, and where a fresh clone stands.""" + holder = copied_holder(family, base) + git("switch", "-q", "-C", TRACKING, cwd=holder) + assert git("status", "--porcelain", cwd=holder).stdout == "" + return holder + + +@pytest.fixture +def stand(tmp_path) -> Path: + """The folder a person runs the refused command from, and types its + remedy in: not the holder's, and not the standard's, with a space in its + name.""" + folder = tmp_path / "my desk" + folder.mkdir() + return folder.resolve() + + +def cited(command: str, ticked: bool) -> str: + return f"`{command}`" if ticked else command + + +def switch_line(holder: Path, branch: str) -> str: + return f"git -C {shell_quoted(holder)} switch -c {branch}" + + +def family_line(lead: str, verb: str, holder: Path, member: str) -> str: + """` --family-root --member `, where + `member` is already spelled for the shell.""" + return (f"{lead}{verb} --family-root {shell_quoted(holder)} " + f"--member {member}") + + +def unknown_remediation(holder: Path, lead: str, ticked: bool = True) -> str: + """What `bump --member IRQS` is told: make a branch, then the `add` that + puts IRQS in the family, owned by the family's `org:`.""" + add = family_line(lead, "add", holder, shell_quoted(f"{ORG}/{SPARE}")) + switch = switch_line(holder, f"family/add-{SPARE.lower()}") + clause = OFF_TRACKING.format(refuses="`add` refuses", + switch=cited(switch, ticked)) + return (f"Remediation: {clause}{cited(add, ticked)} puts it in the " + f"family first. {BRANCH_LEFT}") + + +def present_remediation(holder: Path, lead: str, ticked: bool = True) -> str: + """What `add` of the member the holder already pins is told: make a + branch, then the `bump` that moves the row, or the `remove` that takes + it out.""" + name = shell_quoted(PINNED) + bump = family_line(lead, "bump", holder, name) + " --to " + remove = family_line(lead, "remove", holder, name) + switch = switch_line(holder, f"family/bump-{PINNED.lower()}") + clause = OFF_TRACKING.format(refuses="`bump` and `remove` refuse", + switch=cited(switch, ticked)) + return (f"Remediation: {clause}{cited(bump, ticked)} moves an existing " + f"member and {cited(remove, ticked)} takes it out. Adding it " + f"twice is not a way to move it. {BRANCH_LEFT}") + + +def bump_unknown(holder: Path, stand: Path, env: dict, member: str = SPARE): + """`bump` of a member the holder does not pin, typed in `stand`.""" + return run_script(FAMILY, "bump", "--family-root", str(holder), + "--member", member, "--to", COMMIT, cwd=stand, env=env) + + +def add_present(family, holder: Path, stand: Path, env: dict): + """`add` of the member the holder already pins, typed in `stand`.""" + return run_script(FAMILY, "add", "--family-root", str(holder), + "--member", f"{ORG}/{PINNED}", "--local-remote-dir", + str(family["remotes"]), cwd=stand, env=env) + + +def typed(line: str, stand: Path, env: dict, *extra: str): + """`line` typed in `stand` as the reader's shell reads it: the + interpreter it names, and the script by the path it names from there.""" + words = host_words(line) + assert words[0] == PYTHON, line + return run_script(Path(words[1]), *words[2:], *extra, cwd=stand, env=env) + + +def switched(line: str, stand: Path) -> None: + """The remedy's `git switch -c`, typed in `stand` as the shell reads + it.""" + words = host_words(line) + assert words[:2] == ["git", "-C"], line + git(*words[1:], cwd=stand) + + +def branch_of(holder: Path) -> str: + return git("symbolic-ref", "--short", "HEAD", cwd=holder).stdout.strip() + + +def rows_of(holder: Path) -> dict: + return {row["project"]: row for row in manifest(holder)["members"]} + + +# --- N1, N2: member-unknown's `add`, from the tracking branch ---------------- + +def test_member_unknowns_add_runs_as_printed_from_the_tracking_branch( + family, offline, stand, tmp_path): + """The remediation, word for word: the branch first, then the `add` by + the interpreter and the path from where the refused `bump` stood, the + owner filled in. Typed alone on the tracking branch the `add` is refused + there, as the remedy says; typed after its `switch -c`, from the same + folder, it puts IRQS in the family on that branch, and the holder is + clean and its gate green.""" + env = isolated(offline, tmp_path) + holder = on_tracking(family, tmp_path / "holders") + lead = family_lead(stand) + refused = bump_unknown(holder, stand, env) + assert refused.returncode == 2, refused.stdout + refused.stderr + assert refusal(refused.stderr) == ( + f"REFUSED member-unknown: {SPARE} is not a member of {NAME}", + unknown_remediation(holder, lead)) + switch = switch_line(holder, f"family/add-{SPARE.lower()}") + add = family_line(lead, "add", holder, shell_quoted(f"{ORG}/{SPARE}")) + remotes = ("--local-remote-dir", str(family["remotes"])) + + alone = typed(add, stand, env, *remotes) + assert alone.returncode == 2, alone.stdout + alone.stderr + assert alone.stderr.startswith("REFUSED family-on-tracking-branch: ") + assert SPARE not in rows_of(holder) + + switched(switch, stand) + done = typed(add, stand, env, *remotes) + assert done.returncode == 0, done.stdout + done.stderr + assert branch_of(holder) == f"family/add-{SPARE.lower()}" + rows = rows_of(holder) + assert sorted(rows) == sorted([*MEMBERS, SPARE]) + assert rows[SPARE]["repository"] == f"{ORG}/{SPARE}" + assert git("status", "--porcelain", cwd=holder).stdout == "" + assert validate(holder).returncode == 0 + + +# --- N1b, N2, N7: member-already-present's `bump` and `remove` --------------- + +@pytest.mark.parametrize("verb", ["bump", "remove"]) +def test_member_already_presents_lines_run_as_printed_from_the_tracking_branch( + family, offline, stand, tmp_path, verb): + """The remediation, word for word: the branch first, then a `bump` line + and a `remove` line, each by the interpreter and the path from where the + refused `add` stood. Typed alone on the tracking branch each is refused + there; typed after the `switch -c`, `bump` (its `` filled in) + moves the row's pin and `remove` takes the row out, and the holder is + clean and its gate green.""" + env = isolated(offline, tmp_path) + holder = on_tracking(family, tmp_path / "holders") + lead = family_lead(stand) + refused = add_present(family, holder, stand, env) + assert refused.returncode == 2, refused.stdout + refused.stderr + assert refusal(refused.stderr) == ( + f"REFUSED member-already-present: {PINNED} is already a member of " + f"{NAME}: the row {PINNED} pins {ORG}/{PINNED}", + present_remediation(holder, lead)) + line = family_line(lead, verb, holder, PINNED) + older = older_commit(family, PINNED) + if verb == "bump": + line += f" --to {older}" + before = rows_of(holder) + + alone = typed(line, stand, env) + assert alone.returncode == 2, alone.stdout + alone.stderr + assert alone.stderr.startswith("REFUSED family-on-tracking-branch: ") + assert rows_of(holder) == before + + switched(switch_line(holder, f"family/bump-{PINNED.lower()}"), stand) + done = typed(line, stand, env) + assert done.returncode == 0, done.stdout + done.stderr + assert branch_of(holder) == f"family/bump-{PINNED.lower()}" + rows = rows_of(holder) + if verb == "bump": + assert sorted(rows) == sorted(MEMBERS) + assert rows[PINNED]["pin"]["commit"] == older + else: + assert sorted(rows) == sorted(MEMBERS[1:]) + assert git("status", "--porcelain", cwd=holder).stdout == "" + assert validate(holder).returncode == 0 + + +# --- N3: the owner of member-unknown's `add` --------------------------------- + +def without_org(text: str) -> str: + edited = re.sub(r"(?m)^org: .*\n", "", text, count=1) + assert edited != text, "premise: family.yaml has an `org:` line" + return edited + + +def org_of(value: str): + """An edit of `family.yaml` that gives `org:` the YAML `value`.""" + def edit(text: str) -> str: + edited = re.sub(r"(?m)^org: .*$", f"org: {value}", text, count=1) + assert edited != text, "premise: family.yaml has an `org:` line" + return edited + return edit + + +#: `(--member as typed, an edit of family.yaml, the --member the line names)`: +#: the typed owner first, then the family's `org:`, then the blank, each +#: only where `/` is a `--member` `add` takes. +OWNERS = { + "the-familys-org": (SPARE, None, shell_quoted(f"{ORG}/{SPARE}")), + "the-typed-owner": (f"OtherOrg/{SPARE}", None, + shell_quoted(f"OtherOrg/{SPARE}")), + "a-typed-owner-add-refuses": (f"_Other/{SPARE}", None, + shell_quoted(f"{ORG}/{SPARE}")), + "no-org": (SPARE, without_org, f"/{shell_quoted(SPARE)}"), + "an-empty-org": (SPARE, org_of('""'), f"/{shell_quoted(SPARE)}"), + "an-org-add-refuses": (SPARE, org_of('"Ink Router"'), + f"/{shell_quoted(SPARE)}"), + "no-org-but-typed": (f"OtherOrg/{SPARE}", without_org, + shell_quoted(f"OtherOrg/{SPARE}")), +} + + +@pytest.mark.parametrize("typed_member, edit, expected", OWNERS.values(), + ids=OWNERS) +def test_member_unknowns_add_names_the_owner_where_it_is_known( + family, offline, stand, tmp_path, typed_member, edit, expected): + """The `--member` of the `add` line: `` is a blank only where + neither the typed `--member` nor `family.yaml` gives an owner `add` + takes, and the value is one word for the reader's shell.""" + env = isolated(offline, tmp_path) + holder = on_tracking(family, tmp_path / "holders") + if edit is not None: + path = holder / "family.yaml" + path.write_text(edit(path.read_text(encoding="utf-8")), + encoding="utf-8") + refused = bump_unknown(holder, stand, env, typed_member) + assert refused.returncode == 2, refused.stdout + refused.stderr + assert refused.stderr.startswith("REFUSED member-unknown: ") + lead = family_lead(stand) + found = re.findall(rf"`{re.escape(lead)}(add [^`]*)`", refused.stderr) + assert found == [f"add --family-root {shell_quoted(holder)} --member " + f"{expected}"] + + +# --- N4: a row's name is one word -------------------------------------------- + +def test_a_rows_name_with_a_space_is_one_word_in_both_lines( + family, offline, stand, tmp_path): + """A row whose `project:` holds a space, edited by hand and committed: + `add` of the repository it pins names it in its `bump` and `remove` + lines as ONE word, the row's name whole.""" + env = isolated(offline, tmp_path) + holder = on_tracking(family, tmp_path / "holders") + path = holder / "family.yaml" + text = path.read_text(encoding="utf-8") + edited = re.sub(rf'(?m)^( - project: )"?{PINNED}"?$', r'\1"IR RS"', + text, count=1) + assert edited != text, "premise: the row's `project:` line" + path.write_text(edited, encoding="utf-8") + git("commit", "-q", "-m", "A row named by hand", "--", "family.yaml", + cwd=holder) + refused = add_present(family, holder, stand, env) + assert refused.returncode == 2, refused.stdout + refused.stderr + assert refused.stderr.startswith("REFUSED member-already-present: ") + lead = family_lead(stand) + lines = re.findall(rf"`{re.escape(lead)}((?:bump|remove) [^`]*)`", + refused.stderr) + assert [host_words(line) for line in lines] == [ + ["bump", "--family-root", str(holder), "--member", "IR RS", "--to", + ""], + ["remove", "--family-root", str(holder), "--member", "IR RS"]] + + +# --- N6: a holder under a folder named with a backtick ----------------------- + +def test_a_command_holding_a_backtick_is_printed_bare( + family, offline, stand, tmp_path): + """Under `a`b/`, every command each remedy names holds the backtick, so + each is printed bare and the prose around it is the same; the `add` + `member-unknown` names, typed after its switch, still puts IRQS in the + family.""" + env = isolated(offline, tmp_path) + holder = on_tracking(family, tmp_path / "a`b") + assert "`" in str(holder), "premise: the holder's path holds a backtick" + lead = family_lead(stand) + + present = add_present(family, holder, stand, env) + assert present.returncode == 2, present.stdout + present.stderr + assert refusal(present.stderr)[1] == present_remediation( + holder, lead, ticked=False) + + unknown = bump_unknown(holder, stand, env) + assert unknown.returncode == 2, unknown.stdout + unknown.stderr + assert refusal(unknown.stderr)[1] == unknown_remediation( + holder, lead, ticked=False) + switched(switch_line(holder, f"family/add-{SPARE.lower()}"), stand) + done = typed(family_line(lead, "add", holder, + shell_quoted(f"{ORG}/{SPARE}")), + stand, env, "--local-remote-dir", str(family["remotes"])) + assert done.returncode == 0, done.stdout + done.stderr + assert SPARE in rows_of(holder) + assert git("status", "--porcelain", cwd=holder).stdout == "" diff --git a/tests/test_readme_windows_quoting_sentence.py b/tests/test_readme_windows_quoting_sentence.py index 695c2803..23b18c61 100644 --- a/tests/test_readme_windows_quoting_sentence.py +++ b/tests/test_readme_windows_quoting_sentence.py @@ -66,18 +66,32 @@ "scripts/family.py": { "_print_next": 1, # the `NEXT` line: `git push -u origin ...` "_switch_command": 1, # the `git switch -c ` line - "_add_command": 3, # the `family.py add --family-root ...` line + # every ` family.py --family-root ...` line (#1127) + "_family_line": 3, + "_add_command": 1, # `init`'s `add`: its `--member /...` + # `bump`'s `member-unknown`: its `add` line's `--member` (#1127) + "_unknown_members_repository": 2, "_print_next_steps": 2, # `init`'s block: its `switch -c` and `cd` "_checked_mount_path": 1, # the `git rm -r --cached -- ` remedy - "_checked_add_target": 1, # `member-already-present`'s `bump` line - # `add`'s `member-duplicate-id`: its `bump` line (#927) and its - # `remove` line (#1005) - "_checked_member_id": 2, - "_checked_bumped_id": 1, # `bump`'s `member-duplicate-id`: `remove` - "cmd_bump": 1, # `bump`'s `member-unknown`: its `add` line }, } +#: The kinds of line README's sentence lists for `family.py`, in its words, +#: and the functions of NAMED that print each (#1127, RV1117's N5 on #1117): +#: the sentence's list was tied to nothing, so a verb dropped from both docs +#: passed. `_family_line` prints every line of `family.py`'s own that a +#: remedy names, so its kinds are the verbs its calls name +#: (`family_line_verbs`), read from the source. +FAMILY_LINES = { + "_print_next": {"NEXT"}, + "_switch_command": {"switch -c"}, + "_family_line": None, + "_add_command": {"add"}, + "_unknown_members_repository": {"add"}, + "_print_next_steps": {"switch -c", "cd"}, + "_checked_mount_path": {"git rm -r --cached"}, +} + #: The quoters' own home is not a tool, and only these two bodies are what the #: sentence need not name: `shell_line` spells each word by `shell_quoted`, and #: `shell_quoted` is the host-dialect rule itself. Any other function of @@ -344,6 +358,63 @@ def test_the_functions_that_quote_for_the_host_are_the_ones_the_sentence_names( f"{NAMED[tool]}: change the sentence in both places, then NAMED here.") +def sentence_lists_for_family_py() -> set: + """The kinds of line README's sentence names for `family.py`, read from + README as written, its backticks kept: "`family.py`'s `NEXT`, ..., `bump` + and `remove` lines".""" + text = squashed((REPO / "README.md").read_text(encoding="utf-8")) + listed = re.search(r"`family\.py`'s (.*?) lines are quoted for the shell", + text) + assert listed is not None, "README names no `family.py` lines" + return set(re.findall(r"`([^`]+)`", listed.group(1))) + + +def family_line_verbs(source: str) -> list: + """The first argument of each call of `_family_line` in `source`: the + verb of the line it prints, or None where that is not a string constant, + which nobody can read without running the tool.""" + return [call.args[0].value + if call.args and isinstance(call.args[0], ast.Constant) + and isinstance(call.args[0].value, str) else None + for call in ast.walk(ast.parse(source)) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Name) + and call.func.id == "_family_line"] + + +def family_line_kinds(source: str) -> set: + """Every kind of line `FAMILY_LINES` says the functions of `family.py` + print, `_family_line`'s read from `source`.""" + kinds = set() + for function, said in FAMILY_LINES.items(): + kinds |= set(family_line_verbs(source)) if said is None else said + return kinds + + +def test_the_kinds_of_family_lines_are_the_ones_the_sentence_lists(): + """README lists the kinds of `family.py` line that are quoted for the + shell they are printed in, and NAMED pins the functions that print them: + each function's kinds, `_family_line`'s from the verb each call names, + are the sentence's list, no more and no fewer (#1127). A verb dropped + from the sentence, or a line of a new verb, fails here.""" + source = (REPO / "scripts" / "family.py").read_text(encoding="utf-8") + assert set(FAMILY_LINES) == set(NAMED["scripts/family.py"]) + verbs = family_line_verbs(source) + assert verbs, "`family.py` calls `_family_line` nowhere" + assert None not in verbs, verbs + assert family_line_kinds(source) == sentence_lists_for_family_py() + + +def test_the_kinds_follow_the_verbs_family_line_is_called_with(): + """`family_line_verbs` reads each call's verb, and a verb no one can read + is None, which the test of the kinds refuses.""" + source = ("def f(h, v):\n _family_line('bump', h)\n" + " _family_line(v, h)\n other('add', h)\n") + assert family_line_verbs(source) == ["bump", None] + assert "siblings" in family_line_kinds( + "def f(h):\n return _family_line('siblings', h)\n") + + def test_a_quoter_imported_under_another_name_is_a_host_call(): source = ("from repo_shape import shell_line as joined, shell_quoted\n" "spelled = joined\n" diff --git a/tests/test_remove_add_remedies_family_root.py b/tests/test_remove_add_remedies_family_root.py index adfd0ad5..5d052782 100644 --- a/tests/test_remove_add_remedies_family_root.py +++ b/tests/test_remove_add_remedies_family_root.py @@ -13,9 +13,8 @@ row out first. Both KNOW the holder, so each names it, in the one word `shell_quoted` makes of its path, as #927's `bump` line does. * `bump`'s `member-unknown`, which says `add` puts the member in the family - first. It names the holder the same way, and leaves `` for the person - to fill in, as the verbs' usage lines do: nothing there says whose - repository the project is. + first. It names the holder the same way, and the owner `family.yaml`'s + `org:` names (#1127; `tests/test_family_remedy_lines.py` has the rest). Each case is measured two ways, over a holder in a plain folder, under two with a space in their names (one of them with an apostrophe too: the paths a @@ -60,7 +59,7 @@ from test_family_bump_to_checks import records_the_neighbours_id from test_family_dry_run import commit_in_mount, isolated from test_family_member_path import MEMBER, OTHER -from test_family_printed_quoting import copied_holder, host_words +from test_family_printed_quoting import copied_holder, family_lead, host_words #: A commit id for a `--to` that is never read: `member-unknown` is said #: before `--to` is. @@ -208,8 +207,8 @@ def mend_bump_unknown(reach, family, env, kept): lambda line, kept: line, lambda family, kept: [], mend_bump_duplicate_id), "bump-member-unknown": Case( - "member-unknown", "add", f"/{SPARE}", refuse_bump_unknown, - lambda line, kept: line.replace("", ORG), + "member-unknown", "add", f"{ORG}/{SPARE}", refuse_bump_unknown, + lambda line, kept: line, # `add` reads the member from the family's own bare repositories, # as every `add` in this suite does: nothing here reaches a forge. lambda family, kept: ["--local-remote-dir", str(family["remotes"])], @@ -217,11 +216,14 @@ def mend_bump_unknown(reach, family, env, kept): } -def the_line(stderr: str, case: Case) -> str: - """The one `family.py ...` the refusal prints, from the verb to - the closing backtick, and the refusal checked to be the case's code.""" +def the_line(stderr: str, case: Case, reach: Reach) -> str: + """The one ` ...` the refusal prints, from the verb to the closing + backtick, after the interpreter and the path to `family.py` from where + the refused run stood (#1127), and the refusal checked to be the case's + code.""" assert f"REFUSED {case.code}: " in stderr, stderr - found = re.findall(rf"`family\.py ({case.verb} [^`]*)`", stderr) + lead = re.escape(f"`{family_lead(reach.cwd)}") + found = re.findall(rf"{lead}({case.verb} [^`]*)`", stderr) assert len(found) == 1, stderr return found[0] @@ -240,7 +242,7 @@ def test_the_line_a_remedy_names_carries_the_holder_whole( refused run was pointed at it.""" stderr, kept = case.refuse(reach, family, isolated(offline, tmp_path), tmp_path) - line = the_line(stderr, case) + line = the_line(stderr, case, reach) assert line == expected(case, reach) assert host_words(line) == [case.verb, "--family-root", str(reach.holder), "--member", case.member] @@ -253,7 +255,7 @@ def test_the_line_a_remedy_names_parses( words are accepted: no `the following arguments are required`.""" stderr, kept = case.refuse(reach, family, isolated(offline, tmp_path), tmp_path) - words = host_words(case.fill(the_line(stderr, case), kept)) + words = host_words(case.fill(the_line(stderr, case, reach), kept)) capsys.readouterr() args = parsed(family_module, words, capsys) assert args.family_root == str(reach.holder) @@ -270,7 +272,7 @@ def test_the_line_a_remedy_names_runs_as_printed_and_does_what_it_says( is in the family: whichever the remedy says it is for.""" env = isolated(offline, tmp_path) stderr, kept = case.refuse(reach, family, env, tmp_path) - words = host_words(case.fill(the_line(stderr, case), kept)) + words = host_words(case.fill(the_line(stderr, case, reach), kept)) done = run_script(FAMILY, *words, *case.extra(family, kept), cwd=elsewhere, env=env) assert "the following arguments are required" not in done.stderr From b54520cea716b44df1a95139a46901a478d616fd Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 24 Oct 2026 14:31:46 +0000 Subject: [PATCH 3/4] family.py: the dry runs' `member-duplicate-id` remedies and `init`'s `family-holder-already-here` say the branch their lines run on, and that `add` line is `_cited`, so each runs as printed from the tracking branch (#1228) Three lines a remedy printed were reached with no branch read, named none, and were refused `family-on-tracking-branch` when typed where a fresh clone stands (lane 5's review of #1201, SHOULD-1): - `add --dry-run` skips `_checked_branch` and still asks `_checked_member_id`; `bump --dry-run` asks `_checked_bumped_id` before `_branch_said`. Both now take a keyword-only `dry_run` flag, false by default, so the real runs' asks (`_mount_and_pin`, `_checked_bump_target`, `_bump_and_pin`) and their words are unchanged. Set, the remedy puts `_off_the_tracking_branch` before its line or lines, naming the branch the `remove` line's own refusal names (`family/remove-`), and ends with `BRANCH_LEFT`, as `member-already-present` does. - `init`'s `family-holder-already-here` printed `_add_command`'s line between plain backticks. It now comes after `init`'s own branch, `git -C switch -c family/add-`, in the same clause, and through `_cited`, so a holder under a folder named with a backtick prints it whole. The switch line is `_add_branch_switch`, shared with the NEXT STEPS block (same bytes there), and the clause's words are `_branch_clause`, which `_off_the_tracking_branch` now calls. The quoting test's `NAMED` moves one host-quoter call from `_print_next_steps` to `_add_branch_switch`; README's sentence already lists `switch -c`. Rows: each dry run's lines and `init`'s `add` are typed as printed from the tracking branch: alone they are refused there, after the `switch -c` they go through. The two `both_runs` rows of `member-duplicate-id` and `add`'s dry-run parity case now pin the real run's words as before and the dry run's with the clause. NIT-1: the row with a space in its name asserts the `switch -c` line, which kills the branch-from-name mutant. NIT-2: `typed()` says it asserts the interpreter and does not look it up. Closes #1228 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 125 ++++++--- tests/test_family_bump_to_checks.py | 62 ++++- tests/test_family_dry_run.py | 51 +++- tests/test_family_remedy_lines.py | 253 +++++++++++++++++- tests/test_readme_windows_quoting_sentence.py | 8 +- 5 files changed, 442 insertions(+), 57 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index ab41b42e..3671b88c 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -2313,19 +2313,41 @@ def _off_the_tracking_branch(family: "Family", refuses: str, suggested: str) -> str: """The clause a remedy puts before the line of this file it names, where the refusal is said before `_checked_branch` asks the holder's branch - (#1127, RV1117's N1): `bump`'s `member-unknown`, and `add`'s - `member-already-present`. On the tracking branch, where a fresh clone - stands, the line that followed was refused `family-on-tracking-branch`. - So the clause says where the line runs and names the `switch -c` that - makes a branch, as the holder's `validate-family.py` does before the - `remove` its `member-duplicate-id` prints; the remedy ends with - `BRANCH_LEFT` (#607). In words true on any branch: the refusal is said - before the branch is read, and reading it here could refuse in its - place. `refuses` names the verb or verbs the branch is refused by and - says so: "`add` refuses", or "`bump` and `remove` refuse".""" - switch = _cited(_switch_command(family.root, suggested)) + (#1127, RV1117's N1): `bump`'s `member-unknown` and `add`'s + `member-already-present` in either run, and the two + `member-duplicate-id` remedies in a dry run, which asks no branch + (#1228; a real `add` or `bump` asks them after `_checked_branch`). On + the tracking branch, where a fresh clone stands, the line that followed + was refused `family-on-tracking-branch`. So the clause says where the + line runs and names the `switch -c` that makes a branch, as the + holder's `validate-family.py` does before the `remove` its + `member-duplicate-id` prints; the remedy ends with `BRANCH_LEFT` + (#607). In words true on any branch: the refusal is said before the + branch is read, and reading it here could refuse in its place. + `refuses` names the verb or verbs the branch is refused by and says so: + "`add` refuses", or "`bump` and `remove` refuse".""" + return _branch_clause(_switch_command(family.root, suggested), refuses) + + +def _branch_clause(switch: str, refuses: str) -> str: + """`_off_the_tracking_branch`'s words, around a `switch -c` line already + spelled: `init`'s `family-holder-already-here` names a holder it has + not opened, by its path, and its branch is `init`'s placeholder + (`_add_branch_switch`), which `_switch_command` would quote (#1228).""" return ("on a branch other than the holder's tracking branch, which " - f"{refuses} ({switch} makes one), ") + f"{refuses} ({_cited(switch)} makes one), ") + + +def _add_branch_switch(holder: Path) -> str: + """`git -C switch -c family/add-`: the branch `init` + says to make before the `add` it prints, in its NEXT STEPS block and in + the remedy of `family-holder-already-here` (#487, #1228). The holder is + spelled by `shell_quoted` for the reader's shell (#486). The branch is + left bare: its `` is the placeholder the `--member` of that + `add` says (#607), and quoted it would read as a value (`_family_line`). + `add`'s own refusal names the branch lowercased.""" + return (shell_line(["git", "-C", holder, "switch", "-c"]) + + " family/add-") def _add_command(holder: Path, org: str) -> str: @@ -2494,15 +2516,24 @@ def _landing(args, family: str) -> tuple[Path | None, Path]: # names it (#609's rule: a refusal by name, never a traceback). The two # paths are named as `shown_path` names a path (#766): `--into`, or the # directory the person stands in, can hold an ESC or a line break. + # THE `add` THE REMEDY NAMES RUNS AS PRINTED (#1228): after the branch + # it needs, as `init`'s NEXT STEPS print the two, since a holder `init` + # made stands on its tracking branch, where `add` refuses; and `_cited`, + # so under a folder named with a backtick the line is printed whole + # (#1127, RV1117's N6). if into.name == family and os.path.isfile(into / family / MANIFEST): raise Refusal( "family-holder-already-here", f"{shown_path(into)} is already the {family} family folder and " f"its holder is at {shown_path(into / family)}, so there is " "nothing to init", - f"Remediation: `{_add_command(into / family, args.org)}` grows " - "it, and `make siblings` in the holder puts each member's " - "working clone beside it. There is no --force.") + "Remediation: " + + _branch_clause(_add_branch_switch(into / family), + "`add` refuses") + + _cited(_add_command(into / family, args.org)) + + " grows it, and `make siblings` in the holder puts each " + "member's working clone beside it. There is no --force. " + + BRANCH_LEFT) folder, holder = ((into, into / family) if into.name == family else (into / family, into / family / family)) if args.into is None: @@ -3494,7 +3525,6 @@ def _print_next_steps(args, work: Path, folder: Path | None) -> None: clones anything in a holder whose parent folder is not named after the family (#543), so there the block says so instead of promising clones. """ - switch = shell_line(["git", "-C", work, "switch", "-c"]) beside = ("`make siblings` then puts each member's WORKING clone\n" "beside the holder, in the family folder, on its tracking " "branch.") @@ -3510,7 +3540,7 @@ def _print_next_steps(args, work: Path, folder: Path | None) -> None: print(f""" NEXT STEPS - {switch} family/add- + {_add_branch_switch(work)} {_add_command(work, args.org)} cd {shell_quoted(work)} make siblings @@ -6640,7 +6670,7 @@ def _row_recording(family: Family, project_id: str, def _checked_member_id(family: Family, repository: str, project: str, - project_id: str) -> None: + project_id: str, *, dry_run: bool = False) -> None: """Refused when a row already records the `id` the member's own `project.yaml` does (#330): the project is a member under another name. @@ -6665,6 +6695,11 @@ def _checked_member_id(family: Family, repository: str, project: str, and nothing it would pass. The validator keeps its own spelling of the rule, as it keeps `member-path`'s: it ships INTO every holder, and a shared copy would move every holder's pin. + + `dry_run` IS `add --dry-run`'s ASK (#1228), which reads no branch: its + remedy says the branch its two lines need, as `member-already-present` + says it. A real `add` asks this after `_checked_branch`, so its remedy + names none. """ row = _row_recording(family, project_id) if row is not None: @@ -6672,7 +6707,16 @@ def _checked_member_id(family: Family, repository: str, project: str, # THE `remove` AND THE `bump` IT NAMES RUN AS PRINTED (#1005, #927), # each a `_family_line` with this holder's `--family-root`, which # both verbs require, and the row's own name, quoted (#1127). A real - # `add` asks this after `_checked_branch`, so no branch is named. + # `add` asks this after `_checked_branch`, which refuses the + # tracking branch, so no branch is named. A dry run asks it before + # the branch is read, so it names the branch first: the one the + # `remove` line's own refusal would name, and `BRANCH_LEFT` (#1228). + branch, left = "", "" + if dry_run: + branch = _off_the_tracking_branch( + family, "`bump` and `remove` refuse", + _suggested_branch("remove", str(row.get("id") or name))) + left = " " + BRANCH_LEFT raise Refusal( "member-duplicate-id", f"{project} is already a member of {family.data.get('name')} " @@ -6682,6 +6726,7 @@ def _checked_member_id(family: Family, repository: str, project: str, "project twice, which `validate-family.py` refuses", "Remediation: a family pins each project once. If " f"{repository} is {row.get('repository')} renamed at the forge, " + + branch + _cited(_family_line("remove", family.root, "--member", name)) + " takes the old row out and this `add` then pins it under the " "new name; " @@ -6689,7 +6734,7 @@ def _checked_member_id(family: Family, repository: str, project: str, + " --to ") + " moves the row that is there instead. Two projects that " "record one id are a fault in one of their `project.yaml` files, " - "mended in that project, not in the family.") + "mended in that project, not in the family." + left) def _probe_member(url: str, protocol: list[str], repository: str, @@ -7603,10 +7648,13 @@ def cmd_add(args) -> int: print(UNREADABLE_HINT, file=sys.stderr) return 2 if probed_id is not None: - _checked_member_id(family, repository, project, probed_id) + _checked_member_id(family, repository, project, probed_id, + dry_run=args.dry_run) # Every refusal above is the same under `--dry-run`, which leaves here, - # before `MountRollback` is taken and anything is mounted (#326). + # before `MountRollback` is taken and anything is mounted (#326), but + # that `member-duplicate-id`'s remedy there names a branch first: the + # real run has read the branch by then, and the dry run has not (#1228). if args.dry_run: return _add_dry_run(family, repository, project, path, url, protocol, commit, at, args.trailer, suggested) @@ -8693,7 +8741,7 @@ def _member_id_at(member: Path, commit: str, project: str, def _checked_bumped_id(family: Family, row: dict, commit: str, - project_id: str) -> None: + project_id: str, *, dry_run: bool = False) -> None: """`member-duplicate-id` for a `bump` whose `--to` records, in the member's own `project.yaml`, the `id` another row already records (#541): the question `add` asks since #330 (`_checked_member_id`), asked @@ -8713,6 +8761,10 @@ def _checked_bumped_id(family: Family, row: dict, commit: str, bump replaces it, so the id it records today is no other project's. Every other row is asked as `add` asks it, so `bump` refuses what the gate would refuse and nothing it would pass. + + `dry_run` IS `bump --dry-run`'s ASK (#1228), which reads no branch + first: its remedy says the branch its `remove` line needs. A real + `bump` asks this after `_checked_branch`, so its remedy names none. """ project = str(row["project"]) held = _row_recording(family, project_id, besides=project) @@ -8722,7 +8774,17 @@ def _checked_bumped_id(family: Family, row: dict, commit: str, # THE `remove` IT NAMES RUNS AS PRINTED (#1005): `remove` requires # `--family-root`, so the line is a `_family_line` naming this holder and # the other row's own name, quoted, as every remedy's line is (#1127). A - # real `bump` asks this after `_checked_branch`, so no branch is named. + # real `bump` asks this after `_checked_branch`, which refuses the + # tracking branch, so no branch is named. A dry run asks it before the + # branch is read, so it names the branch first: the one the `remove` + # line's own refusal would name, and `BRANCH_LEFT` (#1228). + branch, left = "", "" + if dry_run: + branch = _off_the_tracking_branch( + family, "`remove` refuses", + _suggested_branch("remove", + str(held.get("id") or held["project"]))) + left = " " + BRANCH_LEFT raise Refusal( "member-duplicate-id", f"{repository} at {commit[:12]} is already a member of " @@ -8734,11 +8796,12 @@ def _checked_bumped_id(family: Family, row: dict, commit: str, f"of {repository} whose project.yaml records an id no other row " f"records. If {repository} and {held.get('repository')} are one " "project, " + + branch + _cited(_family_line("remove", family.root, "--member", held["project"])) + " takes the other row out first. Two projects that record one id " "are a fault in one of their `project.yaml` files, mended in that " - "project, not in the family.") + "project, not in the family." + left) def _head_oid(root: Path, path: str) -> str | None: @@ -9211,10 +9274,12 @@ def _bump_dry_run(family: Family, row: dict, path: str, commit: str, resolved as the checkout would take it (`^{commit}`, which is what `git checkout` accepts). The manifest is read by `_member_id_at`, its id is asked against the other rows by `_checked_bumped_id` as the real run - asks it (#541), and the digest is computed by `tree_digest`, which reads - objects and never a checkout, so the digest printed is the one the real - run computes, never a guess. The rows are `_bumped_rows`', and - `family.rewritten` asks `family-members-unrecognised` without writing. + asks it (#541), its remedy naming a branch first, which the real run + has read by then (#1228), and the digest is computed by `tree_digest`, + which reads objects and never a checkout, so the digest printed is the + one the real run computes, never a guess. The rows are `_bumped_rows`', + and `family.rewritten` asks `family-members-unrecognised` without + writing. `nothing to do` IS THE REAL RUN'S OWN RULE (#210), asked without the write: the commit would record nothing when HEAD's gitlink is already @@ -9242,7 +9307,7 @@ def _bump_dry_run(family: Family, row: dict, path: str, commit: str, if linked: project_id = str(row.get("id") or "") else: - _checked_bumped_id(family, row, commit, project_id) + _checked_bumped_id(family, row, commit, project_id, dry_run=True) digest = tree_digest(member, commit) new, rows = _bumped_rows(family, row, project_id, commit, digest) text = family.rewritten(rows) diff --git a/tests/test_family_bump_to_checks.py b/tests/test_family_bump_to_checks.py index ea27ca98..d9b513ae 100644 --- a/tests/test_family_bump_to_checks.py +++ b/tests/test_family_bump_to_checks.py @@ -62,6 +62,8 @@ #: The shell's reading of a printed line, and of a refusal's `switch`. from test_family_printed_quoting import family_lead, host_words from test_family_tracking_branch import remedy_switch +#: The sentence every remedy that names a `switch -c` ends with (#607). +from test_validate_family_branch_step import BRANCH_LEFT PATH = f"members/{MEMBER}" @@ -80,12 +82,25 @@ def records_the_neighbours_id(manifest_path: Path) -> None: OWN_ID_LINE, f'\nid: "{OTHER_ID}"\n'.encode("ascii"))) -def duplicate_id_refusal(to: str, holder: Path) -> tuple[str, str]: +def duplicate_id_refusal(to: str, holder: Path, + dry_run: bool = False) -> tuple[str, str]: """The two lines `bump --to ` is refused with when the member's `project.yaml` at `to` records the neighbour's id. The `remove` the remedy names carries `--family-root` and the holder, as `remove` requires (#1005), after the interpreter and the path to `family.py` - from where the run stood (#1127).""" + from where the run stood (#1127). + + `dry_run` FOR `bump --dry-run`'s (#1228), which reads no branch before + it asks the id, where the real run has passed `_checked_branch`: the + `remove` comes after the branch it needs, the one `remove`'s own + refusal names, and the remedy ends with `BRANCH_LEFT`.""" + root = shell_quoted(holder.resolve()) + branch, left = "", "" + if dry_run: + branch = ("on a branch other than the holder's tracking branch, " + f"which `remove` refuses (`git -C {root} switch -c " + f"family/remove-{OTHER_ID}` makes one), ") + left = f" {BRANCH_LEFT}" return ( f"REFUSED member-duplicate-id: {ORG}/{MEMBER} at {to[:12]} is " f"already a member of {NAME} under another name: its project.yaml " @@ -95,11 +110,30 @@ def duplicate_id_refusal(to: str, holder: Path) -> tuple[str, str]: "Remediation: a family pins each project once. Pass `--to` a commit " f"of {ORG}/{MEMBER} whose project.yaml records an id no other row " f"records. If {ORG}/{MEMBER} and {ORG}/{OTHER} are one project, " - f"`{family_lead()}remove --family-root " - f"{shell_quoted(holder.resolve())} " + f"{branch}`{family_lead()}remove --family-root {root} " f"--member {OTHER}` takes the other row out first. " "Two projects that record one id are a fault in one of their " - "`project.yaml` files, mended in that project, not in the family.") + "`project.yaml` files, mended in that project, not in the family." + f"{left}") + + +def both_refused(holder: Path, env: dict, to: str, + stdout: str = "") -> tuple[str, str]: + """`both_runs`, for a refusal whose words the two runs say apart: + `bump --to ` with `--dry-run` and then for real, over the same + holder, both exit 2 with `stdout` and nothing else on stdout and + nothing in the holder moved. Returns `(the dry run's stderr, the real + run's)`.""" + before = state(holder, env) + + dry = bump(holder, env, to, "--dry-run") + real = bump(holder, env, to) + + assert_as_it_was(holder, env, before, BUMP_ORDER) + assert dry.returncode == real.returncode == 2, ( + dry.stdout + dry.stderr + real.stdout + real.stderr) + assert dry.stdout == real.stdout == stdout + return dry.stderr, real.stderr # --- the duplicate id -------------------------------------------------------- @@ -109,15 +143,17 @@ def test_bump_to_a_commit_recording_another_rows_id_is_refused_alike( """THE ISSUE'S FIRST LINE. The member's `project.yaml` at `--to` records the neighbour's id. `bump` committed it, exit 0, and the holder's own gate refused `member-duplicate-id`. Now both runs refuse it under that - code, in the same two lines, before the checkout, and the holder is as - it was and still passes its gate.""" + code, in the same detail, before the checkout, and the holder is as it + was and still passes its gate. The dry run's remedy names the branch + its `remove` needs first, which the real run has read (#1228).""" env = isolated(offline, tmp_path) to = commit_in_mount(holder, env, lambda mount: records_the_neighbours_id( mount / "project.yaml")) - stderr = both_runs(holder, env, to) + dry, real = both_refused(holder, env, to) - assert refusal(stderr) == duplicate_id_refusal(to, holder) + assert refusal(real) == duplicate_id_refusal(to, holder) + assert refusal(dry) == duplicate_id_refusal(to, holder, dry_run=True) assert validate(holder).returncode == 0 offline.assert_left_nothing() @@ -163,15 +199,17 @@ def test_a_duplicate_id_behind_a_tag_is_refused_after_the_peel_line( commit whose `project.yaml` records the neighbour's id. Both runs peel it and say so, in the line that says what WOULD be pinned, then refuse `member-duplicate-id` for the commit the tag names, exit 2, and pin - nothing: the holder is as it was and still passes its gate.""" + nothing: the holder is as it was and still passes its gate. The dry + run's remedy names the branch its `remove` needs first (#1228).""" env = isolated(offline, tmp_path) made = commit_in_mount(holder, env, lambda mount: records_the_neighbours_id(mount / "project.yaml")) to = annotated(holder / PATH, env, "v-duplicate", made) - stderr = both_runs(holder, env, to, peeled_line(to, made) + "\n") + dry, real = both_refused(holder, env, to, peeled_line(to, made) + "\n") - assert refusal(stderr) == duplicate_id_refusal(made, holder) + assert refusal(real) == duplicate_id_refusal(made, holder) + assert refusal(dry) == duplicate_id_refusal(made, holder, dry_run=True) assert validate(holder).returncode == 0 offline.assert_left_nothing() diff --git a/tests/test_family_dry_run.py b/tests/test_family_dry_run.py index 8d2662b6..f06cf9c5 100644 --- a/tests/test_family_dry_run.py +++ b/tests/test_family_dry_run.py @@ -51,6 +51,7 @@ from conftest import (appended_to_command_scope, cloned_just_pushed, git, run_script) +from repo_shape import shell_quoted #: The trailer lines a lane's run lands, from the file that owns them. from test_commit_trailers import TRAILERS, trailers_of #: The family fixture, its names and the offline environment, imported from @@ -71,6 +72,8 @@ GLOB, MEMBER, OTHER, hand_edit, older, raw, recorded, ) from test_family_member_path import isolated as member_path_env +#: The sentence every remedy that names a `switch -c` ends with (#607). +from test_validate_family_branch_step import BRANCH_LEFT #: GIT'S AUTOMATIC MAINTENANCE, OFF for the gits `isolated()` below hands an #: environment to: `family.py`'s children and the snapshot's gits. A `git @@ -1082,10 +1085,12 @@ def a_renamed_copy_of_a_member(root: Path) -> Path: #: Each refusal `add` makes before its first write that a fixture provokes #: cheaply, in `add`'s own order, as `(--member, more arguments, set-up, #: what stderr starts with)`: the holder's own refusals first, then the -#: member's, which need the probe's throwaway clone, and last the member's -#: `id:` against the rows (#330), which needs what that clone read. A -#: set-up that needs the member read from somewhere other than the family's -#: remotes returns that directory of remotes; the others return None. +#: member's, which need the probe's throwaway clone. A set-up that needs the +#: member read from somewhere other than the family's remotes returns that +#: directory of remotes; the others return None. The last refusal, the +#: member's `id:` against the rows (#330), which needs what that clone read, +#: is the one whose remedy the dry run says apart, and has a row of its own +#: after these (#1228). ADD_REFUSALS = { "malformed": (SPARE, (), None, "REFUSED member-malformed: "), "malformed-dot": (f"{ORG}/..", (), None, "REFUSED member-malformed: "), @@ -1129,8 +1134,6 @@ def a_renamed_copy_of_a_member(root: Path) -> Path: "REFUSED member-not-a-project: "), "unreadable": (f"{ORG}/IRNOPE", (), None, f"REFUSED reading {ORG}/IRNOPE. THE EXACT COMMAND WAS:"), - "duplicate-id": (f"{ORG}/{RENAMED}", (), a_renamed_copy_of_a_member, - "REFUSED member-duplicate-id: "), } @@ -1152,7 +1155,8 @@ def test_a_refusal_add_makes_before_it_mounts_is_the_same_under_dry_run( of the probe's throwaway clone). The real run and the dry run, over the same holder, print the same stdout and the same stderr, and leave the holder as it was, every hand edit a set-up made still in place, and no - temporary clone behind.""" + temporary clone behind. The one refusal whose remedy the two say apart + has the next row (#1228).""" env = isolated(offline, tmp_path) remotes = family["remotes"] if set_up is not None: @@ -1170,6 +1174,39 @@ def test_a_refusal_add_makes_before_it_mounts_is_the_same_under_dry_run( offline.assert_left_nothing() +def test_add_dry_runs_duplicate_id_names_the_branch_the_real_run_has_read( + family, holder, offline, tmp_path): + """THE RULING'S ONE DIFFERENCE (#1228). The member's `id:` against the + rows (#330) is asked last, of what the probe's clone read, and the dry + run refuses it as the real run does: the same code, exit 2, the same + detail and stdout, the holder as it was and no temporary clone behind. + A real `add` asks it after `_checked_branch`, so its remedy names no + branch; the dry run reads no branch, so its remedy says, before the two + lines, the branch they need, the one the `remove` line's own refusal + names, and ends with `BRANCH_LEFT`.""" + env = isolated(offline, tmp_path) + remotes = a_renamed_copy_of_a_member(holder) + before = add_state(holder, env) + + real = add(holder, env, remotes, member=f"{ORG}/{RENAMED}") + dry = add(holder, env, remotes, "--dry-run", member=f"{ORG}/{RENAMED}") + + assert dry.returncode == real.returncode == 2, dry.stdout + dry.stderr + assert dry.stdout == real.stdout + said, remedy = refusal(real.stderr) + assert said.startswith("REFUSED member-duplicate-id: "), said + forge = " renamed at the forge, " + assert remedy.count(forge) == 1, remedy + clause = ("on a branch other than the holder's tracking branch, which " + f"`bump` and `remove` refuse (`git -C " + f"{shell_quoted(holder.resolve())} switch -c " + f"family/remove-{PINNED.lower()}` makes one), ") + assert refusal(dry.stderr) == ( + said, f"{remedy.replace(forge, forge + clause)} {BRANCH_LEFT}") + assert_add_as_it_was(holder, env, before) + offline.assert_left_nothing() + + def test_add_dry_run_prints_the_trailers_the_real_commit_ends_with( family, holder, offline, tmp_path): """`--trailer` with `--dry-run` prints the lines, in order, after the row diff --git a/tests/test_family_remedy_lines.py b/tests/test_family_remedy_lines.py index b2f2ee20..17688c9a 100644 --- a/tests/test_family_remedy_lines.py +++ b/tests/test_family_remedy_lines.py @@ -38,6 +38,21 @@ as `tests/test_family_tracking_branch.py` does; its spare member `IRQS` is in its remotes and not in the holder. No network: `gh` is a shim that exits non-zero. + +Three more lines, from lane 5's review of #1201 (#1228), are measured the +same way. Each was reached with no branch read, and named none: + +* `add --dry-run`'s `member-duplicate-id`, for `IRRS2`, a renamed copy of + the pinned `IRRS` (`tests/test_family_add_member_id.py`'s), and +* `bump --dry-run`'s, for a `--to` whose `project.yaml` records the + neighbour's id (`tests/test_family_bump_to_checks.py`'s). A dry run asks + no branch, so each now names the one its lines need, the branch the + `remove` line's own refusal names, then `BRANCH_LEFT`; the real runs ask + them after `_checked_branch`, and their words are pinned unchanged in + those two files. +* `init`'s `family-holder-already-here`, whose `add` stands on the holder + `init` made, on its tracking branch. It now comes after `init`'s own + `switch -c family/add-`, and is `_cited` (N6). """ from __future__ import annotations @@ -47,7 +62,7 @@ import pytest -from conftest import git, run_script +from conftest import git, refusal_detail, run_script from repo_shape import PYTHON, shell_quoted #: The family fixture and its names, the offline environment and the #: helpers of the tests that already trigger these codes, imported from the @@ -55,8 +70,12 @@ from test_family import ( # noqa: F401 FAMILY, MEMBERS, NAME, ORG, SPARE, family, manifest, validate, ) +from test_family_add_member_id import DUPLICATE_ID, RENAMED, renamed_copy from test_family_add_refusals import offline, refusal # noqa: F401 -from test_family_dry_run import isolated +from test_family_bump_to_checks import ( + duplicate_id_refusal, records_the_neighbours_id, +) +from test_family_dry_run import commit_in_mount, isolated from test_family_printed_quoting import copied_holder, family_lead, host_words from test_family_tracking_branch import TRACKING, older_commit #: The sentence every remedy that names a `switch -c` ends with (#607). @@ -66,8 +85,9 @@ #: before `--to` is. COMMIT = "0123456789abcdef0123456789abcdef01234567" -#: The pinned member `add` is refused for, by the row's name. -PINNED = MEMBERS[0] +#: The pinned member `add` is refused for, by the row's name, and its +#: neighbour, whose id `bump --dry-run`'s `--to` records (#1228). +PINNED, NEIGHBOUR = MEMBERS #: What the remedies say before the line of `family.py` they name, with the #: verbs the tracking branch is refused by. @@ -136,6 +156,58 @@ def present_remediation(holder: Path, lead: str, ticked: bool = True) -> str: f"twice is not a way to move it. {BRANCH_LEFT}") +def add_dry_duplicate_remediation(holder: Path, lead: str) -> str: + """What `add --dry-run` of `IRRS2`, whose `project.yaml` records the id + the row `IRRS` pins, is told (#1228): the branch first, the one + `remove`'s own refusal names, then the `remove` and the `bump` the real + run names, then `BRANCH_LEFT`.""" + name = shell_quoted(PINNED) + remove = family_line(lead, "remove", holder, name) + bump = family_line(lead, "bump", holder, name) + " --to " + switch = switch_line(holder, f"family/remove-{PINNED.lower()}") + clause = OFF_TRACKING.format(refuses="`bump` and `remove` refuse", + switch=f"`{switch}`") + return (f"Remediation: a family pins each project once. If " + f"{ORG}/{RENAMED} is {ORG}/{PINNED} renamed at the forge, " + f"{clause}`{remove}` takes the old row out and this `add` then " + f"pins it under the new name; `{bump}` moves the row that is " + "there instead. Two projects that record one id are a fault in " + "one of their `project.yaml` files, mended in that project, not " + f"in the family. {BRANCH_LEFT}") + + +def bump_dry_duplicate_remediation(holder: Path, lead: str) -> str: + """What `bump --dry-run` of `IRRS` to a commit whose `project.yaml` + records the id of `IRSS` is told (#1228): the branch first, the one + `remove`'s own refusal names, then the `remove` the real run names, + then `BRANCH_LEFT`.""" + remove = family_line(lead, "remove", holder, shell_quoted(NEIGHBOUR)) + switch = switch_line(holder, f"family/remove-{NEIGHBOUR.lower()}") + clause = OFF_TRACKING.format(refuses="`remove` refuses", + switch=f"`{switch}`") + return (f"Remediation: a family pins each project once. Pass `--to` a " + f"commit of {ORG}/{PINNED} whose project.yaml records an id no " + f"other row records. If {ORG}/{PINNED} and {ORG}/{NEIGHBOUR} are " + f"one project, {clause}`{remove}` takes the other row out first. " + "Two projects that record one id are a fault in one of their " + "`project.yaml` files, mended in that project, not in the " + f"family. {BRANCH_LEFT}") + + +def holder_here_remediation(holder: Path, lead: str, + ticked: bool = True) -> str: + """What `init` run again over the family folder it made is told + (#1228): `init`'s own branch, its `` bare as the NEXT STEPS + print it, then `init`'s own `add`, then `BRANCH_LEFT`.""" + switch = switch_line(holder, "family/add-") + add = family_line(lead, "add", holder, f"{shell_quoted(ORG)}/") + clause = OFF_TRACKING.format(refuses="`add` refuses", + switch=cited(switch, ticked)) + return (f"Remediation: {clause}{cited(add, ticked)} grows it, and `make " + "siblings` in the holder puts each member's working clone beside " + f"it. There is no --force. {BRANCH_LEFT}") + + def bump_unknown(holder: Path, stand: Path, env: dict, member: str = SPARE): """`bump` of a member the holder does not pin, typed in `stand`.""" return run_script(FAMILY, "bump", "--family-root", str(holder), @@ -151,7 +223,13 @@ def add_present(family, holder: Path, stand: Path, env: dict): def typed(line: str, stand: Path, env: dict, *extra: str): """`line` typed in `stand` as the reader's shell reads it: the - interpreter it names, and the script by the path it names from there.""" + interpreter it names, and the script by the path it names from there. + + THE INTERPRETER IS ASSERTED, NOT LOOKED UP (RV1201's NIT-2 on #1201): + the line's first word must be `PYTHON`, and the script then runs under + this test's own interpreter (`run_script`). So a row proves the words a + shell reads and the script they reach, not that the reader's PATH has + a `PYTHON`.""" words = host_words(line) assert words[0] == PYTHON, line return run_script(Path(words[1]), *words[2:], *extra, cwd=stand, env=env) @@ -319,7 +397,10 @@ def test_a_rows_name_with_a_space_is_one_word_in_both_lines( family, offline, stand, tmp_path): """A row whose `project:` holds a space, edited by hand and committed: `add` of the repository it pins names it in its `bump` and `remove` - lines as ONE word, the row's name whole.""" + lines as ONE word, the row's name whole. The branch the clause names + is made from the row's `id:`, which is still `irrs`, and not from its + name, which would make `family/bump-member` (RV1201's NIT-1 on #1201: + every other row's id is its name lowercased).""" env = isolated(offline, tmp_path) holder = on_tracking(family, tmp_path / "holders") path = holder / "family.yaml" @@ -330,6 +411,8 @@ def test_a_rows_name_with_a_space_is_one_word_in_both_lines( path.write_text(edited, encoding="utf-8") git("commit", "-q", "-m", "A row named by hand", "--", "family.yaml", cwd=holder) + assert rows_of(holder)["IR RS"]["id"] == PINNED.lower(), ( + "premise: the row keeps its id") refused = add_present(family, holder, stand, env) assert refused.returncode == 2, refused.stdout + refused.stderr assert refused.stderr.startswith("REFUSED member-already-present: ") @@ -340,6 +423,8 @@ def test_a_rows_name_with_a_space_is_one_word_in_both_lines( ["bump", "--family-root", str(holder), "--member", "IR RS", "--to", ""], ["remove", "--family-root", str(holder), "--member", "IR RS"]] + assert re.findall(r"`(git -C [^`]*)`", refused.stderr) == [ + switch_line(holder, f"family/bump-{PINNED.lower()}")] # --- N6: a holder under a folder named with a backtick ----------------------- @@ -371,3 +456,159 @@ def test_a_command_holding_a_backtick_is_printed_bare( assert done.returncode == 0, done.stdout + done.stderr assert SPARE in rows_of(holder) assert git("status", "--porcelain", cwd=holder).stdout == "" + + +# --- #1228: the lines reached with no branch read ---------------------------- + +def add_renamed(holder: Path, remotes: Path, stand: Path, env: dict, + *extra: str): + """`add` of `IRRS2`, the pinned `IRRS` renamed at the forge, typed in + `stand`, reading the member from `remotes`.""" + return run_script(FAMILY, "add", *extra, "--family-root", str(holder), + "--member", f"{ORG}/{RENAMED}", "--local-remote-dir", + str(remotes), cwd=stand, env=env) + + +@pytest.mark.parametrize("verb", ["remove", "bump"]) +def test_add_dry_runs_duplicate_id_lines_run_from_the_tracking_branch( + family, offline, stand, tmp_path, verb): + """(a) `add --dry-run` reads no branch, so on the tracking branch it is + refused `member-duplicate-id` for `IRRS2`. Its remediation, word for + word: the branch the `remove` line's own refusal names, then the + `remove` and the `bump` lines, then `BRANCH_LEFT`. Typed alone there, + each line is refused `family-on-tracking-branch`; typed after the + `switch -c`, `remove` takes the old row out and the refused `add`, run + for real, then pins `IRRS2`, or `bump` (its `` filled in) moves + the row, and the holder is clean and its gate green.""" + env = isolated(offline, tmp_path) + holder = on_tracking(family, tmp_path / "holders") + remotes = renamed_copy(family, tmp_path) + lead = family_lead(stand) + refused = add_renamed(holder, remotes, stand, env, "--dry-run") + assert refused.returncode == 2, refused.stdout + refused.stderr + assert refusal(refused.stderr) == ( + DUPLICATE_ID.format(who=RENAMED, after=""), + add_dry_duplicate_remediation(holder, lead)) + line = family_line(lead, verb, holder, PINNED) + older = older_commit(family, PINNED) + if verb == "bump": + line += f" --to {older}" + before = rows_of(holder) + + alone = typed(line, stand, env) + assert alone.returncode == 2, alone.stdout + alone.stderr + assert alone.stderr.startswith("REFUSED family-on-tracking-branch: ") + assert rows_of(holder) == before + + switched(switch_line(holder, f"family/remove-{PINNED.lower()}"), stand) + done = typed(line, stand, env) + assert done.returncode == 0, done.stdout + done.stderr + assert branch_of(holder) == f"family/remove-{PINNED.lower()}" + if verb == "bump": + assert rows_of(holder)[PINNED]["pin"]["commit"] == older + else: + added = add_renamed(holder, remotes, stand, env) + assert added.returncode == 0, added.stdout + added.stderr + assert sorted(rows_of(holder)) == sorted([RENAMED, NEIGHBOUR]) + assert git("status", "--porcelain", cwd=holder).stdout == "" + assert validate(holder).returncode == 0 + + +def test_bump_dry_runs_duplicate_id_remove_runs_from_the_tracking_branch( + family, offline, stand, tmp_path): + """(b) `bump --dry-run` of `IRRS` to a pushed commit whose + `project.yaml` records the id of `IRSS` reads no branch before the id, + so on the tracking branch it is refused `member-duplicate-id`. Its + remediation, word for word: the branch the `remove` line's own refusal + names, then the `remove` line, then `BRANCH_LEFT`. Typed alone there, + the `remove` is refused `family-on-tracking-branch`; typed after the + `switch -c`, it takes `IRSS` out, the refused `bump`, run for real, + then pins that commit, and the holder is clean and its gate green.""" + env = isolated(offline, tmp_path) + holder = on_tracking(family, tmp_path / "holders") + to = commit_in_mount(holder, env, lambda mount: records_the_neighbours_id( + mount / "project.yaml")) + lead = family_lead(stand) + + def bump(*extra: str): + return run_script(FAMILY, "bump", *extra, "--family-root", + str(holder), "--member", PINNED, "--to", to, + cwd=stand, env=env) + + refused = bump("--dry-run") + assert refused.returncode == 2, refused.stdout + refused.stderr + assert refusal(refused.stderr) == ( + duplicate_id_refusal(to, holder)[0], + bump_dry_duplicate_remediation(holder, lead)) + line = family_line(lead, "remove", holder, NEIGHBOUR) + + alone = typed(line, stand, env) + assert alone.returncode == 2, alone.stdout + alone.stderr + assert alone.stderr.startswith("REFUSED family-on-tracking-branch: ") + assert sorted(rows_of(holder)) == sorted(MEMBERS) + + switched(switch_line(holder, f"family/remove-{NEIGHBOUR.lower()}"), stand) + done = typed(line, stand, env) + assert done.returncode == 0, done.stdout + done.stderr + assert branch_of(holder) == f"family/remove-{NEIGHBOUR.lower()}" + assert sorted(rows_of(holder)) == [PINNED] + bumped = bump() + assert bumped.returncode == 0, bumped.stdout + bumped.stderr + assert rows_of(holder)[PINNED]["pin"]["commit"] == to + assert git("status", "--porcelain", cwd=holder).stdout == "" + assert validate(holder).returncode == 0 + + +#: `init`'s arguments, but where it lands, for a family of its own. +INIT = ("init", "--org", ORG, "--family", "Orbit", "--created-by", + "Test Human", "--created-on", "2026-10-12") + + +@pytest.mark.parametrize("folder", ["plain", "a`b"]) +def test_init_holder_already_heres_add_runs_as_printed_after_its_switch( + family, offline, stand, tmp_path, folder): + """(c) `init` run again over the family folder it made is refused + `family-holder-already-here`. Its remediation, word for word: `init`'s + own branch, `family/add-` bare, then `init`'s own `add`, then + `BRANCH_LEFT`; under `a`b/`, where both commands hold the backtick, + both are printed bare and the prose is the same. The holder `init` made + stands on its tracking branch: there the `add`, `` filled in, + is refused `family-on-tracking-branch`; typed after the `switch -c`, + `` filled in alike, it puts IRQS in the family on that branch, + and the holder is clean and its gate green.""" + env = isolated(offline, tmp_path) + into = tmp_path / folder + into.mkdir() + lands = ("--local-remote-dir", str(tmp_path / "init remotes")) + made = run_script(FAMILY, *INIT, *lands, "--into", str(into), cwd=stand, + env=env) + assert made.returncode == 0, made.stdout + made.stderr + holder = into.resolve() / "Orbit" / "Orbit" + assert branch_of(holder) == TRACKING, "premise: where `init` leaves it" + lead = family_lead(stand) + + again = run_script(FAMILY, *INIT, *lands, "--into", str(holder.parent), + cwd=stand, env=env) + assert again.returncode == 2, again.stdout + again.stderr + # By its code: `init` warns first where the checkout it copies from is + # dirty, as a developer's may be. + lines = again.stderr.splitlines() + said = refusal_detail(again.stderr, "family-holder-already-here") + assert len(said) == 1, again.stderr + remedy = lines[lines.index(said[0]) + 1] + assert remedy == holder_here_remediation(holder, lead, + ticked="`" not in folder) + add = family_line(lead, "add", holder, f"{shell_quoted(ORG)}/{SPARE}") + members = ("--local-remote-dir", str(family["remotes"])) + + alone = typed(add, stand, env, *members) + assert alone.returncode == 2, alone.stdout + alone.stderr + assert alone.stderr.startswith("REFUSED family-on-tracking-branch: ") + + switched(switch_line(holder, f"family/add-{SPARE}"), stand) + done = typed(add, stand, env, *members) + assert done.returncode == 0, done.stdout + done.stderr + assert branch_of(holder) == f"family/add-{SPARE}" + assert sorted(rows_of(holder)) == [SPARE] + assert git("status", "--porcelain", cwd=holder).stdout == "" + assert validate(holder).returncode == 0 diff --git a/tests/test_readme_windows_quoting_sentence.py b/tests/test_readme_windows_quoting_sentence.py index 23b18c61..2bea9ba8 100644 --- a/tests/test_readme_windows_quoting_sentence.py +++ b/tests/test_readme_windows_quoting_sentence.py @@ -71,7 +71,10 @@ "_add_command": 1, # `init`'s `add`: its `--member /...` # `bump`'s `member-unknown`: its `add` line's `--member` (#1127) "_unknown_members_repository": 2, - "_print_next_steps": 2, # `init`'s block: its `switch -c` and `cd` + "_print_next_steps": 1, # `init`'s block: its `cd` + # `init`'s `switch -c family/add-`, in its block and in + # `family-holder-already-here`'s remedy (#1228) + "_add_branch_switch": 1, "_checked_mount_path": 1, # the `git rm -r --cached -- ` remedy }, } @@ -88,7 +91,8 @@ "_family_line": None, "_add_command": {"add"}, "_unknown_members_repository": {"add"}, - "_print_next_steps": {"switch -c", "cd"}, + "_print_next_steps": {"cd"}, + "_add_branch_switch": {"switch -c"}, "_checked_mount_path": {"git rm -r --cached"}, } From 43d2e8b2665e5eeec1376e5bf01d6a374cb030de Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 17:41:31 +0000 Subject: [PATCH 4/4] family.py and test_family_remedy_lines.py: `init`'s `family-holder-already-here` builds its clause before it raises, so "Remediation: " is no fourth bare copy, and two rows pin that each dry run's clause names its branch from the row's id (#1228) rv1264's two NITs on #1264, both at b54520cea: - NIT-1: the (c) hunk wrote `"Remediation: "` beside `_branch_clause`'s call, a fourth bare copy of that literal after #1201's three, which SonarCloud reports on #1264 as python:S1192. `_landing` now holds the clause in a local and says `f"Remediation: {clause}"`. The refusal's words are byte for byte what they were, for a plain folder and for one named with a backtick (probed before and after, same script path). - NIT-2: mutants M10a and M10b, the dry runs' clause branch built from the row's name and not its `id:`, survived because every fixture row's id is its name lowercased. Two rows rename a row by hand (`IRRS` to `Orrs`, `IRSS` to `Osss`) and assert the clause's `switch -c` names `family/remove-irrs` and `family/remove-irss`. The edit those rows and the `IR RS` row share is now `renamed_by_hand`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- scripts/family.py | 6 +-- tests/test_family_remedy_lines.py | 74 ++++++++++++++++++++++++++----- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index 3671b88c..da2cefae 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -2522,14 +2522,14 @@ def _landing(args, family: str) -> tuple[Path | None, Path]: # so under a folder named with a backtick the line is printed whole # (#1127, RV1117's N6). if into.name == family and os.path.isfile(into / family / MANIFEST): + clause = _branch_clause(_add_branch_switch(into / family), + "`add` refuses") raise Refusal( "family-holder-already-here", f"{shown_path(into)} is already the {family} family folder and " f"its holder is at {shown_path(into / family)}, so there is " "nothing to init", - "Remediation: " - + _branch_clause(_add_branch_switch(into / family), - "`add` refuses") + f"Remediation: {clause}" + _cited(_add_command(into / family, args.org)) + " grows it, and `make siblings` in the holder puts each " "member's working clone beside it. There is no --force. " diff --git a/tests/test_family_remedy_lines.py b/tests/test_family_remedy_lines.py index 17688c9a..002920ce 100644 --- a/tests/test_family_remedy_lines.py +++ b/tests/test_family_remedy_lines.py @@ -53,6 +53,10 @@ * `init`'s `family-holder-already-here`, whose `add` stands on the holder `init` made, on its tracking branch. It now comes after `init`'s own `switch -c family/add-`, and is `_cited` (N6). + +The branch each dry run's clause names is made from the row's `id:`, and +not from its name: a row renamed by hand, whose name is not its id +lowercased, tells the two apart (RV1264's NIT-2 on #1264). """ from __future__ import annotations @@ -393,6 +397,23 @@ def test_member_unknowns_add_names_the_owner_where_it_is_known( # --- N4: a row's name is one word -------------------------------------------- +def renamed_by_hand(holder: Path, project: str, name: str) -> None: + """Edit the `project:` of the row that pins `project` to `name` and + commit it, as a person editing `family.yaml` by hand would. The row + keeps its `id:`, which is `project` lowercased: every other row's id + is its name lowercased, so only a row like this tells the two apart.""" + path = holder / "family.yaml" + text = path.read_text(encoding="utf-8") + edited = re.sub(rf'(?m)^( - project: )"?{project}"?$', rf'\1"{name}"', + text, count=1) + assert edited != text, "premise: the row's `project:` line" + path.write_text(edited, encoding="utf-8") + git("commit", "-q", "-m", "A row named by hand", "--", "family.yaml", + cwd=holder) + assert rows_of(holder)[name]["id"] == project.lower(), ( + "premise: the row keeps its id") + + def test_a_rows_name_with_a_space_is_one_word_in_both_lines( family, offline, stand, tmp_path): """A row whose `project:` holds a space, edited by hand and committed: @@ -403,16 +424,7 @@ def test_a_rows_name_with_a_space_is_one_word_in_both_lines( every other row's id is its name lowercased).""" env = isolated(offline, tmp_path) holder = on_tracking(family, tmp_path / "holders") - path = holder / "family.yaml" - text = path.read_text(encoding="utf-8") - edited = re.sub(rf'(?m)^( - project: )"?{PINNED}"?$', r'\1"IR RS"', - text, count=1) - assert edited != text, "premise: the row's `project:` line" - path.write_text(edited, encoding="utf-8") - git("commit", "-q", "-m", "A row named by hand", "--", "family.yaml", - cwd=holder) - assert rows_of(holder)["IR RS"]["id"] == PINNED.lower(), ( - "premise: the row keeps its id") + renamed_by_hand(holder, PINNED, "IR RS") refused = add_present(family, holder, stand, env) assert refused.returncode == 2, refused.stdout + refused.stderr assert refused.stderr.startswith("REFUSED member-already-present: ") @@ -559,6 +571,48 @@ def bump(*extra: str): assert validate(holder).returncode == 0 +def test_add_dry_runs_clause_branch_is_made_from_the_rows_id( + family, offline, stand, tmp_path): + """(a) The branch the clause names is the one `remove`'s own refusal + names, made from the row's `id:` and not from its name (RV1264's NIT-2 + on #1264: every other row's id is its name lowercased). The row `IRRS`, + renamed by hand to `Orrs`, still pins id `irrs`, so `add --dry-run` of + `IRRS2` is refused `member-duplicate-id`, and the clause's `switch -c` + names `family/remove-irrs`, not `family/remove-orrs`.""" + env = isolated(offline, tmp_path) + holder = on_tracking(family, tmp_path / "holders") + renamed_by_hand(holder, PINNED, "Orrs") + refused = add_renamed(holder, renamed_copy(family, tmp_path), stand, env, + "--dry-run") + assert refused.returncode == 2, refused.stdout + refused.stderr + said, remedy = refusal(refused.stderr) + assert said.startswith("REFUSED member-duplicate-id: "), said + assert re.findall(r"`(git -C [^`]*)`", remedy) == [ + switch_line(holder, f"family/remove-{PINNED.lower()}")] + + +def test_bump_dry_runs_clause_branch_is_made_from_the_other_rows_id( + family, offline, stand, tmp_path): + """(b) The same for `bump --dry-run` of `IRRS` to a commit whose + `project.yaml` records the id of `IRSS`, the row the `remove` takes out + (RV1264's NIT-2 on #1264). `IRSS`, renamed by hand to `Osss`, still + pins id `irss`: the clause's `switch -c` names `family/remove-irss`, + not `family/remove-osss`.""" + env = isolated(offline, tmp_path) + holder = on_tracking(family, tmp_path / "holders") + renamed_by_hand(holder, NEIGHBOUR, "Osss") + to = commit_in_mount(holder, env, lambda mount: records_the_neighbours_id( + mount / "project.yaml")) + refused = run_script(FAMILY, "bump", "--dry-run", "--family-root", + str(holder), "--member", PINNED, "--to", to, + cwd=stand, env=env) + assert refused.returncode == 2, refused.stdout + refused.stderr + said, remedy = refusal(refused.stderr) + assert said.startswith("REFUSED member-duplicate-id: "), said + assert re.findall(r"`(git -C [^`]*)`", remedy) == [ + switch_line(holder, f"family/remove-{NEIGHBOUR.lower()}")] + + #: `init`'s arguments, but where it lands, for a family of its own. INIT = ("init", "--org", ORG, "--family", "Orbit", "--created-by", "Test Human", "--created-on", "2026-10-12")