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 | tree 4e8a... |
这个 commit 的 hash,就是用上面这些字节计算出来的。两个 commit 如果内容完全相同,包括作者和时间也相同,它们就是同一个对象。hash 相同,就不是 “ 两份内容相同的不同 commit”。
第一,Git 概念上存快照,不存差异。每个 commit 指向一棵完整的 tree,tree 再指向所有 blob。git diff 是查询时计算出来的结果。底层 packfile 会用压缩和增量存储节省空间,但语义上每个版本都是完整快照。
第二,相同内容会共享对象。两个分支上如果有一个完全相同的文件,仓库里只需要存一份 blob。两个 tree 同时引用它,不存在 “ 复制 “。
第三,篡改会破坏链条。改任意一个字节,hash 都会变。改老 commit 的内容,会让它的 hash 变化。它后续子孙 commit 的 parent 字段也会失效。要修复这条链,只能连同子孙一起重新创建。
1.2 改其实是重写
很多人把 git commit --amend 和 git rebase 理解成 “ 修改 commit”。这个直觉是错的。Git 里没有原地修改 commit 的操作。
amend 的真实动作是:用新内容创建一个新 commit,再把当前分支指针移过去。旧 commit 会被孤立。
rebase 的真实动作是:把一段 commit 序列放到新基底上逐个重新创建。新 commit 的文件内容可能和旧 commit 一样,但 parent 变了,hash 也会变。它们是一组全新的对象。
所以所有 “ 修改历史 “ 的操作,都可以归约成三步:创建新 commit、移动指针、让旧 commit 变成不可达。
2. 分支是指针
分支只是一个写着 commit hash 的文件。
2.1 .git/refs/ 里有什么
执行下面两条命令:
1 | ls .git/refs/heads/ |
第二条命令会输出一行 hash。整个 main 分支的 “ 身份信息 “,就只有这一行。
origin/main 这样的远程跟踪分支在 .git/refs/remotes/origin/。它们本质上都是从名字到 commit 的映射。
2.2 commit 不知道自己属于哪条分支
commit 对象里有什么?只有 tree、parent、作者、提交者和提交信息。它没有 “ 我属于 main 分支 “ 这个字段。
这个 commit 在哪条分支上 “ 是一次状态查询,不是 commit 的固有属性。今天能到,明天分支被删了,答案就可能变。
这个 commit 当年属于哪条分支 “ 没有可靠答案。Git 从来没有存过这条信息。
删除分支不会删除 commit,但会删除 “ 它当年叫什么 “。
1 | 合并前: |
E 和 F 仍然存在,因为它们能从 M 的 parent 链到达。但 “ 它们曾经叫 feature” 这件事不可查。这个信息从来没有进过仓库。
普通提交只有一个父提交,往下只有一条路。Merge 提交有两个父提交,往下会分成两条路:
- 一条回到合并前的当前分支。
- 另一条通向这次被收进来的分支。
3. GUI 图
Git 没有分支历史 这个一等概念,只有 commit 历史。
很多 GUI 会努力营造 “ 这条线就是 dev 的历程 “ 的感觉。它们靠的是从当前所有分支指针向回画。一旦某条线对应的指针不存在了,工具就无法说明那段 commit 属于谁,只能把它画成一段无主历史。
这也是为什么提交图里经常出现 “ 找不到对应分支的线 “。不是图画错了,而是问题问错了。
3.1 示例解释
- 一个点代表一次提交。
- 点之间的线代表“这个提交从哪个提交产生”。
- 两条线汇入一个点,代表一次合并。
- 颜色没有固定意义,不代表永久的
main或dev。 main、dev标签只是可以移动的书签。

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
- 从这个点可以找到两个父亲,
f24a4f3272和4dd00d5118 - 一个父亲是 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 会记住解决过的冲突,并在相同冲突再次出现时自动复用。