壁紙アプリの配信まわりで使っている小さな管理ツールの履歴を、週末の夜に見返していたときのことです。エージェントに書いてもらったリトライ処理の PR は、数日前に私が承認して、すんなりマージしていました。
ところが、同じファイルのログをもう少し下まで辿ると、その数日後に「エッジケースを直す」という私自身のコミットが並んでいました。直していたのは、まさにあの PR が足した行でした。
承認したときの私は、差分を読み、テストが通るのを見て、問題なしと判断していました。その判断が外れていたこと自体よりも、外れていたことに一週間近く気づかなかったことのほうが、胃が痛くなりました。
レビューで見抜けなかったものは、マージのあとにしか現れません。そこで、マージ後の手直しを数字で拾える仕組みを手元に置くことにしました。その作り方と、作る途中で数字が狂った話を書き残します。
最初にお伝えしたいのは、「通った」は品質の指標ではないということです
レビューを通過した割合は、エージェントの出力の良し悪しを測っているようで、実はレビューする側の通しやすさも一緒に測っています。差分がきれいに整っていて、説明文も丁寧なら、人の目は安心して先へ進みやすいのです。
一方で、マージ後に同じ行が書き直された割合は、レビューの通しやすさとは切り離されます。その行が本当に役目を果たしたかを、時間が経ってから確かめる数字だからです。
そこで私は、承認はその日の私が、答え合わせは二週間後の git がという線引きにしました。レビューの手順を増やす前に、まず答え合わせの道具を一つ置くことにしたのです。
何を数えるか
数えるのは「残存率」です。あるコミットが追加した行のうち、N 日後のリポジトリでもまだそのコミットに帰属している行の割合を指します。
- 追加した行数は
git show --numstatで取れます - N 日後の時点のリビジョンは
git rev-list -1 --before=<日時>で引けます - その時点で誰の行かは
git blame --porcelainで数えられます
対象をエージェントのコミットに絞るため、コミットメッセージに Agent: antigravity という来歴の一行(Git trailer)を入れておきます。トレーラの設計そのものは、コミット粒度と来歴トレーラの設計に詳しく書いています。
# Git 2.32 以降は --trailer でトレーラを付けられます
git commit -m "Add retry for asset upload" --trailer "Agent: antigravity"残存率を数えるスクリプト
リポジトリの直下で実行すると、エージェントのコミットごとに「追加行・残った行・残存率」を1行ずつ出します。まだ N 日経っていないコミットは数えません。
#!/usr/bin/env python3
# マージから N 日後に、そのコミットが足した行がいくつ残っているかを数える
import subprocess, sys
def git(*args):
return subprocess.run(["git", *args], capture_output=True, text=True, check=True).stdout
def added_lines(sha):
# そのコミットが追加した行数(ファイル別)。バイナリは "-" になるので除外
out = git("show", "--numstat", "--format=", sha)
counts = {}
for row in out.splitlines():
a, _, path = row.split("\t", 2)
if a != "-" and int(a) > 0:
counts[path] = int(a)
return counts
def surviving(sha, at_rev, path):
# at_rev 時点の blame で、まだ sha に帰属している行を数える
try:
out = git("blame", "--porcelain", "-w", "-M", at_rev, "--", path)
except subprocess.CalledProcessError:
return 0 # ファイルごと消えた・改名を追えなかった
n = 0
for line in out.splitlines():
parts = line.split(" ")
# 行ごとのヘッダは「sha 元の行 今の行 [行数]」。本文行はタブで始まるので数えない
if parts[0] == sha and len(parts) in (3, 4):
n += 1
return n
def main(days, grep):
rows = git("log", "--no-merges", f"--grep={grep}", "--format=%H %ct").split("\n")
head_time = int(git("log", "-1", "--format=%ct").strip())
for row in filter(None, rows):
sha, ct = row.split()
limit = int(ct) + days * 86400
if limit > head_time:
continue # まだ N 日経っていないコミットは数えない
at_rev = git("rev-list", "-1", f"--before={limit}", "HEAD").strip()
add = added_lines(sha)
total = sum(add.values())
if total == 0:
continue
kept = sum(surviving(sha, at_rev, p) for p in add)
print(f"{sha[:8]} added={total:4d} kept={kept:4d} survival={kept/total:5.0%}")
if __name__ == "__main__":
main(int(sys.argv[1]) if len(sys.argv) > 1 else 14, sys.argv[2] if len(sys.argv) > 2 else "Agent:")-w は空白だけの変更を「書き直し」と数えないため、-M は同じファイル内で行を移動しただけのものを元のコミットに帰属させたままにするために付けています。ファイルをまたいだ移動まで追いたい場合は -C を足せますが、そのぶん遅くなります。
小さなリポジトリで動きを確かめました
手元の Linux 環境(Git 2.34.1・Python 3.10)に、検証用のリポジトリを作りました。エージェントのコミットを2つ置き、片方が足した40行のうち12行だけを、4日後の別コミットで書き換えています。
python3 survival.py 14 "Agent:"
# 8dc1f602 added= 30 kept= 30 survival= 100%
# ba896ce2 added= 40 kept= 28 survival= 70%
python3 survival.py 3 "Agent:"
# 8dc1f602 added= 30 kept= 30 survival= 100%
# ba896ce2 added= 40 kept= 40 survival= 100%3日の窓では手直しの前なので100%、14日の窓では手直しを含むので70%になります。5コミットの小さな履歴で、実行にかかった時間は0.04秒ほどでした。実際のリポジトリでは blame の回数ぶん時間が延びるので、まずは直近1か月分のコミットから流していただくのがよいと思います。
数字が一度狂った話 — 日付の並びです
最初に検証用のリポジトリを組んだとき、3日の窓でも70%が出ました。手直しは4日後なので、本来は100%のはずです。
原因は、私が検証用のコミットの日付を、履歴の順番と食い違うように付けていたことでした。rev-list --before は履歴を新しい側から辿り、指定より古い日付のコミットを最初に見つけたところで止まります。手直しより後ろに「日付だけ古い」コミットがあると、そこが3日後の時点として選ばれ、手直しを含んだ状態で数えてしまうのです。
検証用だけの話ではありません。rebase や cherry-pick を挟むと、コミッター日時は付け直され、履歴の順番と日付の順番がずれることがあります。数える前に、本流の日付が順番どおりに並んでいるかを一度だけ確かめるようにしています。
# 本流を親の順に辿り、日付が後戻りしている箇所を出す(何も出なければ問題なし)
git log --first-parent --format='%ct %h' | awk 'NR>1 && $1>prev {print "日付が逆転:", $2} {prev=$1}'数字が出たあと、どこで止めるか
残存率は、点数ではなく「どこを見直すか」の目印として使っています。数字だけで良し悪しを決めると、今度は数字を上げるための PR が増えるからです。
- 残存率が低いコミットを3つ選び、手直しの中身を読みます。仕様の読み違いか、テストの穴か、私のレビューの見落としかを一行ずつ書き分けます
- 同じ理由が2回続いたら、依頼の切り方を変えます。レビューできる単位でタスクを切る話のとおり、まず依頼を小さくします
- 追加行が大きいのに残存率も高いコミットは、むしろ丁寧に読み直します。誰も触っていないだけ、ということもあるからです
要するに、残存率はレビューの代わりではなく、レビューの答え合わせです。
いま思えば、あの夜に引っかかったのは手直しそのものではありませんでした。承認から手直しまでの数日間、自分の判断が外れていたことを知る手段を何も持っていなかった——そのことのほうが、ずっと気になっていたのだと思います。個人開発ではレビューする人が自分ひとりなので、答え合わせの仕組みを外に置いておかないと、同じ見落としを何度でも繰り返してしまうのかもしれません。差分を小さく刻む運用については、1か月の運用で変えたレビューの流儀にも書いています。
まずは一つだけ
いちばん手軽な始め方は、今日からエージェントのコミットに Agent: のトレーラを付けておくことです。二週間後にスクリプトを一度流すだけで、どの PR の後始末に自分の時間が使われていたかが見えてきます。
承認した日の自分の判断を、二週間後の自分が確かめ直します。この往復を、私自身はしばらく続けてみるつもりです。