Files
RepoPulse-Public/backend/app/git_analysis.py
T

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]