有次在项目里放了个 dist.zip,顺手在 .gitignore 里写了 .zip,接着写代码去了。过了几天提交时,发现 git status 里那个 dist.zip 一直躺着。我第一反应是规则没保存,反复确认后又怀疑 .gitignore 文件放错了目录,折腾了一轮才发现:.zip 和 *.zip 在 Git 的忽略规则里根本不是一回事。很多人(包括当时的我)会把 .zip 想当然地理解成“所有 zip 压缩包”,但 Git 没那么智能,它做的只是字符串模式匹配,差一个 *,行为就差得很远。
这个问题看起来小,实际是 .gitignore 初学者最容易踩的坑之一。因为你的“意图”和 Git 的“匹配规则”完全是两套东西:你脑子里想的是“忽略所有 zip 压缩包”,Git 脑子里只有“这个路径名跟我记的模式对不对得上”。搞清楚这一点,不只是能解决 .zip 的问题,.env、.idea、*.log 这一串相关写法你都会豁然开朗。今天就把这个坑从头到尾拆一遍。
1. 写了 .zip 却压不住:一次压缩包忽略失败的排查
1.1 问题现场:git status 里的压缩包一直删不掉
先还原一下场景。项目目录结构大概是这样的:
text复制project/
├── .gitignore
├── src/
└── dist.zip
.gitignore 里的内容是:
gitignore复制.zip
执行 git status,看到:
bash复制$ git status
Untracked files:
dist.zip
也就是说,dist.zip 依然没有被忽略。这时候我去检查 .gitignore 有没有生效,于是直接试一下规则本身:
bash复制$ git check-ignore -v .zip
.gitignore:1:.zip .zip
输出说明:.gitignore 第 1 行的 .zip 规则,确实匹配到了一个叫 .zip 的文件。可问题是,项目里没有哪个文件的名字是 .zip,我们真正想忽略的是 dist.zip。再测一下:
bash复制$ git check-ignore -v dist.zip
# 没有任何输出
没有输出,就是没有匹配上。规则写在文件里,但只对一个不存在的 .zip 生效,对真正的 dist.zip 视而不见。这就是问题本身。
1.2 用 git check-ignore 定位规则为何没生效
这里用到的排查命令是 git check-ignore -v <path>。它专门用来反问 Git:这个路径到底有没有被忽略?如果被忽略了,是哪条规则生效的;如果没有,它就不输出任何内容。
把模式改成 *.zip 之后再看:
bash复制$ cat .gitignore
*.zip
$ git check-ignore -v dist.zip
.gitignore:1:*.zip dist.zip
输出里明确告诉我们:*.zip 规则命中了 dist.zip。同时 git status 里也不会再出现 dist.zip。
所以排查这类问题,千万不要靠肉眼盯着 .gitignore 猜。直接拿 git check-ignore -v 验证要定位的文件,几秒钟就能判断是规则写法问题,还是文件本身不在规则覆盖范围内。
1.3 复盘:把 .zip 当成“所有压缩包”是惯性思维
为什么我会在第一反应里写 .zip?复盘下来有两个原因。
第一个原因,是把 Git 的匹配规则默认成了“后缀匹配”。很多人以为写 .zip 就等于“以 .zip 结尾的文件”,这其实是文本编辑器、文件管理器里面“按扩展名筛选”的习惯。Git 不在乎“扩展名”这个概念,它只认路径和 basename。
第二个原因,是正则表达式的惯性。在正则里,. 是通配符,能匹配任意一个字符,所以 .zip 可能被误解成“任意字符 + zip”。但 .gitignore 用的不是正则,而是 glob 模式,这里的 . 就是字面量的点号,没有通配能力。真正能表示“任意内容”的是 *。
把这两点拆开,你就能理解:.zip 在 glob 语义里就是一个普通字符串,它的含义是“名字恰好叫 .zip”,而不是“扩展名是 zip 的文件”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. .zip 和 *.zip 在 Git 匹配规则里的真实差异
2.1 先理解 gitignore 的两种基本模式
Git 的 .gitignore 规则,本质上是一行一个匹配模式。官方文档 gitignore(5) 对模式的分类,核心是看“模式串里有没有斜杠 /”。
- 模式里没有斜杠(除了结尾的斜杠),比如
.zip、*.zip、cache,这类模式匹配的是文件树的任意层级下的 basename。也就是说,dist.zip放在根目录、放在src/release/下,只要文件名本身是dist.zip,就会被*.zip匹配到。 - 模式里有斜杠,比如
/dist.zip、build/output.zip、build/,这类模式是锚定路径匹配,从.gitignore所在目录开始,按相对路径找。以一个斜杠开头时,行为会更严格,只匹配相对于该.gitignore所在目录的路径。
这个分类非常关键,因为很多人会误以为没有斜杠的规则只能匹配当前目录。实际上恰恰相反:没有斜杠时,Git 会拿每个文件/目录的 basename 去套模式,所以 *.zip 天然覆盖了项目里所有层级的 zip 文件。而加了斜杠之后,匹配范围反而被限制在特定路径下。
2.2 * 是通配符,点号是字面量
.gitignore 里可用的通配符有三个:
*:匹配任意多个字符,但不能匹配路径分隔符/。?:匹配任意一个字符,也不能匹配/。[...]:字符类,匹配方括号内列出的任意一个字符,比如[0-9]匹配数字。
注意这里的重点是:* 不能跨过 /。所以如果你写的是 foo/*.zip,它只能匹配 foo/ 下面一层里的 zip,foo/deep/bar.zip 就匹配不到。但 *.zip 是个无斜杠模式,它匹配的是任意层级的 basename,所以 foo/deep/bar.zip 反而能被匹配到。这两者之间的差别,很多人在写嵌套目录规则时都会绕晕。
点号 . 在这里没有任何特殊含义,就是普通字符。所以:
.zip只能匹配 basename 恰好为.zip的文件或目录。*.zip匹配所有 basename 以.zip结尾的文件或目录。a.zip、backup.zip、2023-01-01.zip都能被*.zip命中。
2.3 边界情况:*.zip 也能匹配名为 .zip 的文件
有个容易被忽略的边界:*.zip 中的 * 可以匹配零个字符,所以 *.zip 反过来也能匹配名为 .zip 的文件。也就是说,*.zip 是 .zip 的超集,它把“名为 .zip 的文件”和“所有 .zip 结尾的文件”全部覆盖了。
我们做个对照表,看得更清楚:
| 模式 | 匹配 dist.zip |
匹配 src/deep/a.zip |
匹配名为 .zip 的文件 |
匹配 dist.zip.bak |
匹配 archive.tar |
|---|---|---|---|---|---|
.zip |
不匹配 | 不匹配 | 匹配 | 不匹配 | 不匹配 |
*.zip |
匹配 | 匹配 | 匹配(* 匹配空串) |
不匹配 | 不匹配 |
/*.zip |
根目录下匹配 | 不匹配 | 根目录下匹配 | 不匹配 | 不匹配 |
**/*.zip |
匹配 | 匹配 | 匹配 | 不匹配 | 不匹配 |
另外补充一个容易忽略的事实:*.zip 不只会忽略文件,如果某个目录的名字正好是 a.zip,它同样会被忽略,因为无斜杠模式匹配 basename,不区分文件与目录。想限定只匹配目录,需要在模式末尾加 /,比如 *.zip/,这种写法比较少用,但确实存在。
2.4 ** 与斜杠模式:什么时候才需要它们
如果你真的想控制目录层级,就需要用到 **。
**/foo.zip:匹配任意层级下的foo.zip,包括根目录。foo/**/bar.zip:匹配foo/bar.zip、foo/deep/bar.zip、foo/deep/deeper/bar.zip,中间的**可以表示零个或多个目录。foo/**:匹配foo目录下的所有内容。
对比一下:*.zip 是无斜杠模式,自动匹配任意层级的 basename;**/*.zip 是带斜杠模式,显式表示从当前目录开始逐层找。两者在“匹配任意层级 zip”这个目标上结果差不多,但语义不同。在团队项目里,如果规则文件放在子目录中,用无斜杠模式更容易造成歧义,所以很多人更倾向于用带斜杠的显式写法。理解了这一层,再回头看 .zip 和 *.zip 的区别,你就会形成自己的判断,而不是靠背结论。
3. 顺着匹配原理,把同类坑一次排掉
3.1 .env 与 *.env、.idea 与 *.idea 的混淆
.zip 这个坑不是孤例。最常见的兄弟坑是环境变量文件。
有的项目里只有一个根目录 .env,那么你在 .gitignore 里写 .env 也能正常工作,因为无斜杠模式匹配 basename,根目录的 .env 的 basename 恰好是 .env。但这会给人一种错觉:.env 等于“所有环境变量文件”。
一旦项目里出现了 config/.env,.env 这个规则其实是能匹配到的,因为它匹配任意层级的 basename。真正不能匹配的是 prod.env、dev.env 这种“前缀 + .env”的命名。如果你们的约定是“所有以 .env 结尾的文件都属于环境配置”,那就应该写 *.env,而不是 .env。
类似地,有人想忽略 IDE 配置,写 .idea,它确实能匹配根目录的 .idea 目录,但匹配不到 config/.idea/ 下的某些同名文件。如果意图是“忽略所有叫 .idea 的目录”,.idea 就够了(因为前缀匹配 basename,任何层级的 .idea 目录都会被忽略);但如果意图是“忽略所有 .idea 开头的文件和目录”,那就得考虑 *.idea 或更精确的写法。核心原则是:写规则之前,先明确你想匹配的到底是“名字恰好是 X”还是“以 X 结尾/开头的一类东西”。
3.2 父目录被忽略时,取反规则经常失灵
再讲一个和匹配层级强相关的坑:取反规则 !。
比如你想忽略 build/ 目录,但想保留 build/keep.zip,于是写了:
gitignore复制build/
!build/keep.zip
实际结果是 build/keep.zip 还是被忽略了。原因很简单:Git 优化机制决定,如果父目录 build/ 已经被排除,Git 根本不会进入到这个目录里去逐条匹配下面的文件,!build/keep.zip 这条规则压根没有机会生效。
想保留文件,需要先把父目录“解禁”,再解禁目标文件:
gitignore复制build/*
!build/keep.zip
这样 build/ 下的其他内容全部忽略,keep.zip 被单独保留。注意,如果 build/ 下面还有子目录,还需要先允许进入子目录,套路是一层一层解。这个坑和 .zip 的坑一样,都是因为对 Git 匹配“粒度”的理解不够:Git 的规则是逐路径段匹配的,父级被排除,子级就不会再被考虑。
3.3 已经提交过的文件,不会因为新规则而“消失”
还有一种常见情况:文件已经被 git add 甚至已经被提交,之后才想起往 .gitignore 里加规则。这时候你发现规则怎么加都没用,文件依然在仓库里。
这不是规则写错了,而是 .gitignore 只影响 untracked(未跟踪)文件。已经被 Git 跟踪的文件,忽略规则管不着。解决办法是从索引中移除,但保留工作区文件:
bash复制git rm --cached dist.zip
之后提交一次,dist.zip 才会从仓库里消失,并且未来不会被重新跟踪。如果项目里已经有一批文件需要统一处理,更常见的操作是:
bash复制git rm -r --cached .
git add -A
git commit -m "chore: clean tracked files and apply gitignore rules"
这个操作会把所有文件从索引里重新过一遍,适合一次性清理历史遗留的误跟踪文件,但提交记录会比较冗余。具体怎么选,取决于你对历史记录洁癖的程度。
3.4 平台差异:大小写和目录分隔符
最后说一个让人抓狂的细节:大小写。
Git 的忽略规则默认区分大小写,*.ZIP 不会匹配 dist.zip。在 Linux 和 macOS 的默认文件系统上,这通常没问题;但 Windows 和 macOS 的默认文件系统可能不区分大小写,Git 会跟随文件系统的行为。为了保证团队一致性,建议规则里统一小写,或者在规则里显式写全大小写形式:
gitignore复制*.zip
*.ZIP
目录分隔符方面,无论 Windows 还是其他系统,.gitignore 模式里一律写 /。Windows 的 \ 在 Git 匹配时只当作普通字符,经常导致规则失效。
4. 不动 .gitignore,也有办法单独屏蔽文件
4.1 .git/info/exclude:只对当前仓库生效的本地忽略
有时候你不想因为自己的临时文件去改动团队的 .gitignore,比如本机的 local.properties,或者某个只有你本地会生成的调试包。这时候可以用 .git/info/exclude。
它的写法和 .gitignore 完全一样:
text复制# .git/info/exclude
local.properties
tmp_debug.zip
区别在于:.git/info/exclude 存放在 .git 目录里,不会提交到版本库,也不会影响团队其他成员。它是“当前仓库、当前开发者”的本地忽略清单,适合个人临时使用。
这个文件使用的时机,其实比很多人想象中更频繁。比如代码评审时发现你多提交了一个本地配置文件,你不想吵到队友,也不想影响历史,就可以先在这里屏蔽掉。
4.2 update-index --skip-worktree 与 --assume-unchanged
如果文件已经存在于仓库中,而你只是希望“本地有时改一改,但永远不要被 Git 看到”,就需要动用 git update-index。
命令是:
bash复制git update-index --skip-worktree config/local.js
这会告诉 Git:这个文件即使在工作区被修改,也不要标记成 modified。适合本地配置文件、环境差异文件等场景。
还有一个类似的命令:
bash复制git update-index --assume-unchanged config/local.js
它原本的设计意图是为了优化大仓库的性能:假设文件没有变化,减少 Git 的 stat 开销。但它也常被用来屏蔽本地修改。两者的区别在于:--skip-worktree 是“我真的不希望 Git 检查这个文件的改动”;--assume-unchanged 是“我假设这个文件没变,别浪费性能去检查”。在“屏蔽本地改动”这个用途上,官方更推荐 --skip-worktree,因为 --assume-unchanged 在文件真实修改时,Git 可能不会检测到,容易造成“我以为改过,但它没提交”的混乱。
查看哪些文件被标记,用:
bash复制git ls-files -v
如果文件行首是 S,表示 skip-worktree;是 h,表示 assume-unchanged。想解除标记:
bash复制git update-index --no-skip-worktree config/local.js
git update-index --no-assume-unchanged config/local.js
这里必须提醒一句:这些命令解决的是“本地工作区不想被 Git 打扰”的问题,不是“让文件从仓库里消失”的方法。文件仍然在仓库里,其他成员 clone 后依然能看到原始版本。如果你想让文件彻底不进仓库,还是得走 .gitignore 或 git rm --cached 路线。
4.3 强制添加与反向排查:git add -f 和 git check-ignore 组合
反过来也有一种需求:文件明明被 .gitignore 忽略了,但你这一次就是想把某个文件强制提交上去。Git 提供了 -f 参数:
bash复制git add -f important.zip
这个命令会绕过忽略规则,强制把文件加入暂存区。它和 git rm --cached 一样,都属于“规则外的故意操作”,适合处理那些标准规则覆盖不到的特殊场景。
写项目时,我习惯把两条命令配合着用:
bash复制git check-ignore -v config/local.js # 看这条规则是谁定的
git add -f config/local.js # 如果确认要提交,强制加
git check-ignore -v 会打印出命中规则的来源文件、行号和模式串。比如:
bash复制$ git check-ignore -v config/local.js
.gitignore:12:local*.js config/local.js
一行输出直接告诉你:是 .gitignore 第 12 行 local*.js 命中了它。这个信息定位问题非常高效。
4.4 规则优先级:从全局配置到仓库内多级 .gitignore
再往深一层,.gitignore 不是孤立存在的。Git 读取规则有明确的优先级顺序,从高到低大致是:
- 命令行中显式指定的排除路径(比如某些 IDE 的插件行为)。
- 仓库内
.gitignore文件,离目标路径越近的优先级越高。也就是说,src/.gitignore里的规则,比仓库根目录.gitignore的规则更优先。 - 同一目录下
.gitignore中,后出现的规则覆盖前面的规则(全局最后一次匹配决定)。 .git/info/exclude。- 全局配置
core.excludesFile指定的文件。
全局配置的典型用法是:
bash复制git config --global core.excludesFile ~/.gitignore_global
然后在 ~/.gitignore_global 里写一些与具体项目无关的通用规则,比如 .DS_Store、Thumbs.db。这个文件的优先级最低,适合放“所有项目都不该提交”的垃圾文件,而不是某个项目的特殊约定。
理解优先级后,很多“为什么我写了规则还是不生效”的问题都能一眼看穿。比如你在根目录 .gitignore 写了 !important.zip,但 src/.gitignore 里可能有一条 *.zip,而 src/.gitignore 离目标文件更近,优先级更高,你的取反自然无效。
5. 我在项目里最终沉淀下来的忽略规则写法
5.1 压缩包与二进制的统一写法
经历了那次 dist.zip 风波之后,我在项目里对“一类文件”的忽略规则做了一个约定:凡是意图匹配“一类文件”,一律使用带 * 的 glob 模式,不带 * 的模式一律视为精确的 basename 匹配。压缩包相关的规则,我固定写成:
gitignore复制# 压缩包
*.zip
*.rar
*.7z
*.tar
*.gz
*.bz2
*.xz
不会有人真的想在仓库里忽略一个名字叫 .zip 的文件,所以直接用 *.zip 准没错。同理,日志文件写 *.log,临时文件写 *.tmp,而不是 .log 和 .tmp。这些写法不是我凭空想出来的,项目模板里也都是这么写的,只是很多人在最初接触时没有意识到那个 * 不是可有可无的。
5.2 验证规则三步走
规则写完之后,我建议做一个固定流程,避免“以为忽略了,实际没忽略”的尴尬:
第一步,用 git status --ignored 看当前有哪些文件被忽略。
bash复制git status --ignored
这个命令会把被忽略的文件也列出来,一眼就能看出你的规则大概覆盖了哪些内容。
第二步,用 git check-ignore -v <path> 单独验证可疑文件。
第三步,如果发现规则没覆盖到,再回头改 .gitignore,改完重复第一步。
这三步操作成本极低,但对新手来说价值很大。我在团队里见过不少次,同事说“我加了规则了”,实际规则根本没生效,就是因为少做了验证这一步。
5.3 团队协作时的忽略规则管理
最后聊一下团队场景。.gitignore 是跟着仓库走的,一旦提交,所有成员都会受影响。所以团队里需要注意几个点:
规则应该尽量精简,只写项目真正需要忽略的东西。不要把个人 IDE 的随意配置全堆进去,更不要把只对你自己生效的临时文件名写进公共规则。个人临时文件放 .git/info/exclude,公共约定才放 .gitignore。
如果需要清理历史遗留的误跟踪文件,一次提交完成,不要在同一个 PR 里既改代码又大范围删文件,这样 review 的时候很容易漏看。提交信息里写清楚“清理误跟踪文件并应用忽略规则”,队友也好 review。
另外一点经验是:定期让新人做一次“忽略规则评审”。他们最容易遇到 .zip 这类坑,而他们踩坑后反馈的问题,往往是文档里不会写的真实场景。我后来把 .zip 和 *.zip 的区别直接写进了团队 Wiki 的 Git 规范页面,新成员入职后先看一遍,能少走很多弯路。实际用下来,踩坑的人确实明显变少了。
回到最初的问题:.gitignore 里 .zip 和 *.zip 的区别,本质上就是“精确命名”和“通配一类”的区别。.zip 只匹配名字恰好是 .zip 的文件,*.zip 匹配所有以 .zip 结尾的文件。写规则时,脑子里始终绷着一根弦:Git 不读心,你的意图必须靠精确的模式来表达。把那颗 * 放在心上,类似的坑基本就不会再踩到了。
