Skip to content

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 --> Commit

1.1 Blob — 文件的快照 ​

Blob(Binary Large Object)存储文件的内容,不包含文件名和路径信息。两个内容相同的文件在 Git 中只存一个 Blob。

bash
# 查看文件内容对应的 SHA-1
echo 'hello world' | git hash-object -w --stdin
# → 3b18e512dba79e4c8300dd08aeb37f8e728b8dad

# Git 内部存储位置
# .git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad

1.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 login

parent 链构成了 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 addWD → SA挑选要提交的变更
git commitSA → Repo创建 Commit 对象
git checkout HEAD -- fileRepo → WD丢弃工作区修改
git reset HEAD fileSA → 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/feature
mermaid
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 -.-> FEAT

3.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 分支合并到 mainRebase保持线性历史,整洁清晰
多人协作的分支Merge不改变已推送的 commit
从 main 同步到 featureRebase保持 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

参考 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记