feat: git initialisation

This commit is contained in:
2026-02-16 10:47:55 +01:00
parent 1ecaae3dbd
commit 3b9ff2fc22
9 changed files with 523 additions and 34 deletions

View File

@@ -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'));

View File

@@ -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.',
});
});
});
});

View File

@@ -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 });
});
});
});