From e9743cb70f20e41c9aca541f73188df68e6727cb Mon Sep 17 00:00:00 2001 From: hugo Date: Mon, 16 Feb 2026 15:34:48 +0100 Subject: [PATCH] feat: phase 6 of git implementation --- src/main/engine/GitEngine.ts | 28 +++++++ src/main/ipc/handlers.ts | 5 ++ src/main/preload.ts | 1 + src/main/shared/electronApi.ts | 9 +++ .../components/GitSidebar/GitSidebar.tsx | 80 ++++++++++++++++++- tests/engine/GitEngine.test.ts | 40 ++++++++++ tests/ipc/handlers.test.ts | 24 ++++++ tests/renderer/components/GitSidebar.test.tsx | 64 +++++++++++++++ 8 files changed, 249 insertions(+), 2 deletions(-) diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index 3beda5b..3087fbc 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -69,6 +69,14 @@ export interface GitHistoryEntry { syncStatus?: GitHistorySyncStatus; } +export interface GitRemoteStateDto { + localBranch: string | null; + upstreamBranch: string | null; + hasUpstream: boolean; + ahead: number; + behind: number; +} + export type GitHistorySyncStatus = 'both' | 'local-only' | 'remote-only'; export type GitInitPhase = @@ -711,6 +719,26 @@ export class GitEngine { }); } + async getRemoteState(projectPath: string): Promise { + const git = simpleGit(projectPath); + const status = await git.status(); + + const localBranch = typeof status.current === 'string' && status.current.trim().length > 0 + ? status.current + : null; + const upstreamBranch = typeof status.tracking === 'string' && status.tracking.trim().length > 0 + ? status.tracking + : null; + + return { + localBranch, + upstreamBranch, + hasUpstream: Boolean(upstreamBranch), + ahead: typeof status.ahead === 'number' ? status.ahead : Number(status.ahead ?? 0), + behind: typeof status.behind === 'number' ? status.behind : Number(status.behind ?? 0), + }; + } + async fetch(projectPath: string): Promise { const git = this.createNonInteractiveGit(projectPath); try { diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 069b582..f95d8d5 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -68,6 +68,11 @@ export function registerIpcHandlers(): void { return engine.getHistory(projectPath, limit); }); + safeHandle('git:remoteState', async (_, projectPath: string) => { + const engine = getGitEngine(); + return engine.getRemoteState(projectPath); + }); + safeHandle('git:fetch', async (_, projectPath: string) => { const engine = getGitEngine(); return engine.fetch(projectPath); diff --git a/src/main/preload.ts b/src/main/preload.ts index 792baee..ff19ec3 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -14,6 +14,7 @@ export const electronAPI: ElectronAPI = { getDiffContent: (projectPath: string, filePath: string) => ipcRenderer.invoke('git:diffContent', projectPath, filePath), getCommitDiffContent: (projectPath: string, commitHash: string) => ipcRenderer.invoke('git:commitDiffContent', projectPath, commitHash), getHistory: (projectPath: string, limit?: number) => ipcRenderer.invoke('git:history', projectPath, limit), + getRemoteState: (projectPath: string) => ipcRenderer.invoke('git:remoteState', projectPath), fetch: (projectPath: string) => ipcRenderer.invoke('git:fetch', projectPath), pull: (projectPath: string) => ipcRenderer.invoke('git:pull', projectPath), push: (projectPath: string) => ipcRenderer.invoke('git:push', projectPath), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index c3d0325..a9ca128 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -271,6 +271,14 @@ export interface GitHistoryEntry { syncStatus?: GitHistorySyncStatus; } +export interface GitRemoteStateDto { + localBranch: string | null; + upstreamBranch: string | null; + hasUpstream: boolean; + ahead: number; + behind: number; +} + export type GitInitPhase = | 'checking-git' | 'initializing-repo' @@ -395,6 +403,7 @@ export interface ElectronAPI { getDiffContent: (projectPath: string, filePath: string) => Promise; getCommitDiffContent: (projectPath: string, commitHash: string) => Promise; getHistory: (projectPath: string, limit?: number) => Promise; + getRemoteState: (projectPath: string) => Promise; fetch: (projectPath: string) => Promise; pull: (projectPath: string) => Promise; push: (projectPath: string) => Promise; diff --git a/src/renderer/components/GitSidebar/GitSidebar.tsx b/src/renderer/components/GitSidebar/GitSidebar.tsx index f444a5f..aadf078 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.tsx +++ b/src/renderer/components/GitSidebar/GitSidebar.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useAppStore } from '../../store'; -import type { GitInitProgress, GitHistoryEntry } from '../../../main/shared/electronApi'; +import type { GitInitProgress, GitHistoryEntry, GitRemoteStateDto } from '../../../main/shared/electronApi'; import './GitSidebar.css'; import '../Sidebar/Sidebar.css'; @@ -36,17 +36,21 @@ export const GitSidebar: React.FC = () => { const [error, setError] = useState(null); const [errorGuidance, setErrorGuidance] = useState([]); const [isRepo, setIsRepo] = useState(false); + const [hasRemote, setHasRemote] = useState(false); const [currentBranch, setCurrentBranch] = useState(null); const [statusFiles, setStatusFiles] = useState>([]); const [commitMessage, setCommitMessage] = useState(''); const [historyLoading, setHistoryLoading] = useState(false); const [historyEntries, setHistoryEntries] = useState([]); + const [remoteState, setRemoteState] = useState(null); + const [remoteStateError, setRemoteStateError] = useState(null); const [initProgress, setInitProgress] = useState(null); const [initTranscript, setInitTranscript] = useState([]); const [isTranscriptExpanded, setIsTranscriptExpanded] = useState(false); const remoteUrlInputRef = useRef(null); const commitMessageInputRef = useRef(null); const statusRefreshInFlightRef = useRef(false); + const remoteRefreshInFlightRef = useRef(false); const refreshRepoDetails = useCallback( async (targetProjectPath: string, options?: { background?: boolean }) => { @@ -80,6 +84,45 @@ export const GitSidebar: React.FC = () => { [], ); + const refreshRemoteState = useCallback( + async (targetProjectPath: string, options?: { background?: boolean; fetchFirst?: boolean }) => { + if (remoteRefreshInFlightRef.current) { + return; + } + + const background = options?.background ?? false; + const fetchFirst = options?.fetchFirst ?? false; + + remoteRefreshInFlightRef.current = true; + try { + if (fetchFirst) { + const fetchResult = await window.electronAPI.git.fetch(targetProjectPath); + if (!fetchResult.success) { + const message = fetchResult.error || 'Failed to fetch remote updates.'; + setRemoteStateError(message); + if (!background) { + setError(message); + } + return; + } + } + + const nextRemoteState = await window.electronAPI.git.getRemoteState(targetProjectPath); + setRemoteState(nextRemoteState); + setRemoteStateError(null); + } catch { + const message = 'Unable to refresh remote tracking state.'; + setRemoteStateError(message); + if (!background) { + setError(message); + } + } finally { + remoteRefreshInFlightRef.current = false; + } + }, + [], + ); + const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`; const getCommitDiffTabId = (commitHash: string): string => `git-diff:commit:${commitHash}`; @@ -167,23 +210,35 @@ export const GitSidebar: React.FC = () => { const repoState = await window.electronAPI.git.getRepoState(resolvedProjectPath); setIsRepo(repoState.isRepo); + setHasRemote(repoState.hasRemote); setCurrentBranch(repoState.currentBranch || null); if (repoState.isRepo) { await refreshRepoDetails(resolvedProjectPath); + if (repoState.hasRemote) { + await refreshRemoteState(resolvedProjectPath); + } else { + setRemoteState(null); + setRemoteStateError(null); + } } else { setStatusFiles([]); setHistoryEntries([]); + setRemoteState(null); + setRemoteStateError(null); } } catch { setError('Unable to load repository status.'); setIsRepo(false); + setHasRemote(false); setStatusFiles([]); setHistoryEntries([]); + setRemoteState(null); + setRemoteStateError(null); } finally { setLoading(false); } - }, [refreshRepoDetails, resolveProjectPath]); + }, [refreshRemoteState, refreshRepoDetails, resolveProjectPath]); useEffect(() => { void loadRepoState(); @@ -217,6 +272,20 @@ export const GitSidebar: React.FC = () => { }; }, [isRepo, projectPath, refreshRepoDetails]); + useEffect(() => { + if (!isRepo || !hasRemote || !projectPath) { + return; + } + + const intervalId = globalThis.setInterval(() => { + void refreshRemoteState(projectPath, { background: true, fetchFirst: true }); + }, 30000); + + return () => { + globalThis.clearInterval(intervalId); + }; + }, [hasRemote, isRepo, projectPath, refreshRemoteState]); + const handleInitialize = async () => { if (!projectPath) { return; @@ -508,6 +577,13 @@ export const GitSidebar: React.FC = () => { )} {currentBranch &&
Branch: {currentBranch}
} + {remoteState?.hasUpstream && remoteState.localBranch && remoteState.upstreamBranch && ( +
{remoteState.localBranch} → {remoteState.upstreamBranch}
+ )} + {remoteState?.hasUpstream && ( +
ahead {remoteState.ahead} / behind {remoteState.behind}
+ )} + {remoteStateError &&
{remoteStateError}
} {error && (
diff --git a/tests/engine/GitEngine.test.ts b/tests/engine/GitEngine.test.ts index d8a1b60..91a5132 100644 --- a/tests/engine/GitEngine.test.ts +++ b/tests/engine/GitEngine.test.ts @@ -348,6 +348,46 @@ describe('GitEngine', () => { }); }); + describe('getRemoteState', () => { + it('should return upstream tracking and ahead/behind counts when tracking branch exists', async () => { + mockStatus.mockResolvedValue({ + current: 'main', + tracking: 'origin/main', + ahead: 2, + behind: 1, + }); + + const result = await gitEngine.getRemoteState('/tmp/project'); + + expect(result).toEqual({ + localBranch: 'main', + upstreamBranch: 'origin/main', + hasUpstream: true, + ahead: 2, + behind: 1, + }); + }); + + it('should return no-upstream state when tracking branch is missing', async () => { + mockStatus.mockResolvedValue({ + current: 'main', + tracking: undefined, + ahead: 0, + behind: 0, + }); + + const result = await gitEngine.getRemoteState('/tmp/project'); + + expect(result).toEqual({ + localBranch: 'main', + upstreamBranch: null, + hasUpstream: false, + ahead: 0, + behind: 0, + }); + }); + }); + describe('ensureGitignore', () => { it('should create .gitignore with default system metadata entries when missing', async () => { mockReadFile.mockRejectedValue(new Error('ENOENT')); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index 6ce5c80..945a983 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -147,6 +147,7 @@ const mockGitEngine = { getDiff: vi.fn(), getDiffContent: vi.fn(), getHistory: vi.fn(), + getRemoteState: vi.fn(), fetch: vi.fn(), pull: vi.fn(), push: vi.fn(), @@ -361,6 +362,29 @@ describe('IPC Handlers', () => { }); }); + describe('git:remoteState', () => { + it('should pass project path to GitEngine.getRemoteState', async () => { + mockGitEngine.getRemoteState.mockResolvedValue({ + localBranch: 'main', + upstreamBranch: 'origin/main', + hasUpstream: true, + ahead: 2, + behind: 1, + }); + + const result = await invokeHandler('git:remoteState', '/repo'); + + expect(mockGitEngine.getRemoteState).toHaveBeenCalledWith('/repo'); + expect(result).toEqual({ + localBranch: 'main', + upstreamBranch: 'origin/main', + hasUpstream: true, + ahead: 2, + behind: 1, + }); + }); + }); + describe('git:diffContent', () => { it('should pass project path and file path to GitEngine.getDiffContent', async () => { mockGitEngine.getDiffContent.mockResolvedValue({ diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index 0636ec3..d8a81d9 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -33,6 +33,7 @@ describe('GitSidebar', () => { checkAvailability: vi.fn().mockResolvedValue({ gitFound: true, version: '2.49.0' }), getRepoState: vi.fn().mockResolvedValue({ isRepo: false, hasRemote: false }), getStatus: vi.fn().mockResolvedValue({ files: [], counts: { untracked: 0, modified: 0, deleted: 0, renamed: 0, staged: 0, total: 0 } }), + getRemoteState: vi.fn().mockResolvedValue({ localBranch: null, upstreamBranch: null, hasUpstream: false, ahead: 0, behind: 0 }), getDiff: vi.fn().mockResolvedValue({ filePath: 'posts/a.md', patch: 'diff --git a/posts/a.md b/posts/a.md' }), getDiffContent: vi.fn().mockResolvedValue({ filePath: 'posts/a.md', original: '', modified: '' }), getCommitDiffContent: vi.fn().mockResolvedValue({ commitHash: 'abc123', original: '', modified: '' }), @@ -637,6 +638,69 @@ describe('GitSidebar', () => { }); }); + it('renders upstream branch relation with ahead/behind indicators', async () => { + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + (window as any).electronAPI.git.getRemoteState = vi.fn().mockResolvedValue({ + localBranch: 'main', + upstreamBranch: 'origin/main', + hasUpstream: true, + ahead: 2, + behind: 1, + }); + + render(); + + expect(await screen.findByText('main → origin/main')).toBeInTheDocument(); + expect(screen.getByText('ahead 2 / behind 1')).toBeInTheDocument(); + }); + + it('polls remote fetch/state periodically when repository has a remote', async () => { + vi.useFakeTimers(); + + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + (window as any).electronAPI.git.getRemoteState = vi.fn().mockResolvedValue({ + localBranch: 'main', + upstreamBranch: 'origin/main', + hasUpstream: true, + ahead: 0, + behind: 0, + }); + (window as any).electronAPI.git.fetch = vi.fn().mockResolvedValue({ success: true }); + + try { + render(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect((window as any).electronAPI.git.getRemoteState).toHaveBeenCalledTimes(1); + expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(0); + + await act(async () => { + vi.advanceTimersByTime(30000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(1); + expect((window as any).electronAPI.git.getRemoteState).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + it('polls repository status on an interval and prevents overlapping in-flight requests', async () => { vi.useFakeTimers();