Git篇(3):提交(Commit)与快照(Snapshot)——commit的本质与底层封装

153 阅读11分钟

为什么需要“提交”?

在开发中,我们不可能一次性写完所有代码,而是要分阶段保存进度

  • 今天写了一半功能,先存一下。
  • 明天写完了,再存一次。
  • 如果哪天写崩了,可以回到昨天的版本。

👉 所以 Git 引入了 commit:把你选中的修改(暂存区)保存下来,形成一份时间点的快照


基本案例示范

假设我们有提交历史:

A - B - C - D (HEAD)
  1. 我在 D 上改了一个文件并执行:
git add file.txt
git commit -m "update file"
  1. Git 会创建一个新的 commit(假设叫 E):
    • 保存了所有文件的快照。
    • 记录了 D 作为父指针。
    • 有唯一的哈希值,比如 e5f6g7h8...
  1. 历史就变成:
A - B - C - D - E (HEAD)

Commit 的本质

快照(Snapshot)

  • 每一次 commit 不是“仅存文件的变化(diff)”,而是整个项目目录的完整快照。

  • Git 会用 内容寻址(基于哈希)确保相同文件只保存一次。

    • 如果文件没变,Git 只会引用之前的对象,不会重复存储。
    • 这让 Git 高效又安全。

👉 这就是为什么 Git 回退版本几乎是瞬间完成的,因为它早就保存好了完整快照,只是换个指针。


深入思考
  1. 上面说每一个次commit是整个项目目录的完整快照,这所谓的“完整快照”是什么意思?
  2. 随后说Git会用内容寻址,确保相同文件只保存一次,那么是不是说,对于一个文件,我每次修改后提交,只要文件内容有差异,git都会重新存储一份,并给这个文件一个唯一标识?
  3. 那么我是否可以这样理解:git之所以高效,是因为每一次commit,其中包含所有文件的唯一历史标识,通过这些标识,就可以去引用获取到这些文件的历史版本,由此实现历史版本的切换?

“完整快照”到底是什么意思?

这里的 快照(snapshot) 不是指真的把整个项目文件夹都复制一份,而是:

  • Git 会记录 当下项目所有文件的状态(内容哈希值),形成一棵 目录树(tree 对象)
  • 这个目录树会指向一系列文件对象(blob),每个文件对象代表某个版本的文件内容。

所以“完整快照”的意思是:
👉 一个 commit 能够完整还原当时的项目目录状态

它不是像传统版本控制(如 SVN)那样只存“改了哪些行(diff)”,而是始终存“这次提交的全部结果是什么”。


修改文件后,Git 会怎么存储?

思考理解得差不多,我们更细化一下:

  • Git 里的 文件内容blob 对象,由文件内容的 SHA-1 哈希标识。

  • 当你修改文件后:

    • 如果内容有变化,Git 会创建一个新的 blob 对象(新的哈希)。
    • 如果内容没变,Git 不会重复保存,直接引用原来的 blob 对象。

举例:

Commit A
  └── file1 (hash1)
  └── file2 (hash2)

如果在下一次提交里只改了 file2:

Commit B
  └── file1 (hash1)   # 沿用原来的
  └── file2 (hash3)   # 新内容 → 新哈希

👉 所以我们上面的思考没错:只要内容有差异,就会存一个新对象;不变的部分则完全复用。


Git 高效性的核心理解

我们的思考比较接近 Git 的本质,但言语描述上不够清晰准确。

我们再把逻辑完整化一下:

  • 每个 commit 保存一个 目录树(记录整个项目的结构)。
  • 目录树里的文件指向一系列 blob 对象(唯一内容标识)。
  • 由于 Git 复用了相同内容的 blob,所以存储不会爆炸。

👉 这就带来了高效性:

  1. 切换历史版本很快:只需移动 HEAD 指针,Git 就知道要引用哪一套 blob。
  2. 存储高效:未改动的文件不会重复保存。
  3. 安全可靠:所有内容由哈希值唯一标识,防篡改。

重回我们的思考

可以这样理解:

  • 每个 commit 是一张快照,里面包含整个项目的文件树和文件内容的引用。
  • 每个文件版本由哈希标识,如果文件内容不同,就会生成新的标识。
  • 历史版本切换 = 重新引用那一组哈希,瞬间复原当时的目录。

👉 所以 Git 的高效本质就是:
用内容哈希唯一标识文件版本 + 快照记录项目整体状态 + 复用相同内容


哈希值(SHA-1)

  • 每个提交会生成一个 40 位十六进制哈希(比如 a1b2c3d4...)。

  • 提交的哈希是根据提交内容(文件快照、父提交、作者、时间戳、提交信息等)计算出来的。

  • 因此:

    • 哈希是唯一的,可以标识一个提交。
    • 提交一旦生成,就不可篡改(内容变,哈希就会变)。

👉 这也是 Git 可靠性的核心:提交即加密签名的历史快照


父指针(Parent)

  • 除了快照和哈希,每个提交还会保存“父提交”的引用。
  • 这样就把提交连成一条链:
AB → C → D (HEAD)
  • 对于合并提交(merge commit),会有多个父指针。

👉 所以 Git 的历史不是日志,而是一个有向无环图(DAG)


Commit的底层流程

首先明确 Git 的四大对象类型

在 Git 的 .git/objects 里,所有东西都存成 内容 + 元数据,然后做 SHA-1 哈希,作为唯一 ID。主要有四种对象:

blob(binary large object)
  • 存的就是文件的内容本身(纯字节流),不带文件名、不带目录,只管内容。
  • 它不管文件名、不管目录,只管“文件内容”。
  • 比如 hello.txt 里面写 hello world,Git 就会存一个 blob,哈希值就是 hash("blob <len>\0hello world"),如此,这个哈希值就对应这个唯一blob对象,后面要判断文件内容是否改变,只需要计算hash("新内容"),看哈希表中是否存在这个哈希值,如果存在,说明文件没变,复用blob;否则新建blob,并对应一个新的哈希值。

Git 存储时的格式大概是:

"blob <内容长度>\0<文件内容>"

然后对整个字节流做 SHA-1,得到一个哈希。

  • 如果两次文件内容一样 → 哈希一样 → 复用已有 blob。
  • 如果文件有任何一字节不同 → 哈希必然不同 → 新建 blob。

👉 所以 blob 就是一个“内容寻址的不可变对象”。


tree(目录树对象)
  • tree 才是“目录快照”。
  • 它会记录:
模式(权限) + 文件名 → blob哈希
模式(权限) + 子目录 → tree 哈希

例子:

100644 hello.txt → b3
100644 readme.md → b2
040000 src       → t_subdir

这里的 040000 就表示 src 是个目录,它对应的就是另一个 tree 对象。

👉 所以 tree 管“名字和目录结构”,blob 管“文件内容”


commit(提交对象)

commit 记录的信息更多:

  • 指向一个 tree(根目录快照)。
  • 指向父 commit(0个或多个)。
  • 作者、提交者、时间戳、提交信息。

commit 对象格式大概是:

tree <根tree哈希>
parent <父commit哈希> (可能多个)
author <信息>
committer <信息>

<提交说明>

👉 所以 commit = 历史节点,它把 tree(文件结构)和 parent(历史链)结合起来。


tag(标签对象)
  • 给 commit(或其他对象)起一个名字
  • 本质上是“对某个对象的引用”
tag有两类
  1. 轻量标签(lightweight tag)

    • 就是一个指针,直接指向一个 commit。
    • 本质上和 branch 很像,只是不会移动。
    • 存在 .git/refs/tags/<tag名> 里,文件内容就是一个 commit 的哈希。

例子:

echo "cA4d5..." > .git/refs/tags/v1.0

就表示 v1.0 这个标签指向 commit cA4d5...

  1. 附注标签(annotated tag)

    • 这是一个真正的 tag 对象,和 blob/tree/commit 一样存到 .git/objects/

    • 它不仅指向 commit,还包含额外信息:

    • 对象类型(commit/tree/blob)

      • 被指向的对象哈希
      • tag 名
      • tag 作者
      • tag 消息(说明文字)
      • (可选)PGP 签名

例子(tag 对象的内部格式,大概长这样):

object 7a8f9c...          ← 指向的 commit
type commit
tag v1.0
tagger Alice <alice@example.com>  2025-10-03 +0800

Release version 1.0

然后这个 tag 对象本身也有一个哈希值,存放在 .git/objects/xx/xxxx... 文件中。
.git/refs/tags/v1.0 里保存的就是这个 tag对象的哈希


tag 和 branch 的区别
  • branch:就是一个指针,指向某个 commit,随着新的提交自动向前移动。
  • lightweight tag:也是指针,但不会移动。
  • annotated tag:是个对象,里面能记更多元数据(适合正式发布)。

👉 所以 tag 更像是“给某个 commit 起个永久名字”
branch 更像是“一个会随着提交前进的名字”。


完整例子

项目历史:

AB → C (HEAD, master)

创建一个附注标签:

git tag -a v1.0 -m "first release"

产生对象关系:

refs/tags/v1.0 → 哈希 t1 (tag对象)
t1 (tag对象) → commit C

tag 对象内容:

object <commit C 哈希>
type commit
tag v1.0
tagger Alice <alice@example.com>
message: "first release"

小结

  • 轻量标签 = 一个名字 → commit哈希。

  • 附注标签 = 一个完整对象 → 存储名字、作者、说明、指向commit 的引用。

  • Git 推荐用 附注标签 来做版本发布,因为它能带版本说明,还能签名验证。


以上对象的存储方式(哈希表?)

是不是“每个对象都有一个更高层的哈希表,通过哈希值指向对象”?——可以这么理解,但更精确点是:

  • Git 的 .git/objects/ 文件夹就是一个 内容寻址存储系统
  • 每个对象(blob / tree / commit / tag)存储在这个数据库里,按哈希值分目录:
.git/objects/ab/cdef1234...   ← 哈希前两位做子目录,后38位做文件名
  • 也就是说:
    • Git 里没有传统意义的“哈希表数据结构”,
    • 而是用 文件系统 + 哈希值做文件名,模拟了一个全局哈希表。

👉 所以只要你有一个哈希值,Git 就能找到对应的对象内容。


总结
  • blob:文件内容 → 内容相同 → 哈希相同 → 复用。
  • tree:目录结构 → 文件名/子目录 + blob哈希/子tree哈希。
  • commit:一次提交的历史节点 → 指向一个根 tree + 父 commit + 提交元数据。
  • 存储:全都塞到 .git/objects/,用哈希作为“地址”。

👉 Git 不是按文件名存储,而是按内容哈希存储;文件名和目录只是一层树状的引用。


项目演化过程

初始文件
hello.txt   内容: "hi"
readme.md   内容: "this is demo"

第一次提交:commit A
  1. Git 生成两个 blob:

    • blob1(内容: "hi") → 哈希 b1
    • blob2(内容: "this is demo") → 哈希 b2
  2. 生成一个 tree:

hello.txt → b1
readme.md → b2

→ 哈希 t1

  1. 生成 commit:

    • 指向 tree t1
    • 没有父 commit
      → 哈希 cA

结构:

cA  t1  { hello.txt: b1, readme.md: b2 }

第二次提交:commit B

修改了 hello.txt 内容 → "hello world"

  1. Git 新生成一个 blob:

    • blob3(内容: "hello world") → 哈希 b3
  2. 生成新 tree:

hello.txt → b3   (更新了)
readme.md → b2   (没变,复用旧 blob)

→ 哈希 t2

  1. 生成新 commit:

    • 指向 tree t2
    • 父 commit = cA
      → 哈希 cB

结构:

cB  t2  { hello.txt: b3, readme.md: b2 }

└── parent: cA

第三次提交:commit C

新增 main.c 内容 → "printf("hi");"

  1. 新增 blob:

    • blob4(内容: "printf("hi");") → 哈希 b4
  2. 生成新 tree:

hello.txt → b3
readme.md → b2
main.c    → b4

→ 哈希 t3

  1. 生成新 commit:

    • 指向 tree t3
    • 父 commit = cB
      → 哈希 cC

结构:

cC  t3  { hello.txt: b3, readme.md: b2, main.c: b4 }

└── parent: cB  t2  { hello.txt: b3, readme.md: b2 }
             
             └── parent: cA  t1  { hello.txt: b1, readme.md: b2 }

关键观察
  1. blob 是不可变的:内容没变就直接复用旧哈希。

    • readme.md 一直没改 → 始终用 b2
  2. tree 也会重建:哪怕只改一个文件,整个目录快照都会生成新的 tree(因为 tree 要保证提交时的目录结构完整)。

  3. commit 是快照链表:每个 commit 指向一个 tree,tree 再指向 blob;commit 之间通过“parent”链接形成历史。


总结
  • blob:存“文件内容” → 文件内容的身份证,只要文件没变,就不会重复存。
  • tree:存“这次提交时的目录快照” → 每次提交必然新建,目录结构的身份证(指向一堆 blob)。
  • commit:存“这次快照 + 父历史 + 作者信息” → 串成时间线,快照的身份证(指向一个 tree) 。

所以 Git 是 “指针套指针”的快照历史” ,而不是“直接存每个文件的改动 diff”。


常见误解

  1. 误区:commit 保存的是 diff。

    • 错。Git 保存的是“完整快照”,只是在存储层面会优化复用。
  2. 误区:删除 commit 会丢失文件。

    • 不一定。提交对象还在,只是没分支指针指向,可以通过 reflog 找回。
  3. 误区:哈希是随机的。

    • 哈希由内容计算,改一个字节都会生成全新的哈希。

总结

  • Commit = 快照 + 哈希 + 父指针 + 元信息

  • 历史结构 = 一条链(或 DAG),不是简单的日志

  • 本质思想

    • git add 选快照内容
    • git commit 固化成历史节点
    • 每个节点都不可篡改

👉 这样你就能理解:Git 历史是一条可追溯、加密签名的快照链,而不是简单的文本日志。