
git gcで8.6MBを回収した — 0.8MBのPNG問題を追いかけて見つけた、一度もパックされていないリポジトリ
git履歴に残る1.6MBのPNGブロブを追ったら、一度もパックされていないリポジトリが出てきました。git gcで8.6MB回収、見送った履歴の書き換えの価値は0.8MBでした。
目次
はじめに
このブログのカバー画像は PNG でした。本文中の図版は変換スクリプトが入った時点から WebP になっていたので、レジストリのほうが対応漏れに見え、フォーマットを揃えるために変換したほうがいいか聞いてみました。答えは「いいえ」で、その理由は予想外のものでした。このリポジトリは一度もガベージコレクションされていなかったのです。git gc が回収したのは 8.6 MB、私が聞いていた変換のほうは 0.8 MB でした。この記事では、フォーマットの話がパックの話に変わっていった計測の過程と、その 0.8 MB のために履歴を書き換えるのをやめた理由、そして本当にブロブの問題を抱えている読者が知っておくべき「git gc にできないこと」を扱います。
揃えたかったフォーマットは、すでに揃っていた
前提のほうが先に崩れました。Astro の画像パイプラインは、ソースが何であれビルド時に WebP の派生画像を出力します。つまりビルド後のサイトは、どちらのフォーマットも同じ形で配信していました。証拠は dist/ にあり、サムネイル由来のものも図版由来のものも同じフォーマットで、どちらも PNG ではありません。
astro-generic.CZGwia_K_2sMF92.webp ← source is PNGone-component-two-locales.CwF4yp8J.webp ← source is WebP読者に届く PNG は 1 つだけ残りますが、これは意図的なものです。Open Graph カードは PNG に固定してあります。
const ogAsset = await getImage({ src: cover.src, width: 1200, height: 630, format: 'png' });この行もソースのフォーマットを気にしません。sharp は WebP を問題なくデコードするからです。ソースを変換しても、通信路を流れるバイト列は 1 バイトも変わりませんでした。 変換に価値があるとしても、読者にとっての価値はゼロだったということです。
残ったのは git の重さだけで、それも小さかった
残るはリポジトリ本体です。これまでに存在したサムネイルのブロブをすべて合計すると、たいていの議論を終わらせるくらい小さな数字が出ます。
git rev-list --objects --all -- src/assets/thumbnails | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | grep '^blob' | awk '{s+=$3; n++} END {printf "%d blobs, %.1f MB\n", n, s/1048576}'# 24 blobs, 1.6 MB現存するファイルは 18 個なのにブロブは 24 個あります。どこかで 6 個が差し替えられたためです。WebP に再エンコードして浮くのは、このうちおよそ半分になります。
このリポジトリには、まさにこの状況を扱うルールが図版向けにすでに書かれていました。scripts/optimize-figures.ts は git が追跡しているファイルを変換しません。理由もコメントに書いてあります。すでにコミットされたファイルを書き換えても何も取り戻せない。古いブロブは履歴に永久に残るからだ、と。18 個のサムネイルはすべてコミット済みでした。その場で変換すれば、1 つ目を置き換えるのではなく 2 つ目のコピーを足すだけになります。
つまり、何かを浮かせられるのは古いブロブを履歴から取り除く方法だけであり、私はそれがどれくらい大変か聞きました。
書き換えが巻き込むもの
作業自体は大変ではありません。git-filter-repo はすでに入っていましたし、リポジトリも小さいものです。コストは実行そのものではなく、実行が何を無効化するかにあり、それは問題のファイルが最初にいつ現れたかで決まります。
git log --format='%h %ad %s' --date=short -- src/assets/thumbnails | tail -1# 999d7e5 2026-08-04 feat: trilingual Astro blog with translation pipeline (#1)サムネイルは最初のコミットに入っていました。 これは考えうる最悪の答えです。コミット 1 から始める書き換えは、それ以降のすべてのコミットのハッシュを変えます。当時の数字でいえば 45 個すべてのコミット、25 個すべてのタグの貼り直し、そしてマージ済みプルリクエスト 34 件のマージコミットが main にないオブジェクトを指したまま取り残される、ということです。
安心できる確認が 1 つありましたが、結論は変わりませんでした。公開済み記事からこのリポジトリへのリンクを探すと 11 件見つかり、そのすべてが別のデモ用リポジトリを指していて、ここのコミットは指していませんでした。つまり公開中のコンテンツは壊れません。ただ、その意味は思ったほど大きくありませんでした。天秤のもう一方の数字が、すでに崩れていたからです。
このリポジトリは一度もパックされていなかった
書き換えでどれだけ回収できるかを測っている途中で、エージェントが git count-objects を実行し、別の問題を見つけました。
du -sh .git# 17Mgit count-objects -vH# count: 2081# size: 16.46 MiB# in-pack: 240# size-pack: 437.45 KiBルーズオブジェクトが 2081 個、パックファイルに入っているのは 240 個。 このリポジトリは何か月もコミットされ続けて、一度もガベージコレクションされていませんでした。そのためほとんどのオブジェクトが、何とも差分圧縮されないまま個別に zlib 圧縮されたファイルとしてディスクに置かれていたのです。
数字がまだ推測の段階で何も壊さないよう、使い捨てのミラークローンでパックしてみたところ、17.0 MB が 7.8 MB になりました。何も書き換えないコマンドで、履歴の書き換えが提示していた額の 11 倍です。
何が縮んだのかという内訳のほうが、持ち帰る価値があります。
全履歴を合計すると、画像ブロブは 6.7 MB になります。この数字はパックの前後でほとんど変わりません。PNG も WebP もすでに圧縮済みのフォーマットで、パックファイルに絞り取れる余地がほとんど残っていないからです。周囲の MDX や TypeScript のほうは大きく縮みます。その結果、画像はルーズなリポジトリでは少数派だったのに、1 バイトも増えないままパック後のリポジトリの大半になりました。ルーズオブジェクトの視点では実際より小さな問題に見え、パック後の視点ではどんな書き換えでも直せないほど大きく見える、というわけです。
この捉え直しが決め手になりました。私は履歴を書き換えないことにしました。すべてのコミット SHA と 25 個のタグ、そしてマージ済みプルリクエスト 34 件のコミットリンクを 0.8 MB と引き換えにするのは、クローン時間が一度も不満になったことのない、著者 1 人のプライベートリポジトリでは割に合いません。git gc はタダでした。
ルーズオブジェクト 2081 個と孤立コミット 483 個の出どころ
同じものを指しているかのように使われる 2 つの言葉があり、その違いが git gc で解決できるかどうかを決めます。
ルーズはオブジェクトの保存形式を指します。 .git/objects/ab/cdef… に置かれた、何とも差分圧縮されていない 1 オブジェクト 1 zlib ファイルのことです。到達不能は、そこを指すものがあるかどうかを指します。 この 2 つは独立した軸であって、同じ状態の別名ではありません。
| 用語 | 何を指すか | 意外に思われがちな例 |
|---|---|---|
| ルーズ | ディスク上での置かれ方 | いま作ったばかりのコミットもルーズで、しかも最新 |
| 到達不能 | 参照や reflog からたどれるか | パック済みのオブジェクトが到達不能なゴミであることもある |
2081 個のルーズオブジェクトのうち、1269 個は到達可能で、まったく現役でした。 どこも壊れていません。git が作業のたびに書くオブジェクト、つまり追加したファイルごとのブロブ、コミットごとのコミットオブジェクト、そのコミットが触れたディレクトリごとのツリーです。数のうえではツリーが最も多くなります。深いパスは、それを触るコミットのたびに階層の数だけツリーを書くからです。
たまっていた理由は、パックのきっかけが一度も起きなかったからです。 git はルーズオブジェクト数が gc.auto を超えると自動的にパックしますが、この既定値は 6700 です。
git config --get gc.auto # unset, so the default 6700 applies2081 では一度もしきい値に届きません。git push もローカルのリポジトリをパックしません。受け取った側の GitHub は自分のコピーをパックするので、リモートは 7964 KB と報告し、手元は 17 MB のままでした。
到達不能なオブジェクトのほうは、出どころがまったく違っていて、そちらのほうが面白いものでした。
git cat-file --batch-all-objects --batch-check='%(objecttype)' | grep -c '^commit'# 532git rev-list --all --count# 49コミット 49 個の履歴に、コミットオブジェクトが 532 個あります。 残りの 483 個は孤立したもので、その最大の生産者はこのブログのリリース手順そのものでした。squash マージはブランチのコミットをマージしません。それらの差分をまとめた新しいコミットを、新しいハッシュで 1 つ書きます。そのあとブランチを削除すれば、元のコミットは何からも参照されなくなります。
この記事のツールを出したリリースが、調査の最中にそれを実演してくれました。到達不能の数が 812 から 826 に増えたのです。増えた 14 個は、そのブランチの 3 個のコミットとそのツリーおよびブロブでした。
git gc が実際にやったことと、やらなかったこと
このリポジトリでの結果です。
git gcdu -sh .git# 8.4Mgit count-objects -vH# count: 0# in-pack: 2331# size-pack: 8.12 MiB8.6 MB 回収、書き換えたオブジェクトはゼロ。 テスト用クローンが出した 7.8 MB より少し大きいのは、到達不能な 812 個のオブジェクトの分で、素の git gc はこれを削除せず保持するからです。
このコマンドが何をしたのかは、正確に押さえておく価値があります。3 つの処理があり、そのうち削除するのは 1 つだけだからです。
- 再パック。 到達可能なオブジェクトをすべて 1 つのパックファイルに入れ、隣接するオブジェクトと差分圧縮します。このパックには最終的に 1519 個が入りました。うち 10 個は先ほどのルーズ数を数えたあとに書かれたものです。削除は一切ありません。
- prune。 到達不能なオブジェクトは
gc.pruneExpire(既定は2.weeks.ago)より古くなった時点で削除されます。812 個はすべてそれより新しく、対象外でした。 - cruft パック。 手順 2 を生き延びたオブジェクトをルーズのまま残すと手順 1 が台無しになるので、git はそれらを 2 つ目のパックファイルに書き、各オブジェクトの経過時間を記録した
.mtimesファイルを添えます。これが、再パックをまたいで猶予期間を保たせる仕組みです。
最後のものはディスク上で確認でき、その中のオブジェクト数もぴったり一致します。
ls .git/objects/pack/# pack-1f33b91c….pack the reachable objects# pack-2bd20ff4….pack the cruft pack# pack-2bd20ff4….mtimes its per-object agesgit verify-pack -v .git/objects/pack/pack-2bd20ff4….idx | grep -cE '^[0-9a-f]{40}'# 812これらを報告してくれるのが git fsck です。本来の役割は掃除ではなく整合性の確認で、全オブジェクトのハッシュを計算し直し、参照されているものが実在するかを確かめます。読み取り専用です。--unreachable の一覧は副産物で、既定では reflog も起点として扱います。git reset --hard から復帰できるのはそのためです。このリポジトリではその既定が 352 個を隠していました。通常は 828 個、--no-reflogs を付けると 1180 個です。
そして、いちばん重要な部分です。この話を正直に書くと「git gc で解決した」にはなりません。コレクションの後の状態はこうです。
git rev-list --objects --all -- src/assets/thumbnails | ... # same command as above# 24 blobs, 1.58 MBPNG ブロブはすべて残っています。git gc が削除するのは、どこからも参照されていないオブジェクトだけで、これらは 9 個のコミットから到達可能なので、パックされて 24 個すべてが保持されました。私が調べ始めた問題は、以前とまったく同じだけ未解決のままであり、それは中途半端に放置しているのではなく正しい結果です。解決には 45 個のコミットの書き換えが必要で、返ってくるのは 0.8 MB です。
持ち帰る価値があるのはこの区別です。git gc と git filter-repo は同じバイトを相手にしていません。
| 症状 | 実際に起きていること | 使う道具 |
|---|---|---|
.git がワークツリーよりはるかに大きく、ルーズオブジェクトが数千個ある |
一度もパックされていない。差分圧縮もパックファイルもない | git gc |
| もう誰も要らない大きなファイルが履歴から到達可能 | ブロブがコミットから参照されているので、何も落とせない | git filter-repo |
| まだ一度もコミットされていない大きなファイル | まだ何も問題は起きていない | コミット前に変換するか削除する |
履歴に本物の 500 MB のバイナリを抱えているなら、やはり書き換えが必要です。git gc はそれをきれいにパックして、何も返してくれません。
代わりに入れたルール
浮くのは最初のコミットより前だけなので、ルールもその瞬間にだけ効くように書きました。pnpm thumbnails:fix は git がまだ追跡していない PNG を変換し、コミット済みの 18 個には手を出しません。図版のパイプラインがすでに使っていたのと同じ扱いで、2 つのスクリプトが同じ問いに同じ答えを返すようになりました。
もっともらしい思い込みが外れたのは、エンコーダの設定でした。エージェントの最初の提案は全部ロスレスで、フラットなロゴカードでは非可逆のリンギングがロゴタイプの縁に出るから、という理屈でした。全カードを両方で測ってみると、その理屈が当てはまるのはレジストリの半分だけでした。
採用したのはどちらの固定設定でもありません。エージェントが提案したのは、1 枚ごとに両方でエンコードして小さいほうを残す方式で、スクリプトはそのとおりに動きます。サイズ稼ぎの小細工に見えて、実際には内容の分類器として働きます。ロスレスの出力が膨らむのは、ちょうど非可逆が目立たなくなるグラデーションや写真的な質感を画像が持っているときなので、小さいほうのファイルは確実にその絵柄に合ったコーデックになります。18 枚の合計は、PNG のままで 1216 KB、全部ロスレスで 602 KB、全部 q90 で 210 KB、1 枚ずつ選んで 193 KB でした。
コードレビューまで生き延びた不具合が 1 つあります。ディレクトリの走査は .png に加えて .webp も拾うよう広げたのに、書き出し側は変換フラグでエンコーダを選んだままでした。このフラグが真になるのは追跡外の PNG のときだけです。そのため規格外の WebP が PNG の経路を通り、自分の .webp という名前のまま PNG のバイト列として書き出され、1200x630 として計測されて以降のチェックをすべて静かに通過していました。私は既知の正常経路だけを出すのではなく、これと残り 2 件の小さな指摘もあわせて直すことにしました。
まとめ
- 私が聞いたフォーマットの統一は、配信の時点ではすでに実現していました。 Astro はどちらのソース形式からも WebP を出力し、残る PNG は意図的に固定した Open Graph カードです。
git gcが回収したのは 8.6 MB、履歴の書き換えが返すのは 0.8 MB でした。 先に無料の選択肢を測ったことが、高くつくほうを明らかに割に合わないものにしました。- ルーズと到達不能は独立しています。 ルーズは保存形式で、その多くは現役でした。到達不能は状態です。
git gcは前者をパックし後者を隔離するだけで、ここでは何ひとつ削除していません。 - ルーズオブジェクトがたまったのは
gc.autoの既定が 6700 だからで、このリポジトリは 2081 までしか届かず、自動パックが一度も走りませんでした。push してもローカルはパックされません。 - 2 つの道具が解く問題は別です。
git gcはルーズオブジェクトをパックし、到達不能なものだけを削除します。1.6 MB の PNG ブロブは 1 つも消えていません。24 個すべてが 9 個のコミットから到達可能だからです。 - パック後のリポジトリがルーズなものと違って見えるのは、すでに圧縮済みのファイルがあるからです。 テキストは差分圧縮が効き、画像は効きません。だから画像は、中身が何も変わらないまま 39% から 80% になりました。
- 書き換えのコストは、そのファイルが最初に現れた時点で決まります。 このサムネイルはコミット 1 にあったので、影響範囲は履歴の一部ではなく全体でした。
- 両方でエンコードして小さいほうを残す方式が、どちらの固定設定にも勝ちました。ファイルサイズが、画像に質感があるかどうかの実用的な代理指標になっていたからです。
参考リンク
- git-gc のドキュメント。到達不能なオブジェクトを残す
gc.pruneExpireの既定値 2 週間についても書かれている - git-count-objects。
-vの出力がルーズオブジェクトとパックファイルを区別する - Git の内部:パックファイルと差分圧縮。テキストが縮んで画像が縮まない理由
- git-filter-repo。
git gcでは手が届かない問題のための道具 - git-fsck。
--unreachableの一覧は--no-reflogsを付けない限り reflog を起点として扱う - cruft パックの設計文書。到達不能なオブジェクトをルーズに戻さずパックファイルと
.mtimesに置く理由