Skip to content

Commit d4b8c08

Browse files
authored
console: replace terminal UI with Spectre.Console (#2377)
## Summary Replace GCM's line-oriented `ITerminal` / `TerminalMenu` stack with a Spectre.Console-backed console layer. Interactive prompts now use the controlling terminal, while diagnostics and other output-only messages continue to use standard error. Git's credential-protocol stdin and stdout remain untouched. The branch adds the cross-platform TTY plumbing needed to make that split work on macOS, Linux, and Windows, then migrates every existing terminal prompt and removes the old abstraction. This is deliberately the console foundation for richer account-picker flows; it does not add the follow-on account-selection behaviour itself. ## Why The existing terminal surface supports basic line output, text/secret input, and numbered one-shot menus. That is enough for today's prompts, but not for the multi-choice, filter-as-you-type, confirmation, and progress experiences needed by the account-picker work. Using Spectre.Console directly is not safe for a credential helper because its default console uses process stdin/stdout, which Git reserves for the credential protocol. This change introduces an explicit routing layer: - Output-only messages render to stderr, where they remain visible and capturable even without a terminal. - Prompts use the controlling TTY through `/dev/tty` on POSIX and `CONIN$` / `CONOUT$` on Windows. ## What changes **Console architecture** - Add Spectre.Console 0.57.0 and expose a single `IConsoleService Console` on `ICommandContext`. - Back the service with two `IAnsiConsole` instances: an output-only stderr console and an interactive controlling-TTY console. - Preserve plain redirected output by disabling ANSI colour when stderr is redirected. - Fall back to a non-interactive, null-input console when no controlling TTY can be opened rather than ever borrowing Git's standard streams. - Add a Spectre-backed test console so prompt tests feed real text, secret, arrow-key, and Enter input through the same prompt implementations used in production. **Native TTY support** - Add POSIX output and input adapters over `/dev/tty`. The input side uses `termios` raw mode plus a platform-neutral ANSI escape parser for navigation, function, modifier, control, and UTF-8 key sequences. - Use `select(2)` for key availability and the short standalone-Escape disambiguation window. Unlike `poll(2)`, this works for TTY character devices on both macOS and Linux. - Add Windows output and input adapters over `CONOUT$` and `CONIN$`. Output enables virtual-terminal processing where available; input translates `ReadConsoleInput` key events into `ConsoleKeyInfo`. - Scope raw mode to an active read or prompt instead of the process lifetime. A prompt-level hold prevents fast typeahead from being echoed between reads during masked input, while returning to cooked mode between prompts keeps normal Ctrl+C handling working during network, broker, and GUI operations. - Restore terminal state on normal completion, exception unwinding, and relevant POSIX signals. Ctrl+C read during a prompt becomes an `InterruptedException`, which exits quietly with the conventional status code 130 after the terminal has been restored. **Prompt and diagnostic migration** - Move username, password/token, two-factor, authentication-mode, Windows integrated-authentication, OAuth, Microsoft-authentication, and existing account-selection prompts to Spectre.Console. - Replace numbered text menus with interactive selection prompts while preserving the available choices, ordering, and default selection. - Route warnings, errors, informational messages, device-code text, and fatal exception output through `IConsoleService`, giving them one consistent stderr-backed surface. - Delete `ITerminal`, the POSIX/macOS/Linux/Windows terminal implementations, `TerminalMenu`, and the old test double once their final consumers move. - Include the process ID in the wait-for-debugger message so the correct GCM process is unambiguous when several helpers are running. ## User-visible behaviour and compatibility - The Git credential wire protocol is unchanged: stdin and stdout are still used only for communication with Git. - Terminal selection prompts now use arrow-key navigation instead of asking the user to enter a menu number. Authentication choices and ordering are unchanged. - Human-facing prefixes are coloured when stderr is attached to a terminal and remain plain text when redirected. - Ctrl+C during an interactive prompt restores the terminal and exits with status 130 without printing a misleading `fatal:` message. - Existing GUI prompt paths and authentication decisions are unchanged. - In headless environments, interactive input degrades to the explicit non-interactive console rather than reading from redirected protocol input. ## Testing - `dotnet test git-credential-manager.slnx --configuration Debug` New coverage includes the ANSI escape parser, POSIX `select(2)` semantics, Windows key-event translation, raw-mode prompt lifetime and cleanup, headless console contracts, stderr routing, interrupt detection, and the migrated provider prompt flows. ## Notes for reviewers - The eight commits are arranged as a progression: console facade, TTY output, POSIX input, Windows input, contract tests, the debugger-message tweak, diagnostic routing, and finally the atomic prompt migration/removal of `ITerminal`. Reviewing commit-by-commit is recommended. - The highest-risk code is isolated in the native input adapters. The key invariant is that raw mode is reference-counted and held only while a read or prompt owns it; every completion and interruption path must restore the original terminal mode. - The account-picker work can build on this branch without reopening the credential-protocol or platform-TTY layers.
2 parents ab75a6b + 34466bb commit d4b8c08

65 files changed

Lines changed: 2795 additions & 946 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<PackageVersion Include="Microsoft.Build.Utilities.Core" Version="16.0.461" />
2121

2222
<!-- Other -->
23+
<PackageVersion Include="Spectre.Console" Version="0.57.0" />
2324
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
2425
<PackageVersion Include="System.Text.Json" Version="8.0.5" />
2526
<PackageVersion Include="Tools.InnoSetup" Version="6.7.3" />
@@ -29,6 +30,7 @@
2930
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
3031
<PackageVersion Include="Moq" Version="4.20.72" />
3132
<PackageVersion Include="ReportGenerator" Version="5.3.10" />
33+
<PackageVersion Include="Spectre.Console.Testing" Version="0.57.0" />
3234
<PackageVersion Include="xunit" Version="2.9.2" />
3335
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
3436
<PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" />

src/Atlassian.Bitbucket.Tests/BitbucketAuthenticationTest.cs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ public class BitbucketAuthenticationTest
1616
public async Task BitbucketAuthentication_GetCredentialsAsync_Basic_SucceedsAfterUserInput(string username, string password)
1717
{
1818
var context = new TestCommandContext();
19-
context.Terminal.Prompts["Username"] = username;
20-
context.Terminal.SecretPrompts["Password"] = password;
19+
context.Console.PushText(password);
2120
Uri targetUri = null;
2221

2322
var bitbucketAuthentication = new BitbucketAuthentication(context);
@@ -52,7 +51,7 @@ public async Task BitbucketAuthentication_GetCredentialsAsync_All_ShowsMenu_OAut
5251
var context = new TestCommandContext();
5352
context.SessionManager.IsDesktopSession = true; // Allow OAuth mode
5453
context.Settings.IsGuiPromptsEnabled = false; // Force text prompts
55-
context.Terminal.Prompts["option (enter for default)"] = "1";
54+
context.Console.PushSelection(0);
5655
Uri targetUri = null;
5756

5857
var bitbucketAuthentication = new BitbucketAuthentication(context);
@@ -73,9 +72,9 @@ public async Task BitbucketAuthentication_GetCredentialsAsync_All_ShowsMenu_Basi
7372
var context = new TestCommandContext();
7473
context.SessionManager.IsDesktopSession = true; // Allow OAuth mode
7574
context.Settings.IsGuiPromptsEnabled = false; // Force text prompts
76-
context.Terminal.Prompts["option (enter for default)"] = "2";
77-
context.Terminal.Prompts["Username"] = username;
78-
context.Terminal.SecretPrompts["Password"] = password;
75+
context.Console.PushSelection(1);
76+
context.Console.PushText(username);
77+
context.Console.PushText(password);
7978
Uri targetUri = null;
8079

8180
var bitbucketAuthentication = new BitbucketAuthentication(context);
@@ -96,8 +95,8 @@ public async Task BitbucketAuthentication_GetCredentialsAsync_All_NoDesktopSessi
9695

9796
var context = new TestCommandContext();
9897
context.SessionManager.IsDesktopSession = false; // Disallow OAuth mode
99-
context.Terminal.Prompts["Username"] = username;
100-
context.Terminal.SecretPrompts["Password"] = password;
98+
context.Console.PushText(username);
99+
context.Console.PushText(password);
101100
Uri targetUri = null;
102101

103102
var bitbucketAuthentication = new BitbucketAuthentication(context);

src/Atlassian.Bitbucket/BitbucketAuthentication.cs

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
using GitCredentialManager;
1010
using GitCredentialManager.Authentication;
1111
using GitCredentialManager.Authentication.OAuth;
12+
using GitCredentialManager.Tty;
1213
using GitCredentialManager.UI;
14+
using Spectre.Console;
1315

1416
namespace Atlassian.Bitbucket
1517
{
@@ -102,7 +104,7 @@ public async Task<CredentialsPromptResult> GetCredentialsAsync(Uri targetUri, st
102104
return await GetCredentialsViaUiAsync(targetUri, userName, modes);
103105
}
104106

105-
return GetCredentialsViaTty(targetUri, userName, modes);
107+
return await GetCredentialsViaTtyAsync(targetUri, userName, modes);
106108
}
107109

108110
private async Task<CredentialsPromptResult> GetCredentialsViaUiAsync(
@@ -145,28 +147,28 @@ private async Task<CredentialsPromptResult> GetCredentialsViaUiAsync(
145147
}
146148
}
147149

148-
private CredentialsPromptResult GetCredentialsViaTty(Uri targetUri, string userName, AuthenticationModes modes)
150+
private async Task<CredentialsPromptResult> GetCredentialsViaTtyAsync(Uri targetUri, string userName, AuthenticationModes modes)
149151
{
150152
ThrowIfTerminalPromptsDisabled();
151153

152154
switch (modes)
153155
{
154156
case AuthenticationModes.Basic:
155-
Context.Terminal.WriteLine("Enter Bitbucket credentials for '{0}'...", targetUri);
157+
Context.Console.WriteLine($"Enter Bitbucket credentials for '{targetUri}'...");
156158

157159
if (!string.IsNullOrWhiteSpace(userName))
158160
{
159161
// Don't need to prompt for the username if it has been specified already
160-
Context.Terminal.WriteLine("Username: {0}", userName);
162+
Context.Console.WriteLine($"Username: {userName}");
161163
}
162164
else
163165
{
164166
// Prompt for username
165-
userName = Context.Terminal.Prompt("Username");
167+
userName = await TerminalPrompts.CreateText("Username").ShowAsync(Context.Console);
166168
}
167169

168170
// Prompt for password
169-
string password = Context.Terminal.PromptSecret("Password");
171+
string password = await TerminalPrompts.CreateSecret("Password").ShowAsync(Context.Console);
170172

171173
return new CredentialsPromptResult(
172174
AuthenticationModes.Basic,
@@ -180,20 +182,17 @@ private CredentialsPromptResult GetCredentialsViaTty(Uri targetUri, string userN
180182
@$"At least one {nameof(AuthenticationModes)} must be supplied");
181183

182184
default:
183-
var menuTitle = $"Select an authentication method for '{targetUri}'";
184-
var menu = new TerminalMenu(Context.Terminal, menuTitle);
185+
var promptTitle = $"Select an authentication method for '{targetUri}'";
186+
var prompt = TerminalPrompts.CreateSelection<AuthenticationModes>()
187+
.Title(promptTitle);
185188

186-
TerminalMenuItem oauthItem = null;
187-
TerminalMenuItem basicItem = null;
189+
if ((modes & AuthenticationModes.OAuth) != 0) prompt.AddChoice("OAuth", AuthenticationModes.OAuth);
190+
if ((modes & AuthenticationModes.Basic) != 0) prompt.AddChoice("Username/password", AuthenticationModes.Basic);
188191

189-
if ((modes & AuthenticationModes.OAuth) != 0) oauthItem = menu.Add("OAuth");
190-
if ((modes & AuthenticationModes.Basic) != 0) basicItem = menu.Add("Username/password");
192+
AuthenticationModes choice = await prompt.ShowAsync(Context.Console);
191193

192-
// Default to the 'first' choice in the menu
193-
TerminalMenuItem choice = menu.Show(0);
194-
195-
if (choice == oauthItem) goto case AuthenticationModes.OAuth;
196-
if (choice == basicItem) goto case AuthenticationModes.Basic;
194+
if (choice == AuthenticationModes.OAuth) goto case AuthenticationModes.OAuth;
195+
if (choice == AuthenticationModes.Basic) goto case AuthenticationModes.Basic;
197196

198197
throw new Exception();
199198
}

src/Atlassian.Bitbucket/BitbucketHostProvider.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Atlassian.Bitbucket.Cloud;
77
using GitCredentialManager;
88
using GitCredentialManager.Authentication.OAuth;
9+
using Spectre.Console;
910

1011
namespace Atlassian.Bitbucket
1112
{
@@ -318,7 +319,7 @@ public async Task<AuthenticationModes> GetSupportedAuthenticationModesAsync(GitR
318319
_context.Trace.WriteException(ex);
319320
_context.Trace2.WriteError(message, format);
320321

321-
_context.Terminal.WriteLine($"warning: {message}");
322+
_context.Console.WriteWarning(message);
322323

323324
// Fall-back to offering all modes so the user is never blocked from authenticating by at least one mode
324325
return AuthenticationModes.All;

src/Core.Tests/ApplicationTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,5 +348,53 @@ public async Task Application_UnconfigureAsync_EmptyAndGcmWithOthersBeforeAndAft
348348
Assert.Equal(emptyHelper, actualValues[1]);
349349
Assert.Equal(afterHelper, actualValues[2]);
350350
}
351+
352+
[Fact]
353+
public void Application_ContainsInterrupt_Direct_True()
354+
{
355+
Assert.True(Application.ContainsInterrupt(new InterruptedException()));
356+
}
357+
358+
[Fact]
359+
public void Application_ContainsInterrupt_WrappedInInner_True()
360+
{
361+
var ex = new System.InvalidOperationException("outer",
362+
new System.Exception("mid", new InterruptedException()));
363+
Assert.True(Application.ContainsInterrupt(ex));
364+
}
365+
366+
[Fact]
367+
public void Application_ContainsInterrupt_InsideAggregate_True()
368+
{
369+
var ex = new System.AggregateException(
370+
new System.Exception("a"),
371+
new InterruptedException());
372+
Assert.True(Application.ContainsInterrupt(ex));
373+
}
374+
375+
[Fact]
376+
public void Application_ContainsInterrupt_NestedAggregate_True()
377+
{
378+
var ex = new System.AggregateException(
379+
new System.AggregateException(new InterruptedException()));
380+
Assert.True(Application.ContainsInterrupt(ex));
381+
}
382+
383+
[Fact]
384+
public void Application_ContainsInterrupt_NotPresent_False()
385+
{
386+
var ex = new System.InvalidOperationException("outer",
387+
new System.Exception("inner"));
388+
Assert.False(Application.ContainsInterrupt(ex));
389+
}
390+
391+
[Fact]
392+
public void Application_ContainsInterrupt_AggregateWithoutInterrupt_False()
393+
{
394+
var ex = new System.AggregateException(
395+
new System.Exception("a"),
396+
new System.InvalidOperationException("b"));
397+
Assert.False(Application.ContainsInterrupt(ex));
398+
}
351399
}
352400
}

src/Core.Tests/Authentication/BasicAuthenticationTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public async Task BasicAuthentication_GetCredentials_NonDesktopSession_ResourceA
2828
const string testPassword = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
2929

3030
var context = new TestCommandContext {SessionManager = {IsDesktopSession = false}};
31-
context.Terminal.SecretPrompts["Password"] = testPassword; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
31+
context.Console.PushText(testPassword); // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
3232

3333
var basicAuth = new BasicAuthentication(context);
3434

@@ -46,8 +46,8 @@ public async Task BasicAuthentication_GetCredentials_NonDesktopSession_Resource_
4646
const string testPassword = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
4747

4848
var context = new TestCommandContext {SessionManager = {IsDesktopSession = false}};
49-
context.Terminal.Prompts["Username"] = testUserName;
50-
context.Terminal.SecretPrompts["Password"] = testPassword; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
49+
context.Console.PushText(testUserName);
50+
context.Console.PushText(testPassword); // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
5151

5252
var basicAuth = new BasicAuthentication(context);
5353

src/Core.Tests/Authentication/MicrosoftAuthenticationTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,19 @@ public void MicrosoftAuthentication_GetManagedIdentity_Invalid_ThrowsArgumentExc
6969
{
7070
Assert.Throws<ArgumentException>(() => MicrosoftAuthentication.GetManagedIdentity(str));
7171
}
72+
73+
[Fact]
74+
public void MicrosoftAuthentication_GetFlowType_UnknownValue_WarnsWithConfiguredValue()
75+
{
76+
const string configuredValue = "unknown-flow";
77+
var context = new TestCommandContext();
78+
context.Environment.Variables[Constants.EnvironmentVariables.MsAuthFlow] = configuredValue;
79+
var authentication = new MicrosoftAuthentication(context);
80+
81+
MicrosoftAuthenticationFlowType result = authentication.GetFlowType();
82+
83+
Assert.Equal(MicrosoftAuthenticationFlowType.Auto, result);
84+
Assert.Contains(context.Console.WrittenMessages, x => x.Contains(configuredValue));
85+
}
7286
}
7387
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using System.IO;
2+
using System.Text;
3+
using GitCredentialManager.Tty;
4+
using Spectre.Console;
5+
using Xunit;
6+
7+
namespace GitCredentialManager.Tests;
8+
9+
public class ConsoleServiceTests
10+
{
11+
[Fact]
12+
public void ConsoleService_WriteMethods_RouteToErrorConsoleWriter()
13+
{
14+
var err = new StringWriter();
15+
var console = new ConsoleService(
16+
AnsiConsoleFactory.CreateHeadless(),
17+
AnsiConsoleFactory.CreateForWriter(err, isRedirected: true));
18+
19+
console.WriteInfo("info-[marker]");
20+
console.WriteWarning("warn-[marker]");
21+
console.WriteError("error-[marker]");
22+
console.WriteFatal("fatal-[marker]");
23+
console.WriteLine("line-[marker]");
24+
25+
string output = err.ToString();
26+
Assert.Contains("info-[marker]", output);
27+
Assert.Contains("warn-[marker]", output);
28+
Assert.Contains("error-[marker]", output);
29+
Assert.Contains("fatal-[marker]", output);
30+
Assert.Contains("line-[marker]", output);
31+
}
32+
33+
[Fact]
34+
public void ConsoleService_WriteFatal_RoutesToAutoFlushStreamWriter()
35+
{
36+
// Mirror StandardStreams.Error exactly: a UTF-8 StreamWriter with AutoFlush.
37+
using var ms = new MemoryStream();
38+
using var sw = new StreamWriter(ms, new UTF8Encoding(false)) { AutoFlush = true, NewLine = "\n" };
39+
40+
var console = new ConsoleService(
41+
AnsiConsoleFactory.CreateHeadless(),
42+
AnsiConsoleFactory.CreateForWriter(sw, isRedirected: true));
43+
44+
console.WriteFatal("fatal-marker");
45+
sw.Flush();
46+
47+
string output = new UTF8Encoding(false).GetString(ms.ToArray());
48+
Assert.Contains("fatal-marker", output);
49+
}
50+
}

src/Core.Tests/Core.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
</PackageReference>
1616
<PackageReference Include="Microsoft.NET.Test.Sdk" />
1717
<PackageReference Include="ReportGenerator" />
18+
<PackageReference Include="Spectre.Console.Testing" />
1819
<PackageReference Include="xunit.runner.visualstudio">
1920
<PrivateAssets>all</PrivateAssets>
2021
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

0 commit comments

Comments
 (0)