git log --graph 中清晰的提交线图方便 code review 和回退定位。两条互补的策略:git pull --rebase 把无意义的分叉抹平,git merge --no-ff 把一组相关提交刻意保留成一个"鼓包"。看起来矛盾,目的一致 —— 让线图表达意图,而不是过程。

pull –rebase:抹掉无意义的分叉

默认 pull 出了什么

本地和远端各自有了新提交,pull 时若不是 fast-forward,Git 默认 merge,会多出一个没有任何代码意义的"Merge branch ‘main’ of …“提交:

170317-git-pull-rebase-and-merge-no-ff-to-keep-clear-commit-graph-01

假设 pull 前:

                 A---B---C  origin/main
                /
           D---E---F---G  main

git pull(默认 merge)后多出 H:

                 A---B---C
                /         \
           D---E---F---G---H  main, origin/main

git pull --rebase 后线图变平,F G 被基于 C 重放成 F’ G’:

           D---E---A---B---C---F'---G'  main, origin/main

写进配置,不要每次记得加

git config --global pull.rebase true       # pull 默认 rebase
git config --global pull.ff only           # 不能 fast-forward 就拒绝(避免误产生 merge commit)
git config --global rebase.autostash true  # rebase 前自动 stash 未提交修改

Git 2.27+ 起,未显式配置 pull.rebase 时每次 pull 会警告,等于在催你做一次明确选择。

什么时候用 rebase

  • 冲突预期很多 —— rebase 每个被重放的 commit 都可能触发冲突,merge 只触发一次。
  • 已 push 出去的本地分支 —— rebase 会改写 SHA,强推会把基于它工作的同事埋掉。
  • 想保留"这一组改动是同一时间合入"的语境 —— 那是 --no-ff 的活。

merge –no-ff:把一组提交保留成"鼓包”

git merge --no-ff <branch> 在可以 fast-forward 时也强制生成一个 merge commit,把整条 feature 分支保留为可识别的形状。

先看 ff 与 no-ff 在线图上的差别

合并前(feature 从 A 拉出,main 期间没动 —— ff 的前提):

A ─ main
 \
  B ─ C ─ feature

git merge feature(默认,能 ff 就 ff):

A ─ B ─ C ─ main, feature       ← main 指针滑到 C,无新 commit,feature 拓扑消失

git merge --no-ff feature(强制不 ff):

A ─────────── M ─ main          ← M 是新 merge commit,两个 parent: A、C
 \           /                     git log --graph 能看到 feature 这一段"鼓包"
  B ─ C ─────

170317-git-pull-rebase-and-merge-no-ff-to-keep-clear-commit-graph-02

--no-ff 只在 ff 可行时才有意义。main 在 feature 期间也有新提交(拓扑已经分叉)时,Git 无论如何都会生成 merge commit,加不加 --no-ff 一样。

鼓包带来的两个隐性价值

  1. 语义边界。merge commit 的 message(默认 Merge branch 'feature/xxx')天然标注"以下这串提交都属于同一个特性"。code review 时一眼分组;git log --merges 直接列出所有 feature 合并点做 release notes;git bisect 区分"哪个特性引入了 bug"也更精确。
  2. 整体回滚git revert -m 1 <merge-sha> 一条命令撤回整个特性,-m 1 指定保留第一个 parent(main 侧),feature 侧的所有改动整体反转。比一笔笔 git revert 简单一个量级。

强制全局 no-ff(团队约定后再开):

git config --global merge.ff false       # 自己手动 merge 时强制不 ff
git config --global merge.ff only        # 反过来:只允许 ff,禁止生成 merge commit(线性派)

你们项目可能本来就是 no-ff

走 PR / MR 流程的团队,服务端的 merge 策略比本地 merge.ff 更优先。常见平台默认值:

平台 / 选项等价行为
GitLab Merge method = Merge commit(默认)--no-ff
GitLab Merge method = Fast-forward mergeff(要求先 rebase)
GitHub PR = Create a merge commit(默认)--no-ff
GitHub PR = Rebase and mergerebase + ff
GitHub / GitLab Squash整 PR 压成 1 commit + ff

判断你们用哪种:git log --graph --oneline main 看一眼。一堆 Merge branch ... 鼓包 = no-ff;纯一条直线 = ff(多半还 squash 了)。

合并前先 rebase,让线图更干净

将 feature 合入 dev 前的标准动作:先把 feature 重排到最新 dev 之后,再 --no-ff merge,分叉点会上移,鼓包紧凑:

git fetch origin
git switch feature
git rebase origin/dev          # 把 feature 重放到最新 dev 之后
git switch dev && git pull
git merge --no-ff feature      # 鼓包合入

判断 feature 是否落后于 dev:git log feature..dev 列出"dev 有而 feature 没有"的提交,有输出 = feature 落后,需要先 rebase;无输出可以直接合。

170317-git-pull-rebase-and-merge-no-ff-to-keep-clear-commit-graph-03

--rebase=merges:rebase 时保留 merge 鼓包

普通 rebase 会把 merge commit 抹平。如果一段历史里有重要的 --no-ff 鼓包要保留,用:

git rebase --rebase-merges origin/main   # 旧名 --preserve-merges 已废弃

平台 squash merge 改变了什么

GitHub / GitLab / Bitbucket 的 “Squash and merge” 把整个 PR 压成一条 commit 合入主干。结果是:

  • 主干线图天生干净,本地的 --rebase / --no-ff 都变得次要。
  • 但本地分支 push 后回到 GitHub 看到的还是原始 commit,PR 描述应当承担起"语义边界"的责任 —— 这部分原本是 merge commit 在干的事。
  • 整体回滚也变成了 git revert <squashed-sha>,一条命令同样能撤。

结论:用 PR squash merge 的团队,本地策略可以放宽到只关心 pull.rebase = true;自托管直接 push 到主干的团队,--no-ff + git rebase --rebase-merges 的组合仍然是首选。

决策表

场景命令目的
日常 pullgit pull --rebase抹平无意义分叉
合入 feature 分支git merge --no-ff feature保留语义鼓包
合并前同步基git rebase origin/dev让鼓包紧凑
rebase 含 merge 的历史git rebase --rebase-merges保留已有鼓包
团队走 PR squash只配 pull.rebase=true平台已处理线图

References

– EOF –