半年前の一行を、差分から思い出せませんでした
八月の終わりの夜、壁紙アプリの AdMob メディエーション設定を見返しておりました。あるアダプタの優先度を下げる変更が半年前に入っていて、その理由をどうしても思い出せませんでした。
コミットメッセージには Update mediation config とだけありました。差分を開けば、何をどこで変えたかはひと目で分かります。けれど、なぜ下げたのかは、どこにも残っておりませんでした。
その変更を通したのは私自身です。下書きされたメッセージを読んで、内容が合っていることを確かめて、そのままコミットしました。手を抜いた記憶がまったくないところが、いちばん厄介でした。
Antigravity 2.0 系の Review ペインに Git のバージョン管理が入り、ステージ済みの変更に対して自動生成されたコミットメッセージをプレビューできるようになりました。私はこの変更を歓迎しております。同時に、あの夜の失い方が前より起きやすくなるとも感じています。
そこで線引きを決めました。差分から読めることは自動に、差分から消えることは自分の手で。 この一文を運用に落とし込むまでの手順と、途中で作業そのものを止めてしまった失敗を書き残します。
Agent Edits と Uncommitted が分けているのは「出どころ」です
Review ペインのビューは3つに分かれています。Agent Edits はエージェントが触った変更、Uncommitted は未コミットの変更全体、Branch はブランチの状態です。差分は split と unified を選べて、ステージ済みの変更には専用のビューがあります。
この3分割は、Git クライアントとしては珍しい切り方だと思います。従来のツールはファイルの状態、つまり未追跡・変更済み・ステージ済みで分けます。Antigravity は変更の出どころで分けています。
ここで、最初に私が取り違えていたことをお伝えします。Agent Edits に並ぶのは「エージェントが書き換えたファイル」であって、「エージェントが意図して変えたファイル」ではありません。
保存時にフォーマッタが走れば、触っていない行の並びも一緒に載ります。生成物のパスを .gitignore に入れ忘れていれば、ビルド成果物も載ります。私は一度、Xcode のスキームに関する差分が Agent Edits に混ざったまま、そのビューごとステージしてしまいました。
ビューがわざわざ3つに分かれているのは、見分けさせるためです。分けた先を読まずにまとめてコミットすると、分けた意味が消えてしまいます。エージェントの判断そのものを追える形にしておく話は、Antigravity エージェントの意思決定ログと説明可能性の設計にも書きました。
差分から読めることと、差分から消えること
線引きの基準は一つだけにしました。半年後に git log と差分だけを見て復元できるかどうか、です。復元できるものは自動生成に任せて、復元できないものは自分で書きます。
| メッセージに載る情報 | 差分から復元できるか | 誰が書くか |
| 変更したファイル・関数・行数 | できます | 自動生成 |
| 変更の種類(追加・修正・削除) | できます | 自動生成 |
| 影響範囲の要約 | おおむねできます | 自動生成 |
| その値を選んだ理由 | できません | 私 |
| 採用しなかった選択肢 | できません | 私 |
| 元に戻してよい条件 | できません | 私 |
自動生成が下手だから自分で書く、という話ではありません。むしろ要約は私より正確です。要約の材料が差分にしかない以上、差分に現れないものは、どれだけ賢いモデルでも書けないのです。
冒頭のメディエーション設定でいえば、優先度を下げた事実は差分に残っておりました。残っていなかったのは、そのアダプタの表示率が特定の地域だけ落ちていたという観測と、次のリリースで戻す前提だったという条件のほうです。
そこで私は、本文の書式を2行だけ決めました。Why: に判断の材料を、Revert-if: に元へ戻してよい条件を書きます。この2行は Git のトレーラとして解釈されるので、あとから機械で取り出せます。
prepare-commit-msg に空の Why 行だけを置きます
自動生成されたメッセージを消すつもりはありません。残したまま、末尾に空欄を2行足します。空欄が埋まっていないコミットは、その場で止めます。
#!/bin/sh
# .git/hooks/prepare-commit-msg
# $1 = メッセージファイル / $2 = メッセージのソース / $3 = リビジョン
MSG_FILE="$1"
SOURCE="$2"
GIT_DIR_PATH=$(git rev-parse --git-dir)
# 1) 再生・統合の最中は何もしない(後述の実測で必要になった分岐)
if [ -d "$GIT_DIR_PATH/rebase-merge" ] || [ -d "$GIT_DIR_PATH/rebase-apply" ] \
|| [ -f "$GIT_DIR_PATH/CHERRY_PICK_HEAD" ] || [ -f "$GIT_DIR_PATH/MERGE_HEAD" ]; then
exit 0
fi
case "$SOURCE" in
merge|squash) exit 0 ;;
esac
# 2) すでに Why 行が埋まっているなら触らない
if grep -qE '^Why:[[:space:]]*\S' "$MSG_FILE"; then
exit 0
fi
# 3) 自動生成された本文はそのまま残し、空欄だけを足す
printf '\nWhy: \nRevert-if: \n' >> "$MSG_FILE"
# 4) -m の一発コミットは編集画面が開かないので、ここで止める
if [ "$SOURCE" = "message" ]; then
echo "prepare-commit-msg: Why が空です。git commit -m ではなく git commit で本文を書いてください。" >&2
exit 1
fi
exit 0
ファイルに実行権限を付けます。
chmod +x .git/hooks/prepare-commit-msg
手元の git 2.34.1 で動きを確かめました。git commit -m "add f" は 4 番の分岐で終了コード 1 を返して止まります。本文に Why: を含めた -m は 2 番で素通りします。エディタを開くコミットでは、自動生成された要約の下に空欄が2行だけ足された状態で編集画面が開きます。
止める側を commit-msg ではなく prepare-commit-msg に置いたのには理由があります。commit-msg は入力後の検査なので、書き終えてから怒られます。prepare-commit-msg なら、書き始める前に空欄が用意されているのです。
$2 だけでは、手打ちと再生を見分けられませんでした
最初に書いたフックには、1 番の分岐がありませんでした。$2 を見れば git commit -m とリベースの再生は区別できると思い込んでいたのです。実際にはできませんでした。
同じ git 2.34.1 で、フックの中から $2 と .git 配下の状態ファイルを記録して確かめた結果が下の表です。
| 操作 | $2 の値 | .git 配下の状態 |
| git commit -m | message | なし |
| git rebase の再生 | message | rebase-merge と CHERRY_PICK_HEAD |
| git cherry-pick | message | CHERRY_PICK_HEAD |
| git revert --no-edit | message | なし |
| git merge --no-ff | merge | MERGE_HEAD |
| git commit(エディタ) | 空文字 | なし |
| commit.template 設定時 | template | なし |
リベースもチェリーピックも、$2 は手打ちと同じ message を返します。つまり $2 だけで判定するフックは、規約を決める前に積み上げた古いコミットを再生した瞬間に、リベースの途中で止まります。私はこれを、複数サイトのブランチをまとめ直している最中に踏みました。作業の中断としてはかなり質が悪いほうでした。
この落とし穴を回避する手がかりは、.git 配下の状態のほうにあります。リベース中は rebase-merge ディレクトリが、チェリーピック中は CHERRY_PICK_HEAD が、その時点ですでに存在しています。1 番の分岐はここを見ています。
表の中で一つだけ、予想と逆だったものがあります。git revert --no-edit は、フックが走る時点で REVERT_HEAD を見せてくれませんでした。素通しにする条件を立てられないので、リバートは普通のコミットと同じ扱いになります。
私はこれを、直すべき穴ではなく、そのまま残す仕様として受け入れました。何かを元に戻すコミットこそ、なぜ戻したのかが後で必要になるからです。取り消しの理由が空欄のまま通ってしまうより、そこで一度手を止めるほうが助かります。
分割を細かくするほど、メッセージの重みは Why へ寄ります
もう一つ、事前の見込みと逆だったことがあります。コミットを細かく分ければ理由書きは要らなくなるだろう、と考えておりました。実際には逆でした。
エージェントが一度に9ファイルを書き換えた差分を、意味のまとまりごとに分けてコミットしていくと、1件あたりの差分は小さくなります。差分が小さいほど、何をしたかは自明になります。件名は Bump AdMob adapter priority for JP のように具体的になり、要約としては十分です。
すると残るのは、なぜその粒度で切ったのか、なぜその順番なのか、という部分だけです。分割の粒度を上げるほど、件名は自動生成で足りるようになり、本文の価値は理由へ寄っていくのです。
裏を返すと、粗いコミットほど自動生成の要約は役に立ちません。9ファイルの変更を1件にまとめた要約は「複数の設定を更新しました」に近づいてしまいます。要約が効くのは、差分がすでに一つのことを語っているときだけです。
私は結局、分割の作業そのものはエージェントに任せて、分けた理由だけを自分で書く形に落ち着きました。Inline Edit と Agent モードの使い分けについては壁紙アプリの運用で1ヶ月使い分けた所感にまとめてあります。
半年後の自分が打つコマンドを、先に決めておきます
書式を決めるときに、取り出し方まで一緒に決めておくことをお勧めします。書き方だけ決めて放置すると、Why: と 理由: と WHY: が混ざり、半年後に検索できなくなります。
# 理由が書かれているコミットだけを一覧する
git log --grep='^Why:' --format='%h %ad %s' --date=short
# 件名と理由を1行に並べる(Why はトレーラとして取り出せます)
git log --format='%h%x09%s%x09%(trailers:key=Why,valueonly=true)' | head -20
# 特定の設定値がいつ出入りしたかを追い、その前後の理由を読む
git log -S'adapter_priority' --format='%h %s%n %(trailers:key=Why,valueonly=true)'
# 規約を守れていない直近のコミットを数える
git log -30 --format='%H' | while read h; do
git log -1 --format='%B' "$h" | grep -qE '^Why:[[:space:]]*\S' || echo "no-why $h"
done
%(trailers:key=Why,valueonly=true) が使える形にしておくと、あとで表を組むのが楽になります。トレーラとして解釈させるには、Why: と Revert-if: をメッセージの末尾ブロックに置いて、そのあとに普通の文を書かないことが条件です。フックが末尾へ追記しているのは、この条件を自動で満たすためでもあります。
Revert-if: のほうは、書くときより読むときに効きます。段階公開を 5% から広げていく途中で不具合が出たとき、どのコミットを先に戻せばよいかの判断が速くなりました。Crash-free users の閾値を割った日に、候補を絞る材料が本文に残っているかどうかで、対応の落ち着き方がかなり変わるのです。
来週の月曜に見るもの
一度に整えようとして、私は最初につまずきました。フックを全リポジトリへ配って、規約を過去へ遡って適用しようとして、リベースの途中で止めてしまったのです。いまは新しいコミットだけを対象にしています。
まずは1つのリポジトリに prepare-commit-msg を置いて、Why: の空欄チェックだけを有効にしてみてください。一週間ためてから git log --grep='^Why:' を眺めると、自分が本当に理由を残したかった場面が、思っていたより偏っていることに気づけるかもしれません。私の場合は、設定値の変更とリバートの2つに集中しておりました。
作業環境そのものの前提を確かめる話はエージェントの作業ルートと境界の扱いにも書いております。最後までお読みくださり、ありがとうございました。