CI/CDにおける「無駄な全件テスト実行」という悪夢
中規模のモノレポやバックエンドプロジェクトでは、ソースコードが3,000〜5,000ファイルに及ぶことも珍しくありません。Pull Request(PR)を作成するたびにリポジトリ全体のlinterやテストを実行していると、平気で15〜20分も消費してしまいます。これではCIのキューが詰まり、チーム全体の開発リズムも損なわれます。最も自然な解決策は、PR内で新規追加または変更されたファイルのみをピンポイントで検証することです。
しかし、問題となるのは差分の比較方法です。mainブランチと比較して、実際にどのファイルが変更されたのかをCI Runnerはどうやって正確に把握するのでしょうか?比較基準となるコミットを誤ると、パイプラインが重大なバグを見逃す恐れがあります。さらに深刻な場合、同僚が5分前にマージした無関係なコードのせいで、linterのエラーが自分に押し付けられることにもなりかねません。
変更ファイル一覧を取得する3つのアプローチ:正解はどれか?
CIでファイルフィルタリングスクリプトを作成する際、多くのエンジニアは以下の3つのアプローチのいずれかから検討を始めます。
Approach 1: 直前のコミットと比較する(HEAD~1)
最も直感的なのは、ブランチにプッシュされた最新のコミットだけを検証する方法です:
git diff --name-only HEAD~1 HEAD
Approach 2: ブランチの先端同士を直接比較する(ダブルドット構文)
フィーチャーブランチとmainを対比させるために、2つのドット記号を使った構文を採用するケースもよく見られます:
git diff --name-only origin/main..HEAD
Approach 3: git merge-baseで共通祖先コミットを特定する
git merge-baseコマンドは、2つのブランチ間の最も近い共通祖先コミット(best common ancestor)を特定します。この分岐点を起点として、現在のブランチの先端(HEAD)までの差分を比較します:
BASE_COMMIT=$(git merge-base origin/main HEAD)
git diff --name-only $BASE_COMMIT HEAD
Gitでは、このロジックをより簡潔に表現できる便利な3つのドット記号(トリプルドット)構文もサポートされています:
git diff --name-only origin/main...HEAD
各アプローチのメリット・デメリットを徹底比較
Approach 1(HEAD~1):高速だが根本的なロジック破綻
- メリット: 実行速度が非常に高速。Runnerが他のブランチの履歴を取得する必要がありません。
- デメリット: フィーチャーブランチに2つ以上のコミットがある時点で破綻します。PRに4つのコミットを積んだ場合、CIは最後の4つ目のコミットの変更ファイルしか検証せず、手前の3つのコミットで行った変更は完全にスルーされてしまいます。
Approach 2(origin/main..HEAD):陥りがちな落とし穴
- メリット: コマンドが短く、記述が容易。
- デメリット: 予期せぬエラーを引き起こしやすいです。例えば月曜の朝に
mainからブランチを切った後、夕方までに同僚が別のPRを8件mainにマージしたとします。origin/main..HEADを使用すると、自分のブランチには含まれていないmain側の変更点まで差分としてカウントされてしまいます。その結果、他人が書いた無数のファイルに対してCIからフォーマット修正を要求される羽目になります。
Approach 3(git merge-base):完全かつ高精度
- メリット: ブランチを分岐させた時点以降に自分が加えた変更のみを100%正確に抽出できます。
main側にどれだけ新しいコミットが追加されていようと影響を受けません。これはGitHubやGitLabの「Files changed」タブで採用されている仕組みそのものです。 - デメリット: Runnerにある程度のコミット履歴が必要です。デフォルトのShallow Clone設定のままでは、設定次第でエラーが発生する可能性があります。
git merge-base導入による実環境での効果
この仕組みを8名のエンジニアチームに導入したところ、毎コミットごとのテスト待機時間が18分から3分未満へと激減しました。AWSやGitHub ActionsのRunner実行コストも大幅に削減できています。
「自分はこのファイルを一切触っていないのに、なぜCIでコードフォーマットを直せと怒られるのか?」という開発者の不満も完全に解消されました。パイプラインに対象の作業スコープを正確に反映させるなら、git merge-baseが唯一の確実な選択肢です。
CI Runnerへの具体的な実装手順
ステップ 1: 有向非巡回グラフ(DAG)から基準点を特定する仕組み
以下のコミットツリーをご覧ください:
B---C (feature - HEAD)
/
A---M1---M2 (origin/main)
各要素の意味は次のとおりです:
A:フィーチャーブランチを分岐させた起点となるコミット。M1,M2:同僚がmainにマージした新しいコミット群。B,C:今回自分が実装したコミット群。
ここで次のコマンドを実行します:
git merge-base origin/main HEAD
Gitは有向グラフ(DAG)を逆算して探索し、コミットAのハッシュ値を返します。このAとC(HEAD)の間でdiffを実行することで、BとCで変更されたファイルのみを正確に抽出できるのです。
ステップ 2: CI RunnerのShallow Cloneエラーを解決する
GitHub ActionsやGitLab CIなどのCIエンジンは、ネットワーク帯域を節約するためにデフォルトでdepth: 1によるShallow Cloneを行います。この状態ではRunner内にコミットAが存在しないため、git merge-baseを実行するとfatal: Not a valid object nameというエラーで失敗します。
解決するには、diffを計算する前にターゲットブランチの履歴を追加取得します:
# 方法 1: mainブランチのコミットを追加取得する
git fetch origin main --depth=100
# 方法 2: リポジトリサイズがそれほど大きくない場合は全履歴を取得する
git fetch --unshallow || true
ステップ 3: 変更ファイルを自動抽出するスクリプトの作成
シェルスクリプトget_changed_files.shを作成し、以下の内容を記述します:
#!/usr/bin/env bash
set -euo pipefail
TARGET_BRANCH="origin/main"
# 最も近い共通祖先コミットを特定
BASE_COMMIT=$(git merge-base "$TARGET_BRANCH" HEAD)
echo "特定されたベースコミット: $BASE_COMMIT"
# 変更または新規追加されたファイルを抽出し、削除されたファイルは除外(--diff-filter=d)
CHANGED_FILES=$(git diff --name-only --diff-filter=d "$BASE_COMMIT" HEAD)
if [ -z "$CHANGED_FILES" ]; then
echo "変更されたファイルは検出されませんでした。"
exit 0
fi
echo "検証対象ファイル一覧:"
echo "$CHANGED_FILES"
# 例: 変更があったPythonファイルのみflake8を実行
PYTHON_FILES=$(echo "$CHANGED_FILES" | grep -E '\.py$' || true)
if [ -n "$PYTHON_FILES" ]; then
echo "Pythonファイルのlint検証を開始します..."
echo "$PYTHON_FILES" | xargs flake8
fi
ステップ 4: モノレポ環境のCIパイプラインへ組み込む
影響を受けたマイクロサービスごとにテストを実行する設定例です:
# 1. mainブランチの参照情報を同期
git fetch origin main:refs/remotes/origin/main
# 2. 共通祖先のハッシュを取得
MERGE_BASE=$(git merge-base origin/main HEAD)
# 3. 変更ファイルが含まれる第1階層のモジュールディレクトリを走査
CHANGED_DIRS=$(git diff --name-only "$MERGE_BASE" HEAD | cut -d/ -f1 | sort -u)
for dir in $CHANGED_DIRS; do
if [ -f "$dir/package.json" ]; then
echo "テストを実行するサービス: $dir"
(cd "$dir" && npm test)
fi
done
git merge-baseの仕組みを正しく理解することで、CI/CDパイプラインを自在に制御し、サーバーリソースを無駄にせず、動作の不安定なサードパーティ製プラグインへの依存からも脱却できます。

