From 30e103f0215332fdbea4c3d6ee81fc24ebddbba0 Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 21 Aug 2026 16:31:22 +1000 Subject: [PATCH 01/12] tests(git-provider): Add duplicated repo registration test --- src/ModuleCore/Git/GitManager.cs | 11 +++++++++-- tests/ModuleTests/Git/AddRegistrationTests.cs | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index 0d5af37..25a5b41 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -138,8 +138,15 @@ public class GitManager // we add it to the relevant dictionary if (directorySegmentsFromName.Count == 0) { - FullRepositoryPath = absoluteRepositoryLocation; - return this; + // If this is a new registration, set the FullRepositoryPath and return, otherwise we've got a duplicate + // entry and we throw + if (FullRepositoryPath == null) + { + FullRepositoryPath = absoluteRepositoryLocation; + return this; + } + + throw new Exception($"Registration already exists for {InternalPath}"); } var nextSegment = directorySegmentsFromName.Peek(); diff --git a/tests/ModuleTests/Git/AddRegistrationTests.cs b/tests/ModuleTests/Git/AddRegistrationTests.cs index 6de31d6..e04cfc3 100644 --- a/tests/ModuleTests/Git/AddRegistrationTests.cs +++ b/tests/ModuleTests/Git/AddRegistrationTests.cs @@ -74,4 +74,23 @@ 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.Throws(() => gitManager.RegisterRepo(testRepoAbsolutePath, names.AltSeparator)); + } } \ No newline at end of file From 2b3c7d0153fbbdfdafe0b69a8bea29f1ada7d625 Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 21 Aug 2026 16:53:40 +1000 Subject: [PATCH 02/12] feat(git-provider): Remove concept of directory registrations and keep it simple --- src/ModuleCore/Git/GitManager.cs | 158 +++--------------- tests/ModuleTests/Git/AddRegistrationTests.cs | 23 ++- .../BasicRepoRegistration_2.verified.txt | 2 +- 3 files changed, 45 insertions(+), 138 deletions(-) diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index 25a5b41..fe1f69e 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -1,4 +1,6 @@ -namespace ModuleCore.Git; +using System.Collections.Concurrent; + +namespace ModuleCore.Git; // TODO: better name for this public class GitManager @@ -11,23 +13,19 @@ public class GitManager /// internal static GitManager InternalFreshInstance => new(); - /// - /// Simply .ToString() - /// - private static readonly string DirectorySeparator = Path.DirectorySeparatorChar.ToString(); + private class GitRegistration + { + public required string Name { get; set; } + public required string Location { get; set; } + } - private readonly InternalDirectory _repositories; - private readonly Lock _readWriteLock = new(); + private readonly ConcurrentDictionary _registrations; private GitManager() { Console.WriteLine($"{nameof(GitManager)} init"); - // Initialise the root container - _repositories = new InternalDirectory() - { - Name = DirectorySeparator, - InternalPath = DirectorySeparator - }; + + _registrations = new ConcurrentDictionary(); } /// @@ -39,131 +37,19 @@ public class GitManager /// The normalised string the repository was registered against public string RegisterRepo(string absoluteRepositoryLocation, string registrationName) { - // 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) + registrationName = string.IsNullOrWhiteSpace(registrationName) + ? new DirectoryInfo(absoluteRepositoryLocation).Name + : registrationName; + + if (_registrations.TryAdd(registrationName, new GitRegistration() + { + Name = registrationName, + Location = absoluteRepositoryLocation, + })) { - var normalisedName = NormaliseNamePath(string.IsNullOrWhiteSpace(registrationName) - ? new DirectoryInfo(absoluteRepositoryLocation).Name - : registrationName); - - // Regardless of if we get a name or not, the fully qualified version for us - // starts with a / - var directorySegmentsFromName = NameToSegments(normalisedName); - - 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("Failed to register location"); - } - - return normalisedName; + return registrationName; } - } - /// - /// Takes a name and returns it as a queue of its parts, starting with a root of - /// - /// - /// - private Queue NameToSegments(string name) - { - var segments = name.Split(DirectorySeparator); - - return segments.Length == 1 - ? new Queue([DirectorySeparator, name]) - : new Queue([DirectorySeparator, ..segments]); - } - - /// - /// 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!; - - public Dictionary Children { get; set; } = []; - - internal string InternalPath { get; set; } - - /// - /// 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 topStack = directorySegmentsFromName.Dequeue(); - - if (topStack != Name) - { - // 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}"); - } - - // 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) - { - // If this is a new registration, set the FullRepositoryPath and return, otherwise we've got a duplicate - // entry and we throw - if (FullRepositoryPath == null) - { - FullRepositoryPath = absoluteRepositoryLocation; - return this; - } - - throw new Exception($"Registration already exists for {InternalPath}"); - } - - var nextSegment = directorySegmentsFromName.Peek(); - - // 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)) - { - nextChild = new InternalDirectory() - { - Name = nextSegment, - InternalPath = Path.Combine(InternalPath, nextSegment) - }; - Children.Add(nextSegment, nextChild); - } - - // add the next - return nextChild.Add(absoluteRepositoryLocation, directorySegmentsFromName); - } + throw new Exception($"Git repo already registered with the name {registrationName}"); } } \ No newline at end of file diff --git a/tests/ModuleTests/Git/AddRegistrationTests.cs b/tests/ModuleTests/Git/AddRegistrationTests.cs index e04cfc3..511833a 100644 --- a/tests/ModuleTests/Git/AddRegistrationTests.cs +++ b/tests/ModuleTests/Git/AddRegistrationTests.cs @@ -90,7 +90,28 @@ public class AddRegistrationTests 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, names.AltSeparator)); + 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 3c0b092..7f2c4e6 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 From a0e29619a47f6fa7753d74efe4a4b0b8989cc16d Mon Sep 17 00:00:00 2001 From: Scott Date: Sun, 23 Aug 2026 09:04:38 +1000 Subject: [PATCH 03/12] chore(git-provider): Move commands to command folder --- src/PowershellModule/Git/{ => Commands}/NewGitRepoCommand.cs | 4 ++-- src/PowershellModule/Git/{ => Commands}/SetGitRepoCommand.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/PowershellModule/Git/{ => Commands}/NewGitRepoCommand.cs (97%) rename src/PowershellModule/Git/{ => Commands}/SetGitRepoCommand.cs (91%) diff --git a/src/PowershellModule/Git/NewGitRepoCommand.cs b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs similarity index 97% rename from src/PowershellModule/Git/NewGitRepoCommand.cs rename to src/PowershellModule/Git/Commands/NewGitRepoCommand.cs index 2b4bf6a..ba56252 100644 --- a/src/PowershellModule/Git/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs @@ -4,10 +4,10 @@ using System.IO; using System.Management.Automation; using ModuleCore.Git; -namespace PowershellModule.Git; +namespace PowershellModule.Git.Commands; [Cmdlet(VerbsCommon.New, Noun)] -public class NewGitRepoCommand : PSCmdlet +public sealed class NewGitRepoCommand : PSCmdlet { private const string Noun = "GitRepo"; diff --git a/src/PowershellModule/Git/SetGitRepoCommand.cs b/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs similarity index 91% rename from src/PowershellModule/Git/SetGitRepoCommand.cs rename to src/PowershellModule/Git/Commands/SetGitRepoCommand.cs index 9d6a7f1..4ccb140 100644 --- a/src/PowershellModule/Git/SetGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs @@ -2,7 +2,7 @@ using System.Management.Automation; using ModuleCore.Git; -namespace PowershellModule.Git; +namespace PowershellModule.Git.Commands; [Cmdlet(VerbsCommon.Set, Noun)] public class SetGitRepoCommand : PSCmdlet From af5c9e43b6d378a8c9b4d3ffb2fd3b606f80d31a Mon Sep 17 00:00:00 2001 From: Scott Date: Sun, 23 Aug 2026 09:25:45 +1000 Subject: [PATCH 04/12] feat(git-provider): Initial Get-GitRepo implementation --- src/ModuleCore/Git/GitManager.cs | 11 +++++++++++ .../Git/Commands/GetGitRepoCommand.cs | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 src/PowershellModule/Git/Commands/GetGitRepoCommand.cs diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index fe1f69e..ce03922 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -52,4 +52,15 @@ public class GitManager throw new Exception($"Git repo already registered with the name {registrationName}"); } + + public List ListRepos() + { + return _registrations.Select(x => new GitReg() { Name = x.Value.Name, Location = x.Value.Location }).ToList(); + } +} + +public class GitReg +{ + public required string Name { get; set; } + public required string Location { get; set; } } \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs b/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs new file mode 100644 index 0000000..c1a4cb6 --- /dev/null +++ b/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs @@ -0,0 +1,19 @@ +using System.Management.Automation; +using ModuleCore.Git; + +namespace PowershellModule.Git.Commands; + +[Cmdlet(VerbsCommon.Get, Noun)] +public class ListGitRepoCommand : PSCmdlet +{ + private const string Noun = "GitRepo"; + + protected override void BeginProcessing() + { + var repos = GitManager.Instance.ListRepos(); + + WriteObject(repos); + + base.BeginProcessing(); + } +} \ No newline at end of file From 740d35d3e759a986f6856c1f89e6ffcbad32e806 Mon Sep 17 00:00:00 2001 From: Scott Date: Sun, 23 Aug 2026 09:53:33 +1000 Subject: [PATCH 05/12] feat(git-provider): Initial Show-GitRepo implementation --- src/ModuleCore/Git/GitManager.cs | 15 +++++++ .../Git/Commands/ShowGitRepoCommand.cs | 45 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index ce03922..702d0e1 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -57,6 +57,21 @@ public class GitManager { return _registrations.Select(x => new GitReg() { Name = x.Value.Name, Location = x.Value.Location }).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}"); + } } public class GitReg diff --git a/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs b/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs new file mode 100644 index 0000000..116062c --- /dev/null +++ b/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs @@ -0,0 +1,45 @@ +using System; +using System.Management.Automation; +using ModuleCore.Git; + +namespace PowershellModule.Git.Commands; + +[Cmdlet(VerbsCommon.Show, Noun)] +public class ShowGitRepoCommand : PSCmdlet +{ + private const string Noun = "GitRepo"; + + [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 From 48ad65e86cac0db65109e5914bedca430a65133d Mon Sep 17 00:00:00 2001 From: Scott Date: Sun, 23 Aug 2026 09:58:16 +1000 Subject: [PATCH 06/12] refactor(git-provider): Centralise *-GitRepo noun name --- src/PowershellModule/Git/Commands/GetGitRepoCommand.cs | 4 +--- src/PowershellModule/Git/Commands/GitCommands.cs | 6 ++++++ src/PowershellModule/Git/Commands/NewGitRepoCommand.cs | 4 +--- src/PowershellModule/Git/Commands/SetGitRepoCommand.cs | 5 ++--- src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs | 4 +--- 5 files changed, 11 insertions(+), 12 deletions(-) create mode 100644 src/PowershellModule/Git/Commands/GitCommands.cs diff --git a/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs b/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs index c1a4cb6..1859375 100644 --- a/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs @@ -3,11 +3,9 @@ using ModuleCore.Git; namespace PowershellModule.Git.Commands; -[Cmdlet(VerbsCommon.Get, Noun)] +[Cmdlet(VerbsCommon.Get, GitCommands.GitRepoNoun)] public class ListGitRepoCommand : PSCmdlet { - private const string Noun = "GitRepo"; - protected override void BeginProcessing() { var repos = GitManager.Instance.ListRepos(); diff --git a/src/PowershellModule/Git/Commands/GitCommands.cs b/src/PowershellModule/Git/Commands/GitCommands.cs new file mode 100644 index 0000000..2d5c888 --- /dev/null +++ b/src/PowershellModule/Git/Commands/GitCommands.cs @@ -0,0 +1,6 @@ +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/NewGitRepoCommand.cs b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs index ba56252..5aabd59 100644 --- a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs @@ -6,11 +6,9 @@ using ModuleCore.Git; namespace PowershellModule.Git.Commands; -[Cmdlet(VerbsCommon.New, Noun)] +[Cmdlet(VerbsCommon.New, GitCommands.GitRepoNoun)] public sealed class NewGitRepoCommand : PSCmdlet { - private const string Noun = "GitRepo"; - [Parameter( Position = 0, ValueFromPipeline = true, diff --git a/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs b/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs index 4ccb140..69c4a34 100644 --- a/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs @@ -4,11 +4,10 @@ using ModuleCore.Git; namespace PowershellModule.Git.Commands; -[Cmdlet(VerbsCommon.Set, Noun)] +// TODO: decide if I want to use this verb instead of show. Currently this implementation is under Show-GitRepo +[Cmdlet(VerbsCommon.Set, GitCommands.GitRepoNoun)] public class SetGitRepoCommand : PSCmdlet { - private const string Noun = "GitRepo"; - public SetGitRepoCommand() { Console.WriteLine($"{nameof(NewGitRepoCommand)} init"); diff --git a/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs b/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs index 116062c..3394ef3 100644 --- a/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs @@ -4,11 +4,9 @@ using ModuleCore.Git; namespace PowershellModule.Git.Commands; -[Cmdlet(VerbsCommon.Show, Noun)] +[Cmdlet(VerbsCommon.Show, GitCommands.GitRepoNoun)] public class ShowGitRepoCommand : PSCmdlet { - private const string Noun = "GitRepo"; - [Parameter( Position = 0, ValueFromPipeline = true, From 52f7260bf457aa3a7f79837a7acf095b2faf4857 Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 24 Aug 2026 10:02:10 +1000 Subject: [PATCH 07/12] chore(git-provider): Remove output from command constructors, change informational output to use WriteDebug --- src/PowershellModule/Git/Commands/NewGitRepoCommand.cs | 3 +-- src/PowershellModule/Git/Commands/SetGitRepoCommand.cs | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs index 5aabd59..4078837 100644 --- a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs @@ -17,13 +17,12 @@ public sealed class NewGitRepoCommand : PSCmdlet public NewGitRepoCommand() { - Console.WriteLine($"{nameof(NewGitRepoCommand)} init"); } protected override void BeginProcessing() { var pwd = this.SessionState.Path.CurrentLocation.Path; - WriteObject("Checking if current directory is a git repository..."); + WriteDebug("Checking if current directory is a git repository..."); var repoFolfder = IsGitRepo(pwd); diff --git a/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs b/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs index 69c4a34..383b436 100644 --- a/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs @@ -1,5 +1,4 @@ -using System; -using System.Management.Automation; +using System.Management.Automation; using ModuleCore.Git; namespace PowershellModule.Git.Commands; @@ -10,8 +9,6 @@ public class SetGitRepoCommand : PSCmdlet { public SetGitRepoCommand() { - Console.WriteLine($"{nameof(NewGitRepoCommand)} init"); - var a = GitManager.Instance; } protected override void BeginProcessing() From 39022e4186499f396425f64e7ad9e0319fe37781 Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 24 Aug 2026 10:09:58 +1000 Subject: [PATCH 08/12] feat(git-provider): Display current branch when listing registered repos - change setters to init when parsing git folders - change Console.WriteLine to Debug.WriteLine in GitManager constructor --- src/ModuleCore/Git/GitManager.cs | 60 ++++++++++++++++++- .../Git/Commands/NewGitRepoCommand.cs | 4 +- .../Git/Commands/ShowGitRepoCommand.cs | 4 ++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index 702d0e1..14b58db 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics; namespace ModuleCore.Git; @@ -17,13 +18,67 @@ public class GitManager { public required string Name { get; set; } public required string Location { get; set; } + public string CurrentBranch => GetCurrentBranch(); + + private string _currentBranch = string.Empty; + private long _nextCheckTime; + + // 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; + + return _currentBranch; + } } private readonly ConcurrentDictionary _registrations; private GitManager() { - Console.WriteLine($"{nameof(GitManager)} init"); + Debug.WriteLine($"{nameof(GitManager)} init"); _registrations = new ConcurrentDictionary(); } @@ -55,7 +110,7 @@ public class GitManager public List ListRepos() { - return _registrations.Select(x => new GitReg() { Name = x.Value.Name, Location = x.Value.Location }).ToList(); + return _registrations.Select(x => new GitReg() { Name = x.Value.Name, Location = x.Value.Location, CurrentBranch = x.Value.CurrentBranch }).ToList(); } public string GetRepo(string? registeredName) @@ -78,4 +133,5 @@ public class GitReg { 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/PowershellModule/Git/Commands/NewGitRepoCommand.cs b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs index 4078837..103cffa 100644 --- a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs @@ -101,11 +101,11 @@ public sealed class NewGitRepoCommand : PSCmdlet /// /// The full path to the top level folder containing a git repository /// - public string Directory { get; set; } = null!; + public string Directory { get; init; } = null!; /// /// The last folder name of the directory /// - public string Folder { get; set; } = null!; + public string Folder { get; init; } = null!; } } \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs b/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs index 3394ef3..6ff7909 100644 --- a/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs @@ -4,6 +4,10 @@ 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 { From 54d5dd25b9c451510c0c77628a9af28d7d6f71ab Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 24 Aug 2026 10:17:08 +1000 Subject: [PATCH 09/12] refactor(git-provider): Rename GitReg to GitRegistration - rename private GitRegistration to InternalGitRegistration --- src/ModuleCore/Git/GitManager.cs | 121 +++++++++---------- src/ModuleCore/Git/Models/GitRegistration.cs | 8 ++ 2 files changed, 67 insertions(+), 62 deletions(-) create mode 100644 src/ModuleCore/Git/Models/GitRegistration.cs diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index 14b58db..264ff5a 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics; +using ModuleCore.Git.Models; namespace ModuleCore.Git; @@ -14,7 +15,64 @@ public class GitManager /// internal static GitManager InternalFreshInstance => new(); - private class GitRegistration + private readonly ConcurrentDictionary _registrations; + + private GitManager() + { + Debug.WriteLine($"{nameof(GitManager)} init"); + + _registrations = new ConcurrentDictionary(); + } + + /// + /// 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; + + if (_registrations.TryAdd(registrationName, new InternalGitRegistration() + { + Name = registrationName, + Location = absoluteRepositoryLocation, + })) + { + return registrationName; + } + + throw new Exception($"Git repo already registered with the name {registrationName}"); + } + + 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"); + } + + 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 + /// + private class InternalGitRegistration { public required string Name { get; set; } public required string Location { get; set; } @@ -73,65 +131,4 @@ public class GitManager return _currentBranch; } } - - private readonly ConcurrentDictionary _registrations; - - private GitManager() - { - Debug.WriteLine($"{nameof(GitManager)} init"); - - _registrations = new ConcurrentDictionary(); - } - - /// - /// 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; - - if (_registrations.TryAdd(registrationName, new GitRegistration() - { - Name = registrationName, - Location = absoluteRepositoryLocation, - })) - { - return registrationName; - } - - throw new Exception($"Git repo already registered with the name {registrationName}"); - } - - public List ListRepos() - { - return _registrations.Select(x => new GitReg() { 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}"); - } -} - -public class GitReg -{ - 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/Git/Models/GitRegistration.cs b/src/ModuleCore/Git/Models/GitRegistration.cs new file mode 100644 index 0000000..11eea11 --- /dev/null +++ b/src/ModuleCore/Git/Models/GitRegistration.cs @@ -0,0 +1,8 @@ +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 From 93717b4e434792b1bdc90360ab4de0445b88067f Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 24 Aug 2026 14:24:10 +1000 Subject: [PATCH 10/12] chore(git-provider): Code style --- src/ModuleCore/Git/GitManager.cs | 33 +++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index 264ff5a..895bbb4 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -8,13 +8,6 @@ namespace ModuleCore.Git; public class GitManager { private static readonly Lazy GitManagerInstance = new(() => new GitManager()); - public static GitManager Instance => GitManagerInstance.Value; - - /// - /// Always returns a new clean instance of GitManager - /// - internal static GitManager InternalFreshInstance => new(); - private readonly ConcurrentDictionary _registrations; private GitManager() @@ -24,8 +17,15 @@ public class GitManager _registrations = new ConcurrentDictionary(); } + public static GitManager Instance => GitManagerInstance.Value; + /// - /// Registers a git repository based on an absolute location. If is null or empty, + /// Always returns a new clean instance of GitManager + /// + internal static GitManager InternalFreshInstance => new(); + + /// + /// 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. /// /// @@ -37,7 +37,7 @@ public class GitManager ? new DirectoryInfo(absoluteRepositoryLocation).Name : registrationName; - if (_registrations.TryAdd(registrationName, new InternalGitRegistration() + if (_registrations.TryAdd(registrationName, new InternalGitRegistration { Name = registrationName, Location = absoluteRepositoryLocation, @@ -51,7 +51,15 @@ public class GitManager public List ListRepos() { - return _registrations.Select(x => new GitRegistration() { Name = x.Value.Name, Location = x.Value.Location, CurrentBranch = x.Value.CurrentBranch }).ToList(); + return _registrations.Select(x => + new GitRegistration + { + Name = x.Value.Name, + Location = x.Value.Location, + CurrentBranch = x.Value.CurrentBranch, + } + ) + .ToList(); } public string GetRepo(string? registeredName) @@ -74,13 +82,12 @@ public class GitManager /// private class InternalGitRegistration { + private string _currentBranch = string.Empty; + private long _nextCheckTime; public required string Name { get; set; } public required string Location { get; set; } public string CurrentBranch => GetCurrentBranch(); - private string _currentBranch = string.Empty; - private long _nextCheckTime; - // TODO: not fully decided on if I want this feature or not, but keeping it in for now private string GetCurrentBranch() { From caa65ca7c3140d1872b301b7182417d417b1a0c5 Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 24 Aug 2026 14:48:48 +1000 Subject: [PATCH 11/12] feat(git-provider): Initial DatabaseManager --- src/ModuleCore/Database/DatabaseManager.cs | 33 ++++++++++++++++++++++ src/ModuleCore/Git/GitManager.cs | 3 ++ 2 files changed, 36 insertions(+) create mode 100644 src/ModuleCore/Database/DatabaseManager.cs diff --git a/src/ModuleCore/Database/DatabaseManager.cs b/src/ModuleCore/Database/DatabaseManager.cs new file mode 100644 index 0000000..4aabadd --- /dev/null +++ b/src/ModuleCore/Database/DatabaseManager.cs @@ -0,0 +1,33 @@ +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!); + var file = File.Create(_databaseLocation.FullName); + file.Close(); + } + + /// + /// 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 895bbb4..576afd5 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics; +using ModuleCore.Database; using ModuleCore.Git.Models; namespace ModuleCore.Git; @@ -9,12 +10,14 @@ 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"); } public static GitManager Instance => GitManagerInstance.Value; From 633b35f7e934791fe849a1df702c1674dd394606 Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 24 Aug 2026 16:02:51 +1000 Subject: [PATCH 12/12] feat(git-provider): Initial git registration storage in database - update PostBuild.ps1 to not delete data directory created during debug - move sqlite-net-pcl to ModuleCore --- src/ModuleCore/Database/DatabaseManager.cs | 37 ++++++++++- src/ModuleCore/Git/GitManager.cs | 66 +++++++++++++++++--- src/ModuleCore/ModuleCore.csproj | 4 ++ src/PowershellModule/PostBuild.ps1 | 1 + src/PowershellModule/PowershellModule.csproj | 1 - 5 files changed, 95 insertions(+), 14 deletions(-) diff --git a/src/ModuleCore/Database/DatabaseManager.cs b/src/ModuleCore/Database/DatabaseManager.cs index 4aabadd..6f6810d 100644 --- a/src/ModuleCore/Database/DatabaseManager.cs +++ b/src/ModuleCore/Database/DatabaseManager.cs @@ -1,4 +1,6 @@ -namespace ModuleCore.Database; +using SQLite; + +namespace ModuleCore.Database; public class DatabaseManager { @@ -16,8 +18,37 @@ public class DatabaseManager _databaseLocation = new FileInfo(Path.Combine(".", "data", $"{SanitiseFilename(databaseName)}.db")); Directory.CreateDirectory(_databaseLocation.DirectoryName!); - var file = File.Create(_databaseLocation.FullName); - file.Close(); + + 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; } /// diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index 576afd5..a530c4e 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using ModuleCore.Database; using ModuleCore.Git.Models; +using SQLite; namespace ModuleCore.Git; @@ -18,6 +19,11 @@ public class GitManager _registrations = new ConcurrentDictionary(); _db = new DatabaseManager("git.db"); + + _db.InConnection(conn => + { + conn.CreateTable(); + }); } public static GitManager Instance => GitManagerInstance.Value; @@ -40,16 +46,46 @@ public class GitManager ? new DirectoryInfo(absoluteRepositoryLocation).Name : registrationName; - if (_registrations.TryAdd(registrationName, new InternalGitRegistration - { - Name = registrationName, - Location = absoluteRepositoryLocation, - })) + var gitRegistration = new InternalGitRegistration { - return registrationName; - } + Name = registrationName, + Location = absoluteRepositoryLocation, + Id = Guid.CreateVersion7(), + }; - throw new Exception($"Git repo already registered with the 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 + ); + + if (registrationExists) + { + throw new Exception($"A Git repo is already registered with the name {registrationName} or location {absoluteRepositoryLocation}"); + } + + // 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() @@ -83,12 +119,22 @@ public class GitManager /// /// Used for internal git registration and handles getting the current branch /// + [Table(TableName)] private class InternalGitRegistration { private string _currentBranch = string.Empty; private long _nextCheckTime; - public required string Name { get; set; } - public required string Location { get; set; } + internal const string TableName = "GitRegistration"; + + [PrimaryKey] + public Guid Id { get; set; } + + [Indexed(Unique = true)] + public string Name { get; set; } = null!; + + [Indexed(Unique = true)] + public string Location { get; set; } = null!; + public string CurrentBranch => GetCurrentBranch(); // TODO: not fully decided on if I want this feature or not, but keeping it in for now diff --git a/src/ModuleCore/ModuleCore.csproj b/src/ModuleCore/ModuleCore.csproj index 2f41110..81bd88b 100644 --- a/src/ModuleCore/ModuleCore.csproj +++ b/src/ModuleCore/ModuleCore.csproj @@ -13,4 +13,8 @@ + + + + diff --git a/src/PowershellModule/PostBuild.ps1 b/src/PowershellModule/PostBuild.ps1 index d4c0725..cb7b8de 100644 --- a/src/PowershellModule/PostBuild.ps1 +++ b/src/PowershellModule/PostBuild.ps1 @@ -22,6 +22,7 @@ $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 95ccbdc..56f10c3 100644 --- a/src/PowershellModule/PowershellModule.csproj +++ b/src/PowershellModule/PowershellModule.csproj @@ -12,7 +12,6 @@ All -