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

git rm --cached 在 .git 留下 26 MB — 工作目錄乾淨,歷史卻不乾淨

git rm --cached 移除的是索引裡的路徑,不是過去的提交。說明如何量測兩者的差異,以及如何用 filter-repo 移除已提交的建置產物。

本頁目錄

引言

有人請我把一個專案的原始碼打包成 tgz 交出去,排除 .venv 之類的東西。這原本應該是兩分鐘就能搞定的請求,但實際打包的時候,問題浮現了出來:這個儲存庫有 26 MB,而其中幾乎全部,都是第一次提交時就被誰也沒注意到地加進去的建置產物。

代理人用 git rm --cached 移除了它,把那個目錄加進 .gitignore,並回報儲存庫已經從 26 MB 縮小到 292 KB。這個說法是錯的,而這正是這類錯誤得以存活下來的常見方式:受追蹤的檔案確實縮小到了 292 KB,但沒有人去看 .git 本身,它依然是 26 MB。這個 blob 在六個提交裡有五個仍然可以到達,只要放著不管,它就會永遠跟著這個儲存庫一起被搬運下去。

真正的修法是 git filter-repo,花不到一秒鐘。這篇文章要說明的是:這個產物是怎麼混進來的、看起來理所當然的移除指令為什麼沒有真的移除它、如何靠量測分辨這兩者的差異,以及為什麼能便宜修好的這扇窗,會在第一次 push 時關上。

26 MB 是怎麼混進來的 — .gitignore 只比對得到檔案,比對不到目錄

這個專案的部署工具,會在儲存庫根目錄寫入兩樣東西:一個設定檔 .bedrock_agentcore.yaml,還有一個建置目錄 .bedrock_agentcore/,裡面裝著一份 vendored 依賴套件的 zip。

代理人寫的 .gitignore,只列出了其中一個:

.gitignore
.venv/
.cognito.env
.bedrock_agentcore.yaml
__pycache__/

gitignore 的規則是拿路徑去比對,不是拿名稱詞幹去比對。 .bedrock_agentcore.yaml 比對得到名字完全一樣的那個檔案,但對 .bedrock_agentcore/ 完全沒有作用,因為這是不同的路徑,所以那個目錄和裡面 26 MB 的 zip,從來沒有被忽略過,並在第一次 git add -A 時就一起進了儲存庫。

沒有任何東西提出異議。 提交順利完成,測試也通過,接下來好幾個小時,儲存庫都運作得完美無缺。

git rm –cached,以及那個看起來像是成功的數字

在打包封存檔的時候,代理人找到了這個產物,並用最直覺的方式把它移除:

終端機視窗
git rm -r --cached .bedrock_agentcore
printf '.bedrock_agentcore/\n' >> .gitignore
git commit -m "Stop tracking the AgentCore build artifact directory"

接著量測,並把結果當成修好的證據回報:

終端機視窗
git ls-files -z | xargs -0 du -ch | tail -1
# 292K total

這份封存檔本身是用 git archive 建出來的,它走的是樹狀結構,而不是物件儲存區,所以量出來只有 83 KB,而且確實是乾淨的。所有看得到的東西,都在告訴你問題已經解決了。

這次量測,量錯了東西。 git ls-files 列出來的,是現在受追蹤的內容。它對之前的提交裡有什麼,沒有任何意見;git status 沒有,你的工作目錄大小也沒有。

實際上還留著什麼

git rm --cached 只做一件事:把一個路徑從索引移除,讓下一次提交不再包含它。 這不是一個操作歷史的指令,它也從沒宣稱過自己是。提交是不可變的,所以原本就參照那個 blob 的五個提交,之後仍然參照著它,而任何一個提交能到達的物件,就是 git 會保留下來的物件。

兩道指令就能把這件事講清楚:一道量測物件儲存區的大小,另一道問那個 blob 是不是還能到達。

終端機視窗
du -sh .git
# 26M .git
git rev-list --objects --all | grep dependencies.zip
# de67db72dca595f3e22bd20b3ac5c77449734dd7 .bedrock_agentcore/.../dependencies.zip

換句話說,這個儲存庫裡裝著 292 KB 的檔案,而整個儲存庫卻有 26 MB。這兩個數字之間的落差,就是這個 bug 的全部,而且對任何一個只回報工作目錄狀態的指令來說,它完全看不見。

git rm --cached 動到的是索引,不是過去的提交圖中有兩條帶狀區域。上方是移除後的索引與工作目錄:23 個檔案、292 KB、git status 乾淨。下方是物件儲存區,六個提交中有五個仍指向同一個 26 MB 的 blob,只有執行移除的第六個提交沒有參照它。git rm --cached 改變的部分索引與工作目錄23 個檔案292 KBgit status 乾淨沒有被改變的部分物件儲存區123456在此從索引移除dependencies.zip · 26 MB六個提交中有五個仍指向這裡
索引是乾淨的。六個提交裡有五個,仍然指向那個 blob。

修復的窗口 — 什麼都還沒 push 出去

有兩個事實,讓我判斷這值得立刻修好,而不是將就著用。

第一個事實是,這個儲存庫完全沒有 remote。它是在本機建立的,從來沒有 push 過,所以不存在其他複本,也沒有任何東西在目錄之外參照過任何一個提交 ID。

第二個事實,值得確認而不是假設,因為同一份程式碼,也曾經以 feature branch 的形式提交到共用的儲存庫。如果這個產物也跟著跑到那邊去了,那就會是一場麻煩得多的對話:

終端機視窗
git rev-list --objects --all | grep -iE "dependencies\.zip|bedrock_agentcore"
# (no output)

乾淨。這個分支總共只新增了 183 KB,因為它是靠複製原始檔案組出來的,而不是複製整個儲存庫。

改寫歷史的成本,以第一次 push 為分界一條標示為「第一次 push」的垂直分隔線把畫面分成兩塊。左邊是僅限本機的情況:沒有其他複本、沒有人參照這些提交 ID,改寫只需一道指令、不到一秒,成本只是一份之後就刪掉的備份。右邊是 push 之後:必須 force-push、每個複本都要重新同步、進行中的 PR 會失效,成本落在其他人身上。第一次 push僅限本機沒有其他複本存在沒有人參照這些提交 ID一道指令,不到一秒成本:一份之後就刪掉的備份push 之後必須 force-push每個複本都要重新同步進行中的 PR 會失效成本:其他人的時間
同一次改寫,成本卻因提交 ID 是否還屬於私人而完全不同。

這樣的組合,就是成本低廉的那一種情況。歷史改寫會改變它觸及的那個點之後的每一個提交 ID,如果沒有任何東西參照那些 ID,成本就是零;但如果有同事已經把它們 checkout 出來、有 pull request 正對著它們開著,或是 CI 已經記錄下了它們,成本就會變高。我以前也基於完全一樣的理由,放棄過一次改寫:當時能回收的重量只有 0.8 MB,而那些提交 ID 早已被 25 個標籤和 34 個已合併的 pull request 咬住。這一次是 26 MB,而且什麼都還沒 push,同一個問題,答案完全相反。

用 filter-repo 改寫

git filter-repo 取代了 filter-branch,也是 git 官方文件現在指向的工具。這次的操作,是把一個路徑從所有提交裡移除:

終端機視窗
# 先備份:既然沒有 remote,這個目錄就是唯一的一份複本。
git bundle create ../project-backup.bundle --all
git filter-repo --path .bedrock_agentcore --invert-paths --force

--path 用來指定路徑,--invert-paths 則把這個選取反過來,保留指定內容以外的一切。之所以需要 --force,是因為 filter-repo 原本預期你是在一份全新的複本上執行,而這裡卻是直接對正在使用中的儲存庫下手。

執行得很快,而且會清楚告訴你它做了什麼:

Parsed 6 commits
New history written in 0.07 seconds; now repacking/cleaning...
Completely finished after 0.20 seconds.

什麼變了,什麼沒變

之前 之後
.git 26 MB 236 KB
受追蹤檔案數 23 23
受追蹤大小 292 KB 292 KB
提交數 6 6
dependencies.zip 可到達

每一個提交都完整留了下來,訊息也原封不動,工作目錄同樣沒有被動到,這一點透過實際匯入這個套件做了抽查確認。真正變了的,正如當初說的,就是每一個提交的 ID。

有一個外觀上的變化,值得先預期到。那個原本唯一目的就是移除這個產物的提交,現在什麼都不會移除了,因為在改寫後的歷史裡,這個產物在任何一個時間點都已經不存在。留在裡面的,只剩下 .gitignore 那一行:

6d88cf7 Stop tracking the AgentCore build artifact directory
.gitignore | 7 ++++---

訊息本身依然準確描述了當初的用意,所以就保留了下來。要修正它,等於是在沒有任何功能收益的情況下,再做一次改寫。

最後還有一個陷阱,而且是個蠻好笑的陷阱。備份用的 bundle 有 25 MB,因為它忠實地保留了包含那個 blob 的完整歷史。把它留在儲存庫旁邊,等於是把你原本想移除的那些位元組,原封不動地留了下來。確認改寫無誤後,再刪除它:

終端機視窗
git bundle verify ../project-backup.bundle
# The bundle records a complete history.

總結

git rm --cached 不是一個操作歷史的指令,它也從沒假裝自己是。它把一個路徑從索引移除,讓之後的提交不再包含它,對它原本設計要處理的情況來說,這正是正確的做法,但對一個已經被提交過的檔案,這完全不夠。

這個錯誤之所以能一直存在,是因為所有方便量測的東西,事後都會跟你站在同一邊:git status 是乾淨的,工作目錄很小,git archive 產出一份乾淨的 tarball。唯一唱反調的數字是 du -sh .git,也只有它,說出了 clone 這個儲存庫時實際會下載多少東西。

如果有一個大檔案已經被提交進去了,在做任何事之前,先確認有沒有東西已經被 push 出去。光是這一個事實,就決定了修復是一道不到一秒的本機指令,還是一個需要協調的問題。

參考連結

分享這篇文章