Git 内部原理
#工具 · #Git · #版本控制 · #blob · #commit · #rebase
git add、git commit、git rebase每天都在用,但你真正理解 Git 的底层数据模型吗?Git 的本质是一个内容寻址文件系统加上一套版本控制用户界面。理解其内部对象模型,才能驾驭 rebase、cherry-pick、reflog 等高级操作。
一、Git 的底层对象模型
Git 的核心是一个简单的 KV 存储:Key = SHA-1 哈希,Value = 对象内容。这不是抽象比喻——.git/objects/ 目录下真的就是这些哈希文件。Git 的"版本控制"本质上是对这些对象的有向无环图(DAG)进行操作。理解四种对象类型是理解所有 Git 命令的前提:
mermaid
flowchart TB
subgraph Objects["Git 对象模型"]
Blob["Blob<br/>文件内容快照<br/>不含文件名"]
Tree["Tree<br/>目录结构<br/>记录文件名+Blob/Tree引用"]
Commit["Commit<br/>一次提交<br/>Tree引用+父Commit+元信息"]
Tag["Tag<br/>带注释的标签<br/>指向 Commit"]
end
Tree --> Blob
Tree --> Tree
Commit --> Tree
Commit --> Commit
Tag --> Commit1.1 Blob — 文件的快照
Blob(Binary Large Object)存储文件的内容,不包含文件名和路径信息。两个内容相同的文件在 Git 中只存一个 Blob。
bash
# 查看文件内容对应的 SHA-1
echo 'hello world' | git hash-object -w --stdin
# → 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
# Git 内部存储位置
# .git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad1.2 Tree — 目录的快照
Tree 对象将文件名映射到 Blob(文件)和 Tree(子目录):
bash
# 查看 HEAD commit 的 tree 内容
git cat-file -p HEAD^{tree}
# 输出示例:
# 100644 blob 3b18e5... README.md ← 文件权限+类型+哈希+名称
# 040000 tree a1b2c3... src/ ← 子目录引用mermaid
flowchart TB
Root["Tree (根目录)<br/>a1b2c3..."] --> README["Blob: README.md<br/>3b18e5..."]
Root --> Src["Tree: src/<br/>d4e5f6..."]
Src --> Main["Blob: main.go<br/>6f7g8h..."]
Src --> Util["Blob: util.go<br/>9i0j1k..."]关键理解:Tree 是"某个时刻项目目录结构的快照"。不同 Commit 可以引用相同的 Tree 子树(如果该子目录没有改动),这就是 Git 去重和节省空间的机制。
1.3 Commit — 提交的元信息
bash
git cat-file -p HEAD
# 输出:
# tree a1b2c3d4e5f6... ← 指向根 Tree
# parent x9y8z7w6v5u4... ← 父 Commit(合并提交有多个 parent)
# author Alice <alice@ex.com> 1704067200 +0800
# committer Alice <alice@ex.com> 1704067200 +0800
#
# feat: add user loginparent 链构成了 Git 的历史 DAG(有向无环图):
mermaid
gitGraph
commit id: "C0 (init)"
commit id: "C1 (add login)"
branch feature
checkout feature
commit id: "C2 (add payment)"
checkout main
commit id: "C3 (fix bug)"
checkout feature
merge main id: "C4 (merge main)"
checkout main
merge feature id: "C5 (merge feature)"二、Git 的三区模型
很多 Git 新手困惑于"为什么我改了文件但 commit 不进去"——因为他们不理解 Git 的三区模型。Git 不是直接从工作区到版本库的,中间存在一个"暂存区"(Staging Area / Index),它让你精确控制"哪些改动应该打包到下一次 commit 中"。这个看似多余的步骤,实际上给了你 commit 前最后的审查机会:
mermaid
flowchart LR
WD["Working Directory<br/>工作区<br/>你实际编辑的文件"] -->|"git add"| SA["Staging Area<br/>暂存区 (Index)<br/>下次 commit 的内容"]
SA -->|"git commit"| Repo["Repository<br/>版本库<br/>.git/objects/"]
Repo -->|"git checkout / git restore"| WD| 命令 | 方向 | 作用 |
|---|---|---|
git add | WD → SA | 挑选要提交的变更 |
git commit | SA → Repo | 创建 Commit 对象 |
git checkout HEAD -- file | Repo → WD | 丢弃工作区修改 |
git reset HEAD file | SA → WD | 从暂存区移除(保留修改) |
三、分支与 HEAD
这是 Git 最优雅的设计之一:分支只是一个指向 Commit 的 41 字节文本文件。创建分支不复制任何代码,只是创建了一个新的指针。理解了这一点,你就能理解为什么 Git 的分支操作几乎是瞬时的——它只是写了一个 SHA-1 字符串到 .git/refs/heads/ 下:
3.1 分支的本质
分支只是一个指向 Commit 的轻量指针。创建分支就是创建一个新指针:
bash
# 查看所有分支及其指向的 Commit
cat .git/refs/heads/main # → a1b2c3d4... (SHA-1)
cat .git/refs/heads/feature # → e5f6g7h8...
# HEAD 指向当前分支
cat .git/HEAD # → ref: refs/heads/featuremermaid
flowchart LR
subgraph Commits["Commit 链"]
C0["C0"]
C1["C1"]
C2["C2"]
C3["C3"]
end
C0 --> C1 --> C2 --> C3
MAIN["main 分支<br/>→ C2"]
FEAT["feature 分支<br/>→ C3"]
HEAD["HEAD<br/>→ feature"]
MAIN -.-> C2
FEAT -.-> C3
HEAD -.-> FEAT3.2 Detached HEAD
HEAD 直接指向 Commit 而非分支名时,进入"游离"状态:
bash
git checkout a1b2c3d # HEAD → a1b2c3d(脱离分支)
# 此时做的 commit 没有分支名引用,容易丢失
# 解决:git checkout -b new-branch四、Merge vs Rebase
这是 Git 中最容易引起争议的话题。两者都能合并分支,但方式完全不同:Merge 创建一个新的合并节点,保留完整历史(包括分支的时间线);Rebase 将 feature 分支的提交"搬"到 main 顶端重放,产生线性历史。选择取决于你的团队偏好——但有一条铁律:不要 rebase 已经 push 的分支,因为 rebase 会改变 commit SHA。
4.1 Merge(合并)
mermaid
gitGraph
commit id: "main: C0"
commit id: "main: C1"
branch feature
checkout feature
commit id: "feature: C2"
commit id: "feature: C3"
checkout main
commit id: "main: C4"
checkout feature
merge main id: "merge commit: C5"git merge 创建一个新的 merge commit(C5),它有两个 parent:feature 的 C3 和 main 的 C4。历史完整但分支线复杂。
4.2 Rebase(变基)
Rebase 的核心思想:把 feature 的提交"搬"到 main 的最新提交之上,重放一遍,产生全新的 Commit。
mermaid
flowchart TB
subgraph Before["rebase 前"]
direction LR
B0["C0"] --> B1["C1"] --> B4["C4 (main)"]
B0 --> B2["C2"] --> B3["C3 (feature)"]
end
subgraph After["git rebase main (在 feature 上)<br/>rebase 后"]
direction LR
A0["C0"] --> A1["C1"] --> A4["C4 (main)"] --> A2["C2' (SHA变了!)"] --> A3["C3' (SHA变了!)"]
end
Before -->|"rebase"| After关键理解:Rebase 的提交 SHA-1 变了,因为
parent变了。这就是"不要 rebase 已 push 的分支"的根本原因——别人基于旧 Commit 的工作会被破坏。
4.3 Merge vs Rebase 决策
| 场景 | 推荐 | 原因 |
|---|---|---|
| feature 分支合并到 main | Rebase | 保持线性历史,整洁清晰 |
| 多人协作的分支 | Merge | 不改变已推送的 commit |
| 从 main 同步到 feature | Rebase | 保持 feature 在 main 最新代码之上 |
| 发布分支合并 | Merge | 保留合并记录可追溯 |
4.4 Interactive Rebase
bash
# 合并最近 3 个 commit 为一个
git rebase -i HEAD~3
# 编辑器中选择:
# pick a1b2c3d feat: add login
# squash e4f5g6h fix: typo ← 合并到上一个
# squash i7j8k9l fix: test
# → 最终只生成一个 commit五、git reflog — 后悔药
HEAD 的每一次移动都会记录在 reflog 中(默认保留 90 天):
bash
git reflog
# a1b2c3d HEAD@{0}: commit: add payment
# e4f5g6h HEAD@{1}: rebase (finish): returning to refs/heads/feature
# i7j8k9l HEAD@{2}: commit: add login
# ...
# 从 reflog 恢复"丢失"的 commit
git checkout HEAD@{2} # 回到 rebase 前的状态
git branch recovered HEAD@{2} # 创建分支保存Reflog 是本地的,不会被 push。它是你对抗
git reset --hard和误 rebase 的最后防线。
六、高级操作速查
bash
# Cherry-pick: 将单个 commit 应用到当前分支
git cherry-pick a1b2c3d
# 撤销最近一次 commit(保留修改在工作区)
git reset --soft HEAD~1
# 修改最近一次 commit(补充漏掉的文件)
git add forgotten_file
git commit --amend --no-edit
# 二分查找引入 bug 的 commit
git bisect start
git bisect bad HEAD
git bisect good v1.0
# 然后 Git 自动 checkout 中间 commit,你测试后标记 good/bad
# 暂存当前工作(临时切分支)
git stash push -m "WIP: half-done feature"
git stash pop
# 查看某个文件的修改历史
git log -p -- path/to/file
# 查看谁改了这行
git blame path/to/file -L 10,20
登录后即可发表评论 👇