Git Content Filters(SmudgeとClean)の設定:チェックアウト・コミット時のファイル自動変換

1. よくある悩み:チーム開発における設定ファイルのジレンマ

エンジニアなら誰もが一度は経験したことがあるでしょう。config.jsonやsettings.php、appsettings.jsonなどの設定ファイルは、プロジェクトの構造を共有するためにGitで追跡する必要があります。しかし、ローカル環境で動かすためのDB接続情報やポート番号は開発者ごとに異なります。

その結果、次のような問題が頻発します:

  • 新規メンバーが誤って個人のDB_PASSWORDやAPIトークンを共有GitLabリポジトリにプッシュしてしまう。
  • git pullするたびに設定ファイルが上書きされ、ローカル環境が壊れてしまい、無駄なコンフリクト解消に15〜30分も奪われる。
  • CI/CDスクリプトでデプロイ前にGitハッシュをコードに埋め込みたいが、バージョン変更だけのためのコミットで履歴を汚したくない。

2. なぜデフォルトのGitでは解決できないのか?

Gitは変更をスナップショットとして追跡します。git addを実行すると、Gitは作業ディレクトリ(working directory)の内容をそのまま読み取り、インデックス内に新しいblobオブジェクトを作成します。

問題は、真っ向から対立する2つの要求があることです:

  • ローカル環境:アプリケーションを実行するための本物のデータが必要(例:localhost:5432への接続情報やStripeのテスト用キー)。
  • リモートリポジトリ:__DB_HOST__や__API_KEY__のようなプレースホルダーを含むテンプレートのみを保存したい。

この2つの状態のズレが、コミットのたびに摩擦を引き起こします。

3. よくある3つの暫定対処法とその限界

Content Filtersを知る前は、多くのチームが以下の3つのテクニックを使っていました:

.env.exampleを用意して.envをgitignoreする

環境変数管理のベストプラクティスですが、複雑な構造を持つXMLやJSONファイル、あるいはリポジトリで追跡が必須なソースコードには適用できません。.gitignoreはファイル単位でしか除外できず、特定の行だけをフィルタリングすることはできないからです。

git update-index --skip-worktreeを使用する

このコマンドは、追跡中ファイルのローカルでの変更を無視するようGitに指示します。しかし欠点として、リモート側でチームメンバーがそのファイルを変更した場合、git pull時にコンフリクトエラーが発生するか、ローカルの変更が消えてしまうリスクがあります。

pre-commitフックを利用する

pre-commitは機密情報の誤プッシュを防ぐのに役立ちます。しかし、警告を出してコミットを中断させるだけなので、結局は手動で修正してからコミットし直す手間が発生します。

4. 根本的な解決策:Git Content Filters(Smudge & Clean)

あまり知られていませんが、Gitには.gitattributesを介して利用できるContent Filtersという仕組みが標準で備わっています。データは2つの変換フィルターを自動的に通過します:

  • Cleanフィルター:git add実行時(作業ツリーからインデックスへ)にトリガーされます。コミット履歴に書き込まれる前に、実際のシークレットをプレースホルダーに置換します。
  • Smudgeフィルター:git checkoutやgit switch実行時(リポジトリから作業ツリーへ)にトリガーされます。プレースホルダーを自動でローカル環境の実機シークレットに復元します。

この仕組みを8人のバックエンドチームに導入したところ、過去6ヶ月間、クレデンシャルの誤コミットを完全にゼロに抑え込むことができました。

ステップ1:.gitattributesでフィルターを定義する

リポジトリのルートに.gitattributesファイルを作成し、Gitにコミットします:

# config.jsonに "secret_filter" という名前のフィルターを割り当てる
config.json filter=secret_filter

ステップ2:ローカル環境でCleanとSmudgeのコマンドを設定する

この設定は各マシンの.git/configに保存されるため、リポジトリにはコミットされません。

リポジトリ上のconfig.jsonにプレースホルダーが含まれているとします:

{
  "api_endpoint": "https://api.example.com",
  "api_key": "__LOCAL_SECRET_KEY__"
}

ターミナルで以下の2つの設定コマンドを実行します:

# 1. Smudge:チェックアウト時にプレースホルダーをローカルキーに置換
git config filter.secret_filter.smudge 'sed "s/__LOCAL_SECRET_KEY__/local-dev-token-999/g"'

# 2. Clean:ステージング(git add)時にローカルキーをプレースホルダーに戻す
git config filter.secret_filter.clean 'sed "s/local-dev-token-999/__LOCAL_SECRET_KEY__/g"'

ステップ3:環境変数を読み込むシェルスクリプトで自動化する

git configにトークンをハードコードするのは安全とは言えません。システムの環境変数を直接読み取るスクリプトを活用しましょう。

Smudgeスクリプトを作成します(.git-filters/smudge.sh):

#!/usr/bin/env bash
# 標準入力を読み込み、プレースホルダーを環境変数MY_API_KEYで置換
KEY="${MY_API_KEY:-sandbox_default_key}"
sed "s|__LOCAL_SECRET_KEY__|$KEY|g"

Cleanスクリプトを作成します(.git-filters/clean.sh):

#!/usr/bin/env bash
# コミット前に機密データをクリーンアップ
KEY="${MY_API_KEY:-sandbox_default_key}"
sed "s|$KEY|__LOCAL_SECRET_KEY__|g"

実行権限を付与し、Git configに登録します:

chmod +x .git-filters/smudge.sh .git-filters/clean.sh
git config filter.secret_filter.smudge .git-filters/smudge.sh
git config filter.secret_filter.clean .git-filters/clean.sh

実際の動作確認

フィルターが意図通りに動作しているかテストしてみましょう:

  1. ローカルファイルを削除して再チェックアウトします:rm config.json && git checkout -- config.json。ファイルを開くと、__LOCAL_SECRET_KEY__が環境変数の値に置き換わっていることが確認できます。
  2. config.jsonの他の行を変更し、git add config.jsonを実行します。
  3. git diff --stagedを実行します。正規の変更点のみが表示され、シークレット部分は__LOCAL_SECRET_KEY__のまま保持されています。

ローカルでは実データで正常稼働させつつ、共有リポジトリからは機密情報を完全に締め出すことができます。

Share: