@@ -41,6 +41,17 @@ internal static class SolutionFileFinder
4141 private static readonly string [ ] HardcodedSkipDirs =
4242 [ "node_modules" , "bin" , "obj" , ".vs" , ".idea" , "packages" ] ;
4343
44+ /// <summary>
45+ /// Cached result of the last <c>git check-ignore</c> call. The cache key is the
46+ /// sorted set of paths that were checked; the value is the set of ignored paths.
47+ /// This avoids re-spawning git every 10 seconds when the input hasn't changed.
48+ /// The cache expires after <see cref="GitIgnoreCacheTtl"/> to pick up .gitignore changes.
49+ /// </summary>
50+ private static string ? _cachedGitIgnoreCacheKey ;
51+ private static HashSet < string > ? _cachedGitIgnoreResult ;
52+ private static DateTime _cachedGitIgnoreTimestamp ;
53+ private static readonly TimeSpan GitIgnoreCacheTtl = TimeSpan . FromSeconds ( 60 ) ;
54+
4455 private static bool ShouldAlwaysSkipDirectory ( string directoryName )
4556 {
4657 if ( HardcodedSkipDirs . Contains ( directoryName , StringComparer . OrdinalIgnoreCase ) )
@@ -114,6 +125,16 @@ .. results.Where(solution => !gitIgnoredSolutions.Contains(solution))
114125 return new HashSet < string > ( StringComparer . OrdinalIgnoreCase ) ;
115126 }
116127
128+ // Build a cache key from the sorted paths + git root so we don't
129+ // re-spawn git check-ignore every 10 seconds for the same input.
130+ var cacheKey = gitRoot + "|" + string . Join ( "|" , paths . OrderBy ( p => p , StringComparer . OrdinalIgnoreCase ) ) ;
131+ if ( cacheKey == _cachedGitIgnoreCacheKey
132+ && _cachedGitIgnoreResult is not null
133+ && DateTime . UtcNow - _cachedGitIgnoreTimestamp < GitIgnoreCacheTtl )
134+ {
135+ return _cachedGitIgnoreResult ;
136+ }
137+
117138 try
118139 {
119140 // Build relative paths for git check-ignore (absolute Windows paths don't work)
@@ -128,33 +149,28 @@ .. results.Where(solution => !gitIgnoredSolutions.Contains(solution))
128149 }
129150
130151 // Pass paths as arguments (--stdin has issues on Windows via ProcessStartInfo)
131- var psi = new ProcessStartInfo ( "git" )
132- {
133- WorkingDirectory = gitRoot ,
134- RedirectStandardOutput = true ,
135- RedirectStandardError = true ,
136- UseShellExecute = false ,
137- CreateNoWindow = true ,
138- } ;
139- psi . ArgumentList . Add ( "check-ignore" ) ;
140- foreach ( var relativePath in relativePaths )
141- {
142- psi . ArgumentList . Add ( relativePath ) ;
143- }
152+ var psi = CreateGitCheckIgnoreStartInfo ( gitRoot , relativePaths ) ;
144153
145154 using var process = Process . Start ( psi ) ;
146155 if ( process is null )
147156 {
148157 return null ; // git not available
149158 }
150159
151- // Kill the process after a timeout to prevent hanging. This
152- // ensures that the subsequent synchronous ReadToEnd() will
153- // return promptly because killing closes the stdout pipe.
160+ // Close stdin immediately — git check-ignore reads from args, not stdin.
161+ // This signals EOF on the redirected pipe so git doesn't wait for input.
162+ process . StandardInput . Close ( ) ;
163+
164+ // Drain stderr on a background thread to prevent pipe buffer deadlocks.
165+ // The OS pipe buffer is ~4 KB; if git writes enough to stderr while we
166+ // block on WaitForExit reading only stdout, both processes deadlock.
167+ process . ErrorDataReceived += ( _ , _ ) => { } ;
168+ process . BeginErrorReadLine ( ) ;
169+
154170 if ( ! process . WaitForExit ( 2000 ) )
155171 {
156- // git hung — kill it and fall back
157- try { process . Kill ( ) ; } catch { /* best effort */ }
172+ // git hung — kill the entire process tree and fall back
173+ try { process . Kill ( entireProcessTree : true ) ; } catch { /* best effort */ }
158174 return null ;
159175 }
160176
@@ -177,7 +193,10 @@ .. results.Where(solution => !gitIgnoredSolutions.Contains(solution))
177193 }
178194 }
179195
180- return ignored ;
196+ _cachedGitIgnoreCacheKey = cacheKey ;
197+ _cachedGitIgnoreResult = new HashSet < string > ( ignored , StringComparer . OrdinalIgnoreCase ) ;
198+ _cachedGitIgnoreTimestamp = DateTime . UtcNow ;
199+ return _cachedGitIgnoreResult ;
181200 }
182201 catch
183202 {
@@ -238,4 +257,28 @@ private static void SearchDirectory(string directory, int currentDepth, int maxD
238257 // Skip directories we can't access or that disappeared during scanning
239258 }
240259 }
260+
261+ /// <summary>
262+ /// Creates the <see cref="ProcessStartInfo"/> for <c>git check-ignore</c>.
263+ /// Exposed as <c>internal</c> so tests can verify critical properties
264+ /// (e.g. <see cref="ProcessStartInfo.RedirectStandardInput"/>).
265+ /// </summary>
266+ internal static ProcessStartInfo CreateGitCheckIgnoreStartInfo ( string gitRoot , List < string > relativePaths )
267+ {
268+ var psi = new ProcessStartInfo ( "git" )
269+ {
270+ WorkingDirectory = gitRoot ,
271+ RedirectStandardOutput = true ,
272+ RedirectStandardError = true ,
273+ RedirectStandardInput = true , // Prevent inheriting parent's stdin (e.g. libuv named pipe from MCP client)
274+ UseShellExecute = false ,
275+ CreateNoWindow = true ,
276+ } ;
277+ psi . ArgumentList . Add ( "check-ignore" ) ;
278+ foreach ( var relativePath in relativePaths )
279+ {
280+ psi . ArgumentList . Add ( relativePath ) ;
281+ }
282+ return psi ;
283+ }
241284}
0 commit comments