Git Hook + Codex自动生成Commit说明:为什么我不让AI直接提交

目录
环境信息
- Git 2.51.1
- Python 3.12.13
os、re、subprocess为Python标准库,无需额外安装依赖。- Codex CLI:命令行中可以执行
codex;本文使用codex exec --sandbox read-only。
很多人的提交记录最后会变成三种样子:update、fix bug、临时改一下。过几周再回头看,真正有用的信息几乎没有。
我今天想拆的不是“如何让AI自动提交代码”,而是一个更具体、也更容易落地的问题:能不能让Codex根据已经放入暂存区的改动,生成一条可修改、可校验的Commit说明?
答案是可以,但不要把git add、git commit一起交给Agent。本文把整个过程拆成四个边界:输入只取暂存区diff,生成过程使用只读调用,输出先交给人确认,最终文本再交给Git Hook校验。

这条链路的关键不是“让AI更大胆”,而是让它只能在一个很小的输入和输出范围里工作。
1. 先把自动生成和自动提交分开
更稳妥的边界是:
- 只读取
git diff --cached,也就是已经明确放进暂存区的改动。 - 先做截断和脱敏,再让Codex生成一条候选说明。
- 把候选说明写进Git的提交信息文件,开发者仍然可以修改。
- 用
commit-msg检查格式,最后由人执行git commit。
2. Git的三个Hook,别放错职责
Git 官方文档对这些 Hook 的时机有明确区分。pre-commit 适合跑格式检查、单元测试和 staged 文件检查,但这个阶段还没有最终的提交说明;prepare-commit-msg 收到提交信息文件,可以修改文件内容;commit-msg 负责检查最终文本,可以拒绝不合规的提交。
这里有一个容易被忽略的细节:prepare-commit-msg 不能被 --no-verify 跳过,而 pre-commit 和 commit-msg 可以被这个参数跳过。因此,本地 Hook 是开发辅助,不是不可绕过的安全边界。真正不能漏过的规则,应该在 CI、远端仓库或受保护分支上再检查一次。

3. 只把暂存区diff交给只读调用
git diff 默认看到的是工作区变化;git diff --cached(也可写成 --staged)看到的是暂存区与 HEAD 的差异。提交说明应该描述“这次准备提交什么”,所以这里必须使用后者。
下面这个脚本只使用 Python 标准库。它做了三件事:读取 staged diff、对常见的键名做一次粗粒度脱敏、限制输入长度。脱敏只是最后一道缓冲,不代表可以把 .env、私钥或生产配置直接加入暂存区。
# scripts/generate_commit_message.py
import os
import re
import subprocess
import sys
COMMIT_RE = re.compile(r"^(feat|fix|refactor|test|docs|chore): .{1,72}$")
SECRET_RE = re.compile(
r"(?i)(api[_-]?key|token|secret|password)\s*[:=]\s*[\"']?[^\s\"']+"
)
def staged_diff() -> str:
result = subprocess.run(
["git", "diff", "--cached", "--no-ext-diff", "--unified=3", "--"],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
return result.stdout
def clean_diff(diff: str, limit: int = 16000) -> str:
diff = SECRET_RE.sub(r"\1=[REDACTED]", diff)
if len(diff) > limit:
diff = diff[:limit] + "\n...[TRUNCATED]"
return diff
def valid_subject(output: str) -> str:
for raw in output.splitlines():
line = raw.strip().strip("`")
if COMMIT_RE.fullmatch(line):
return line
return ""
def main() -> int:
diff = clean_diff(staged_diff())
if not diff.strip():
return 0
# 让测试可以不依赖网络和账号;真实运行时删除这个分支即可。
if os.getenv("CODEX_DRY_RUN") == "1":
print("chore: update staged changes")
return 0
prompt = (
"Read the staged diff from stdin and output exactly one line. "
"Use one of feat, fix, refactor, test, docs, chore followed by ': '. "
"Write an imperative subject, no markdown, no explanation, max 72 chars. "
"Do not invent behavior that is absent from the diff."
)
codex = os.getenv("CODEX_BIN", "codex")
result = subprocess.run(
[codex, "exec", "--ephemeral", "--sandbox", "read-only", prompt],
input=diff,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=45,
check=False,
)
subject = valid_subject(result.stdout)
if subject:
print(subject)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, subprocess.SubprocessError) as exc:
print(f"Codex unavailable; keep commit message manual: {exc}", file=sys.stderr)
raise SystemExit(0)
这里用的是 codex exec,不是让 Agent 进入交互式终端。官方文档说明,非交互模式默认使用只读沙箱,并且最终文本会写到标准输出,适合接到脚本里。脚本还设置了失败即回退:Codex 没装、超时或输出不符合格式时,不阻塞正常提交。
4. 让prepare-commit-msg填入候选文本
#!/bin/sh
# .githooks/prepare-commit-msg
set -eu
MSG_FILE="$1"
SOURCE="${2-}"
# 合并提交、压缩提交和已有提交信息不由模型重写。
case "$SOURCE" in
merge|squash|commit) exit 0 ;;
esac
git diff --cached --quiet && exit 0
TMP_FILE="${MSG_FILE}.codex.tmp"
trap 'rm -f "$TMP_FILE"' EXIT HUP INT TERM
python3 scripts/generate_commit_message.py > "$TMP_FILE"
if [ -s "$TMP_FILE" ]; then
cat "$TMP_FILE" > "$MSG_FILE"
fi
不要在这个 Hook 里直接执行 git commit。否则一个本来只是“生成文本”的动作,就会变成会改变仓库历史的动作;而且提交前的人工检查也被绕开了。
5. 用commit-msg拦住明显不合规的文本
#!/bin/sh
# .githooks/commit-msg
set -eu
MSG_FILE="$1"
FIRST_LINE="$(sed -n '1p' "$MSG_FILE")"
case "$FIRST_LINE" in
feat:*|fix:*|refactor:*|test:*|docs:*|chore:*) ;;
*)
echo "commit subject must start with feat/fix/refactor/test/docs/chore:" >&2
exit 1
;;
esac
if [ "${#FIRST_LINE}" -gt 72 ]; then
echo "commit subject is longer than 72 characters" >&2
exit 1
fi
if grep -Eiq '(api[_-]?key|token|secret|password)[[:space:]]*[:=]' "$MSG_FILE"; then
echo "possible secret found in commit message" >&2
exit 1
fi
格式校验故意保持简单。它不是为了把团队变成“提交信息警察”,而是让自动生成失败时有一个清晰的回退路径:删除候选文本,手动写一条准确的说明。
6. 安装Hook并运行
在仓库根目录执行:
mkdir -p .githooks
git config core.hooksPath .githooks
chmod +x .githooks/prepare-commit-msg .githooks/commit-msg
git add src/app.py
git commit
首次验证建议先不调用Codex:
CODEX_DRY_RUN=1 git commit
然后检查三种情况:
| 场景 | 预期结果 |
|---|---|
工作区有改动,但没有 git add |
不生成候选说明 |
| staged diff 正常 | 出现 chore: update staged changes,可以人工修改 |
手动写成 update |
commit-msg 拒绝提交 |
真实使用时,把 CODEX_DRY_RUN 去掉即可。Git Hook 文件可以提交到仓库,core.hooksPath 的配置则需要每位开发者在本地安装;团队若要统一执行,应在 CI 中复用同一套检查。
7. 先验证三个容易出错的边界
有三个风险不能靠一段脚本消失:
第一,模型看到的 diff 可能带出密钥。本文的正则只能识别一小部分 key=value 形式,不能替代密钥扫描,更不能替代提交前的人工检查。
第二,--no-verify 可以绕过部分本地 Hook。Git 官方 FAQ 也提醒,客户端 Hook 不适合作为强制策略;需要强制执行的规则必须放在服务器端或 CI。
第三,提交说明不是事实证明。AI 可能把重命名写成重构,把局部修复写成“解决全部问题”。所以提示词里要限制“只能依据 diff”,而最终的责任仍在提交者。
8. 如果要扩展到团队仓库
在个人实验仓库里,自动生成提交说明很省心;在真实项目里,我只会让Codex生成候选文本,不会让它直接写入Git历史。工程上值得自动化的是重复劳动,不能自动放弃的是权限边界、敏感信息检查和最终确认。
如果要在团队仓库中推广,建议把.githooks目录纳入版本控制,并把关键检查同步到CI或受保护分支。原因很现实:本地Hook可以被--no-verify绕过,不能把它当作唯一的强制边界。
如果你也在用Agent生成Commit,最值得先检查的不是模型名称,而是这三个问题:它读的是工作区还是暂存区?它有没有git commit权限?本地检查被绕过之后,远端还有没有第二道门?
总结
这次真正值得自动化的,不是让AI替开发者写入Git历史,而是把“查看改动、整理提交说明、检查格式”这些重复劳动压缩成一个可回退的流程。
最终的边界可以概括成一句话:Codex只读取经过人工确认的暂存区diff,只输出候选文本;开发者负责确认,Git负责校验,CI或远端仓库负责最后的强制检查。
参考资料
代码说明:本文示例默认在macOS/Linux的Git仓库中运行;Windows用户可将
python3替换为本机Python命令,并保持Hook文件具备可执行权限。正则脱敏只覆盖常见键名,不能代替正式的密钥扫描。

葡萄城是专业的软件开发技术和低代码平台提供商,聚焦软件开发技术,以“赋能开发者”为使命,致力于通过表格控件、低代码和BI等各类软件开发工具和服务,一站式满足开发者需求,帮助企业提升开发效率并创新开发模式。
更多推荐
所有评论(0)