diff --git a/src/shared/Core.Tests/GitConfigurationTests.cs b/src/shared/Core.Tests/GitConfigurationTests.cs index 498651f73..5005cf431 100644 --- a/src/shared/Core.Tests/GitConfigurationTests.cs +++ b/src/shared/Core.Tests/GitConfigurationTests.cs @@ -436,5 +436,225 @@ public void GitConfiguration_UnsetAll_All_ThrowsException() Assert.Throws(() => config.UnsetAll(GitConfigurationLevel.All, "core.foobar", Constants.RegexPatterns.Any)); } + + [Fact] + public void GitConfiguration_CacheTryGet_ReturnsValueFromCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local user.email john@example.com").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // First access loads cache + bool result1 = config.TryGet("user.name", false, out string value1); + Assert.True(result1); + Assert.Equal("john.doe", value1); + + // Second access should use cache + bool result2 = config.TryGet("user.email", false, out string value2); + Assert.True(result2); + Assert.Equal("john@example.com", value2); + } + + [Fact] + public void GitConfiguration_CacheGetAll_ReturnsAllValuesFromCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local --add test.multi value1").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local --add test.multi value2").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local --add test.multi value3").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + var values = new List(config.GetAll("test.multi")); + + Assert.Equal(3, values.Count); + Assert.Equal("value1", values[0]); + Assert.Equal("value2", values[1]); + Assert.Equal("value3", values[2]); + } + + [Fact] + public void GitConfiguration_CacheEnumerate_EnumeratesFromCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local cache.name test-value").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local cache.enabled true").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + var cacheEntries = new List<(string key, string value)>(); + config.Enumerate(entry => + { + if (entry.Key.StartsWith("cache.")) + { + cacheEntries.Add((entry.Key, entry.Value)); + } + return true; + }); + + Assert.Equal(2, cacheEntries.Count); + Assert.Contains(("cache.name", "test-value"), cacheEntries); + Assert.Contains(("cache.enabled", "true"), cacheEntries); + } + + [Fact] + public void GitConfiguration_CacheInvalidation_SetInvalidatesCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local test.value initial").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Load cache with initial value + bool result1 = config.TryGet("test.value", false, out string value1); + Assert.True(result1); + Assert.Equal("initial", value1); + + // Set new value (should invalidate cache) + config.Set(GitConfigurationLevel.Local, "test.value", "updated"); + + // Next read should get updated value + bool result2 = config.TryGet("test.value", false, out string value2); + Assert.True(result2); + Assert.Equal("updated", value2); + } + + [Fact] + public void GitConfiguration_CacheInvalidation_AddInvalidatesCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local test.multi first").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Load cache + var values1 = new List(config.GetAll("test.multi")); + Assert.Single(values1); + Assert.Equal("first", values1[0]); + + // Add new value (should invalidate cache) + config.Add(GitConfigurationLevel.Local, "test.multi", "second"); + + // Next read should include new value + var values2 = new List(config.GetAll("test.multi")); + Assert.Equal(2, values2.Count); + Assert.Equal("first", values2[0]); + Assert.Equal("second", values2[1]); + } + + [Fact] + public void GitConfiguration_CacheInvalidation_UnsetInvalidatesCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local test.value exists").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Load cache + bool result1 = config.TryGet("test.value", false, out string value1); + Assert.True(result1); + Assert.Equal("exists", value1); + + // Unset value (should invalidate cache) + config.Unset(GitConfigurationLevel.Local, "test.value"); + + // Next read should not find value + bool result2 = config.TryGet("test.value", false, out string value2); + Assert.False(result2); + Assert.Null(value2); + } + + [Fact] + public void GitConfiguration_CacheLevelFilter_ReturnsOnlyLocalValues() + { + string repoPath = CreateRepository(out string workDirPath); + + try + { + ExecGit(repoPath, workDirPath, "config --global test.level global-value").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local test.level local-value").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Get local value only + bool result = config.TryGet(GitConfigurationLevel.Local, GitConfigurationType.Raw, + "test.level", out string value); + Assert.True(result); + Assert.Equal("local-value", value); + } + finally + { + // Cleanup global config + ExecGit(repoPath, workDirPath, "config --global --unset test.level"); + } + } + + [Fact] + public void GitConfiguration_TypedQuery_CanonicalizesValues() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local test.path ~/example").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Path type queries use a separate cache loaded with --type=path, + // so Git canonicalizes the values during cache load. + bool result = config.TryGet(GitConfigurationLevel.Local, GitConfigurationType.Path, + "test.path", out string value); + Assert.True(result); + Assert.NotNull(value); + // Value should be canonicalized path, not raw "~/example" + Assert.NotEqual("~/example", value); + } } } diff --git a/src/shared/Core/GitConfiguration.cs b/src/shared/Core/GitConfiguration.cs index 9603b2db5..725e30f67 100644 --- a/src/shared/Core/GitConfiguration.cs +++ b/src/shared/Core/GitConfiguration.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Text; namespace GitCredentialManager @@ -108,24 +109,342 @@ public interface IGitConfiguration void UnsetAll(GitConfigurationLevel level, string name, string valueRegex); } + /// + /// Represents a single configuration entry with its origin and level. + /// + internal class ConfigCacheEntry + { + public string Origin { get; set; } + public string Value { get; set; } + public GitConfigurationLevel Level { get; set; } + + public ConfigCacheEntry(string origin, string value) + { + Origin = origin; + Value = value; + Level = DetermineLevel(origin); + } + + private static GitConfigurationLevel DetermineLevel(string origin) + { + if (string.IsNullOrEmpty(origin)) + return GitConfigurationLevel.Unknown; + + // Origins look like: "file:/path/to/config", "command line:", "standard input:" + if (!origin.StartsWith("file:")) + return GitConfigurationLevel.Unknown; + + string path = origin.Substring(5); // Remove "file:" prefix + + // System config is typically in /etc/gitconfig or $(prefix)/etc/gitconfig + if (path.Contains("/etc/gitconfig") || path.EndsWith("/gitconfig")) + return GitConfigurationLevel.System; + + // Global config is typically in ~/.gitconfig or ~/.config/git/config + if (path.Contains("/.gitconfig") || path.Contains("/.config/git/config")) + return GitConfigurationLevel.Global; + + // Local config is typically in .git/config within a repository + if (path.Contains("/.git/config")) + return GitConfigurationLevel.Local; + + return GitConfigurationLevel.Unknown; + } + } + + /// + /// Cache for Git configuration entries loaded from 'git config list --show-origin -z'. + /// + internal class ConfigCache + { + private Dictionary> _entries; + private readonly object _lock = new object(); + + public bool IsLoaded => _entries != null; + + public void Load(string data, ITrace trace) + { + lock (_lock) + { + var entries = new Dictionary>(GitConfigurationKeyComparer.Instance); + + var origin = new StringBuilder(); + var key = new StringBuilder(); + var value = new StringBuilder(); + + int i = 0; + while (i < data.Length) + { + origin.Clear(); + key.Clear(); + value.Clear(); + + // Read origin (NUL terminated) + while (i < data.Length && data[i] != '\0') + { + origin.Append(data[i++]); + } + + if (i >= data.Length) + { + trace.WriteLine("Invalid Git configuration output. Expected null terminator (\\0) after origin."); + break; + } + + // Skip the NUL terminator + i++; + + // Read key (newline terminated) + while (i < data.Length && data[i] != '\n') + { + key.Append(data[i++]); + } + + if (i >= data.Length) + { + trace.WriteLine("Invalid Git configuration output. Expected newline terminator (\\n) after key."); + break; + } + + // Skip the newline terminator + i++; + + // Read value (NUL terminated) + while (i < data.Length && data[i] != '\0') + { + value.Append(data[i++]); + } + + if (i >= data.Length) + { + trace.WriteLine("Invalid Git configuration output. Expected null terminator (\\0) after value."); + break; + } + + // Skip the NUL terminator + i++; + + string keyStr = key.ToString(); + var entry = new ConfigCacheEntry(origin.ToString(), value.ToString()); + + if (!entries.ContainsKey(keyStr)) + { + entries[keyStr] = new List(); + } + entries[keyStr].Add(entry); + } + + _entries = entries; + } + } + + public bool TryGet(string name, GitConfigurationLevel level, out string value) + { + lock (_lock) + { + if (_entries == null) + { + value = null; + return false; + } + + if (!_entries.TryGetValue(name, out var entryList)) + { + value = null; + return false; + } + + // Find the last entry matching the level filter (respects Git's precedence) + // Git config precedence: system < global < local, so last match wins + ConfigCacheEntry lastMatch = null; + foreach (var entry in entryList) + { + if (level == GitConfigurationLevel.All || entry.Level == level) + { + lastMatch = entry; + } + } + + if (lastMatch != null) + { + value = lastMatch.Value; + return true; + } + + value = null; + return false; + } + } + + public IEnumerable GetAll(string name, GitConfigurationLevel level) + { + lock (_lock) + { + if (_entries == null || !_entries.TryGetValue(name, out var entryList)) + { + return Array.Empty(); + } + + var results = new List(); + foreach (var entry in entryList) + { + if (level == GitConfigurationLevel.All || entry.Level == level) + { + results.Add(entry.Value); + } + } + + return results; + } + } + + public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCallback cb) + { + lock (_lock) + { + if (_entries == null) + return; + + foreach (var kvp in _entries) + { + foreach (var entry in kvp.Value) + { + if (level == GitConfigurationLevel.All || entry.Level == level) + { + var configEntry = new GitConfigurationEntry(kvp.Key, entry.Value); + if (!cb(configEntry)) + { + return; + } + } + } + } + } + } + + public void Clear() + { + lock (_lock) + { + _entries = null; + } + } + } + public class GitProcessConfiguration : IGitConfiguration { private static readonly GitVersion TypeConfigMinVersion = new GitVersion(2, 18, 0); + private static readonly GitVersion ConfigListTypeMinVersion = new GitVersion(2, 54, 0); private readonly ITrace _trace; private readonly GitProcess _git; + private readonly Dictionary _cache; + private readonly bool _useCache; + + internal GitProcessConfiguration(ITrace trace, GitProcess git) : this(trace, git, useCache: true) + { + } - internal GitProcessConfiguration(ITrace trace, GitProcess git) + internal GitProcessConfiguration(ITrace trace, GitProcess git, bool useCache) { EnsureArgument.NotNull(trace, nameof(trace)); EnsureArgument.NotNull(git, nameof(git)); _trace = trace; _git = git; + + // 'git config list --type=' requires Git 2.54.0+ + if (useCache && git.Version < ConfigListTypeMinVersion) + { + trace.WriteLine($"Git version {git.Version} is below {ConfigListTypeMinVersion}; config cache disabled"); + useCache = false; + } + + _useCache = useCache; + _cache = useCache ? new Dictionary() : null; + } + + private void EnsureCacheLoaded(GitConfigurationType type) + { + ConfigCache cache; + if (!_useCache || (_cache.TryGetValue(type, out cache) && cache.IsLoaded)) + { + return; + } + + if (cache == null) + { + cache = new ConfigCache(); + _cache[type] = cache; + } + + string typeArg; + + switch (type) + { + case GitConfigurationType.Raw: + typeArg = "--no-type"; + break; + + case GitConfigurationType.Path: + typeArg = "--type=path"; + break; + + case GitConfigurationType.Bool: + typeArg = "--type=bool"; + break; + + default: + return; + } + + using (ChildProcess git = _git.CreateProcess($"config list --show-origin -z {typeArg}")) + { + git.Start(Trace2ProcessClass.Git); + // To avoid deadlocks, always read the output stream first and then wait + string data = git.StandardOutput.ReadToEnd(); + git.WaitForExit(); + + switch (git.ExitCode) + { + case 0: // OK + cache.Load(data, _trace); + break; + default: + _trace.WriteLine($"Failed to load config cache (exit={git.ExitCode})"); + // Don't throw - fall back to individual commands + break; + } + } + } + + private void InvalidateCache() + { + if (_useCache) + { + foreach (ConfigCache cache in _cache.Values) + { + cache.Clear(); + } + } } public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCallback cb) { + if (_useCache) + { + EnsureCacheLoaded(GitConfigurationType.Raw); + + ConfigCache cache = _cache[GitConfigurationType.Raw]; + + if (cache.IsLoaded) + { + cache.Enumerate(level, cb); + return; + } + } + + // Fall back to original implementation string levelArg = GetLevelFilterArg(level); using (ChildProcess git = _git.CreateProcess($"config --null {levelArg} --list")) { @@ -194,6 +513,19 @@ public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCa public bool TryGet(GitConfigurationLevel level, GitConfigurationType type, string name, out string value) { + if (_useCache) + { + EnsureCacheLoaded(type); + + ConfigCache cache = _cache[type]; + if (cache.IsLoaded) + { + // Cache is loaded, use it for the result (whether found or not) + return cache.TryGet(name, level, out value); + } + } + + // Fall back to individual git config command if cache not available string levelArg = GetLevelFilterArg(level); string typeArg = GetCanonicalizeTypeArg(type); using (ChildProcess git = _git.CreateProcess($"config --null {levelArg} {typeArg} {QuoteCmdArg(name)}")) @@ -242,6 +574,7 @@ public void Set(GitConfigurationLevel level, string name, string value) switch (git.ExitCode) { case 0: // OK + InvalidateCache(); break; default: _trace.WriteLine($"Failed to set config entry '{name}' to value '{value}' (exit={git.ExitCode}, level={level})"); @@ -263,6 +596,7 @@ public void Add(GitConfigurationLevel level, string name, string value) switch (git.ExitCode) { case 0: // OK + InvalidateCache(); break; default: _trace.WriteLine($"Failed to add config entry '{name}' with value '{value}' (exit={git.ExitCode}, level={level})"); @@ -285,6 +619,7 @@ public void Unset(GitConfigurationLevel level, string name) { case 0: // OK case 5: // Trying to unset a value that does not exist + InvalidateCache(); break; default: _trace.WriteLine($"Failed to unset config entry '{name}' (exit={git.ExitCode}, level={level})"); @@ -295,6 +630,23 @@ public void Unset(GitConfigurationLevel level, string name) public IEnumerable GetAll(GitConfigurationLevel level, GitConfigurationType type, string name) { + if (_useCache) + { + EnsureCacheLoaded(type); + + ConfigCache cache = _cache[type]; + if (cache.IsLoaded) + { + var cachedValues = cache.GetAll(name, level); + foreach (var val in cachedValues) + { + yield return val; + } + yield break; + } + } + + // Fall back to individual git config command string levelArg = GetLevelFilterArg(level); string typeArg = GetCanonicalizeTypeArg(type); @@ -392,6 +744,7 @@ public void ReplaceAll(GitConfigurationLevel level, string name, string valueReg switch (git.ExitCode) { case 0: // OK + InvalidateCache(); break; default: _trace.WriteLine($"Failed to replace all multivar '{name}' and value regex '{valueRegex}' with new value '{value}' (exit={git.ExitCode}, level={level})"); @@ -420,6 +773,7 @@ public void UnsetAll(GitConfigurationLevel level, string name, string valueRegex { case 0: // OK case 5: // Trying to unset a value that does not exist + InvalidateCache(); break; default: _trace.WriteLine($"Failed to unset all multivar '{name}' with value regex '{valueRegex}' (exit={git.ExitCode}, level={level})");