Coverage for packages / griffelib / src / griffe / _internal / git.py: 84.66%
131 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-11 11:48 +0100
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-11 11:48 +0100
1# This module contains Git utilities, used by our [`load_git`][griffe.load_git] function,
2# which in turn is used to load the API for different snapshots of a Git repository
3# and find breaking changes between them.
5from __future__ import annotations
7import os
8import re
9import shutil
10import subprocess
11import unicodedata
12from contextlib import contextmanager
13from dataclasses import dataclass
14from pathlib import Path
15from tempfile import TemporaryDirectory
16from typing import TYPE_CHECKING, Literal
17from urllib.parse import urlsplit, urlunsplit
19from griffe._internal.exceptions import BuiltinModuleError, GitError
21if TYPE_CHECKING:
22 from collections.abc import Iterator
24 from griffe._internal.models import Module
27_WORKTREE_PREFIX = "griffe-worktree-"
30def _normalize(value: str) -> str:
31 value = unicodedata.normalize("NFKC", value)
32 value = re.sub(r"[^\w]+", "-", value)
33 return re.sub(r"[-\s]+", "-", value).strip("-")
36def _git(*args: str, check: bool = True) -> str:
37 process = subprocess.run(
38 ["git", *args],
39 check=False,
40 text=True,
41 stdout=subprocess.PIPE,
42 stderr=subprocess.STDOUT,
43 encoding="utf8",
44 )
45 if check and process.returncode != 0:
46 raise GitError(process.stdout.strip())
47 return process.stdout.strip()
50def _assert_git_repo(path: str | Path) -> None:
51 """Deprecated. Assert that a directory is a Git repository.
53 Parameters:
54 path: Path to a directory.
56 Raises:
57 OSError: When the directory is not a Git repository.
58 """
59 if not shutil.which("git"): 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true
60 raise RuntimeError("Could not find git executable. Please install git.")
61 try:
62 _git("-C", str(path), "rev-parse", "--is-inside-work-tree")
63 except GitError as error:
64 raise OSError(f"Not a git repository: {path}") from error
67def _get_latest_tag(repo: str | Path) -> str:
68 """Deprecated. Get latest tag of a Git repository.
70 Parameters:
71 repo: The path to Git repository.
73 Returns:
74 The latest tag.
75 """
76 if isinstance(repo, str): 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true
77 repo = Path(repo)
78 if not repo.is_dir(): 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true
79 repo = repo.parent
80 try:
81 output = _git("tag", "-l", "--sort=-creatordate")
82 except GitError as error:
83 raise GitError(f"Cannot list Git tags in {repo}: {error or 'no tags'}") from error
84 return output.split("\n", 1)[0]
87def _get_repo_root(repo: str | Path) -> Path:
88 """Deprecated. Get the root of a Git repository.
90 Parameters:
91 repo: The path to a Git repository.
93 Returns:
94 The root of the repository.
95 """
96 if isinstance(repo, str): 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true
97 repo = Path(repo)
98 if not repo.is_dir():
99 repo = repo.parent
100 return Path(_git("-C", str(repo), "rev-parse", "--show-toplevel"))
103@contextmanager
104def _tmp_worktree(repo: str | Path = ".", ref: str = "HEAD") -> Iterator[Path]:
105 """Deprecated. Context manager that checks out the given reference in the given repository to a temporary worktree.
107 Parameters:
108 repo: Path to the repository (i.e. the directory *containing* the `.git` directory)
109 ref: A Git reference such as a commit, tag or branch.
111 Yields:
112 The path to the temporary worktree.
114 Raises:
115 OSError: If `repo` is not a valid `.git` repository
116 RuntimeError: If the `git` executable is unavailable, or if it cannot create a worktree
117 """
118 _assert_git_repo(repo)
119 repo_name = Path(repo).resolve().name
120 normref = _normalize(ref) # Branch names can contain slashes.
121 with TemporaryDirectory(prefix=f"{_WORKTREE_PREFIX}{repo_name}-{normref}-") as tmp_dir:
122 location = os.path.join(tmp_dir, normref) # noqa: PTH118
123 tmp_branch = f"griffe-{normref}" # Temporary branch name must not already exist.
124 try:
125 _git("-C", str(repo), "worktree", "add", "-b", tmp_branch, location, ref)
126 except GitError as error:
127 raise RuntimeError(f"Could not create git worktree: {error}") from error
129 try:
130 yield Path(location)
131 finally:
132 _git("-C", str(repo), "worktree", "remove", location, check=False)
133 _git("-C", str(repo), "worktree", "prune", check=False)
134 _git("-C", str(repo), "branch", "-D", tmp_branch, check=False)
137def _get_git_remote_url(repo: str | Path = ".") -> str:
138 if git_url := os.getenv("GRIFFE_GIT_REMOTE_URL"): 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 return git_url
141 remote = "remote." + os.getenv("GRIFFE_GIT_REMOTE", "origin") + ".url"
142 git_url = _git("-C", str(repo), "config", "--default", "", "--get", remote)
143 if git_url.startswith("git@"):
144 git_url = git_url.replace(":", "/", 1).replace("git@", "https://", 1)
145 git_url = git_url.removesuffix(".git")
147 # Remove credentials from the URL.
148 if git_url.startswith(("http://", "https://")):
149 # (addressing scheme, network location, path, query, fragment identifier)
150 urlparts = list(urlsplit(git_url))
151 urlparts[1] = urlparts[1].split("@", 1)[-1]
152 git_url = urlunsplit(urlparts)
154 return git_url
157KnownGitService = Literal["github", "gitlab", "sourcehut", "gitea", "gogs", "forgejo", "codeberg", "radicle"]
158"""Known Git hosting services."""
160_service_re = re.compile(rf"({'|'.join(KnownGitService.__args__)})")
163def _get_git_known_service(git_remote_url: str) -> KnownGitService | None:
164 if service := os.getenv("GRIFFE_GIT_SERVICE"): 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 if service not in KnownGitService.__args__:
166 return None
167 return service # ty:ignore[invalid-return-type]
168 if match := _service_re.search(urlsplit(git_remote_url).netloc):
169 return match.group(1) # ty:ignore[invalid-return-type]
170 return None
173# For Radicle we use https://app.radicle.at/nodes/seed.radicle.at which I believe seeds everything?
174# Line ranges do not seem to be supported.
175# The rad remote is declared as such in .git/config:
176#
177# ```ini
178# [remote "rad"]
179# url = rad://z4M5XTPDD4Wh1sm8iPCenF85J3z8Z
180# ```
181_RADICLE_URL = "https://app.radicle.at/nodes/seed.radicle.at"
184def _get_radicle_url(url_or_rid: str, commit_hash: str, filepath: str, lineno: int, endlineno: int) -> str: # noqa: ARG001
185 # This lets users override the full URL with `GRIFFE_GIT_REMOTE_URL=https://.../rad:...`.
186 url = f"{_RADICLE_URL}/{url_or_rid.replace('//', '')}" if url_or_rid.startswith("rad://") else url_or_rid
187 return f"{url}/tree/{commit_hash}/{filepath}#L{lineno}"
190_service_to_url = {
191 "github": lambda url, ch, fp, ln, eln: f"{url}/blob/{ch}/{fp}#L{ln}-L{eln}",
192 "gitlab": lambda url, ch, fp, ln, eln: f"{url}/-/blob/{ch}/{fp}#L{ln}-L{eln}",
193 # SourceHut does not seem to support line ranges.
194 "sourcehut": lambda url, ch, fp, ln, eln: f"{url}/tree/{ch}/{fp}#L{ln}",
195 # Cannot find a demo Gogs instance so not sure about this URL template.
196 "gogs": lambda url, ch, fp, ln, eln: f"{url}/blob/{ch}/{fp}#L{ln}-L{eln}",
197 "gitea": lambda url, ch, fp, ln, eln: f"{url}/src/commit/{ch}/{fp}#L{ln}-L{eln}",
198 "codeberg": lambda url, ch, fp, ln, eln: f"{url}/src/commit/{ch}/{fp}#L{ln}-L{eln}",
199 "forgejo": lambda url, ch, fp, ln, eln: f"{url}/src/commit/{ch}/{fp}#L{ln}-L{eln}",
200 "radicle": _get_radicle_url,
201}
204def _get_source_link(
205 service: KnownGitService,
206 remote_url: str,
207 commit_hash: str,
208 filepath: str | Path,
209 lineno: int,
210 endlineno: int,
211) -> str | None:
212 if isinstance(filepath, Path): 212 ↛ 214line 212 didn't jump to line 214 because the condition on line 212 was always true
213 filepath = filepath.as_posix()
214 return _service_to_url[service](remote_url, commit_hash, filepath, lineno, endlineno)
217def _get_git_commit_hash(repo: str | Path = ".") -> str:
218 if commit_hash := os.getenv("GRIFFE_GIT_COMMIT_HASH"): 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true
219 return commit_hash
220 return _git("-C", str(repo), "rev-parse", "HEAD")
223def _is_tracked(filepath: str | Path, repo: str | Path = ".") -> bool:
224 return not _git("-C", str(repo), "check-ignore", str(filepath), check=False)
227@dataclass
228class GitInfo:
229 """Information about a Git repository."""
231 repository: Path
232 """The path to the Git repository."""
233 service: KnownGitService
234 """The Git hosting service (used to build the right URLs)."""
235 remote_url: str
236 """The remote URL of the Git repository."""
237 commit_hash: str
238 """A commit hash (usually the current checked-out one)."""
240 @classmethod
241 def from_package(cls, package: Module) -> GitInfo | None:
242 """Create a GitInfo instance from a Griffe package.
244 Returns:
245 The GitInfo instance, or None if unknown.
246 """
247 try:
248 path = package.filepath[0] if isinstance(package.filepath, list) else package.filepath
249 except BuiltinModuleError:
250 return None
251 try:
252 repo = _get_repo_root(path)
253 if not _is_tracked(path.relative_to(repo), repo): 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true
254 return None
255 remote_url = _get_git_remote_url(repo)
256 if not (service := _get_git_known_service(remote_url)):
257 return None
258 commit_hash = _get_git_commit_hash(repo)
259 except (GitError, ValueError, OSError):
260 # `ValueError` can happen if `path` is not relative to `repo`.
261 # `OSError` is caught just to be safe.
262 return None
263 return cls(repository=repo, service=service, remote_url=remote_url, commit_hash=commit_hash)
265 def get_source_link(self, filepath: str | Path, lineno: int, endlineno: int) -> str | None:
266 """Get the source link for the file at the given line numbers.
268 Returns:
269 The source link, or None if unknown.
270 """
271 return _get_source_link(self.service, self.remote_url, self.commit_hash, filepath, lineno, endlineno)