AI 重构生成了重复文件:安全清理步骤

Agent 没改原文件,反而在旁边建了 `UserList2.tsx`。diff、合并、删除、修 import,全程不破坏构建。

让 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 tsxtsx 不是 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 --stat review 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 坑)留下一堆死引用。

相关阅读

标签: #AI 编程 #排查 #排查