using System.Collections.Concurrent; using System.Diagnostics; using ModuleCore.Database; using ModuleCore.Git.Models; using SQLite; namespace ModuleCore.Git; // TODO: better name for this public class GitManager { private static readonly Lazy GitManagerInstance = new(() => new GitManager()); private static Action? _debugWriterDelegate; private readonly DatabaseManager _db; private readonly ConcurrentDictionary _registrations; private GitManager(string? databaseName = null) { _registrations = new ConcurrentDictionary(); // Regular usage of this constructor will never pass a database name in. Currently only tests should be hitting // a code path that has a different database name _db = new DatabaseManager(databaseName ?? "git.db"); InitialiseRegistrations(); } /// /// Returns the current instance. If no instance has been created, returns a new instance /// and then the same instance every call after. /// public static GitManager Instance => GitManagerInstance.Value; /// /// Always returns a new clean instance of GitManager /// internal static GitManager InternalFreshInstance(string databaseName) => new(databaseName); /// /// Deletes the underlying database file. /// /// Avoid calling this outside of tests. /// /// internal void DeleteDatabase() => _db.DeleteDatabase(); /// /// Creates up any database tables and loads all previously saved git registrations. /// private void InitialiseRegistrations() { _debugWriterDelegate?.Invoke("Initialising GitManager from first run - this should only happen once."); _db.InConnection(conn => { var createTableResult = conn.CreateTable(); if (createTableResult == CreateTableResult.Created) { _debugWriterDelegate?.Invoke($"Created table {InternalGitRegistration.TableName}."); } }); _debugWriterDelegate?.Invoke("Loading previous registrations from database."); var registrations = _db.InConnection>(conn => conn.Table() .ToList() ); foreach (var internalGitRegistration in registrations) { _debugWriterDelegate?.Invoke($"Loading {internalGitRegistration.Name} ({internalGitRegistration.Id}) from database..."); if (!_registrations.TryAdd(internalGitRegistration.Name, internalGitRegistration)) { _debugWriterDelegate?.Invoke("...failed to restore - potential duplicate name."); } } } /// /// Registers a git repository based on an absolute location. If is null or empty, /// the registration will use the folder name for the git repo at the top level. /// /// /// /// The normalised string the repository was registered against public string RegisterRepo(string absoluteRepositoryLocation, string registrationName) { registrationName = string.IsNullOrWhiteSpace(registrationName) ? new DirectoryInfo(absoluteRepositoryLocation).Name : registrationName; var gitRegistration = new InternalGitRegistration { Name = registrationName, Location = absoluteRepositoryLocation, Id = Guid.CreateVersion7(), }; return _db.InConnection(conn => { // Query if we already have a registration either by name. Previously we also checked by location, but I // decided to stick with constraining to the name only, same as the key used for the dictionary. // tbh this is a bit of a janky way to do exists when I have to pass the query in anyway, but I just didn't // want to do null checks and a truthy check so I wrap it in a barely-valuable method. var registrationExists = _db.Exists( $""" SELECT 1 FROM {InternalGitRegistration.TableName} WHERE Name = ? """, gitRegistration.Name ); if (registrationExists) { throw new Exception($"A Git repo is already registered with the name {registrationName}."); } // Insert the new record conn.Insert(gitRegistration); if (_registrations.TryAdd(registrationName, gitRegistration)) { _debugWriterDelegate?.Invoke($"Registered '{gitRegistration.Location}' to name '{registrationName}'"); return registrationName; } // This error case should be unlikely, but if a registration was removed but the registrations wasn't updated // correctly then we'd unable to re-add a repo with the same name throw new Exception("An error occured during registration."); }); } /// /// Unregisters a git repo registration by name. If no registration exists an exception will be thrown. /// /// /// public void UnregisterRepo(string registrationName) { _db.InConnection(conn => { var existingRegistration = conn.Query( $""" SELECT {nameof(InternalGitRegistration.Id)} ,{nameof(InternalGitRegistration.Name)} ,{nameof(InternalGitRegistration.Location)} FROM {InternalGitRegistration.TableName} WHERE {nameof(InternalGitRegistration.Name)} = ? """, registrationName) .FirstOrDefault(); if (existingRegistration is null) { throw new Exception($"No registration exists for '{registrationName}'.") { Source = "unregister-repository", }; } var deleted = conn.Delete(existingRegistration.Id); // If we somehow found a registration but delete returned nothing, just return and assume we've already // removed it from registrations. // Seems a bit risky when you read it logically, but by this point the registration shouldn't exist so it doesn't // matter. if (deleted == 0) { return; } // Remove by the name we get from the database instead of what was passed in if (_registrations.TryRemove(registrationName, out var removedItem)) { _debugWriterDelegate?.Invoke($"Removed {registrationName}."); return; } // Weird error to throw, but by this stage we shouldn't have a git repo registered under this name throw new Exception("Failed to remove registration - no registration exists.") { Source = "unregister-repository", }; }); } /// /// Returns all currently registered git repositories, including additional information such as the git repositories /// current branch. /// /// public List ListRepos() { return _registrations.Select(x => new GitRegistration { Name = x.Value.Name, Location = x.Value.Location, CurrentBranch = x.Value.CurrentBranch, } ) .ToList(); } /// /// Returns the file location for a git repo registration by name. /// /// /// /// public string GetDirectoryForRegisteredRepo(string? registeredName) { if (string.IsNullOrEmpty(registeredName)) { throw new Exception("Name cannot be null"); } if (_registrations.TryGetValue(registeredName, out var registration)) { return registration.Location; } throw new Exception($"No git repo has been registered with the name {registeredName}"); } /// /// Returns the git root directory for any nested directory if git rev-parse --show-toplevel returns a value. /// /// Will always return a non-null value if the directory is a git repo, otherwise an exception will be thrown /// /// /// Path to check if it or any of its parents contain a git repository /// /// /// Git fails to start, returns an error (ie: the directory is not in a git repo), or the git process does not return /// any output or error. /// public static ParsedGitFolderDetails IsGitRepo(string path) { _debugWriterDelegate?.Invoke("Checking if current directory is a git repository..."); var ps = new ProcessStartInfo("git", ["-C", path, "rev-parse", "--show-toplevel"]) { RedirectStandardOutput = true, RedirectStandardError = true, }; // If the user doesn't have git on their path, this will throw an exception that I don't have to do anything // special with, it'll be unhandled and powershell will handle it var gitProcess = Process.Start(ps); // This probably shouldn't be possible? Not really sure of the conditions where the process could be started // but return null, but I'm going to consider that unrecoverable error territory if (gitProcess is null) { throw new Exception("git failed to start") { Source = "git-process", }; } gitProcess.WaitForExit(); if (!gitProcess.StandardOutput.EndOfStream) { var directory = gitProcess.StandardOutput.ReadToEnd(); // Gotta trim what we get as it might already have a newline character at the end var dirInfo = new DirectoryInfo(directory.Trim()); _debugWriterDelegate?.Invoke("...location is a git repo!"); var repoFolderInfo = new ParsedGitFolderDetails { Directory = dirInfo.FullName, Folder = dirInfo.Name, }; return repoFolderInfo; } if (!gitProcess.StandardError.EndOfStream) { throw new Exception(gitProcess.StandardError.ReadToEnd()) { Source = "git-not-found", }; } throw new Exception("Unable to determine if directory is repository: git command returned no output or errors.") { Source = "git-parse-failed", }; } /// /// Registers an output for debug output. should be called as soon as the need for output /// is no longer needed. /// /// public static void SetDebugWriter(Action commandRuntime) { _debugWriterDelegate = commandRuntime; } /// /// Clears any output previously registered with /// public static void ClearDebugWriter() { _debugWriterDelegate = null; } /// /// Used for internal git registration and handles getting the current branch /// [Table(TableName)] private class InternalGitRegistration { internal const string TableName = "GitRegistration"; private string _currentBranch = string.Empty; private long _nextCheckTime; [PrimaryKey] public Guid Id { get; set; } [Indexed(Unique = true)] public string Name { get; set; } = null!; public string Location { get; set; } = null!; /// /// The current branch of the repository. This value is cached for 15 minutes after which it becomes stale and /// will be refreshed on the next call to this property. /// public string CurrentBranch => GetCurrentBranch(); // TODO: not fully decided on if I want this feature or not, but keeping it in for now private string GetCurrentBranch() { var now = DateTime.Now; // git branch should be quick enough that even with a large number of registrations this shouldn't be that slow // when doing Get-GitRepo, but regardless we still only get the current branch via git if it's been some amount // of time since the last time we did. if (now.Ticks < _nextCheckTime) { return _currentBranch; } // use -C for the git command so we don't need to set the working directory and the git command can be run // from anywhere against the appropriate location var ps = new ProcessStartInfo("git", ["-C", Location, "branch", "--show-current"]) { RedirectStandardOutput = true, RedirectStandardError = true, }; // If the user doesn't have git on their path, this will throw an exception that I don't have to do anything // special with, it'll be unhandled and powershell will handle it var gitProcess = Process.Start(ps); // This probably shouldn't be possible? Not really sure of the conditions where the process could be started // but return null, but I'm going to consider that unrecoverable error territory if (gitProcess is null) { throw new Exception("git failed to start"); } gitProcess.WaitForExit(); if (!gitProcess.StandardOutput.EndOfStream) { _currentBranch = gitProcess.StandardOutput.ReadToEnd(); } if (!gitProcess.StandardError.EndOfStream) { _currentBranch = gitProcess.StandardError.ReadToEnd(); } // Set the next check to be in the future so we don't hold up any list commands every time. _nextCheckTime = now.AddMinutes(15).Ticks; // The branch name could (will) have a newline character at the end, so we trim that off return _currentBranch.Trim(); } } } /// /// The directory details of the directory returned from git rev-parse --show-toplevel /// public class ParsedGitFolderDetails { /// /// The full path to the top level folder containing a git repository /// public string Directory { get; init; } = null!; /// /// The last folder name of the directory /// public string Folder { get; init; } = null!; }