From e6665a588bcca86b9c25ef4791274840bc65b4fa Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 6 Aug 2026 14:44:24 +1000 Subject: [PATCH 01/10] feat(git-provider): Add basic GitRepo provider --- src/PowershellModule/Git/GitProvider.cs | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/PowershellModule/Git/GitProvider.cs diff --git a/src/PowershellModule/Git/GitProvider.cs b/src/PowershellModule/Git/GitProvider.cs new file mode 100644 index 0000000..5167ede --- /dev/null +++ b/src/PowershellModule/Git/GitProvider.cs @@ -0,0 +1,52 @@ +using System; +using System.Management.Automation; +using System.Management.Automation.Provider; + +namespace PowershellModule.Git; + +// https://learn.microsoft.com/en-us/powershell/scripting/developer/provider/accessdbprovidersample02?view=powershell-7.6 +[CmdletProvider(Name, ProviderCapabilities.None)] +public class GitProvider : NavigationCmdletProvider +{ + public const string Name = "GitRepo"; + + public GitProvider() + { + } + + protected override PSDriveInfo NewDrive(PSDriveInfo drive) + { + if (drive.Root != string.Empty) + { + throw new Exception("Drive root must be an empty string"); + } + return base.NewDrive(drive); + } + + protected override void NewItem(string path, string itemTypeName, object newItemValue) + { + base.NewItem(path, itemTypeName, newItemValue); + } + + protected override PSDriveInfo RemoveDrive(PSDriveInfo drive) + { + return base.RemoveDrive(drive); + } + + // I think this is needed to be able to cd into it + protected override bool IsItemContainer(string path) + { + return true; + } + + // I think this is needed to be able to cd into it + protected override bool ItemExists(string path) + { + return true; + } + + protected override bool IsValidPath(string path) + { + throw new System.NotImplementedException(); + } +} \ No newline at end of file From 05d6eece99c180c163e588c0c7f7568a3f3069d1 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 6 Aug 2026 14:46:06 +1000 Subject: [PATCH 02/10] chore(powershell-harness): refactor command code, add git provider to runspace, output command results to console --- src/PowershellHarness/Program.cs | 59 +++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/src/PowershellHarness/Program.cs b/src/PowershellHarness/Program.cs index 077a9b7..4bbd0be 100644 --- a/src/PowershellHarness/Program.cs +++ b/src/PowershellHarness/Program.cs @@ -2,6 +2,7 @@ using System.Management.Automation.Runspaces; using System.Text; using PowershellModule.Calendar; +using PowershellModule.Git; namespace PowershellHarness; @@ -36,8 +37,26 @@ class Program var host = new CustomHost(Console.WindowWidth); var runspace = InitialisePowershellHost(host); - // InvokeCommand(runspace, GetCalendarCommand.FullName); + host.UI.SetNextPromptChoice(3); + InvokeCommand(runspace, "New-PSDrive", [ + CreateCommand("name", "git-test"), + CreateCommand("PSProvider", "GitRepo"), + CreateCommand("Root", "\\"), + ]); + + InvokeCommand(runspace, "Set-Location", + [ + // Technically this can just be the command but this is a bit easier + CreateCommand("path", "git-test:/") + ]); + + InvokeCommand(runspace, "Get-Location"); + } + + private static void CalendarTestCommands(Runspace runspace) + { + InvokeCommand(runspace, GetCalendarCommand.FullName); foreach (var day in Enum.GetValues()) { InvokeCommand(runspace, GetCalendarCommand.FullName, [ @@ -48,11 +67,15 @@ class Program ]); } - // InvokeCommand(runspace, GetCalendarCommand.FullName, [ - // new CommandParameter(nameof(GetCalendarCommand.MarkedDaySymbol), "🤫"), - // new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), DayOfWeek.Saturday) - // ]); - // InvokeCommand(runspace, GetCalendarCommand.FullName, [new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), "Sunday")]); + InvokeCommand(runspace, GetCalendarCommand.FullName, [ + new CommandParameter(nameof(GetCalendarCommand.MarkedDaySymbol), "🤫"), + new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), DayOfWeek.Saturday) + ]); + InvokeCommand(runspace, GetCalendarCommand.FullName, [new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), "Sunday")]); + } + + private static void TestGitProvider(Runspace runspace) + { } private static CommandParameter CreateCommand(string name, string? argument = null) @@ -97,6 +120,10 @@ class Program var getCalendarCommand = new SessionStateCmdletEntry(GetCalendarCommand.FullName, typeof(GetCalendarCommand), null); initialSessionState.Commands.Add(getCalendarCommand); + + var gitProvider = new SessionStateProviderEntry(GitProvider.Name, typeof(GitProvider), null); + initialSessionState.Providers.Add(gitProvider); + // Create a runspace from the state, open and return it var runspace = RunspaceFactory.CreateRunspace(host, initialSessionState); @@ -114,16 +141,29 @@ class Program // and this is just a debug harness so it doesn't really matter for now using var pipeline = runspace.CreatePipeline(); // using var powershell = PowerShell.Create(runspace); + + // StringBuilder to store the output of this command including any output results (but not errors yet) + // this is just a rudimentary test and the pwsh debug profile should be used instead as it loads the module + // in a full powershell window with debugger attached. Just no automatic command running sadly. + var sb = new StringBuilder(); + + sb.Append(command); var cmd = new Command(command); if (parameters is not null) { - foreach (var commandParameter in parameters) + var param = parameters.ToList(); + sb.Append(' ') + .AppendJoin(' ', param.Select(x => $"-{x.Name} {x.Value}")); + + foreach (var commandParameter in param) { cmd.Parameters.Add(commandParameter); } } + sb.AppendLine(); + pipeline.Commands.Add(cmd); // powershell.Commands.AddCommand(cmd); @@ -133,8 +173,11 @@ class Program // var results = powershell.Invoke(); foreach (var result in results) { - Console.Write(result); + sb.AppendLine(result.ToString()); + // Console.WriteLine(result); } + + Console.WriteLine(sb); } catch (Exception ex) { From 1b09d15937f5be3d96e9b0e01f61b001db86fe31 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 6 Aug 2026 15:31:17 +1000 Subject: [PATCH 03/10] feat(git-provider): More placeholder overrides for future use --- src/PowershellModule/Git/GitProvider.cs | 18 +++++++++++++++++- src/PowershellModule/Git/GitPsDriveInfo.cs | 12 ++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/PowershellModule/Git/GitPsDriveInfo.cs diff --git a/src/PowershellModule/Git/GitProvider.cs b/src/PowershellModule/Git/GitProvider.cs index 5167ede..5b290be 100644 --- a/src/PowershellModule/Git/GitProvider.cs +++ b/src/PowershellModule/Git/GitProvider.cs @@ -16,16 +16,20 @@ public class GitProvider : NavigationCmdletProvider protected override PSDriveInfo NewDrive(PSDriveInfo drive) { + // This is a bit of a hack to ensure that no drive is registered with a root location. + // It seems to be how PSDrives are registered for Alias providers, and we technically don't have any concept + // of a normal file system if (drive.Root != string.Empty) { throw new Exception("Drive root must be an empty string"); } + return base.NewDrive(drive); } protected override void NewItem(string path, string itemTypeName, object newItemValue) { - base.NewItem(path, itemTypeName, newItemValue); + //base.NewItem(path, itemTypeName, newItemValue); } protected override PSDriveInfo RemoveDrive(PSDriveInfo drive) @@ -33,6 +37,18 @@ public class GitProvider : NavigationCmdletProvider return base.RemoveDrive(drive); } + protected override void GetChildNames(string path, ReturnContainers returnContainers) + { + // TODO: get all configured repos based on a path from the underlying GitPsDriveInfo then + // output their repository names + } + + protected override void GetChildItems(string path, bool recurse) + { + // TODO: get all configured repos based on a path from the underlying GitPsDriveInfo then + // output their repository locations and any other meta data I store + } + // I think this is needed to be able to cd into it protected override bool IsItemContainer(string path) { diff --git a/src/PowershellModule/Git/GitPsDriveInfo.cs b/src/PowershellModule/Git/GitPsDriveInfo.cs new file mode 100644 index 0000000..2566267 --- /dev/null +++ b/src/PowershellModule/Git/GitPsDriveInfo.cs @@ -0,0 +1,12 @@ +using System.Management.Automation; + +namespace PowershellModule.Git; + +public class GitPsDriveInfo : PSDriveInfo +{ + protected GitPsDriveInfo(PSDriveInfo driveInfo) + : base(driveInfo) + { + + } +} \ No newline at end of file From 830ae2c5606856e1bea78be4a2eaf2063f2290eb Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 7 Aug 2026 08:51:54 +1000 Subject: [PATCH 04/10] feat(git-provider): add inital implementation for New-GitRepo --- src/PowershellModule/Git/GitProvider.cs | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/PowershellModule/Git/GitProvider.cs b/src/PowershellModule/Git/GitProvider.cs index 5b290be..46ce4cd 100644 --- a/src/PowershellModule/Git/GitProvider.cs +++ b/src/PowershellModule/Git/GitProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Management.Automation; using System.Management.Automation.Provider; @@ -65,4 +66,35 @@ public class GitProvider : NavigationCmdletProvider { throw new System.NotImplementedException(); } +} + +[Cmdlet(VerbsCommon.New, Noun)] +public class RegisterGitRepo : PSCmdlet +{ + private const string Noun = "GitRepo"; + + [Parameter( + Mandatory = true, + Position = 0, + ValueFromPipeline = true, + HelpMessage = "Reference name for the repo")] + public string Name { get; set; } + + protected override void BeginProcessing() + { + var pwd = this.SessionState.Path.CurrentLocation.Path; + WriteObject("Checking if current directory is a git repository..."); + var ps = new ProcessStartInfo( //$"git -C \"{pwd}\" rev-parse --show-toplevel") + "git", ["rev-parse", "--show-toplevel"]) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = pwd + }; + var a = Process.Start(ps); + Console.WriteLine(a.StandardOutput.ReadToEnd()); + Console.WriteLine(a.StandardError.ReadToEnd()); + + base.BeginProcessing(); + } } \ No newline at end of file From 6193d30029bc5d5ed947b45457c92ecbae817706 Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 7 Aug 2026 11:05:14 +1000 Subject: [PATCH 05/10] feat(git-provider): add Sqlite to PowershellModule - add CopyLocalLockFileAssemblies to PowershellModule.csproj - add postbuild script to copy Sqlite files and remove unneeded files in debug output - add sqlite-net-pcl 1.11.285 --- Directory.Packages.props | 1 + src/PowershellModule/Git/GitProvider.cs | 32 --------------- src/PowershellModule/Git/NewGitRepoCommand.cs | 39 +++++++++++++++++++ src/PowershellModule/PostBuild.ps1 | 27 +++++++++++++ src/PowershellModule/PowershellModule.csproj | 6 +++ 5 files changed, 73 insertions(+), 32 deletions(-) create mode 100644 src/PowershellModule/Git/NewGitRepoCommand.cs create mode 100644 src/PowershellModule/PostBuild.ps1 diff --git a/Directory.Packages.props b/Directory.Packages.props index 1e8aa77..6ef9afe 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + diff --git a/src/PowershellModule/Git/GitProvider.cs b/src/PowershellModule/Git/GitProvider.cs index 46ce4cd..5b290be 100644 --- a/src/PowershellModule/Git/GitProvider.cs +++ b/src/PowershellModule/Git/GitProvider.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using System.Management.Automation; using System.Management.Automation.Provider; @@ -66,35 +65,4 @@ public class GitProvider : NavigationCmdletProvider { throw new System.NotImplementedException(); } -} - -[Cmdlet(VerbsCommon.New, Noun)] -public class RegisterGitRepo : PSCmdlet -{ - private const string Noun = "GitRepo"; - - [Parameter( - Mandatory = true, - Position = 0, - ValueFromPipeline = true, - HelpMessage = "Reference name for the repo")] - public string Name { get; set; } - - protected override void BeginProcessing() - { - var pwd = this.SessionState.Path.CurrentLocation.Path; - WriteObject("Checking if current directory is a git repository..."); - var ps = new ProcessStartInfo( //$"git -C \"{pwd}\" rev-parse --show-toplevel") - "git", ["rev-parse", "--show-toplevel"]) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - WorkingDirectory = pwd - }; - var a = Process.Start(ps); - Console.WriteLine(a.StandardOutput.ReadToEnd()); - Console.WriteLine(a.StandardError.ReadToEnd()); - - base.BeginProcessing(); - } } \ No newline at end of file diff --git a/src/PowershellModule/Git/NewGitRepoCommand.cs b/src/PowershellModule/Git/NewGitRepoCommand.cs new file mode 100644 index 0000000..89891f6 --- /dev/null +++ b/src/PowershellModule/Git/NewGitRepoCommand.cs @@ -0,0 +1,39 @@ +using System; +using System.Diagnostics; +using System.Management.Automation; +using SQLite; + +namespace PowershellModule.Git; + +[Cmdlet(VerbsCommon.New, Noun)] +public class NewGitRepoCommand : PSCmdlet +{ + private const string Noun = "GitRepo"; + + [Parameter( + Mandatory = true, + Position = 0, + ValueFromPipeline = true, + HelpMessage = "Reference name for the repo")] + public string Name { get; set; } + + protected override void BeginProcessing() + { + using var conn = new SQLiteConnection("./test.db"); + + var pwd = this.SessionState.Path.CurrentLocation.Path; + WriteObject("Checking if current directory is a git repository..."); + var ps = new ProcessStartInfo( //$"git -C \"{pwd}\" rev-parse --show-toplevel") + "git", ["rev-parse", "--show-toplevel"]) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = pwd + }; + var a = Process.Start(ps); + Console.WriteLine(a.StandardOutput.ReadToEnd()); + Console.WriteLine(a.StandardError.ReadToEnd()); + + base.BeginProcessing(); + } +} \ No newline at end of file diff --git a/src/PowershellModule/PostBuild.ps1 b/src/PowershellModule/PostBuild.ps1 new file mode 100644 index 0000000..d4c0725 --- /dev/null +++ b/src/PowershellModule/PostBuild.ps1 @@ -0,0 +1,27 @@ +<# + .SYNOPSIS + Removes all non-core files from build output +#> +param( + [Parameter(ValueFromPipeline = $true, Position = 0, Mandatory = $true)] + $targetDir +) + +$nativeSqliteDllLocation = "$( $targetDir )runtimes\win-x64\native\e_sqlite3.dll"; + +if ($false -eq (Test-Path $nativeSqliteDllLocation)) +{ + Write-Error "POST BUILD FAILED: Unable to locate $nativeSqliteDllLocation" +} + +Write-Host "Copying '$nativeSqliteDllLocation' to '$targetDir'" +Copy-Item -Path $nativeSqliteDllLocation -Destination $targetDir + +# Because we use CopyLocalLockFileAssemblies Files that are needed for the module +$allowList = @( + "ModuleCore*" + "PowershellModule*" + "*SQLite*" +) +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 c7f5801..95ccbdc 100644 --- a/src/PowershellModule/PowershellModule.csproj +++ b/src/PowershellModule/PowershellModule.csproj @@ -5,16 +5,22 @@ PowershellModule latestmajor enable + true All + + + + + From dedea9213b540dd3142d2a399ffec03717ada123 Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 7 Aug 2026 14:58:16 +1000 Subject: [PATCH 06/10] refactor(git-provider): Basic manager class implementation and dummy Set-GitRepo command --- src/PowershellModule/Git/NewGitRepoCommand.cs | 116 ++++++++++++++++-- 1 file changed, 104 insertions(+), 12 deletions(-) diff --git a/src/PowershellModule/Git/NewGitRepoCommand.cs b/src/PowershellModule/Git/NewGitRepoCommand.cs index 89891f6..583a616 100644 --- a/src/PowershellModule/Git/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/NewGitRepoCommand.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.IO; using System.Management.Automation; using SQLite; @@ -17,23 +18,114 @@ public class NewGitRepoCommand : PSCmdlet HelpMessage = "Reference name for the repo")] public string Name { get; set; } + public NewGitRepoCommand() + { + Console.WriteLine($"{nameof(NewGitRepoCommand)} init"); + var gm = GitManager.Instance; + } + protected override void BeginProcessing() { - using var conn = new SQLiteConnection("./test.db"); - + //using var conn = new SQLiteConnection("./test.db"); + var pwd = this.SessionState.Path.CurrentLocation.Path; WriteObject("Checking if current directory is a git repository..."); - var ps = new ProcessStartInfo( //$"git -C \"{pwd}\" rev-parse --show-toplevel") - "git", ["rev-parse", "--show-toplevel"]) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - WorkingDirectory = pwd - }; - var a = Process.Start(ps); - Console.WriteLine(a.StandardOutput.ReadToEnd()); - Console.WriteLine(a.StandardError.ReadToEnd()); + + var repoFolfder = IsGitRepo(pwd); + base.BeginProcessing(); } + + private ParsedGitFolderDetails? IsGitRepo(string path) + { + var ps = new ProcessStartInfo("git", + ["rev-parse", "--show-toplevel"]) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = path + }; + + // 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) + { + 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()); + + var repoFolderInfo = new ParsedGitFolderDetails + { + Directory = dirInfo.FullName, + Folder = dirInfo.Name, + }; + + return repoFolderInfo; + } + + if (!gitProcess.StandardError.EndOfStream) + { + var errorAsException = new Exception(gitProcess.StandardError.ReadToEnd()); + WriteError(new ErrorRecord(errorAsException, "git-not-found", ErrorCategory.FromStdErr, null)); + } + + return null; + } + + /// + /// The directory details of the directory returned from git rev-parse --show-toplevel + /// + private class ParsedGitFolderDetails + { + /// + /// The full path to the top level folder containing a git repository + /// + public string Directory { get; set; } = null!; + + /// + /// The last folder name of the directory + /// + public string Folder { get; set; } = null!; + } +} + +[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() + { + base.BeginProcessing(); + } +} + +// TODO: better name for this +public class GitManager +{ + private static readonly Lazy GitManagerInstance = new(() => new GitManager()); + public static GitManager Instance => GitManagerInstance.Value; + + private GitManager() + { + Console.WriteLine($"{nameof(GitManager)} init"); + } } \ No newline at end of file From 1363f6ce02353d73061a05cd55f0e615656d5b5f Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 7 Aug 2026 16:49:30 +1000 Subject: [PATCH 07/10] chore(git-provider): explore setting the location based on a child fragment match --- src/PowershellModule/Git/GitProvider.cs | 18 ++++++++++++++++++ src/PowershellModule/Git/NewGitRepoCommand.cs | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/PowershellModule/Git/GitProvider.cs b/src/PowershellModule/Git/GitProvider.cs index 5b290be..e340c2b 100644 --- a/src/PowershellModule/Git/GitProvider.cs +++ b/src/PowershellModule/Git/GitProvider.cs @@ -61,6 +61,24 @@ public class GitProvider : NavigationCmdletProvider return true; } + protected override string MakePath(string parent, string child) + { + // Test to see what happens if set the location if the path matches a pretend repo registered with the name test + // The result of the test is that yes, this does work, but there's also a shit load of calls to this method as + // it traverses the entire directory path. + // This seems to just be internal powershell behaviour so as long as this is quick the first time, everything + // afterwards just always happens. But probably happens more seeing as I'm changing the location of the path + // in this current SessionState and probably doesn't happen if this were a normal path traversal + // where I don't set the location. + if (child == "test") + { + SessionState.Path.SetLocation("F:/Repos/PowershellModule/src/PowershellModule/Git"); + return "F:/Repos/PowershellModule/src/PowershellModule/Git"; + } + + return base.MakePath(parent, child); + } + protected override bool IsValidPath(string path) { throw new System.NotImplementedException(); diff --git a/src/PowershellModule/Git/NewGitRepoCommand.cs b/src/PowershellModule/Git/NewGitRepoCommand.cs index 583a616..3486a91 100644 --- a/src/PowershellModule/Git/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/NewGitRepoCommand.cs @@ -33,7 +33,6 @@ public class NewGitRepoCommand : PSCmdlet var repoFolfder = IsGitRepo(pwd); - base.BeginProcessing(); } @@ -114,6 +113,7 @@ public class SetGitRepoCommand : PSCmdlet protected override void BeginProcessing() { + SessionState.Path.SetLocation("F:/Repos/PowershellModule/src/PowershellModule/Git"); base.BeginProcessing(); } } From b9f856e716ba6ae36f394f1ad661f747601d7291 Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 10 Aug 2026 14:54:15 +1000 Subject: [PATCH 08/10] feat(git-provider): First pass of creating directories from New-GitRepo command - move GitManager to ModuleCore - refactor SetGitRepoCommand to own file --- src/ModuleCore/Git/GitManager.cs | 131 ++++++++++++++++++ src/PowershellModule/Git/NewGitRepoCommand.cs | 55 +++----- src/PowershellModule/Git/SetGitRepoCommand.cs | 23 +++ 3 files changed, 173 insertions(+), 36 deletions(-) create mode 100644 src/ModuleCore/Git/GitManager.cs create mode 100644 src/PowershellModule/Git/SetGitRepoCommand.cs diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs new file mode 100644 index 0000000..7dbfe8a --- /dev/null +++ b/src/ModuleCore/Git/GitManager.cs @@ -0,0 +1,131 @@ +namespace ModuleCore.Git; + +// TODO: better name for this +public class GitManager +{ + private static readonly Lazy GitManagerInstance = new(() => new GitManager()); + public static GitManager Instance => GitManagerInstance.Value; + + /// + /// Simply .ToString() + /// + private static readonly string DirectorySeparator = Path.DirectorySeparatorChar.ToString(); + + private readonly InternalDirectory _repositories; + + private GitManager() + { + Console.WriteLine($"{nameof(GitManager)} init"); + // Initialise the root container + _repositories = new InternalDirectory() + { + Name = 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. + /// + /// + /// + /// + public void RegisterRepo(string absoluteRepositoryLocation, string registrationName) + { + // Regardless of if we get a name or not, the fully qualified version for us + // starts with a + var directorySegmentsFromName = NameToSegments( + string.IsNullOrWhiteSpace(registrationName) + ? new DirectoryInfo(absoluteRepositoryLocation).Name + : registrationName + ); + + _repositories.Add(absoluteRepositoryLocation, directorySegmentsFromName); + } + + /// + /// Takes a name and returns it as a queue of its parts, starting with a root of + /// + /// + /// + private Queue NameToSegments(string name) + { + var segments = NormaliseNamePath(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; } = []; + + /// + /// If not null, this is the absolute location of a registered git repository + /// + public string? FullRepositoryPath { get; set; } + + internal void Add(string absoluteRepositoryLocation, Queue directorySegmentsFromName) + { + var topStack = directorySegmentsFromName.Dequeue(); + + if (topStack == Name) + { + // 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) + { + FullRepositoryPath = absoluteRepositoryLocation; + //Children.Add(topStack, directory); + } + else + { + 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, + }; + Children.Add(nextSegment, nextChild); + } + + // add the next + nextChild.Add(absoluteRepositoryLocation, directorySegmentsFromName); + } + } + else + { + // 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}"); + } + } + } +} \ No newline at end of file diff --git a/src/PowershellModule/Git/NewGitRepoCommand.cs b/src/PowershellModule/Git/NewGitRepoCommand.cs index 3486a91..2b4bf6a 100644 --- a/src/PowershellModule/Git/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/NewGitRepoCommand.cs @@ -2,7 +2,7 @@ using System.Diagnostics; using System.IO; using System.Management.Automation; -using SQLite; +using ModuleCore.Git; namespace PowershellModule.Git; @@ -12,27 +12,40 @@ public class NewGitRepoCommand : PSCmdlet private const string Noun = "GitRepo"; [Parameter( - Mandatory = true, Position = 0, ValueFromPipeline = true, HelpMessage = "Reference name for the repo")] - public string Name { get; set; } + public string? Name { get; set; } public NewGitRepoCommand() { Console.WriteLine($"{nameof(NewGitRepoCommand)} init"); - var gm = GitManager.Instance; } protected override void BeginProcessing() { - //using var conn = new SQLiteConnection("./test.db"); - var pwd = this.SessionState.Path.CurrentLocation.Path; WriteObject("Checking if current directory is a git repository..."); var repoFolfder = IsGitRepo(pwd); + if (repoFolfder is not null) + { + GitManager.Instance.RegisterRepo(repoFolfder.Directory, Name ?? repoFolfder.Folder); + } + else + { + // Not sure how we'd hit this path, but in case we do, show some sort of error. + // TODO: I should probably have IsGitRepo throw instead so I can get the location it tried in the stack track + WriteError(new ErrorRecord( + new Exception("Unable to register repo - failed to parse git repo location"), + "git-parse-failed", + ErrorCategory.InvalidData, + null + ) + ); + } + base.BeginProcessing(); } @@ -98,34 +111,4 @@ public class NewGitRepoCommand : PSCmdlet /// public string Folder { get; set; } = null!; } -} - -[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() - { - SessionState.Path.SetLocation("F:/Repos/PowershellModule/src/PowershellModule/Git"); - base.BeginProcessing(); - } -} - -// TODO: better name for this -public class GitManager -{ - private static readonly Lazy GitManagerInstance = new(() => new GitManager()); - public static GitManager Instance => GitManagerInstance.Value; - - private GitManager() - { - Console.WriteLine($"{nameof(GitManager)} init"); - } } \ No newline at end of file diff --git a/src/PowershellModule/Git/SetGitRepoCommand.cs b/src/PowershellModule/Git/SetGitRepoCommand.cs new file mode 100644 index 0000000..9d6a7f1 --- /dev/null +++ b/src/PowershellModule/Git/SetGitRepoCommand.cs @@ -0,0 +1,23 @@ +using System; +using System.Management.Automation; +using ModuleCore.Git; + +namespace PowershellModule.Git; + +[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() + { + SessionState.Path.SetLocation("F:/Repos/PowershellModule/src/PowershellModule/Git"); + base.BeginProcessing(); + } +} \ No newline at end of file From 09236e826ab1fef37ec6bd62c2a9ed0e930182ec Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 10 Aug 2026 15:50:24 +1000 Subject: [PATCH 09/10] tests(git-provider): Add basic registration tests - make ModuleCore internals visible to ModuleTests --- src/ModuleCore/Git/GitManager.cs | 107 +++++++++++------- src/ModuleCore/ModuleCore.csproj | 6 + tests/ModuleTests/Git/AddRegistrationTests.cs | 36 ++++++ .../BasicRepoRegistration_0.verified.txt | 2 + .../BasicRepoRegistration_1.verified.txt | 2 + .../BasicRepoRegistration_2.verified.txt | 2 + .../Git/TestData/AddRegistrationTestData.cs | 18 +++ 7 files changed, 135 insertions(+), 38 deletions(-) create mode 100644 tests/ModuleTests/Git/AddRegistrationTests.cs create mode 100644 tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_0.verified.txt create mode 100644 tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_1.verified.txt create mode 100644 tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_2.verified.txt create mode 100644 tests/ModuleTests/Git/TestData/AddRegistrationTestData.cs diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index 7dbfe8a..0d5af37 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -6,12 +6,18 @@ 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(); + /// /// Simply .ToString() /// private static readonly string DirectorySeparator = Path.DirectorySeparatorChar.ToString(); private readonly InternalDirectory _repositories; + private readonly Lock _readWriteLock = new(); private GitManager() { @@ -20,6 +26,7 @@ public class GitManager _repositories = new InternalDirectory() { Name = DirectorySeparator, + InternalPath = DirectorySeparator }; } @@ -29,18 +36,35 @@ public class GitManager /// /// /// - /// - public void RegisterRepo(string absoluteRepositoryLocation, string registrationName) + /// The normalised string the repository was registered against + public string RegisterRepo(string absoluteRepositoryLocation, string registrationName) { - // Regardless of if we get a name or not, the fully qualified version for us - // starts with a - var directorySegmentsFromName = NameToSegments( - string.IsNullOrWhiteSpace(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) + { + var normalisedName = NormaliseNamePath(string.IsNullOrWhiteSpace(registrationName) ? new DirectoryInfo(absoluteRepositoryLocation).Name - : registrationName - ); + : registrationName); - _repositories.Add(absoluteRepositoryLocation, directorySegmentsFromName); + // 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; + } } /// @@ -50,7 +74,7 @@ public class GitManager /// private Queue NameToSegments(string name) { - var segments = NormaliseNamePath(name).Split(DirectorySeparator); + var segments = name.Split(DirectorySeparator); return segments.Length == 1 ? new Queue([DirectorySeparator, name]) @@ -84,48 +108,55 @@ public class GitManager 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 void Add(string absoluteRepositoryLocation, Queue directorySegmentsFromName) + /// + /// + /// + /// + /// + /// + /// + internal InternalDirectory? Add(string absoluteRepositoryLocation, Queue directorySegmentsFromName) { var topStack = directorySegmentsFromName.Dequeue(); - if (topStack == Name) - { - // 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) - { - FullRepositoryPath = absoluteRepositoryLocation; - //Children.Add(topStack, directory); - } - else - { - 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, - }; - Children.Add(nextSegment, nextChild); - } - - // add the next - nextChild.Add(absoluteRepositoryLocation, directorySegmentsFromName); - } - } - else + 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) + { + FullRepositoryPath = absoluteRepositoryLocation; + return this; + } + + 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); } } } \ No newline at end of file diff --git a/src/ModuleCore/ModuleCore.csproj b/src/ModuleCore/ModuleCore.csproj index 4ef1a45..2f41110 100644 --- a/src/ModuleCore/ModuleCore.csproj +++ b/src/ModuleCore/ModuleCore.csproj @@ -7,4 +7,10 @@ latestmajor + + + <_Parameter1>ModuleTests + + + diff --git a/tests/ModuleTests/Git/AddRegistrationTests.cs b/tests/ModuleTests/Git/AddRegistrationTests.cs new file mode 100644 index 0000000..0597e61 --- /dev/null +++ b/tests/ModuleTests/Git/AddRegistrationTests.cs @@ -0,0 +1,36 @@ +using System.Text; +using ModuleCore.Calendar; +using ModuleCore.Git; +using ModuleTests.Git.TestData; + +namespace ModuleTests.Git; + +public class AddRegistrationTests +{ + private static readonly VerifySettings Settings; + + static AddRegistrationTests() + { + Settings = new VerifySettings(); + var testBaseDirectory = Path.Join(TestConstants.SnapshotFolderName, nameof(AddRegistrationTests)); + + Settings.UseDirectory(testBaseDirectory); + Settings.DisableDiff(); + } + + [Theory] + [ClassData(typeof(AddRegistrationTestData))] + public Task BasicRepoRegistration((int testId, string path) testData) + { + Settings.UseFileName($"{nameof(BasicRepoRegistration)}_{testData.testId}"); + + var gitManager = GitManager.InternalFreshInstance; + + var repoRegistration = gitManager.RegisterRepo("Test:/some/test/repo", testData.path); + var sb = new StringBuilder(); + sb.AppendLine($"Attempted to register: {testData.path}") + .AppendLine($"Registration result: {repoRegistration}"); + + return Verify(sb, Settings); + } +} \ No newline at end of file diff --git a/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_0.verified.txt b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_0.verified.txt new file mode 100644 index 0000000..3183455 --- /dev/null +++ b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_0.verified.txt @@ -0,0 +1,2 @@ +Attempted to register: test +Registration result: test diff --git a/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_1.verified.txt b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_1.verified.txt new file mode 100644 index 0000000..e9b945d --- /dev/null +++ b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_1.verified.txt @@ -0,0 +1,2 @@ +Attempted to register: test\path +Registration result: test\path diff --git a/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_2.verified.txt b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_2.verified.txt new file mode 100644 index 0000000..3c0b092 --- /dev/null +++ b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_2.verified.txt @@ -0,0 +1,2 @@ +Attempted to register: other/path +Registration result: other\path diff --git a/tests/ModuleTests/Git/TestData/AddRegistrationTestData.cs b/tests/ModuleTests/Git/TestData/AddRegistrationTestData.cs new file mode 100644 index 0000000..3668ba2 --- /dev/null +++ b/tests/ModuleTests/Git/TestData/AddRegistrationTestData.cs @@ -0,0 +1,18 @@ +using System.Linq; + +namespace ModuleTests.Git.TestData; + +public class AddRegistrationTestData : TestDataEnumerator<(int testId, string path)> +{ + public AddRegistrationTestData() + { + Data = new List() + { + "test", + $"test{Path.DirectorySeparatorChar}path", + $"other{Path.AltDirectorySeparatorChar}path" + } + .Select((x, i) => (i, x)) + .ToList(); + } +} \ No newline at end of file From cf2660b6b2ac7d8a1a41718d1e6d2558d6392a6f Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 10 Aug 2026 16:01:15 +1000 Subject: [PATCH 10/10] tests(git-provider): Add fact tests for empty name registrations --- tests/ModuleTests/Git/AddRegistrationTests.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/ModuleTests/Git/AddRegistrationTests.cs b/tests/ModuleTests/Git/AddRegistrationTests.cs index 0597e61..6de31d6 100644 --- a/tests/ModuleTests/Git/AddRegistrationTests.cs +++ b/tests/ModuleTests/Git/AddRegistrationTests.cs @@ -33,4 +33,45 @@ public class AddRegistrationTests return Verify(sb, Settings); } + + [Fact] + public void RepoRegistrationWithEmptyName() + { + Settings.UseFileName(nameof(RepoRegistrationWithEmptyName)); + + var gitManager = GitManager.InternalFreshInstance; + var testRepoAbsolutePath = "Test:/some/test/repo"; + + var emptyName = gitManager.RegisterRepo(testRepoAbsolutePath, ""); + + Assert.Equal("repo", emptyName); + } + + [Fact] + public void RepoRegistrationWithNullName() + { + Settings.UseFileName(nameof(RepoRegistrationWithNullName)); + + var gitManager = GitManager.InternalFreshInstance; + var testRepoAbsolutePath = "Test:/some/test/repo"; + + // Name is technically not-nullable, but string is a reference type so null can be passed in so we should test + // it regardless + var nullName = gitManager.RegisterRepo(testRepoAbsolutePath, null!); + + Assert.Equal("repo", nullName); + } + + [Fact] + public void RepoRegistrationWithWhitespaceName() + { + Settings.UseFileName(nameof(RepoRegistrationWithWhitespaceName)); + + var gitManager = GitManager.InternalFreshInstance; + var testRepoAbsolutePath = "Test:/some/test/repo"; + + var whitespaceName = gitManager.RegisterRepo(testRepoAbsolutePath, " "); + + Assert.Equal("repo", whitespaceName); + } } \ No newline at end of file