一篇文章讲透 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 的提交历史本质上是一个有向无环图DAGDirected 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 最新 commitgit 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 addgit 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 的对比维度MergeRebase是否创建新 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 fetchgit merge origin/分支名如果加上--rebase参数则变成fetchrebase。它之所以显得比fetch危险一些正是因为它会自动把远程内容合并到你当前所在的分支。git push把本地有、远程没有的对象上传然后请求远程仓库把对应分支的引用移动到你推送的这个新 commit 上。如果远程分支当前的指向和你本地记录的origin/xxx不一致说明远程存在你还没同步的新提交Git 会拒绝这次推送这被称为 non-fast-forward 错误以防止你用陈旧的历史覆盖别人的新工作——这也是先 pull 再 push成为协作黄金法则的根本原因。八、一张表串起所有命令命令底层原理git init创建.git目录结构objects、refs、HEADgit add计算文件哈希 → 写入 blob 对象 → 更新.git/indexgit commit把暂存区打包成 tree 对象 → 创建 commit 对象parent 指向当前 HEAD→ 移动分支引用git branch name在refs/heads/下新建一个文件写入当前 commit 哈希git checkout branch修改HEAD指向该分支用目标 commit 对应的 tree 覆盖工作区git merge无分叉移动引用指针fast-forwardgit merge有分叉三方对比 base/ours/theirs → 生成双 parent 的 merge commitgit rebase逐个重新应用 diff → 生成全新哈希的 commit 序列 → 移动分支指针git reset移动分支引用 可选同步暂存区/工作区git fetch下载远程独有的对象 → 更新refs/remotes/*git push上传本地独有的对象 → 请求远程移动其分支引用结语理解 Git 的关键其实可以浓缩成三句话一切皆对象commit、tree、blob 通过哈希互相引用构成一个内容寻址的数据库分支只是指针轻量、可以随意移动本身不包含任何数据历史是一张有向无环图普通提交是单链条合并提交则让图产生分叉与汇聚。把这三条原理刻在脑子里之后你会发现自己不再需要死记硬背命令的用法——面对任何一个新命令、任何一次冲突、任何一次看起来很吓人的报错你都能通过推导对象和指针的变化准确判断接下来该怎么做。这才是真正掌握 Git而不是仅仅会用 Git。