2026-09-05 · テスト · git · 設計 · typescript · 個人開発
「動いた」を検証と見なすと、1コミットでだけ通るコードが出荷される
有料で配っているスクリプトに、バグを1つ入れたまま出しました。 しかも私は、そのバグを「4シナリオで検証済み」と報告していました。
検証はしていました。検証したつもりになる形で。
何が壊れていたか
git worktree で作った作業ツリーを、マージ済みなら片付けるスクリプトです。
判定の一部に、squash マージの検出が入っていました。
# 壊れていた判定
cherry="$(git cherry "$base" "$branch")"
[ -z "$cherry" ] && return 1
printf '%s\n' "$cherry" | grep -q '^+' && return 1
return 0 # 全行が - なのでマージ済み
git cherry は、ブランチのコミットが base 側に同等物を持つかを - / + で返します。
全部 - ならマージ済み、という判定です。
これは誤りです。git cherry は patch-id の一致で判定するので、
複数のコミットを1つに潰した squash では、原理的に一致しません。
手元で確かめた結果です。
# 1コミットのブランチを squash merge した後
$ git cherry main one
- 52b3d961...
# 2コミットのブランチを squash merge した後
$ git cherry main two
+ 02577c73...
+ 221f83ce...
1コミットのときだけ、たまたま通ります。
なぜ気づかなかったか
検証用のリポジトリを作って、4つのシナリオを試していました。
- 作業なし → 消さない ✓
- 通常マージ → 消す ✓
- 未マージ → 消さない ✓
- squash マージ → 消す ✓
4つとも期待どおりでした。そして4つ目の検証用ブランチは、 コミットが1つしかありませんでした。
検証コードはこう書いていました。
git checkout -qb squash main
echo x > x.txt && git add -A && git commit -qm c1 # ← 1コミット
git checkout -q main && git merge -q --squash squash && git commit -qm "squash"
テストを書くとき、最小の再現を作ろうとするのは自然です。 そしてその「最小」が、たまたま壊れた実装が通る唯一の形でした。
squash は「複数のコミットを1つに潰す」操作です。 1コミットの squash は、squash の性質を何も持っていません。 私は squash を検証したつもりで、squash ではないものを検証していました。
直し方は「もっと賢く判定する」ではなかった
最初に考えたのは、より正確な判定方法を探すことでした。 差分の内容を比較する、ツリーのハッシュを見る、といった案です。
やめました。誤って「マージ済み」と判定すると、作業ツリーを消してしまうからです。 消す側に倒れる誤りは、残す側に倒れる誤りより桁違いに高くつきます。
結論は、判定しないことでした。
# squash merge はここでは判定しない。
#
# git cherry は patch-id の一致で見るため、複数コミットを1つに潰した squash では
# 原理的に一致しない。実測: 1コミットのブランチは `-`、2コミットは `+ +` を返す。
# つまり1コミットのときだけ偶然動いていた。
#
# 誤って「マージ済み」と判定すると作業ツリーを消してしまうので、
# 当てにならない推測はしない。gh があれば PR の状態で確実に分かる。
return 1
そのうえで、判定できないことを黙らせないようにしました。
if ! command -v gh >/dev/null 2>&1; then
echo " 注意: gh コマンドが無いため、squash マージされたブランチは判定できません。"
echo " 残った中に squash 済みのものがあれば、手で remove してください。"
fi
黙って何もしないと、「動いていない」と誤解されます。 できないことは、できないと出力する。
再検証は、境界ではなく「性質」で作る
直した後、シナリオを作り直しました。
| ブランチ | コミット数 | 期待 | 結果 |
|---|---|---|---|
| 作業なし | 0 | 残す | ✓ |
| 通常マージ | 2 | 消す | ✓ |
| 未マージ | 2 | 残す | ✓ |
| squash(1コミット) | 1 | 残す | ✓ |
| squash(3コミット) | 3 | 残す | ✓ |
変えたのは、すべてのシナリオでコミット数を1より大きくしたことではありません。 「1コミットの squash」と「複数コミットの squash」を別のケースとして分けたことです。
境界値を足すという発想だと、「0件・1件・2件」を試すという話になります。 そうではなく、その操作の性質が現れる最小の形を考える必要がありました。 squash の性質は「潰す」ことなので、潰す対象が2つ以上ないと現れません。
同じ穴を、測定でも踏んでいた
もう1つ、同じセッションで3回繰り返した失敗があります。 終了コードの測定です。
# 間違い。$? は head のもの
./script.sh | head -5
echo "終了コード: $?"
パイプを通すと、$? は最後のコマンドの終了コードになります。
head は必ず 0 を返すので、スクリプトが失敗していても「0」と報告されます。
3回とも「ツールは正しく、私の測定が間違っていた」という結末でした。
# 正しい
./script.sh > /tmp/out.log 2>&1
echo "終了コード: $?"
head -5 /tmp/out.log
共通しているのは、測定系そのものを疑っていないことです。 出た数字が期待どおりだと、測り方は検証されません。
チェックリストにしたもの
作業のたびに見る形で残しました。
- 「動いた」は検証の完了ではない。 通った理由を説明できるか
- テストの入力が、検証したい性質を持っているか。最小の再現が性質を消していないか
- 誤りがどちら側に倒れるかを先に決める。消す側の誤りは残す側より高くつく
- 判定できないときは、黙らずに言う
- 測定系(パイプ、リダイレクト、
$?)を疑う。期待どおりの数字ほど疑う
まとめ
git cherryで squash マージを判定するのは誤り。patch-id の一致で見るので、 複数コミットを潰した squash では一致しない- 1コミットのブランチでだけ通るので、試して動いたと思い込みやすい
- 最小の再現を作るとき、検証したい性質まで削っていないか確認する
- 正確に判定できないなら、判定しない。そのかわり黙らない
- 期待どおりの結果が出たときこそ、測り方を疑う
このスクリプトは有料で配っているものです。出荷済みのものにバグを見つけたら、 直した経緯ごと書くほうが、黙って差し替えるより信用が残ると考えています。