From 6e3c4363cbb58da983f1fc9a21fa81cb031fcc1b Mon Sep 17 00:00:00 2001 From: Alex Hochheiden Date: Mon, 21 Sep 2026 12:44:39 -0700 Subject: [PATCH] fix(decision): use the merge base as base_rev for GitHub pushes and pull requests (bug 2072920) GitHub reports the wrong base for a new branch (the null revision), a force push (the orphaned old tip) and a pull request (the current tip of its target), so `files_changed` lists files the push never touched. The Decision Task now uses the merge base of the head and the target branch, which for a push is the default branch. A force push to the default branch uses the merge base of its old and new tips. A fast forward push, or one whose merge base cannot be found, keeps its reported base. GitHub's compare API provides the merge base, authenticated with `GITHUB_TOKEN` when it is set. When the API fails, and for other hosts, git finds it instead. Refs #808 --- docs/reference/parameters.rst | 11 + src/taskgraph/decision.py | 26 + src/taskgraph/util/vcs.py | 368 ++++++++++++++ test/test_decision.py | 69 +++ test/test_util_vcs.py | 915 +++++++++++++++++++++++++++++++++- 5 files changed, 1387 insertions(+), 2 deletions(-) diff --git a/docs/reference/parameters.rst b/docs/reference/parameters.rst index ebb32eb3a..ac06b80d0 100644 --- a/docs/reference/parameters.rst +++ b/docs/reference/parameters.rst @@ -28,6 +28,17 @@ base_rev ~~~~~~~~ The previous revision before ``head_rev`` got merged into. This can be a short revision string. + For a ``github-push`` of a branch and for ``github-pull-request``, the + :term:`Decision Task` replaces the reported base with the merge base of + ``head_rev`` and the target branch, so ``files_changed`` covers only the + new commits. The target is ``base_ref`` for a pull request and the default + branch for a push. A push that only adds commits keeps its reported base, + and a force push to the default branch uses the merge base of its previous + and new tips. When no merge base can be found, the reported base is kept. + + Set the ``GITHUB_TOKEN`` environment variable in the :term:`Decision Task` + to raise the rate limit of the GitHub API requests this makes. + head_rev ~~~~~~~~ The revision to check out, this can be a short revision string. diff --git a/src/taskgraph/decision.py b/src/taskgraph/decision.py index 7d927ecd8..b45fb03af 100644 --- a/src/taskgraph/decision.py +++ b/src/taskgraph/decision.py @@ -153,6 +153,28 @@ def taskgraph_decision(options, parameters=None): ) +def derive_base_rev(repo, parameters): + """Return the base to use for a GitHub push or pull request, or ``None`` + to keep ``parameters["base_rev"]`` as reported.""" + tasks_for = parameters.get("tasks_for", "") + if tasks_for == "github-push": + # GitHub's event.before is not the base of a new or force pushed + # branch, see GitRepository.derive_base_rev. + return repo.derive_base_rev( + parameters["head_repository"], + parameters["head_rev"], + parameters["head_ref"], + parameters["base_rev"], + ) + if tasks_for.startswith("github-pull-request"): + return repo.derive_pull_request_base_rev( + parameters["base_repository"], + parameters["head_rev"], + parameters.get("base_ref"), + ) + return None + + def get_decision_parameters(graph_config, options): """ Load parameters from the command-line options for 'taskgraph decision'. @@ -188,6 +210,10 @@ def get_decision_parameters(graph_config, options): except UnicodeDecodeError: commit_message = "" + if base_rev := derive_base_rev(repo, parameters): + logger.info(f"Using {base_rev} as base_rev for {parameters['head_ref']}") + parameters["base_rev"] = base_rev + # Define default filter list, as most configurations shouldn't need # custom filters. parameters["files_changed"] = repo.get_changed_files( diff --git a/src/taskgraph/util/vcs.py b/src/taskgraph/util/vcs.py index fbb18f266..0d523d773 100644 --- a/src/taskgraph/util/vcs.py +++ b/src/taskgraph/util/vcs.py @@ -7,9 +7,14 @@ import os import re import subprocess +import time from abc import ABC, abstractmethod +from pathlib import Path from shutil import which from typing import Optional +from urllib.parse import quote + +import requests from taskgraph.util.path import ancestors @@ -18,6 +23,10 @@ logger = logging.getLogger(__name__) +class _GitHubCompareError(Exception): + pass + + class Repository(ABC): # Both mercurial and git use sha1 as revision identifiers. Luckily, both define # the same value as the null revision. @@ -215,6 +224,29 @@ def does_revision_exist_locally(self, revision: str) -> bool: If this function returns an unexpected value, then make sure the revision was fetched from the remote repository.""" + def derive_base_rev( + self, + head_repository: str, + head_rev: str, + head_ref: str, + base_rev: str, + ) -> Optional[str]: + """Return the base to use for a push of ``head_rev`` to ``head_ref`` + when the reported ``base_rev`` is not it, or ``None`` to keep + ``base_rev`` as reported. + + Only supported by Git; returns ``None`` for all other VCS types. + """ + return None + + def derive_pull_request_base_rev( + self, + base_repository: str, + head_rev: str, + base_ref: str, + ) -> Optional[str]: + return None + def get_note( self, note: str, @@ -396,6 +428,28 @@ def default_remote_name(self) -> str: _LS_REMOTE_PATTERN = re.compile(r"ref:\s+refs/heads/(?P\S+)\s+HEAD") + # A fetch against GitHub takes a few seconds here. A failed derivation only + # falls back to the reported base, so a stalled transfer must not eat the + # decision task's time budget. + FETCH_TIMEOUT = 120 + DERIVE_TIMEOUT = 300 + # Enough history to find the reported base under a normal push without + # fetching the whole branch. The check is repeated with more history when + # this misses, so this depth only saves a fetch. + PROBE_DEPTH = 50 + MERGE_BASE_DEPTHS = (200, 800, 3200, 12800) + _GITHUB_URL = re.compile(r"https://github\.com/(?P[^/]+/[^/]+?)(?:\.git)?/?$") + # Each network call gets FETCH_TIMEOUT or what is left of the + # DERIVE_TIMEOUT budget, whichever is shorter. Outside a derivation the + # deadline is infinite, so only FETCH_TIMEOUT applies. + _deadline = float("inf") + + def _timeout(self): + remaining = self._deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired("derive base_rev", self.DERIVE_TIMEOUT) + return min(self.FETCH_TIMEOUT, remaining) + @property def is_shallow(self): return self.run("rev-parse", "--is-shallow-repository").strip() == "true" @@ -602,6 +656,320 @@ def does_revision_exist_locally(self, revision): return False raise + def _default_branch(self, repository): + output = self.run( + "ls-remote", "--symref", repository, "HEAD", timeout=self._timeout() + ) + if not (matches := self._LS_REMOTE_PATTERN.search(output)): + return None, None + tip = next( + ( + line.split()[0] + for line in output.splitlines() + if not line.startswith("ref:") + ), + None, + ) + return f"refs/heads/{matches['branch_name']}", tip + + def _github_compare(self, repository, base, head): + if not (match := self._GITHUB_URL.match(repository)): + raise _GitHubCompareError(f"{repository} is not on GitHub") + headers = {"Accept": "application/vnd.github+json"} + if token := os.environ.get("GITHUB_TOKEN"): + headers["Authorization"] = f"Bearer {token}" + try: + response = requests.get( + f"https://api.github.com/repos/{match['repo']}/compare/{quote(base)}...{head}", + params={"per_page": 1}, + headers=headers, + timeout=self._timeout(), + ) + response.raise_for_status() + compare = response.json() + if not (compare["status"] and compare["merge_base_commit"]["sha"]): + raise ValueError("no status or merge base") + return compare + except (requests.RequestException, KeyError, TypeError, ValueError) as e: + raise _GitHubCompareError( + f"GitHub compare of {base}...{head} failed: {e}" + ) from e + + def _github_base_rev(self, repository, head_rev, head_ref, base_rev, branch_ref): + # A push has no target of its own, so it targets the default branch. + if branch_ref is None: + compare = None + if base_rev: + compare = self._github_compare(repository, base_rev, head_rev) + # The push only added commits on top of the reported base, which + # is then the right base. + if compare["status"] in ("ahead", "identical"): + self._ensure_commit(repository, base_rev) + return None + branch_ref, _ = self._default_branch(repository) + if not branch_ref: + return None + # A push to the default branch that rewrote it uses the merge base of + # its old and new tips. A new default branch keeps the reported base. + if head_ref == branch_ref: + return compare and self._ensure_commit( + repository, compare["merge_base_commit"]["sha"] + ) + # Otherwise the base is the merge base of the head and the target branch. + branch = branch_ref.removeprefix("refs/heads/") + compare = self._github_compare(repository, branch, head_rev) + return self._ensure_commit(repository, compare["merge_base_commit"]["sha"]) + + def _ensure_commit(self, repository, rev): + if not self._has_commit(rev): + self._fetch(repository, rev, "--depth=1") + return rev + + def _has_commit(self, rev): + return bool( + self.run( + "cat-file", "-t", rev, return_codes=[128], stderr=subprocess.DEVNULL + ) + ) + + def _resolve(self, rev): + return ( + self.run( + "rev-parse", + "--verify", + "--quiet", + f"{rev}^{{commit}}", + return_codes=[1], + ).strip() + or rev + ) + + def _remote_tip(self, repository, ref): + output = self.run("ls-remote", repository, ref, timeout=self._timeout()).split() + return output[0] if output else None + + def _is_ancestor(self, rev, descendant): + # merge-base exits 1 without shared history and 128 when rev is + # unknown locally. Both mean rev is not an ancestor we can see. + merge_base = self.run( + "merge-base", + rev, + descendant, + return_codes=[1, 128], + stderr=subprocess.DEVNULL, + ).strip() + return merge_base.startswith(rev) + + def _fetch(self, repository, refspec, *options): + remote = self.default_remote_name + if ( + self.run( + "remote", "get-url", remote, return_codes=[2], stderr=subprocess.DEVNULL + ).strip() + == repository + ): + repository = remote + timeout = self._timeout() + try: + self.run("fetch", *options, repository, refspec, timeout=timeout) + except subprocess.TimeoutExpired: + lock = self.run("rev-parse", "--git-path", "shallow.lock").strip() + Path(self.path, lock).unlink(missing_ok=True) + raise + + def _shallow_commits(self): + shallow = Path( + self.path, self.run("rev-parse", "--git-path", "shallow").strip() + ) + return shallow.read_text().split() if shallow.exists() else [] + + def _fetch_missing_shallow_commits(self, repository): + if not (commits := self._shallow_commits()): + return + found = self.run("cat-file", "--batch-check", input="\n".join(commits) + "\n") + for line in found.splitlines(): + if line.endswith(" missing"): + self._fetch(repository, line.split()[0], "--depth=1") + + def _fetch_excluding(self, repository, rev, ref): + try: + self._fetch(repository, rev, f"--shallow-exclude={ref}") + except subprocess.CalledProcessError: + return None + tip = self.run("rev-parse", "FETCH_HEAD").strip() + self._fetch_missing_shallow_commits(repository) + return tip + + def _graft_root_parents(self, rev): + """Return the parents recorded in the raw commit objects of the graft + roots below ``rev``. + + After an excluding fetch, a single parent is the fork point. Several + mean the fetched history crosses the shallow boundary through more + than one parent, as after a merge from the excluded branch.""" + parents = set() + for root in self.run("rev-list", "--max-parents=0", rev).split(): + for line in self.run("cat-file", "-p", root, errors="replace").splitlines(): + if not line: + break + if line.startswith("parent "): + parents.add(line.split()[1]) + return parents + + def _merge_base(self, repository, head_rev, target): + if not self.is_shallow: + if target.startswith("refs/") or not self._has_commit(target): + self._fetch(repository, target) + target = "FETCH_HEAD" + return ( + self.run("merge-base", target, head_rev, return_codes=[1]).strip() + or None + ) + for depth in self.MERGE_BASE_DEPTHS: + self._fetch(repository, head_rev, f"--depth={depth}") + self._fetch(repository, target, f"--depth={depth}") + tip = self.run("rev-parse", "FETCH_HEAD").strip() + merge_base = self.run("merge-base", tip, head_rev, return_codes=[1]).strip() + if merge_base and not set(self._shallow_commits()).intersection( + self.run("rev-list", tip, head_rev, "--not", merge_base).split() + ): + return merge_base + if not merge_base and not self.is_shallow: + break + logger.warning(f"No merge base of {head_rev} and {target} found") + return None + + def _contains(self, repository, head_rev, rev, branch_ref, complete): + """Return True when ``rev`` is an ancestor of ``head_rev``, False when + it is not, and None when a shallow clone cannot tell.""" + if self._is_ancestor(rev, head_rev): + return True + if not self.is_shallow: + return False + if complete and self._fetch_excluding(repository, rev, branch_ref): + return False + if merge_base := self._merge_base(repository, head_rev, rev): + return merge_base.startswith(rev) + return None if self.is_shallow else False + + def derive_base_rev(self, head_repository, head_rev, head_ref, base_rev): + """Return the base to use for a push of ``head_rev`` to ``head_ref``, or + ``None`` to keep ``base_rev`` as reported. + + A fast forward push keeps ``base_rev``. A push to the default branch keeps + it unless the push rewrote the branch, which makes the base the merge base + of ``base_rev`` and ``head_rev``. Otherwise the base is the merge base of + ``head_rev`` and the default branch. GitHub's compare API answers when it + can, and Git finds the merge base when it cannot.""" + if not head_ref.startswith("refs/heads/"): + return None + return self._try_derive_base_rev( + head_repository, head_rev, head_ref=head_ref, base_rev=base_rev + ) + + def derive_pull_request_base_rev(self, base_repository, head_rev, base_ref): + """Return the merge base of ``head_rev`` and the target branch + ``base_ref``, or ``None`` to keep the reported base.""" + if not base_ref: + return None + branch_ref = f"refs/heads/{base_ref.removeprefix('refs/heads/')}" + return self._try_derive_base_rev( + base_repository, head_rev, branch_ref=branch_ref + ) + + def _try_derive_base_rev( + self, repository, head_rev, head_ref=None, base_rev=None, branch_ref=None + ): + self._deadline = time.monotonic() + self.DERIVE_TIMEOUT + try: + return self._derive_base_rev( + repository, + head_rev, + head_ref=head_ref, + base_rev=base_rev, + branch_ref=branch_ref, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + logger.warning(f"Cannot derive base_rev for {head_rev}: {e}") + return None + finally: + del self._deadline + + def _derive_base_rev( + self, repository, head_rev, head_ref=None, base_rev=None, branch_ref=None + ): + shallow = self.is_shallow + head_rev = self._resolve(head_rev) + if base_rev == self.NULL_REVISION: + base_rev = None + if base_rev: + base_rev = self._resolve(base_rev) + + if self._GITHUB_URL.match(repository): + try: + return self._github_base_rev( + repository, head_rev, head_ref, base_rev, branch_ref + ) + except _GitHubCompareError as e: + logger.info(f"{e}, using git") + + # A normal push adds commits on top of the reported base. Look for it + # in recent history first, which is cheap. + if base_rev: + if shallow: + self._fetch(repository, head_rev, f"--depth={self.PROBE_DEPTH}") + if self._is_ancestor(base_rev, head_rev): + return None + + branch_tip = None + if branch_ref is None: + branch_ref, branch_tip = self._default_branch(repository) + if not branch_ref: + return None + + if head_ref == branch_ref: + # A push to the default branch reports its previous tip, which is + # the right base unless the push rewrote it. + if not base_rev: + return None + return self._merge_base(repository, head_rev, base_rev) + + fork_point = None + unrelated = False + if shallow: + # Fetch the pushed commits and nothing else. This fails when + # head_rev is already on the branch. + if self._fetch_excluding(repository, head_rev, branch_ref): + parents = self._graft_root_parents(head_rev) + unrelated = not parents + if len(parents) == 1: + (fork_point,) = parents + elif (branch_tip or self._remote_tip(repository, branch_ref)) == head_rev: + fork_point = head_rev + elif ( + head_ref + and (tip := self._fetch_excluding(repository, branch_ref, head_ref)) + and head_rev in self._graft_root_parents(tip) + ): + fork_point = head_rev + complete = fork_point is not None + else: + complete = False + + if unrelated: + return None + + # A normal push of more than PROBE_DEPTH commits ends here. + if base_rev and ( + self._contains(repository, head_rev, base_rev, branch_ref, complete) + is not False + ): + return None + + if fork_point is None: + return self._merge_base(repository, head_rev, branch_ref) + return self._ensure_commit(repository, fork_point) + def get_note( self, note: str, diff --git a/test/test_decision.py b/test/test_decision.py index 677d9a9b1..877705fdb 100644 --- a/test/test_decision.py +++ b/test/test_decision.py @@ -19,6 +19,16 @@ FAKE_GRAPH_CONFIG = {"product-dir": "browser", "taskgraph": {}} +@pytest.fixture(autouse=True) +def keep_reported_base_rev(monkeypatch): + """The tests run against this checkout, so deriving a base would fetch + from the fake repositories.""" + monkeypatch.setattr(GitRepository, "derive_base_rev", lambda self, *args: None) + monkeypatch.setattr( + GitRepository, "derive_pull_request_base_rev", lambda self, *args: None + ) + + class TestDecision(unittest.TestCase): def test_write_artifact_json(self): data = [{"some": "data"}] @@ -198,3 +208,62 @@ def test_decision_parameters_note_invalid_json(mock_files_changed, mock_get_note Exception, match="Failed to parse refs/notes/decision-parameters as JSON" ): decision.get_decision_parameters(FAKE_GRAPH_CONFIG, options) + + +@unittest.mock.patch.object(GitRepository, "derive_base_rev", return_value="cccc") +@unittest.mock.patch.object(GitRepository, "get_changed_files", return_value=[]) +def test_decision_parameters_github_push_derives_base_rev( + mock_files_changed, mock_derive_base_rev +): + params = decision.get_decision_parameters(FAKE_GRAPH_CONFIG, _BASE_OPTIONS) + mock_derive_base_rev.assert_called_once_with( + _BASE_OPTIONS["head_repository"], "bbbb", "default", "aaaa" + ) + mock_files_changed.assert_called_once_with(rev="bbbb", base="cccc") + assert params["base_rev"] == "cccc" + + +@unittest.mock.patch.object(GitRepository, "derive_base_rev", return_value=None) +@unittest.mock.patch.object(GitRepository, "get_changed_files", return_value=[]) +def test_decision_parameters_github_push_keeps_reported_base_rev( + mock_files_changed, mock_derive_base_rev +): + params = decision.get_decision_parameters(FAKE_GRAPH_CONFIG, _BASE_OPTIONS) + mock_derive_base_rev.assert_called_once() + mock_files_changed.assert_called_once_with(rev="bbbb", base="aaaa") + assert params["base_rev"] == "aaaa" + + +@pytest.mark.parametrize( + "tasks_for", ("github-pull-request", "github-pull-request-untrusted") +) +@unittest.mock.patch.object( + GitRepository, "derive_pull_request_base_rev", return_value="cccc" +) +@unittest.mock.patch.object(GitRepository, "derive_base_rev", return_value="dddd") +@unittest.mock.patch.object(GitRepository, "get_changed_files", return_value=[]) +def test_decision_parameters_pull_request_derives_base_rev( + mock_files_changed, mock_derive_base_rev, mock_derive_pr_base_rev, tasks_for +): + options = {**_BASE_OPTIONS, "tasks_for": tasks_for, "base_ref": "main"} + params = decision.get_decision_parameters(FAKE_GRAPH_CONFIG, options) + mock_derive_base_rev.assert_not_called() + mock_derive_pr_base_rev.assert_called_once_with( + _BASE_OPTIONS["base_repository"], "bbbb", "main" + ) + mock_files_changed.assert_called_once_with(rev="bbbb", base="cccc") + assert params["base_rev"] == "cccc" + + +@unittest.mock.patch.object( + GitRepository, "derive_pull_request_base_rev", return_value=None +) +@unittest.mock.patch.object(GitRepository, "get_changed_files", return_value=[]) +def test_decision_parameters_pull_request_keeps_reported_base_rev( + mock_files_changed, mock_derive_pr_base_rev +): + options = {**_BASE_OPTIONS, "tasks_for": "github-pull-request", "base_ref": "main"} + params = decision.get_decision_parameters(FAKE_GRAPH_CONFIG, options) + mock_derive_pr_base_rev.assert_called_once() + mock_files_changed.assert_called_once_with(rev="bbbb", base="aaaa") + assert params["base_rev"] == "aaaa" diff --git a/test/test_util_vcs.py b/test/test_util_vcs.py index 0d36c21f5..a7bf4ab0f 100644 --- a/test/test_util_vcs.py +++ b/test/test_util_vcs.py @@ -7,10 +7,18 @@ import subprocess from pathlib import Path from textwrap import dedent +from types import SimpleNamespace import pytest - -from taskgraph.util.vcs import HgRepository, Repository, get_repository +import requests + +from taskgraph.util.vcs import ( + GitRepository, + HgRepository, + Repository, + _GitHubCompareError, + get_repository, +) from .conftest import nowin @@ -671,3 +679,906 @@ def test_get_note_git(git_repo, tmpdir): assert repo.get_note("try-config", src_path) == "test note" assert repo.get_note("try-config", src_path, revision=rev) == "test note" assert repo.get_note("other", src_path) is None + + +PUSH_BRANCH = "topic" + + +def git(cwd, *args): + return subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + encoding="utf-8", + ).stdout.strip() + + +@pytest.fixture +def push_origin(tmp_path): + """A repository whose `main` moved on after `topic` branched off. + + `old_tip` is an earlier tip of that branch, `previous` a rebuilt stack that + a force push orphaned (still served by sha, as GitHub does), `merge_tip` a + stack that merged `main` back in and `on_main` a branch pointing at the tip + of `main`.""" + path = tmp_path / "origin" + path.mkdir() + git(path, "init", "-q") + git(path, "symbolic-ref", "HEAD", "refs/heads/main") + git(path, "config", "user.name", "Test") + git(path, "config", "user.email", "test@example.com") + git(path, "config", "uploadpack.allowAnySHA1InWant", "true") + + (path / "a.txt").write_bytes(b"a\n") + (path / "b.txt").write_bytes(b"b\n") + git(path, "add", ".") + git(path, "commit", "-qm", "base1") + (path / "a.txt").write_bytes(b"a2\n") + git(path, "commit", "-qam", "base2") + base = git(path, "rev-parse", "HEAD") + (path / "m.txt").write_bytes(b"m\n") + git(path, "add", ".") + git(path, "commit", "-qm", "main3") + main_rev = git(path, "rev-parse", "HEAD") + git(path, "branch", "on_main", main_rev) + + git(path, "checkout", "-qb", "previous", base) + (path / "p.txt").write_bytes(b"p\n") + git(path, "add", ".") + git(path, "commit", "-qm", "previous stack") + previous = git(path, "rev-parse", "HEAD") + + git(path, "checkout", "-qb", PUSH_BRANCH, base) + (path / "c.txt").write_bytes(b"c\n") + git(path, "add", ".") + git(path, "commit", "-qm", "stack1") + old_tip = git(path, "rev-parse", "HEAD") + (path / "b.txt").write_bytes(b"b\nd\n") + git(path, "commit", "-qam", "stack2") + head = git(path, "rev-parse", "HEAD") + + git(path, "checkout", "-qb", "merged", head) + git(path, "merge", "-q", "-m", "merge main", "main") + merge_tip = git(path, "rev-parse", "HEAD") + git(path, "checkout", "-q", "main") + git(path, "branch", "-qD", "previous") + + return SimpleNamespace( + path=path, + url=path.as_uri(), + base=base, + head=head, + old_tip=old_tip, + previous=previous, + main_rev=main_rev, + merge_tip=merge_tip, + ) + + +def shallow_checkout(tmp_path, origin, branch, head, base_rev=None): + """The checkout `run-task` produces for a shallow clone when + `_BASE_REV` is `base_rev` (unset or the null revision when None).""" + checkout = tmp_path / "checkout" + git( + tmp_path, "clone", "-q", "--depth=1", "--no-checkout", origin.url, str(checkout) + ) + if base_rev: + git(checkout, "fetch", "-q", "--depth=1", origin.url, base_rev) + git(checkout, "fetch", "-q", "--depth=1", origin.url, head) + git(checkout, "checkout", "-qf", "-B", branch, head) + return get_repository(str(checkout)) + + +def test_derive_base_rev_new_branch(tmp_path, push_origin): + repo = shallow_checkout(tmp_path, push_origin, PUSH_BRANCH, push_origin.head) + head_ref = f"refs/heads/{PUSH_BRANCH}" + + base = repo.derive_base_rev( + push_origin.url, push_origin.head, head_ref, Repository.NULL_REVISION + ) + + assert base == push_origin.base + assert repo.get_changed_files(rev=push_origin.head, base=base) == ["b.txt", "c.txt"] + + +def test_derive_base_rev_force_push(tmp_path, push_origin): + repo = shallow_checkout( + tmp_path, push_origin, PUSH_BRANCH, push_origin.head, push_origin.previous + ) + head_ref = f"refs/heads/{PUSH_BRANCH}" + + base = repo.derive_base_rev( + push_origin.url, push_origin.head, head_ref, push_origin.previous + ) + + assert base == push_origin.base + assert repo.get_changed_files(rev=push_origin.head, base=base) == ["b.txt", "c.txt"] + + +@pytest.mark.parametrize("abbreviate", (False, True)) +def test_derive_base_rev_fast_forward_push_keeps_base_rev( + tmp_path, push_origin, abbreviate, monkeypatch +): + repo = shallow_checkout( + tmp_path, push_origin, PUSH_BRANCH, push_origin.head, push_origin.old_tip + ) + monkeypatch.setattr(repo, "_default_branch", lambda *args: pytest.fail("looked up")) + head_ref = f"refs/heads/{PUSH_BRANCH}" + base_rev = push_origin.old_tip[:12] if abbreviate else push_origin.old_tip + + assert ( + repo.derive_base_rev(push_origin.url, push_origin.head, head_ref, base_rev) + is None + ) + + +def test_derive_base_rev_fast_forward_push_longer_than_probe( + tmp_path, push_origin, monkeypatch +): + monkeypatch.setattr(GitRepository, "PROBE_DEPTH", 1) + repo = shallow_checkout( + tmp_path, push_origin, PUSH_BRANCH, push_origin.head, push_origin.old_tip + ) + head_ref = f"refs/heads/{PUSH_BRANCH}" + + assert ( + repo.derive_base_rev( + push_origin.url, push_origin.head, head_ref, push_origin.old_tip + ) + is None + ) + + +def test_derive_base_rev_fast_forward_over_default_branch_commits( + tmp_path, push_origin, monkeypatch +): + monkeypatch.setattr(GitRepository, "PROBE_DEPTH", 1) + git(push_origin.path, "checkout", "-qb", "tracking", push_origin.main_rev) + (push_origin.path / "t.txt").write_bytes(b"t\n") + git(push_origin.path, "add", ".") + git(push_origin.path, "commit", "-qm", "tracking") + head = git(push_origin.path, "rev-parse", "HEAD") + git(push_origin.path, "checkout", "-q", "main") + repo = shallow_checkout(tmp_path, push_origin, "tracking", head, push_origin.base) + + assert ( + repo.derive_base_rev( + push_origin.url, head, "refs/heads/tracking", push_origin.base + ) + is None + ) + + +def test_derive_base_rev_force_push_from_default_branch_commit(tmp_path, push_origin): + repo = shallow_checkout( + tmp_path, push_origin, PUSH_BRANCH, push_origin.head, push_origin.main_rev + ) + + base = repo.derive_base_rev( + push_origin.url, + push_origin.head, + f"refs/heads/{PUSH_BRANCH}", + push_origin.main_rev, + ) + + assert base == push_origin.base + assert repo.get_changed_files(rev=push_origin.head, base=base) == ["b.txt", "c.txt"] + + +def test_derive_base_rev_default_branch(tmp_path, push_origin, monkeypatch): + repo = shallow_checkout( + tmp_path, push_origin, "main", push_origin.main_rev, push_origin.base + ) + monkeypatch.setattr( + repo, "_fetch_excluding", lambda *args: pytest.fail("excluding fetch") + ) + + assert ( + repo.derive_base_rev( + push_origin.url, push_origin.main_rev, "refs/heads/main", push_origin.base + ) + is None + ) + + +@pytest.mark.parametrize("abbreviate", (False, True)) +def test_derive_base_rev_default_branch_force_push(tmp_path, push_origin, abbreviate): + repo = shallow_checkout( + tmp_path, push_origin, "main", push_origin.main_rev, push_origin.previous + ) + previous = push_origin.previous[:12] if abbreviate else push_origin.previous + + base = repo.derive_base_rev( + push_origin.url, push_origin.main_rev, "refs/heads/main", previous + ) + + assert base == push_origin.base + assert repo.get_changed_files(rev=push_origin.main_rev, base=base) == ["m.txt"] + + +def test_derive_base_rev_new_branch_at_default_branch_tip( + tmp_path, push_origin, monkeypatch +): + monkeypatch.setattr(GitRepository, "MERGE_BASE_DEPTHS", ()) + repo = shallow_checkout(tmp_path, push_origin, "on_main", push_origin.main_rev) + + base = repo.derive_base_rev( + push_origin.url, + push_origin.main_rev, + "refs/heads/on_main", + Repository.NULL_REVISION, + ) + + assert base == push_origin.main_rev + assert repo.get_changed_files(rev=push_origin.main_rev, base=base) == [] + + +def test_derive_base_rev_unknown_ancestry_keeps_base_rev( + tmp_path, push_origin, monkeypatch +): + monkeypatch.setattr(GitRepository, "PROBE_DEPTH", 1) + monkeypatch.setattr(GitRepository, "MERGE_BASE_DEPTHS", ()) + git(push_origin.path, "branch", "staging", push_origin.main_rev) + repo = shallow_checkout( + tmp_path, push_origin, "staging", push_origin.main_rev, push_origin.base + ) + + assert ( + repo.derive_base_rev( + push_origin.url, + push_origin.main_rev, + "refs/heads/staging", + push_origin.base, + ) + is None + ) + + +@pytest.mark.parametrize("reported", ("null", "main")) +def test_derive_base_rev_unrelated_history( + tmp_path, push_origin, monkeypatch, reported +): + base_rev = push_origin.main_rev if reported == "main" else Repository.NULL_REVISION + git(push_origin.path, "checkout", "-q", "--orphan", "lone") + git(push_origin.path, "rm", "-rqf", ".") + (push_origin.path / "z.txt").write_bytes(b"z\n") + git(push_origin.path, "add", ".") + git(push_origin.path, "commit", "-qm", "lone") + head = git(push_origin.path, "rev-parse", "HEAD") + git(push_origin.path, "checkout", "-qf", "main") + repo = shallow_checkout(tmp_path, push_origin, "lone", head) + monkeypatch.setattr(repo, "_merge_base", lambda *args: pytest.fail("deepened")) + + assert ( + repo.derive_base_rev(push_origin.url, head, "refs/heads/lone", base_rev) is None + ) + + +def test_derive_base_rev_keeps_partial_clone_filter(tmp_path, push_origin): + git(push_origin.path, "config", "uploadpack.allowFilter", "true") + git(push_origin.path, "checkout", "-qb", "partial", push_origin.base) + (push_origin.path / "x.txt").write_bytes(b"x1\n") + git(push_origin.path, "add", ".") + git(push_origin.path, "commit", "-qm", "x1") + blob = git(push_origin.path, "rev-parse", "HEAD:x.txt") + (push_origin.path / "x.txt").write_bytes(b"x2\n") + git(push_origin.path, "commit", "-qam", "x2") + head = git(push_origin.path, "rev-parse", "HEAD") + git(push_origin.path, "checkout", "-q", "main") + checkout = tmp_path / "checkout" + git( + tmp_path, + "clone", + "-q", + "--depth=1", + "--filter=blob:none", + "--no-checkout", + push_origin.url, + str(checkout), + ) + git(checkout, "fetch", "-q", "--depth=1", "origin", head) + repo = get_repository(str(checkout)) + + base = repo.derive_base_rev( + push_origin.url, head, "refs/heads/partial", Repository.NULL_REVISION + ) + + assert base == push_origin.base + missing = git(checkout, "rev-list", "--objects", "--missing=print", head) + assert f"?{blob}" in missing.splitlines() + + +def test_derive_base_rev_tag(tmp_path, push_origin): + git(push_origin.path, "tag", "v1", push_origin.head) + repo = shallow_checkout(tmp_path, push_origin, PUSH_BRANCH, push_origin.head) + + assert ( + repo.derive_base_rev( + push_origin.url, + push_origin.head, + "refs/tags/v1", + Repository.NULL_REVISION, + ) + is None + ) + + +def test_derive_base_rev_merge_from_default_branch(tmp_path, push_origin): + repo = shallow_checkout(tmp_path, push_origin, "merged", push_origin.merge_tip) + + base = repo.derive_base_rev( + push_origin.url, + push_origin.merge_tip, + "refs/heads/merged", + Repository.NULL_REVISION, + ) + + assert base == push_origin.main_rev + assert repo.get_changed_files(rev=push_origin.merge_tip, base=base) == [ + "b.txt", + "c.txt", + ] + + +def test_derive_base_rev_unreachable_repository(tmp_path, push_origin, caplog): + repo = shallow_checkout(tmp_path, push_origin, PUSH_BRANCH, push_origin.head) + missing = (tmp_path / "missing").as_uri() + + assert ( + repo.derive_base_rev( + missing, + push_origin.head, + f"refs/heads/{PUSH_BRANCH}", + Repository.NULL_REVISION, + ) + is None + ) + assert "Cannot derive base_rev" in caplog.text + + +def test_derive_base_rev_fetch_timeout(tmp_path, push_origin, monkeypatch, caplog): + repo = shallow_checkout(tmp_path, push_origin, PUSH_BRANCH, push_origin.head) + run = repo.run + + lock = Path(repo.path, ".git", "shallow.lock") + + def slow_run(*args, **kwargs): + if args[0] == "fetch": + lock.touch() + raise subprocess.TimeoutExpired(args, kwargs.get("timeout")) + return run(*args, **kwargs) + + monkeypatch.setattr(repo, "run", slow_run) + + assert ( + repo.derive_base_rev( + push_origin.url, + push_origin.head, + f"refs/heads/{PUSH_BRANCH}", + Repository.NULL_REVISION, + ) + is None + ) + assert "Cannot derive base_rev" in caplog.text + assert not lock.exists() + + +def test_derive_base_rev_deadline(tmp_path, push_origin, monkeypatch, caplog): + monkeypatch.setattr(GitRepository, "DERIVE_TIMEOUT", 0) + repo = shallow_checkout(tmp_path, push_origin, PUSH_BRANCH, push_origin.head) + run = repo.run + + def local_run(*args, **kwargs): + assert args[0] not in ("fetch", "ls-remote") + return run(*args, **kwargs) + + monkeypatch.setattr(repo, "run", local_run) + + assert ( + repo.derive_base_rev( + push_origin.url, + push_origin.head, + f"refs/heads/{PUSH_BRANCH}", + Repository.NULL_REVISION, + ) + is None + ) + assert "Cannot derive base_rev" in caplog.text + assert repo._deadline == float("inf") + + +def test_derive_base_rev_full_clone_stays_complete(tmp_path, push_origin): + clone = tmp_path / "clone" + git(tmp_path, "clone", "-q", "--branch", PUSH_BRANCH, push_origin.url, str(clone)) + repo = get_repository(str(clone)) + + base = repo.derive_base_rev( + push_origin.url, + push_origin.head, + f"refs/heads/{PUSH_BRANCH}", + Repository.NULL_REVISION, + ) + + assert base == push_origin.base + assert repo.get_changed_files(rev=push_origin.head, base=base) == ["b.txt", "c.txt"] + assert not repo.is_shallow + + +def test_derive_base_rev_new_branch_at_default_branch_commit( + tmp_path, push_origin, monkeypatch +): + monkeypatch.setattr(GitRepository, "MERGE_BASE_DEPTHS", ()) + git(push_origin.path, "branch", "old", push_origin.base) + repo = shallow_checkout(tmp_path, push_origin, "old", push_origin.base) + + base = repo.derive_base_rev( + push_origin.url, + push_origin.base, + "refs/heads/old", + Repository.NULL_REVISION, + ) + + assert base == push_origin.base + assert repo.get_changed_files(rev=push_origin.base, base=base) == [] + + +def test_derive_base_rev_fast_forward_along_default_branch( + tmp_path, push_origin, monkeypatch +): + monkeypatch.setattr(GitRepository, "PROBE_DEPTH", 1) + git(push_origin.path, "branch", "staging", push_origin.main_rev) + repo = shallow_checkout( + tmp_path, push_origin, "staging", push_origin.main_rev, push_origin.base + ) + + assert ( + repo.derive_base_rev( + push_origin.url, + push_origin.main_rev, + "refs/heads/staging", + push_origin.base, + ) + is None + ) + + +def test_derive_pull_request_base_rev_hidden_by_shallow_boundary(tmp_path, monkeypatch): + monkeypatch.setattr(GitRepository, "MERGE_BASE_DEPTHS", (3, 6, 100)) + origin = tmp_path / "origin" + origin.mkdir() + git(origin, "init", "-q") + git(origin, "symbolic-ref", "HEAD", "refs/heads/main") + git(origin, "config", "user.name", "Test") + git(origin, "config", "user.email", "test@example.com") + git(origin, "config", "uploadpack.allowAnySHA1InWant", "true") + + def commit(name): + (origin / f"{name}.txt").write_bytes(f"{name}\n".encode()) + git(origin, "add", ".") + git(origin, "commit", "-qm", name) + return git(origin, "rev-parse", "HEAD") + + commit("a0") + commit("A") + git(origin, "checkout", "-qb", "side") + commit("s1") + git(origin, "checkout", "-q", "main") + commit("B") + git(origin, "checkout", "-qb", "pr") + commit("h1") + git(origin, "checkout", "-q", "main") + y1 = commit("y1") + git(origin, "checkout", "-q", "pr") + git(origin, "merge", "-q", "-m", "merge main", "main") + head = commit("h2") + git(origin, "checkout", "-q", "main") + for name in ("y2", "y3", "y4", "y5"): + commit(name) + git(origin, "merge", "-q", "--no-ff", "-m", "merge side", "side") + commit("T") + repo = shallow_checkout(tmp_path, SimpleNamespace(url=origin.as_uri()), "pr", head) + + base = repo.derive_pull_request_base_rev(origin.as_uri(), head, "main") + + assert base == y1 + assert sorted(repo.get_changed_files(rev=head, base=base)) == ["h1.txt", "h2.txt"] + + +def test_derive_base_rev_non_utf8_commit_message(tmp_path, push_origin): + git(push_origin.path, "checkout", "-qb", "latin", push_origin.base) + (push_origin.path / "l.txt").write_bytes(b"l\n") + git(push_origin.path, "add", ".") + message = tmp_path / "message" + message.write_bytes("café\n".encode("latin1")) + git(push_origin.path, "-c", "i18n.commitEncoding=latin1", "commit", "-qF", message) + head = git(push_origin.path, "rev-parse", "HEAD") + git(push_origin.path, "checkout", "-q", "main") + repo = shallow_checkout(tmp_path, push_origin, "latin", head) + + base = repo.derive_base_rev( + push_origin.url, head, "refs/heads/latin", Repository.NULL_REVISION + ) + + assert base == push_origin.base + + +@pytest.fixture +def fork_pull_request(tmp_path, push_origin): + fork = tmp_path / "fork" + git(tmp_path, "clone", "-q", push_origin.url, str(fork)) + git(fork, "config", "user.name", "Test") + git(fork, "config", "user.email", "test@example.com") + git(fork, "checkout", "-qb", "feature", push_origin.base) + (fork / "e.txt").write_bytes(b"e\n") + git(fork, "add", ".") + git(fork, "commit", "-qm", "fork stack") + head = git(fork, "rev-parse", "HEAD") + git(fork, "push", "-q", push_origin.url, f"{head}:refs/pull/1/head") + return SimpleNamespace(url=fork.as_uri(), head=head) + + +@pytest.mark.parametrize( + "base_ref,abbreviate", + (("main", False), ("refs/heads/main", False), ("main", True)), +) +def test_derive_pull_request_base_rev_behind_base( + tmp_path, push_origin, base_ref, abbreviate +): + repo = shallow_checkout( + tmp_path, push_origin, PUSH_BRANCH, push_origin.head, push_origin.main_rev + ) + head = push_origin.head[:12] if abbreviate else push_origin.head + + base = repo.derive_pull_request_base_rev(push_origin.url, head, base_ref) + + assert base == push_origin.base + assert repo.get_changed_files(rev=push_origin.head, base=base) == ["b.txt", "c.txt"] + + +def test_derive_pull_request_base_rev_after_merge_from_target(tmp_path, push_origin): + (push_origin.path / "n.txt").write_bytes(b"n\n") + git(push_origin.path, "add", ".") + git(push_origin.path, "commit", "-qm", "main4") + tip = git(push_origin.path, "rev-parse", "HEAD") + repo = shallow_checkout(tmp_path, push_origin, "merged", push_origin.merge_tip, tip) + + base = repo.derive_pull_request_base_rev( + push_origin.url, push_origin.merge_tip, "main" + ) + + assert base == push_origin.main_rev + assert repo.get_changed_files(rev=push_origin.merge_tip, base=base) == [ + "b.txt", + "c.txt", + ] + + +def test_derive_pull_request_base_rev_from_fork( + tmp_path, push_origin, fork_pull_request +): + repo = shallow_checkout( + tmp_path, push_origin, "feature", fork_pull_request.head, push_origin.main_rev + ) + + base = repo.derive_pull_request_base_rev( + push_origin.url, fork_pull_request.head, "main" + ) + + assert base == push_origin.base + assert repo.get_changed_files(rev=fork_pull_request.head, base=base) == ["e.txt"] + + +def test_derive_pull_request_base_rev_without_base_ref(tmp_path, push_origin): + repo = shallow_checkout(tmp_path, push_origin, PUSH_BRANCH, push_origin.head) + + assert ( + repo.derive_pull_request_base_rev(push_origin.url, push_origin.head, "") is None + ) + + +class FakeResponse: + def __init__(self, payload, status=200): + self.payload = payload + self.status = status + + def raise_for_status(self): + if self.status != 200: + raise requests.HTTPError(str(self.status)) + + def json(self): + return self.payload + + +GITHUB_URL = "https://github.com/test/repo" + + +@pytest.fixture +def github(push_origin, monkeypatch): + monkeypatch.setenv("GIT_CONFIG_COUNT", "1") + monkeypatch.setenv("GIT_CONFIG_KEY_0", f"url.{push_origin.url}.insteadOf") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", GITHUB_URL) + requests_made = [] + + def fake_get(url, **kwargs): + requests_made.append(url) + base, head = url.rsplit("/compare/", 1)[1].split("...") + try: + merge_base = git(push_origin.path, "merge-base", base, head) + except subprocess.CalledProcessError: + return FakeResponse({}, status=404) + status = { + (True, True): "identical", + (True, False): "ahead", + (False, True): "behind", + }.get( + ( + merge_base == git(push_origin.path, "rev-parse", base), + merge_base == git(push_origin.path, "rev-parse", head), + ), + "diverged", + ) + return FakeResponse( + {"status": status, "merge_base_commit": {"sha": merge_base}} + ) + + monkeypatch.setattr(requests, "get", fake_get) + return requests_made + + +def github_clone(tmp_path, head): + checkout = tmp_path / "checkout" + git( + tmp_path, "clone", "-q", "--depth=1", "--no-checkout", GITHUB_URL, str(checkout) + ) + git(checkout, "fetch", "-q", "--depth=1", "origin", head) + git(checkout, "checkout", "-qf", head) + return get_repository(str(checkout)) + + +def github_checkout(tmp_path, head, monkeypatch): + repo = github_clone(tmp_path, head) + for name in ("_fetch_excluding", "_is_ancestor", "_merge_base"): + monkeypatch.setattr(repo, name, lambda *args: pytest.fail("used git")) + return repo + + +@pytest.mark.parametrize( + "head,base_ref,expected", + ( + pytest.param("head", "main", "base", id="behind-base"), + pytest.param("merge_tip", "refs/heads/main", "main_rev", id="merged-target"), + ), +) +def test_derive_pull_request_base_rev_github( + tmp_path, push_origin, github, monkeypatch, head, base_ref, expected +): + head = getattr(push_origin, head) + repo = github_checkout(tmp_path, head, monkeypatch) + + base = repo.derive_pull_request_base_rev(GITHUB_URL, head, base_ref) + + assert base == getattr(push_origin, expected) + assert repo.get_changed_files(rev=head, base=base) == ["b.txt", "c.txt"] + assert github == [f"https://api.github.com/repos/test/repo/compare/main...{head}"] + + +@pytest.mark.parametrize( + "head,head_ref,base_rev,expected,compares", + ( + pytest.param("head", PUSH_BRANCH, "null", "base", ["main"], id="new-branch"), + pytest.param( + "head", + PUSH_BRANCH, + "previous", + "base", + ["previous", "main"], + id="force-push", + ), + pytest.param( + "head", PUSH_BRANCH, "old_tip", None, ["old_tip"], id="fast-forward" + ), + pytest.param( + "main_rev", "main", "base", None, ["base"], id="default-fast-forward" + ), + pytest.param( + "main_rev", + "main", + "previous", + "base", + ["previous"], + id="default-force-push", + ), + pytest.param("main_rev", "main", "null", None, [], id="new-default-branch"), + ), +) +def test_derive_base_rev_github( + tmp_path, + push_origin, + github, + monkeypatch, + head, + head_ref, + base_rev, + expected, + compares, +): + head = getattr(push_origin, head) + base_rev = ( + Repository.NULL_REVISION + if base_rev == "null" + else getattr(push_origin, base_rev) + ) + repo = github_checkout(tmp_path, head, monkeypatch) + + base = repo.derive_base_rev(GITHUB_URL, head, f"refs/heads/{head_ref}", base_rev) + + assert base == (expected and getattr(push_origin, expected)) + assert github == [ + "https://api.github.com/repos/test/repo/compare/" + f"{getattr(push_origin, compare, compare)}...{head}" + for compare in compares + ] + if base_rev != Repository.NULL_REVISION: + repo.get_changed_files(rev=head, base=base or base_rev) + + +def test_derive_pull_request_base_rev_github_falls_back_to_git( + tmp_path, push_origin, github, monkeypatch +): + calls = [] + + def rate_limited(url, **kwargs): + calls.append(url) + return FakeResponse({}, status=403) + + monkeypatch.setattr(requests, "get", rate_limited) + repo = github_clone(tmp_path, push_origin.head) + + base = repo.derive_pull_request_base_rev(GITHUB_URL, push_origin.head, "main") + + assert base == push_origin.base + assert calls == [ + f"https://api.github.com/repos/test/repo/compare/main...{push_origin.head}" + ] + + +@pytest.mark.parametrize( + "failing,compares", + ( + pytest.param("", ["previous"], id="first-compare"), + pytest.param("main", ["previous", "main"], id="second-compare"), + ), +) +def test_derive_base_rev_github_falls_back_to_git( + tmp_path, push_origin, github, monkeypatch, failing, compares +): + fake_get = requests.get + + def rate_limited(url, **kwargs): + if f"/compare/{failing}" not in url: + return fake_get(url, **kwargs) + github.append(url) + return FakeResponse({}, status=403) + + monkeypatch.setattr(requests, "get", rate_limited) + repo = github_clone(tmp_path, push_origin.head) + + base = repo.derive_base_rev( + GITHUB_URL, + push_origin.head, + f"refs/heads/{PUSH_BRANCH}", + push_origin.previous, + ) + + assert base == push_origin.base + assert github == [ + "https://api.github.com/repos/test/repo/compare/" + f"{getattr(push_origin, compare, compare)}...{push_origin.head}" + for compare in compares + ] + + +@pytest.mark.parametrize("token", (None, "secret")) +def test_github_compare(git_repo, monkeypatch, token): + calls = [] + + def fake_get(url, **kwargs): + calls.append((url, kwargs)) + return FakeResponse({"status": "ahead", "merge_base_commit": {"sha": "abc"}}) + + monkeypatch.setattr(requests, "get", fake_get) + if token: + monkeypatch.setenv("GITHUB_TOKEN", token) + else: + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + repo = get_repository(git_repo) + + assert repo._github_compare( + "https://github.com/mozilla-firefox/firefox.git", "fix/#12?x", "def" + ) == {"status": "ahead", "merge_base_commit": {"sha": "abc"}} + ((url, kwargs),) = calls + assert url == ( + "https://api.github.com/repos/mozilla-firefox/firefox/compare/fix/%2312%3Fx...def" + ) + assert kwargs["params"] == {"per_page": 1} + assert kwargs["headers"].get("Authorization") == ( + f"Bearer {token}" if token else None + ) + + +def test_derive_base_rev_full_clone_uses_local_base(tmp_path, push_origin, monkeypatch): + clone = tmp_path / "clone" + git(tmp_path, "clone", "-q", push_origin.url, str(clone)) + git(clone, "fetch", "-q", "origin", push_origin.previous) + repo = get_repository(str(clone)) + fetch = repo._fetch + + def checked_fetch(repository, refspec, *options): + assert refspec != push_origin.previous + return fetch(repository, refspec, *options) + + monkeypatch.setattr(repo, "_fetch", checked_fetch) + + base = repo.derive_base_rev( + push_origin.url, push_origin.main_rev, "refs/heads/main", push_origin.previous + ) + + assert base == push_origin.base + + +def test_derive_base_rev_force_push_from_unrelated_history(tmp_path, push_origin): + git(push_origin.path, "checkout", "-q", "--orphan", "lone") + git(push_origin.path, "rm", "-rqf", ".") + (push_origin.path / "z.txt").write_bytes(b"z\n") + git(push_origin.path, "add", ".") + git(push_origin.path, "commit", "-qm", "lone") + lone = git(push_origin.path, "rev-parse", "HEAD") + git(push_origin.path, "checkout", "-qf", "main") + repo = shallow_checkout( + tmp_path, push_origin, "merged", push_origin.merge_tip, lone + ) + + base = repo.derive_base_rev( + push_origin.url, push_origin.merge_tip, "refs/heads/merged", lone + ) + + assert base == push_origin.main_rev + + +@pytest.mark.parametrize( + "response", + ( + pytest.param(FakeResponse({}, status=403), id="http-error"), + pytest.param(FakeResponse({}), id="empty"), + pytest.param( + FakeResponse({"merge_base_commit": {"sha": "abc"}}), id="no-status" + ), + pytest.param( + FakeResponse({"status": "diverged", "merge_base_commit": {"sha": None}}), + id="no-merge-base", + ), + ), +) +def test_github_compare_unavailable(git_repo, monkeypatch, response): + monkeypatch.setattr(requests, "get", lambda url, **kwargs: response) + repo = get_repository(git_repo) + + with pytest.raises(_GitHubCompareError): + repo._github_compare("https://github.com/a/b", "main", "def") + + +def test_github_compare_not_github(git_repo): + repo = get_repository(git_repo) + + with pytest.raises(_GitHubCompareError, match="is not on GitHub"): + repo._github_compare("https://example.com/a/b", "main", "def") + + +def test_derive_base_rev_hg(hg_repo): + repo = get_repository(hg_repo) + + assert repo.derive_base_rev("https://example.com", "abc", "default", "def") is None + assert ( + repo.derive_pull_request_base_rev("https://example.com", "abc", "main") is None + )