Skip to content

Commit 44715bd

Browse files
committed
response: add Yield() for helpers that have nothing to offer
GitResponse already distinguishes "I produced a credential" (Ok) from "stop the whole acquisition pipeline" (Cancel, wired as quit=1). There is a third shape the credential helper protocol expects: "I have nothing to contribute for this request, but I'm not stopping you -- please try other helpers or fall back to your interactive prompt." Today providers have no clean way to say this; they either throw (wrong: a no-op is not an error) or construct a response with empty credentials (wrong: that's the WIA signal and gets stored). Add a Yield() factory that returns a response whose IsYielded flag is set. The command layer translates it into an empty response on standard output: just the terminating blank line, no credential fields, no quit signal. Git then proceeds to the next helper in the chain or to its built-in interactive prompt -- which is the exact behaviour Git defaults to when a helper returns nothing, so this is the most polite "I'm out" a helper can send. Keep the response shape constructor enforcing that Ok / Cancel / Yield are mutually exclusive: a response carrying a credential cannot be cancelled or yielded, and a response cannot be both cancelled and yielded at once. AdditionalProperties continue to be ignored on the non-Ok shapes. Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com> Assisted-by: Claude Opus 4.7
1 parent 7bebed0 commit 44715bd

4 files changed

Lines changed: 111 additions & 27 deletions

File tree

src/Core.Tests/Commands/GetCommandTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,30 @@ public async Task GetCommand_ExecuteAsync_CancelledResponse_EmitsQuitAndNoCreden
146146
Assert.Equal("quit=1\n\n", actualOutput);
147147
}
148148

149+
[Fact]
150+
public async Task GetCommand_ExecuteAsync_YieldedResponse_EmitsEmptyResponse()
151+
{
152+
// A provider that has nothing to contribute but does not want to stop
153+
// the pipeline returns GitResponse.Yield(); the command MUST emit just
154+
// the terminating blank line (no credential fields, no quit signal) so
155+
// Git proceeds to the next helper or its interactive prompt.
156+
var stdin = "protocol=https\nhost=example.com\n\n";
157+
158+
var providerMock = new Mock<IHostProvider>();
159+
providerMock.Setup(x => x.GetCredentialAsync(It.IsAny<GitRequest>()))
160+
.ReturnsAsync(GitResponse.Yield());
161+
var providerRegistry = new TestHostProviderRegistry { Provider = providerMock.Object };
162+
var context = new TestCommandContext { Streams = { In = stdin } };
163+
164+
var command = new GetCommand(context, providerRegistry);
165+
166+
await command.ExecuteAsync();
167+
168+
string actualOutput = context.Streams.Out.ToString().Replace("\r\n", "\n");
169+
170+
Assert.Equal("\n", actualOutput);
171+
}
172+
149173
#region Helpers
150174

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

src/Core.Tests/GitResponseTests.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,26 @@ public void GitResponse_Cancel_ReturnsCancellationResponseWithNoCredential()
8383
var response = GitResponse.Cancel();
8484

8585
Assert.True(response.IsCancelled);
86+
Assert.False(response.IsYielded);
8687
Assert.Null(response.Credential);
8788
}
89+
90+
[Fact]
91+
public void GitResponse_Yield_ReturnsYieldedResponseWithNoCredential()
92+
{
93+
var response = GitResponse.Yield();
94+
95+
Assert.True(response.IsYielded);
96+
Assert.False(response.IsCancelled);
97+
Assert.Null(response.Credential);
98+
}
99+
100+
[Fact]
101+
public void GitResponse_Ok_IsNeitherCancelledNorYielded()
102+
{
103+
var response = GitResponse.Ok(new GitCredential("alice", "hunter2"));
104+
105+
Assert.False(response.IsCancelled);
106+
Assert.False(response.IsYielded);
107+
}
88108
}

src/Core/Commands/GetCommand.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ protected override async Task ExecuteInternalAsync(GitRequest request, IHostProv
3333
return;
3434
}
3535

36+
if (response.IsYielded)
37+
{
38+
// Provider has nothing to contribute but does not want to stop the
39+
// pipeline. Emit an empty response (just the terminating blank line)
40+
// so Git proceeds to the next helper or its interactive prompt.
41+
Context.Trace.WriteLine("Provider yielded; emitting empty response.");
42+
Context.Streams.Out.WriteLine();
43+
return;
44+
}
45+
3646
ICredential credential = response.Credential;
3747

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

src/Core/GitResponse.cs

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,20 @@ namespace GitCredentialManager;
99
/// </summary>
1010
/// <remarks>
1111
/// <para>
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"/>).
12+
/// A response is exactly one of three shapes:
1513
/// </para>
14+
/// <list type="bullet">
15+
/// <item><see cref="Ok(ICredential)"/> -- the provider produced a credential.</item>
16+
/// <item><see cref="Cancel"/> -- the provider declined and wants the whole
17+
/// credential acquisition pipeline to stop (emits <c>quit=1</c>; Git aborts
18+
/// the operation without a fallback interactive prompt).</item>
19+
/// <item><see cref="Yield"/> -- the provider has nothing to contribute but
20+
/// wants Git to continue trying other helpers or fall back to its built-in
21+
/// interactive prompt (emits an empty response).</item>
22+
/// </list>
1623
/// <para>
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.
24+
/// Use the factory methods rather than the constructor where possible; they
25+
/// make the intent of each call site obvious.
2826
/// </para>
2927
/// <para>
3028
/// Strongly-typed properties for newer Git credential protocol fields
@@ -38,44 +36,52 @@ namespace GitCredentialManager;
3836
/// </remarks>
3937
public class GitResponse
4038
{
41-
private GitResponse(ICredential credential, bool isCancelled)
39+
private GitResponse(ICredential credential, bool isCancelled, bool isYielded)
4240
{
43-
if (isCancelled && credential is not null)
41+
if (isCancelled && isYielded)
4442
{
4543
throw new ArgumentException(
46-
"A cancelled response cannot carry a credential.",
44+
"A response cannot be both cancelled and yielded.");
45+
}
46+
47+
if ((isCancelled || isYielded) && credential is not null)
48+
{
49+
throw new ArgumentException(
50+
"A cancelled or yielded response cannot carry a credential.",
4751
nameof(credential));
4852
}
4953

50-
if (!isCancelled && credential is null)
54+
if (!isCancelled && !isYielded && credential is null)
5155
{
5256
throw new ArgumentNullException(
5357
nameof(credential),
54-
"A non-cancelled response must carry a credential. Use Cancel() instead.");
58+
"A successful response must carry a credential. Use Cancel() or Yield() instead.");
5559
}
5660

5761
Credential = credential;
5862
IsCancelled = isCancelled;
63+
IsYielded = isYielded;
5964
}
6065

6166
/// <summary>
6267
/// Construct a successful response carrying the given credential.
6368
/// </summary>
6469
/// <remarks>Equivalent to <see cref="Ok(ICredential)"/>.</remarks>
6570
public GitResponse(ICredential credential)
66-
: this(credential, isCancelled: false)
71+
: this(credential, isCancelled: false, isYielded: false)
6772
{
6873
}
6974

7075
/// <summary>
7176
/// Construct a successful response carrying the given credential.
7277
/// </summary>
7378
public static GitResponse Ok(ICredential credential) =>
74-
new GitResponse(credential, isCancelled: false);
79+
new GitResponse(credential, isCancelled: false, isYielded: false);
7580

7681
/// <summary>
7782
/// Construct a cancellation response: the provider declined to produce a
78-
/// credential for this request.
83+
/// credential for this request, and the whole credential acquisition
84+
/// pipeline should stop.
7985
/// </summary>
8086
/// <remarks>
8187
/// The command layer translates a cancelled response into a <c>quit=1</c>
@@ -85,28 +91,52 @@ public static GitResponse Ok(ICredential credential) =>
8591
/// response are ignored.
8692
/// </remarks>
8793
public static GitResponse Cancel() =>
88-
new GitResponse(credential: null, isCancelled: true);
94+
new GitResponse(credential: null, isCancelled: true, isYielded: false);
95+
96+
/// <summary>
97+
/// Construct a yielded response: the provider has nothing to contribute
98+
/// for this request but does not want to stop other helpers from being
99+
/// tried (or Git's interactive prompt from being shown).
100+
/// </summary>
101+
/// <remarks>
102+
/// The command layer translates a yielded response into an empty response
103+
/// on standard output (no credential fields, no <c>quit</c> signal). Git
104+
/// then proceeds to the next helper in the chain or to its built-in
105+
/// interactive prompt. Any <see cref="AdditionalProperties"/> set on a
106+
/// yielded response are ignored.
107+
/// </remarks>
108+
public static GitResponse Yield() =>
109+
new GitResponse(credential: null, isCancelled: false, isYielded: true);
89110

90111
/// <summary>
91112
/// The credential resolved or generated for the request, or <see langword="null"/>
92-
/// when <see cref="IsCancelled"/> is <see langword="true"/>.
113+
/// when <see cref="IsCancelled"/> or <see cref="IsYielded"/> is <see langword="true"/>.
93114
/// </summary>
94115
public ICredential Credential { get; }
95116

96117
/// <summary>
97118
/// <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.
119+
/// for this request and wants the whole credential acquisition pipeline to
120+
/// stop. The command layer translates this into a <c>quit=1</c> signal to
121+
/// Git.
100122
/// </summary>
101123
public bool IsCancelled { get; }
102124

125+
/// <summary>
126+
/// <see langword="true"/> when the provider has nothing to contribute but
127+
/// does not want to stop the credential acquisition pipeline. The command
128+
/// layer translates this into an empty response on standard output so Git
129+
/// proceeds to the next helper or its interactive prompt.
130+
/// </summary>
131+
public bool IsYielded { get; }
132+
103133
/// <summary>
104134
/// Additional, untyped output to be emitted alongside the credential.
105135
/// </summary>
106136
/// <remarks>
107137
/// This is the legacy escape hatch for non-credential output. New code
108138
/// should prefer typed properties on <see cref="GitResponse"/> as they
109-
/// are added. Ignored on cancelled responses.
139+
/// are added. Ignored on cancelled and yielded responses.
110140
/// </remarks>
111141
public IDictionary<string, string> AdditionalProperties { get; set; }
112142
= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

0 commit comments

Comments
 (0)