Coverage for plugin/scripts/_rhiza_forge.py: 100%
36 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Which forge a repository is hosted on, and a read-only way to ask git.
4Two entry points need this and neither may guess: ``platform_cli.py`` picks between
5`gh` and `glab` before it *writes* anything (a PR, an issue, a release), and
6``pr_status.py`` picks between them before it *reads* CI state. Both start from the same
7question — what is `origin`? — and both get the same wrong answer if it is answered
8loosely.
10The detection lived inside ``platform_cli.py`` while it was the only caller. It is here
11now because the alternative was the second caller copying it, and ``classify_host`` is
12not a function to have two of: it is the piece that refuses ``github.com.evil.example``,
13a host that embeds a known domain without being a subdomain of one. A copy that drifts
14from this one acts against the wrong forge, which is strictly worse than refusing to act.
15"""
17from __future__ import annotations
19import os
20import re
21import shutil
22import subprocess # nosec B404
23from pathlib import Path
25_KNOWN_HOSTS = {"github.com": "github", "gitlab.com": "gitlab"}
27# `git@host:owner/repo` and `scheme://[user@]host/owner/repo`, the two spellings a
28# remote is written in.
29_SSH_REMOTE = re.compile(r"git@([^:]+):")
30_URL_REMOTE = re.compile(r"[a-zA-Z]+://(?:[^@]+@)?([^/]+)/")
33class PlatformError(Exception):
34 """The hosting platform could not be determined."""
37def git_stdout(target_dir: Path, args: list[str]) -> str:
38 """Run a read-only git command in *target_dir*, returning stdout ('' on failure).
40 ``GIT_TERMINAL_PROMPT=0`` so a repo whose remote wants credentials fails fast
41 instead of blocking a non-interactive run on a password prompt.
42 """
43 env = os.environ.copy()
44 env["GIT_TERMINAL_PROMPT"] = "0"
45 result = subprocess.run( # nosec B603
46 [shutil.which("git") or "git", *args],
47 cwd=str(target_dir),
48 capture_output=True,
49 text=True,
50 env=env,
51 check=False,
52 )
53 return result.stdout.strip() if result.returncode == 0 else ""
56def classify_host(host: str) -> str | None:
57 """Return ``github``/``gitlab`` for *host*, or None when it is neither.
59 Label-boundary matching, so ``github.com.evil.example`` — which embeds a known
60 domain without being a subdomain of it — is not taken for GitHub. Self-hosted
61 GitLab conventionally lives at ``gitlab.<company>.<tld>``.
63 The exact host, and a real subdomain of it:
65 >>> classify_host("github.com")
66 'github'
67 >>> classify_host("code.gitlab.com")
68 'gitlab'
70 Self-hosted GitLab, matched on the first label:
72 >>> classify_host("gitlab.acme.io")
73 'gitlab'
75 A host that *embeds* a known domain without being a subdomain of one is refused
76 rather than guessed at — this is the case the function exists for:
78 >>> classify_host("github.com.evil.example") is None
79 True
81 So is anything simply unrecognised:
83 >>> classify_host("bitbucket.org") is None
84 True
85 """
86 h = host.lower().rstrip(".")
87 for domain, platform in _KNOWN_HOSTS.items():
88 if h == domain or h.endswith(f".{domain}"):
89 return platform
90 if domain in h:
91 return None
92 return "gitlab" if h.split(".", 1)[0] == "gitlab" else None
95def detect_platform(target_dir: Path) -> str:
96 """Return the hosting platform for *target_dir*'s `origin` remote."""
97 url = git_stdout(target_dir, ["remote", "get-url", "origin"])
98 if not url:
99 raise PlatformError("no `origin` remote — cannot tell which platform to use")
100 match = _SSH_REMOTE.match(url) or _URL_REMOTE.match(url)
101 if match is None:
102 raise PlatformError(f"could not parse a host from origin: {url}")
103 platform = classify_host(match.group(1))
104 if platform is None:
105 raise PlatformError(
106 f"unsupported host {match.group(1)!r} — only GitHub and GitLab are handled"
107 )
108 return platform
111def current_branch(target_dir: Path) -> str | None:
112 """Return the branch checked out in *target_dir*, or None when there is none.
114 ``symbolic-ref`` rather than ``rev-parse --abbrev-ref``: the caller uses this to
115 find *the request for the branch you are on*, so the two cases that must not be
116 confused are a detached HEAD (no branch — ``rev-parse`` answers the literal string
117 ``HEAD``, which is a plausible-looking branch name) and a branch with no commits on
118 it yet (a branch, which ``rev-parse`` fails outright on). ``symbolic-ref`` gets both
119 right.
120 """
121 return git_stdout(target_dir, ["symbolic-ref", "--short", "HEAD"]) or None