「やらかした」と気づく瞬間は、背筋が凍るものです。

ふとローカルのファイル一覧を眺めていたとき、token.json.bak という見慣れないファイルがGitの管理下にあることに気づきました。 正規の token.json.gitignore に入れて厳重に管理していたものの、一時的なバックアップとして作った .bak ファイルが、監視の目をすり抜けてコミットされていたのです。

最新のコミットで慌てて git rm しても意味がありません。Gitはすべてを覚えているからです。履歴を遡れば、そのファイルはそこにいます。 これは、過去の履歴そのものを書き換える外科手術が必要です。

選択肢:歴史改変のツール

Gitの歴史を書き換えるツールとしては、最近では Python製の git-filter-repo が推奨されています。速度も速く、安全性も高い。 しかし、今この場ですぐに解決したい。追加のツールをインストールする手間さえ惜しい緊急事態(あるいは単なる横着)において、古来から伝わる標準コマンド git filter-branch は未だに強力な武器です。

今回は、標準の filter-branch を使って、この異物を歴史の闇に葬り去ることにしました。

実行したコマンド

実行したコマンドは以下の通りです。この一行の呪文が、リポジトリの全歴史を走査し、指定したファイルを「最初から存在しなかったこと」にします。

git filter-branch --force --index-filter "git rm --cached --ignore-unmatch token.json.bak" --prune-empty --tag-name-filter cat -- --all

何が起きているのか、分解して見てみましょう。

  1. --index-filter "git rm --cached ..." これが手術の執刀部分です。すべてのコミットにおいて、インデックスから token.json.bak を削除しようと試みます。 --cached をつけているのは、実ファイルが手元に残っていても構わないから(Git管理から消えれば良い)。 --ignore-unmatch は重要です。「そのファイルが存在しない時代のコミット」でエラー停止させないために必要です。

  2. --prune-empty ファイルを消した結果、「変更点が何もなくなったコミット」が生まれた場合、そのコミット自体を削除します。空っぽのコミットログを残さないための配慮です。

  3. --tag-name-filter cat コミットハッシュが変わるとタグが迷子になってしまいます。これを指定することで、タグも新しい歴史に合わせて付け直します。

  4. -- --all すべてのブランチとタグを対象にします。

仕上げの強制プッシュ

ローカルでの手術が成功しても、GitHub上のリモートリポジトリにはまだ古い歴史が残っています。 これを上書きするには、禁断の --force オプションを使います。

git push origin --force --all

これで、GitHub上からもファイルは消滅しました。 コミットハッシュが全て変わってしまいましたが、個人開発プロジェクトなので誰にも迷惑はかかりません。もしチーム開発でこれをやるなら、Slackで土下座してから行う必要があります。

教訓と規律

今回の事故は、単純な .gitignore の設定漏れが原因です。 しかし、ミスは起こるもの。重要なのは、ミスが起きたときに「削除コミットで誤魔化す」のではなく、「リスクを完全に絶つために歴史を書き換える」 という判断が正しくできるかどうかです。

エンジニアリングには、時に大胆な外科手術も必要です。ただし、執刀の前には必ずバックアップ(あるいは覚悟)を忘れずに。