diff --git a/.gitignore b/.gitignore
index 3e759b75b..8361a679b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -328,3 +328,6 @@ ASALocalRun/
# MFractors (Xamarin productivity tool) working folder
.mfractor/
+
+# macOS Finder files
+.DS_Store
diff --git a/Git-Credential-Manager.sln b/Git-Credential-Manager.sln
new file mode 100644
index 000000000..f31dc7c8c
--- /dev/null
+++ b/Git-Credential-Manager.sln
@@ -0,0 +1,37 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A7FC1234-95E3-4496-B5F7-4306F41E6A0E}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4B305AC9-153F-4EA3-822F-3E5023BABAF1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "git-credential-manager", "src\git-credential-manager\git-credential-manager.csproj", "{28F06D44-AB25-4CF5-93F9-978C23FAA9D6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Git.CredentialManager", "src\Microsoft.Git.CredentialManager\Microsoft.Git.CredentialManager.csproj", "{31BCFC70-B767-4274-873F-1A076D422FC3}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Git.CredentialManager.Tests", "tests\Microsoft.Git.CredentialManager.Tests\Microsoft.Git.CredentialManager.Tests.csproj", "{AD41FA1E-51F5-4E4F-B7DA-32F921491313}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {28F06D44-AB25-4CF5-93F9-978C23FAA9D6} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E}
+ {31BCFC70-B767-4274-873F-1A076D422FC3} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E}
+ {AD41FA1E-51F5-4E4F-B7DA-32F921491313} = {4B305AC9-153F-4EA3-822F-3E5023BABAF1}
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {31BCFC70-B767-4274-873F-1A076D422FC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {31BCFC70-B767-4274-873F-1A076D422FC3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {31BCFC70-B767-4274-873F-1A076D422FC3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {31BCFC70-B767-4274-873F-1A076D422FC3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AD41FA1E-51F5-4E4F-B7DA-32F921491313}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/src/Microsoft.Git.CredentialManager/CommandContext.cs b/src/Microsoft.Git.CredentialManager/CommandContext.cs
new file mode 100644
index 000000000..eac1d5152
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/CommandContext.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+namespace Microsoft.Git.CredentialManager
+{
+ ///
+ /// Represents the execution environment for a Git credential helper command.
+ ///
+ public interface ICommandContext
+ {
+ ///
+ /// The standard input text stream from the calling process, typically Git.
+ ///
+ TextReader StdIn { get; }
+
+ ///
+ /// The standard output text stream connected back to the calling process, typically Git.
+ ///
+ TextWriter StdOut { get; }
+
+ ///
+ /// The standard error text stream connected back to the calling process, typically Git.
+ ///
+ TextWriter StdError { get; }
+
+ ///
+ /// Application tracing system.
+ ///
+ ITrace Trace { get; }
+
+ ///
+ /// File system abstraction (exists mainly for testing).
+ ///
+ IFileSystem FileSystem { get; }
+
+ ///
+ /// Access the environment variables for the current GCM process.
+ ///
+ /// Set of all current environment variables.
+ IReadOnlyDictionary GetEnvironmentVariables();
+ }
+
+ ///
+ /// Real command execution environment using the actual , file system calls and environment.
+ ///
+ public class CommandContext : ICommandContext
+ {
+ private const string LineFeed = "\n";
+
+ private static readonly Encoding Utf8NoBomEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
+
+ private TextReader _stdIn;
+ private TextWriter _stdOut;
+ private TextWriter _stdErr;
+
+ #region ICommandContext
+
+ public TextReader StdIn
+ {
+ get
+ {
+ if (_stdIn == null)
+ {
+ _stdIn = new StreamReader(Console.OpenStandardInput(), Utf8NoBomEncoding);
+ }
+
+ return _stdIn;
+ }
+ }
+
+ public TextWriter StdOut
+ {
+ get
+ {
+ if (_stdOut == null)
+ {
+ _stdOut = new StreamWriter(Console.OpenStandardOutput(), Utf8NoBomEncoding)
+ {
+ AutoFlush = true,
+ NewLine = LineFeed,
+ };
+ }
+
+ return _stdOut;
+ }
+ }
+
+ public TextWriter StdError
+ {
+ get
+ {
+ if (_stdErr == null)
+ {
+ _stdErr = new StreamWriter(Console.OpenStandardError(), Utf8NoBomEncoding)
+ {
+ AutoFlush = true,
+ NewLine = LineFeed,
+ };
+ }
+
+ return _stdErr;
+ }
+ }
+
+ public ITrace Trace { get; } = new Trace();
+
+ public IFileSystem FileSystem { get; } = new FileSystem();
+
+ public IReadOnlyDictionary GetEnvironmentVariables()
+ {
+ IDictionary variables = Environment.GetEnvironmentVariables();
+
+ // On Windows it is technically possible to get env vars which differ only by case
+ // even though the general assumption is that they are case insensitive on Windows.
+ // For example, some of the standard .NET types like System.Diagnostics.Process
+ // will fail to start a process on Windows if given duplicate environment variables.
+ // See this issue for more information: https://github.com/dotnet/corefx/issues/13146
+
+ // If we're on the Windows platform we should de-duplicate by setting the string
+ // comparer to OrdinalIgnoreCase.
+ var comparer = PlatformUtils.IsWindows()
+ ? StringComparer.OrdinalIgnoreCase
+ : StringComparer.Ordinal;
+
+ var result = new Dictionary(comparer);
+
+ foreach (var key in variables.Keys)
+ {
+ if (key is string name && variables[key] is string value)
+ {
+ result[name] = value;
+ }
+ }
+
+ return result;
+ }
+
+ #endregion
+ }
+
+ public static class CommandContextExtensions
+ {
+ ///
+ /// Try to get the current value of the specified environment variable.
+ ///
+ ///
+ /// The name of environment variable.
+ /// The current value of the environment variable.
+ /// True if the environment variable was set and has a value, false otherwise.
+ public static bool TryGetEnvironmentVariable(this ICommandContext context, string key, out string value)
+ {
+ return context.GetEnvironmentVariables().TryGetValue(key, out value);
+ }
+
+ ///
+ /// Test if the specified environment variable is 'truthy' (considered to be equivalent to a 'true' value
+ /// by ).
+ ///
+ ///
+ /// The name of environment variable.
+ ///
+ /// The assumed default value of the environment variable if it does not
+ /// exist/has not been set.
+ ///
+ /// True if the environment variable was set and has a 'truthy' value, otherwise.
+ public static bool IsEnvironmentVariableTruthy(this ICommandContext context, string key, bool defaultValue)
+ {
+ if (context.TryGetEnvironmentVariable(key, out string valueStr) && valueStr.IsTruthy())
+ {
+ return true;
+ }
+
+ return defaultValue;
+ }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/Commands/Command.cs b/src/Microsoft.Git.CredentialManager/Commands/Command.cs
new file mode 100644
index 000000000..d7f580437
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/Commands/Command.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Microsoft.Git.CredentialManager.Commands
+{
+ ///
+ /// Represents a Git Credential Manager command.
+ ///
+ public abstract class CommandBase
+ {
+ ///
+ /// Check if this command should be executed for the given arguments.
+ ///
+ /// Application command-line arguments.
+ /// True if the command should be executed, false otherwise.
+ public abstract bool CanExecute(string[] args);
+
+ ///
+ /// Execute the command.
+ ///
+ /// The current command execution context.
+ /// Application command-line arguments.
+ /// Awaitable task for the command execution.
+ public abstract Task ExecuteAsync(ICommandContext context, string[] args);
+ }
+
+ ///
+ /// Represents a simple Git Credential Manager command that takes a single named verb.
+ ///
+ public abstract class VerbCommandBase : CommandBase
+ {
+ ///
+ /// Name of the command verb.
+ ///
+ protected abstract string Name { get; }
+
+ public override bool CanExecute(string[] args)
+ {
+ return args.Length != 0 && StringComparer.OrdinalIgnoreCase.Equals(args[0], Name);
+ }
+ }
+
+ ///
+ /// Represents a command which selects a from a
+ /// based on the from standard input, and interacts with a .
+ ///
+ public abstract class HostProviderCommandBase : VerbCommandBase
+ {
+ private readonly IHostProviderRegistry _hostProviderRegistry;
+
+ protected HostProviderCommandBase(IHostProviderRegistry hostProviderRegistry)
+ {
+ _hostProviderRegistry = hostProviderRegistry;
+ }
+
+ public override async Task ExecuteAsync(ICommandContext context, string[] args)
+ {
+ // Parse standard input arguments
+ // git-credential treats the keys as case-sensitive; so should we.
+ IDictionary inputDict = await context.StdIn.ReadDictionaryAsync(StringComparer.Ordinal);
+ var input = new InputArguments(inputDict);
+
+ // Determine the host provider
+ context.Trace.WriteLine("Detecting host provider for input:");
+ context.Trace.WriteDictionary(inputDict);
+ IHostProvider provider = _hostProviderRegistry.GetProvider(input);
+ context.Trace.WriteLine($"Host provider '{provider.Name}' was selected.");
+
+ // Build the credential identifier
+ string hostProviderKey = provider.GetCredentialKey(input);
+ string credentialKey = $"git:{hostProviderKey}";
+ context.Trace.WriteLine($"Credential key is '{credentialKey}'.");
+
+ await ExecuteInternalAsync(context, input, provider, credentialKey);
+ }
+
+ ///
+ /// Execute the command using the given , , and key.
+ ///
+ /// The current command execution context.
+ /// Input arguments of the current Git credential query.
+ /// Host provider for the current .
+ /// Unique identifier in the OS secure storage system for a .
+ /// Awaitable task for the command execution.
+ protected abstract Task ExecuteInternalAsync(ICommandContext context, InputArguments input, IHostProvider provider, string credentialKey);
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/Commands/EraseCommand.cs b/src/Microsoft.Git.CredentialManager/Commands/EraseCommand.cs
new file mode 100644
index 000000000..d06d31b2b
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/Commands/EraseCommand.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Threading.Tasks;
+
+namespace Microsoft.Git.CredentialManager.Commands
+{
+ ///
+ /// Erase a previously stored from the OS secure credential store.
+ ///
+ public class EraseCommand : HostProviderCommandBase
+ {
+ public EraseCommand(IHostProviderRegistry hostProviderRegistry)
+ : base(hostProviderRegistry) { }
+
+ protected override string Name => "erase";
+
+ protected override Task ExecuteInternalAsync(ICommandContext context, InputArguments input, IHostProvider provider, string credentialKey)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/Commands/GetCommand.cs b/src/Microsoft.Git.CredentialManager/Commands/GetCommand.cs
new file mode 100644
index 000000000..cba14d5d8
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/Commands/GetCommand.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Threading.Tasks;
+
+namespace Microsoft.Git.CredentialManager.Commands
+{
+ ///
+ /// Acquire a new from a .
+ ///
+ public class GetCommand : HostProviderCommandBase
+ {
+ public GetCommand(IHostProviderRegistry hostProviderRegistry)
+ : base(hostProviderRegistry) { }
+
+ protected override string Name => "get";
+
+ protected override Task ExecuteInternalAsync(ICommandContext context, InputArguments input, IHostProvider provider, string credentialKey)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/Commands/HelpCommand.cs b/src/Microsoft.Git.CredentialManager/Commands/HelpCommand.cs
new file mode 100644
index 000000000..56f3756c1
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/Commands/HelpCommand.cs
@@ -0,0 +1,56 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.Git.CredentialManager.Commands
+{
+ ///
+ /// Print usage information and basic help for Git Credential Manager.
+ ///
+ public class HelpCommand : CommandBase
+ {
+ private readonly string _appName;
+
+ public HelpCommand(string appName)
+ {
+ _appName = appName ?? throw new ArgumentNullException(nameof(appName));
+ }
+
+ public override bool CanExecute(string[] args)
+ {
+ return args.Any(x => StringComparer.OrdinalIgnoreCase.Equals(x, "--help") ||
+ StringComparer.OrdinalIgnoreCase.Equals(x, "-h") ||
+ StringComparer.OrdinalIgnoreCase.Equals(x, "help") ||
+ (x != null && x.Contains('?')));
+ }
+
+ public override Task ExecuteAsync(ICommandContext context, string[] args)
+ {
+ context.StdOut.WriteLine(Constants.GcmProgramNameFormat, Constants.GcmVersion, PlatformUtils.GetOSInfo());
+
+ PrintUsage(context.StdOut);
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Print the standard usage documentation for Git Credential Manager to the given .
+ ///
+ /// Text writer to write usage information to.
+ public void PrintUsage(TextWriter writer)
+ {
+ writer.WriteLine();
+ writer.WriteLine("usage: {0} ", _appName);
+ writer.WriteLine();
+ writer.WriteLine(" Available commands:");
+ writer.WriteLine(" erase");
+ writer.WriteLine(" get");
+ writer.WriteLine(" store");
+ writer.WriteLine();
+ writer.WriteLine(" --version, version");
+ writer.WriteLine(" --help, -h, -?");
+ writer.WriteLine();
+ }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/Commands/StoreCommand.cs b/src/Microsoft.Git.CredentialManager/Commands/StoreCommand.cs
new file mode 100644
index 000000000..c4785dfeb
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/Commands/StoreCommand.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Threading.Tasks;
+
+namespace Microsoft.Git.CredentialManager.Commands
+{
+ ///
+ /// Store a previously created in the OS secure credential store.
+ ///
+ public class StoreCommand : HostProviderCommandBase
+ {
+ public StoreCommand(IHostProviderRegistry hostProviderRegistry)
+ : base(hostProviderRegistry) { }
+
+ protected override string Name => "store";
+
+ protected override Task ExecuteInternalAsync(ICommandContext context, InputArguments input, IHostProvider provider, string credentialKey)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/Commands/VersionCommand.cs b/src/Microsoft.Git.CredentialManager/Commands/VersionCommand.cs
new file mode 100644
index 000000000..9c93e1764
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/Commands/VersionCommand.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.Git.CredentialManager.Commands
+{
+ ///
+ /// Print version information for Git Credential Manager.
+ ///
+ public class VersionCommand : CommandBase
+ {
+ private readonly string _header;
+
+ public VersionCommand(string header)
+ {
+ _header = header;
+ }
+
+ public override bool CanExecute(string[] args)
+ {
+ return args.Any(x => StringComparer.OrdinalIgnoreCase.Equals(x, "--version"))
+ || args.Any(x => StringComparer.OrdinalIgnoreCase.Equals(x, "version"));
+ }
+
+ public override Task ExecuteAsync(ICommandContext context, string[] args)
+ {
+ context.StdOut.WriteLine(_header);
+
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/Constants.cs b/src/Microsoft.Git.CredentialManager/Constants.cs
new file mode 100644
index 000000000..4c158d519
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/Constants.cs
@@ -0,0 +1,28 @@
+namespace Microsoft.Git.CredentialManager
+{
+ public static class Constants
+ {
+ public const string GcmVersion = "1.0";
+ public const string PersonalAccessTokenUserName = "PersonalAccessToken";
+
+ public static class EnvironmentVariables
+ {
+ public const string GcmTrace = "GCM_TRACE";
+ public const string GcmTraceSecrets = "GCM_TRACE_SECRETS";
+ public const string GcmDebug = "GCM_DEBUG";
+ }
+
+ public static readonly string GcmProgramNameFormat = "Git Credential Manager Core (version {0}, {1})";
+
+ ///
+ /// Get standard program header title for Git Credential Manager, including the current version and OS information.
+ ///
+ /// Standard program header.
+ public static string GetProgramHeader()
+ {
+ string os = PlatformUtils.GetOSInfo();
+
+ return string.Format(GcmProgramNameFormat, GcmVersion, os);
+ }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/EnsureArgument.cs b/src/Microsoft.Git.CredentialManager/EnsureArgument.cs
new file mode 100644
index 000000000..fa2ef3e89
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/EnsureArgument.cs
@@ -0,0 +1,77 @@
+using System;
+
+namespace Microsoft.Git.CredentialManager
+{
+ public static class EnsureArgument
+ {
+ public static void NotNull(T arg, string name) where T : class
+ {
+ if (arg is null)
+ {
+ throw new ArgumentNullException(name);
+ }
+ }
+
+ public static void NotNullOrEmpty(string arg, string name)
+ {
+ NotNull(arg, name);
+
+ if (string.IsNullOrEmpty(arg))
+ {
+ throw new ArgumentException("Argument cannot be empty.", name);
+ }
+ }
+
+ public static void NotNullOrWhiteSpace(string arg, string name)
+ {
+ NotNull(arg, name);
+
+ if (string.IsNullOrWhiteSpace(arg))
+ {
+ throw new ArgumentException("Argument cannot be empty or white space.", name);
+ }
+ }
+
+ public static void AbsoluteUri(Uri arg, string name)
+ {
+ NotNull(arg, name);
+
+ if (!arg.IsAbsoluteUri)
+ {
+ throw new ArgumentException("Argument must be an absolute URI.", name);
+ }
+ }
+
+ public static void PositiveOrZero(int arg, string name)
+ {
+ if (arg < 0)
+ {
+ throw new ArgumentOutOfRangeException(name, "Argument must be positive or zero (non-negative).");
+ }
+ }
+
+ public static void Positive(int arg, string name)
+ {
+ if (arg <= 0)
+ {
+ throw new ArgumentOutOfRangeException(name, "Argument must be positive.");
+ }
+ }
+
+ public static void NegativeOrZero(int arg, string name)
+ {
+ if (arg > 0)
+ {
+ throw new ArgumentOutOfRangeException(name, "Argument must be negative or zero (non-positive).");
+ }
+ }
+
+ public static void Negative(int arg, string name)
+ {
+ if (arg >= 0)
+ {
+ throw new ArgumentOutOfRangeException(name, "Argument must be negative.");
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/FileSystem.cs b/src/Microsoft.Git.CredentialManager/FileSystem.cs
new file mode 100644
index 000000000..1d9a5a959
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/FileSystem.cs
@@ -0,0 +1,55 @@
+using System.IO;
+
+namespace Microsoft.Git.CredentialManager
+{
+ ///
+ /// Represents a file system and operations that can be performed.
+ ///
+ public interface IFileSystem
+ {
+ ///
+ /// Check if a file exists at the specified path.
+ ///
+ /// Full path to file to test.
+ /// True if a file exists, false otherwise.
+ bool FileExists(string path);
+
+ ///
+ /// Check if a directory exists at the specified path.
+ ///
+ /// Full path to directory to test.
+ /// True if a directory exists, false otherwise.
+ bool DirectoryExists(string path);
+
+ ///
+ /// Get the path to the current directory of the currently executing process.
+ ///
+ /// Current process directory.
+ string GetCurrentDirectory();
+
+ ///
+ /// Open a file stream at the specified path with the given access and mode settings.
+ ///
+ /// Full file path.
+ /// File mode settings.
+ /// File access settings.
+ /// File share settings.
+ ///
+ Stream OpenFileStream(string path, FileMode fileMode, FileAccess fileAccess, FileShare fileShare);
+ }
+
+ ///
+ /// The real file system.
+ ///
+ public class FileSystem : IFileSystem
+ {
+ public bool FileExists(string path) => File.Exists(path);
+
+ public bool DirectoryExists(string path) => Directory.Exists(path);
+
+ public string GetCurrentDirectory() => Directory.GetCurrentDirectory();
+
+ public Stream OpenFileStream(string path, FileMode fileMode, FileAccess fileAccess, FileShare fileShare)
+ => File.Open(path, fileMode, fileAccess, fileShare);
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/GitCredential.cs b/src/Microsoft.Git.CredentialManager/GitCredential.cs
new file mode 100644
index 000000000..821258cad
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/GitCredential.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Globalization;
+using System.Text;
+
+namespace Microsoft.Git.CredentialManager
+{
+ ///
+ /// Represents a credential (username/password pair) that Git can use to authenticate to a remote repository.
+ ///
+ public class GitCredential
+ {
+ public GitCredential(string userName, string password)
+ {
+ UserName = userName;
+ Password = password;
+ }
+
+ ///
+ /// User name.
+ ///
+ public string UserName { get; }
+
+ ///
+ /// Password.
+ ///
+ public string Password { get; }
+
+ ///
+ /// Returns the base-64 encoded, {username}:{password} formatted string of this ``.
+ ///
+ public string ToBase64String()
+ {
+ string basicAuthValue = string.Format(CultureInfo.InvariantCulture, "{0}:{1}", UserName, Password);
+ byte[] authBytes = Encoding.UTF8.GetBytes(basicAuthValue);
+ return Convert.ToBase64String(authBytes);
+ }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/HostProvider.cs b/src/Microsoft.Git.CredentialManager/HostProvider.cs
new file mode 100644
index 000000000..8cbad8dca
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/HostProvider.cs
@@ -0,0 +1,81 @@
+using System.Threading.Tasks;
+
+namespace Microsoft.Git.CredentialManager
+{
+ ///
+ /// Represents a particular Git hosting service and provides for the creation of credentials to access the remote.
+ ///
+ public interface IHostProvider
+ {
+ ///
+ /// Name of the hosting provider.
+ ///
+ string Name { get; }
+
+ ///
+ /// Determine if the are recognized by this particular Git hosting provider.
+ ///
+ /// Input arguments of a Git credential query.
+ /// True if the provider supports the Git credential request, false otherwise.
+ bool IsSupported(InputArguments input);
+
+ ///
+ /// Return a key that uniquely represents the given Git credential query arguments.
+ ///
+ ///
+ /// This key forms part of the identifier used to retrieve and store credentials from the OS secure
+ /// credential storage system. It is important the returned value is stable over time to avoid any
+ /// potential re-authentication requests.
+ ///
+ /// Input arguments of a Git credential query.
+ ///
+ string GetCredentialKey(InputArguments input);
+
+ ///
+ /// Create a new credential for accessing the remote Git repository on this hosting service.
+ ///
+ /// Input arguments of a Git credential query.
+ /// A new credential Git can use to authenticate to the remote repository.
+ Task CreateCredentialAsync(InputArguments input);
+
+ ///
+ /// Whether or not s created by this provider should be stored in the
+ /// secure credential storage system when the call to is made.
+ ///
+ ///
+ /// Some host providers may wish to or need to store s created by them
+ /// immediately after they are created, rather than at a later time when requested to do so by Git.
+ /// One example reason for this is that the provider is unable to create the same credential key
+ /// due to missing information in the on subsequent calls to
+ /// .
+ ///
+ /// If this property returns true, Git Credential Manager will store any credential created by this
+ /// provider during the `git credential-helper get` call, rather than in a `store` call (which will
+ /// now be a no-op).
+ ///
+ bool IsCredentialStoredOnCreation { get; }
+ }
+
+ public abstract class HostProvider : IHostProvider
+ {
+ protected HostProvider(ICommandContext context)
+ {
+ Context = context;
+ }
+
+ ///
+ /// The current command execution context.
+ ///
+ protected ICommandContext Context { get; }
+
+ public abstract string Name { get; }
+
+ public abstract bool IsSupported(InputArguments input);
+
+ public abstract string GetCredentialKey(InputArguments input);
+
+ public abstract Task CreateCredentialAsync(InputArguments input);
+
+ public abstract bool IsCredentialStoredOnCreation { get; }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/HostProviderRegistry.cs b/src/Microsoft.Git.CredentialManager/HostProviderRegistry.cs
new file mode 100644
index 000000000..4987655c3
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/HostProviderRegistry.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Microsoft.Git.CredentialManager
+{
+ ///
+ /// Represents a collection of s which are selected based on Git credential query
+ /// .
+ ///
+ public interface IHostProviderRegistry
+ {
+ ///
+ /// Add the given (s) to this registry.
+ ///
+ /// A collection of providers to register.
+ void Register(params IHostProvider[] hostProviders);
+
+ ///
+ /// Select a that can service the Git credential query based on the
+ /// .
+ ///
+ /// Input arguments of a Git credential query.
+ /// A host provider that can service the given query.
+ IHostProvider GetProvider(InputArguments input);
+ }
+
+ ///
+ /// A simple host provider registry where each provider is queried in registration order until the first
+ /// provider that supports the credential query is found.
+ ///
+ public class HostProviderRegistry : IHostProviderRegistry
+ {
+ private readonly List _hostProviders = new List();
+
+ public void Register(params IHostProvider[] hostProviders)
+ {
+ if (hostProviders == null)
+ {
+ throw new ArgumentNullException(nameof(hostProviders));
+ }
+
+ _hostProviders.AddRange(hostProviders);
+ }
+
+ public IHostProvider GetProvider(InputArguments input)
+ {
+ var provider = _hostProviders.FirstOrDefault(x => x.IsSupported(input));
+
+ if (provider == null)
+ {
+ throw new Exception("No host provider available to service this request.");
+ }
+
+ return provider;
+ }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/InputArguments.cs b/src/Microsoft.Git.CredentialManager/InputArguments.cs
new file mode 100644
index 000000000..ff354230f
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/InputArguments.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace Microsoft.Git.CredentialManager
+{
+ ///
+ /// Represents the input for a Git credential query such as get, erase, or store.
+ ///
+ ///
+ /// This class surfaces the input that is streamed over standard in from Git which provides
+ /// the credential helper the remote repository information, including the protocol, host,
+ /// and remote repository path.
+ ///
+ public class InputArguments
+ {
+ private readonly IReadOnlyDictionary _dict;
+
+ public InputArguments(IDictionary dict)
+ {
+ if (dict == null)
+ {
+ throw new ArgumentNullException(nameof(dict));
+ }
+
+ // Wrap the dictionary internally as readonly
+ _dict = new ReadOnlyDictionary(dict);
+ }
+
+ #region Common Arguments
+
+ 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");
+
+ #endregion
+
+ #region Public Methods
+
+ public string this[string key]
+ {
+ get => GetArgumentOrDefault(key);
+ }
+
+ public string GetArgumentOrDefault(string key)
+ {
+ return _dict.TryGetValue(key, out string value) ? value : null;
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj b/src/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj
new file mode 100644
index 000000000..52d4ca7ca
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/Microsoft.Git.CredentialManager.csproj
@@ -0,0 +1,10 @@
+
+
+
+ netstandard2.0
+ latest
+ Microsoft.Git.CredentialManager
+ Microsoft.Git.CredentialManager
+
+
+
diff --git a/src/Microsoft.Git.CredentialManager/PlatformUtils.cs b/src/Microsoft.Git.CredentialManager/PlatformUtils.cs
new file mode 100644
index 000000000..295f2dff3
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/PlatformUtils.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Git.CredentialManager
+{
+ public static class PlatformUtils
+ {
+ ///
+ /// Get information about the Operating System.
+ ///
+ ///
+ public static string GetOSInfo()
+ {
+ if (IsWindows())
+ {
+ return "Windows";
+ }
+
+ if (IsMacOS())
+ {
+ return "macOS";
+ }
+
+ if (IsLinux())
+ {
+ return "Linux";
+ }
+
+ return "Unknown";
+ }
+
+ ///
+ /// Check if the current Operating System is macOS.
+ ///
+ /// True if running on macOS, false otherwise.
+ public static bool IsMacOS()
+ {
+ return RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
+ }
+
+ ///
+ /// Check if the current Operating System is Windows.
+ ///
+ /// True if running on Windows, false otherwise.
+ public static bool IsWindows()
+ {
+ return RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+ }
+
+ ///
+ /// Check if the current Operating System is Linux-based.
+ ///
+ /// True if running on a Linux distribution, false otherwise.
+ public static bool IsLinux()
+ {
+ return RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
+ }
+
+ ///
+ /// Ensure the current Operating System is macOS, fail otherwise.
+ ///
+ /// Thrown if the current OS is not macOS.
+ public static void EnsureMacOS()
+ {
+ if (!IsMacOS())
+ {
+ throw new PlatformNotSupportedException();
+ }
+ }
+
+ ///
+ /// Ensure the current Operating System is Windows, fail otherwise.
+ ///
+ /// Thrown if the current OS is not Windows.
+ public static void EnsureWindows()
+ {
+ if (!IsWindows())
+ {
+ throw new PlatformNotSupportedException();
+ }
+ }
+
+ ///
+ /// Ensure the current Operating System is Linux-based, fail otherwise.
+ ///
+ /// Thrown if the current OS is not Linux-based.
+ public static void EnsureLinux()
+ {
+ if (!IsLinux())
+ {
+ throw new PlatformNotSupportedException();
+ }
+ }
+
+ ///
+ /// Wait until a debugger has attached to the currently executing process.
+ ///
+ public static void WaitForDebuggerAttached()
+ {
+ // Attempt to launch the debugger if the OS supports the explicit launching
+ if (!Debugger.Launch())
+ {
+ // The prompt to debug was declined
+ return;
+ }
+
+ // Wait for the debugger to attach and poll & sleep until then
+ while (!Debugger.IsAttached)
+ {
+ Thread.Sleep(TimeSpan.FromSeconds(1));
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/StreamExtensions.cs b/src/Microsoft.Git.CredentialManager/StreamExtensions.cs
new file mode 100644
index 000000000..ffbcfc205
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/StreamExtensions.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Microsoft.Git.CredentialManager
+{
+ public static class StreamExtensions
+ {
+ ///
+ /// Read a dictionary in the form `key1=value\nkey2=value\n\n` from the specified .
+ ///
+ /// Uses the comparer for dictionary keys.
+ /// Text reader to read a dictionary from.
+ /// Dictionary read from the text reader.
+ public static IDictionary ReadDictionary(this TextReader reader) =>
+ ReadDictionary(reader, StringComparer.Ordinal);
+
+ ///
+ /// Read a dictionary in the form `key1=value\nkey2=value\n\n` from the specified ,
+ /// with the specified used to compare dictionary keys.
+ ///
+ /// Text reader to read a dictionary from.
+ /// Comparer to use when comparing dictionary keys.
+ /// Dictionary read from the text reader.
+ public static IDictionary ReadDictionary(this TextReader reader, StringComparer comparer)
+ {
+ var dict = new Dictionary(comparer);
+
+ string line;
+ while ((line = reader.ReadLine()) != null && !string.IsNullOrWhiteSpace(line))
+ {
+ ParseLine(dict, line);
+ }
+
+ return dict;
+ }
+
+ ///
+ /// Asynchronously read a dictionary in the form `key1=value\nkey2=value\n\n` from the specified .
+ ///
+ /// Uses the comparer for dictionary keys.
+ /// Text reader to read a dictionary from.
+ /// Dictionary read from the text reader.
+ public static Task> ReadDictionaryAsync(this TextReader reader) =>
+ ReadDictionaryAsync(reader, StringComparer.Ordinal);
+
+ ///
+ /// Asynchronously read a dictionary in the form `key1=value\nkey2=value\n\n` from the specified ,
+ /// with the specified used to compare dictionary keys.
+ ///
+ /// Text reader to read a dictionary from.
+ /// Comparer to use when comparing dictionary keys.
+ /// Dictionary read from the text reader.
+ public static async Task> ReadDictionaryAsync(this TextReader reader, StringComparer comparer)
+ {
+ var dict = new Dictionary(comparer);
+
+ string line;
+ while ((line = await reader.ReadLineAsync()) != null && !string.IsNullOrWhiteSpace(line))
+ {
+ ParseLine(dict, line);
+ }
+
+ return dict;
+ }
+
+ ///
+ /// Write a dictionary in the form `key1=value\nkey2=value\n\n` to the specified .
+ ///
+ /// Text writer to write a dictionary to.
+ /// Dictionary to write to the text writer.
+ public static void WriteDictionary(this TextWriter writer, IDictionary dict)
+ {
+ foreach (var kvp in dict)
+ {
+ WriteKeyValuePair(writer, kvp);
+ }
+
+ // Write terminating line
+ writer.WriteLine();
+ }
+
+ ///
+ /// Asynchronously write a dictionary in the form `key1=value\nkey2=value\n\n` to the specified .
+ ///
+ /// Text writer to write a dictionary to.
+ /// Dictionary to write to the text writer.
+ public static async Task WriteDictionaryAsync(this TextWriter writer, IDictionary dict)
+ {
+ foreach (var kvp in dict)
+ {
+ await WriteKeyValuePairAsync(writer, kvp);
+ }
+
+ // Write terminating line
+ await writer.WriteLineAsync();
+ }
+
+ private static void WriteKeyValuePair(this TextWriter writer, KeyValuePair kvp)
+ => WriteKeyValuePair(writer, kvp.Key, kvp.Value);
+
+ private static void WriteKeyValuePair(this TextWriter writer, string key, string value)
+ {
+ writer.WriteLine("{0}={1}", key, value);
+ }
+
+ private static Task WriteKeyValuePairAsync(this TextWriter writer, KeyValuePair kvp)
+ => WriteKeyValuePairAsync(writer, kvp.Key, kvp.Value);
+
+ private static Task WriteKeyValuePairAsync(this TextWriter writer, string key, string value)
+ {
+ return writer.WriteLineAsync($"{key}={value}");
+ }
+
+ private static void ParseLine(IDictionary dict, string line)
+ {
+ int splitIndex = line.IndexOf('=');
+ if (splitIndex > 0)
+ {
+ string key = line.Substring(0, splitIndex);
+ string value = line.Substring(splitIndex + 1);
+
+ dict[key] = value;
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/StringExtensions.cs b/src/Microsoft.Git.CredentialManager/StringExtensions.cs
new file mode 100644
index 000000000..86d09ca60
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/StringExtensions.cs
@@ -0,0 +1,27 @@
+using System;
+
+namespace Microsoft.Git.CredentialManager
+{
+ public static class StringExtensions
+ {
+ ///
+ /// Check if the string is considered to be 'truthy' (a value considered equivalent to 'true').
+ ///
+ ///
+ /// Git considers several different values to be equivalent to 'true'; we try to be consistent with this
+ /// behavior.
+ ///
+ /// See the following Git documentation for a list of values considered to be equivalent to 'true':
+ /// https://git-scm.com/docs/git-config#git-config-boolean
+ ///
+ /// String value to check.
+ /// True if the value is 'truthy', false otherwise.
+ public static bool IsTruthy(this string str)
+ {
+ return StringComparer.OrdinalIgnoreCase.Equals(str, bool.TrueString) ||
+ StringComparer.OrdinalIgnoreCase.Equals(str, "1") ||
+ StringComparer.OrdinalIgnoreCase.Equals(str, "on") ||
+ StringComparer.OrdinalIgnoreCase.Equals(str, "yes");
+ }
+ }
+}
diff --git a/src/Microsoft.Git.CredentialManager/Trace.cs b/src/Microsoft.Git.CredentialManager/Trace.cs
new file mode 100644
index 000000000..d4e278a0b
--- /dev/null
+++ b/src/Microsoft.Git.CredentialManager/Trace.cs
@@ -0,0 +1,277 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace Microsoft.Git.CredentialManager
+{
+ ///
+ /// Represents the application's tracing system.
+ ///
+ public interface ITrace
+ {
+ ///
+ /// Get or set whether or not sensitive information such as secrets and credentials should be
+ /// output to attached trace listeners.
+ ///
+ bool EnableSecretTracing { get; set; }
+
+ ///
+ /// Add a listener to the trace writer.
+ ///
+ /// The listener to add.
+ void AddListener(TextWriter listener);
+
+ ///
+ /// Forces any pending trace messages to be written to any listeners.
+ ///
+ void Flush();
+
+ ///
+ /// Writes an exception as a message to the trace writer.
+ ///
+ /// Expands exceptions' inner exceptions into additional trace lines.
+ ///
+ /// The exception to write.
+ /// Path of the file this method is called from.
+ /// Line number of file this method is called from.
+ /// Name of the member in which this method is called.
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")]
+ void WriteException(
+ Exception exception,
+ [System.Runtime.CompilerServices.CallerFilePath] string filePath = "",
+ [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0,
+ [System.Runtime.CompilerServices.CallerMemberName] string memberName = "");
+
+ ///
+ /// Write the contents of a dictionary to the trace writer.
+ ///
+ /// Calls on all keys and values.
+ ///
+ /// The dictionary to write.
+ /// Path of the file this method is called from.
+ /// Line number of file this method is called from.
+ /// Name of the member in which this method is called.
+ void WriteDictionary(
+ IDictionary dictionary,
+ [System.Runtime.CompilerServices.CallerFilePath] string filePath = "",
+ [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0,
+ [System.Runtime.CompilerServices.CallerMemberName] string memberName = "");
+
+ ///
+ /// Writes a message to the trace writer followed by a line terminator.
+ ///
+ /// The message to write.
+ /// Path of the file this method is called from.
+ /// Line number of file this method is called from.
+ /// Name of the member in which this method is called.
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")]
+ void WriteLine(
+ string message,
+ [System.Runtime.CompilerServices.CallerFilePath] string filePath = "",
+ [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0,
+ [System.Runtime.CompilerServices.CallerMemberName] string memberName = "");
+
+ ///
+ /// Writes a message containing sensitive information to the trace writer followed by a line terminator.
+ ///
+ /// Attached listeners will only receive the fully formatted message if is set
+ /// to true, otherwise the secret arguments will be masked.
+ ///
+ /// The format string to write.
+ /// Sensitive/secret arguments for the format string.
+ /// Path of the file this method is called from.
+ /// Line number of file this method is called from.
+ /// Name of the member in which this method is called.
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")]
+ void WriteLineSecrets(
+ string format,
+ object[] secrets,
+ [System.Runtime.CompilerServices.CallerFilePath] string filePath = "",
+ [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0,
+ [System.Runtime.CompilerServices.CallerMemberName] string memberName = "");
+ }
+
+ internal class Trace : ITrace, IDisposable
+ {
+ private readonly object _writersLock = new object();
+ private readonly List _writers = new List();
+
+ public bool EnableSecretTracing { get; set; }
+
+ public void AddListener(TextWriter listener)
+ {
+ lock (_writersLock)
+ {
+ // Try not to add the same listener more than once
+ if (_writers.Contains(listener))
+ return;
+
+ _writers.Add(listener);
+ }
+ }
+
+ ~Trace()
+ {
+ Dispose(true);
+ }
+
+ public void Dispose()
+ {
+ Dispose(false);
+ GC.SuppressFinalize(this);
+ }
+
+ public void Flush()
+ {
+ lock (_writersLock)
+ {
+ foreach (var writer in _writers)
+ {
+ try
+ {
+ writer?.Flush();
+ }
+ catch
+ { /* squelch */ }
+ }
+ }
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")]
+ public void WriteException(
+ Exception exception,
+ [System.Runtime.CompilerServices.CallerFilePath] string filePath = "",
+ [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0,
+ [System.Runtime.CompilerServices.CallerMemberName] string memberName = "")
+ {
+ // Exception being null probably won't happen, but we shouldn't die because we failed to trace it.
+ if (exception is null)
+ return;
+
+ WriteLine($"! error: '{exception.Message}'.", filePath, lineNumber, memberName);
+
+ while ((exception = exception.InnerException) != null)
+ {
+ WriteLine($" > '{exception.Message}'.", filePath, lineNumber, memberName);
+ }
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")]
+ public void WriteDictionary(
+ IDictionary dictionary,
+ [System.Runtime.CompilerServices.CallerFilePath] string filePath = "",
+ [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0,
+ [System.Runtime.CompilerServices.CallerMemberName] string memberName = "")
+ {
+ foreach (KeyValuePair entry in dictionary)
+ {
+ WriteLine($"\t{entry.Key}={entry.Value}", filePath, lineNumber, memberName);
+ }
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")]
+ public void WriteLine(
+ string message,
+ [System.Runtime.CompilerServices.CallerFilePath] string filePath = "",
+ [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0,
+ [System.Runtime.CompilerServices.CallerMemberName] string memberName = "")
+ {
+ lock (_writersLock)
+ {
+ if (_writers.Count == 0)
+ {
+ return;
+ }
+
+ string text = FormatText(message, filePath, lineNumber, memberName);
+
+ foreach (var writer in _writers)
+ {
+ try
+ {
+ writer?.Write(text);
+ writer?.Write('\n');
+ writer?.Flush();
+ }
+ catch { /* squelch */ }
+ }
+ }
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed")]
+ public void WriteLineSecrets(
+ string format,
+ object[] secrets,
+ [System.Runtime.CompilerServices.CallerFilePath] string filePath = "",
+ [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0,
+ [System.Runtime.CompilerServices.CallerMemberName] string memberName = "")
+ {
+ string message = this.EnableSecretTracing
+ ? string.Format(format, secrets)
+ : string.Format(format, secrets.Select(x => "********"));
+
+ WriteLine(message, filePath, lineNumber, memberName);
+ }
+
+ private void Dispose(bool finalizing)
+ {
+ if (!finalizing)
+ {
+ lock (_writersLock)
+ {
+ try
+ {
+ for (int i = 0; i < _writers.Count; i += 1)
+ {
+ using (var writer = _writers[i])
+ {
+ _writers.Remove(writer);
+ }
+ }
+ }
+ catch
+ { /* squelch */ }
+ }
+ }
+ }
+
+ private static string FormatText(string message, string filePath, int lineNumber, string memberName)
+ {
+ const int sourceColumnMaxWidth = 23;
+
+ EnsureArgument.NotNull(message, nameof(message));
+ EnsureArgument.NotNull(filePath, nameof(filePath));
+ EnsureArgument.PositiveOrZero(lineNumber, nameof(lineNumber));
+ EnsureArgument.NotNull(memberName, nameof(memberName));
+
+ // Source column format is file:line
+ string source = $"{filePath}:{lineNumber}";
+
+ if (source.Length > sourceColumnMaxWidth)
+ {
+ int idx = 0;
+ int maxlen = sourceColumnMaxWidth - 3;
+ int srclen = source.Length;
+
+ while (idx >= 0 && (srclen - idx) > maxlen)
+ {
+ idx = source.IndexOf('\\', idx + 1);
+ }
+
+ // If we cannot find a path separator which allows the path to be long enough, just truncate the file name
+ if (idx < 0)
+ {
+ idx = srclen - maxlen;
+ }
+
+ source = "..." + source.Substring(idx);
+ }
+
+ // Git's trace format is "{timestamp,-15} {source,-23} trace: {details}"
+ string text = $"{DateTime.Now:HH:mm:ss.ffffff} {source,-23} trace: [{memberName}] {message}";
+
+ return text;
+ }
+ }
+}
diff --git a/src/git-credential-manager/Application.cs b/src/git-credential-manager/Application.cs
new file mode 100644
index 000000000..f47aa73a2
--- /dev/null
+++ b/src/git-credential-manager/Application.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Git.CredentialManager.Commands;
+
+namespace Microsoft.Git.CredentialManager
+{
+ public static class Application
+ {
+ private static readonly string ExecutableName =
+ Path.GetFileName(typeof(Application).Assembly.CodeBase);
+
+ private static readonly string ApplicationHeader =
+ Constants.GetProgramHeader();
+
+ private static readonly IHostProviderRegistry HostProviderRegistry = new HostProviderRegistry();
+
+ private static readonly HelpCommand HelpCommand = new HelpCommand(ExecutableName);
+
+ private static readonly ICollection Commands = new CommandBase[]
+ {
+ new EraseCommand(HostProviderRegistry),
+ new GetCommand(HostProviderRegistry),
+ new StoreCommand(HostProviderRegistry),
+ new VersionCommand(ApplicationHeader),
+ HelpCommand,
+ };
+
+ public static async Task RunAsync(string[] args)
+ {
+ var context = new CommandContext();
+
+ // Launch debugger
+ if (context.IsEnvironmentVariableTruthy(Constants.EnvironmentVariables.GcmDebug, false))
+ {
+ context.StdError.WriteLine("Waiting for debugger to be attached...");
+ PlatformUtils.WaitForDebuggerAttached();
+
+ // Now the debugger is attached, break!
+ Debugger.Break();
+ }
+
+ // Enable tracing
+ if (context.TryGetEnvironmentVariable(Constants.EnvironmentVariables.GcmTrace, out string traceEnvar))
+ {
+ if (traceEnvar.IsTruthy()) // Trace to stderr
+ {
+ context.Trace.AddListener(context.StdError);
+ }
+ else if (Path.IsPathRooted(traceEnvar) && // Trace to a file
+ TryCreateTextWriter(context, traceEnvar, out var fileWriter))
+ {
+ context.Trace.AddListener(fileWriter);
+ }
+ else
+ {
+ context.StdError.WriteLine($"warning: cannot write trace output to {traceEnvar}");
+ }
+ }
+
+ // Enable sensitive tracing and show warning
+ if (context.IsEnvironmentVariableTruthy(Constants.EnvironmentVariables.GcmTraceSecrets, false))
+ {
+ context.Trace.EnableSecretTracing = true;
+ context.StdError.WriteLine("Secret tracing is enabled. Trace output may contain sensitive information.");
+ }
+
+ // Register all supported host providers
+ HostProviderRegistry.Register(
+ // TODO
+ );
+
+ // Trace the current version and program arguments
+ context.Trace.WriteLine($"{ApplicationHeader} '{string.Join(" ", args)}'");
+
+ if (args.Length == 0)
+ {
+ context.StdError.WriteLine("Missing command.");
+ HelpCommand.PrintUsage(context.StdError);
+ return -1;
+ }
+
+ foreach (var cmd in Commands)
+ {
+ if (cmd.CanExecute(args))
+ {
+ try
+ {
+ await cmd.ExecuteAsync(context, args);
+ return 0;
+ }
+ catch (Exception e)
+ {
+ if (e is AggregateException ae)
+ {
+ ae.Handle(x => WriteException(context, x));
+ }
+ else
+ {
+ WriteException(context, e);
+ }
+
+ return -1;
+ }
+ }
+ }
+
+ context.StdError.WriteLine("Unrecognized command '{0}'.", args[0]);
+ HelpCommand.PrintUsage(context.StdError);
+ return -1;
+ }
+
+ private static bool WriteException(ICommandContext context, Exception e)
+ {
+ context.StdError.WriteLine("fatal: {0}", e.Message);
+ if (e.InnerException != null)
+ {
+ context.StdError.WriteLine("fatal: {0}", e.InnerException.Message);
+ }
+
+ return true;
+ }
+
+ private static bool TryCreateTextWriter(ICommandContext context, string path, out TextWriter writer)
+ {
+ writer = null;
+
+ try
+ {
+ var utf8NoBomEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
+
+ var stream = context.FileSystem.OpenFileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
+ writer = new StreamWriter(stream, utf8NoBomEncoding, 4096, leaveOpen: false);
+ }
+ catch
+ {
+ // Swallow all exceptions
+ }
+
+ return writer != null;
+ }
+ }
+}
diff --git a/src/git-credential-manager/Program.cs b/src/git-credential-manager/Program.cs
new file mode 100644
index 000000000..426d36058
--- /dev/null
+++ b/src/git-credential-manager/Program.cs
@@ -0,0 +1,13 @@
+using System;
+
+namespace Microsoft.Git.CredentialManager
+{
+ public static class Program
+ {
+ public static void Main(string[] args)
+ {
+ int exitCode = Application.RunAsync(args).ConfigureAwait(false).GetAwaiter().GetResult();
+ Environment.Exit(exitCode);
+ }
+ }
+}
diff --git a/src/git-credential-manager/git-credential-manager.csproj b/src/git-credential-manager/git-credential-manager.csproj
new file mode 100644
index 000000000..a5844a4fe
--- /dev/null
+++ b/src/git-credential-manager/git-credential-manager.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ netcoreapp2.1
+ win-x64;osx-x64
+ git-credential-manager
+ Microsoft.Git.CredentialManager
+ latest
+
+
+
+
+
+
+
diff --git a/tests/Microsoft.Git.CredentialManager.Tests/Commands/HostProviderCommandBaseTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/Commands/HostProviderCommandBaseTests.cs
new file mode 100644
index 000000000..2d1d8078b
--- /dev/null
+++ b/tests/Microsoft.Git.CredentialManager.Tests/Commands/HostProviderCommandBaseTests.cs
@@ -0,0 +1,72 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Git.CredentialManager.Commands;
+using Moq;
+using Xunit;
+
+namespace Microsoft.Git.CredentialManager.Tests.Commands
+{
+ public class HostProviderCommandBaseTests
+ {
+ [Fact]
+ public async Task HostProviderCommandBase_ExecuteAsync_CallsExecuteInternalAyncWithCorrectArgs()
+ {
+ const string testProviderCredKey = "test-cred-key";
+ const string testFinalCredKey = "git:test-cred-key";
+
+ var mockContext = new Mock();
+ var mockProvider = new Mock();
+ var mockHostRegistry = new Mock();
+
+ mockHostRegistry.Setup(x => x.GetProvider(It.IsAny()))
+ .Returns(mockProvider.Object)
+ .Verifiable();
+
+ mockProvider.Setup(x => x.IsSupported(It.IsAny()))
+ .Returns(true);
+ mockProvider.Setup(x => x.GetCredentialKey(It.IsAny()))
+ .Returns(testProviderCredKey);
+
+ string standardIn = "protocol=test\nhost=example.com\npath=a/b/c\n\n";
+ TextReader standardInReader = new StringReader(standardIn);
+
+ mockContext.Setup(x => x.StdIn).Returns(standardInReader);
+ mockContext.Setup(x => x.Trace).Returns(new Mock().Object);
+
+ HostProviderCommandBase testCommand = new TestCommand(mockHostRegistry.Object)
+ {
+ VerifyExecuteInternalAsync = (context, input, provider, credentialKey) =>
+ {
+ Assert.Same(mockContext.Object, context);
+ Assert.Same(mockProvider.Object, provider);
+ Assert.Equal(testFinalCredKey, credentialKey);
+ Assert.Equal("test", input.Protocol);
+ Assert.Equal("example.com", input.Host);
+ Assert.Equal("a/b/c", input.Path);
+ }
+ };
+
+ await testCommand.ExecuteAsync(mockContext.Object, new string[0]);
+ }
+
+ private class TestCommand : HostProviderCommandBase
+ {
+ public TestCommand(IHostProviderRegistry hostProviderRegistry)
+ : base(hostProviderRegistry)
+ {
+ }
+
+ protected override string Name { get; }
+
+ protected override Task ExecuteInternalAsync(ICommandContext context, InputArguments input,
+ IHostProvider provider, string credentialKey)
+ {
+ VerifyExecuteInternalAsync(context, input, provider, credentialKey);
+ return Task.CompletedTask;
+ }
+
+ public Action VerifyExecuteInternalAsync { get; set; }
+ }
+ }
+}
diff --git a/tests/Microsoft.Git.CredentialManager.Tests/GitCredentialTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/GitCredentialTests.cs
new file mode 100644
index 000000000..0fb92c140
--- /dev/null
+++ b/tests/Microsoft.Git.CredentialManager.Tests/GitCredentialTests.cs
@@ -0,0 +1,59 @@
+using Xunit;
+
+namespace Microsoft.Git.CredentialManager.Tests
+{
+ public class GitCredentialTests
+ {
+ [Fact]
+ public void GitCredential_ToBase64String_ComplexUserPass_ReturnsCorrectString()
+ {
+ const string expected = "aGVsbG8tbXlfbmFtZSBpczpqb2huLmRvZTp0aGlzIWlzQVA0U1NXMFJEOiB3aXRoPyBfbG90cyBvZi8gY2hhcnM=";
+ const string testUserName = "hello-my_name is:john.doe";
+ const string testPassword = "this!isAP4SSW0RD: with? _lots of/ chars";
+
+ var credential = new GitCredential(testUserName, testPassword);
+ string actual = credential.ToBase64String();
+
+ Assert.Equal(expected, actual);
+ }
+
+ [Fact]
+ public void GitCredential_ToBase64String_EmptyUserName_ReturnsCorrectString()
+ {
+ const string expected = "OmxldG1laW4xMjM=";
+ const string testUserName = "";
+ const string testPassword = "letmein123";
+
+ var credential = new GitCredential(testUserName, testPassword);
+ string actual = credential.ToBase64String();
+
+ Assert.Equal(expected, actual);
+ }
+
+ [Fact]
+ public void GitCredential_ToBase64String_EmptyPassword_ReturnsCorrectString()
+ {
+ const string expected = "am9obi5kb2U6";
+ const string testUserName = "john.doe";
+ const string testPassword = "";
+
+ var credential = new GitCredential(testUserName, testPassword);
+ string actual = credential.ToBase64String();
+
+ Assert.Equal(expected, actual);
+ }
+
+ [Fact]
+ public void GitCredential_ToBase64String_EmptyCredential_ReturnsCorrectString()
+ {
+ const string expected = "Og==";
+ const string testUserName = "";
+ const string testPassword = "";
+
+ var credential = new GitCredential(testUserName, testPassword);
+ string actual = credential.ToBase64String();
+
+ Assert.Equal(expected, actual);
+ }
+ }
+}
diff --git a/tests/Microsoft.Git.CredentialManager.Tests/HostProviderRegistryTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/HostProviderRegistryTests.cs
new file mode 100644
index 000000000..6da9e8f5c
--- /dev/null
+++ b/tests/Microsoft.Git.CredentialManager.Tests/HostProviderRegistryTests.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Collections.Generic;
+using Moq;
+using Xunit;
+
+namespace Microsoft.Git.CredentialManager.Tests
+{
+ public class HostProviderRegistryTests
+ {
+ [Fact]
+ public void HostProviderRegistry_NoProviders_ThrowException()
+ {
+ var registry = new HostProviderRegistry();
+ var input = new InputArguments(new Dictionary());
+
+ Assert.Throws(() => registry.GetProvider(input));
+ }
+
+ [Fact]
+ public void HostProviderRegistry_HasProviders_ReturnsSupportedProvider()
+ {
+ var registry = new HostProviderRegistry();
+ var input = new InputArguments(new Dictionary());
+
+ var provider1 = new Mock();
+ var provider2 = new Mock();
+ var provider3 = new Mock();
+
+ provider1.Setup(x => x.IsSupported(It.IsAny())).Returns(false);
+ provider2.Setup(x => x.IsSupported(It.IsAny())).Returns(true);
+ provider3.Setup(x => x.IsSupported(It.IsAny())).Returns(false);
+
+ registry.Register(provider1.Object, provider2.Object, provider3.Object);
+
+ IHostProvider result = registry.GetProvider(input);
+
+ Assert.Same(provider2.Object, result);
+ }
+
+ [Fact]
+ public void HostProviderRegistry_MultipleValidProviders_ReturnsFirstRegistered()
+ {
+ var registry = new HostProviderRegistry();
+ var input = new InputArguments(new Dictionary());
+
+ var provider1 = new Mock();
+ var provider2 = new Mock();
+ var provider3 = new Mock();
+
+ provider1.Setup(x => x.IsSupported(It.IsAny())).Returns(true);
+ provider2.Setup(x => x.IsSupported(It.IsAny())).Returns(true);
+ provider3.Setup(x => x.IsSupported(It.IsAny())).Returns(true);
+
+ registry.Register(provider1.Object, provider2.Object, provider3.Object);
+
+ IHostProvider result = registry.GetProvider(input);
+
+ Assert.Same(provider1.Object, result);
+ }
+ }
+}
diff --git a/tests/Microsoft.Git.CredentialManager.Tests/InputArgumentsTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/InputArgumentsTests.cs
new file mode 100644
index 000000000..01b7bdc72
--- /dev/null
+++ b/tests/Microsoft.Git.CredentialManager.Tests/InputArgumentsTests.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Collections.Generic;
+using Xunit;
+
+namespace Microsoft.Git.CredentialManager.Tests
+{
+ public class InputArgumentsTests
+ {
+ [Fact]
+ public void InputArguments_Ctor_Null_ThrowsArgNullException()
+ {
+ Assert.Throws(() => new InputArguments(null));
+ }
+
+ [Fact]
+ public void InputArguments_CommonArguments_ValuePresent_ReturnsValues()
+ {
+ var dict = new Dictionary
+ {
+ ["protocol"] = "https",
+ ["host"] = "example.com",
+ ["path"] = "an/example/path",
+ ["username"] = "john.doe",
+ ["password"] = "password123"
+ };
+
+ var inputArgs = new InputArguments(dict);
+
+ Assert.Equal("https", inputArgs.Protocol);
+ Assert.Equal("example.com", inputArgs.Host);
+ Assert.Equal("an/example/path", inputArgs.Path);
+ Assert.Equal("john.doe", inputArgs.UserName);
+ Assert.Equal("password123", inputArgs.Password);
+ }
+
+ [Fact]
+ public void InputArguments_CommonArguments_ValueMissing_ReturnsNull()
+ {
+ var dict = new Dictionary();
+
+ var inputArgs = new InputArguments(dict);
+
+ Assert.Null(inputArgs.Protocol);
+ Assert.Null(inputArgs.Host);
+ Assert.Null(inputArgs.Path);
+ Assert.Null(inputArgs.UserName);
+ Assert.Null(inputArgs.Password);
+ }
+
+ [Fact]
+ public void InputArguments_OtherArguments()
+ {
+ var dict = new Dictionary
+ {
+ ["foo"] = "bar"
+ };
+
+ var inputArgs = new InputArguments(dict);
+
+ Assert.Equal("bar", inputArgs["foo"]);
+ Assert.Equal("bar", inputArgs.GetArgumentOrDefault("foo"));
+ }
+ }
+}
diff --git a/tests/Microsoft.Git.CredentialManager.Tests/Microsoft.Git.CredentialManager.Tests.csproj b/tests/Microsoft.Git.CredentialManager.Tests/Microsoft.Git.CredentialManager.Tests.csproj
new file mode 100644
index 000000000..9dcdd58b4
--- /dev/null
+++ b/tests/Microsoft.Git.CredentialManager.Tests/Microsoft.Git.CredentialManager.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ netcoreapp2.1
+
+ false
+
+ Microsoft.Git.CredentialManager.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Microsoft.Git.CredentialManager.Tests/PlatformFactAttribute.cs b/tests/Microsoft.Git.CredentialManager.Tests/PlatformFactAttribute.cs
new file mode 100644
index 000000000..6244da6c0
--- /dev/null
+++ b/tests/Microsoft.Git.CredentialManager.Tests/PlatformFactAttribute.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Linq;
+using System.Runtime.InteropServices;
+using Xunit;
+
+namespace Microsoft.Git.CredentialManager.Tests
+{
+ public class PlatformFactAttribute : FactAttribute
+ {
+ public PlatformFactAttribute(params Platform[] platforms)
+ {
+ if (platforms.Contains(Platform.Windows) && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return;
+ }
+
+ if (platforms.Contains(Platform.MacOS) && RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ return;
+ }
+
+ if (platforms.Contains(Platform.Linux) && RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ return;
+ }
+
+ Skip = "Test not supported on this platform.";
+ }
+ }
+
+ public enum Platform
+ {
+ Windows,
+ MacOS,
+ Linux,
+ }
+}
diff --git a/tests/Microsoft.Git.CredentialManager.Tests/StreamExtensionsTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/StreamExtensionsTests.cs
new file mode 100644
index 000000000..be2738249
--- /dev/null
+++ b/tests/Microsoft.Git.CredentialManager.Tests/StreamExtensionsTests.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Xunit;
+
+namespace Microsoft.Git.CredentialManager.Tests
+{
+ public class StreamExtensionsTests
+ {
+ [Fact]
+ public void StreamExtensions_ReadDictionary_EmptyString_ReturnsEmptyDictionary()
+ {
+ string input = string.Empty;
+
+ var output = ReadStringStream(input, StreamExtensions.ReadDictionary);
+
+ Assert.NotNull(output);
+ Assert.Equal(0, output.Count);
+ }
+
+ [Fact]
+ public void StreamExtensions_ReadDictionary_TerminatedLF_ReturnsDictionary()
+ {
+ string input = "a=1\nb=2\nc=3\n\n";
+
+ var output = ReadStringStream(input, StreamExtensions.ReadDictionary);
+
+ Assert.NotNull(output);
+ Assert.Equal(3, output.Count);
+ Assert.Contains(KeyValuePair.Create("a", "1"), output);
+ Assert.Contains(KeyValuePair.Create("b", "2"), output);
+ Assert.Contains(KeyValuePair.Create("c", "3"), output);
+ }
+
+ [Fact]
+ public void StreamExtensions_ReadDictionary_TerminatedCRLF_ReturnsDictionary()
+ {
+ string input = "a=1\r\nb=2\r\nc=3\r\n\r\n";
+
+ var output = ReadStringStream(input, StreamExtensions.ReadDictionary);
+
+ Assert.NotNull(output);
+ Assert.Equal(3, output.Count);
+ Assert.Contains(KeyValuePair.Create("a", "1"), output);
+ Assert.Contains(KeyValuePair.Create("b", "2"), output);
+ Assert.Contains(KeyValuePair.Create("c", "3"), output);
+ }
+
+ [Fact]
+ public void StreamExtensions_ReadDictionary_CaseSensitive_ReturnsDictionaryWithMultipleEntries()
+ {
+ string input = "a=1\nA=2\n\n";
+
+ var output = ReadStringStream(input, x => StreamExtensions.ReadDictionary(x, StringComparer.Ordinal));
+
+ Assert.NotNull(output);
+ Assert.Equal(2, output.Count);
+ Assert.Contains(KeyValuePair.Create("a", "1"), output);
+ Assert.Contains(KeyValuePair.Create("A", "2"), output);
+ }
+
+ [Fact]
+ public void StreamExtensions_ReadDictionary_CaseInsensitive_ReturnsDictionaryWithLastValue()
+ {
+ string input = "a=1\nA=2\n\n";
+
+ var output = ReadStringStream(input, x => StreamExtensions.ReadDictionary(x, StringComparer.OrdinalIgnoreCase));
+
+ Assert.NotNull(output);
+ Assert.Equal(1, output.Count);
+ Assert.Contains(KeyValuePair.Create("a", "2"), output);
+ }
+
+ [Fact]
+ public void StreamExtensions_ReadDictionary_Spaces_ReturnsCorrectKeysAndValues()
+ {
+ string input = "key a=value 1\n key b = 2 \nkey\tc\t=\t3\t\n\n";
+
+ var output = ReadStringStream(input, StreamExtensions.ReadDictionary);
+
+ Assert.NotNull(output);
+ Assert.Equal(3, output.Count);
+ Assert.Contains(KeyValuePair.Create("key a", "value 1"), output);
+ Assert.Contains(KeyValuePair.Create(" key b ", " 2 "), output);
+ Assert.Contains(KeyValuePair.Create("key\tc\t", "\t3\t"), output);
+ }
+
+ [Fact]
+ public void StreamExtensions_ReadDictionary_EqualsInValues_ReturnsCorrectKeysAndValues()
+ {
+ string input = "a=value=1\nb=value=2\nc=value=3\n\n";
+
+ var output = ReadStringStream(input, StreamExtensions.ReadDictionary);
+
+ Assert.NotNull(output);
+ Assert.Equal(3, output.Count);
+ Assert.Contains(KeyValuePair.Create("a", "value=1"), output);
+ Assert.Contains(KeyValuePair.Create("b", "value=2"), output);
+ Assert.Contains(KeyValuePair.Create("c", "value=3"), output);
+ }
+
+ [Fact]
+ public void StreamExtensions_WriteDictionary_EmptyDictionary_WritesLineTerminator()
+ {
+ var input = new Dictionary();
+
+ string output = WriteStringStream(input, StreamExtensions.WriteDictionary);
+
+ Assert.Equal(Environment.NewLine, output);
+ }
+
+ [Fact]
+ public void StreamExtensions_WriteDictionary_Entries_WritesKVPsAndLineTerminator()
+ {
+ var input = new Dictionary
+ {
+ ["a"] = "1",
+ ["b"] = "2",
+ ["c"] = "3"
+ };
+
+ string output = WriteStringStream(input, StreamExtensions.WriteDictionary);
+
+ Assert.Equal("a=1\nb=2\nc=3\n\n", output);
+ }
+
+ [Fact]
+ public void StreamExtensions_WriteDictionary_EntriesWithSpaces_WritesKVPsAndLineTerminator()
+ {
+ var input = new Dictionary
+ {
+ ["key a"] = "value 1",
+ [" key b "] = " value 2 ",
+ ["\tvalue\tc\t"] = "\t3\t"
+ };
+
+ string output = WriteStringStream(input, StreamExtensions.WriteDictionary);
+
+ Assert.Equal("key a=value 1\n key b = value 2 \n\tvalue\tc\t=\t3\t\n\n", output);
+ }
+
+ #region Helpers
+
+ private static IDictionary ReadStringStream(string input, Func> func)
+ {
+ IDictionary output;
+ using (var reader = new StringReader(input))
+ {
+ output = func(reader);
+ }
+
+ return output;
+ }
+
+ private static string WriteStringStream(IDictionary input, Action> action)
+ {
+ var output = new StringBuilder();
+ using (var writer = new StringWriter(output))
+ {
+ action(writer, input);
+ }
+
+ return output.ToString();
+ }
+
+ #endregion
+ }
+}
diff --git a/tests/Microsoft.Git.CredentialManager.Tests/StringExtensionsTests.cs b/tests/Microsoft.Git.CredentialManager.Tests/StringExtensionsTests.cs
new file mode 100644
index 000000000..35b6906b6
--- /dev/null
+++ b/tests/Microsoft.Git.CredentialManager.Tests/StringExtensionsTests.cs
@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+using Xunit;
+
+namespace Microsoft.Git.CredentialManager.Tests
+{
+ public class StringExtensionsTests
+ {
+ [Theory]
+ [InlineData("true", true)]
+ [InlineData("TRUE", true)]
+ [InlineData("tRuE", true)]
+ [InlineData("yes", true)]
+ [InlineData("YES", true)]
+ [InlineData("yEs", true)]
+ [InlineData("on", true)]
+ [InlineData("ON", true)]
+ [InlineData("oN", true)]
+ [InlineData("1", true)]
+ [InlineData("false", false)]
+ [InlineData("i am a random string", false)]
+ [InlineData("", false)]
+ [InlineData(" ", false)]
+ [InlineData("\t", false)]
+ [InlineData(null, false)]
+ public void StringExtensions_IsTruthy(string input, bool expected)
+ {
+ if (expected)
+ {
+ Assert.True(StringExtensions.IsTruthy(input));
+ }
+ else
+ {
+ Assert.False(StringExtensions.IsTruthy(input));
+ }
+ }
+ }
+}