资讯动态

深入解析.git目录:对象存储、分支引用与故障排查

发布时间:2026/9/18 8:13:50 来源:尧图企业网站定制
1. 先说结论.git 不是缓存它就是仓库本身大部分人第一次认真看项目根目录时都是因为出了点事。要么是仓库突然变得异常臃肿要么是同事把整个项目文件夹打个压缩包发过来解压后 Git 命令全都不认了要么是在服务器上部署完才发现站点能被人下载到源码。这些问题的根子都指向同一个地方——.git文件夹以及我们对它的理解程度。我用 Git 的头几年一直把它当成一个命令集合来用脑子里根本没有仓库这个物理概念。直到有次 clone 一个前端项目工作区才四十多兆.git目录却有六百多兆我才真正开始把它拆开看。那次排查的结论很简单有人提交过几个大的二进制资源文件后来又删掉了工作区里看不到但历史记录里的对象还老老实实躺在.git/objects里。这件事让我意识到.git不是一个可以被忽略的附属品它是整个仓库的真身工作区反而只是从它里面检出出来的一个快照视图。这篇文章不讲git add该怎么敲而是回答一个更底层的问题Git 到底把东西存在了哪儿每一块分别管什么以及这些知识怎么在真实故障里派上用场。读完之后你应该能自己判断出fatal: not a git repository到底在说什么、误删分支后去哪儿翻记录、仓库体积异常时从哪个目录开始查、为什么部署时.git绝对不能跟着代码一起传到 Web 根目录。1.1 从一次仓库比代码重十倍的排查说起那次排查的入口其实很土du -sh .git和git count-objects -vH两条命令。前者告诉你.git占了多少磁盘后者告诉你里面有多少个松散对象、多少个打包对象、打包文件多大。两条命令一跑问题范围立刻从整个项目缩小到对象存储。接下来我用git verify-pack -v .git/objects/pack/*.idx | sort -k3 -n | tail -20把 pack 里最大的对象排出来一眼就看到几个几十兆的 blob路径指向早期的图片资源目录。这些文件在当时的那次提交里就存在后来被删掉但删除只是新增了一个删除的提交旧对象还在因为 Git 的默认策略是只要对象还被任何提交或 reflog 引用着就不清理。这里有个很多人会踩的认知坑以为git rm之后就释放空间了。实际上git rm加提交只是让工作区和最新提交里看不到这个文件历史里依然有。真正要清掉要么用filter-repo之类的工具重写历史要么等对象彻底没人引用之后跑git gc --prunenow把它剪掉。而这两条路各有代价前者会改写所有后续提交的哈希团队里其他人必须重新 clone 或者按指引重置本地分支后者只对已经没有任何引用的对象有效。所以.git变胖这件事本质上是一个存储模型的必然结果而不是 Git 的 bug。搞懂objects目录的组织方式你才能判断自己面对的是历史里有大文件还是松散对象太多没打包这两者的处理方式完全不同。1.2 删掉 .git 到底会发生什么这是我被问得最多的问题。回答之前先明确一件事.git目录里保存的是提交历史、分支引用、暂存区、配置、钩子脚本、reflog 记录。它不保存工作区的当前内容但工作区的当前内容能不能被追溯完全依赖它。如果你直接rm -rf .git那么项目目录里的文件本身还在你写的代码一行都不会丢。丢的是所有历史提交、所有分支、所有标签、暂存区状态、本地配置。此时这个目录就变成了一个普通的文件夹git status会直接报fatal: not a git repository。想恢复只能从远程重新 clone或者从别的备份里捞——而且如果你本地有还没推送的提交那些提交就永远找不回来了。更隐蔽的一种情况是把项目文件夹直接拷贝走。Windows 上有些压缩工具默认跳过隐藏文件夹.git就是隐藏的压缩包里没有它解压出来的项目自然就不是仓库了。还有人把项目放在网盘同步目录里多台设备同时同步.git/index和.git/objects冲突副本一多仓库就会进入各种奇怪的状态。这些都属于.git被环境弄坏而非 Git 本身出错。注意任何针对.git的手工改动之前先把整个目录复制一份到别处。.git内部的很多东西没有撤销按钮改错了就是永久性的。1.3 动手前的安全姿势复制一份再拆我自己拆.git的习惯是先在旁边做一个完整副本cp -r project project.git-bak然后在副本上做实验。理由很实际——git fsck能查出对象缺失和引用断裂但它不一定能修手工改refs文件很爽但改错一个字符那个分支就指向了一个不存在的对象仓库会变成半损坏状态。另外建议装两个小工具级别的习惯。一是用git rev-parse --git-dir确认当前目录到底把哪个.git当成了仓库根这在子目录、子模块、多重工作区场景里特别有用因为一个仓库的子目录往上找.git时可能找到的是完全意料之外的那个。二是记住git rev-parse --show-toplevel它会打印工作区根目录的绝对路径配合上面那条命令能快速定位自己处在哪一层。准备工作做完就可以按真实结构一块块看了。下面从顶层的普通文件开始因为它们最容易被人忽略也最容易在关键时刻救场。2. 顶层文件清单HEAD、config、index 是三个必须认识的角色一个典型的.git目录顶层用ls -A看大概是这样HEAD、config、description、hooks/、info/、logs/、objects/、refs/可能还有index、packed-refs、ORIG_HEAD、FETCH_HEAD、COMMIT_EDITMSG、worktrees/、modules/、shallow等。这些不是平级的随机文件它们承担的角色差别很大。HEAD、config、index是日常最常被间接使用的三个。HEAD决定当前分支config决定这个仓库的本地行为index决定下一次提交会包含什么。有意思的是绝大多数 Git 教程只讲命令怎么调它们很少讲它们的物理形态——而当仓库出问题时能打开这些文件看一眼内容往往比翻文档快得多。这一节的重点就是把这三个文件的物理形态说清楚顺带把几个只在特定时机出现的临时文件也过一遍因为它们经常在报错信息里出现却很少有人知道它们是从哪冒出来的。2.1 HEAD一个只有一行的文件决定了你在哪儿cat .git/HEAD的结果通常就两种形态。第一种是ref: refs/heads/main说明你现在在main分支上Git 会顺藤摸瓜去读refs/heads/main这个文件从里面拿到最新的提交哈希。第二种是一个四十位的十六进制哈希直接写在HEAD里这就是所谓的分离头指针状态通常出现在git checkout commit或者git rebase的过程中。理解这一点之后很多现象就顺了。比如git branch时要切换分支本质上就是把.git/HEAD这一个文本文件改写成指向另一个 ref比如 rebase 过程中你处于分离头指针状态git status会提醒你HEAD detached at xxx因为此时HEAD里没有分支名只有一个裸哈希再比如新建仓库时git init会写一个ref: refs/heads/master或main但那个 ref 文件本身还不存在直到你第一次提交才被创建。所以刚 init 的空仓库git branch看不到任何分支不是坏了是还没生成。这里顺带解释一个常见困惑为什么git checkout一个历史提交之后再提交的东西不见了。因为你在分离头指针上产生的提交没有分支引用指向它它只会被 reflog 短暂记住一旦 reflog 过期或者你切走再切回来那个提交就成了悬空对象等着被 gc 回收。要保住它必须在切走之前git branch new-name hash给它一个引用。2.2 config本地配置的优先级与常见误改.git/config是这个仓库的本地配置文件INI 风格分节写。最典型的两节是[core]和[remote origin]。前列里能看到repositoryformatversion、filemode、bare、logallrefupdates后者能看到远程地址和 fetch 规则。配置的读取是有优先级的从高到低大致是命令行-c参数、.git/config本地仓库、用户主目录下的全局配置、系统级配置。这个优先级顺序解释了很多我明明配了却没用的情况。比如你在全局配了user.email在某个仓库的本地配置里又配了另一个那么在这个仓库里提交时生效的是本地那份。再比如某些命令会自动往.git/config里写东西你手工改过全局配置却发现在这个仓库不生效八成是被本地配置覆盖了。排错时有个很实用的命令是git config --list --show-origin它会把每一条生效配置连同来源文件一起打印出来优先级关系一目了然不用再去猜哪份配置赢。下面这张表是我自己整理的高频配置项和它们的实际影响日常排查时对着看很快。配置项所在层级实际影响user.name/user.email本地或全局提交记录里的作者与提交者信息core.autocrlf本地或全局换行符转换策略跨平台协作易出错core.filemode本地是否跟踪文件可执行位变化remote.origin.url本地推送与拉取的默认目标地址core.repositoryformatversion本地仓库格式版本手工改成高版本可能打不开core.bare本地是否是无工作区的裸仓库注意core.repositoryformatversion和core.bare是那种改错了整个仓库直接不可用的配置。手工编辑.git/config时请确保你清楚每一行的作用。2.3 index暂存区真身与 DIRC 二进制头git add到底干了什么答案是把文件内容做成 blob 对象写进.git/objects然后把这个 blob 的哈希、路径、权限、时间戳等信息写进.git/index。所谓暂存区物理上就是这个 index 文件。这个文件是二进制的开头四个字节是DIRC可以理解为它自己的魔数标识接着是版本号和条目数量然后是一连串的条目每个条目记录路径、权限、blob 哈希、stat 信息。最后可能还有一段扩展区。正因为每个条目里保存了文件的修改时间、大小、inode 等 stat 信息Git 才能在不读文件内容的前提下快速判断这个文件有没有被改过——这就是为什么git status通常很快它大部分时候只是在比对 stat 信息只有 stat 对不上时才去算内容哈希。理解这一点之后几个经典现象就都有解释了。比如git status偶尔会多报某些文件被修改明明内容没变这是 stat 信息变化导致的git add一下就恢复正常比如手动删除.git/index之后Git 会认为所有文件都是新的、未跟踪的因为暂存区信息没了比如.git/index.lock文件残留会让 Git 报 Another git process seems to be running因为并发保护用的锁文件没被正常释放删掉这个 lock 文件即可前提是确认确实没有正在运行的 Git 进程。顺便说一个很少有人注意的细节git add -p这种交互式暂存本质上是在往 index 里写入部分内容的 blob。所以你在git add -p之后直接看 index 的哈希会发现它对应的 blob 并不是工作区里那个完整文件的哈希而是你选中的那些行拼起来的新对象。这也解释了为什么暂存了一部分又改了剩下的部分之后同一文件会同时存在已暂存和未暂存两个版本。2.4 那些只在特定时刻出现的文件ORIG_HEAD、MERGE_HEAD、COMMIT_EDITMSG这几个文件平时看不到但它们在特定操作后会冒出来而且经常是救命的关键。ORIG_HEAD记录的是上一次操作之前的 HEAD 位置。执行git reset、git merge、git rebase之后原来的 HEAD 会被写进ORIG_HEAD。所以当你git reset --hard之后发现完了回不去了第一反应可以是git reset --hard ORIG_HEAD前提是中间没有再执行别的会覆盖ORIG_HEAD的操作。注意它只记一层不是无限历史多层回退还得靠 reflog。MERGE_HEAD出现在冲突合并的过程中记录的是被合并进来的那个提交。合并完成后它会被清掉合并处于冲突状态时它会一直存在。如果你执行git merge --abortGit 就是靠这些状态文件来还原现场的。同类的还有CHERRY_PICK_HEAD、REVERT_HEAD分别对应 cherry-pick 和 revert 的中断状态。COMMIT_EDITMSG是上一次提交时你写的提交信息原文。它有点像一个输入缓存git commit --amend时会默认打开这个文件让你编辑。它也是很多编辑器插件读取最近一条提交信息的来源。FETCH_HEAD记录最近一次git fetch拉下来的分支指向哪些提交git pull在内部其实就是 fetch 加一次 merge 或 rebase。description这个文件基本只在gitweb之类的场景用日常可以忽略。这些文件加起来说明了 Git 的一个设计取向状态不用内存里的全局变量维护而是落到磁盘上的小文件里这样任何一条命令崩溃了下一个进程还能从磁盘上读到准确状态。3. objectsGit 的内容寻址存储到底怎么存的如果只能从.git里挑一个目录讲清楚我会挑objects。因为 Git 的所有核心能力——版本历史、分支、标签、diff、blame——全部建立在这个目录的存储模型上。它万变不离其宗的规则只有一条对象的名字由内容算出来内容变了名字就变名字不变就意味着内容一模一样。这个规则听起来简单但它带来的推论很多。两个内容完全相同的文件不管叫什么名字、在哪个目录在 Git 里只会存一份一次提交里如果只改了文件的一小部分未变的部分继续复用原来的对象两个分支如果有相同的历史提交它们指向的是同一批对象不产生额外存储。理解了这一点你就不会再觉得Git 分支很轻量是一句口号而是能亲眼在objects里看到证据。3.1 blob / tree / commit / tag 四种对象的分工Git 里只有四种对象类型各司其职。blob存文件内容不存路径也不存文件名。一个文件的内容对应一个 blob这就是为什么重命名文件不会产生新的 blob——内容没变对象复用。tree相当于一个目录里面是一串条目每条包含模式普通文件、可执行文件、子目录、符号链接、名字和一个哈希这个哈希指向 blob 或者另一个 tree。commit是一个快照的说明包含它指向的那棵根 tree 的哈希、父提交的哈希列表、作者信息、提交信息。tag是附注标签包含它指向的对象哈希、标签名、打标签的人和信息。这四种对象构成了一个自底向上的引用链commit指向treetree指向blob或子tree。你git log看到的历史本质上是沿着 commit 的父指针往回走你git diff看到的差异本质上是比较两棵 tree 的差异你git checkout一个分支本质上是拿到那个 commit 指向的 tree然后把树里的 blob 一段段写到工作区。严格来说 Git 的数据结构是有向无环图而不是一棵树因为一个 commit 可以有多个父提交——merge commit 就是这样。但正因为它是无环的任意一个对象被任意一个 commit 或 tag 或 reflog 引用着它就活着一旦所有引用路径都断了它就变成悬空对象等着被回收。3.2 对象名是怎么算出来的SHA-1 输入格式与路径分片对象的名字是一个二十字节的哈希通常写成四十位十六进制。计算方式是把一个固定格式的头部和内容拼起来再做哈希。头部格式是类型 内容长度\0。举个具体的例子。假设某文件内容是hello\n长度是 6 字节那就拼出blob 6\0hello\n这一段字节串对它做 SHA-1得到的结果就是这个 blob 的对象名。你可以用printf blob 6\0hello\n | sha1sum在命令行里验证Git 算出来的名字会和它一致。这个头部加内容的设计不是随意的把类型和长度都纳入哈希计算能防止不同类型的对象因为内容相同而产生哈希碰撞也能在流式读取时提前知道该读多少字节。算出来的哈希不是直接当一个文件放进去而是做路径分片前两位十六进制作为目录名剩下三十八位作为文件名。所以一个对象名e69de29bb2d1d6434b8b29ae775ad8c2e48c5391会落在.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391。这种两位散列目录的做法在很多内容寻址系统里都能见到目的一是避免单个目录下堆积成千上万个文件导致文件系统查找变慢二是让哈希相近的对象自然分散到不同目录。3.3 用 cat-file 把一个 commit 拆到字节级真正想弄明白这个模型光看文档没用得自己拆一个。下面这套命令我经常在给同事做讲解时用从一次提交一层层拆到文件内容。# 1. 看最新提交的对象名 git rev-parse HEAD # 2. 看这个对象的类型和大小 git cat-file -t HEAD git cat-file -s HEAD # 3. 打印它的原始内容注意这会显示它指向的 tree 和父提交 git cat-file -p HEAD # 4. 拿出根 tree 的哈希继续往下拆 git cat-file -p tree-hash # 5. 找到某个文件的 blob 哈希把内容打出来 git cat-file -p blob-hash第三条命令的输出会长这样tree后面跟一个哈希parent后面跟一个哈希然后是作者、提交者和提交信息。看到这个输出你就应该明白为什么说提交不可变——cat-file -p打印的这些字节被原样拿去算哈希只要改动其中任何一个字符包括提交信息末尾多一个空格哈希就会变它就是另一个对象了。第四条命令输出的是目录条目每行开头是六位的模式中间是对象名最后是名字。模式不只是文件还是目录这么简单100644是普通文件100755是可执行文件120000是符号链接40000显示成040000是子目录。这也是 Git 追踪可执行位的物理依据与core.filemode配置配合使用。我建议你至少手动做一次这个实验新建一个空目录并 initgit hash-object -w写入一个自己也用sha1sum算一遍的字符串然后到.git/objects里找到那个分片路径用xxd看一眼文件的原始字节。你会看到文件开头是 zlib 压缩的魔数因为所有对象在磁盘上都是压缩存储的cat-file帮你做着解压这件事。3.4 松散对象、packfile 与 idx仓库先胖后瘦的原因刚做几次提交的小仓库.git/objects下是一堆两位十六进制命名的子目录每个里面放着几个压缩过的对象文件这叫松散对象。好处是写入快、单文件损坏不影响别人坏处是数量一多磁盘占用因为每个文件有块大小和元数据开销而虚高查找也变慢。达到一定阈值后Git 会触发打包。git gc或者自动触发的gc --auto会把大量松散对象合并成.git/objects/pack/下的一个.pack文件和配套的.idx文件。.pack里是压缩后的对象数据的集合而且采用了增量存储——相似的对象只保存差异部分所以打包后体积通常会明显小于松散对象之和。.idx是索引用来在不解压整个 pack 的前提下按对象名快速定位到它在 pack 里的偏移量。这就是为什么先胖后瘦写的时候松散对象多、占空间大打包之后相似内容被压缩体积回落。有个经常被拿来验证结构的命令git cat-file --batch-check --batch-all-objects它会输出仓库里所有对象的哈希、类型和大小配合wc -l就能知道对象总数配合awk求和就能知道解压后的总大小。拿这个数字和.git实际占用来对比你就能判断空间是被对象撑起来的还是被 pack 文件撑起来的还是被别的东西撑起来的。还有git count-objects -vH它会直接给出松散对象数量、体积、pack 数量、pack 体积、可回收体积排查磁盘问题时这两条命令基本够用。提示objects/info/alternates这个文件如果存在说明当前仓库和别的仓库共享对象库对象可以不在自己的目录里而在别人那里。这在多分支的大项目、CI 缓存复用场景里很常见排查对象明明有却说找不到时值得看一眼。3.5 一次对象损坏的真实排查链路我遇到过最典型的一次是硬盘异常之后仓库报fatal: loose object ... is corrupted报错里带一个路径指到某个松散对象文件。这个报错信息其实非常友好它直接告诉你哪个文件坏了。排查顺序我是这么走的。先用git cat-file -t hash确认这个对象是否还能读出来读不出来说明文件确实坏了。接着git fsck --full跑一次完整体检它会列出所有悬空对象、缺失对象和损坏对象。如果损坏对象只在一个未被任何提交引用的位置上那基本可以不管用git prune清掉就行。如果损坏对象被某个提交引用着那就要看这个提交是否只在本地存在——如果远程还有一份完好副本最省事的做法是从远程重新拉取这个引用再把本地工作区里尚未提交的改动转移过去。这里有个经验值得记下来git fsck的报错类型要分开对待。dangling开头的是悬空对象属于正常现象reflog 过期前它们都会存在missing开头的是缺失对象说明有引用指向了一个不存在的对象这个是真问题corrupt开头的是内容损坏。把输出按这三类分开看能省掉很多无谓的紧张。另外任何情况下都别急着git gc --prunenow因为一旦剪掉悬空对象reflog 里那些其实还能救回来的提交就真的没了。4. refs 与分支分支真的只是一个文件新手最容易觉得神奇的一件事是Git 建分支好快。这个快的物理原因特别朴素.git/refs/heads/下面就是一堆普通文本文件每个文件的内容是四十位哈希加一个换行文件名就是分支名。新建分支无非就是写一个几十字节的小文件。理解了这一点很多操作就变成了文件读写层面的操作排查起来会更直接。比如为什么分支名不能有空格和某些特殊字符因为它是文件名为什么有些分支在文件系统里看不到因为它被合并进了packed-refs为什么改分支指向可以用最原始的方式直接写文件。这一节就围绕这几个问题展开顺带说清楚refs的目录结构和一次手工救分支的完整过程。4.1 heads、tags、remotes 三棵子树的实际内容refs下面典型有三个子目录heads、tags、remotes。heads存本地分支文件内容是该分支最新提交的哈希tags存标签轻量标签的内容直接是提交哈希附注标签的内容是那个 tag 对象的哈希remotes存远程跟踪分支通常是origin/main这类名字它们只是上次 fetch 时远程分支指向哪儿的本地记录不是实时状态。这里要说清一个特别容易混淆的点本地分支和远程跟踪分支是两回事。git fetch会更新.git/refs/remotes/origin/*但不会动你的本地分支git pull在 fetch 之后多做一步合并或变基才会间接影响本地分支。所以我 fetch 了但代码没变是正常行为而不是 bug。还有一个细节是refs/namespaces和refs/notes前者主要用在服务端多仓库共享对象的场景后者用来存git notes写下的注解。日常项目里很少直接看到但知道它们可能存在于refs下面能避免看到陌生目录时误删。4.2 packed-refs 从哪来为什么它会让分支文件消失随着引用数量变多一个个小文件会带来性能问题查找某个分支要读文件遍历所有分支要遍历目录。所以 Git 引入了packed-refs把一批引用合并到一个文件里格式是每行哈希 空格 引用全名顶部可能有一行注释另外^开头的行表示附注标签指向的提交。这样读一次文件就能拿到大量引用信息。于是就出现了那个让人慌的场景ls .git/refs/heads是空的但git branch明明列出了一堆分支。这不是分支丢了而是它们被打包进了packed-refs。你cat .git/packed-refs就能看到全部内容。反过来如果你新建一个分支Git 通常会在refs/heads/下写一个普通文件而不是立刻改packed-refs。当同名引用同时存在于文件和packed-refs中时文件形式优先级更高——不过这种状态本身是个待清理的中间态git pack-refs --all会把它整理干净。4.3 手工改 refs 救回误删分支含风险提示最经典的场景你刚做了一次git branch -D feature-x删的时候很爽删完发现那个分支上的提交还没合并到主分支。这时候的救命路径一般是先git reflog或者git log -g找到那个分支删除前指向的提交哈希然后git branch feature-x hash重建分支。这个路子干净、安全优先用它。只有当 reflog 也过期了、但你明确知道哈希的情况下才会考虑直接写 refs 文件# 前提确认哈希存在且 git cat-file -t hash 能返回 commit echo 40位哈希 .git/refs/heads/feature-x git rev-parse --verify feature-x第二条命令用来验证结果如果报错说明引用指向的对象有问题。这个操作的坑在于写文件不会顺带更新packed-refs如果同名分支恰好也在packed-refs里你就得到一个文件与打包引用并存的状态多数情况下 Git 会以文件为准但不建议长期这样放着。正确收尾方式是git pack-refs --all --prune整理一遍。注意直接修改.git/refs下的文件属于底层操作绕过了一切校验。写完一定要用git rev-parse --verify和git fsck各检查一次确认仓库仍处于健康状态。5. 藏在角落的目录logs、hooks、info、worktrees、modules顶层文件看完了还有一批目录平时基本没人碰但它们的价值在出问题时才体现出来。logs是误操作的时间机器hooks是本地自动化的落点info管着那层容易被忽略的忽略规则worktrees和modules则对应着两个进阶使用场景。这一节的主要目的是让你知道遇到某类问题该往哪儿翻而不是把这些目录用法讲全。5.1 logs/HEAD 与 reflog误操作后的时间机器.git/logs/HEAD是一个纯文本文件每行记录一次 HEAD 的移动。一行大致长这样旧的哈希、新的哈希、操作人、时间戳和时区、一个制表符、然后是这次操作的自然语言描述。git reflog读的就是这些内容的反向展开。为什么说它是时间机器因为 reflog 记录的是HEAD 实际走过的路包括那些没有分支引用指向、濒临被回收的提交。git reset --hard退错了、rebase 做了半天发现不对、git commit --amend改完发现原提交丢了这些场景的第一反应都应该是git reflog找到目标哈希再决定是 reset 回去还是建个新分支。我在 5.4 节会提到的悬空对象能不能救很大程度上取决于 reflog 还在不在。这里有个默认策略值得记住reflog 条目是有过期时间的默认对可达条目约九十天、不可达条目约三十天后清理具体行为还会受gc影响。所以用 reflog 救回三天前的误操作很稳想救半年前的就得看运气。真要长期保底靠的是远程仓库或者额外备份而不是本地 reflog。5.2 hooks本地自动化检查的落点与团队落地方式.git/hooks目录里默认有一堆以.sample结尾的示例脚本它们是给参考用的不加执行权限就不会生效。去掉后缀并赋予可执行权限它们就成了真正会执行的钩子。常用的几个位置pre-commit在提交前跑适合做代码格式检查和静态扫描commit-msg检查提交信息是否符合规范pre-push在推送前跑适合做完整的单测post-merge在合并后跑适合做依赖同步。钩子最大的特点是本地性.git/hooks不参与版本控制clone 下来的人不会有你的钩子。所以团队要在钩子上做统一约束通常不会直接改.git/hooks而是把脚本放进项目里的某个目录并纳入版本控制然后由工具在初始化阶段把它们链接到.git/hooks或者配一个统一的安装步骤写进文档。这条路径的关键点在于钩子可以被本地绕过所以它适合做早点发现问题不适合做防止违规。真正需要强制的规则还是要放在服务端侧去校验。另外钩子脚本会直接影响你的提交体验写得慢会让人觉得整个 Git 卡住。我见过把完整构建流程塞进pre-commit的团队结果每次提交要等两分钟最后大家都开始用--no-verify钩子形同虚设。合理的做法是pre-commit只跑针对改动文件的快速检查重活留给pre-push或者 CI。5.3 info/exclude 与 .gitignore 的三层忽略优先级忽略规则实际有三层优先级从高到低是命令行参数指定的排除文件、仓库内被版本控制的.gitignore文件、以及.git/info/exclude。最后一层是纯本地文件不参与版本控制特别适合放那些只对我这个环境有意义的忽略项比如我本地用某个编辑器生成的临时目录、某种调试脚本的输出文件。很多人习惯把所有个人忽略项都写进.gitignore然后提交这其实会污染团队仓库。判断标准很简单这条规则对团队里所有人是否都成立成立就进.gitignore不成立就进.git/info/exclude。还有一层是用户级的全局排除文件通过core.excludesFile配置适合跨仓库都生效的个人规则比如系统自动生成的.DS_Store或者某个 IDE 的工程文件。顺带提一个常见误区.gitignore只对未被跟踪的文件生效。一个文件如果已经被提交过后来才加进忽略规则它依然会被继续跟踪git status里也会照常出现。要让忽略生效得先把它从索引里移除git rm --cached file注意带上--cached否则工作区里的文件也会被删掉。5.4 modules、worktrees、shallow、alternates 这几个进阶目录modules目录出现在用了子模块的仓库里每个子模块在.git/modules/path下有一份独立的 Git 目录。这么设计是为了让子模块的 Git 数据集中管理避免在每个子模块目录里再塞一个完整的.git也让主仓库的 Git 命令能统一操作它们。子模块出问题时先看.git/modules下的目录是否完整是个不错的切入点。worktrees目录对应git worktree功能它允许同一个仓库同时检出多个工作区比如主目录做开发另一个目录专门跑测试或者对照某个发布版本。每个额外工作区的工作区目录里会有一个.git文件注意是文件不是目录内容形如gitdir: /path/to/main/.git/worktrees/xxx真正的 Git 数据放在.git/worktrees/xxx下面。这个文件机制是工作区怎么找到自己所属仓库的关键明白了以后就不会再奇怪为什么子目录里的.git是个文件。shallow文件出现在浅克隆的仓库里记录这次克隆截断在哪个提交git fetch --unshallow会依赖它把历史补全。objects/info/alternates前面提过用来共享对象库多个仓库共用一个大的对象池能显著节省磁盘和时间代价是任何一个仓库删对象时都要小心别把别人需要的东西清掉。6. 拿结构知识排错四类高频故障的定位路径前面把结构讲完了这一节换个角度直接按报错信息来组织。因为在实际工作里我们通常不是先想到结构再去查问题而是先撞上一个红色报错然后才需要判断它对应结构里的哪一块。下面这几类是我这些年处理的最多的。6.1 fatal: not a git repository 的四种真实成因这条报错可以说是出现频率最高的一个但它的成因至少有四种处理方式完全不同。第一种当前目录真的不是仓库或者不在仓库的子目录里。这时候pwd看一眼自己在哪儿回到项目根目录就好。第二种仓库根确实在但.git缺失了——可能是拷贝时被隐藏文件过滤跳过可能是部署脚本复制时排除了点开头的目录也可能是压缩包解压不完整。判断方法是ls -A看有没有.git。第三种GIT_DIR环境变量指向了一个不存在的路径或者.git路径被某个工具改写成了别的地方。这种比较隐蔽可以env | grep GIT检查一下相关变量。第四种.git存在但内容损坏比如HEAD文件被清空或者objects整个丢了此时即使目录在Git 也无法把它当成有效仓库。一个很好用的排查小工具是git rev-parse --git-dir和git rev-parse --show-toplevel的组合。前者告诉你 Git 认为的 Git 目录在哪后者告诉你它认为的工作区根在哪儿。两条命令的输出和你的预期不一致时问题范围立刻缩小了。6.2 仓库被复制、被压缩、被网盘同步之后这类问题有个共同特征命令能跑但状态诡异。常见表现包括git status显示一大堆莫名其妙的改动、分支列表少了一半、.git/index.lock反复出现、某个 pack 文件被标成损坏。复制引发的典型问题是文件权限和可执行位的变化尤其是在跨文件系统复制时会导致大量文件被判定为模式变更。这种情况可以用git config core.filemode false临时规避但要注意这只影响判断不改变历史里记录的模式。压缩引发的典型问题是符号链接和大小写敏感差异某些压缩工具会把符号链接存成普通文件取出来内容就是一个路径字符串于是这些链接在 Git 看来全都变了。网盘同步的问题最麻烦因为同步工具会把冲突副本命名成xxx (1).conflict之类的文件塞进.git里这些文件 Git 不认识运气不好还会干扰正常的对象查找。处理办法是先暂停同步把冲突副本移出去再用git fsck体检。6.3 体积异常与 git gc 的取舍前面提过仓库变胖的两大来源历史里的大文件以及长期积累的松散对象。处理思路是先诊断再动手。诊断用git count-objects -vH看松散对象和打包对象的占比用git verify-pack排序找出 pack 里最大的对象用git rev-list --objects --all配合路径过滤把大对象对应到具体文件名。只有把对象映射回文件名你才知道这个体积是不是必要的。如果是松散对象太多git gc通常就够了而且开销不大一般不会造成困扰。如果确认是历史里的大文件那就要考虑重写历史。重写历史这件事的代价必须提前讲清楚所有相关提交的哈希都会变团队里每个人的本地分支都需要重新对齐任何已经基于旧提交打的标签、发的版本号都需要重新处理。所以这件事只在体积问题确实影响使用时才做而且一定要提前通知协作方选一个大家都方便重新同步的时间点。提示绝对不要为了省空间随手跑git gc --prunenow --aggressive。前者会立刻剪掉所有悬空对象直接断掉 reflog 的救命能力后者在对象很多时可能跑很久且收益未必明显。默认的git gc大多数时候够用。7. 几个我反复踩到的坑和日常维护习惯最后分享一点零散的体会都是从实际翻车里攒出来的。第一个是把.git目录暴露到公网的坑。理解了.git/objects的结构之后你会立刻明白为什么这件事严重只要有HEAD、refs和objects任何人都能用git命令把整个历史和全部源码还原出来。而且这不需要什么高深工具普通的 Git 客户端就能做到。所以部署时一定确认 Web 根目录或静态资源目录里没有.git构建产物目录应该是干净的不应该把源码目录整个搬上去。这个东西在我见过的每一次站点事故里都排在前几位。第二个是.git不要放在同步盘里。多设备同时同步.git/objects和.git/index是自找麻烦而且出问题的表现往往很诡异让人根本联想不到是同步导致的。跨设备协作用远程仓库这是它本来该做的事。第三个是别在.git里做顺手清理。有人看到objects目录里一堆两位十六进制命名的子目录觉得是垃圾就删了结果仓库直接不可用。也有人在.git里看见.conflict、.tmp这类奇怪文件直接删了没备份删掉的可能恰好是正在写入的 pack 索引。原则很简单.git里的东西要么用 Git 命令操作要么先整体备份再动手。第四个是定期跑一次git fsck。这不需要每天做但每过一段时间在你本地仓库跑一次能提前发现对象缺失、引用断裂这类问题。真等到要用的时候才发现仓库半损坏那就被动了。同时git count-objects -vH也值得偶尔看一下体积的增长趋势能帮你在它变成问题之前就注意到。第五个也是最实用的一条给项目根目录的.git加个备注。我在很多项目里会留一个简短的 README 说明这个仓库的克隆方式、主分支名、以及是否存在子模块或者特殊配置因为.git里那些本地配置、钩子、生成物目录都是跟着环境走的。团队成员看到这些说明会少走不少弯路。理解.git结构的最终意义大概就是把Git 是一个神秘命令集合这个认知换成Git 是一堆有明确职责的小文件——出问题时你能直接找到对应那个文件而不是靠猜。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价