
git gc 回收了 8.6 MB — 追查一個 0.8 MB 的問題時,發現儲存庫從未封裝過
追查 git 歷史裡 1.6 MB 的 PNG blob,結果發現儲存庫從未封裝過。git gc 回收了 8.6 MB,而我放棄的那次歷史改寫只值 0.8 MB。
本頁目錄
引言
這個部落格的封面圖是 PNG。內文圖表在轉檔腳本進來之後就一直是 WebP,所以登錄表看起來像是漏掉了,我便問說為了統一格式把它轉掉是不是個好主意。答案是不用,理由出乎意料:這個儲存庫從來沒有做過垃圾回收,git gc 回收了 8.6 MB,而我問的那個轉檔只值 0.8 MB。這篇文章會走過那些把格式問題變成封裝問題的量測、我為什麼決定不為了那 0.8 MB 去改寫歷史,以及一件真正遇到 blob 問題的讀者必須知道的事:git gc 做不到什麼。
我想要的一致性早就存在了
前提先垮了。Astro 的圖片管線不管來源檔是什麼,都會在建置時輸出 WebP 衍生圖,所以建置後的網站本來就以同樣的形式提供兩者。證據就放在 dist/ 裡,來自封面圖與來自內文圖表的衍生檔格式相同,而且都不是 PNG:
astro-generic.CZGwia_K_2sMF92.webp ← source is PNGone-component-two-locales.CwF4yp8J.webp ← source is WebP確實有一個 PNG 會送到讀者手上,而那是刻意的。Open Graph 卡片被固定成 PNG:
const ogAsset = await getImage({ src: cover.src, width: 1200, height: 630, format: 'png' });這一行同樣不在意來源格式,因為 sharp 解 WebP 完全沒問題。把來源轉掉,走在線路上的位元組一個都不會變。 不管轉檔值多少,對讀者來說的價值是零。
剩下的只有 git 的重量,而且很小
那就剩儲存庫本身了。把歷來存在過的封面圖 blob 全部加總,會得到一個小到足以結束多數爭論的數字:
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 早就裝好了,儲存庫也小。代價不在於執行本身,而在於執行會讓什麼失效,這取決於那些檔案最早出現在什麼時候:
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 上。
有一項檢查結果令人安心,但沒有改變結論。在已發布文章中搜尋指向這個儲存庫的連結,共找到 11 處,而每一處指的都是另一個示範用儲存庫,不是這裡的提交,所以不會有任何已發布內容壞掉。這件事的份量沒有聽起來那麼重,因為天平另一端的數字早就崩了。
這個儲存庫從來沒有被封裝過
在量測改寫最多能拿回多少的過程中,代理執行了 git count-objects,發現了另一個問題:
du -sh .git# 17Mgit count-objects -vH# count: 2081# size: 16.46 MiB# in-pack: 240# size-pack: 437.45 KiB2081 個鬆散物件,封裝檔裡只有 240 個。 這個儲存庫被持續提交了好幾個月,一次垃圾回收都沒做過,所以裡面幾乎每個物件都以個別 zlib 壓縮的檔案躺在磁碟上,沒有跟任何東西做差分壓縮。
為了在數字還只是猜測時不冒任何風險,先在一份用完即丟的鏡像複本上封裝,結果 17.0 MB 變成 7.8 MB。這是歷史改寫所能提供的 11 倍,而且這個指令什麼都不改寫。
真正值得記住的,是縮小的部分是怎麼分布的:
把整個歷史加總,圖片 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:
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'# 532git rev-list --all --count# 49一個 49 個提交的歷史,卻存在 532 個提交物件。 另外那 483 個是孤兒,最大的製造者就是這個部落格自己的發布流程。squash 合併不會合併分支上的提交,而是把它們的差異合起來,用一個新的雜湊寫成一個新提交。之後刪掉分支,原本那些提交就再也沒有東西指向它們了。
發布這篇文章相關工具的那次發布,就在調查途中示範了一次:不可到達的數量從 812 變成 826,新增的 14 個正是那條分支的三個提交,加上它們的 tree 和 blob。
git gc 實際做了什麼,又沒做什麼
在這個儲存庫上:
git gcdu -sh .git# 8.4Mgit count-objects -vH# count: 0# in-pack: 2331# size-pack: 8.12 MiB回收 8.6 MB,改寫的物件是零。 結果比測試複本回報的 7.8 MB 略高,差的就是那 812 個不可到達的物件,因為單純的 git gc 會保留而不是刪除它們。
值得把這個指令做的事講精確,因為它其實是三個動作,而只有其中一個會刪東西:
- 重新封裝。 所有可到達的物件收進同一個封裝檔,並與相鄰物件做差分壓縮。這個封裝檔最後裝了 1519 個物件,其中 10 個是在前面數鬆散物件之後才寫入的,而且什麼也沒刪。
- prune。 不可到達的物件在超過
gc.pruneExpire(預設2.weeks.ago)之後才會被刪除。那 812 個都比這新,所以都不符合條件。 - cruft 封裝檔。 撐過第 2 步的物件如果還維持鬆散,就會讓第 1 步白做,所以 git 把它們寫進第二個封裝檔,旁邊附上一個記錄每個物件年齡的
.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 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 個已提交的。這跟圖表管線本來就採用的豁免方式相同,於是兩支腳本現在對同一個問題給出同樣的答案。
唯一一個看似合理卻猜錯的地方是編碼器設定。代理最初的建議是全部使用無損,理由是這些都是純色卡片上的扁平標誌,有損編碼的振鈴會出現在字樣邊緣。把每張卡片都用兩種方式量過之後,這個理由只適用於登錄表的一半:
最後採用的不是任何一種固定設定。代理提議每張圖都用兩種方式編碼,保留比較小的那一份,腳本也就是這樣做的。它看起來像是省容量的小手段,實際上是一個內容分類器:無損輸出會膨脹的時機,正好就是圖片帶有讓有損編碼難以察覺的漸層或照片質感的時候,所以比較小的那份檔案,可靠地就是適合這張圖的編碼器。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%。
- 改寫的代價,由那個檔案最早出現的時間點決定。 這些封面圖在第一個提交裡,所以波及範圍是整個歷史,而不是最近的一小段。
- 兩種方式都編一次、留小的那份,勝過任何一種固定設定,因為檔案大小結果是一個好用的代理指標,能判斷一張圖有沒有質感。