From 40b030cbfa6dd897703e5d335e7fd3597ce18f6e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 21:49:30 +0000 Subject: [PATCH] adopt-project.py: a source whose `refs` this user cannot list is refused `source-unreadable` naming the directory, never `source-default-branch-not-local` "(its branches: none)" (#1219) git passes over a directory of refs it cannot list (for-each-ref exits 0) and says "No such ref" (symbolic-ref -q exits 128) of a ref it cannot reach. Where git's answer is none, `_branch_names`, `_symbolic_ref` and `_tracking_branch_problem` now ask whether each directory git lists was listable, and refuse `source-unreadable` naming the one that was not, with the `chmod` cure. A source git reads whole asks nothing more. Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 177 +++++++++-- tests/test_adopt_tracking_branch.py | 466 +++++++++++++++++++++++++++- 2 files changed, 619 insertions(+), 24 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 5e8cfd75..2893f892 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -2985,17 +2985,28 @@ def _default_branch(path: Path, instead: str | None = None) -> str: `--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) + any other in its place. + + WHAT GIT COULD NOT READ IS NOT READ AS NOT THERE (#1219). git passes + over a directory of the source's refs it cannot list, and says "No such + ref" of a ref it cannot reach. So on main a clone whose `.git/refs/heads` + was at mode 000 or `--x`, or its `.git/refs` at `--x`, was refused as + having no branch of its own, "(its branches: none)", with a `git branch` + to run in place of the `chmod` that is the cure; and one whose + `refs/remotes/origin` was at mode 000 was read at HEAD's branch, its + `origin/HEAD` passed over. Each is `source-unreadable` now, naming the + directory (`_symbolic_ref`, `_branch_names`), and asked only where git's + answer is none, so a source git reads whole costs nothing new.""" + named = _symbolic_ref(path, ORIGIN_HEAD, ORIGIN_BRANCHES) 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) + head = None if origins else _branch_under( + _symbolic_ref(path, "HEAD", BRANCH_REFS), BRANCH_REFS) branch = origins or head or "main" - branches = _branch_names(path, BRANCH_REFS) + branches = _branch_names(path, BRANCH_REFS, branch) if branch in branches: return branch if instead in branches: @@ -3051,8 +3062,14 @@ def _from_the_remote(path: Path, branch: str) -> str: 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): + is `_spelled` and the step said in words. + + `origin` IS ASKED AGAIN ONLY WHERE GIT COULD LIST ITS BRANCHES + (`_branch_names`, #1219): with the source's `refs/remotes/origin` at + mode `--x`, its `origin/HEAD` read and the branch it names fetched, main + said `origin` "has no branch ... either" and to ask it again, which + changes nothing; that is `source-unreadable` now, naming the directory.""" + if branch not in _branch_names(path, ORIGIN_BRANCHES, branch): 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 " @@ -3067,18 +3084,34 @@ def _from_the_remote(path: Path, branch: str) -> str: "origin --auto` first.") -def _symbolic_ref(path: Path, ref: str) -> str | None: +def _symbolic_ref(path: Path, ref: str, holding: 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") + which a branch's name may end in, for white space (#797). + + A REF GIT COULD NOT REACH IS ASKED ABOUT BEFORE IT IS NONE (#1219). `git + symbolic-ref -q` exits 1, saying nothing, where `ref` is not there or is + no symbolic ref, and 128, "No such ref", where git could not read it or + the ref it names; both were None here. So with the source's + `refs/remotes/origin` at mode 000 its `origin/HEAD` was passed over and + the source read at HEAD's branch, and with `refs/heads/feature` at mode + 000 a HEAD on `feature/x` was read as detached and the source read at + `main`: each a branch nobody named. Any other exit than 0 and 1 now asks + whether git could list `holding`, the refs `ref` names one of + (`_refuse_unlisted_refs`); where it could, the answer is None, as + before. git is run here, not through `git_out`, because its exit is the + answer, and a git that cannot be started raises as there.""" + read = subprocess.run(["git", "symbolic-ref", "-q", ref], cwd=str(path), + capture_output=True, check=False) + if read.returncode == 0: + return read.stdout.removesuffix(b"\n").decode("utf-8", + "surrogateescape") + if read.returncode != 1: + _refuse_unlisted_refs(path, holding) + return None def _branch_under(name: str | None, prefix: str) -> str | None: @@ -3090,15 +3123,104 @@ def _branch_under(name: str | None, prefix: str) -> str | None: return None -def _branch_names(path: Path, prefix: str) -> list[str]: +def _branch_names(path: Path, prefix: str, wanted: str | None) -> 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.""" + rather than refused. + + A LIST WITHOUT `wanted`, THE BRANCH THE CALLER LOOKS FOR, IS ASKED ABOUT + BEFORE IT IS BELIEVED (#1219). `git for-each-ref` passes over a + directory it cannot list without a word, and exits 0: with the source's + `.git/refs/heads` at mode 000, on main a clone `plan` made was refused + `source-default-branch-not-local`, "(its branches: none)", by `plan`, + `check` and `execute` alike. So where `wanted` is not among them, + whether git could list each directory it reads them from is asked + first (`_refuse_unlisted_refs`); where it is, or is None, nothing more + is asked.""" 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())) + names = sorted(ref[len(prefix):] + for ref in map(_git_path, raw.splitlines())) + if wanted is not None and wanted not in names: + _refuse_unlisted_refs(path, prefix) + return names + + +#: What `source-unreadable` cannot tell of a source whose refs git could not +#: list (#1219), by the refs it was asked for. +BRANCHES_UNTOLD = "the source's branches" +ORIGIN_BRANCHES_UNTOLD = "the branches of the source's remote `origin`" + + +def _refuse_unlisted_refs(path: Path, *prefixes: str) -> None: + """Raises `source-unreadable` where git, asked in the source at `path` + for its refs under one of `prefixes` (`BRANCH_REFS`, `ORIGIN_BRANCHES`), + could not list a directory it reads them from + (`_unlisted_ref_directory`): the directory named, as a part of the + source's refs and whole, with the operating system's reason and the + `chmod` cure (`_source_unreadable`). Returns where git could list each + one. Asked only where git's answer was none, so it costs a source git + reads whole nothing.""" + common = Path(os.fsdecode(git_out( + ["rev-parse", "--path-format=absolute", "--git-common-dir"], + cwd=path, binary=True).rstrip(b"\n"))) + for prefix in prefixes: + found = _unlisted_ref_directory(common, prefix) + if found is not None: + part, exc = found + raise _source_unreadable( + f"the source's {_spelled(part.as_posix())}, " + f"{_spelled(str(common.joinpath(*part.parts)))}", exc, + BRANCHES_UNTOLD if prefix == BRANCH_REFS + else ORIGIN_BRANCHES_UNTOLD) + + +def _unlisted_ref_directory(common: Path, prefix: str + ) -> tuple[PurePosixPath, OSError] | None: + """The first directory, in the common git directory `common`, that git + lists to answer `git for-each-ref ` and this user cannot list, + with the operating system's refusal; None where it can list each one, + or one is not there, which git reads as holding no refs. + + ASKED AS GIT 2.43'S FILES BACKEND LISTS THEM (`loose_fill_ref_dir`): BY + LISTING, NOT BY A SEARCH. A directory at mode `--x` can be searched and + not listed, and git lists nothing in it. git lists `refs` to find + `heads` in it (with `refs` at `--x`, `for-each-ref refs/heads/` printed + nothing), so each directory above `prefix` is listed, and then `prefix` + and every directory under it, a branch `feature/x` being kept in + `refs/heads/feature`. Each entry on the way is `stat`ed to know a + directory, as git's `get_dtype` follows a link, so a directory listed + and not searched (mode `r--`), whose refs git cannot open, is named too. + Under a reftable `refs/heads` is a file, and nothing is listed.""" + target = PurePosixPath(prefix) + at = PurePosixPath(target.parts[0]) + pending = [at] + try: + while pending: + at = pending.pop() + for name in _listed_names(common.joinpath(*at.parts)) or (): + below = at / name + if (below == target or below in target.parents + or target in below.parents) and _is_a( + common.joinpath(*below.parts), stat.S_ISDIR): + pending.append(below) + except OSError as exc: + return at, exc + return None + + +def _listed_names(directory: Path) -> list[str] | None: + """The names `directory` holds, listed as git's `opendir` lists them; + None where nothing is there or it is no directory (`_absent`), and any + other refusal raised.""" + try: + with os.scandir(directory) as entries: + return [entry.name for entry in entries] + except OSError as exc: + if _absent(exc): + return None + raise # --------------------------------------------------------------------------- @@ -4922,10 +5044,17 @@ def _tracking_branch_problem(source: Source, what: str, """Why `tracking`, named as `what`, cannot be this adoption's tracking branch, as a sentence; None when it is a branch of the source as `_branches_of` reads them. ONE TEST, asked by `plan` as a NOTE and by - `check` and `execute` as `plan-tracking-branch-missing`.""" + `check` and `execute` as `plan-tracking-branch-missing`. + + A BRANCH GIT COULD NOT LIST IS NOT ONE THE SOURCE LACKS (#1219): before + it is said, whether git could list each directory of both kinds of refs + `_branches_of` reads is asked (`_refuse_unlisted_refs`). On main, with + the source's `refs/remotes/origin` at mode `--x`, a branch `origin` has + was "no branch of the source", read from its own branches instead.""" whose, branches = _branches_of(source) if tracking in branches: return None + _refuse_unlisted_refs(source.path, ORIGIN_BRANCHES, BRANCH_REFS) return (f"{what} is {_spelled(tracking)}, which is no branch of {whose} " f"(its branches: {_branches_named(branches)}; the source's " f"default branch is {_spelled(source.branch)}). `execute` pushes " @@ -10385,9 +10514,13 @@ def _split_branch_of(plan: Plan) -> str | None: def _cloned_branch(source: Source) -> str | None: """The branch `git clone` checks out in a clone of `source`: the one its HEAD is on. None where HEAD is on no branch the source holds, so - that git guesses one, or checks none out.""" - head = _branch_under(_symbolic_ref(source.path, "HEAD"), BRANCH_REFS) - return head if head in _branch_names(source.path, BRANCH_REFS) else None + that git guesses one, or checks none out. A HEAD or a branch git could + not read is `source-unreadable` (`_symbolic_ref`, `_branch_names`, + #1219), never a HEAD on no branch.""" + head = _branch_under(_symbolic_ref(source.path, "HEAD", BRANCH_REFS), + BRANCH_REFS) + return head if head in _branch_names(source.path, BRANCH_REFS, + head) else None def _absolute_here(target: str) -> bool: diff --git a/tests/test_adopt_tracking_branch.py b/tests/test_adopt_tracking_branch.py index ce584070..6e797a6d 100644 --- a/tests/test_adopt_tracking_branch.py +++ b/tests/test_adopt_tracking_branch.py @@ -35,18 +35,32 @@ 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. + +A branch git could not list is no branch the source lacks (#1219). A +directory of the source's refs this user cannot list was read as holding +none, and a ref git could not reach as not there, so a clone whose +`.git/refs/heads` was at mode 000 was refused as having no branch of its +own, "(its branches: none)". Each is `source-unreadable` now, naming the +directory, with the `chmod` cure; the rows are at the end of this file. """ from __future__ import annotations +import contextlib +import errno +import os import shutil +import stat +import subprocess import sys from pathlib import Path import pytest -from conftest import (ADOPT, FILE_PROTOCOL, REPO, git, make_source_repo, - run_script, write_plan) +from conftest import (ADOPT, FILE_PROTOCOL, REPO, git, load_without_bytecode, + make_source_repo, run_script, spelled, write_plan) +from test_adopt_unsearchable_source import (CURE, DENIED, UNREADABLE, + needs_a_mode, recording_gh, said) sys.path.insert(0, str(REPO / "scripts")) from repo_shape import load_yaml # noqa: E402 @@ -520,3 +534,451 @@ def test_a_default_branch_outside_the_alphabet_names_the_way_past_it( assert written["tracking_branch"] == "main" checked = check(plan) assert checked.returncode == 0, checked.stdout + checked.stderr + + +# --- a directory of the source's refs git cannot list (#1219) --------------- +# +# `git for-each-ref` passes over a directory of refs it cannot list, without +# a word and exit 0; `git symbolic-ref -q` exits 128, "No such ref", of a +# ref it cannot reach, where it exits 1 of one that is not there. ON +# 50ce8ab1 (2026-10-23), git 2.43.0, Python 3.12.3, uid 1000, a stub `gh` +# and local remotes: +# +# - `.git/refs/heads` at mode 000 or `--x`, or `.git/refs` at `--x`: `plan +# --source`, and `check` and `execute` of a plan recording `cloned: true`, +# were `source-default-branch-not-local`, "the source's `origin/HEAD` +# names `main` ... (its branches: none)"; so was a clone on `release/v1` +# with `refs/heads/release` at 000 or `--x`. +# - `refs/heads/feature` at `--x`, HEAD on `feature/x` beside `main`: "its +# HEAD is on `feature/x`, which has no commit yet"; at 000, HEAD was read +# as detached and `plan` written at `main`, and `check` found `plan-stale`. +# - `refs/remotes/origin` at 000, `origin/HEAD` naming `main` and only a +# `dev` of the source's own: `plan` written at `dev`, its `origin/HEAD` +# passed over; at `--x`, the remote's branches fetched loose, refused +# saying `origin` has no `main` "either", and to ask it again. +# +# Now each is `source-unreadable`, naming the directory, with the `chmod` +# cure, before anything is cloned or made, and `gh` is never asked. A real +# mode runs in a child, skipped on Windows and as root (`needs_a_mode`); the +# same questions are asked in-process too, which runs everywhere. + +#: The modes a directory of refs is put at: no listing and no search, a +#: search alone, a listing alone. +REF_MODES = [pytest.param(0o000, id="000"), pytest.param(0o100, id="x-only"), + pytest.param(0o400, id="r-only")] +#: What `source-unreadable` cannot tell, by the refs git could not list. +BRANCHES_UNTOLD = "the source's branches" +ORIGIN_UNTOLD = "the branches of the source's remote `origin`" +#: What 50ce8ab1 said of a clone whose `.git/refs/heads` it could not list. +NO_BRANCHES = "(its branches: none)" +#: The GitHub url the source's `origin` names, which `plan` records. +NORTHWIND_URL = "https://github.com/testorg/Northwind.git" +#: `plan`'s flags after `--source `, as `write_plan` passes them. +PLAN_FLAGS = ("--project", "Northwind", "--org", "testorg", "--elected-by", + "Test Human", "--elected-on", "2026-09-02") + + +@pytest.fixture(scope="module") +def adopter(): + return load_without_bytecode(ADOPT, "adopt_tracking_branch") + + +@contextlib.contextmanager +def refs_at(directory: Path, mode: int): + """`directory` at `mode` for the `with`, its mode given back in a + `finally`. The mode is asserted to bite: this user can no longer both + list and search it, which a file system that ignores modes would let + pass for the wrong reason.""" + before = stat.S_IMODE(directory.stat().st_mode) + directory.chmod(mode) + try: + assert not os.access(directory, os.R_OK | os.X_OK), directory + yield directory + finally: + directory.chmod(before) + + +def refs_refused(source: Path, part: str, untold: str, + reason: str = DENIED) -> str: + """The detail `source-unreadable` names `part` of the refs in the + source's `.git` with, and the directory whole.""" + whole = os.path.join(os.path.realpath(source), ".git", *part.split("/")) + return (f"the source's `{part}`, {spelled(whole)}: {reason}, so " + f"{untold} cannot be told") + + +def a_clone(root: Path, default: str = "main") -> Path: + """`git clone` of a repository whose default branch is `default`, with a + `dev` beside it: `origin/HEAD` names `default`, the clone's one branch, + and its `origin` is the GitHub url `plan` records.""" + seed = make_source_repo(root / "seed", tree=TREE, edits=(), + branch=default) + git("branch", "dev", cwd=seed) + remote = root / "testorg" / "Northwind.git" + git(*FILE_PROTOCOL, "clone", "-q", "--bare", str(seed), str(remote), + cwd=root) + source = root / "Northwind" + git(*FILE_PROTOCOL, "clone", "-q", str(remote), str(source), cwd=root) + git("remote", "set-url", "origin", NORTHWIND_URL, cwd=source) + return source + + +def a_fetched_source(root: Path, own: str) -> Path: + """A source as `git init`, `git remote add` and `git fetch` leave one: + its remote's branches, `main` and `dev`, loose in `refs/remotes/origin` + (a clone packs them), `origin/HEAD` naming `main`, and one branch of its + own, `own`, made from its remote's.""" + seed = make_source_repo(root / "seed", tree=TREE, edits=()) + git("branch", "dev", cwd=seed) + remote = root / "testorg" / "Northwind.git" + git(*FILE_PROTOCOL, "clone", "-q", "--bare", str(seed), str(remote), + cwd=root) + source = root / "Northwind" + source.mkdir() + git("init", "-q", "-b", own, ".", cwd=source) + git("remote", "add", "origin", str(remote), cwd=source) + git(*FILE_PROTOCOL, "fetch", "-q", "origin", cwd=source) + git("remote", "set-head", "origin", "main", cwd=source) + git("checkout", "-q", "-B", own, f"origin/{own}", cwd=source) + git("remote", "set-url", "origin", NORTHWIND_URL, cwd=source) + assert not (source / ".git" / "packed-refs").exists() + return source + + +def a_repository_on(root: Path, branch: str) -> Path: + """A repository with no remote: `main`, and HEAD on `branch`, a commit + ahead of it.""" + source = make_source_repo(root / "Northwind", tree=TREE, edits=()) + git("switch", "-q", "-c", branch, cwd=source) + git("commit", "-q", "--allow-empty", "-m", "Ahead", cwd=source) + return source + + +def planned_as_cloned(root: Path, source: Path, *flags: str) -> Path: + """`plan --source ` while git reads it whole, and the plan it + wrote, recorded as a clone `plan` made (`source.cloned: true`), as `plan + --source ` records its clone.""" + plan = root / PLAN_NAME + result = write_plan(source, plan, extra=( + "--work-dir", str(root / "plan-work"), *flags)) + assert result.returncode == 0, result.stdout + result.stderr + assert MISSING not in result.stdout + result.stderr + text = plan.read_bytes() + assert text.count(b"\n cloned: false\n") == 1, text + plan.write_bytes(text.replace(b"\n cloned: false\n", + b"\n cloned: true\n")) + return plan + + +def asked_of(root: Path, verb: str, source: Path, plan: Path | None, + env: dict, *flags: str) -> subprocess.CompletedProcess: + """`plan --source `, or ` --plan ` with a + work directory and local remotes of its own.""" + if verb == "plan": + return run_script(ADOPT, "plan", "--source", str(source), + *PLAN_FLAGS, *flags, "--out", + str(root / "again.yaml"), "--work-dir", + str(root / "again-work"), env=env) + return run_script(ADOPT, verb, "--plan", str(plan), + *(["--yes"] if verb == "execute" else []), + "--work-dir", str(root / f"{verb}-work"), + "--local-remote-dir", str(root / "remotes"), env=env) + + +def assert_refs_refused(proc, detail: str, root: Path, log: Path, *, + written: bool = False) -> None: + """Exit 2, `source-unreadable` with `detail` and the cure, none of the + answers 50ce8ab1 gave, `gh` never asked and nothing made; and no plan + written again unless `written`, `plan` having written it before the + report's last reads.""" + text = said(proc) + assert proc.returncode == 2, text + assert f"{UNREADABLE}{detail}" in proc.stderr, text + assert CURE in proc.stderr, text + for words in (NOT_LOCAL, NO_BRANCHES, ASK_AGAIN, MISSING, "plan-stale", + "Traceback"): + assert words not in text, (words, text) + assert log.read_bytes() == b"" + assert (root / "again.yaml").exists() is written + assert not (root / "remotes").exists() + + +@needs_a_mode +@pytest.mark.parametrize("mode", REF_MODES) +@pytest.mark.parametrize("verb", ["plan", "check", "execute"]) +def test_a_clone_whose_branches_git_cannot_list_is_refused_by_name( + tmp_path, verb, mode): + """RV1186's case: a clone `plan` made, its `.git/refs/heads` at mode 000, + `--x` or `r--`. On 50ce8ab1 `plan`, `check` and `execute` alike were + `source-default-branch-not-local`, "(its branches: none)", with a `git + branch` to run as the way out. Now `source-unreadable`, naming the + directory, exit 2.""" + source = a_clone(tmp_path) + plan = planned_as_cloned(tmp_path, source) + env, log = recording_gh(tmp_path / "gh-bin") + + with refs_at(source / ".git" / "refs" / "heads", mode): + proc = asked_of(tmp_path, verb, source, plan, env) + + assert_refs_refused(proc, refs_refused(source, "refs/heads", + BRANCHES_UNTOLD), tmp_path, log) + + +@needs_a_mode +def test_a_clone_whose_refs_git_can_search_and_not_list_is_named(tmp_path): + """`.git/refs` at `--x`: git takes the repository for one, since it + asks `refs` with a search, and lists nothing in it, so on 50ce8ab1 the + clone had no branch of its own. Now `refs` is named.""" + source = a_clone(tmp_path) + env, log = recording_gh(tmp_path / "gh-bin") + + with refs_at(source / ".git" / "refs", 0o100): + proc = asked_of(tmp_path, "plan", source, None, env) + + assert_refs_refused(proc, refs_refused(source, "refs", BRANCHES_UNTOLD), + tmp_path, log) + + +@needs_a_mode +@pytest.mark.parametrize("mode", REF_MODES) +@pytest.mark.parametrize("verb", ["plan", "check"]) +@pytest.mark.parametrize(("layout", "part"), [ + pytest.param("origin-names-it", "refs/heads/release", + id="origin-names-it"), + pytest.param("head-is-on-it", "refs/heads/feature", id="head-is-on-it"), +]) +def test_a_nested_directory_of_branches_git_cannot_list_is_named( + tmp_path, layout, part, verb, mode): + """A branch named `release/v1` is kept in `refs/heads/release`, which git + lists apart. A clone on it, `origin/HEAD` naming it, and a repository + with no remote, its HEAD on `feature/x` beside `main`, with that + directory at mode 000, `--x` or `r--`: on 50ce8ab1 refused as having no + such branch, or, HEAD on it at 000, read at `main`. Now that directory + is named.""" + source = (a_clone(tmp_path, "release/v1") if layout == "origin-names-it" + else a_repository_on(tmp_path, "feature/x")) + plan = planned_as_cloned(tmp_path, source) + env, log = recording_gh(tmp_path / "gh-bin") + + with refs_at(source / ".git" / Path(*part.split("/")), mode): + proc = asked_of(tmp_path, verb, source, plan, env) + + assert_refs_refused(proc, refs_refused(source, part, BRANCHES_UNTOLD), + tmp_path, log) + + +@needs_a_mode +@pytest.mark.parametrize("mode", REF_MODES) +def test_a_remote_whose_branches_git_cannot_list_is_named(tmp_path, mode): + """A source whose `origin/HEAD` names `main`, which only its remote has: + read whole, it is `source-default-branch-not-local`, with `git branch + main origin/main` as the way through. Its `refs/remotes/origin` at mode + 000: on 50ce8ab1 git said "No such ref" of the `origin/HEAD`, which was + passed over, and the plan written at `dev`; at `--x`, `origin` had no + `main` "either", and was to be asked again. Now the directory is + named.""" + source = a_fetched_source(tmp_path, "dev") + env, log = recording_gh(tmp_path / "gh-bin") + whole = asked_of(tmp_path, "plan", source, None, env) + assert (f"{NOT_LOCAL}the source's `origin/HEAD` names `main`") in \ + whole.stderr, said(whole) + assert MAKE_IT_LOCAL in whole.stderr, said(whole) + + with refs_at(source / ".git" / "refs" / "remotes" / "origin", mode): + proc = asked_of(tmp_path, "plan", source, None, env) + + assert_refs_refused(proc, refs_refused(source, "refs/remotes/origin", + ORIGIN_UNTOLD), tmp_path, log) + + +@needs_a_mode +@pytest.mark.parametrize("verb", ["check", "execute"]) +def test_an_origin_head_git_cannot_reach_is_not_passed_over(tmp_path, verb): + """The same source planned with `--tracking-branch dev`, recorded as a + clone `plan` made, and its `refs/remotes/origin` then at mode 000: on + 50ce8ab1 `check` read it at HEAD's branch, `plan ok`, what its + `origin/HEAD` names untold. Now `check` and `execute` are refused.""" + source = a_fetched_source(tmp_path, "dev") + plan = planned_as_cloned(tmp_path, source, TRACKING, "dev") + env, log = recording_gh(tmp_path / "gh-bin") + + with refs_at(source / ".git" / "refs" / "remotes" / "origin", 0o000): + proc = asked_of(tmp_path, verb, source, plan, env) + + assert_refs_refused(proc, refs_refused(source, "refs/remotes/origin", + ORIGIN_UNTOLD), tmp_path, log) + + +@needs_a_mode +@pytest.mark.parametrize("verb", ["plan", "check"]) +def test_a_tracking_branch_git_cannot_list_is_not_called_missing(tmp_path, + verb): + """A source on a `main` of its own, tracking `dev`, which only its + remote has, fetched loose; its `refs/remotes/origin` at `--x`, so + `origin/HEAD` is read and `origin`'s branches are not listed. On + 50ce8ab1 `plan` noted, and `check` found, `plan-tracking-branch-missing`: + `dev` "is no branch of the source (its branches: `main`; ...)", read + from its own branches. Now `source-unreadable`; `plan` has written the + plan by then, as before any of the report's last reads.""" + source = a_fetched_source(tmp_path, "main") + plan = planned_as_cloned(tmp_path, source, TRACKING, "dev") + env, log = recording_gh(tmp_path / "gh-bin") + + with refs_at(source / ".git" / "refs" / "remotes" / "origin", 0o100): + proc = asked_of(tmp_path, verb, source, plan, env, TRACKING, "dev") + + assert_refs_refused(proc, refs_refused(source, "refs/remotes/origin", + ORIGIN_UNTOLD), tmp_path, log, + written=verb == "plan") + + +def test_a_source_with_no_branch_of_its_own_is_still_said_to_have_none( + tmp_path): + """A source whose HEAD is detached and whose one branch was deleted has + no branch: git lists each directory and finds none. It is + `source-default-branch-not-local`, "(its branches: none)", as on + 50ce8ab1, and never `source-unreadable`.""" + source = make_source_repo(tmp_path / "Northwind", tree=TREE, edits=()) + git("checkout", "-q", "--detach", cwd=source) + git("branch", "-q", "-D", "main", cwd=source) + + result = write_plan(source, tmp_path / PLAN_NAME) + + assert result.returncode == 2, result.stdout + result.stderr + assert (f"{NOT_LOCAL}the source names no branch of its own to read: it " + "has no `origin/HEAD` naming a branch of its remote `origin`, " + "its HEAD is detached, and it has no branch `main`, which this " + f"tool reads then {NO_BRANCHES}.") in result.stderr, result.stderr + assert "source-unreadable" not in result.stderr, result.stderr + + +# --- the same questions, in-process (as root and on Windows too) ------------ + +def unlisted_here(adopter, monkeypatch, directory: Path, + code: int = errno.EACCES) -> None: + """In this process, as git and the system answer where `directory` + cannot be listed: `git for-each-ref` printing nothing, and `os.scandir` + of `directory` refused with `code`; every other call is the real one.""" + locked = os.path.normcase(os.path.realpath(directory)) + real_scandir = os.scandir + real_git_out = adopter.git_out + + def refusing(path="."): + if not isinstance(path, int) and os.path.normcase( + os.path.realpath(path)) == locked: + raise OSError(code, os.strerror(code), os.fspath(path)) + return real_scandir(path) + + def listing_nothing(args, cwd, *rest, **kwargs): + if args[:1] == ["for-each-ref"]: + return b"" + return real_git_out(args, cwd, *rest, **kwargs) + + monkeypatch.setattr(os, "scandir", refusing) + monkeypatch.setattr(adopter, "git_out", listing_nothing) + + +@pytest.mark.parametrize(("code", "cure", "not_said"), [ + pytest.param(errno.EACCES, CURE, "which no `chmod`", id="eacces"), + pytest.param(errno.EIO, "which no `chmod` puts right", "chmod u+rx", + id="eio"), +]) +def test_a_directory_the_system_will_not_list_is_named_everywhere( + adopter, monkeypatch, tmp_path, code, cure, not_said): + """`Source.open` of a clone on `release/v1` whose `refs/heads/release` + the system will not list: `source-unreadable`, naming it, with the cure + for a permission and none for another reason.""" + source = a_clone(tmp_path, "release/v1") + unlisted_here(adopter, monkeypatch, + source / ".git" / "refs" / "heads" / "release", code) + + with pytest.raises(adopter.Refusal) as raised: + adopter.Source.open(str(source), tmp_path / "work") + + refusal = raised.value + assert refusal.code == "source-unreadable" + assert refusal.detail == refs_refused( + source, "refs/heads/release", BRANCHES_UNTOLD, + f"cannot be read ({os.strerror(code)})") + assert cure in refusal.remediation + assert not_said not in refusal.remediation + + +def test_a_head_git_cannot_reach_is_asked_about_everywhere( + adopter, monkeypatch, tmp_path): + """`git symbolic-ref -q HEAD` exiting 128, "No such ref", as git does + where it cannot reach the branch HEAD names, and `refs/heads/feature` + refused to `os.scandir`: a repository on `feature/x` is + `source-unreadable`, naming it. On 50ce8ab1 HEAD was read as detached + and the source read at `main`.""" + source = a_repository_on(tmp_path, "feature/x") + unlisted_here(adopter, monkeypatch, + source / ".git" / "refs" / "heads" / "feature") + real_run = subprocess.run + + def unreached(args, *rest, **kwargs): + if list(args) == ["git", "symbolic-ref", "-q", "HEAD"]: + return subprocess.CompletedProcess( + args, 128, b"", b"fatal: No such ref: HEAD\n") + return real_run(args, *rest, **kwargs) + + monkeypatch.setattr(subprocess, "run", unreached) + + with pytest.raises(adopter.Refusal) as raised: + adopter.Source.open(str(source), tmp_path / "work") + + assert raised.value.code == "source-unreadable" + assert raised.value.detail == refs_refused(source, "refs/heads/feature", + BRANCHES_UNTOLD) + + +def test_the_branch_a_clone_checks_out_is_not_guessed_past_an_unlisted_one( + adopter, monkeypatch, tmp_path): + """`_cloned_branch` reads HEAD's branch, `feature/x`, and on 50ce8ab1 + took a list without it for a HEAD on no branch, so `check` guessed what + `git clone` would write. Now that list is asked about first.""" + source = a_repository_on(tmp_path, "feature/x") + opened = adopter.Source(Path(os.path.realpath(source)), None, + "feature/x", "") + assert adopter._cloned_branch(opened) == "feature/x" + unlisted_here(adopter, monkeypatch, + source / ".git" / "refs" / "heads" / "feature") + + with pytest.raises(adopter.Refusal) as raised: + adopter._cloned_branch(opened) + + assert raised.value.code == "source-unreadable" + assert raised.value.detail == refs_refused(source, "refs/heads/feature", + BRANCHES_UNTOLD) + + +def test_a_source_git_reads_whole_is_asked_nothing_more(adopter, monkeypatch, + tmp_path): + """A source git reads whole costs nothing new: a clone, a clone on a + branch in a directory of its own, a repository with no remote on + `feature/x`, and one whose HEAD is detached are each read at the branch + they were read at before, and their tracking branch found, and + `_refuse_unlisted_refs`, which runs `git rev-parse` and lists + directories, is never asked.""" + asked = [] + monkeypatch.setattr(adopter, "_refuse_unlisted_refs", + lambda *args: asked.append(args)) + detached = make_source_repo(tmp_path / "detached" / "Northwind", + tree=TREE, edits=()) + git("checkout", "-q", "--detach", cwd=detached) + sources = {"clone": a_clone(tmp_path / "clone"), + "nested": a_clone(tmp_path / "nested", "release/v1"), + "no-remote": a_repository_on(tmp_path / "local", "feature/x"), + "detached": detached} + read = {} + for name, source in sources.items(): + opened = adopter.Source.open(str(source), tmp_path / "work") + read[name] = opened.branch + assert adopter._tracking_branch_problem(opened, "it", + opened.branch) is None + assert adopter._cloned_branch(opened) == ( + None if name == "detached" else opened.branch) + + assert read == {"clone": "main", "nested": "release/v1", + "no-remote": "feature/x", "detached": "main"} + assert asked == []