ccmanager 2.1.0 → 2.2.1
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/constants/statePersistence.d.ts +2 -0
- package/dist/constants/statePersistence.js +4 -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 +40 -39
- package/dist/services/sessionManager.statePersistence.test.d.ts +1 -0
- package/dist/services/sessionManager.statePersistence.test.js +215 -0
- 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 +17 -7
- 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 +405 -0
- package/dist/utils/worktreeUtils.test.js +7 -0
- package/package.json +1 -1
- package/dist/components/ConfigureHooks.d.ts +0 -6
|
@@ -4,8 +4,9 @@ import pkg from '@xterm/headless';
|
|
|
4
4
|
import { exec } from 'child_process';
|
|
5
5
|
import { promisify } from 'util';
|
|
6
6
|
import { configurationManager } from './configurationManager.js';
|
|
7
|
-
import {
|
|
7
|
+
import { executeStatusHook } from '../utils/hookExecutor.js';
|
|
8
8
|
import { createStateDetector } from './stateDetector.js';
|
|
9
|
+
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
|
|
9
10
|
const { Terminal } = pkg;
|
|
10
11
|
const execAsync = promisify(exec);
|
|
11
12
|
export class SessionManager extends EventEmitter {
|
|
@@ -71,10 +72,13 @@ export class SessionManager extends EventEmitter {
|
|
|
71
72
|
lastActivity: new Date(),
|
|
72
73
|
isActive: false,
|
|
73
74
|
terminal,
|
|
75
|
+
stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
|
|
74
76
|
isPrimaryCommand: options.isPrimaryCommand ?? true,
|
|
75
77
|
commandConfig,
|
|
76
78
|
detectionStrategy: options.detectionStrategy ?? 'claude',
|
|
77
|
-
devcontainerConfig: options.devcontainerConfig,
|
|
79
|
+
devcontainerConfig: options.devcontainerConfig ?? undefined,
|
|
80
|
+
pendingState: undefined,
|
|
81
|
+
pendingStateStart: undefined,
|
|
78
82
|
};
|
|
79
83
|
// Set up persistent background data handler for state detection
|
|
80
84
|
this.setupBackgroundHandler(session);
|
|
@@ -188,16 +192,39 @@ export class SessionManager extends EventEmitter {
|
|
|
188
192
|
setupBackgroundHandler(session) {
|
|
189
193
|
// Setup data handler
|
|
190
194
|
this.setupDataHandler(session);
|
|
191
|
-
// Set up interval-based state detection
|
|
195
|
+
// Set up interval-based state detection with persistence
|
|
192
196
|
session.stateCheckInterval = setInterval(() => {
|
|
193
197
|
const oldState = session.state;
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
this
|
|
198
|
+
const detectedState = this.detectTerminalState(session);
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
// If detected state is different from current state
|
|
201
|
+
if (detectedState !== oldState) {
|
|
202
|
+
// If this is a new pending state or the pending state changed
|
|
203
|
+
if (session.pendingState !== detectedState) {
|
|
204
|
+
session.pendingState = detectedState;
|
|
205
|
+
session.pendingStateStart = now;
|
|
206
|
+
}
|
|
207
|
+
else if (session.pendingState !== undefined &&
|
|
208
|
+
session.pendingStateStart !== undefined) {
|
|
209
|
+
// Check if the pending state has persisted long enough
|
|
210
|
+
const duration = now - session.pendingStateStart;
|
|
211
|
+
if (duration >= STATE_PERSISTENCE_DURATION_MS) {
|
|
212
|
+
// Confirm the state change
|
|
213
|
+
session.state = detectedState;
|
|
214
|
+
session.pendingState = undefined;
|
|
215
|
+
session.pendingStateStart = undefined;
|
|
216
|
+
// Execute status hook asynchronously (non-blocking)
|
|
217
|
+
void executeStatusHook(oldState, detectedState, session);
|
|
218
|
+
this.emit('sessionStateChanged', session);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
// Detected state matches current state, clear any pending state
|
|
224
|
+
session.pendingState = undefined;
|
|
225
|
+
session.pendingStateStart = undefined;
|
|
199
226
|
}
|
|
200
|
-
},
|
|
227
|
+
}, STATE_CHECK_INTERVAL_MS);
|
|
201
228
|
// Setup exit handler
|
|
202
229
|
this.setupExitHandler(session);
|
|
203
230
|
}
|
|
@@ -205,7 +232,11 @@ export class SessionManager extends EventEmitter {
|
|
|
205
232
|
// Clear the state check interval
|
|
206
233
|
if (session.stateCheckInterval) {
|
|
207
234
|
clearInterval(session.stateCheckInterval);
|
|
235
|
+
session.stateCheckInterval = undefined;
|
|
208
236
|
}
|
|
237
|
+
// Clear any pending state
|
|
238
|
+
session.pendingState = undefined;
|
|
239
|
+
session.pendingStateStart = undefined;
|
|
209
240
|
// Update state to idle before destroying
|
|
210
241
|
session.state = 'idle';
|
|
211
242
|
this.emit('sessionStateChanged', session);
|
|
@@ -252,36 +283,6 @@ export class SessionManager extends EventEmitter {
|
|
|
252
283
|
getAllSessions() {
|
|
253
284
|
return Array.from(this.sessions.values());
|
|
254
285
|
}
|
|
255
|
-
executeStatusHook(oldState, newState, session) {
|
|
256
|
-
const statusHooks = configurationManager.getStatusHooks();
|
|
257
|
-
const hook = statusHooks[newState];
|
|
258
|
-
if (hook && hook.enabled && hook.command) {
|
|
259
|
-
// Get branch information
|
|
260
|
-
const worktreeService = new WorktreeService();
|
|
261
|
-
const worktrees = worktreeService.getWorktrees();
|
|
262
|
-
const worktree = worktrees.find(wt => wt.path === session.worktreePath);
|
|
263
|
-
const branch = worktree?.branch || 'unknown';
|
|
264
|
-
// Execute the hook command in the session's worktree directory
|
|
265
|
-
exec(hook.command, {
|
|
266
|
-
cwd: session.worktreePath,
|
|
267
|
-
env: {
|
|
268
|
-
...process.env,
|
|
269
|
-
CCMANAGER_OLD_STATE: oldState,
|
|
270
|
-
CCMANAGER_NEW_STATE: newState,
|
|
271
|
-
CCMANAGER_WORKTREE: session.worktreePath,
|
|
272
|
-
CCMANAGER_WORKTREE_BRANCH: branch,
|
|
273
|
-
CCMANAGER_SESSION_ID: session.id,
|
|
274
|
-
},
|
|
275
|
-
}, (error, _stdout, stderr) => {
|
|
276
|
-
if (error) {
|
|
277
|
-
console.error(`Failed to execute ${newState} hook: ${error.message}`);
|
|
278
|
-
}
|
|
279
|
-
if (stderr) {
|
|
280
|
-
console.error(`Hook stderr: ${stderr}`);
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
286
|
async createSessionWithDevcontainer(worktreePath, devcontainerConfig, presetId) {
|
|
286
287
|
// Check if session already exists
|
|
287
288
|
const existing = this.sessions.get(worktreePath);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { SessionManager } from './sessionManager.js';
|
|
3
|
+
import { spawn } from 'node-pty';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
|
|
6
|
+
vi.mock('node-pty');
|
|
7
|
+
vi.mock('./configurationManager.js', () => ({
|
|
8
|
+
configurationManager: {
|
|
9
|
+
getConfig: vi.fn().mockReturnValue({
|
|
10
|
+
commands: [
|
|
11
|
+
{
|
|
12
|
+
id: 'test',
|
|
13
|
+
name: 'Test',
|
|
14
|
+
command: 'test',
|
|
15
|
+
args: [],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
defaultCommandId: 'test',
|
|
19
|
+
}),
|
|
20
|
+
getPresetById: vi.fn().mockReturnValue({
|
|
21
|
+
id: 'test',
|
|
22
|
+
name: 'Test',
|
|
23
|
+
command: 'test',
|
|
24
|
+
args: [],
|
|
25
|
+
}),
|
|
26
|
+
getDefaultPreset: vi.fn().mockReturnValue({
|
|
27
|
+
id: 'test',
|
|
28
|
+
name: 'Test',
|
|
29
|
+
command: 'test',
|
|
30
|
+
args: [],
|
|
31
|
+
}),
|
|
32
|
+
getHooks: vi.fn().mockReturnValue({}),
|
|
33
|
+
getStatusHooks: vi.fn().mockReturnValue({}),
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
describe('SessionManager - State Persistence', () => {
|
|
37
|
+
let sessionManager;
|
|
38
|
+
let mockPtyInstances;
|
|
39
|
+
let eventEmitters;
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.useFakeTimers();
|
|
42
|
+
sessionManager = new SessionManager();
|
|
43
|
+
mockPtyInstances = new Map();
|
|
44
|
+
eventEmitters = new Map();
|
|
45
|
+
// Create mock PTY process factory
|
|
46
|
+
spawn.mockImplementation((command, args, options) => {
|
|
47
|
+
const path = options.cwd;
|
|
48
|
+
const eventEmitter = new EventEmitter();
|
|
49
|
+
eventEmitters.set(path, eventEmitter);
|
|
50
|
+
const mockPty = {
|
|
51
|
+
onData: vi.fn((callback) => {
|
|
52
|
+
eventEmitter.on('data', callback);
|
|
53
|
+
return { dispose: vi.fn() };
|
|
54
|
+
}),
|
|
55
|
+
onExit: vi.fn((callback) => {
|
|
56
|
+
eventEmitter.on('exit', callback);
|
|
57
|
+
return { dispose: vi.fn() };
|
|
58
|
+
}),
|
|
59
|
+
write: vi.fn(),
|
|
60
|
+
resize: vi.fn(),
|
|
61
|
+
kill: vi.fn(),
|
|
62
|
+
process: 'test',
|
|
63
|
+
pid: 12345 + mockPtyInstances.size,
|
|
64
|
+
};
|
|
65
|
+
mockPtyInstances.set(path, mockPty);
|
|
66
|
+
return mockPty;
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
vi.clearAllTimers();
|
|
71
|
+
vi.useRealTimers();
|
|
72
|
+
vi.clearAllMocks();
|
|
73
|
+
});
|
|
74
|
+
it('should not change state immediately when detected state changes', async () => {
|
|
75
|
+
const session = await sessionManager.createSessionWithPreset('/test/path');
|
|
76
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
77
|
+
// Initial state should be busy
|
|
78
|
+
expect(session.state).toBe('busy');
|
|
79
|
+
// Simulate output that would trigger idle state
|
|
80
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
81
|
+
// Advance time less than persistence duration
|
|
82
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
|
|
83
|
+
// State should still be busy, but pending state should be set
|
|
84
|
+
expect(session.state).toBe('busy');
|
|
85
|
+
expect(session.pendingState).toBe('idle');
|
|
86
|
+
expect(session.pendingStateStart).toBeDefined();
|
|
87
|
+
});
|
|
88
|
+
it('should change state after persistence duration is met', async () => {
|
|
89
|
+
const session = await sessionManager.createSessionWithPreset('/test/path');
|
|
90
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
91
|
+
const stateChangeHandler = vi.fn();
|
|
92
|
+
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
93
|
+
// Initial state should be busy
|
|
94
|
+
expect(session.state).toBe('busy');
|
|
95
|
+
// Simulate output that would trigger idle state
|
|
96
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
97
|
+
// Advance time less than persistence duration
|
|
98
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
|
|
99
|
+
expect(session.state).toBe('busy');
|
|
100
|
+
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
101
|
+
// Advance time to exceed persistence duration
|
|
102
|
+
vi.advanceTimersByTime(STATE_PERSISTENCE_DURATION_MS);
|
|
103
|
+
// State should now be changed
|
|
104
|
+
expect(session.state).toBe('idle');
|
|
105
|
+
expect(session.pendingState).toBeUndefined();
|
|
106
|
+
expect(session.pendingStateStart).toBeUndefined();
|
|
107
|
+
expect(stateChangeHandler).toHaveBeenCalledWith(session);
|
|
108
|
+
});
|
|
109
|
+
it('should cancel pending state if detected state changes again before persistence', async () => {
|
|
110
|
+
const session = await sessionManager.createSessionWithPreset('/test/path');
|
|
111
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
112
|
+
// Initial state should be busy
|
|
113
|
+
expect(session.state).toBe('busy');
|
|
114
|
+
// Simulate output that would trigger idle state
|
|
115
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
116
|
+
// Advance time less than persistence duration
|
|
117
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
|
|
118
|
+
expect(session.pendingState).toBe('idle');
|
|
119
|
+
// Simulate output that would trigger waiting_input state
|
|
120
|
+
eventEmitter.emit('data', '│ Do you want to continue?');
|
|
121
|
+
// Advance time to trigger another check
|
|
122
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS);
|
|
123
|
+
// Pending state should now be waiting_input, not idle
|
|
124
|
+
expect(session.state).toBe('busy'); // Still original state
|
|
125
|
+
expect(session.pendingState).toBe('waiting_input');
|
|
126
|
+
});
|
|
127
|
+
it('should clear pending state if detected state returns to current state', async () => {
|
|
128
|
+
const session = await sessionManager.createSessionWithPreset('/test/path');
|
|
129
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
130
|
+
// Initial state should be busy
|
|
131
|
+
expect(session.state).toBe('busy');
|
|
132
|
+
// Simulate output that would trigger idle state
|
|
133
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
134
|
+
// Advance time less than persistence duration
|
|
135
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
|
|
136
|
+
expect(session.pendingState).toBe('idle');
|
|
137
|
+
expect(session.pendingStateStart).toBeDefined();
|
|
138
|
+
// Simulate output that would trigger busy state again (back to original)
|
|
139
|
+
eventEmitter.emit('data', 'ESC to interrupt');
|
|
140
|
+
// Advance time to trigger another check
|
|
141
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS);
|
|
142
|
+
// Pending state should be cleared
|
|
143
|
+
expect(session.state).toBe('busy');
|
|
144
|
+
expect(session.pendingState).toBeUndefined();
|
|
145
|
+
expect(session.pendingStateStart).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
it('should not confirm state changes that do not persist long enough', async () => {
|
|
148
|
+
const session = await sessionManager.createSessionWithPreset('/test/path');
|
|
149
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
150
|
+
const stateChangeHandler = vi.fn();
|
|
151
|
+
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
152
|
+
// Initial state should be busy
|
|
153
|
+
expect(session.state).toBe('busy');
|
|
154
|
+
// Try to change to idle
|
|
155
|
+
eventEmitter.emit('data', 'Some idle output\n');
|
|
156
|
+
// Wait for detection but not full persistence (less than 200ms)
|
|
157
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS); // 100ms
|
|
158
|
+
// Should have pending state but not confirmed
|
|
159
|
+
expect(session.state).toBe('busy');
|
|
160
|
+
expect(session.pendingState).toBe('idle');
|
|
161
|
+
// Now change to a different state before idle persists
|
|
162
|
+
// Clear terminal first and add waiting prompt
|
|
163
|
+
eventEmitter.emit('data', '\x1b[2J\x1b[H│ Do you want to continue?\n');
|
|
164
|
+
// Advance time to detect new state but still less than persistence duration from first change
|
|
165
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS); // Another 100ms, total 200ms exactly at threshold
|
|
166
|
+
// Pending state should have changed to waiting_input
|
|
167
|
+
expect(session.state).toBe('busy'); // Still original state
|
|
168
|
+
expect(session.pendingState).toBe('waiting_input');
|
|
169
|
+
// Since states kept changing before persisting, no state change should have been confirmed
|
|
170
|
+
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
it('should properly clean up pending state when session is destroyed', async () => {
|
|
173
|
+
const session = await sessionManager.createSessionWithPreset('/test/path');
|
|
174
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
175
|
+
// Simulate output that would trigger idle state
|
|
176
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
177
|
+
// Advance time less than persistence duration
|
|
178
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
|
|
179
|
+
expect(session.pendingState).toBe('idle');
|
|
180
|
+
expect(session.pendingStateStart).toBeDefined();
|
|
181
|
+
// Destroy the session
|
|
182
|
+
sessionManager.destroySession('/test/path');
|
|
183
|
+
// Check that pending state is cleared
|
|
184
|
+
const destroyedSession = sessionManager.getSession('/test/path');
|
|
185
|
+
expect(destroyedSession).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
it('should handle multiple sessions with independent state persistence', async () => {
|
|
188
|
+
const session1 = await sessionManager.createSessionWithPreset('/test/path1');
|
|
189
|
+
const session2 = await sessionManager.createSessionWithPreset('/test/path2');
|
|
190
|
+
const eventEmitter1 = eventEmitters.get('/test/path1');
|
|
191
|
+
const eventEmitter2 = eventEmitters.get('/test/path2');
|
|
192
|
+
// Both should start as busy
|
|
193
|
+
expect(session1.state).toBe('busy');
|
|
194
|
+
expect(session2.state).toBe('busy');
|
|
195
|
+
// Simulate different outputs for each session
|
|
196
|
+
// Session 1 goes to idle
|
|
197
|
+
eventEmitter1.emit('data', 'Idle output for session 1');
|
|
198
|
+
// Session 2 goes to waiting_input
|
|
199
|
+
eventEmitter2.emit('data', '│ Do you want to continue?');
|
|
200
|
+
// Advance time to check but not confirm
|
|
201
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
|
|
202
|
+
// Both should have pending states but not changed yet
|
|
203
|
+
expect(session1.state).toBe('busy');
|
|
204
|
+
expect(session1.pendingState).toBe('idle');
|
|
205
|
+
expect(session2.state).toBe('busy');
|
|
206
|
+
expect(session2.pendingState).toBe('waiting_input');
|
|
207
|
+
// Advance time to confirm both
|
|
208
|
+
vi.advanceTimersByTime(STATE_PERSISTENCE_DURATION_MS);
|
|
209
|
+
// Both should now be in their new states
|
|
210
|
+
expect(session1.state).toBe('idle');
|
|
211
|
+
expect(session1.pendingState).toBeUndefined();
|
|
212
|
+
expect(session2.state).toBe('waiting_input');
|
|
213
|
+
expect(session2.pendingState).toBeUndefined();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -10,10 +10,10 @@ export declare class WorktreeService {
|
|
|
10
10
|
getGitRootPath(): string;
|
|
11
11
|
getDefaultBranch(): string;
|
|
12
12
|
getAllBranches(): string[];
|
|
13
|
-
createWorktree(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): {
|
|
13
|
+
createWorktree(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Promise<{
|
|
14
14
|
success: boolean;
|
|
15
15
|
error?: string;
|
|
16
|
-
}
|
|
16
|
+
}>;
|
|
17
17
|
deleteWorktree(worktreePath: string, options?: {
|
|
18
18
|
deleteBranch?: boolean;
|
|
19
19
|
}): {
|
|
@@ -3,6 +3,8 @@ import { existsSync, statSync, cpSync } from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { setWorktreeParentBranch } from '../utils/worktreeConfig.js';
|
|
5
5
|
import { getClaudeProjectsDir, pathToClaudeProjectName, } from '../utils/claudeDir.js';
|
|
6
|
+
import { executeWorktreePostCreationHook } from '../utils/hookExecutor.js';
|
|
7
|
+
import { configurationManager } from './configurationManager.js';
|
|
6
8
|
const CLAUDE_DIR = '.claude';
|
|
7
9
|
export class WorktreeService {
|
|
8
10
|
constructor(rootPath) {
|
|
@@ -188,7 +190,7 @@ export class WorktreeService {
|
|
|
188
190
|
return [];
|
|
189
191
|
}
|
|
190
192
|
}
|
|
191
|
-
createWorktree(worktreePath, branch, baseBranch, copySessionData = false, copyClaudeDirectory = false) {
|
|
193
|
+
async createWorktree(worktreePath, branch, baseBranch, copySessionData = false, copyClaudeDirectory = false) {
|
|
192
194
|
try {
|
|
193
195
|
// Resolve the worktree path relative to the git repository root
|
|
194
196
|
const resolvedPath = path.isAbsolute(worktreePath)
|
|
@@ -239,6 +241,21 @@ export class WorktreeService {
|
|
|
239
241
|
console.error('Warning: Failed to copy .claude directory:', error);
|
|
240
242
|
}
|
|
241
243
|
}
|
|
244
|
+
// Execute post-creation hook if configured
|
|
245
|
+
const worktreeHooks = configurationManager.getWorktreeHooks();
|
|
246
|
+
if (worktreeHooks.post_creation?.enabled &&
|
|
247
|
+
worktreeHooks.post_creation?.command) {
|
|
248
|
+
// Create a worktree object for the hook
|
|
249
|
+
const newWorktree = {
|
|
250
|
+
path: resolvedPath,
|
|
251
|
+
branch: branch,
|
|
252
|
+
isMainWorktree: false,
|
|
253
|
+
hasSession: false,
|
|
254
|
+
};
|
|
255
|
+
// Execute the hook synchronously (blocking)
|
|
256
|
+
// Wait for the hook to complete before returning
|
|
257
|
+
await executeWorktreePostCreationHook(worktreeHooks.post_creation.command, newWorktree, this.gitRootPath, baseBranch);
|
|
258
|
+
}
|
|
242
259
|
return { success: true };
|
|
243
260
|
}
|
|
244
261
|
catch (error) {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
2
|
import { WorktreeService } from './worktreeService.js';
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
4
|
import { existsSync, statSync } from 'fs';
|
|
5
|
+
import { configurationManager } from './configurationManager.js';
|
|
6
|
+
import { executeWorktreePostCreationHook } from '../utils/hookExecutor.js';
|
|
5
7
|
// Mock child_process module
|
|
6
8
|
vi.mock('child_process');
|
|
7
9
|
// Mock fs module
|
|
@@ -14,10 +16,22 @@ vi.mock('./worktreeConfigManager.js', () => ({
|
|
|
14
16
|
reset: vi.fn(),
|
|
15
17
|
},
|
|
16
18
|
}));
|
|
19
|
+
// Mock configurationManager
|
|
20
|
+
vi.mock('./configurationManager.js', () => ({
|
|
21
|
+
configurationManager: {
|
|
22
|
+
getWorktreeHooks: vi.fn(),
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
// Mock HookExecutor
|
|
26
|
+
vi.mock('../utils/hookExecutor.js', () => ({
|
|
27
|
+
executeWorktreePostCreationHook: vi.fn(),
|
|
28
|
+
}));
|
|
17
29
|
// Get the mocked function with proper typing
|
|
18
30
|
const mockedExecSync = vi.mocked(execSync);
|
|
19
31
|
const mockedExistsSync = vi.mocked(existsSync);
|
|
20
32
|
const mockedStatSync = vi.mocked(statSync);
|
|
33
|
+
const mockedGetWorktreeHooks = vi.mocked(configurationManager.getWorktreeHooks);
|
|
34
|
+
const mockedExecuteHook = vi.mocked(executeWorktreePostCreationHook);
|
|
21
35
|
describe('WorktreeService', () => {
|
|
22
36
|
let service;
|
|
23
37
|
beforeEach(() => {
|
|
@@ -29,6 +43,8 @@ describe('WorktreeService', () => {
|
|
|
29
43
|
}
|
|
30
44
|
throw new Error('Command not mocked: ' + cmd);
|
|
31
45
|
});
|
|
46
|
+
// Default mock for getWorktreeHooks to return empty config
|
|
47
|
+
mockedGetWorktreeHooks.mockReturnValue({});
|
|
32
48
|
service = new WorktreeService('/fake/path');
|
|
33
49
|
});
|
|
34
50
|
describe('getGitRootPath', () => {
|
|
@@ -177,7 +193,7 @@ origin/feature/test
|
|
|
177
193
|
});
|
|
178
194
|
});
|
|
179
195
|
describe('createWorktree', () => {
|
|
180
|
-
it('should create worktree with base branch when branch does not exist', () => {
|
|
196
|
+
it('should create worktree with base branch when branch does not exist', async () => {
|
|
181
197
|
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
182
198
|
if (typeof cmd === 'string') {
|
|
183
199
|
if (cmd === 'git rev-parse --git-common-dir') {
|
|
@@ -190,11 +206,11 @@ origin/feature/test
|
|
|
190
206
|
}
|
|
191
207
|
throw new Error('Unexpected command');
|
|
192
208
|
});
|
|
193
|
-
const result = service.createWorktree('/path/to/worktree', 'new-feature', 'develop');
|
|
209
|
+
const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'develop');
|
|
194
210
|
expect(result).toEqual({ success: true });
|
|
195
211
|
expect(execSync).toHaveBeenCalledWith('git worktree add -b "new-feature" "/path/to/worktree" "develop"', expect.any(Object));
|
|
196
212
|
});
|
|
197
|
-
it('should create worktree without base branch when branch exists', () => {
|
|
213
|
+
it('should create worktree without base branch when branch exists', async () => {
|
|
198
214
|
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
199
215
|
if (typeof cmd === 'string') {
|
|
200
216
|
if (cmd === 'git rev-parse --git-common-dir') {
|
|
@@ -207,11 +223,11 @@ origin/feature/test
|
|
|
207
223
|
}
|
|
208
224
|
throw new Error('Unexpected command');
|
|
209
225
|
});
|
|
210
|
-
const result = service.createWorktree('/path/to/worktree', 'existing-feature', 'main');
|
|
226
|
+
const result = await service.createWorktree('/path/to/worktree', 'existing-feature', 'main');
|
|
211
227
|
expect(result).toEqual({ success: true });
|
|
212
228
|
expect(execSync).toHaveBeenCalledWith('git worktree add "/path/to/worktree" "existing-feature"', expect.any(Object));
|
|
213
229
|
});
|
|
214
|
-
it('should create worktree from specified base branch when branch does not exist', () => {
|
|
230
|
+
it('should create worktree from specified base branch when branch does not exist', async () => {
|
|
215
231
|
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
216
232
|
if (typeof cmd === 'string') {
|
|
217
233
|
if (cmd === 'git rev-parse --git-common-dir') {
|
|
@@ -224,7 +240,7 @@ origin/feature/test
|
|
|
224
240
|
}
|
|
225
241
|
throw new Error('Unexpected command');
|
|
226
242
|
});
|
|
227
|
-
const result = service.createWorktree('/path/to/worktree', 'new-feature', 'main');
|
|
243
|
+
const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'main');
|
|
228
244
|
expect(result).toEqual({ success: true });
|
|
229
245
|
expect(execSync).toHaveBeenCalledWith('git worktree add -b "new-feature" "/path/to/worktree" "main"', expect.any(Object));
|
|
230
246
|
});
|
|
@@ -389,4 +405,143 @@ branch refs/heads/other-branch
|
|
|
389
405
|
expect(existsSync).toHaveBeenCalledWith('/fake/path/.claude');
|
|
390
406
|
});
|
|
391
407
|
});
|
|
408
|
+
describe('Worktree Hook Execution', () => {
|
|
409
|
+
afterEach(() => {
|
|
410
|
+
vi.clearAllMocks();
|
|
411
|
+
});
|
|
412
|
+
it('should execute post-creation hook when worktree is created', async () => {
|
|
413
|
+
// Arrange
|
|
414
|
+
const hookCommand = 'echo "Worktree created: $CCMANAGER_WORKTREE_PATH"';
|
|
415
|
+
mockedGetWorktreeHooks.mockReturnValue({
|
|
416
|
+
post_creation: {
|
|
417
|
+
command: hookCommand,
|
|
418
|
+
enabled: true,
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
mockedExecuteHook.mockResolvedValue(undefined);
|
|
422
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
423
|
+
if (typeof cmd === 'string') {
|
|
424
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
425
|
+
return '/fake/path/.git\n';
|
|
426
|
+
}
|
|
427
|
+
if (cmd.includes('git worktree list')) {
|
|
428
|
+
return 'worktree /fake/path\nHEAD abc123\nbranch refs/heads/main\n';
|
|
429
|
+
}
|
|
430
|
+
if (cmd.includes('git worktree add')) {
|
|
431
|
+
return '';
|
|
432
|
+
}
|
|
433
|
+
if (cmd.includes('git rev-parse --verify')) {
|
|
434
|
+
throw new Error('Branch not found');
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return '';
|
|
438
|
+
});
|
|
439
|
+
// Act
|
|
440
|
+
const result = await service.createWorktree('feature-branch-dir', 'feature-branch', 'main', false, false);
|
|
441
|
+
// Assert
|
|
442
|
+
expect(result.success).toBe(true);
|
|
443
|
+
expect(mockedGetWorktreeHooks).toHaveBeenCalled();
|
|
444
|
+
expect(mockedExecuteHook).toHaveBeenCalledWith(hookCommand, expect.objectContaining({
|
|
445
|
+
path: '/fake/path/feature-branch-dir',
|
|
446
|
+
branch: 'feature-branch',
|
|
447
|
+
isMainWorktree: false,
|
|
448
|
+
hasSession: false,
|
|
449
|
+
}), '/fake/path', 'main');
|
|
450
|
+
});
|
|
451
|
+
it('should not execute hook when disabled', async () => {
|
|
452
|
+
// Arrange
|
|
453
|
+
mockedGetWorktreeHooks.mockReturnValue({
|
|
454
|
+
post_creation: {
|
|
455
|
+
command: 'echo "Should not run"',
|
|
456
|
+
enabled: false,
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
460
|
+
if (typeof cmd === 'string') {
|
|
461
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
462
|
+
return '/fake/path/.git\n';
|
|
463
|
+
}
|
|
464
|
+
if (cmd.includes('git worktree list')) {
|
|
465
|
+
return 'worktree /fake/path\nHEAD abc123\nbranch refs/heads/main\n';
|
|
466
|
+
}
|
|
467
|
+
if (cmd.includes('git worktree add')) {
|
|
468
|
+
return '';
|
|
469
|
+
}
|
|
470
|
+
if (cmd.includes('git rev-parse --verify')) {
|
|
471
|
+
throw new Error('Branch not found');
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return '';
|
|
475
|
+
});
|
|
476
|
+
// Act
|
|
477
|
+
const result = await service.createWorktree('feature-branch-dir', 'feature-branch', 'main', false, false);
|
|
478
|
+
// Assert
|
|
479
|
+
expect(result.success).toBe(true);
|
|
480
|
+
expect(mockedGetWorktreeHooks).toHaveBeenCalled();
|
|
481
|
+
expect(mockedExecuteHook).not.toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
it('should not execute hook when not configured', async () => {
|
|
484
|
+
// Arrange
|
|
485
|
+
mockedGetWorktreeHooks.mockReturnValue({});
|
|
486
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
487
|
+
if (typeof cmd === 'string') {
|
|
488
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
489
|
+
return '/fake/path/.git\n';
|
|
490
|
+
}
|
|
491
|
+
if (cmd.includes('git worktree list')) {
|
|
492
|
+
return 'worktree /fake/path\nHEAD abc123\nbranch refs/heads/main\n';
|
|
493
|
+
}
|
|
494
|
+
if (cmd.includes('git worktree add')) {
|
|
495
|
+
return '';
|
|
496
|
+
}
|
|
497
|
+
if (cmd.includes('git rev-parse --verify')) {
|
|
498
|
+
throw new Error('Branch not found');
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return '';
|
|
502
|
+
});
|
|
503
|
+
// Act
|
|
504
|
+
const result = await service.createWorktree('feature-branch-dir', 'feature-branch', 'main', false, false);
|
|
505
|
+
// Assert
|
|
506
|
+
expect(result.success).toBe(true);
|
|
507
|
+
expect(mockedGetWorktreeHooks).toHaveBeenCalled();
|
|
508
|
+
expect(mockedExecuteHook).not.toHaveBeenCalled();
|
|
509
|
+
});
|
|
510
|
+
it('should not fail worktree creation if hook execution fails', async () => {
|
|
511
|
+
// Arrange
|
|
512
|
+
mockedGetWorktreeHooks.mockReturnValue({
|
|
513
|
+
post_creation: {
|
|
514
|
+
command: 'failing-command',
|
|
515
|
+
enabled: true,
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
// The real executeWorktreePostCreationHook doesn't throw, it catches errors internally
|
|
519
|
+
// So the mock should resolve, not reject
|
|
520
|
+
mockedExecuteHook.mockResolvedValue(undefined);
|
|
521
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
522
|
+
if (typeof cmd === 'string') {
|
|
523
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
524
|
+
return '/fake/path/.git\n';
|
|
525
|
+
}
|
|
526
|
+
if (cmd.includes('git worktree list')) {
|
|
527
|
+
return 'worktree /fake/path\nHEAD abc123\nbranch refs/heads/main\n';
|
|
528
|
+
}
|
|
529
|
+
if (cmd.includes('git worktree add')) {
|
|
530
|
+
return '';
|
|
531
|
+
}
|
|
532
|
+
if (cmd.includes('git rev-parse --verify')) {
|
|
533
|
+
throw new Error('Branch not found');
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return '';
|
|
537
|
+
});
|
|
538
|
+
// Act
|
|
539
|
+
const result = await service.createWorktree('feature-branch-dir', 'feature-branch', 'main', false, false);
|
|
540
|
+
// Allow async operations to complete
|
|
541
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
542
|
+
// Assert
|
|
543
|
+
expect(result.success).toBe(true);
|
|
544
|
+
expect(mockedExecuteHook).toHaveBeenCalled();
|
|
545
|
+
});
|
|
546
|
+
});
|
|
392
547
|
});
|