Git篇:提交/推送代码时自动打包文件——Git Hooks 原理与实战

coverImg

起因

本文的实战载体是开源项目 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

我们要实现两条防线:

  1. pre-commit(提交前):自动打包并把 zip 加入本次提交——解决"忘记打包"。
  2. 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

这里有三个值得学习的工程细节:

  1. 过滤噪音文件:__pycache__、.DS_Store、.pyc 一律不进包,保证产物干净、可复现。
  2. 先写临时文件,再原子替换:os.replace() 保证不会出现"打包到一半被打断导致 zip 损坏"的情况。
  3. 压缩级别 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

它的巧思在于**"以打包结果反推是否同步"**:

  1. 用最新源码重新打一次包;
  2. 用 git diff --quiet 判断这次重打包是否改动了已提交的 zip;
  3. 如果有差异,说明提交里的 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

评论区请在客户端页面查看