diff --git a/eslint.config.js b/eslint.config.js index 564c216..d713f62 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -32,7 +32,9 @@ export default [ clearInterval: true, setTimeout: true, clearTimeout: true, - NodeJS: true + NodeJS: true, + AbortController: true, + AbortSignal: true } }, plugins: { diff --git a/src/cli.tsx b/src/cli.tsx index ece6626..c766f40 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -3,6 +3,7 @@ import React from 'react'; import {render} from 'ink'; import meow from 'meow'; import App from './components/App.js'; +import {worktreeConfigManager} from './services/worktreeConfigManager.js'; meow( ` @@ -29,4 +30,7 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) { process.exit(1); } +// Initialize worktree config manager +worktreeConfigManager.initialize(); + render(); diff --git a/src/components/Menu.tsx b/src/components/Menu.tsx index 3f330b1..eeaa6df 100644 --- a/src/components/Menu.tsx +++ b/src/components/Menu.tsx @@ -8,8 +8,13 @@ import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, - getStatusDisplay, } from '../constants/statusIcons.js'; +import {useGitStatus} from '../hooks/useGitStatus.js'; +import { + prepareWorktreeItems, + calculateColumnPositions, + assembleWorktreeLabel, +} from '../utils/worktreeUtils.js'; interface MenuProps { sessionManager: SessionManager; @@ -23,7 +28,9 @@ interface MenuItem { } const Menu: React.FC = ({sessionManager, onSelectWorktree}) => { - const [worktrees, setWorktrees] = useState([]); + const [baseWorktrees, setBaseWorktrees] = useState([]); + const [defaultBranch, setDefaultBranch] = useState(null); + const worktrees = useGitStatus(baseWorktrees, defaultBranch); const [sessions, setSessions] = useState([]); const [items, setItems] = useState([]); @@ -31,7 +38,8 @@ const Menu: React.FC = ({sessionManager, onSelectWorktree}) => { // Load worktrees const worktreeService = new WorktreeService(); const loadedWorktrees = worktreeService.getWorktrees(); - setWorktrees(loadedWorktrees); + setBaseWorktrees(loadedWorktrees); + setDefaultBranch(worktreeService.getDefaultBranch()); // Update sessions const updateSessions = () => { @@ -60,24 +68,18 @@ const Menu: React.FC = ({sessionManager, onSelectWorktree}) => { }, [sessionManager]); useEffect(() => { - // Build menu items - const menuItems: MenuItem[] = worktrees.map(wt => { - const session = sessions.find(s => s.worktreePath === wt.path); - let status = ''; - - if (session) { - status = ` [${getStatusDisplay(session.state)}]`; - } + // Prepare worktree items and calculate layout + const items = prepareWorktreeItems(worktrees, sessions); + const columnPositions = calculateColumnPositions(items); - const branchName = wt.branch - ? wt.branch.replace('refs/heads/', '') - : 'detached'; - const isMain = wt.isMainWorktree ? ' (main)' : ''; + // Build menu items with proper alignment + const menuItems: MenuItem[] = items.map(item => { + const label = assembleWorktreeLabel(item, columnPositions); return { - label: `${branchName}${isMain}${status}`, - value: wt.path, - worktree: wt, + label, + value: item.worktree.path, + worktree: item.worktree, }; }); @@ -107,7 +109,7 @@ const Menu: React.FC = ({sessionManager, onSelectWorktree}) => { value: 'exit', }); setItems(menuItems); - }, [worktrees, sessions]); + }, [worktrees, sessions, defaultBranch]); const handleSelect = (item: MenuItem) => { if (item.value === 'separator') { diff --git a/src/hooks/useGitStatus.test.ts b/src/hooks/useGitStatus.test.ts new file mode 100644 index 0000000..a340d48 --- /dev/null +++ b/src/hooks/useGitStatus.test.ts @@ -0,0 +1,248 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import React from 'react'; +import {render, cleanup} from 'ink-testing-library'; +import {Text} from 'ink'; +import {useGitStatus} from './useGitStatus.js'; +import type {Worktree} from '../types/index.js'; +import {getGitStatusLimited, type GitStatus} from '../utils/gitStatus.js'; + +// Mock the gitStatus module +vi.mock('../utils/gitStatus.js', () => ({ + getGitStatusLimited: vi.fn(), +})); + +describe('useGitStatus', () => { + const mockGetGitStatus = getGitStatusLimited as ReturnType; + + const createWorktree = (path: string): Worktree => ({ + path, + branch: 'main', + isMainWorktree: false, + hasSession: false, + }); + + const createGitStatus = (added = 1, deleted = 0): GitStatus => ({ + filesAdded: added, + filesDeleted: deleted, + aheadCount: 0, + behindCount: 0, + parentBranch: 'main', + }); + + beforeEach(() => { + vi.useFakeTimers(); + mockGetGitStatus.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + cleanup(); + }); + + // Main behavioral test + it('should fetch and update git status for worktrees', async () => { + const worktrees = [createWorktree('/path1'), createWorktree('/path2')]; + const gitStatus1 = createGitStatus(5, 3); + const gitStatus2 = createGitStatus(2, 1); + let hookResult: Worktree[] = []; + + mockGetGitStatus.mockImplementation(async path => { + if (path === '/path1') { + return {success: true, data: gitStatus1}; + } + return {success: true, data: gitStatus2}; + }); + + const TestComponent = () => { + hookResult = useGitStatus(worktrees, 'main', 100); + return React.createElement(Text, null, 'test'); + }; + + render(React.createElement(TestComponent)); + + // Should return worktrees immediately + expect(hookResult).toEqual(worktrees); + + // Wait for status updates + await vi.waitFor(() => { + expect(hookResult[0]?.gitStatus).toBeDefined(); + expect(hookResult[1]?.gitStatus).toBeDefined(); + }); + + // Should have correct status for each worktree + expect(hookResult[0]?.gitStatus).toEqual(gitStatus1); + expect(hookResult[1]?.gitStatus).toEqual(gitStatus2); + }); + + it('should handle empty worktree array', () => { + let hookResult: Worktree[] = []; + + const TestComponent = () => { + hookResult = useGitStatus([], 'main'); + return React.createElement(Text, null, 'test'); + }; + + render(React.createElement(TestComponent)); + + expect(hookResult).toEqual([]); + expect(mockGetGitStatus).not.toHaveBeenCalled(); + }); + + it('should not fetch when defaultBranch is null', async () => { + const worktrees = [createWorktree('/path1'), createWorktree('/path2')]; + let hookResult: Worktree[] = []; + + const TestComponent = () => { + hookResult = useGitStatus(worktrees, null); + return React.createElement(Text, null, 'test'); + }; + + render(React.createElement(TestComponent)); + + // Should return worktrees immediately without modification + expect(hookResult).toEqual(worktrees); + + // Wait to ensure no fetches occur + await vi.advanceTimersByTimeAsync(1000); + expect(mockGetGitStatus).not.toHaveBeenCalled(); + }); + + it('should continue polling after errors', async () => { + const worktrees = [createWorktree('/path1')]; + + mockGetGitStatus.mockResolvedValue({ + success: false, + error: 'Git error', + }); + + const TestComponent = () => { + useGitStatus(worktrees, 'main', 100); + return React.createElement(Text, null, 'test'); + }; + + render(React.createElement(TestComponent)); + + // Wait for initial fetch + await vi.waitFor(() => { + expect(mockGetGitStatus).toHaveBeenCalledTimes(1); + }); + + // Clear to track subsequent calls + mockGetGitStatus.mockClear(); + + // Advance time and verify polling continues despite errors + await vi.advanceTimersByTimeAsync(100); + expect(mockGetGitStatus).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(100); + expect(mockGetGitStatus).toHaveBeenCalledTimes(2); + + // All calls should have been made despite continuous errors + expect(mockGetGitStatus).toHaveBeenCalledWith( + '/path1', + 'main', + expect.any(AbortSignal), + ); + }); + + it('should handle slow git operations that exceed update interval', async () => { + const worktrees = [createWorktree('/path1')]; + let fetchCount = 0; + let resolveFetch: + | ((value: {success: boolean; data?: GitStatus}) => void) + | null = null; + + mockGetGitStatus.mockImplementation(async () => { + fetchCount++; + // Create a promise that we can resolve manually + return new Promise(resolve => { + resolveFetch = resolve; + }); + }); + + const TestComponent = () => { + useGitStatus(worktrees, 'main', 100); + return React.createElement(Text, null, 'test'); + }; + + render(React.createElement(TestComponent)); + + // Wait for initial fetch to start + await vi.waitFor(() => { + expect(mockGetGitStatus).toHaveBeenCalledTimes(1); + }); + + // Advance time past the update interval while fetch is still pending + await vi.advanceTimersByTimeAsync(250); + + // Should not have started a second fetch yet + expect(mockGetGitStatus).toHaveBeenCalledTimes(1); + + // Complete the first fetch + resolveFetch!({success: true, data: createGitStatus(1, 0)}); + + // Wait for the promise to resolve + await vi.waitFor(() => { + expect(fetchCount).toBe(1); + }); + + // Now advance time by the update interval + await vi.advanceTimersByTimeAsync(100); + + // Should have started the second fetch + await vi.waitFor(() => { + expect(mockGetGitStatus).toHaveBeenCalledTimes(2); + }); + }); + + it('should properly cleanup resources when worktrees change', async () => { + let activeRequests = 0; + const abortedSignals: AbortSignal[] = []; + + mockGetGitStatus.mockImplementation(async (path, branch, signal) => { + activeRequests++; + + signal.addEventListener('abort', () => { + activeRequests--; + abortedSignals.push(signal); + }); + + // Simulate ongoing request + return new Promise(() => {}); + }); + + const TestComponent: React.FC<{worktrees: Worktree[]}> = ({worktrees}) => { + useGitStatus(worktrees, 'main', 100); + return React.createElement(Text, null, 'test'); + }; + + // Start with 3 worktrees + const initialWorktrees = [ + createWorktree('/path1'), + createWorktree('/path2'), + createWorktree('/path3'), + ]; + + const {rerender} = render( + React.createElement(TestComponent, {worktrees: initialWorktrees}), + ); + + // Should have 3 active requests + await vi.waitFor(() => { + expect(activeRequests).toBe(3); + }); + + // Change to 2 different worktrees + const newWorktrees = [createWorktree('/path4'), createWorktree('/path5')]; + rerender(React.createElement(TestComponent, {worktrees: newWorktrees})); + + // Wait for cleanup and new requests + await vi.waitFor(() => { + expect(abortedSignals).toHaveLength(3); + expect(activeRequests).toBe(2); + }); + + // Verify all old signals were aborted + expect(abortedSignals.every(signal => signal.aborted)).toBe(true); + }); +}); diff --git a/src/hooks/useGitStatus.ts b/src/hooks/useGitStatus.ts new file mode 100644 index 0000000..22fa117 --- /dev/null +++ b/src/hooks/useGitStatus.ts @@ -0,0 +1,79 @@ +import {useEffect, useState} from 'react'; +import {Worktree} from '../types/index.js'; +import {getGitStatusLimited} from '../utils/gitStatus.js'; + +export function useGitStatus( + worktrees: Worktree[], + defaultBranch: string | null, + updateInterval = 5000, +): Worktree[] { + const [worktreesWithStatus, setWorktreesWithStatus] = useState(worktrees); + + useEffect(() => { + if (!defaultBranch) { + return; + } + + const timeouts = new Map(); + const activeRequests = new Map(); + let isCleanedUp = false; + + const fetchStatus = async ( + worktree: Worktree, + abortController: AbortController, + ) => { + try { + const result = await getGitStatusLimited( + worktree.path, + defaultBranch, + abortController.signal, + ); + + if (result.data || result.error) { + setWorktreesWithStatus(prev => + prev.map(wt => + wt.path === worktree.path + ? {...wt, gitStatus: result.data, gitStatusError: result.error} + : wt, + ), + ); + } + } catch { + // Ignore errors - the fetch failed or was aborted + } + }; + + const scheduleUpdate = (worktree: Worktree) => { + const abortController = new AbortController(); + activeRequests.set(worktree.path, abortController); + + fetchStatus(worktree, abortController).finally(() => { + const isActive = () => !isCleanedUp && !abortController.signal.aborted; + if (isActive()) { + const timeout = setTimeout(() => { + if (isActive()) { + scheduleUpdate(worktree); + } + }, updateInterval); + + timeouts.set(worktree.path, timeout); + } + }); + }; + + setWorktreesWithStatus(worktrees); + + // Start fetching for each worktree + worktrees.forEach(worktree => { + scheduleUpdate(worktree); + }); + + return () => { + isCleanedUp = true; + timeouts.forEach(timeout => clearTimeout(timeout)); + activeRequests.forEach(controller => controller.abort()); + }; + }, [worktrees, defaultBranch, updateInterval]); + + return worktreesWithStatus; +} diff --git a/src/services/worktreeConfigManager.ts b/src/services/worktreeConfigManager.ts new file mode 100644 index 0000000..66616eb --- /dev/null +++ b/src/services/worktreeConfigManager.ts @@ -0,0 +1,28 @@ +import {isWorktreeConfigEnabled} from '../utils/worktreeConfig.js'; + +class WorktreeConfigManager { + private static instance: WorktreeConfigManager; + private isExtensionAvailable: boolean | null = null; + + private constructor() {} + + static getInstance(): WorktreeConfigManager { + if (!WorktreeConfigManager.instance) { + WorktreeConfigManager.instance = new WorktreeConfigManager(); + } + return WorktreeConfigManager.instance; + } + + initialize(gitPath?: string): void { + this.isExtensionAvailable = isWorktreeConfigEnabled(gitPath); + } + + isAvailable(): boolean { + if (this.isExtensionAvailable === null) { + throw new Error('WorktreeConfigManager not initialized'); + } + return this.isExtensionAvailable; + } +} + +export const worktreeConfigManager = WorktreeConfigManager.getInstance(); diff --git a/src/services/worktreeService.test.ts b/src/services/worktreeService.test.ts index 30f14df..c4ebddf 100644 --- a/src/services/worktreeService.test.ts +++ b/src/services/worktreeService.test.ts @@ -5,6 +5,15 @@ import {execSync} from 'child_process'; // Mock child_process module vi.mock('child_process'); +// Mock worktreeConfigManager +vi.mock('./worktreeConfigManager.js', () => ({ + worktreeConfigManager: { + initialize: vi.fn(), + isAvailable: vi.fn(() => true), + reset: vi.fn(), + }, +})); + // Get the mocked function with proper typing const mockedExecSync = vi.mocked(execSync); diff --git a/src/services/worktreeService.ts b/src/services/worktreeService.ts index 15cd4f8..368f6a8 100644 --- a/src/services/worktreeService.ts +++ b/src/services/worktreeService.ts @@ -2,6 +2,7 @@ import {execSync} from 'child_process'; import {existsSync} from 'fs'; import path from 'path'; import {Worktree} from '../types/index.js'; +import {setWorktreeParentBranch} from '../utils/worktreeConfig.js'; export class WorktreeService { private rootPath: string; @@ -223,6 +224,16 @@ export class WorktreeService { encoding: 'utf8', }); + // Store the parent branch in worktree config + try { + setWorktreeParentBranch(resolvedPath, baseBranch); + } catch (error) { + console.error( + 'Warning: Failed to set parent branch in worktree config:', + error, + ); + } + return {success: true}; } catch (error) { return { diff --git a/src/types/index.ts b/src/types/index.ts index aa7caff..ab42dfa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,6 @@ import {IPty} from 'node-pty'; import type pkg from '@xterm/headless'; +import {GitStatus} from '../utils/gitStatus.js'; export type Terminal = InstanceType; @@ -10,6 +11,8 @@ export interface Worktree { branch?: string; isMainWorktree: boolean; hasSession: boolean; + gitStatus?: GitStatus; + gitStatusError?: string; } export interface Session { diff --git a/src/utils/concurrencyLimit.test.ts b/src/utils/concurrencyLimit.test.ts new file mode 100644 index 0000000..e485ec7 --- /dev/null +++ b/src/utils/concurrencyLimit.test.ts @@ -0,0 +1,87 @@ +import {describe, it, expect} from 'vitest'; +import {createConcurrencyLimited} from './concurrencyLimit.js'; + +describe('createConcurrencyLimited', () => { + it('should limit concurrent executions', async () => { + let running = 0; + let maxRunning = 0; + + const task = async (id: number) => { + running++; + maxRunning = Math.max(maxRunning, running); + // Simulate work + await new Promise(resolve => setTimeout(resolve, 10)); + running--; + return id; + }; + + const limitedTask = createConcurrencyLimited(task, 2); + + // Start 5 tasks + const promises = [ + limitedTask(1), + limitedTask(2), + limitedTask(3), + limitedTask(4), + limitedTask(5), + ]; + + const results = await Promise.all(promises); + + // All tasks should complete + expect(results).toEqual([1, 2, 3, 4, 5]); + // Max concurrent should not exceed limit + expect(maxRunning).toBeLessThanOrEqual(2); + // All tasks should have finished + expect(running).toBe(0); + }); + + it('should handle errors without blocking queue', async () => { + let callCount = 0; + const original = async () => { + callCount++; + if (callCount === 1) { + throw new Error('Task failed'); + } + return 'success'; + }; + + const limited = createConcurrencyLimited(original, 1); + + // Start failing task first + const promise1 = limited().catch(e => e.message); + // Queue successful task + const promise2 = limited(); + + const results = await Promise.all([promise1, promise2]); + + expect(results[0]).toBe('Task failed'); + expect(results[1]).toBe('success'); + }); + + it('should preserve function arguments', async () => { + const original = async ( + a: number, + b: string, + c: boolean, + ): Promise => { + return `${a}-${b}-${c}`; + }; + + const limited = createConcurrencyLimited(original, 1); + + const result = await limited(42, 'test', true); + expect(result).toBe('42-test-true'); + }); + + it('should throw for invalid maxConcurrent', () => { + const fn = async () => 'test'; + + expect(() => createConcurrencyLimited(fn, 0)).toThrow( + 'maxConcurrent must be at least 1', + ); + expect(() => createConcurrencyLimited(fn, -1)).toThrow( + 'maxConcurrent must be at least 1', + ); + }); +}); diff --git a/src/utils/concurrencyLimit.ts b/src/utils/concurrencyLimit.ts new file mode 100644 index 0000000..8a5ce5c --- /dev/null +++ b/src/utils/concurrencyLimit.ts @@ -0,0 +1,36 @@ +/** + * Create a function that limits concurrent executions + */ +export function createConcurrencyLimited( + fn: (...args: TArgs) => Promise, + maxConcurrent: number, +): (...args: TArgs) => Promise { + if (maxConcurrent < 1) { + throw new RangeError('maxConcurrent must be at least 1'); + } + + let activeCount = 0; + const queue: Array<() => void> = []; + + return async (...args: TArgs): Promise => { + // Wait for a slot if at capacity + if (activeCount >= maxConcurrent) { + await new Promise(resolve => { + queue.push(resolve); + }); + } + + activeCount++; + + try { + return await fn(...args); + } finally { + activeCount--; + // Release the next waiter in queue + const next = queue.shift(); + if (next) { + next(); + } + } + }; +} diff --git a/src/utils/gitStatus.test.ts b/src/utils/gitStatus.test.ts new file mode 100644 index 0000000..007aa13 --- /dev/null +++ b/src/utils/gitStatus.test.ts @@ -0,0 +1,177 @@ +import {describe, it, expect, vi} from 'vitest'; +import { + formatGitStatus, + formatGitFileChanges, + formatGitAheadBehind, + formatParentBranch, + getGitStatus, + type GitStatus, +} from './gitStatus.js'; +import {exec} from 'child_process'; +import {promisify} from 'util'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Mock worktreeConfigManager +vi.mock('../services/worktreeConfigManager.js', () => ({ + worktreeConfigManager: { + initialize: vi.fn(), + isAvailable: vi.fn(() => true), + reset: vi.fn(), + }, +})); + +const execAsync = promisify(exec); + +describe('formatGitStatus', () => { + it('should format status with ANSI colors', () => { + const status: GitStatus = { + filesAdded: 42, + filesDeleted: 10, + aheadCount: 5, + behindCount: 3, + parentBranch: 'main', + }; + + const formatted = formatGitStatus(status); + + expect(formatted).toBe( + '\x1b[32m+42\x1b[0m \x1b[31m-10\x1b[0m \x1b[36m↑5\x1b[0m \x1b[35m↓3\x1b[0m', + ); + }); + + it('should use formatGitStatusWithColors as alias', () => { + const status: GitStatus = { + filesAdded: 1, + filesDeleted: 2, + aheadCount: 3, + behindCount: 4, + parentBranch: 'main', + }; + + const withColors = formatGitStatus(status); + const withColorsParam = formatGitStatus(status); + + expect(withColors).toBe(withColorsParam); + }); +}); + +describe('GitService Integration Tests', {timeout: 10000}, () => { + it('should handle concurrent calls correctly', async () => { + // Create a temporary git repo for testing + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccmanager-test-')); + + try { + // Initialize git repo + await execAsync('git init', {cwd: tmpDir}); + await execAsync('git config user.email "test@example.com"', { + cwd: tmpDir, + }); + await execAsync('git config user.name "Test User"', {cwd: tmpDir}); + await execAsync('git config commit.gpgsign false', {cwd: tmpDir}); + + // Create a file and commit + fs.writeFileSync(path.join(tmpDir, 'test.txt'), 'Hello World'); + await execAsync('git add test.txt', {cwd: tmpDir}); + await execAsync('git commit -m "Initial commit"', {cwd: tmpDir}); + + // Test concurrent calls - all should succeed now without locking + // Create abort controllers for each call + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const controller3 = new AbortController(); + + const results = await Promise.all([ + getGitStatus(tmpDir, 'main', controller1.signal), + getGitStatus(tmpDir, 'main', controller2.signal), + getGitStatus(tmpDir, 'main', controller3.signal), + ]); + + // All should succeed + const successCount = results.filter(r => r.success).length; + expect(successCount).toBe(3); + + // All results should have the same data + const firstData = results[0]!.data; + results.forEach(result => { + expect(result.success).toBe(true); + expect(result.data).toEqual(firstData); + }); + } finally { + // Cleanup + fs.rmSync(tmpDir, {recursive: true, force: true}); + } + }); +}); + +describe('formatGitFileChanges', () => { + it('should format only file changes', () => { + const status: GitStatus = { + filesAdded: 10, + filesDeleted: 5, + aheadCount: 3, + behindCount: 2, + parentBranch: 'main', + }; + expect(formatGitFileChanges(status)).toBe( + '\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m', + ); + }); + + it('should handle zero file changes', () => { + const status: GitStatus = { + filesAdded: 0, + filesDeleted: 0, + aheadCount: 3, + behindCount: 2, + parentBranch: 'main', + }; + expect(formatGitFileChanges(status)).toBe(''); + }); +}); + +describe('formatGitAheadBehind', () => { + it('should format only ahead/behind markers', () => { + const status: GitStatus = { + filesAdded: 10, + filesDeleted: 5, + aheadCount: 3, + behindCount: 2, + parentBranch: 'main', + }; + expect(formatGitAheadBehind(status)).toBe( + '\x1b[36m↑3\x1b[0m \x1b[35m↓2\x1b[0m', + ); + }); + + it('should handle zero ahead/behind', () => { + const status: GitStatus = { + filesAdded: 10, + filesDeleted: 5, + aheadCount: 0, + behindCount: 0, + parentBranch: 'main', + }; + expect(formatGitAheadBehind(status)).toBe(''); + }); +}); + +describe('formatParentBranch', () => { + it('should return empty string when parent and current branch are the same', () => { + expect(formatParentBranch('main', 'main')).toBe(''); + expect(formatParentBranch('feature', 'feature')).toBe(''); + }); + + it('should format parent branch when different from current', () => { + expect(formatParentBranch('main', 'feature')).toBe('\x1b[90m(main)\x1b[0m'); + expect(formatParentBranch('develop', 'feature-123')).toBe( + '\x1b[90m(develop)\x1b[0m', + ); + }); + + it('should include color codes', () => { + const formatted = formatParentBranch('main', 'feature'); + expect(formatted).toBe('\x1b[90m(main)\x1b[0m'); + }); +}); diff --git a/src/utils/gitStatus.ts b/src/utils/gitStatus.ts new file mode 100644 index 0000000..5fa30a3 --- /dev/null +++ b/src/utils/gitStatus.ts @@ -0,0 +1,207 @@ +import {promisify} from 'util'; +import {exec, execFile} from 'child_process'; +import {getWorktreeParentBranch} from './worktreeConfig.js'; +import {createConcurrencyLimited} from './concurrencyLimit.js'; + +const execp = promisify(exec); +const execFilePromisified = promisify(execFile); + +export interface GitStatus { + filesAdded: number; + filesDeleted: number; + aheadCount: number; + behindCount: number; + parentBranch: string; +} + +export interface GitOperationResult { + success: boolean; + data?: T; + error?: string; + skipped?: boolean; +} + +export async function getGitStatus( + worktreePath: string, + defaultBranch: string, + signal: AbortSignal, +): Promise> { + try { + // Get unstaged changes + const [diffResult, stagedResult, branchResult, parentBranch] = + await Promise.all([ + execp('git diff --shortstat', {cwd: worktreePath, signal}).catch( + () => EMPTY_EXEC_RESULT, + ), + execp('git diff --staged --shortstat', { + cwd: worktreePath, + signal, + }).catch(() => EMPTY_EXEC_RESULT), + execp('git branch --show-current', {cwd: worktreePath, signal}).catch( + () => EMPTY_EXEC_RESULT, + ), + getWorktreeParentBranch(worktreePath, signal).then( + parent => parent || defaultBranch, + ), + ]); + + // Parse file changes + let filesAdded = 0; + let filesDeleted = 0; + + if (diffResult.stdout) { + const stats = parseGitStats(diffResult.stdout); + filesAdded += stats.insertions; + filesDeleted += stats.deletions; + } + if (stagedResult.stdout) { + const stats = parseGitStats(stagedResult.stdout); + filesAdded += stats.insertions; + filesDeleted += stats.deletions; + } + + // Get ahead/behind counts + let aheadCount = 0; + let behindCount = 0; + + const currentBranch = branchResult.stdout.trim(); + if (currentBranch && currentBranch !== parentBranch) { + try { + const aheadBehindResult = await execFilePromisified( + 'git', + ['rev-list', '--left-right', '--count', `${parentBranch}...HEAD`], + {cwd: worktreePath, signal}, + ); + + const [behind, ahead] = aheadBehindResult.stdout + .trim() + .split('\t') + .map(n => parseInt(n, 10)); + aheadCount = ahead || 0; + behindCount = behind || 0; + } catch { + // Branch comparison might fail + } + } + + return { + success: true, + data: { + filesAdded, + filesDeleted, + aheadCount, + behindCount, + parentBranch, + }, + }; + } catch (error) { + let errorMessage = ''; + if (error instanceof Error) { + errorMessage = error.message; + } else { + errorMessage = String(error); + } + return { + success: false, + error: errorMessage, + }; + } +} + +// Split git status formatting into file changes and ahead/behind +export function formatGitFileChanges(status: GitStatus): string { + const parts: string[] = []; + + const colors = { + green: '\x1b[32m', + red: '\x1b[31m', + reset: '\x1b[0m', + }; + + // File changes + if (status.filesAdded > 0) { + parts.push(`${colors.green}+${status.filesAdded}${colors.reset}`); + } + if (status.filesDeleted > 0) { + parts.push(`${colors.red}-${status.filesDeleted}${colors.reset}`); + } + + return parts.join(' '); +} + +export function formatGitAheadBehind(status: GitStatus): string { + const parts: string[] = []; + + const colors = { + cyan: '\x1b[36m', + magenta: '\x1b[35m', + reset: '\x1b[0m', + }; + + // Ahead/behind - compact format with arrows + if (status.aheadCount > 0) { + parts.push(`${colors.cyan}↑${status.aheadCount}${colors.reset}`); + } + if (status.behindCount > 0) { + parts.push(`${colors.magenta}↓${status.behindCount}${colors.reset}`); + } + + return parts.join(' '); +} + +// Keep the original function for backward compatibility +export function formatGitStatus(status: GitStatus): string { + const fileChanges = formatGitFileChanges(status); + const aheadBehind = formatGitAheadBehind(status); + + const parts = []; + if (fileChanges) parts.push(fileChanges); + if (aheadBehind) parts.push(aheadBehind); + + return parts.join(' '); +} + +export function formatParentBranch( + parentBranch: string, + currentBranch: string, +): string { + // Only show parent branch if different from current branch + if (parentBranch === currentBranch) { + return ''; + } + + const colors = { + dim: '\x1b[90m', + reset: '\x1b[0m', + }; + + return `${colors.dim}(${parentBranch})${colors.reset}`; +} + +const EMPTY_EXEC_RESULT = {stdout: '', stderr: ''}; + +interface GitStats { + insertions: number; + deletions: number; +} + +function parseGitStats(statLine: string): GitStats { + let insertions = 0; + let deletions = 0; + + // Parse git diff --shortstat output + // Example: " 3 files changed, 42 insertions(+), 10 deletions(-)" + const insertMatch = statLine.match(/(\d+) insertion/); + const deleteMatch = statLine.match(/(\d+) deletion/); + + if (insertMatch && insertMatch[1]) { + insertions = parseInt(insertMatch[1], 10); + } + if (deleteMatch && deleteMatch[1]) { + deletions = parseInt(deleteMatch[1], 10); + } + + return {insertions, deletions}; +} + +export const getGitStatusLimited = createConcurrencyLimited(getGitStatus, 10); diff --git a/src/utils/worktreeConfig.ts b/src/utils/worktreeConfig.ts new file mode 100644 index 0000000..e2c5fac --- /dev/null +++ b/src/utils/worktreeConfig.ts @@ -0,0 +1,57 @@ +import {promisify} from 'util'; +import {exec, execSync, execFileSync} from 'child_process'; +import {worktreeConfigManager} from '../services/worktreeConfigManager.js'; + +const execp = promisify(exec); + +export function isWorktreeConfigEnabled(gitPath?: string): boolean { + try { + const result = execSync('git config extensions.worktreeConfig', { + cwd: gitPath || process.cwd(), + encoding: 'utf8', + }).trim(); + return result === 'true'; + } catch { + return false; + } +} + +export async function getWorktreeParentBranch( + worktreePath: string, + signal?: AbortSignal, +): Promise { + // Return null if worktree config extension is not available + if (!worktreeConfigManager.isAvailable()) { + return null; + } + + try { + const result = await execp('git config --worktree ccmanager.parentBranch', { + cwd: worktreePath, + encoding: 'utf8', + signal, + }); + return result.stdout.trim() || null; + } catch { + return null; + } +} + +export function setWorktreeParentBranch( + worktreePath: string, + parentBranch: string, +): void { + // Skip if worktree config extension is not available + if (!worktreeConfigManager.isAvailable()) { + return; + } + + execFileSync( + 'git', + ['config', '--worktree', 'ccmanager.parentBranch', parentBranch], + { + cwd: worktreePath, + encoding: 'utf8', + }, + ); +} diff --git a/src/utils/worktreeUtils.test.ts b/src/utils/worktreeUtils.test.ts index 4373c63..b9c3f8c 100644 --- a/src/utils/worktreeUtils.test.ts +++ b/src/utils/worktreeUtils.test.ts @@ -2,7 +2,12 @@ import {describe, it, expect} from 'vitest'; import { generateWorktreeDirectory, extractBranchParts, + truncateString, + prepareWorktreeItems, + calculateColumnPositions, + assembleWorktreeLabel, } from './worktreeUtils.js'; +import {Worktree, Session} from '../types/index.js'; describe('generateWorktreeDirectory', () => { describe('with default pattern', () => { @@ -111,3 +116,122 @@ describe('extractBranchParts', () => { }); }); }); + +describe('truncateString', () => { + it('should return original string if shorter than max length', () => { + expect(truncateString('hello', 10)).toBe('hello'); + expect(truncateString('test', 4)).toBe('test'); + }); + + it('should truncate and add ellipsis if longer than max length', () => { + expect(truncateString('hello world', 8)).toBe('hello...'); + expect(truncateString('this is a long string', 10)).toBe('this is...'); + }); + + it('should handle edge cases', () => { + expect(truncateString('', 5)).toBe(''); + expect(truncateString('abc', 3)).toBe('abc'); + expect(truncateString('abcd', 3)).toBe('...'); + }); +}); + +describe('prepareWorktreeItems', () => { + const mockWorktree: Worktree = { + path: '/path/to/worktree', + branch: 'feature/test-branch', + isMainWorktree: false, + hasSession: false, + }; + + // Simplified mock + const mockSession: Session = { + id: 'test-session', + worktreePath: '/path/to/worktree', + state: 'idle', + process: {} as Session['process'], + output: [], + outputHistory: [], + lastActivity: new Date(), + isActive: true, + terminal: {} as Session['terminal'], + }; + + it('should prepare basic worktree without git status', () => { + const items = prepareWorktreeItems([mockWorktree], []); + expect(items).toHaveLength(1); + expect(items[0]?.baseLabel).toBe('feature/test-branch'); + }); + + it('should include session status in label', () => { + const items = prepareWorktreeItems([mockWorktree], [mockSession]); + expect(items[0]?.baseLabel).toContain('[○ Idle]'); + }); + + it('should mark main worktree', () => { + const mainWorktree = {...mockWorktree, isMainWorktree: true}; + const items = prepareWorktreeItems([mainWorktree], []); + expect(items[0]?.baseLabel).toContain('(main)'); + }); + + it('should truncate long branch names', () => { + const longBranch = { + ...mockWorktree, + branch: + 'feature/this-is-a-very-long-branch-name-that-should-be-truncated', + }; + const items = prepareWorktreeItems([longBranch], []); + expect(items[0]?.baseLabel.length).toBeLessThanOrEqual(50); // 40 + status + default + }); +}); + +describe('column alignment', () => { + const mockItems = [ + { + worktree: {} as Worktree, + baseLabel: 'feature/test-branch', + fileChanges: '\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m', + aheadBehind: '\x1b[33m↑2 ↓3\x1b[0m', + parentBranch: '', + lengths: { + base: 19, // 'feature/test-branch'.length + fileChanges: 6, // '+10 -5'.length + aheadBehind: 5, // '↑2 ↓3'.length + parentBranch: 0, + }, + }, + { + worktree: {} as Worktree, + baseLabel: 'main', + fileChanges: '\x1b[32m+2\x1b[0m \x1b[31m-1\x1b[0m', + aheadBehind: '\x1b[33m↑1\x1b[0m', + parentBranch: '', + lengths: { + base: 4, // 'main'.length + fileChanges: 5, // '+2 -1'.length + aheadBehind: 2, // '↑1'.length + parentBranch: 0, + }, + }, + ]; + + it('should calculate column positions from items', () => { + const positions = calculateColumnPositions(mockItems); + expect(positions.fileChanges).toBe(21); // 19 + 2 padding + expect(positions.aheadBehind).toBeGreaterThan(positions.fileChanges); + expect(positions.parentBranch).toBeGreaterThan(positions.aheadBehind); + }); + + it('should assemble label with proper alignment', () => { + const item = mockItems[0]!; + const columns = calculateColumnPositions(mockItems); + const result = assembleWorktreeLabel(item, columns); + + expect(result).toContain('feature/test-branch'); + expect(result).toContain('\x1b[32m+10\x1b[0m'); + expect(result).toContain('\x1b[33m↑2 ↓3\x1b[0m'); + + // Check alignment by stripping ANSI codes + const plain = result.replace(/\x1b\[[0-9;]*m/g, ''); + expect(plain.indexOf('+10 -5')).toBe(21); // Should start at column 21 + }); +}); diff --git a/src/utils/worktreeUtils.ts b/src/utils/worktreeUtils.ts index b02ac6b..abd96fe 100644 --- a/src/utils/worktreeUtils.ts +++ b/src/utils/worktreeUtils.ts @@ -1,4 +1,44 @@ import path from 'path'; +import {Worktree, Session} from '../types/index.js'; +import {getStatusDisplay} from '../constants/statusIcons.js'; +import { + formatGitFileChanges, + formatGitAheadBehind, + formatParentBranch, +} from './gitStatus.js'; + +// Constants +const MAX_BRANCH_NAME_LENGTH = 40; // Maximum characters for branch name display +const MIN_COLUMN_PADDING = 2; // Minimum spaces between columns + +// Strip ANSI escape codes for length calculation +const stripAnsi = (str: string): string => str.replace(/\x1b\[[0-9;]*m/g, ''); + +/** + * Worktree item with formatted content for display. + */ +interface WorktreeItem { + worktree: Worktree; + session?: Session; + baseLabel: string; + fileChanges: string; + aheadBehind: string; + parentBranch: string; + error?: string; + // Visible lengths (without ANSI codes) for alignment calculation + lengths: { + base: number; + fileChanges: number; + aheadBehind: number; + parentBranch: number; + }; +} + +// Utility function to truncate strings with ellipsis +export function truncateString(str: string, maxLength: number): string { + if (str.length <= maxLength) return str; + return str.substring(0, maxLength - 3) + '...'; +} export function generateWorktreeDirectory( branchName: string, @@ -38,3 +78,132 @@ export function extractBranchParts(branchName: string): { } return {name: branchName}; } + +/** + * Prepares worktree content for display with plain and colored versions. + */ +export function prepareWorktreeItems( + worktrees: Worktree[], + sessions: Session[], +): WorktreeItem[] { + return worktrees.map(wt => { + const session = sessions.find(s => s.worktreePath === wt.path); + const status = session ? ` [${getStatusDisplay(session.state)}]` : ''; + const fullBranchName = wt.branch + ? wt.branch.replace('refs/heads/', '') + : 'detached'; + const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH); + const isMain = wt.isMainWorktree ? ' (main)' : ''; + const baseLabel = `${branchName}${isMain}${status}`; + + let fileChanges = ''; + let aheadBehind = ''; + let parentBranch = ''; + let error = ''; + + if (wt.gitStatus) { + fileChanges = formatGitFileChanges(wt.gitStatus); + aheadBehind = formatGitAheadBehind(wt.gitStatus); + parentBranch = formatParentBranch( + wt.gitStatus.parentBranch, + fullBranchName, + ); + } else if (wt.gitStatusError) { + // Format error in red + error = `\x1b[31m[git error]\x1b[0m`; + } else { + // Show fetching status in dim gray + fileChanges = '\x1b[90m[fetching...]\x1b[0m'; + } + + return { + worktree: wt, + session, + baseLabel, + fileChanges, + aheadBehind, + parentBranch, + error, + lengths: { + base: stripAnsi(baseLabel).length, + fileChanges: stripAnsi(fileChanges).length, + aheadBehind: stripAnsi(aheadBehind).length, + parentBranch: stripAnsi(parentBranch).length, + }, + }; + }); +} + +/** + * Calculates column positions based on content widths. + */ +export function calculateColumnPositions(items: WorktreeItem[]) { + // Calculate maximum widths from pre-calculated lengths + let maxBranchLength = 0; + let maxFileChangesLength = 0; + let maxAheadBehindLength = 0; + + items.forEach(item => { + // Skip items with errors for alignment calculation + if (item.error) return; + + maxBranchLength = Math.max(maxBranchLength, item.lengths.base); + maxFileChangesLength = Math.max( + maxFileChangesLength, + item.lengths.fileChanges, + ); + maxAheadBehindLength = Math.max( + maxAheadBehindLength, + item.lengths.aheadBehind, + ); + }); + + // Simple column positioning + const fileChangesColumn = maxBranchLength + MIN_COLUMN_PADDING; + const aheadBehindColumn = + fileChangesColumn + maxFileChangesLength + MIN_COLUMN_PADDING + 2; + const parentBranchColumn = + aheadBehindColumn + maxAheadBehindLength + MIN_COLUMN_PADDING + 2; + + return { + fileChanges: fileChangesColumn, + aheadBehind: aheadBehindColumn, + parentBranch: parentBranchColumn, + }; +} + +// Pad string to column position +function padTo(str: string, visibleLength: number, column: number): string { + return str + ' '.repeat(Math.max(0, column - visibleLength)); +} + +/** + * Assembles the final worktree label with proper column alignment + */ +export function assembleWorktreeLabel( + item: WorktreeItem, + columns: ReturnType, +): string { + // If there's an error, just show the base label with error appended + if (item.error) { + return `${item.baseLabel} ${item.error}`; + } + + let label = item.baseLabel; + let currentLength = item.lengths.base; + + if (item.fileChanges) { + label = padTo(label, currentLength, columns.fileChanges) + item.fileChanges; + currentLength = columns.fileChanges + item.lengths.fileChanges; + } + if (item.aheadBehind) { + label = padTo(label, currentLength, columns.aheadBehind) + item.aheadBehind; + currentLength = columns.aheadBehind + item.lengths.aheadBehind; + } + if (item.parentBranch) { + label = + padTo(label, currentLength, columns.parentBranch) + item.parentBranch; + } + + return label; +}