git filter-branchで過去の全てのcommitから画像ファイルの追加/変更をなかったことにしてリポジトリを軽量化する

表題の通り、分散型バージョン管理システムのGitでいわゆる「歴史の書き換え」をする。

この処理を行う想定としては、複数人で進めているプロジェクトで開発の途中までは画像をリポジトリに含めて管理していたけど、今度から画像は別で管理することにしてリポジトリから消したい、などという場合。その後月日が経った状況で画像を commit していた頃の log がとても容量を食っている場合でももちろん可。

写真素材サイトで画像をうっかり Git 管理してたとか、ゲーム系でキャラクターや背景の高解像度の画像を Git 管理していた頃があるとかだと、新しい branch を checkout して push する度にリポジトリはどんどん肥大化していく。そうやってギガバイト単位に膨れ上がったリポジトリでも、filter-branch で劇的に軽量化することができる。

具体的にはfilter-branchというコマンドを使う。ファイルを持っていた歴史を残しておきたいのであれば filter-branch をする必要なないが、新規メンバーがそのリポジトリを clone する時間を少しでも短縮したいと考えるなら、試してみる価値はある。

これをやる前に注意して欲しいのは、filter-branch を実行した環境以外では、実行後にリポジトリを clone し直す必要があるということ。なぜなら、filter-branch は commit 自体の書き換えを行うのものなので、revert などとは違いその書き換え自体のログは残らない。さらに commit のハッシュも再発行される。そしてこれをリモートのリポジトリに反映するためにgit push -fする。なので、以前とは全く違うリポジトリになると考えた方がいい。もし filter-branch に失敗したら、当然ながら、絶対に push してはいけない。やり直したい場合は実行環境ではもうログが失われているので、別のディレクトリに移動して filter-branch 実行前の状態を clone してやればいい。

手順はさほど難しくない。まず最初に、コラボレーターやコミッター全員にその時点での変更(branch 含む)を全て push してもらい、変更作業を止める。そしてを行う作業者の手元で全ての branch で pull しておく。push 漏れ・pull 漏れがあるとワークツリーがデグレードしてしまうので忘れずにやって欲しい。

次にリポジトリから消したいファイルやディレクトリを決める。これは通常の git コマンドなので複数指定できる。今回はわかりやすいように XXX フォルダ配下全てと YYY/img/admin-face.jpg ファイルを削除してみる。

歴史の書き換えはたったの数個のコマンド実行で済む。

$ git filter-branch -f --index-filter "git rm -rf --cached --ignore-unmatch XXX/ YYY/img/admin-face.jpg" --prune-empty -- --all

filter-branch--index-filterオプションで渡しているダブルクォーテーションの中が、各 commit に対して行うコマンドになる。git rm -rfなのでフォルダもファイルも対象に消す。見た目わかりづらいがXXX/とYYY/img~の間にはスペースがあり複数指定している。

--chacedオプションがあると commit log からだけ削除してワークツリーでは残すようになる(つまりファイルが手元に残る)。--cachedオプションがないとワークツリーからも消えるのでそこは注意してほしい。

--ignore-unmatchで当てはまる対象がない時のエラーを無視できる。

--prune-emptyがあると、対象のファイルを commit log から消した時に生まれうる、コメントのみの空 commit も消してくれるようになる。前述の2つのオプションはgit rmコマンドで、--prune-emptyfilter-branchのオプションだ。

ハイフン 2 つで前のコマンドのオプションから抜け、--allで全ブランチを対象に同じ処理を行う。

実行完了には少し時間がかかるかもしれない。ファイル数や commit 数で前後する。

あとはローカルのキャッシュを消したりゴミ掃除したりしていく。

$ rm -rf .git/refs/original/
$ git reflog expire --expire=now --all
$ git gc --aggressive --prune=now

掃除し終わったらgit log -pなどで消えていることを確認し、force push する。

$ git push -f

コマンドが$ git push origin <branch-name> -fでないのは、前述では全ブランチに対して filter-branch を行っているので、これをリポジトリに丸ごと反映したいから。filter-branch をしたのが特定の branch だけ(あまりないと思うが)なら push -f する時に branch を指定しないとそれはそれで事故になるので注意。

以上となる。リモート反映後に他の作業者にリポジトリを clone し直してもらえば、以後は重い log のない軽量なリポジトリとなる。commit がリハッシュされているのは実行環境と別の作業環境で git log し合って見ればわかる。

消すファイルの数や commit の数にもよるので、この歴史の書き換えにかかる時間や軽量化できる容量は一概には言えないが、僕が関わっているプロジェクトで実行したところ 8 万オブジェクトで 1.6GB あったリポジトリが、4 万オブジェクトの 145MB まで軽量化することができた。すごい威力を感じる。


filter-branchは別にリポジトリの軽量化を目的とした機能ではない。リポジトリにうっかり含めてしまったセキュリティ上問題あるデータを log からも消し去りたい場合にも使う。全 commit から目的のファイルをホニャララするのが filter-branch の機能というわけだ。また、--index-filterではなく--tree-fliterを使って同様の処理を行うこともできるが、こちらは checkout を branch ごとに行うとのことで--index-filterより低速らしい。--tree-filterでは--allオプションはいらないのかどうか、調べたけどすぐに出てこなかったのでわからない。

実行するのは git コマンド以外も可能だ。こちらの記事では--tree-filterオプションで、cp -fコマンドで強制的にファイルを上書きコピーしている。


この記事をある程度書いたところでもっとわかりやすく書いているブログがあることを知った。悔しいので dskd では--index-filter のオプションの書き方を少しだけ詳しく書いた。

本文の表現を加筆修正

なぜ filter-branch のあとで他の作業者はリポジトリを clone し直すのか

filter-branchが実行されたリポジトリをリモートに反映後、他の作業者が clone ではなくそれまでのディレクトリで pull をするとどうなるかというと、普通に Merge されて pull できる。しかし、この作業環境のリポジトリには削除したかったファイルやその commit が残っているので、pull したのちに何かを編集して commit して push すると、消したかったはずの情報がまたリモートに送られてしまい意味がなくなる。さらに、filter-branch したあとのリポジトリの commit message が filter-branch 前のに混ざって二重に commit しているように見える。ハッシュは異なるので別の変更履歴として扱われるわけだ。git logするとちょっと耐えられない感じになるだろう。

履歴はよごれるし push すると filter-branch が無意味になので、他の作業環境では必ず clone し直そう。

「なぜ filter-branch のあとで他の作業者はリポジトリを clone し直すのか」の見出しとその本文を追記


下記は自分用のメモ:

$ git filter-branch --commit-filter &apos;\
 GIT_AUTHOR_NAME="oti"\
 GIT_AUTHOR_EMAIL="otiext@gmail.com"\
 GIT_COMMITTER_NAME="oti"\
 GIT_COMMITTER_EMAIL="otiext@gmail.com"\
 git commit-tree "$@"\
&apos; -- --all

author や commiter で秘匿情報が混ざったら--commit-filterを使う。改行は削除してカタカタッターンすること。