本文是项目的设计说明与知识沉淀:整合 docs/ 下三篇调试笔记(merge / rebase / cherry-pick)的精髓,说明 Git 合入类操作如何工作、三路归并中 o / a / b 如何确定、冲突检测与合并逻辑、常见事故(合丢、重复、ABA)及工程应对,并明确 git-vis 事后展示 o / a / b 的可行边界。
docs/ 目录下有三篇笔记(PDF,基于 lldb 跟踪 Git 源码):
- How
git mergeWorks - How
git rebaseWorks - How
git cherrypickWorks
撰写背景:项目组曾有人误操作 Git,导致代码在合并过程中丢失或重复;根因往往是对 三路归并 里的 merge-base、parent、ours / theirs 理解有误区——例如把 merge commit 的「两个 parent」直接当成「o 和 a」,或做 diff / 解冲突时没有用正确的基线版本 o。
git-vis 的初衷:把这些过程 可视化,帮助使用者理解「改动是怎么合过来的」,减少误操作。
理解合入逻辑前,需要先接受 Git 的对象模型(详见 cherry-pick 笔记第 7 节):
- 每个 commit 是一次完整快照,关联一个 tree;tree 指向各路径的 blob(或子 tree)。
- 合入、重放、打 patch,最终都是在 tree / blob 层面 做三路比较与合并,而不是「按提交时间顺序 replay diff 列表」。
git diff A B是事后 比较两个快照 算出来的,不是 commit 对象里存着的字段。
因此:三路归并只关心三个快照 o、a、b 各自的内容,不直接关心「哪个分支先改、哪个后改」。这是 ABA 等问题的重要根源(见第 8 节)。
跟踪源码可知,git merge(3-way)、git cherry-pick、git rebase(逐步 pick)、git apply 在 内容合并 阶段最终都汇入相似调用链:
process_entry
→ handle_content_merge
→ merge_3way
→ ll_merge → ll_xdl_merge
→ xdl_do_diff(o, a) // a 相对 o 的改动
→ xdl_do_diff(o, b) // b 相对 o 的改动
→ xdl_do_merge(...) // 判冲突、产出结果
核心结论(三篇 PDF 共同强调):
- 差别主要在如何选定 o、a、b(以及 merge 还有 fast-forward 等分支,根本不进入上述路径)。
- o、a、b 确定之后,相对基线算 diff、单边/双边分流、冲突检测与合并算法 是同一套(
xdl_do_diff+xdl_do_merge)。 - Git 的冲突检测是 基于相对基线 o 的修改块,不是基于「合并后文件的行号是否重叠」的直觉。
diff3 冲突标记对应关系:
| 标记 | 含义 |
|---|---|
<<<<<<< |
a(ours) |
| ` | |
>>>>>>> |
b(theirs) |
类型(merge 笔记):
| 类型 | 条件 | 是否产生 merge commit | 是否三路合并 |
|---|---|---|---|
| fast-forward | 当前分支是对方分支的祖先 | 否,只移动指针 | 否 |
| 3-way merge | 双方都有独有提交 | 是(通常 2 个 parent) | 是 |
3-way merge 执行时(生成 merge commit C 之前):
| 符号 | 含义 | 典型来源 |
|---|---|---|
| o | 共同祖先 | merge-base(HEAD, MERGE_HEAD)(通常只取 一个 最佳共同祖先) |
| a | 我方 | 当前 HEAD 的 tree |
| b | 对方 | 被合分支 tip(MERGE_HEAD)的 tree |
merge commit C 已存在之后(事后分析,与 commit 对象字段一致):
| 符号 | 含义 |
|---|---|
| a | parent[0](first parent = merge into 的那条线) |
| b | parent[1](被 merge 进来的那条线) |
| o | merge-base(parent[0], parent[1]) |
典型 git merge feat 在 master 上:parent[0] = 合并前 master tip,parent[1] = feat tip。
重要:o 一般不是 parent[1],也不是 C 的任一 parent;它是两条线在分叉前的共同祖先,往往比两个 parent 都更早。
o = merge-base(P₁, P₂)
/ \
P₁ = parent[0] P₂ = parent[1]
\ /
C (merge commit)
把 某个 commit B 的改动 接到当前 HEAD 上。merge_3way 参数语义(cherry-pick 笔记第 8 节注释):
| 符号 | 含义 |
|---|---|
| o | **B 的父提交**上的 tree(parentOf(B);pick merge commit 时需 -m 指定 mainline) |
| a | 当前 HEAD(目标分支 tip)的 tree |
| b | 被 pick 的 commit **B** 的 tree |
与 merge 的 关键区别:cherry-pick 的 o 不是 merge-base(HEAD, B),而是 被 pick 提交的直接父快照。语义是「重放 B 相对其父 o 的那次改动,接到当前 a 上」。
Cherry-pick 通常产生单 parent 新 commit;message 里可能带 (cherry picked from commit <hash>)(-x 默认添加,可被改掉),对象里没有「这是 cherry-pick」的专用字段。
现代 rebase 基于 sequencer + 逐步 cherry-pick(rebase 笔记):
- 计算
upstream..HEAD上的独有 commits → todo list; reset --hard到 upstream(或等价移动基底);- 对每个
pick <commit>调用与 cherry-pick 相同的pick_one_commit→do_recursive_merge→process_entry。
每一步 pick 的 o / a / b 与 cherry-pick 同型,且 每步都会变:
pick C₁: o = parent(C₁), a = HEAD₀, b = tree(C₁) → C₁'
pick C₂: o = parent(C₂), a = tree(C₁'), b = tree(C₂) → C₂'
Rebase 完成后:历史是 一串新的单 parent commit,不保留「哪一步用了哪组 o / a / b」的结构化信息。
| 符号 | 含义 |
|---|---|
| o | patch 上下文所依据的 基线版本(通常对应 patch 生成时「目标分支的 parent / 上下文 commit」) |
| a | 当前 HEAD 的 tree |
| b | 应用 patch 之后 期望达到的内容(由 patch hunk 反推,不是某个已有 commit 的 tree) |
与 cherry-pick 类似:基线 o 由 patch 的上下文决定,不是 merge-base。三路合并引擎仍可能参与(取决于是否能干净应用)。
| 操作 | o | a | b | 典型结果 commit |
|---|---|---|---|---|
| merge (3-way) | merge-base(HEAD, theirs) |
HEAD |
对方 tip | 2+ parents 的 merge commit |
| merge (FF) | — | — | — | 无新 commit,无 o/a/b |
| cherry-pick | parent(picked) |
HEAD |
tree(picked) |
通常 1 parent |
| rebase(每步) | parent(当前 pick) |
上一步结果 HEAD | tree(当前 pick) |
每步 1 parent |
| apply | patch 上下文基线 | HEAD |
patch 期望内容 | 工作区变更(commit 可选) |
在 process_entry 中,Git 对每个路径(文件)先做 变更分类,再决定是否进入 handle_content_merge(merge / rebase / cherry-pick 笔记均跟踪到此逻辑)。
仅 a 或仅 b 相对 o 改了该路径(新增、删除、修改只在一侧发生):
- 直接采用有改动那一侧的版本;
- 不进入 完整三路内容合并,不会 报冲突。
rebase 笔记中「两个分支改不同文件」的用例,大量走这条快速路径。
两侧都相对 o 改了同一路径时,进入 handle_content_merge → merge_3way → xdl_do_merge:
**xdl_do_diff(o, a)** → 改动块链表xscr1(每个块记录相对 o 的行号范围与内容);**xdl_do_diff(o, b)**→xscr2;- 按 相对 o 的变更行号 排序,逐块比较:
- 只在一边出现的改动 → 直接应用到结果(「非重叠的双边修改」);
- 两边都改了同一块 → 比较是否 完全一致(起始行、范围、内容):
- 一致 → 自动合并,不冲突;
- 不一致 → 报冲突,写 diff3 标记,等人肉。
Git 判定冲突的依据是:相对同一基线 o,a 与 b 是否在同一个 diff 块上做了不同修改。
不会 因为「合并后文件里两段插入挨得近、看起来该冲突」就必然报冲突。典型反例(三篇 PDF 的核心案例):
- o 某行附近,a 在 行 2 后 插入两行,b 在 行 1 后 插入两行;
- 相对 o 的 diff 块 行号区间不重叠,Git 视为 两处独立插入,自动合并,结果里 两段都保留;
- 若两段逻辑相似,就会出现 代码重复,但 Git 不认为这是冲突。
这是 设计如此,不是实现 bug:Git 倾向于 启发式、自动化 减少冲突,而不是「有一点并行修改就停下来」。
合并 算法 相同,但 工作流 略有不同(merge 笔记第 4 节):例如自定义 merge driver 返回冲突时,git merge 与 git cherry-pick 对工作区 / 索引中已合并结果的保留行为可能不一致。工程上应 以是否出现 conflict markers、是否处于 sequencer 状态为准,不能假设「merge 成功了就一定没问题」。
常见原因 不是 Git 随机丢块,而是 理解或操作错误:
| 原因 | 说明 |
|---|---|
| 误用 diff 视角 | 用 git diff P₁ P₂ 代替相对 o 的三路视角,与 Git 内部不一致 |
| 解冲突选边错误 | 在 rebase / cherry-pick 中仍按 merge 的「保留 ours」直觉,未意识到 a、b 角色已变 |
| 只看 first parent | git show mergeCommit 默认相对 parent[0],看不到相对 b 或 o 引入了啥 |
| 静默合并 | 两侧改动被算法判为「可自动合并」,但其中一侧的语义被另一侧覆盖(含 ABA) |
| squash / rebase 改写历史 | 多步合入压成一步,事后难以审计 |
本文档与 PDF 中最具冲击力的案例:两分支在 相对 o 的不同行位置 插入相似逻辑 → Git 不报冲突 → 合并结果 两段并存。merge、cherry-pick、rebase 均可出现,因为底层是同一套 xdl_do_merge。
场景:多线维护时,分支 A 做了修改 X,分支 B 做了修改 Y 又 改回 类似 X 的旧状态;三路合并 只看 o、a、b 三个快照, 不考虑时间先后。合并结果可能取 B 侧快照,表现为 「改过的又被改回去」(ABA 指中间状态在结果中消失)。
根因:多线修改 + 对 Git 快照合并语义理解不足;不是 cherry-pick 独有,merge 同样会有。
- 减少多线并行改同一模块:主干开发、短分支、频繁集成到集成分支,避免「攒几个月一次 merge」。
- merge 大分支前先 diff / 测试:大 diff 一次性合入本身风险高;应用 增量集成(merge 笔记:新特性发完验证后及时合入集成分支)。
- Cherry-pick 后必须 review:看 该 commit 触达的文件列表 与 diff,不能只看「命令成功、无 conflict」。
- Rebase 后再 merge:若 fast-forward,历史上 没有 merge commit,合入事件不可见;若需要审计合入点,考虑
--no-ff(见第 9 节)。 - 避免盲目
Never cherry-pick:关键在理解语义与 review;在必须 backport 的场景 cherry-pick 仍常用,但要 知道 o 是 parent(picked),不是 merge-base。
- 单元 / 集成 / QA 测试(merge 笔记第 4 节)。
- 对关键合入:除默认
git show外,主动看相对 o、b 的差异(git diff o..C、git diff b..C等)。 - 团队规范:冲突解决后 必须 打开文件确认,不能仅
git add了事。
Git 默认是 启发式 / 自动化 一端:尽量合并,只在 diff 块级「真不一致」时报冲突。
| 策略 | 行为 | 优点 | 缺点 |
|---|---|---|---|
| 默认(启发式) | 按 o 的 diff 块判冲突;独立插入可并存 | 冲突少,日常流畅 | 重复代码、静默语义冲突可能溜过去 |
| 激进(自定义 merge driver) | 例如:只要 a、b 都 相对 o 改过 同一文件 就强制冲突(merge 笔记 merge-driver.sh demo) |
多线修改必过人眼 | 误报多、合并成本高;与 merge / cherry-pick 行为细节可能不完全一致 |
如何平衡(PDF 结论):
- 默认信任 Git 启发式,靠 分支策略 + review + 测试 兜底(大多数团队的主路径)。
- 对 核心模块 / 高频多线冲突路径,在
.gitattributes上对特定路径启用 自定义 merge driver,做「同文件双边修改即报警」类保护。 - 激进 driver 不能替代 流程:它不知道业务语义,只能缩小「完全无冲突标记的静默合并」窗口。
- Cherry-pick 场景代码量通常较小,人工 review 该 commit 文件列表 往往比全仓库激进 driver 更划算。
参考 demo:merge-driver.sh 与 .gitattributes 配置见 merge / cherry-pick 笔记及 git_merge_driver.tgz。
本文围绕一个核心事实展开:Git 合入的本质是三路归并(o / a / b 三个快照),而不是按提交顺序拼接 diff。merge、cherry-pick、rebase、apply 在内容层最终都依赖同一套三路合并引擎,关键差异主要在 o / a / b 的选取方式。因此,理解「这次操作的基线 o 是谁」比记忆命令表面行为更重要。
在机制层面,Git 的冲突检测是 相对 o 的 diff 块是否发生不一致,不是「两边都改了就一定冲突」。这解释了为何实践中会出现“无冲突但重复代码”“看起来没问题却语义被覆盖(含 ABA)”等事故:它们常常不是 Git 随机失效,而是启发式自动合并与业务语义之间的天然张力。
落到工程实践,结论是:
- 流程上,优先主干开发、短分支、频繁集成,降低多线长期分叉;
- 操作上,把冲突解决和 cherry-pick/rebase 后 review 当成必做环节,不以“命令成功”代替“语义正确”;
- 保障上,用测试与审计兜底,必要时仅对核心路径启用更激进的自定义 merge driver。
docs/How git merge Works.pdfdocs/How git rebase Works.pdfdocs/How git cherrypick Works.pdf- Git 对象存储说明(外部)
- 自定义 merge driver demo(外部)
- 延伸阅读:Stop cherry-picking, start merging (Part 1 & 2)(Microsoft Old New Thing,讨论静默合并未冲突的案例)