From 40b030cbfa6dd897703e5d335e7fd3597ce18f6e Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 23 Oct 2026 21:49:30 +0000 Subject: [PATCH 1/3] 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 == [] From 59c6ecacca559ac6b5a702b3060334bc6c33b030 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 16:10:27 +0000 Subject: [PATCH 2/3] adopt-project.py: a stale split branch in a source refs directory this user cannot list is refused `source-unreadable` by name, never `plan ok` from `check` or two legs made by `execute` (#1257) `_branches_at` and `_branches_in`, which feed the split-branch finding `adopt-branch-exists` and `execute`'s refusals of an earlier run's split branch, listed the source's refs with `git for-each-ref` alone, which passes over a directory it cannot list and exits 0. Measured on #1252 merged into main (b7a00b31): with an earlier run's `adopt/three-repo-shape` in `.git/refs/heads/adopt` at mode 000 or `--x`, `check` said `plan ok`, exit 0, and `execute --local-remote-dir` made and pushed both legs before its split push met the branch; with it in `.git/refs/remotes/origin/adopt`, `check` said `plan ok`. Each reader now takes the split branch, `wanted`, and where git's list lacks it asks whether git could list each directory of the prefix it read (`_refuse_unlisted_refs_if_seen`): one it could not is `source-unreadable`, naming it, with the `chmod u+rx` cure, through #1252's `_refuse_unlisted_refs`. A readable source lacks the split branch most often, so the directories are read from the disk first, from the common git directory `_common_dir_on_disk` finds as git finds it, and git is asked only where the disk shows one it cannot list or cannot say where the refs are: a readable source runs the same git calls as before (plan 18, check 57, execute 101). Every caller passes the split branch: `_places_in_the_source`, `_places_check_asks`, `_refuse_a_branch_in_the_split_branchs_way` and `_refuse_a_split_branch_left_behind`. Rows at the end of tests/test_adopt_tracking_branch.py, beside #1219's. Co-Authored-By: Claude Opus 5.5 --- adopt-project.py | 140 ++++++++++--- tests/test_adopt_tracking_branch.py | 300 ++++++++++++++++++++++++++++ 2 files changed, 416 insertions(+), 24 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index 2893f892..dc5f5abd 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -9893,7 +9893,67 @@ def _listed_by(args: list[str], cwd: Path) -> dict[str, str]: return _listed_branches(listed.stdout, BRANCH_REFS) -def _branches_at(url: str, cwd: Path) -> dict[str, str]: +def _refuse_unlisted_refs_if_seen(path: Path, prefix: str) -> None: + """`_refuse_unlisted_refs(path, prefix)`, run only where the disk shows + a directory of those refs this user cannot list (#1257): where the + source's common git directory can be read from the disk + (`_common_dir_on_disk`) and each directory git lists for `prefix` there + can be listed (`_unlisted_ref_directory`), nothing more is asked. + + FOR THE SPLIT BRANCH, WHICH A SOURCE GIT READS WHOLE MOST OFTEN LACKS. + `_branch_names` asks where the default branch is missing, which a + readable source never is, but `_branches_at` and `_branches_in` ask + where the split branch is missing, which it is in every source no + earlier run left one in. `_refuse_unlisted_refs` runs `git rev-parse` + for the common git directory, so asked there it was one git call more + for each, in every `check` and `execute`; read from the disk it is none. + Where the disk shows a directory git could not list, or cannot say + where the refs are, git is asked, and the refusal names what git + names.""" + common = _common_dir_on_disk(path) + if common is not None and _unlisted_ref_directory(common, + prefix) is None: + return + _refuse_unlisted_refs(path, prefix) + + +def _common_dir_on_disk(path: Path) -> Path | None: + """The common git directory of the repository at `path`, the top-level + directory or the git directory `_top_level` took, read from the disk + without running git, as git 2.43's `read_gitfile_gently` and + `get_common_dir_noenv` read it: the git directory is `path/.git` where + that is a directory, the one a `.git` file's `gitdir: ` line names, + joined to `path`, and otherwise `path` itself; then the one its + `commondir` file names, joined to it, where it has that file. None + where git is told another (`GIT_DIR` or `GIT_COMMON_DIR` set), where + either file holds what git would not read so (no `gitdir: `, a NUL), + or where the disk refuses a read: `_refuse_unlisted_refs_if_seen` asks + git then.""" + if "GIT_DIR" in os.environ or "GIT_COMMON_DIR" in os.environ: + return None + dotgit = path / ".git" + try: + if _is_a(dotgit, stat.S_ISREG): + named = dotgit.read_bytes() + if not named.startswith(b"gitdir: ") or b"\0" in named: + return None + git_dir = path / os.fsdecode( + named[len(b"gitdir: "):].rstrip(b"\r\n")) + else: + git_dir = dotgit if _is_a(dotgit, stat.S_ISDIR) else path + commondir = git_dir / "commondir" + if not _is_a(commondir, stat.S_ISREG): + return git_dir + named = commondir.read_bytes() + except OSError: + return None + if b"\0" in named: + return None + return git_dir / os.fsdecode(named.rstrip(b"\r\n")) + + +def _branches_at(url: str, cwd: Path, wanted: str | None + ) -> dict[str, str]: """Every branch of the repository at `url`, as a push to it meets them. A LOCAL path's are its own refs, `git for-each-ref` (#520): `git ls-remote` leaves out a branch the repository hides with `hideRefs`, and @@ -9902,10 +9962,26 @@ def _branches_at(url: str, cwd: Path) -> dict[str, str]: (`_branches_a_clone_gets`). One that cannot be listed fails as `run` fails a command, `CommandFailed`, and in `execute` before any leg exists: `_refuse_a_split_branch_left_behind` has just asked the same - repository, and a failure there is the same.""" + repository, and a failure there is the same. + + A LOCAL LIST WITHOUT `wanted`, THE SPLIT BRANCH, IS ASKED ABOUT BEFORE + IT IS BELIEVED (#1257), as `_branch_names` asks (#1219): `git + for-each-ref` passes over a directory it cannot list, and exits 0. On + main, with a source's `.git/refs/heads/adopt` at mode 000, `check` said + `plan ok` over the `adopt/three-repo-shape` an earlier run left there, + which it finds as `adopt-branch-exists` once that directory can be + listed, and `execute --local-remote-dir` made and pushed both legs + before its split push met that branch. So where `wanted` is not among + them, whether git could list each directory of `BRANCH_REFS` is asked + first (`_refuse_unlisted_refs_if_seen`), and one it could not is + `source-unreadable`, named; where it is, or is None, nothing more is + asked.""" if remote_is_a_path(url): - return _listed_by(["git", "for-each-ref", REF_LISTING, BRANCH_REFS], - Path(url)) + branches = _listed_by(["git", "for-each-ref", REF_LISTING, + BRANCH_REFS], Path(url)) + if wanted is not None and wanted not in branches: + _refuse_unlisted_refs_if_seen(Path(url), BRANCH_REFS) + return branches return _branches_a_clone_gets(url, cwd) @@ -10044,23 +10120,35 @@ def _assembly_repository(plan: Plan) -> str | None: return f"{org}/{name}" if org and name else None -def _branches_in(path: Path, refs: str) -> dict[str, str]: +def _branches_in(path: Path, refs: str, wanted: str | None + ) -> dict[str, str]: """`_listed_branches` of the refs under `refs` in the repository at - `path`, read from it with `git for-each-ref`: no network.""" - return _listed_branches( + `path`, read from it with `git for-each-ref`: no network. Where + `wanted`, the split branch, is not among them, whether git could list + each directory of `refs` is asked first, as `_branches_at` asks (#1257): + on main, with the source's `.git/refs/remotes/origin/adopt` at mode + 000, `check` said `plan ok` over the `adopt/three-repo-shape` its + `origin` last fetched, which it finds as `adopt-branch-exists` once that + directory can be listed.""" + branches = _listed_branches( git_out(["for-each-ref", REF_LISTING, refs], cwd=path, binary=True), refs) + if wanted is not None and wanted not in branches: + _refuse_unlisted_refs_if_seen(path, refs) + return branches -def _places_in_the_source(where: str, path: Path, extracting: bool) -> list: +def _places_in_the_source(where: str, path: Path, extracting: bool, + branch: str | None) -> list: """The source at `path`, named `where`, as the place a run with `--local-remote-dir` pushes the split to, `(where, pushed_by, extracted, branches)`, as `_split_branch_findings` asks it: its own branches, as - the push meets them (`_branches_at`). While `extracting`, those a clone + the push meets them (`_branches_at`, which asks about a list without + the split branch `branch`, #1257). While `extracting`, those a clone of it gets (`_branches_a_clone_gets`) are met by either run's extraction too, and one it hides from a clone with `hideRefs` (#520) is met by the push alone, a second place, so a finding about it is about that run.""" - branches = _branches_at(str(path), path) + branches = _branches_at(str(path), path, branch) if not extracting: return [(where, LOCAL_RUN, False, branches)] cloned = _branches_a_clone_gets(str(path), path) @@ -10071,21 +10159,23 @@ def _places_in_the_source(where: str, path: Path, extracting: bool) -> list: if name not in cloned})] -def _places_check_asks(plan: Plan, source: Source, extracting: bool) -> list: - """The places `check` asks for the split branch, `(where, pushed_by, - extracted, branches)`: the source, which a run with `--local-remote-dir` - pushes to and either run extracts from while `extracting` - (`_places_in_the_source`), and, where the source's `origin` is the - assembly repository, that repository's branches as the source last - fetched them, which a run without it pushes to. `check` reads no +def _places_check_asks(plan: Plan, source: Source, extracting: bool, + branch: str | None) -> list: + """The places `check` asks for the split branch `branch`, `(where, + pushed_by, extracted, branches)`: the source, which a run with + `--local-remote-dir` pushes to and either run extracts from while + `extracting` (`_places_in_the_source`), and, where the source's `origin` + is the assembly repository, that repository's branches as the source + last fetched them, which a run without it pushes to. `check` reads no network for either.""" places = _places_in_the_source(f"the source {_shown(str(source.path))}", - source.path, extracting) + source.path, extracting, branch) assembly = _assembly_repository(plan) if assembly and (source.repository or "").lower() == assembly.lower(): places.append((f"`{assembly}` on GitHub, as the source's `origin` " "last fetched it,", GITHUB_RUN, False, - _branches_in(source.path, "refs/remotes/origin/"))) + _branches_in(source.path, "refs/remotes/origin/", + branch))) return places @@ -10103,7 +10193,7 @@ def _check_split_branch(plan: Plan, source: Source branch = checked_value(key, plan.get(key, default)) extracting = _extracts_a_leg(assigned_paths(plan.entries)) found = _split_branch_findings( - branch, _places_check_asks(plan, source, extracting), source) + branch, _places_check_asks(plan, source, extracting, branch), source) return found, [SPLIT_BRANCH_REMEDIATION] if found else [] @@ -10159,13 +10249,15 @@ def _refuse_a_branch_in_the_split_branchs_way( return the_source = f"the source {_shown(str(source.path))}" if local: - places = _places_in_the_source(the_source, source.path, extracting) + places = _places_in_the_source(the_source, source.path, extracting, + branch) else: places = [(the_source, None, True, _branches_a_clone_gets(str(source.path), source.path)) ] if extracting else [] places.append((f"`{repositories['assembly']}` on GitHub", GITHUB_RUN, - False, _branches_at(urls["assembly"], source.path))) + False, _branches_at(urls["assembly"], source.path, + branch))) found = _split_branch_findings(branch, places) if found: raise Refusal(SPLIT_BRANCH_TAKEN, "\n".join( @@ -14194,8 +14286,8 @@ def _refuse_a_split_branch_left_behind(source: Source, urls: dict, return # Under `--local-remote-dir` the push goes into the source, and meets its # own refs, a branch it hides from `git ls-remote` too (#520). - tip = (_branches_at(urls["assembly"], source.path).get(branch) if local - else _tip_at(urls["assembly"], branch)) + tip = (_branches_at(urls["assembly"], source.path, branch).get(branch) + if local else _tip_at(urls["assembly"], branch)) if tip is None or _in_the_history_of(source, tip): return raise Refusal( diff --git a/tests/test_adopt_tracking_branch.py b/tests/test_adopt_tracking_branch.py index 6e797a6d..d8fb1719 100644 --- a/tests/test_adopt_tracking_branch.py +++ b/tests/test_adopt_tracking_branch.py @@ -42,6 +42,13 @@ `.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. + +So is one that may hold the split branch an earlier run left (#1257): +with the source's `.git/refs/heads/adopt` at mode 000, `check` said +`plan ok` over the `adopt/three-repo-shape` it finds as +`adopt-branch-exists` once it can list that directory, and `execute +--local-remote-dir` made and pushed both legs before its split push met +that branch. Their rows follow #1219's. """ from __future__ import annotations @@ -53,6 +60,7 @@ import stat import subprocess import sys +from argparse import Namespace from pathlib import Path import pytest @@ -982,3 +990,295 @@ def test_a_source_git_reads_whole_is_asked_nothing_more(adopter, monkeypatch, assert read == {"clone": "main", "nested": "release/v1", "no-remote": "feature/x", "detached": "main"} assert asked == [] + + +# --- a directory git cannot list where the split branch may be (#1257) ------ +# +# `check` asks for the plan's split branch, `adopt/three-repo-shape`, among +# the source's own branches (`_branches_at`) and, where the source's +# `origin` is the assembly repository, among its remote's as it last +# fetched them (`_branches_in`); `execute --local-remote-dir` asks the +# source's own before any leg exists. Each was `git for-each-ref` alone. ON +# b7a00b31 (#1252 merged into main at dffc4226, 2026-11-08), git 2.43.0, +# Python 3.12.3, uid 1000, a stub `gh` and local remotes, with an earlier +# run's split branch at a commit outside `main`: +# +# - in `.git/refs/heads/adopt` at mode 000 or `--x`: `check` said `plan ok`; +# at `r--`, `adopt-git-unasked`, the split's clone meeting a null OID; and +# `execute --local-remote-dir`, at 000, made and pushed both legs and then +# failed at the split push. +# - in `.git/refs/remotes/origin/adopt` at any of those modes: `check` said +# `plan ok`. +# +# Readable, each is `adopt-branch-exists`. Now each is `source-unreadable`, +# naming the directory, with the `chmod` cure, before any leg exists; and +# a source git reads whole runs the same git calls as before, because the +# directories are read from the disk (`_refuse_unlisted_refs_if_seen`). + +#: The plan's split branch, and `check`'s finding for one an earlier run left. +SPLIT_BRANCH = "adopt/three-repo-shape" +BRANCH_EXISTS = "adopt-branch-exists" +#: Where an earlier run's split branch is kept, by the refs `check` reads it +#: in, and what `source-unreadable` cannot tell where git cannot list it. +SPLIT_PLACES = {"own": ("refs/heads/adopt", BRANCHES_UNTOLD), + "fetched": ("refs/remotes/origin/adopt", ORIGIN_UNTOLD)} + + +def with_a_split_branch_left(source: Path, place: str) -> None: + """An earlier run's split branch, a commit outside `main`, loose in the + source: one of its own (`own`), or its `origin`'s as it last fetched it + (`fetched`).""" + git("switch", "-q", "-c", "earlier-run", cwd=source) + git("commit", "-q", "--allow-empty", "-m", "An earlier split", cwd=source) + tip = git("rev-parse", "HEAD", cwd=source).stdout.strip() + git("switch", "-q", "main", cwd=source) + git("branch", "-q", "-D", "earlier-run", cwd=source) + refs = "refs/heads/" if place == "own" else "refs/remotes/origin/" + git("update-ref", refs + SPLIT_BRANCH, tip, cwd=source) + assert (source / ".git" / Path(*SPLIT_PLACES[place][0].split("/")) + ).is_dir() + + +@pytest.mark.parametrize("place", SPLIT_PLACES) +def test_a_split_branch_git_lists_is_still_found(tmp_path, place): + """Read whole, an earlier run's split branch is `adopt-branch-exists`, + as before, and never `source-unreadable`.""" + source = a_clone(tmp_path) + with_a_split_branch_left(source, place) + plan = planned_as_cloned(tmp_path, source) + env, _ = recording_gh(tmp_path / "gh-bin") + + proc = asked_of(tmp_path, "check", source, plan, env) + + assert proc.returncode == 1, said(proc) + assert proc.stderr.count(f"FINDING {BRANCH_EXISTS}: ") == 1, said(proc) + assert "source-unreadable" not in said(proc) + + +@needs_a_mode +@pytest.mark.parametrize("mode", REF_MODES) +@pytest.mark.parametrize("place", SPLIT_PLACES) +def test_a_split_branch_git_cannot_list_is_refused_by_check(tmp_path, place, + mode): + """RV1252's case, and the same for the split branch `origin` last + fetched: its directory at mode 000, `--x` or `r--`. On b7a00b31 `check` + said `plan ok` (at `r--` in `refs/heads/adopt`, `adopt-git-unasked`). + Now `source-unreadable`, naming the directory, exit 2.""" + source = a_clone(tmp_path) + with_a_split_branch_left(source, place) + plan = planned_as_cloned(tmp_path, source) + env, log = recording_gh(tmp_path / "gh-bin") + part, untold = SPLIT_PLACES[place] + + with refs_at(source / ".git" / Path(*part.split("/")), mode): + proc = asked_of(tmp_path, "check", source, plan, env) + + assert_refs_refused(proc, refs_refused(source, part, untold), tmp_path, + log) + assert "plan ok" not in said(proc) + + +@needs_a_mode +@pytest.mark.parametrize("mode", REF_MODES[:2]) +def test_a_split_branch_git_cannot_list_is_refused_before_any_leg(tmp_path, + mode): + """`execute --local-remote-dir` pushes the split into the source, and + asks it first for the branch an earlier run left there + (`_refuse_a_split_branch_left_behind`). On b7a00b31, with its + `.git/refs/heads/adopt` at mode 000, it made and pushed both legs and + then failed at the split push. Now it is refused before any leg exists. + At `r--` git lists the branch and cannot read it, and the split's clone + meets a null OID first, `adopt-git-unasked`, before any leg, as on + b7a00b31: `check`'s own questions come before this one in `execute`.""" + source = a_clone(tmp_path) + with_a_split_branch_left(source, "own") + plan = planned_as_cloned(tmp_path, source) + env, log = recording_gh(tmp_path / "gh-bin") + + with refs_at(source / ".git" / "refs" / "heads" / "adopt", mode): + proc = asked_of(tmp_path, "execute", source, plan, env) + + assert_refs_refused(proc, refs_refused(source, "refs/heads/adopt", + BRANCHES_UNTOLD), tmp_path, log) + + +# --- the same questions, in-process (as root and on Windows too) ------------ + +def split_directory_unlisted_here(adopter, monkeypatch, source: Path, + part: str) -> Path: + """The source's directory `part` made, empty, as git lists it, and + refused to `os.scandir` in this process, as a mode refuses it + (`unlisted_here`, with git left to list what it can).""" + directory = source / ".git" / Path(*part.split("/")) + directory.mkdir() + git_listing = adopter.git_out + unlisted_here(adopter, monkeypatch, directory) + monkeypatch.setattr(adopter, "git_out", git_listing) + return directory + + +def check_args(root: Path, plan: Path) -> Namespace: + return Namespace(plan=str(plan), source=None, work_dir=root / "read", + local_remote_dir=root / "remotes") + + +def execute_args(root: Path, plan: Path) -> Namespace: + return Namespace(plan=str(plan), source=None, + local_remote_dir=root / "remotes", allow_empty_leg=[], + yes=True, work_dir=root / "execute-work") + + +def stopped_at_the_legs(adopter, monkeypatch) -> None: + """`execute` with its probe for `git filter-repo` stubbed, and stopped + where it would make the legs: what reaches there was refused by + nothing.""" + def proceeded(*args, **kwargs): + raise AssertionError("execute went on to make the legs") + monkeypatch.setattr(adopter, "_require_filter_repo", lambda: None) + monkeypatch.setattr(adopter, "_create_leg_remotes", proceeded) + + +@pytest.mark.parametrize("place", SPLIT_PLACES) +def test_check_names_a_split_branch_directory_the_system_will_not_list( + adopter, monkeypatch, tmp_path, place): + """`check`, in this process, of a clone whose split-branch directory the + system will not list: `source-unreadable`, naming it. On b7a00b31 this + was `plan ok`.""" + source = a_clone(tmp_path) + plan = planned_as_cloned(tmp_path, source) + part, untold = SPLIT_PLACES[place] + split_directory_unlisted_here(adopter, monkeypatch, source, part) + + with pytest.raises(adopter.Refusal) as raised: + adopter.cmd_check(check_args(tmp_path, plan)) + + assert raised.value.code == "source-unreadable" + assert raised.value.detail == refs_refused(source, part, untold) + assert CURE in raised.value.remediation + + +def test_execute_names_a_split_branch_directory_the_system_will_not_list( + adopter, monkeypatch, tmp_path): + """`execute --local-remote-dir`, in this process: refused naming the + source's `refs/heads/adopt` before any leg exists. On b7a00b31 it went + on to make the legs.""" + source = a_clone(tmp_path) + plan = planned_as_cloned(tmp_path, source) + split_directory_unlisted_here(adopter, monkeypatch, source, + "refs/heads/adopt") + stopped_at_the_legs(adopter, monkeypatch) + + with pytest.raises(adopter.Refusal) as raised: + adopter.cmd_execute(execute_args(tmp_path, plan)) + + assert raised.value.code == "source-unreadable" + assert raised.value.detail == refs_refused(source, "refs/heads/adopt", + BRANCHES_UNTOLD) + assert not (tmp_path / "remotes").exists() + + +def test_a_source_git_reads_whole_asks_no_git_more_for_its_split_branch( + adopter, monkeypatch, tmp_path, capsys): + """A source git reads whole runs the same git calls as before (#1257). + With no split branch, `check` and `execute` read its directories from + the disk, `refs/heads/` and `refs/remotes/origin/` for `check` and + `refs/heads/` for each of `execute`'s two questions, and never run + `_refuse_unlisted_refs`, whose `git rev-parse` would be one call more + each time. With an earlier run's split branch in both places, which git + lists, nothing more is asked at all.""" + refused, walked = [], [] + real_refuse = adopter._refuse_unlisted_refs + real_walk = adopter._unlisted_ref_directory + + def refusing(*args): + refused.append(args) + return real_refuse(*args) + + def walking(common, prefix): + walked.append(prefix) + return real_walk(common, prefix) + + clean = a_clone(tmp_path / "clean") + clean_plan = planned_as_cloned(tmp_path / "clean", clean) + left = a_clone(tmp_path / "left") + for place in SPLIT_PLACES: + with_a_split_branch_left(left, place) + left_plan = planned_as_cloned(tmp_path / "left", left) + monkeypatch.setattr(adopter, "_refuse_unlisted_refs", refusing) + monkeypatch.setattr(adopter, "_unlisted_ref_directory", walking) + + assert adopter.cmd_check(check_args(tmp_path / "clean", clean_plan)) == 0 + assert walked == ["refs/heads/", "refs/remotes/origin/"] + del walked[:] + stopped_at_the_legs(adopter, monkeypatch) + with pytest.raises(AssertionError, match="went on to make the legs"): + adopter.cmd_execute(execute_args(tmp_path / "clean", clean_plan)) + assert walked == ["refs/heads/", "refs/heads/"] + del walked[:] + assert adopter.cmd_check(check_args(tmp_path / "left", left_plan)) == 1 + assert capsys.readouterr().err.count(f"FINDING {BRANCH_EXISTS}: ") == 2 + assert walked == [] + assert refused == [] + + +@pytest.mark.parametrize("layout", ["clone", "worktree", "separate", + "git-dir"]) +def test_the_disk_names_the_common_git_directory_git_names(adopter, tmp_path, + layout): + """`_common_dir_on_disk` finds the source's refs where git finds them: a + clone's `.git`; a linked worktree's `.git` file, then its git + directory's `commondir`; the `.git` file `git init --separate-git-dir` + leaves; and a git directory read as the source itself.""" + source = a_clone(tmp_path) + path = {"clone": source, "worktree": tmp_path / "worktree", + "separate": tmp_path / "separate", + "git-dir": source / ".git"}[layout] + if layout == "worktree": + git("worktree", "add", "-q", "-b", "elsewhere", str(path), + cwd=source) + elif layout == "separate": + git("init", "-q", "--separate-git-dir", str(tmp_path / "kept.git"), + str(path), cwd=tmp_path) + named = git("rev-parse", "--path-format=absolute", "--git-common-dir", + cwd=path).stdout.rstrip("\n") + + found = adopter._common_dir_on_disk(path) + + assert found is not None + assert os.path.realpath(found) == os.path.realpath(named) + + +def test_the_disk_is_not_read_where_git_would_not_read_it(adopter, + monkeypatch, + tmp_path): + """Where git is told another git directory, or a `.git` file names + none, `_common_dir_on_disk` says nothing, and git is asked.""" + source = a_clone(tmp_path) + for name in ("GIT_DIR", "GIT_COMMON_DIR"): + with monkeypatch.context() as told: + told.setenv(name, str(source / ".git")) + assert adopter._common_dir_on_disk(source) is None + odd = tmp_path / "odd" + odd.mkdir() + (odd / ".git").write_bytes(b"not a gitfile\n") + assert adopter._common_dir_on_disk(odd) is None + + +def test_a_linked_worktree_is_asked_in_its_common_git_directory( + adopter, monkeypatch, tmp_path): + """A linked worktree keeps its branches in its common git directory, the + clone's `.git`: there a `refs/heads/adopt` the system will not list is + named, as `git rev-parse --git-common-dir` names it.""" + source = a_clone(tmp_path) + worktree = tmp_path / "worktree" + git("worktree", "add", "-q", "-b", "elsewhere", str(worktree), + cwd=source) + split_directory_unlisted_here(adopter, monkeypatch, source, + "refs/heads/adopt") + + with pytest.raises(adopter.Refusal) as raised: + adopter._branches_at(str(worktree), worktree, SPLIT_BRANCH) + + assert raised.value.code == "source-unreadable" + assert raised.value.detail == refs_refused(source, "refs/heads/adopt", + BRANCHES_UNTOLD) From 1a9782900e9e8f9be7d5ba6278bb866e2184afbc Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sun, 8 Nov 2026 18:06:10 +0000 Subject: [PATCH 3/3] adopt-project.py and tests: pin #1265's fallback to git and word _common_dir_on_disk's NUL case (rv1265 N2, N3) Two NITs of lane 5's independent review of #1265 (rv1265, at 59c6ecac), which found it READY with no MUST or SHOULD. N2. `_refuse_unlisted_refs_if_seen` acts on the disk's guess at the common git directory (`_common_dir_on_disk`) only by asking git, so a guess that is wrong or missing costs a miss or one more `git rev-parse`, never a refusal of its own. Five mutants of that guess survived the 20 rows of #1257. Six rows now pin it. Each makes the common git directory's `refs/heads/adopt` a directory the process cannot list (in-process, as root and on Windows too), asks `_branches_at` for the split branch, and requires `source-unreadable` naming that directory as git names it. - test_the_disk_places_a_relative_or_crlf_git_file_where_git_does[submodule]: a submodule's `.git` file, `gitdir: ../.git/modules/sub`, read from another folder. It must name the clone's `.git/modules/sub` as git does. Kills R1, a relative gitfile joined to the cwd. - ...[crlf-gitfile]: a linked worktree's `.git` file ending in CR LF. Kills R6, the CR kept on the gitfile's line. - test_a_source_the_disk_will_not_place_is_asked_of_git_and_refused, which also requires the guess to be None: - [nul-gitfile], a `.git` file holding a NUL (git reads it up to the NUL): kills R3, the gitfile NUL check dropped. - [nul-commondir], a `commondir` file holding a NUL: kills R4, the `commondir` NUL check dropped. - [git-dir-set]: kills R8, a None guess asking nothing, as do the other three. - [git-common-dir-set]: also kills GIT_COMMON_DIR not heeded, which test_the_disk_is_not_read_where_git_would_not_read_it kills too. - refs_refused takes `common=` for a git directory that is not the source's `.git`; its other callers are unchanged. Each mutant, applied alone to adopt-project.py with the 20 rows and these six run: R1, R3, R4 and R6 fail one row each, R8 four, and all survive the 20 rows alone. N3. `_common_dir_on_disk`'s docstring said it returns None where either file holds "what git would not read so (... a NUL)". git reads a gitfile or `commondir` holding a NUL up to the NUL, and succeeds. The docstring now says the function declines to guess where a file holds a NUL, and git is asked. No code changed. Co-Authored-By: Claude Sonnet 5.5 --- adopt-project.py | 5 +- tests/test_adopt_tracking_branch.py | 119 +++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 5 deletions(-) diff --git a/adopt-project.py b/adopt-project.py index dc5f5abd..6c034acb 100755 --- a/adopt-project.py +++ b/adopt-project.py @@ -9926,8 +9926,9 @@ def _common_dir_on_disk(path: Path) -> Path | None: joined to `path`, and otherwise `path` itself; then the one its `commondir` file names, joined to it, where it has that file. None where git is told another (`GIT_DIR` or `GIT_COMMON_DIR` set), where - either file holds what git would not read so (no `gitdir: `, a NUL), - or where the disk refuses a read: `_refuse_unlisted_refs_if_seen` asks + the `.git` file does not begin `gitdir: `, where either file holds a + NUL (git reads such a file up to the NUL; this declines to guess), or + where the disk refuses a read: `_refuse_unlisted_refs_if_seen` asks git then.""" if "GIT_DIR" in os.environ or "GIT_COMMON_DIR" in os.environ: return None diff --git a/tests/test_adopt_tracking_branch.py b/tests/test_adopt_tracking_branch.py index d8fb1719..aec6f47f 100644 --- a/tests/test_adopt_tracking_branch.py +++ b/tests/test_adopt_tracking_branch.py @@ -607,10 +607,13 @@ def refs_at(directory: Path, mode: int): def refs_refused(source: Path, part: str, untold: str, - reason: str = DENIED) -> str: + reason: str = DENIED, *, common: Path | None = None) -> 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("/")) + source's `.git` with, and the directory whole; in `common`, the git + directory git names, where that is not the source's `.git`.""" + home = (os.path.join(os.path.realpath(source), ".git") if common is None + else os.path.realpath(common)) + whole = os.path.join(home, *part.split("/")) return (f"the source's `{part}`, {spelled(whole)}: {reason}, so " f"{untold} cannot be told") @@ -1282,3 +1285,113 @@ def test_a_linked_worktree_is_asked_in_its_common_git_directory( assert raised.value.code == "source-unreadable" assert raised.value.detail == refs_refused(source, "refs/heads/adopt", BRANCHES_UNTOLD) + + +# --- the disk's guess at the common git directory, and git asked past it ---- +# +# `_common_dir_on_disk` only GUESSES where the refs are, and +# `_refuse_unlisted_refs_if_seen` acts on the guess only by asking git +# (`_refuse_unlisted_refs`), so a guess that is wrong or missing costs a +# miss or one more `git rev-parse`, never a refusal of its own (#1265's +# review, N2). Each row builds a source in one shape, makes the common git +# directory's `refs/heads/adopt` a directory this process cannot list, and +# asks `_branches_at` for the split branch: the refusal names that +# directory as git names it. + +#: Shapes the disk reads where git reads them, and shapes it leaves to git. +DISK_READS = ["submodule", "crlf-gitfile"] +GIT_IS_ASKED = ["git-dir-set", "git-common-dir-set", "nul-gitfile", + "nul-commondir"] +#: The variable each `*-set` shape tells git its directory with. +TOLD_BY = {"git-dir-set": "GIT_DIR", "git-common-dir-set": "GIT_COMMON_DIR"} + + +def a_source_in_shape(monkeypatch, tmp_path: Path, shape: str + ) -> tuple[Path, Path, Path]: + """`(clone, path, common)`: a clone, the source `shape` makes of it or + in it, and the common git directory git names for that source, which is + in the clone's `.git`. + + - `submodule`: `sub`, whose `.git` file names its git directory + relative to itself, `gitdir: ../.git/modules/sub`; + - `crlf-gitfile`, `nul-gitfile`, `nul-commondir`: a linked worktree + whose `.git` file ends in CR LF, or in a NUL and more, or whose git + directory's `commondir` file holds a NUL (git reads each of the last + two up to the NUL); + - `git-dir-set`, `git-common-dir-set`: the clone itself, with git told + its git directory by the environment.""" + clone = a_clone(tmp_path) + path, common = clone, clone / ".git" + if shape == "submodule": + inside = make_source_repo(tmp_path / "inside", tree=TREE, edits=()) + git(*FILE_PROTOCOL, "submodule", "add", "-q", str(inside), "sub", + cwd=clone) + path, common = clone / "sub", common / "modules" / "sub" + assert (path / ".git").read_bytes().startswith(b"gitdir: ../") + elif shape.endswith(("gitfile", "commondir")): + path = tmp_path / "worktree" + git("worktree", "add", "-q", "-b", "elsewhere", str(path), cwd=clone) + gitfile = path / ".git" + named = gitfile.read_bytes().rstrip(b"\n") + if shape == "crlf-gitfile": + gitfile.write_bytes(named + b"\r\n") + elif shape == "nul-gitfile": + gitfile.write_bytes(named + b"\0junk\n") + else: + (common / "worktrees" / path.name / "commondir").write_bytes( + b"../..\0junk\n") + answered = git("rev-parse", "--path-format=absolute", "--git-common-dir", + cwd=path).stdout.rstrip("\n") + assert os.path.realpath(answered) == os.path.realpath(common), shape + if shape in TOLD_BY: + monkeypatch.setenv(TOLD_BY[shape], str(common)) + return clone, path, common + + +def split_branch_asked_of(adopter, monkeypatch, clone: Path, path: Path, + common: Path) -> None: + """`_branches_at` of `path` for the split branch, with `common`'s + `refs/heads/adopt` a directory the system will not list: it is + `source-unreadable`, naming that directory in the git directory git + names, `common`.""" + part = "/".join([*common.relative_to(clone / ".git").parts, + "refs", "heads", "adopt"]) + split_directory_unlisted_here(adopter, monkeypatch, clone, part) + + with pytest.raises(adopter.Refusal) as raised: + adopter._branches_at(str(path), path, SPLIT_BRANCH) + + assert raised.value.code == "source-unreadable" + assert raised.value.detail == refs_refused( + clone, "refs/heads/adopt", BRANCHES_UNTOLD, common=common) + + +@pytest.mark.parametrize("shape", DISK_READS) +def test_the_disk_places_a_relative_or_crlf_git_file_where_git_does( + adopter, monkeypatch, tmp_path, shape): + """A submodule's `.git` file names its git directory relative to the + file's own folder, not to where the process runs, and a `.git` file + that ends in CR LF names the directory without the CR, as git reads + both. The refusal is then git's, `source-unreadable` naming the + directory in the clone's `.git`.""" + clone, path, common = a_source_in_shape(monkeypatch, tmp_path, shape) + monkeypatch.chdir(tmp_path) + + found = adopter._common_dir_on_disk(path) + + assert found is not None + assert os.path.realpath(found) == os.path.realpath(common) + split_branch_asked_of(adopter, monkeypatch, clone, path, common) + + +@pytest.mark.parametrize("shape", GIT_IS_ASKED) +def test_a_source_the_disk_will_not_place_is_asked_of_git_and_refused( + adopter, monkeypatch, tmp_path, shape): + """Where git is told its directory, or a `.git` file or a `commondir` + holds a NUL, the disk says nothing, and git is asked all the same: the + refusal is not skipped, and it is not a traceback from a path with a + NUL in it.""" + clone, path, common = a_source_in_shape(monkeypatch, tmp_path, shape) + + assert adopter._common_dir_on_disk(path) is None + split_branch_asked_of(adopter, monkeypatch, clone, path, common)