Skip to content

Git 内部原理

本章要点

  • .git 目录结构
  • 四大对象类型:blob / tree / commit / tag
  • 对象寻址:SHA-1 哈希与内容寻址
  • 引用(refs):分支、HEAD、tag 的本质
  • 索引(index):暂存区的真身
  • 一次 git commit 的底层完整流程
  • 钩子(hooks)与常用扩展机制
  • GC、pack 文件与性能

💡 本章讲的是 Git 的模型(model)而非具体实现细节。理解模型后,前面所有命令都变得"理所当然"。

.git 目录结构

text
.git/
├── HEAD                  # 指向当前分支的文件(如 ref: refs/heads/main)
├── config                # 本仓库的 local 配置
├── description           # 仓库描述(仅供 GitWeb 使用)
├── index                 # 暂存区(索引)的二进制文件
├── objects/              # 对象数据库(所有内容的存储地)
│   ├── info/
│   └── pack/             # 打包后的对象文件
├── refs/                 # 引用(指针)
│   ├── heads/            # 本地分支(refs/heads/main)
│   ├── tags/             # 标签
│   └── remotes/          # 远程跟踪分支(refs/remotes/origin/main)
├── logs/                 # reflog(HEAD 移动历史)
├── hooks/                # 钩子脚本
└── info/exclude          # 仓库级忽略(类似 .gitignore)

四大对象类型

Git 的一切数据都是对象,共四种:

对象存储内容类比
blob一个文件的内容(不含文件名)文件的内容本身
tree一个目录的清单:文件名 → blob/tree目录/文件夹
commit快照元信息:指向 tree、父提交、作者、提交信息快照的"存档说明"
tag(附注标签)指向某提交的固定引用及说明里程碑

对象之间的关系

text
commit(提交)                        ← 只记录"我基于谁、指向哪个快照、谁写的、写了什么"
 │  指向

tree(目录快照)
 ├── "README.md" → blob abc123...      ← blob 存文件内容
 ├── "src/"      → tree def456...      ← 子目录是另一个 tree
 └── "config.js" → blob 7890ab...

blob(文件内容,不含文件名)

💡 关键认知 1:文件名存在 tree 里,内容存在 blob 里。两个不同名字的文件内容相同,会共享同一个 blob。 💡 关键认知 2:commit 只存元信息(tree 指针 + 父指针 + 作者 + 信息),所以"提交"本身极轻。

内容寻址:SHA-1 哈希

每个对象以内容本身计算 SHA-1 哈希作为文件名(前 2 位作目录名,后 38 位作文件名):

text
$ echo "hello" | git hash-object --stdin
# b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0
  • 相同内容 → 相同哈希 → 同一对象(这就是"快照去重"的原理)。
  • 内容一变 → 哈希大变 → 必然生成新对象。
  • 由此保证数据完整性:任何篡改都会导致哈希不匹配,Git 立即发现。
bash
$ git cat-file -t <>      # 查看对象类型
$ git cat-file -p <>      # 查看对象内容

引用(refs):分支的真相

分支 = 一个指向提交的指针(前面说过),它存储在 refs/heads/ 下:

text
refs/heads/main  →  8f4a2c9...(一个 40 位十六进制哈希)
bash
$ cat .git/refs/heads/main    # 直接查看分支指向的哈希
$ git rev-parse main          # 等价命令
$ git symbolic-ref HEAD       # 查看 HEAD 指向哪个分支

HEAD:当前在哪

  • HEAD 是一个特殊引用,通常指向一个分支(间接),如 ref: refs/heads/main
  • 检出某次提交时 HEAD 直接指向提交 → 这就是 detached HEAD(第 6 章讲过)。

常见引用速查

引用含义
HEAD当前所在位置
HEAD~1HEAD 的父提交(~ 往前找父链)
HEAD~3往前第 3 个祖先
HEAD^2合并提交的第二个父提交(^ 按父编号取)
origin/main远程跟踪分支(上次 fetch 时 origin 上 main 的位置)
main@{1}main 的上一个位置(reflog 语法)

索引(index):暂存区的真身

  • 暂存区在磁盘上就是 .git/index 这个二进制文件。
  • 它记录:每个被跟踪文件的路径、blob 哈希、文件模式、时间戳等信息。
  • git add = 更新 index 中该文件对应的条目;git commit = 把 index 的内容固化成 tree + commit。

一次 git commit 的完整旅程

text
1. 你修改 file.txt
2. git add file.txt
   → 把新内容写成一个 blob 对象(objects/)
   → 更新 .git/index 中 file.txt 的条目指向新 blob
3. git commit -m "说明"
   a. 根据 index 生成 tree 对象(目录快照)
   b. 创建 commit 对象:
      { tree: <刚生成的tree哈希>,
        parent: <当前 HEAD 指向的提交>,
        author / committer: 你的身份+时间,
        message: "说明" }
   c. 把 commit 的哈希写入 refs/heads/main(分支指针前移)
   d. HEAD 仍然指向 main(main 指向新 commit)

看懂了吗?"提交"的本质就是:写对象 + 移动分支指针。 所有"撤销/回滚/重写历史"操作,底层都是在移动指针、生成/废弃对象。

钩子(hooks):自动化扩展

钩子是 .git/hooks/ 下的可执行脚本,在特定时机自动触发:

bash
$ ls .git/hooks/
# applypatch-msg.sample  pre-commit.sample  pre-push.sample ...
# (.sample 后缀 = 未启用模板)

常用钩子

钩子触发时机典型用途
pre-commitcommit 前运行 lint、格式化检查、禁止提交密钥
commit-msg提交信息写好之后校验提交信息是否符合规范
pre-pushpush 前运行测试,失败则阻止推送
post-commitcommit 后通知、CI 触发
bash
# 启用钩子:去掉 .sample 后缀并设为可执行
$ mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
$ chmod +x .git/hooks/pre-commit

⚠️ 钩子存于 .git/hooks/(本地,不随仓库共享)。团队共享钩子可用 Husky(npm)等工具或提交到仓库后通过脚本安装。

GC 与 pack 文件:Git 如何省空间

  • 对象先以"松散(loose)"形式逐个存储。
  • 随着对象增多,git gc(自动触发)会把对象打包(pack)
    • 相同/相似内容做增量压缩(delta compression),只存差异。
    • 生成 .pack 二进制文件 + .idx 索引。
  • git gc 还会清理:reflog 过期的条目、悬空(dangling)对象、未引用的 pack。
bash
$ git gc                 # 手动执行垃圾回收与打包
$ git count-objects -v   # 查看对象统计
$ git fsck               # 检查仓库完整性(找悬空对象等)

💡 理解了"对象不可变 + 引用可移动"这两个铁律,你就能理解 Git 几乎全部行为:

  • 为什么 commit 哈希会变(内容变了 / 父指针变了 → 新哈希)
  • 为什么 rebase 是"复制提交"而不是"修改提交"(旧提交对象还在,只是没人引用了)
  • 为什么"删掉的分支"还能找回(引用删了,对象还在,直到 gc)

理解"三个铁律"总结

  1. 对象不可变:内容一经写入,永不被修改(只能产生新对象)。
  2. 引用可移动:分支、HEAD、标签(轻量)都只是指针。
  3. 一切皆可找回(在 gc 之前):reflog + 悬空对象是后悔药的底层保障。