Skip to content

Commit abfcbe0

Browse files
authored
Cache typed config with git config list --type=<X> (#2268)
In certain repos and on the Windows platform, `git-credential-manager` can take 8-15 seconds before looking up a credential or having any user-facing interaction. This is due to dozens of `git config --get` processes that take 150-250 milliseconds each. The config keys that cause this pain are `http.<url>.sslCAinfo` and `http.<url>.cookieFile`. When `credential.useHttpPath` is enabled, each key is checked as `<url>` is incrementally truncated by directory segment. It would be best if we could call a single Git process to send multiple config queries instead of running multiple processes. gitgitgadget/git#2033 suggested this direction of a single process solution, but it's very complicated! During review of that effort, it was recommended to use `git config list` instead. But then there's a different problem! In all released versions of Git, `git config list` silently ignores the `--type` argument. We need the `--type` argument to guarantee that the resulting output string matches the `bool` or `path` formats. The core Git change in gitgitgadget/git#2044 is now merged to `next` and thus is queued for Git 2.54.0. (We should wait until it merges to `master` before merging this change, just in case.) We can only check compatibility using a version check since the original command silently misbehaves. This pull request allows for caching the list of all config values that match the given types: bool, path, or none. These are loaded lazily so if a command doesn't need one of the types then the command doesn't run. We are also careful to clear this cache if GCM mutates the config. Since we ask for Git config values using `--type=bool`, `--type=path`, _and_ `--no-type`, we may launch three `git config list` commands to satisfy these needs. There is a possibility that this feature is fast-tracked into microsoft/git, in which case the version check would need augmentation. I have that available in derrickstolee#1 as an example. Disclaimer: I used Claude Code and GitHub Copilot CLI to assist with this change. I carefully reviewed the changes and made adjustments based on my own taste and testing. I did an end-to-end performance test on a local monorepo and got these results for improvements to no-op `git fetch` calls: | Command | Mean [s] | Min [s] | Max [s] | Relative | |:---|---:|---:|---:|---:| | Without Cache | 14.986 ± 0.255 | 14.558 | 15.192 | 3.29 ± 0.17 | | With Cache | 4.561 ± 0.223 | 4.390 | 4.935 | 1.00 |
2 parents 2e312eb + 2d2658e commit abfcbe0

File tree

2 files changed

+564
-1
lines changed

2 files changed

+564
-1
lines changed

src/shared/Core.Tests/GitConfigurationTests.cs

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,5 +436,225 @@ public void GitConfiguration_UnsetAll_All_ThrowsException()
436436
Assert.Throws<InvalidOperationException>(() =>
437437
config.UnsetAll(GitConfigurationLevel.All, "core.foobar", Constants.RegexPatterns.Any));
438438
}
439+
440+
[Fact]
441+
public void GitConfiguration_CacheTryGet_ReturnsValueFromCache()
442+
{
443+
string repoPath = CreateRepository(out string workDirPath);
444+
ExecGit(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess();
445+
ExecGit(repoPath, workDirPath, "config --local user.email john@example.com").AssertSuccess();
446+
447+
string gitPath = GetGitPath();
448+
var trace = new NullTrace();
449+
var trace2 = new NullTrace2();
450+
var processManager = new TestProcessManager();
451+
452+
var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath);
453+
IGitConfiguration config = git.GetConfiguration();
454+
455+
// First access loads cache
456+
bool result1 = config.TryGet("user.name", false, out string value1);
457+
Assert.True(result1);
458+
Assert.Equal("john.doe", value1);
459+
460+
// Second access should use cache
461+
bool result2 = config.TryGet("user.email", false, out string value2);
462+
Assert.True(result2);
463+
Assert.Equal("john@example.com", value2);
464+
}
465+
466+
[Fact]
467+
public void GitConfiguration_CacheGetAll_ReturnsAllValuesFromCache()
468+
{
469+
string repoPath = CreateRepository(out string workDirPath);
470+
ExecGit(repoPath, workDirPath, "config --local --add test.multi value1").AssertSuccess();
471+
ExecGit(repoPath, workDirPath, "config --local --add test.multi value2").AssertSuccess();
472+
ExecGit(repoPath, workDirPath, "config --local --add test.multi value3").AssertSuccess();
473+
474+
string gitPath = GetGitPath();
475+
var trace = new NullTrace();
476+
var trace2 = new NullTrace2();
477+
var processManager = new TestProcessManager();
478+
479+
var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath);
480+
IGitConfiguration config = git.GetConfiguration();
481+
482+
var values = new List<string>(config.GetAll("test.multi"));
483+
484+
Assert.Equal(3, values.Count);
485+
Assert.Equal("value1", values[0]);
486+
Assert.Equal("value2", values[1]);
487+
Assert.Equal("value3", values[2]);
488+
}
489+
490+
[Fact]
491+
public void GitConfiguration_CacheEnumerate_EnumeratesFromCache()
492+
{
493+
string repoPath = CreateRepository(out string workDirPath);
494+
ExecGit(repoPath, workDirPath, "config --local cache.name test-value").AssertSuccess();
495+
ExecGit(repoPath, workDirPath, "config --local cache.enabled true").AssertSuccess();
496+
497+
string gitPath = GetGitPath();
498+
var trace = new NullTrace();
499+
var trace2 = new NullTrace2();
500+
var processManager = new TestProcessManager();
501+
502+
var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath);
503+
IGitConfiguration config = git.GetConfiguration();
504+
505+
var cacheEntries = new List<(string key, string value)>();
506+
config.Enumerate(entry =>
507+
{
508+
if (entry.Key.StartsWith("cache."))
509+
{
510+
cacheEntries.Add((entry.Key, entry.Value));
511+
}
512+
return true;
513+
});
514+
515+
Assert.Equal(2, cacheEntries.Count);
516+
Assert.Contains(("cache.name", "test-value"), cacheEntries);
517+
Assert.Contains(("cache.enabled", "true"), cacheEntries);
518+
}
519+
520+
[Fact]
521+
public void GitConfiguration_CacheInvalidation_SetInvalidatesCache()
522+
{
523+
string repoPath = CreateRepository(out string workDirPath);
524+
ExecGit(repoPath, workDirPath, "config --local test.value initial").AssertSuccess();
525+
526+
string gitPath = GetGitPath();
527+
var trace = new NullTrace();
528+
var trace2 = new NullTrace2();
529+
var processManager = new TestProcessManager();
530+
531+
var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath);
532+
IGitConfiguration config = git.GetConfiguration();
533+
534+
// Load cache with initial value
535+
bool result1 = config.TryGet("test.value", false, out string value1);
536+
Assert.True(result1);
537+
Assert.Equal("initial", value1);
538+
539+
// Set new value (should invalidate cache)
540+
config.Set(GitConfigurationLevel.Local, "test.value", "updated");
541+
542+
// Next read should get updated value
543+
bool result2 = config.TryGet("test.value", false, out string value2);
544+
Assert.True(result2);
545+
Assert.Equal("updated", value2);
546+
}
547+
548+
[Fact]
549+
public void GitConfiguration_CacheInvalidation_AddInvalidatesCache()
550+
{
551+
string repoPath = CreateRepository(out string workDirPath);
552+
ExecGit(repoPath, workDirPath, "config --local test.multi first").AssertSuccess();
553+
554+
string gitPath = GetGitPath();
555+
var trace = new NullTrace();
556+
var trace2 = new NullTrace2();
557+
var processManager = new TestProcessManager();
558+
559+
var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath);
560+
IGitConfiguration config = git.GetConfiguration();
561+
562+
// Load cache
563+
var values1 = new List<string>(config.GetAll("test.multi"));
564+
Assert.Single(values1);
565+
Assert.Equal("first", values1[0]);
566+
567+
// Add new value (should invalidate cache)
568+
config.Add(GitConfigurationLevel.Local, "test.multi", "second");
569+
570+
// Next read should include new value
571+
var values2 = new List<string>(config.GetAll("test.multi"));
572+
Assert.Equal(2, values2.Count);
573+
Assert.Equal("first", values2[0]);
574+
Assert.Equal("second", values2[1]);
575+
}
576+
577+
[Fact]
578+
public void GitConfiguration_CacheInvalidation_UnsetInvalidatesCache()
579+
{
580+
string repoPath = CreateRepository(out string workDirPath);
581+
ExecGit(repoPath, workDirPath, "config --local test.value exists").AssertSuccess();
582+
583+
string gitPath = GetGitPath();
584+
var trace = new NullTrace();
585+
var trace2 = new NullTrace2();
586+
var processManager = new TestProcessManager();
587+
588+
var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath);
589+
IGitConfiguration config = git.GetConfiguration();
590+
591+
// Load cache
592+
bool result1 = config.TryGet("test.value", false, out string value1);
593+
Assert.True(result1);
594+
Assert.Equal("exists", value1);
595+
596+
// Unset value (should invalidate cache)
597+
config.Unset(GitConfigurationLevel.Local, "test.value");
598+
599+
// Next read should not find value
600+
bool result2 = config.TryGet("test.value", false, out string value2);
601+
Assert.False(result2);
602+
Assert.Null(value2);
603+
}
604+
605+
[Fact]
606+
public void GitConfiguration_CacheLevelFilter_ReturnsOnlyLocalValues()
607+
{
608+
string repoPath = CreateRepository(out string workDirPath);
609+
610+
try
611+
{
612+
ExecGit(repoPath, workDirPath, "config --global test.level global-value").AssertSuccess();
613+
ExecGit(repoPath, workDirPath, "config --local test.level local-value").AssertSuccess();
614+
615+
string gitPath = GetGitPath();
616+
var trace = new NullTrace();
617+
var trace2 = new NullTrace2();
618+
var processManager = new TestProcessManager();
619+
620+
var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath);
621+
IGitConfiguration config = git.GetConfiguration();
622+
623+
// Get local value only
624+
bool result = config.TryGet(GitConfigurationLevel.Local, GitConfigurationType.Raw,
625+
"test.level", out string value);
626+
Assert.True(result);
627+
Assert.Equal("local-value", value);
628+
}
629+
finally
630+
{
631+
// Cleanup global config
632+
ExecGit(repoPath, workDirPath, "config --global --unset test.level");
633+
}
634+
}
635+
636+
[Fact]
637+
public void GitConfiguration_TypedQuery_CanonicalizesValues()
638+
{
639+
string repoPath = CreateRepository(out string workDirPath);
640+
ExecGit(repoPath, workDirPath, "config --local test.path ~/example").AssertSuccess();
641+
642+
string gitPath = GetGitPath();
643+
var trace = new NullTrace();
644+
var trace2 = new NullTrace2();
645+
var processManager = new TestProcessManager();
646+
647+
var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath);
648+
IGitConfiguration config = git.GetConfiguration();
649+
650+
// Path type queries use a separate cache loaded with --type=path,
651+
// so Git canonicalizes the values during cache load.
652+
bool result = config.TryGet(GitConfigurationLevel.Local, GitConfigurationType.Path,
653+
"test.path", out string value);
654+
Assert.True(result);
655+
Assert.NotNull(value);
656+
// Value should be canonicalized path, not raw "~/example"
657+
Assert.NotEqual("~/example", value);
658+
}
439659
}
440660
}

0 commit comments

Comments
 (0)