我一直觉得,Git这款工具最神奇的地方在于:大多数人天天用,但真正搞懂它内部是怎么运作的,反而少之又少。很多朋友跑来问我为什么rebase会搞乱历史、为什么merge出来的提交图一团乱麻、为什么有时候分支删了还能找回提交——这些问题如果不从Git的对象模型和引用机制层面去理解,光靠背命令,永远只能靠试错来避开坑。这篇内容我就把Git内部最核心的三个概念:对象、引用、分支合并策略,从原理到实战彻底拆开讲透,希望能帮你把Git从“会用的工具”变成“能掌控的工具”。
这个主题适合谁?如果你已经被merge和rebase的区别绕晕过,或者好奇.git目录里到底是什么,又或者你想在工作中设计一套靠谱的分支管理策略,那么这篇内容就是为你准备的。我尽量用日常能遇到的场景来对应底层原理,不堆术语,但这篇文章里的每个结论都能在命令行里验证。
1. Git到底存了什么:对象模型是一切的地基
先说一个反直觉的事实:Git存的根本不是“版本差异”,而是“快照”。很多人一开始学Git时都以为它像Word的修订记录一样,保存的是“这一版比上一版改了哪些行”。实际上,Git的存储模型是:每次提交都保存一份完整的项目文件快照,差异是后面通过对比快照算出来的。
这个设计思路决定了后面所有行为。要理解快照,就必须认识Git四种基础对象:blob、tree、commit、tag。
1.1 blob对象:文件内容的一次“定格”
blob(Binary Large Object)保存的是一个文件的内容。这里的关键词是“内容”——不是文件名,不是文件路径,不带任何元数据信息。
你可以用git hash-object命令手动创建一个blob对象试试:
bash复制$ echo "hello git" | git hash-object -w --stdin
3b18e512dba79e4c8300dd08aeb37f8e728b8dad
这条命令把字符串“hello git”写入Git的对象库,然后返回一个40位的哈希值。这个哈希值是根据文件内容计算出来的SHA-1,内容相同则哈希相同,内容不同则哈希不同。这就是Git所谓的内容寻址(content-addressable)——对象的“地址”(哈希值)完全由“内容”决定。
这个设计带来一个非常重要的特性:去重。假设一个项目里有两个文件内容完全相同,在Git对象库中只会保存一份blob对象,两个tree条目都指向同一个哈希。反过来,如果一个文件几轮提交都没改动过,新commit里的tree仍然指向原来那个blob,不会额外占用空间。这就是为什么Git仓库往往比想象中“省空间”的原因之一。
1.2 tree对象:目录结构的快照
一个blob只负责文件内容,那文件名、目录结构、文件权限这些信息谁来管?答案是tree对象。
tree对象本质上是一个表,每一行记录一个条目:文件模式(普通文件、可执行文件、符号链接)、对象类型(blob或tree)、对象哈希、文件名。简单来说,tree就是目录的快照。
你可以用git ls-tree查看任意提交的顶层tree:
bash复制$ git ls-tree HEAD
100644 blob a5a5f5f... README.md
100644 blob b6b6c6c... main.py
040000 tree c7c7d7d... src
看到没有,src这一行指向的是一个tree对象,不是blob对象。这意味着目录嵌套在Git内部就是tree套tree的结构,和文件系统非常像。
tree对象的哈希也是内容寻址的——目录里放了哪些文件、每个文件的内容是什么、权限如何,全部会被编码进tree的哈希。哪怕只改了一个文件内容,父级目录的tree哈希就会跟着变化,一直传到根tree。
这里有个常常被忽略的点:因为tree保存的是完整目录条目,所以Git天然就能感知文件的重命名。你不需要额外记录“这个文件改名了”,只要内容不变,两个commit的对比结果自然就是“旧文件名没了、新文件名出现、内容一样”,Git展示为rename只是对比算法的呈现方式。
1.3 commit对象:一次提交的“快照指针”
有了Blob保存内容、Tree保存目录结构,还缺一个东西:提交元数据——谁在什么时间提交的、提交信息是什么、这次提交的父提交是谁。
commit对象就是干这个的。它本身不包含任何项目文件内容,只包含四类信息:
- 一棵tree的哈希(指向本次提交对应的根目录快照)
- 父提交的哈希(可以有多个,合并提交就是多个父提交)
- 作者信息(author):谁写的代码,什么时候
- 提交者信息(committer):谁执行的提交,什么时候
- 提交信息(message)
写一段伪代码来描述commit对象的结构就是:
text复制tree 5f1c3c6a7d...
parent 9a2e0c1b8d...
author Alice <alice@example.com> 1700000000 +0800
committer Bob <bob@example.com> 1700000100 +0800
fix: 修复登录页面崩溃问题
留个思考题:既然每次commit都指向一个完整的tree快照,那么两个功能分支完全独立开发、互不涉及的文件,在合并时Git为什么要花力气“合并”?因为commit的父链已经记录了分支分叉点,Git只需要对比三个tree,就可以精确知道两边各改了什么,这个我们放到第三章细讲。
