Git 底层原理系列 · 第4讲 — `git add` 与 `git commit` 底层做了什么

Git 底层原理系列 · 第4讲 --- git addgit 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

两个关键认知:

  1. git add 不是"把文件加到 Git 里"------它只是把文件内容转成 blob,并更新 Index 中的指针。文件本身的元数据(文件名、目录结构)还没有被 Git 记录,它们只在 Index 中。

  2. 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 addgit 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>

核心认知

  1. git add 的本质:把文件内容转成不可变的 blob,并规划下一次 commit 的内容
  2. git commit 的本质:把 Index 中的规划"冻结"成一个不可变的快照(tree + commit)
  3. Index 的本质:下一次 commit 的"草稿区",一个扁平的目录条目表
  4. Ref 的本质:一个可移动的指针,指向当前分支的最新 commit

自测卡片

Q1:运行 `git add file.txt` 后,`.git/` 目录下发生了什么变化?

A: 两件事:① 如果 file.txt 的内容从未在仓库中出现过,一个新的 blob 对象被写入 .git/objects/xx/xxxx;如果内容之前出现过,则复用已存在的 blob。② .git/indexfile.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 statusgit diffgit checkout 等命令是如何在三个树之间操作的。

相关推荐
猫咪老师QAQ4 小时前
基于 Git Flow 的团队协作与发布流程实践
git
caicai_xiaobai4 小时前
分享一个访问Git Hub的好方法
git
心中有国也有家4 小时前
从零上手 CANN 学习中心:像逛技术便利店一样学昇腾
学习·算法·开源
Joy T4 小时前
【Web3】跨链资金池与消息路由:CCIP 智能合约集成实战与权限收束
git·web3·node·智能合约·hardhat
openFuyao5 小时前
以开源之力,突破多样化算力困局——openFuyao开源一周年背后的故事
人工智能·云原生·开源·openfuyao·多样化算力·集群软件
難釋懷6 小时前
Nginx虚拟主机
git·nginx·github
500846 小时前
ATC 做了什么:从 ONNX 到 .om
分布式·架构·开源·wpf·开源鸿蒙
moMo6 小时前
# Git 入门—代码仓库的使用
git·github