1. 先分清哪几种"重复":hash、patch与提交信息是三个裁判
1.1 commit的身份证是内容哈希,不是Message
要解决重复提交历史记录的问题,第一件事不是急着删提交,而是理解Git到底怎么区分"这条提交"和"那条提交"。
Git里的每个commit都有一个40位的哈希值(新版Git也支持SHA-256,但绝大多数仓库还在用SHA-1),这个哈希不是随机生成的,而是把该提交包含的全部关键信息整体做哈希计算得出的。一个commit对象里大概装着这些东西:
- 当前文件快照的tree对象哈希
- 父提交的哈希(如果有多个父提交就是merge commit)
- 作者姓名、邮箱、提交时间
- 提交者姓名、邮箱、提交时间
- 提交信息(commit message)
也就是说,哪怕两条提交的文件改动一模一样,只要提交时间差了一秒,或者父提交不同,哈希就完全不同。反过来,如果两条commit的哈希完全相同,说明它们从内容到父提交到时间戳全部分毫不差,这样的提交在Git对象库里只会保存一份,正常情况下不会在日志里看到两条。
这个原理带来的直接结论是:你在git log里看到的两条"一模一样"的记录,几乎必然是两个哈希不同、但内容等价的提交对象。它们不是同一个对象被重复显示,而是在不同时间、不同父提交基础上,被重复引入了同一份改动。搞清楚这一点,后面所有操作都不会跑偏。
1.2 我见过的三种"重复提交记录"
做过的项目多了之后,我发现所谓的"重复提交"其实分好几种,处理方式完全不同。我第一次遇到这个问题时差点把代码删没了,就是因为没有先区分类型。
第一种是真正的"双胞胎提交":两条commit的diff完全相同,历史位置不同,哈希不同。典型发生场景是同一个修复在多个分支上各提交了一次,之后分支合并时被带进同一条历史线。这种重复最容易用patch-id识别,也是本文主要针对的情况。
第二种是"同名提交":提交信息可能一样,但diff不同。比如多个成员都用"fix: 修复登录bug"作为提交信息,实际改了完全不同的东西。这种只是看起来像重复,其实根本不重复,如果贸然rebase掉其中一条,代码就出问题了。
第三种是"reflog幽灵提交":用git log看不到,但在某些GUI工具里能翻到一堆碎片记录。这通常是因为有人对已推送的提交做了amend或reset,旧提交还残留在reflog引用列表里。这种不是真正的历史重复,用git reflog expire --expire=now --all配合git gc --prune=now可以清掉,日常不用管。
1.3 判定重复的标准:diff等价,不等于同名
所以我判断两条提交是否为"重复"时,从来不看提交信息,只看内容。最朴素的方式是把两个commit代表的改动拉出来对比:
git diff <commitA> <commitB> --stat如果输出为空,说明两个提交最终指向的文件快照完全相同。但这里有个细节:即使快照相同,也可能只是碰巧最后状态一致,它们的"改动过程"未必相同。更严格的判断是用git patch-id:
git show <commitA> | git patch-id git show <commitB> | git patch-idpatch-id输出的第一列相同,就说明这两个提交在语义上引入的是同一份diff。这个机制正是git cherry判断"等价提交"的底层依据。
需要提醒的是,patch-id对普通提交有效,对merge commit要小心。merge commit的diff是合并后的组合结果,直接喂给patch-id可能不准确,应该改用git diff commitA^1 commitA去生成。
2. 定位重复提交的三板斧:graph、diff与cherry
2.1 先看全貌:git log --graph把提交结构铺开
排查重复提交历史记录的第一步,永远是把整个提交拓扑看清楚。我一般会执行这条命令:
git log --oneline --graph --decorate --all--graph会画出一条ASCII图形化的提交链,--all会把所有分支、标签都纳入视野。这时候你很容易看到某份改动是不是在两条分支上分别出现过,比如main分支有一串提交,feature分支也有一串提交,合并之后历史里出现了两个相同内容的节点。
我遇到过的最典型场景是这样的:hotfix分支修复了一个紧急bug,同时被cherry-pick到了release分支和main分支,之后hotfix分支又被整体merge进main。这么一顿操作下来,main的历史里既能找到cherry-pick产生的副本,又能找到hotfix原始提交,两条diff一样,只是哈希不同。没有--graph串联关系的话,光靠眼睛看git log根本对不上。
2.2 锁定证据:git diff和patch-id确认内容是否真一致
从--graph里找到疑似重复的两个提交后,别急着动手,先用第1章说的方法锁定证据。
假设两个提交是4a1b2c3和9b7e5d1,我会依次跑:
git diff 4a1b2c3 9b7e5d1 --stat git show 4a1b2c3 | git patch-id git show 9b7e5d1 | git patch-id如果两次patch-id的第一列一模一样,基本可以实锤是重复提交。如果需要给团队其他人看,我还会顺手截一下git log --oneline的输出,把两条记录并排放在一起,视觉冲击力比任何解释都强。
这里有个踩坑经验:git diff A B输出为空,只能说明两个commit最终的文件快照一致,不能证明它们是同一份改动。比如一条是"新增文件然后删除",另一条是"什么都没干",最后状态相同,但过程完全不同。所以diff为空先别高兴,还要确认两个提交的父子关系,以及中间有没有发生"负负得正"的情况。
2.3 批量识别等价提交:git cherry找上游已有的重复
当分支上的提交数量很多时,逐对diff不现实。这时候用git cherry效率最高:
git cherry origin/main my-feature输出里每一行是一个提交哈希,前面带+表示这个提交在my-feature里有、origin/main里没有;带-表示该提交已经在上游有了内容等价的版本。换句话说,所有带-的提交,都是需要考虑清理的"重复"嫌疑对象。
我在发布前检查PR时,几乎必跑这条命令。它能直接告诉你:你的功能分支里,到底有多少提交是别人已经干过的活。团队里如果经常出现"两个人修了同一个bug、提交了两次"这种事,用git cherry一照一个准。
如果嫌输出不够直观,还可以加-v参数显示提交信息,或者配合git log --cherry-pick --oneline origin/main...my-feature查看两侧的等价提交映射。
3. 本地分支还没推送:重写历史,干净摘除重复
3.1 动手前先备份:标签和分支是后悔药
确认了重复提交、确认这些提交还没有被推送到远端,那么恭喜,你拿到了"重写历史"的许可。
第一步永远是在重写之前打一个备份点。不要觉得自己操作很熟就跳过,rebase -i 一旦选错action,有可能把后续好几条提交全部打乱,这时候一个备份分支能让你瞬间回到事故发生前。
git branch backup-before-rebase或者打标签:
git tag backup-before-rebase标签的好处是不容易被误删,坏处是如果提交从历史里消失,这个标签还拽着旧对象不放,后续需要手动清理。备份分支更轻量,我一般用分支,完事之后就删掉。
3.2 交互式rebase:drop重复、fixup同名
假设当前分支历史长这样,9b7e5d1和7b0a5d6是两条重复的"修复登录bug"提交:
9b7e5d1 fix: 修复登录bug 3a1f6c2 feat: 调整样式 7b0a5d6 fix: 修复登录bug 2c99e01 feat: 增加登录接口 e3f2a11 init执行:
git rebase -i HEAD~5编辑器里会从旧到新列出5条提交。这时候把重复的那条从pick改成drop:
pick 2c99e01 feat: 增加登录接口 drop 7b0a5d6 fix: 修复登录bug pick 3a1f6c2 feat: 调整样式 pick 9b7e5d1 fix: 修复登录bug保存退出后,Git会自动把剩下的提交按顺序重放。如果drop的那条提交是纯重复,重放过程通常很顺利。如果后面有提交依赖了被drop掉的改动内容,Git会停下来报冲突,这时需要手动解决冲突并git rebase --continue。
我的习惯是,遇到两个"同名但改动略有差异"的提交时,优先考虑用fixup而不是drop。比如一条提交是完整修复,另一条是后续补丁式修改,直接drop会丢内容,正确的做法是把后一条fixup到前一条,保留前者的提交信息,把两者改动合并成一个逻辑单元:
pick 7b0a5d6 fix: 修复登录bug fixup 9b7e5d1 fix: 修复登录bug这样历史从"两条重复提交"变成"一条提交",内容一点没丢。如果只是想改提交信息,squash比fixup更合适,因为它会一起打开编辑器让你重写合并后的message。
3.3 整段移除重复历史:git rebase --onto
有时候重复的不是一条提交,而是一整段提交。比如feature分支基于很旧的main,包含提交D1 D2 D3,这三条提交的改动已经通过其他方式进了main。现在feature上还有E1 E2两条新提交需要保留。如果还想用rebase -i一条条drop,会非常累,而且容易看花眼。
这种情况用git rebase --onto:
git rebase --onto origin/main D3 feature它的含义是:把D3之后的提交(也就是E1、E2)重放到origin/main之上,直接把包含D1、D2、D3在内的一整段历史丢弃。注意第二个参数要填"你想丢掉的那段范围里最后一个提交",如果连D3本身也要丢掉,就写D3^。这个细节我踩过坑,写错了一个参数,差点把不该丢的提交也卷走了。
--onto适合的场景是"这段提交已经没有存在价值",它不跟你商量,直接整段搬走。用之前务必确认,这段提交的内容确实已经完整存在于目标分支里,否则E1、E2会建立在缺少依赖的新基座上,冲突能让你怀疑人生。
3.4 重写之后自查:最终代码树不能变
历史重写的核心原则是:最终的文件状态必须和重写之前保持一致。也就是说,你清理的是"历史记录层面的重复",不是"代码内容"。如果重写完之后代码功能变了,那说明你在drop过程中把不该丢的东西丢了。
重写完成后,我用备份分支做一次总检查:
git diff backup-before-rebase HEAD --stat输出为空,说明当前工作区文件状态和重写前完全相同,历史只是变干净了。然后再看一眼提交数:
git rev-list --count backup-before-rebase git rev-list --count HEAD提交数变少了,但代码树没变,这就是一次成功的"去重手术"。
这时候还有个细节:git log里可能还会出现旧的提交对象,因为backup分支还引用着它们。确认一切没问题后,记得删掉备份分支:
git branch -D backup-before-rebase4. 已经推送甚至合入主干:安全方案与协作节奏
4.1 公共分支上的重复:用git revert抵消,不要改写
如果你的重复提交已经推进了远端,甚至合入了main这类共享分支,那就不能用rebase重写了。强行改写公共历史会让所有协作者的仓库瞬间分裂,别人pull的时候会遇到一堆莫名其妙的分叉,严重的话整个团队的工作都会被卡住。
这种情况下,标准做法是用git revert生成一条反向提交。比如两条重复提交里,后一条是误引入的重复修复,想把它造成的"重复记录"抵消掉,可以:
git revert <后一条提交的哈希> --no-editGit会新建一条"Revert xxx"提交,把这个提交之前的改动全部反向撤销。对公共历史来说,多一条revert记录完全正常,审计链是完整的,别人pull也毫无压力。
但这里有个必须想清楚的点:如果两条重复提交代表的是同一份改动,而当前代码状态是"这份改动恰好正确存在",你revert掉任意一条,反而会把正确内容删掉。所以revert只适用于"重复提交中有一条扰乱了代码状态、需要回退"的情况。如果纯粹是历史看起来冗余、代码实际没问题,那就不要动,让历史记录保持原样,最多在代码评审时口头说明一句"这两条是等价提交"。
4.2 自己的远端分支:force-with-lease比force安全
如果你的重复提交只是在个人功能分支上,还没有合入共享分支,那还是有机会重写历史的。只是推送时不要用简单粗暴的--force,要用--force-with-lease:
git push --force-with-lease origin my-feature这个参数和--force的区别在于:强推之前,Git会检查远端分支的当前状态是否和你上次拉取时一致。如果这期间别人往同一个远端分支推过新提交,推送会被拒绝,而不是直接覆盖。等于给强推加了一道保险,避免把别人刚推上去的提交冲掉。
我见过团队里有人用git push -f把同事的提交覆盖了,结果两个人对同一分支的本地状态完全不同,最后靠git reflog抢救才把丢失的提交找回来。从那以后我给自己定了一条规矩:凡是重写历史之后要推送,只准用--force-with-lease,远程仓库也开启对应策略。
4.3 保证协作不翻车的三点约定
在团队里推行这套流程时,我总结了三件事,能减少90%以上的协作冲突:
第一,共享分支一律禁止重写历史。main、master、release这些分支代码一旦合入,默认不可变,发现问题只能revert。这个约定要写进团队文档,review时直接卡。
第二,个人功能分支合并之前,作者有责任自查一遍git cherry origin/main <自己的分支>,把带-的重复提交处理干净再提PR。这个习惯能让review的人少干很多重复劳动。
第三,如果一定要重写某个已经被多人在用的分支(比如临时release分支),操作前必须在群里同步,操作后立刻通知所有人执行git pull --rebase,并明确告诉他们没有其他选择。不要让任何人基于旧历史继续提交,否则又会制造新的重复。
5. 从习惯上根治:让重复提交在源头消失
5.1 提交前先问:这个改动是不是已经在别的分支存在
清理已经发生的重复提交始终是事后补救,真正的效率提升在于从一开始就不制造重复。我在提交前习惯先做一件事:确认当前改动的归属。
比如我同时在main和feature分支上工作,改完一个bug后,我要先想清楚这个修复应该落在哪个分支,而不是"顺手在两个分支都提交一遍"。如果确实需要在多个分支落地同一个修复,我会评估是cherry-pick还是直接合并,二选一,而不是同时做,否则就会在后续合并时撞出重复。
有一个命令能帮你快速判断某个提交是否已经存在于其他分支:
git branch --contains <commit哈希>这个命令会列出所有包含该提交的分支。如果输出里已经有多个分支,说明这个提交早就被带过去了,没必要再重复造一份。我在热修复之后的常规动作是:先把hotfix分支提交,然后确认要发布的目标分支,再决定是merge还是cherry-pick,一路保持单一路径。
5.2 用pull --rebase代替pull,减少冗余合并记录
还有一种"重复感"不是内容重复,而是历史结构上的冗余:你每次git pull都生成一个merge commit,一星期之后git log --graph里全是蜘蛛网。这不是提交内容重复,但看历史记录的人会觉得"怎么多了这么多同样的东西"。
解决办法很简单——用rebase代替默认merge:
git pull --rebase或者直接配置成全局默认:
git config --global pull.rebase true这样本地未推送的提交会被临时取下来,远程更新后再逐个重放,不会产生无意义的merge commit。这条命令特别适合个人功能分支同步远端代码的场景。如果远端历史确实被人重写过,git pull --rebase会自动检测并提示,比默认merge更早暴露问题。
5.3 amend和cherry-pick的正确用法,别为了省事制造历史
git commit --amend和git cherry-pick这两个命令,用对了很高效,用错了就是重复提交的温床。
--amend的正确使用场景只有一个:上一条提交还没有推送到远端,你想补充一个遗漏的小改动,或者修改提交信息。做法是:
git add <遗漏的文件> git commit --amend --no-edit这样不会新增一条提交,而是更新原提交。如果上一条提交已经推送了,再用amend就会在远端产生一条旧commit、一条新commit,看起来就像同一个改动重复提交了两次。实际排查历史时,这种"amend后强推"造成的双记录特别多。
cherry-pick则是把一条提交复制到当前分支。绝大多数情况下,我不建议在"后续会合并整个分支"的预期下使用它。你一旦cherry-pick了某条提交,之后再把源分支整体合并过来,Git会在历史里同时保留源提交和副本提交,即便内容等价,记录也会冗余。如果实在要用,我的建议是:先想清楚这个改动是"临时带过去"还是"要正式落地",两者的后续合并方案完全不同。
5.4 把"查重复"写进每次Review的检查清单
最后分享一个软性习惯。我在代码评审的检查清单里加了一条:合并前必须跑一遍git cherry和git log --graph,重点看有没有带-的等价提交,以及提交信息相同但diff不同的"疑似重复"。
团队里一开始有人觉得这条多余,直到有一次release分支上出现两条"fix: 修复支付回调"的提交,一条是cherry-pick来的,一条是merge带来的,虽然代码没有实际冲突,但回滚的时候谁都不敢动,因为分不清哪个才是"最终版本"。花五分钟做一次检查,能帮后面省掉一整晚的排查时间。
对我个人而言,最实用的组合拳是:日常用git pull --rebase保持历史干净,提交前用git branch --contains确认改动归属,review前用git cherry origin/main HEAD扫一遍重复。这三条习惯养成之后,重复提交历史记录这个问题的出场率会大幅下降。如果真碰到了,就用前面几章的方法,先区分类型、再定位证据、最后再决定drop还是revert——顺序千万别反,反了就可能要从reflog里捞代码了。