210 lines
5.9 KiB
Python
Executable file
210 lines
5.9 KiB
Python
Executable file
#!/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())
|