diff --git a/src/ModuleCore/Database/DatabaseManager.cs b/src/ModuleCore/Database/DatabaseManager.cs deleted file mode 100644 index 6f6810d..0000000 --- a/src/ModuleCore/Database/DatabaseManager.cs +++ /dev/null @@ -1,64 +0,0 @@ -using SQLite; - -namespace ModuleCore.Database; - -public class DatabaseManager -{ - private readonly FileInfo _databaseLocation; - - /// - /// Creates a new manager for the given database file by name - /// - /// - /// Filename for the database with no extension. Slashes are accepted and will create directories as needed. - /// - public DatabaseManager(string databaseName) - { - // Even though the xmldoc says "with no extension", we strip off any extension regardless - _databaseLocation = new FileInfo(Path.Combine(".", "data", $"{SanitiseFilename(databaseName)}.db")); - - Directory.CreateDirectory(_databaseLocation.DirectoryName!); - - if (!File.Exists(_databaseLocation.FullName)) - { - var file = File.Create(_databaseLocation.FullName); - file.Close(); - } - } - - public void InConnection(Action dbAction) - { - using var conn = new SQLiteConnection(_databaseLocation.FullName); - dbAction(conn); - } - - public T InConnection(Func dbAction) - { - using var conn = new SQLiteConnection(_databaseLocation.FullName); - return dbAction(conn); - } - - /// - /// Returns a bool for the given query. Convenience method for . - /// - /// A query starting with SELECT 1, optionally paramaterised with ? - /// Parameter values - /// - public bool Exists(string query, params object[] args) - { - using var conn = new SQLiteConnection(_databaseLocation.FullName); - var exists = conn.ExecuteScalar(query, args); - return exists ?? false; - } - - /// - /// Removes double dots from the filename and removes the file extension - /// - /// - /// - private string SanitiseFilename(string filename) - { - // Honestly not really needed seeing as its just me and this isn't coming from user supplied code, but eh. - return Path.GetFileNameWithoutExtension(filename.Replace("..", string.Empty)); - } -} \ No newline at end of file diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index a530c4e..0d5af37 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -1,31 +1,9 @@ -using System.Collections.Concurrent; -using System.Diagnostics; -using ModuleCore.Database; -using ModuleCore.Git.Models; -using SQLite; - -namespace ModuleCore.Git; +namespace ModuleCore.Git; // TODO: better name for this public class GitManager { private static readonly Lazy GitManagerInstance = new(() => new GitManager()); - private readonly ConcurrentDictionary _registrations; - private readonly DatabaseManager _db; - - private GitManager() - { - Debug.WriteLine($"{nameof(GitManager)} init"); - - _registrations = new ConcurrentDictionary(); - _db = new DatabaseManager("git.db"); - - _db.InConnection(conn => - { - conn.CreateTable(); - }); - } - public static GitManager Instance => GitManagerInstance.Value; /// @@ -34,7 +12,26 @@ public class GitManager internal static GitManager InternalFreshInstance => new(); /// - /// Registers a git repository based on an absolute location. If is null or empty, + /// Simply .ToString() + /// + private static readonly string DirectorySeparator = Path.DirectorySeparatorChar.ToString(); + + private readonly InternalDirectory _repositories; + private readonly Lock _readWriteLock = new(); + + private GitManager() + { + Console.WriteLine($"{nameof(GitManager)} init"); + // Initialise the root container + _repositories = new InternalDirectory() + { + Name = DirectorySeparator, + InternalPath = DirectorySeparator + }; + } + + /// + /// 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. /// /// @@ -42,149 +39,124 @@ public class GitManager /// 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 + // Depending on the caller, it might be possible that they've scripted automatic repo registration. Because I + // don't really want to account to all the subtle ways that can be parallised, I just naively lock on every + // registration attempt. This method should be quick regardless, and I could use ConcurrentDictionary except + // that means every instance of InternalDirectory would need it and yeah nah fuck that I can just lock at the + // top level + lock (_readWriteLock) { - Name = registrationName, - Location = absoluteRepositoryLocation, - Id = Guid.CreateVersion7(), - }; + var normalisedName = NormaliseNamePath(string.IsNullOrWhiteSpace(registrationName) + ? new DirectoryInfo(absoluteRepositoryLocation).Name + : registrationName); - return _db.InConnection(conn => - { - // Query if we already have a registration either by name or location. - // 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 = ? OR - Location = ? - """, - gitRegistration.Name, - gitRegistration.Location - ); + // Regardless of if we get a name or not, the fully qualified version for us + // starts with a / + var directorySegmentsFromName = NameToSegments(normalisedName); - if (registrationExists) + var added = _repositories.Add(absoluteRepositoryLocation, directorySegmentsFromName); + + // Not sure about this, the Add should throw any exceptions on duplicate/failures but for now I'll leave this + // here + if (added == null) { - throw new Exception($"A Git repo is already registered with the name {registrationName} or location {absoluteRepositoryLocation}"); + throw new Exception("Failed to register location"); } - // Insert the new record - conn.Insert(gitRegistration); - - if (_registrations.TryAdd(registrationName, gitRegistration)) - { - 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 List 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"); + return normalisedName; } - - if (_registrations.TryGetValue(registeredName, out var registration)) - { - return registration.Location; - } - - throw new Exception($"No git repo has been registered with the name {registeredName}"); } /// - /// Used for internal git registration and handles getting the current branch + /// Takes a name and returns it as a queue of its parts, starting with a root of /// - [Table(TableName)] - private class InternalGitRegistration + /// + /// + private Queue NameToSegments(string name) { - private string _currentBranch = string.Empty; - private long _nextCheckTime; - internal const string TableName = "GitRegistration"; + var segments = name.Split(DirectorySeparator); - [PrimaryKey] - public Guid Id { get; set; } + return segments.Length == 1 + ? new Queue([DirectorySeparator, name]) + : new Queue([DirectorySeparator, ..segments]); + } - [Indexed(Unique = true)] + /// + /// Normalises the path separators in the given string to use Path.DirectorySeparatorChar + /// + /// + /// + private string NormaliseNamePath(string name) + { + // Feels a bit hacky, but this will actually normalise a path to a valid form. So if the input is + // some/directory/paths, Path.GetRelativePath will normalise it to some\directory\paths, relative to ./ + // which is kind of handy but I also just wish there was a Path method that would do this for me. I know that + // the whole point of Path is that it's based on a file system, but file systems can also be arbitrary and not + // always be drive rooted. + // Either way, this works and saves me having to reimplement a worse method when it's more important that users + // are able to use file paths in whatever form they prefer, which means we leverage the internal implementation + // in a weird way. + return Path.GetRelativePath("./", name); + } + + private class InternalDirectory + { + /// + /// Name of the folder this + /// public string Name { get; set; } = null!; - [Indexed(Unique = true)] - public string Location { get; set; } = null!; + public Dictionary Children { get; set; } = []; - public string CurrentBranch => GetCurrentBranch(); + internal string InternalPath { get; set; } - // TODO: not fully decided on if I want this feature or not, but keeping it in for now - private string GetCurrentBranch() + /// + /// If not null, this is the absolute location of a registered git repository + /// + public string? FullRepositoryPath { get; set; } + + /// + /// + /// + /// + /// + /// + /// + internal InternalDirectory? Add(string absoluteRepositoryLocation, Queue directorySegmentsFromName) { - 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) + var topStack = directorySegmentsFromName.Dequeue(); + + if (topStack != Name) { - return _currentBranch; + // logically it shouldn't be possible to have a value on top of the stack that _doesn't_ exist, but + // just incase we throw as this should only happen if an Add is attempted on the root and the queue was + // not correctly rooted to / + throw new Exception($"Directory segment does not seem to exist: {topStack}"); } - // 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"]) + // We're at the end of the directory segments so we can safely say we're at the end of the tree so + // we add it to the relevant dictionary + if (directorySegmentsFromName.Count == 0) { - 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"); + FullRepositoryPath = absoluteRepositoryLocation; + return this; } - gitProcess.WaitForExit(); + var nextSegment = directorySegmentsFromName.Peek(); - if (!gitProcess.StandardOutput.EndOfStream) + // Attempt to get the next level of the directory. If we don't have a key entry, create one + if (!Children.TryGetValue(nextSegment, out var nextChild)) { - _currentBranch = gitProcess.StandardOutput.ReadToEnd(); + nextChild = new InternalDirectory() + { + Name = nextSegment, + InternalPath = Path.Combine(InternalPath, nextSegment) + }; + Children.Add(nextSegment, nextChild); } - 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; - - return _currentBranch; + // add the next + return nextChild.Add(absoluteRepositoryLocation, directorySegmentsFromName); } } } \ No newline at end of file diff --git a/src/ModuleCore/Git/Models/GitRegistration.cs b/src/ModuleCore/Git/Models/GitRegistration.cs deleted file mode 100644 index 11eea11..0000000 --- a/src/ModuleCore/Git/Models/GitRegistration.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ModuleCore.Git.Models; - -public class GitRegistration -{ - public required string Name { get; set; } - public required string Location { get; set; } - public required string CurrentBranch { get; set; } -} \ No newline at end of file diff --git a/src/ModuleCore/ModuleCore.csproj b/src/ModuleCore/ModuleCore.csproj index 81bd88b..2f41110 100644 --- a/src/ModuleCore/ModuleCore.csproj +++ b/src/ModuleCore/ModuleCore.csproj @@ -13,8 +13,4 @@ - - - - diff --git a/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs b/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs deleted file mode 100644 index 1859375..0000000 --- a/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Management.Automation; -using ModuleCore.Git; - -namespace PowershellModule.Git.Commands; - -[Cmdlet(VerbsCommon.Get, GitCommands.GitRepoNoun)] -public class ListGitRepoCommand : PSCmdlet -{ - protected override void BeginProcessing() - { - var repos = GitManager.Instance.ListRepos(); - - WriteObject(repos); - - base.BeginProcessing(); - } -} \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/GitCommands.cs b/src/PowershellModule/Git/Commands/GitCommands.cs deleted file mode 100644 index 2d5c888..0000000 --- a/src/PowershellModule/Git/Commands/GitCommands.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace PowershellModule.Git.Commands; - -public class GitCommands -{ - public const string GitRepoNoun = "GitRepo"; -} \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs b/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs deleted file mode 100644 index 6ff7909..0000000 --- a/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Management.Automation; -using ModuleCore.Git; - -namespace PowershellModule.Git.Commands; - -// TODO: decide on if I like Show to be used as the verb name. Other options I have are push/pop and open. -// Seeing as this is likely just for me currently, Show-GitRepo suits my workflow more where I'll want to quickly just -// pushd into a git repo, do whatever I want to do with it/change directories in it whatever, and then popd at the end. -// I'll also be integrating the current stack into the custom prompt whenever I get around to doing that -[Cmdlet(VerbsCommon.Show, GitCommands.GitRepoNoun)] -public class ShowGitRepoCommand : PSCmdlet -{ - [Parameter( - Position = 0, - ValueFromPipeline = true, - Mandatory = true, - HelpMessage = "Reference name for the repo")] - public string? Name { get; set; } - - [Parameter( - Mandatory = false, - HelpMessage = "Changes directory directly instead of using Set-Location")] - [Alias("NoSetLocation")] - public SwitchParameter NoStack { get; set; } - - protected override void BeginProcessing() - { - var location = GitManager.Instance.GetRepo(Name); - - // By default instead of doing the same as cd, we instead do pushd so a user can popd straight back to where - // they came from. - // TODO: incorporate this into the custom prompt when I develop that - if (!NoStack) - { - // Push the current location to the stack - // TODO: support named stacks. PowerShell *-Location commands support named stacks, but I don't personally - // use them myself so I haven't implemented them initially. I would like to in the future though, but right - // now it's low value to me. - // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/set-location?view=powershell-7.6#example-4-set-the-current-location-to-a-named-stack - SessionState.Path.PushCurrentLocation(null); - } - - SessionState.Path.SetLocation(location); - base.BeginProcessing(); - } -} \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs b/src/PowershellModule/Git/NewGitRepoCommand.cs similarity index 88% rename from src/PowershellModule/Git/Commands/NewGitRepoCommand.cs rename to src/PowershellModule/Git/NewGitRepoCommand.cs index 103cffa..2b4bf6a 100644 --- a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/NewGitRepoCommand.cs @@ -4,11 +4,13 @@ using System.IO; using System.Management.Automation; using ModuleCore.Git; -namespace PowershellModule.Git.Commands; +namespace PowershellModule.Git; -[Cmdlet(VerbsCommon.New, GitCommands.GitRepoNoun)] -public sealed class NewGitRepoCommand : PSCmdlet +[Cmdlet(VerbsCommon.New, Noun)] +public class NewGitRepoCommand : PSCmdlet { + private const string Noun = "GitRepo"; + [Parameter( Position = 0, ValueFromPipeline = true, @@ -17,12 +19,13 @@ public sealed class NewGitRepoCommand : PSCmdlet public NewGitRepoCommand() { + Console.WriteLine($"{nameof(NewGitRepoCommand)} init"); } protected override void BeginProcessing() { var pwd = this.SessionState.Path.CurrentLocation.Path; - WriteDebug("Checking if current directory is a git repository..."); + WriteObject("Checking if current directory is a git repository..."); var repoFolfder = IsGitRepo(pwd); @@ -101,11 +104,11 @@ public sealed class NewGitRepoCommand : PSCmdlet /// /// The full path to the top level folder containing a git repository /// - public string Directory { get; init; } = null!; + public string Directory { get; set; } = null!; /// /// The last folder name of the directory /// - public string Folder { get; init; } = null!; + public string Folder { get; set; } = null!; } } \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs b/src/PowershellModule/Git/SetGitRepoCommand.cs similarity index 51% rename from src/PowershellModule/Git/Commands/SetGitRepoCommand.cs rename to src/PowershellModule/Git/SetGitRepoCommand.cs index 383b436..9d6a7f1 100644 --- a/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs +++ b/src/PowershellModule/Git/SetGitRepoCommand.cs @@ -1,14 +1,18 @@ -using System.Management.Automation; +using System; +using System.Management.Automation; using ModuleCore.Git; -namespace PowershellModule.Git.Commands; +namespace PowershellModule.Git; -// TODO: decide if I want to use this verb instead of show. Currently this implementation is under Show-GitRepo -[Cmdlet(VerbsCommon.Set, GitCommands.GitRepoNoun)] +[Cmdlet(VerbsCommon.Set, Noun)] public class SetGitRepoCommand : PSCmdlet { + private const string Noun = "GitRepo"; + public SetGitRepoCommand() { + Console.WriteLine($"{nameof(NewGitRepoCommand)} init"); + var a = GitManager.Instance; } protected override void BeginProcessing() diff --git a/src/PowershellModule/PostBuild.ps1 b/src/PowershellModule/PostBuild.ps1 index cb7b8de..d4c0725 100644 --- a/src/PowershellModule/PostBuild.ps1 +++ b/src/PowershellModule/PostBuild.ps1 @@ -22,7 +22,6 @@ $allowList = @( "ModuleCore*" "PowershellModule*" "*SQLite*" - "data" ) Write-Host "Removing all non-module required files from '$targetDir'" Get-ChildItem -Path $targetDir -exclude $allowList | Remove-Item -Recurse \ No newline at end of file diff --git a/src/PowershellModule/PowershellModule.csproj b/src/PowershellModule/PowershellModule.csproj index 56f10c3..95ccbdc 100644 --- a/src/PowershellModule/PowershellModule.csproj +++ b/src/PowershellModule/PowershellModule.csproj @@ -12,6 +12,7 @@ All + diff --git a/tests/ModuleTests/Git/AddRegistrationTests.cs b/tests/ModuleTests/Git/AddRegistrationTests.cs index 511833a..6de31d6 100644 --- a/tests/ModuleTests/Git/AddRegistrationTests.cs +++ b/tests/ModuleTests/Git/AddRegistrationTests.cs @@ -74,44 +74,4 @@ public class AddRegistrationTests Assert.Equal("repo", whitespaceName); } - - [Fact] - public void DuplicateRepoRegistrationShouldFail() - { - Settings.UseFileName(nameof(RepoRegistrationWithWhitespaceName)); - - var gitManager = GitManager.InternalFreshInstance; - var testRepoAbsolutePath = "Test:/some/test/repo"; - string[] paths = ["test", "nested", "path"]; - var names = (NormalSeparator: string.Join(Path.DirectorySeparatorChar, paths), AltSeparator: string.Join(Path.AltDirectorySeparatorChar, paths)); - - var firstRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, names.NormalSeparator); - // TODO: make nested registrations fail in both directions and test - var secondRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, Path.Combine(paths[..1])); - - Assert.Equal(Path.Combine(paths), firstRegistration); - Assert.Equal(Path.Combine(paths[..1]), secondRegistration); - - Assert.Throws(() => gitManager.RegisterRepo(testRepoAbsolutePath, Path.Combine(paths[..1]))); - } - - [Fact] - public void DuplicateRepoRegistrationDifferentSlashShouldNotFail() - { - Settings.UseFileName(nameof(RepoRegistrationWithWhitespaceName)); - - var gitManager = GitManager.InternalFreshInstance; - var testRepoAbsolutePath = "Test:/some/test/repo"; - string[] paths = ["test", "nested", "path"]; - var names = (NormalSeparator: string.Join(Path.DirectorySeparatorChar, paths), AltSeparator: string.Join(Path.AltDirectorySeparatorChar, paths)); - - var firstRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, names.NormalSeparator); - // TODO: make nested registrations fail in both directions and test - var secondRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, Path.Combine(paths[..1])); - var differentPathSeparatorRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, names.AltSeparator); - - Assert.Equal(Path.Combine(paths), firstRegistration); - Assert.Equal(Path.Combine(paths[..1]), secondRegistration); - Assert.Equal(names.AltSeparator, differentPathSeparatorRegistration); - } } \ No newline at end of file diff --git a/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_2.verified.txt b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_2.verified.txt index 7f2c4e6..3c0b092 100644 --- a/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_2.verified.txt +++ b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_2.verified.txt @@ -1,2 +1,2 @@ Attempted to register: other/path -Registration result: other/path +Registration result: other\path