diff --git a/scripts/check_branch_not_superseded.py b/scripts/check_branch_not_superseded.py new file mode 100644 index 0000000..75be576 --- /dev/null +++ b/scripts/check_branch_not_superseded.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +"""Stop a rebase or salvage of a branch whose work has already landed on its base. + +A branch that sits behind its base still shows per-file differences, and a +file listing reads every difference as content. Direction is the signal: on +a superseded branch the differences are the base's newer work, and carrying +the branch forward reverts it. This gate reads direction and prints the +numbers behind its verdict. + +Catches, SUPERSEDED, exit 3: + - every commit on the branch is already on the base: `git cherry` prints + no '+' line, which includes a branch the base already contains; + - the net diff against the base is deletion-dominated, and either every + file that differs is behind the base (the branch's copy is one the base + already had since they split), or the branch's own change is already on + the base: every line it adds is in the base's copy of that file, every + line it removes is gone from it, and no file it changes is binary or + mode-only. + +Allows, LIVE, exit 0: + - a branch carrying commits the base does not have, even when it is also + far behind the base. + +Refuses, UNCHECKED, exit 2: + - the ref or base does not resolve, a pull ref cannot be fetched, the + clone is shallow, the two share no history, or git errors. UNCHECKED + never exits 0, so it can never read as LIVE. + + python3 scripts/check_branch_not_superseded.py + python3 scripts/check_branch_not_superseded.py '#' --base origin/main + python3 scripts/check_branch_not_superseded.py --repo --json +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import asdict, dataclass + +LIVE = "LIVE" +SUPERSEDED = "SUPERSEDED" +UNCHECKED = "UNCHECKED" +EXIT_CODES = {LIVE: 0, UNCHECKED: 2, SUPERSEDED: 3} + +PULL_REF_RE = re.compile(r"^(?:#|(?:refs/)?pull/)(\d+)(?:/head)?$") +FETCHED_PULL_NAMESPACE = "refs/check-branch-not-superseded/pull" +ABSENT = "absent" + + +class GitError(Exception): + pass + + +@dataclass +class Evidence: + ahead: int + behind: int + unique_commits: int + additions: int + deletions: int + binary_files: int + files_differ: int + files_behind: int + added_lines_missing_from_base: int + removed_lines_still_on_base: int + unreadable_branch_files: int + + @property + def files_ahead(self) -> int: + return self.files_differ - self.files_behind + + +def _evidence(**overrides) -> dict: + fields = dict(ahead=1, behind=0, unique_commits=1, additions=0, deletions=0, binary_files=0, + files_differ=0, files_behind=0, added_lines_missing_from_base=0, + removed_lines_still_on_base=0, unreadable_branch_files=0) + fields.update(overrides) + return fields + + +PROMISED_CATCH = ( + "ahead=1 behind=4 unique_commits=0 additions=82 deletions=1257 files_differ=32 files_behind=32", + "ahead=0 behind=9 unique_commits=0 deletions=400 files_differ=12 files_behind=12", + "ahead=2 behind=18 unique_commits=2 additions=192 deletions=7207 files_differ=118 files_behind=118", + "ahead=3 behind=183 unique_commits=2 additions=849 deletions=38370 binary_files=15" + " files_differ=573 files_behind=573 added_lines_missing_from_base=2", + "ahead=2 behind=6 unique_commits=2 additions=4 deletions=120 files_differ=9 files_behind=8", +) +PROMISED_ALLOW = ( + "additions=40 deletions=2 files_differ=2 added_lines_missing_from_base=40", + "behind=30 additions=12 deletions=900 files_differ=25 files_behind=24 added_lines_missing_from_base=12", + "behind=30 deletions=950 files_differ=26 files_behind=25 removed_lines_still_on_base=60", + "behind=30 additions=3 deletions=950 files_differ=26 files_behind=25 binary_files=1 unreadable_branch_files=1", +) + + +def exemplar_evidence(exemplar: str) -> Evidence: + overrides = {key: int(value) for key, value in (pair.split("=") for pair in exemplar.split())} + return Evidence(**_evidence(**overrides)) + + +def classify(ev: Evidence) -> tuple[str, str]: + if ev.unique_commits == 0: + return SUPERSEDED, "every commit on the branch is already on the base (git cherry shows no '+')" + if ev.deletions > ev.additions: + if ev.files_ahead == 0: + return SUPERSEDED, ( + "the net diff is deletion-dominated and every file that differs is behind the base" + ) + if (ev.added_lines_missing_from_base == 0 and ev.removed_lines_still_on_base == 0 + and ev.unreadable_branch_files == 0): + return SUPERSEDED, ( + "the net diff is deletion-dominated and the branch's own change is already on the base" + ) + return LIVE, f"{ev.unique_commits} commit(s) on the branch are not on the base" + + +def flags_exemplar(exemplar: str) -> bool: + return classify(exemplar_evidence(exemplar))[0] == SUPERSEDED + + +def git(repo: str, *args: str, ok: tuple[int, ...] = (0,)) -> subprocess.CompletedProcess: + env = dict(os.environ, GIT_TERMINAL_PROMPT="0") + proc = subprocess.run( + ["git", "-C", repo, *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="surrogateescape", + env=env, + ) + if proc.returncode not in ok: + detail = proc.stderr.strip() or proc.stdout.strip() or "no output" + raise GitError(f"git {' '.join(args)} exited {proc.returncode}: {detail}") + return proc + + +def resolve_commit(repo: str, rev: str) -> str | None: + if not rev or rev.startswith("-"): + return None + proc = git(repo, "rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}", ok=(0, 1, 128)) + return proc.stdout.strip() if proc.returncode == 0 and proc.stdout.strip() else None + + +def resolve_branch(repo: str, ref: str, remote: str) -> tuple[str | None, str]: + match = PULL_REF_RE.match(ref) + if not match: + sha = resolve_commit(repo, ref) + return sha, "" if sha else f"ref {ref!r} does not resolve to a commit" + number = match.group(1) + local = f"{FETCHED_PULL_NAMESPACE}/{number}" + fetched = git(repo, "fetch", "--quiet", "--no-tags", remote, + f"+refs/pull/{number}/head:{local}", ok=(0, 1, 128)) + if fetched.returncode != 0: + detail = fetched.stderr.strip() or "no output" + return None, f"could not fetch refs/pull/{number}/head from {remote!r}: {detail}" + sha = resolve_commit(repo, local) + return sha, "" if sha else f"fetched refs/pull/{number}/head but {local} does not resolve" + + +def parse_raw_z(output: str) -> list[tuple[str, str, str, str, str]]: + tokens = output.split("\0") + records = [] + i = 0 + while i < len(tokens): + head = tokens[i].lstrip("\n") + if head.startswith(":") and i + 1 < len(tokens): + old_mode, new_mode, old_oid, new_oid = head[1:].split()[:4] + records.append((tokens[i + 1], old_mode, new_mode, old_oid, new_oid)) + i += 2 + else: + i += 1 + return records + + +def file_state(mode: str, oid: str) -> str: + return ABSENT if set(oid) == {"0"} else f"{mode}:{oid}" + + +def raw_diff(repo: str, old: str, new: str) -> list[tuple[str, str, str, str, str]]: + return parse_raw_z(git(repo, "diff", "--raw", "-z", "--no-renames", "--no-abbrev", old, new).stdout) + + +def count_files_behind(repo: str, base: str, merge_base: str, differing: dict[str, str]) -> int: + history: dict[str, set[str]] = {} + log = git(repo, "log", "--raw", "-z", "-m", "--no-renames", "--no-abbrev", "--format=", f"{merge_base}..{base}").stdout + for path, old_mode, new_mode, old, new in parse_raw_z(log): + history.setdefault(path, set()).update({file_state(old_mode, old), file_state(new_mode, new)}) + return sum(1 for path, state in differing.items() if state in history.get(path, set())) + + +def changed_lines_by_file(diff_text: str) -> dict[str, tuple[list[str], list[str]]]: + out: dict[str, tuple[list[str], list[str]]] = {} + old_path: str | None = None + current: str | None = None + for raw in diff_text.splitlines(): + if raw.startswith("diff --git "): + old_path = current = None + elif raw.startswith("--- ") and current is None: + old_path = raw[6:] if raw.startswith("--- a/") else None + elif raw.startswith("+++ ") and current is None: + current = raw[6:] if raw.startswith("+++ b/") else old_path + if current is not None: + out.setdefault(current, ([], [])) + elif raw.startswith("@@"): + continue + elif current is not None and raw.startswith("+"): + out[current][0].append(raw[1:]) + elif current is not None and raw.startswith("-"): + out[current][1].append(raw[1:]) + return out + + +def branch_change_on_base(repo: str, base: str, branch: str, merge_base: str, + differing: dict[str, str]) -> tuple[int, int, int]: + own = {path: (om, nm) for path, om, nm, _oo, _no in raw_diff(repo, merge_base, branch)} + binary = set() + for entry in git(repo, "diff", "--numstat", "-z", "--no-renames", merge_base, branch).stdout.split("\0"): + parts = entry.split("\t", 2) + if len(parts) == 3 and parts[0] == "-": + binary.add(parts[2]) + diff = git(repo, "-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--no-ext-diff", + "--no-renames", "--src-prefix=a/", "--dst-prefix=b/", merge_base, branch).stdout + lines_by_file = changed_lines_by_file(diff) + unreadable = sum(1 for path in lines_by_file if path not in own) + missing = still = 0 + for path, (old_mode, new_mode) in own.items(): + if path not in differing: + continue + mode_only = old_mode != new_mode and "000000" not in (old_mode, new_mode) + if path in binary or mode_only or path not in lines_by_file: + unreadable += 1 + continue + shown = git(repo, "cat-file", "blob", f"{base}:{path}", ok=(0, 128)) + present = {line.strip() for line in shown.stdout.splitlines()} if shown.returncode == 0 else set() + added, removed = lines_by_file[path] + missing += sum(1 for line in added if line.strip() and line.strip() not in present) + still += sum(1 for line in removed if line.strip() and line.strip() in present) + return missing, still, unreadable + + +def numstat_totals(repo: str, base: str, branch: str) -> tuple[int, int, int]: + additions = deletions = binary = 0 + for line in git(repo, "diff", "--numstat", "--no-renames", base, branch).stdout.splitlines(): + added, deleted, _path = line.split("\t", 2) + if added == "-" or deleted == "-": + binary += 1 + continue + additions += int(added) + deletions += int(deleted) + return additions, deletions, binary + + +def gather(repo: str, base: str, branch: str) -> tuple[Evidence | None, str]: + if git(repo, "rev-parse", "--is-shallow-repository").stdout.strip() == "true": + return None, "the clone is shallow, so commit counts and patch matching cannot see full history" + merge_base_proc = git(repo, "merge-base", base, branch, ok=(0, 1)) + merge_base = merge_base_proc.stdout.strip() + if merge_base_proc.returncode != 0 or not merge_base: + return None, "the branch and the base share no history" + behind, ahead = (int(n) for n in git(repo, "rev-list", "--left-right", "--count", f"{base}...{branch}").stdout.split()) + unique = sum(1 for line in git(repo, "cherry", base, branch).stdout.splitlines() if line.startswith("+")) + additions, deletions, binary = numstat_totals(repo, base, branch) + differing = {path: file_state(nm, new) for path, _om, nm, _old, new in raw_diff(repo, base, branch)} + missing, still, unreadable = branch_change_on_base(repo, base, branch, merge_base, differing) + evidence = Evidence( + ahead=ahead, + behind=behind, + unique_commits=unique, + additions=additions, + deletions=deletions, + binary_files=binary, + files_differ=len(differing), + files_behind=count_files_behind(repo, base, merge_base, differing), + added_lines_missing_from_base=missing, + removed_lines_still_on_base=still, + unreadable_branch_files=unreadable, + ) + return evidence, "" + + +def evaluate(repo: str, ref: str, base: str, remote: str) -> dict: + result = {"verdict": UNCHECKED, "ref": ref, "base": base, "reason": "", "numbers": None} + try: + base_sha = resolve_commit(repo, base) + if not base_sha: + result["reason"] = f"base {base!r} does not resolve; fetch it first" + return result + branch_sha, why = resolve_branch(repo, ref, remote) + if not branch_sha: + result["reason"] = why + return result + evidence, why = gather(repo, base_sha, branch_sha) + except GitError as exc: + result["reason"] = str(exc) + return result + except (OSError, ValueError) as exc: + result["reason"] = f"{type(exc).__name__}: {exc}" + return result + if evidence is None: + result["reason"] = why + return result + verdict, reason = classify(evidence) + numbers = asdict(evidence) + numbers["files_ahead"] = evidence.files_ahead + result.update(verdict=verdict, reason=reason, numbers=numbers) + return result + + +def render(result: dict) -> str: + lines = [f"{result['verdict']} {result['ref']} against {result['base']}", f" reason: {result['reason']}"] + n = result["numbers"] + if n is None: + lines.append(" numbers: not computed, so this is not a pass") + return "\n".join(lines) + lines += [ + f" commits ahead: {n['ahead']} ({n['unique_commits']} not on the base)", + f" commits behind: {n['behind']}", + f" net additions: {n['additions']}", + f" net deletions: {n['deletions']}", + f" files differ: {n['files_differ']} ({n['files_behind']} behind the base, {n['files_ahead']} ahead of it)", + f" binary files differ: {n['binary_files']}", + f" lines the branch adds that the base lacks: {n['added_lines_missing_from_base']}", + f" lines the branch removes that the base still has: {n['removed_lines_still_on_base']}", + f" branch files the line check cannot read: {n['unreadable_branch_files']}", + ] + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("ref", help="branch, commit, or pull ref ('#', 'pull/')") + ap.add_argument("--base", default="origin/main") + ap.add_argument("--remote", default="origin", help="remote to fetch pull refs from") + ap.add_argument("--repo", default=".", help="path to the git clone") + ap.add_argument("--json", action="store_true") + args = ap.parse_args(argv) + result = evaluate(args.repo, args.ref, args.base, args.remote) + print(json.dumps(result, indent=2) if args.json else render(result)) + if result["verdict"] == UNCHECKED: + print(f"UNCHECKED {result['reason']}", file=sys.stderr) + return EXIT_CODES[result["verdict"]] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_check_branch_not_superseded.py b/tests/test_check_branch_not_superseded.py new file mode 100644 index 0000000..d121041 --- /dev/null +++ b/tests/test_check_branch_not_superseded.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +SCRIPT = REPO / "scripts" / "check_branch_not_superseded.py" +sys.path.insert(0, str(REPO / "scripts")) + +import check_branch_not_superseded as gate # noqa: E402 +from git_test_repo import init_repo # noqa: E402 + +HERMETIC_ENV = dict( + os.environ, + GIT_CONFIG_GLOBAL=os.devnull, + GIT_CONFIG_NOSYSTEM="1", + GIT_AUTHOR_NAME="Fixture", + GIT_AUTHOR_EMAIL="fixture@example.com", + GIT_COMMITTER_NAME="Fixture", + GIT_COMMITTER_EMAIL="fixture@example.com", +) +EXPECTED_EXIT = {"LIVE": 0, "UNCHECKED": 2, "SUPERSEDED": 3} + + +def lines(tag: str, count: int = 30) -> str: + return "".join(f"{tag} line {i}\n" for i in range(count)) + + +class Fixture: + def __init__(self, root: Path): + self.root = root + init_repo(root, "-b", "main", env=HERMETIC_ENV) + + def git(self, *args: str) -> str: + return subprocess.run(["git", "-C", str(self.root), *args], check=True, capture_output=True, + text=True, env=HERMETIC_ENV).stdout.strip() + + def write(self, path: str, text: str) -> None: + target = self.root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text) + + def commit(self, message: str, files: dict[str, str | None]) -> str: + for path, text in files.items(): + if text is None: + self.git("rm", "-q", path) + else: + self.write(path, text) + self.git("add", path) + self.git("commit", "-q", "-m", message) + return self.git("rev-parse", "HEAD") + + def seed(self, count: int = 10) -> None: + self.commit("seed", {f"docs/f{i}.md": lines(f"f{i}") for i in range(count)}) + + def base_moves_on(self, count: int = 10) -> None: + self.git("checkout", "-q", "main") + for i in range(1, count): + self.commit(f"grow f{i}", {f"docs/f{i}.md": lines(f"f{i}") + lines(f"grow{i}", 20)}) + self.commit("add g", {"docs/g.md": lines("g", 60)}) + + def run(self, ref: str, *extra: str) -> tuple[int, dict]: + proc = subprocess.run([sys.executable, str(SCRIPT), ref, "--base", "main", "--repo", str(self.root), + "--json", *extra], capture_output=True, text=True, env=HERMETIC_ENV) + return proc.returncode, json.loads(proc.stdout) + + +class FixtureCase(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.repo = Fixture(Path(self._tmp.name) / "repo") + self.repo.seed() + + def assertVerdict(self, ref: str, verdict: str, *extra: str) -> dict: + code, result = self.repo.run(ref, *extra) + self.assertEqual(result["verdict"], verdict, result) + self.assertEqual(code, EXPECTED_EXIT[verdict], result) + return result + + +class TestSuperseded(FixtureCase): + def test_branch_whose_commit_was_cherry_picked_then_base_moved_on(self): + self.repo.git("checkout", "-q", "-b", "topic") + landed = self.repo.commit("topic change", {"docs/f0.md": lines("f0") + "topic line\n"}) + self.repo.git("checkout", "-q", "main") + self.repo.commit("unrelated", {"docs/h.md": "h\n"}) + self.repo.git("cherry-pick", landed) + self.repo.base_moves_on() + listed = self.repo.git("diff", "--name-only", "main..topic").splitlines() + self.assertGreaterEqual(len(listed), 10) + result = self.assertVerdict("topic", gate.SUPERSEDED) + n = result["numbers"] + self.assertEqual((n["ahead"], n["unique_commits"]), (1, 0)) + self.assertEqual(n["behind"], 12) + self.assertEqual(n["files_differ"], len(listed)) + self.assertEqual((n["files_behind"], n["files_ahead"]), (len(listed), 0)) + self.assertGreater(n["deletions"], n["additions"]) + self.assertIn("git cherry shows no '+'", result["reason"]) + + def test_branch_the_base_already_contains(self): + self.repo.git("checkout", "-q", "-b", "topic") + self.repo.commit("topic change", {"docs/f0.md": lines("f0") + "topic line\n"}) + self.repo.git("checkout", "-q", "main") + self.repo.git("merge", "-q", "--no-ff", "-m", "merge topic", "topic") + self.repo.base_moves_on() + n = self.assertVerdict("topic", gate.SUPERSEDED)["numbers"] + self.assertEqual((n["ahead"], n["unique_commits"]), (0, 0)) + + def test_squash_landed_then_edited_on_base(self): + self.repo.git("checkout", "-q", "-b", "topic") + self.repo.commit("topic one", {"docs/f0.md": lines("f0") + "topic one\n"}) + self.repo.commit("topic two", {"docs/f0.md": lines("f0") + "topic one\ntopic two\n"}) + self.repo.git("checkout", "-q", "main") + self.repo.git("merge", "-q", "--squash", "topic") + self.repo.git("commit", "-q", "-m", "squash topic") + self.repo.commit("reword", {"docs/f0.md": lines("f0") + "topic one, reworded\ntopic two\n"}) + self.repo.base_moves_on() + result = self.assertVerdict("topic", gate.SUPERSEDED) + n = result["numbers"] + self.assertEqual(n["unique_commits"], 2) + self.assertEqual(n["files_ahead"], 0) + self.assertEqual(n["added_lines_missing_from_base"], 1) + self.assertIn("every file that differs is behind the base", result["reason"]) + + def test_squash_landed_beside_a_concurrent_edit_to_the_same_file(self): + self.repo.git("checkout", "-q", "-b", "topic") + self.repo.commit("topic one", {"docs/f0.md": "topic head\n" + lines("f0")}) + self.repo.commit("topic two", {"docs/f0.md": "topic head\ntopic second\n" + lines("f0")}) + self.repo.git("checkout", "-q", "main") + self.repo.commit("tail edit", {"docs/f0.md": lines("f0") + "base tail\n"}) + self.repo.commit("squash topic", {"docs/f0.md": "topic head\ntopic second\n" + lines("f0") + "base tail\n"}) + self.repo.base_moves_on() + result = self.assertVerdict("topic", gate.SUPERSEDED) + n = result["numbers"] + self.assertEqual(n["unique_commits"], 2) + self.assertGreater(n["files_ahead"], 0) + self.assertEqual((n["added_lines_missing_from_base"], n["removed_lines_still_on_base"]), (0, 0)) + self.assertIn("own change is already on the base", result["reason"]) + + +class TestLive(FixtureCase): + def test_branch_with_a_new_commit_on_an_unmoved_base(self): + self.repo.git("checkout", "-q", "-b", "topic") + self.repo.commit("topic change", {"docs/new.md": lines("new", 12)}) + n = self.assertVerdict("topic", gate.LIVE)["numbers"] + self.assertEqual((n["ahead"], n["behind"], n["unique_commits"]), (1, 0, 1)) + self.assertEqual((n["additions"], n["deletions"]), (12, 0)) + self.assertEqual((n["files_differ"], n["files_ahead"]), (1, 1)) + + def test_branch_both_ahead_and_far_behind_is_live(self): + self.repo.git("checkout", "-q", "-b", "topic") + self.repo.commit("topic change", {"docs/new.md": lines("new", 3)}) + self.repo.base_moves_on() + n = self.assertVerdict("topic", gate.LIVE)["numbers"] + self.assertEqual((n["ahead"], n["behind"], n["unique_commits"]), (1, 10, 1)) + self.assertGreater(n["deletions"], n["additions"]) + self.assertEqual(n["files_ahead"], 1) + self.assertEqual(n["added_lines_missing_from_base"], 3) + + def test_branch_that_only_deletes_while_far_behind_is_live(self): + self.repo.git("checkout", "-q", "-b", "topic") + self.repo.commit("drop f0", {"docs/f0.md": None}) + self.repo.base_moves_on() + n = self.assertVerdict("topic", gate.LIVE)["numbers"] + self.assertEqual(n["additions"], 0) + self.assertEqual(n["added_lines_missing_from_base"], 0) + self.assertEqual(n["removed_lines_still_on_base"], 30) + self.assertEqual(n["files_ahead"], 1) + + def test_mode_only_change_while_far_behind_is_live(self): + self.repo.git("checkout", "-q", "-b", "topic") + (self.repo.root / "docs/f0.md").chmod(0o755) + self.repo.git("add", "docs/f0.md") + self.repo.git("commit", "-q", "-m", "make f0 executable") + self.repo.base_moves_on() + n = self.assertVerdict("topic", gate.LIVE)["numbers"] + self.assertEqual(n["files_ahead"], 1) + self.assertEqual(n["unreadable_branch_files"], 1) + + +class TestUnchecked(FixtureCase): + def assertUnchecked(self, ref: str, reason: str, *extra: str) -> None: + result = self.assertVerdict(ref, gate.UNCHECKED, *extra) + self.assertIn(reason, result["reason"]) + self.assertIsNone(result["numbers"]) + + def test_unresolvable_ref(self): + self.assertUnchecked("no-such-branch", "does not resolve") + + def test_unfetched_base(self): + self.repo.git("checkout", "-q", "-b", "topic") + self.repo.commit("topic change", {"docs/new.md": "x\n"}) + self.assertUnchecked("topic", "fetch it first", "--base", "origin/main") + + def test_no_shared_history(self): + self.repo.git("checkout", "-q", "--orphan", "island") + self.repo.git("rm", "-rqf", ".") + self.repo.commit("island", {"island.md": "alone\n"}) + self.assertUnchecked("island", "share no history") + + def test_shallow_clone(self): + self.repo.git("checkout", "-q", "-b", "topic") + self.repo.commit("topic change", {"docs/new.md": "x\n"}) + shallow = Path(self._tmp.name) / "shallow" + subprocess.run(["git", "clone", "-q", "--depth", "1", "--no-single-branch", + f"file://{self.repo.root}", str(shallow)], check=True, capture_output=True, env=HERMETIC_ENV) + self.repo.root = shallow + self.assertUnchecked("origin/topic", "shallow", "--base", "origin/main") + + def test_ref_that_looks_like_an_option(self): + result = gate.evaluate(str(self.repo.root), "--output=/tmp/x", "main", "origin") + self.assertEqual(result["verdict"], gate.UNCHECKED) + self.assertIn("does not resolve", result["reason"]) + + def test_unchecked_never_shares_an_exit_code(self): + self.assertEqual(gate.EXIT_CODES, EXPECTED_EXIT) + self.assertEqual(len(set(gate.EXIT_CODES.values())), 3) + self.assertNotEqual(gate.EXIT_CODES[gate.UNCHECKED], gate.EXIT_CODES[gate.LIVE]) + self.assertNotEqual(gate.EXIT_CODES[gate.UNCHECKED], 1) + self.assertNotEqual(gate.EXIT_CODES[gate.SUPERSEDED], 1) + + def test_text_report_says_numbers_were_not_computed(self): + proc = subprocess.run([sys.executable, str(SCRIPT), "no-such-branch", "--base", "main", + "--repo", str(self.repo.root)], capture_output=True, text=True, env=HERMETIC_ENV) + self.assertEqual(proc.returncode, EXPECTED_EXIT[gate.UNCHECKED]) + self.assertIn("UNCHECKED", proc.stdout) + self.assertIn("not computed, so this is not a pass", proc.stdout) + self.assertIn("UNCHECKED", proc.stderr) + + +class TestPullRefs(FixtureCase): + def setUp(self): + super().setUp() + self.remote = Path(self._tmp.name) / "remote.git" + init_repo(self.remote, "--bare", env=HERMETIC_ENV) + self.repo.git("remote", "add", "origin", str(self.remote)) + self.repo.git("checkout", "-q", "-b", "topic") + self.repo.commit("topic change", {"docs/new.md": lines("new", 4)}) + self.repo.git("push", "-q", "origin", "topic:refs/pull/7/head", "main:main") + self.repo.git("checkout", "-q", "main") + self.repo.git("branch", "-D", "topic") + + def test_hash_number_fetches_the_pull_head(self): + for ref in ("#7", "pull/7", "refs/pull/7/head"): + with self.subTest(ref=ref): + n = self.assertVerdict(ref, gate.LIVE)["numbers"] + self.assertEqual((n["ahead"], n["unique_commits"], n["additions"]), (1, 1, 4)) + + def test_missing_pull_head_is_unchecked(self): + result = self.assertVerdict("#8", gate.UNCHECKED) + self.assertIn("could not fetch refs/pull/8/head", result["reason"]) + + +class TestReportFormat(FixtureCase): + def test_text_report_names_every_number(self): + self.repo.git("checkout", "-q", "-b", "topic") + self.repo.commit("topic change", {"docs/new.md": "x\n"}) + proc = subprocess.run([sys.executable, str(SCRIPT), "topic", "--base", "main", "--repo", str(self.repo.root)], + capture_output=True, text=True, env=HERMETIC_ENV) + self.assertEqual(proc.returncode, 0, proc.stderr) + for label in ("commits ahead: 1 (1 not on the base)", "commits behind: 0", "net additions: 1", + "net deletions: 0", "files differ: 1 (0 behind the base, 1 ahead of it)"): + self.assertIn(label, proc.stdout) + + +class TestExemplars(unittest.TestCase): + def test_catch_exemplars_are_superseded(self): + for ex in gate.PROMISED_CATCH: + with self.subTest(ex=ex): + self.assertTrue(gate.flags_exemplar(ex)) + + def test_allow_exemplars_are_live(self): + for ex in gate.PROMISED_ALLOW: + with self.subTest(ex=ex): + self.assertFalse(gate.flags_exemplar(ex)) + self.assertEqual(gate.classify(gate.exemplar_evidence(ex))[0], gate.LIVE) + + +if __name__ == "__main__": + unittest.main()