scripts: loc-day-by-lang hour×language report
This commit is contained in:
parent
1574506fb0
commit
125dcf837a
1 changed files with 210 additions and 0 deletions
210
scripts/loc-day-by-lang.py
Executable file
210
scripts/loc-day-by-lang.py
Executable 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue