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.
@@ -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 {};