Quartet / Quintet

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つのシナリオを試していました。

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

共通しているのは、測定系そのものを疑っていないことです。 出た数字が期待どおりだと、測り方は検証されません。

チェックリストにしたもの

作業のたびに見る形で残しました。

まとめ

このスクリプトは有料で配っているものです。出荷済みのものにバグを見つけたら、 直した経緯ごと書くほうが、黙って差し替えるより信用が残ると考えています。

この運用そのものを配っています

4 人格版 Quartet は MIT で無料公開しています。UI 設計人格・レビュー基準・ Issue 単位の並列実行スクリプト・実践ガイド 10 章を足した Quintet は有料版です。

無料版を見る 製品ページ