用AI在git提交前自动审查代码:Mini Reviewer搭建实践
代码提交前的自查,很多人靠的是"再扫一眼"和"应该没问题吧"这两样东西。我干过太多次这种事:本地跑通了,测试也过了,git push一按,CI 红了一片,回头一看是个变量名拼错、一个边界条件没处理、或者干脆把调试用的print带上去了。后来我想,既然大模型现在读代码的能力已经够用,为什么不把它接进提交链路里,让它在git commit之前先帮我审一遍本次改动的 diff?这就是我做 Mini Reviewer 的起点。
这个工具做的事情很聚焦:拿到你本次准备提交的代码变更,调用 AI 做一轮结构化审查,把问题按严重程度列出来,让你在提交前决定是修还是忽略。它不替代 CI,也不替代人工 review,它解决的是"提交前最后一道自检"这个空档。适合有一定 Python 基础、日常用 git 管理代码、想给自己加一道自动化防线的开发者。下面我把整个搭建过程、设计取舍和踩过的坑完整讲一遍。
1. 先想清楚 Mini Reviewer 到底该审什么
动手写代码之前,最容易犯的错是"什么都想审"。我第一版就是这么干的,把整个仓库丢给模型,让它"全面审查代码质量",结果返回一堆泛泛而谈的建议,什么"建议增加注释""考虑模块化",全是废话,还烧了不少 token。后来我重新定义了它的职责边界,工具才真正有用。
1.1 只审本次 diff,不审全仓库
这是最核心的设计决策。全仓库审查有两个致命问题:一是上下文太长,模型注意力被稀释,真正的问题反而被淹没;二是每次提交都重复审同样的老代码,纯属浪费。而本次 diff 恰好是"你刚刚改动的部分",是风险最集中的区域,也是你最该关注的地方。
获取 diff 的命令很简单:
git diff --cached注意这里用的是--cached,也就是暂存区。为什么不是git diff?因为git diff显示的是工作区和暂存区的差异,而--cached显示的是暂存区和上次提交的差异,也就是"你这次真正要提交的内容"。这个区别很关键,用错了审的就不是同一批代码。
1.2 审查维度要收敛到可执行的几类
我把审查维度收敛成四类,每类都要求模型给出具体的行号和修改建议,而不是泛泛而谈:
- 正确性缺陷:空指针、越界、类型不匹配、逻辑分支遗漏、异常未处理
- 安全隐患:硬编码密钥、SQL 拼接、命令注入、不安全的反序列化
- 性能问题:循环内的重复计算、N+1 查询、不必要的全量拷贝
- 可维护性:命名混乱、魔法数字、重复代码、调试残留
关键点在于,每一类都要求模型"指出具体位置 + 说明为什么是问题 + 给出修改方向"。如果它给不出具体位置,那这条建议就丢弃。这个约束能过滤掉绝大部分空话。
1.3 输出要能直接指导行动
我要求输出是结构化的,每条问题包含:文件路径、行号、严重等级(高/中/低)、问题描述、修改建议。这样我扫一眼就知道哪些必须改、哪些可以放一放。严重等级用高/中/低三档就够了,分太细反而增加判断成本。
提示:审查维度不要贪多。我试过加到七八个维度,结果模型每条都蜻蜓点水,反而不如聚焦四类来得深。维度少而深,比多而浅有用得多。
2. 用 Python 把 git diff 和 AI 调用串起来
工具的主体就是一个 Python 脚本,逻辑不复杂,但有几个细节处理不好就会翻车。我按数据流顺序讲:先拿 diff,再清洗,再构造 prompt,最后调模型。
2.1 拿到干净的 diff 文本
直接subprocess调 git 就行,但要注意编码和空 diff 的处理:
import subprocess def get_staged_diff(): result = subprocess.run( ["git", "diff", "--cached", "--unified=3"], capture_output=True, text=True, encoding="utf-8" ) if result.returncode != 0: raise RuntimeError(f"git diff 执行失败: {result.stderr}") return result.stdout这里--unified=3是控制上下文行数的,默认就是 3,显式写出来是为了明确意图。上下文太少模型看不懂改动意图,太多又浪费 token,3 行是个经过验证的平衡点。
拿到 diff 后第一件事是判断它是不是空的。如果暂存区没东西,直接提示用户"没有待提交的改动",别傻乎乎地去调模型。
2.2 diff 太长怎么办:分块而不是截断
这是踩过坑的地方。有一次我改了一个大文件,diff 有几千行,直接丢给模型,结果它只审了开头一部分,后面的全忽略了。截断更糟,等于假装审了实际没审。
正确做法是按文件分块。diff 的结构天然以diff --git开头分文件,可以据此切分:
def split_diff_by_file(diff_text): chunks = [] current = [] for line in diff_text.splitlines(): if line.startswith("diff --git"): if current: chunks.append("\n".join(current)) current = [line] else: current.append(line) if current: chunks.append("\n".join(current)) return chunks每个文件块单独送审,这样既不会超上下文,也不会漏审。如果单个文件的 diff 还是太长(比如超过 800 行),可以再按 hunk(@@开头)细分。实测下来,按文件分块已经能覆盖绝大多数场景。
2.3 构造 prompt 的几个讲究
prompt 写得好不好,直接决定审查质量。我反复调整后固定成这个结构:
SYSTEM_PROMPT = """你是一名严格的代码审查员。你只审查用户提供的代码 diff, 不要评价未出现在 diff 中的代码。对每个问题,必须给出: 1. 文件路径和行号 2. 严重等级(高/中/低) 3. 问题描述(一句话说清) 4. 修改建议(具体到怎么改) 如果某段 diff 没有问题,不要强行找问题。""" USER_TEMPLATE = """请审查以下代码变更: {file_diff} 请按以下格式输出,每条问题一行: [等级] 文件:行号 - 问题描述 | 建议:修改方向 """几个关键点:第一,明确告诉它"只审 diff 里的代码",否则它会去猜上下文然后瞎编;第二,明确说"没问题就别硬找",否则模型有讨好倾向,会编出一些不存在的问题;第三,输出格式固定成一行一条,方便后续解析。
2.4 调用模型与结果解析
调用部分用标准的 HTTP 请求即可,这里不绑定具体厂商,任何提供对话接口的服务都能接。核心是把 system prompt 和 user prompt 传进去,拿到文本结果。解析时按行处理,用正则提取等级、位置、描述:
import re PATTERN = re.compile(r"\[(高|中|低)\]\s*(.+?):(\d+)\s*-\s*(.+?)\s*\|\s*建议:(.+)") def parse_findings(text): findings = [] for line in text.splitlines(): m = PATTERN.match(line.strip()) if m: findings.append({ "level": m.group(1), "file": m.group(2), "line": m.group(3), "issue": m.group(4), "suggestion": m.group(5), }) return findings解析不出来的行直接丢弃,不要试图"猜"模型的意思。宁可漏报,不可误报,因为误报会消耗你对工具的信任。
3. 把它挂到 git 提交链路上
工具写完了,如果每次都要手动跑,用不了几天就懒得用了。真正让它产生价值的是自动化——挂到 git hook 上,提交时自动触发。
3.1 用 pre-commit hook 拦截提交
git 的pre-commithook 在git commit执行时、真正生成提交对象之前运行。如果 hook 返回非零退出码,提交就会被中止。这正是我们需要的拦截点。
在仓库的.git/hooks/目录下创建pre-commit文件(注意没有扩展名):
#!/bin/sh python /path/to/mini_reviewer.py exit $?然后给它执行权限:
chmod +x .git/hooks/pre-commit这样每次git commit都会先跑一遍审查。但这里有个体验问题:如果审查发现问题就中止提交,会打断你的工作流。我的做法是分级处理——只有"高"等级问题才中止提交,"中""低"等级只打印警告。
3.2 退出码的设计
退出码是 hook 和脚本之间的通信语言,设计清楚很重要:
| 退出码 | 含义 | 对提交的影响 |
|---|---|---|
| 0 | 无问题或仅有低等级问题 | 允许提交 |
| 1 | 存在高等级问题 | 中止提交 |
| 2 | 脚本自身出错(如网络失败) | 中止提交并提示 |
这里有个细节:脚本自身出错时,我选择中止提交而不是放行。理由是,如果审查没跑成功你却以为跑了,这种"虚假的安全感"比不审查更危险。当然,如果你赶时间,可以加一个--skip-review参数手动绕过。
3.3 网络失败与超时的兜底
调用外部服务必然会有失败的时候。我遇到过网络抖动导致请求超时,结果提交被卡住,很烦。后来加了超时和重试:
import time def call_with_retry(fn, retries=2, timeout=30): for i in range(retries + 1): try: return fn(timeout=timeout) except TimeoutError: if i == retries: raise time.sleep(2 ** i)超时设 30 秒,重试 2 次,指数退避。超过就报错退出,让用户自己决定是重试还是跳过。不要无限重试,那会让提交卡死。
注意:hook 里跑网络请求会让提交变慢。如果你一天提交几十次,这个延迟会累积。我的建议是只在改动较大时才启用自动审查,小改动(比如改个文案)手动跳过。
4. 实测中暴露的问题和调优
工具跑起来只是开始,真正让它好用是在一轮轮实测中磨出来的。下面这几个问题都是我实际遇到的,每个都值得单独说。
4.1 误报太多会毁掉信任
第一版跑下来,模型报了一堆"问题",其中一半是误报。比如它把if x is None报成"应该用==",把正常的日志输出报成"调试残留"。误报多了,你就会开始无视所有报告,工具就废了。
我的解决办法是在 prompt 里加"反例约束":明确列出哪些不算问题。比如"日志输出不算调试残留""显式的 None 判断是推荐写法"。加了这些约束后,误报率明显下降。另一个办法是让模型对每个问题给出"置信度",只展示高置信度的。
4.2 上下文缺失导致的"假问题"
diff 只有改动部分,模型看不到完整的函数上下文,有时会误判。比如你删了一行,模型以为你漏写了,其实那行是被移到别处了。这种问题没法完全避免,但可以缓解:在 prompt 里附上改动所在函数的完整定义。
获取函数上下文可以用git diff的-U参数加大上下文行数,或者用git show拿到改动后的完整文件再截取相关函数。我选的是后者,虽然多一步,但准确率高不少。
4.3 token 成本和速度的平衡
按文件分块送审虽然准,但文件多了调用次数就多,成本和延迟都上去了。我的优化是:小文件(diff 少于 50 行)合并成一批送审,大文件单独送。这样既控制了单次请求的规模,又减少了请求次数。
另外,审查结果可以缓存。如果同一个文件的 diff 哈希没变,直接复用上次结果,不用重复调用。这个优化在反复提交调试时特别有用。
4.4 别让它变成"橡皮图章"
最危险的情况是:工具说没问题,你就真的不看了。AI 审查有盲区,它可能漏掉业务逻辑错误、架构层面的问题。我始终把它定位成"第一道筛子",而不是"最终裁判"。高等级问题必须人工确认,中低等级可以快速扫过。这个心态摆正了,工具才能真正帮到你,而不是让你变懒。
5. 几个让工具更顺手的扩展方向
基础版跑通后,我陆续加了几个小功能,用起来顺手很多。这些不是必须的,但如果你打算长期用,值得考虑。
5.1 按严重等级着色输出
终端里用 ANSI 颜色码给不同等级上色,高等级红色、中等级黄色、低等级灰色,扫一眼就知道重点在哪。实现很简单:
COLORS = {"高": "\033[91m", "中": "\033[93m", "低": "\033[90m"} RESET = "\033[0m" def format_finding(f): color = COLORS.get(f["level"], "") return f"{color}[{f['level']}] {f['file']}:{f['line']} - {f['issue']}{RESET}"5.2 生成审查报告存档
每次审查的结果存成 Markdown 文件,放在.review_history/目录下,按时间戳命名。这样你可以回溯"上次这个问题是不是提过",也能看到自己的代码质量趋势。我坚持存了几个月,发现某些类型的问题反复出现,就针对性地调整了编码习惯。
5.3 支持自定义规则
不同项目关注点不同。比如做数据处理的脚本特别在意异常处理,做 Web 服务的特别在意输入校验。我加了一个配置文件,允许按项目指定重点审查的维度:
focus: - 异常处理 - 输入校验 ignore_patterns: - "tests/" - "*.md"这样同一个工具在不同项目里能自动切换关注点,不用每次改代码。
5.4 和 CI 的配合
本地 hook 是"提交前"的防线,CI 是"提交后"的防线,两者不冲突。我把审查脚本也接进了 CI,但 CI 里只跑"高等级问题检查",作为合并前的最后一道关卡。本地 hook 可以跳过,CI 不能跳过,这样既保证了灵活性,又守住了底线。
6. 关于这套方案我踩过的几个真实坑
最后说几个具体的坑,都是我在实际使用中撞过的,希望能帮你少走弯路。
第一个坑是编码问题。Windows 上subprocess默认用系统编码,遇到中文注释的 diff 会乱码,导致模型看不懂。解决办法是显式指定encoding="utf-8",并且在 git 配置里设置core.quotepath false,避免中文路径被转义。
第二个坑是hook 里的工作目录。git hook 执行时的工作目录不一定是仓库根目录,如果你脚本里用了相对路径就会找不到文件。稳妥做法是在脚本开头os.chdir到仓库根目录,或者所有路径都用绝对路径。
第三个坑是大文件二进制 diff。如果暂存区里有图片、编译产物这类二进制文件,git diff会输出一堆乱码,送给模型纯属浪费。要在分块后过滤掉二进制块,判断依据是 diff 里是否包含Binary files字样。
第四个坑是模型对 diff 格式的误解。diff 里的+和-前缀,模型有时会理解反,把删除的行当成新增的。解决办法是在 prompt 里明确说明"+开头是新增行,-开头是删除行",并给一个例子。这个说明加上之后,理解错误基本消失了。
这套工具我从最初几十行的脚本,慢慢磨到现在能稳定用,前后大概花了两个周末。它没有多高深的技术,核心就是把"git diff 获取""AI 结构化审查""hook 自动触发"这三件事串好,再在细节上反复打磨。真正让它有价值的不是 AI 本身,而是你对"审什么、怎么审、审完怎么办"这些问题的思考。工具是死的,判断是活的,把它当成一个不知疲倦的初级审查员,而不是一个权威,你的代码质量会实实在在地提升。