diff --git a/README.md b/README.md index 5829ac0..5a8aceb 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,7 @@ An interactive git branch management CLI tool inspired by `git branch` and JetBr ## Installation ```bash -# Clone the repository -git clone -cd rosella-cli - -# Install dependencies -yarn install - -# Build the project -yarn build +npm install -g rosella-cli ``` ## Usage @@ -40,8 +32,16 @@ rosella ### Branch Actions - **n** - Create new branch from current -- **Delete** - Safe delete branch (git branch -d) -- **Shift+Del** - Force delete branch (git branch -D) +- **Delete** - Delete branch + +### Git Operations + +- **f** - Fetch from remote +- **u** - Pull latest changes (on current branch) +- **p** - Push to remote (on current branch) +- **m** - Merge selected branch into current +- **r** - Rebase current branch onto selected + ### Search diff --git a/src/__mocks__/simple-git.ts b/src/__mocks__/simple-git.ts deleted file mode 100644 index 268739f..0000000 --- a/src/__mocks__/simple-git.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { vi } from 'vitest'; - -export const simpleGit = vi.fn(); - -export default simpleGit; diff --git a/src/__tests__/components/BranchList.test.tsx b/src/__tests__/components/BranchList.test.tsx index cad7087..0af1d07 100644 --- a/src/__tests__/components/BranchList.test.tsx +++ b/src/__tests__/components/BranchList.test.tsx @@ -297,4 +297,115 @@ describe('BranchList', () => { expect(output).toContain('abc'); }); }); + + describe('Branch Behind Remote Indicator', () => { + it('should show arrow icon for branches behind remote', () => { + const branchesWithBehind: BranchInfo[] = [ + { name: 'main', current: true, commit: 'abc1234567', label: 'main', behindRemote: 3 }, + { name: 'feature-1', current: false, commit: 'def4567890', label: 'feature-1', behindRemote: 1 }, + ]; + + const { lastFrame } = render( + + ); + + const output = lastFrame(); + expect(output).toContain('↙'); + expect(output).toContain('main'); + expect(output).toContain('feature-1'); + }); + + it('should not show arrow icon for branches up-to-date with remote', () => { + const branchesUpToDate: BranchInfo[] = [ + { name: 'main', current: true, commit: 'abc1234567', label: 'main' }, + { name: 'feature-1', current: false, commit: 'def4567890', label: 'feature-1' }, + ]; + + const { lastFrame } = render( + + ); + + const output = lastFrame(); + expect(output).not.toContain('↙'); + }); + }); + + describe('Uncommitted Changes Indicator', () => { + it('should show indicator for current branch with uncommitted changes', () => { + const branchesWithChanges: BranchInfo[] = [ + { name: 'main', current: true, commit: 'abc1234567', label: 'main', hasUncommittedChanges: true }, + { name: 'feature-1', current: false, commit: 'def4567890', label: 'feature-1' }, + ]; + + const { lastFrame } = render( + + ); + + const output = lastFrame(); + expect(output).toContain('●'); + expect(output).toContain('main'); + }); + + it('should not show indicator for current branch without uncommitted changes', () => { + const branchesClean: BranchInfo[] = [ + { name: 'main', current: true, commit: 'abc1234567', label: 'main', hasUncommittedChanges: false }, + ]; + + const { lastFrame } = render( + + ); + + const output = lastFrame(); + expect(output).not.toContain('●'); + }); + }); + + describe('Combined Indicators', () => { + it('should show both arrow icon and uncommitted changes indicator', () => { + const branchesWithBoth: BranchInfo[] = [ + { + name: 'main', + current: true, + commit: 'abc1234567', + label: 'main', + behindRemote: 2, + hasUncommittedChanges: true + }, + ]; + + const { lastFrame } = render( + + ); + + const output = lastFrame(); + expect(output).toContain('↙'); + expect(output).toContain('●'); + expect(output).toContain('main'); + }); + }); }); diff --git a/src/__tests__/components/GitBranchUI.test.tsx b/src/__tests__/components/GitBranchUI.test.tsx index 4ece994..326a6b9 100644 --- a/src/__tests__/components/GitBranchUI.test.tsx +++ b/src/__tests__/components/GitBranchUI.test.tsx @@ -25,6 +25,7 @@ describe('GitBranchUI - Integration Tests', () => { isGitRepository: ReturnType; createBranch: ReturnType; deleteBranch: ReturnType; + getGitVersion: ReturnType; }; let mockBranches: BranchInfo[]; @@ -58,6 +59,7 @@ describe('GitBranchUI - Integration Tests', () => { isGitRepository: vi.fn().mockResolvedValue(true), createBranch: vi.fn().mockResolvedValue(undefined), deleteBranch: vi.fn().mockResolvedValue(undefined), + getGitVersion: vi.fn().mockResolvedValue('git version 2.39.2'), }; }); @@ -152,10 +154,11 @@ describe('GitBranchUI - Integration Tests', () => { stdin.write('\r'); await new Promise((resolve) => setTimeout(resolve, 100)); - expect(mockGitManager.createBranch).toHaveBeenCalledWith('new-feature'); + // Now expects to be called with the selected branch name as the second parameter + expect(mockGitManager.createBranch).toHaveBeenCalledWith('new-feature', 'main'); const output = lastFrame(); - expect(output).toContain("Branch 'new-feature' created"); + expect(output).toContain("Branch 'new-feature' created from 'main'"); expect(output).toContain('Checkout now?'); }); @@ -231,7 +234,7 @@ describe('GitBranchUI - Integration Tests', () => { // Move to non-current branch stdin.write('j'); - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 100)); // Press Delete stdin.write('\x7F'); @@ -247,8 +250,6 @@ describe('GitBranchUI - Integration Tests', () => { // But UI should still be interactive (branch list should still be visible) expect(output).toContain('main'); expect(output).toContain('feature-1'); - // Status bar should still be visible - expect(output).toContain('line'); }); it('should complete full branch deletion workflow', async () => { @@ -258,7 +259,7 @@ describe('GitBranchUI - Integration Tests', () => { // Move to non-current branch stdin.write('j'); - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 100)); // Press Delete stdin.write('\x7F'); @@ -476,8 +477,8 @@ describe('GitBranchUI - Integration Tests', () => { const output = lastFrame(); // Should show branch list expect(output).toContain('main'); - // Should show status bar - expect(output).toContain('line 1 of 3'); + // Should show hints in status bar + expect(output).toContain('h: Help'); }); it('should coordinate state across components', async () => { @@ -490,8 +491,8 @@ describe('GitBranchUI - Integration Tests', () => { await new Promise((resolve) => setTimeout(resolve, 50)); const output = lastFrame(); - // Status bar should update - expect(output).toContain('line 2 of 3'); + // Status bar should show hints + expect(output).toContain('h: Help'); }); }); }); diff --git a/src/__tests__/components/Header.test.tsx b/src/__tests__/components/Header.test.tsx index e154c61..c5c8d26 100644 --- a/src/__tests__/components/Header.test.tsx +++ b/src/__tests__/components/Header.test.tsx @@ -5,6 +5,7 @@ import { Header } from '../../components/Header.js'; describe('Header', () => { const mockProps = { version: '1.0.0', + gitVersion: 'git version 2.39.2', cwd: '/Users/test/project', }; @@ -17,17 +18,25 @@ describe('Header', () => { }); it('should display version number', () => { - const { lastFrame } = render(
); + const { lastFrame } = render(
); const output = lastFrame(); expect(output).toContain('1.2.3'); }); - it('should display version with space after Rosella', () => { + it('should display version with space after app name', () => { const { lastFrame } = render(
); const output = lastFrame(); - expect(output).toContain('Rosella 1.0.0'); + // Should display "Rosella 1.0.0" based on config + expect(output).toContain('1.0.0'); + }); + + it('should display git version', () => { + const { lastFrame } = render(
); + + const output = lastFrame(); + expect(output).toContain('git version 2.39.2'); }); }); @@ -41,7 +50,7 @@ describe('Header', () => { it('should display different cwd when provided', () => { const { lastFrame } = render( -
+
); const output = lastFrame(); @@ -68,14 +77,20 @@ describe('Header', () => { describe('Props Handling', () => { it('should handle empty version string', () => { - const { lastFrame } = render(
); + const { lastFrame } = render(
); const output = lastFrame(); expect(output).toContain('Rosella'); }); it('should handle empty cwd string', () => { - const { lastFrame } = render(
); + const { lastFrame } = render(
); + + expect(() => lastFrame()).not.toThrow(); + }); + + it('should handle empty gitVersion string', () => { + const { lastFrame } = render(
); expect(() => lastFrame()).not.toThrow(); }); diff --git a/src/__tests__/components/Help.test.tsx b/src/__tests__/components/Help.test.tsx index 7ad60ee..e78ff2e 100644 --- a/src/__tests__/components/Help.test.tsx +++ b/src/__tests__/components/Help.test.tsx @@ -8,7 +8,7 @@ describe('Help', () => { const { lastFrame } = render(); const output = lastFrame(); - expect(output).toContain('rosella - Help'); + expect(output).toContain('Rosella - Help'); }); }); @@ -65,17 +65,7 @@ describe('Help', () => { const output = lastFrame(); expect(output).toContain('Delete'); - expect(output).toContain('Safe delete branch'); - expect(output).toContain('git branch -d'); - }); - - it('should list force delete branch command', () => { - const { lastFrame } = render(); - - const output = lastFrame(); - expect(output).toContain('Shift+Del'); - expect(output).toContain('Force delete branch'); - expect(output).toContain('git branch -D'); + expect(output).toContain('Delete branch'); }); }); @@ -155,6 +145,7 @@ describe('Help', () => { // Verify all major sections are present expect(output).toContain('Navigation'); expect(output).toContain('Branch Actions'); + expect(output).toContain('Git Operations'); expect(output).toContain('Search'); expect(output).toContain('Other'); }); @@ -172,7 +163,8 @@ describe('Help', () => { const output = lastFrame(); const shortcuts = [ - '↑/↓', 'j/k', 'Enter', 'n', 'Delete', 'Shift+Del', + '↑/↓', 'j/k', 'Enter', 'n', 'Delete', + 'f', 'u', 'p', 'm', 'r', '/', ':', 'Esc', 'h', 'q' ]; diff --git a/src/__tests__/components/StatusBar.test.tsx b/src/__tests__/components/StatusBar.test.tsx index 7033d6d..c3eebb1 100644 --- a/src/__tests__/components/StatusBar.test.tsx +++ b/src/__tests__/components/StatusBar.test.tsx @@ -4,74 +4,59 @@ import { StatusBar } from '../../components/StatusBar.js'; describe('StatusBar', () => { describe('With Branches', () => { - it('should display current position with single branch', () => { + it('should render status bar with single branch', () => { const { lastFrame } = render( - + ); const output = lastFrame(); - expect(output).toContain('[press h for help]'); - expect(output).toContain('line 1 of 1'); + expect(output).toBeDefined(); }); - it('should display current position with multiple branches', () => { + it('should render status bar with multiple branches', () => { const { lastFrame } = render( - + ); const output = lastFrame(); - expect(output).toContain('line 1 of 5'); - }); - - it('should update position when selected index changes', () => { - const { lastFrame, rerender } = render( - - ); - - expect(lastFrame()).toContain('line 1 of 10'); - - rerender(); - expect(lastFrame()).toContain('line 5 of 10'); - - rerender(); - expect(lastFrame()).toContain('line 10 of 10'); + expect(output).toBeDefined(); }); - it('should handle large number of branches', () => { + it('should render status bar with large number of branches', () => { const { lastFrame } = render( - + ); const output = lastFrame(); - expect(output).toContain('line 100 of 100'); + expect(output).toBeDefined(); }); }); describe('Without Branches', () => { it('should display "No branches" when total is 0', () => { const { lastFrame } = render( - + ); const output = lastFrame(); expect(output).toContain('No branches'); - expect(output).not.toContain('line'); }); }); - describe('Help Text', () => { - it('should always display help hint', () => { + describe('Hints Display', () => { + it('should display hints when provided', () => { const { lastFrame } = render( - + ); const output = lastFrame(); - expect(output).toContain('[press h for help]'); + expect(output).toContain('f: Fetch'); + expect(output).toContain('h: Help'); }); - it('should display help hint even with no branches', () => { + it('should display no branches message with no branches', () => { const { lastFrame } = render( - + ); const output = lastFrame(); @@ -80,22 +65,21 @@ describe('StatusBar', () => { }); describe('Styling', () => { - it('should use blue background and white text', () => { + it('should render with proper styling', () => { const { lastFrame } = render( - + ); - // The output should contain ANSI color codes for blue background and white text + // The status bar should render const output = lastFrame(); expect(output).toBeDefined(); - expect(output!.length).toBeGreaterThan(0); }); }); describe('Blank Line', () => { it('should include a blank line after status bar', () => { const { lastFrame } = render( - + ); const output = lastFrame(); @@ -107,43 +91,38 @@ describe('StatusBar', () => { describe('Message Display', () => { it('should display message when provided', () => { const { lastFrame } = render( - + ); const output = lastFrame(); expect(output).toContain('Branch switched successfully'); - expect(output).not.toContain('[press h for help]'); - expect(output).not.toContain('line 1 of 5'); }); it('should prioritize message over default status', () => { const { lastFrame } = render( - + ); const output = lastFrame(); expect(output).toContain('Already on this branch'); - expect(output).not.toContain('line 3 of 10'); }); - it('should show default status when message is null', () => { + it('should render status bar when message is null', () => { const { lastFrame } = render( - + ); const output = lastFrame(); - expect(output).toContain('[press h for help]'); - expect(output).toContain('line 1 of 5'); + expect(output).toBeDefined(); }); - it('should show default status when message is undefined', () => { + it('should render status bar when message is undefined', () => { const { lastFrame } = render( - + ); const output = lastFrame(); - expect(output).toContain('[press h for help]'); - expect(output).toContain('line 1 of 5'); + expect(output).toBeDefined(); }); }); }); diff --git a/src/__tests__/utils/git.test.ts b/src/__tests__/utils/git.test.ts index bcd7826..5b5282f 100644 --- a/src/__tests__/utils/git.test.ts +++ b/src/__tests__/utils/git.test.ts @@ -8,6 +8,7 @@ const mockGit = { checkout: vi.fn<(branchName: string) => Promise>(), status: vi.fn<() => Promise>(), deleteLocalBranch: vi.fn<(branchName: string, force?: boolean) => Promise>(), + raw: vi.fn<(args: string[]) => Promise>(), }; const mockSimpleGit = vi.fn(() => mockGit); @@ -28,83 +29,66 @@ describe('GitManager', () => { describe('getBranches', () => { it('should return list of local branches', async () => { - const mockBranchSummary: BranchSummary = { - all: ['main', 'feature-1', 'feature-2'], - branches: { - 'main': { - current: true, - name: 'main', - commit: 'abc123', - label: 'main', - linkedWorkTree: false, - }, - 'feature-1': { - current: false, - name: 'feature-1', - commit: 'def456', - label: 'feature-1', - linkedWorkTree: false, - }, - 'feature-2': { - current: false, - name: 'feature-2', - commit: 'ghi789', - label: 'feature-2', - linkedWorkTree: false, - }, - }, + const mockStatus: StatusResult = { + not_added: [], + conflicted: [], + created: [], + deleted: [], + modified: [], + renamed: [], + files: [], + staged: [], + ahead: 0, + behind: 0, current: 'main', + tracking: null, detached: false, - }; + isClean: () => true, + } as StatusResult; + + // Mock git for-each-ref output: format is "refname|commit|HEAD|tracking" + const mockForEachRefOutput = 'main|abc123|*|\nfeature-1|def456||\nfeature-2|ghi789||'; - mockGit.branchLocal.mockResolvedValue(mockBranchSummary); + mockGit.status.mockResolvedValue(mockStatus); + mockGit.raw.mockResolvedValue(mockForEachRefOutput); const branches = await gitManager.getBranches(); expect(branches).toHaveLength(3); expect(branches[0].name).toBe('main'); expect(branches[0].current).toBe(true); - expect(mockGit.branchLocal).toHaveBeenCalledTimes(1); + expect(branches[0].hasUncommittedChanges).toBe(false); + expect(mockGit.status).toHaveBeenCalledTimes(1); + expect(mockGit.raw).toHaveBeenCalledWith([ + 'for-each-ref', + '--format=%(refname:short)|%(objectname:short)|%(HEAD)|%(upstream:track)', + 'refs/heads/', + ]); }); it('should sort branches with current first, then main/master, then alphabetically', async () => { - const mockBranchSummary: BranchSummary = { - all: ['feature-b', 'main', 'feature-a', 'dev'], - branches: { - 'feature-b': { - current: false, - name: 'feature-b', - commit: 'def456', - label: 'feature-b', - linkedWorkTree: false, - }, - 'main': { - current: false, - name: 'main', - commit: 'abc123', - label: 'main', - linkedWorkTree: false, - }, - 'feature-a': { - current: false, - name: 'feature-a', - commit: 'ghi789', - label: 'feature-a', - linkedWorkTree: false, - }, - 'dev': { - current: true, - name: 'dev', - commit: 'jkl012', - label: 'dev', - linkedWorkTree: false, - }, - }, + const mockStatus: StatusResult = { + not_added: [], + conflicted: [], + created: [], + deleted: [], + modified: [], + renamed: [], + files: [], + staged: [], + ahead: 0, + behind: 0, current: 'dev', + tracking: null, detached: false, - }; + isClean: () => true, + } as StatusResult; - mockGit.branchLocal.mockResolvedValue(mockBranchSummary); + // Mock git for-each-ref output: format is "refname|commit|HEAD|tracking" + const mockForEachRefOutput = 'feature-b|def456||\nmain|abc123||\nfeature-a|ghi789||\ndev|jkl012|*|'; + + mockGit.status.mockResolvedValue(mockStatus); + mockGit.raw.mockResolvedValue(mockForEachRefOutput); const branches = await gitManager.getBranches(); @@ -114,8 +98,74 @@ describe('GitManager', () => { expect(branches[3].name).toBe('feature-b'); }); - it('should throw error when git.branch fails', async () => { - mockGit.branchLocal.mockRejectedValue(new Error('Git error')); + it('should detect branches behind remote', async () => { + const mockStatus: StatusResult = { + not_added: [], + conflicted: [], + created: [], + deleted: [], + modified: [], + renamed: [], + files: [], + staged: [], + ahead: 0, + behind: 0, + current: 'main', + tracking: null, + detached: false, + isClean: () => true, + } as StatusResult; + + // Mock git for-each-ref output with tracking info + // Format is "refname|commit|HEAD|tracking" + // Tracking shows "[behind 3]" for main + const mockForEachRefOutput = 'main|abc123|*|[behind 3]\nfeature-1|def456||'; + + mockGit.status.mockResolvedValue(mockStatus); + mockGit.raw.mockResolvedValue(mockForEachRefOutput); + + const branches = await gitManager.getBranches(); + + expect(branches[0].name).toBe('main'); + expect(branches[0].behindRemote).toBe(3); + expect(branches[1].name).toBe('feature-1'); + expect(branches[1].behindRemote).toBeUndefined(); + }); + + it('should detect uncommitted changes on current branch', async () => { + const mockStatus: StatusResult = { + not_added: ['new-file.txt'], + conflicted: [], + created: ['another-file.txt'], + deleted: [], + modified: ['existing-file.txt'], + renamed: [], + files: [], + staged: [], + ahead: 0, + behind: 0, + current: 'main', + tracking: null, + detached: false, + isClean: () => false, + } as StatusResult; + + // Mock git for-each-ref output + const mockForEachRefOutput = 'main|abc123|*|'; + + mockGit.status.mockResolvedValue(mockStatus); + mockGit.raw.mockResolvedValue(mockForEachRefOutput); + + const branches = await gitManager.getBranches(); + + expect(branches[0].name).toBe('main'); + expect(branches[0].current).toBe(true); + expect(branches[0].hasUncommittedChanges).toBe(true); + }); + + it('should throw error when git operation fails', async () => { + mockGit.status.mockRejectedValue(new Error('Git error')); + mockGit.raw.mockResolvedValue(''); await expect(gitManager.getBranches()).rejects.toThrow( 'Failed to fetch branches' diff --git a/src/components/BranchList.tsx b/src/components/BranchList.tsx index 4432dbb..af3a196 100644 --- a/src/components/BranchList.tsx +++ b/src/components/BranchList.tsx @@ -27,17 +27,38 @@ export const BranchList: React.FC = ({ const actualIndex = topIndex + viewportIndex; const isSelected = actualIndex === selectedIndex; + // Build the branch indicator prefix + let branchPrefix = ''; + if (branch.current) { + branchPrefix = branch.hasUncommittedChanges ? '* ● ' : '* '; + } else { + branchPrefix = ' '; + } + + // Add arrow icon for branches behind remote + const behindIcon = branch.behindRemote ? '↙ ' : ''; + return ( {isSelected ? ( - {branch.current ? '* ' : ' '} + {branch.current ? ( + {branchPrefix} + ) : ( + branchPrefix + )} + {behindIcon} {branch.name} ({branch.commit.substring(0, 7)}) ) : ( - {branch.current ? * : ' '} + {branch.current ? ( + {branchPrefix} + ) : ( + branchPrefix + )} + {behindIcon} {branch.name} ({branch.commit.substring(0, 7)}) diff --git a/src/components/ErrorPane.tsx b/src/components/ErrorPane.tsx new file mode 100644 index 0000000..f238b2e --- /dev/null +++ b/src/components/ErrorPane.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Box, Text } from 'ink'; +import { useStdout } from 'ink'; + +interface Props { + error: string; +} + +export const ErrorPane: React.FC = ({ error }) => { + const { stdout } = useStdout(); + const width = stdout?.columns || 80; + const height = stdout?.rows || 24; + + // Split error into lines + const errorLines = error.split('\n'); + + // Calculate max content height - leave space for dividers and title + const maxContentHeight = Math.min(height - 6, 20); + + // Wrap long lines to fit within terminal width + const wrappedLines: string[] = []; + for (const line of errorLines) { + if (line.length <= width) { + wrappedLines.push(line); + } else { + // Split long lines + for (let i = 0; i < line.length; i += width) { + wrappedLines.push(line.substring(i, i + width)); + } + } + } + + // Limit to maxContentHeight + const displayLines = wrappedLines.slice(0, maxContentHeight); + const hasMore = wrappedLines.length > maxContentHeight; + + // Create divider + const divider = '─'.repeat(width); + + // Center the pane vertically + const totalPaneHeight = displayLines.length + 5 + (hasMore ? 1 : 0); // +5 for 3 dividers + title + dismiss + const topPadding = Math.max(0, Math.floor((height - totalPaneHeight) / 2)); + + return ( + + {/* Top divider */} + + {divider} + + + {/* Title */} + + ERROR + + + {/* Divider after title */} + + {divider} + + + {/* Error content */} + {displayLines.map((line, index) => ( + + {line} + + ))} + + {/* "More lines" indicator */} + {hasMore && ( + + ... and more lines + + )} + + {/* Divider before dismiss message */} + + {divider} + + + {/* Dismiss message */} + + Press any key to dismiss + + + ); +}; diff --git a/src/components/GitBranchUI.tsx b/src/components/GitBranchUI.tsx index d1796b6..988c59f 100644 --- a/src/components/GitBranchUI.tsx +++ b/src/components/GitBranchUI.tsx @@ -7,11 +7,62 @@ import { BranchList } from './BranchList.js'; import { StatusBar } from './StatusBar.js'; import { PromptBar } from './PromptBar.js'; import { Header } from './Header.js'; +import { ErrorPane } from './ErrorPane.js'; +import { APP_VERSION } from '../utils/app-info.js'; interface Props { gitManager: GitManager; } +// UI overhead constants for viewport height calculation +const UI_OVERHEAD = { + HEADER: 2, + PROMPT_BAR: 1, + STATUS_BAR: 2, + BORDERS: 2, +} as const; + +const TOTAL_UI_OVERHEAD = Object.values(UI_OVERHEAD).reduce((a, b) => a + b, 0); + +// Helper function to provide user-friendly error messages +function parseGitError(error: unknown, operation: string): string { + const errorMessage = error instanceof Error ? error.message : `${operation} failed`; + + // Network errors + if (errorMessage.includes('Could not resolve host') || + errorMessage.includes('network') || + errorMessage.includes('Connection') || + errorMessage.includes('timeout')) { + return `Network error: Unable to connect to remote.\nPlease check your internet connection and try again.`; + } + + // Authentication errors + if (errorMessage.includes('Authentication failed') || + errorMessage.includes('Permission denied') || + errorMessage.includes('fatal: could not read')) { + return `Authentication error: Failed to authenticate with remote.\nPlease check your credentials and try again.`; + } + + // Merge conflicts + if (errorMessage.includes('CONFLICT') || errorMessage.includes('conflict')) { + return `Merge conflict detected:\n${errorMessage}\n\nResolve conflicts manually and try again.`; + } + + // Diverged branches + if (errorMessage.includes('diverged') || errorMessage.includes('non-fast-forward')) { + return `Branches have diverged.\nPull the latest changes or force push (use with caution).`; + } + + // Uncommitted changes blocking operation + if (errorMessage.includes('would be overwritten') || + errorMessage.includes('Please commit your changes')) { + return `Uncommitted changes detected.\nCommit or stash your changes before proceeding.`; + } + + // Return original error if no specific case matched + return errorMessage; +} + export const GitBranchUI: React.FC = ({ gitManager }) => { const { exit } = useApp(); const { stdout } = useStdout(); @@ -23,11 +74,14 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { const [error, setError] = useState(null); const [message, setMessage] = useState(null); const [showHelp, setShowHelp] = useState(false); + const [gitVersion, setGitVersion] = useState('loading...'); + const loadRequestIdRef = React.useRef(0); // Track request IDs to prevent race conditions const [search, setSearch] = useState({ active: false, query: '', mode: 'normal', }); + const [searchValidationError, setSearchValidationError] = useState(null); const [confirmation, setConfirmation] = useState<{ active: boolean; branchName: string; @@ -39,6 +93,7 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { const [creation, setCreation] = useState<{ active: boolean; branchName: string; + fromBranch?: string; validationError?: string; }>({ active: false, branchName: '' }); const [checkoutPrompt, setCheckoutPrompt] = useState<{ @@ -46,20 +101,42 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { branchName: string; } | null>(null); const [savedSelectionIndex, setSavedSelectionIndex] = useState(0); + const [mergePrompt, setMergePrompt] = useState<{ + active: boolean; + branchName: string; + } | null>(null); + const [rebasePrompt, setRebasePrompt] = useState<{ + active: boolean; + branchName: string; + } | null>(null); + const [pushPrompt, setPushPrompt] = useState<{ + active: boolean; + needsUpstream: boolean; + } | null>(null); + const [operationInProgress, setOperationInProgress] = useState(null); + const [showErrorPane, setShowErrorPane] = useState(false); // Calculate viewport size - use full terminal height like vim - // Account for: header (2), prompt bar (1), status bar (1), borders (2) const getViewportHeight = () => { const terminalHeight = stdout?.rows || 24; - const uiOverhead = 6; // Header (2 lines) + PromptBar + StatusBar + TopBorder + BottomBorder // Return content area height (excluding borders which are rendered by the Box component) - return Math.max(1, terminalHeight - uiOverhead); + return Math.max(1, terminalHeight - TOTAL_UI_OVERHEAD); }; const loadBranches = async () => { + // Increment request ID to track this specific request + loadRequestIdRef.current += 1; + const currentRequestId = loadRequestIdRef.current; + try { setLoading(true); const isRepo = await gitManager.isGitRepository(); + + // Check if this request is still the latest + if (currentRequestId !== loadRequestIdRef.current) { + return; // Abort if a newer request was started + } + if (!isRepo) { setError('Not a git repository'); setLoading(false); @@ -67,18 +144,28 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { } const branchList = await gitManager.getBranches(); + + // Check again before updating state + if (currentRequestId !== loadRequestIdRef.current) { + return; // Abort if a newer request was started + } + setBranches(branchList); setFilteredBranches(branchList); setLoading(false); } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - setLoading(false); + // Only update error if this is still the latest request + if (currentRequestId === loadRequestIdRef.current) { + setError(err instanceof Error ? err.message : 'Unknown error'); + setLoading(false); + } } }; const filterBranches = () => { if (!search.query) { setFilteredBranches(branches); + setSearchValidationError(null); return; } @@ -87,16 +174,17 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { try { const regex = new RegExp(search.query, 'i'); filtered = branches.filter((b) => regex.test(b.name)); + setSearchValidationError(null); // Clear error if regex is valid } catch { - // Invalid regex, fall back to normal search - filtered = branches.filter((b) => - b.name.toLowerCase().includes(search.query.toLowerCase()) - ); + // Invalid regex - show error and display all branches + setSearchValidationError('Invalid regex pattern'); + filtered = branches; } } else { filtered = branches.filter((b) => b.name.toLowerCase().includes(search.query.toLowerCase()) ); + setSearchValidationError(null); } setFilteredBranches(filtered); @@ -104,6 +192,12 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { useEffect(() => { loadBranches(); + // Fetch git version + gitManager.getGitVersion().then((version) => { + setGitVersion(version); + }).catch(() => { + setGitVersion('git version unknown'); + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -112,6 +206,13 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [branches, search.query, search.mode]); + // Check if error is multi-line and should trigger error pane + useEffect(() => { + if (error && error.includes('\n')) { + setShowErrorPane(true); + } + }, [error]); + // Adjust selection when filtered branches change useEffect(() => { if (filteredBranches.length === 0) { @@ -246,7 +347,13 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { const handleCreateRequest = () => { setSavedSelectionIndex(selectedIndex); setError(null); - setCreation({ active: true, branchName: '', validationError: undefined }); + const selectedBranch = filteredBranches[selectedIndex]; + setCreation({ + active: true, + branchName: '', + fromBranch: selectedBranch?.name, + validationError: undefined + }); }; const handleConfirmCreate = async () => { @@ -263,7 +370,7 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { } try { - await gitManager.createBranch(branchName); + await gitManager.createBranch(branchName, creation.fromBranch); // Clear creation state setCreation({ active: false, branchName: '', validationError: undefined }); @@ -273,7 +380,8 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { // Show success message in StatusBar setError(null); - setMessage(`Branch '${branchName}' created`); + const fromMsg = creation.fromBranch ? ` from '${creation.fromBranch}'` : ''; + setMessage(`Branch '${branchName}' created${fromMsg}`); // Show checkout prompt in PromptBar setCheckoutPrompt({ @@ -339,6 +447,241 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { setCreation({ active: false, branchName: '', validationError: undefined }); }; + const handleMergeRequest = () => { + if (filteredBranches.length === 0) return; + + const selectedBranch = filteredBranches[selectedIndex]; + + // Can't merge the current branch into itself + if (selectedBranch.current) { + setError('Cannot merge current branch into itself'); + return; + } + + setError(null); + setMergePrompt({ + active: true, + branchName: selectedBranch.name, + }); + }; + + const handleConfirmMerge = async () => { + if (!mergePrompt) return; + + const { branchName } = mergePrompt; + + try { + setMergePrompt(null); + setOperationInProgress('Merging...'); + + await gitManager.merge(branchName); + + setOperationInProgress(null); + await loadBranches(); + + setError(null); + setMessage(`✓ Merged '${branchName}' into current branch`); + } catch (err) { + setOperationInProgress(null); + setError(parseGitError(err, 'Merge')); + } + }; + + const handleCancelMerge = () => { + setMergePrompt(null); + }; + + const handleRebaseRequest = () => { + if (filteredBranches.length === 0) return; + + const selectedBranch = filteredBranches[selectedIndex]; + + // Can't rebase on the current branch + if (selectedBranch.current) { + setError('Cannot rebase current branch onto itself'); + return; + } + + setError(null); + setRebasePrompt({ + active: true, + branchName: selectedBranch.name, + }); + }; + + const handleConfirmRebase = async () => { + if (!rebasePrompt) return; + + const { branchName } = rebasePrompt; + + try { + setRebasePrompt(null); + setOperationInProgress('Rebasing...'); + + await gitManager.rebase(branchName); + + setOperationInProgress(null); + await loadBranches(); + + setError(null); + setMessage(`✓ Rebased onto '${branchName}'`); + } catch (err) { + setOperationInProgress(null); + setError(parseGitError(err, 'Rebase')); + } + }; + + const handleCancelRebase = () => { + setRebasePrompt(null); + }; + + const handlePullRequest = async () => { + if (filteredBranches.length === 0) return; + + const selectedBranch = filteredBranches[selectedIndex]; + + // Can only pull on the current branch + if (!selectedBranch.current) { + setError('Can only pull on current branch'); + return; + } + + try { + setError(null); + setOperationInProgress('Pulling...'); + + await gitManager.pull(); + + setOperationInProgress(null); + await loadBranches(); + + setMessage(`✓ Pulled latest changes`); + } catch (err) { + setOperationInProgress(null); + setError(parseGitError(err, 'Pull')); + } + }; + + const handlePushRequest = async () => { + if (filteredBranches.length === 0) return; + + const selectedBranch = filteredBranches[selectedIndex]; + + // Can only push on the current branch + if (!selectedBranch.current) { + setError('Can only push from current branch'); + return; + } + + try { + setError(null); + setOperationInProgress('Pushing...'); + + await gitManager.push(); + + setOperationInProgress(null); + await loadBranches(); + + setMessage(`✓ Pushed to remote`); + } catch (err) { + setOperationInProgress(null); + + const errorMessage = err instanceof Error ? err.message : 'Push failed'; + + // Check if upstream needs to be set + if (errorMessage.includes('NO_UPSTREAM')) { + setPushPrompt({ + active: true, + needsUpstream: true, + }); + } else { + setError(parseGitError(err, 'Push')); + } + } + }; + + const handleConfirmPush = async () => { + if (!pushPrompt) return; + + try { + setPushPrompt(null); + setOperationInProgress('Pushing...'); + + await gitManager.push(true); // Set upstream + + setOperationInProgress(null); + await loadBranches(); + + setError(null); + setMessage(`✓ Pushed to remote with upstream set`); + } catch (err) { + setOperationInProgress(null); + setError(parseGitError(err, 'Push')); + } + }; + + const handleCancelPush = () => { + setPushPrompt(null); + }; + + const handleFetchRequest = async () => { + try { + setError(null); + setOperationInProgress('Fetching...'); + + await gitManager.fetch(); + + setOperationInProgress(null); + await loadBranches(); + + setMessage(`✓ Fetched from remote`); + } catch (err) { + setOperationInProgress(null); + setError(parseGitError(err, 'Fetch')); + } + }; + + // Generate context-aware hints for the status bar + const generateHints = (): string | null => { + // Don't show hints if in any modal/prompt mode + if ( + search.active || + creation.active || + confirmation?.active || + forceDeletePrompt?.active || + checkoutPrompt?.active || + mergePrompt?.active || + rebasePrompt?.active || + pushPrompt?.active || + operationInProgress || + showHelp + ) { + return null; + } + + if (filteredBranches.length === 0) { + return 'f: Fetch | h: Help'; + } + + const selectedBranch = filteredBranches[selectedIndex]; + const isActiveBranch = selectedBranch?.current; + + // Global commands (always available) + const globalHints = 'f: Fetch'; + + // Branch-specific commands + let branchHints: string; + if (isActiveBranch) { + // Commands for active branch + branchHints = 'n: New Branch | u: Pull | p: Push'; + } else { + // Commands for non-active branch + branchHints = 'Enter: Checkout | n: New Branch | Del: Delete | m: Merge | r: Rebase'; + } + + return `${globalHints} | ${branchHints} | h: Help`; + }; + // Helper function to calculate navigation indices // This batches the state updates to prevent screen flashing const calculateNavigation = ( @@ -378,6 +721,18 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { }; useInput((input, key) => { + // Handle error pane dismissal + if (showErrorPane) { + setShowErrorPane(false); + setError(null); + return; + } + + // Disable input during operations + if (operationInProgress) { + return; + } + // Handle checkout prompt mode if (checkoutPrompt?.active) { if (input === 'y' || input === 'Y') { @@ -408,6 +763,36 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { return; } + // Handle merge prompt mode + if (mergePrompt?.active) { + if (input === 'y' || input === 'Y') { + handleConfirmMerge(); + } else { + handleCancelMerge(); + } + return; + } + + // Handle rebase prompt mode + if (rebasePrompt?.active) { + if (input === 'y' || input === 'Y') { + handleConfirmRebase(); + } else { + handleCancelRebase(); + } + return; + } + + // Handle push prompt mode + if (pushPrompt?.active) { + if (input === 'y' || input === 'Y') { + handleConfirmPush(); + } else { + handleCancelPush(); + } + return; + } + // Handle help mode if (showHelp) { if (key.escape || input === 'q' || input === 'h') { @@ -467,12 +852,15 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { if (search.active) { if (key.escape) { setSearch({ active: false, query: '', mode: 'normal' }); + setSearchValidationError(null); } else if (key.return) { setSearch((prev) => ({ ...prev, active: false })); + setSearchValidationError(null); } else if (key.backspace || key.delete) { setSearch((prev) => { // If query is already empty, exit search mode if (prev.query.length === 0) { + setSearchValidationError(null); return { active: false, query: '', mode: 'normal' }; } // Otherwise, delete the last character @@ -534,17 +922,41 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { handleCheckout(); } else if (input === '/') { setError(null); + setMessage(null); setSearch({ active: true, query: '', mode: 'normal' }); } else if (input === ':') { setError(null); + setMessage(null); setSearch({ active: true, query: '', mode: 'regex' }); } else if (input === 'h') { + setMessage(null); setShowHelp(true); } else if (input === 'n') { + setMessage(null); handleCreateRequest(); } else if (key.delete) { // Delete - will prompt for normal or force delete handleDeleteRequest(); + } else if (input === 'f') { + // Fetch - always available (global command) + setMessage(null); + handleFetchRequest(); + } else if (input === 'm') { + // Merge - only available when non-active branch is selected + setMessage(null); + handleMergeRequest(); + } else if (input === 'r') { + // Rebase - only available when non-active branch is selected + setMessage(null); + handleRebaseRequest(); + } else if (input === 'u') { + // Pull - only available when active branch is selected + setMessage(null); + handlePullRequest(); + } else if (input === 'p') { + // Push - only available when active branch is selected + setMessage(null); + handlePushRequest(); } }); @@ -559,11 +971,17 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { // Main branch list view const viewportHeight = getViewportHeight(); - const version = '1.0.0'; // TODO: Get from package.json const cwd = process.cwd(); // Determine prompt bar content and mode const getPromptBarProps = (): { text: string; mode: 'input' | 'keyListen' } => { + if (operationInProgress) { + return { + text: operationInProgress, + mode: 'keyListen', + }; + } + if (confirmation?.active) { return { text: `Delete branch '${confirmation.branchName}'? (y/n)`, @@ -585,16 +1003,46 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { }; } + if (mergePrompt?.active) { + // Get current branch name + const currentBranch = branches.find((b) => b.current); + return { + text: `Merge '${mergePrompt.branchName}' into '${currentBranch?.name || 'current'}'? (y/n)`, + mode: 'keyListen', + }; + } + + if (rebasePrompt?.active) { + return { + text: `Rebase onto '${rebasePrompt.branchName}'? (y/n)`, + mode: 'keyListen', + }; + } + + if (pushPrompt?.active) { + return { + text: pushPrompt.needsUpstream + ? `No upstream branch set. Push and set upstream? (y/n)` + : `Push to remote? (y/n)`, + mode: 'keyListen', + }; + } + if (search.active) { const prefix = search.mode === 'regex' ? 'Regex search: ' : 'Fuzzy search: '; + const baseText = prefix + search.query; + const text = searchValidationError + ? `${baseText} - Error: ${searchValidationError}` + : baseText; return { - text: prefix + search.query, + text, mode: 'input', }; } if (creation.active) { - const baseText = `New branch: ${creation.branchName}`; + const fromText = creation.fromBranch ? ` from '${creation.fromBranch}'` : ''; + const baseText = `New branch${fromText}: ${creation.branchName}`; const text = creation.validationError ? `${baseText} - Error: ${creation.validationError}` : baseText; @@ -613,9 +1061,16 @@ export const GitBranchUI: React.FC = ({ gitManager }) => { const promptBarProps = getPromptBarProps(); + // Show error pane if multi-line error is present + if (showErrorPane && error) { + return ( + + ); + } + return ( -
+
= ({ gitManager }) => { ); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index a238db7..6458ee3 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,17 +1,20 @@ import React from 'react'; import { Box, Text } from 'ink'; +import { APP_DISPLAY_NAME } from '../utils/app-info.js'; interface Props { version: string; + gitVersion: string; cwd: string; } -export const Header: React.FC = ({ version, cwd }) => { +export const Header: React.FC = ({ version, gitVersion, cwd }) => { return ( - Rosella + {APP_DISPLAY_NAME} {version} + | {gitVersion} {cwd} diff --git a/src/components/Help.tsx b/src/components/Help.tsx index 2e60ffe..c260795 100644 --- a/src/components/Help.tsx +++ b/src/components/Help.tsx @@ -1,11 +1,12 @@ import React from 'react'; import { Box, Text } from 'ink'; +import { APP_DISPLAY_NAME } from '../utils/app-info.js'; export const Help: React.FC = () => { return ( - rosella - Help + {APP_DISPLAY_NAME} - Help @@ -15,9 +16,15 @@ export const Help: React.FC = () => { Enter Checkout selected branch Branch Actions - n Create new branch from current - Delete Safe delete branch (git branch -d) - Shift+Del Force delete branch (git branch -D) + n Create new branch + Delete Delete branch + + Git Operations + f Fetch from remote + u Pull (on current branch) + p Push (on current branch) + m Merge selected branch into current + r Rebase current branch onto selected Search / Start search (fuzzy match) @@ -26,7 +33,7 @@ export const Help: React.FC = () => { Other h Toggle this help - q Quit + q or Esc Quit {/* Spacer to push status bar to bottom */} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 0dea04e..6533f7f 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -2,17 +2,18 @@ import React from 'react'; import { Box, Text, useStdout } from 'ink'; interface Props { - selectedIndex: number; totalBranches: number; message?: string | null; error?: string | null; + hints?: string | null; } -export const StatusBar: React.FC = ({ selectedIndex, totalBranches, message, error }) => { +export const StatusBar: React.FC = ({ totalBranches, message, error, hints }) => { const { stdout } = useStdout(); const width = stdout?.columns || 80; - const text = (() => { + // Primary content: Status, message, or error -> hints + const content = (() => { // Error takes highest priority if (error) { return ` ${error} `; @@ -26,20 +27,62 @@ export const StatusBar: React.FC = ({ selectedIndex, totalBranches, messa if (totalBranches === 0) { return ' No branches '; } - return ` [press h for help] - line ${selectedIndex + 1} of ${totalBranches} `; + + // Secondary content: Hints or empty + if (hints) { + return ` ${hints} `; + } + + return ''; // Empty string instead of single space })(); - // Pad to full width - const paddedText = text + ' '.repeat(Math.max(0, width - text.length)); + // Helper to pad text to full width + const padToWidth = (text: string, maxWidth: number): string => { + return text + ' '.repeat(Math.max(0, maxWidth - text.length)); + }; + + let line1: string; + let line2: string; + + // Determine what content to show + if (content.length <= width) { + // Fits on one line - put on line 1 + line1 = padToWidth(content, width); + line2 = padToWidth('', width); + } else if (content.length <= width * 2) { + // Fits on two lines - split smartly + // Try to split at a word boundary near the middle + let splitPoint = width; + const searchStart = Math.max(0, width - 20); + const pipeIndex = content.lastIndexOf('|', width); + + if (pipeIndex > searchStart) { + // Split at the last pipe before width + splitPoint = pipeIndex; + line1 = padToWidth(content.substring(0, splitPoint).trimEnd() + ' ', width); + line2 = padToWidth(content.substring(splitPoint).trimStart(), width); + } else { + // No good split point - just split at width + line1 = padToWidth(content.substring(0, width), width); + line2 = padToWidth(content.substring(width), width); + } + } else { + // Too long for two lines - truncate + line1 = padToWidth(content.substring(0, width), width); + line2 = padToWidth(content.substring(width, width * 2 - 2) + '… ', width); + } // Choose colors based on state const backgroundColor = error ? 'red' : message ? 'green' : 'blue'; const textColor = 'white'; return ( - + + + {line1} + - {paddedText} + {line2} ); diff --git a/src/types/index.ts b/src/types/index.ts index dbeec46..957e82d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,6 +3,8 @@ export interface BranchInfo { current: boolean; commit: string; label?: string; + behindRemote?: number; + hasUncommittedChanges?: boolean; } export interface SearchState { diff --git a/src/utils/app-info.ts b/src/utils/app-info.ts new file mode 100644 index 0000000..feda524 --- /dev/null +++ b/src/utils/app-info.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +// Get the directory of the current module +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Read package.json at build time +const packageJsonPath = join(__dirname, '../../package.json'); +const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + +export const APP_NAME = packageJson.name; +export const APP_VERSION = packageJson.version; + +/** + * Display name for the application (capitalized) + * Converts 'rosella-cli' to 'Rosella' + */ +const baseName = APP_NAME.split('-')[0]; +export const APP_DISPLAY_NAME = baseName.charAt(0).toUpperCase() + baseName.slice(1); diff --git a/src/utils/git.ts b/src/utils/git.ts index 21a28cb..7c577df 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -1,4 +1,4 @@ -import { simpleGit, SimpleGit, BranchSummary } from 'simple-git'; +import { simpleGit, SimpleGit } from 'simple-git'; import type { BranchInfo } from '../types/index.js'; export interface ValidationResult { @@ -45,16 +45,52 @@ export class GitManager { async getBranches(): Promise { try { - const branchSummary: BranchSummary = await this.git.branchLocal(); - const branches: BranchInfo[] = []; + // Run git status and git for-each-ref in parallel for better performance + const [status, branchInfo] = await Promise.all([ + this.git.status(), + this.git.raw([ + 'for-each-ref', + '--format=%(refname:short)|%(objectname:short)|%(HEAD)|%(upstream:track)', + 'refs/heads/', + ]), + ]); + + const hasUncommittedChanges = + status.modified.length > 0 || + status.created.length > 0 || + status.deleted.length > 0 || + status.renamed.length > 0 || + status.staged.length > 0 || + status.not_added.length > 0; - for (const [name, branch] of Object.entries(branchSummary.branches)) { - branches.push({ - name, - current: branch.current, - commit: branch.commit, - label: branch.label, - }); + const branches: BranchInfo[] = []; + const branchLines = branchInfo.trim().split('\n').filter(line => line.length > 0); + + for (const line of branchLines) { + // Parse format: "branch-name|commit-hash|*|[behind X]" + const parts = line.split('|'); + if (parts.length >= 3) { + const name = parts[0]; + const commit = parts[1]; + const isCurrent = parts[2] === '*'; + const trackingInfo = parts[3] || ''; + + // Extract behind count from tracking info like "[behind 3]" + let behindRemote: number | undefined; + const behindMatch = trackingInfo.match(/behind (\d+)/); + if (behindMatch) { + behindRemote = parseInt(behindMatch[1], 10); + } + + branches.push({ + name, + current: isCurrent, + commit, + label: name, + behindRemote, + hasUncommittedChanges: isCurrent ? hasUncommittedChanges : undefined, + }); + } } // Sort: current -> main/master -> other @@ -95,9 +131,15 @@ export class GitManager { } } - async createBranch(branchName: string): Promise { + async createBranch(branchName: string, fromBranch?: string): Promise { try { - await this.git.branch([branchName]); + if (fromBranch) { + // Create branch from specified base branch + await this.git.branch([branchName, fromBranch]); + } else { + // Create branch from current HEAD + await this.git.branch([branchName]); + } } catch (error) { const errorMessage = String(error); // Check if branch already exists @@ -108,6 +150,72 @@ export class GitManager { } } + async fetch(): Promise { + try { + await this.git.fetch(); + } catch (error) { + throw new Error(`Failed to fetch: ${error}`); + } + } + + async pull(): Promise { + try { + await this.git.pull(); + } catch (error) { + throw new Error(`Failed to pull: ${error}`); + } + } + + async push(setUpstream: boolean = false): Promise { + try { + if (setUpstream) { + // Get current branch name + const status = await this.git.status(); + const currentBranch = status.current; + if (!currentBranch) { + throw new Error('No current branch found'); + } + // Push with --set-upstream + await this.git.push(['-u', 'origin', currentBranch]); + } else { + await this.git.push(); + } + } catch (error) { + const errorMessage = String(error); + // Check if upstream is not set + if (errorMessage.includes('no upstream') || errorMessage.includes('has no upstream branch')) { + throw new Error('NO_UPSTREAM'); + } + throw new Error(`Failed to push: ${error}`); + } + } + + async merge(branchName: string): Promise { + try { + await this.git.merge([branchName]); + } catch (error) { + const errorMessage = String(error); + // Check for merge conflicts + if (errorMessage.includes('CONFLICT') || errorMessage.includes('conflict')) { + throw new Error(`Merge conflict detected. Please resolve conflicts manually.`); + } + throw new Error(`Failed to merge '${branchName}': ${error}`); + } + } + + async rebase(branchName: string): Promise { + try { + await this.git.rebase([branchName]); + } catch (error) { + const errorMessage = String(error); + // Check for rebase conflicts + if (errorMessage.includes('CONFLICT') || errorMessage.includes('conflict')) { + throw new Error(`Rebase conflict detected. Please resolve conflicts manually.`); + } + throw new Error(`Failed to rebase onto '${branchName}': ${error}`); + } + } + async isGitRepository(): Promise { try { await this.git.status(); @@ -116,4 +224,15 @@ export class GitManager { return false; } } + + async getGitVersion(): Promise { + try { + const result = await this.git.raw(['--version']); + // Extract version from output like "git version 2.39.2" + return result.trim(); + } catch { + // Return a fallback if git version can't be determined + return 'git version unknown'; + } + } }