# This module contains Git utilities, used by our [`load_git`][griffe.load_git] function, # which in turn is used to load the API for different snapshots of a Git repository # and find breaking changes between them. from __future__ import annotations import os import re import shutil import subprocess import unicodedata from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING from _griffe.exceptions import GitError if TYPE_CHECKING: from collections.abc import Iterator _WORKTREE_PREFIX = "griffe-worktree-" def _normalize(value: str) -> str: value = unicodedata.normalize("NFKC", value) value = re.sub(r"[^\w]+", "-", value) return re.sub(r"[-\s]+", "-", value).strip("-") def assert_git_repo(path: str | Path) -> None: """Assert that a directory is a Git repository. Parameters: path: Path to a directory. Raises: OSError: When the directory is not a Git repository. """ if not shutil.which("git"): raise RuntimeError("Could not find git executable. Please install git.") try: subprocess.run( ["git", "-C", str(path), "rev-parse", "--is-inside-work-tree"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) except subprocess.CalledProcessError as err: raise OSError(f"Not a git repository: {path}") from err def get_latest_tag(repo: str | Path) -> str: """Get latest tag of a Git repository. Parameters: repo: The path to Git repository. Returns: The latest tag. """ if isinstance(repo, str): repo = Path(repo) if not repo.is_dir(): repo = repo.parent process = subprocess.run( ["git", "tag", "-l", "--sort=-creatordate"], cwd=repo, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, ) output = process.stdout.strip() if process.returncode != 0 or not output: raise GitError(f"Cannot list Git tags in {repo}: {output or 'no tags'}") return output.split("\n", 1)[0] def get_repo_root(repo: str | Path) -> str: """Get the root of a Git repository. Parameters: repo: The path to a Git repository. Returns: The root of the repository. """ if isinstance(repo, str): repo = Path(repo) if not repo.is_dir(): repo = repo.parent output = subprocess.check_output( ["git", "rev-parse", "--show-toplevel"], cwd=repo, ) return output.decode().strip() @contextmanager def tmp_worktree(repo: str | Path = ".", ref: str = "HEAD") -> Iterator[Path]: """Context manager that checks out the given reference in the given repository to a temporary worktree. Parameters: repo: Path to the repository (i.e. the directory *containing* the `.git` directory) ref: A Git reference such as a commit, tag or branch. Yields: The path to the temporary worktree. Raises: OSError: If `repo` is not a valid `.git` repository RuntimeError: If the `git` executable is unavailable, or if it cannot create a worktree """ assert_git_repo(repo) repo_name = Path(repo).resolve().name normref = _normalize(ref) # Branch names can contain slashes. with TemporaryDirectory(prefix=f"{_WORKTREE_PREFIX}{repo_name}-{normref}-") as tmp_dir: location = os.path.join(tmp_dir, normref) # noqa: PTH118 tmp_branch = f"griffe-{normref}" # Temporary branch name must not already exist. process = subprocess.run( ["git", "-C", repo, "worktree", "add", "-b", tmp_branch, location, ref], capture_output=True, check=False, ) if process.returncode: raise RuntimeError(f"Could not create git worktree: {process.stderr.decode()}") try: yield Path(location) finally: subprocess.run(["git", "-C", repo, "worktree", "remove", location], stdout=subprocess.DEVNULL, check=False) subprocess.run(["git", "-C", repo, "worktree", "prune"], stdout=subprocess.DEVNULL, check=False) subprocess.run(["git", "-C", repo, "branch", "-D", tmp_branch], stdout=subprocess.DEVNULL, check=False)