从历史中移除文件
git rm 只能让一个文件不再出现在新的提交里。它对已经做出的提交毫无作用:那个 blob 仍然在对象数据库里,仍然在每一份克隆里,仍然只隔着一条 git show。
这件事在两种场合要紧——当那个文件是一份凭据的时候,以及当它有 400 MB 的时候。
⌘K → 从历史中移除文件,或者右键点击那个文件——在项目树里、在某个提交的文件列表里,或者在提交编辑器里。人们通常正是在那个删除了文件的提交上意识到它还留在历史里,所以出路也放在了那个菜单里。
找到路径
有两个入口,因为它们回答的是不同的问题。
直接输入——相对于仓库根目录,不带前导斜杠——适合你早就知道自己是来删什么的。
浏览历史适合你不知道的时候。它会列出曾经被提交过的每一个路径,按体积从大到小排列,附上每个路径有多少个版本、以及它是否仍被跟踪。被删除的路径会被标出来,而它们通常正是你要找的:一个已经从工作区消失、却仍然存在于每一份克隆里的文件,恰恰是普通文件对话框没法展示给你的情况,因为那个文件根本不在那儿可供挑选。
同一份列表也回答了人们来这里的另一个理由——为什么这份克隆有两个 GB——因为它是按每个路径的 blob 实际占用的字节数排序的。点中一行就会立刻测量它。

先测量,再点头
按测量(或者点中一行)。此时什么都还没有写入。你会得到:
| 将被重写的提交数 | 从第一个包含该文件的提交起,往后的每一个提交 |
| 分支/标签 | 将会移动的引用 |
| 其 blob 占用的空间 | 那些版本实际占用的字节数 |
| 第一个提交 | 重写从哪里开始——它之后的一切都会得到新的哈希 |

如果数量是零,那就是路径写错了。这通常是拼写或者目录前缀的问题,而不是「它不存在」。
这次重写实际做了什么
Gitcito 会先把每一个分支和标签复制到 refs/gitcito/pre-purge/<timestamp>/…,然后运行:
git filter-branch --force \
--index-filter 'git rm --cached --ignore-unmatch -- <path>' \
--prune-empty --tag-name-filter cat -- --branches --tags
--index-filter 直接重写索引,而不是把每个提交都检出一遍,这就是几分钟和几小时的区别。用 --branches --tags 而不是 --all 是刻意的:--all 会把备份引用也算进去,那样这次重写就会把自己的安全网也吞掉。
除了被移除的文件之外什么都不包含的提交会被丢弃(--prune-empty)。标签会被重新指向它们被重写后的提交。
备份,以及为什么空间还没有回来
清除是可以撤销的,而它的代价就是在你开口之前,磁盘空间不会被回收。只要备份还在,那些旧提交就仍然可达,所以 git 不会回收它们。
| 操作 | 效果 |
|---|---|
| 恢复 | 每一个分支和标签都回到清除前的提交;那个文件也跟着回来 |
| 丢弃备份 | 删除备份引用,让 reflog 过期,运行 git gc --prune=now——空间归还,清除自此永久生效 |
分成两步而不是一步,因为第一步是可恢复的那一半,第二步不是。
无论如何都要轮换密钥
如果一份凭据曾经被推送出去,重写你的历史并不能把它「未泄露」回来。 可能已经有人获取过它;代码托管服务器会把无引用的对象保留一阵子;某条 CI 日志可能已经把它打印出来了。重写只能阻止它继续扩散——它撤销不了这次暴露。
去轮换那把密钥。然后再清除,好让下一个克隆仓库的人找不到它。
它不会做的事
- 它不会推送。 重写是本地的。发布结果意味着对每一个受影响的分支做一次强制推送,而其他所有人都必须重新克隆或者硬重置——那个决定属于强制推送守卫。
- 工作区不干净时它会拒绝,处于合并/变基中途时也一样。重写会反复移动 HEAD,围着未提交的工作做这件事,正是它被弄丢的方式。
- 它按路径重写,不按内容。 如果一份密钥是被粘贴进某个源文件里的,而不是自己独占一个文件,那就需要内容过滤器——那是
git filter-repo --replace-text的地盘,Gitcito 没有为它做封装。 filter-branch在极大的历史上很慢。 但它是各处的 git 都自带的东西,这也是 Gitcito 用它的原因。在一个有几万个提交的仓库上,在终端里用git filter-repo才是更快的工具。- 别人的克隆不是你的仓库。 在他们重新克隆之前,他们手里保留的仍是旧历史。