@@ -2712,6 +2712,203 @@ async function handleFsRead(cmd: Record<string, unknown>, serverLink: ServerLink
27122712 }
27132713}
27142714
2715+ const GIT_STATUS_CACHE_TTL_MS = 5_000 ;
2716+ const GIT_DIFF_CACHE_TTL_MS = 5_000 ;
2717+
2718+ type GitStatusFile = { path : string ; code : string ; additions ?: number ; deletions ?: number } ;
2719+
2720+ interface RepoContext {
2721+ repoRoot : string ;
2722+ gitDir : string ;
2723+ repoSignature : string ;
2724+ }
2725+
2726+ interface GitStatusSnapshot {
2727+ repoRoot : string ;
2728+ repoSignature : string ;
2729+ files : GitStatusFile [ ] ;
2730+ }
2731+
2732+ interface GitDiffSnapshot {
2733+ repoRoot : string ;
2734+ repoSignature : string ;
2735+ fileSignature : string ;
2736+ diff : string ;
2737+ }
2738+
2739+ const gitStatusCache = new Map < string , { expiresAt : number ; value : GitStatusSnapshot } > ( ) ;
2740+ const gitStatusInflight = new Map < string , Promise < GitStatusSnapshot > > ( ) ;
2741+ const gitDiffCache = new Map < string , { expiresAt : number ; value : GitDiffSnapshot } > ( ) ;
2742+ const gitDiffInflight = new Map < string , Promise < GitDiffSnapshot > > ( ) ;
2743+
2744+ function normalizeFsPath ( value : string ) : string {
2745+ return nodePath . resolve ( value ) ;
2746+ }
2747+
2748+ function isPathInside ( root : string , candidate : string ) : boolean {
2749+ const normalizedRoot = normalizeFsPath ( root ) ;
2750+ const normalizedCandidate = normalizeFsPath ( candidate ) ;
2751+ return normalizedCandidate === normalizedRoot || normalizedCandidate . startsWith ( normalizedRoot + nodePath . sep ) ;
2752+ }
2753+
2754+ async function safeStatSignature ( targetPath : string ) : Promise < string > {
2755+ try {
2756+ const stats = await fsStat ( targetPath ) ;
2757+ return `${ stats . mtimeMs } :${ stats . size } ` ;
2758+ } catch {
2759+ return 'missing' ;
2760+ }
2761+ }
2762+
2763+ async function resolveGitDir ( dotGitPath : string , repoRoot : string ) : Promise < string | null > {
2764+ try {
2765+ const stats = await fsStat ( dotGitPath ) ;
2766+ if ( stats . isDirectory ( ) ) return dotGitPath ;
2767+ if ( ! stats . isFile ( ) ) return null ;
2768+ const raw = await fsReadFileRaw ( dotGitPath , 'utf8' ) ;
2769+ const match = raw . match ( / ^ g i t d i r : \s * ( .+ ) \s * $ / mi) ;
2770+ if ( ! match ?. [ 1 ] ) return null ;
2771+ return nodePath . resolve ( repoRoot , match [ 1 ] . trim ( ) ) ;
2772+ } catch {
2773+ return null ;
2774+ }
2775+ }
2776+
2777+ async function findRepoRoot ( startPath : string ) : Promise < { repoRoot : string ; gitDir : string } | null > {
2778+ let current = normalizeFsPath ( startPath ) ;
2779+ while ( true ) {
2780+ const dotGit = nodePath . join ( current , '.git' ) ;
2781+ const gitDir = await resolveGitDir ( dotGit , current ) ;
2782+ if ( gitDir ) return { repoRoot : current , gitDir } ;
2783+ const parent = nodePath . dirname ( current ) ;
2784+ if ( parent === current ) break ;
2785+ current = parent ;
2786+ }
2787+ return null ;
2788+ }
2789+
2790+ async function buildRepoSignature ( gitDir : string ) : Promise < string > {
2791+ const indexSig = await safeStatSignature ( nodePath . join ( gitDir , 'index' ) ) ;
2792+ const headPath = nodePath . join ( gitDir , 'HEAD' ) ;
2793+ const headSig = await safeStatSignature ( headPath ) ;
2794+ let refSig = 'none' ;
2795+ try {
2796+ const headRaw = await fsReadFileRaw ( headPath , 'utf8' ) ;
2797+ const match = headRaw . match ( / ^ r e f : \s * ( .+ ) \s * $ / m) ;
2798+ if ( match ?. [ 1 ] ) {
2799+ refSig = await safeStatSignature ( nodePath . join ( gitDir , match [ 1 ] . trim ( ) ) ) ;
2800+ }
2801+ } catch {
2802+ refSig = 'missing' ;
2803+ }
2804+ return `${ indexSig } |${ headSig } |${ refSig } ` ;
2805+ }
2806+
2807+ async function resolveRepoContext ( startPath : string ) : Promise < RepoContext | null > {
2808+ const repo = await findRepoRoot ( startPath ) ;
2809+ if ( ! repo ) return null ;
2810+ return {
2811+ repoRoot : repo . repoRoot ,
2812+ gitDir : repo . gitDir ,
2813+ repoSignature : await buildRepoSignature ( repo . gitDir ) ,
2814+ } ;
2815+ }
2816+
2817+ async function loadRepoGitStatusSnapshot ( repoRoot : string , repoSignature : string ) : Promise < GitStatusSnapshot > {
2818+ const { stdout } = await execAsync ( 'git status --porcelain -u' , { cwd : repoRoot , timeout : 5000 } ) ;
2819+ const files : GitStatusFile [ ] = [ ] ;
2820+ for ( const line of stdout . split ( '\n' ) ) {
2821+ if ( ! line . trim ( ) ) continue ;
2822+ const code = line . slice ( 0 , 2 ) . trim ( ) ;
2823+ const filePath = line . slice ( 3 ) . trim ( ) . replace ( / ^ " ( .* ) " $ / , '$1' ) ;
2824+ files . push ( { path : nodePath . join ( repoRoot , filePath ) , code } ) ;
2825+ }
2826+ return { repoRoot, repoSignature, files } ;
2827+ }
2828+
2829+ async function getRepoGitStatusSnapshot ( startPath : string ) : Promise < GitStatusSnapshot | null > {
2830+ const context = await resolveRepoContext ( startPath ) ;
2831+ if ( ! context ) return null ;
2832+ const cached = gitStatusCache . get ( context . repoRoot ) ;
2833+ if ( cached && cached . expiresAt > Date . now ( ) && cached . value . repoSignature === context . repoSignature ) {
2834+ return cached . value ;
2835+ }
2836+ const inflight = gitStatusInflight . get ( context . repoRoot ) ;
2837+ if ( inflight ) return await inflight ;
2838+ const promise = loadRepoGitStatusSnapshot ( context . repoRoot , context . repoSignature )
2839+ . then ( ( value ) => {
2840+ gitStatusCache . set ( context . repoRoot , { value, expiresAt : Date . now ( ) + GIT_STATUS_CACHE_TTL_MS } ) ;
2841+ return value ;
2842+ } )
2843+ . finally ( ( ) => {
2844+ gitStatusInflight . delete ( context . repoRoot ) ;
2845+ } ) ;
2846+ gitStatusInflight . set ( context . repoRoot , promise ) ;
2847+ return await promise ;
2848+ }
2849+
2850+ async function loadFileGitDiffSnapshot ( realPath : string , repoRoot : string , repoSignature : string , fileSignature : string ) : Promise < GitDiffSnapshot > {
2851+ let diff = '' ;
2852+ try {
2853+ const { stdout } = await execAsync ( `git diff HEAD -- ${ JSON . stringify ( realPath ) } ` , { cwd : repoRoot , timeout : 5000 } ) ;
2854+ diff = stdout ;
2855+ } catch { /* ignore */ }
2856+ if ( ! diff ) {
2857+ try {
2858+ const { stdout } = await execAsync ( `git diff -- ${ JSON . stringify ( realPath ) } ` , { cwd : repoRoot , timeout : 5000 } ) ;
2859+ diff = stdout ;
2860+ } catch { /* ignore */ }
2861+ }
2862+ return { repoRoot, repoSignature, fileSignature, diff } ;
2863+ }
2864+
2865+ async function getFileGitDiffSnapshot ( realPath : string ) : Promise < GitDiffSnapshot | null > {
2866+ const context = await resolveRepoContext ( nodePath . dirname ( realPath ) ) ;
2867+ if ( ! context ) return null ;
2868+ const fileSignature = await safeStatSignature ( realPath ) ;
2869+ const cached = gitDiffCache . get ( realPath ) ;
2870+ if (
2871+ cached
2872+ && cached . expiresAt > Date . now ( )
2873+ && cached . value . repoSignature === context . repoSignature
2874+ && cached . value . fileSignature === fileSignature
2875+ ) {
2876+ return cached . value ;
2877+ }
2878+ const inflight = gitDiffInflight . get ( realPath ) ;
2879+ if ( inflight ) return await inflight ;
2880+ const promise = loadFileGitDiffSnapshot ( realPath , context . repoRoot , context . repoSignature , fileSignature )
2881+ . then ( ( value ) => {
2882+ gitDiffCache . set ( realPath , { value, expiresAt : Date . now ( ) + GIT_DIFF_CACHE_TTL_MS } ) ;
2883+ return value ;
2884+ } )
2885+ . finally ( ( ) => {
2886+ gitDiffInflight . delete ( realPath ) ;
2887+ } ) ;
2888+ gitDiffInflight . set ( realPath , promise ) ;
2889+ return await promise ;
2890+ }
2891+
2892+ function filterRepoFilesForPath ( files : GitStatusFile [ ] , requestedPath : string ) : GitStatusFile [ ] {
2893+ return files . filter ( ( file ) => isPathInside ( requestedPath , file . path ) ) ;
2894+ }
2895+
2896+ function invalidateGitCachesForPath ( targetPath : string ) : void {
2897+ const normalized = normalizeFsPath ( targetPath ) ;
2898+ gitDiffCache . delete ( normalized ) ;
2899+ gitDiffInflight . delete ( normalized ) ;
2900+ for ( const key of gitStatusCache . keys ( ) ) {
2901+ if ( isPathInside ( key , normalized ) ) gitStatusCache . delete ( key ) ;
2902+ }
2903+ }
2904+
2905+ export function __resetFsGitCachesForTests ( ) : void {
2906+ gitStatusCache . clear ( ) ;
2907+ gitStatusInflight . clear ( ) ;
2908+ gitDiffCache . clear ( ) ;
2909+ gitDiffInflight . clear ( ) ;
2910+ }
2911+
27152912/** fs.git_status — return git modified file list for a directory */
27162913async function handleFsGitStatus ( cmd : Record < string , unknown > , serverLink : ServerLink ) : Promise < void > {
27172914 const rawPath = cmd . path as string | undefined ;
@@ -2728,32 +2925,8 @@ async function handleFsGitStatus(cmd: Record<string, unknown>, serverLink: Serve
27282925 try { serverLink . send ( { type : 'fs.git_status_response' , requestId, path : rawPath , status : 'error' , error : 'forbidden_path' } ) ; } catch { /* ignore */ }
27292926 return ;
27302927 }
2731-
2732- const { stdout } = await execAsync ( 'git status --porcelain -u' , { cwd : real , timeout : 5000 } ) ;
2733- const files : Array < { path : string ; code : string ; additions ?: number ; deletions ?: number } > = [ ] ;
2734- for ( const line of stdout . split ( '\n' ) ) {
2735- if ( ! line . trim ( ) ) continue ;
2736- const code = line . slice ( 0 , 2 ) . trim ( ) ;
2737- const filePath = line . slice ( 3 ) . trim ( ) . replace ( / ^ " ( .* ) " $ / , '$1' ) ; // unquote if needed
2738- files . push ( { path : nodePath . join ( real , filePath ) , code } ) ;
2739- }
2740- // Enrich with +/- line stats from git diff --numstat (best-effort)
2741- try {
2742- const { stdout : numstat } = await execAsync ( 'git diff --numstat HEAD 2>/dev/null || git diff --numstat' , { cwd : real , timeout : 5000 } ) ;
2743- const statsMap = new Map < string , { add : number ; del : number } > ( ) ;
2744- for ( const line of numstat . split ( '\n' ) ) {
2745- const m = line . match ( / ^ ( \d + | - ) \t ( \d + | - ) \t ( .+ ) $ / ) ;
2746- if ( m ) {
2747- const add = m [ 1 ] === '-' ? 0 : parseInt ( m [ 1 ] , 10 ) ;
2748- const del = m [ 2 ] === '-' ? 0 : parseInt ( m [ 2 ] , 10 ) ;
2749- statsMap . set ( nodePath . join ( real , m [ 3 ] . trim ( ) ) , { add, del } ) ;
2750- }
2751- }
2752- for ( const f of files ) {
2753- const s = statsMap . get ( f . path ) ;
2754- if ( s ) { f . additions = s . add ; f . deletions = s . del ; }
2755- }
2756- } catch { /* ignore — stats are best-effort */ }
2928+ const snapshot = await getRepoGitStatusSnapshot ( real ) ;
2929+ const files = snapshot ? filterRepoFilesForPath ( snapshot . files , real ) : [ ] ;
27572930 try { serverLink . send ( { type : 'fs.git_status_response' , requestId, path : rawPath , resolvedPath : real , status : 'ok' , files } ) ; } catch { /* ignore */ }
27582931 } catch ( err ) {
27592932 const msg = err instanceof Error ? err . message : String ( err ) ;
@@ -2779,20 +2952,8 @@ async function handleFsGitDiff(cmd: Record<string, unknown>, serverLink: ServerL
27792952 try { serverLink . send ( { type : 'fs.git_diff_response' , requestId, path : rawPath , status : 'error' , error : 'forbidden_path' } ) ; } catch { /* ignore */ }
27802953 return ;
27812954 }
2782-
2783- const dir = nodePath . dirname ( real ) ;
2784- // Try staged+unstaged diff vs HEAD; fall back to index diff; then untracked diff
2785- let diff = '' ;
2786- try {
2787- const { stdout } = await execAsync ( `git diff HEAD -- ${ JSON . stringify ( real ) } ` , { cwd : dir , timeout : 5000 } ) ;
2788- diff = stdout ;
2789- } catch { /* ignore */ }
2790- if ( ! diff ) {
2791- try {
2792- const { stdout } = await execAsync ( `git diff -- ${ JSON . stringify ( real ) } ` , { cwd : dir , timeout : 5000 } ) ;
2793- diff = stdout ;
2794- } catch { /* ignore */ }
2795- }
2955+ const snapshot = await getFileGitDiffSnapshot ( real ) ;
2956+ const diff = snapshot ?. diff ?? '' ;
27962957 // Untracked files: no diff (nothing meaningful to compare against)
27972958 try { serverLink . send ( { type : 'fs.git_diff_response' , requestId, path : rawPath , resolvedPath : real , status : 'ok' , diff } ) ; } catch { /* ignore */ }
27982959 } catch ( err ) {
@@ -2895,6 +3056,7 @@ async function handleFsWrite(cmd: Record<string, unknown>, serverLink: ServerLin
28953056 // Write the file
28963057 await fsWriteFile ( real , content , 'utf-8' ) ;
28973058 const newStats = await fsStat ( real ) ;
3059+ invalidateGitCachesForPath ( real ) ;
28983060 try { serverLink . send ( { type : 'fs.write_response' , requestId, path : rawPath , resolvedPath : real , status : 'ok' , mtime : newStats . mtimeMs } ) ; } catch { /* ignore */ }
28993061 } catch ( err ) {
29003062 try { serverLink . send ( { type : 'fs.write_response' , requestId, path : rawPath , status : 'error' , error : err instanceof Error ? err . message : String ( err ) } ) ; } catch { /* ignore */ }
@@ -2913,6 +3075,7 @@ async function handleFsWrite(cmd: Record<string, unknown>, serverLink: ServerLin
29133075 await fsWriteFile ( resolved , content , 'utf-8' ) ;
29143076 const newStats = await fsStat ( resolved ) ;
29153077 const real = await fsRealpath ( resolved ) ;
3078+ invalidateGitCachesForPath ( real ) ;
29163079 try { serverLink . send ( { type : 'fs.write_response' , requestId, path : rawPath , resolvedPath : real , status : 'ok' , mtime : newStats . mtimeMs } ) ; } catch { /* ignore */ }
29173080 } catch ( err ) {
29183081 const msg = err instanceof Error ? err . message : String ( err ) ;
0 commit comments