diff --git a/scripts/loc-day-by-lang.py b/scripts/loc-day-by-lang.py new file mode 100755 index 0000000..e115450 --- /dev/null +++ b/scripts/loc-day-by-lang.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Report git insertions for a calendar day, by hour × language (file extension). + +Default: today's commits in the current repo (AuthorDate, local clock). +Skips binary numstat rows (added/deleted marked '-'). +""" + +from __future__ import annotations + +import argparse +import collections +import os +import subprocess +import sys +from datetime import date, datetime, timedelta +from pathlib import Path + +LANG = { + ".c": "C", + ".h": "C", + ".cc": "C++", + ".cpp": "C++", + ".hpp": "C++", + ".md": "Markdown", + ".sh": "Shell", + ".py": "Python", + ".gd": "GDScript", + ".json": "JSON", + ".txt": "Text", + ".toml": "TOML", + ".yml": "YAML", + ".yaml": "YAML", + ".cmake": "CMake", + ".png": "PNG (bin)", + ".jpg": "JPEG (bin)", + ".jpeg": "JPEG (bin)", +} + + +def lang_of(path: str) -> str: + ext = Path(path).suffix.lower() + if ext in LANG: + return LANG[ext] + if ext: + return ext[1:].upper() + return "noext" + + +def day_bounds(day: date) -> tuple[str, str]: + start = datetime(day.year, day.month, day.day, 0, 0, 0) + end = start + timedelta(days=1) + fmt = "%Y-%m-%d %H:%M:%S" + return start.strftime(fmt), end.strftime(fmt) + + +def load_numstat(repo: Path, since: str, until: str) -> str: + return subprocess.check_output( + [ + "git", + "-C", + str(repo), + "log", + f"--since={since}", + f"--until={until}", + "--pretty=format:COMMIT\t%ad\t%H\t%s", + "--date=format:%Y-%m-%d %H:%M:%S", + "--numstat", + ], + text=True, + ) + + +def aggregate(out: str) -> tuple[ + dict[str, dict[str, int]], + dict[str, int], + dict[str, int], + dict[str, list[int]], + int, + int, +]: + by_hour_lang: dict[str, dict[str, int]] = collections.defaultdict( + lambda: collections.defaultdict(int) + ) + by_lang: dict[str, int] = collections.defaultdict(int) + by_hour: dict[str, int] = collections.defaultdict(int) + net: dict[str, list[int]] = collections.defaultdict(lambda: [0, 0]) + commits = 0 + binary_skips = 0 + cur_hour: str | None = None + + for line in out.splitlines(): + if not line.strip(): + continue + if line.startswith("COMMIT\t"): + _, ad, _h, _s = line.split("\t", 3) + dt = datetime.strptime(ad, "%Y-%m-%d %H:%M:%S") + cur_hour = dt.strftime("%H:00") + commits += 1 + continue + parts = line.split("\t") + if len(parts) != 3 or cur_hour is None: + continue + added_s, deleted_s, path = parts + if added_s == "-" or deleted_s == "-": + binary_skips += 1 + continue + added = int(added_s) + deleted = int(deleted_s) + lang = lang_of(path) + by_hour_lang[cur_hour][lang] += added + by_lang[lang] += added + by_hour[cur_hour] += added + net[lang][0] += added + net[lang][1] += deleted + + return by_hour_lang, by_lang, by_hour, net, commits, binary_skips + + +def print_report( + repo: Path, + day: date, + by_hour_lang: dict[str, dict[str, int]], + by_lang: dict[str, int], + by_hour: dict[str, int], + net: dict[str, list[int]], + commits: int, + binary_skips: int, +) -> None: + hours = sorted(by_hour.keys()) + langs = sorted(by_lang.keys(), key=lambda x: (-by_lang[x], x)) + print(f"REPO={repo}") + print(f"DAY={day.isoformat()} COMMITS={commits} BINARY_FILE_TOUCHES_SKIPPED={binary_skips}") + print() + print("=== +Zeilen je Stunde × Sprache (nur Insertions, nicht Netto) ===") + if not hours: + print("(keine Commits an diesem Tag)") + return + print(f"{'Stunde':<8}" + "".join(f"{l:>12}" for l in langs) + f"{'TOTAL':>12}") + for h in hours: + row = f"{h:<8}" + for l in langs: + v = by_hour_lang[h].get(l, 0) + row += f"{v:>12}" if v else f"{'·':>12}" + row += f"{by_hour[h]:>12}" + print(row) + print( + f"{'SUMME':<8}" + + "".join(f"{by_lang[l]:>12}" for l in langs) + + f"{sum(by_lang.values()):>12}" + ) + print() + print("=== Tages-Total nach Sprache ===") + for l in langs: + print(f" {l:<12} +{by_lang[l]:,}") + print(f" {'TOTAL':<12} +{sum(by_lang.values()):,}") + print() + print("=== Netto (+/−) (Kurz) ===") + for l in langs: + a, d = net[l] + print(f" {l:<12} +{a:,} −{d:,} net {a - d:+,}") + total_a = sum(v[0] for v in net.values()) + total_d = sum(v[1] for v in net.values()) + print(f" {'TOTAL':<12} +{total_a:,} −{total_d:,} net {total_a - total_d:+,}") + + +def parse_day(s: str) -> date: + return date.fromisoformat(s) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Git insertions for one calendar day, by hour × language." + ) + ap.add_argument( + "--repo", + type=Path, + default=Path("."), + help="git repo root (default: cwd)", + ) + ap.add_argument( + "--day", + type=parse_day, + default=date.today(), + help="calendar day YYYY-MM-DD (default: today)", + ) + args = ap.parse_args() + repo = args.repo.resolve() + if not (repo / ".git").exists() and not (repo / ".git").is_file(): + # worktree .git may be a file; also accept git rev-parse + try: + top = subprocess.check_output( + ["git", "-C", str(repo), "rev-parse", "--show-toplevel"], + text=True, + ).strip() + repo = Path(top) + except subprocess.CalledProcessError: + print(f"ERROR: not a git repo: {repo}", file=sys.stderr) + return 2 + + since, until = day_bounds(args.day) + out = load_numstat(repo, since, until) + by_hour_lang, by_lang, by_hour, net, commits, binary_skips = aggregate(out) + print_report( + repo, args.day, by_hour_lang, by_lang, by_hour, net, commits, binary_skips + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())