ccmanager 0.1.14 → 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 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));
@@ -1,31 +1,32 @@
1
1
  import React, { useState } from 'react';
2
- import { Box, Text } from 'ink';
2
+ import { Box, Text, useInput } from 'ink';
3
3
  import SelectInput from 'ink-select-input';
4
4
  import ConfigureShortcuts from './ConfigureShortcuts.js';
5
5
  import ConfigureHooks from './ConfigureHooks.js';
6
6
  import ConfigureWorktree from './ConfigureWorktree.js';
7
7
  import ConfigureCommand from './ConfigureCommand.js';
8
+ import { shortcutManager } from '../services/shortcutManager.js';
8
9
  const Configuration = ({ onComplete }) => {
9
10
  const [view, setView] = useState('menu');
10
11
  const menuItems = [
11
12
  {
12
- label: '⌨ Configure Shortcuts',
13
+ label: 'S ⌨ Configure Shortcuts',
13
14
  value: 'shortcuts',
14
15
  },
15
16
  {
16
- label: '🔧 Configure Status Hooks',
17
+ label: 'H 🔧 Configure Status Hooks',
17
18
  value: 'hooks',
18
19
  },
19
20
  {
20
- label: '📁 Configure Worktree Settings',
21
+ label: 'W 📁 Configure Worktree Settings',
21
22
  value: 'worktree',
22
23
  },
23
24
  {
24
- label: '🚀 Configure Command',
25
+ label: 'C 🚀 Configure Command',
25
26
  value: 'command',
26
27
  },
27
28
  {
28
- label: '← Back to Main Menu',
29
+ label: 'B ← Back to Main Menu',
29
30
  value: 'back',
30
31
  },
31
32
  ];
@@ -49,6 +50,33 @@ const Configuration = ({ onComplete }) => {
49
50
  const handleSubMenuComplete = () => {
50
51
  setView('menu');
51
52
  };
53
+ // Handle hotkeys (only when in menu view)
54
+ useInput((input, key) => {
55
+ if (view !== 'menu')
56
+ return; // Only handle hotkeys in menu view
57
+ const keyPressed = input.toLowerCase();
58
+ switch (keyPressed) {
59
+ case 's':
60
+ setView('shortcuts');
61
+ break;
62
+ case 'h':
63
+ setView('hooks');
64
+ break;
65
+ case 'w':
66
+ setView('worktree');
67
+ break;
68
+ case 'c':
69
+ setView('command');
70
+ break;
71
+ case 'b':
72
+ onComplete();
73
+ break;
74
+ }
75
+ // Handle escape key
76
+ if (shortcutManager.matchesShortcut('cancel', input, key)) {
77
+ onComplete();
78
+ }
79
+ });
52
80
  if (view === 'shortcuts') {
53
81
  return React.createElement(ConfigureShortcuts, { onComplete: handleSubMenuComplete });
54
82
  }
@@ -1,17 +1,22 @@
1
1
  import React, { useState, useEffect } from 'react';
2
- import { Box, Text } from 'ink';
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, getStatusDisplay, } from '../constants/statusIcons.js';
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 [worktrees, setWorktrees] = useState([]);
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
- setWorktrees(loadedWorktrees);
18
+ setBaseWorktrees(loadedWorktrees);
19
+ setDefaultBranch(worktreeService.getDefaultBranch());
15
20
  // Update sessions
16
21
  const updateSessions = () => {
17
22
  const allSessions = sessionManager.getAllSessions();
@@ -34,21 +39,18 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
34
39
  };
35
40
  }, [sessionManager]);
36
41
  useEffect(() => {
37
- // Build menu items
38
- const menuItems = worktrees.map(wt => {
39
- const session = sessions.find(s => s.worktreePath === wt.path);
40
- let status = '';
41
- if (session) {
42
- status = ` [${getStatusDisplay(session.state)}]`;
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
+ // Only show numbers for first 10 worktrees (0-9)
49
+ const numberPrefix = index < 10 ? `${index} ❯ ` : '❯ ';
48
50
  return {
49
- label: `${branchName}${isMain}${status}`,
50
- value: wt.path,
51
- worktree: wt,
51
+ label: numberPrefix + label,
52
+ value: item.worktree.path,
53
+ worktree: item.worktree,
52
54
  };
53
55
  });
54
56
  // Add menu options
@@ -57,27 +59,87 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
57
59
  value: 'separator',
58
60
  });
59
61
  menuItems.push({
60
- label: `${MENU_ICONS.NEW_WORKTREE} New Worktree`,
62
+ label: `N ${MENU_ICONS.NEW_WORKTREE} New Worktree`,
61
63
  value: 'new-worktree',
62
64
  });
63
65
  menuItems.push({
64
- label: `${MENU_ICONS.MERGE_WORKTREE} Merge Worktree`,
66
+ label: `M ${MENU_ICONS.MERGE_WORKTREE} Merge Worktree`,
65
67
  value: 'merge-worktree',
66
68
  });
67
69
  menuItems.push({
68
- label: `${MENU_ICONS.DELETE_WORKTREE} Delete Worktree`,
70
+ label: `D ${MENU_ICONS.DELETE_WORKTREE} Delete Worktree`,
69
71
  value: 'delete-worktree',
70
72
  });
71
73
  menuItems.push({
72
- label: `${MENU_ICONS.CONFIGURE_SHORTCUTS} Configuration`,
74
+ label: `C ${MENU_ICONS.CONFIGURE_SHORTCUTS} Configuration`,
73
75
  value: 'configuration',
74
76
  });
75
77
  menuItems.push({
76
- label: `${MENU_ICONS.EXIT} Exit`,
78
+ label: `Q ${MENU_ICONS.EXIT} Exit`,
77
79
  value: 'exit',
78
80
  });
79
81
  setItems(menuItems);
80
- }, [worktrees, sessions]);
82
+ }, [worktrees, sessions, defaultBranch]);
83
+ // Handle hotkeys
84
+ useInput((input, _key) => {
85
+ const keyPressed = input.toLowerCase();
86
+ // Handle number keys 0-9 for worktree selection (first 10 only)
87
+ if (/^[0-9]$/.test(keyPressed)) {
88
+ const index = parseInt(keyPressed);
89
+ if (index < Math.min(10, worktrees.length) && worktrees[index]) {
90
+ onSelectWorktree(worktrees[index]);
91
+ }
92
+ return;
93
+ }
94
+ switch (keyPressed) {
95
+ case 'n':
96
+ // Trigger new worktree action
97
+ onSelectWorktree({
98
+ path: '',
99
+ branch: '',
100
+ isMainWorktree: false,
101
+ hasSession: false,
102
+ });
103
+ break;
104
+ case 'm':
105
+ // Trigger merge worktree action
106
+ onSelectWorktree({
107
+ path: 'MERGE_WORKTREE',
108
+ branch: '',
109
+ isMainWorktree: false,
110
+ hasSession: false,
111
+ });
112
+ break;
113
+ case 'd':
114
+ // Trigger delete worktree action
115
+ onSelectWorktree({
116
+ path: 'DELETE_WORKTREE',
117
+ branch: '',
118
+ isMainWorktree: false,
119
+ hasSession: false,
120
+ });
121
+ break;
122
+ case 'c':
123
+ // Trigger configuration action
124
+ onSelectWorktree({
125
+ path: 'CONFIGURATION',
126
+ branch: '',
127
+ isMainWorktree: false,
128
+ hasSession: false,
129
+ });
130
+ break;
131
+ case 'q':
132
+ case 'x':
133
+ // Trigger exit action
134
+ onSelectWorktree({
135
+ path: 'EXIT_APPLICATION',
136
+ branch: '',
137
+ isMainWorktree: false,
138
+ hasSession: false,
139
+ });
140
+ break;
141
+ }
142
+ });
81
143
  const handleSelect = (item) => {
82
144
  if (item.value === 'separator') {
83
145
  // Do nothing for separator
@@ -151,6 +213,6 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
151
213
  STATUS_ICONS.IDLE,
152
214
  ' ',
153
215
  STATUS_LABELS.IDLE),
154
- React.createElement(Text, { dimColor: true }, "Controls: \u2191\u2193 Navigate Enter Select"))));
216
+ React.createElement(Text, { dimColor: true }, "Controls: \u2191\u2193 Navigate Enter Select | Hotkeys: 0-9 Quick Select (first 10) N-New M-Merge D-Delete C-Config Q-Quit"))));
155
217
  };
156
218
  export default Menu;
@@ -0,0 +1,2 @@
1
+ import { Worktree } from '../types/index.js';
2
+ export declare function useGitStatus(worktrees: Worktree[], defaultBranch: string | null, updateInterval?: number): Worktree[];
@@ -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', () => {
@@ -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,4 @@
1
+ /**
2
+ * Create a function that limits concurrent executions
3
+ */
4
+ export declare function createConcurrencyLimited<TArgs extends unknown[], TResult>(fn: (...args: TArgs) => Promise<TResult>, maxConcurrent: number): (...args: TArgs) => Promise<TResult>;
@@ -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 {};