一句需求换来 30 个文件的改动,AI 的手怎么才能收回来

2026-07-28

内容截至 2026-07。文中用到 git restoregit worktree、带路径的 git stash push,这几条在较老的 Git 上可能没有(git restore 可用 git checkout -- 替代),跑之前先 git --version 看一眼;各工具的模式名称与区域可用性以官方最新说明为准。

你让它”把登录接口的错误提示改得友好一点”,回来的是 30 个文件的改动:顺手统一了日志格式、抽了个工具函数、把两个组件重命名、还改了一份配置。多数人第一反应是”这模型太浪了,我得把话说得更死一点”,于是提示越写越长,下一次它照样刨到别处去。真正漏风的地方通常不在你那句需求,而在三处:任务边界没有变成可验证的东西、上下文供给让它看到了太多”顺手能改”的地方、验收动作发生在改完之后而不是改动过程中。 把这三处分开看,才知道该修哪个。

站内有两篇是从上游讲的:规格驱动开发怎么落地 讲的是需求怎么写成一份能被机器和人共同校验的规格,规则文件的最佳实践 讲的是长期约束怎么沉淀成项目级配置。这篇不重复那两件事,它站在”diff 已经在你屏幕上、或者眼看就要失控”的时刻,按排查顺序处理:先分因,再拦截,最后收敛和回滚。

一、先分因:同样是 30 个文件,成因完全不同

不要看到大 diff 就一律归为”模型爱扩大范围”。先花两分钟做一次归类,方法是看改动的分布形态,而不是看总行数。

跑一下这个,先看规模和集中度:

git diff --stat
git diff --name-only | wc -l

再看改动散落在哪些顶层目录,这一步比总数有用得多:

import collections, subprocess

out = subprocess.run(["git", "diff", "--name-only"],
                     capture_output=True, text=True).stdout
buckets = collections.Counter(p.split("/")[0] for p in out.splitlines() if p)
for name, n in buckets.most_common():
    print(f"{n:4d}  {name}")

如果 30 个文件里 25 个集中在一个模块,那大概率是这个模块本身耦合重,改一处必然连带;如果散在六七个顶层目录,那是任务边界的问题;如果里面混着大量格式变化、导入顺序调整、无关注释重写,那是它把”顺手整理”当成了任务的一部分。三种情况的处置动作互不相同。

顺带确认一件事:这些改动里有多少是你根本没读过就已经落盘的。工具在自动应用编辑时,你其实是在事后审计而不是在决策。这一点决定了后面第三节该把闸门放在哪。

二、判别表:现象对应成因和动作

现象大概率成因怎么验证处置动作
改动集中在一个模块但文件很多模块内部耦合重,单点改动必然连带看被改文件之间是否互相 import;把外围那几个连带文件单独还原掉再编译,报错说明连带确实必要,编译照过说明那些改动本来就能砍接受必要的连带,但要求它先输出改动清单再动手;把重构和修复拆成两次提交
改动散在多个不相关目录任务边界没有落成可验证的判定条件逐个文件问”这个改动对应需求里的哪一句”,答不上的就是越界用只读的方案讨论先定清单,清单外的一律不动
大量纯格式、导入顺序、注释重写它把代码整理当成任务的隐含部分git diff -w --stat 忽略空白再看一遍,两次的行数差就是纯排版的量在规则里明确”不做与需求无关的格式化”;把格式交给格式化工具在独立提交里做
新增了一堆没人调用的工具函数或抽象层它按”通用做法”提前抽象,而不是按当前需求全局搜新符号,除定义处之外只有一个调用点,就是过度抽象要求内联回去;抽象只在第三个调用点出现时做
改了配置、依赖清单、脚本它为了让自己那套写法跑通而调环境单独看这几个文件的 diff,是否引入新依赖或改了构建参数一律先拒;环境变更必须单独一次提交并说明理由
删了看起来没用的代码或分支逻辑它读不到调用方,把动态调用判为死代码搜字符串引用、反射调用、配置里的名字恢复删除;删代码的权限不下放给自动改动
改动越修越多,来回反复同几个文件上下文里已经装满了失败尝试,它在自己的错误里绕看同一文件被反复改写的次数停下,清空当前会话,从干净分支重开一轮
测试文件被大改以让测试通过验收标准被当成可协商项看测试的断言是被放宽还是被删立刻回滚测试文件,测试的改动必须人写

这张表的用法是:拿你当前的 diff 逐行对,通常会命中两到三项。命中项决定你接下来做哪一节。

三、事前约束:把边界写成能被检查的东西

写在需求里的”只改这个文件”是一句愿望,写成能被检查的条件才是约束。三件事按性价比排序。

第一件,先要清单再要代码。开工前让它输出打算改哪些文件、每个文件改什么、为什么必须改。这个清单你花三十秒能看完,能否砍掉一半也是三十秒的事。多数工具都有只做方案不落盘的模式,方案模式的用法 那篇讲得更细。清单一旦确认,它后面的每一步都有了可对照的靶子,你也有了拒绝越界的依据。

第二件,把工作区变小。范围失控有一半是因为它能看到的东西太多。只把相关目录放进可编辑范围,其余以只读方式提供参考。跨模块的任务拆成两轮,而不是一轮里让它同时动两边。上下文的组织方式直接决定它认为”什么算相关”,这一层的取舍在 上下文管理 里有系统的写法。

第三件,给它一个干净的、可以随时丢掉的地方干活。并行开支线用独立工作树比在主工作区来回切分支安全得多:

git worktree add ../proj-fix-login -b fix/login-message

改砸了就把整个目录删掉,主工作区一行没动。这比 stash 套 stash 可靠。

四、事中拦截:越界要在提交前被打回

事前约束会被绕过,所以要有一道机械闸门。最省事的是在提交前数文件数,超过约定就拦下:

#!/bin/sh
# .git/hooks/pre-commit
n=$(git diff --cached --name-only | wc -l)
limit=12
if [ "$n" -gt "$limit" ]; then
  echo "本次提交涉及 $n 个文件,超过约定的 $limit 个。"
  echo "请拆分提交;确认无误可用 git commit --no-verify 放行。"
  exit 1
fi

存好之后要 chmod +x .git/hooks/pre-commit,否则它不会被执行。另外 .git/hooks 不随仓库分发,团队里想统一就把脚本放进版本库某个目录,再用 git config core.hooksPath <目录> 指过去,新人一条命令接上。

这个钩子的价值不在于数字定多少,而在于它把”我顺手就提了”变成”我得想一下”。数字定得让你每周被拦一两次就合适,从不触发说明太松,天天触发说明你的任务粒度本来就该拆。

比数量更重要的是暂存粒度。不要 git add -A,用交互式挑:

git add -p

一块一块过,你会当场发现哪些改动是它自己加的戏。挑的过程就是评审的过程,比事后读一大坨 diff 高效。

还有一条常被跳过的:让它每完成一小步就停下来等确认,而不是一口气做完再汇报。哪些环节值得设人工确认点,人在环中的设计 讨论了取舍。代价是慢,收益是错误不会累积成 30 个文件。

五、事后收敛:diff 已经摊在那儿了怎么办

先别急着回滚全部,也别急着一个个读。按这个顺序处理。

第一步,把明确无关的整类改动先剥掉。格式化、导入顺序、无关注释,整文件恢复掉:

git restore --source=HEAD --staged --worktree -- src/utils/format.ts

这条只对已被 Git 跟踪的文件有效。它新建出来的文件不在 HEAD 里,restore 报的是路径不匹配,那类要么直接删掉,要么先 git status 看清未跟踪清单再逐个处理,别图快用一把梭的清理命令,容易把你自己的本地配置也扫掉。

第二步,把剩下的按”需求必需”和”顺手改进”两堆分。顺手改进那堆不要直接删,先按路径挪走存着,以后单独评估:

git stash push -m "顺手改进-待评估" -- src/components src/hooks

带路径的 stash 只收走这几个目录里的改动,工作区其余部分保持不动,正好用来做分堆。以后想认真评估时,git stash list 找到编号,git stash branch eval/shunshou stash@{0} 直接把它落成一个分支再慢慢看,比一直压在栈里稳。

第三步,只对”需求必需”那堆做认真评审。这时候剩下的通常是三到五个文件,读得完。评审重点是删除和替换,新增的代码危害有限,被悄悄改掉的既有逻辑才是事故来源。

第四步,分次提交。需求必需的一次,格式整理的一次,如果确实要保留部分顺手改进再单独一次。提交信息里写清哪些是自动生成后经你确认的,半年后回头查故障时这句话很值钱。

六、什么情况下别再折腾

沉没成本在这类问题上特别容易发作:改了两小时,diff 已经乱了,你还想着”再修一轮就好了”。给自己几条硬线。

止损线一:同一个文件被反复改写超过三轮还没稳定。这说明上下文里已经堆满了失败路径,它在自己造的坑里绕。清掉会话,从干净分支重开,把已经确认有效的那点结论手写进任务描述里带过去。重开的成本远低于继续绕。

止损线二:你已经看不懂当前 diff 和原始需求的对应关系。看不懂就无法评审,无法评审的代码不能进主干。直接 git stash 或建分支存档,回到起点重做一轮,任务拆一半大小。

止损线三:它开始改测试来让测试通过,或者改配置来让构建通过。这是方向性错误,不是进度问题。立刻回滚,重新确认需求本身是否讲清了。

止损线四:涉及删除既有逻辑、改动数据库结构或迁移脚本、动权限与认证相关代码。这几类不适合让自动改动一次做完,退回到你自己写、让它做评审和补测试的模式。

回滚点怎么留:开工前保证工作区干净并有一个明确的提交点,这是最低成本的保险。已经提交了才发现问题就用 git revert 生成反向提交,保留历史;还没推送且确定不要的可以 git reset --hard <sha>,但先确认没有别人依赖这段历史。养成开工前跑一次 git status 的习惯,比事后抢救省十倍力气。

七、避坑清单

用更长的需求描述去压制范围扩张。 为什么会踩:直觉上写得越细约束越强。实际上描述变长以后,它读到的可动区域反而更多,你顺手提到的每个文件名都成了它认为可以改的依据。怎么避:需求写短,边界写成清单和检查项,把”不要做什么”放在规则文件里长期生效,而不是每次重复叮嘱。

一次任务里同时要修复和重构。 为什么会踩:修 bug 的时候顺眼看到烂代码,就一起说了。合并之后你分不清哪行是修复哪行是重构,出问题不知道回滚哪部分。怎么避:两件事两轮做,中间提交一次。

放任它自己决定要不要抽象。 为什么会踩:通用写法里”重复三次就抽象”是常识,但它常常在第一次就抽。多出来的中间层没人用,还得跟着维护。怎么避:明确写”不新增抽象层,需要抽象时先告诉我”。

git add -A 收尾。 为什么会踩:文件太多懒得挑。结果混进临时文件、调试输出、被改坏的配置。怎么避:养成 git add -p 的肌肉记忆;确实要全加时先 git status 看完整清单。

让它自己跑测试并只汇报结果。 为什么会踩:它说通过了你就信了。可能测试被改宽、被跳过,或者根本跑的不是你以为的那套。怎么避:测试命令你自己跑一遍,测试文件的改动必须你亲自读。

信”这次改动很小”这句自述。 为什么会踩:它对改动规模的自我评估经常偏低,而你懒得核。怎么避:只看 git diff --stat,不看它的总结。数字不会替自己说好话。

照抄海外教程的工作流,最后发现底座用不了。 为什么会踩:这类范围控制的方案很多来自国外团队的分享,配置抄完才发现工具本身在你所处地区拿不到账号或连不上。部分海外厂商的服务范围本身就不覆盖中国大陆,具体是否可用、以什么方式可用,以各家官方条款和服务状态页为准,本文不提供任何绕过区域限制的方式。怎么避:选型阶段先确认账号和网络是否合法可得,再往下搭流程;把方法论和具体产品分开看,清单、钩子、分次提交这些动作跟用哪家工具无关,换成本地可用的工具照样成立。

把范围失控当模型问题,换个模型再试。 为什么会踩:换模型是最不费脑子的动作。但边界没定清,换谁都一样刨。怎么避:先按第一节分因,确认是边界问题就去修边界,确认是耦合问题就去拆模块。

收束

范围失控本质是权限问题:你把”决定改哪些文件”这个权力让出去了,却没给出边界。要拿回来,只需要三个动作按顺序生效——开工前拿到改动清单,提交前用钩子和 git add -p 做机械拦截,收尾时分类提交。

下次 diff 又炸开的时候,照这五条自检一遍:

  1. 开工前工作区是否干净、有没有可回退的提交点。
  2. 有没有一份你确认过的改动清单,当前 diff 里有多少文件不在清单上。
  3. 改动散落在几个顶层目录,是耦合导致还是越界导致。
  4. 测试文件、配置文件、依赖清单有没有被动过。
  5. 同一个文件是不是已经被反复改写三轮以上,该重开了。

五条里有两条答不上来,就先别提交。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。