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

4 阅读15分钟

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

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

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

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

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

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

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

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

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

# 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 实现,全程没有花里胡哨的东西:

# 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 风格,以你项目实际框架为准;替身接口怎么造,上一篇讲过,不连真实接口):

# 功能还没做时,这两个测试应该失败——先确认它们红得对
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、钩子配置和检查脚本,最后还要让它做一件关键的事:在一个小改动上故意越界,亲眼验证闸门真的把它拦下来了,再把临时改动还原。 没验证过的闸门等于没装。

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

【背景】我要给这个仓库配提交前自动检查,相关目录和现有测试配置已 @ 进来。
先不要动手写配置。
【第一步】读仓库结构、现有 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 接手一个完全没文档的老项目,怎么快速建立全局认识再下刀;另外有一篇接入渠道的避坑,只讲怎么分辨、不推任何东西。你更想看哪个,评论区说一声。

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