From 87c72aed4df46afe3d77f2dec10b619bd9af3d85 Mon Sep 17 00:00:00 2001
From: Brett Heap
Date: Mon, 19 Oct 2026 11:47:49 +0000
Subject: [PATCH 1/6] WIP (#927): the three remedies that name `family.py bump`
name `--family-root`
family.py's `member-already-present` and `member-duplicate-id` remedies name
`--family-root ` (shell_quoted, as `add`'s own line does), and
bump-leg.py's `bump-leg-assembly-is-not-a-leg` remedy names `--family-root
` as a placeholder. tests/test_bump_remedies_family_root.py is new;
test_family.py and test_family_add_member_id.py follow the new wording.
Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5
---
scripts/bump-leg.py | 3 +-
scripts/family.py | 14 +-
tests/test_bump_remedies_family_root.py | 187 ++++++++++++++++++++++++
tests/test_family.py | 8 +-
tests/test_family_add_member_id.py | 40 +++--
5 files changed, 228 insertions(+), 24 deletions(-)
create mode 100644 tests/test_bump_remedies_family_root.py
diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py
index 8c3f933c..b592e9f1 100755
--- a/scripts/bump-leg.py
+++ b/scripts/bump-leg.py
@@ -283,7 +283,8 @@ def leg(self, role: str) -> dict:
"Remediation: --leg takes `spec` or `code`. The assembly "
"root advances by its own commits; if you meant to move the "
"project's pin inside a FAMILY, that is `scripts/family.py "
- "bump --member --to `.")
+ "bump --family-root --member --to "
+ "`.")
for row in self.legs:
if str(row.get("role")) == role:
return row
diff --git a/scripts/family.py b/scripts/family.py
index 0e5c8a10..aa0dde3a 100755
--- a/scripts/family.py
+++ b/scripts/family.py
@@ -6173,9 +6173,10 @@ def _checked_add_target(family: Family, repository: str, project: str,
"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 --member "
- f"{held.get('project')} --to ` moves an existing member; "
- "`remove` takes it out. Adding it twice is not a way to move it.")
+ "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.")
# 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,
@@ -6290,9 +6291,10 @@ def _checked_member_id(family: Family, repository: str, project: str,
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"--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 "
+ f"--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.")
diff --git a/tests/test_bump_remedies_family_root.py b/tests/test_bump_remedies_family_root.py
new file mode 100644
index 00000000..73295907
--- /dev/null
+++ b/tests/test_bump_remedies_family_root.py
@@ -0,0 +1,187 @@
+# SPDX-License-Identifier: Apache-2.0
+"""A remedy that names `family.py bump` names `--family-root`, so the line
+runs as printed (#927).
+
+`bump` requires `--family-root` (its usage line, `family.py`'s head). Three
+remedies named the verb without it, and typed as printed each ended in
+`family.py bump: error: the following arguments are required: --family-root`:
+
+* `member-already-present`, which `add` says of a member the holder already
+ pins, and `member-duplicate-id`, which `add` says of a member that records
+ an id another row records. Both KNOW the holder, so each names it, in the
+ one word `shell_quoted` makes of its path, as `add`'s own line does.
+* `bump-leg-assembly-is-not-a-leg`, which says a family's member pin moves
+ with `family.py bump`. `bump-leg.py` has no holder to name, so it says
+ ``, the way it says `` and ``.
+
+Each family case is measured three ways, in a holder under a plain folder and
+under two with a space and an apostrophe in their names (the paths a shell
+would tear in half): the line names the holder as ONE word that comes back as
+the holder, `family.py`'s own parser accepts it, and it is RUN as printed and
+moves the pin. The `bump-leg.py` line is read the same way once its three
+placeholders are filled in.
+
+The fixtures are the ones the tests of these codes already use:
+`tests/test_family.py`'s family, `tests/test_family_add_member_id.py`'s
+renamed copy and `tests/test_bump_leg.py`'s project. No network: `gh` is a
+shim that exits non-zero.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import re
+from pathlib import Path
+
+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
+#: project made any other way would test a different one.
+from test_bump_leg import advanced, branched, bump # noqa: F401
+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
+
+#: Where the holder stands: a folder a shell reads whole, one with a space,
+#: and one with a space beside an apostrophe, where `sh` and PowerShell
+#: escape differently.
+PLACES = ["plain", "my work", "Dana's work"]
+
+
+@pytest.fixture(params=PLACES)
+def placed(request, family, tmp_path) -> Path:
+ """A private copy of the family's holder, clean, under `request.param`."""
+ base = tmp_path / ("holders" if request.param == "plain"
+ else request.param)
+ return copied_holder(family, base)
+
+
+@pytest.fixture(scope="module")
+def family_module():
+ """`family.py` as a module, for its own argument parser."""
+ spec = importlib.util.spec_from_file_location("family_bump_remedies",
+ FAMILY)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def already_present(family, holder, offline, tmp_path):
+ """`add` of the member the holder already pins: `member-already-present`
+ (the case `tests/test_family.py` refuses)."""
+ return offline.add(holder, PINNED, remotes=family["remotes"])
+
+
+def duplicate_id(family, holder, offline, tmp_path):
+ """`add` of a copy of the pinned member under a new name, whose
+ `project.yaml` records the id the row `IRRS` pins: `member-duplicate-id`
+ (the case `tests/test_family_add_member_id.py` refuses)."""
+ return offline.add(holder, RENAMED,
+ remotes=renamed_copy(family, tmp_path))
+
+
+#: Each code, and the `add` that is refused with it.
+CASES = {"member-already-present": already_present,
+ "member-duplicate-id": duplicate_id}
+
+
+def refused_as(code, family, holder, offline, tmp_path):
+ """The stderr of the `add` that `code` refuses, checked to be it and
+ exit 2."""
+ result = CASES[code](family, holder, offline, tmp_path)
+ assert result.returncode == 2, result.stdout + result.stderr
+ assert f"REFUSED {code}: " in result.stderr
+ 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)
+ assert len(found) == 1, stderr
+ return found[0]
+
+
+def parsed(family_module, words, capsys):
+ """What `family.py`'s own parser makes of `words`; a failure to parse is
+ the failure, said in argparse's own words."""
+ try:
+ return family_module.build_parser().parse_args(words)
+ except SystemExit:
+ pytest.fail(f"family.py {' '.join(words)}\n"
+ + capsys.readouterr().err)
+
+
+@pytest.mark.parametrize("code", CASES)
+def test_the_bump_a_remedy_names_carries_the_holder_whole(
+ code, family, placed, offline, tmp_path):
+ """The line is `bump --family-root --member IRRS --to
+ `, 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."""
+ stderr = refused_as(code, family, placed, offline, tmp_path)
+ line = the_bump_line(stderr)
+ assert line == (f"bump --family-root {shell_quoted(placed)} "
+ f"--member {PINNED} --to ")
+ assert host_words(line) == ["bump", "--family-root", str(placed),
+ "--member", PINNED, "--to", ""]
+
+
+@pytest.mark.parametrize("code", CASES)
+def test_the_bump_a_remedy_names_parses(
+ code, family, placed, offline, tmp_path, family_module, capsys):
+ """Fed to `family.py`'s own parser, with a commit for ``, the
+ printed words are accepted: no `the following arguments are required`."""
+ stderr = refused_as(code, family, placed, offline, tmp_path)
+ commit = "0123456789abcdef0123456789abcdef01234567"
+ words = host_words(the_bump_line(stderr).replace("", commit))
+ capsys.readouterr()
+ args = parsed(family_module, words, capsys)
+ assert args.family_root == str(placed)
+ assert args.member == PINNED
+ assert args.to == commit
+ assert args.func is family_module.cmd_bump
+
+
+@pytest.mark.parametrize("code", CASES)
+def test_the_bump_a_remedy_names_runs_as_printed(
+ code, family, placed, offline, tmp_path):
+ """The remediation is the repair. Typed as printed, with a real commit
+ of the member for ``, `bump` moves the row that is there: exit 0,
+ the pin at that commit, and the holder clean."""
+ stderr = refused_as(code, family, placed, offline, tmp_path)
+ older = git("rev-parse", "main~1",
+ cwd=family["remotes"] / f"{PINNED}.git").stdout.strip()
+ words = host_words(the_bump_line(stderr).replace("", older))
+ done = run_script(FAMILY, *words, env=offline.env)
+ assert "the following arguments are required" not in done.stderr
+ assert done.returncode == 0, done.stdout + done.stderr
+ rows = {row["project"]: row for row in manifest(placed)["members"]}
+ assert rows[PINNED]["pin"]["commit"] == older
+ assert git("status", "--porcelain", cwd=placed).stdout == ""
+
+
+def test_the_assembly_remedy_of_bump_leg_names_the_holder(
+ project, scaffolded, advanced, family_module, capsys):
+ """`bump-leg.py` has no holder to name, so the line it prints says
+ `` as it says `` and ``: the remedy for `--leg
+ assembly` names `--family-root`, and with its three placeholders filled
+ in, `family.py`'s own parser accepts it."""
+ branched(project)
+ result = bump(project, scaffolded, advanced, leg="assembly")
+ assert result.returncode == 2, result.stdout + result.stderr
+ assert "REFUSED bump-leg-assembly-is-not-a-leg: " in result.stderr
+ line = the_bump_line(result.stderr, "scripts/")
+ assert line == "bump --family-root --member --to "
+ filled = (line.replace("", str(project.parent))
+ .replace("", PINNED).replace("", advanced))
+ capsys.readouterr()
+ args = parsed(family_module, host_words(filled), capsys)
+ assert args.family_root == str(project.parent)
+ assert args.member == PINNED
+ assert args.to == advanced
diff --git a/tests/test_family.py b/tests/test_family.py
index ac12a5bf..c4e24926 100644
--- a/tests/test_family.py
+++ b/tests/test_family.py
@@ -47,7 +47,9 @@
from test_update_shape import rendered_shape_line, strip_shape_block
sys.path.insert(0, str(REPO / "scripts"))
-from repo_shape import Refusal, load_yaml, tree_digest # noqa: E402
+from repo_shape import ( # noqa: E402
+ Refusal, load_yaml, shell_quoted, tree_digest,
+)
from shape_materialize import ( # noqa: E402
SHAPE_REPOSITORY, readme_shape_lines, readme_trailing_shape_line,
)
@@ -538,7 +540,9 @@ 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 --member IRRS --to `" in result.stderr
+ assert ("`family.py bump --family-root "
+ f"{shell_quoted(holder.resolve())} --member IRRS --to "
+ "`") in result.stderr
assert "mounting" not in result.stdout
assert git("rev-parse", "HEAD", cwd=holder).stdout == head
assert git("status", "--porcelain", cwd=holder).stdout == status
diff --git a/tests/test_family_add_member_id.py b/tests/test_family_add_member_id.py
index 4409f505..22027915 100644
--- a/tests/test_family_add_member_id.py
+++ b/tests/test_family_add_member_id.py
@@ -39,6 +39,7 @@
import pytest
from conftest import cloned_just_pushed, git, run_script
+from repo_shape import shell_quoted
#: The family fixture, its names and the offline environment, imported from
#: the modules that own them rather than rebuilt: a holder made any other way
#: would test a different holder.
@@ -69,10 +70,18 @@
f"Remediation: a family pins each project once. If {ORG}/{{who}} is "
f"{ORG}/{PINNED} renamed at the forge, `family.py remove --member "
f"{PINNED}` takes the old row out and this `add` then pins it under the "
- f"new name; `family.py bump --member {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.")
+ "new name; `family.py 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.")
+
+
+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)."""
+ return DUPLICATE_ID_REMEDIATION.format(
+ who=who, holder=shell_quoted(holder.resolve()))
def renamed_copy(family, tmp_path: Path) -> Path:
@@ -101,7 +110,7 @@ def test_a_renamed_repository_is_refused_before_anything_is_mounted(
assert result.stdout == f"checking {ORG}/{RENAMED} before mounting it\n"
assert refusal(result.stderr) == (
DUPLICATE_ID.format(who=RENAMED, after=""),
- DUPLICATE_ID_REMEDIATION.format(who=RENAMED))
+ duplicate_id_remediation(RENAMED, holder))
assert_unchanged(holder, before, RENAMED)
assert validate(holder).returncode == 0
offline.assert_left_nothing()
@@ -141,7 +150,7 @@ def test_a_section_already_at_the_mount_path_is_refused_first_then_the_id(
assert second.stdout == f"checking {ORG}/{RENAMED} before mounting it\n"
assert refusal(second.stderr) == (
DUPLICATE_ID.format(who=RENAMED, after=""),
- DUPLICATE_ID_REMEDIATION.format(who=RENAMED))
+ duplicate_id_remediation(RENAMED, holder))
assert snapshot(holder) == cleared
assert not (holder / "members" / RENAMED).exists()
assert not (holder / ".git" / "modules" / "members" / RENAMED).exists()
@@ -152,11 +161,12 @@ def test_a_section_already_at_the_mount_path_is_refused_first_then_the_id(
@pytest.mark.parametrize("verb", ["remove", "bump"])
def test_each_command_the_refusal_names_runs_on_the_row_it_names(
family, holder, offline, tmp_path, verb):
- """The remediation is the repair. Typed as printed — with the
- `--family-root` every verb takes, and a real commit 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`."""
+ """The remediation is the repair. Typed as printed, with a real commit
+ 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."""
remotes = renamed_copy(family, tmp_path)
refused = offline.add(holder, RENAMED, remotes=remotes)
assert refused.returncode == 2, refused.stdout + refused.stderr
@@ -166,8 +176,8 @@ def test_each_command_the_refusal_names_runs_on_the_row_it_names(
cwd=family["remotes"] / f"{PINNED}.git").stdout.strip()
argv = commands[0].replace("", older).split()
assert argv[argv.index("--member") + 1] == PINNED
- done = run_script(FAMILY, *argv, "--family-root", str(holder),
- env=offline.env)
+ unnamed = [] if verb == "bump" else ["--family-root", str(holder)]
+ done = run_script(FAMILY, *argv, *unnamed, env=offline.env)
assert done.returncode == 0, done.stdout + done.stderr
if verb == "remove":
added = offline.add(holder, RENAMED, remotes=remotes)
@@ -222,7 +232,7 @@ def test_a_linked_manifest_with_a_pinned_id_is_refused_by_the_mount_unwound(
assert refusal(result.stderr) == (
DUPLICATE_ID.format(who="IRLN",
after=". " + mount_undone("IRLN")),
- DUPLICATE_ID_REMEDIATION.format(who="IRLN"))
+ duplicate_id_remediation("IRLN", holder))
assert_unchanged(holder, before, "IRLN")
assert validate(holder).returncode == 0
offline.assert_left_nothing()
@@ -271,7 +281,7 @@ def renumbered(*args, **kwargs):
f"mounting {ORG}/{SPARE} at members/{SPARE}\n")
assert refusal(err) == (
DUPLICATE_ID.format(who=SPARE, after=". " + mount_undone(SPARE)),
- DUPLICATE_ID_REMEDIATION.format(who=SPARE))
+ duplicate_id_remediation(SPARE, holder))
assert_unchanged(holder, before, SPARE)
offline.assert_left_nothing()
From 1a898f1a7e9984acfbee939e9fef684f0f2d382d Mon Sep 17 00:00:00 2001
From: Brett Heap
Date: Mon, 19 Oct 2026 11:57:46 +0000
Subject: [PATCH 2/6] WIP (#927): the new test no longer shares the `advanced`
fixture of test_bump_leg.py
tests/test_bump_remedies_family_root.py imported `advanced`, which pushes a
branch to the session's bare repositories; a second module doing it made
test_bump_leg.py's own `advanced` fail non-fast-forward. The bump-leg row
needs only a commit id to fill ``, so it takes a fixed one.
Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5
---
tests/test_bump_remedies_family_root.py | 21 +++++++++++++--------
1 file changed, 13 insertions(+), 8 deletions(-)
diff --git a/tests/test_bump_remedies_family_root.py b/tests/test_bump_remedies_family_root.py
index 73295907..4175009b 100644
--- a/tests/test_bump_remedies_family_root.py
+++ b/tests/test_bump_remedies_family_root.py
@@ -40,12 +40,18 @@
#: 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
#: project made any other way would test a different one.
-from test_bump_leg import advanced, branched, bump # noqa: F401
+from test_bump_leg import branched, bump
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
+#: 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
+#: the session's bare repositories, and a second module doing it makes
+#: `tests/test_bump_leg.py`'s own fail non-fast-forward.
+COMMIT = "0123456789abcdef0123456789abcdef01234567"
+
#: Where the holder stands: a folder a shell reads whole, one with a space,
#: and one with a space beside an apostrophe, where `sh` and PowerShell
#: escape differently.
@@ -138,13 +144,12 @@ def test_the_bump_a_remedy_names_parses(
"""Fed to `family.py`'s own parser, with a commit for ``, the
printed words are accepted: no `the following arguments are required`."""
stderr = refused_as(code, family, placed, offline, tmp_path)
- commit = "0123456789abcdef0123456789abcdef01234567"
- words = host_words(the_bump_line(stderr).replace("", commit))
+ words = host_words(the_bump_line(stderr).replace("", COMMIT))
capsys.readouterr()
args = parsed(family_module, words, capsys)
assert args.family_root == str(placed)
assert args.member == PINNED
- assert args.to == commit
+ assert args.to == COMMIT
assert args.func is family_module.cmd_bump
@@ -167,21 +172,21 @@ def test_the_bump_a_remedy_names_runs_as_printed(
def test_the_assembly_remedy_of_bump_leg_names_the_holder(
- project, scaffolded, advanced, family_module, capsys):
+ project, scaffolded, family_module, capsys):
"""`bump-leg.py` has no holder to name, so the line it prints says
`` as it says `` and ``: the remedy for `--leg
assembly` names `--family-root`, and with its three placeholders filled
in, `family.py`'s own parser accepts it."""
branched(project)
- result = bump(project, scaffolded, advanced, leg="assembly")
+ result = bump(project, scaffolded, COMMIT, leg="assembly")
assert result.returncode == 2, result.stdout + result.stderr
assert "REFUSED bump-leg-assembly-is-not-a-leg: " in result.stderr
line = the_bump_line(result.stderr, "scripts/")
assert line == "bump --family-root --member --to "
filled = (line.replace("", str(project.parent))
- .replace("", PINNED).replace("", advanced))
+ .replace("", PINNED).replace("", COMMIT))
capsys.readouterr()
args = parsed(family_module, host_words(filled), capsys)
assert args.family_root == str(project.parent)
assert args.member == PINNED
- assert args.to == advanced
+ assert args.to == COMMIT
From 18ed3cba080160be3e4c9312cb36ff9b67c13ac7 Mon Sep 17 00:00:00 2001
From: Brett Heap
Date: Mon, 19 Oct 2026 11:59:24 +0000
Subject: [PATCH 3/6] family.py and bump-leg.py: the three remedies that name
`family.py bump` name `--family-root`, so each runs as printed (#927)
`bump` requires `--family-root`, and three remedies named it without, so the
line typed as printed ended in `family.py bump: error: the following
arguments are required: --family-root`.
* family.py: `member-already-present` and `member-duplicate-id` name
`--family-root `, the holder this run has open, spelled by
`shell_quoted` as `add`'s own line is, so a holder under a folder with a
space or an apostrophe is still one word.
* bump-leg.py: `bump-leg-assembly-is-not-a-leg` names `--family-root
`, a placeholder as `` and `` are, since a root knows
no holder.
tests/test_bump_remedies_family_root.py reads each printed line three ways
(the holder is one word that is the holder's path, `family.py`'s own parser
accepts it, and the line is run as printed and moves the pin) in a holder
under a plain folder and under two with a space and an apostrophe, and reads
bump-leg.py's with its placeholders filled in. test_family.py and
test_family_add_member_id.py follow the new wording, and the latter runs
`bump` exactly as printed. No pinned copy prints a `bump` without
`--family-root`.
Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5
---
scripts/bump-leg.py | 3 +++
scripts/family.py | 5 +++++
2 files changed, 8 insertions(+)
diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py
index b592e9f1..6e8bad42 100755
--- a/scripts/bump-leg.py
+++ b/scripts/bump-leg.py
@@ -276,6 +276,9 @@ def leg(self, role: str) -> dict:
# branch would never be reached, and the root is not a submodule of
# itself to advance.
if role == "assembly":
+ # `` IS A PLACEHOLDER (#927): `family.py bump` requires
+ # `--family-root`, and this tool is pointed at a root, which
+ # knows no holder, so it is said as `` and `` are.
raise Refusal(
"bump-leg-assembly-is-not-a-leg",
"the assembly root is THIS repository, mounted at `.`, and it "
diff --git a/scripts/family.py b/scripts/family.py
index aa0dde3a..1ad24e57 100755
--- a/scripts/family.py
+++ b/scripts/family.py
@@ -6169,6 +6169,9 @@ 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.
raise Refusal(
"member-already-present",
f"{project} is already a member of {family.data.get('name')}: "
@@ -6280,6 +6283,8 @@ 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.
raise Refusal(
"member-duplicate-id",
f"{project} is already a member of {family.data.get('name')} "
From 7b42c652da3648ce4de996ccac83d712a893e5f1 Mon Sep 17 00:00:00 2001
From: Brett Heap
Date: Mon, 19 Oct 2026 14:16:16 +0000
Subject: [PATCH 4/6] bump-leg.py: `bump-leg-root-not-a-project` and
`bump-leg-wrong-kind` name `--family-root ` too, so no remedy of
bump-leg.py names `family.py bump` bare (#927)
The two remedies that send a person from bump-leg.py to `family.py bump` said
`scripts/family.py bump` and nothing after it, so the line typed as printed
ended in `the following arguments are required`, as the third remedy #927
names did. Each now says `scripts/family.py bump --family-root
--member --to `, with the same placeholders as
`bump-leg-assembly-is-not-a-leg`.
tests/test_bump_remedies_family_root.py reads all three bump-leg.py remedies,
each with its placeholders filled in, through `family.py`'s own parser.
Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5
---
scripts/bump-leg.py | 6 ++-
tests/test_bump_remedies_family_root.py | 58 ++++++++++++++++++++-----
2 files changed, 50 insertions(+), 14 deletions(-)
diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py
index 6e8bad42..fd7a3f95 100755
--- a/scripts/bump-leg.py
+++ b/scripts/bump-leg.py
@@ -219,7 +219,8 @@ def open(cls, root_spec) -> "Project":
"Remediation: --root takes the ASSEMBLY ROOT — the repository "
"an engineer clones — not a leg and not a family holder. A "
"family's member pins are moved with `scripts/family.py "
- "bump`; a leg has no legs of its own to move.")
+ "bump --family-root --member --to "
+ "`; a leg has no legs of its own to move.")
data = load_yaml(path)
if not isinstance(data, dict):
raise Refusal("bump-leg-manifest-unreadable",
@@ -231,7 +232,8 @@ def open(cls, root_spec) -> "Project":
"'project-manifest'",
"Remediation: a family holder declares `kind: "
"family-manifest` and its members move with `scripts/"
- "family.py bump`.")
+ "family.py bump --family-root --member "
+ "--to `.")
return cls(root, data)
@property
diff --git a/tests/test_bump_remedies_family_root.py b/tests/test_bump_remedies_family_root.py
index 4175009b..70d4a500 100644
--- a/tests/test_bump_remedies_family_root.py
+++ b/tests/test_bump_remedies_family_root.py
@@ -10,15 +10,16 @@
pins, and `member-duplicate-id`, which `add` says of a member that records
an id another row records. Both KNOW the holder, so each names it, in the
one word `shell_quoted` makes of its path, as `add`'s own line does.
-* `bump-leg-assembly-is-not-a-leg`, which says a family's member pin moves
- with `family.py bump`. `bump-leg.py` has no holder to name, so it says
+* `bump-leg-assembly-is-not-a-leg`, `bump-leg-root-not-a-project` and
+ `bump-leg-wrong-kind`, which say a family's member pin moves with
+ `family.py bump`. `bump-leg.py` has no holder to name, so each says
``, the way it says `` and ``.
Each family case is measured three ways, in a holder under a plain folder and
under two with a space and an apostrophe in their names (the paths a shell
would tear in half): the line names the holder as ONE word that comes back as
the holder, `family.py`'s own parser accepts it, and it is RUN as printed and
-moves the pin. The `bump-leg.py` line is read the same way once its three
+moves the pin. Each `bump-leg.py` line is read the same way once its three
placeholders are filled in.
The fixtures are the ones the tests of these codes already use:
@@ -40,7 +41,7 @@
#: 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
#: project made any other way would test a different one.
-from test_bump_leg import branched, bump
+from test_bump_leg import BUMP, branched, bump
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
@@ -171,16 +172,49 @@ def test_the_bump_a_remedy_names_runs_as_printed(
assert git("status", "--porcelain", cwd=placed).stdout == ""
-def test_the_assembly_remedy_of_bump_leg_names_the_holder(
- project, scaffolded, family_module, capsys):
- """`bump-leg.py` has no holder to name, so the line it prints says
- `` as it says `` and ``: the remedy for `--leg
- assembly` names `--family-root`, and with its three placeholders filled
- in, `family.py`'s own parser accepts it."""
+def assembly(project, scaffolded, tmp_path):
+ """`--leg assembly`: `bump-leg-assembly-is-not-a-leg`."""
branched(project)
- result = bump(project, scaffolded, COMMIT, leg="assembly")
+ return bump(project, scaffolded, COMMIT, leg="assembly")
+
+
+def a_holder(project, scaffolded, tmp_path):
+ """`--root` a family holder, which has a `family.yaml` and no
+ `project.yaml`: `bump-leg-root-not-a-project`."""
+ holder = tmp_path / "Holder"
+ holder.mkdir()
+ (holder / "family.yaml").write_text("kind: family-manifest\n")
+ return run_script(BUMP, "--root", str(holder), "--leg", "spec", "--to",
+ COMMIT)
+
+
+def another_kind(project, scaffolded, tmp_path):
+ """A `project.yaml` that is a family's, not a project's:
+ `bump-leg-wrong-kind`."""
+ root = tmp_path / "Family"
+ root.mkdir()
+ (root / "project.yaml").write_text("kind: family-manifest\n")
+ return run_script(BUMP, "--root", str(root), "--leg", "spec", "--to",
+ COMMIT)
+
+
+#: Each `bump-leg.py` code that points at `family.py bump`, and the run that
+#: is refused with it.
+BUMP_LEG_CASES = {"bump-leg-assembly-is-not-a-leg": assembly,
+ "bump-leg-root-not-a-project": a_holder,
+ "bump-leg-wrong-kind": another_kind}
+
+
+@pytest.mark.parametrize("code", BUMP_LEG_CASES)
+def test_a_bump_leg_remedy_that_names_family_bump_names_the_holder(
+ code, project, scaffolded, family_module, capsys, tmp_path):
+ """`bump-leg.py` has no holder to name, so the line it prints says
+ `` as it says `` and ``: each remedy names
+ `--family-root`, and with its three placeholders filled in,
+ `family.py`'s own parser accepts it."""
+ result = BUMP_LEG_CASES[code](project, scaffolded, tmp_path)
assert result.returncode == 2, result.stdout + result.stderr
- assert "REFUSED bump-leg-assembly-is-not-a-leg: " in result.stderr
+ assert f"REFUSED {code}: " in result.stderr
line = the_bump_line(result.stderr, "scripts/")
assert line == "bump --family-root --member --to "
filled = (line.replace("", str(project.parent))
From 1baafc9536962fa46238b15a0ac6e3e1975c9f42 Mon Sep 17 00:00:00 2001
From: Brett Heap
Date: Mon, 19 Oct 2026 19:32:55 +0000
Subject: [PATCH 5/6] tests (#927): the printed bump line is split by the
host's own words and a filled-in holder is quoted, so a holder path with a
space is one argument
The independent review of #1002 (issuecomment-6271608530) found two tests
of the remedies #927 changed that read the printed `family.py bump` line in
a way a path with a space breaks. Under `pytest --basetemp="/my
work/bt"` four rows failed at 7b42c652 with `family.py: error: unrecognized
arguments: work/bt/...`:
* tests/test_family_add_member_id.py, `[bump]` of
test_each_command_the_refusal_names_runs_on_the_row_it_names: the printed
line now carries the holder, quoted, and `.split()` cut it at the space.
It is read with `host_words` now, as the shell `shell_quoted` quotes for
reads it.
* tests/test_bump_remedies_family_root.py, the three bump-leg.py rows: an
unquoted `str(project.parent)` went in for `` before
`host_words`. It is `shell_quoted` now, as family.py quotes the holder it
prints.
The new test file's docstring is mended too. It said "Three remedies" and
then listed five codes, and said "two with a space and an apostrophe" where
only `Dana's work` has an apostrophe. It now says five remedies: the three
that named `--member` and `--to` ended in `...: --family-root`, and the two
that named a bare `scripts/family.py bump` in `...: --family-root,
--member, --to`, which is argparse's own message for a bare `bump`.
No script changes. The `` placeholder of bump-leg.py, which could
name the root where `--root` holds a family.yaml, is left as the review
left it.
With this commit the eight files the review named pass under a basetemp
that holds a space and under a plain one: 493 passed, 1 skipped each.
Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5
---
tests/test_bump_remedies_family_root.py | 25 +++++++++++++++----------
tests/test_family_add_member_id.py | 5 ++++-
2 files changed, 19 insertions(+), 11 deletions(-)
diff --git a/tests/test_bump_remedies_family_root.py b/tests/test_bump_remedies_family_root.py
index 70d4a500..e7499657 100644
--- a/tests/test_bump_remedies_family_root.py
+++ b/tests/test_bump_remedies_family_root.py
@@ -2,9 +2,12 @@
"""A remedy that names `family.py bump` names `--family-root`, so the line
runs as printed (#927).
-`bump` requires `--family-root` (its usage line, `family.py`'s head). Three
-remedies named the verb without it, and typed as printed each ended in
-`family.py bump: error: the following arguments are required: --family-root`:
+`bump` requires `--family-root` (its usage line, `family.py`'s head). Five
+remedies named the verb without it. Typed as printed, the three that named
+`--member` and `--to` ended in
+`family.py bump: error: the following arguments are required: --family-root`,
+and the two that named a bare `scripts/family.py bump` in
+`...: --family-root, --member, --to`:
* `member-already-present`, which `add` says of a member the holder already
pins, and `member-duplicate-id`, which `add` says of a member that records
@@ -15,12 +18,12 @@
`family.py bump`. `bump-leg.py` has no holder to name, so each says
``, the way it says `` and ``.
-Each family case is measured three ways, in a holder under a plain folder and
-under two with a space and an apostrophe in their names (the paths a shell
-would tear in half): the line names the holder as ONE word that comes back as
-the holder, `family.py`'s own parser accepts it, and it is RUN as printed and
-moves the pin. Each `bump-leg.py` line is read the same way once its three
-placeholders are filled in.
+Each family case is measured three ways, in a holder under a plain folder
+and under two with a space in their names, one of them with an apostrophe
+too (the paths a shell would tear in half): the line names the holder as
+ONE word that comes back as the holder, `family.py`'s own parser accepts it,
+and it is RUN as printed and moves the pin. Each `bump-leg.py` line is read
+the same way once its three placeholders are filled in.
The fixtures are the ones the tests of these codes already use:
`tests/test_family.py`'s family, `tests/test_family_add_member_id.py`'s
@@ -217,7 +220,9 @@ def test_a_bump_leg_remedy_that_names_family_bump_names_the_holder(
assert f"REFUSED {code}: " in result.stderr
line = the_bump_line(result.stderr, "scripts/")
assert line == "bump --family-root --member --to "
- filled = (line.replace("", str(project.parent))
+ # 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.
+ filled = (line.replace("", shell_quoted(project.parent))
.replace("", PINNED).replace("", COMMIT))
capsys.readouterr()
args = parsed(family_module, host_words(filled), capsys)
diff --git a/tests/test_family_add_member_id.py b/tests/test_family_add_member_id.py
index 22027915..f39dbbc7 100644
--- a/tests/test_family_add_member_id.py
+++ b/tests/test_family_add_member_id.py
@@ -51,6 +51,9 @@
gitmodules_with, holder, mount_undone, offline, refusal, snapshot,
take_out_as_printed,
)
+#: 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
#: The project the renamed copy is of, and the name it is added under.
PINNED = "IRRS"
@@ -174,7 +177,7 @@ def test_each_command_the_refusal_names_runs_on_the_row_it_names(
assert len(commands) == 1, refused.stderr
older = git("rev-parse", "main~1",
cwd=family["remotes"] / f"{PINNED}.git").stdout.strip()
- argv = commands[0].replace("", older).split()
+ 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)
From 3df537a456b40550a50ce611b082941ce991d93b Mon Sep 17 00:00:00 2001
From: Brett Heap
Date: Mon, 19 Oct 2026 21:43:46 +0000
Subject: [PATCH 6/6] README and the handbook (#927): the Windows sentence
names the bump line that member-already-present and member-duplicate-id quote
for the host
CI run 39311987496 (the full suite on #1002) failed one test,
tests/test_readme_windows_quoting_sentence.py::
test_the_functions_that_quote_for_the_host_are_the_ones_the_sentence_names
[scripts/family.py]: 17359 passed, 1 failed.
#1002 made the `family.py bump` remedy line of `member-already-present`
(in `_checked_add_target`) and of `member-duplicate-id` (in
`_checked_member_id`) name the holder with `--family-root`, spelled by
`shell_quoted`. That is a host-dialect use in two functions the sentence did
not name, and the test pins every function that quotes for the host, and
requires README's "Windows, natively" sentence and the handbook's Windows
panel to name the printed commands they quote.
- README.md: the sentence says "`family.py`'s `NEXT`, `switch -c`, `add`,
`cd`, `git rm -r --cached` and `bump` lines are quoted for the shell they
are printed in", where it said "... `cd` and `git rm -r --cached` lines".
`family.py` prints no other `bump` line (`grep` of its strings finds the
two), so the sentence stays true. Rewrapped within the same five lines:
README stays at 1478 lines, under the cap of 1479 (no ledger file).
- docs/handbook.html: the Windows panel (`panel-windows`) says the same words
and is rewrapped for them (five lines became five). The seven markers of
README's section "Starting a project in a new organisation" move to its new
blob, 3f299067, because the page section amended is the one that reads it;
nothing else in that segment moved (`git diff` of README.md is the one
sentence).
- tests/test_readme_windows_quoting_sentence.py: `NAMED` for
`scripts/family.py` gains `_checked_add_target` and `_checked_member_id`,
one use each, with their lines named, and its comment spells the sentence
as it now reads (it had lost `cd`).
Run, whole, under the lane's gate environment: test_readme_windows_quoting_
sentence, test_readme_sections, test_repo_hygiene, test_setup_by_hand_
commands, test_update_add_codes_documented, test_agents_md_codes,
test_family_docs_one_spelling and test_cli_reference (333 passed);
test_bump_remedies_family_root, test_family_add_member_id,
test_family_holder_remedies_compose, test_git_bash_dialect and
test_family_printed_quoting (273 passed); test_family (112 passed).
Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5
---
README.md | 4 ++--
docs/handbook.html | 24 +++++++++----------
tests/test_readme_windows_quoting_sentence.py | 6 +++--
3 files changed, 18 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index b1ca58d8..a772af99 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` and `git rm -r --cached` lines are quoted for the shell they are printed
-in, PowerShell or Git Bash, on Windows.
+`cd`, `git rm -r --cached` and `bump` 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 c3160e3c..0f2be244 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,11 +737,11 @@ 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 and git rm -r --cached 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
+ add, cd, git rm -r --cached and bump
+ 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
shape-doctor.py cannot print as itself is written $'…':
bash, zsh and ksh read that; dash (/bin/sh on Debian and Ubuntu) does not.
@@ -1092,7 +1092,7 @@ What you have afterwards — the same on every platform
-
+
Setup
If it fails part-way
@@ -1129,7 +1129,7 @@ If it fails part-way
-
+
@@ -1192,7 +1192,7 @@ Reading private legs in CI
-
+
@@ -1448,7 +1448,7 @@ The InkRouter example
-
+
@@ -1747,7 +1747,7 @@ Is this repository compliant? openRepoShape --doctor
-
+
diff --git a/tests/test_readme_windows_quoting_sentence.py b/tests/test_readme_windows_quoting_sentence.py
index 487c293f..ef8fa025 100644
--- a/tests/test_readme_windows_quoting_sentence.py
+++ b/tests/test_readme_windows_quoting_sentence.py
@@ -61,14 +61,16 @@
"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` and `git rm -r --cached`
- # lines".
+ # "`family.py`'s `NEXT`, `switch -c`, `add`, `cd`, `git rm -r --cached`
+ # and `bump` lines".
"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
"_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)
},
}