Coverage for src/somesy/git/harvest.py: 87%
70 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:31 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:31 +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
11from urllib.parse import urlsplit, urlunsplit
13from .models import GitAuthor, GitMetadata
15logger = logging.getLogger("somesy")
17_GIT_ERRORS = (subprocess.CalledProcessError, FileNotFoundError)
20def _git(path: Path, *args: str) -> str:
21 """Run Git without invoking a shell."""
22 git = shutil.which("git")
23 if git is None:
24 raise FileNotFoundError("Git executable not found")
25 result = subprocess.run( # noqa: S603 - arguments are fixed internal Git commands
26 [git, *args],
27 cwd=path,
28 capture_output=True,
29 check=True,
30 encoding="utf-8",
31 errors="replace",
32 )
33 return result.stdout.strip()
36def _strip_credentials(url: str) -> str:
37 """Remove userinfo (user:token) from http(s) URLs; leave other forms as is."""
38 parts = urlsplit(url)
39 if parts.scheme not in ("http", "https") or "@" not in parts.netloc:
40 return url
41 return urlunsplit(parts._replace(netloc=parts.netloc.rpartition("@")[2]))
44def _remote(path: Path) -> str | None:
45 """Return the origin URL, or the first configured remote URL."""
46 try:
47 return _strip_credentials(_git(path, "remote", "get-url", "origin"))
48 except _GIT_ERRORS:
49 try:
50 remotes = _git(path, "remote").splitlines()
51 if not remotes:
52 return None
53 return _strip_credentials(_git(path, "remote", "get-url", remotes[0]))
54 except _GIT_ERRORS:
55 return None
58def _authors(path: Path) -> list[GitAuthor]:
59 """Return all distinct mailmap-aware Git authors, ranked by commit count."""
60 try:
61 raw = _git(path, "log", "--all", "--format=%aN%x00%aE")
62 except _GIT_ERRORS:
63 return []
65 identities = []
66 for record in raw.splitlines():
67 name, _, email = record.partition("\x00")
68 if name:
69 identities.append((name, email or None))
71 counts = Counter(identities)
72 return [
73 GitAuthor(name=name, email=email, commit_count=count)
74 for (name, email), count in sorted(
75 counts.items(), key=lambda item: (-item[1], item[0])
76 )
77 ]
80def _date(path: Path, *args: str) -> date | None:
81 """Return a Git date, if the requested revision exists."""
82 try:
83 value = _git(path, *args)
84 return date.fromisoformat(value.splitlines()[0]) if value else None
85 except (*_GIT_ERRORS, ValueError):
86 return None
89def _is_shallow(path: Path) -> bool:
90 """Return whether Git history is incomplete."""
91 try:
92 return _git(path, "rev-parse", "--is-shallow-repository") == "true"
93 except _GIT_ERRORS:
94 return False
97def harvest(path: Path | None = None) -> GitMetadata | None:
98 """Harvest Somesy-relevant metadata from ``path`` (default: cwd) if it is a Git repository."""
99 path = Path.cwd() if path is None else path
100 try:
101 root = Path(_git(path, "rev-parse", "--show-toplevel"))
102 except _GIT_ERRORS:
103 return None
105 try:
106 version = _git(root, "describe", "--tags", "--abbrev=0") or None
107 except _GIT_ERRORS:
108 version = None
110 shallow = _is_shallow(root)
111 if shallow:
112 logger.warning(
113 "Git history is shallow; omitting dateCreated. Fetch full history to enrich it."
114 )
116 return GitMetadata(
117 name=root.name,
118 repository=_remote(root),
119 version=version,
120 date_created=None
121 if shallow
122 else _date(root, "log", "--reverse", "--format=%cs"),
123 date_modified=_date(root, "log", "-1", "--format=%cs"),
124 authors=_authors(root),
125 )