Git提交图为何混乱

每次打开 git log --graph,看到那些七拐八绕的线,第一反应往往是:换个工具会不会清楚点?

通常不会。图乱不是渲染器的问题,而是历史本身的问题。要看清这一点,先要回答两件事:Git 底层到底存了什么,以及 “ 分支 “” 提交 “” 合并 “ 这些词真正指向哪些对象。

1. 图是 DAG 的镜子

Git 底层是一个有向无环图(DAG)。每个 commit 都记录自己的父 commit。普通 commit 有一个父 commit,merge commit 有两个或更多父 commit。所谓提交图,只是把这组父子关系画出来。

GitKraken 画得圆滑,Gitea 画成直角,git log --graph 用 ASCII 拼出来。它们画的是同一个 DAG。底层数据长什么样,图就长什么样。

所以问题不在 “ 线怎么画 “,而在 “ 线本身为什么这么走 “。要回答这个问题,先要看清 commit 到底是什么。

2. Git 的对象模型

理解 Git,最好先把它看成一个内容寻址的小型对象数据库。Git 主要存三类对象:

  • blob:文件内容快照。
  • tree:目录快照,记录文件名到 blob 或子 tree 的映射。
  • commit:指向一个 tree,并记录作者、时间、提交信息和父 commit。

三者的关系并不复杂:

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'secondaryColor': '#10B981', 'tertiaryColor': '#F59E0B'}}}%%
flowchart LR
    Ref["refs/heads/main"] --> Commit["commit: tree + parents + metadata"]
    Commit --> Tree["tree: directory snapshot"]
    Commit --> Parent["parent commit"]
    Tree --> BlobA["blob: file content"]
    Tree --> BlobB["blob: another file"]

    classDef ref fill:#DBEAFE,stroke:#2563EB,color:#1E3A5F;
    classDef object fill:#ECFDF5,stroke:#10B981,color:#064E3B;
    classDef parent fill:#FEF3C7,stroke:#F59E0B,color:#78350F;
    class Ref ref;
    class Commit,Tree,BlobA,BlobB object;
    class Parent parent;

每个对象都有一个摘要值。老仓库通常使用 SHA-1,新仓库可以选择 SHA-256。摘要由对象内容计算得出。这个设计撑起了 Git 的几个核心性质。

2.1 内容寻址:hash 不是名字,是身份

在任意 Git 仓库里执行:

1
git cat-file -p HEAD

可以看到当前 commit 对象的明文内容:

1
2
3
4
5
6
tree 4e8a...
parent f12b...
author liuwei <...> 1715... +0800
committer liuwei <...> 1715... +0800

fix: typo in README

这个 commit 的 hash,就是用上面这些字节计算出来的。两个 commit 如果内容完全相同,包括作者和时间也相同,它们就是同一个对象。hash 相同,就不是 “ 两份内容相同的不同 commit”。

这就是内容寻址:对象的身份不由名字决定,而由对象自己的内容决定。

它会推出三件事:

第一,Git 概念上存快照,不存差异。每个 commit 指向一棵完整的 treetree 再指向所有 blobgit diff 是查询时计算出来的结果。底层 packfile 会用压缩和增量存储节省空间,但语义上每个版本都是完整快照。

第二,相同内容会共享对象。两个分支上如果有一个完全相同的文件,仓库里只需要存一份 blob。两个 tree 同时引用它,不存在 “ 复制 “。

第三,篡改会破坏链条。改任意一个字节,hash 都会变。改老 commit 的内容,会让它的 hash 变化。它后续子孙 commit 的 parent 字段也会失效。要修复这条链,只能连同子孙一起重新创建。

第三点是理解 rebase 的根。

2.2 不可变性:你 “ 改 “ 的其实是 “ 重写 “

很多人把 git commit --amendgit rebase 理解成 “ 修改 commit”。这个直觉是错的。Git 里没有原地修改 commit 的操作。

amend 的真实动作是:用新内容创建一个新 commit,再把当前分支指针移过去。旧 commit 会被孤立。

rebase 的真实动作是:把一段 commit 序列放到新基底上逐个重新创建。新 commit 的文件内容可能和旧 commit 一样,但 parent 变了,hash 也会变。它们是一组全新的对象。

所以所有 “ 修改历史 “ 的操作,都可以归约成三步:创建新 commit、移动指针、让旧 commit 变成不可达。

不可变性不是 Git 的口号,而是 hash 链结构的逻辑后果。你不能既修改内容,又保留原来的 hash。理解这一点,rebase 的规则就会自然浮现。

3. 分支只是指针

理解了 commit 是不可变对象,分支就简单了。分支只是一个写着 commit hash 的文件。

3.1 .git/refs/ 里有什么

执行下面两条命令:

1
2
ls .git/refs/heads/
cat .git/refs/heads/main

第二条命令会输出一行 hash。整个 main 分支的 “ 身份信息 “,就只有这一行。

轻量 tag 也是类似结构,文件在 .git/refs/tags/origin/main 这样的远程跟踪分支在 .git/refs/remotes/origin/。它们本质上都是从名字到 commit 的映射。

HEAD 稍特殊。通常它存的不是 hash,而是:

1
ref: refs/heads/main

这叫符号引用。它表示 “ 我现在站在哪条分支上 “,再间接指向某个 commit。执行 git checkout <commit-hash> 后,HEAD 会直接指向 commit。这就是 detached HEAD。此时新 commit 不会被任何命名分支记住。

记住这层抽象:ref 是名字到 commit 的一对一映射,没有别的。

3.2 Commit 不知道自己属于哪条分支

commit 对象里有什么?只有 treeparent、作者、提交者和提交信息。它没有 “ 我属于 main 分支 “ 这个字段。

这会推出几个反直觉但精确的事实。

第一,” 这个 commit 在哪条分支上 “ 是一次状态查询,不是 commit 的固有属性。

1
2
git branch --contains <hash>
git branch -a --contains <hash>

--contains 的答案来自当前还存在的分支指针。Git 从这些指针向回追溯,看哪些链路能到达这个 commit。今天能到,明天分支被删了,答案就可能变。

第二,” 这个 commit 当年属于哪条分支 “ 没有可靠答案。Git 从来没有存过这条信息。即使 GUI 把某段历史画在一根叫 dev 的彩色线上,那也只是工具根据当前 ref 和 parent 链拼出来的视图。颜色属于渲染器,不属于 Git 数据。

第三,删除分支不会删除 commit,但会删除 “ 它当年叫什么 “。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
合并前:
A - B - C - D <- dev
\
E - F <- feature

合并 feature 到 dev:
A - B - C - D - M <- dev
\ /
E - F <- feature

删除 feature:
A - B - C - D - M <- dev
\ /
E - F <- 仍可通过 M 追溯,但不再有 feature 这个名字

EF 仍然存在,因为它们能从 M 的 parent 链到达。但 “ 它们曾经叫 feature” 这件事不可查。这个信息从来没有进过仓库。

3.3 这一层抽象的代价

把分支设计成指针,有一个常被忽视的代价:Git 没有 “ 分支历史 “ 这个一等概念,只有 commit 历史。

很多 GUI 会努力营造 “ 这条线就是 dev 的历程 “ 的感觉。它们靠的是从当前所有分支指针向回画。一旦某条线对应的指针不存在了,工具就无法说明那段 commit 属于谁,只能把它画成一段无主历史。

这也是为什么提交图里经常出现 “ 找不到对应分支的线 “。不是图画错了,而是问题问错了。

理解了对象和指针,接下来可以看提交图变乱的真正来源。

4. 图乱的真实来源

DAG 和指针本身是中性的。好的工作流会得到清晰的历史;糟糕的工作流会把历史缠在一起。

让多数仓库历史变乱的常见动作,是默认行为下的 git pull

4.1 默认 Pull 在替你说话

git pull 不是单一操作。它等于 git fetch 加上一个集成步骤。这个集成步骤的默认行为,长期以来是 merge。

设想一个场景:你本地 dev 比远程 dev 多一个 commit,远程 dev 也比你本地多一个 commit。执行 git pull 后,Git 会自动创建一个 merge commit。它的两个 parent 分别指向你的本地提交和远程提交。

这个 merge commit 不代表任何产品或工程决策。它只表示 “ 刚才我按了一次 pull”。默认提交信息通常也是自动生成的,例如:

1
Merge branch 'dev' of origin into dev

问题是,它和真正有意义的 merge commit 在拓扑上完全一样。十个人每天各自 pull 多次,仓库里就会充满这种 “ 不是决策的 merge”。它们就是提交图里那些突然出现、又莫名汇合的横线和斜线。

4.2 Commit 应该服务于阅读

人为什么要翻 Git history?通常是为了知道某一行为什么变成这样。Git 能告诉你是谁、什么时候改的。至于 “ 为什么 “,只能依赖 commit message 和 commit 本身的存在意义。

理想情况下,每个 commit 都应该对应一次决策:实现一个功能、修复一个 bug、完成一次重构、发布一个版本。每根分叉应该说明 “ 为什么要分出去 “,每次合流应该说明 “ 为什么要汇回来 “。

默认 git pull 产生的 merge 没有这个意义。它是机械动作的副产品,却伪装成了人的意图。读者翻到它,得不到信息,但注意力仍然被消耗。

4.3 局部安全,集体灾难

为什么 Git 长期选择这种默认行为?因为 pull --rebase 在错误场景下有协作风险。如果你 rebase 的 commit 已经被 push,并且别人已经基于它继续开发,rebase 会重写历史,让协作者被迫修复自己的分支。

因此,默认 merge 是一个保守选择:不丢数据,但留下痕迹。

这个选择对单个用户合理。问题在于,十个人都按这个默认值运行时,集体产物就是充满噪音的历史。每个人都在为 “ 自己不出事 “ 负责,没人为 “ 半年后的仓库是否好读 “ 负责。

这就是版本控制里的公地悲剧:默认值优化的是单点风险,不是集体可读性。

看清了噪音来源后,再讨论 rebase 和 merge 的取舍才有意义。

5. Rebase 和 Merge 的取舍

讨论 “ 应该用 rebase 还是 merge” 之前,先看清它们各自的物理动作。

5.1 两件事的物理层

merge 会创建一个新 commit。这个新 commit 有两个或更多 parent。原来两条线上的 commit 都不变,新增的 merge commit 把两条线的 tip 汇到一起。历史拓扑记录了 “ 这两条线在这里合流 “。

rebase 不创建合流点。它把一段 commit 序列放到另一个 base 上逐个重新创建。原始 commit 对象还在仓库里,但分支指针不再指向它们。对当前分支来说,它们已经不可达,后续可能被 GC 回收。

物理层一句话:merge 在历史上加节点,rebase 在历史上换骨架。

5.2 黄金法则

rebase 的核心约束只有一条:

不要 rebase 已经 push 出去并被别人使用的 commit。

原因可以从对象模型直接推出。rebase 后,旧 commit 的 hash 会失效。如果 A 已经 push 给同事,同事又在 A 上写了 B,你把 A rebase 成 A' 再 force push,同事下次同步时会看到两套历史:B 仍然指向旧的 A,旁边又出现新的 A'

修复这类问题,需要同事把 B 重新放到 A' 上。这个过程可能选错 base,也可能在冲突处理中丢失变更。

这不是说 rebase 危险,而是说 rebase 要用在正确作用域里。未 push 的本地 commit、私有分支、明确由一人维护的 feature 分支,都是 rebase 的主场。mainrelease/* 和多人共享的长期分支,则应避免被 rebase。

5.3 Squash Merge 的代价

squash merge 会把一个 PR 上的多个开发 commit 压成一个。GitHub 和 GitLab 都常把它作为默认策略。它的好处很直接:一个 PR 对应一个 commit,主线上不出现 “ 修变量名 “” 跑测试 “” 再改 typo” 这类过程性提交。

代价也明确:

  • 丢失开发过程的指纹。中间 commit 里也许有 “ 先尝试 A 方案,再回滚,最后改用 B 方案 “ 的轨迹。压扁之后,这些线索会消失。
  • git bisect 不友好。一个 500 行的大 commit 被定位为问题点后,还要在 diff 里继续拆。
  • 无法区分粒度。如果一个 PR 同时改了几件相关但独立的事,squash 后只能整体回滚。

判断标准只有一个:PR 的中间 commit 对未来读者是否有信息价值?

如果 PR 里大多是 WIP、格式化、按 review 调整,应该 squash。如果 PR 被精心拆成几个有逻辑递进的 commit,应该保留这些 commit,可以选择 rebase merge。

很多团队默认全部 squash,是因为整理 PR 内部 commit 需要纪律。这是合理妥协,但要知道妥协掉的是什么。

5.4 何时保留真正的 Merge Commit

不要把 “ 消除所有 merge commit” 当成目标。merge commit 在一些场景里有明确价值:

  • 集成节点:长期 feature 分支合进 main,merge commit 标记这个功能正式合入。
  • 发布合流:release/x.y 分支合回 main,merge commit 表示这个版本发布完成。

判断准则很简单:这个 merge 是否代表一次决策?如果是,保留。如果只是同步动作,消除。

GitHub 的 “Create a merge commit” 按钮默认会给每个 PR 留 merge commit。它和默认 git pull 的问题同构:把决策语义贬值成机械动作的痕迹。

5.5 双向合流为什么是反模式

很多团队让 devmain 互相合并:main 修了紧急 bug,merge 回 devdev 攒了一批功能,再 merge 进 main。看起来对称,实际是在制造混乱。

集成方向应该尽量单向:

  • 功能从 feature/* 流向 dev 或直接流向 main
  • hotfix 从 hotfix/* 流向 main,再同步到 dev
  • 发布从 main 打 tag,不需要回流。

双向合流有三个问题:

  1. 拓扑不可读。maindev 来回纠缠,读者看不出哪条是主线。
  2. 责任不清。一个 commit 出现在 main 里,可能是因为它该上线,也可能是被某次 merge 夹带进来。
  3. 回退困难。要从 main 撤回一个功能时,可能发现它穿过了多次双向 merge,边界已经模糊。

一条规则就够:一个 commit 进入 main,必须经过 PR 和 review。main 上的修改同步回 dev,用 rebase 或 cherry-pick,不走反向 merge。

方向感建立后,提交图会清晰很多。接下来把这些原则落成几条配置和团队规则。

6. 三件修复

理解了原理,修复动作并不复杂。关键是同时说明收益和代价,避免把工具配置包装成银弹。

6.1 配置 pull.rebase = true

1
git config --global pull.rebase true

配置之后,git pull 在分叉时不再创建 merge commit,而是把本地未 push 的 commit rebase 到远程 tip 上。

它的收益是消除默认 pull 产生的伪 merge。一次同步动作不再污染长期历史。

它的代价是冲突处理更考验耐心。rebase 会逐个重放 commit,每个 commit 都可能遇到冲突。但只要这些 commit 尚未被别人使用,就没有违反黄金法则。

进阶配置可以打开 rerere

1
git config --global rerere.enabled true

rerere 会记住解决过的冲突,并在相同冲突再次出现时自动复用。

6.2 把 Squash Merge 作为 PR 默认策略

在 GitHub 或 GitLab 仓库设置里,把默认 merge 策略改成 “Squash and merge”。

它的收益是让一个 PR 对应一次决策,主线 commit 数量和决策数量更接近。历史会更短,也更容易读。

它的代价是丢失 PR 内部的开发过程。如果某个 PR 的中间 commit 有信息价值,reviewer 应允许选择 “Rebase and merge” 或保留 merge commit。

采用 squash 后,PR 描述要承担更多档案职责。commit message 写 “ 做了什么 “,PR 描述写 “ 为什么这么做、试过什么、留下什么风险 “。

6.3 约定单向合流

约定一个集成方向,例如:

1
2
feature/* -> dev -> main
hotfix/* -> main -> dev

main 上的 hotfix 通过 cherry-pick 或 rebase 同步回 dev,不允许把 main merge 进 dev

它的收益是让 main 永远保持清晰主线。dev 也有自己的方向,二者之间只有语义明确的合流点。

它的代价是需要团队纪律。第一次有人把 main merge 回 dev 时,应尽快 revert 并重做。更稳妥的做法是在 CI 里加检查,检测 dev 上是否出现来自 main 的反向 merge commit。

规则讲清楚后,可以用一个对照图看效果。

7. 一个对照

同一段开发量,在两种工作流下会长成完全不同的提交图。

默认设置下,机械同步会不断留下 merge commit:

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'secondaryColor': '#10B981', 'tertiaryColor': '#F59E0B'}}}%%
gitGraph
   commit id: "init"
   branch dev
   checkout dev
   commit id: "B"
   checkout main
   commit id: "C"
   checkout dev
   merge main id: "pull-noise-1"
   commit id: "D"
   checkout main
   commit id: "E"
   checkout dev
   merge main id: "pull-noise-2"
   commit id: "F"
   checkout main
   merge dev id: "release"

应用三件修复后,主线只保留真正有意义的决策:

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'secondaryColor': '#10B981', 'tertiaryColor': '#F59E0B'}}}%%
gitGraph
   commit id: "init"
   commit id: "feature-A"
   commit id: "feature-B"
   commit id: "feature-C"

工作量没有变化,阅读成本已经不同。前者记录了大量同步动作,后者更接近工程决策的时间线。

有了对照,再回到最初的心智模型做一次自检。

8. 自检

下面三句话如果能向同事讲清楚,说明 Git 提交图的心智模型已经建立起来了。

  1. commit 是不可变的内容寻址对象。Git 里不存在原地 “ 修改 commit”;rebaseamend 都是创建新 commit 再移动指针。
  2. 分支只是一个指向 commit 的命名指针。commit 不知道自己属于哪条分支,” 它当年属于哪条分支 “ 在 Git 里没有可靠答案。
  3. git log --graph 上的线,是工具基于当前 ref 和 parent 链渲染出来的视图。底层只有 commit 和 parent,没有 “ 分支线 “。

讲不通哪一条,就回到对应章节。

最后再回答开头的问题:工具能帮忙,但工具不能替你维护历史。

9. 工具的归工具,历史的归历史

有没有更清晰的 Git 图工具?有。

但工具只能把已有历史画清楚。决定历史是否好读的,不是渲染器,而是仓库里有没有值得画的东西。

先写下这两行:

1
2
git config --global pull.rebase true
git config --global rerere.enabled true

再把 PR 默认策略改成 squash,把合流方向改成单向。一个月后回头看,提交图会少很多没有意义的线。半年后再看,历史会更像一条能读懂的工程记录:谁、什么时候、为了什么,都更清楚。

工具负责把历史画出来。让历史本身值得画,是团队工作流的责任。