如何保持 Git 提交历史是线性的

git log --graph 打开一看,分叉、合并、菱形交织成一张网,想找出「哪次提交引入了这个 Bug」几乎无从下手。而在另一些仓库里,历史是一条笔直的线,git bisect 几下就能定位问题。

这个差别不是运气,而是一整套习惯和配置的结果。本文讲清楚线性历史是什么、为什么值得追求,以及怎么从个人配置一路落地到团队规范。

一、什么是线性历史

先看两张图。这是非线性的历史——每次 git pullgit merge 都可能产生一个合并提交(merge commit),历史变成了网状:

1
2
3
4
5
6
7
8
9
10
*   9f2c1ab Merge branch 'main' into feature/login
|\
| * 7d3e8f2 fix: 修正登录态判断
* | 4a1b9c0 Merge pull request #12 from dev
|\ \
| |/
| * 2c8d5e1 feat: 增加验证码
* | 8b7a3f9 chore: 升级依赖
|/
* 1a2b3c4 init

这是线性的历史——所有提交排成一条直线,每个提交只有一个父提交:

1
2
3
4
* 7d3e8f2 fix: 修正登录态判断
* 2c8d5e1 feat: 增加验证码
* 8b7a3f9 chore: 升级依赖
* 1a2b3c4 init

严格定义很简单:主分支上每个提交都只有一个父提交,不存在合并提交。

为什么值得追求

  • 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
2
3
4
5
# 全局生效:pull 时使用 rebase 而不是 merge
git config --global pull.rebase true

# rebase 前自动 stash 未提交的改动,结束后自动还原
git config --global rebase.autoStash true

配置之后,git pull 等价于 git pull --rebase:Git 会先把远端的新提交取下来,再把你本地的提交一个个「重放」到最新的远端提交之上。结果是一条直线,没有合并提交。

还有一个更保守的选项,适合作为团队默认值:

1
git config --global pull.ff only

这表示 git pull 只允许快进(fast-forward)。一旦本地和远端出现分叉,pull 会直接报错停下,让你自己决定是 rebase 还是 merge,而不是默默替你合并。对新人来说这个「报错」其实是好事——它把问题暴露在早期。

三、第二步:推送前整理本地提交

线性不只是「没有分叉」,还包括「每个提交都有意义」。像 fix再修一下typo真的好了 这种提交,即使排成一条直线也是垃圾。

交互式 rebase

假设当前分支相对 main 有 5 个提交:

1
git rebase -i main

编辑器里会列出这 5 个提交,把每行开头的动词改掉即可:

1
2
3
4
5
pick   a1b2c3d feat: 用户登录接口
squash e4f5g6h fix: 补上参数校验
squash h7i8j9k fix: typo
pick l1m2n3o feat: 登录页面
reword p4q5r6s 加了点样式

常用动作:

动作 缩写 作用
pick p 保留该提交
squash s 合并到上一个提交,保留并可编辑提交信息
fixup f 合并到上一个提交,丢弃本提交的信息
reword r 只改提交信息,不改内容
edit e 停下来,允许修改这个提交的内容
drop d 删除该提交

--fixup 自动化这个过程

上面手动改动词还是麻烦。更顺手的做法是:发现前面某个提交有问题时,直接打上标记:

1
2
3
4
5
6
7
8
9
# 找到要修复的目标提交
git log --oneline -5

# 改完代码后,生成一个绑定到目标提交的修复提交
git add .
git commit --fixup a1b2c3d

# 推送前,自动把所有 fixup 提交折叠回各自的目标提交
git rebase -i --autosquash main

--autosquash 会自动把 fixup! xxx 排到目标提交后面并标记为 fixup,你直接保存退出就行。把它设为默认更省事:

1
git config --global rebase.autosquash true

四、第三步:选一种线性的合并方式

本地干净了,还要保证合入主分支时不产生合并提交。三种可选策略:

1. Fast-forward only(最纯粹)

1
2
git checkout main
git merge --ff-only feature/login

只有当 main 没有新提交、可以直接快进时才成功;否则报错,你需要先在 feature 分支上 git rebase main。这是最严格也最干净的方式。

配成默认,防止手滑产生合并提交:

1
git config --global merge.ff only

注意:这个配置会让 git merge 在无法快进时直接失败。真的需要合并提交时(比如合并一个长期存在的发布分支),显式加 --no-ff 即可。

2. Squash merge(一个 PR = 一个提交)

1
2
git merge --squash feature/login
git commit

把整个分支压成一个提交合进主干。主分支历史极其干净,一个提交对应一个功能,回滚时整个功能一起走。代价是分支内部的开发过程全部丢失——如果一个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash
# pre-receive: 拒绝包含合并提交的推送
while read oldrev newrev refname; do
[ "$refname" != "refs/heads/main" ] && continue
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue

if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
range="$newrev"
else
range="$oldrev..$newrev"
fi

merges=$(git rev-list --merges "$range")
if [ -n "$merges" ]; then
echo "拒绝推送:main 分支不允许合并提交" >&2
echo "$merges" >&2
exit 1
fi
done
exit 0

放到裸仓库的 hooks/pre-receivechmod +x 即可生效。

六、rebase 的三条安全准则

1. 黄金法则:不要 rebase 别人在用的分支

rebase 会改写提交——即使内容一模一样,commit hash 也是全新的。如果这个分支已经推到远端且有人基于它工作,你 rebase 后 force push,对方再 pull 时就会出现两套并存的提交,历史彻底乱掉。

判断标准很简单:这个分支只有我一个人在用吗? 是,随便 rebase;不是,先在群里说一声,或者干脆用 merge。

2. force push 一律用 --force-with-lease

1
2
3
4
5
# 危险:无条件覆盖远端,可能抹掉同事刚推的提交
git push --force

# 安全:只有当远端仍停在你上次看到的位置时才覆盖
git push --force-with-lease

--force-with-lease 会检查远端分支是否还是你本地记录的那个位置。如果别人在这期间推了新东西,推送会被拒绝,而不是把对方的工作直接删掉。把它设成别名:

1
git config --global alias.pushf 'push --force-with-lease'

3. 让 Git 记住冲突的解法

rebase 一串提交时,同一个冲突可能反复出现。开启 rerere(reuse recorded resolution),Git 会记住你解决过的冲突,下次自动套用:

1
git config --global rerere.enabled true

长期看这个配置能省下大量重复劳动,尤其是在需要频繁 rebase 长分支的场景。

七、出事了怎么救

rebase 中途发现搞错了,随时可以退回原状:

1
git rebase --abort

已经 rebase 完成甚至 push 了才发现不对,用 reflog 找回原来的位置:

1
2
3
4
5
6
7
git reflog
# 输出示例:
# a1b2c3d HEAD@{0}: rebase (finish): returning to refs/heads/feature
# 9f8e7d6 HEAD@{1}: rebase (start): checkout main
# 4c5d6e7 HEAD@{2}: commit: feat: 用户登录接口 ← rebase 之前的状态

git reset --hard HEAD@{2}

reflog 记录了 HEAD 的每一次移动,默认保留 90 天。只要提交过,就基本找得回来——这是敢于放心使用 rebase 的底气。

另外,Git 在 rebase / merge / reset 前会自动把原位置存进 ORIG_HEAD,紧急情况下可以直接:

1
git reset --hard ORIG_HEAD

八、一份可以直接抄的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# pull 时 rebase,不产生合并提交
git config --global pull.rebase true
# rebase 前自动 stash,结束后还原
git config --global rebase.autoStash true
# rebase -i 默认启用 autosquash
git config --global rebase.autosquash true
# merge 默认只允许快进
git config --global merge.ff only
# 记住冲突解法,减少重复解决
git config --global rerere.enabled true
# 安全的 force push 别名
git config --global alias.pushf 'push --force-with-lease'
# 方便查看历史形状
git config --global alias.lg "log --graph --oneline --decorate --all"

配置完之后,日常流程就固定成这样:

1
2
3
4
5
6
git checkout -b feature/xxx main   # 从最新 main 开分支
# ... 开发,随手提交,需要修补时用 git commit --fixup <hash>
git fetch origin
git rebase -i --autosquash origin/main # 整理提交并重放到最新主干
git pushf # --force-with-lease
# 在平台上发起 PR,用 squash 或 rebase 方式合并

九、小结

保持线性历史,本质上是四层防线:

  1. 配置层pull.rebase + merge.ff only,从源头杜绝无意义的合并提交;
  2. 本地层:推送前用 rebase -i --autosquash 把提交整理成有意义的单元;
  3. 合并层:统一用 fast-forward、squash 或 rebase 中的一种合入主干;
  4. 平台层:分支保护规则或 pre-receive 钩子做最终兜底。

四层里最有性价比的是第一层和第四层——一行配置加一个勾选,就能挡掉 90% 的问题。至于是否要严格到每个提交都精心打磨,那是团队根据实际情况做的取舍,不必教条。

最后再强调一次那条黄金法则:只 rebase 自己的分支,force push 永远带 --force-with-lease。守住这两条,rebase 就是安全的。


如何保持 Git 提交历史是线性的
http://eevann.cn/2026/07/24/git-linear-history/
作者
月下独白
发布于
2026年7月24日
许可协议