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.
- package/README.md +6 -6
- package/README.zh-CN.md +5 -5
- package/dist/index.js +91 -23
- package/dist/postinstall.js +27 -0
- package/docs/superpowers/findings/2026-06-01-sync-validate-diverged-findings.md +203 -0
- package/docs/superpowers/plans/2026-06-01-validate-ignored-files-conflict.md +412 -0
- package/docs/superpowers/specs/2026-06-01-validate-ignored-files-conflict-design.md +76 -0
- package/docs/validate.md +42 -5
- package/package.json +1 -1
- package/src/constants/messages/validate.ts +17 -0
- package/src/utils/git-core.ts +23 -0
- package/src/utils/index.ts +2 -1
- package/src/utils/validate-core.ts +52 -0
- package/tests/unit/utils/git-core.test.ts +43 -0
- package/tests/unit/utils/validate-core.test.ts +60 -0
|
@@ -2,6 +2,8 @@ import { logger } from '../logger/index.js';
|
|
|
2
2
|
import { ClawtError } from '../errors/index.js';
|
|
3
3
|
import { MESSAGES } from '../constants/index.js';
|
|
4
4
|
import { getCurrentLanguage } from './i18n.js';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
5
7
|
import {
|
|
6
8
|
gitAddAll,
|
|
7
9
|
gitCommit,
|
|
@@ -22,8 +24,26 @@ import {
|
|
|
22
24
|
getHeadCommitHash,
|
|
23
25
|
writeSnapshot,
|
|
24
26
|
printWarning,
|
|
27
|
+
gitCheckIgnored,
|
|
28
|
+
execCommand,
|
|
25
29
|
} from './index.js';
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* 根据冲突文件列表生成 git clean 清理命令
|
|
33
|
+
* 按直接父目录去重,生成针对性的清理命令
|
|
34
|
+
* @param {string[]} files - 冲突文件的相对路径列表
|
|
35
|
+
* @returns {string[]} 清理命令列表
|
|
36
|
+
*/
|
|
37
|
+
function buildCleanCommands(files: string[]): string[] {
|
|
38
|
+
const dirs = new Set<string>();
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const lastSlash = file.lastIndexOf('/');
|
|
41
|
+
const dir = lastSlash > 0 ? file.substring(0, lastSlash) : '.';
|
|
42
|
+
dirs.add(dir);
|
|
43
|
+
}
|
|
44
|
+
return Array.from(dirs).map(dir => `git clean -fdx ${dir}/`);
|
|
45
|
+
}
|
|
46
|
+
|
|
27
47
|
/**
|
|
28
48
|
* 通过 patch 将目标分支的全量变更(已提交 + 未提交)迁移到主 worktree
|
|
29
49
|
* 使用 git diff HEAD...branch --binary 获取变更,避免 stash 方式无法检测已提交 commit 的问题
|
|
@@ -44,6 +64,16 @@ export function migrateChangesViaPatch(targetWorktreePath: string, mainWorktreeP
|
|
|
44
64
|
didTempCommit = true;
|
|
45
65
|
}
|
|
46
66
|
|
|
67
|
+
// 先执行轻量检测:检测被 .gitignore 忽略的残留文件(幽灵文件),在 apply 之前拦截
|
|
68
|
+
// 使用 --name-only 远比 --binary 便宜,检测到冲突时可跳过昂贵的 binary diff
|
|
69
|
+
const ignoredFiles = detectIgnoredFilesInPatch(branchName, mainWorktreePath);
|
|
70
|
+
if (ignoredFiles.length > 0) {
|
|
71
|
+
const cleanCommands = buildCleanCommands(ignoredFiles);
|
|
72
|
+
logger.warn(`检测到 ${ignoredFiles.length} 个被忽略的残留文件冲突`);
|
|
73
|
+
printWarning(MESSAGES.VALIDATE_IGNORED_FILES_CONFLICT(ignoredFiles, cleanCommands));
|
|
74
|
+
return { success: false };
|
|
75
|
+
}
|
|
76
|
+
|
|
47
77
|
// 在主 worktree 执行三点 diff,获取目标分支自分叉点以来的全量变更
|
|
48
78
|
const patch = gitDiffBinaryAgainstBranch(branchName, mainWorktreePath);
|
|
49
79
|
|
|
@@ -173,3 +203,25 @@ export function switchToValidateBranch(branchName: string, mainWorktreePath: str
|
|
|
173
203
|
}
|
|
174
204
|
return validateBranchName;
|
|
175
205
|
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 检测 patch 中被 .gitignore 忽略且物理存在于主 worktree 的文件(幽灵文件)
|
|
209
|
+
* 这些文件会导致 git apply 失败("已经存在于工作区中")
|
|
210
|
+
* @param {string} branchName - 目标分支名
|
|
211
|
+
* @param {string} mainWorktreePath - 主 worktree 路径
|
|
212
|
+
* @returns {string[]} 幽灵文件的相对路径列表
|
|
213
|
+
*/
|
|
214
|
+
export function detectIgnoredFilesInPatch(branchName: string, mainWorktreePath: string): string[] {
|
|
215
|
+
try {
|
|
216
|
+
const output = execCommand(`git diff --name-only HEAD...${branchName}`, { cwd: mainWorktreePath });
|
|
217
|
+
const patchFiles = output.split('\n').filter(Boolean);
|
|
218
|
+
if (patchFiles.length === 0) return [];
|
|
219
|
+
|
|
220
|
+
// 筛选被 .gitignore 忽略且物理存在的文件(幽灵文件)
|
|
221
|
+
return gitCheckIgnored(patchFiles, mainWorktreePath)
|
|
222
|
+
.filter(file => existsSync(join(mainWorktreePath, file)));
|
|
223
|
+
} catch {
|
|
224
|
+
// diff 失败时跳过检测,降级为当前行为(让 apply 自行报错)
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { gitCheckIgnored } from '../../../src/utils/git-core.js';
|
|
3
|
+
|
|
4
|
+
// mock child_process
|
|
5
|
+
vi.mock('node:child_process', () => ({
|
|
6
|
+
execSync: vi.fn(),
|
|
7
|
+
execFileSync: vi.fn(),
|
|
8
|
+
exec: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
const mockExecFileSync = vi.mocked(execFileSync);
|
|
13
|
+
|
|
14
|
+
describe('gitCheckIgnored', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.clearAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('空数组输入时返回空数组', () => {
|
|
20
|
+
const result = gitCheckIgnored([]);
|
|
21
|
+
expect(result).toEqual([]);
|
|
22
|
+
expect(mockExecFileSync).not.toHaveBeenCalled();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('全部被忽略时返回全部路径', () => {
|
|
26
|
+
mockExecFileSync.mockReturnValue('docs/superpowers/a.md\ndocs/superpowers/b.md\n');
|
|
27
|
+
const result = gitCheckIgnored(['docs/superpowers/a.md', 'docs/superpowers/b.md']);
|
|
28
|
+
expect(result).toEqual(['docs/superpowers/a.md', 'docs/superpowers/b.md']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('全部不被忽略时返回空数组', () => {
|
|
32
|
+
// git check-ignore 无匹配时退出码为 1,execFileSync 抛出异常
|
|
33
|
+
mockExecFileSync.mockImplementation(() => { throw new Error('exit code 1'); });
|
|
34
|
+
const result = gitCheckIgnored(['src/index.ts']);
|
|
35
|
+
expect(result).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('混合场景时仅返回被忽略的路径', () => {
|
|
39
|
+
mockExecFileSync.mockReturnValue('docs/superpowers/a.md\n');
|
|
40
|
+
const result = gitCheckIgnored(['docs/superpowers/a.md', 'src/index.ts']);
|
|
41
|
+
expect(result).toEqual(['docs/superpowers/a.md']);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('../../../src/utils/git-core.js', async () => {
|
|
4
|
+
const actual = await vi.importActual('../../../src/utils/git-core.js');
|
|
5
|
+
return { ...actual, gitCheckIgnored: vi.fn() };
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
vi.mock('node:fs', () => ({
|
|
9
|
+
existsSync: vi.fn(),
|
|
10
|
+
mkdirSync: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('../../../src/utils/shell.js', async () => {
|
|
14
|
+
const actual = await vi.importActual('../../../src/utils/shell.js');
|
|
15
|
+
return { ...actual, execCommand: vi.fn() };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
import { detectIgnoredFilesInPatch } from '../../../src/utils/validate-core.js';
|
|
19
|
+
import { gitCheckIgnored } from '../../../src/utils/git-core.js';
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
21
|
+
import { execCommand } from '../../../src/utils/shell.js';
|
|
22
|
+
|
|
23
|
+
const mockGitCheckIgnored = vi.mocked(gitCheckIgnored);
|
|
24
|
+
const mockExistsSync = vi.mocked(existsSync);
|
|
25
|
+
const mockExecCommand = vi.mocked(execCommand);
|
|
26
|
+
|
|
27
|
+
describe('detectIgnoredFilesInPatch', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('无幽灵文件时返回空数组', () => {
|
|
33
|
+
mockExecCommand.mockReturnValue('src/a.ts\nsrc/b.ts');
|
|
34
|
+
mockGitCheckIgnored.mockReturnValue([]);
|
|
35
|
+
const result = detectIgnoredFilesInPatch('feature', '/main');
|
|
36
|
+
expect(result).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('检测到幽灵文件时返回文件列表', () => {
|
|
40
|
+
mockExecCommand.mockReturnValue('docs/superpowers/a.md\nsrc/b.ts');
|
|
41
|
+
mockGitCheckIgnored.mockReturnValue(['docs/superpowers/a.md']);
|
|
42
|
+
mockExistsSync.mockImplementation((p: string) => p === '/main/docs/superpowers/a.md');
|
|
43
|
+
const result = detectIgnoredFilesInPatch('feature', '/main');
|
|
44
|
+
expect(result).toEqual(['docs/superpowers/a.md']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('被忽略但物理不存在的文件不包含在结果中', () => {
|
|
48
|
+
mockExecCommand.mockReturnValue('docs/superpowers/a.md');
|
|
49
|
+
mockGitCheckIgnored.mockReturnValue(['docs/superpowers/a.md']);
|
|
50
|
+
mockExistsSync.mockReturnValue(false);
|
|
51
|
+
const result = detectIgnoredFilesInPatch('feature', '/main');
|
|
52
|
+
expect(result).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('git diff --name-only 失败时返回空数组(降级)', () => {
|
|
56
|
+
mockExecCommand.mockImplementation(() => { throw new Error('fatal'); });
|
|
57
|
+
const result = detectIgnoredFilesInPatch('feature', '/main');
|
|
58
|
+
expect(result).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
});
|