scripts: loc-day-by-lang hour×language report

This commit is contained in:
Hernâni Marques 2026-09-19 16:53:53 +02:00
parent 1574506fb0
commit 125dcf837a
No known key found for this signature in database

210
scripts/loc-day-by-lang.py Executable file
View file

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