起因是仓库提交历史很乱:来回合并、分支互相吞并、主线缺内容。排查后发现乱象背后是一个清晰的因果链,而收尾方式又恰好印证了 Git 合并机制的两个核心概念。
一、现象
打开 git log --oneline --graph,看到的是这样的画面:
* merge: 合并功能B到功能A|\| * merge: 合并远程分支| |\| | * ...| * | code review 修复| |/| * ...* | Merge branch '功能A' of origin into 功能A|\ \| | | * Merge branch '功能A'(master 上的合并)| | | |\| | |_|/| |/| || * | | ...
分叉、汇合、再分叉、再汇合,三个分支之间织了一张网。更麻烦的是:master 上缺了大量本该有的功能,而某个功能分支却"领先 master 三十多个提交"——它明明只是从 master 上分出去的小分支,为什么会领先这么多?
二、背景
- 两个开发者(下文称 A 和 B)在同一个仓库并行开发。
- 三条线:master(主线)、分支 X(A 主导)、分支 Y(B 主导,后来两人都在上面提交)。
- 一天之内,三条线发生了四次合并,其中两次方向"不对"。
三、排查方法:把时间线"钉死"
面对一团乱麻,不能靠猜。Git 提供了足够的线索,按下面四步就能把历史完整还原:
1. 画全局图
git log --oneline --graph --all -n 120
先看全貌,找出所有 merge 提交和分叉点。
2. 拆解每个 merge 提交的父母
merge 提交的价值在于它的 parents 列表——它精确记录了"谁在什么时候把谁并进了谁":
git log --format="%h %ci %p %s" -1 <merge提交># 输出: e576be4 2026-08-12 11:02:17 +0800 e4dcc93 4939e77 Merge ...# 哈希 时间 第一父 第二父 提交说明
- 第一父:合并时当前分支的 HEAD(合并发生在谁身上);
- 第二父:被合并进来的分支头(把谁并了进来)。
一个 %p 字段,直接把"谁合并了谁"钉死,比任何口头回忆都可靠。
3. 补上作者和时间
git show -s --format="%h|%an|%ci|%s" <提交>
给每个关键提交补上作者与精确时间。两个开发者的提交在同一个分支上交叉出现、同一文件被两边平行修改——这些都会在时间线上现形。
4. 验证"包含关系"而不是凭感觉
判断"分支 A 的内容是否在 master 里",用:
git merge-base --is-ancestor <分支A头> master && echo 已合入 || echo 未合入
判断"谁领先谁多少":
git rev-list --count master..分支A # 分支A 领先 master 多少git rev-list --count 分支A..master # master 领先分支A 多少
这一步直接推翻了我们最初的记忆:“B 应该把分支 Y 合进 master 了吧?”——答案是没有。
四、还原出的时间线
| 时间 | 谁 | 动作 | 影响 |
|---|---|---|---|
| 上午 | A | 在分支 Y 上连续提交 8 个样式修复,推送远程 | 分支 Y 出现两条平行线 |
| 中午 | B | 在自己本地的分支 Y(还停在旧节点)上提交 code review 修复 | 与 A 的提交分叉 |
| 中午 | B | pull 合并 → 分支 Y 内部完成一次交叉合并 | 交叉点 1:分支内部合并 |
| 傍晚 17:29 | A | 基于 master 分叉,提交功能提交 P,推送到分支 X | |
| 傍晚 17:30~17:47 | B | 在 master 上连续提交 3 个提交,与 P 平行改同一文件 | 撞车隐患 |
| 傍晚 17:53 | B | 在分支 X 上提交提交 Q(基于 P),随后 master 合并分支 X | 交叉点 2:master 合入"空壳" |
| 晚上 19:00~19:01 | A | 在分支 X 上提交核心功能(比 B 合并晚了 1 小时) | 核心功能错过了 master 的合并 |
| 晚上 19:03 | A | pull,自动合并 Q | 交叉点 3 |
| 晚上 19:20 | A | 把分支 Y 整体并入分支 X | 交叉点 4:功能分支互合 |
| 次日 11:02 | B | 把 master 反向并入分支 X | 局面突然变干净 |
五、三个关键发现
发现一:master 合并了一个"空壳"分支
master 的合并提交 parents 是 (master头, Q),Q 是分支 X 上仅有的两个提交之一。那一刻(17:53):
- 核心功能提交还没产生(19:00 才有);
- 分支 Y 的整套功能还没并进分支 X(19:20 才有)。
所以 master 合了个寂寞——合进来的是分支 X 的中间态,真正的功能全在合并之后才出现。这是"master 缺内容"的直接原因。
发现二:功能分支互相合并,而不是走 master
分支 Y 被直接并进分支 X(而不是各自并 master),导致:
- master 对两套功能完全不知情;
- 分支 X “领先 master 三十多个提交”,但其中自己开发的只有 3 个,其余全是吞进来的分支 Y 的内容和合并提交。
发现三:分支职责混在一起
分支 Y 里既有功能 A 的开发,也混着若干无关的优化提交;分支 X 里也混着别的修复。职责不清的分支让"这个提交属于哪个功能"无法回答。
六、原理:为什么"反向合并"反而让正向合并变干净
这是本次排查最有趣的部分。B 次日做了一次反向合并(把 master 并进分支 X),表面看是"又添乱",实际上把收尾变得零成本。原理如下:
1. 合并的本质
git merge B 做的事:
- 找两个分支的共同祖先;
- 对比"祖先→当前分支"和"祖先→B"两份差异;
- 合并两份差异,生成一个合并提交(两个父节点);
- 两份差异改到同一文件同一区域时 → 冲突,需人工裁决。
2. 正向合并当时的处境
两边各有对方没有的提交,且都改了同一个文件:
共同祖先 ├── master 独有:3 个提交 └── 分支X 独有:33 个提交
master 直接合并 → 必须建合并提交 + 人工解冲突。
3. 反向合并的效果
B 在分支 X 上执行 git merge master,冲突在这个提交里已经解决(解决结果固化在合并提交中)。合并后,master 的全部内容都并入了分支 X,分支 X 成为 master 的超集。
4. 正向合并变成 fast-forward
此时 master 再合并分支 X,Git 发现:master 的 HEAD 是分支 X 头的祖先。这种情况 Git 不走"找祖先 + 合差异"流程,而是直接 fast-forward:
- 不创建新提交;
- 不做差异对比,不存在冲突的可能;
- 只是把 master 指针移动到分支 X 的头。
冲突的解决工作被前置到了功能分支上,master 这边只是"接受结果"。这就是"反向合并把提交变干净"的原理。
七、经验教训
- 功能分支只从 master 拉、只往 master 合。分支之间互合是交叉网的起点。
- 共享分支上的操作要克制。分支 X 是两人共用的,A 推完 B 拉、B 提交完 A 又拉,每拉一次就多一个交叉点。
- 合并前先确认要合的内容。
merge-base --is-ancestor+rev-list --count两行命令,能避免"合了个空壳"。 - 合并提交的 parents 是还原历史的第一手证据。排查乱历史,先
git log --format="%h %ci %p %s"把所有 merge 提交拆开看。 - 反向合并确实能让收尾变干净,但要验证冲突解决没丢东西:
git diff <合并前master> <新头> --stat # 应只含功能分支的改动git diff <合并前分支头> <新头> --stat # 应只含master独有提交的改动
- 分支职责单一。一个分支只做一件事,混入无关提交会让后续所有追溯都变难。
八、最后
这次排查的每一步都只用了 git log、git merge-base、git rev-list 这几个基础命令——Git 的历史是完备的,乱只是表面,把时间、作者、parents 三样信息对齐,任何乱麻都能还原成一条清晰的因果链。而收尾方案的选择,也恰好是一次对合并机制(merge commit vs fast-forward)的实践检验。