Dev Study
← 開発の実務
レッスン 7 / 12·約 8 分·第16期 開発の実務

コミットの粒度は、後で遡る道具の解像度

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

前回は、落ちた場所と壊れた場所が違うことを見ました。今回は、「壊れている」と判定する手順を過去の履歴の上で走らせ、隣り合う2つのコミットを比べます。

まず結論

git bisect も git blame も、答えを「コミット」で返します。返ってきたコミットと、その親(1つ前)との違いが、道具が言える最も細かい答えです。

だから答えの細かさは道具ではなく、コミットの切り方で決まります。バグ1行と無関係な変更を1コミットにまとめてあれば、正しく動いた bisect も「この全部のどこか」としか言えません。

? 読む前に予測

送料の条件 `>=` を `>` に誤って変えたバグがあります。最終内容が同じ2つの履歴 ——(A)6コミットに分けたもの、(B)初期版のあと1コミットにまとめたもの—— に、同じテストで `git bisect run` をかけると、どうなるでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

遡る道具は、隣り合うコミットを比べている

図解コース『Gitの内部構造から理解する』で見たとおり、コミットはスナップショットと親への参照を持ちます。だから、どのコミットにも「1つ前との違い」が定まります。

回帰を追うとき、「今は壊れている」だけでは「いつから」が言えません。壊れていない版と比べて初めて言えます。git bisect は、判定手順(テスト)を過去のコミットで走らせ、判定が良いから悪いへ変わる境目を探します。返すのは first bad commit ——親では良く、自分では悪いコミットです。探索の回数は『半分にすると、なぜ20回で済むのか』に譲ります。

この回で見るのは、答えの単位です。境目の片側は親、もう片側はそのコミット。その間に何行の変更があっても、道具は区別しません。

Fowler はこの調べ方を Diff Debugging と呼び、こう書きます。

It helps greatly to have small commits, due to high-frequency integration. That way when we find the guilty commit, we can more easily narrow down what happened.

bisect が言えるのは、隣り合う2つのコミットの間まで
親コミット判定: 良い
犯人コミット判定: 悪い
この2つの間に入った変更が、答えの全部
小さく切った履歴1行追加・1行削除
まとめた履歴7行追加・3行削除。変更5つ
  • ・どちらも bisect は正しく動いている。違うのは、境目の間に入っていた量
  • ・コミットより細かい区別は、履歴を作った時点で失われている
具体実物

同じバグ・同じ最終内容で、答えの大きさが変わる

仕様は「送料は300円以上で無料」、テストは shipping(300) === 0 を含みます。バグは >= を > にした1文字です。履歴Aは6コミット、履歴Bは同じ最終内容を初期版のあと1コミットにまとめたもので、最終内容が同一であることは diff exit=0 で確かめてあります。どちらにも git bisect run node test.js をかけました。

履歴A: bisect の答えと犯人コミットの差分(抜粋。git 2.50.1)コマンド出力
c854abee0211f3ded17a4734bd2a63c328ef8aa5 is the first bad commit
refactor shipping condition
shop.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
-function shipping(total) { return total >= 300 ? 0 : 500; }
+function shipping(total) { return total > 300 ? 0 : 500; }
履歴B: 犯人コミットの git show(git 2.50.1)コマンド出力
c826141 misc changes
 
shop.js | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/shop.js b/shop.js
index 710a30b..ee1c90c 100644
--- a/shop.js
+++ b/shop.js
@@ -1,4 +1,8 @@
-function price(qty, unit) { return qty * unit; }
-function shipping(total) { return total >= 300 ? 0 : 500; }
-function tax(total) { return Math.floor(total / 10); }
+function price(qty, unit) {
+ return qty * unit;
+}
+function shipping(total) { return total > 300 ? 0 : 500; }
+function tax(total) { return Math.floor(total * 0.1); }
module.exports = { price, shipping, tax };
+// shipping rule: free at 300 yen or more
+// tax: 10%, rounded down

履歴Aの答えは1行です。履歴Bの答えには、バグ1つと無関係な変更4つ(整形・コメント2つ・税の計算)が入っています。

しかも Math.floor(total * 0.1) は、掛け算に変わっていていかにも怪しく見えます。このテストの値では Math.floor(300 * 0.1) は30で、判定は通ります。差分を目で読む人は、先にこちらを疑いがちです。

✓ ここまでの確認

履歴Bで bisect が7行追加・3行削除のコミットを返したのは、なぜですか。

抽象原理

行ごとに遡る道具も、同じ単位で答える

git blame も同じ構造です。man は Annotates each line in the given file with information from the revision which last modified the line. と書きます。行ごとに「最後に変えたコミット」を返すので、答えの単位はやはりコミットです。

ここで、全行の先頭空白を変える整形コミットがあると、全行の答えがそれになります。整形は嘘ではありません。最後に行を変えたのは本当に整形です。ただ、知りたかった「この条件を書いたのは誰か」には届きません。

git はこれに対してコミット単位の無視を用意しています。

Ignore changes made by the revision when assigning blame, as if the change never happened. Lines that were changed or added by an ignored commit will be blamed on the previous commit that changed that line or nearby lines.

無視できる単位もコミットです。整形だけのコミットなら、丸ごと無視しても何も失いません。 中身の変更と混ざっていたら、中身の変更も前のコミットに帰属されることがあります。整形と同時に >= を > に変えたコミットで試すと、> の行は元の作者 alice に帰属されました(git 2.50.1)。逆に帰属先が見つからない行は、無視したコミットのまま残ります(man の blame.markUnblamableLines の節)。どちらになっても、中身を変えたのが誰かは正しく出ません。

具体実物

整形コミットが、全員の名前を上書きする

alice が関数を書き、bob が3行目を「空のカートは送料0」に変え、carol がインデントを2から4に整形しました。

git blame の出力(git 2.50.1。$ の行は実際に渡した引数を書き足したもの)コマンド出力
$ git blame --date=short shop.js
^09a5aca (alice 2026-01-05 1) function shipping(total) {
e8ce8169 (carol 2026-03-20 2) if (total >= 300) return 0;
e8ce8169 (carol 2026-03-20 3) return total > 0 ? 500 : 0;
^09a5aca (alice 2026-01-05 4) }
$ git blame --date=short --ignore-revs-file .git-blame-ignore-revs shop.js
^09a5aca (alice 2026-01-05 1) function shipping(total) {
^09a5aca (alice 2026-01-05 2) if (total >= 300) return 0;
083858d7 (bob 2026-02-10 3) return total > 0 ? 500 : 0;
^09a5aca (alice 2026-01-05 4) }

無視リストには carol のコミットの短縮しないハッシュを1行だけ書きました。2行目は alice、3行目は bob に戻ります。^ は境界コミットの印です(この例では最初のコミット)。

✓ ここまでの確認

整形コミットを `--ignore-revs-file` で無視して、bob と alice の名前が戻りました。この実演で何が前提になっていましたか。

⇄ 2つを見比べる
具体例 A

git bisect(コミットを遡る)

同じバグ・同じ最終内容でも、1コミットにまとめた履歴では、バグと無関係な変更4つが混ざった差分が答えになりました。

犯人コミットの大きさ表
履歴 コミット数 犯人コミットの差分
A 6 1行追加・1行削除
B 2 7行追加・3行削除
具体例 B

git blame(行を遡る)

整形コミットが全行を触ったため、3行目の答えは carol になりました。整形が独立したコミットだったので、無視すると bob に戻りました。

3行目に返ったコミット表
条件 返ったコミット
そのまま carol(整形)
整形コミットを無視 bob(空カートは送料0)

コミットを遡る道具と、行を遡る道具。答えが粗くなった原因は同じでしょうか。

道具が返す単位と、その単位の中身を決めたのが誰かを比べてください。

この章のまとめ

  • bisect も blame も、答えはコミット。親との違いが、言える最も細かい答え。
  • 同じバグ・同じ最終内容でも、まとめた履歴では答えが7行追加・3行削除に広がった。bisect は正しく動いている。
  • 差分に混ざった無害な変更(Math.floor(total * 0.1))は、人の目を先に引く。
  • 整形コミットは blame の答えを上書きする。--ignore-revs-file で外せるのは、整形が独立したコミットのときだけ。
  • Fowler は小さなコミットを勧めつつ、長期の履歴の squash にも触れている。「常に細かく」とは言っていない。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

Fowler の「Diff Debugging」(2023年改稿版)は、コミットの大きさについて何と書いていますか。

用語集(3語)
回帰
以前から動いていた機能に新しく現れたバグ。Fowler は `Regression bugs are newly appeared bugs in features of the software that have been around for a while.` と書く。
first bad commit
bisect が返すコミット。親では判定が良く、自分では悪い。答えの細かさはこのコミットの差分の大きさで決まる。
--ignore-revs-file
git blame で無視するコミットを列挙したファイルを渡すオプション。1行に短縮しないハッシュを1つ書く。