Skip to content

feat: add git status indicators for worktrees #31

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export default [
clearInterval: true,
setTimeout: true,
clearTimeout: true,
NodeJS: true
NodeJS: true,
AbortController: true,
AbortSignal: true
}
},
plugins: {
Expand Down
4 changes: 4 additions & 0 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`
Expand All @@ -29,4 +30,7 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) {
process.exit(1);
}

// Initialize worktree config manager
worktreeConfigManager.initialize();

render(<App />);
40 changes: 21 additions & 19 deletions src/components/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,15 +28,18 @@ interface MenuItem {
}

const Menu: React.FC<MenuProps> = ({sessionManager, onSelectWorktree}) => {
const [worktrees, setWorktrees] = useState<Worktree[]>([]);
const [baseWorktrees, setBaseWorktrees] = useState<Worktree[]>([]);
const [defaultBranch, setDefaultBranch] = useState<string | null>(null);
const worktrees = useGitStatus(baseWorktrees, defaultBranch);
const [sessions, setSessions] = useState<Session[]>([]);
const [items, setItems] = useState<MenuItem[]>([]);

useEffect(() => {
// Load worktrees
const worktreeService = new WorktreeService();
const loadedWorktrees = worktreeService.getWorktrees();
setWorktrees(loadedWorktrees);
setBaseWorktrees(loadedWorktrees);
setDefaultBranch(worktreeService.getDefaultBranch());

// Update sessions
const updateSessions = () => {
Expand Down Expand Up @@ -60,24 +68,18 @@ const Menu: React.FC<MenuProps> = ({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,
};
});

Expand Down Expand Up @@ -107,7 +109,7 @@ const Menu: React.FC<MenuProps> = ({sessionManager, onSelectWorktree}) => {
value: 'exit',
});
setItems(menuItems);
}, [worktrees, sessions]);
}, [worktrees, sessions, defaultBranch]);

const handleSelect = (item: MenuItem) => {
if (item.value === 'separator') {
Expand Down
248 changes: 248 additions & 0 deletions src/hooks/useGitStatus.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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);
});
});
Loading