
起因
本文的实战载体是开源项目 open-office-skill(github.com/agentforge-tech/open-office-skill)——一个面向 AI Agent 的 Office 精细操作 Skill,能力全部放在 skills/open-office-skill/ 目录里。
为什么需要打包? 因为用户要的是"能一键安装的产物",而不是一堆源码目录。所以项目把这个目录打包成 skills/open-office-skill.zip,用户一条 curl 就能从仓库直接下载:
curl -fsSL -o open-office-skill.zip \
https://raw.githubusercontent.com/agentforge-tech/open-office-skill/master/skills/open-office-skill.zip
问题在于:源码(skills/open-office-skill/)和产物(skills/open-office-skill.zip)同处一个仓库、同进一次提交。每次改完源码都要手动打包、git add、再提交;一旦哪次忘了打包,远程的 zip 就成了过期产物——源码是新的,用户下载到的却是旧的。
问题引入:能不能让 Git 在提交 / 推送时自动打包,甚至在产物过期时直接拒绝推送?
答案就是本文的主角——Git Hooks。它允许我们在 Git 生命周期的特定节点注入自定义逻辑,把"约定"变成"强制"。下面我们就从原理讲到实战。
一、Git Hooks 是什么?先建立全局认知
1.1、一句话理解
Git Hooks 是 Git 在执行特定操作时自动触发的脚本。它们被放在仓库的 .git/hooks/ 目录下,只要脚本可执行且命名匹配,Git 就会在对应时机调用它,并根据脚本的退出码决定是否继续。
一个关键特性是:钩子脚本本身不随 git clone 分发。这是 Git 的有意设计——出于安全,防止别人克隆你的仓库时执行未知脚本。这个"坑"稍后我们在第三章的"方案二"会重点解决。
1.2、两大阵营:客户端钩子 vs 服务端钩子
按执行位置划分,Git Hooks 分为两类:
| 阵营 | 执行位置 | 典型钩子 | 主要用途 |
|---|---|---|---|
| 客户端钩子 | 开发者本地机器 | pre-commit、commit-msg、pre-push、post-merge |
代码检查、格式化、打包、提交规范 |
| 服务端钩子 | 远程仓库服务器 | pre-receive、update、post-receive |
权限校验、CI 触发、部署 |
重点:客户端钩子是"防君子不防小人"的。开发者可以用
--no-verify绕过,所以它适合做前置守卫(本地快速反馈),而最终把关应当交给服务端钩子或 CI。
1.3、生命周期全景图
下面按一次完整开发流程,标注常见钩子的触发时机:
git commit
│
├─► pre-commit ← 提交信息编辑前(可拦截)
├─► prepare-commit-msg ← 打开提交信息编辑器前
├─► commit-msg ← 收到提交信息后(可校验格式,可拦截)
└─► post-commit ← 提交完成后(不可拦截)
git push
│
└─► pre-push ← 传输对象前(可拦截)
服务端收到 push
│
├─► pre-receive ← 处理引用前(可拦截,权威关卡)
├─► update ← 每个分支单独校验
└─► post-receive ← 处理完成后(常用于部署/通知)
注意:
pre-*前缀的钩子(pre-commit、pre-push、pre-receive)都能通过返回非零退出码来中断当前操作;而post-*前缀的钩子只做收尾通知,无法阻止已经发生的事情。
二、核心原理:钩子是怎么被触发和拦截的?
搞清"何时触发"只是第一步,理解"如何拦截"才是用好钩子的关键。
2.1、退出码即开关
Git 调用钩子脚本时,只看一件事——退出码(exit code):
- 返回
0:一切正常,继续执行后续流程; - 返回非零:中断当前 Git 操作。
这就是 pre-commit 能阻止一次提交、pre-push 能阻止一次推送的根本机制。我们仓库里的 pre-push 正是利用了这一点:
if ! git diff --quiet -- skills/open-office-skill.zip; then
echo "[githooks/pre-push] skills/open-office-skill.zip 已过期(与最新源码不一致)。" >&2
echo " 已重新生成,请 git add 并提交后再 push。" >&2
exit 1 # ← 非零退出码,直接拒绝推送
fi
2.2、钩子运行时的工作目录
重点:钩子在运行时,其工作目录是仓库的根目录(准确说是 Git 认为的顶层目录)。不过为了稳妥,我们在脚本里显式做了一次切换:
root="$(git rev-parse --show-toplevel)"
cd "$root"
git rev-parse --show-toplevel 会返回仓库顶层路径。这一步保证了无论从哪个子目录触发,脚本都能定位到正确位置。
2.3、钩子能拿到哪些上下文?
不同钩子会被传入不同的参数或标准输入:
pre-commit:无参数;pre-push:接收两个参数(远程名、远程 URL),并通过 stdin 传入即将推送的引用列表;commit-msg:接收一个参数,即存放提交信息的文件路径;pre-receive:通过 stdin 接收每一行<旧SHA> <新SHA> <引用名>。
弊端说明:很多人以为
pre-push会自动告诉你"要推哪些提交",其实需要自己从 stdin 解析。本文的实战为了聚焦打包逻辑,没有处理 stdin,因此对引用列表不敏感——这也是它适合做"产物校验"的原因。
三、实现思路:三种让钩子"团队共享"的方案对比
原理清楚了,接下来是工程化的问题:如何让团队成员都自动获得这些钩子? 我们对比三种主流方案。
方案一:手动复制到 .git/hooks/
cp githooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
- 优点:零配置依赖,最直观。
- 缺点:
hooks/目录不受版本控制,每个成员、每台机器都要手动做一遍;一旦忘记就是"摆设"。 - 适用场景:个人项目、一次性验证。
方案二:core.hooksPath 指向版本化目录(本项目采用)
git config core.hooksPath githooks
- 优点:把钩子放进仓库里的
githooks/目录并纳入 Git 管理,开发者只需执行一次命令,即可让所有钩子生效。团队共享、可 review、可追溯。 - 缺点:仍需每位成员手动执行一次配置;Git 2.9+ 才支持。
- 适用场景:团队协作、开源项目——强烈推荐。
方案三:借助工具自动安装(Husky / pre-commit 框架)
-
Husky:Node 生态常用,
npm install后通过prepare脚本自动设置hooksPath。 -
pre-commit(Python 框架):用 YAML 声明式配置,自带环境隔离。
-
优点:安装全自动,与包管理器生命周期绑定。
-
缺点:引入额外依赖(Node/Python 环境),对纯 Shell 仓库略重。
-
适用场景:已有前端/Node 工程链、需要复杂钩子编排的项目。
对比结论:本项目是轻量的 Shell + Python 脚本仓库,方案二以最低成本换来了"钩子版本化 + 一次性启用",是最优解。
四、实战代码:pre-commit 自动打包 + pre-push 强制校验
4.1、核心目录结构总览
先看最终要落地的目录形态,照着抄两个关键位置即可:Git 默认查找钩子的 .git/hooks/,以及我们自己版本化管理的 githooks/:
open-office-skill/ # 仓库根目录
├── .git/ # Git 内部目录(不纳入版本控制)
│ └── hooks/ # ← Git 默认查找钩子的位置(含 *.sample 示例,仅本地)
├── githooks/ # 【新增】版本化管理的钩子目录(core.hooksPath 指向这里)
│ ├── pre-commit # 【新增】提交前:自动打包并 git add
│ └── pre-push # 【新增】推送前:校验产物是否过期
├── bin/
│ └── package_skills.py # 【已有/新增】打包脚本 → 生成 skills/open-office-skill.zip
├── skills/
│ ├── open-office-skill/ # 【已有】Skill 源码目录
│ └── open-office-skill.zip # 【产物】打包结果(纳入版本管理,随源码一起提交)
└── ...
重点:
.git/hooks/是 Git 默认的钩子目录,但它不随仓库分发;我们自建githooks/并纳入版本控制,再通过git config core.hooksPath githooks让 Git 改从那里读取。这样团队每个人拉下代码、执行一次配置即可生效。
4.2、整体设计:我们要动哪些文件?
本方案共涉及 3 类操作,先列清楚"新增还是编辑",照着做即可:
| 操作 | 对象 | 新增 / 编辑 | 作用 |
|---|---|---|---|
| 配置 | core.hooksPath = githooks |
修改 Git 配置(非文件) | 让 Git 从 githooks/ 读取钩子 |
| 新增 | githooks/pre-commit |
新建文件 | 提交前自动打包并 git add |
| 新增 | githooks/pre-push |
新建文件 | 推送前校验产物是否过期 |
| 已有 / 新增 | bin/package_skills.py |
复用已有(无则新建) | 真正执行打包的脚本 |
注意:钩子文件必须可执行(
chmod +x),否则 Git 会静默忽略。若不确定,创建后统一执行一次:chmod +x githooks/pre-commit githooks/pre-push
我们要实现两条防线:
pre-commit(提交前):自动打包并把 zip 加入本次提交——解决"忘记打包"。pre-push(推送前):校验 zip 是否与源码一致,过期则拒绝推送——解决"推送过期产物"。
4.3、打包脚本 bin/package_skills.py(已有则可直接复用)
先看被打包脚本的核心逻辑(节选):
def should_skip(path: Path) -> bool:
"""判断某个路径是否应被排除出压缩包。"""
# 命中以下目录/文件一律跳过:Python 缓存、pytest 缓存、Git 内部目录、系统噪音文件
parts = set(path.parts)
return bool(parts & {"__pycache__", ".pytest_cache", ".git"}) \
or path.name == ".DS_Store" or path.suffix == ".pyc"
def package_skill(skill_dir: Path, output_dir: Path, force: bool) -> Path:
"""把 skill_dir 打包成 output_dir/<name>.zip 并返回产物路径。"""
output_dir.mkdir(parents=True, exist_ok=True) # 输出目录不存在则创建
target = output_dir / f"{skill_dir.name}.zip" # 最终产物:如 skills/open-office-skill.zip
# 先写临时文件,全部成功后再原子替换,避免中途失败留下损坏的 zip
tmp_target = target.with_suffix(".zip.tmp")
with zipfile.ZipFile(tmp_target, "w",
compression=zipfile.ZIP_DEFLATED, # 使用 deflate 压缩算法
compresslevel=9) as zf: # 9 = 最高压缩比(体积最小)
# 递归遍历源码目录并排序:排序保证打包顺序稳定、产物可复现
for path in sorted(skill_dir.rglob("*")):
rel = path.relative_to(skill_dir) # 相对路径,用于过滤判断
if should_skip(rel):
continue # 跳过噪音文件
arcname = Path(skill_dir.name) / rel # 包内路径:open-office-skill/xxx
if path.is_file():
zf.write(path, arcname) # 写入文件,保留原始目录层级
os.replace(tmp_target, target) # ← 原子替换:一次性替换为正式产物
return target
这里有三个值得学习的工程细节:
- 过滤噪音文件:
__pycache__、.DS_Store、.pyc一律不进包,保证产物干净、可复现。 - 先写临时文件,再原子替换:
os.replace()保证不会出现"打包到一半被打断导致 zip 损坏"的情况。 - 压缩级别 9 +
ZIP_DEFLATED:最大化压缩,减小用户下载体积。
4.4、pre-commit:提交即打包(新增文件 githooks/pre-commit)
新建文件 githooks/pre-commit,写入以下内容:
#!/usr/bin/env bash
# 提交前:强制把 skills/open-office-skill 打包为 zip,并加入本次提交。
# 启用方式(仓库根执行一次):git config core.hooksPath githooks
# -e 任一步失败即退出;-u 使用未定义变量报错;-o pipefail 管道任一环节失败即失败
set -euo pipefail
root="$(git rev-parse --show-toplevel)" # 取仓库根目录,保证从任意子目录执行都定位正确
cd "$root" # 切换到根目录再操作
if [ ! -d "skills/open-office-skill" ]; then # 源码目录不存在 → 不是本仓库场景
exit 0 # 直接放行,不影响在其他仓库复用该脚本
fi
echo "[githooks/pre-commit] 打包 Skill → skills/open-office-skill.zip"
python3 bin/package_skills.py --force > /dev/null # 重新打包(--force 覆盖旧产物,日志丢弃)
git add skills/open-office-skill.zip # 把新产物纳入本次提交
关键点解读:
set -euo pipefail:重点,任何一步失败即中断,避免"打包失败却还继续提交"。- 目录不存在时
exit 0优雅退出,避免污染其他仓库复用。 git add把重新生成的 zip 纳入本次提交,从源头保证"源码与产物同步落地"。
4.5、pre-push:过期即拦截(新增文件 githooks/pre-push)
新建文件 githooks/pre-push,写入以下内容:
#!/usr/bin/env bash
# 推送前:重新打包并要求 zip 已提交,避免推送过期产物。
set -euo pipefail # 同上:任何一步失败即中断
root="$(git rev-parse --show-toplevel)" # 定位仓库根目录
cd "$root" # 切换到根目录
if [ ! -d "skills/open-office-skill" ]; then # 非本仓库场景则跳过
exit 0
fi
echo "[githooks/pre-push] 校验 Skill zip 是否为最新"
python3 bin/package_skills.py --force > /dev/null # 用最新源码重新打一次包
# git diff --quiet 在"有差异"时返回非零;这里取反表示"存在差异"
if ! git diff --quiet -- skills/open-office-skill.zip; then
# 有差异 = 提交里的 zip 与最新源码对不上 = 过期产物
echo "[githooks/pre-push] skills/open-office-skill.zip 已过期(与最新源码不一致)。" >&2
echo " 已重新生成,请 git add 并提交后再 push。" >&2
exit 1 # 非零退出码 → 拒绝本次 push
fi
它的巧思在于**"以打包结果反推是否同步"**:
- 用最新源码重新打一次包;
- 用
git diff --quiet判断这次重打包是否改动了已提交的 zip; - 如果有差异,说明提交里的 zip 是旧的 →
exit 1拒绝推送。
注意:
git diff --quiet在"有差异"时返回非零,!取反后恰好表达"存在差异"。同时它只检查工作区与暂存/HEAD 的差异,配合-- <path>精确定位到 zip 文件,不误伤其他改动。
五、验证测试:让钩子真的跑起来
5.1、启用钩子
上面新增的钩子文件默认不会生效,还需两步收尾(在仓库根目录执行一次即可):
chmod +x githooks/pre-commit githooks/pre-push # 1. 赋予可执行权限(新增文件必做)
git config core.hooksPath githooks # 2. 让 Git 从 githooks/ 读取钩子
验证是否生效:
git config core.hooksPath应输出githooks。
5.2、验证 pre-commit 自动打包
随便修改 skills/open-office-skill 下的某个文件,然后提交:
echo "test" >> skills/open-office-skill/SKILL.md
git add skills/open-office-skill/SKILL.md
git commit -m "test: 验证 pre-commit 自动打包"
预期输出中会出现:
[githooks/pre-commit] 打包 Skill → skills/open-office-skill.zip
并可用 git show --stat HEAD 看到 skills/open-office-skill.zip 确实随本次提交更新。
5.3、验证 pre-push 拦截过期产物
现在模拟"忘记提交 zip"的场景——只提交源码、不回提交 zip:
git add skills/open-office-skill/SKILL.md
git commit --no-verify -m "test: 故意跳过 hook,制造过期 zip"
git push
预期 push 被拒绝:
[githooks/pre-push] 校验 Skill zip 是否为最新
[githooks/pre-push] skills/open-office-skill.zip 已过期(与最新源码不一致)。
已重新生成,请 git add 并提交后再 push。
error: failed to push some refs to '...'
按提示补一次提交即可恢复正常:
git add skills/open-office-skill.zip
git commit -m "chore: 同步最新 Skill zip"
git push
验证结论:
pre-commit负责"顺手帮你打包",pre-push负责"最后一道门"。两者配合,既降低了操作负担,又杜绝了过期产物外泄。
核心总结
1、Git Hooks 生命周期脉络
提交侧:pre-commit → prepare-commit-msg → commit-msg → post-commit
推送侧:pre-push
服务端:pre-receive → update → post-receive
pre-*可拦截(靠非零退出码),post-*只收尾;- 客户端钩子不随 clone 分发,也不防
--no-verify。
2、团队共享三方案
| 方案 | 共享性 | 成本 | 推荐度 |
|---|---|---|---|
手动复制 .git/hooks/ |
差 | 低 | ⭐ |
core.hooksPath 版本化目录 |
好 | 低 | ⭐⭐⭐ |
| Husky / pre-commit 框架 | 好 | 中 | ⭐⭐ |
3、本项目的落地组合
git config core.hooksPath githooks:一次性启用,钩子纳入版本管理;pre-commit:自动打包 +git add,防"忘记打包";pre-push:重打包 +git diff --quiet校验,防"推送过期产物"。
4、重点避坑
- ❌ 不要把打包产物写进
.gitignore后又指望推送它(本项目 zip 是纳管产物,必须提交); - ❌ 不要在
pre-commit里exit 0掩盖失败,务必set -e让错误显性化; - ❌ 不要只依赖客户端钩子做最终把关,团队规范建议在 CI 侧再校验一次产物一致性;
- ✅ 打包脚本要过滤噪音文件 + 原子替换,保证产物可复现、不损坏。
参考资料
[1]. Git 官方文档 - Customizing Git - Git Hooks
[2]. git-config 手册 - core.hooksPath
[3]. git rev-parse 手册 - --show-toplevel
[4]. Husky - Git hooks made easy
[5]. pre-commit - A framework for managing and maintaining multi-language pre-commit hooks
整理者:长路 创建时间:2026.10.11 更新时间:2026.10.11
评论区请在客户端页面查看