expand "our" section: real Pylons org stats (28 lifetime, 4 active), PR rejection & pivot to downstream migration of 33 repos, BDFL commentary. embed unbury.py source & downloadable file. new contributor-decay diagram. update load-bearing diagram with org-wide data. fix #333333 text on gold nodes. convert all 8 SVG animations from one-shot to infinite loop (13s hold, fade out, restart). add unturf SVG diagram style guide to CLAUDE.md.
102 lines
3.1 KiB
Python
Executable file
102 lines
3.1 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""unbury - exhume contributor stats from a graveyard of git repos.
|
|
|
|
Usage:
|
|
unbury [path] # defaults to current directory
|
|
unbury /home/fox/git/pylons
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta
|
|
|
|
PERIODS = [
|
|
("3 months", 90),
|
|
("1 year", 365),
|
|
("3 years", 365 * 3),
|
|
("6 years", 365 * 6),
|
|
("18 years", 365 * 18),
|
|
("lifetime", None),
|
|
]
|
|
|
|
|
|
def git_contributors(repo_path, since=None):
|
|
"""Return set of (name, email) tuples for a repo."""
|
|
cmd = ["git", "-C", repo_path, "log", "--format=%aN\t%aE"]
|
|
if since:
|
|
cmd.append(f"--since={since}")
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
contributors = set()
|
|
for line in result.stdout.strip().split("\n"):
|
|
if "\t" in line:
|
|
name, email = line.split("\t", 1)
|
|
contributors.add((name.strip(), email.strip().lower()))
|
|
return contributors
|
|
except (subprocess.TimeoutExpired, Exception):
|
|
return set()
|
|
|
|
|
|
def find_repos(root):
|
|
"""Find all git repos (immediate subdirs with .git)."""
|
|
repos = []
|
|
for entry in sorted(os.listdir(root)):
|
|
path = os.path.join(root, entry)
|
|
if os.path.isdir(path) and os.path.isdir(os.path.join(path, ".git")):
|
|
repos.append(path)
|
|
return repos
|
|
|
|
|
|
def main():
|
|
root = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
|
|
root = os.path.abspath(root)
|
|
|
|
repos = find_repos(root)
|
|
if not repos:
|
|
print(f"no git repos found in {root}")
|
|
sys.exit(1)
|
|
|
|
print(f"scanning {len(repos)} repos in {root}\n")
|
|
|
|
now = datetime.now()
|
|
|
|
for label, days in PERIODS:
|
|
since = (now - timedelta(days=days)).strftime("%Y-%m-%d") if days else None
|
|
all_contributors = set()
|
|
repo_counts = {}
|
|
|
|
for repo in repos:
|
|
contribs = git_contributors(repo, since)
|
|
if contribs:
|
|
repo_name = os.path.basename(repo)
|
|
repo_counts[repo_name] = len(contribs)
|
|
all_contributors.update(contribs)
|
|
|
|
active_repos = len(repo_counts)
|
|
print(f"{'─' * 50}")
|
|
print(f"{label}: {len(all_contributors)} unique contributors across {active_repos} active repos")
|
|
|
|
if all_contributors:
|
|
# count contributions per person across repos
|
|
person_repos = defaultdict(list)
|
|
for repo in repos:
|
|
contribs = git_contributors(repo, since)
|
|
repo_name = os.path.basename(repo)
|
|
for c in contribs:
|
|
person_repos[c].append(repo_name)
|
|
|
|
# sort by number of repos touched
|
|
ranked = sorted(person_repos.items(), key=lambda x: -len(x[1]))
|
|
top = ranked[:10]
|
|
for (name, email), touched in top:
|
|
print(f" {name:30s} {len(touched):3d} repos ({email})")
|
|
if len(ranked) > 10:
|
|
print(f" ... and {len(ranked) - 10} more")
|
|
|
|
print(f"{'─' * 50}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|