Skip to content

Commit 7bebed0

Browse files
committed
response: add Ok/Cancel factories and wire quit=1
When a provider can produce a credential it returns one. When it deliberately can't (the user closed an auth prompt, no eligible account was found, etc.) it currently has to throw, which routes through the generic top-level exception handler and surfaces as "fatal: ..." on stderr with exit -1. That conflates "the provider decided not to authenticate" with "something went unexpectedly wrong", and forces every "no credential" path through an exception. Give providers a non-exceptional way to express both outcomes: * GitResponse.Ok(credential) -- successful response; same shape as the existing public constructor, just named for the intent. * GitResponse.Cancel() -- cancellation response; carries no credential and tells the command layer to emit `quit=1` on standard output, which is the Git credential helper protocol's signal to abort the credential acquisition pipeline. Git responds by terminating the operation immediately, rather than falling back to an interactive terminal prompt that would just re-ask the user who already cancelled in a GUI dialog. Enforce the invariant in GitResponse that cancelled responses cannot carry a credential and that non-cancelled responses must. The existing 1-arg constructor is retained as an alias for Ok so in-tree providers keep compiling; migrating them to the factories is the next commit. Exit-code plumbing is deliberately not touched: `quit=1` is the in-band protocol signal that the protocol actually defines for this case, and Git's `die()` on receiving it makes the helper's exit code irrelevant. Reserving non-zero exit for genuinely unexpected internal errors (which already throw and route through Application.OnException) keeps the two channels semantically distinct. Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com> Assisted-by: Claude Opus 4.7
1 parent 2eb59c9 commit 7bebed0

4 files changed

Lines changed: 150 additions & 12 deletions

File tree

src/Core.Tests/Commands/GetCommandTests.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,31 @@ public async Task GetCommand_ExecuteAsync_NoNegotiatedCapabilities_EmitsNoCapabi
121121
Assert.DoesNotContain("capability", actualOutput);
122122
}
123123

124+
[Fact]
125+
public async Task GetCommand_ExecuteAsync_CancelledResponse_EmitsQuitAndNoCredential()
126+
{
127+
// A provider that declines to produce a credential (e.g. the user closed
128+
// an auth prompt) returns GitResponse.Cancel(); the command MUST emit
129+
// `quit=1` so Git aborts the credential acquisition pipeline rather than
130+
// falling back to an interactive prompt that re-asks the user. No
131+
// credential fields must be emitted.
132+
var stdin = "protocol=https\nhost=example.com\n\n";
133+
134+
var providerMock = new Mock<IHostProvider>();
135+
providerMock.Setup(x => x.GetCredentialAsync(It.IsAny<GitRequest>()))
136+
.ReturnsAsync(GitResponse.Cancel());
137+
var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object };
138+
var context = new TestCommandContext { Streams = { In = stdin } };
139+
140+
var command = new GetCommand(context, providerRegistry);
141+
142+
await command.ExecuteAsync();
143+
144+
string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n");
145+
146+
Assert.Equal("quit=1\n\n", actualOutput);
147+
}
148+
124149
#region Helpers
125150

126151
private static IDictionary<string, string> ParseDictionary(StringBuilder sb) => ParseDictionary(sb.ToString());

src/Core.Tests/GitResponseTests.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,37 @@ public void GitResponse_AdditionalProperties_CanBeReplaced()
5252
Assert.Single(response.AdditionalProperties);
5353
Assert.Equal("allow", response.AdditionalProperties["ntlm"]);
5454
}
55+
56+
[Fact]
57+
public void GitResponse_Constructor_NullCredential_Throws()
58+
{
59+
// The non-cancelled ctor requires a credential. Use Cancel() for the no-credential case.
60+
Assert.Throws<System.ArgumentNullException>(() => new GitResponse(null));
61+
}
62+
63+
[Fact]
64+
public void GitResponse_Ok_ReturnsSuccessfulResponseWithCredential()
65+
{
66+
ICredential credential = new GitCredential("alice", "hunter2");
67+
68+
var response = GitResponse.Ok(credential);
69+
70+
Assert.Same(credential, response.Credential);
71+
Assert.False(response.IsCancelled);
72+
}
73+
74+
[Fact]
75+
public void GitResponse_Ok_NullCredential_Throws()
76+
{
77+
Assert.Throws<System.ArgumentNullException>(() => GitResponse.Ok(null));
78+
}
79+
80+
[Fact]
81+
public void GitResponse_Cancel_ReturnsCancellationResponseWithNoCredential()
82+
{
83+
var response = GitResponse.Cancel();
84+
85+
Assert.True(response.IsCancelled);
86+
Assert.Null(response.Credential);
87+
}
5588
}

src/Core/Commands/GetCommand.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ public GetCommand(ICommandContext context, IHostProviderRegistry hostProviderReg
1919
protected override async Task ExecuteInternalAsync(GitRequest request, IHostProvider provider)
2020
{
2121
GitResponse response = await provider.GetCredentialAsync(request);
22+
23+
if (response.IsCancelled)
24+
{
25+
// Provider declined to produce a credential. Tell Git to stop the
26+
// credential acquisition pipeline (no fallback interactive prompt)
27+
// via the `quit` protocol attribute. This avoids re-prompting a
28+
// user who has already explicitly cancelled in a GUI dialog.
29+
Context.Trace.WriteLine("Provider cancelled the credential request; emitting quit=1.");
30+
Context.Streams.Error.WriteLine("info: user cancelled the credential request.");
31+
Context.Streams.Out.WriteLine("quit=1");
32+
Context.Streams.Out.WriteLine();
33+
return;
34+
}
35+
2236
ICredential credential = response.Credential;
2337

2438
// Negotiate capabilities by intersecting what Git advertised with what GCM supports.

src/Core/GitResponse.cs

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,38 +9,104 @@ namespace GitCredentialManager;
99
/// </summary>
1010
/// <remarks>
1111
/// <para>
12-
/// Holds the resolved <see cref="ICredential"/> together with any non-credential
13-
/// output the provider wants to emit back to Git. Strongly-typed properties for
14-
/// newer Git credential protocol fields (e.g. <c>state[]</c>, <c>continue</c>,
15-
/// <c>authtype</c>, <c>credential</c>, <c>ephemeral</c>, <c>password_expiry_utc</c>,
16-
/// <c>oauth_refresh_token</c>) will be added here as the corresponding
17-
/// capabilities are wired up; see <see cref="GitCapabilities"/>.
12+
/// A successful response holds the resolved <see cref="ICredential"/> together
13+
/// with any non-credential output the provider wants to emit back to Git
14+
/// (see <see cref="AdditionalProperties"/>).
1815
/// </para>
1916
/// <para>
20-
/// Until then, <see cref="AdditionalProperties"/> remains the escape hatch for
21-
/// arbitrary extra output keys (for example, the generic provider's
22-
/// <c>ntlm=allow</c> hint).
17+
/// A response can also be a <em>cancellation</em>: the provider was asked for a
18+
/// credential but deliberately declined to produce one (the user closed an
19+
/// auth prompt, no eligible account was found, and so on). A cancelled
20+
/// response has no <see cref="Credential"/> and causes the command layer to
21+
/// emit <c>quit=1</c> to Git, which terminates the credential acquisition
22+
/// pipeline without falling back to an interactive prompt.
23+
/// </para>
24+
/// <para>
25+
/// Use the <see cref="Ok(ICredential)"/> and <see cref="Cancel"/> factory
26+
/// methods rather than the constructor where possible; they make the intent
27+
/// of each call site obvious.
28+
/// </para>
29+
/// <para>
30+
/// Strongly-typed properties for newer Git credential protocol fields
31+
/// (e.g. <c>state[]</c>, <c>continue</c>, <c>authtype</c>, <c>credential</c>,
32+
/// <c>ephemeral</c>, <c>password_expiry_utc</c>, <c>oauth_refresh_token</c>)
33+
/// will be added here as the corresponding capabilities are wired up; see
34+
/// <see cref="GitCapabilities"/>. Until then, <see cref="AdditionalProperties"/>
35+
/// remains the escape hatch for arbitrary extra output keys (for example, the
36+
/// generic provider's <c>ntlm=allow</c> hint).
2337
/// </para>
2438
/// </remarks>
2539
public class GitResponse
2640
{
27-
public GitResponse(ICredential credential)
41+
private GitResponse(ICredential credential, bool isCancelled)
2842
{
43+
if (isCancelled && credential is not null)
44+
{
45+
throw new ArgumentException(
46+
"A cancelled response cannot carry a credential.",
47+
nameof(credential));
48+
}
49+
50+
if (!isCancelled && credential is null)
51+
{
52+
throw new ArgumentNullException(
53+
nameof(credential),
54+
"A non-cancelled response must carry a credential. Use Cancel() instead.");
55+
}
56+
2957
Credential = credential;
58+
IsCancelled = isCancelled;
59+
}
60+
61+
/// <summary>
62+
/// Construct a successful response carrying the given credential.
63+
/// </summary>
64+
/// <remarks>Equivalent to <see cref="Ok(ICredential)"/>.</remarks>
65+
public GitResponse(ICredential credential)
66+
: this(credential, isCancelled: false)
67+
{
3068
}
3169

3270
/// <summary>
33-
/// The credential resolved or generated for the request.
71+
/// Construct a successful response carrying the given credential.
72+
/// </summary>
73+
public static GitResponse Ok(ICredential credential) =>
74+
new GitResponse(credential, isCancelled: false);
75+
76+
/// <summary>
77+
/// Construct a cancellation response: the provider declined to produce a
78+
/// credential for this request.
79+
/// </summary>
80+
/// <remarks>
81+
/// The command layer translates a cancelled response into a <c>quit=1</c>
82+
/// line on standard output, which tells Git to abort the credential
83+
/// acquisition pipeline immediately without falling back to an interactive
84+
/// prompt. Any <see cref="AdditionalProperties"/> set on a cancelled
85+
/// response are ignored.
86+
/// </remarks>
87+
public static GitResponse Cancel() =>
88+
new GitResponse(credential: null, isCancelled: true);
89+
90+
/// <summary>
91+
/// The credential resolved or generated for the request, or <see langword="null"/>
92+
/// when <see cref="IsCancelled"/> is <see langword="true"/>.
3493
/// </summary>
3594
public ICredential Credential { get; }
3695

96+
/// <summary>
97+
/// <see langword="true"/> when the provider declined to produce a credential
98+
/// for this request. The command layer translates this into a <c>quit=1</c>
99+
/// signal to Git rather than any specific exit code.
100+
/// </summary>
101+
public bool IsCancelled { get; }
102+
37103
/// <summary>
38104
/// Additional, untyped output to be emitted alongside the credential.
39105
/// </summary>
40106
/// <remarks>
41107
/// This is the legacy escape hatch for non-credential output. New code
42108
/// should prefer typed properties on <see cref="GitResponse"/> as they
43-
/// are added.
109+
/// are added. Ignored on cancelled responses.
44110
/// </remarks>
45111
public IDictionary<string, string> AdditionalProperties { get; set; }
46112
= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

0 commit comments

Comments
 (0)