ccmanager 2.10.0 → 2.11.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.
@@ -10,6 +10,7 @@ const ConfigureWorktree = ({ onComplete }) => {
10
10
  const [autoDirectory, setAutoDirectory] = useState(worktreeConfig.autoDirectory);
11
11
  const [pattern, setPattern] = useState(worktreeConfig.autoDirectoryPattern || '../{branch}');
12
12
  const [copySessionData, setCopySessionData] = useState(worktreeConfig.copySessionData ?? true);
13
+ const [sortByLastSession, setSortByLastSession] = useState(worktreeConfig.sortByLastSession ?? false);
13
14
  const [editMode, setEditMode] = useState('menu');
14
15
  const [tempPattern, setTempPattern] = useState(pattern);
15
16
  // Example values for preview
@@ -34,6 +35,10 @@ const ConfigureWorktree = ({ onComplete }) => {
34
35
  label: `Copy Session Data: ${copySessionData ? '✅ Enabled' : '❌ Disabled'}`,
35
36
  value: 'toggleCopy',
36
37
  },
38
+ {
39
+ label: `Sort by Last Session: ${sortByLastSession ? '✅ Enabled' : '❌ Disabled'}`,
40
+ value: 'toggleSort',
41
+ },
37
42
  {
38
43
  label: '💾 Save Changes',
39
44
  value: 'save',
@@ -55,12 +60,16 @@ const ConfigureWorktree = ({ onComplete }) => {
55
60
  case 'toggleCopy':
56
61
  setCopySessionData(!copySessionData);
57
62
  break;
63
+ case 'toggleSort':
64
+ setSortByLastSession(!sortByLastSession);
65
+ break;
58
66
  case 'save':
59
67
  // Save the configuration
60
68
  configurationManager.setWorktreeConfig({
61
69
  autoDirectory,
62
70
  autoDirectoryPattern: pattern,
63
71
  copySessionData,
72
+ sortByLastSession,
64
73
  });
65
74
  onComplete();
66
75
  break;
@@ -10,6 +10,7 @@ import { projectManager } from '../services/projectManager.js';
10
10
  import TextInputWrapper from './TextInputWrapper.js';
11
11
  import { useSearchMode } from '../hooks/useSearchMode.js';
12
12
  import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
13
+ import { configurationManager } from '../services/configurationManager.js';
13
14
  const createSeparatorWithText = (text, totalWidth = 35) => {
14
15
  const textWithSpaces = ` ${text} `;
15
16
  const textLength = textWithSpaces.length;
@@ -34,6 +35,8 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
34
35
  const [items, setItems] = useState([]);
35
36
  const [recentProjects, setRecentProjects] = useState([]);
36
37
  const limit = 10;
38
+ // Get worktree configuration for sorting
39
+ const worktreeConfig = configurationManager.getWorktreeConfig();
37
40
  // Use the search mode hook
38
41
  const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
39
42
  isDisabled: !!error || !!loadError,
@@ -42,7 +45,9 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
42
45
  let cancelled = false;
43
46
  // Load worktrees and default branch using Effect composition
44
47
  // Chain getWorktreesEffect and getDefaultBranchEffect using Effect.flatMap
45
- const loadWorktreesAndBranch = Effect.flatMap(worktreeService.getWorktreesEffect(), worktrees => Effect.map(worktreeService.getDefaultBranchEffect(), defaultBranch => ({
48
+ const loadWorktreesAndBranch = Effect.flatMap(worktreeService.getWorktreesEffect({
49
+ sortByLastSession: worktreeConfig.sortByLastSession,
50
+ }), worktrees => Effect.map(worktreeService.getDefaultBranchEffect(), defaultBranch => ({
46
51
  worktrees,
47
52
  defaultBranch,
48
53
  })));
@@ -60,9 +65,6 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
60
65
  .then(result => {
61
66
  if (!cancelled) {
62
67
  if (result.success) {
63
- setBaseWorktrees(result.worktrees);
64
- setDefaultBranch(result.defaultBranch);
65
- setLoadError(null);
66
68
  // Update sessions after worktrees are loaded
67
69
  const allSessions = sessionManager.getAllSessions();
68
70
  setSessions(allSessions);
@@ -70,6 +72,9 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
70
72
  result.worktrees.forEach(wt => {
71
73
  wt.hasSession = allSessions.some(s => s.worktreePath === wt.path);
72
74
  });
75
+ setBaseWorktrees(result.worktrees);
76
+ setDefaultBranch(result.defaultBranch);
77
+ setLoadError(null);
73
78
  }
74
79
  else {
75
80
  // Handle GitError with pattern matching
@@ -105,7 +110,12 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
105
110
  sessionManager.off('sessionDestroyed', handleSessionChange);
106
111
  sessionManager.off('sessionStateChanged', handleSessionChange);
107
112
  };
108
- }, [sessionManager, worktreeService, multiProject]);
113
+ }, [
114
+ sessionManager,
115
+ worktreeService,
116
+ multiProject,
117
+ worktreeConfig.sortByLastSession,
118
+ ]);
109
119
  useEffect(() => {
110
120
  // Prepare worktree items and calculate layout
111
121
  const items = prepareWorktreeItems(worktrees, sessions);
@@ -32,6 +32,9 @@ export declare class ConfigurationManager {
32
32
  setDefaultPreset(id: string): void;
33
33
  getSelectPresetOnStart(): boolean;
34
34
  setSelectPresetOnStart(enabled: boolean): void;
35
+ getWorktreeLastOpened(): Record<string, number>;
36
+ setWorktreeLastOpened(worktreePath: string, timestamp: number): void;
37
+ getWorktreeLastOpenedTime(worktreePath: string): number | undefined;
35
38
  /**
36
39
  * Load configuration from file with Effect-based error handling
37
40
  *
@@ -79,11 +79,15 @@ export class ConfigurationManager {
79
79
  this.config.worktree = {
80
80
  autoDirectory: false,
81
81
  copySessionData: true,
82
+ sortByLastSession: false,
82
83
  };
83
84
  }
84
85
  if (!Object.prototype.hasOwnProperty.call(this.config.worktree, 'copySessionData')) {
85
86
  this.config.worktree.copySessionData = true;
86
87
  }
88
+ if (!Object.prototype.hasOwnProperty.call(this.config.worktree, 'sortByLastSession')) {
89
+ this.config.worktree.sortByLastSession = false;
90
+ }
87
91
  if (!this.config.command) {
88
92
  this.config.command = {
89
93
  command: 'claude',
@@ -270,6 +274,19 @@ export class ConfigurationManager {
270
274
  presets.selectPresetOnStart = enabled;
271
275
  this.setCommandPresets(presets);
272
276
  }
277
+ getWorktreeLastOpened() {
278
+ return this.config.worktreeLastOpened || {};
279
+ }
280
+ setWorktreeLastOpened(worktreePath, timestamp) {
281
+ if (!this.config.worktreeLastOpened) {
282
+ this.config.worktreeLastOpened = {};
283
+ }
284
+ this.config.worktreeLastOpened[worktreePath] = timestamp;
285
+ this.saveConfig();
286
+ }
287
+ getWorktreeLastOpenedTime(worktreePath) {
288
+ return this.config.worktreeLastOpened?.[worktreePath];
289
+ }
273
290
  // Effect-based methods for type-safe error handling
274
291
  /**
275
292
  * Load configuration from file with Effect-based error handling
@@ -478,11 +495,15 @@ export class ConfigurationManager {
478
495
  config.worktree = {
479
496
  autoDirectory: false,
480
497
  copySessionData: true,
498
+ sortByLastSession: false,
481
499
  };
482
500
  }
483
501
  if (!Object.prototype.hasOwnProperty.call(config.worktree, 'copySessionData')) {
484
502
  config.worktree.copySessionData = true;
485
503
  }
504
+ if (!Object.prototype.hasOwnProperty.call(config.worktree, 'sortByLastSession')) {
505
+ config.worktree.sortByLastSession = false;
506
+ }
486
507
  if (!config.command) {
487
508
  config.command = {
488
509
  command: 'claude',
@@ -16,6 +16,9 @@ vi.mock('./configurationManager.js', () => ({
16
16
  configurationManager: {
17
17
  getDefaultPreset: vi.fn(),
18
18
  getPresetById: vi.fn(),
19
+ setWorktreeLastOpened: vi.fn(),
20
+ getWorktreeLastOpenedTime: vi.fn(),
21
+ getWorktreeLastOpened: vi.fn(() => ({})),
19
22
  },
20
23
  }));
21
24
  // Mock Terminal
@@ -85,6 +85,8 @@ export class SessionManager extends EventEmitter {
85
85
  // Set up persistent background data handler for state detection
86
86
  this.setupBackgroundHandler(session);
87
87
  this.sessions.set(worktreePath, session);
88
+ // Record the timestamp when this worktree was opened
89
+ configurationManager.setWorktreeLastOpened(worktreePath, Date.now());
88
90
  this.emit('sessionCreated', session);
89
91
  return session;
90
92
  }
@@ -33,6 +33,9 @@ vi.mock('./configurationManager.js', () => ({
33
33
  }),
34
34
  getHooks: vi.fn().mockReturnValue({}),
35
35
  getStatusHooks: vi.fn().mockReturnValue({}),
36
+ setWorktreeLastOpened: vi.fn(),
37
+ getWorktreeLastOpenedTime: vi.fn(),
38
+ getWorktreeLastOpened: vi.fn(() => ({})),
36
39
  },
37
40
  }));
38
41
  describe('SessionManager - State Persistence', () => {
@@ -19,6 +19,9 @@ vi.mock('./configurationManager.js', () => ({
19
19
  getStatusHooks: vi.fn(() => ({})),
20
20
  getDefaultPreset: vi.fn(),
21
21
  getPresetById: vi.fn(),
22
+ setWorktreeLastOpened: vi.fn(),
23
+ getWorktreeLastOpenedTime: vi.fn(),
24
+ getWorktreeLastOpened: vi.fn(() => ({})),
22
25
  },
23
26
  }));
24
27
  // Mock Terminal
@@ -267,7 +267,9 @@ export declare class WorktreeService {
267
267
  *
268
268
  * @throws {GitError} When git worktree list command fails
269
269
  */
270
- getWorktreesEffect(): Effect.Effect<Worktree[], GitError, never>;
270
+ getWorktreesEffect(options?: {
271
+ sortByLastSession?: boolean;
272
+ }): Effect.Effect<Worktree[], GitError, never>;
271
273
  /**
272
274
  * Effect-based createWorktree operation
273
275
  * May fail with GitError or FileSystemError
@@ -585,9 +585,10 @@ export class WorktreeService {
585
585
  *
586
586
  * @throws {GitError} When git worktree list command fails
587
587
  */
588
- getWorktreesEffect() {
588
+ getWorktreesEffect(options) {
589
589
  // eslint-disable-next-line @typescript-eslint/no-this-alias
590
590
  const self = this;
591
+ const sortByLastSession = options?.sortByLastSession ?? false;
591
592
  return Effect.catchAll(Effect.try({
592
593
  try: () => {
593
594
  const output = execSync('git worktree list --porcelain', {
@@ -636,6 +637,16 @@ export class WorktreeService {
636
637
  if (worktrees.length > 0 && !worktrees.some(w => w.isMainWorktree)) {
637
638
  worktrees[0].isMainWorktree = true;
638
639
  }
640
+ // Sort worktrees by last session if requested
641
+ if (sortByLastSession) {
642
+ worktrees.sort((a, b) => {
643
+ // Get last opened timestamps for both worktrees
644
+ const timeA = configurationManager.getWorktreeLastOpenedTime(a.path) || 0;
645
+ const timeB = configurationManager.getWorktreeLastOpenedTime(b.path) || 0;
646
+ // Sort in descending order (most recent first)
647
+ return timeB - timeA;
648
+ });
649
+ }
639
650
  return worktrees;
640
651
  },
641
652
  catch: (error) => error,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,321 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { Effect } from 'effect';
3
+ import { WorktreeService } from './worktreeService.js';
4
+ import { execSync } from 'child_process';
5
+ import { configurationManager } from './configurationManager.js';
6
+ // Mock child_process module
7
+ vi.mock('child_process');
8
+ // Mock fs module
9
+ vi.mock('fs');
10
+ // Mock worktreeConfigManager
11
+ vi.mock('./worktreeConfigManager.js', () => ({
12
+ worktreeConfigManager: {
13
+ initialize: vi.fn(),
14
+ isAvailable: vi.fn(() => true),
15
+ reset: vi.fn(),
16
+ },
17
+ }));
18
+ // Mock configurationManager
19
+ vi.mock('./configurationManager.js', () => ({
20
+ configurationManager: {
21
+ getWorktreeLastOpenedTime: vi.fn(),
22
+ setWorktreeLastOpened: vi.fn(),
23
+ getWorktreeLastOpened: vi.fn(() => ({})),
24
+ getWorktreeConfig: vi.fn(() => ({
25
+ autoDirectory: false,
26
+ copySessionData: true,
27
+ sortByLastSession: false,
28
+ })),
29
+ getWorktreeHooks: vi.fn(() => ({})),
30
+ },
31
+ }));
32
+ // Mock HookExecutor
33
+ vi.mock('../utils/hookExecutor.js', () => ({
34
+ executeWorktreePostCreationHook: vi.fn(),
35
+ }));
36
+ // Get the mocked functions with proper typing
37
+ const mockedExecSync = vi.mocked(execSync);
38
+ const mockedGetWorktreeLastOpenedTime = vi.mocked(configurationManager.getWorktreeLastOpenedTime);
39
+ describe('WorktreeService - Sorting', () => {
40
+ let service;
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ // Mock git rev-parse --git-common-dir to return a predictable path
44
+ mockedExecSync.mockImplementation((cmd, _options) => {
45
+ if (typeof cmd === 'string' && cmd === 'git rev-parse --git-common-dir') {
46
+ return '/test/repo/.git\n';
47
+ }
48
+ throw new Error('Command not mocked: ' + cmd);
49
+ });
50
+ // Create service instance
51
+ service = new WorktreeService('/test/repo');
52
+ });
53
+ describe('getWorktreesEffect with sortByLastSession', () => {
54
+ it('should not sort worktrees when sortByLastSession is false', async () => {
55
+ // Setup mock git output
56
+ const gitOutput = `worktree /test/repo
57
+ branch refs/heads/main
58
+
59
+ worktree /test/repo/feature-a
60
+ branch refs/heads/feature-a
61
+
62
+ worktree /test/repo/feature-b
63
+ branch refs/heads/feature-b
64
+ `;
65
+ mockedExecSync.mockReturnValue(gitOutput);
66
+ // Execute
67
+ const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: false }));
68
+ // Verify order is unchanged (as returned by git)
69
+ expect(result).toHaveLength(3);
70
+ expect(result[0]?.path).toBe('/test/repo');
71
+ expect(result[1]?.path).toBe('/test/repo/feature-a');
72
+ expect(result[2]?.path).toBe('/test/repo/feature-b');
73
+ });
74
+ it('should not sort worktrees when sortByLastSession is undefined', async () => {
75
+ // Setup mock git output
76
+ const gitOutput = `worktree /test/repo
77
+ branch refs/heads/main
78
+
79
+ worktree /test/repo/feature-a
80
+ branch refs/heads/feature-a
81
+
82
+ worktree /test/repo/feature-b
83
+ branch refs/heads/feature-b
84
+ `;
85
+ mockedExecSync.mockReturnValue(gitOutput);
86
+ // Execute without options
87
+ const result = await Effect.runPromise(service.getWorktreesEffect());
88
+ // Verify order is unchanged (as returned by git)
89
+ expect(result).toHaveLength(3);
90
+ expect(result[0]?.path).toBe('/test/repo');
91
+ expect(result[1]?.path).toBe('/test/repo/feature-a');
92
+ expect(result[2]?.path).toBe('/test/repo/feature-b');
93
+ });
94
+ it('should sort worktrees by last opened timestamp in descending order', async () => {
95
+ // Setup mock git output
96
+ const gitOutput = `worktree /test/repo
97
+ branch refs/heads/main
98
+
99
+ worktree /test/repo/feature-a
100
+ branch refs/heads/feature-a
101
+
102
+ worktree /test/repo/feature-b
103
+ branch refs/heads/feature-b
104
+ `;
105
+ mockedExecSync.mockReturnValue(gitOutput);
106
+ // 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
+ });
116
+ // Execute
117
+ const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
118
+ // Verify sorted order (most recent first)
119
+ expect(result).toHaveLength(3);
120
+ expect(result[0]?.path).toBe('/test/repo/feature-b'); // 3000
121
+ expect(result[1]?.path).toBe('/test/repo'); // 2000
122
+ expect(result[2]?.path).toBe('/test/repo/feature-a'); // 1000
123
+ });
124
+ it('should place worktrees without timestamps at the end', async () => {
125
+ // Setup mock git output
126
+ const gitOutput = `worktree /test/repo
127
+ branch refs/heads/main
128
+
129
+ worktree /test/repo/feature-a
130
+ branch refs/heads/feature-a
131
+
132
+ worktree /test/repo/feature-b
133
+ branch refs/heads/feature-b
134
+
135
+ worktree /test/repo/feature-c
136
+ branch refs/heads/feature-c
137
+ `;
138
+ mockedExecSync.mockReturnValue(gitOutput);
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
+ });
148
+ // Execute
149
+ const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
150
+ // Verify sorted order
151
+ 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
154
+ // 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
157
+ });
158
+ it('should handle empty worktree list', async () => {
159
+ // Setup empty git output
160
+ mockedExecSync.mockReturnValue('');
161
+ // Execute
162
+ const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
163
+ // Verify empty result
164
+ expect(result).toHaveLength(0);
165
+ });
166
+ it('should handle single worktree', async () => {
167
+ // Setup mock git output with single worktree
168
+ const gitOutput = `worktree /test/repo
169
+ branch refs/heads/main
170
+ `;
171
+ mockedExecSync.mockReturnValue(gitOutput);
172
+ mockedGetWorktreeLastOpenedTime.mockReturnValue(1000);
173
+ // Execute
174
+ const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
175
+ // Verify single result
176
+ expect(result).toHaveLength(1);
177
+ expect(result[0]?.path).toBe('/test/repo');
178
+ });
179
+ it('should maintain stable sort for worktrees with same timestamp', async () => {
180
+ // Setup mock git output
181
+ const gitOutput = `worktree /test/repo/feature-a
182
+ branch refs/heads/feature-a
183
+
184
+ worktree /test/repo/feature-b
185
+ branch refs/heads/feature-b
186
+
187
+ worktree /test/repo/feature-c
188
+ branch refs/heads/feature-c
189
+ `;
190
+ mockedExecSync.mockReturnValue(gitOutput);
191
+ // All have the same timestamp
192
+ mockedGetWorktreeLastOpenedTime.mockReturnValue(1000);
193
+ // Execute
194
+ const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
195
+ // Verify original order is maintained (stable sort)
196
+ 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');
200
+ });
201
+ it('should sort correctly with mixed timestamps including zero', async () => {
202
+ // Setup mock git output
203
+ const gitOutput = `worktree /test/repo/zero-timestamp
204
+ branch refs/heads/zero-timestamp
205
+
206
+ worktree /test/repo/recent
207
+ branch refs/heads/recent
208
+
209
+ worktree /test/repo/older
210
+ branch refs/heads/older
211
+ `;
212
+ mockedExecSync.mockReturnValue(gitOutput);
213
+ // 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
+ });
223
+ // Execute
224
+ const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
225
+ // Verify sorted order
226
+ 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
230
+ });
231
+ it('should preserve worktree properties after sorting', async () => {
232
+ // Setup mock git output
233
+ const gitOutput = `worktree /test/repo
234
+ branch refs/heads/main
235
+ bare
236
+
237
+ worktree /test/repo/feature-a
238
+ branch refs/heads/feature-a
239
+ `;
240
+ 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
+ });
248
+ // Execute
249
+ const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
250
+ // Verify properties are preserved
251
+ expect(result).toHaveLength(2);
252
+ expect(result[0]?.path).toBe('/test/repo/feature-a');
253
+ expect(result[0]?.branch).toBe('feature-a');
254
+ expect(result[0]?.isMainWorktree).toBe(false);
255
+ expect(result[1]?.path).toBe('/test/repo');
256
+ expect(result[1]?.branch).toBe('main');
257
+ expect(result[1]?.isMainWorktree).toBe(true);
258
+ });
259
+ it('should handle very large timestamps', async () => {
260
+ // Setup mock git output
261
+ const gitOutput = `worktree /test/repo/old
262
+ branch refs/heads/old
263
+
264
+ worktree /test/repo/new
265
+ branch refs/heads/new
266
+ `;
267
+ mockedExecSync.mockReturnValue(gitOutput);
268
+ // Use actual Date.now() values
269
+ const now = Date.now();
270
+ 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
+ });
278
+ // Execute
279
+ const result = await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: true }));
280
+ // Verify sorted order
281
+ expect(result).toHaveLength(2);
282
+ expect(result[0]?.path).toBe('/test/repo/new');
283
+ expect(result[1]?.path).toBe('/test/repo/old');
284
+ });
285
+ });
286
+ 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
290
+ branch refs/heads/main
291
+ `;
292
+ mockedExecSync.mockReturnValue(gitOutput);
293
+ // Execute
294
+ await Effect.runPromise(service.getWorktreesEffect({ sortByLastSession: false }));
295
+ // Verify getWorktreeLastOpenedTime was not called
296
+ expect(mockedGetWorktreeLastOpenedTime).not.toHaveBeenCalled();
297
+ });
298
+ it('should call getWorktreeLastOpenedTime for each worktree when sorting', async () => {
299
+ // Setup mock git output
300
+ const gitOutput = `worktree /test/repo
301
+ branch refs/heads/main
302
+
303
+ worktree /test/repo/feature-a
304
+ branch refs/heads/feature-a
305
+
306
+ worktree /test/repo/feature-b
307
+ branch refs/heads/feature-b
308
+ `;
309
+ 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');
319
+ });
320
+ });
321
+ });
@@ -67,6 +67,7 @@ export interface WorktreeConfig {
67
67
  autoDirectory: boolean;
68
68
  autoDirectoryPattern?: string;
69
69
  copySessionData?: boolean;
70
+ sortByLastSession?: boolean;
70
71
  }
71
72
  export interface CommandConfig {
72
73
  command: string;
@@ -97,6 +98,7 @@ export interface ConfigurationData {
97
98
  worktree?: WorktreeConfig;
98
99
  command?: CommandConfig;
99
100
  commandPresets?: CommandPresetsConfig;
101
+ worktreeLastOpened?: Record<string, number>;
100
102
  }
101
103
  export interface GitProject {
102
104
  name: string;
@@ -143,7 +145,9 @@ export declare class AmbiguousBranchError extends Error {
143
145
  constructor(branchName: string, matches: RemoteBranchMatch[]);
144
146
  }
145
147
  export interface IWorktreeService {
146
- getWorktreesEffect(): import('effect').Effect.Effect<Worktree[], import('../types/errors.js').GitError, never>;
148
+ getWorktreesEffect(options?: {
149
+ sortByLastSession?: boolean;
150
+ }): import('effect').Effect.Effect<Worktree[], import('../types/errors.js').GitError, never>;
147
151
  getGitRootPath(): string;
148
152
  createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): import('effect').Effect.Effect<Worktree, import('../types/errors.js').GitError | import('../types/errors.js').FileSystemError, never>;
149
153
  deleteWorktreeEffect(worktreePath: string, options?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",