Yu Guo @ yuguo.im

git merge 的三种链路形态:--ff / --no-ff / --squash

Aug 14 · 8min

这三个选项决定的不是「代码怎么合」,而是合完之后提交链长什么样 —— 分支指针怎么动、有没有新提交、新提交有几个父。

main 上的提交feature 上的提交merge commit(双父)squash commit(单父)

起点:统一的初始状态

main 停在 C 之后没再动;feature 从 C 拉出,多了 D、E。因为 C 是 E 的祖先、两边没有分叉,所以此时「快进(fast-forward)」是可能的。

ABCDE← main← feature共同祖先 CHEAD 在 main,C 是 E 的祖先,两边没有分叉 —— 快进是可能的

--ff:只挪指针,不留痕迹(默认)

既然 main 上没有新提交,git 根本不需要「合」任何东西:把 main 这个指针直接快进到 E 就行。提交总数不变,历史是一条直线。

ABCDEmain 指针快进(没有新提交)main / feature两个名字指向同一个提交
  • 前提:能快进才快进。一旦 main 上也有了新提交(出现分叉),--ff 自动失效,git 会退化成造一个 merge commit。
  • 好处:历史干净、线性,git log 一条线读下来。
  • 代价:事后完全看不出「这里曾经有一个 feature 分支」,D、E 混进主干序列里;想整体回滚只能一个个 revert。
  • 想强制线性git merge --ff-only —— 不能快进就直接报错退出,绝不偷偷造合并提交。CI 与主干保护常用这个。

--no-ff:即使能快进也要造一个合并提交

强制生成合并提交 M。M 的特点是有两个父:第一父 C(主干来路)、第二父 E(被合进来的分支)。提交链因此出现一个「菱形」。

ABCDEM← main(M)feature 仍指向 E第一父 C第二父 E
  • 好处:分支边界永久保留。git log --graph --first-parent 看主干时,一个功能就是一个节点。
  • 整体回滚很爽git revert -m 1 M 一条命令撤掉整个功能。
  • 代价:历史图变成「铁路网」,多出很多信息量为零的 Merge branch 'feature' 提交。
  • 默认场景:合并 annotated / signed tag 时 git 本来就用 --no-ff(因为要把 tag 信息记进合并提交)。想让所有合并都这样:git config --global merge.ff false

Note

D、E 两个提交原样保留在 main 的历史里,作者、时间、单个提交的 diff 全都在,git bisect 能定位到 D 或 E。

--squash:只要结果,不要链路

git 把 D+E 的合并结果铺到工作区和索引里,然后就停在这儿:不提交、不移动 HEAD、不写 MERGE_HEAD。你自己 git commit,得到一个普通的单父提交 S。

ABCSDE← main(S,父只有 C)← feature只复制内容,不建立父子关系S 的内容 = D+E 的合并结果;D、E 本身没有进入 main 的历史
git merge --squash feature   # 改动已在工作区 + 索引,尚未提交
git status                   # 看到一堆 staged 改动
git commit -m "feat: 用户导入功能"   # 这才产生 S
  • 好处:主干只留一个语义完整的提交,分支里 wipfix typo再改一下 全部消失。
  • 代价 1:中间提交历史丢了 —— bisect 粒度变粗,分支内多人协作的作者信息被并成提交者一人。
  • 代价 2(最容易踩):因为没有父子关系,git 不知道 feature 已经合过了git branch --merged 不会列出它;之后再从这个分支合过来,同样的改动会被重新算一遍,极易冲突。所以 squash 之后应当删掉该分支,不要继续在上面开发。
  • --no-squash 用来覆盖前面的 --squash(比如别名、脚本或配置里已经加了 --squash),恢复「真合并并直接提交」的行为。

Warning

--squash--ff / --no-ff 不是同一维度的东西。前两者决定「要不要造合并提交」,--squash 决定「要不要保留分支链路」—— 它产生的 S 是个普通提交,不是合并提交。

一张表对齐三者

--ff(默认)--no-ff--squash
新提交1 个合并提交 M1 个普通提交 S(手动 commit)
新提交的父2 个(C 和 E)1 个(C)
HEAD / 分支指针快进到 E移到 M不动,等你提交
D、E 是否进主干历史是(原样)是(原样)否(只有内容)
历史形状直线菱形 / 分叉图直线
git 认为已合并
整体回滚逐个 revertrevert -m 1 Mrevert S
有分叉时失效 → 自动真合并照常合并照常压扁

怎么选

  • 短小的个人分支 / 只有一两个提交--ff(默认)即可,别给历史加噪音。
  • 有意义的功能块,需要可追溯与整体回滚(release / hotfix / 大特性)--no-ff
  • 分支里提交很碎、过程不值得留档(典型的 PR 合主干)--squash,合完删分支。GitHub 的 “Squash and merge” 就是它。
  • 团队想要纯线性历史:合并前先 git rebase main,再 git merge --ff-only
git config merge.ff false          # 本仓库所有 merge 都造合并提交
git config --global pull.ff only   # pull 不许自动造合并提交
> comment on bluesky / twitter
>
CC BY-NC-SA 4.0 2021-PRESENT © Yu Guo