Skip to content

Commit fe06149

Browse files
24kchengYeclaude
andcommitted
perf: Cache git diff results with fingerprint-based invalidation
- Backend: SHA-1 fingerprint from HEAD + git status for cache keys - Frontend: Skip re-fetch when fingerprint unchanged on tab switch - Auto-invalidate on commit, revert, restore operations - 60-second max cache age as safety fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 71ad50b commit fe06149

4 files changed

Lines changed: 165 additions & 9 deletions

File tree

frontend/src/components/panels/diff/DiffPanel.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const DiffPanel: React.FC<DiffPanelProps> = ({
2121
const diffState = panel.state?.customState as DiffPanelState | undefined;
2222
const lastRefreshRef = useRef<number>(Date.now());
2323
const combinedDiffRef = useRef<CombinedDiffViewHandle>(null);
24+
const lastFingerprintRef = useRef<string | null>(null);
2425

2526
// Listen for file change events from other panels
2627
useEffect(() => {
@@ -50,14 +51,32 @@ export const DiffPanel: React.FC<DiffPanelProps> = ({
5051
};
5152
}, [panel.id, sessionId]);
5253

53-
// Mark stale when panel was inactive and becomes active (may have missed changes)
54+
// When panel becomes active after being inactive, check fingerprint before marking stale
5455
const wasActiveRef = useRef(isActive);
5556
useEffect(() => {
5657
if (isActive && !wasActiveRef.current) {
57-
setIsStale(true);
58+
// Check if git state actually changed before triggering a refresh
59+
(async () => {
60+
try {
61+
const result = await window.electron?.invoke('sessions:get-diff-fingerprint', sessionId);
62+
if (result?.success && result.data?.fingerprint) {
63+
const newFingerprint = result.data.fingerprint;
64+
if (lastFingerprintRef.current === null || lastFingerprintRef.current !== newFingerprint) {
65+
lastFingerprintRef.current = newFingerprint;
66+
setIsStale(true);
67+
}
68+
// If fingerprint matches, skip refresh -- data hasn't changed
69+
} else {
70+
// Fallback: if fingerprint check fails, mark stale to be safe
71+
setIsStale(true);
72+
}
73+
} catch {
74+
setIsStale(true);
75+
}
76+
})();
5877
}
5978
wasActiveRef.current = isActive;
60-
}, [isActive]);
79+
}, [isActive, sessionId]);
6180

6281
// Auto-refresh when becoming active and stale
6382
useEffect(() => {
@@ -68,6 +87,13 @@ export const DiffPanel: React.FC<DiffPanelProps> = ({
6887
const timer = setTimeout(() => {
6988
lastRefreshRef.current = Date.now();
7089

90+
// Update fingerprint after refresh so next activation can skip if unchanged
91+
window.electron?.invoke('sessions:get-diff-fingerprint', sessionId).then((result: { success: boolean; data?: { fingerprint: string } } | undefined) => {
92+
if (result?.success && result.data?.fingerprint) {
93+
lastFingerprintRef.current = result.data.fingerprint;
94+
}
95+
}).catch(() => { /* best effort */ });
96+
7197
window.electron?.invoke('panels:update', panel.id, {
7298
state: {
7399
...panel.state,

main/src/ipc/file.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ interface FileSearchRequest {
6565
}
6666

6767
export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): void {
68-
const { sessionManager, databaseService, gitStatusManager, configManager } = services;
68+
const { sessionManager, databaseService, gitStatusManager, gitDiffManager, configManager } = services;
6969

7070
// Read file contents from a session's worktree
7171
ipcMain.handle('file:read', async (_, request: FileReadRequest) => {
@@ -252,7 +252,8 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v
252252
env: { ...process.env, ...GIT_ATTRIBUTION_ENV }
253253
}, commandRunner.wslContext);
254254

255-
// Refresh git status for this session after commit
255+
// Invalidate diff cache and refresh git status after commit
256+
gitDiffManager.invalidateCache(session.worktreePath);
256257
try {
257258
await gitStatusManager.refreshSessionGitStatus(request.sessionId, false);
258259
} catch (error) {
@@ -280,7 +281,8 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v
280281
env: { ...process.env, ...GIT_ATTRIBUTION_ENV }
281282
}, commandRunner.wslContext);
282283

283-
// Refresh git status for this session after commit
284+
// Invalidate diff cache and refresh git status after commit (retry)
285+
gitDiffManager.invalidateCache(session.worktreePath);
284286
try {
285287
await gitStatusManager.refreshSessionGitStatus(request.sessionId, false);
286288
} catch (error) {
@@ -325,6 +327,9 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v
325327
const command = `git revert ${request.commitHash} --no-edit`;
326328
await commandRunner.execAsync(command, session.worktreePath);
327329

330+
// Invalidate diff cache after revert
331+
gitDiffManager.invalidateCache(session.worktreePath);
332+
328333
return { success: true };
329334
} catch (error: unknown) {
330335
throw new Error(`Git revert failed: ${error instanceof Error ? error.message : error}`);
@@ -357,6 +362,9 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v
357362
// Clean untracked files
358363
await commandRunner.execAsync('git clean -fd', session.worktreePath);
359364

365+
// Invalidate diff cache after restore
366+
gitDiffManager.invalidateCache(session.worktreePath);
367+
360368
return { success: true };
361369
} catch (error: unknown) {
362370
throw new Error(`Git restore failed: ${error instanceof Error ? error.message : error}`);

main/src/ipc/git.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,6 +747,25 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo
747747
}
748748
});
749749

750+
// Get git working directory fingerprint (cheap check for frontend caching)
751+
ipcMain.handle('sessions:get-diff-fingerprint', async (_event, sessionId: string) => {
752+
try {
753+
const session = await sessionManager.getSession(sessionId);
754+
if (!session || !session.worktreePath) {
755+
return { success: false, error: 'Session or worktree path not found' };
756+
}
757+
const ctx = sessionManager.getProjectContext(sessionId);
758+
if (!ctx) {
759+
return { success: false, error: 'Project context not found' };
760+
}
761+
const fingerprint = gitDiffManager.getWorkingDirectoryFingerprint(session.worktreePath, ctx.commandRunner);
762+
return { success: true, data: { fingerprint } };
763+
} catch (error) {
764+
const errorMessage = error instanceof Error ? error.message : 'Failed to get fingerprint';
765+
return { success: false, error: errorMessage };
766+
}
767+
});
768+
750769
// Git rebase operations
751770
ipcMain.handle('sessions:check-rebase-conflicts', async (_event, sessionId: string) => {
752771
try {

main/src/services/gitDiffManager.ts

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from 'crypto';
12
import type { Logger } from '../utils/logger';
23
import type { AnalyticsManager } from './analyticsManager';
34
import { CommandRunner } from '../utils/commandRunner';
@@ -37,12 +38,57 @@ export interface GitGraphCommit {
3738
deletions?: number;
3839
}
3940

41+
interface DiffCacheEntry {
42+
fingerprint: string;
43+
result: GitDiffResult;
44+
timestamp: number;
45+
}
46+
4047
export class GitDiffManager {
48+
// Cache keyed by worktreePath for working directory diffs
49+
private diffCache: Map<string, DiffCacheEntry> = new Map();
50+
// Cache keyed by "worktreePath:fromCommit:toCommit" for commit range diffs
51+
private commitDiffCache: Map<string, DiffCacheEntry> = new Map();
52+
private readonly CACHE_MAX_AGE_MS = 60_000; // Evict stale entries after 60s even if fingerprint matches
53+
4154
constructor(
4255
private logger?: Logger,
4356
private analyticsManager?: AnalyticsManager
4457
) {}
4558

59+
/**
60+
* Compute a fingerprint for the current working directory state.
61+
* Combines HEAD hash + porcelain status so we can detect any change.
62+
*/
63+
getWorkingDirectoryFingerprint(worktreePath: string, commandRunner: CommandRunner): string {
64+
try {
65+
const head = commandRunner.exec('git rev-parse HEAD', worktreePath).trim();
66+
const status = commandRunner.exec('git status --porcelain', worktreePath);
67+
return createHash('sha1').update(head + '\n' + status).digest('hex');
68+
} catch {
69+
// On error return empty string so cache is always missed
70+
return '';
71+
}
72+
}
73+
74+
/**
75+
* Invalidate all caches for a given worktree (call after commit, restore, etc.)
76+
*/
77+
invalidateCache(worktreePath?: string): void {
78+
if (worktreePath) {
79+
this.diffCache.delete(worktreePath);
80+
// Also clear commit diff entries for this worktree
81+
for (const key of this.commitDiffCache.keys()) {
82+
if (key.startsWith(worktreePath + ':')) {
83+
this.commitDiffCache.delete(key);
84+
}
85+
}
86+
} else {
87+
this.diffCache.clear();
88+
this.commitDiffCache.clear();
89+
}
90+
}
91+
4692
/**
4793
* Capture git diff for a worktree directory
4894
*/
@@ -51,6 +97,14 @@ export class GitDiffManager {
5197
console.log(`captureWorkingDirectoryDiff called for: ${worktreePath}`);
5298
this.logger?.verbose(`Capturing git diff in ${worktreePath}`);
5399

100+
// Check cache: compare fingerprint to detect if anything changed
101+
const fingerprint = this.getWorkingDirectoryFingerprint(worktreePath, commandRunner);
102+
const cached = this.diffCache.get(worktreePath);
103+
if (fingerprint && cached && cached.fingerprint === fingerprint && (Date.now() - cached.timestamp) < this.CACHE_MAX_AGE_MS) {
104+
console.log(`[DiffCache] HIT for working directory diff in ${worktreePath}`);
105+
return cached.result;
106+
}
107+
54108
// Get current commit hash
55109
const beforeHash = this.getCurrentCommitHash(worktreePath, commandRunner);
56110

@@ -67,13 +121,21 @@ export class GitDiffManager {
67121
this.logger?.verbose(`Captured diff: ${stats.filesChanged} files, +${stats.additions} -${stats.deletions}`);
68122
console.log(`Diff stats:`, stats);
69123

70-
return {
124+
const result: GitDiffResult = {
71125
diff,
72126
stats,
73127
changedFiles,
74128
beforeHash,
75129
afterHash: undefined // No after hash for working directory changes
76130
};
131+
132+
// Store in cache
133+
if (fingerprint) {
134+
this.diffCache.set(worktreePath, { fingerprint, result, timestamp: Date.now() });
135+
console.log(`[DiffCache] STORED working directory diff for ${worktreePath}`);
136+
}
137+
138+
return result;
77139
} catch (error) {
78140
this.logger?.error(`Failed to capture git diff in ${worktreePath}:`, error instanceof Error ? error : undefined);
79141
throw error;
@@ -88,6 +150,23 @@ export class GitDiffManager {
88150
const to = toCommit || 'HEAD';
89151
this.logger?.verbose(`Capturing git diff in ${worktreePath} from ${fromCommit} to ${to}`);
90152

153+
// For commit-to-commit diffs (not involving HEAD/working dir), result is immutable -- cache by hashes
154+
const cacheKey = `${worktreePath}:${fromCommit}:${to}`;
155+
const cached = this.commitDiffCache.get(cacheKey);
156+
if (cached && (Date.now() - cached.timestamp) < this.CACHE_MAX_AGE_MS) {
157+
// For HEAD references, verify fingerprint still matches
158+
if (to === 'HEAD') {
159+
const fingerprint = this.getWorkingDirectoryFingerprint(worktreePath, commandRunner);
160+
if (fingerprint && cached.fingerprint === fingerprint) {
161+
console.log(`[DiffCache] HIT for commit diff ${fromCommit}..${to}`);
162+
return cached.result;
163+
}
164+
} else {
165+
console.log(`[DiffCache] HIT for commit diff ${fromCommit}..${to}`);
166+
return cached.result;
167+
}
168+
}
169+
91170
// Get diff between commits
92171
const diff = this.getGitCommitDiff(worktreePath, fromCommit, to, commandRunner);
93172

@@ -97,13 +176,21 @@ export class GitDiffManager {
97176
// Get diff stats between commits
98177
const stats = this.getCommitDiffStats(worktreePath, fromCommit, to, commandRunner);
99178

100-
return {
179+
const result: GitDiffResult = {
101180
diff,
102181
stats,
103182
changedFiles,
104183
beforeHash: fromCommit,
105184
afterHash: to === 'HEAD' ? this.getCurrentCommitHash(worktreePath, commandRunner) : to
106185
};
186+
187+
// Store in cache
188+
const fingerprint = to === 'HEAD'
189+
? this.getWorkingDirectoryFingerprint(worktreePath, commandRunner)
190+
: `${fromCommit}:${to}`;
191+
this.commitDiffCache.set(cacheKey, { fingerprint, result, timestamp: Date.now() });
192+
193+
return result;
107194
} catch (error) {
108195
this.logger?.error(`Failed to capture commit diff in ${worktreePath}:`, error instanceof Error ? error : undefined);
109196
throw error;
@@ -405,6 +492,15 @@ export class GitDiffManager {
405492
}
406493

407494
async getCombinedDiff(worktreePath: string, mainBranch: string, commandRunner: CommandRunner): Promise<GitDiffResult> {
495+
// Check cache using fingerprint
496+
const cacheKey = `${worktreePath}:origin/${mainBranch}...HEAD`;
497+
const fingerprint = this.getWorkingDirectoryFingerprint(worktreePath, commandRunner);
498+
const cached = this.commitDiffCache.get(cacheKey);
499+
if (fingerprint && cached && cached.fingerprint === fingerprint && (Date.now() - cached.timestamp) < this.CACHE_MAX_AGE_MS) {
500+
console.log(`[DiffCache] HIT for combined diff in ${worktreePath}`);
501+
return cached.result;
502+
}
503+
408504
// Get diff against main branch
409505
try {
410506

@@ -419,13 +515,20 @@ export class GitDiffManager {
419515

420516
const stats = this.parseDiffStats(statsOutput);
421517

422-
return {
518+
const result: GitDiffResult = {
423519
diff,
424520
stats,
425521
changedFiles,
426522
beforeHash: `origin/${mainBranch}`,
427523
afterHash: 'HEAD'
428524
};
525+
526+
// Store in cache
527+
if (fingerprint) {
528+
this.commitDiffCache.set(cacheKey, { fingerprint, result, timestamp: Date.now() });
529+
}
530+
531+
return result;
429532
} catch (error) {
430533
this.logger?.warn(`Could not get combined diff in ${worktreePath}:`, error instanceof Error ? error : undefined);
431534
// Fallback to working directory diff

0 commit comments

Comments
 (0)