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

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

View File

@@ -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 ============

View File

@@ -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

View File

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

View File

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

View File

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