fd 与 fzf 仓库收敛:从海量路径安全选中一个文件
文件选对了,命令却作用在另一个对象上
一个 monorepo 里有几万个文件。工程师用 find | grep | xargs 找迁移清单,路径 packages/payment v2/config.yaml 被空格拆成两个参数;另一次,使用者在模糊选择器里按 Esc 取消,包装脚本却把空字符串解释为当前目录,随后对整个目录执行了格式化。第三次没有任何选择错误,预览命令本身却在光标上下移动时反复访问网络。
fd 和 fzf 组合得很好,因为职责足够窄:fd 遍历文件系统并输出路径候选,fzf 对输入记录做交互过滤并输出选择。它们都不理解源码语义,也不会替调用方决定下一条命令是否安全。可靠的仓库收敛链必须同时保护四个边界:发现语料可解释、记录分隔不歧义、预览只读且有界、取消选择不会触发后续动作。
安装后先确认命令名与路径
以下行为以 fd 10.4.2 与 fzf 0.74.0 为基线。fd 采用 MIT 或 Apache-2.0 双许可,fzf 采用 MIT 许可;两者都是继承当前进程文件与终端权限的本地 CLI,没有需要部署的服务端,也没有托管版与自托管版的功能差异。
Windows 可用 winget、Scoop 或 Chocolatey,macOS 可用 Homebrew,Linux 使用发行版仓库。Debian/Ubuntu 的 fd 包可能把可执行文件命名为 fdfind,因为 fd 名称与已有包冲突;团队脚本必须检测实际命令,不能只在个人 shell 里悄悄建 alias。
winget install sharkdp.fd
winget install junegunn.fzf
fd --version
fzf --version
Get-Command fd,fzf | Select-Object Name,Sourcebrew install fd fzf # macOS
sudo apt-get install fd-find fzf # Debian / Ubuntu
sudo dnf install fd-find fzf # Fedora
fdfind --version || fd --version
fzf --version
command -v fdfind || command -v fd
command -v fzf发布制品和更多平台入口见 fd 官方仓库 与 fzf 官方仓库。发行版仓库可能滞后于上游发布线;直接下载发布制品时要校验发布页摘要,并固定 CPU 架构。项目若依赖 --print0/--read0、预览占位符或特定 shell 集成,应固定经验证版本线,并在升级时执行含空格、换行、隐藏路径、.git、取消选择和预览中断的回归样例。
两个进程之间只传“候选”,不传意图
fd PATTERN ROOT 默认用正则匹配路径的文件名部分,并从 ROOT 并行遍历;--glob 才把模式改为 glob,--type f 只保留普通文件,--extension ts 按扩展名过滤。fzf 从标准输入读取记录,在内存中按查询评分、过滤和排序,确认后把所选记录写到标准输出。
两者都不建立持久索引,也不解析 AST。fd 每次调用都从根目录重新读取目录项和元数据,ignore、隐藏、类型、大小与名称字段共同决定候选;fzf 只处理生产者送来的记录,不会回到文件系统补查遗漏。于是 fd 漏掉的路径无法靠 fzf 查询“搜回来”,fzf 高分候选也不代表语义相关,更不代表路径在确认后仍指向同一个对象。
模糊匹配只负责人机收敛,不会验证文件属于哪个模块、是否为生成代码、是否有权限修改。后续动作必须重新检查路径仍在仓库内、对象类型符合预期,并把选择作为一个完整参数传递。
正向实验:从仓库安全选出配置文件
在 Bash、zsh、Git Bash 或 WSL 中创建一个临时 Git 仓库:
mkdir fd-fzf-lab && cd fd-fzf-lab
git init -q
mkdir -p 'packages/payment v2' packages/legacy .hidden
printf 'mode: current\n' > 'packages/payment v2/config.yaml'
printf 'mode: old\n' > packages/legacy/config.yaml
printf 'mode: secret\n' > .hidden/config.yaml
printf 'packages/legacy/\n' > .gitignore
git add .gitignore 'packages/payment v2/config.yaml'先不用交互界面,确认 fd 候选语料:
fd --type f --glob 'config.yaml' .预期只输出 packages/payment v2/config.yaml。普通文件过滤掉目录,glob 把模式解释为文件名通配;隐藏目录和 .gitignore 命中的 packages/legacy 默认不出现。fd 搜索路径名,不搜索 mode: current 这段正文。
把 --glob 去掉并不是等价简写。默认正则里的 . 匹配任意字符,且模式默认不锚定;增加一个反例就能看到误报:
printf 'not yaml\n' > 'packages/payment v2/configXyaml'
fd --type f 'config.yaml' .
fd --type f --glob 'config.yaml' .第一条会同时接受 config.yaml 与 configXyaml,第二条只接受字面文件名。若目标是所有 YAML 后缀,--extension yaml 比手写名称正则更能表达字段意图;若目标是完整相对路径,增加 --full-path 后,pattern 的作用域也随之改变。正反结果应进入升级 fixture,避免“候选更多所以更好”的误报漂移。
然后用安全记录分隔进入 fzf:
fd --type f --glob 'config.yaml' --print0 . \
| fzf --read0 --print0 --preview 'sed -n "1,80p;80q" < {}'fd --print0/-0 用 NUL 结束每条路径,fzf --read0 按 NUL 读取,--print0 继续用 NUL 输出选择。空格、制表符甚至文件名中的换行都不再充当记录边界。预览中的 {} 会被 fzf 作为当前记录的 shell 引用形式替换;把它放在输入重定向右侧,可避免以连字符开头的文件名被 sed 当成选项。80q 则让 sed 打印到第 80 行后立即退出,不再继续扫描大文件的剩余内容。
这个命令只把选择写到标准输出,没有修改文件。真正接下游时仍要按 NUL 读取,而不是用命令替换 selected=$(...),因为 shell 变量不能可靠保存 NUL,且命令替换通常会剥离尾部换行。
ignore 优先级与 Git 工作树必须显式验证
fd 默认跳过隐藏条目,并在 Git 仓库内采用 .gitignore;它也读取通用 .ignore、专用 .fdignore 和全局 fd ignore。所有规则使用 gitignore 风格解释,但不能把文件名想当然地排成一条通用的 .fdignore > .ignore > .gitignore 优先级口诀:目录层级、规则出现位置和是否仍会遍历父目录都会影响重新纳入。--ignore-file 追加规则,命令行 --exclude/-E 即使与 --no-ignore 同时出现仍会排除对象。最终候选必须用正反命令验证。
printf 'packages/legacy/\n' > .fdignore
fd --no-ignore-vcs --type f --glob 'config.yaml' .
fd --no-ignore --type f --glob 'config.yaml' .
fd --no-ignore --type f --glob 'config.yaml' \
--exclude 'packages/payment v2/**' .第一条只关闭版本控制 ignore,.fdignore 仍会排除 legacy;第二条关闭全部 ignore 后,legacy 才出现;第三条证明 --exclude 即使与 --no-ignore 同时使用,仍会排除 payment 路径。若团队希望 rg 和 fd 共享排除,使用 .ignore;只服务 fd 的规则放 .fdignore。规则进入仓库评审,并用固定 fixture 验证,避免个人全局 ignore 让候选集漂移。
Git 元数据同样影响发现结果。把仓库内容复制到不含 .git 的导出目录后,.gitignore 未必仍按工作树规则生效;容器构建上下文、源码压缩包和 CI artifact 都可能出现这种差异。当前 fd 版本的完整帮助是判定入口:
fd --help | rg 'ignore|require-git'当工具提供 --no-require-git 时,它表示即使未识别到 Git 仓库也采用 Git ignore;团队应在目标版本线上验证后再写进脚本。没有这个能力或需要完全确定时,使用受控 --ignore-file/专用 .fdignore,并把导出目录作为测试场景。--no-ignore-vcs 只关闭版本控制 ignore,--no-ignore 会放宽更多 ignore 来源,两者不能混成同一个开关。
fd 10.0.0 还改变了一个容易沿用旧经验的边界:使用 --hidden 且仍启用 VCS ignore 时,不再自动把 .git 目录排除。只要规则没有另行忽略它,.git 就可能进入候选。仓库脚本启用 --hidden 时应显式加 --exclude .git,并用“应出现的隐藏配置”和“不得出现的 .git/objects”组成正反断言。
--hidden、--no-ignore 与 --unrestricted
fd --hidden --type f --glob 'config.yaml' .
fd --no-ignore --type f --glob 'config.yaml' .
fd --unrestricted --type f --glob 'config.yaml' .--hidden/-H 只纳入隐藏路径,仍尊重 ignore;--no-ignore/-I 关闭 ignore 过滤,仍不自动纳入隐藏路径;--unrestricted/-u 相当于同时放宽两者。它们不会自动变成“只看 Git 跟踪文件”。若候选必须严格等于索引内容,用 git ls-files -z 更准确;若需要工作树中未跟踪但未忽略的文件,使用 git ls-files -z --cached --others --exclude-standard。
这也是 fd 与 rg -u/-uu/-uuu 的重要差异:两个工具都叫 -u,层级语义并不相同。不要在共享脚本里凭简称迁移参数。
反向实验:换行分隔怎样悄悄破坏路径
空格路径在良好引用下还能幸存,文件名换行会直接破坏“每行一条”的假设:
printf 'mode: newline\n' > $'packages/payment v2/line\nbreak.yaml'
fd --type f --extension yaml . | while IFS= read -r path; do
printf '<%s>\n' "$path"
done预期这个文件被打印成两条伪路径,证明换行协议已经丢失记录边界。改成 NUL 后,每个路径仍是一个参数:
fd --type f --extension yaml --print0 . \
| while IFS= read -r -d '' path; do
printf '<%q>\n' "$path"
done在 GNU 工具链中也可以用 xargs -0,但修改类命令还应关闭并行或证明并行安全:
fd --type f --extension yaml --print0 . \
| xargs -0 -r -n 1 printf 'candidate=%s\n'-r 是 GNU xargs 的行为,BSD/macOS 兼容性不同。跨平台项目更稳妥的是在 Bash 循环或 Node/Python 小程序里按 NUL 读取,并把每条路径作为参数数组元素执行,绝不拼成 shell 字符串再 eval。
fzf 预览不是界面装饰,而是反复执行的命令
设置 --preview 后,fzf 会通过 shell 启动外部进程,并在焦点变化时重新执行;旧预览还可能在新预览开始时被终止。也就是说,按十次方向键可能启动十次命令,子进程不能把“被中断”误当成可以提交部分写入。预览必须满足四个条件:只读、有超时或输出上限、不访问敏感凭据、不把候选拼进未经引用的 shell 代码。
一个稳妥起点是只读取有限行数:
fd --type f --print0 . \
| fzf --read0 --print0 \
--preview 'sed -n "1,200p;200q" < {}' \
--preview-window 'right,60%,wrap'安装了 bat 时可使用 bat --color=always --style=numbers --line-range=:200 -- {}。不要把 --preview 放进全局 FZF_DEFAULT_OPTS:fzf 也会过滤进程、历史、主机名等任意文本,文件预览命令收到这些记录时会产生错误甚至意外访问。预览应跟候选生产命令成对配置。
以下做法危险,即使候选来自自己的仓库:
# 不要这样做
fzf --preview 'sh {}'
fzf --preview 'curl -X POST https://example.com/inspect -d @{}'
fzf --preview 'tool --token "$TOKEN" inspect {}'第一条执行候选文件,第二条在移动焦点时上传内容,第三条让子进程和错误日志接触凭据。预览还会形成容量放大:大文件高亮、Git diff、压缩包解压或网络查询随焦点快速重启。限制文件类型、预览行数和进程时长;远程查询先做本地缓存,敏感目录在 fd 阶段就排除。
取消选择必须是一个正常而明确的状态
fzf 正常确认退出 0,没有匹配退出 1,执行错误退出 2,Esc、Ctrl-C 或其他中断退出 130。包装脚本必须同时检查退出状态和输出数量。取消不是“选择了空路径”,更不是“退回当前目录”。
下面的 Bash 函数用临时文件保留 NUL 输出和 fzf 退出码。管道直接接 xargs 会丢失“用户取消”与“候选为空”的业务语义,因此先完成选择,再决定是否执行:
choose_one_file() {
local output status selected
output=$(mktemp)
if fd --type f --print0 . \
| fzf --read0 --print0 --no-multi \
--preview 'sed -n "1,120p;120q" < {}' >"$output"; then
status=0
else
status=$?
fi
if [ "$status" -eq 130 ]; then
echo '已取消,未执行任何动作' >&2
rm -f "$output"
return 130
fi
if [ "$status" -ne 0 ]; then
echo "选择器失败或没有候选,exit=$status" >&2
rm -f "$output"
return "$status"
fi
IFS= read -r -d '' selected <"$output" || {
echo '选择器成功退出但没有返回路径' >&2
rm -f "$output"
return 2
}
rm -f "$output"
printf '%s\0' "$selected"
}调用方也必须保留状态:
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
if choose_one_file >"$tmp"; then
IFS= read -r -d '' selected <"$tmp"
printf '准备打开:%q\n' "$selected"
"${EDITOR:-vi}" -- "$selected"
else
status=$?
[ "$status" -eq 130 ] && exit 0
exit "$status"
fi--no-multi 把输出数量约束为一个;-- 保护连字符开头的路径;编辑器只在确认成功后启动。自动化测试可用空输入验证非零分支,用向 fzf 进程发送中断验证 130 分支,并断言一个哨兵文件没有变化。不要加 --exit-0 或 || true 抹平状态,除非调用方已经用其他字段完整区分了取消、空候选和执行错误。
配置字段怎样改变运行边界
FZF_DEFAULT_COMMAND 只在 fzf 没有标准输入时提供候选生产命令;当 fd ... | fzf 已有管道输入,它不接管候选。FZF_DEFAULT_OPTS 会注入每次 fzf 调用,适合颜色、布局等纯界面参数,不适合预览、--multi、--bind execute(...) 或会改变退出语义的选项。
export FZF_DEFAULT_COMMAND='fd --type f --strip-cwd-prefix'
export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"
export FZF_DEFAULT_OPTS='--height=60% --layout=reverse --border'--multi 允许多选,会把输出从一个路径变成多个记录,下游必须逐条处理并定义部分失败语义;--select-1 在仅有一个候选时自动选择,减少交互却绕过人工确认;--exit-0 改变空结果退出行为,容易让包装脚本误判成功;--bind 'enter:execute(...)' 把执行权限放进选择器内部,绕过外层状态检查和审计。工程脚本优先让 fzf 只输出选择,把执行留在可测试函数中。
在 fzf 0.74.0 与 tmux 3.7+ 的组合里,--popup 默认使用可切换到其他 pane/window 的浮动 pane,而不是严格模态 popup;显式边框样式时才回到 popup。包装器不能把“fzf 仍显示在屏幕上”当作调用方被阻塞或用户仍在当前上下文的证据。真正的同步边界仍是 fzf 进程退出、状态码被检查、NUL 输出被完整读取。
fd 侧的 --max-depth 控制遍历深度,--type 控制对象类型,--extension 和 --glob 控制名称,--exclude 排除高成本或敏感路径,--follow 跟随符号链接。启用 --follow 前要处理环路、越界目录和重复文件;多数源码仓库保持默认不跟随更安全。
项目接入:让候选源成为仓库契约
团队可以把下面脚本保存为受评审的工具入口,由任务运行器调用。它优先使用 fd,在 Debian 风格环境回退到 fdfind,固定仓库根目录,并从发现阶段排除凭据和依赖缓存:
#!/usr/bin/env bash
set -euo pipefail
root=$(git rev-parse --show-toplevel)
if command -v fd >/dev/null 2>&1; then
FD=fd
elif command -v fdfind >/dev/null 2>&1; then
FD=fdfind
else
echo '缺少 fd/fdfind' >&2
exit 127
fi
command -v fzf >/dev/null 2>&1 || { echo '缺少 fzf' >&2; exit 127; }
cd "$root"
"$FD" --type f --print0 \
--exclude .git \
--exclude node_modules \
--exclude .env \
--exclude '*.pem' . \
| fzf --read0 --print0 --no-multi \
--preview 'sed -n "1,160p;160q" < {}'这个入口仍只负责选择。调用者若要格式化、删除、上传或修改文件,必须验证规范化路径仍位于 root 下,拒绝符号链接越界,记录工具版本和所选相对路径,并在执行前显示不可变动作摘要。批量动作使用 --multi 时,先冻结完整候选清单,再逐项产生 diff;任何一项失败都要定义继续、停止和回滚策略。
清理、回滚与失败证据
实验结束后离开目录并删除 fd-fzf-lab。若设置过环境变量,执行 unset FZF_DEFAULT_COMMAND FZF_CTRL_T_COMMAND FZF_DEFAULT_OPTS;PowerShell 使用 Remove-Item Env:FZF_DEFAULT_COMMAND,Env:FZF_CTRL_T_COMMAND,Env:FZF_DEFAULT_OPTS -ErrorAction SilentlyContinue。卸载使用原安装器,例如 winget uninstall sharkdp.fd、winget uninstall junegunn.fzf 或 brew uninstall fd fzf。
排障时按链路找第一处差异。fd 没输出,检查根目录、pattern 是正则还是 glob、对象类型、隐藏与 ignore;fd -HI 才出现时,分别用 -H 和 -I 定位是哪层过滤。fd 有候选而 fzf 无结果,清空查询并检查 --read0 是否与生产者 --print0 配对。预览报错而选择正常,检查 $SHELL、占位符引用、输入重定向和预览工具是否安装。按 Esc 后仍执行下游,检查包装脚本是否吞掉 130、是否把空字符串替换为 .。路径被拆分,检查整条链是否从生产到消费都保持 NUL,而不是中途进入 shell 变量或逐行文本。
回滚不只是删除配置。若曾把危险 execute 绑定写入 FZF_DEFAULT_OPTS,先从 shell 启动文件移除并开启新 shell;若预览访问了网络或凭据,撤销相关 Token、检查服务端审计记录和终端历史;若下游命令已经修改仓库,先审查 git diff,只回退这次选择产生的文件,不能用破坏性命令覆盖其他人的工作树。
权限、敏感数据、容量与成本
两个工具都以当前进程权限运行。fd --hidden --no-ignore --follow 可以把搜索面扩到密钥、依赖缓存、挂载目录和仓库外符号链接;fzf 预览又会把内容显示在屏幕、录屏、终端日志或远程会话中。企业脚本应使用最小仓库根目录,默认排除密钥扩展名与配置目录,禁用预览中的网络和写操作,并为共享终端、CI artifact 与录屏设置数据分级规则。
容量瓶颈有三处:fd 的目录遍历和元数据读取、fzf 保存与排序候选的内存、预览进程随焦点变化的重复启动。候选达到大型 monorepo 规模时,先用 --type、--extension、--max-depth 和 --exclude 收敛,不要把整个磁盘送进 fzf;预览限制行数和文件大小。性能测量要同时保存候选数、冷/热缓存条件、首屏延迟、峰值内存和预览启动次数,否则“更快”可能只是少遍历了本应命中的路径。性能门槛由代表性仓库基线、交互延迟预算和开发机配置决定,不能用一个固定文件数承诺所有团队。
软件本身没有集中式服务成本,治理成本主要是跨平台版本、shell 差异、脚本维护和误操作恢复。团队应保留包含空格、换行、隐藏、ignore 冲突、连字符开头和取消选择的 fixture;升级 fd、fzf 或 shell 后自动回归。配置 owner 评审 .ignore/.fdignore 与全局环境变量,任何 execute 绑定、符号链接跟随或多选写操作都需要单独安全审查。
选型时看候选从哪里来、最后要做什么
按路径名、扩展名、类型和深度发现本地文件时选 fd;已有任意文本候选、需要人快速收敛时选 fzf;两者组合适合交互导航、挑选测试、打开配置和生成只读候选清单。候选必须严格等于 Git 索引时,用 git ls-files -z 取代 fd;需要搜索文件正文时用 rg;需要符号身份和调用关系时用 LSP;无人值守批处理则通常不应依赖交互式 fzf,而应使用可审计的确定性清单。
成熟的组合不是一条炫目的管道,而是一份状态契约:生产者决定哪些路径有资格进入,NUL 保证路径身份不被文本分隔破坏,预览只帮助判断,fzf 的退出状态表达确认或取消,外层程序最后才获得执行权。只要其中一层含糊,模糊查找就会把效率问题升级成权限和数据问题。
出现版本差异时,先以目标二进制的 fd --help、fzf --man 为字段证据,再查 fd Releases、fzf Releases 与 fzf 官方使用说明。帮助文本决定当前机器究竟支持哪些参数,发布说明用于发现行为变化;团队脚本不能因上游文档已经更新,就假定发行版仓库里的旧二进制也有同样语义。
