如何保持 Git 提交历史是线性的
git log --graph 打开一看,分叉、合并、菱形交织成一张网,想找出「哪次提交引入了这个 Bug」几乎无从下手。而在另一些仓库里,历史是一条笔直的线,git bisect 几下就能定位问题。
这个差别不是运气,而是一整套习惯和配置的结果。本文讲清楚线性历史是什么、为什么值得追求,以及怎么从个人配置一路落地到团队规范。
一、什么是线性历史
先看两张图。这是非线性的历史——每次 git pull 和 git merge 都可能产生一个合并提交(merge commit),历史变成了网状:
1 | |
这是线性的历史——所有提交排成一条直线,每个提交只有一个父提交:
1 | |
严格定义很简单:主分支上每个提交都只有一个父提交,不存在合并提交。
为什么值得追求
git bisect真正可用。二分查找定位问题提交的前提是历史可以线性排序,网状历史下 bisect 会跳进各种半成品的中间状态。git log可读。一条线从上往下读就是项目的演进过程,不需要在脑子里维护一个图。- 回滚粒度清晰。
git revert <commit>撤销一个普通提交是确定的;撤销一个合并提交要纠结-m 1还是-m 2,还会让这条分支后续再也合不进来。 git blame更准。少一层「谁合并的」噪音,直接指向真正写这行代码的提交。- Cherry-pick 到发布分支更容易。每个提交自成一体,摘一个就是一个完整改动。
代价也要说清楚:线性历史意味着要改写提交(rebase / squash),而改写就要 force push,也就丢失了「这个分支实际是从哪个点拉出来、什么时候合回去」的真实拓扑。如果你的团队严重依赖这个信息,或者有人还不熟悉 rebase,那么先把下面第二、三节做好即可,不必强求 100% 线性。
二、第一步:让 git pull 不再制造合并提交
绝大多数无谓的合并提交,都来自这个场景:你本地提交了两个 commit,同事也推了新提交,你 git pull 一下——Git 默认执行 merge,于是历史上凭空多出一个 Merge branch 'main' of github.com:xxx。
这类提交没有任何信息量,纯属噪音。改掉它只需要一行配置:
1 | |
配置之后,git pull 等价于 git pull --rebase:Git 会先把远端的新提交取下来,再把你本地的提交一个个「重放」到最新的远端提交之上。结果是一条直线,没有合并提交。
还有一个更保守的选项,适合作为团队默认值:
1 | |
这表示 git pull 只允许快进(fast-forward)。一旦本地和远端出现分叉,pull 会直接报错停下,让你自己决定是 rebase 还是 merge,而不是默默替你合并。对新人来说这个「报错」其实是好事——它把问题暴露在早期。
三、第二步:推送前整理本地提交
线性不只是「没有分叉」,还包括「每个提交都有意义」。像 fix、再修一下、typo、真的好了 这种提交,即使排成一条直线也是垃圾。
交互式 rebase
假设当前分支相对 main 有 5 个提交:
1 | |
编辑器里会列出这 5 个提交,把每行开头的动词改掉即可:
1 | |
常用动作:
| 动作 | 缩写 | 作用 |
|---|---|---|
pick |
p |
保留该提交 |
squash |
s |
合并到上一个提交,保留并可编辑提交信息 |
fixup |
f |
合并到上一个提交,丢弃本提交的信息 |
reword |
r |
只改提交信息,不改内容 |
edit |
e |
停下来,允许修改这个提交的内容 |
drop |
d |
删除该提交 |
用 --fixup 自动化这个过程
上面手动改动词还是麻烦。更顺手的做法是:发现前面某个提交有问题时,直接打上标记:
1 | |
--autosquash 会自动把 fixup! xxx 排到目标提交后面并标记为 fixup,你直接保存退出就行。把它设为默认更省事:
1 | |
四、第三步:选一种线性的合并方式
本地干净了,还要保证合入主分支时不产生合并提交。三种可选策略:
1. Fast-forward only(最纯粹)
1 | |
只有当 main 没有新提交、可以直接快进时才成功;否则报错,你需要先在 feature 分支上 git rebase main。这是最严格也最干净的方式。
配成默认,防止手滑产生合并提交:
1 | |
注意:这个配置会让
git merge在无法快进时直接失败。真的需要合并提交时(比如合并一个长期存在的发布分支),显式加--no-ff即可。
2. Squash merge(一个 PR = 一个提交)
1 | |
把整个分支压成一个提交合进主干。主分支历史极其干净,一个提交对应一个功能,回滚时整个功能一起走。代价是分支内部的开发过程全部丢失——如果一个 PR 改了两千行,它在历史上就是一个两千行的黑盒。
适合:PR 粒度小、分支生命周期短、成员提交习惯参差不齐的团队。
3. Rebase merge(保留每个提交)
先 git rebase main 把分支重放到主干最新提交之上,再快进合并。分支里的每个提交都独立保留在主干历史上。
适合:成员会认真组织提交、每个提交自成一体的团队。
三者对比:
| 策略 | 主干历史 | 保留分支内提交 | 需要 force push |
|---|---|---|---|
| Fast-forward only | 线性 | 是 | 是(rebase 后) |
| Squash merge | 线性 | 否 | 否 |
| Rebase merge | 线性 | 是 | 是 |
| Merge commit(对照) | 网状 | 是 | 否 |
五、第四步:在平台上强制执行
个人配置管不住整个团队,最终还得靠服务端规则兜底。
GitHub
仓库 Settings → Branches → Branch protection rules,对 main 分支:
- 勾选 Require linear history——直接拒绝任何会产生合并提交的推送,这是最关键的一条;
- 在 Settings → General → Pull Requests 中,取消勾选 Allow merge commits,只保留 Allow squash merging 和/或 Allow rebase merging;
- 配合 Require a pull request before merging,禁止直接向
main推送。
GitLab
项目 Settings → Merge requests → Merge method,选择:
- Fast-forward merge:不允许合并提交,MR 无法快进时要求提交者先 rebase(GitLab 界面上有 Rebase 按钮);
- 同时勾选 Squash commits when merging 的默认行为,视团队习惯而定。
自建仓库(如 Gitea、裸仓库)
服务端可以用 pre-receive 钩子拒绝带合并提交的推送。核心判断是:新推上来的提交里,有没有父提交数量大于 1 的:
1 | |
放到裸仓库的 hooks/pre-receive 并 chmod +x 即可生效。
六、rebase 的三条安全准则
1. 黄金法则:不要 rebase 别人在用的分支
rebase 会改写提交——即使内容一模一样,commit hash 也是全新的。如果这个分支已经推到远端且有人基于它工作,你 rebase 后 force push,对方再 pull 时就会出现两套并存的提交,历史彻底乱掉。
判断标准很简单:这个分支只有我一个人在用吗? 是,随便 rebase;不是,先在群里说一声,或者干脆用 merge。
2. force push 一律用 --force-with-lease
1 | |
--force-with-lease 会检查远端分支是否还是你本地记录的那个位置。如果别人在这期间推了新东西,推送会被拒绝,而不是把对方的工作直接删掉。把它设成别名:
1 | |
3. 让 Git 记住冲突的解法
rebase 一串提交时,同一个冲突可能反复出现。开启 rerere(reuse recorded resolution),Git 会记住你解决过的冲突,下次自动套用:
1 | |
长期看这个配置能省下大量重复劳动,尤其是在需要频繁 rebase 长分支的场景。
七、出事了怎么救
rebase 中途发现搞错了,随时可以退回原状:
1 | |
已经 rebase 完成甚至 push 了才发现不对,用 reflog 找回原来的位置:
1 | |
reflog 记录了 HEAD 的每一次移动,默认保留 90 天。只要提交过,就基本找得回来——这是敢于放心使用 rebase 的底气。
另外,Git 在 rebase / merge / reset 前会自动把原位置存进 ORIG_HEAD,紧急情况下可以直接:
1 | |
八、一份可以直接抄的配置
1 | |
配置完之后,日常流程就固定成这样:
1 | |
九、小结
保持线性历史,本质上是四层防线:
- 配置层:
pull.rebase+merge.ff only,从源头杜绝无意义的合并提交; - 本地层:推送前用
rebase -i --autosquash把提交整理成有意义的单元; - 合并层:统一用 fast-forward、squash 或 rebase 中的一种合入主干;
- 平台层:分支保护规则或
pre-receive钩子做最终兜底。
四层里最有性价比的是第一层和第四层——一行配置加一个勾选,就能挡掉 90% 的问题。至于是否要严格到每个提交都精心打磨,那是团队根据实际情况做的取舍,不必教条。
最后再强调一次那条黄金法则:只 rebase 自己的分支,force push 永远带 --force-with-lease。守住这两条,rebase 就是安全的。