ccmanager 0.1.15 → 0.2.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/cli.js +3 -0
- package/dist/components/Menu.js +18 -18
- package/dist/hooks/useGitStatus.d.ts +2 -0
- package/dist/hooks/useGitStatus.js +52 -0
- package/dist/hooks/useGitStatus.test.d.ts +1 -0
- package/dist/hooks/useGitStatus.test.js +186 -0
- package/dist/services/worktreeConfigManager.d.ts +10 -0
- package/dist/services/worktreeConfigManager.js +27 -0
- package/dist/services/worktreeService.js +8 -0
- package/dist/services/worktreeService.test.js +8 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/utils/concurrencyLimit.d.ts +4 -0
- package/dist/utils/concurrencyLimit.js +30 -0
- package/dist/utils/concurrencyLimit.test.d.ts +1 -0
- package/dist/utils/concurrencyLimit.test.js +63 -0
- package/dist/utils/gitStatus.d.ts +19 -0
- package/dist/utils/gitStatus.js +146 -0
- package/dist/utils/gitStatus.test.d.ts +1 -0
- package/dist/utils/gitStatus.test.js +141 -0
- package/dist/utils/worktreeConfig.d.ts +3 -0
- package/dist/utils/worktreeConfig.js +43 -0
- package/dist/utils/worktreeUtils.d.ts +37 -0
- package/dist/utils/worktreeUtils.js +114 -0
- package/dist/utils/worktreeUtils.test.js +105 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import React from 'react';
|
|
|
3
3
|
import { render } from 'ink';
|
|
4
4
|
import meow from 'meow';
|
|
5
5
|
import App from './components/App.js';
|
|
6
|
+
import { worktreeConfigManager } from './services/worktreeConfigManager.js';
|
|
6
7
|
meow(`
|
|
7
8
|
Usage
|
|
8
9
|
$ ccmanager
|
|
@@ -21,4 +22,6 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
|
21
22
|
console.error('Error: ccmanager must be run in an interactive terminal (TTY)');
|
|
22
23
|
process.exit(1);
|
|
23
24
|
}
|
|
25
|
+
// Initialize worktree config manager
|
|
26
|
+
worktreeConfigManager.initialize();
|
|
24
27
|
render(React.createElement(App, null));
|
package/dist/components/Menu.js
CHANGED
|
@@ -2,16 +2,21 @@ import React, { useState, useEffect } from 'react';
|
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import SelectInput from 'ink-select-input';
|
|
4
4
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
5
|
-
import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS,
|
|
5
|
+
import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, } from '../constants/statusIcons.js';
|
|
6
|
+
import { useGitStatus } from '../hooks/useGitStatus.js';
|
|
7
|
+
import { prepareWorktreeItems, calculateColumnPositions, assembleWorktreeLabel, } from '../utils/worktreeUtils.js';
|
|
6
8
|
const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
7
|
-
const [
|
|
9
|
+
const [baseWorktrees, setBaseWorktrees] = useState([]);
|
|
10
|
+
const [defaultBranch, setDefaultBranch] = useState(null);
|
|
11
|
+
const worktrees = useGitStatus(baseWorktrees, defaultBranch);
|
|
8
12
|
const [sessions, setSessions] = useState([]);
|
|
9
13
|
const [items, setItems] = useState([]);
|
|
10
14
|
useEffect(() => {
|
|
11
15
|
// Load worktrees
|
|
12
16
|
const worktreeService = new WorktreeService();
|
|
13
17
|
const loadedWorktrees = worktreeService.getWorktrees();
|
|
14
|
-
|
|
18
|
+
setBaseWorktrees(loadedWorktrees);
|
|
19
|
+
setDefaultBranch(worktreeService.getDefaultBranch());
|
|
15
20
|
// Update sessions
|
|
16
21
|
const updateSessions = () => {
|
|
17
22
|
const allSessions = sessionManager.getAllSessions();
|
|
@@ -34,23 +39,18 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
34
39
|
};
|
|
35
40
|
}, [sessionManager]);
|
|
36
41
|
useEffect(() => {
|
|
37
|
-
//
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
const branchName = wt.branch
|
|
45
|
-
? wt.branch.replace('refs/heads/', '')
|
|
46
|
-
: 'detached';
|
|
47
|
-
const isMain = wt.isMainWorktree ? ' (main)' : '';
|
|
42
|
+
// Prepare worktree items and calculate layout
|
|
43
|
+
const items = prepareWorktreeItems(worktrees, sessions);
|
|
44
|
+
const columnPositions = calculateColumnPositions(items);
|
|
45
|
+
// Build menu items with proper alignment
|
|
46
|
+
const menuItems = items.map((item, index) => {
|
|
47
|
+
const label = assembleWorktreeLabel(item, columnPositions);
|
|
48
48
|
// Only show numbers for first 10 worktrees (0-9)
|
|
49
49
|
const numberPrefix = index < 10 ? `${index} ❯ ` : '❯ ';
|
|
50
50
|
return {
|
|
51
|
-
label:
|
|
52
|
-
value:
|
|
53
|
-
worktree:
|
|
51
|
+
label: numberPrefix + label,
|
|
52
|
+
value: item.worktree.path,
|
|
53
|
+
worktree: item.worktree,
|
|
54
54
|
};
|
|
55
55
|
});
|
|
56
56
|
// Add menu options
|
|
@@ -79,7 +79,7 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
79
79
|
value: 'exit',
|
|
80
80
|
});
|
|
81
81
|
setItems(menuItems);
|
|
82
|
-
}, [worktrees, sessions]);
|
|
82
|
+
}, [worktrees, sessions, defaultBranch]);
|
|
83
83
|
// Handle hotkeys
|
|
84
84
|
useInput((input, _key) => {
|
|
85
85
|
const keyPressed = input.toLowerCase();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { getGitStatusLimited } from '../utils/gitStatus.js';
|
|
3
|
+
export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
|
|
4
|
+
const [worktreesWithStatus, setWorktreesWithStatus] = useState(worktrees);
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
if (!defaultBranch) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const timeouts = new Map();
|
|
10
|
+
const activeRequests = new Map();
|
|
11
|
+
let isCleanedUp = false;
|
|
12
|
+
const fetchStatus = async (worktree, abortController) => {
|
|
13
|
+
try {
|
|
14
|
+
const result = await getGitStatusLimited(worktree.path, abortController.signal);
|
|
15
|
+
if (result.data || result.error) {
|
|
16
|
+
setWorktreesWithStatus(prev => prev.map(wt => wt.path === worktree.path
|
|
17
|
+
? { ...wt, gitStatus: result.data, gitStatusError: result.error }
|
|
18
|
+
: wt));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Ignore errors - the fetch failed or was aborted
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const scheduleUpdate = (worktree) => {
|
|
26
|
+
const abortController = new AbortController();
|
|
27
|
+
activeRequests.set(worktree.path, abortController);
|
|
28
|
+
fetchStatus(worktree, abortController).finally(() => {
|
|
29
|
+
const isActive = () => !isCleanedUp && !abortController.signal.aborted;
|
|
30
|
+
if (isActive()) {
|
|
31
|
+
const timeout = setTimeout(() => {
|
|
32
|
+
if (isActive()) {
|
|
33
|
+
scheduleUpdate(worktree);
|
|
34
|
+
}
|
|
35
|
+
}, updateInterval);
|
|
36
|
+
timeouts.set(worktree.path, timeout);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
setWorktreesWithStatus(worktrees);
|
|
41
|
+
// Start fetching for each worktree
|
|
42
|
+
worktrees.forEach(worktree => {
|
|
43
|
+
scheduleUpdate(worktree);
|
|
44
|
+
});
|
|
45
|
+
return () => {
|
|
46
|
+
isCleanedUp = true;
|
|
47
|
+
timeouts.forEach(timeout => clearTimeout(timeout));
|
|
48
|
+
activeRequests.forEach(controller => controller.abort());
|
|
49
|
+
};
|
|
50
|
+
}, [worktrees, defaultBranch, updateInterval]);
|
|
51
|
+
return worktreesWithStatus;
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render, cleanup } from 'ink-testing-library';
|
|
4
|
+
import { Text } from 'ink';
|
|
5
|
+
import { useGitStatus } from './useGitStatus.js';
|
|
6
|
+
import { getGitStatusLimited } from '../utils/gitStatus.js';
|
|
7
|
+
// Mock the gitStatus module
|
|
8
|
+
vi.mock('../utils/gitStatus.js', () => ({
|
|
9
|
+
getGitStatusLimited: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
describe('useGitStatus', () => {
|
|
12
|
+
const mockGetGitStatus = getGitStatusLimited;
|
|
13
|
+
const createWorktree = (path) => ({
|
|
14
|
+
path,
|
|
15
|
+
branch: 'main',
|
|
16
|
+
isMainWorktree: false,
|
|
17
|
+
hasSession: false,
|
|
18
|
+
});
|
|
19
|
+
const createGitStatus = (added = 1, deleted = 0) => ({
|
|
20
|
+
filesAdded: added,
|
|
21
|
+
filesDeleted: deleted,
|
|
22
|
+
aheadCount: 0,
|
|
23
|
+
behindCount: 0,
|
|
24
|
+
parentBranch: 'main',
|
|
25
|
+
});
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.useFakeTimers();
|
|
28
|
+
mockGetGitStatus.mockClear();
|
|
29
|
+
});
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.useRealTimers();
|
|
32
|
+
cleanup();
|
|
33
|
+
});
|
|
34
|
+
// Main behavioral test
|
|
35
|
+
it('should fetch and update git status for worktrees', async () => {
|
|
36
|
+
const worktrees = [createWorktree('/path1'), createWorktree('/path2')];
|
|
37
|
+
const gitStatus1 = createGitStatus(5, 3);
|
|
38
|
+
const gitStatus2 = createGitStatus(2, 1);
|
|
39
|
+
let hookResult = [];
|
|
40
|
+
mockGetGitStatus.mockImplementation(async (path) => {
|
|
41
|
+
if (path === '/path1') {
|
|
42
|
+
return { success: true, data: gitStatus1 };
|
|
43
|
+
}
|
|
44
|
+
return { success: true, data: gitStatus2 };
|
|
45
|
+
});
|
|
46
|
+
const TestComponent = () => {
|
|
47
|
+
hookResult = useGitStatus(worktrees, 'main', 100);
|
|
48
|
+
return React.createElement(Text, null, 'test');
|
|
49
|
+
};
|
|
50
|
+
render(React.createElement(TestComponent));
|
|
51
|
+
// Should return worktrees immediately
|
|
52
|
+
expect(hookResult).toEqual(worktrees);
|
|
53
|
+
// Wait for status updates
|
|
54
|
+
await vi.waitFor(() => {
|
|
55
|
+
expect(hookResult[0]?.gitStatus).toBeDefined();
|
|
56
|
+
expect(hookResult[1]?.gitStatus).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
// Should have correct status for each worktree
|
|
59
|
+
expect(hookResult[0]?.gitStatus).toEqual(gitStatus1);
|
|
60
|
+
expect(hookResult[1]?.gitStatus).toEqual(gitStatus2);
|
|
61
|
+
});
|
|
62
|
+
it('should handle empty worktree array', () => {
|
|
63
|
+
let hookResult = [];
|
|
64
|
+
const TestComponent = () => {
|
|
65
|
+
hookResult = useGitStatus([], 'main');
|
|
66
|
+
return React.createElement(Text, null, 'test');
|
|
67
|
+
};
|
|
68
|
+
render(React.createElement(TestComponent));
|
|
69
|
+
expect(hookResult).toEqual([]);
|
|
70
|
+
expect(mockGetGitStatus).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
it('should not fetch when defaultBranch is null', async () => {
|
|
73
|
+
const worktrees = [createWorktree('/path1'), createWorktree('/path2')];
|
|
74
|
+
let hookResult = [];
|
|
75
|
+
const TestComponent = () => {
|
|
76
|
+
hookResult = useGitStatus(worktrees, null);
|
|
77
|
+
return React.createElement(Text, null, 'test');
|
|
78
|
+
};
|
|
79
|
+
render(React.createElement(TestComponent));
|
|
80
|
+
// Should return worktrees immediately without modification
|
|
81
|
+
expect(hookResult).toEqual(worktrees);
|
|
82
|
+
// Wait to ensure no fetches occur
|
|
83
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
84
|
+
expect(mockGetGitStatus).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
it('should continue polling after errors', async () => {
|
|
87
|
+
const worktrees = [createWorktree('/path1')];
|
|
88
|
+
mockGetGitStatus.mockResolvedValue({
|
|
89
|
+
success: false,
|
|
90
|
+
error: 'Git error',
|
|
91
|
+
});
|
|
92
|
+
const TestComponent = () => {
|
|
93
|
+
useGitStatus(worktrees, 'main', 100);
|
|
94
|
+
return React.createElement(Text, null, 'test');
|
|
95
|
+
};
|
|
96
|
+
render(React.createElement(TestComponent));
|
|
97
|
+
// Wait for initial fetch
|
|
98
|
+
await vi.waitFor(() => {
|
|
99
|
+
expect(mockGetGitStatus).toHaveBeenCalledTimes(1);
|
|
100
|
+
});
|
|
101
|
+
// Clear to track subsequent calls
|
|
102
|
+
mockGetGitStatus.mockClear();
|
|
103
|
+
// Advance time and verify polling continues despite errors
|
|
104
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
105
|
+
expect(mockGetGitStatus).toHaveBeenCalledTimes(1);
|
|
106
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
107
|
+
expect(mockGetGitStatus).toHaveBeenCalledTimes(2);
|
|
108
|
+
// All calls should have been made despite continuous errors
|
|
109
|
+
expect(mockGetGitStatus).toHaveBeenCalledWith('/path1', expect.any(AbortSignal));
|
|
110
|
+
});
|
|
111
|
+
it('should handle slow git operations that exceed update interval', async () => {
|
|
112
|
+
const worktrees = [createWorktree('/path1')];
|
|
113
|
+
let fetchCount = 0;
|
|
114
|
+
let resolveFetch = null;
|
|
115
|
+
mockGetGitStatus.mockImplementation(async () => {
|
|
116
|
+
fetchCount++;
|
|
117
|
+
// Create a promise that we can resolve manually
|
|
118
|
+
return new Promise(resolve => {
|
|
119
|
+
resolveFetch = resolve;
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
const TestComponent = () => {
|
|
123
|
+
useGitStatus(worktrees, 'main', 100);
|
|
124
|
+
return React.createElement(Text, null, 'test');
|
|
125
|
+
};
|
|
126
|
+
render(React.createElement(TestComponent));
|
|
127
|
+
// Wait for initial fetch to start
|
|
128
|
+
await vi.waitFor(() => {
|
|
129
|
+
expect(mockGetGitStatus).toHaveBeenCalledTimes(1);
|
|
130
|
+
});
|
|
131
|
+
// Advance time past the update interval while fetch is still pending
|
|
132
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
133
|
+
// Should not have started a second fetch yet
|
|
134
|
+
expect(mockGetGitStatus).toHaveBeenCalledTimes(1);
|
|
135
|
+
// Complete the first fetch
|
|
136
|
+
resolveFetch({ success: true, data: createGitStatus(1, 0) });
|
|
137
|
+
// Wait for the promise to resolve
|
|
138
|
+
await vi.waitFor(() => {
|
|
139
|
+
expect(fetchCount).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
// Now advance time by the update interval
|
|
142
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
143
|
+
// Should have started the second fetch
|
|
144
|
+
await vi.waitFor(() => {
|
|
145
|
+
expect(mockGetGitStatus).toHaveBeenCalledTimes(2);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
it('should properly cleanup resources when worktrees change', async () => {
|
|
149
|
+
let activeRequests = 0;
|
|
150
|
+
const abortedSignals = [];
|
|
151
|
+
mockGetGitStatus.mockImplementation(async (path, signal) => {
|
|
152
|
+
activeRequests++;
|
|
153
|
+
signal.addEventListener('abort', () => {
|
|
154
|
+
activeRequests--;
|
|
155
|
+
abortedSignals.push(signal);
|
|
156
|
+
});
|
|
157
|
+
// Simulate ongoing request
|
|
158
|
+
return new Promise(() => { });
|
|
159
|
+
});
|
|
160
|
+
const TestComponent = ({ worktrees }) => {
|
|
161
|
+
useGitStatus(worktrees, 'main', 100);
|
|
162
|
+
return React.createElement(Text, null, 'test');
|
|
163
|
+
};
|
|
164
|
+
// Start with 3 worktrees
|
|
165
|
+
const initialWorktrees = [
|
|
166
|
+
createWorktree('/path1'),
|
|
167
|
+
createWorktree('/path2'),
|
|
168
|
+
createWorktree('/path3'),
|
|
169
|
+
];
|
|
170
|
+
const { rerender } = render(React.createElement(TestComponent, { worktrees: initialWorktrees }));
|
|
171
|
+
// Should have 3 active requests
|
|
172
|
+
await vi.waitFor(() => {
|
|
173
|
+
expect(activeRequests).toBe(3);
|
|
174
|
+
});
|
|
175
|
+
// Change to 2 different worktrees
|
|
176
|
+
const newWorktrees = [createWorktree('/path4'), createWorktree('/path5')];
|
|
177
|
+
rerender(React.createElement(TestComponent, { worktrees: newWorktrees }));
|
|
178
|
+
// Wait for cleanup and new requests
|
|
179
|
+
await vi.waitFor(() => {
|
|
180
|
+
expect(abortedSignals).toHaveLength(3);
|
|
181
|
+
expect(activeRequests).toBe(2);
|
|
182
|
+
});
|
|
183
|
+
// Verify all old signals were aborted
|
|
184
|
+
expect(abortedSignals.every(signal => signal.aborted)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
declare class WorktreeConfigManager {
|
|
2
|
+
private static instance;
|
|
3
|
+
private isExtensionAvailable;
|
|
4
|
+
private constructor();
|
|
5
|
+
static getInstance(): WorktreeConfigManager;
|
|
6
|
+
initialize(gitPath?: string): void;
|
|
7
|
+
isAvailable(): boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare const worktreeConfigManager: WorktreeConfigManager;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isWorktreeConfigEnabled } from '../utils/worktreeConfig.js';
|
|
2
|
+
class WorktreeConfigManager {
|
|
3
|
+
constructor() {
|
|
4
|
+
Object.defineProperty(this, "isExtensionAvailable", {
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true,
|
|
8
|
+
value: null
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
static getInstance() {
|
|
12
|
+
if (!WorktreeConfigManager.instance) {
|
|
13
|
+
WorktreeConfigManager.instance = new WorktreeConfigManager();
|
|
14
|
+
}
|
|
15
|
+
return WorktreeConfigManager.instance;
|
|
16
|
+
}
|
|
17
|
+
initialize(gitPath) {
|
|
18
|
+
this.isExtensionAvailable = isWorktreeConfigEnabled(gitPath);
|
|
19
|
+
}
|
|
20
|
+
isAvailable() {
|
|
21
|
+
if (this.isExtensionAvailable === null) {
|
|
22
|
+
throw new Error('WorktreeConfigManager not initialized');
|
|
23
|
+
}
|
|
24
|
+
return this.isExtensionAvailable;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export const worktreeConfigManager = WorktreeConfigManager.getInstance();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { setWorktreeParentBranch } from '../utils/worktreeConfig.js';
|
|
4
5
|
export class WorktreeService {
|
|
5
6
|
constructor(rootPath) {
|
|
6
7
|
Object.defineProperty(this, "rootPath", {
|
|
@@ -201,6 +202,13 @@ export class WorktreeService {
|
|
|
201
202
|
cwd: this.gitRootPath, // Execute from git root to ensure proper resolution
|
|
202
203
|
encoding: 'utf8',
|
|
203
204
|
});
|
|
205
|
+
// Store the parent branch in worktree config
|
|
206
|
+
try {
|
|
207
|
+
setWorktreeParentBranch(resolvedPath, baseBranch);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
console.error('Warning: Failed to set parent branch in worktree config:', error);
|
|
211
|
+
}
|
|
204
212
|
return { success: true };
|
|
205
213
|
}
|
|
206
214
|
catch (error) {
|
|
@@ -3,6 +3,14 @@ import { WorktreeService } from './worktreeService.js';
|
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
4
|
// Mock child_process module
|
|
5
5
|
vi.mock('child_process');
|
|
6
|
+
// Mock worktreeConfigManager
|
|
7
|
+
vi.mock('./worktreeConfigManager.js', () => ({
|
|
8
|
+
worktreeConfigManager: {
|
|
9
|
+
initialize: vi.fn(),
|
|
10
|
+
isAvailable: vi.fn(() => true),
|
|
11
|
+
reset: vi.fn(),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
6
14
|
// Get the mocked function with proper typing
|
|
7
15
|
const mockedExecSync = vi.mocked(execSync);
|
|
8
16
|
describe('WorktreeService', () => {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { IPty } from 'node-pty';
|
|
2
2
|
import type pkg from '@xterm/headless';
|
|
3
|
+
import { GitStatus } from '../utils/gitStatus.js';
|
|
3
4
|
export type Terminal = InstanceType<typeof pkg.Terminal>;
|
|
4
5
|
export type SessionState = 'idle' | 'busy' | 'waiting_input';
|
|
5
6
|
export interface Worktree {
|
|
@@ -7,6 +8,8 @@ export interface Worktree {
|
|
|
7
8
|
branch?: string;
|
|
8
9
|
isMainWorktree: boolean;
|
|
9
10
|
hasSession: boolean;
|
|
11
|
+
gitStatus?: GitStatus;
|
|
12
|
+
gitStatusError?: string;
|
|
10
13
|
}
|
|
11
14
|
export interface Session {
|
|
12
15
|
id: string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a function that limits concurrent executions
|
|
3
|
+
*/
|
|
4
|
+
export function createConcurrencyLimited(fn, maxConcurrent) {
|
|
5
|
+
if (maxConcurrent < 1) {
|
|
6
|
+
throw new RangeError('maxConcurrent must be at least 1');
|
|
7
|
+
}
|
|
8
|
+
let activeCount = 0;
|
|
9
|
+
const queue = [];
|
|
10
|
+
return async (...args) => {
|
|
11
|
+
// Wait for a slot if at capacity
|
|
12
|
+
if (activeCount >= maxConcurrent) {
|
|
13
|
+
await new Promise(resolve => {
|
|
14
|
+
queue.push(resolve);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
activeCount++;
|
|
18
|
+
try {
|
|
19
|
+
return await fn(...args);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
activeCount--;
|
|
23
|
+
// Release the next waiter in queue
|
|
24
|
+
const next = queue.shift();
|
|
25
|
+
if (next) {
|
|
26
|
+
next();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createConcurrencyLimited } from './concurrencyLimit.js';
|
|
3
|
+
describe('createConcurrencyLimited', () => {
|
|
4
|
+
it('should limit concurrent executions', async () => {
|
|
5
|
+
let running = 0;
|
|
6
|
+
let maxRunning = 0;
|
|
7
|
+
const task = async (id) => {
|
|
8
|
+
running++;
|
|
9
|
+
maxRunning = Math.max(maxRunning, running);
|
|
10
|
+
// Simulate work
|
|
11
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
12
|
+
running--;
|
|
13
|
+
return id;
|
|
14
|
+
};
|
|
15
|
+
const limitedTask = createConcurrencyLimited(task, 2);
|
|
16
|
+
// Start 5 tasks
|
|
17
|
+
const promises = [
|
|
18
|
+
limitedTask(1),
|
|
19
|
+
limitedTask(2),
|
|
20
|
+
limitedTask(3),
|
|
21
|
+
limitedTask(4),
|
|
22
|
+
limitedTask(5),
|
|
23
|
+
];
|
|
24
|
+
const results = await Promise.all(promises);
|
|
25
|
+
// All tasks should complete
|
|
26
|
+
expect(results).toEqual([1, 2, 3, 4, 5]);
|
|
27
|
+
// Max concurrent should not exceed limit
|
|
28
|
+
expect(maxRunning).toBeLessThanOrEqual(2);
|
|
29
|
+
// All tasks should have finished
|
|
30
|
+
expect(running).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
it('should handle errors without blocking queue', async () => {
|
|
33
|
+
let callCount = 0;
|
|
34
|
+
const original = async () => {
|
|
35
|
+
callCount++;
|
|
36
|
+
if (callCount === 1) {
|
|
37
|
+
throw new Error('Task failed');
|
|
38
|
+
}
|
|
39
|
+
return 'success';
|
|
40
|
+
};
|
|
41
|
+
const limited = createConcurrencyLimited(original, 1);
|
|
42
|
+
// Start failing task first
|
|
43
|
+
const promise1 = limited().catch(e => e.message);
|
|
44
|
+
// Queue successful task
|
|
45
|
+
const promise2 = limited();
|
|
46
|
+
const results = await Promise.all([promise1, promise2]);
|
|
47
|
+
expect(results[0]).toBe('Task failed');
|
|
48
|
+
expect(results[1]).toBe('success');
|
|
49
|
+
});
|
|
50
|
+
it('should preserve function arguments', async () => {
|
|
51
|
+
const original = async (a, b, c) => {
|
|
52
|
+
return `${a}-${b}-${c}`;
|
|
53
|
+
};
|
|
54
|
+
const limited = createConcurrencyLimited(original, 1);
|
|
55
|
+
const result = await limited(42, 'test', true);
|
|
56
|
+
expect(result).toBe('42-test-true');
|
|
57
|
+
});
|
|
58
|
+
it('should throw for invalid maxConcurrent', () => {
|
|
59
|
+
const fn = async () => 'test';
|
|
60
|
+
expect(() => createConcurrencyLimited(fn, 0)).toThrow('maxConcurrent must be at least 1');
|
|
61
|
+
expect(() => createConcurrencyLimited(fn, -1)).toThrow('maxConcurrent must be at least 1');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface GitStatus {
|
|
2
|
+
filesAdded: number;
|
|
3
|
+
filesDeleted: number;
|
|
4
|
+
aheadCount: number;
|
|
5
|
+
behindCount: number;
|
|
6
|
+
parentBranch: string | null;
|
|
7
|
+
}
|
|
8
|
+
export interface GitOperationResult<T> {
|
|
9
|
+
success: boolean;
|
|
10
|
+
data?: T;
|
|
11
|
+
error?: string;
|
|
12
|
+
skipped?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function getGitStatus(worktreePath: string, signal: AbortSignal): Promise<GitOperationResult<GitStatus>>;
|
|
15
|
+
export declare function formatGitFileChanges(status: GitStatus): string;
|
|
16
|
+
export declare function formatGitAheadBehind(status: GitStatus): string;
|
|
17
|
+
export declare function formatGitStatus(status: GitStatus): string;
|
|
18
|
+
export declare function formatParentBranch(parentBranch: string | null, currentBranch: string): string;
|
|
19
|
+
export declare const getGitStatusLimited: (worktreePath: string, signal: AbortSignal) => Promise<GitOperationResult<GitStatus>>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { promisify } from 'util';
|
|
2
|
+
import { exec, execFile } from 'child_process';
|
|
3
|
+
import { getWorktreeParentBranch } from './worktreeConfig.js';
|
|
4
|
+
import { createConcurrencyLimited } from './concurrencyLimit.js';
|
|
5
|
+
const execp = promisify(exec);
|
|
6
|
+
const execFilePromisified = promisify(execFile);
|
|
7
|
+
export async function getGitStatus(worktreePath, signal) {
|
|
8
|
+
try {
|
|
9
|
+
// Get unstaged changes
|
|
10
|
+
const [diffResult, stagedResult, branchResult, parentBranch] = await Promise.all([
|
|
11
|
+
execp('git diff --shortstat', { cwd: worktreePath, signal }).catch(() => EMPTY_EXEC_RESULT),
|
|
12
|
+
execp('git diff --staged --shortstat', {
|
|
13
|
+
cwd: worktreePath,
|
|
14
|
+
signal,
|
|
15
|
+
}).catch(() => EMPTY_EXEC_RESULT),
|
|
16
|
+
execp('git branch --show-current', { cwd: worktreePath, signal }).catch(() => EMPTY_EXEC_RESULT),
|
|
17
|
+
getWorktreeParentBranch(worktreePath, signal),
|
|
18
|
+
]);
|
|
19
|
+
// Parse file changes
|
|
20
|
+
let filesAdded = 0;
|
|
21
|
+
let filesDeleted = 0;
|
|
22
|
+
if (diffResult.stdout) {
|
|
23
|
+
const stats = parseGitStats(diffResult.stdout);
|
|
24
|
+
filesAdded += stats.insertions;
|
|
25
|
+
filesDeleted += stats.deletions;
|
|
26
|
+
}
|
|
27
|
+
if (stagedResult.stdout) {
|
|
28
|
+
const stats = parseGitStats(stagedResult.stdout);
|
|
29
|
+
filesAdded += stats.insertions;
|
|
30
|
+
filesDeleted += stats.deletions;
|
|
31
|
+
}
|
|
32
|
+
// Get ahead/behind counts
|
|
33
|
+
let aheadCount = 0;
|
|
34
|
+
let behindCount = 0;
|
|
35
|
+
const currentBranch = branchResult.stdout.trim();
|
|
36
|
+
if (currentBranch && parentBranch && currentBranch !== parentBranch) {
|
|
37
|
+
try {
|
|
38
|
+
const aheadBehindResult = await execFilePromisified('git', ['rev-list', '--left-right', '--count', `${parentBranch}...HEAD`], { cwd: worktreePath, signal });
|
|
39
|
+
const [behind, ahead] = aheadBehindResult.stdout
|
|
40
|
+
.trim()
|
|
41
|
+
.split('\t')
|
|
42
|
+
.map(n => parseInt(n, 10));
|
|
43
|
+
aheadCount = ahead || 0;
|
|
44
|
+
behindCount = behind || 0;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Branch comparison might fail
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
success: true,
|
|
52
|
+
data: {
|
|
53
|
+
filesAdded,
|
|
54
|
+
filesDeleted,
|
|
55
|
+
aheadCount,
|
|
56
|
+
behindCount,
|
|
57
|
+
parentBranch,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
let errorMessage = '';
|
|
63
|
+
if (error instanceof Error) {
|
|
64
|
+
errorMessage = error.message;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
errorMessage = String(error);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
success: false,
|
|
71
|
+
error: errorMessage,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Split git status formatting into file changes and ahead/behind
|
|
76
|
+
export function formatGitFileChanges(status) {
|
|
77
|
+
const parts = [];
|
|
78
|
+
const colors = {
|
|
79
|
+
green: '\x1b[32m',
|
|
80
|
+
red: '\x1b[31m',
|
|
81
|
+
reset: '\x1b[0m',
|
|
82
|
+
};
|
|
83
|
+
// File changes
|
|
84
|
+
if (status.filesAdded > 0) {
|
|
85
|
+
parts.push(`${colors.green}+${status.filesAdded}${colors.reset}`);
|
|
86
|
+
}
|
|
87
|
+
if (status.filesDeleted > 0) {
|
|
88
|
+
parts.push(`${colors.red}-${status.filesDeleted}${colors.reset}`);
|
|
89
|
+
}
|
|
90
|
+
return parts.join(' ');
|
|
91
|
+
}
|
|
92
|
+
export function formatGitAheadBehind(status) {
|
|
93
|
+
const parts = [];
|
|
94
|
+
const colors = {
|
|
95
|
+
cyan: '\x1b[36m',
|
|
96
|
+
magenta: '\x1b[35m',
|
|
97
|
+
reset: '\x1b[0m',
|
|
98
|
+
};
|
|
99
|
+
// Ahead/behind - compact format with arrows
|
|
100
|
+
if (status.aheadCount > 0) {
|
|
101
|
+
parts.push(`${colors.cyan}↑${status.aheadCount}${colors.reset}`);
|
|
102
|
+
}
|
|
103
|
+
if (status.behindCount > 0) {
|
|
104
|
+
parts.push(`${colors.magenta}↓${status.behindCount}${colors.reset}`);
|
|
105
|
+
}
|
|
106
|
+
return parts.join(' ');
|
|
107
|
+
}
|
|
108
|
+
// Keep the original function for backward compatibility
|
|
109
|
+
export function formatGitStatus(status) {
|
|
110
|
+
const fileChanges = formatGitFileChanges(status);
|
|
111
|
+
const aheadBehind = formatGitAheadBehind(status);
|
|
112
|
+
const parts = [];
|
|
113
|
+
if (fileChanges)
|
|
114
|
+
parts.push(fileChanges);
|
|
115
|
+
if (aheadBehind)
|
|
116
|
+
parts.push(aheadBehind);
|
|
117
|
+
return parts.join(' ');
|
|
118
|
+
}
|
|
119
|
+
export function formatParentBranch(parentBranch, currentBranch) {
|
|
120
|
+
// Only show parent branch if it exists and is different from current branch
|
|
121
|
+
if (!parentBranch || parentBranch === currentBranch) {
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
const colors = {
|
|
125
|
+
dim: '\x1b[90m',
|
|
126
|
+
reset: '\x1b[0m',
|
|
127
|
+
};
|
|
128
|
+
return `${colors.dim}(${parentBranch})${colors.reset}`;
|
|
129
|
+
}
|
|
130
|
+
const EMPTY_EXEC_RESULT = { stdout: '', stderr: '' };
|
|
131
|
+
function parseGitStats(statLine) {
|
|
132
|
+
let insertions = 0;
|
|
133
|
+
let deletions = 0;
|
|
134
|
+
// Parse git diff --shortstat output
|
|
135
|
+
// Example: " 3 files changed, 42 insertions(+), 10 deletions(-)"
|
|
136
|
+
const insertMatch = statLine.match(/(\d+) insertion/);
|
|
137
|
+
const deleteMatch = statLine.match(/(\d+) deletion/);
|
|
138
|
+
if (insertMatch && insertMatch[1]) {
|
|
139
|
+
insertions = parseInt(insertMatch[1], 10);
|
|
140
|
+
}
|
|
141
|
+
if (deleteMatch && deleteMatch[1]) {
|
|
142
|
+
deletions = parseInt(deleteMatch[1], 10);
|
|
143
|
+
}
|
|
144
|
+
return { insertions, deletions };
|
|
145
|
+
}
|
|
146
|
+
export const getGitStatusLimited = createConcurrencyLimited(getGitStatus, 10);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { formatGitStatus, formatGitFileChanges, formatGitAheadBehind, formatParentBranch, getGitStatus, } from './gitStatus.js';
|
|
3
|
+
import { exec } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
// Mock worktreeConfigManager
|
|
9
|
+
vi.mock('../services/worktreeConfigManager.js', () => ({
|
|
10
|
+
worktreeConfigManager: {
|
|
11
|
+
initialize: vi.fn(),
|
|
12
|
+
isAvailable: vi.fn(() => true),
|
|
13
|
+
reset: vi.fn(),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
const execAsync = promisify(exec);
|
|
17
|
+
describe('formatGitStatus', () => {
|
|
18
|
+
it('should format status with ANSI colors', () => {
|
|
19
|
+
const status = {
|
|
20
|
+
filesAdded: 42,
|
|
21
|
+
filesDeleted: 10,
|
|
22
|
+
aheadCount: 5,
|
|
23
|
+
behindCount: 3,
|
|
24
|
+
parentBranch: 'main',
|
|
25
|
+
};
|
|
26
|
+
const formatted = formatGitStatus(status);
|
|
27
|
+
expect(formatted).toBe('\x1b[32m+42\x1b[0m \x1b[31m-10\x1b[0m \x1b[36m↑5\x1b[0m \x1b[35m↓3\x1b[0m');
|
|
28
|
+
});
|
|
29
|
+
it('should use formatGitStatusWithColors as alias', () => {
|
|
30
|
+
const status = {
|
|
31
|
+
filesAdded: 1,
|
|
32
|
+
filesDeleted: 2,
|
|
33
|
+
aheadCount: 3,
|
|
34
|
+
behindCount: 4,
|
|
35
|
+
parentBranch: 'main',
|
|
36
|
+
};
|
|
37
|
+
const withColors = formatGitStatus(status);
|
|
38
|
+
const withColorsParam = formatGitStatus(status);
|
|
39
|
+
expect(withColors).toBe(withColorsParam);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('GitService Integration Tests', { timeout: 10000 }, () => {
|
|
43
|
+
it('should handle concurrent calls correctly', async () => {
|
|
44
|
+
// Create a temporary git repo for testing
|
|
45
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccmanager-test-'));
|
|
46
|
+
try {
|
|
47
|
+
// Initialize git repo
|
|
48
|
+
await execAsync('git init', { cwd: tmpDir });
|
|
49
|
+
await execAsync('git config user.email "test@example.com"', {
|
|
50
|
+
cwd: tmpDir,
|
|
51
|
+
});
|
|
52
|
+
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
|
|
53
|
+
await execAsync('git config commit.gpgsign false', { cwd: tmpDir });
|
|
54
|
+
// Create a file and commit
|
|
55
|
+
fs.writeFileSync(path.join(tmpDir, 'test.txt'), 'Hello World');
|
|
56
|
+
await execAsync('git add test.txt', { cwd: tmpDir });
|
|
57
|
+
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
|
|
58
|
+
// Test concurrent calls - all should succeed now without locking
|
|
59
|
+
// Create abort controllers for each call
|
|
60
|
+
const controller1 = new AbortController();
|
|
61
|
+
const controller2 = new AbortController();
|
|
62
|
+
const controller3 = new AbortController();
|
|
63
|
+
const results = await Promise.all([
|
|
64
|
+
getGitStatus(tmpDir, controller1.signal),
|
|
65
|
+
getGitStatus(tmpDir, controller2.signal),
|
|
66
|
+
getGitStatus(tmpDir, controller3.signal),
|
|
67
|
+
]);
|
|
68
|
+
// All should succeed
|
|
69
|
+
const successCount = results.filter(r => r.success).length;
|
|
70
|
+
expect(successCount).toBe(3);
|
|
71
|
+
// All results should have the same data
|
|
72
|
+
const firstData = results[0].data;
|
|
73
|
+
results.forEach(result => {
|
|
74
|
+
expect(result.success).toBe(true);
|
|
75
|
+
expect(result.data).toEqual(firstData);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
// Cleanup
|
|
80
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('formatGitFileChanges', () => {
|
|
85
|
+
it('should format only file changes', () => {
|
|
86
|
+
const status = {
|
|
87
|
+
filesAdded: 10,
|
|
88
|
+
filesDeleted: 5,
|
|
89
|
+
aheadCount: 3,
|
|
90
|
+
behindCount: 2,
|
|
91
|
+
parentBranch: 'main',
|
|
92
|
+
};
|
|
93
|
+
expect(formatGitFileChanges(status)).toBe('\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m');
|
|
94
|
+
});
|
|
95
|
+
it('should handle zero file changes', () => {
|
|
96
|
+
const status = {
|
|
97
|
+
filesAdded: 0,
|
|
98
|
+
filesDeleted: 0,
|
|
99
|
+
aheadCount: 3,
|
|
100
|
+
behindCount: 2,
|
|
101
|
+
parentBranch: 'main',
|
|
102
|
+
};
|
|
103
|
+
expect(formatGitFileChanges(status)).toBe('');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('formatGitAheadBehind', () => {
|
|
107
|
+
it('should format only ahead/behind markers', () => {
|
|
108
|
+
const status = {
|
|
109
|
+
filesAdded: 10,
|
|
110
|
+
filesDeleted: 5,
|
|
111
|
+
aheadCount: 3,
|
|
112
|
+
behindCount: 2,
|
|
113
|
+
parentBranch: 'main',
|
|
114
|
+
};
|
|
115
|
+
expect(formatGitAheadBehind(status)).toBe('\x1b[36m↑3\x1b[0m \x1b[35m↓2\x1b[0m');
|
|
116
|
+
});
|
|
117
|
+
it('should handle zero ahead/behind', () => {
|
|
118
|
+
const status = {
|
|
119
|
+
filesAdded: 10,
|
|
120
|
+
filesDeleted: 5,
|
|
121
|
+
aheadCount: 0,
|
|
122
|
+
behindCount: 0,
|
|
123
|
+
parentBranch: 'main',
|
|
124
|
+
};
|
|
125
|
+
expect(formatGitAheadBehind(status)).toBe('');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe('formatParentBranch', () => {
|
|
129
|
+
it('should return empty string when parent and current branch are the same', () => {
|
|
130
|
+
expect(formatParentBranch('main', 'main')).toBe('');
|
|
131
|
+
expect(formatParentBranch('feature', 'feature')).toBe('');
|
|
132
|
+
});
|
|
133
|
+
it('should format parent branch when different from current', () => {
|
|
134
|
+
expect(formatParentBranch('main', 'feature')).toBe('\x1b[90m(main)\x1b[0m');
|
|
135
|
+
expect(formatParentBranch('develop', 'feature-123')).toBe('\x1b[90m(develop)\x1b[0m');
|
|
136
|
+
});
|
|
137
|
+
it('should include color codes', () => {
|
|
138
|
+
const formatted = formatParentBranch('main', 'feature');
|
|
139
|
+
expect(formatted).toBe('\x1b[90m(main)\x1b[0m');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function isWorktreeConfigEnabled(gitPath?: string): boolean;
|
|
2
|
+
export declare function getWorktreeParentBranch(worktreePath: string, signal?: AbortSignal): Promise<string | null>;
|
|
3
|
+
export declare function setWorktreeParentBranch(worktreePath: string, parentBranch: string): void;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { promisify } from 'util';
|
|
2
|
+
import { exec, execSync, execFileSync } from 'child_process';
|
|
3
|
+
import { worktreeConfigManager } from '../services/worktreeConfigManager.js';
|
|
4
|
+
const execp = promisify(exec);
|
|
5
|
+
export function isWorktreeConfigEnabled(gitPath) {
|
|
6
|
+
try {
|
|
7
|
+
const result = execSync('git config extensions.worktreeConfig', {
|
|
8
|
+
cwd: gitPath || process.cwd(),
|
|
9
|
+
encoding: 'utf8',
|
|
10
|
+
}).trim();
|
|
11
|
+
return result === 'true';
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function getWorktreeParentBranch(worktreePath, signal) {
|
|
18
|
+
// Return null if worktree config extension is not available
|
|
19
|
+
if (!worktreeConfigManager.isAvailable()) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const result = await execp('git config --worktree ccmanager.parentBranch', {
|
|
24
|
+
cwd: worktreePath,
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
signal,
|
|
27
|
+
});
|
|
28
|
+
return result.stdout.trim() || null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function setWorktreeParentBranch(worktreePath, parentBranch) {
|
|
35
|
+
// Skip if worktree config extension is not available
|
|
36
|
+
if (!worktreeConfigManager.isAvailable()) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
execFileSync('git', ['config', '--worktree', 'ccmanager.parentBranch', parentBranch], {
|
|
40
|
+
cwd: worktreePath,
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -1,5 +1,42 @@
|
|
|
1
|
+
import { Worktree, Session } from '../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Worktree item with formatted content for display.
|
|
4
|
+
*/
|
|
5
|
+
interface WorktreeItem {
|
|
6
|
+
worktree: Worktree;
|
|
7
|
+
session?: Session;
|
|
8
|
+
baseLabel: string;
|
|
9
|
+
fileChanges: string;
|
|
10
|
+
aheadBehind: string;
|
|
11
|
+
parentBranch: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
lengths: {
|
|
14
|
+
base: number;
|
|
15
|
+
fileChanges: number;
|
|
16
|
+
aheadBehind: number;
|
|
17
|
+
parentBranch: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export declare function truncateString(str: string, maxLength: number): string;
|
|
1
21
|
export declare function generateWorktreeDirectory(branchName: string, pattern?: string): string;
|
|
2
22
|
export declare function extractBranchParts(branchName: string): {
|
|
3
23
|
prefix?: string;
|
|
4
24
|
name: string;
|
|
5
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Prepares worktree content for display with plain and colored versions.
|
|
28
|
+
*/
|
|
29
|
+
export declare function prepareWorktreeItems(worktrees: Worktree[], sessions: Session[]): WorktreeItem[];
|
|
30
|
+
/**
|
|
31
|
+
* Calculates column positions based on content widths.
|
|
32
|
+
*/
|
|
33
|
+
export declare function calculateColumnPositions(items: WorktreeItem[]): {
|
|
34
|
+
fileChanges: number;
|
|
35
|
+
aheadBehind: number;
|
|
36
|
+
parentBranch: number;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Assembles the final worktree label with proper column alignment
|
|
40
|
+
*/
|
|
41
|
+
export declare function assembleWorktreeLabel(item: WorktreeItem, columns: ReturnType<typeof calculateColumnPositions>): string;
|
|
42
|
+
export {};
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
+
import { getStatusDisplay } from '../constants/statusIcons.js';
|
|
3
|
+
import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from './gitStatus.js';
|
|
4
|
+
// Constants
|
|
5
|
+
const MAX_BRANCH_NAME_LENGTH = 40; // Maximum characters for branch name display
|
|
6
|
+
const MIN_COLUMN_PADDING = 2; // Minimum spaces between columns
|
|
7
|
+
// Strip ANSI escape codes for length calculation
|
|
8
|
+
const stripAnsi = (str) => str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
9
|
+
// Utility function to truncate strings with ellipsis
|
|
10
|
+
export function truncateString(str, maxLength) {
|
|
11
|
+
if (str.length <= maxLength)
|
|
12
|
+
return str;
|
|
13
|
+
return str.substring(0, maxLength - 3) + '...';
|
|
14
|
+
}
|
|
2
15
|
export function generateWorktreeDirectory(branchName, pattern) {
|
|
3
16
|
// Default pattern if not specified
|
|
4
17
|
const defaultPattern = '../{branch}';
|
|
@@ -27,3 +40,104 @@ export function extractBranchParts(branchName) {
|
|
|
27
40
|
}
|
|
28
41
|
return { name: branchName };
|
|
29
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Prepares worktree content for display with plain and colored versions.
|
|
45
|
+
*/
|
|
46
|
+
export function prepareWorktreeItems(worktrees, sessions) {
|
|
47
|
+
return worktrees.map(wt => {
|
|
48
|
+
const session = sessions.find(s => s.worktreePath === wt.path);
|
|
49
|
+
const status = session ? ` [${getStatusDisplay(session.state)}]` : '';
|
|
50
|
+
const fullBranchName = wt.branch
|
|
51
|
+
? wt.branch.replace('refs/heads/', '')
|
|
52
|
+
: 'detached';
|
|
53
|
+
const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
|
|
54
|
+
const isMain = wt.isMainWorktree ? ' (main)' : '';
|
|
55
|
+
const baseLabel = `${branchName}${isMain}${status}`;
|
|
56
|
+
let fileChanges = '';
|
|
57
|
+
let aheadBehind = '';
|
|
58
|
+
let parentBranch = '';
|
|
59
|
+
let error = '';
|
|
60
|
+
if (wt.gitStatus) {
|
|
61
|
+
fileChanges = formatGitFileChanges(wt.gitStatus);
|
|
62
|
+
aheadBehind = formatGitAheadBehind(wt.gitStatus);
|
|
63
|
+
parentBranch = formatParentBranch(wt.gitStatus.parentBranch, fullBranchName);
|
|
64
|
+
}
|
|
65
|
+
else if (wt.gitStatusError) {
|
|
66
|
+
// Format error in red
|
|
67
|
+
error = `\x1b[31m[git error]\x1b[0m`;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Show fetching status in dim gray
|
|
71
|
+
fileChanges = '\x1b[90m[fetching...]\x1b[0m';
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
worktree: wt,
|
|
75
|
+
session,
|
|
76
|
+
baseLabel,
|
|
77
|
+
fileChanges,
|
|
78
|
+
aheadBehind,
|
|
79
|
+
parentBranch,
|
|
80
|
+
error,
|
|
81
|
+
lengths: {
|
|
82
|
+
base: stripAnsi(baseLabel).length,
|
|
83
|
+
fileChanges: stripAnsi(fileChanges).length,
|
|
84
|
+
aheadBehind: stripAnsi(aheadBehind).length,
|
|
85
|
+
parentBranch: stripAnsi(parentBranch).length,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Calculates column positions based on content widths.
|
|
92
|
+
*/
|
|
93
|
+
export function calculateColumnPositions(items) {
|
|
94
|
+
// Calculate maximum widths from pre-calculated lengths
|
|
95
|
+
let maxBranchLength = 0;
|
|
96
|
+
let maxFileChangesLength = 0;
|
|
97
|
+
let maxAheadBehindLength = 0;
|
|
98
|
+
items.forEach(item => {
|
|
99
|
+
// Skip items with errors for alignment calculation
|
|
100
|
+
if (item.error)
|
|
101
|
+
return;
|
|
102
|
+
maxBranchLength = Math.max(maxBranchLength, item.lengths.base);
|
|
103
|
+
maxFileChangesLength = Math.max(maxFileChangesLength, item.lengths.fileChanges);
|
|
104
|
+
maxAheadBehindLength = Math.max(maxAheadBehindLength, item.lengths.aheadBehind);
|
|
105
|
+
});
|
|
106
|
+
// Simple column positioning
|
|
107
|
+
const fileChangesColumn = maxBranchLength + MIN_COLUMN_PADDING;
|
|
108
|
+
const aheadBehindColumn = fileChangesColumn + maxFileChangesLength + MIN_COLUMN_PADDING + 2;
|
|
109
|
+
const parentBranchColumn = aheadBehindColumn + maxAheadBehindLength + MIN_COLUMN_PADDING + 2;
|
|
110
|
+
return {
|
|
111
|
+
fileChanges: fileChangesColumn,
|
|
112
|
+
aheadBehind: aheadBehindColumn,
|
|
113
|
+
parentBranch: parentBranchColumn,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// Pad string to column position
|
|
117
|
+
function padTo(str, visibleLength, column) {
|
|
118
|
+
return str + ' '.repeat(Math.max(0, column - visibleLength));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Assembles the final worktree label with proper column alignment
|
|
122
|
+
*/
|
|
123
|
+
export function assembleWorktreeLabel(item, columns) {
|
|
124
|
+
// If there's an error, just show the base label with error appended
|
|
125
|
+
if (item.error) {
|
|
126
|
+
return `${item.baseLabel} ${item.error}`;
|
|
127
|
+
}
|
|
128
|
+
let label = item.baseLabel;
|
|
129
|
+
let currentLength = item.lengths.base;
|
|
130
|
+
if (item.fileChanges) {
|
|
131
|
+
label = padTo(label, currentLength, columns.fileChanges) + item.fileChanges;
|
|
132
|
+
currentLength = columns.fileChanges + item.lengths.fileChanges;
|
|
133
|
+
}
|
|
134
|
+
if (item.aheadBehind) {
|
|
135
|
+
label = padTo(label, currentLength, columns.aheadBehind) + item.aheadBehind;
|
|
136
|
+
currentLength = columns.aheadBehind + item.lengths.aheadBehind;
|
|
137
|
+
}
|
|
138
|
+
if (item.parentBranch) {
|
|
139
|
+
label =
|
|
140
|
+
padTo(label, currentLength, columns.parentBranch) + item.parentBranch;
|
|
141
|
+
}
|
|
142
|
+
return label;
|
|
143
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { generateWorktreeDirectory, extractBranchParts, } from './worktreeUtils.js';
|
|
2
|
+
import { generateWorktreeDirectory, extractBranchParts, truncateString, prepareWorktreeItems, calculateColumnPositions, assembleWorktreeLabel, } from './worktreeUtils.js';
|
|
3
3
|
describe('generateWorktreeDirectory', () => {
|
|
4
4
|
describe('with default pattern', () => {
|
|
5
5
|
it('should generate directory with sanitized branch name', () => {
|
|
@@ -72,3 +72,107 @@ describe('extractBranchParts', () => {
|
|
|
72
72
|
});
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
|
+
describe('truncateString', () => {
|
|
76
|
+
it('should return original string if shorter than max length', () => {
|
|
77
|
+
expect(truncateString('hello', 10)).toBe('hello');
|
|
78
|
+
expect(truncateString('test', 4)).toBe('test');
|
|
79
|
+
});
|
|
80
|
+
it('should truncate and add ellipsis if longer than max length', () => {
|
|
81
|
+
expect(truncateString('hello world', 8)).toBe('hello...');
|
|
82
|
+
expect(truncateString('this is a long string', 10)).toBe('this is...');
|
|
83
|
+
});
|
|
84
|
+
it('should handle edge cases', () => {
|
|
85
|
+
expect(truncateString('', 5)).toBe('');
|
|
86
|
+
expect(truncateString('abc', 3)).toBe('abc');
|
|
87
|
+
expect(truncateString('abcd', 3)).toBe('...');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('prepareWorktreeItems', () => {
|
|
91
|
+
const mockWorktree = {
|
|
92
|
+
path: '/path/to/worktree',
|
|
93
|
+
branch: 'feature/test-branch',
|
|
94
|
+
isMainWorktree: false,
|
|
95
|
+
hasSession: false,
|
|
96
|
+
};
|
|
97
|
+
// Simplified mock
|
|
98
|
+
const mockSession = {
|
|
99
|
+
id: 'test-session',
|
|
100
|
+
worktreePath: '/path/to/worktree',
|
|
101
|
+
state: 'idle',
|
|
102
|
+
process: {},
|
|
103
|
+
output: [],
|
|
104
|
+
outputHistory: [],
|
|
105
|
+
lastActivity: new Date(),
|
|
106
|
+
isActive: true,
|
|
107
|
+
terminal: {},
|
|
108
|
+
};
|
|
109
|
+
it('should prepare basic worktree without git status', () => {
|
|
110
|
+
const items = prepareWorktreeItems([mockWorktree], []);
|
|
111
|
+
expect(items).toHaveLength(1);
|
|
112
|
+
expect(items[0]?.baseLabel).toBe('feature/test-branch');
|
|
113
|
+
});
|
|
114
|
+
it('should include session status in label', () => {
|
|
115
|
+
const items = prepareWorktreeItems([mockWorktree], [mockSession]);
|
|
116
|
+
expect(items[0]?.baseLabel).toContain('[○ Idle]');
|
|
117
|
+
});
|
|
118
|
+
it('should mark main worktree', () => {
|
|
119
|
+
const mainWorktree = { ...mockWorktree, isMainWorktree: true };
|
|
120
|
+
const items = prepareWorktreeItems([mainWorktree], []);
|
|
121
|
+
expect(items[0]?.baseLabel).toContain('(main)');
|
|
122
|
+
});
|
|
123
|
+
it('should truncate long branch names', () => {
|
|
124
|
+
const longBranch = {
|
|
125
|
+
...mockWorktree,
|
|
126
|
+
branch: 'feature/this-is-a-very-long-branch-name-that-should-be-truncated',
|
|
127
|
+
};
|
|
128
|
+
const items = prepareWorktreeItems([longBranch], []);
|
|
129
|
+
expect(items[0]?.baseLabel.length).toBeLessThanOrEqual(50); // 40 + status + default
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe('column alignment', () => {
|
|
133
|
+
const mockItems = [
|
|
134
|
+
{
|
|
135
|
+
worktree: {},
|
|
136
|
+
baseLabel: 'feature/test-branch',
|
|
137
|
+
fileChanges: '\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m',
|
|
138
|
+
aheadBehind: '\x1b[33m↑2 ↓3\x1b[0m',
|
|
139
|
+
parentBranch: '',
|
|
140
|
+
lengths: {
|
|
141
|
+
base: 19, // 'feature/test-branch'.length
|
|
142
|
+
fileChanges: 6, // '+10 -5'.length
|
|
143
|
+
aheadBehind: 5, // '↑2 ↓3'.length
|
|
144
|
+
parentBranch: 0,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
worktree: {},
|
|
149
|
+
baseLabel: 'main',
|
|
150
|
+
fileChanges: '\x1b[32m+2\x1b[0m \x1b[31m-1\x1b[0m',
|
|
151
|
+
aheadBehind: '\x1b[33m↑1\x1b[0m',
|
|
152
|
+
parentBranch: '',
|
|
153
|
+
lengths: {
|
|
154
|
+
base: 4, // 'main'.length
|
|
155
|
+
fileChanges: 5, // '+2 -1'.length
|
|
156
|
+
aheadBehind: 2, // '↑1'.length
|
|
157
|
+
parentBranch: 0,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
it('should calculate column positions from items', () => {
|
|
162
|
+
const positions = calculateColumnPositions(mockItems);
|
|
163
|
+
expect(positions.fileChanges).toBe(21); // 19 + 2 padding
|
|
164
|
+
expect(positions.aheadBehind).toBeGreaterThan(positions.fileChanges);
|
|
165
|
+
expect(positions.parentBranch).toBeGreaterThan(positions.aheadBehind);
|
|
166
|
+
});
|
|
167
|
+
it('should assemble label with proper alignment', () => {
|
|
168
|
+
const item = mockItems[0];
|
|
169
|
+
const columns = calculateColumnPositions(mockItems);
|
|
170
|
+
const result = assembleWorktreeLabel(item, columns);
|
|
171
|
+
expect(result).toContain('feature/test-branch');
|
|
172
|
+
expect(result).toContain('\x1b[32m+10\x1b[0m');
|
|
173
|
+
expect(result).toContain('\x1b[33m↑2 ↓3\x1b[0m');
|
|
174
|
+
// Check alignment by stripping ANSI codes
|
|
175
|
+
const plain = result.replace(/\x1b\[[0-9;]*m/g, '');
|
|
176
|
+
expect(plain.indexOf('+10 -5')).toBe(21); // Should start at column 21
|
|
177
|
+
});
|
|
178
|
+
});
|