1+ import { createHash } from 'crypto' ;
12import type { Logger } from '../utils/logger' ;
23import type { AnalyticsManager } from './analyticsManager' ;
34import { 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+
4047export 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