From e9dc8a4290a233c944f511634f8cc824c9574039 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Wed, 23 Sep 2026 14:07:06 +0100 Subject: [PATCH 01/17] release: fix VERSION parsing for release Fix the VERSION file parsing for release steps. The VERSION file previously had four parts (x.y.z.w), but we only care about the first three components (x.y.z). Now we moved VERSION to only have 3-parts itself, so the release pipeline does not need to strip the .w part. Signed-off-by: Matthew John Cheetham --- .azure-pipelines/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 68154682b..183bfa79d 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -609,7 +609,7 @@ extends: inputs: targetType: inline script: | - echo "##vso[task.setvariable variable=value;isOutput=true;isReadOnly=true]$(cat ./VERSION | sed -E 's/.[0-9]+$//')" + echo "##vso[task.setvariable variable=value;isOutput=true;isReadOnly=true]$(cat ./VERSION)" - job: github displayName: 'Publish GitHub release' From 4f9d6fe82d563e5b00918976dd863aaf2a87c216 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 25 Sep 2026 09:51:33 +0100 Subject: [PATCH 02/17] build: drop win-x86 (32-bit x86) builds for release and CI With GCM 3.0 on Windows, drop the 32-bit x86 target from being built in CI and release jobs. We still retain the ability to build for win-x86 locally for those that wish to build it themselves. This is similar to the support we have for linux-arm (armhf) - you can built it yourself; we do not publish bits. Signed-off-by: Matthew John Cheetham --- .azure-pipelines/release.yml | 12 ------------ .github/workflows/continuous-integration.yml | 11 +---------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 183bfa79d..34f7a5b6e 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -31,13 +31,6 @@ parameters: - name: windows_matrix type: object default: - - id: windows_x86 - jobName: 'Windows (x86)' - runtime: win-x86 - pool: GitClientPME-1ESHostedPool-intel-pc - poolArch: amd64 - image: win-x86_64-ado1es - os: windows - id: windows_x64 jobName: 'Windows (x64)' runtime: win-x64 @@ -626,9 +619,6 @@ extends: isProduction: true inputs: # Installers and packages - - input: pipelineArtifact - artifactName: 'win-x86' - targetPath: $(Pipeline.Workspace)/assets/win-x86 - input: pipelineArtifact artifactName: 'win-x64' targetPath: $(Pipeline.Workspace)/assets/win-x64 @@ -664,8 +654,6 @@ extends: isDraft: true addChangeLog: false assets: | - $(Pipeline.Workspace)/assets/win-x86/*.exe - $(Pipeline.Workspace)/assets/win-x86/*.zip $(Pipeline.Workspace)/assets/win-x64/*.exe $(Pipeline.Workspace)/assets/win-x64/*.zip $(Pipeline.Workspace)/assets/win-arm64/*.exe diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index e6a589997..b640e6f73 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - runtime: [ win-x86, win-x64, win-arm64 ] + runtime: [ win-x64, win-arm64 ] steps: - uses: actions/checkout@v7 @@ -27,15 +27,6 @@ jobs: with: dotnet-version: 10.0.x - # The x86 test host requires an x86 .NET runtime, which isn't pre-installed - # on the runner, nor can the actions/setup-dotnet action install it. - # Install it manually so tests can run. - - name: Setup .NET (x86) - if: matrix.runtime == 'win-x86' - run: | - Invoke-WebRequest 'https://dot.net/v1/dotnet-install.ps1' -OutFile dotnet-install.ps1 - ./dotnet-install.ps1 -Channel 10.0 -Architecture x86 -InstallDir 'C:\Program Files (x86)\dotnet' - - name: Install dependencies run: dotnet restore From 57ed653a776637132f730bebf0b135cb282b667d Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 25 Sep 2026 10:29:21 +0100 Subject: [PATCH 03/17] README.md: update support matrix for CPU/OS Clarify that only .NET supported Linux distros are supported by GCM, that x86 on Windows no longer carries official builds, and that x64 on macOS is best effort (with Apple dropping Intel support with their next major release; macOS 28). Signed-off-by: Matthew John Cheetham --- README.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 92ab1c7f9..6ad8b4454 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ You can still use Git with SSH - see the specific documentation for your host on how to set up SSH: [Azure DevOps][azure-devops-ssh], [GitHub][github-ssh], [Bitbucket][bitbucket-ssh] -Feature|Windows|macOS|Linux +Feature|Windows|macOS|Linux¹ -|:-:|:-:|:-: Installer/uninstaller|✓|✓|✓ Secure platform [credential storage][gcm-credstores]|✓|✓|✓ @@ -47,10 +47,20 @@ Windows Integrated Authentication (NTLM/Kerberos)|✓|_N/A_|_N/A_ Generic OAuth authentication|✓|✓|✓ Basic HTTP authentication|✓|✓|✓ Network proxies|✓|✓|✓ -`amd64` support|✓|✓|✓ -`x86` support|✓|_N/A_|✗ -`arm64` support|best effort|✓|✓ -`armhf` support|_N/A_|_N/A_|✓ +x64 _(x86_64 / AMD64)_ support|✓|✓²|✓ +x86 _(i686)_ support|✓³|_N/A_|✗ +ARM64 _(aarch64)_ support|✓|✓|✓ +ARM32 _(armhf)_ support|_N/A_|_N/A_|✓³ + +1. Linux support is limited to the + [distributions officially supported by .NET][dotnet-os-support]. + +2. Mac support on Intel-based (x64) devices is best effort. + Apple announced that macOS 28 will [drop support][apple-intel-support] for + Intel-based applications. + +3. No official builds produced, but local builds should be possible. + Support is best effort. ## Supported Environments @@ -183,3 +193,4 @@ When using GitHub logos, please be sure to follow the [roadmap]: https://github.com/git-ecosystem/git-credential-manager/milestones?direction=desc&sort=due_date&state=open [roadmap-announcement]: https://github.com/git-ecosystem/git-credential-manager/discussions/1203 [workflow-status]: https://github.com/git-ecosystem/git-credential-manager/actions/workflows/continuous-integration.yml +[apple-intel-support]: https://support.apple.com/en-us/102527 From 64ba07bdf6a5460299e85196eb0891e1d72b50ef Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 28 Sep 2026 16:23:57 +0100 Subject: [PATCH 04/17] entra: do not use default account on DevBox As of GCM 3.0, the 'UseMsAuthDefaultAccount' setting is now a tri-state flag, which has the following meanings: true => always use the OS account false => never use the OS account unset => ask to use the OS account This means that, on DevBox, where we currently special-case and return 'true' for this setting, the user has no way to sign-in to an Entra-backed provider using a _different_ account than the OS default account. Remove the special-casing of DevBox, and return `null` (unset) so the user continues to see the prompt. Until we can wire-up `continue=1` and retry logic to the Azure Repos provider, we need to make the user experience better for this scenario. Note, there is an obscure workaround: manually bind your Azure DevOps org to a different account - this is not very discoverable. Signed-off-by: Matthew John Cheetham --- src/Core/Settings.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Core/Settings.cs b/src/Core/Settings.cs index ae787ef19..b023fa9d7 100644 --- a/src/Core/Settings.cs +++ b/src/Core/Settings.cs @@ -819,11 +819,6 @@ public bool? UseMsAuthDefaultAccount return str.ToBooleany(); } - if (PlatformUtils.IsDevBox()) - { - return true; - } - return null; } } From 000a1757298d8ec4615ab529f90750ace487344c Mon Sep 17 00:00:00 2001 From: Marc Becker Date: Sat, 26 Sep 2026 22:35:03 +0200 Subject: [PATCH 05/17] protocol: add and wire 'authtype' capability add and register capability flag for 'authtype' create credential output based on Authorization type and support define and use Constants for credential protocol keys move default arguments for GitResponse flags to internal constructor --- .../Commands/CapabilityCommandTests.cs | 2 +- src/Core.Tests/Commands/GetCommandTests.cs | 56 ++++++++++++++++++- src/Core/Commands/GetCommand.cs | 20 +++++-- src/Core/Commands/GitCommandBase.cs | 2 +- src/Core/Commands/StoreCommand.cs | 10 ++++ src/Core/Constants.cs | 25 ++++++++- src/Core/GitCapabilities.cs | 14 ++++- src/Core/GitRequest.cs | 8 ++- src/Core/GitResponse.cs | 22 +++++--- 9 files changed, 139 insertions(+), 20 deletions(-) diff --git a/src/Core.Tests/Commands/CapabilityCommandTests.cs b/src/Core.Tests/Commands/CapabilityCommandTests.cs index 9a90eb58b..3b2ef862c 100644 --- a/src/Core.Tests/Commands/CapabilityCommandTests.cs +++ b/src/Core.Tests/Commands/CapabilityCommandTests.cs @@ -22,7 +22,7 @@ public void CapabilityCommand_Execute_WritesVersionAndAdvertisedCapabilities() Assert.StartsWith("version 0\n", actualOutput); // GCM advertises the state capability. - Assert.Equal("version 0\ncapability state\n", actualOutput); + Assert.Equal("version 0\ncapability state\ncapability authtype\n", actualOutput); } [Fact] diff --git a/src/Core.Tests/Commands/GetCommandTests.cs b/src/Core.Tests/Commands/GetCommandTests.cs index 3878e5b40..be974b1a1 100644 --- a/src/Core.Tests/Commands/GetCommandTests.cs +++ b/src/Core.Tests/Commands/GetCommandTests.cs @@ -122,7 +122,7 @@ public async Task GetCommand_ExecuteAsync_NegotiatedCapability_EchoesIntersectio string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n"); Assert.Contains("capability[]=state\n", actualOutput); - Assert.DoesNotContain("capability[]=authtype", actualOutput); + Assert.Contains("capability[]=authtype\n", actualOutput); } [Fact] @@ -328,6 +328,60 @@ public async Task GetCommand_ExecuteAsync_OutputOrdering_CapabilitiesFirstThenSc "state[] must follow continue=1"); } + [Fact] + public async Task GetCommand_ExecuteAsync_CapabilityAuthType_MissingRevertsToPlainPassword() + { + ICredential testCredential = new GitCredential("alice", "hunter2"); + var response = GitResponse.Ok(testCredential, authtype: "token"); + + var stdin = "protocol=https\nhost=example.com\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(response); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string[] actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n").Split('\n'); + + // Emits regular Credential without 'state[]=authtype' support. + Assert.Contains("username=alice", actualOutput); + Assert.Contains("password=hunter2", actualOutput); + Assert.DoesNotContain("authtype=token", actualOutput); + Assert.DoesNotContain("credential=hunter2", actualOutput); + } + + [Fact] + public async Task GetCommand_ExecuteAsync_CapabilityAuthType_UsesAuthTypeFeatures() + { + ICredential testCredential = new GitCredential("alice", "hunter2"); + var response = GitResponse.Ok(testCredential, authtype: "token"); + + var stdin = "protocol=https\nhost=example.com\ncapability[]=authtype\n\n"; + + var providerMock = new Mock(); + providerMock.Setup(x => x.GetCredentialAsync(It.IsAny())) + .ReturnsAsync(response); + var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object }; + var context = new TestCommandContext { Streams = { In = stdin } }; + + var command = new GetCommand(context, providerRegistry); + + await command.ExecuteAsync(); + + string[] actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n").Split('\n'); + + // Ephemeral credential with 'authtype' and 'credential' value + Assert.DoesNotContain("username=alice", actualOutput); + Assert.DoesNotContain("password=hunter2", actualOutput); + Assert.Contains("authtype=token", actualOutput); + Assert.Contains("credential=hunter2", actualOutput); + } + #region Helpers private static IDictionary ParseDictionary(StringBuilder sb) => ParseDictionary(sb.ToString()); diff --git a/src/Core/Commands/GetCommand.cs b/src/Core/Commands/GetCommand.cs index 551e1feac..5b22d7a8a 100644 --- a/src/Core/Commands/GetCommand.cs +++ b/src/Core/Commands/GetCommand.cs @@ -59,6 +59,7 @@ protected override async Task ExecuteInternalAsync(GitRequest request, IHostProv // Negotiate capabilities by intersecting what Git advertised with what GCM supports. // Capability-gated output fields may only be emitted for capabilities in this set. GitCapabilities negotiated = request.Capabilities & Constants.SupportedCapabilities; + bool authTypeCapNegotiated = (negotiated & GitCapabilities.AuthType) != 0; bool stateCapNegotiated = (negotiated & GitCapabilities.State) != 0; Context.Trace.WriteLine($"Git capability: {request.Capabilities}"); @@ -98,10 +99,21 @@ protected override async Task ExecuteInternalAsync(GitRequest request, IHostProv // // Credential // - stdout.WriteLine($"username={credential.Account}"); - Context.Trace.WriteLine($"\tusername={credential.Account}"); - stdout.WriteLine($"password={credential.Password}"); - Context.Trace.WriteLineSecrets("\tpassword={0}", new object[] { credential.Password }); + var authtype = response.AuthType; + if (authTypeCapNegotiated && authtype is not null) + { + stdout.WriteLine($"{Constants.CredentialProtocol.AuthTypeKey}={authtype}"); + Context.Trace.WriteLine($"\t{Constants.CredentialProtocol.AuthTypeKey}={authtype}"); + stdout.WriteLine($"{Constants.CredentialProtocol.CredentialKey}={credential.Password}"); + Context.Trace.WriteLineSecrets("\t" + Constants.CredentialProtocol.CredentialKey + "={0}", [credential.Password]); + } + else + { + stdout.WriteLine($"{Constants.CredentialProtocol.UserNameKey}={credential.Account}"); + Context.Trace.WriteLine($"\t{Constants.CredentialProtocol.UserNameKey}={credential.Account}"); + stdout.WriteLine($"{Constants.CredentialProtocol.PasswordKey}={credential.Password}"); + Context.Trace.WriteLineSecrets("\t" + Constants.CredentialProtocol.PasswordKey + "={0}", [ credential.Password ]); + } // // Custom additional properties diff --git a/src/Core/Commands/GitCommandBase.cs b/src/Core/Commands/GitCommandBase.cs index 1dfc398a9..fcb0a0d79 100644 --- a/src/Core/Commands/GitCommandBase.cs +++ b/src/Core/Commands/GitCommandBase.cs @@ -51,7 +51,7 @@ internal async Task ExecuteAsync() // Determine the host provider Context.Trace.WriteLine("Detecting host provider for request:"); - Context.Trace.WriteDictionarySecrets(inputDict, new []{ "password" }, StringComparer.OrdinalIgnoreCase); + Context.Trace.WriteDictionarySecrets(inputDict, [ "password", "credential" ], StringComparer.OrdinalIgnoreCase); IHostProvider provider; using (Trace2.StartRegion("git_cmd", "resolve_provider")) { diff --git a/src/Core/Commands/StoreCommand.cs b/src/Core/Commands/StoreCommand.cs index 09043ff74..dcc100c98 100644 --- a/src/Core/Commands/StoreCommand.cs +++ b/src/Core/Commands/StoreCommand.cs @@ -24,6 +24,16 @@ protected override void EnsureMinimumRequest(GitRequest request) { base.EnsureMinimumRequest(request); + // When "authtype" is set, Git uses "credential" field for the secret + if (request.AuthType is not null) + { + if (request.Credential is null) + { + throw new InvalidOperationException("Missing 'credential' request argument"); + } + return; + } + // An empty string username/password are valid inputs, so only check for `null` (not provided) if (request.UserName is null) { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index cff7a1a1b..826f8cf55 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -36,7 +36,7 @@ public static class Constants /// /// The set of Git credential protocol capabilities supported by Git Credential Manager. /// - public const GitCapabilities SupportedCapabilities = GitCapabilities.State; + public const GitCapabilities SupportedCapabilities = GitCapabilities.State | GitCapabilities.AuthType; public static class CredentialProtocol { @@ -57,6 +57,29 @@ public static class CredentialProtocol /// public const string ContinueKey = "continue"; + /// + /// The Git credential protocol attribute name carrying alternative + /// HTTP Authorization scheme type. + /// (string, gated by the authtype capability). + /// + public const string AuthTypeKey = "authtype"; + + /// + /// The Git credential protocol attribute name carrying raw credential data. + /// (string, gated by the authtype capability). + /// + public const string CredentialKey = "credential"; + + /// + /// The Git credential protocol attribute name for password. + /// + public const string PasswordKey = "password"; + + /// + /// The Git credential protocol attribute name for username. + /// + public const string UserNameKey = "username"; + /// /// Prefix that Git Credential Manager reserves on every state[] entry /// so its state can be distinguished from other helpers' state per diff --git a/src/Core/GitCapabilities.cs b/src/Core/GitCapabilities.cs index 2232abbbf..82de5a779 100644 --- a/src/Core/GitCapabilities.cs +++ b/src/Core/GitCapabilities.cs @@ -38,6 +38,14 @@ public enum GitCapabilities /// flag that signals a non-final authentication step is expected. /// State = 1 << 0, + + /// + /// The authtype, credential, and ephemeral attributes are understood. + /// + /// + /// Provides alternative formats for HTTP Authorization header. + /// + AuthType = 1 << 1, } /// @@ -66,7 +74,8 @@ public static GitCapabilities ParseName(string name) // handling is implemented. return name.ToLowerInvariant() switch { - "state" => GitCapabilities.State, + Constants.CredentialProtocol.StateKey => GitCapabilities.State, + Constants.CredentialProtocol.AuthTypeKey => GitCapabilities.AuthType, _ => GitCapabilities.None, }; } @@ -89,7 +98,8 @@ public static string ToProtocolName(GitCapabilities capability) // protocol name distinct from its .NET enum name). return capability switch { - GitCapabilities.State => "state", + GitCapabilities.State => Constants.CredentialProtocol.StateKey, + GitCapabilities.AuthType => Constants.CredentialProtocol.AuthTypeKey, GitCapabilities.None => throw new ArgumentException( "Cannot render the None capability to a protocol name.", nameof(capability)), diff --git a/src/Core/GitRequest.cs b/src/Core/GitRequest.cs index d742650da..fbc73da3e 100644 --- a/src/Core/GitRequest.cs +++ b/src/Core/GitRequest.cs @@ -47,8 +47,12 @@ public GitRequest(IDictionary> dict) public string Protocol => GetArgumentOrDefault("protocol"); public string Host => GetArgumentOrDefault("host"); public string Path => GetArgumentOrDefault("path"); - public string UserName => GetArgumentOrDefault("username"); - public string Password => GetArgumentOrDefault("password"); + + public string AuthType => GetArgumentOrDefault(Constants.CredentialProtocol.AuthTypeKey); + public string Credential => GetArgumentOrDefault(Constants.CredentialProtocol.CredentialKey); + public string UserName => GetArgumentOrDefault(Constants.CredentialProtocol.UserNameKey); + public string Password => GetArgumentOrDefault(Constants.CredentialProtocol.PasswordKey); + public IList WwwAuth => GetMultiArgumentOrDefault("wwwauth"); /// diff --git a/src/Core/GitResponse.cs b/src/Core/GitResponse.cs index b7baaf3cb..37893af5d 100644 --- a/src/Core/GitResponse.cs +++ b/src/Core/GitResponse.cs @@ -43,7 +43,7 @@ public class GitResponse private readonly Dictionary _state = new(StringComparer.Ordinal); private ReadOnlyDictionary _stateView; - private GitResponse(ICredential credential, bool isContinue, bool isCancelled, bool isYielded) + private GitResponse(ICredential credential, string authType = null, bool isContinue = false, bool isCancelled = false, bool isYielded = false) { // At most one of Continue, Cancel, Yield may be set (Ok is "none of them"). if ((isContinue && isCancelled) || @@ -71,6 +71,7 @@ private GitResponse(ICredential credential, bool isContinue, bool isCancelled, b } Credential = credential; + AuthType = authType; IsContinue = isContinue; IsCancelled = isCancelled; IsYielded = isYielded; @@ -81,15 +82,15 @@ private GitResponse(ICredential credential, bool isContinue, bool isCancelled, b /// /// Equivalent to . public GitResponse(ICredential credential) - : this(credential, isContinue: false, isCancelled: false, isYielded: false) + : this(credential, authType: null) { } /// /// Construct a successful response carrying the given credential. /// - public static GitResponse Ok(ICredential credential) => - new GitResponse(credential, isContinue: false, isCancelled: false, isYielded: false); + public static GitResponse Ok(ICredential credential, string authtype = null) => + new(credential, authType: authtype); /// /// Construct a successful response carrying the given credential and @@ -101,8 +102,8 @@ public static GitResponse Ok(ICredential credential) => /// multistage HTTP authentication (NTLM/Kerberos) and any flow where the /// helper wants to be invoked again after the next server response. /// - public static GitResponse Continue(ICredential credential) => - new GitResponse(credential, isContinue: true, isCancelled: false, isYielded: false); + public static GitResponse Continue(ICredential credential, string authType = null) => + new (credential, authType: authType, isContinue: true); /// /// Construct a cancellation response: the provider declined to produce a @@ -117,7 +118,7 @@ public static GitResponse Continue(ICredential credential) => /// cancelled response are ignored. /// public static GitResponse Cancel() => - new GitResponse(credential: null, isContinue: false, isCancelled: true, isYielded: false); + new GitResponse(credential: null, isCancelled: true); /// /// Construct a yielded response: the provider has nothing to contribute @@ -132,7 +133,7 @@ public static GitResponse Cancel() => /// set on a yielded response are ignored. /// public static GitResponse Yield() => - new GitResponse(credential: null, isContinue: false, isCancelled: false, isYielded: true); + new GitResponse(credential: null, isYielded: true); /// /// The credential resolved or generated for the request, or @@ -140,6 +141,11 @@ public static GitResponse Yield() => /// public ICredential Credential { get; } + /// + /// The special Autorization type for the supplied credential. + /// + public string AuthType { get; } + /// /// when the provider expects a further round of /// authentication. The command layer translates this into a continue=1 From 36f46247274d624e9978debb5bd9f5af52241cc6 Mon Sep 17 00:00:00 2001 From: Marc Becker Date: Wed, 30 Sep 2026 20:36:47 +0200 Subject: [PATCH 06/17] credential: add flag for ephemeral response state emit 'ephemeral' value in GitResponse if set and capability is present check 'ephemeral' setting in GitRequest content --- src/Core.Tests/Commands/GetCommandTests.cs | 6 ++++-- src/Core/Commands/GetCommand.cs | 5 +++++ src/Core/Constants.cs | 7 +++++++ src/Core/GitRequest.cs | 1 + src/Core/GitResponse.cs | 17 ++++++++++++----- 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/Core.Tests/Commands/GetCommandTests.cs b/src/Core.Tests/Commands/GetCommandTests.cs index be974b1a1..5993487a4 100644 --- a/src/Core.Tests/Commands/GetCommandTests.cs +++ b/src/Core.Tests/Commands/GetCommandTests.cs @@ -332,7 +332,7 @@ public async Task GetCommand_ExecuteAsync_OutputOrdering_CapabilitiesFirstThenSc public async Task GetCommand_ExecuteAsync_CapabilityAuthType_MissingRevertsToPlainPassword() { ICredential testCredential = new GitCredential("alice", "hunter2"); - var response = GitResponse.Ok(testCredential, authtype: "token"); + var response = GitResponse.Ok(testCredential, isEphemeral: true, authtype: "token"); var stdin = "protocol=https\nhost=example.com\n\n"; @@ -353,13 +353,14 @@ public async Task GetCommand_ExecuteAsync_CapabilityAuthType_MissingRevertsToPla Assert.Contains("password=hunter2", actualOutput); Assert.DoesNotContain("authtype=token", actualOutput); Assert.DoesNotContain("credential=hunter2", actualOutput); + Assert.DoesNotContain("ephemeral=1", actualOutput); } [Fact] public async Task GetCommand_ExecuteAsync_CapabilityAuthType_UsesAuthTypeFeatures() { ICredential testCredential = new GitCredential("alice", "hunter2"); - var response = GitResponse.Ok(testCredential, authtype: "token"); + var response = GitResponse.Ok(testCredential, isEphemeral: true, authtype: "token"); var stdin = "protocol=https\nhost=example.com\ncapability[]=authtype\n\n"; @@ -380,6 +381,7 @@ public async Task GetCommand_ExecuteAsync_CapabilityAuthType_UsesAuthTypeFeature Assert.DoesNotContain("password=hunter2", actualOutput); Assert.Contains("authtype=token", actualOutput); Assert.Contains("credential=hunter2", actualOutput); + Assert.Contains("ephemeral=1", actualOutput); } #region Helpers diff --git a/src/Core/Commands/GetCommand.cs b/src/Core/Commands/GetCommand.cs index 5b22d7a8a..6ac5a0756 100644 --- a/src/Core/Commands/GetCommand.cs +++ b/src/Core/Commands/GetCommand.cs @@ -114,6 +114,11 @@ protected override async Task ExecuteInternalAsync(GitRequest request, IHostProv stdout.WriteLine($"{Constants.CredentialProtocol.PasswordKey}={credential.Password}"); Context.Trace.WriteLineSecrets("\t" + Constants.CredentialProtocol.PasswordKey + "={0}", [ credential.Password ]); } + if (authTypeCapNegotiated && response.IsCredentialEphemeral) + { + stdout.WriteLine($"{Constants.CredentialProtocol.EphemeralKey}=1"); + Context.Trace.WriteLine($"\t{Constants.CredentialProtocol.EphemeralKey}=1"); + } // // Custom additional properties diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 826f8cf55..f2409b0ab 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -70,6 +70,13 @@ public static class CredentialProtocol /// public const string CredentialKey = "credential"; + /// + /// The Git credential protocol attribute name carrying setting + /// for ephemeral credential type. + /// (string, gated by the authtype capability). + /// + public const string EphemeralKey = "ephemeral"; + /// /// The Git credential protocol attribute name for password. /// diff --git a/src/Core/GitRequest.cs b/src/Core/GitRequest.cs index fbc73da3e..fac9c37ec 100644 --- a/src/Core/GitRequest.cs +++ b/src/Core/GitRequest.cs @@ -52,6 +52,7 @@ public GitRequest(IDictionary> dict) public string Credential => GetArgumentOrDefault(Constants.CredentialProtocol.CredentialKey); public string UserName => GetArgumentOrDefault(Constants.CredentialProtocol.UserNameKey); public string Password => GetArgumentOrDefault(Constants.CredentialProtocol.PasswordKey); + public bool IsEphemeral => StringExtensions.IsTruthy(GetArgumentOrDefault(Constants.CredentialProtocol.EphemeralKey)); public IList WwwAuth => GetMultiArgumentOrDefault("wwwauth"); diff --git a/src/Core/GitResponse.cs b/src/Core/GitResponse.cs index 37893af5d..caaf1b54e 100644 --- a/src/Core/GitResponse.cs +++ b/src/Core/GitResponse.cs @@ -43,7 +43,7 @@ public class GitResponse private readonly Dictionary _state = new(StringComparer.Ordinal); private ReadOnlyDictionary _stateView; - private GitResponse(ICredential credential, string authType = null, bool isContinue = false, bool isCancelled = false, bool isYielded = false) + private GitResponse(ICredential credential, bool isEphemeral = false, string authType = null, bool isContinue = false, bool isCancelled = false, bool isYielded = false) { // At most one of Continue, Cancel, Yield may be set (Ok is "none of them"). if ((isContinue && isCancelled) || @@ -71,7 +71,9 @@ private GitResponse(ICredential credential, string authType = null, bool isConti } Credential = credential; + IsCredentialEphemeral = isEphemeral; AuthType = authType; + IsContinue = isContinue; IsCancelled = isCancelled; IsYielded = isYielded; @@ -89,8 +91,8 @@ public GitResponse(ICredential credential) /// /// Construct a successful response carrying the given credential. /// - public static GitResponse Ok(ICredential credential, string authtype = null) => - new(credential, authType: authtype); + public static GitResponse Ok(ICredential credential, bool isEphemeral = false, string authtype = null) => + new(credential, isEphemeral: isEphemeral, authType: authtype); /// /// Construct a successful response carrying the given credential and @@ -102,8 +104,8 @@ public static GitResponse Ok(ICredential credential, string authtype = null) => /// multistage HTTP authentication (NTLM/Kerberos) and any flow where the /// helper wants to be invoked again after the next server response. /// - public static GitResponse Continue(ICredential credential, string authType = null) => - new (credential, authType: authType, isContinue: true); + public static GitResponse Continue(ICredential credential, bool isEphemeral = false, string authType = null) => + new (credential, isEphemeral: isEphemeral, authType: authType, isContinue: true); /// /// Construct a cancellation response: the provider declined to produce a @@ -141,6 +143,11 @@ public static GitResponse Yield() => /// public ICredential Credential { get; } + /// + /// The credential is ephemeral (or generated) and not to be stored on success. + /// + public bool IsCredentialEphemeral { get; } + /// /// The special Autorization type for the supplied credential. /// From a091842e6200cca938df97fb3750d4dd48008951 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 1 Oct 2026 10:46:50 +0100 Subject: [PATCH 07/17] docs: update WSL docs to use correct GfW install paths Different versions of Git for Windows have different installation path prefixes; our existing docs only reference the mingw64 toolchain path. Update the docs to call out the different installation paths for x64 (UCRT64 and MINGW64), and ARM64 (CLANGARM64). Signed-off-by: Matthew John Cheetham --- docs/wsl.md | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/docs/wsl.md b/docs/wsl.md index cbcd6b384..c37b4c234 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -21,20 +21,46 @@ Start by installing the [latest Git for Windows ⬇️][latest-git-for-windows] _Inside your WSL installation_, run the following command to set GCM as the Git credential helper: +> [!IMPORTANT] +> The exact location of git-credential-manager.exe may be different in your +> installation of Git for Windows depending on the version you have installed. +> Please check and confirm the actual installation path before proceeding. + +### Git for Windows (x64 / x86_64 / "64-bit") + +The latest versions of Git for Windows for x64 (v2.56.0 and later) use the +`ucrt64` toolchain and installation path: + +```shell +git config --global credential.helper "/mnt/c/Program\ Files/Git/ucrt64/bin/git-credential-manager.exe" +``` + +Older versions of Git for Windows for x64 (v2.55.x and earlier) use the +`mingw64` toolchain and installation path: + ```shell git config --global credential.helper "/mnt/c/Program\ Files/Git/mingw64/bin/git-credential-manager.exe" ``` -> **Note:** the location of git-credential-manager.exe may be different in your -installation of Git for Windows. +### Git for Windows (ARM64) -If you intend to use Azure DevOps you must _also_ set the following Git -configuration _inside of your WSL installation_. +All known versions of Git for Windows for ARM64 use the `clangarm64` toolchain +and installation path: ```shell -git config --global credential.https://dev.azure.com.useHttpPath true +git config --global credential.helper "/mnt/c/Program\ Files/Git/clangarm64/bin/git-credential-manager.exe" ``` +## Azure DevOps Additional Configuration + +> [!IMPORTANT] +> If you intend to use Azure DevOps you must _also_ set the following Git +> configuration _inside of your WSL installation_. +> +> ```shell +> git config --global credential.https://dev.azure.com.useHttpPath true +> ``` + ## Configuring WSL without Git for Windows If you wish to use GCM inside of WSL _without installing Git for Windows_ From 917c8244794c37412c556e1fab36a79b87df68f6 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 1 Oct 2026 11:05:30 +0100 Subject: [PATCH 08/17] docs: fix WSL without GfW paths The file paths for using GCM in WSL _without_ Git for Windows are only correct for the old x86 GCM installations. Update the docs to call out the x64 and ARM64 paths too, first. Signed-off-by: Matthew John Cheetham --- docs/wsl.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/wsl.md b/docs/wsl.md index c37b4c234..dd6c80d3e 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -73,21 +73,27 @@ _Inside your WSL installation_, run the following command to set GCM as the Git credential helper: ```shell +# For x64 or ARM64 installations of Git Credential Manager +git config --global credential.helper "/mnt/c/Program\ Files/Git\ Credential\ Manager/git-credential-manager.exe" + +# For x86 installations of Git Credential Manager git config --global credential.helper "/mnt/c/Program\ Files\ \(x86\)/Git\ Credential\ Manager/git-credential-manager.exe" # For Azure DevOps support only git config --global credential.https://dev.azure.com.useHttpPath true ``` -In **_Windows_** you need to update the `WSLENV` environment variable to include -the value `GIT_EXEC_PATH/wp`. From an _Administrator_ Command Prompt run the -following: - -```batch -SETX WSLENV %WSLENV%:GIT_EXEC_PATH/wp -``` - -After updating the `WSLENV` environment variable, restart your WSL installation. +> [!IMPORTANT] +> In **_Windows_** you need to update the `WSLENV` environment variable to +> include the value `GIT_EXEC_PATH/wp`. From an _Administrator_ Command Prompt +> run the following: +> +> ```batch +> SETX WSLENV %WSLENV%:GIT_EXEC_PATH/wp +> ``` +> +> After updating the `WSLENV` environment variable, **restart your WSL +> installation**. ### Using the user-only GCM installer? From 5bc1c0916e10f7cdd399b03f132149f96d0fbbbb Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 5 Oct 2026 14:07:49 +0100 Subject: [PATCH 09/17] msal: ensure we only parent to visible console windows Ensure that we only consider a console window (or hosting terminal in case of ConPTY) a valid parent for MSAL windows if it is visible. This will prevent MSAL windows from being hidden too. Note that we do **not** apply the visibility check to the optional explicitly provided parent window (via `GCM_MODEL_PARENTHWND)` - the caller should be the one to decide if their handle is good to use or not. Finally, note that we do **not** check for a minimised parent window (via `IsIconic(HWND)`) because we should probably also remain minimised if our parent was. Signed-off-by: Matthew John Cheetham --- src/Core/Authentication/Entra/MsalAdaptors.cs | 11 ++++++++++- src/Core/Interop/Windows/Native/User32.cs | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Core/Authentication/Entra/MsalAdaptors.cs b/src/Core/Authentication/Entra/MsalAdaptors.cs index 33997fc5e..cce41a5f2 100644 --- a/src/Core/Authentication/Entra/MsalAdaptors.cs +++ b/src/Core/Authentication/Entra/MsalAdaptors.cs @@ -59,9 +59,18 @@ public object GetWindow() if (PlatformUtils.IsWindows()) { IntPtr consoleHandle = Kernel32.GetConsoleWindow(); + + // When the parent is using ConPTY (pseudo-terminals) the console + // window may be a fake/stub (as is the case with Windows Terminal). + // This means we need to walk up the ancestor chain to get the actual + // root owner (typically the terminal emulator's window). IntPtr parentHandle = User32.GetAncestor(consoleHandle, GetAncestorFlags.GetRootOwner); - if (parentHandle != IntPtr.Zero) + // Only use the parent handle if it is valid and visible! + // Note that minimised windows are still 'visibile', and to filter + // those out we'd need to call IsIconic(HWND), but we're happy to + // parent to a minimised parent window and be minimised ourself. + if (parentHandle != IntPtr.Zero && User32.IsWindowVisible(parentHandle)) { return parentHandle; } diff --git a/src/Core/Interop/Windows/Native/User32.cs b/src/Core/Interop/Windows/Native/User32.cs index 8cf05a8d8..7745a10db 100644 --- a/src/Core/Interop/Windows/Native/User32.cs +++ b/src/Core/Interop/Windows/Native/User32.cs @@ -36,6 +36,9 @@ public static IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr value) [DllImport(LibraryName, SetLastError = true)] public static extern bool GetClientRect(IntPtr hwnd, out RECT lpRect); + [DllImport(LibraryName)] + public static extern bool IsWindowVisible(IntPtr hwnd); + /// /// Retrieves the handle to the ancestor of the specified window. /// From a7a2d47f5102eb710594693106b7b3bcb13ad115 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 5 Oct 2026 14:14:09 +0100 Subject: [PATCH 10/17] msal: extract console parent to a method Extract the console parent lookup logic to a static method in preparation for allowing tests to mock it. Signed-off-by: Matthew John Cheetham --- src/Core/Authentication/Entra/MsalAdaptors.cs | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src/Core/Authentication/Entra/MsalAdaptors.cs b/src/Core/Authentication/Entra/MsalAdaptors.cs index cce41a5f2..fc917bc4a 100644 --- a/src/Core/Authentication/Entra/MsalAdaptors.cs +++ b/src/Core/Authentication/Entra/MsalAdaptors.cs @@ -55,30 +55,34 @@ public object GetWindow() return ProgressWindow.ShowAndGetHandle(_cts.Token); } - // On Windows we can try and get the console window parent handle if that exists - if (PlatformUtils.IsWindows()) + // See if we can use the console window as a parent. + // We only consider the window if it is valid and visible. + IntPtr consoleParent = GetConsoleParentWindow(); + if (consoleParent != IntPtr.Zero && User32.IsWindowVisible(consoleParent)) { - IntPtr consoleHandle = Kernel32.GetConsoleWindow(); - - // When the parent is using ConPTY (pseudo-terminals) the console - // window may be a fake/stub (as is the case with Windows Terminal). - // This means we need to walk up the ancestor chain to get the actual - // root owner (typically the terminal emulator's window). - IntPtr parentHandle = User32.GetAncestor(consoleHandle, GetAncestorFlags.GetRootOwner); - - // Only use the parent handle if it is valid and visible! - // Note that minimised windows are still 'visibile', and to filter - // those out we'd need to call IsIconic(HWND), but we're happy to - // parent to a minimised parent window and be minimised ourself. - if (parentHandle != IntPtr.Zero && User32.IsWindowVisible(parentHandle)) - { - return parentHandle; - } + return consoleParent; } return null; } + private static IntPtr GetConsoleParentWindow() + { + // On Windows we can try and get the console window parent handle if that exists + if (!PlatformUtils.IsWindows()) + { + return IntPtr.Zero; + } + + IntPtr consoleHandle = Kernel32.GetConsoleWindow(); + + // When the parent is using ConPTY (pseudo-terminals) the console + // window may be a fake/stub (as is the case with Windows Terminal). + // This means we need to walk up the ancestor chain to get the actual + // root owner (typically the terminal emulator's window). + return User32.GetAncestor(consoleHandle, GetAncestorFlags.GetRootOwner); + } + public void Dispose() { // Close and clean up any stub window we may have created From d997195c06de8bcadaeff1030957df0c4447b380 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 5 Oct 2026 14:20:05 +0100 Subject: [PATCH 11/17] msal: allow testing of MsalParentWindowAdatper Allow testing of the parent window adapter by allowing mocks of the core platform-specific APIs: * creation of stub window * window visibilty checking * get console window Signed-off-by: Matthew John Cheetham --- src/Core/Authentication/Entra/MsalAdaptors.cs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/Core/Authentication/Entra/MsalAdaptors.cs b/src/Core/Authentication/Entra/MsalAdaptors.cs index fc917bc4a..48393353b 100644 --- a/src/Core/Authentication/Entra/MsalAdaptors.cs +++ b/src/Core/Authentication/Entra/MsalAdaptors.cs @@ -31,15 +31,30 @@ internal class MsalParentWindowAdapter : IDisposable private readonly bool _createIfMissing; private readonly CancellationTokenSource _cts = new(); + private readonly Func _getConsoleParentWindow; + private readonly Func _isWindowVisible; + private readonly Func _createWindow; + public static MsalParentWindowAdapter Create(object parentWindow, bool createIfMissing = false) { - return new MsalParentWindowAdapter(parentWindow, createIfMissing); + return new MsalParentWindowAdapter(parentWindow, createIfMissing, + GetConsoleParentWindow, User32.IsWindowVisible, ProgressWindow.ShowAndGetHandle); } - private MsalParentWindowAdapter(object parentWindow, bool createIfMissing = false) + internal MsalParentWindowAdapter(object parentWindow, bool createIfMissing, + Func getConsoleParentWindow, Func isWindowVisible, + Func createWindow) { + EnsureArgument.NotNull(getConsoleParentWindow, nameof(getConsoleParentWindow)); + EnsureArgument.NotNull(isWindowVisible, nameof(isWindowVisible)); + EnsureArgument.NotNull(createWindow, nameof(createWindow)); + _parentWindow = parentWindow; _createIfMissing = createIfMissing; + + _getConsoleParentWindow = getConsoleParentWindow; + _isWindowVisible = isWindowVisible; + _createWindow = createWindow; } public object GetWindow() @@ -52,13 +67,13 @@ public object GetWindow() // Create a stub window to use as a parent if (_createIfMissing) { - return ProgressWindow.ShowAndGetHandle(_cts.Token); + return _createWindow(_cts.Token); } // See if we can use the console window as a parent. // We only consider the window if it is valid and visible. - IntPtr consoleParent = GetConsoleParentWindow(); - if (consoleParent != IntPtr.Zero && User32.IsWindowVisible(consoleParent)) + IntPtr consoleParent = _getConsoleParentWindow(); + if (consoleParent != IntPtr.Zero && _isWindowVisible(consoleParent)) { return consoleParent; } From 261bdd39b9fa29188d674a514009def869129e8c Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 5 Oct 2026 14:21:55 +0100 Subject: [PATCH 12/17] msal: prefer the console window over progress stub In previous versions of GCM (2.x) we preferred to parent MSAL windows to the console window, if possible, over creating our own stub 'progress' window. In GCM 3.0.0 we accidentally reversed that preference, which means in practice we always used the stub window and never the console window. Let's reverse that back to the desired order: 1. Explicit parent (via `GCM_MODAL_PARENTHWND`) 2. Console window parent (if present and visible) 3. Stub progress window (if required by the caller) 4. None Signed-off-by: Matthew John Cheetham --- src/Core/Authentication/Entra/MsalAdaptors.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Core/Authentication/Entra/MsalAdaptors.cs b/src/Core/Authentication/Entra/MsalAdaptors.cs index 48393353b..e169ee838 100644 --- a/src/Core/Authentication/Entra/MsalAdaptors.cs +++ b/src/Core/Authentication/Entra/MsalAdaptors.cs @@ -64,12 +64,6 @@ public object GetWindow() return _parentWindow; } - // Create a stub window to use as a parent - if (_createIfMissing) - { - return _createWindow(_cts.Token); - } - // See if we can use the console window as a parent. // We only consider the window if it is valid and visible. IntPtr consoleParent = _getConsoleParentWindow(); @@ -78,6 +72,12 @@ public object GetWindow() return consoleParent; } + // Create a stub window to use as a parent + if (_createIfMissing) + { + return _createWindow(_cts.Token); + } + return null; } From 6f4fedcdbd3d663e131318fe6a745330cc12372c Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 5 Oct 2026 14:24:17 +0100 Subject: [PATCH 13/17] msal: add tests of the parent window adapter Add comprehensive tests of the MSAL parent window adapter, and the precedence logic for selecting (and creating) an appropriate parent window. Signed-off-by: Matthew John Cheetham --- .../Entra/MsalParentWindowAdapterTests.cs | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 src/Core.Tests/Authentication/Entra/MsalParentWindowAdapterTests.cs diff --git a/src/Core.Tests/Authentication/Entra/MsalParentWindowAdapterTests.cs b/src/Core.Tests/Authentication/Entra/MsalParentWindowAdapterTests.cs new file mode 100644 index 000000000..831ae489f --- /dev/null +++ b/src/Core.Tests/Authentication/Entra/MsalParentWindowAdapterTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Threading; +using GitCredentialManager.Authentication.Entra; +using Xunit; + +namespace GitCredentialManager.Tests.Authentication.Entra; + +public class MsalParentWindowAdapterTests +{ + [Theory] + [InlineData(42, false)] + [InlineData(42, true)] + [InlineData(-1, false)] + [InlineData(-1, true)] + public void GetWindow_ExplicitParent_TakesPrecedence(int parentValue, bool createIfMissing) + { + object parentWindow = new IntPtr(parentValue); + using var adapter = new MsalParentWindowAdapter(parentWindow, createIfMissing, + () => throw new InvalidOperationException("Console lookup should not be called."), + _ => throw new InvalidOperationException("Visibility should not be checked."), + _ => throw new InvalidOperationException("A progress window should not be created.")); + + object result = adapter.GetWindow(); + Assert.Same(parentWindow, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GetWindow_VisibleConsoleRoot_PrecedesProgressWindow(bool createIfMissing) + { + var consoleRoot = new IntPtr(42); + bool visibilityChecked = false; + using var adapter = new MsalParentWindowAdapter(IntPtr.Zero, createIfMissing, + () => consoleRoot, + hwnd => + { + Assert.Equal(consoleRoot, hwnd); + visibilityChecked = true; + return true; + }, + _ => throw new InvalidOperationException("A progress window should not be created.")); + + IntPtr result = Assert.IsType(adapter.GetWindow()); + + Assert.Equal(consoleRoot, result); + Assert.True(visibilityChecked); + } + + [Theory] + [InlineData(0)] + [InlineData(42)] + public void GetWindow_NoVisibleConsoleRoot_CreatesProgressWhenRequired(int consoleRootValue) + { + var consoleRoot = new IntPtr(consoleRootValue); + var progressWindow = new IntPtr(84); + int visibilityChecks = 0; + var windowToken = CancellationToken.None; + using (var adapter = new MsalParentWindowAdapter(IntPtr.Zero, true, + () => consoleRoot, + hwnd => + { + Assert.Equal(consoleRoot, hwnd); + visibilityChecks++; + return false; + }, + ct => + { + windowToken = ct; + return progressWindow; + })) + { + IntPtr result = Assert.IsType(adapter.GetWindow()); + + Assert.Equal(progressWindow, result); + Assert.Equal(consoleRootValue == 0 ? 0 : 1, visibilityChecks); + Assert.True(windowToken.CanBeCanceled); + Assert.False(windowToken.IsCancellationRequested); + } + + Assert.True(windowToken.IsCancellationRequested); + } + + [Theory] + [InlineData(0)] + [InlineData(42)] + public void GetWindow_NoVisibleConsoleRoot_ReturnsNullWhenParentNotRequired(int consoleRootValue) + { + var consoleRoot = new IntPtr(consoleRootValue); + int visibilityChecks = 0; + using var adapter = new MsalParentWindowAdapter(IntPtr.Zero, false, + () => consoleRoot, + hwnd => + { + Assert.Equal(consoleRoot, hwnd); + visibilityChecks++; + return false; + }, + _ => throw new InvalidOperationException("A progress window should not be created.")); + + object result = adapter.GetWindow(); + + Assert.Null(result); + Assert.Equal(consoleRootValue == 0 ? 0 : 1, visibilityChecks); + } + + [Fact] + public void GetWindow_VisibilityCheckThrows_PropagatesException() + { + var exception = new InvalidOperationException("Visibility check failed."); + using var adapter = new MsalParentWindowAdapter(IntPtr.Zero, true, + () => new IntPtr(42), + _ => throw exception, + _ => throw new InvalidOperationException("A progress window should not be created.")); + + var thrown = Assert.Throws(adapter.GetWindow); + Assert.Same(exception, thrown); + } + + [Fact] + public void Create_ExplicitParent_IsReturned() + { + object parentWindow = new IntPtr(42); + using var adapter = MsalParentWindowAdapter.Create(parentWindow, true); + + object result = adapter.GetWindow(); + Assert.Same(parentWindow, result); + } + + [PosixFact] + public void Create_NoExplicitParent_Posix_ReturnsNull() + { + using var adapter = MsalParentWindowAdapter.Create(IntPtr.Zero); + + object result = adapter.GetWindow(); + Assert.Null(result); + } +} From 8413a8f3ef274753905d8a970e3fd88303f94321 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 5 Oct 2026 14:40:25 +0100 Subject: [PATCH 14/17] docs: document the GCM_MODAL_PARENTHWND variable Signed-off-by: Matthew John Cheetham --- docs/environment.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/environment.md b/docs/environment.md index 424867a68..888e13adb 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -173,6 +173,40 @@ Defaults to enabled. --- +### GCM_MODAL_PARENTHWND + +Specify the parent window handle (`HWND`) for modal dialogs on Windows. + +> [!NOTE] +> This is intended for use by application developers who are calling Git and/or +> GCM from their application and wish to correctly parent authentication dialogs +> to an existing window. +> +> Do **not** set this manually. + +#### Example + +##### Windows + +```csharp +public static int Example(IntPtr myWindowHandle) +{ + using var process = new Process(); + process.StartInfo = new ProcessStartInfo("git.exe", "pull") + { + Environment = + { + ["GCM_MODAL_PARENTHWND"] = myWindowHandle.ToString("d") + } + }; + process.Start(); + process.WaitForExit(); + return process.ExitCode; +} +``` + +--- + ### GCM_PROVIDER Define the host provider to use when authenticating. From 601b7ad3384747278f39f202f834abaf01764c3b Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 5 Oct 2026 16:49:20 +0100 Subject: [PATCH 15/17] auth: share visible console parenting Console parenting currently lives inside the MSAL adapter, so other in-process authentication dialogs do not benefit from the visible terminal owner. Those prompts can appear disconnected from the Git operation that opened them. Resolve the parent at the shared authentication boundary so all in-process prompts follow the same policy. Explicit handles retain precedence, hidden console owners are ignored, and minimised owners remain eligible. Keep progress-window creation in the MSAL adapter: it is a fallback for broker flows that require a parent, not a default owner for ordinary authentication dialogs. Assisted-by: Claude Sonnet 5 Assisted-by: GPT-6.1 Sol Signed-off-by: Matthew John Cheetham --- docs/environment.md | 6 ++ .../Entra/MsalParentWindowAdapterTests.cs | 95 ++----------------- src/Core/Authentication/AuthenticationBase.cs | 9 +- src/Core/Authentication/Entra/MsalAdaptors.cs | 40 +------- src/Core/PlatformUtils.cs | 31 ++++++ 5 files changed, 54 insertions(+), 127 deletions(-) diff --git a/docs/environment.md b/docs/environment.md index 888e13adb..c6d258f52 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -177,6 +177,12 @@ Defaults to enabled. Specify the parent window handle (`HWND`) for modal dialogs on Windows. +When this variable is unset, GCM tries to parent authentication dialogs to the +console's visible root-owner window. Hidden or invalid windows are ignored. + +Explicitly setting a handle using this variable takes precedence and it is not +checked for visibility. + > [!NOTE] > This is intended for use by application developers who are calling Git and/or > GCM from their application and wish to correctly parent authentication dialogs diff --git a/src/Core.Tests/Authentication/Entra/MsalParentWindowAdapterTests.cs b/src/Core.Tests/Authentication/Entra/MsalParentWindowAdapterTests.cs index 831ae489f..b60a70558 100644 --- a/src/Core.Tests/Authentication/Entra/MsalParentWindowAdapterTests.cs +++ b/src/Core.Tests/Authentication/Entra/MsalParentWindowAdapterTests.cs @@ -10,60 +10,22 @@ public class MsalParentWindowAdapterTests [Theory] [InlineData(42, false)] [InlineData(42, true)] - [InlineData(-1, false)] - [InlineData(-1, true)] - public void GetWindow_ExplicitParent_TakesPrecedence(int parentValue, bool createIfMissing) + public void GetWindow_ExplicitParent_ReturnsParent(int parentValue, bool createIfMissing) { object parentWindow = new IntPtr(parentValue); using var adapter = new MsalParentWindowAdapter(parentWindow, createIfMissing, - () => throw new InvalidOperationException("Console lookup should not be called."), - _ => throw new InvalidOperationException("Visibility should not be checked."), _ => throw new InvalidOperationException("A progress window should not be created.")); object result = adapter.GetWindow(); Assert.Same(parentWindow, result); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void GetWindow_VisibleConsoleRoot_PrecedesProgressWindow(bool createIfMissing) - { - var consoleRoot = new IntPtr(42); - bool visibilityChecked = false; - using var adapter = new MsalParentWindowAdapter(IntPtr.Zero, createIfMissing, - () => consoleRoot, - hwnd => - { - Assert.Equal(consoleRoot, hwnd); - visibilityChecked = true; - return true; - }, - _ => throw new InvalidOperationException("A progress window should not be created.")); - - IntPtr result = Assert.IsType(adapter.GetWindow()); - - Assert.Equal(consoleRoot, result); - Assert.True(visibilityChecked); - } - - [Theory] - [InlineData(0)] - [InlineData(42)] - public void GetWindow_NoVisibleConsoleRoot_CreatesProgressWhenRequired(int consoleRootValue) + [Fact] + public void GetWindow_NoParent_Required_CreatesProgressWindow() { - var consoleRoot = new IntPtr(consoleRootValue); - var progressWindow = new IntPtr(84); - int visibilityChecks = 0; + var progressWindow = new IntPtr(42); var windowToken = CancellationToken.None; using (var adapter = new MsalParentWindowAdapter(IntPtr.Zero, true, - () => consoleRoot, - hwnd => - { - Assert.Equal(consoleRoot, hwnd); - visibilityChecks++; - return false; - }, ct => { windowToken = ct; @@ -73,7 +35,6 @@ public void GetWindow_NoVisibleConsoleRoot_CreatesProgressWhenRequired(int conso IntPtr result = Assert.IsType(adapter.GetWindow()); Assert.Equal(progressWindow, result); - Assert.Equal(consoleRootValue == 0 ? 0 : 1, visibilityChecks); Assert.True(windowToken.CanBeCanceled); Assert.False(windowToken.IsCancellationRequested); } @@ -81,58 +42,14 @@ public void GetWindow_NoVisibleConsoleRoot_CreatesProgressWhenRequired(int conso Assert.True(windowToken.IsCancellationRequested); } - [Theory] - [InlineData(0)] - [InlineData(42)] - public void GetWindow_NoVisibleConsoleRoot_ReturnsNullWhenParentNotRequired(int consoleRootValue) - { - var consoleRoot = new IntPtr(consoleRootValue); - int visibilityChecks = 0; - using var adapter = new MsalParentWindowAdapter(IntPtr.Zero, false, - () => consoleRoot, - hwnd => - { - Assert.Equal(consoleRoot, hwnd); - visibilityChecks++; - return false; - }, - _ => throw new InvalidOperationException("A progress window should not be created.")); - - object result = adapter.GetWindow(); - - Assert.Null(result); - Assert.Equal(consoleRootValue == 0 ? 0 : 1, visibilityChecks); - } - [Fact] - public void GetWindow_VisibilityCheckThrows_PropagatesException() + public void GetWindow_NoParent_NotRequired_ReturnsNull() { - var exception = new InvalidOperationException("Visibility check failed."); - using var adapter = new MsalParentWindowAdapter(IntPtr.Zero, true, - () => new IntPtr(42), - _ => throw exception, + using var adapter = new MsalParentWindowAdapter(IntPtr.Zero, false, _ => throw new InvalidOperationException("A progress window should not be created.")); - var thrown = Assert.Throws(adapter.GetWindow); - Assert.Same(exception, thrown); - } - - [Fact] - public void Create_ExplicitParent_IsReturned() - { - object parentWindow = new IntPtr(42); - using var adapter = MsalParentWindowAdapter.Create(parentWindow, true); - object result = adapter.GetWindow(); - Assert.Same(parentWindow, result); - } - - [PosixFact] - public void Create_NoExplicitParent_Posix_ReturnsNull() - { - using var adapter = MsalParentWindowAdapter.Create(IntPtr.Zero); - object result = adapter.GetWindow(); Assert.Null(result); } } diff --git a/src/Core/Authentication/AuthenticationBase.cs b/src/Core/Authentication/AuthenticationBase.cs index fb177d112..5bfc1a9d0 100644 --- a/src/Core/Authentication/AuthenticationBase.cs +++ b/src/Core/Authentication/AuthenticationBase.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using GitCredentialManager.Tty; using GitCredentialManager.UI.ViewModels; namespace GitCredentialManager.Authentication @@ -125,11 +124,19 @@ protected void ThrowIfWindowCancelled(WindowViewModel viewModel) protected IntPtr GetParentWindowHandle() { + // Check if the user has specified a parent window ID if (int.TryParse(Context.Settings.ParentWindowId, out int id)) { return new IntPtr(id); } + // Check if we can use the console window as a parent + IntPtr consoleParent = PlatformUtils.GetConsoleParentWindow(); + if (consoleParent != IntPtr.Zero) + { + return consoleParent; + } + return IntPtr.Zero; } diff --git a/src/Core/Authentication/Entra/MsalAdaptors.cs b/src/Core/Authentication/Entra/MsalAdaptors.cs index e169ee838..2f335649c 100644 --- a/src/Core/Authentication/Entra/MsalAdaptors.cs +++ b/src/Core/Authentication/Entra/MsalAdaptors.cs @@ -1,7 +1,6 @@ using System; using System.Net.Http; using System.Threading; -using GitCredentialManager.Interop.Windows.Native; using GitCredentialManager.UI.Controls; using Microsoft.Identity.Client; @@ -31,48 +30,32 @@ internal class MsalParentWindowAdapter : IDisposable private readonly bool _createIfMissing; private readonly CancellationTokenSource _cts = new(); - private readonly Func _getConsoleParentWindow; - private readonly Func _isWindowVisible; private readonly Func _createWindow; public static MsalParentWindowAdapter Create(object parentWindow, bool createIfMissing = false) { - return new MsalParentWindowAdapter(parentWindow, createIfMissing, - GetConsoleParentWindow, User32.IsWindowVisible, ProgressWindow.ShowAndGetHandle); + return new MsalParentWindowAdapter(parentWindow, createIfMissing, ProgressWindow.ShowAndGetHandle); } internal MsalParentWindowAdapter(object parentWindow, bool createIfMissing, - Func getConsoleParentWindow, Func isWindowVisible, Func createWindow) { - EnsureArgument.NotNull(getConsoleParentWindow, nameof(getConsoleParentWindow)); - EnsureArgument.NotNull(isWindowVisible, nameof(isWindowVisible)); EnsureArgument.NotNull(createWindow, nameof(createWindow)); _parentWindow = parentWindow; _createIfMissing = createIfMissing; - - _getConsoleParentWindow = getConsoleParentWindow; - _isWindowVisible = isWindowVisible; _createWindow = createWindow; } public object GetWindow() { + // Try to use the parent window we were given (if any) if (_parentWindow is IntPtr p && p != IntPtr.Zero) { return _parentWindow; } - // See if we can use the console window as a parent. - // We only consider the window if it is valid and visible. - IntPtr consoleParent = _getConsoleParentWindow(); - if (consoleParent != IntPtr.Zero && _isWindowVisible(consoleParent)) - { - return consoleParent; - } - - // Create a stub window to use as a parent + // Create a stub window to use as a parent instead (if the caller requires it) if (_createIfMissing) { return _createWindow(_cts.Token); @@ -81,23 +64,6 @@ public object GetWindow() return null; } - private static IntPtr GetConsoleParentWindow() - { - // On Windows we can try and get the console window parent handle if that exists - if (!PlatformUtils.IsWindows()) - { - return IntPtr.Zero; - } - - IntPtr consoleHandle = Kernel32.GetConsoleWindow(); - - // When the parent is using ConPTY (pseudo-terminals) the console - // window may be a fake/stub (as is the case with Windows Terminal). - // This means we need to walk up the ancestor chain to get the actual - // root owner (typically the terminal emulator's window). - return User32.GetAncestor(consoleHandle, GetAncestorFlags.GetRootOwner); - } - public void Dispose() { // Close and clean up any stub window we may have created diff --git a/src/Core/PlatformUtils.cs b/src/Core/PlatformUtils.cs index cba2c4e3c..6f8dc38e9 100644 --- a/src/Core/PlatformUtils.cs +++ b/src/Core/PlatformUtils.cs @@ -5,6 +5,7 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; using GitCredentialManager.Interop.Posix.Native; +using GitCredentialManager.Interop.Windows.Native; namespace GitCredentialManager { @@ -211,6 +212,36 @@ public static bool IsElevatedUser() return false; } + public static IntPtr GetConsoleParentWindow() + { + // On Windows we can try and get the console window parent handle if that exists + if (!IsWindows()) + { + return IntPtr.Zero; + } + + IntPtr consoleHandle = Kernel32.GetConsoleWindow(); + + // When the parent is using ConPTY (pseudo-terminals) the console + // window may be a fake/stub (as is the case with Windows Terminal). + // This means we need to walk up the ancestor chain to get the actual + // root owner (typically the terminal emulator's window). + IntPtr parent = User32.GetAncestor(consoleHandle, GetAncestorFlags.GetRootOwner); + + // On Windows the parent window may be hidden (not visible) if the console is + // running in a background process (like a service). + // We do not consider a hidden window as a valid parent. We do however consider + // a minimised window as a valid parent (and the user is able to restore it and + // see our child window too). + if (User32.IsWindowVisible(parent)) + { + return parent; + } + + // No visible console window available! + return IntPtr.Zero; + } + #region Platform Entry Path Utils /// From a3f1cea44e7073e0dabcac4c7264b4a8d5e9a99a Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 5 Oct 2026 16:50:03 +0100 Subject: [PATCH 16/17] ui: use console fallback for helper parents Helper commands resolve their parent handle independently of the in-process authentication path. Keeping only the explicit-HWND case would leave their default parent-selection policy inconsistent with the shared authentication lookup. Use the same visible-console fallback when no handle is configured. Explicit handles still take precedence, and a missing or hidden console leaves the parent unset. Assisted-by: GPT-6.1 Sol Signed-off-by: Matthew John Cheetham --- src/Core/UI/HelperCommand.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Core/UI/HelperCommand.cs b/src/Core/UI/HelperCommand.cs index f75096ddb..49492bf1f 100644 --- a/src/Core/UI/HelperCommand.cs +++ b/src/Core/UI/HelperCommand.cs @@ -16,11 +16,19 @@ public HelperCommand(ICommandContext context, string name, string description) protected IntPtr GetParentHandle() { + // Check if the user has specified a parent window ID if (int.TryParse(Context.Settings.ParentWindowId, out int id)) { return new IntPtr(id); } + // Check if we can use the console window as a parent + IntPtr consoleParent = PlatformUtils.GetConsoleParentWindow(); + if (consoleParent != IntPtr.Zero) + { + return consoleParent; + } + return IntPtr.Zero; } From 1ca86304acaa6b258325acae91cbfb8766169d07 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 6 Oct 2026 12:11:48 +0100 Subject: [PATCH 17/17] VERSION: bump to 3.0.1 Signed-off-by: Matthew John Cheetham --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 4a36342fc..cb2b00e4f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0 +3.0.1