diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index f8a4966..d468e27 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -1,4 +1,6 @@ import { simpleGit } from 'simple-git'; +import { readFile, stat } from 'fs/promises'; +import * as path from 'path'; export interface GitAvailability { gitFound: boolean; @@ -34,10 +36,28 @@ export interface GitStatusDto { counts: GitStatusCounts; } +export type GitInitPhase = + | 'checking-git' + | 'initializing-repo' + | 'configuring-lfs' + | 'tracking-lfs-patterns' + | 'staging-files' + | 'creating-initial-commit' + | 'configuring-remote' + | 'completed' + | 'failed'; + +export interface GitInitProgress { + phase: GitInitPhase; + progress: number; + message: string; + detail?: string; +} + export interface GitInitResult { success: boolean; error?: string; - code?: 'git-missing' | 'git-lfs-missing' | 'init-failed' | 'remote-failed'; + code?: 'git-missing' | 'git-lfs-missing' | 'init-failed' | 'remote-failed' | 'commit-failed'; } let gitEngineInstance: GitEngine | null = null; @@ -50,6 +70,56 @@ export function getGitEngine(): GitEngine { } export class GitEngine { + private async readLfsTrackedPatterns(projectPath: string): Promise> { + try { + const attributesPath = path.join(projectPath, '.gitattributes'); + const content = await readFile(attributesPath, 'utf8'); + const patterns = content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#') && line.includes('filter=lfs')) + .map((line) => line.split(/\s+/)[0]) + .filter(Boolean); + return new Set(patterns); + } catch { + return new Set(); + } + } + + private async hasHeadCommit(git: ReturnType): Promise { + try { + await git.raw(['rev-parse', '--verify', 'HEAD']); + return true; + } catch { + return false; + } + } + + private async isLfsConfigured(git: ReturnType): Promise { + try { + const output = await git.raw(['config', '--local', '--get', 'filter.lfs.clean']); + return output.trim().length > 0; + } catch { + return false; + } + } + + private async existingStageTargets(projectPath: string): Promise { + const targets = ['posts', 'media', 'meta', '.gitattributes']; + const existing: string[] = []; + + for (const target of targets) { + try { + await stat(path.join(projectPath, target)); + existing.push(target); + } catch { + continue; + } + } + + return existing; + } + async checkAvailability(): Promise { try { const versionResult = await simpleGit().version(); @@ -117,9 +187,19 @@ export class GitEngine { }; } - async initializeRepo(projectPath: string, remoteUrl?: string): Promise { + async initializeRepo( + projectPath: string, + remoteUrl?: string, + onProgress?: (progress: GitInitProgress) => void, + ): Promise { + const emitProgress = (phase: GitInitPhase, progress: number, message: string, detail?: string): void => { + onProgress?.({ phase, progress, message, detail }); + }; + + emitProgress('checking-git', 5, 'Checking Git availability...'); const availability = await this.checkAvailability(); if (!availability.gitFound) { + emitProgress('failed', 100, 'Git executable not found. Please install Git and restart the app.'); return { success: false, code: 'git-missing', @@ -128,55 +208,121 @@ export class GitEngine { } const git = simpleGit(projectPath); + const isRepo = await git.checkIsRepo(); - try { - await git.init(); - } catch { - return { - success: false, - code: 'init-failed', - error: 'Failed to initialize repository for this project.', - }; + if (isRepo) { + emitProgress('initializing-repo', 15, 'Initializing repository...', 'already initialized'); + } else { + emitProgress('initializing-repo', 15, 'Initializing repository...'); + try { + await git.init(); + } catch { + emitProgress('failed', 100, 'Failed to initialize repository for this project.'); + return { + success: false, + code: 'init-failed', + error: 'Failed to initialize repository for this project.', + }; + } } - try { - await git.raw(['lfs', 'install', '--local']); - } catch { - return { - success: false, - code: 'git-lfs-missing', - error: 'Git LFS executable not found. Please install Git LFS and try again.', - }; + const lfsConfigured = await this.isLfsConfigured(git); + if (lfsConfigured) { + emitProgress('configuring-lfs', 30, 'Configuring Git LFS...', 'already configured'); + } else { + emitProgress('configuring-lfs', 30, 'Configuring Git LFS...'); + try { + await git.raw(['lfs', 'install', '--local']); + } catch { + emitProgress('failed', 100, 'Git LFS executable not found. Please install Git LFS and try again.'); + return { + success: false, + code: 'git-lfs-missing', + error: 'Git LFS executable not found. Please install Git LFS and try again.', + }; + } } const imagePatterns = ['*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.svg', '*.avif', '*.heic']; + const trackedPatterns = await this.readLfsTrackedPatterns(projectPath); + const patternsToTrack = imagePatterns.filter((pattern) => !trackedPatterns.has(pattern)); - for (const pattern of imagePatterns) { - await git.raw(['lfs', 'track', pattern]); + if (patternsToTrack.length === 0) { + emitProgress('tracking-lfs-patterns', 55, 'Tracking image patterns with Git LFS...', 'already tracked'); + } else { + for (let index = 0; index < patternsToTrack.length; index += 1) { + const pattern = patternsToTrack[index]; + const progress = 35 + Math.round((index / patternsToTrack.length) * 20); + emitProgress('tracking-lfs-patterns', progress, 'Tracking image patterns with Git LFS...', pattern); + await git.raw(['lfs', 'track', pattern]); + } } - await git.add('.gitattributes'); + const stageTargets = await this.existingStageTargets(projectPath); + if (stageTargets.length === 0) { + emitProgress('staging-files', 75, 'Staging project files...', 'no target files found'); + } else { + emitProgress('staging-files', 75, 'Staging project files...', stageTargets.join(', ')); + await git.add(stageTargets); + } + + const hasCommit = await this.hasHeadCommit(git); + if (hasCommit) { + emitProgress('creating-initial-commit', 90, 'Creating initial commit...', 'already has commits'); + } else { + emitProgress('creating-initial-commit', 90, 'Creating initial commit...'); + try { + await git.commit('initial commit'); + } catch (error) { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + if (message.includes('nothing to commit')) { + emitProgress('creating-initial-commit', 90, 'Creating initial commit...', 'nothing to commit'); + } else { + emitProgress('failed', 100, 'Failed to create initial commit.'); + return { + success: false, + code: 'commit-failed', + error: 'Failed to create initial commit.', + }; + } + } + } const normalizedRemoteUrl = remoteUrl?.trim(); if (normalizedRemoteUrl) { + emitProgress('configuring-remote', 96, 'Configuring remote repository...'); try { const remotes = await git.getRemotes(true); - const hasOrigin = remotes.some((remote) => remote.name === 'origin'); + const origin = remotes.find((remote) => remote.name === 'origin'); - if (hasOrigin) { - await git.remote(['set-url', 'origin', normalizedRemoteUrl]); + if (origin) { + const fetchUrl = origin.refs.fetch || ''; + const pushUrl = origin.refs.push || ''; + const alreadyMatching = fetchUrl === normalizedRemoteUrl && (pushUrl === normalizedRemoteUrl || pushUrl === ''); + + if (alreadyMatching) { + emitProgress('configuring-remote', 96, 'Configuring remote repository...', 'already up to date'); + } else { + await git.remote(['set-url', 'origin', normalizedRemoteUrl]); + emitProgress('configuring-remote', 96, 'Configuring remote repository...', 'updated origin URL'); + } } else { await git.addRemote('origin', normalizedRemoteUrl); + emitProgress('configuring-remote', 96, 'Configuring remote repository...', 'created origin remote'); } } catch { + emitProgress('failed', 100, 'Failed to configure remote repository.'); return { success: false, code: 'remote-failed', error: 'Failed to configure remote repository.', }; } + } else { + emitProgress('configuring-remote', 96, 'Configuring remote repository...', 'not provided'); } + emitProgress('completed', 100, 'Repository initialized successfully.'); return { success: true }; } } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index ec8f708..952ab8c 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -48,12 +48,11 @@ export function registerIpcHandlers(): void { return engine.getStatus(projectPath); }); - safeHandle('git:init', async (_, projectPath: string, remoteUrl?: string) => { + safeHandle('git:init', async (event, projectPath: string, remoteUrl?: string) => { const engine = getGitEngine(); - if (remoteUrl) { - return engine.initializeRepo(projectPath, remoteUrl); - } - return engine.initializeRepo(projectPath); + return engine.initializeRepo(projectPath, remoteUrl, (progress) => { + event.sender.send('git:initProgress', progress); + }); }); // ============ Project Handlers ============ diff --git a/src/main/preload.ts b/src/main/preload.ts index 30e8de6..50981da 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { ElectronAPI } from './shared/electronApi'; +import type { GitInitProgress } from './shared/electronApi'; // Expose protected methods that allow the renderer process to use // ipcRenderer without exposing the entire object @@ -15,6 +16,11 @@ export const electronAPI: ElectronAPI = { } return ipcRenderer.invoke('git:init', projectPath); }, + onInitProgress: (callback: (data: GitInitProgress) => void) => { + const subscription = (_event: Electron.IpcRendererEvent, data: GitInitProgress) => callback(data); + ipcRenderer.on('git:initProgress', subscription); + return () => ipcRenderer.removeListener('git:initProgress', subscription); + }, }, // Projects diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 95500c0..c12d92f 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -236,10 +236,28 @@ export interface GitStatusDto { counts: GitStatusCounts; } +export type GitInitPhase = + | 'checking-git' + | 'initializing-repo' + | 'configuring-lfs' + | 'tracking-lfs-patterns' + | 'staging-files' + | 'creating-initial-commit' + | 'configuring-remote' + | 'completed' + | 'failed'; + +export interface GitInitProgress { + phase: GitInitPhase; + progress: number; + message: string; + detail?: string; +} + export interface GitInitResult { success: boolean; error?: string; - code?: 'git-missing' | 'git-lfs-missing' | 'init-failed' | 'remote-failed'; + code?: 'git-missing' | 'git-lfs-missing' | 'init-failed' | 'remote-failed' | 'commit-failed'; } // Post-Media Link types @@ -317,6 +335,7 @@ export interface ElectronAPI { getRepoState: (projectPath: string) => Promise; getStatus: (projectPath: string) => Promise; init: (projectPath: string, remoteUrl?: string) => Promise; + onInitProgress: (callback: (progress: GitInitProgress) => void) => () => void; }; projects: { create: (data: { name: string; description?: string; slug?: string; dataPath?: string }) => Promise; diff --git a/src/renderer/components/GitSidebar/GitSidebar.css b/src/renderer/components/GitSidebar/GitSidebar.css index 983202e..0699e31 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.css +++ b/src/renderer/components/GitSidebar/GitSidebar.css @@ -27,6 +27,36 @@ color: var(--vscode-errorForeground); } +.git-sidebar-progress { + color: var(--vscode-descriptionForeground); +} + +.git-sidebar-transcript { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid var(--vscode-editorWidget-border); +} + +.git-sidebar-transcript-title { + margin: 0 0 6px; + font-size: 11px; + font-weight: 600; + color: var(--vscode-sideBar-foreground); +} + +.git-sidebar-transcript-list { + margin: 0; + padding-left: 16px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + max-height: 180px; + overflow: auto; +} + +.git-sidebar-transcript-list li { + margin: 0 0 4px; +} + .git-sidebar-input { width: 100%; margin: 0 0 10px; diff --git a/src/renderer/components/GitSidebar/GitSidebar.tsx b/src/renderer/components/GitSidebar/GitSidebar.tsx index 0bcebc4..106dceb 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.tsx +++ b/src/renderer/components/GitSidebar/GitSidebar.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useAppStore } from '../../store'; +import type { GitInitProgress } from '../../../main/shared/electronApi'; import './GitSidebar.css'; export const GitSidebar: React.FC = () => { @@ -10,6 +11,8 @@ export const GitSidebar: React.FC = () => { const [error, setError] = useState(null); const [isRepo, setIsRepo] = useState(false); const [currentBranch, setCurrentBranch] = useState(null); + const [initProgress, setInitProgress] = useState(null); + const [initTranscript, setInitTranscript] = useState([]); const remoteUrlInputRef = useRef(null); const resolveProjectPath = useCallback(async (): Promise => { @@ -60,6 +63,17 @@ export const GitSidebar: React.FC = () => { void loadRepoState(); }, [loadRepoState]); + useEffect(() => { + const unsubscribe = window.electronAPI.git.onInitProgress((progress) => { + setInitProgress(progress); + setInitTranscript((previous) => [...previous, progress].slice(-12)); + }); + + return () => { + unsubscribe(); + }; + }, []); + const handleInitialize = async () => { if (!projectPath) { return; @@ -67,6 +81,12 @@ export const GitSidebar: React.FC = () => { setInitializing(true); setError(null); + setInitTranscript([]); + setInitProgress({ + phase: 'initializing-repo', + progress: 0, + message: 'Preparing repository initialization...', + }); try { const normalizedRemoteUrl = remoteUrlInputRef.current?.value.trim() || ''; @@ -95,6 +115,20 @@ export const GitSidebar: React.FC = () => { ); } + const transcriptSection = initTranscript.length > 0 ? ( +
+

Initialization transcript

+
    + {initTranscript.map((entry, index) => ( +
  • + {entry.progress}% — {entry.message.toLowerCase().replace(/\.+$/, '')} + {entry.detail ? ` (${entry.detail})` : ''} +
  • + ))} +
+
+ ) : null; + if (isRepo) { return (
@@ -102,6 +136,7 @@ export const GitSidebar: React.FC = () => {

Git repository ready

{currentBranch &&

Branch: {currentBranch}

} + {transcriptSection}
); @@ -119,6 +154,13 @@ export const GitSidebar: React.FC = () => { placeholder="Optional remote repository URL" disabled={initializing} /> + {initializing && ( +

+ {initProgress?.message || 'Initializing repository...'} + {typeof initProgress?.progress === 'number' ? ` (${initProgress.progress}%)` : ''} + {initProgress?.detail ? ` — ${initProgress.detail}` : ''} +

+ )} {error &&

{error}

} + {transcriptSection} ); diff --git a/tests/engine/GitEngine.test.ts b/tests/engine/GitEngine.test.ts index 6a5346b..177f259 100644 --- a/tests/engine/GitEngine.test.ts +++ b/tests/engine/GitEngine.test.ts @@ -7,9 +7,20 @@ const mockStatus = vi.fn(); const mockInit = vi.fn(); const mockRaw = vi.fn(); const mockAdd = vi.fn(); +const mockCommit = vi.fn(); const mockGetRemotes = vi.fn(); const mockAddRemote = vi.fn(); const mockRemote = vi.fn(); +const { mockReadFile, mockStat } = vi.hoisted(() => ({ + mockReadFile: vi.fn(), + mockStat: vi.fn(), +})); + +vi.mock('fs/promises', () => ({ + readFile: mockReadFile, + stat: mockStat, + default: {}, +})); vi.mock('simple-git', () => ({ simpleGit: vi.fn(() => ({ @@ -20,6 +31,7 @@ vi.mock('simple-git', () => ({ init: mockInit, raw: mockRaw, add: mockAdd, + commit: mockCommit, getRemotes: mockGetRemotes, addRemote: mockAddRemote, remote: mockRemote, @@ -33,6 +45,9 @@ describe('GitEngine', () => { beforeEach(() => { vi.clearAllMocks(); + mockReadFile.mockRejectedValue(new Error('ENOENT')); + mockStat.mockResolvedValue({}); + mockCheckIsRepo.mockResolvedValue(false); gitEngine = new GitEngine(); }); @@ -122,18 +137,114 @@ describe('GitEngine', () => { }); describe('initializeRepo', () => { - it('should initialize git repo, configure lfs and track image patterns', async () => { + it('should emit detailed progress updates throughout initialization', async () => { mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true }); mockInit.mockResolvedValue(undefined); mockRaw.mockResolvedValue('ok'); mockAdd.mockResolvedValue(undefined); + mockCommit.mockResolvedValue(undefined); + mockGetRemotes.mockResolvedValue([]); + mockAddRemote.mockResolvedValue(undefined); + + const onProgress = vi.fn(); + const result = await gitEngine.initializeRepo('/tmp/project', 'https://github.com/example/repo.git', onProgress); + + expect(result).toEqual({ success: true }); + expect(onProgress).toHaveBeenCalled(); + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'checking-git' })); + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'initializing-repo' })); + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'configuring-lfs' })); + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'tracking-lfs-patterns' })); + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'staging-files' })); + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'creating-initial-commit' })); + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'configuring-remote' })); + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'completed', progress: 100 })); + }); + + it('should emit failed progress state when initialization fails', async () => { + mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true }); + mockInit.mockRejectedValue(new Error('init failed')); + + const onProgress = vi.fn(); + const result = await gitEngine.initializeRepo('/tmp/project', undefined, onProgress); + + expect(result.success).toBe(false); + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'failed' })); + }); + + it('should skip already-completed steps on re-run and still succeed', async () => { + mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true }); + mockCheckIsRepo.mockResolvedValue(true); + mockRaw.mockImplementation(async (args: string[]) => { + if (args[0] === 'config' && args[4] === 'filter.lfs.clean') { + return 'git-lfs clean -- %f'; + } + if (args[0] === 'rev-parse' && args[2] === 'HEAD') { + return 'abc123'; + } + return 'ok'; + }); + mockReadFile.mockResolvedValue( + '*.png filter=lfs diff=lfs merge=lfs -text\n*.jpg filter=lfs diff=lfs merge=lfs -text\n', + ); + mockGetRemotes.mockResolvedValue([{ name: 'origin', refs: { fetch: 'https://github.com/example/repo.git', push: 'https://github.com/example/repo.git' } }]); + + const onProgress = vi.fn(); + const result = await gitEngine.initializeRepo('/tmp/project', 'https://github.com/example/repo.git', onProgress); + + expect(result).toEqual({ success: true }); + expect(mockInit).not.toHaveBeenCalled(); + expect(mockCommit).not.toHaveBeenCalled(); + expect(mockAddRemote).not.toHaveBeenCalled(); + expect(mockRemote).not.toHaveBeenCalled(); + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'initializing-repo', detail: 'already initialized' })); + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'configuring-lfs', detail: 'already configured' })); + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'creating-initial-commit', detail: 'already has commits' })); + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'configuring-remote', detail: 'already up to date' })); + }); + + it('should update existing origin remote when URL differs', async () => { + mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true }); + mockCheckIsRepo.mockResolvedValue(true); + mockRaw.mockImplementation(async (args: string[]) => { + if (args[0] === 'config' && args[4] === 'filter.lfs.clean') { + return 'git-lfs clean -- %f'; + } + if (args[0] === 'rev-parse' && args[2] === 'HEAD') { + return 'abc123'; + } + return 'ok'; + }); + mockReadFile.mockResolvedValue('*.png filter=lfs diff=lfs merge=lfs -text\n'); + mockGetRemotes.mockResolvedValue([{ name: 'origin', refs: { fetch: 'https://github.com/old/repo.git', push: 'https://github.com/old/repo.git' } }]); + + const result = await gitEngine.initializeRepo('/tmp/project', 'https://github.com/new/repo.git'); + + expect(result).toEqual({ success: true }); + expect(mockRemote).toHaveBeenCalledWith(['set-url', 'origin', 'https://github.com/new/repo.git']); + }); + + it('should initialize git repo, configure lfs and track image patterns', async () => { + mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true }); + mockInit.mockResolvedValue(undefined); + mockRaw.mockImplementation(async (args: string[]) => { + if (args[0] === 'config' && args[3] === 'filter.lfs.clean') { + throw new Error('unset'); + } + if (args[0] === 'rev-parse' && args[2] === 'HEAD') { + throw new Error('no commits'); + } + return 'ok'; + }); + mockAdd.mockResolvedValue(undefined); + mockCommit.mockResolvedValue(undefined); const result = await gitEngine.initializeRepo('/tmp/project'); expect(result).toEqual({ success: true }); expect(mockInit).toHaveBeenCalled(); expect(mockRaw).toHaveBeenCalledWith(['lfs', 'install', '--local']); - expect(mockAdd).toHaveBeenCalledWith('.gitattributes'); + expect(mockCommit).toHaveBeenCalledWith('initial commit'); expect(mockAddRemote).not.toHaveBeenCalled(); }); @@ -142,6 +253,7 @@ describe('GitEngine', () => { mockInit.mockResolvedValue(undefined); mockRaw.mockResolvedValue('ok'); mockAdd.mockResolvedValue(undefined); + mockCommit.mockResolvedValue(undefined); mockGetRemotes.mockResolvedValue([]); mockAddRemote.mockResolvedValue(undefined); @@ -152,6 +264,18 @@ describe('GitEngine', () => { expect(mockAddRemote).toHaveBeenCalledWith('origin', 'https://github.com/example/repo.git'); }); + it('should succeed when there is nothing to commit after staging', async () => { + mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true }); + mockInit.mockResolvedValue(undefined); + mockRaw.mockResolvedValue('ok'); + mockAdd.mockResolvedValue(undefined); + mockCommit.mockRejectedValue(new Error('nothing to commit, working tree clean')); + + const result = await gitEngine.initializeRepo('/tmp/project'); + + expect(result).toEqual({ success: true }); + }); + it('should return explicit git missing guidance when git is unavailable', async () => { mockVersion.mockRejectedValue(new Error('git: command not found')); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index ce1da8a..6d209c1 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -237,6 +237,14 @@ async function invokeHandler(channel: string, ...args: any[]): Promise { return handler({}, ...args); } +async function invokeHandlerWithEvent(event: any, channel: string, ...args: any[]): Promise { + const handler = registeredHandlers.get(channel); + if (!handler) { + throw new Error(`No handler registered for channel: ${channel}`); + } + return handler(event, ...args); +} + describe('IPC Handlers', () => { beforeEach(async () => { // Clear all mocks @@ -314,7 +322,7 @@ describe('IPC Handlers', () => { const result = await invokeHandler('git:init', '/repo'); - expect(mockGitEngine.initializeRepo).toHaveBeenCalledWith('/repo'); + expect(mockGitEngine.initializeRepo).toHaveBeenCalledWith('/repo', undefined, expect.any(Function)); expect(result).toEqual({ success: true }); }); @@ -323,7 +331,32 @@ describe('IPC Handlers', () => { await invokeHandler('git:init', '/repo', 'https://github.com/example/repo.git'); - expect(mockGitEngine.initializeRepo).toHaveBeenCalledWith('/repo', 'https://github.com/example/repo.git'); + expect(mockGitEngine.initializeRepo).toHaveBeenCalledWith('/repo', 'https://github.com/example/repo.git', expect.any(Function)); + }); + + it('should forward init progress updates to renderer via event sender', async () => { + mockGitEngine.initializeRepo.mockImplementation(async (_projectPath: string, _remoteUrl: string | undefined, onProgress: (payload: unknown) => void) => { + onProgress({ phase: 'initializing-repo', progress: 20, message: 'Initializing repository...' }); + onProgress({ phase: 'completed', progress: 100, message: 'Repository initialized.' }); + return { success: true }; + }); + + const send = vi.fn(); + const event = { sender: { send } }; + + const result = await invokeHandlerWithEvent(event, 'git:init', '/repo'); + + expect(result).toEqual({ success: true }); + expect(send).toHaveBeenCalledWith('git:initProgress', { + phase: 'initializing-repo', + progress: 20, + message: 'Initializing repository...', + }); + expect(send).toHaveBeenCalledWith('git:initProgress', { + phase: 'completed', + progress: 100, + message: 'Repository initialized.', + }); }); }); }); diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index 3f72a4e..cd00ec4 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -29,6 +29,7 @@ describe('GitSidebar', () => { checkAvailability: vi.fn().mockResolvedValue({ gitFound: true, version: '2.49.0' }), getRepoState: vi.fn().mockResolvedValue({ isRepo: false, hasRemote: false }), init: vi.fn().mockResolvedValue({ success: true }), + onInitProgress: vi.fn().mockImplementation(() => () => {}), }, }; }); @@ -89,4 +90,92 @@ describe('GitSidebar', () => { expect((window as any).electronAPI.git.init).toHaveBeenCalledWith('/repo/path', 'https://github.com/example/repo.git'); }); }); + + it('shows detailed progress feedback while initialization is running', async () => { + let resolveInit: ((value: { success: boolean }) => void) | null = null; + (window as any).electronAPI.git.init = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveInit = resolve; + }), + ); + + render(); + + const initButton = await screen.findByRole('button', { name: /initialize git/i }); + + await act(async () => { + fireEvent.click(initButton); + }); + + expect(screen.getByText(/preparing repository initialization/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /initializing/i })).toBeDisabled(); + + await act(async () => { + resolveInit?.({ success: true }); + }); + }); + + it('updates progress detail text from init progress events', async () => { + let resolveInit: ((value: { success: boolean }) => void) | null = null; + (window as any).electronAPI.git.init = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveInit = resolve; + }), + ); + + render(); + + const onInitProgressMock = (window as any).electronAPI.git.onInitProgress as ReturnType; + const subscription = onInitProgressMock.mock.calls[0][0] as (payload: { message: string }) => void; + + const initButton = await screen.findByRole('button', { name: /initialize git/i }); + await act(async () => { + fireEvent.click(initButton); + }); + + await act(async () => { + subscription({ message: 'Staging project files...', progress: 75 }); + }); + + expect(screen.getByText(/75% — staging project files/i)).toBeInTheDocument(); + + await act(async () => { + resolveInit?.({ success: true }); + }); + }); + + it('renders a compact transcript of initialization steps', async () => { + let resolveInit: ((value: { success: boolean }) => void) | null = null; + (window as any).electronAPI.git.init = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveInit = resolve; + }), + ); + + render(); + + const onInitProgressMock = (window as any).electronAPI.git.onInitProgress as ReturnType; + const subscription = onInitProgressMock.mock.calls[0][0] as (payload: { message: string; progress: number }) => void; + + const initButton = await screen.findByRole('button', { name: /initialize git/i }); + await act(async () => { + fireEvent.click(initButton); + }); + + await act(async () => { + subscription({ message: 'Checking Git availability...', progress: 5 }); + subscription({ message: 'Initializing repository...', progress: 15 }); + }); + + expect(screen.getByText(/initialization transcript/i)).toBeInTheDocument(); + expect(screen.getByText(/5% — checking git availability/i)).toBeInTheDocument(); + expect(screen.getByText(/15% — initializing repository/i)).toBeInTheDocument(); + + await act(async () => { + resolveInit?.({ success: true }); + }); + }); });