Skip to content

Instantly share code, notes, and snippets.

@odiak
Created September 30, 2025 21:20
Show Gist options
  • Select an option

  • Save odiak/1c1836e1c8a354300beb2857556982f9 to your computer and use it in GitHub Desktop.

Select an option

Save odiak/1c1836e1c8a354300beb2857556982f9 to your computer and use it in GitHub Desktop.
#!/usr/bin/env node
// git-monthly-churn.js
// Usage:
// node git-monthly-churn.js # カレントリポジトリで実行
// node git-monthly-churn.js /path/to/repo > churn.svg
//
// 出力: 月別の変更行数(追加+削除)の棒グラフSVGを stdout に出力
import { spawnSync } from 'node:child_process';
import { stdout, stderr, argv } from 'node:process';
import { EOL } from 'node:os';
import path from 'node:path';
const repoPath = argv[2] ? path.resolve(argv[2]) : process.cwd();
// git log を取得(コミット行と numstat 行を交互に流す)
const gitArgs = [
'--no-pager',
'log',
'--no-merges',
'--date=iso',
'--pretty=format:%H\t%ad',
'--numstat',
];
const res = spawnSync('git', gitArgs, {
cwd: repoPath,
encoding: 'utf8',
maxBuffer: 1024 * 1024 * 1024,
});
if (res.error) {
stderr.write(`Failed to run git: ${res.error.message}${EOL}`);
process.exit(1);
}
if (res.status !== 0) {
stderr.write(res.stderr || `git exited with ${res.status}${EOL}`);
process.exit(res.status || 1);
}
const lines = res.stdout.split(/\r?\n/);
// 解析:コミット行(タブ区切り: <hash>\t<date>)の直後に複数の numstat 行が続く
// numstat 行は "added\tdeleted\tpath" (バイナリは "-")
// 月キーは YYYY-MM
const monthly = new Map(); // key: 'YYYY-MM', value: total churn
let currentMonth = null;
for (const line of lines) {
if (!line) continue;
// コミット行判定: "<40hex>\t<date>"
// ざっくり "%H\t%ad" の形を期待
const commitMatch = line.match(/^([0-9a-f]{7,40})\t(.+)$/i);
if (commitMatch) {
const iso = commitMatch[2].trim(); // e.g., 2024-09-30 12:34:56 +0900
const m = iso.match(/^(\d{4})-(\d{2})-\d{2}/);
currentMonth = m ? `${m[1]}-${m[2]}` : null;
continue;
}
// numstat 行
// e.g., "12\t3\tsrc/index.ts"
const parts = line.split('\t');
if (parts.length >= 3 && currentMonth) {
const add = parts[0] === '-' ? NaN : Number(parts[0]);
const del = parts[1] === '-' ? NaN : Number(parts[1]);
if (Number.isFinite(add) && Number.isFinite(del)) {
const churn = add + del;
monthly.set(currentMonth, (monthly.get(currentMonth) || 0) + churn);
}
}
}
// 月順に並べ替え(昇順)し、欠損月を 0 で埋める
const monthKeys = Array.from(monthly.keys()).sort((a, b) =>
a.localeCompare(b),
);
// データがない場合
if (monthKeys.length === 0) {
stderr.write('No data. Are you in a Git repository with commits?\n');
process.exit(0);
}
const filledMonths = fillMonthRange(
monthKeys[0],
monthKeys[monthKeys.length - 1],
);
const entries = filledMonths.map((key) => [key, monthly.get(key) || 0]);
// SVG パラメータ
const width = 900;
const height = 420;
const margin = { top: 30, right: 20, bottom: 80, left: 70 };
const innerW = width - margin.left - margin.right;
const innerH = height - margin.top - margin.bottom;
// スケール計算
const labels = entries.map(([m]) => m);
const values = entries.map(([, v]) => v);
const maxV = Math.max(...values);
const niceMax = niceCeil(maxV); // 軸目盛をキリ良く
const barGap = 6;
const barW = Math.max(
2,
Math.floor((innerW - barGap * (labels.length - 1)) / labels.length),
);
const actualW = barW * labels.length + barGap * (labels.length - 1);
// Yスケール: value -> pixel (上が0)
// Math.round で丸めると小さな値が 0px になり棒が消えるため、そのまま計算する
const y = (v) => innerH - (v / niceMax) * innerH;
// 軸目盛(最大値に応じて 4〜6 本程度)
const ticks = chooseTicks(niceMax);
// SVG ヘルパ
const esc = (s) =>
String(s).replace(
/[&<>"']/g,
(c) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[c],
);
// 描画
let bars = '';
let x = Math.floor((innerW - actualW) / 2); // 中央寄せ
entries.forEach(([month, val]) => {
const yPos = y(val);
const h = Math.max(0, innerH - yPos);
const rx = 3;
bars += `<rect class="bar" x="${x}" y="${yPos}" width="${barW}" height="${h}" rx="${rx}" ry="${rx}" />\n`;
// x軸ラベル(斜め表示)
const lx = x + barW / 2;
const ly = innerH + 18;
bars += `<text x="${lx}" y="${ly}" transform="rotate(-45 ${lx} ${ly})" font-size="10" text-anchor="end">${esc(month)}</text>\n`;
x += barW + barGap;
});
// Y軸とグリッド
let grid = '';
ticks.forEach((t) => {
const yy = y(t);
grid += `<line x1="0" y1="${yy}" x2="${innerW}" y2="${yy}" stroke="currentColor" stroke-opacity="0.15"/>\n`;
grid += `<text x="-8" y="${yy}" font-size="10" text-anchor="end" dominant-baseline="middle">${t.toLocaleString()}</text>\n`;
});
// タイトルと補足
const title = 'Monthly Code Changes (adds + deletes)';
const subtitle = `Repo: ${repoPath} • Data: git log --numstat • Bars: total changed lines/month`;
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" role="img" aria-labelledby="title desc">
<title id="title">${esc(title)}</title>
<desc id="desc">${esc(subtitle)}</desc>
<style>
text { fill: #111; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif; }
.bar { fill: #69c; } /* 色は固定せずに良ければ変更可 */
</style>
<rect x="0" y="0" width="${width}" height="${height}" fill="white"/>
<g transform="translate(${margin.left},${margin.top})">
<!-- Y grid + labels -->
${grid}
<!-- X/Y axes -->
<line x1="0" y1="${innerH}" x2="${innerW}" y2="${innerH}" stroke="black"/>
<line x1="0" y1="0" x2="0" y2="${innerH}" stroke="black"/>
<!-- Bars + X labels -->
${bars}
<!-- Axis titles -->
<text x="${-margin.left + 10}" y="${-10}" font-size="12">${esc(title)}</text>
<text x="${innerW / 2}" y="${innerH + 55}" font-size="11" text-anchor="middle">Month</text>
<text x="-${innerH / 2}" y="-45" transform="rotate(-90)" font-size="11" text-anchor="middle">Changed lines / month</text>
</g>
<text x="${margin.left}" y="${height - 12}" font-size="10" fill="#555">${esc(subtitle)}</text>
</svg>
`.trim();
stdout.write(svg + EOL);
// ---- helpers ----
function niceCeil(n) {
if (n <= 0) return 1;
const pow10 = Math.pow(10, Math.floor(Math.log10(n)));
const m = n / pow10;
let nice;
if (m <= 1) nice = 1;
else if (m <= 2) nice = 2;
else if (m <= 5) nice = 5;
else nice = 10;
return nice * pow10;
}
function chooseTicks(maxVal) {
const candidates = [1, 2, 5, 10];
const exp = Math.pow(10, Math.floor(Math.log10(maxVal || 1)));
let best = exp;
let count = 0;
for (const c of candidates) {
const v = c * exp;
const k = Math.ceil(maxVal / v);
if (k >= 4 && k <= 8) {
best = v;
count = k;
break;
}
}
const step = best;
const ticks = [];
for (let t = 0; t <= maxVal; t += step) ticks.push(t);
if (ticks[ticks.length - 1] !== maxVal) ticks.push(maxVal);
return ticks;
}
function fillMonthRange(startKey, endKey) {
const res = [];
let { year, month } = parseMonthKey(startKey);
const end = parseMonthKey(endKey);
while (true) {
res.push(formatMonth(year, month));
if (year === end.year && month === end.month) break;
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
// 念のため無限ループ防止
if (year > end.year || (year === end.year && month > end.month)) break;
}
return res;
}
function parseMonthKey(key) {
const [yearStr, monthStr] = key.split('-');
return { year: Number(yearStr), month: Number(monthStr) };
}
function formatMonth(year, month) {
return `${year}-${String(month).padStart(2, '0')}`;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment