clawt 3.10.3 → 3.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ # validate 被忽略文件冲突检测 Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** 在 validate 的 patch apply 之前检测被 `.gitignore` 忽略的残留文件(幽灵文件),输出清晰的错误提示和清理命令,替代当前误导性的 "diverged too far" 信息。
6
+
7
+ **Architecture:** 在 `migrateChangesViaPatch` 中,`git apply` 之前新增检测步骤:通过 `git diff --name-only` 获取 patch 文件列表 → `git check-ignore` 筛选被忽略的文件 → `fs.existsSync` 确认物理存在 → 有冲突则输出提示并返回失败。
8
+
9
+ **Tech Stack:** TypeScript, Node.js, Git CLI (`git check-ignore`, `git diff --name-only`), Vitest
10
+
11
+ ---
12
+
13
+ ### Task 1: 新增 `gitCheckIgnored` 函数
14
+
15
+ **Files:**
16
+ - Modify: `src/utils/git-core.ts`
17
+ - Test: `tests/unit/utils/git-core.test.ts`(新建)
18
+
19
+ - [ ] **Step 1: 编写 `gitCheckIgnored` 的失败测试**
20
+
21
+ ```typescript
22
+ // tests/unit/utils/git-core.test.ts
23
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
24
+ import { gitCheckIgnored } from '../../../src/utils/git-core.js';
25
+
26
+ // mock execSync
27
+ vi.mock('node:child_process', () => ({
28
+ execSync: vi.fn(),
29
+ execFileSync: vi.fn(),
30
+ }));
31
+
32
+ import { execSync } from 'node:child_process';
33
+ const mockExecSync = vi.mocked(execSync);
34
+
35
+ describe('gitCheckIgnored', () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ });
39
+
40
+ it('空数组输入时返回空数组', () => {
41
+ const result = gitCheckIgnored([]);
42
+ expect(result).toEqual([]);
43
+ expect(mockExecSync).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it('全部被忽略时返回全部路径', () => {
47
+ mockExecSync.mockReturnValue('docs/superpowers/a.md\ndocs/superpowers/b.md\n');
48
+ const result = gitCheckIgnored(['docs/superpowers/a.md', 'docs/superpowers/b.md']);
49
+ expect(result).toEqual(['docs/superpowers/a.md', 'docs/superpowers/b.md']);
50
+ });
51
+
52
+ it('全部不被忽略时返回空数组', () => {
53
+ // git check-ignore 无匹配时退出码为 1,execSync 抛出异常
54
+ mockExecSync.mockImplementation(() => { throw new Error('exit code 1'); });
55
+ const result = gitCheckIgnored(['src/index.ts']);
56
+ expect(result).toEqual([]);
57
+ });
58
+
59
+ it('混合场景时仅返回被忽略的路径', () => {
60
+ mockExecSync.mockReturnValue('docs/superpowers/a.md\n');
61
+ const result = gitCheckIgnored(['docs/superpowers/a.md', 'src/index.ts']);
62
+ expect(result).toEqual(['docs/superpowers/a.md']);
63
+ });
64
+ });
65
+ ```
66
+
67
+ - [ ] **Step 2: 运行测试确认失败**
68
+
69
+ Run: `npx vitest run tests/unit/utils/git-core.test.ts`
70
+ Expected: FAIL — `gitCheckIgnored` 未定义
71
+
72
+ - [ ] **Step 3: 实现 `gitCheckIgnored`**
73
+
74
+ 在 `src/utils/git-core.ts` 中新增:
75
+
76
+ ```typescript
77
+ /**
78
+ * 批量检测文件是否被 .gitignore 忽略
79
+ * 使用 git check-ignore 命令,退出码 1 表示无匹配(非错误)
80
+ * @param {string[]} paths - 要检测的文件路径列表
81
+ * @param {string} [cwd] - 工作目录
82
+ * @returns {string[]} 被忽略的文件路径列表
83
+ */
84
+ export function gitCheckIgnored(paths: string[], cwd?: string): string[] {
85
+ if (paths.length === 0) return [];
86
+
87
+ try {
88
+ const output = execSync(`git check-ignore ${paths.map(p => `"${p}"`).join(' ')}`, {
89
+ cwd,
90
+ encoding: 'utf-8',
91
+ stdio: ['pipe', 'pipe', 'pipe'],
92
+ });
93
+ return output.trim().split('\n').filter(Boolean);
94
+ } catch {
95
+ // git check-ignore 退出码 1 表示无匹配文件,属于正常情况
96
+ return [];
97
+ }
98
+ }
99
+ ```
100
+
101
+ - [ ] **Step 4: 运行测试确认通过**
102
+
103
+ Run: `npx vitest run tests/unit/utils/git-core.test.ts`
104
+ Expected: PASS
105
+
106
+ - [ ] **Step 5: 在 `src/utils/index.ts` 中导出**
107
+
108
+ 在 `src/utils/index.ts` 的 git-core 导出块中添加 `gitCheckIgnored`:
109
+
110
+ ```typescript
111
+ // 在现有的 git-core 导出列表中添加 gitCheckIgnored
112
+ ```
113
+
114
+ 具体位置:在 `export { ... } from './git.js'` 块中添加 `gitCheckIgnored`(因为 `git.ts` 通过 `export * from './git-core.js'` 重导出)。
115
+
116
+ - [ ] **Step 6: 提交**
117
+
118
+ ```bash
119
+ git add src/utils/git-core.ts src/utils/index.ts tests/unit/utils/git-core.test.ts
120
+ git commit -m "feat: add gitCheckIgnored for batch gitignore detection"
121
+ ```
122
+
123
+ ---
124
+
125
+ ### Task 2: 新增 `detectIgnoredFilesInPatch` 函数
126
+
127
+ **Files:**
128
+ - Modify: `src/utils/validate-core.ts`
129
+ - Test: `tests/unit/utils/validate-core.test.ts`(新建)
130
+
131
+ - [ ] **Step 1: 编写 `detectIgnoredFilesInPatch` 的失败测试**
132
+
133
+ ```typescript
134
+ // tests/unit/utils/validate-core.test.ts
135
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
136
+
137
+ vi.mock('../../../src/utils/git-core.js', async () => {
138
+ const actual = await vi.importActual('../../../src/utils/git-core.js');
139
+ return { ...actual, gitCheckIgnored: vi.fn(), execSync: vi.fn() };
140
+ });
141
+
142
+ vi.mock('node:fs', () => ({
143
+ existsSync: vi.fn(),
144
+ }));
145
+
146
+ import { detectIgnoredFilesInPatch } from '../../../src/utils/validate-core.js';
147
+ import { gitCheckIgnored } from '../../../src/utils/git-core.js';
148
+ import { existsSync } from 'node:fs';
149
+ import { execSync } from 'node:child_process';
150
+
151
+ const mockGitCheckIgnored = vi.mocked(gitCheckIgnored);
152
+ const mockExistsSync = vi.mocked(existsSync);
153
+ const mockExecSync = vi.mocked(execSync);
154
+
155
+ describe('detectIgnoredFilesInPatch', () => {
156
+ beforeEach(() => {
157
+ vi.clearAllMocks();
158
+ });
159
+
160
+ it('无幽灵文件时返回空数组', () => {
161
+ mockExecSync.mockReturnValue('src/a.ts\nsrc/b.ts\n');
162
+ mockGitCheckIgnored.mockReturnValue([]);
163
+ const result = detectIgnoredFilesInPatch('feature', '/main');
164
+ expect(result).toEqual([]);
165
+ });
166
+
167
+ it('检测到幽灵文件时返回文件列表', () => {
168
+ mockExecSync.mockReturnValue('docs/superpowers/a.md\nsrc/b.ts\n');
169
+ mockGitCheckIgnored.mockReturnValue(['docs/superpowers/a.md']);
170
+ mockExistsSync.mockImplementation((p: string) => p === '/main/docs/superpowers/a.md');
171
+ const result = detectIgnoredFilesInPatch('feature', '/main');
172
+ expect(result).toEqual(['docs/superpowers/a.md']);
173
+ });
174
+
175
+ it('被忽略但物理不存在的文件不包含在结果中', () => {
176
+ mockExecSync.mockReturnValue('docs/superpowers/a.md\n');
177
+ mockGitCheckIgnored.mockReturnValue(['docs/superpowers/a.md']);
178
+ mockExistsSync.mockReturnValue(false);
179
+ const result = detectIgnoredFilesInPatch('feature', '/main');
180
+ expect(result).toEqual([]);
181
+ });
182
+
183
+ it('git diff --name-only 失败时返回空数组(降级)', () => {
184
+ mockExecSync.mockImplementation(() => { throw new Error('fatal'); });
185
+ const result = detectIgnoredFilesInPatch('feature', '/main');
186
+ expect(result).toEqual([]);
187
+ });
188
+ });
189
+ ```
190
+
191
+ - [ ] **Step 2: 运行测试确认失败**
192
+
193
+ Run: `npx vitest run tests/unit/utils/validate-core.test.ts`
194
+ Expected: FAIL — `detectIgnoredFilesInPatch` 未定义
195
+
196
+ - [ ] **Step 3: 实现 `detectIgnoredFilesInPatch`**
197
+
198
+ 在 `src/utils/validate-core.ts` 中新增:
199
+
200
+ ```typescript
201
+ import { existsSync } from 'node:fs';
202
+ import { join } from 'node:path';
203
+ import { execSync } from 'node:child_process';
204
+ import { EXEC_MAX_BUFFER } from '../constants/index.js';
205
+
206
+ /**
207
+ * 检测 patch 中被 .gitignore 忽略且物理存在于主 worktree 的文件(幽灵文件)
208
+ * 这些文件会导致 git apply 失败("已经存在于工作区中")
209
+ * @param {string} branchName - 目标分支名
210
+ * @param {string} mainWorktreePath - 主 worktree 路径
211
+ * @returns {string[]} 幽灵文件的相对路径列表
212
+ */
213
+ export function detectIgnoredFilesInPatch(branchName: string, mainWorktreePath: string): string[] {
214
+ let patchFiles: string[];
215
+ try {
216
+ const output = execSync(`git diff --name-only HEAD...${branchName}`, {
217
+ cwd: mainWorktreePath,
218
+ encoding: 'utf-8',
219
+ stdio: ['pipe', 'pipe', 'pipe'],
220
+ maxBuffer: EXEC_MAX_BUFFER,
221
+ });
222
+ patchFiles = output.trim().split('\n').filter(Boolean);
223
+ } catch {
224
+ // diff 失败时跳过检测,降级为当前行为(让 apply 自行报错)
225
+ return [];
226
+ }
227
+
228
+ if (patchFiles.length === 0) return [];
229
+
230
+ const ignoredFiles = gitCheckIgnored(patchFiles, mainWorktreePath);
231
+ if (ignoredFiles.length === 0) return [];
232
+
233
+ // 仅保留物理存在的文件(幽灵文件)
234
+ return ignoredFiles.filter(file => existsSync(join(mainWorktreePath, file)));
235
+ }
236
+ ```
237
+
238
+ 注意:需要在文件顶部的 import 中添加 `gitCheckIgnored`(从 `./index.js` 导入)和 `EXEC_MAX_BUFFER`(从 `../constants/index.js` 导入)。
239
+
240
+ - [ ] **Step 4: 运行测试确认通过**
241
+
242
+ Run: `npx vitest run tests/unit/utils/validate-core.test.ts`
243
+ Expected: PASS
244
+
245
+ - [ ] **Step 5: 在 `src/utils/index.ts` 中导出**
246
+
247
+ 在 `src/utils/index.ts` 的 validate-core 导出行中添加 `detectIgnoredFilesInPatch`:
248
+
249
+ ```typescript
250
+ export { migrateChangesViaPatch, computeCurrentTreeHash, saveCurrentSnapshotTree, loadOldSnapshotToStage, switchToValidateBranch, detectIgnoredFilesInPatch } from './validate-core.js';
251
+ ```
252
+
253
+ - [ ] **Step 6: 提交**
254
+
255
+ ```bash
256
+ git add src/utils/validate-core.ts src/utils/index.ts tests/unit/utils/validate-core.test.ts
257
+ git commit -m "feat: add detectIgnoredFilesInPatch for ghost file detection"
258
+ ```
259
+
260
+ ---
261
+
262
+ ### Task 3: 新增消息常量 `VALIDATE_IGNORED_FILES_CONFLICT`
263
+
264
+ **Files:**
265
+ - Modify: `src/constants/messages/validate.ts`
266
+
267
+ - [ ] **Step 1: 添加双语消息常量**
268
+
269
+ 在 `src/constants/messages/validate.ts` 的 `VALIDATE_MESSAGES_I18N` 对象中,在 `VALIDATE_PATCH_APPLY_FAILED` 之后添加:
270
+
271
+ ```typescript
272
+ /** validate 检测到被 .gitignore 忽略的残留文件冲突 */
273
+ VALIDATE_IGNORED_FILES_CONFLICT: {
274
+ en: (files: string[], cleanCommands: string[]) => {
275
+ const maxDisplay = 10;
276
+ const displayed = files.slice(0, maxDisplay).map(f => ` - ${f}`).join('\n');
277
+ const more = files.length > maxDisplay ? `\n ...(${files.length} files total)` : '';
278
+ const cmds = cleanCommands.map(c => ` ${c}`).join('\n');
279
+ return `Ignored files left in main worktree are blocking patch apply:\n${displayed}${more}\n\nPlease clean up manually and retry:\n${cmds}`;
280
+ },
281
+ 'zh-CN': (files: string[], cleanCommands: string[]) => {
282
+ const maxDisplay = 10;
283
+ const displayed = files.slice(0, maxDisplay).map(f => ` - ${f}`).join('\n');
284
+ const more = files.length > maxDisplay ? `\n ...(共 ${files.length} 个文件)` : '';
285
+ const cmds = cleanCommands.map(c => ` ${c}`).join('\n');
286
+ return `检测到被 .gitignore 忽略的文件残留在主 worktree 中,导致变更无法应用:\n${displayed}${more}\n\n请手动清理后重试:\n${cmds}`;
287
+ },
288
+ },
289
+ ```
290
+
291
+ - [ ] **Step 2: 提交**
292
+
293
+ ```bash
294
+ git add src/constants/messages/validate.ts
295
+ git commit -m "feat: add VALIDATE_IGNORED_FILES_CONFLICT message constant"
296
+ ```
297
+
298
+ ---
299
+
300
+ ### Task 4: 修改 `migrateChangesViaPatch` 集成检测逻辑
301
+
302
+ **Files:**
303
+ - Modify: `src/utils/validate-core.ts`
304
+
305
+ - [ ] **Step 1: 修改 `migrateChangesViaPatch`**
306
+
307
+ 在 `migrateChangesViaPatch` 函数中,`gitApplyFromStdin` 调用之前,添加幽灵文件检测逻辑:
308
+
309
+ ```typescript
310
+ export function migrateChangesViaPatch(targetWorktreePath: string, mainWorktreePath: string, branchName: string, hasUncommitted: boolean): { success: boolean } {
311
+ let didTempCommit = false;
312
+
313
+ try {
314
+ // 如果有未提交修改,先做临时 commit 以便 diff 能捕获全部变更
315
+ if (hasUncommitted) {
316
+ gitAddAll(targetWorktreePath);
317
+ gitCommit('clawt:temp-commit-for-validate', targetWorktreePath);
318
+ didTempCommit = true;
319
+ }
320
+
321
+ // 在主 worktree 执行三点 diff,获取目标分支自分叉点以来的全量变更
322
+ const patch = gitDiffBinaryAgainstBranch(branchName, mainWorktreePath);
323
+
324
+ // 检测被 .gitignore 忽略的残留文件(幽灵文件),在 apply 之前拦截
325
+ const ignoredFiles = detectIgnoredFilesInPatch(branchName, mainWorktreePath);
326
+ if (ignoredFiles.length > 0) {
327
+ const cleanCommands = buildCleanCommands(ignoredFiles);
328
+ logger.warn(`检测到 ${ignoredFiles.length} 个被忽略的残留文件冲突`);
329
+ printWarning(MESSAGES.VALIDATE_IGNORED_FILES_CONFLICT(ignoredFiles, cleanCommands));
330
+ return { success: false };
331
+ }
332
+
333
+ // 应用 patch 到主 worktree 工作目录
334
+ if (patch.length > 0) {
335
+ try {
336
+ gitApplyFromStdin(patch, mainWorktreePath);
337
+ } catch (error) {
338
+ logger.warn(`patch apply 失败: ${error}`);
339
+ printWarning(MESSAGES.VALIDATE_PATCH_APPLY_FAILED(branchName));
340
+ return { success: false };
341
+ }
342
+ }
343
+
344
+ return { success: true };
345
+ } finally {
346
+ // ...(finally 块保持不变)
347
+ }
348
+ }
349
+ ```
350
+
351
+ - [ ] **Step 2: 实现 `buildCleanCommands` 辅助函数**
352
+
353
+ 在 `src/utils/validate-core.ts` 中新增(不导出,仅内部使用):
354
+
355
+ ```typescript
356
+ /**
357
+ * 根据冲突文件列表生成 git clean 清理命令
358
+ * 按直接父目录去重,生成针对性的清理命令
359
+ * @param {string[]} files - 冲突文件的相对路径列表
360
+ * @returns {string[]} 清理命令列表
361
+ */
362
+ function buildCleanCommands(files: string[]): string[] {
363
+ const dirs = new Set<string>();
364
+ for (const file of files) {
365
+ const lastSlash = file.lastIndexOf('/');
366
+ const dir = lastSlash > 0 ? file.substring(0, lastSlash) : '.';
367
+ dirs.add(dir);
368
+ }
369
+ return Array.from(dirs).map(dir => `git clean -fdx ${dir}/`);
370
+ }
371
+ ```
372
+
373
+ - [ ] **Step 3: 确保 import 完整**
374
+
375
+ 在 `src/utils/validate-core.ts` 顶部确认以下 import 存在:
376
+ - `MESSAGES` 从 `'../constants/index.js'`(已有)
377
+ - `printWarning` 从 `'./index.js'`(已有)
378
+ - `detectIgnoredFilesInPatch` 在同文件中定义,无需额外 import
379
+ - `buildCleanCommands` 在同文件中定义,无需额外 import
380
+
381
+ - [ ] **Step 4: 运行全部测试确认无回归**
382
+
383
+ Run: `npx vitest run`
384
+ Expected: 全部 PASS
385
+
386
+ - [ ] **Step 5: 提交**
387
+
388
+ ```bash
389
+ git add src/utils/validate-core.ts
390
+ git commit -m "feat: integrate ghost file detection into migrateChangesViaPatch"
391
+ ```
392
+
393
+ ---
394
+
395
+ ### Task 5: 集成验证
396
+
397
+ - [ ] **Step 1: 构建项目确认无编译错误**
398
+
399
+ Run: `npm run build`
400
+ Expected: 构建成功
401
+
402
+ - [ ] **Step 2: 运行全部测试**
403
+
404
+ Run: `npm test`
405
+ Expected: 全部 PASS
406
+
407
+ - [ ] **Step 3: 提交全部变更(如有遗漏)**
408
+
409
+ ```bash
410
+ git add -A
411
+ git commit -m "feat: detect ignored ghost files before patch apply in validate"
412
+ ```
@@ -0,0 +1,76 @@
1
+ # validate 被忽略文件冲突检测 — 设计文档
2
+
3
+ ## 问题
4
+
5
+ 当目标分支跟踪了某些在 `.gitignore` 中的文件(如 AI Agent 用 `git add -f` 强制提交),validate 通过 `git apply` 将这些文件创建到主 worktree 工作目录后,`git clean -fd` 无法清理它们(因为被 `.gitignore` 忽略)。后续 validate 的 patch apply 因文件已存在而失败,且 sync 无法打破这个死循环。
6
+
7
+ ## 修复目标
8
+
9
+ 在 `migrateChangesViaPatch` 中,`git apply` 之前检测被 `.gitignore` 忽略且物理存在于主 worktree 工作目录的文件("幽灵文件"),若检测到冲突则输出清晰的错误提示和可执行的清理命令,而非当前的误导性信息 "diverged too far from main"。
10
+
11
+ ## 验收标准
12
+
13
+ 1. 检测到幽灵文件时,输出文件列表和 `git clean -fdx <dir>` 清理命令
14
+ 2. 未检测到幽灵文件时,行为与当前完全一致(无回归)
15
+ 3. 提示信息为双语(中英)
16
+ 4. 有单元测试覆盖检测逻辑
17
+
18
+ ## 架构
19
+
20
+ ### 检测流程
21
+
22
+ ```
23
+ migrateChangesViaPatch()
24
+ ├── gitDiffBinaryAgainstBranch() → 获取 patch
25
+ ├── detectIgnoredFilesInPatch() → 新增:检测幽灵文件
26
+ │ ├── git diff --name-only HEAD...branch → 获取 patch 涉及的文件列表
27
+ │ ├── git check-ignore <files> → 筛选被忽略的文件
28
+ │ └── fs.existsSync → 筛选物理存在的文件
29
+ ├── 有冲突 → printWarning + return { success: false }
30
+ └── 无冲突 → gitApplyFromStdin() → 正常 apply
31
+ ```
32
+
33
+ ### 新增函数
34
+
35
+ | 函数 | 文件 | 职责 |
36
+ |------|------|------|
37
+ | `gitCheckIgnored(paths, cwd)` | `src/utils/git-core.ts` | 批量检测文件是否被 `.gitignore` 忽略 |
38
+ | `detectIgnoredFilesInPatch(branchName, mainWorktreePath)` | `src/utils/validate-core.ts` | 检测 patch 中的幽灵文件列表 |
39
+
40
+ ### 修改函数
41
+
42
+ | 函数 | 文件 | 变更 |
43
+ |------|------|------|
44
+ | `migrateChangesViaPatch` | `src/utils/validate-core.ts` | apply 前调用检测函数 |
45
+
46
+ ### 新增消息常量
47
+
48
+ | 常量 | 文件 | 用途 |
49
+ |------|------|------|
50
+ | `VALIDATE_IGNORED_FILES_CONFLICT` | `src/constants/messages/validate.ts` | 幽灵文件冲突提示(含文件列表和清理命令) |
51
+
52
+ ### 提示格式
53
+
54
+ ```
55
+ ⚠ 检测到被 .gitignore 忽略的文件残留在主 worktree 中,导致变更无法应用:
56
+ - docs/superpowers/findings/2026-05-30-chat-message-block-findings.md
57
+ - docs/superpowers/plans/2026-05-30-chat-message-block.md
58
+ ...(共 18 个文件)
59
+
60
+ 请手动清理后重试:
61
+ git clean -fdx docs/superpowers/
62
+ ```
63
+
64
+ 清理命令按冲突文件的直接父目录去重生成。
65
+
66
+ ## 错误处理
67
+
68
+ - `git check-ignore` 无匹配时退出码为 1(非错误),需 catch 后返回空数组
69
+ - `git diff --name-only` 失败时不阻断流程,跳过检测继续 apply(降级为当前行为)
70
+ - 检测函数本身的异常不应阻断 validate 流程
71
+
72
+ ## 回归测试要求
73
+
74
+ - 无幽灵文件时:validate 正常通过(现有测试覆盖)
75
+ - 有幽灵文件时:validate 返回 `{ success: false }` 并输出提示
76
+ - `gitCheckIgnored` 单元测试:空输入、全部忽略、全部不忽略、混合场景
package/docs/validate.md CHANGED
@@ -158,9 +158,44 @@ git restore --staged .
158
158
  > 此步骤结束后,目标 worktree 的代码保持原样,主 worktree 工作目录包含目标分支的全量变更。
159
159
  > 如果 patch apply 失败(兜底场景),`migrateChangesViaPatch` 返回 `{ success: false }`,进入自动 sync 交互流程(见下文 [patch apply 失败后的自动 sync 流程](#patch-apply-失败后的自动-sync-流程))。
160
160
 
161
+ ###### 幽灵文件检测(patch apply 前置拦截)
162
+
163
+ 在执行昂贵的 `git diff --binary` 之前,`migrateChangesViaPatch` 会先进行轻量级的**幽灵文件检测**,提前拦截一类常见的 patch apply 失败场景。
164
+
165
+ **背景:** AI Agent(如 Claude Code)在 worktree 中工作时,可能会创建被 `.gitignore` 忽略的文件(如 `node_modules/` 下的依赖、构建产物等)。这些文件不受 git 跟踪,但当目标分支的 patch 中包含同名文件时,`git apply` 会因为"文件已存在于工作区中"而失败。由于这些文件被 `.gitignore` 忽略,`git clean -fd` 无法清理它们(需要 `git clean -fdx`),用户往往难以自行发现和定位。
166
+
167
+ **检测流程:**
168
+
169
+ 1. **获取 patch 涉及的文件列表**:通过 `git diff --name-only HEAD...<branchName>` 轻量获取目标分支变更涉及的所有文件路径(不含二进制内容,远比 `--binary` 便宜)
170
+ 2. **筛选被 `.gitignore` 忽略的文件**:调用 `gitCheckIgnored()`(`src/utils/git-core.ts`),通过 `git check-ignore` 批量检测哪些文件被忽略规则匹配
171
+ 3. **确认文件物理存在**:对被忽略的文件进一步检查其是否真实存在于主 worktree 文件系统中(`existsSync`),只有同时满足"被忽略"和"物理存在"两个条件的才是幽灵文件
172
+ 4. **拦截并提示**:如果检测到幽灵文件,生成针对性的 `git clean -fdx` 清理命令(按直接父目录去重,通过 `buildCleanCommands()` 生成),输出清晰的错误提示后返回 `{ success: false }`
173
+
174
+ **错误提示示例:**
175
+
176
+ ```
177
+ 检测到被 .gitignore 忽略的文件残留在主 worktree 中,导致变更无法应用:
178
+ - dist/bundle.js
179
+ - node_modules/.cache/temp.json
180
+
181
+ 请手动清理后重试:
182
+ git clean -fdx dist/
183
+ git clean -fdx node_modules/.cache/
184
+ ```
185
+
186
+ > 幽灵文件检测失败后,同样进入 [patch apply 失败后的自动 sync 流程](#patch-apply-失败后的自动-sync-流程)(询问用户是否执行 sync),但用户通常应根据提示先手动清理幽灵文件再重试 validate。
187
+ > 如果 `git diff --name-only` 执行失败(如分支不存在),检测会静默跳过(降级为原有行为,让后续 apply 自行报错)。
188
+
189
+ **实现要点:**
190
+
191
+ - `detectIgnoredFilesInPatch(branchName, mainWorktreePath)`(`src/utils/validate-core.ts`):检测 patch 中的幽灵文件,返回幽灵文件的相对路径列表
192
+ - `gitCheckIgnored(paths, cwd)`(`src/utils/git-core.ts`):封装 `git check-ignore` 命令,批量检测文件是否被忽略,退出码 1(无匹配)视为正常情况返回空数组
193
+ - `buildCleanCommands(files)`(`src/utils/validate-core.ts`):根据冲突文件列表,按直接父目录去重生成 `git clean -fdx <dir>/` 命令
194
+ - 消息常量:`MESSAGES.VALIDATE_IGNORED_FILES_CONFLICT`(`src/constants/messages/validate.ts`):双语提示,最多展示 10 个文件路径,超出部分显示总数
195
+
161
196
  ##### patch apply 失败后的自动 sync 流程
162
197
 
163
- 当 patch apply 失败时,validate 不再直接退出,而是先通过 `ensureOnMainWorkBranch()` 确保主 worktree 切回主工作分支,然后通过 `handlePatchApplyFailure()` 函数进入交互流程:
198
+ 当 patch 迁移失败时(包括 patch apply 冲突和幽灵文件检测拦截两种情况),validate 不再直接退出,而是先通过 `ensureOnMainWorkBranch()` 确保主 worktree 切回主工作分支,然后通过 `handlePatchApplyFailure()` 函数进入交互流程:
164
199
 
165
200
  1. **询问用户**:提示 `是否立即执行 sync 同步主分支到 <branchName>?`
166
201
  2. **用户拒绝** → 输出提示 `请手动执行 clawt sync -b <branchName> 同步主分支后重试`,退出
@@ -170,10 +205,12 @@ git restore --staged .
170
205
 
171
206
  **实现要点:**
172
207
 
173
- - `migrateChangesViaPatch()`(`src/utils/validate-core.ts`)返回 `{ success: boolean }`,patch apply 失败时返回 `{ success: false }` 而非抛出异常
208
+ - `migrateChangesViaPatch()`(`src/utils/validate-core.ts`)返回 `{ success: boolean }`,patch apply 失败或幽灵文件检测拦截时返回 `{ success: false }` 而非抛出异常
209
+ - `detectIgnoredFilesInPatch(branchName, mainWorktreePath)`(`src/utils/validate-core.ts`):幽灵文件检测函数,在 patch apply 之前调用,返回被 `.gitignore` 忽略且物理存在的文件列表
210
+ - `gitCheckIgnored(paths, cwd)`(`src/utils/git-core.ts`):封装 `git check-ignore`,批量检测文件是否被忽略规则匹配
174
211
  - `handleFirstValidate()` 和 `handleIncrementalValidate()` 为 `async` 函数,支持交互式确认
175
- - `handlePatchApplyFailure()`(`src/commands/validate.ts`)为异步函数,负责 patch 失败后的交互逻辑
176
- - 消息常量:`MESSAGES.VALIDATE_CONFIRM_AUTO_SYNC`、`MESSAGES.VALIDATE_AUTO_SYNC_START`、`MESSAGES.VALIDATE_AUTO_SYNC_DECLINED`(`src/constants/messages/validate.ts`)
212
+ - `handlePatchApplyFailure()`(`src/commands/validate.ts`)为异步函数,负责 patch 迁移失败后的交互逻辑(含幽灵文件冲突和 patch apply 冲突两种场景)
213
+ - 消息常量:`MESSAGES.VALIDATE_CONFIRM_AUTO_SYNC`、`MESSAGES.VALIDATE_AUTO_SYNC_START`、`MESSAGES.VALIDATE_AUTO_SYNC_DECLINED`、`MESSAGES.VALIDATE_IGNORED_FILES_CONFLICT`(`src/constants/messages/validate.ts`)
177
214
 
178
215
  ##### 步骤 5:保存快照为 git tree 对象
179
216
 
@@ -323,7 +360,7 @@ git checkout clawt-validate-<branchName>
323
360
 
324
361
  ##### 步骤 4:从目标分支获取最新全量变更
325
362
 
326
- 通过 patch 方式从目标分支获取最新全量变更(流程同首次 validate 的步骤 4)。如果 patch apply 失败,同样进入自动 sync 交互流程(见首次 validate 的 [patch apply 失败后的自动 sync 流程](#patch-apply-失败后的自动-sync-流程)),validate 流程提前结束。
363
+ 通过 patch 方式从目标分支获取最新全量变更(流程同首次 validate 的步骤 4,包含幽灵文件前置检测)。如果幽灵文件检测拦截或 patch apply 失败,同样进入自动 sync 交互流程(见首次 validate 的 [patch apply 失败后的自动 sync 流程](#patch-apply-失败后的自动-sync-流程)),validate 流程提前结束。
327
364
 
328
365
  ##### 步骤 5:检测是否有新变更
329
366
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "3.10.3",
3
+ "version": "3.10.5",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -40,6 +40,23 @@ const VALIDATE_MESSAGES_I18N = {
40
40
  'zh-CN': (branch: string) =>
41
41
  `变更迁移失败:目标分支与主分支差异过大\n 请先执行 clawt sync -b ${branch} 同步主分支后重试`,
42
42
  },
43
+ /** validate 检测到被 .gitignore 忽略的残留文件冲突 */
44
+ VALIDATE_IGNORED_FILES_CONFLICT: {
45
+ en: (files: string[], cleanCommands: string[]) => {
46
+ const maxDisplay = 10;
47
+ const displayed = files.slice(0, maxDisplay).map(f => ` - ${f}`).join('\n');
48
+ const more = files.length > maxDisplay ? `\n ...(${files.length} files total)` : '';
49
+ const cmds = cleanCommands.map(c => ` ${c}`).join('\n');
50
+ return `Ignored files left in main worktree are blocking patch apply:\n${displayed}${more}\n\nPlease clean up manually and retry:\n${cmds}`;
51
+ },
52
+ 'zh-CN': (files: string[], cleanCommands: string[]) => {
53
+ const maxDisplay = 10;
54
+ const displayed = files.slice(0, maxDisplay).map(f => ` - ${f}`).join('\n');
55
+ const more = files.length > maxDisplay ? `\n ...(共 ${files.length} 个文件)` : '';
56
+ const cmds = cleanCommands.map(c => ` ${c}`).join('\n');
57
+ return `检测到被 .gitignore 忽略的文件残留在主 worktree 中,导致变更无法应用:\n${displayed}${more}\n\n请手动清理后重试:\n${cmds}`;
58
+ },
59
+ },
43
60
  /** validate 无可用 worktree */
44
61
  VALIDATE_NO_WORKTREES: {
45
62
  en: 'No worktrees available, please create one with clawt run or clawt create first',
@@ -498,3 +498,26 @@ export function gitMergeAbort(cwd?: string): void {
498
498
  export function buildAutoSaveCommitMessage(mainBranch: string, branch: string): string {
499
499
  return `${AUTO_SAVE_COMMIT_MESSAGE_PREFIX} ${mainBranch} into ${branch}`;
500
500
  }
501
+
502
+ /**
503
+ * 批量检测文件是否被 .gitignore 忽略
504
+ * 使用 git check-ignore 命令,退出码 1 表示无匹配(非错误)
505
+ * @param {string[]} paths - 要检测的文件路径列表
506
+ * @param {string} [cwd] - 工作目录
507
+ * @returns {string[]} 被忽略的文件路径列表
508
+ */
509
+ export function gitCheckIgnored(paths: string[], cwd?: string): string[] {
510
+ if (paths.length === 0) return [];
511
+
512
+ try {
513
+ const output = execFileSync('git', ['check-ignore', '--', ...paths], {
514
+ cwd,
515
+ encoding: 'utf-8',
516
+ stdio: ['pipe', 'pipe', 'pipe'],
517
+ });
518
+ return output.trim().split('\n').filter(Boolean);
519
+ } catch {
520
+ // git check-ignore 退出码 1 表示无匹配文件,属于正常情况
521
+ return [];
522
+ }
523
+ }
@@ -63,6 +63,7 @@ export {
63
63
  gitMergeAbort,
64
64
  buildAutoSaveCommitMessage,
65
65
  throwIfGitIndexLockError,
66
+ gitCheckIgnored,
66
67
  } from './git.js';
67
68
  export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } from './branch.js';
68
69
  export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled, validateHeadExists, validateWorkingDirClean, runPreChecks } from './validation.js';
@@ -92,7 +93,7 @@ export { getValidateBranchName, createValidateBranch, deleteValidateBranch, rebu
92
93
  export { safeStringify } from './json.js';
93
94
  export { isNonInteractive, setNonInteractive } from './interactive.js';
94
95
  export { executeRunCommand } from './validate-runner.js';
95
- export { migrateChangesViaPatch, computeCurrentTreeHash, saveCurrentSnapshotTree, loadOldSnapshotToStage, switchToValidateBranch } from './validate-core.js';
96
+ export { migrateChangesViaPatch, computeCurrentTreeHash, saveCurrentSnapshotTree, loadOldSnapshotToStage, switchToValidateBranch, detectIgnoredFilesInPatch } from './validate-core.js';
96
97
  export { InteractivePanel } from './interactive-panel.js';
97
98
  export { buildPanelFrame, buildGroupedWorktreeLines, buildDisplayOrder, renderDateSeparator, renderWorktreeBlock, renderSnapshotSummary, renderFooter, calculateVisibleRows } from './interactive-panel-render.js';
98
99
  export type { PanelLine } from './interactive-panel-render.js';