# git gc 回收了 8.6 MB — 追查一個 0.8 MB 的問題時，發現儲存庫從未封裝過

> 追查 git 歷史裡 1.6 MB 的 PNG blob，結果發現儲存庫從未封裝過。git gc 回收了 8.6 MB，而我放棄的那次歷史改寫只值 0.8 MB。

- Source: https://oharu121.com/zh-tw/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 去改寫歷史，以及一件真正遇到 blob 問題的讀者必須知道的事：`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 會送到讀者手上，而那是刻意的。Open Graph 卡片被固定成 PNG：

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

這一行同樣不在意來源格式，因為 `sharp` 解 WebP 完全沒問題。**把來源轉掉，走在線路上的位元組一個都不會變。** 不管轉檔值多少，對讀者來說的價值是零。

## 剩下的只有 git 的重量，而且很小

那就剩儲存庫本身了。把歷來存在過的封面圖 blob 全部加總，會得到一個小到足以結束多數爭論的數字：

```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 個，blob 卻有 24 個，因為其中 6 個曾經被換掉過。重新編碼成 WebP 大約能省下其中一半。

這個儲存庫裡本來就有一條處理同樣情況的規則，是為內文圖表寫的。`scripts/optimize-figures.ts` 不會轉換任何 git 已追蹤的檔案，理由也寫在它自己的註解裡：**改寫一個已經提交的檔案什麼也拿不回來，因為舊的 blob 會永遠留在歷史裡。** 那 18 張封面圖全都已經提交。就地轉換只會多出第二份副本，而不是取代第一份。

**所以唯一能省下東西的做法，是把舊 blob 從歷史裡移除**，我問了這件事有多難。

## 那次改寫會波及什麼

以操作來說並不難。`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)
```

**那些封面圖就在第一個提交裡**，這是最糟糕的答案。從第一個提交開始的改寫會重新計算其後每一個提交的雜湊，以當時的數字來說，就是全部 45 個提交、全部 25 個標籤重新指向，以及 34 個已合併 PR 背後的合併提交，指向的物件不再存在於 `main` 上。

*Figure — BlastRadius: 那 1.6 MB 的 PNG blob 是唯一只有改寫才能移除的項目。表裡其餘每一項，都是不要執行改寫的理由。*

有一項檢查結果令人安心，但沒有改變結論。在已發布文章中搜尋指向這個儲存庫的連結，共找到 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: 這兩條長條之間沒有任何新增或刪除。非圖片的那一半被壓縮了，圖片那一半壓不動，因為它進來的時候就已經是壓縮過的。*

把整個歷史加總，圖片 blob 是 6.7 MB。這個數字在封裝前後幾乎一樣，因為 PNG 和 WebP 都已經是壓縮過的格式，封裝檔幾乎榨不出東西。周圍的 MDX 和 TypeScript 則縮得非常多。於是圖片從鬆散儲存庫裡的少數，變成**封裝後儲存庫的大半，而且一個位元組都沒有增加**。這也說明了為什麼鬆散物件的視角讓它們看起來比實際小，而封裝後的視角又讓它們看起來大到任何改寫都補不回來。

這個重新框架就是決定的關鍵。我決定不改寫歷史：拿全部提交的 SHA、25 個標籤，以及 34 個已合併 PR 上的提交連結去換 0.8 MB，在一個從來沒人抱怨過複製速度、只有一位作者的私有儲存庫上並不划算。執行 `git gc` 則不用付出任何代價。

## 2081 個鬆散物件與 483 個孤立提交是哪裡來的

有兩個詞常被當成同一件事在用，而它們的差別正好決定了 `git gc` 幫不幫得上忙。

**鬆散講的是物件怎麼存**：位於 `.git/objects/ab/cdef…` 的一個檔案一個物件的 zlib 檔，沒有跟任何東西做差分壓縮。**不可到達講的是有沒有東西指向它。** 這是兩個獨立的軸，不是同一個狀態的兩種說法。

| 用語 | 指的是什麼 | 常讓人意外的情況 |
| --- | --- | --- |
| 鬆散 | 物件在磁碟上的存放方式 | 你剛做的那個提交就是鬆散的，而且完全是最新的 |
| 不可到達 | 有沒有參照或 reflog 指向它 | 已封裝的物件也可能是不可到達的垃圾 |

**那 2081 個鬆散物件裡，有 1269 個是可到達而且完全現役的。** 它們一點問題也沒有，就是 git 一路寫下來的物件：每個加入的檔案一個 blob、每個提交一個提交物件，以及該提交碰到的每一層目錄一個 tree。數量上以 tree 最多，因為一條巢狀路徑在每次被碰到的提交裡，都會依層數各寫一個 tree。

**它們會累積，是因為從來沒有任何事觸發封裝。** 當鬆散物件數超過 `gc.auto` 時 git 會自動封裝，而這個值預設是 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 合併不會合併分支上的提交，而是把它們的差異合起來，用一個新的雜湊寫成一個新提交。之後刪掉分支，原本那些提交就再也沒有東西指向它們了。

*Figure — SquashOrphans: 功能分支那三個提交仍以物件的形式存在。在 squash 與刪除分支之後，沒有任何東西參照它們。*

發布這篇文章相關工具的那次發布，就在調查途中示範了一次：不可到達的數量從 812 變成 826，新增的 14 個正是那條分支的三個提交，加上它們的 tree 和 blob。

## 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` 會保留而不是刪除它們。

值得把這個指令做的事講精確，因為它其實是三個動作，而**只有其中一個會刪東西**：

1. **重新封裝。** 所有可到達的物件收進同一個封裝檔，並與相鄰物件做差分壓縮。這個封裝檔最後裝了 1519 個物件，其中 10 個是在前面數鬆散物件之後才寫入的，而且什麼也沒刪。
2. **prune。** 不可到達的物件在超過 `gc.pruneExpire`（預設 `2.weeks.ago`）之後才會被刪除。那 812 個都比這新，所以都不符合條件。
3. **cruft 封裝檔。** 撐過第 2 步的物件如果還維持鬆散，就會讓第 1 步白做，所以 git 把它們寫進第二個封裝檔，旁邊附上一個記錄每個物件年齡的 `.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 blob 都還在。**`git gc` 只會刪除沒有任何東西參照的物件**，而這些從 9 個提交都到得了，所以它把它們封裝起來，24 個全部保留。**我一開始要調查的那個問題，跟之前一樣完全沒有解決**，而這是正確的結果，不是沒收尾。要解決它得改寫 45 個提交，換回 0.8 MB。

值得帶走的就是這個區別。**`git gc` 和 `git filter-repo` 處理的根本不是同一批位元組：**

| 症狀 | 實際上出了什麼事 | 該用的工具 |
| --- | --- | --- |
| `.git` 遠大於工作目錄，有數千個鬆散物件 | 從未封裝。沒有差分壓縮，也沒有封裝檔 | `git gc` |
| 歷史中可到達、但已經沒人要的大檔案 | blob 被某個提交參照著，所以誰都丟不掉它 | `git filter-repo` |
| 從來沒有被提交過的大檔案 | 目前還沒有問題 | 在提交之前轉檔或刪掉 |

如果你歷史裡真的埋著一個 500 MB 的二進位檔，那還是得靠改寫。`git gc` 會把它整齊地封裝好，然後什麼也不還給你。

## 最後上線的規則

既然能省下的只存在於第一次提交之前，這條規則就寫成只在那個時刻生效。`pnpm thumbnails:fix` 現在會轉換任何 git 尚未追蹤的 PNG，並且不碰那 18 個已提交的。這跟圖表管線本來就採用的豁免方式相同，於是兩支腳本現在對同一個問題給出同樣的答案。

唯一一個看似合理卻猜錯的地方是編碼器設定。代理最初的建議是全部使用無損，理由是這些都是純色卡片上的扁平標誌，有損編碼的振鈴會出現在字樣邊緣。把每張卡片都用兩種方式量過之後，這個理由只適用於登錄表的一半：

*Figure — CodecSplit: 同樣兩種編碼器，同一張圖。扁平標誌與帶質感的卡片，對於哪一種比較小的答案差了一個數量級。*

最後採用的不是任何一種固定設定。代理提議每張圖都用兩種方式編碼，保留比較小的那一份，腳本也就是這樣做的。它看起來像是省容量的小手段，實際上是一個內容分類器：**無損輸出會膨脹的時機，正好就是圖片帶有讓有損編碼難以察覺的漸層或照片質感的時候**，所以比較小的那份檔案，可靠地就是適合這張圖的編碼器。18 張卡片的總計是：維持 PNG 為 1216 KB、全部無損為 602 KB、全部 q90 為 210 KB，逐張挑選則是 193 KB。

有一個缺陷一路活到程式碼審查。目錄掃描已經放寬到 `.png` 之外也接受 `.webp`，但寫入端仍然依據轉換旗標挑選編碼器，而這個旗標只有在遇到未追蹤的 PNG 時才為真。**於是規格不符的 WebP 走了 PNG 那條路，以自己的 `.webp` 檔名被寫成 PNG 位元組**，量起來是 1200x630，安靜地通過了之後每一項檢查。我選擇把它連同另外兩個較小的問題一起修掉，而不是只交付已知正常的那條路徑。

## 總結

- **我問的格式一致性，在交付端早就成立了。** Astro 從兩種來源格式都會輸出 WebP，唯一留下的 PNG 是刻意固定的 Open Graph 卡片。
- **`git gc` 回收了 8.6 MB，歷史改寫會拿回 0.8 MB。** 先量免費的那個選項，才讓昂貴的那個明顯不值得執行。
- **鬆散與不可到達是彼此獨立的。** 鬆散是存放格式，而這些多半是現役的；不可到達是生命週期狀態。`git gc` 封裝前者、隔離後者，在這裡它什麼也沒刪。
- **鬆散物件會堆積，是因為 `gc.auto` 預設是 6700**，而這個儲存庫最多只到 2081，自動封裝從未觸發。push 也不會封裝本機那一份。
- **兩個工具解的是不同的問題。** `git gc` 封裝鬆散物件，而且只刪除不可到達的。它一個 PNG blob 都沒移除，因為那 24 個全都從 9 個提交可到達。
- **已經壓縮過的檔案，正是封裝後的儲存庫看起來和鬆散時不同的原因。** 文字吃得到差分壓縮，圖片吃不到，所以圖片在自身完全沒變的情況下，從 39% 變成 80%。
- **改寫的代價，由那個檔案最早出現的時間點決定。** 這些封面圖在第一個提交裡，所以波及範圍是整個歷史，而不是最近的一小段。
- 兩種方式都編一次、留小的那份，勝過任何一種固定設定，因為檔案大小結果是一個好用的代理指標，能判斷一張圖有沒有質感。

## 參考連結

- [git-gc 文件，包含讓不可到達物件留存的 `gc.pruneExpire` 兩週預設值](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` 清單預設把 reflog 當成根，除非你加上 `--no-reflogs`](https://git-scm.com/docs/git-fsck)
- [cruft 封裝檔的設計文件，說明為什麼不可到達的物件會進封裝檔與 `.mtimes`，而不是被還原成鬆散檔](https://github.com/git/git/blob/master/Documentation/technical/cruft-packs.adoc)
