diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index 45f69eb..7dd018d 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -66,8 +66,11 @@ export interface GitHistoryEntry { date: string; subject: string; author: string; + syncStatus?: GitHistorySyncStatus; } +export type GitHistorySyncStatus = 'both' | 'local-only' | 'remote-only'; + export type GitInitPhase = | 'checking-git' | 'initializing-repo' @@ -643,15 +646,67 @@ export class GitEngine { async getHistory(projectPath: string, limit = 20): Promise { const git = simpleGit(projectPath); - const history = await git.log({ maxCount: limit }); + const status = await git.status(); + const localHistory = await git.log({ maxCount: limit }); - return history.all.map((entry) => ({ - hash: entry.hash, - shortHash: entry.hash.slice(0, 7), - date: entry.date, - subject: entry.message, - author: entry.author_name, - })); + if (!status.tracking) { + return localHistory.all.map((entry) => ({ + hash: entry.hash, + shortHash: entry.hash.slice(0, 7), + date: entry.date, + subject: entry.message, + author: entry.author_name, + syncStatus: 'local-only', + })); + } + + const remoteHistory = await git.log([status.tracking, '--max-count', String(limit)]); + + type CommitLike = { + hash: string; + date: string; + message: string; + author_name: string; + }; + + const localMap = new Map(); + const remoteMap = new Map(); + + for (const entry of localHistory.all) { + localMap.set(entry.hash, entry); + } + + for (const entry of remoteHistory.all) { + remoteMap.set(entry.hash, entry); + } + + const combined = new Map(); + for (const entry of localMap.values()) { + combined.set(entry.hash, entry); + } + for (const entry of remoteMap.values()) { + if (!combined.has(entry.hash)) { + combined.set(entry.hash, entry); + } + } + + return Array.from(combined.values()) + .sort((first, second) => new Date(second.date).getTime() - new Date(first.date).getTime()) + .slice(0, limit) + .map((entry) => { + const inLocal = localMap.has(entry.hash); + const inRemote = remoteMap.has(entry.hash); + const syncStatus: GitHistorySyncStatus = inLocal && inRemote ? 'both' : inLocal ? 'local-only' : 'remote-only'; + + return { + hash: entry.hash, + shortHash: entry.hash.slice(0, 7), + date: entry.date, + subject: entry.message, + author: entry.author_name, + syncStatus, + }; + }); } async fetch(projectPath: string): Promise { diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index ae12a7d..b0e202f 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -260,12 +260,15 @@ export interface GitCommitDiffFileDto { modified: string; } +export type GitHistorySyncStatus = 'both' | 'local-only' | 'remote-only'; + export interface GitHistoryEntry { hash: string; shortHash: string; date: string; subject: string; author: string; + syncStatus?: GitHistorySyncStatus; } export type GitInitPhase = diff --git a/src/renderer/components/GitSidebar/GitSidebar.css b/src/renderer/components/GitSidebar/GitSidebar.css index 6797d34..d26e806 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.css +++ b/src/renderer/components/GitSidebar/GitSidebar.css @@ -46,6 +46,9 @@ margin-top: 12px; border-top: 1px solid var(--vscode-editorWidget-border); padding-top: 8px; + --git-history-synced-color: var(--vscode-gitDecoration-addedResourceForeground, var(--vscode-charts-green, #89d185)); + --git-history-local-color: var(--vscode-gitDecoration-untrackedResourceForeground, var(--vscode-charts-blue, #75beff)); + --git-history-remote-color: var(--vscode-gitDecoration-deletedResourceForeground, var(--vscode-charts-yellow, #cca700)); } .git-sidebar-empty-state { @@ -111,6 +114,40 @@ padding: 0 12px 8px; } +.git-sidebar-history-legend { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0 12px 8px; + font-size: 10px; + color: var(--vscode-descriptionForeground); +} + +.git-sidebar-history-legend-item { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.git-sidebar-history-legend-dot { + width: 8px; + height: 8px; + border-radius: 999px; + display: inline-block; +} + +.git-sidebar-history-legend-dot--both { + background: var(--git-history-synced-color); +} + +.git-sidebar-history-legend-dot--local-only { + background: var(--git-history-local-color); +} + +.git-sidebar-history-legend-dot--remote-only { + background: var(--git-history-remote-color); +} + .git-sidebar-history-item { width: 100%; border: none; @@ -126,6 +163,18 @@ background: var(--vscode-list-hoverBackground); } +.git-sidebar-history-item--both { + border-left: 3px solid var(--git-history-synced-color); +} + +.git-sidebar-history-item--local-only { + border-left: 3px solid var(--git-history-local-color); +} + +.git-sidebar-history-item--remote-only { + border-left: 3px solid var(--git-history-remote-color); +} + .git-sidebar-history-subject { font-size: 12px; color: var(--vscode-sideBar-foreground); @@ -141,6 +190,23 @@ color: var(--vscode-descriptionForeground); } +.git-sidebar-history-status { + font-size: 10px; + font-weight: 600; +} + +.git-sidebar-history-status--both { + color: var(--git-history-synced-color); +} + +.git-sidebar-history-status--local-only { + color: var(--git-history-local-color); +} + +.git-sidebar-history-status--remote-only { + color: var(--git-history-remote-color); +} + .git-sidebar-main { min-height: 0; } diff --git a/src/renderer/components/GitSidebar/GitSidebar.tsx b/src/renderer/components/GitSidebar/GitSidebar.tsx index 4172623..4efe17c 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.tsx +++ b/src/renderer/components/GitSidebar/GitSidebar.tsx @@ -10,7 +10,7 @@ export const GitSidebar: React.FC = () => { const [loading, setLoading] = useState(true); const [initializing, setInitializing] = useState(false); const [statusLoading, setStatusLoading] = useState(false); - const [actionLoading, setActionLoading] = useState<'fetch' | 'pull' | 'push' | 'commit' | null>(null); + const [actionLoading, setActionLoading] = useState<'fetch' | 'pull' | 'push' | 'prune-lfs' | 'commit' | null>(null); const [error, setError] = useState(null); const [errorGuidance, setErrorGuidance] = useState([]); const [isRepo, setIsRepo] = useState(false); @@ -28,7 +28,7 @@ export const GitSidebar: React.FC = () => { const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`; const getCommitDiffTabId = (commitHash: string): string => `git-diff:commit:${commitHash}`; - const getActionProgressMessage = (action: 'fetch' | 'pull' | 'push' | 'commit'): string => { + const getActionProgressMessage = (action: 'fetch' | 'pull' | 'push' | 'prune-lfs' | 'commit'): string => { if (action === 'push') { return 'Pushing commits to remote... this can take a while for large uploads.'; } @@ -38,9 +38,22 @@ export const GitSidebar: React.FC = () => { if (action === 'pull') { return 'Pulling latest changes...'; } + if (action === 'prune-lfs') { + return 'Pruning local Git LFS cache...'; + } return 'Creating commit...'; }; + const getHistoryStatusLabel = (status: GitHistoryEntry['syncStatus']): string => { + if (status === 'local-only') { + return 'Local only'; + } + if (status === 'remote-only') { + return 'Remote only'; + } + return 'Synced'; + }; + const openDiffTab = useCallback( (filePath: string, isTransient: boolean) => { openTab({ @@ -179,7 +192,7 @@ export const GitSidebar: React.FC = () => { } }; - const handleRepoAction = async (action: 'fetch' | 'pull' | 'push') => { + const handleRepoAction = async (action: 'fetch' | 'pull' | 'push' | 'prune-lfs') => { if (actionLoading) { return; } @@ -202,10 +215,15 @@ export const GitSidebar: React.FC = () => { ? await window.electronAPI.git.fetch(effectiveProjectPath) : action === 'pull' ? await window.electronAPI.git.pull(effectiveProjectPath) - : await window.electronAPI.git.push(effectiveProjectPath); + : action === 'push' + ? await window.electronAPI.git.push(effectiveProjectPath) + : await window.electronAPI.git.pruneLfs(effectiveProjectPath, { + dryRun: false, + verifyRemote: true, + }); if (!result.success) { setError(result.error || `Failed to ${action}.`); - setErrorGuidance(result.guidance || []); + setErrorGuidance('guidance' in result ? result.guidance || [] : []); return; } await loadRepoState(); @@ -317,6 +335,14 @@ export const GitSidebar: React.FC = () => { > {actionLoading === 'push' ? 'Pushing...' : 'Push'} + {actionLoading && (
@@ -372,6 +398,29 @@ export const GitSidebar: React.FC = () => {
Version History ({historyEntries.length})
+
+ + + Synced + + + + Local only + + + + Remote only + +
{historyLoading ? (
Loading history...
) : historyEntries.length === 0 ? ( @@ -382,7 +431,7 @@ export const GitSidebar: React.FC = () => {
))} diff --git a/tests/engine/GitEngine.test.ts b/tests/engine/GitEngine.test.ts index 758e0ad..74e957d 100644 --- a/tests/engine/GitEngine.test.ts +++ b/tests/engine/GitEngine.test.ts @@ -246,7 +246,8 @@ describe('GitEngine', () => { }); describe('getHistory', () => { - it('should return latest commits from git log', async () => { + it('should return latest commits from git log as local-only when no tracking branch exists', async () => { + mockStatus.mockResolvedValue({ current: 'main', tracking: undefined }); mockLog.mockResolvedValue({ all: [ { @@ -274,8 +275,77 @@ describe('GitEngine', () => { date: '2026-02-16T10:00:00.000Z', subject: 'feat: add git sidebar', author: 'Dev One', + syncStatus: 'local-only', }); }); + + it('should classify commits as both, local-only, and remote-only when tracking branch exists', async () => { + mockStatus.mockResolvedValue({ current: 'main', tracking: 'origin/main' }); + mockLog + .mockResolvedValueOnce({ + all: [ + { + hash: 'aaa111', + date: '2026-02-16T12:00:00.000Z', + message: 'feat: local commit', + author_name: 'Local Dev', + }, + { + hash: 'bbb222', + date: '2026-02-16T11:00:00.000Z', + message: 'feat: shared commit', + author_name: 'Shared Dev', + }, + ], + }) + .mockResolvedValueOnce({ + all: [ + { + hash: 'bbb222', + date: '2026-02-16T11:00:00.000Z', + message: 'feat: shared commit', + author_name: 'Shared Dev', + }, + { + hash: 'ccc333', + date: '2026-02-16T10:00:00.000Z', + message: 'fix: remote commit', + author_name: 'Remote Dev', + }, + ], + }); + + const result = await gitEngine.getHistory('/tmp/project', 20); + + expect(mockLog).toHaveBeenNthCalledWith(1, { maxCount: 20 }); + expect(mockLog).toHaveBeenNthCalledWith(2, ['origin/main', '--max-count', '20']); + expect(result).toEqual([ + { + hash: 'aaa111', + shortHash: 'aaa111', + date: '2026-02-16T12:00:00.000Z', + subject: 'feat: local commit', + author: 'Local Dev', + syncStatus: 'local-only', + }, + { + hash: 'bbb222', + shortHash: 'bbb222', + date: '2026-02-16T11:00:00.000Z', + subject: 'feat: shared commit', + author: 'Shared Dev', + syncStatus: 'both', + }, + { + hash: 'ccc333', + shortHash: 'ccc333', + date: '2026-02-16T10:00:00.000Z', + subject: 'fix: remote commit', + author: 'Remote Dev', + syncStatus: 'remote-only', + }, + ]); + }); }); describe('ensureGitignore', () => { diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index ce9a241..9dd75c1 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen, fireEvent, act } from '@testing-library/react'; +import { render, screen, fireEvent, act, within } from '@testing-library/react'; import { GitSidebar } from '../../../src/renderer/components/GitSidebar/GitSidebar'; import { useAppStore } from '../../../src/renderer/store'; @@ -40,6 +40,7 @@ describe('GitSidebar', () => { fetch: vi.fn().mockResolvedValue({ success: true }), pull: vi.fn().mockResolvedValue({ success: true }), push: vi.fn().mockResolvedValue({ success: true }), + pruneLfs: vi.fn().mockResolvedValue({ success: true, dryRun: false, verifyRemote: true }), commitAll: vi.fn().mockResolvedValue({ success: true }), init: vi.fn().mockResolvedValue({ success: true }), ensureGitignore: vi.fn().mockResolvedValue({ updated: false, created: false, addedEntries: [] }), @@ -96,6 +97,85 @@ describe('GitSidebar', () => { expect(screen.getByText(/abc123/i)).toBeInTheDocument(); }); + it('renders color-coded commit state labels for local, remote, and synced commits', async () => { + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + (window as any).electronAPI.git.getHistory = vi.fn().mockResolvedValue([ + { + hash: 'aaa111', + shortHash: 'aaa111', + date: '2026-02-16T10:00:00.000Z', + subject: 'feat: local', + author: 'Dev One', + syncStatus: 'local-only', + }, + { + hash: 'bbb222', + shortHash: 'bbb222', + date: '2026-02-16T09:00:00.000Z', + subject: 'feat: remote', + author: 'Dev Two', + syncStatus: 'remote-only', + }, + { + hash: 'ccc333', + shortHash: 'ccc333', + date: '2026-02-16T08:00:00.000Z', + subject: 'feat: both', + author: 'Dev Three', + syncStatus: 'both', + }, + ]); + + render(); + + expect((await screen.findAllByText('Local only')).length).toBeGreaterThan(0); + expect(screen.getAllByText('Remote only').length).toBeGreaterThan(0); + expect(screen.getAllByText('Synced').length).toBeGreaterThan(0); + + const localCommit = screen.getByRole('button', { name: /feat: local/i }); + const remoteCommit = screen.getByRole('button', { name: /feat: remote/i }); + const syncedCommit = screen.getByRole('button', { name: /feat: both/i }); + + expect(localCommit).toHaveClass('git-sidebar-history-item--local-only'); + expect(remoteCommit).toHaveClass('git-sidebar-history-item--remote-only'); + expect(syncedCommit).toHaveClass('git-sidebar-history-item--both'); + }); + + it('renders commit status legend in version history section', async () => { + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + (window as any).electronAPI.git.getHistory = vi.fn().mockResolvedValue([ + { + hash: 'aaa111', + shortHash: 'aaa111', + date: '2026-02-16T10:00:00.000Z', + subject: 'feat: local', + author: 'Dev One', + syncStatus: 'local-only', + }, + ]); + + render(); + + expect(await screen.findByText(/version history/i)).toBeInTheDocument(); + const legend = screen.getByLabelText('Commit status legend'); + expect(within(legend).getByText('Synced')).toBeInTheDocument(); + expect(within(legend).getByText('Local only')).toBeInTheDocument(); + expect(within(legend).getByText('Remote only')).toBeInTheDocument(); + expect(screen.getByTestId('git-history-legend-both')).toBeInTheDocument(); + expect(screen.getByTestId('git-history-legend-local-only')).toBeInTheDocument(); + expect(screen.getByTestId('git-history-legend-remote-only')).toBeInTheDocument(); + }); + it('uses the same section-title class as posts published heading', async () => { (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ isRepo: true, @@ -401,7 +481,7 @@ describe('GitSidebar', () => { expect(screen.getByText(/100% — failed to configure remote repository/i)).toBeInTheDocument(); }); - it('wires fetch, pull, and push buttons', async () => { + it('wires fetch, pull, push, and prune lfs buttons', async () => { (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ isRepo: true, rootPath: '/repo/path', @@ -414,16 +494,52 @@ describe('GitSidebar', () => { const fetchButton = await screen.findByRole('button', { name: /fetch/i }); const pullButton = screen.getByRole('button', { name: /pull/i }); const pushButton = screen.getByRole('button', { name: /push/i }); + const pruneButton = screen.getByRole('button', { name: /prune lfs/i }); await act(async () => { fireEvent.click(fetchButton); fireEvent.click(pullButton); fireEvent.click(pushButton); + fireEvent.click(pruneButton); }); expect((window as any).electronAPI.git.fetch).toHaveBeenCalledWith('/repo/path'); expect((window as any).electronAPI.git.pull).toHaveBeenCalledWith('/repo/path'); expect((window as any).electronAPI.git.push).toHaveBeenCalledWith('/repo/path'); + expect((window as any).electronAPI.git.pruneLfs).toHaveBeenCalledWith('/repo/path', { + dryRun: false, + verifyRemote: true, + }); + }); + + it('shows in-progress feedback while prune lfs is running', async () => { + let resolvePrune: ((value: { success: boolean; dryRun: boolean; verifyRemote: boolean }) => void) | null = null; + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + (window as any).electronAPI.git.pruneLfs = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolvePrune = resolve; + }), + ); + + render(); + + const pruneButton = await screen.findByRole('button', { name: /prune lfs/i }); + await act(async () => { + fireEvent.click(pruneButton); + }); + + expect(screen.getByRole('status')).toHaveTextContent(/pruning local git lfs cache/i); + expect(screen.getByRole('button', { name: /pruning/i })).toBeDisabled(); + + await act(async () => { + resolvePrune?.({ success: true, dryRun: false, verifyRemote: true }); + }); }); it('commits all changes and closes open git-diff tabs', async () => {