ccmanager 2.0.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.
@@ -4,7 +4,7 @@ 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
9
  const { Terminal } = pkg;
10
10
  const execAsync = promisify(exec);
@@ -188,13 +188,13 @@ export class SessionManager extends EventEmitter {
188
188
  setupBackgroundHandler(session) {
189
189
  // Setup data handler
190
190
  this.setupDataHandler(session);
191
- // Set up interval-based state detection
192
191
  session.stateCheckInterval = setInterval(() => {
193
192
  const oldState = session.state;
194
193
  const newState = this.detectTerminalState(session);
195
194
  if (newState !== oldState) {
196
195
  session.state = newState;
197
- this.executeStatusHook(oldState, newState, session);
196
+ // Execute status hook asynchronously (non-blocking)
197
+ void executeStatusHook(oldState, newState, session);
198
198
  this.emit('sessionStateChanged', session);
199
199
  }
200
200
  }, 100); // Check every 100ms
@@ -252,36 +252,6 @@ export class SessionManager extends EventEmitter {
252
252
  getAllSessions() {
253
253
  return Array.from(this.sessions.values());
254
254
  }
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
255
  async createSessionWithDevcontainer(worktreePath, devcontainerConfig, presetId) {
286
256
  // Check if session already exists
287
257
  const existing = this.sessions.get(worktreePath);
@@ -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
  });
@@ -54,6 +54,13 @@ export interface StatusHookConfig {
54
54
  busy?: StatusHook;
55
55
  waiting_input?: StatusHook;
56
56
  }
57
+ export interface WorktreeHook {
58
+ command: string;
59
+ enabled: boolean;
60
+ }
61
+ export interface WorktreeHookConfig {
62
+ post_creation?: WorktreeHook;
63
+ }
57
64
  export interface WorktreeConfig {
58
65
  autoDirectory: boolean;
59
66
  autoDirectoryPattern?: string;
@@ -84,6 +91,7 @@ export interface DevcontainerConfig {
84
91
  export interface ConfigurationData {
85
92
  shortcuts?: ShortcutConfig;
86
93
  statusHooks?: StatusHookConfig;
94
+ worktreeHooks?: WorktreeHookConfig;
87
95
  worktree?: WorktreeConfig;
88
96
  command?: CommandConfig;
89
97
  commandPresets?: CommandPresetsConfig;
@@ -126,10 +134,10 @@ export interface IProjectManager {
126
134
  export interface IWorktreeService {
127
135
  getWorktrees(): Worktree[];
128
136
  getGitRootPath(): string;
129
- createWorktree(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): {
137
+ createWorktree(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Promise<{
130
138
  success: boolean;
131
139
  error?: string;
132
- };
140
+ }>;
133
141
  deleteWorktree(worktreePath: string, options?: {
134
142
  deleteBranch?: boolean;
135
143
  }): {
@@ -0,0 +1,20 @@
1
+ import { Worktree, Session, SessionState } from '../types/index.js';
2
+ export interface HookEnvironment {
3
+ CCMANAGER_WORKTREE_PATH: string;
4
+ CCMANAGER_WORKTREE_BRANCH: string;
5
+ CCMANAGER_GIT_ROOT: string;
6
+ CCMANAGER_BASE_BRANCH?: string;
7
+ [key: string]: string | undefined;
8
+ }
9
+ /**
10
+ * Execute a hook command with the provided environment variables
11
+ */
12
+ export declare function executeHook(command: string, cwd: string, environment: HookEnvironment): Promise<void>;
13
+ /**
14
+ * Execute a worktree post-creation hook
15
+ */
16
+ export declare function executeWorktreePostCreationHook(command: string, worktree: Worktree, gitRoot: string, baseBranch?: string): Promise<void>;
17
+ /**
18
+ * Execute a session status change hook
19
+ */
20
+ export declare function executeStatusHook(oldState: SessionState, newState: SessionState, session: Session): Promise<void>;
@@ -0,0 +1,96 @@
1
+ import { spawn } from 'child_process';
2
+ import { WorktreeService } from '../services/worktreeService.js';
3
+ import { configurationManager } from '../services/configurationManager.js';
4
+ /**
5
+ * Execute a hook command with the provided environment variables
6
+ */
7
+ export function executeHook(command, cwd, environment) {
8
+ return new Promise((resolve, reject) => {
9
+ // Use spawn with shell to execute the command and wait for all child processes
10
+ const child = spawn(command, [], {
11
+ cwd,
12
+ env: {
13
+ ...process.env,
14
+ ...environment,
15
+ },
16
+ shell: true,
17
+ stdio: ['ignore', 'pipe', 'pipe'],
18
+ });
19
+ let stderr = '';
20
+ // Collect stderr for logging
21
+ child.stderr?.on('data', data => {
22
+ stderr += data.toString();
23
+ });
24
+ // Wait for the process and all its children to exit
25
+ child.on('exit', (code, signal) => {
26
+ if (code !== 0 || signal) {
27
+ let errorMessage = signal
28
+ ? `Hook terminated by signal ${signal}`
29
+ : `Hook exited with code ${code}`;
30
+ // Include stderr in the error message when exit code is not 0
31
+ if (stderr) {
32
+ errorMessage += `\nStderr: ${stderr}`;
33
+ }
34
+ reject(new Error(errorMessage));
35
+ return;
36
+ }
37
+ // When exit code is 0, ignore stderr and resolve successfully
38
+ resolve();
39
+ });
40
+ // Handle errors in spawning the process
41
+ child.on('error', error => {
42
+ reject(error);
43
+ });
44
+ });
45
+ }
46
+ /**
47
+ * Execute a worktree post-creation hook
48
+ */
49
+ export async function executeWorktreePostCreationHook(command, worktree, gitRoot, baseBranch) {
50
+ const environment = {
51
+ CCMANAGER_WORKTREE_PATH: worktree.path,
52
+ CCMANAGER_WORKTREE_BRANCH: worktree.branch || 'unknown',
53
+ CCMANAGER_GIT_ROOT: gitRoot,
54
+ };
55
+ if (baseBranch) {
56
+ environment.CCMANAGER_BASE_BRANCH = baseBranch;
57
+ }
58
+ try {
59
+ await executeHook(command, worktree.path, environment);
60
+ }
61
+ catch (error) {
62
+ // Log error but don't throw - hooks should not break the main flow
63
+ console.error(`Failed to execute post-creation hook: ${error instanceof Error ? error.message : String(error)}`);
64
+ }
65
+ }
66
+ /**
67
+ * Execute a session status change hook
68
+ */
69
+ export async function executeStatusHook(oldState, newState, session) {
70
+ const statusHooks = configurationManager.getStatusHooks();
71
+ const hook = statusHooks[newState];
72
+ if (hook && hook.enabled && hook.command) {
73
+ // Get branch information
74
+ const worktreeService = new WorktreeService();
75
+ const worktrees = worktreeService.getWorktrees();
76
+ const worktree = worktrees.find(wt => wt.path === session.worktreePath);
77
+ const branch = worktree?.branch || 'unknown';
78
+ // Build environment for status hook
79
+ const environment = {
80
+ CCMANAGER_WORKTREE_PATH: session.worktreePath,
81
+ CCMANAGER_WORKTREE_BRANCH: branch,
82
+ CCMANAGER_GIT_ROOT: session.worktreePath, // For status hooks, we use worktree path as cwd
83
+ CCMANAGER_OLD_STATE: oldState,
84
+ CCMANAGER_NEW_STATE: newState,
85
+ CCMANAGER_SESSION_ID: session.id,
86
+ };
87
+ // Execute the hook command in the session's worktree directory
88
+ try {
89
+ await executeHook(hook.command, session.worktreePath, environment);
90
+ }
91
+ catch (error) {
92
+ // Log error but don't throw - hooks should not break the main flow
93
+ console.error(`Failed to execute ${newState} hook: ${error instanceof Error ? error.message : String(error)}`);
94
+ }
95
+ }
96
+ }
@@ -0,0 +1 @@
1
+ export {};