ccmanager 4.1.10 → 4.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/App.js +65 -8
- package/dist/components/App.test.js +152 -12
- package/dist/services/sessionManager.d.ts +0 -6
- package/dist/services/sessionManager.js +1 -88
- package/dist/services/sessionManager.test.js +0 -111
- package/dist/services/stateDetector/codex.js +2 -1
- package/dist/services/stateDetector/codex.test.js +20 -0
- package/dist/services/worktreeService.d.ts +2 -2
- package/dist/services/worktreeService.js +45 -11
- package/dist/services/worktreeService.test.js +74 -2
- package/dist/types/errors.d.ts +2 -0
- package/dist/types/index.d.ts +6 -1
- package/dist/utils/hookExecutor.d.ts +1 -1
- package/dist/utils/hookExecutor.js +47 -6
- package/dist/utils/hookExecutor.test.js +8 -3
- package/dist/utils/logger.js +1 -1
- package/package.json +6 -6
package/dist/components/App.js
CHANGED
|
@@ -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 =
|
|
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({
|
|
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 =
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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));
|
|
@@ -18,8 +18,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
18
18
|
private autoApprovalDisabledWorktrees;
|
|
19
19
|
private restoringSessions;
|
|
20
20
|
private bufferedRestoreData;
|
|
21
|
-
private restoreRefreshTimers;
|
|
22
|
-
private restoreRefreshDeadlines;
|
|
23
21
|
private spawn;
|
|
24
22
|
private resolvePreset;
|
|
25
23
|
detectTerminalState(session: Session): SessionState;
|
|
@@ -41,10 +39,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
41
39
|
private createTerminal;
|
|
42
40
|
private shouldResetRestoreScrollback;
|
|
43
41
|
private getRestoreSnapshot;
|
|
44
|
-
private scheduleRestoreRefresh;
|
|
45
|
-
private armRestoreRefreshTimer;
|
|
46
|
-
private cancelRestoreRefresh;
|
|
47
|
-
private fireRestoreRefresh;
|
|
48
42
|
private createSessionInternal;
|
|
49
43
|
/**
|
|
50
44
|
* Create session with command preset using Effect-based error handling
|
|
@@ -21,15 +21,6 @@ const { Terminal } = pkg;
|
|
|
21
21
|
const TERMINAL_CONTENT_MAX_LINES = 300;
|
|
22
22
|
const TERMINAL_SCROLLBACK_LINES = 5000;
|
|
23
23
|
const TERMINAL_RESTORE_SCROLLBACK_LINES = 200;
|
|
24
|
-
// Claude Code's Ink-based renderer sometimes splits a single UI redraw across
|
|
25
|
-
// multiple PTY writes with short time gaps. If we snapshot between chunks, the
|
|
26
|
-
// resulting viewport can miss rows (e.g. empty middle area while the top/bottom
|
|
27
|
-
// chrome already rendered). Re-emit the snapshot after the PTY output has been
|
|
28
|
-
// quiet for this long so late chunks are accounted for.
|
|
29
|
-
const RESTORE_REFRESH_QUIET_MS = 120;
|
|
30
|
-
// Cap on how long we wait for quiet before forcing the refresh, so continuous
|
|
31
|
-
// streaming output (e.g. a long busy turn) still produces an updated snapshot.
|
|
32
|
-
const RESTORE_REFRESH_MAX_WAIT_MS = 400;
|
|
33
24
|
export class SessionManager extends EventEmitter {
|
|
34
25
|
sessions;
|
|
35
26
|
waitingWithBottomBorder = new Map();
|
|
@@ -37,8 +28,6 @@ export class SessionManager extends EventEmitter {
|
|
|
37
28
|
autoApprovalDisabledWorktrees = new Set();
|
|
38
29
|
restoringSessions = new Set();
|
|
39
30
|
bufferedRestoreData = new Map();
|
|
40
|
-
restoreRefreshTimers = new Map();
|
|
41
|
-
restoreRefreshDeadlines = new Map();
|
|
42
31
|
async spawn(command, args, worktreePath, options = {}) {
|
|
43
32
|
const spawnOptions = {
|
|
44
33
|
name: 'xterm-256color',
|
|
@@ -216,7 +205,7 @@ export class SessionManager extends EventEmitter {
|
|
|
216
205
|
data.includes('\x1b[3J') ||
|
|
217
206
|
data.includes('\x1bc'));
|
|
218
207
|
}
|
|
219
|
-
getRestoreSnapshot(session
|
|
208
|
+
getRestoreSnapshot(session) {
|
|
220
209
|
const activeBuffer = session.terminal.buffer.active;
|
|
221
210
|
if (activeBuffer.type !== 'normal') {
|
|
222
211
|
return session.serializer.serialize({
|
|
@@ -228,22 +217,6 @@ export class SessionManager extends EventEmitter {
|
|
|
228
217
|
if (bufferLength === 0) {
|
|
229
218
|
return '';
|
|
230
219
|
}
|
|
231
|
-
// While the session is busy, cursor-addressed status-box redraws can push
|
|
232
|
-
// stale frames into scrollback (e.g. Claude's spinner + token stats line).
|
|
233
|
-
// Those ghost rows render as duplicated status bars when replayed, so
|
|
234
|
-
// restore only the viewport during busy state. Refresh re-emits also
|
|
235
|
-
// bypass scrollback to avoid duplicating history into real-terminal
|
|
236
|
-
// scrollback on top of the initial emit.
|
|
237
|
-
const isBusy = session.stateMutex.getSnapshot().state === 'busy';
|
|
238
|
-
if (options.viewportOnly || isBusy) {
|
|
239
|
-
const snapshot = session.serializer.serialize({
|
|
240
|
-
scrollback: 0,
|
|
241
|
-
excludeAltBuffer: true,
|
|
242
|
-
});
|
|
243
|
-
const cursorRow = normalBuffer.cursorY + 1;
|
|
244
|
-
const cursorCol = normalBuffer.cursorX + 1;
|
|
245
|
-
return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
|
|
246
|
-
}
|
|
247
220
|
const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
|
|
248
221
|
const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
|
|
249
222
|
const rangeEnd = bufferLength - 1;
|
|
@@ -258,56 +231,6 @@ export class SessionManager extends EventEmitter {
|
|
|
258
231
|
const cursorCol = normalBuffer.cursorX + 1;
|
|
259
232
|
return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
|
|
260
233
|
}
|
|
261
|
-
scheduleRestoreRefresh(session) {
|
|
262
|
-
this.restoreRefreshDeadlines.set(session.id, Date.now() + RESTORE_REFRESH_MAX_WAIT_MS);
|
|
263
|
-
this.armRestoreRefreshTimer(session);
|
|
264
|
-
}
|
|
265
|
-
armRestoreRefreshTimer(session) {
|
|
266
|
-
const deadline = this.restoreRefreshDeadlines.get(session.id);
|
|
267
|
-
if (deadline === undefined) {
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
const existing = this.restoreRefreshTimers.get(session.id);
|
|
271
|
-
if (existing !== undefined) {
|
|
272
|
-
clearTimeout(existing);
|
|
273
|
-
this.restoreRefreshTimers.delete(session.id);
|
|
274
|
-
}
|
|
275
|
-
const remaining = deadline - Date.now();
|
|
276
|
-
if (remaining <= 0) {
|
|
277
|
-
this.fireRestoreRefresh(session);
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
const delay = Math.min(RESTORE_REFRESH_QUIET_MS, remaining);
|
|
281
|
-
const timer = setTimeout(() => this.fireRestoreRefresh(session), delay);
|
|
282
|
-
this.restoreRefreshTimers.set(session.id, timer);
|
|
283
|
-
}
|
|
284
|
-
cancelRestoreRefresh(session) {
|
|
285
|
-
const existing = this.restoreRefreshTimers.get(session.id);
|
|
286
|
-
if (existing !== undefined) {
|
|
287
|
-
clearTimeout(existing);
|
|
288
|
-
this.restoreRefreshTimers.delete(session.id);
|
|
289
|
-
}
|
|
290
|
-
this.restoreRefreshDeadlines.delete(session.id);
|
|
291
|
-
}
|
|
292
|
-
fireRestoreRefresh(session) {
|
|
293
|
-
this.restoreRefreshTimers.delete(session.id);
|
|
294
|
-
this.restoreRefreshDeadlines.delete(session.id);
|
|
295
|
-
if (!session.isActive) {
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
const snapshot = this.getRestoreSnapshot(session, { viewportOnly: true });
|
|
299
|
-
if (snapshot.length > 0) {
|
|
300
|
-
// \x1b[2J: clear the viewport before repainting — without this, a
|
|
301
|
-
// refresh snapshot shorter than the already-displayed content leaves
|
|
302
|
-
// a "ghost tail" of pre-refresh rows at the bottom.
|
|
303
|
-
// \x1b[?7h / \x1b[?7l: Session.tsx disables auto-wrap (DECAWM) after
|
|
304
|
-
// the initial restore, but SerializeAddon omits row separators for
|
|
305
|
-
// wrapped lines and relies on DECAWM to advance to the next row
|
|
306
|
-
// (see PR #276). Re-enable auto-wrap just for the snapshot write so
|
|
307
|
-
// wrapped viewport rows don't overlap, then restore the TUI default.
|
|
308
|
-
this.emit('sessionRestore', session, `\x1b[?7h\x1b[2J\x1b[H${snapshot}\x1b[?7l`);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
234
|
async createSessionInternal(worktreePath, ptyProcess, options = {}) {
|
|
312
235
|
const existingSessions = this.getSessionsForWorktree(worktreePath);
|
|
313
236
|
const maxNumber = existingSessions.reduce((max, s) => Math.max(max, s.sessionNumber), 0);
|
|
@@ -411,12 +334,6 @@ export class SessionManager extends EventEmitter {
|
|
|
411
334
|
session.terminal.buffer.normal.baseY;
|
|
412
335
|
}
|
|
413
336
|
session.lastActivity = new Date();
|
|
414
|
-
// If a restore-refresh is pending, each incoming chunk resets the
|
|
415
|
-
// quiet timer so the follow-up snapshot only fires after Claude's
|
|
416
|
-
// multi-chunk redraw has settled.
|
|
417
|
-
if (this.restoreRefreshDeadlines.has(session.id)) {
|
|
418
|
-
this.armRestoreRefreshTimer(session);
|
|
419
|
-
}
|
|
420
337
|
// Only emit data events when session is active
|
|
421
338
|
if (session.isActive) {
|
|
422
339
|
if (this.restoringSessions.has(session.id)) {
|
|
@@ -576,12 +493,10 @@ export class SessionManager extends EventEmitter {
|
|
|
576
493
|
}
|
|
577
494
|
}
|
|
578
495
|
}
|
|
579
|
-
this.scheduleRestoreRefresh(session);
|
|
580
496
|
}
|
|
581
497
|
else {
|
|
582
498
|
this.restoringSessions.delete(session.id);
|
|
583
499
|
this.bufferedRestoreData.delete(session.id);
|
|
584
|
-
this.cancelRestoreRefresh(session);
|
|
585
500
|
}
|
|
586
501
|
}
|
|
587
502
|
}
|
|
@@ -656,7 +571,6 @@ export class SessionManager extends EventEmitter {
|
|
|
656
571
|
this.waitingWithBottomBorder.delete(sessionId);
|
|
657
572
|
this.restoringSessions.delete(sessionId);
|
|
658
573
|
this.bufferedRestoreData.delete(sessionId);
|
|
659
|
-
this.cancelRestoreRefresh(session);
|
|
660
574
|
this.emit('sessionDestroyed', session);
|
|
661
575
|
}
|
|
662
576
|
}
|
|
@@ -710,7 +624,6 @@ export class SessionManager extends EventEmitter {
|
|
|
710
624
|
}
|
|
711
625
|
this.sessions.delete(sessionId);
|
|
712
626
|
this.waitingWithBottomBorder.delete(sessionId);
|
|
713
|
-
this.cancelRestoreRefresh(session);
|
|
714
627
|
this.emit('sessionDestroyed', session);
|
|
715
628
|
},
|
|
716
629
|
catch: (error) => {
|
|
@@ -782,7 +782,6 @@ describe('SessionManager', () => {
|
|
|
782
782
|
});
|
|
783
783
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
784
784
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
785
|
-
await session.stateMutex.update(data => ({ ...data, state: 'idle' }));
|
|
786
785
|
const normalBuffer = session.terminal.buffer.normal;
|
|
787
786
|
normalBuffer.baseY = 260;
|
|
788
787
|
normalBuffer.length = 300;
|
|
@@ -804,32 +803,6 @@ describe('SessionManager', () => {
|
|
|
804
803
|
});
|
|
805
804
|
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m\u001b[8;12H');
|
|
806
805
|
});
|
|
807
|
-
it('should emit a viewport-only restore snapshot while session is busy', async () => {
|
|
808
|
-
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
809
|
-
id: '1',
|
|
810
|
-
name: 'Main',
|
|
811
|
-
command: 'claude',
|
|
812
|
-
});
|
|
813
|
-
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
814
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
815
|
-
const normalBuffer = session.terminal.buffer.normal;
|
|
816
|
-
normalBuffer.baseY = 260;
|
|
817
|
-
normalBuffer.length = 300;
|
|
818
|
-
normalBuffer.cursorY = 7;
|
|
819
|
-
normalBuffer.cursorX = 11;
|
|
820
|
-
session.restoreScrollbackBaseLine = 120;
|
|
821
|
-
const serializeMock = vi
|
|
822
|
-
.spyOn(session.serializer, 'serialize')
|
|
823
|
-
.mockReturnValue('\u001b[31mbusy-viewport\u001b[0m');
|
|
824
|
-
const restoreHandler = vi.fn();
|
|
825
|
-
sessionManager.on('sessionRestore', restoreHandler);
|
|
826
|
-
sessionManager.setSessionActive(session.id, true);
|
|
827
|
-
expect(serializeMock).toHaveBeenCalledWith({
|
|
828
|
-
scrollback: 0,
|
|
829
|
-
excludeAltBuffer: true,
|
|
830
|
-
});
|
|
831
|
-
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mbusy-viewport\u001b[0m\u001b[8;12H');
|
|
832
|
-
});
|
|
833
806
|
it('should keep viewport-only restore behavior for alternate screen sessions', async () => {
|
|
834
807
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
835
808
|
id: '1',
|
|
@@ -899,90 +872,6 @@ describe('SessionManager', () => {
|
|
|
899
872
|
sessionManager.setSessionActive(session.id, true);
|
|
900
873
|
expect(eventOrder).toEqual(['restore', 'data']);
|
|
901
874
|
});
|
|
902
|
-
it('should re-emit a viewport-only snapshot after the PTY quiet period', async () => {
|
|
903
|
-
vi.useFakeTimers();
|
|
904
|
-
try {
|
|
905
|
-
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
906
|
-
id: '1',
|
|
907
|
-
name: 'Main',
|
|
908
|
-
command: 'claude',
|
|
909
|
-
});
|
|
910
|
-
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
911
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
912
|
-
session.terminal.buffer.normal.length = 1;
|
|
913
|
-
const serializeMock = vi
|
|
914
|
-
.spyOn(session.serializer, 'serialize')
|
|
915
|
-
.mockReturnValue('snap');
|
|
916
|
-
const restoreHandler = vi.fn();
|
|
917
|
-
sessionManager.on('sessionRestore', restoreHandler);
|
|
918
|
-
sessionManager.setSessionActive(session.id, true);
|
|
919
|
-
expect(restoreHandler).toHaveBeenCalledTimes(1);
|
|
920
|
-
await vi.advanceTimersByTimeAsync(50);
|
|
921
|
-
mockPty.emit('data', 'late-chunk');
|
|
922
|
-
await vi.advanceTimersByTimeAsync(50);
|
|
923
|
-
expect(restoreHandler).toHaveBeenCalledTimes(1);
|
|
924
|
-
await vi.advanceTimersByTimeAsync(120);
|
|
925
|
-
expect(restoreHandler).toHaveBeenCalledTimes(2);
|
|
926
|
-
expect(restoreHandler.mock.calls[1]?.[1]).toMatch(/^\u001b\[\?7h\u001b\[2J\u001b\[Hsnap.*\u001b\[\?7l$/);
|
|
927
|
-
expect(serializeMock).toHaveBeenLastCalledWith({
|
|
928
|
-
scrollback: 0,
|
|
929
|
-
excludeAltBuffer: true,
|
|
930
|
-
});
|
|
931
|
-
}
|
|
932
|
-
finally {
|
|
933
|
-
vi.useRealTimers();
|
|
934
|
-
}
|
|
935
|
-
});
|
|
936
|
-
it('should force a refresh at the max-wait deadline even while data keeps streaming', async () => {
|
|
937
|
-
vi.useFakeTimers();
|
|
938
|
-
try {
|
|
939
|
-
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
940
|
-
id: '1',
|
|
941
|
-
name: 'Main',
|
|
942
|
-
command: 'claude',
|
|
943
|
-
});
|
|
944
|
-
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
945
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
946
|
-
session.terminal.buffer.normal.length = 1;
|
|
947
|
-
vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
|
|
948
|
-
const restoreHandler = vi.fn();
|
|
949
|
-
sessionManager.on('sessionRestore', restoreHandler);
|
|
950
|
-
sessionManager.setSessionActive(session.id, true);
|
|
951
|
-
expect(restoreHandler).toHaveBeenCalledTimes(1);
|
|
952
|
-
for (let elapsed = 0; elapsed < 400; elapsed += 50) {
|
|
953
|
-
await vi.advanceTimersByTimeAsync(50);
|
|
954
|
-
mockPty.emit('data', 'chunk');
|
|
955
|
-
}
|
|
956
|
-
expect(restoreHandler).toHaveBeenCalledTimes(2);
|
|
957
|
-
}
|
|
958
|
-
finally {
|
|
959
|
-
vi.useRealTimers();
|
|
960
|
-
}
|
|
961
|
-
});
|
|
962
|
-
it('should cancel a scheduled refresh when the session is deactivated', async () => {
|
|
963
|
-
vi.useFakeTimers();
|
|
964
|
-
try {
|
|
965
|
-
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
966
|
-
id: '1',
|
|
967
|
-
name: 'Main',
|
|
968
|
-
command: 'claude',
|
|
969
|
-
});
|
|
970
|
-
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
971
|
-
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
972
|
-
session.terminal.buffer.normal.length = 1;
|
|
973
|
-
vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
|
|
974
|
-
const restoreHandler = vi.fn();
|
|
975
|
-
sessionManager.on('sessionRestore', restoreHandler);
|
|
976
|
-
sessionManager.setSessionActive(session.id, true);
|
|
977
|
-
expect(restoreHandler).toHaveBeenCalledTimes(1);
|
|
978
|
-
sessionManager.setSessionActive(session.id, false);
|
|
979
|
-
await vi.advanceTimersByTimeAsync(500);
|
|
980
|
-
expect(restoreHandler).toHaveBeenCalledTimes(1);
|
|
981
|
-
}
|
|
982
|
-
finally {
|
|
983
|
-
vi.useRealTimers();
|
|
984
|
-
}
|
|
985
|
-
});
|
|
986
875
|
});
|
|
987
876
|
describe('static methods', () => {
|
|
988
877
|
describe('getSessionCounts', () => {
|
|
@@ -15,7 +15,8 @@ export class CodexStateDetector extends BaseStateDetector {
|
|
|
15
15
|
// Check for waiting prompts
|
|
16
16
|
if (lowerContent.includes('allow command?') ||
|
|
17
17
|
lowerContent.includes('[y/n]') ||
|
|
18
|
-
lowerContent.includes('yes (y)')
|
|
18
|
+
lowerContent.includes('yes (y)') ||
|
|
19
|
+
lowerContent.includes('enter to submit')) {
|
|
19
20
|
return 'waiting_input';
|
|
20
21
|
}
|
|
21
22
|
if (/(do you want|would you like)[\s\S]*?\n+[\s\S]*?\byes\b/.test(lowerContent)) {
|
|
@@ -171,4 +171,24 @@ describe('CodexStateDetector', () => {
|
|
|
171
171
|
// Assert
|
|
172
172
|
expect(state).toBe('waiting_input');
|
|
173
173
|
});
|
|
174
|
+
it('should detect waiting_input for MCP tool permission prompt with "enter to submit"', () => {
|
|
175
|
+
// Arrange
|
|
176
|
+
terminal = createMockTerminal([
|
|
177
|
+
'Field 1/1',
|
|
178
|
+
'Allow the chrome-devtools MCP server to run tool "new_page"?',
|
|
179
|
+
'',
|
|
180
|
+
'timeout: 10000',
|
|
181
|
+
'url: http://localhost:4000/scenarios',
|
|
182
|
+
'',
|
|
183
|
+
'› 1. Allow Run the tool and continue.',
|
|
184
|
+
'2. Allow for this session Run the tool and remember this choice for this session.',
|
|
185
|
+
'3. Always allow Run the tool and remember this choice for future tool calls.',
|
|
186
|
+
'4. Cancel Cancel this tool call',
|
|
187
|
+
'enter to submit | esc to cancel',
|
|
188
|
+
]);
|
|
189
|
+
// Act
|
|
190
|
+
const state = detector.detectState(terminal, 'idle');
|
|
191
|
+
// Assert
|
|
192
|
+
expect(state).toBe('waiting_input');
|
|
193
|
+
});
|
|
174
194
|
});
|
|
@@ -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<
|
|
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
|
-
|
|
839
|
-
worktreeHooks.post_creation?.
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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 () => {
|
package/dist/types/errors.d.ts
CHANGED
|
@@ -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 & {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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<
|
|
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,
|
|
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:
|
|
69
|
-
? `${errorMessage}\
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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 -
|
|
170
|
-
await
|
|
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
|
package/dist/utils/logger.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.12",
|
|
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.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.1.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.1.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "4.1.12",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.12",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.12",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.12",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.12"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|