diff --git a/adopt-project.py b/adopt-project.py index d9088f4a..4d6072f5 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -1811,27 +1811,33 @@ def __init__(self, path: Path, repository: str | None, branch: str, @classmethod def open(cls, spec: str, work_root: Path, unwritable: str | None = None, - made: list | None = None, done: str = "") -> "Source": + made: list | None = None, done: str = "", + instead: str | None = None) -> "Source": """`spec`, a `--source`, as `_source_spec` reads it: a local path, read in place, or a GitHub repository, cloned (`clone`, which takes - `unwritable`, `made` and `done`).""" + `unwritable`, `made` and `done`). `instead` is `_default_branch`'s: + the branch to read it at where its remote's default branch is no + branch of its own.""" path, repository = _source_spec(spec) if repository is not None: return cls.clone(repository, work_root, unwritable, made, - done=done) - return cls.in_place(path, work_root) + done=done, instead=instead) + return cls.in_place(path, work_root, instead) @classmethod - def in_place(cls, path: Path, work_root: Path) -> "Source": + def in_place(cls, path: Path, work_root: Path, + instead: str | None = None) -> "Source": """The repository at `path`, read where it is, named by its `origin`.""" path = path.expanduser().resolve() - return cls._at(path, _remote_repository(path), work_root, False) + return cls._at(path, _remote_repository(path), work_root, False, + instead) @classmethod def clone(cls, repository: str, work_root: Path, unwritable: str | None = None, made: list | None = None, *, - note: str = "", taken: str = "", done: str = "") -> "Source": + note: str = "", taken: str = "", done: str = "", + instead: str | None = None) -> "Source": """`repository`, an `owner/repo` `_github_name` gave, cloned with `gh repo clone` into `/source-readonly/`, and read there. Every clone of a source goes through here. @@ -1877,14 +1883,18 @@ def clone(cls, repository: str, work_root: Path, f"could not clone {repository}: {exc.output.strip()[:400]}", "Remediation: check `gh auth status` and the repository " "name, or clone it yourself and pass the path.") from exc - return cls._at(path, repository, work_root, True) + return cls._at(path, repository, work_root, True, instead) @classmethod def _at(cls, path: Path, repository: str | None, work_root: Path, - cloned: bool) -> "Source": - branch = _default_branch(path) - # The name as the bytes git keeps it, under any locale (#653). - commit = _git_out_exact(["rev-parse", branch], path).lower() + cloned: bool, instead: str | None = None) -> "Source": + branch = _default_branch(path, instead) + # The branch's own commit, read by its full name and nothing else + # (#797): `git rev-parse refs/heads/` looks further, and a tag + # named `refs/heads/` answers it. As the bytes git keeps the name, + # under any locale (#653). + commit = _git_out_exact(["show-ref", "--verify", "--hash", + f"{BRANCH_REFS}{branch}"], path).lower() return cls(path, repository, branch, commit, work_root, cloned) def tree(self) -> list[tuple[str, str, str, int]]: @@ -2010,8 +2020,36 @@ def _origin_url(path: Path) -> str | None: return None -def _default_branch(path: Path) -> str: - """The branch the adoption reads, in the order a reader would guess it. +#: Where git keeps the default branch of the source's remote `origin`, as +#: `git clone` and `git remote set-head` record it. +ORIGIN_HEAD = "refs/remotes/origin/HEAD" +#: How `Source.open` refuses a source whose default branch is no branch of +#: its own, and the way through it, in `plan`, `check` and `execute` alike +#: (#797). +DEFAULT_BRANCH_NOT_LOCAL = "source-default-branch-not-local" +#: How each of its details ends, after "the source". +NOT_IN_ITS_PLACE = ("is read at a branch of its own, never at another " + "nobody named in its place") + + +def _default_branch(path: Path, instead: str | None = None) -> str: + """The branch the adoption reads, in the order a reader would guess it: + the default branch of the source's remote `origin`, as its `origin/HEAD` + names it; else the branch its HEAD is on; else `main`. The name of a + branch of the source's own, which `Source._at` reads by its full name. + + BY FULL NAME, AND WHOLE (#797). `git symbolic-ref --short` gave a name + with `origin/` to take off, and it was taken off HEAD's too, so a HEAD + on a branch of the source's own named `origin/x` read `x`; a tag named + like the branch made HEAD's short name `heads/main`, which `plan` + recorded as the tracking branch; and the name `origin/HEAD` gave, + `main`, was read with `git rev-parse main`, which a tag `main` answers + first. The name was also stripped once decoded, which takes U+00A0 off + `main`, and the branch `main` beside it was read. So the full + name is read (`_symbolic_ref`), and only its own prefix comes off. An + `origin/HEAD` set by hand to a ref outside `refs/remotes/origin/` + names no branch of `origin`: it is passed over for HEAD's branch, in a + NOTE that names it. A BYTE THAT IS NOT UTF-8 IS KEPT, AS `sys.argv` KEEPS ONE (#425). git keeps a branch's name as the bytes it was given: `git branch caf` is @@ -2024,17 +2062,133 @@ def _default_branch(path: Path) -> str: `check` and `execute` name, as `plan-tracking-branch-missing`, when the plan tracks one the source no longer has. Where the plan tracks a branch that is text, the source is read at this one and adopted. - """ - for args in (["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], - ["symbolic-ref", "--short", "HEAD"]): - try: - name = git_out(args, cwd=path, binary=True).decode( - "utf-8", "surrogateescape").strip() - except Refusal: - continue - if name: - return name.split("/", 1)[1] if name.startswith("origin/") else name - return "main" + + ONLY A BRANCH OF THE SOURCE'S OWN IS EVER READ (#797). A clone made + with `git clone -b dev`, or one whose `main` was deleted, has + `origin/HEAD` naming `main` and no `main` of its own, and `git rev-parse + main` ended `plan`, `check` and `execute` as `git-failed`, in git's + words, whatever `--tracking-branch` said. A detached HEAD's guessed + `main` and an orphan HEAD's branch with no commit yet can be no branch + either, and `git rev-parse refs/heads/` then looks further, so a tag + named `refs/heads/main` answered for it. Such a source is read at + `instead` where that is a branch of its own: `plan`'s + `--tracking-branch`, or the branch a plan records reading it at, for + `check` and `execute`. Otherwise it is refused by name, with the ways + through (`_no_branch_of_its_own`), and never read at HEAD's branch or + any other in its place.""" + named = _symbolic_ref(path, ORIGIN_HEAD) + origins = _branch_under(named, ORIGIN_BRANCHES) + if named is not None and origins is None: + print(f"NOTE the source's `origin/HEAD` names {_spelled(named)}, " + "which is no branch of its remote `origin`, so it is passed " + "over, and the branch its HEAD is on is read instead.") + head = None if origins else _branch_under(_symbolic_ref(path, "HEAD"), + BRANCH_REFS) + branch = origins or head or "main" + branches = _branch_names(path, BRANCH_REFS) + if branch in branches: + return branch + if instead in branches: + return instead + if origins: + raise _no_branch_of_its_own(path, branch, "origin", instead, branches) + raise _no_branch_of_its_own(path, branch, "head" if head else None, + instead, branches) + + +def _no_branch_of_its_own(path: Path, branch: str, where: str | None, + instead: str | None, branches: list) -> Refusal: + """`source-default-branch-not-local`: the source's default branch, + `branch`, is no branch of its own, nor is `instead`. `where` is what + named it: `origin` (its `origin/HEAD`), `head` (HEAD, on a branch with + no commit yet), or None (no `origin/HEAD` and a detached HEAD, so the + `main` this tool reads then). Each way through is said: the branch + made, or HEAD moved, and `--tracking-branch`.""" + nor = "" if instead in (None, branch) else \ + f", nor one named {_spelled(instead)}" + held = f"{nor} (its branches: {_branches_named(branches)})" + if where == "origin": + detail = (f"the source's `origin/HEAD` names {_spelled(branch)}, the " + "default branch its remote `origin` had when it was last " + f"asked, and the source has no branch {_spelled(branch)} of " + f"its own{held}. A clone made with `git clone -b ` " + "has none, nor does one whose own was deleted, and the " + f"source {NOT_IN_ITS_PLACE}") + first = _from_the_remote(path, branch) + else: + named = (f"its HEAD is on {_spelled(branch)}, which has no commit " + "yet, so it is no branch" if where == "head" else + "it has no `origin/HEAD` naming a branch of its remote " + "`origin`, its HEAD is detached, and it has no branch " + "`main`, which this tool reads then") + detail = f"the source names no branch of its own to read: {named}" \ + f"{held}. The source {NOT_IN_ITS_PLACE}" + first = ("Remediation: check out the branch to adopt, in the source " + "(`git switch `).") + return Refusal( + DEFAULT_BRANCH_NOT_LOCAL, detail, + f"{first} Or run `plan` with `--tracking-branch` naming a branch " + "the source has, and the source is read at that branch; `check` " + "and `execute` then read it at that branch while the source has " + f"no branch {_spelled(branch)} of its own.") + + +def _from_the_remote(path: Path, branch: str) -> str: + """The first way through for `origin/HEAD`'s `branch`: made the + source's own from its remote's, where the source has fetched that; and + where it has not, as after the remote renamed its default branch, the + remote asked again (`git remote set-head origin --auto`). A command is + printed with the name in it only where the name is printable, which a + lone surrogate never is: a name holding U+0085 or U+2028 broke the line, + and #425's `caf\\udce9` printed a command git refuses. Otherwise the name + is `_spelled` and the step said in words.""" + if branch not in _branch_names(path, ORIGIN_BRANCHES): + return ("Remediation: its remote `origin` has no branch " + f"{_spelled(branch)} either, as the source last fetched it: " + "ask `origin` for its default branch again, `git remote " + "set-head origin --auto`, run in the source.") + made = (f"`git branch {shlex.quote(branch)} " + f"{shlex.quote('origin/' + branch)}`" if branch.isprintable() + else f"a branch named {_spelled(branch)} made from `origin`'s " + "branch of that name") + return ("Remediation: make it a branch of the source's own, from its " + f"remote's: {made}, run in the source; where `origin` may have " + "changed its default branch since, run `git remote set-head " + "origin --auto` first.") + + +def _symbolic_ref(path: Path, ref: str) -> str | None: + """The full name of the ref the symbolic ref `ref` names; None where + `ref` is no symbolic ref. + + Read as bytes and decoded as #425 decodes them, with only git's line end + taken off first, from the bytes: `str.strip` after decoding took U+00A0, + which a branch's name may end in, for white space (#797).""" + try: + full = git_out(["symbolic-ref", "-q", ref], cwd=path, binary=True) + except Refusal: + return None + return full.removesuffix(b"\n").decode("utf-8", "surrogateescape") + + +def _branch_under(name: str | None, prefix: str) -> str | None: + """`name` with exactly `prefix` taken off; None where it is not a name + under `prefix`.""" + if name is not None and name.startswith(prefix) \ + and len(name) > len(prefix): + return name[len(prefix):] + return None + + +def _branch_names(path: Path, prefix: str) -> list[str]: + """The names of the source's refs under `prefix` (`BRANCH_REFS`, its own + branches; `ORIGIN_BRANCHES`, its remote's as it last fetched them), each + as `Source.tree` reads a path, so a byte that is not UTF-8 is kept + rather than refused.""" + raw = git_out(["for-each-ref", "--format=%(refname)", prefix], cwd=path, + binary=True) + return sorted(ref[len(prefix):] + for ref in map(_git_path, raw.splitlines())) # --------------------------------------------------------------------------- @@ -3663,7 +3817,7 @@ def _branches_of(source: Source) -> tuple[str, list[str]]: where they were read: its remote `origin`'s when it has fetched any, and otherwise its own. Each name is read as `Source.tree` reads a path, so a byte that is not UTF-8 is kept rather than refused.""" - raw = git_out(["for-each-ref", "--format=%(refname)", "refs/heads/", + raw = git_out(["for-each-ref", "--format=%(refname)", BRANCH_REFS, ORIGIN_BRANCHES], cwd=source.path, binary=True) refs = [_git_path(line) for line in raw.splitlines()] fetched = sorted(ref[len(ORIGIN_BRANCHES):] for ref in refs @@ -3671,8 +3825,17 @@ def _branches_of(source: Source) -> tuple[str, list[str]]: and ref != f"{ORIGIN_BRANCHES}HEAD") if fetched: return "the source's remote `origin`", fetched - return "the source", sorted(ref[len("refs/heads/"):] for ref in refs - if ref.startswith("refs/heads/")) + return "the source", sorted(ref[len(BRANCH_REFS):] for ref in refs + if ref.startswith(BRANCH_REFS)) + + +def _branches_named(branches: list[str]) -> str: + """`branches` as a finding names them: the first `BRANCHES_NAMED`, and + how many more there are; `none` where there are none.""" + named = _listed(branches[:BRANCHES_NAMED]) or "none" + if len(branches) > BRANCHES_NAMED: + named += f" and {len(branches) - BRANCHES_NAMED} more" + return named def _tracking_branch_problem(source: Source, what: str, @@ -3684,16 +3847,14 @@ def _tracking_branch_problem(source: Source, what: str, whose, branches = _branches_of(source) if tracking in branches: return None - named = _listed(branches[:BRANCHES_NAMED]) or "none" - if len(branches) > BRANCHES_NAMED: - named += f" and {len(branches) - BRANCHES_NAMED} more" return (f"{what} is {_spelled(tracking)}, which is no branch of {whose} " - f"(its branches: {named}; the source's default branch is " - f"{_spelled(source.branch)}). `execute` pushes both legs to it, " - "writes it into the root's `project.yaml` as the branch the " - "project tracks, and opens the split's pull request against it " - "once both legs and the split are pushed, where GitHub refuses a " - "base that is not a branch (`Base ref must be a branch`)") + f"(its branches: {_branches_named(branches)}; the source's " + f"default branch is {_spelled(source.branch)}). `execute` pushes " + "both legs to it, writes it into the root's `project.yaml` as the " + "branch the project tracks, and opens the split's pull request " + "against it once both legs and the split are pushed, where " + "GitHub refuses a base that is not a branch (`Base ref must be a " + "branch`)") def _tracking_branch_findings(plan: Plan, source: Source @@ -3856,6 +4017,31 @@ def _refuse_bad_id(project_id: str, project: str, given: set, remedy) +#: What `plan` says after `checked_value`'s remedy when the value refused is +#: the source's own branch, which nobody passed (#797, L1-71's first case on +#: #457). The alphabet alone said nothing of the way past it, which works. +DEFAULT_BRANCH_WAY_PAST = ( + "The source's branch can keep its name: the source is read at it as it " + "is, and only the tracking branch is held to that pattern, since " + "`execute` pushes both legs to it and opens the split's pull request " + "against it. So make a branch whose name matches at the same commit, in " + "the source with that branch checked out (`git branch main`, and `git " + "push origin main` where the source has a remote `origin`), and pass " + "it: `--tracking-branch main`.") + + +def _checked_default_branch(what: str, branch: str) -> str: + """`checked_value(what, branch)` of the source's default branch, which + `plan` takes as the tracking branch when none is passed, its refusal + saying the way past it too (`DEFAULT_BRANCH_WAY_PAST`).""" + try: + return checked_value(what, branch) + except Refusal as refused: + raise Refusal(refused.code, refused.detail, + f"{refused.remediation} {DEFAULT_BRANCH_WAY_PAST}" + ) from None + + def _checked_plan_inputs(args, source: Source, naming: NamingPolicy) -> tuple[dict, list]: """Every value the plan will RECORD, defaulted and checked before one of @@ -3886,8 +4072,9 @@ def _checked_plan_inputs(args, source: Source, # `--elected-by` is below, and not as a flag the person never passed. given = args.tracking_branch is not None tracking = _flag_named(TRACKING_KEY, {TRACKING_KEY} if given else set()) - args.tracking_branch = checked_value( - tracking, args.tracking_branch if given else source.branch) + args.tracking_branch = (checked_value(tracking, args.tracking_branch) + if given else + _checked_default_branch(tracking, source.branch)) # Then git's own question (#420): a name inside that alphabet that git # will not create (`a..b`, `~`, `x.lock`, `a/`) is refused here, in git's # words, and is not written for `check` to find it no branch of the @@ -4495,9 +4682,12 @@ def cmd_plan(args) -> int: # it (#650); a local source is read in place, and writes nothing there. # The clone goes on `args.clones`, and `_leave_the_clones` removes it # unless this run ends having written the plan that records it (#652). + # Read at `--tracking-branch` where its remote's default branch is no + # branch of its own, a `git clone -b` (#797). source = Source.open(args.source, work_root, _unwritable_work_dir(args, work_root), - getattr(args, "clones", None)) + getattr(args, "clones", None), + instead=args.tracking_branch) policy = PathPolicy.load(args.path_policy or PATH_POLICY) naming = NamingPolicy.load(NAMING_POLICY) @@ -4706,6 +4896,15 @@ def source(self) -> dict: def source_commit(self) -> str: return str(self.source.get("commit") or "") + @property + def read_at(self) -> str | None: + """The branch `plan` read the source at, `source.default_branch`, or + None where the plan records none: where the source's remote's + default branch is no branch of its own, `check` and `execute` read + it there again (`_default_branch`'s `instead`, #797).""" + branch = self.source.get("default_branch") + return None if branch is None else str(branch) + def open_source(self, override: str | None, work_root: Path, unwritable: str | None = None, made: list | None = None, done: str = "") -> Source: @@ -4721,7 +4920,8 @@ def open_source(self, override: str | None, work_root: Path, existed, said the plan's repository is cloned again and read that directory instead.""" if override: - return Source.open(override, work_root, unwritable, made, done) + return Source.open(override, work_root, unwritable, made, done, + self.read_at) local_path = self.source.get("local_path") if local_path: return self._recorded_source(str(local_path), work_root, @@ -4732,7 +4932,7 @@ def open_source(self, override: str | None, work_root: Path, f"{self.path}: `source:` names neither a local path " "nor a repository") return Source.clone(repository, work_root, unwritable, made, - done=done) + done=done, instead=self.read_at) def _recorded_repository(self) -> str | None: """The plan's `source.repository` as `_github_name` reads it, or None @@ -4785,7 +4985,7 @@ def _recorded_source(self, local_path: str, work_root: Path, there = path.exists() if self.source.get("cloned") is not True: if there: - return Source.in_place(path, work_root) + return Source.in_place(path, work_root, self.read_at) raise self._gone(local_path, "names nothing that exists", "the plan does not record it as a clone `plan` " "made (`source.cloned` is not `true`), so " @@ -4793,7 +4993,7 @@ def _recorded_source(self, local_path: str, work_root: Path, broken = _not_a_whole_clone(path, self.source_commit) if there \ else None if there and broken is None: - return Source.in_place(path, work_root) + return Source.in_place(path, work_root, self.read_at) state = (f"is a clone `plan` made that is no longer whole ({broken})" if there else "no longer exists") repository = self._recorded_repository() @@ -4811,7 +5011,7 @@ def _recorded_source(self, local_path: str, work_root: Path, "GitHub, as `plan` cloned it."), taken=(f"the plan's `source.local_path`, {_spelled(local_path)}, " f"{state}" if at_the_record and there else ""), - done=done) + done=done, instead=self.read_at) def _gone(self, local_path: str, state: str, why: str) -> Refusal: """`plan-source-gone`: the plan's `source.local_path` is in `state`, diff --git a/tests/test_adopt_branch_names.py b/tests/test_adopt_branch_names.py index 0489bea1..795260e1 100644 --- a/tests/test_adopt_branch_names.py +++ b/tests/test_adopt_branch_names.py @@ -32,20 +32,31 @@ in this process, refused before any leg; the one end-to-end test needs `git filter-repo`. Each plan is edited as bytes, so it stays LF on every platform, and a name git refuses is only ever a plan value, never a branch on disk. + +The branch `plan` reads the source at is read by its full name (#797). Its +short name took `origin/` off HEAD's branch too, so a HEAD on a branch of +the source's own named `origin/x` read `x`; a tag named like the branch made +HEAD's short name `heads/main`, which `plan` recorded as the tracking branch +and `check` found no branch of the source; and an `origin/HEAD` naming `main` +was read with `git rev-parse main`, which a tag `main` answers first. """ from __future__ import annotations import importlib.util import shutil +import sys from argparse import Namespace from pathlib import Path import pytest -from conftest import (ADOPT, findings, git, make_source_repo, +from conftest import (ADOPT, REPO, findings, git, make_source_repo, refused_before_any_leg, run_script, write_plan) +sys.path.insert(0, str(REPO / "scripts")) +from repo_shape import load_yaml # noqa: E402 + 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`") @@ -262,3 +273,140 @@ def test_a_split_branch_git_will_not_create_makes_no_leg_and_the_fix_adopts( adopted = run_script(ADOPT, "execute", *flags) assert adopted.returncode == 0, adopted.stderr + adopted.stdout assert "adoption verified" in adopted.stdout + + +# --- the branch the source is read at, by its full name (#797) -------------- + +#: Where git says which branch the source is on: its HEAD, or the default +#: branch its remote `origin` names, `refs/remotes/origin/HEAD`. +WHERE = pytest.mark.parametrize("where", ["head", "origin-head"]) +#: A branch of the source's own that is named like a remote-tracking one. +ORIGIN_X = "origin/x" + + +def planned_at(source: Path, base: Path) -> dict: + """`plan` of `source` into `base`, which must write the plan: the plan.""" + plan = base / PLAN_FILE + written = write_plan(source, plan) + assert written.returncode == 0, written.stderr + written.stdout + return load_yaml(plan) + + +def commit_of(source: Path, ref: str) -> str: + return git("rev-parse", ref, cwd=source).stdout.strip() + + +def test_a_head_on_a_branch_named_like_a_remote_ones_is_read_there(tmp_path): + """L1-69: HEAD on a branch of the source's own named `origin/x`, a + commit ahead of a branch `x`. `_default_branch` took `origin/` off + HEAD's short name as off `origin/HEAD`'s, so `plan` read the source at + `x` and recorded `x`'s commit, and said nothing. HEAD's full name loses + `refs/heads/` alone now.""" + source = make_source_repo(tmp_path / "Northwind", tree=TREE, edits=()) + git("branch", "x", cwd=source) + git("switch", "-q", "-c", ORIGIN_X, cwd=source) + git("commit", "-q", "--allow-empty", "-m", "Ahead of x", cwd=source) + + written = planned_at(source, tmp_path) + + assert written["source"]["default_branch"] == ORIGIN_X + assert written["source"]["commit"] == commit_of(source, + f"refs/heads/{ORIGIN_X}") + assert written["tracking_branch"] == ORIGIN_X + + +@WHERE +def test_a_tag_named_like_the_default_branch_shadows_nothing(tmp_path, where): + """A tag `main`, a commit behind the branch `main`. Read from HEAD, the + short name was `heads/main`, which `plan` recorded as the tracking + branch and `check` found no branch of the source; read from + `origin/HEAD`, it was `main`, and `git rev-parse main` answered with the + TAG's commit. The full name is read now, and the branch's commit.""" + source = make_source_repo(tmp_path / "Northwind", tree=TREE, edits=()) + git("tag", "main", cwd=source) + git("commit", "-q", "--allow-empty", "-m", "Past the tag", cwd=source) + if where == "origin-head": + git("update-ref", "refs/remotes/origin/main", "refs/heads/main", + cwd=source) + git("symbolic-ref", "refs/remotes/origin/HEAD", + "refs/remotes/origin/main", cwd=source) + + written = planned_at(source, tmp_path) + + assert written["source"]["default_branch"] == "main" + assert written["source"]["commit"] == commit_of(source, "refs/heads/main") + assert written["tracking_branch"] == "main" + checked = run_script(ADOPT, "check", "--plan", str(tmp_path / PLAN_FILE)) + assert checked.returncode == 0, checked.stdout + checked.stderr + + +#: How `plan` refuses a source whose default branch is no branch of its own. +NOT_LOCAL = "REFUSED source-default-branch-not-local: " +#: A ref that is no branch, named so that `git rev-parse refs/heads/` +#: finds it when the source has no branch `` (r797's first finding): +#: a tag, a remote-tracking ref of a remote named `refs/heads`, and a +#: branch whose own name is `refs/heads/`. +SHADOWS = pytest.mark.parametrize("make, shadow", [ + (("tag", "refs/heads/main", "HEAD~1"), "main"), + (("update-ref", "refs/remotes/refs/heads/main", "HEAD~1"), "main"), + (("branch", "refs/heads/new", "HEAD~1"), "new"), +], ids=["tag", "remote-ref", "branch"]) + + +@SHADOWS +def test_a_ref_named_like_a_missing_branch_is_never_read_for_it(tmp_path, + make, shadow): + """With no `origin/HEAD`, a detached HEAD has `main` guessed, and an + orphan HEAD is on `new`, which has no commit yet: neither is a branch of + the source. `git rev-parse refs/heads/` then looked further, and + `plan` read the source at the commit of the ref above, which nobody + named, and exited 0. It is refused by name now, and nothing is + written.""" + source = make_source_repo(tmp_path / "Northwind", tree=TREE, edits=(), + branch="trunk") + git("commit", "-q", "--allow-empty", "-m", "Ahead", cwd=source) + git(*make, cwd=source) + if shadow == "main": + git("checkout", "-q", "--detach", cwd=source) + else: + git("switch", "-q", "--orphan", shadow, cwd=source) + plan = tmp_path / PLAN_FILE + + result = write_plan(source, plan) + + assert result.returncode == 2, result.stdout + result.stderr + named = (f"its HEAD is on `{shadow}`, which has no commit yet, so it is " + "no branch" if shadow == "new" else "it has no `origin/HEAD` " + "naming a branch of its remote `origin`, its HEAD is detached, " + "and it has no branch `main`, which this tool reads then") + assert (f"{NOT_LOCAL}the source names no branch of its own to read: " + f"{named} (its branches: ") in result.stderr, result.stderr + assert not plan.exists() + + +def test_an_origin_head_naming_another_remotes_ref_is_named_in_a_note( + tmp_path): + """r797's seventh finding: `origin/HEAD` set by hand to + `refs/remotes/upstream/main`, which is no branch of `origin`. It is + passed over for the branch HEAD is on, as before, and now a NOTE names + it, once.""" + source = make_source_repo(tmp_path / "Northwind", tree=TREE, edits=()) + git("update-ref", "refs/remotes/upstream/main", "refs/heads/main", + cwd=source) + git("symbolic-ref", "refs/remotes/origin/HEAD", + "refs/remotes/upstream/main", cwd=source) + git("switch", "-q", "-c", "feature", cwd=source) + git("commit", "-q", "--allow-empty", "-m", "Ahead on feature", cwd=source) + plan = tmp_path / PLAN_FILE + + result = write_plan(source, plan) + + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.count( + "NOTE the source's `origin/HEAD` names `refs/remotes/upstream/main`, " + "which is no branch of its remote `origin`, so it is passed over, " + "and the branch its HEAD is on is read instead.\n") == 1, \ + result.stdout + read_at = load_yaml(plan)["source"] + assert read_at["default_branch"] == "feature" + assert read_at["commit"] == commit_of(source, "refs/heads/feature") diff --git a/tests/test_adopt_c_locale_bytes.py b/tests/test_adopt_c_locale_bytes.py index 74c5838c..164b392e 100644 --- a/tests/test_adopt_c_locale_bytes.py +++ b/tests/test_adopt_c_locale_bytes.py @@ -358,8 +358,9 @@ def on_its_own_branch(base: Path) -> Path: def its_branch_gone(base: Path) -> Path: """A clone of a repository on BRANCH, whose `origin/HEAD` still names - BRANCH after the clone's own BRANCH was deleted: `git rev-parse` of the - name `_default_branch` reads finds nothing, and git refuses it.""" + BRANCH after the clone's own BRANCH was deleted: a default branch that + is no branch of the source's own, which `_default_branch` refuses by + name (#797).""" upstream = make_source_repo(base / ORG / NORTHWIND, on_github.SOURCE_TREE, branch=BRANCH, edits=()) source = base / NORTHWIND @@ -382,9 +383,10 @@ def test_a_default_branch_outside_ascii_reads_as_in_utf_8_mode( branch (`tracked`), it is refused `unsafe-value` before any plan is written. Told `--tracking-branch main` (`main`), it writes the plan UTF-8 mode writes, byte for byte, and `check` finds `main` no branch of - the source, naming `café` as UTF-8 mode names it. Where git refuses - `café` (`gone`), the refusal is `git-failed`, naming the command as - UTF-8 mode's does.""" + the source, naming `café` as UTF-8 mode names it. Where the clone has + no `café` of its own (`gone`), the refusal is + `source-default-branch-not-local` (#797), naming it as UTF-8 mode's + does.""" c_locale_or_skip() source = make_source(tmp_path) plans, runs = [], [] diff --git a/tests/test_adopt_git_reads.py b/tests/test_adopt_git_reads.py index 3fc21136..2420d998 100644 --- a/tests/test_adopt_git_reads.py +++ b/tests/test_adopt_git_reads.py @@ -28,6 +28,10 @@ is kept as a lone surrogate, and `plan` refuses the name as `unsafe-value` for `--tracking-branch` before any plan is written; `check` and `execute` name it too, or adopt past it when the plan tracks a branch that is text. + +And only git's line end comes off it, from the bytes (#797). The name was +stripped once decoded, which takes U+00A0 off `main` too: `plan` +read the branch `main` beside it, a commit behind, and recorded that commit. """ from __future__ import annotations @@ -519,32 +523,35 @@ def test_a_utf8_clone_of_a_source_whose_path_is_not_utf8_adopts(tmp_path): LOST_REFUSAL = b"REFUSED plan-tracking-branch-missing: " -def with_default_branch(source: Path, where: str) -> Path: - """`source`, on `main`, with `BRANCH` as its default branch where git - says one: `head` is its HEAD, and `origin-head` is - `refs/remotes/origin/HEAD`, with HEAD left on `main`. `BRANCH` is one - commit AHEAD of `main`, which is still a branch of the source: a plan - that read the wrong branch would adopt the wrong commit, and a - `--tracking-branch main` names a branch it has. +def with_default_branch(source: Path, where: str, + branch: str = BRANCH) -> Path: + """`source`, on `main`, with `branch` (`BRANCH` unless given) as its + default branch where git says one: `head` is its HEAD, and + `origin-head` is `refs/remotes/origin/HEAD`, with HEAD left on `main`. + `branch` is one commit AHEAD of `main`, which is still a branch of the + source: a plan that read the wrong branch would adopt the wrong commit, + and a `--tracking-branch main` names a branch it has. - Skipped where no branch can be named with the byte 0xE9 (Windows, macOS's - APFS), or git here does not give the name back as it was written.""" + Skipped where no branch can be named so (the byte 0xE9 on Windows or + macOS's APFS), or git here does not give the name back as it was + written.""" ref = "refs/remotes/origin/HEAD" if where == ORIGIN_HEAD else "HEAD" + written = branch.encode("utf-8", "surrogateescape") try: - git("switch", "-q", "-c", BRANCH, cwd=source) + git("switch", "-q", "-c", branch, cwd=source) git("commit", "-q", "--allow-empty", "-m", "One ahead", cwd=source) if where == ORIGIN_HEAD: git("switch", "-q", "main", cwd=source) - for name in (BRANCH, "main"): + for name in (branch, "main"): git("update-ref", f"refs/remotes/origin/{name}", name, cwd=source) - git("symbolic-ref", ref, f"refs/remotes/origin/{BRANCH}", + git("symbolic-ref", ref, f"refs/remotes/origin/{branch}", cwd=source) except (AssertionError, UnicodeError, OSError) as exc: - pytest.skip(f"no branch here is named {NOT_UTF8_BRANCH!r}: {exc!r}") + pytest.skip(f"no branch here is named {written!r}: {exc!r}") read = subprocess.run(["git", "symbolic-ref", ref], cwd=str(source), capture_output=True, check=False) - if not read.stdout.rstrip(b"\r\n").endswith(b"/" + NOT_UTF8_BRANCH): + if not read.stdout.rstrip(b"\r\n").endswith(b"/" + written): pytest.skip(f"`git symbolic-ref {ref}` here is {read.stdout!r}") return source @@ -606,12 +613,14 @@ def test_execute_refuses_a_plans_branch_gone_for_one_that_is_not_utf8( assert LOST_FINDING in refused.stderr, said(refused) -def planned_past(base: Path, where: str) -> tuple[Path, dict]: +def planned_past(base: Path, where: str, + branch: str = BRANCH) -> tuple[Path, dict]: """`(source, runs)`: `plan --tracking-branch main` of a source whose - default branch is `BRANCH` (`with_default_branch`), the plan answered, + default branch is `branch` (`with_default_branch`), the plan answered, and `check`, each run there was. `main` is the branch to adopt into, so - nothing is asked of the name that is not UTF-8.""" - source = with_default_branch(make_source_repo(base / "Thing"), where) + nothing is asked of the default branch's name.""" + source = with_default_branch(make_source_repo(base / "Thing"), where, + branch) runs = {"plan": planned(base, source, TRACKING, "main")} assert runs["plan"].returncode == 0, said(runs["plan"]) answer(base) @@ -654,3 +663,96 @@ def test_execute_adopts_a_source_whose_default_branch_is_not_utf8(tmp_path, assert runs["execute"].returncode == 0, said(runs["execute"]) assert VERIFIED in runs["execute"].stdout, said(runs["execute"]) assert tip_of(source, f"{SPLIT_BRANCH}^") == tip_of(source, BRANCH) + + +# --- `git symbolic-ref`: the name whole, white space and all (#797) ---------- + +#: `main`, then U+00A0, NO-BREAK SPACE: a branch git keeps, and white space +#: to `str.strip` (L1-68 on #457). +NBSP_BRANCH = "main\u00a0" +#: `plan`'s refusal of that default branch as the tracking branch it would +#: take, as `UNSAFE_BRANCH` is of `caf`. +UNSAFE_NBSP = ( + b"REFUSED unsafe-value: --tracking-branch (not given, so the source's " + b"default branch) is 'main\\xa0', which is not a value this tool will " + b"put on a `git` or `gh` command line\n") + + +@by_where +def test_plan_refuses_a_default_branch_ending_in_white_space(tmp_path, + where): + """L1-68: `_default_branch` stripped the name it decoded, which took + the U+00A0 off `main`, so `plan` planned `main`, the branch + beside it, and said nothing. Only git's line end comes off now, and + the name is the tracking branch `plan` would take, refused as + `unsafe-value` before any plan is written.""" + source = with_default_branch(make_source_repo(tmp_path / "Thing"), where, + NBSP_BRANCH) + refused = planned(tmp_path, source) + assert_no_traceback({"plan": refused}) + assert refused.returncode == 2, said(refused) + assert UNSAFE_NBSP in refused.stderr, said(refused) + assert not (tmp_path / PLAN_FILE).exists() + + +@by_where +def test_plan_reads_a_default_branch_ending_in_white_space_whole(tmp_path, + where): + """With `--tracking-branch main`, the source is read at `main` + itself, with ITS commit, where the stripped name read `main`'s, a + commit behind; and the plan records the name as git keeps it.""" + source, runs = planned_past(tmp_path, where, NBSP_BRANCH) + assert_no_traceback(runs) + read_at = load_yaml(tmp_path / PLAN_FILE)["source"] + assert read_at["default_branch"] == NBSP_BRANCH + assert read_at["commit"] == tip_of(source, NBSP_BRANCH) + assert runs["check"].returncode == 0, said(runs["check"]) + + +# --- the way through, for a name no command line carries (#797) ------------- + +#: `ma`, U+2028 LINE SEPARATOR, `in`: a branch git keeps, and a line break +#: to `str.splitlines`, as U+0085 is. +LINE_SEPARATED = "ma\u2028in" +#: How the remediation names a branch it cannot print as a command. +FROM_ORIGINS = b"made from `origin`'s branch of that name, run in the source" + + +def remembered_on(source: Path, branch: str) -> Path: + """`source` with `origin/HEAD` naming `origin/`, which it has + fetched, and no branch `branch` of its own, as `git clone -b main` of a + repository whose default branch is `branch` leaves it. Skipped as + `with_default_branch` is, where git refuses or alters the name.""" + try: + git("update-ref", f"refs/remotes/origin/{branch}", "refs/heads/main", + cwd=source) + git("symbolic-ref", "refs/remotes/origin/HEAD", + f"refs/remotes/origin/{branch}", cwd=source) + except (AssertionError, UnicodeError, OSError) as exc: + pytest.skip(f"no ref here is named {branch!r}: {exc!r}") + return source + + +@pytest.mark.parametrize("branch, spelled", [ + (LINE_SEPARATED, b"`ma\\u2028in`"), + (BRANCH, b"`caf\\udce9`"), +], ids=["u2028", "not-utf8"]) +def test_the_way_through_spells_a_name_no_command_line_carries( + tmp_path, branch, spelled): + """r797's second finding: the remediation printed `origin/HEAD`'s + branch raw in a `git branch` command. A U+2028 in it broke the + refusal's two lines in four for `str.splitlines`, and #425's + `caf` printed `git branch 'caf\\udce9' ...`, which git + refuses. The name is spelled now, and the step said in words.""" + source = remembered_on(make_source_repo(tmp_path / "Thing"), branch) + + refused = planned(tmp_path, source) + + assert_no_traceback({"plan": refused}) + assert refused.returncode == 2, said(refused) + assert len(refused.stderr.splitlines()) == 2, said(refused) + assert len(refused.stderr.decode("utf-8").splitlines()) == 2, \ + said(refused) + assert b"a branch named " + spelled + b" " + FROM_ORIGINS in \ + refused.stderr, said(refused) + assert b"`git branch " not in refused.stderr, said(refused) diff --git a/tests/test_adopt_tracking_branch.py b/tests/test_adopt_tracking_branch.py index a8176935..286c1956 100644 --- a/tests/test_adopt_tracking_branch.py +++ b/tests/test_adopt_tracking_branch.py @@ -22,13 +22,22 @@ remote about, are in `tests/test_adopt_github_exits.py`, beside the other exits a GitHub run has. Nothing here runs `gh`: every run below is `plan` and `check`, which read a local source, or `execute --local-remote-dir`, which -refuses before any leg. +refuses before any leg or adopts. + +The branch the source is read at is one of its own (#797). A clone made with +`git clone -b dev` has `origin/HEAD` naming `main` and no `main` of its own, +and `plan`, `check` and `execute` ended `git-failed` in `git rev-parse main`, +with no remedy, whatever `--tracking-branch` said. Now that clone is refused +by name, with both ways through, and `--tracking-branch dev` reads it at +`dev`. A default branch outside the alphabet a command line may carry, such +as `café`, is refused as before, and the refusal now says the way past it. """ from __future__ import annotations import shutil import sys +from pathlib import Path import pytest @@ -277,3 +286,233 @@ def test_a_passed_branch_equal_to_the_sources_own_is_still_named_as_passed( result.stderr, result.stderr assert "not given" not in result.stderr, result.stderr assert not plan.exists() + + +# --- the source's own branch, by its full name (#797) ----------------------- + +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`") +#: How a source whose `origin/HEAD` names a branch it has no branch of its +#: own for is refused, by `plan`, `check` and `execute` alike (L1-71's second +#: case on #457). +NOT_LOCAL = "REFUSED source-default-branch-not-local: " +#: What it says of the branch `origin/HEAD` names, `main`. +NAMES_MAIN = ("the source's `origin/HEAD` names `main`, the default branch " + "its remote `origin` had when it was last asked, and the " + "source has no branch `main` of its own (its branches: `dev`)") +#: The two ways through it, as its remediation names them, and what the +#: second promises of `check` and `execute`. +MAKE_IT_LOCAL = "`git branch main origin/main`, run in the source" +OR_TRACK_ANOTHER = ("run `plan` with `--tracking-branch` naming a branch " + "the source has, and the source is read at that branch") +WHILE_IT_HAS_NONE = ("`check` and `execute` then read it at that branch " + "while the source has no branch `main` of its own.") +#: The way through for an `origin/HEAD` its remote no longer has. +ASK_AGAIN = "`git remote set-head origin --auto`" +#: What `check` says once it reads the source where `plan` did. +PLAN_OK = "\nplan ok\n" +#: The flag that names the branch to track, and the plan `write_plan` writes. +TRACKING = "--tracking-branch" +PLAN_NAME = "adoption-plan.yaml" + + +def cloned_with(tmp_path, branch: str) -> Path: + """`git clone -b ` of a repository whose default branch is + `main` and which has a `dev` a commit ahead of it: `origin/HEAD` names + `origin/main`, and `branch` is the clone's one branch.""" + seed = make_source_repo(tmp_path / "seed", tree=TREE, edits=()) + git("switch", "-q", "-c", "dev", cwd=seed) + git("commit", "-q", "--allow-empty", "-m", "Ahead on dev", cwd=seed) + git("switch", "-q", "main", cwd=seed) + remote = tmp_path / "testorg" / "Northwind.git" + git(*FILE_PROTOCOL, "clone", "-q", "--bare", str(seed), str(remote), + cwd=tmp_path) + source = tmp_path / "Northwind" + git(*FILE_PROTOCOL, "clone", "-q", "-b", branch, str(remote), str(source), + cwd=tmp_path) + return source + + +def tip(source, branch: str) -> str: + """The commit the source's own `branch` names.""" + return git("rev-parse", f"refs/heads/{branch}", cwd=source).stdout.strip() + + +def test_a_clone_of_another_branch_is_refused_by_name_with_both_ways_through( + tmp_path): + """L1-71's second case: on main, `plan` of a `git clone -b dev` source + was refused `git-failed` in `git rev-parse main`, in git's words, with + no remedy. It is refused by name now, before any plan is written, with + both ways through; and the first, `main` made the source's own, plans + the source at it.""" + source = cloned_with(tmp_path, "dev") + plan = tmp_path / PLAN_NAME + + result = write_plan(source, plan) + + assert result.returncode == 2, result.stdout + result.stderr + assert f"{NOT_LOCAL}{NAMES_MAIN}" in result.stderr, result.stderr + assert MAKE_IT_LOCAL in result.stderr, result.stderr + assert OR_TRACK_ANOTHER in result.stderr, result.stderr + assert WHILE_IT_HAS_NONE in result.stderr, result.stderr + assert "git-failed" not in result.stderr, result.stderr + assert not plan.exists() + git("branch", "main", "origin/main", cwd=source) + _, plan = planned(tmp_path / "made", source) + assert load_yaml(plan)["source"]["commit"] == tip(source, "main") + + +def test_a_clone_of_another_branch_is_read_at_the_tracking_branch_given( + tmp_path): + """The other way through, `--tracking-branch dev`, was refused as the + first run was: `Source.open` asked for `main` before anything else. Now + the source is read at `dev`, which the plan records with ITS commit, + and `check` reads it where `plan` did.""" + source = cloned_with(tmp_path, "dev") + + result, plan = planned(tmp_path, source, TRACKING, "dev") + + written = load_yaml(plan) + assert written["source"]["default_branch"] == "dev", written["source"] + assert written["source"]["commit"] == tip(source, "dev") + assert written["tracking_branch"] == "dev" + assert MISSING not in result.stdout + result.stderr + checked = check(plan) + assert checked.returncode == 0, checked.stdout + checked.stderr + assert PLAN_OK in checked.stdout + + +def test_check_of_a_plan_whose_branch_was_deleted_is_refused_by_name( + tmp_path): + """The issue's `check` row: a plan of a clone on `main`, whose `main` + is then deleted with `dev` checked out. On main `check` said + `git-failed`, `git rev-parse main`; now it refuses as `plan` does, + naming `main` once.""" + source = cloned_with(tmp_path, "main") + _, plan = planned(tmp_path, source) + git("switch", "-q", "dev", cwd=source) + git("branch", "-q", "-D", "main", cwd=source) + + result = check(plan) + + assert result.returncode == 2, result.stdout + result.stderr + assert (f"{NOT_LOCAL}{NAMES_MAIN}. A clone made with `git clone -b " + "` has none, nor does one whose own was deleted") in \ + result.stderr, result.stderr + assert MAKE_IT_LOCAL in result.stderr, result.stderr + + +def test_an_origin_head_its_remote_renamed_names_the_way_to_ask_again( + tmp_path): + """r797's third finding: a clone made while its repository's default + branch was `master`, which GitHub then renamed `main`. The clone's own + branch is renamed to match and pruned, but `origin/HEAD` still names + `master`, which `origin` no longer has. `git branch master + origin/master` cannot run there, so the refusal asks `origin` for its + default branch again instead, and that plans the source at `main`.""" + seed = make_source_repo(tmp_path / "seed", tree=TREE, edits=(), + branch="master") + remote = tmp_path / "testorg" / "Northwind.git" + git(*FILE_PROTOCOL, "clone", "-q", "--bare", str(seed), str(remote), + cwd=tmp_path) + source = tmp_path / "Northwind" + git(*FILE_PROTOCOL, "clone", "-q", str(remote), str(source), cwd=tmp_path) + git("branch", "-m", "master", "main", cwd=remote) + git("symbolic-ref", "HEAD", "refs/heads/main", cwd=remote) + git("branch", "-m", "master", "main", cwd=source) + git(*FILE_PROTOCOL, "fetch", "-q", "--prune", "origin", cwd=source) + git("branch", "-q", "-u", "origin/main", "main", cwd=source) + # As a clone made before the rename keeps it, whatever this git's fetch + # does with a remote HEAD. + git("symbolic-ref", "refs/remotes/origin/HEAD", + "refs/remotes/origin/master", cwd=source) + + result = write_plan(source, tmp_path / PLAN_NAME) + + assert result.returncode == 2, result.stdout + result.stderr + assert (f"{NOT_LOCAL}the source's `origin/HEAD` names `master`, the " + "default branch its remote `origin` had when it was last asked, " + "and the source has no branch `master` of its own (its " + "branches: `main`)") in result.stderr, result.stderr + assert ("Remediation: its remote `origin` has no branch `master` " + f"either, as the source last fetched it: ask `origin` for its " + f"default branch again, {ASK_AGAIN}, run in the source.") in \ + result.stderr, result.stderr + assert "origin/master`" not in result.stderr, result.stderr + git(*FILE_PROTOCOL, "remote", "set-head", "origin", "--auto", cwd=source) + _, plan = planned(tmp_path / "asked", source) + assert load_yaml(plan)["source"]["default_branch"] == "main" + + +@pytest.mark.parametrize("head", ["detached", "unborn"]) +def test_a_default_branch_with_no_commit_is_read_at_the_tracking_branch( + tmp_path, head): + """`--tracking-branch` needs no default branch to resolve. With no + `origin/HEAD`, a detached HEAD has `main` guessed, which this source + does not have, and an orphan HEAD is on a branch with no commit yet: + each was refused `git-failed` in `git rev-parse` of it, whatever + `--tracking-branch` named. Now the source is read at the branch given, + and `check` reads it there again.""" + source = make_source_repo(tmp_path / "Northwind", tree=TREE, edits=(), + branch="trunk") + if head == "detached": + git("checkout", "-q", "--detach", cwd=source) + else: + git("switch", "-q", "--orphan", "new", cwd=source) + + _, plan = planned(tmp_path / "plan", source, TRACKING, "trunk") + + written = load_yaml(plan) + assert written["source"]["default_branch"] == "trunk", written["source"] + assert written["source"]["commit"] == tip(source, "trunk") + checked = check(plan) + assert checked.returncode == 0, checked.stdout + checked.stderr + + +@needs_filter_repo +def test_a_clone_of_another_branch_is_adopted_at_the_tracking_branch( + tmp_path): + """`execute` of that plan reads the source where `plan` did, and the + split is made on `dev`'s commit, which is not `main`'s.""" + source = cloned_with(tmp_path, "dev") + _, plan = planned(tmp_path, source, TRACKING, "dev") + + result = run_script(ADOPT, "execute", "--plan", str(plan), "--yes", + "--local-remote-dir", str(tmp_path / "remotes"), + "--work-dir", str(tmp_path / "work")) + + assert result.returncode == 0, result.stdout + result.stderr + assert "adoption verified" in result.stdout + split = git("rev-parse", "refs/heads/adopt/three-repo-shape^", + cwd=source).stdout.strip() + assert split == tip(source, "dev") + + +def test_a_default_branch_outside_the_alphabet_names_the_way_past_it( + tmp_path): + """L1-71's first case: a source whose only branch is `café` was refused + `unsafe-value` with the alphabet alone as its remedy, though a way past + it works. The refusal says it now: a branch whose name is in the + alphabet, at the same commit, given as `--tracking-branch`. Followed, + `plan` reads the source at `café` and `check` finds nothing.""" + source = make_source_repo(tmp_path / "Northwind", tree=TREE, edits=(), + branch="café") + plan = tmp_path / PLAN_NAME + + result = write_plan(source, plan) + + assert result.returncode == 2, result.stdout + result.stderr + said = " ".join(result.stderr.split()) + assert ("So make a branch whose name matches at the same commit, in the " + "source with that branch checked out (`git branch main`, and `git " + "push origin main` where the source has a remote `origin`), and " + "pass it: `--tracking-branch main`.") in said, said + assert not plan.exists() + git("branch", "main", cwd=source) + _, plan = planned(tmp_path / "past", source, TRACKING, "main") + written = load_yaml(plan) + assert written["source"]["default_branch"] == "café", written["source"] + assert written["tracking_branch"] == "main" + checked = check(plan) + assert checked.returncode == 0, checked.stdout + checked.stderr