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) }, }