ccmanager 3.3.2 → 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 (92) 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/Menu.recent-projects.test.js +2 -0
  17. package/dist/components/Menu.test.js +2 -0
  18. package/dist/components/NewWorktree.js +2 -2
  19. package/dist/components/NewWorktree.test.js +6 -6
  20. package/dist/components/PresetSelector.js +2 -2
  21. package/dist/constants/statusIcons.d.ts +4 -1
  22. package/dist/constants/statusIcons.js +10 -1
  23. package/dist/constants/statusIcons.test.js +42 -0
  24. package/dist/contexts/ConfigEditorContext.d.ts +21 -0
  25. package/dist/contexts/ConfigEditorContext.js +25 -0
  26. package/dist/services/autoApprovalVerifier.js +3 -3
  27. package/dist/services/autoApprovalVerifier.test.js +2 -2
  28. package/dist/services/config/configEditor.d.ts +46 -0
  29. package/dist/services/{configurationManager.effect.test.js → config/configEditor.effect.test.js} +46 -49
  30. package/dist/services/config/configEditor.js +101 -0
  31. package/dist/services/{configurationManager.selectPresetOnStart.test.js → config/configEditor.selectPresetOnStart.test.js} +27 -19
  32. package/dist/services/config/configEditor.test.d.ts +1 -0
  33. package/dist/services/{configurationManager.test.js → config/configEditor.test.js} +60 -132
  34. package/dist/services/config/configReader.d.ts +28 -0
  35. package/dist/services/config/configReader.js +95 -0
  36. package/dist/services/config/configReader.multiProject.test.d.ts +1 -0
  37. package/dist/services/config/configReader.multiProject.test.js +136 -0
  38. package/dist/services/config/globalConfigManager.d.ts +30 -0
  39. package/dist/services/config/globalConfigManager.js +216 -0
  40. package/dist/services/config/index.d.ts +13 -0
  41. package/dist/services/config/index.js +13 -0
  42. package/dist/services/config/projectConfigManager.d.ts +41 -0
  43. package/dist/services/config/projectConfigManager.js +181 -0
  44. package/dist/services/config/projectConfigManager.test.d.ts +1 -0
  45. package/dist/services/config/projectConfigManager.test.js +105 -0
  46. package/dist/services/config/testUtils.d.ts +81 -0
  47. package/dist/services/config/testUtils.js +351 -0
  48. package/dist/services/sessionManager.autoApproval.test.js +9 -6
  49. package/dist/services/sessionManager.d.ts +2 -0
  50. package/dist/services/sessionManager.effect.test.js +27 -18
  51. package/dist/services/sessionManager.js +43 -40
  52. package/dist/services/sessionManager.statePersistence.test.js +5 -4
  53. package/dist/services/sessionManager.test.js +71 -49
  54. package/dist/services/shortcutManager.d.ts +0 -1
  55. package/dist/services/shortcutManager.js +5 -16
  56. package/dist/services/shortcutManager.test.js +2 -2
  57. package/dist/services/stateDetector/base.d.ts +1 -0
  58. package/dist/services/stateDetector/claude.d.ts +1 -0
  59. package/dist/services/stateDetector/claude.js +8 -0
  60. package/dist/services/stateDetector/claude.test.js +102 -0
  61. package/dist/services/stateDetector/cline.d.ts +1 -0
  62. package/dist/services/stateDetector/cline.js +3 -0
  63. package/dist/services/stateDetector/codex.d.ts +1 -0
  64. package/dist/services/stateDetector/codex.js +3 -0
  65. package/dist/services/stateDetector/cursor.d.ts +1 -0
  66. package/dist/services/stateDetector/cursor.js +3 -0
  67. package/dist/services/stateDetector/gemini.d.ts +1 -0
  68. package/dist/services/stateDetector/gemini.js +3 -0
  69. package/dist/services/stateDetector/github-copilot.d.ts +1 -0
  70. package/dist/services/stateDetector/github-copilot.js +3 -0
  71. package/dist/services/stateDetector/opencode.d.ts +1 -0
  72. package/dist/services/stateDetector/opencode.js +3 -0
  73. package/dist/services/stateDetector/types.d.ts +1 -0
  74. package/dist/services/worktreeService.d.ts +12 -0
  75. package/dist/services/worktreeService.js +24 -4
  76. package/dist/services/worktreeService.sort.test.js +105 -109
  77. package/dist/services/worktreeService.test.js +5 -5
  78. package/dist/types/index.d.ts +47 -7
  79. package/dist/utils/gitUtils.d.ts +8 -0
  80. package/dist/utils/gitUtils.js +32 -0
  81. package/dist/utils/hookExecutor.js +2 -2
  82. package/dist/utils/hookExecutor.test.js +13 -12
  83. package/dist/utils/mutex.d.ts +1 -0
  84. package/dist/utils/mutex.js +1 -0
  85. package/dist/utils/worktreeUtils.js +3 -2
  86. package/dist/utils/worktreeUtils.test.js +2 -1
  87. package/package.json +7 -7
  88. package/dist/services/configurationManager.d.ts +0 -121
  89. package/dist/services/configurationManager.js +0 -597
  90. /package/dist/{services/configurationManager.effect.test.d.ts → constants/statusIcons.test.d.ts} +0 -0
  91. /package/dist/services/{configurationManager.selectPresetOnStart.test.d.ts → config/configEditor.effect.test.d.ts} +0 -0
  92. /package/dist/services/{configurationManager.test.d.ts → config/configEditor.selectPresetOnStart.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(() => {
@@ -2,6 +2,7 @@ import type { IPty } from '../services/bunTerminal.js';
2
2
  import type pkg from '@xterm/headless';
3
3
  import { GitStatus } from '../utils/gitStatus.js';
4
4
  import { Mutex, SessionStateData } from '../utils/mutex.js';
5
+ import type { StateDetector } from '../services/stateDetector/types.js';
5
6
  export type Terminal = InstanceType<typeof pkg.Terminal>;
6
7
  export type SessionState = 'idle' | 'busy' | 'waiting_input' | 'pending_auto_approval';
7
8
  export type StateDetectionStrategy = 'claude' | 'gemini' | 'codex' | 'cursor' | 'github-copilot' | 'cline' | 'opencode';
@@ -24,7 +25,6 @@ export interface Session {
24
25
  terminal: Terminal;
25
26
  stateCheckInterval: NodeJS.Timeout | undefined;
26
27
  isPrimaryCommand: boolean;
27
- commandConfig: CommandConfig | undefined;
28
28
  detectionStrategy: StateDetectionStrategy | undefined;
29
29
  devcontainerConfig: DevcontainerConfig | undefined;
30
30
  /**
@@ -33,6 +33,11 @@ export interface Session {
33
33
  * Contains: state, pendingState, pendingStateStart, autoApprovalFailed, autoApprovalReason, autoApprovalAbortController
34
34
  */
35
35
  stateMutex: Mutex<SessionStateData>;
36
+ /**
37
+ * State detector instance for this session.
38
+ * Created once during session initialization based on detectionStrategy.
39
+ */
40
+ stateDetector: StateDetector;
36
41
  }
37
42
  export interface AutoApprovalResponse {
38
43
  needsPermission: boolean;
@@ -79,11 +84,6 @@ export interface WorktreeConfig {
79
84
  copySessionData?: boolean;
80
85
  sortByLastSession?: boolean;
81
86
  }
82
- export interface CommandConfig {
83
- command: string;
84
- args?: string[];
85
- fallbackArgs?: string[];
86
- }
87
87
  export interface CommandPreset {
88
88
  id: string;
89
89
  name: string;
@@ -106,7 +106,6 @@ export interface ConfigurationData {
106
106
  statusHooks?: StatusHookConfig;
107
107
  worktreeHooks?: WorktreeHookConfig;
108
108
  worktree?: WorktreeConfig;
109
- command?: CommandConfig;
110
109
  commandPresets?: CommandPresetsConfig;
111
110
  autoApproval?: {
112
111
  enabled: boolean;
@@ -114,6 +113,47 @@ export interface ConfigurationData {
114
113
  timeout?: number;
115
114
  };
116
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
+ }
117
157
  export interface GitProject {
118
158
  name: string;
119
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,13 +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
- // Mock the configurationManager
12
- vi.mock('../services/configurationManager.js', () => ({
13
- configurationManager: {
11
+ import { createStateDetector } from '../services/stateDetector/index.js';
12
+ // Mock the configReader
13
+ vi.mock('../services/config/configReader.js', () => ({
14
+ configReader: {
14
15
  getStatusHooks: vi.fn(),
15
16
  },
16
17
  }));
@@ -269,12 +270,12 @@ describe('hookExecutor Integration Tests', () => {
269
270
  outputHistory: [],
270
271
  stateCheckInterval: undefined,
271
272
  isPrimaryCommand: true,
272
- commandConfig: undefined,
273
273
  detectionStrategy: 'claude',
274
274
  devcontainerConfig: undefined,
275
275
  lastActivity: new Date(),
276
276
  isActive: true,
277
277
  stateMutex: new Mutex(createInitialSessionStateData()),
278
+ stateDetector: createStateDetector('claude'),
278
279
  };
279
280
  // Mock WorktreeService to return a worktree with the tmpDir path
280
281
  vi.mocked(WorktreeService).mockImplementation(function () {
@@ -290,7 +291,7 @@ describe('hookExecutor Integration Tests', () => {
290
291
  };
291
292
  });
292
293
  // Configure mock to return a hook that writes to a file with delay
293
- vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
294
+ vi.mocked(configReader.getStatusHooks).mockReturnValue({
294
295
  busy: {
295
296
  enabled: true,
296
297
  command: `sleep 0.1 && echo "Hook executed" > "${outputFile}"`,
@@ -323,12 +324,12 @@ describe('hookExecutor Integration Tests', () => {
323
324
  outputHistory: [],
324
325
  stateCheckInterval: undefined,
325
326
  isPrimaryCommand: true,
326
- commandConfig: undefined,
327
327
  detectionStrategy: 'claude',
328
328
  devcontainerConfig: undefined,
329
329
  lastActivity: new Date(),
330
330
  isActive: true,
331
331
  stateMutex: new Mutex(createInitialSessionStateData()),
332
+ stateDetector: createStateDetector('claude'),
332
333
  };
333
334
  // Mock WorktreeService to return a worktree with the tmpDir path
334
335
  vi.mocked(WorktreeService).mockImplementation(function () {
@@ -344,7 +345,7 @@ describe('hookExecutor Integration Tests', () => {
344
345
  };
345
346
  });
346
347
  // Configure mock to return a hook that fails
347
- vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
348
+ vi.mocked(configReader.getStatusHooks).mockReturnValue({
348
349
  busy: {
349
350
  enabled: true,
350
351
  command: 'exit 1',
@@ -375,12 +376,12 @@ describe('hookExecutor Integration Tests', () => {
375
376
  outputHistory: [],
376
377
  stateCheckInterval: undefined,
377
378
  isPrimaryCommand: true,
378
- commandConfig: undefined,
379
379
  detectionStrategy: 'claude',
380
380
  devcontainerConfig: undefined,
381
381
  lastActivity: new Date(),
382
382
  isActive: true,
383
383
  stateMutex: new Mutex(createInitialSessionStateData()),
384
+ stateDetector: createStateDetector('claude'),
384
385
  };
385
386
  // Mock WorktreeService to return a worktree with the tmpDir path
386
387
  vi.mocked(WorktreeService).mockImplementation(function () {
@@ -396,7 +397,7 @@ describe('hookExecutor Integration Tests', () => {
396
397
  };
397
398
  });
398
399
  // Configure mock to return a disabled hook
399
- vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
400
+ vi.mocked(configReader.getStatusHooks).mockReturnValue({
400
401
  busy: {
401
402
  enabled: false,
402
403
  command: `echo "Should not run" > "${outputFile}"`,
@@ -429,12 +430,12 @@ describe('hookExecutor Integration Tests', () => {
429
430
  outputHistory: [],
430
431
  stateCheckInterval: undefined,
431
432
  isPrimaryCommand: true,
432
- commandConfig: undefined,
433
433
  detectionStrategy: 'claude',
434
434
  devcontainerConfig: undefined,
435
435
  lastActivity: new Date(),
436
436
  isActive: true,
437
437
  stateMutex: new Mutex(createInitialSessionStateData()),
438
+ stateDetector: createStateDetector('claude'),
438
439
  };
439
440
  // Mock WorktreeService to fail with GitError
440
441
  vi.mocked(WorktreeService).mockImplementation(function () {
@@ -447,7 +448,7 @@ describe('hookExecutor Integration Tests', () => {
447
448
  };
448
449
  });
449
450
  // Configure mock to return a hook that should execute despite worktree query failure
450
- vi.mocked(configurationManager.getStatusHooks).mockReturnValue({
451
+ vi.mocked(configReader.getStatusHooks).mockReturnValue({
451
452
  busy: {
452
453
  enabled: true,
453
454
  command: `echo "Hook ran with branch: $CCMANAGER_WORKTREE_BRANCH" > "${outputFile}"`,