diff --git a/scripts/validate_pr.py b/scripts/validate_pr.py index b1a922b3..393c2e6a 100644 --- a/scripts/validate_pr.py +++ b/scripts/validate_pr.py @@ -20,6 +20,7 @@ from __future__ import annotations import argparse import json import os +import re import subprocess import sys @@ -413,20 +414,67 @@ def _git_lines(*args: str) -> list[str] | None: return [line for line in result.stdout.splitlines() if line] +PROJECT_URLS = ( + "https://github.com/Abdess/retrobios", + "https://github.com/Abdess/retroarch_system", +) + + +def _repo_key(url: str) -> str: + """owner/name of a clone URL, whatever its scheme.""" + path = url.strip().rstrip("/").removesuffix(".git") + return "/".join(re.split(r"[/:]", path)[-2:]).casefold() + + +def _project_main_line() -> str | None: + """The project's main line, fetched when no remote of this clone names it. + + A fork's clone has origin alone. Once the commit is pushed to the fork's + main, origin/main is HEAD and the diff against it was empty, so --changed + found nothing and exited 0. None leaves the upstream/ and origin/ lines + in charge: a remote of that name names the project, or the clone has no + remote at all and only its uncommitted files can be read. + """ + remotes = _git_lines("remote") or [] + if not remotes: + return None + project = {_repo_key(url) for url in PROJECT_URLS} + for remote in remotes: + urls = _git_lines("remote", "get-url", "--all", remote) or [] + if any(_repo_key(url) in project for url in urls): + return None if remote in ("upstream", "origin") else f"{remote}/main" + fetched = subprocess.run( + ["git", "fetch", "--quiet", "--no-tags", PROJECT_URLS[0], "main"], + capture_output=True, text=True, check=False, + ) + if fetched.returncode != 0: + raise RuntimeError( + f"no remote of this clone is {PROJECT_URLS[0]}, and fetching its " + f"main failed: {fetched.stderr.strip()}" + ) + return "FETCH_HEAD" + + def get_changed_files() -> list[str]: """BIOS files this checkout changes against the project's main line. - Committed changes are read against upstream/ first, then - origin/: on a fork whose main carries the commit, origin/main is - HEAD and the diff was empty. Uncommitted and untracked files count too, + Committed changes are read against the project's main line: the remote + that names the project (upstream/ first, then origin/), or + the project's main fetched when no remote does. Uncommitted and + untracked files count too, since CONTRIBUTING has the file copied into bios/ before any commit. A git that fails outright is an error, never "nothing changed". """ changed: list[str] = [] compared = False - for remote in ("upstream", "origin"): - for base in ("main", "master", "v2"): - committed = _git_lines("diff", "--name-only", f"{remote}/{base}...HEAD") + project_base = _project_main_line() + remotes = [[project_base]] if project_base else [ + [f"{remote}/{base}" for base in ("main", "master", "v2")] + for remote in ("upstream", "origin") + ] + for candidates in remotes: + for line in candidates: + committed = _git_lines("diff", "--name-only", f"{line}...HEAD") if committed is None: continue compared = True diff --git a/tests/test_refresh_failures.py b/tests/test_refresh_failures.py index ddcdd6b3..524d354b 100644 --- a/tests/test_refresh_failures.py +++ b/tests/test_refresh_failures.py @@ -87,6 +87,51 @@ class ChangesNotYetCommittedAreRead(unittest.TestCase): self.assertIn("bios/new.bin", proc.stdout) +class AForkPushedToItsOwnMain(unittest.TestCase): + """A fork's clone has origin alone. Once the commit was pushed to the + fork's main, origin/main was HEAD, the diff was empty, and --changed + said "No changed BIOS files detected" with rc 0.""" + + GIT = ["git", "-c", "user.email=a@b", "-c", "user.name=a", + "-c", "init.defaultBranch=main"] + + def _git(self, *args: str, cwd: str | None = None) -> None: + subprocess.run([*self.GIT, *args], cwd=cwd, check=True, capture_output=True) + + def _changed(self, clone: str, project: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", ( + f"import sys; sys.path.insert(0, {str(REPO_ROOT / 'scripts')!r}); " + "import validate_pr; " + f"validate_pr.PROJECT_URLS = ({project!r},); " + "print(validate_pr.get_changed_files())" + )], + cwd=clone, capture_output=True, text=True, timeout=60, check=False, + ) + + def test_the_project_main_is_fetched_and_compared(self): + with tempfile.TemporaryDirectory() as tmp: + project, fork, clone = (str(Path(tmp, n)) for n in ("project", "fork", "clone")) + self._git("init", "-q", project) + Path(project, "README").write_text("x") + self._git("add", "README", cwd=project) + self._git("commit", "-qm", "init", cwd=project) + self._git("clone", "-q", "--bare", project, fork) + self._git("clone", "-q", fork, clone) + Path(clone, "bios").mkdir() + Path(clone, "bios", "new.bin").write_bytes(b"x") + self._git("add", "bios/new.bin", cwd=clone) + self._git("commit", "-qm", "add", cwd=clone) + self._git("push", "-q", "origin", "main", cwd=clone) + proc = self._changed(clone, project) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("bios/new.bin", proc.stdout) + # A project that cannot be reached is an error, never "nothing". + missing = self._changed(clone, str(Path(tmp, "absent"))) + self.assertNotEqual(missing.returncode, 0) + self.assertIn("fetching its main failed", missing.stderr) + + class RefreshFailuresAreFailures(unittest.TestCase): def test_a_failed_refresh_is_returned_and_exits_nonzero(self): report = {"entries": [{"status": "UPDATED", "key": "dolphin-sys"},