上一篇把 fzf 定位成通用选择器,本篇补齐高质量候选的生产端。ripgrep(rg)搜索内容,fd 搜索路径,find 提供最完整的 POSIX/文件属性表达;三者不是互相淘汰,而是覆盖不同精度与可移植性需求。
一、痛点:搜索结果多,不代表搜索正确
rg pattern默认递归、尊重.gitignore、跳过隐藏和二进制文件,这正适合代码仓库,却可能漏掉被忽略的生成物。-u逐级放宽过滤,-uu包含隐藏与忽略文件,-uuu还尝试二进制;排障时要明确自己在哪一层。--hidden只包含隐藏文件,仍尊重忽略规则。搜索秘密或构建缓存前也要评估输出是否会进入日志。
fd 以用户友好的默认值搜索文件名,模式默认是正则而非 shell glob;精确文件名可用--glob或--fixed-strings。find 虽语法冗长,却能按权限、时间、所有者和文件系统边界筛选,并用-exec command {} +安全批量执行。不要解析ls,它是面向人的展示格式。
二、原理:正则引擎、忽略层和退出码
ripgrep 默认使用 Rust regex 引擎,保证线性时间,但不支持回溯和环视;确需这些特性可用-P启用 PCRE2,代价是可用性与性能特征变化。字面搜索用-F,单词边界用-w,文件类型用-t,上下文用-C。先缩小文件集合再写复杂模式,通常比一个巨型正则更容易验证。
退出码 0 表示找到匹配,1 表示没有匹配,2 表示错误。开启set -e的脚本若把“没找到”当失败,会提前退出;应用显式分支处理。机器消费时用--json、-0或--null-data,不要解析带颜色、列宽和本地化的人类输出。
搜索工具的退出码也属于接口。下面程序构造独立夹具,模拟“忽略目录、按后缀筛选、逐行报告”三步。它让没有 rg 的环境也能验证搜索边界;迁移到 rg 时,应保持输入范围、模式与退出语义不变,而不是只替换命令名。
frompathlibimportPathfromtempfileimportTemporaryDirectorywithTemporaryDirectory()astemp:root=Path(temp)files={"src/app.py":"token = load_secret()\nprint('ready')\n","src/test_app.py":"def test_ready():\n assert True\n","vendor/old.py":"token = 'ignored'\n","README.md":"token appears in docs\n",}forrelative,contentinfiles.items():path=root/relative path.parent.mkdir(parents=True,exist_ok=True)path.write_text(content,encoding="utf-8")matches:list[str]=[]forpathinsorted(root.rglob("*.py")):relative=path.relative_to(root)if"vendor"inrelative.parts:continuelines=path.read_text(encoding="utf-8").splitlines()fornumber,lineinenumerate(lines,1):if"token"inline:matches.append(f"{relative}:{number}:{line.strip()}")formatchinmatches:print(match)print(f"matches={len(matches)}")print(f"exit_code={0ifmatcheselse1}")运行输出:
src/app.py:1:token = load_secret() matches=1 exit_code=0三、实现:可复用的仓库审计
下面脚本同时检查调试语句、疑似私钥头和超大文件,报告可复现且不修改仓库。模式只是初筛,命中后仍需人工判断;安全扫描不能只依赖几个正则。
#!/usr/bin/env bashset-euopipefailroot="${1:-.}"root="$(cd"$root"&&pwd-P)"command-vrg>/dev/null2>&1||{echo'需要 ripgrep'>&2;exit1;}fail=0run_check(){locallabel="$1"pattern="$2"shift2printf'\n[%s]\n'"$label"set+e rg --line-number--column--color=never"$@""$pattern""$root"status=$?set-ecase"$status"in0)fail=1;printf'发现候选项,请复核\n';;1)printf'未发现\n';;*)printf'搜索失败,退出码=%s\n'"$status">&2;exit"$status";;esac}run_check'debug statements'\'console\.log\(|debugger;|breakpoint\(\)'\-g'!vendor/**'-g'!dist/**'run_check'private key headers'\'BEGIN (RSA |OPENSSH |EC )?PRIVATE KEY'\--hidden-g'!.git/**'printf'\n[large files]\n'find"$root"-typef-size+5M-not-path'*/.git/*'-printprintf'\nresult=%s\n'"$fail"exit"$fail"若希望把结果交给上一篇的 fzf,可让rg --line-number --no-heading输出path:line:text,fzf 用--delimiter : --preview 'sed -n "{2}p" {1}'预览。更严格的机器接口使用 JSON,再由 jq 解析字段,以免 Windows 盘符或文本中的冒号破坏切分。
pattern='DEPRECATED|REMOVE_BEFORE_RELEASE'set+e rg--json"$pattern".>"${TMPDIR:-/tmp}/rg-result.jsonl"status=$?set-eif[["$status"-gt1]];thenprintf'ripgrep failed: %s\n'"$status">&2exit"$status"fiprintf'match_records='grep-c'"type":"match"'"${TMPDIR:-/tmp}/rg-result.jsonl"||true四、踩坑:忽略规则与批量修改
.gitignore、父目录 ignore、全局 Git ignore 和.ignore会共同影响结果。用rg --debug可观察过滤原因,但输出很大,应用小目录复现。符号链接默认不跟随,启用-L后可能跨出仓库或形成环;find 可用-xdev限制文件系统边界。搜索压缩包需要额外预处理,不能把“没输出”误判为内容安全。
把搜索直接接sed -i或删除命令风险很高。先输出 NUL 分隔清单,人工审阅,再用能正确处理 NUL 的循环执行;对每个文件保留版本控制或备份。并行执行时还要考虑多个进程写同一文件。性能比较应保持相同的隐藏、忽略、编码和正则语义,否则数字没有可比性。
五、验证:用刻意构造的小夹具测边界
建立含普通文件、隐藏文件、被忽略文件、空格路径、二进制字节和符号链接的临时目录,逐项验证过滤层。再验证匹配、无匹配、无权限三种退出路径。团队可以把关键搜索写进 CI,但必须锁定工具最低版本,并让误报能通过明确的 ignore 文件解释。
至此,我们有了快速、可组合的查找层。下一篇会把反复使用的搜索和导航流程封装成别名与 Shell 函数,重点区分何时用别名、何时必须写函数或独立脚本。
参考来源
- ripgrep User Guide
- fd 官方文档
- GNU findutils Manual
👍 觉得有用就点个赞 + 收藏,方便回头查阅;有疑问直接在评论区留言,我看到都会回。
🚀 本文属于《终端与命令行进阶》系列,持续更新,关注不迷路。
📌 文章里的代码都能直接跑。想要可直接 clone 的完整工程 + 配套部署脚本 / 踩坑清单?评论一声或发邮件到cj2664@qq.com,我免费发你。
如果你正好在做类似系统、或有工程化难题想找人做,也欢迎邮件聊一句——我按实际情况评估,能落地的就接单或出方案。评论和邮件都能直接找到我,不用跳别的平台。