エージェントに任せた変更が溜まってくると、レビューの入口で手が止まります。git diff --stat を開くと、ファイルがパス順に並んでいるだけで、どれから読めばよいのか分かりません。
そこで、差分を「読む優先度」で並べ替える小さなスクリプトを書きました。設定ファイルや実装を先に、本文や生成物を後ろに回すだけの道具です。
書き終えてすぐ、常用するかどうかで迷いました。便利そうに見える一方で、私の普段の差分は小さいものが大半で、並べ替えが効く場面は思ったほど多くないような気もしたのです。
迷ったときは、勘ではなく自分の履歴に当てて数えます。個人開発で運営している Web サイトのリポジトリに残っている、直近のコミットで試しました。結論を先にお伝えすると、効いたのは 106 コミット中 8 件と、1,776 ファイルを一度に書き換えた 1 回でした。並べ替えが効かない日のほうが多いのかもしれない、という最初の予感は、おおむね当たっていました。
並べ替えの規則は、先に決めて固定します
規則を数字を見てから調整すると、結果に合わせた分類になってしまいます。そのため、先に次の 8 区分と順番を決め、あとで動かさないことにしました。
| 読む順 | 区分 | 入るもの | 先に読む理由 |
|---|---|---|---|
| 1 | secrets-infra | .env 系、.dev.vars、wrangler 設定、CI のワークフロー、YAML | 行数は少なくても、秘密情報や公開範囲に直結します |
| 2 | deps | package.json、requirements、pyproject | 依存の追加は、本文より先に確認したいものです |
| 3 | logic | ts、tsx、js、mjs、py など | 動作を変える本体です |
| 4 | tests | tests ディレクトリ、spec と test のつくファイル | ロジックを読んだあとに、意図と照らします |
| 5 | style / data / content | css、json や csv、Markdown | 見た目と本文は、壊れても気づきやすい部類です |
| 末尾 | skip / binary | ロックファイル、min 化したファイル、画像やフォント | 人の目で読んでも得るものがありません |
ここで 1 つだけ、自分の判断を入れています。行数の多い順ではなく、壊したときの影響が大きい順に読む、という線引きです。
行数順は一見公平ですが、数行の設定変更が一番大きな事故になることがあります。後述する落とし穴の対策も含め、この順番を固定しました。
完成したスクリプトの全文です
標準ライブラリだけで動きます。git diff --numstat の出力を区分に振り分け、読む順に並べ直します。
#!/usr/bin/env python3
"""git の差分を『読む順番』に並べ替える。使い方: diff_triage.py <rev-range> [repo]"""
import re, subprocess, sys
from collections import defaultdict
# 上から順に最初に当たった区分を採用する。tests は logic より前に置く(拡張子 .py が先に当たるのを防ぐ)
RULES = [
("secrets-infra", re.compile(r"(^|/)(\.env[^/]*|\.dev\.vars|wrangler\.(toml|jsonc?)|\.github/workflows/[^/]+|Dockerfile|[^/]*\.ya?ml)$")),
("deps", re.compile(r"(^|/)(package\.json|requirements[^/]*\.txt|pyproject\.toml|go\.mod)$")),
("tests", re.compile(r"(^|/)(tests?|__tests__)/|\.(test|spec)\.[a-z]+$")),
("logic", re.compile(r"\.(tsx?|jsx?|mjs|py|go|rs|swift|kt)$")),
("style", re.compile(r"\.(css|scss)$")),
("content", re.compile(r"\.(mdx?|txt)$")),
("data", re.compile(r"\.(json|csv|tsv)$")),
]
SKIP = re.compile(r"(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|.*\.min\.(js|css)|.*\.snap)$")
ORDER = ["secrets-infra", "deps", "logic", "tests", "style", "data", "content", "other"]
def run(cmd, repo):
return subprocess.run(cmd, cwd=repo, capture_output=True, text=True, check=True).stdout
def bucket(path):
if SKIP.search(path):
return "skip"
for name, rx in RULES:
if rx.search(path):
return name
return "other"
def triage(rev, repo="."):
rows = []
for line in run(["git", "-c", "core.quotepath=false", "diff", "--numstat", "-M", rev], repo).splitlines():
add, dele, path = line.split("\t", 2)
path = path.strip('"') # 空白や記号を含む名前は git が引用符で包む
if "=>" in path: # 改名は 'a/{old => new}.ts' の新しい側を採る
path = re.sub(r"\{.*? => (.*?)\}", r"\1", path).replace("//", "/")
if add == "-": # バイナリは行数が取れない
rows.append(("binary", path, 0, 0)); continue
rows.append((bucket(path), path, int(add), int(dele)))
return rows
def render(rows):
by = defaultdict(list)
for b, p, a, d in rows:
by[b].append((p, a, d))
out = []
for b in ORDER + ["binary", "skip"]:
files = sorted(by.get(b, []), key=lambda x: -(x[1] + x[2]))
if not files:
continue
total = sum(a + d for _, a, d in files)
out.append(f"[{b}] {len(files)} files / {total} lines")
if b in ("skip", "content") and len(files) > 3:
out.append(" (一覧のみ・先頭 3 件) " + ", ".join(p for p, _, _ in files[:3]))
else:
out += [f" {a:>5}+ {d:>5}- {p}" for p, a, d in files[:8]]
return "\n".join(out)
if __name__ == "__main__":
print(render(triage(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else ".")))分類の規則は上の表と同じで、RULES の並びがそのまま優先順位です。本文と生成物は 3 件だけ名前を出して畳み、実装と設定は全件を行数順に見せます。
使うときは python3 diff_triage.py HEAD~1..HEAD . のように、見たいリビジョンの範囲を渡します。