自动化工作流:Git 客户端钩子实用指南

使用 Git 客户端钩子实现快速本地检查、共享设置、提交消息规则以及更安全的合并后自动化。

自动化工作流:Git 客户端钩子实用指南

Git 客户端钩子是在 Git 工作流中特定节点运行时的小型脚本。pre-commit 钩子在创建提交前运行。commit-msg 钩子在您编写提交消息后、Git 接受前运行。post-merge 钩子在合并完成后运行。如果使用得当,钩子可以及早捕获无聊的错误:忘记格式化、损坏的生成文件、缺少依赖安装,或不符合团队约定的提交消息。

重要的限制是客户端钩子是本地的。当有人克隆仓库时,它们不会自动随仓库一起传播。这使得它们非常适合快速反馈和本地便利,但作为团队规则的唯一执行层则较弱。如果某项检查真正保护主分支,也应将其放入 CI 或服务器端规则中。

每个仓库在 .git/hooks 下都有一个钩子目录:

ls .git/hooks

新仓库通常包含示例文件,如 pre-commit.sample。示例钩子不会执行任何操作,直到您创建一个没有 .sample 后缀的可执行文件:

cp .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

钩子可以是 Shell 脚本、Python 脚本、Ruby 脚本、Node 脚本,或任何您的机器可以执行的内容。第一行应指向解释器:

#!/usr/bin/env bash

对于大多数团队,更好的长期模式不是在每台笔记本电脑上手编辑 .git/hooks。将钩子脚本存储在仓库中,然后配置 Git 使用该目录:

git config core.hooksPath .githooks
mkdir -p .githooks

现在,.githooks/pre-commit 处的钩子可以像普通项目代码一样被提交和审查。每个开发者仍然需要 core.hooksPath 设置,但可以将设置添加到引导脚本或入职文档中。

一个有用的 Pre-Commit 钩子

一个好的 pre-commit 钩子应该快速且专注。如果每次提交需要两分钟,人们会使用 git commit --no-verify 绕过它,钩子就会变成噪音。将完整的测试套件留给 CI,除非项目足够小,它们确实很快。

这是一个实用的 Shell 钩子,只检查暂存的文件。这个区别很重要。您的工作树中可能有未完成的工作,您还不想测试。提交应根据暂存的内容来评判。

创建 .githooks/pre-commit

#!/usr/bin/env bash
set -u

changed_files=$(git diff --cached --name-only --diff-filter=ACMR)

if [ -z "$changed_files" ]; then
  exit 0
fi

if git diff --cached --check; then
  :
else
  echo "Fix whitespace errors before committing."
  exit 1
fi

secret_matches=$(git diff --cached --name-only --diff-filter=ACMR | xargs grep -nE 'AKIA[0-9A-Z]{16}|BEGIN RSA PRIVATE KEY' 2>/dev/null || true)
if [ -n "$secret_matches" ]; then
  echo "Possible secret found in staged files:"
  echo "$secret_matches"
  exit 1
fi

python_files=$(printf '%s\n' "$changed_files" | grep '\.py$' || true)
if [ -n "$python_files" ]; then
  printf '%s\n' "$python_files" | while IFS= read -r file; do
    [ -f "$file" ] || continue
    python3 -m py_compile "$file" || exit 1
  done
fi

exit 0

这个钩子做了三件适度的事情:它让 Git 检测空白错误,检查暂存文件中几个明显的秘密模式,并编译更改的 Python 文件。它不能替代真正的秘密扫描器或测试套件。它是一个快速的绊线。

一个常见的错误是使用 grep 针对文件名而不是文件内容。这种错误的模式只检查路径是否包含 TODO,而不是文件是否包含它:

git diff --cached --name-only | grep TODO

如果您想阻止 TODO 注释,请检查暂存的差异:

if git diff --cached -U0 | grep -E '^\+.*TODO:'; then
  echo "Staged TODO comments found."
  exit 1
fi

即使如此,也要小心。有些团队负责任地使用 TODO 注释。阻止每个 TODO 可能比帮助更烦人。

提交消息钩子

commit-msg 钩子接收临时提交消息文件的路径作为其第一个参数。这使得它对于诸如“每个提交必须以工单 ID 开头”或“使用常规提交”之类的规则很有用。

一个小例子:

#!/usr/bin/env bash
set -u

message_file="$1"
first_line=$(head -n 1 "$message_file")

if printf '%s' "$first_line" | grep -Eq '^(feat|fix|docs|test|refactor|chore)(\(.+\))?: .+'; then
  exit 0
fi

echo "Commit message should look like: fix(api): handle empty token"
exit 1

当发布说明或变更日志从提交生成时,这很有帮助。当您的团队进行压缩合并并重写 PR 标题时,这就不那么有帮助了。将钩子与您实际使用的工作流匹配。

合并后钩子

post-merge 钩子最适合在您的工作树更改后进行本地清理。经典示例是在锁定文件更改后刷新依赖项。

#!/usr/bin/env bash
set -u

previous_head="HEAD@{1}"

if git diff --name-only "$previous_head" HEAD | grep -Eq '(^package-lock\.json$|^pnpm-lock\.yaml$|^yarn\.lock$)'; then
  if command -v npm >/dev/null 2>&1 && [ -f package-lock.json ]; then
    echo "Lockfile changed; running npm install."
    npm install
  fi
fi

if git diff --name-only "$previous_head" HEAD | grep -q '^\.gitmodules$'; then
  echo "Submodule config changed; syncing submodules."
  git submodule sync --recursive
  git submodule update --init --recursive
fi

这个钩子不应该做出令人惊讶的更改。如果它安装依赖项,请打印它正在做什么。如果安装失败,请告诉开发者如何恢复。一个静默更改工作树的钩子很难信任。

共享钩子而不制造混乱

有三种常见的方式共享钩子。

最简单的是 core.hooksPath,其中仓库包含 .githooks/,设置让 Git 使用它。这是透明的,不需要另一个包管理器。

JavaScript 项目通常使用 Husky,因为它与 npmpnpmyarn 安装流程集成。当每个贡献者已经使用 Node 工具链时,这可能是一个很好的选择。

许多混合语言团队使用 pre-commit 框架。它安装并运行在 .pre-commit-config.yaml 中定义的钩子,并固定了格式化程序、linter 和文件检查等工具的版本。它增加了另一个工具,但它比维基页面更好地解决了“我们如何到处安装相同的钩子?”的问题。

我避免的是手动将大型脚本复制到 .git/hooks 中。没有人审查它们,没有人知道安装了哪个版本,调试变成了个人考古学。

调试钩子

当钩子不运行时,按顺序检查这些:

git config --get core.hooksPath
ls -l .git/hooks .githooks 2>/dev/null

如果设置了 core.hooksPath,Git 会忽略 .git/hooks 并使用配置的目录。如果钩子文件在 macOS 或 Linux 上不可执行,Git 将不会运行它:

chmod +x .githooks/pre-commit

当钩子运行但神秘失败时,添加临时跟踪:

set -x
pwd
env | sort

在正常的 Git 使用中,钩子从仓库根目录运行,但 GUI 客户端和 IDE 可能会暴露路径或环境差异。在假设 linter 或包管理器可用之前,在钩子内部使用 command -v toolname

还要记住绕过开关:

git commit --no-verify

这本身不是一个安全漏洞;这是 Git 的工作方式。这是另一个原因,为什么严肃的执行属于 CI 或受保护的分支规则。

合理的钩子策略

使用钩子进行快速、确定且易于解释的检查。格式化暂存文件、捕获空白错误、验证提交消息以及提醒开发者安装依赖项都是很好的候选。避免需要网络访问、耗时较长或依赖脆弱本地状态的钩子。

如果钩子阻止了提交,其消息应准确说明失败的原因以及如何修复。“钩子失败”是不够的。处于合并或生产热修复中的开发者需要一个清晰的下一个命令。

当客户端 Git 钩子感觉像是有帮助的护栏而不是本地官僚机构时,它们效果最好。保持它们小巧,保持它们版本化,并将最终权威保留在 CI 中。

在紧急情况下保持钩子友好

钩子应在正常工作中提供帮助,而不在紧急修复期间困住某人。这意味着每个阻塞钩子都需要一个清晰的失败消息和一个现实的逃生舱口。Git 已经为提交和推送钩子提供了 --no-verify,但您的团队仍应决定何时允许绕过。生产热修复与开发者匆忙跳过格式化不同。

一个好的钩子消息会说明失败的内容、位置以及接下来要运行什么:

echo "ESLint failed on staged JavaScript files."
echo "Run: npm run lint -- --fix"
exit 1

一个糟糕的消息只显示 failed 或转储多页工具输出而没有上下文。人们学会忽略那种钩子。

如果钩子修改文件,要格外小心。格式化程序在 pre-commit 中可能有用,但当它们更改文件的未暂存部分时,也可能造成混乱。许多团队更喜欢在钩子中检查格式,让开发者手动运行格式化程序。其他人使用只格式化暂存块的工具。选择一种行为并在仓库中记录,而不是在会消失的聊天线程中。

对于团队,像审查应用程序代码一样审查钩子更改。一个钩子可能会减慢每次提交,将环境细节泄露到日志中,或者如果它假设仅 Bash 行为,则会在 Windows 上破坏贡献者。如果您的项目有 Windows 贡献者,请在 Git Bash 中测试钩子或使用跨平台钩子运行器。如果您的项目有容器或开发 Shell,请考虑在与应用程序相同的环境中运行钩子,以便每个人都使用相同的工具版本。

最好的钩子在一切正常时几乎不可见,在出现问题时非常具体。这是要追求的标准。

像产品代码一样版本化钩子

钩子脚本成为开发者体验的一部分。如果它坏了,每个贡献者都会感受到。保持脚本小巧,清晰命名辅助函数,避免在简单命令就足够时使用巧妙的 Shell 技巧。如果钩子增长到超过一两个屏幕,将实际逻辑移到一个经过测试的项目脚本中,让钩子调用该脚本。

例如,不要在 .githooks/pre-commit 中嵌入长的 lint 例程,而是调用:

./scripts/check-staged-files.sh

该脚本可以被开发者、钩子和 CI 运行。这也意味着开发者可以在不假装提交的情况下重现失败。可重现性是帮助性钩子和神秘本地障碍之间的区别。

尽可能固定工具版本。一个调用 PATH 中第一个 blackeslintprettier 的钩子在不同机器上可能行为不同。项目本地依赖、锁定文件、容器或版本管理器使钩子输出更可预测。

最后,保持钩子限定在仓库范围内。全局钩子听起来方便,但它们经常在几个月后让您感到惊讶,因为一个不相关的仓库由于旧的个人规则而开始失败。仅将全局钩子用于真正的个人偏好,而不是团队策略。

最后一个实用规则:永远不要让钩子成为命令存在的唯一地方。如果钩子检查暂存的 Python 文件,也要将该命令保留在脚本或任务运行器中。开发者应该能够在 Git 中断他们之前有目的地运行相同的检查。

对于开源项目,假设贡献者可能尚未安装您的完整工具链。一个带有友好设置消息的钩子失败是可以的。一个由于缺少本地二进制文件而抛出堆栈跟踪的钩子感觉坏了。在运行更重的命令之前检查先决条件,并指向项目使用的设置命令。

还要考虑部分提交。许多有经验的开发者只暂存文件的一部分。格式化整个文件的钩子可能会意外地将未暂存的工作拉入提交。如果您的团队经常使用部分提交,请优先选择读取暂存差异或专为暂存内容设计的工具的检查。

如果钩子不断被绕过,将其视为反馈。要么检查太慢,失败消息不清楚,要么规则属于 CI 而不是本地提交路径。修复摩擦,而不是责备开发者使用 Git 提供的绕过方式。