pre-commit hook で 「マージ衝突マーカーが残ったままコミットされる事故」 を防ぎたい。簡単そうに見えて、正規表現を雑に書くと通常コードを誤検出します。
そこで、8ケースの実機テストを設計してから hook を実装した結果、誤検出ゼロ + 過去事故パターンの完全検出を達成しました。本記事は、その正規表現設計と境界条件の詰め方を共有します。
防ぎたい事故:衝突マーカー残置コミット
git merge や git cherry-pick で衝突解決中、一部のブロックを直し忘れてコミットしてしまう事故。
1def fetch_data():2<<<<<<< HEAD3 return api.get('/v1/data')4=======5 return api.get('/v2/data')6>>>>>>> feature/v2これがコミットされて push されると、構文エラー or import エラーで CI が落ちます。レビューで気づけばまだいいですが、squash merge してから気づくと取り返しがつきません。
ナイーブな正規表現の罠
最初にこう書きたくなります。
1git diff --cached | grep -E "(<<<<|====|>>>>)"しかしこれは通常コードを誤検出します。
| コード | 誤検出される? |
|---|---|
=== "test" (Python の比較演算) | される |
>>> (Python REPL のプロンプト出力) | される |
<<<< (テンプレートエンジンの記号) | される |
==== (Markdown の区切り線) | される |
通常コードに含まれる === や >>> で hook が落ちると、開発者が hook を切ってしまう(hook 自殺現象)。
正しい正規表現:行頭アンカー + 完全一致 + 行末アンカー
衝突マーカーは Git が生成するため、形式が固定です。
1<<<<<<< (7文字) ブランチ名 ← 行頭から2======= (7文字) ← 7文字、行末3>>>>>>> (7文字) ブランチ名 ← 行頭からこれに合わせた正規表現は、
1# 各マーカーごとに別個の正規表現2^<{7} # 行頭の <<<<<<<3^={7}$ # 行頭から行末まで完全一致の =======4^>{7} # 行頭の >>>>>>>ポイントは3つ。
- 行頭アンカー
^: 行頭に限定。インデント付きの<<<は除外(衝突マーカーは必ず行頭) - 回数指定
{7}: 7文字ぴったり。Markdown の=====(5〜6文字) や========(8文字超) を除外 - 行末アンカー
$:=======だけは行末まで完全一致。後続にブランチ名が付く<<<<<<<>>>>>>>には付けない
8ケース実機テスト
設計の正しさを担保するため、8ケースのテストファイルを作って実機検証しました。
| ケース | コード例 | 期待値 |
|---|---|---|
| 1 | <<<<<<< HEAD | 検出する |
| 2 | ======= (7文字、行末) | 検出する |
| 3 | >>>>>>> feature/v2 | 検出する |
| 4 | if a == "test": | 検出しない |
| 5 | >>> import os (REPL) | 検出しない |
| 6 | === (3文字) | 検出しない |
| 7 | ======== (8文字) | 検出しない |
| 8 | <<<<<<< HEAD (インデント付き) | 検出しない |
ケース8は意図的に外す設計。インデント付きの <<< は、コメントやテンプレート文字列内にしか現れない(実衝突は必ず行頭)。
バイナリファイル除外
正規表現以外の落とし穴として、バイナリファイルがあります。画像やバイナリログに偶然 ======= が含まれていると hook が誤検出します。
対策は git diff --numstat の出力を活用。
1git diff --cached --numstat | while read added removed file; do2 if [ "$added" = "-" ] && [ "$removed" = "-" ]; then3 continue # バイナリファイルはスキップ4 fi5 # 通常ファイルのみチェック6done- - がバイナリの目印。これで .png や .zip を除外します。
hook の最終形
1#!/bin/bash2ERRORS=03
4while IFS=$'\t' read -r added removed file; do5 # バイナリファイル除外6 [ "$added" = "-" ] && [ "$removed" = "-" ] && continue7
8 # 衝突マーカー検出9 if grep -nE '^(<{7}|>{7})' "$file" > /dev/null 2>&1; then10 echo "ERROR: 衝突マーカー残置 in $file"11 ERRORS=$((ERRORS + 1))12 fi13 if grep -nE '^={7}$' "$file" > /dev/null 2>&1; then7 collapsed lines
14 echo "ERROR: 衝突区切り行残置 in $file"15 ERRORS=$((ERRORS + 1))16 fi17done < <(git diff --cached --numstat)18
19[ "$ERRORS" -gt 0 ] && exit 120exit 0過去事故パターンの再現テスト
実装後、過去にコミットしてしまった衝突マーカー残置の実例で再現テストしました。
- ケース実例: 1ファイル中に複数衝突ブロックがあり、最後のブロックだけ残っていた事例
- 期待値: hook が検出する
- 結果: 検出成功
「過去の事故再現テスト」は、hook の信頼性を担保する強力な手段です。hook を導入する前に、過去にやらかした失敗を必ず再現させます。
教訓:正規表現を書く前にテストケースを書く
この経験から得た教訓です。
教訓1: 正規表現の境界条件は実機テストで詰める
頭で考えるだけでは、必ず例外ケースを見逃します。8ケース以上のテーブルを作って、機械的に検証します。
教訓2: 既存コードに含まれる「似たパターン」を必ず探す
grep -E "patterns" . でリポジトリ全体を検索し、通常コードで誤検出するケースを発見します。
教訓3: hook は「false positive を起こさない」設計を最優先
false positive を出すと、開発者が hook を切ってしまいます。多少 false negative があっても、false positive ゼロを優先します。
教訓4: 過去事故の再現テストを必ず添える
hook の意義は「過去の事故を防ぐこと」。過去事故再現テストがない hook は、将来の事故も防げません。
まとめ
- 衝突マーカー検出 hook は、行頭アンカー + 7文字完全一致が肝
- 8ケースの実機テストで誤検出ゼロを担保
- バイナリファイルは
numstatの--判定で除外 - 過去事故の再現テストを必ず添える
正規表現1行の hook でも、設計プロセスを丁寧に踏むかどうかで、運用後の信頼度が決まります。