从 FreeBSD Ports 代码仓库冻结事件谈起:Git 历史重写,以及如何从此类状况中恢复
最近,FreeBSD 的 ports 代码库由于一次技术事故进行了一次历史重写。
我本人没有直接参与这次的处理,但是关于 Git 的历史重写我有许多
背景
2026 年 7 月 20 日,FreeBSD Ports 源码树中提交了一个涉及 misc/github-copilot-cli
的变动(Commit bb14266f00bb,提交说明是例行的 update 1.0.71 → 1.0.72),
其中,有人不慎将一个约 150MB 的编译二进制文件直接推入了 Git 仓库。
这导致了一系列技术与合规问题:
- 许可证合规风险:该二进制文件没有明确的再分发许可,因此不宜保留在版本控制历史中。
- 托管镜像中断:GitHub 限制单个文件大小不得超过 100MB,导致 FreeBSD 官方向 GitHub 的自动化镜像推送被拒绝并中断。
简单地回退 (revert) 这个变动并不足以修正问题:几个小时后就有了删除该文件的 Commit(f208eb093c),
但 Git 的历史是只增不改的,旧的 commit 依然指向那个 blob 对象,
克隆仓库时依然需要下载这些数据,许可证合规风险也没有解除。
鉴于上述原因,经 FreeBSD 核心团队(core@)、Git 管理团队(gitadm@)、集群管理团队(clusteradm@)及 Ports 管理团队(portmgr@)讨论,core@ 于 7 月 21 日 冻结了 Ports 仓库的推送, 通过重写 Git 历史彻底移除涉事文件,并在几天后 公布了重写的结果和操作指引。
仓库管理员在这种情况下应该做什么
完整的版本历史,加上透明可复现的改写过程,是防篡改、可审计、可追溯供应链风险的信任基础。 通常情况下,改写历史是在没有其他更好替代方案时的最后手段,因此在实施时需要极其慎重。
历史重写过程通常希望是公开的,因为这样第三方可以很容易地审计其正确性。 FreeBSD 这次也明确把「结果必须能被第三方复现」列为了前提条件之一。
在常规开发中,git rebase -i 是删除特定提交的常用方法,但对于已经推送到公共仓库的历史并不适用,
原因是这样做本质上是在某一特定版本之上逐个 cherry-pick,这个过程中,
虽然 author 信息(包括 author date)会被保留,但 committer 的身份和 committer date
都会变成操作者当时的设置,因而每个人跑出来的结果都不一样,
这意味着这个过程很难被第三方独立重现,从而增加社区与下游的审计成本。
为了保留原始的作者、提交者及时间戳信息,FreeBSD 采用的方案是用 git fast-export 把待重放的历史导出成文本流,
用一段 sed 脚本把两个涉事 commit 从流中滤掉,再用 git fast-import 导回去,
具体使用的脚本发布在了 ports-reset.sh
这里,脚本本身的 SHA256 则写进了 PGP 签名的公告里。
这个做法的好处是没有引入 git-filter-repo 之类的额外依赖,
整个过滤逻辑短到可以直接肉眼审计。
我个人在这种情况下,则几乎一定会使用官方已经标记为「过时」的 filter-branch。
这个工具是是 git 内置的,而且用起来相当简单。需要说明的是,filter-branch 之所以被劝退,
主要问题是在全量重写超大仓库时,如果使用不当的话很可能会很慢,但这个弱点通常可以绕开,
而且,本次需要重放的范围只有一百多个 commit,即使真有问题也不算显著。
还有就是,官方采取的策略是直接移除了相关的两个 commit,
考虑到实际的情况,如果是我来做的话,我会改写第一个 commit,令其中错误引入的文件消失。
按照这种设定,使用 filter-branch 只需要一行(下面用 misc/github-copilot-cli/copilot
指代那个被误提交的文件,实际路径请以仓库中的实际情况为准):
git filter-branch --force --index-filter \
'git rm --cached --ignore-unmatch misc/github-copilot-cli/copilot' \
--prune-empty -- a70c5c3fd44b49eb692a4703fe91a62b37c58ce7..HEAD上面命令行的含义是,遍历从 a70c5c3fd44b49eb692a4703fe91a62b37c58ce7 开始到当前分支的全部 commit,
对每一个 commit 的 git index 直接作外科手术式的改动,删掉名为 misc/github-copilot-cli/copilot
的文件。如果此文件不存在,则忽略错误;如果我们改完之后发现 git index 没有变化,则跳过当前 commit
(具体来说,指的是后续删除违规文件的那次操作,因为我们已经把文件在改写过程中砍掉了)。
跑完之后,改写后的 bb14266f00bb 会保留下来,
f208eb093c 因为变空而被 --prune-empty 丢掉,那 150MB 的 blob 则从所有 commit 的树里彻底消失。
假如要实现与官方类似的效果——把那两个 commit 整个抹掉,就当它们从来没有发生过——
那么光删掉那个二进制文件是不够的,还需要把整个 misc/github-copilot-cli
目录恢复成 a70c5c3fd44b49eb692a4703fe91a62b37c58ce7 时的样子,
也就是连同 Makefile、distinfo 中 1.0.71 → 1.0.72 的版本号改动一起回退:
| |
(--index-filter 跑在一个没有展开工作树的环境里,所以这里用 git read-tree --prefix=
而不是 git checkout;read-tree 带 --prefix 时会保留索引中的其余内容,只覆盖指定的子目录。)
有意思的是,这样一来 --commit-filter 就完全用不上了:涉事的两个 commit
所改动的内容全部落在这个目录里,回退之后它们的树与各自父节点完全一致,
--prune-empty 会自动把它们双双丢掉。需要注意的副作用是,这个 filter 会把范围内
所有 commit 中该目录的内容都钉死在旧版本上,因此使用前必须确认这段区间内没有对该 port
的其他正当改动(本例中确实没有,这个 port 的下一次变更发生在重写完成之后)。
顺带说一句,filter-branch 还提供了 --commit-filter 配合 skip_commit 直接摘掉 commit 节点的办法,
看上去更「对症」,但拿它来对付本例是个陷阱。
首先,它和 --prune-empty 是互斥的,git 会直接报错拒绝执行:
Cannot set --prune-empty and --commit-filter at the same time其次,即便单独使用它也解决不了问题:filter-branch 按「树」而不是按「差异」工作,
skip_commit 只是把节点从链条上取下来,并不撤销它带来的内容变化,它的子 commit
依然各自带着自己那棵完整的树。bb14266f00bb 和 f208eb093c 之间还夹着 6 个 commit,
只用 --commit-filter 的话,历史里那两个 commit 确实消失了,
可 150MB 的二进制文件会原封不动地留在这 6 个 commit 的树里——
看起来干净了,实际上一点用都没有,这大概是这类操作中最容易骗过自己的一种失败方式。
官方的 git fast-export 方案则天然没有这个烦恼:fast-export 输出的是增量的 M / D 指令流,
把整个 commit 块从流里删掉,它带来的所有改动自然也就跟着消失了,不需要额外考虑中间那几个 commit。
需要说明的是,由于问题发现得较晚,a70c5c3fd44b 之后已经有 118 个 commit 进入,
去掉涉事的那两个之后,实际被重写(因而 SHA1 发生变化)的是 116 个 commit,
因此 FreeBSD 官方还增加了 git notes 来标注改写前和改写后的 SHA1 版本的对应关系。
对于季度分支(2026Q3),FreeBSD 官方团队采取的策略是不动(安全分支的改写门槛更高;
另外有 5 个在改写点之后的 commit 被 cherry-pick 过去,旧的 SHA1 出现在了 commit message 的
cherry picked from commit 标记中,改写的话影响面较大),只在这些 commit 上同样附加 notes。
我认为这种顾虑有一定道理,但并非绝对无法破解。实际上,filter-branch 提供了改写 commit message 的方法
(--msg-filter),考虑到只有 5 个涉事 commit 受到影响,而我们在生成 notes 时已经知道了前后 SHA1 的对应关系,
完全可以直接将季度分支也一并改写。这样的好处是不需要 notes 的「拐杖」,对于没有下载 notes 的未来查阅历史的人来说,
这样一份干净的历史(我们反正已经在修改历史了)要容易理解得多。
此外,我个人认为 git cherry-pick 时使用原始的 SHA1 是一种给自己挖坑的做法,因为 git 的 SHA1 一旦修改历史就会变化。
在 Gerrit 的用法中,使用了一个 commit hook 在提交时自动生成一个 Change-Id,
它只在 commit message 中不存在时才会被写入,因此无论一个变动如何 amend、如何 rebase,
其 Change-Id 都保持不变。这种做法避开了使用 SHA1 的这种尴尬。
上游 force-push 之后,开发者需要做什么
出于显而易见的原因,正常情况下 git 要求 push 时必须是可以 fast-forward 的,
非 fast-forward 的推送会被拒绝,除非显式使用 --force(且服务端允许)。
对于不同的本地情况,开发者可以采用不同的应对策略。
本地无变动
如果本地分支没有未推送到上游的变更,直接重置到上游最新状态:
git fetch origin
git reset --hard origin/main本地做了一些还没推送到上游的 commit
如果基于旧历史拉出了本地分支并提交了代码,直接运行 git rebase origin/main 是不行的:
由于改写后的历史与本地历史的共同祖先被推到了很远的地方(在本例中是 118 个 commit 之前),
rebase 会试图把这 118 个 commit 连同你自己的改动一起重放到新的 main 上。
虽然 git 会依据 patch-id 自动跳过内容相同的部分,但恰恰是被官方删掉的那两个 commit 不会被跳过,
于是那个 150MB 的二进制文件又回来了,顺带还可能引发一系列合并冲突。
作为一个爱问「所以,你的备份呢」的讨厌鬼,我个人会在做所有操作之前先 tag 一下我此前用的 HEAD, 不过,现代的 git 有 reflog,通常不 tag 也问题不大。
正确的做法是用 git rebase --onto 显式指定旧的基点,把本地的改动移植到新的起点上
(在待处理的本地分支上执行):
git rebase --onto f9c90a4f96f0700 0a717e9c118aa6其中,0a717e9c118aa6 是此前的 main,而 f9c90a4f96f0700 则是改写后的最新 tip-of-tree。
这样 rebase 只会重放 0a717e9c118aa6 之后属于你自己的那些 commit。通常情况下,这个过程无需人工干预。
万一搞砸了怎么恢复
git rebase 搞砸了?不要紧,git 会把近期 HEAD 的移动记录到一个叫 reflog 的地方,
使用 git reflog 可以查看。
| |
需要说明的是,reflog 假定你知道自己在干啥。它并不是永久保险:可达对象的 reflog 记录默认保留 90 天,
不可达的只有 30 天,过期之后一次 git gc 就会把对应的对象清掉;
如果手贱跑了 git gc --prune=now(或者 git reflog expire --expire=now --all),
那就真的救不回来了。所以该做的备份还是要做。
延伸:关于 signed commit
在这 116 个被重写的 Commit 中,有若干个是经过 GPG 签名的。
由于签名是对 Git 对象本身做的,而改写历史必然会改变这些对象,因此这些签名全部失效了。
FreeBSD 的脚本干脆直接把它们丢掉(git fast-export 默认不会带上 gpgsig,脚本另外还加了
--signed-tags=strip),并且在注释里写明了理由:只有这样,重写的结果才是可复现的——
毕竟没人能替别人重新签一遍名。
Git 与区块链在数据结构上有相似之处(当然,Git 出现得更早):它本质上是一个 Merkle DAG, 每一个 Commit 的 hash 都覆盖了:
- 父节点 (Parent) 的 Hash
- 当前 Tree 对象的 hash
- 作者/提交者信息与时间戳等元数据
- 提交说明本身
这意味着:你对当前 Commit 的签名,本质上是对该节点及其所有祖先历史做了一次整条线的完整性背书。
我认为 signed commit 是不必要的。
为什么呢?因为 git 的设计中,类似历史改写这种伤筋动骨的操作,一定会导致 hash 的变化。 这意味着它们一定会被注意到,而另一方面,在有合理理由需要进行改写时,这些签名一定会消失。
实践上,据我观察,几乎没有人会真的去逐个检查 commit 的签名,这不经济,而且作为分布式版本控制系统, 真正的「最新版本」依赖的是人们的历史共识,Git 本身的 Merkle 结构已经提供了足够的防篡改能力 (严格地说,它提供的是「可发现篡改」,前提是你手里已经有一个可信的参照 hash——这一点后面还会说到)。
理想状态下,签名代表「活人仔细审查过改动」。但在实际开发中,开发者往往只是给本地的自动签名脚本设置了 GPG Key 密码。绝大多数人在 rebase 或签名时,根本不会逐个检查历史链条的安全性。 这类虚假的安全幻觉,要远比没做签名的危害更大。
作为折中,我认为用来做正式发布的 tag 是完全有必要做签名的。因为这些 tag 是对最终静态状态的绝对终审, 它同时也提供了上面所说的那个「可信的参照 hash」, 这种做法,既保留了开源协作中偶尔重写历史时的自由度(虽然绝大多数情况下都不应该做), 又在关键交付节点提供了更高的验证保证。
经验总结
我认为本次最大的教训,其实是我们应该采取一些预防措施来阻止发生这种(引入无授权二进制文件的)情况。 在服务器和客户端部署 hook,在 push / commit 时按文件尺寸和类型拦截,就不会出现这类问题—— FreeBSD 在公告中也提到会补上服务端的尺寸限制 hook。
另一方面,Git 的历史在必要时,只要遵循流程透明的原则,并不需要将重写历史当作洪水猛兽。 虽然尽量不要做这种改写,但在需要时,它也是维护仓库合规性的必要手段。
最后是应该理性看待签名,不应为了签名而去签名,密码学工具应该为开发提供价值,包括完整性确认, 但在使用时,也应考虑实际情况和成本收益。