lint-staged:只对 Git 暂存快照运行任务
lint-staged 的输入不是“当前目录里改过的文件”,而是 Git index 中准备进入下一次提交的路径。它选择匹配文件、运行格式化或 Lint、把任务产生的修改更新回 index,并保护同一文件尚未暂存的片段。这个过程会同时触碰工作区、index 和临时备份,配置错误时可能把未完成代码带进提交,所以它不是一段随手复制的 glob 配置。
本文以 lint-staged 17.3.0 为实验坐标。该版本要求 Node >=22.22.1;升级前必须先核对开发机、GUI、CI 和构建镜像的 Node 基线,不能只更新 lockfile。
安装后先观察真实版本
在有锁文件的 Node 仓库中精确安装:
node --version
npm install --save-dev --save-exact lint-staged@17.3.0
npx lint-staged --version
npm ls lint-stagedlint-staged 通常由 Husky、pre-commit 或其他原生 Hook 调用,但它不负责安装 Hook。先在命令行独立验证,再接入触发器,可以把文件选择与 Hook 路径问题分开。
配置要避免并发改写同一文件
JS 配置便于表达互斥 glob 和串行任务:
// lint-staged.config.mjs
export default {
'!(*.{js,jsx,ts,tsx})': 'prettier --write --ignore-unknown',
'*.{js,jsx,ts,tsx}': ['eslint --fix', 'prettier --write'],
}lint-staged 会把匹配路径追加到命令。数组中的任务串行执行,不同 glob 默认可以并行。如果两个可写任务通过重叠 glob 同时处理一个文件,结果可能随调度变化。优先让 glob 互斥,再用数组固定同一文件的写入顺序;诊断时可用 --concurrent false --debug 验证并发是否是根因。
任务必须使用传入文件。某个脚本若忽略参数并执行 eslint . --fix 或全仓格式化,就会越过 lint-staged 的输入边界,修改未暂存文件。不要在任务末尾执行 git add;lint-staged 会处理任务修改,额外 Git 写操作可能争抢 index lock 或把无关工作带入暂存区。
部分暂存是必须验收的状态
同一文件可能一部分已暂存、另一部分仍在工作区。lint-staged 默认隐藏 partially staged 文件中的未暂存片段,运行任务后再恢复。下面的临时仓库实验可以观察三层状态:
set -euo pipefail
LAB_DIR="$(mktemp -d)"
cd "$LAB_DIR"
git init -q
git config user.name "Index Fixture"
git config user.email "fixture@example.invalid"
npm init -y >/dev/null
npm install --save-dev --save-exact lint-staged@17.3.0 prettier
cat > lint-staged.config.mjs <<'JS'
export default { '*.js': 'prettier --write' }
JS
printf 'const staged = 0;\nconst kept = 1;\n' > demo.js
git add package.json package-lock.json lint-staged.config.mjs demo.js
git commit -qm initial
printf 'const staged = 1; \nconst kept = 1;\n' > demo.js
git add demo.js
printf 'const staged = 1; \nconst kept = 2;\n' > demo.js
npx lint-staged --debug
git show :demo.js
git diff -- demo.js
git diff --staged -- demo.js预期 index 中 staged 已被格式化而 kept 仍为 1,工作区继续保留 kept = 2。若 index 出现 kept = 2,检查任务是否扫描全目录、是否执行额外 git add,以及是否启用了 --no-hide-partially-staged。这个选项会允许任务看到未暂存片段,必须把潜在混入风险写进调用合同。
自动修复和只读裁决应分开
本地提交前适合运行快速、确定的自动修复,但开发者仍需看最终 staged diff。希望任务修改文件后先停下来人工确认,可以评估 --fail-on-changes:发现任务产生改动时返回非零,并保留修改供重新暂存。
CI 通常不运行 lint-staged,因为干净 checkout 没有开发机意义上的 index 选择。服务端直接运行 eslint .、prettier --check . 或项目全量脚本,复用同一版本与规则源。lint-staged 优化反馈范围,不能成为唯一门禁。
备份恢复不是绝对保险
lint-staged 默认创建备份 stash,并在任务失败时恢复。进程被强杀、磁盘满、Git 锁冲突或自定义任务再次操作 stash 时,恢复可能中断。失败后先停止其他 Git 写操作并保存证据:
git status --short
git diff
git diff --staged
git stash list --format="%h %gd %s"只有确认某个 hash 对应本次 lint-staged 自动备份后,才考虑用 git apply --index <verified-stash-hash> 恢复。不要盲目应用最新 stash;IDE、开发者和其他工具都可能创建条目。发现 .git/index.lock 时先确认没有活跃 Git 进程,直接删锁可能破坏正在进行的写入。
关闭备份或隐藏机制的选项会改变数据安全边界。使用 --no-stash、--no-hide-partially-staged 或 --hide-unstaged 前,要用部分暂存、删除、重命名和任务失败样例验证实际状态,不应为了少一次 stash 就全局开启。
文件名、参数长度与 shell
lint-staged 负责把文件路径作为参数传给任务,但自定义函数如果自行拼接字符串,就必须处理空格、引号、换行与平台差异。不要把路径直接插入未经转义的 shell 命令。大规模重构还可能触发命令行长度限制,尤其在 Windows 和容器 shell 中;观察 debug 输出中的分块,而不是假设一次命令总能容纳全部路径。
没有匹配文件属于 skipped,不是规则 passed。监控要区分无输入、规则失败、工具故障和成功。任务错误被 || true 吞掉后,lint-staged 只能看到绿色,无法替包装脚本恢复语义。
Monorepo 使用最近配置
lint-staged 会为每个暂存文件选择距离它最近的配置,配置之间不会自动合并。子包配置只写 TypeScript glob 时,该包的 Markdown 文件不会自动回退到根配置。公共规则应由 JS 模块显式导入:
// packages/web/lint-staged.config.mjs
import base from '../../lint-staged.base.mjs'
export default {
...base,
'*.{ts,tsx}': ['eslint --fix', 'prettier --write'],
}根目录文件、每个 package、跨包重命名、删除文件和超长路径集合都需要样例。Monorepo 通常只有一个根 Hook 调用 lint-staged,各包只负责自己的配置与脚本;多个 Husky 实例争抢 Hook 路径不属于 lint-staged 的职责。
安全与成本边界
lint-staged 执行仓库配置中的命令,并继承开发者权限。拉取不受信任仓库后,不应自动安装依赖和运行 Hook。任务不应获取生产 Token、发布密钥或云写权限;私有依赖凭证要限制作用域,Fork 和外部贡献路径不得可见。
性能要记录匹配文件数、任务 P50/P95、无匹配比例、失败类型和绕过原因,不记录源码。持续缓慢的全仓任务应改成真正消费文件参数的增量任务,或移到 CI。缓存可以提速,但若不同任务并行写同一缓存,也可能制造偶发失败。
升级、故障与回滚
升级先核对 Node engines,再用干净、普通暂存、部分暂存、任务修改、任务失败、重命名和删除样例跑新旧版本。比较 index、工作区、stash、退出码和 debug 输出,而不只比较最终格式。
结果偶发不同先关闭并发;未暂存代码进入 index,检查全仓任务、额外 git add 和隐藏选项;失败后工作丢失,保留三层 diff 与 stash 列表再恢复;GUI 中命令缺失,比较 PATH 和 Node 版本。回滚恢复上一版 package.json、lockfile 和配置,并重跑部分暂存实验。禁用备份、改成全仓 git add -A 或从 Hook 删除 lint-staged 都不是安全回滚。
