diff --git a/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs b/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs
index 0118e9d85..42f5cedc7 100644
--- a/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs
+++ b/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs
@@ -2,6 +2,7 @@
using System.Net.Http;
using System.Security.AccessControl;
using System.Text;
+using System.Threading.Tasks;
using GitCredentialManager.Diagnostics;
using GitCredentialManager.Tests.Objects;
using Xunit;
@@ -11,7 +12,7 @@ namespace Core.Tests.Commands;
public class DiagnoseCommandTests
{
[Fact]
- public void NetworkingDiagnostic_SendHttpRequest_Primary_OK()
+ public async Task NetworkingDiagnostic_SendHttpRequest_Primary_OK()
{
var primaryUriString = "http://example.com";
var sb = new StringBuilder();
@@ -24,14 +25,14 @@ public void NetworkingDiagnostic_SendHttpRequest_Primary_OK()
httpHandler.Setup(HttpMethod.Head, primaryUri, httpResponse);
- networkingDiagnostic.SendHttpRequest(sb, new HttpClient(httpHandler));
+ await networkingDiagnostic.SendHttpRequestAsync(sb, new HttpClient(httpHandler));
httpHandler.AssertRequest(HttpMethod.Head, primaryUri, expectedNumberOfCalls: 1);
Assert.Contains(expected, sb.ToString());
}
[Fact]
- public void NetworkingDiagnostic_SendHttpRequest_Backup_OK()
+ public async Task NetworkingDiagnostic_SendHttpRequest_Backup_OK()
{
var primaryUriString = "http://example.com";
var backupUriString = "http://httpforever.com";
@@ -48,7 +49,7 @@ public void NetworkingDiagnostic_SendHttpRequest_Backup_OK()
httpHandler.Setup(HttpMethod.Head, primaryUri, httpResponse);
httpHandler.Setup(HttpMethod.Head, backupUri, httpResponse);
- networkingDiagnostic.SendHttpRequest(sb, new HttpClient(httpHandler));
+ await networkingDiagnostic.SendHttpRequestAsync(sb, new HttpClient(httpHandler));
httpHandler.AssertRequest(HttpMethod.Head, primaryUri, expectedNumberOfCalls: 1);
httpHandler.AssertRequest(HttpMethod.Head, backupUri, expectedNumberOfCalls: 1);
@@ -56,7 +57,7 @@ public void NetworkingDiagnostic_SendHttpRequest_Backup_OK()
}
[Fact]
- public void NetworkingDiagnostic_SendHttpRequest_No_Network()
+ public async Task NetworkingDiagnostic_SendHttpRequest_No_Network()
{
var primaryUriString = "http://example.com";
var backupUriString = "http://httpforever.com";
@@ -73,7 +74,7 @@ public void NetworkingDiagnostic_SendHttpRequest_No_Network()
httpHandler.Setup(HttpMethod.Head, primaryUri, httpResponse);
httpHandler.Setup(HttpMethod.Head, backupUri, httpResponse);
- networkingDiagnostic.SendHttpRequest(sb, new HttpClient(httpHandler));
+ await networkingDiagnostic.SendHttpRequestAsync(sb, new HttpClient(httpHandler));
httpHandler.AssertRequest(HttpMethod.Head, primaryUri, expectedNumberOfCalls: 1);
httpHandler.AssertRequest(HttpMethod.Head, backupUri, expectedNumberOfCalls: 1);
diff --git a/src/shared/Core.Tests/Core.Tests.csproj b/src/shared/Core.Tests/Core.Tests.csproj
index e3ae7e6b0..64a1e2437 100644
--- a/src/shared/Core.Tests/Core.Tests.csproj
+++ b/src/shared/Core.Tests/Core.Tests.csproj
@@ -1,7 +1,7 @@
- net8.0
+ net10.0
false
true
latest
@@ -9,13 +9,13 @@
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
-
-
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/src/shared/Core.Tests/EnvironmentTests.cs b/src/shared/Core.Tests/EnvironmentTests.cs
index d9b7cb67c..40c685c2a 100644
--- a/src/shared/Core.Tests/EnvironmentTests.cs
+++ b/src/shared/Core.Tests/EnvironmentTests.cs
@@ -94,6 +94,7 @@ public void PosixEnvironment_TryLocateExecutable_Exists_ReturnTrueAndPath()
[expectedPath] = Array.Empty(),
}
};
+ fs.SetExecutable(expectedPath);
var envars = new Dictionary {["PATH"] = PosixPathVar};
var env = new PosixEnvironment(fs, envars);
@@ -116,6 +117,32 @@ public void PosixEnvironment_TryLocateExecutable_ExistsMultiple_ReturnTrueAndFir
["/bin/foo"] = Array.Empty(),
}
};
+ fs.SetExecutable(expectedPath);
+ fs.SetExecutable("/usr/local/bin/foo");
+ fs.SetExecutable("/bin/foo");
+ var envars = new Dictionary {["PATH"] = PosixPathVar};
+ var env = new PosixEnvironment(fs, envars);
+
+ bool actualResult = env.TryLocateExecutable(PosixExecName, out string actualPath);
+
+ Assert.True(actualResult);
+ Assert.Equal(expectedPath, actualPath);
+ }
+
+ [PosixFact]
+ public void PosixEnvironment_TryLocateExecutable_NotExecutable_SkipsToNextMatch()
+ {
+ string nonExecPath = "/home/john.doe/bin/foo";
+ string expectedPath = "/usr/local/bin/foo";
+ var fs = new TestFileSystem
+ {
+ Files = new Dictionary
+ {
+ [nonExecPath] = Array.Empty(),
+ [expectedPath] = Array.Empty(),
+ }
+ };
+ fs.SetExecutable(expectedPath);
var envars = new Dictionary {["PATH"] = PosixPathVar};
var env = new PosixEnvironment(fs, envars);
@@ -142,6 +169,8 @@ public void MacOSEnvironment_TryLocateExecutable_Paths_Are_Ignored()
[expectedPath] = Array.Empty(),
}
};
+ fs.SetExecutable(pathsToIgnore.FirstOrDefault());
+ fs.SetExecutable(expectedPath);
var envars = new Dictionary {["PATH"] = PosixPathVar};
var env = new PosixEnvironment(fs, envars);
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.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs
index 7ff80f03d..eb87e8c57 100644
--- a/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs
+++ b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs
@@ -86,6 +86,102 @@ public void GnuPassCredentialStore_Remove_NotFound_ReturnsFalse()
Assert.False(result);
}
+ [PosixFact]
+ public void GnuPassCredentialStore_ReadWriteDelete_GpgIdInSubdirectory()
+ {
+ var fs = new TestFileSystem();
+ var gpg = new TestGpg(fs);
+
+ string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ string storePath = Path.Combine(homePath, ".password-store");
+ const string userId = "gcm-test@example.com";
+
+ // Place .gpg-id only in the namespace subdirectory (not the store root),
+ // simulating a pass store where the root has no .gpg-id but submodules do.
+ string subDirPath = Path.Combine(storePath, TestNamespace);
+ string gpgIdPath = Path.Combine(subDirPath, ".gpg-id");
+
+ gpg.GenerateKeys(userId);
+
+ fs.Directories.Add(storePath);
+ fs.Directories.Add(subDirPath);
+ fs.Files[gpgIdPath] = Encoding.UTF8.GetBytes(userId);
+
+ var collection = new GpgPassCredentialStore(fs, gpg, storePath, TestNamespace);
+
+ string service = $"https://example.com/{Guid.NewGuid():N}";
+ const string userName = "john.doe";
+ string password = Guid.NewGuid().ToString("N");
+
+ try
+ {
+ // Write
+ collection.AddOrUpdate(service, userName, password);
+
+ // Read
+ ICredential outCredential = collection.Get(service, userName);
+
+ Assert.NotNull(outCredential);
+ Assert.Equal(userName, outCredential.Account);
+ Assert.Equal(password, outCredential.Password);
+ }
+ finally
+ {
+ // Ensure we clean up after ourselves even in case of 'get' failures
+ collection.Remove(service, userName);
+ }
+ }
+
+ [PosixFact]
+ public void GnuPassCredentialStore_WriteCredential_MultipleGpgIds_UsesNearestGpgId()
+ {
+ // Verify that when two subdirectories each have their own .gpg-id, encrypting a credential
+ // under one subdirectory uses that subdirectory's GPG identity, not the other one.
+ var fs = new TestFileSystem();
+ var gpg = new TestGpg(fs);
+
+ string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ string storePath = Path.Combine(homePath, ".password-store");
+
+ const string personalUserId = "personal@example.com";
+ const string workUserId = "work@example.com";
+
+ // Only register the personal key; if the wrong (work) key is picked, EncryptFile will throw.
+ gpg.GenerateKeys(personalUserId);
+
+ string personalSubDir = Path.Combine(storePath, "personal");
+ string workSubDir = Path.Combine(storePath, "work");
+
+ fs.Directories.Add(storePath);
+ fs.Directories.Add(personalSubDir);
+ fs.Directories.Add(workSubDir);
+ fs.Files[Path.Combine(personalSubDir, ".gpg-id")] = Encoding.UTF8.GetBytes(personalUserId);
+ fs.Files[Path.Combine(workSubDir, ".gpg-id")] = Encoding.UTF8.GetBytes(workUserId);
+
+ // Use "personal" namespace so credentials are stored under storePath/personal/...
+ var collection = new GpgPassCredentialStore(fs, gpg, storePath, "personal");
+
+ string service = $"https://example.com/{Guid.NewGuid():N}";
+ const string userName = "john.doe";
+ string password = Guid.NewGuid().ToString("N");
+
+ try
+ {
+ // Write - should pick personal/.gpg-id (personalUserId), not work/.gpg-id (workUserId)
+ collection.AddOrUpdate(service, userName, password);
+
+ ICredential outCredential = collection.Get(service, userName);
+
+ Assert.NotNull(outCredential);
+ Assert.Equal(userName, outCredential.Account);
+ Assert.Equal(password, outCredential.Password);
+ }
+ finally
+ {
+ collection.Remove(service, userName);
+ }
+ }
+
private static string InitializePasswordStore(TestFileSystem fs, TestGpg gpg)
{
string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
diff --git a/src/shared/Core.Tests/StreamExtensionsTests.cs b/src/shared/Core.Tests/StreamExtensionsTests.cs
index 09153ad26..b72874ba9 100644
--- a/src/shared/Core.Tests/StreamExtensionsTests.cs
+++ b/src/shared/Core.Tests/StreamExtensionsTests.cs
@@ -381,12 +381,13 @@ public void StreamExtensions_WriteDictionary_MultiEntriesWithEmpty_WritesKVPList
{
["a"] = new[] {"1", "2", "", "3", "4"},
["b"] = new[] {"5"},
- ["c"] = new[] {"6", "7", ""}
+ ["c"] = new[] {"6", "7", ""},
+ ["d"] = new[] {"8", "", "9"}
};
string output = WriteStringStream(input, StreamExtensions.WriteDictionary, newLine: LF);
- Assert.Equal("a[]=3\na[]=4\nb=5\n\n", output);
+ Assert.Equal("a[]=3\na[]=4\nb=5\nd=9\n\n", output);
}
#endregion
diff --git a/src/shared/Core.Tests/Trace2MessageTests.cs b/src/shared/Core.Tests/Trace2MessageTests.cs
index 7e29a641f..82c1249ca 100644
--- a/src/shared/Core.Tests/Trace2MessageTests.cs
+++ b/src/shared/Core.Tests/Trace2MessageTests.cs
@@ -12,6 +12,8 @@ public class Trace2MessageTests
[InlineData(26.316083, " 26.316083 ")]
[InlineData(100.316083, "100.316083 ")]
[InlineData(1000.316083, "1000.316083")]
+ [InlineData(10000.316083, "10000.316083")]
+ [InlineData(100000.31608, "100000.316080")]
public void BuildTimeSpan_Match_Returns_Expected_String(double input, string expected)
{
var actual = Trace2Message.BuildTimeSpan(input);
diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs
index 5e2eca63c..5d65fa982 100644
--- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs
+++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs
@@ -9,6 +9,7 @@
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Extensions.Msal;
using System.Text;
+using System.Text.Json;
using System.Threading;
using GitCredentialManager.UI;
using GitCredentialManager.UI.Controls;
@@ -63,6 +64,14 @@ Task GetTokenForUserAsync(string authority, stri
/// - "resource://{guid}" - Use the user-assigned managed identity with resource ID {guid}.
///
Task GetTokenForManagedIdentityAsync(string managedIdentity, string resource);
+
+ ///
+ /// Acquire a token using workload federation.
+ ///
+ /// An object containing configuration workload federation.
+ /// Scopes to request.
+ /// Authentication result including access token.
+ Task GetTokenUsingWorkloadFederationAsync(MicrosoftWorkloadFederationOptions fedOpts, string[] scopes);
}
public class ServicePrincipalIdentity
@@ -287,7 +296,8 @@ public async Task GetTokenForServicePrincipalAsy
}
}
- public async Task GetTokenForManagedIdentityAsync(string managedIdentity, string resource)
+ public async Task GetTokenForManagedIdentityAsync(
+ string managedIdentity, string resource)
{
var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory);
@@ -306,8 +316,88 @@ public async Task GetTokenForManagedIdentityAsyn
{
Context.Trace.WriteLine(mid == ManagedIdentityId.SystemAssigned
? "Failed to acquire token for system managed identity."
- : $"Failed to acquire token for user managed identity '{managedIdentity:D}'.");
+ : $"Failed to acquire token for user managed identity '{managedIdentity}'.");
+ Context.Trace.WriteException(ex);
+ throw;
+ }
+ }
+
+ public async Task GetTokenUsingWorkloadFederationAsync(MicrosoftWorkloadFederationOptions fedOpts, string[] scopes)
+ {
+ IConfidentialClientApplication app = await CreateConfidentialClientApplicationAsync(fedOpts);
+
+ AuthenticationResult result = await app.AcquireTokenForClient(scopes)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ return new MsalResult(result);
+ }
+
+ private async Task GetClientAssertion(MicrosoftWorkloadFederationOptions fedOpts, AssertionRequestOptions _)
+ {
+ switch (fedOpts.Scenario)
+ {
+ case MicrosoftWorkloadFederationScenario.Generic:
+ Context.Trace.WriteLine("Getting client assertion for generic workload federation scenario...");
+ if (string.IsNullOrWhiteSpace(fedOpts.GenericClientAssertion))
+ throw new InvalidOperationException(
+ "Client assertion must be provided for generic workload federation scenario.");
+ return fedOpts.GenericClientAssertion;
+
+ case MicrosoftWorkloadFederationScenario.ManagedIdentity:
+ Context.Trace.WriteLine("Getting client assertion for managed identity workload federation scenario...");
+ var miResult = await GetTokenForManagedIdentityAsync(fedOpts.ManagedIdentityId, fedOpts.Audience);
+ return miResult.AccessToken;
+
+ case MicrosoftWorkloadFederationScenario.GitHubActions:
+ Context.Trace.WriteLine("Getting client assertion for GitHub Actions workload federation scenario...");
+ return await GetGitHubOidcToken(fedOpts.GitHubTokenRequestUrl, fedOpts.Audience, fedOpts.GitHubTokenRequestToken);
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(fedOpts.Scenario), fedOpts.Scenario, "Unsupported workload federation scenario.");
+ }
+ }
+
+ private async Task GetGitHubOidcToken(Uri requestUri, string audience, string requestToken)
+ {
+ using HttpClient http = Context.HttpClientFactory.CreateClient();
+
+ UriBuilder ub = new UriBuilder(requestUri);
+ if (ub.Query.Length > 0) ub.Query += "&";
+ ub.Query += $"audience={Uri.EscapeDataString(audience)}";
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, ub.Uri);
+ request.AddBearerAuthenticationHeader(requestToken);
+
+ Context.Trace.WriteLine($"Requesting GitHub OIDC token from '{request.RequestUri}'...");
+ Context.Trace.WriteLineSecrets("OIDC request token: {0}", new[] { requestToken });
+ using HttpResponseMessage response = await http.SendAsync(request);
+ if (!response.IsSuccessStatusCode)
+ {
+ string error = await response.Content.ReadAsStringAsync();
+ Context.Trace.WriteLine($"Failed to acquire GitHub OIDC token [{response.StatusCode:D} {response.StatusCode}]: {error}");
+ response.EnsureSuccessStatusCode();
+ }
+
+ string json = await response.Content.ReadAsStringAsync();
+
+ try
+ {
+ using JsonDocument jsonDoc = JsonDocument.Parse(json);
+ if (!jsonDoc.RootElement.TryGetProperty("value", out JsonElement tokenElement))
+ {
+ throw new InvalidOperationException(
+ "Invalid response from GitHub OIDC token endpoint: 'value' property not found.");
+ }
+
+ return tokenElement.GetString() ??
+ throw new InvalidOperationException(
+ "Invalid response from GitHub OIDC token endpoint: 'value' property is null.");
+ }
+ catch (Exception ex)
+ {
Context.Trace.WriteException(ex);
+ Context.Trace.WriteLine($"OIDC token response: {json}");
throw;
}
}
@@ -558,6 +648,24 @@ private async Task CreateConfidentialClientAppli
return app;
}
+ private async Task CreateConfidentialClientApplicationAsync(
+ MicrosoftWorkloadFederationOptions fedOpts)
+ {
+ var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory);
+
+ Context.Trace.WriteLine($"Creating federated confidential client application for {fedOpts.TenantId}/{fedOpts.ClientId}...");
+ var appBuilder = ConfidentialClientApplicationBuilder.Create(fedOpts.ClientId)
+ .WithTenantId(fedOpts.TenantId)
+ .WithHttpClientFactory(httpFactoryAdaptor)
+ .WithClientAssertion(reqOpts => GetClientAssertion(fedOpts, reqOpts));
+
+ IConfidentialClientApplication app = appBuilder.Build();
+
+ await RegisterTokenCacheAsync(app.AppTokenCache, CreateAppTokenCacheProps, Context.Trace2);
+
+ return app;
+ }
+
#endregion
#region Helpers
diff --git a/src/shared/Core/Authentication/MicrosoftWorkloadFederationOptions.cs b/src/shared/Core/Authentication/MicrosoftWorkloadFederationOptions.cs
new file mode 100644
index 000000000..5511c0dee
--- /dev/null
+++ b/src/shared/Core/Authentication/MicrosoftWorkloadFederationOptions.cs
@@ -0,0 +1,77 @@
+using System;
+
+namespace GitCredentialManager.Authentication;
+
+public enum MicrosoftWorkloadFederationScenario
+{
+ ///
+ /// Federate via pre-computed client assertion.
+ ///
+ Generic,
+
+ ///
+ /// Federate via an access token for an Entra ID Managed Identity.
+ ///
+ ManagedIdentity,
+
+ ///
+ /// Federate via a GitHub Actions OIDC token.
+ ///
+ GitHubActions,
+}
+
+public class MicrosoftWorkloadFederationOptions
+{
+ public const string DefaultAudience = Constants.DefaultWorkloadFederationAudience;
+
+ private string _audience = DefaultAudience;
+
+ ///
+ /// The workload federation scenario to use.
+ ///
+ public MicrosoftWorkloadFederationScenario Scenario { get; set; }
+
+ ///
+ /// Tenant ID of the identity to request an access token for.
+ ///
+ public string TenantId { get; set; }
+
+ ///
+ /// Client ID of the identity to request an access token for.
+ ///
+ public string ClientId { get; set; }
+
+ ///
+ /// The audience to use when requesting a token.
+ ///
+ /// If this is null, the default audience will be used.
+ public string Audience
+ {
+ get => _audience;
+ set => _audience = value ?? DefaultAudience;
+ }
+
+ ///
+ /// Generic assertion.
+ ///
+ /// Used with the federation scenario.
+ public string GenericClientAssertion { get; set; }
+
+ ///
+ /// The managed identity to request a federated token for, to exchange for an access token.
+ ///
+ /// Used with the federation scenario.
+ public string ManagedIdentityId { get; set; }
+
+ ///
+ /// GitHub Actions OIDC token request URI.
+ ///
+ /// Used with the federation scenario.
+ public Uri GitHubTokenRequestUrl { get; set; }
+
+ ///
+ /// GitHub Actions OIDC token request token.
+ ///
+ /// Used with the federation scenario.
+ public string GitHubTokenRequestToken { get; set; }
+}
diff --git a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs
index 34d6cfbe7..05843f9df 100644
--- a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs
+++ b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs
@@ -10,12 +10,16 @@ namespace GitCredentialManager.Authentication.OAuth
public class OAuth2WebBrowserOptions
{
internal const string DefaultSuccessHtml = @"
-
+
+
Authentication successful
Authentication successful
You can now close this page.
";
internal const string DefaultFailureHtmlFormat = @"
-
+
+
Authentication failed
Authentication failed
- Error:
- {0}
diff --git a/src/shared/Core/Authentication/OAuthAuthentication.cs b/src/shared/Core/Authentication/OAuthAuthentication.cs
index a8de4ecb6..792ba40ec 100644
--- a/src/shared/Core/Authentication/OAuthAuthentication.cs
+++ b/src/shared/Core/Authentication/OAuthAuthentication.cs
@@ -247,7 +247,7 @@ private Task ShowDeviceCodeViaUiAsync(OAuth2DeviceCodeResult dcr, CancellationTo
VerificationUrl = dcr.VerificationUri.ToString(),
};
- return AvaloniaUi.ShowViewAsync(viewModel, GetParentWindowHandle(), CancellationToken.None);
+ return AvaloniaUi.ShowViewAsync(viewModel, GetParentWindowHandle(), ct);
}
private Task ShowDeviceCodeViaHelperAsync(
diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs
index e6625a6ea..6fecc2b38 100644
--- a/src/shared/Core/Constants.cs
+++ b/src/shared/Core/Constants.cs
@@ -31,6 +31,8 @@ public static class Constants
///
public static readonly Guid MsaTransferTenantId = new("f8cdef31-a31e-4b4a-93e4-5f571e91255a");
+ public const string DefaultWorkloadFederationAudience = "api://AzureADTokenExchange";
+
public static class CredentialProtocol
{
public const string NtlmKey = "ntlm";
@@ -130,6 +132,9 @@ public static class EnvironmentVariables
public const string GcmDevUseLegacyUiHelpers = "GCM_DEV_USELEGACYUIHELPERS";
public const string GcmGuiSoftwareRendering = "GCM_GUI_SOFTWARE_RENDERING";
public const string GcmAllowUnsafeRemotes = "GCM_ALLOW_UNSAFE_REMOTES";
+
+ public const string GitHubActionsTokenRequestUrl = "ACTIONS_ID_TOKEN_REQUEST_URL";
+ public const string GitHubActionsTokenRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
}
public static class Http
diff --git a/src/shared/Core/Core.csproj b/src/shared/Core/Core.csproj
index f2804177b..d316df992 100644
--- a/src/shared/Core/Core.csproj
+++ b/src/shared/Core/Core.csproj
@@ -1,8 +1,8 @@
- net8.0
- net8.0;net472
+ net10.0
+ net10.0;net472
gcmcore
GitCredentialManager
false
@@ -13,25 +13,26 @@
-
-
+
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
-
+
+
diff --git a/src/shared/Core/CredentialStore.cs b/src/shared/Core/CredentialStore.cs
index 11dc83818..95d26df32 100644
--- a/src/shared/Core/CredentialStore.cs
+++ b/src/shared/Core/CredentialStore.cs
@@ -291,18 +291,6 @@ private void ValidateGpgPass(out string storeRoot, out string execPath)
storeRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".password-store");
}
- // Check we have a GPG ID to sign credential files with
- string gpgIdFile = Path.Combine(storeRoot, ".gpg-id");
- if (!_context.FileSystem.FileExists(gpgIdFile))
- {
- var format =
- "Password store has not been initialized at '{0}'; run `pass init ` to initialize the store.";
- var message = string.Format(format, storeRoot);
- _context.Trace2.WriteError(message);
- throw new Exception(message + Environment.NewLine +
- $"See {Constants.HelpUrls.GcmCredentialStores} for more information."
- );
- }
}
private void ValidateCredentialCache(out string options)
diff --git a/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs b/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs
index 50ab5b4da..c49104ea8 100644
--- a/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs
+++ b/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs
@@ -29,7 +29,7 @@ protected override async Task RunInternalAsync(StringBuilder log, IList RunInternalAsync(StringBuilder log, IList { TestHttpUri, TestHttpUriFallback })
{
diff --git a/src/shared/Core/EnvironmentBase.cs b/src/shared/Core/EnvironmentBase.cs
index 6a3967193..39ed9dd03 100644
--- a/src/shared/Core/EnvironmentBase.cs
+++ b/src/shared/Core/EnvironmentBase.cs
@@ -138,7 +138,8 @@ internal virtual bool TryLocateExecutable(string program, ICollection pa
{
string candidatePath = Path.Combine(basePath, program);
if (FileSystem.FileExists(candidatePath) && (pathsToIgnore is null ||
- !pathsToIgnore.Contains(candidatePath, StringComparer.OrdinalIgnoreCase)))
+ !pathsToIgnore.Contains(candidatePath, StringComparer.OrdinalIgnoreCase))
+ && FileSystem.FileIsExecutable(candidatePath))
{
path = candidatePath;
return true;
diff --git a/src/shared/Core/FileSystem.cs b/src/shared/Core/FileSystem.cs
index aeacfd51d..c23f0faa1 100644
--- a/src/shared/Core/FileSystem.cs
+++ b/src/shared/Core/FileSystem.cs
@@ -34,6 +34,14 @@ public interface IFileSystem
/// True if a file exists, false otherwise.
bool FileExists(string path);
+ ///
+ /// Check if a file has execute permissions.
+ /// On Windows this always returns true. On POSIX it checks for any execute bit.
+ ///
+ /// Full path to file to test.
+ /// True if the file is executable, false otherwise.
+ bool FileIsExecutable(string path);
+
///
/// Check if a directory exists at the specified path.
///
@@ -122,6 +130,23 @@ public abstract class FileSystem : IFileSystem
public bool FileExists(string path) => File.Exists(path);
+#if NETFRAMEWORK
+ public bool FileIsExecutable(string path) => true;
+#else
+ public bool FileIsExecutable(string path)
+ {
+ if (!PlatformUtils.IsPosix())
+ return true;
+
+#pragma warning disable CA1416 // Platform guard via PlatformUtils.IsPosix()
+ var mode = File.GetUnixFileMode(path);
+ return (mode & (UnixFileMode.UserExecute |
+ UnixFileMode.GroupExecute |
+ UnixFileMode.OtherExecute)) != 0;
+#pragma warning restore CA1416
+ }
+#endif
+
public bool DirectoryExists(string path) => Directory.Exists(path);
public string GetCurrentDirectory() => Directory.GetCurrentDirectory();
diff --git a/src/shared/Core/Git.cs b/src/shared/Core/Git.cs
index 0c58e0159..82588357c 100644
--- a/src/shared/Core/Git.cs
+++ b/src/shared/Core/Git.cs
@@ -146,6 +146,15 @@ private string GetCurrentRepositoryInternal(bool suppressStreams)
}
git.Start(Trace2ProcessClass.Git);
+
+ // Drain and throw away stderr asynchronously to avoid a deadlock
+ // if the child process fills the stderr pipe buffer.
+ if (suppressStreams)
+ {
+ git.Process.ErrorDataReceived += (_, _) => { };
+ git.Process.BeginErrorReadLine();
+ }
+
string data = git.StandardOutput.ReadToEnd();
git.WaitForExit();
@@ -167,6 +176,8 @@ public IEnumerable GetRemotes()
{
using (var git = CreateProcess("remote -v show"))
{
+ // Redirect stderr so we can check for 'not a git repository' errors
+ git.StartInfo.RedirectStandardError = true;
git.Start(Trace2ProcessClass.Git);
// To avoid deadlocks, always read the output stream first and then wait
// TODO: don't read in all the data at once; stream it
@@ -267,7 +278,9 @@ public async Task> InvokeHelperAsync(string args, ID
public static GitException CreateGitException(ChildProcess git, string message, ITrace2 trace2 = null)
{
- var gitMessage = git.StandardError.ReadToEnd();
+ var gitMessage = git.StartInfo.RedirectStandardError
+ ? git.StandardError.ReadToEnd()
+ : null;
if (trace2 != null)
throw new Trace2GitException(trace2, message, git.ExitCode, gitMessage);
diff --git a/src/shared/Core/GitConfiguration.cs b/src/shared/Core/GitConfiguration.cs
index 9603b2db5..83a10d591 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,359 @@ 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 Value { get; set; }
+ public GitConfigurationLevel Level { get; set; }
+
+ public ConfigCacheEntry(string scope, string value)
+ {
+ Value = value;
+ Level = ParseScope(scope);
+ }
+
+ private static GitConfigurationLevel ParseScope(string scope)
+ {
+ switch (scope)
+ {
+ case "system":
+ return GitConfigurationLevel.System;
+ case "global":
+ return GitConfigurationLevel.Global;
+ case "local":
+ case "worktree":
+ case "command":
+ return GitConfigurationLevel.Local;
+ default:
+ return GitConfigurationLevel.Unknown;
+ }
+ }
+ }
+
+ ///
+ /// Cache for Git configuration entries loaded from 'git config list --show-scope -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 scope = new StringBuilder();
+ var key = new StringBuilder();
+ var value = new StringBuilder();
+
+ int i = 0;
+ while (i < data.Length)
+ {
+ scope.Clear();
+ key.Clear();
+ value.Clear();
+
+ // Read scope (NUL terminated)
+ while (i < data.Length && data[i] != '\0')
+ {
+ scope.Append(data[i++]);
+ }
+
+ if (i >= data.Length)
+ {
+ trace.WriteLine("Invalid Git configuration output. Expected null terminator (\\0) after scope.");
+ 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(scope.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 static readonly GitVersion ConfigListTypeMinVfsBase = new GitVersion(2, 53, 0);
+ private static readonly GitVersion ConfigListTypeMinVfsSuffix = new GitVersion(0, 1);
private readonly ITrace _trace;
private readonly GitProcess _git;
+ private readonly Dictionary _cache;
+ private readonly bool _useCache;
- internal GitProcessConfiguration(ITrace trace, GitProcess git)
+ internal GitProcessConfiguration(ITrace trace, GitProcess git) : this(trace, git, useCache: true)
+ {
+ }
+
+ 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+,
+ // or microsoft/git fork 2.53.0.vfs.0.1+
+ if (useCache && !SupportsConfigListType(git))
+ {
+ trace.WriteLine($"Git version {git.Version.OriginalString} does not support 'git config list --type'; config cache disabled");
+ useCache = false;
+ }
+
+ _useCache = useCache;
+ _cache = useCache ? new Dictionary() : null;
+ }
+
+ private static bool SupportsConfigListType(GitProcess git)
+ {
+ if (git.Version >= ConfigListTypeMinVersion)
+ return true;
+
+ // The microsoft/git fork fast-tracked the fix into 2.53.0.vfs.0.1.
+ // Version strings like "2.53.0.vfs.0.1" parse as [2,53,0] because
+ // GitVersion stops at the non-integer "vfs" component, so we check
+ // the original string for the ".vfs." marker and parse the suffix.
+ string versionStr = git.Version.OriginalString;
+ if (versionStr != null)
+ {
+ int vfsIdx = versionStr.IndexOf(".vfs.");
+ if (vfsIdx >= 0)
+ {
+ var baseVersion = new GitVersion(versionStr.Substring(0, vfsIdx));
+ var vfsSuffix = new GitVersion(versionStr.Substring(vfsIdx + 5));
+ return baseVersion >= ConfigListTypeMinVfsBase
+ && vfsSuffix >= ConfigListTypeMinVfsSuffix;
+ }
+ }
+
+ return false;
+ }
+
+ 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-scope -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 +530,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 +591,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 +613,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 +636,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 +647,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 +761,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 +790,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})");
diff --git a/src/shared/Core/HttpClientFactory.cs b/src/shared/Core/HttpClientFactory.cs
index c48e277e5..d66fad39f 100644
--- a/src/shared/Core/HttpClientFactory.cs
+++ b/src/shared/Core/HttpClientFactory.cs
@@ -130,7 +130,11 @@ public HttpClient CreateClient()
// Import the custom certs
X509Certificate2Collection certBundle = new X509Certificate2Collection();
+#if NETFRAMEWORK
certBundle.Import(certBundlePath);
+#else
+ certBundle.ImportFromPemFile(certBundlePath);
+#endif
try
{
diff --git a/src/shared/Core/Interop/Linux/LinuxConfigParser.cs b/src/shared/Core/Interop/Linux/LinuxConfigParser.cs
index 1caa918fc..b9b759977 100644
--- a/src/shared/Core/Interop/Linux/LinuxConfigParser.cs
+++ b/src/shared/Core/Interop/Linux/LinuxConfigParser.cs
@@ -31,7 +31,7 @@ public IDictionary Parse(string content)
{
var result = new Dictionary(GitConfigurationKeyComparer.Instance);
- IEnumerable lines = content.Split(['\n'], StringSplitOptions.RemoveEmptyEntries);
+ IEnumerable lines = content.Split(new char[] { '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (string line in lines)
{
diff --git a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs
index 6ed56c693..debc9c815 100644
--- a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs
+++ b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs
@@ -21,19 +21,33 @@ public GpgPassCredentialStore(IFileSystem fileSystem, IGpg gpg, string storeRoot
protected override string CredentialFileExtension => ".gpg";
- private string GetGpgId()
+ private string GetGpgId(string credentialFullPath)
{
- string gpgIdPath = Path.Combine(StoreRoot, ".gpg-id");
- if (!FileSystem.FileExists(gpgIdPath))
+ // Walk up from the credential's directory to the store root, looking for a .gpg-id file.
+ // This mimics the behaviour of GNU Pass, which uses the nearest .gpg-id in the directory hierarchy.
+ string dir = Path.GetDirectoryName(credentialFullPath);
+ while (dir != null)
{
- throw new Exception($"Cannot find GPG ID in '{gpgIdPath}'; password store has not been initialized");
- }
+ string gpgIdPath = Path.Combine(dir, ".gpg-id");
+ if (FileSystem.FileExists(gpgIdPath))
+ {
+ using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read))
+ using (var reader = new StreamReader(stream))
+ {
+ return reader.ReadLine();
+ }
+ }
- using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read))
- using (var reader = new StreamReader(stream))
- {
- return reader.ReadLine();
+ // Stop after checking the store root
+ if (FileSystem.IsSamePath(dir, StoreRoot))
+ {
+ break;
+ }
+
+ dir = Path.GetDirectoryName(dir);
}
+
+ throw new Exception($"Cannot find GPG ID in password store at '{StoreRoot}'; run `pass init ` to initialize the store.");
}
protected override bool TryDeserializeCredential(string path, out FileCredential credential)
@@ -68,7 +82,7 @@ protected override bool TryDeserializeCredential(string path, out FileCredential
protected override void SerializeCredential(FileCredential credential)
{
- string gpgId = GetGpgId();
+ string gpgId = GetGpgId(credential.FullPath);
var sb = new StringBuilder(credential.Password);
sb.AppendFormat("{1}service={0}{1}", credential.Service, Environment.NewLine);
diff --git a/src/shared/Core/Settings.cs b/src/shared/Core/Settings.cs
index af3dcf99c..480db7ea5 100644
--- a/src/shared/Core/Settings.cs
+++ b/src/shared/Core/Settings.cs
@@ -659,7 +659,7 @@ public bool IsCertificateVerificationEnabled
}
public bool AutomaticallyUseClientCertificates =>
- TryGetSetting(null, KnownGitCfg.Credential.SectionName, KnownGitCfg.Http.SslAutoClientCert, out string value) && value.ToBooleanyOrDefault(false);
+ TryGetSetting(null, KnownGitCfg.Http.SectionName, KnownGitCfg.Http.SslAutoClientCert, out string value) && value.ToBooleanyOrDefault(false);
public string CustomCertificateBundlePath =>
TryGetPathSetting(KnownEnvars.GitSslCaInfo, KnownGitCfg.Http.SectionName, KnownGitCfg.Http.SslCaInfo, out string value) ? value : null;
diff --git a/src/shared/Core/StreamExtensions.cs b/src/shared/Core/StreamExtensions.cs
index 7ff338f5a..beb85699b 100644
--- a/src/shared/Core/StreamExtensions.cs
+++ b/src/shared/Core/StreamExtensions.cs
@@ -179,7 +179,7 @@ public static void WriteDictionary(this TextWriter writer, IDictionary= 0; i--)
{
- using (var writer = _writers[i])
+ using (_writers[i])
{
- _writers.Remove(writer);
+ _writers.RemoveAt(i);
}
}
}
@@ -640,7 +640,7 @@ private void WriteMessage(Trace2Message message)
private static string BuildThreadName()
{
// If this is the entry thread, call it "main", per Trace2 convention
- if (Thread.CurrentThread.ManagedThreadId == 0)
+ if (Thread.CurrentThread.ManagedThreadId == 1)
{
return "main";
}
diff --git a/src/shared/Core/Trace2Message.cs b/src/shared/Core/Trace2Message.cs
index 14327031f..78eb05a20 100644
--- a/src/shared/Core/Trace2Message.cs
+++ b/src/shared/Core/Trace2Message.cs
@@ -151,7 +151,7 @@ private static string BuildSpan(PerformanceFormatSpan component, string data)
if (double.TryParse(data, out _))
{
// Remove all padding for values that take up the entire span
- if (Math.Abs(sizeDifference) == paddingTotal)
+ if (Math.Abs(sizeDifference) >= paddingTotal)
{
component.BeginPadding = 0;
component.EndPadding = 0;
diff --git a/src/shared/DotnetTool/DotnetTool.csproj b/src/shared/DotnetTool/DotnetTool.csproj
index a1107a4b6..720f75693 100644
--- a/src/shared/DotnetTool/DotnetTool.csproj
+++ b/src/shared/DotnetTool/DotnetTool.csproj
@@ -1,6 +1,6 @@
- net8.0
+ net10.0
true
dotnet-tool.nuspec
diff --git a/src/shared/DotnetTool/dotnet-tool.nuspec b/src/shared/DotnetTool/dotnet-tool.nuspec
index 35f81ebc9..9eb9a7020 100644
--- a/src/shared/DotnetTool/dotnet-tool.nuspec
+++ b/src/shared/DotnetTool/dotnet-tool.nuspec
@@ -11,7 +11,7 @@
-
+
diff --git a/src/shared/DotnetTool/layout.ps1 b/src/shared/DotnetTool/layout.ps1
index ca9b13011..f2bd87285 100644
--- a/src/shared/DotnetTool/layout.ps1
+++ b/src/shared/DotnetTool/layout.ps1
@@ -44,7 +44,7 @@ $DotnetToolRel = "shared/DotnetTool"
$GcmSrc = Join-Path $Src "shared\Git-Credential-Manager"
$ProjOut = Join-Path $Out $DotnetToolRel
-$Framework = "net8.0"
+$Framework = "net10.0"
if (-not $Output -or $Output.Trim() -eq "") {
$Output = Join-Path $ProjOut "nupkg\$Configuration"
diff --git a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj
index 8c469897e..b367bb48a 100644
--- a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj
+++ b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj
@@ -2,8 +2,8 @@
Exe
- net8.0
- net472;net8.0
+ net10.0
+ net472;net10.0
win-x86;win-x64;win-arm64;osx-x64;linux-x64;osx-arm64;linux-arm64;linux-arm
git-credential-manager
GitCredentialManager
diff --git a/src/shared/GitHub.Tests/GitHub.Tests.csproj b/src/shared/GitHub.Tests/GitHub.Tests.csproj
index 0574e00d1..cf7f8be69 100644
--- a/src/shared/GitHub.Tests/GitHub.Tests.csproj
+++ b/src/shared/GitHub.Tests/GitHub.Tests.csproj
@@ -1,20 +1,20 @@
- net8.0
+ net10.0
false
true
latest
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
-
-
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/src/shared/GitHub/GitHub.csproj b/src/shared/GitHub/GitHub.csproj
index 66a4afd79..53ac5cf94 100644
--- a/src/shared/GitHub/GitHub.csproj
+++ b/src/shared/GitHub/GitHub.csproj
@@ -1,8 +1,8 @@
- net8.0
- net8.0;net472
+ net10.0
+ net10.0;net472
GitHub
GitHub
false
diff --git a/src/shared/GitHub/GitHubAuthChallenge.cs b/src/shared/GitHub/GitHubAuthChallenge.cs
index de3afbdbd..1b33330c9 100644
--- a/src/shared/GitHub/GitHubAuthChallenge.cs
+++ b/src/shared/GitHub/GitHubAuthChallenge.cs
@@ -107,7 +107,15 @@ public override bool Equals(object obj)
public override int GetHashCode()
{
- return Domain.GetHashCode() * 1019 ^
- Enterprise.GetHashCode() * 337;
+ int domainHash = Domain is null
+ ? 0
+ : StringComparer.OrdinalIgnoreCase.GetHashCode(Domain);
+
+ int enterpriseHash = Enterprise is null
+ ? 0
+ : StringComparer.OrdinalIgnoreCase.GetHashCode(Enterprise);
+
+ return (domainHash * 1019) ^
+ (enterpriseHash * 337);
}
}
diff --git a/src/shared/GitHub/GitHubHostProvider.cs b/src/shared/GitHub/GitHubHostProvider.cs
index 06afd9592..07607dd4e 100644
--- a/src/shared/GitHub/GitHubHostProvider.cs
+++ b/src/shared/GitHub/GitHubHostProvider.cs
@@ -198,6 +198,7 @@ private bool FilterAccounts(Uri remoteUri, IEnumerable wwwAuth, ref ILis
if (!IsGitHubDotCom(remoteUri))
{
_context.Trace.WriteLine("No account filtering outside of GitHub.com.");
+ return false;
}
// Allow the user to disable account filtering until this feature stabilises.
diff --git a/src/shared/GitHub/GitHubResources.resx b/src/shared/GitHub/GitHubResources.resx
index a5348d617..3972d4779 100644
--- a/src/shared/GitHub/GitHubResources.resx
+++ b/src/shared/GitHub/GitHubResources.resx
@@ -20,6 +20,7 @@
+
Git Credential Manager - Authentication Succeeded
@@ -75,6 +89,7 @@ p {
+
Git Credential Manager - Authentication Failed
diff --git a/src/shared/GitHub/UI/Commands/CredentialsCommand.cs b/src/shared/GitHub/UI/Commands/CredentialsCommand.cs
index f14b3cb3e..45c6cfd7f 100644
--- a/src/shared/GitHub/UI/Commands/CredentialsCommand.cs
+++ b/src/shared/GitHub/UI/Commands/CredentialsCommand.cs
@@ -38,7 +38,7 @@ protected CredentialsCommand(ICommandContext context)
this.SetHandler(ExecuteAsync, url, userName, basic, browser, device, pat, all);
}
- private async Task
ExecuteAsync(string userName, string enterpriseUrl,
+ private async Task ExecuteAsync(string enterpriseUrl, string userName,
bool basic, bool browser, bool device, bool pat, bool all)
{
var viewModel = new CredentialsViewModel(Context.SessionManager, Context.ProcessManager)
diff --git a/src/shared/GitLab.Tests/GitLab.Tests.csproj b/src/shared/GitLab.Tests/GitLab.Tests.csproj
index 098878aec..aef8483a1 100644
--- a/src/shared/GitLab.Tests/GitLab.Tests.csproj
+++ b/src/shared/GitLab.Tests/GitLab.Tests.csproj
@@ -1,20 +1,20 @@
- net8.0
+ net10.0
false
true
latest
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
-
-
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/src/shared/GitLab/GitLab.csproj b/src/shared/GitLab/GitLab.csproj
index 25c37f2fe..19aa356f2 100644
--- a/src/shared/GitLab/GitLab.csproj
+++ b/src/shared/GitLab/GitLab.csproj
@@ -1,8 +1,8 @@
- net8.0
- net8.0;net472
+ net10.0
+ net10.0;net472
GitLab
GitLab
false
diff --git a/src/shared/GitLab/UI/Commands/CredentialsCommand.cs b/src/shared/GitLab/UI/Commands/CredentialsCommand.cs
index 1c1995a8d..02a0f7818 100644
--- a/src/shared/GitLab/UI/Commands/CredentialsCommand.cs
+++ b/src/shared/GitLab/UI/Commands/CredentialsCommand.cs
@@ -35,7 +35,7 @@ protected CredentialsCommand(ICommandContext context)
this.SetHandler(ExecuteAsync, url, userName, basic, browser, pat, all);
}
- private async Task ExecuteAsync(string userName, string url, bool basic, bool browser, bool pat, bool all)
+ private async Task ExecuteAsync(string url, string userName, bool basic, bool browser, bool pat, bool all)
{
var viewModel = new CredentialsViewModel(Context.SessionManager)
{
diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposBindingManagerTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposBindingManagerTests.cs
index 2c506ae62..ec60d8f70 100644
--- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposBindingManagerTests.cs
+++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposBindingManagerTests.cs
@@ -628,6 +628,93 @@ public void AzureReposBindingManager_SignIn_OtherGlobalOtherLocal_BindsLocal()
Assert.Equal(user2, actualGlobalUser);
}
+ // Idempotency: SignIn when state is already correct should not write to git config
+
+ [Fact]
+ public void AzureReposBindingManager_SignIn_SameGlobalNoLocal_NoConfigWrites()
+ {
+ // Steady-state: global already bound to signing-in user, no local override.
+ // This is the common case on every 'git fetch' after the first sign-in.
+ const string orgName = "org";
+ const string user1 = "user1";
+
+ var git = new TestGit();
+ var trace = new NullTrace();
+ var manager = new AzureReposBindingManager(trace, git);
+
+ git.Configuration.Global[CreateKey(orgName)] = new[] {user1};
+
+ manager.SignIn(orgName, user1);
+
+ Assert.Equal(0, git.Configuration.SetCallCount);
+ Assert.Equal(0, git.Configuration.UnsetCallCount);
+ }
+
+ [Fact]
+ public void AzureReposBindingManager_SignIn_OtherGlobalSameLocal_NoConfigWrites()
+ {
+ // Steady-state: a different user holds the global binding, and local is already
+ // bound to the signing-in user. No change needed.
+ const string orgName = "org";
+ const string user1 = "user1";
+ const string user2 = "user2";
+
+ var git = new TestGit();
+ var trace = new NullTrace();
+ var manager = new AzureReposBindingManager(trace, git);
+
+ git.Configuration.Global[CreateKey(orgName)] = new[] {user2};
+ git.Configuration.Local[CreateKey(orgName)] = new[] {user1};
+
+ manager.SignIn(orgName, user1);
+
+ Assert.Equal(0, git.Configuration.SetCallCount);
+ Assert.Equal(0, git.Configuration.UnsetCallCount);
+ }
+
+ [Fact]
+ public void AzureReposBindingManager_SignIn_SameGlobalSameLocal_OnlyUnbindsLocal()
+ {
+ // Global already matches, local redundantly mirrors it.
+ // Only the local unset is needed; re-writing the global value is wasteful.
+ const string orgName = "org";
+ const string user1 = "user1";
+
+ var git = new TestGit();
+ var trace = new NullTrace();
+ var manager = new AzureReposBindingManager(trace, git);
+
+ git.Configuration.Global[CreateKey(orgName)] = new[] {user1};
+ git.Configuration.Local[CreateKey(orgName)] = new[] {user1};
+
+ manager.SignIn(orgName, user1);
+
+ Assert.Equal(0, git.Configuration.SetCallCount);
+ Assert.Equal(1, git.Configuration.UnsetCallCount);
+ }
+
+ [Fact]
+ public void AzureReposBindingManager_SignIn_SameGlobalOtherLocal_OnlyUnbindsLocal()
+ {
+ // Global already matches, local has a different user that needs removing.
+ // Only the local unset is needed; re-writing the global value is wasteful.
+ const string orgName = "org";
+ const string user1 = "user1";
+ const string user2 = "user2";
+
+ var git = new TestGit();
+ var trace = new NullTrace();
+ var manager = new AzureReposBindingManager(trace, git);
+
+ git.Configuration.Global[CreateKey(orgName)] = new[] {user1};
+ git.Configuration.Local[CreateKey(orgName)] = new[] {user2};
+
+ manager.SignIn(orgName, user1);
+
+ Assert.Equal(0, git.Configuration.SetCallCount);
+ Assert.Equal(1, git.Configuration.UnsetCallCount);
+ }
+
#endregion
#region SignOut
diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs
index bfd14d14f..e05db1646 100644
--- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs
+++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs
@@ -605,6 +605,248 @@ public async Task AzureReposProvider_GetCredentialAsync_ManagedIdentity_ReturnsM
AzureDevOpsConstants.AzureDevOpsResourceId), Times.Once);
}
+ [Fact]
+ public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_Generic_ReturnsFederationOptions()
+ {
+ var input = new InputArguments(new Dictionary
+ {
+ ["protocol"] = "https",
+ ["host"] = "dev.azure.com",
+ ["path"] = "org/proj/_git/repo"
+ });
+
+ const string accessToken = "FEDERATED-IDENTITY-TOKEN";
+ const string wifScenario = "generic";
+ const string tenantId = "00000000-0000-0000-0000-000000000000";
+ const string clientId = "11111111-1111-1111-1111-111111111111";
+ const string assertion = "CLIENT-ASSERTION";
+
+ var context = new TestCommandContext
+ {
+ Environment =
+ {
+ Variables =
+ {
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario,
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId,
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId,
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAssertion] = assertion,
+ }
+ }
+ };
+
+ var azDevOps = Mock.Of();
+ var authorityCache = Mock.Of();
+ var userMgr = Mock.Of();
+ var msAuthMock = new Mock();
+
+ msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken });
+
+ var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr);
+
+ GetCredentialResult result = await provider.GetCredentialAsync(input);
+ ICredential credential = result.Credential;
+
+ Assert.NotNull(credential);
+ Assert.Equal(clientId, credential.Account);
+ Assert.Equal(accessToken, credential.Password);
+
+ msAuthMock.Verify(
+ x => x.GetTokenUsingWorkloadFederationAsync(
+ It.Is(
+ fed => fed.Scenario == MicrosoftWorkloadFederationScenario.Generic &&
+ fed.TenantId == tenantId &&
+ fed.ClientId == clientId &&
+ fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience &&
+ fed.GenericClientAssertion == assertion),
+ AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once);
+ }
+
+ [Fact]
+ public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_GenericFileAssertion_ReadsFromFile()
+ {
+ var input = new InputArguments(new Dictionary
+ {
+ ["protocol"] = "https",
+ ["host"] = "dev.azure.com",
+ ["path"] = "org/proj/_git/repo"
+ });
+
+ const string accessToken = "FEDERATED-IDENTITY-TOKEN";
+ const string wifScenario = "generic";
+ const string tenantId = "00000000-0000-0000-0000-000000000000";
+ const string clientId = "11111111-1111-1111-1111-111111111111";
+ const string assertion = "CLIENT-ASSERTION-FROM-FILE";
+ const string filePath = "/tmp/assertion-token.txt";
+
+ var context = new TestCommandContext
+ {
+ Environment =
+ {
+ Variables =
+ {
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario,
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId,
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId,
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAssertion] = $"file://{filePath}",
+ }
+ }
+ };
+
+ context.FileSystem.Files[filePath] = System.Text.Encoding.UTF8.GetBytes(assertion);
+
+ var azDevOps = Mock.Of();
+ var authorityCache = Mock.Of();
+ var userMgr = Mock.Of();
+ var msAuthMock = new Mock();
+
+ msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken });
+
+ var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr);
+
+ GetCredentialResult result = await provider.GetCredentialAsync(input);
+ ICredential credential = result.Credential;
+
+ Assert.NotNull(credential);
+ Assert.Equal(clientId, credential.Account);
+ Assert.Equal(accessToken, credential.Password);
+
+ msAuthMock.Verify(
+ x => x.GetTokenUsingWorkloadFederationAsync(
+ It.Is(
+ fed => fed.Scenario == MicrosoftWorkloadFederationScenario.Generic &&
+ fed.TenantId == tenantId &&
+ fed.ClientId == clientId &&
+ fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience &&
+ fed.GenericClientAssertion == assertion),
+ AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once);
+ }
+
+ [Fact]
+ public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_MI_ReturnsFederationOptions()
+ {
+ var input = new InputArguments(new Dictionary
+ {
+ ["protocol"] = "https",
+ ["host"] = "dev.azure.com",
+ ["path"] = "org/proj/_git/repo"
+ });
+
+ const string accessToken = "FEDERATED-IDENTITY-TOKEN";
+ const string wifScenario = "managedidentity";
+ const string tenantId = "00000000-0000-0000-0000-000000000000";
+ const string clientId = "11111111-1111-1111-1111-111111111111";
+ const string managedIdentity = "22222222-2222-2222-2222-222222222222";
+
+ var context = new TestCommandContext
+ {
+ Environment =
+ {
+ Variables =
+ {
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario,
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId,
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId,
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationManagedIdentity] = managedIdentity,
+ }
+ }
+ };
+
+ var azDevOps = Mock.Of();
+ var authorityCache = Mock.Of();
+ var userMgr = Mock.Of();
+ var msAuthMock = new Mock();
+
+ msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken });
+
+ var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr);
+
+ GetCredentialResult result = await provider.GetCredentialAsync(input);
+ ICredential credential = result.Credential;
+
+ Assert.NotNull(credential);
+ Assert.Equal(clientId, credential.Account);
+ Assert.Equal(accessToken, credential.Password);
+
+ msAuthMock.Verify(
+ x => x.GetTokenUsingWorkloadFederationAsync(
+ It.Is(
+ fed => fed.Scenario == MicrosoftWorkloadFederationScenario.ManagedIdentity &&
+ fed.TenantId == tenantId &&
+ fed.ClientId == clientId &&
+ fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience &&
+ fed.ManagedIdentityId == managedIdentity),
+ AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once);
+ }
+
+ [Fact]
+ public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_GitHubActions_ReturnsFederationOptions()
+ {
+ var input = new InputArguments(new Dictionary
+ {
+ ["protocol"] = "https",
+ ["host"] = "dev.azure.com",
+ ["path"] = "org/proj/_git/repo"
+ });
+
+ const string accessToken = "FEDERATED-IDENTITY-TOKEN";
+ const string wifScenario = "githubactions";
+ const string tenantId = "00000000-0000-0000-0000-000000000000";
+ const string clientId = "11111111-1111-1111-1111-111111111111";
+ const string ghRequestUrl = "https://token.actions.example.com/oidc/example?param=value";
+ const string ghRequestToken = "OIDC-TOKEN";
+
+ var context = new TestCommandContext
+ {
+ Environment =
+ {
+ Variables =
+ {
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario,
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId,
+ [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId,
+ [Constants.EnvironmentVariables.GitHubActionsTokenRequestUrl] = ghRequestUrl,
+ [Constants.EnvironmentVariables.GitHubActionsTokenRequestToken] = ghRequestToken,
+ }
+ }
+ };
+
+ var azDevOps = Mock.Of();
+ var authorityCache = Mock.Of();
+ var userMgr = Mock.Of();
+ var msAuthMock = new Mock();
+
+ msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync(
+ It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken });
+
+ var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr);
+
+ GetCredentialResult result = await provider.GetCredentialAsync(input);
+ ICredential credential = result.Credential;
+
+ Assert.NotNull(credential);
+ Assert.Equal(clientId, credential.Account);
+ Assert.Equal(accessToken, credential.Password);
+
+ msAuthMock.Verify(
+ x => x.GetTokenUsingWorkloadFederationAsync(
+ It.Is(
+ fed => fed.Scenario == MicrosoftWorkloadFederationScenario.GitHubActions &&
+ fed.TenantId == tenantId &&
+ fed.ClientId == clientId &&
+ fed.GitHubTokenRequestUrl == new Uri(ghRequestUrl) &&
+ fed.GitHubTokenRequestToken == ghRequestToken &&
+ fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience),
+ AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once);
+ }
+
[Fact]
public async Task AzureReposProvider_GetCredentialAsync_ServicePrincipal_ReturnsSPCredential()
{
diff --git a/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj b/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj
index 1c673bcc9..e9cb3f16d 100644
--- a/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj
+++ b/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj
@@ -1,20 +1,20 @@
- net8.0
+ net10.0
false
true
latest
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
-
-
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs
index a282d4eff..f9ec8ce0f 100644
--- a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs
+++ b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs
@@ -46,6 +46,12 @@ public static class EnvironmentVariables
public const string ServicePrincipalCertificateThumbprint = "GCM_AZREPOS_SP_CERT_THUMBPRINT";
public const string ServicePrincipalCertificateSendX5C = "GCM_AZREPOS_SP_CERT_SEND_X5C";
public const string ManagedIdentity = "GCM_AZREPOS_MANAGEDIDENTITY";
+ public const string WorkloadFederation = "GCM_AZREPOS_WIF";
+ public const string WorkloadFederationClientId = "GCM_AZREPOS_WIF_CLIENTID";
+ public const string WorkloadFederationTenantId = "GCM_AZREPOS_WIF_TENANTID";
+ public const string WorkloadFederationAudience = "GCM_AZREPOS_WIF_AUDIENCE";
+ public const string WorkloadFederationAssertion = "GCM_AZREPOS_WIF_ASSERTION";
+ public const string WorkloadFederationManagedIdentity = "GCM_AZREPOS_WIF_MANAGEDIDENTITY";
}
public static class GitConfiguration
@@ -62,6 +68,12 @@ public static class Credential
public const string ServicePrincipalCertificateThumbprint = "azreposServicePrincipalCertificateThumbprint";
public const string ServicePrincipalCertificateSendX5C = "azreposServicePrincipalCertificateSendX5C";
public const string ManagedIdentity = "azreposManagedIdentity";
+ public const string WorkloadFederation = "azreposWorkloadFederation";
+ public const string WorkloadFederationClientId = "azreposWorkloadFederationClientId";
+ public const string WorkloadFederationTenantId = "azreposWorkloadFederationTenantId";
+ public const string WorkloadFederationAudience = "azreposWorkloadFederationAudience";
+ public const string WorkloadFederationAssertion = "azreposWorkloadFederationAssertion";
+ public const string WorkloadFederationManagedIdentity = "azreposWorkloadFederationManagedIdentity";
}
}
}
diff --git a/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs b/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs
index 7e26590bd..2ae7fd11a 100644
--- a/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs
+++ b/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs
@@ -283,12 +283,24 @@ public static void SignIn(this IAzureReposBindingManager bindingManager, string
if (existingBinding?.GlobalUserName != null &&
!StringComparer.OrdinalIgnoreCase.Equals(existingBinding.GlobalUserName, userName))
{
- bindingManager.Bind(orgName, userName, local: true);
+ // Global is bound to a different user (B); bind this user locally (-> B | A).
+ // Skip the write if local is already correct.
+ if (!StringComparer.OrdinalIgnoreCase.Equals(existingBinding.LocalUserName, userName))
+ {
+ bindingManager.Bind(orgName, userName, local: true);
+ }
}
else
{
- bindingManager.Bind(orgName, userName, local: false);
- bindingManager.Unbind(orgName, local: true);
+ // Global is absent or already matches; ensure global is set and local is clear.
+ if (existingBinding?.GlobalUserName is null)
+ {
+ bindingManager.Bind(orgName, userName, local: false);
+ }
+ if (existingBinding?.LocalUserName is not null)
+ {
+ bindingManager.Unbind(orgName, local: true);
+ }
}
}
diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs
index 72eb378eb..9a916a236 100644
--- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs
+++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs
@@ -85,6 +85,15 @@ public async Task GetCredentialAsync(InputArguments input)
);
}
+ if (UseWorkloadFederation(out MicrosoftWorkloadFederationOptions fedOpts))
+ {
+ _context.Trace.WriteLine($"Getting Azure Access Token using WIF (scenario: {fedOpts.Scenario})...");
+ var azureResult = await _msAuth.GetTokenUsingWorkloadFederationAsync(fedOpts, AzureDevOpsConstants.AzureDevOpsDefaultScopes);
+ return new GetCredentialResult(
+ new GitCredential(fedOpts.ClientId, azureResult.AccessToken)
+ );
+ }
+
if (UseServicePrincipal(out ServicePrincipalIdentity sp))
{
_context.Trace.WriteLine($"Getting Azure Access Token for service principal {sp.TenantId}/{sp.Id}...");
@@ -137,6 +146,10 @@ public Task StoreCredentialAsync(InputArguments input)
{
_context.Trace.WriteLine("Nothing to store for managed identity authentication.");
}
+ else if (UseWorkloadFederation(out _))
+ {
+ _context.Trace.WriteLine("Nothing to store for federated identity authentication.");
+ }
else if (UseServicePrincipal(out _))
{
_context.Trace.WriteLine("Nothing to store for service principal authentication.");
@@ -172,6 +185,10 @@ public Task EraseCredentialAsync(InputArguments input)
{
_context.Trace.WriteLine("Nothing to erase for managed identity authentication.");
}
+ else if (UseWorkloadFederation(out _))
+ {
+ _context.Trace.WriteLine("Nothing to erase for federated identity authentication.");
+ }
else if (UseServicePrincipal(out _))
{
_context.Trace.WriteLine("Nothing to erase for service principal authentication.");
@@ -588,6 +605,160 @@ private bool UseManagedIdentity(out string mid)
!string.IsNullOrWhiteSpace(mid);
}
+ private bool UseWorkloadFederation(out MicrosoftWorkloadFederationOptions fedOpts)
+ {
+ if (!_context.Settings.TryGetSetting(
+ AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation,
+ Constants.GitConfiguration.Credential.SectionName,
+ AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederation,
+ out string wifStr))
+ {
+ fedOpts = null;
+ return false;
+ }
+
+ MicrosoftWorkloadFederationScenario scenario;
+ switch (wifStr.ToLowerInvariant())
+ {
+ case "generic":
+ scenario = MicrosoftWorkloadFederationScenario.Generic;
+ break;
+
+ case "mi":
+ case "managedidentity":
+ scenario = MicrosoftWorkloadFederationScenario.ManagedIdentity;
+ break;
+
+ case "github":
+ case "githubactions":
+ scenario = MicrosoftWorkloadFederationScenario.GitHubActions;
+ break;
+
+ default: // Unknown scenario value
+ fedOpts = null;
+ return false;
+ }
+
+ bool hasClientId = _context.Settings.TryGetSetting(
+ AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId,
+ Constants.GitConfiguration.Credential.SectionName,
+ AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationClientId,
+ out string clientId);
+
+ bool hasTenantId = _context.Settings.TryGetSetting(
+ AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId,
+ Constants.GitConfiguration.Credential.SectionName,
+ AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationTenantId,
+ out string tenantId);
+
+ if (!hasClientId || !hasTenantId)
+ {
+ _context.Streams.Error.WriteLine("error: both client ID and tenant ID are required for workload federation");
+ fedOpts = null;
+ return false;
+ }
+
+ // Audience is optional - the default is "api://AzureADTokenExchange"
+ if (!_context.Settings.TryGetSetting(
+ AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAudience,
+ Constants.GitConfiguration.Credential.SectionName,
+ AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationAudience,
+ out string audience) || string.IsNullOrWhiteSpace(audience))
+ {
+ audience = MicrosoftWorkloadFederationOptions.DefaultAudience;
+ }
+
+ fedOpts = new MicrosoftWorkloadFederationOptions
+ {
+ Scenario = scenario,
+ ClientId = clientId,
+ TenantId = tenantId,
+ Audience = audience
+ };
+
+ switch (scenario)
+ {
+ case MicrosoftWorkloadFederationScenario.Generic:
+ if (!_context.Settings.TryGetSetting(
+ AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAssertion,
+ Constants.GitConfiguration.Credential.SectionName,
+ AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationAssertion,
+ out string assertion) || string.IsNullOrWhiteSpace(assertion))
+ {
+ _context.Streams.Error.WriteLine("error: assertion is required for the generic workload federation scenario");
+ fedOpts = null;
+ return false;
+ }
+
+ // Check if this value points to a file containing the actual assertion (file://)
+ if (Uri.TryCreate(assertion, UriKind.Absolute, out Uri assertionUri)
+ && StringComparer.OrdinalIgnoreCase.Equals(assertionUri.Scheme, "file"))
+ {
+ string filePath = assertionUri.LocalPath;
+ if (!_context.FileSystem.FileExists(filePath))
+ {
+ _context.Streams.Error.WriteLine($"error: assertion file not found: {filePath}");
+ fedOpts = null;
+ return false;
+ }
+
+ _context.Trace.WriteLine($"Reading workload federation assertion from file '{filePath}'...");
+ assertion = _context.FileSystem.ReadAllText(filePath).Trim();
+ if (string.IsNullOrWhiteSpace(assertion))
+ {
+ _context.Streams.Error.WriteLine($"error: assertion file is empty: {filePath}");
+ fedOpts = null;
+ return false;
+ }
+ }
+
+ fedOpts.GenericClientAssertion = assertion;
+ break;
+
+ case MicrosoftWorkloadFederationScenario.ManagedIdentity:
+ if (!_context.Settings.TryGetSetting(
+ AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationManagedIdentity,
+ Constants.GitConfiguration.Credential.SectionName,
+ AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationManagedIdentity,
+ out string managedIdentity) || string.IsNullOrWhiteSpace(managedIdentity))
+ {
+ _context.Streams.Error.WriteLine("error: managed identity is required for the managed identity workload federation scenario");
+ fedOpts = null;
+ return false;
+ }
+
+ fedOpts.ManagedIdentityId = managedIdentity;
+ break;
+
+ case MicrosoftWorkloadFederationScenario.GitHubActions:
+ if (!_context.Environment.Variables.TryGetValue(
+ Constants.EnvironmentVariables.GitHubActionsTokenRequestUrl, out string tokenRequestUrl)
+ || !Uri.TryCreate(tokenRequestUrl, UriKind.Absolute, out Uri tokenRequestUri))
+ {
+ _context.Streams.Error.WriteLine(
+ "error: unable to get valid token request URL from environment variable for the GitHub Actions workload federation scenario");
+ fedOpts = null;
+ return false;
+ }
+
+ if (!_context.Environment.Variables.TryGetValue(
+ Constants.EnvironmentVariables.GitHubActionsTokenRequestToken, out string tokenRequestToken)
+ || string.IsNullOrWhiteSpace(tokenRequestToken))
+ {
+ _context.Streams.Error.WriteLine(
+ "error: unable to get valid token request token from environment variable for the GitHub Actions workload federation scenario");
+ fedOpts = null;
+ return false;
+ }
+
+ fedOpts.GitHubTokenRequestUrl = tokenRequestUri;
+ fedOpts.GitHubTokenRequestToken = tokenRequestToken;
+ break;
+ }
+
+ return true;
+ }
+
#endregion
#region IConfigurationComponent
diff --git a/src/shared/Microsoft.AzureRepos/Microsoft.AzureRepos.csproj b/src/shared/Microsoft.AzureRepos/Microsoft.AzureRepos.csproj
index eaf866bfa..73b7dedff 100644
--- a/src/shared/Microsoft.AzureRepos/Microsoft.AzureRepos.csproj
+++ b/src/shared/Microsoft.AzureRepos/Microsoft.AzureRepos.csproj
@@ -1,8 +1,8 @@
- net8.0
- net8.0;net472
+ net10.0
+ net10.0;net472
Microsoft.AzureRepos
Microsoft.AzureRepos
false
diff --git a/src/shared/TestInfrastructure/Objects/TestFileSystem.cs b/src/shared/TestInfrastructure/Objects/TestFileSystem.cs
index 11dff8f1f..57a75f2b8 100644
--- a/src/shared/TestInfrastructure/Objects/TestFileSystem.cs
+++ b/src/shared/TestInfrastructure/Objects/TestFileSystem.cs
@@ -11,6 +11,7 @@ public class TestFileSystem : IFileSystem
public string UserHomePath { get; set; }
public string UserDataDirectoryPath { get; set; }
public IDictionary Files { get; set; } = new Dictionary();
+ public ISet ExecutableFiles { get; } = new HashSet();
public ISet Directories { get; set; } = new HashSet();
public string CurrentDirectory { get; set; } = Path.GetTempPath();
public bool IsCaseSensitive { get; set; } = false;
@@ -36,6 +37,18 @@ bool IFileSystem.FileExists(string path)
return Files.ContainsKey(path);
}
+ bool IFileSystem.FileIsExecutable(string path)
+ {
+ if (!Files.ContainsKey(path))
+ throw new FileNotFoundException("File not found", path);
+
+ // On Windows, all files are considered executable.
+ if (!PlatformUtils.IsPosix())
+ return true;
+
+ return ExecutableFiles.Contains(path);
+ }
+
bool IFileSystem.DirectoryExists(string path)
{
return Directories.Contains(TrimSlash(path));
@@ -130,6 +143,20 @@ string[] IFileSystem.ReadAllLines(string path)
#endregion
+ ///
+ /// Mark a test file as executable. File must exist in already.
+ ///
+ public void SetExecutable(string path, bool isExecutable = true)
+ {
+ if (!Files.ContainsKey(path))
+ throw new FileNotFoundException("File not found", path);
+
+ if (isExecutable)
+ ExecutableFiles.Add(path);
+ else
+ ExecutableFiles.Remove(path);
+ }
+
///
/// Trim trailing slashes from a path.
///
diff --git a/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs b/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs
index 517a8c7b8..6a887a97c 100644
--- a/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs
+++ b/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs
@@ -16,6 +16,9 @@ public class TestGitConfiguration : IGitConfiguration
public IDictionary> Local { get; set; } =
new Dictionary>(GitConfigurationKeyComparer.Instance);
+ public int SetCallCount { get; private set; }
+ public int UnsetCallCount { get; private set; }
+
#region IGitConfiguration
public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCallback cb)
@@ -68,6 +71,7 @@ public bool TryGet(GitConfigurationLevel level, GitConfigurationType type, strin
public void Set(GitConfigurationLevel level, string name, string value)
{
+ SetCallCount++;
IDictionary> dict = GetDictionary(level);
if (!dict.TryGetValue(name, out var values))
@@ -107,6 +111,7 @@ public void Add(GitConfigurationLevel level, string name, string value)
public void Unset(GitConfigurationLevel level, string name)
{
+ UnsetCallCount++;
IDictionary> dict = GetDictionary(level);
// Simulate git
diff --git a/src/shared/TestInfrastructure/TestInfrastructure.csproj b/src/shared/TestInfrastructure/TestInfrastructure.csproj
index 63f6fee89..9c3e96e5e 100644
--- a/src/shared/TestInfrastructure/TestInfrastructure.csproj
+++ b/src/shared/TestInfrastructure/TestInfrastructure.csproj
@@ -1,7 +1,7 @@
- net8.0
+ net10.0
GitCredentialManager.Tests
false
false
@@ -9,10 +9,10 @@
-
-
-
-
+
+
+
+
diff --git a/src/windows/Installer.Windows/Installer.Windows.csproj b/src/windows/Installer.Windows/Installer.Windows.csproj
index ec678fe5f..b625be44d 100644
--- a/src/windows/Installer.Windows/Installer.Windows.csproj
+++ b/src/windows/Installer.Windows/Installer.Windows.csproj
@@ -14,7 +14,6 @@
false
false
$(PlatformOutPath)Installer.Windows\bin\$(Configuration)\net472\$(RuntimeIdentifier)\
- 6.3.1
false
@@ -29,7 +28,7 @@
-
+
@@ -37,14 +36,14 @@
- "$(NuGetPackageRoot)Tools.InnoSetup\$(InnoSetupVersion)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=system /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)"
- "$(NuGetPackageRoot)Tools.InnoSetup\$(InnoSetupVersion)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=user /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)"
+ "$(PkgTools_InnoSetup)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=system /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)"
+ "$(PkgTools_InnoSetup)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=user /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)"
diff --git a/src/windows/Installer.Windows/layout.ps1 b/src/windows/Installer.Windows/layout.ps1
index 3b1624896..53646764a 100644
--- a/src/windows/Installer.Windows/layout.ps1
+++ b/src/windows/Installer.Windows/layout.ps1
@@ -3,7 +3,10 @@ param ([Parameter(Mandatory)] $Configuration, [Parameter(Mandatory)] $Output, $R
# Trim trailing slashes from output paths
$Output = $Output.TrimEnd('\','/')
-$SymbolOutput = $SymbolOutput.TrimEnd('\','/')
+
+if ($SymbolOutput) {
+ $SymbolOutput = $SymbolOutput.TrimEnd('\','/')
+}
Write-Output "Output: $Output"