
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,只列出了其中一個:
.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_agentcoreprintf '.bedrock_agentcore/\n' >> .gitignoregit 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 的全部,而且對任何一個只回報工作目錄狀態的指令來說,它完全看不見。
修復的窗口 — 什麼都還沒 push 出去
有兩個事實,讓我判斷這值得立刻修好,而不是將就著用。
第一個事實是,這個儲存庫完全沒有 remote。它是在本機建立的,從來沒有 push 過,所以不存在其他複本,也沒有任何東西在目錄之外參照過任何一個提交 ID。
第二個事實,值得確認而不是假設,因為同一份程式碼,也曾經以 feature branch 的形式提交到共用的儲存庫。如果這個產物也跟著跑到那邊去了,那就會是一場麻煩得多的對話:
git rev-list --objects --all | grep -iE "dependencies\.zip|bedrock_agentcore"# (no output)乾淨。這個分支總共只新增了 183 KB,因為它是靠複製原始檔案組出來的,而不是複製整個儲存庫。
這樣的組合,就是成本低廉的那一種情況。歷史改寫會改變它觸及的那個點之後的每一個提交 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 commitsNew 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 出去。光是這一個事實,就決定了修復是一道不到一秒的本機指令,還是一個需要協調的問題。
