📚 Git 深入课(以 sourcepack 仓库为标本)· 第 7/12 章
全部示例均来自真机实测;实验请在隔离克隆里做。
本章目标:把
.gitignore从"抄来的模板"变成"我能预测其行为"的规则系统。
学完你能回答:为什么abc/**不忽略abc自己?为什么!有时候"不起作用"?
为什么.gitignore挡不住已经被提交的文件?
以及——为什么 sourcepack 明明调 git,却还要自己写一套匹配器。
7.1 忽略规则有五个来源,优先级不同
| 来源 | 位置 | 是否进版本库 | 优先级 |
|---|---|---|---|
| 命令行 | git add --force、git check-ignore 的 -c 配置等 |
— | 最高 |
.gitignore |
仓库各层目录 | 是(可共享) | 中(就近优先) |
.git/info/exclude |
仓库内部 | 否(本地) | 中低 |
core.excludesFile |
用户全局(如 ~/.config/git/ignore) |
否 | 低 |
.gitattributes 的 export-ignore |
仓库 | 是 | 只管 git archive,与忽略无关 |
实测三种来源同时生效(git check-ignore -v 会告诉你"被谁命中的"):
$ git check-ignore -v root.log # 仓库 .gitignore
.gitignore:1:*.log root.log
$ git check-ignore -v local-only.txt # .git/info/exclude
.git/info/exclude:1:local-only.txt local-only.txt
$ git config core.excludesFile /tmp/global-excludes.txt
$ git check-ignore -v global-ignore.tmp # 全局忽略
/tmp/global-excludes.txt:1:global-ignore.tmp global-ignore.tmp
为什么要有三套:
.gitignore:团队共享的规则(构建产物、依赖目录);.git/info/exclude:你自己的本地噪音(编辑器临时文件),不想让同事看到;core.excludesFile:你所有仓库的通用规则(.DS_Store、*.swp)。
7.2 语法全表
| 写法 | 含义 |
|---|---|
*.log |
不含 / → 匹配任意层级的名字 |
/build |
前导 / → 锚定到该文件所在目录(这里是仓库根) |
build/ |
尾部 / → 只匹配目录,且其下内容一并忽略 |
**/foo |
零层或多层目录 |
foo/** |
foo 内部的内容(不含 foo 自身,见 7.6) |
a/**/b |
a/b、a/x/b、a/x/y/b |
!keep.log |
负向模式(重新纳入),按"最后一条命中的规则生效" |
\#name、\!name |
字面量的 # / ! |
temp?、[ab]c.txt |
? 单字符;[...] 字符组([!...] 取反) |
# 注释、空行 |
忽略 |
| 行尾空格 | 未转义的行尾空格会被剥掉 |
关于 \# 与 \! 的实测(很容易写错的一处):行首的 # 会被当成注释,
所以想忽略一个真的叫 #hash.txt 的文件,必须转义:
$ printf '#hash.txt\n' > .gitignore ; git check-ignore -q '#hash.txt' ; echo $?
1 # ← 行首 # 被当注释,文件根本没被忽略
$ printf '\\#hash.txt\n' > .gitignore ; git check-ignore -q '#hash.txt' ; echo $?
0 # ← 转义后才真正生效
注意:
git check-ignore的参数是路径,不是模式——想验证某个模式,
要把它写进.gitignore/.git/info/exclude/core.excludesFile之后再做检查。
我第一版就在这里写错了(以为能直接传模式),实测纠正。
7.3 git check-ignore -v:排查忽略问题的第一工具
它不只回答"是否忽略",还回答"被哪个文件的第几行、哪条模式忽略":
$ git check-ignore -v app.log keep.log build/out.o sub/skip.txt deep/x.tmp
.gitignore:4:!keep.log keep.log
.gitignore:3:build/ build/out.o
.gitignore:5:sub/skip.txt sub/skip.txt
.gitignore:6:**/deep/*.tmp deep/x.tmp
格式:来源文件:行号:模式<TAB>路径。
注意第 1 行:keep.log 被负向规则 !keep.log 命中——它列出来是因为
"命中的规则是这条",而 ! 的含义是"不要忽略"。判断是否真的被忽略要看退出码:
$ git check-ignore -q keep.log ; echo $?
1 # ← 退出码 1 = 没有任何规则让它被忽略(保留)
$ git check-ignore -q app.log ; echo $?
0 # ← 0 = 被忽略
踩坑提示:一次传多个路径时,只要有任意一个不被忽略,整条命令就退出 1
且不打印任何内容。排查时请一次只查一个,或用git check-ignore -v --stdin。
其它排查命令:
| 命令 | 用途 |
|---|---|
git status --ignored |
连被忽略的文件一起列(!! 前缀) |
git clean -ndX |
演习:列出"只删被忽略文件"会删掉什么(-n 演习,-X 只删忽略项) |
git ls-files --others --exclude-standard |
未跟踪且未被忽略的文件(sourcepack 取未跟踪清单用的就是它) |
git check-ignore -v --no-index <p> |
连已跟踪文件也按规则判定(见 7.4) |
实测:
$ git status --ignored --porcelain
M .gitignore
?? a/
!! build/
!! deep/
!! sub/skip.txt
$ git clean -ndX
Would remove build/
Would remove deep/
Would remove sub/skip.txt
7.4 .gitignore 只管未跟踪文件(最常见的误解)
$ git add -f app.log && git commit -m "强制跟踪 app.log"
$ git ls-files --error-unmatch app.log >/dev/null && echo 已跟踪
已跟踪
$ git check-ignore -q app.log ; echo $?
1 # ← 已跟踪文件:check-ignore 直接说"不忽略"
$ git status --porcelain app.log
M app.log # ← 照常显示修改
$ git rm --cached app.log # 取消跟踪
$ git check-ignore -q app.log ; echo $?
0 # ← 现在又被忽略了
规则:.gitignore 的作用是"决定哪些未跟踪文件不出现在 git status / git add . 的候选里"。
一旦文件已进索引,忽略规则对它完全无效——它照常显示修改、照常被提交。
实用推论:
- "我把它写进
.gitignore了,为什么还在被提交?" → 因为它已经被跟踪,
需要git rm --cached(保留工作区文件)才会"脱离版本控制"; - "为什么同事那边这个文件没被忽略?" →
.gitignore是工作区文件,必须提交并推送才对别人生效;
本地规则请用.git/info/exclude。
7.5 嵌套 .gitignore 与"就近优先"
规则:每一层目录的 .gitignore 都生效;冲突时"更深的目录里的规则"优先;
同一个文件内"最后一条命中的规则"赢。
实测:根 .gitignore 是 *.log + !keep.log,而 sub/.gitignore 里顺序相反:
$ cat .gitignore $ cat sub/.gitignore
*.log !keep.log
!keep.log *.log
$ for f in root.log keep.log sub/a.log sub/keep.log; do git check-ignore -v $f; done
.gitignore:1:*.log root.log
.gitignore:2:!keep.log keep.log # ← 根目录的 ! 生效:保留
sub/.gitignore:2:*.log sub/a.log
sub/.gitignore:2:*.log sub/keep.log # ← 子目录的 *.log 在 !keep.log 之后 → 忽略
读法:sub/keep.log 先命中 !keep.log(第 1 行),再命中 *.log(第 2 行),
后者更靠后 → 最终被忽略。所以"顺序"是 .gitignore 里最容易被忽略的语义。
7.6 两条反直觉规则(sourcepack 都踩过)
7.6.1 abc/** 不匹配 abc 自身
$ printf 'abc/**\n' > .gitignore
$ for p in abc abc/f.txt abc/d/deep.txt abcd; do
printf "%-16s → %s\n" $p "$(git check-ignore -q $p && echo 忽略 || echo 保留)"; done
abc → 保留
abc/f.txt → 忽略
abc/d/deep.txt → 忽略
abcd → 保留
原因:git 的定义是"abc/** 匹配 abc 内部的内容",
所以零层的 ** 在这里不被允许——必须至少有一层。
而相反地,前导 **/(**/foo)必须允许零层(否则根目录下的 foo 匹配不到)。
sourcepack 的实现(ignore.go:275-279):
// abc/** 表示"abc 内部的内容"(不含 abc 自身):补一个 * 段要求至少一层,
// 否则 ** 会匹配零层、把 abc 自己也吞掉。**/ 开头则必须允许零层。
if len(r.segs) > 1 && r.segs[len(r.segs)-1] == "**" {
r.segs = append(r.segs, "*")
}
一行代码就体现了这条规则的方向性——这也是"自己实现匹配器"最容易写错的地方之一。
7.6.2 父目录被排除后,! 救不回来
$ printf 'a/**/b\n!a/b/c\n' > .gitignore
$ mkdir -p a/b && printf 'x\n' > a/b/c
$ git check-ignore -q a/b ; echo $? → 0(a/b 是目录,被 a/**/b 忽略)
$ git check-ignore -q a/b/c ; echo $? → 0(仍然被忽略!)
$ git check-ignore -v --no-index a/b/c
.gitignore:1:a/**/b a/b/c # ← 归咎于 a/**/b,而不是!a/b/c
git 的规则:"无法重新纳入一个其父目录已被排除的文件"
(It is not possible to re-include a file if a parent directory of that file is excluded)。
为什么:Git 遍历到 a/b 时发现它被忽略,就不再往里走——
a/b/c 根本没被"看见",自然无从被 ! 救回。
对照:如果模式命中文件而不是目录,! 就有效:
$ printf '*.tmp\n!keep.tmp\n' > .gitignore && printf 'x\n' > keep.tmp
$ git check-ignore -q keep.tmp ; echo $? → 1(保留 ✅)
这条规则对工具的影响极大(sourcepack 的两个模式处理方式不同):
| 模式 | 怎么处理"目录下的内容" |
|---|---|
| 全量扫描(遍历工作区) | 在目录上判定 → 命中就 SkipDir,天然满足 git 的规则 |
| git 变更模式(清单来自 git,没有遍历) | 必须显式回溯各级父目录,否则 a/b/c 会被当成"未被忽略"漏进产物 |
源码(ignore.go:101-111):
// 祖先目录被忽略 → 本路径也被忽略,而且**无法用 ! 重新纳入**
// (git 的原话:不能重新纳入"父目录已被排除"的文件)。
// 遍历模式靠 SkipDir 达到同样效果,但 git 变更模式没有遍历,必须在这里兜住。
comps := strings.Split(relPath, "/")
for i := 1; i < len(comps); i++ {
if v := m.dirVerdict(strings.Join(comps[:i], "/")); v.ignored {
return true, true, v.source
}
}
我在实现这块时的真实经历:对照测试第一次跑就报了一处不一致——
a/**/b之下a/b/c我判成"保留",git 判成"忽略"。
这条规则不是靠想用例能覆盖到的,是git check-ignore告诉我答案的。
这也说明"以 git 为真值"的测试策略有多值。
7.7 三种"看起来像忽略、其实不是"的东西
| 机制 | 真实作用 | 常见误用 |
|---|---|---|
git update-index --assume-unchanged <f> |
告诉 Git"别检查这个文件的变化"(性能手段) | 当 .gitignore 用 → 克隆到别处就失效,还可能导致改动被静默忽略 |
git update-index --skip-worktree <f> |
让 Git 忽略工作区版本(用于本地配置覆盖) | 同上;且 git pull 撞上它时会报冲突 |
.gitattributes 的 export-ignore |
只影响 git archive 打包内容 |
以为它会阻止提交 → 不会 |
空文件 .gitkeep |
纯约定:让空目录能被提交(Git 不跟踪空目录) | 以为它有特殊功能 → 没有,任何文件名都行 |
7.8 跨平台的两个坑
7.8.1 路径分隔符:filepath.Match 在 Windows 会让 * 跨过 /
这是 sourcepack 真实踩到的 bug(v1.8.2 修复):
- 我们内部的
relPath一律用filepath.ToSlash归一(/分隔); - 但
filepath.Match是平台相关的:Windows 上它把\当分隔符、把/当普通字符; - 于是
src/*.go在 Windows 上会匹配src/sub/main.go—— 过度排除。
// ignore.go:357
// 用 path.Match 而不是 filepath.Match:后者在 Windows 上把 \ 当分隔符、把 /
// 当普通字符,* 于是会跨过 /(这正是 CI 在 windows-latest 上抓到的差异)。
CI 的报错原文(三个 Windows job 同时挂):
--- FAIL: TestMatchPattern (0.00s)
main_test.go:40: matchPattern("src/sub/main.go", "src/*.go") = true, want false
教训:这类"只在某个平台成立"的差异,没有对应平台的 CI 就永远发现不了。
这也是我们把 windows-latest 加进矩阵的直接原因——加完当天就抓到它和
另一个 CRLF 问题(gofmt 把 CRLF 判成未格式化)。
7.8.2 大小写
| 平台 | 默认行为 |
|---|---|
| Linux | 大小写敏感(core.ignoreCase=false) |
| macOS / Windows | 文件系统大小写不敏感(git 会设 core.ignoreCase=true) |
后果:.gitignore 里写 *.LOG 在 macOS 上会匹配 x.log(因为文件系统不敏感),
在 Linux 上不会。跨平台项目建议两边都写,或统一小写命名。
7.9 回到 sourcepack:为什么要自己实现匹配器
一个自然的疑问:既然 sourcepack 到处调 git,为什么忽略规则要自己写一套?
答案有三层:
.gdignore是项目自己的机制——git 完全不认识这个文件,
而"不改项目.gitignore也能让快照跳过某些文件"是这个工具的特性;- 全量扫描必须在"不是 git 仓库"的目录里工作(
gx -d /tmp/foo很常见); - git 模式虽然由 git 给清单,但
.gdignore仍要叠加在 git 的结论之上。
7.9.1 语义实现要点(ignore.go)
| 环节 | 做法 |
|---|---|
| 解析 | parseIgnoreLine:# 注释、\!/\# 字面量、! 负向、尾部 / 目录、行尾空格 |
| 切段 | 按 / 切;末尾 ** 补一个 * 段(7.6.1) |
| 匹配 | matchSegments 支持 ** 匹配零到多段;segmentMatch 用 path.Match |
| 目录规则 | dirOnly 命中任意一层父目录即命中(7.6.2) |
| 祖先传递 | dirVerdict + 缓存(避免深目录 O(深度²)) |
| 优先级 | 浅→深 .gitignore → 浅→深 .gdignore → 命令行 -I |
".gdignore 永远压过 .gitignore"这条是我们自己的规定(git 没有这个概念):
实现方式是"先遍历 .gitignore 的全部规则,再遍历 .gdignore 的",
于是后者天然是"最后命中的规则"。
7.9.2 以 git 为真值的对照测试
ignore_test.go 里有一张模式表 × 路径表,逐个对比 git check-ignore 的结论:
// 当前规模:14 个模式 × 40 条路径,完全一致
var oraclePatterns = []string{
"*.log", "**/*.log", "**/*.tmp", "src/**/*.go", "**/node_modules",
"a/**/b", "abc/**", "build/", "/rooted.txt", "docs/**/*.md",
"*.min.js", "temp?", "[ab]racket.txt", "keep/**/deep.txt",
}
为什么这比"多写几个用例"强:模式的组合爆炸(锚定 × ** 位置 × 目录规则 × 负向)
不是靠列举能覆盖的,而把判定权交给 git,就变成了"对齐一个已有实现"。
7.10 动手实验 [lab]
cd /tmp/git-lab
git checkout -q -B lab7 HEAD && git clean -qfd
# 1) 谁忽略的?—— check-ignore -v 是最强工具
cat > .gitignore <<'EOF'
*.log
!keep.log
build/
a/**/b
abc/**
EOF
mkdir -p build a/b abc/d && for f in app.log keep.log build/o.o a/b/c abc/f.txt; do mkdir -p $(dirname $f); echo x > $f; done
git check-ignore -v app.log keep.log build/o.o # 来源文件:行号:模式
git check-ignore -q keep.log ; echo "keep.log 退出码 $?" # 1 = 保留
# 2) 已跟踪文件不受忽略规则影响
git add -f app.log && git commit -qm "track app.log"
git check-ignore -q app.log ; echo $? # 1:git 说"不忽略"(已跟踪)
git rm --cached -q app.log
git check-ignore -q app.log ; echo $? # 0:回到未跟踪,又被忽略
# 3) 嵌套与顺序
printf '*.log\n!keep.log\n' > .gitignore ; mkdir -p sub
printf '!keep.log\n*.log\n' > sub/.gitignore ; touch sub/keep.log
git check-ignore -v sub/keep.log # 子目录的第 2 行赢
# 4) 两个反直觉规则
printf 'abc/**\n' > .gitignore
git check-ignore -q abc ; echo "abc 退出码 $?(1=保留)"
printf 'a/**/b\n!a/b/c\n' > .gitignore
git check-ignore -v --no-index a/b/c # 归咎于 a/**/b,! 无效
# 5) 三种来源
echo local-noise.txt > .git/info/exclude
printf 'global-noise.tmp\n' > /tmp/gx.txt && git config core.excludesFile /tmp/gx.txt
touch local-noise.txt global-noise.tmp
git check-ignore -v local-noise.txt global-noise.tmp
git config --unset core.excludesFile
# 6) 排查工具箱
git status --ignored --porcelain | head
git clean -ndX | head
git ls-files --others --exclude-standard
自测题:
.gitignore为什么管不住已经提交的文件?怎么让一个文件"脱离版本控制但保留在工作区"?abc/**与**/abc的区别是什么?各自匹配到什么?!什么情况下"不起作用"?为什么?git check-ignore -v输出.gitignore:4:!keep.log keep.log,说明这个文件被忽略了还是被保留了?- 为什么 sourcepack 不干脆让 git 来决定一切忽略(而要实现
.gdignore匹配器)? filepath.Match与path.Match在 Windows 上的差别会导致什么后果?
7.11 小结
- 忽略规则有五个来源、优先级不同:命令行 >
.gitignore(就近/深者优先).git/info/exclude>core.excludesFile;.gitattributes的export-ignore与忽略无关; - 同一文件内最后一条命中的规则生效——顺序是语义的一部分(实测
sub/keep.log的例子); .gitignore只管未跟踪文件:已跟踪文件照常修改与提交(check-ignore对已跟踪文件退出 1,
git rm --cached后变 0);git check-ignore -v给出来源文件:行号:模式,是排查的第一工具;
但它混入未忽略路径时会整体退出 1 且不打印;- 两条反直觉规则:
abc/**不含自身(sourcepack 补一个*段)、
父目录被排除后!无效(遍历靠SkipDir、git 模式靠显式回溯祖先); - 跨平台坑:
filepath.Match在 Windows 让*跨过/(我们改用path.Match,
并由windows-latestCI 抓到)、大小写敏感性差异; - sourcepack 自己实现匹配器的三层理由(
.gdignore是自有机制、非 git 目录要能扫、
git 模式仍需叠加),并用 14 模式 × 40 路径的git check-ignore对照测试保证语义对齐。
下一章:远端与传输——refspec 语法、fetch/push 到底交换了什么、
packfile 协商与"增量传输"、force-with-lease 为什么比 --force 安全,
以及 sourcepack 的推送(-p)与 git 传输的区别(我们推的是产物,不是提交)。