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/scripts/bump-leg.py b/scripts/bump-leg.py index 746a75dd..8c5a470e 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 @@ -276,6 +278,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 " @@ -283,7 +288,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 010a24ed..bbcfd3a9 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -6610,13 +6610,17 @@ 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')}: " 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, @@ -6733,6 +6737,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')} " @@ -6744,9 +6750,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..e7499657 --- /dev/null +++ b/tests/test_bump_remedies_family_root.py @@ -0,0 +1,231 @@ +# 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). 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 + 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`, `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 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 +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 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 +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. +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) + 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 assembly(project, scaffolded, tmp_path): + """`--leg assembly`: `bump-leg-assembly-is-not-a-leg`.""" + branched(project) + 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 f"REFUSED {code}: " in result.stderr + line = the_bump_line(result.stderr, "scripts/") + 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. + filled = (line.replace("", shell_quoted(project.parent)) + .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 == COMMIT 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..f39dbbc7 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. @@ -50,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" @@ -69,10 +73,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 +113,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 +153,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 +164,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 @@ -164,10 +177,10 @@ 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 - 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 +235,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 +284,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() 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) }, }