git的底层原理详解

对于 Git 相信大家一定不陌生,Git 相关的命令大家也经常使用,但是 Git 的底层原理你真的了解吗?接下来从 Git 到底在底层存了什么、提交时发生了什么、分支为什么很轻、合并/变基的原理是什么"等几个角度介绍 Git 的底层使用原理。

目录

git的本质:内容寻址文件系统

git目录:git仓库的核心

[git 的四类核心对象](#git 的四类核心对象)

blob:文件内容对象

tree:目录结构对象

commit:提交对象

tag:标签对象

[一次 git add 发生了什么?](#一次 git add 发生了什么?)

[一次 git commit 发生了什么?](#一次 git commit 发生了什么?)

工作区、暂存区、本地仓库

[工作区 vs 暂存区](#工作区 vs 暂存区)

[暂存区 vs HEAD](#暂存区 vs HEAD)

[HEAD 是什么?](#HEAD 是什么?)

HEAD指向分支

[Detached HEAD(游离 HEAD)](#Detached HEAD(游离 HEAD))

分支的底层原理

[git checkout / git switch 的底层原理](#git checkout / git switch 的底层原理)

[合并 git merge 的原理](#合并 git merge 的原理)

[Fast-forward merge](#Fast-forward merge)

[Three-way merge](#Three-way merge)

冲突是怎么来的?

[git rebase 的原理](#git rebase 的原理)

[git reset 的底层原理](#git reset 的底层原理)

[--soft:只移动 HEAD](#--soft:只移动 HEAD)

[--mixed:移动 HEAD,并重置暂存区](#--mixed:移动 HEAD,并重置暂存区)

[--hard:移动 HEAD,重置暂存区和工作](#--hard:移动 HEAD,重置暂存区和工作)

[git diff 的原理](#git diff 的原理)

[git diff](#git diff)

[git diff --cached](#git diff --cached)

[git diff HEAD](#git diff HEAD)

[git stash 的原理](#git stash 的原理)

远程仓库的底层原理

[git fetch 的原理](#git fetch 的原理)

[git pull 的原理](#git pull 的原理)

[git push 的原理](#git push 的原理)

[Git 为什么能恢复很多"误删"?](#Git 为什么能恢复很多“误删”?)

[垃圾回收 git gc](#垃圾回收 git gc)

完整例子

本文总结


git的本质:内容寻址文件系统

git是一个基于内容寻址的对象数据库,git会把项目中的内容存成一个个对象,并用这些对象内容的哈希值作为ID,早期git使用SHA-1,现在也支持SHA-256。你常见的提交ID:a1b2c3d4... 本质上就是某个git对象的哈希值。

git目录:git仓库的核心

当你执行 git init 时,git会在当前目录下创建一个隐藏目录 .git/ ,这个目录就是git仓库真正的数据库,常见结构如下:

几个关键部分:

路径 作用
.git/objects/ 存放 Git 对象
.git/refs/heads/ 存放分支指针
.git/HEAD 当前所在分支或提交
.git/index 暂存区
.git/config 仓库配置
.git/logs/ reflog 记录

git 的四类核心对象

git 底层主要有四种对象:

blob、tree、commit、tag

blob:文件内容对象

blob 保存的是文件内容,不包含文件名。所以如果两个文件内容完全相同,它们可以指向同一个 blob 对象。

注意:Git 的 blob 只关心内容,不关心文件名。

例如你有一个文件:hello.txt,内容是:hello git,git 会把 hello git 这段内容存成一个 blob 对象。

blob 哈希 = sha1(文件内容) (目前支持sha256)

tree:目录结构对象

tree 保存的是目录结构。它记录:文件名、文件权限、文件对应的 blob ID、子目录对应的 tree ID

可以理解为:

tree

├── hello.txt -> blob abc123

├── README.md -> blob def456

└── src/ -> tree 789abc

总结:blob 保存文件内容;tree 保存文件名和目录结构

commit:提交对象

commit 保存一次提交的信息。

一个 commit 对象通常包含:

树对象(tree)的哈希值:代表当前项目根目录下所有文件和文件夹的完整快照。

父提交的哈希值:代表当前提交的上一个版本

作者信息:姓名、邮箱、时间戳(精确到秒)

提交者信息:姓名、邮箱、时间戳

提交说明:你写的 git commit -m "..." 里面的内容

例如

commit C3

├── tree T3

├── parent C2

└── message: fix bug

如果是普通提交,它有一个 parent。

如果是合并提交,它可能有两个或多个 parent。

C4

/ \

C2 C3

\ /

C5 <- merge commit

tag:标签对象

tag 一般用来给某个 commit 打标签,例如:git tag v1.0.0

轻量标签本质上只是一个引用。

附注标签则会创建一个 tag 对象,里面包含:

1.指向的 commit:标识这个标签固定在哪个具体的提交上

2.tag 名称:例:tag v1.0.0,标签的引用名称,存储在 tag 对象内部

3.tagger 信息:例:tagger xiaowang <wang@example.com> 1707123456 +0800,记录谁、在什么时候打了这个标签

包含四个部分:

|---------|-------------|------------------|
| 姓名 | 创建标签的用户名 | xiaowang |
| 邮箱 | 创建标签的邮箱 | wang@example.com |
| 时间戳 | Unix 时间戳(秒) | 1707123456 |
| 时区 | UTC 偏移量 | +0800(北京时间) |

4.message:标签的说明文字,类似 commit message

5.可选的 GPG 签名:用 GPG 私钥对 tag 对象进行数字签名

创建轻量标签&附注标签:

命令 标签类型 是否签名
git tag <name> 轻量标签
git tag -a <name> -m "msg" 附注标签(无签名)
git tag -s <name> -m "msg" 附注标签(GPG 签名) ✅(默认密钥)
git tag -u <key-id> <name> -m "msg" 附注标签(GPG 签名) ✅(指定密钥)

总结:轻量标签和附注标签的区别在于,前者本质是提交的别名/引用,后者会创建独立的git对象。

一次 git add 发生了什么?

假设你修改了一个文件:echo "hello" > a.txt,然后执行:git add a.txt,Git 会做几件事:

第一步:计算文件内容哈希

Git 读取 a.txt 内容,并生成一个 blob 对象。可以理解为:a.txt 内容 -> hash -> blob id

第二步:把 blob 存入 .git/objects/

Git 会把对象压缩后存入:.git/objects/,对象路径类似:.git/objects/ab/cdef123456...,其中前两位哈希作为目录名,后面部分作为文件名。

第三步:更新暂存区 index

.git/index 是暂存区。它会记录:a.txt -> blob-id,也就是说:

git add 不是简单地"标记文件已添加",而是把当前文件内容写入对象库,并更新 index。

一次 git commit 发生了什么?

执行:git commit -m "add a.txt",Git 会做这些事:

第一步:根据 index 生成 tree 对象

暂存区中记录了所有文件路径和 blob ID。Git 会根据 index 生成一个 tree 对象,表示当前项目快照。

tree T1

└── a.txt -> blob B1

第二步:生成 commit 对象

commit 对象指向这个 tree。

commit C1

├── tree T1

├── parent null

└── message "add a.txt"

如果之前已经有提交,那么新 commit 会记录上一个 commit 作为 parent:

commit C2

├── tree T2

├── parent C1

└── message "update a.txt"

第三步:移动当前分支指针

如果当前在 main 分支,那么:refs/heads/main,会从旧 commit 指向新 commit。

例如提交前:main -> C1,提交后:main -> C2

工作区、暂存区、本地仓库

Git 日常使用中有三个重要区域:

| 工作区 | 暂存区 | 本地仓库 |
| Working | Index | Repository |

Directory Stage .git

对应关系:

修改文件

↓ git add

暂存区

↓ git commit

本地仓库

git status 看到的其实就是 Git 在比较这三者的状态:

工作区 vs 暂存区

Git 比较 当前文件内容 vsindex 中记录的 blob,如果不同,说明文件被修改但未暂存。

暂存区 vs HEAD

Git 比较:index 中的内容 vs HEAD 指向的 commit,如果不同,说明有内容已经暂存但未提交。

HEAD 是什么?

.git/HEAD 是一个指针文件,它永远指向当前所在的引用(分支或提交)。

HEAD指向分支

在 main 分支上

git checkout main

cat .git/HEAD

输出:ref: refs/heads/main

指针链:HEAD -> refs/heads/main -> commit C2

.git/HEAD 内容:ref: refs/heads/main

.git/refs/heads/main 内容:9fceb02d0ae5... (commit C2 的哈希)

Commit C2 (实际的对象)

当你执行 git commit 时,Git 会:

  1. 创建新的 commit C3
  2. 修改 .git/refs/heads/main 指向 C3
  3. HEAD 本身不变(仍然指向 main 分支)

最终效果:HEAD -> main -> C3

Detached HEAD(游离 HEAD)

如果你 checkout 到某个具体 commit:git checkout a1b2c3,此时 HEAD 直接指向 commit,而不是分支:HEAD -> a1b2c3,这叫:detached HEAD,也就是"游离 HEAD"。

分支的底层原理

了解分支底层原理之前,首先回顾一下一个很重要的概念:

HEAD 指针:这是一个极其重要的特殊指针。它的作用就是告诉 Git "你现在在哪个分支上"。当你切换分支时,本质就是修改 HEAD 文件的内容。

假设当前有两个分支main和dev:

.git/refs/heads/main 文件:这个文件的内容非常简单,就是一个 40 位的哈希值,例如 a1b2c3d4...。这个哈希值,就是当前 main 分支所指向的那个 commit 对象的 ID。

.git/refs/heads/dev 文件:同理,它也是一个文本文件,里面也是一个 40 位的哈希值。

所以,分支的本质就是:保存在 .git/refs/heads/ 目录下、内容为一个 commit ID 的文本文件。 这个文件的名字,就是分支名;文件的内容,就是它指向的 commit。

"创建分支非常快"的原因也就在这里。 执行 git branch dev,Git 并没有复制任何文件、没有创建任何项目的快照。它只是:

1.创建一个新的文本文件 .git/refs/heads/dev。

2.读取当前 HEAD 指向的 commit ID。

3.把这个 ID 写入 .git/refs/heads/dev 文件。

整个过程就是一次文件写入操作,所以几乎是瞬间完成。

同样,执行 git switch dev 切换分支相当于:修改 HEAD 文件的内容,让它从指向 main 改为指向 dev。

git checkout / git switch 的底层原理

执行:git switch dev,Git 做两件事:

第一:更新 HEAD

HEAD -> refs/heads/dev

第二:更新工作区和暂存区

Git 会把工作区文件更新成 dev 分支指向的 commit 对应的 tree。

也就是说:commit -> tree -> blob -> 工作区文件

合并 git merge 的原理

假设有两个分支:

C3 dev

/

C1--C2 main

你在 main 上执行:git merge dev

Git 会根据情况选择不同策略。

Fast-forward merge

如果 main 是 dev 的祖先:

C1--C2 main

\

C3 dev

那么合并只需要把 main 指针移动到 C3:

C1--C2--C3 main/dev

这叫快进合并。

底层只是移动引用指针,不会产生新的 commit。

Three-way merge

如果两个分支都各自有新提交:

C3 dev

/

C1--C2

\

C4 main

Git 会寻找共同祖先 C2,然后比较:

base: C2

ours: C4

theirs: C3

然后生成一个新的 merge commit:

C3

/ \

C1--C2 C5 main

\ /

C4

这个 merge commit 有两个 parent:

parent C4

parent C3

冲突是怎么来的?

冲突通常发生在三方合并时。Git 会比较:

  • 共同祖先版本
  • 当前分支版本
  • 目标分支版本

如果两个分支修改了同一个地方,Git 无法自动判断采用哪一个,就会产生冲突。

例如:有一个文件hello.txt,内容只有hello

共同祖先:hello

main 改成:hello main

dev 改成:hello dev

当你执行 git merge dev 时(此时在 main 分支上),Git 进行三方对比:

祖先:hello

main:hello main (改成了带 main 的)

dev:hello dev (改成了带 dev 的)

Git 发现:两个分支都修改了同一行,且结果不同。它无法决定保留哪一个,于是暂停合并,把冲突标记写入文件。

此时打开hello.txt,你会看到:

<<<<<<< HEAD

hello main

=======

hello dev

>>>>>>> dev

  • <<<<<<< HEAD 到 =======:这是当前分支 (main) 的内容 (hello main)。
  • ======= 到 >>>>>>> dev:这是目标分支 (dev) 的内容 (hello dev)。
  • HEAD 是 Git 对"当前分支最新提交"的称呼。

你解决冲突后:

git add hello.txt # 告诉 Git:这个文件我处理好了

git commit # 完成合并

git rebase 的原理

merge 是把两个分支历史合起来。rebase 是把一段提交"搬家"。

假设:

D1--D2 dev

/

A--B--C main

在 dev 上执行:git rebase main

Git 会做:

  1. 找到 dev 和 main 的共同祖先 B

  2. 找出 dev 上独有提交 D1、D2

  3. 暂存这些提交对应的改动

  4. 把 dev 移到 main 最新提交 C

  5. 重新应用 D1、D2 的改动,生成新提交 D1'、D2'

结果:

A--B--C main

\

D1'--D2' dev

注意:rebase 会创建新的 commit,因为 commit 的 parent 变了,commit hash 也会变。

git reset 的底层原理

git reset 主要是在移动 HEAD 或分支指针,并根据参数决定是否修改 index 和工作区。

常见三种:

  • git reset --soft
  • git reset --mixed
  • git reset --hard

撤回到指定提交:git reset --soft <commit_id>

撤回到上几次提交:git reset --soft HEAD~n

假设你有一个文件 test.txt,内容如下:

第一次提交:文件内容是 "v1"

echo "v1" > test.txt

git add test.txt

git commit -m "first commit" # 假设这个提交哈希是 aaaaaa

第二次提交:文件内容改为 "v2"

echo "v2" > test.txt

git add test.txt

git commit -m "second commit" # 假设这个提交哈希是 bbbbbb

现在的状态:

  • 工作区:v2
  • 暂存区:v2
  • 版本库:aaaaaa (v1) ← bbbbbb (v2)
  • HEAD 指向 bbbbbb

--soft:只移动 HEAD

git reset --soft HEAD~1 # 回到上一个提交,但保留暂存区和工作区

结果:

  • HEAD 指向 aaaaaa(撤销了 bbbbbb 提交)
  • 暂存区:仍然是 v2
  • 工作区:仍然是 v2
  • 实际效果:你相当于执行了 git commit 的逆操作 ------ 提交没了,但修改还在暂存区,可以直接重新 git commit。

--mixed:移动 HEAD,并重置暂存区

git reset --mixed HEAD~1(默认模式,可省略 --mixed)

结果:

  • HEAD 指向 aaaaaa(撤销了 bbbbbb 提交)
  • 暂存区:被重置为 aaaaaa 时的状态,即 v1(因为 --mixed 会用 HEAD 指向的内容填充暂存区)
  • 工作区:仍然是 v2
  • 实际效果:提交没了,修改还在工作区,但不在暂存区。需要重新 git add 才能再次提交。

--hard:移动 HEAD,重置暂存区和工作

git reset --hard HEAD~1(危险,慎用)

结果:

  • HEAD 指向 aaaaaa(撤销了 bbbbbb 提交)
  • 暂存区:被重置为 aaaaaa 时的状态,即 v1
  • 工作区:也被强制重置为 aaaaaa 时的状态,即 v1
  • 实际效果:提交 bbbbbb 以及你随后对 test.txt 的所有修改(v2)彻底丢失。

总结:

| 命令 | 是否移动 HEAD 和分支指针 | 是否重置暂存区 | 是否重置工作区 | 安全性 | 主要用途 |
| git reset --soft <commit> | ✅ 是 | ❌ 否(保留) | ❌ 否(保留) | 最安全 | 撤销 commit,但保留修改,准备重新提交 |
| git reset --mixed <commit> (默认) | ✅ 是 | ✅ 是(清空) | ❌ 否(保留) | 较安全 | 撤销 commit + unstage 文件,保留修改内容 |

git reset --hard <commit> ✅ 是 ✅ 是(清空) ✅ 是(覆盖) 危险 ⚠️ 彻底丢弃所有修改,回到指定状态

git diff 的原理

不同命令比较的是不同区域。

git diff

比较:工作区 vs 暂存区

表示还没有 git add 的修改。

git diff --cached

比较:暂存区 vs HEAD

表示已经 git add 但还没有 commit 的修改。

git diff HEAD

比较:工作区 vs HEAD

表示从最近一次提交以来的所有修改。

git stash 的原理

执行:git stash

Git 会把当前未提交的修改临时保存成特殊 commit,然后恢复工作区。

可以理解为:当前改动 -> stash commit -> stash 栈

查看:git stash list

恢复:git stash pop

本质上 stash 也是通过 commit 对象保存的,只是引用位置在:refs/stash

远程仓库的底层原理

当你添加远程仓库:git remote add origin git@github.com:user/repo.git,Git 会在配置中记录:

remote "origin"

url = ...

fetch = ...

远程分支通常表现为:

origin/main

origin/dev

这些引用存放在:.git/refs/remotes/origin/ 或被打包到:.git/packed-refs

git fetch 的原理

执行:git fetch origin,Git 会:

  1. 连接远程仓库

  2. 比较本地缺少哪些对象

  3. 下载缺少的 commit、tree、blob

  4. 更新远程跟踪分支,例如 origin/main

注意:git fetch 不会直接修改你的本地分支和工作区。

git pull 的原理

git pull 本质上是:git fetch + git merge(默认)或 git fetch + git rebase(如果配置了rebase)

所以 git pull 并不是一个单独的神秘操作,而是先下载,再整合。

查看当前的 pull 配置:

git config --global pull.rebase

修改为默认使用 rebase:

git config --global pull.rebase true

git push 的原理

执行:git push origin main,Git 会:

  1. 找出远程仓库缺少的对象

  2. 上传这些对象

  3. 请求远程更新引用 refs/heads/main

如果远程分支不是你的本地分支的祖先,Git 会拒绝 push:non-fast-forward,因为直接更新会导致远程已有历史被覆盖。

如果你强制推送:git push --force,就是要求远程把分支指针强行移动到你的本地提交上。

更安全的是:git push --force-with-lease,它会检查远程分支是否还是你上次看到的状态,避免误覆盖别人的提交。

Git 为什么能恢复很多"误删"?

因为 Git 有 reflog。

例如:git reflog,它记录 HEAD 和分支引用的移动历史:

C3 HEAD@{0}: reset: moving to C1

C2 HEAD@{1}: commit: update

C1 HEAD@{2}: commit: init

即使你 reset 了,只要对象还没有被垃圾回收,执行:

git reset --hard HEAD@{1} 或 git checkout <commit-id>,通常可以找回来。

垃圾回收 git gc

Git 对象一开始可能是松散对象:.git/objects/ab/cdef...

随着对象增多,Git 会压缩它们到 packfile:

.git/objects/pack/

├── pack-xxx.pack

└── pack-xxx.idx

执行:git gc,Git 会:

  • 清理不可达对象
  • 压缩对象
  • 合并 packfile
  • 优化仓库性能

所谓不可达对象,是指没有任何分支、标签、reflog 等能引用到的对象。比如,执行 git commit --amend 修改最近一次提交,会创建一个新的提交替换掉原来的提交,原来的提交会变成"悬空"状态,最终被 Git 垃圾回收。

完整例子

假设你执行:

git init

echo "hello" > a.txt

git add a.txt

git commit -m "init"

底层大概是:

工作区:a.txt = "hello"

git add 后:

blob B1 = "hello"

index:a.txt -> B1

git commit 后:

tree T1:a.txt -> B1

commit C1:

  • tree -> T1
  • parent -> null
  • message -> init

main -> C1

HEAD -> main

再修改:

echo "hello v2" > a.txt

git add a.txt

git commit -m "update"

底层变成:

blob B2 = "hello v2"

tree T2:a.txt -> B2

commit C2:

  • tree -> T2
  • parent -> C1
  • message -> update

main -> C2

HEAD -> main

历史关系:C1 -- C2,每个 commit 都指向一个完整项目快照。

本文总结

命令 本质
git add 把文件内容写入对象库,更新 index
git commit 根据 index 创建 tree 和 commit,移动分支指针
git branch 创建一个新的 commit 指针
git checkout / switch 移动 HEAD,并更新 index/工作区
git merge 合并提交图,可能创建 merge commit(git merge --no-ff feature 强制创建merge commit,不使用快进)
git rebase 复制提交到新的 base 上
git reset 移动 HEAD/分支,并按模式更新 index/工作区
git fetch 下载对象并更新远程跟踪分支
git pull fetch + merge/rebase
git push 上传对象并请求远程更新引用

如有问题,欢迎交流指正!

相关推荐
待什么青丝1 小时前
【git的摸鱼技巧】之工欲善其事
git·elasticsearch·搜索引擎
2601_961194022 小时前
高中英语教资资料推荐|科三大题背诵和教学设计模板
git·开源·github·开源软件·开源协议·ossinsight
ting94520002 小时前
InsForge Backend Branching 后端全链路 Git 式分支技术原理、架构实现与底层源码剖析
人工智能·git·elasticsearch·架构
程序猿阿伟2 小时前
《扣子如何让OpenClaw技能开发提速》
人工智能·git·github
摇滚侠4 小时前
IDEA 创建 Java 项目 推送到远程 Git 仓库
java·git·intellij-idea
稷下元歌5 小时前
7天学会plc加机器视觉关于运动控制部份,配套视频在bib
开发语言·c++·git·vscode·python·docker·pip
tealcwu5 小时前
【Git 实战】三类方案实现一键推送多端仓库(Gitee & GitHub)
git·gitee·github
摇滚侠5 小时前
git ignore 忽略 .idea 目录 全新项目(尚未提交过 .idea).idea 已经被 Git 跟踪(已提交过)
java·git·intellij-idea
之歆9 小时前
Day05_Git 版本控制完全指南:从入门到精通的专业实践
git