为什么 rebase 之后,别人的 commit 在我分支上换了 hash?

问题

develop 是公共分支。我从它切出个人分支,完成需求后提交了 AB,推到远程并创建了 MR。后来 MR 暂时无法合入,而其他人又向 develop 合入了 XY 两笔提交。

为了同步主干,我执行了 fetchrebase origin/develop,随后又提交了修复 C。这时普通 push 被拒绝,IDE 提示选择 Merge 或 Rebase。

我再次选择了 Rebase,推送后却发现:MR 里别人的 XY 提交说明和改动都还在,但 hash 已经与 develop 对不上,变成了 X'Y'

看起来像是我把别人的 commit 重写了。

先说结论

问题出在 push 被拒后,IDE 弹窗里的 Rebase。它以旧的远程个人分支为基准,重新整理当前本地分支,而不是保留本地已经 rebase 好的历史。于是,刚从 develop 同步来的 XY 被再次重放,生成了 hash 不同的 X'Y'develop 上原来的提交并没有改变。

所以,本地 rebase 完 develop 后,应该直接用 --force-with-lease 更新个人远程分支。

git fetch origin
git rebase origin/develop
# 若有冲突,解决后执行:git rebase --continue

git push origin HEAD --force-with-lease

如果尚未创建 MR,并且确认远程个人分支只有自己使用,也可以每次MR前,删除远程分支,再重新推送本地分支并创建 MR。

而不是先执行普通 push,等推送被拒后,再通过 IDE 弹窗里的 Merge 或 Rebase 更新分支。

MR 已经出现影子提交,怎么恢复

先看本地是否还保留着正确的分支历史。即:

D ── X ── Y ── A' ── B' ── C

也就是没有 X'Y'

本地还保留着正确历史

这种情况最简单。切换到这个正确的本地分支,然后用它覆盖 MR 对应的远程个人分支:

git switch <正确的本地分支>
git push origin HEAD:my-feature --force-with-lease

命令中的 my-feature 要替换成 MR 实际使用的远程分支名。

也可以先删除远程分支,再重新push。

但是除远程分支会导致 MR 被关闭或失去关联,--force-with-lease 会更新 MR 内容,但通常会保留原 MR。。

本地正确历史也已经丢失

如果 IDE 的 Rebase 已经改写了当前本地分支,而你也没有保留正确的本地分支,就需要重新整理。思路是:从最新的 develop 新建一个干净分支,只把自己的 ABC 复制过去,再用它更新 MR 分支。

开始前先执行 git status。如果还有未提交的修改,应先提交或 stash,再继续下面的操作。

先在当前分支查看提交记录:

git log --oneline

根据提交说明,记下属于自己的 ABC 的 hash,并按照从旧到新的顺序排列。接着从最新的 develop 创建一个新分支:

git fetch origin
git switch -c my-feature-clean origin/develop
git cherry-pick <A 的 hash> <B 的 hash> <C 的 hash>

这里的 cherry-pick 可以理解为“只复制指定的提交”。原来的本地分支不会被修改,它本身就是一份备份。新分支的历史应该是“最新 develop + 自己的 ABC”,不会再包含额外的 X'Y'

用下面的命令做最后确认:

git log --oneline origin/develop..HEAD

如果输出中只有自己的提交,就用这个干净分支更新 MR 对应的远程分支:

git push origin HEAD:my-feature --force-with-lease

推送完成后,再检查一次 MR 的 Commits 和 Changes:提交列表中应只有自己的提交,代码改动中也不应再出现无关内容。

原因探索

本地主干 rebase 和拒推后的更新 rebase

假设最初的提交历史如下:

develop:              D ── X ── Y
                       \
origin/my-feature:      A ── B

其中 D 是个人分支与 develop 的旧分叉点,XY 是后来进入 develop 的两笔别人提交,AB 是自己的提交。

执行本地主干 rebase,也就是 git rebase origin/develop 时,Git 以最新的 develop 为新基准,把 AB 依次重放到 Y 后面:

develop:          D ── X ── Y
                            \
本地 my-feature:             A' ── B' ── C

远程 my-feature: D ── A ── B

这里的 A'B' 与原来的 AB 改动相同,但它们的父提交已经不同,所以 hash 必然不同。新提交 C 则继续接在这条新历史上。

此时本地与远程个人分支已经分叉。远程仍保留 D → A → B,本地则变成 D → X → Y → A' → B' → C,因此普通 push 不是 fast-forward,Git 会拒绝推送。这是 Git 在保护远程历史,并不是 rebase 失败。

如果这时在 push rejected 弹窗中选择 Rebase, JetBrains 系列IDE 中会用 rebase 方式更新当前个人分支。其效果类似于:

git rebase origin/my-feature

新的基准变成了旧的远程个人分支 D → A → B。按照 Git 官方手册的规则,rebase 会找出当前分支分叉后、且在上游没有等价提交的 commit,再逐个重放。

在这里,A'B' 分别与上游已有的 AB 补丁等价,Git 通常会提示 skipped previously applied commit 并跳过它们。XY 虽然来自 develop,但 origin/my-feature 并不包含它们;从这次 rebase 的比较范围看,它们属于本地独有的 commit,所以会和 C 一起被重放:

远程旧历史:      D ── A ── B
                              \
错误 rebase 后:                X' ── Y' ── C'

实际结果会受 Git 版本、rebase 参数和冲突解决方式影响,但关键因果不变:拒推后的更新 rebase 把 XY 放到了旧的 B 后面重新创建,于是得到内容相同、父提交不同、hash 也不同的 X'Y'

为什么父提交一变,hash 就会变

Git 的 commit hash 并不只由代码改动决定。一个 commit 对象还包含目录树、父提交、作者、提交者、时间和提交说明等信息。可以粗略理解为:

commit hash = hash(
  文件快照 + 父提交 + 作者信息 + 提交时间 + 提交说明 + ...
)

因此,“改动内容相同”不等于“是同一个 commit”。只要父提交或其他元数据发生变化,hash 就会变化。rebase 的本质正是把提交作为补丁取下,再接到新的基准上重新创建 commit,所以被重放的提交通常都会获得新 hash。

这也解释了为什么 X'Y' 不是对 develop 上原提交的篡改。XY 仍然留在公共分支原来的位置;X'Y' 是个人分支时间线上新建的 commit,更像是把同一份改动重新誊写了一遍。

为什么这里需要安全强推

普通 git push 只接受 fast-forward 更新,也就是远程现有历史必须完整包含在本地新历史中。rebase 已经用 A'B' 替换了 AB,两边不再满足这个条件,所以必须明确告诉 Git:用本地重写后的历史更新远程个人分支。

git push origin HEAD --force-with-lease

--force-with-lease 会先检查远程分支是否仍处于自己最后一次获取到的状态。如果此后有人向该分支推送了新提交,它会拒绝覆盖。相比不做检查的 --force,它能降低误删他人提交的风险。

不过,“安全强推”并不等于“可以强推任意分支”。它适合只有自己维护、团队也允许重写历史的个人 MR 分支。公共分支以及多人协作分支仍不应随意 rebase 或强推。

参考资料

Git 官方 git rebase 手册说明了 rebase 的简化过程:找出当前分支相对上游没有等价提交的 commit,再逐个重放;手册中的 --reapply-cherry-picks 说明也解释了为什么补丁等价的 A'B' 默认会被跳过。

JetBrains 支持文档中的 push rejected 说明确认,拒推后的 Rebase 选项会先用 rebase 方式更新当前分支,然后再尝试推送。

posted @ 2026-08-03 11:53  kingwzun  阅读(12)  评论(0)    收藏  举报