diff --git a/extensions/GitExtension/FileExplorerGitIntegration.UnitTest/GitCommandRunnerTests.cs b/extensions/GitExtension/FileExplorerGitIntegration.UnitTest/GitCommandRunnerTests.cs new file mode 100644 index 0000000000..ce704be7dd --- /dev/null +++ b/extensions/GitExtension/FileExplorerGitIntegration.UnitTest/GitCommandRunnerTests.cs @@ -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 GitDetect GitDetector { get; set; } = new(); + + 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 = GitDetector.DetectGit(); + if (!isGitInstalled) + { + Assert.Inconclusive("Git is not installed. Test cannot run in this case."); + return; + } + + var result = GitExecute.ExecuteGitCommand(GitDetector.GitConfiguration.ReadInstallPath(), RepoPath, "--version"); + Assert.IsNotNull(result.Output); + Assert.IsTrue(result.Output.Contains("git version")); + } + + [TestMethod] + public void TestInvokeGitFunctionalityForRawStatus() + { + var isGitInstalled = GitDetector.DetectGit(); + if (!isGitInstalled) + { + Assert.Inconclusive("Git is not installed. Test cannot run in this case."); + return; + } + + var result = GitExecute.ExecuteGitCommand(GitDetector.GitConfiguration.ReadInstallPath(), 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")); + } + } +} diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Helpers/GitCommandRunnerResultInfo.cs b/extensions/GitExtension/FileExplorerGitIntegration/Helpers/GitCommandRunnerResultInfo.cs new file mode 100644 index 0000000000..cfe2c480ae --- /dev/null +++ b/extensions/GitExtension/FileExplorerGitIntegration/Helpers/GitCommandRunnerResultInfo.cs @@ -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; + } +} diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/GitConfiguration.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitConfiguration.cs new file mode 100644 index 0000000000..d64437f31d --- /dev/null +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitConfiguration.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Common.Helpers; +using DevHome.Common.Services; +using Serilog; +using Windows.Storage; + +namespace FileExplorerGitIntegration.Models; + +public class GitConfiguration : IDisposable +{ + public GitExecutableConfigOptions GitExecutableConfigOptions { get; set; } + + private readonly FileService fileService; + + private string GitExeInstallPath { get; set; } = string.Empty; + + private readonly object fileLock = new(); + + private readonly ILogger log = Log.ForContext(); + + private readonly string tempConfigurationFileName = "TemporaryGitConfiguration.json"; + + public GitConfiguration(string? path) + { + string folderPath; + if (RuntimeHelper.IsMSIX) + { + folderPath = ApplicationData.Current.LocalFolder.Path; + } + else + { + folderPath = path ?? string.Empty; + } + + GitExecutableConfigOptions = new GitExecutableConfigOptions + { + GitExecutableConfigFolderPath = folderPath, + }; + + fileService = new FileService(); + EnsureConfigFileCreation(); + } + + public string ReadInstallPath() + { + lock (fileLock) + { + GitExeInstallPath = fileService.Read(GitExecutableConfigOptions.GitExecutableConfigFolderPath, GitExecutableConfigOptions.GitExecutableConfigFileName); + return GitExeInstallPath; + } + } + + public void EnsureConfigFileCreation() + { + lock (fileLock) + { + if (!Directory.Exists(GitExecutableConfigOptions.GitExecutableConfigFolderPath)) + { + Directory.CreateDirectory(GitExecutableConfigOptions.GitExecutableConfigFolderPath); + } + + var configFileFullPath = Path.Combine(GitExecutableConfigOptions.GitExecutableConfigFolderPath, GitExecutableConfigOptions.GitExecutableConfigFileName); + if (!File.Exists(configFileFullPath)) + { + fileService.Save(GitExecutableConfigOptions.GitExecutableConfigFolderPath, GitExecutableConfigOptions.GitExecutableConfigFileName, string.Empty); + log.Information("The git configuration file did not exists and has just been created"); + } + } + } + + public bool IsGitExeInstallPathSet() + { + return !string.IsNullOrEmpty(GitExeInstallPath); + } + + public bool StoreGitExeInstallPath(string path) + { + lock (fileLock) + { + log.Information("Setting Git Exe Install Path"); + GitExeInstallPath = path; + + fileService.Save(GitExecutableConfigOptions.GitExecutableConfigFolderPath, tempConfigurationFileName, GitExeInstallPath); + File.Replace(Path.Combine(GitExecutableConfigOptions.GitExecutableConfigFolderPath, tempConfigurationFileName), Path.Combine(GitExecutableConfigOptions.GitExecutableConfigFolderPath, GitExecutableConfigOptions.GitExecutableConfigFileName), null); + log.Information("Git Exe Install Path stored successfully"); + return true; + } + } + + public void Dispose() + { + GC.SuppressFinalize(this); + } +} diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/GitDetect.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitDetect.cs new file mode 100644 index 0000000000..d2e0710dc1 --- /dev/null +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitDetect.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Win32; +using Microsoft.Windows.DevHome.SDK; +using Serilog; + +namespace FileExplorerGitIntegration.Models; + +public class GitDetect +{ + public GitConfiguration GitConfiguration { get; set; } + + private readonly ILogger log = Log.ForContext(); + + public GitDetect() + { + GitConfiguration = new GitConfiguration(null); + } + + public bool DetectGit() + { + var gitExeFound = false; + + if (!gitExeFound) + { + // Check if git.exe is present in PATH environment variable + gitExeFound = ValidateGitConfigurationPath("git.exe"); + if (gitExeFound) + { + GitConfiguration.StoreGitExeInstallPath("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)) + { + var paths = FindSubdirectories(gitPath); + gitExeFound = CheckForExeInPaths(paths); + if (gitExeFound) + { + break; + } + } + } + } + + if (!gitExeFound) + { + // Search for git.exe in common file paths + var programFiles = Environment.GetEnvironmentVariable("ProgramFiles"); + var programFilesX86 = Environment.GetEnvironmentVariable("ProgramFiles(x86)"); + string[] possiblePaths = { $"{programFiles}\\Git\\bin", $"{programFilesX86}\\Git\\bin", $"{programFiles}\\Git\\cmd", $"{programFilesX86}\\Git\\cmd" }; + gitExeFound = CheckForExeInPaths(possiblePaths); + } + + 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(); + } + } + catch (Exception ex) + { + log.Warning(ex, "Failed to find subdirectories in install location: {InstallLocation}", installLocation); + return Array.Empty(); + } + } + + private bool CheckForExeInPaths(string[] possiblePaths) + { + // Iterate through the possible paths to find the git.exe file + foreach (var path in possiblePaths.Where(x => !string.IsNullOrEmpty(x))) + { + var gitPath = Path.Combine(path, "git.exe"); + var isValid = ValidateGitConfigurationPath(gitPath); + + // If the git.exe file is found, store the install path and log the information + if (isValid) + { + GitConfiguration.StoreGitExeInstallPath(gitPath); + log.Information("Git Exe Install Path found"); + return true; + } + } + + log.Debug("Git.exe not found in paths examined"); + return false; + } + + public bool ValidateGitConfigurationPath(string path) + { + var result = GitExecute.ExecuteGitCommand(path, string.Empty, "--version"); + if (result.Status == ProviderOperationStatus.Success && result.Output != null && result.Output.Contains("git version")) + { + return true; + } + + return false; + } +} diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/GitExeceutableConfigOptions.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitExeceutableConfigOptions.cs new file mode 100644 index 0000000000..73a7515409 --- /dev/null +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitExeceutableConfigOptions.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace FileExplorerGitIntegration.Models; + +public partial class GitExecutableConfigOptions +{ + private const string GitExecutableConfigFileNameDefault = "GitConfiguration.json"; + + public string GitExecutableConfigFileName { get; set; } = GitExecutableConfigFileNameDefault; + + private readonly string gitExecutableConfigFolderPathDefault = Path.Combine(Path.GetTempPath(), "FileExplorerGitIntegration"); + + private string? gitExecutableConfigFolderPath; + + public string GitExecutableConfigFolderPath + { + get => gitExecutableConfigFolderPath is null ? gitExecutableConfigFolderPathDefault : gitExecutableConfigFolderPath; + set => gitExecutableConfigFolderPath = string.IsNullOrEmpty(value) ? gitExecutableConfigFolderPathDefault : value; + } +} diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/GitExecute.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitExecute.cs new file mode 100644 index 0000000000..6ae7b4fcb7 --- /dev/null +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitExecute.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; +using FileExplorerGitIntegration.Helpers; +using Microsoft.Windows.DevHome.SDK; +using Serilog; + +namespace FileExplorerGitIntegration.Models; + +public class GitExecute +{ + public static GitCommandRunnerResultInfo ExecuteGitCommand(string gitApplication, string repositoryDirectory, string arguments) + { + try + { + var processStartInfo = new ProcessStartInfo + { + FileName = gitApplication, + Arguments = arguments, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = repositoryDirectory ?? string.Empty, + }; + + using var process = Process.Start(processStartInfo); + if (process != null) + { + var output = process.StandardOutput.ReadToEnd(); + + // Add timeout for 1 minute + process.WaitForExit(TimeSpan.FromMinutes(1)); + 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}", arguments); + return new GitCommandRunnerResultInfo(ProviderOperationStatus.Failure, "Failed to invoke Git with arguments", string.Empty, ex, arguments); + } + } +}