Coverage for src/somesy/git/harvest.py: 85%
60 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-18 08:48 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-18 08:48 +0000
1"""Harvest project metadata from a Git repository."""
3from __future__ import annotations
5import logging
6import shutil
7import subprocess
8from collections import Counter
9from datetime import date
10from pathlib import Path
12from .models import GitAuthor, GitMetadata
14logger = logging.getLogger("somesy")
17def _git(path: Path, *args: str) -> str:
18 """Run Git without invoking a shell."""
19 git = shutil.which("git")
20 if git is None:
21 raise RuntimeError("Git executable not found")
22 result = subprocess.run( # noqa: S603 - arguments are fixed internal Git commands
23 [git, *args],
24 cwd=path,
25 capture_output=True,
26 check=True,
27 text=True,
28 )
29 return result.stdout.strip()
32def _remote(path: Path) -> str | None:
33 """Return the origin URL, or the first configured remote URL."""
34 try:
35 return _git(path, "remote", "get-url", "origin")
36 except (subprocess.CalledProcessError, FileNotFoundError):
37 try:
38 remotes = _git(path, "remote").splitlines()
39 return _git(path, "remote", "get-url", remotes[0]) if remotes else None
40 except (subprocess.CalledProcessError, FileNotFoundError):
41 return None
44def _authors(path: Path) -> list[GitAuthor]:
45 """Return all distinct mailmap-aware Git authors, ranked by commit count."""
46 try:
47 raw = _git(path, "log", "--all", "--format=%aN%x00%aE")
48 except (subprocess.CalledProcessError, FileNotFoundError):
49 return []
51 identities = []
52 for record in raw.splitlines():
53 name, _, email = record.partition("\x00")
54 if name:
55 identities.append((name, email or None))
57 counts = Counter(identities)
58 return [
59 GitAuthor(name=name, email=email, commit_count=count)
60 for (name, email), count in sorted(
61 counts.items(), key=lambda item: (-item[1], item[0])
62 )
63 ]
66def _date(path: Path, *args: str) -> date | None:
67 """Return a Git date, if the requested revision exists."""
68 try:
69 value = _git(path, *args)
70 return date.fromisoformat(value.splitlines()[0]) if value else None
71 except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
72 return None
75def _is_shallow(path: Path) -> bool:
76 """Return whether Git history is incomplete."""
77 try:
78 return _git(path, "rev-parse", "--is-shallow-repository") == "true"
79 except (subprocess.CalledProcessError, FileNotFoundError):
80 return False
83def harvest(path: Path = Path.cwd()) -> GitMetadata | None:
84 """Harvest Somesy-relevant metadata from ``path`` if it is a Git repository."""
85 try:
86 root = Path(_git(path, "rev-parse", "--show-toplevel"))
87 except (subprocess.CalledProcessError, FileNotFoundError):
88 return None
90 try:
91 version = _git(root, "describe", "--tags", "--abbrev=0") or None
92 except (subprocess.CalledProcessError, FileNotFoundError):
93 version = None
95 shallow = _is_shallow(root)
96 if shallow:
97 logger.warning(
98 "Git history is shallow; omitting dateCreated. Fetch full history to enrich it."
99 )
101 return GitMetadata(
102 name=root.name,
103 repository=_remote(root),
104 version=version,
105 date_created=None
106 if shallow
107 else _date(root, "log", "--reverse", "--format=%cs"),
108 date_modified=_date(root, "log", "-1", "--format=%cs"),
109 authors=_authors(root),
110 )