デプロイ後の差分を眺めていて、見覚えのないファイル名が目に入りました。
next.config.ts.bak。
エージェントに設定ファイルの自動修正を任せ、ゲートが通ったので push した直後のことです。本番は何事もなく動いていました。だからこそ気づくのが遅れました。
個人開発でリポジトリを触るのは私一人ですので、レビュアーもいません。混入に気づいたのは、翌週に別の作業で ls を叩いたときでした。
症状 — 直したはずの差分に、直していないものが並ぶ
エージェントに --fix オプション付きの整形ツールや検査スクリプトを走らせ、その流れでコミットさせると、次のようなファイルがリポジトリに入ります。
| 混入するもの | 生成元 | 厄介な点 |
|---|---|---|
*.bak | 自作の in-place 修正スクリプト | 元ファイルの旧版がそのまま残り、秘密情報を含む場合がある |
*.orig / *.rej | patch・マージ衝突の残骸 | 次回のマージで混乱の元になる |
*.tmp / *.swp | エディタ・一時書き出し | 数が増えると差分レビューが機能しなくなる |
__pycache__/ | 検査スクリプトの実行 | 環境依存のバイナリがリポジトリに乗る |
私の場合は .bak が1つでした。ビルドは通ります。テストも通ります。CI も緑のままです。
何も壊れないというのが、この症状のいちばん質の悪いところでした。
再現条件 — 「直す」と「集める」が別々の道具である
再現には3つの条件が揃う必要があります。
- 自動修正ツールが、書き換えの前にバックアップを作る
- そのツールが、修正後にバックアップを消さない
- コミット直前のステージング操作が、ワークツリー全体を対象にしている
3番目が引き金です。エージェントに「修正して、コミットして」と頼むと、多くの場合 git add -A か git add . が選ばれます。エージェントは差分の内容を理解していますが、「その差分を作った副産物」までは意図の対象として区別しません。
再現手順は次の通りです。
# 1. バックアップを残す典型的な in-place 修正(GNU sed / BSD sed 両対応の書き方)
sed -i.bak 's/foo/bar/g' next.config.ts
ls next.config.ts*
# => next.config.ts next.config.ts.bak
# 2. エージェントが選びがちな広いステージング
git add -A
# 3. 何が入ったかを確認せずにコミット
git commit -m "Fix: config"
# 4. 事後に気づく
git show --stat HEAD
# next.config.ts | 2 +-
# next.config.ts.bak | 47 ++++++++++++++ ← 意図していないsed -i.bak は macOS の BSD sed でも動く互換的な書き方として広く使われています。互換性を優先した結果として副産物が残る、という順序でした。
原因 — .gitignore は「後から」効かない
最初に疑ったのは .gitignore の不備でした。実際 *.bak の記述はありませんでしたので、そこに1行足せば済むと考えました。
これは半分だけ正解でした。
.gitignore が働くのは、未追跡ファイルに対してだけです。一度コミットされて追跡対象になったファイルは、後から .gitignore に書いても無視されません。すでに入ってしまった .bak は git rm --cached で外す必要があります。
そしてもう半分。.gitignore は拡張子を列挙する防御ですので、列挙していない拡張子は素通りします。.bak を塞いだ翌月に .rej で同じことが起きるだけでした。
根本原因は拡張子の側ではなく、ステージング範囲の側にあります。
git add -A は「ワークツリーで変わったものを全部」という意味です。エージェントの作業では、変わったものの中に「成果物」と「作業の残りかす」が必ず混在します。全部を対象にする限り、区別は .gitignore の網の目に委ねられ続けます。
対処1 — ステージ対象をパスで限定する
いちばん効いたのはこれでした。エージェントへの指示から git add -A を排し、変更してよいディレクトリだけを明示します。
# ✗ 何が入るか事前に決まらない
git add -A
# ✓ 入りうるものが事前に決まる
git add content/ src/config/add の引数がホワイトリストになりますので、.bak がどこに落ちても、対象ディレクトリの外にあれば入りません。対象ディレクトリの中に落ちた場合は次の対処で塞ぎます。
私はエージェントの運用規約に「add はパス限定。-A と . は使わない」の1行を足しました。これだけで、その後の8週間で .bak 系の混入はゼロになっています。
対処2 — 修正ツールを、掃除まで含めてラップする
--fix を素で呼ばず、副産物の後始末までを1つのコマンドにまとめます。
#!/usr/bin/env bash
# scripts/safe-fix.sh — in-place 修正と後始末を分離させない
set -euo pipefail
TARGET="${1:?usage: safe-fix.sh <file>}"
BACKUP="${TARGET}.bak"
# 修正前の状態を退避(リポジトリ外へ)
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
sed -i.bak 's/foo/bar/g' "$TARGET"
if [ -f "$BACKUP" ]; then
# ロールバック用に退避してから、リポジトリ内からは消す
mv "$BACKUP" "$TMPDIR/$(basename "$BACKUP")"
echo "backup moved out of repo: $TMPDIR"
fi
# 差分が空なら修正不要だったということ
if git diff --quiet -- "$TARGET"; then
echo "no change: $TARGET"
fitrap ... EXIT を置いていますので、途中で失敗しても退避先は残りません。バックアップを完全に捨てるのではなく、いったんリポジトリ外へ動かしているのが要点です。手元でのロールバック手段は残しつつ、Git の視界からは外れます。
自作スクリプトなら書き換えられますが、サードパーティの codemod ではそうもいきません。その場合は実行後に find . -name '*.bak' -newer <基準ファイル> -delete を足すか、次の対処に頼ります。
対処3 — pre-commit で、ステージ済みの拡張子を検査する
最後の砦です。コミットの瞬間に、ステージされたファイル名だけを見て弾きます。
#!/usr/bin/env bash
# .git/hooks/pre-commit — 副産物の混入を機械的に止める
set -euo pipefail
DENY='\.(bak|orig|rej|tmp|swp)$|(^|/)__pycache__/|(^|/)\.DS_Store$'
# -z を付けないと、非ASCIIパスが八進エスケープされて検査を素通りする
BAD="$(git diff --cached --name-only -z --diff-filter=ACM \
| tr '\0' '\n' | grep -E "$DENY" || true)"
if [ -n "$BAD" ]; then
echo "コミット対象に副産物が含まれています:" >&2
echo "$BAD" | sed 's/^/ - /' >&2
echo "" >&2
echo "対処: git restore --staged <file> でステージから外してください" >&2
exit 1
fi--diff-filter=ACM で追加・コピー・変更に絞り、削除されたファイルを誤検知しないようにしています。-z を付けている理由は実害があったからです。付けずに書いた最初の版は、日本語を含むパスを八進エスケープした文字列で返してきて、検査対象を取り違えたまま「クリーン」と報告していました。
ゲートが嘘をつくのは、ゲートが無いより悪い状態です。私自身、エージェントが書いたファイルが .gitignore に飲まれる問題を追いかけたときにも同じ落とし穴を踏んでいますので、ファイル名を走査する処理では -z を反射的に付けるようにしています。
なお .git/hooks/ はリポジトリに含まれませんので、新しい環境では設定が必要です。git config core.hooksPath .githooks でリポジトリ管理下のディレクトリを指すようにしておくと、clone した側にも自動で行き渡ります。
予防 — コミット前の1行を、エージェントの手順に埋める
3つの対処のうち、どれか1つだけを選ぶなら対処1です。ステージ範囲が狭ければ、残りは保険になります。
その上で、エージェントの手順に次の確認を入れると効果が上がりました。
# add の後・commit の前に、必ず一度は目に入れる
git diff --cached --name-only出力を読ませるだけです。エージェントは自分がステージしたファイル一覧を見ると、意図と合わないものに気づきます。私の体感では、add のパス限定と合わせて、意図しない混入の大半はこの段階で止まりました。
push が「成功」と表示されながら実際には何も反映されていないgit identity 未設定による空振りもそうですが、エージェントに Git を任せるときの事故は、たいてい「表示は正常」という顔をしてやってきます。目視ではなく機械的な検査を挟む箇所を決めておくのが、結局いちばん安く済みました。
すでに .bak を入れてしまった場合の後始末は次の通りです。
# 追跡から外す(ローカルのファイルは残る)
git rm --cached next.config.ts.bak
echo '*.bak' >> .gitignore
git add .gitignore
git commit -m "Remove: backup artifact from repository"履歴からも消したい場合は git filter-repo が必要になりますが、秘密情報を含んでいないのであれば、追跡から外すだけで実務上は足ります。私は .bak の中身を確認した上で、履歴の書き換えまでは行いませんでした。
次の一手
エージェントの直近3コミットに何が入っているかを確認してみてください。
git log -3 --stat --oneline見覚えのない拡張子が1つでも出てきたら、そのコミットを作った手順のどこかに git add -A があります。そこをパス限定に書き換えるところから始めるのが、いちばん早いはずです。
コミットの内容そのものを検査する仕組みについては、エージェントのコミットを pre-commit で止める仕組みにまとめてあります。合わせて読んでいただければ幸いです。