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

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

- Source: https://oharu121.com/zh-tw/blog/git-rm-cached-working-tree-vs-history-filter-repo/
- Published: 2026-08-27T13:59:31+09:00
- Tags: Git

---
**重點摘要**

- `git rm --cached` 會把一個路徑從索引和下一次提交中移除。但每一個已經存在的提交，仍然指向那個 blob。
- 乾淨的 `git status` 和小小的工作目錄，並不能證明儲存庫的大小。能證明的是 `du -sh .git`。
- `.gitignore` 裡指名一個檔案的規則，並不會涵蓋與它同名詞幹的目錄。
- `git filter-repo --path X --invert-paths` 會把一個路徑從每一個提交中移除。所有提交 ID 都會改變：在第一次 push 之前，這不花任何成本；push 之後，成本就很高了。
- 備份用的 bundle，完整保留了你剛剛移除的那些位元組。確認改寫無誤後，記得刪除它。

## 引言

有人請我把一個專案的原始碼打包成 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`，只列出了其中一個：

```text title=".gitignore"
.venv/
.cognito.env
.bedrock_agentcore.yaml
__pycache__/
```

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

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

## git rm --cached，以及那個看起來像是成功的數字

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

```bash
git rm -r --cached .bedrock_agentcore
printf '.bedrock_agentcore/\n' >> .gitignore
git commit -m "Stop tracking the AgentCore build artifact directory"
```

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

```bash
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 是不是還能到達。

```bash
du -sh .git
#   26M  .git

git rev-list --objects --all | grep dependencies.zip
#   de67db72dca595f3e22bd20b3ac5c77449734dd7 .bedrock_agentcore/.../dependencies.zip
```

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

*Figure — WorkingTreeVsHistory: 索引是乾淨的。六個提交裡有五個，仍然指向那個 blob。*

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

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

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

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

```bash
git rev-list --objects --all | grep -iE "dependencies\.zip|bedrock_agentcore"
#   (no output)
```

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

*Figure — RepairWindow: 同一次改寫，成本卻因提交 ID 是否還屬於私人而完全不同。*

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

## 用 filter-repo 改寫

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

```bash
# 先備份：既然沒有 remote，這個目錄就是唯一的一份複本。
git bundle create ../project-backup.bundle --all

git filter-repo --path .bedrock_agentcore --invert-paths --force
```

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

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

```text
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` 那一行：

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

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

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

```bash
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 出去。**光是這一個事實**，就決定了修復是一道不到一秒的本機指令，還是一個需要協調的問題。

## 參考連結

- [git rm 文件，包含 --cached 對索引做了什麼](https://git-scm.com/docs/git-rm)
- [git filter-repo，git 專案用來取代 filter-branch 所推薦的工具](https://github.com/newren/git-filter-repo)
- [gitignore 的模式格式，說明規則如何比對路徑，以及結尾的斜線如何把比對限制在目錄上](https://git-scm.com/docs/gitignore#_pattern_format)
- [git bundle 文件，說明如何建立與驗證一個儲存庫的離線備份](https://git-scm.com/docs/git-bundle)
