我们每天都在使用 git add、git commit 和 git switch,但这些命令到底做了什么?为什么创建分支几乎是瞬间完成的?为什么 git reset 之后,有时还能通过 reflog 找回提交?答案大多藏在项目根目录的 .git 中。
本文不会把 .git 当成一张文件清单来背,而是从一次提交开始,逐步理解 Git 的对象数据库、暂存区、引用和恢复机制。读完以后,很多看似零散的 Git 命令会连接成同一套模型。
如果你只是想快速查命令,可以配合《Git 命令速查手册》阅读。
一、工作区与 .git 是什么关系
一个 Git 项目实际上包含两个世界:
| |
src/、README.md 等是工作区 ,也就是我们直接编辑和运行的文件;.git/ 是仓库数据库 ,保存 Git 理解这个项目所需的历史、对象、分支、暂存区和本地配置。
可以把它们理解成:
| |
如果删除 .git,源代码仍然存在,但当前目录不再是 Git 仓库:本地提交历史、分支、标签、暂存内容、远程地址和 reflog 都会消失。重新执行 git init 只能得到一个新仓库,不能凭空恢复原来的历史。
.git中可能包含远程地址、Hooks,以及已删除但尚未清理的历史对象。不要把整个目录作为普通附件公开,也不要在不了解后果时直接修改其中的文件。
二、先理解 Git 的三棵“树”
日常操作可以看成三个版本之间的移动:
| |
更准确地说:
- HEAD :当前检出的提交快照;
- Index :下一次准备提交的快照;
- Working Tree :磁盘上正在编辑的文件。
这也解释了两种 diff 为什么结果不同:
| |
很多人把暂存区理解成“修改文件列表”,其实它更接近下一次提交的完整目录快照 。一个文件可以有一部分进入暂存区,另一部分仍留在工作区,因此 git status -s 中会出现 MM。
三、一次 git add 到底做了什么
假设我们创建一个文件:
| |
git add 主要做了两件事:
- 根据文件内容创建 Blob 对象并写入
.git/objects/; - 更新
.git/index,让暂存区中的hello.txt指向这个 Blob。
可以用底层命令观察:
| |
输出可能类似:
| |
这里的 100644 是普通非可执行文件模式,后面是 Blob 对象 ID。Git 跟踪的是内容,不是简单地把文件复制到一个“暂存文件夹”。
如果 git add 后继续修改 hello.txt,工作区内容已经变化,但 Index 仍指向刚才的 Blob,所以同一文件会同时存在“已暂存”和“未暂存”的修改。
四、Git 的四类核心对象
.git/objects/ 是 Git 的对象数据库,主要保存四类对象:
| 对象 | 保存什么 | 不保存什么 |
|---|---|---|
| Blob | 文件内容 | 文件名和所在目录 |
| Tree | 文件名、目录结构、权限及对象引用 | 文件内容本身 |
| Commit | Tree、父提交、作者、时间和提交信息 | 分支名称 |
| Tag | 附注标签及其目标对象 | 轻量标签引用 |
4.1 Blob:只关心内容
两个路径中内容完全相同的文件,可以复用同一个 Blob。文件名存储在 Tree 中,而不是 Blob 中。
| |
4.2 Tree:描述目录快照
Tree 类似目录清单,记录名称、权限以及所指向的 Blob 或子 Tree。查看当前提交的根 Tree:
| |
4.3 Commit:把快照连接成历史
Commit 对象通常包含:
| |
查看当前提交的原始内容:
| |
普通提交有一个父提交;第一次提交没有父提交;Merge Commit 通常有两个或更多父提交。所谓提交历史,本质上就是 Commit 通过 parent 关系形成的有向无环图。
4.4 Tag:给对象一个稳定名称
轻量标签只是 refs/tags/ 下的引用;附注标签则会创建 Tag 对象,记录创建者、时间和说明,还可以签名。
五、一次 git commit 发生了什么
执行:
| |
Git 大致完成以下步骤:
| |
注意,Commit 保存的是 Tree,而不是“本次修改了哪些行”。git show 展示出的补丁,是 Git把当前 Commit 的 Tree 与父 Commit 的 Tree 比较后计算出来的。
这就是 Git 常被称为“快照系统”的原因。Git 会通过对象复用和 Packfile 压缩降低存储成本,并不是每次提交都机械复制全部文件。
六、HEAD、分支与引用
.git/HEAD 通常只有一行:
| |
意思是 HEAD 指向 main,而 .git/refs/heads/main 再保存某个 Commit ID:
| |
所以分支本质上只是一个可以移动的引用。执行:
| |
不会复制工作区和历史,只是创建另一个指向当前 Commit 的引用,因此几乎瞬间完成。
当你在 main 上提交时,main 向前移动;feature/login 仍停留在旧提交:
| |
可以用这些命令观察引用:
| |
引用较多时,Git 可能将它们压缩到 .git/packed-refs,所以不要假设每个分支一定对应一个可见的独立文件。
七、切换分支为什么会改变文件
执行:
| |
Git 会:
- 让
HEAD指向refs/heads/feature/login; - 用该分支 Commit 对应的 Tree 更新 Index;
- 更新工作区,使其匹配新的 Index。
如果工作区修改会被覆盖,Git 通常会拒绝切换。这并不是 Git“太谨慎”,而是它无法在没有明确授权时判断这些修改该丢弃、暂存还是带到另一个分支。
Detached HEAD 是什么
如果直接检出某个 Commit:
| |
.git/HEAD 会直接保存 Commit ID,而不是指向分支。此时可以查看和提交,但新提交不属于任何分支。需要保留时,应及时创建分支:
| |
八、.git/index:暂存区的真实身份
.git/index 是二进制文件,记录路径、文件模式、对象 ID 和部分状态信息。不要直接用文本编辑器修改它。
暂存区让开发者把工作区中的多项修改拆成不同提交:
| |
发生合并冲突时,Index 还能同时保存 Base、Ours 和 Theirs 三个阶段的对象:
| |
这说明 Index 不只是“待提交列表”,还是 Git 合并机制的重要数据结构。
九、.git/config 与远程仓库
.git/config 保存当前仓库的本地配置,例如:
| |
这里说明:
origin是远程仓库的本地名称,并非 Git 关键字;origin/main是远程跟踪引用,不是服务器上的分支本身;- 本地
main配置为跟踪origin的main。
可以查看配置来自哪里:
| |
git fetch 会下载本地缺少的对象,并更新 refs/remotes/origin/*;git merge 或 git rebase 才会进一步改变当前本地分支。git pull 通常相当于 Fetch 再 Merge 或 Rebase。
十、reflog 为什么能找回提交
分支记录“现在指向哪里”,.git/logs/ 则记录引用“曾经怎样移动”。git reflog 会展示 HEAD 的本地移动历史,包括提交、切换分支、Reset 和 Rebase。
假设误执行:
| |
分支已经退回,普通 git log 看不到刚才的提交,但它们通常仍在对象数据库中,而且 reflog 记录了旧位置:
| |
为什么说“通常”能恢复?因为 reflog 是有过期策略的本地记录,不会推送到远程;没有引用且 reflog 已过期的对象,最终可能被垃圾回收删除。
十一、Merge、Rebase 为什么知道进行到哪一步
Git 在复杂操作中会在 .git 下保存临时状态,例如:
| 文件或目录 | 表示的状态 |
|---|---|
MERGE_HEAD | 正在合并的另一个提交 |
MERGE_MSG | 合并提交的默认说明 |
CHERRY_PICK_HEAD | 正在 Cherry-pick 的提交 |
REVERT_HEAD | 正在 Revert 的提交 |
rebase-merge/、rebase-apply/ | Rebase 的步骤与状态 |
所以发生冲突后,Git 知道应该执行:
| |
也知道如何取消:
| |
直接删除这些状态文件可能让 Git 失去上下文。遇到问题应优先使用对应的 --continue、--skip 或 --abort。
十二、松散对象与 Packfile
刚创建的对象通常以松散对象形式存在:
| |
目录名是对象 ID 的前两个字符,文件名是剩余字符。随着对象增多,Git 会将它们压缩到:
| |
.pack保存压缩后的对象数据;.idx帮助按对象 ID 快速定位;.rev是用于特定遍历优化的反向索引。
Packfile 可以使用 Delta Compression 保存对象之间的差异,但这是存储优化,不改变 Git 在逻辑上使用快照和对象的模型。
日常通常让 Git 自动维护即可:
| |
不要手动删除 .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则会失去整个本地仓库数据库。
执行破坏性命令前,建议按顺序确认:
| |
不确定时,可以先创建临时分支或 Bundle:
| |
十五、历史中的大文件怎样处理
.git 完全可能比当前工作区大。最常见的原因不是源码提交太多,而是历史中曾经加入压缩包、视频、数据库备份、模型文件或编译产物。
假设先提交、后删除一个大文件:
| |
第二次提交只说明新快照不再引用 backup.zip,第一次提交仍然引用它的 Blob。只要这个旧提交可以通过分支或标签到达,大文件就是可达对象 ,Git 必须保留它:
| |
所以 git rm、新增 .gitignore 或执行 git gc 都不能删除仍在历史中的大文件。.gitignore 只阻止未来未跟踪文件被加入,不会修改已有提交。
15.1 先判断该保留还是删除
处理前先做选择:
| |
如果只是体积稍大,但仓库克隆、CI 和维护均未受到影响,历史重写的协作成本可能高于节省的空间。不要为了让数字更小而清理历史。
15.2 分析哪些对象占用空间
先查看仓库对象总体占用:
| |
排查历史路径和大 Blob,推荐使用独立工具 git-filter-repo 的只读分析模式:
| |
报告会写入:
| |
可以重点查看按对象大小和路径聚合的报告,再确认大文件的所有历史路径。文件如果曾经移动或改名,只删除当前路径是不够的。
15.3 先准备可恢复备份
历史重写前,应暂停其他人推送,并在仓库之外保留一份备份。Bundle 可以保存所有可达分支与标签:
| |
这份 Bundle 当然也包含准备删除的大文件,它的目的就是在操作错误时恢复原历史。不要把它重新提交到仓库中。
更稳妥的做法是在一个新克隆中执行历史清理,验证无误后再更新远端,避免直接拿唯一工作副本试验。
15.4 误提交的大文件:用 git filter-repo 删除
从所有被重写的历史中删除指定路径:
| |
删除整个历史目录:
| |
如果文件有多个历史路径,需要全部列出:
| |
也可以按 Blob 大小清理:
| |
按大小删除看似方便,却可能误删仍然需要的资产。正式处理时优先根据分析报告按路径精确清理。
git filter-repo 会为受影响的 Commit 重新生成 Tree 和 Commit。因为 Commit ID 由内容及元数据计算,所以被改写的 Commit 及其后代 ID 都会改变;签名也可能失效。这不是普通的“删除文件提交”,而是创建一套新的仓库历史。
15.5 仍要保留的大文件:迁移到 Git LFS
Git LFS 在普通 Git 历史中保存小型指针,把实际大文件放在 LFS 对象存储中。只执行 git lfs track 只影响未来提交,不能自动迁移已有历史。
迁移历史中的文件类型可以使用:
| |
迁移同样会重写历史并改变 Commit ID。远端还必须支持 Git LFS,而且 LFS 存储与下载流量可能单独计费。本地 .git/lfs/objects/ 也可能缓存多个大文件版本,所以 LFS 不是“文件不再占任何空间”。
15.6 为什么重写后需要强制推送
清理后的 main 与远端 main 不再是快进关系。确认新历史完整、测试通过并协调所有协作者后,才更新受影响引用:
| |
大型清理可能涉及多个分支,必须先列出受影响引用并逐一确认,不能机械复制一条“强推全部”的命令。受保护分支、开放中的 Pull Request、CI、部署环境和 Fork 都可能受到影响。
协作者最安全的处理方式通常是重新克隆。旧克隆若把旧分支 Merge 回新历史,可能把刚清理的对象重新引入。
15.7 本地与远端空间为什么不一定立即下降
只要旧分支、标签、替换引用或 reflog 仍指向旧提交,对象就仍然可达。确认备份有效、新历史正确且旧引用不再需要后,才考虑让旧本地对象过期并回收:
| |
不要把这两条命令当作日常清理。普通情况下 Git 会按自己的过期策略维护仓库;立即过期会减少误操作后的恢复机会。
远程托管平台也可能通过 Pull Request 引用、Fork、缓存或服务端保留策略继续保存旧对象。普通大文件通常等待平台自行回收;如果涉及敏感数据,GitHub 官方文档建议在本地重写和推送后继续协调其他克隆,并在满足条件时联系 GitHub Support 清理缓存引用。
15.8 敏感信息必须先失效
如果历史中出现密码、Token、私钥或证书,第一步永远是吊销、轮换或重新签发。即使稍后从 Git 和 GitHub 中删除,也无法证明它未被他人克隆、缓存或复制。
正确顺序是:
- 立即使凭据失效;
- 确认泄露范围与所有历史路径;
- 使用
git filter-repo重写历史; - 更新远端并协调协作者清理旧克隆;
- 增加
.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 的核心:
| |
在此基础上,分支为何轻量、提交为何能追溯、Reset 为何危险、Reflog 为何能救援,都可以从同一套机制推出。日常仍应通过 Git 命令操作 .git,但当问题出现时,你已经知道该观察哪一层,而不是只靠反复尝试命令。