OpenKKnD/scripts/loc-day-by-lang.py
2026-09-19 16:53:53 +02:00

210 lines
5.9 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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