rg 与 fd 搜不全代码:四个过滤层和六个坑

格 格达软件 2026-11-08 2 次阅读

上周做一次 API 迁移,要把 GetUserV1 全换成 GetUserV2。rg GetUserV1 一跑,17 个文件,改完上线,支付服务挂了——它的调用点在 deploy/ 目录下,那个目录被 .gitignore 忽略了,rg 默认不读被忽略的文件,这次搜索从头到尾没看见它。

这类事故的根因都一样:你以为 rg 只是「快一点的 grep」,其实它在启动时就替你做了一堆范围决策,而你没意识到。下面把这几层拆开讲。

一、rg 默认做了四层过滤

rg pattern 和 grep -r pattern . 的输出差异不是速度快慢,是搜索范围。

过滤层 rg 的默认行为 对应开关 典型漏搜场景
版本控制忽略规则 读 .gitignore / .ignore / .rgignore --no-ignore / -u 构建产物、生成代码、部署脚本
隐藏文件 跳过 . 开头的文件和目录 --hidden .env、.github/、.dockerignore
二进制文件 检测到 NUL 字节就停手 --binary(只报告)或 -a/--text 序列化配置、某些日志、.pb 文件
符号链接 不跟随 -L/--follow monorepo 里用软链做的包别名

-u 是可叠加的:-u 等于 --no-ignore,-uu 等于 --no-ignore --hidden,-uuu 再加 --binary。

常见错误写法:不管什么搜索都 rg -uuu pattern .。看着最保险,实际会把 .git/ 下的 object 文件和 pack 文件全扫一遍。在一个 20 万文件的仓库里,不加 -uuu 的搜索是秒级,加了之后大概率得等十几秒,而且 --binary 那层的输出基本是噪声。

稳一点的做法是显式写清范围:

# 只要隐藏文件,不要 .git,也不要被 ignore 的文件
rg --hidden -g '!.git/' 'DATABASE_URL' .

# 连被 ignore 的文件一起搜(查部署脚本、生成代码时用)
rg --hidden --no-ignore -g '!.git/' -g '!node_modules/' 'DATABASE_URL' .

-g 用的是 gitignore 语义,不是 shell glob

这是第二个高频误解。规则有三条:

  • 模式里不带 /,匹配任意层级。-g '*.go' 等价于所有目录下的 .go 文件。
  • 模式里带 /,从当前工作目录开始算相对路径。-g 'src/*.go' 只匹配 ./src/ 下第一层。
  • 模式以 / 结尾,只匹配目录。-g '!vendor/' 只排掉目录,不排同名的普通文件。
# 错的:以为只排根目录的 dist,其实把所有叫 dist 的目录都排了
# 而且没写尾部斜杠,某些边界下同名文件也被一起排掉
rg 'API_KEY' -g '!dist' -g '!node_modules' .

# 对的:故意要全局排除就写清楚,要精确到某一层就用带 / 的路径
rg 'API_KEY' -g '!**/dist/' -g '!**/node_modules/' .
rg 'API_KEY' -g '!./dist/' .

如果你们的排除规则超过五条,别塞在命令行里,写文件:

# .rgignore 放在仓库根,rg 会自动读取,和 .gitignore 叠加生效
cat > .rgignore <<'EOF'
dist/
build/
*.min.js
*.pb.go
zz_generated_*.go
EOF

.rgignore 的好处是它独立于 .gitignore:生成代码需要提交进仓库,但搜索时又想跳过,用这个刚好。

二、fd 的 -x 和 -X 差一个数量级

fd -e go -x gofmt -w 和 fd -e go -X gofmt -w 结果一样,代价不一样:

参数 长写法 行为 适合
-x --exec 每个文件执行一次命令,并行,线程数默认等于 CPU 核数 只接受单文件参数的命令
-X --exec-batch 所有文件作为参数,只执行一次,串行 支持多文件参数的命令

gofmt -w、rg、rm、wc -l 都吃多文件,用 -X。file、identify、md5sum(虽然它其实支持多文件)这类按单个文件设计的,用 -x。

性能陷阱:对 5000 个文件跑 fd -e go -x gofmt -w,等于 fork 5000 次进程,即使 8 核并行,进程创建开销也能吃掉大部分时间。换成 -X 是一次 execve 传 5000 个参数,快得多。

但 -X 有个致命边界:参数总长度会撞 ARG_MAX(Linux 默认单参数上限约 2MB,getconf ARG_MAX 可以查)。超了就是 Argument list too long。这时候要么退回 -x,要么显式切块:

# 每 500 个文件一批,兼顾进程开销和 ARG_MAX
fd -e go -0 | xargs -0 -n 500 gofmt -w

-0 是让 fd 用 NUL 分隔输出。文件名带空格或换行时,不加 -0 的管道会把一个路径拆成两个,这是最容易在小文件上不出现、在大仓库里偶发的问题。fd 和 rg 都支持 -0,xargs 记得配 -0。

fd 的模式默认只匹配文件名

fd 'handler' 匹配的是 basename,不是完整路径。所以 fd 'services/api' 一条都找不到。要匹配路径得加 --full-path:

# 找 services 下所有 handler 文件,排除测试
fd --full-path 'services/.*/handler_.*\.go$' -E '*_test.go'

# -e 是扩展名,等价于 -E 的反面,比写 glob 稳
fd -e ts -e tsx -E 'node_modules' -E '*.test.ts' src/

-E 的 glob 匹配的是相对路径,-E 'node_modules' 会排掉任意层级的 node_modules 目录。-e 走的是内置扩展名表,比 -E '!*.ts' 更不容易写错。

三、六个真会咬人的坑

坑 1:.env 全在隐藏文件里

排查「密钥硬编码」时,rg 'AKIA' . 一条都搜不到,因为 .env、.env.production、.github/workflows/ 全是隐藏的。

# 找所有硬编码的 AK/SK 和设备 token,包括 .env 和 CI 配置
rg -uu -n -g '!.git/' -g '!*.lock' \
  -e 'AKIA[0-9A-Z]{16}' \
  -e '(?i)(api[_-]?key|secret|token)[_-]?(id|value)?\s*[:=]\s*\S{16,}' \
  .

-uu 同时打开 --hidden 和 --no-ignore,-g '!.git/' 把最脏的目录挡住。这个组合是「审计」场景的默认起点。

坑 2:压缩后的单行文件刷爆终端

dist/app.min.js 一行 20 万字符,匹配到就整行打印,终端直接卡死。

# 超过 200 字符的行只显示前缀
rg -n --max-columns 200 --max-columns-preview 'useEffect' src/

输出会变成 src/app.min.js:1:[Omitted long matching line] 或者截断加 ... [123456 bytes omitted]。做日志分析时同理,某些 JSON 日志一行就是一条完整记录。

坑 3:多行匹配默认是关的

JSX 里这种写法,普通的 rg 'Button onClick' 搜不到:

<Button
  onClick={() => handleSubmit(id)}
>
# -U 开多行,--multiline-dotall 让 . 能匹配换行
rg -U --multiline-dotall -n 'Button\s+onClick=\{[^}]+\}' src/

代价:-U 会让 rg 放弃按行流式处理,整个文件(或跨行窗口)读进内存再匹配。拿它去扫 monorepo 里所有大文件,内存占用会明显上去。只在确认需要跨行时开。

坑 4:-c 数的是匹配行数,不是匹配次数

rg -c 'TODO' src/            # 每个文件「有多少行含 TODO」
rg --count-matches 'TODO' src/  # 每个文件「有多少个 TODO」
rg -o 'TODO' src/ | wc -l       # 全部匹配次数

一行里写了两个 TODO,-c 只算 1。做技术债统计时用错这个,数字会小一半。

坑 5:输出顺序不保证,--sort 有代价

rg 是多线程并行搜索的,rg -l 吐出来的文件顺序和你 ls 的顺序对不上,每次跑还不一样。要出对比报告、或者把结果贴进 diff 时:

rg -l --sort path 'GetUserV1' .

--sort 会让 rg 放弃并行、退化成单线程收集再排序。在几十万文件的仓库里这个差异肉眼可见,自己用 hyperfine 对比一下就知道值不值:

hyperfine --warmup 3 \
  'rg -l GetUserV1 .' \
  'rg -l --sort path GetUserV1 .'

交互式搜索不要加 --sort,只有需要稳定输出时再加。

坑 6:-P 打开了回溯的门

rg 默认用 Rust 的 regex 引擎,保证线性时间,代价是不支持反向引用和环视。需要这些时加 -P(PCRE2):

# 找重复单词,必须 PCRE2
rg -P -n '\b(\w+)\s+\1\b' docs/

但 -P 就失去了线性时间保证。模式里如果有嵌套量词((a+)+ 这种)叠加上回溯,遇到不匹配的长输入会指数级爆炸。默认引擎会直接拒绝这类模式并报错,-P 不会——它只会让你的终端卡住。规则:-P + 用户可控输入(比如在服务端做内容校验)是危险组合,别这么用。

四、把常用组合固化成配置

RIPGREP_CONFIG_PATH 指向的文件会在每次调用时被读取,适合放那些「永远想开」的参数:

# ~/.ripgreprc
--smart-case
--hidden
--glob=!.git/
--max-columns=300
--max-columns-preview
# ~/.zshrc 或 ~/.bashrc
export RIPGREP_CONFIG_PATH="$HOME/.ripgreprc"

几个注意点:

  • 配置文件里的参数会被命令行后追加的参数覆盖。你在命令行敲 --no-hidden 就能临时关掉。
  • 别把 --no-heading 写进去。多文件搜索时 --heading 按文件分组更易读,单文件时默认就不分组,写死反而破坏交互体验。
  • 别把 -uuu 写进去。那是「审计」专用,不是日常默认。

fd 没有官方配置文件,用 shell 函数兜:

# 在源码里搜,排除所有生成物和依赖目录,带上下文和行号
srcsearch() {
  rg -n --heading -C 2 \
    -g '!.git/' -g '!node_modules/' -g '!vendor/' \
    -g '!dist/' -g '!build/' -g '!*_test.go' \
    "$@" .
}

# 只列文件,方便管道接 vim / xargs
srcfiles() {
  rg -l --sort path -g '!.git/' -g '!node_modules/' "$@" .
}

五、什么时候别用 rg

rg 不是无条件的更快。

网络文件系统:NFS、SMB 上元数据读取延迟高,rg 的多线程会把并发请求打满,反而比单线程慢。加 -j1 试试,很多时候这一个参数就能把时间砍半。

极少文件:只剩三五个文件要搜时,rg 启动时的线程池初始化和 ignore 规则解析是固定开销。差别在毫秒级,但如果你在写脚本循环调用,用 grep 少一层依赖。

需要正则回溯能力:反向引用、递归匹配这些,rg 默认直接报错,-P 有回溯风险。这种场景直接用 perl -ne 或者 awk 更坦白。

非 UTF-8 文本:GBK 编码的老日志,rg 默认会跳过或报错。加 --encoding gbk 或者 -a 当成字节流搜,但匹配位置的行号可能不准。

六、回到开头那个事故

保证「搜全」不能靠人脑记规则,得让命令自己暴露差异。每次做破坏性重构前,跑一遍这个对比:

# 默认范围:遵循 .gitignore,跳过隐藏文件
rg -l 'GetUserV1' . | wc -l

# 全量范围:连被忽略的和隐藏的都算上,只挡住 .git
rg -uu -l -g '!.git/' 'GetUserV1' . | wc -l

两个数一样,说明这次运气好,范围没漏。不一样,就 diff 一下两份文件清单,看多出来的是哪类文件——是生成代码(可以放心忽略),还是 deploy/ 里的脚本(那就是事故源头)。这一步花 10 秒,能省掉一次回滚。

fd 侧同理,写批量脚本前先数一下:

# 确认要处理的文件数量,别一上来就跑 -x
fd -e go -E 'vendor' . | wc -l
# 数量超过几千,改用 -X 或 xargs 分批
格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。