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

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 ではありません。

dist/_astro/
astro-generic.CZGwia_K_2sMF92.webp ← source is PNG
one-component-two-locales.CwF4yp8J.webp ← source is WebP

読者に届く PNG は 1 つだけ残りますが、これは意図的なものです。Open Graph カードは PNG に固定してあります。

src/layouts/ArticleLayout.astro
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 にないオブジェクトを指したまま取り残される、ということです。

各案のコストと、得られるもの2 つの案の比較。git filter-repo による履歴の書き換えは、45 個すべてのコミットのハッシュを変え、25 個すべてのタグを貼り直し、マージ済みプルリクエスト 34 件のマージコミットを切り離したうえで 0.8 MB を回収します。1.6 MB の PNG ブロブを削除できるのはこちらだけです。git gc はコミットもタグもプルリクエストも一切変えずに 8.6 MB を回収しますが、PNG ブロブは 1 つも削除せず、24 個すべてが残ります。各案のコストと、得られるもの当時のこのリポジトリでの計測値:コミット 45、タグ 25、マージ済みプルリクエスト 34git filter-repogit gcハッシュが変わるコミット45なし貼り直されるタグ25なし切り離されるマージ済み PR34なしPNG ブロブの削除できる(1.6 MB)できない(24 個すべて残る)回収できる容量0.8 MB8.6 MB
1.6 MB の PNG ブロブは、書き換えでしか取り除けない唯一の項目です。表の残りはすべて、書き換えを実行しない理由になります。

安心できる確認が 1 つありましたが、結論は変わりませんでした。公開済み記事からこのリポジトリへのリンクを探すと 11 件見つかり、そのすべてが別のデモ用リポジトリを指していて、ここのコミットは指していませんでした。つまり公開中のコンテンツは壊れません。ただ、その意味は思ったほど大きくありませんでした。天秤のもう一方の数字が、すでに崩れていたからです。

このリポジトリは一度もパックされていなかった

書き換えでどれだけ回収できるかを測っている途中で、エージェントが git count-objects を実行し、別の問題を見つけました。

ターミナルウィンドウ
du -sh .git
# 17M
git 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 倍です。

何が縮んだのかという内訳のほうが、持ち帰る価値があります。

同じリポジトリ、パック前とパック後実際の比率で描いた 2 本の横棒グラフ。ガベージコレクション前のリポジトリは 17.0 MB で、うち画像ブロブが 6.7 MB、それ以外が 10.3 MB、画像の割合は 39% です。ガベージコレクション後は 8.4 MB で、画像ブロブは同じ 6.7 MB のまま、それ以外は 1.7 MB に減り、画像の割合は 80% になります。画像部分は変わらず、それ以外だけが縮んでいます。同じリポジトリ、パック前とパック後画像ブロブは全履歴の合計サイズ。パックしてもほとんど変わらないgc 前17.0 MBgc 後8.4 MB画像ブロブそれ以外
2 本の棒の間で、追加も削除もしていません。画像以外の半分は圧縮され、画像の半分は圧縮できませんでした。届いた時点ですでに圧縮済みだからです。

全履歴を合計すると、画像ブロブは 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 applies

2081 では一度もしきい値に届きません。git push もローカルのリポジトリをパックしません。受け取った側の GitHub は自分のコピーをパックするので、リモートは 7964 KB と報告し、手元は 17 MB のままでした。

到達不能なオブジェクトのほうは、出どころがまったく違っていて、そちらのほうが面白いものでした。

ターミナルウィンドウ
git cat-file --batch-all-objects --batch-check='%(objecttype)' | grep -c '^commit'
# 532
git rev-list --all --count
# 49

コミット 49 個の履歴に、コミットオブジェクトが 532 個あります。 残りの 483 個は孤立したもので、その最大の生産者はこのブログのリリース手順そのものでした。squash マージはブランチのコミットをマージしません。それらの差分をまとめた新しいコミットを、新しいハッシュで 1 つ書きます。そのあとブランチを削除すれば、元のコミットは何からも参照されなくなります。

squash マージが残していくもの2 段の図。マージ前は main のコミット列に 3 個のコミットからなるフィーチャーブランチがつながっています。マージ後、main には差分全体を含む新しいハッシュのコミットが 1 個増え、フィーチャーブランチの 3 個のコミットはどこからも参照されない状態で切り離されて描かれています。squash マージはコミットをマージせず差分を再生するだけで、そのあとブランチが削除されたためです。squash マージが残していくものリリースごとに 1 回。その積み重ねが 483 個の孤立コミットマージ前mainフィーチャーブランチ(後に削除)マージ後main新しいハッシュの新規コミット 1 個もうどこからも参照されない
フィーチャーブランチの 3 個のコミットはオブジェクトとしては残っています。squash とブランチ削除のあと、それらを参照するものはありません。

この記事のツールを出したリリースが、調査の最中にそれを実演してくれました。到達不能の数が 812 から 826 に増えたのです。増えた 14 個は、そのブランチの 3 個のコミットとそのツリーおよびブロブでした。

git gc が実際にやったことと、やらなかったこと

このリポジトリでの結果です。

ターミナルウィンドウ
git gc
du -sh .git
# 8.4M
git count-objects -vH
# count: 0
# in-pack: 2331
# size-pack: 8.12 MiB

8.6 MB 回収、書き換えたオブジェクトはゼロ。 テスト用クローンが出した 7.8 MB より少し大きいのは、到達不能な 812 個のオブジェクトの分で、素の git gc はこれを削除せず保持するからです。

このコマンドが何をしたのかは、正確に押さえておく価値があります。3 つの処理があり、そのうち削除するのは 1 つだけだからです。

  1. 再パック。 到達可能なオブジェクトをすべて 1 つのパックファイルに入れ、隣接するオブジェクトと差分圧縮します。このパックには最終的に 1519 個が入りました。うち 10 個は先ほどのルーズ数を数えたあとに書かれたものです。削除は一切ありません。
  2. prune。 到達不能なオブジェクトは gc.pruneExpire(既定は 2.weeks.ago)より古くなった時点で削除されます。812 個はすべてそれより新しく、対象外でした。
  3. 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 ages
git verify-pack -v .git/objects/pack/pack-2bd20ff4….idx | grep -cE '^[0-9a-f]{40}'
# 812
独立した 2 つの軸と、4 通りの結果2 行 2 列のグリッド。列は到達可能と到達不能、行はルーズとパック済み。ルーズかつ到達可能なオブジェクトはメインのパックファイルにまとめられ、その数はルーズ 2081 個のうち 1269 個で、これが 8.6 MB の内訳です。ルーズかつ到達不能なオブジェクトは cruft パックに退避され、812 個が 2 週間保持されます。パック済みかつ到達可能なものは差分圧縮をやり直して保持され、元から 240 個ありました。パック済みかつ到達不能なものは 2 週間経過後に削除されますが、期限切れはまだありません。24 個の PNG ブロブは到達可能の列にあり、そのため gc では決して削除されません。独立した 2 つの軸と、4 通りの結果git gc の挙動は片方ではなく両方で決まる到達可能参照や reflog からたどれる到達不能どこからもたどれないルーズ1 オブジェクト 1 zlib ファイルパック済みパックファイルの中メインのパックファイルへルーズ 2081 個中 1269 個。8.6 MB の正体cruft パックへ退避812 個。2 週間は保持差分圧縮をやり直して保持元から 240 個2 週間経過後に削除期限切れはまだゼロ24 個の PNG ブロブは到達可能の列にあります。gc をいくら実行しても消えないのはそのためです。
削除が起きるのは右下のセルだけで、そこは空でした。回収できた分はすべて左上から来ています。

これらを報告してくれるのが 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 MB

PNG ブロブはすべて残っています。git gc が削除するのは、どこからも参照されていないオブジェクトだけで、これらは 9 個のコミットから到達可能なので、パックされて 24 個すべてが保持されました。私が調べ始めた問題は、以前とまったく同じだけ未解決のままであり、それは中途半端に放置しているのではなく正しい結果です。解決には 45 個のコミットの書き換えが必要で、返ってくるのは 0.8 MB です。

持ち帰る価値があるのはこの区別です。git gcgit filter-repo は同じバイトを相手にしていません。

症状 実際に起きていること 使う道具
.git がワークツリーよりはるかに大きく、ルーズオブジェクトが数千個ある 一度もパックされていない。差分圧縮もパックファイルもない git gc
もう誰も要らない大きなファイルが履歴から到達可能 ブロブがコミットから参照されているので、何も落とせない git filter-repo
まだ一度もコミットされていない大きなファイル まだ何も問題は起きていない コミット前に変換するか削除する

履歴に本物の 500 MB のバイナリを抱えているなら、やはり書き換えが必要です。git gc はそれをきれいにパックして、何も返してくれません。

代わりに入れたルール

浮くのは最初のコミットより前だけなので、ルールもその瞬間にだけ効くように書きました。pnpm thumbnails:fix は git がまだ追跡していない PNG を変換し、コミット済みの 18 個には手を出しません。図版のパイプラインがすでに使っていたのと同じ扱いで、2 つのスクリプトが同じ問いに同じ答えを返すようになりました。

もっともらしい思い込みが外れたのは、エンコーダの設定でした。エージェントの最初の提案は全部ロスレスで、フラットなロゴカードでは非可逆のリンギングがロゴタイプの縁に出るから、という理屈でした。全カードを両方で測ってみると、その理屈が当てはまるのはレジストリの半分だけでした。

1 つの設定ではレジストリの両方をまかなえない4 枚のカバー画像を WebP ロスレスと WebP q90 の両方でエンコードし、同じ尺度で並べた図。フラットなマークではロスレスのほうが小さく、git-generic は 5 KB 対 12 KB、vercel-generic は 4 KB 対 7 KB です。質感のあるカードでは結果が一桁の差で逆転し、expressive-code-generic は 90 KB 対 15 KB、llm-generic は 197 KB 対 20 KB になります。1 つの設定ではレジストリの両方をまかなえないカードはすべて 1200 × 630。棒は同じ 2 つのエンコーダ単色カード上のフラットなマークgit-generic5 KB ロスレス12 KB q90vercel-generic4 KB ロスレス7 KB q90グラデーションや写真的な質感expressive-code-generic90 KB ロスレス15 KB q90llm-generic197 KB ロスレス20 KB q90小さいほう
同じ 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 にあったので、影響範囲は履歴の一部ではなく全体でした。
  • 両方でエンコードして小さいほうを残す方式が、どちらの固定設定にも勝ちました。ファイルサイズが、画像に質感があるかどうかの実用的な代理指標になっていたからです。

参考リンク

この記事をシェア