ccmanager 2.1.0 → 2.2.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/README.md +18 -0
- package/dist/components/App.js +1 -1
- package/dist/components/Configuration.js +21 -7
- package/dist/components/ConfigureStatusHooks.d.ts +6 -0
- package/dist/components/{ConfigureHooks.js → ConfigureStatusHooks.js} +16 -18
- package/dist/components/ConfigureStatusHooks.test.d.ts +1 -0
- package/dist/components/ConfigureStatusHooks.test.js +62 -0
- package/dist/components/ConfigureWorktreeHooks.d.ts +6 -0
- package/dist/components/ConfigureWorktreeHooks.js +114 -0
- package/dist/components/ConfigureWorktreeHooks.test.d.ts +1 -0
- package/dist/components/ConfigureWorktreeHooks.test.js +60 -0
- package/dist/services/configurationManager.d.ts +3 -1
- package/dist/services/configurationManager.js +10 -0
- package/dist/services/projectManager.test.js +8 -9
- package/dist/services/sessionManager.d.ts +0 -1
- package/dist/services/sessionManager.js +3 -33
- package/dist/services/worktreeService.d.ts +2 -2
- package/dist/services/worktreeService.js +18 -1
- package/dist/services/worktreeService.test.js +162 -7
- package/dist/types/index.d.ts +10 -2
- package/dist/utils/hookExecutor.d.ts +20 -0
- package/dist/utils/hookExecutor.js +96 -0
- package/dist/utils/hookExecutor.test.d.ts +1 -0
- package/dist/utils/hookExecutor.test.js +387 -0
- package/package.json +1 -1
- package/dist/components/ConfigureHooks.d.ts +0 -6
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { executeHook, executeWorktreePostCreationHook, executeStatusHook, } from './hookExecutor.js';
|
|
3
|
+
import { mkdtemp, rm, readFile } from 'fs/promises';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { configurationManager } from '../services/configurationManager.js';
|
|
7
|
+
import { WorktreeService } from '../services/worktreeService.js';
|
|
8
|
+
// Mock the configurationManager
|
|
9
|
+
vi.mock('../services/configurationManager.js', () => ({
|
|
10
|
+
configurationManager: {
|
|
11
|
+
getStatusHooks: vi.fn(),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
// Mock the WorktreeService
|
|
15
|
+
vi.mock('../services/worktreeService.js', () => ({
|
|
16
|
+
WorktreeService: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
// Note: This file contains integration tests that execute real commands
|
|
19
|
+
describe('hookExecutor Integration Tests', () => {
|
|
20
|
+
describe('executeHook (real execution)', () => {
|
|
21
|
+
it('should execute a simple echo command', async () => {
|
|
22
|
+
// Arrange
|
|
23
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
|
|
24
|
+
const environment = {
|
|
25
|
+
CCMANAGER_WORKTREE_PATH: tmpDir,
|
|
26
|
+
CCMANAGER_WORKTREE_BRANCH: 'test-branch',
|
|
27
|
+
CCMANAGER_GIT_ROOT: tmpDir,
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
// Act & Assert - should not throw
|
|
31
|
+
await expect(executeHook('echo "Test successful"', tmpDir, environment)).resolves.toBeUndefined();
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
// Cleanup
|
|
35
|
+
await rm(tmpDir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it('should reject when command fails', async () => {
|
|
39
|
+
// Arrange
|
|
40
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
|
|
41
|
+
const environment = {
|
|
42
|
+
CCMANAGER_WORKTREE_PATH: tmpDir,
|
|
43
|
+
CCMANAGER_WORKTREE_BRANCH: 'test-branch',
|
|
44
|
+
CCMANAGER_GIT_ROOT: tmpDir,
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
// Act & Assert
|
|
48
|
+
await expect(executeHook('exit 1', tmpDir, environment)).rejects.toThrow();
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
// Cleanup
|
|
52
|
+
await rm(tmpDir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
it('should include stderr in error message when command fails', async () => {
|
|
56
|
+
// Arrange
|
|
57
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
|
|
58
|
+
const environment = {
|
|
59
|
+
CCMANAGER_WORKTREE_PATH: tmpDir,
|
|
60
|
+
CCMANAGER_WORKTREE_BRANCH: 'test-branch',
|
|
61
|
+
CCMANAGER_GIT_ROOT: tmpDir,
|
|
62
|
+
};
|
|
63
|
+
try {
|
|
64
|
+
// Act & Assert - command that writes to stderr and exits with error
|
|
65
|
+
await expect(executeHook('>&2 echo "Error details here"; exit 1', tmpDir, environment)).rejects.toThrow('Hook exited with code 1\nStderr: Error details here\n');
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
// Cleanup
|
|
69
|
+
await rm(tmpDir, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
it('should verify stderr handling in error messages', async () => {
|
|
73
|
+
// Arrange
|
|
74
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
|
|
75
|
+
const environment = {
|
|
76
|
+
CCMANAGER_WORKTREE_PATH: tmpDir,
|
|
77
|
+
CCMANAGER_WORKTREE_BRANCH: 'test-branch',
|
|
78
|
+
CCMANAGER_GIT_ROOT: tmpDir,
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
// Test with multiline stderr
|
|
82
|
+
try {
|
|
83
|
+
await executeHook('>&2 echo "Line 1"; >&2 echo "Line 2"; exit 3', tmpDir, environment);
|
|
84
|
+
expect.fail('Should have thrown');
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
expect(error).toBeInstanceOf(Error);
|
|
88
|
+
expect(error.message).toContain('Hook exited with code 3');
|
|
89
|
+
expect(error.message).toContain('Stderr: Line 1\nLine 2');
|
|
90
|
+
}
|
|
91
|
+
// Test with empty stderr
|
|
92
|
+
try {
|
|
93
|
+
await executeHook('exit 4', tmpDir, environment);
|
|
94
|
+
expect.fail('Should have thrown');
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
expect(error).toBeInstanceOf(Error);
|
|
98
|
+
expect(error.message).toBe('Hook exited with code 4');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
// Cleanup
|
|
103
|
+
await rm(tmpDir, { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
it('should ignore stderr when command succeeds', async () => {
|
|
107
|
+
// Arrange
|
|
108
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
|
|
109
|
+
const environment = {
|
|
110
|
+
CCMANAGER_WORKTREE_PATH: tmpDir,
|
|
111
|
+
CCMANAGER_WORKTREE_BRANCH: 'test-branch',
|
|
112
|
+
CCMANAGER_GIT_ROOT: tmpDir,
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
// Act - command that writes to stderr but exits successfully
|
|
116
|
+
// Should not throw even though there's stderr output
|
|
117
|
+
await expect(executeHook('>&2 echo "Warning message"; exit 0', tmpDir, environment)).resolves.toBeUndefined();
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
// Cleanup
|
|
121
|
+
await rm(tmpDir, { recursive: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
it('should execute hook in the specified working directory', async () => {
|
|
125
|
+
// Arrange
|
|
126
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-cwd-test-'));
|
|
127
|
+
const outputFile = join(tmpDir, 'cwd.txt');
|
|
128
|
+
const environment = {
|
|
129
|
+
CCMANAGER_WORKTREE_PATH: tmpDir,
|
|
130
|
+
CCMANAGER_WORKTREE_BRANCH: 'test-branch',
|
|
131
|
+
CCMANAGER_GIT_ROOT: '/some/other/path',
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
// Act - write current directory to file
|
|
135
|
+
await executeHook(`pwd > "${outputFile}"`, tmpDir, environment);
|
|
136
|
+
// Read the output
|
|
137
|
+
const { readFile } = await import('fs/promises');
|
|
138
|
+
const output = await readFile(outputFile, 'utf-8');
|
|
139
|
+
// Assert - should be executed in tmpDir
|
|
140
|
+
expect(output.trim()).toBe(tmpDir);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
// Cleanup
|
|
144
|
+
await rm(tmpDir, { recursive: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe('executeWorktreePostCreationHook (real execution)', () => {
|
|
149
|
+
it('should not throw even when command fails', async () => {
|
|
150
|
+
// Arrange
|
|
151
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
|
|
152
|
+
const worktree = {
|
|
153
|
+
path: tmpDir,
|
|
154
|
+
branch: 'test-branch',
|
|
155
|
+
isMainWorktree: false,
|
|
156
|
+
hasSession: false,
|
|
157
|
+
};
|
|
158
|
+
try {
|
|
159
|
+
// Act & Assert - should not throw even with failing command
|
|
160
|
+
await expect(executeWorktreePostCreationHook('exit 1', worktree, tmpDir, 'main')).resolves.toBeUndefined();
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
// Cleanup
|
|
164
|
+
await rm(tmpDir, { recursive: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
it('should execute worktree hook in the worktree path by default', async () => {
|
|
168
|
+
// Arrange
|
|
169
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-worktree-test-'));
|
|
170
|
+
const outputFile = join(tmpDir, 'cwd.txt');
|
|
171
|
+
const worktree = {
|
|
172
|
+
path: tmpDir,
|
|
173
|
+
branch: 'test-branch',
|
|
174
|
+
isMainWorktree: false,
|
|
175
|
+
hasSession: false,
|
|
176
|
+
};
|
|
177
|
+
const gitRoot = '/different/git/root';
|
|
178
|
+
try {
|
|
179
|
+
// Act - write current directory to file
|
|
180
|
+
await executeWorktreePostCreationHook(`pwd > "${outputFile}"`, worktree, gitRoot, 'main');
|
|
181
|
+
// Read the output
|
|
182
|
+
const { readFile } = await import('fs/promises');
|
|
183
|
+
const output = await readFile(outputFile, 'utf-8');
|
|
184
|
+
// Assert - should be executed in worktree path, not git root
|
|
185
|
+
expect(output.trim()).toBe(tmpDir);
|
|
186
|
+
expect(output.trim()).not.toBe(gitRoot);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
// Cleanup
|
|
190
|
+
await rm(tmpDir, { recursive: true });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
it('should allow changing to git root using environment variable', async () => {
|
|
194
|
+
// Arrange
|
|
195
|
+
const tmpWorktreeDir = await mkdtemp(join(tmpdir(), 'hook-worktree-'));
|
|
196
|
+
const tmpGitRootDir = await mkdtemp(join(tmpdir(), 'hook-gitroot-'));
|
|
197
|
+
const outputFile = join(tmpWorktreeDir, 'gitroot.txt');
|
|
198
|
+
const worktree = {
|
|
199
|
+
path: tmpWorktreeDir,
|
|
200
|
+
branch: 'test-branch',
|
|
201
|
+
isMainWorktree: false,
|
|
202
|
+
hasSession: false,
|
|
203
|
+
};
|
|
204
|
+
try {
|
|
205
|
+
// Act - change to git root and write its path
|
|
206
|
+
await executeWorktreePostCreationHook(`cd "$CCMANAGER_GIT_ROOT" && pwd > "${outputFile}"`, worktree, tmpGitRootDir, 'main');
|
|
207
|
+
// Read the output
|
|
208
|
+
const { readFile } = await import('fs/promises');
|
|
209
|
+
const output = await readFile(outputFile, 'utf-8');
|
|
210
|
+
// Assert - should have changed to git root
|
|
211
|
+
expect(output.trim()).toBe(tmpGitRootDir);
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
// Cleanup
|
|
215
|
+
await rm(tmpWorktreeDir, { recursive: true });
|
|
216
|
+
await rm(tmpGitRootDir, { recursive: true });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
it('should wait for all child processes to complete', async () => {
|
|
220
|
+
// Arrange
|
|
221
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-wait-test-'));
|
|
222
|
+
const outputFile = join(tmpDir, 'delayed.txt');
|
|
223
|
+
const worktree = {
|
|
224
|
+
path: tmpDir,
|
|
225
|
+
branch: 'test-branch',
|
|
226
|
+
isMainWorktree: false,
|
|
227
|
+
hasSession: false,
|
|
228
|
+
};
|
|
229
|
+
try {
|
|
230
|
+
// Act - execute a command that spawns a background process with a delay
|
|
231
|
+
// The background process writes to a file after a delay
|
|
232
|
+
// We use a shell command that creates a background process and then exits
|
|
233
|
+
await executeWorktreePostCreationHook(`(sleep 0.1 && echo "completed" > "${outputFile}") & wait`, worktree, tmpDir, 'main');
|
|
234
|
+
// Read the output - this should exist because we waited for the background process
|
|
235
|
+
const { readFile } = await import('fs/promises');
|
|
236
|
+
const output = await readFile(outputFile, 'utf-8');
|
|
237
|
+
// Assert - the file should contain the expected content
|
|
238
|
+
expect(output.trim()).toBe('completed');
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
// Cleanup
|
|
242
|
+
await rm(tmpDir, { recursive: true });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
describe('executeStatusHook', () => {
|
|
247
|
+
it('should wait for hook execution to complete', async () => {
|
|
248
|
+
// Arrange
|
|
249
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'status-hook-test-'));
|
|
250
|
+
const outputFile = join(tmpDir, 'status-hook-output.txt');
|
|
251
|
+
const mockSession = {
|
|
252
|
+
id: 'test-session-123',
|
|
253
|
+
worktreePath: tmpDir, // Use tmpDir as the worktree path
|
|
254
|
+
process: {},
|
|
255
|
+
terminal: {},
|
|
256
|
+
output: [],
|
|
257
|
+
outputHistory: [],
|
|
258
|
+
state: 'idle',
|
|
259
|
+
stateCheckInterval: undefined,
|
|
260
|
+
lastActivity: new Date(),
|
|
261
|
+
isActive: true,
|
|
262
|
+
};
|
|
263
|
+
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
264
|
+
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
265
|
+
getWorktrees: vi.fn(() => [
|
|
266
|
+
{
|
|
267
|
+
path: tmpDir,
|
|
268
|
+
branch: 'test-branch',
|
|
269
|
+
isMainWorktree: false,
|
|
270
|
+
hasSession: true,
|
|
271
|
+
},
|
|
272
|
+
]),
|
|
273
|
+
}));
|
|
274
|
+
// Configure mock to return a hook that writes to a file with delay
|
|
275
|
+
vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
|
|
276
|
+
busy: {
|
|
277
|
+
enabled: true,
|
|
278
|
+
command: `sleep 0.1 && echo "Hook executed" > "${outputFile}"`,
|
|
279
|
+
},
|
|
280
|
+
idle: { enabled: false, command: '' },
|
|
281
|
+
waiting_input: { enabled: false, command: '' },
|
|
282
|
+
});
|
|
283
|
+
try {
|
|
284
|
+
// Act - execute the hook and await it
|
|
285
|
+
await executeStatusHook('idle', 'busy', mockSession);
|
|
286
|
+
// Assert - file should exist because we awaited the hook
|
|
287
|
+
const content = await readFile(outputFile, 'utf-8');
|
|
288
|
+
expect(content.trim()).toBe('Hook executed');
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
// Cleanup
|
|
292
|
+
await rm(tmpDir, { recursive: true });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
it('should handle hook execution errors gracefully', async () => {
|
|
296
|
+
// Arrange
|
|
297
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'status-hook-test-'));
|
|
298
|
+
const mockSession = {
|
|
299
|
+
id: 'test-session-456',
|
|
300
|
+
worktreePath: tmpDir, // Use tmpDir as the worktree path
|
|
301
|
+
process: {},
|
|
302
|
+
terminal: {},
|
|
303
|
+
output: [],
|
|
304
|
+
outputHistory: [],
|
|
305
|
+
state: 'idle',
|
|
306
|
+
stateCheckInterval: undefined,
|
|
307
|
+
lastActivity: new Date(),
|
|
308
|
+
isActive: true,
|
|
309
|
+
};
|
|
310
|
+
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
311
|
+
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
312
|
+
getWorktrees: vi.fn(() => [
|
|
313
|
+
{
|
|
314
|
+
path: tmpDir,
|
|
315
|
+
branch: 'test-branch',
|
|
316
|
+
isMainWorktree: false,
|
|
317
|
+
hasSession: true,
|
|
318
|
+
},
|
|
319
|
+
]),
|
|
320
|
+
}));
|
|
321
|
+
// Configure mock to return a hook that fails
|
|
322
|
+
vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
|
|
323
|
+
busy: {
|
|
324
|
+
enabled: true,
|
|
325
|
+
command: 'exit 1',
|
|
326
|
+
},
|
|
327
|
+
idle: { enabled: false, command: '' },
|
|
328
|
+
waiting_input: { enabled: false, command: '' },
|
|
329
|
+
});
|
|
330
|
+
try {
|
|
331
|
+
// Act & Assert - should not throw even when hook fails
|
|
332
|
+
await expect(executeStatusHook('idle', 'busy', mockSession)).resolves.toBeUndefined();
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
// Cleanup
|
|
336
|
+
await rm(tmpDir, { recursive: true });
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
it('should not execute disabled hooks', async () => {
|
|
340
|
+
// Arrange
|
|
341
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'status-hook-test-'));
|
|
342
|
+
const outputFile = join(tmpDir, 'should-not-exist.txt');
|
|
343
|
+
const mockSession = {
|
|
344
|
+
id: 'test-session-789',
|
|
345
|
+
worktreePath: tmpDir, // Use tmpDir as the worktree path
|
|
346
|
+
process: {},
|
|
347
|
+
terminal: {},
|
|
348
|
+
output: [],
|
|
349
|
+
outputHistory: [],
|
|
350
|
+
state: 'idle',
|
|
351
|
+
stateCheckInterval: undefined,
|
|
352
|
+
lastActivity: new Date(),
|
|
353
|
+
isActive: true,
|
|
354
|
+
};
|
|
355
|
+
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
356
|
+
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
357
|
+
getWorktrees: vi.fn(() => [
|
|
358
|
+
{
|
|
359
|
+
path: tmpDir,
|
|
360
|
+
branch: 'test-branch',
|
|
361
|
+
isMainWorktree: false,
|
|
362
|
+
hasSession: true,
|
|
363
|
+
},
|
|
364
|
+
]),
|
|
365
|
+
}));
|
|
366
|
+
// Configure mock to return a disabled hook
|
|
367
|
+
vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
|
|
368
|
+
busy: {
|
|
369
|
+
enabled: false,
|
|
370
|
+
command: `echo "Should not run" > "${outputFile}"`,
|
|
371
|
+
},
|
|
372
|
+
idle: { enabled: false, command: '' },
|
|
373
|
+
waiting_input: { enabled: false, command: '' },
|
|
374
|
+
});
|
|
375
|
+
try {
|
|
376
|
+
// Act
|
|
377
|
+
await executeStatusHook('idle', 'busy', mockSession);
|
|
378
|
+
// Assert - file should not exist because hook was disabled
|
|
379
|
+
await expect(readFile(outputFile, 'utf-8')).rejects.toThrow();
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
// Cleanup
|
|
383
|
+
await rm(tmpDir, { recursive: true });
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
});
|
package/package.json
CHANGED