Skip to content

Commit e34dba7

Browse files
authored
github: add multi-account prompt and login/logout commands (#1267)
Add multi-account support for the GitHub host provider. Previously it was possible to use multiple accounts with GitHub, but this required prior knowledge about including the username in the remote URL (e.g. `https://[email protected]/mona/test`), and without this the incorrect account may be selected, and subsequently erased due to insufficient permissions. This PR adds a few different features and prompts to improve discoverability and experience using multiple GitHub accounts with GCM: 1. Add `login`, `logout` and `list` commands for the GitHub provider 2. Add UI and TTY prompts to select between GitHub user accounts 3. Abridged documentation and links to quickly discover the 'user-in-remote-URL' functionality 4. (bonus!) Ability to suppress GUI prompts over text-based ones via the command-line using `--no-ui` One thing that is absent compared to the Azure Repos provider's multi-account support is the lack of account 'binding'. This is due to lack of knowledge, by default, of the full remote URL from Git to be able to discern between different repositories or organisations on the same host. **Command-line usage:** ```console % git-credential-manager github --help Description: Commands for interacting with the GitHub host provider Usage: git-credential-manager github [command] [options] Options: --no-ui Do not use graphical user interface prompts -?, -h, --help Show help and usage information Commands: list List all known GitHub accounts. login Add a GitHub account. logout <account> Remove a GitHub account. ``` ```console % git-credential-manager github login --help Description: Add a GitHub account. Usage: git-credential-manager github login [options] Options: --url <url> URL of the GitHub instance to target, otherwise use GitHub.com --username <username> User name to authenticate with --device Use device flow to authenticate --browser, --web Use a web browser to authenticate --pat, --token <token> Use personal access token to authenticate --no-ui Do not use graphical user interface prompts -?, -h, --help Show help and usage information ``` ```console % git-credential-manager github logout --help Description: Remove a GitHub account. Usage: git-credential-manager github logout <account> [options] Arguments: <account> Account to remove Options: --url <url> URL of the GitHub instance to target, otherwise use GitHub.com --no-ui Do not use graphical user interface prompts -?, -h, --help Show help and usage information ``` ```console % git-credential-manager github list --help Description: List all known GitHub accounts. Usage: git-credential-manager github list [options] Options: --url <url> URL of the GitHub instance to target, otherwise use GitHub.com --no-ui Do not use graphical user interface prompts -?, -h, --help Show help and usage information ``` **Select account UI prompts:** Avalonia|Windows legacy helper (WPF) -|- <img width="532" alt="image" src="https://github.com/git-ecosystem/git-credential-manager/assets/5658207/e7fc0ab9-903a-466b-9424-26183c188652">|![image](https://github.com/git-ecosystem/git-credential-manager/assets/5658207/6b2fa7bb-2e62-435f-a187-72f42734ced0)
2 parents c6c3579 + b94ade0 commit e34dba7

34 files changed

+1172
-87
lines changed

docs/multiple-users.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ If you work with multiple different identities on a single Git hosting service,
44
you may be wondering if Git Credential Manager (GCM) supports this workflow. The
55
answer is yes, with a bit of complexity due to how it interoperates with Git.
66

7+
---
8+
9+
**Prompted to select an account?**
10+
11+
Read the [**TL;DR** section][tldr] below for a quick summary of how to make GCM
12+
remember which account to use for which repository.
13+
14+
---
15+
716
## Foundations: Git and Git hosts
817

918
Git itself doesn't have a single, strong concept of "user". There's the
@@ -79,3 +88,35 @@ git remote set-url origin https://[email protected]/big-company/secret-re
7988
which you should also be aware of if you're using it.
8089

8190
[azure-access-tokens]: azrepos-users-and-tokens.md
91+
92+
## GitHub
93+
94+
You can use the `github [list | login | logout]` commands to manage your GitHub
95+
accounts. These commands are documented in the [command-line usage][cli-usage]
96+
or by running `git-credential-manager github --help`.
97+
98+
## TL;DR: Tell GCM to remember which account to use
99+
100+
The easiest way to have GCM remember which account to use for which repository
101+
is to include the account name in the remote URL. If you're using HTTPS remotes,
102+
you can include the account name in the URL by inserting it before the `@` sign
103+
in the domain name.
104+
105+
For example, if you want to always use the `alice` account for the `mona/test`
106+
GitHub repository, you can clone it using the `alice` account by running:
107+
108+
```shell
109+
git clone https://[email protected]/mona/test
110+
```
111+
112+
To update an existing clone, you can run `git remote set-url` to update the URL:
113+
114+
```shell
115+
git remote set-url origin https://[email protected]/mona/test
116+
```
117+
118+
If your account name includes an `@` then remember to escape this character
119+
using `%40`: `https://alice%[email protected]/test`.
120+
121+
[tldr]: #tldr-tell-gcm-to-remember-which-account-to-use
122+
[cli-usage]: usage.md

docs/usage.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,8 @@ For more information about managing user account bindings see
4646
4747
[azure-access-tokens-ua]: azrepos-users-and-tokens.md#useraccounts
4848
[git-credentials-custom-helpers]: https://git-scm.com/docs/gitcredentials#_custom_helpers
49+
50+
### github
51+
52+
Interact with the GitHub host provider to manage your accounts on GitHub.com and
53+
GitHub Enterprise Server instances.

src/shared/Core.Tests/Authentication/BasicAuthenticationTests.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.IO;
34
using System.Threading.Tasks;
45
using GitCredentialManager.Authentication;
56
using GitCredentialManager.Tests.Objects;
@@ -101,7 +102,7 @@ public async Task BasicAuthentication_GetCredentials_DesktopSession_UIHelper_Cal
101102
auth.Setup(x => x.InvokeHelperAsync(
102103
It.IsAny<string>(),
103104
$"basic --resource {testResource}",
104-
It.IsAny<IDictionary<string, string>>(),
105+
It.IsAny<StreamReader>(),
105106
It.IsAny<System.Threading.CancellationToken>()))
106107
.ReturnsAsync(
107108
new Dictionary<string, string>
@@ -147,7 +148,7 @@ public async Task BasicAuthentication_GetCredentials_DesktopSession_UIHelper_Use
147148
auth.Setup(x => x.InvokeHelperAsync(
148149
It.IsAny<string>(),
149150
$"basic --resource {testResource} --username {testUserName}",
150-
It.IsAny<IDictionary<string, string>>(),
151+
It.IsAny<StreamReader>(),
151152
It.IsAny<System.Threading.CancellationToken>()))
152153
.ReturnsAsync(
153154
new Dictionary<string, string>

src/shared/Core/Application.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
using System.CommandLine.Builder;
55
using System.CommandLine.Invocation;
66
using System.CommandLine.Parsing;
7-
using System.IO;
87
using System.Linq;
98
using System.Text.RegularExpressions;
109
using System.Threading.Tasks;
@@ -70,6 +69,18 @@ protected override async Task<int> RunInternalAsync(string[] args)
7069
var rootCommand = new RootCommand();
7170
var diagnoseCommand = new DiagnoseCommand(Context);
7271

72+
// Add common options
73+
var noGuiOption = new Option<bool>("--no-ui", "Do not use graphical user interface prompts");
74+
rootCommand.AddGlobalOption(noGuiOption);
75+
76+
void NoGuiOptionHandler(InvocationContext context)
77+
{
78+
if (context.ParseResult.HasOption(noGuiOption))
79+
{
80+
Context.Settings.IsGuiPromptsEnabled = false;
81+
}
82+
}
83+
7384
// Add standard commands
7485
rootCommand.AddCommand(new GetCommand(Context, _providerRegistry));
7586
rootCommand.AddCommand(new StoreCommand(Context, _providerRegistry));
@@ -103,6 +114,7 @@ protected override async Task<int> RunInternalAsync(string[] args)
103114
var parser = new CommandLineBuilder(rootCommand)
104115
.UseDefaults()
105116
.UseExceptionHandler(OnException)
117+
.AddMiddleware(NoGuiOptionHandler)
106118
.Build();
107119

108120
return await parser.InvokeAsync(args);

src/shared/Core/Authentication/AuthenticationBase.cs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ protected AuthenticationBase(ICommandContext context)
2121
}
2222

2323
protected Task<IDictionary<string, string>> InvokeHelperAsync(string path, string args,
24-
IDictionary<string, string> standardInput = null)
24+
StreamReader standardInput = null)
2525
{
26-
return InvokeHelperAsync(path, args, null, CancellationToken.None);
26+
return InvokeHelperAsync(path, args, standardInput, CancellationToken.None);
2727
}
2828

2929
protected internal virtual async Task<IDictionary<string, string>> InvokeHelperAsync(string path, string args,
30-
IDictionary<string, string> standardInput, CancellationToken ct)
30+
StreamReader standardInput, CancellationToken ct)
3131
{
3232
var procStartInfo = new ProcessStartInfo(path)
3333
{
@@ -56,9 +56,15 @@ protected internal virtual async Task<IDictionary<string, string>> InvokeHelperA
5656
// Kill the process upon a cancellation request
5757
ct.Register(() => process.Kill());
5858

59-
if (!(standardInput is null))
59+
// Write the standard input to the process if we have any to write
60+
if (standardInput is not null)
6061
{
61-
await process.StandardInput.WriteDictionaryAsync(standardInput);
62+
#if NETFRAMEWORK
63+
await standardInput.BaseStream.CopyToAsync(process.StandardInput.BaseStream);
64+
#else
65+
await standardInput.BaseStream.CopyToAsync(process.StandardInput.BaseStream, ct);
66+
#endif
67+
process.StandardInput.Close();
6268
}
6369

6470
IDictionary<string, string> resultDict = await process.StandardOutput.ReadDictionaryAsync(StringComparer.OrdinalIgnoreCase);

src/shared/Core/CommandExtensions.cs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.CommandLine;
4+
using System.Diagnostics;
5+
using System.Linq;
6+
7+
namespace GitCredentialManager;
8+
9+
public static class CommandExtensions
10+
{
11+
/// <summary>
12+
/// Add options to a command.
13+
/// </summary>
14+
/// <param name="command">Command to add options to.</param>
15+
/// <param name="arity">Specify the required arity of the options.</param>
16+
/// <param name="options">Set of options to add.</param>
17+
public static void AddOptionSet(this Command command, OptionArity arity, params Option[] options)
18+
{
19+
foreach (Option option in options)
20+
{
21+
command.AddOption(option);
22+
}
23+
24+
// No need to add a validator if 0..* options are OK
25+
if (arity != OptionArity.ZeroOrMore)
26+
{
27+
command.AddValidator(r =>
28+
{
29+
int count = options.Count(o => r.FindResultFor(o) is not null);
30+
string optionList = string.Join(", ", options.Select(s => $"--{s.Name}"));
31+
switch (arity)
32+
{
33+
case OptionArity.ZeroOrOne:
34+
if (count > 1)
35+
r.ErrorMessage = $"Can only specify one of: {optionList}";
36+
break;
37+
38+
case OptionArity.ExactlyOne:
39+
if (count != 1)
40+
r.ErrorMessage = $"Require exactly one of: {optionList}";
41+
break;
42+
43+
case OptionArity.Zero:
44+
if (count != 0)
45+
{
46+
IEnumerable<string> usedOptions = options.Where(o => r.FindResultFor(o) is not null)
47+
.Select(x => $"--{x.Name}");
48+
r.ErrorMessage = $"{command.Name} does not support options: {string.Join(", ", usedOptions)}";
49+
}
50+
break;
51+
52+
case OptionArity.OneOrMore:
53+
if (count == 0)
54+
r.ErrorMessage = $"Require at least one of: {optionList}";
55+
break;
56+
57+
case OptionArity.ZeroOrMore:
58+
Debug.Fail("Should not have a validator for an arity of ZeroOrMore.");
59+
break;
60+
61+
default:
62+
throw new ArgumentOutOfRangeException(nameof(arity), arity, null);
63+
}
64+
});
65+
}
66+
}
67+
}
68+
69+
public enum OptionArity
70+
{
71+
/// <summary>
72+
/// An arity that may have multiple values.
73+
/// </summary>
74+
ZeroOrMore = 0,
75+
76+
/// <summary>
77+
/// An arity that must have exactly one value.
78+
/// </summary>
79+
ExactlyOne,
80+
81+
/// <summary>
82+
/// An arity that does not allow any values.
83+
/// </summary>
84+
Zero,
85+
86+
/// <summary>
87+
/// An arity that may have one value, but no more than one.
88+
/// </summary>
89+
ZeroOrOne,
90+
91+
/// <summary>
92+
/// An arity that must have at least one value.
93+
/// </summary>
94+
OneOrMore,
95+
}

src/shared/Core/Constants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ public static class HelpUrls
211211
public const string GcmAutoDetect = "https://aka.ms/gcm/autodetect";
212212
public const string GcmExecRename = "https://aka.ms/gcm/rename";
213213
public const string GcmDefaultAccount = "https://aka.ms/gcm/defaultaccount";
214+
public const string GcmMultipleUsers = "https://aka.ms/gcm/multipleusers";
214215
}
215216

216217
private static Version _gcmVersion;

src/shared/Core/CredentialCacheStore.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Collections.Generic;
23

34
namespace GitCredentialManager
@@ -22,6 +23,25 @@ public CredentialCacheStore(IGit git, string options)
2223

2324
#region ICredentialStore
2425

26+
public IList<string> GetAccounts(string service)
27+
{
28+
// Listing accounts is not supported by the credential-cache store so we just attempt to retrieve
29+
// the username from first credential for the given service and return an empty list if it fails.
30+
var input = MakeGitCredentialsEntry(service, null);
31+
32+
var result = _git.InvokeHelperAsync(
33+
$"credential-cache get {_options}",
34+
input
35+
).GetAwaiter().GetResult();
36+
37+
if (result.TryGetValue("username", out string value))
38+
{
39+
return new List<string> { value };
40+
}
41+
42+
return Array.Empty<string>();
43+
}
44+
2545
public ICredential Get(string service, string account)
2646
{
2747
var input = MakeGitCredentialsEntry(service, account);

src/shared/Core/CredentialStore.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.IO;
34
using System.Text;
45
using GitCredentialManager.Interop.Linux;
@@ -24,6 +25,12 @@ public CredentialStore(ICommandContext context)
2425

2526
#region ICredentialStore
2627

28+
public IList<string> GetAccounts(string service)
29+
{
30+
EnsureBackingStore();
31+
return _backingStore.GetAccounts(service);
32+
}
33+
2734
public ICredential Get(string service, string account)
2835
{
2936
EnsureBackingStore();

src/shared/Core/ICredentialStore.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Generic;
12

23
namespace GitCredentialManager
34
{
@@ -6,6 +7,13 @@ namespace GitCredentialManager
67
/// </summary>
78
public interface ICredentialStore
89
{
10+
/// <summary>
11+
/// Get all accounts from the store for the given service.
12+
/// </summary>
13+
/// <param name="service">Name of the service to match against. Use null to match all values.</param>
14+
/// <returns>All accounts that match the query.</returns>
15+
IList<string> GetAccounts(string service);
16+
917
/// <summary>
1018
/// Get the first credential from the store that matches the given query.
1119
/// </summary>

0 commit comments

Comments
 (0)