ccmanager 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +11 -5
  2. package/dist/components/App.js +17 -3
  3. package/dist/components/App.test.js +5 -5
  4. package/dist/components/Configuration.d.ts +2 -0
  5. package/dist/components/Configuration.js +6 -2
  6. package/dist/components/ConfigureCommand.js +34 -11
  7. package/dist/components/ConfigureOther.js +18 -4
  8. package/dist/components/ConfigureOther.test.js +48 -12
  9. package/dist/components/ConfigureShortcuts.js +27 -85
  10. package/dist/components/ConfigureStatusHooks.js +19 -4
  11. package/dist/components/ConfigureStatusHooks.test.js +46 -12
  12. package/dist/components/ConfigureWorktree.js +18 -4
  13. package/dist/components/ConfigureWorktreeHooks.js +19 -4
  14. package/dist/components/ConfigureWorktreeHooks.test.js +49 -14
  15. package/dist/components/Menu.js +72 -14
  16. package/dist/components/NewWorktree.js +2 -2
  17. package/dist/components/NewWorktree.test.js +6 -6
  18. package/dist/components/PresetSelector.js +2 -2
  19. package/dist/contexts/ConfigEditorContext.d.ts +21 -0
  20. package/dist/contexts/ConfigEditorContext.js +25 -0
  21. package/dist/services/autoApprovalVerifier.js +3 -3
  22. package/dist/services/autoApprovalVerifier.test.js +2 -2
  23. package/dist/services/config/configEditor.d.ts +46 -0
  24. package/dist/services/{configurationManager.effect.test.js → config/configEditor.effect.test.js} +46 -49
  25. package/dist/services/config/configEditor.js +101 -0
  26. package/dist/services/{configurationManager.selectPresetOnStart.test.js → config/configEditor.selectPresetOnStart.test.js} +27 -19
  27. package/dist/services/{configurationManager.test.js → config/configEditor.test.js} +60 -132
  28. package/dist/services/config/configReader.d.ts +28 -0
  29. package/dist/services/config/configReader.js +95 -0
  30. package/dist/services/config/configReader.multiProject.test.d.ts +1 -0
  31. package/dist/services/config/configReader.multiProject.test.js +136 -0
  32. package/dist/services/config/globalConfigManager.d.ts +30 -0
  33. package/dist/services/config/globalConfigManager.js +216 -0
  34. package/dist/services/config/index.d.ts +13 -0
  35. package/dist/services/config/index.js +13 -0
  36. package/dist/services/config/projectConfigManager.d.ts +41 -0
  37. package/dist/services/config/projectConfigManager.js +181 -0
  38. package/dist/services/config/projectConfigManager.test.d.ts +1 -0
  39. package/dist/services/config/projectConfigManager.test.js +105 -0
  40. package/dist/services/config/testUtils.d.ts +81 -0
  41. package/dist/services/config/testUtils.js +351 -0
  42. package/dist/services/sessionManager.autoApproval.test.js +5 -5
  43. package/dist/services/sessionManager.effect.test.js +27 -18
  44. package/dist/services/sessionManager.js +17 -34
  45. package/dist/services/sessionManager.statePersistence.test.js +5 -4
  46. package/dist/services/sessionManager.test.js +52 -47
  47. package/dist/services/shortcutManager.d.ts +0 -1
  48. package/dist/services/shortcutManager.js +5 -16
  49. package/dist/services/shortcutManager.test.js +2 -2
  50. package/dist/services/worktreeService.d.ts +12 -0
  51. package/dist/services/worktreeService.js +24 -4
  52. package/dist/services/worktreeService.sort.test.js +105 -109
  53. package/dist/services/worktreeService.test.js +5 -5
  54. package/dist/types/index.d.ts +41 -7
  55. package/dist/utils/gitUtils.d.ts +8 -0
  56. package/dist/utils/gitUtils.js +32 -0
  57. package/dist/utils/hookExecutor.js +2 -2
  58. package/dist/utils/hookExecutor.test.js +8 -12
  59. package/dist/utils/worktreeUtils.test.js +0 -1
  60. package/package.json +7 -7
  61. package/dist/services/configurationManager.d.ts +0 -121
  62. package/dist/services/configurationManager.js +0 -597
  63. /package/dist/services/{configurationManager.effect.test.d.ts → config/configEditor.effect.test.d.ts} +0 -0
  64. /package/dist/services/{configurationManager.selectPresetOnStart.test.d.ts → config/configEditor.selectPresetOnStart.test.d.ts} +0 -0
  65. /package/dist/services/{configurationManager.test.d.ts → config/configEditor.test.d.ts} +0 -0
@@ -1,8 +1,9 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest';
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
2
  import { Effect } from 'effect';
3
- import { WorktreeService } from './worktreeService.js';
3
+ import { WorktreeService, setWorktreeLastOpened } from './worktreeService.js';
4
4
  import { execSync } from 'child_process';
5
- import { configurationManager } from './configurationManager.js';
5
+ // We need to keep a reference to the original Map to clear it between tests
6
+ // Module-level state needs to be reset for isolated tests
6
7
  // Mock child_process module
7
8
  vi.mock('child_process');
8
9
  // Mock fs module
@@ -15,12 +16,9 @@ vi.mock('./worktreeConfigManager.js', () => ({
15
16
  reset: vi.fn(),
16
17
  },
17
18
  }));
18
- // Mock configurationManager
19
- vi.mock('./configurationManager.js', () => ({
20
- configurationManager: {
21
- getWorktreeLastOpenedTime: vi.fn(),
22
- setWorktreeLastOpened: vi.fn(),
23
- getWorktreeLastOpened: vi.fn(() => ({})),
19
+ // Mock configReader (still needed for getWorktreeHooks in createWorktreeEffect)
20
+ vi.mock('./config/configReader.js', () => ({
21
+ configReader: {
24
22
  getWorktreeConfig: vi.fn(() => ({
25
23
  autoDirectory: false,
26
24
  copySessionData: true,
@@ -35,7 +33,12 @@ vi.mock('../utils/hookExecutor.js', () => ({
35
33
  }));
36
34
  // Get the mocked functions with proper typing
37
35
  const mockedExecSync = vi.mocked(execSync);
38
- const mockedGetWorktreeLastOpenedTime = vi.mocked(configurationManager.getWorktreeLastOpenedTime);
36
+ // Helper to clear worktree last opened state by setting all known paths to undefined time
37
+ // Since we can't clear the Map directly, we'll set timestamps to 0 for cleanup
38
+ const clearWorktreeTimestamps = () => {
39
+ // This is a workaround since we can't access the internal Map
40
+ // Tests should use unique paths or set their own timestamps
41
+ };
39
42
  describe('WorktreeService - Sorting', () => {
40
43
  let service;
41
44
  beforeEach(() => {
@@ -50,6 +53,9 @@ describe('WorktreeService - Sorting', () => {
50
53
  // Create service instance
51
54
  service = new WorktreeService('/test/repo');
52
55
  });
56
+ afterEach(() => {
57
+ clearWorktreeTimestamps();
58
+ });
53
59
  describe('getWorktreesEffect with sortByLastSession', () => {
54
60
  it('should not sort worktrees when sortByLastSession is false', async () => {
55
61
  // Setup mock git output
@@ -104,15 +110,9 @@ branch refs/heads/feature-b
104
110
  `;
105
111
  mockedExecSync.mockReturnValue(gitOutput);
106
112
  // Setup timestamps - feature-b was opened most recently, then main, then feature-a
107
- mockedGetWorktreeLastOpenedTime.mockImplementation((path) => {
108
- if (path === '/test/repo')
109
- return 2000;
110
- if (path === '/test/repo/feature-a')
111
- return 1000;
112
- if (path === '/test/repo/feature-b')
113
- return 3000;
114
- return undefined;
115
- });
113
+ setWorktreeLastOpened('/test/repo', 2000);
114
+ setWorktreeLastOpened('/test/repo/feature-a', 1000);
115
+ setWorktreeLastOpened('/test/repo/feature-b', 3000);
116
116
  // Execute
117
117
  const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
118
118
  // Verify sorted order (most recent first)
@@ -122,38 +122,33 @@ branch refs/heads/feature-b
122
122
  expect(result[2]?.path).toBe('/test/repo/feature-a'); // 1000
123
123
  });
124
124
  it('should place worktrees without timestamps at the end', async () => {
125
- // Setup mock git output
126
- const gitOutput = `worktree /test/repo
125
+ // Setup mock git output - use unique paths to avoid state pollution
126
+ const gitOutput = `worktree /test/repo-no-ts/main
127
127
  branch refs/heads/main
128
128
 
129
- worktree /test/repo/feature-a
129
+ worktree /test/repo-no-ts/feature-a
130
130
  branch refs/heads/feature-a
131
131
 
132
- worktree /test/repo/feature-b
132
+ worktree /test/repo-no-ts/feature-b
133
133
  branch refs/heads/feature-b
134
134
 
135
- worktree /test/repo/feature-c
135
+ worktree /test/repo-no-ts/feature-c
136
136
  branch refs/heads/feature-c
137
137
  `;
138
138
  mockedExecSync.mockReturnValue(gitOutput);
139
139
  // Setup timestamps - only feature-a and feature-b have timestamps
140
- mockedGetWorktreeLastOpenedTime.mockImplementation((path) => {
141
- if (path === '/test/repo/feature-a')
142
- return 1000;
143
- if (path === '/test/repo/feature-b')
144
- return 2000;
145
- // main and feature-c have no timestamps (undefined)
146
- return undefined;
147
- });
140
+ // main and feature-c have no timestamps set
141
+ setWorktreeLastOpened('/test/repo-no-ts/feature-a', 1000);
142
+ setWorktreeLastOpened('/test/repo-no-ts/feature-b', 2000);
148
143
  // Execute
149
144
  const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
150
145
  // Verify sorted order
151
146
  expect(result).toHaveLength(4);
152
- expect(result[0]?.path).toBe('/test/repo/feature-b'); // 2000
153
- expect(result[1]?.path).toBe('/test/repo/feature-a'); // 1000
147
+ expect(result[0]?.path).toBe('/test/repo-no-ts/feature-b'); // 2000
148
+ expect(result[1]?.path).toBe('/test/repo-no-ts/feature-a'); // 1000
154
149
  // main and feature-c at the end with timestamp 0 (original order preserved)
155
- expect(result[2]?.path).toBe('/test/repo'); // 0
156
- expect(result[3]?.path).toBe('/test/repo/feature-c'); // 0
150
+ expect(result[2]?.path).toBe('/test/repo-no-ts/main'); // undefined -> 0
151
+ expect(result[3]?.path).toBe('/test/repo-no-ts/feature-c'); // undefined -> 0
157
152
  });
158
153
  it('should handle empty worktree list', async () => {
159
154
  // Setup empty git output
@@ -164,158 +159,159 @@ branch refs/heads/feature-c
164
159
  expect(result).toHaveLength(0);
165
160
  });
166
161
  it('should handle single worktree', async () => {
167
- // Setup mock git output with single worktree
168
- const gitOutput = `worktree /test/repo
162
+ // Setup mock git output with single worktree - unique path
163
+ const gitOutput = `worktree /test/repo-single
169
164
  branch refs/heads/main
170
165
  `;
171
166
  mockedExecSync.mockReturnValue(gitOutput);
172
- mockedGetWorktreeLastOpenedTime.mockReturnValue(1000);
167
+ setWorktreeLastOpened('/test/repo-single', 1000);
173
168
  // Execute
174
169
  const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
175
170
  // Verify single result
176
171
  expect(result).toHaveLength(1);
177
- expect(result[0]?.path).toBe('/test/repo');
172
+ expect(result[0]?.path).toBe('/test/repo-single');
178
173
  });
179
174
  it('should maintain stable sort for worktrees with same timestamp', async () => {
180
- // Setup mock git output
181
- const gitOutput = `worktree /test/repo/feature-a
175
+ // Setup mock git output - unique paths
176
+ const gitOutput = `worktree /test/repo-stable/feature-a
182
177
  branch refs/heads/feature-a
183
178
 
184
- worktree /test/repo/feature-b
179
+ worktree /test/repo-stable/feature-b
185
180
  branch refs/heads/feature-b
186
181
 
187
- worktree /test/repo/feature-c
182
+ worktree /test/repo-stable/feature-c
188
183
  branch refs/heads/feature-c
189
184
  `;
190
185
  mockedExecSync.mockReturnValue(gitOutput);
191
186
  // All have the same timestamp
192
- mockedGetWorktreeLastOpenedTime.mockReturnValue(1000);
187
+ setWorktreeLastOpened('/test/repo-stable/feature-a', 1000);
188
+ setWorktreeLastOpened('/test/repo-stable/feature-b', 1000);
189
+ setWorktreeLastOpened('/test/repo-stable/feature-c', 1000);
193
190
  // Execute
194
191
  const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
195
192
  // Verify original order is maintained (stable sort)
196
193
  expect(result).toHaveLength(3);
197
- expect(result[0]?.path).toBe('/test/repo/feature-a');
198
- expect(result[1]?.path).toBe('/test/repo/feature-b');
199
- expect(result[2]?.path).toBe('/test/repo/feature-c');
194
+ expect(result[0]?.path).toBe('/test/repo-stable/feature-a');
195
+ expect(result[1]?.path).toBe('/test/repo-stable/feature-b');
196
+ expect(result[2]?.path).toBe('/test/repo-stable/feature-c');
200
197
  });
201
198
  it('should sort correctly with mixed timestamps including zero', async () => {
202
- // Setup mock git output
203
- const gitOutput = `worktree /test/repo/zero-timestamp
199
+ // Setup mock git output - unique paths
200
+ const gitOutput = `worktree /test/repo-zero/zero-timestamp
204
201
  branch refs/heads/zero-timestamp
205
202
 
206
- worktree /test/repo/recent
203
+ worktree /test/repo-zero/recent
207
204
  branch refs/heads/recent
208
205
 
209
- worktree /test/repo/older
206
+ worktree /test/repo-zero/older
210
207
  branch refs/heads/older
211
208
  `;
212
209
  mockedExecSync.mockReturnValue(gitOutput);
213
210
  // Setup timestamps including explicit zero
214
- mockedGetWorktreeLastOpenedTime.mockImplementation((path) => {
215
- if (path === '/test/repo/zero-timestamp')
216
- return 0;
217
- if (path === '/test/repo/recent')
218
- return 3000;
219
- if (path === '/test/repo/older')
220
- return 1000;
221
- return undefined;
222
- });
211
+ setWorktreeLastOpened('/test/repo-zero/zero-timestamp', 0);
212
+ setWorktreeLastOpened('/test/repo-zero/recent', 3000);
213
+ setWorktreeLastOpened('/test/repo-zero/older', 1000);
223
214
  // Execute
224
215
  const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
225
216
  // Verify sorted order
226
217
  expect(result).toHaveLength(3);
227
- expect(result[0]?.path).toBe('/test/repo/recent'); // 3000
228
- expect(result[1]?.path).toBe('/test/repo/older'); // 1000
229
- expect(result[2]?.path).toBe('/test/repo/zero-timestamp'); // 0
218
+ expect(result[0]?.path).toBe('/test/repo-zero/recent'); // 3000
219
+ expect(result[1]?.path).toBe('/test/repo-zero/older'); // 1000
220
+ expect(result[2]?.path).toBe('/test/repo-zero/zero-timestamp'); // 0
230
221
  });
231
222
  it('should preserve worktree properties after sorting', async () => {
232
- // Setup mock git output
233
- const gitOutput = `worktree /test/repo
223
+ // Setup mock git output - unique paths
224
+ const gitOutput = `worktree /test/repo-props
234
225
  branch refs/heads/main
235
226
  bare
236
227
 
237
- worktree /test/repo/feature-a
228
+ worktree /test/repo-props/feature-a
238
229
  branch refs/heads/feature-a
239
230
  `;
240
231
  mockedExecSync.mockReturnValue(gitOutput);
241
- mockedGetWorktreeLastOpenedTime.mockImplementation((path) => {
242
- if (path === '/test/repo')
243
- return 1000;
244
- if (path === '/test/repo/feature-a')
245
- return 2000;
246
- return undefined;
247
- });
232
+ setWorktreeLastOpened('/test/repo-props', 1000);
233
+ setWorktreeLastOpened('/test/repo-props/feature-a', 2000);
248
234
  // Execute
249
235
  const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
250
236
  // Verify properties are preserved
251
237
  expect(result).toHaveLength(2);
252
- expect(result[0]?.path).toBe('/test/repo/feature-a');
238
+ expect(result[0]?.path).toBe('/test/repo-props/feature-a');
253
239
  expect(result[0]?.branch).toBe('feature-a');
254
240
  expect(result[0]?.isMainWorktree).toBe(false);
255
- expect(result[1]?.path).toBe('/test/repo');
241
+ expect(result[1]?.path).toBe('/test/repo-props');
256
242
  expect(result[1]?.branch).toBe('main');
257
243
  expect(result[1]?.isMainWorktree).toBe(true);
258
244
  });
259
245
  it('should handle very large timestamps', async () => {
260
- // Setup mock git output
261
- const gitOutput = `worktree /test/repo/old
246
+ // Setup mock git output - unique paths
247
+ const gitOutput = `worktree /test/repo-large/old
262
248
  branch refs/heads/old
263
249
 
264
- worktree /test/repo/new
250
+ worktree /test/repo-large/new
265
251
  branch refs/heads/new
266
252
  `;
267
253
  mockedExecSync.mockReturnValue(gitOutput);
268
254
  // Use actual Date.now() values
269
255
  const now = Date.now();
270
256
  const yesterday = now - 24 * 60 * 60 * 1000;
271
- mockedGetWorktreeLastOpenedTime.mockImplementation((path) => {
272
- if (path === '/test/repo/old')
273
- return yesterday;
274
- if (path === '/test/repo/new')
275
- return now;
276
- return undefined;
277
- });
257
+ setWorktreeLastOpened('/test/repo-large/old', yesterday);
258
+ setWorktreeLastOpened('/test/repo-large/new', now);
278
259
  // Execute
279
260
  const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
280
261
  // Verify sorted order
281
262
  expect(result).toHaveLength(2);
282
- expect(result[0]?.path).toBe('/test/repo/new');
283
- expect(result[1]?.path).toBe('/test/repo/old');
263
+ expect(result[0]?.path).toBe('/test/repo-large/new');
264
+ expect(result[1]?.path).toBe('/test/repo-large/old');
284
265
  });
285
266
  });
286
267
  describe('getWorktreesEffect error handling with sorting', () => {
287
- it('should not call getWorktreeLastOpenedTime when sortByLastSession is false', async () => {
288
- // Setup mock git output
289
- const gitOutput = `worktree /test/repo
268
+ it('should sort correctly when sortByLastSession is true', async () => {
269
+ // Setup mock git output - unique paths
270
+ const gitOutput = `worktree /test/repo-sort
290
271
  branch refs/heads/main
272
+
273
+ worktree /test/repo-sort/feature-a
274
+ branch refs/heads/feature-a
275
+
276
+ worktree /test/repo-sort/feature-b
277
+ branch refs/heads/feature-b
291
278
  `;
292
279
  mockedExecSync.mockReturnValue(gitOutput);
293
- // Execute
294
- await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: false }));
295
- // Verify getWorktreeLastOpenedTime was not called
296
- expect(mockedGetWorktreeLastOpenedTime).not.toHaveBeenCalled();
280
+ // Set timestamps to verify sorting works
281
+ setWorktreeLastOpened('/test/repo-sort', 1000);
282
+ setWorktreeLastOpened('/test/repo-sort/feature-a', 3000);
283
+ setWorktreeLastOpened('/test/repo-sort/feature-b', 2000);
284
+ // Execute with sorting
285
+ const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
286
+ // Verify sorting happened correctly (implicitly means getWorktreeLastOpenedTime was called)
287
+ expect(result).toHaveLength(3);
288
+ expect(result[0]?.path).toBe('/test/repo-sort/feature-a'); // 3000
289
+ expect(result[1]?.path).toBe('/test/repo-sort/feature-b'); // 2000
290
+ expect(result[2]?.path).toBe('/test/repo-sort'); // 1000
297
291
  });
298
- it('should call getWorktreeLastOpenedTime for each worktree when sorting', async () => {
299
- // Setup mock git output
300
- const gitOutput = `worktree /test/repo
292
+ it('should not sort when sortByLastSession is false', async () => {
293
+ // Setup mock git output - unique paths
294
+ const gitOutput = `worktree /test/repo-nosort
301
295
  branch refs/heads/main
302
296
 
303
- worktree /test/repo/feature-a
297
+ worktree /test/repo-nosort/feature-a
304
298
  branch refs/heads/feature-a
305
299
 
306
- worktree /test/repo/feature-b
300
+ worktree /test/repo-nosort/feature-b
307
301
  branch refs/heads/feature-b
308
302
  `;
309
303
  mockedExecSync.mockReturnValue(gitOutput);
310
- mockedGetWorktreeLastOpenedTime.mockReturnValue(1000);
311
- // Execute
312
- await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
313
- // Verify getWorktreeLastOpenedTime was called for each worktree
314
- // Note: May be called multiple times during sort comparisons
315
- expect(mockedGetWorktreeLastOpenedTime).toHaveBeenCalled();
316
- expect(mockedGetWorktreeLastOpenedTime).toHaveBeenCalledWith('/test/repo');
317
- expect(mockedGetWorktreeLastOpenedTime).toHaveBeenCalledWith('/test/repo/feature-a');
318
- expect(mockedGetWorktreeLastOpenedTime).toHaveBeenCalledWith('/test/repo/feature-b');
304
+ // Set timestamps that would cause reordering if sorting was applied
305
+ setWorktreeLastOpened('/test/repo-nosort', 1000);
306
+ setWorktreeLastOpened('/test/repo-nosort/feature-a', 3000);
307
+ setWorktreeLastOpened('/test/repo-nosort/feature-b', 2000);
308
+ // Execute without sorting
309
+ const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: false }));
310
+ // Verify original order is preserved
311
+ expect(result).toHaveLength(3);
312
+ expect(result[0]?.path).toBe('/test/repo-nosort');
313
+ expect(result[1]?.path).toBe('/test/repo-nosort/feature-a');
314
+ expect(result[2]?.path).toBe('/test/repo-nosort/feature-b');
319
315
  });
320
316
  });
321
317
  });
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { WorktreeService } from './worktreeService.js';
3
3
  import { execSync } from 'child_process';
4
4
  import { existsSync, statSync } from 'fs';
5
- import { configurationManager } from './configurationManager.js';
5
+ import { configReader } from './config/configReader.js';
6
6
  import { Effect } from 'effect';
7
7
  import { GitError } from '../types/errors.js';
8
8
  // Mock child_process module
@@ -17,9 +17,9 @@ vi.mock('./worktreeConfigManager.js', () => ({
17
17
  reset: vi.fn(),
18
18
  },
19
19
  }));
20
- // Mock configurationManager
21
- vi.mock('./configurationManager.js', () => ({
22
- configurationManager: {
20
+ // Mock configReader
21
+ vi.mock('./config/configReader.js', () => ({
22
+ configReader: {
23
23
  getWorktreeHooks: vi.fn(),
24
24
  },
25
25
  }));
@@ -31,7 +31,7 @@ vi.mock('../utils/hookExecutor.js', () => ({
31
31
  const mockedExecSync = vi.mocked(execSync);
32
32
  const mockedExistsSync = vi.mocked(existsSync);
33
33
  const mockedStatSync = vi.mocked(statSync);
34
- const mockedGetWorktreeHooks = vi.mocked(configurationManager.getWorktreeHooks);
34
+ const mockedGetWorktreeHooks = vi.mocked(configReader.getWorktreeHooks);
35
35
  describe('WorktreeService', () => {
36
36
  let service;
37
37
  beforeEach(() => {
@@ -25,7 +25,6 @@ export interface Session {
25
25
  terminal: Terminal;
26
26
  stateCheckInterval: NodeJS.Timeout | undefined;
27
27
  isPrimaryCommand: boolean;
28
- commandConfig: CommandConfig | undefined;
29
28
  detectionStrategy: StateDetectionStrategy | undefined;
30
29
  devcontainerConfig: DevcontainerConfig | undefined;
31
30
  /**
@@ -85,11 +84,6 @@ export interface WorktreeConfig {
85
84
  copySessionData?: boolean;
86
85
  sortByLastSession?: boolean;
87
86
  }
88
- export interface CommandConfig {
89
- command: string;
90
- args?: string[];
91
- fallbackArgs?: string[];
92
- }
93
87
  export interface CommandPreset {
94
88
  id: string;
95
89
  name: string;
@@ -112,7 +106,6 @@ export interface ConfigurationData {
112
106
  statusHooks?: StatusHookConfig;
113
107
  worktreeHooks?: WorktreeHookConfig;
114
108
  worktree?: WorktreeConfig;
115
- command?: CommandConfig;
116
109
  commandPresets?: CommandPresetsConfig;
117
110
  autoApproval?: {
118
111
  enabled: boolean;
@@ -120,6 +113,47 @@ export interface ConfigurationData {
120
113
  timeout?: number;
121
114
  };
122
115
  }
116
+ export type ConfigScope = 'project' | 'global';
117
+ export interface AutoApprovalConfig {
118
+ enabled: boolean;
119
+ customCommand?: string;
120
+ timeout?: number;
121
+ }
122
+ export interface ProjectConfigurationData {
123
+ shortcuts?: ShortcutConfig;
124
+ statusHooks?: StatusHookConfig;
125
+ worktreeHooks?: WorktreeHookConfig;
126
+ worktree?: WorktreeConfig;
127
+ commandPresets?: CommandPresetsConfig;
128
+ autoApproval?: AutoApprovalConfig;
129
+ }
130
+ /**
131
+ * Common interface for configuration readers.
132
+ * Provides read-only access to configuration values.
133
+ * Implemented by ConfigReader, ConfigEditor, GlobalConfigManager, ProjectConfigManager.
134
+ */
135
+ export interface IConfigReader {
136
+ getShortcuts(): ShortcutConfig | undefined;
137
+ getStatusHooks(): StatusHookConfig | undefined;
138
+ getWorktreeHooks(): WorktreeHookConfig | undefined;
139
+ getWorktreeConfig(): WorktreeConfig | undefined;
140
+ getCommandPresets(): CommandPresetsConfig | undefined;
141
+ getAutoApprovalConfig(): AutoApprovalConfig | undefined;
142
+ reload(): void;
143
+ }
144
+ /**
145
+ * Common interface for configuration editors.
146
+ * Extends IConfigReader with write capabilities.
147
+ * Implemented by ConfigEditor, GlobalConfigManager, ProjectConfigManager.
148
+ */
149
+ export interface IConfigEditor extends IConfigReader {
150
+ setShortcuts(value: ShortcutConfig): void;
151
+ setStatusHooks(value: StatusHookConfig): void;
152
+ setWorktreeHooks(value: WorktreeHookConfig): void;
153
+ setWorktreeConfig(value: WorktreeConfig): void;
154
+ setCommandPresets(value: CommandPresetsConfig): void;
155
+ setAutoApprovalConfig(value: AutoApprovalConfig): void;
156
+ }
123
157
  export interface GitProject {
124
158
  name: string;
125
159
  path: string;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Get the git repository root path from a given directory.
3
+ * For worktrees, this returns the main repository root (parent of .git).
4
+ *
5
+ * @param cwd - The directory to start searching from
6
+ * @returns The absolute path to the git repository root, or null if not in a git repo
7
+ */
8
+ export declare function getGitRepositoryRoot(cwd: string): string | null;
@@ -0,0 +1,32 @@
1
+ import path from 'path';
2
+ import { execSync } from 'child_process';
3
+ /**
4
+ * Get the git repository root path from a given directory.
5
+ * For worktrees, this returns the main repository root (parent of .git).
6
+ *
7
+ * @param cwd - The directory to start searching from
8
+ * @returns The absolute path to the git repository root, or null if not in a git repo
9
+ */
10
+ export function getGitRepositoryRoot(cwd) {
11
+ try {
12
+ const gitCommonDir = execSync('git rev-parse --git-common-dir', {
13
+ cwd,
14
+ encoding: 'utf8',
15
+ stdio: ['pipe', 'pipe', 'pipe'],
16
+ }).trim();
17
+ const absoluteGitCommonDir = path.isAbsolute(gitCommonDir)
18
+ ? gitCommonDir
19
+ : path.resolve(cwd, gitCommonDir);
20
+ // Handle worktree paths: if path contains .git/worktrees, find the real .git parent
21
+ if (absoluteGitCommonDir.includes('.git/worktrees')) {
22
+ const gitIndex = absoluteGitCommonDir.indexOf('.git');
23
+ const gitPath = absoluteGitCommonDir.substring(0, gitIndex + 4);
24
+ return path.dirname(gitPath);
25
+ }
26
+ // For regular .git directories, the parent is the repository root
27
+ return path.dirname(absoluteGitCommonDir);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
@@ -2,7 +2,7 @@ import { spawn } from 'child_process';
2
2
  import { Effect } from 'effect';
3
3
  import { ProcessError } from '../types/errors.js';
4
4
  import { WorktreeService } from '../services/worktreeService.js';
5
- import { configurationManager } from '../services/configurationManager.js';
5
+ import { configReader } from '../services/config/configReader.js';
6
6
  /**
7
7
  * Execute a hook command with the provided environment variables using Effect
8
8
  *
@@ -106,7 +106,7 @@ export function executeWorktreePostCreationHook(command, worktree, gitRoot, base
106
106
  * Errors are caught and logged but do not break the main flow
107
107
  */
108
108
  export function executeStatusHook(oldState, newState, session) {
109
- const statusHooks = configurationManager.getStatusHooks();
109
+ const statusHooks = configReader.getStatusHooks();
110
110
  const hook = statusHooks[newState];
111
111
  if (!hook || !hook.enabled || !hook.command) {
112
112
  return Effect.void;
@@ -4,14 +4,14 @@ import { executeHook, executeWorktreePostCreationHook, executeStatusHook, } from
4
4
  import { mkdtemp, rm, readFile, realpath } from 'fs/promises';
5
5
  import { tmpdir } from 'os';
6
6
  import { join } from 'path';
7
- import { configurationManager } from '../services/configurationManager.js';
7
+ import { configReader } from '../services/config/configReader.js';
8
8
  import { WorktreeService } from '../services/worktreeService.js';
9
9
  import { GitError } from '../types/errors.js';
10
10
  import { Mutex, createInitialSessionStateData } from './mutex.js';
11
11
  import { createStateDetector } from '../services/stateDetector/index.js';
12
- // Mock the configurationManager
13
- vi.mock('../services/configurationManager.js', () => ({
14
- configurationManager: {
12
+ // Mock the configReader
13
+ vi.mock('../services/config/configReader.js', () => ({
14
+ configReader: {
15
15
  getStatusHooks: vi.fn(),
16
16
  },
17
17
  }));
@@ -270,7 +270,6 @@ describe('hookExecutor Integration Tests', () => {
270
270
  outputHistory: [],
271
271
  stateCheckInterval: undefined,
272
272
  isPrimaryCommand: true,
273
- commandConfig: undefined,
274
273
  detectionStrategy: 'claude',
275
274
  devcontainerConfig: undefined,
276
275
  lastActivity: new Date(),
@@ -292,7 +291,7 @@ describe('hookExecutor Integration Tests', () => {
292
291
  };
293
292
  });
294
293
  // Configure mock to return a hook that writes to a file with delay
295
- vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
294
+ vi.mocked(configReader.getStatusHooks).mockReturnValue({
296
295
  busy: {
297
296
  enabled: true,
298
297
  command: `sleep 0.1 && echo "Hook executed" > "${outputFile}"`,
@@ -325,7 +324,6 @@ describe('hookExecutor Integration Tests', () => {
325
324
  outputHistory: [],
326
325
  stateCheckInterval: undefined,
327
326
  isPrimaryCommand: true,
328
- commandConfig: undefined,
329
327
  detectionStrategy: 'claude',
330
328
  devcontainerConfig: undefined,
331
329
  lastActivity: new Date(),
@@ -347,7 +345,7 @@ describe('hookExecutor Integration Tests', () => {
347
345
  };
348
346
  });
349
347
  // Configure mock to return a hook that fails
350
- vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
348
+ vi.mocked(configReader.getStatusHooks).mockReturnValue({
351
349
  busy: {
352
350
  enabled: true,
353
351
  command: 'exit 1',
@@ -378,7 +376,6 @@ describe('hookExecutor Integration Tests', () => {
378
376
  outputHistory: [],
379
377
  stateCheckInterval: undefined,
380
378
  isPrimaryCommand: true,
381
- commandConfig: undefined,
382
379
  detectionStrategy: 'claude',
383
380
  devcontainerConfig: undefined,
384
381
  lastActivity: new Date(),
@@ -400,7 +397,7 @@ describe('hookExecutor Integration Tests', () => {
400
397
  };
401
398
  });
402
399
  // Configure mock to return a disabled hook
403
- vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
400
+ vi.mocked(configReader.getStatusHooks).mockReturnValue({
404
401
  busy: {
405
402
  enabled: false,
406
403
  command: `echo "Should not run" > "${outputFile}"`,
@@ -433,7 +430,6 @@ describe('hookExecutor Integration Tests', () => {
433
430
  outputHistory: [],
434
431
  stateCheckInterval: undefined,
435
432
  isPrimaryCommand: true,
436
- commandConfig: undefined,
437
433
  detectionStrategy: 'claude',
438
434
  devcontainerConfig: undefined,
439
435
  lastActivity: new Date(),
@@ -452,7 +448,7 @@ describe('hookExecutor Integration Tests', () => {
452
448
  };
453
449
  });
454
450
  // Configure mock to return a hook that should execute despite worktree query failure
455
- vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
451
+ vi.mocked(configReader.getStatusHooks).mockReturnValue({
456
452
  busy: {
457
453
  enabled: true,
458
454
  command: `echo "Hook ran with branch: $CCMANAGER_WORKTREE_BRANCH" > "${outputFile}"`,
@@ -131,7 +131,6 @@ describe('prepareWorktreeItems', () => {
131
131
  terminal: {},
132
132
  stateCheckInterval: undefined,
133
133
  isPrimaryCommand: true,
134
- commandConfig: undefined,
135
134
  detectionStrategy: 'claude',
136
135
  devcontainerConfig: undefined,
137
136
  stateMutex: new Mutex({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
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.4.0",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "3.4.0",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "3.4.0",
47
- "@kodaikabasawa/ccmanager-linux-x64": "3.4.0",
48
- "@kodaikabasawa/ccmanager-win32-x64": "3.4.0"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "3.5.0",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "3.5.0",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "3.5.0",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "3.5.0",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "3.5.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",
@@ -73,7 +73,7 @@
73
73
  "ink": "5.2.1",
74
74
  "ink-select-input": "^6.0.0",
75
75
  "ink-text-input": "^6.0.0",
76
- "meow": "^11.0.0",
76
+ "meow": "^14.0.0",
77
77
  "react": "18.3.1",
78
78
  "react-devtools-core": "^7.0.1",
79
79
  "react-dom": "18.3.1",