#!/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())