ccmanager 3.6.7 → 3.6.9
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/ConfigureWorktree.js +9 -0
- package/dist/components/ConfigureWorktreeHooks.js +22 -4
- package/dist/components/NewWorktree.js +31 -4
- package/dist/components/NewWorktree.test.js +64 -0
- package/dist/services/config/configEditor.js +38 -10
- package/dist/services/config/configEditor.test.js +26 -0
- package/dist/services/config/configReader.js +48 -22
- package/dist/services/config/configReader.multiProject.test.js +95 -0
- package/dist/services/config/globalConfigManager.js +1 -0
- package/dist/services/stateDetector/claude.js +4 -0
- package/dist/services/stateDetector/claude.test.js +24 -0
- package/dist/services/worktreeService.d.ts +2 -2
- package/dist/services/worktreeService.js +7 -1
- package/dist/types/index.d.ts +3 -1
- package/dist/utils/hookExecutor.d.ts +12 -0
- package/dist/utils/hookExecutor.js +24 -0
- package/dist/utils/hookExecutor.test.js +113 -1
- package/package.json +7 -7
|
@@ -15,6 +15,7 @@ const ConfigureWorktree = ({ onComplete }) => {
|
|
|
15
15
|
const [pattern, setPattern] = useState(worktreeConfig.autoDirectoryPattern || '../{branch}');
|
|
16
16
|
const [copySessionData, setCopySessionData] = useState(worktreeConfig.copySessionData ?? true);
|
|
17
17
|
const [sortByLastSession, setSortByLastSession] = useState(worktreeConfig.sortByLastSession ?? false);
|
|
18
|
+
const [autoUseDefaultBranch, setAutoUseDefaultBranch] = useState(worktreeConfig.autoUseDefaultBranch ?? false);
|
|
18
19
|
const [editMode, setEditMode] = useState('menu');
|
|
19
20
|
const [tempPattern, setTempPattern] = useState(pattern);
|
|
20
21
|
// Show if inheriting from global (for project scope)
|
|
@@ -45,6 +46,10 @@ const ConfigureWorktree = ({ onComplete }) => {
|
|
|
45
46
|
label: `Sort by Last Session: ${sortByLastSession ? '✅ Enabled' : '❌ Disabled'}`,
|
|
46
47
|
value: 'toggleSort',
|
|
47
48
|
},
|
|
49
|
+
{
|
|
50
|
+
label: `Auto Use Default Branch: ${autoUseDefaultBranch ? '✅ Enabled' : '❌ Disabled'}`,
|
|
51
|
+
value: 'toggleAutoUseDefault',
|
|
52
|
+
},
|
|
48
53
|
{
|
|
49
54
|
label: '💾 Save Changes',
|
|
50
55
|
value: 'save',
|
|
@@ -69,6 +74,9 @@ const ConfigureWorktree = ({ onComplete }) => {
|
|
|
69
74
|
case 'toggleSort':
|
|
70
75
|
setSortByLastSession(!sortByLastSession);
|
|
71
76
|
break;
|
|
77
|
+
case 'toggleAutoUseDefault':
|
|
78
|
+
setAutoUseDefaultBranch(!autoUseDefaultBranch);
|
|
79
|
+
break;
|
|
72
80
|
case 'save':
|
|
73
81
|
// Save the configuration
|
|
74
82
|
configEditor.setWorktreeConfig({
|
|
@@ -76,6 +84,7 @@ const ConfigureWorktree = ({ onComplete }) => {
|
|
|
76
84
|
autoDirectoryPattern: pattern,
|
|
77
85
|
copySessionData,
|
|
78
86
|
sortByLastSession,
|
|
87
|
+
autoUseDefaultBranch,
|
|
79
88
|
});
|
|
80
89
|
onComplete();
|
|
81
90
|
break;
|
|
@@ -13,6 +13,7 @@ const ConfigureWorktreeHooks = ({ onComplete, }) => {
|
|
|
13
13
|
const [worktreeHooks, setWorktreeHooks] = useState(initialWorktreeHooks);
|
|
14
14
|
const [currentCommand, setCurrentCommand] = useState('');
|
|
15
15
|
const [currentEnabled, setCurrentEnabled] = useState(false);
|
|
16
|
+
const [currentHookType, setCurrentHookType] = useState('post_creation');
|
|
16
17
|
const [showSaveMessage, setShowSaveMessage] = useState(false);
|
|
17
18
|
// Show if inheriting from global (for project scope)
|
|
18
19
|
const isInheriting = scope === 'project' && !configEditor.hasProjectOverride('worktreeHooks');
|
|
@@ -32,6 +33,13 @@ const ConfigureWorktreeHooks = ({ onComplete, }) => {
|
|
|
32
33
|
const getMenuItems = () => {
|
|
33
34
|
const items = [];
|
|
34
35
|
// Add worktree hook items
|
|
36
|
+
const preCreationHook = worktreeHooks.pre_creation;
|
|
37
|
+
const preCreationEnabled = preCreationHook?.enabled ? '✓' : '✗';
|
|
38
|
+
const preCreationCommand = preCreationHook?.command || '(not set)';
|
|
39
|
+
items.push({
|
|
40
|
+
label: `Pre Creation: ${preCreationEnabled} ${preCreationCommand}`,
|
|
41
|
+
value: 'worktree:pre_creation',
|
|
42
|
+
});
|
|
35
43
|
const postCreationHook = worktreeHooks.post_creation;
|
|
36
44
|
const postCreationEnabled = postCreationHook?.enabled ? '✓' : '✗';
|
|
37
45
|
const postCreationCommand = postCreationHook?.command || '(not set)';
|
|
@@ -64,18 +72,25 @@ const ConfigureWorktreeHooks = ({ onComplete, }) => {
|
|
|
64
72
|
else if (item.value === 'cancel') {
|
|
65
73
|
onComplete();
|
|
66
74
|
}
|
|
67
|
-
else if (
|
|
68
|
-
|
|
75
|
+
else if (item.value === 'worktree:pre_creation') {
|
|
76
|
+
const hook = worktreeHooks.pre_creation;
|
|
77
|
+
setCurrentCommand(hook?.command || '');
|
|
78
|
+
setCurrentEnabled(hook?.enabled ?? true);
|
|
79
|
+
setCurrentHookType('pre_creation');
|
|
80
|
+
setView('edit');
|
|
81
|
+
}
|
|
82
|
+
else if (item.value === 'worktree:post_creation') {
|
|
69
83
|
const hook = worktreeHooks.post_creation;
|
|
70
84
|
setCurrentCommand(hook?.command || '');
|
|
71
85
|
setCurrentEnabled(hook?.enabled ?? true);
|
|
86
|
+
setCurrentHookType('post_creation');
|
|
72
87
|
setView('edit');
|
|
73
88
|
}
|
|
74
89
|
};
|
|
75
90
|
const handleCommandSubmit = (value) => {
|
|
76
91
|
setWorktreeHooks(prev => ({
|
|
77
92
|
...prev,
|
|
78
|
-
|
|
93
|
+
[currentHookType]: {
|
|
79
94
|
command: value,
|
|
80
95
|
enabled: currentEnabled,
|
|
81
96
|
},
|
|
@@ -89,7 +104,10 @@ const ConfigureWorktreeHooks = ({ onComplete, }) => {
|
|
|
89
104
|
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "green", children: "\u2713 Configuration saved successfully!" }) }));
|
|
90
105
|
}
|
|
91
106
|
if (view === 'edit') {
|
|
92
|
-
|
|
107
|
+
const isPreCreation = currentHookType === 'pre_creation';
|
|
108
|
+
const hookTypeLabel = isPreCreation ? 'Pre' : 'Post';
|
|
109
|
+
const timingLabel = isPreCreation ? 'before' : 'after';
|
|
110
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure ", hookTypeLabel, " Worktree Creation Hook"] }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Command to execute ", timingLabel, " creating a new worktree:"] }) }), isPreCreation && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "Note: Runs in git root directory. Failures abort worktree creation." }) })), _jsx(Box, { marginBottom: 1, children: _jsx(TextInputWrapper, { value: currentCommand, onChange: setCurrentCommand, onSubmit: handleCommandSubmit, placeholder: "Enter command (e.g., npm install && npm run build)" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Enabled: ", currentEnabled ? '✓' : '✗', " (Press Tab to toggle)"] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Environment variables available: CCMANAGER_WORKTREE_PATH, CCMANAGER_WORKTREE_BRANCH," }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "CCMANAGER_BASE_BRANCH, CCMANAGER_GIT_ROOT" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter to save, Tab to toggle enabled, Esc to cancel" }) })] }));
|
|
93
111
|
}
|
|
94
112
|
const scopeLabel = scope === 'project' ? 'Project' : 'Global';
|
|
95
113
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure Worktree Hooks (", scopeLabel, ")"] }) }), isInheriting && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { backgroundColor: "cyan", color: "black", children: [' ', "\uD83D\uDCCB Inheriting from global configuration", ' '] }) })), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Set commands to run on worktree events:" }) }), _jsx(SelectInput, { items: getMenuItems(), onSelect: handleMenuSelect, isFocused: true, limit: 10 }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Esc to go back" }) })] }));
|
|
@@ -12,9 +12,20 @@ import { Effect } from 'effect';
|
|
|
12
12
|
const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
13
13
|
const worktreeConfig = configReader.getWorktreeConfig();
|
|
14
14
|
const isAutoDirectory = worktreeConfig.autoDirectory;
|
|
15
|
+
const isAutoUseDefaultBranch = worktreeConfig.autoUseDefaultBranch ?? false;
|
|
15
16
|
const limit = 10;
|
|
16
|
-
//
|
|
17
|
-
|
|
17
|
+
// Determine initial step based on config options
|
|
18
|
+
// If autoUseDefaultBranch is enabled, we start at a temporary 'loading' state
|
|
19
|
+
// and will transition to 'branch-strategy' after branches are loaded
|
|
20
|
+
const getInitialStep = () => {
|
|
21
|
+
if (isAutoDirectory) {
|
|
22
|
+
// With autoDirectory, skip path input
|
|
23
|
+
// If autoUseDefaultBranch is also enabled, we'll skip base-branch after loading
|
|
24
|
+
return 'base-branch';
|
|
25
|
+
}
|
|
26
|
+
return 'path';
|
|
27
|
+
};
|
|
28
|
+
const [step, setStep] = useState(getInitialStep());
|
|
18
29
|
const [path, setPath] = useState('');
|
|
19
30
|
const [branch, setBranch] = useState('');
|
|
20
31
|
const [baseBranch, setBaseBranch] = useState('');
|
|
@@ -52,6 +63,14 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
52
63
|
setBranches(result.branches);
|
|
53
64
|
setDefaultBranch(result.defaultBranch);
|
|
54
65
|
setIsLoadingBranches(false);
|
|
66
|
+
// If autoUseDefaultBranch is enabled, auto-set the base branch
|
|
67
|
+
// and skip to branch-strategy step
|
|
68
|
+
if (isAutoUseDefaultBranch && result.defaultBranch) {
|
|
69
|
+
setBaseBranch(result.defaultBranch);
|
|
70
|
+
// Skip base-branch step, go directly to branch-strategy
|
|
71
|
+
// (if we're at base-branch step, which happens with autoDirectory)
|
|
72
|
+
setStep(currentStep => currentStep === 'base-branch' ? 'branch-strategy' : currentStep);
|
|
73
|
+
}
|
|
55
74
|
}
|
|
56
75
|
}
|
|
57
76
|
};
|
|
@@ -64,7 +83,7 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
64
83
|
return () => {
|
|
65
84
|
cancelled = true;
|
|
66
85
|
};
|
|
67
|
-
}, [projectPath]);
|
|
86
|
+
}, [projectPath, isAutoUseDefaultBranch]);
|
|
68
87
|
// Create branch items with default branch first (memoized)
|
|
69
88
|
const allBranchItems = useMemo(() => [
|
|
70
89
|
{ label: `${defaultBranch} (default)`, value: defaultBranch },
|
|
@@ -96,7 +115,15 @@ const NewWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
96
115
|
const handlePathSubmit = (value) => {
|
|
97
116
|
if (value.trim()) {
|
|
98
117
|
setPath(value.trim());
|
|
99
|
-
|
|
118
|
+
// If autoUseDefaultBranch is enabled and we have the default branch,
|
|
119
|
+
// skip base-branch selection and go directly to branch-strategy
|
|
120
|
+
if (isAutoUseDefaultBranch && defaultBranch) {
|
|
121
|
+
setBaseBranch(defaultBranch);
|
|
122
|
+
setStep('branch-strategy');
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
setStep('base-branch');
|
|
126
|
+
}
|
|
100
127
|
}
|
|
101
128
|
};
|
|
102
129
|
const handleBranchSubmit = (value) => {
|
|
@@ -259,4 +259,68 @@ describe('NewWorktree component Effect integration', () => {
|
|
|
259
259
|
// Verify Effect was executed (Effect.match pattern)
|
|
260
260
|
expect(effectExecuted).toBe(true);
|
|
261
261
|
});
|
|
262
|
+
it('should skip base branch selection when autoUseDefaultBranch is enabled with autoDirectory', async () => {
|
|
263
|
+
const { Effect } = await import('effect');
|
|
264
|
+
const { WorktreeService } = await import('../services/worktreeService.js');
|
|
265
|
+
const { configReader } = await import('../services/config/configReader.js');
|
|
266
|
+
// Mock config with both autoDirectory and autoUseDefaultBranch enabled
|
|
267
|
+
vi.spyOn(configReader, 'getWorktreeConfig').mockReturnValue({
|
|
268
|
+
autoDirectory: true,
|
|
269
|
+
autoDirectoryPattern: '../{project}-{branch}',
|
|
270
|
+
copySessionData: true,
|
|
271
|
+
autoUseDefaultBranch: true,
|
|
272
|
+
});
|
|
273
|
+
const mockBranches = ['main', 'feature-1', 'develop'];
|
|
274
|
+
const mockDefaultBranch = 'main';
|
|
275
|
+
// Mock WorktreeService to succeed
|
|
276
|
+
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
277
|
+
return {
|
|
278
|
+
getAllBranchesEffect: vi.fn(() => Effect.succeed(mockBranches)),
|
|
279
|
+
getDefaultBranchEffect: vi.fn(() => Effect.succeed(mockDefaultBranch)),
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
const onComplete = vi.fn();
|
|
283
|
+
const onCancel = vi.fn();
|
|
284
|
+
const { lastFrame } = render(_jsx(NewWorktree, { onComplete: onComplete, onCancel: onCancel }));
|
|
285
|
+
// Wait for Effect to execute and state to update
|
|
286
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
287
|
+
const output = lastFrame();
|
|
288
|
+
// Should skip base-branch step and show branch-strategy step
|
|
289
|
+
// which displays "Base branch:" and "Choose branch creation strategy"
|
|
290
|
+
expect(output).toContain('Create New Worktree');
|
|
291
|
+
expect(output).toContain('Base branch:');
|
|
292
|
+
expect(output).toContain('main');
|
|
293
|
+
expect(output).toContain('Choose branch creation strategy');
|
|
294
|
+
});
|
|
295
|
+
it('should show base branch selection when autoUseDefaultBranch is disabled', async () => {
|
|
296
|
+
const { Effect } = await import('effect');
|
|
297
|
+
const { WorktreeService } = await import('../services/worktreeService.js');
|
|
298
|
+
const { configReader } = await import('../services/config/configReader.js');
|
|
299
|
+
// Mock config with autoDirectory enabled but autoUseDefaultBranch disabled
|
|
300
|
+
vi.spyOn(configReader, 'getWorktreeConfig').mockReturnValue({
|
|
301
|
+
autoDirectory: true,
|
|
302
|
+
autoDirectoryPattern: '../{project}-{branch}',
|
|
303
|
+
copySessionData: true,
|
|
304
|
+
autoUseDefaultBranch: false,
|
|
305
|
+
});
|
|
306
|
+
const mockBranches = ['main', 'feature-1', 'develop'];
|
|
307
|
+
const mockDefaultBranch = 'main';
|
|
308
|
+
// Mock WorktreeService to succeed
|
|
309
|
+
vi.mocked(WorktreeService).mockImplementation(function () {
|
|
310
|
+
return {
|
|
311
|
+
getAllBranchesEffect: vi.fn(() => Effect.succeed(mockBranches)),
|
|
312
|
+
getDefaultBranchEffect: vi.fn(() => Effect.succeed(mockDefaultBranch)),
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
const onComplete = vi.fn();
|
|
316
|
+
const onCancel = vi.fn();
|
|
317
|
+
const { lastFrame } = render(_jsx(NewWorktree, { onComplete: onComplete, onCancel: onCancel }));
|
|
318
|
+
// Wait for Effect to execute
|
|
319
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
320
|
+
const output = lastFrame();
|
|
321
|
+
// Should show base-branch selection step (not branch-strategy)
|
|
322
|
+
expect(output).toContain('Create New Worktree');
|
|
323
|
+
expect(output).toContain('Select base branch');
|
|
324
|
+
expect(output).toContain('main (default)');
|
|
325
|
+
});
|
|
262
326
|
});
|
|
@@ -21,41 +21,69 @@ export class ConfigEditor {
|
|
|
21
21
|
}
|
|
22
22
|
// IConfigEditor implementation - delegates to configEditor with fallback to global
|
|
23
23
|
getShortcuts() {
|
|
24
|
-
|
|
24
|
+
const globalConfig = globalConfigManager.getShortcuts();
|
|
25
|
+
const scopedConfig = this.configEditor.getShortcuts();
|
|
26
|
+
return {
|
|
27
|
+
...globalConfig,
|
|
28
|
+
...(scopedConfig || {}),
|
|
29
|
+
};
|
|
25
30
|
}
|
|
26
31
|
setShortcuts(value) {
|
|
27
32
|
this.configEditor.setShortcuts(value);
|
|
28
33
|
}
|
|
29
34
|
getStatusHooks() {
|
|
30
|
-
|
|
35
|
+
const globalConfig = globalConfigManager.getStatusHooks();
|
|
36
|
+
const scopedConfig = this.configEditor.getStatusHooks();
|
|
37
|
+
return {
|
|
38
|
+
...globalConfig,
|
|
39
|
+
...(scopedConfig || {}),
|
|
40
|
+
};
|
|
31
41
|
}
|
|
32
42
|
setStatusHooks(value) {
|
|
33
43
|
this.configEditor.setStatusHooks(value);
|
|
34
44
|
}
|
|
35
45
|
getWorktreeHooks() {
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
const globalConfig = globalConfigManager.getWorktreeHooks();
|
|
47
|
+
const scopedConfig = this.configEditor.getWorktreeHooks();
|
|
48
|
+
return {
|
|
49
|
+
...globalConfig,
|
|
50
|
+
...(scopedConfig || {}),
|
|
51
|
+
};
|
|
38
52
|
}
|
|
39
53
|
setWorktreeHooks(value) {
|
|
40
54
|
this.configEditor.setWorktreeHooks(value);
|
|
41
55
|
}
|
|
42
56
|
getWorktreeConfig() {
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
const globalConfig = globalConfigManager.getWorktreeConfig();
|
|
58
|
+
const scopedConfig = this.configEditor.getWorktreeConfig();
|
|
59
|
+
// Merge: global config is the base, scoped config fields override
|
|
60
|
+
// This ensures explicit false values in project config take priority
|
|
61
|
+
return {
|
|
62
|
+
...globalConfig,
|
|
63
|
+
...(scopedConfig || {}),
|
|
64
|
+
};
|
|
45
65
|
}
|
|
46
66
|
setWorktreeConfig(value) {
|
|
47
67
|
this.configEditor.setWorktreeConfig(value);
|
|
48
68
|
}
|
|
49
69
|
getCommandPresets() {
|
|
50
|
-
|
|
51
|
-
|
|
70
|
+
const globalConfig = globalConfigManager.getCommandPresets();
|
|
71
|
+
const scopedConfig = this.configEditor.getCommandPresets();
|
|
72
|
+
return {
|
|
73
|
+
...globalConfig,
|
|
74
|
+
...(scopedConfig || {}),
|
|
75
|
+
};
|
|
52
76
|
}
|
|
53
77
|
setCommandPresets(value) {
|
|
54
78
|
this.configEditor.setCommandPresets(value);
|
|
55
79
|
}
|
|
56
80
|
getAutoApprovalConfig() {
|
|
57
|
-
|
|
58
|
-
|
|
81
|
+
const globalConfig = globalConfigManager.getAutoApprovalConfig();
|
|
82
|
+
const scopedConfig = this.configEditor.getAutoApprovalConfig();
|
|
83
|
+
return {
|
|
84
|
+
...globalConfig,
|
|
85
|
+
...(scopedConfig || {}),
|
|
86
|
+
};
|
|
59
87
|
}
|
|
60
88
|
setAutoApprovalConfig(value) {
|
|
61
89
|
this.configEditor.setAutoApprovalConfig(value);
|
|
@@ -238,4 +238,30 @@ describe('ConfigEditor (global scope) - Command Presets', () => {
|
|
|
238
238
|
expect(presets.defaultPresetId).toBe('1');
|
|
239
239
|
});
|
|
240
240
|
});
|
|
241
|
+
describe('getWorktreeConfig - field-level merging', () => {
|
|
242
|
+
it('should return default worktree config values', () => {
|
|
243
|
+
resetSavedConfig();
|
|
244
|
+
configEditor.reload();
|
|
245
|
+
const worktreeConfig = configEditor.getWorktreeConfig();
|
|
246
|
+
expect(worktreeConfig.autoDirectory).toBe(false);
|
|
247
|
+
expect(worktreeConfig.copySessionData).toBe(true);
|
|
248
|
+
expect(worktreeConfig.sortByLastSession).toBe(false);
|
|
249
|
+
expect(worktreeConfig.autoUseDefaultBranch).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
it('should merge worktree config from global when not overridden', () => {
|
|
252
|
+
mockConfigData.worktree = {
|
|
253
|
+
autoDirectory: true,
|
|
254
|
+
autoUseDefaultBranch: true,
|
|
255
|
+
copySessionData: false,
|
|
256
|
+
sortByLastSession: true,
|
|
257
|
+
};
|
|
258
|
+
resetSavedConfig();
|
|
259
|
+
configEditor.reload();
|
|
260
|
+
const worktreeConfig = configEditor.getWorktreeConfig();
|
|
261
|
+
expect(worktreeConfig.autoDirectory).toBe(true);
|
|
262
|
+
expect(worktreeConfig.autoUseDefaultBranch).toBe(true);
|
|
263
|
+
expect(worktreeConfig.copySessionData).toBe(false);
|
|
264
|
+
expect(worktreeConfig.sortByLastSession).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
241
267
|
});
|
|
@@ -10,29 +10,52 @@ import { projectConfigManager } from './projectConfigManager.js';
|
|
|
10
10
|
* Uses the singleton projectConfigManager (cwd-based) for project config.
|
|
11
11
|
*/
|
|
12
12
|
export class ConfigReader {
|
|
13
|
-
// Shortcuts - returns merged value (project
|
|
13
|
+
// Shortcuts - returns merged value (project fields override global fields)
|
|
14
14
|
getShortcuts() {
|
|
15
|
-
|
|
15
|
+
const globalConfig = globalConfigManager.getShortcuts();
|
|
16
|
+
const projectConfig = projectConfigManager.getShortcuts();
|
|
17
|
+
return {
|
|
18
|
+
...globalConfig,
|
|
19
|
+
...(projectConfig || {}),
|
|
20
|
+
};
|
|
16
21
|
}
|
|
17
|
-
// Status Hooks - returns merged value (project
|
|
22
|
+
// Status Hooks - returns merged value (project fields override global fields)
|
|
18
23
|
getStatusHooks() {
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
const globalConfig = globalConfigManager.getStatusHooks();
|
|
25
|
+
const projectConfig = projectConfigManager.getStatusHooks();
|
|
26
|
+
return {
|
|
27
|
+
...globalConfig,
|
|
28
|
+
...(projectConfig || {}),
|
|
29
|
+
};
|
|
21
30
|
}
|
|
22
|
-
// Worktree Hooks - returns merged value (project
|
|
31
|
+
// Worktree Hooks - returns merged value (project fields override global fields)
|
|
23
32
|
getWorktreeHooks() {
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
const globalConfig = globalConfigManager.getWorktreeHooks();
|
|
34
|
+
const projectConfig = projectConfigManager.getWorktreeHooks();
|
|
35
|
+
return {
|
|
36
|
+
...globalConfig,
|
|
37
|
+
...(projectConfig || {}),
|
|
38
|
+
};
|
|
26
39
|
}
|
|
27
|
-
// Worktree Config - returns merged value (project
|
|
40
|
+
// Worktree Config - returns merged value (project fields override global fields)
|
|
28
41
|
getWorktreeConfig() {
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
const globalConfig = globalConfigManager.getWorktreeConfig();
|
|
43
|
+
const projectConfig = projectConfigManager.getWorktreeConfig();
|
|
44
|
+
// Merge: global config is the base, project config fields override
|
|
45
|
+
// This ensures explicit false values in project config take priority
|
|
46
|
+
return {
|
|
47
|
+
...globalConfig,
|
|
48
|
+
...(projectConfig || {}),
|
|
49
|
+
};
|
|
31
50
|
}
|
|
32
|
-
// Command Presets - returns merged value (project
|
|
51
|
+
// Command Presets - returns merged value (project fields override global fields)
|
|
33
52
|
getCommandPresets() {
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
const globalConfig = globalConfigManager.getCommandPresets();
|
|
54
|
+
const projectConfig = projectConfigManager.getCommandPresets();
|
|
55
|
+
return {
|
|
56
|
+
...globalConfig,
|
|
57
|
+
...(projectConfig || {}),
|
|
58
|
+
};
|
|
36
59
|
}
|
|
37
60
|
// Get full merged configuration
|
|
38
61
|
getConfiguration() {
|
|
@@ -45,16 +68,19 @@ export class ConfigReader {
|
|
|
45
68
|
autoApproval: this.getAutoApprovalConfig(),
|
|
46
69
|
};
|
|
47
70
|
}
|
|
48
|
-
// Auto Approval Config - returns merged value (project
|
|
71
|
+
// Auto Approval Config - returns merged value (project fields override global fields)
|
|
49
72
|
getAutoApprovalConfig() {
|
|
73
|
+
const globalConfig = globalConfigManager.getAutoApprovalConfig();
|
|
50
74
|
const projectConfig = projectConfigManager.getAutoApprovalConfig();
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
75
|
+
const merged = {
|
|
76
|
+
...globalConfig,
|
|
77
|
+
...(projectConfig || {}),
|
|
78
|
+
};
|
|
79
|
+
// Ensure timeout has a default value
|
|
80
|
+
return {
|
|
81
|
+
...merged,
|
|
82
|
+
timeout: merged.timeout ?? 30,
|
|
83
|
+
};
|
|
58
84
|
}
|
|
59
85
|
// Check if auto-approval is enabled
|
|
60
86
|
isAutoApprovalEnabled() {
|
|
@@ -134,3 +134,98 @@ describe('ConfigReader in multi-project mode', () => {
|
|
|
134
134
|
expect(worktreeConfig.copySessionData).toBe(true);
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
|
+
describe('ConfigReader - worktree config field-level merging', () => {
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
vi.clearAllMocks();
|
|
140
|
+
vi.resetModules();
|
|
141
|
+
// Ensure multi-project mode is NOT set
|
|
142
|
+
delete process.env[ENV_VARS.MULTI_PROJECT_ROOT];
|
|
143
|
+
mkdirSync.mockImplementation(() => { });
|
|
144
|
+
writeFileSync.mockImplementation(() => { });
|
|
145
|
+
});
|
|
146
|
+
afterEach(() => {
|
|
147
|
+
vi.resetAllMocks();
|
|
148
|
+
});
|
|
149
|
+
it('should allow project config to override global autoUseDefaultBranch with false', async () => {
|
|
150
|
+
// Global config has autoUseDefaultBranch: true
|
|
151
|
+
const globalConfig = {
|
|
152
|
+
worktree: {
|
|
153
|
+
autoDirectory: true,
|
|
154
|
+
autoUseDefaultBranch: true,
|
|
155
|
+
copySessionData: true,
|
|
156
|
+
sortByLastSession: false,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
// Project config explicitly sets autoUseDefaultBranch: false
|
|
160
|
+
const projectConfig = {
|
|
161
|
+
worktree: {
|
|
162
|
+
autoDirectory: true,
|
|
163
|
+
autoUseDefaultBranch: false,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
existsSync.mockImplementation((path) => {
|
|
167
|
+
return path.includes('.ccmanager.json') || path.includes('config.json');
|
|
168
|
+
});
|
|
169
|
+
readFileSync.mockImplementation((path) => {
|
|
170
|
+
if (path.includes('.ccmanager.json')) {
|
|
171
|
+
return JSON.stringify(projectConfig);
|
|
172
|
+
}
|
|
173
|
+
if (path.includes('config.json')) {
|
|
174
|
+
return JSON.stringify(globalConfig);
|
|
175
|
+
}
|
|
176
|
+
return '{}';
|
|
177
|
+
});
|
|
178
|
+
const { ConfigReader } = await import('./configReader.js');
|
|
179
|
+
const reader = new ConfigReader();
|
|
180
|
+
reader.reload();
|
|
181
|
+
const worktreeConfig = reader.getWorktreeConfig();
|
|
182
|
+
// Project's explicit false should override global's true
|
|
183
|
+
expect(worktreeConfig.autoUseDefaultBranch).toBe(false);
|
|
184
|
+
// Other fields should be merged
|
|
185
|
+
expect(worktreeConfig.autoDirectory).toBe(true);
|
|
186
|
+
// Fields only in global should be inherited
|
|
187
|
+
expect(worktreeConfig.copySessionData).toBe(true);
|
|
188
|
+
expect(worktreeConfig.sortByLastSession).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
it('should inherit global values for fields not set in project config', async () => {
|
|
191
|
+
// Global config has various settings
|
|
192
|
+
const globalConfig = {
|
|
193
|
+
worktree: {
|
|
194
|
+
autoDirectory: false,
|
|
195
|
+
autoUseDefaultBranch: true,
|
|
196
|
+
copySessionData: false,
|
|
197
|
+
sortByLastSession: true,
|
|
198
|
+
autoDirectoryPattern: '../{branch}',
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
// Project config only overrides autoDirectory
|
|
202
|
+
const projectConfig = {
|
|
203
|
+
worktree: {
|
|
204
|
+
autoDirectory: true,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
existsSync.mockImplementation((path) => {
|
|
208
|
+
return path.includes('.ccmanager.json') || path.includes('config.json');
|
|
209
|
+
});
|
|
210
|
+
readFileSync.mockImplementation((path) => {
|
|
211
|
+
if (path.includes('.ccmanager.json')) {
|
|
212
|
+
return JSON.stringify(projectConfig);
|
|
213
|
+
}
|
|
214
|
+
if (path.includes('config.json')) {
|
|
215
|
+
return JSON.stringify(globalConfig);
|
|
216
|
+
}
|
|
217
|
+
return '{}';
|
|
218
|
+
});
|
|
219
|
+
const { ConfigReader } = await import('./configReader.js');
|
|
220
|
+
const reader = new ConfigReader();
|
|
221
|
+
reader.reload();
|
|
222
|
+
const worktreeConfig = reader.getWorktreeConfig();
|
|
223
|
+
// Project override
|
|
224
|
+
expect(worktreeConfig.autoDirectory).toBe(true);
|
|
225
|
+
// Inherited from global
|
|
226
|
+
expect(worktreeConfig.autoUseDefaultBranch).toBe(true);
|
|
227
|
+
expect(worktreeConfig.copySessionData).toBe(false);
|
|
228
|
+
expect(worktreeConfig.sortByLastSession).toBe(true);
|
|
229
|
+
expect(worktreeConfig.autoDirectoryPattern).toBe('../{branch}');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -7,6 +7,10 @@ export class ClaudeStateDetector extends BaseStateDetector {
|
|
|
7
7
|
if (lowerContent.includes('ctrl+r to toggle')) {
|
|
8
8
|
return currentState;
|
|
9
9
|
}
|
|
10
|
+
// Check for search prompt (⌕ Search…) - always idle
|
|
11
|
+
if (content.includes('⌕ Search…')) {
|
|
12
|
+
return 'idle';
|
|
13
|
+
}
|
|
10
14
|
// Check for "Do you want" or "Would you like" pattern with options
|
|
11
15
|
// Handles both simple ("Do you want...\nYes") and complex (numbered options) formats
|
|
12
16
|
if (/(?:do you want|would you like).+\n+[\s\S]*?(?:yes|❯)/.test(lowerContent)) {
|
|
@@ -273,6 +273,30 @@ describe('ClaudeStateDetector', () => {
|
|
|
273
273
|
// Assert - Should detect waiting_input from viewport
|
|
274
274
|
expect(state).toBe('waiting_input');
|
|
275
275
|
});
|
|
276
|
+
it('should detect idle when "⌕ Search…" is present', () => {
|
|
277
|
+
// Arrange - Search prompt should always be idle
|
|
278
|
+
terminal = createMockTerminal(['⌕ Search…', 'Some content']);
|
|
279
|
+
// Act
|
|
280
|
+
const state = detector.detectState(terminal, 'busy');
|
|
281
|
+
// Assert
|
|
282
|
+
expect(state).toBe('idle');
|
|
283
|
+
});
|
|
284
|
+
it('should detect idle when "⌕ Search…" is present even with "esc to cancel"', () => {
|
|
285
|
+
// Arrange
|
|
286
|
+
terminal = createMockTerminal(['⌕ Search…', 'esc to cancel']);
|
|
287
|
+
// Act
|
|
288
|
+
const state = detector.detectState(terminal, 'idle');
|
|
289
|
+
// Assert - Should be idle because search prompt takes precedence
|
|
290
|
+
expect(state).toBe('idle');
|
|
291
|
+
});
|
|
292
|
+
it('should detect idle when "⌕ Search…" is present even with "esc to interrupt"', () => {
|
|
293
|
+
// Arrange
|
|
294
|
+
terminal = createMockTerminal(['⌕ Search…', 'Press esc to interrupt']);
|
|
295
|
+
// Act
|
|
296
|
+
const state = detector.detectState(terminal, 'idle');
|
|
297
|
+
// Assert - Should be idle because search prompt takes precedence
|
|
298
|
+
expect(state).toBe('idle');
|
|
299
|
+
});
|
|
276
300
|
});
|
|
277
301
|
describe('detectBackgroundTask', () => {
|
|
278
302
|
it('should return count 1 when "1 background task" is in status bar', () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect } from 'effect';
|
|
2
2
|
import { Worktree } from '../types/index.js';
|
|
3
|
-
import { GitError, FileSystemError } from '../types/errors.js';
|
|
3
|
+
import { GitError, FileSystemError, ProcessError } from '../types/errors.js';
|
|
4
4
|
/**
|
|
5
5
|
* Get all worktree last opened timestamps
|
|
6
6
|
*/
|
|
@@ -326,7 +326,7 @@ export declare class WorktreeService {
|
|
|
326
326
|
* @throws {GitError} When git worktree add command fails
|
|
327
327
|
* @throws {FileSystemError} When session data copy fails
|
|
328
328
|
*/
|
|
329
|
-
createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Effect.Effect<Worktree, GitError | FileSystemError, never>;
|
|
329
|
+
createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Effect.Effect<Worktree, GitError | FileSystemError | ProcessError, never>;
|
|
330
330
|
/**
|
|
331
331
|
* Effect-based deleteWorktree operation
|
|
332
332
|
* May fail with GitError
|
|
@@ -6,7 +6,7 @@ import { AmbiguousBranchError, } from '../types/index.js';
|
|
|
6
6
|
import { GitError, FileSystemError } from '../types/errors.js';
|
|
7
7
|
import { setWorktreeParentBranch } from '../utils/worktreeConfig.js';
|
|
8
8
|
import { getClaudeProjectsDir, pathToClaudeProjectName, } from '../utils/claudeDir.js';
|
|
9
|
-
import { executeWorktreePostCreationHook } from '../utils/hookExecutor.js';
|
|
9
|
+
import { executeWorktreePostCreationHook, executeWorktreePreCreationHook, } from '../utils/hookExecutor.js';
|
|
10
10
|
import { configReader } from './config/configReader.js';
|
|
11
11
|
const CLAUDE_DIR = '.claude';
|
|
12
12
|
// Module-level state for worktree last opened tracking (runtime state, not persisted)
|
|
@@ -776,6 +776,12 @@ export class WorktreeService {
|
|
|
776
776
|
},
|
|
777
777
|
catch: (error) => error,
|
|
778
778
|
}), () => Effect.succeed(false));
|
|
779
|
+
// Execute pre-creation hook if configured (BEFORE git worktree add)
|
|
780
|
+
const worktreeHooksConfig = configReader.getWorktreeHooks();
|
|
781
|
+
if (worktreeHooksConfig.pre_creation?.enabled &&
|
|
782
|
+
worktreeHooksConfig.pre_creation?.command) {
|
|
783
|
+
yield* executeWorktreePreCreationHook(worktreeHooksConfig.pre_creation.command, resolvedPath, branch, absoluteGitRoot, baseBranch);
|
|
784
|
+
}
|
|
779
785
|
// Create the worktree command
|
|
780
786
|
let command;
|
|
781
787
|
if (branchExists) {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -76,6 +76,7 @@ export interface WorktreeHook {
|
|
|
76
76
|
enabled: boolean;
|
|
77
77
|
}
|
|
78
78
|
export interface WorktreeHookConfig {
|
|
79
|
+
pre_creation?: WorktreeHook;
|
|
79
80
|
post_creation?: WorktreeHook;
|
|
80
81
|
}
|
|
81
82
|
export interface WorktreeConfig {
|
|
@@ -83,6 +84,7 @@ export interface WorktreeConfig {
|
|
|
83
84
|
autoDirectoryPattern?: string;
|
|
84
85
|
copySessionData?: boolean;
|
|
85
86
|
sortByLastSession?: boolean;
|
|
87
|
+
autoUseDefaultBranch?: boolean;
|
|
86
88
|
}
|
|
87
89
|
export interface CommandPreset {
|
|
88
90
|
id: string;
|
|
@@ -203,7 +205,7 @@ export interface IWorktreeService {
|
|
|
203
205
|
sortByLastSession?: boolean;
|
|
204
206
|
}): import('effect').Effect.Effect<Worktree[], import('../types/errors.js').GitError, never>;
|
|
205
207
|
getGitRootPath(): string;
|
|
206
|
-
createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): import('effect').Effect.Effect<Worktree, import('../types/errors.js').GitError | import('../types/errors.js').FileSystemError, never>;
|
|
208
|
+
createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): import('effect').Effect.Effect<Worktree, import('../types/errors.js').GitError | import('../types/errors.js').FileSystemError | import('../types/errors.js').ProcessError, never>;
|
|
207
209
|
deleteWorktreeEffect(worktreePath: string, options?: {
|
|
208
210
|
deleteBranch?: boolean;
|
|
209
211
|
}): import('effect').Effect.Effect<void, import('../types/errors.js').GitError, never>;
|
|
@@ -43,6 +43,18 @@ export interface HookEnvironment {
|
|
|
43
43
|
* ```
|
|
44
44
|
*/
|
|
45
45
|
export declare function executeHook(command: string, cwd: string, environment: HookEnvironment): Effect.Effect<void, ProcessError>;
|
|
46
|
+
/**
|
|
47
|
+
* Execute a worktree pre-creation hook using Effect
|
|
48
|
+
* Errors propagate to abort worktree creation - this is intentional
|
|
49
|
+
*
|
|
50
|
+
* @param {string} command - Shell command to execute
|
|
51
|
+
* @param {string} worktreePath - Path where the worktree will be created (doesn't exist yet)
|
|
52
|
+
* @param {string} branch - Branch name for the new worktree
|
|
53
|
+
* @param {string} gitRoot - Git repository root (working directory for the hook)
|
|
54
|
+
* @param {string} baseBranch - Optional base branch the worktree is created from
|
|
55
|
+
* @returns {Effect.Effect<void, ProcessError>} Effect that succeeds on hook completion or fails with ProcessError
|
|
56
|
+
*/
|
|
57
|
+
export declare function executeWorktreePreCreationHook(command: string, worktreePath: string, branch: string, gitRoot: string, baseBranch?: string): Effect.Effect<void, ProcessError>;
|
|
46
58
|
/**
|
|
47
59
|
* Execute a worktree post-creation hook using Effect
|
|
48
60
|
* Errors are caught and logged but do not break the main flow
|
|
@@ -82,6 +82,30 @@ export function executeHook(command, cwd, environment) {
|
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Execute a worktree pre-creation hook using Effect
|
|
87
|
+
* Errors propagate to abort worktree creation - this is intentional
|
|
88
|
+
*
|
|
89
|
+
* @param {string} command - Shell command to execute
|
|
90
|
+
* @param {string} worktreePath - Path where the worktree will be created (doesn't exist yet)
|
|
91
|
+
* @param {string} branch - Branch name for the new worktree
|
|
92
|
+
* @param {string} gitRoot - Git repository root (working directory for the hook)
|
|
93
|
+
* @param {string} baseBranch - Optional base branch the worktree is created from
|
|
94
|
+
* @returns {Effect.Effect<void, ProcessError>} Effect that succeeds on hook completion or fails with ProcessError
|
|
95
|
+
*/
|
|
96
|
+
export function executeWorktreePreCreationHook(command, worktreePath, branch, gitRoot, baseBranch) {
|
|
97
|
+
const environment = {
|
|
98
|
+
CCMANAGER_WORKTREE_PATH: worktreePath,
|
|
99
|
+
CCMANAGER_WORKTREE_BRANCH: branch,
|
|
100
|
+
CCMANAGER_GIT_ROOT: gitRoot,
|
|
101
|
+
};
|
|
102
|
+
if (baseBranch) {
|
|
103
|
+
environment.CCMANAGER_BASE_BRANCH = baseBranch;
|
|
104
|
+
}
|
|
105
|
+
// Execute in git root (worktree doesn't exist yet)
|
|
106
|
+
// NO Effect.catchAll - errors must propagate to abort creation
|
|
107
|
+
return executeHook(command, gitRoot, environment);
|
|
108
|
+
}
|
|
85
109
|
/**
|
|
86
110
|
* Execute a worktree post-creation hook using Effect
|
|
87
111
|
* Errors are caught and logged but do not break the main flow
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { Effect } from 'effect';
|
|
3
|
-
import { executeHook, executeWorktreePostCreationHook, executeStatusHook, } from './hookExecutor.js';
|
|
3
|
+
import { executeHook, executeWorktreePreCreationHook, executeWorktreePostCreationHook, executeStatusHook, } from './hookExecutor.js';
|
|
4
4
|
import { mkdtemp, rm, readFile, realpath } from 'fs/promises';
|
|
5
5
|
import { tmpdir } from 'os';
|
|
6
6
|
import { join } from 'path';
|
|
@@ -256,6 +256,118 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
256
256
|
}
|
|
257
257
|
});
|
|
258
258
|
});
|
|
259
|
+
describe('executeWorktreePreCreationHook (real execution)', () => {
|
|
260
|
+
it('should execute successfully when command succeeds', async () => {
|
|
261
|
+
// Arrange
|
|
262
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pre-hook-test-'));
|
|
263
|
+
const worktreePath = join(tmpDir, 'new-worktree');
|
|
264
|
+
const gitRoot = tmpDir;
|
|
265
|
+
const branch = 'feature-branch';
|
|
266
|
+
try {
|
|
267
|
+
// Act & Assert - should not throw
|
|
268
|
+
await expect(Effect.runPromise(executeWorktreePreCreationHook('echo "Pre-creation hook executed"', worktreePath, branch, gitRoot, 'main'))).resolves.toBeUndefined();
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
// Cleanup
|
|
272
|
+
await rm(tmpDir, { recursive: true });
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
it('should propagate ProcessError when command fails (not caught)', async () => {
|
|
276
|
+
// Arrange
|
|
277
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pre-hook-test-'));
|
|
278
|
+
const worktreePath = join(tmpDir, 'new-worktree');
|
|
279
|
+
const gitRoot = tmpDir;
|
|
280
|
+
const branch = 'feature-branch';
|
|
281
|
+
try {
|
|
282
|
+
// Act & Assert - should throw (unlike post-creation hook)
|
|
283
|
+
await expect(Effect.runPromise(executeWorktreePreCreationHook('exit 1', worktreePath, branch, gitRoot, 'main'))).rejects.toThrow();
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
// Cleanup
|
|
287
|
+
await rm(tmpDir, { recursive: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
it('should execute in git root directory (not worktree path)', async () => {
|
|
291
|
+
// Arrange
|
|
292
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pre-hook-cwd-test-'));
|
|
293
|
+
const worktreePath = join(tmpDir, 'new-worktree', 'does-not-exist');
|
|
294
|
+
const gitRoot = tmpDir;
|
|
295
|
+
const branch = 'feature-branch';
|
|
296
|
+
const outputFile = join(tmpDir, 'cwd.txt');
|
|
297
|
+
try {
|
|
298
|
+
// Act - write current directory to file
|
|
299
|
+
await Effect.runPromise(executeWorktreePreCreationHook(`pwd > "${outputFile}"`, worktreePath, branch, gitRoot, 'main'));
|
|
300
|
+
// Read the output
|
|
301
|
+
const output = await readFile(outputFile, 'utf-8');
|
|
302
|
+
// Assert - should be executed in git root, not worktree path
|
|
303
|
+
const expectedPath = await realpath(gitRoot);
|
|
304
|
+
const actualPath = await realpath(output.trim());
|
|
305
|
+
expect(actualPath).toBe(expectedPath);
|
|
306
|
+
// Also verify it's not the worktree path
|
|
307
|
+
expect(output.trim()).not.toBe(worktreePath);
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
// Cleanup
|
|
311
|
+
await rm(tmpDir, { recursive: true });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
it('should pass environment variables correctly', async () => {
|
|
315
|
+
// Arrange
|
|
316
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pre-hook-env-test-'));
|
|
317
|
+
const worktreePath = join(tmpDir, 'new-worktree');
|
|
318
|
+
const gitRoot = tmpDir;
|
|
319
|
+
const branch = 'feature-branch';
|
|
320
|
+
const baseBranch = 'main';
|
|
321
|
+
const outputFile = join(tmpDir, 'env.txt');
|
|
322
|
+
try {
|
|
323
|
+
// Act - write environment variables to file
|
|
324
|
+
await Effect.runPromise(executeWorktreePreCreationHook(`echo "PATH:$CCMANAGER_WORKTREE_PATH|BRANCH:$CCMANAGER_WORKTREE_BRANCH|ROOT:$CCMANAGER_GIT_ROOT|BASE:$CCMANAGER_BASE_BRANCH" > "${outputFile}"`, worktreePath, branch, gitRoot, baseBranch));
|
|
325
|
+
// Read the output
|
|
326
|
+
const output = await readFile(outputFile, 'utf-8');
|
|
327
|
+
// Assert - environment variables should be set correctly
|
|
328
|
+
expect(output.trim()).toBe(`PATH:${worktreePath}|BRANCH:${branch}|ROOT:${gitRoot}|BASE:${baseBranch}`);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
// Cleanup
|
|
332
|
+
await rm(tmpDir, { recursive: true });
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
it('should include stderr in error message on failure', async () => {
|
|
336
|
+
// Arrange
|
|
337
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pre-hook-stderr-test-'));
|
|
338
|
+
const worktreePath = join(tmpDir, 'new-worktree');
|
|
339
|
+
const gitRoot = tmpDir;
|
|
340
|
+
const branch = 'feature-branch';
|
|
341
|
+
try {
|
|
342
|
+
// Act & Assert - should include stderr in error
|
|
343
|
+
await expect(Effect.runPromise(executeWorktreePreCreationHook('>&2 echo "Pre-creation validation failed"; exit 1', worktreePath, branch, gitRoot, 'main'))).rejects.toThrow('Hook exited with code 1\nStderr: Pre-creation validation failed\n');
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
// Cleanup
|
|
347
|
+
await rm(tmpDir, { recursive: true });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
it('should work without baseBranch parameter', async () => {
|
|
351
|
+
// Arrange
|
|
352
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pre-hook-no-base-test-'));
|
|
353
|
+
const worktreePath = join(tmpDir, 'new-worktree');
|
|
354
|
+
const gitRoot = tmpDir;
|
|
355
|
+
const branch = 'feature-branch';
|
|
356
|
+
const outputFile = join(tmpDir, 'env.txt');
|
|
357
|
+
try {
|
|
358
|
+
// Act - write environment variables to file (without baseBranch)
|
|
359
|
+
await Effect.runPromise(executeWorktreePreCreationHook(`echo "BASE:$CCMANAGER_BASE_BRANCH" > "${outputFile}"`, worktreePath, branch, gitRoot));
|
|
360
|
+
// Read the output
|
|
361
|
+
const output = await readFile(outputFile, 'utf-8');
|
|
362
|
+
// Assert - base branch should be empty
|
|
363
|
+
expect(output.trim()).toBe('BASE:');
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
// Cleanup
|
|
367
|
+
await rm(tmpDir, { recursive: true });
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
});
|
|
259
371
|
describe('executeStatusHook', () => {
|
|
260
372
|
it('should wait for hook execution to complete', async () => {
|
|
261
373
|
// Arrange
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "3.6.
|
|
3
|
+
"version": "3.6.9",
|
|
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": "3.6.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "3.6.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "3.6.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "3.6.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "3.6.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "3.6.9",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "3.6.9",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "3.6.9",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "3.6.9",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "3.6.9"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"ink-text-input": "^6.0.0",
|
|
76
76
|
"meow": "^14.0.0",
|
|
77
77
|
"react": "^19.2.3",
|
|
78
|
-
"react-devtools-core": "^
|
|
78
|
+
"react-devtools-core": "^6.1.2",
|
|
79
79
|
"react-dom": "^19.2.3",
|
|
80
80
|
"strip-ansi": "^7.1.0"
|
|
81
81
|
}
|