From 7e55d002f631311655ad02ad7ed3d0af04693fc0 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:21:12 +0000 Subject: [PATCH 1/3] adopt-project.py: `check` reports, and `execute` refuses before any leg exists, a branch at or in git's way of the plan's split branch where the split is pushed, so a re-run over an earlier run's `adopt/three-repo-shape` no longer makes both legs and dies at the push of the split (#320) `execute` pushes its one split commit to `refs/heads/` of the repository the split goes to: the source itself under `--local-remote-dir`, the assembly repository on GitHub otherwise. An earlier run leaves that branch, verified or failed after the push, and nothing in `plan`, `check` or `execute` looked for it. Measured on main 71b58a1 with git 2.43 and `git filter-repo` 2.47.0: the same plan re-run into fresh legs after a verified run passed `check` (exit 0), and `execute` exited 2 with both leg repositories made, at the push of the split (`! [rejected] HEAD -> adopt/three-repo-shape (non-fast-forward)`); a source branch `adopt` passed `check`, and `execute` exited 2, saying nothing, at `git filter-repo` after both legs were made; a branch at the plan's own commit was fast-forwarded with the split and verified. Over a tip `.gitmodules` git's submodule reader cannot read, #226's history scan refused the re-run as `plan-gitmodules-unreadable`, naming the old split commit, which the extraction never exports. - A branch AT the plan's split branch, or one git cannot hold beside it (its name a directory of the split branch's, or under it), in the repository the split is pushed to, is the new finding `plan-split-branch-taken`. It names the place, the branch and its commit, the run that pushes there, and the two exits: delete that branch there, or set `adopt_branch:` to a branch git can create there. A remediation is printed once after the findings. - `check` asks without the network: the source's own branches (where `execute --local-remote-dir` pushes), and, where the source's `origin` is the plan's `/`, that remote's branches as the source last fetched them (where `execute` without it pushes). Its findings come after `plan-stale` and before every finding about the entries. - `execute` asks the one repository it pushes to, with `git ls-remote --heads`, after `_checked_plan_values` and before `_confirm` and `_create_leg_remotes`. Its findings lead `_refuse_what_check_finds`'s list, so the refusal wears `plan-split-branch-taken` and still carries every other finding of `check`'s; a `--local-remote-dir` run's finding is `check`'s word for word. A repository it cannot list is refused as `adopt-split-target-unreadable`, before any leg exists. - #226's history scan is unchanged, and still reads the branch. With this finding first the re-run is refused for the branch, and a run that gets past it reads what the scan reads: with the branch deleted neither holds it, and with another `adopt_branch:` `git filter-repo` exports it (measured: `_extract_leg` fails on the tip `.gitmodules` the scan's finding names under another name, and passes under the same one, which its `checkout -B` shadows). Unchanged: a plan whose split branch is new where it is pushed checks and executes as before, and `plan` is untouched. `tests/test_adopt_e2e.py`'s `leg-remote-exists` re-run now names another split branch, because the same plan is refused first for the first run's; a new test beside it covers that refusal. Left out: a branch in git's way in a local source, for a run without `--local-remote-dir`, still fails the extraction after both legs exist (`check` reports it, `execute` asks only GitHub); a case-only twin of the split branch is not compared; the notices after a failed run (`_verify`'s footer and the mount remediation still say to re-run into fresh legs) are #188's. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 246 ++++++++++++++- tests/test_adopt_e2e.py | 23 +- tests/test_adopt_split_branch.py | 518 +++++++++++++++++++++++++++++++ 3 files changed, 779 insertions(+), 8 deletions(-) create mode 100644 tests/test_adopt_split_branch.py diff --git a/adopt-project.py b/adopt-project.py index aaf2c5bc..f3c367d7 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -36,9 +36,10 @@ no path kept in the root where the shape writes (a plain file at a directory it writes into, `.github`, `contracts` or `scripts`, or the source's own path at a copy it writes under `shape/`, in - any case), and the source's own submodules kept on one leg with - their `.gitmodules`, registered there and never in the root. It - prints what will happen and changes nothing. + any case), the source's own submodules kept on one leg with + their `.gitmodules`, registered there and never in the root, and + the split branch a new one where `execute` pushes it. It prints + what will happen and changes nothing. execute creates the two legs, extracts them with `git filter-repo`, makes the one split commit on a branch of the source, sets the `xf-project-` topic on all three (skipped for local remotes), @@ -2837,6 +2838,219 @@ def _entry_findings(plan: Plan, source: Source unsafe_remediation + remediation + unwritable_remediation) +# --------------------------------------------------------------------------- +# The split branch is a NEW branch where the split is pushed (#320) +# --------------------------------------------------------------------------- +# +# `_commit_the_split` pushes the one split commit to `refs/heads/` +# of the repository the split goes to: the source itself under +# `--local-remote-dir` (`_repository_urls`), and otherwise the assembly +# repository on GitHub, which an in-place adoption keeps as the source. +# Nothing asked whether that branch was already there, and an earlier run +# leaves it: a verified one, and one that failed after the push. On main at +# 71b58a1 the same plan, re-run into fresh legs after a verified run, passed +# `check` as `plan ok`, made and pushed both legs, and was refused at the push +# of the split as a non-fast-forward. A source branch `adopt` failed the run +# after both legs were made as well, at `git filter-repo`, which could not +# create `refs/heads/adopt` beside the split branch `_extract_leg` checked +# out: git cannot hold a branch whose name is a directory of another's, or +# lies under it. +# +# So a branch AT the split branch's name, or in git's way of it, in the +# repository the split is pushed to, is `plan-split-branch-taken`: `check` +# reports it, and `execute` refuses it, FIRST among `check`'s findings and +# with them, before any leg exists. Whatever the branch holds: one the split +# would fast-forward is a branch this adoption did not make, and on main the +# split was pushed onto it and verified. +# +# WHERE IT IS ASKED. `execute` knows where it pushes, and asks that +# repository with `git ls-remote`, as the push will meet it. `check` does not +# know whether `--local-remote-dir` will be passed, so it asks the two places +# it can see without the network, and each finding says which run it is +# about: the source's own branches, and, where the source's `origin` is the +# assembly repository, what that remote held when the source last fetched it +# -- for an `org/repo` source, which `Source.open` clones, when it was cloned. +# +# THE HISTORY SCAN IS NOT CHANGED (#226). `_exported_gitlink` reads +# `--branches`, so a split branch the source keeps is in it, and +# `_extract_leg`'s `git checkout -B` shadows that branch in the extraction's +# clone: on main the scan named a commit of it which the extraction never +# exports. With this finding first, a plan that leaves the branch where it +# is and names it is refused for the branch, and a run that gets past it +# reads what the scan reads: with the branch deleted neither holds it, and +# with another `adopt_branch:` `git filter-repo` exports it, and failed on +# the tip `.gitmodules` the scan's finding names (measured with git 2.43 and +# `git filter-repo` 2.47.0). + +#: `check`'s finding, and the code of `execute`'s refusal, for a branch at or +#: in the way of the split branch where the split is pushed. +SPLIT_BRANCH_TAKEN = "plan-split-branch-taken" +#: `execute`'s refusal when the repository it pushes the split to cannot be +#: listed: a push to it would fail the same way, after both legs exist. +SPLIT_TARGET_UNREADABLE = "adopt-split-target-unreadable" +#: How a finding names the run that pushes to each place `check` asks. +LOCAL_RUN = "`execute --local-remote-dir`" +GITHUB_RUN = "`execute` without `--local-remote-dir`" + +#: What a plan whose split branch is taken is told to do. Said by `check`, +#: once after its findings, and by `execute`'s refusal. +SPLIT_BRANCH_REMEDIATION = ( + "Remediation: `execute` pushes its one split commit to the plan's " + "`adopt_branch:` as a NEW branch of the repository the split goes to: the " + "source itself under `--local-remote-dir`, and otherwise the assembly " + "repository on GitHub. A verified run leaves its split branch there, and " + "so does a run that failed after that push, so an earlier run's split is " + "what this finding names most often. Delete the branch a finding names, " + "where it names it, once neither it nor a pull request from it is " + "wanted, or set `adopt_branch:` to a branch git can create there, and run " + "`check` again. A branch the source itself holds is also in the history " + "`check` reads for the source's submodules, and `git filter-repo` exports " + "it once the plan names another branch, so a finding that names one of " + "its commits stands until that branch is deleted.") + + +def _listed_branches(raw: bytes, prefix: str) -> dict[str, str]: + """`{branch: commit}` from ` TAB ` lines, as `git ls-remote` + and `git for-each-ref` print them, for each ref under `prefix`. A name + that is not UTF-8 keeps each byte as a surrogate, as `Source.tree` + keeps a path's (`_git_path`).""" + out: dict[str, str] = {} + for line in raw.splitlines(): + oid, _, ref = line.partition(b"\t") + name = _git_path(ref) + if name.startswith(prefix) and name != prefix + "HEAD": + out[name[len(prefix):]] = oid.decode().strip().lower() + return out + + +def _branches_at(url: str, cwd: Path, branch: str) -> dict[str, str]: + """Every branch of the repository at `url`, as a push to it meets them: + `git ls-remote --heads`. One that cannot be listed is refused before any + leg exists, because `execute` pushes the split there after both.""" + listed = subprocess.run( + ["git", *FILE_PROTOCOL, "ls-remote", "--heads", "--", url], + cwd=cwd, capture_output=True, check=False) + if listed.returncode: + raise Refusal( + SPLIT_TARGET_UNREADABLE, + f"could not list the branches of {_shown(url)}, where `execute` " + f"pushes the split, to see whether {_spelled(branch)} is new " + f"there: `git ls-remote` exited {listed.returncode}: " + + _one_line(listed.stderr.decode("utf-8", "replace")), + "Remediation: no leg repository was created and nothing was " + "pushed. `execute` pushes the split to that same repository once " + "it has made both leg repositories, so a run that cannot read it " + "now cannot push to it then. Check that the repository exists and " + "that git can reach it (`gh auth status`; `gh auth setup-git` " + "lets git use that login), then run the command again.") + return _listed_branches(listed.stdout, "refs/heads/") + + +def _in_the_way(branches: dict[str, str], branch: str) -> list[tuple]: + """`(name, commit)` of each of `branches` that stops `refs/heads/` + being created as a new branch: `branch` itself, first, then each whose + name is a directory of it or lies under it, which git cannot hold beside + it.""" + return sorted(((name, commit) for name, commit in branches.items() + if name == branch or name.startswith(branch + "/") + or branch.startswith(name + "/")), + key=lambda found: (found[0] != branch, found[0])) + + +def _taken(where: str, run: str, branch: str, name: str, + commit: str) -> str: + """One `plan-split-branch-taken` finding: `where` holds the branch + `name` at `commit`, which takes the split branch `branch`, and `run` + pushes the split there.""" + split = _spelled(branch) + if name == branch: + held = f"{where} already holds the plan's split branch {split}" + why = ("and as a new branch: a branch already there refuses that " + "push unless the split fast-forwards it, and then the split " + "lands on a branch this adoption did not make") + else: + held = f"{where} holds the branch {_spelled(name)}" + # git's own words, each name spelled as `_spelled` spells it, so a + # backtick a branch name may hold cannot end the quotation. + why = (f"and git cannot create {split} beside {_spelled(name)}: " + f"`'refs/heads/{_spelled(name)[1:-1]}' exists; cannot create " + f"'refs/heads/{split[1:-1]}'`") + return (f"{held}, at {commit[:12]}. {run} pushes its split commit to " + f"{split} there, after both leg repositories were created and " + f"pushed, {why}. Delete {_spelled(name)} there, or set " + "`adopt_branch:` in the plan to a branch git can create there.") + + +def _split_branch_findings(branch: str | None, places: list + ) -> tuple[list[str], list[str]]: + """`plan-split-branch-taken`, one FINDING per branch in the way in each + of `places`, `(where, run, branches)`; and what to print ONCE after + every finding, as `_submodule_findings` returns it. `branch` None is a + split branch `execute` refuses as `unsafe-value`, and nothing is asked + about it here.""" + found = [f"FINDING {SPLIT_BRANCH_TAKEN}: " + + _taken(where, run, branch, name, commit) + for where, run, branches in (places if branch else []) + for name, commit in _in_the_way(branches, branch)] + return found, [SPLIT_BRANCH_REMEDIATION] if found else [] + + +def _assembly_repository(plan: Plan) -> str | None: + """`/`, the repository a run without `--local-remote-dir` + pushes the split to (`_repository_urls`), as the plan names it; None + where it names no organisation or no assembly root.""" + legs = plan.legs if isinstance(plan.legs, dict) else {} + org, name = plan.get("org"), legs.get("assembly") + return f"{org}/{name}" if org and name else None + + +def _places_check_asks(plan: Plan, source: Source, branch: str) -> list: + """The places `check` asks for the split branch `branch`, `(where, run, + branches)`: the source, which a run with `--local-remote-dir` pushes to, + and, where the source's `origin` is the assembly repository, that + repository's branches as the source last fetched them, which a run + without it pushes to. `check` reads no network for either.""" + places = [(f"the source {_shown(str(source.path))}", LOCAL_RUN, + _branches_at(str(source.path), source.path, branch))] + assembly = _assembly_repository(plan) + if assembly and (source.repository or "").lower() == assembly.lower(): + tracked = git_out(["for-each-ref", "--format=%(objectname)%09" + "%(refname)", "refs/remotes/origin/"], + cwd=source.path, binary=True) + places.append((f"`{assembly}` on GitHub, as the source's `origin` " + "last fetched it,", GITHUB_RUN, + _listed_branches(tracked, "refs/remotes/origin/"))) + return places + + +def _check_split_branch(plan: Plan, source: Source + ) -> tuple[list[str], list[str]]: + """`_split_branch_findings` as `check` asks it. A split branch that is + not a value `execute` puts on a command line is not asked about: it is + `execute`'s refusal `unsafe-value`, before any leg exists.""" + try: + branch = checked_value("adopt_branch", + plan.get("adopt_branch", ADOPT_BRANCH)) + except Refusal: + return [], [] + return _split_branch_findings(branch, + _places_check_asks(plan, source, branch)) + + +def _execute_split_branch(branch: str, local: bool, repositories: dict, + urls: dict, source: Source + ) -> tuple[list[str], list[str]]: + """`_split_branch_findings` as `execute` asks it: of the one repository + this run pushes the split to, listed as the push will meet it. A run + with `--local-remote-dir` says what `check` says of the source, word for + word, so that its refusal carries `check`'s findings as `check` prints + them.""" + place = ((f"the source {_shown(str(source.path))}", LOCAL_RUN) if local + else (f"`{repositories['assembly']}` on GitHub", GITHUB_RUN)) + return _split_branch_findings(branch, [ + (*place, _branches_at(urls["assembly"], source.path, branch))]) + + def _topics_line(topic: str, local: bool) -> str: """The `topics` plan line, the one `scaffold-project.py` also prints. @@ -2898,8 +3112,14 @@ def cmd_check(args) -> int: "against a tree that has moved proves nothing about the tree that " "will be split.") + # Before the entries (#320): a split branch that is already there is the + # one cause of a re-run's failure, and the history scan the entries' + # findings make reads that branch too. + taken, taken_remediation = _check_split_branch(plan, source) + findings.extend(taken) entry_findings, remediation = _entry_findings(plan, source) findings.extend(entry_findings) + remediation = taken_remediation + remediation names = plan.names() pins = set(plan.pins) @@ -3412,7 +3632,8 @@ def _leg_paths(plan: Plan) -> dict: REFUSED_FINDINGS_SHOWN = 8 -def _refuse_what_check_finds(plan: Plan, source: Source) -> None: +def _refuse_what_check_finds(plan: Plan, source: Source, + taken: tuple = ((), ())) -> None: """Refuse a plan `check` rejects, BEFORE a leg repository exists (#168). Of `check`'s findings, `execute` used to refuse only `plan-unresolved`, @@ -3477,8 +3698,20 @@ def _refuse_what_check_finds(plan: Plan, source: Source) -> None: states: a plan `check` rejects as uncovered and split is refused for both at once, and a submodule problem is refused here like any other, after the entries' paths, which are the first of these findings. + + THE SPLIT BRANCH COMES FIRST OF ALL (#320). `taken` is + `_execute_split_branch`'s `(findings, remediation)`: a branch at or in + the way of the split branch in the one repository this run pushes the + split to, which `cmd_execute` asks before this runs. Its findings lead + the list, as `check` lists its own, so a re-run over an earlier run's + split branch is refused as `plan-split-branch-taken`, and never as a + finding of the history that branch adds to the scan (#226). `check` + asks where it can see without the network (`_places_check_asks`), and + for a run without `--local-remote-dir` this asks the repository itself. """ - findings, remediation = _entry_findings(plan, source) + entry_findings, entry_remediation = _entry_findings(plan, source) + findings = [*taken[0], *entry_findings] + remediation = [*taken[1], *entry_remediation] if not findings: return # `FINDING : ` is the one shape both functions write. @@ -3759,7 +3992,8 @@ def cmd_execute(args) -> int: local, repositories, urls = _repository_urls(args, plan, names, source) _refuse_an_unwritable_clone_url(urls["assembly"]) paths_for = _leg_paths(plan) - _refuse_what_check_finds(plan, source) + _refuse_what_check_finds(plan, source, _execute_split_branch( + branch, local, repositories, urls, source)) seeded = seeded_legs(paths_for) _refuse_unconsented_seeding(args, plan, seeded) diff --git a/tests/test_adopt_e2e.py b/tests/test_adopt_e2e.py index b75efafe..8545b3c8 100644 --- a/tests/test_adopt_e2e.py +++ b/tests/test_adopt_e2e.py @@ -300,8 +300,14 @@ def test_a_dropped_path_is_accounted_for_as_dropped(source_repo, tmp_path): def test_re_running_into_a_live_leg_refuses(adopted, tmp_path): """There is no --force here either: re-running over a seeded leg is not - an adoption.""" - result = run_script(ADOPT, "execute", "--plan", str(adopted["plan"]), + an adoption. The plan names another split branch, because the first + run's is still in the source, and the same plan is refused for it first + (#320, `tests/test_adopt_split_branch.py`).""" + plan = tmp_path / "plan.yaml" + plan.write_bytes(adopted["plan"].read_bytes().replace( + b"adopt_branch: adopt/three-repo-shape\n", + b"adopt_branch: adopt/again\n", 1)) + result = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", "--local-remote-dir", str(adopted["remotes"]), "--work-dir", str(tmp_path / "work2")) assert result.returncode == 2 @@ -309,6 +315,19 @@ def test_re_running_into_a_live_leg_refuses(adopted, tmp_path): assert "There is no --force" in result.stderr +def test_re_running_over_the_first_runs_split_branch_refuses(adopted, + tmp_path): + """The same plan again is refused before `leg-remote-exists`: the first + run's split branch is still in the source, where this run pushes.""" + result = run_script(ADOPT, "execute", "--plan", str(adopted["plan"]), + "--yes", "--local-remote-dir", str(adopted["remotes"]), + "--work-dir", str(tmp_path / "work2")) + assert result.returncode == 2 + assert "REFUSED plan-split-branch-taken: " in result.stderr + assert "REFUSED leg-remote-exists" not in result.stderr + assert "creating the leg repositories" not in result.stdout + + def test_the_tool_never_touches_the_source_working_tree(adopted): """`plan` and `check` READ. Nothing here dirties the repository it reads.""" status = git("status", "--porcelain", cwd=adopted["source"]).stdout diff --git a/tests/test_adopt_split_branch.py b/tests/test_adopt_split_branch.py new file mode 100644 index 00000000..c6713c06 --- /dev/null +++ b/tests/test_adopt_split_branch.py @@ -0,0 +1,518 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The split branch is a NEW branch where the split is pushed (#320). + +`execute` pushes its one split commit to the plan's `adopt_branch:` +(`adopt/three-repo-shape`) in the repository the split goes to: the source +itself under `--local-remote-dir`, the assembly repository on GitHub +otherwise. An earlier run leaves that branch behind, verified or failed after +the push, and nothing looked for it. On main at 71b58a1 the same plan re-run +into fresh legs passed `check` as `plan ok`, made and pushed both legs, and +was refused at the push of the split as a non-fast-forward; a source branch +`adopt`, which git cannot hold beside `adopt/three-repo-shape`, failed the +run after both legs were made as well. Over a source whose tip `.gitmodules` +git's submodule reader cannot read, #226's history scan refused the re-run, +but named a commit of the old split branch that the extraction never +exports. + +Now a branch at the split branch's name, or in git's way of it, is `check`'s +finding `plan-split-branch-taken`, and `execute` refuses it before any leg +exists, first among `check`'s findings. + +All repositories here are local, GitHub included: a run without +`--local-remote-dir` is pointed at a bare repository on disk by git's own +`url..insteadOf`, and its `gh` is never reached. Only the tests marked +`needs_filter_repo` run `git filter-repo`; the rest call `cmd_execute` in +this process with its probe for the tool stubbed, because every refusal +under test comes before the tool would be used. +""" + +from __future__ import annotations + +import importlib.util +import shutil +import subprocess +from argparse import Namespace +from pathlib import Path + +import pytest + +from conftest import ADOPT, FILE_PROTOCOL, GITMODULES, as_human, findings, \ + git, make_source_repo, planned, put_gitmodules, refused_before_any_leg, \ + run_script + +needs_filter_repo = pytest.mark.skipif( + shutil.which("git-filter-repo") is None, + reason="git filter-repo is not installed: `pip install git-filter-repo`") + +TAKEN = "plan-split-branch-taken" +UNREADABLE = "plan-gitmodules-unreadable" +SPLIT_BRANCH = "adopt/three-repo-shape" +PROJECT, ORG = "Northwind", "testorg" +REMOTES, WORK = "remotes", "work" +#: A source `plan` asks no question about: `specs/` and `src/` classify +#: themselves and `README.md` stays in the root. +SMALL_TREE = {"README.md": "# Thing\n", "src/app/util.py": "VALUE = 1\n", + "specs/001-feature/tasks.md": "- [ ] one\n"} +#: #195's tip `.gitmodules`: a key with no value, which git's submodule +#: reader dies on for any gitlink `git fast-export` meets. +BARE_PATH = '[submodule "b"]\n\tpath\n' + + +@pytest.fixture(scope="module") +def adopter(): + spec = importlib.util.spec_from_file_location("adopt_split_branch", + ADOPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class Proceeded(Exception): + """`cmd_execute` got past every refusal to the step that makes legs.""" + + +def small_source(base: Path, gitmodules: str | None = None) -> Path: + """SMALL_TREE in one commit on `main`, and `gitmodules` as its + `.gitmodules` in a second when one is given.""" + source = make_source_repo(base / "Thing", tree=SMALL_TREE, edits=()) + if gitmodules is not None: + put_gitmodules(source, gitmodules) + as_human("commit", "-qm", "A .gitmodules", cwd=source) + return source + + +def split_branch_by_hand(source: Path, branch: str = SPLIT_BRANCH) -> str: + """`branch`, one commit off `main` that adds gitlinks `spec` and `code`: + the tree an earlier run's split commit has, made as the issue made it. + `main` is checked out again after it, and holds no gitlink.""" + head = git("rev-parse", "main", cwd=source).stdout.strip() + as_human("checkout", "-q", "-b", branch, cwd=source) + for path in ("spec", "code"): + git("update-index", "--add", "--cacheinfo", f"160000,{head},{path}", + cwd=source) + as_human("commit", "-qm", "An earlier split, by hand", cwd=source) + commit = git("rev-parse", "HEAD", cwd=source).stdout.strip() + as_human("checkout", "-q", "-f", "main", cwd=source) + return commit + + +def plan_for(source: Path, base: Path, answers: dict | None = None) -> Path: + return planned(source, base, answers or {}) + + +def renamed(plan: Path, branch: str) -> Path: + """`plan` with `adopt_branch:` set to `branch`, beside it.""" + other = plan.with_name("renamed-plan.yaml") + other.write_bytes(plan.read_bytes().replace( + f"adopt_branch: {SPLIT_BRANCH}\n".encode(), + f"adopt_branch: {branch}\n".encode(), 1)) + assert other.read_bytes() != plan.read_bytes() + return other + + +def execute_args(plan: Path, base: Path, local: bool = True) -> Namespace: + return Namespace(plan=str(plan), source=None, + local_remote_dir=base / REMOTES if local else None, + allow_empty_leg=[], yes=True, work_dir=base / WORK) + + +def stop_at_the_legs(adopter, monkeypatch) -> None: + """`_create_leg_remotes` raises `Proceeded`: what reaches it was refused + by nothing before it.""" + def proceeded(*args, **kwargs): + raise Proceeded() + monkeypatch.setattr(adopter, "_require_filter_repo", lambda: None) + monkeypatch.setattr(adopter, "_create_leg_remotes", proceeded) + + +# --- what `check` finds, and `execute` refuses, in the source --------------- + +def test_check_finds_an_earlier_split_branch_in_the_source(adopter, + tmp_path): + """On main this was `plan ok`, and `execute` made both legs before the + push of the split was refused as a non-fast-forward.""" + source = small_source(tmp_path) + old = split_branch_by_hand(source) + found = findings(plan_for(source, tmp_path)) + assert found == [(TAKEN, ( + f"the source {source} already holds the plan's split branch " + f"`{SPLIT_BRANCH}`, at {old[:12]}. `execute --local-remote-dir` " + f"pushes its split commit to `{SPLIT_BRANCH}` there, after both leg " + "repositories were created and pushed, and as a new branch: a " + "branch already there refuses that push unless the split " + "fast-forwards it, and then the split lands on a branch this " + f"adoption did not make. Delete `{SPLIT_BRANCH}` there, or set " + "`adopt_branch:` in the plan to a branch git can create there."))] + + +def test_check_says_how_to_go_on_once_after_its_findings(adopter, tmp_path): + source = small_source(tmp_path) + split_branch_by_hand(source) + result = run_script(ADOPT, "check", "--plan", + str(plan_for(source, tmp_path))) + assert result.returncode == 1, result.stderr + result.stdout + assert result.stderr.count(adopter.SPLIT_BRANCH_REMEDIATION) == 1 + + +def test_execute_refuses_it_before_any_leg_exists(adopter, tmp_path, + monkeypatch): + """`check`'s finding, raised by `_refuse_what_check_finds` before + `_create_leg_remotes`: the directory the legs go in is never made, and + the source's branch is where it was.""" + source = small_source(tmp_path) + old = split_branch_by_hand(source) + refusal = refused_before_any_leg(adopter, monkeypatch, + plan_for(source, tmp_path), tmp_path) + assert refusal.code == TAKEN + assert refusal.remediation.endswith( + "\n" + adopter.SPLIT_BRANCH_REMEDIATION) + assert git("rev-parse", SPLIT_BRANCH, cwd=source).stdout.strip() == old + + +def test_a_branch_the_split_would_fast_forward_is_taken_too(adopter, + tmp_path, + monkeypatch): + """At the plan's own commit, the split fast-forwards it, and on main it + was pushed onto that branch and verified: a branch this adoption did + not make. It is refused like any other.""" + source = small_source(tmp_path) + git("branch", SPLIT_BRANCH, cwd=source) + plan = plan_for(source, tmp_path) + assert [code for code, _ in findings(plan)] == [TAKEN] + refusal = refused_before_any_leg(adopter, monkeypatch, plan, tmp_path) + assert refusal.code == TAKEN + + +#: Branch names git cannot hold beside the split branch: one whose name is +#: a directory of it, and one under it. On main each passed `check`, and +#: `execute` failed after both legs were made -- for `adopt`, silently, at +#: the `git filter-repo` that could not create `refs/heads/adopt` beside the +#: split branch `_extract_leg` checks out. +IN_THE_WAY = ["adopt", f"{SPLIT_BRANCH}/old"] +#: Names that only look like it, which git holds beside it. +BESIDE_IT = [f"{SPLIT_BRANCH}-old", "adopt/other", "adopt-three-repo-shape", + "three-repo-shape"] + + +@pytest.mark.parametrize("name", IN_THE_WAY) +def test_a_branch_in_gits_way_of_the_split_branch_is_taken(adopter, + tmp_path, + monkeypatch, + name): + source = small_source(tmp_path) + git("branch", name, cwd=source) + at = git("rev-parse", name, cwd=source).stdout.strip() + plan = plan_for(source, tmp_path) + found = findings(plan) + assert [code for code, _ in found] == [TAKEN], found + assert found[0][1].startswith( + f"the source {source} holds the branch `{name}`, at {at[:12]}. ") + assert (f"git cannot create `{SPLIT_BRANCH}` beside `{name}`: " + f"`'refs/heads/{name}' exists; cannot create " + f"'refs/heads/{SPLIT_BRANCH}'`. Delete `{name}` there, or set " + "`adopt_branch:` in the plan to a branch git can create there." + ) in found[0][1] + refusal = refused_before_any_leg(adopter, monkeypatch, plan, tmp_path) + assert refusal.code == TAKEN + + +@pytest.mark.parametrize("name", BESIDE_IT) +def test_a_branch_git_holds_beside_the_split_branch_is_not_taken(tmp_path, + name): + source = small_source(tmp_path) + git("branch", name, cwd=source) + assert findings(plan_for(source, tmp_path)) == [] + + +def test_another_split_branch_in_the_plan_is_not_taken(tmp_path): + """The finding's own second exit: the plan names a branch that is new + there.""" + source = small_source(tmp_path) + split_branch_by_hand(source) + assert findings(renamed(plan_for(source, tmp_path), "adopt/again")) == [] + + +def test_each_branch_in_the_way_is_named(tmp_path): + """Two branches under the split branch's name, which one repository can + hold together (it cannot hold either beside the split branch itself).""" + source = small_source(tmp_path) + for name in ("old", "older"): + git("branch", f"{SPLIT_BRANCH}/{name}", cwd=source) + found = findings(plan_for(source, tmp_path)) + assert [code for code, _ in found] == [TAKEN, TAKEN] + assert f"holds the branch `{SPLIT_BRANCH}/old`" in found[0][1] + assert f"holds the branch `{SPLIT_BRANCH}/older`" in found[1][1] + + +# --- #226's history scan, with this finding first -------------------------- + +def issue_source(base: Path) -> tuple[Path, str]: + """The issue's second shape: a tip `.gitmodules` with a key with no + value, no gitlink at the tip, and an earlier split branch whose one + commit adds gitlinks. Returns the source and that commit.""" + source = small_source(base) + old = split_branch_by_hand(source) + put_gitmodules(source, BARE_PATH) + as_human("commit", "-qm", "The tip .gitmodules", cwd=source) + assert "160000" not in git("ls-tree", "-r", "main", cwd=source).stdout + return source, old + + +def scan_names(commit: str) -> str: + """How `plan-gitmodules-unreadable` names the old split commit.""" + return f"where commit {commit[:12]} adds `code`" + + +def test_the_branch_is_named_before_the_history_it_adds(adopter, tmp_path, + monkeypatch): + """On main `check` and `execute` named only the history scan's finding, + for a commit the extraction never exports while the plan names the + branch that holds it. The branch is now the first finding, and the code + of `execute`'s refusal.""" + source, old = issue_source(tmp_path) + plan = plan_for(source, tmp_path, {GITMODULES: "code"}) + found = findings(plan) + assert [code for code, _ in found] == [TAKEN, UNREADABLE], found + assert f"at {old[:12]}" in found[0][1] + assert scan_names(old) in found[1][1] + refusal = refused_before_any_leg(adopter, monkeypatch, plan, tmp_path) + assert refusal.code == TAKEN + + +def test_with_the_branch_deleted_nothing_names_its_history(tmp_path): + source, _ = issue_source(tmp_path) + plan = plan_for(source, tmp_path, {GITMODULES: "code"}) + git("branch", "-q", "-D", SPLIT_BRANCH, cwd=source) + assert findings(plan) == [] + + +def test_with_another_split_branch_the_history_finding_stands(tmp_path): + """Not left out of the scan: under another name `git filter-repo` + exports the old branch, which + `test_the_extraction_exports_the_old_branch_only_under_another_name` + measures.""" + source, old = issue_source(tmp_path) + plan = renamed(plan_for(source, tmp_path, {GITMODULES: "code"}), + "adopt/again") + found = findings(plan) + assert [code for code, _ in found] == [UNREADABLE], found + assert scan_names(old) in found[0][1] + + +@needs_filter_repo +@pytest.mark.parametrize("branch, exported", [ + (SPLIT_BRANCH, False), ("adopt/again", True)], + ids=["the-same-name", "another-name"]) +def test_the_extraction_exports_the_old_branch_only_under_another_name( + adopter, tmp_path, branch, exported): + """`_extract_leg` itself, over the issue's source: `git checkout -B` + of the plan's branch in its clone shadows an old branch of that name, + which `git filter-repo` then leaves out, and under another name it + exports it and fails on the tip `.gitmodules`. So with this refusal + first, the scan, which reads the old branch, reads what every run that + gets past it exports.""" + source, _ = issue_source(tmp_path) + read = adopter.Source.open(str(source), tmp_path / "read") + leg = tmp_path / "leg.git" + git("init", "-q", "--bare", "-b", "main", str(leg), cwd=tmp_path) + extract = (lambda: adopter._extract_leg( + "code", read, tmp_path / "clone", ["src/"], tmp_path / "paths.txt", + branch, str(leg), "main", f"{ORG}/{PROJECT}-code")) + if not exported: + extract() + assert git("rev-parse", "main", cwd=leg).stdout.strip() + return + with pytest.raises(adopter.CommandFailed) as caught: + extract() + assert caught.value.args_list[1] == "filter-repo", caught.value.args_list + assert ".gitmodules" in caught.value.output, caught.value.output + + +# --- the assembly repository on GitHub -------------------------------------- + +def github_repository(tmp_path: Path, branches=(SPLIT_BRANCH,)) -> Path: + """A bare `testorg/Northwind` under `tmp_path/github`, holding `main` + and `branches`, each at a commit of another repository's `main`.""" + bare = tmp_path / "github" / ORG / f"{PROJECT}.git" + bare.mkdir(parents=True) + seed = small_source(tmp_path / "seed") + git("init", "-q", "--bare", "-b", "main", str(bare), cwd=tmp_path) + for name in ("main", *branches): + git(*FILE_PROTOCOL, "push", "-q", str(bare), f"main:refs/heads/{name}", + cwd=seed) + return bare + + +def github_at(root: Path, monkeypatch) -> None: + """git pointed at `root` for `https://github.com/testorg/`, by its own + `url..insteadOf`, in the environment every `git` this process + starts inherits. The org is in it because git takes the LONGEST + `insteadOf` that matches, and a workstation may already rewrite + `https://github.com/` (to `git@github.com:`, say) in its own config.""" + monkeypatch.setenv("GIT_CONFIG_COUNT", "1") + monkeypatch.setenv("GIT_CONFIG_KEY_0", + f"url.{(root / ORG).as_posix()}/.insteadOf") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", f"https://github.com/{ORG}/") + + +def a_github(tmp_path: Path, monkeypatch, branches=(SPLIT_BRANCH,)) -> Path: + """`github_repository`, with git pointed at it (`github_at`).""" + bare = github_repository(tmp_path, branches) + github_at(tmp_path / "github", monkeypatch) + return bare + + +def no_gh(adopter, monkeypatch) -> list: + """`run`, with every `gh` command it is given recorded and refused as + `Proceeded`: nothing here may reach GitHub.""" + asked: list = [] + real = adopter.run + + def run(args, cwd=None, capture=True): + if args and args[0] == "gh": + asked.append(args) + raise Proceeded(args) + return real(args, cwd, capture) + + monkeypatch.setattr(adopter, "run", run) + monkeypatch.setattr(adopter, "_require_filter_repo", lambda: None) + return asked + + +def test_execute_asks_github_where_it_pushes_without_local_remotes( + adopter, tmp_path, monkeypatch): + """The source holds no such branch; the repository the split is pushed + to does, and `execute` asks it, before `gh repo create`.""" + source = small_source(tmp_path) + plan = plan_for(source, tmp_path) + bare = a_github(tmp_path, monkeypatch) + at = git("rev-parse", SPLIT_BRANCH, cwd=bare).stdout.strip() + asked = no_gh(adopter, monkeypatch) + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(execute_args(plan, tmp_path, local=False)) + assert caught.value.code == TAKEN + assert (f" FINDING {TAKEN}: `{ORG}/{PROJECT}` on GitHub already holds " + f"the plan's split branch `{SPLIT_BRANCH}`, at {at[:12]}. " + "`execute` without `--local-remote-dir` pushes its split commit " + f"to `{SPLIT_BRANCH}` there") in caught.value.detail + assert asked == [] + + +def test_execute_goes_on_to_the_legs_when_github_has_no_such_branch( + adopter, tmp_path, monkeypatch): + source = small_source(tmp_path) + plan = plan_for(source, tmp_path) + a_github(tmp_path, monkeypatch, branches=("adopt/other",)) + asked = no_gh(adopter, monkeypatch) + with pytest.raises(Proceeded): + adopter.cmd_execute(execute_args(plan, tmp_path, local=False)) + assert asked[0][:3] == ["gh", "repo", "create"] + + +def test_execute_refuses_a_repository_it_cannot_list(adopter, tmp_path, + monkeypatch): + """No `testorg/Northwind` where git is pointed: the push of the split + would fail there after both legs were made, so nothing is made.""" + source = small_source(tmp_path) + plan = plan_for(source, tmp_path) + github_at(tmp_path / "nowhere", monkeypatch) + asked = no_gh(adopter, monkeypatch) + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(execute_args(plan, tmp_path, local=False)) + assert caught.value.code == adopter.SPLIT_TARGET_UNREADABLE + assert caught.value.detail.startswith( + f"could not list the branches of https://github.com/{ORG}/{PROJECT}" + f".git, where `execute` pushes the split, to see whether " + f"`{SPLIT_BRANCH}` is new there: `git ls-remote` exited ") + assert "no leg repository was created" in caught.value.remediation + assert asked == [] + + +def with_origin(source: Path, bare: Path) -> None: + """`bare` as the source's `origin`, fetched: its branches are the + source's remote-tracking branches, and its path ends in + `testorg/Northwind.git`, which `plan` reads as the source's repository.""" + git("remote", "add", "origin", bare.as_posix(), cwd=source) + git(*FILE_PROTOCOL, "fetch", "-q", "origin", cwd=source) + + +def test_check_reads_github_as_the_sources_origin_last_fetched_it( + adopter, tmp_path, monkeypatch): + """`check` reads no network, and does not know whether `execute` will + be given `--local-remote-dir`: where the source's `origin` is the + assembly repository, what it fetched from there is asked too, and the + finding says which run it is about. A run that pushes to the source + itself is not refused for it.""" + bare = github_repository(tmp_path) + source = small_source(tmp_path) + with_origin(source, bare) + at = git("rev-parse", f"origin/{SPLIT_BRANCH}", cwd=source).stdout.strip() + plan = plan_for(source, tmp_path) + found = findings(plan) + assert found == [(TAKEN, ( + f"`{ORG}/{PROJECT}` on GitHub, as the source's `origin` last fetched " + f"it, already holds the plan's split branch `{SPLIT_BRANCH}`, at " + f"{at[:12]}. `execute` without `--local-remote-dir` pushes its split " + f"commit to `{SPLIT_BRANCH}` there, after both leg repositories were " + "created and pushed, and as a new branch: a branch already there " + "refuses that push unless the split fast-forwards it, and then the " + "split lands on a branch this adoption did not make. Delete " + f"`{SPLIT_BRANCH}` there, or set `adopt_branch:` in the plan to a " + "branch git can create there."))] + stop_at_the_legs(adopter, monkeypatch) + with pytest.raises(Proceeded): + adopter.cmd_execute(execute_args(plan, tmp_path)) + + +def test_an_origin_that_is_not_the_assembly_repository_is_not_asked( + tmp_path): + source = small_source(tmp_path) + with_origin(source, github_repository(tmp_path)) + plan = tmp_path / "plan" / "adoption-plan.yaml" + plan.parent.mkdir() + written = run_script(ADOPT, "plan", "--source", str(source), "--project", + "Elsewhere", "--org", ORG, "--elected-by", "Test", + "--elected-on", "2026-09-02", "--out", str(plan)) + assert written.returncode == 0, written.stderr + written.stdout + assert findings(plan) == [] + + +# --- end to end ------------------------------------------------------------- + +@needs_filter_repo +def test_the_issue_re_run_is_refused_and_runs_once_the_branch_is_gone( + tmp_path): + """#320 as measured on main: a verified run, then the same plan re-run + into fresh legs, which passed `check` and died at the push of the split + after both legs were made. Now `check` names the branch and the first + run's split commit, `execute` refuses before any leg exists, and once + the branch is deleted the same command adopts.""" + source = small_source(tmp_path) + plan = plan_for(source, tmp_path) + flags = ("--plan", str(plan), "--yes") + first = run_script(ADOPT, "execute", *flags, "--local-remote-dir", + str(tmp_path / "first-remotes"), "--work-dir", + str(tmp_path / "first-work")) + assert first.returncode == 0, first.stderr + first.stdout + split = git("rev-parse", SPLIT_BRANCH, cwd=source).stdout.strip() + + checked = run_script(ADOPT, "check", "--plan", str(plan)) + assert checked.returncode == 1, checked.stderr + checked.stdout + assert (f"FINDING {TAKEN}: the source {source} already holds the plan's " + f"split branch `{SPLIT_BRANCH}`, at {split[:12]}.") \ + in checked.stderr + remotes = tmp_path / REMOTES + again = ("--local-remote-dir", str(remotes), "--work-dir", + str(tmp_path / WORK)) + refused = run_script(ADOPT, "execute", *flags, *again) + assert refused.returncode == 2, refused.stderr + refused.stdout + assert f"REFUSED {TAKEN}: 1 finding(s) in " in refused.stderr + assert "creating the leg repositories" not in refused.stdout + assert not remotes.exists() + assert git("rev-parse", SPLIT_BRANCH, cwd=source).stdout.strip() == split + + git("branch", "-q", "-D", SPLIT_BRANCH, cwd=source) + assert run_script(ADOPT, "check", "--plan", str(plan)).returncode == 0 + adopted = run_script(ADOPT, "execute", *flags, *again) + assert adopted.returncode == 0, adopted.stderr + adopted.stdout + assert "adoption verified" in adopted.stdout From 33c6deeb83fb6827d2cae0fb3f7cf4690cacfb68 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:37:38 +0000 Subject: [PATCH 2/3] adopt-project.py: `execute` without `--local-remote-dir` asks the source's own branches for one git cannot hold beside the split branch, and `check` says that finding is about both runs, so a local source holding `adopt` no longer has both GitHub leg repositories made before `git filter-repo` fails (#320) The review of 7e55d00 measured a run without `--local-remote-dir`, from a local source holding the branch `adopt`, with `gh` recorded: `check` reported `plan-split-branch-taken` as a finding about `execute --local-remote-dir` alone, and `execute` asked only GitHub, which held no such branch, created both leg repositories, and exited 2 with nothing on stderr. `_extract_leg` clones the source, checks the split branch out in the clone and runs `git filter-repo`, which makes each of the clone's remote-tracking branches a branch and cannot make `adopt` beside `adopt/three-repo-shape`, whichever repository the split is then pushed to. - A source branch in git's way of the split branch (its name a directory of the split branch's, or under it) is now said as the extraction's failure, about `execute` with `--local-remote-dir` or without it. - `execute` without `--local-remote-dir` asks the source's own branches (`git for-each-ref refs/heads/`, no network) before GitHub, and refuses those names before any repository is created. The split branch's own name there is not refused for that run: the clone's `checkout -B` shadows it, and the push goes to GitHub. - A plan that seeds both legs extracts nothing, so neither command asks the source for the extraction; a branch in git's way there is then the push's finding, about `execute --local-remote-dir`, as before. - The remediation says the extraction meets the source's branches, and that a branch named as the source's `origin` last fetched it leaves `check`'s findings once `git fetch --prune origin` finds it gone. Unchanged: the split branch at its own name, and GitHub's branches, are asked and worded as before; a `--local-remote-dir` run's refusal still carries `check`'s findings word for word. Tests: `tests/test_adopt_split_branch.py` gains eight. Run against 7e55d00's `adopt-project.py`, five fail: the two runs without `--local-remote-dir` over a source branch `adopt` and `adopt/three-repo-shape/old` reach `gh repo create`, the two `check` sentences are the push's, and the remediation names no prune. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 168 ++++++++++++++++++++++--------- tests/test_adopt_split_branch.py | 161 +++++++++++++++++++++++++++-- 2 files changed, 274 insertions(+), 55 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index edd695e0..306ff02b 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -39,8 +39,9 @@ any case), the source's own submodules kept on one leg with their `.gitmodules`, registered there and never in the root, each relative url naming the same repository from the leg as from the - source, and the split branch a new one where `execute` pushes it. - It prints what will happen and changes nothing. + source, and the split branch a new one where `execute` pushes it, + which git can hold beside the source's own branches. It prints + what will happen and changes nothing. execute creates the two legs, extracts them with `git filter-repo`, makes the one split commit on a branch of the source, sets the `xf-project-` topic on all three (skipped for local remotes), @@ -3144,6 +3145,21 @@ def _entry_findings(plan: Plan, source: Source, # assembly repository, what that remote held when the source last fetched it # -- for an `org/repo` source, which `Source.open` clones, when it was cloned. # +# THE EXTRACTION MEETS THE SOURCE'S BRANCHES WHEREVER THE SPLIT IS PUSHED. +# `_extract_leg` clones the source, checks the split branch out in the clone +# and runs `git filter-repo`, which makes each of the clone's +# `refs/remotes/origin/*` a branch, and cannot make one git cannot hold +# beside the split branch. So a run WITHOUT `--local-remote-dir` from a +# source holding `adopt` created both leg repositories on GitHub and exited 2 +# at the filter, saying nothing, while `check`'s finding said it was about +# `execute --local-remote-dir` alone (measured with a recorded `gh`, the +# review of #320). A source branch in git's way is therefore this finding +# for both runs, said as the extraction's failure, and `execute` without +# `--local-remote-dir` asks the source's branches for it too, reading no +# network. The split branch's own name is not: the clone's `checkout -B` +# shadows it, so only the run that pushes to the source is refused for it. +# A plan that seeds both legs extracts nothing and is not asked. +# # THE HISTORY SCAN IS NOT CHANGED (#226). `_exported_gitlink` reads # `--branches`, so a split branch the source keeps is in it, and # `_extract_leg`'s `git checkout -B` shadows that branch in the extraction's @@ -3164,6 +3180,8 @@ def _entry_findings(plan: Plan, source: Source, #: How a finding names the run that pushes to each place `check` asks. LOCAL_RUN = "`execute --local-remote-dir`" GITHUB_RUN = "`execute` without `--local-remote-dir`" +#: How a finding names the runs that extract a leg from the source: both. +EITHER_RUN = "`execute`, with `--local-remote-dir` or without it," #: What a plan whose split branch is taken is told to do. Said by `check`, #: once after its findings, and by `execute`'s refusal. @@ -3171,15 +3189,20 @@ def _entry_findings(plan: Plan, source: Source, "Remediation: `execute` pushes its one split commit to the plan's " "`adopt_branch:` as a NEW branch of the repository the split goes to: the " "source itself under `--local-remote-dir`, and otherwise the assembly " - "repository on GitHub. A verified run leaves its split branch there, and " - "so does a run that failed after that push, so an earlier run's split is " - "what this finding names most often. Delete the branch a finding names, " - "where it names it, once neither it nor a pull request from it is " - "wanted, or set `adopt_branch:` to a branch git can create there, and run " - "`check` again. A branch the source itself holds is also in the history " - "`check` reads for the source's submodules, and `git filter-repo` exports " - "it once the plan names another branch, so a finding that names one of " - "its commits stands until that branch is deleted.") + "repository on GitHub. It also extracts each leg from a clone of the " + "source with that branch checked out, so a source branch git cannot " + "hold beside it stops either run. A verified run leaves its split branch " + "where it pushed it, and so does a run that failed after that push, so " + "an earlier run's split is what this finding names most often. Delete " + "the branch a finding names, where it names it, once neither it nor a " + "pull request from it is wanted, or set `adopt_branch:` to a branch git " + "can create there, and run `check` again. A branch named as the source's " + "`origin` last fetched it leaves `check`'s findings once `git fetch " + "--prune origin` in the source finds it gone there. A branch the source " + "itself holds is also in the history `check` reads for the source's " + "submodules, and `git filter-repo` exports it once the plan names " + "another branch, so a finding that names one of its commits stands " + "until that branch is deleted.") def _listed_branches(raw: bytes, prefix: str) -> dict[str, str]: @@ -3254,20 +3277,54 @@ def _taken(where: str, run: str, branch: str, name: str, "`adopt_branch:` in the plan to a branch git can create there.") +def _in_the_extraction(where: str, branch: str, name: str, + commit: str) -> str: + """One `plan-split-branch-taken` finding: the source, `where`, holds the + branch `name` at `commit`, which git cannot hold beside the split branch + `branch`, and `_extract_leg`'s `git filter-repo` makes each branch of + the source a branch of a clone where `branch` is checked out. Either + run extracts, so the finding is about both.""" + split, held = _spelled(branch), _spelled(name) + return (f"{where} holds the branch {held}, at {commit[:12]}. " + f"{EITHER_RUN} extracts a leg from a clone of the source with " + f"the plan's split branch {split} checked out in it, after both " + "leg repositories were created, and `git filter-repo` cannot " + f"create {held} beside {split} there: git holds no branch whose " + "name is a directory of another's, or lies under it. Delete " + f"{held} there, or set `adopt_branch:` in the plan to a branch " + "git can create there.") + + def _split_branch_findings(branch: str | None, places: list ) -> tuple[list[str], list[str]]: """`plan-split-branch-taken`, one FINDING per branch in the way in each - of `places`, `(where, run, branches)`; and what to print ONCE after - every finding, as `_submodule_findings` returns it. `branch` None is a - split branch `execute` refuses as `unsafe-value`, and nothing is asked - about it here.""" - found = [f"FINDING {SPLIT_BRANCH_TAKEN}: " - + _taken(where, run, branch, name, commit) - for where, run, branches in (places if branch else []) - for name, commit in _in_the_way(branches, branch)] + of `places`, `(where, pushed_by, extracted, branches)`; and what to + print ONCE after every finding, as `_submodule_findings` returns it. + + `pushed_by` is the run that pushes the split there, or None, and + `extracted` is whether `_extract_leg` clones it: the source, when a leg + is extracted. There a branch in git's way stops the extraction of + either run (`_in_the_extraction`), and the split branch's own name is + asked about only as the run that pushes there, because the clone's + `checkout -B` shadows it. `branch` None is a split branch `execute` + refuses as `unsafe-value`, and nothing is asked about it here.""" + found = [] + for where, pushed_by, extracted, branches in (places if branch else []): + for name, commit in _in_the_way(branches, branch): + if extracted and name != branch: + found.append(_in_the_extraction(where, branch, name, commit)) + elif pushed_by: + found.append(_taken(where, pushed_by, branch, name, commit)) + found = [f"FINDING {SPLIT_BRANCH_TAKEN}: {finding}" for finding in found] return found, [SPLIT_BRANCH_REMEDIATION] if found else [] +def _extracts_a_leg(assigned: dict) -> bool: + """Whether `_build_the_legs` runs `_extract_leg`: not when the plan + seeds both legs (`seeded_legs`, one definition).""" + return len(seeded_legs(assigned)) < len(EXTRACTED_LEGS) + + def _assembly_repository(plan: Plan) -> str | None: """`/`, the repository a run without `--local-remote-dir` pushes the split to (`_repository_urls`), as the plan names it; None @@ -3277,22 +3334,31 @@ def _assembly_repository(plan: Plan) -> str | None: return f"{org}/{name}" if org and name else None -def _places_check_asks(plan: Plan, source: Source, branch: str) -> list: - """The places `check` asks for the split branch `branch`, `(where, run, - branches)`: the source, which a run with `--local-remote-dir` pushes to, - and, where the source's `origin` is the assembly repository, that - repository's branches as the source last fetched them, which a run - without it pushes to. `check` reads no network for either.""" +def _branches_in(path: Path, refs: str) -> dict[str, str]: + """`_listed_branches` of the refs under `refs` in the repository at + `path`, read from it with `git for-each-ref`: no network.""" + return _listed_branches( + git_out(["for-each-ref", "--format=%(objectname)%09%(refname)", + refs], cwd=path, binary=True), refs) + + +def _places_check_asks(plan: Plan, source: Source, branch: str, + extracting: bool) -> list: + """The places `check` asks for the split branch `branch`, `(where, + pushed_by, extracted, branches)`: the source, which a run with + `--local-remote-dir` pushes to and either run extracts from while + `extracting`, and, where the source's `origin` is the assembly + repository, that repository's branches as the source last fetched them, + which a run without it pushes to. `check` reads no network for + either.""" places = [(f"the source {_shown(str(source.path))}", LOCAL_RUN, + extracting, _branches_at(str(source.path), source.path, branch))] assembly = _assembly_repository(plan) if assembly and (source.repository or "").lower() == assembly.lower(): - tracked = git_out(["for-each-ref", "--format=%(objectname)%09" - "%(refname)", "refs/remotes/origin/"], - cwd=source.path, binary=True) places.append((f"`{assembly}` on GitHub, as the source's `origin` " - "last fetched it,", GITHUB_RUN, - _listed_branches(tracked, "refs/remotes/origin/"))) + "last fetched it,", GITHUB_RUN, False, + _branches_in(source.path, "refs/remotes/origin/"))) return places @@ -3306,22 +3372,33 @@ def _check_split_branch(plan: Plan, source: Source plan.get("adopt_branch", ADOPT_BRANCH)) except Refusal: return [], [] - return _split_branch_findings(branch, - _places_check_asks(plan, source, branch)) + extracting = _extracts_a_leg(assigned_paths(plan.entries)) + return _split_branch_findings( + branch, _places_check_asks(plan, source, branch, extracting)) def _execute_split_branch(branch: str, local: bool, repositories: dict, - urls: dict, source: Source + urls: dict, source: Source, extracting: bool ) -> tuple[list[str], list[str]]: """`_split_branch_findings` as `execute` asks it: of the one repository - this run pushes the split to, listed as the push will meet it. A run - with `--local-remote-dir` says what `check` says of the source, word for - word, so that its refusal carries `check`'s findings as `check` prints - them.""" - place = ((f"the source {_shown(str(source.path))}", LOCAL_RUN) if local - else (f"`{repositories['assembly']}` on GitHub", GITHUB_RUN)) - return _split_branch_findings(branch, [ - (*place, _branches_at(urls["assembly"], source.path, branch))]) + this run pushes the split to, listed as the push will meet it, and, + while `extracting`, of the source the extraction clones. A run with + `--local-remote-dir` pushes to the source and says what `check` says of + it, word for word, so that its refusal carries `check`'s findings as + `check` prints them; a run without it asks the source's own branches + first, as `check` lists them, and then GitHub.""" + the_source = f"the source {_shown(str(source.path))}" + if local: + return _split_branch_findings(branch, [ + (the_source, LOCAL_RUN, extracting, + _branches_at(urls["assembly"], source.path, branch))]) + places = [(the_source, None, True, + _branches_in(source.path, "refs/heads/")) + ] if extracting else [] + places.append((f"`{repositories['assembly']}` on GitHub", GITHUB_RUN, + False, + _branches_at(urls["assembly"], source.path, branch))) + return _split_branch_findings(branch, places) def _topics_line(topic: str, local: bool) -> str: @@ -3992,7 +4069,8 @@ def _refuse_what_check_finds(plan: Plan, source: Source, THE SPLIT BRANCH COMES FIRST OF ALL (#320). `taken` is `_execute_split_branch`'s `(findings, remediation)`: a branch at or in the way of the split branch in the one repository this run pushes the - split to, which `cmd_execute` asks before this runs. Its findings lead + split to, and one in its way in the source the extraction clones, which + `cmd_execute` asks before this runs. Its findings lead the list, as `check` lists its own, so a re-run over an earlier run's split branch is refused as `plan-split-branch-taken`, and never as a finding of the history that branch adds to the scan (#226). `check` @@ -4298,9 +4376,9 @@ def cmd_execute(args) -> int: _refuse_an_unwritable_clone_url(urls["assembly"]) paths_for = _leg_paths(plan) _refuse_what_check_finds(plan, source, args.local_remote_dir, - _execute_split_branch(branch, local, - repositories, urls, - source)) + _execute_split_branch( + branch, local, repositories, urls, source, + _extracts_a_leg(paths_for))) seeded = seeded_legs(paths_for) _refuse_unconsented_seeding(args, plan, seeded) diff --git a/tests/test_adopt_split_branch.py b/tests/test_adopt_split_branch.py index c6713c06..c62214da 100644 --- a/tests/test_adopt_split_branch.py +++ b/tests/test_adopt_split_branch.py @@ -30,7 +30,6 @@ import importlib.util import shutil -import subprocess from argparse import Namespace from pathlib import Path @@ -194,24 +193,35 @@ def test_a_branch_the_split_would_fast_forward_is_taken_too(adopter, "three-repo-shape"] +def in_the_extraction(source: Path, name: str, at: str) -> str: + """`check`'s finding for a source branch `name` at `at` that git cannot + hold beside the split branch: about the extraction, which both runs + make.""" + return (f"the source {source} holds the branch `{name}`, at {at[:12]}. " + "`execute`, with `--local-remote-dir` or without it, extracts a " + "leg from a clone of the source with the plan's split branch " + f"`{SPLIT_BRANCH}` checked out in it, after both leg repositories " + f"were created, and `git filter-repo` cannot create `{name}` " + f"beside `{SPLIT_BRANCH}` there: git holds no branch whose name " + "is a directory of another's, or lies under it. Delete " + f"`{name}` there, or set `adopt_branch:` in the plan to a branch " + "git can create there.") + + @pytest.mark.parametrize("name", IN_THE_WAY) def test_a_branch_in_gits_way_of_the_split_branch_is_taken(adopter, tmp_path, monkeypatch, name): + """Said as the extraction's failure, for both runs: the review of #320 + measured a run without `--local-remote-dir` failing there too, after + both GitHub leg repositories were made, while this finding said it was + about `execute --local-remote-dir` alone.""" source = small_source(tmp_path) git("branch", name, cwd=source) at = git("rev-parse", name, cwd=source).stdout.strip() plan = plan_for(source, tmp_path) - found = findings(plan) - assert [code for code, _ in found] == [TAKEN], found - assert found[0][1].startswith( - f"the source {source} holds the branch `{name}`, at {at[:12]}. ") - assert (f"git cannot create `{SPLIT_BRANCH}` beside `{name}`: " - f"`'refs/heads/{name}' exists; cannot create " - f"'refs/heads/{SPLIT_BRANCH}'`. Delete `{name}` there, or set " - "`adopt_branch:` in the plan to a branch git can create there." - ) in found[0][1] + assert findings(plan) == [(TAKEN, in_the_extraction(source, name, at))] refusal = refused_before_any_leg(adopter, monkeypatch, plan, tmp_path) assert refusal.code == TAKEN @@ -328,6 +338,28 @@ def test_the_extraction_exports_the_old_branch_only_under_another_name( assert ".gitmodules" in caught.value.output, caught.value.output +@needs_filter_repo +@pytest.mark.parametrize("name", IN_THE_WAY) +def test_the_extraction_stops_at_a_source_branch_in_its_way(adopter, + tmp_path, name): + """`_extract_leg` itself, over a source holding `name`: its clone has + the split branch checked out, and `git filter-repo`, which makes each + of the clone's remote-tracking branches a branch, cannot make `name` + beside it, so nothing is pushed to the leg. That is before the split is + pushed anywhere, so the finding is about both runs.""" + source = small_source(tmp_path) + git("branch", name, cwd=source) + read = adopter.Source.open(str(source), tmp_path / "read") + leg = tmp_path / "leg.git" + git("init", "-q", "--bare", "-b", "main", str(leg), cwd=tmp_path) + with pytest.raises(adopter.CommandFailed) as caught: + adopter._extract_leg( + "code", read, tmp_path / "clone", ["src/"], tmp_path / "paths.txt", + SPLIT_BRANCH, str(leg), "main", f"{ORG}/{PROJECT}-code") + assert caught.value.args_list[1] == "filter-repo", caught.value.args_list + assert git("for-each-ref", cwd=leg).stdout == "" + + # --- the assembly repository on GitHub -------------------------------------- def github_repository(tmp_path: Path, branches=(SPLIT_BRANCH,)) -> Path: @@ -428,6 +460,96 @@ def test_execute_refuses_a_repository_it_cannot_list(adopter, tmp_path, assert asked == [] +def test_a_branch_in_gits_way_on_github_is_refused_in_the_pushs_words( + adopter, tmp_path, monkeypatch): + """GitHub holds `adopt`, and the source does not: the push of the + split would meet it there, so the finding is about the run that pushes + there, in git's own words.""" + source = small_source(tmp_path) + plan = plan_for(source, tmp_path) + bare = a_github(tmp_path, monkeypatch, branches=("adopt",)) + at = git("rev-parse", "adopt", cwd=bare).stdout.strip() + asked = no_gh(adopter, monkeypatch) + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(execute_args(plan, tmp_path, local=False)) + assert caught.value.code == TAKEN + assert (f" FINDING {TAKEN}: `{ORG}/{PROJECT}` on GitHub holds the " + f"branch `adopt`, at {at[:12]}. `execute` without " + "`--local-remote-dir` pushes its split commit to " + f"`{SPLIT_BRANCH}` there, after both leg repositories were " + "created and pushed, and git cannot create " + f"`{SPLIT_BRANCH}` beside `adopt`: `'refs/heads/adopt' exists; " + f"cannot create 'refs/heads/{SPLIT_BRANCH}'`. Delete `adopt` " + "there, or set `adopt_branch:` in the plan to a branch git can " + "create there.") in caught.value.detail.splitlines() + assert asked == [] + + +@pytest.mark.parametrize("name", IN_THE_WAY) +def test_a_github_run_is_refused_for_a_source_branch_in_the_extractions_way( + adopter, tmp_path, monkeypatch, name): + """The review of #320: without `--local-remote-dir`, a local source + holding `adopt` made both GitHub leg repositories, and the run exited 2 + at `git filter-repo`, saying nothing. GitHub holds no such branch here; + the source does, and `execute` asks it as well, before `gh repo + create`, and refuses with `check`'s finding, word for word.""" + source = small_source(tmp_path) + git("branch", name, cwd=source) + at = git("rev-parse", name, cwd=source).stdout.strip() + plan = plan_for(source, tmp_path) + a_github(tmp_path, monkeypatch, branches=()) + asked = no_gh(adopter, monkeypatch) + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(execute_args(plan, tmp_path, local=False)) + assert caught.value.code == TAKEN + lines = caught.value.detail.splitlines() + assert lines[0].startswith("1 finding(s) in "), lines + assert lines[1] == (f" FINDING {TAKEN}: " + + in_the_extraction(source, name, at)) + assert asked == [] + + +def test_a_github_run_is_not_refused_for_the_split_branch_in_the_source( + adopter, tmp_path, monkeypatch): + """The split branch's own name in the source stops only the run that + pushes there. The extraction's `checkout -B` shadows it in its clone + (`test_the_extraction_exports_the_old_branch_only_under_another_name`), + and this run pushes to GitHub, which holds none.""" + source = small_source(tmp_path) + split_branch_by_hand(source) + plan = plan_for(source, tmp_path) + a_github(tmp_path, monkeypatch, branches=()) + asked = no_gh(adopter, monkeypatch) + with pytest.raises(Proceeded): + adopter.cmd_execute(execute_args(plan, tmp_path, local=False)) + assert asked[0][:3] == ["gh", "repo", "create"] + + +def test_a_plan_that_seeds_both_legs_is_not_asked_about_the_extraction( + adopter, tmp_path, monkeypatch): + """Nothing is extracted, so a source branch `adopt` stops only the push + of the split to the source: `check` says that of `execute + --local-remote-dir`, in the push's words, and a run without it goes + on.""" + source = small_source(tmp_path) + git("branch", "adopt", cwd=source) + at = git("rev-parse", "adopt", cwd=source).stdout.strip() + plan = plan_for(source, tmp_path, {"specs/": "root", "src/": "root"}) + found = findings(plan) + assert [code for code, _ in found] == [TAKEN], found + assert found[0][1].startswith( + f"the source {source} holds the branch `adopt`, at {at[:12]}. " + "`execute --local-remote-dir` pushes its split commit to " + f"`{SPLIT_BRANCH}` there, "), found + a_github(tmp_path, monkeypatch, branches=()) + asked = no_gh(adopter, monkeypatch) + args = execute_args(plan, tmp_path, local=False) + args.allow_empty_leg = ["spec", "code"] + with pytest.raises(Proceeded): + adopter.cmd_execute(args) + assert asked[0][:3] == ["gh", "repo", "create"] + + def with_origin(source: Path, bare: Path) -> None: """`bare` as the source's `origin`, fetched: its branches are the source's remote-tracking branches, and its path ends in @@ -477,6 +599,25 @@ def test_an_origin_that_is_not_the_assembly_repository_is_not_asked( assert findings(plan) == [] +def test_a_branch_gone_from_github_leaves_check_once_the_source_prunes( + adopter, tmp_path): + """What the remediation says of a branch named as the source's `origin` + last fetched it: deleted there, it stays a finding until `git fetch + --prune origin` in the source, which `execute`, asking GitHub itself, + does not wait for.""" + bare = github_repository(tmp_path) + source = small_source(tmp_path) + with_origin(source, bare) + plan = plan_for(source, tmp_path) + assert [code for code, _ in findings(plan)] == [TAKEN] + git("update-ref", "-d", f"refs/heads/{SPLIT_BRANCH}", cwd=bare) + assert [code for code, _ in findings(plan)] == [TAKEN] + git(*FILE_PROTOCOL, "fetch", "-q", "--prune", "origin", cwd=source) + assert findings(plan) == [] + assert ("once `git fetch --prune origin` in the source finds it gone " + "there") in adopter.SPLIT_BRANCH_REMEDIATION + + # --- end to end ------------------------------------------------------------- @needs_filter_repo From 32abe485ad343585808ae3e8f2f05defc1f27520 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Mon, 12 Oct 2026 22:43:55 +0000 Subject: [PATCH 3/3] #320 composed with #445: `check` names the split branch an earlier run left under `execute`'s own `adopt-branch-exists`, and `execute` refuses only a branch in git's way of it, after #445's refusals #445 (75052cb) refuses in `execute` the split branch an earlier run left, as `adopt-branch-exists`, before any leg exists, and only where the push cannot fast-forward it. Its tests pin its order: an occupied work directory first, then that branch, and a live leg repository under `--local-remote-dir` keeps `leg-remote-exists`. This branch's refusal of the same branch came first, in `_refuse_what_check_finds`, and preempted all three: 8 tests of `tests/test_adopt_late_failure.py` failed on 973d0fe. - `check` keeps the finding main lacks. For the split branch itself where the split is pushed it is now `adopt-branch-exists`, the code `execute` refuses it under and `_left_by_the_split` tells the re-run, asked by #445's rule (`_in_the_history_of`), so a branch at the source commit or behind it, which `execute` pushes over and verifies, is no finding. - A branch in git's way of the split branch (`adopt`, `adopt/three-repo-shape/old`), which main still meets only after both legs exist (measured on ec4a7a0: at `git filter-repo`, or at the split push), stays `plan-split-branch-taken` in `check` and in `execute`. - `execute` asks for it in `_refuse_a_branch_in_the_split_branchs_way`, right after `_refuse_a_split_branch_left_behind`, skipped like it while a leg repository is still there under `--local-remote-dir`. `_refuse_what_check_finds` is main's again. - Dropped: `execute`'s own refusal of the split branch itself, and `adopt-split-target-unreadable`, which #445's `git ls-remote` of the same repository now always meets first. Unchanged: #445's functions and `cmd_execute`'s order before the new call are byte-identical to main's; `tests/test_adopt_late_failure.py` and `tests/test_adopt_e2e.py` are main's, unedited. `execute` over a split branch whose commit #226's history scan names is refused for the scan's finding first, as on main; `check` names the branch first. Lane: openreposhape-3 (openRepoShape-3) Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 423 +++++++++++++++++-------------- tests/test_adopt_e2e.py | 23 +- tests/test_adopt_split_branch.py | 327 ++++++++++++++++-------- 3 files changed, 465 insertions(+), 308 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index aa49801b..a7ab04d5 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -40,9 +40,10 @@ any case), the source's own submodules kept on one leg with their `.gitmodules`, registered there and never in the root, each relative url naming the same repository from the leg as from the - source, and the split branch a new one where `execute` pushes it, - which git can hold beside the source's own branches. It prints - what will happen and changes nothing. + source, and the split branch one `execute` can make: none of its + name where the split is pushed that the push cannot fast-forward, + and none git cannot hold beside it there or in the source. It + prints what will happen and changes nothing. execute creates the two legs, extracts them with `git filter-repo`, makes the one split commit on a branch of the source, sets the `xf-project-` topic on all three (skipped for local remotes), @@ -3501,91 +3502,120 @@ def _entry_findings(plan: Plan, source: Source, # # `_commit_the_split` pushes the one split commit to `refs/heads/` # of the repository the split goes to: the source itself under -# `--local-remote-dir` (`_repository_urls`), and otherwise the assembly -# repository on GitHub, which an in-place adoption keeps as the source. -# Nothing asked whether that branch was already there, and an earlier run -# leaves it: a verified one, and one that failed after the push. On main at -# 71b58a1 the same plan, re-run into fresh legs after a verified run, passed -# `check` as `plan ok`, made and pushed both legs, and was refused at the push -# of the split as a non-fast-forward. A source branch `adopt` failed the run -# after both legs were made as well, at `git filter-repo`, which could not -# create `refs/heads/adopt` beside the split branch `_extract_leg` checked -# out: git cannot hold a branch whose name is a directory of another's, or -# lies under it. +# `--local-remote-dir` (`_remote_urls`), and otherwise the assembly +# repository on GitHub, which an in-place adoption keeps as the source. And +# `_extract_leg` clones the source, checks that branch out in the clone and +# runs `git filter-repo`, which makes each of the clone's +# `refs/remotes/origin/*` a branch. Two kinds of branch stop it, and `check` +# passed both as `plan ok`: # -# So a branch AT the split branch's name, or in git's way of it, in the -# repository the split is pushed to, is `plan-split-branch-taken`: `check` -# reports it, and `execute` refuses it, FIRST among `check`'s findings and -# with them, before any leg exists. Whatever the branch holds: one the split -# would fast-forward is a branch this adoption did not make, and on main the -# split was pushed onto it and verified. +# - THE SPLIT BRANCH ITSELF, where the split is pushed, at a commit the push +# cannot fast-forward: what an earlier run leaves there, verified or +# failed after its push. `execute` refuses it as `adopt-branch-exists` +# before any leg exists (`_refuse_a_split_branch_left_behind`, #188 N1). +# One at the source commit or behind it is pushed over, and verifies. +# - A BRANCH IN GIT'S WAY OF IT, whose name is a directory of the split +# branch's, or lies under it: git cannot hold the two side by side. In the +# source it stops the extraction of either run, at `git filter-repo`; +# where the split is pushed, it stops that push. Nothing refused it. +# Measured on main at ec4a7a0 with git 2.43 and `git filter-repo` 2.47.0: +# a source branch `adopt` or `adopt/three-repo-shape/old` made both legs, +# two bare repositories or two `gh repo create`s, and the run exited 2 at +# the filter; a plan seeding both legs from a source holding `adopt`, and a +# GitHub assembly repository holding `adopt`, made both legs and exited 2 +# at the push of the split. # -# WHERE IT IS ASKED. `execute` knows where it pushes, and asks that -# repository with `git ls-remote`, as the push will meet it. `check` does not -# know whether `--local-remote-dir` will be passed, so it asks the two places -# it can see without the network, and each finding says which run it is -# about: the source's own branches, and, where the source's `origin` is the -# assembly repository, what that remote held when the source last fetched it -# -- for an `org/repo` source, which `Source.open` clones, when it was cloned. +# So `check` reports both, and `execute` refuses the second, before any leg +# exists, as `plan-split-branch-taken`. # -# THE EXTRACTION MEETS THE SOURCE'S BRANCHES WHEREVER THE SPLIT IS PUSHED. -# `_extract_leg` clones the source, checks the split branch out in the clone -# and runs `git filter-repo`, which makes each of the clone's -# `refs/remotes/origin/*` a branch, and cannot make one git cannot hold -# beside the split branch. So a run WITHOUT `--local-remote-dir` from a -# source holding `adopt` created both leg repositories on GitHub and exited 2 -# at the filter, saying nothing, while `check`'s finding said it was about -# `execute --local-remote-dir` alone (measured with a recorded `gh`, the -# review of #320). A source branch in git's way is therefore this finding -# for both runs, said as the extraction's failure, and `execute` without -# `--local-remote-dir` asks the source's branches for it too, reading no -# network. The split branch's own name is not: the clone's `checkout -B` -# shadows it, so only the run that pushes to the source is refused for it. -# A plan that seeds both legs extracts nothing and is not asked. +# ONE CODE FOR ONE SITUATION. `check`'s finding for the split branch itself +# is `adopt-branch-exists`: the code `execute` refuses that branch under, and +# the one `_left_by_the_split` tells a run that pushed its split the re-run +# will meet. It is asked by that refusal's own rule (`_in_the_history_of`), +# so a branch the push fast-forwards is no finding, and the two commands +# cannot disagree about it, as #168 has them agree about every other finding. +# A branch in the way is `plan-split-branch-taken` in both. +# +# WHERE `check` ASKS. It does not know whether `--local-remote-dir` will be +# passed, so it asks the two places it can see without the network, and each +# finding says which run it is about: the source's own branches, and, where +# the source's `origin` is the assembly repository, what that remote held +# when the source last fetched it -- for an `org/repo` source, which +# `Source.open` clones, when it was cloned. A source branch in the way is +# about both runs, said as the extraction's failure, unless the plan seeds +# both legs and extracts nothing. The split branch's own name in the source +# is about the run that pushes there alone: the extraction's `checkout -B` +# shadows it in its clone. +# +# WHERE `execute` ASKS (`_refuse_a_branch_in_the_split_branchs_way`). Right +# after `_refuse_a_split_branch_left_behind`, before `_confirm`: every +# refusal of what an earlier run left keeps its place and its words, the +# work directory first and then the split branch itself, and a leg +# repository still there under `--local-remote-dir`, which +# `_create_leg_remotes` refuses as `leg-remote-exists`, keeps its precedence +# over this refusal as over that one. It lists the repository it pushes to +# with `git ls-remote --heads`, as the push will meet it, and, without +# `--local-remote-dir`, the source's own branches for the extraction, +# reading no network for them. # # THE HISTORY SCAN IS NOT CHANGED (#226). `_exported_gitlink` reads # `--branches`, so a split branch the source keeps is in it, and # `_extract_leg`'s `git checkout -B` shadows that branch in the extraction's -# clone: on main the scan named a commit of it which the extraction never -# exports. With this finding first, a plan that leaves the branch where it -# is and names it is refused for the branch, and a run that gets past it -# reads what the scan reads: with the branch deleted neither holds it, and -# with another `adopt_branch:` `git filter-repo` exports it, and failed on -# the tip `.gitmodules` the scan's finding names (measured with git 2.43 and -# `git filter-repo` 2.47.0). - -#: `check`'s finding, and the code of `execute`'s refusal, for a branch at or -#: in the way of the split branch where the split is pushed. +# clone: the scan can name a commit of it which the extraction never +# exports. `check` names the branch first, before the entries' findings, and +# its remediation says that such a finding stands until the branch is +# deleted. `execute` refuses the scan's finding, in +# `_refuse_what_check_finds`, before `_refuse_a_split_branch_left_behind` +# asks for the branch. With the branch deleted neither holds it, and under +# another `adopt_branch:` `git filter-repo` exports it, and fails on the tip +# `.gitmodules` the scan's finding names (measured with git 2.43 and `git +# filter-repo` 2.47.0). + +#: `check`'s finding, and the code of `execute`'s refusal, for a branch in +#: git's way of the split branch where the split is pushed or extracted. SPLIT_BRANCH_TAKEN = "plan-split-branch-taken" -#: `execute`'s refusal when the repository it pushes the split to cannot be -#: listed: a push to it would fail the same way, after both legs exist. -SPLIT_TARGET_UNREADABLE = "adopt-split-target-unreadable" +#: `check`'s finding for the split branch itself where the split is pushed, +#: at a commit the push cannot fast-forward: the code +#: `_refuse_a_split_branch_left_behind` refuses that branch under. +SPLIT_BRANCH_EXISTS = "adopt-branch-exists" #: How a finding names the run that pushes to each place `check` asks. LOCAL_RUN = "`execute --local-remote-dir`" GITHUB_RUN = "`execute` without `--local-remote-dir`" #: How a finding names the runs that extract a leg from the source: both. EITHER_RUN = "`execute`, with `--local-remote-dir` or without it," -#: What a plan whose split branch is taken is told to do. Said by `check`, -#: once after its findings, and by `execute`'s refusal. +#: What `check` says once after its findings when one of them is about the +#: split branch. SPLIT_BRANCH_REMEDIATION = ( "Remediation: `execute` pushes its one split commit to the plan's " - "`adopt_branch:` as a NEW branch of the repository the split goes to: the " - "source itself under `--local-remote-dir`, and otherwise the assembly " - "repository on GitHub. It also extracts each leg from a clone of the " - "source with that branch checked out, so a source branch git cannot " - "hold beside it stops either run. A verified run leaves its split branch " - "where it pushed it, and so does a run that failed after that push, so " - "an earlier run's split is what this finding names most often. Delete " - "the branch a finding names, where it names it, once neither it nor a " - "pull request from it is wanted, or set `adopt_branch:` to a branch git " - "can create there, and run `check` again. A branch named as the source's " - "`origin` last fetched it leaves `check`'s findings once `git fetch " - "--prune origin` in the source finds it gone there. A branch the source " - "itself holds is also in the history `check` reads for the source's " - "submodules, and `git filter-repo` exports it once the plan names " - "another branch, so a finding that names one of its commits stands " - "until that branch is deleted.") + "`adopt_branch:` in the repository the split goes to: the source itself " + "under `--local-remote-dir`, and otherwise the assembly repository on " + "GitHub. It moves a branch already there only as a fast-forward, and it " + "extracts each leg from a clone of the source with that branch checked " + "out, so a branch git cannot hold beside it, there or in the source, " + "stops either run. A run leaves its split branch where it pushed it, " + "verified or not, so an earlier run's split is what " + "`adopt-branch-exists` names most often: if that run verified and its " + "pull request is the adoption you want, review that instead. Otherwise " + "delete each branch a finding names, where it names it, once neither it " + "nor a pull request from it is wanted, or set `adopt_branch:` to a name " + "git can create there, and run `check` again. A branch named as the " + "source's `origin` last fetched it leaves `check`'s findings once `git " + "fetch --prune origin` in the source finds it gone there. A branch the " + "source itself holds is also in the history `check` reads for the " + "source's submodules, and `git filter-repo` exports it once the plan " + "names another branch, so a finding that names one of its commits " + "stands until that branch is deleted.") + +#: `execute`'s remediation for `plan-split-branch-taken`. +SPLIT_BRANCH_TAKEN_REMEDIATION = ( + "Remediation: no leg repository was created and nothing was pushed. Git " + "holds no branch whose name is a directory of another's, or lies under " + "it, so the plan's split branch cannot be made beside a branch named " + "above, where it is named. Delete that branch there, once neither it " + "nor a pull request from it is wanted, or set `adopt_branch:` in the " + "plan to a name git can create there, then run `check` and `execute` " + "again.") def _listed_branches(raw: bytes, prefix: str) -> dict[str, str]: @@ -3602,26 +3632,17 @@ def _listed_branches(raw: bytes, prefix: str) -> dict[str, str]: return out -def _branches_at(url: str, cwd: Path, branch: str) -> dict[str, str]: +def _branches_at(url: str, cwd: Path) -> dict[str, str]: """Every branch of the repository at `url`, as a push to it meets them: - `git ls-remote --heads`. One that cannot be listed is refused before any - leg exists, because `execute` pushes the split there after both.""" - listed = subprocess.run( - ["git", *FILE_PROTOCOL, "ls-remote", "--heads", "--", url], - cwd=cwd, capture_output=True, check=False) + `git ls-remote --heads`. One that cannot be listed fails as `run` fails + a command, `CommandFailed`, and in `execute` before any leg exists: + `_refuse_a_split_branch_left_behind` has just listed the same + repository, and a failure there is the same.""" + args = ["git", *FILE_PROTOCOL, "ls-remote", "--heads", "--", url] + listed = subprocess.run(args, cwd=cwd, capture_output=True, check=False) if listed.returncode: - raise Refusal( - SPLIT_TARGET_UNREADABLE, - f"could not list the branches of {_shown(url)}, where `execute` " - f"pushes the split, to see whether {_spelled(branch)} is new " - f"there: `git ls-remote` exited {listed.returncode}: " - + _one_line(listed.stderr.decode("utf-8", "replace")), - "Remediation: no leg repository was created and nothing was " - "pushed. `execute` pushes the split to that same repository once " - "it has made both leg repositories, so a run that cannot read it " - "now cannot push to it then. Check that the repository exists and " - "that git can reach it (`gh auth status`; `gh auth setup-git` " - "lets git use that login), then run the command again.") + raise CommandFailed(args, cwd, listed.returncode, + listed.stderr.decode("utf-8", "replace")) return _listed_branches(listed.stdout, "refs/heads/") @@ -3636,28 +3657,38 @@ def _in_the_way(branches: dict[str, str], branch: str) -> list[tuple]: key=lambda found: (found[0] != branch, found[0])) +def _left_behind(where: str, run: str, branch: str, commit: str, + source_commit: str) -> str: + """One `adopt-branch-exists` finding: `where` already has the split + branch `branch`, at `commit`, which is neither `source_commit` nor a + commit behind it, and `run` pushes the split there. Said as + `_refuse_a_split_branch_left_behind` says it.""" + split = _spelled(branch) + return (f"{where} already has {split}, the branch {run} pushes its " + f"split commit to, at {commit[:12]}: neither the source commit " + f"{source_commit[:12]} nor a commit behind it. The split commit " + "is made on top of the source commit, and git moves a branch to " + "it without force only from that commit or one behind it, so " + "`execute` refuses that run before it creates a leg, as " + f"`{SPLIT_BRANCH_EXISTS}`. Delete {split} there, or set " + "`adopt_branch:` in the plan to a name no branch has there.") + + def _taken(where: str, run: str, branch: str, name: str, commit: str) -> str: - """One `plan-split-branch-taken` finding: `where` holds the branch - `name` at `commit`, which takes the split branch `branch`, and `run` - pushes the split there.""" - split = _spelled(branch) - if name == branch: - held = f"{where} already holds the plan's split branch {split}" - why = ("and as a new branch: a branch already there refuses that " - "push unless the split fast-forwards it, and then the split " - "lands on a branch this adoption did not make") - else: - held = f"{where} holds the branch {_spelled(name)}" - # git's own words, each name spelled as `_spelled` spells it, so a - # backtick a branch name may hold cannot end the quotation. - why = (f"and git cannot create {split} beside {_spelled(name)}: " - f"`'refs/heads/{_spelled(name)[1:-1]}' exists; cannot create " - f"'refs/heads/{split[1:-1]}'`") - return (f"{held}, at {commit[:12]}. {run} pushes its split commit to " - f"{split} there, after both leg repositories were created and " - f"pushed, {why}. Delete {_spelled(name)} there, or set " - "`adopt_branch:` in the plan to a branch git can create there.") + """One `plan-split-branch-taken` finding about the push: `where` holds + the branch `name`, at `commit`, which git cannot hold beside the split + branch `branch`, and `run` pushes the split there.""" + split, held = _spelled(branch), _spelled(name) + # git's own words, each name spelled as `_spelled` spells it, so a + # backtick a branch name may hold cannot end the quotation. + return (f"{where} holds the branch {held}, at {commit[:12]}. {run} " + f"pushes its split commit to {split} there, after both leg " + "repositories were created and pushed, and git cannot create " + f"{split} beside {held}: `'refs/heads/{held[1:-1]}' exists; " + f"cannot create 'refs/heads/{split[1:-1]}'`. Delete {held} " + "there, or set `adopt_branch:` in the plan to a branch git can " + "create there.") def _in_the_extraction(where: str, branch: str, name: str, @@ -3678,28 +3709,40 @@ def _in_the_extraction(where: str, branch: str, name: str, "git can create there.") -def _split_branch_findings(branch: str | None, places: list - ) -> tuple[list[str], list[str]]: - """`plan-split-branch-taken`, one FINDING per branch in the way in each - of `places`, `(where, pushed_by, extracted, branches)`; and what to - print ONCE after every finding, as `_submodule_findings` returns it. +def _split_branch_findings(branch: str | None, places: list, + source: Source | None = None) -> list[str]: + """One FINDING per branch that stops the split branch `branch` in each + of `places`, `(where, pushed_by, extracted, branches)`. `pushed_by` is the run that pushes the split there, or None, and `extracted` is whether `_extract_leg` clones it: the source, when a leg - is extracted. There a branch in git's way stops the extraction of - either run (`_in_the_extraction`), and the split branch's own name is - asked about only as the run that pushes there, because the clone's - `checkout -B` shadows it. `branch` None is a split branch `execute` - refuses as `unsafe-value`, and nothing is asked about it here.""" + is extracted. A branch in git's way is `plan-split-branch-taken`: about + the extraction, for either run, where it is extracted + (`_in_the_extraction`), and otherwise about the push (`_taken`). + + The split branch's own name is asked only where the split is pushed, + because the extraction's `checkout -B` shadows it, and only by `check`, + which hands its `source` in: `adopt-branch-exists`, by + `_refuse_a_split_branch_left_behind`'s rule, so a branch the push + fast-forwards is none. `execute` leaves that branch to that function. + `branch` None is a split branch `execute` refuses as `unsafe-value`, + and nothing is asked about it here.""" found = [] for where, pushed_by, extracted, branches in (places if branch else []): for name, commit in _in_the_way(branches, branch): - if extracted and name != branch: - found.append(_in_the_extraction(where, branch, name, commit)) - elif pushed_by: - found.append(_taken(where, pushed_by, branch, name, commit)) - found = [f"FINDING {SPLIT_BRANCH_TAKEN}: {finding}" for finding in found] - return found, [SPLIT_BRANCH_REMEDIATION] if found else [] + if name != branch and extracted: + found.append(f"FINDING {SPLIT_BRANCH_TAKEN}: " + + _in_the_extraction(where, branch, name, + commit)) + elif name != branch and pushed_by: + found.append(f"FINDING {SPLIT_BRANCH_TAKEN}: " + + _taken(where, pushed_by, branch, name, commit)) + elif (name == branch and pushed_by and source is not None + and not _in_the_history_of(source, commit)): + found.append(f"FINDING {SPLIT_BRANCH_EXISTS}: " + + _left_behind(where, pushed_by, branch, commit, + source.commit)) + return found def _extracts_a_leg(assigned: dict) -> bool: @@ -3725,18 +3768,15 @@ def _branches_in(path: Path, refs: str) -> dict[str, str]: refs], cwd=path, binary=True), refs) -def _places_check_asks(plan: Plan, source: Source, branch: str, - extracting: bool) -> list: - """The places `check` asks for the split branch `branch`, `(where, - pushed_by, extracted, branches)`: the source, which a run with - `--local-remote-dir` pushes to and either run extracts from while - `extracting`, and, where the source's `origin` is the assembly - repository, that repository's branches as the source last fetched them, - which a run without it pushes to. `check` reads no network for - either.""" +def _places_check_asks(plan: Plan, source: Source, extracting: bool) -> list: + """The places `check` asks for the split branch, `(where, pushed_by, + extracted, branches)`: the source, which a run with `--local-remote-dir` + pushes to and either run extracts from while `extracting`, and, where + the source's `origin` is the assembly repository, that repository's + branches as the source last fetched them, which a run without it pushes + to. `check` reads no network for either.""" places = [(f"the source {_shown(str(source.path))}", LOCAL_RUN, - extracting, - _branches_at(str(source.path), source.path, branch))] + extracting, _branches_at(str(source.path), source.path))] assembly = _assembly_repository(plan) if assembly and (source.repository or "").lower() == assembly.lower(): places.append((f"`{assembly}` on GitHub, as the source's `origin` " @@ -3747,41 +3787,62 @@ def _places_check_asks(plan: Plan, source: Source, branch: str, def _check_split_branch(plan: Plan, source: Source ) -> tuple[list[str], list[str]]: - """`_split_branch_findings` as `check` asks it. A split branch that is - not a value `execute` puts on a command line is not asked about: it is - `execute`'s refusal `unsafe-value`, before any leg exists.""" + """`_split_branch_findings` as `check` asks it, the split branch's own + name included, and what to print ONCE after every finding, as + `_submodule_findings` returns it. A split branch that is not a value + `execute` puts on a command line is not asked about: it is `execute`'s + refusal `unsafe-value`, before any leg exists.""" try: branch = checked_value("adopt_branch", plan.get("adopt_branch", ADOPT_BRANCH)) except Refusal: return [], [] extracting = _extracts_a_leg(assigned_paths(plan.entries)) - return _split_branch_findings( - branch, _places_check_asks(plan, source, branch, extracting)) - - -def _execute_split_branch(branch: str, local: bool, repositories: dict, - urls: dict, source: Source, extracting: bool - ) -> tuple[list[str], list[str]]: - """`_split_branch_findings` as `execute` asks it: of the one repository - this run pushes the split to, listed as the push will meet it, and, - while `extracting`, of the source the extraction clones. A run with - `--local-remote-dir` pushes to the source and says what `check` says of - it, word for word, so that its refusal carries `check`'s findings as - `check` prints them; a run without it asks the source's own branches - first, as `check` lists them, and then GitHub.""" + found = _split_branch_findings( + branch, _places_check_asks(plan, source, extracting), source) + return found, [SPLIT_BRANCH_REMEDIATION] if found else [] + + +def _refuse_a_branch_in_the_split_branchs_way( + plan: Plan, source: Source, repositories: dict, urls: dict, + branch: str, local: bool, extracting: bool) -> None: + """Refuse a branch git cannot hold beside the split branch, where this + run pushes the split or, while `extracting`, in the source it extracts + the legs from, BEFORE a leg repository exists (#320): `check`'s finding + `plan-split-branch-taken`, which nothing refused. + + `cmd_execute` runs it right after `_refuse_a_split_branch_left_behind`, + which asks for the split branch's own name, and it keeps that + function's precedence: a leg repository still there under + `--local-remote-dir` is `leg-remote-exists`'s to refuse, and this asks + nothing then. + + Under `--local-remote-dir` the split is pushed to the source, which is + also what the extraction clones: one listing, `git ls-remote --heads`, + as the push will meet it, and each finding is `check`'s, word for word. + Without it, the source's own branches are asked for the extraction, + reading no network, and each finding is `check`'s, word for word; then + the assembly repository on GitHub, as the push will meet it, which + `check` reads as the source's `origin` last fetched it, if at all. + """ + if local and any(Path(urls[role]).exists() for role in EXTRACTED_LEGS): + return the_source = f"the source {_shown(str(source.path))}" if local: - return _split_branch_findings(branch, [ - (the_source, LOCAL_RUN, extracting, - _branches_at(urls["assembly"], source.path, branch))]) - places = [(the_source, None, True, - _branches_in(source.path, "refs/heads/")) - ] if extracting else [] - places.append((f"`{repositories['assembly']}` on GitHub", GITHUB_RUN, - False, - _branches_at(urls["assembly"], source.path, branch))) - return _split_branch_findings(branch, places) + places = [(the_source, LOCAL_RUN, extracting, + _branches_at(urls["assembly"], source.path))] + else: + places = [(the_source, None, True, + _branches_in(source.path, "refs/heads/")) + ] if extracting else [] + places.append((f"`{repositories['assembly']}` on GitHub", GITHUB_RUN, + False, _branches_at(urls["assembly"], source.path))) + found = _split_branch_findings(branch, places) + if found: + raise Refusal(SPLIT_BRANCH_TAKEN, "\n".join( + [f"{len(found)} finding(s) in {plan.path}:", + *(f" {finding}" for finding in found)]), + SPLIT_BRANCH_TAKEN_REMEDIATION) def _topics_line(topic: str, local: bool) -> str: @@ -3845,9 +3906,8 @@ def cmd_check(args) -> int: "against a tree that has moved proves nothing about the tree that " "will be split.") - # Before the entries (#320): a split branch that is already there is the - # one cause of a re-run's failure, and the history scan the entries' - # findings make reads that branch too. + # Before the entries (#320): the history scan their findings make reads + # a split branch the source keeps, and the branch is named first. taken, taken_remediation = _check_split_branch(plan, source) findings.extend(taken) entry_findings, remediation, notes = _entry_findings(plan, source) @@ -4841,8 +4901,7 @@ def _leg_paths(plan: Plan) -> dict: def _refuse_what_check_finds(plan: Plan, source: Source, - local_remote_dir: Path | None = None, - taken: tuple = ((), ())) -> None: + local_remote_dir: Path | None = None) -> None: """Refuse a plan `check` rejects, BEFORE a leg repository exists (#168). Of `check`'s findings, `execute` used to refuse only `plan-unresolved`, @@ -4914,22 +4973,8 @@ def _refuse_what_check_finds(plan: Plan, source: Source, a finding is this run's own, and the refusal says so. A url `check` finds from GitHub's remotes may name the same repository from these, and one it passes may not. - - THE SPLIT BRANCH COMES FIRST OF ALL (#320). `taken` is - `_execute_split_branch`'s `(findings, remediation)`: a branch at or in - the way of the split branch in the one repository this run pushes the - split to, and one in its way in the source the extraction clones, which - `cmd_execute` asks before this runs. Its findings lead - the list, as `check` lists its own, so a re-run over an earlier run's - split branch is refused as `plan-split-branch-taken`, and never as a - finding of the history that branch adds to the scan (#226). `check` - asks where it can see without the network (`_places_check_asks`), and - for a run without `--local-remote-dir` this asks the repository itself. """ - entry_findings, entry_remediation, _ = _entry_findings( - plan, source, local_remote_dir) - findings = [*taken[0], *entry_findings] - remediation = [*taken[1], *entry_remediation] + findings, remediation, _ = _entry_findings(plan, source, local_remote_dir) if not findings: return # `FINDING : ` is the one shape both functions write. @@ -5321,10 +5366,7 @@ def cmd_execute(args) -> int: local, repositories, urls = _repository_urls(args, plan, names, source) _refuse_an_unwritable_clone_url(urls["assembly"]) paths_for = _leg_paths(plan) - _refuse_what_check_finds(plan, source, args.local_remote_dir, - _execute_split_branch( - branch, local, repositories, urls, source, - _extracts_a_leg(paths_for))) + _refuse_what_check_finds(plan, source, args.local_remote_dir) seeded = seeded_legs(paths_for) _refuse_unconsented_seeding(args, plan, seeded) @@ -5332,6 +5374,11 @@ def cmd_execute(args) -> int: # whose way out is "run `execute` again" then meets one that is free. _refuse_an_occupied_work_dir(work_root, names, seeded) _refuse_a_split_branch_left_behind(source, urls, branch, local) + # Then a branch in git's way of the split branch, which no run of this + # tool leaves behind (#320). + _refuse_a_branch_in_the_split_branchs_way( + plan, source, repositories, urls, branch, local, + _extracts_a_leg(paths_for)) for line in plan.seeding_record_disagreements(seeded): print(line) topic = naming.topic_for(plan.project_id) diff --git a/tests/test_adopt_e2e.py b/tests/test_adopt_e2e.py index 8545b3c8..b75efafe 100644 --- a/tests/test_adopt_e2e.py +++ b/tests/test_adopt_e2e.py @@ -300,14 +300,8 @@ def test_a_dropped_path_is_accounted_for_as_dropped(source_repo, tmp_path): def test_re_running_into_a_live_leg_refuses(adopted, tmp_path): """There is no --force here either: re-running over a seeded leg is not - an adoption. The plan names another split branch, because the first - run's is still in the source, and the same plan is refused for it first - (#320, `tests/test_adopt_split_branch.py`).""" - plan = tmp_path / "plan.yaml" - plan.write_bytes(adopted["plan"].read_bytes().replace( - b"adopt_branch: adopt/three-repo-shape\n", - b"adopt_branch: adopt/again\n", 1)) - result = run_script(ADOPT, "execute", "--plan", str(plan), + an adoption.""" + result = run_script(ADOPT, "execute", "--plan", str(adopted["plan"]), "--yes", "--local-remote-dir", str(adopted["remotes"]), "--work-dir", str(tmp_path / "work2")) assert result.returncode == 2 @@ -315,19 +309,6 @@ def test_re_running_into_a_live_leg_refuses(adopted, tmp_path): assert "There is no --force" in result.stderr -def test_re_running_over_the_first_runs_split_branch_refuses(adopted, - tmp_path): - """The same plan again is refused before `leg-remote-exists`: the first - run's split branch is still in the source, where this run pushes.""" - result = run_script(ADOPT, "execute", "--plan", str(adopted["plan"]), - "--yes", "--local-remote-dir", str(adopted["remotes"]), - "--work-dir", str(tmp_path / "work2")) - assert result.returncode == 2 - assert "REFUSED plan-split-branch-taken: " in result.stderr - assert "REFUSED leg-remote-exists" not in result.stderr - assert "creating the leg repositories" not in result.stdout - - def test_the_tool_never_touches_the_source_working_tree(adopted): """`plan` and `check` READ. Nothing here dirties the repository it reads.""" status = git("status", "--porcelain", cwd=adopted["source"]).stdout diff --git a/tests/test_adopt_split_branch.py b/tests/test_adopt_split_branch.py index c62214da..e90a938d 100644 --- a/tests/test_adopt_split_branch.py +++ b/tests/test_adopt_split_branch.py @@ -4,19 +4,19 @@ `execute` pushes its one split commit to the plan's `adopt_branch:` (`adopt/three-repo-shape`) in the repository the split goes to: the source itself under `--local-remote-dir`, the assembly repository on GitHub -otherwise. An earlier run leaves that branch behind, verified or failed after -the push, and nothing looked for it. On main at 71b58a1 the same plan re-run -into fresh legs passed `check` as `plan ok`, made and pushed both legs, and -was refused at the push of the split as a non-fast-forward; a source branch -`adopt`, which git cannot hold beside `adopt/three-repo-shape`, failed the -run after both legs were made as well. Over a source whose tip `.gitmodules` -git's submodule reader cannot read, #226's history scan refused the re-run, -but named a commit of the old split branch that the extraction never -exports. - -Now a branch at the split branch's name, or in git's way of it, is `check`'s -finding `plan-split-branch-taken`, and `execute` refuses it before any leg -exists, first among `check`'s findings. +otherwise, and extracts each leg from a clone of the source with that branch +checked out. On main at ec4a7a0 `check` passed as `plan ok` a plan whose +split branch an earlier run had left there, which `execute` refuses as +`adopt-branch-exists` before any leg exists (#188 N1), and one whose split +branch git cannot hold beside a branch already there or in the source +(`adopt`, `adopt/three-repo-shape/old`), which `execute` met only after both +legs were made: at `git filter-repo`, or at the push of the split. + +Now `check` reports the first under `execute`'s own code, +`adopt-branch-exists`, by `execute`'s rule, so a branch the push +fast-forwards is no finding; and the second as `plan-split-branch-taken`, +which `execute` refuses before any leg exists, after every refusal of what +an earlier run left (`tests/test_adopt_late_failure.py` holds that order). All repositories here are local, GitHub included: a run without `--local-remote-dir` is pointed at a bare repository on disk by git's own @@ -37,13 +37,14 @@ from conftest import ADOPT, FILE_PROTOCOL, GITMODULES, as_human, findings, \ git, make_source_repo, planned, put_gitmodules, refused_before_any_leg, \ - run_script + run_script, spelled needs_filter_repo = pytest.mark.skipif( shutil.which("git-filter-repo") is None, reason="git filter-repo is not installed: `pip install git-filter-repo`") TAKEN = "plan-split-branch-taken" +BRANCH_EXISTS = "adopt-branch-exists" UNREADABLE = "plan-gitmodules-unreadable" SPLIT_BRANCH = "adopt/three-repo-shape" PROJECT, ORG = "Northwind", "testorg" @@ -126,22 +127,28 @@ def proceeded(*args, **kwargs): # --- what `check` finds, and `execute` refuses, in the source --------------- -def test_check_finds_an_earlier_split_branch_in_the_source(adopter, - tmp_path): - """On main this was `plan ok`, and `execute` made both legs before the - push of the split was refused as a non-fast-forward.""" +def left_behind(where: str, run: str, at: str, source_commit: str) -> str: + """`check`'s `adopt-branch-exists` finding: `where` has the split branch + at `at`, which `run` pushes to, as `execute`'s refusal says it.""" + return (f"{where} already has `{SPLIT_BRANCH}`, the branch {run} pushes " + f"its split commit to, at {at[:12]}: neither the source commit " + f"{source_commit[:12]} nor a commit behind it. The split commit " + "is made on top of the source commit, and git moves a branch to " + "it without force only from that commit or one behind it, so " + "`execute` refuses that run before it creates a leg, as " + f"`{BRANCH_EXISTS}`. Delete `{SPLIT_BRANCH}` there, or set " + "`adopt_branch:` in the plan to a name no branch has there.") + + +def test_check_finds_an_earlier_split_branch_in_the_source(tmp_path): + """On main this was `plan ok`, and `execute` refused it as + `adopt-branch-exists`: `check` now says so first, under that code.""" source = small_source(tmp_path) old = split_branch_by_hand(source) found = findings(plan_for(source, tmp_path)) - assert found == [(TAKEN, ( - f"the source {source} already holds the plan's split branch " - f"`{SPLIT_BRANCH}`, at {old[:12]}. `execute --local-remote-dir` " - f"pushes its split commit to `{SPLIT_BRANCH}` there, after both leg " - "repositories were created and pushed, and as a new branch: a " - "branch already there refuses that push unless the split " - "fast-forwards it, and then the split lands on a branch this " - f"adoption did not make. Delete `{SPLIT_BRANCH}` there, or set " - "`adopt_branch:` in the plan to a branch git can create there."))] + head = git("rev-parse", "main", cwd=source).stdout.strip() + assert found == [(BRANCH_EXISTS, left_behind( + f"the source {source}", "`execute --local-remote-dir`", old, head))] def test_check_says_how_to_go_on_once_after_its_findings(adopter, tmp_path): @@ -153,40 +160,52 @@ def test_check_says_how_to_go_on_once_after_its_findings(adopter, tmp_path): assert result.stderr.count(adopter.SPLIT_BRANCH_REMEDIATION) == 1 -def test_execute_refuses_it_before_any_leg_exists(adopter, tmp_path, - monkeypatch): - """`check`'s finding, raised by `_refuse_what_check_finds` before - `_create_leg_remotes`: the directory the legs go in is never made, and - the source's branch is where it was.""" +def test_execute_refuses_it_under_the_same_code_before_any_leg_exists( + adopter, tmp_path, monkeypatch): + """One situation, one code: `execute` refuses the branch `check` names + as `adopt-branch-exists`, `_refuse_a_split_branch_left_behind`'s own + refusal, before `_create_leg_remotes`. The directory the legs go in is + never made, and the source's branch is where it was.""" source = small_source(tmp_path) old = split_branch_by_hand(source) - refusal = refused_before_any_leg(adopter, monkeypatch, - plan_for(source, tmp_path), tmp_path) - assert refusal.code == TAKEN - assert refusal.remediation.endswith( - "\n" + adopter.SPLIT_BRANCH_REMEDIATION) + plan = plan_for(source, tmp_path) + assert [code for code, _ in findings(plan)] == [BRANCH_EXISTS] + stop_at_the_legs(adopter, monkeypatch) + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(execute_args(plan, tmp_path)) + assert caught.value.code == BRANCH_EXISTS + assert caught.value.detail.startswith( + f"{spelled(source)} already has `{SPLIT_BRANCH}`, the branch this " + f"adoption pushes its split commit to, at {old[:12]}: ") + assert not (tmp_path / REMOTES).exists() assert git("rev-parse", SPLIT_BRANCH, cwd=source).stdout.strip() == old -def test_a_branch_the_split_would_fast_forward_is_taken_too(adopter, - tmp_path, - monkeypatch): - """At the plan's own commit, the split fast-forwards it, and on main it - was pushed onto that branch and verified: a branch this adoption did - not make. It is refused like any other.""" +@pytest.mark.parametrize("behind", [0, 1], + ids=["at-the-source-commit", "behind-it"]) +def test_a_branch_the_split_push_fast_forwards_is_no_finding( + adopter, tmp_path, monkeypatch, behind): + """`execute` pushes the split over a branch at the source commit or + behind it, and verifies (#188's review, N1), so `check` names none: + the two commands agree, and `execute` goes on to the legs.""" source = small_source(tmp_path) git("branch", SPLIT_BRANCH, cwd=source) + for count in range(behind): + (source / "README.md").write_text(f"# Thing {count}\n", + encoding="utf-8") + as_human("commit", "-qam", f"After the branch, {count}", cwd=source) plan = plan_for(source, tmp_path) - assert [code for code, _ in findings(plan)] == [TAKEN] - refusal = refused_before_any_leg(adopter, monkeypatch, plan, tmp_path) - assert refusal.code == TAKEN + assert findings(plan) == [] + stop_at_the_legs(adopter, monkeypatch) + with pytest.raises(Proceeded): + adopter.cmd_execute(execute_args(plan, tmp_path)) #: Branch names git cannot hold beside the split branch: one whose name is -#: a directory of it, and one under it. On main each passed `check`, and -#: `execute` failed after both legs were made -- for `adopt`, silently, at -#: the `git filter-repo` that could not create `refs/heads/adopt` beside the -#: split branch `_extract_leg` checks out. +#: a directory of it, and one under it. On main at ec4a7a0 each passed +#: `check`, and `execute` failed after both legs were made, at the `git +#: filter-repo` that could not create the branch beside the split branch +#: `_extract_leg` checks out. IN_THE_WAY = ["adopt", f"{SPLIT_BRANCH}/old"] #: Names that only look like it, which git holds beside it. BESIDE_IT = [f"{SPLIT_BRANCH}-old", "adopt/other", "adopt-three-repo-shape", @@ -216,7 +235,8 @@ def test_a_branch_in_gits_way_of_the_split_branch_is_taken(adopter, """Said as the extraction's failure, for both runs: the review of #320 measured a run without `--local-remote-dir` failing there too, after both GitHub leg repositories were made, while this finding said it was - about `execute --local-remote-dir` alone.""" + about `execute --local-remote-dir` alone. `execute` refuses it with + `check`'s finding, word for word, before any leg exists.""" source = small_source(tmp_path) git("branch", name, cwd=source) at = git("rev-parse", name, cwd=source).stdout.strip() @@ -224,6 +244,7 @@ def test_a_branch_in_gits_way_of_the_split_branch_is_taken(adopter, assert findings(plan) == [(TAKEN, in_the_extraction(source, name, at))] refusal = refused_before_any_leg(adopter, monkeypatch, plan, tmp_path) assert refusal.code == TAKEN + assert refusal.remediation == adopter.SPLIT_BRANCH_TAKEN_REMEDIATION @pytest.mark.parametrize("name", BESIDE_IT) @@ -254,7 +275,44 @@ def test_each_branch_in_the_way_is_named(tmp_path): assert f"holds the branch `{SPLIT_BRANCH}/older`" in found[1][1] -# --- #226's history scan, with this finding first -------------------------- +# --- after every refusal of what an earlier run left ----------------------- + +def test_an_occupied_work_dir_is_refused_before_a_branch_in_the_way( + adopter, tmp_path, monkeypatch): + """`adopt-work-dir-occupied` is the first refusal of what an earlier + run left (`tests/test_adopt_late_failure.py`), so every later refusal's + way out meets a free work directory; a branch in the way comes after + it, as `adopt-branch-exists` does.""" + source = small_source(tmp_path) + git("branch", "adopt", cwd=source) + plan = plan_for(source, tmp_path) + assert [code for code, _ in findings(plan)] == [TAKEN] + (tmp_path / WORK / f"{PROJECT}-spec").mkdir(parents=True) + (tmp_path / WORK / f"{PROJECT}-spec" / "left.txt").write_text( + "an earlier run's\n", encoding="utf-8") + stop_at_the_legs(adopter, monkeypatch) + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(execute_args(plan, tmp_path)) + assert caught.value.code == "adopt-work-dir-occupied" + + +def test_a_live_leg_is_refused_before_a_branch_in_the_way(adopter, tmp_path, + monkeypatch): + """A leg repository still there under `--local-remote-dir` keeps its + own refusal, `leg-remote-exists`, over a branch in the way, as it does + over the split branch an earlier run left.""" + source = small_source(tmp_path) + git("branch", "adopt", cwd=source) + plan = plan_for(source, tmp_path) + (tmp_path / REMOTES / f"{PROJECT}-spec.git").mkdir(parents=True) + monkeypatch.setattr(adopter, "_require_filter_repo", lambda: None) + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(execute_args(plan, tmp_path)) + assert caught.value.code == "leg-remote-exists" + assert not (tmp_path / REMOTES / f"{PROJECT}-code.git").exists() + + +# --- #226's history scan, with the branch named first --------------------- def issue_source(base: Path) -> tuple[Path, str]: """The issue's second shape: a tip `.gitmodules` with a key with no @@ -273,20 +331,28 @@ def scan_names(commit: str) -> str: return f"where commit {commit[:12]} adds `code`" -def test_the_branch_is_named_before_the_history_it_adds(adopter, tmp_path, - monkeypatch): - """On main `check` and `execute` named only the history scan's finding, - for a commit the extraction never exports while the plan names the - branch that holds it. The branch is now the first finding, and the code - of `execute`'s refusal.""" +def test_check_names_the_branch_before_the_history_it_adds(adopter, tmp_path, + monkeypatch): + """On main `check` named only the history scan's finding, for a commit + the extraction never exports while the plan names the branch that holds + it. `check` now names the branch first, and its remediation says the + scan's finding stands until that branch is deleted. `execute` refuses + as on main: the scan's finding, in `_refuse_what_check_finds`, which + runs before `_refuse_a_split_branch_left_behind` asks for the branch.""" source, old = issue_source(tmp_path) plan = plan_for(source, tmp_path, {GITMODULES: "code"}) found = findings(plan) - assert [code for code, _ in found] == [TAKEN, UNREADABLE], found + assert [code for code, _ in found] == [BRANCH_EXISTS, UNREADABLE], found assert f"at {old[:12]}" in found[0][1] assert scan_names(old) in found[1][1] - refusal = refused_before_any_leg(adopter, monkeypatch, plan, tmp_path) - assert refusal.code == TAKEN + assert ("a finding that names one of its commits stands until that " + "branch is deleted") in adopter.SPLIT_BRANCH_REMEDIATION + stop_at_the_legs(adopter, monkeypatch) + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(execute_args(plan, tmp_path)) + assert caught.value.code == UNREADABLE + assert scan_names(old) in caught.value.detail + assert not (tmp_path / REMOTES).exists() def test_with_the_branch_deleted_nothing_names_its_history(tmp_path): @@ -363,15 +429,22 @@ def test_the_extraction_stops_at_a_source_branch_in_its_way(adopter, # --- the assembly repository on GitHub -------------------------------------- def github_repository(tmp_path: Path, branches=(SPLIT_BRANCH,)) -> Path: - """A bare `testorg/Northwind` under `tmp_path/github`, holding `main` - and `branches`, each at a commit of another repository's `main`.""" + """A bare `testorg/Northwind` under `tmp_path/github`, holding another + repository's `main`, and `branches` at a commit made on top of it, as an + earlier run's split commit is. Not at that `main`: a source made in the + same second has the same tree, identity and time, so the same commit, + and the split push would fast-forward a branch at it.""" bare = tmp_path / "github" / ORG / f"{PROJECT}.git" bare.mkdir(parents=True) seed = small_source(tmp_path / "seed") + split = as_human("commit-tree", "main^{tree}", "-p", "main", "-m", + "An earlier split", cwd=seed).stdout.strip() git("init", "-q", "--bare", "-b", "main", str(bare), cwd=tmp_path) - for name in ("main", *branches): - git(*FILE_PROTOCOL, "push", "-q", str(bare), f"main:refs/heads/{name}", - cwd=seed) + git(*FILE_PROTOCOL, "push", "-q", str(bare), "main:refs/heads/main", + cwd=seed) + for name in branches: + git(*FILE_PROTOCOL, "push", "-q", str(bare), + f"{split}:refs/heads/{name}", cwd=seed) return bare @@ -414,7 +487,8 @@ def run(args, cwd=None, capture=True): def test_execute_asks_github_where_it_pushes_without_local_remotes( adopter, tmp_path, monkeypatch): """The source holds no such branch; the repository the split is pushed - to does, and `execute` asks it, before `gh repo create`.""" + to does, and `execute` asks it, before `gh repo create`, as + `adopt-branch-exists`.""" source = small_source(tmp_path) plan = plan_for(source, tmp_path) bare = a_github(tmp_path, monkeypatch) @@ -422,11 +496,11 @@ def test_execute_asks_github_where_it_pushes_without_local_remotes( asked = no_gh(adopter, monkeypatch) with pytest.raises(adopter.Refusal) as caught: adopter.cmd_execute(execute_args(plan, tmp_path, local=False)) - assert caught.value.code == TAKEN - assert (f" FINDING {TAKEN}: `{ORG}/{PROJECT}` on GitHub already holds " - f"the plan's split branch `{SPLIT_BRANCH}`, at {at[:12]}. " - "`execute` without `--local-remote-dir` pushes its split commit " - f"to `{SPLIT_BRANCH}` there") in caught.value.detail + assert caught.value.code == BRANCH_EXISTS + assert caught.value.detail.startswith( + f"`https://github.com/{ORG}/{PROJECT}.git` already has " + f"`{SPLIT_BRANCH}`, the branch this adoption pushes its split commit " + f"to, at {at[:12]}: ") assert asked == [] @@ -444,19 +518,17 @@ def test_execute_goes_on_to_the_legs_when_github_has_no_such_branch( def test_execute_refuses_a_repository_it_cannot_list(adopter, tmp_path, monkeypatch): """No `testorg/Northwind` where git is pointed: the push of the split - would fail there after both legs were made, so nothing is made.""" + would fail there after both legs were made, so nothing is made. The + first to list it is `_refuse_a_split_branch_left_behind`, as on main, + and its `git ls-remote` fails the run before `gh repo create`.""" source = small_source(tmp_path) plan = plan_for(source, tmp_path) github_at(tmp_path / "nowhere", monkeypatch) asked = no_gh(adopter, monkeypatch) - with pytest.raises(adopter.Refusal) as caught: + with pytest.raises(adopter.CommandFailed) as caught: adopter.cmd_execute(execute_args(plan, tmp_path, local=False)) - assert caught.value.code == adopter.SPLIT_TARGET_UNREADABLE - assert caught.value.detail.startswith( - f"could not list the branches of https://github.com/{ORG}/{PROJECT}" - f".git, where `execute` pushes the split, to see whether " - f"`{SPLIT_BRANCH}` is new there: `git ls-remote` exited ") - assert "no leg repository was created" in caught.value.remediation + assert caught.value.args_list[:2] == ["git", "ls-remote"] + assert f"https://github.com/{ORG}/{PROJECT}.git" in caught.value.args_list assert asked == [] @@ -509,6 +581,22 @@ def test_a_github_run_is_refused_for_a_source_branch_in_the_extractions_way( assert asked == [] +def test_the_split_branch_left_behind_is_refused_before_a_branch_in_the_way( + adopter, tmp_path, monkeypatch): + """GitHub holds an earlier run's split branch and the source holds + `adopt`: `_refuse_a_split_branch_left_behind` asks first, as on main, + and the run is refused as `adopt-branch-exists`.""" + source = small_source(tmp_path) + git("branch", "adopt", cwd=source) + plan = plan_for(source, tmp_path) + a_github(tmp_path, monkeypatch) + asked = no_gh(adopter, monkeypatch) + with pytest.raises(adopter.Refusal) as caught: + adopter.cmd_execute(execute_args(plan, tmp_path, local=False)) + assert caught.value.code == BRANCH_EXISTS + assert asked == [] + + def test_a_github_run_is_not_refused_for_the_split_branch_in_the_source( adopter, tmp_path, monkeypatch): """The split branch's own name in the source stops only the run that @@ -569,18 +657,12 @@ def test_check_reads_github_as_the_sources_origin_last_fetched_it( source = small_source(tmp_path) with_origin(source, bare) at = git("rev-parse", f"origin/{SPLIT_BRANCH}", cwd=source).stdout.strip() + head = git("rev-parse", "main", cwd=source).stdout.strip() plan = plan_for(source, tmp_path) found = findings(plan) - assert found == [(TAKEN, ( + assert found == [(BRANCH_EXISTS, left_behind( f"`{ORG}/{PROJECT}` on GitHub, as the source's `origin` last fetched " - f"it, already holds the plan's split branch `{SPLIT_BRANCH}`, at " - f"{at[:12]}. `execute` without `--local-remote-dir` pushes its split " - f"commit to `{SPLIT_BRANCH}` there, after both leg repositories were " - "created and pushed, and as a new branch: a branch already there " - "refuses that push unless the split fast-forwards it, and then the " - "split lands on a branch this adoption did not make. Delete " - f"`{SPLIT_BRANCH}` there, or set `adopt_branch:` in the plan to a " - "branch git can create there."))] + "it,", "`execute` without `--local-remote-dir`", at, head))] stop_at_the_legs(adopter, monkeypatch) with pytest.raises(Proceeded): adopter.cmd_execute(execute_args(plan, tmp_path)) @@ -609,25 +691,39 @@ def test_a_branch_gone_from_github_leaves_check_once_the_source_prunes( source = small_source(tmp_path) with_origin(source, bare) plan = plan_for(source, tmp_path) - assert [code for code, _ in findings(plan)] == [TAKEN] + assert [code for code, _ in findings(plan)] == [BRANCH_EXISTS] git("update-ref", "-d", f"refs/heads/{SPLIT_BRANCH}", cwd=bare) - assert [code for code, _ in findings(plan)] == [TAKEN] + assert [code for code, _ in findings(plan)] == [BRANCH_EXISTS] git(*FILE_PROTOCOL, "fetch", "-q", "--prune", "origin", cwd=source) assert findings(plan) == [] assert ("once `git fetch --prune origin` in the source finds it gone " "there") in adopter.SPLIT_BRANCH_REMEDIATION +def test_a_fetched_branch_the_split_push_fast_forwards_is_no_finding( + tmp_path): + """GitHub's split branch at the source commit, as the source last + fetched it: `execute` without `--local-remote-dir` pushes over it and + verifies, so `check` names nothing.""" + bare = github_repository(tmp_path, branches=()) + source = small_source(tmp_path) + git(*FILE_PROTOCOL, "push", "-q", str(bare), + f"main:refs/heads/{SPLIT_BRANCH}", cwd=source) + with_origin(source, bare) + assert findings(plan_for(source, tmp_path)) == [] + + # --- end to end ------------------------------------------------------------- @needs_filter_repo def test_the_issue_re_run_is_refused_and_runs_once_the_branch_is_gone( tmp_path): - """#320 as measured on main: a verified run, then the same plan re-run - into fresh legs, which passed `check` and died at the push of the split - after both legs were made. Now `check` names the branch and the first - run's split commit, `execute` refuses before any leg exists, and once - the branch is deleted the same command adopts.""" + """#320 as measured on main at 71b58a1: a verified run, then the same + plan re-run into fresh legs, which passed `check` and died at the push + of the split after both legs were made. `execute` now refuses it before + any leg exists as `adopt-branch-exists` (#188 N1); `check` names the + branch and the first run's split commit under that code, and once the + branch is deleted the same command adopts.""" source = small_source(tmp_path) plan = plan_for(source, tmp_path) flags = ("--plan", str(plan), "--yes") @@ -639,15 +735,16 @@ def test_the_issue_re_run_is_refused_and_runs_once_the_branch_is_gone( checked = run_script(ADOPT, "check", "--plan", str(plan)) assert checked.returncode == 1, checked.stderr + checked.stdout - assert (f"FINDING {TAKEN}: the source {source} already holds the plan's " - f"split branch `{SPLIT_BRANCH}`, at {split[:12]}.") \ + assert (f"FINDING {BRANCH_EXISTS}: the source {source} already has " + f"`{SPLIT_BRANCH}`, the branch `execute --local-remote-dir` " + f"pushes its split commit to, at {split[:12]}: ") \ in checked.stderr remotes = tmp_path / REMOTES again = ("--local-remote-dir", str(remotes), "--work-dir", str(tmp_path / WORK)) refused = run_script(ADOPT, "execute", *flags, *again) assert refused.returncode == 2, refused.stderr + refused.stdout - assert f"REFUSED {TAKEN}: 1 finding(s) in " in refused.stderr + assert f"REFUSED {BRANCH_EXISTS}: " in refused.stderr assert "creating the leg repositories" not in refused.stdout assert not remotes.exists() assert git("rev-parse", SPLIT_BRANCH, cwd=source).stdout.strip() == split @@ -657,3 +754,35 @@ def test_the_issue_re_run_is_refused_and_runs_once_the_branch_is_gone( adopted = run_script(ADOPT, "execute", *flags, *again) assert adopted.returncode == 0, adopted.stderr + adopted.stdout assert "adoption verified" in adopted.stdout + + +@needs_filter_repo +def test_a_source_branch_in_the_way_is_refused_and_runs_once_it_is_gone( + tmp_path): + """A source branch `adopt`: on main at ec4a7a0 `check` said `plan ok` + and `execute` made both legs and exited 2 at `git filter-repo`. Now + `check` names the branch, `execute` refuses before any leg exists, and + once the branch is deleted the same command adopts.""" + source = small_source(tmp_path) + git("branch", "adopt", cwd=source) + plan = plan_for(source, tmp_path) + flags = ("--plan", str(plan), "--yes") + + checked = run_script(ADOPT, "check", "--plan", str(plan)) + assert checked.returncode == 1, checked.stderr + checked.stdout + assert (f"FINDING {TAKEN}: the source {source} holds the branch " + "`adopt`, at ") in checked.stderr + remotes = tmp_path / REMOTES + again = ("--local-remote-dir", str(remotes), "--work-dir", + str(tmp_path / WORK)) + refused = run_script(ADOPT, "execute", *flags, *again) + assert refused.returncode == 2, refused.stderr + refused.stdout + assert f"REFUSED {TAKEN}: 1 finding(s) in " in refused.stderr + assert "creating the leg repositories" not in refused.stdout + assert not remotes.exists() + + git("branch", "-q", "-D", "adopt", cwd=source) + assert run_script(ADOPT, "check", "--plan", str(plan)).returncode == 0 + adopted = run_script(ADOPT, "execute", *flags, *again) + assert adopted.returncode == 0, adopted.stderr + adopted.stdout + assert "adoption verified" in adopted.stdout