
先交代一句这篇是Git系列里的第三篇。前两篇聊完了基本流程、分支合并这些“怎么用”的事这篇我打算带大家把.git目录打开看看Git到底是怎么存文件、怎么记历史的。顺便把文件管理背后的操作逻辑一次说透包括那些被问过无数遍的问题——为什么删了文件仓库还那么大、.gitignore为什么拦不住已经跟踪的文件、git gc到底什么时候能清掉东西。如果你已经会用add、commit、pull但面对.git内部结构还是一头雾水或者你被仓库体积膨胀、误删文件、对象损坏这类问题折磨过那这篇正好对症。1. 为什么值得把.git目录翻个底朝天1.1 一次把仓库弄到“无法提交”的教训有次团队的公共仓库突然推不上去报错很直接error: object file ... is empty。当时大家都没辙因为谁都没碰过.git里面的东西。后来排查才知道一位同事为了手动改暂存内容直接编辑了.git/index文件结果把二进制结构弄坏了仓库的索引和对象库对不上所有基于暂存区的操作全部异常。那次事故让我意识到一件事Git用起来确实简单但它本质上不是“带撤销功能的文件夹”而一个小型的内容寻址文件系统。你平日敲的git add、git commit最终都落到.git里那一堆文件上。如果完全不懂内部结构出了问题就只能靠“删掉重来”而很多数据一旦无脑重来就再也找不回来了。1.2 Git其实是一套“内容寻址”的存储系统传统的版本控制比如老派的集中式工具习惯记录“哪个文件在哪一行发生了什么变化”。Git的设计思路完全不同它把每一次提交后的整个项目状态做成一堆不可变的快照对象。你提交的瞬间Git关注的是“当前所有文件的内容是什么”而不是“这次改了几行”。所以Git管理文件的基本单元不是“变更记录”而是对象。对象有三类blob对应文件内容tree对应目录结构commit对应某次历史快照。文件名、目录名、嵌套关系全都被编码进对象里然后靠哈希互相引用。这套机制理解透了后面文件管理里很多反直觉的现象就都能解释清楚了。2. 打开.git目录看“户型图”先不急着写命令。你可以随便找一个仓库执行cd .git ls -a大概率会看到这些成员HEAD config description hooks/ info/ objects/ refs/ index logs/它们各自的职责差别很大。下面按重要程度拆开讲。2.1 objects整个仓库的心脏.git/objects是最核心的目录Git存储的一切——每次提交、每个文件版本、每个目录快照——都在这里。Looser对象以“哈希前两位作为子目录名、其余位作为文件名”的方式存放比如objects/8a/b68695c1e9b0b8f6f2f4e1b8b97a1d4b3f0a7你直接打开这个文件会发现它根本不是可读的文本因为Git会把对象内容先用zlib压缩再写盘。对象本体由“头部 内容”组成头部格式大概是类型 内容字节数\0内容比如一个内容为hello git\n的blob对象它的原始数据是blob 10\0hello git\n然后再整体压缩。看对象内容有现成命令不需要自己去解压git cat-file -t hash # 查对象类型 git cat-file -s hash # 查对象大小 git cat-file -p hash # 查对象内容只要给出对象哈希cat-file就能把压缩后的对象解开给你看。这是排查底层问题时最常用的工具几乎是.git领域的手电筒。另外objects下还有pack/和info/子目录。当仓库对象过多Git会做打包把大量对象压缩进.pack文件以减少体积pack文件会配上.idx索引。这也是“仓库不小但loose对象不多”的原因之一。2.2 HEAD与refs指针的世界.git/HEAD是一个文本文件里面存的不是哈希而是当前分支的引用路径。正常情况下内容长这样ref: refs/heads/main也就是说HEAD是个“符号引用”它指向refs/heads/main而refs/heads/main才最终指向某个commit哈希。refs/目录下主要分为heads/和tags/heads里是一个个本地分支文件每个文件只存一行commit哈希tags里是标签。所以“分支”在Git底层的真实面目不过是一个32字节左右的文本文件里面记录着某个commit哈希。你新建分支时Git本质上是复制了一份指向提交的指针文件而非复制整个目录树。碰到HEAD损坏或想切换引用可以用git symbolic-ref操作。但日常建议不要手改这些文件理解它即可。2.3 index暂存区比你想象的更复杂很多人以为暂存区是“一个缓存文件夹”其实它是.git/index这个二进制文件里面存放着即将进入下次提交的文件清单以及每个文件对应的对象哈希、文件模式甚至包括文件大小和修改时间等stat信息。查看暂存区内容git ls-files --stage输出大概长这样100644 8ab68695c1e9b0b8f6f2f4e1b8b97a1d4b3f0a7 0 hello.txt第一列是文件模式第二列是对象哈希0是合并状态正常为0最后一列是路径。因为index记录了大量文件元信息git status显示未修改、已修改、已暂存本质上就是对“工作区 vs index”和“index vs HEAD”做比较。如果把index直接删掉Git会认为暂存区空空如也但工作区文件都还在——这就是为什么有时候误删index后最直接的恢复方式反而是git reset重建。2.4 那些容易被忽略的成员config是仓库级配置文件存了用户、远程地址、分支对应关系等。hooks/存放钩子脚本info/exclude相当于仓库私有的.gitignore优先级比.gitignore低但不会随仓库提交。description仅供GitWeb这类工具展示用日常基本不管。logs/目录非常重要它保存着reflog——也就是HEAD和分支指针的变动历史。后面讲误删恢复主要就靠它。3. 三种对象如何拼出一次提交Git对象系统里只有三种关键对象blob、tree、commit。它们的关系就像是“文件内容、目录骨架、历史节点”三层结构完全可以手动拼出来。3.1 blobGit只认内容不认文件名blob是文件内容的载体。它的特点很反直觉同一个内容的文件不管文件名是a.txt还是b.txt对应的blob哈希完全一样因为blob不包含文件名信息只包含文件内容。验证一下echo hello a.txt echo hello b.txt git hash-object a.txt git hash-object b.txt两次输出的哈希一模一样。这意味着如果你在仓库里复制了大量内容相同的文件它们在Git对象库里只占一份空间。同时它也解释了为什么Git对大文件不友好——大文件的任何修改都会产生两个体积接近的blob对象历史一长仓库体积很容易失控。3.2 tree把文件名和目录结构装进来tree对象解决的是“文件名和目录结构放哪”的问题。它内部是一个条目列表每条表示“某个模式下的某个名称指向某个对象”100644 blob 8ab686... hello.txt 100755 blob 5f0a1b... run.sh 040000 tree 7c2d0e... src模式含义100644普通文件100755可执行文件040000目录tree对象本身120000符号链接tree对象的优势在于只要目录里文件结构相同就可以复用同一棵tree。所以两次提交之间如果某个子目录没变Git不会重复生成对应的tree对象而是继续引用旧的。这保证了快照存储的高效性。3.3 commit把历史串成一串commit对象看起来像一页元数据tree 8ab686... # 本次快照的根目录树 parent a1b2c3... # 父提交哈希首次提交没有此字段 author 某开发者 xxx 1718000000 0800 committer 某开发者 xxx 1718000000 0800 提交消息正文所以一次提交本质上是指向一棵tree加一串附加信息的指针。分支切换、回滚、diff全都是在这个有向无环图上做指针操作。为什么Git切分支快因为多数情况下只是移动HEAD指针而不是把文件全部复制一遍。3.4 亲手制造一次提交底层命令演练纸上谈兵不如自己造一遍。下面这套命令会完全绕开git add和git commit手动完成一次提交帮你把对象引用关系看明白。git init -b main manual cd manual echo hello git hello.txt # 第一步生成并写入blob对象 blob$(git hash-object -w hello.txt) echo blob: $blob # 第二步手动构造tree对象 tree$(printf 100644 blob %s\thello.txt\n $blob | git mktree) echo tree: $tree # 第三步基于tree生成commit对象 commit$(echo manual first commit | git commit-tree $tree) echo commit: $commit # 第四步让HEAD指向这个commit git update-ref refs/heads/main $commit # 第五步把tree内容读入index并验证状态 git read-tree $tree git status git log --oneline要注意的是git hash-object -w里的-w表示写入对象库不加就只是计算哈希。printf构造tree条目时模式、类型、哈希、文件名之间必须严格用空格和tab分隔git mktree才会接受。这套流程走完你会发现之前所有的抽象概念都变成了具体文件blob在.git/objects里躺着tree被commit引用commit又被refs/heads/main指向。以后再有人问“Git提交到底是什么”你可以直接说就是一套对象引用链。4. 文件管理实战暂存、删除、忽略背后的机制4.1git add到底做了什么git add不是一个“把文件拷进暂存区”的动作它包含两步把工作区文件内容写入对象库生成或复用对应的blob对象更新.git/index把文件路径映射到该blob哈希。如果文件内容之前已经提交过第一步会直接复用已有对象不会重复存储。这也是为什么同内容文件在Git里只占一份空间的底层原因。理解这一点后git status的输出就好懂了“Changes to be committed”是index和HEAD的差异“Changes not staged for commit”是工作区和index的差异。git add只是把后者的差异同步到index但index并不代表“最终会被提交的版本”——提交时Git只看index的当前状态。所以如果你add之后又改了文件不重新add提交进去的还是旧内容。这个坑很多人踩过。4.2 为什么.gitignore拦不住已跟踪文件很多人写好了.gitignore却发现文件依然出现在git status里气得不行。原因很简单.gitignore只影响“未被跟踪”的文件也就是那些从未进入过index和对象库的文件。一旦某个文件已经被git add或提交过它就在index和对象库里有了正式记录再写ignore规则也不会自动移除。想要彻底不再跟踪一个已提交的文件正确流程是git rm --cached file echo file .gitignore git commit -m stop tracking filegit rm --cached的意思是从index里移除该文件但保留工作区文件。这适合处理误提交的密钥、日志、构建产物。如果你用了普通git rm工作区文件也会被删掉不一定是你想要的后果。4.3 删除和重命名Git视角下都是改写目录条目在Git底层“删除一个文件”就是“在新tree对象里不再包含该路径条目”文件字节仍在对象库里只是不再被新快照引用。而“重命名”不过是一次删除加一次新增。Git不会为“重命名”这件事单独设计对象它是在对比两个tree时通过内容相似度猜测出来的。所以git mv old.txt new.txt实际上执行了两步删除旧路径条目添加新路径条目。展示历史时git diff --find-renames会根据相似度自动标记R但这只是展示层的推断底层没有独立的重命名记录。理解了这点你就不会纠结“为什么有时候重命名显示成了删除和新增”。4.4 误删文件后找回的边界在哪里误删文件是日常高频事故。恢复手段分情况文件已被跟踪且你只动了工作区git restore file或git checkout -- file从index恢复。文件已暂存但还没提交可以用git restore --staged --worktree file从HEAD同时恢复index和工作区。文件已提交但后来不小心用git reset弄丢了分支先别慌去查git reflog找到reset之前的HEAD哈希再git reset --hard hash或基于它创建新分支。但有一条边界必须记住如果在文件从未被git add的情况下工作区文件被外部程序覆盖或删除Git根本不知道它的存在也就没有对象可以恢复。这类文件只能靠编辑器缓存、备份工具或手工抢救。Git不是万能的备份只有“进入过对象库的内容”才有恢复的可能。5. 我实际踩过的坑对象损坏、仓库膨胀与恢复链路5.1 一觉醒来推不上去对象损坏的排查过程开头提到的那次事故我完整复盘一遍排查链路可以当模板用。现象是某仓库执行git commit时报error: object file .git/objects/xx/yyy is corrupt连git status都异常。当时我先做的第一件事是备份整个.git目录这是所有恢复操作的前提。然后执行git fsck --fullfsck会遍历对象库检查对象之间的引用链是否完整、对象内容是否可解压。它很快就指出了坏对象的位置。接着执行git cat-file -t 损坏对象哈希确认类型读取失败确实坏了。由于旧对象不可恢复而该提交同时存在于远程最终采用了“从远程重新获取对象”的方案临时把损坏对象文件移走再git fetch让Git从远端重新拉取完整对象。之后仓库恢复正常。如果遇到的是index损坏恢复路径更简单备份后删除.git/index然后执行git reset不加--hard让Git根据HEAD和对象库重建index。代价是之前暂存但未提交的内容会丢失所以操作前先想清楚。5.2 删了文件仓库为什么没有变小这是最常被问的问题。比如仓库里有个几百MB的视频文件你用git rm删掉并提交推完一看.git目录体积纹丝不动。原因在前面已经铺垫过了删除操作只是让新提交的tree不再引用旧blob但旧blob依然被历史commit引用。Git的GC默认只清理“不可达”对象——也就是没有任何引用指向它、也没在reflog里的对象。于是旧文件对象只要还在历史树上就会一直躺在对象库或包文件里。查看对象库基线数据git count-objects -vH关键字段是countloose对象数量和size-packpack文件体积。如果size-pack很大说明历史里堆了不少大对象。要真正让仓库瘦下来只能重写历史让新历史里彻底不包含那个大文件然后清理并强制推送。这个操作要谨慎需要在完整备份后执行且所有协作者在之后都必须重新克隆或小心处理直接pull往往会遇到激烈的历史分叉。5.3 在跑git gc之前先想好这几件事git gc不是一键瘦身按钮它做的是四件事打包loose对象、清理不可达对象、按过期时间清理reflog、合并并压缩pack。它的“清理”边界比我以前以为的复杂得多。执行git gc --prunenow会立刻清除不可达对象并把超过配置时限的reflog一并处理。默认gc.reflogExpire是90天也就是说任何只在reflog里存在的提交90天后可能被GC彻底抹掉。我个人的建议是在跑任何形式的GC前先确认三件事仓库是否已完整备份最好用git clone --mirror拉一份裸仓库。是否有误删的提交还指望靠reflog恢复如果有先恢复结束再GC。仓库是否涉及大文件清理如果是仅GC不够需要历史重写配合。git prune是更底层的清理命令平时基本不用因为git gc已经会调用它。如果你只是想看看到底有哪些“孤儿”对象可以用git fsck --lost-found列出dangling commit和dangling blob确认没价值后再清理。5.4 我养成的几个检查习惯踩过几次坑之后我现在维护仓库基本遵循一套固定动作成本很低但见效明显新仓库初始化后先确认默认分支名、用户信息避免提交历史里出现奇怪身份。一周至少跑一次git fsck尤其是多人协作的仓库早发现坏对象比晚发现容易处理得多。收到仓库体积突然变大的提醒时第一时间用git count-objects -vH和git rev-list --objects --all排查大对象而不是急着删文件。任何危险操作reset、rebase、filter历史重写前先顺手记一个分支标签或确认reflog可用。这比事后找恢复工具靠谱。手动看对象库时多依赖git cat-file -p不要直接在.git/objects里翻二进制文件很容易手滑弄坏。另外一个值得养成的习惯是在测试仓库里模仿一次“手动构造提交”的实验。这大概半小时但对理解对象模型的效果比看十篇文章都强。我就是那次实验之后才算把Git的文件管理机制真正刻进脑子里的。最后再分享一点个人体会Git的绝大多数“怪现象”比如仓库膨胀、文件删不掉、恢复不了归根结底都是对.git内部机制的误解。你别把它当成黑盒用底层命令拆一遍很多恐惧都会消失。下一篇如果还有机会我会接着讲讲对象压缩和pack格式的细节那些是仓库真正瘦身时绕不开的东西。