需要什么样的 Git 多人协作方式?

看一些成熟开源项目的 Git 历史时,经常会看到一条很干净的线:

开源项目里很干净的线性历史

主干上的每个提交都像一次完整的变化。它不太关心开发者中途修了几次 bug、改了几次 lint、补了几次测试。

再看一些日常项目,历史经常会乱很多:

  • 一堆 Merge branch ... 之类的信息。
  • 一个需求里混着很多 fixupdatetest临时提交
  • 不同需求的提交交叉在一起,想回滚一个需求时很难判断到底要撤哪些提交。
  • 图形化历史里分叉很多,看起来不像代码演进记录,更像开发过程录像。

例如下面这种情况,已经隐藏了一些不方便公开的信息,但仍然能看出线很乱:

多人项目里混乱的 Git 历史

当时想解决的其实不是「怎么把 Git 图画得好看」,而是两个更实际的问题:

  • 主干上能不能尽量只留下有意义的需求提交。
  • 出问题时,能不能快速看出哪次合入带来了变化,必要时也能回滚。

要讲清这件事,还是得先从 commit 和 branch 讲起。

提交的本质

在 Git 里,每一个 commit 都会保存几类信息:

  • 本次提交对应的文件快照,也就是 tree。
  • 父提交的 sha。
  • 作者和提交者信息。
  • 提交时间。
  • 提交 message。

其中最影响历史形状的是「父提交」。

一个普通提交通常只有一个父提交,所以它们会串成一条链:

A -> B -> C -> D

合并提交会有多个父提交,所以历史会出现分叉和汇合:

A -> B -> C -> D
      \       \
       E -> F  H
             \
              G

Git 历史本来就不是天然的一条直线,它可以是一张图。

想要直线历史,是团队在协作上的一种选择:主干用来记录代码怎么演进,不用来完整保存每个人在本地怎么试错。

分支的本质

分支本质上是指向某个提交的指针。

假设当前只有一个 master 分支,已经有三个提交:

A -> B -> C

这时 master 指向 C

A -> B -> C
          ↑
        master

master 新建一个 feature 分支时,featuremaster 会先指向同一个提交:

          feature
          ↓
A -> B -> C
          ↑
        master

然后你在 feature 上继续开发:

               feature
               ↓
A -> B -> C -> D
          ↑
        master

如果此时别人也往 master 上合入了新提交,历史就分叉了:

               feature
               ↓
A -> B -> C -> D
          \
           E -> F
                ↑
              master

多人协作时,大家本来就不是站在同一条线的同一个时间点上写代码。分叉很正常。

后面要决定的是:这些分叉要不要原样出现在主干历史里。

rebase 和 merge 解决的是同一个问题

rebasemerge 都可以把一个分支上的变化同步到另一个分支上。

先看一张对照图,后面再用线条图拆开讲:

merge 和 rebase 的分支变化对照

比如现在是这样:

  commit B    commit C      commit D
A -------> AB --------> ABC -------> ABCD   master
            \
             \ commit E     commit F
              --------> ABE -------> ABEF   feature

feature 是自己的开发分支。现在 master 上已经多了 CD,需要把这些变化同步到 feature 上。

merge 会保留分叉和合并动作

如果在 feature 上执行:

git merge master

Git 会把 master 的变化合进来。

当两个分支已经各自有新提交时,Git 通常会创建一个新的合并提交:

  commit B      commit C     commit D
A -------> AB ---------> ABC -------> ABCD   master
             \                            \
              \ commit E     commit F      Merge branch 'master' into 'feature'
               --------> ABE -------> ABEF ---------------------------------> ABCDEF  feature

这个合并提交有意义,它记录了「这里把 master 合进了 feature」。

只是团队里每个人都这样同步主干时,历史里会多出很多和业务变化关系不大的合并动作。

rebase 会把自己的提交挪到最新主干之后

如果在 feature 上执行:

git rebase master

Git 会把 feature 上独有的提交拿出来,放到 master 当前最新提交后面重新播放:

  commit B    commit C     commit D
A -------> AB -------> ABC -------> ABCD   master
                                        \
                                         \ commit E       commit F
                                          -------> ABCDE -------> ABCDEF   feature

这样做之后,feature 看起来就像是从最新 master 上切出来的一样。

在自己的 feature 分支上同步主干时,更习惯用 rebase。它能少留一些和业务无关的分叉。

不过 rebase 会改写提交历史。自己独立开发的 feature 分支可以 rebase;已经被多人共同基于它继续开发的共享分支,就不要随便 rebase。

merge 的几个参数

merge 不是只有一种行为。

有时两个分支没有真正分叉,只是一个分支落后于另一个分支:

  commit B      commit C     commit D
A -------> AB ---------> ABC -------> ABCD   master
            \
             \
              feature

这时在 feature 上执行 git merge master,Git 只需要把 feature 指针移动到 master 指向的位置:

  commit B    commit C      commit D
A -------> AB --------> ABC -------> ABCD   feature & master

这叫 Fast Forward。

几个常见参数会影响 merge 行为。

--no-ff

即使可以 Fast Forward,也强制生成一个 merge commit。

它的好处是保留「这里发生过一次合并」的信息。它的代价是主干上会有更多合并提交。

想保留分支边界时可以用它;想让主干尽量线性时,它就不太合适。

--ff-only

只允许 Fast Forward。

如果不能 Fast Forward,命令直接失败。

这个参数常用于保护主干。它表达的是:这里不要创建新的合并提交,主干只能向前快进。

--squash

把一个分支上的所有变化压成一次工作区变更,再由你手动提交。

比如三个需求分支分别有很多中间提交:

------------------------------------------------------------------------ master
\   \   \
 \   \   -----> A1 ------------------------------> A2 ---> A3 ---------- branch-a
  \   \
   \   ---------------------- B1 ---------> B2 -----------------> B3 --- branch-b
    \
     --> C1 ---------> C2 ---------> C3 -------------------------------- branch-c

如果把这些中间提交原样合进主干,主干可能变成:

-------> C1 --> A1 --> C2 --> B1 --> C3 --> B2 --> A2 --> A3 --> B3 --- master

需求 A 的提交被拆散在主干里。要回滚 A,需要找到 A1A2A3,还要确认它们和别的需求有没有耦合。

如果每个需求合入前先 squash,主干会更像这样:

--------------------> B -------------> C -----------> A ---------------- master

一个需求对应一个主干 commit。

这会丢掉开发过程里的细碎提交,但主干更关心「代码发生了哪些有意义的变化」,而不是「开发者中途保存过几次进度」。

--no-commit

让 merge 完成后先停在暂存区,不自动创建提交。

想先检查合并结果、补一些调整,再手动提交时,可以用它。

-m

在需要生成 merge commit 时,指定提交信息。

它可以避免默认的 Merge branch 'xxx' into 'xxx'。如果团队本来就在追求线性历史,更关键的还是从流程上减少这类 merge commit。

--no-verify

跳过 pre-commitcommit-msg 这类 Git Hook。

这个参数不是协作优化手段。只有在明确知道 hook 本身有问题,或者有其他验证兜底时,才适合临时用一下。

两种协作模型:Fork + Pull Request 与同仓库分支 + Merge Request

开源 GitHub 项目常见的是 Fork + Pull Request。公司里的 GitLab 项目,更常见的是同一个仓库切 feature 分支,然后发 Merge Request。

这两种流程长得不一样,但最后想拿到的主干结果可以一样:合入后主干是一条线,一个需求变成一个清楚的提交。

差别主要在权限边界。

GitHub 开源项目:Fork 隔离写权限

开源项目里,大多数贡献者不能直接往上游仓库 push。他们通常先 fork 一份自己的远端仓库,再 clone 自己的 fork,在本地分支上开发,最后向上游仓库发 Pull Request。

                 fork
upstream/master -------> origin/master
   上游仓库              你的 fork 仓库
   通常只读              你可以写
      │                       │
      │ pull upstream         │ push feature
      ▼                       ▼
              本地仓库
        feature ── 开发 ── rebase upstream/master

这个模型里,隔离主要靠「仓库」。你可以随便改自己的 fork,但不能直接污染上游主干。维护者通过 PR review 决定要不要合入。

合入时,开源项目常用 squash merge 或 rebase merge。贡献者本地有多少中间提交不重要,进入上游主干时可以整理成一次干净提交。

公司项目:同仓库分支隔离变更

公司内部项目里,很多人对同一个仓库都有写权限。大家通常直接从同一个 origin/master 切 feature 分支:

              origin (同一个仓库)
master  A ── B ── C
          │    │    │
          │    │    └── feat-c ── MR
          │    └─────── feat-b ── MR
          └──────────── feat-a ── MR

这个模型里,隔离主要靠「分支」和「保护分支」。开发者可以 push 自己的 feature 分支,但不能直接 push master。所有变化通过 MR 进入主干。

如果 MR 合入时也使用 squash merge,主干仍然可以保持干净:

A ── B ── C ── feature-a ── feature-b ── feature-c   master

Fork + PR 和同仓库 MR 的区别不在于谁更高级,而在于隔离方式:

  • Fork + PR 用仓库边界隔离权限,更适合陌生贡献者很多的开源项目。
  • 同仓库分支 + MR 用分支和保护规则隔离权限,更适合内部团队。
  • 两者都可以用 rebase 同步主干,用 squash merge 控制进入主干的提交形态。

日常项目里怎么做

如果想让主干像开源项目那样清楚,会把流程定得简单一点。

所有需求都从最新主干切 feature 分支。

git checkout master
git pull --ff-only
git checkout -b feat/user-profile

开发过程中可以随手提交,但这些提交 message 不必都进入主干。

本地分支上的提交是开发过程记录,主干上的提交是项目演进记录。这两者可以不一样。

同步主干时,自己的 feature 分支优先用 rebase。

git fetch origin
git rebase origin/master

如果已经把 feature 分支 push 到远端,rebase 后需要更新远端分支:

git push --force-with-lease

这里用 --force-with-lease,不要直接用 --force。它会在远端分支没有被别人更新过时才覆盖,安全一点。

主干合入用 squash merge。

PR/MR 里可以有很多中间提交,但合入时压成一个提交。

合并提交信息要像发布日志一样清楚,例如:

feat: add user profile page
fix: prevent duplicate payment callbacks
refactor: simplify order status mapping

不要让主干上出现:

fix
update
wip
改一下
Merge branch 'master' into feat/user-profile

主干要保护起来。

最少做到这些:

  • 禁止直接 push 主干。
  • 必须通过 PR/MR。
  • 合入前要求 review 或 CI 通过。
  • 合入策略尽量固定,不要有人 merge commit,有人 squash,有人 rebase merge。

流程不是为了显得正式,而是为了减少每个人临场选择的成本。

也不用什么都强行拉直

线性历史很好,但不是所有场景都要追求到极致。

如果团队需要保留长期分支的合并边界,比如 release 分支、hotfix 分支、多个版本线并行维护,merge commit 有时反而有价值。

如果一个 feature 分支是多人长期共用分支,就不要频繁 rebase 它。因为 rebase 会改写历史,别人基于旧提交继续开发时会很痛苦。

如果一个 MR 里包含多个彼此独立的需求,不应该靠 squash 把它们硬压成一个提交。更好的做法是拆 MR,让每个合入单元本身就清楚。

重点不是迷信某个命令,而是先问清楚:主干历史到底要服务谁。

对大多数日常业务项目来说,主干历史最常见的读者是未来排查问题的人。他关心的是:

  • 这个功能什么时候进来的。
  • 这次合入改了什么。
  • 出问题时能不能准确回滚。
  • 能不能快速看懂版本演进。

按这个目标看,「feature 分支 rebase 同步主干,PR/MR squash merge 进入主干」是比较省心的默认方案。

回到开头那张图

开源项目的历史之所以干净,不只是因为维护者用了某个 Git 命令。它背后是一整套约束:

  • 贡献者不能直接改主干。
  • 变化必须经过 PR。
  • 合入前先 review 和 CI。
  • 合入时把中间过程整理成一个清楚的提交。

公司项目即使不是 Fork + PR,也可以学这套思路。同一个仓库、同一批开发者、GitLab 的 MR,一样可以做到:

主干只记录项目演进,不记录每个人的手忙脚乱。

这就是更认可的日常 Git 协作方式:主干尽量记录项目怎么变好,不记录每个人中途怎么手忙脚乱。