ccmanager 3.6.7 → 3.6.10

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.
@@ -18,6 +18,7 @@ const ConfigureOther = ({ onComplete }) => {
18
18
  const [customCommandDraft, setCustomCommandDraft] = useState(customCommand);
19
19
  const [timeout, setTimeout] = useState(autoApprovalConfig.timeout ?? 30);
20
20
  const [timeoutDraft, setTimeoutDraft] = useState(timeout);
21
+ const [clearHistoryOnClear, setClearHistoryOnClear] = useState(autoApprovalConfig.clearHistoryOnClear ?? false);
21
22
  // Show if inheriting from global (for project scope)
22
23
  const isInheriting = scope === 'project' && !configEditor.hasProjectOverride('autoApproval');
23
24
  useInput((input, key) => {
@@ -48,6 +49,10 @@ const ConfigureOther = ({ onComplete }) => {
48
49
  label: `⏱️ Set Timeout (${timeout}s)`,
49
50
  value: 'timeout',
50
51
  },
52
+ {
53
+ label: `Clear History on Screen Clear: ${clearHistoryOnClear ? '✅ Enabled' : '❌ Disabled'}`,
54
+ value: 'toggleClearHistory',
55
+ },
51
56
  {
52
57
  label: '💾 Save Changes',
53
58
  value: 'save',
@@ -70,11 +75,15 @@ const ConfigureOther = ({ onComplete }) => {
70
75
  setTimeoutDraft(timeout);
71
76
  setView('timeout');
72
77
  break;
78
+ case 'toggleClearHistory':
79
+ setClearHistoryOnClear(!clearHistoryOnClear);
80
+ break;
73
81
  case 'save':
74
82
  configEditor.setAutoApprovalConfig({
75
83
  enabled: autoApprovalEnabled,
76
84
  customCommand: customCommand.trim() || undefined,
77
85
  timeout,
86
+ clearHistoryOnClear,
78
87
  });
79
88
  onComplete();
80
89
  break;
@@ -104,6 +113,6 @@ const ConfigureOther = ({ onComplete }) => {
104
113
  } }));
105
114
  }
106
115
  const scopeLabel = scope === 'project' ? 'Project' : 'Global';
107
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Other & Experimental Settings (", 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: "Toggle experimental capabilities and other miscellaneous options." }) }), _jsx(CustomCommandSummary, { command: customCommand }), _jsx(SelectInput, { items: menuItems, onSelect: handleSelect, isFocused: true }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", shortcutManager.getShortcutDisplay('cancel'), " to return without saving"] }) })] }));
116
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Other & Experimental Settings (", 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: "Toggle experimental capabilities and other miscellaneous options." }) }), _jsx(CustomCommandSummary, { command: customCommand }), clearHistoryOnClear && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Clear History: When enabled, session output history is cleared when a screen clear escape sequence is detected (e.g., /clear command). This prevents excessive scrolling during session restoration." }) })), _jsx(SelectInput, { items: menuItems, onSelect: handleSelect, isFocused: true }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press ", shortcutManager.getShortcutDisplay('cancel'), " to return without saving"] }) })] }));
108
117
  };
109
118
  export default ConfigureOther;
@@ -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 (!item.value.includes('separator') &&
68
- item.value === 'worktree:post_creation') {
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
- post_creation: {
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
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Configure Post Worktree Creation Hook" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: "Command to execute after creating a new worktree:" }) }), _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" }) })] }));
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
- // Adjust initial step based on auto directory mode
17
- const [step, setStep] = useState(isAutoDirectory ? 'base-branch' : 'path');
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
- setStep('base-branch');
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
- return (this.configEditor.getShortcuts() ?? globalConfigManager.getShortcuts());
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
- return (this.configEditor.getStatusHooks() ?? globalConfigManager.getStatusHooks());
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
- return (this.configEditor.getWorktreeHooks() ??
37
- globalConfigManager.getWorktreeHooks());
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
- return (this.configEditor.getWorktreeConfig() ??
44
- globalConfigManager.getWorktreeConfig());
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
- return (this.configEditor.getCommandPresets() ??
51
- globalConfigManager.getCommandPresets());
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
- return (this.configEditor.getAutoApprovalConfig() ??
58
- globalConfigManager.getAutoApprovalConfig());
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
  });
@@ -17,6 +17,7 @@ export declare class ConfigReader implements IConfigReader {
17
17
  getConfiguration(): ConfigurationData;
18
18
  getAutoApprovalConfig(): NonNullable<ConfigurationData['autoApproval']>;
19
19
  isAutoApprovalEnabled(): boolean;
20
+ isClearHistoryOnClearEnabled(): boolean;
20
21
  getDefaultPreset(): CommandPreset;
21
22
  getSelectPresetOnStart(): boolean;
22
23
  getPresetByIdEffect(id: string): Either.Either<CommandPreset, ValidationError>;
@@ -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 > global)
13
+ // Shortcuts - returns merged value (project fields override global fields)
14
14
  getShortcuts() {
15
- return (projectConfigManager.getShortcuts() || globalConfigManager.getShortcuts());
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 > global)
22
+ // Status Hooks - returns merged value (project fields override global fields)
18
23
  getStatusHooks() {
19
- return (projectConfigManager.getStatusHooks() ||
20
- globalConfigManager.getStatusHooks());
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 > global)
31
+ // Worktree Hooks - returns merged value (project fields override global fields)
23
32
  getWorktreeHooks() {
24
- return (projectConfigManager.getWorktreeHooks() ||
25
- globalConfigManager.getWorktreeHooks());
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 > global)
40
+ // Worktree Config - returns merged value (project fields override global fields)
28
41
  getWorktreeConfig() {
29
- return (projectConfigManager.getWorktreeConfig() ||
30
- globalConfigManager.getWorktreeConfig());
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 > global)
51
+ // Command Presets - returns merged value (project fields override global fields)
33
52
  getCommandPresets() {
34
- return (projectConfigManager.getCommandPresets() ||
35
- globalConfigManager.getCommandPresets());
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,21 +68,28 @@ export class ConfigReader {
45
68
  autoApproval: this.getAutoApprovalConfig(),
46
69
  };
47
70
  }
48
- // Auto Approval Config - returns merged value (project > global)
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
- if (projectConfig) {
52
- return {
53
- ...projectConfig,
54
- timeout: projectConfig.timeout ?? 30,
55
- };
56
- }
57
- return globalConfigManager.getAutoApprovalConfig();
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() {
61
87
  return this.getAutoApprovalConfig().enabled;
62
88
  }
89
+ // Check if clear history on clear is enabled
90
+ isClearHistoryOnClearEnabled() {
91
+ return this.getAutoApprovalConfig().clearHistoryOnClear ?? false;
92
+ }
63
93
  // Command Preset methods - delegate to global config for modifications
64
94
  getDefaultPreset() {
65
95
  const presets = this.getCommandPresets();
@@ -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
+ });
@@ -63,6 +63,7 @@ class GlobalConfigManager {
63
63
  autoDirectory: false,
64
64
  copySessionData: true,
65
65
  sortByLastSession: false,
66
+ autoUseDefaultBranch: false,
66
67
  };
67
68
  }
68
69
  if (!Object.prototype.hasOwnProperty.call(this.config.worktree, 'copySessionData')) {
@@ -75,6 +76,7 @@ class GlobalConfigManager {
75
76
  this.config.autoApproval = {
76
77
  enabled: false,
77
78
  timeout: 30,
79
+ clearHistoryOnClear: false,
78
80
  };
79
81
  }
80
82
  else {
@@ -84,6 +86,9 @@ class GlobalConfigManager {
84
86
  if (!Object.prototype.hasOwnProperty.call(this.config.autoApproval, 'timeout')) {
85
87
  this.config.autoApproval.timeout = 30;
86
88
  }
89
+ if (!Object.prototype.hasOwnProperty.call(this.config.autoApproval, 'clearHistoryOnClear')) {
90
+ this.config.autoApproval.clearHistoryOnClear = false;
91
+ }
87
92
  }
88
93
  // Migrate legacy command config to presets if needed
89
94
  this.ensureDefaultPresets();
@@ -277,6 +277,13 @@ export class SessionManager extends EventEmitter {
277
277
  session.process.onData((data) => {
278
278
  // Write data to virtual terminal
279
279
  session.terminal.write(data);
280
+ // Check for screen clear escape sequence (e.g., from /clear command)
281
+ // When enabled and detected, clear the output history to prevent replaying old content on restore
282
+ // This helps avoid excessive scrolling when restoring sessions with large output history
283
+ if (configReader.isClearHistoryOnClearEnabled() &&
284
+ data.includes('\x1B[2J')) {
285
+ session.outputHistory = [];
286
+ }
280
287
  // Store in output history as Buffer
281
288
  const buffer = Buffer.from(data, 'utf8');
282
289
  session.outputHistory.push(buffer);
@@ -41,6 +41,7 @@ vi.mock('./config/configReader.js', () => ({
41
41
  getWorktreeLastOpened: vi.fn(() => ({})),
42
42
  isAutoApprovalEnabled: vi.fn(() => false),
43
43
  setAutoApprovalEnabled: vi.fn(),
44
+ isClearHistoryOnClearEnabled: vi.fn(() => false),
44
45
  },
45
46
  }));
46
47
  describe('SessionManager - State Persistence', () => {
@@ -30,6 +30,7 @@ vi.mock('./config/configReader.js', () => ({
30
30
  getWorktreeLastOpened: vi.fn(() => ({})),
31
31
  isAutoApprovalEnabled: vi.fn(() => false),
32
32
  setAutoApprovalEnabled: vi.fn(),
33
+ isClearHistoryOnClearEnabled: vi.fn(() => false),
33
34
  },
34
35
  }));
35
36
  // Mock Terminal
@@ -694,6 +695,89 @@ describe('SessionManager', () => {
694
695
  expect(session.isPrimaryCommand).toBe(false);
695
696
  });
696
697
  });
698
+ describe('clearHistoryOnClear', () => {
699
+ it('should clear output history when screen clear escape sequence is detected and setting is enabled', async () => {
700
+ // Setup
701
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
702
+ id: '1',
703
+ name: 'Main',
704
+ command: 'claude',
705
+ });
706
+ vi.mocked(configReader.isClearHistoryOnClearEnabled).mockReturnValue(true);
707
+ vi.mocked(spawn).mockReturnValue(mockPty);
708
+ // Create session
709
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
710
+ // Simulate some data output
711
+ mockPty.emit('data', 'Hello World');
712
+ mockPty.emit('data', 'More data');
713
+ // Verify output history has data
714
+ expect(session.outputHistory.length).toBe(2);
715
+ // Simulate screen clear escape sequence
716
+ mockPty.emit('data', '\x1B[2J');
717
+ // Verify output history was cleared and only contains the clear sequence
718
+ expect(session.outputHistory.length).toBe(1);
719
+ expect(session.outputHistory[0]?.toString()).toBe('\x1B[2J');
720
+ });
721
+ it('should not clear output history when screen clear escape sequence is detected but setting is disabled', async () => {
722
+ // Setup
723
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
724
+ id: '1',
725
+ name: 'Main',
726
+ command: 'claude',
727
+ });
728
+ vi.mocked(configReader.isClearHistoryOnClearEnabled).mockReturnValue(false);
729
+ vi.mocked(spawn).mockReturnValue(mockPty);
730
+ // Create session
731
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
732
+ // Simulate some data output
733
+ mockPty.emit('data', 'Hello World');
734
+ mockPty.emit('data', 'More data');
735
+ // Verify output history has data
736
+ expect(session.outputHistory.length).toBe(2);
737
+ // Simulate screen clear escape sequence
738
+ mockPty.emit('data', '\x1B[2J');
739
+ // Verify output history was NOT cleared
740
+ expect(session.outputHistory.length).toBe(3);
741
+ });
742
+ it('should not clear output history for normal data when setting is enabled', async () => {
743
+ // Setup
744
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
745
+ id: '1',
746
+ name: 'Main',
747
+ command: 'claude',
748
+ });
749
+ vi.mocked(configReader.isClearHistoryOnClearEnabled).mockReturnValue(true);
750
+ vi.mocked(spawn).mockReturnValue(mockPty);
751
+ // Create session
752
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
753
+ // Simulate normal data output without screen clear
754
+ mockPty.emit('data', 'Hello World');
755
+ mockPty.emit('data', 'More data');
756
+ mockPty.emit('data', 'Even more data');
757
+ // Verify output history contains all data
758
+ expect(session.outputHistory.length).toBe(3);
759
+ });
760
+ it('should clear history when screen clear is part of larger data chunk', async () => {
761
+ // Setup
762
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
763
+ id: '1',
764
+ name: 'Main',
765
+ command: 'claude',
766
+ });
767
+ vi.mocked(configReader.isClearHistoryOnClearEnabled).mockReturnValue(true);
768
+ vi.mocked(spawn).mockReturnValue(mockPty);
769
+ // Create session
770
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
771
+ // Simulate some data output
772
+ mockPty.emit('data', 'Hello World');
773
+ mockPty.emit('data', 'More data');
774
+ // Simulate screen clear as part of larger data chunk (e.g., from /clear command)
775
+ mockPty.emit('data', 'prefix\x1B[2Jsuffix');
776
+ // Verify output history was cleared and only contains the new chunk
777
+ expect(session.outputHistory.length).toBe(1);
778
+ expect(session.outputHistory[0]?.toString()).toBe('prefix\x1B[2Jsuffix');
779
+ });
780
+ });
697
781
  describe('static methods', () => {
698
782
  describe('getSessionCounts', () => {
699
783
  // Helper to create mock session with stateMutex
@@ -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) {
@@ -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;
@@ -111,6 +113,7 @@ export interface ConfigurationData {
111
113
  enabled: boolean;
112
114
  customCommand?: string;
113
115
  timeout?: number;
116
+ clearHistoryOnClear?: boolean;
114
117
  };
115
118
  }
116
119
  export type ConfigScope = 'project' | 'global';
@@ -118,6 +121,7 @@ export interface AutoApprovalConfig {
118
121
  enabled: boolean;
119
122
  customCommand?: string;
120
123
  timeout?: number;
124
+ clearHistoryOnClear?: boolean;
121
125
  }
122
126
  export interface ProjectConfigurationData {
123
127
  shortcuts?: ShortcutConfig;
@@ -203,7 +207,7 @@ export interface IWorktreeService {
203
207
  sortByLastSession?: boolean;
204
208
  }): import('effect').Effect.Effect<Worktree[], import('../types/errors.js').GitError, never>;
205
209
  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>;
210
+ 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
211
  deleteWorktreeEffect(worktreePath: string, options?: {
208
212
  deleteBranch?: boolean;
209
213
  }): 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.7",
3
+ "version": "3.6.10",
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.7",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "3.6.7",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "3.6.7",
47
- "@kodaikabasawa/ccmanager-linux-x64": "3.6.7",
48
- "@kodaikabasawa/ccmanager-win32-x64": "3.6.7"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "3.6.10",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "3.6.10",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "3.6.10",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "3.6.10",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "3.6.10"
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": "^7.0.1",
78
+ "react-devtools-core": "^6.1.2",
79
79
  "react-dom": "^19.2.3",
80
80
  "strip-ansi": "^7.1.0"
81
81
  }