Skip to content

Commit cfd3298

Browse files
derrickstoleeclaude
andcommitted
refactor: Add config caching to reduce git process calls
Context: Git configuration queries currently spawn a new git process for each TryGet(), GetAll(), or Enumerate() call. In scenarios where multiple config values are needed (e.g., credential helper initialization), this results in dozens of git process spawns, each parsing the same config files repeatedly. This impacts performance, especially on Windows where process creation is more expensive. Justification: Rather than caching individual queries, we cache the entire config output from a single 'git config list --show-origin -z' call. This approach provides several benefits: - Single process spawn loads all config values at once - Origin information allows accurate level filtering (system/global/local) - Cache invalidation on write operations keeps data consistent - Thread-safe implementation supports concurrent access We only cache Raw type queries since Bool and Path types require Git's canonicalization logic. Cache is loaded lazily on first access and invalidated on any write operation (Set, Add, Unset, etc.). Implementation: Added ConfigCacheEntry class to store origin, value, and level for each config entry. The ConfigCache class parses the NUL-delimited output from 'git config list --show-origin -z' (format: origin\0key\nvalue\0) and stores entries in a case-insensitive dictionary keyed by config name. Level detection examines the file path in the origin to determine System/Global/Local classification. Fallback to individual git config commands occurs if cache load fails or for typed (Bool/Path) queries. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 595b120 commit cfd3298

1 file changed

Lines changed: 300 additions & 1 deletion

File tree

src/shared/Core/GitConfiguration.cs

Lines changed: 300 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,24 +108,291 @@ public interface IGitConfiguration
108108
void UnsetAll(GitConfigurationLevel level, string name, string valueRegex);
109109
}
110110

111+
/// <summary>
112+
/// Represents a single configuration entry with its origin and level.
113+
/// </summary>
114+
internal class ConfigCacheEntry
115+
{
116+
public string Origin { get; set; }
117+
public string Value { get; set; }
118+
public GitConfigurationLevel Level { get; set; }
119+
120+
public ConfigCacheEntry(string origin, string value)
121+
{
122+
Origin = origin;
123+
Value = value;
124+
Level = DetermineLevel(origin);
125+
}
126+
127+
private static GitConfigurationLevel DetermineLevel(string origin)
128+
{
129+
if (string.IsNullOrEmpty(origin))
130+
return GitConfigurationLevel.Unknown;
131+
132+
// Origins look like: "file:/path/to/config", "command line:", "standard input:"
133+
if (!origin.StartsWith("file:"))
134+
return GitConfigurationLevel.Unknown;
135+
136+
string path = origin.Substring(5); // Remove "file:" prefix
137+
138+
// System config is typically in /etc/gitconfig or $(prefix)/etc/gitconfig
139+
if (path.Contains("/etc/gitconfig") || path.EndsWith("/gitconfig"))
140+
return GitConfigurationLevel.System;
141+
142+
// Global config is typically in ~/.gitconfig or ~/.config/git/config
143+
if (path.Contains("/.gitconfig") || path.Contains("/.config/git/config"))
144+
return GitConfigurationLevel.Global;
145+
146+
// Local config is typically in .git/config within a repository
147+
if (path.Contains("/.git/config"))
148+
return GitConfigurationLevel.Local;
149+
150+
return GitConfigurationLevel.Unknown;
151+
}
152+
}
153+
154+
/// <summary>
155+
/// Cache for Git configuration entries loaded from 'git config list --show-origin -z'.
156+
/// </summary>
157+
internal class ConfigCache
158+
{
159+
private Dictionary<string, List<ConfigCacheEntry>> _entries;
160+
private readonly object _lock = new object();
161+
162+
public bool IsLoaded => _entries != null;
163+
164+
public void Load(string data, ITrace trace)
165+
{
166+
lock (_lock)
167+
{
168+
var entries = new Dictionary<string, List<ConfigCacheEntry>>(GitConfigurationKeyComparer.Instance);
169+
170+
var origin = new StringBuilder();
171+
var key = new StringBuilder();
172+
var value = new StringBuilder();
173+
174+
int i = 0;
175+
while (i < data.Length)
176+
{
177+
origin.Clear();
178+
key.Clear();
179+
value.Clear();
180+
181+
// Read origin (NUL terminated)
182+
while (i < data.Length && data[i] != '\0')
183+
{
184+
origin.Append(data[i++]);
185+
}
186+
187+
if (i >= data.Length)
188+
{
189+
trace.WriteLine("Invalid Git configuration output. Expected null terminator (\\0) after origin.");
190+
break;
191+
}
192+
193+
// Skip the NUL terminator
194+
i++;
195+
196+
// Read key (newline terminated)
197+
while (i < data.Length && data[i] != '\n')
198+
{
199+
key.Append(data[i++]);
200+
}
201+
202+
if (i >= data.Length)
203+
{
204+
trace.WriteLine("Invalid Git configuration output. Expected newline terminator (\\n) after key.");
205+
break;
206+
}
207+
208+
// Skip the newline terminator
209+
i++;
210+
211+
// Read value (NUL terminated)
212+
while (i < data.Length && data[i] != '\0')
213+
{
214+
value.Append(data[i++]);
215+
}
216+
217+
if (i >= data.Length)
218+
{
219+
trace.WriteLine("Invalid Git configuration output. Expected null terminator (\\0) after value.");
220+
break;
221+
}
222+
223+
// Skip the NUL terminator
224+
i++;
225+
226+
string keyStr = key.ToString();
227+
var entry = new ConfigCacheEntry(origin.ToString(), value.ToString());
228+
229+
if (!entries.ContainsKey(keyStr))
230+
{
231+
entries[keyStr] = new List<ConfigCacheEntry>();
232+
}
233+
entries[keyStr].Add(entry);
234+
}
235+
236+
_entries = entries;
237+
}
238+
}
239+
240+
public bool TryGet(string name, GitConfigurationLevel level, out string value)
241+
{
242+
lock (_lock)
243+
{
244+
if (_entries == null)
245+
{
246+
value = null;
247+
return false;
248+
}
249+
250+
if (!_entries.TryGetValue(name, out var entryList))
251+
{
252+
value = null;
253+
return false;
254+
}
255+
256+
// Find the first entry matching the level filter
257+
foreach (var entry in entryList)
258+
{
259+
if (level == GitConfigurationLevel.All || entry.Level == level)
260+
{
261+
value = entry.Value;
262+
return true;
263+
}
264+
}
265+
266+
value = null;
267+
return false;
268+
}
269+
}
270+
271+
public IEnumerable<string> GetAll(string name, GitConfigurationLevel level)
272+
{
273+
lock (_lock)
274+
{
275+
if (_entries == null || !_entries.TryGetValue(name, out var entryList))
276+
{
277+
return Array.Empty<string>();
278+
}
279+
280+
var results = new List<string>();
281+
foreach (var entry in entryList)
282+
{
283+
if (level == GitConfigurationLevel.All || entry.Level == level)
284+
{
285+
results.Add(entry.Value);
286+
}
287+
}
288+
289+
return results;
290+
}
291+
}
292+
293+
public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCallback cb)
294+
{
295+
lock (_lock)
296+
{
297+
if (_entries == null)
298+
return;
299+
300+
foreach (var kvp in _entries)
301+
{
302+
foreach (var entry in kvp.Value)
303+
{
304+
if (level == GitConfigurationLevel.All || entry.Level == level)
305+
{
306+
var configEntry = new GitConfigurationEntry(kvp.Key, entry.Value);
307+
if (!cb(configEntry))
308+
{
309+
return;
310+
}
311+
}
312+
}
313+
}
314+
}
315+
}
316+
317+
public void Clear()
318+
{
319+
lock (_lock)
320+
{
321+
_entries = null;
322+
}
323+
}
324+
}
325+
111326
public class GitProcessConfiguration : IGitConfiguration
112327
{
113328
private static readonly GitVersion TypeConfigMinVersion = new GitVersion(2, 18, 0);
114329

115330
private readonly ITrace _trace;
116331
private readonly GitProcess _git;
332+
private readonly ConfigCache _cache;
333+
private readonly bool _useCache;
334+
335+
internal GitProcessConfiguration(ITrace trace, GitProcess git) : this(trace, git, useCache: true)
336+
{
337+
}
117338

118-
internal GitProcessConfiguration(ITrace trace, GitProcess git)
339+
internal GitProcessConfiguration(ITrace trace, GitProcess git, bool useCache)
119340
{
120341
EnsureArgument.NotNull(trace, nameof(trace));
121342
EnsureArgument.NotNull(git, nameof(git));
122343

123344
_trace = trace;
124345
_git = git;
346+
_useCache = useCache;
347+
_cache = useCache ? new ConfigCache() : null;
348+
}
349+
350+
private void EnsureCacheLoaded()
351+
{
352+
if (!_useCache || _cache.IsLoaded)
353+
return;
354+
355+
using (ChildProcess git = _git.CreateProcess("config list --show-origin -z"))
356+
{
357+
git.Start(Trace2ProcessClass.Git);
358+
// To avoid deadlocks, always read the output stream first and then wait
359+
string data = git.StandardOutput.ReadToEnd();
360+
git.WaitForExit();
361+
362+
switch (git.ExitCode)
363+
{
364+
case 0: // OK
365+
_cache.Load(data, _trace);
366+
break;
367+
default:
368+
_trace.WriteLine($"Failed to load config cache (exit={git.ExitCode})");
369+
// Don't throw - fall back to individual commands
370+
break;
371+
}
372+
}
373+
}
374+
375+
private void InvalidateCache()
376+
{
377+
if (_useCache)
378+
{
379+
_cache.Clear();
380+
}
125381
}
126382

127383
public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCallback cb)
128384
{
385+
if (_useCache)
386+
{
387+
EnsureCacheLoaded();
388+
if (_cache.IsLoaded)
389+
{
390+
_cache.Enumerate(level, cb);
391+
return;
392+
}
393+
}
394+
395+
// Fall back to original implementation
129396
string levelArg = GetLevelFilterArg(level);
130397
using (ChildProcess git = _git.CreateProcess($"config --null {levelArg} --list"))
131398
{
@@ -194,6 +461,17 @@ public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCa
194461

195462
public bool TryGet(GitConfigurationLevel level, GitConfigurationType type, string name, out string value)
196463
{
464+
// Use cache for raw types only - typed queries need Git's canonicalization
465+
if (_useCache && type == GitConfigurationType.Raw)
466+
{
467+
EnsureCacheLoaded();
468+
if (_cache.IsLoaded && _cache.TryGet(name, level, out value))
469+
{
470+
return true;
471+
}
472+
}
473+
474+
// Fall back to individual git config command for typed queries or cache miss
197475
string levelArg = GetLevelFilterArg(level);
198476
string typeArg = GetCanonicalizeTypeArg(type);
199477
using (ChildProcess git = _git.CreateProcess($"config --null {levelArg} {typeArg} {QuoteCmdArg(name)}"))
@@ -242,6 +520,7 @@ public void Set(GitConfigurationLevel level, string name, string value)
242520
switch (git.ExitCode)
243521
{
244522
case 0: // OK
523+
InvalidateCache();
245524
break;
246525
default:
247526
_trace.WriteLine($"Failed to set config entry '{name}' to value '{value}' (exit={git.ExitCode}, level={level})");
@@ -263,6 +542,7 @@ public void Add(GitConfigurationLevel level, string name, string value)
263542
switch (git.ExitCode)
264543
{
265544
case 0: // OK
545+
InvalidateCache();
266546
break;
267547
default:
268548
_trace.WriteLine($"Failed to add config entry '{name}' with value '{value}' (exit={git.ExitCode}, level={level})");
@@ -285,6 +565,7 @@ public void Unset(GitConfigurationLevel level, string name)
285565
{
286566
case 0: // OK
287567
case 5: // Trying to unset a value that does not exist
568+
InvalidateCache();
288569
break;
289570
default:
290571
_trace.WriteLine($"Failed to unset config entry '{name}' (exit={git.ExitCode}, level={level})");
@@ -295,6 +576,22 @@ public void Unset(GitConfigurationLevel level, string name)
295576

296577
public IEnumerable<string> GetAll(GitConfigurationLevel level, GitConfigurationType type, string name)
297578
{
579+
// Use cache for raw types only - typed queries need Git's canonicalization
580+
if (_useCache && type == GitConfigurationType.Raw)
581+
{
582+
EnsureCacheLoaded();
583+
if (_cache.IsLoaded)
584+
{
585+
var cachedValues = _cache.GetAll(name, level);
586+
foreach (var val in cachedValues)
587+
{
588+
yield return val;
589+
}
590+
yield break;
591+
}
592+
}
593+
594+
// Fall back to individual git config command
298595
string levelArg = GetLevelFilterArg(level);
299596
string typeArg = GetCanonicalizeTypeArg(type);
300597

@@ -392,6 +689,7 @@ public void ReplaceAll(GitConfigurationLevel level, string name, string valueReg
392689
switch (git.ExitCode)
393690
{
394691
case 0: // OK
692+
InvalidateCache();
395693
break;
396694
default:
397695
_trace.WriteLine($"Failed to replace all multivar '{name}' and value regex '{valueRegex}' with new value '{value}' (exit={git.ExitCode}, level={level})");
@@ -420,6 +718,7 @@ public void UnsetAll(GitConfigurationLevel level, string name, string valueRegex
420718
{
421719
case 0: // OK
422720
case 5: // Trying to unset a value that does not exist
721+
InvalidateCache();
423722
break;
424723
default:
425724
_trace.WriteLine($"Failed to unset all multivar '{name}' with value regex '{valueRegex}' (exit={git.ExitCode}, level={level})");

0 commit comments

Comments
 (0)