1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

git log をブラウザに貼るだけでコミット統計を可視化する — パンチカード heatmap とタイムゾーンの罠

1
Posted at

「このリポジトリ、みんないつコミットしてるんだ?」を可視化するツールを作った。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/

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?