From 2e27df0d63975a2bb24fb8e539ef044b21f6c477 Mon Sep 17 00:00:00 2001 From: hugo Date: Mon, 16 Feb 2026 10:58:30 +0100 Subject: [PATCH] fix: added gitignore handling --- src/main/engine/GitEngine.ts | 69 ++++++++++++++- src/main/ipc/handlers.ts | 5 ++ src/main/preload.ts | 1 + src/main/shared/electronApi.ts | 7 ++ .../components/GitSidebar/GitSidebar.css | 25 +++++- .../components/GitSidebar/GitSidebar.tsx | 87 +++++++++++-------- tests/engine/GitEngine.test.ts | 47 +++++++++- tests/ipc/handlers.test.ts | 20 +++++ tests/renderer/components/GitSidebar.test.tsx | 37 +++++++- 9 files changed, 254 insertions(+), 44 deletions(-) diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index d468e27..f6aebc9 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -1,5 +1,5 @@ import { simpleGit } from 'simple-git'; -import { readFile, stat } from 'fs/promises'; +import * as fsPromises from 'fs/promises'; import * as path from 'path'; export interface GitAvailability { @@ -60,6 +60,12 @@ export interface GitInitResult { code?: 'git-missing' | 'git-lfs-missing' | 'init-failed' | 'remote-failed' | 'commit-failed'; } +export interface GitIgnoreEnsureResult { + updated: boolean; + created: boolean; + addedEntries: string[]; +} + let gitEngineInstance: GitEngine | null = null; export function getGitEngine(): GitEngine { @@ -70,10 +76,21 @@ export function getGitEngine(): GitEngine { } export class GitEngine { + private readonly defaultGitignoreEntries = [ + '.DS_Store', + 'Thumbs.db', + 'Desktop.ini', + '$RECYCLE.BIN/', + '.Spotlight-V100/', + '.Trashes/', + '._*', + '.fseventsd', + ]; + private async readLfsTrackedPatterns(projectPath: string): Promise> { try { const attributesPath = path.join(projectPath, '.gitattributes'); - const content = await readFile(attributesPath, 'utf8'); + const content = await fsPromises.readFile(attributesPath, 'utf8'); const patterns = content .split('\n') .map((line) => line.trim()) @@ -110,7 +127,7 @@ export class GitEngine { for (const target of targets) { try { - await stat(path.join(projectPath, target)); + await fsPromises.stat(path.join(projectPath, target)); existing.push(target); } catch { continue; @@ -187,6 +204,52 @@ export class GitEngine { }; } + async ensureGitignore(projectPath: string): Promise { + const gitignorePath = path.join(projectPath, '.gitignore'); + + let existingContent = ''; + let created = false; + + try { + existingContent = await fsPromises.readFile(gitignorePath, 'utf8'); + } catch { + created = true; + } + + const existingEntries = new Set( + existingContent + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')), + ); + + const addedEntries = this.defaultGitignoreEntries.filter((entry) => !existingEntries.has(entry)); + + if (addedEntries.length === 0) { + return { + updated: false, + created: false, + addedEntries: [], + }; + } + + const sections: string[] = []; + if (existingContent.trim().length > 0) { + sections.push(existingContent.trimEnd()); + } + + sections.push('# System metadata'); + sections.push(...addedEntries); + + await fsPromises.writeFile(gitignorePath, `${sections.join('\n')}\n`, 'utf8'); + + return { + updated: true, + created, + addedEntries, + }; + } + async initializeRepo( projectPath: string, remoteUrl?: string, diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 952ab8c..ac7b089 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -55,6 +55,11 @@ export function registerIpcHandlers(): void { }); }); + safeHandle('git:ensureGitignore', async (_, projectPath: string) => { + const engine = getGitEngine(); + return engine.ensureGitignore(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 50981da..4549f5f 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -10,6 +10,7 @@ export const electronAPI: ElectronAPI = { checkAvailability: () => ipcRenderer.invoke('git:checkAvailability'), getRepoState: (projectPath: string) => ipcRenderer.invoke('git:getRepoState', projectPath), getStatus: (projectPath: string) => ipcRenderer.invoke('git:status', projectPath), + ensureGitignore: (projectPath: string) => ipcRenderer.invoke('git:ensureGitignore', projectPath), init: (projectPath: string, remoteUrl?: string) => { if (remoteUrl) { return ipcRenderer.invoke('git:init', projectPath, remoteUrl); diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index c12d92f..f71c69e 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -260,6 +260,12 @@ export interface GitInitResult { code?: 'git-missing' | 'git-lfs-missing' | 'init-failed' | 'remote-failed' | 'commit-failed'; } +export interface GitIgnoreEnsureResult { + updated: boolean; + created: boolean; + addedEntries: string[]; +} + // Post-Media Link types export interface MediaLinkData { id: string; @@ -334,6 +340,7 @@ export interface ElectronAPI { checkAvailability: () => Promise; getRepoState: (projectPath: string) => Promise; getStatus: (projectPath: string) => Promise; + ensureGitignore: (projectPath: string) => Promise; init: (projectPath: string, remoteUrl?: string) => Promise; onInitProgress: (callback: (progress: GitInitProgress) => void) => () => void; }; diff --git a/src/renderer/components/GitSidebar/GitSidebar.css b/src/renderer/components/GitSidebar/GitSidebar.css index 0699e31..0239d7d 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.css +++ b/src/renderer/components/GitSidebar/GitSidebar.css @@ -14,11 +14,18 @@ } .git-sidebar-empty { + display: flex; + flex-direction: column; + flex: 1; padding: 16px 12px; color: var(--vscode-descriptionForeground); font-size: 12px; } +.git-sidebar-main { + min-height: 0; +} + .git-sidebar-empty p { margin: 0 0 10px; } @@ -32,20 +39,30 @@ } .git-sidebar-transcript { - margin-top: 10px; + margin-top: auto; padding-top: 8px; border-top: 1px solid var(--vscode-editorWidget-border); } -.git-sidebar-transcript-title { - margin: 0 0 6px; +.git-sidebar-transcript-toggle { + width: 100%; + margin: 0; + padding: 0; + border: none; + background: transparent; + text-align: left; + cursor: pointer; font-size: 11px; font-weight: 600; color: var(--vscode-sideBar-foreground); } +.git-sidebar-transcript-toggle:hover { + text-decoration: underline; +} + .git-sidebar-transcript-list { - margin: 0; + margin: 6px 0 0; padding-left: 16px; font-size: 11px; color: var(--vscode-descriptionForeground); diff --git a/src/renderer/components/GitSidebar/GitSidebar.tsx b/src/renderer/components/GitSidebar/GitSidebar.tsx index 106dceb..6d248d9 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.tsx +++ b/src/renderer/components/GitSidebar/GitSidebar.tsx @@ -13,6 +13,7 @@ export const GitSidebar: React.FC = () => { const [currentBranch, setCurrentBranch] = useState(null); const [initProgress, setInitProgress] = useState(null); const [initTranscript, setInitTranscript] = useState([]); + const [isTranscriptExpanded, setIsTranscriptExpanded] = useState(false); const remoteUrlInputRef = useRef(null); const resolveProjectPath = useCallback(async (): Promise => { @@ -48,6 +49,8 @@ export const GitSidebar: React.FC = () => { return; } + await window.electronAPI.git.ensureGitignore(resolvedProjectPath); + const repoState = await window.electronAPI.git.getRepoState(resolvedProjectPath); setIsRepo(repoState.isRepo); setCurrentBranch(repoState.currentBranch || null); @@ -67,6 +70,9 @@ export const GitSidebar: React.FC = () => { const unsubscribe = window.electronAPI.git.onInitProgress((progress) => { setInitProgress(progress); setInitTranscript((previous) => [...previous, progress].slice(-12)); + if (progress.phase === 'failed') { + setIsTranscriptExpanded(true); + } }); return () => { @@ -117,15 +123,24 @@ 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})` : ''} -
  • - ))} -
+ + {isTranscriptExpanded && ( +
    + {initTranscript.map((entry, index) => ( +
  • + {entry.progress}% — {entry.message.toLowerCase().replace(/\.+$/, '')} + {entry.detail ? ` (${entry.detail})` : ''} +
  • + ))} +
+ )}
) : null; @@ -134,8 +149,10 @@ export const GitSidebar: React.FC = () => {
SOURCE CONTROL
-

Git repository ready

- {currentBranch &&

Branch: {currentBranch}

} +
+

Git repository ready

+ {currentBranch &&

Branch: {currentBranch}

} +
{transcriptSection}
@@ -146,29 +163,31 @@ export const GitSidebar: React.FC = () => {
SOURCE CONTROL
-

This project is not a git repository.

- - {initializing && ( -

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

- )} - {error &&

{error}

} - +
+

This project is not a git repository.

+ + {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 177f259..a0d50a2 100644 --- a/tests/engine/GitEngine.test.ts +++ b/tests/engine/GitEngine.test.ts @@ -11,14 +11,16 @@ const mockCommit = vi.fn(); const mockGetRemotes = vi.fn(); const mockAddRemote = vi.fn(); const mockRemote = vi.fn(); -const { mockReadFile, mockStat } = vi.hoisted(() => ({ +const { mockReadFile, mockStat, mockWriteFile } = vi.hoisted(() => ({ mockReadFile: vi.fn(), mockStat: vi.fn(), + mockWriteFile: vi.fn(), })); vi.mock('fs/promises', () => ({ readFile: mockReadFile, stat: mockStat, + writeFile: mockWriteFile, default: {}, })); @@ -47,6 +49,7 @@ describe('GitEngine', () => { vi.clearAllMocks(); mockReadFile.mockRejectedValue(new Error('ENOENT')); mockStat.mockResolvedValue({}); + mockWriteFile.mockResolvedValue(undefined); mockCheckIsRepo.mockResolvedValue(false); gitEngine = new GitEngine(); }); @@ -136,6 +139,48 @@ describe('GitEngine', () => { }); }); + describe('ensureGitignore', () => { + it('should create .gitignore with default system metadata entries when missing', async () => { + mockReadFile.mockRejectedValue(new Error('ENOENT')); + + const result = await gitEngine.ensureGitignore('/tmp/project'); + + expect(result.updated).toBe(true); + expect(result.created).toBe(true); + expect(result.addedEntries.length).toBeGreaterThan(0); + expect(mockWriteFile).toHaveBeenCalledTimes(1); + }); + + it('should append missing entries when .gitignore exists but is incomplete', async () => { + mockReadFile.mockResolvedValue('node_modules/\n.DS_Store\n'); + + const result = await gitEngine.ensureGitignore('/tmp/project'); + + expect(result.updated).toBe(true); + expect(result.created).toBe(false); + expect(result.addedEntries).toContain('Thumbs.db'); + expect(mockWriteFile).toHaveBeenCalledTimes(1); + }); + + it('should not rewrite .gitignore when all default entries already exist', async () => { + mockReadFile.mockResolvedValue([ + '.DS_Store', + 'Thumbs.db', + 'Desktop.ini', + '$RECYCLE.BIN/', + '.Spotlight-V100/', + '.Trashes/', + '._*', + '.fseventsd', + ].join('\n')); + + const result = await gitEngine.ensureGitignore('/tmp/project'); + + expect(result).toEqual({ updated: false, created: false, addedEntries: [] }); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + }); + describe('initializeRepo', () => { it('should emit detailed progress updates throughout initialization', async () => { mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true }); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index 6d209c1..67f36f2 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -145,6 +145,7 @@ const mockGitEngine = { getRepoState: vi.fn(), getStatus: vi.fn(), initializeRepo: vi.fn(), + ensureGitignore: vi.fn(), }; const mockTaskManager = { @@ -359,6 +360,25 @@ describe('IPC Handlers', () => { }); }); }); + + describe('git:ensureGitignore', () => { + it('should pass project path to GitEngine.ensureGitignore', async () => { + mockGitEngine.ensureGitignore.mockResolvedValue({ + updated: true, + created: false, + addedEntries: ['Thumbs.db'], + }); + + const result = await invokeHandler('git:ensureGitignore', '/repo'); + + expect(mockGitEngine.ensureGitignore).toHaveBeenCalledWith('/repo'); + expect(result).toEqual({ + updated: true, + created: false, + addedEntries: ['Thumbs.db'], + }); + }); + }); }); // ============ Project Handlers ============ diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index cd00ec4..b56f9d5 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -29,11 +29,20 @@ 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 }), + ensureGitignore: vi.fn().mockResolvedValue({ updated: false, created: false, addedEntries: [] }), onInitProgress: vi.fn().mockImplementation(() => () => {}), }, }; }); + it('checks gitignore defaults when sidebar loads', async () => { + render(); + + await screen.findByRole('button', { name: /initialize git/i }); + + expect((window as any).electronAPI.git.ensureGitignore).toHaveBeenCalledWith('/repo/path'); + }); + it('shows Initialize Git button when active project is not a git repository', async () => { render(); @@ -139,7 +148,7 @@ describe('GitSidebar', () => { subscription({ message: 'Staging project files...', progress: 75 }); }); - expect(screen.getByText(/75% — staging project files/i)).toBeInTheDocument(); + expect(screen.getByText(/staging project files\.\.\.\s*\(75%\)/i)).toBeInTheDocument(); await act(async () => { resolveInit?.({ success: true }); @@ -170,7 +179,16 @@ describe('GitSidebar', () => { subscription({ message: 'Initializing repository...', progress: 15 }); }); - expect(screen.getByText(/initialization transcript/i)).toBeInTheDocument(); + const transcriptToggle = screen.getByRole('button', { name: /initialization transcript/i }); + expect(transcriptToggle).toBeInTheDocument(); + expect(transcriptToggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText(/5% — checking git availability/i)).not.toBeInTheDocument(); + + await act(async () => { + fireEvent.click(transcriptToggle); + }); + + expect(transcriptToggle).toHaveAttribute('aria-expanded', 'true'); expect(screen.getByText(/5% — checking git availability/i)).toBeInTheDocument(); expect(screen.getByText(/15% — initializing repository/i)).toBeInTheDocument(); @@ -178,4 +196,19 @@ describe('GitSidebar', () => { resolveInit?.({ success: true }); }); }); + + it('auto-expands transcript when a failed progress event is received', async () => { + render(); + + const onInitProgressMock = (window as any).electronAPI.git.onInitProgress as ReturnType; + const subscription = onInitProgressMock.mock.calls[0][0] as (payload: { phase: string; message: string; progress: number }) => void; + + await act(async () => { + subscription({ phase: 'failed', message: 'Failed to configure remote repository.', progress: 100 }); + }); + + const transcriptToggle = screen.getByRole('button', { name: /initialization transcript/i }); + expect(transcriptToggle).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText(/100% — failed to configure remote repository/i)).toBeInTheDocument(); + }); });