git gc 回收了 8.6 MB — 追查一個 0.8 MB 的問題時,發現 repo 從未封裝過

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

白色卡片上的紅色 Git 菱形標誌與深棕色字樣
本頁目錄

引言

這個部落格的封面圖是 PNG。內文圖表在轉檔腳本進來之後就一直是 WebP,所以登錄表看起來像是漏掉了,我便問說為了統一格式把它轉掉是不是個好主意。答案是不用,理由出乎意料:這個 repo 從來沒有做過垃圾回收,git gc 回收了 8.6 MB,而我問的那個轉檔只值 0.8 MB。這篇文章會走過那些把格式問題變成封裝問題的量測、我為什麼決定不為了那 0.8 MB 去改寫歷史,以及一件真正遇到 blob 問題的讀者必須知道的事: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 會送到讀者手上,而那是刻意的。Open Graph 卡片被固定成 PNG:

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

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

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

那就剩 repo 本身了。把歷來存在過的封面圖 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 大約能省下其中一半。

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

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

那次改寫會波及什麼

以操作來說並不難。git-filter-repo 早就裝好了,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 上。

兩個做法各自的代價與回收兩個做法的比較。用 git filter-repo 改寫歷史會重新計算全部 45 個提交的雜湊、重新指向全部 25 個標籤、孤立 34 個已合併 PR 的合併提交,並回收 0.8 MB,而且只有這個做法能移除那 1.6 MB 的 PNG blob。git gc 不動任何提交、標籤或 PR,回收 8.6 MB,但完全不會移除 PNG blob,24 個全部保留。兩個做法各自的代價與回收當時在這個 repo 上的量測值:45 個提交、25 個標籤、34 個已合併的 PRgit filter-repogit gc重新計算雜湊的提交45無重新指向的標籤25無被孤立的已合併 PR34無移除 PNG blob可以,1.6 MB不會,24 個全部保留回收容量0.8 MB8.6 MB
那 1.6 MB 的 PNG blob 是唯一只有改寫才能移除的項目。表裡其餘每一項,都是不要執行改寫的理由。

有一項檢查結果令人安心,但沒有改變結論。在已發布文章中搜尋指向這個 repo 的連結,共找到 11 處,而每一處指的都是另一個示範用 repo,不是這裡的提交,所以不會有任何已發布內容壞掉。這件事的份量沒有聽起來那麼重,因為天平另一端的數字早就崩了。

這個 repo 從來沒有被封裝過

在量測改寫最多能拿回多少的過程中,代理執行了 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 個。 這個 repo 被持續提交了好幾個月,一次垃圾回收都沒做過,所以裡面幾乎每個物件都以個別 zlib 壓縮的檔案躺在磁碟上,沒有跟任何東西做差分壓縮。

為了在數字還只是猜測時不冒任何風險,先在一份用完即丟的鏡像複本上封裝,結果 17.0 MB 變成 7.8 MB。這是歷史改寫所能提供的 11 倍,而且這個指令什麼都不改寫。

真正值得記住的,是縮小的部分是怎麼分布的:

同一個 repo,封裝前與封裝後依實際比例繪製的兩條橫向長條圖。垃圾回收前的 repo 為 17.0 MB,其中圖片 blob 佔 6.7 MB,其餘為 10.3 MB,圖片佔 39%。垃圾回收後為 8.4 MB,圖片 blob 仍是 6.7 MB,其餘只剩 1.7 MB,圖片佔 80%。圖片部分完全沒變,縮小的只有其餘部分。同一個 repo,封裝前與封裝後圖片 blob 為整個歷史的加總大小,封裝後幾乎不變gc 前17.0 MBgc 後8.4 MB圖片 blob其餘全部
這兩條長條之間沒有任何新增或刪除。非圖片的那一半被壓縮了,圖片那一半壓不動,因為它進來的時候就已經是壓縮過的。

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

這個重新框架就是決定的關鍵。我決定不改寫歷史:拿全部提交的 SHA、25 個標籤,以及 34 個已合併 PR 上的提交連結去換 0.8 MB,在一個從來沒人抱怨過複製速度、只有一位作者的私有 repo 上並不划算。執行 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 也不會封裝本機的 repo。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 合併不會合併分支上的提交,而是把它們的差異合起來,用一個新的雜湊寫成一個新提交。之後刪掉分支,原本那些提交就再也沒有東西指向它們了。

一次 squash 合併留下了什麼兩列圖。合併前,main 上有一串提交,並接著一條由三個提交組成的功能分支,彼此相連。合併後,main 多了一個帶著整份差異、雜湊全新的提交,而功能分支那三個提交被畫成分離狀態,沒有任何東西指向它們,因為 squash 合併只是重放差異而非合併提交,之後分支又被刪除。一次 squash 合併留下了什麼每次發布一次,累積成那 483 個孤立提交合併前main功能分支,之後被刪除合併後main一個新提交,新的雜湊已經沒有東西指向這裡
功能分支那三個提交仍以物件的形式存在。在 squash 與刪除分支之後,沒有任何東西參照它們。

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

git gc 實際做了什麼,又沒做什麼

在這個 repo 上:

終端機視窗
輸入
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 檔,這正是讓寬限期能跨過重新封裝而繼續有效的機制。

最後這一項在磁碟上看得到,裡面的物件數也完全吻合:

終端機視窗
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
兩個獨立的軸,四種不同結果一個二乘二的表格。欄為可到達與不可到達,列為鬆散與已封裝。鬆散且可到達的物件會被收進主要封裝檔,為 2081 個鬆散物件中的 1269 個,這就是那 8.6 MB 的來源。鬆散且不可到達的物件會被掃進 cruft 封裝檔,共 812 個,保留兩週。已封裝且可到達的物件重新差分壓縮後保留,原本就有 240 個。已封裝且不可到達的物件滿兩週後刪除,目前還沒有到期的。那 24 個 PNG blob 位於可到達這一欄,因此垃圾回收永遠不會移除它們。兩個獨立的軸,四種不同結果git gc 的行為由兩者共同決定,不是其中之一可到達有參照或 reflog 指向它不可到達沒有任何東西指向它鬆散每個物件一個 zlib 檔已封裝位於封裝檔內收進主要封裝檔2081 個鬆散物件中的 1269 個,這就是那 8.6 MB掃進 cruft 封裝檔812 個,保留兩週重新差分壓縮並保留原本就有 240 個滿兩週後刪除目前還沒有到期的那 24 個 PNG blob 位於可到達這一欄,所以無論執行幾次 gc 都不會被移除。
只有右下那一格會刪東西,而它是空的。回收到的全部來自左上那一格。

回報這一切的工具是 git fsck,它真正的工作是完整性而不是清理:它會重新計算每個物件的雜湊,並確認被參照的東西都確實存在。它是唯讀的。--unreachable 的清單只是副產品,而且它預設把 reflog 也當成根,這正是 git reset --hard 還救得回來的原因。在這個 repo 上,這個預設藏起了 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 個已提交的。這跟圖表管線本來就採用的豁免方式相同,於是兩支腳本現在對同一個問題給出同樣的答案。

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

單一設定無法同時照顧登錄表的兩半四張封面圖各以 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。單一設定無法同時照顧登錄表的兩半每張卡片都是 1200 × 630,長條為同樣的兩種編碼器純色卡片上的扁平標誌git-generic5 KB 無損12 KB q90vercel-generic4 KB 無損7 KB q90帶漸層或照片質感expressive-code-generic90 KB 無損15 KB q90llm-generic197 KB 無損20 KB q90較小者
同樣兩種編碼器,同一張圖。扁平標誌與帶質感的卡片,對於哪一種比較小的答案差了一個數量級。

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

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

總結

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

參考連結

分享這篇文章