「このリポジトリ、みんないつコミットしてるんだ?」を可視化するツールを作った。
git logの出力をテキストエリアに貼ると、作者別ランキング・時間帯ヒストグラム・曜日別・そしてパンチカード (曜日×時間の heatmap) が出る。完全クライアントサイドなのでログは外部に送られない。実装の hinge は 2 つ: (1)git logの 2 種類のフォーマット (パイプ区切りと通常出力) を自動判別してパース、(2) コミット時刻のタイムゾーンの扱い —23:00+09:00を UTC に変換すると 14:00 になってしまい「いつ働いているか」が壊れる。
🌐 デモ: https://sen.ltd/portfolio/git-stats/
📦 GitHub: https://github.com/sen-ltd/git-stats
なぜブラウザで完結させるか
コミット統計は git log | awk や専用ツール (gitstats など) でも取れる。でもブラウザ完結だと:
- インストール不要、URL を開くだけ
- ログが外部に送られない — 業務リポジトリの作者名・メールを SaaS に貼るのは気が引ける
- 出力を貼るだけなので、自分のマシンに git が無くても (同僚のログをもらっても) 動く
入力は git log の出力テキスト。これをパースする。
2 フォーマットの自動判別
git log の出力は使うオプションで形が変わる。本ツールは 2 つに対応:
1. パイプ区切り (推奨) — git log --pretty=format:'%H|%an|%ae|%aI':
8e18b4d|Alice Tanaka|alice@example.com|2026-06-15T22:30:00+09:00
2. 通常出力 — オプション無しの git log:
commit 8e18b4d1234567890
Author: Alice Tanaka <alice@example.com>
Date: Mon Jun 15 22:30:00 2026 +0900
コミットメッセージ
判別は「パイプを 3 つ以上含み ISO 日付っぽい行があるか」のヒューリスティック:
export function parseGitLog(text) {
const lines = text.split("\n");
const looksPipe = lines.some((l) =>
l.split("|").length >= 4 && /\d{4}-\d{2}-\d{2}T/.test(l));
if (looksPipe) return parsePipe(text);
return parseDefault(text);
}
通常出力のパースは状態機械: commit 行で新しいコミットを開始、Author: / Date: 行で属性を埋める。
タイムゾーンの罠 — ここが本題
コミット時刻を「いつ働いているか」の指標にしたい。git log が記録する日時はこういう形:
2026-06-15T22:30:00+09:00 ← ISO
Mon Jun 15 22:30:00 2026 +0900 ← 通常形式
重要な事実: 表示されている時刻は既に作者のローカル時間。+09:00 は「この時刻は UTC+9 の壁時計だよ」という注記であって、適用すべきオフセットではない。
ここで new Date("2026-06-15T22:30:00+09:00").getHours() をやると大事故になる:
- JS は ISO 文字列をオフセット込みで解釈し、内部的に UTC (13:30Z) に変換
-
getHours()は実行環境のローカルタイムゾーンで返す - CI (UTC) で動かせば 13 時、ブラウザ (JST) なら 22 時 — 環境依存でブレる
正しくは「文字列に書かれている時刻の数字をそのまま読む」。オフセットは無視:
export function parseISO(s) {
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(s.trim());
if (!m) return null;
const [, y, mo, d, h, mi] = m.map(Number);
return { year: y, monthIdx: mo - 1, day: d, hour: h, minute: mi,
dow: dayOfWeek(y, mo - 1, d) };
}
テストで「オフセット調整されない」ことを固定:
test("local hour is read directly, NOT offset-adjusted", () => {
// 23:00+09:00 は hour 23 のまま。14 UTC にしてはいけない
const d = parseISO("2026-06-15T23:00:00+09:00");
assert.equal(d.hour, 23);
});
作者が深夜 23 時にコミットしたという事実は、その人のタイムゾーンでの 23 時。UTC に直したら「いつ働いているか」という問いの答えが壊れる。
曜日計算もタイムゾーンに注意
ISO 形式には曜日名が無いので、年月日から計算する。ここでも new Date(y, m, d) を使うとローカルタイムゾーンが絡むので、Date.UTC をカレンダー計算機としてだけ使う:
export function dayOfWeek(year, monthIdx, day) {
return new Date(Date.UTC(year, monthIdx, day)).getUTCDay();
}
test("2026-06-15 → Monday (1)", () => assert.equal(dayOfWeek(2026, 5, 15), 1));
test("2000-01-01 → Saturday (6)", () => assert.equal(dayOfWeek(2000, 0, 1), 6));
既にローカルに分解済みの (year, monthIdx, day) を Date.UTC に渡し getUTCDay() で取り出す。これでタイムゾーンが一切絡まない純粋な曜日計算になる。
パンチカード: 7×24 の heatmap
GitHub が昔提供していた punchcard と同じ考え方。曜日 (縦 7) × 時間 (横 24) のグリッドで、各セルのコミット数を濃淡で表す:
export function punchcard(commits) {
const grid = Array.from({ length: 7 }, () => new Array(24).fill(0));
for (const c of commits) grid[c.date.dow][c.date.hour]++;
return grid;
}
色は最大値で正規化した青のグラデーション。SVG で 7×24=168 個の rect を置くだけ:
const intensity = v / max;
const color = v === 0 ? "#161b22" : mix(intensity); // 暗い青→明るい青
パンチカードの良さは「平日の昼に固まっている (業務)」「深夜と週末に散っている (趣味/OSS)」が一目で分かること。サンプルデータだと月曜の夜と土曜に偏っていて、いかにも個人開発という分布になる。
集計テストは合計の保存則で守る
ヒストグラム系の集計は「合計がコミット総数と一致する」ことをテストすると漏れがない:
test("byHour sum equals total", () => {
assert.equal(byHour(SAMPLE).reduce((a, b) => a + b, 0), SAMPLE.length);
});
test("punchcard total equals commit count", () => {
let sum = 0;
for (const row of punchcard(SAMPLE)) for (const v of row) sum += v;
assert.equal(sum, SAMPLE.length);
});
「どのバケツに入るか」を個別に確認するより、「全部のバケツの和 = 入力数」という保存則の方が、取りこぼし・二重カウントを確実に捕まえる。
壊れた行は落とさず数える
現実の git log 出力にはマージコミットの余計な行などが混ざる。パースは壊れた行をスキップして数えるだけで、全体を止めない:
test("malformed lines are skipped, not fatal", () => {
const r = parseGitLog("good|A|a@x|2026-06-15T09:00:00+09:00\ngarbage line\n");
assert.equal(r.commits.length, 1);
assert.equal(r.skipped, 1);
});
UI には「15 件を解析 (pipe 形式) — 2 行スキップ」と出して、黙って間引いていないことを示す。
設計
parse.js ← git log → 正規化コミット (pipe + default 両形式, DOM-free)
stats.js ← author/hour/dow/punchcard/month/summary 集計 (DOM-free)
app.js ← SVG パンチカード + ヒストグラム
テスト 37 個。日付パース (ISO + git 形式) + フォーマット判別 + skip 処理 + 全集計。
試してみる
自分のリポジトリで git log --pretty=format:'%H|%an|%ae|%aI' | pbcopy して貼ってみてほしい。自分が「夜型」か「朝型」か、パンチカードが教えてくれる。
まとめ
- コミット統計はブラウザ完結にできる。ログを外部に送らないのが業務リポジトリでの安心。
-
git logの時刻は既に作者のローカル時間。new Date(iso).getHours()は実行環境 TZ でブレるので、文字列の数字を直接読む。 - 曜日は
Date.UTC(y,m,d)をカレンダー計算機としてだけ使えば TZ 非依存。 - パンチカードは 7×24 グリッド。SVG の
rectを並べるだけで「いつ働いているか」が可視化できる。 - 集計テストは合計の保存則 (Σバケツ = 入力数) で守ると漏れない。
- パースは壊れた行をスキップして数える。黙って間引かず UI に出す。
これは SEN 合同会社の OSS ポートフォリオ #264 です。https://sen.ltd/portfolio/
