From 6ac545f7bb798997311e894f9a3f373876ec7bb2 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 11:46:52 +0000 Subject: [PATCH 1/2] bump-leg.py and setup-project.py: the `git switch -c` line says under it what to do where an earlier act left that branch, so `already exists` is no dead end for a bump or a family's `add` (#647) #607 gave `family.py` and the holder's `validate-family.py` one sentence for a `switch -c` that fails `fatal: a branch named '' already exists` (exit 128): give the branch another name, which starts it where the holder stands, or, only if that branch holds work not merged yet, the same command with `switch` in place of `switch -c` goes back to it and keeps every commit on it. Two tools of this standard still printed a `switch -c` and said nothing more. - `scripts/bump-leg.py`: the sentence is its own constant, `BRANCH_LEFT`, with "the project" where the others say "the holder". It follows the command in all three places `_switch_command` is named: after `and re-run.` in the `bump-leg-on-tracking-branch` remedy, after the `bump-leg-root-detached` remedy, and as the last line of the dry run's hint (#477), under it. - `setup-project.py`: the same sentence, word for word, as one more comment line under the family block's `switch -c` line, again its own constant. No command line changes, and none is quoted differently: the sentence is words, not a second command. Four existing tests that pinned the old last lines of those outputs take the sentence (`test_bump_leg_dry_run_tracking.py`, `test_bump_leg_paste_lines.py`, `test_bump_leg_dry_run_truth.py`). The new `tests/test_branch_left_bump_leg_setup.py` types each printed line in a real local project and a real local holder where an earlier act left the branch, shows `already exists` (exit 128), and follows each choice the sentence gives. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- scripts/bump-leg.py | 35 +- setup-project.py | 24 +- tests/test_branch_left_bump_leg_setup.py | 415 +++++++++++++++++++++++ tests/test_bump_leg_dry_run_tracking.py | 31 +- tests/test_bump_leg_dry_run_truth.py | 3 +- tests/test_bump_leg_paste_lines.py | 16 +- 6 files changed, 502 insertions(+), 22 deletions(-) create mode 100644 tests/test_branch_left_bump_leg_setup.py diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py index e9121d8e..b4e8e2b4 100755 --- a/scripts/bump-leg.py +++ b/scripts/bump-leg.py @@ -1146,12 +1146,31 @@ def _switch_command(root: Path, role: str, commit: str) -> str: root, which always holds a backslash, prints in single quotes, as `shlex` spells it. They are lines for a POSIX shell (Git Bash, or WSL2 on Windows), as `update-shape.py`'s are, and README's "Windows, - natively" says so. + natively" says so. Each of the three is followed by `BRANCH_LEFT`, for a + root that has the branch already (#647). """ return shlex.join(["git", "-C", str(root), "switch", "-c", f"bump/{role}-{commit[:12]}"]) +#: What every place that names `_switch_command` says under it (#647): where +#: an earlier act left that branch, `switch -c` fails `fatal: a branch named +#: '' already exists` (exit 128), and nothing said what to do next. +#: Another name comes first, because a new branch starts where the project +#: stands now; going back to the old branch is right only where it holds work +#: not merged yet, and `git switch` keeps its commits. IN WORDS, NOT A SECOND +#: COMMAND, as `family.py` and the holder's `validate-family.py` say it: no +#: line of ours changes, so none needs quoting, and the three tools follow one +#: rule. THE SAME SENTENCE as their `BRANCH_LEFT` (#607), word for word, with +#: "the project" where they say "the holder". +BRANCH_LEFT = ("If `git switch -c` says that branch already exists, an " + "earlier act left it: give the branch another name, which " + "starts it where the project stands, or, only if that branch " + "holds work not merged yet, the same command with `switch` " + "in place of `switch -c` goes back to it and keeps every " + "commit on it.") + + def _branch_to_bump_from(root: Path, project: "Project", role: str, commit: str, *, dry_run: bool = False) -> str: """The branch on which the bump commits, refused if there is none or @@ -1163,7 +1182,8 @@ def _branch_to_bump_from(root: Path, project: "Project", role: str, commit, so on the tracking branch it is let through and ends by naming the branch the real run needs (#477); a root on no branch at all is refused either way. Both refusals name that branch's `switch -c` - through `_switch_command`, quoted (#532). + through `_switch_command`, quoted (#532), and end with `BRANCH_LEFT`, the + sentence for a root that has that branch already (#647). """ branch = current_branch(root) if branch is None: @@ -1172,7 +1192,7 @@ def _branch_to_bump_from(root: Path, project: "Project", role: str, f"{root} is not on a branch, so there is no branch to push and " "open a pull request from", f"Remediation: `{_switch_command(root, role, commit)}` and " - "re-run.") + f"re-run.\n{BRANCH_LEFT}") if branch == project.tracking and not dry_run: raise Refusal( "bump-leg-on-tracking-branch", @@ -1181,7 +1201,8 @@ def _branch_to_bump_from(root: Path, project: "Project", role: str, "Remediation: these organisations are pull-request only, so this " "will not make a commit whose only next step is a push to the " "default branch. Run:\n " - f"{_switch_command(root, role, commit)}\nand re-run.") + f"{_switch_command(root, role, commit)}\nand re-run.\n" + f"{BRANCH_LEFT}") return branch @@ -1670,11 +1691,13 @@ def cmd_bump(args) -> int: "untouched.") if branch == project.tracking: # LAST, after everything a dry run on a branch prints, so that - # line is the whole of the difference between the two (#477). - # The command is quoted, so a root under `My Projects/` pastes. + # line and the sentence under it are the whole of the difference + # between the two (#477, #647). The command is quoted, so a root + # under `My Projects/` pastes. print(f"\nthe real run needs a branch: {root} is on {branch!r}, " "the tracking branch. Create one first with: " + _switch_command(root, role, asked)) + print(BRANCH_LEFT) return 0 # ---- the rewrite and the commit, remembered byte for byte so a failure diff --git a/setup-project.py b/setup-project.py index bcd485f9..44043a8c 100755 --- a/setup-project.py +++ b/setup-project.py @@ -2334,6 +2334,25 @@ def shell_word(value, posix: bool = False) -> str: return "'" + text.replace("'", "'\\''") + "'" +#: What the `switch -c` line of the family block says under it (#647): where +#: an earlier try left that branch in the holder, `switch -c` fails `fatal: a +#: branch named '' already exists` (exit 128). Another name comes +#: first, because a new branch starts where the holder stands now; going back +#: is right only where the old branch holds work not merged yet. IN WORDS, NOT +#: A SECOND COMMAND, so no line changes and none needs quoting. THE SAME +#: SENTENCE as `scripts/family.py`'s and the holder's `validate-family.py`'s +#: `BRANCH_LEFT` (#607), word for word: this file carries its own copy, as it +#: does of `shell_word`, because it runs before any checkout of the standard +#: exists, and `tests/test_branch_left_bump_leg_setup.py` holds them to one +#: answer. +BRANCH_LEFT = ("If `git switch -c` says that branch already exists, an " + "earlier act left it: give the branch another name, which " + "starts it where the holder stands, or, only if that branch " + "holds work not merged yet, the same command with `switch` " + "in place of `switch -c` goes back to it and keeps every " + "commit on it.") + + def family_next_command(project: str, org: str, landing: Landing) -> None: """The LAST line of the next commands: who records the membership. @@ -2352,12 +2371,15 @@ def family_next_command(project: str, org: str, landing: Landing) -> None: A BRANCH FIRST, AND EVERY PATH ONE WORD (#487, #486). `add` refuses to commit onto the holder's tracking branch, so the `git switch -c` that makes the branch it suggests comes before it; and a holder under a - folder with a space in it must reach `--family-root` whole. + folder with a space in it must reach `--family-root` whole. Under the + `switch -c`, one comment line says what to do where the holder has that + branch already (`BRANCH_LEFT`, #647). """ holder = shell_word(landing.holder) say(" git -C %s switch -c family/add-%s" % (holder, project.lower())) say(" # a branch first: `add` refuses the") say(" # holder's tracking branch") + say(" # " + BRANCH_LEFT) say(" %s scripts/family.py add --family-root %s --member %s" % (PYTHON_CMD, holder, shell_word("%s/%s" % (org, project)))) say(" # from a checkout of openRepoShape. The") diff --git a/tests/test_branch_left_bump_leg_setup.py b/tests/test_branch_left_bump_leg_setup.py new file mode 100644 index 00000000..f46d00f3 --- /dev/null +++ b/tests/test_branch_left_bump_leg_setup.py @@ -0,0 +1,415 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The `git switch -c` line of `bump-leg.py` and of `setup-project.py`'s +family block, where an earlier act left that branch (#647). + +THE BRANCH THAT IS THERE ALREADY. #607 gave `family.py` and the holder's +`validate-family.py` one sentence for a `switch -c` that fails `fatal: a +branch named '' already exists` (exit 128): give the branch another +name, which starts it where the holder stands, or, only if that branch holds +work not merged yet, the same command with `switch` in place of `switch -c` +goes back to it and keeps every commit on it. Two tools of this standard +still printed a `switch -c` and said nothing more: `bump-leg.py`, in both +refusals of `_branch_to_bump_from` (`bump-leg-on-tracking-branch`, +`bump-leg-root-detached`) and in the dry run's closing hint (#477), and +`setup-project.py`, in the family block it ends on (#487). Each now says the +SAME sentence under that line, `bump-leg.py` with "the project" where the +others say "the holder". No command line changes. + +THE TEXT IS PINNED HERE, as a person, an agent and a holder's CI read it, and +each tool's own constant is held to it: one rule, in the same words. + +THE PROOF IS THE PRINTED LINE, TYPED. In a real local project (`bump-leg.py`) +and a real local holder (the family block), an earlier act leaves the branch +with a commit of its own, the printed `switch -c` fails `already exists` +(exit 128) and changes nothing, and each choice the sentence gives works: +another name starts at the commit the root stands on, and the same command +with `switch` for `switch -c` goes back to the branch at its own commit. The +act then goes through on either, and the commit it writes sits on top of the +commit it started from. + +NO NETWORK, NO `gh`, NO SHELL: each line is read back the way a shell reads +it and typed as an argv, so the tests hold on every OS the suite runs on. +Nothing here pushes. +""" + +from __future__ import annotations + +import importlib.util +import re +import subprocess +from pathlib import Path + +import pytest + +from conftest import PROJECT, REPO, cloned_just_pushed, git +from test_bump_leg import bump, bump_leg_module # noqa: F401 +from test_bump_leg_paste_lines import ( + BRANCH_LEFT_PROJECT, detached_remedy, switch_argv, tracking_remedy, words, +) +from test_family import ORG, SPARE, family # noqa: F401 +from test_family_printed_quoting import FOLDERS, host_words +from test_family_tracking_branch import ( # noqa: F401 + act, on_main, suggested, +) +from test_setup_project_py import run_entry + +SETUP = REPO / "setup-project.py" + +#: The sentence of `family.py` and the holder's `validate-family.py` (#607), +#: pinned as text. `bump-leg.py` says the same sentence with a project where +#: this has a holder: `BRANCH_LEFT_PROJECT`, pinned in +#: `test_bump_leg_paste_lines.py` beside the lines it follows. +BRANCH_LEFT_HOLDER = ( + "If `git switch -c` says that branch already exists, an earlier act left " + "it: give the branch another name, which starts it where the holder " + "stands, or, only if that branch holds work not merged yet, the same " + "command with `switch` in place of `switch -c` goes back to it and keeps " + "every commit on it.") + +#: Where the advanced spec commit is pushed. Not another module's branch: the +#: bare remotes are session-scoped, and a second, different commit pushed to +#: a name already taken is refused as non-fast-forward. +ADVANCE_BRANCH = "bump-leg-branch-left-advance" + +#: The three places `bump-leg.py` names a `switch -c`. +KINDS = pytest.mark.parametrize("kind", ["tracking", "detached", "dry-run"]) + +#: What stands under the command in each, to the end of the output: the +#: tracking-branch remedy ends `and re-run.` under its command line, the +#: others carry the command on the line they stand on. +UNDER_THE_COMMAND = { + "tracking": ["and re-run.", BRANCH_LEFT_PROJECT], + "detached": [BRANCH_LEFT_PROJECT], + "dry-run": [BRANCH_LEFT_PROJECT], +} + +#: Needle the dry run's hint puts directly before the command. +PASTE_AFTER = "Create one first with: " + + +@pytest.fixture(scope="module") +def advanced(scaffolded, tmp_path_factory) -> str: + """A real new commit on the spec leg's bare remote; returns its sha.""" + work = cloned_just_pushed( + scaffolded["remotes"] / f"{PROJECT}-spec.git", + tmp_path_factory.mktemp("spec-advance-branch-left") + / f"{PROJECT}-spec") + (work / "BRANCH-LEFT.md").write_text("# a contract gained\n") + git("add", "--", "BRANCH-LEFT.md", cwd=work) + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", + "Advance the spec leg for the branch-left tests", cwd=work) + git("push", "-q", "origin", f"HEAD:refs/heads/{ADVANCE_BRANCH}", cwd=work) + return git("rev-parse", "HEAD", cwd=work).stdout.strip() + + +def setup_module(): + """`setup-project.py` as a module (the name has a hyphen in it).""" + spec = importlib.util.spec_from_file_location("setup_branch_left", SETUP) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +# --- the text, one rule --------------------------------------------------------- + +def test_bump_leg_says_the_sentence_with_the_project(bump_leg_module): + assert bump_leg_module.BRANCH_LEFT == BRANCH_LEFT_PROJECT + + +def test_setup_says_the_sentence_with_the_holder(): + assert setup_module().BRANCH_LEFT == BRANCH_LEFT_HOLDER + + +def test_the_two_texts_differ_in_the_one_word_only(): + assert BRANCH_LEFT_HOLDER.count("the holder") == 1 + assert BRANCH_LEFT_HOLDER.replace("the holder", "the project") == \ + BRANCH_LEFT_PROJECT + + +# --- what is typed ------------------------------------------------------------ + +def head(root: Path) -> str: + return git("rev-parse", "HEAD", cwd=root).stdout.strip() + + +def on_branch(root: Path) -> str: + return git("symbolic-ref", "--short", "-q", "HEAD", cwd=root, + check=False).stdout.strip() + + +def typed(argv: list[str]) -> subprocess.CompletedProcess: + """`argv` run as a person runs the line it was read from.""" + return subprocess.run(argv, capture_output=True, text=True, check=False) + + +def without_c(argv: list[str]) -> list[str]: + """The same command with `switch` in place of `switch -c`, as the + sentence says.""" + assert argv.count("-c") == 1, argv + return [word for word in argv if word != "-c"] + + +def leave_branch(root: Path, branch: str) -> str: + """What an earlier act leaves: `branch`, made from the root's HEAD and + holding one commit of its own, while the root stays where it stands. + Written with `commit-tree` and `update-ref`, so nothing is checked out. + Returns that commit.""" + earlier = git("commit-tree", "-p", "HEAD", "-m", "An earlier act's work", + "HEAD^{tree}", cwd=root).stdout.strip() + git("update-ref", f"refs/heads/{branch}", earlier, cwd=root) + return earlier + + +def refused_as_left(root: Path, switch: list[str], branch: str) -> None: + """The printed `switch -c`, typed where `branch` is there: git's `fatal: + a branch named ... already exists`, exit 128, and nothing changed.""" + before = head(root) + stood_on = on_branch(root) + made = typed(switch) + assert made.returncode == 128, made.stderr + assert re.search(rf"fatal: a branch named '{re.escape(branch)}' " + "already exists", made.stderr, re.I), made.stderr + assert head(root) == before + assert on_branch(root) == stood_on + + +def type_where_none_is_left(root: Path, switch: list[str], + branch: str) -> None: + """The control: with no such branch, the printed line makes it.""" + made = typed(switch) + assert made.returncode == 0, made.stderr + assert on_branch(root) == branch + + +def type_another_name(root: Path, switch: list[str], branch: str) -> str: + """The printed line where `branch` is there (refused), then with another + name: the new branch starts where the root stands. Returns its name.""" + assert switch[-1] == branch, switch + refused_as_left(root, switch, branch) + other = f"{branch}-2" + before = head(root) + made = typed([*switch[:-1], other]) + assert made.returncode == 0, made.stderr + assert head(root) == before + assert on_branch(root) == other + return other + + +def type_going_back(root: Path, switch: list[str], branch: str, + earlier: str) -> None: + """The printed line where `branch` is there (refused), then the same + command with `switch` in place of `switch -c`: back on the branch, at the + commit it holds.""" + refused_as_left(root, switch, branch) + back = typed(without_c(switch)) + assert back.returncode == 0, back.stderr + assert on_branch(root) == branch + assert head(root) == earlier + + +def parent(root: Path) -> str: + return git("rev-parse", "HEAD~1", cwd=root).stdout.strip() + + +# --- bump-leg.py: what is printed, under the command --------------------------- + +def put_where(kind: str, root: Path) -> None: + """The root where `kind` meets it: on its tracking branch (what a fresh + project is), or on no branch.""" + if kind == "detached": + git("checkout", "-q", "--detach", cwd=root) + + +def printed(kind: str, root: Path, scaffolded: dict, advanced: str): + """`bump-leg.py` run where `kind` meets the root: the printed + `switch -c` as the argv a POSIX shell reads out of it, and every line + under the command to the end of what was printed, which ends with the + sentence.""" + put_where(kind, root) + if kind == "dry-run": + result = bump(root, scaffolded, advanced, "--dry-run") + assert result.returncode == 0, result.stderr + result.stdout + lines = result.stdout.splitlines() + command = lines[-2].partition(PASTE_AFTER)[2] + assert command, result.stdout + under = lines[-1:] + else: + result = bump(root, scaffolded, advanced) + assert result.returncode == 2, result.stderr + result.stdout + assert result.stdout == "" + lines = result.stderr.splitlines() + if kind == "detached": + command = detached_remedy(result.stderr) + at = lines.index(f"Remediation: `{command}` and re-run.") + else: + command = tracking_remedy(result.stderr) + at = lines.index(" " + command) + under = lines[at + 1:] + assert under[-1] == BRANCH_LEFT_PROJECT, under + return words(command), under + + +@KINDS +def test_each_place_says_the_sentence_under_its_command( + project, scaffolded, advanced, kind): + """THE ISSUE, for each of the three places. The command line is the one + it always was, and what stands under it, to the end, is the sentence: + after `and re-run.` in the tracking-branch remedy, alone under the other + two.""" + switch, under = printed(kind, project, scaffolded, advanced) + assert switch == switch_argv(project, advanced) + assert under == UNDER_THE_COMMAND[kind] + + +@KINDS +def test_the_printed_line_makes_the_branch_where_none_is_left( + project, scaffolded, advanced, kind): + """The control: with no such branch, the printed `switch -c` makes it, + and the run it refused goes through on it.""" + switch, _ = printed(kind, project, scaffolded, advanced) + before = head(project) + type_where_none_is_left(project, switch, switch[-1]) + done = bump(project, scaffolded, advanced) + assert done.returncode == 0, done.stderr + done.stdout + assert parent(project) == before + + +@KINDS +def test_another_name_works_where_an_earlier_bump_left_the_branch( + project, scaffolded, advanced, kind): + """CHOICE ONE. An earlier bump left `bump/spec-` holding a commit of + its own: the printed line fails `already exists`, the same line with + another name starts a branch where the root stands, and the bump goes + through there, on top of the commit it started from.""" + branch = f"bump/spec-{advanced[:12]}" + put_where(kind, project) + leave_branch(project, branch) + switch, _ = printed(kind, project, scaffolded, advanced) + other = type_another_name(project, switch, branch) + before = head(project) + done = bump(project, scaffolded, advanced) + assert done.returncode == 0, done.stderr + done.stdout + assert on_branch(project) == other + assert parent(project) == before + assert git("rev-parse", "HEAD:spec", cwd=project).stdout.strip() == \ + advanced + + +@KINDS +def test_going_back_works_where_an_earlier_bump_left_the_branch( + project, scaffolded, advanced, kind): + """CHOICE TWO. The same command with `switch` in place of `switch -c` + goes back to the branch at its own commit, and the bump goes through + there, on top of the earlier commit, which it keeps.""" + branch = f"bump/spec-{advanced[:12]}" + put_where(kind, project) + earlier = leave_branch(project, branch) + switch, _ = printed(kind, project, scaffolded, advanced) + type_going_back(project, switch, branch, earlier) + done = bump(project, scaffolded, advanced) + assert done.returncode == 0, done.stderr + done.stdout + assert on_branch(project) == branch + assert parent(project) == earlier + assert git("rev-parse", "HEAD:spec", cwd=project).stdout.strip() == \ + advanced + + +# --- setup-project.py: the family block ---------------------------------------- + +def family_block(landing, capsys, project: str = "Sample", + org: str = "demoorg") -> list[str]: + """What `family_next_command` prints for `landing`, line by line.""" + setup_module().family_next_command(project, org, landing) + return capsys.readouterr().out.splitlines() + + +def landing_for(holder: Path): + return setup_module().Landing(holder.parent, holder.name, holder) + + +@pytest.mark.parametrize("folder", FOLDERS) +def test_setup_says_the_sentence_under_its_switch_line(tmp_path, folder, + capsys): + """THE ISSUE. The `switch -c` line is the one it was, then its two + comment lines, then ONE more comment line, the sentence, in the holder's + words, before the `add` line; and the commands are the same two.""" + holder = tmp_path / folder / "Orbit" / "Orbit" + lines = family_block(landing_for(holder), capsys) + assert host_words(lines[0].strip()) == [ + "git", "-C", str(holder), "switch", "-c", "family/add-sample"] + assert lines[1].strip() == "# a branch first: `add` refuses the" + assert lines[2].strip() == "# holder's tracking branch" + assert lines[3] == " " * 27 + "# " + BRANCH_LEFT_HOLDER + assert host_words(lines[4].strip())[1:4] == ["scripts/family.py", "add", + "--family-root"] + commands = [line for line in lines + if line.strip() and not line.strip().startswith("#")] + assert len(commands) == 2, lines + + +def test_a_real_run_prints_the_sentence_once_under_the_switch_line(tmp_path): + """The block as `hand_over` prints it, from a real run into a family + folder: the sentence stands once, in the same words, right after the + comment that says why a branch comes first.""" + result = run_entry("Sample", "--yes", "--org", "demoorg", + "--family", "Zephyr", + "--local-remote-dir", str(tmp_path / "remotes"), + "--into", str(tmp_path / "work")) + assert result.returncode == 0, result.stderr + result.stdout + lines = result.stdout.splitlines() + assert lines.count(" " * 27 + "# " + BRANCH_LEFT_HOLDER) == 1, lines + at = lines.index(" " * 27 + "# " + BRANCH_LEFT_HOLDER) + assert lines[at - 1].strip() == "# holder's tracking branch" + assert " switch -c family/add-sample" in lines[at - 3] + + +def setup_switch(holder: Path, capsys) -> list[str]: + """The `switch -c` the family block prints for the fixture's spare + member, as the reader's shell reads it, with the sentence under it.""" + lines = family_block(landing_for(holder), capsys, SPARE, ORG) + assert lines[3] == " " * 27 + "# " + BRANCH_LEFT_HOLDER + return host_words(lines[0].strip()) + + +def test_the_family_blocks_line_makes_the_branch_where_none_is_left( + family, on_main, capsys): + """The control, in a real holder: the block's `switch -c` makes the + branch, and the `add` under it goes through on it.""" + holder = on_main + switch = setup_switch(holder, capsys) + assert switch[-1] == suggested(family, "add") + type_where_none_is_left(holder, switch, switch[-1]) + done = act(family, holder, "add") + assert done.returncode == 0, done.stdout + done.stderr + assert on_branch(holder) == switch[-1] + + +def test_another_name_works_where_an_earlier_try_left_the_family_branch( + family, on_main, capsys): + """CHOICE ONE, in a real holder that has `family/add-` from an + earlier try, with a commit of its own: the block's line fails `already + exists`, another name starts where the holder stands, and the `add` + goes through there.""" + holder = on_main + switch = setup_switch(holder, capsys) + leave_branch(holder, switch[-1]) + other = type_another_name(holder, switch, switch[-1]) + before = head(holder) + done = act(family, holder, "add") + assert done.returncode == 0, done.stdout + done.stderr + assert on_branch(holder) == other + assert parent(holder) == before + + +def test_going_back_works_where_an_earlier_try_left_the_family_branch( + family, on_main, capsys): + """CHOICE TWO, in the same holder: the same command with `switch` goes + back to the branch at its own commit, and the `add` goes through on top + of it.""" + holder = on_main + switch = setup_switch(holder, capsys) + earlier = leave_branch(holder, switch[-1]) + type_going_back(holder, switch, switch[-1], earlier) + done = act(family, holder, "add") + assert done.returncode == 0, done.stdout + done.stderr + assert parent(holder) == earlier diff --git a/tests/test_bump_leg_dry_run_tracking.py b/tests/test_bump_leg_dry_run_tracking.py index 5b8a168a..43c53834 100644 --- a/tests/test_bump_leg_dry_run_tracking.py +++ b/tests/test_bump_leg_dry_run_tracking.py @@ -33,6 +33,7 @@ from conftest import PROJECT, cloned_just_pushed, git from test_bump_leg import branched, bump, reference_both_legs +from test_bump_leg_paste_lines import BRANCH_LEFT_PROJECT from test_bump_leg_rollback import assert_nothing_changed, snapshot from test_commit_trailers import TRAILERS @@ -85,7 +86,8 @@ def switch_command(project: Path, to: str) -> str: def test_a_dry_run_on_the_tracking_branch_prints_the_move_and_names_a_branch( project, scaffolded, advanced): """THE FIX: exit 0, the whole report, and a last line that says what the - real run needs. The workflows and the trailers are printed too, so the + real run needs, with the sentence for a branch that is there already + under it (#647). The workflows and the trailers are printed too, so the run is shown to reach every part of the report a dry run on a branch reaches, not only the first line.""" was, _ = reference_both_legs(project) @@ -106,7 +108,8 @@ def test_a_dry_run_on_the_tracking_branch_prints_the_move_and_names_a_branch( assert "--dry-run: nothing was changed." in out assert "bump-leg-on-tracking-branch" not in out - hint = out.splitlines()[-1] + *_, hint, sentence = out.splitlines() + assert sentence == BRANCH_LEFT_PROJECT assert f"the real run {NEEDS_A_BRANCH}" in hint assert hint.endswith(switch_command(project, advanced)), hint assert out.index(old_to_new) < out.index(TRAILERS[0]), ( @@ -123,9 +126,10 @@ def test_a_dry_run_on_the_tracking_branch_prints_the_move_and_names_a_branch( def test_the_branch_hint_is_the_only_difference_from_a_dry_run_on_a_branch( project, scaffolded, advanced): """The same root, the same arguments, first on `main` and then on a - branch: the second report is the first minus its last two lines, a blank - one and the hint, byte for byte. So "a dry run on a branch is unchanged" - is a comparison, not a pasted copy of today's wording.""" + branch: the second report is the first minus its last three lines, a + blank one, the hint and the sentence under it (#647), byte for byte. So + "a dry run on a branch is unchanged" is a comparison, not a pasted copy + of today's wording.""" reference_both_legs(project) on_tracking = bump(project, scaffolded, advanced, DRY_RUN) branched(project) @@ -137,8 +141,10 @@ def test_the_branch_hint_is_the_only_difference_from_a_dry_run_on_a_branch( "a root already on a branch is told nothing about one") assert on_tracking.stdout.startswith(on_a_branch.stdout) tail = on_tracking.stdout[len(on_a_branch.stdout):] - assert tail.splitlines() == ["", on_tracking.stdout.splitlines()[-1]] - assert tail.endswith(switch_command(project, advanced) + "\n") + *_, hint, sentence = on_tracking.stdout.splitlines() + assert tail.splitlines() == ["", hint, sentence] + assert tail.endswith(switch_command(project, advanced) + "\n" + + BRANCH_LEFT_PROJECT + "\n") assert on_tracking.stderr == "" assert on_a_branch.stderr == "" @@ -157,7 +163,7 @@ def test_the_printed_switch_command_pastes_for_a_root_with_a_space( result = bump(root, scaffolded, advanced, DRY_RUN) assert result.returncode == 0, result.stderr + result.stdout - _, found, command = result.stdout.splitlines()[-1].partition(PASTE_AFTER) + _, found, command = result.stdout.splitlines()[-2].partition(PASTE_AFTER) assert found, result.stdout branch = f"bump/spec-{advanced[:12]}" @@ -186,9 +192,10 @@ def test_the_real_run_on_the_tracking_branch_is_refused_byte_for_byte( project, scaffolded, advanced): """The refusal's every byte, spelled out, and nothing on stdout: this is the text the run had before #477, and a change to it is a change to what - a lane that scripts around it reads. The `switch -c` on its last line is - spelled as the dry run's hint is, by `shlex` (#532): a plain root prints - the bytes it always did, and one that needs quoting prints quoted.""" + a lane that scripts around it reads. The `switch -c` is spelled as the + dry run's hint is, by `shlex` (#532): a plain root prints the bytes it + always did, and one that needs quoting prints quoted. The sentence for a + branch that is there already ends it, under `and re-run.` (#647).""" root = project.resolve() before = snapshot(project) @@ -201,7 +208,7 @@ def test_the_real_run_on_the_tracking_branch_is_refused_byte_for_byte( "Remediation: these organisations are pull-request only, so this " "will not make a commit whose only next step is a push to the " f"default branch. Run:\n {switch_command(project, advanced)}\n" - "and re-run.\n") + f"and re-run.\n{BRANCH_LEFT_PROJECT}\n") assert_nothing_changed(project, before) diff --git a/tests/test_bump_leg_dry_run_truth.py b/tests/test_bump_leg_dry_run_truth.py index 86a51187..2d9e280c 100644 --- a/tests/test_bump_leg_dry_run_truth.py +++ b/tests/test_bump_leg_dry_run_truth.py @@ -433,7 +433,8 @@ def test_the_dry_runs_branch_hint_names_the_branch_the_real_runs_refusal_names( assert_nothing_changed(project, before) assert peel_line(tag, commit) in dry.stdout, "the dry run peeled the tag" assert "REFUSED bump-leg-on-tracking-branch" in real.stderr - hint = dry.stdout.splitlines()[-1] + # The hint is the last line but one: the sentence #647 adds is under it. + hint = dry.stdout.splitlines()[-2] assert "the real run needs a branch" in hint named_by_dry_run = SWITCH_BRANCH.search(hint).group(1) named_by_real_run = SWITCH_BRANCH.search(real.stderr).group(1) diff --git a/tests/test_bump_leg_paste_lines.py b/tests/test_bump_leg_paste_lines.py index 5763c3b4..32e95199 100644 --- a/tests/test_bump_leg_paste_lines.py +++ b/tests/test_bump_leg_paste_lines.py @@ -51,6 +51,16 @@ #: a name already taken is refused as non-fast-forward. ADVANCE_BRANCH = "bump-leg-paste-lines-advance" +#: The sentence each of the three lines is followed by (#647), pinned as text: +#: `family.py`'s and the holder's `BRANCH_LEFT` (#607) with a project where +#: they have a holder. Imported by the tests that read these lines' bytes. +BRANCH_LEFT_PROJECT = ( + "If `git switch -c` says that branch already exists, an earlier act left " + "it: give the branch another name, which starts it where the project " + "stands, or, only if that branch holds work not merged yet, the same " + "command with `switch` in place of `switch -c` goes back to it and keeps " + "every commit on it.") + #: A directory name each way a path breaks a line pasted bare: a space (two #: arguments), an apostrophe (a quotation nothing closes), a `$` (an #: expansion). @@ -225,11 +235,13 @@ def test_an_exact_remedy_is_the_line_shlex_spells(make_root, scaffolded, tracking = bump(root, scaffolded, advanced) assert tracking.stderr.endswith( "will not make a commit whose only next step is a push to the " - f"default branch. Run:\n {line}\nand re-run.\n"), tracking.stderr + f"default branch. Run:\n {line}\nand re-run.\n" + f"{BRANCH_LEFT_PROJECT}\n"), tracking.stderr git("checkout", "-q", "--detach", cwd=root) detached = bump(root, scaffolded, advanced) - assert detached.stderr.endswith(f"Remediation: `{line}` and re-run.\n"), \ + assert detached.stderr.endswith( + f"Remediation: `{line}` and re-run.\n{BRANCH_LEFT_PROJECT}\n"), \ detached.stderr From be1bff901b5b92e54ac1d816f44badd372a42233 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 12:02:28 +0000 Subject: [PATCH 2/2] test_family_switch_branch_exists.py and test_branch_left_bump_leg_setup.py: #607's equality test holds all four copies of the sentence, so setup-project.py or bump-leg.py cannot drift from family.py's words unseen (#647) #607 landed (95780dd0) after the first commit, so the sentence is no longer pinned a second time in the new test module. `test_the_two_tools_say_the_same_words` becomes `test_the_tools_say_the_same_words` and also asserts that `setup-project.py`'s `BRANCH_LEFT` equals `family.py`'s and the holder's word for word, and that `bump-leg.py`'s equals it with "the project" for "the holder". The new module drops its own copies of that test and of #607's typing helpers, and imports them from `test_family_switch_branch_exists.py`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_branch_left_bump_leg_setup.py | 119 ++++------------------ tests/test_family_switch_branch_exists.py | 15 ++- 2 files changed, 29 insertions(+), 105 deletions(-) diff --git a/tests/test_branch_left_bump_leg_setup.py b/tests/test_branch_left_bump_leg_setup.py index f46d00f3..5fd424ed 100644 --- a/tests/test_branch_left_bump_leg_setup.py +++ b/tests/test_branch_left_bump_leg_setup.py @@ -15,8 +15,11 @@ SAME sentence under that line, `bump-leg.py` with "the project" where the others say "the holder". No command line changes. -THE TEXT IS PINNED HERE, as a person, an agent and a holder's CI read it, and -each tool's own constant is held to it: one rule, in the same words. +THE TEXT IS PINNED AS TEXT, as a person, an agent and a holder's CI read it: +the holder's words in `test_validate_family_branch_step.py`, the project's in +`test_bump_leg_paste_lines.py` beside the lines it follows. Each tool's own +constant is held to them by `test_the_tools_say_the_same_words` in +`test_family_switch_branch_exists.py`: one rule, in the same words. THE PROOF IS THE PRINTED LINE, TYPED. In a real local project (`bump-leg.py`) and a real local holder (the family block), an earlier act leaves the branch @@ -34,37 +37,26 @@ from __future__ import annotations -import importlib.util -import re -import subprocess from pathlib import Path import pytest -from conftest import PROJECT, REPO, cloned_just_pushed, git -from test_bump_leg import bump, bump_leg_module # noqa: F401 +from conftest import PROJECT, cloned_just_pushed, git +from test_bump_leg import bump from test_bump_leg_paste_lines import ( BRANCH_LEFT_PROJECT, detached_remedy, switch_argv, tracking_remedy, words, ) from test_family import ORG, SPARE, family # noqa: F401 from test_family_printed_quoting import FOLDERS, host_words +from test_family_switch_branch_exists import ( + head, leave_branch, on_branch, refused_as_left, type_other_name, typed, + without_c, +) from test_family_tracking_branch import ( # noqa: F401 act, on_main, suggested, ) -from test_setup_project_py import run_entry - -SETUP = REPO / "setup-project.py" - -#: The sentence of `family.py` and the holder's `validate-family.py` (#607), -#: pinned as text. `bump-leg.py` says the same sentence with a project where -#: this has a holder: `BRANCH_LEFT_PROJECT`, pinned in -#: `test_bump_leg_paste_lines.py` beside the lines it follows. -BRANCH_LEFT_HOLDER = ( - "If `git switch -c` says that branch already exists, an earlier act left " - "it: give the branch another name, which starts it where the holder " - "stands, or, only if that branch holds work not merged yet, the same " - "command with `switch` in place of `switch -c` goes back to it and keeps " - "every commit on it.") +from test_setup_project_py import entry_point_module, run_entry +from test_validate_family_branch_step import BRANCH_LEFT as BRANCH_LEFT_HOLDER #: Where the advanced spec commit is pushed. Not another module's branch: the #: bare remotes are session-scoped, and a second, different commit pushed to @@ -102,25 +94,9 @@ def advanced(scaffolded, tmp_path_factory) -> str: return git("rev-parse", "HEAD", cwd=work).stdout.strip() -def setup_module(): - """`setup-project.py` as a module (the name has a hyphen in it).""" - spec = importlib.util.spec_from_file_location("setup_branch_left", SETUP) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - # --- the text, one rule --------------------------------------------------------- -def test_bump_leg_says_the_sentence_with_the_project(bump_leg_module): - assert bump_leg_module.BRANCH_LEFT == BRANCH_LEFT_PROJECT - - -def test_setup_says_the_sentence_with_the_holder(): - assert setup_module().BRANCH_LEFT == BRANCH_LEFT_HOLDER - - -def test_the_two_texts_differ_in_the_one_word_only(): +def test_the_two_pinned_texts_differ_in_the_one_word_only(): assert BRANCH_LEFT_HOLDER.count("the holder") == 1 assert BRANCH_LEFT_HOLDER.replace("the holder", "the project") == \ BRANCH_LEFT_PROJECT @@ -128,51 +104,6 @@ def test_the_two_texts_differ_in_the_one_word_only(): # --- what is typed ------------------------------------------------------------ -def head(root: Path) -> str: - return git("rev-parse", "HEAD", cwd=root).stdout.strip() - - -def on_branch(root: Path) -> str: - return git("symbolic-ref", "--short", "-q", "HEAD", cwd=root, - check=False).stdout.strip() - - -def typed(argv: list[str]) -> subprocess.CompletedProcess: - """`argv` run as a person runs the line it was read from.""" - return subprocess.run(argv, capture_output=True, text=True, check=False) - - -def without_c(argv: list[str]) -> list[str]: - """The same command with `switch` in place of `switch -c`, as the - sentence says.""" - assert argv.count("-c") == 1, argv - return [word for word in argv if word != "-c"] - - -def leave_branch(root: Path, branch: str) -> str: - """What an earlier act leaves: `branch`, made from the root's HEAD and - holding one commit of its own, while the root stays where it stands. - Written with `commit-tree` and `update-ref`, so nothing is checked out. - Returns that commit.""" - earlier = git("commit-tree", "-p", "HEAD", "-m", "An earlier act's work", - "HEAD^{tree}", cwd=root).stdout.strip() - git("update-ref", f"refs/heads/{branch}", earlier, cwd=root) - return earlier - - -def refused_as_left(root: Path, switch: list[str], branch: str) -> None: - """The printed `switch -c`, typed where `branch` is there: git's `fatal: - a branch named ... already exists`, exit 128, and nothing changed.""" - before = head(root) - stood_on = on_branch(root) - made = typed(switch) - assert made.returncode == 128, made.stderr - assert re.search(rf"fatal: a branch named '{re.escape(branch)}' " - "already exists", made.stderr, re.I), made.stderr - assert head(root) == before - assert on_branch(root) == stood_on - - def type_where_none_is_left(root: Path, switch: list[str], branch: str) -> None: """The control: with no such branch, the printed line makes it.""" @@ -181,20 +112,6 @@ def type_where_none_is_left(root: Path, switch: list[str], assert on_branch(root) == branch -def type_another_name(root: Path, switch: list[str], branch: str) -> str: - """The printed line where `branch` is there (refused), then with another - name: the new branch starts where the root stands. Returns its name.""" - assert switch[-1] == branch, switch - refused_as_left(root, switch, branch) - other = f"{branch}-2" - before = head(root) - made = typed([*switch[:-1], other]) - assert made.returncode == 0, made.stderr - assert head(root) == before - assert on_branch(root) == other - return other - - def type_going_back(root: Path, switch: list[str], branch: str, earlier: str) -> None: """The printed line where `branch` is there (refused), then the same @@ -285,7 +202,7 @@ def test_another_name_works_where_an_earlier_bump_left_the_branch( put_where(kind, project) leave_branch(project, branch) switch, _ = printed(kind, project, scaffolded, advanced) - other = type_another_name(project, switch, branch) + other = type_other_name(project, switch, branch) before = head(project) done = bump(project, scaffolded, advanced) assert done.returncode == 0, done.stderr + done.stdout @@ -319,12 +236,12 @@ def test_going_back_works_where_an_earlier_bump_left_the_branch( def family_block(landing, capsys, project: str = "Sample", org: str = "demoorg") -> list[str]: """What `family_next_command` prints for `landing`, line by line.""" - setup_module().family_next_command(project, org, landing) + entry_point_module().family_next_command(project, org, landing) return capsys.readouterr().out.splitlines() def landing_for(holder: Path): - return setup_module().Landing(holder.parent, holder.name, holder) + return entry_point_module().Landing(holder.parent, holder.name, holder) @pytest.mark.parametrize("folder", FOLDERS) @@ -393,7 +310,7 @@ def test_another_name_works_where_an_earlier_try_left_the_family_branch( holder = on_main switch = setup_switch(holder, capsys) leave_branch(holder, switch[-1]) - other = type_another_name(holder, switch, switch[-1]) + other = type_other_name(holder, switch, switch[-1]) before = head(holder) done = act(family, holder, "add") assert done.returncode == 0, done.stdout + done.stderr diff --git a/tests/test_family_switch_branch_exists.py b/tests/test_family_switch_branch_exists.py index e6490ddc..5d1f3cca 100644 --- a/tests/test_family_switch_branch_exists.py +++ b/tests/test_family_switch_branch_exists.py @@ -53,6 +53,7 @@ from conftest import REPO, git, run_script from repo_shape import PYTHON +from test_bump_leg import bump_leg_module # noqa: F401 from test_family import ( # noqa: F401 FAMILY, ORG, SPARE, family, family_module, manifest, mount_twice, scaffold_member, validate, @@ -61,9 +62,10 @@ from test_family_tracking_branch import ( # noqa: F401 TRACKING, act, next_push, on_main, remedy_switch, snapshot, suggested, ) -# `BRANCH_LEFT` is the sentence both tools end the remedy with, pinned as +from test_setup_project_py import entry_point_module +# `BRANCH_LEFT` is the sentence the tools end the remedy with, pinned as # text: a person, an agent and a holder's CI read it, so it is not rebuilt -# from either tool's own constant here. +# from any tool's own constant here. from test_validate_family_branch_step import BRANCH_LEFT, TEMPLATE, gate_module from test_validate_family_unnamed_mounts import spare_commits, stage_gitlink @@ -447,12 +449,17 @@ def test_the_guard_sees_a_remedy_left_without_the_sentence(): assert unguarded(source + fourth_remedy(" {BRANCH_LEFT}")) == [] -def test_the_two_tools_say_the_same_words(monkeypatch): +def test_the_tools_say_the_same_words(monkeypatch, bump_leg_module): """ONE ANSWER: `family.py` and the holder's validator, which a holder carries as a copy and which cannot import `family.py`, spell the - sentence the same, word for word.""" + sentence the same, word for word, and so do `setup-project.py`, which + runs before any checkout of the standard exists, and `bump-leg.py`, + which says "the project" where the others say "the holder" (#647).""" assert family_module().BRANCH_LEFT == BRANCH_LEFT assert gate_module(monkeypatch).BRANCH_LEFT == BRANCH_LEFT + assert entry_point_module().BRANCH_LEFT == BRANCH_LEFT + assert bump_leg_module.BRANCH_LEFT == BRANCH_LEFT.replace( + "the holder", "the project") # --- one placeholder for the member (L2-45) ----------------------------------