一篇文章讲透 Git 底层原理:从对象模型到分支、合并与远程协作
写在前面
很多人用 Git 用了很久,却始终停留在"背命令"的阶段——add、commit、push、pull、merge、rebase,记住了怎么用,却说不清楚它们背后到底发生了什么。结果就是一旦遇到冲突、误操作或者历史被搞乱,就完全慌了神,只能求助搜索引擎里那些语焉不详的"复制粘贴式"解决方案。
这篇文章的目标是彻底打通 Git 的底层逻辑。读完之后,你会发现几乎所有 Git 命令的行为都可以从几条简单的原理里自然推导出来,而不再需要死记硬背。
一、Git 的本质:一个内容寻址的文件系统
很多教程会把 Git 类比成"拍照式"的版本管理工具,这个说法没错,但没有说到点子上。要真正理解 Git,你需要知道它本质上是一个基于内容寻址(Content-Addressable)的键值数据库。
1.1 四种核心对象
Git 仓库的.git/objects目录下存储着四种对象,每个对象都以其内容的SHA-1 哈希值(一个 40 位十六进制字符串)作为唯一标识。这意味着:
只要内容不变,哈希就不变;哪怕改动一个字节,哈希就会完全不同。
这个特性是 Git 一切机制的基石。四种对象类型分别是:
| 对象类型 | 作用 | 存储内容 |
|---|---|---|
| blob | 存储文件内容 | 纯文件数据(不含文件名) |
| tree | 存储目录结构 | 文件名 + 权限 + 指向 blob/tree 的哈希 |
| commit | 存储一次提交 | tree 哈希 + 父 commit 哈希 + 作者/提交者信息 + 提交信息 |
| tag | 存储标签 | 指向某个 commit 的引用 + 附加信息 |
三者之间的引用关系构成了一条清晰的链条:
commit ──指向──> tree ──指向──> blob │ │ │ 提交信息 目录结构 文件内容 (作者/时间/message) (文件名+权限) (纯数据)关键点在于:commit 对象里并不会直接记录"改了哪些文件",而是指向一整棵 tree——也就是整个项目在那一刻的完整目录快照。
1.2 快照,而非差异
这是 Git 最常被误解的地方。很多人以为 Git 像老一代版本控制工具(比如 SVN)一样,每次提交存储的是"文件差异"(diff)。这是错误的。
Git 每次提交存储的是整个项目当时的完整快照:
- 如果某个文件内容没有变化,新的 tree 会直接复用旧的 blob 哈希,不会重复存储;
- 如果文件变了,会生成一个新的 blob,新的 tree 指向这个新 blob;
- 这也是为什么 Git 用 SHA-1 做寻址——相同内容必然产生相同哈希,天然实现了去重。
举个例子:如果你的项目里有 100 个文件,某次提交只改了其中 1 个,Git 生成的新 tree 里,另外 99 个文件对应的 blob 哈希会和上一次提交完全相同,Git 不会重复存储它们的内容,只是"复用引用"。
1.3 commit 的父指针:历史是一张有向无环图
commit 对象中有一个parent字段,正是它把一个个孤立的快照串联成了完整的历史:
- 普通提交:有 1 个 parent(指向上一次提交)
- 初始提交:没有 parent(历史的起点)
- 合并提交(merge commit):有 2 个(或以上)parent
正因为存在多父提交的可能性,Git 的提交历史本质上是一个有向无环图(DAG,Directed Acyclic Graph),而不是一条简单的直线。理解这一点,是理解后面分支合并逻辑的前提。
二、分支的本质:一个"可移动的指针"
这是 Git 设计中最优雅、也最容易被误解的部分。很多人潜意识里把"分支"想象成一份独立的代码拷贝,这个想法并不准确。
2.1 分支就是一个存着 commit 哈希的文本文件
在.git/refs/heads/目录下,每一个分支对应一个文件,文件里只有一行内容——它所指向的那个 commit 的哈希值。而HEAD则是一个指向"当前分支"的指针,正常情况下相当于一个"指针的指针"。
这个设计带来了几个直接的推论:
- 创建新分支本质上只是新建一个文件,写入当前 commit 的哈希,几乎零开销。这正是 Git 分支被称为"轻量级分支"的原因,完全不像某些老版本控制工具那样需要复制整个目录。
- 切换分支(
checkout/switch)= 把HEAD指向另一个分支文件,并用该分支指向的 commit 对应的 tree 内容,覆盖工作区的文件。 - 提交新 commit时,Git 做的事情是:创建一个新的 commit 对象(其 parent 指向当前 HEAD 所在的 commit)→ 把当前分支的引用文件内容更新为这个新 commit 的哈希。
2.2 一个直观的理解
如果把 commit 历史想象成一条由珠子串成的项链,那么"分支"就是别在某颗珠子上的一个可移动的小夹子。提交新的 commit,就是把项链延长一节,同时把夹子往后挪一位。多个分支,就是项链上别了好几个不同位置的夹子——它们指向的可能是同一条链,也可能各自延伸出了不同的分岔。
正因为分支只是"夹子"而不是"项链本身",创建、删除、切换分支才会如此轻量和快速。
三、工作区、暂存区、版本库:三个区域的博弈
Git 管理文件变化,靠的是三个区域之间的对比,而不是简单的"改了就提交":
工作区 (Working Directory) │ git add ▼ 暂存区 (Staging Area / Index) │ git commit ▼ 版本库 (Repository, HEAD 指向的 commit)3.1 暂存区的作用
暂存区本质上是一份临时 tree 的草稿,存放在.git/index这个二进制文件里。它存在的意义,是让你可以精细地控制"这次提交到底要包含哪些改动"——比如只暂存一个文件里的部分修改(git add -p),而不是把工作区的所有改动一股脑打包提交。
理解了这一点,git diff命令族的行为也就自然清楚了:
| 命令 | 比较的对象 |
|---|---|
git diff | 工作区 vs 暂存区 |
git diff --cached(或--staged) | 暂存区 vs 最新 commit |
git diff HEAD | 工作区 vs 最新 commit(跳过暂存区,两步合一) |
而git commit做的事情,就是把暂存区的内容打包生成一个新的 tree 对象,再创建一个 commit 对象指向这个 tree,最后把当前分支的引用更新到这个新 commit。
四、合并(Merge)的两种数学模型
4.1 Fast-forward 合并:并不存在的"真合并"
如果你要合并的目标分支自分叉以来没有产生任何新提交,Git 会采取最简单的策略——直接移动分支指针:
合并前: master ──> A ──> B feature ──> C 合并后: master ──────────────> C (指针直接移到 C,不产生任何新对象)这种情况下合并后master和feature会指向完全相同的 commit 哈希——这不是巧合,因为 fast-forward 合并根本没有创建任何新的 commit 对象,只是把master的引用文件内容改写成了feature当前指向的哈希值。
4.2 三方合并:真正的合并逻辑
当两个分支各自都产生了新提交(历史分叉了)之后再合并,Git 就需要创建一个拥有两个 parent 的 merge commit。它的核心算法是:找到两个分支的共同祖先(merge base),然后做逐行的三方对比:
- 如果某一行ours(你的分支)和base(共同祖先)相同,而theirs(对方分支)改了 → 自动采用 theirs 的版本;
- 如果某一行 theirs 和 base 相同,而 ours 改了 → 自动采用 ours 的版本;
- 如果 ours 和 theirs都相对 base 做了不同的修改→产生冲突,需要人工裁决。
这也解释了冲突标记的由来——Git 把无法自动决策的部分原样插入到文件中:
<<<<<<< HEAD 你的版本内容 ======= 对方的版本内容 >>>>>>> feature解决冲突后执行git add+git commit,本质上就是手动完成那个双 parent 的 merge commit 的生成。
五、Rebase 的原理:重写历史,而非合并历史
rebase常常被简单地理解为"和 merge 效果一样,但看起来更好看",但这其实是完全不同的操作机制。它不会创造 merge commit,而是把一系列 commit 逐个"重新演绎"到新的基点之上。
5.1 核心步骤
执行git rebase master(假设你在feature分支上)时,Git 实际做的是:
- 找到
feature相对master分叉之后的所有 commit,按顺序列成一个队列; - 把工作基点临时切到
master指向的 commit; - 逐个把队列里每个 commit 对应的改动(diff)重新应用一遍;
- 每应用一次,就生成一个全新的 commit 对象——新的 parent、新的 tree、也因此产生全新的哈希;
- 最后把
feature分支的引用指向这一串新 commit 的最后一个。
5.2 一个容易被忽视的关键点
rebase 之后的 commit 是全新对象,即使内容完全一样,哈希也会彻底不同,因为 commit 的哈希计算包含了它的 parent 信息,而 parent 变了。
这直接引出了一条重要的协作准则:不要对已经推送到远程、且可能已经被别人拉取过的分支做 rebase。因为你实际上是在用一批新哈希的 commit,替换掉一批旧哈希的 commit,等于重写了历史,会导致协作者本地的仓库和你的历史"对不上号"。
rebase更适合的场景是:在合并之前整理自己的本地分支,让提交历史保持线性、干净,避免出现不必要的合并节点。
5.3 Merge 与 Rebase 的对比
| 维度 | Merge | Rebase |
|---|---|---|
| 是否创建新 commit | 是(一个 merge commit,两个 parent) | 是(每个原 commit 都变成新 commit) |
| 是否改写已有 commit 的哈希 | 否,原有 commit 保持不变 | 是,被 rebase 的所有 commit 哈希都会变化 |
| 历史形状 | 保留真实的分叉与合并节点 | 拉直成一条线性历史 |
| 对已推送的公共分支 | 安全 | 危险(除非确定没有人拉取过) |
六、git reset三兄弟:到底移动了什么
reset是最容易被误用的命令之一,因为它可以同时操作三个区域中的一部分或者全部。理解它的原理其实很简单:git reset <commit>首先会把当前分支的引用文件改写为目标 commit 的哈希,而三种模式的区别,就在于是否顺带同步暂存区和工作区。
| 模式 | 分支指针 | 暂存区 | 工作区 | 典型用途 |
|---|---|---|---|---|
--soft | 会改变 | 不变 | 不变 | 提交早了,想把几个 commit 合并重新提交 |
--mixed(默认) | 会改变 | 会重置 | 不变 | 回退提交和暂存动作,但改动仍在,需要重新 add |
--hard | 会改变 | 会重置 | 会重置 | 彻底放弃改动,回到某个历史状态(危险操作) |
--soft:只改动分支指针,暂存区和工作区原封不动。效果相当于"撤回了提交动作,但改动内容还在,而且已经处于暂存状态"。--mixed(默认行为):改动分支指针的同时,用目标 commit 的 tree 内容覆盖暂存区,但工作区不受影响。效果是"撤回了提交和 add 这两步操作,文件的实际改动还在,只是需要重新 add"。--hard:分支指针、暂存区、工作区三者全部替换为目标 commit 的内容。这意味着尚未提交的改动会真实丢失,操作前务必谨慎。
6.1 安全网:reflog
即便执行了reset --hard或者误删了分支,只要对应的 commit 对象没有被垃圾回收(Git 默认保留 90 天),它依然完整地躺在.git/objects目录里,只是暂时没有任何引用指向它。
这时候git reflog就是你的救命稻草——它记录了HEAD曾经指向过的每一个位置,可以据此找回看似"丢失"的 commit。这也解释了为什么 Git 里的大多数"删除"操作实际上是可逆的:只要对象还在,就总有办法通过哈希把它找回来。
七、远程协作:本质是两个仓库互相传输对象
clone、fetch、pull、push这几个命令看似各不相同,实际上都建立在同一个模型之上:
每一个 Git 仓库都是一个完整独立的对象数据库,远程操作就是在两个对象数据库之间传输彼此缺失的对象,并同步分支引用的指向。
7.1 远程分支引用(Remote-tracking branches)
执行git clone之后,本地仓库会多出一套refs/remotes/origin/*引用,用于记录"上一次同步时,远程分支指向了哪个 commit":
.git/refs/heads/master ← 你的本地分支 .git/refs/remotes/origin/master ← 远程 master 上次同步时的位置快照理解了这套机制,几个命令的行为就一目了然:
git fetch:下载远程仓库有、本地没有的对象,然后只更新refs/remotes/origin/*这套引用——完全不会触碰你本地正在工作的分支,是绝对安全的操作。git pull实际上等于git fetch+git merge origin/<分支名>(如果加上--rebase参数,则变成fetch+rebase)。它之所以显得比fetch"危险"一些,正是因为它会自动把远程内容合并到你当前所在的分支。git push:把本地有、远程没有的对象上传,然后请求远程仓库把对应分支的引用移动到你推送的这个新 commit 上。如果远程分支当前的指向和你本地记录的origin/xxx不一致(说明远程存在你还没同步的新提交),Git 会拒绝这次推送(这被称为 non-fast-forward 错误),以防止你用陈旧的历史覆盖别人的新工作——这也是"先 pull 再 push"成为协作黄金法则的根本原因。
八、一张表串起所有命令
| 命令 | 底层原理 |
|---|---|
git init | 创建.git目录结构(objects、refs、HEAD) |
git add | 计算文件哈希 → 写入 blob 对象 → 更新.git/index |
git commit | 把暂存区打包成 tree 对象 → 创建 commit 对象(parent 指向当前 HEAD)→ 移动分支引用 |
git branch <name> | 在refs/heads/下新建一个文件,写入当前 commit 哈希 |
git checkout <branch> | 修改HEAD指向该分支;用目标 commit 对应的 tree 覆盖工作区 |
git merge(无分叉) | 移动引用指针(fast-forward) |
git merge(有分叉) | 三方对比 base/ours/theirs → 生成双 parent 的 merge commit |
git rebase | 逐个重新应用 diff → 生成全新哈希的 commit 序列 → 移动分支指针 |
git reset | 移动分支引用(+ 可选同步暂存区/工作区) |
git fetch | 下载远程独有的对象 → 更新refs/remotes/* |
git push | 上传本地独有的对象 → 请求远程移动其分支引用 |
结语
理解 Git 的关键,其实可以浓缩成三句话:
- 一切皆对象:commit、tree、blob 通过哈希互相引用,构成一个内容寻址的数据库;
- 分支只是指针:轻量、可以随意移动,本身不包含任何数据;
- 历史是一张有向无环图:普通提交是单链条,合并提交则让图产生分叉与汇聚。
把这三条原理刻在脑子里之后,你会发现自己不再需要死记硬背命令的用法——面对任何一个新命令、任何一次冲突、任何一次"看起来很吓人"的报错,你都能通过推导对象和指针的变化,准确判断接下来该怎么做。这才是真正掌握 Git,而不是仅仅"会用" Git。