对于 Git 相信大家一定不陌生,Git 相关的命令大家也经常使用,但是 Git 的底层原理你真的了解吗?接下来从 Git 到底在底层存了什么、提交时发生了什么、分支为什么很轻、合并/变基的原理是什么"等几个角度介绍 Git 的底层使用原理。
目录
[git 的四类核心对象](#git 的四类核心对象)
[一次 git add 发生了什么?](#一次 git add 发生了什么?)
[一次 git commit 发生了什么?](#一次 git commit 发生了什么?)
[工作区 vs 暂存区](#工作区 vs 暂存区)
[暂存区 vs HEAD](#暂存区 vs HEAD)
[HEAD 是什么?](#HEAD 是什么?)
[Detached HEAD(游离 HEAD)](#Detached HEAD(游离 HEAD))
[git checkout / git switch 的底层原理](#git checkout / git switch 的底层原理)
[合并 git merge 的原理](#合并 git merge 的原理)
[Fast-forward merge](#Fast-forward merge)
[Three-way merge](#Three-way merge)
[git rebase 的原理](#git rebase 的原理)
[git reset 的底层原理](#git reset 的底层原理)
[--soft:只移动 HEAD](#--soft:只移动 HEAD)
[--mixed:移动 HEAD,并重置暂存区](#--mixed:移动 HEAD,并重置暂存区)
[--hard:移动 HEAD,重置暂存区和工作](#--hard:移动 HEAD,重置暂存区和工作)
[git diff 的原理](#git diff 的原理)
[git diff](#git diff)
[git diff --cached](#git diff --cached)
[git diff HEAD](#git diff HEAD)
[git stash 的原理](#git stash 的原理)
[git fetch 的原理](#git fetch 的原理)
[git pull 的原理](#git pull 的原理)
[git push 的原理](#git push 的原理)
[Git 为什么能恢复很多"误删"?](#Git 为什么能恢复很多“误删”?)
[垃圾回收 git gc](#垃圾回收 git gc)
git的本质:内容寻址文件系统
git是一个基于内容寻址的对象数据库,git会把项目中的内容存成一个个对象,并用这些对象内容的哈希值作为ID,早期git使用SHA-1,现在也支持SHA-256。你常见的提交ID:a1b2c3d4... 本质上就是某个git对象的哈希值。
git目录:git仓库的核心
当你执行 git init 时,git会在当前目录下创建一个隐藏目录 .git/ ,这个目录就是git仓库真正的数据库,常见结构如下:

几个关键部分:
| 路径 | 作用 |
|---|---|
| .git/objects/ | 存放 Git 对象 |
| .git/refs/heads/ | 存放分支指针 |
| .git/HEAD | 当前所在分支或提交 |
| .git/index | 暂存区 |
| .git/config | 仓库配置 |
| .git/logs/ | reflog 记录 |
git 的四类核心对象
git 底层主要有四种对象:
blob、tree、commit、tag
blob:文件内容对象
blob 保存的是文件内容,不包含文件名。所以如果两个文件内容完全相同,它们可以指向同一个 blob 对象。
注意:Git 的 blob 只关心内容,不关心文件名。
例如你有一个文件:hello.txt,内容是:hello git,git 会把 hello git 这段内容存成一个 blob 对象。
blob 哈希 = sha1(文件内容) (目前支持sha256)
tree:目录结构对象
tree 保存的是目录结构。它记录:文件名、文件权限、文件对应的 blob ID、子目录对应的 tree ID
可以理解为:
tree
├── hello.txt -> blob abc123
├── README.md -> blob def456
└── src/ -> tree 789abc
总结:blob 保存文件内容;tree 保存文件名和目录结构
commit:提交对象
commit 保存一次提交的信息。
一个 commit 对象通常包含:
树对象(tree)的哈希值:代表当前项目根目录下所有文件和文件夹的完整快照。
父提交的哈希值:代表当前提交的上一个版本
作者信息:姓名、邮箱、时间戳(精确到秒)
提交者信息:姓名、邮箱、时间戳
提交说明:你写的 git commit -m "..." 里面的内容
例如
commit C3
├── tree T3
├── parent C2
└── message: fix bug
如果是普通提交,它有一个 parent。
如果是合并提交,它可能有两个或多个 parent。
C4
/ \
C2 C3
\ /
C5 <- merge commit
tag:标签对象
tag 一般用来给某个 commit 打标签,例如:git tag v1.0.0
轻量标签本质上只是一个引用。
附注标签则会创建一个 tag 对象,里面包含:
1.指向的 commit:标识这个标签固定在哪个具体的提交上
2.tag 名称:例:tag v1.0.0,标签的引用名称,存储在 tag 对象内部
3.tagger 信息:例:tagger xiaowang <wang@example.com> 1707123456 +0800,记录谁、在什么时候打了这个标签
包含四个部分:
|---------|-------------|------------------|
| 姓名 | 创建标签的用户名 | xiaowang |
| 邮箱 | 创建标签的邮箱 | wang@example.com |
| 时间戳 | Unix 时间戳(秒) | 1707123456 |
| 时区 | UTC 偏移量 | +0800(北京时间) |
4.message:标签的说明文字,类似 commit message
5.可选的 GPG 签名:用 GPG 私钥对 tag 对象进行数字签名
创建轻量标签&附注标签:
| 命令 | 标签类型 | 是否签名 |
|---|---|---|
| git tag <name> | 轻量标签 | ❌ |
| git tag -a <name> -m "msg" | 附注标签(无签名) | ❌ |
| git tag -s <name> -m "msg" | 附注标签(GPG 签名) | ✅(默认密钥) |
| git tag -u <key-id> <name> -m "msg" | 附注标签(GPG 签名) | ✅(指定密钥) |
总结:轻量标签和附注标签的区别在于,前者本质是提交的别名/引用,后者会创建独立的git对象。
一次 git add 发生了什么?
假设你修改了一个文件:echo "hello" > a.txt,然后执行:git add a.txt,Git 会做几件事:
第一步:计算文件内容哈希
Git 读取 a.txt 内容,并生成一个 blob 对象。可以理解为:a.txt 内容 -> hash -> blob id
第二步:把 blob 存入 .git/objects/
Git 会把对象压缩后存入:.git/objects/,对象路径类似:.git/objects/ab/cdef123456...,其中前两位哈希作为目录名,后面部分作为文件名。
第三步:更新暂存区 index
.git/index 是暂存区。它会记录:a.txt -> blob-id,也就是说:
git add 不是简单地"标记文件已添加",而是把当前文件内容写入对象库,并更新 index。
一次 git commit 发生了什么?
执行:git commit -m "add a.txt",Git 会做这些事:
第一步:根据 index 生成 tree 对象
暂存区中记录了所有文件路径和 blob ID。Git 会根据 index 生成一个 tree 对象,表示当前项目快照。
tree T1
└── a.txt -> blob B1
第二步:生成 commit 对象
commit 对象指向这个 tree。
commit C1
├── tree T1
├── parent null
└── message "add a.txt"
如果之前已经有提交,那么新 commit 会记录上一个 commit 作为 parent:
commit C2
├── tree T2
├── parent C1
└── message "update a.txt"
第三步:移动当前分支指针
如果当前在 main 分支,那么:refs/heads/main,会从旧 commit 指向新 commit。
例如提交前:main -> C1,提交后:main -> C2
工作区、暂存区、本地仓库
Git 日常使用中有三个重要区域:
| 工作区 | 暂存区 | 本地仓库 |
| Working | Index | Repository |
| Directory | Stage | .git |
|---|
对应关系:
修改文件
↓ git add
暂存区
↓ git commit
本地仓库
git status 看到的其实就是 Git 在比较这三者的状态:
工作区 vs 暂存区
Git 比较 当前文件内容 vsindex 中记录的 blob,如果不同,说明文件被修改但未暂存。
暂存区 vs HEAD
Git 比较:index 中的内容 vs HEAD 指向的 commit,如果不同,说明有内容已经暂存但未提交。
HEAD 是什么?
.git/HEAD 是一个指针文件,它永远指向当前所在的引用(分支或提交)。
HEAD指向分支
在 main 分支上
git checkout main
cat .git/HEAD
输出:ref: refs/heads/main
指针链:HEAD -> refs/heads/main -> commit C2
.git/HEAD 内容:ref: refs/heads/main
↓
.git/refs/heads/main 内容:9fceb02d0ae5... (commit C2 的哈希)
↓
Commit C2 (实际的对象)
当你执行 git commit 时,Git 会:
- 创建新的 commit C3
- 修改 .git/refs/heads/main 指向 C3
- HEAD 本身不变(仍然指向 main 分支)
最终效果:HEAD -> main -> C3
Detached HEAD(游离 HEAD)
如果你 checkout 到某个具体 commit:git checkout a1b2c3,此时 HEAD 直接指向 commit,而不是分支:HEAD -> a1b2c3,这叫:detached HEAD,也就是"游离 HEAD"。
分支的底层原理
了解分支底层原理之前,首先回顾一下一个很重要的概念:
HEAD 指针:这是一个极其重要的特殊指针。它的作用就是告诉 Git "你现在在哪个分支上"。当你切换分支时,本质就是修改 HEAD 文件的内容。
假设当前有两个分支main和dev:
.git/refs/heads/main 文件:这个文件的内容非常简单,就是一个 40 位的哈希值,例如 a1b2c3d4...。这个哈希值,就是当前 main 分支所指向的那个 commit 对象的 ID。
.git/refs/heads/dev 文件:同理,它也是一个文本文件,里面也是一个 40 位的哈希值。
所以,分支的本质就是:保存在 .git/refs/heads/ 目录下、内容为一个 commit ID 的文本文件。 这个文件的名字,就是分支名;文件的内容,就是它指向的 commit。
"创建分支非常快"的原因也就在这里。 执行 git branch dev,Git 并没有复制任何文件、没有创建任何项目的快照。它只是:
1.创建一个新的文本文件 .git/refs/heads/dev。
2.读取当前 HEAD 指向的 commit ID。
3.把这个 ID 写入 .git/refs/heads/dev 文件。
整个过程就是一次文件写入操作,所以几乎是瞬间完成。
同样,执行 git switch dev 切换分支相当于:修改 HEAD 文件的内容,让它从指向 main 改为指向 dev。
git checkout / git switch 的底层原理
执行:git switch dev,Git 做两件事:
第一:更新 HEAD
HEAD -> refs/heads/dev
第二:更新工作区和暂存区
Git 会把工作区文件更新成 dev 分支指向的 commit 对应的 tree。
也就是说:commit -> tree -> blob -> 工作区文件
合并 git merge 的原理
假设有两个分支:
C3 dev
/
C1--C2 main
你在 main 上执行:git merge dev
Git 会根据情况选择不同策略。
Fast-forward merge
如果 main 是 dev 的祖先:
C1--C2 main
\
C3 dev
那么合并只需要把 main 指针移动到 C3:
C1--C2--C3 main/dev
这叫快进合并。
底层只是移动引用指针,不会产生新的 commit。
Three-way merge
如果两个分支都各自有新提交:
C3 dev
/
C1--C2
\
C4 main
Git 会寻找共同祖先 C2,然后比较:
base: C2
ours: C4
theirs: C3
然后生成一个新的 merge commit:
C3
/ \
C1--C2 C5 main
\ /
C4
这个 merge commit 有两个 parent:
parent C4
parent C3
冲突是怎么来的?
冲突通常发生在三方合并时。Git 会比较:
- 共同祖先版本
- 当前分支版本
- 目标分支版本
如果两个分支修改了同一个地方,Git 无法自动判断采用哪一个,就会产生冲突。
例如:有一个文件hello.txt,内容只有hello
共同祖先:hello
main 改成:hello main
dev 改成:hello dev
当你执行 git merge dev 时(此时在 main 分支上),Git 进行三方对比:
祖先:hello
main:hello main (改成了带 main 的)
dev:hello dev (改成了带 dev 的)
Git 发现:两个分支都修改了同一行,且结果不同。它无法决定保留哪一个,于是暂停合并,把冲突标记写入文件。
此时打开hello.txt,你会看到:
<<<<<<< HEAD
hello main
=======
hello dev
>>>>>>> dev
- <<<<<<< HEAD 到 =======:这是当前分支 (main) 的内容 (hello main)。
- ======= 到 >>>>>>> dev:这是目标分支 (dev) 的内容 (hello dev)。
- HEAD 是 Git 对"当前分支最新提交"的称呼。
你解决冲突后:
git add hello.txt # 告诉 Git:这个文件我处理好了
git commit # 完成合并
git rebase 的原理
merge 是把两个分支历史合起来。rebase 是把一段提交"搬家"。
假设:
D1--D2 dev
/
A--B--C main
在 dev 上执行:git rebase main
Git 会做:
-
找到 dev 和 main 的共同祖先 B
-
找出 dev 上独有提交 D1、D2
-
暂存这些提交对应的改动
-
把 dev 移到 main 最新提交 C
-
重新应用 D1、D2 的改动,生成新提交 D1'、D2'
结果:
A--B--C main
\
D1'--D2' dev
注意:rebase 会创建新的 commit,因为 commit 的 parent 变了,commit hash 也会变。
git reset 的底层原理
git reset 主要是在移动 HEAD 或分支指针,并根据参数决定是否修改 index 和工作区。
常见三种:
- git reset --soft
- git reset --mixed
- git reset --hard
撤回到指定提交:git reset --soft <commit_id>
撤回到上几次提交:git reset --soft HEAD~n
假设你有一个文件 test.txt,内容如下:
第一次提交:文件内容是 "v1"
echo "v1" > test.txt
git add test.txt
git commit -m "first commit" # 假设这个提交哈希是 aaaaaa
第二次提交:文件内容改为 "v2"
echo "v2" > test.txt
git add test.txt
git commit -m "second commit" # 假设这个提交哈希是 bbbbbb
现在的状态:
- 工作区:v2
- 暂存区:v2
- 版本库:aaaaaa (v1) ← bbbbbb (v2)
- HEAD 指向 bbbbbb
--soft:只移动 HEAD
git reset --soft HEAD~1 # 回到上一个提交,但保留暂存区和工作区
结果:
- HEAD 指向 aaaaaa(撤销了 bbbbbb 提交)
- 暂存区:仍然是 v2
- 工作区:仍然是 v2
- 实际效果:你相当于执行了 git commit 的逆操作 ------ 提交没了,但修改还在暂存区,可以直接重新 git commit。
--mixed:移动 HEAD,并重置暂存区
git reset --mixed HEAD~1(默认模式,可省略 --mixed)
结果:
- HEAD 指向 aaaaaa(撤销了 bbbbbb 提交)
- 暂存区:被重置为 aaaaaa 时的状态,即 v1(因为 --mixed 会用 HEAD 指向的内容填充暂存区)
- 工作区:仍然是 v2
- 实际效果:提交没了,修改还在工作区,但不在暂存区。需要重新 git add 才能再次提交。
--hard:移动 HEAD,重置暂存区和工作
git reset --hard HEAD~1(危险,慎用)
结果:
- HEAD 指向 aaaaaa(撤销了 bbbbbb 提交)
- 暂存区:被重置为 aaaaaa 时的状态,即 v1
- 工作区:也被强制重置为 aaaaaa 时的状态,即 v1
- 实际效果:提交 bbbbbb 以及你随后对 test.txt 的所有修改(v2)彻底丢失。
总结:
| 命令 | 是否移动 HEAD 和分支指针 | 是否重置暂存区 | 是否重置工作区 | 安全性 | 主要用途 |
| git reset --soft <commit> | ✅ 是 | ❌ 否(保留) | ❌ 否(保留) | 最安全 | 撤销 commit,但保留修改,准备重新提交 |
| git reset --mixed <commit> (默认) | ✅ 是 | ✅ 是(清空) | ❌ 否(保留) | 较安全 | 撤销 commit + unstage 文件,保留修改内容 |
| git reset --hard <commit> | ✅ 是 | ✅ 是(清空) | ✅ 是(覆盖) | 危险 ⚠️ | 彻底丢弃所有修改,回到指定状态 |
|---|
git diff 的原理
不同命令比较的是不同区域。
git diff
比较:工作区 vs 暂存区
表示还没有 git add 的修改。
git diff --cached
比较:暂存区 vs HEAD
表示已经 git add 但还没有 commit 的修改。
git diff HEAD
比较:工作区 vs HEAD
表示从最近一次提交以来的所有修改。
git stash 的原理
执行:git stash
Git 会把当前未提交的修改临时保存成特殊 commit,然后恢复工作区。
可以理解为:当前改动 -> stash commit -> stash 栈
查看:git stash list
恢复:git stash pop
本质上 stash 也是通过 commit 对象保存的,只是引用位置在:refs/stash
远程仓库的底层原理
当你添加远程仓库:git remote add origin git@github.com:user/repo.git,Git 会在配置中记录:
remote "origin"
url = ...
fetch = ...
远程分支通常表现为:
origin/main
origin/dev
这些引用存放在:.git/refs/remotes/origin/ 或被打包到:.git/packed-refs
git fetch 的原理
执行:git fetch origin,Git 会:
-
连接远程仓库
-
比较本地缺少哪些对象
-
下载缺少的 commit、tree、blob
-
更新远程跟踪分支,例如 origin/main
注意:git fetch 不会直接修改你的本地分支和工作区。
git pull 的原理
git pull 本质上是:git fetch + git merge(默认)或 git fetch + git rebase(如果配置了rebase)
所以 git pull 并不是一个单独的神秘操作,而是先下载,再整合。
查看当前的 pull 配置:
git config --global pull.rebase
修改为默认使用 rebase:
git config --global pull.rebase true
git push 的原理
执行:git push origin main,Git 会:
-
找出远程仓库缺少的对象
-
上传这些对象
-
请求远程更新引用 refs/heads/main
如果远程分支不是你的本地分支的祖先,Git 会拒绝 push:non-fast-forward,因为直接更新会导致远程已有历史被覆盖。
如果你强制推送:git push --force,就是要求远程把分支指针强行移动到你的本地提交上。
更安全的是:git push --force-with-lease,它会检查远程分支是否还是你上次看到的状态,避免误覆盖别人的提交。
Git 为什么能恢复很多"误删"?
因为 Git 有 reflog。
例如:git reflog,它记录 HEAD 和分支引用的移动历史:
C3 HEAD@{0}: reset: moving to C1
C2 HEAD@{1}: commit: update
C1 HEAD@{2}: commit: init
即使你 reset 了,只要对象还没有被垃圾回收,执行:
git reset --hard HEAD@{1} 或 git checkout <commit-id>,通常可以找回来。
垃圾回收 git gc
Git 对象一开始可能是松散对象:.git/objects/ab/cdef...
随着对象增多,Git 会压缩它们到 packfile:
.git/objects/pack/
├── pack-xxx.pack
└── pack-xxx.idx
执行:git gc,Git 会:
- 清理不可达对象
- 压缩对象
- 合并 packfile
- 优化仓库性能
所谓不可达对象,是指没有任何分支、标签、reflog 等能引用到的对象。比如,执行 git commit --amend 修改最近一次提交,会创建一个新的提交替换掉原来的提交,原来的提交会变成"悬空"状态,最终被 Git 垃圾回收。
完整例子
假设你执行:
git init
echo "hello" > a.txt
git add a.txt
git commit -m "init"
底层大概是:
工作区:a.txt = "hello"
git add 后:
blob B1 = "hello"
index:a.txt -> B1
git commit 后:
tree T1:a.txt -> B1
commit C1:
- tree -> T1
- parent -> null
- message -> init
main -> C1
HEAD -> main
再修改:
echo "hello v2" > a.txt
git add a.txt
git commit -m "update"
底层变成:
blob B2 = "hello v2"
tree T2:a.txt -> B2
commit C2:
- tree -> T2
- parent -> C1
- message -> update
main -> C2
HEAD -> main
历史关系:C1 -- C2,每个 commit 都指向一个完整项目快照。
本文总结
| 命令 | 本质 |
|---|---|
| git add | 把文件内容写入对象库,更新 index |
| git commit | 根据 index 创建 tree 和 commit,移动分支指针 |
| git branch | 创建一个新的 commit 指针 |
| git checkout / switch | 移动 HEAD,并更新 index/工作区 |
| git merge | 合并提交图,可能创建 merge commit(git merge --no-ff feature 强制创建merge commit,不使用快进) |
| git rebase | 复制提交到新的 base 上 |
| git reset | 移动 HEAD/分支,并按模式更新 index/工作区 |
| git fetch | 下载对象并更新远程跟踪分支 |
| git pull | fetch + merge/rebase |
| git push | 上传对象并请求远程更新引用 |
如有问题,欢迎交流指正!