russell.ballestrini.net/content/uploads/2026/03/unbury.py
russell@unturf.com cf92f1f613 add PR #3805 saga, unbury script, contributor decay diagram, looping SVG animations, CLAUDE.md style guide
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.
2026-03-04 14:22:00 -05:00

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()