git rebase时如何解决merge节点冲突
0. 场景
长期存活的共享特性开发分支feature,开发历史里塞满 !NNN merge X into feature 这类平台合并节点,且X中存在merge feature into X的merge节点,也就是历史里有两种方向相反的 merge:
主线 master
M0 -------------------------------------M1
开发分支 feature
A ──── B ──────────── C ──────────── F
╲ ╲ ╱
╲ ╲ ╱
P ──── Q ────── R ──────╯
▲ 侧分支 X
│
回合 R = "merge feature into X" 父2 = C
并回 F = "!NNN merge X into feature" 父2 = R
| 节点 | 方向 | 性质 | |
|---|---|---|---|
| 并回 | F |
侧 → 主 | 平台产生,每个 MR 一个,无害 |
| 回合 | R |
主 → 侧 | 开发者手工做的,比如新手会简单的用git merge解决冲突,麻烦全在这里 |
现在要 rebase 到 master,结果冲突炸开。
根因 :平台合并时解决的冲突,内容存放在 merge commit 自身(它的 tree 既不等于父 1 也不等于父 2)。
1. 先量化,别急着动手
bash
B=$(git merge-base master HEAD)
echo "master 独有: $(git rev-list --count $B..master)"
echo "分支 独有: $(git rev-list --count $B..HEAD) ($(git rev-list --merges --count $B..HEAD) merges)"
echo "作者数: $(git log --format='%ae' $B..HEAD | sort -u | wc -l)"
找出侧分支内部还嵌套 merge 的(最难处理的那些):
bash
for m in $(git rev-list --first-parent --merges $B..HEAD); do
p1=$(git rev-parse $m^1); p2=$(git rev-parse $m^2 2>/dev/null) || continue
nm=$(git rev-list --merges --count $p1..$p2)
[ "$nm" -gt 0 ] && echo "$(git rev-parse --short $m) 内含 $nm 个 merge"
done
2. 核心技术:git replace --graft
bash
git replace --graft <commit> [<new-parent>...]
它创建一个替换对象:tree / message / author / committer / 两个日期全部保留,只改父列表。git 在遍历历史时透明地用它顶替原提交。
为什么零冲突:tree 是原样复用的,不存在三方合并,构造上不可能冲突。
在 scratch repo 实测验证元数据保留:
--- before --- A:orig_author <oa@x.com> 2020-01-01 | C:orig_committer <oc@x.com> 2020-01-02
--- after --- A:orig_author <oa@x.com> 2020-01-01 | C:orig_committer <oc@x.com> 2020-01-02
唯一损失:GPG 签名 (git 会警告)。这是数学上无法避免的 ------ 签名覆盖 commit 对象字节流,其中包含 parent 行,改父即失效。而且签名通常出自平台的 merge key,你没有私钥,无法以原身份重签。
3. 两种形态
形态 A:整条侧分支塌成一个提交
bash
git replace --graft <merge-commit> <merge-commit>^1
一条命令。原 merge commit 变成单亲普通提交,diff 恰好是该 feature 的净贡献(含全部冲突解决)。!NNN 标题保留在 message 里,MR 可追溯性不丢。
形态 B:保留 merge 节点,侧分支塌成一个提交
想维持 GitLab「squash + merge commit」那种图形:
bash
M=<merge-commit>
P1=$(git rev-parse $M^1)
P2=$(git rev-parse $M^2)
T=$(git rev-parse $M^{tree})
FIRST=$(git rev-list --reverse --no-merges $P1..$P2 | head -1) # 取 author
LAST=$(git rev-list --no-merges $P1..$P2 | head -1) # 取 committer 时间
NEW=$(GIT_AUTHOR_NAME="$(git log -1 --format=%an $FIRST)" \
GIT_AUTHOR_EMAIL="$(git log -1 --format=%ae $FIRST)" \
GIT_AUTHOR_DATE="$(git log -1 --format=%aI $FIRST)" \
GIT_COMMITTER_NAME="$(git log -1 --format=%cn $LAST)" \
GIT_COMMITTER_EMAIL="$(git log -1 --format=%ce $LAST)" \
GIT_COMMITTER_DATE="$(git log -1 --format=%cI $LAST)" \
git commit-tree $T -p $P1 -m "$(git log -1 --format=%s $FIRST)")
git replace --graft $M $P1 $NEW
几个要点:
commit-tree默认用你当前身份 ,必须显式传GIT_AUTHOR_*/GIT_COMMITTER_*- committer date 取侧分支最后一个真实提交的时间,避免晚于 merge 节点造成
git log时序倒挂 - 这样造出的 merge 是可 fast-forward 的「退化 merge」(tree 与第二父相同)。这正是平台 squash-merge 的标准形态,不是错误
git replace没有-S(git replace -h可确认)。要签名必须手工git commit-tree -S造出 merge 提交,再用git replace -f <old> <new>
前置安全检查
形态 A/B 都把侧分支塌到 M^1 之上。必须确认侧分支内部 merge 拉进来的主线内容已经是 M^1 的祖先,否则会掺入无关改动:
bash
for im in $(git rev-list --merges $P1..$P2); do
for sp in $(git rev-parse $im^2); do
git merge-base --is-ancestor $sp $P1 || echo "警告: $sp 不是祖先"
done
done
4. 校验清单
tree 不变性是唯一的核心不变量。 注意 git rev-parse 不认 --no-replace-objects 这个 flag(会把它当 rev 解析),统一用环境变量:
bash
# 单个节点
printf '原=%s 新=%s\n' \
"$(GIT_NO_REPLACE_OBJECTS=1 git rev-parse $M^{tree})" \
"$(git rev-parse $M^{tree})"
# 分支 tip ------ 最重要的一条
printf '原=%s 新=%s\n' \
"$(GIT_NO_REPLACE_OBJECTS=1 git rev-parse HEAD^{tree})" \
"$(git rev-parse HEAD^{tree})"
# 净改动等价
GIT_NO_REPLACE_OBJECTS=1 git diff --shortstat $M^1 $M
git diff --shortstat $M^1 $NEW
# 折叠掉的提交应全部移出历史
git merge-base --is-ancestor <old-commit> HEAD && echo 仍可达 || echo 已移出
# 数量账目
git rev-list --count master..HEAD
git rev-list --merges --count master..HEAD
实测:134/56 → 113/49,tip tree 932b71d9 全程不变。
5. 执行 rebase
bash
git rebase -r master # 必须 -r,否则所有 merge 节点被丢掉
git replace -l | xargs -r git replace -d # 成功后清理
-r = --rebase-merges。rebase 会按 replace 后的视图重放,把 squash 结果烘焙成真实提交。
replace ref 的三个性质,必须理解:
- 纯本地 ,
git push不带走 - 全局生效 ,不绑定分支。所有引用了被替换 SHA 的地方(包括你的「备份 tag」、
origin/*、同事的分支)在你本地都显示成改写后的样子。备份 tag 护不住你的视图 - rebase 成功后才清理。中途 abort 要保留它们以便重试
bash
# 查看真相
GIT_NO_REPLACE_OBJECTS=1 git log --graph --oneline <ref>
长 rebase 强烈建议先开 rerere:
bash
git config rerere.enabled true
git config rerere.autoupdate true
怎么定位来源提交
bash
git log -S'<被复活的字符串>' --oneline master..HEAD -- <file>
git log --diff-filter=A --oneline master..HEAD -- <file> # 谁引入的文件
7. 在源头提交修复
比在顶端追加「修复提交」更好 ------ 保证从那一点起每个提交都是可构建的。
选注入点(有个坑)
必须查后续哪些提交碰过这个文件:
bash
git log --oneline <candidate>..HEAD -- <file>
实测踩的坑:想在 45cd112 删掉重复调用,但后面的 6d83497 的 hunk 把那一行当尾部上下文:
return failure();
}
- [30 行被删除的代码]
-
inferCoreType(op); <- 上下文行
提前删掉 → 重放 6d83497 时上下文失配 → 冲突。改到最后一个碰该文件的提交里修,零下游冲突。
反之,如果该文件在引入提交之后无人再碰,就直接 amend 引入提交,最干净。
非交互式标记 edit
不想手动编辑 todo(382 行)就用 GIT_SEQUENCE_EDITOR:
bash
# 先空跑抓 todo,确认行格式与 SHA 缩写
cat > /tmp/dump.sh <<'EOF'
#!/bin/sh
cp "$1" /tmp/todo.txt
exit 1 # 非零退出 -> rebase 不启动
EOF
chmod +x /tmp/dump.sh
GIT_SEQUENCE_EDITOR=/tmp/dump.sh git rebase -i --rebase-merges master
grep -nE '<sha1>|<sha2>' /tmp/todo.txt
# 正式启动
cat > /tmp/mark.sh <<'EOF'
#!/bin/sh
sed -i -e 's/^pick <sha1>/edit <sha1>/' -e 's/^pick <sha2>/edit <sha2>/' "$1"
cp "$1" /tmp/todo-applied.txt
EOF
chmod +x /tmp/mark.sh
GIT_SEQUENCE_EDITOR=/tmp/mark.sh git rebase -i --rebase-merges master
grep -n '^edit' /tmp/todo-applied.txt # 确认替换生效
每个停点:
bash
# 整体回退某文件对这个提交的改动
git checkout HEAD^ -- <file>
# 需要保留部分 hunk 的文件手工编辑
git add -A && git commit --amend --no-edit && git rebase --continue
--amend 保留原 author,committer 变成你 ------ 历史重写的正常结果。
8. --onto 的正确用法
当底座分支本身被重写过 时,merge-base 会塌回很远的地方,普通 git rebase <base> 会把整段旧历史当成「你的改动」重放。
bash
git rebase --onto <newbase> <upstream> [<branch>]
# ^ 重放到哪儿 ^ 排除点(它及其祖先都不重放)
不加 --onto 时 <upstream> 同时承担两个角色;--onto 把它们拆开。
记忆法 :--onto 后面永远是目标,接着才是起点。
实测的典型错误 ------ 两个参数填反:
bash
git rebase --onto 92ba21fb feature/megakernel # 反了
怎么识别搞反了
reflog :rebase (start): checkout X 里的 X 就是 --onto 的目标。
HEAD@{4}: rebase (start): checkout 92ba21fb <- 它成了底座,说明反了
HEAD@{3}: rebase (pick): !51 merge ... <- 开始 pick 远古提交
输出里的这两行也是强信号:
warning: skipped previously applied commit ffa80eb
dropping 7cc78954... -- patch contents already upstream
git 按 patch-id 发现内容已在上游而跳过 ------ 这本身是正确行为,但如果范围正确,根本不会出现这类消息(你的提交都不在上游)。看到就立刻 abort。
正确做法:先确认 fork 点和提交数。
bash
git log --oneline --first-parent <branch> | head # 找自己提交的边界
git rev-list --count <fork>..<branch> # 确认数量
git merge-base --is-ancestor <fork> <newbase> && echo 可达 || echo 已被重写