105 lines
3.7 KiB
Python
105 lines
3.7 KiB
Python
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from collections import Counter
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from .models import GitSignals
|
|
|
|
|
|
def _run(root: Path, *args: str, timeout: int = 8) -> str | None:
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "-C", str(root), *args],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
errors="replace",
|
|
timeout=timeout,
|
|
)
|
|
except (OSError, UnicodeError, subprocess.SubprocessError):
|
|
return None
|
|
return result.stdout
|
|
|
|
|
|
def analyze_git(root: Path) -> tuple[GitSignals, dict[str, int]]:
|
|
top_level = _run(root, "rev-parse", "--show-toplevel")
|
|
if not top_level:
|
|
return GitSignals(), {}
|
|
try:
|
|
if Path(top_level.strip()).resolve() != root.resolve():
|
|
return GitSignals(), {}
|
|
except (OSError, RuntimeError, ValueError):
|
|
return GitSignals(), {}
|
|
|
|
inside = _run(root, "rev-parse", "--is-inside-work-tree")
|
|
if not inside or inside.strip() != "true":
|
|
return GitSignals(), {}
|
|
|
|
raw_count = _run(root, "rev-list", "--count", "HEAD")
|
|
raw_latest = _run(root, "log", "-1", "--format=%aI")
|
|
raw_authors = _run(root, "log", "--format=%aN%x00%aE", "-n", "2000")
|
|
raw_files = _run(root, "ls-files", "-z")
|
|
raw_churn = _run(root, "log", "--numstat", "--format=", "--find-renames=20%", "-n", "200")
|
|
raw_renames = _run(root, "log", "--name-status", "--format=", "--find-renames=20%", "-n", "200")
|
|
|
|
contributors: set[str] = set()
|
|
if raw_authors:
|
|
for line in raw_authors.splitlines():
|
|
if line.strip():
|
|
contributors.add(line.strip())
|
|
|
|
churn: Counter[str] = Counter()
|
|
renamed_files: set[str] = set()
|
|
rename_map: dict[str, str] = {}
|
|
if raw_renames:
|
|
for line in raw_renames.splitlines():
|
|
parts = line.split("\t")
|
|
if len(parts) >= 3 and parts[0].startswith("R"):
|
|
old_path, new_path = parts[-2:]
|
|
rename_map[old_path] = new_path
|
|
renamed_files.add(new_path)
|
|
if raw_churn:
|
|
for line in raw_churn.splitlines():
|
|
parts = line.split("\t", 2)
|
|
if len(parts) != 3:
|
|
continue
|
|
additions, deletions, path = parts
|
|
churn_path = _rename_target(path) if " => " in path else rename_map.get(path, path)
|
|
if " => " in path:
|
|
renamed_files.add(churn_path)
|
|
if additions.isdigit():
|
|
churn[churn_path] += int(additions)
|
|
if deletions.isdigit():
|
|
churn[churn_path] += int(deletions)
|
|
|
|
latest_commit_at = None
|
|
if raw_latest and raw_latest.strip():
|
|
try:
|
|
latest_commit_at = datetime.fromisoformat(raw_latest.strip())
|
|
except ValueError:
|
|
latest_commit_at = None
|
|
|
|
return (
|
|
GitSignals(
|
|
available=True,
|
|
commit_count=int(raw_count.strip()) if raw_count and raw_count.strip().isdigit() else None,
|
|
contributor_count=len(contributors) if contributors else None,
|
|
latest_commit_at=latest_commit_at,
|
|
tracked_files=len([item for item in raw_files.split("\0") if item]) if raw_files is not None else None,
|
|
renamed_files=sorted(renamed_files),
|
|
),
|
|
dict(churn),
|
|
)
|
|
|
|
|
|
def _rename_target(path: str) -> str:
|
|
"""Normalize Git's brace-formatted rename path to the new path."""
|
|
if "{" in path and "}" in path:
|
|
prefix, remainder = path.split("{", 1)
|
|
inner, suffix = remainder.split("}", 1)
|
|
if " => " in inner:
|
|
return f"{prefix}{inner.split(' => ', 1)[1]}{suffix}"
|
|
return path.rsplit(" => ", 1)[-1]
|