消したからといって消えてはいない
一言でいうと
ファイルを削除してコミットすることは、今後そのファイルがないという意味にすぎず、履歴に残ったオブジェクトはそのままです。実際になくすには、履歴を書き換え(filter-branch)、バックアップ参照を取り除き、reflogを空にし、gc --prune=nowまでする必要があります。それでも、すでに広まったコピーは残ります。
なぜ必要なのか
2つの事故が、同じ形をしています。
1つは、機密情報です。本番のトークンが入った.envを誤ってコミットしてしまい、気づいて削除しました。リポジトリを開いてみると、そのファイルはありません。ところが、git log -- conf/prod.envは今でもコミットを2つ出し、git cat-file -p <커밋>:conf/prod.envは、トークンの原文をそのまま表示してくれます(プレースホルダーはコミットです)。gitは内容アドレス方式のストアなので、一度入ったblobは、それを指すコミットが残っている限り、消えません。
もう1つは、サイズです。数百MBのデータファイルやビルドのアーティファクトを一度コミットすると、あとで削除しても、cloneする人のたびに、そのオブジェクトをダウンロードし続けます。「なぜ自分たちのリポジトリはcloneに10分かかるのか」の答えは、たいていここにあります。
どちらも、今後ではなく過去の履歴を直さないと解決しません。
どう動くのか
git filter-branchは、指定したrefのコミットを1つずつ作り直しながら、コミットごとにフィルターを適用します。ファイルを取り除くときは、インデックスだけを直す--index-filterが最も速いです。ワーキングツリーを展開しないからです。
FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch --index-filter \
'git rm -r --cached --ignore-unmatch conf/prod.env assets/blob.bin' \
--prune-empty --tag-name-filter cat -- --all
オプション1つ1つに、理由があります。
| オプション | なぜ必要か |
|---|---|
--ignore-unmatch |
そのファイルがないコミットでgit rmが失敗して、全体が止まらないように |
--prune-empty |
そのファイルだけを追加していたコミットが、空のコミットとして残らないように |
--tag-name-filter cat |
タグが古いコミットを指したまま残らないように |
-- --all |
ブランチ1つではなく、すべてのrefを書き換えるため |
FILTER_BRANCH_SQUELCH_WARNING |
gitが表示する10秒の警告を飛ばすために |
ここで、ほとんどの人が止まります。コマンドが終わってgit logを見るとファイルはないのに、git rev-list --objects --allには、そのファイルが今でも出てきます。理由は、refs/original/です。filter-branchは、元に戻せるように、元のrefをすべてその下にバックアップしておきますが、それもrefなので、古いコミットと古いblobを掴んでいます。
そのため、順序があります。
refs/original/を削除します。git for-each-ref --format='%(refname)' refs/originalで一覧を取り出し、git update-ref -dを実行します。- reflogを空にします。
git reflog expire --expire=now --expire-unreachable=now --allを使います。reflogも参照です。HEADが通ったすべての場所を記憶しています。 git gc --prune=nowで、到達不能なオブジェクトを削除します。--prune=nowがないと、デフォルトの猶予期間(2週間)が過ぎるまで削除されません。
git count-objects -vHで前後を測ると、この過程が実際に何をしたのかが、数字で見えます。
現場での姿
書き換えが終わると、すべてのコミットのハッシュが変わります。コミットのハッシュは、親とツリーを含めて計算されるため、履歴の一番最初で何か1つを直すと、その後がすべて変わります。そのため、次のことが伴います。
- チーム全員が、強制プッシュされたブランチを取得し直す必要があります。各自が
git pullを行うと、古い履歴と新しい履歴が混ざって、コミットが2組になります。書き換えた直後は、「各自で新しくcloneしてください」が、最も安全な案内です。 - 開いていたPRとCIパイプラインが、すべて壊れます。指していたコミットが消えたためです。
- このリポジトリをサブモジュールとして組み込んでいる親があれば、そのピンが切れます。
そして、最も重要なこと。すでにcloneを持っていった人のコピーには、そのオブジェクトがそのまま残ります。同僚のノートパソコン、CIのキャッシュ、フォークしたリポジトリ、バックアップに、すべて残っています。そのため、機密情報が履歴に入ったときに最初にすべきことは、履歴の掃除ではなく、その機密情報を取り消して、新しく発行することです。履歴の掃除は、2つ目です。順序を逆にすると、掃除をしている数日の間、生きている認証情報が外に出たままになります。
大きなリポジトリでは、git filter-branchは遅いです。公式ドキュメントも、git filter-repoやBFGのようなツールを勧めています。ただし、それらのツールは別にインストールする必要があり、行うことと後処理の順序は、まったく同じです。書き換え、バックアップ参照の削除、reflogの期限切れ、gcです。
次のラボですること
トークンが入った設定ファイルと、900KBの塊が混ざった履歴を作り、ワーキングツリーから削除してもgit cat-fileで原文が出ることを、まず確認します。そのあと、書き換えを実行し、まだ残っている理由(refs/original)を自分で見つけて取り除き、サイズが実際に減ることをgit count-objects -vHで測ります。最後に、あらかじめ取っておいた同僚のコピーに、その機密情報がそのまま残っているのを示し、そのため何を先にすべきかで締めくくります。
公式ドキュメントは、git-filter-branch、git-reflog、git-gcです。