白いカードに赤のGitのひし形ロゴと濃い茶色のロゴタイプ

git rm --cachedで.gitに26MBを残した — ワーキングツリーはクリーンで、履歴はそうではなかった

git rm --cached が消すのはインデックスからのパスであり、過去のコミットからではありません。その違いを計測する方法と、filter-repo でコミット済みのビルド成果物を取り除く手順を解説します。

目次

はじめに

あるプロジェクトのソースを、.venv などを除いた tgz として渡してほしいと頼まれました。それ自体は2分で終わる作業のはずでしたが、実際にパッケージ化してみて問題が表面化しました。リポジトリは 26 MB あり、そのほとんどは最初のコミットで誰にも気づかれないままコミットされていたビルド成果物だったのです。

エージェントは git rm --cached でそれを削除し、ディレクトリを .gitignore に追加し、リポジトリが26 MBから292 KBになったと報告しました。その主張は誤りでした。そしてこれは、この種の間違いがそのまま生き延びる典型的な経路です。追跡対象のファイルは実際に292 KBまで縮んでいましたが、誰も .git そのものを見ていませんでした。.git は依然として26 MBのままだったのです。この blob は6つのうち5つのコミットから到達可能な状態にあり、放っておけばリポジトリと永遠に一緒に運ばれ続けるところでした。

本当の修正は git filter-repo で、かかった時間は1秒未満でした。この記事では、この成果物がどう紛れ込んだのか、一見正しく見える削除コマンドがなぜそれを削除しなかったのか、計測によってその違いをどう見分けるのか、そして安く直せる猶予が最初の push で閉じてしまう理由を扱います。

26 MBが紛れ込んだ経緯 — .gitignore はファイルにマッチしても、ディレクトリにはマッチしなかった

このプロジェクトのデプロイツールは、リポジトリのルートに2つのものを書き込みます。設定ファイルの .bedrock_agentcore.yaml と、ベンダリングされた依存関係の zip を格納するビルドディレクトリの .bedrock_agentcore/ です。

エージェントが書いた .gitignore は、そのうち片方だけを列挙していました。

.gitignore
.venv/
.cognito.env
.bedrock_agentcore.yaml
__pycache__/

gitignore のパターンは、パスに対してマッチするのであって、名前の語幹に対してではありません。 .bedrock_agentcore.yaml は、まさにその名前を持つファイルにマッチします。これは .bedrock_agentcore/ について何も述べていません。これは別のパスなので、そのディレクトリと26 MBのzipは一度も無視されず、最初の git add -A で取り込まれました。

何も文句を言いませんでした。 コミットは成功し、テストは通り、リポジトリはその後何時間も問題なく動作しました。

git rm –cached と、成功に見えた数字

アーカイブをパッケージ化する際、エージェントはこの成果物を見つけ、いかにも正しそうなやり方で削除しました。

ターミナルウィンドウ
git rm -r --cached .bedrock_agentcore
printf '.bedrock_agentcore/\n' >> .gitignore
git commit -m "Stop tracking the AgentCore build artifact directory"

そして計測し、その結果を修正として報告しました。

ターミナルウィンドウ
git ls-files -z | xargs -0 du -ch | tail -1
# 292K total

アーカイブ自体は git archive で作られていました。これはオブジェクトストアではなくツリーを歩くコマンドなので、サイズは 83 KB となり、本当にクリーンでした。目に見えるものはすべて、問題は解決したと告げていました。

その計測は、そもそも見るべきものを間違えていました。 git ls-files が一覧するのは、 追跡されているものです。それ以前のコミットに何が含まれているかについて、git ls-files は何の判断も下しません。git status も、ワーキングツリーのサイズも同じです。

実際にはまだ何が残っていたのか

git rm --cached がすることは1つだけです。パスをインデックスから取り除き、次のコミットにはそれが含まれないようにすることです。これは履歴を操作するコマンドではありませんし、そのように振る舞うつもりもありません。コミットは不変なので、すでにその blob を参照していた5つのコミットは、その後も参照したままでした。そして、いずれかのコミットから到達可能なオブジェクトは、git が保持し続けるオブジェクトです。

これを決着させるコマンドは2つです。1つはオブジェクトストアの大きさを計測し、もう1つは blob がまだ到達可能かどうかを尋ねます。

ターミナルウィンドウ
du -sh .git
# 26M .git
git rev-list --objects --all | grep dependencies.zip
# de67db72dca595f3e22bd20b3ac5c77449734dd7 .bedrock_agentcore/.../dependencies.zip

つまり、このリポジトリは292 KBのファイルを、26 MBのリポジトリの中に抱えていたことになります。この2つの数字の差こそがバグのすべてであり、ワーキングツリーについて報告するどのコマンドからも見えません。

git rm --cached が触るのはインデックスで、過去のコミットではない2 つの帯がある。上の帯は削除後のインデックスとワーキングツリーで、23 ファイル・292 KB・git status はクリーン。下の帯はオブジェクトストアで、6 つのコミットのうち 5 つが 26 MB の blob を参照し続けている。参照していないのは削除を実行した 6 つ目のコミットだけである。git rm --cached が変えたものインデックスとワーキングツリー23 ファイル292 KBgit status はクリーン変わっていないものオブジェクトストア123456ここで index から外したdependencies.zip · 26 MB6 つのうち 5 つのコミットがここを参照している
インデックスはクリーンでした。6つのうち5つのコミットは、それでも blob を指したままでした。

猶予期間 — まだ何も push されていなかったこと

これをそのまま放置せず、すぐに直す価値があると判断させた事実が2つありました。

1つ目は、このリポジトリにリモートが一切なかったことです。ローカルで作成され、一度も push されたことがなかったので、他にクローンは存在せず、ディレクトリの外部からコミット ID を参照しているものも何もありませんでした。

2つ目は、思い込みではなく確認する価値がありました。というのも、同じコードが feature branch として共有リポジトリにも提出されていたからです。もしこの成果物がそちらにも渡っていたら、はるかに厄介な話になっていたはずです。

ターミナルウィンドウ
git rev-list --objects --all | grep -iE "dependencies\.zip|bedrock_agentcore"
# (no output)

クリーンでした。このブランチが追加したのは合計183 KBで、リポジトリをコピーするのではなく、ソースファイルをコピーして組み立てられていたからです。

履歴書き換えのコストは最初の push を境に変わる「最初の push」と書かれた縦の区切り線が 2 つのパネルを分けている。左はローカルのみの状態で、他のクローンが存在せず、コミット ID を誰も参照しておらず、書き換えは 1 コマンド・1 秒未満で済み、コストは後で消すバックアップだけである。右は push 後の状態で、force-push が必要になり、全クローンで再同期が必要になり、進行中の PR が無効になり、コストは他人の時間として発生する。最初の pushローカルのみ他のクローンが存在しないコミット ID を誰も参照していない1 コマンド・1 秒未満コスト: 後で消すバックアップだけpush した後force-push が必要全クローンで再同期が必要進行中の PR が無効になるコスト: 他人の時間
同じ書き換えでも、コミット ID が非公開でなくなる瞬間を境に、コストがまったく変わります。

この組み合わせが、コストの安いケースです。history rewrite は、それが触れた地点より後のすべてのコミット ID を変えます。それらの ID を誰も参照していなければコストはゼロですが、同僚がそれをチェックアウトしていたり、pull request がそれに対して開かれていたり、CI がその ID を記録していたりすれば、コストは大きくなります。私は以前、まったく同じ理由で書き換えを見送ったことがあります。回収できる容量が0.8 MBで、コミット ID がすでに25個のタグと34件のマージ済み pull request に握られていたときです。今回は26 MBで、しかも何も push されていませんでした。同じ問いに対して、正反対の答えが出たわけです。

filter-repo で書き換える

git filter-repofilter-branch の後継であり、現在の git ドキュメントが指し示しているのもこちらです。今回の操作は、すべてのコミットにわたるパスの削除です。

ターミナルウィンドウ
# 先にバックアップを取る: リモートがないので、このディレクトリが唯一のコピーです。
git bundle create ../project-backup.bundle --all
git filter-repo --path .bedrock_agentcore --invert-paths --force

--path はパスを選択し、--invert-paths はその選択を反転させて、指定したもの 以外 をすべて残します。--force が必要なのは、filter-repo が本来はクローンしたての状態を前提としているのに対し、今回は作業中のリポジトリそのものに対して実行したからです。

実行は速く、何をしたかもきちんと出力します。

Parsed 6 commits
New history written in 0.07 seconds; now repacking/cleaning...
Completely finished after 0.20 seconds.

何が変わり、何が変わらなかったのか

変更前 変更後
.git 26 MB 236 KB
追跡ファイル数 23 23
追跡サイズ 292 KB 292 KB
コミット数 6 6
dependencies.zip が到達可能か はい いいえ

すべてのコミットはメッセージそのままに生き残り、ワーキングツリーには一切手が加えられていませんでした。これはパッケージを実際にインポートして抜き打ちで確認済みです。変わったのは、約束どおりすべてのコミット ID だけでした。

見た目の上で1つ、想定しておくべき変化があります。この成果物を削除することだけを目的としていたコミットが、今では何も削除しなくなったのです。書き換え後の履歴のどの時点にも、その成果物自体が存在しなくなったからです。そのコミットに残っているのは、.gitignore の行だけです。

6d88cf7 Stop tracking the AgentCore build artifact directory
.gitignore | 7 ++++---

それでもメッセージ自体は意図を正確に説明しているので、そのまま残しました。これを直すには、機能的な利益がないまま、もう一度書き換えを行うことになります。

最後にもう1つ、ちょっと笑ってしまう罠があります。バックアップ用のバンドルは25 MBあります。これは blob を含む履歴を、そのまま忠実に保存しているからです。これをリポジトリの隣に置いたままにするというのは、まさに取り除こうとしたバイトそのものを保持し続けるということです。書き換えを検証したら削除してください。

ターミナルウィンドウ
git bundle verify ../project-backup.bundle
# The bundle records a complete history.

まとめ

git rm --cached は履歴を操作するコマンドではありませんし、そう振る舞うつもりもありません。パスをインデックスから取り除き、以降のコミットからそれを除外するというもので、想定されている用途にはまさに正しい動作ですが、すでにコミットされてしまったファイルに対してはまったく不十分です。

この間違いがそのまま居座ってしまう理由は、手軽に測れるものがすべて、事後になってあなたに同意してしまうからです。git status はクリーンで、ワーキングツリーは小さく、git archive はきれいな tarball を作ります。ただ1つ食い違う数字が du -sh .git で、これだけがクローン時に実際にダウンロードされるものを言い当てています。

大きなファイルがコミットされてしまっていたら、何よりも先に、何か push 済みかどうかを確認してください。このひとつの事実だけで、修正が1秒未満のローカルコマンドで済むのか、それとも調整が必要な問題になるのかが決まります。

参考リンク

この記事をシェア