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.
- package/README.md +11 -5
- package/dist/components/App.js +17 -3
- package/dist/components/App.test.js +5 -5
- package/dist/components/Configuration.d.ts +2 -0
- package/dist/components/Configuration.js +6 -2
- package/dist/components/ConfigureCommand.js +34 -11
- package/dist/components/ConfigureOther.js +18 -4
- package/dist/components/ConfigureOther.test.js +48 -12
- package/dist/components/ConfigureShortcuts.js +27 -85
- package/dist/components/ConfigureStatusHooks.js +19 -4
- package/dist/components/ConfigureStatusHooks.test.js +46 -12
- package/dist/components/ConfigureWorktree.js +18 -4
- package/dist/components/ConfigureWorktreeHooks.js +19 -4
- package/dist/components/ConfigureWorktreeHooks.test.js +49 -14
- package/dist/components/Menu.js +72 -14
- package/dist/components/Menu.recent-projects.test.js +2 -0
- package/dist/components/Menu.test.js +2 -0
- package/dist/components/NewWorktree.js +2 -2
- package/dist/components/NewWorktree.test.js +6 -6
- package/dist/components/PresetSelector.js +2 -2
- package/dist/constants/statusIcons.d.ts +4 -1
- package/dist/constants/statusIcons.js +10 -1
- package/dist/constants/statusIcons.test.js +42 -0
- package/dist/contexts/ConfigEditorContext.d.ts +21 -0
- package/dist/contexts/ConfigEditorContext.js +25 -0
- package/dist/services/autoApprovalVerifier.js +3 -3
- package/dist/services/autoApprovalVerifier.test.js +2 -2
- package/dist/services/config/configEditor.d.ts +46 -0
- package/dist/services/{configurationManager.effect.test.js → config/configEditor.effect.test.js} +46 -49
- package/dist/services/config/configEditor.js +101 -0
- package/dist/services/{configurationManager.selectPresetOnStart.test.js → config/configEditor.selectPresetOnStart.test.js} +27 -19
- package/dist/services/config/configEditor.test.d.ts +1 -0
- package/dist/services/{configurationManager.test.js → config/configEditor.test.js} +60 -132
- package/dist/services/config/configReader.d.ts +28 -0
- package/dist/services/config/configReader.js +95 -0
- package/dist/services/config/configReader.multiProject.test.d.ts +1 -0
- package/dist/services/config/configReader.multiProject.test.js +136 -0
- package/dist/services/config/globalConfigManager.d.ts +30 -0
- package/dist/services/config/globalConfigManager.js +216 -0
- package/dist/services/config/index.d.ts +13 -0
- package/dist/services/config/index.js +13 -0
- package/dist/services/config/projectConfigManager.d.ts +41 -0
- package/dist/services/config/projectConfigManager.js +181 -0
- package/dist/services/config/projectConfigManager.test.d.ts +1 -0
- package/dist/services/config/projectConfigManager.test.js +105 -0
- package/dist/services/config/testUtils.d.ts +81 -0
- package/dist/services/config/testUtils.js +351 -0
- package/dist/services/sessionManager.autoApproval.test.js +9 -6
- package/dist/services/sessionManager.d.ts +2 -0
- package/dist/services/sessionManager.effect.test.js +27 -18
- package/dist/services/sessionManager.js +43 -40
- package/dist/services/sessionManager.statePersistence.test.js +5 -4
- package/dist/services/sessionManager.test.js +71 -49
- package/dist/services/shortcutManager.d.ts +0 -1
- package/dist/services/shortcutManager.js +5 -16
- package/dist/services/shortcutManager.test.js +2 -2
- package/dist/services/stateDetector/base.d.ts +1 -0
- package/dist/services/stateDetector/claude.d.ts +1 -0
- package/dist/services/stateDetector/claude.js +8 -0
- package/dist/services/stateDetector/claude.test.js +102 -0
- package/dist/services/stateDetector/cline.d.ts +1 -0
- package/dist/services/stateDetector/cline.js +3 -0
- package/dist/services/stateDetector/codex.d.ts +1 -0
- package/dist/services/stateDetector/codex.js +3 -0
- package/dist/services/stateDetector/cursor.d.ts +1 -0
- package/dist/services/stateDetector/cursor.js +3 -0
- package/dist/services/stateDetector/gemini.d.ts +1 -0
- package/dist/services/stateDetector/gemini.js +3 -0
- package/dist/services/stateDetector/github-copilot.d.ts +1 -0
- package/dist/services/stateDetector/github-copilot.js +3 -0
- package/dist/services/stateDetector/opencode.d.ts +1 -0
- package/dist/services/stateDetector/opencode.js +3 -0
- package/dist/services/stateDetector/types.d.ts +1 -0
- package/dist/services/worktreeService.d.ts +12 -0
- package/dist/services/worktreeService.js +24 -4
- package/dist/services/worktreeService.sort.test.js +105 -109
- package/dist/services/worktreeService.test.js +5 -5
- package/dist/types/index.d.ts +47 -7
- package/dist/utils/gitUtils.d.ts +8 -0
- package/dist/utils/gitUtils.js +32 -0
- package/dist/utils/hookExecutor.js +2 -2
- package/dist/utils/hookExecutor.test.js +13 -12
- package/dist/utils/mutex.d.ts +1 -0
- package/dist/utils/mutex.js +1 -0
- package/dist/utils/worktreeUtils.js +3 -2
- package/dist/utils/worktreeUtils.test.js +2 -1
- package/package.json +7 -7
- package/dist/services/configurationManager.d.ts +0 -121
- package/dist/services/configurationManager.js +0 -597
- /package/dist/{services/configurationManager.effect.test.d.ts → constants/statusIcons.test.d.ts} +0 -0
- /package/dist/services/{configurationManager.selectPresetOnStart.test.d.ts → config/configEditor.effect.test.d.ts} +0 -0
- /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
|
-
|
|
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
|
|
19
|
-
vi.mock('./
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
|
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
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
expect(
|
|
318
|
-
expect(
|
|
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 {
|
|
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
|
|
21
|
-
vi.mock('./
|
|
22
|
-
|
|
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(
|
|
34
|
+
const mockedGetWorktreeHooks = vi.mocked(configReader.getWorktreeHooks);
|
|
35
35
|
describe('WorktreeService', () => {
|
|
36
36
|
let service;
|
|
37
37
|
beforeEach(() => {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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}"`,
|