CatchAdmin PHP 后台管理框架 Logo CatchAdmin

下一份 PR 之前,PHP 团队该装好的九条 Git Hook

一位资深开发者在评审某个 PR。三轮来回评审之后,他们终于合并了它。整场对话的走向大致如下:

"OrderService.php 里还留着一个 var_dump。" 推修复。"分支名叫 johns-stuff —— 请按约定改名。" 推修复。"提交信息要遵循 Conventional Commits 格式。" 变基、强推。"迁移文件有个解析错误,你在本地没跑过。" 推修复。"还有 composer.lock 没和 composer.json 一起更新。" 推修复,终于变绿。

这些问题没有一条需要人类评审。没有一条需要一个资深开发者的注意力。每一条都可以由一段在开发者电脑上 200 毫秒就跑完、甚至还没离开笔记本就能拦下的脚本发现。结果整个团队花掉四小时资深评审时间、三天日历时间,去抓这些机器就该抓的问题。

这就是 Git Hook 存在的理由。不是空谈"代码风格强制",而是很具体的一件事:把那些浪费评审者时间、堵塞 CI 的低级错误挡在上游。下面九条 Hook 是多数团队都用得上的,每一条都附上已经验证过能按预期工作的 bash 脚本。每条 Hook 都很小,合在一起就能把评审中整整一类噪音消掉。

TL;DR 速览

  • 最有价值的 Git Hook 不是那些强制架构规则的,而是那些在评审者队列之前就抓住低级错误的。
  • 一条跑 php -l 语法检查的 pre-commit Hook 大约只花 50ms,就能防掉一整类"我本地没测"的 PR。
  • 针对 var_dumpddprint_r 等的检测模式,能抓住 PHP 代码库里最常见的一条评审意见。
  • 另外三条 Hook(提交信息格式、分支命名、composer 同步)能消除一整批与代码本身无关的流程性评审反馈。
  • captainhookhusky-phplefthook 这类工具用来在团队内共享 Hook。单人项目裸用 .git/hooks/ 也行。无论走哪条路,Hook 本体一样,区别只在管理层。

本文要点

  • 九条具体 Git Hook,每条都有经过验证、可直接运行的 bash 实现
  • 为什么有的 Hook 属于 pre-commit、有的属于 commit-msg、还有几条属于 pre-push
  • 如何处理误报问题,以及 --no-verify 何时是合理的逃生口
  • Hook 必须够快——任何超过 2 秒的 Hook 都会被团队集体绕过
  • 在不信任随手脚本的团队里共享 Hook 的工具选型

Hook 1,PHP 语法检查(pre-commit)

最廉价的安全网。PHP 的 -l 标志(lint)只解析文件并报告语法错误,不执行代码。提交前对每一个暂存的 PHP 文件跑一遍 php -l,就能挡住"把解析器搞坏了还没察觉"这一整类错误。

bash
#!/bin/bash
# .git/hooks/pre-commit
set -e

STAGED_PHP=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.php$' || true)
[ -z "$STAGED_PHP" ] && exit 0

ERRORS=0
for FILE in $STAGED_PHP; do
    if ! php -l "$FILE" > /dev/null 2>&1; then
        echo "Syntax error in $FILE:"
        php -l "$FILE"
        ERRORS=$((ERRORS + 1))
    fi
done

if [ $ERRORS -gt 0 ]; then
    echo ""
    echo "$ERRORS file(s) have syntax errors. Commit blocked."
    exit 1
fi

--diff-filter=ACMR 只保留 Added、Copied、Modified、Renamed 文件——跳过 Deletion,因为不能对不存在的文件跑 lint。该 Hook 只检查暂存文件、不扫整个代码库,所以即便在一万文件量级的项目里,运行时间仍在 1 秒以内。

这条 Hook 在团队里第一次有人忘了在本地跑迁移时就回本了。解析错误在 commit 时就被抓到,而不是 CI 八分钟后才发现,也不是队友拉了分支后发现分支坏了。本榜单里投入最低、收益最高的一条。

Hook 2,查出遗漏的调试语句(pre-commit)

PHP 代码库里最常见的一条评审意见,几乎总是某种变体的"这里落了个 var_dump"。在评审之前拦下来再容易不过:

bash
#!/bin/bash
# .git/hooks/pre-commit(扩展)
set -e

STAGED_PHP=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.php$' || true)
[ -z "$STAGED_PHP" ] && exit 0

PATTERNS='var_dump\(|dd\(|dump\(|print_r\(|XDEBUG_BREAK'

FOUND=$(git diff --cached --diff-filter=ACMR -U0 -- $STAGED_PHP \
    | grep -E "^\+[^+]" \
    | grep -E "$PATTERNS" || true)

if [ -n "$FOUND" ]; then
    echo "Debug statements found in staged changes:"
    echo "$FOUND" | head -20
    echo ""
    echo "Remove them, or use git commit --no-verify if intentional."
    exit 1
fi

巧妙之处在于:Hook 检查的是diff 里新增的那些行,而不是文件当前整体内容。^\+[^+] 正则匹配以 + 开头但不是 +++ 的行(那是 diff header)。这意味着你没改过的文件里原本就有的 var_dump 不会触发 Hook——只有新加或改动的行才会。

XDEBUG_BREAK 用来抓某些团队调试时加的 IDE 断点。按团队习惯增删模式:使用 Laravel 的团队可以加上 \Illuminate\Support\Facades\Log::debugdump()(Symfony VarDumper);使用 Spatie ray() 的团队也应加入。

模式正则故意没有覆盖所有边界情况。确实需要 print_r 的生产日志场景,用一次 --no-verify 即可,或把调用包装成不匹配该模式的形式。这没问题。Hook 只为 95% 的场景服务,不追求 100%。

Hook 3,密钥检测(pre-commit)

这条 Hook 能阻止整整一类灾难级事故。一个被提交进去的 AWS 密钥或 OpenAI API Token,哪怕几秒后就被回滚,也已经永久留在 Git 历史里,必须轮换。轮换的代价从"令人不快"到"生产故障"不等。一开始就别让它进提交,代价比事后处理低得多。

bash
#!/bin/bash
# .git/hooks/pre-commit(扩展)
set -e

STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
[ -z "$STAGED" ] && exit 0

PATTERNS=(
    'AKIA[0-9A-Z]{16}'                    # AWS access key
    'aws_secret_access_key\s*=\s*[A-Za-z0-9/+=]{40}'
    'sk-[A-Za-z0-9]{20,}'                 # OpenAI/Anthropic 风格密钥
    'AIza[0-9A-Za-z_-]{35}'               # Google API key
    'ghp_[A-Za-z0-9]{36}'                 # GitHub PAT
    'xox[baprs]-[A-Za-z0-9-]+'            # Slack token
    'BEGIN (RSA |DSA |EC |OPENSSH )?PRIVATE KEY'
    '"password"\s*:\s*"[^"]{8,}"'         # JSON 里的 password 字段
)

FOUND=""
for PATTERN in "${PATTERNS[@]}"; do
    MATCH=$(git diff --cached --diff-filter=ACMR -U0 -- $STAGED \
        | grep -E "^\+[^+]" \
        | grep -E "$PATTERN" || true)
    [ -n "$MATCH" ] && FOUND="${FOUND}${MATCH}\n"
done

if [ -n "$FOUND" ]; then
    echo "Possible secrets in staged changes:"
    echo -e "$FOUND" | head -10
    echo ""
    echo "If false positives, use git commit --no-verify."
    echo "Better: rotate any real secret that almost got committed."
    exit 1
fi

上面的模式有意收紧——只匹配已知类型凭据的具体格式。更宽的模式(比如任何 32 字符的 base64 字符串)能抓到更多密钥,但会在哈希、ID 以及任何碰巧像密钥的字符串上误报。

想做全面的密钥扫描,gitleakstrufflehogdetect-secrets 等专用工具更胜任——它们自带数百条规则,误报率也更低。上面这条 Hook 在不依赖外部工具的前提下覆盖了最常见的情形。处理敏感基础设施的团队应当两层并用:本地 Hook 给出快速反馈,CI 中的专用扫描器负责严谨

结尾的提示是刻意留下的。如果真的因为一条真实密钥触发了这个 Hook,开发者应该立刻轮换,而不仅仅是把它从暂存改动里删掉。那把密钥已经到过磁盘,可能还在 IDE 的最近文件列表里,可能已经被粘到过某个聊天窗口。把 Hook 的触发当成"险些出事",而不是"成功拦下"。

Hook 4,composer.json 与 composer.lock 同步(pre-commit)

没人喜欢的那种 CI 失败:composer install --no-dev 跑出来的 lock 文件和仓库里 committed 的 lock 不一致。根因通常是:有人改了 composer.json(加了依赖、升了版本),却没跑 composer update,也没把更新后的 composer.lock 一起 commit。

bash
#!/bin/bash
# .git/hooks/pre-commit(扩展)
set -e

STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
JSON_STAGED=$(echo "$STAGED" | grep -c "^composer\.json$" || true)
LOCK_STAGED=$(echo "$STAGED" | grep -c "^composer\.lock$" || true)

if [ "$JSON_STAGED" -gt 0 ] && [ "$LOCK_STAGED" -eq 0 ]; then
    echo "composer.json staged but composer.lock is not."
    echo "Run 'composer update' or 'composer install', then stage composer.lock."
    exit 1
fi

if [ "$JSON_STAGED" -eq 0 ] && [ "$LOCK_STAGED" -gt 0 ]; then
    echo "composer.lock staged but composer.json is not."
    echo "Did you mean to update both? Use --no-verify if this is a security update."
    exit 1
fi

这条 Hook 双向检查:composer.json 没带 composer.lock 是常见情形,反过来(lock 变了但 json 没变)也会出现,通常是因为 composer update 升了某些小版本。无论方向如何,暂存的状态应当是刻意的。

逃生口在这里确实重要。有些场景 lock 文件就是应当在 json 不变的情况下更新——比如安全通告更新,composer update vendor/package 只会更新 lock 而不改 json。此时用 --no-verify 处理即可。Hook 是一道理智提示,不是死规矩。

Hook 5,拦截 .env 与密钥文件(pre-commit)

与密钥检测相邻但在另一个层次:无论内容如何,永远不提交这些文件名

bash
#!/bin/bash
# .git/hooks/pre-commit(扩展)
set -e

STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
[ -z "$STAGED" ] && exit 0

# 拦住 .env,但放行 .env.example、.env.dist、.env.testing
DANGEROUS=$(echo "$STAGED" | grep -E '(^|/)\.env$' || true)
KEY_FILES=$(echo "$STAGED" | grep -E '\.(pem|key|p12|pfx)$' || true)

if [ -n "$DANGEROUS" ] || [ -n "$KEY_FILES" ]; then
    echo "Refusing to commit files that typically contain secrets:"
    [ -n "$DANGEROUS" ] && echo "$DANGEROUS"
    [ -n "$KEY_FILES" ] && echo "$KEY_FILES"
    echo ""
    echo "Add these to .gitignore."
    exit 1
fi

正则 (^|/)\.env$ 精准匹配 .envpath/to/.env,但不会匹配 .env.example.env.dist.env.testing 或其它带后缀的变体。这些本来就是要被 commit 的模板文件(示意该设置哪些环境变量),Hook 应当放过它们。

.pem.key.p12.pfx 这些扩展名通常对应 SSL、签名或认证的私钥。测试 fixture 偶尔会有合法存在的此类文件,这时 --no-verify 即是合适的逃生口。Hook 秉持"默认拦、例外放"的保守策略。

Hook 6,合并冲突标记(pre-commit)

merge 或 rebase 出现冲突时,开发者在编辑器里解决。令人意外地经常发生的是——保存时没把冲突标记删干净就直接 commit 了。结果文件里留着字面量 <<<<<<< HEAD,PHP 根本无法解析。

bash
#!/bin/bash
# .git/hooks/pre-commit(扩展)
set -e

STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
[ -z "$STAGED" ] && exit 0

FOUND=$(git diff --cached --diff-filter=ACMR -U0 -- $STAGED \
    | grep -E "^\+[^+]" \
    | grep -E '^\+(<{7}|={7}$|>{7})' || true)

if [ -n "$FOUND" ]; then
    echo "Merge conflict markers found in staged changes:"
    echo "$FOUND" | head -10
    echo ""
    echo "Resolve the conflict properly before committing."
    exit 1
fi

模式严格匹配"行首恰好 7 个连续的 <>="——这正是 git 冲突标记的固定格式。别处出现 7 个同样字符的情况(某段注释用一排 = 做强调、ASCII art 等)不会匹配,因为它们不是出现在行首。

Hook 1 的 PHP 语法检查其实已经间接能抓到冲突标记(它们会导致解析错误)。这条 Hook 更快——无需启动 PHP 进程,仅靠文本匹配——而且错误信息更直白。两条同时跑没问题,都很便宜,从不同角度抓同一类问题。

Hook 7,大文件(pre-commit)

不小心 commit 进仓库的二进制资产是一次永久承诺。一旦进入历史,要移除就必须改写历史、强推——这种扰动大到大多数团队根本不会去做。于是那份文件永远留在仓库里,膨胀 clone 大小、拖慢各种操作。

bash
#!/bin/bash
# .git/hooks/pre-commit(扩展)
set -e

MAX_SIZE_KB=500

STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
[ -z "$STAGED" ] && exit 0

LARGE_FILES=""
while IFS= read -r FILE; do
    [ -z "$FILE" ] && continue
    [ ! -f "$FILE" ] && continue

    SIZE_KB=$(du -k "$FILE" | cut -f1)

    if [ "$SIZE_KB" -gt "$MAX_SIZE_KB" ]; then
        LARGE_FILES="${LARGE_FILES}${FILE} (${SIZE_KB}KB)\n"
    fi
done <<< "$STAGED"

if [ -n "$LARGE_FILES" ]; then
    echo "Files exceed ${MAX_SIZE_KB}KB limit:"
    echo -e "$LARGE_FILES"
    echo "Consider Git LFS for binary assets."
    exit 1
fi

500KB 的阈值偏保守,足以抓到多数意外提交。确有合法大资产(样本数据、fixture、真正该入仓的图片等)的团队应提高上限或把特定路径列为白名单。只要某类文件经常超过上限,它大概率该用 Git LFS 或外部存储托管。

这条 Hook 只检查暂存文件,不检查已在仓库中的历史文件。既有的大文件不会触发任何东西;Hook 只是防新大文件偷偷混进来。

Hook 8,分支名校验(pre-push)

这是一条流程约束而非正确性约束。有些团队出于工具链原因在意分支命名(release/* 自动部署、fix/JIRA-123 自动关联 Jira 单),有些则不在意。对于在意的团队,在分支被推出去之前先拦一道,比事后改名要顺畅得多:

bash
#!/bin/bash
# .git/hooks/pre-push
set -e

BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")

PATTERN='^(main|develop|master|(feature|fix|chore|hotfix|release|refactor|test|docs)/[a-z0-9._-]+)$'

if [[ ! "$BRANCH" =~ $PATTERN ]]; then
    echo "Branch name '$BRANCH' does not match allowed patterns:"
    echo "  feature/short-description"
    echo "  fix/issue-123"
    echo "  chore/cleanup-x"
    echo "  hotfix/urgent-thing"
    echo ""
    echo "Use git branch -m <new-name> to rename."
    exit 1
fi

放在 pre-push 而不是 pre-commit 的原因:分支命名只在"分享"时才有意义——本地在一个奇怪分支上提交谁也不会受影响。放到推送时拦截,开发者可以先在 johns-stuff 上一直干,直到准备推送时再改名为 feature/inventory-update

模式只是起点,按团队约定调整即可。有的团队要求带工单号(feature/JIRA-1234-short-name),有的只允许某几种前缀,还有的完全走另一套规则。Hook 结构不变,只改正则。

Hook 9,Conventional Commits(commit-msg)

最后一条,也是开发者最容易一开始抱怨、用上之后就回不去的那一条。Conventional Commits 格式(type(scope): description)是一种结构化的提交信息风格,让 git log 真正变得对变更日志、发布和历史考古有用。

bash
#!/bin/bash
# .git/hooks/commit-msg
set -e

MSG_FILE=$1
MSG=$(head -1 "$MSG_FILE")

# 跳过 merge 和 revert(git 自动生成这类消息)
if echo "$MSG" | grep -qE '^(Merge|Revert) '; then
    exit 0
fi

PATTERN='^(feat|fix|chore|refactor|test|docs|perf|style|build|ci)(\([a-z0-9_-]+\))?: .{1,70}$'

if [[ ! "$MSG" =~ $PATTERN ]]; then
    echo "Commit message does not follow Conventional Commits format:"
    echo "  '$MSG'"
    echo ""
    echo "Expected: type(scope): description"
    echo "Examples:"
    echo "  feat(auth): add SAML SSO support"
    echo "  fix(checkout): handle null tax rates"
    echo "  chore: update composer dependencies"
    echo ""
    echo "Allowed types: feat, fix, chore, refactor, test, docs, perf, style, build, ci"
    exit 1
fi

这条 Hook 住在 commit-msg,而不是 pre-commit。区别在于:commit-msg 在提交信息已经拼好之后触发,第一个参数是消息文件。Hook 可以读或改这个消息;这里只做校验。

描述的 70 字符上限对齐 Linux 内核约定和大多数项目规范。有的团队偏好 50 字符——正则里的上限是参数,不是教条。merge 与 revert 被跳过,因为 git 会自动生成这类消息,它们不符合该约定。

随时间积累,这带来的是:团队的 git log --oneline 变成一份自解释的历史。conventional-changelog 这类工具可以直接从 commit 生成发布说明;基于 commit 类型做语义版本号自动跳号(feat = minor、fix = patch、feat!BREAKING CHANGE: = major)成为可能。这条 Hook 是一整套依赖一致 commit 信息的自动化生态的入口。

在团队里共享 Hook

上面所有 Hook 都住在 .git/hooks/ 里,而这个目录不会被纳入仓库。每位开发者都要自己装一遍。对单人项目够用,对团队就不行。

三种常见模式:

手工安装脚本。 把 Hook 脚本放在仓库的 bin/git-hooks/,配一个 bin/install-hooks.sh 做复制或创建符号链接。新成员入职时跑一次安装脚本。简单、零依赖、靠自觉。

captainhookhttps://captainhook.info)。 原生 PHP 的 Git Hook 管理器。通过仓库根目录的 captainhook.json 配置,作为 Composer 开发依赖安装。Hook 以 JSON 声明,条件与动作是 PHP 类。开发者执行 composer install 时会自动装好 Hook。对希望声明式管理 Hook 的 PHP 团队来说,是最地道的选择。

lefthookhttps://github.com/evilmartians/lefthook)。 跨语言的 Git Hook 管理器。通过 lefthook.yml 配置,默认并行执行 Hook,速度更快。Hook 本身都是 shell 脚本时,它通常比特定语言的工具更快。适合多语言混合栈的团队。

husky-phphttps://github.com/krlove/husky-php)。 Node 生态中 husky 的 PHP 移植。社区规模小于 captainhook,但对从 JavaScript 背景而来的开发者更熟悉。

对多数 PHP 团队,captainhook 是默认推荐——通过 Composer 安装,与 PHPStan 等工具的集成原生顺畅,可按仓库定制。独立开发者与小团队可以先用裸 .git/hooks/ 加安装脚本,等团队扩大再切到工具。

需要避开的陷阱

任何超过 2 秒的 Hook。 只要够慢,--no-verify 就会被习惯性使用,一旦全队开始习惯绕过 Hook,这套纪律就会瞬间崩塌。给 Hook 计时,超过 2 秒就剖析、精简。对每个暂存文件都跑静态分析很慢;只对已变更文件跑就快。选快的那种。

在 pre-commit 里自动改写代码。 有些团队让 Hook 用 PHP-CS-Fixer --fix 自动改并重新暂存。听起来贴心,实则会带来"commit 出去的代码不等于开发者本地看到的"的调试地狱。pre-commit 里跑检查模式(--dry-run 有差异就非零退出),让开发者自己修。

把 Hook 绑死在特定 PHP 版本上。 一条用到 PHP 8.3 新特性的 Hook,会在仍跑 8.1 的开发机上坏掉。Hook 要以团队支持的最低 PHP 版本为基线去测试,而不是以当前主机版本为基线。

--no-verify 塑造成"罪过"。 它的存在是有正当理由的。有时 Hook 判错了,有时这次改动真的是例外。围绕 --no-verify 制造社会压力,会让开发者绕更远的路去避开 Hook(比如直接在 GitHub Web UI 编辑提交,彻底跳过本地 Hook)。逃生口是安全阀,要让它发挥作用。

把重量级 lint 放进 pre-commit 而不是 CI。 完整的 PHPStan、全量 PHPUnit、PHP-CS-Fixer 检查是 CI 的活。前面这些 Hook 毫秒级完成,完整静态分析动辄几分钟。别把两层混在一起——本地要快反馈,CI 要全面检查,两层都要。

忘了给 Hook 加执行权限。chmod +x 的 pre-commit 脚本会静默失效。安装脚本要显式设好权限。captainhook 与 lefthook 自动处理;手工方案要自己管。

简明 Q&A

Hook 应该每次 commit 都跑,还是只在 push 前跑?
快检查(语法、调试语句、冲突标记)主要放 pre-commit——应该在 commit 被创建之前就失败,让开发者在上下文还在手边时就修掉。较慢的检查(完整测试套件)放 pre-push;只在分享时才有意义的东西(比如分支名校验)也放 pre-push。消息格式放 commit-msg。选哪一层取决于要检查什么,没有单一答案。

这些 Hook 的误报率控制在多少算合理?
低于 1%。如果一条 Hook 每 100 次合法提交中触发超过一次,开发者就会开始惯性绕过。把模式收得更具体,为已知误报加白名单(例如调试日志工具里允许出现的 var_dump)。目标是"Hook 只在真有问题时触发";如果无事也在响,它就是噪音。

这些 Hook 能同时放在 CI 里跑吗?
可以,而且应该。作为 pre-commit 运行的逻辑同样能作为 CI 步骤运行。不少团队两边都跑:Hook 提供本地快反馈,CI 做权威把关确保没漏。语法检查、密钥扫描、冲突标记检测的 CI 版本应在每个 PR 上都运行——Hook 面向开发者友好,CI 才是真正的闸门。

Hook 能覆盖 GitHub 网页端修改或 PR review 中的编辑吗?
不能。本地 Git Hook 只作用于本地 commit。GitHub Web UI 的编辑完全绕过它们。任何不管改动途径都必须强制的规则,要用 GitHub Actions(或等价的 CI)——它们每次 push 都会触发,不看来源。Hook 是开发者便利层,CI 才是真正的门禁。

结语

Git Hook 的合理野心应是"小而明确"。它们负责挡住那些浪费评审者时间、却不提供真正评审价值的低级错误——遗忘的调试代码、语法错误、漏网的密钥、写坏的 commit 信息。它们要快到没人愿意绕过;要在失败时给出清晰、指向修复动作的提示。任何更高阶的检查都属于 CI。

上面九条 Hook 覆盖了 PHP 团队面对的大部分低级错误空间。每一条都只有几十行 bash。合在一起,能把"分支名""commit 格式""缺文件""残留调试语句"这整类与代码本身无关的评审反馈彻底消除。评审者的注意力从此可以留给架构、逻辑、设计,而不是"请重命名你的分支"。

真正的难点更多是社会层面,不是技术层面。Hook 本身很简单;让团队统一一套、装好、不再随手绕过,才是需要协调的部分。captainhook 这类工具让技术部分几乎无痛;社会部分只需要团队达成共识:早拦住低级错误,是值得在 commit 时等上 200ms 的

收束闭环

一位资深开发者在评审一份 PR。PR 描述清晰,分支名合规,commit 信息 scope 明确,diff 里没有调试语句、没有缺失文件、没有语法错误。评审者专注地读真正的逻辑——新加的锁代码里有一处细微的竞态,需要人类的眼睛。评审留下一条评论,作者改完,PR 当天就发出去了。

这是"低级评审反馈被上游拦下"之后的样子。评审者的注意力留给了真正需要人类判断的事情。PR 周期从"几天"压缩到"几小时"。作者不再被那些自己三十秒就能修掉的东西反复挑刺——只要他知道该去修。

这就是整桩交易:每次 commit 多花 200 毫秒跑 Hook,换来以小时计的评审时间和对所有人都更快的 PR 节奏。

"相关问答" 8 问

1. pre-commit、commit-msg、pre-push 有什么差别?
pre-commit 在 commit 创建之前触发,非零退出会让 commit 失败。适合用于语法检查、调试语句检测、文件级校验。commit-msg 在 commit 信息已拼好之后触发,第一个参数是消息文件,适合做消息格式校验。pre-push 在推送到远端之前触发,非零退出会让 push 失败,适合较慢的检查(测试套件)或只在分享时才有意义的检查(分支名校验)。

2. PHP 团队怎么共享 Git Hook?
三条路。手工方案:把 Hook 脚本放在仓库的 bin/git-hooks/,用安装脚本把它们复制进 .git/hooks/captainhook 方案:通过 Composer 安装,用 captainhook.json 配置,开发者跑 composer install 时自动装好。lefthook 方案:安装二进制,用 lefthook.yml 配置,克隆后执行一次 lefthook install。纯 PHP 栈团队首选 captainhook。

3. Git Hook 会拖慢工作流吗?
本文里的 Hook 每条都在毫秒级完成,整套在 commit 时的总耗时通常不到 500ms。让 Hook 变棘手的是重量级检查(全量 PHPUnit、综合 PHPStan)——那些应该住在 CI,不在本地 Hook。两秒法则:任何超过 2 秒的 Hook 都会被习惯性绕过,也就失去了意义。

4. Git Hook 能被绕过吗?
能,通过 git commit --no-verify。这是有意为之——Hook 用来拦截低级错误,不用来做强制策略。真要做强制,用服务端检查(每个 PR 的 CI、分支保护规则、push 上的 GitHub Actions)。Hook 是开发者友好的安全网,真正的闸门在 CI。

5. Hook 应该只检查变更文件,还是检查整个仓库?
只检查变更文件。上面的 Hook 都用 git diff --cached 只看暂存改动,而不是整个代码库。对整个代码库跑会让 Hook 变慢,还会在当前提交并未引入的老问题上产生噪音。Hook 应该是增量的:只要当前提交没让情况变糟,就应该放行。

6. 最好的密钥扫描器是哪款?
做全面扫描首选 gitleakstrufflehog,被广泛使用,内置数百条规则。本文的 bash Hook 在不依赖外部工具的前提下覆盖最常见的场景(AWS、OpenAI、GitHub、Google、Slack、RSA 密钥)。对处理敏感基础设施的团队,两层并用:Hook 负责本地快反馈,CI 中的专用扫描器负责彻底检测。

7. 怎么写一条只对变更文件跑 PHPStan 的 Git Hook?
先用 git diff --cached --name-only --diff-filter=ACMR | grep '\.php$' 拿到暂存的 PHP 文件列表,再传给 PHPStan:vendor/bin/phpstan analyse --no-progress -- $FILES。这样 PHPStan 只分析当前提交涉及的文件,比分析整个代码库快得多。对需要跨文件关联的深度分析,CI 仍应在每个 PR 上跑全量分析。

8. 应不应该把 .git/hooks 目录 commit 进仓库?
不能——.git/hooks/ 不在 git 跟踪范围内,提交不了。正确做法是把 Hook 脚本放到一个被跟踪的目录(如 bin/hooks/),或使用 captainhook 这类把 Hook 配置保存在可跟踪文件(captainhook.json)中的工具。安装阶段由安装器把脚本复制或创建符号链接到 .git/hooks/

说明:本文全部 Hook 脚本均在测试 Git 仓库中用 PHP 8.3.6 与 bash 5.x 验证过,涵盖语法检查、调试语句检测(采用 grep -E^\+[^+] 跳过 diff header)、密钥模式匹配、composer 文件同步、.env 拦截、冲突标记检测、大文件检查、分支名校验、Conventional Commits 消息校验。Conventional Commits 规范见 https://www.conventionalcommits.org。文中提到的工具(captainhooklefthookhusky-phpgitleakstrufflehog)在写作时均为真实且仍在维护的项目。

本作品采用《CC 协议》,转载必须注明作者和本文链接