ccmanager 2.1.0 → 2.2.1
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/README.md +18 -0
- package/dist/components/App.js +1 -1
- package/dist/components/Configuration.js +21 -7
- package/dist/components/ConfigureStatusHooks.d.ts +6 -0
- package/dist/components/{ConfigureHooks.js → ConfigureStatusHooks.js} +16 -18
- package/dist/components/ConfigureStatusHooks.test.d.ts +1 -0
- package/dist/components/ConfigureStatusHooks.test.js +62 -0
- package/dist/components/ConfigureWorktreeHooks.d.ts +6 -0
- package/dist/components/ConfigureWorktreeHooks.js +114 -0
- package/dist/components/ConfigureWorktreeHooks.test.d.ts +1 -0
- package/dist/components/ConfigureWorktreeHooks.test.js +60 -0
- package/dist/constants/statePersistence.d.ts +2 -0
- package/dist/constants/statePersistence.js +4 -0
- package/dist/services/configurationManager.d.ts +3 -1
- package/dist/services/configurationManager.js +10 -0
- package/dist/services/projectManager.test.js +8 -9
- package/dist/services/sessionManager.d.ts +0 -1
- package/dist/services/sessionManager.js +40 -39
- package/dist/services/sessionManager.statePersistence.test.d.ts +1 -0
- package/dist/services/sessionManager.statePersistence.test.js +215 -0
- package/dist/services/worktreeService.d.ts +2 -2
- package/dist/services/worktreeService.js +18 -1
- package/dist/services/worktreeService.test.js +162 -7
- package/dist/types/index.d.ts +17 -7
- package/dist/utils/hookExecutor.d.ts +20 -0
- package/dist/utils/hookExecutor.js +96 -0
- package/dist/utils/hookExecutor.test.d.ts +1 -0
- package/dist/utils/hookExecutor.test.js +405 -0
- package/dist/utils/worktreeUtils.test.js +7 -0
- package/package.json +1 -1
- package/dist/components/ConfigureHooks.d.ts +0 -6
package/dist/types/index.d.ts
CHANGED
|
@@ -22,11 +22,13 @@ export interface Session {
|
|
|
22
22
|
lastActivity: Date;
|
|
23
23
|
isActive: boolean;
|
|
24
24
|
terminal: Terminal;
|
|
25
|
-
stateCheckInterval
|
|
26
|
-
isPrimaryCommand
|
|
27
|
-
commandConfig
|
|
28
|
-
detectionStrategy
|
|
29
|
-
devcontainerConfig
|
|
25
|
+
stateCheckInterval: NodeJS.Timeout | undefined;
|
|
26
|
+
isPrimaryCommand: boolean;
|
|
27
|
+
commandConfig: CommandConfig | undefined;
|
|
28
|
+
detectionStrategy: StateDetectionStrategy | undefined;
|
|
29
|
+
devcontainerConfig: DevcontainerConfig | undefined;
|
|
30
|
+
pendingState: SessionState | undefined;
|
|
31
|
+
pendingStateStart: number | undefined;
|
|
30
32
|
}
|
|
31
33
|
export interface SessionManager {
|
|
32
34
|
sessions: Map<string, Session>;
|
|
@@ -54,6 +56,13 @@ export interface StatusHookConfig {
|
|
|
54
56
|
busy?: StatusHook;
|
|
55
57
|
waiting_input?: StatusHook;
|
|
56
58
|
}
|
|
59
|
+
export interface WorktreeHook {
|
|
60
|
+
command: string;
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface WorktreeHookConfig {
|
|
64
|
+
post_creation?: WorktreeHook;
|
|
65
|
+
}
|
|
57
66
|
export interface WorktreeConfig {
|
|
58
67
|
autoDirectory: boolean;
|
|
59
68
|
autoDirectoryPattern?: string;
|
|
@@ -84,6 +93,7 @@ export interface DevcontainerConfig {
|
|
|
84
93
|
export interface ConfigurationData {
|
|
85
94
|
shortcuts?: ShortcutConfig;
|
|
86
95
|
statusHooks?: StatusHookConfig;
|
|
96
|
+
worktreeHooks?: WorktreeHookConfig;
|
|
87
97
|
worktree?: WorktreeConfig;
|
|
88
98
|
command?: CommandConfig;
|
|
89
99
|
commandPresets?: CommandPresetsConfig;
|
|
@@ -126,10 +136,10 @@ export interface IProjectManager {
|
|
|
126
136
|
export interface IWorktreeService {
|
|
127
137
|
getWorktrees(): Worktree[];
|
|
128
138
|
getGitRootPath(): string;
|
|
129
|
-
createWorktree(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): {
|
|
139
|
+
createWorktree(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Promise<{
|
|
130
140
|
success: boolean;
|
|
131
141
|
error?: string;
|
|
132
|
-
}
|
|
142
|
+
}>;
|
|
133
143
|
deleteWorktree(worktreePath: string, options?: {
|
|
134
144
|
deleteBranch?: boolean;
|
|
135
145
|
}): {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Worktree, Session, SessionState } from '../types/index.js';
|
|
2
|
+
export interface HookEnvironment {
|
|
3
|
+
CCMANAGER_WORKTREE_PATH: string;
|
|
4
|
+
CCMANAGER_WORKTREE_BRANCH: string;
|
|
5
|
+
CCMANAGER_GIT_ROOT: string;
|
|
6
|
+
CCMANAGER_BASE_BRANCH?: string;
|
|
7
|
+
[key: string]: string | undefined;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Execute a hook command with the provided environment variables
|
|
11
|
+
*/
|
|
12
|
+
export declare function executeHook(command: string, cwd: string, environment: HookEnvironment): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Execute a worktree post-creation hook
|
|
15
|
+
*/
|
|
16
|
+
export declare function executeWorktreePostCreationHook(command: string, worktree: Worktree, gitRoot: string, baseBranch?: string): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Execute a session status change hook
|
|
19
|
+
*/
|
|
20
|
+
export declare function executeStatusHook(oldState: SessionState, newState: SessionState, session: Session): Promise<void>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { WorktreeService } from '../services/worktreeService.js';
|
|
3
|
+
import { configurationManager } from '../services/configurationManager.js';
|
|
4
|
+
/**
|
|
5
|
+
* Execute a hook command with the provided environment variables
|
|
6
|
+
*/
|
|
7
|
+
export function executeHook(command, cwd, environment) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
// Use spawn with shell to execute the command and wait for all child processes
|
|
10
|
+
const child = spawn(command, [], {
|
|
11
|
+
cwd,
|
|
12
|
+
env: {
|
|
13
|
+
...process.env,
|
|
14
|
+
...environment,
|
|
15
|
+
},
|
|
16
|
+
shell: true,
|
|
17
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
18
|
+
});
|
|
19
|
+
let stderr = '';
|
|
20
|
+
// Collect stderr for logging
|
|
21
|
+
child.stderr?.on('data', data => {
|
|
22
|
+
stderr += data.toString();
|
|
23
|
+
});
|
|
24
|
+
// Wait for the process and all its children to exit
|
|
25
|
+
child.on('exit', (code, signal) => {
|
|
26
|
+
if (code !== 0 || signal) {
|
|
27
|
+
let errorMessage = signal
|
|
28
|
+
? `Hook terminated by signal ${signal}`
|
|
29
|
+
: `Hook exited with code ${code}`;
|
|
30
|
+
// Include stderr in the error message when exit code is not 0
|
|
31
|
+
if (stderr) {
|
|
32
|
+
errorMessage += `\nStderr: ${stderr}`;
|
|
33
|
+
}
|
|
34
|
+
reject(new Error(errorMessage));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// When exit code is 0, ignore stderr and resolve successfully
|
|
38
|
+
resolve();
|
|
39
|
+
});
|
|
40
|
+
// Handle errors in spawning the process
|
|
41
|
+
child.on('error', error => {
|
|
42
|
+
reject(error);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Execute a worktree post-creation hook
|
|
48
|
+
*/
|
|
49
|
+
export async function executeWorktreePostCreationHook(command, worktree, gitRoot, baseBranch) {
|
|
50
|
+
const environment = {
|
|
51
|
+
CCMANAGER_WORKTREE_PATH: worktree.path,
|
|
52
|
+
CCMANAGER_WORKTREE_BRANCH: worktree.branch || 'unknown',
|
|
53
|
+
CCMANAGER_GIT_ROOT: gitRoot,
|
|
54
|
+
};
|
|
55
|
+
if (baseBranch) {
|
|
56
|
+
environment.CCMANAGER_BASE_BRANCH = baseBranch;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
await executeHook(command, worktree.path, environment);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
// Log error but don't throw - hooks should not break the main flow
|
|
63
|
+
console.error(`Failed to execute post-creation hook: ${error instanceof Error ? error.message : String(error)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Execute a session status change hook
|
|
68
|
+
*/
|
|
69
|
+
export async function executeStatusHook(oldState, newState, session) {
|
|
70
|
+
const statusHooks = configurationManager.getStatusHooks();
|
|
71
|
+
const hook = statusHooks[newState];
|
|
72
|
+
if (hook && hook.enabled && hook.command) {
|
|
73
|
+
// Get branch information
|
|
74
|
+
const worktreeService = new WorktreeService();
|
|
75
|
+
const worktrees = worktreeService.getWorktrees();
|
|
76
|
+
const worktree = worktrees.find(wt => wt.path === session.worktreePath);
|
|
77
|
+
const branch = worktree?.branch || 'unknown';
|
|
78
|
+
// Build environment for status hook
|
|
79
|
+
const environment = {
|
|
80
|
+
CCMANAGER_WORKTREE_PATH: session.worktreePath,
|
|
81
|
+
CCMANAGER_WORKTREE_BRANCH: branch,
|
|
82
|
+
CCMANAGER_GIT_ROOT: session.worktreePath, // For status hooks, we use worktree path as cwd
|
|
83
|
+
CCMANAGER_OLD_STATE: oldState,
|
|
84
|
+
CCMANAGER_NEW_STATE: newState,
|
|
85
|
+
CCMANAGER_SESSION_ID: session.id,
|
|
86
|
+
};
|
|
87
|
+
// Execute the hook command in the session's worktree directory
|
|
88
|
+
try {
|
|
89
|
+
await executeHook(hook.command, session.worktreePath, environment);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
// Log error but don't throw - hooks should not break the main flow
|
|
93
|
+
console.error(`Failed to execute ${newState} hook: ${error instanceof Error ? error.message : String(error)}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { executeHook, executeWorktreePostCreationHook, executeStatusHook, } from './hookExecutor.js';
|
|
3
|
+
import { mkdtemp, rm, readFile } from 'fs/promises';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { configurationManager } from '../services/configurationManager.js';
|
|
7
|
+
import { WorktreeService } from '../services/worktreeService.js';
|
|
8
|
+
// Mock the configurationManager
|
|
9
|
+
vi.mock('../services/configurationManager.js', () => ({
|
|
10
|
+
configurationManager: {
|
|
11
|
+
getStatusHooks: vi.fn(),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
// Mock the WorktreeService
|
|
15
|
+
vi.mock('../services/worktreeService.js', () => ({
|
|
16
|
+
WorktreeService: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
// Note: This file contains integration tests that execute real commands
|
|
19
|
+
describe('hookExecutor Integration Tests', () => {
|
|
20
|
+
describe('executeHook (real execution)', () => {
|
|
21
|
+
it('should execute a simple echo command', async () => {
|
|
22
|
+
// Arrange
|
|
23
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
|
|
24
|
+
const environment = {
|
|
25
|
+
CCMANAGER_WORKTREE_PATH: tmpDir,
|
|
26
|
+
CCMANAGER_WORKTREE_BRANCH: 'test-branch',
|
|
27
|
+
CCMANAGER_GIT_ROOT: tmpDir,
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
// Act & Assert - should not throw
|
|
31
|
+
await expect(executeHook('echo "Test successful"', tmpDir, environment)).resolves.toBeUndefined();
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
// Cleanup
|
|
35
|
+
await rm(tmpDir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it('should reject when command fails', async () => {
|
|
39
|
+
// Arrange
|
|
40
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
|
|
41
|
+
const environment = {
|
|
42
|
+
CCMANAGER_WORKTREE_PATH: tmpDir,
|
|
43
|
+
CCMANAGER_WORKTREE_BRANCH: 'test-branch',
|
|
44
|
+
CCMANAGER_GIT_ROOT: tmpDir,
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
// Act & Assert
|
|
48
|
+
await expect(executeHook('exit 1', tmpDir, environment)).rejects.toThrow();
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
// Cleanup
|
|
52
|
+
await rm(tmpDir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
it('should include stderr in error message when command fails', async () => {
|
|
56
|
+
// Arrange
|
|
57
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
|
|
58
|
+
const environment = {
|
|
59
|
+
CCMANAGER_WORKTREE_PATH: tmpDir,
|
|
60
|
+
CCMANAGER_WORKTREE_BRANCH: 'test-branch',
|
|
61
|
+
CCMANAGER_GIT_ROOT: tmpDir,
|
|
62
|
+
};
|
|
63
|
+
try {
|
|
64
|
+
// 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');
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
// Cleanup
|
|
69
|
+
await rm(tmpDir, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
it('should verify stderr handling in error messages', async () => {
|
|
73
|
+
// Arrange
|
|
74
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
|
|
75
|
+
const environment = {
|
|
76
|
+
CCMANAGER_WORKTREE_PATH: tmpDir,
|
|
77
|
+
CCMANAGER_WORKTREE_BRANCH: 'test-branch',
|
|
78
|
+
CCMANAGER_GIT_ROOT: tmpDir,
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
// Test with multiline stderr
|
|
82
|
+
try {
|
|
83
|
+
await executeHook('>&2 echo "Line 1"; >&2 echo "Line 2"; exit 3', tmpDir, environment);
|
|
84
|
+
expect.fail('Should have thrown');
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
expect(error).toBeInstanceOf(Error);
|
|
88
|
+
expect(error.message).toContain('Hook exited with code 3');
|
|
89
|
+
expect(error.message).toContain('Stderr: Line 1\nLine 2');
|
|
90
|
+
}
|
|
91
|
+
// Test with empty stderr
|
|
92
|
+
try {
|
|
93
|
+
await executeHook('exit 4', tmpDir, environment);
|
|
94
|
+
expect.fail('Should have thrown');
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
expect(error).toBeInstanceOf(Error);
|
|
98
|
+
expect(error.message).toBe('Hook exited with code 4');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
// Cleanup
|
|
103
|
+
await rm(tmpDir, { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
it('should ignore stderr when command succeeds', async () => {
|
|
107
|
+
// Arrange
|
|
108
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
|
|
109
|
+
const environment = {
|
|
110
|
+
CCMANAGER_WORKTREE_PATH: tmpDir,
|
|
111
|
+
CCMANAGER_WORKTREE_BRANCH: 'test-branch',
|
|
112
|
+
CCMANAGER_GIT_ROOT: tmpDir,
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
// Act - command that writes to stderr but exits successfully
|
|
116
|
+
// Should not throw even though there's stderr output
|
|
117
|
+
await expect(executeHook('>&2 echo "Warning message"; exit 0', tmpDir, environment)).resolves.toBeUndefined();
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
// Cleanup
|
|
121
|
+
await rm(tmpDir, { recursive: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
it('should execute hook in the specified working directory', async () => {
|
|
125
|
+
// Arrange
|
|
126
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-cwd-test-'));
|
|
127
|
+
const outputFile = join(tmpDir, 'cwd.txt');
|
|
128
|
+
const environment = {
|
|
129
|
+
CCMANAGER_WORKTREE_PATH: tmpDir,
|
|
130
|
+
CCMANAGER_WORKTREE_BRANCH: 'test-branch',
|
|
131
|
+
CCMANAGER_GIT_ROOT: '/some/other/path',
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
// Act - write current directory to file
|
|
135
|
+
await executeHook(`pwd > "${outputFile}"`, tmpDir, environment);
|
|
136
|
+
// Read the output
|
|
137
|
+
const { readFile } = await import('fs/promises');
|
|
138
|
+
const output = await readFile(outputFile, 'utf-8');
|
|
139
|
+
// Assert - should be executed in tmpDir
|
|
140
|
+
expect(output.trim()).toBe(tmpDir);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
// Cleanup
|
|
144
|
+
await rm(tmpDir, { recursive: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe('executeWorktreePostCreationHook (real execution)', () => {
|
|
149
|
+
it('should not throw even when command fails', async () => {
|
|
150
|
+
// Arrange
|
|
151
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
|
|
152
|
+
const worktree = {
|
|
153
|
+
path: tmpDir,
|
|
154
|
+
branch: 'test-branch',
|
|
155
|
+
isMainWorktree: false,
|
|
156
|
+
hasSession: false,
|
|
157
|
+
};
|
|
158
|
+
try {
|
|
159
|
+
// Act & Assert - should not throw even with failing command
|
|
160
|
+
await expect(executeWorktreePostCreationHook('exit 1', worktree, tmpDir, 'main')).resolves.toBeUndefined();
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
// Cleanup
|
|
164
|
+
await rm(tmpDir, { recursive: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
it('should execute worktree hook in the worktree path by default', async () => {
|
|
168
|
+
// Arrange
|
|
169
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-worktree-test-'));
|
|
170
|
+
const outputFile = join(tmpDir, 'cwd.txt');
|
|
171
|
+
const worktree = {
|
|
172
|
+
path: tmpDir,
|
|
173
|
+
branch: 'test-branch',
|
|
174
|
+
isMainWorktree: false,
|
|
175
|
+
hasSession: false,
|
|
176
|
+
};
|
|
177
|
+
const gitRoot = '/different/git/root';
|
|
178
|
+
try {
|
|
179
|
+
// Act - write current directory to file
|
|
180
|
+
await executeWorktreePostCreationHook(`pwd > "${outputFile}"`, worktree, gitRoot, 'main');
|
|
181
|
+
// Read the output
|
|
182
|
+
const { readFile } = await import('fs/promises');
|
|
183
|
+
const output = await readFile(outputFile, 'utf-8');
|
|
184
|
+
// Assert - should be executed in worktree path, not git root
|
|
185
|
+
expect(output.trim()).toBe(tmpDir);
|
|
186
|
+
expect(output.trim()).not.toBe(gitRoot);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
// Cleanup
|
|
190
|
+
await rm(tmpDir, { recursive: true });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
it('should allow changing to git root using environment variable', async () => {
|
|
194
|
+
// Arrange
|
|
195
|
+
const tmpWorktreeDir = await mkdtemp(join(tmpdir(), 'hook-worktree-'));
|
|
196
|
+
const tmpGitRootDir = await mkdtemp(join(tmpdir(), 'hook-gitroot-'));
|
|
197
|
+
const outputFile = join(tmpWorktreeDir, 'gitroot.txt');
|
|
198
|
+
const worktree = {
|
|
199
|
+
path: tmpWorktreeDir,
|
|
200
|
+
branch: 'test-branch',
|
|
201
|
+
isMainWorktree: false,
|
|
202
|
+
hasSession: false,
|
|
203
|
+
};
|
|
204
|
+
try {
|
|
205
|
+
// Act - change to git root and write its path
|
|
206
|
+
await executeWorktreePostCreationHook(`cd "$CCMANAGER_GIT_ROOT" && pwd > "${outputFile}"`, worktree, tmpGitRootDir, 'main');
|
|
207
|
+
// Read the output
|
|
208
|
+
const { readFile } = await import('fs/promises');
|
|
209
|
+
const output = await readFile(outputFile, 'utf-8');
|
|
210
|
+
// Assert - should have changed to git root
|
|
211
|
+
expect(output.trim()).toBe(tmpGitRootDir);
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
// Cleanup
|
|
215
|
+
await rm(tmpWorktreeDir, { recursive: true });
|
|
216
|
+
await rm(tmpGitRootDir, { recursive: true });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
it('should wait for all child processes to complete', async () => {
|
|
220
|
+
// Arrange
|
|
221
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'hook-wait-test-'));
|
|
222
|
+
const outputFile = join(tmpDir, 'delayed.txt');
|
|
223
|
+
const worktree = {
|
|
224
|
+
path: tmpDir,
|
|
225
|
+
branch: 'test-branch',
|
|
226
|
+
isMainWorktree: false,
|
|
227
|
+
hasSession: false,
|
|
228
|
+
};
|
|
229
|
+
try {
|
|
230
|
+
// Act - execute a command that spawns a background process with a delay
|
|
231
|
+
// The background process writes to a file after a delay
|
|
232
|
+
// We use a shell command that creates a background process and then exits
|
|
233
|
+
await executeWorktreePostCreationHook(`(sleep 0.1 && echo "completed" > "${outputFile}") & wait`, worktree, tmpDir, 'main');
|
|
234
|
+
// Read the output - this should exist because we waited for the background process
|
|
235
|
+
const { readFile } = await import('fs/promises');
|
|
236
|
+
const output = await readFile(outputFile, 'utf-8');
|
|
237
|
+
// Assert - the file should contain the expected content
|
|
238
|
+
expect(output.trim()).toBe('completed');
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
// Cleanup
|
|
242
|
+
await rm(tmpDir, { recursive: true });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
describe('executeStatusHook', () => {
|
|
247
|
+
it('should wait for hook execution to complete', async () => {
|
|
248
|
+
// Arrange
|
|
249
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'status-hook-test-'));
|
|
250
|
+
const outputFile = join(tmpDir, 'status-hook-output.txt');
|
|
251
|
+
const mockSession = {
|
|
252
|
+
id: 'test-session-123',
|
|
253
|
+
worktreePath: tmpDir, // Use tmpDir as the worktree path
|
|
254
|
+
process: {},
|
|
255
|
+
terminal: {},
|
|
256
|
+
output: [],
|
|
257
|
+
outputHistory: [],
|
|
258
|
+
state: 'idle',
|
|
259
|
+
stateCheckInterval: undefined,
|
|
260
|
+
isPrimaryCommand: true,
|
|
261
|
+
commandConfig: undefined,
|
|
262
|
+
detectionStrategy: 'claude',
|
|
263
|
+
devcontainerConfig: undefined,
|
|
264
|
+
pendingState: undefined,
|
|
265
|
+
pendingStateStart: undefined,
|
|
266
|
+
lastActivity: new Date(),
|
|
267
|
+
isActive: true,
|
|
268
|
+
};
|
|
269
|
+
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
270
|
+
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
271
|
+
getWorktrees: vi.fn(() => [
|
|
272
|
+
{
|
|
273
|
+
path: tmpDir,
|
|
274
|
+
branch: 'test-branch',
|
|
275
|
+
isMainWorktree: false,
|
|
276
|
+
hasSession: true,
|
|
277
|
+
},
|
|
278
|
+
]),
|
|
279
|
+
}));
|
|
280
|
+
// Configure mock to return a hook that writes to a file with delay
|
|
281
|
+
vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
|
|
282
|
+
busy: {
|
|
283
|
+
enabled: true,
|
|
284
|
+
command: `sleep 0.1 && echo "Hook executed" > "${outputFile}"`,
|
|
285
|
+
},
|
|
286
|
+
idle: { enabled: false, command: '' },
|
|
287
|
+
waiting_input: { enabled: false, command: '' },
|
|
288
|
+
});
|
|
289
|
+
try {
|
|
290
|
+
// Act - execute the hook and await it
|
|
291
|
+
await executeStatusHook('idle', 'busy', mockSession);
|
|
292
|
+
// Assert - file should exist because we awaited the hook
|
|
293
|
+
const content = await readFile(outputFile, 'utf-8');
|
|
294
|
+
expect(content.trim()).toBe('Hook executed');
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
// Cleanup
|
|
298
|
+
await rm(tmpDir, { recursive: true });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
it('should handle hook execution errors gracefully', async () => {
|
|
302
|
+
// Arrange
|
|
303
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'status-hook-test-'));
|
|
304
|
+
const mockSession = {
|
|
305
|
+
id: 'test-session-456',
|
|
306
|
+
worktreePath: tmpDir, // Use tmpDir as the worktree path
|
|
307
|
+
process: {},
|
|
308
|
+
terminal: {},
|
|
309
|
+
output: [],
|
|
310
|
+
outputHistory: [],
|
|
311
|
+
state: 'idle',
|
|
312
|
+
stateCheckInterval: undefined,
|
|
313
|
+
isPrimaryCommand: true,
|
|
314
|
+
commandConfig: undefined,
|
|
315
|
+
detectionStrategy: 'claude',
|
|
316
|
+
devcontainerConfig: undefined,
|
|
317
|
+
pendingState: undefined,
|
|
318
|
+
pendingStateStart: undefined,
|
|
319
|
+
lastActivity: new Date(),
|
|
320
|
+
isActive: true,
|
|
321
|
+
};
|
|
322
|
+
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
323
|
+
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
324
|
+
getWorktrees: vi.fn(() => [
|
|
325
|
+
{
|
|
326
|
+
path: tmpDir,
|
|
327
|
+
branch: 'test-branch',
|
|
328
|
+
isMainWorktree: false,
|
|
329
|
+
hasSession: true,
|
|
330
|
+
},
|
|
331
|
+
]),
|
|
332
|
+
}));
|
|
333
|
+
// Configure mock to return a hook that fails
|
|
334
|
+
vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
|
|
335
|
+
busy: {
|
|
336
|
+
enabled: true,
|
|
337
|
+
command: 'exit 1',
|
|
338
|
+
},
|
|
339
|
+
idle: { enabled: false, command: '' },
|
|
340
|
+
waiting_input: { enabled: false, command: '' },
|
|
341
|
+
});
|
|
342
|
+
try {
|
|
343
|
+
// Act & Assert - should not throw even when hook fails
|
|
344
|
+
await expect(executeStatusHook('idle', 'busy', mockSession)).resolves.toBeUndefined();
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
// Cleanup
|
|
348
|
+
await rm(tmpDir, { recursive: true });
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
it('should not execute disabled hooks', async () => {
|
|
352
|
+
// Arrange
|
|
353
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'status-hook-test-'));
|
|
354
|
+
const outputFile = join(tmpDir, 'should-not-exist.txt');
|
|
355
|
+
const mockSession = {
|
|
356
|
+
id: 'test-session-789',
|
|
357
|
+
worktreePath: tmpDir, // Use tmpDir as the worktree path
|
|
358
|
+
process: {},
|
|
359
|
+
terminal: {},
|
|
360
|
+
output: [],
|
|
361
|
+
outputHistory: [],
|
|
362
|
+
state: 'idle',
|
|
363
|
+
stateCheckInterval: undefined,
|
|
364
|
+
isPrimaryCommand: true,
|
|
365
|
+
commandConfig: undefined,
|
|
366
|
+
detectionStrategy: 'claude',
|
|
367
|
+
devcontainerConfig: undefined,
|
|
368
|
+
pendingState: undefined,
|
|
369
|
+
pendingStateStart: undefined,
|
|
370
|
+
lastActivity: new Date(),
|
|
371
|
+
isActive: true,
|
|
372
|
+
};
|
|
373
|
+
// Mock WorktreeService to return a worktree with the tmpDir path
|
|
374
|
+
vi.mocked(WorktreeService).mockImplementation(() => ({
|
|
375
|
+
getWorktrees: vi.fn(() => [
|
|
376
|
+
{
|
|
377
|
+
path: tmpDir,
|
|
378
|
+
branch: 'test-branch',
|
|
379
|
+
isMainWorktree: false,
|
|
380
|
+
hasSession: true,
|
|
381
|
+
},
|
|
382
|
+
]),
|
|
383
|
+
}));
|
|
384
|
+
// Configure mock to return a disabled hook
|
|
385
|
+
vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
|
|
386
|
+
busy: {
|
|
387
|
+
enabled: false,
|
|
388
|
+
command: `echo "Should not run" > "${outputFile}"`,
|
|
389
|
+
},
|
|
390
|
+
idle: { enabled: false, command: '' },
|
|
391
|
+
waiting_input: { enabled: false, command: '' },
|
|
392
|
+
});
|
|
393
|
+
try {
|
|
394
|
+
// Act
|
|
395
|
+
await executeStatusHook('idle', 'busy', mockSession);
|
|
396
|
+
// Assert - file should not exist because hook was disabled
|
|
397
|
+
await expect(readFile(outputFile, 'utf-8')).rejects.toThrow();
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
// Cleanup
|
|
401
|
+
await rm(tmpDir, { recursive: true });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
});
|
|
@@ -105,6 +105,13 @@ describe('prepareWorktreeItems', () => {
|
|
|
105
105
|
lastActivity: new Date(),
|
|
106
106
|
isActive: true,
|
|
107
107
|
terminal: {},
|
|
108
|
+
stateCheckInterval: undefined,
|
|
109
|
+
isPrimaryCommand: true,
|
|
110
|
+
commandConfig: undefined,
|
|
111
|
+
detectionStrategy: 'claude',
|
|
112
|
+
devcontainerConfig: undefined,
|
|
113
|
+
pendingState: undefined,
|
|
114
|
+
pendingStateStart: undefined,
|
|
108
115
|
};
|
|
109
116
|
it('should prepare basic worktree without git status', () => {
|
|
110
117
|
const items = prepareWorktreeItems([mockWorktree], []);
|
package/package.json
CHANGED