在这里插入图片描述

环境信息

  • Git 2.51.1
  • Python 3.12.13
  • osresubprocess为Python标准库,无需额外安装依赖。
  • Codex CLI:命令行中可以执行codex;本文使用codex exec --sandbox read-only

很多人的提交记录最后会变成三种样子:updatefix bug临时改一下。过几周再回头看,真正有用的信息几乎没有。

我今天想拆的不是“如何让AI自动提交代码”,而是一个更具体、也更容易落地的问题:能不能让Codex根据已经放入暂存区的改动,生成一条可修改、可校验的Commit说明?

答案是可以,但不要把git addgit commit一起交给Agent。本文把整个过程拆成四个边界:输入只取暂存区diff,生成过程使用只读调用,输出先交给人确认,最终文本再交给Git Hook校验。

在这里插入图片描述

这条链路的关键不是“让AI更大胆”,而是让它只能在一个很小的输入和输出范围里工作。

1. 先把自动生成和自动提交分开

更稳妥的边界是:

  1. 只读取git diff --cached,也就是已经明确放进暂存区的改动。
  2. 先做截断和脱敏,再让Codex生成一条候选说明。
  3. 把候选说明写进Git的提交信息文件,开发者仍然可以修改。
  4. commit-msg检查格式,最后由人执行git commit

2. Git的三个Hook,别放错职责

Git 官方文档对这些 Hook 的时机有明确区分。pre-commit 适合跑格式检查、单元测试和 staged 文件检查,但这个阶段还没有最终的提交说明;prepare-commit-msg 收到提交信息文件,可以修改文件内容;commit-msg 负责检查最终文本,可以拒绝不合规的提交。

这里有一个容易被忽略的细节:prepare-commit-msg 不能被 --no-verify 跳过,而 pre-commitcommit-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或远端仓库负责最后的强制检查。

参考资料

  1. Git Hooks 官方文档
  2. git diff 官方文档
  3. Codex CLI 非交互模式官方文档

代码说明:本文示例默认在macOS/Linux的Git仓库中运行;Windows用户可将python3替换为本机Python命令,并保持Hook文件具备可执行权限。正则脱敏只覆盖常见键名,不能代替正式的密钥扫描。

在这里插入图片描述

Logo

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

更多推荐