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 +21 -0
- package/dist/components/ConfigureWorktree.js +9 -0
- package/dist/components/Menu.js +15 -5
- package/dist/services/configurationManager.d.ts +4 -0
- package/dist/services/configurationManager.js +23 -0
- package/dist/services/sessionManager.effect.test.js +3 -0
- package/dist/services/sessionManager.js +9 -3
- 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 +19 -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 +4 -1
- package/package.json +1 -1
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;
|
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);
|
|
@@ -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,
|
|
305
|
-
if (active
|
|
306
|
-
|
|
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(
|
|
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
|
+
});
|
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;
|
|
@@ -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(
|
|
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?: {
|