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

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. 

4 

5from __future__ import annotations 

6 

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 

18 

19from griffe._internal.exceptions import BuiltinModuleError, GitError 

20 

21if TYPE_CHECKING: 

22 from collections.abc import Iterator 

23 

24 from griffe._internal.models import Module 

25 

26 

27_WORKTREE_PREFIX = "griffe-worktree-" 

28 

29 

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("-") 

34 

35 

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() 

48 

49 

50def _assert_git_repo(path: str | Path) -> None: 

51 """Deprecated. Assert that a directory is a Git repository. 

52 

53 Parameters: 

54 path: Path to a directory. 

55 

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 

65 

66 

67def _get_latest_tag(repo: str | Path) -> str: 

68 """Deprecated. Get latest tag of a Git repository. 

69 

70 Parameters: 

71 repo: The path to Git repository. 

72 

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] 

85 

86 

87def _get_repo_root(repo: str | Path) -> Path: 

88 """Deprecated. Get the root of a Git repository. 

89 

90 Parameters: 

91 repo: The path to a Git repository. 

92 

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")) 

101 

102 

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. 

106 

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. 

110 

111 Yields: 

112 The path to the temporary worktree. 

113 

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 

128 

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) 

135 

136 

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 

140 

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") 

146 

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) 

153 

154 return git_url 

155 

156 

157KnownGitService = Literal["github", "gitlab", "sourcehut", "gitea", "gogs", "forgejo", "codeberg", "radicle"] 

158"""Known Git hosting services.""" 

159 

160_service_re = re.compile(rf"({'|'.join(KnownGitService.__args__)})") 

161 

162 

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 

171 

172 

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" 

182 

183 

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}" 

188 

189 

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} 

202 

203 

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) 

215 

216 

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") 

221 

222 

223def _is_tracked(filepath: str | Path, repo: str | Path = ".") -> bool: 

224 return not _git("-C", str(repo), "check-ignore", str(filepath), check=False) 

225 

226 

227@dataclass 

228class GitInfo: 

229 """Information about a Git repository.""" 

230 

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).""" 

239 

240 @classmethod 

241 def from_package(cls, package: Module) -> GitInfo | None: 

242 """Create a GitInfo instance from a Griffe package. 

243 

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) 

264 

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. 

267 

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)