git用了很久其实也只会add commit push这三个操作,我现在用三篇blog来解释一下底层git的实现原理,和分支管理多人协作等。
1:Git是内容寻址库
在敲任何命令之前,我们必须先建立一个最核心的认知:Git 本质上是一个基于 SHA-1 哈希的键值对数据库,版本控制只是它的上层应用。
1:什么是内容寻址
当你向Git中存入任何内容时,Git会做两件事:
- 对内容执行 SHA-1 哈希算法,生成一个 40 位的十六进制字符串(比如
3b18e512dba79e4c8300dd08aeb37f8e728b8dad) - 以这个哈希值为 "键(key)",原始内容为 "值(value)",存入数据库
这个特性决定了 Git 的三个核心优势:
- 绝对完整性:任何内容的哪怕一个字节的修改,都会导致哈希值完全不同,Git 可以轻松校验数据是否被篡改
- 自动去重:完全相同的内容无论存入多少次,只会生成一个哈希值,只存储一份
- 分布式可靠性:每个克隆的仓库都包含完整的数据库,任意一个副本损坏都可以从其他机器恢复
2:亲手创建一个Git对象
我们不用 git init,先手动体验 Git 的数据库功能:
bash
# 创建一个空目录并进入
mkdir git-test && cd git-test
# 创建一个内容为 "hello git" 的文件
echo "hello git" > test.txt
# 让 Git 计算这个文件的哈希值(不存入数据库)
git hash-object test.txt
# 输出:3b18e512dba79e4c8300dd08aeb37f8e728b8dad
# 加上 -w 参数,将内容写入 Git 数据库
git hash-object -w test.txt
# 输出同样的哈希值
现在我们看看目录里发生了什么:
bash
# 查看当前目录,你会发现多了一个 .git 目录!
ls -la
# 输出:drwxr-xr-x 3 user group 96 May 20 10:00 .git
# 进入 .git/objects 目录,这就是 Git 的数据库
cd .git/objects && ls -la
# 输出:drwxr-xr-x 3 user group 96 May 20 10:00 3b
# 进入 3b 目录,你会看到剩下的 38 位哈希值作为文件名
cd 3b && ls
# 输出:18e512dba79e4c8300dd08aeb37f8e728b8dad
Git 为了优化文件系统性能,会把哈希值的前两位作为目录名,后 38 位作为文件名。现在我们可以用 Git 命令读取这个数据库里的内容:
bash
# 查看对象内容(可以只写前 7 位哈希值,Git 会自动补全)
git cat-file -p 3b18e51
# 输出:hello git
# 查看对象类型
git cat-file -t 3b18e51
# 输出:blob
我们刚刚手动创建了 Git 中最基础的对象 ------Blob 对象,它只存储文件的原始内容,不包含任何元数据(文件名、权限、修改时间等)。
2:初始化仓库.git目录
刚才我们通过 git hash-object -w 间接创建了 .git 目录,现在我们正式初始化一个标准的 Git 仓库,完整解析 .git 目录的结构。
bash
# 回到项目根目录,先删除刚才的 .git 目录
cd ../.. && rm -rf .git
# 正式初始化 Git 仓库
git init
# 输出:Initialized empty Git repository in /path/to/git-test/.git/
现在查看完整的 .git 目录结构:
bash
tree .git
# 输出(精简后):
.git
├── branches/ # 已废弃,仅用于向后兼容
├── config # 仓库级配置文件
├── description # 仓库描述,仅用于 GitWeb 服务
├── HEAD # 指向当前所在分支的指针(核心!)
├── hooks/ # 钩子脚本目录,可自定义自动化操作
│ ├── pre-commit.sample
│ ├── post-commit.sample
│ └── ...
├── info/
│ └── exclude # 全局忽略文件,优先级高于 .gitignore
├── objects/ # Git 对象数据库(核心!)
│ ├── info/
│ └── pack/
└── refs/ # 引用目录(核心!)
├── heads/ # 本地分支引用
└── tags/ # 标签引用
我们重点讲解三个核心文件 / 目录,其他的可以暂时不用管:
1: .git/HEAD
这是一个只有一行内容的文本文件,它告诉 Git:"你现在在哪个分支上"。
bash
cat .git/HEAD
# 输出:ref: refs/heads/master
这说明当前我们在 master 分支上。注意,这里 HEAD 不是直接指向一个提交,而是指向一个分支引用,这种状态叫做 "符号引用"。
2: .git/refs/
SHA-1 哈希值虽然唯一,但完全无法记忆。Git 用 "引用(Ref)" 解决这个问题:引用本质上就是一个文本文件,里面只存一个 SHA-1 哈希值,相当于给这个哈希值起了一个人类可读的名字。
.git/refs/heads/:存放本地分支引用,每个文件对应一个分支.git/refs/tags/:存放标签引用.git/refs/remotes/:存放远程仓库的分支引用(后面会讲)
现在我们看看 master 分支的引用:
bash
cat .git/refs/heads/master
# 输出:cat: .git/refs/heads/master: No such file or directory
为什么不存在?因为我们还没有任何提交,master 分支还没有被真正创建。Git 的分支只有在有提交指向它的时候才会实际存在。
3: .git/objects/
这就是我们之前见过的对象数据库,所有的文件内容、目录结构、提交信息都存在这里。现在它还是空的,等我们执行 git add 和 git commit 后,这里就会出现我们创建的各种对象。
3:完整的提交流程
现在我们来完成第一次真正的提交,并且每执行一步,就停下来看 .git 目录的变化。Git 的所有操作都围绕三个核心区域展开,我们会把每个命令和这三个区域的变化对应起来:
- 工作区:你电脑上能直接编辑的文件目录
- 暂存区(Index) :存放在
.git/index,临时存放即将提交的修改 - 版本库 :存放在
.git/objects/,永久存储所有提交的版本快照
1:在工作区修改文件
我们先创建两个文件,模拟真实的开发场景:
cpp
# 创建 README.md 和 main.c 文件
echo "# Git 测试项目" > README.md
echo "int main() { return 0; }" > main.c
# 查看工作区状态
git status
git status 的输出会告诉你:当前在 master 分支,没有任何提交,有两个未跟踪的文件(Untracked files)。
此时 .git 目录的变化:没有任何变化!Git 不会自动跟踪工作区的文件,除非你明确告诉它要这么做。

2:git add:将修改添加到暂存区
现在我们把这两个文件添加到暂存区:
bash
git add README.md main.c
# 或者用 git add . 一次性添加当前目录所有修改
看.git目录发生了什么
1:对象数据库多了两个Blob对象
bash
find .git/objects -type f
# 输出:
# .git/objects/79/874c4a6748a9277082874784f5b3106f308e0b
# .git/objects/8b/13791973e043695332443745309206f4645908
我们分别查看这两个对象:
bash
git cat-file -p 79874c4 # README.md 的内容
# 输出:# Git 测试项目
git cat-file -p 8b13797 # main.c 的内容
# 输出:int main() { return 0; }
这说明:git add 的本质,就是为工作区中修改过的每个文件创建对应的 Blob 对象,存入数据库。
2:生成了.git.index文件(暂存区)
bash
ls -la .git/index
# 输出:-rw-r--r-- 1 user group 144 May 20 10:30 .git/index
.git/index 是一个二进制文件,它记录了暂存区的所有信息:每个文件的文件名、权限、最后修改时间,以及对应的 Blob 对象哈希值。我们可以用 git ls-files 命令查看暂存区的内容:
bash
git ls-files -s
# 输出:
# 100644 79874c4a6748a9277082874784f5b3106f308e0b 0 README.md
# 100644 8b137973e043695332443745309206f4645908 0 main.c
这说明:暂存区本质上就是一个 "文件路径 → Blob 哈希值" 的映射表。它不存储文件内容本身,只存储指向内容的指针。
3:git commit :将暂存区永久存入版本库
现在我们把暂存区的内容提交到版本库:
bash
git commit -m "第一次提交:初始化项目"
# 输出:
# [master (root-commit) a1b2c3d] 第一次提交:初始化项目
# 2 files changed, 2 insertions(+)
# create mode 100644 README.md
# create mode 100644 main.c
见证奇迹的时刻!现在看 .git 目录的变化
1:又多了两个对象:Tree对象和Commit对象
bash
find .git/objects -type f
# 现在一共有 4 个对象了!
我们先看刚才生成的 Commit 对象(用你自己的提交哈希值):
bash
git cat-file -p a1b2c3d
# 输出:
# tree 4d83cfc9e7a35b6f1a9d5e7f8a3b6f1a9d5e7f8
# author Your Name <your@email.com> 1716200000 +0800
# committer Your Name <your@email.com> 1716200000 +0800
#
# 第一次提交:初始化项目
Commit 对象是版本历史的基本单位,它存储了:
- 指向根 Tree 对象的哈希值
- 作者和提交者信息(姓名、邮箱、时间戳)
- 提交信息
- 父提交哈希值(第一次提交没有父提交,所以没有这一行)
接下来我们看这个 Tree 对象:
bash
git cat-file -p 4d83cfc
# 输出:
# 100644 blob 79874c4a6748a9277082874784f5b3106f308e0b README.md
# 100644 blob 8b137973e043695332443745309206f4645908 main.c
Tree 对象对应文件系统中的目录,它存储了该目录下所有文件和子目录的信息:
- 文件权限(100644 表示普通文件)
- 对象类型(blob 表示文件,tree 表示子目录)
- 对象哈希值
- 文件名
如果我们有子目录,Git 会递归创建 Tree 对象。比如我们创建一个 src/utils.c 文件,提交后会生成:
- 一个 Blob 对象存储
utils.c的内容 - 一个 Tree 对象存储
src目录的信息(指向utils.c的 Blob) - 根 Tree 对象会新增一个条目指向
src的 Tree 对象
2:分枝引用被创建并更新
bash
cat .git/refs/heads/master
# 输出:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b
现在 master 分支的引用文件终于存在了,它的内容就是我们刚才这次提交的哈希值。这就是 Git 分支的本质:一个指向 Commit 对象的可移动指针。
3:HEAD仍然指向master分支
bash
cat .git/HEAD
# 输出:ref: refs/heads/master
4:提交流程完整总结
现在我们把整个提交流程的底层变化串起来:
- 你在工作区修改文件
- 执行
git add:- 为每个修改过的文件创建 Blob 对象,存入
.git/objects/ - 更新
.git/index文件,记录文件路径和 Blob 哈希值的映射
- 为每个修改过的文件创建 Blob 对象,存入
- 执行
git commit:- 根据暂存区的内容创建根 Tree 对象
- 创建 Commit 对象,指向根 Tree 对象,并记录提交信息
- 将当前分支的指针(如
master)移动到这个新的 Commit 对象 - HEAD 仍然指向当前分支
一个非常重要的认知 :Git 每次提交保存的不是 "文件的差异",而是整个项目目录的完整快照。如果一个文件在两次提交之间没有变化,Git 不会重复存储它,只会复用之前的 Blob 对象哈希值。
4:版本回退:三个参数的底层本质
现在我们修改一下文件,做第二次提交,然后演示版本回退:
bash
# 修改 main.c 文件
echo "int main() { printf(\"hello world\"); return 0; }" > main.c
# 提交
git add main.c
git commit -m "第二次提交:添加 hello world 输出"
现在我们有两个提交了:
bash
git log --pretty=oneline
# 输出:
# e4f5g6h (HEAD -> master) 第二次提交:添加 hello world 输出
# a1b2c3d 第一次提交:初始化项目
版本回退的核心命令是 git reset,它有三个常用参数,很多人搞不清它们的区别。其实只要从 "三个区域" 的角度去理解,就非常简单:git reset 的本质是 "移动 HEAD 指针",不同参数决定了是否同步更新暂存区和工作区。
1:git reset --soft:只移动HEAD指针
bash
# 回退到上一个版本(第一次提交)
git reset --soft HEAD^
底层变化
- 只修改了
.git/refs/heads/master的内容,变成了第一次提交的哈希值a1b2c3d .git/index(暂存区)没有变化- 工作区的文件没有变化
现在状态
bash
git status
# 输出:Changes to be committed:
# modified: main.c
相当于我们回到了 "已经执行完 git add,还没执行 git commit" 的状态。适用场景:刚提交完发现写错了提交信息,或者漏加了一个文件,想重新提交。
2:git reset --mixed(默认):移动HEAD指针+重置暂存区
这是不带参数时 git reset 的默认行为:
bash
# 先回到第二次提交
git reset --hard e4f5g6h
# 用默认模式回退
git reset HEAD^
底层变化:
- 修改了
.git/refs/heads/master的内容 - 用目标提交的 Tree 对象重置了
.git/index文件 - 工作区的文件没有变化
现在的状态
bash
git status
# 输出:Changes not staged for commit:
# modified: main.c
相当于我们回到了 "修改了文件,还没执行 git add" 的状态。适用场景:不小心把错误的文件添加到了暂存区,想撤回重新 add。
3:git reset --hard:移动HEAD+重置暂存区+重置工作区
这是最危险也最常用的模式:
bash
# 先回到第二次提交
git reset --hard e4f5g6h
# 彻底回退到第一次提交
git reset --hard HEAD^
底层变化:
- 修改了
.git/refs/heads/master的内容 - 用目标提交的 Tree 对象重置了
.git/index文件 - 用目标提交的 Tree 对象重置了工作区的所有文件
现在的状态:
bash
git status
# 输出:nothing to commit, working tree clean
cat main.c
# 输出:int main() { return 0; } # 回到了第一次提交的内容
4:急救措施git reflog
用 --hard 回退错了版本,发现最新的代码没了。别慌,git reflog 能帮你找回所有操作记录。
bash
# 查看所有操作历史
git reflog
# 输出:
# a1b2c3d (HEAD -> master) HEAD@{0}: reset: moving to HEAD^
# e4f5g6h HEAD@{1}: commit: 第二次提交:添加 hello world 输出
# a1b2c3d (HEAD -> master) HEAD@{2}: commit (initial): 第一次提交:初始化项目
git reflog 记录了 HEAD 指针的每一次移动,哪怕是已经被删除的提交也能看到。现在我们可以轻松回到第二次提交:
reflog 的底层原理 :所有的操作记录都存在 .git/logs/ 目录下,每个分支都有一个对应的日志文件:
bash
cat .git/logs/refs/heads/master
# 你会看到所有对 master 分支的操作记录
5:远程仓库:分布式协作的底层逻辑
Git 是分布式版本控制系统,这意味着每个开发者的电脑上都有一个完整的仓库。远程仓库(比如 GitHub、Gitee)本质上只是一个 "公共的服务器仓库",用来方便大家交换各自的修改。
1:关联远程仓库
首先我们在 GitHub 上创建一个空的仓库,然后关联到本地:
bash
# 关联远程仓库,origin 是远程仓库的默认别名
git remote add origin git@github.com:你的用户名/git-test.git
# 查看远程仓库信息
git remote -v
# 输出:
# origin git@github.com:你的用户名/git-test.git (fetch)
# origin git@github.com:你的用户名/git-test.git (push)
底层变化 :Git 在 .git/config 文件中添加了远程仓库的配置:
bash
cat .git/config
# 会看到新增了:
# [remote "origin"]
# url = git@github.com:你的用户名/git-test.git
# fetch = +refs/heads/*:refs/remotes/origin/*
2:git push将本地提交推送到远程
现在我们把本地的 master 分支推送到远程:
bash
git push -u origin master
push 命令的底层逻辑:
- 本地 Git 与远程仓库建立连接
- 比较本地分支和远程分支的提交历史
- 将本地有但远程没有的所有 Commit、Tree、Blob 对象全部上传到远程仓库的
.git/objects/目录 - 更新远程仓库的对应分支引用(比如
refs/heads/master) -u参数表示建立 "追踪关系",之后再 push 就不用写origin master了
底层变化 :本地 .git/refs/remotes/origin/master 文件被创建,内容是远程 master 分支的最新提交哈希值。这个文件叫做 "远程跟踪分支",它是远程分支在本地的一个只读镜像。
3:git clone克隆远程仓库
当你克隆一个远程仓库时,Git 会做以下几件事:
- 创建一个空的本地仓库
- 将远程仓库的所有对象(Commit、Tree、Blob)完整下载到本地
.git/objects/目录 - 在本地创建所有远程分支的远程跟踪分支(如
origin/master) - 创建一个本地 master 分支,指向与
origin/master相同的提交 - 检出 master 分支的内容到工作区
4:git fetch和git pull
git fetch origin master:只下载远程 master 分支的最新提交和对象,更新本地的origin/master远程跟踪分支,但不会修改本地的 master 分支和工作区git pull origin master:等价于git fetch origin master+git merge origin/master,先下载再合并到本地当前分支
最佳实践 :推荐优先使用 git fetch,然后手动查看远程的修改,确认没问题后再执行 git merge。这样可以避免自动合并带来的意外冲突。