ccmanager 2.10.0 → 2.11.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kodai Kabasawa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -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);
@@ -6,6 +6,7 @@ export declare class ConfigurationManager {
6
6
  private legacyShortcutsPath;
7
7
  private configDir;
8
8
  private config;
9
+ private worktreeLastOpened;
9
10
  constructor();
10
11
  private loadConfig;
11
12
  private migrateLegacyShortcuts;
@@ -32,6 +33,9 @@ export declare class ConfigurationManager {
32
33
  setDefaultPreset(id: string): void;
33
34
  getSelectPresetOnStart(): boolean;
34
35
  setSelectPresetOnStart(enabled: boolean): void;
36
+ getWorktreeLastOpened(): Record<string, number>;
37
+ setWorktreeLastOpened(worktreePath: string, timestamp: number): void;
38
+ getWorktreeLastOpenedTime(worktreePath: string): number | undefined;
35
39
  /**
36
40
  * Load configuration from file with Effect-based error handling
37
41
  *
@@ -30,6 +30,12 @@ export class ConfigurationManager {
30
30
  writable: true,
31
31
  value: {}
32
32
  });
33
+ Object.defineProperty(this, "worktreeLastOpened", {
34
+ enumerable: true,
35
+ configurable: true,
36
+ writable: true,
37
+ value: new Map()
38
+ });
33
39
  // Determine config directory based on platform
34
40
  const homeDir = homedir();
35
41
  this.configDir =
@@ -79,11 +85,15 @@ export class ConfigurationManager {
79
85
  this.config.worktree = {
80
86
  autoDirectory: false,
81
87
  copySessionData: true,
88
+ sortByLastSession: false,
82
89
  };
83
90
  }
84
91
  if (!Object.prototype.hasOwnProperty.call(this.config.worktree, 'copySessionData')) {
85
92
  this.config.worktree.copySessionData = true;
86
93
  }
94
+ if (!Object.prototype.hasOwnProperty.call(this.config.worktree, 'sortByLastSession')) {
95
+ this.config.worktree.sortByLastSession = false;
96
+ }
87
97
  if (!this.config.command) {
88
98
  this.config.command = {
89
99
  command: 'claude',
@@ -270,6 +280,15 @@ export class ConfigurationManager {
270
280
  presets.selectPresetOnStart = enabled;
271
281
  this.setCommandPresets(presets);
272
282
  }
283
+ getWorktreeLastOpened() {
284
+ return Object.fromEntries(this.worktreeLastOpened);
285
+ }
286
+ setWorktreeLastOpened(worktreePath, timestamp) {
287
+ this.worktreeLastOpened.set(worktreePath, timestamp);
288
+ }
289
+ getWorktreeLastOpenedTime(worktreePath) {
290
+ return this.worktreeLastOpened.get(worktreePath);
291
+ }
273
292
  // Effect-based methods for type-safe error handling
274
293
  /**
275
294
  * Load configuration from file with Effect-based error handling
@@ -478,11 +497,15 @@ export class ConfigurationManager {
478
497
  config.worktree = {
479
498
  autoDirectory: false,
480
499
  copySessionData: true,
500
+ sortByLastSession: false,
481
501
  };
482
502
  }
483
503
  if (!Object.prototype.hasOwnProperty.call(config.worktree, 'copySessionData')) {
484
504
  config.worktree.copySessionData = true;
485
505
  }
506
+ if (!Object.prototype.hasOwnProperty.call(config.worktree, 'sortByLastSession')) {
507
+ config.worktree.sortByLastSession = false;
508
+ }
486
509
  if (!config.command) {
487
510
  config.command = {
488
511
  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
  }
@@ -301,9 +303,13 @@ export class SessionManager extends EventEmitter {
301
303
  const session = this.sessions.get(worktreePath);
302
304
  if (session) {
303
305
  session.isActive = active;
304
- // If becoming active, emit a restore event with the output history
305
- if (active && session.outputHistory.length > 0) {
306
- this.emit('sessionRestore', session);
306
+ // If becoming active, record the timestamp when this worktree was opened
307
+ if (active) {
308
+ configurationManager.setWorktreeLastOpened(worktreePath, Date.now());
309
+ // Emit a restore event with the output history if available
310
+ if (session.outputHistory.length > 0) {
311
+ this.emit('sessionRestore', session);
312
+ }
307
313
  }
308
314
  }
309
315
  }
@@ -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,23 @@ 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);
645
+ const timeB = configurationManager.getWorktreeLastOpenedTime(b.path);
646
+ // If both timestamps are undefined, preserve original order
647
+ if (timeA === undefined && timeB === undefined) {
648
+ return 0;
649
+ }
650
+ // If only one is undefined, treat it as older (0)
651
+ const compareTimeA = timeA || 0;
652
+ const compareTimeB = timeB || 0;
653
+ // Sort in descending order (most recent first)
654
+ return compareTimeB - compareTimeA;
655
+ });
656
+ }
639
657
  return worktrees;
640
658
  },
641
659
  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;
@@ -143,7 +144,9 @@ export declare class AmbiguousBranchError extends Error {
143
144
  constructor(branchName: string, matches: RemoteBranchMatch[]);
144
145
  }
145
146
  export interface IWorktreeService {
146
- getWorktreesEffect(): import('effect').Effect.Effect<Worktree[], import('../types/errors.js').GitError, never>;
147
+ getWorktreesEffect(options?: {
148
+ sortByLastSession?: boolean;
149
+ }): import('effect').Effect.Effect<Worktree[], import('../types/errors.js').GitError, never>;
147
150
  getGitRootPath(): string;
148
151
  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
152
  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.1",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",