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 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));
@@ -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, 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,23 +39,18 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
34
39
  };
35
40
  }, [sessionManager]);
36
41
  useEffect(() => {
37
- // Build menu items
38
- const menuItems = worktrees.map((wt, index) => {
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
48
  // Only show numbers for first 10 worktrees (0-9)
49
49
  const numberPrefix = index < 10 ? `${index} ❯ ` : '❯ ';
50
50
  return {
51
- label: `${numberPrefix}${branchName}${isMain}${status}`,
52
- value: wt.path,
53
- worktree: wt,
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,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 {};
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "0.1.15",
3
+ "version": "0.2.0",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",