别只在 prompt 里让 AI 别碰文件:我给仓库装了四道提交闸门,越界直接拒绝提交

上一篇讲怎么用 Cursor 的 Agent 做多文件改造,里面有个关键动作:在 prompt 里把"能碰哪些文件、绝对不许碰哪些"写清楚。评论区一位读者点醒了我,他说只写在 prompt 里的约束,Agent 迟早会在某个顺手的时刻越界;更稳的做法是把禁碰区变成机器能校验的规则,越界就直接拒绝提交,验收标准也最好在动手前先写成会失败的测试,改完跑绿才算数。

我后来真按他说的把这套补了一遍,发现这才是"敢放心让 Agent 动手"的最后一块拼图。前面几篇讲的规则、分支、回滚、补测试,更多是靠人记得去做;提交闸门的价值,是不管你或 AI 那一刻有没有想起来,规则在提交的瞬间会被强制执行,根本没有"手滑提交"的机会。

这篇把四道闸门从易到难讲一遍:CODEOWNERS 给禁区上锁、pre-commit 钩子在提交前自动检查、一个路径白名单脚本专门拦越界改动、动手前先写会失败的红测试。涉及的 git 钩子、配置文件名、托管平台菜单在不同版本上略有差异,具体字段以你用的平台官方当期文档为准。

一、为什么只写在 prompt 里的约束,迟早会被越界

prompt 里的约束本质是"请求",不是"强制"。模型要理解、记住、再执行,这条链路每一步都有概率掉链子:会话聊得很长、上下文被截断、它觉得顺手改一下公共方法更省事,那句"不许碰 src/api"就可能被悄悄绕过去。它不是故意的,是在追求把活干完的过程中,自然地选择了它眼里更顺的路径。

人工逐块审 diff 也一样,它是一道"人肉闸门",前提是你每次都认真看。可赶上赶进度、改动一大片、diff 看着都正常,人很容易就一键全收了。至于检查点和分支回滚,那是出事后的补救,坏改动这时候已经进了提交历史,能退不等于没发生。

机器闸门不一样。它在提交或合并的那一刻自动跑检查,不通过就中止,没有商量,也不看你当天累不累。

手段 靠什么生效 什么时候会失效
prompt 里写禁碰区 模型记得住 长会话、上下文被截断、它觉得顺手
人工逐块审 diff 你每次都认真看 累、赶时间、改动太多看不过来
检查点 / 分支回滚 出事后能退 是事后补救,坏改动已经进了历史
机器提交闸门 提交那一刻强制执行 只有规则没配到才会漏,配了就拦

我把要补的防护归成四道,从外到内、从易到难:

  1. CODEOWNERS:给禁区指定负责人,改动必须走审批,这是仓库托管层的锁;
  2. pre-commit 钩子:每次提交前自动跑格式、lint、快速测试,这是本地通用防护;
  3. 路径白名单脚本:给 Agent 这次任务圈定允许改动的目录,越界直接拒绝,这是给 AI 定制的保险;
  4. 先写红测试:验收标准在动手前就写成会失败的测试,改完跑绿才算数,这是把"完成"的标准钉死。

前两道任何项目都该有,属于通用卫生;后两道越是让 Agent 大范围改、越值得配。

二、第一道:CODEOWNERS 给禁区上锁

CODEOWNERS 是代码托管平台支持的一个文件,作用是把"哪些路径归谁管"写在仓库里。它的内容不复杂,每行是一条路径模式加负责人,路径匹配到的代码一旦被改动,就必须由对应负责人审批,配合平台的"保护分支 / 必需评审",这些改动的合并请求没有批准就合不进去。

文件一般放在仓库根目录或平台约定的位置,主流代码托管平台都支持,具体放置路径和语法以你用的平台当期文档为准。下面是一份示意,每行前面是路径、后面跟负责人(实际填你平台上的团队或账号名,这里用中文占位):

text 复制代码
# 公共请求层、拦截器,只有基础设施组能改
/src/api/           基础设施组
# 算钱、金额、支付相关
/src/billing/       后端负责人
# CI、依赖清单、构建配置
/.github/           基础设施组
/package.json       基础设施组
/package-lock.json  基础设施组

个人项目或者两三个人的小团队,也别觉得这东西用不上。你可以把这些敏感路径都指给自己,它的实际作用是"强制走一次显式批准",而不是在本地一个回车就把公共层改了直接提交。哪怕只是多一道"我确实要动这里"的确认,也能挡掉绝大多数手滑。

要清楚它的边界:CODEOWNERS 拦的是"合并 / 推送",拦不住你在本地编辑、本地提交。它是仓库门口的保安,不是工位旁边的监督,所以本地还得配上下面这道钩子。

三、第二道:pre-commit 钩子,提交前自动跑

git 本身留了钩子的位置:每次提交前,它会先执行钩子脚本,脚本返回非零,这次提交就被中止。这意味着你可以把平时"记得就跑、不记得就跳过"的检查,变成每次提交都强制跑一遍。

配法有两种。一种是直接用仓库里的原生钩子,优点是简单,缺点是钩子文件不随仓库分发、不进版本管理,团队每个人得各自装一遍。另一种是用 pre-commit 这类钩子管理框架,把检查项写进一份随仓库走的配置文件,成员安装一次就全员生效,具体框架名和配置文件名以你实际选用的为准。

钩子里放什么?格式化检查、lint、类型检查,以及和改动相关的快速测试。下面这个脚本是个最小示意,把你项目里实际的检查命令替换进去就行:

python 复制代码
# scripts/preflight_check.py 提交前的快速检查(示意)
import subprocess
import sys

# 只放秒级能跑完的检查;几分钟的全量测试放 CI,别塞钩子
CHECKS = [
    ["python", "-m", "compileall", "-q", "src"],
    # 换成你项目真实的 lint / 快速测试命令
    ["pytest", "-q", "tests/test_order_filter.py"],
]

def run(cmd):
    print(">", " ".join(cmd))
    return subprocess.run(cmd).returncode == 0

def main():
    if not all(run(cmd) for cmd in CHECKS):
        print("提交前检查没通过,已中止提交。修好再来。")
        sys.exit(1)
    print("提交前检查通过。")

if __name__ == "__main__":
    main()

让它在提交前跑起来:用原生钩子的话,在钩子目录里让 pre-commit 去调用这个脚本并赋予可执行权限;用框架的话,把它登记成一个本地钩子。两种方式都是一次性配置,之后自动生效。

这里有个容易被忽略的分寸:钩子要快。你要是把全量测试套件塞进提交前钩子,一跑几分钟,队友和你自己都会忍不住找跳过参数,防护就形同虚设。正确做法是钩子只放秒级的快速检查,耗时的完整测试放到 CI 上跑。

还有那个"跳过钩子"的口子要管住。git 允许提交时带参数跳过钩子,团队要约定非紧急不许用,而且关键检查在服务端(CODEOWNERS 加必需的 CI 检查)再兜底一道------本地就算跳了,服务端检查不过,照样合不进主干。本地钩子和服务端检查是两层,不能只靠一层。

四、第三道:路径白名单脚本,专门拦 Agent 越界

这是给 AI 改代码专门配的一道。场景很具体:你让 Agent 做一个只该动订单页面的需求,它干着干着,觉得公共请求层某个方法不顺手,"帮你"顺手改了一下------这种改动你审 diff 时一眼看漏,就进仓库了。

思路是评论里那位读者给的:提交前跑一个脚本,取出本次改动的文件清单,挨个核对是不是都在允许的路径里,只要冒出禁区文件,直接拒绝提交。取改动清单用 git 自带的按文件名列出改动的能力,下面是一份可直接抄的 Python 实现,全程没有花里胡哨的东西:

python 复制代码
# scripts/check_changed_paths.py 提交前路径白名单检查
import subprocess
import sys

# 这次任务允许改动的路径前缀;换一个需求就改这里
ALLOWED_PREFIXES = (
    "src/views/order/",
)
# 任何时候都不许碰的路径和文件(永久禁区)
FORBIDDEN = (
    "src/api/common/",
    "src/router/index.ts",
    "package.json",
    "package-lock.json",
)

def changed_files():
    # 列出已暂存的改动文件;想对主干检查就换成对应分支的对比
    result = subprocess.run(
        ["git", "diff", "--name-only", "--cached"],
        capture_output=True,
        text=True,
    )
    return [line.strip() for line in result.stdout.splitlines() if line.strip()]

def main():
    files = changed_files()
    blocked = []
    for path in files:
        if path.startswith(FORBIDDEN):
            blocked.append(("碰到永久禁区", path))
        elif not path.startswith(ALLOWED_PREFIXES):
            blocked.append(("超出本次允许范围", path))
    if blocked:
        print("本次提交有越界文件,已拒绝:")
        for reason, path in blocked:
            print("  [%s] %s" % (reason, path))
        print("如果确实要改,请先人工确认,再更新白名单。")
        sys.exit(1)
    print("改动路径检查通过,共", len(files), "个文件")

if __name__ == "__main__":
    main()

把它挂进提交钩子,Agent 那次"顺手改公共层"的提交就会被拦下来,脚本还会把越界文件名列得清清楚楚,你不用在大段 diff 里自己找。

它可以配两种清单:一种是"本次任务的允许清单",随需求换,比如这次只开 src/views/order;另一种是"永久禁区",比如公共请求层、路由、依赖清单,任何任务都不许直接动。

也要说清它管不了什么:它只核对文件路径,不看文件内容。你允许 Agent 改订单页面,它在这个页面里写了错误逻辑,路径脚本是看不出来的------那是红测试和人工 review 的活。路径闸门专门解决"它动了不该动的文件"这一类问题,别指望它包打天下。

五、第四道:先写红测试,把验收标准钉在动手之前

上一篇讲的是怎么给老项目补测试:特征测试、最小回归、开接缝。这一节只往前挪一步,讲一件事------把验收测试的位置从"改完之后"挪到"动手之前",让它成为这次任务能不能算完成的闸门。

事后让 AI"自己验证一下",它的常见动作是跑一遍、看没报错、就回你"没问题"。这中间它很容易用"我觉得没问题"糊弄过去。先写红测试,是把"什么叫没问题"的标准,在它动手前就钉死。

具体流程是四步:

  1. 需求和验收标准确认后,先让 Cursor 照着验收标准写测试。这时候功能还没做,这些测试必然失败(行话叫红)。
  2. 你要确认它"红得对"------是因为功能确实缺失才失败,而不是测试本身写错了、或者写成了永远通过的空测试。一个永远绿的测试没有任何价值。
  3. 再让它写实现,目标明确:把这些测试做到全绿。
  4. 这组测试挂进提交钩子或 CI,以后不管是谁、包括 Agent,把这块改坏了,提交或合并就过不了。

接着订单筛选的例子,验收测试大概长这样(pytest 风格,以你项目实际框架为准;替身接口怎么造,上一篇讲过,不连真实接口):

python 复制代码
# 功能还没做时,这两个测试应该失败------先确认它们红得对
def test_filter_passes_status_to_api(fake_order_api):
    order_list = build_order_list(api=fake_order_api, status="paid")
    order_list.load()
    assert fake_order_api.last_request_params.get("status") == "paid"

def test_changing_filter_resets_pagination(fake_order_api):
    order_list = build_order_list(api=fake_order_api)
    order_list.go_to_page(3)
    order_list.set_filter(status="shipped")
    assert order_list.current_page == 1

这两条断言的就是需求里"筛选参数要正确传给接口""切换筛选时分页要重置",而不是某个内部实现细节。功能没实现时它们红,Agent 把实现写到全绿才算完成;以后谁改坏了,这两条立刻翻红。

写红测试有三个标准:它得真的能失败(别写成恒真);它断言的是需求验收点、不是实现过程;它要和上一篇讲的一样隔离依赖、能反复跑出一致结果。

六、让 Cursor 帮你搭这套闸门,加一张落地清单

这些钩子、配置、脚本不用你从头敲,完全可以让 Cursor 帮你生成。把仓库结构、敏感路径、用的测试框架都 @ 给它,让它产出 CODEOWNERS、钩子配置和检查脚本,最后还要让它做一件关键的事:在一个小改动上故意越界,亲眼验证闸门真的把它拦下来了,再把临时改动还原。 没验证过的闸门等于没装。

给它的指令我会写清这几条:

text 复制代码
【背景】我要给这个仓库配提交前自动检查,相关目录和现有测试配置已 @ 进来。
先不要动手写配置。
【第一步】读仓库结构、现有 lint 和测试命令,不要引入新框架、新依赖,
除非我明确同意;沿用现有工具链。
【第二步】列出你认为的敏感路径和理由(公共层、算钱、配置、CI、锁文件),
我确认后你再写。
【第三步】产出 CODEOWNERS、提交钩子配置和路径白名单脚本,
脚本要能在本地直接跑,越界时给出可读的提示。
【第四步】用一个故意越界的临时改动,验证钩子和路径脚本确实会拦截,
看到拦截后把临时改动还原,确认仓库恢复干净。

整套闸门落地,对着这张清单过一遍:

  • 代码托管平台开了分支保护、必需评审,敏感改动不能裸推
  • CODEOWNERS 把公共层、算钱、配置、CI、依赖清单指给了负责人
  • 本地装了提交钩子,提交时自动跑格式 / lint / 快速测试
  • 给 Agent 的任务配了路径白名单脚本,并实测越界会被拒绝
  • 让 Agent 做需求前先写红测试,确认它红得对,再写实现
  • 红测试和关键回归挂进了钩子或 CI,服务端有兜底
  • 约定非紧急不许跳过钩子,且故意越界验证过整套拦截有效

配的时候容易踩的坑,也一并整理:

你遇到的情况 多半是怎么回事 怎么办
钩子配了却没拦住 没装 / 没加执行权限,只在本地、服务端没兜底 钩子配置随仓库走;CODEOWNERS 加必需 CI 在服务端兜底
有人或 AI 用跳过参数绕过 钩子只是本地防线 服务端设必需检查,本地跳了也合不进
钩子太慢大家都想跳 塞了全量测试 钩子只放秒级检查,重测试放 CI
白名单没抓到越界 只比了路径、没对比主干,或漏算新文件 用相对主干的改动清单,新增文件也算
红测试一直绿 写成恒真或断言太弱 先让它在功能缺失时失败,确认红得对
Agent 改了禁区但没提交 编辑阶段拦不住,闸门在提交时 禁区是最后防线,装了它也照样要开分支、要审 diff
规则太严总误拦正常改动 白名单太窄、禁区划太大 按任务更新白名单,误拦时显式确认再放行,别一关了之

写在最后

前面几篇的规则、prompt、分支、回滚,是"让 AI 尽量别犯错";这篇的提交闸门,是"就算它犯了错,也进不了主干"。这两套不是替代关系,是叠加关系------前者降低犯错概率,后者兜住漏网的那一次。

如果你只想最小起步,一个人或小团队先做两件事就够了:把敏感路径写进 CODEOWNERS、开必需评审,再配一个提交前自动跑测试的钩子。这两步成本极低,能挡掉绝大多数事故;等你开始频繁让 Agent 大范围改代码,再上路径白名单和先写红测试。

评论区留个数字,看看你现在到哪一步:1、还全靠 prompt 里叮嘱,没装任何闸门;2、有 CI 或 lint,但提交前不拦;3、配了提交钩子,但没给 Agent 单独做路径白名单;4、四道闸门都齐了。 票数最高的那档,我下一篇往细里拆。

排期里还压着一篇:让 Cursor 接手一个完全没文档的老项目,怎么快速建立全局认识再下刀;另外有一篇接入渠道的避坑,只讲怎么分辨、不推任何东西。你更想看哪个,评论区说一声。

觉得有用,点个赞、收藏起来对着仓库配一遍;顺手关注一下,这个系列我一篇篇往下更。

相关推荐
ZzT1 小时前
Claude Code 团队的代码评审:合并量翻倍,回滚率降了 40%
ai编程·claude
HjhIron1 小时前
从朴素RAG到Agentic RAG(二):基于LangGraph的混合检索、重排与闭环设计
ai编程
坊钰1 小时前
【LangGraph框架入门级】12. 人机交互:interrupt 中断与 Command 恢复
开发语言·数据库·python·人机交互·ai编程·langgraph
Solara1 小时前
一个标志位挂了 18 天不落地:它等的重启,2 天 14 小时后就完成了
人工智能·程序员·ai编程
SkyWalking中文站2 小时前
从模型输入到文件变更:追踪 AgentCore 上的 LangGraph Agent
langchain·aigc·ai编程
金銀銅鐵2 小时前
[Java] 用 GUI 展示 ArrayList 扩容的过程
java·后端·ai编程
NC 王炸3 小时前
AI 做的便签,换个浏览器就没了?加一个导出按钮,把数据带走
javascript·ai编程·数据备份·localstorage·txt导出
凯歌的博客15 小时前
DuckDB 是一款进程内的分析型数据库
数据库·ai编程