ccmanager 2.8.0 → 2.9.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.
- package/dist/cli.test.js +13 -2
- package/dist/components/App.js +125 -50
- package/dist/components/App.test.js +270 -0
- package/dist/components/ConfigureShortcuts.js +82 -8
- package/dist/components/DeleteWorktree.js +39 -5
- package/dist/components/DeleteWorktree.test.d.ts +1 -0
- package/dist/components/DeleteWorktree.test.js +128 -0
- package/dist/components/LoadingSpinner.d.ts +8 -0
- package/dist/components/LoadingSpinner.js +37 -0
- package/dist/components/LoadingSpinner.test.d.ts +1 -0
- package/dist/components/LoadingSpinner.test.js +187 -0
- package/dist/components/Menu.js +64 -16
- package/dist/components/Menu.recent-projects.test.js +32 -11
- package/dist/components/Menu.test.js +136 -4
- package/dist/components/MergeWorktree.js +79 -18
- package/dist/components/MergeWorktree.test.d.ts +1 -0
- package/dist/components/MergeWorktree.test.js +227 -0
- package/dist/components/NewWorktree.js +88 -9
- package/dist/components/NewWorktree.test.d.ts +1 -0
- package/dist/components/NewWorktree.test.js +244 -0
- package/dist/components/ProjectList.js +44 -13
- package/dist/components/ProjectList.recent-projects.test.js +8 -3
- package/dist/components/ProjectList.test.js +105 -8
- package/dist/components/RemoteBranchSelector.test.js +3 -1
- package/dist/hooks/useGitStatus.d.ts +11 -0
- package/dist/hooks/useGitStatus.js +70 -12
- package/dist/hooks/useGitStatus.test.js +30 -23
- package/dist/services/configurationManager.d.ts +75 -0
- package/dist/services/configurationManager.effect.test.d.ts +1 -0
- package/dist/services/configurationManager.effect.test.js +407 -0
- package/dist/services/configurationManager.js +246 -0
- package/dist/services/globalSessionOrchestrator.test.js +0 -8
- package/dist/services/projectManager.d.ts +98 -2
- package/dist/services/projectManager.js +228 -59
- package/dist/services/projectManager.test.js +242 -2
- package/dist/services/sessionManager.d.ts +44 -2
- package/dist/services/sessionManager.effect.test.d.ts +1 -0
- package/dist/services/sessionManager.effect.test.js +321 -0
- package/dist/services/sessionManager.js +216 -65
- package/dist/services/sessionManager.statePersistence.test.js +18 -9
- package/dist/services/sessionManager.test.js +40 -36
- package/dist/services/worktreeService.d.ts +356 -26
- package/dist/services/worktreeService.js +793 -353
- package/dist/services/worktreeService.test.js +294 -313
- package/dist/types/errors.d.ts +74 -0
- package/dist/types/errors.js +31 -0
- package/dist/types/errors.test.d.ts +1 -0
- package/dist/types/errors.test.js +201 -0
- package/dist/types/index.d.ts +5 -17
- package/dist/utils/claudeDir.d.ts +58 -6
- package/dist/utils/claudeDir.js +103 -8
- package/dist/utils/claudeDir.test.d.ts +1 -0
- package/dist/utils/claudeDir.test.js +108 -0
- package/dist/utils/concurrencyLimit.d.ts +5 -0
- package/dist/utils/concurrencyLimit.js +11 -0
- package/dist/utils/concurrencyLimit.test.js +40 -1
- package/dist/utils/gitStatus.d.ts +36 -8
- package/dist/utils/gitStatus.js +170 -88
- package/dist/utils/gitStatus.test.js +12 -9
- package/dist/utils/hookExecutor.d.ts +41 -6
- package/dist/utils/hookExecutor.js +75 -32
- package/dist/utils/hookExecutor.test.js +73 -20
- package/dist/utils/terminalCapabilities.d.ts +18 -0
- package/dist/utils/terminalCapabilities.js +81 -0
- package/dist/utils/terminalCapabilities.test.d.ts +1 -0
- package/dist/utils/terminalCapabilities.test.js +104 -0
- package/dist/utils/testHelpers.d.ts +106 -0
- package/dist/utils/testHelpers.js +153 -0
- package/dist/utils/testHelpers.test.d.ts +1 -0
- package/dist/utils/testHelpers.test.js +114 -0
- package/dist/utils/worktreeConfig.d.ts +77 -2
- package/dist/utils/worktreeConfig.js +156 -16
- package/dist/utils/worktreeConfig.test.d.ts +1 -0
- package/dist/utils/worktreeConfig.test.js +39 -0
- package/package.json +4 -4
- package/dist/integration-tests/devcontainer.integration.test.js +0 -101
- /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { ProcessError } from '../types/errors.js';
|
|
2
4
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
3
5
|
import { configurationManager } from '../services/configurationManager.js';
|
|
4
6
|
/**
|
|
5
|
-
* Execute a hook command with the provided environment variables
|
|
7
|
+
* Execute a hook command with the provided environment variables using Effect
|
|
8
|
+
*
|
|
9
|
+
* Spawns a shell process to run the hook command with custom environment.
|
|
10
|
+
* Errors are captured but hook failures don't propagate to prevent breaking main flow.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} command - Shell command to execute
|
|
13
|
+
* @param {string} cwd - Working directory for command execution
|
|
14
|
+
* @param {HookEnvironment} environment - Environment variables for the hook
|
|
15
|
+
* @returns {Effect.Effect<void, ProcessError, never>} Effect that succeeds on hook completion or fails with ProcessError
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* import {Effect} from 'effect';
|
|
20
|
+
* import {executeHook} from './utils/hookExecutor.js';
|
|
21
|
+
*
|
|
22
|
+
* const env = {
|
|
23
|
+
* CCMANAGER_WORKTREE_PATH: '/path/to/worktree',
|
|
24
|
+
* CCMANAGER_WORKTREE_BRANCH: 'feature-branch',
|
|
25
|
+
* CCMANAGER_GIT_ROOT: '/path/to/repo'
|
|
26
|
+
* };
|
|
27
|
+
*
|
|
28
|
+
* // Execute hook with error recovery
|
|
29
|
+
* const result = await Effect.runPromise(
|
|
30
|
+
* Effect.catchAll(
|
|
31
|
+
* executeHook('npm install', '/path/to/worktree', env),
|
|
32
|
+
* (error) => {
|
|
33
|
+
* console.error(`Hook failed: ${error.message}`);
|
|
34
|
+
* return Effect.succeed(undefined); // Continue despite error
|
|
35
|
+
* }
|
|
36
|
+
* )
|
|
37
|
+
* );
|
|
38
|
+
* ```
|
|
6
39
|
*/
|
|
7
40
|
export function executeHook(command, cwd, environment) {
|
|
8
|
-
return
|
|
41
|
+
return Effect.async(resume => {
|
|
9
42
|
// Use spawn with shell to execute the command and wait for all child processes
|
|
10
43
|
const child = spawn(command, [], {
|
|
11
44
|
cwd,
|
|
@@ -24,29 +57,36 @@ export function executeHook(command, cwd, environment) {
|
|
|
24
57
|
// Wait for the process and all its children to exit
|
|
25
58
|
child.on('exit', (code, signal) => {
|
|
26
59
|
if (code !== 0 || signal) {
|
|
27
|
-
|
|
60
|
+
const errorMessage = signal
|
|
28
61
|
? `Hook terminated by signal ${signal}`
|
|
29
62
|
: `Hook exited with code ${code}`;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
63
|
+
resume(Effect.fail(new ProcessError({
|
|
64
|
+
command,
|
|
65
|
+
exitCode: code ?? undefined,
|
|
66
|
+
signal: signal ?? undefined,
|
|
67
|
+
message: stderr
|
|
68
|
+
? `${errorMessage}\nStderr: ${stderr}`
|
|
69
|
+
: errorMessage,
|
|
70
|
+
})));
|
|
35
71
|
return;
|
|
36
72
|
}
|
|
37
73
|
// When exit code is 0, ignore stderr and resolve successfully
|
|
38
|
-
|
|
74
|
+
resume(Effect.void);
|
|
39
75
|
});
|
|
40
76
|
// Handle errors in spawning the process
|
|
41
77
|
child.on('error', error => {
|
|
42
|
-
|
|
78
|
+
resume(Effect.fail(new ProcessError({
|
|
79
|
+
command,
|
|
80
|
+
message: error.message,
|
|
81
|
+
})));
|
|
43
82
|
});
|
|
44
83
|
});
|
|
45
84
|
}
|
|
46
85
|
/**
|
|
47
|
-
* Execute a worktree post-creation hook
|
|
86
|
+
* Execute a worktree post-creation hook using Effect
|
|
87
|
+
* Errors are caught and logged but do not break the main flow
|
|
48
88
|
*/
|
|
49
|
-
export
|
|
89
|
+
export function executeWorktreePostCreationHook(command, worktree, gitRoot, baseBranch) {
|
|
50
90
|
const environment = {
|
|
51
91
|
CCMANAGER_WORKTREE_PATH: worktree.path,
|
|
52
92
|
CCMANAGER_WORKTREE_BRANCH: worktree.branch || 'unknown',
|
|
@@ -55,24 +95,30 @@ export async function executeWorktreePostCreationHook(command, worktree, gitRoot
|
|
|
55
95
|
if (baseBranch) {
|
|
56
96
|
environment.CCMANAGER_BASE_BRANCH = baseBranch;
|
|
57
97
|
}
|
|
58
|
-
|
|
59
|
-
await executeHook(command, worktree.path, environment);
|
|
60
|
-
}
|
|
61
|
-
catch (error) {
|
|
98
|
+
return Effect.catchAll(executeHook(command, worktree.path, environment), error => {
|
|
62
99
|
// Log error but don't throw - hooks should not break the main flow
|
|
63
|
-
console.error(`Failed to execute post-creation hook: ${error
|
|
64
|
-
|
|
100
|
+
console.error(`Failed to execute post-creation hook: ${error.message}`);
|
|
101
|
+
return Effect.void;
|
|
102
|
+
});
|
|
65
103
|
}
|
|
66
104
|
/**
|
|
67
|
-
* Execute a session status change hook
|
|
105
|
+
* Execute a session status change hook using Effect
|
|
106
|
+
* Errors are caught and logged but do not break the main flow
|
|
68
107
|
*/
|
|
69
|
-
export
|
|
108
|
+
export function executeStatusHook(oldState, newState, session) {
|
|
70
109
|
const statusHooks = configurationManager.getStatusHooks();
|
|
71
110
|
const hook = statusHooks[newState];
|
|
72
|
-
if (hook
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
111
|
+
if (!hook || !hook.enabled || !hook.command) {
|
|
112
|
+
return Effect.void;
|
|
113
|
+
}
|
|
114
|
+
const worktreeService = new WorktreeService();
|
|
115
|
+
return Effect.gen(function* () {
|
|
116
|
+
// Get branch information using Effect-based method
|
|
117
|
+
const worktrees = yield* Effect.catchAll(worktreeService.getWorktreesEffect(), error => {
|
|
118
|
+
// Log error but continue with empty array - hooks should not break main flow
|
|
119
|
+
console.error(`Failed to get worktrees for status hook: ${error.message || String(error)}`);
|
|
120
|
+
return Effect.succeed([]);
|
|
121
|
+
});
|
|
76
122
|
const worktree = worktrees.find(wt => wt.path === session.worktreePath);
|
|
77
123
|
const branch = worktree?.branch || 'unknown';
|
|
78
124
|
// Build environment for status hook
|
|
@@ -84,13 +130,10 @@ export async function executeStatusHook(oldState, newState, session) {
|
|
|
84
130
|
CCMANAGER_NEW_STATE: newState,
|
|
85
131
|
CCMANAGER_SESSION_ID: session.id,
|
|
86
132
|
};
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
await executeHook(hook.command, session.worktreePath, environment);
|
|
90
|
-
}
|
|
91
|
-
catch (error) {
|
|
133
|
+
yield* Effect.catchAll(executeHook(hook.command, session.worktreePath, environment), error => {
|
|
92
134
|
// Log error but don't throw - hooks should not break the main flow
|
|
93
|
-
console.error(`Failed to execute ${newState} hook: ${error
|
|
94
|
-
|
|
95
|
-
|
|
135
|
+
console.error(`Failed to execute ${newState} hook: ${error.message}`);
|
|
136
|
+
return Effect.void;
|
|
137
|
+
});
|
|
138
|
+
});
|
|
96
139
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { Effect } from 'effect';
|
|
2
3
|
import { executeHook, executeWorktreePostCreationHook, executeStatusHook, } from './hookExecutor.js';
|
|
3
4
|
import { mkdtemp, rm, readFile, realpath } from 'fs/promises';
|
|
4
5
|
import { tmpdir } from 'os';
|
|
5
6
|
import { join } from 'path';
|
|
6
7
|
import { configurationManager } from '../services/configurationManager.js';
|
|
7
8
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
9
|
+
import { GitError } from '../types/errors.js';
|
|
8
10
|
// Mock the configurationManager
|
|
9
11
|
vi.mock('../services/configurationManager.js', () => ({
|
|
10
12
|
configurationManager: {
|
|
@@ -28,7 +30,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
28
30
|
};
|
|
29
31
|
try {
|
|
30
32
|
// Act & Assert - should not throw
|
|
31
|
-
await expect(executeHook('echo "Test successful"', tmpDir, environment)).resolves.toBeUndefined();
|
|
33
|
+
await expect(Effect.runPromise(executeHook('echo "Test successful"', tmpDir, environment))).resolves.toBeUndefined();
|
|
32
34
|
}
|
|
33
35
|
finally {
|
|
34
36
|
// Cleanup
|
|
@@ -45,7 +47,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
45
47
|
};
|
|
46
48
|
try {
|
|
47
49
|
// Act & Assert
|
|
48
|
-
await expect(executeHook('exit 1', tmpDir, environment)).rejects.toThrow();
|
|
50
|
+
await expect(Effect.runPromise(executeHook('exit 1', tmpDir, environment))).rejects.toThrow();
|
|
49
51
|
}
|
|
50
52
|
finally {
|
|
51
53
|
// Cleanup
|
|
@@ -62,7 +64,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
62
64
|
};
|
|
63
65
|
try {
|
|
64
66
|
// Act & Assert - command that writes to stderr and exits with error
|
|
65
|
-
await expect(executeHook('>&2 echo "Error details here"; exit 1', tmpDir, environment)).rejects.toThrow('Hook exited with code 1\nStderr: Error details here\n');
|
|
67
|
+
await expect(Effect.runPromise(executeHook('>&2 echo "Error details here"; exit 1', tmpDir, environment))).rejects.toThrow('Hook exited with code 1\nStderr: Error details here\n');
|
|
66
68
|
}
|
|
67
69
|
finally {
|
|
68
70
|
// Cleanup
|
|
@@ -80,7 +82,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
80
82
|
try {
|
|
81
83
|
// Test with multiline stderr
|
|
82
84
|
try {
|
|
83
|
-
await executeHook('>&2 echo "Line 1"; >&2 echo "Line 2"; exit 3', tmpDir, environment);
|
|
85
|
+
await Effect.runPromise(executeHook('>&2 echo "Line 1"; >&2 echo "Line 2"; exit 3', tmpDir, environment));
|
|
84
86
|
expect.fail('Should have thrown');
|
|
85
87
|
}
|
|
86
88
|
catch (error) {
|
|
@@ -90,7 +92,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
90
92
|
}
|
|
91
93
|
// Test with empty stderr
|
|
92
94
|
try {
|
|
93
|
-
await executeHook('exit 4', tmpDir, environment);
|
|
95
|
+
await Effect.runPromise(executeHook('exit 4', tmpDir, environment));
|
|
94
96
|
expect.fail('Should have thrown');
|
|
95
97
|
}
|
|
96
98
|
catch (error) {
|
|
@@ -114,7 +116,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
114
116
|
try {
|
|
115
117
|
// Act - command that writes to stderr but exits successfully
|
|
116
118
|
// Should not throw even though there's stderr output
|
|
117
|
-
await expect(executeHook('>&2 echo "Warning message"; exit 0', tmpDir, environment)).resolves.toBeUndefined();
|
|
119
|
+
await expect(Effect.runPromise(executeHook('>&2 echo "Warning message"; exit 0', tmpDir, environment))).resolves.toBeUndefined();
|
|
118
120
|
}
|
|
119
121
|
finally {
|
|
120
122
|
// Cleanup
|
|
@@ -132,7 +134,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
132
134
|
};
|
|
133
135
|
try {
|
|
134
136
|
// Act - write current directory to file
|
|
135
|
-
await executeHook(`pwd > "${outputFile}"`, tmpDir, environment);
|
|
137
|
+
await Effect.runPromise(executeHook(`pwd > "${outputFile}"`, tmpDir, environment));
|
|
136
138
|
// Read the output
|
|
137
139
|
const { readFile } = await import('fs/promises');
|
|
138
140
|
const output = await readFile(outputFile, 'utf-8');
|
|
@@ -159,7 +161,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
159
161
|
};
|
|
160
162
|
try {
|
|
161
163
|
// Act & Assert - should not throw even with failing command
|
|
162
|
-
await expect(executeWorktreePostCreationHook('exit 1', worktree, tmpDir, 'main')).resolves.toBeUndefined();
|
|
164
|
+
await expect(Effect.runPromise(executeWorktreePostCreationHook('exit 1', worktree, tmpDir, 'main'))).resolves.toBeUndefined();
|
|
163
165
|
}
|
|
164
166
|
finally {
|
|
165
167
|
// Cleanup
|
|
@@ -179,7 +181,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
179
181
|
const gitRoot = '/different/git/root';
|
|
180
182
|
try {
|
|
181
183
|
// Act - write current directory to file
|
|
182
|
-
await executeWorktreePostCreationHook(`pwd > "${outputFile}"`, worktree, gitRoot, 'main');
|
|
184
|
+
await Effect.runPromise(executeWorktreePostCreationHook(`pwd > "${outputFile}"`, worktree, gitRoot, 'main'));
|
|
183
185
|
// Read the output
|
|
184
186
|
const { readFile } = await import('fs/promises');
|
|
185
187
|
const output = await readFile(outputFile, 'utf-8');
|
|
@@ -208,7 +210,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
208
210
|
};
|
|
209
211
|
try {
|
|
210
212
|
// Act - change to git root and write its path
|
|
211
|
-
await executeWorktreePostCreationHook(`cd "$CCMANAGER_GIT_ROOT" && pwd > "${outputFile}"`, worktree, tmpGitRootDir, 'main');
|
|
213
|
+
await Effect.runPromise(executeWorktreePostCreationHook(`cd "$CCMANAGER_GIT_ROOT" && pwd > "${outputFile}"`, worktree, tmpGitRootDir, 'main'));
|
|
212
214
|
// Read the output
|
|
213
215
|
const { readFile } = await import('fs/promises');
|
|
214
216
|
const output = await readFile(outputFile, 'utf-8');
|
|
@@ -235,7 +237,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
235
237
|
// Act - execute a command that spawns a background process with a delay
|
|
236
238
|
// The background process writes to a file after a delay
|
|
237
239
|
// We use a shell command that creates a background process and then exits
|
|
238
|
-
await executeWorktreePostCreationHook(`(sleep 0.1 && echo "completed" > "${outputFile}") & wait`, worktree, tmpDir, 'main');
|
|
240
|
+
await Effect.runPromise(executeWorktreePostCreationHook(`(sleep 0.1 && echo "completed" > "${outputFile}") & wait`, worktree, tmpDir, 'main'));
|
|
239
241
|
// Read the output - this should exist because we waited for the background process
|
|
240
242
|
const { readFile } = await import('fs/promises');
|
|
241
243
|
const output = await readFile(outputFile, 'utf-8');
|
|
@@ -273,14 +275,14 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
273
275
|
};
|
|
274
276
|
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
275
277
|
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
276
|
-
|
|
278
|
+
getWorktreesEffect: vi.fn(() => Effect.succeed([
|
|
277
279
|
{
|
|
278
280
|
path: tmpDir,
|
|
279
281
|
branch: 'test-branch',
|
|
280
282
|
isMainWorktree: false,
|
|
281
283
|
hasSession: true,
|
|
282
284
|
},
|
|
283
|
-
]),
|
|
285
|
+
])),
|
|
284
286
|
}));
|
|
285
287
|
// Configure mock to return a hook that writes to a file with delay
|
|
286
288
|
vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
|
|
@@ -293,7 +295,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
293
295
|
});
|
|
294
296
|
try {
|
|
295
297
|
// Act - execute the hook and await it
|
|
296
|
-
await executeStatusHook('idle', 'busy', mockSession);
|
|
298
|
+
await Effect.runPromise(executeStatusHook('idle', 'busy', mockSession));
|
|
297
299
|
// Assert - file should exist because we awaited the hook
|
|
298
300
|
const content = await readFile(outputFile, 'utf-8');
|
|
299
301
|
expect(content.trim()).toBe('Hook executed');
|
|
@@ -326,14 +328,14 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
326
328
|
};
|
|
327
329
|
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
328
330
|
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
329
|
-
|
|
331
|
+
getWorktreesEffect: vi.fn(() => Effect.succeed([
|
|
330
332
|
{
|
|
331
333
|
path: tmpDir,
|
|
332
334
|
branch: 'test-branch',
|
|
333
335
|
isMainWorktree: false,
|
|
334
336
|
hasSession: true,
|
|
335
337
|
},
|
|
336
|
-
]),
|
|
338
|
+
])),
|
|
337
339
|
}));
|
|
338
340
|
// Configure mock to return a hook that fails
|
|
339
341
|
vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
|
|
@@ -346,7 +348,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
346
348
|
});
|
|
347
349
|
try {
|
|
348
350
|
// Act & Assert - should not throw even when hook fails
|
|
349
|
-
await expect(executeStatusHook('idle', 'busy', mockSession)).resolves.toBeUndefined();
|
|
351
|
+
await expect(Effect.runPromise(executeStatusHook('idle', 'busy', mockSession))).resolves.toBeUndefined();
|
|
350
352
|
}
|
|
351
353
|
finally {
|
|
352
354
|
// Cleanup
|
|
@@ -377,14 +379,14 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
377
379
|
};
|
|
378
380
|
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
379
381
|
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
380
|
-
|
|
382
|
+
getWorktreesEffect: vi.fn(() => Effect.succeed([
|
|
381
383
|
{
|
|
382
384
|
path: tmpDir,
|
|
383
385
|
branch: 'test-branch',
|
|
384
386
|
isMainWorktree: false,
|
|
385
387
|
hasSession: true,
|
|
386
388
|
},
|
|
387
|
-
]),
|
|
389
|
+
])),
|
|
388
390
|
}));
|
|
389
391
|
// Configure mock to return a disabled hook
|
|
390
392
|
vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
|
|
@@ -397,7 +399,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
397
399
|
});
|
|
398
400
|
try {
|
|
399
401
|
// Act
|
|
400
|
-
await executeStatusHook('idle', 'busy', mockSession);
|
|
402
|
+
await Effect.runPromise(executeStatusHook('idle', 'busy', mockSession));
|
|
401
403
|
// Assert - file should not exist because hook was disabled
|
|
402
404
|
await expect(readFile(outputFile, 'utf-8')).rejects.toThrow();
|
|
403
405
|
}
|
|
@@ -406,5 +408,56 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
406
408
|
await rm(tmpDir, { recursive: true });
|
|
407
409
|
}
|
|
408
410
|
});
|
|
411
|
+
it('should handle getWorktreesEffect failures gracefully', async () => {
|
|
412
|
+
// Arrange
|
|
413
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'status-hook-test-'));
|
|
414
|
+
const outputFile = join(tmpDir, 'hook-output.txt');
|
|
415
|
+
const mockSession = {
|
|
416
|
+
id: 'test-session-failure',
|
|
417
|
+
worktreePath: tmpDir,
|
|
418
|
+
process: {},
|
|
419
|
+
terminal: {},
|
|
420
|
+
output: [],
|
|
421
|
+
outputHistory: [],
|
|
422
|
+
state: 'idle',
|
|
423
|
+
stateCheckInterval: undefined,
|
|
424
|
+
isPrimaryCommand: true,
|
|
425
|
+
commandConfig: undefined,
|
|
426
|
+
detectionStrategy: 'claude',
|
|
427
|
+
devcontainerConfig: undefined,
|
|
428
|
+
pendingState: undefined,
|
|
429
|
+
pendingStateStart: undefined,
|
|
430
|
+
lastActivity: new Date(),
|
|
431
|
+
isActive: true,
|
|
432
|
+
};
|
|
433
|
+
// Mock WorktreeService to fail with GitError
|
|
434
|
+
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
435
|
+
getWorktreesEffect: vi.fn(() => Effect.fail(new GitError({
|
|
436
|
+
command: 'git worktree list --porcelain',
|
|
437
|
+
exitCode: 128,
|
|
438
|
+
stderr: 'not a git repository',
|
|
439
|
+
}))),
|
|
440
|
+
}));
|
|
441
|
+
// Configure mock to return a hook that should execute despite worktree query failure
|
|
442
|
+
vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
|
|
443
|
+
busy: {
|
|
444
|
+
enabled: true,
|
|
445
|
+
command: `echo "Hook ran with branch: $CCMANAGER_WORKTREE_BRANCH" > "${outputFile}"`,
|
|
446
|
+
},
|
|
447
|
+
idle: { enabled: false, command: '' },
|
|
448
|
+
waiting_input: { enabled: false, command: '' },
|
|
449
|
+
});
|
|
450
|
+
try {
|
|
451
|
+
// Act - should not throw even when getWorktreesEffect fails
|
|
452
|
+
await expect(Effect.runPromise(executeStatusHook('idle', 'busy', mockSession))).resolves.toBeUndefined();
|
|
453
|
+
// Assert - hook should have executed with 'unknown' branch
|
|
454
|
+
const content = await readFile(outputFile, 'utf-8');
|
|
455
|
+
expect(content.trim()).toBe('Hook ran with branch: unknown');
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
// Cleanup
|
|
459
|
+
await rm(tmpDir, { recursive: true });
|
|
460
|
+
}
|
|
461
|
+
});
|
|
409
462
|
});
|
|
410
463
|
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal capabilities detection utilities for CCManager.
|
|
3
|
+
* Determines terminal feature support for optimal UI rendering.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Detect if the current terminal supports Unicode characters.
|
|
7
|
+
* This function checks various environment variables and platform indicators
|
|
8
|
+
* to determine if Unicode spinner characters (⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) will render correctly.
|
|
9
|
+
*
|
|
10
|
+
* Detection strategy:
|
|
11
|
+
* 1. Check TERM environment variable for known Unicode-capable terminals
|
|
12
|
+
* 2. Check LANG and LC_ALL for UTF-8 encoding
|
|
13
|
+
* 3. Platform-specific checks (Windows Terminal, etc.)
|
|
14
|
+
* 4. Fallback to ASCII if no Unicode indicators present
|
|
15
|
+
*
|
|
16
|
+
* @returns true if Unicode is supported, false otherwise
|
|
17
|
+
*/
|
|
18
|
+
export declare function supportsUnicode(): boolean;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal capabilities detection utilities for CCManager.
|
|
3
|
+
* Determines terminal feature support for optimal UI rendering.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Detect if the current terminal supports Unicode characters.
|
|
7
|
+
* This function checks various environment variables and platform indicators
|
|
8
|
+
* to determine if Unicode spinner characters (⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) will render correctly.
|
|
9
|
+
*
|
|
10
|
+
* Detection strategy:
|
|
11
|
+
* 1. Check TERM environment variable for known Unicode-capable terminals
|
|
12
|
+
* 2. Check LANG and LC_ALL for UTF-8 encoding
|
|
13
|
+
* 3. Platform-specific checks (Windows Terminal, etc.)
|
|
14
|
+
* 4. Fallback to ASCII if no Unicode indicators present
|
|
15
|
+
*
|
|
16
|
+
* @returns true if Unicode is supported, false otherwise
|
|
17
|
+
*/
|
|
18
|
+
export function supportsUnicode() {
|
|
19
|
+
// Check for explicitly non-Unicode terminals
|
|
20
|
+
const term = process.env['TERM'];
|
|
21
|
+
if (term === 'dumb') {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
// Windows-specific detection
|
|
25
|
+
if (process.platform === 'win32') {
|
|
26
|
+
// Windows Terminal supports Unicode
|
|
27
|
+
if (process.env['WT_SESSION']) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
// Without Windows Terminal or explicit TERM, assume no Unicode
|
|
31
|
+
if (!term) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Check TERM for known Unicode-capable terminals
|
|
36
|
+
if (term) {
|
|
37
|
+
// xterm variants, screen, alacritty all support Unicode
|
|
38
|
+
const unicodeTerms = [
|
|
39
|
+
'xterm',
|
|
40
|
+
'xterm-256color',
|
|
41
|
+
'screen',
|
|
42
|
+
'screen-256color',
|
|
43
|
+
'alacritty',
|
|
44
|
+
'vte',
|
|
45
|
+
'rxvt',
|
|
46
|
+
];
|
|
47
|
+
for (const unicodeTerm of unicodeTerms) {
|
|
48
|
+
if (term.includes(unicodeTerm)) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Linux console (not linux-utf8) has limited Unicode support
|
|
53
|
+
if (term === 'linux' && !hasUtf8Locale()) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Check locale settings for UTF-8 encoding
|
|
58
|
+
if (hasUtf8Locale()) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
// Apple Terminal historically had issues, but with UTF-8 locale it should work
|
|
62
|
+
if (process.env['TERM_PROGRAM'] === 'Apple_Terminal' && !hasUtf8Locale()) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
// CI environments often support Unicode if locale is set
|
|
66
|
+
if (process.env['CI'] && hasUtf8Locale()) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
// Default to false if no Unicode indicators found
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Check if the system locale indicates UTF-8 encoding.
|
|
74
|
+
* Examines LANG and LC_ALL environment variables.
|
|
75
|
+
*
|
|
76
|
+
* @returns true if UTF-8 locale is detected
|
|
77
|
+
*/
|
|
78
|
+
function hasUtf8Locale() {
|
|
79
|
+
const lang = process.env['LANG'] || process.env['LC_ALL'] || '';
|
|
80
|
+
return /utf-?8/i.test(lang);
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { supportsUnicode } from './terminalCapabilities.js';
|
|
3
|
+
describe('terminalCapabilities', () => {
|
|
4
|
+
// Store original environment variables
|
|
5
|
+
const originalEnv = { ...process.env };
|
|
6
|
+
const originalStdout = { ...process.stdout };
|
|
7
|
+
const originalPlatform = process.platform;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// Reset environment before each test
|
|
10
|
+
vi.resetModules();
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
// Restore original environment
|
|
14
|
+
process.env = { ...originalEnv };
|
|
15
|
+
Object.assign(process.stdout, originalStdout);
|
|
16
|
+
// Restore platform if it was modified
|
|
17
|
+
Object.defineProperty(process, 'platform', {
|
|
18
|
+
value: originalPlatform,
|
|
19
|
+
writable: true,
|
|
20
|
+
configurable: true,
|
|
21
|
+
});
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
describe('supportsUnicode', () => {
|
|
25
|
+
it('should return true when TERM environment variable indicates Unicode support', () => {
|
|
26
|
+
process.env['TERM'] = 'xterm-256color';
|
|
27
|
+
expect(supportsUnicode()).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
it('should return true when TERM is set to xterm', () => {
|
|
30
|
+
process.env['TERM'] = 'xterm';
|
|
31
|
+
expect(supportsUnicode()).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
it('should return true when TERM is screen with Unicode support', () => {
|
|
34
|
+
process.env['TERM'] = 'screen-256color';
|
|
35
|
+
expect(supportsUnicode()).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
it('should return true when TERM is alacritty', () => {
|
|
38
|
+
process.env['TERM'] = 'alacritty';
|
|
39
|
+
expect(supportsUnicode()).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
it('should return true when LANG indicates UTF-8 encoding', () => {
|
|
42
|
+
delete process.env['TERM'];
|
|
43
|
+
process.env['LANG'] = 'en_US.UTF-8';
|
|
44
|
+
expect(supportsUnicode()).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
it('should return true when LC_ALL indicates UTF-8 encoding', () => {
|
|
47
|
+
delete process.env['TERM'];
|
|
48
|
+
delete process.env['LANG'];
|
|
49
|
+
process.env['LC_ALL'] = 'en_US.UTF-8';
|
|
50
|
+
expect(supportsUnicode()).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
it('should return false when TERM is dumb', () => {
|
|
53
|
+
process.env['TERM'] = 'dumb';
|
|
54
|
+
expect(supportsUnicode()).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
it('should return false when TERM is linux without Unicode support', () => {
|
|
57
|
+
process.env['TERM'] = 'linux';
|
|
58
|
+
delete process.env['LANG'];
|
|
59
|
+
delete process.env['LC_ALL'];
|
|
60
|
+
expect(supportsUnicode()).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
it('should return false when no Unicode indicators are present', () => {
|
|
63
|
+
delete process.env['TERM'];
|
|
64
|
+
delete process.env['LANG'];
|
|
65
|
+
delete process.env['LC_ALL'];
|
|
66
|
+
expect(supportsUnicode()).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
it('should return true on Windows when WT_SESSION is set (Windows Terminal)', () => {
|
|
69
|
+
Object.defineProperty(process, 'platform', {
|
|
70
|
+
value: 'win32',
|
|
71
|
+
writable: true,
|
|
72
|
+
});
|
|
73
|
+
process.env['WT_SESSION'] = 'some-session-id';
|
|
74
|
+
expect(supportsUnicode()).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
it('should return false on Windows without Windows Terminal markers', () => {
|
|
77
|
+
Object.defineProperty(process, 'platform', {
|
|
78
|
+
value: 'win32',
|
|
79
|
+
writable: true,
|
|
80
|
+
});
|
|
81
|
+
delete process.env['WT_SESSION'];
|
|
82
|
+
delete process.env['TERM'];
|
|
83
|
+
expect(supportsUnicode()).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
it('should return true when CI environment variable is set and LANG is UTF-8', () => {
|
|
86
|
+
process.env['CI'] = 'true';
|
|
87
|
+
process.env['LANG'] = 'en_US.UTF-8';
|
|
88
|
+
expect(supportsUnicode()).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
it('should return false when TERM_PROGRAM is Apple_Terminal on older macOS', () => {
|
|
91
|
+
// Apple Terminal historically had limited Unicode support
|
|
92
|
+
process.env['TERM_PROGRAM'] = 'Apple_Terminal';
|
|
93
|
+
delete process.env['LANG'];
|
|
94
|
+
delete process.env['LC_ALL'];
|
|
95
|
+
delete process.env['TERM'];
|
|
96
|
+
expect(supportsUnicode()).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
it('should handle case-insensitive UTF-8 check in LANG', () => {
|
|
99
|
+
delete process.env['TERM'];
|
|
100
|
+
process.env['LANG'] = 'en_US.utf-8'; // lowercase
|
|
101
|
+
expect(supportsUnicode()).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|