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.
- package/dist/components/ConfigureWorktree.js +9 -0
- package/dist/components/Menu.js +15 -5
- package/dist/services/configurationManager.d.ts +3 -0
- package/dist/services/configurationManager.js +21 -0
- package/dist/services/sessionManager.effect.test.js +3 -0
- package/dist/services/sessionManager.js +2 -0
- package/dist/services/sessionManager.statePersistence.test.js +3 -0
- package/dist/services/sessionManager.test.js +3 -0
- package/dist/services/worktreeService.d.ts +3 -1
- package/dist/services/worktreeService.js +12 -1
- package/dist/services/worktreeService.sort.test.d.ts +1 -0
- package/dist/services/worktreeService.sort.test.js +321 -0
- package/dist/types/index.d.ts +5 -1
- package/package.json +1 -1
|
@@ -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;
|
package/dist/components/Menu.js
CHANGED
|
@@ -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(
|
|
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
|
-
}, [
|
|
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(
|
|
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
|
+
});
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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(
|
|
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?: {
|