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.
@@ -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
  });
@@ -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,16 +68,19 @@ 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() {
@@ -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')) {
@@ -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;
@@ -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.7",
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.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.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": "^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
  }