Skip to content

Latest commit

 

History

History
293 lines (182 loc) · 16.9 KB

File metadata and controls

293 lines (182 loc) · 16.9 KB

git: 三路归并与工程实践

本文是项目的设计说明与知识沉淀:整合 docs/ 下三篇调试笔记(merge / rebase / cherry-pick)的精髓,说明 Git 合入类操作如何工作、三路归并中 o / a / b 如何确定、冲突检测与合并逻辑、常见事故(合丢、重复、ABA)及工程应对,并明确 git-vis 事后展示 o / a / b 的可行边界

1. 背景与初衷

docs/ 目录下有三篇笔记(PDF,基于 lldb 跟踪 Git 源码):

  • How git merge Works
  • How git rebase Works
  • How git cherrypick Works

撰写背景:项目组曾有人误操作 Git,导致代码在合并过程中丢失或重复;根因往往是对 三路归并 里的 merge-baseparentours / theirs 理解有误区——例如把 merge commit 的「两个 parent」直接当成「o 和 a」,或做 diff / 解冲突时没有用正确的基线版本 o。

git-vis 的初衷:把这些过程 可视化,帮助使用者理解「改动是怎么合过来的」,减少误操作。


2. Git 底层:快照,不是 diff 链

理解合入逻辑前,需要先接受 Git 的对象模型(详见 cherry-pick 笔记第 7 节):

  • 每个 commit 是一次完整快照,关联一个 tree;tree 指向各路径的 blob(或子 tree)。
  • 合入、重放、打 patch,最终都是在 tree / blob 层面 做三路比较与合并,而不是「按提交时间顺序 replay diff 列表」。
  • git diff A B 是事后 比较两个快照 算出来的,不是 commit 对象里存着的字段。

因此:三路归并只关心三个快照 o、a、b 各自的内容,不直接关心「哪个分支先改、哪个后改」。这是 ABA 等问题的重要根源(见第 8 节)。


3. 统一合并引擎:四种操作走同一条路

跟踪源码可知,git merge(3-way)、git cherry-pickgit 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 共同强调):

  1. 差别主要在如何选定 o、a、b(以及 merge 还有 fast-forward 等分支,根本不进入上述路径)。
  2. o、a、b 确定之后,相对基线算 diff、单边/双边分流、冲突检测与合并算法 是同一套xdl_do_diff + xdl_do_merge)。
  3. Git 的冲突检测是 基于相对基线 o 的修改块,不是基于「合并后文件的行号是否重叠」的直觉。

diff3 冲突标记对应关系:

标记 含义
<<<<<<< a(ours)
`
>>>>>>> b(theirs)

4. 四种操作如何工作,o / a / b 如何确定

4.1 git merge

类型(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)

4.2 git cherry-pick

某个 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」的专用字段

4.3 git rebase

现代 rebase 基于 sequencer + 逐步 cherry-pick(rebase 笔记):

  1. 计算 upstream..HEAD 上的独有 commits → todo list;
  2. reset --hard 到 upstream(或等价移动基底);
  3. 对每个 pick <commit> 调用与 cherry-pick 相同的 pick_one_commitdo_recursive_mergeprocess_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」的结构化信息。

4.4 git apply(及类似 patch 应用)

符号 含义
o patch 上下文所依据的 基线版本(通常对应 patch 生成时「目标分支的 parent / 上下文 commit」)
a 当前 HEAD 的 tree
b 应用 patch 之后 期望达到的内容(由 patch hunk 反推,不是某个已有 commit 的 tree)

与 cherry-pick 类似:基线 o 由 patch 的上下文决定,不是 merge-base。三路合并引擎仍可能参与(取决于是否能干净应用)。

4.5 对照表

操作 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 可选)

5. 合并过程:单边、双边与冲突检测

process_entry 中,Git 对每个路径(文件)先做 变更分类,再决定是否进入 handle_content_merge(merge / rebase / cherry-pick 笔记均跟踪到此逻辑)。

5.1 单边修改

仅 a 或仅 b 相对 o 改了该路径(新增、删除、修改只在一侧发生):

  • 直接采用有改动那一侧的版本;
  • 不进入 完整三路内容合并,不会 报冲突。

rebase 笔记中「两个分支改不同文件」的用例,大量走这条快速路径。

5.2 双边修改 → 三路合并

两侧都相对 o 改了同一路径时,进入 handle_content_mergemerge_3wayxdl_do_merge

  1. **xdl_do_diff(o, a)** → 改动块链表 xscr1(每个块记录相对 o 的行号范围与内容);
  2. **xdl_do_diff(o, b)**xscr2
  3. 相对 o 的变更行号 排序,逐块比较:
  • 只在一边出现的改动 → 直接应用到结果(「非重叠的双边修改」);
  • 两边都改了同一块 → 比较是否 完全一致(起始行、范围、内容):
    • 一致 → 自动合并,不冲突;
    • 不一致 → 报冲突,写 diff3 标记,等人肉。

5.3 Git 的「冲突」定义 vs 人的直觉

Git 判定冲突的依据是:相对同一基线 o,a 与 b 是否在同一个 diff 块上做了不同修改

不会 因为「合并后文件里两段插入挨得近、看起来该冲突」就必然报冲突。典型反例(三篇 PDF 的核心案例):

  • o 某行附近,a 在 行 2 后 插入两行,b 在 行 1 后 插入两行;
  • 相对 o 的 diff 块 行号区间不重叠,Git 视为 两处独立插入自动合并,结果里 两段都保留
  • 若两段逻辑相似,就会出现 代码重复,但 Git 不认为这是冲突

这是 设计如此,不是实现 bug:Git 倾向于 启发式、自动化 减少冲突,而不是「有一点并行修改就停下来」。

5.4 merge 与 cherry-pick 在「停下来」上的差异

合并 算法 相同,但 工作流 略有不同(merge 笔记第 4 节):例如自定义 merge driver 返回冲突时,git mergegit cherry-pick 对工作区 / 索引中已合并结果的保留行为可能不一致。工程上应 以是否出现 conflict markers、是否处于 sequencer 状态为准,不能假设「merge 成功了就一定没问题」。


6. 常见事故:合丢、重复、ABA

6.1 代码「合丢」

常见原因 不是 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 改写历史 多步合入压成一步,事后难以审计

6.2 代码重复

本文档与 PDF 中最具冲击力的案例:两分支在 相对 o 的不同行位置 插入相似逻辑 → Git 不报冲突 → 合并结果 两段并存。merge、cherry-pick、rebase 均可出现,因为底层是同一套 xdl_do_merge

6.3 ABA 问题

场景:多线维护时,分支 A 做了修改 X,分支 B 做了修改 Y 又 改回 类似 X 的旧状态;三路合并 只看 o、a、b 三个快照不考虑时间先后。合并结果可能取 B 侧快照,表现为 「改过的又被改回去」(ABA 指中间状态在结果中消失)。

根因:多线修改 + 对 Git 快照合并语义理解不足;不是 cherry-pick 独有,merge 同样会有。


7. 工程实践:如何避免

7.1 流程与分支策略

  • 减少多线并行改同一模块:主干开发、短分支、频繁集成到集成分支,避免「攒几个月一次 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。

7.2 验证手段

  • 单元 / 集成 / QA 测试(merge 笔记第 4 节)。
  • 对关键合入:除默认 git show 外,主动看相对 ob 的差异(git diff o..Cgit diff b..C 等)。
  • 团队规范:冲突解决后 必须 打开文件确认,不能仅 git add 了事。

7.3 启发式冲突检测 vs 激进冲突检测

Git 默认是 启发式 / 自动化 一端:尽量合并,只在 diff 块级「真不一致」时报冲突。

策略 行为 优点 缺点
默认(启发式) 按 o 的 diff 块判冲突;独立插入可并存 冲突少,日常流畅 重复代码、静默语义冲突可能溜过去
激进(自定义 merge driver) 例如:只要 a、b 相对 o 改过 同一文件 就强制冲突(merge 笔记 merge-driver.sh demo) 多线修改必过人眼 误报多、合并成本高;与 merge / cherry-pick 行为细节可能不完全一致

如何平衡(PDF 结论):

  1. 默认信任 Git 启发式,靠 分支策略 + review + 测试 兜底(大多数团队的主路径)。
  2. 核心模块 / 高频多线冲突路径,在 .gitattributes 上对特定路径启用 自定义 merge driver,做「同文件双边修改即报警」类保护。
  3. 激进 driver 不能替代 流程:它不知道业务语义,只能缩小「完全无冲突标记的静默合并」窗口。
  4. Cherry-pick 场景代码量通常较小,人工 review 该 commit 文件列表 往往比全仓库激进 driver 更划算。

参考 demo:merge-driver.sh.gitattributes 配置见 merge / cherry-pick 笔记及 git_merge_driver.tgz

8. 本文总结

本文围绕一个核心事实展开:Git 合入的本质是三路归并(o / a / b 三个快照),而不是按提交顺序拼接 diffmergecherry-pickrebaseapply 在内容层最终都依赖同一套三路合并引擎,关键差异主要在 o / a / b 的选取方式。因此,理解「这次操作的基线 o 是谁」比记忆命令表面行为更重要。

在机制层面,Git 的冲突检测是 相对 o 的 diff 块是否发生不一致,不是「两边都改了就一定冲突」。这解释了为何实践中会出现“无冲突但重复代码”“看起来没问题却语义被覆盖(含 ABA)”等事故:它们常常不是 Git 随机失效,而是启发式自动合并与业务语义之间的天然张力。

落到工程实践,结论是:

  • 流程上,优先主干开发、短分支、频繁集成,降低多线长期分叉;
  • 操作上,把冲突解决和 cherry-pick/rebase 后 review 当成必做环节,不以“命令成功”代替“语义正确”;
  • 保障上,用测试与审计兜底,必要时仅对核心路径启用更激进的自定义 merge driver。

9. 相关文档