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

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

当时真正要解决的是两个实际问题:
- 主干上能不能尽量只留下有意义的需求提交。
- 出问题时,能不能快速看出哪次合入带来了变化,必要时也能回滚。
要讲清这件事,先得承认一点:Git 历史不是天然的一条直线。线性历史是团队在协作流程上做出来的选择。
提交的本质
在 Git 里,每一个 commit 都会保存几类信息:
- 本次提交对应的文件快照,也就是 tree。
- 父提交的 sha。
- 作者和提交者信息。
- 提交时间。
- 提交 message。
其中最影响历史形状的是「父提交」。
一个普通提交通常只有一个父提交,所以它们会连成一条链:

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

父提交之间的关系组成了历史图。主干要不要保留每次分叉和合并,需要由团队的合入方式决定。
分支的本质
分支是指向某个提交的指针。
假设当前只有一个 master 分支,已经有三个提交,master 指向最后的 C:

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

然后你在 feature 上继续开发:

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

多人协作时,大家本来就不是站在同一条线的同一个时间点上写代码。分叉很正常。
后面要决定的是:这些分叉要不要原样出现在主干历史里。
rebase 和 merge 解决的是同一个问题
rebase 和 merge 都可以把一个分支上的变化同步到另一个分支上。
先看一张对照图,后面再用线条图拆开讲:

比如现在是这样:

feature 是自己的开发分支。现在 master 上已经多了 C 和 D,需要把这些变化同步到 feature 上。
merge 会保留分叉和合并动作
如果在 feature 上执行:
git merge masterGit 会把 master 的变化合进来。
两个分支已经各自有新提交时,Git 通常会创建一个新的合并提交:

这个合并提交有意义,它记录了「这里把 master 合进了 feature」。
只是团队里每个人都这样同步主干时,历史里会多出很多和业务变化关系不大的合并动作。
rebase 会把自己的提交挪到最新主干之后
如果在 feature 上执行:
git rebase masterGit 会把 feature 上独有的提交拿出来,放到 master 当前最新提交后面重新播放:

这样做之后,feature 看起来就像是从最新 master 上切出来的一样。
在自己的 feature 分支上同步主干时,我更习惯用 rebase,避免为每次同步都留下合并提交。
不过 rebase 会改写提交历史。自己独立开发的 feature 分支可以 rebase;已经被多人共同基于它继续开发的共享分支,就不要随便 rebase。
merge 的几个参数
merge 不是只有一种行为。
有时两个分支没有真正分叉,只是一个分支落后于另一个分支:

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

这叫 Fast Forward。
几个常见参数会影响 merge 行为。
--no-ff
即使可以 Fast Forward,也强制生成一个 merge commit。
它的好处是保留「这里发生过一次合并」的信息。它的代价是主干上会有更多合并提交。
想保留分支边界时可以用它;想让主干尽量线性时,它就不太合适。
--ff-only
只允许 Fast Forward。
如果不能 Fast Forward,命令直接失败。
这个参数常用于保护主干。它表达的是:这里不要创建新的合并提交,主干只能向前快进。
--squash
把一个分支上的所有变化压成一次工作区变更,再由你手动提交。
比如三个需求分支分别有很多中间提交:

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

需求 A 的提交被拆散在主干里。要回滚 A,需要找到 A1、A2、A3,还要确认它们和别的需求有没有耦合。
如果每个需求合入前先 squash,主干会更像这样:

一个需求对应一个主干 commit,定位和撤销这次合入会方便一些。但如果后来的需求依赖它,回滚仍然需要处理依赖关系;squash 不会消除业务耦合。
这会丢掉开发过程里的细碎提交,但主干更关心「代码发生了哪些有意义的变化」,而不是「开发者中途保存过几次进度」。
--no-commit
在创建合并提交之前停下,让你先检查暂存区里的结果,再手动提交。
它不能阻止 Fast Forward,因为快进本来就不创建合并提交。如果要保证合并后先检查、再移动当前分支,需要组合 --no-ff --no-commit;工作区和暂存区仍会发生变化。Git 的 merge 文档专门说明了这个区别。
-m
在需要生成 merge commit 时,指定提交信息。
它可以避开 Git 默认生成的 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,但不能直接污染上游主干。维护者通过 PR review 决定要不要合入。
合入时,开源项目常用 squash merge 或 rebase merge。贡献者本地有多少中间提交不重要,进入上游主干时可以整理成一次干净提交。
公司项目:同仓库分支隔离变更
公司内部项目里,很多人对同一个仓库都有写权限。大家通常直接从同一个 origin/master 切 feature 分支:

这个模型里,隔离主要靠「分支」和「保护分支」。开发者可以 push 自己的 feature 分支,但不能直接 push master。所有变化通过 MR 进入主干。
如果 MR 合入时也使用 squash merge,主干仍然可以保持干净:

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这里只适用于自己维护的 feature 分支。--force-with-lease 比 --force 多了远端提交检查,但默认比较依据是本地的远端跟踪引用,不保证你已经看过远端所有变化。编辑器后台 fetch 可能更新这个依据;多人共用分支不要照搬这条命令,需要重写时应明确核对要保留的提交和预期远端 SHA。Git 的 push 文档解释了这个限制。
主干合入用 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 进入主干」是比较省心的默认方案。
我更认可这种区分:feature 分支允许试错和临时提交,主干保留经过验证、范围清楚的合入记录。它不要求团队一定使用 Fork,也不要求把所有长期分支拉直。真正需要统一的是合入单元、审核要求和历史整理方式。