很多同学在使用Git的时候,对基础命令非常熟练,例如:
这些基础指令基本不会出错。但一旦遇到复杂问题,就容易开始“玄学操作”,比如:
- reset --hard
- rebase
- force push
- ……
每一步都如履薄冰。
关键在于,会用Git并不等同于理解Git。大部分人的学习路径是:
- 先背命令
- 能提交代码
- 能协作
- 逐渐把Git当成“文件上传工具”
这条路径能快速上手,但留下一个隐患:你知道“怎么做”,却不知道“为什么”。当你面对以下问题时,就会感到困惑:
- Git到底保存的是差异(diff),还是整个项目的快照?
- 为什么Git的分支创建如此轻量,几乎是秒级的?
checkout 为什么那么快?它到底做了什么?
reset --soft / --mixed / --hard 的差别,究竟是“改了哪里”?
merge 和 rebase 到底谁更好?它们本质上有什么区别?
- 为什么Git天生就是分布式的?离开GitHub也能完整工作?
这些问题都指向同一个核心:理解Git的底层模型。学习Git的初期目标是掌握命令,后期则需要了解其内部结构。
这篇文章将帮你建立一个稳固的Git心智模型。我们不从命令列表讲起,而是深入Git的底层世界,探讨:
- Git内部到底存了哪些东西?
- commit、branch、HEAD这些概念,在底层是什么?
- 为什么Git能高效保存不同版本且不浪费空间?
只要理解了这个模型,许多看似复杂的命令都会变得简单。你将不再靠背命令,而是靠理解结构来驾驭Git。如果你希望系统性地了解更多开发者必备的基础与综合知识,欢迎访问云栈社区进行深入探讨。
一个关键认知:Git ≠ 文件管理工具
如果整篇文章你最需要记住一句话,那就是:
Git 不是一个文件管理工具。
这句话看似违背直觉,因为我们日常用Git做的事,几乎就是:
无论怎么看,Git都像一个文件管理工具。然而,这只是表象。Git看起来是在管理文件,但底层设计根本不关心“文件”这个概念。它不在意:
- 文件是
.js 还是 .txt
- 文件是不是“源代码”
- 文件名是否被重命名过
- 文件原来在哪个目录下
Git真正关心的只有一件事:当前这段内容是什么。
换句话说,对Git而言,文件名是“外壳”,路径结构是“组织方式”,它真正关心的是内容本身。
从底层设计看,Git是一个以“内容哈希”为索引的对象数据库。我们可以用一段极简的伪代码来模拟Git内部的存储模型:
// 一个极简的 Git 对象数据库示意
objectDatabase = {
"e68ff1...": {
type: "blob",
content: "console.log('Hello Git')"
},
"a3c912...": {
type: "tree",
entries: [
{ name: "index.js", hash: "e68ff1..." }
]
},
"f9b2d4...": {
type: "commit",
tree: "a3c912...",
parent: "b17c02...",
author: "Alice",
message: "Initial commit"
}
}
这段伪代码揭示了几个核心要点:
- Git只存对象,不存版本
- 哈希值不是随机的,而是基于内容计算出来的
- commit本身并不存储代码
下面我们来详细解释这三点。
1. Git 只存对象,不存版本
在Git内部,不存在“版本号”的概念。那Git如何记住历史呢?答案就是哈希值。
hash → object
在上述伪代码中:
"e68ff1..." → 指向这个blob对象
"a3c912..." → 指向这个tree对象
"f9b2d4..." → 指向这个commit对象
哈希就是唯一ID。这个ID的含义是:“只要你拿到这个hash,我就能给你完全一样的内容”。
来看一个具体例子。用户第一次提交,有一个JS文件,内容为:
console.log("Hello Git")
此时Git会:
算 hash → 创建 blob
算 hash → 创建 tree
算 hash → 创建 commit
于是数据库里多了几个对象:
{
"e68ff1": blob("Hello Git"),
"a3c912": tree(index.js -> e68ff1),
"f9b2d4": commit(tree: a3c912)
}
注意,Git并没有说这是v1版本,它只是记录:“我知道一个commit,hash是f9b2d4,它长这样。”
接下来用户第二次提交,将内容改为:
console.log("Hello Git v2")
Git再次执行相同流程:
{
"9aa111": blob("Hello Git v2"),
"bb2222": tree(index.js -> 9aa111),
"cc3333": commit(tree: bb2222, parent: f9b2d4)
}
现在数据库里有两个commit对象:
f9b2d4 (旧 commit)
cc3333 (新 commit)
但Git依然没有“版本号”的概念。
2. hash 不是随机的,而是基于内容算出来的
前面提到的哈希值是基于内容计算的:
hash = SHA1(type + content)
这意味着:
- 内容完全一样:计算出的hash一定相同
- 内容即使只改动一个字符:计算出的hash就完全不同
3. commit 本身并不存储代码
仔细观察commit的伪结构:
"f9b2d4...": {
type: "commit",
tree: "a3c912...",
parent: "b17c02...",
author: "Alice",
message: "Initial commit"
}
该对象有几个关键字段:
- tree:定义当前这一刻项目的完整状态
- parent:指向上一次提交的commit hash
3.1 tree
一句话总结:tree记录的是目录里每一项(文件/子目录)的 名字 + 类型 + 指向的对象hash。
假设项目结构如下:
project/
├── index.js
└── src/
└── util.js
在Git里,对应关系是:
index.js → blob(hash_index)
util.js → blob(hash_util)
src/ → tree(hash_src)
project/ → tree(hash_root)
这里的关键点:文件对应blob类型,目录对应tree类型,tree内部存储的是目录内容清单。
我们展开一个tree对象的“伪结构”:
"a3c912...": {
type: "tree",
entries: [
{
name: "index.js",
type: "blob",
hash: "e68ff1..."
},
{
name: "src",
type: "tree",
hash: "d41d8c..."
}
]
}
- name:当前目录下的文件名或目录名。
- type:如果是文件,值为
blob;如果是目录,值为tree。
- hash:指向真正的内容对象。
- 如果是blob,hash指向文件内容
- 如果是tree,hash指向子目录的tree
现在我们知道,tree的值也是一个hash。那么,tree的hash是如何得到的呢?
tree的hash是根据tree对象本身的内容计算出来的。 tree对象的内容完整描述了“当前目录在某一刻的结构状态”,包括:
- 当前目录下每一项的name(文件名/子目录名)
- 每一项的type(blob或tree)
- 每一项所指向的对象hash
只要这三者中的任意一个发生变化,例如:
- 新增或删除文件
- 文件或目录被重命名
- 文件内容变化(对应的blob hash改变)
- 子目录内部结构变化(子tree的hash改变)
都意味着tree对象的内容发生了变化,其hash也必然随之改变。
3.2 parent
在commit对象中,parent字段指向上一次提交的commit hash。它看似只是一个普通引用,但Git的整个历史模型几乎都建立在这个字段之上。
理解Git历史的关键:
- Git中没有“第1版/第2版”的概念
- Git的历史也不是一个数组
Git的历史是通过parent字段,一条一条连接起来的链式结构:

因此,Git的历史本质上就是一张有向无环图。
Git的底层世界:四种对象模型
到目前为止,我们反复提到一个词:对象。接下来看看Git底层究竟有哪几种对象类型。
在Git内部,所有数据最终都被存储为对象,共有4种类型:
| 对象类型 |
存储内容 |
主要作用 |
哈希计算基础 |
| Blob |
文件内容 |
存储实际数据 |
文件内容 |
| Tree |
目录结构 |
记录文件组织 |
Tree内容+引用 |
| Commit |
项目快照 |
记录版本历史 |
Tree+Parent+元数据 |
| Tag |
版本标记 |
标记重要版本 |
Commit+标签信息 |
1. blob
blob只存储文件内容,不存储文件名和路径。例如,有一个 index.js 文件,内容为:
console.log("Hello Git")
Git在内部只会记录类似这样的信息:
文件:hello.txt(内容:"Hello Git")
↓
生成Blob对象:
哈希值:3b18e512dba79e2239a6c93a32514e27458b84dd
内容:Hello Git
至于这个内容叫不叫 index.js,以及放在哪个目录下,blob完全不知道。
这也是为什么文件重命名对Git来说几乎“零成本”,多个文件只要内容完全一致,就可以复用同一个blob。
2. tree
blob描述一个文件的内容,tree则描述一个目录的结构状态。
项目结构:
myproject/
├── README.md
├── src/
│ └── main.py
└── .gitignore
对应的Tree对象:
───────────────────────────
Tree: a4b8c9d2...
├── blob 3b18e512... README.md
├── tree 5f7e8d9c...
│ └── blob 8a9b2c1d... main.py
└── blob 2x1y3z4a... .gitignore
───────────────────────────
tree对象的值是一个清单,列出了该目录下的每一项。每一项都是一个对象,包含:
- name:文件名/子目录名
- type:blob 或 tree
- hash:指向对应对象的hash
多个tree可以层层嵌套,从而完整描述整个项目的目录结构。理解这些核心数据结构是高效使用Git的关键,查阅专业的技术文档能帮你更深入地掌握其设计哲学。
3. commit
如果说blob表示内容,tree表示结构,那么commit的职责是把“某一刻的完整项目状态”固定下来。一个commit有两个核心作用:
- 通过
tree 字段指向一棵完整的目录树
- 通过
parent 字段指向历史中的上一个commit
Commit: 3c5d8f1a2b9e7c4d6a1f3e5b8c2d9a0f
Tree: a4b8c9d2... ← 指向此次提交的项目快照
Parent: 8x7y6z5a... ← 指向父提交(上一个版本)
Author: John Doe <john@example.com>
Date: Wed Jan 14 2026 10:30:00
Message: "Add new feature to main.py"
正因为如此,我们说:commit本身并不存代码,它只是快照的入口和历史的连接点。
4. tag
前三种对象我们已经熟悉:
- commit用hash唯一标识一次提交
- tree描述项目结构
- parent串起历史
但这里存在一个现实问题:hash对机器友好,对人却不友好。例如 f9b2d4a1c3e7... 对人来说几乎没有语义。
这正是tag对象存在的意义。tag的核心作用是给commit起一个稳定、有意义、不随时间变化的名字。例如:
v1.0.0 → commit abc123
这行信息意味着:
v1.0.0 代表项目的一个正式版本,它明确指向某一次commit
- 无论后续分支如何推进,这个指向关系都不会改变
这也是为什么在实际工程中,发布版本几乎总是使用tag,而不是依赖某个分支名。
标签分为两种:
- 轻量级标签:只是指向Commit的指针
- 附注标签:包含标签信息的对象
轻量级标签:
refs/tags/v1.0 → 3c5d8f1a2b9e...
附注标签对象:
Tag: f4e5d6c7...
Name: v1.0
Target: 3c5d8f1a2b9e... ← 指向某个Commit
Tagger: John Doe <john@example.com>
Date: Wed Jan 14 2026 15:45:00
Message: "Release version 1.0"
| tag和branch的区别(容易混淆): |
对比项 |
branch |
tag |
| 是否会移动 |
会,随着新提交前进 |
不会 |
| 语义 |
当前开发线 |
历史中的某一刻 |
| 常见用途 |
日常开发 |
版本发布 / 里程碑 |
一句话总结:branch表示“现在走到哪了”,tag表示“当时停在这里”。
四种对象的关系图
Tag (标签)
│
└─→ Commit (提交)
│
├─→ Parent Commit (父提交) ──→ ... → 最初提交
│
└─→ Tree (树/目录结构)
│
├─→ Blob (文件内容)
├─→ Blob (文件内容)
└─→ Tree (子目录)
│
└─→ Blob (文件内容)
典型的版本历史:
┌──────┐ ┌──────┐ ┌──────┐
│ C1 │───▶│ C2 │───▶│ C3 │
└──────┘ └──────┘ └──────┘
│ │ │
▼ ▼ ▼
Tree1 Tree2 Tree3
│ │ │
└─Blobs └─Blobs └─Blobs
v1.0 标签指向 C1
v2.0 标签指向 C3
这四种对象相互关联,形成了Git强大的版本控制能力。
实际工作流程
最后,我们通过一个实际工作流程,加深对这4种对象的理解。
操作1:创建并提交文件
echo "Hello" > file.txt
git add file.txt
git commit -m "Initial commit"
Git内部执行过程:
- 创建Blob对象:存储
"Hello" 内容
- 创建Tree对象:记录
file.txt 指向该Blob
- 创建Commit对象:指向该Tree,记录作者/时间/信息
- master分支指向这个Commit
操作2:修改文件并再次提交
echo "Hello World" > file.txt
git add file.txt
git commit -m "Update content"
Git内部执行过程:
- 创建新的Blob对象:存储
"Hello World" 内容
- 创建新的Tree对象:记录
file.txt 指向新的Blob
- 创建新的Commit对象:指向新的Tree,Parent指向前一个Commit
- master分支指向这个新Commit
操作3:打标签
git tag v1.0
Git内部执行过程: 创建Tag对象(或简单的引用),指向当前Commit。
关于Git工作原理的上半部分(核心对象模型)就先介绍到这里。理解了这些底层概念,你就能更从容地应对复杂的版本控制场景。