clawt 3.7.1 → 3.8.0
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/.clawt/postCreate.sh +2 -0
- package/CLAUDE.md +10 -0
- package/README.md +18 -1
- package/dist/index.js +171 -25
- package/dist/postinstall.js +30 -1
- package/docs/create.md +6 -2
- package/docs/init.md +2 -2
- package/docs/post-create-hook.md +142 -0
- package/docs/project-config.md +9 -2
- package/docs/run.md +10 -6
- package/docs/spec.md +4 -1
- package/package.json +1 -1
- package/src/commands/create.ts +5 -0
- package/src/commands/run.ts +12 -0
- package/src/constants/config.ts +1 -1
- package/src/constants/messages/index.ts +2 -0
- package/src/constants/messages/post-create.ts +29 -0
- package/src/constants/project-config.ts +4 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/post-create.ts +198 -0
- package/src/types/command.ts +4 -0
- package/src/types/index.ts +1 -0
- package/src/types/postCreateHook.ts +24 -0
- package/src/types/projectConfig.ts +2 -0
- package/src/utils/claude.ts +2 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/task-executor.ts +5 -1
- package/tests/unit/commands/create.test.ts +1 -0
- package/tests/unit/commands/run.test.ts +1 -0
- package/tests/unit/constants/messages-post-create.test.ts +112 -0
- package/tests/unit/hooks/post-create.test.ts +434 -0
- package/tests/unit/utils/claude.test.ts +76 -1
package/src/utils/claude.ts
CHANGED
|
@@ -108,9 +108,10 @@ function escapeShellSingleQuote(str: string): string {
|
|
|
108
108
|
*/
|
|
109
109
|
export function buildClaudeCommand(worktree: WorktreeInfo, hasPreviousSession: boolean): string {
|
|
110
110
|
const commandStr = getConfigValue('claudeCodeCommand');
|
|
111
|
+
const systemPrompt = APPEND_SYSTEM_PROMPT;
|
|
111
112
|
|
|
112
113
|
const escapedPath = escapeShellSingleQuote(worktree.path);
|
|
113
|
-
const escapedPrompt = escapeShellSingleQuote(
|
|
114
|
+
const escapedPrompt = escapeShellSingleQuote(systemPrompt);
|
|
114
115
|
const continueFlag = hasPreviousSession ? ' --continue' : '';
|
|
115
116
|
|
|
116
117
|
return `cd '${escapedPath}' && ${commandStr} --append-system-prompt '${escapedPrompt}'${continueFlag}`;
|
package/src/utils/index.ts
CHANGED
|
@@ -89,4 +89,5 @@ export { InteractivePanel } from './interactive-panel.js';
|
|
|
89
89
|
export { buildPanelFrame, buildGroupedWorktreeLines, buildDisplayOrder, renderDateSeparator, renderWorktreeBlock, renderSnapshotSummary, renderFooter, calculateVisibleRows } from './interactive-panel-render.js';
|
|
90
90
|
export type { PanelLine } from './interactive-panel-render.js';
|
|
91
91
|
export { buildConflictResolvePrompt, invokeClaudeForConflictResolve, resolveConflictsWithAI, determineConflictResolveMode, handleMergeConflict } from './conflict-resolver.js';
|
|
92
|
+
export { resolvePostCreateHook, executePostCreateHooks, runPostCreateHooks } from '../hooks/index.js';
|
|
92
93
|
|
|
@@ -5,6 +5,7 @@ import type { ClaudeCodeResult, TaskResult, TaskSummary, WorktreeInfo } from '..
|
|
|
5
5
|
import { spawnProcess, killAllChildProcesses } from './shell.js';
|
|
6
6
|
import { cleanupWorktrees } from './worktree.js';
|
|
7
7
|
import { getConfigValue } from './config.js';
|
|
8
|
+
import { APPEND_SYSTEM_PROMPT } from '../constants/index.js';
|
|
8
9
|
import { printSuccess, printWarning, printInfo, printDoubleSeparator, confirmAction } from './formatter.js';
|
|
9
10
|
import { ProgressRenderer } from './progress.js';
|
|
10
11
|
import { createLineBuffer, parseStreamLine, parseStreamEvent, truncateText } from './stream-parser.js';
|
|
@@ -34,8 +35,11 @@ type ActivityCallback = (activityText: string) => void;
|
|
|
34
35
|
* @returns {ClaudeTaskHandle} 包含子进程引用和结果 Promise
|
|
35
36
|
*/
|
|
36
37
|
function executeClaudeTask(worktree: WorktreeInfo, task: string, onActivity?: ActivityCallback, continueSession?: boolean): ClaudeTaskHandle {
|
|
38
|
+
// 使用统一的系统提示常量
|
|
39
|
+
const systemPrompt = APPEND_SYSTEM_PROMPT;
|
|
40
|
+
|
|
37
41
|
// 旧版使用 --output-format json,现改为 stream-json --verbose 以支持实时活动信息
|
|
38
|
-
const args = ['-p', task, '--output-format', 'stream-json', '--verbose', '--permission-mode', 'bypassPermissions'];
|
|
42
|
+
const args = ['-p', task, '--output-format', 'stream-json', '--verbose', '--permission-mode', 'bypassPermissions', '--append-system-prompt', systemPrompt];
|
|
39
43
|
|
|
40
44
|
// 追问模式:追加 --continue 继续该目录下最新会话
|
|
41
45
|
if (continueSession) {
|
|
@@ -39,6 +39,7 @@ vi.mock('../../../src/utils/index.js', () => ({
|
|
|
39
39
|
printSeparator: vi.fn(),
|
|
40
40
|
guardMainWorkBranch: vi.fn().mockResolvedValue(undefined),
|
|
41
41
|
guardMainWorkBranchExists: vi.fn(),
|
|
42
|
+
runPostCreateHooks: vi.fn().mockReturnValue(false),
|
|
42
43
|
}));
|
|
43
44
|
|
|
44
45
|
import { registerCreateCommand } from '../../../src/commands/create.js';
|
|
@@ -79,6 +79,7 @@ vi.mock('../../../src/utils/index.js', async (importOriginal) => {
|
|
|
79
79
|
ensureOnMainWorkBranch: vi.fn().mockResolvedValue(undefined),
|
|
80
80
|
guardMainWorkBranch: vi.fn().mockResolvedValue(undefined),
|
|
81
81
|
guardMainWorkBranchExists: vi.fn(),
|
|
82
|
+
runPostCreateHooks: vi.fn(),
|
|
82
83
|
};
|
|
83
84
|
});
|
|
84
85
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { POST_CREATE_MESSAGES } from '../../../src/constants/messages/post-create.js';
|
|
3
|
+
|
|
4
|
+
describe('POST_CREATE_MESSAGES', () => {
|
|
5
|
+
describe('纯字符串消息', () => {
|
|
6
|
+
it('HOOK_SKIPPED 包含 --no-post-create', () => {
|
|
7
|
+
expect(POST_CREATE_MESSAGES.HOOK_SKIPPED).toContain('--no-post-create');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('HOOK_SKIPPED 不包含旧的 --no-deps', () => {
|
|
11
|
+
expect(POST_CREATE_MESSAGES.HOOK_SKIPPED).not.toContain('--no-deps');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('HOOK_NOT_CONFIGURED 不包含"依赖"字样', () => {
|
|
15
|
+
expect(POST_CREATE_MESSAGES.HOOK_NOT_CONFIGURED).not.toContain('依赖');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('HOOK_NOT_CONFIGURED 包含"未配置"和"跳过"', () => {
|
|
19
|
+
expect(POST_CREATE_MESSAGES.HOOK_NOT_CONFIGURED).toContain('未配置');
|
|
20
|
+
expect(POST_CREATE_MESSAGES.HOOK_NOT_CONFIGURED).toContain('跳过');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('模板函数消息', () => {
|
|
25
|
+
it('HOOK_SOURCE_INFO 包含来源描述', () => {
|
|
26
|
+
const result = POST_CREATE_MESSAGES.HOOK_SOURCE_INFO('项目配置 (postCreate)');
|
|
27
|
+
expect(result).toContain('项目配置 (postCreate)');
|
|
28
|
+
expect(result).toContain('postCreate hook 来源');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('HOOK_EXECUTING 包含分支名和命令', () => {
|
|
32
|
+
const result = POST_CREATE_MESSAGES.HOOK_EXECUTING('feat-login', 'npm install');
|
|
33
|
+
expect(result).toContain('feat-login');
|
|
34
|
+
expect(result).toContain('npm install');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('HOOK_SUCCESS 包含分支名', () => {
|
|
38
|
+
const result = POST_CREATE_MESSAGES.HOOK_SUCCESS('feat-login');
|
|
39
|
+
expect(result).toContain('feat-login');
|
|
40
|
+
expect(result).toContain('成功');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('HOOK_FAILED 包含分支名和错误信息', () => {
|
|
44
|
+
const result = POST_CREATE_MESSAGES.HOOK_FAILED('feat-login', '命令退出码: 1');
|
|
45
|
+
expect(result).toContain('feat-login');
|
|
46
|
+
expect(result).toContain('命令退出码: 1');
|
|
47
|
+
expect(result).toContain('失败');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('HOOK_SUMMARY 包含成功和失败计数', () => {
|
|
51
|
+
const result = POST_CREATE_MESSAGES.HOOK_SUMMARY(3, 1);
|
|
52
|
+
expect(result).toContain('3');
|
|
53
|
+
expect(result).toContain('1');
|
|
54
|
+
expect(result).toContain('成功');
|
|
55
|
+
expect(result).toContain('失败');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('HOOK_SUMMARY 全部成功时失败数为 0', () => {
|
|
59
|
+
const result = POST_CREATE_MESSAGES.HOOK_SUMMARY(5, 0);
|
|
60
|
+
expect(result).toContain('5 成功');
|
|
61
|
+
expect(result).toContain('0 失败');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('POST_CREATE_SCRIPT_NOT_EXECUTABLE 包含路径和手动 chmod 提示', () => {
|
|
65
|
+
const result = POST_CREATE_MESSAGES.POST_CREATE_SCRIPT_NOT_EXECUTABLE('/repo/.clawt/postCreate.sh');
|
|
66
|
+
expect(result).toContain('/repo/.clawt/postCreate.sh');
|
|
67
|
+
expect(result).toContain('chmod +x');
|
|
68
|
+
expect(result).toContain('不可执行');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('POST_CREATE_SCRIPT_AUTO_CHMOD 包含路径和自动修复提示', () => {
|
|
72
|
+
const result = POST_CREATE_MESSAGES.POST_CREATE_SCRIPT_AUTO_CHMOD('/repo/.clawt/postCreate.sh');
|
|
73
|
+
expect(result).toContain('/repo/.clawt/postCreate.sh');
|
|
74
|
+
expect(result).toContain('已自动添加执行权限');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('HOOK_BACKGROUND_START 包含 worktree 数量和命令', () => {
|
|
78
|
+
const result = POST_CREATE_MESSAGES.HOOK_BACKGROUND_START(3, 'npm install');
|
|
79
|
+
expect(result).toContain('3');
|
|
80
|
+
expect(result).toContain('npm install');
|
|
81
|
+
expect(result).toContain('后台执行');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('HOOK_BACKGROUND_START 单个 worktree 时正确显示', () => {
|
|
85
|
+
const result = POST_CREATE_MESSAGES.HOOK_BACKGROUND_START(1, 'pnpm install');
|
|
86
|
+
expect(result).toContain('1 个 worktree');
|
|
87
|
+
expect(result).toContain('pnpm install');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('语义修正验证', () => {
|
|
92
|
+
it('不存在旧的 HOOK_SKIPPED_NO_DEPS 键', () => {
|
|
93
|
+
expect('HOOK_SKIPPED_NO_DEPS' in POST_CREATE_MESSAGES).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('不存在旧的 SETUP_SCRIPT_NOT_EXECUTABLE 键', () => {
|
|
97
|
+
expect('SETUP_SCRIPT_NOT_EXECUTABLE' in POST_CREATE_MESSAGES).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('存在新的 HOOK_SKIPPED 键', () => {
|
|
101
|
+
expect('HOOK_SKIPPED' in POST_CREATE_MESSAGES).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('存在新的 POST_CREATE_SCRIPT_NOT_EXECUTABLE 键', () => {
|
|
105
|
+
expect('POST_CREATE_SCRIPT_NOT_EXECUTABLE' in POST_CREATE_MESSAGES).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('存在新的 POST_CREATE_SCRIPT_AUTO_CHMOD 键', () => {
|
|
109
|
+
expect('POST_CREATE_SCRIPT_AUTO_CHMOD' in POST_CREATE_MESSAGES).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// mock logger(避免测试时写日志文件)
|
|
4
|
+
vi.mock('../../../src/logger/index.js', () => ({
|
|
5
|
+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// mock node:fs
|
|
9
|
+
vi.mock('node:fs', () => ({
|
|
10
|
+
existsSync: vi.fn(),
|
|
11
|
+
accessSync: vi.fn(),
|
|
12
|
+
chmodSync: vi.fn(),
|
|
13
|
+
constants: { X_OK: 1 },
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// mock node:child_process
|
|
17
|
+
vi.mock('node:child_process', () => ({
|
|
18
|
+
spawn: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// mock project-config
|
|
22
|
+
vi.mock('../../../src/utils/project-config.js', () => ({
|
|
23
|
+
loadProjectConfig: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
// mock git
|
|
27
|
+
vi.mock('../../../src/utils/git.js', () => ({
|
|
28
|
+
getMainWorktreePath: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// mock formatter
|
|
32
|
+
vi.mock('../../../src/utils/formatter.js', () => ({
|
|
33
|
+
printInfo: vi.fn(),
|
|
34
|
+
printSuccess: vi.fn(),
|
|
35
|
+
printWarning: vi.fn(),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
import { EventEmitter } from 'node:events';
|
|
39
|
+
import { existsSync, accessSync, chmodSync } from 'node:fs';
|
|
40
|
+
import { spawn } from 'node:child_process';
|
|
41
|
+
import { loadProjectConfig } from '../../../src/utils/project-config.js';
|
|
42
|
+
import { getMainWorktreePath } from '../../../src/utils/git.js';
|
|
43
|
+
import { printInfo, printSuccess, printWarning } from '../../../src/utils/formatter.js';
|
|
44
|
+
import { logger } from '../../../src/logger/index.js';
|
|
45
|
+
import {
|
|
46
|
+
resolvePostCreateHook,
|
|
47
|
+
executePostCreateHooks,
|
|
48
|
+
runPostCreateHooks,
|
|
49
|
+
} from '../../../src/hooks/post-create.js';
|
|
50
|
+
import { createWorktreeInfo, createWorktreeList } from '../../helpers/fixtures.js';
|
|
51
|
+
import type { ResolvedHook } from '../../../src/types/index.js';
|
|
52
|
+
|
|
53
|
+
const mockedLoadProjectConfig = vi.mocked(loadProjectConfig);
|
|
54
|
+
const mockedGetMainWorktreePath = vi.mocked(getMainWorktreePath);
|
|
55
|
+
const mockedExistsSync = vi.mocked(existsSync);
|
|
56
|
+
const mockedAccessSync = vi.mocked(accessSync);
|
|
57
|
+
const mockedChmodSync = vi.mocked(chmodSync);
|
|
58
|
+
const mockedSpawn = vi.mocked(spawn);
|
|
59
|
+
const mockedPrintInfo = vi.mocked(printInfo);
|
|
60
|
+
const mockedPrintSuccess = vi.mocked(printSuccess);
|
|
61
|
+
const mockedPrintWarning = vi.mocked(printWarning);
|
|
62
|
+
const mockedLoggerError = vi.mocked(logger.error);
|
|
63
|
+
const mockedLoggerInfo = vi.mocked(logger.info);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 创建模拟的 spawn 子进程
|
|
67
|
+
* @param {number} exitCode - 子进程退出码
|
|
68
|
+
* @returns {object} 模拟的子进程对象
|
|
69
|
+
*/
|
|
70
|
+
function createMockSpawnChild(exitCode: number) {
|
|
71
|
+
const child = new EventEmitter() as any;
|
|
72
|
+
child.pid = 12345;
|
|
73
|
+
child.killed = false;
|
|
74
|
+
child.kill = vi.fn();
|
|
75
|
+
|
|
76
|
+
// 延迟触发 close 事件
|
|
77
|
+
setTimeout(() => {
|
|
78
|
+
child.emit('close', exitCode);
|
|
79
|
+
}, 5);
|
|
80
|
+
|
|
81
|
+
return child;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 创建会触发 error 事件的模拟子进程
|
|
86
|
+
* @param {string} errorMessage - 错误信息
|
|
87
|
+
* @returns {object} 模拟的子进程对象
|
|
88
|
+
*/
|
|
89
|
+
function createMockSpawnChildWithError(errorMessage: string) {
|
|
90
|
+
const child = new EventEmitter() as any;
|
|
91
|
+
child.pid = 12345;
|
|
92
|
+
child.killed = false;
|
|
93
|
+
child.kill = vi.fn();
|
|
94
|
+
|
|
95
|
+
// 延迟触发 error 事件
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
child.emit('error', new Error(errorMessage));
|
|
98
|
+
}, 5);
|
|
99
|
+
|
|
100
|
+
return child;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
vi.clearAllMocks();
|
|
105
|
+
mockedGetMainWorktreePath.mockReturnValue('/repo');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ─────────────────────────────────────────────
|
|
109
|
+
// resolvePostCreateHook
|
|
110
|
+
// ─────────────────────────────────────────────
|
|
111
|
+
describe('resolvePostCreateHook', () => {
|
|
112
|
+
it('项目配置有 postCreate 字符串时返回 projectConfig 来源', () => {
|
|
113
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
114
|
+
clawtMainWorkBranch: 'main',
|
|
115
|
+
postCreate: 'npm install',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const result = resolvePostCreateHook();
|
|
119
|
+
|
|
120
|
+
expect(result).toEqual({
|
|
121
|
+
command: 'npm install',
|
|
122
|
+
source: 'projectConfig',
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('项目配置 postCreate 为空字符串时回退到脚本检测', () => {
|
|
127
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
128
|
+
clawtMainWorkBranch: 'main',
|
|
129
|
+
postCreate: '',
|
|
130
|
+
});
|
|
131
|
+
mockedExistsSync.mockReturnValue(false);
|
|
132
|
+
|
|
133
|
+
const result = resolvePostCreateHook();
|
|
134
|
+
|
|
135
|
+
expect(result).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('无项目配置时检测 .clawt/postCreate.sh 脚本', () => {
|
|
139
|
+
mockedLoadProjectConfig.mockReturnValue(null);
|
|
140
|
+
mockedExistsSync.mockReturnValue(true);
|
|
141
|
+
// accessSync 不抛出表示可执行
|
|
142
|
+
mockedAccessSync.mockReturnValue(undefined);
|
|
143
|
+
|
|
144
|
+
const result = resolvePostCreateHook();
|
|
145
|
+
|
|
146
|
+
expect(result).toEqual({
|
|
147
|
+
command: '/repo/.clawt/postCreate.sh',
|
|
148
|
+
source: 'postCreateScript',
|
|
149
|
+
});
|
|
150
|
+
// 验证检测的路径包含 postCreate.sh(而非 setup.sh)
|
|
151
|
+
expect(mockedExistsSync).toHaveBeenCalledWith('/repo/.clawt/postCreate.sh');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('脚本存在但不可执行时自动 chmod +x 后返回 postCreateScript', () => {
|
|
155
|
+
mockedLoadProjectConfig.mockReturnValue(null);
|
|
156
|
+
mockedExistsSync.mockReturnValue(true);
|
|
157
|
+
mockedAccessSync.mockImplementation(() => {
|
|
158
|
+
throw new Error('EACCES');
|
|
159
|
+
});
|
|
160
|
+
mockedChmodSync.mockReturnValue(undefined);
|
|
161
|
+
|
|
162
|
+
const result = resolvePostCreateHook();
|
|
163
|
+
|
|
164
|
+
expect(result).toEqual({
|
|
165
|
+
command: '/repo/.clawt/postCreate.sh',
|
|
166
|
+
source: 'postCreateScript',
|
|
167
|
+
});
|
|
168
|
+
expect(mockedChmodSync).toHaveBeenCalledWith('/repo/.clawt/postCreate.sh', 0o755);
|
|
169
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(
|
|
170
|
+
expect.stringContaining('已自动添加执行权限'),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('脚本不可执行且自动 chmod 失败时打印警告但仍返回 hook', () => {
|
|
175
|
+
mockedLoadProjectConfig.mockReturnValue(null);
|
|
176
|
+
mockedExistsSync.mockReturnValue(true);
|
|
177
|
+
mockedAccessSync.mockImplementation(() => {
|
|
178
|
+
throw new Error('EACCES');
|
|
179
|
+
});
|
|
180
|
+
mockedChmodSync.mockImplementation(() => {
|
|
181
|
+
throw new Error('EPERM');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const result = resolvePostCreateHook();
|
|
185
|
+
|
|
186
|
+
// 仍然返回 hook(脚本执行阶段会自然报错)
|
|
187
|
+
expect(result).toEqual({
|
|
188
|
+
command: '/repo/.clawt/postCreate.sh',
|
|
189
|
+
source: 'postCreateScript',
|
|
190
|
+
});
|
|
191
|
+
expect(mockedPrintWarning).toHaveBeenCalledWith(
|
|
192
|
+
expect.stringContaining('不可执行'),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('无项目配置且脚本不存在时返回 null', () => {
|
|
197
|
+
mockedLoadProjectConfig.mockReturnValue(null);
|
|
198
|
+
mockedExistsSync.mockReturnValue(false);
|
|
199
|
+
|
|
200
|
+
const result = resolvePostCreateHook();
|
|
201
|
+
|
|
202
|
+
expect(result).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('项目配置优先级高于脚本', () => {
|
|
206
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
207
|
+
clawtMainWorkBranch: 'main',
|
|
208
|
+
postCreate: 'pnpm install',
|
|
209
|
+
});
|
|
210
|
+
// 即使脚本也存在,也应该返回 projectConfig 来源
|
|
211
|
+
mockedExistsSync.mockReturnValue(true);
|
|
212
|
+
mockedAccessSync.mockReturnValue(undefined);
|
|
213
|
+
|
|
214
|
+
const result = resolvePostCreateHook();
|
|
215
|
+
|
|
216
|
+
expect(result!.source).toBe('projectConfig');
|
|
217
|
+
expect(result!.command).toBe('pnpm install');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('字符串命令前后空白会被 trim', () => {
|
|
221
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
222
|
+
clawtMainWorkBranch: 'main',
|
|
223
|
+
postCreate: ' npm install ',
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const result = resolvePostCreateHook();
|
|
227
|
+
|
|
228
|
+
expect(result!.command).toBe('npm install');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ─────────────────────────────────────────────
|
|
234
|
+
// executePostCreateHooks(异步并行版本)
|
|
235
|
+
// ─────────────────────────────────────────────
|
|
236
|
+
describe('executePostCreateHooks', () => {
|
|
237
|
+
const hook: ResolvedHook = {
|
|
238
|
+
command: 'npm install',
|
|
239
|
+
source: 'projectConfig',
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
it('单个 worktree 执行成功', async () => {
|
|
243
|
+
const worktree = createWorktreeInfo({ path: '/wt/feat', branch: 'feat' });
|
|
244
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
245
|
+
|
|
246
|
+
const results = await executePostCreateHooks([worktree], hook);
|
|
247
|
+
|
|
248
|
+
expect(results).toHaveLength(1);
|
|
249
|
+
expect(results[0].success).toBe(true);
|
|
250
|
+
expect(results[0].worktreePath).toBe('/wt/feat');
|
|
251
|
+
expect(results[0].branch).toBe('feat');
|
|
252
|
+
expect(results[0].source).toBe('projectConfig');
|
|
253
|
+
expect(results[0].error).toBeUndefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('命令退出码非零时标记失败', async () => {
|
|
257
|
+
const worktree = createWorktreeInfo({ path: '/wt/feat', branch: 'feat' });
|
|
258
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(1));
|
|
259
|
+
|
|
260
|
+
const results = await executePostCreateHooks([worktree], hook);
|
|
261
|
+
|
|
262
|
+
expect(results[0].success).toBe(false);
|
|
263
|
+
expect(results[0].error).toContain('退出码: 1');
|
|
264
|
+
expect(mockedLoggerError).toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('子进程触发 error 事件时标记失败', async () => {
|
|
268
|
+
const worktree = createWorktreeInfo({ path: '/wt/feat', branch: 'feat' });
|
|
269
|
+
mockedSpawn.mockReturnValue(createMockSpawnChildWithError('spawn ENOENT'));
|
|
270
|
+
|
|
271
|
+
const results = await executePostCreateHooks([worktree], hook);
|
|
272
|
+
|
|
273
|
+
expect(results[0].success).toBe(false);
|
|
274
|
+
expect(results[0].error).toContain('spawn ENOENT');
|
|
275
|
+
expect(mockedLoggerError).toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('多个 worktree 并行执行,互不影响', async () => {
|
|
279
|
+
const worktrees = createWorktreeList(3);
|
|
280
|
+
mockedSpawn
|
|
281
|
+
.mockReturnValueOnce(createMockSpawnChild(0))
|
|
282
|
+
.mockReturnValueOnce(createMockSpawnChild(1))
|
|
283
|
+
.mockReturnValueOnce(createMockSpawnChild(0));
|
|
284
|
+
|
|
285
|
+
const results = await executePostCreateHooks(worktrees, hook);
|
|
286
|
+
|
|
287
|
+
expect(results).toHaveLength(3);
|
|
288
|
+
expect(results[0].success).toBe(true);
|
|
289
|
+
expect(results[1].success).toBe(false);
|
|
290
|
+
expect(results[2].success).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('正确传递 cwd 给 spawn', async () => {
|
|
294
|
+
const worktree = createWorktreeInfo({ path: '/custom/path', branch: 'test' });
|
|
295
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
296
|
+
|
|
297
|
+
await executePostCreateHooks([worktree], hook);
|
|
298
|
+
|
|
299
|
+
expect(mockedSpawn).toHaveBeenCalledWith(
|
|
300
|
+
'npm install',
|
|
301
|
+
expect.objectContaining({ cwd: '/custom/path' }),
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('spawn 使用 shell 模式和 stdio: ignore', async () => {
|
|
306
|
+
const worktree = createWorktreeInfo({ path: '/wt/feat', branch: 'feat' });
|
|
307
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
308
|
+
|
|
309
|
+
await executePostCreateHooks([worktree], hook);
|
|
310
|
+
|
|
311
|
+
expect(mockedSpawn).toHaveBeenCalledWith(
|
|
312
|
+
'npm install',
|
|
313
|
+
expect.objectContaining({
|
|
314
|
+
stdio: 'ignore',
|
|
315
|
+
shell: true,
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('退出码为 null 时视为成功(信号终止场景)', async () => {
|
|
321
|
+
const worktree = createWorktreeInfo({ path: '/wt/feat', branch: 'feat' });
|
|
322
|
+
// 创建退出码为 null 的子进程
|
|
323
|
+
const child = new EventEmitter() as any;
|
|
324
|
+
child.pid = 12345;
|
|
325
|
+
child.killed = false;
|
|
326
|
+
child.kill = vi.fn();
|
|
327
|
+
setTimeout(() => {
|
|
328
|
+
child.emit('close', null);
|
|
329
|
+
}, 5);
|
|
330
|
+
mockedSpawn.mockReturnValue(child);
|
|
331
|
+
|
|
332
|
+
const results = await executePostCreateHooks([worktree], hook);
|
|
333
|
+
|
|
334
|
+
expect(results[0].success).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('source 字段正确传递 postCreateScript', async () => {
|
|
338
|
+
const scriptHook: ResolvedHook = {
|
|
339
|
+
command: '/repo/.clawt/postCreate.sh',
|
|
340
|
+
source: 'postCreateScript',
|
|
341
|
+
};
|
|
342
|
+
const worktree = createWorktreeInfo({ path: '/wt/feat', branch: 'feat' });
|
|
343
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
344
|
+
|
|
345
|
+
const results = await executePostCreateHooks([worktree], scriptHook);
|
|
346
|
+
|
|
347
|
+
expect(results[0].source).toBe('postCreateScript');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// ─────────────────────────────────────────────
|
|
352
|
+
// runPostCreateHooks(完整入口,fire-and-forget)
|
|
353
|
+
// ─────────────────────────────────────────────
|
|
354
|
+
describe('runPostCreateHooks', () => {
|
|
355
|
+
const worktrees = createWorktreeList(2);
|
|
356
|
+
|
|
357
|
+
it('skip 为 true 时直接跳过', () => {
|
|
358
|
+
runPostCreateHooks(worktrees, true);
|
|
359
|
+
|
|
360
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(
|
|
361
|
+
expect.stringContaining('--no-post-create'),
|
|
362
|
+
);
|
|
363
|
+
// 不应调用任何配置加载或命令执行
|
|
364
|
+
expect(mockedLoadProjectConfig).not.toHaveBeenCalled();
|
|
365
|
+
expect(mockedSpawn).not.toHaveBeenCalled();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('无 hook 配置时提示未配置', () => {
|
|
369
|
+
mockedLoadProjectConfig.mockReturnValue(null);
|
|
370
|
+
mockedExistsSync.mockReturnValue(false);
|
|
371
|
+
|
|
372
|
+
runPostCreateHooks(worktrees, false);
|
|
373
|
+
|
|
374
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(
|
|
375
|
+
expect.stringContaining('未配置'),
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('有 hook 配置时调用 spawn 启动后台执行', () => {
|
|
380
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
381
|
+
clawtMainWorkBranch: 'main',
|
|
382
|
+
postCreate: 'npm install',
|
|
383
|
+
});
|
|
384
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
385
|
+
|
|
386
|
+
runPostCreateHooks(worktrees, false);
|
|
387
|
+
|
|
388
|
+
// fire-and-forget:spawn 应该被调用(后台异步执行)
|
|
389
|
+
expect(mockedSpawn).toHaveBeenCalledTimes(2);
|
|
390
|
+
// 验证后台执行提示已输出
|
|
391
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(
|
|
392
|
+
expect.stringContaining('后台执行'),
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('返回值为 void', () => {
|
|
397
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
398
|
+
clawtMainWorkBranch: 'main',
|
|
399
|
+
postCreate: 'npm install',
|
|
400
|
+
});
|
|
401
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
402
|
+
|
|
403
|
+
const result = runPostCreateHooks(worktrees, false);
|
|
404
|
+
|
|
405
|
+
expect(result).toBeUndefined();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('输出 hook 来源信息(projectConfig)', () => {
|
|
409
|
+
mockedLoadProjectConfig.mockReturnValue({
|
|
410
|
+
clawtMainWorkBranch: 'main',
|
|
411
|
+
postCreate: 'npm install',
|
|
412
|
+
});
|
|
413
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
414
|
+
|
|
415
|
+
runPostCreateHooks(worktrees, false);
|
|
416
|
+
|
|
417
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(
|
|
418
|
+
expect.stringContaining('项目配置 (postCreate)'),
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('输出 hook 来源信息(postCreateScript)', () => {
|
|
423
|
+
mockedLoadProjectConfig.mockReturnValue(null);
|
|
424
|
+
mockedExistsSync.mockReturnValue(true);
|
|
425
|
+
mockedAccessSync.mockReturnValue(undefined);
|
|
426
|
+
mockedSpawn.mockReturnValue(createMockSpawnChild(0));
|
|
427
|
+
|
|
428
|
+
runPostCreateHooks(worktrees, false);
|
|
429
|
+
|
|
430
|
+
expect(mockedPrintInfo).toHaveBeenCalledWith(
|
|
431
|
+
expect.stringContaining('.clawt/postCreate.sh'),
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
});
|