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]