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.
Files changed (31) hide show
  1. package/README.md +18 -0
  2. package/dist/components/App.js +1 -1
  3. package/dist/components/Configuration.js +21 -7
  4. package/dist/components/ConfigureStatusHooks.d.ts +6 -0
  5. package/dist/components/{ConfigureHooks.js → ConfigureStatusHooks.js} +16 -18
  6. package/dist/components/ConfigureStatusHooks.test.d.ts +1 -0
  7. package/dist/components/ConfigureStatusHooks.test.js +62 -0
  8. package/dist/components/ConfigureWorktreeHooks.d.ts +6 -0
  9. package/dist/components/ConfigureWorktreeHooks.js +114 -0
  10. package/dist/components/ConfigureWorktreeHooks.test.d.ts +1 -0
  11. package/dist/components/ConfigureWorktreeHooks.test.js +60 -0
  12. package/dist/constants/statePersistence.d.ts +2 -0
  13. package/dist/constants/statePersistence.js +4 -0
  14. package/dist/services/configurationManager.d.ts +3 -1
  15. package/dist/services/configurationManager.js +10 -0
  16. package/dist/services/projectManager.test.js +8 -9
  17. package/dist/services/sessionManager.d.ts +0 -1
  18. package/dist/services/sessionManager.js +40 -39
  19. package/dist/services/sessionManager.statePersistence.test.d.ts +1 -0
  20. package/dist/services/sessionManager.statePersistence.test.js +215 -0
  21. package/dist/services/worktreeService.d.ts +2 -2
  22. package/dist/services/worktreeService.js +18 -1
  23. package/dist/services/worktreeService.test.js +162 -7
  24. package/dist/types/index.d.ts +17 -7
  25. package/dist/utils/hookExecutor.d.ts +20 -0
  26. package/dist/utils/hookExecutor.js +96 -0
  27. package/dist/utils/hookExecutor.test.d.ts +1 -0
  28. package/dist/utils/hookExecutor.test.js +405 -0
  29. package/dist/utils/worktreeUtils.test.js +7 -0
  30. package/package.json +1 -1
  31. 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 { WorktreeService } from './worktreeService.js';
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 newState = this.detectTerminalState(session);
195
- if (newState !== oldState) {
196
- session.state = newState;
197
- this.executeStatusHook(oldState, newState, session);
198
- this.emit('sessionStateChanged', session);
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
- }, 100); // Check every 100ms
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,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
  });