news 2026/9/16 22:08:41

Git 空目录不显示?用 .gitkeep 保留目录结构,避免 clone 后目录丢失

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Git 空目录不显示?用 .gitkeep 保留目录结构,避免 clone 后目录丢失

先把结论放在前面:.gitkeep 并不是 Git 官方提供的一个特殊文件类型,它只是一个约定俗成的占位文件,核心职责是让一个空目录能够在 Git 仓库里被真正地保留下来。很多刚开始用 Git 的人都会撞见同一个诡异现象:本地明明建好了 uploads 目录,代码里也写了往这个目录写日志、存图片的逻辑,推完代码让同事一 clone,目录直接没了,程序跑起来立刻报错。我第一次遇到时还以为是网络问题,反复 clone 了好几遍,结果都一样。后来才明白,问题根本不在传输,而在 Git 的底层模型,它压根不追踪目录本身。

这篇文章我会从 Git 的对象模型讲起,把 .gitkeep 为什么会出现、它到底做了什么、在真实项目里怎么用才不踩坑讲清楚。无论你是刚接触 Git 的新手,还是已经在团队里带项目的同学,这篇都能给你一些可以直接拿来用的经验。

1. 克隆下来少了好几个目录:.gitkeep 要解决的问题

1.1 那次目录凭空消失的排查经历

先说我的真实经历。当时项目是一个 Python 写的后端服务,代码结构大概是这样的:

project/ ├── app/ │ ├── main.py │ └── utils.py ├── uploads/ # 用户上传文件的目录 ├── logs/ # 运行日志目录 └── README.md

本地开发一切正常,因为 uploads 和 logs 是我刚搭项目时手动建好的。等我把代码推到远端仓库,另一个刚加入的新同事 clone 下来,服务启动之后一旦有用户上传文件,代码就会因为uploads目录不存在直接抛异常。当时我第一反应是检查 .gitignore,怀疑是不是有人不小心把这两个目录加进了忽略规则。

检查结果是没有,git status 也显示工作区很干净。最后我用了 Git 自带的文件列表命令才找到真相:

git ls-files | grep uploads

输出为空。也就是说,这个目录从始至终就没有进入过版本库。本地之所以有,是因为我手工建过;同事那里之所以没有,是因为 Git 在 clone 时根本不知道存在这个目录。这个排查过程花了我将近半小时,从那以后我对"目录是否真的在仓库里"这件事就特别敏感。

1.2 Git 的"跟踪"粒度:只认文件,不认目录

要理解这个问题,需要稍微深入一点 Git 的数据模型。Git 里有三类核心对象:

  • blob 对象:存的是文件内容。不管文件名是什么,内容相同就对应同一个 blob。
  • tree 对象:存的是目录结构。一个 tree 对应一个目录,里面记录了子文件名、子目录名,以及它们各自指向的 blob 或 tree 对象。
  • commit 对象:存的是某次提交对应的根 tree,加上作者、提交信息、父提交等元数据。

当你执行git add时,Git 会把文件内容做成 blob,并把路径信息写进一个叫"索引"(index)的区域。索引是一个扁平的清单,里面每一项都是一个"文件路径 -> 对象 ID"的映射。如果你的工作目录里有一个空文件夹uploads,这个文件夹下没有任何文件,那么git add uploads时,Git 遍历这个目录会发现里面没有任何需要处理的文件,自然也就不会在索引里生成任何条目。

等到你git commit时,索引里有什么,commit 里的 tree 对象就记录什么。空目录在索引里一个条目都没有,那它在 tree 对象里也就不存在。最终效果是:这个目录从未被提交,也就从未进入版本历史。

1.3 tree 与 blob:为什么空目录在 Git 里等于不存在

我再用一个生活化的类比解释一下这事。你可以把 Git 仓库想象成一套建筑图纸,blob 是图纸上标注的每一件物品,tree 是房间的隔断布局。commit 就是某一版图纸的完整快照。空目录就像一间没有任何家具的空房间,你在图纸上根本找不到这间房的存在,那么当你把这个图纸交给另一个施工队去复建房时,施工队自然不会把这间空房建出来。

tree 对象在真实存储里是一串这样的条目:

040000 tree 3b18e5c73a4a2f8f2b1c2b5e6a0e1f2a3b4c5d6e uploads 100644 blob 6a5f3c2d8b9a1e4f7a0b2c3d4e5f6a7b8c9d0e1f README.md

第一列是文件模式,第二列是对象类型,第三列是对象 ID,第四列是名称。只有当子目录uploads里有内容时,它的上级 tree 里才会出现这一行。一个空目录没有任何内容,它的上级 tree 里就不会有这一行,于是 Git 在还原工作区时,根本不会去创建这个目录。

这个机制解释了为什么.gitkeep的本质不是一个"特殊文件",而是一个普通文件作为目录的"存在凭据"。

2. .gitkeep 没有魔法:它只是一个普通占位文件

2.1 .gitkeep 与 .keep:一个被行业习惯"焊死"的名字

既然 Git 只认文件,那么解决问题的方法就顺理成章了:在空目录里放一个文件,让这个目录在索引里"有东西可记录"。这个文件叫什么其实无所谓,只要你愿意,叫placeholder.txtkeep_me.txt都行,Git 一视同仁。

但社区最终选择了.gitkeep这个名字,原因很朴素:

  • 点号开头在 Unix/Linux 下是隐藏文件,不会让目录看起来乱糟糟的。
  • .gitkeep这个名字直白地表达了意图,看到的人能立刻明白:这是为了让 Git 保留这个目录,而不是业务文件。
  • 它不会被绝大多数程序误读,不会影响构建、部署和运行。

还有一个姊妹命名.keep也常见,尤其在 Ruby 社区和一些前端脚手架里。两者的原理完全相同,只是.gitkeep更明确地把"这是给 Git 看的东西"写在了名字里。

这个文件没有任何内置行为,Git 内核根本不认识.gitkeep这个名字,它不会给这个文件什么特殊权限,也不会因为这个文件名就自动保目录。真正起作用的是"目录里有一个文件"这一客观事实。

2.2 一个占位文件的完整生命周期

从一个空目录到一个被版本控制保留的目录,一共只需要三步。

先在项目里建一个空目录:

mkdir -p uploads

此时直接执行git status,你会看到工作区完全干净,Git 对uploads的存在毫无感知。这就是很多人坚持认为"我明明建了目录,为什么 clone 后没有"的原因。

然后在目录里创建占位文件:

touch uploads/.gitkeep git add uploads/.gitkeep git status

这次输出里会出现new file: uploads/.gitkeep。接着提交:

git commit -m "chore: add .gitkeep placeholder for uploads directory"

提交之后,你可以立刻验证一下:

git ls-files | grep uploads

你会看到uploads/.gitkeep已经在仓库清单里。这时候git clone到别的机器,工作区里一定会创建uploads目录,因为 Git 在 checkout 时看到 tree 对象里存在uploads这一项,就会创建这个目录,再把里面的.gitkeep文件还原出来。

2.3 空文件和带内容的占位文件,Git 都一视同仁

有人习惯用touch创建空文件,也有人会在里面写一行注释:

# 该目录用于存放用户上传的临时文件,请勿删除。

这两种方式在 Git 眼里没有任何区别,它只关心"路径是否存在",不关心文件内容是什么。如果是从可维护性角度考虑,我通常会这样选择:

  • 如果目录用途单一且明确,用空文件即可,diff 噪音最小。
  • 如果这个目录的用途容易让人困惑,用带说明的.gitkeep更好,相当于一个只有一行字的目录说明书。
  • 如果团队对目录结构有强制规范,甚至可以在里面放一个README.md来写清楚目录应该放什么、不该放什么。

需要特别提醒的是,.gitkeep不需要在.gitattributes里配置,也不需要赋予可执行权限。Git 平常保存文件权限,普通文件默认是100644,这就够了。

3. 项目里最值得放 .gitkeep 的地方与替代方案

3.1 真正值得用 .gitkeep 的目录,通常长这样

不是所有目录都需要.gitkeep。判断标准很简单:这个目录是否必须"在 clone 之后立刻存在",以及"在正常运行前是否有程序会自动创建它"。满足第一点且不满足第二点的目录,就应该放.gitkeep

我在实际项目里最常用的场景是这些:

目录为什么需要占位
logs/日志框架通常在启动时直接向该目录写文件,不会先自动建目录
uploads/用户上传文件前需要先写入,目录缺失会直接报错
tmp/cache/运行时需要临时缓存,但启动阶段不一定会自动建
storage/五言框架中常见的统一存储目录,存放各类业务附件
coverage/report/测试报告生成目录,CI 和本地都希望目录结构一致
Dockerfile 里的挂载点镜像中如果指定了VOLUME /data,但DATA目录不存在,某些镜像构建方式会有问题

一个容易被忽略的场景是前端项目和脚手架。比如构建工具会把产物输出到dist,但源码仓库里希望保留一个空dist占位,这样 IDE 的文件树里能看到一个预期的输出目录,CI 第一次构建前目录结构也更完整。这种情况下放.gitkeep同样管用。

3.2 三种替代方案:README、.gitignore 自引用、.htaccess

有人会问:既然任何文件都能占位,那是不是用别的文件更好?我用一张表把这几种常见做法说清楚:

方案优点缺点适用场景
.gitkeep空文件零成本、无运行影响、意图明确没有任何说明文字,新手可能不认识大多数通用目录
README.md可以写详细说明,其他人看到就能理解文件有内容,会出现在 README 索引里,稍显多余目录结构复杂、需要文档说明的场景
.gitignore自引用文件本身既做占位,同时还能屏蔽目录里的其他内容规则绕,后续 add 文件容易踩坑需要整个目录都忽略、且目录必须保留的场景
.htaccess可以在 Web 目录里顺便配访问规则只对 Apache 生效,不通用仅限 Web 服务目录

其中.gitignore自引用的方式比较冷门,我实际见过有人这么用。实现方法是在目录里放一个.gitignore,内容写:

* !.gitignore

第一行表示忽略这个目录里的所有文件,第二行表示.gitignore自己除外。这样这个目录里只有.gitignore会被跟踪,其他内容都会被忽略。但这里有个很隐蔽的陷阱:如果你之后想往这个目录里加入一个业务文件,比如uploads/avatar.png,你会发现git add uploads/avatar.png会被忽略策略挡掉,必须git add -f强制添加,否则就得先改忽略规则。这种心智负担比.gitkeep大得多,所以我通常只有在"目录里只允许存在占位文件,不允许放任何业务文件"这种强约束场景下才会推荐。

3.3 放错位置或漏放层级时会出现什么问题

.gitkeep只能保住它所在的直接目录,没有"向上生效"或"向下递归"的能力。最常见的一个误区是:

  • uploads/里放了.gitkeep,但uploads/2024/这个子目录是空的,那子目录在 clone 后照样不存在。
  • 在根目录放了一个.gitkeep,以为所有空目录都能保留,结果一个都没有保住。

所以检查时必须逐层确认。如果项目里有一棵比较深的目录树,建议用命令批量检查一下有哪些空目录还没有占位文件:

find . -type d -empty -not -path './.git*'

这条命令会列出所有没有被版本控制追踪到的空目录,看到之后就可以逐一定位补.gitkeep

还有一个更隐蔽的问题是命名冲突。如果某个目录叫.git开头,比如.github.gitlab,那放通过普通touch创建的文件没有任何问题,这些目录本身通常已经有内容,不需要.gitkeep。但如果某个目录因为被某些工具库特殊识别而无法放普通文件,例如 Git 子模块目录,这时候不要尝试在子模块目录里放.gitkeep,应该在父仓库里用别的方式保证目录存在,或者换一种项目结构。这种情况比较少见,但遇到了会非常迷惑。

4. 团队协作中关于 .gitkeep 的实战经验与踩坑记录

4.1 .gitkeep 与 .gitignore 的"相爱相杀"

一个非常经典的坑是:你在.gitignore里写了针对整个目录的忽略规则,比如:

uploads/

然后往uploads目录里放了一个.gitkeep,满怀期待地执行git add uploads/.gitkeep,结果 Git 提示什么都没有。原因很简单:忽略规则直接覆盖了你的占位意图,Git 连.gitkeep这个文件一起忽略了。

正确做法是在忽略规则的目录通配后面做反向排除:

uploads/* !uploads/.gitkeep

第一行忽略uploads目录下的所有内容,第二行把.gitkeep放回来。这里有一个细节很容易写错:有人会写成:

uploads/ !uploads/

这种逆向排除是无效的,因为 Git 在匹配到uploads/这个规则后,会直接把整个目录标记为忽略,随后即使有!uploads/也无法让目录重新变回可跟踪状态。更重要的是,如果你只是写!uploads/,那它会重新包含整个目录,Git 需要重新递归遍历这个目录里的所有内容,很可能把不想加的文件也卷进来。

遇到这类问题时,可以用 Git 自带的排查命令看看到底是哪条规则在生效:

git check-ignore -v uploads/.gitkeep

它会输出命中该忽略规则的具体文件和规则行号,定位起来非常快。

另外有一个很容易被忽略的规则:已经跟踪的文件不受新增忽略规则影响。也就是说,如果.gitkeep在加入忽略规则之前就已经提交进仓库了,那么之后再在.gitignore里写uploads/,这个.gitkeep依然会被跟踪。这既是好事也是坏事。好事是仓库里目录结构不会因为误加忽略规则而消失;坏事是你会误以为"即使被忽略也没关系",结果新同事 clone 时发现目录在,但真正的业务文件全都拉不下来,排查半天才发现是忽略规则里的目录模式把内容都屏蔽了。

4.2 git clean 与"目录突然消失"的连带伤害

团队开发中另一个高频事故和git clean有关。有些提交规范要求提交前清理工作区,或者在 CI 里执行:

git clean -xdf

这条命令会删除所有未被跟踪的文件和目录,-d表示包含目录,-f表示强制,-x表示连被.gitignore忽略的文件也一并删除。如果某个目录只是本地手动建了一个占位文件,而这个占位文件没有提交到仓库,那么执行这条命令之后,整个目录都会消失。

反过来,如果占位文件是已提交的.gitkeep,那git clean -xdf就不会有事,因为git clean只清理未跟踪的文件,已经提交的.gitkeep是受版本控制的,不会被触碰。目录里如果有其他被忽略的临时文件,则会被清掉,目录本身因为.gitkeep还在而继续保留。这个效果其实很理想:CI 想要一个干净的环境,又希望目录结构完整。

所以我会在几乎所有的项目里约定一条规则:凡是需要长期存在的目录,占位文件必须提交;本地临时创建的目录一律不要依赖,也不要用"等会我 commit 一下"来拖延,因为一次不完整的提交就可能让整条 CI 链路挂掉。

4.3 批量补占位文件与提交规范

接手一个老项目时,最痛苦的事情之一就是给大量空目录补.gitkeep。如果目录数量多,手工逐个 touch 效率太低。我通常用下面这条命令一次搞定:

find . -type d -empty -not -path './.git*' -exec touch {}/.gitkeep \;

解释一下参数:

  • -type d:只找目录。
  • -empty:只找空目录。
  • -not -path './.git*':排除.git内部目录,防止在.git里创建无意义文件。
  • -exec touch {}/.gitkeep \;:在每个找到的空目录里执行 touch。

执行完之后,记得git status确认一下有哪些新文件出现。这一步非常重要,因为可能有部分目录因为命名或忽略规则而没有被创建成功。

提交信息我建议统一用chore前缀:

chore: add .gitkeep placeholders for runtime directories

这比直接提交一句 "add .gitkeep" 要清晰得多,因为在 git log 里翻历史时,对方能一眼看出这些文件是为了保留目录结构而加的,不是业务文件。

在 Windows 环境里还有一个操作细节。资源管理器里直接右键创建.gitkeep会提示必须输入文件名,处理办法有两种:一是在 Git Bash 里用touch命令,二是先创建一个gitkeep文件,然后右键重命名成.gitkeep。第二种方式在部分 Windows 版本上需要先开启"显示文件扩展名"和"显示隐藏文件"才能在资源管理器里看到。

4.4 容器和 CI 里的隐藏坑:目录权限

最后说一个很多人遇到但不知道和.gitkeep有关的坑:clone 下来之后目录确实存在了,但程序在写入文件时报Permission denied。这个锅不能甩给.gitkeep,因为它只负责让目录存在,不负责让目录的权限符合运行要求。

Git 保存文件的权限位,比如普通文件是100644,可执行文件是100755,但它不保存目录权限。目录的权限完全由 checkout 时的进程 umask 决定。在容器环境里,如果镜像构建阶段以 root 创建了某个挂载目录,而运行阶段切换到普通用户,那么这个目录可能没有写权限。

.gitkeep解决不了权限问题,但它可以帮你更快定位问题。因为如果目录不存在,日志里是No such file or directory;如果目录存在但没权限,日志里是Permission denied。两类错误在日志里一眼就能区分开,我很依赖这个信号。真正的解法通常是在程序入口做一次目录创建并设置权限,比如 Python 里:

import os os.makedirs("uploads", mode=0o755, exist_ok=True)

或者容器启动脚本里提前mkdir -pchown给运行用户。

关于.gitkeep我最后想说的一个体会是:它的核心价值不在于技术含量,而在于它让"目录结构"这件事从隐式依赖变成了显式约定。你只要在项目里看到带.gitkeep的目录,就能确定这个目录是刻意保留的;如果看到空目录,反而会让人怀疑是本地残留还是漏加了占位文件。现在我每新起一个项目,都会顺手把 logs、uploads、storage 这类运行期目录的.gitkeep.gitignore一起提交掉,再写进 README 的目录结构说明里。这个习惯帮我少踩了很多"目录凭空消失"的坑,也希望这篇能帮你少走一次弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 22:08:13

onboard 向导选模型连不上?TaoToken 这样改模型通道

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 22:07:31

DANN实战:PyTorch实现对抗迁移学习,解决域漂移

最近这两年做深度学习落地项目,我最怕听到的一句话就是“模型上线后效果不对”。明明训练集上准确率已经刷到97%,一换到新的采集设备、新的光照环境,或者换了标注渠道,准确率直接掉回70%上下。这种数据集分布不一致的问题&#xf…

作者头像 李华
网站建设 2026/9/16 22:06:42

ADPCM语音压缩原理与G.721工业级C实现

简介:本资源是一份面向通信工程、嵌入式音频开发及数字信号处理初学者的ADPCM语音压缩技术实践包,聚焦语音编码原理与标准实现。压缩包含12个文件,以7个C源码(如g711.c、g721.c、g723_24.c等)、2个说明类txt文件、1个R…

作者头像 李华
网站建设 2026/9/16 22:06:11

Sonoma下CocoaPods安装全攻略:彻底解决Ruby版本冲突与权限问题

升级到 Sonoma 以后一头撞在 CocoaPods 安装的墙上,这种事我今年见了太多。群里隔三差五就有人甩过来一段报错截图,紧跟一句“我明明什么都装了,为什么 pod 还是用不了”。说实话,Sonoma 下装 CocoaPods 之所以劝退这么多人&#…

作者头像 李华
网站建设 2026/9/16 22:04:47

射频开关与功率检波器协同设计实战指南

1. 这不是“调频旋钮”,而是射频信号的精密手术刀你手头有一块PCB,上面焊着两颗黑黢黢的表贴芯片:一颗标着MASWSS0115,另一颗印着R7KA8D2KFLCAC。它们既不发光也不发热,看起来像普通电阻电容,但一旦通电&am…

作者头像 李华