有人问过我,为什么在 VSCode 里新建了一个空文件夹,左侧源代码管理面板却完全看不到它。这个问题我一开始也困惑过几秒,因为从直觉来说,文件夹也是项目的一部分,Git 为什么不把它列出来。后来把 Git 存储模型捋了一遍才明白,Git 的目标不是“记录目录”,而是“记录文件快照”。想让空文件夹进入版本库,不能直接拖动文件夹,而是要在文件夹里放一个占位文件,最常见的就是.gitkeep。这篇文章就围绕 VSCode 里 Git 提交空文件夹这件事展开,先解释为什么空文件夹进不了 Git,再给三种可落地的处理方案,以及提交之后怎么验证、跨平台怎么避坑。
1. 空文件夹为什么进了 VSCode 却进不了 Git:先理解底层逻辑
1.1 Git 的快照模型里没有“空目录”这个对象
先做一个简单的类比:提交一次 Git 版本,相当于给项目拍一张快照,这张快照记录的是一批文件在某个时刻的内容,而不是记录“我的电脑上有哪些文件夹”。Git 内部有几种核心对象,和目录相关的主要是 blob 和 tree。blob 保存文件内容,tree 保存目录里的条目。一个 tree 对象里记录着子文件名与 blob 的对应关系,也可能记录子目录与另一个 tree 的对应关系。
问题就出在这里:如果一个目录是空的,它下面没有文件需要 blob 承载,那这个目录的 tree 就没有任何可记录的内容,Git 在生成提交的时候会直接跳过它。或者反过来理解:一个目录能出现在 Git 里,前提是它下面至少要有一个文件被 Git 跟踪。文件一旦入库,它路径上的所有目录都会被 Git 自动生成并保留。
这个设计和很多人的直觉不太一样。以前用集中式版本控制工具的老同事可能还记得,某些工具是允许直接提交空目录的,因为它的版本模型里“目录”本身就是一种可版本化的对象。但 Git 不一样,Git 更纯粹:一切以文件内容为中心,目录只是文件的组织方式。
1.2 VSCode 只是忠实翻译了 git status
最初我以为 VSCode 的源代码管理面板显示不出来,可能是插件的问题。后来直接在集成终端里跑了一次git status,发现结果一样:空目录根本没被列出来。
VSCode 面板本质上是把git status、git diff这类命令的结果渲染成图形界面。Git 根本不认为空目录是一个“变更”,所以面板自然看不到。这不是 VSCode 的 bug,更不是我们操作错了,而是 Git 的底层设计使然。
这里可以做一个快速实验:在 VSCode 里创建一个空目录,比如叫test-empty,观察源代码管理面板,没有任何变化。然后在test-empty里随便新建一个1.txt,源代码管理面板立刻会多出一个未跟踪文件,目录结构也随之显示出来。这个实验能直观感受到 Git 的工作方式:目录本身不产生变更,文件才会。
1.3 为什么不能直接 add 一个空文件夹
你可能会想,那我直接在 VSCode 里右键空文件夹,选择“添加到暂存区”可不可以?不好意思,VSCode 的源代码管理面板里,空目录根本没有可供点击的条目。在命令行执行git add 目录名,Git 也不会报错,而是直接静默忽略,然后git commit的时候会告诉你没有任何改动可提交。
原因是git add处理的是文件路径,它要遍历目录找到可入库的文件,目录里没有文件,就没有路径可以写入暂存区。这个设计决定了唯一可行的思路:让空目录里出现一个能入库的文件。这个文件可以是任何名字、任何内容,但业界约定俗成地选了.gitkeep。
2. 标准解法:.gitkeep 占位文件,在 VSCode 里三步完成
2.1 .gitkeep 为什么是约定俗成的名字
.gitkeep并不是 Git 官方保留的特殊文件名,它只是一个社区通用约定。Git 只关心“目录里有文件”,完全不关心文件叫什么。你可以叫keep、placeholder,甚至放一个空的txt,都行。
但为什么最后大家基本都用.gitkeep?因为它的名字自带语义:这个文件存在的意义,就是让目录被 Git 保留。团队协作时,任何人打开仓库看到.gitkeep都会立刻明白,不需要翻文档。顺便提醒一句:不要写成.GitKeep,因为文件名大小写在 Git 仓库里是敏感的,统一小写.gitkeep可以避免同一个目录里出现两个占位文件,造成协作混乱。
2.2 VSCode 里的完整操作步骤
操作非常简单,我按顺序列一遍:
- 在 VSCode 资源管理器中,鼠标右键空文件夹,选择“新建文件”。
- 输入文件名
.gitkeep,回车确认。 - 不需要写任何内容,保存成空文件即可。
- 打开左侧源代码管理面板,也就是活动栏里那个分支图标。
- 面板中会出现一个未跟踪文件
.gitkeep,点击右侧的加号,把它暂存起来。 - 在上方输入框写提交信息,比如
chore: 保留空目录结构,然后点击提交。 - 需要推到远程时,点击源代码管理面板底部的“同步更改”按钮。
如果在第 2 步之后,资源管理器里看不到.gitkeep,多半是项目配置了files.exclude规则,把带点开头的文件隐藏了。可以打开设置搜索files.exclude,看是否有类似"**/.gitkeep": true的规则,删掉后刷新资源管理器就能看到。
2.3 多个空目录怎么批量处理
在一个刚初始化的项目里,空目录经常是好几个一起出现。手动一个个右键新建也可以,但效率太低,更推荐用一条命令批量补占位文件。这里用 Git Bash 或 macOS/Linux 终端都可以:
find . -type d -empty -not -path "./.git/*" -exec touch {}/.gitkeep \;这条命令的含义是:查找当前目录下所有空目录,排除.git目录,然后在每一个空目录里创建.gitkeep文件。执行完再看 VSCode 的源代码管理面板,会一次性出现一列.gitkeep。
Windows 的 PowerShell 也能做,思路差不多:
Get-ChildItem -Directory -Recurse | Where-Object { $_.GetFiles().Count -eq 0 -and $_.FullName -notlike "*\.git*" } | ForEach-Object { New-Item -ItemType File -Path $_.FullName -Name ".gitkeep" -Force }批量操作的时候有一个经验:最好先把所有占位文件准备好,再一次性提交,不要每跑一条命令就提交一次。这样 Git 历史里不会出现一堆零碎的“添加 .gitkeep”提交,review 起来也干净。
2.4 嵌套空目录到底要不要每一层都放占位文件
这个问题最容易被忽略。假如项目里有a/b/c三个层级,其中c是空目录,b和a也暂时没有直接文件。很多人会想:是不是a、b、c每层都要放一个.gitkeep?
其实不需要。只要c/.gitkeep被提交,Git 在创建 tree 对象时会先为c生成一个 tree,再为b生成一个包含c的 tree,最后为a生成包含b的 tree。提交并检出后,a、b、c整条路径都会被重建。
但要注意:如果b下面还有一个兄弟空目录d,d里没有任何文件,那它就没法被记录,clone 下来之后依然会缺失。结论就是:占位文件放在“你需要保留的那个分支的最深层空目录”即可,同级的其他空分支需要各自放占位文件。
3. 进阶需求:目录要保留,里面的内容永远不提交
3.1 只放 .gitkeep 的局限
.gitkeep是最直接的解决方案,但它有一个不能覆盖的场景:像logs、uploads、cache这类目录,目录需要一直在项目里存在,但里面的文件是程序运行后生成的,不该进 Git。
如果只放一个.gitkeep,程序第一次运行后,目录里会多出一堆日志文件或者缓存文件,VSCode 的源代码管理面板马上就喧闹起来了。每跑一次测试,都会产生大量变更,你得不停处理这些没意义的改动。
有人为了省事,直接在.gitignore里写logs/,这样整个目录被忽略,确实清静了。但问题也随之而来:.gitignore把整个目录忽略之后,目录本身也不会被提交,别人 clone 仓库下来,logs目录根本不存在。如果程序启动时没有自动创建目录的逻辑,应用就直接报错。
这就陷入了一个矛盾:目录要入库,但目录里的内容不入库。
3.2 用自引用 .gitignore 保住目录
正确做法是在空目录里放一个.gitignore文件,内容只有两行:
* !.gitignore第一行表示忽略当前目录下所有文件和子目录;第二行表示把当前的.gitignore自己重新包含进来。最终效果是:目录被提交,因为目录里有.gitignore这个入库文件;目录里后续生成的任何内容都不会出现在git status里。
这个文件本身会被提交,在别的环境 clone 下来后,目录仍然存在。因为 Git 忽略规则是按顺序读取的,后面的规则可以覆盖前面的规则,所以!.gitignore能让第一行*产生的忽略效果对这个文件本身失效。
3.3 这招的两个边界条件
第一个边界条件是子目录不会自动保留。因为第一条规则*会把当前目录下的所有子目录也忽略掉,Git 不会进到被忽略的目录里找文件,所以即使子目录里有自己的.gitignore,也无法被追踪。如果希望多层目录都被保留,就需要在这些目录里分别放同样的.gitignore。
第二个边界条件:如果目录里本身有必须提交的文件,比如uploads里既要保留目录,又要提交一份默认图片,那么就不能用*把所有文件都忽略,而应该写更细的规则,比如:
* !.gitignore !default.png这样既保留了目录,又保留了必须入库的默认图片,其余动态文件全部忽略。这个变体在实际项目里很常用。
用一个简单的表格总结方案选择:
| 方案 | 目录会被提交 | 目录内新文件会被追踪 | 适用场景 |
|---|---|---|---|
.gitkeep占位 | 是 | 会 | 静态目录、只想保留结构 |
自引用.gitignore | 是 | 不会 | 运行期会写入内容的目录 |
| 放 README.md 占位 | 是 | 会 | 想顺便说明目录用途,但易被文档流程干扰 |
4. 提交后别大意:验证、克隆与跨平台细节
4.1 怎么确认占位文件真的进了提交
VSCode 面板显示提交成功,有时并不能完全证明目录结构是对的。最稳妥的方法是命令行验证:
git ls-files | grep .gitkeep git log --oneline -1git ls-files输出的是所有被跟踪文件,把占位文件列出来说明确实入库了。更接近真实场景的验证是临时克隆一份到新目录:
git clone 仓库地址 /tmp/repo-check ls -la /tmp/repo-check/resources看到resources目录还在,就说明目录结构没有问题。这一步相当于模拟新同事第一次把项目拉下来的体验,比我对着面板猜要靠谱得多。
4.2 隐藏文件不显示不等于没提交
Windows 资源管理器默认不显示带点开头的文件,macOS 的 Finder 默认也会隐藏 UNIX 隐藏文件。很多人在 VSCode 里明明提交了.gitkeep,下载到本机后却发现目录“看起来是空的”,然后以为提交失败了。
实际上用ls -a看一下,.gitkeep就在那里。记住两层逻辑:磁盘上有没有隐藏文件,是一回事;Git 有没有跟踪这个文件,是另一回事。不要因为资源管理器里没显示就误判。
还有一个常见情况是项目的.vscode/settings.json里配置了files.exclude,把点开头的文件全部隐藏。这种情况下,VSCode 资源管理器里看不到.gitkeep,但 Git 面板依然能正常显示和提交,刚开始用很容易被搞糊涂。
4.3 顺手总结几个常见的小坑
.gitkeep和.Gitkeep混用,会让同一个目录出现两个占位文件,选择一种并固定下来。- 不要把
.DS_Store、Thumbs.db这类系统生成文件当成占位文件提交,它们不是项目的一部分,后面会持续制造噪音,建议在全局忽略规则里直接排除。 - 如果目录里本来就有必须入库的文件,就没必要放
.gitkeep,放了反而会造成额外的占位提交。 - 在 VSCode 里添加隐藏文件后,如果源代码管理面板没刷新,点一下面板右上角的刷新按钮,大多数情况只是缓存问题。
5. 这些经验在团队项目里怎么落地
5.1 项目初始化时就把“空目录策略”定下来
空目录占位这件事,最尴尬的时机是在项目运行两周后再来补,因为那时候会产生一次看起来毫无意义的提交,还容易引发同事的疑惑。建议在项目骨架阶段就约定:哪些目录用.gitkeep,哪些目录用自引用.gitignore。
可以在项目文档里放一张目录规范表:
| 目录 | 处理方式 | 原因 |
|---|---|---|
src/assets/resources | .gitkeep | 静态资源,希望保留并跟踪 |
logs | 自引用.gitignore | 运行时日志,不入库 |
uploads/tmp | 自引用.gitignore | 动态上传内容,不入库 |
目录策略定得越早,后面越省事。项目第一天就把所有需要保留的目录建好并提交,之后每个同事 clone 下来都是完整结构。
5.2 占位文件思想不只适用于 Git
这个套路在其他工具链里也经常复用。比如容器构建时,如果构建上下文里缺少某个目录,COPY指令可能直接失败,很多人会在镜像目录里放一个占位文件来保证构建上下文稳定。再比如打包发布时,空目录通常不会被打进产物里,如果需要最终部署包里有某个空目录,也需要类似的占位文件。
理解了 Git 的“目录靠文件撑起来”这个思想,很多类似问题都能一通百通。本质上,占位文件的职责不是“占位置”,而是让下游工具链意识到这个目录应该存在。
5.3 团队如果坚持不用 .gitkeep 怎么办
有些团队的规范是“目录用途用 README.md 说明”,这种约定本身没毛病,Git 认的是文件,不认文件名。但从实际维护角度看,README.md 会被文档扫描、网站构建等流程抓取,还可能有人顺手往里写内容导致无意义的版本变更;.gitkeep因为名字特殊、文件通常为空,几乎不会被打扰。
个人建议是:通用目录默认用.gitkeep,确实需要说明用途的目录,再加一个 README.md,两者不冲突。毕竟.gitkeep解决的是“目录是否存在”的问题,README 解决的是“这个目录是干什么的”的问题。
最后说一点个人体会。我自己在项目里最常用的组合是:静态资源目录放.gitkeep,运行期会写文件的目录放自引用.gitignore。这套做法在 VSCode 里配合源代码管理面板看非常直观:需要入库的目录是安静的,不需要入库的内容也不会出来吵。一个不起眼的文件,能省掉很多“为什么我的 uploads 目录不见了”“日志目录怎么没提交”之类的沟通成本。如果你刚开始维护一个仓库,建议在项目第一天就把目录策略定下来,后面会少操很多心。