让 Claude Code 或 Cursor 重构一个组件,它没改原文件,反而新建了 UserList2.tsx / UserListNew.tsx / user-list-v2.tsx,把改动写进新文件。更糟的是有时它还顺手 import 新文件的同时保留旧文件,两份代码同时存活,bug 修了一半在新文件、另一半还在旧文件里。
本文给你一条”先 diff 出两份文件的真实差异、把好的那份合并完整、再删另一份并让 import 自动跟上”的清理路径,外加在 CLAUDE.md 里写规则防止再犯。
常见原因
按命中率从高到低。
1. Agent 没读到原文件,凭印象重写
最常见。你说”重构 UserList”,agent 用 grep 或 file search 找不到精确匹配(路径有 alias、文件名有破折号、组件不在 components/ 而在 features/),它就假设文件不存在,直接新建。
> Tool: write_file
path: src/components/UserList2.tsx
reason: "Creating a new improved version of the UserList component."
如何判断:agent log / chat transcript 里出现 “creating new file” 而你的本意是 edit;或者 git status 里出现 UserList2.tsx 这种带数字 / New / v2 后缀的新文件。
2. 多步 agent 忘了自己已经建过
Aider 或 Codex 在长会话里跨多步执行任务,前面建了 lib/utils/format.ts,几步之后又被要求”为格式化加一个工具”,它在 src/helpers/format.ts 再建一份。两份函数签名相同,逻辑略有差异。
如何判断:rg "export function formatDate" -t ts 两次以上命中且文件名不同。
3. 会话中途命名规则漂移
你前半段叫 userService,agent 后半段写成 UserService / user-service,import 路径解析失败时它就建新文件。在 case-insensitive 文件系统(macOS 默认)上特别隐蔽——本地没问题,Linux CI 上挂。
如何判断:git ls-files | sort -f | uniq -di 找出大小写重复的文件名。
4. Agent 怕改坏原文件,做”安全副本”
某些 agent(尤其是被反复教育过”小心已 commit 的代码”)的默认行为是:要重大改动时先复制一份再改。比如要把 class component 重写成 hooks,它建 UserListHooks.tsx 而不是替换 UserList.tsx。
如何判断:新文件内容是旧文件的”风格升级版”,public API 几乎一致。
5. 多 agent / 多分支并行写同一功能
你早上让 Cursor 写了 analytics/track.ts,下午让 Claude Code 在另一分支写了 lib/analytics.ts,合并时两份共存。
如何判断:git log --diff-filter=A --since="1 week" -- "*analytics*" 同一周内多个 commit 新建了类似文件。
6. AI 不知道项目的目录约定
prompt 没给 src/components/ 是 UI、src/features/<name>/ 是业务这种约定,agent 把 UI 组件建在了 features 目录里,旧的 UI 版本还在 components。
如何判断:新旧文件位于不同顶层目录但功能相同。
最短修复路径
Step 1:列出所有可疑重复文件
# 找带数字 / New / v2 后缀的文件
git status --short | grep -E '(2|3|New|new|v2|copy|Copy)\.(ts|tsx|js|jsx|py)$'
# 找最近一周新建的、和已有文件 basename 相似的
git log --diff-filter=A --since="1 week" --name-only --pretty=format: \
| sort -u | awk -F/ '{print $NF}' | sort | uniq -d
输出每一组重复的两个路径,写到清单上。
Step 2:diff 两份文件,决定保留哪一份
diff -u src/components/UserList.tsx src/components/UserList2.tsx
# 或并排
git diff --no-index src/components/UserList.tsx src/components/UserList2.tsx
逐组按下面表格判断:
| 情况 | 保留哪份 |
|---|---|
| 新文件是完整重写,旧文件没人 import | 保留新文件,删旧文件 |
| 旧文件还在被 import,新文件包含真正的修复 | 把新文件的修复 cherry-pick 到旧文件,删新文件 |
| 两份各修了一部分 bug | 手动合并到旧文件路径,删新文件 |
| 完全相同 | 删任意一份 |
永远保留 import 多的那个路径——少改 import 就少出 bug。
Step 3:把好的内容合到唯一保留的文件
用 agent 帮你 cherry-pick,比让它整体重写更安全:
Prompt:对比 src/components/UserList.tsx 和 src/components/UserList2.tsx,
列出 UserList2 里有但 UserList 里没有的逻辑改动。
然后只把这些改动应用到 UserList.tsx,不要做任何风格 / 排序 / 重命名。
让它先输出 diff 计划,你审核后再执行。
Step 4:删重复文件并修 import
git rm src/components/UserList2.tsx
# 找还有谁在引用被删的文件
rg "UserList2|user-list-v2" --type ts --type tsx
逐个把残留 import 改回正确路径。如果项目用 TypeScript,tsc --noEmit 会立刻把所有失效 import 报出来:
npx tsc --noEmit
Step 5:跑测试 + build 验证
npm test
npm run build
特别注意:case-insensitive 文件系统上本地能跑、Linux CI 挂的情况——git ls-files | sort -f | uniq -di 再扫一次。
预防建议
- Prompt 里说”编辑
src/components/UserList.tsx”,不要说”创建一个改进版本”;用@提到具体文件路径 - CLAUDE.md / .cursorrules / AGENTS.md 加一条:“禁止建带数字 / New / v2 / copy 后缀的文件;要替换原文件就直接编辑原路径”
- pre-commit hook 拦截命名:发现
*2.tsx/*New.tsx/*Copy.tsx直接拒绝 commit - 用
git diff --statreview agent 的每一轮输出,只要看到不熟悉的新文件路径就停下来确认 - 长会话定期
/clear或新开 session,避免 agent “忘记自己已经建过” - TypeScript 项目把
forceConsistentCasingInFileNames设为 true,配合tsc --noEmit在 pre-commit 阶段就拦下大小写重复