你有没有过这种经历:Git用得挺溜,commit、push、pull、merge一套组合拳下来很顺畅,但某天分支乱了、提交找不回来、或者报错信息里出现一串看不懂的哈希值时,整个人就懵了。我当年学Git也有这个阶段,直到我花了一个周末彻底啃完"Git对象"这一章,才感觉自己真正摸到了Git的骨架。这篇笔记就是基于我在学习第一章时的双语精译内容整理出来的,重点放在Git对象上。适合已经会基本Git操作、但想深入理解底层原理的读者,也适合正在啃官方文档但被术语劝退的人。
这篇笔记能帮你解决一个核心问题:Git到底是怎么把文件存下来的。搞懂这件事之后,你再去看分支管理、版本回退、 cherry-pick、rebase这些操作,就会觉得它们全都是在同一个逻辑上做文章,而不是靠死记硬背命令。我会从对象模型讲起,然后带你用底层命令亲手造一次提交,再拆解日常操作背后的对象流转,最后分享一些我实际踩过的坑和排查方法。
1. Git对象模型:先搞懂Git到底在存什么
1.1 从.git目录说起
每一个Git仓库根目录下都有一个.git文件夹,日常开发中我们基本不会去动它,但它才是Git真正的主场。你的工作目录里那些文件反而是"可以被替换的视图",Git真正持久化保存的一切都在.git里。
我第一次打开.git目录时,说实话有点失望,里面看起来就是一堆普通文件和文件夹。但仔细看就会发现一个叫objects的目录,这个目录就是Git对象数据库的实体位置。你在Git里做的每一次提交、每一个文件版本,最终都会以对象的形式写进这个目录里。
可以这样理解:Git不是一个"记录文件变化"的系统,而是一个"存储内容快照"的系统。每次提交时,Git会把当时所有文件的内容按照一定规则打包成对象存下来,然后通过对象之间的引用关系串成一条历史链。这就是为什么Git切换分支那么快——它不用去算差异,只要把对应快照的文件内容还原到工作目录就行。
1.2 四种对象类型和它们的关系
Git对象一共就四种:blob、tree、commit、tag。刚接触的人容易混淆,尤其是blob和tree的区别,我当年也绕了一阵子。
- blob对象:存储的是单个文件的内容,不包含文件名、路径、权限这些元信息。你可以把它看成"文件内容的快照"。
- tree对象:存储目录结构信息,记录了一个目录下有哪些子项,每个子项是blob还是tree,以及对应的文件名和权限。一个tree对象就代表一个目录快照。
- commit对象:存储一次提交的元信息,包括作者、提交者、时间、提交说明,以及指向一个tree对象(代表这次提交时整个项目的根目录快照),还有父提交的引用。
- tag对象:这个用得相对少一些,通常用于给某个commit打一个固定的名字,比如发布版本号时用。它指向一个commit,而且正常情况下不会被移动。
它们的关系可以这样看:commit指向tree,tree下面挂着blob和子tree,逐层展开就能还原出某次提交时的完整文件状态。相当于commit是某次"拍照"的相册封面,tree是相册里的目录页,blob是每张照片本身。
为了更直观,我整理了一个对比表:
| 对象类型 | 存储内容 | 是否包含文件名 | 典型用途 |
|---|---|---|---|
| blob | 文件内容 | 否 | 保存文件某个版本的内容 |
| tree | 目录结构 | 是 | 记录目录下文件名、权限、子项引用 |
| commit | 提交元信息 | 不适用 | 记录一次提交的tree、父提交、作者和时间 |
| tag | 标签信息 | 不适用 | 给指定commit打固定标签 |
1.3 对象寻址:SHA-1哈希为什么是核心
Git里的每个对象都有一个唯一ID,这个ID是对象内容经过SHA-1哈希计算后得到的40位十六进制字符串。有些人会问,为什么用哈希当名字?两个原因:一是内容寻址,只要对象内容不变,ID就不会变;二是完整性校验,读取对象时可以重新计算哈希,和对象名对比,从而发现文件是否损坏。
这里有个容易忽略的细节:两个内容完全相同的文件,在不同目录下甚至不同仓库里,生成的blob对象哈希是一样的。因为blob不存文件名和路径,只存内容。这也是Git存储效率高的原因之一——重复内容不会重复存储。当你把一个文件复制到十个目录时,Git其实只存了一份内容,tree对象里用相同哈希引用它就行。
我在学习时做过一个实验:在两个不同的仓库里创建内容相同的文件,然后看.git/objects下的哈希,结果确实一模一样。这个实验强烈建议你也做一次,能帮你真正理解"内容寻址"的含义。
1.4 对象在.git/objects里是怎么存放的
如果你打开.git/objects目录,会看到一堆两位十六进制的子目录,比如ab、c9这种,每个目录里有一些38位十六进制文件名的文件。一个对象的完整哈希是40位,前两位作为目录名,后38位作为文件名。这么设计的原因很简单:避免单个目录里文件过多,分散存储能提升文件系统性能。
另外还有两个特殊目录:info和pack。pack目录里放着打包后的对象文件,这是Git做垃圾回收时产生的;info目录里有一些辅助信息。对于初学者,理解pack目录不用太着急,后面第4节我会展开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心命令实操:用底层命令亲手造一个提交
2.1 环境准备与仓库初始化
这个实验建议你用一个全新的临时目录来做,避免把你正在开发的项目搞乱。我在学习时就是专门建了一个git-object-lab文件夹来折腾。
bash复制mkdir git-object-lab
cd git-object-lab
git init
初始化完,你会发现.git目录已经生成。这时候用find命令看看objects目录的结构:
bash复制find .git/objects -type f
正常情况是什么输出都没有,因为还没有任何对象被创建。但如果你加上-type d,会看到info和pack两个空目录。这说明Git在仓库初始化时就已经为对象存储做好了准备。
2.2 hash-object与cat-file:读写对象的入门钥匙
第一个要掌握的底层命令是git hash-object,它的作用是把一段内容存入对象数据库,并返回对应的哈希值。常用的是-w选项,表示真正写入对象库;不加-w的话只计算哈希,不写入。
试一下最简单的场景,创建一个文本文件并写入内容:
bash复制echo "hello git objects" | git hash-object -w --stdin
--stdin表示从标准输入读取内容。执行后会输出一个40位哈希,比如8baef1b4abc478178b0071e9b4e2f4f4f4d4e7c1(具体值取决于内容)。这时再看.objects目录,会发现多了一个以哈希前两位命名的子目录。
那怎么读取对象内容?用git cat-file。两个常用参数:-t查看对象类型,-p查看对象内容。
bash复制git cat-file -t 8baef1b4abc478178b0071e9b4e2f4f4f4d4e7c1
git cat-file -p 8baef1b4abc478178b0071e9b4e2f4f4f4d4e7c1
第一个命令会输出blob,第二个会输出hello git objects。到这里,你已经完成了第一个Git对象的创建和读取。这种"亲手操作"带来的理解,比看十遍文档都管用。
再做一个实验验证"内容寻址":往同一个仓库里再写入同样的内容:
bash复制echo "hello git objects" | git hash-object -w --stdin
虽然你会得到同样的哈希,但这次不会新增文件,因为对象已经存在了。这就是Git去重机制的底层表现。
2.3 手动构建tree对象
blob对象只是文件内容,真正把文件名和目录结构串起来的是tree对象。Git提供了一个底层命令git mktree用来手动创建tree对象,但更常用的方式是用git write-tree把暂存区的内容写成tree对象。
先验证一下当前暂存区状态:
bash复制git status
这时候工作区有文件但还没add,暂存区是空的。执行git write-tree,Git会告诉你暂存区没有内容。所以先add一下:
bash复制git add hello.txt
git write-tree
此时会输出一个tree对象的哈希。用git cat-file -t看一下类型,确认是tree。再用git cat-file -p看内容,你会看到类似这样的输出:
code复制100644 blob 8baef1b4abc478178b0071e9b4e2f4f4f4d4e7c1 hello.txt
这行记录的含义是:权限100644(普通文件)、类型blob、对象哈希、文件名hello.txt。tree对象本质就是这样一个清单。
2.4 手动构建commit对象
有了tree对象,下一步就是创建commit对象。commit对象只是附加上作者、提交时间、提交说明和父提交信息。手动创建用git commit-tree:
bash复制echo "first commit" | git commit-tree <tree哈希>
执行后会生成一个commit对象的哈希。git cat-file -p查看内容,会看到类似:
code复制tree 3f5b1c2d4e...
author Your Name <you@example.com> 1712345678 +0800
committer Your Name <you@example.com> 1712345678 +0800
first commit
注意这里没有parent字段,因为这是第一个提交。如果创建第二个commit,需要把第一个commit的哈希作为父提交传进去:
bash复制echo "second commit" | git commit-tree <第二个tree哈希> -p <第一个commit哈希>
这样第二个commit对象里就会有parent字段,指向第一个commit。一条提交链就这样形成了。
到这里,你已经用最底层的方式完整复现了一次Git提交过程。以后你用git commit时,心里就有底了:它无非就是在背后帮你做了"写blob、构建tree、创建commit对象"这一整套动作。
3. 从暂存区到提交:理解Git日常操作的底层逻辑
3.1 暂存区本质上是待写入tree的清单
很多教程说暂存区是"存放待提交内容的地方",这个说法没错,但不够本质。从对象角度看,暂存区其实是索引文件,路径是.git/index。它记录着当前暂存状态下,每个文件路径对应的blob对象哈希、文件权限、以及一些时间戳信息。
当你执行git add时,Git做了两件事:第一,把文件内容打包成blob对象写入对象库;第二,更新.git/index里的记录,让这个文件路径指向新的blob哈希。如果文件已经被add过,再修改后重新add,会生成一个新的blob对象,index里的引用同步更新,旧blob还留在对象库里。
这个设计的巧妙之处在于:暂存区不需要复制文件内容,它只存引用关系。所以无论文件多大,add操作都很快,因为它只是把内容压成对象,再更新一条索引记录。
3.2 git add和git commit在底层干了什么
我把整个流程串一下,这样你能在脑子里形成一条完整链路。
执行git add hello.txt时:
- 读取hello.txt的内容,计算SHA-1哈希。
- 如果对象库里没有相同哈希的对象,就把内容压缩后写入.git/objects。
- 更新.git/index,新增或修改一条记录:路径hello.txt -> blob哈希。
执行git commit时:
- 读取当前.git/index里的全部记录。
- 根据这些记录构建tree对象(如果暂存区没有变化,Git会复用之前的tree对象)。
- 创建commit对象,包含tree哈希、作者和提交者信息、提交说明、父提交哈希。
- 更新当前分支引用,让它指向新创建的commit对象。
日常使用中你不需要关心这些细节,但理解之后再看某些现象就不会困惑了。比如为什么git commit提示"nothing to commit"?因为index里的内容和HEAD指向的commit里的tree内容完全一致,没有生成新tree的必要,也就不需要新commit。
还有一个容易混淆的点:git commit -a并不是跳过暂存区,而是先把所有已跟踪且被修改的文件自动执行一遍add,再走正常commit流程。本质上没有绕过对象模型。
3.3 分支、HEAD和对象引用链
搞懂了对象,再看分支就简单了。分支本质上是一个指向commit对象的可变引用,它存储在.git/refs/heads/目录下,文件名就是分支名,文件内容就是该分支最新commit的哈希。
比如.git/refs/heads/main文件里存着main分支最新提交的哈希。当你新建分支时,Git只是创建一个新文件,把当前HEAD指向的commit哈希写进去。切换到分支时,Git读取对应文件里的commit哈希,把工作目录内容还原成那个commit对应的tree快照。
HEAD则是一个特殊引用,表示"当前在哪个分支上"。它通常存在.git/HEAD文件里,内容不是哈希,而是一个引用路径,比如ref: refs/heads/main。当你commit时,Git读取HEAD指向的分支文件,更新那个文件里的哈希。
这样整条链路就通了:HEAD -> 分支引用 -> commit对象 -> tree对象 -> blob对象。日常所有操作,最终都是在移动这串链条上的某个环节。
我见过不少人在学习分支时背命令,比如git branch、git checkout、git switch,但搞不懂为什么切换分支后工作目录会变。理解了对象引用链,你会发现分支切换其实就两步:改HEAD的指向,然后根据新的commit哈希对应的tree重新填充工作目录。思路清晰了,命令自然就记住了。
4. 对象不可变性与存储优化
4.1 为什么Git对象不可变
Git对象一旦写入对象库,就永远不会被修改。这一点和很多人的直觉相反——版本管理工具难道不应该"修改记录"吗?
关键在于,Git的"修改"不是覆盖旧对象,而是生成新对象。你改了文件内容,add后会生成新的blob对象;commit后会生成新的commit对象,它的parent指向旧commit。旧对象不会消失,只是不再被任何引用指向。
这个设计带来的好处很多:
- 历史不可篡改。只要对象还在,你就能通过哈希验证内容是否被改动。
- 支持任意回溯。因为所有历史对象都还在,checkout到任何历史提交都是完全可行的。
- 并发安全。多个分支同时提交时,只是在创建新对象,不存在"同时修改同一个文件"的冲突。
代价就是对象数量会不断增长。同一个文件改十次,就有十个blob对象;即便内容变化很小,每个版本都会被完整保存。这也是为什么Git仓库用久了会变大,需要垃圾回收机制来优化。
4.2 垃圾回收与packfile
Git的垃圾回收(gc)负责两件事:一是把松散对象打包成packfile,二是删除不可达的对象。我第一次跑git gc时,特别好奇packfile里到底是什么东西。
packfile是一种二进制格式,里面可以包含多个对象,并且会做增量压缩。比如一个文件有十个版本,packfile不会简单地把十份完整内容都塞进去,而是存一个完整版本,再存几条差异记录,这样体积能大幅缩小。你可以在.git/objects/pack目录下看到两类文件:.pack是真正的数据,.idx是索引文件,用于快速定位某个哈希在packfile里的位置。
git gc会自动触发,也可以手动执行。对于普通项目,不用过度干预;但如果仓库特别大,或者你刚执行过大规模历史改写(比如filter-repo),跑一次git gc能明显瘦身。
需要注意,git gc默认只会清理"不可达"对象,也就是没有任何引用指向的对象,比如你创建了一个commit但分支已经移到别处,那个commit就成了不可达对象。但reflog里可能还有记录,所以GC不会立刻删掉它。只有clearly expired的reflog条目才会被彻底清理。
4.3 对象存储的安全保障
Git能在断电、误删、文件损坏等情况下尽量保证数据安全,很大程度上依赖对象机制和GC策略的分层保护。日常使用中我建议:
- 不要手动删除.git/objects里的任何文件。真出问题用
git fsck检查和恢复。 - 重要仓库定期执行
git gc,能减少对象碎片,提升后续操作性能。 - 遇到"object file is empty"之类的报错,多半是对象文件损坏或没写完整,优先考虑从远端重新clone或从备份恢复。
5. 常见问题与排查技巧实录
5.1 误删分支后如何找回提交
这是我在工作中实际遇到过的场景。有次我误删了一个还没合并的分支,当时大脑一片空白,以为几个小时的代码全没了。后来冷静下来,想起reflog可以救命。
bash复制git reflog
reflog会列出HEAD的所有历史移动记录,每一条都包含一个commit哈希。找到被删分支最后一次指向的哈希,然后基于它重新创建分支:
bash复制git branch recover-branch <commit哈希>
这样就找回了一个新分支,内容完全对得上。原理其实很简单:分支引用文件被删了,但commit对象本身还在对象库里,没有被GC清理。Git的reflog默认保留90天,所以只要不是太久远,基本都能救回来。
5.2 reflog与对象恢复
除了分支误删,还有一个常见场景:你git reset --hard后想找回之前的提交。同样用reflog:
bash复制git reflog
# 找到目标提交哈希
git reset --hard <commit哈希>
reflog记录的是HEAD指针的移动历史,不只是分支操作,包括checkout、commit、reset、merge都会记录。它是普通用户最容易忽略的"后悔药"机制。
这里有个细节:reflog是按HEAD维度记录的,如果你操作的对象是某个分支而不是HEAD(比如git branch -f修改分支位置),那个分支自己的reflog里也有记录。查看方式:
bash复制git reflog show <分支名>
5.3 git fsck检查仓库完整性
如果怀疑对象库有损坏,或者想找出所有不可达对象,用git fsck:
bash复制git fsck --full
它会扫描对象库里的所有对象,检查哈希完整性,并列出"dangling"对象。这些dangling对象就是没有被任何引用指向但还存在的对象。如果你发现自己丢了提交,这里可能会给你线索。
在误删分支后,即使reflog里找不到记录,也可以试试git fsck --lost-found,Git会把所有不可达对象提取出来放进.git/lost-found目录。虽然文件名看着不友好,但内容一般都是完整的。
5.4 新手最常踩的坑
结合我自己的学习经历和帮别人排查问题的经验,整理几个高频坑点:
| 问题现象 | 根本原因 | 解决办法 |
|---|---|---|
| commit后显示"nothing to commit" | 暂存区内容与HEAD相同 | 检查index是否有变化,确认是否忘了add |
| 切换分支后工作区文件"消失" | 当前分支的tree里没有这个文件 | 这是正常现象,切回原分支就能看到 |
.git目录体积异常大 |
对象库中累积了大量历史blob | 执行git gc进行打包压缩 |
| 误删分支找不回来 | 分支引用被删,但commit对象还在 | 用git reflog或git fsck找回 |
| 克隆时报对象错误 | 远端对象损坏或网络中断 | 重新clone,或从备份恢复 |
还有一个我特别想提醒的:不要直接把.git目录从一个位置复制到另一个位置,尤其是跨操作系统时。对象文件本身是跨平台通用的,但文件权限、换行符配置等可能导致问题。复制仓库的正确方式是git clone,或者用git bundle打包迁移。
另外,很多人问"Git对象模型学了到底有什么用"。我的体验是,它解决的核心痛点是:当Git报错、行为诡异、或者需要做复杂历史操作时,你不会心里没底。比如理解了tree和blob的关系,你就能理解为什么Git可以跟踪"文件重命名"——其实Git根本不跟踪重命名,它只是比较内容哈希,发现旧路径的blob和新路径的blob一模一样,就推断这是重命名。这种底层认知会让你对Git"信任感"完全不同。
我自己在学完这一章后做了一件事,把日常高频操作对应的对象变化画成流程手写了一遍。什么场景会创建blob,什么时候创建tree,什么时候创建commit,分支移动的是什么引用。画完之后,很多命令不再需要查文档了。建议你也试试。
最后再分享一个小技巧:如果你在阅读Git官方文档时遇到"对象"这个词,别急着跳过,它指的就是这里说的四种对象。理解了这四种对象的职责和关系,官方文档读起来会顺畅很多。Git的学习曲线之所以陡,不是因为命令多,而是因为这些概念藏得深。这篇笔记如果能帮你跨过这道坎,我的目的就达到了。
