no7.space
🫣

Git リポジトリ上の特定のファイルを歴史から消し去る方法

APIキーが直書きされたファイルとか、ポエミーなREADMEとか、本当はコミットされてたらまずかったり、コミットしたことをなかったことにしたいファイルとかって、あるとおもいます。
気がついて慌ててファイルを修正してコミットしても、履歴をたどれば確認できちゃいます。

じゃあどうしたらいいのかというと、履歴をたどって確認できちゃうならば、履歴をたどってコミット情報からファイルを消して回れば良いのです。

Git の歴史を改ざんする

以下のコマンドをリポジトリのルート1から実行します。
/PATH/TO/TARGET/FILE には消したい 黒歴史 ファイルまでのパスを指定します。

git filter-branch --force --index-filter 'git rm --cached --ignore-unmatch PATH/TO/TARGET/FILE' -- --all

最強オプション: filter-branch

filter-branch とは、大量のコミットを機械的に処理するための機能です。公式ドキュメントにも、最強のオプションとして紹介されています。

これは、大量のコミットの書き換えを機械的に行いたい場合 (メールアドレスを一括変更したりすべてのコミットからあるファイルを削除したりなど) に使うものです。 そのためのコマンドが filter-branch です。これは歴史を大規模にばさっと書き換えることができるものなので、プロジェクトを一般に公開した後や書き換え対象のコミットを元にしてだれかが作業を始めている場合はまず使うことはありません。 しかし、これは非常に便利なものでもあります。

ただ、filter-branch で検索するとほとんどの場合が、歴史上から特定のファイルを消すことに使われていて、実際ヒットする記事もそれが中心な気がします 💦

具体的なオプションについても ChatGPT に解説してもらいました。

--force / -f
強制的に適用するオプションです。
ChatGPTに聞いたところ filter-branchrefs/original/... などのバックアップが残ってると、安全のために実行を拒否するらしいのですが、このオプションをつけることで、既存のバックアップがあっても実行するようになります。

--index-filter
コミットごとのインデックス(ステージング領域)に対して指定したコマンド(今回なら git rm --cached ...)を実行し、その結果から新しいツリーを生成するフィルタだそうです。
ざっくりと言ってしまうならば、過去のコミットを遡って実行したいコマンドを指定する、といったことでしょうか

実際に渡すコマンド部分については後述

--
filter-branch のオプションの終端を示します。つまり、「ここまでが filter-branch のオプションですよ」のサインです。
このあと最後に、どのコミットを対象にするかという、リビジョン指定を行います

--all
リビジョン指定をおこないます。全ての参照(全ブランチ及びタグ)に到達可能な全コミットを対象にします。

所謂歴史から抹消する用途だと、全てのコミットを対象とするはずなので基本的に --all 一択だとおもいますが、他にも

  • -- main main ブランチのみを対象
  • -- --branches 全てのブランチ

みたいなこともできます。--all はリモート追跡ブランチも含むため、用途によってはあえて --branches を使うこともあるらしい。私は使ったことないですが…

各コミットに実行するコマンドの中身

今回は特定のファイルを履歴から消すので、git rm --cached です。

git rm --cached --ignore-unmatch PATH/TO/TARGET/FILE

git rm
ファイルを削除するための Git サブコマンド
git のサブコマンドだけど次に指定する --cached をつけないとファイルも消える

--cached
インデックスからのみ削除して作業ツリーの実ファイルは触らないようにする指示。一般的に間違ってコミットしちゃったあと特定のファイルのみ取り除くときはこれつけますね。
今回は index-filter をつかってるので最終的に対象が必要ないとしても、このオプションは必要です。(というか、index-filter の場合作業ツリーがないので、つけないと正しく動作しない)

--ignore-unmatch
全部のコミットをさらう関係で対象のファイルが含まれてないコミットとかにもコマンドを実行することになりますが、ファイルがないとそこでエラーになって停止してしまいます。
なので、そうならないために指定します。

やってみた

git filter-branch --force --index-filter 'git rm --cached --ignore-unmatch server/settings.json' -- --all
WARNING: git-filter-branch has a glut of gotchas generating mangled history
         rewrites.  Hit Ctrl-C before proceeding to abort, then use an
         alternative filtering tool such as 'git filter-repo'
         (https://github.com/newren/git-filter-repo/) instead.  See the
         filter-branch manual page for more details; to squelch this warning,
         set FILTER_BRANCH_SQUELCH_WARNING=1.
Proceeding with filter-branch...

Rewrite b65b25786f957af4a1b157f600e8efec314bd915 (1/7) (0 seconds passed, remaining 0 predicted)    rm 'server/settings.json'
Rewrite 7e35a9c62702b21c816eafc34199297e714ad4f4 (2/7) (0 seconds passed, remaining 0 predicted)    rm 'server/settings.json'
Rewrite 6d9bf962431d2e144decc6fa0e1e8c10ab1e379e (3/7) (0 seconds passed, remaining 0 predicted)    rm 'server/settings.json'
Rewrite d9d46c65db3f33712a558944d9b6ff5a740e1548 (4/7) (0 seconds passed, remaining 0 predicted)    rm 'server/settings.json'
Rewrite 04f4280178f3106cd4566e249b271ed6ef71e253 (5/7) (0 seconds passed, remaining 0 predicted)    rm 'server/settings.json'
Rewrite be015039951c2f2aa177772a776ae8da4333414c (7/7) (0 seconds passed, remaining 0 predicted)    
Ref 'refs/heads/develop' was rewritten
Ref 'refs/remotes/origin/develop' was rewritten
WARNING: Ref 'refs/remotes/origin/develop' is unchanged

必ずリポジトリのルートで実行

このコマンドは、ワーキングツリーのトップレベルで行う必要があります。

You need to run this command from the toplevel of the working tree.

ワーキングツリーというのは、Gitが作業してる場所で、.git と同じ階層です。
それってつまりはリポジトリのルートってことにもみえますが、それは正解でもあり不正解でもあるらしいです。
(ただ、サブモジュールを使ってる場合などなので通常はほぼ同義って考えてもいいのかも?)

複数人で作業してるリポジトリの場合は注意

この処理を行うとリモートと整合性がとれなくなるので、当然 PUSH できなくなります。
なので、-f で強制的に PUSH することになりますが、そうなると当たり前ですが他に作業してる人だったりにも影響があります。
なぜならこのあと作業を行った時点の手元の環境で全部の環境を上書きすることになるためです。

なので、作業を行いたい場合は、他のメンバーに事前に伝えるなどして作業を止めてもらい、全員のローカルの状態をリモートに上げてもらって最新にしてから行うなど、慎重に作業を行う必要がありそうです。

参考

Footnotes

  1. 厳密には、ワーキングツリーのトップレベルの位置