標籤
白色卡片上的紅色 Git 菱形標誌與深棕色字樣

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:

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 的重量,而且很小

那就剩儲存庫本身了。把歷來存在過的封面圖 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 上。

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

有一項檢查結果令人安心,但沒有改變結論。在已發布文章中搜尋指向這個儲存庫的連結,共找到 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 倍,而且這個指令什麼都不改寫。

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

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

把整個歷史加總,圖片 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'
# 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 實際做了什麼,又沒做什麼

在這個儲存庫上:

終端機視窗
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 還救得回來的原因。在這個儲存庫上,這個預設藏起了 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 gcgit 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,但寫入端仍然依據轉換旗標挑選編碼器,而這個旗標只有在遇到未追蹤的 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%。
  • 改寫的代價,由那個檔案最早出現的時間點決定。 這些封面圖在第一個提交裡,所以波及範圍是整個歷史,而不是最近的一小段。
  • 兩種方式都編一次、留小的那份,勝過任何一種固定設定,因為檔案大小結果是一個好用的代理指標,能判斷一張圖有沒有質感。

參考連結

分享這篇文章