From 772c0cbb0e0a1129fc780feb035eaa76365a650d Mon Sep 17 00:00:00 2001 From: hugo Date: Mon, 16 Feb 2026 14:42:04 +0100 Subject: [PATCH] feat: coloring of version history --- src/main/engine/GitEngine.ts | 30 +++++++++- src/main/preload.ts | 2 +- src/main/shared/electronApi.ts | 3 +- .../components/GitSidebar/GitSidebar.tsx | 1 + tests/engine/GitEngine.test.ts | 57 ++++++++++++++++++- tests/renderer/components/GitSidebar.test.tsx | 7 ++- 6 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index 7dd018d..3beda5b 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -104,12 +104,14 @@ export interface GitIgnoreEnsureResult { export interface GitLfsPruneOptions { dryRun?: boolean; verifyRemote?: boolean; + recentCommitsToKeep?: number; } export interface GitLfsPruneResult { success: boolean; dryRun: boolean; verifyRemote: boolean; + recentCommitsToKeep: number; output?: string; error?: string; } @@ -823,8 +825,32 @@ export class GitEngine { const git = simpleGit(projectPath); const verifyRemote = options.verifyRemote ?? true; const dryRun = options.dryRun ?? false; + const recentCommitsToKeep = Math.max(0, options.recentCommitsToKeep ?? 2); - const args = ['lfs', 'prune']; + let recentCommitDays = 0; + if (recentCommitsToKeep > 0) { + const history = await git.log({ maxCount: recentCommitsToKeep }); + if (history.all.length > 0) { + const oldestProtected = history.all[history.all.length - 1]; + const oldestProtectedTimestamp = new Date(oldestProtected.date).getTime(); + if (!Number.isNaN(oldestProtectedTimestamp)) { + const msPerDay = 24 * 60 * 60 * 1000; + const ageInDays = Math.ceil(Math.max(0, Date.now() - oldestProtectedTimestamp) / msPerDay); + recentCommitDays = ageInDays; + } + } + } + + const args = [ + '-c', + `lfs.fetchrecentcommitsdays=${recentCommitDays}`, + '-c', + 'lfs.fetchrecentrefsdays=0', + '-c', + 'lfs.pruneoffsetdays=0', + 'lfs', + 'prune', + ]; if (verifyRemote) { args.push('--verify-remote'); } @@ -838,6 +864,7 @@ export class GitEngine { success: true, dryRun, verifyRemote, + recentCommitsToKeep, output, }; } catch (error) { @@ -846,6 +873,7 @@ export class GitEngine { success: false, dryRun, verifyRemote, + recentCommitsToKeep, error: message, }; } diff --git a/src/main/preload.ts b/src/main/preload.ts index 9560cd2..792baee 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -19,7 +19,7 @@ export const electronAPI: ElectronAPI = { push: (projectPath: string) => ipcRenderer.invoke('git:push', projectPath), commitAll: (projectPath: string, message: string) => ipcRenderer.invoke('git:commitAll', projectPath, message), ensureGitignore: (projectPath: string) => ipcRenderer.invoke('git:ensureGitignore', projectPath), - pruneLfs: (projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean }) => ipcRenderer.invoke('git:pruneLfs', projectPath, options), + pruneLfs: (projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean; recentCommitsToKeep?: number }) => ipcRenderer.invoke('git:pruneLfs', projectPath, options), 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 b0e202f..c3d0325 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -305,6 +305,7 @@ export interface GitLfsPruneResult { success: boolean; dryRun: boolean; verifyRemote: boolean; + recentCommitsToKeep: number; output?: string; error?: string; } @@ -399,7 +400,7 @@ export interface ElectronAPI { push: (projectPath: string) => Promise; commitAll: (projectPath: string, message: string) => Promise; ensureGitignore: (projectPath: string) => Promise; - pruneLfs: (projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean }) => Promise; + pruneLfs: (projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean; recentCommitsToKeep?: number }) => Promise; init: (projectPath: string, remoteUrl?: string) => Promise; onInitProgress: (callback: (progress: GitInitProgress) => void) => () => void; }; diff --git a/src/renderer/components/GitSidebar/GitSidebar.tsx b/src/renderer/components/GitSidebar/GitSidebar.tsx index 4efe17c..0d1ccd7 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.tsx +++ b/src/renderer/components/GitSidebar/GitSidebar.tsx @@ -220,6 +220,7 @@ export const GitSidebar: React.FC = () => { : await window.electronAPI.git.pruneLfs(effectiveProjectPath, { dryRun: false, verifyRemote: true, + recentCommitsToKeep: 2, }); if (!result.success) { setError(result.error || `Failed to ${action}.`); diff --git a/tests/engine/GitEngine.test.ts b/tests/engine/GitEngine.test.ts index 74e957d..d8a1b60 100644 --- a/tests/engine/GitEngine.test.ts +++ b/tests/engine/GitEngine.test.ts @@ -562,34 +562,85 @@ describe('GitEngine', () => { }); describe('pruneLfsCache', () => { - it('should run git lfs prune with verify-remote by default', async () => { + it('should run git lfs prune with verify-remote and aggressive recency defaults', async () => { + mockLog.mockResolvedValue({ + all: [ + { hash: 'aaa111', date: '2026-02-16T12:00:00.000Z', message: 'new', author_name: 'Dev' }, + { hash: 'bbb222', date: '2026-02-16T11:00:00.000Z', message: 'old', author_name: 'Dev' }, + ], + }); mockRaw.mockResolvedValue('prune complete'); const result = await gitEngine.pruneLfsCache('/tmp/project'); - expect(mockRaw).toHaveBeenCalledWith(['lfs', 'prune', '--verify-remote']); + expect(mockLog).toHaveBeenCalledWith({ maxCount: 2 }); + expect(mockRaw).toHaveBeenCalledWith([ + '-c', + 'lfs.fetchrecentcommitsdays=1', + '-c', + 'lfs.fetchrecentrefsdays=0', + '-c', + 'lfs.pruneoffsetdays=0', + 'lfs', + 'prune', + '--verify-remote', + ]); expect(result.success).toBe(true); expect(result.dryRun).toBe(false); expect(result.verifyRemote).toBe(true); + expect(result.recentCommitsToKeep).toBe(2); }); it('should run git lfs prune in dry-run mode when requested', async () => { + mockLog.mockResolvedValue({ + all: [ + { hash: 'aaa111', date: '2026-02-16T12:00:00.000Z', message: 'new', author_name: 'Dev' }, + { hash: 'bbb222', date: '2026-02-16T11:00:00.000Z', message: 'old', author_name: 'Dev' }, + ], + }); mockRaw.mockResolvedValue('would prune'); const result = await gitEngine.pruneLfsCache('/tmp/project', { dryRun: true }); - expect(mockRaw).toHaveBeenCalledWith(['lfs', 'prune', '--verify-remote', '--dry-run']); + expect(mockRaw).toHaveBeenCalledWith([ + '-c', + 'lfs.fetchrecentcommitsdays=1', + '-c', + 'lfs.fetchrecentrefsdays=0', + '-c', + 'lfs.pruneoffsetdays=0', + 'lfs', + 'prune', + '--verify-remote', + '--dry-run', + ]); expect(result.success).toBe(true); expect(result.dryRun).toBe(true); + expect(result.recentCommitsToKeep).toBe(2); + }); + + it('should allow overriding how many recent commits are protected', async () => { + mockLog.mockResolvedValue({ + all: [{ hash: 'aaa111', date: '2026-02-16T12:00:00.000Z', message: 'new', author_name: 'Dev' }], + }); + mockRaw.mockResolvedValue('prune complete'); + + const result = await gitEngine.pruneLfsCache('/tmp/project', { recentCommitsToKeep: 1 }); + + expect(mockLog).toHaveBeenCalledWith({ maxCount: 1 }); + expect(result.success).toBe(true); + expect(result.recentCommitsToKeep).toBe(1); }); it('should return error result when git lfs prune fails', async () => { + mockLog.mockResolvedValue({ all: [] }); mockRaw.mockRejectedValue(new Error('prune failed')); const result = await gitEngine.pruneLfsCache('/tmp/project'); expect(result.success).toBe(false); expect(result.error).toContain('prune failed'); + expect(result.recentCommitsToKeep).toBe(2); }); }); diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index 9dd75c1..291aa14 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -40,7 +40,7 @@ describe('GitSidebar', () => { fetch: vi.fn().mockResolvedValue({ success: true }), pull: vi.fn().mockResolvedValue({ success: true }), push: vi.fn().mockResolvedValue({ success: true }), - pruneLfs: vi.fn().mockResolvedValue({ success: true, dryRun: false, verifyRemote: true }), + pruneLfs: vi.fn().mockResolvedValue({ success: true, dryRun: false, verifyRemote: true, recentCommitsToKeep: 2 }), commitAll: vi.fn().mockResolvedValue({ success: true }), init: vi.fn().mockResolvedValue({ success: true }), ensureGitignore: vi.fn().mockResolvedValue({ updated: false, created: false, addedEntries: [] }), @@ -509,11 +509,12 @@ describe('GitSidebar', () => { expect((window as any).electronAPI.git.pruneLfs).toHaveBeenCalledWith('/repo/path', { dryRun: false, verifyRemote: true, + recentCommitsToKeep: 2, }); }); it('shows in-progress feedback while prune lfs is running', async () => { - let resolvePrune: ((value: { success: boolean; dryRun: boolean; verifyRemote: boolean }) => void) | null = null; + let resolvePrune: ((value: { success: boolean; dryRun: boolean; verifyRemote: boolean; recentCommitsToKeep: number }) => void) | null = null; (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ isRepo: true, rootPath: '/repo/path', @@ -538,7 +539,7 @@ describe('GitSidebar', () => { expect(screen.getByRole('button', { name: /pruning/i })).toBeDisabled(); await act(async () => { - resolvePrune?.({ success: true, dryRun: false, verifyRemote: true }); + resolvePrune?.({ success: true, dryRun: false, verifyRemote: true, recentCommitsToKeep: 2 }); }); });