Git 底层原理系列 · 第4讲 --- git add 与 git commit 底层做了什么
⏱️ 预计阅读时间:22 分钟
目录
- [📚 学习导航](#📚 学习导航 "#-%E5%AD%A6%E4%B9%A0%E5%AF%BC%E8%88%AA")
- [⚡ 认知冲突](#⚡ 认知冲突 "#-%E8%AE%A4%E7%9F%A5%E5%86%B2%E7%AA%81")
- [1 全景俯瞰:一次 commit 的完整数据流](#1 全景俯瞰:一次 commit 的完整数据流 "#1-%E5%85%A8%E6%99%AF%E4%BF%AF%E7%9E%B0%E4%B8%80%E6%AC%A1-commit-%E7%9A%84%E5%AE%8C%E6%95%B4%E6%95%B0%E6%8D%AE%E6%B5%81")
- [2 第一步:git add --- 把文件变成 blob](#2 第一步:git add — 把文件变成 blob "#2-%E7%AC%AC%E4%B8%80%E6%AD%A5git-add--%E6%8A%8A%E6%96%87%E4%BB%B6%E5%8F%98%E6%88%90-blob")
- [3 第二步:git add --- 更新 Index(暂存区)](#3 第二步:git add — 更新 Index(暂存区) "#3-%E7%AC%AC%E4%BA%8C%E6%AD%A5git-add--%E6%9B%B4%E6%96%B0-index%E6%9A%82%E5%AD%98%E5%8C%BA")
- [4 第三步:git commit --- 创建 Tree 对象](#4 第三步:git commit — 创建 Tree 对象 "#4-%E7%AC%AC%E4%B8%89%E6%AD%A5git-commit--%E5%88%9B%E5%BB%BA-tree-%E5%AF%B9%E8%B1%A1")
- [5 第四步:git commit --- 创建 Commit 对象](#5 第四步:git commit — 创建 Commit 对象 "#5-%E7%AC%AC%E5%9B%9B%E6%AD%A5git-commit--%E5%88%9B%E5%BB%BA-commit-%E5%AF%B9%E8%B1%A1")
- [6 第五步:git commit --- 更新引用(Ref)](#6 第五步:git commit — 更新引用(Ref) "#6-%E7%AC%AC%E4%BA%94%E6%AD%A5git-commit--%E6%9B%B4%E6%96%B0%E5%BC%95%E7%94%A8ref")
- [7 完整实验:用底层命令手动模拟一次 commit](#7 完整实验:用底层命令手动模拟一次 commit "#7-%E5%AE%8C%E6%95%B4%E5%AE%9E%E9%AA%8C%E7%94%A8%E5%BA%95%E5%B1%82%E5%91%BD%E4%BB%A4%E6%89%8B%E5%8A%A8%E6%A8%A1%E6%8B%9F%E4%B8%80%E6%AC%A1-commit")
- [git commit --amend 底层做了什么](#git commit --amend 底层做了什么 "#8-git-commit---amend-%E5%BA%95%E5%B1%82%E5%81%9A%E4%BA%86%E4%BB%80%E4%B9%88")
- [总结:一张图看懂 add + commit](#总结:一张图看懂 add + commit "#%E6%80%BB%E7%BB%93%E4%B8%80%E5%BC%A0%E5%9B%BE%E7%9C%8B%E6%87%82-add--commit")
- 自测卡片
- [🎮 上瘾学习路径](#🎮 上瘾学习路径 "#-%E4%B8%8A%E7%98%BE%E5%AD%A6%E4%B9%A0%E8%B7%AF%E5%BE%84")
📚 学习导航
| 项目 | 内容 |
|---|---|
| 前置知识 | 第3讲:blob / tree / commit 对象 |
| 核心问题 | Q1: git add 到底往 .git/ 写了什么? Q2: git commit 在底层创建了几个对象? Q3: Index(暂存区)到底是什么? |
| 预计收获 | 彻底理解 add + commit 的每一步底层操作;能不用 git add/git commit 纯手工完成一次提交 |
| 阅读路径 | 顺序阅读;强烈建议在终端跑完第7节的完整实验 |
⚡ 认知冲突
你以为
git add只是"把文件标记为要提交"?实际上,
git add做了两件具体的事:① 把文件内容转成 blob 对象 写入.git/objects/② 在 Index 文件 (.git/index)中记录这个文件的条目。git commit则做了三件事:① 从 Index 创建 tree 对象 ② 创建 commit 对象 ③ 更新 分支引用。所以一次
git add+git commit,在.git/目录下至少创建了 blob × N + 1 tree + 1 commit 个对象。
1 全景俯瞰:一次 commit 的完整数据流
bash
工作区(Working Directory)
│
│ git add file.txt
▼
┌─────────────────────────────┐
│ ① 创建 blob 对象 │ → 写入 .git/objects/xx/xxxx...
│ ② 更新 Index(暂存区) │ → 写入 .git/index
└─────────────────────────────┘
│
│ git commit -m "message"
▼
┌─────────────────────────────┐
│ ③ 从 Index 创建 tree 对象 │ → 写入 .git/objects/xx/xxxx...
│ ④ 创建 commit 对象 │ → 写入 .git/objects/xx/xxxx...
│ ⑤ 更新分支引用 │ → 写入 .git/refs/heads/<branch>
└─────────────────────────────┘
│
▼
一次 commit 完成
一次 git add + git commit 在磁盘上至少产生:
- N 个 blob(每个新增/修改的文件一个)
- 1 个或多个 tree(根目录一个,每个子目录一个)
- 1 个 commit
- 1 次 ref 更新
下面我们一步一步拆开看。
2 第一步:git add --- 把文件变成 blob
当你在终端敲下 git add
bash
echo "hello" > file.txt
git add file.txt
git add 内部调用了 git hash-object -w:
bash
# 这其实就是 git add 做的第一件事
git hash-object -w file.txt
# → 8b1a9953c4611296a827abf8c47804d7cd6c54e4
这个命令做了三件事:
1. 读取文件内容
swift
file.txt 的内容 → "hello\n"
2. 构造 blob 存储格式
sql
blob 6\0hello\n
格式:blob <内容长度(字节)>\0<原始内容>
3. 计算 SHA-1 哈希
bash
# 验证一下(不写入文件,只看 hash)
echo "hello" | git hash-object --stdin
# → 8b1a9953c4611296a827abf8c47804d7cd6c54e4
# -w 参数表示写入 .git/objects/
echo "hello" | git hash-object --stdin -w
# 写入 .git/objects/8b/1a9953c4611296a827abf8c47804d7cd6c54e4
4. zlib 压缩后写入磁盘
bash
# 查看写入的文件
ls -l .git/objects/8b/1a9953c4611296a827abf8c47804d7cd6c54e4
# 用 git 命令查看原始内容
git cat-file -p 8b1a9953c4611296a827abf8c47804d7cd6c54e4
# → hello
# 查看类型
git cat-file -t 8b1a9953c4611296a827abf8c47804d7cd6c54e4
# → blob
重要细节:Git 做了哪些优化
bash
# 如果文件很大,Git 会:
# 1. 分块存储(对于大文件)
# 2. 用 zlib 压缩(默认压缩级别)
# 3. 相同的文件内容 → 相同的 hash → 只存一次
# 验证去重:
echo "hello" > a.txt
echo "hello" > b.txt
git add a.txt b.txt
# a.txt 和 b.txt 的内容一样 → 只有一个 blob!
# .git/objects 下只有一个 8b1a9953...
# Index 中两个文件都指向同一个 blob hash
3 第二步:git add --- 更新 Index(暂存区)
Index 是什么
Index(也叫暂存区、staging area)是一个二进制文件 ,位于 .git/index。
它不是对象的集合,而是一个扁平的目录列表------记录了每个文件当前的 blob hash、文件模式、时间戳等信息。
Index 的二进制结构
bash
┌──────────────────────────────────────────┐
│ 文件头(12 字节) │
│ 签名 "DIRC"(4 字节) │
│ 版本号(4 字节) │
│ 条目数量(4 字节) │
├──────────────────────────────────────────┤
│ 条目 1(每个条目 62+ 字节) │
│ ├── 创建时间(8 字节) │
│ ├── 修改时间(8 字节) │
│ ├── 设备号(4 字节) │
│ ├── inode(4 字节) │
│ ├── 文件模式(4 字节) │
│ ├── UID(4 字节) │
│ ├── GID(4 字节) │
│ ├── 文件大小(4 字节) │
│ ├── 文件 hash(20 字节 = SHA-1) │
│ ├── 标记(2 字节) │
│ └── 文件名(可变长度,以 \0 结尾) │
├──────────────────────────────────────────┤
│ 条目 2 ... │
├──────────────────────────────────────────┤
│ SHA-1 校验和(20 字节,对整个文件) │
└──────────────────────────────────────────┘
git add 更新 Index 时的操作
bash
git add file.txt
# 内部:
# 1. hash-object -w file.txt → 得到 blob hash
# 2. 在 .git/index 中查找 file.txt 的旧条目
# 3. 如果存在:更新 blob hash 和时间戳
# 4. 如果不存在:新增条目
# 5. 重新计算整个 .git/index 的 SHA-1 校验和
为什么 Index 中要记录时间戳和 inode?
为了性能优化。 当你运行 git status 时,Git 不需要重新计算每个文件的 hash------它只需要比较文件的修改时间(mtime)和 inode 是否和 Index 中的记录一致。如果一致,说明文件没变,跳过快照。
bash
# 这就是为什么 git status 秒出结果
# 它不是在读取所有文件,而是在比较 inode/mtime
Index vs 工作区 vs 仓库
sql
工作区文件 Index(暂存区) 仓库(.git/objects)
┌─────────┐ ┌──────────┐ ┌──────────────┐
│ file.txt│─────▶│ file.txt │────────▶│ blob hash │
│ (修改后) │ │ (blob │ │ tree │
│ │ │ hash) │ │ commit │
└─────────┘ └──────────┘ └──────────────┘
git add git commit
两个关键认知:
-
git add不是"把文件加到 Git 里"------它只是把文件内容转成 blob,并更新 Index 中的指针。文件本身的元数据(文件名、目录结构)还没有被 Git 记录,它们只在 Index 中。 -
Index 是"下一次 commit 的计划" ------你
git add了哪些文件,下一次git commit就会把哪些文件的当前状态包含进快照。
4 第三步:git commit --- 创建 Tree 对象
当你运行 git commit,Git 做的第一件事是:从 Index 创建一个 tree 对象。
Tree 的创建过程
bash
# git commit 内部调用了:
git write-tree
# → d3f1a5b4c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1
write-tree 做的事情:
markdown
1. 读取整个 .git/index 文件
2. 遍历所有条目,按目录结构分组
3. 为每个子目录创建 tree 对象
4. 递归向上,最终创建根 tree
5. 根 tree 的 hash 就是 commit 中要记录的 tree 字段
具体例子
假设 Index 中有三个文件:
css
src/main.js → blob abc123
src/utils.js → blob def456
README.md → blob 789abc
write-tree 会创建:
sql
根 tree(hash: T1)
├── 100644 blob 789abc README.md
└── 040000 tree T2 src/
├── 100644 blob abc123 main.js
└── 100644 blob def456 utils.js
如果没有任何文件被 git add,Index 是空的,git write-tree 会失败。
Tree 对象写入磁盘
bash
.git/objects/ab/
└── cd1234... ← tree 对象,zlib 压缩
关键点
- Tree 是快照的骨架------它保存了完整的目录结构
- Blob 是快照的实体------它保存了每个文件的内容
- 一个 tree 对应一个目录------有多少个子目录,就有多少个 tree 对象
5 第四步:git commit --- 创建 Commit 对象
Commit 的创建过程
有了 tree 之后,Git 创建 commit 对象:
bash
# git commit 内部调用了:
echo "commit message" | git commit-tree <tree-hash> -p <parent-hash>
# → 012def...
commit-tree 收集五个信息:
bash
git cat-file -p 012def...
# → tree d3f1a5b4c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1 # 从 write-tree 得到
# → parent a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 # 从 HEAD 解析得到
# → author Yang Yangwen <yangwen@example.com> 1712345678 +0800 # 从 git config 得到
# → committer Yang Yangwen <yangwen@example.com> 1712345678 +0800 # 从 git config 得到
# →
# → commit message
五个字段的来源
| 字段 | 从哪里来 | 说明 |
|---|---|---|
| tree | write-tree 的输出 |
当前目录结构的快照入口 |
| parent | HEAD 指向的 commit |
上一个 commit 的 hash(首次提交没有 parent) |
| author | git config user.name / user.email |
谁写的代码 |
| committer | git config user.name / user.email |
谁提交的 |
| message | -m 参数或编辑器输入 |
提交说明 |
首次提交 vs 后续提交
首次提交(没有 parent):
vbnet
commit abc
tree: T1
parent: (nil) ← 没有 parent 字段
author: ...
committer: ...
msg: "initial commit"
后续提交(有一个 parent):
vbnet
commit def
tree: T2
parent: abc ← 指向首次提交
author: ...
committer: ...
msg: "second commit"
合并提交(有两个 parent):
vbnet
commit ghi
tree: T3
parent: def ← 来自 main 分支
parent: xyz ← 来自 feature 分支
author: ...
committer: ...
msg: "Merge branch 'feature'"
6 第五步:git commit --- 更新引用(Ref)
创建完 commit 对象后
Commit 对象已经写入磁盘了,但 Git 还不知道"当前分支的最新 commit 是哪个"。
最后一步:更新分支引用。
bash
# git commit 内部调用了:
git update-ref refs/heads/main 012def...
Ref 是什么
Ref(reference)是一个指针文件,内容就是 commit 的 hash。
less
.git/
├── refs/
│ ├── heads/
│ │ └── main ← 内容: 012def...(当前分支最新 commit)
│ └── tags/
│ └── v1.0 ← 内容: a1b2c3...(某个 tag)
└── HEAD ← 内容: ref: refs/heads/main
bash
# 查看 ref 的内容
cat .git/refs/heads/main
# → 012def012def012def012def012def012def012def
HEAD 是什么
HEAD 是一个特殊的指针------它指向"当前正在使用的分支"。
bash
cat .git/HEAD
# → ref: refs/heads/main
当 git update-ref refs/heads/main 012def... 被执行时,HEAD 不需要改变------因为 HEAD 指向 refs/heads/main,而这个文件的内容被更新了。
如果当前分支是 main
sql
执行前:
HEAD → refs/heads/main → commit abc
git commit 执行后:
HEAD → refs/heads/main → commit def(新的)
commit abc(旧的,仍然是 def 的 parent)
如果当前不在分支上(detached HEAD)
sql
HEAD → commit abc(直接指向 commit,不经过分支)
git commit 执行后:
HEAD → commit def(新的)
这种情况下,分支引用不会被更新。这就是 detached HEAD 状态下 commit 会"丢失"的原因------没有任何分支指向新 commit。
7 完整实验:用底层命令手动模拟一次 commit
这次实验不使用
git add和git commit,纯手工完成一次完整的提交。
第1步:初始化
bash
mkdir ~/sandbox/manual-commit && cd ~/sandbox/manual-commit
git init
初始状态:
bash
.git/objects/ → 空的
.git/refs/heads/ → 空的
.git/HEAD → ref: refs/heads/main
.git/index → 空的(还没有文件)
第2步:手动 add(创建 blob + 更新 Index)
bash
# 创建文件
echo "Hello Git Internals" > README.md
# ① 创建 blob
BLOB_HASH=$(git hash-object -w README.md)
echo "Blob hash: $BLOB_HASH"
# ② 更新 Index
git update-index --add --cacheinfo 100644 $BLOB_HASH README.md
此时:
bash
.git/objects/xx/xxxx... ← 有一个 blob
.git/index ← 有一条记录指向这个 blob
第3步:手动 commit(创建 tree + commit + 更新 ref)
bash
# ③ 从 Index 创建 tree
TREE_HASH=$(git write-tree)
echo "Tree hash: $TREE_HASH"
# ④ 创建 commit(首次提交,没有 parent)
COMMIT_HASH=$(echo "manual commit - no git add/git commit" | git commit-tree $TREE_HASH)
echo "Commit hash: $COMMIT_HASH"
# ⑤ 更新分支引用
git update-ref refs/heads/main $COMMIT_HASH
第4步:验证
bash
# 查看 log
git log
# → commit <hash> (HEAD -> main)
# → manual commit - no git add/git commit
# 查看内容
git show --stat
# 查看 .git 目录结构
find .git/objects -type f
# 查看 ref
cat .git/refs/heads/main
对比高层命令
| 你的手工操作 | 等价的高层命令 |
|---|---|
git hash-object -w README.md + update-index |
git add README.md |
git write-tree + git commit-tree + update-ref |
git commit -m "..." |
你刚刚用 5 条底层命令完成了 git add + git commit 的所有工作。
8 git commit --amend 底层做了什么
git commit --amend 不是"修改上一次 commit"------它是创建一个新的 commit 来替换旧的。
--amend 的内部流程
bash
git commit --amend -m "new message"
底层执行:
markdown
1. 读取当前 HEAD 指向的 commit
2. 获取它的 tree(不变)和 parent(不变)
3. 如果有新的 git add 文件,创建新的 tree
4. 创建新的 commit 对象:
- tree: 和旧 commit 相同(或更新后的 tree)
- parent: 和旧 commit 的 parent 相同(不是旧 commit 本身!)
- message: 新的提交信息
5. 更新 refs/heads/<branch> 指向新 commit
示意图
sql
--amend 之前:
commit abc ← main
parent: nil
tree: T1
msg: "first draft"
--amend 之后:
commit abc(依然存在,但不再被任何分支引用)
commit def ← main(替换了 abc)
parent: nil(和 abc 的 parent 一样)
tree: T1(和 abc 一样,除非有 stage 的修改)
msg: "better message"
旧的 commit abc 没有被删除 ------它只是从分支历史中"脱离"了。它仍然在 .git/objects/ 中,直到 git gc 清理它。
为什么 --amend 不是"修改"而是"替换"
因为 Git 对象是不可变 的。你无法修改一个已经存在的 commit 对象。--amend 只是创建了一个新的 commit,然后移动分支指针指向新 commit。
bash
# --amend 前后 commit hash 一定不同
git log --oneline -1
# → abc1234 (HEAD -> main) first draft
git commit --amend -m "revised message"
git log --oneline -1
# → def5678 (HEAD -> main) revised message ← hash 变了!
总结:一张图看懂 add + commit
sql
git add git commit
┌─────────────────────┐ ┌──────────────────────────┐
│ │ │ │
工作区 │ ① hash-object -w │ Index │ ③ write-tree │ 仓库
file1 ──┼──→ blob1 ─┼────────┼──→ tree ←── tree1 │
file2 ──┼──→ blob2 ─┼────────┼──→ tree ←── tree2 │
│ ② update-index │ │ ④ commit-tree │
│ │ │ tree: tree1 │
│ │ │ parent: old-commit │
│ │ │ message: "..." │
│ │ │ │
│ │ │ ⑤ update-ref │
│ │ │ refs/heads/main ────→│ commit
└─────────────────────┘ └──────────────────────────┘
一次 git add + git commit 的磁盘影响
| 步骤 | 创建了什么 | 写入哪里 |
|---|---|---|
git add |
N 个 blob 对象 | .git/objects/xx/xxxx |
git add |
更新 Index | .git/index |
git commit |
1 个或多个 tree 对象 | .git/objects/xx/xxxx |
git commit |
1 个 commit 对象 | .git/objects/xx/xxxx |
git commit |
更新 ref 文件 | .git/refs/heads/<branch> |
核心认知
git add的本质:把文件内容转成不可变的 blob,并规划下一次 commit 的内容git commit的本质:把 Index 中的规划"冻结"成一个不可变的快照(tree + commit)- Index 的本质:下一次 commit 的"草稿区",一个扁平的目录条目表
- Ref 的本质:一个可移动的指针,指向当前分支的最新 commit
自测卡片
Q1:运行 `git add file.txt` 后,`.git/` 目录下发生了什么变化?
A: 两件事:① 如果 file.txt 的内容从未在仓库中出现过,一个新的 blob 对象被写入 .git/objects/xx/xxxx;如果内容之前出现过,则复用已存在的 blob。② .git/index 中 file.txt 的条目被创建或更新,指向新(或已有)的 blob hash。
Q2:Index(`.git/index`)中存了什么信息?
A: Index 是一个扁平的二进制文件,每个条目包含:文件路径、blob hash、文件模式(权限)、时间戳(mtime)、inode 号、文件大小等。它是一个"目录条目表",不是对象存储。git status 通过比较工作区文件的 mtime/inode 和 Index 中的记录来判断文件是否被修改。
Q3:`git commit --amend` 真的"修改"了上一次提交吗?
A: 没有。Git 对象是不可变的。--amend 创建了一个新的 commit 对象,然后移动分支指针指向新的 commit。旧的 commit 依然存在于 .git/objects/ 中,但不再被任何分支引用,直到 git gc 清理。所以 --amend 前后的 commit hash 一定不同。
Q4:为什么在 detached HEAD 状态下 commit 会"丢失"?
A: 正常 commit 的最后一步是 update-ref refs/heads/<branch> 更新分支引用。但 detached HEAD 没有指向任何分支,所以 commit 创建后,只有 HEAD 直接指向它。当你 checkout 到其他 commit/branch 时,HEAD 移动了,新的 commit 没有任何引用指向它------它变成了"悬空对象",最终被 git gc 清理。
Q5(动手题):如果连续两次 `git add` 同一个修改过的文件,Git 会创建两个 blob 吗?
A: 会。因为文件内容变了 → 新的内容产生新的 SHA-1 hash → 新的 blob 对象被写入。Index 中该文件的条目也被更新为新的 blob hash。旧的 blob 对象依然存在(只是不再被 Index 引用)。这解释了为什么 Git 仓库会随着反复修改和 add 而变大------每个版本的"内容快照"都被永久保存了。
🎮 上瘾学习路径
动手实验
把第7节的"完整实验"跑一遍。只需要 5 分钟,而且你会亲眼看到:
bash
# 实验前后对比
echo "=== 实验前 ==="
find .git/objects -type f
echo "=== 实验后 ==="
find .git/objects -type f
# 看到了吗?------出现了 blob、tree、commit 对象
探索练习
bash
# 查看 Index 的原始内容(hexdump)
hexdump -C .git/index | head -30
# 查看当前 HEAD 指向的 commit
git cat-file -p HEAD
# 查看当前 commit 的 tree
git ls-tree -r HEAD | head -10
给自己出个题
"如果让你设计一个替代 Index 的方案,你会怎么做?"
想一下 Index 的作用:它让用户可以分次 git add 多个文件,然后一次 git commit。如果没有 Index,你必须一次性提交所有修改------就像 SVN 那样。Index 的存在让 Git 的使用更加灵活。
第4讲完。下一讲:三棵树 --- 工作区 / 暂存区 / 仓库的三角关系。我们将深入探讨这三个区域之间的交互,理解 git status、git diff、git checkout 等命令是如何在三个树之间操作的。