让 Claude Code 或 Cursor 重构一个组件,它没改原文件,反而新建了 UserList2.tsx / UserListNew.tsx / user-list-v2.tsx,把改动写进新文件。更糟的是有时它还顺手 import 新文件的同时保留旧文件,两份代码同时存活,bug 修了一半在新文件、另一半还在旧文件里。
这并不是默认行为。Claude Code、Cursor、Codex、Aider 默认都是在原文件上原地编辑(edit in place),只有当 agent 找不到原文件、长会话中途跟丢了、或者刻意做”安全副本”时才会冒出重复文件。但不管是哪种原因,修复路径都一样。
最快路径: 用 git status 找出多出来的文件,用 git diff --no-index old new 看清楚重复文件到底改了什么,把真正的逻辑改动挪回原文件,然后 git rm 删掉重复文件,最后跑 tsc --noEmit 一次性揪出所有失效 import。下面会展开几个坑(大小写碰撞、以及会把残留引用藏起来的 rg --type tsx 陷阱),并给出一条 CLAUDE.md / AGENTS.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
这里有个值得专门点名的坑:不要写 rg --type tsx。tsx 不是 ripgrep 的内置类型,rg --type tsx 会直接报错退出 rg: unrecognized file type: tsx;而如果是让 agent 来跑这个搜索,它的 Grep 封装会把这个错误悄悄变成”0 matches”(见 Claude Code issue #55744)。于是你以为没有残留引用了,其实还在。内置的 ts 类型本身就覆盖 .ts 和 .tsx;其它扩展名直接用 glob:
rg "UserList2|user-list-v2" --glob '*.{ts,tsx,js,jsx}'
逐个把残留 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 再扫一次。
如何确认已修好
commit 前把这三条都跑一遍,任何一条没过就说明还没收尾:
# 1. 重复文件已删除,且不再被 git 跟踪
git ls-files | grep -E 'UserList2|user-list-v2' # 期望无输出
# 2. 没有任何失效 import(TypeScript)
npx tsc --noEmit # 期望退出码 0
# 3. 没有大小写重复偷偷溜回来
git ls-files | sort -f | uniq -di # 期望无输出
然后跑 npm test && npm run build。如果构建通过但保留下来的文件其实缺了重复文件里的某段逻辑,build 也可能照样过,所以推送前再 git diff 通读一遍那个保留文件。
预防建议
- Prompt 里说”编辑
src/components/UserList.tsx”,不要说”创建一个改进版本”;用@提到具体文件路径,让 agent 不用猜文件在哪 - 在 agent 配置文件里加一条规则。截至 2026 年 6 月,
AGENTS.md是跨工具标准,Codex、Cursor、Copilot、Aider、Windsurf、Zed 都原生读取,Claude Code 也读它(同时还有更丰富的CLAUDE.md);Cursor 的单文件.cursorrules已被弃用,改用.cursor/rules/*.mdc。把规则放到你团队工具会读的地方:“禁止建带数字 / New / v2 / copy 后缀的文件;要替换原文件就直接原地编辑;找不到文件先搜索,不要假设它不存在。” - 加一个 pre-commit hook,拦截命名匹配
*[0-9].tsx/*New.tsx/*Copy.tsx/*-v[0-9].ts的新文件 - 用
git diff --statreview agent 的每一轮输出,只要看到不熟悉的新文件路径就停下来确认 - 长会话定期
/clear或新开 session,避免 agent “忘记自己已经建过” - TypeScript 项目在
tsconfig.json里把forceConsistentCasingInFileNames设为 true,配合tsc --noEmit在 pre-commit 阶段就拦下大小写重复,避免它到 Linux CI 才爆
常见问题
为什么 agent 不直接改文件,要建重复文件?
几乎都是因为它没找到原文件。路径 alias(@/components/...)、带破折号的文件名、组件不在 components/ 而在 features/,都会让 agent 的搜索落空,于是它认定这是个新文件。上面那个 rg --type tsx bug 会雪上加霜:本该找到文件的搜索返回了”0 matches”,agent 就新建了一份。
该保留新文件还是旧文件?
保留被 import 次数多的那个路径——少改 import 就少出 bug。对两个候选分别跑 rg "from .*UserList" --glob '*.{ts,tsx}',留命中数高的那个。唯一例外是:新文件是一份完整且正确的重写,而旧文件目前还没人 import。
那种没有明显后缀的重复(原因 2 和 6)怎么找?
搜符号而不是搜文件名:rg "export function formatDate" --type ts,或 rg "export (default )?(function|class|const) UserList\b" --type ts。同一个符号在两个不同路径下都命中,就是重复,哪怕文件名看起来毫不相关。
两份只差大小写(userService.ts vs UserService.ts),git 为什么看不到两份?
macOS 和 Windows 的文件系统是大小写不敏感的,所以 git 工作区只显示一个文件,而 index 里可能存着两个。用 git ls-files | sort -f | uniq -di 检出,然后通过一个临时名两步重命名来修:git mv UserService.ts tmp.ts && git mv tmp.ts userService.ts。把 forceConsistentCasingInFileNames 设为 true,它就不会再悄悄复发。
能不能整个交给 agent 清理?
diff 和合并这一步可以——让它”列出 UserList2.tsx 里有、UserList.tsx 里没有的逻辑改动,只把这些改动应用到 UserList.tsx,不做任何风格或重命名”,并先审核它的计划。但 git rm、搜索残留引用、以及最后的 tsc --noEmit 自己来做,免得一个被吞掉的搜索错误(见上面那个 ripgrep 坑)留下一堆死引用。