pre-commit:跨语言 Git Hook 的版本化执行面
pre-commit 解决的不是某一种语言如何 Lint,而是如何把不同来源、不同运行时的检查器固定到一份仓库配置中,并在 Git Hook、手工检查和 CI 之间复用。它会拉取 Hook 仓库、按 rev 创建环境并把调度入口安装到当前 clone,因此既是开发效率工具,也是一个会执行第三方代码的供应链入口。
本文以 pre-commit 4.6.2 为实验坐标。工具版本、Hook revision、语言运行时和仓库配置共同决定结果;只固定其中一个,仍不能得到可复现门禁。
安装到受控 Python 环境
项目可以用独立虚拟环境、pipx 或受控开发镜像分发 CLI。下面的方式不会污染系统 Python:
python3 -m venv .tools/pre-commit
. .tools/pre-commit/bin/activate
python -m pip install 'pre-commit==4.6.2'
test "$(pre-commit --version)" = "pre-commit 4.6.2"生产 CI 应通过带哈希的 requirements 或固定镜像 digest 安装。pre-commit 后续还会为 Hook 创建 Python、Node、Go 等环境,内网使用前要准备代理、CA、镜像与缓存,不能等到开发者第一次提交才发现依赖无法下载。
配置的核心是固定可执行代码
仓库根目录的 .pre-commit-config.yaml 同时描述代码来源、版本、Hook、输入范围与执行方式:
default_install_hook_types: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
files: ^(src|tests)/
- id: end-of-file-fixer
exclude: ^vendor/
- repo: local
hooks:
- id: project-lint
name: project lint
entry: npm run lint:files --
language: system
files: \.(js|jsx|ts|tsx)$
require_serial: truerev 不能写 main、master 或浮动标签。它是第三方 Hook 代码的供应链锚点,也会参与环境缓存。files 与 exclude 使用正则筛选文件,范围过窄会静默漏检,范围过宽会让本机反馈失去速度优势。每次调整都要准备应命中和不应命中的路径样本。
language 决定环境由 pre-commit 构建还是复用系统工具。language: system 适合已经由项目锁文件管理的命令,但必须由项目自己保证 Node、Java 或其他运行时一致。require_serial 适合争用缓存、输出目录或共享状态的任务;不应把所有 Hook 都串行化来掩盖设计问题。
安装 Hook 与预热环境是两件事
配置进入 Git 并不会修改另一个 clone 的 .git/hooks。每次 clone 都要执行安装:
pre-commit validate-config
pre-commit install --install-hooks
pre-commit run --all-filesinstall 写入 Git Hook 调度脚本,--install-hooks 同时准备环境。把这一步放进明确的开发初始化命令,可以避免第一次提交突然联网。初始化完成后必须用一个已知违规样例证明 Hook 真会阻止提交;只检查文件存在或安装命令返回 0 不够。
Git 支持多个 Hook stage。昂贵检查可以设置 stages: [manual],由 pre-commit run --hook-stage manual <id> 触发;提交消息检查则属于其他 stage。团队需要写清每个 Hook 在哪个事件运行,不能因为配置中存在就假设所有提交路径都覆盖。
正反样例要验证筛选与退出码
一个最小实验应在临时 Git 仓库中放入合格文件、明确违规文件和被排除文件,然后分别运行单个 Hook与全量入口:
pre-commit validate-config
pre-commit run trailing-whitespace --all-files
pre-commit run --all-files --show-diff-on-failure自动修复型 Hook 会改变工作区,运行前后要查看 git diff 与 git diff --staged。pre-commit 可能在存在未暂存改动时暂存和恢复状态,但进程中断、磁盘满或其他 Git 写操作仍可能导致恢复失败。重要工作不能只依赖自动 stash,实验应放在可丢弃仓库或先保存补丁。
CI 复用规则,不伪造开发机
CI 的输入是干净 checkout,不需要临时 git add 模拟本地 index。PR 可以检查明确的 base/head 区间,默认分支或周期任务做全量复核:
pre-commit run --from-ref "$BASE_SHA" --to-ref "$HEAD_SHA"
pre-commit run --all-files浅克隆可能没有 base commit。流水线应先用 git cat-file -e "$BASE_SHA^{commit}" 证明对象存在,缺失时补齐历史或明确失败,不能悄悄变成空范围。CI 还要固定 pre-commit 版本并使用相同配置;共享缓存只能提速,不是规则或依赖的版本真相源。
Hook 更新要作为依赖升级评审
pre-commit autoupdate 能修改 revision,但自动生成的 diff 不是批准。升级 PR 要核对上游 tag 和变更、环境依赖、文件范围、正反 fixture、自动修复 diff、执行时间和 CI 结果。Hook 仓库被改名、tag 被替换或依赖下载源变化时,也要按供应链事件处理。
缓存包含可执行环境。共享 Runner 上要隔离项目、限制写权限、设置容量与清理周期,不应允许低信任任务向高信任分支复用可写缓存。诊断缓存问题可以清理后重建,但不能把全局删除缓存作为日常修复。
跳过是显式例外
pre-commit 支持用 SKIP=hook-id 跳过指定 Hook,也可以通过 Git --no-verify 绕过客户端执行。这些能力对工具故障和紧急处置有用,却说明本地 Hook 永远不是强制边界。
受控跳过需要记录规则 ID、变更、原因、批准、补检命令、结果和到期时间。CI 仍运行不可绕过的对应规则。把 SKIP 固定在团队 shell 配置、机器人环境或流水线中,会让配置表面存在、实际长期不执行。
故障证据从环境日志开始
首次运行离线失败,先看 Hook 环境是否预热、代理和 CA 是否可用。某类文件从不执行,核对 files、exclude、types 和 stage。不同机器结论不一致,比较 pre-commit 版本、配置摘要、Hook rev、语言运行时和系统工具版本。任务没有匹配文件时的 skipped 与规则通过不是同一状态,监控不能把两者合并。
自动修复失败后先保存 git status --short、工作区 diff、暂存 diff 和 pre-commit 日志,再处理恢复。不要使用 git reset --hard 清理现场。Hook 本身不应持有生产凭据;私有依赖只提供只读、短期且限制域名的凭证,Fork 与外部贡献路径不得获得它。
卸载与回滚
pre-commit 提供对当前 clone 的卸载命令:
pre-commit uninstall
git config --get core.hooksPath || true回滚升级时恢复上一版 CLI 约束、.pre-commit-config.yaml 和必要的语言锁文件,再用原正反样例复核。删除配置、扩大 exclude 或长期设置 SKIP 只是关闭门禁。卸载后若由另一套工具接管 Hook,要用失败样例证明替代链路已经工作,避免仓库进入静默无 Hook 状态。
