382 lines
No EOL
12 KiB
C#
382 lines
No EOL
12 KiB
C#
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<GitManager> GitManagerInstance = new(() => new GitManager());
|
|
private static Action<string>? _debugWriterDelegate;
|
|
private readonly DatabaseManager _db;
|
|
private readonly ConcurrentDictionary<string, InternalGitRegistration> _registrations;
|
|
|
|
private GitManager()
|
|
{
|
|
_registrations = new ConcurrentDictionary<string, InternalGitRegistration>();
|
|
_db = new DatabaseManager("git.db");
|
|
|
|
InitialiseRegistrations();
|
|
}
|
|
|
|
public static GitManager Instance => GitManagerInstance.Value;
|
|
|
|
/// <summary>
|
|
/// Always returns a new clean instance of GitManager
|
|
/// </summary>
|
|
internal static GitManager InternalFreshInstance => new();
|
|
|
|
/// <summary>
|
|
/// Creates up any database tables and loads all previously saved git registrations.
|
|
/// </summary>
|
|
private void InitialiseRegistrations()
|
|
{
|
|
_debugWriterDelegate?.Invoke("Initialising GitManager from first run - this should only happen once.");
|
|
|
|
_db.InConnection(conn =>
|
|
{
|
|
var createTableResult = conn.CreateTable<InternalGitRegistration>();
|
|
|
|
if (createTableResult == CreateTableResult.Created)
|
|
{
|
|
_debugWriterDelegate?.Invoke($"Created table {InternalGitRegistration.TableName}.");
|
|
}
|
|
});
|
|
|
|
_debugWriterDelegate?.Invoke("Loading previous registrations from database.");
|
|
|
|
var registrations = _db.InConnection<List<InternalGitRegistration>>(conn =>
|
|
conn.Table<InternalGitRegistration>()
|
|
.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.");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers a git repository based on an absolute location. If <paramref name="registrationName" /> is null or empty,
|
|
/// the registration will use the folder name for the git repo at the top level.
|
|
/// </summary>
|
|
/// <param name="absoluteRepositoryLocation"></param>
|
|
/// <param name="registrationName"></param>
|
|
/// <returns>The normalised string the repository was registered against</returns>
|
|
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<string>(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.");
|
|
});
|
|
}
|
|
|
|
public void UnregisterRepo(string registrationName)
|
|
{
|
|
_db.InConnection(conn =>
|
|
{
|
|
var existingRegistration = conn.Query<InternalGitRegistration>(
|
|
$"""
|
|
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<InternalGitRegistration>(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",
|
|
};
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns all currently registered git repositories, including additional information such as the git repositories
|
|
/// current branch.
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
public List<GitRegistration> ListRepos()
|
|
{
|
|
return _registrations.Select(x =>
|
|
new GitRegistration
|
|
{
|
|
Name = x.Value.Name,
|
|
Location = x.Value.Location,
|
|
CurrentBranch = x.Value.CurrentBranch,
|
|
}
|
|
)
|
|
.ToList();
|
|
}
|
|
|
|
public string GetRepo(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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the git root directory for any nested directory if git rev-parse --show-toplevel returns a value.
|
|
/// <para>
|
|
/// Will always return a non-null value if the directory is a git repo, otherwise an exception will be thrown
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="path">Path to check if it or any of its parents contain a git repository</param>
|
|
/// <returns></returns>
|
|
/// <exception cref="Exception">
|
|
/// 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.
|
|
/// </exception>
|
|
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",
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers an output for debug output. <see cref="ClearDebugWriter" /> should be called as soon as the need for output
|
|
/// is no longer needed.
|
|
/// </summary>
|
|
/// <param name="commandRuntime"></param>
|
|
public static void SetDebugWriter(Action<string> commandRuntime)
|
|
{
|
|
_debugWriterDelegate = commandRuntime;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears any output previously registered with <see cref="SetDebugWriter" />
|
|
/// </summary>
|
|
public static void ClearDebugWriter()
|
|
{
|
|
_debugWriterDelegate = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Used for internal git registration and handles getting the current branch
|
|
/// </summary>
|
|
[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!;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The directory details of the directory returned from git rev-parse --show-toplevel
|
|
/// </summary>
|
|
public class ParsedGitFolderDetails
|
|
{
|
|
/// <summary>
|
|
/// The full path to the top level folder containing a git repository
|
|
/// </summary>
|
|
public string Directory { get; init; } = null!;
|
|
|
|
/// <summary>
|
|
/// The last folder name of the directory
|
|
/// </summary>
|
|
public string Folder { get; init; } = null!;
|
|
} |