# git gcで8.6MBを回収した — 0.8MBのPNG問題を追いかけて見つけた、一度もパックされていないリポジトリ

> git履歴に残る1.6MBのPNGブロブを追ったら、一度もパックされていないリポジトリが出てきました。git gcで8.6MB回収、見送った履歴の書き換えの価値は0.8MBでした。

- Source: https://oharu121.com/ja/blog/git-gc-loose-objects-vs-filter-repo-history-rewrite/
- Published: 2026-08-14T00:04:22+09:00
- Tags: Git, WebP

---
## はじめに

このブログのカバー画像は PNG でした。本文中の図版は変換スクリプトが入った時点から WebP になっていたので、レジストリのほうが対応漏れに見え、フォーマットを揃えるために変換したほうがいいか聞いてみました。答えは「いいえ」で、その理由は予想外のものでした。**このリポジトリは一度もガベージコレクションされていなかった**のです。`git gc` が回収したのは 8.6 MB、私が聞いていた変換のほうは 0.8 MB でした。この記事では、フォーマットの話がパックの話に変わっていった計測の過程と、その 0.8 MB のために履歴を書き換えるのをやめた理由、そして本当にブロブの問題を抱えている読者が知っておくべき「`git gc` にできないこと」を扱います。

## 揃えたかったフォーマットは、すでに揃っていた

前提のほうが先に崩れました。**Astro の画像パイプラインは、ソースが何であれビルド時に WebP の派生画像を出力します**。つまりビルド後のサイトは、どちらのフォーマットも同じ形で配信していました。証拠は `dist/` にあり、サムネイル由来のものも図版由来のものも同じフォーマットで、どちらも PNG ではありません。

```text title="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 に固定してあります。

```ts title="src/layouts/ArticleLayout.astro"
const ogAsset = await getImage({ src: cover.src, width: 1200, height: 630, format: 'png' });
```

この行もソースのフォーマットを気にしません。`sharp` は WebP を問題なくデコードするからです。**ソースを変換しても、通信路を流れるバイト列は 1 バイトも変わりませんでした。** 変換に価値があるとしても、読者にとっての価値はゼロだったということです。

## 残ったのは git の重さだけで、それも小さかった

残るはリポジトリ本体です。これまでに存在したサムネイルのブロブをすべて合計すると、たいていの議論を終わらせるくらい小さな数字が出ます。

```bash
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` はすでに入っていましたし、リポジトリも小さいものです。**コストは実行そのものではなく、実行が何を無効化するかにあり**、それは問題のファイルが最初にいつ現れたかで決まります。

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

*Figure — BlastRadius: 1.6 MB の PNG ブロブは、書き換えでしか取り除けない唯一の項目です。表の残りはすべて、書き換えを実行しない理由になります。*

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

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

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

```bash
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 倍です。**

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

*Figure — RepoWeight: 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 です。

```bash
git config --get gc.auto   # unset, so the default 6700 applies
```

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

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

```bash
git cat-file --batch-all-objects --batch-check='%(objecttype)' | grep -c '^commit'
# 532
git rev-list --all --count
# 49
```

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

*Figure — SquashOrphans: フィーチャーブランチの 3 個のコミットはオブジェクトとしては残っています。squash とブランチ削除のあと、それらを参照するものはありません。*

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

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

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

```bash
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` ファイルを添えます。これが、再パックをまたいで猶予期間を保たせる仕組みです。

最後のものはディスク上で確認でき、その中のオブジェクト数もぴったり一致します。

```bash
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
```

*Figure — ObjectStates: 削除が起きるのは右下のセルだけで、そこは空でした。回収できた分はすべて左上から来ています。*

これらを報告してくれるのが `git fsck` です。本来の役割は掃除ではなく整合性の確認で、全オブジェクトのハッシュを計算し直し、参照されているものが実在するかを確かめます。読み取り専用です。`--unreachable` の一覧は副産物で、既定では reflog も起点として扱います。`git reset --hard` から復帰できるのはそのためです。このリポジトリではその既定が 352 個を隠していました。通常は 828 個、`--no-reflogs` を付けると 1180 個です。

そして、いちばん重要な部分です。この話を正直に書くと「`git gc` で解決した」にはなりません。コレクションの後の状態はこうです。

```bash
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 gc` と `git filter-repo` は同じバイトを相手にしていません。**

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

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

## 代わりに入れたルール

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

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

*Figure — CodecSplit: 同じ 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 週間についても書かれている](https://git-scm.com/docs/git-gc)
- [git-count-objects。`-v` の出力がルーズオブジェクトとパックファイルを区別する](https://git-scm.com/docs/git-count-objects)
- [Git の内部：パックファイルと差分圧縮。テキストが縮んで画像が縮まない理由](https://git-scm.com/book/en/v2/Git-Internals-Packfiles)
- [git-filter-repo。`git gc` では手が届かない問題のための道具](https://github.com/newren/git-filter-repo)
- [git-fsck。`--unreachable` の一覧は `--no-reflogs` を付けない限り reflog を起点として扱う](https://git-scm.com/docs/git-fsck)
- [cruft パックの設計文書。到達不能なオブジェクトをルーズに戻さずパックファイルと `.mtimes` に置く理由](https://github.com/git/git/blob/master/Documentation/technical/cruft-packs.adoc)
