Created
September 30, 2025 21:20
-
-
Save odiak/1c1836e1c8a354300beb2857556982f9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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) => | |
| ({ | |
| '&': '&', | |
| '<': '<', | |
| '>': '>', | |
| '"': '"', | |
| "'": ''', | |
| })[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