prek Hook Groups 实战指南:用--group打造自定义 Git Hook 运行画像
【免费下载链接】prek⚡ A fast Git hook manager written in Rust, designed as a drop-in alternative to pre-commit, reimagined.项目地址: https://gitcode.com/GitHub_Trending/pr/prek
导读
prek 是一个用 Rust 编写、可平替 pre-commit 的快速 Git Hook 管理器。本文围绕其特有的Hook Groups(钩子分组)机制展开:通过给 Hook 打上自定义标签,并借助prek run的--group、--require-group、--no-group三个可重复选项,你可以按 CI、Agent、发布等任意执行画像来挑选要运行的 Hook,而无需改变 Git Hook stage 语义。读完本文,你将掌握groups字段的配置规范、三过滤器组合逻辑、与 stage 的交互规则、工作区行为及全部边界情况,并深入理解其在当前仓库中的源码实现。
该机制的完整设计源自 docs/proposals/hook-groups.md,且功能已在当前仓库中落地实现——从配置解析、CLI 参数到选择器与运行管线均有对应源码,本文会逐层给出佐证。
为什么需要 Hook Groups
prek run --all-files已经能很好地覆盖 CI、Agent 工作流以及各类显式命令行场景。但现实中的项目往往需要只运行项目自定义的 Hook 子集:
- CI 只跑 lint 与格式化 Hook,而把测试交给独立的 job;
- CI 把检查类与格式化类 Hook 拆分到不同 job;
- Agent 在提交前运行慢速类型检查器,而人类贡献者不应在每次提交时被这些慢 Hook 阻塞;
- 某些 Hook 本地默认不启用,但要在特定的 MR 或发布流程中开启;
- 某些 Hook 依赖大型本地工具链或用户刻意回避的生态,需要从本地运行中排除。
提案明确指出:stages描述的是Git Hook 上下文,而上述场景描述的是任意执行画像(execution profile)。用 stage 近似实现(例如给 Hook 加manual再用prek run --stage manual)既令人困惑,又会让调用方反复与 Git 阶段语义纠缠。若为ci、docker、release、agent等逐一新增专用 stage,则会重复同样的问题并需要无限扩张词汇表。因此设计目标是:增加一个轻量的 Hook 级标签机制,让用户自己定义运行画像,同时完全不改变 Git Hook stage 语义。
配置:Hook 级别的groups字段
YAML(.pre-commit-config.yaml)
groups是项目 Hook 配置中新增的可选字段:
repos: - repo: local hooks: - id: format name: Format Python entry: ruff format language: system groups: ["format", "ci"] - id: lint name: Lint Python entry: ruff check language: system groups: ["lint", "ci"] - id: typecheck name: Typecheck Python entry: pyright language: system groups: ["slow", "agent"]TOML(prek.toml)
prek.toml使用完全相同的字段名:
[[repos]] repo = "local" hooks = [ { id = "format", name = "Format Python", entry = "ruff format", language = "system", groups = ["format", "ci"], }, ]字段属性一览
| 属性 | 值 | | -- | -- | | 类型 | 字符串列表 | | 默认值 | 空列表 | | 作用域 | 仅限项目配置文件(project configuration) | | 匹配方式 | 精确匹配、大小写敏感 | | 命名约束 | 非空字符串、不含空白字符、不以@开头 |
prek 专属字段:不进入远程 Hook manifest
groups是prek 独有字段,不属于上游 pre-commit,也不应被当作远程 Hook manifest 的元数据。分组描述的是"当前项目希望如何运行 Hook",而非某个 Hook 仓库的内在属性。如果远程 manifest(.pre-commit-hooks.yaml)中出现了groups,prek 会警告并忽略该字段,与它处理priority等其他仅配置生效字段的方式一致。
这一行为在源码中有明确体现:远程 manifest 的解析类型是ManifestHook(见 crates/prek/src/config/hook.rs),它只包含id、name、entry、language与options,没有groups字段;而项目配置侧的RemoteHook与LocalHook才声明groups(crates/prek/src/config/hook.rs)。HookWire中通过#[serde(flatten)] _unused_keys收集未知键(crates/prek/src/config/hook.rs),保证多余字段不会导致解析崩溃,而是被安全地忽略。
名称校验:解析期即拦截非法值
groups的反序列化走deserialize_groups(crates/prek/src/config/priority.rs),它在配置解析阶段就对每个名称调用validate_group_name(crates/prek/src/config/priority.rs),校验规则为:非空、不含任意空白字符(char::is_whitespace)、不以@开头。对应测试hook_groups_reject_invalid_names_during_deserialize位于 crates/prek/src/config/mod.rs,用于逐条验证非法名称在反序列化时被拒绝。
CLI:三个可重复的过滤器
prek run新增三个可重复选项:
prek run --group <name> prek run --require-group <name> prek run --no-group <name>各自的语义
--group <name>:包含过滤器(并集)。指定一个或多个分组时,Hook 只要groups中包含任意一个被请求的分组即被选中。--require-group <name>:交集过滤器。Hook 只有在groups中同时包含每一个被要求的分组时才被选中。--no-group <name>:排除过滤器。Hook 的groups中只要包含任意一个被排除的分组即被移除。
特殊选择器@ungrouped
@ungrouped是保留的特殊选择器,匹配有效groups列表为空的 Hook。它像普通分组成员一样可与三个选项组合使用,但不能配置在 Hook 的groups列表中(名称校验的@前缀保留规则正是为此服务)。
组合规则:顺序无关,排除优先
三个选项独立组合,与参数顺序无关,且排除优先:
- 若提供了任意
--group,先选出与这些分组匹配的 Hook; - 若提供了任意
--require-group,仅保留与每一个必选分组都匹配的 Hook; - 若提供了任意
--no-group,移除与任意排除分组匹配的 Hook。
等价地,一个 Hook 必须同时满足:
(如果指定了 --group:命中任意一个) AND (命中每一个 --require-group) AND (不命中任何 --no-group)常用示例
prek run --all-files --group ci prek run --all-files --group lint --group typecheck prek run --all-files --require-group lint --require-group fast prek run --all-files --no-group format prek run --all-files --group ci --require-group lint --no-group slow prek run --all-files --group ci --group @ungrouped prek run --all-files --group ci --stage pre-push组合示例:fast AND (format OR lint-only)
考虑如下 Hook 分组:
| Hook | Groups | | -- | -- | |ty|lint-only,fast,local| |ruff-format|format,fast,local| |mypy|lint-only,slow,ci| |black|format,slow,ci|
prek run --all-files --require-group fast --group format --group lint-only这表示fast AND (format OR lint-only),因此恰好选中ty与ruff-format。调整选项顺序不会改变选择结果。
源码实现:GroupFilters
以上语义在 crates/prek/src/cli/run/selector.rs 的GroupFilters中实现:内部维护include_any、require_all、exclude_any三个GroupSelector列表;GroupSelector是Named(String)与Ungrouped的枚举,UNGROUPED_GROUP = "@ungrouped"(crates/prek/src/cli/run/selector.rs)。matches_groups的实现严格遵循"排除优先、include 任意、require 全部"的逻辑,并对每次命中记录 usage 以便后续报告未匹配的过滤器(crates/prek/src/cli/run/selector.rs)。
CLI 参数在RunArgs中定义(crates/prek/src/cli/mod.rs):groups、required_groups、no_groups三个Vec<String>分别对应--group、--require-group、--no-group,均为可重复参数,并归入 "Hook selection" 帮助分组。注意prek run --stage的帮助文档也明确写道:不指定 stage 且无 group 过滤器时,默认先取pre-commit阶段 Hook,若命中了命令指定的 Hook ID 再回退匹配manual;而使用任一 group 选项时省略 stage 则允许任意阶段匹配。
选择模型:过滤器的作用顺序
分组过滤与现有的项目/Hook 选择器叠加生效,有效选择顺序如下:
- 从选中的项目加载 Hook;
- 应用位置参数的 Hook 或项目包含选择;
- 应用
--skip选择器与 skip 环境变量; - 应用分组的包含(include)、交集(require)与排除(exclude)过滤;
- 若提供了显式
--stage,应用 stage 过滤; - 若既无 group 过滤器也无显式
--stage,沿用现有默认pre-commitstage 过滤与 Hook 目标的manual回退; - 应用现有的文件匹配与运行输入逻辑。
例如:
prek run frontend/ --group ci --no-group slow会运行frontend/目录下所有标记了ci、但未标记slow的 Hook。
在 crates/prek/src/cli/run/run.rs 中可以看到该顺序的落地:先解析GroupFilters,再在 Hook 初始化后依次经过selectors.select_hook(include/skip)与group_filters.matches_hook(分组过滤)双重要求才进入selected_hooks。
被分组过滤排除的 Hook 绝不会被安装或执行——这一点对依赖大型工具链、本地不支持的依赖或用户刻意回避的生态的 Hook 尤其重要。从源码看,分组过滤甚至在克隆远程仓库之前就生效:HookInitFilters::keeps_configured_hook与keeps_remote_repo会基于配置信息先行排除不可能匹配的远程仓库(crates/prek/src/workspace.rs),ProjectInitPlan::new据此跳过整个仓库配置(crates/prek/src/workspace.rs)。也就是说,--no-group排除的 Hook 不仅不会执行,连其语言环境都不会被准备、安装或克隆。
与 Git Hook stage 的交互
分组与 Git Hook stage 相互独立,这也是整个设计最核心的部分。
未使用 group 选项时:行为不变
不使用--group、--require-group、--no-group时,现有 stage 行为完全不变:省略--stage/--hook-stage时先选择pre-commit阶段符合条件的 Hook;若没有选中且命令点名了 Hook ID,则用相同 ID 再匹配配置为manual的 Hook。
使用 group 选项且未显式指定 stage:进入分组选择模式
一旦使用了任一 group 选项而未显式给出--stage,prek run进入分组选择模式:
- 任意配置阶段的 Hook 都可能被匹配;
- 不再执行针对
manual的第二次点名匹配; - 文件输入按普通手动
prek run文件模式收集:显式--files/--directory、--all-files、合并冲突文件或暂存文件; - 仅配置了
commit-msg和/或prepare-commit-msg的 Hook 无法在此模式下运行——因为它们需要 Git 的消息文件参数,会被过滤掉。
如果消息文件过滤把分组过滤匹配到的 Hook 全部移除,prek run应警告并失败而不是静默成功。源码中的infer_stage_and_input_mode(crates/prek/src/cli/run/run.rs)正是实现:存在 group 过滤器且无显式 stage 时返回(None, RunInputMode::Files);随后uses_only_message_file_input(crates/prek/src/cli/run/run.rs)配合stage_uses_message_file_input(crates/prek/src/cli/filter.rs 中的stage_uses_message_file_input,实际定义于 crates/prek/src/cli/run/filter.rs)判断Stage::CommitMsg | Stage::PrepareCommitMsg。若最终为空,会输出警告 "all hooks selected by group filters requirecommit-msgorprepare-commit-msgstage ..." 并返回失败(crates/prek/src/cli/run/run.rs)。
其原理在于:手动prek run --group ...没有 Git Hook payload。所有非消息文件阶段都可以按各 Hook 自身的过滤规则与pass_filenames设置,用文件输入或无文件名执行;只有消息文件阶段需要无法推断的输入,因此在无 stage 的分组运行中不被选中。
分组 + 显式 stage:按交集组合
分组选择器与显式 stage 组合时按交集过滤:
prek run --group ci --stage pre-push该命令只运行同时满足"标记了ci"与"适用于pre-push"的 Hook。等价的--hook-stage拼写行为相同。
这套设计让基于分组的 CI 用法保持简洁——prek run --group ci不需要用户给每个 CI Hook 添加manual;同时,当用户确实想将分组收窄到真实 Git Hook 上下文时也完全可行。
运行时语义:分组只决定"谁能跑"
分组只决定哪些 Hook 具备运行资格,之后的既有 Hook 行为完全不变:
files、exclude、types、types_or、exclude_types仍负责过滤文件;always_run仅在 Hook 通过分组过滤后才生效;pass_filenames: false仍是 Hook 执行设置,而非分组选择设置;priority继续调度剩余的 Hook;fail_fast、require_serial、diff 检测、修改文件报告、输出处理与 Hook 结果语义均不变;- 语言支持检查仍适用于被选中的 Hook;
- 既有的 Hook 缓存与安装行为只考虑通过分组过滤的 Hook。
如果分组过滤后没有任何 Hook 可运行,prek run应报告"没有 Hook 匹配请求的选择器"并返回失败——与显式选择器写错时的行为一致。运行管线中select_runnable_env_hooks(crates/prek/src/cli/run/run.rs)也印证了这一点:只有既通过分组过滤、又有匹配文件(或always_run)的 Hook 才会进入环境安装阶段。
工作区行为
在 workspace 模式下,分组名是跨所有选中项目应用的 CLI 选择器:
prek run --group ci选中每个选中项目中所有标记ci的 Hook;- 项目选择器可以先用项目前缀收窄 workspace 范围,再执行分组过滤;
- 分组名无需全局声明;
- 分组名不跨项目配置文件协调调度。
分组匹配按 Hook 逐一评估。没有匹配 Hook 的项目会直接退出本次运行。源码中run对 workspace 内每个项目独立执行init_hooks并传递同一组GroupFilters(crates/prek/src/cli/run/run.rs),随后统一经过selectors.select_hook与group_filters.matches_hook双重过滤。
边界情况全览
未分组 Hook(ungrouped hooks)
省略groups或groups: []的 Hook 视为未分组:
- 未传任何 group 选项时,未分组 Hook 照常运行(行为与今天一致);
- 传了
--group <name>时,未分组 Hook 不匹配、不运行;--group @ungrouped可显式包含它们; - 传了
--require-group <name>时,未分组 Hook 同样不匹配; - 只传
--no-group <name>时,未分组 Hook 仍被选中,因为它们不属于被排除的分组。
@ungrouped也与其他过滤器配合:--require-group @ungrouped只保留未分组 Hook,--no-group @ungrouped则移除它们。在源码中,matches_hook对GroupSelector::Ungrouped的判定是hook.groups.is_empty()(crates/prek/src/cli/run/selector.rs)。
多分组 Hook
一个 Hook 可以属于多个分组:
- id: ruff groups: ["lint", "python", "ci"]它命中任意 include 分组、必须命中全部 required 分组、被任意 exclude 分组排除:
--group lint选中它;--require-group lint --require-group ci选中它;--require-group lint --require-group format不选中它;--group ci --no-group python排除它;--no-group format不排除它。
重复分组名
同一 Hook 上的重复分组名按单个分组成员关系处理,实现可在解析或匹配阶段去重。这也与运行时表示一致:Hook中的groups字段是BTreeSet<String>(crates/prek/src/hook.rs),构建时通过collect::<BTreeSet<_>>()天然去重(crates/prek/src/hook.rs)。例如groups: ["ci", "ci"]等价于groups: ["ci"]。
非法分组名
分组名必须是非空字符串、不含空白字符、不以@开头。名称按原样精确匹配,实现不应在验证前对名称做 trim 或归一化。@命名空间保留给特殊 CLI 选择器。
非法示例:
groups: ["", "ci slow", " ci", "ci\nslow", "@custom"]除保留的@命名空间外,不应强制任何固定词汇表。ci、agent、slow、format、lint都只是示例,不是保留名称。项目配置的 schema 正则^[^@\s]\S*$(见 crates/prek/src/config/hook.rs)正是这一约束的机器可读表达。
大小写敏感
分组匹配大小写敏感,CI与ci是不同分组。这避免了平台相关的归一化差异,也与大多数既有配置键/值的匹配行为一致。
未知 CLI 分组
- 如果某个分组选择器没有匹配到任何 Hook,运行应失败,报错方式与未匹配的 Hook 选择器一致;
- 如果请求了多个分组且至少一个有匹配,未匹配的分组名应产生警告而非让整个运行失败,这与现有选择器报告风格一致。
例如:
prek run --group ci --group does-not-exist应运行ci分组的 Hook,并警告does-not-exist未匹配任何 Hook。GroupFilters::report_unused(crates/prek/src/cli/run/selector.rs)通过FilterUsage记录每个 include/require/exclude 是否被实际使用,未使用的以warn_user!输出(单个时)或列表形式(多个时)。
Skip 选择器
--skip持续生效,即使 Hook 匹配了分组也会被移除:
prek run --group ci --skip ruffruffHook 会被跳过。
环境变量 Skip
既有的 skip 环境变量继续生效,且本提案不为其新增分组语法。是否需要PREK_GROUP、PREK_NO_GROUP之类的环境级分组选择,可另行讨论(详见 crates/prek-consts/src/env_vars.rs 中既有 skip 变量如SKIP、PREK_SKIP的读取逻辑 crates/prek/src/cli/run/selector.rs)。
本地与远程 Hook
当项目配置可以附加 Hook 选项时,分组同等地适用于本地、远程、meta 与 builtin Hook。远程 Hook manifest 不应定义默认分组——远程仓库不知道消费项目的 CI、Agent 或本地工作流策略。若 manifest 中出现groups,该字段被忽略并给出警告(与"prek 专属字段"一节的源码证据一致)。
优先级调度
groups是选择器,不是调度组。分组过滤之后,剩余 Hook 按既有priority语义调度;同priority的 Hook 仍可并行运行。属于同一groups值的 Hook 之间不产生任何顺序或并发关系。运行期的调度在 crates/prek/src/cli/run/run.rs 中体现:hooks.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.idx.cmp(&b.idx))),随后按 priority 分组并发执行,与 groups 无关。相关文档也特别注明 "Priority aliases are not hook groups"(见 docs/reference/configuration.md)。
修改文件(modified files)
分组选择不改变修改文件检测。若被选中的 Hook 修改了文件,既有失败与 diff 报告行为照常生效。若 Hook 被分组过滤排除,它不可能产生文件修改,prek 也不应安装或执行它(与"被排除即不安装"一致)。
try-repo 不接受分组选项
prek try-repo不应接受--group、--require-group、--no-group。因为try-repo从远程 Hook manifest 构建临时项目配置,生成的配置只含 Hook ID,没有项目本地的groups元数据——接受分组过滤器会让命令看似支持分组选择,而每个生成的 Hook 实际都是未分组的。因此这些标志应由 CLI 直接拒绝(TryRepoArgs定义于 crates/prek/src/cli/mod.rs,不含任何 group 参数)。
list 命令
prek list接受与prek run相同的--group、--require-group、--no-group过滤器,让用户在不运行 Hook 的情况下预览某分组表达式会选中哪些 Hook。其实现复用同一GroupFilters(crates/prek/src/cli/list.rs),并对selectors、group_filters、stage、language做联合过滤。该功能不向--output-format=json添加分组元数据,暴露该数据是另一项独立变更(SerializableHook结构见 crates/prek/src/cli/list.rs,目前不包含 groups 字段)。
安装行为:不持久化分组
本提案不引入prek install --group。把分组选择持久化到已安装的 Git shim 中,会让"普通 Git Hook 到底跑什么"难以审视。已安装 Hook 应继续使用 stage 语义。希望按画像执行的用户可以在 CI、Agent 工作流或贡献者文档中显式调用prek run --group ...。未来的提案可以讨论安装期分组或默认分组,但那会改变默认运行的含义,应单独设计。
非目标(Non-goals)
本提案不新增:
- 新的
cistage; - 调度器或依赖组(
priority仍是唯一调度机制); - DAG 调度或
after依赖; --output-format=grouped;- 默认禁用(default-disabled)的 Hook;
- 用于分组选择的环境变量;
prek install --group;prek try-repo --group、prek try-repo --require-group、prek try-repo --no-group;- 全局分组声明,或针对根级列表的校验。
向后兼容性
- 不使用
groups、--group、--require-group、--no-group时,行为完全不变; - 既有
stages行为在普通prek run与已安装 Git Hook 执行中不变;唯一新增行为是显式分组选择模式,以及该模式与显式 stage 求交的能力; - 以
@开头的分组名被拒绝,防止特殊选择器与配置的分组成员冲突; - 现有配置中未知的
groups键目前会被忽略;本提案实现后该键开始有意义。这是可接受的:它是增量特性,未知键不属于保证稳定的行为契约。
实现落地与测试验证
虽然文档以 proposal 形式呈现,当前仓库中该功能已完整实现,实现要点与提案一一对应:
groups已加入配置 Hook 类型(HookWire/RemoteHook/LocalHook,见 crates/prek/src/config/hook.rs)与构建后的Hook类型(crates/prek/src/hook.rs);- 分组名在配置解析期通过
deserialize_groups校验(crates/prek/src/config/priority.rs); prek run已加入可重复的--group/--require-group/--no-group参数(crates/prek/src/cli/mod.rs);- 分组过滤发生在显式 stage 过滤与安装选择之前(crates/prek/src/cli/run/run.rs);
- group 模式激活且未显式
--stage时跳过默认 stage 过滤(infer_stage_and_input_mode,crates/prek/src/cli/run/run.rs); - 分组选择器在
prek try-repo中不可用(TryRepoArgs无对应字段); - schema 与正式文档已同步更新:
groups的机器可读约束体现在 schema 正则^[^@\s]\S*$,正式使用文档见 docs/reference/configuration.md,CLI 参考见 docs/reference/cli.md(--group、--require-group、--no-group条目); - 测试覆盖 include、require 交集、exclude、混合分组过滤器、未分组 Hook、显式 stage 交集、workspace 选择与"被排除 Hook 不安装"等场景——非法名称拒绝测试见 crates/prek/src/config/mod.rs,Hook 构建测试中亦包含
groups: Some(vec!["ci", "format"])的合并用例(crates/prek/src/hook.rs)。
总结
Hook Groups 是 prek 在 Git Hook 管理上的关键差异化能力:它以"轻量标签 + 三个可重复 CLI 过滤器"的方式,把"何时运行哪些 Hook"从 Git stage 语义中彻底解耦,让 CI、Agent、本地开发、发布流程各自拥有清晰、可组合、可审计的执行画像。--group做并集包含,--require-group做交集收窄,--no-group做排除,@ungrouped补齐未分组场景,排除优先、顺序无关的组合规则保证了可预测性。配合"被排除即不克隆、不安装、不执行"的实现策略,它还天然解决了大型工具链与慢速检查器带来的资源与体验问题。这套机制既保持了与既有配置、stage 行为及 pre-commit 生态的向后兼容,又为未来的默认分组、安装期分组等演进预留了清晰边界。
【免费下载链接】prek⚡ A fast Git hook manager written in Rust, designed as a drop-in alternative to pre-commit, reimagined.项目地址: https://gitcode.com/GitHub_Trending/pr/prek
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考