在一个多分支并行开发的项目中,某次发布后出现了一个「曾经被修复过」的 bug。翻查 git log 发现主干上同一条提交信息出现了两次,两个 commit 的 hash 和 committer 都不同,但内容完全一致:
对应的 fix commit 确实存在:
但 fix commit 的时间(15:40)早于第二个 feat commit(17:14),在 merge 时后者把 fix 的内容覆盖了,bug 就此复现。
通过 git log --oneline 在主干分支检索,发现同一条提交信息出现两次,hash、作者、时间均不同,但执行 git show 对比 diff 内容完全一致,确认是同一批文件改动被引入了两遍。
fix commit i9j0k1l2 确实存在于主干历史,且时间早于第二个 feat commit。
在三路 merge 时,Git 以 merge base 为参考:
Git 选择了有净变化的一侧,fix 的效果因此丢失。
追踪到它来自某个个人开发分支(feat/xxx),通过以下路径进入主干:
检查第二个 feat commit 的 parent:
该 parent 并不是原始 feat commit 的 parent,说明这个 commit 是被重放(replay)出来的,而非原始提交。
进一步验证:
结论:开发者在个人分支上执行了类似如下的操作:
这次 rebase 将个人分支上的若干 commit 全部重放,产生了一批全新 hash 的副本,其中就包括了原始 feat commit 的变异版本。
Git commit 的 hash 由以下内容共同计算:
rebase 会将 commit 的 parent 从旧 base 替换为新 base。第 1 个 commit 的 parent 变了,hash 随之改变;第 2 个 commit 的 parent 是第 1 个的新 hash,hash 也随之改变……形成链式反应,所有后续 commit 全部得到新 hash。
这与文件内容是否有冲突无关——哪怕所有文件一字未改,只要 parent 变了,hash 就必然变化。
理解这个问题,需要先搞清楚 rebase 的「重放范围」是如何确定的。
rebase 的第一步是找到当前分支(feat/xxx)与新 base(release/feature-branch)的最近公共祖先(merge base)。公共祖先之后、属于当前分支的所有 commit,都会被逐一重放到新 base 上。
release/feature-branch 是专门为某个大需求建立的集成分支,它从未同步过主干,因此它不包含原始 feat commit(原始 feat 是从主干引入到个人分支的)。这就导致两个分支的公共祖先正好卡在原始 feat commit 进入个人分支之前,使其落入了「需要重放」的范围。
rebase 在重放时会计算每个 commit 的 patch-id(只看文件内容的变更,忽略时间、parent 等元信息),用来判断某个改动是否已经被新 base 包含、可以跳过。
但由于 release/feature-branch 从未同步过主干,它的历史里根本没有原始 feat commit 的改动内容,patch-id 对不上,Git 认为这是一个「尚未应用」的变更,于是老老实实地把它重放了一遍,产生了一个内容相同但 hash 全新的副本。
一句话总结:集成分支(release/feature-branch)没有同步过主干,导致它与个人分支的公共祖先把原始 feat commit 划进了重放范围;新 base 里又确实没有这个改动,Git 只能将其重放,产生变异副本。
三路 merge 的判断逻辑如下:
Git 在三路 merge 中,发现路径 B 对某文件有新增改动而路径 A 没有,就会选择路径 B 的版本。fix 的内容等同于「什么都没做」,最终被路径 B 覆盖,bug 复现。
如果你怀疑分支上存在「变异 commit」,可以按以下步骤排查:
若原始 commit 之后没有针对它的 fix → 风险较低。
若存在对应 fix → 进入步骤 2。
若 fix 的关键改动仍然存在 → 暂时安全。
若 fix 的改动已被覆盖 → 需要立即重新提交 fix 并评估影响范围。
也可以通过时序快速判断:
若变异 commit 出现在 fix commit 之后,且改动的是同一文件的同一区域,fix 大概率已被覆盖。
本次问题并非单纯由 rebase 本身引起,而是由两个条件同时成立触发的:
release/feature-branch)未及时同步主干,导致它不包含原始 feat commit 及其 fix在此基础上,个人分支对集成分支执行 rebase 时,原始 feat commit 对于新 base 是「未知的」,被当作新 patch 重放,产生了变异副本。
如果集成分支提前同步了主干,它就已经包含原始 feat commit,rebase 时 patch-id 能对上,Git 会自动识别并跳过,不会产生重复 commit。
大型需求开发周期较长,主干期间会持续有新 commit 合入。集成分支应定期 merge 主干,避免与主干产生过大的分叉:
同步时机建议:
在对集成分支执行 rebase/pull 之前,先检查集成分支是否落后于主干:
若输出不为空,说明集成分支尚未同步主干,此时 rebase 存在产生重复 commit 的风险。应先推动集成分支同步主干,再在个人分支上执行 rebase。
在向集成分支发起 MR 前,检查本次 MR 是否带入了非预期的 commit:
若发现有不属于本次开发的 commit(尤其是与主干历史内容相同的),说明可能存在重复引入,需先清理再发 MR。
git rebase -i 将重复 commit 标记为 dropgit reset 回退到公共祖先,再 cherry-pick 仅属于本次开发的 commitgit log --oneline <target-branch>..HEAD 确认结果符合预期| 阶段 | 关键点 |
|---|---|
| 为什么 hash 全变 | rebase 改变 parent,触发链式 hash 变化,与文件内容无关 |
| 为什么原始 commit 被重放 | 集成分支未同步主干,公共祖先把原始 commit 划进了重放范围;patch-id 也无法跳过 |
| 为什么 fix 被覆盖 | 三路 merge 中,fix 等于「相对 base 无净变化」,变异 commit 等于「有新增」,Git 选择了后者 |
| 如何预防 | 集成分支定期同步主干;rebase 前检查目标分支同步状态;MR 前 diff 检查 |
核心认知:
git pull --rebase不仅仅是「拉代码」,它会重写本地所有未推送 commit 的 hash。在多分支长周期协作场景中,rebase 的范围由公共祖先决定,而公共祖先的位置取决于各分支的同步状态——这才是问题的真正根源。