Coverage for src/_griffe/git.py: 73.91%
55 statements
« prev ^ index » next coverage.py v7.6.2, created at 2024-10-12 01:34 +0200
« prev ^ index » next coverage.py v7.6.2, created at 2024-10-12 01:34 +0200
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 pathlib import Path
14from tempfile import TemporaryDirectory
15from typing import TYPE_CHECKING
17from _griffe.exceptions import GitError
19if TYPE_CHECKING:
20 from collections.abc import Iterator
22_WORKTREE_PREFIX = "griffe-worktree-"
25def _normalize(value: str) -> str:
26 value = unicodedata.normalize("NFKC", value)
27 value = re.sub(r"[^\w]+", "-", value)
28 return re.sub(r"[-\s]+", "-", value).strip("-")
31def assert_git_repo(path: str | Path) -> None:
32 """Assert that a directory is a Git repository.
34 Parameters:
35 path: Path to a directory.
37 Raises:
38 OSError: When the directory is not a Git repository.
39 """
40 if not shutil.which("git"): 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true
41 raise RuntimeError("Could not find git executable. Please install git.")
43 try:
44 subprocess.run(
45 ["git", "-C", str(path), "rev-parse", "--is-inside-work-tree"],
46 check=True,
47 stdout=subprocess.DEVNULL,
48 stderr=subprocess.DEVNULL,
49 )
50 except subprocess.CalledProcessError as err:
51 raise OSError(f"Not a git repository: {path}") from err
54def get_latest_tag(repo: str | Path) -> str:
55 """Get latest tag of a Git repository.
57 Parameters:
58 repo: The path to Git repository.
60 Returns:
61 The latest tag.
62 """
63 if isinstance(repo, str): 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true
64 repo = Path(repo)
65 if not repo.is_dir(): 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true
66 repo = repo.parent
67 process = subprocess.run(
68 ["git", "tag", "-l", "--sort=-committerdate"],
69 cwd=repo,
70 text=True,
71 stdout=subprocess.PIPE,
72 stderr=subprocess.STDOUT,
73 check=False,
74 )
75 output = process.stdout.strip()
76 if process.returncode != 0 or not output: 76 ↛ 78line 76 didn't jump to line 78 because the condition on line 76 was always true
77 raise GitError(f"Cannot list Git tags in {repo}: {output or 'no tags'}")
78 return output.split("\n", 1)[0]
81def get_repo_root(repo: str | Path) -> str:
82 """Get the root of a Git repository.
84 Parameters:
85 repo: The path to a Git repository.
87 Returns:
88 The root of the repository.
89 """
90 if isinstance(repo, str):
91 repo = Path(repo)
92 if not repo.is_dir():
93 repo = repo.parent
94 output = subprocess.check_output(
95 ["git", "rev-parse", "--show-toplevel"],
96 cwd=repo,
97 )
98 return output.decode().strip()
101@contextmanager
102def tmp_worktree(repo: str | Path = ".", ref: str = "HEAD") -> Iterator[Path]:
103 """Context manager that checks out the given reference in the given repository to a temporary worktree.
105 Parameters:
106 repo: Path to the repository (i.e. the directory *containing* the `.git` directory)
107 ref: A Git reference such as a commit, tag or branch.
109 Yields:
110 The path to the temporary worktree.
112 Raises:
113 OSError: If `repo` is not a valid `.git` repository
114 RuntimeError: If the `git` executable is unavailable, or if it cannot create a worktree
115 """
116 assert_git_repo(repo)
117 repo_name = Path(repo).resolve().name
118 normref = _normalize(ref)
119 with TemporaryDirectory(prefix=f"{_WORKTREE_PREFIX}{repo_name}-{normref}-") as tmp_dir:
120 location = os.path.join(tmp_dir, normref) # noqa: PTH118
121 process = subprocess.run(
122 ["git", "-C", repo, "worktree", "add", "-b", normref, location, ref],
123 capture_output=True,
124 check=False,
125 )
126 if process.returncode:
127 raise RuntimeError(f"Could not create git worktree: {process.stderr.decode()}")
129 try:
130 yield Path(location)
131 finally:
132 subprocess.run(["git", "-C", repo, "worktree", "remove", normref], stdout=subprocess.DEVNULL, check=False)
133 subprocess.run(["git", "-C", repo, "worktree", "prune"], stdout=subprocess.DEVNULL, check=False)
134 subprocess.run(["git", "-C", repo, "branch", "-D", normref], stdout=subprocess.DEVNULL, check=False)