那天处理一批本地版本库的历史修剪时,我原本以为这只是一个“保留最近 10 条提交”的清理动作。结果几个仓库先后停在了不同的中间状态:有的卡在 rebase 过程中,有的停在临时分支上,工作区里的文件还回到了旧快照。真正让我警觉的,不是历史没有压短,而是一个本来只应该改变提交记录的动作,越过边界改动了正在工作的文件。
这类问题很容易被低估。提交历史看起来像过去的记录,工作区却是现在还没有保存进历史的现实。只要修剪动作把两者混在一起,任何一个中途失败,都可能让未提交改动、暂存改动,甚至只是刚放进目录的未跟踪文件,变得难以判断。如果不处理,后面会导致人先花时间找回工作区,再花时间确认历史到底有没有被改坏,原本的维护任务就变成了恢复现场。
一次看似普通的历史清理
旧流程的思路并不陌生:先创建一个临时分支,再通过 rebase 把提交重新排列,最后让原分支指向压缩后的结果。顺利时,它确实能把最近的提交整理成更短的历史。问题出在它把“重写历史”和“切换当前文件”绑在了一起。
当历史里出现合并提交,rebase 可能无法自动完成,过程会在中途停下。此时 Git 不只是留下一个待处理的历史操作,工作区也已经随着切换和应用提交发生变化。脚本如果只看到命令失败就直接退出,仓库就可能停在 rebase 半路,或者停在那个本来只用于过渡的分支上。人再打开目录时,看到的是一份被换过的文件快照,而不是开始修剪前的现场。
我一开始也想过,是否只要在开始前做一次 stash,失败后再恢复就够了。后来我判断这不是最稳的边界,因为 stash 本身又引入了搬运和恢复过程,暂存区、未跟踪文件以及并行工作的状态都需要额外处理。历史修剪最先要保护的不是“命令能否跑完”,而是修剪前工作区的状态不被动作接管。
表面是 rebase,实际是边界失守
这件事表面上是 rebase 遇到了合并提交,实际上是脚本没有把对象数据库、引用、暂存区和工作区区分开。Git 的提交对象本质上是指向父提交和文件树的记录,分支引用则只是指向某个提交的位置。既然目标只是把一段旧历史压缩掉,就没有必要为了生成新提交而真的把当前分支切走,也没有必要把新历史逐个应用到当前目录。
真正需要重新生成的是对象层的候选历史。可以把要保留的最近 10 条提交读出来,先根据最早的一份文件树建立一个新的起点,再按顺序生成后续提交。合并提交在这个过程中会被拉直,保留合并后的文件快照,但不再保留另一条分支来源。这个变化必须被明确看见:它是历史形状的改变,不是文件内容的改变。
我选择这条路径,是因为它把风险限制在应该变化的地方。候选提交可以先存在对象数据库里,分支引用在最后一步才移动;在此之前,当前分支、暂存区和工作区都保持原样。一个安全的重写流程,应当先在对象层准备候选结果,最后才用一次受控的引用移动完成切换。
提交前先证明内容没有变
只在对象层生成新提交还不够。历史被压缩之后,最末端的提交必须仍然代表原来 HEAD 的文件内容,否则即使工作区没有被直接覆盖,分支也可能悄悄指向了另一份结果。
因此,移动分支之前要做一道快照校验:把新历史末端的文件树和原 HEAD 的文件树逐项比较,确认文件内容完全一致。这里比较的是最终快照,而不是提交数量、提交说明或父子关系。提交的作者信息可以不同,提交链可以更短,合并关系也可以被拉直,但同一个分支最后代表的文件内容不能凭感觉“应该差不多”。
这道门禁也改变了失败处理的顺序。生成候选历史失败,原引用不动;快照不一致,原引用不动;最后移动引用时出现异常,仍然不把工作区作为补偿手段。失败保持原状,比失败后努力恢复更可靠,因为前者不需要猜测哪些状态已经被改变。
这里的“原状”不是一句宽泛的口号,而是几个可以验证的对象:原分支还指向原提交,暂存区没有被清空,工作区文件没有被切换,未跟踪文件仍在原来的位置。修剪前先记录这些边界,修剪后再逐项比对,才能知道保护是真的发生了,而不是脚本恰好没有报错。
不稳定状态直接不修剪
还有两种状态,我认为不应该继续尝试自动修剪。第一种是仓库已经处于 rebase 进行中,说明它本来就有一个未收尾的历史操作;第二种是当前不在任何分支上,引用关系不够稳定。此时继续生成并移动引用,会让原本已经复杂的现场更难判断。
所以新流程把它们视为跳过条件,而不是需要强行修复的输入。跳过不是失败,也不是偷懒,它是在承认当前仓库缺少安全前提。等已有的 rebase 被明确收尾,或者仓库回到一个可识别的分支,再重新发起修剪,判断链会清楚得多。
最容易误判的地方,是把“历史很旧、正好该清理”当成了“现在适合清理”。历史是否需要变短,和仓库当前是否稳定,是两个问题。一个仓库即使只剩几条提交,只要处于中途操作或分离状态,就不应该为了完成数量目标继续向前推进。
用临时仓库验证保护边界
这次修复没有只拿一个干净仓库跑通命令,而是用临时仓库把危险场景逐个放进去。测试覆盖了带合并的历史,也覆盖了未提交改动、暂存改动、未跟踪文件和中途失败。这样做的原因很直接:如果只测试干净工作区,最关键的保护承诺根本没有被检验。
每个场景都不只看“历史是否变成 10 条”,还要看修剪前后的工作区和暂存区是否保持原样。合并历史要确认最终文件快照没变;失败场景要确认引用没有前移;不在分支上和正在 rebase 的场景要确认脚本确实跳过。测试最后输出 ALL PASS,多个临时仓库在压缩之后保持干净,这才说明流程同时覆盖了成功路径和失败路径。
这件事让我重新意识到,版本控制工具的安全性不在于命令听起来多熟悉,而在于它有没有把边界写进验证。真正难的不是把历史从很多条变成 10 条,而是证明这次变化没有顺手改变使用者还没提交的现在。一个流程成熟的标志,不是它能跑通一次,而是它能在中途失败时仍然留下可解释的现场。
下次我会怎么做
下次遇到类似的历史整理,我会先问 3 个问题:当前是否在一个明确的分支上?仓库是否已经处于某种未完成操作?这次动作到底应该改变对象和引用,还是确实需要改动工作区?只要前两个问题没有得到肯定答案,我就先跳过,不把不稳定状态交给自动化脚本。
接着,我会把工作区、暂存区和未跟踪文件当成不可侵犯的边界,在对象层生成候选历史。候选结果生成后,先比较最终文件快照,再移动分支引用;任何一步失败,都返回原状态,不用 stash、切分支或人工找回作为默认补救。这个顺序不一定是最短的命令链,却是最短的风险链。
**我会把“引用移动前的快照等价性”当成历史重写的提交前门禁,把“失败不改变引用”当成不可省略的恢复策略。**这套方法不只适用于 Git 历史修剪,也适用于任何需要重建内部记录、最后再切换外部指针的维护任务:先隔离候选结果,再证明对外可见的内容没有意外变化,最后才提交状态转换。
我更愿意把它看成一种工作习惯,而不是一个 Git 技巧。过去的记录可以整理,正在发生的工作不能被顺手拿来试错。只有先承认这条边界,自动化才值得被放进日常维护里。