feat: coloring of version history

This commit is contained in:
2026-02-16 14:42:04 +01:00
parent 1cd7d4f6ef
commit 772c0cbb0e
6 changed files with 91 additions and 9 deletions

View File

@@ -104,12 +104,14 @@ export interface GitIgnoreEnsureResult {
export interface GitLfsPruneOptions { export interface GitLfsPruneOptions {
dryRun?: boolean; dryRun?: boolean;
verifyRemote?: boolean; verifyRemote?: boolean;
recentCommitsToKeep?: number;
} }
export interface GitLfsPruneResult { export interface GitLfsPruneResult {
success: boolean; success: boolean;
dryRun: boolean; dryRun: boolean;
verifyRemote: boolean; verifyRemote: boolean;
recentCommitsToKeep: number;
output?: string; output?: string;
error?: string; error?: string;
} }
@@ -823,8 +825,32 @@ export class GitEngine {
const git = simpleGit(projectPath); const git = simpleGit(projectPath);
const verifyRemote = options.verifyRemote ?? true; const verifyRemote = options.verifyRemote ?? true;
const dryRun = options.dryRun ?? false; 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) { if (verifyRemote) {
args.push('--verify-remote'); args.push('--verify-remote');
} }
@@ -838,6 +864,7 @@ export class GitEngine {
success: true, success: true,
dryRun, dryRun,
verifyRemote, verifyRemote,
recentCommitsToKeep,
output, output,
}; };
} catch (error) { } catch (error) {
@@ -846,6 +873,7 @@ export class GitEngine {
success: false, success: false,
dryRun, dryRun,
verifyRemote, verifyRemote,
recentCommitsToKeep,
error: message, error: message,
}; };
} }

View File

@@ -19,7 +19,7 @@ export const electronAPI: ElectronAPI = {
push: (projectPath: string) => ipcRenderer.invoke('git:push', projectPath), push: (projectPath: string) => ipcRenderer.invoke('git:push', projectPath),
commitAll: (projectPath: string, message: string) => ipcRenderer.invoke('git:commitAll', projectPath, message), commitAll: (projectPath: string, message: string) => ipcRenderer.invoke('git:commitAll', projectPath, message),
ensureGitignore: (projectPath: string) => ipcRenderer.invoke('git:ensureGitignore', projectPath), 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) => { init: (projectPath: string, remoteUrl?: string) => {
if (remoteUrl) { if (remoteUrl) {
return ipcRenderer.invoke('git:init', projectPath, remoteUrl); return ipcRenderer.invoke('git:init', projectPath, remoteUrl);

View File

@@ -305,6 +305,7 @@ export interface GitLfsPruneResult {
success: boolean; success: boolean;
dryRun: boolean; dryRun: boolean;
verifyRemote: boolean; verifyRemote: boolean;
recentCommitsToKeep: number;
output?: string; output?: string;
error?: string; error?: string;
} }
@@ -399,7 +400,7 @@ export interface ElectronAPI {
push: (projectPath: string) => Promise<GitActionResult>; push: (projectPath: string) => Promise<GitActionResult>;
commitAll: (projectPath: string, message: string) => Promise<GitActionResult>; commitAll: (projectPath: string, message: string) => Promise<GitActionResult>;
ensureGitignore: (projectPath: string) => Promise<GitIgnoreEnsureResult>; ensureGitignore: (projectPath: string) => Promise<GitIgnoreEnsureResult>;
pruneLfs: (projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean }) => Promise<GitLfsPruneResult>; pruneLfs: (projectPath: string, options?: { dryRun?: boolean; verifyRemote?: boolean; recentCommitsToKeep?: number }) => Promise<GitLfsPruneResult>;
init: (projectPath: string, remoteUrl?: string) => Promise<GitInitResult>; init: (projectPath: string, remoteUrl?: string) => Promise<GitInitResult>;
onInitProgress: (callback: (progress: GitInitProgress) => void) => () => void; onInitProgress: (callback: (progress: GitInitProgress) => void) => () => void;
}; };

View File

@@ -220,6 +220,7 @@ export const GitSidebar: React.FC = () => {
: await window.electronAPI.git.pruneLfs(effectiveProjectPath, { : await window.electronAPI.git.pruneLfs(effectiveProjectPath, {
dryRun: false, dryRun: false,
verifyRemote: true, verifyRemote: true,
recentCommitsToKeep: 2,
}); });
if (!result.success) { if (!result.success) {
setError(result.error || `Failed to ${action}.`); setError(result.error || `Failed to ${action}.`);

View File

@@ -562,34 +562,85 @@ describe('GitEngine', () => {
}); });
describe('pruneLfsCache', () => { 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'); mockRaw.mockResolvedValue('prune complete');
const result = await gitEngine.pruneLfsCache('/tmp/project'); 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.success).toBe(true);
expect(result.dryRun).toBe(false); expect(result.dryRun).toBe(false);
expect(result.verifyRemote).toBe(true); expect(result.verifyRemote).toBe(true);
expect(result.recentCommitsToKeep).toBe(2);
}); });
it('should run git lfs prune in dry-run mode when requested', async () => { 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'); mockRaw.mockResolvedValue('would prune');
const result = await gitEngine.pruneLfsCache('/tmp/project', { dryRun: true }); 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.success).toBe(true);
expect(result.dryRun).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 () => { it('should return error result when git lfs prune fails', async () => {
mockLog.mockResolvedValue({ all: [] });
mockRaw.mockRejectedValue(new Error('prune failed')); mockRaw.mockRejectedValue(new Error('prune failed'));
const result = await gitEngine.pruneLfsCache('/tmp/project'); const result = await gitEngine.pruneLfsCache('/tmp/project');
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('prune failed'); expect(result.error).toContain('prune failed');
expect(result.recentCommitsToKeep).toBe(2);
}); });
}); });

View File

@@ -40,7 +40,7 @@ describe('GitSidebar', () => {
fetch: vi.fn().mockResolvedValue({ success: true }), fetch: vi.fn().mockResolvedValue({ success: true }),
pull: vi.fn().mockResolvedValue({ success: true }), pull: vi.fn().mockResolvedValue({ success: true }),
push: 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 }), commitAll: vi.fn().mockResolvedValue({ success: true }),
init: vi.fn().mockResolvedValue({ success: true }), init: vi.fn().mockResolvedValue({ success: true }),
ensureGitignore: vi.fn().mockResolvedValue({ updated: false, created: false, addedEntries: [] }), 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', { expect((window as any).electronAPI.git.pruneLfs).toHaveBeenCalledWith('/repo/path', {
dryRun: false, dryRun: false,
verifyRemote: true, verifyRemote: true,
recentCommitsToKeep: 2,
}); });
}); });
it('shows in-progress feedback while prune lfs is running', async () => { 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({ (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({
isRepo: true, isRepo: true,
rootPath: '/repo/path', rootPath: '/repo/path',
@@ -538,7 +539,7 @@ describe('GitSidebar', () => {
expect(screen.getByRole('button', { name: /pruning/i })).toBeDisabled(); expect(screen.getByRole('button', { name: /pruning/i })).toBeDisabled();
await act(async () => { await act(async () => {
resolvePrune?.({ success: true, dryRun: false, verifyRemote: true }); resolvePrune?.({ success: true, dryRun: false, verifyRemote: true, recentCommitsToKeep: 2 });
}); });
}); });