Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detect/install command-line git and invoke #3419

Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Diagnostics;
using FileExplorerGitIntegration.Models;
using LibGit2Sharp;

namespace FileExplorerGitIntegration.UnitTest;

[TestClass]
public class GitCommandRunnerTests
{
private GitCommandRunner GitCmdRunner { get; set; } = new(Path.Combine(Path.GetTempPath()));

private static string RepoPath => Path.Combine(Path.GetTempPath(), "GitTestRepository");

[ClassInitialize]
public static void ClassInitialize(TestContext context)
{
Debug.WriteLine("ClassInitialize");
const string url = "http://github.com/libgit2/TestGitRepository";
try
{
_ = Repository.Clone(url, RepoPath);
}
catch (NameConflictException)
{
// Clean stale test state and try again
if (Directory.Exists(RepoPath))
{
// Cloning the repo leads to files that are hidden and readonly (such as under the .git directory).
// Therefore, change the attribute so they can be deleted
var repoDirectory = new DirectoryInfo(RepoPath)
{
Attributes = System.IO.FileAttributes.Normal,
};

foreach (var dirInfo in repoDirectory.GetFileSystemInfos("*", SearchOption.AllDirectories))
{
dirInfo.Attributes = System.IO.FileAttributes.Normal;
}

Directory.Delete(RepoPath, true);
}

_ = Repository.Clone(url, RepoPath);
}
}

[ClassCleanup]
public static void ClassCleanup()
{
Debug.WriteLine("ClassCleanup");
if (Directory.Exists(RepoPath))
{
// Cloning the repo leads to files that are hidden and readonly (such as under the .git directory).
// Therefore, change the attribute so they can be deleted
var repoDirectory = new DirectoryInfo(RepoPath)
{
Attributes = FileAttributes.Normal,
};

foreach (var dirInfo in repoDirectory.GetFileSystemInfos("*", SearchOption.AllDirectories))
{
dirInfo.Attributes = FileAttributes.Normal;
}

Directory.Delete(RepoPath, true);
}
}

[TestMethod]
public void TestBasicInvokeGitFunctionality()
{
var isGitInstalled = GitCmdRunner.DetectGit();
if (!isGitInstalled)
{
Assert.Inconclusive("Git is not installed. Test cannot run in this case.");
return;
}

var result = GitCmdRunner.InvokeGitWithArguments(string.Empty, "version");
Assert.IsNotNull(result.Output);
Assert.IsTrue(result.Output.Contains("git version"));
}

[TestMethod]
public void TestInvokeGitFunctionalityForRawStatus()
{
var isGitInstalled = GitCmdRunner.DetectGit();
if (!isGitInstalled)
{
Assert.Inconclusive("Git is not installed. Test cannot run in this case.");
return;
}

var result = GitCmdRunner.InvokeGitWithArguments(RepoPath, "status");
Assert.IsNotNull(result.Output);
Assert.IsTrue(result.Output.Contains("On branch"));
}

[TestCleanup]
public void TestCleanup()
{
if (File.Exists(Path.Combine(Path.GetTempPath(), "GitConfiguration.json")))
{
File.Delete(Path.Combine(Path.GetTempPath(), "GitConfiguration.json"));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.Windows.DevHome.SDK;

namespace FileExplorerGitIntegration.Helpers;

public class GitCommandRunnerResultInfo
{
public ProviderOperationStatus Status { get; set; }

public string? Output { get; set; }

public string? DisplayMessage { get; set; }

public string? DiagnosticText { get; set; }

public Exception? Ex { get; set; }

public string? Arguments { get; set; }

public GitCommandRunnerResultInfo(ProviderOperationStatus status, string? output)
{
Status = status;
Output = output;
}

public GitCommandRunnerResultInfo(ProviderOperationStatus status, string? displayMessage, string? diagnosticText, Exception? ex, string? args)
{
Status = status;
DisplayMessage = displayMessage;
DiagnosticText = diagnosticText;
Ex = ex;
Arguments = args;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Diagnostics;
using DevHome.Common.Helpers;
using DevHome.Common.Services;
using FileExplorerGitIntegration.Helpers;
using Microsoft.Win32;
using Microsoft.Windows.DevHome.SDK;
using Serilog;
using Windows.Storage;

namespace FileExplorerGitIntegration.Models;

public class GitCommandRunner : IDisposable
{
public GitExecutableConfigOptions GitExecutableConfigOptions { get; set; }

private readonly FileService fileService;

private string GitExeInstallPath { get; set; } = string.Empty;

private readonly FileSystemWatcher fileWatcher;

private readonly ILogger log = Log.ForContext<GitCommandRunner>();

public GitCommandRunner(string? path)
{
if (RuntimeHelper.IsMSIX)
{
GitExecutableConfigOptions = new GitExecutableConfigOptions
{
GitExecutableConfigFolderPath = ApplicationData.Current.LocalFolder.Path,
};
}
else
{
GitExecutableConfigOptions = new GitExecutableConfigOptions
{
GitExecutableConfigFolderPath = path ?? string.Empty,
};
}

fileService = new FileService();
ReadInstallPath();

fileWatcher = new FileSystemWatcher(GitExecutableConfigOptions.GitExecutableConfigFolderPath, GitExecutableConfigOptions.GitExecutableConfigFileName);
fileWatcher.NotifyFilter = NotifyFilters.LastWrite;
fileWatcher.Changed += OnFileChanged;
fileWatcher.EnableRaisingEvents = true;
log.Debug("FileSystemWatcher initialized for configuration file");
}

private void OnFileChanged(object sender, FileSystemEventArgs args)
{
if (args.Name == GitExecutableConfigOptions.GitExecutableConfigFileName)
{
ReadInstallPath();
}
ssparach marked this conversation as resolved.
Show resolved Hide resolved
}

public void ReadInstallPath()
{
GitExeInstallPath = fileService.Read<string>(GitExecutableConfigOptions.GitExecutableConfigFolderPath, GitExecutableConfigOptions.GitExecutableConfigFileName);
}

public bool IsGitExeInstallPathSet()
{
return !string.IsNullOrEmpty(GitExeInstallPath);
}

public GitCommandRunnerResultInfo ValidateGitExeInstallPath()
{
try
{
System.Diagnostics.Process.Start(GitExeInstallPath);
ssparach marked this conversation as resolved.
Show resolved Hide resolved
ssparach marked this conversation as resolved.
Show resolved Hide resolved
return new GitCommandRunnerResultInfo(ProviderOperationStatus.Success, null);
}
catch (Exception ex)
{
log.Error(ex, "Failed to start Git.exe at configured path");
return new GitCommandRunnerResultInfo(ProviderOperationStatus.Failure, "Failed to start Git.exe at configured path", string.Empty, ex, null);
}
}

public bool StoreGitExeInstallPath(string path)
{
log.Information("Setting Git Exe Install Path");
GitExeInstallPath = path;
fileService.Save(GitExecutableConfigOptions.GitExecutableConfigFolderPath, GitExecutableConfigOptions.GitExecutableConfigFileName, GitExeInstallPath);
log.Information("Git Exe Install Path stored successfully");
return true;
}

public bool DetectGit()
{
var gitExeFound = false;

if (!gitExeFound)
{
// Check if git.exe is present in PATH environment variable
try
{
ssparach marked this conversation as resolved.
Show resolved Hide resolved
System.Diagnostics.Process.Start("git.exe");
dhoehna marked this conversation as resolved.
Show resolved Hide resolved
gitExeFound = true;
StoreGitExeInstallPath("git.exe");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not run "where git" in cmd and parse the output?
C:>where git
C:\Program Files\Git\cmd\git.exe
D:\work\depot_tools\git.bat

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly we're trying to be somewhat resilient to users not having git.exe on the path, and try some sensible fallback directories. But to be fair, the first option we try is the one the path.

Or am I misremembering that "where" depends on the PATH variable?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're correct where depends on the path variable. my comment was why execute "git.exe" to figure out if its on the path when we have an existing utility that will tell us if it is and precisely in what location.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we're sort of agreeing with each other. From the point of this code, if it's on the PATH, we don't care where it is - we'll just execute git.exe without qualifying the path. The other options are for "Hmm... it's not on PATH. Let's try a few other well-known locations to see if we can find something that works".

}
catch (Exception ex)
{
log.Debug(ex, "Failed to start Git.exe");
}
}

if (!gitExeFound)
{
// Check execution of git.exe by finding install location in registry keys
string[] registryPaths = { "HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Git_is1", "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Git_is1" };

foreach (var registryPath in registryPaths)
{
var gitPath = Registry.GetValue(registryPath, "InstallLocation", defaultValue: string.Empty) as string;
if (!string.IsNullOrEmpty(gitPath))
dhoehna marked this conversation as resolved.
Show resolved Hide resolved
{
var paths = FindSubdirectories(gitPath);
ssparach marked this conversation as resolved.
Show resolved Hide resolved
CheckForExeInPaths(paths, ref gitExeFound);
ssparach marked this conversation as resolved.
Show resolved Hide resolved
if (gitExeFound)
{
break;
}
}
}
}

if (!gitExeFound)
{
// Search for git.exe in common file paths
var programFiles = Environment.GetEnvironmentVariable("ProgramFiles");
var programFilesX86 = Environment.GetEnvironmentVariable("ProgramFiles(x86)");
DefaultRyan marked this conversation as resolved.
Show resolved Hide resolved
string[] possiblePaths = { $"{programFiles}\\Git\\bin", $"{programFilesX86}\\Git\\bin", $"{programFiles}\\Git\\cmd", $"{programFilesX86}\\Git\\cmd" };
CheckForExeInPaths(possiblePaths, ref gitExeFound);
}

return gitExeFound;
}

private string[] FindSubdirectories(string installLocation)
{
try
{
if (Directory.Exists(installLocation))
{
return Directory.GetDirectories(installLocation);
}
else
{
log.Warning("Install location does not exist: {InstallLocation}", installLocation);
return Array.Empty<string>();
}
}
catch (Exception ex)
{
log.Warning(ex, "Failed to find subdirectories in install location: {InstallLocation}", installLocation);
return Array.Empty<string>();
}
}

private void CheckForExeInPaths(string[] possiblePaths, ref bool gitExeFound)
ssparach marked this conversation as resolved.
Show resolved Hide resolved
{
foreach (var path in possiblePaths)
ssparach marked this conversation as resolved.
Show resolved Hide resolved
{
if (!string.IsNullOrEmpty(path))
{
try
{
var gitPath = Path.Combine(path, "git.exe");
Process.Start(gitPath);
ssparach marked this conversation as resolved.
Show resolved Hide resolved
StoreGitExeInstallPath(gitPath);
gitExeFound = true;
log.Information("Git Exe Install Path found");
break;
}
catch (Exception ex)
{
log.Debug(ex, "Failed to start Git.exe while checking for executable in possible paths");
ssparach marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
}

public GitCommandRunnerResultInfo InvokeGitWithArguments(string? repositoryDirectory, string argument)
ssparach marked this conversation as resolved.
Show resolved Hide resolved
{
try
{
var processStartInfo = new ProcessStartInfo
{
FileName = GitExeInstallPath,
Arguments = argument,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = repositoryDirectory ?? string.Empty,
};

using var process = Process.Start(processStartInfo);
if (process != null)
{
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit();
return new GitCommandRunnerResultInfo(ProviderOperationStatus.Success, output);
}
else
{
log.Error("Failed to start the Git process: process is null");
return new GitCommandRunnerResultInfo(ProviderOperationStatus.Failure, "Git process is null", string.Empty, new InvalidOperationException("Failed to start the Git process: process is null"), null);
}
}
catch (Exception ex)
{
log.Error(ex, "Failed to invoke Git with arguments: {Argument}", argument);
return new GitCommandRunnerResultInfo(ProviderOperationStatus.Failure, "Failed to invoke Git with arguments", string.Empty, ex, argument);
}
}

public void Dispose()
{
fileWatcher.Dispose();
GC.SuppressFinalize(this);
}
}
Loading
Loading