diff --git a/adopt-project.py b/adopt-project.py index 35c78c56..9391c680 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -1608,11 +1608,25 @@ def _origin_url(path: Path) -> str | None: def _default_branch(path: Path) -> str: - """The branch the adoption reads, in the order a reader would guess it.""" + """The branch the adoption reads, in the order a reader would guess 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 + legal, and a name typed in a Latin-1 terminal is one. Read strictly, it + ended `plan`, `check` and `execute` in a `UnicodeDecodeError` traceback + from `Source.open`, with no plan written and nothing said of the branch. + Kept as a lone surrogate, it is the tracking branch `plan` would take + (#479) and refuses as `unsafe-value`, before any plan is written, with + `--tracking-branch` as the way past it; and it is the default branch + `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) + name = git_out(args, cwd=path, binary=True).decode( + "utf-8", "surrogateescape").strip() except Refusal: continue if name: diff --git a/tests/test_adopt_git_reads.py b/tests/test_adopt_git_reads.py index 90157c35..144544e3 100644 --- a/tests/test_adopt_git_reads.py +++ b/tests/test_adopt_git_reads.py @@ -20,6 +20,14 @@ The runs are `test_adopt_template_values.py`'s: on a strict stdout, answered in bytes, and each read for a traceback. + +`_default_branch` asks `git symbolic-ref` which branch the source is on, and +git keeps that name as bytes too (#425): `git branch caf` is legal. Read +strictly, a HEAD or an `origin/HEAD` naming such a branch ended `plan`, +`check` and `execute` in the same traceback, from `Source.open`. Now the byte +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. """ from __future__ import annotations @@ -33,8 +41,8 @@ from conftest import REPO, git, make_source_repo from test_adopt_template_values import (ADOPT_REFUSED, PLAN_FILE, PLAN_OK, - PROJECT, REMOTES, VERIFIED, WORK, - adopted, answer, + PROJECT, REMOTES, SPLIT_BRANCH, + VERIFIED, WORK, adopted, answer, assert_no_traceback, assert_no_traceback_and_no_leg, checked, executed, needs_filter_repo, @@ -479,3 +487,173 @@ def test_a_utf8_clone_of_a_source_whose_path_is_not_utf8_adopts(tmp_path): assert_no_traceback(runs) assert runs["execute"].returncode == 0, said(runs["execute"]) assert VERIFIED in runs["execute"].stdout, said(runs["execute"]) + + +# --- `git symbolic-ref`: the branch the source is on (#425) ------------------ + +#: A branch git accepts and no UTF-8 text spells: `caf` and the byte 0xE9, as +#: a Latin-1 terminal types `café`. +NOT_UTF8_BRANCH = b"caf\xe9" +#: The same name as `Source.branch` holds it, and as `sys.argv` and +#: `os.environ` carry one on POSIX: the byte kept as a lone surrogate. +BRANCH = NOT_UTF8_BRANCH.decode("utf-8", "surrogateescape") +#: Where git can say that `BRANCH` is the source's default branch, in the +#: order `_default_branch` asks: `refs/remotes/origin/HEAD`, then HEAD. +ORIGIN_HEAD = "origin-head" +by_where = pytest.mark.parametrize("where", [ORIGIN_HEAD, "head"]) +#: The flag that names the branch to track, so that the name that is not +#: UTF-8 is only the one the source is read at. +TRACKING = "--tracking-branch" + +#: What `plan` says of that default branch, which it would track (#479): the +#: flag, where the value came from, and the byte spelled as the plan would +#: write it. The refusal is `checked_value`'s, for the alphabet a branch may +#: be written in, which a UTF-8 `café` fails alike. +UNSAFE_BRANCH = ( + b"REFUSED unsafe-value: --tracking-branch (not given, so the source's " + b"default branch) is 'caf\\udce9', which is not a value this tool will " + b"put on a `git` or `gh` command line\n") +#: What `check` finds, and `execute` refuses, of a plan that tracks `main` +#: once the source has no `main`: both branches, as the plan spells them. +BRANCH_LOST = ( + b"`tracking_branch` is `main`, which is no branch of the source (its " + b"branches: `caf\\udce9`; the source's default branch is `caf\\udce9`)") +LOST_FINDING = b"FINDING plan-tracking-branch-missing: " + BRANCH_LOST +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. + + 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.""" + ref = "refs/remotes/origin/HEAD" if where == ORIGIN_HEAD else "HEAD" + try: + 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"): + git("update-ref", f"refs/remotes/origin/{name}", name, + cwd=source) + 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}") + 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): + pytest.skip(f"`git symbolic-ref {ref}` here is {read.stdout!r}") + return source + + +def moved_to_a_branch_that_is_not_utf8(base: Path) -> Path: + """A source on `main`, planned and answered, whose `main` is then + renamed `BRANCH`: a plan that tracks a branch the source no longer has, + and a source whose HEAD is on one that is not UTF-8. Skipped as + `with_default_branch` is.""" + source = make_source_repo(base / "Thing") + made = planned(base, source) + assert made.returncode == 0, said(made) + answer(base) + try: + git("branch", "-m", "main", BRANCH, cwd=source) + except (AssertionError, UnicodeError, OSError) as exc: + pytest.skip(f"no branch here is named {NOT_UTF8_BRANCH!r}: {exc!r}") + return source + + +@by_where +def test_plan_refuses_a_default_branch_that_is_not_utf8(tmp_path, where): + """The issue's `plan` rows: on the base, a HEAD or an `origin/HEAD` on + `caf` ended `plan` in a `UnicodeDecodeError` traceback from + `_default_branch`, exit 1, and wrote no plan. Now the name is kept as a + lone surrogate and refused as the `--tracking-branch` it would be, + before any plan is written.""" + source = with_default_branch(make_source_repo(tmp_path / "Thing"), where) + refused = planned(tmp_path, source) + assert_no_traceback_and_no_leg(tmp_path, {"plan": refused}) + assert refused.returncode == 2, said(refused) + assert UNSAFE_BRANCH in refused.stderr, said(refused) + assert not (tmp_path / PLAN_FILE).exists() + + +def test_check_finds_a_plans_branch_gone_for_one_that_is_not_utf8(tmp_path): + """The issue's `check` row: a plan written on `main`, then the source's + branch renamed `caf`. On the base `check` ended in the traceback, + from `Source.open`; now it finds what any other branch the source moved + to would be (`plan-tracking-branch-missing`), naming both.""" + moved_to_a_branch_that_is_not_utf8(tmp_path) + found = checked(tmp_path) + assert_no_traceback({"check": found}) + assert found.returncode == 1, said(found) + assert LOST_FINDING in found.stderr, said(found) + + +@needs_filter_repo +def test_execute_refuses_a_plans_branch_gone_for_one_that_is_not_utf8( + tmp_path): + """The issue's `execute` row: the same plan, and on the base the same + traceback, with the remote directory holding nothing. Now `execute` + refuses it with `check`'s finding, before any leg exists.""" + moved_to_a_branch_that_is_not_utf8(tmp_path) + refused = executed(tmp_path) + assert_no_traceback_and_no_leg(tmp_path, {"execute": refused}) + assert refused.returncode == 2, said(refused) + assert LOST_REFUSAL in refused.stderr, said(refused) + assert LOST_FINDING in refused.stderr, said(refused) + + +def planned_past(base: Path, where: str) -> tuple[Path, dict]: + """`(source, runs)`: `plan --tracking-branch main` of a source whose + 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) + runs = {"plan": planned(base, source, TRACKING, "main")} + assert runs["plan"].returncode == 0, said(runs["plan"]) + answer(base) + runs["check"] = checked(base) + return source, runs + + +def tip_of(source: Path, ref: str) -> str: + """The commit `ref` names in `source`, as git spells it.""" + return git("rev-parse", ref, cwd=source).stdout.strip() + + +@by_where +def test_plan_and_check_go_past_a_default_branch_that_is_not_utf8(tmp_path, + where): + """With the branch to track given, the default branch that is not UTF-8 + is only the one the source is read at: `plan` records it as the plan + spells a byte (`\\udce9`) with ITS commit, which is not `main`'s, and + `check` finds no fault.""" + source, runs = planned_past(tmp_path, where) + assert_no_traceback(runs) + plan = load_yaml(tmp_path / PLAN_FILE) + read_at = plan["source"] + assert read_at["default_branch"] == BRANCH + assert read_at["commit"] == tip_of(source, BRANCH) + assert plan["tracking_branch"] == "main" + assert runs["check"].returncode == 0, said(runs["check"]) + assert PLAN_OK in runs["check"].stdout, said(runs["check"]) + + +@needs_filter_repo +@by_where +def test_execute_adopts_a_source_whose_default_branch_is_not_utf8(tmp_path, + where): + """The same plan, `execute`d: the source is read at `caf`, so the + split is made on ITS commit, and every source path is accounted for.""" + source, runs = planned_past(tmp_path, where) + runs["execute"] = executed(tmp_path) + assert_no_traceback(runs) + 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)