从 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的历史在必要时,只要遵循流程透明的原则,并不需要将重写历史当作洪水猛兽。虽然尽量不要做这种改写,但在需要时,它也是维护仓库合规性的必要手段。
最后是应该理性看待签名,不应为了签名而去签名,密码学工具应该为开发提供价值,包括完整性确认,但在使用时,也应考虑实际情况和成本收益。