需要什么样的 Git 多人协作方式?
看一些成熟开源项目的 Git 历史时,经常会看到一条很干净的线:

主干上的每个提交都像一次完整的变化。它不太关心开发者中途修了几次 bug、改了几次 lint、补了几次测试。
再看一些日常项目,历史经常会乱很多:
- 一堆
Merge branch ...之类的信息。 - 一个需求里混着很多
fix、update、test、临时提交。 - 不同需求的提交交叉在一起,想回滚一个需求时很难判断到底要撤哪些提交。
- 图形化历史里分叉很多,看起来不像代码演进记录,更像开发过程录像。
例如下面这种情况,已经隐藏了一些不方便公开的信息,但仍然能看出线很乱:

当时想解决的其实不是「怎么把 Git 图画得好看」,而是两个更实际的问题:
- 主干上能不能尽量只留下有意义的需求提交。
- 出问题时,能不能快速看出哪次合入带来了变化,必要时也能回滚。
要讲清这件事,还是得先从 commit 和 branch 讲起。
提交的本质
在 Git 里,每一个 commit 都会保存几类信息:
- 本次提交对应的文件快照,也就是 tree。
- 父提交的 sha。
- 作者和提交者信息。
- 提交时间。
- 提交 message。
其中最影响历史形状的是「父提交」。
一个普通提交通常只有一个父提交,所以它们会串成一条链:
A -> B -> C -> D合并提交会有多个父提交,所以历史会出现分叉和汇合:
A -> B -> C -> D
\ \
E -> F H
\
GGit 历史本来就不是天然的一条直线,它可以是一张图。
想要直线历史,是团队在协作上的一种选择:主干用来记录代码怎么演进,不用来完整保存每个人在本地怎么试错。
分支的本质
分支本质上是指向某个提交的指针。
假设当前只有一个 master 分支,已经有三个提交:
A -> B -> C这时 master 指向 C:
A -> B -> C
↑
master从 master 新建一个 feature 分支时,feature 和 master 会先指向同一个提交:
feature
↓
A -> B -> C
↑
master然后你在 feature 上继续开发:
feature
↓
A -> B -> C -> D
↑
master如果此时别人也往 master 上合入了新提交,历史就分叉了:
feature
↓
A -> B -> C -> D
\
E -> F
↑
master多人协作时,大家本来就不是站在同一条线的同一个时间点上写代码。分叉很正常。
后面要决定的是:这些分叉要不要原样出现在主干历史里。
rebase 和 merge 解决的是同一个问题
rebase 和 merge 都可以把一个分支上的变化同步到另一个分支上。
先看一张对照图,后面再用线条图拆开讲:

比如现在是这样:
commit B commit C commit D
A -------> AB --------> ABC -------> ABCD master
\
\ commit E commit F
--------> ABE -------> ABEF featurefeature 是自己的开发分支。现在 master 上已经多了 C 和 D,需要把这些变化同步到 feature 上。
merge 会保留分叉和合并动作
如果在 feature 上执行:
git merge masterGit 会把 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 masterGit 会把 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,需要找到 A1、A2、A3,还要确认它们和别的需求有没有耦合。
如果每个需求合入前先 squash,主干会更像这样:
--------------------> B -------------> C -----------> A ---------------- master一个需求对应一个主干 commit。
这会丢掉开发过程里的细碎提交,但主干更关心「代码发生了哪些有意义的变化」,而不是「开发者中途保存过几次进度」。
--no-commit
让 merge 完成后先停在暂存区,不自动创建提交。
想先检查合并结果、补一些调整,再手动提交时,可以用它。
-m
在需要生成 merge commit 时,指定提交信息。
它可以避免默认的 Merge branch 'xxx' into 'xxx'。如果团队本来就在追求线性历史,更关键的还是从流程上减少这类 merge commit。
--no-verify
跳过 pre-commit、commit-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 masterFork + 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 协作方式:主干尽量记录项目怎么变好,不记录每个人中途怎么手忙脚乱。