diff --git a/GVFS/GVFS.Common/GVFSPlatform.cs b/GVFS/GVFS.Common/GVFSPlatform.cs index 5b232628aa..964b8d6acd 100644 --- a/GVFS/GVFS.Common/GVFSPlatform.cs +++ b/GVFS/GVFS.Common/GVFSPlatform.cs @@ -65,6 +65,20 @@ public static void Register(GVFSPlatform platform) /// public abstract void PrepareProcessToRunInBackground(); + /// + /// Returns true if the current process owns a visible console window that a child process + /// (e.g. git launching the credential manager) can use as the owner for an interactive prompt. + /// + /// + /// This inspects the current process's console window and its root owner, not arbitrary GUI + /// windows. Background processes such as GVFS.Mount run with a hidden console, so this returns + /// false for them; foreground processes launched from a visible console return true. Credential + /// helpers launched by a process with no visible console window should be started detached from + /// the console, so the credential manager parents its prompt to a topmost stub window instead + /// of the hidden console. + /// + public abstract bool CurrentProcessHasVisibleConsoleWindow(); + public abstract bool IsProcessActive(int processId); /// diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index 43a40ced02..df54900b7c 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -273,18 +273,27 @@ public virtual bool TryGetCertificatePassword( using (ITracer activity = tracer.StartActivity("TryGetCertificatePassword", EventLevel.Informational)) { + // Record whether the current process has a visible console window, using the same value + // GetGitProcess uses to decide whether to detach git from a hidden console. Capturing it + // once here keeps the logged value authoritative and diagnosable on both the success and + // failure paths when a credential prompt fails to appear. + bool hasVisibleConsoleWindow = CurrentProcessHasVisibleConsoleWindowSafe(); + // See GetFromConfig for why pre-command hook is disabled // for bootstrap-time git operations. Result gitCredentialOutput = this.InvokeGitAgainstDotGitFolder( "credential fill", stdin => stdin.Write("protocol=cert\npath=" + certificatePath + "\nusername=\n\n"), parseStdOutLine: null, - usePreCommandHook: false); + usePreCommandHook: false, + mayRequireAuth: true, + hasVisibleConsoleWindow: hasVisibleConsoleWindow); if (gitCredentialOutput.ExitCodeIsFailure) { EventMetadata errorData = new EventMetadata(); errorData.Add("CertificatePath", certificatePath); + errorData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow); tracer.RelatedWarning( errorData, "Git could not get credentials: " + gitCredentialOutput.Errors, @@ -301,7 +310,8 @@ public virtual bool TryGetCertificatePassword( EventMetadata metadata = new EventMetadata { { "Success", success }, - { "CertificatePath", certificatePath } + { "CertificatePath", certificatePath }, + { "hasVisibleConsoleWindow", hasVisibleConsoleWindow } }; if (!success) @@ -328,6 +338,12 @@ public virtual bool TryGetCredential( using (ITracer activity = tracer.StartActivity(nameof(this.TryGetCredential), EventLevel.Informational)) { + // Record whether the current process has a visible console window, using the same value + // GetGitProcess uses to decide whether to detach git from a hidden console. Capturing it + // once here keeps the logged value authoritative and diagnosable on both the success and + // failure paths (including the timeout that a hidden, unanswered prompt produces). + bool hasVisibleConsoleWindow = CurrentProcessHasVisibleConsoleWindowSafe(); + // See GetFromConfig for why pre-command hook is disabled // for bootstrap-time git operations. Result gitCredentialOutput = this.InvokeGitAgainstDotGitFolder( @@ -335,11 +351,14 @@ public virtual bool TryGetCredential( stdin => stdin.Write($"url={repoUrl}\n\n"), parseStdOutLine: null, usePreCommandHook: false, - timeoutMs: timeoutMs); + timeoutMs: timeoutMs, + mayRequireAuth: true, + hasVisibleConsoleWindow: hasVisibleConsoleWindow); if (gitCredentialOutput.ExitCodeIsFailure) { EventMetadata errorData = new EventMetadata(); + errorData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow); if (gitCredentialOutput.Errors.StartsWith("Operation timed out")) { @@ -361,6 +380,14 @@ public virtual bool TryGetCredential( return false; } + // Record whether the current process had a visible console window. GetGitProcess uses + // this to decide whether to detach git from a hidden console so the credential prompt is + // not left behind other windows; logging it keeps that decision diagnosable from telemetry + // when a prompt fails to appear. + EventMetadata consoleMetadata = new EventMetadata(); + consoleMetadata.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow); + activity.RelatedInfo(consoleMetadata, "Credential fill console window visibility"); + username = ParseValue(gitCredentialOutput.Output, "username="); password = ParseValue(gitCredentialOutput.Output, "password="); @@ -902,7 +929,7 @@ public Result MultiPackIndexRepack(string gitObjectDirectory, string batchSize) return this.InvokeGitAgainstDotGitFolder($"-c pack.threads=1 -c repack.packKeptObjects=true multi-pack-index repack --object-dir=\"{gitObjectDirectory}\" --batch-size={batchSize} --no-progress"); } - public Process GetGitProcess(string command, string workingDirectory, string dotGitDirectory, bool useReadObjectHook, string gitObjectsDirectory, bool usePreCommandHook) + public Process GetGitProcess(string command, string workingDirectory, string dotGitDirectory, bool useReadObjectHook, string gitObjectsDirectory, bool usePreCommandHook, bool mayRequireAuth = false, bool? hasVisibleConsoleWindow = null) { ProcessStartInfo processInfo = new ProcessStartInfo(this.gitBinPath); processInfo.WorkingDirectory = workingDirectory; @@ -919,7 +946,16 @@ public Process GetGitProcess(string command, string workingDirectory, string dot // instead), CreateNoWindow must be set to true for that case — otherwise // the non-redirected stream inherits the parent's console handle, which // may be absent when running as a service, causing lost output. - processInfo.CreateNoWindow = false; + // + // When a command may require authentication, git can launch the credential + // manager. Detaching from a hidden console (CreateNoWindow=true) makes that + // prompt appear on top instead of behind other windows. See + // GVFSPlatform.CurrentProcessHasVisibleConsoleWindow for the full rationale. + // A caller that already probed the console visibility passes the result in + // hasVisibleConsoleWindow so the logged and acted-on value stay identical; + // otherwise probe here. The && short-circuits the probe when auth cannot + // be required. + processInfo.CreateNoWindow = mayRequireAuth && !(hasVisibleConsoleWindow ?? CurrentProcessHasVisibleConsoleWindowSafe()); processInfo.StandardOutputEncoding = UTF8NoBOM; processInfo.StandardErrorEncoding = UTF8NoBOM; @@ -986,7 +1022,9 @@ protected virtual Result InvokeGitImpl( Action parseStdOutLine, int timeoutMs, string gitObjectsDirectory = null, - bool usePreCommandHook = true) + bool usePreCommandHook = true, + bool mayRequireAuth = false, + bool? hasVisibleConsoleWindow = null) { if (failedToSetEncoding && writeStdIn != null) { @@ -998,7 +1036,7 @@ protected virtual Result InvokeGitImpl( // From https://msdn.microsoft.com/en-us/library/system.diagnostics.process.standardoutput.aspx // To avoid deadlocks, use asynchronous read operations on at least one of the streams. // Do not perform a synchronous read to the end of both redirected streams. - using (this.executingProcess = this.GetGitProcess(command, workingDirectory, dotGitDirectory, useReadObjectHook, gitObjectsDirectory: gitObjectsDirectory, usePreCommandHook: usePreCommandHook)) + using (this.executingProcess = this.GetGitProcess(command, workingDirectory, dotGitDirectory, useReadObjectHook, gitObjectsDirectory: gitObjectsDirectory, usePreCommandHook: usePreCommandHook, mayRequireAuth: mayRequireAuth, hasVisibleConsoleWindow: hasVisibleConsoleWindow)) { // Bound how much stdout/stderr we buffer so a pathologically noisy git command // cannot grow these buffers without limit until GVFS.Mount hits an @@ -1086,6 +1124,20 @@ private static string GenerateCredentialVerbCommand(string verb) return $"-c {GitConfigSetting.CredentialUseHttpPath}=true credential {verb}"; } + private static bool CurrentProcessHasVisibleConsoleWindowSafe() + { + try + { + return GVFSPlatform.Instance.CurrentProcessHasVisibleConsoleWindow(); + } + catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException) + { + // If the native console probe cannot run, assume a visible console so callers keep + // the previous behavior (CreateNoWindow=false) rather than aborting the command. + return true; + } + } + private static string ParseValue(string contents, string prefix) { int startIndex = contents.IndexOf(prefix) + prefix.Length; @@ -1166,7 +1218,9 @@ private Result InvokeGitAgainstDotGitFolder( Action parseStdOutLine, bool usePreCommandHook = true, string gitObjectsDirectory = null, - int timeoutMs = -1) + int timeoutMs = -1, + bool mayRequireAuth = false, + bool? hasVisibleConsoleWindow = null) { // This git command should not need/use the working directory of the repo. // Run git.exe in Environment.SystemDirectory to ensure the git.exe process @@ -1180,7 +1234,9 @@ private Result InvokeGitAgainstDotGitFolder( parseStdOutLine: parseStdOutLine, timeoutMs: timeoutMs, gitObjectsDirectory: gitObjectsDirectory, - usePreCommandHook: usePreCommandHook); + usePreCommandHook: usePreCommandHook, + mayRequireAuth: mayRequireAuth, + hasVisibleConsoleWindow: hasVisibleConsoleWindow); } public class Result diff --git a/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs b/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs index 91da42f767..a27c980834 100644 --- a/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs +++ b/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs @@ -11,6 +11,7 @@ using System.IO; using System.IO.Pipes; using System.Linq; +using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; using System.ServiceProcess; @@ -28,6 +29,9 @@ public partial class WindowsPlatform : GVFSPlatform private const string ProfileImagePathRegistryValue = "ProfileImagePath"; private const string AdminProtectionProfilePrefix = "ADMIN_"; + // GetAncestor flag GA_ROOTOWNER (3): walk the owner chain to the root owned window. + private const uint GA_ROOTOWNER = 3; + public WindowsPlatform() : base(underConstruction: new UnderConstructionFlags()) { } @@ -224,6 +228,22 @@ public override void PrepareProcessToRunInBackground() // No additional work required } + public override bool CurrentProcessHasVisibleConsoleWindow() + { + IntPtr consoleWindow = GetConsoleWindow(); + if (consoleWindow == IntPtr.Zero) + { + // The process has no console at all (e.g. a GUI process), so there is no console + // window to own an interactive prompt. + return false; + } + + // GVFS.Mount runs with a hidden console, so its console window exists but its root + // owner is not visible. Only treat the window as usable when its root owner is visible. + IntPtr rootOwner = GetAncestor(consoleWindow, GA_ROOTOWNER); + return rootOwner != IntPtr.Zero && IsWindowVisible(rootOwner); + } + public override NamedPipeServerStream CreatePipeByName(string pipeName) { PipeSecurity security = new PipeSecurity(); @@ -458,6 +478,16 @@ private static object GetValueFromRegistry(RegistryHive registryHive, string key return value; } + [DllImport("kernel32.dll")] + private static extern IntPtr GetConsoleWindow(); + + [DllImport("user32.dll")] + private static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindowVisible(IntPtr hWnd); + public class WindowsPlatformConstants : GVFSPlatformConstants { public override string ExecutableExtension diff --git a/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs b/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs index 25aa675b7e..682a80537f 100644 --- a/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs +++ b/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs @@ -1,7 +1,9 @@ using System; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; +using GVFS.Common; using GVFS.Common.Git; using GVFS.Tests; using GVFS.Tests.Should; @@ -342,6 +344,201 @@ public void TryGetCredentialsWaitsForBackgroundInitializationThenSucceeds() authString.ShouldNotBeNull("A credential string should be returned"); } + [TestCase] + public void CredentialFillIsFlaggedAsMayRequireAuth() + { + // Only credential fill can trigger an interactive sign-in from the credential manager, so + // only it is flagged mayRequireAuth=true. GetGitProcess uses that flag to detach git from a + // hidden console so the prompt is not left behind other windows. + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = this.GetGitProcess(); + GitAuthentication dut = new GitAuthentication(gitProcess, "mock://repoUrl"); + dut.TryInitializeAndRequireAuth(tracer, out _); + + dut.TryGetCredentials(tracer, out _, out string error).ShouldEqual(true, "Failed to get credential: " + error); + + gitProcess.MayRequireAuthFor($"{AzureDevOpsUseHttpPathString} credential fill") + .ShouldEqual(true, "Credential fill should be flagged as possibly requiring auth"); + + // Non-credential git commands never prompt, so they must not be flagged. + gitProcess.MayRequireAuthFor("config --get-urlmatch http mock://repoUrl") + .ShouldEqual(false, "Non-credential git commands should not be flagged as requiring auth"); + } + + [TestCase] + public void CredentialApproveIsNotFlaggedAsMayRequireAuth() + { + // Credential approve stores an already-obtained credential and never prompts. + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = this.GetGitProcess(); + GitAuthentication dut = new GitAuthentication(gitProcess, "mock://repoUrl"); + dut.TryInitializeAndRequireAuth(tracer, out _); + + dut.TryGetCredentials(tracer, out string authString, out string error).ShouldEqual(true, "Failed to get credential: " + error); + dut.ApproveCredentials(tracer, authString); + + gitProcess.MayRequireAuthFor($"{AzureDevOpsUseHttpPathString} credential approve") + .ShouldEqual(false, "Credential approve should not be flagged as requiring auth"); + } + + [TestCase] + public void CredentialRejectIsNotFlaggedAsMayRequireAuth() + { + // Credential reject erases a stored credential and never prompts. + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = this.GetGitProcess(); + GitAuthentication dut = new GitAuthentication(gitProcess, "mock://repoUrl"); + dut.TryInitializeAndRequireAuth(tracer, out _); + + dut.TryGetCredentials(tracer, out string authString, out string error).ShouldEqual(true, "Failed to get credential: " + error); + dut.RejectCredentials(tracer, authString); + + gitProcess.MayRequireAuthFor($"{AzureDevOpsUseHttpPathString} credential reject") + .ShouldEqual(false, "Credential reject should not be flagged as requiring auth"); + } + + [TestCase] + public void CertificateCredentialFillIsFlaggedAsMayRequireAuth() + { + // The certificate credential fill can also trigger an interactive sign-in, so it is flagged + // mayRequireAuth=true like the URL fill. It is covered directly here because the standard + // credential flow does not exercise the certificate path. + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + "credential fill", + () => new GitProcess.Result("password=certpassword\r\n", string.Empty, GitProcess.Result.SuccessCode)); + + gitProcess.TryGetCertificatePassword(tracer, CertificatePath, out string password, out string error) + .ShouldEqual(true, "Failed to get certificate password: " + error); + + gitProcess.MayRequireAuthFor("credential fill") + .ShouldEqual(true, "Certificate credential fill should be flagged as possibly requiring auth"); + } + + [TestCase] + public void GetGitProcessDetachesFromConsoleOnlyWhenAuthMayBeRequiredAndNoVisibleWindow() + { + MockPlatform platform = (MockPlatform)GVFSPlatform.Instance; + bool originalHasVisibleWindow = platform.HasVisibleWindow; + try + { + GitProcess gitProcess = new GitProcess("git.exe", workingDirectoryRoot: null); + + // No visible console + command may require auth => detach so the prompt is topmost. + platform.HasVisibleWindow = false; + using (Process process = gitProcess.GetGitProcess("credential fill", Environment.SystemDirectory, dotGitDirectory: null, useReadObjectHook: false, gitObjectsDirectory: null, usePreCommandHook: false, mayRequireAuth: true)) + { + process.StartInfo.CreateNoWindow.ShouldEqual(true, "Should detach when auth may be required and there is no visible console window"); + } + + // Visible console + command may require auth => keep the console so the prompt parents to it. + platform.HasVisibleWindow = true; + using (Process process = gitProcess.GetGitProcess("credential fill", Environment.SystemDirectory, dotGitDirectory: null, useReadObjectHook: false, gitObjectsDirectory: null, usePreCommandHook: false, mayRequireAuth: true)) + { + process.StartInfo.CreateNoWindow.ShouldEqual(false, "Should keep the console when it is visible"); + } + + // Command cannot require auth => never detach, regardless of console visibility. + platform.HasVisibleWindow = false; + using (Process process = gitProcess.GetGitProcess("rev-parse HEAD", Environment.SystemDirectory, dotGitDirectory: null, useReadObjectHook: false, gitObjectsDirectory: null, usePreCommandHook: true, mayRequireAuth: false)) + { + process.StartInfo.CreateNoWindow.ShouldEqual(false, "Should never detach when auth cannot be required"); + } + + // Native console probe cannot run => safe fallback assumes a visible console, so do not detach. + platform.HasVisibleWindow = false; + platform.ThrowOnConsoleProbe = true; + using (Process process = gitProcess.GetGitProcess("credential fill", Environment.SystemDirectory, dotGitDirectory: null, useReadObjectHook: false, gitObjectsDirectory: null, usePreCommandHook: false, mayRequireAuth: true)) + { + process.StartInfo.CreateNoWindow.ShouldEqual(false, "Should not detach when the console probe cannot run"); + } + + // A caller-supplied console-visibility value is authoritative and bypasses the platform probe + // (the probe would throw if consulted here). + platform.ThrowOnConsoleProbe = true; + platform.HasVisibleWindow = true; + using (Process process = gitProcess.GetGitProcess("credential fill", Environment.SystemDirectory, dotGitDirectory: null, useReadObjectHook: false, gitObjectsDirectory: null, usePreCommandHook: false, mayRequireAuth: true, hasVisibleConsoleWindow: false)) + { + process.StartInfo.CreateNoWindow.ShouldEqual(true, "A caller-supplied false should force detach without probing the platform"); + } + } + finally + { + platform.HasVisibleWindow = originalHasVisibleWindow; + platform.ThrowOnConsoleProbe = false; + } + } + + [TestCase] + public void TryGetCredentialLogsConsoleVisibilityWhenCredentialFillFails() + { + // The console-visibility telemetry must be recorded on the failure path too, because the + // hidden-prompt timeout that this diagnostic exists to explain is itself a failure result. + MockPlatform platform = (MockPlatform)GVFSPlatform.Instance; + bool originalHasVisibleWindow = platform.HasVisibleWindow; + try + { + platform.HasVisibleWindow = false; + + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result(string.Empty, "Operation timed out waiting for a response", GitProcess.Result.GenericFailureCode)); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out string username, out string password, out string error, timeoutMs: 5000) + .ShouldEqual(false, "Credential fill should report failure when git times out"); + + // Assert the logged value, not just the key: the diagnostic is only useful if it records + // the actual console-visibility state (false here) that explains the hidden-prompt timeout. + tracer.RelatedWarningEvents.ShouldContain(e => e.Contains("\"hasVisibleConsoleWindow\":false")); + + // The probed value must thread all the way down to the git invocation that GetGitProcess uses. + gitProcess.InvocationsRun + .Single(invocation => invocation.Command == $"{AzureDevOpsUseHttpPathString} credential fill") + .HasVisibleConsoleWindow.ShouldEqual(false, "The probed console-visibility value must thread down to the git invocation"); + } + finally + { + platform.HasVisibleWindow = originalHasVisibleWindow; + } + } + + [TestCase] + public void TryGetCertificatePasswordLogsConsoleVisibilityWhenCredentialFillFails() + { + // The certificate credential fill stamps the same console-visibility telemetry as the URL fill. + // It is covered separately because the standard credential flow does not exercise the certificate + // path, and the value must be present on the failure path where a hidden prompt times out. + MockPlatform platform = (MockPlatform)GVFSPlatform.Instance; + bool originalHasVisibleWindow = platform.HasVisibleWindow; + try + { + platform.HasVisibleWindow = false; + + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + "credential fill", + () => new GitProcess.Result(string.Empty, "Operation timed out waiting for a response", GitProcess.Result.GenericFailureCode)); + + gitProcess.TryGetCertificatePassword(tracer, CertificatePath, out string password, out string error) + .ShouldEqual(false, "Certificate password fill should report failure when git times out"); + + tracer.RelatedWarningEvents.ShouldContain(e => e.Contains("\"hasVisibleConsoleWindow\":false")); + + // The probed value must thread all the way down to the git invocation that GetGitProcess uses. + gitProcess.InvocationsRun + .Single(invocation => invocation.Command == "credential fill") + .HasVisibleConsoleWindow.ShouldEqual(false, "The probed console-visibility value must thread down to the git invocation"); + } + finally + { + platform.HasVisibleWindow = originalHasVisibleWindow; + } + } + private MockGitProcess GetGitProcess() { MockGitProcess gitProcess = new MockGitProcess(); diff --git a/GVFS/GVFS.UnitTests/Mock/Common/MockPlatform.cs b/GVFS/GVFS.UnitTests/Mock/Common/MockPlatform.cs index 3ee7425de0..a5c92b0c9f 100644 --- a/GVFS/GVFS.UnitTests/Mock/Common/MockPlatform.cs +++ b/GVFS/GVFS.UnitTests/Mock/Common/MockPlatform.cs @@ -21,6 +21,18 @@ public class MockPlatform : GVFSPlatform public string MockCurrentUser { get; set; } + /// + /// Controls the return value of . Defaults to true + /// so tests keep the foreground behavior unless they opt into the background-process case. + /// + public bool HasVisibleWindow { get; set; } = true; + + /// + /// When true, throws to simulate a native + /// console probe that cannot run (missing entry point), so tests can cover the safe fallback. + /// + public bool ThrowOnConsoleProbe { get; set; } + public override IKernelDriver KernelDriver => throw new NotSupportedException(); public override IGitInstallation GitInstallation { get; } = new MockGitInstallation(); @@ -200,6 +212,16 @@ public override void PrepareProcessToRunInBackground() throw new NotSupportedException(); } + public override bool CurrentProcessHasVisibleConsoleWindow() + { + if (this.ThrowOnConsoleProbe) + { + throw new EntryPointNotFoundException(); + } + + return this.HasVisibleWindow; + } + public override bool IsGitStatusCacheSupported() { return true; diff --git a/GVFS/GVFS.UnitTests/Mock/Git/MockGitProcess.cs b/GVFS/GVFS.UnitTests/Mock/Git/MockGitProcess.cs index c9095cc2c8..cf786a9835 100644 --- a/GVFS/GVFS.UnitTests/Mock/Git/MockGitProcess.cs +++ b/GVFS/GVFS.UnitTests/Mock/Git/MockGitProcess.cs @@ -18,17 +18,43 @@ public MockGitProcess() : base(new MockGVFSEnlistment()) { this.CommandsRun = new List(); + this.InvocationsRun = new List(); this.StoredCredentials = new Dictionary(StringComparer.OrdinalIgnoreCase); this.CredentialApprovals = new Dictionary>(); this.CredentialRejections = new Dictionary>(); } public List CommandsRun { get; } + + /// + /// Records every call into in order, with the mayRequireAuth and + /// hasVisibleConsoleWindow values passed to it. Recording each invocation (rather than keeping only + /// the last value per command) keeps the wiring assertions honest if a command is ever run more than + /// once with different flags. + /// + public List InvocationsRun { get; } public bool ShouldFail { get; set; } public Dictionary StoredCredentials { get; } public Dictionary> CredentialApprovals { get; } public Dictionary> CredentialRejections { get; } + /// + /// Returns the mayRequireAuth value the given command was invoked with, asserting the command ran and + /// that every invocation of it agreed on the value. + /// + public bool MayRequireAuthFor(string command) + { + List matches = this.InvocationsRun + .Where(invocation => string.Equals(invocation.Command, command, StringComparison.Ordinal)) + .ToList(); + + matches.Count.ShouldBeAtLeast(1, "Command was never run: " + command); + matches.Select(invocation => invocation.MayRequireAuth).Distinct().Count() + .ShouldEqual(1, "Command ran with conflicting mayRequireAuth values: " + command); + + return matches[0].MayRequireAuth; + } + public void SetExpectedCommandResult(string command, Func result, bool matchPrefix = false) { CommandInfo commandInfo = new CommandInfo(command, result, matchPrefix); @@ -84,9 +110,12 @@ protected override Result InvokeGitImpl( Action parseStdOutLine, int timeoutMs, string gitObjectsDirectory = null, - bool usePrecommandHook = true) + bool usePrecommandHook = true, + bool mayRequireAuth = false, + bool? hasVisibleConsoleWindow = null) { this.CommandsRun.Add(command); + this.InvocationsRun.Add(new GitInvocation(command, mayRequireAuth, hasVisibleConsoleWindow)); if (this.ShouldFail) { @@ -125,6 +154,20 @@ protected override Result InvokeGitImpl( return result; } + public class GitInvocation + { + public GitInvocation(string command, bool mayRequireAuth, bool? hasVisibleConsoleWindow) + { + this.Command = command; + this.MayRequireAuth = mayRequireAuth; + this.HasVisibleConsoleWindow = hasVisibleConsoleWindow; + } + + public string Command { get; } + public bool MayRequireAuth { get; } + public bool? HasVisibleConsoleWindow { get; } + } + public class Credential { public Credential(string username, string password)