Skip to content
Published on

.gitignore 不生效的时候 — 头号原因与模式规则精读

分享
Authors

引言 — 明明写了忽略,它还是一直上去

在忽略列表里写了一行,文件也确认过了,也没有拼写错误。可状态检查里还是能看到那个文件。到这一步大多数人开始怀疑规则,多加一个星号,或者把斜杠挪来挪去。

问题几乎从来不是规则。忽略列表只对 Git 尚未跟踪的路径生效。哪怕只被提交过一次的文件,规则写得再精细也照样会上去。本文先从确认这个原因开始,然后才处理模式确实写错了的情况。

头号原因 — 对已经被跟踪的文件不生效

先做诊断。有一条命令可以列出"正在被跟踪、同时又命中了忽略规则"的文件。搜索里不太容易出现,但它是最接近这个问题答案的一行。

$ git ls-files -i -c --exclude-standard
config/local.env
build/out.js
node_modules/.package-lock.json

如果这里出现了名字,说明规则是正常的,原因是那个文件在索引里。把它从索引里摘出来就行。

$ git rm --cached config/local.env
rm 'config/local.env'

$ git status --short
D  config/local.env
?? config/local.env

磁盘上的文件原封不动,只是从索引里出去了。但状态输出的第一行要看仔细。这个操作会把删除放进暂存区。一旦提交推上去,拉取那个分支的同事,其工作目录里的这个文件就会被真的删掉。哪怕它在忽略列表里也一样。

对配置文件用这条命令尤其危险。同事本地的连接信息毫无预告地消失,服务起不来,找原因要搭进去半天。实务上必须同时做两件事:一是另外跟踪一个示例文件,二是在提交信息和公告里写明。

$ cp config/local.env config/local.env.example   # 把值清掉,只留键名
$ git add config/local.env.example
$ git commit -m "chore: 로컬 환경 파일 추적 해제, 예시 파일 추가"

要把整个目录摘出去就用递归选项。

$ git rm -r --cached node_modules
rm 'node_modules/.package-lock.json'
rm 'node_modules/.bin/tsc'
...

如果对忽略规则做了大改,也可以把索引整个重建一遍。不过这种做法有时会把换行设置或文件模式的变化一起带出来,所以提交前必须先看一遍变更列表。

$ git rm -r --cached .
$ git add .
$ git status --short | head

这里要点出一条常见的错误建议。想只藏起被跟踪文件的本地修改时,下面这两条命令经常被推荐。

$ git update-index --assume-unchanged config/local.env
$ git update-index --skip-worktree config/local.env

前一条不是忽略功能,而是性能优化。它是在向 Git 承诺"这个文件不会变,不用检查",于是切分支或者拉取的过程中 Git 把这个文件覆盖掉时,连一句警告都没有。后一条保守一些,但一旦遇到必须更新那条路径的操作,就会以看不懂的错误停住。两者都不会传达给别人。没有办法在保持跟踪的同时忽略一个文件。断开跟踪、放一个示例文件,才是唯一能长期维护的答案

直接问 Git 到底是哪条规则在作祟

不需要用眼睛读模式再去推理。有一条命令会告诉你,是哪个文件的第几行抓住了这条路径。

$ git check-ignore -v build/out.js
.gitignore:2:build/	build/out.js

依次是文件名、行号、规则、目标路径。可这里有个坑。

$ git check-ignore -v config/local.env
$ echo $?
1

什么都不输出。看上去像是规则出了问题,其实不是。这条命令默认会参考索引,而且它认定被跟踪的路径不属于忽略对象,于是安静地跳过。想单独测试规则本身,就得告诉它忽略索引。

$ git check-ignore -v --no-index config/local.env
.gitignore:5:*.env	config/local.env

规则从一开始就是正常的。这两段输出的差别,恰恰也是确认头号原因最快的办法。只有在忽略索引时才出现规则,说明那个文件正被跟踪

想看全貌时,就给状态检查加个选项。

$ git status --ignored --short
 M src/app.ts
?? refund.ts
!! build/
!! .env

模式语法精读 — 斜杠的位置,以及否定模式失效的规则

接下来是规则真的写错了的情况。忽略模式里的困惑,大部分都来自斜杠在哪儿。

模式含义命中的例子命中不到的例子
logs任意深度上名为 logs 的文件和目录logs, src/logslogs.txt
logs/只匹配目录,任意深度logs/, src/logs/同名的普通文件
/logs只匹配与这个规则文件同级的 logslogssrc/logs
doc/*.txt含斜杠所以位置锁定,且只匹配一级doc/note.txtdoc/api/note.txt
build/*build 里面的条目,build 本身不是排除对象build/out.jsbuild
!keep.log取消先前的规则只在上层未被排除时上层目录已被排除的情况

表里第三行和第四行是关键。只要模式里出现哪怕一个斜杠,这条模式就以规则文件所在位置为基准被锁定;完全没有斜杠,就在任意深度都能命中。第四行的星号跨不过斜杠,所以只匹配一级。要跨越多级就需要双星号。

# 以规则文件位置为基准,doc 下面无论几级都能命中
doc/**/*.txt

# 任意深度的 build 目录 (与不带斜杠的模式含义相同)
**/build

# build 下面的一切 (build 目录本身不是对象)
build/**

# 注释以井号开头。文件名以井号开头时用反斜杠转义
\#important.txt

# 末尾附带的空格会被忽略。想保留就加反斜杠
trailing\ 

让否定模式失效的那一条规则

为了保住一个空目录而像下面这样写的情况非常常见。

$ cat .gitignore
build/
!build/keep/.gitkeep

$ git check-ignore -v build/keep/.gitkeep
.gitignore:1:build/	build/keep/.gitkeep

否定模式明明在下面一行,上面那条规则却赢了。这不是顺序问题。Git 根本不会走进被排除的目录里。它在目录这一层就筛掉了,所以里面文件的规则连读都不会读。文档里也明确写着:上层目录被排除的文件无法再被重新包含。

解决办法是排除内容而不是目录,并且把想保住的路径一级一级打开。

$ cat .gitignore
build/*
!build/keep/
build/keep/*
!build/keep/.gitkeep

$ git check-ignore -v build/keep/.gitkeep
.gitignore:4:!build/keep/.gitkeep	build/keep/.gitkeep

$ git status --short --ignored
?? build/keep/.gitkeep
!! build/out.js

同样的道理,下面这种写法也经常失败。后面的规则赢没错,但上层整个目录被挡住时,根本走不到那条规则。

$ cat .gitignore
logs/
!logs/app.log

$ git check-ignore -v logs/app.log
.gitignore:1:logs/	logs/app.log

忽略规则的优先级层次

规则不止存在于一个文件里。Git 会按顺序检查多个来源,前面定了就不再看后面。从高到低是这样。

第一是命令行上给的模式。清理命令或者文件列表查询上附加的排除选项属于这一类。第二是与目标路径同级目录里的规则文件,没有就往上走。更近的那个目录里的规则文件永远赢。第三是只存在于仓库内、不会被共享的本地排除文件,最后是用户的全局设置。

$ git check-ignore -v logs/app.log
.git/info/exclude:1:logs/app.log	logs/app.log

知道了这个层次,什么写在哪里就自动定下来了。

# 要与仓库全员共享的规则
$ cat .gitignore

# 像只有自己用的临时目录这种、不该提交的个人规则
$ cat .git/info/exclude

# 要应用到整个账号的编辑器和操作系统副产物
$ git config --global core.excludesFile ~/.config/git/ignore
$ cat ~/.config/git/ignore
.DS_Store
.idea/
*.swp

实务上只定一条规则的话,就是这条。编辑器和操作系统生成的文件,不要放进项目的规则文件里。自己用的编辑器名字没有理由被提交到别人的仓库里,而且那份清单每个人都不一样。放进全局设置,所有项目一次解决。

不区分大小写的文件系统制造的幽灵变更

macOS 和 Windows 的默认文件系统不区分名字的大小写。Git 在创建仓库时会检测到这一点,并自动打开相应设置。

$ git config core.ignorecase
true

由此产生的症状是这样的:把文件名从大写改成小写,状态检查里什么都没有。本地跑得好好的,偏偏在 Linux 的 CI 上以"找不到模块"失败。因为仓库里装着的仍然是原来大小写的名字。

要让 Git 确实认到这次改名,就分两步做。

$ git mv Utils.ts utils-tmp.ts
$ git mv utils-tmp.ts utils.ts
$ git status --short
R  Utils.ts -> utils.ts

很多时候加一次强制选项也能成,但在某些文件系统上会失败,所以两步走更稳妥。

$ git mv --force Utils.ts utils.ts

忽略规则也会跟着遇到同样的问题。扩展名写成大写的文件和写成小写的规则,在某些机器上命中、在另一些机器上命中不到。写规则时不依赖大小写更安全。有必要的话就把两种都写上。

还有一个同样折磨人的幽灵变更:换行符。这一边的正确答案不是让每个人去对齐设置,而是把规则提交进仓库。

$ cat .gitattributes
* text=auto eol=lf
*.png binary

已经提交的密钥,靠忽略列表是删不掉的

最后这一节最重要。误提交了访问密钥之后,把它加进忽略列表、在下一个提交里把文件删掉——这种处理经常见到。那把密钥仍然原封不动地待在历史里。忽略规则只是针对将来要添加的文件的规则,它不改变过去。

$ git log --all --full-history --oneline -- config/local.env
5b21c4e chore: 로컬 환경 파일 추적 해제
9a03d17 feat: 결제 게이트웨이 연동

$ git log -S 'AKIA' --oneline --all
9a03d17 feat: 결제 게이트웨이 연동

顺序必须严格遵守。第一步不是清理历史,而是吊销并重新签发密钥。重写历史需要全团队协调,最快也要几个小时,而扫描公开仓库的自动采集器几秒钟就把密钥拿走了。顺序一颠倒,你就把一把有效的密钥向全世界敞开了好几个小时。

密钥吊销之后,接下来才是清理历史。

$ git filter-repo --path config/local.env --invert-paths
Parsed 9134 commits
New history written in 11.42 seconds; now repacking/cleaning...

这里的局限同样明确。重写只改变自己仓库里的历史,而托管服务上 PR 引用和 fork 常常还抓着旧提交,需要另外提清理请求。如果已经有人克隆走了,那就无法收回。重写历史不是撤销泄露,只是抑制扩散。重写要付的代价,详细整理在仓库变慢的时候一篇的最后一节。

预防要便宜得多。带着真实值的环境文件从一开始就不跟踪,只提交示例文件。在提交钩子上挂一个密钥探测器,并打开托管服务的推送拦截功能。把这三件事装好所花的时间,比吊销并轮换一把密钥还短。

结语 — 怀疑规则之前,先确认状态

忽略列表不生效时的顺序永远一样:先确认是不是被跟踪了,再问 Git 是哪条规则抓住了它,最后才去改模式。光是守住这个顺序,花在搜索上的时间大半就没了。

要记住的只有一句话。忽略列表是针对未被跟踪文件的规则,对已经在索引里的文件毫无影响。而如果被卷进去的是密钥,那忽略列表从一开始就不是应对手段。那时候第一条命令不是在 Git 里执行,而是在密钥签发控制台里执行。