feat: git initialisation
This commit is contained in:
@@ -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<Set<string>> {
|
||||
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<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private async hasHeadCommit(git: ReturnType<typeof simpleGit>): Promise<boolean> {
|
||||
try {
|
||||
await git.raw(['rev-parse', '--verify', 'HEAD']);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async isLfsConfigured(git: ReturnType<typeof simpleGit>): Promise<boolean> {
|
||||
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<string[]> {
|
||||
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<GitAvailability> {
|
||||
try {
|
||||
const versionResult = await simpleGit().version();
|
||||
@@ -117,9 +187,19 @@ export class GitEngine {
|
||||
};
|
||||
}
|
||||
|
||||
async initializeRepo(projectPath: string, remoteUrl?: string): Promise<GitInitResult> {
|
||||
async initializeRepo(
|
||||
projectPath: string,
|
||||
remoteUrl?: string,
|
||||
onProgress?: (progress: GitInitProgress) => void,
|
||||
): Promise<GitInitResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ============
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<GitRepoState>;
|
||||
getStatus: (projectPath: string) => Promise<GitStatusDto>;
|
||||
init: (projectPath: string, remoteUrl?: string) => Promise<GitInitResult>;
|
||||
onInitProgress: (callback: (progress: GitInitProgress) => void) => () => void;
|
||||
};
|
||||
projects: {
|
||||
create: (data: { name: string; description?: string; slug?: string; dataPath?: string }) => Promise<ProjectData>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [isRepo, setIsRepo] = useState(false);
|
||||
const [currentBranch, setCurrentBranch] = useState<string | null>(null);
|
||||
const [initProgress, setInitProgress] = useState<GitInitProgress | null>(null);
|
||||
const [initTranscript, setInitTranscript] = useState<GitInitProgress[]>([]);
|
||||
const remoteUrlInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const resolveProjectPath = useCallback(async (): Promise<string | null> => {
|
||||
@@ -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 ? (
|
||||
<div className="git-sidebar-transcript">
|
||||
<p className="git-sidebar-transcript-title">Initialization transcript</p>
|
||||
<ul className="git-sidebar-transcript-list">
|
||||
{initTranscript.map((entry, index) => (
|
||||
<li key={`${entry.phase}-${entry.progress}-${index}`}>
|
||||
{entry.progress}% — {entry.message.toLowerCase().replace(/\.+$/, '')}
|
||||
{entry.detail ? ` (${entry.detail})` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (isRepo) {
|
||||
return (
|
||||
<div className="git-sidebar">
|
||||
@@ -102,6 +136,7 @@ export const GitSidebar: React.FC = () => {
|
||||
<div className="git-sidebar-empty">
|
||||
<p>Git repository ready</p>
|
||||
{currentBranch && <p>Branch: {currentBranch}</p>}
|
||||
{transcriptSection}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -119,6 +154,13 @@ export const GitSidebar: React.FC = () => {
|
||||
placeholder="Optional remote repository URL"
|
||||
disabled={initializing}
|
||||
/>
|
||||
{initializing && (
|
||||
<p className="git-sidebar-progress">
|
||||
{initProgress?.message || 'Initializing repository...'}
|
||||
{typeof initProgress?.progress === 'number' ? ` (${initProgress.progress}%)` : ''}
|
||||
{initProgress?.detail ? ` — ${initProgress.detail}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="git-sidebar-error">{error}</p>}
|
||||
<button
|
||||
className="git-sidebar-button"
|
||||
@@ -127,6 +169,7 @@ export const GitSidebar: React.FC = () => {
|
||||
>
|
||||
{initializing ? 'Initializing...' : 'Initialize Git'}
|
||||
</button>
|
||||
{transcriptSection}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -237,6 +237,14 @@ async function invokeHandler(channel: string, ...args: any[]): Promise<any> {
|
||||
return handler({}, ...args);
|
||||
}
|
||||
|
||||
async function invokeHandlerWithEvent(event: any, channel: string, ...args: any[]): Promise<any> {
|
||||
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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<GitSidebar />);
|
||||
|
||||
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(<GitSidebar />);
|
||||
|
||||
const onInitProgressMock = (window as any).electronAPI.git.onInitProgress as ReturnType<typeof vi.fn>;
|
||||
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(<GitSidebar />);
|
||||
|
||||
const onInitProgressMock = (window as any).electronAPI.git.onInitProgress as ReturnType<typeof vi.fn>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user