深入理解 .git 目录:Git 如何存储提交、分支与暂存区

我们每天都在使用 git addgit commitgit switch,但这些命令到底做了什么?为什么创建分支几乎是瞬间完成的?为什么 git reset 之后,有时还能通过 reflog 找回提交?答案大多藏在项目根目录的 .git 中。

本文不会把 .git 当成一张文件清单来背,而是从一次提交开始,逐步理解 Git 的对象数据库、暂存区、引用和恢复机制。读完以后,很多看似零散的 Git 命令会连接成同一套模型。

如果你只是想快速查命令,可以配合《Git 命令速查手册》阅读。


一、工作区与 .git 是什么关系

一个 Git 项目实际上包含两个世界:

1
2
3
4
my-project/
├── src/
├── README.md
└── .git/

src/README.md 等是工作区 ,也就是我们直接编辑和运行的文件;.git/仓库数据库 ,保存 Git 理解这个项目所需的历史、对象、分支、暂存区和本地配置。

可以把它们理解成:

1
2
工作区:项目现在呈现出来的样子
.git:项目有哪些历史,以及 Git 当前处于什么状态

如果删除 .git,源代码仍然存在,但当前目录不再是 Git 仓库:本地提交历史、分支、标签、暂存内容、远程地址和 reflog 都会消失。重新执行 git init 只能得到一个新仓库,不能凭空恢复原来的历史。

.git 中可能包含远程地址、Hooks,以及已删除但尚未清理的历史对象。不要把整个目录作为普通附件公开,也不要在不了解后果时直接修改其中的文件。

二、先理解 Git 的三棵“树”

日常操作可以看成三个版本之间的移动:

1
2
3
4
5
6
7
8
9
HEAD 中的提交快照
        │ git restore --staged
暂存区(Index)
        │ git restore
工作区(Working Tree)

更准确地说:

  • HEAD :当前检出的提交快照;
  • Index :下一次准备提交的快照;
  • Working Tree :磁盘上正在编辑的文件。

这也解释了两种 diff 为什么结果不同:

1
2
3
4
5
6
7
8
# 工作区与暂存区比较:还有什么没有 git add
git diff

# 暂存区与 HEAD 比较:下一次提交会包含什么
git diff --staged

# 工作区与 HEAD 直接比较:当前一共改了什么
git diff HEAD

很多人把暂存区理解成“修改文件列表”,其实它更接近下一次提交的完整目录快照 。一个文件可以有一部分进入暂存区,另一部分仍留在工作区,因此 git status -s 中会出现 MM

三、一次 git add 到底做了什么

假设我们创建一个文件:

1
2
echo "hello git" > hello.txt
git add hello.txt

git add 主要做了两件事:

  1. 根据文件内容创建 Blob 对象并写入 .git/objects/
  2. 更新 .git/index,让暂存区中的 hello.txt 指向这个 Blob。

可以用底层命令观察:

1
2
3
4
5
6
7
8
# 计算文件内容对应的对象 ID,但不写入对象数据库
git hash-object hello.txt

# 将内容写入对象数据库,并输出对象 ID
git hash-object -w hello.txt

# 查看暂存区记录的路径、权限和对象 ID
git ls-files --stage

输出可能类似:

1
100644 14a8d... 0    hello.txt

这里的 100644 是普通非可执行文件模式,后面是 Blob 对象 ID。Git 跟踪的是内容,不是简单地把文件复制到一个“暂存文件夹”。

如果 git add 后继续修改 hello.txt,工作区内容已经变化,但 Index 仍指向刚才的 Blob,所以同一文件会同时存在“已暂存”和“未暂存”的修改。

四、Git 的四类核心对象

.git/objects/ 是 Git 的对象数据库,主要保存四类对象:

对象保存什么不保存什么
Blob文件内容文件名和所在目录
Tree文件名、目录结构、权限及对象引用文件内容本身
CommitTree、父提交、作者、时间和提交信息分支名称
Tag附注标签及其目标对象轻量标签引用

4.1 Blob:只关心内容

两个路径中内容完全相同的文件,可以复用同一个 Blob。文件名存储在 Tree 中,而不是 Blob 中。

1
2
3
4
5
6
7
8
# 查看对象类型
git cat-file -t <object-id>

# 查看对象大小
git cat-file -s <object-id>

# 查看对象内容
git cat-file -p <object-id>

4.2 Tree:描述目录快照

Tree 类似目录清单,记录名称、权限以及所指向的 Blob 或子 Tree。查看当前提交的根 Tree:

1
2
3
4
git ls-tree HEAD

# 递归查看所有路径
git ls-tree -r HEAD

4.3 Commit:把快照连接成历史

Commit 对象通常包含:

1
2
3
4
5
6
tree <tree-id>
parent <parent-commit-id>
author ...
committer ...

提交信息

查看当前提交的原始内容:

1
git cat-file -p HEAD

普通提交有一个父提交;第一次提交没有父提交;Merge Commit 通常有两个或更多父提交。所谓提交历史,本质上就是 Commit 通过 parent 关系形成的有向无环图。

4.4 Tag:给对象一个稳定名称

轻量标签只是 refs/tags/ 下的引用;附注标签则会创建 Tag 对象,记录创建者、时间和说明,还可以签名。

五、一次 git commit 发生了什么

执行:

1
git commit -m "docs: add hello"

Git 大致完成以下步骤:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
读取 .git/index
为暂存区目录生成 Tree 对象
创建 Commit 对象
    ├── 指向刚生成的 Tree
    └── 指向原来的 HEAD 作为 parent
让当前分支指向新 Commit
在 reflog 中记录引用移动

注意,Commit 保存的是 Tree,而不是“本次修改了哪些行”。git show 展示出的补丁,是 Git把当前 Commit 的 Tree 与父 Commit 的 Tree 比较后计算出来的。

这就是 Git 常被称为“快照系统”的原因。Git 会通过对象复用和 Packfile 压缩降低存储成本,并不是每次提交都机械复制全部文件。

六、HEAD、分支与引用

.git/HEAD 通常只有一行:

1
ref: refs/heads/main

意思是 HEAD 指向 main,而 .git/refs/heads/main 再保存某个 Commit ID:

1
HEAD → refs/heads/main → Commit

所以分支本质上只是一个可以移动的引用。执行:

1
git branch feature/login

不会复制工作区和历史,只是创建另一个指向当前 Commit 的引用,因此几乎瞬间完成。

当你在 main 上提交时,main 向前移动;feature/login 仍停留在旧提交:

1
2
3
A---B---C  main、HEAD
     \
      feature/login

可以用这些命令观察引用:

1
2
3
4
5
6
7
8
9
# 解析名称最终指向的对象 ID
git rev-parse HEAD
git rev-parse main

# 查看本地引用
git show-ref

# 查看分支指向及上游关系
git branch -vv

引用较多时,Git 可能将它们压缩到 .git/packed-refs,所以不要假设每个分支一定对应一个可见的独立文件。

七、切换分支为什么会改变文件

执行:

1
git switch feature/login

Git 会:

  1. HEAD 指向 refs/heads/feature/login
  2. 用该分支 Commit 对应的 Tree 更新 Index;
  3. 更新工作区,使其匹配新的 Index。

如果工作区修改会被覆盖,Git 通常会拒绝切换。这并不是 Git“太谨慎”,而是它无法在没有明确授权时判断这些修改该丢弃、暂存还是带到另一个分支。

Detached HEAD 是什么

如果直接检出某个 Commit:

1
git switch --detach abc1234

.git/HEAD 会直接保存 Commit ID,而不是指向分支。此时可以查看和提交,但新提交不属于任何分支。需要保留时,应及时创建分支:

1
git switch -c experiment

八、.git/index:暂存区的真实身份

.git/index 是二进制文件,记录路径、文件模式、对象 ID 和部分状态信息。不要直接用文本编辑器修改它。

暂存区让开发者把工作区中的多项修改拆成不同提交:

1
2
3
4
5
6
7
8
# 按修改块选择要暂存的内容
git add -p

# 查看下一次提交的完整文件列表
git ls-files --stage

# 将文件移出暂存区,但保留工作区修改
git restore --staged path/to/file

发生合并冲突时,Index 还能同时保存 Base、Ours 和 Theirs 三个阶段的对象:

1
git ls-files --unmerged

这说明 Index 不只是“待提交列表”,还是 Git 合并机制的重要数据结构。

九、.git/config 与远程仓库

.git/config 保存当前仓库的本地配置,例如:

1
2
3
4
5
6
7
[remote "origin"]
    url = https://github.com/user/repo.git
    fetch = +refs/heads/*:refs/remotes/origin/*

[branch "main"]
    remote = origin
    merge = refs/heads/main

这里说明:

  • origin 是远程仓库的本地名称,并非 Git 关键字;
  • origin/main 是远程跟踪引用,不是服务器上的分支本身;
  • 本地 main 配置为跟踪 originmain

可以查看配置来自哪里:

1
2
3
git config --list --show-origin
git remote -v
git branch -vv

git fetch 会下载本地缺少的对象,并更新 refs/remotes/origin/*git mergegit rebase 才会进一步改变当前本地分支。git pull 通常相当于 Fetch 再 Merge 或 Rebase。

十、reflog 为什么能找回提交

分支记录“现在指向哪里”,.git/logs/ 则记录引用“曾经怎样移动”。git reflog 会展示 HEAD 的本地移动历史,包括提交、切换分支、Reset 和 Rebase。

假设误执行:

1
git reset --hard HEAD~2

分支已经退回,普通 git log 看不到刚才的提交,但它们通常仍在对象数据库中,而且 reflog 记录了旧位置:

1
2
3
4
git reflog

# 先创建恢复分支,比直接 reset 更安全
git branch recovery <old-commit-id>

为什么说“通常”能恢复?因为 reflog 是有过期策略的本地记录,不会推送到远程;没有引用且 reflog 已过期的对象,最终可能被垃圾回收删除。

十一、Merge、Rebase 为什么知道进行到哪一步

Git 在复杂操作中会在 .git 下保存临时状态,例如:

文件或目录表示的状态
MERGE_HEAD正在合并的另一个提交
MERGE_MSG合并提交的默认说明
CHERRY_PICK_HEAD正在 Cherry-pick 的提交
REVERT_HEAD正在 Revert 的提交
rebase-merge/rebase-apply/Rebase 的步骤与状态

所以发生冲突后,Git 知道应该执行:

1
2
3
git merge --continue
git rebase --continue
git cherry-pick --continue

也知道如何取消:

1
2
3
git merge --abort
git rebase --abort
git cherry-pick --abort

直接删除这些状态文件可能让 Git 失去上下文。遇到问题应优先使用对应的 --continue--skip--abort

十二、松散对象与 Packfile

刚创建的对象通常以松散对象形式存在:

1
.git/objects/ab/cdef...

目录名是对象 ID 的前两个字符,文件名是剩余字符。随着对象增多,Git 会将它们压缩到:

1
2
3
4
.git/objects/pack/
├── pack-xxxx.pack
├── pack-xxxx.idx
└── pack-xxxx.rev
  • .pack 保存压缩后的对象数据;
  • .idx 帮助按对象 ID 快速定位;
  • .rev 是用于特定遍历优化的反向索引。

Packfile 可以使用 Delta Compression 保存对象之间的差异,但这是存储优化,不改变 Git 在逻辑上使用快照和对象的模型。

日常通常让 Git 自动维护即可:

1
2
3
4
5
6
7
8
# 查看对象数量和占用空间
git count-objects -vH

# 检查对象数据库连通性与有效性
git fsck --full

# 运行仓库维护任务
git maintenance run

不要手动删除 .git/objects/ 中看似无用的文件。

十三、.git 中其他常见内容

路径用途
config当前仓库的本地配置
description供 GitWeb 等工具使用的仓库说明
hooks/提交、推送等操作前后的 Hook 脚本
info/exclude仅当前仓库生效、不会提交的忽略规则
FETCH_HEAD最近一次 Fetch 得到的引用信息
ORIG_HEAD某些危险操作之前的 HEAD 位置
COMMIT_EDITMSG最近编辑的提交说明

这些文件的生命周期不同,有些可能暂时不存在。比如只有正在 Merge 时才会出现 MERGE_HEAD

十四、哪些操作会真正丢失数据

理解 .git 后,可以更准确地判断风险:

  • git restore path 可能丢失未提交的工作区修改;
  • git reset --hard 会同时重置分支、Index 和工作区;
  • git clean -fd 会删除未跟踪文件和目录;
  • 删除分支通常只删除引用,提交可能还能从其他引用或 reflog 找到;
  • 删除 .git 则会失去整个本地仓库数据库。

执行破坏性命令前,建议按顺序确认:

1
2
3
4
5
git status
git diff
git diff --staged
git log --oneline --decorate -10
git reflog -10

不确定时,可以先创建临时分支或 Bundle:

1
2
git branch backup-before-reset
git bundle create repository-backup.bundle --all

十五、历史中的大文件怎样处理

.git 完全可能比当前工作区大。最常见的原因不是源码提交太多,而是历史中曾经加入压缩包、视频、数据库备份、模型文件或编译产物。

假设先提交、后删除一个大文件:

1
2
3
4
5
git add backup.zip
git commit -m "add backup"

git rm backup.zip
git commit -m "remove backup"

第二次提交只说明新快照不再引用 backup.zip,第一次提交仍然引用它的 Blob。只要这个旧提交可以通过分支或标签到达,大文件就是可达对象 ,Git 必须保留它:

1
2
3
4
5
6
7
main
Commit B:没有 backup.zip
  │ parent
Commit A:仍然引用 backup.zip 的 Blob

所以 git rm、新增 .gitignore 或执行 git gc 都不能删除仍在历史中的大文件。.gitignore 只阻止未来未跟踪文件被加入,不会修改已有提交。

15.1 先判断该保留还是删除

处理前先做选择:

1
2
3
4
5
6
7
8
历史大文件
├── 仍是项目资产,需要版本管理
│   └── 迁移到 Git LFS
├── 是误提交的构建产物、备份或临时文件
│   └── 从历史中删除
└── 包含密码、Token 或私钥
    ├── 立即吊销或轮换凭据
    └── 再从历史和托管平台中清理

如果只是体积稍大,但仓库克隆、CI 和维护均未受到影响,历史重写的协作成本可能高于节省的空间。不要为了让数字更小而清理历史。

15.2 分析哪些对象占用空间

先查看仓库对象总体占用:

1
git count-objects -vH

排查历史路径和大 Blob,推荐使用独立工具 git-filter-repo 的只读分析模式:

1
git filter-repo --analyze

报告会写入:

1
.git/filter-repo/analysis/

可以重点查看按对象大小和路径聚合的报告,再确认大文件的所有历史路径。文件如果曾经移动或改名,只删除当前路径是不够的。

15.3 先准备可恢复备份

历史重写前,应暂停其他人推送,并在仓库之外保留一份备份。Bundle 可以保存所有可达分支与标签:

1
2
git bundle create ../before-cleanup.bundle --all
git bundle verify ../before-cleanup.bundle

这份 Bundle 当然也包含准备删除的大文件,它的目的就是在操作错误时恢复原历史。不要把它重新提交到仓库中。

更稳妥的做法是在一个新克隆中执行历史清理,验证无误后再更新远端,避免直接拿唯一工作副本试验。

15.4 误提交的大文件:用 git filter-repo 删除

从所有被重写的历史中删除指定路径:

1
2
3
git filter-repo \
  --path backup.zip \
  --invert-paths

删除整个历史目录:

1
2
3
git filter-repo \
  --path public/ \
  --invert-paths

如果文件有多个历史路径,需要全部列出:

1
2
3
4
git filter-repo \
  --path old/backup.zip \
  --path backups/backup.zip \
  --invert-paths

也可以按 Blob 大小清理:

1
2
# 删除历史中超过 100 MiB 的 Blob
git filter-repo --strip-blobs-bigger-than 100M

按大小删除看似方便,却可能误删仍然需要的资产。正式处理时优先根据分析报告按路径精确清理。

git filter-repo 会为受影响的 Commit 重新生成 Tree 和 Commit。因为 Commit ID 由内容及元数据计算,所以被改写的 Commit 及其后代 ID 都会改变;签名也可能失效。这不是普通的“删除文件提交”,而是创建一套新的仓库历史。

15.5 仍要保留的大文件:迁移到 Git LFS

Git LFS 在普通 Git 历史中保存小型指针,把实际大文件放在 LFS 对象存储中。只执行 git lfs track 只影响未来提交,不能自动迁移已有历史。

迁移历史中的文件类型可以使用:

1
2
3
4
git lfs install
git lfs migrate import \
  --include="*.psd,*.mp4,*.zip" \
  --everything

迁移同样会重写历史并改变 Commit ID。远端还必须支持 Git LFS,而且 LFS 存储与下载流量可能单独计费。本地 .git/lfs/objects/ 也可能缓存多个大文件版本,所以 LFS 不是“文件不再占任何空间”。

15.6 为什么重写后需要强制推送

清理后的 main 与远端 main 不再是快进关系。确认新历史完整、测试通过并协调所有协作者后,才更新受影响引用:

1
2
3
4
5
# 示例:更新主分支
git push --force-with-lease origin main

# 确认所有标签也已按预期重写后再推送
git push --force origin --tags

大型清理可能涉及多个分支,必须先列出受影响引用并逐一确认,不能机械复制一条“强推全部”的命令。受保护分支、开放中的 Pull Request、CI、部署环境和 Fork 都可能受到影响。

协作者最安全的处理方式通常是重新克隆。旧克隆若把旧分支 Merge 回新历史,可能把刚清理的对象重新引入。

15.7 本地与远端空间为什么不一定立即下降

只要旧分支、标签、替换引用或 reflog 仍指向旧提交,对象就仍然可达。确认备份有效、新历史正确且旧引用不再需要后,才考虑让旧本地对象过期并回收:

1
2
3
# 破坏性操作:先确认不再需要通过 reflog 恢复旧历史
git reflog expire --expire=now --all
git gc --prune=now

不要把这两条命令当作日常清理。普通情况下 Git 会按自己的过期策略维护仓库;立即过期会减少误操作后的恢复机会。

远程托管平台也可能通过 Pull Request 引用、Fork、缓存或服务端保留策略继续保存旧对象。普通大文件通常等待平台自行回收;如果涉及敏感数据,GitHub 官方文档建议在本地重写和推送后继续协调其他克隆,并在满足条件时联系 GitHub Support 清理缓存引用。

15.8 敏感信息必须先失效

如果历史中出现密码、Token、私钥或证书,第一步永远是吊销、轮换或重新签发。即使稍后从 Git 和 GitHub 中删除,也无法证明它未被他人克隆、缓存或复制。

正确顺序是:

  1. 立即使凭据失效;
  2. 确认泄露范围与所有历史路径;
  3. 使用 git filter-repo 重写历史;
  4. 更新远端并协调协作者清理旧克隆;
  5. 增加 .gitignore、提交前检查或服务端保护,防止再次发生。

这也是为什么“从最新提交删除文件”和“从所有副本中消除泄露”是两个完全不同的问题。

十六、把常用命令放回底层模型

最后将高频命令与 .git 中的变化对应起来:

命令主要影响
git add写入对象,并更新 Index
git commit创建 Tree 和 Commit,移动当前分支引用
git branch创建、删除或查看引用
git switch改变 HEAD,并更新 Index 与工作区
git fetch下载对象,更新远程跟踪引用
git merge合并历史,必要时创建 Merge Commit
git reset移动引用,并按模式更新 Index、工作区
git reflog读取本地引用移动记录
git gc压缩对象并清理满足过期条件的不可达数据

至此,Git 不再是一组需要死记的命令:工作区负责编辑,Index 组织下一次快照,Objects 保存内容与历史,Refs 给提交起可移动的名字,Reflog 则为引用移动留下本地记录。

总结

.git 是 Git 仓库真正的主体,工作区只是当前被检出的一个视图。理解下面这条链路,就抓住了 Git 的核心:

1
2
3
4
5
6
7
文件内容
  → Blob
  → Index
  → Tree
  → Commit
  → Branch Ref
  → HEAD

在此基础上,分支为何轻量、提交为何能追溯、Reset 为何危险、Reflog 为何能救援,都可以从同一套机制推出。日常仍应通过 Git 命令操作 .git,但当问题出现时,你已经知道该观察哪一层,而不是只靠反复尝试命令。

参考资料

Licensed under CC BY-NC-SA 4.0