From d7286ef92f09f6b67b7e63007a5b598a9890764b Mon Sep 17 00:00:00 2001 From: hugo Date: Mon, 16 Feb 2026 09:45:34 +0100 Subject: [PATCH] feat: start of git integration --- package-lock.json | 31 +++ package.json | 1 + src/main/engine/GitEngine.ts | 182 ++++++++++++++++++ src/main/engine/index.ts | 12 +- src/main/ipc/handlers.ts | 26 +++ src/main/preload.ts | 13 ++ src/main/shared/electronApi.ts | 46 +++++ .../components/ActivityBar/ActivityBar.tsx | 16 +- .../components/GitSidebar/GitSidebar.css | 53 +++++ .../components/GitSidebar/GitSidebar.tsx | 133 +++++++++++++ src/renderer/components/Sidebar/Sidebar.tsx | 2 + src/renderer/store/appStore.ts | 4 +- tests/engine/GitEngine.test.ts | 177 +++++++++++++++++ tests/ipc/handlers.test.ts | 86 +++++++++ tests/renderer/components/GitSidebar.test.tsx | 92 +++++++++ tests/setup.ts | 6 + 16 files changed, 876 insertions(+), 4 deletions(-) create mode 100644 src/main/engine/GitEngine.ts create mode 100644 src/renderer/components/GitSidebar/GitSidebar.css create mode 100644 src/renderer/components/GitSidebar/GitSidebar.tsx create mode 100644 tests/engine/GitEngine.test.ts create mode 100644 tests/renderer/components/GitSidebar.test.tsx diff --git a/package-lock.json b/package-lock.json index 6a408f1..667f5f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "react-dom": "^19.2.4", "react-hot-toast": "^2.6.0", "sharp": "^0.34.5", + "simple-git": "^3.31.1", "snowball-stemmers": "^0.6.0", "turndown": "^7.2.2", "zod": "^4.3.6", @@ -3502,6 +3503,21 @@ "tslib": "2" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, "node_modules/@lezer/common": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", @@ -12096,6 +12112,21 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-git": { + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.31.1.tgz", + "integrity": "sha512-oiWP4Q9+kO8q9hHqkX35uuHmxiEbZNTrZ5IPxgMGrJwN76pzjm/jabkZO0ItEcqxAincqGAzL3QHSaHt4+knBg==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", diff --git a/package.json b/package.json index e7ed096..729de4f 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "react-dom": "^19.2.4", "react-hot-toast": "^2.6.0", "sharp": "^0.34.5", + "simple-git": "^3.31.1", "snowball-stemmers": "^0.6.0", "turndown": "^7.2.2", "zod": "^4.3.6", diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts new file mode 100644 index 0000000..f8a4966 --- /dev/null +++ b/src/main/engine/GitEngine.ts @@ -0,0 +1,182 @@ +import { simpleGit } from 'simple-git'; + +export interface GitAvailability { + gitFound: boolean; + version?: string; +} + +export interface RepoState { + isRepo: boolean; + rootPath?: string; + currentBranch?: string; + hasRemote: boolean; +} + +export type GitFileStatus = 'untracked' | 'modified' | 'deleted' | 'renamed' | 'staged'; + +export interface GitStatusFile { + path: string; + status: GitFileStatus; + previousPath?: string; +} + +export interface GitStatusCounts { + untracked: number; + modified: number; + deleted: number; + renamed: number; + staged: number; + total: number; +} + +export interface GitStatusDto { + files: GitStatusFile[]; + counts: GitStatusCounts; +} + +export interface GitInitResult { + success: boolean; + error?: string; + code?: 'git-missing' | 'git-lfs-missing' | 'init-failed' | 'remote-failed'; +} + +let gitEngineInstance: GitEngine | null = null; + +export function getGitEngine(): GitEngine { + if (!gitEngineInstance) { + gitEngineInstance = new GitEngine(); + } + return gitEngineInstance; +} + +export class GitEngine { + async checkAvailability(): Promise { + try { + const versionResult = await simpleGit().version(); + return { + gitFound: true, + version: `${versionResult.major}.${versionResult.minor}.${versionResult.patch}`, + }; + } catch { + return { gitFound: false }; + } + } + + async getRepoState(projectPath: string): Promise { + const git = simpleGit(projectPath); + const isRepo = await git.checkIsRepo(); + + if (!isRepo) { + return { + isRepo: false, + hasRemote: false, + }; + } + + const [rootPath, status] = await Promise.all([ + git.revparse(['--show-toplevel']), + git.status(), + ]); + + return { + isRepo: true, + rootPath: rootPath.trim(), + currentBranch: status.current ?? undefined, + hasRemote: Boolean(status.tracking), + }; + } + + async getStatus(projectPath: string): Promise { + const git = simpleGit(projectPath); + const status = await git.status(); + + const files: GitStatusFile[] = [ + ...status.not_added.map((filePath) => ({ path: filePath, status: 'untracked' as const })), + ...status.modified.map((filePath) => ({ path: filePath, status: 'modified' as const })), + ...status.deleted.map((filePath) => ({ path: filePath, status: 'deleted' as const })), + ...status.renamed.map((renamed) => ({ + path: renamed.to, + status: 'renamed' as const, + previousPath: renamed.from, + })), + ...status.created.map((filePath) => ({ path: filePath, status: 'staged' as const })), + ]; + + const counts: GitStatusCounts = { + untracked: status.not_added.length, + modified: status.modified.length, + deleted: status.deleted.length, + renamed: status.renamed.length, + staged: status.created.length, + total: files.length, + }; + + return { + files, + counts, + }; + } + + async initializeRepo(projectPath: string, remoteUrl?: string): Promise { + const availability = await this.checkAvailability(); + if (!availability.gitFound) { + return { + success: false, + code: 'git-missing', + error: 'Git executable not found. Please install Git and restart the app.', + }; + } + + const git = simpleGit(projectPath); + + try { + await git.init(); + } catch { + 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 imagePatterns = ['*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.svg', '*.avif', '*.heic']; + + for (const pattern of imagePatterns) { + await git.raw(['lfs', 'track', pattern]); + } + + await git.add('.gitattributes'); + + const normalizedRemoteUrl = remoteUrl?.trim(); + if (normalizedRemoteUrl) { + try { + const remotes = await git.getRemotes(true); + const hasOrigin = remotes.some((remote) => remote.name === 'origin'); + + if (hasOrigin) { + await git.remote(['set-url', 'origin', normalizedRemoteUrl]); + } else { + await git.addRemote('origin', normalizedRemoteUrl); + } + } catch { + return { + success: false, + code: 'remote-failed', + error: 'Failed to configure remote repository.', + }; + } + } + + return { success: true }; + } +} diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index 97b28f5..97eaf46 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -72,4 +72,14 @@ export { type DiffField, type ScanResult, type TableStats, -} from './MetadataDiffEngine'; \ No newline at end of file +} from './MetadataDiffEngine'; +export { + GitEngine, + getGitEngine, + type GitAvailability, + type RepoState, + type GitStatusDto, + type GitStatusFile, + type GitStatusCounts, + type GitInitResult, +} from './GitEngine'; \ No newline at end of file diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 05786a8..fff0cf1 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -8,6 +8,7 @@ import { getProjectEngine, ProjectData } from '../engine/ProjectEngine'; import { getMetaEngine } from '../engine/MetaEngine'; import { getTagEngine } from '../engine/TagEngine'; import { getPostMediaEngine } from '../engine/PostMediaEngine'; +import { getGitEngine } from '../engine/GitEngine'; import { taskManager, TaskProgress } from '../engine/TaskManager'; import { getDatabase } from '../database'; import { media } from '../database/schema'; @@ -30,6 +31,31 @@ function safeHandle(channel: string, handler: (...args: any[]) => Promise): } export function registerIpcHandlers(): void { + // ============ Git Handlers ============ + + safeHandle('git:checkAvailability', async () => { + const engine = getGitEngine(); + return engine.checkAvailability(); + }); + + safeHandle('git:getRepoState', async (_, projectPath: string) => { + const engine = getGitEngine(); + return engine.getRepoState(projectPath); + }); + + safeHandle('git:status', async (_, projectPath: string) => { + const engine = getGitEngine(); + return engine.getStatus(projectPath); + }); + + safeHandle('git:init', async (_, projectPath: string, remoteUrl?: string) => { + const engine = getGitEngine(); + if (remoteUrl) { + return engine.initializeRepo(projectPath, remoteUrl); + } + return engine.initializeRepo(projectPath); + }); + // ============ Project Handlers ============ safeHandle('projects:create', async (_, data: { name: string; description?: string; slug?: string; dataPath?: string }) => { diff --git a/src/main/preload.ts b/src/main/preload.ts index 46cdcc6..30e8de6 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -4,6 +4,19 @@ import type { ElectronAPI } from './shared/electronApi'; // Expose protected methods that allow the renderer process to use // ipcRenderer without exposing the entire object export const electronAPI: ElectronAPI = { + // Git + git: { + checkAvailability: () => ipcRenderer.invoke('git:checkAvailability'), + getRepoState: (projectPath: string) => ipcRenderer.invoke('git:getRepoState', projectPath), + getStatus: (projectPath: string) => ipcRenderer.invoke('git:status', projectPath), + init: (projectPath: string, remoteUrl?: string) => { + if (remoteUrl) { + return ipcRenderer.invoke('git:init', projectPath, remoteUrl); + } + return ipcRenderer.invoke('git:init', projectPath); + }, + }, + // Projects projects: { create: (data: { name: string; description?: string; slug?: string; dataPath?: string }) => ipcRenderer.invoke('projects:create', data), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index bc85093..95500c0 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -202,6 +202,46 @@ export interface SyncTagsResult { added: string[]; } +export interface GitAvailability { + gitFound: boolean; + version?: string; +} + +export interface GitRepoState { + isRepo: boolean; + rootPath?: string; + currentBranch?: string; + hasRemote: boolean; +} + +export type GitFileStatus = 'untracked' | 'modified' | 'deleted' | 'renamed' | 'staged'; + +export interface GitStatusFile { + path: string; + status: GitFileStatus; + previousPath?: string; +} + +export interface GitStatusCounts { + untracked: number; + modified: number; + deleted: number; + renamed: number; + staged: number; + total: number; +} + +export interface GitStatusDto { + files: GitStatusFile[]; + counts: GitStatusCounts; +} + +export interface GitInitResult { + success: boolean; + error?: string; + code?: 'git-missing' | 'git-lfs-missing' | 'init-failed' | 'remote-failed'; +} + // Post-Media Link types export interface MediaLinkData { id: string; @@ -272,6 +312,12 @@ export interface ChatTitleUpdate { } export interface ElectronAPI { + git: { + checkAvailability: () => Promise; + getRepoState: (projectPath: string) => Promise; + getStatus: (projectPath: string) => Promise; + init: (projectPath: string, remoteUrl?: string) => Promise; + }; projects: { create: (data: { name: string; description?: string; slug?: string; dataPath?: string }) => Promise; update: (id: string, data: Partial) => Promise; diff --git a/src/renderer/components/ActivityBar/ActivityBar.tsx b/src/renderer/components/ActivityBar/ActivityBar.tsx index d0d313e..6d2228e 100644 --- a/src/renderer/components/ActivityBar/ActivityBar.tsx +++ b/src/renderer/components/ActivityBar/ActivityBar.tsx @@ -49,6 +49,12 @@ const ImportIcon = () => ( ); +const GitIcon = () => ( + + + +); + export const ActivityBar: React.FC = () => { const { activeView, setActiveView, sidebarVisible, toggleSidebar, openTab, tabs, activeTabId } = useAppStore(); @@ -66,9 +72,10 @@ export const ActivityBar: React.FC = () => { // Check if import sidebar is active const isImportActive = activeView === 'import' && sidebarVisible; + const isGitActive = activeView === 'git' && sidebarVisible; // Handle view click - toggle sidebar if clicking on active view, otherwise switch view - const handleViewClick = (view: 'posts' | 'pages' | 'media' | 'chat') => { + const handleViewClick = (view: 'posts' | 'pages' | 'media' | 'chat' | 'git') => { if (activeView === view && sidebarVisible) { // Clicking on active view toggles sidebar off toggleSidebar(); @@ -162,6 +169,13 @@ export const ActivityBar: React.FC = () => {
+ +
+ + ); +}; diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 2b9cf0a..3bb0b3f 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -3,6 +3,7 @@ import { useAppStore, PostData, MediaData } from '../../store'; import { showToast } from '../Toast'; import { getContrastColor, groupPostsByStatus } from '../../utils'; import type { ChatConversation, ImportDefinitionData } from '../../types/electron'; +import { GitSidebar } from '../GitSidebar/GitSidebar'; import './Sidebar.css'; /** Get display name for media: title (truncated to 60 chars) or fallback to filename */ @@ -1640,6 +1641,7 @@ export const Sidebar: React.FC = () => { {activeView === 'tags' && } {activeView === 'chat' && } {activeView === 'import' && } + {activeView === 'git' && } ); }; diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index e2ab29f..dad8266 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -50,7 +50,7 @@ interface AppState { activeTabId: string | null; // UI State - activeView: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import'; + activeView: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import' | 'git'; sidebarVisible: boolean; panelVisible: boolean; selectedPostId: string | null; @@ -96,7 +96,7 @@ interface AppState { restoreTabState: (state: TabState) => void; // Actions - setActiveView: (view: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import') => void; + setActiveView: (view: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import' | 'git') => void; toggleSidebar: () => void; togglePanel: () => void; setSelectedPost: (id: string | null) => void; diff --git a/tests/engine/GitEngine.test.ts b/tests/engine/GitEngine.test.ts new file mode 100644 index 0000000..6a5346b --- /dev/null +++ b/tests/engine/GitEngine.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const mockVersion = vi.fn(); +const mockCheckIsRepo = vi.fn(); +const mockRevparse = vi.fn(); +const mockStatus = vi.fn(); +const mockInit = vi.fn(); +const mockRaw = vi.fn(); +const mockAdd = vi.fn(); +const mockGetRemotes = vi.fn(); +const mockAddRemote = vi.fn(); +const mockRemote = vi.fn(); + +vi.mock('simple-git', () => ({ + simpleGit: vi.fn(() => ({ + version: mockVersion, + checkIsRepo: mockCheckIsRepo, + revparse: mockRevparse, + status: mockStatus, + init: mockInit, + raw: mockRaw, + add: mockAdd, + getRemotes: mockGetRemotes, + addRemote: mockAddRemote, + remote: mockRemote, + })), +})); + +import { GitEngine } from '../../src/main/engine/GitEngine'; + +describe('GitEngine', () => { + let gitEngine: GitEngine; + + beforeEach(() => { + vi.clearAllMocks(); + gitEngine = new GitEngine(); + }); + + describe('checkAvailability', () => { + it('should return gitFound true with version when git is available', async () => { + mockVersion.mockResolvedValue({ + major: 2, + minor: 49, + patch: 0, + agent: 'git/version', + installed: true, + }); + + const result = await gitEngine.checkAvailability(); + + expect(result).toEqual({ gitFound: true, version: '2.49.0' }); + }); + + it('should return gitFound false when git is not available', async () => { + mockVersion.mockRejectedValue(new Error('git: command not found')); + + const result = await gitEngine.checkAvailability(); + + expect(result).toEqual({ gitFound: false }); + }); + }); + + describe('getRepoState', () => { + it('should return non-repo state when project is not a git repository', async () => { + mockCheckIsRepo.mockResolvedValue(false); + + const result = await gitEngine.getRepoState('/tmp/project'); + + expect(result).toEqual({ + isRepo: false, + hasRemote: false, + }); + }); + + it('should return repo details for a valid repository', async () => { + mockCheckIsRepo.mockResolvedValue(true); + mockRevparse.mockResolvedValue('/tmp/project'); + mockStatus.mockResolvedValue({ + current: 'main', + tracking: 'origin/main', + }); + + const result = await gitEngine.getRepoState('/tmp/project'); + + expect(result).toEqual({ + isRepo: true, + rootPath: '/tmp/project', + currentBranch: 'main', + hasRemote: true, + }); + }); + }); + + describe('getStatus', () => { + it('should normalize changed files and counts from git status', async () => { + mockStatus.mockResolvedValue({ + not_added: ['new-file.md'], + modified: ['edited.md'], + deleted: ['removed.md'], + renamed: [{ from: 'old.md', to: 'new.md' }], + created: ['staged.md'], + }); + + const result = await gitEngine.getStatus('/tmp/project'); + + expect(result.counts).toEqual({ + untracked: 1, + modified: 1, + deleted: 1, + renamed: 1, + staged: 1, + total: 5, + }); + expect(result.files).toEqual([ + { path: 'new-file.md', status: 'untracked' }, + { path: 'edited.md', status: 'modified' }, + { path: 'removed.md', status: 'deleted' }, + { path: 'new.md', status: 'renamed', previousPath: 'old.md' }, + { path: 'staged.md', status: 'staged' }, + ]); + }); + }); + + describe('initializeRepo', () => { + 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.mockResolvedValue('ok'); + mockAdd.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(mockAddRemote).not.toHaveBeenCalled(); + }); + + it('should configure origin remote when a remote url is provided', async () => { + mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true }); + mockInit.mockResolvedValue(undefined); + mockRaw.mockResolvedValue('ok'); + mockAdd.mockResolvedValue(undefined); + mockGetRemotes.mockResolvedValue([]); + mockAddRemote.mockResolvedValue(undefined); + + const result = await gitEngine.initializeRepo('/tmp/project', 'https://github.com/example/repo.git'); + + expect(result).toEqual({ success: true }); + expect(mockGetRemotes).toHaveBeenCalledWith(true); + expect(mockAddRemote).toHaveBeenCalledWith('origin', 'https://github.com/example/repo.git'); + }); + + it('should return explicit git missing guidance when git is unavailable', async () => { + mockVersion.mockRejectedValue(new Error('git: command not found')); + + const result = await gitEngine.initializeRepo('/tmp/project'); + + expect(result.success).toBe(false); + expect(result.code).toBe('git-missing'); + expect(result.error).toContain('install Git'); + }); + + it('should return explicit git-lfs missing guidance when lfs command fails', async () => { + mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true }); + mockInit.mockResolvedValue(undefined); + mockRaw.mockRejectedValue(new Error('git: lfs is not a git command')); + + const result = await gitEngine.initializeRepo('/tmp/project'); + + expect(result.success).toBe(false); + expect(result.code).toBe('git-lfs-missing'); + expect(result.error).toContain('install Git LFS'); + }); + }); +}); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index 946604e..e358c71 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -139,6 +139,13 @@ const mockPostMediaEngine = { rebuildFromSidecars: vi.fn(), }; +const mockGitEngine = { + checkAvailability: vi.fn(), + getRepoState: vi.fn(), + getStatus: vi.fn(), + initializeRepo: vi.fn(), +}; + const mockTaskManager = { getAllTasks: vi.fn(), cancelTask: vi.fn(), @@ -193,6 +200,10 @@ vi.mock('../../src/main/engine/PostMediaEngine', () => ({ getPostMediaEngine: vi.fn(() => mockPostMediaEngine), })); +vi.mock('../../src/main/engine/GitEngine', () => ({ + getGitEngine: vi.fn(() => mockGitEngine), +})); + vi.mock('../../src/main/engine/TaskManager', () => ({ taskManager: mockTaskManager, TaskProgress: {}, @@ -241,6 +252,81 @@ describe('IPC Handlers', () => { vi.resetModules(); }); + // ============ Git Handlers ============ + describe('Git Handlers', () => { + describe('git:checkAvailability', () => { + it('should return availability from GitEngine', async () => { + mockGitEngine.checkAvailability.mockResolvedValue({ gitFound: true, version: '2.49.0' }); + + const result = await invokeHandler('git:checkAvailability'); + + expect(mockGitEngine.checkAvailability).toHaveBeenCalled(); + expect(result).toEqual({ gitFound: true, version: '2.49.0' }); + }); + }); + + describe('git:getRepoState', () => { + it('should pass project path to GitEngine.getRepoState', async () => { + mockGitEngine.getRepoState.mockResolvedValue({ + isRepo: true, + rootPath: '/repo', + currentBranch: 'main', + hasRemote: true, + }); + + const result = await invokeHandler('git:getRepoState', '/repo'); + + expect(mockGitEngine.getRepoState).toHaveBeenCalledWith('/repo'); + expect(result).toEqual({ + isRepo: true, + rootPath: '/repo', + currentBranch: 'main', + hasRemote: true, + }); + }); + }); + + describe('git:status', () => { + it('should pass project path to GitEngine.getStatus', async () => { + mockGitEngine.getStatus.mockResolvedValue({ + files: [{ path: 'file.md', status: 'modified' }], + counts: { + untracked: 0, + modified: 1, + deleted: 0, + renamed: 0, + staged: 0, + total: 1, + }, + }); + + const result = await invokeHandler('git:status', '/repo'); + + expect(mockGitEngine.getStatus).toHaveBeenCalledWith('/repo'); + expect(result.counts.total).toBe(1); + }); + }); + + describe('git:init', () => { + it('should pass project path to GitEngine.initializeRepo', async () => { + mockGitEngine.initializeRepo.mockResolvedValue({ success: true }); + + const result = await invokeHandler('git:init', '/repo'); + + expect(mockGitEngine.initializeRepo).toHaveBeenCalledWith('/repo'); + expect(result).toEqual({ success: true }); + }); + + it('should pass optional remote url to GitEngine.initializeRepo', async () => { + mockGitEngine.initializeRepo.mockResolvedValue({ success: true }); + + await invokeHandler('git:init', '/repo', 'https://github.com/example/repo.git'); + + expect(mockGitEngine.initializeRepo).toHaveBeenCalledWith('/repo', 'https://github.com/example/repo.git'); + }); + }); + }); + // ============ Project Handlers ============ describe('Project Handlers', () => { describe('projects:create', () => { diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx new file mode 100644 index 0000000..3f72a4e --- /dev/null +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { GitSidebar } from '../../../src/renderer/components/GitSidebar/GitSidebar'; +import { useAppStore } from '../../../src/renderer/store'; + +describe('GitSidebar', () => { + beforeEach(() => { + vi.clearAllMocks(); + + useAppStore.setState({ + activeProject: { + id: 'project-1', + name: 'Test Project', + slug: 'test-project', + isActive: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + + (window as any).electronAPI = { + ...(window as any).electronAPI, + app: { + ...(window as any).electronAPI?.app, + getDefaultProjectPath: vi.fn().mockResolvedValue('/repo/path'), + }, + git: { + checkAvailability: vi.fn().mockResolvedValue({ gitFound: true, version: '2.49.0' }), + getRepoState: vi.fn().mockResolvedValue({ isRepo: false, hasRemote: false }), + init: vi.fn().mockResolvedValue({ success: true }), + }, + }; + }); + + it('shows Initialize Git button when active project is not a git repository', async () => { + render(); + + expect(await screen.findByRole('button', { name: /initialize git/i })).toBeInTheDocument(); + }); + + it('initializes repository and refreshes repo state after clicking Initialize Git', async () => { + const getRepoStateMock = vi + .fn() + .mockResolvedValueOnce({ isRepo: false, hasRemote: false }) + .mockResolvedValueOnce({ isRepo: true, rootPath: '/repo/path', currentBranch: 'main', hasRemote: true }); + + (window as any).electronAPI.git.getRepoState = getRepoStateMock; + (window as any).electronAPI.git.init = vi.fn().mockResolvedValue({ success: true }); + + render(); + + const initButton = await screen.findByRole('button', { name: /initialize git/i }); + await act(async () => { + fireEvent.click(initButton); + }); + + await vi.waitFor(() => { + expect((window as any).electronAPI.git.init).toHaveBeenCalledWith('/repo/path'); + }); + + await vi.waitFor(() => { + expect(screen.getByText(/git repository ready/i)).toBeInTheDocument(); + }); + }); + + it('passes remote url to init when user provides one', async () => { + (window as any).electronAPI.git.init = vi.fn().mockResolvedValue({ success: true }); + (window as any).electronAPI.git.getRepoState = vi + .fn() + .mockResolvedValueOnce({ isRepo: false, hasRemote: false }) + .mockResolvedValueOnce({ isRepo: true, rootPath: '/repo/path', currentBranch: 'main', hasRemote: true }); + + render(); + + const remoteInput = await screen.findByPlaceholderText(/optional remote repository url/i); + await act(async () => { + fireEvent.change(remoteInput, { target: { value: 'https://github.com/example/repo.git' } }); + }); + + expect((remoteInput as HTMLInputElement).value).toBe('https://github.com/example/repo.git'); + + const initButton = screen.getByRole('button', { name: /initialize git/i }); + await act(async () => { + fireEvent.click(initButton); + }); + + await vi.waitFor(() => { + expect((window as any).electronAPI.git.init).toHaveBeenCalledWith('/repo/path', 'https://github.com/example/repo.git'); + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 4f093ec..5b518b8 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -44,6 +44,12 @@ Object.defineProperty(globalThis, 'window', { value: { localStorage: localStorageMock, electronAPI: { + git: { + checkAvailability: vi.fn(), + getRepoState: vi.fn(), + getStatus: vi.fn(), + init: vi.fn(), + }, posts: { create: vi.fn(), update: vi.fn(),