#!/usr/bin/env node import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(SCRIPT_DIR, "../.."); export const COMMIT_HISTORY_DAYS = 60; export const COMMIT_HISTORY_TIME_ZONE = "Europe/Berlin"; const DEFAULTS = Object.freeze({ days: COMMIT_HISTORY_DAYS, label: null, outputDir: "docs/metrics", ref: "origin/main", }); function printHelp() { console.log(`Usage: node scripts/metrics/commit-history.mjs [options] Options: --ref Revision to inspect (default: ${DEFAULTS.ref}) --label Ref label rendered in outputs (default: value of --ref) --days Calendar days to render (default: ${DEFAULTS.days}) --output-dir Output directory (default: ${DEFAULTS.outputDir}) --help Show this help Outputs: commit-history.csv Daily commit counts commit-history.svg Standalone 60-day chart All commits reachable from the selected revision are counted by committer date, including merge commits and commits introduced by merged branches. Calendar days are grouped in ${COMMIT_HISTORY_TIME_ZONE}; days without commits remain visible.`); } export function parseArgs(argv) { const options = { ...DEFAULTS }; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === "--help") { printHelp(); process.exit(0); } if (argument === "--ref" || argument === "--label" || argument === "--days" || argument === "--output-dir") { const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new Error(`Missing value for ${argument}`); if (argument === "--ref") options.ref = value; if (argument === "--label") options.label = value; if (argument === "--output-dir") options.outputDir = value; if (argument === "--days") { const days = Number(value); if (!Number.isInteger(days) || days < 1 || days > 365) { throw new Error("--days must be an integer between 1 and 365"); } options.days = days; } index += 1; continue; } throw new Error(`Unknown argument: ${argument}`); } return { ...options, label: options.label ?? options.ref }; } function git(args, options = {}) { return execFileSync("git", args, { cwd: options.repoRoot ?? REPO_ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, }); } export function toBerlinDateKey(timestamp) { const date = new Date(timestamp); if (Number.isNaN(date.getTime())) throw new Error(`Invalid Git timestamp: ${timestamp}`); const parts = new Intl.DateTimeFormat("en-US", { day: "2-digit", month: "2-digit", timeZone: COMMIT_HISTORY_TIME_ZONE, year: "numeric", }).formatToParts(date); const value = (type) => parts.find((part) => part.type === type)?.value; return `${value("year")}-${value("month")}-${value("day")}`; } function addCalendarDays(dateKey, amount) { const [year, month, day] = dateKey.split("-").map(Number); const date = new Date(Date.UTC(year, month - 1, day + amount, 12)); return date.toISOString().slice(0, 10); } export function collectCommitHistory(ref, { days = COMMIT_HISTORY_DAYS, repoRoot = REPO_ROOT, runGit = git } = {}) { const resolvedRef = runGit(["rev-parse", "--verify", `${ref}^{commit}`], { repoRoot }).trim(); const tipTimestamp = runGit(["show", "-s", "--format=%cI", resolvedRef], { repoRoot }).trim(); const endDate = toBerlinDateKey(tipTimestamp); const startDate = addCalendarDays(endDate, -(days - 1)); const rows = []; const counts = new Map(); for (let offset = 0; offset < days; offset += 1) { const date = addCalendarDays(startDate, offset); const row = { count: 0, date }; rows.push(row); counts.set(date, row); } const log = runGit(["log", resolvedRef, "--format=%H%x09%cI"], { repoRoot }).trim(); if (log) { for (const line of log.split("\n")) { const [sha, timestamp] = line.split("\t"); if (!sha || !timestamp) throw new Error(`Malformed Git log entry: ${line}`); const row = counts.get(toBerlinDateKey(timestamp)); if (row) row.count += 1; } } return { days, endDate, resolvedRef, rows, startDate, tipTimestamp }; } function escapeXml(value) { return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); } function formatNumber(value) { return new Intl.NumberFormat("de-DE").format(Math.round(value)); } function formatDecimal(value) { return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1, minimumFractionDigits: 1, }).format(value); } function formatDisplayDate(dateKey) { const [, month, day] = dateKey.split("-"); return `${day}.${month}.`; } function niceAxisMaximum(value) { if (value <= 0) return 1; const roughStep = value / 4; const magnitude = 10 ** Math.floor(Math.log10(roughStep)); const normalized = roughStep / magnitude; const niceNormalized = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 2.5 ? 2.5 : normalized <= 4 ? 4 : normalized <= 5 ? 5 : 10; const step = niceNormalized * magnitude; return Math.ceil(value / step) * step; } export function renderCsv(history, ref) { const metadata = [ `# ref=${ref}`, `# resolved_tip=${history.resolvedRef}`, `# tip_timestamp=${history.tipTimestamp}`, "# metric=commits reachable from ref grouped by committer date", `# window_days=${history.days}`, `# timezone=${COMMIT_HISTORY_TIME_ZONE}`, "# included=merge commits and commits introduced by merged branches", "# excluded=commits reachable only from other branches such as loc-metrics", ]; const body = history.rows.map((row) => `${row.date},${row.count}`); return `${[...metadata, "date,commit_count", ...body].join("\n")}\n`; } export function renderSvg(history, ref) { const width = 1600; const height = 900; const plot = { bottom: 730, left: 126, right: 1518, top: 320 }; const plotWidth = plot.right - plot.left; const plotHeight = plot.bottom - plot.top; const rows = history.rows; if (rows.length === 0) throw new Error("Commit history requires at least one calendar day"); const latest = rows.at(-1); const latestIndex = rows.length - 1; const lastSeven = rows.slice(-7).reduce((sum, row) => sum + row.count, 0); const total = rows.reduce((sum, row) => sum + row.count, 0); const average = total / rows.length; const peakCount = Math.max(...rows.map((row) => row.count)); const peakIndex = rows.findIndex((row) => row.count === peakCount); const peak = rows[peakIndex]; const axisMaximum = niceAxisMaximum(peakCount * 1.02); const step = plotWidth / rows.length; const barWidth = Math.max(4, Math.min(28, step * 0.6)); const x = (index) => plot.left + (index + 0.5) * step; const y = (value) => plot.bottom - (value / axisMaximum) * plotHeight; const barHeight = (value) => (value / axisMaximum) * plotHeight; const trendPoints = []; for (let index = 6; index < rows.length; index += 1) { const movingAverage = rows.slice(index - 6, index + 1).reduce((sum, row) => sum + row.count, 0) / 7; trendPoints.push(`${x(index).toFixed(1)},${y(movingAverage).toFixed(1)}`); } const cards = [ { color: "#0F172A", label: "HEUTE", value: `${formatNumber(latest.count)} Commits` }, { color: "#0F766E", label: "LETZTE 7 TAGE", value: `${formatNumber(lastSeven)} Commits` }, { color: "#F59E0B", label: `Ø PRO TAG · ${history.days} TAGE`, value: `${formatDecimal(average)} Commits` }, { color: "#8B5CF6", label: `PEAK · ${formatDisplayDate(peak.date)}`, value: `${formatNumber(peak.count)} Commits` }, ]; const svg = []; svg.push(``); svg.push( `` ); svg.push(`HiveGrid Commit-Aktivität auf ${escapeXml(ref)}`); svg.push( `Tägliche Anzahl aller von ${escapeXml(ref)} erreichbaren Commits für die letzten ${history.days} Kalendertage, gruppiert in der Zeitzone ${COMMIT_HISTORY_TIME_ZONE}, mit Sieben-Tage-Durchschnitt.` ); svg.push( `` ); svg.push(``); svg.push(`HiveGrid · Commit-Aktivität`); svg.push( `${escapeXml(ref)} @ ${history.resolvedRef.slice(0, 9)} · letzte ${history.days} Kalendertage · ${COMMIT_HISTORY_TIME_ZONE} · heute laufend` ); cards.forEach((card, index) => { const cardX = 78 + index * 366; svg.push(``); svg.push(``); svg.push( `${escapeXml(card.label)}` ); svg.push(`${escapeXml(card.value)}`); }); for (let tick = 0; tick <= 4; tick += 1) { const value = (axisMaximum / 4) * tick; const tickY = y(value); svg.push(``); svg.push(`${escapeXml(formatNumber(value))}`); } rows.forEach((row, index) => { const isLatest = index === latestIndex; const isPeak = index === peakIndex && !isLatest; const fill = isLatest ? "#0F172A" : isPeak ? "#8B5CF6" : null; const extra = fill ? ` fill="${fill}"${isPeak ? ' fill-opacity="0.9"' : ""}` : ' class="bar"'; svg.push( `` ); }); if (trendPoints.length > 0) { svg.push(``); const lastTrendPoint = trendPoints.at(-1).split(","); svg.push(``); } const renderValueLabel = (index, value, color, stroke) => { const centerX = x(index); const labelY = Math.max(plot.top - 22, y(value) - 45); svg.push(``); svg.push( `${escapeXml(formatNumber(value))}` ); }; if (peakIndex !== latestIndex && Math.abs(x(peakIndex) - x(latestIndex)) >= 60) { renderValueLabel(peakIndex, peak.count, "#7C3AED", "#DDD6FE"); } renderValueLabel(latestIndex, latest.count, "#0F172A", "#CBD5E1"); const tickCount = Math.min(7, rows.length); const tickIndexes = new Set(); for (let tick = 0; tick < tickCount; tick += 1) { const index = tick === tickCount - 1 ? rows.length - 1 : Math.round((tick * rows.length) / Math.max(1, tickCount - 1)); tickIndexes.add(Math.min(rows.length - 1, index)); } for (const index of tickIndexes) { const tickX = x(index); svg.push(``); svg.push( `${formatDisplayDate(rows[index].date)}` ); } svg.push(``); svg.push(`Commits pro Tag`); svg.push(``); svg.push(`7-Tage-Durchschnitt`); svg.push( `Alle von main erreichbaren Commits · Merge-Commits und Branch-Commits · Tage ohne Commits = 0` ); svg.push(``); return `${svg.join("\n")}\n`; } function main() { const options = parseArgs(process.argv.slice(2)); const outputDir = path.resolve(REPO_ROOT, options.outputDir); const history = collectCommitHistory(options.ref, { days: options.days, repoRoot: REPO_ROOT }); fs.mkdirSync(outputDir, { recursive: true }); fs.writeFileSync(path.join(outputDir, "commit-history.csv"), renderCsv(history, options.label)); fs.writeFileSync(path.join(outputDir, "commit-history.svg"), renderSvg(history, options.label)); const latest = history.rows.at(-1); const total = history.rows.reduce((sum, row) => sum + row.count, 0); console.log(`Commit history: ${history.days} calendar days from ${history.startDate} to ${history.endDate}`); console.log(`Latest day: ${formatNumber(latest.count)} commits; window total: ${formatNumber(total)}`); console.log(`Wrote ${path.relative(REPO_ROOT, outputDir)}/commit-history.{csv,svg}`); } const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (invokedDirectly) { try { main(); } catch (error) { console.error(error instanceof Error ? error.message : error); process.exit(1); } }