Git提交图为何混乱

每次打开 git log --graph,看到那些七拐八绕的线。先要回答两件事:

  • Git 底层到底存了什么,
  • 以及 分支的 提交和合并 真正指向哪些对象。

1. 底层原型

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

1.1 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 字段也会失效。要修复这条链,只能连同子孙一起重新创建。

1.2 改其实是重写

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

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

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

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

2. 分支是指针

分支只是一个写着 commit hash 的文件。

2.1 .git/refs/ 里有什么

执行下面两条命令:

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

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

origin/main 这样的远程跟踪分支在 .git/refs/remotes/origin/。它们本质上都是从名字到 commit 的映射。

2.2 commit 不知道自己属于哪条分支

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

  • 这个 commit 在哪条分支上 “ 是一次状态查询,不是 commit 的固有属性。今天能到,明天分支被删了,答案就可能变。

  • 这个 commit 当年属于哪条分支 “ 没有可靠答案。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” 这件事不可查。这个信息从来没有进过仓库。

普通提交只有一个父提交,往下只有一条路。Merge 提交有两个父提交,往下会分成两条路:

  • 一条回到合并前的当前分支。
  • 另一条通向这次被收进来的分支。

3. GUI 图

Git 没有分支历史 这个一等概念,只有 commit 历史。

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

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

3.1 示例解释

  • 一个点代表一次提交。
  • 点之间的线代表“这个提交从哪个提交产生”。
  • 两条线汇入一个点,代表一次合并。
  • 颜色没有固定意义,不代表永久的 maindev
  • maindev 标签只是可以移动的书签。
image-20260807220516756

3.2 Git 操作拆解

  • 节点 dc73646343 (作者: guojian):

    • 动作: 发生了一次合并(Merge branch ‘feat/l4-video-funnels’ into staging)。
    • 怎么看分支末端?
    • feat/l4-video-funnels 的末端 = 0e67cc4591 (不用看颜色,也不用顺着线猜。只看分支标签。)
    • 最后一个提交基于哪个提交?
      • 点开 0e67cc4591,父节点:bc1a3d0147 -> 4dd00d5118
    • dc73646343 又是什么?
      • dc73646343 是 staging 的合并提交。
      • dc736463 是 staging 的最新 commit
        • 从这个点可以找到两个父亲,f24a4f32724dd00d5118
        • 一个父亲是 feat/l4-video-funnels 这条线,合并时候 4dd00d5118 功能分支最新提交
        • 一个父亲是 staging 历史的一条线,历史 staging 的commit 是 f24a4f3272
  • 节点 284a9ee86d (作者: xuyiheng):

    • 动作: 合并了编号为 #870 的 Pull Request。
    • production 最新的 commit 代表就是 284a9ee86d
    • 一个父亲看不到头(翻页后是 c194726e55),一个父亲是84bb28bed1

4. 解决乱

4.1 merge 和 rebase

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

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

4.2 配置 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 会记住解决过的冲突,并在相同冲突再次出现时自动复用。