172 lines
6.4 KiB
Python
172 lines
6.4 KiB
Python
"""Per-repo inventory: file trees, doc inventory, markers, git activity.
|
|||
|
|
|
||
|
|
Writes tree_<repo>.txt, docs_inventory.tsv, markers.tsv, git_activity.tsv.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import re
|
||
|
|
from collections import defaultdict
|
||
|
|
from datetime import date
|
||
|
|
|
||
|
|
from common import (
|
||
|
|
DATA_DIR, DOC_SUFFIXES, assert_untouched, days_since, git, last_commit,
|
||
|
|
ref_of, repos, tracked_files, write_tsv,
|
||
|
|
)
|
||
|
|
|
||
|
|
MARKER_RE = re.compile(r"\b(TODO|FIXME|HACK|XXX|DEPRECATED)\b")
|
||
|
|
# git grep's ERE does not implement \b, which silently reports zero
|
||
|
|
# markers; the scan therefore runs in Python, where \b works.
|
||
|
|
|
||
|
|
MAX_SCAN_BYTES = 512 * 1024
|
||
|
|
|
||
|
|
|
||
|
|
def write_trees():
|
||
|
|
for name, repo in repos():
|
||
|
|
files = tracked_files(name, repo)
|
||
|
|
path = DATA_DIR / f"tree_{name}.txt"
|
||
|
|
path.write_text("\n".join(files) + "\n", encoding="utf-8")
|
||
|
|
print(f" tree_{name}.txt: {len(files)} files")
|
||
|
|
|
||
|
|
|
||
|
|
def write_docs_inventory():
|
||
|
|
rows = []
|
||
|
|
for name, repo in repos():
|
||
|
|
for f in tracked_files(name, repo):
|
||
|
|
if not any(f.endswith(s) for s in DOC_SUFFIXES):
|
||
|
|
continue
|
||
|
|
size = (repo / f).stat().st_size
|
||
|
|
iso, author, sha = last_commit(name, repo, f)
|
||
|
|
rows.append([name, f, size, iso, author, days_since(iso), sha])
|
||
|
|
rows.sort(key=lambda r: (r[0], r[1]))
|
||
|
|
write_tsv(
|
||
|
|
"docs_inventory.tsv",
|
||
|
|
["repo", "path", "bytes", "last_commit_date", "last_author",
|
||
|
|
"days_since_change", "last_commit"],
|
||
|
|
rows,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _blame_dates(repo, path, lines_wanted):
|
||
|
|
"""Map line number -> (author date, short sha, author) via one blame pass."""
|
||
|
|
try:
|
||
|
|
out = git(repo, "blame", "--line-porcelain", "--", path)
|
||
|
|
except RuntimeError:
|
||
|
|
return {}
|
||
|
|
result, sha, author, when, lineno = {}, None, None, None, None
|
||
|
|
for line in out.splitlines():
|
||
|
|
if re.match(r"^[0-9a-f]{40} ", line):
|
||
|
|
parts = line.split()
|
||
|
|
sha, lineno = parts[0], int(parts[2])
|
||
|
|
elif line.startswith("author "):
|
||
|
|
author = line[len("author "):]
|
||
|
|
elif line.startswith("author-time "):
|
||
|
|
when = date.fromtimestamp(int(line[len("author-time "):])).isoformat()
|
||
|
|
elif line.startswith("\t"):
|
||
|
|
if lineno in lines_wanted:
|
||
|
|
result[lineno] = (when or "", (sha or "")[:9], author or "")
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def write_markers():
|
||
|
|
rows = []
|
||
|
|
for name, repo in repos():
|
||
|
|
for f in tracked_files(name, repo):
|
||
|
|
full = repo / f
|
||
|
|
try:
|
||
|
|
if full.stat().st_size > MAX_SCAN_BYTES:
|
||
|
|
continue
|
||
|
|
text = full.read_text(encoding="utf-8")
|
||
|
|
except (OSError, UnicodeDecodeError):
|
||
|
|
continue # binary or unreadable: not a marker carrier
|
||
|
|
hits = {}
|
||
|
|
for i, line in enumerate(text.splitlines(), start=1):
|
||
|
|
m = MARKER_RE.search(line)
|
||
|
|
if m:
|
||
|
|
hits[i] = (m.group(1), line.strip()[:200])
|
||
|
|
if not hits:
|
||
|
|
continue
|
||
|
|
blame = _blame_dates(repo, f, set(hits))
|
||
|
|
for lineno in sorted(hits):
|
||
|
|
marker, snippet = hits[lineno]
|
||
|
|
when, sha, author = blame.get(lineno, ("", "", ""))
|
||
|
|
rows.append([name, f, lineno, marker, snippet, when,
|
||
|
|
days_since(when), sha, author])
|
||
|
|
rows.sort(key=lambda r: (r[0], r[1], r[2]))
|
||
|
|
write_tsv(
|
||
|
|
"markers.tsv",
|
||
|
|
["repo", "path", "line", "marker", "text", "blame_date",
|
||
|
|
"days_since_blame", "blame_commit", "blame_author"],
|
||
|
|
rows,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _is_doc(path):
|
||
|
|
return any(path.endswith(s) for s in DOC_SUFFIXES)
|
||
|
|
|
||
|
|
|
||
|
|
def write_git_activity():
|
||
|
|
rows = []
|
||
|
|
for name, repo in repos():
|
||
|
|
log = git(repo, "log", "--format=%x01%H\t%ad", "--date=short",
|
||
|
|
"--name-only", ref_of(name))
|
||
|
|
per_month = defaultdict(lambda: {"commits": 0, "docs": 0, "code": 0,
|
||
|
|
"docs_only": 0})
|
||
|
|
commit_days = []
|
||
|
|
for chunk in log.split("\x01"):
|
||
|
|
if not chunk.strip():
|
||
|
|
continue
|
||
|
|
head, _, body = chunk.partition("\n")
|
||
|
|
sha, iso = head.split("\t")
|
||
|
|
files = [ln for ln in body.splitlines() if ln.strip()]
|
||
|
|
touched_docs = any(_is_doc(f) for f in files)
|
||
|
|
touched_code = any(not _is_doc(f) for f in files)
|
||
|
|
bucket = per_month[iso[:7]]
|
||
|
|
bucket["commits"] += 1
|
||
|
|
bucket["docs"] += 1 if touched_docs else 0
|
||
|
|
bucket["code"] += 1 if touched_code else 0
|
||
|
|
bucket["docs_only"] += 1 if touched_docs and not touched_code else 0
|
||
|
|
commit_days.append(iso)
|
||
|
|
|
||
|
|
for month in sorted(per_month):
|
||
|
|
b = per_month[month]
|
||
|
|
for metric in ("commits", "docs", "code", "docs_only"):
|
||
|
|
key = {"docs": "commits_touching_docs",
|
||
|
|
"code": "commits_touching_code",
|
||
|
|
"docs_only": "commits_docs_only"}.get(metric, metric)
|
||
|
|
rows.append([name, month, key, b[metric]])
|
||
|
|
|
||
|
|
days = sorted({d for d in commit_days})
|
||
|
|
totals = {
|
||
|
|
"commits": len(commit_days),
|
||
|
|
"commits_touching_docs": sum(b["docs"] for b in per_month.values()),
|
||
|
|
"commits_touching_code": sum(b["code"] for b in per_month.values()),
|
||
|
|
"commits_docs_only": sum(b["docs_only"] for b in per_month.values()),
|
||
|
|
"active_days": len(days),
|
||
|
|
"first_commit": days[0] if days else "",
|
||
|
|
"last_commit": days[-1] if days else "",
|
||
|
|
"days_since_last_commit": days_since(days[-1]) if days else "",
|
||
|
|
}
|
||
|
|
gap, gap_from, gap_to = 0, "", ""
|
||
|
|
for a, b in zip(days, days[1:]):
|
||
|
|
ya, ma, da = (int(x) for x in a.split("-"))
|
||
|
|
yb, mb, db = (int(x) for x in b.split("-"))
|
||
|
|
delta = (date(yb, mb, db) - date(ya, ma, da)).days
|
||
|
|
if delta > gap:
|
||
|
|
gap, gap_from, gap_to = delta, a, b
|
||
|
|
totals["max_gap_days"] = gap
|
||
|
|
totals["max_gap_from"] = gap_from
|
||
|
|
totals["max_gap_to"] = gap_to
|
||
|
|
for metric in sorted(totals):
|
||
|
|
rows.append([name, "ALL", metric, totals[metric]])
|
||
|
|
|
||
|
|
rows.sort(key=lambda r: (r[0], r[1], r[2]))
|
||
|
|
write_tsv("git_activity.tsv", ["repo", "scope", "metric", "value"], rows)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
assert_untouched()
|
||
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
|
write_trees()
|
||
|
|
write_docs_inventory()
|
||
|
|
write_markers()
|
||
|
|
write_git_activity()
|