Coverage for src/somesy/git/harvest.py: 88%
43 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 11:35 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 11:35 +0000
1"""Harvest project metadata from a Git repository."""
3from __future__ import annotations
5import shutil
6import subprocess
7from collections import Counter
8from pathlib import Path
10from .models import GitAuthor, GitMetadata
13def _git(path: Path, *args: str) -> str:
14 """Run Git without invoking a shell."""
15 git = shutil.which("git")
16 if git is None:
17 raise RuntimeError("Git executable not found")
18 result = subprocess.run( # noqa: S603 - arguments are fixed internal Git commands
19 [git, *args],
20 cwd=path,
21 capture_output=True,
22 check=True,
23 text=True,
24 )
25 return result.stdout.strip()
28def _remote(path: Path) -> str | None:
29 """Return the origin URL, or the first configured remote URL."""
30 try:
31 return _git(path, "remote", "get-url", "origin")
32 except (subprocess.CalledProcessError, FileNotFoundError):
33 try:
34 remotes = _git(path, "remote").splitlines()
35 return _git(path, "remote", "get-url", remotes[0]) if remotes else None
36 except (subprocess.CalledProcessError, FileNotFoundError):
37 return None
40def _authors(path: Path) -> list[GitAuthor]:
41 """Return all distinct mailmap-aware Git authors, ranked by commit count."""
42 try:
43 raw = _git(path, "log", "--all", "--format=%aN%x00%aE")
44 except (subprocess.CalledProcessError, FileNotFoundError):
45 return []
47 identities = []
48 for record in raw.splitlines():
49 name, _, email = record.partition("\x00")
50 if name:
51 identities.append((name, email or None))
53 counts = Counter(identities)
54 return [
55 GitAuthor(name=name, email=email, commit_count=count)
56 for (name, email), count in sorted(
57 counts.items(), key=lambda item: (-item[1], item[0])
58 )
59 ]
62def harvest(path: Path = Path.cwd()) -> GitMetadata | None:
63 """Harvest Somesy-relevant metadata from ``path`` if it is a Git repository."""
64 try:
65 root = Path(_git(path, "rev-parse", "--show-toplevel"))
66 except (subprocess.CalledProcessError, FileNotFoundError):
67 return None
69 try:
70 version = _git(root, "describe", "--tags", "--abbrev=0") or None
71 except (subprocess.CalledProcessError, FileNotFoundError):
72 version = None
74 return GitMetadata(
75 name=root.name,
76 repository=_remote(root),
77 version=version,
78 authors=_authors(root),
79 )