ccmanager 4.1.10 → 4.1.11

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,6 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useCallback } from 'react';
3
- import { useApp, Box, Text } from 'ink';
3
+ import { useApp, useInput, Box, Text } from 'ink';
4
4
  import { Effect } from 'effect';
5
5
  import Menu from './Menu.js';
6
6
  import Dashboard from './Dashboard.js';
@@ -31,6 +31,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
31
31
  const [worktreeService, setWorktreeService] = useState(() => new WorktreeService());
32
32
  const [activeSession, setActiveSession] = useState(null);
33
33
  const [error, setError] = useState(null);
34
+ const [worktreeHookError, setWorktreeHookError] = useState(null);
34
35
  const [menuKey, setMenuKey] = useState(0); // Force menu refresh
35
36
  const [selectedWorktree, setSelectedWorktree] = useState(null); // Store selected worktree for preset selection
36
37
  const [renameTarget, setRenameTarget] = useState(null);
@@ -44,6 +45,26 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
44
45
  const [loadingContext, setLoadingContext] = useState({});
45
46
  // State for streaming devcontainer up logs
46
47
  const [devcontainerLogs, setDevcontainerLogs] = useState([]);
48
+ const [canReturnFromHookError, setCanReturnFromHookError] = useState(false);
49
+ useEffect(() => {
50
+ if (view !== 'worktree-hook-error') {
51
+ setCanReturnFromHookError(false);
52
+ return;
53
+ }
54
+ const timeout = setTimeout(() => {
55
+ setCanReturnFromHookError(true);
56
+ }, 100);
57
+ return () => {
58
+ clearTimeout(timeout);
59
+ };
60
+ }, [view]);
61
+ useInput(() => {
62
+ if (view !== 'worktree-hook-error' || !canReturnFromHookError) {
63
+ return;
64
+ }
65
+ setWorktreeHookError(null);
66
+ handleReturnToMenu();
67
+ });
47
68
  // Helper function to format error messages based on error type using _tag discrimination
48
69
  const formatErrorMessage = (error) => {
49
70
  switch (error._tag) {
@@ -59,6 +80,10 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
59
80
  return `Validation failed for ${error.field}: ${error.constraint}`;
60
81
  }
61
82
  };
83
+ const formatPostCreationHookWarning = (error) => `Post-creation hook failed: ${error.message}`;
84
+ const formatPreCreationHookError = (error) => error._tag === 'ProcessError'
85
+ ? `Pre-creation hook failed: ${error.message}`
86
+ : formatErrorMessage(error);
62
87
  // Helper function to create session with Effect-based error handling
63
88
  const createSessionWithEffect = useCallback(async (worktreePath, presetId, initialPrompt) => {
64
89
  setDevcontainerLogs([]);
@@ -218,6 +243,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
218
243
  // Helper function to handle worktree creation results
219
244
  const handleWorktreeCreationResult = (result, creationData) => {
220
245
  if (result.success) {
246
+ if (result.warning) {
247
+ setError(null);
248
+ setWorktreeHookError(result.warning);
249
+ setView('worktree-hook-error');
250
+ return;
251
+ }
221
252
  if (creationData.presetId && creationData.initialPrompt) {
222
253
  setPendingMenuSessionLaunch({
223
254
  worktree: {
@@ -381,7 +412,13 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
381
412
  // Transform Effect result to legacy format for handleWorktreeCreationResult
382
413
  if (result._tag === 'Left') {
383
414
  // Handle error using pattern matching on _tag
384
- const errorMessage = formatErrorMessage(result.left);
415
+ const errorMessage = formatPreCreationHookError(result.left);
416
+ if (result.left._tag === 'ProcessError') {
417
+ setError(null);
418
+ setWorktreeHookError(errorMessage);
419
+ setView('worktree-hook-error');
420
+ return;
421
+ }
385
422
  handleWorktreeCreationResult({ success: false, error: errorMessage }, {
386
423
  path: targetPath,
387
424
  branch,
@@ -396,8 +433,13 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
396
433
  }
397
434
  else {
398
435
  // Success case
399
- const createdWorktree = result.right;
400
- handleWorktreeCreationResult({ success: true }, {
436
+ const { worktree: createdWorktree, postCreationHookError } = result.right;
437
+ handleWorktreeCreationResult({
438
+ success: true,
439
+ warning: postCreationHookError
440
+ ? formatPostCreationHookWarning(postCreationHookError)
441
+ : undefined,
442
+ }, {
401
443
  path: createdWorktree.path,
402
444
  branch: createdWorktree.branch || branch,
403
445
  baseBranch: request.baseBranch,
@@ -432,14 +474,26 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
432
474
  creationData.copySessionData, creationData.copyClaudeDirectory)));
433
475
  if (result._tag === 'Left') {
434
476
  // Handle error using pattern matching on _tag
435
- const errorMessage = formatErrorMessage(result.left);
477
+ const errorMessage = formatPreCreationHookError(result.left);
478
+ if (result.left._tag === 'ProcessError') {
479
+ setError(null);
480
+ setWorktreeHookError(errorMessage);
481
+ setView('worktree-hook-error');
482
+ return;
483
+ }
436
484
  setError(errorMessage);
437
485
  setView('new-worktree');
438
486
  }
439
487
  else {
440
- handleWorktreeCreationResult({ success: true }, {
441
- path: creationData.path,
442
- branch: creationData.branch,
488
+ const { worktree: createdWorktree, postCreationHookError } = result.right;
489
+ handleWorktreeCreationResult({
490
+ success: true,
491
+ warning: postCreationHookError
492
+ ? formatPostCreationHookWarning(postCreationHookError)
493
+ : undefined,
494
+ }, {
495
+ path: createdWorktree.path,
496
+ branch: createdWorktree.branch || creationData.branch,
443
497
  baseBranch: selectedRemoteRef,
444
498
  copySessionData: creationData.copySessionData,
445
499
  copyClaudeDirectory: creationData.copyClaudeDirectory,
@@ -555,6 +609,9 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
555
609
  : 'Creating worktree...';
556
610
  return (_jsx(Box, { flexDirection: "column", children: _jsx(LoadingSpinner, { message: message, color: "cyan" }) }));
557
611
  }
612
+ if (view === 'worktree-hook-error') {
613
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "red", children: "Worktree hook error" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "red", children: worktreeHookError }) }), _jsx(Text, { dimColor: true, children: "Press any key to return to the menu" })] }));
614
+ }
558
615
  if (view === 'delete-worktree') {
559
616
  return (_jsxs(Box, { flexDirection: "column", children: [error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })), _jsx(DeleteWorktree, { projectPath: selectedProject?.path, onComplete: handleDeleteWorktrees, onCancel: handleCancelDeleteWorktree })] }));
560
617
  }
@@ -3,6 +3,7 @@ import { render } from 'ink-testing-library';
3
3
  import { beforeAll, beforeEach, afterEach, describe, expect, it, vi, } from 'vitest';
4
4
  import { Effect } from 'effect';
5
5
  import { ENV_VARS } from '../constants/env.js';
6
+ import { ProcessError } from '../types/errors.js';
6
7
  let App;
7
8
  let menuProps;
8
9
  let newWorktreeProps;
@@ -129,10 +130,12 @@ beforeEach(() => {
129
130
  createWorktreeEffectMock.mockReset();
130
131
  deleteWorktreeEffectMock.mockReset();
131
132
  createWorktreeEffectMock.mockImplementation((path, branch) => Effect.succeed({
132
- path,
133
- branch,
134
- isMainWorktree: false,
135
- hasSession: false,
133
+ worktree: {
134
+ path,
135
+ branch,
136
+ isMainWorktree: false,
137
+ hasSession: false,
138
+ },
136
139
  }));
137
140
  deleteWorktreeEffectMock.mockImplementation(() => Effect.succeed(undefined));
138
141
  sessionManagers.length = 0;
@@ -171,10 +174,12 @@ describe('App component loading state machine', () => {
171
174
  createWorktreeEffectMock.mockImplementation(() => Effect.tryPromise({
172
175
  try: () => new Promise(resolve => {
173
176
  resolveWorktree = () => resolve({
174
- path: '/tmp/test',
175
- branch: 'feature',
176
- isMainWorktree: false,
177
- hasSession: false,
177
+ worktree: {
178
+ path: '/tmp/test',
179
+ branch: 'feature',
180
+ isMainWorktree: false,
181
+ hasSession: false,
182
+ },
178
183
  });
179
184
  }),
180
185
  catch: (error) => error,
@@ -228,12 +233,147 @@ describe('App component loading state machine', () => {
228
233
  expect(sessionProps?.session).toEqual(mockSession);
229
234
  unmount();
230
235
  });
236
+ it('shows a hook error screen before returning to menu when post-creation hook fails after manual worktree creation', async () => {
237
+ createWorktreeEffectMock.mockImplementation((path, branch) => Effect.succeed({
238
+ worktree: {
239
+ path,
240
+ branch,
241
+ isMainWorktree: false,
242
+ hasSession: false,
243
+ },
244
+ postCreationHookError: new ProcessError({
245
+ command: 'exit 1',
246
+ exitCode: 1,
247
+ message: 'Hook exited with code 1',
248
+ }),
249
+ }));
250
+ const { lastFrame, stdin, unmount } = render(_jsx(App, { version: "test" }));
251
+ await waitForCondition(() => Boolean(menuProps));
252
+ await Promise.resolve(menuProps.onMenuAction({ type: 'newWorktree' }));
253
+ await waitForCondition(() => Boolean(newWorktreeProps));
254
+ await Promise.resolve(newWorktreeProps.onComplete({
255
+ creationMode: 'manual',
256
+ path: '/tmp/test',
257
+ branch: 'feature',
258
+ baseBranch: 'main',
259
+ copySessionData: false,
260
+ copyClaudeDirectory: false,
261
+ }));
262
+ await waitForCondition(() => lastFrame()?.includes('Worktree hook error') ?? false);
263
+ expect(lastFrame()).toContain('Post-creation hook failed');
264
+ expect(lastFrame()).toContain('Hook exited with code 1');
265
+ expect(lastFrame()).toContain('Press any key to return to the menu');
266
+ await flush(120);
267
+ stdin.write('x');
268
+ await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
269
+ unmount();
270
+ });
271
+ it('shows a hook error screen before returning to menu when pre-creation hook fails', async () => {
272
+ createWorktreeEffectMock.mockImplementation(() => Effect.fail(new ProcessError({
273
+ command: 'exit 1',
274
+ exitCode: 1,
275
+ message: 'Hook exited with code 1',
276
+ })));
277
+ const { lastFrame, stdin, unmount } = render(_jsx(App, { version: "test" }));
278
+ await waitForCondition(() => Boolean(menuProps));
279
+ await Promise.resolve(menuProps.onMenuAction({ type: 'newWorktree' }));
280
+ await waitForCondition(() => Boolean(newWorktreeProps));
281
+ await Promise.resolve(newWorktreeProps.onComplete({
282
+ creationMode: 'manual',
283
+ path: '/tmp/test',
284
+ branch: 'feature',
285
+ baseBranch: 'main',
286
+ copySessionData: false,
287
+ copyClaudeDirectory: false,
288
+ }));
289
+ await waitForCondition(() => lastFrame()?.includes('Worktree hook error') ?? false);
290
+ expect(lastFrame()).toContain('Pre-creation hook failed');
291
+ expect(lastFrame()).toContain('Hook exited with code 1');
292
+ expect(lastFrame()).toContain('Press any key to return to the menu');
293
+ await flush(120);
294
+ stdin.write('x');
295
+ await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
296
+ unmount();
297
+ });
298
+ it('ignores immediate input on the hook error screen so the error remains visible', async () => {
299
+ createWorktreeEffectMock.mockImplementation((path, branch) => Effect.succeed({
300
+ worktree: {
301
+ path,
302
+ branch,
303
+ isMainWorktree: false,
304
+ hasSession: false,
305
+ },
306
+ postCreationHookError: new ProcessError({
307
+ command: 'exit 1',
308
+ exitCode: 1,
309
+ message: 'Hook exited with code 1',
310
+ }),
311
+ }));
312
+ const { lastFrame, stdin, unmount } = render(_jsx(App, { version: "test" }));
313
+ await waitForCondition(() => Boolean(menuProps));
314
+ await Promise.resolve(menuProps.onMenuAction({ type: 'newWorktree' }));
315
+ await waitForCondition(() => Boolean(newWorktreeProps));
316
+ await Promise.resolve(newWorktreeProps.onComplete({
317
+ creationMode: 'manual',
318
+ path: '/tmp/test',
319
+ branch: 'feature',
320
+ baseBranch: 'main',
321
+ copySessionData: false,
322
+ copyClaudeDirectory: false,
323
+ }));
324
+ await waitForCondition(() => lastFrame()?.includes('Worktree hook error') ?? false);
325
+ stdin.write('x');
326
+ await flush(40);
327
+ expect(lastFrame()).toContain('Worktree hook error');
328
+ expect(lastFrame()).toContain('Hook exited with code 1');
329
+ await flush(120);
330
+ stdin.write('x');
331
+ await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
332
+ unmount();
333
+ });
334
+ it('does not auto-start a prompt-first session when post-creation hook fails', async () => {
335
+ createWorktreeEffectMock.mockImplementation((path, branch) => Effect.succeed({
336
+ worktree: {
337
+ path,
338
+ branch,
339
+ isMainWorktree: false,
340
+ hasSession: false,
341
+ },
342
+ postCreationHookError: new ProcessError({
343
+ command: 'exit 1',
344
+ exitCode: 1,
345
+ message: 'Hook exited with code 1',
346
+ }),
347
+ }));
348
+ const { lastFrame, unmount } = render(_jsx(App, { version: "test" }));
349
+ await waitForCondition(() => Boolean(menuProps));
350
+ const sessionManager = sessionManagers[0];
351
+ await Promise.resolve(menuProps.onMenuAction({ type: 'newWorktree' }));
352
+ await waitForCondition(() => Boolean(newWorktreeProps));
353
+ await Promise.resolve(newWorktreeProps.onComplete({
354
+ creationMode: 'prompt',
355
+ path: '/tmp/project',
356
+ projectPath: '/tmp/project',
357
+ autoDirectoryPattern: '../{branch}',
358
+ baseBranch: 'main',
359
+ presetId: 'claude',
360
+ initialPrompt: 'trim worktree name output',
361
+ copySessionData: false,
362
+ copyClaudeDirectory: false,
363
+ }));
364
+ await waitForCondition(() => lastFrame()?.includes('Worktree hook error') ?? false);
365
+ expect(sessionManager.createSessionWithPresetEffect).not.toHaveBeenCalled();
366
+ expect(lastFrame()).toContain('Post-creation hook failed');
367
+ unmount();
368
+ });
231
369
  it('uses the created worktree path when auto-starting a prompt-first session', async () => {
232
370
  createWorktreeEffectMock.mockImplementation((_path, branch) => Effect.succeed({
233
- path: '/tmp/resolved-worktree',
234
- branch,
235
- isMainWorktree: false,
236
- hasSession: false,
371
+ worktree: {
372
+ path: '/tmp/resolved-worktree',
373
+ branch,
374
+ isMainWorktree: false,
375
+ hasSession: false,
376
+ },
237
377
  }));
238
378
  const { unmount } = render(_jsx(App, { version: "test" }));
239
379
  await waitForCondition(() => Boolean(menuProps));
@@ -1,5 +1,5 @@
1
1
  import { Effect } from 'effect';
2
- import { Worktree, MergeConfig } from '../types/index.js';
2
+ import { Worktree, CreateWorktreeResult, MergeConfig } from '../types/index.js';
3
3
  import { GitError, FileSystemError, ProcessError } from '../types/errors.js';
4
4
  /**
5
5
  * WorktreeService - Git worktree management with Effect-based error handling
@@ -323,7 +323,7 @@ export declare class WorktreeService {
323
323
  * @throws {GitError} When git worktree add command fails
324
324
  * @throws {FileSystemError} When session data copy fails
325
325
  */
326
- createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Effect.Effect<Worktree, GitError | FileSystemError | ProcessError, never>;
326
+ createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Effect.Effect<CreateWorktreeResult, GitError | FileSystemError | ProcessError, never>;
327
327
  /**
328
328
  * Effect-based deleteWorktree operation
329
329
  * May fail with GitError
@@ -8,6 +8,7 @@ import { setWorktreeParentBranch } from '../utils/worktreeConfig.js';
8
8
  import { getClaudeProjectsDir, pathToClaudeProjectName, } from '../utils/claudeDir.js';
9
9
  import { executeWorktreePostCreationHook, executeWorktreePreCreationHook, } from '../utils/hookExecutor.js';
10
10
  import { configReader } from './config/configReader.js';
11
+ import { logger } from '../utils/logger.js';
11
12
  const CLAUDE_DIR = '.claude';
12
13
  /**
13
14
  * WorktreeService - Git worktree management with Effect-based error handling
@@ -764,6 +765,16 @@ export class WorktreeService {
764
765
  const resolvedPath = path.isAbsolute(worktreePath)
765
766
  ? worktreePath
766
767
  : path.join(absoluteGitRoot, worktreePath);
768
+ logger.info('Worktree creation requested', {
769
+ inputPath: worktreePath,
770
+ resolvedPath,
771
+ branch,
772
+ baseBranch,
773
+ rootPath: self.rootPath,
774
+ absoluteGitRoot,
775
+ copySessionData,
776
+ copyClaudeDirectory,
777
+ });
767
778
  // Check if branch exists
768
779
  const branchExists = yield* Effect.catchAll(Effect.try({
769
780
  try: () => {
@@ -777,6 +788,12 @@ export class WorktreeService {
777
788
  }), () => Effect.succeed(false));
778
789
  // Execute pre-creation hook if configured (BEFORE git worktree add)
779
790
  const worktreeHooksConfig = configReader.getWorktreeHooks();
791
+ logger.info('Worktree hook config before creation', {
792
+ preCreationEnabled: Boolean(worktreeHooksConfig.pre_creation?.enabled),
793
+ preCreationCommand: worktreeHooksConfig.pre_creation?.command,
794
+ postCreationEnabled: Boolean(worktreeHooksConfig.post_creation?.enabled),
795
+ postCreationCommand: worktreeHooksConfig.post_creation?.command,
796
+ });
780
797
  if (worktreeHooksConfig.pre_creation?.enabled &&
781
798
  worktreeHooksConfig.pre_creation?.command) {
782
799
  yield* executeWorktreePreCreationHook(worktreeHooksConfig.pre_creation.command, resolvedPath, branch, absoluteGitRoot, baseBranch);
@@ -794,10 +811,18 @@ export class WorktreeService {
794
811
  // Execute the worktree creation command
795
812
  yield* Effect.try({
796
813
  try: () => {
814
+ logger.info('Executing git worktree add command', {
815
+ command,
816
+ cwd: absoluteGitRoot,
817
+ });
797
818
  execSync(command, {
798
819
  cwd: absoluteGitRoot,
799
820
  encoding: 'utf8',
800
821
  });
822
+ logger.info('Git worktree add command succeeded', {
823
+ command,
824
+ cwd: absoluteGitRoot,
825
+ });
801
826
  },
802
827
  catch: (error) => {
803
828
  const execError = error;
@@ -835,22 +860,31 @@ export class WorktreeService {
835
860
  }
836
861
  // Execute post-creation hook if configured
837
862
  const worktreeHooks = configReader.getWorktreeHooks();
838
- if (worktreeHooks.post_creation?.enabled &&
839
- worktreeHooks.post_creation?.command) {
840
- const newWorktree = {
841
- path: resolvedPath,
842
- branch: branch,
843
- isMainWorktree: false,
844
- hasSession: false,
845
- };
846
- yield* executeWorktreePostCreationHook(worktreeHooks.post_creation.command, newWorktree, absoluteGitRoot, baseBranch);
847
- }
848
- return {
863
+ logger.info('Worktree hook config after creation', {
864
+ postCreationEnabled: Boolean(worktreeHooks.post_creation?.enabled),
865
+ postCreationCommand: worktreeHooks.post_creation?.command,
866
+ resolvedPath,
867
+ branch,
868
+ });
869
+ let postCreationHookError;
870
+ const createdWorktree = {
849
871
  path: resolvedPath,
850
872
  branch,
851
873
  isMainWorktree: false,
852
874
  hasSession: false,
853
875
  };
876
+ if (worktreeHooks.post_creation?.enabled &&
877
+ worktreeHooks.post_creation?.command) {
878
+ const postCreationHookResult = yield* Effect.either(executeWorktreePostCreationHook(worktreeHooks.post_creation.command, createdWorktree, absoluteGitRoot, baseBranch));
879
+ if (postCreationHookResult._tag === 'Left') {
880
+ postCreationHookError = postCreationHookResult.left;
881
+ logger.error(`Failed to execute post-creation hook: ${postCreationHookError.message}`);
882
+ }
883
+ }
884
+ return {
885
+ worktree: createdWorktree,
886
+ postCreationHookError,
887
+ };
854
888
  });
855
889
  }
856
890
  /**
@@ -4,7 +4,7 @@ import { execSync } from 'child_process';
4
4
  import { existsSync, statSync } from 'fs';
5
5
  import { configReader } from './config/configReader.js';
6
6
  import { Effect } from 'effect';
7
- import { GitError } from '../types/errors.js';
7
+ import { GitError, ProcessError } from '../types/errors.js';
8
8
  // Mock child_process module
9
9
  vi.mock('child_process');
10
10
  // Mock fs module
@@ -25,6 +25,7 @@ vi.mock('./config/configReader.js', () => ({
25
25
  }));
26
26
  // Mock HookExecutor
27
27
  vi.mock('../utils/hookExecutor.js', () => ({
28
+ executeWorktreePreCreationHook: vi.fn(),
28
29
  executeWorktreePostCreationHook: vi.fn(),
29
30
  }));
30
31
  // Get the mocked function with proper typing
@@ -641,7 +642,7 @@ branch refs/heads/feature
641
642
  });
642
643
  const effect = service.createWorktreeEffect('/path/to/worktree', 'new-feature', 'main');
643
644
  const result = await Effect.runPromise(effect);
644
- expect(result).toMatchObject({
645
+ expect(result.worktree).toMatchObject({
645
646
  path: '/path/to/worktree',
646
647
  branch: 'new-feature',
647
648
  isMainWorktree: false,
@@ -676,6 +677,77 @@ branch refs/heads/feature
676
677
  expect.fail('Should have returned Left with GitError');
677
678
  }
678
679
  });
680
+ it('should fail with ProcessError and skip git worktree add when pre-creation hook fails', async () => {
681
+ const { executeWorktreePreCreationHook } = await import('../utils/hookExecutor.js');
682
+ const mockedPreHook = vi.mocked(executeWorktreePreCreationHook);
683
+ mockedGetWorktreeHooks.mockReturnValue({
684
+ pre_creation: {
685
+ command: 'exit 1',
686
+ enabled: true,
687
+ },
688
+ });
689
+ mockedPreHook.mockReturnValue(Effect.fail(new ProcessError({
690
+ command: 'exit 1',
691
+ exitCode: 1,
692
+ message: 'Hook exited with code 1',
693
+ })));
694
+ mockedExecSync.mockImplementation((cmd, _options) => {
695
+ if (typeof cmd === 'string') {
696
+ if (cmd === 'git rev-parse --git-common-dir') {
697
+ return '/fake/path/.git\n';
698
+ }
699
+ if (cmd.includes('rev-parse --verify')) {
700
+ throw new Error('Branch not found');
701
+ }
702
+ if (cmd.includes('git worktree add')) {
703
+ throw new Error('git worktree add should not be called');
704
+ }
705
+ }
706
+ return '';
707
+ });
708
+ const result = await Effect.runPromise(Effect.either(service.createWorktreeEffect('/path/to/worktree', 'new-feature', 'main')));
709
+ expect(result._tag).toBe('Left');
710
+ if (result._tag === 'Left') {
711
+ expect(result.left).toBeInstanceOf(ProcessError);
712
+ }
713
+ expect(mockedExecSync).not.toHaveBeenCalledWith(expect.stringContaining('git worktree add'), expect.anything());
714
+ });
715
+ it('should return Worktree with postCreationHookError when post-creation hook fails', async () => {
716
+ const { executeWorktreePostCreationHook } = await import('../utils/hookExecutor.js');
717
+ const mockedPostHook = vi.mocked(executeWorktreePostCreationHook);
718
+ const hookError = new ProcessError({
719
+ command: 'exit 1',
720
+ exitCode: 1,
721
+ message: 'Hook exited with code 1',
722
+ });
723
+ mockedGetWorktreeHooks.mockReturnValue({
724
+ post_creation: {
725
+ command: 'exit 1',
726
+ enabled: true,
727
+ },
728
+ });
729
+ mockedPostHook.mockReturnValue(Effect.fail(hookError));
730
+ mockedExecSync.mockImplementation((cmd, _options) => {
731
+ if (typeof cmd === 'string') {
732
+ if (cmd === 'git rev-parse --git-common-dir') {
733
+ return '/fake/path/.git\n';
734
+ }
735
+ if (cmd.includes('rev-parse --verify')) {
736
+ throw new Error('Branch not found');
737
+ }
738
+ if (cmd.includes('git worktree add')) {
739
+ return '';
740
+ }
741
+ }
742
+ return '';
743
+ });
744
+ const result = await Effect.runPromise(service.createWorktreeEffect('/path/to/worktree', 'new-feature', 'main'));
745
+ expect(result.worktree).toMatchObject({
746
+ path: '/path/to/worktree',
747
+ branch: 'new-feature',
748
+ });
749
+ expect(result.postCreationHookError).toBe(hookError);
750
+ });
679
751
  });
680
752
  describe('Effect-based deleteWorktree', () => {
681
753
  it('should return Effect with void on success', async () => {
@@ -51,6 +51,8 @@ export declare class ProcessError extends ProcessError_base<{
51
51
  readonly signal?: string;
52
52
  readonly exitCode?: number;
53
53
  readonly message: string;
54
+ readonly stdout?: string;
55
+ readonly stderr?: string;
54
56
  }> {
55
57
  }
56
58
  declare const ValidationError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
@@ -4,6 +4,7 @@ import type { SerializeAddon } from '@xterm/addon-serialize';
4
4
  import { GitStatus } from '../utils/gitStatus.js';
5
5
  import { Mutex, SessionStateData } from '../utils/mutex.js';
6
6
  import type { StateDetector } from '../services/stateDetector/types.js';
7
+ import type { ProcessError } from './errors.js';
7
8
  export type Terminal = InstanceType<typeof pkg.Terminal>;
8
9
  export type SessionState = 'idle' | 'busy' | 'waiting_input' | 'pending_auto_approval';
9
10
  export type StateDetectionStrategy = 'claude' | 'gemini' | 'codex' | 'cursor' | 'github-copilot' | 'cline' | 'opencode' | 'kimi';
@@ -16,6 +17,10 @@ export interface Worktree {
16
17
  gitStatusError?: string;
17
18
  lastCommitDate?: Date;
18
19
  }
20
+ export interface CreateWorktreeResult {
21
+ worktree: Worktree;
22
+ postCreationHookError?: ProcessError;
23
+ }
19
24
  export interface Session {
20
25
  id: string;
21
26
  worktreePath: string;
@@ -251,7 +256,7 @@ export declare class AmbiguousBranchError extends Error {
251
256
  export interface IWorktreeService {
252
257
  getWorktreesEffect(): import('effect').Effect.Effect<Worktree[], import('../types/errors.js').GitError, never>;
253
258
  getGitRootPath(): string;
254
- createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): import('effect').Effect.Effect<Worktree, import('../types/errors.js').GitError | import('../types/errors.js').FileSystemError | import('../types/errors.js').ProcessError, never>;
259
+ createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): import('effect').Effect.Effect<CreateWorktreeResult, import('../types/errors.js').GitError | import('../types/errors.js').FileSystemError | import('../types/errors.js').ProcessError, never>;
255
260
  deleteWorktreeEffect(worktreePath: string, options?: {
256
261
  deleteBranch?: boolean;
257
262
  }): import('effect').Effect.Effect<void, import('../types/errors.js').GitError, never>;
@@ -59,7 +59,7 @@ export declare function executeWorktreePreCreationHook(command: string, worktree
59
59
  * Execute a worktree post-creation hook using Effect
60
60
  * Errors are caught and logged but do not break the main flow
61
61
  */
62
- export declare function executeWorktreePostCreationHook(command: string, worktree: Worktree, gitRoot: string, baseBranch?: string): Effect.Effect<void, never>;
62
+ export declare function executeWorktreePostCreationHook(command: string, worktree: Worktree, gitRoot: string, baseBranch?: string): Effect.Effect<void, ProcessError>;
63
63
  /**
64
64
  * Execute a session status change hook using Effect
65
65
  * Errors are caught and logged but do not break the main flow
@@ -4,6 +4,7 @@ import { Effect } from 'effect';
4
4
  import { ProcessError } from '../types/errors.js';
5
5
  import { WorktreeService } from '../services/worktreeService.js';
6
6
  import { configReader } from '../services/config/configReader.js';
7
+ import { logger } from './logger.js';
7
8
  /**
8
9
  * Execute a hook command with the provided environment variables using Effect
9
10
  *
@@ -40,6 +41,11 @@ import { configReader } from '../services/config/configReader.js';
40
41
  */
41
42
  export function executeHook(command, cwd, environment) {
42
43
  return Effect.async(resume => {
44
+ logger.info('Hook execution starting', {
45
+ command,
46
+ cwd,
47
+ environment,
48
+ });
43
49
  // Use spawn with shell to execute the command and wait for all child processes
44
50
  const child = spawn(command, [], {
45
51
  cwd,
@@ -50,24 +56,44 @@ export function executeHook(command, cwd, environment) {
50
56
  shell: true,
51
57
  stdio: ['ignore', 'pipe', 'pipe'],
52
58
  });
59
+ let stdout = '';
53
60
  let stderr = '';
61
+ child.stdout?.on('data', data => {
62
+ stdout += data.toString();
63
+ });
54
64
  // Collect stderr for logging
55
65
  child.stderr?.on('data', data => {
56
66
  stderr += data.toString();
57
67
  });
58
68
  // Wait for the process and all its children to exit
59
69
  child.on('exit', (code, signal) => {
70
+ logger.info('Hook execution finished', {
71
+ command,
72
+ cwd,
73
+ exitCode: code,
74
+ signal,
75
+ stdout,
76
+ stderr,
77
+ });
60
78
  if (code !== 0 || signal) {
61
79
  const errorMessage = signal
62
80
  ? `Hook terminated by signal ${signal}`
63
81
  : `Hook exited with code ${code}`;
82
+ const outputDetails = [
83
+ stderr ? `Stderr: ${stderr}` : undefined,
84
+ stdout ? `Stdout: ${stdout}` : undefined,
85
+ ]
86
+ .filter(Boolean)
87
+ .join('\n');
64
88
  resume(Effect.fail(new ProcessError({
65
89
  command,
66
90
  exitCode: code ?? undefined,
67
91
  signal: signal ?? undefined,
68
- message: stderr
69
- ? `${errorMessage}\nStderr: ${stderr}`
92
+ message: outputDetails
93
+ ? `${errorMessage}\n${outputDetails}`
70
94
  : errorMessage,
95
+ stdout,
96
+ stderr,
71
97
  })));
72
98
  return;
73
99
  }
@@ -76,6 +102,11 @@ export function executeHook(command, cwd, environment) {
76
102
  });
77
103
  // Handle errors in spawning the process
78
104
  child.on('error', error => {
105
+ logger.error('Hook spawn failed', {
106
+ command,
107
+ cwd,
108
+ error: error.message,
109
+ });
79
110
  resume(Effect.fail(new ProcessError({
80
111
  command,
81
112
  message: error.message,
@@ -103,6 +134,13 @@ export function executeWorktreePreCreationHook(command, worktreePath, branch, gi
103
134
  if (baseBranch) {
104
135
  environment.CCMANAGER_BASE_BRANCH = baseBranch;
105
136
  }
137
+ logger.info('Worktree pre-creation hook configured', {
138
+ command,
139
+ worktreePath,
140
+ branch,
141
+ gitRoot,
142
+ baseBranch,
143
+ });
106
144
  // Execute in git root (worktree doesn't exist yet)
107
145
  // NO Effect.catchAll - errors must propagate to abort creation
108
146
  return executeHook(command, gitRoot, environment);
@@ -120,11 +158,14 @@ export function executeWorktreePostCreationHook(command, worktree, gitRoot, base
120
158
  if (baseBranch) {
121
159
  environment.CCMANAGER_BASE_BRANCH = baseBranch;
122
160
  }
123
- return Effect.catchAll(executeHook(command, worktree.path, environment), error => {
124
- // Log error but don't throw - hooks should not break the main flow
125
- console.error(`Failed to execute post-creation hook: ${error.message}`);
126
- return Effect.void;
161
+ logger.info('Worktree post-creation hook configured', {
162
+ command,
163
+ worktreePath: worktree.path,
164
+ branch: worktree.branch || 'unknown',
165
+ gitRoot,
166
+ baseBranch,
127
167
  });
168
+ return executeHook(command, worktree.path, environment);
128
169
  }
129
170
  /**
130
171
  * Execute a session status change hook using Effect
@@ -156,7 +156,7 @@ describe('hookExecutor Integration Tests', () => {
156
156
  });
157
157
  });
158
158
  describe('executeWorktreePostCreationHook (real execution)', () => {
159
- it('should not throw even when command fails', async () => {
159
+ it('should fail with ProcessError when command fails', async () => {
160
160
  // Arrange
161
161
  const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
162
162
  const worktree = {
@@ -166,8 +166,13 @@ describe('hookExecutor Integration Tests', () => {
166
166
  hasSession: false,
167
167
  };
168
168
  try {
169
- // Act & Assert - should not throw even with failing command
170
- await expect(Effect.runPromise(executeWorktreePostCreationHook('exit 1', worktree, tmpDir, 'main'))).resolves.toBeUndefined();
169
+ // Act & Assert - post-creation hook failures are surfaced to callers
170
+ const result = await Effect.runPromise(Effect.either(executeWorktreePostCreationHook('exit 1', worktree, tmpDir, 'main')));
171
+ expect(result._tag).toBe('Left');
172
+ if (result._tag === 'Left') {
173
+ expect(result.left._tag).toBe('ProcessError');
174
+ expect(result.left.exitCode).toBe(1);
175
+ }
171
176
  }
172
177
  finally {
173
178
  // Cleanup
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { format } from 'util';
4
- import os from 'os';
4
+ import * as os from 'os';
5
5
  /**
6
6
  * Log level constants for structured logging
7
7
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.10",
3
+ "version": "4.1.11",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -41,11 +41,11 @@
41
41
  "bin"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.10",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.10",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.10",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.10",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.10"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.11",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.11",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.11",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.11",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.11"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",