什么是 .gitignore 文件?
.gitignore 文件是一个文本文件,用来告诉 Git 哪些文件不纳入版本控制。每一行都是一个模式 —— *.log、node_modules/、.env —— 任何匹配到某个模式的未跟踪路径都会从 git status 中消失,被 git add . 跳过,也永远不会被提交。
这句话的分量几乎全落在 未跟踪 这个词上;单凭它就能解释比所有模式写错加起来还多的“gitignore 不起作用”的情况。Git 只会为它还没有跟踪的文件去查 .gitignore。一个文件一旦被提交过,之后无论你往 .gitignore 里加什么,Git 都会一直继续跟踪它。
Gitignore Debugger 能做什么
粘贴文件路径和你的 .gitignore 内容:这个 .gitignore 调试器会给出与 Git 相同的判定 —— 忽略或未忽略 —— 并指出做出这一判定的确切规则和行号。接着它会按 Git 求值的顺序列出所有匹配该路径的规则,并标出真正起决定作用的那一条。
最后这一点,正是普通 gitignore 检查工具所缺少的。知道一个文件被忽略了,很少是难点。难的是知道你那四十条规则里究竟是 哪一条 做的,以及你原本以为会生效的那条为什么输了。这在两个方向上都有用:既能看出你想提交的文件为什么被 Git 忽略,也能看出你想清掉的文件为什么没被忽略。
引擎重新实现了 Git 的模式匹配规则:按基名匹配的模式与锚定模式的区别、* 绝不跨越 /、** 的三种形式、只匹配目录的结尾 /、字符类,以及经过转义的行尾空格。它的行为是对照 git check-ignore 的真实输出验证过的,而不是凭猜测。
使用方法
- 按路径在仓库中的实际写法输入,相对于仓库根目录 —— 写
src/config/local.json,而不是C:\projects\app\src\config\local.json。请使用正斜杠;Windows 的反斜杠会自动为你转换。 - 把
.gitignore整个粘贴进来。不要只留下你怀疑的那一条规则 —— 真正的问题通常出在规则之间的优先级上,而删减文件恰恰会丢掉这部分信息。 - 先看判定,再看下面的规则链条。不起作用的 gitignore 规则,就是在这里不再是个谜的。
如果要针对目录而不是文件来测试 gitignore 规则,请让路径以斜杠结尾 —— 比如 build/。这个区别很重要,因为以 / 结尾的规则只匹配目录。
为什么 gitignore 规则有时不起作用
大致按出现频率排列,元凶如下:
- 文件已经被跟踪了。
.gitignore不会追溯生效。如果文件是在规则出现之前提交的,这条规则会被完全跳过。请运行git rm --cached path/to/file然后提交:文件仍留在磁盘上,而 Git 不再跟踪它。 - 后面的规则把它覆盖了。Git 不会在第一个匹配的模式处停下。
- 某个上层目录被忽略了。你为该文件写的规则根本没有被走到。
- 你以为不受限的模式,其实被锚定了。只要斜杠出现在结尾以外的位置,这个模式就会被锚定到包含该
.gitignore的目录,因此src/*.log匹配src/debug.log,却不匹配更深的路径。完全不含斜杠的模式则按基名在任意深度匹配:*.log既能抓到debug.log,也能抓到src/debug.log和a/b/c/debug.log。 - 模式的格式有误。像
[abc这样未闭合的字符类并不会退化成字面的[。Git 会放弃匹配,这条规则于是悄无声息地什么都不匹配 —— 和从未写过完全没有区别。所以在调试器里,它根本不会出现在匹配规则列表中。
gitignore 规则的优先级:最后一次匹配胜出
这就是那个让所有人都栽跟头的地方。在同一个 .gitignore 里,决定一个路径命运的是 最后 一个匹配它的模式,而不是第一个。下面这两个文件包含的规则完全相同,对 important.log 却给出相反的结果:
*.log
!important.log → important.log 会被提交!important.log
*.log → important.log 会被忽略所以把宽泛的规则放在前面,例外放在后面。在多个文件之间,位于子目录中的 .gitignore 对其下的路径会胜过上层的那一个。Git 还会读取仅属于本仓库、且永远不会被提交的 .git/info/exclude,以及你的全局 core.excludesFile。一条你在任何 .gitignore 里都找不到的规则,往往就藏在这两处之一 —— git check-ignore -v 会显示是哪个文件的哪一行做出的决定。
否定规则,以及 ! 为什么经常失效
在模式前加上 !,可以把先前某条规则忽略掉的内容重新包含回来。这件事有两个限制,而第二个几乎解释了所有“gitignore 的否定不起作用”的情况。
第一,必须先有东西忽略过这个路径。单独写一条 !important.log 什么用也没有,因为压根就没有需要覆盖的对象。
第二点则意外得多:你无法把一个位于被忽略目录中的文件重新包含回来。下面这种写法不起作用,而且调换顺序也救不了:
build/
!build/keep.txt当 build/ 被忽略时,Git 根本不会进去。它不会去查看 build/keep.txt,因此永远走不到第 2 行。改成忽略目录的内容,这样目录本身仍然可以被遍历:
build/*
!build/keep.txt如果要救回更深处的东西,中间的每一层目录也都必须保持可访问 —— 先 build/*,再 !build/assets/,再 !build/assets/logo.svg。
被忽略的上层目录
Git 的目录遍历会在遇到的第一个被忽略的目录处停止,其下的一切都会直接继承这个判定,而不会被逐一检查。这就是为什么一个被忽略的上层目录,会胜过你为文件本身写的任何规则 —— 不管那条规则看起来多么具体。
遇到这种情况时,调试器不会只说文件被忽略了,而会指出是哪个上层目录挡住了遍历;同时它仍会显示你为该文件写的否定规则,并标记为不可达,因为 Git 从来没有走到那么深、根本不会考虑它们。先把目录修好,下面的规则自然就又能生效了。
常见的 .gitignore 错误
- 在提交文件之后才添加规则。规则本身没错,也完全不起作用。参见上面的
git rm --cached。 - 行尾注释。
#只在行首才开启注释,所以*.log # debug output是一个又长又完整的模式,匹配不到你手上的任何文件。 - 用反斜杠当分隔符。在
.gitignore里,\是转义字符,绝不是路径分隔符。src\config表示的是字面名称srcconfig。请始终写成src/config。 - 指望
*能跨越目录。*会在斜杠处停下,所以src/*/test.js只会往下一层。要匹配任意深度,请用src/**/test.js。 - 忘了结尾的斜杠。
logs既匹配名为logs的文件,也匹配同名目录;logs/只匹配目录。 - 以为忽略能抹掉历史。规则只能拦住之后的提交,并不会删除已经推送出去的密钥 —— 请更换这份凭据。
几个实际例子
在这些规则下,路径 src/logs/debug.log 没有被忽略。否定是最后一次匹配,而且没有任何上层目录挡住遍历,所以它被走到了,并且胜出:
*.log
!src/logs/debug.log在这些规则下,路径 build/keep.txt 被忽略了,原因是第 1 行的 build/ 匹配了它的上层目录。第 2 行永远不会触发:
build/
!build/keep.txt在这条规则下,路径 docs/api/notes.md 没有被忽略,因为这个模式被它的斜杠锚定了,而 * 不会跨过 api/notes.md 里的那个斜杠。本意要写的是 docs/**/*.md:
docs/*.md把上面任意一个例子粘进页面顶部的 gitignore 测试器,就能看到完整的规则链条。回到终端里,git check-ignore -v path/to/file 就是内置的对应命令 —— 它会指出做出决定的来源文件、行号和模式,值得记住。