From d1ec31c6e91fa042156bb08c86db516514cd3f19 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 6 Aug 2026 14:42:56 +1000 Subject: [PATCH 01/58] chore: add placeholder content when generating module manifest --- build/Scripts/CreateModuleManifest.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Scripts/CreateModuleManifest.ps1 b/build/Scripts/CreateModuleManifest.ps1 index 4e3d920..7f5b26a 100644 --- a/build/Scripts/CreateModuleManifest.ps1 +++ b/build/Scripts/CreateModuleManifest.ps1 @@ -21,4 +21,4 @@ $manifestSplat = @{ } New-ModuleManifest @manifestSplat -New-Item "$manifestFileLocation" -ItemType File \ No newline at end of file +New-Item "$manifestFileLocation" -ItemType File -Value "# This file is run when Import-Module `"PowershellModule`" is called" \ No newline at end of file From 6ae34b3c836f6db2de71a650e68564d7a62434cc Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 6 Aug 2026 14:43:51 +1000 Subject: [PATCH 02/58] chore(custom-host): Add way to manage PromptForChoice --- src/PowershellHarness/CustomHost.cs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/PowershellHarness/CustomHost.cs b/src/PowershellHarness/CustomHost.cs index 96ef22b..dcc81ac 100644 --- a/src/PowershellHarness/CustomHost.cs +++ b/src/PowershellHarness/CustomHost.cs @@ -121,6 +121,19 @@ public class CustomUiHost : PSHostUserInterface public string Output => output.ToString(); + private int? _nextChoiceOption; + + /// + /// Sets the next choice option to be used on the next call to . + /// + /// You'll probably get the + /// + /// + public void SetNextPromptChoice(int choiceOption) + { + _nextChoiceOption = choiceOption; + } + public override Dictionary Prompt(string caption, string message, System.Collections.ObjectModel.Collection descriptions) { throw new NotImplementedException("Prompt is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); @@ -128,7 +141,14 @@ public class CustomUiHost : PSHostUserInterface public override int PromptForChoice(string caption, string message, System.Collections.ObjectModel.Collection choices, int defaultChoice) { - throw new NotImplementedException("PromptForChoice is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + if (_nextChoiceOption is null) + { + throw new NotImplementedException("PromptForChoice is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + var choiceReturn = _nextChoiceOption.Value; + _nextChoiceOption = null; + return choiceReturn; } public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options) From e6665a588bcca86b9c25ef4791274840bc65b4fa Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 6 Aug 2026 14:44:24 +1000 Subject: [PATCH 03/58] 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 04/58] 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 05/58] 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 06/58] 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 07/58] 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 08/58] 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 09/58] 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 10/58] 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 11/58] 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 12/58] 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 From 30e103f0215332fdbea4c3d6ee81fc24ebddbba0 Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 21 Aug 2026 16:31:22 +1000 Subject: [PATCH 13/58] 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 14/58] 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 15/58] 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 16/58] 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 17/58] 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 18/58] 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 19/58] 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 20/58] 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 21/58] 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 22/58] 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 23/58] 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 24/58] 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 - From cf336da27191716d29e9e1511ddb140bee0e6b20 Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 24 Aug 2026 16:45:42 +1000 Subject: [PATCH 25/58] feat(git-provider): Load previous registrations, add debug hook - add way to redirect debug output - remove unique constraint on Location --- src/ModuleCore/Git/GitManager.cs | 66 ++++++++++++++++--- .../Git/Commands/NewGitRepoCommand.cs | 4 ++ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index a530c4e..36f2886 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -12,18 +12,48 @@ public class GitManager private static readonly Lazy GitManagerInstance = new(() => new GitManager()); private readonly ConcurrentDictionary _registrations; private readonly DatabaseManager _db; + private static Action? _debugWriterDelegate; private GitManager() { - Debug.WriteLine($"{nameof(GitManager)} init"); - _registrations = new ConcurrentDictionary(); _db = new DatabaseManager("git.db"); + InitialiseRegistrations(); + } + + /// + /// Creates up any database tables and loads all previously saved git registrations. + /// + private void InitialiseRegistrations() + { + _debugWriterDelegate?.Invoke("Initialising GitManager from first run - this should only happen once."); + _db.InConnection(conn => { - conn.CreateTable(); + var createTableResult = conn.CreateTable(); + + if (createTableResult == CreateTableResult.Created) + { + _debugWriterDelegate?.Invoke($"Created table {InternalGitRegistration.TableName}."); + } }); + + _debugWriterDelegate?.Invoke("Loading previous registrations from database."); + + var registrations = _db.InConnection>(conn => + conn.Table() + .ToList() + ); + + foreach (var internalGitRegistration in registrations) + { + _debugWriterDelegate?.Invoke($"Loading {internalGitRegistration.Name} ({internalGitRegistration.Id}) from database..."); + if (!_registrations.TryAdd(internalGitRegistration.Name, internalGitRegistration)) + { + _debugWriterDelegate?.Invoke("...failed to restore - potential duplicate name."); + } + } } public static GitManager Instance => GitManagerInstance.Value; @@ -55,23 +85,22 @@ public class GitManager return _db.InConnection(conn => { - // Query if we already have a registration either by name or location. + // Query if we already have a registration either by name. Previously we also checked by location, but I + // decided to stick with constraining to the name only, same as the key used for the dictionary. // tbh this is a bit of a janky way to do exists when I have to pass the query in anyway, but I just didn't // want to do null checks and a truthy check so I wrap it in a barely-valuable method. var registrationExists = _db.Exists( $""" SELECT 1 FROM {InternalGitRegistration.TableName} - WHERE Name = ? OR - Location = ? + WHERE Name = ? """, - gitRegistration.Name, - gitRegistration.Location + gitRegistration.Name ); if (registrationExists) { - throw new Exception($"A Git repo is already registered with the name {registrationName} or location {absoluteRepositoryLocation}"); + throw new Exception($"A Git repo is already registered with the name {registrationName}."); } // Insert the new record @@ -116,6 +145,24 @@ public class GitManager throw new Exception($"No git repo has been registered with the name {registeredName}"); } + /// + /// Registers an output for debug output. should be called as soon as the need for output + /// is no longer needed. + /// + /// + public static void SetDebugWriter(Action commandRuntime) + { + _debugWriterDelegate = commandRuntime; + } + + /// + /// Clears any output previously registered with + /// + public static void ClearDebugWriter() + { + _debugWriterDelegate = null; + } + /// /// Used for internal git registration and handles getting the current branch /// @@ -132,7 +179,6 @@ public class GitManager [Indexed(Unique = true)] public string Name { get; set; } = null!; - [Indexed(Unique = true)] public string Location { get; set; } = null!; public string CurrentBranch => GetCurrentBranch(); diff --git a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs index 103cffa..9be33a4 100644 --- a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs @@ -24,6 +24,8 @@ public sealed class NewGitRepoCommand : PSCmdlet var pwd = this.SessionState.Path.CurrentLocation.Path; WriteDebug("Checking if current directory is a git repository..."); + GitManager.SetDebugWriter(WriteDebug); + var repoFolfder = IsGitRepo(pwd); if (repoFolfder is not null) @@ -43,6 +45,8 @@ public sealed class NewGitRepoCommand : PSCmdlet ); } + GitManager.ClearDebugWriter(); + base.BeginProcessing(); } From 164df4ecf476a5c7642fd4114e37fa057ab2cd0a Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 24 Aug 2026 16:46:35 +1000 Subject: [PATCH 26/58] chore(git-provider): Code style --- src/ModuleCore/Git/GitManager.cs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index 36f2886..b0e847a 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -10,9 +10,9 @@ namespace ModuleCore.Git; public class GitManager { private static readonly Lazy GitManagerInstance = new(() => new GitManager()); - private readonly ConcurrentDictionary _registrations; - private readonly DatabaseManager _db; private static Action? _debugWriterDelegate; + private readonly DatabaseManager _db; + private readonly ConcurrentDictionary _registrations; private GitManager() { @@ -22,6 +22,13 @@ public class GitManager InitialiseRegistrations(); } + public static GitManager Instance => GitManagerInstance.Value; + + /// + /// Always returns a new clean instance of GitManager + /// + internal static GitManager InternalFreshInstance => new(); + /// /// Creates up any database tables and loads all previously saved git registrations. /// @@ -56,13 +63,6 @@ public class GitManager } } - public static GitManager Instance => GitManagerInstance.Value; - - /// - /// 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. @@ -146,7 +146,7 @@ public class GitManager } /// - /// Registers an output for debug output. should be called as soon as the need for output + /// Registers an output for debug output. should be called as soon as the need for output /// is no longer needed. /// /// @@ -156,7 +156,7 @@ public class GitManager } /// - /// Clears any output previously registered with + /// Clears any output previously registered with /// public static void ClearDebugWriter() { @@ -169,9 +169,9 @@ public class GitManager [Table(TableName)] private class InternalGitRegistration { + internal const string TableName = "GitRegistration"; private string _currentBranch = string.Empty; private long _nextCheckTime; - internal const string TableName = "GitRegistration"; [PrimaryKey] public Guid Id { get; set; } From 7b59b905720fa694bd5cff6836ecccc9b41d1fed Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 24 Aug 2026 17:10:20 +1000 Subject: [PATCH 27/58] chore(git-provider): Use -C when checking if current directory is a git repo - move WriteDebug to IsGitRepo - inline variable for SessionState.Path.CurrentLocation.Path --- .../Git/Commands/NewGitRepoCommand.cs | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs index 9be33a4..29f629a 100644 --- a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs @@ -15,22 +15,15 @@ public sealed class NewGitRepoCommand : PSCmdlet HelpMessage = "Reference name for the repo")] public string? Name { get; set; } - public NewGitRepoCommand() - { - } - protected override void BeginProcessing() { - var pwd = this.SessionState.Path.CurrentLocation.Path; - WriteDebug("Checking if current directory is a git repository..."); - GitManager.SetDebugWriter(WriteDebug); - var repoFolfder = IsGitRepo(pwd); + var repoFolder = IsGitRepo(SessionState.Path.CurrentLocation.Path); - if (repoFolfder is not null) + if (repoFolder is not null) { - GitManager.Instance.RegisterRepo(repoFolfder.Directory, Name ?? repoFolfder.Folder); + GitManager.Instance.RegisterRepo(repoFolder.Directory, Name ?? repoFolder.Folder); } else { @@ -52,12 +45,13 @@ public sealed class NewGitRepoCommand : PSCmdlet private ParsedGitFolderDetails? IsGitRepo(string path) { + WriteDebug("Checking if current directory is a git repository..."); + var ps = new ProcessStartInfo("git", - ["rev-parse", "--show-toplevel"]) + ["-C", path, "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 @@ -79,6 +73,8 @@ public sealed class NewGitRepoCommand : PSCmdlet // Gotta trim what we get as it might already have a newline character at the end var dirInfo = new DirectoryInfo(directory.Trim()); + WriteDebug("...location is a git repo (duh)."); + var repoFolderInfo = new ParsedGitFolderDetails { Directory = dirInfo.FullName, From c48549117f602a26af3834bf83ef43155de92710 Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 24 Aug 2026 17:35:53 +1000 Subject: [PATCH 28/58] chore(git-provider): Trim output when getting current branch --- src/ModuleCore/Git/GitManager.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index b0e847a..8b2b6f4 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -230,7 +230,8 @@ public class GitManager // 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; + // The branch name could (will) have a newline character at the end, so we trim that off + return _currentBranch.Trim(); } } } \ No newline at end of file From cf446fc5dcbebca4a6b63e2fb3d455eef93896bc Mon Sep 17 00:00:00 2001 From: Scott Date: Tue, 25 Aug 2026 08:41:29 +1000 Subject: [PATCH 29/58] refactor(git-provider): move IsGitRepo to GitManager, refactor NewGitRepoCommand --- src/ModuleCore/Git/GitManager.cs | 83 ++++++++++++++++ .../Git/Commands/NewGitRepoCommand.cs | 96 +++---------------- 2 files changed, 94 insertions(+), 85 deletions(-) diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index 8b2b6f4..cfdf2d8 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -145,6 +145,73 @@ public class GitManager throw new Exception($"No git repo has been registered with the name {registeredName}"); } + /// + /// Returns the git root directory for any nested directory if git rev-parse --show-toplevel returns a value. + /// + /// Path to check if it or any of its parents contain a git repository + /// + /// + /// Git fails to start, returns an error (ie: the directory is not in a git repo), or the git process does not return + /// any output or error. + /// + public static ParsedGitFolderDetails IsGitRepo(string path) + { + _debugWriterDelegate?.Invoke("Checking if current directory is a git repository..."); + + var ps = new ProcessStartInfo("git", + ["-C", path, "rev-parse", "--show-toplevel"]) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + // If the user doesn't have git on their path, this will throw an exception that I don't have to do anything + // special with, it'll be unhandled and powershell will handle it + var gitProcess = Process.Start(ps); + + // This probably shouldn't be possible? Not really sure of the conditions where the process could be started + // but return null, but I'm going to consider that unrecoverable error territory + if (gitProcess is null) + { + throw new Exception("git failed to start") + { + Source = "git-process", + }; + } + + gitProcess.WaitForExit(); + + if (!gitProcess.StandardOutput.EndOfStream) + { + var directory = gitProcess.StandardOutput.ReadToEnd(); + // Gotta trim what we get as it might already have a newline character at the end + var dirInfo = new DirectoryInfo(directory.Trim()); + + _debugWriterDelegate?.Invoke("...location is a git repo (duh)."); + + var repoFolderInfo = new ParsedGitFolderDetails + { + Directory = dirInfo.FullName, + Folder = dirInfo.Name, + }; + + return repoFolderInfo; + } + + if (!gitProcess.StandardError.EndOfStream) + { + throw new Exception(gitProcess.StandardError.ReadToEnd()) + { + Source = "git-not-found", + }; + } + + throw new Exception("Unable to determine if directory is repository: git command returned no output or errors.") + { + Source = "git-parse-failed", + }; + } + /// /// Registers an output for debug output. should be called as soon as the need for output /// is no longer needed. @@ -234,4 +301,20 @@ public class GitManager return _currentBranch.Trim(); } } +} + +/// +/// The directory details of the directory returned from git rev-parse --show-toplevel +/// +public class ParsedGitFolderDetails +{ + /// + /// The full path to the top level folder containing a git repository + /// + public string Directory { get; init; } = null!; + + /// + /// The last folder name of the directory + /// + public string Folder { get; init; } = null!; } \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs index 29f629a..7204731 100644 --- a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs @@ -17,95 +17,21 @@ public sealed class NewGitRepoCommand : PSCmdlet protected override void BeginProcessing() { - GitManager.SetDebugWriter(WriteDebug); - - var repoFolder = IsGitRepo(SessionState.Path.CurrentLocation.Path); - - if (repoFolder is not null) + try { + GitManager.SetDebugWriter(WriteDebug); + + var repoFolder = GitManager.IsGitRepo(SessionState.Path.CurrentLocation.Path); + GitManager.Instance.RegisterRepo(repoFolder.Directory, Name ?? repoFolder.Folder); + + GitManager.ClearDebugWriter(); + + base.BeginProcessing(); } - else + catch (Exception ex) { - // 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 - ) - ); + WriteError(new ErrorRecord(ex, ex.Source, ErrorCategory.FromStdErr, null)); } - - GitManager.ClearDebugWriter(); - - base.BeginProcessing(); - } - - private ParsedGitFolderDetails? IsGitRepo(string path) - { - WriteDebug("Checking if current directory is a git repository..."); - - var ps = new ProcessStartInfo("git", - ["-C", path, "rev-parse", "--show-toplevel"]) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - - // If the user doesn't have git on their path, this will throw an exception that I don't have to do anything - // special with, it'll be unhandled and powershell will handle it - var gitProcess = Process.Start(ps); - - // This probably shouldn't be possible? Not really sure of the conditions where the process could be started - // but return null, but I'm going to consider that unrecoverable error territory - if (gitProcess is null) - { - throw new Exception("git failed to start"); - } - - 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()); - - WriteDebug("...location is a git repo (duh)."); - - 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; init; } = null!; - - /// - /// The last folder name of the directory - /// - public string Folder { get; init; } = null!; } } \ No newline at end of file From a666a49db07cb66b32ad4ebdb9140398b502d914 Mon Sep 17 00:00:00 2001 From: Scott Date: Tue, 25 Aug 2026 11:18:03 +1000 Subject: [PATCH 30/58] feat(git-provider): Implement RemoveGitRepoCommand --- src/ModuleCore/Git/GitManager.cs | 48 +++++++++++++++++++ .../Git/Commands/RemoveGitRepoCommand.cs | 36 ++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/PowershellModule/Git/Commands/RemoveGitRepoCommand.cs diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index cfdf2d8..dfeb1f7 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -117,6 +117,54 @@ public class GitManager }); } + public void UnregisterRepo(string registrationName) + { + _db.InConnection(conn => + { + var existingRegistration = conn.Query( + $""" + SELECT {nameof(InternalGitRegistration.Id)} + ,{nameof(InternalGitRegistration.Name)} + ,{nameof(InternalGitRegistration.Location)} + FROM {InternalGitRegistration.TableName} + WHERE {nameof(InternalGitRegistration.Name)} = ? + """, + registrationName) + .FirstOrDefault(); + + if (existingRegistration is null) + { + throw new Exception($"No registration exists for '{registrationName}'.") + { + Source = "unregister-repository", + }; + } + + var deleted = conn.Delete(existingRegistration.Id); + + // If we somehow found a registration but delete returned nothing, just return and assume we've already + // removed it from registrations. + // Seems a bit risky when you read it logically, but by this point the registration shouldn't exist so it doesn't + // matter. + if (deleted == 0) + { + return; + } + + // Remove by the name we get from the database instead of what was passed in + if (_registrations.TryRemove(registrationName, out var removedItem)) + { + _debugWriterDelegate?.Invoke($"Removed {registrationName}."); + } + + // Weird error to throw, but by this stage we shouldn't have a git repo registered under this name + throw new Exception("Failed to remove registration - no registration exists.") + { + Source = "unregister-repository", + }; + }); + } + public List ListRepos() { return _registrations.Select(x => diff --git a/src/PowershellModule/Git/Commands/RemoveGitRepoCommand.cs b/src/PowershellModule/Git/Commands/RemoveGitRepoCommand.cs new file mode 100644 index 0000000..f7c73a3 --- /dev/null +++ b/src/PowershellModule/Git/Commands/RemoveGitRepoCommand.cs @@ -0,0 +1,36 @@ +๏ปฟusing System; +using System.Management.Automation; +using ModuleCore.Git; + +namespace PowershellModule.Git.Commands; + +[Cmdlet(VerbsCommon.Remove, GitCommands.GitRepoNoun)] +public class RemoveGitRepoCommand : PSCmdlet +{ + [Parameter( + Position = 0, + ValueFromPipeline = true, + HelpMessage = "Reference name for the repo")] + public string? Name { get; set; } + + protected override void BeginProcessing() + { + try + { + GitManager.SetDebugWriter(WriteDebug); + + var repoFolder = GitManager.IsGitRepo(SessionState.Path.CurrentLocation.Path); + + // Removing a registration works similar to registering a new one - we either remove by exact name, or by + // the folder if no name is given (so a user can remove a registration from a git repo they're currently in) + GitManager.Instance.UnregisterRepo(Name ?? repoFolder.Folder); + GitManager.ClearDebugWriter(); + + base.BeginProcessing(); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, ex.Source, ErrorCategory.FromStdErr, null)); + } + } +} \ No newline at end of file From 27117ce64b20b4c5139c3f871c356d348403a8fe Mon Sep 17 00:00:00 2001 From: Scott Date: Tue, 25 Aug 2026 11:18:52 +1000 Subject: [PATCH 31/58] refactor(git-provider): remove SetGitRepoCommand --- .../Git/Commands/NewGitRepoCommand.cs | 1 - .../Git/Commands/SetGitRepoCommand.cs | 19 ------------------- .../Git/Commands/ShowGitRepoCommand.cs | 4 ---- 3 files changed, 24 deletions(-) delete mode 100644 src/PowershellModule/Git/Commands/SetGitRepoCommand.cs diff --git a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs index 7204731..bccd95f 100644 --- a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs @@ -24,7 +24,6 @@ public sealed class NewGitRepoCommand : PSCmdlet var repoFolder = GitManager.IsGitRepo(SessionState.Path.CurrentLocation.Path); GitManager.Instance.RegisterRepo(repoFolder.Directory, Name ?? repoFolder.Folder); - GitManager.ClearDebugWriter(); base.BeginProcessing(); diff --git a/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs b/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs deleted file mode 100644 index 383b436..0000000 --- a/src/PowershellModule/Git/Commands/SetGitRepoCommand.cs +++ /dev/null @@ -1,19 +0,0 @@ -๏ปฟusing System.Management.Automation; -using ModuleCore.Git; - -namespace PowershellModule.Git.Commands; - -// 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 -{ - public SetGitRepoCommand() - { - } - - protected override void BeginProcessing() - { - SessionState.Path.SetLocation("F:/Repos/PowershellModule/src/PowershellModule/Git"); - base.BeginProcessing(); - } -} \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs b/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs index 6ff7909..3394ef3 100644 --- a/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs @@ -4,10 +4,6 @@ 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 9eb58698fa5038b84af323855f556c71bf9790ee Mon Sep 17 00:00:00 2001 From: Scott Date: Tue, 25 Aug 2026 13:37:47 +1000 Subject: [PATCH 32/58] chore(git-provider): Code style --- src/PowershellModule/Git/Commands/NewGitRepoCommand.cs | 2 -- src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs | 3 +-- src/PowershellModule/Git/GitProvider.cs | 6 +----- src/PowershellModule/Git/GitPsDriveInfo.cs | 1 - 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs index bccd95f..15678f9 100644 --- a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs @@ -1,6 +1,4 @@ ๏ปฟusing System; -using System.Diagnostics; -using System.IO; using System.Management.Automation; using ModuleCore.Git; diff --git a/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs b/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs index 3394ef3..8a805e1 100644 --- a/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs @@ -1,5 +1,4 @@ -๏ปฟusing System; -using System.Management.Automation; +๏ปฟusing System.Management.Automation; using ModuleCore.Git; namespace PowershellModule.Git.Commands; diff --git a/src/PowershellModule/Git/GitProvider.cs b/src/PowershellModule/Git/GitProvider.cs index e340c2b..7d643ee 100644 --- a/src/PowershellModule/Git/GitProvider.cs +++ b/src/PowershellModule/Git/GitProvider.cs @@ -10,10 +10,6 @@ public class GitProvider : NavigationCmdletProvider { public const string Name = "GitRepo"; - public GitProvider() - { - } - protected override PSDriveInfo NewDrive(PSDriveInfo drive) { // This is a bit of a hack to ensure that no drive is registered with a root location. @@ -81,6 +77,6 @@ public class GitProvider : NavigationCmdletProvider protected override bool IsValidPath(string path) { - throw new System.NotImplementedException(); + throw new NotImplementedException(); } } \ No newline at end of file diff --git a/src/PowershellModule/Git/GitPsDriveInfo.cs b/src/PowershellModule/Git/GitPsDriveInfo.cs index 2566267..7b0d691 100644 --- a/src/PowershellModule/Git/GitPsDriveInfo.cs +++ b/src/PowershellModule/Git/GitPsDriveInfo.cs @@ -7,6 +7,5 @@ public class GitPsDriveInfo : PSDriveInfo protected GitPsDriveInfo(PSDriveInfo driveInfo) : base(driveInfo) { - } } \ No newline at end of file From 50752c069950fa8a0d522d0bf5da6da678bd7851 Mon Sep 17 00:00:00 2001 From: Scott Date: Tue, 25 Aug 2026 13:40:14 +1000 Subject: [PATCH 33/58] chore: Remove unneeded files --- src/PowershellModule/manifest.json | 5 ----- src/PowershellModule/meta/PowershellModule.psd1 | 0 src/PowershellModule/meta/PowershellModule.psm1 | 0 3 files changed, 5 deletions(-) delete mode 100644 src/PowershellModule/manifest.json delete mode 100644 src/PowershellModule/meta/PowershellModule.psd1 delete mode 100644 src/PowershellModule/meta/PowershellModule.psm1 diff --git a/src/PowershellModule/manifest.json b/src/PowershellModule/manifest.json deleted file mode 100644 index 3039b76..0000000 --- a/src/PowershellModule/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -๏ปฟ{ - "_TODO":[ - "add manifest details here maybe idk" - ] -} \ No newline at end of file diff --git a/src/PowershellModule/meta/PowershellModule.psd1 b/src/PowershellModule/meta/PowershellModule.psd1 deleted file mode 100644 index e69de29..0000000 diff --git a/src/PowershellModule/meta/PowershellModule.psm1 b/src/PowershellModule/meta/PowershellModule.psm1 deleted file mode 100644 index e69de29..0000000 From e49b322ec06fa691601097a04f6f638b63a480bf Mon Sep 17 00:00:00 2001 From: Scott Date: Tue, 25 Aug 2026 13:41:20 +1000 Subject: [PATCH 34/58] chore(git-provider): Rename class to match file name --- src/PowershellModule/Git/Commands/GetGitRepoCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs b/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs index 1859375..206f40d 100644 --- a/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs @@ -4,7 +4,7 @@ using ModuleCore.Git; namespace PowershellModule.Git.Commands; [Cmdlet(VerbsCommon.Get, GitCommands.GitRepoNoun)] -public class ListGitRepoCommand : PSCmdlet +public class GetGitRepoCommand : PSCmdlet { protected override void BeginProcessing() { From 5f04ed4262235d9f07af627f99b2ad0da058e59d Mon Sep 17 00:00:00 2001 From: Scott Date: Tue, 25 Aug 2026 14:54:31 +1000 Subject: [PATCH 35/58] feat(git-provider): Set OutputType for GetGitRepoCommand --- src/PowershellModule/Git/Commands/GetGitRepoCommand.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs b/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs index 206f40d..a719fc5 100644 --- a/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs @@ -1,9 +1,11 @@ ๏ปฟusing System.Management.Automation; using ModuleCore.Git; +using ModuleCore.Git.Models; namespace PowershellModule.Git.Commands; [Cmdlet(VerbsCommon.Get, GitCommands.GitRepoNoun)] +[OutputType(typeof(GitRegistration))] public class GetGitRepoCommand : PSCmdlet { protected override void BeginProcessing() From 5528a756f9252dd41c4dd114b360820bb8e5e6d4 Mon Sep 17 00:00:00 2001 From: Scott Date: Tue, 25 Aug 2026 17:16:06 +1000 Subject: [PATCH 36/58] refactor(git-provider): Rename noun to GitRepoRegistration --- ...{GetGitRepoCommand.cs => GetGitRepoRegistrationCommand.cs} | 4 ++-- src/PowershellModule/Git/Commands/GitCommands.cs | 2 +- ...{NewGitRepoCommand.cs => NewGitRepoRegistrationCommand.cs} | 4 ++-- ...eGitRepoCommand.cs => RemoveGitRepoRegistrationCommand.cs} | 4 ++-- ...howGitRepoCommand.cs => ShowGitRepoRegistrationCommand.cs} | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) rename src/PowershellModule/Git/Commands/{GetGitRepoCommand.cs => GetGitRepoRegistrationCommand.cs} (72%) rename src/PowershellModule/Git/Commands/{NewGitRepoCommand.cs => NewGitRepoRegistrationCommand.cs} (84%) rename src/PowershellModule/Git/Commands/{RemoveGitRepoCommand.cs => RemoveGitRepoRegistrationCommand.cs} (87%) rename src/PowershellModule/Git/Commands/{ShowGitRepoCommand.cs => ShowGitRepoRegistrationCommand.cs} (91%) diff --git a/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs b/src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs similarity index 72% rename from src/PowershellModule/Git/Commands/GetGitRepoCommand.cs rename to src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs index a719fc5..c343c22 100644 --- a/src/PowershellModule/Git/Commands/GetGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs @@ -4,9 +4,9 @@ using ModuleCore.Git.Models; namespace PowershellModule.Git.Commands; -[Cmdlet(VerbsCommon.Get, GitCommands.GitRepoNoun)] +[Cmdlet(VerbsCommon.Get, GitCommands.GitRepoRegistrationNoun)] [OutputType(typeof(GitRegistration))] -public class GetGitRepoCommand : PSCmdlet +public class GetGitRepoRegistrationCommand : PSCmdlet { protected override void BeginProcessing() { diff --git a/src/PowershellModule/Git/Commands/GitCommands.cs b/src/PowershellModule/Git/Commands/GitCommands.cs index 2d5c888..8dc36f6 100644 --- a/src/PowershellModule/Git/Commands/GitCommands.cs +++ b/src/PowershellModule/Git/Commands/GitCommands.cs @@ -2,5 +2,5 @@ public class GitCommands { - public const string GitRepoNoun = "GitRepo"; + public const string GitRepoRegistrationNoun = "GitRepoRegistration"; } \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs b/src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs similarity index 84% rename from src/PowershellModule/Git/Commands/NewGitRepoCommand.cs rename to src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs index 15678f9..1f968aa 100644 --- a/src/PowershellModule/Git/Commands/NewGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs @@ -4,8 +4,8 @@ using ModuleCore.Git; namespace PowershellModule.Git.Commands; -[Cmdlet(VerbsCommon.New, GitCommands.GitRepoNoun)] -public sealed class NewGitRepoCommand : PSCmdlet +[Cmdlet(VerbsCommon.New, GitCommands.GitRepoRegistrationNoun)] +public sealed class NewGitRepoRegistrationCommand : PSCmdlet { [Parameter( Position = 0, diff --git a/src/PowershellModule/Git/Commands/RemoveGitRepoCommand.cs b/src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs similarity index 87% rename from src/PowershellModule/Git/Commands/RemoveGitRepoCommand.cs rename to src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs index f7c73a3..fc30cb8 100644 --- a/src/PowershellModule/Git/Commands/RemoveGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs @@ -4,8 +4,8 @@ using ModuleCore.Git; namespace PowershellModule.Git.Commands; -[Cmdlet(VerbsCommon.Remove, GitCommands.GitRepoNoun)] -public class RemoveGitRepoCommand : PSCmdlet +[Cmdlet(VerbsCommon.Remove, GitCommands.GitRepoRegistrationNoun)] +public class RemoveGitRepoRegistrationCommand : PSCmdlet { [Parameter( Position = 0, diff --git a/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs b/src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs similarity index 91% rename from src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs rename to src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs index 8a805e1..56a64ba 100644 --- a/src/PowershellModule/Git/Commands/ShowGitRepoCommand.cs +++ b/src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs @@ -3,8 +3,8 @@ using ModuleCore.Git; namespace PowershellModule.Git.Commands; -[Cmdlet(VerbsCommon.Show, GitCommands.GitRepoNoun)] -public class ShowGitRepoCommand : PSCmdlet +[Cmdlet(VerbsCommon.Show, GitCommands.GitRepoRegistrationNoun)] +public class ShowGitRepoRegistrationCommand : PSCmdlet { [Parameter( Position = 0, From 7c6a47bfcb999a74df20d41dd9873721adf297f0 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 13:16:20 +1000 Subject: [PATCH 37/58] docs(git-provider): Add initial git command documentation - create solution folder for docs --- PowershellModule.slnx | 39 +++++++++++++++++-------------- docs/GitRepositoryRegistration.md | 19 +++++++++++++++ 2 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 docs/GitRepositoryRegistration.md diff --git a/PowershellModule.slnx b/PowershellModule.slnx index 04cf69b..2f6dd79 100644 --- a/PowershellModule.slnx +++ b/PowershellModule.slnx @@ -1,20 +1,23 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/GitRepositoryRegistration.md b/docs/GitRepositoryRegistration.md new file mode 100644 index 0000000..88e74d3 --- /dev/null +++ b/docs/GitRepositoryRegistration.md @@ -0,0 +1,19 @@ +๏ปฟ# Git Repo Registration + +- all commands support `-debug` + +## New-GitRepoRegistration + +`New-GitRepoRegistration [-Name string]` + +## Get-GitRepoRegistration + +`Get-GitRepoRegistration` + +## Show-GitRepoRegistration + +`Show-GitRepoRegistration -Name string [-NoStack|-NoSetLocation]` + +## Remove-GitRepoRegistration + +`Remove-GitRepoRegistration [-Name string]` \ No newline at end of file From 8d64b1aa9ee8cb92eea35ddc3c4171b36a996348 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 13:30:18 +1000 Subject: [PATCH 38/58] fix(git-provider): Return after successful registration removal --- src/ModuleCore/Git/GitManager.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index dfeb1f7..eeb3b60 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -155,6 +155,7 @@ public class GitManager if (_registrations.TryRemove(registrationName, out var removedItem)) { _debugWriterDelegate?.Invoke($"Removed {registrationName}."); + return; } // Weird error to throw, but by this stage we shouldn't have a git repo registered under this name From 46eeb0cb1ee68387c2a4df6b82c5577b7f1218e7 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 14:16:48 +1000 Subject: [PATCH 39/58] feat(git-provider): Update debug output when registering - output when no name is given - update message when current location is confirmed to be a git repo --- src/ModuleCore/Git/GitManager.cs | 6 +++++- .../Git/Commands/NewGitRepoRegistrationCommand.cs | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index eeb3b60..f301244 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -108,6 +108,7 @@ public class GitManager if (_registrations.TryAdd(registrationName, gitRegistration)) { + _debugWriterDelegate?.Invoke($"Registered '{gitRegistration.Location}' to name '{registrationName}'"); return registrationName; } @@ -196,6 +197,9 @@ public class GitManager /// /// Returns the git root directory for any nested directory if git rev-parse --show-toplevel returns a value. + /// + /// Will always return a non-null value if the directory is a git repo, otherwise an exception will be thrown + /// /// /// Path to check if it or any of its parents contain a git repository /// @@ -236,7 +240,7 @@ public class GitManager // Gotta trim what we get as it might already have a newline character at the end var dirInfo = new DirectoryInfo(directory.Trim()); - _debugWriterDelegate?.Invoke("...location is a git repo (duh)."); + _debugWriterDelegate?.Invoke("...location is a git repo!"); var repoFolderInfo = new ParsedGitFolderDetails { diff --git a/src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs b/src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs index 1f968aa..ec460eb 100644 --- a/src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs +++ b/src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs @@ -19,8 +19,15 @@ public sealed class NewGitRepoRegistrationCommand : PSCmdlet { GitManager.SetDebugWriter(WriteDebug); + // Test that we're in a git repo first. If we aren't (or git isn't available), this method will throw + // so we don't need to handle for null (yet). var repoFolder = GitManager.IsGitRepo(SessionState.Path.CurrentLocation.Path); + if (string.IsNullOrWhiteSpace(Name)) + { + WriteDebug("No name given for registration, defaulting to git folder root."); + } + GitManager.Instance.RegisterRepo(repoFolder.Directory, Name ?? repoFolder.Folder); GitManager.ClearDebugWriter(); From 5e0e4ad2be44d9463fdd6a8984efd4958540ba09 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 14:17:06 +1000 Subject: [PATCH 40/58] docs(git-provider): Document New-GitRepoRegistration --- docs/GitRepositoryRegistration.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/GitRepositoryRegistration.md b/docs/GitRepositoryRegistration.md index 88e74d3..83038b7 100644 --- a/docs/GitRepositoryRegistration.md +++ b/docs/GitRepositoryRegistration.md @@ -6,6 +6,34 @@ `New-GitRepoRegistration [-Name string]` +Creates a new registration for the current directory, optionally registering against the given name. If `-Name` is not given the folder for the root level of the git repo will be used. + +Name registrations are _not_ case-sensitive, so registrations can be made using different cases for the same location, and multiple registrations can exist for the git repository. + +```pwsh +# Default registration +PS D:/Repos/Project-a> New-GitRepoRegistration -debug +DEBUG: Checking if current directory is a git repository... +DEBUG: ...location is a git repo! +DEBUG: No name given for registration, defaulting to git folder root. +DEBUG: Registered 'D:/Repos/Project-a' to name 'Project-a' + +# Named registration +PS D:/Repos/Project-a> New-GitRepoRegistration "test repo" -debug +DEBUG: Checking if current directory is a git repository... +DEBUG: ...location is a git repo! +DEBUG: Registered 'D:/Repos/Project-a' to name 'test repo' + +PS D:/Repos/Project-a> Get-GitRepoRegistration + +Name Location CurrentBranch +---- -------- ------------- +test-repo D:/Repos/Project-a main +Project-a D:/Repos/Project-a main +``` + +If the current directory is not in a git repo, a registration already exists by name or default folder, or if any other issue occurs such as git not being available an error will be thrown. + ## Get-GitRepoRegistration `Get-GitRepoRegistration` From 52944936ed7678537087ab163b560d2f8c31318a Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 14:34:18 +1000 Subject: [PATCH 41/58] docs(git-provider): Document Get-GitRepoRegistration - update comments for related classes and methods --- docs/GitRepositoryRegistration.md | 13 +++++++++++++ src/ModuleCore/Git/GitManager.cs | 9 +++++++++ .../Git/Commands/GetGitRepoRegistrationCommand.cs | 3 +++ 3 files changed, 25 insertions(+) diff --git a/docs/GitRepositoryRegistration.md b/docs/GitRepositoryRegistration.md index 83038b7..e31d51f 100644 --- a/docs/GitRepositoryRegistration.md +++ b/docs/GitRepositoryRegistration.md @@ -38,6 +38,19 @@ If the current directory is not in a git repo, a registration already exists by `Get-GitRepoRegistration` +Returns all currently registered git repositories, as well as their current branch. + +```pwsh +PS D:/Repos/Project-a> Get-GitRepoRegistration + +Name Location CurrentBranch +---- -------- ------------- +test-repo D:/Repos/Project-a main +Project-a D:/Repos/Project-a main +``` + +The current branch is cached for 15 minutes for performance reasons so it may not be up to date if a git repo has recently had its branch changed. + ## Show-GitRepoRegistration `Show-GitRepoRegistration -Name string [-NoStack|-NoSetLocation]` diff --git a/src/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs index f301244..1312b03 100644 --- a/src/ModuleCore/Git/GitManager.cs +++ b/src/ModuleCore/Git/GitManager.cs @@ -167,6 +167,11 @@ public class GitManager }); } + /// + /// Returns all currently registered git repositories, including additional information such as the git repositories + /// current branch. + /// + /// public List ListRepos() { return _registrations.Select(x => @@ -301,6 +306,10 @@ public class GitManager public string Location { get; set; } = null!; + /// + /// The current branch of the repository. This value is cached for 15 minutes after which it becomes stale and + /// will be refreshed on the next call to this property. + /// public string CurrentBranch => GetCurrentBranch(); // TODO: not fully decided on if I want this feature or not, but keeping it in for now diff --git a/src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs b/src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs index c343c22..d1885ec 100644 --- a/src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs +++ b/src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs @@ -4,6 +4,9 @@ using ModuleCore.Git.Models; namespace PowershellModule.Git.Commands; +/// +/// Lists all currently registered git repositories, with additional information such as their current branch. +/// [Cmdlet(VerbsCommon.Get, GitCommands.GitRepoRegistrationNoun)] [OutputType(typeof(GitRegistration))] public class GetGitRepoRegistrationCommand : PSCmdlet From d53e5b2fe4bb75fe778a57056eb467dc682a01d8 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 17:17:47 +1000 Subject: [PATCH 42/58] feat(git-provider): Change alias of NoStack to NoPushLocation --- .../Git/Commands/ShowGitRepoRegistrationCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs b/src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs index 56a64ba..61b7561 100644 --- a/src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs +++ b/src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs @@ -16,7 +16,7 @@ public class ShowGitRepoRegistrationCommand : PSCmdlet [Parameter( Mandatory = false, HelpMessage = "Changes directory directly instead of using Set-Location")] - [Alias("NoSetLocation")] + [Alias("NoPushLocation")] public SwitchParameter NoStack { get; set; } protected override void BeginProcessing() From 0fbb0775c1e1d0a16ec99c977f2fb25c32d027ef Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 17:18:34 +1000 Subject: [PATCH 43/58] docs(git-provider): Document Show-GitRepoRegistration --- docs/GitRepositoryRegistration.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/GitRepositoryRegistration.md b/docs/GitRepositoryRegistration.md index e31d51f..ac50559 100644 --- a/docs/GitRepositoryRegistration.md +++ b/docs/GitRepositoryRegistration.md @@ -53,7 +53,11 @@ The current branch is cached for 15 minutes for performance reasons so it may no ## Show-GitRepoRegistration -`Show-GitRepoRegistration -Name string [-NoStack|-NoSetLocation]` +`Show-GitRepoRegistration -Name string [-NoStack|-NoPushLocation]` + +Changes your location to the location of the git repo registered by the given `-Name`. If used without `-NoStack` or its alias `-NoPushLocation`, your original location will be preserved and can be returned to at any time via `Pop-Location`/`Popd`. + +Using `-NoStack`/`-NoPushLocation` will not push your current location onto the stack, and will behave the same as `cd`/`Set-Location`. ## Remove-GitRepoRegistration From 6550da8c99fadf58ecc4ea90e08276206b7e85f8 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 26 Aug 2026 17:26:51 +1000 Subject: [PATCH 44/58] docs(git-provider): Update Show-GitRepoRegistration --- docs/GitRepositoryRegistration.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/GitRepositoryRegistration.md b/docs/GitRepositoryRegistration.md index ac50559..cbea932 100644 --- a/docs/GitRepositoryRegistration.md +++ b/docs/GitRepositoryRegistration.md @@ -1,6 +1,7 @@ ๏ปฟ# Git Repo Registration - all commands support `-debug` +- at its simpliest level these commands allow you to register a git repo against a simple name, and provide the ability to quickly pushd to a location ## New-GitRepoRegistration @@ -55,9 +56,29 @@ The current branch is cached for 15 minutes for performance reasons so it may no `Show-GitRepoRegistration -Name string [-NoStack|-NoPushLocation]` -Changes your location to the location of the git repo registered by the given `-Name`. If used without `-NoStack` or its alias `-NoPushLocation`, your original location will be preserved and can be returned to at any time via `Pop-Location`/`Popd`. +Changes your location to the location of the git repo registered by the given `-Name` and your original location will be preserved and can be returned to at any time via `Pop-Location`/`Popd`. By default, this command is identical to calling +`pushd [repository directory]`. -Using `-NoStack`/`-NoPushLocation` will not push your current location onto the stack, and will behave the same as `cd`/`Set-Location`. +Using `-NoStack`/`-NoPushLocation` will not push your current location onto the stack, and will behave the same as `cd [repository directory]`/`Set-Location [repository directory]`. + +```pwsh +PS C:/> Show-GitRepoRegistration Project-a +PS D:/Repos/Project-a> Get-Location -stack + +Path +---- +C:\ + +PS D:/Repos/Project-a> cd ./build +PS D:/Repos/Project-a/Build> Get-Location -stack + +Path +---- +C:\ + +PS D:/Repos/Project-a/Build> popd +PS C:/> +``` ## Remove-GitRepoRegistration From b91a868a541623a877690574bf636dfeb0747252 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 2 Sep 2026 11:18:26 +1000 Subject: [PATCH 45/58] feat(git-provider): Update Remove-GitRepoRegistration to work from any directory when -Name is given --- .../Git/Commands/RemoveGitRepoRegistrationCommand.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs b/src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs index fc30cb8..36b192e 100644 --- a/src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs +++ b/src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs @@ -19,11 +19,18 @@ public class RemoveGitRepoRegistrationCommand : PSCmdlet { GitManager.SetDebugWriter(WriteDebug); - var repoFolder = GitManager.IsGitRepo(SessionState.Path.CurrentLocation.Path); + // If we aren't given a value for the Name argument, default behaviour is to attempt to remove a registration + // by the current git repo folder name for the current location. + // If we have a name, don't bother testing for a git repo, just attempt to remove the registration by name + // regardless of where we're being called from + var registrationNameToRemove = string.IsNullOrEmpty(Name) + ? GitManager.IsGitRepo(SessionState.Path.CurrentLocation.Path).Folder + : Name; + + GitManager.Instance.UnregisterRepo(registrationNameToRemove); // Removing a registration works similar to registering a new one - we either remove by exact name, or by // the folder if no name is given (so a user can remove a registration from a git repo they're currently in) - GitManager.Instance.UnregisterRepo(Name ?? repoFolder.Folder); GitManager.ClearDebugWriter(); base.BeginProcessing(); From c8717df44a2be7f58a23b0142d62c600c8676349 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 2 Sep 2026 11:18:36 +1000 Subject: [PATCH 46/58] docs(git-provider): Document Remove-GitRepoRegistration --- docs/GitRepositoryRegistration.md | 39 ++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/GitRepositoryRegistration.md b/docs/GitRepositoryRegistration.md index cbea932..d679505 100644 --- a/docs/GitRepositoryRegistration.md +++ b/docs/GitRepositoryRegistration.md @@ -82,4 +82,41 @@ PS C:/> ## Remove-GitRepoRegistration -`Remove-GitRepoRegistration [-Name string]` \ No newline at end of file +`Remove-GitRepoRegistration [-Name string]` + +Removes a repo registration by the given name, or if no name is specified, attempts to remove the git repo registered by resolving the git repo. + +If `Name` is not provided then the command must be run in a location that is a git repo and the registration to remove will use the parent git repo folder name. If `Name` _is_ provided this command can be executed from any location. + +```pwsh +# Default registration without a name within a folder in a git repo +PS D:/Repos/Project-a> New-GitRepoRegistration + +# List registrations +PS D:/Repos/Project-a> Get-GitRepoRegistration + +Name Location CurrentBranch +---- -------- ------------- +Project-a D:/Repos/Project-a main +Test-Repo D:/Repos/Project-a main + +# Remove a registration from within a git repo +PS D:/Repos/Project-a> Remove-GitRepoRegistration +# No output indicates successful removal + +PS D:/Repos/Project-a> Get-GitRepoRegistration + +Name Location CurrentBranch +---- -------- ------------- +Test-Repo D:/Repos/Project-a main + +# Duplicate calls error if there is no registration +PS D:/Repos/Project-a> Remove-GitRepoRegistration +Remove-GitRepoRegistration: No registration exists for 'Project-a'. + +# Debug output via named argument +PS D:/Repos/Project-a> Remove-GitRepoRegistration -name Test-Repo -debug +DEBUG: Checking if current directory is a git repository... +DEBUG: ...location is a git repo! +DEBUG: Removed Test-Repo. +``` \ No newline at end of file From e2caa604c551408b12a20a8596bfd11d1bb0a493 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 2 Sep 2026 11:19:00 +1000 Subject: [PATCH 47/58] docs(git-provider): Small documentation updates to verb usage and default behaviour for New-GitRepoRegistration --- docs/GitRepositoryRegistration.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/GitRepositoryRegistration.md b/docs/GitRepositoryRegistration.md index d679505..2ce24f0 100644 --- a/docs/GitRepositoryRegistration.md +++ b/docs/GitRepositoryRegistration.md @@ -1,7 +1,11 @@ ๏ปฟ# Git Repo Registration - all commands support `-debug` -- at its simpliest level these commands allow you to register a git repo against a simple name, and provide the ability to quickly pushd to a location +- at its simplest level these commands allow you to register a git repo against a simple name, and provide the ability to quickly pushd to a location + +Most `*-GitRepoRegistration` commands are expected to be run within a folder that is contained within a git repo. The only exceptions to this are any verbs that list information or change locations such as `Get-`, `Show-`, `Push-`, `Pop-`. + +Any exceptions to behaviours are outlined within the relevant command section ## New-GitRepoRegistration @@ -11,15 +15,17 @@ Creates a new registration for the current directory, optionally registering aga Name registrations are _not_ case-sensitive, so registrations can be made using different cases for the same location, and multiple registrations can exist for the git repository. +If `New-GitRepoRegistration` is used in 2 repositories with the same name but different locations, the 2nd call will fail as a registration will already exist by name. + ```pwsh -# Default registration +# Default registration without a name within a folder in a git repo PS D:/Repos/Project-a> New-GitRepoRegistration -debug DEBUG: Checking if current directory is a git repository... DEBUG: ...location is a git repo! DEBUG: No name given for registration, defaulting to git folder root. DEBUG: Registered 'D:/Repos/Project-a' to name 'Project-a' -# Named registration +# Named registration PS D:/Repos/Project-a> New-GitRepoRegistration "test repo" -debug DEBUG: Checking if current directory is a git repository... DEBUG: ...location is a git repo! From 78974c732c000212e70d02ed2e177f68b568b3ee Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 2 Sep 2026 11:30:15 +1000 Subject: [PATCH 48/58] docs(git-provider): Update general section to outline case-sensitivity --- docs/GitRepositoryRegistration.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/GitRepositoryRegistration.md b/docs/GitRepositoryRegistration.md index 2ce24f0..8bff64e 100644 --- a/docs/GitRepositoryRegistration.md +++ b/docs/GitRepositoryRegistration.md @@ -5,7 +5,12 @@ Most `*-GitRepoRegistration` commands are expected to be run within a folder that is contained within a git repo. The only exceptions to this are any verbs that list information or change locations such as `Get-`, `Show-`, `Push-`, `Pop-`. -Any exceptions to behaviours are outlined within the relevant command section +Any exceptions to behaviours are outlined within the relevant command section. + +> [!info] Case-Sensitivity +> For all commands, the `Name` parameter is treated as _case-sensitive_, so multiple registrations can exist with the same name but different casing and point to different git repo locations or the same location - acting as an alias in a sense. +> +> While this isn't explicitly supported functionality, we don't do anything to prevent you having multiple registrations for the same git repo. Why should we after all? This set of commandlets are designed to make git repos more organised so how you use it is up to you. ## New-GitRepoRegistration From 5448fa3f9e75644b874a592f29f9642c9550d751 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 2 Sep 2026 14:10:44 +1000 Subject: [PATCH 49/58] docs(git-provider): Change info box to be normal-markdown instead of Obsidian-markdown --- docs/GitRepositoryRegistration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/GitRepositoryRegistration.md b/docs/GitRepositoryRegistration.md index 8bff64e..0f8a7c4 100644 --- a/docs/GitRepositoryRegistration.md +++ b/docs/GitRepositoryRegistration.md @@ -7,7 +7,7 @@ Most `*-GitRepoRegistration` commands are expected to be run within a folder tha Any exceptions to behaviours are outlined within the relevant command section. -> [!info] Case-Sensitivity +> ### Case-Sensitivity > For all commands, the `Name` parameter is treated as _case-sensitive_, so multiple registrations can exist with the same name but different casing and point to different git repo locations or the same location - acting as an alias in a sense. > > While this isn't explicitly supported functionality, we don't do anything to prevent you having multiple registrations for the same git repo. Why should we after all? This set of commandlets are designed to make git repos more organised so how you use it is up to you. From 270d545e561395fa884fef7655805ab51fab5015 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 3 Sep 2026 20:42:20 +1000 Subject: [PATCH 50/58] build(git-provider): Add GitRepoRegistration commands to build --- build/Scripts/CreateModuleManifest.ps1 | 9 +++++--- build/Tasks/CopyOutputTask.cs | 30 +++++++++++++++++++++----- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/build/Scripts/CreateModuleManifest.ps1 b/build/Scripts/CreateModuleManifest.ps1 index 7f5b26a..740983d 100644 --- a/build/Scripts/CreateModuleManifest.ps1 +++ b/build/Scripts/CreateModuleManifest.ps1 @@ -1,15 +1,18 @@ ๏ปฟparam ( - [string]$path, + [string]$powershellModuleFileLocation, [string]$guid, [string]$author, [string[]]$nestedModules, [string]$rootModule, [string[]]$cmdletsToExport, - [string]$manifestFileLocation + [string]$manifestFileLocation, + # not used for anything yet, but I should probably simplify the module locations as they get the output dir + # created in CopyOutputTask.cs + [string]$outputDir ) $manifestSplat = @{ - Path = "$path" + Path = "$powershellModuleFileLocation" GUID = "$guid" Author = "$author" NestedModules = @($nestedModules) diff --git a/build/Tasks/CopyOutputTask.cs b/build/Tasks/CopyOutputTask.cs index 175f82a..32caea6 100644 --- a/build/Tasks/CopyOutputTask.cs +++ b/build/Tasks/CopyOutputTask.cs @@ -1,4 +1,5 @@ ๏ปฟusing System; +using System.Collections.Generic; using System.Linq; using Cake.Core.Diagnostics; using Cake.Core.IO; @@ -13,17 +14,20 @@ public class CopyOutputTask : FrostingTask public override void Run(BuildContext context) { var powershellModuleName = "PowershellModule"; + // TODO: probably don't create full file locations when I can pass the output dir in and have the script + // make the path var scriptParams = new { - Path = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psd1", + PowershellModuleFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psd1", Guid = Guid.Parse("5cdf4635-edb0-428c-8d9b-92d0bcd47443"), Author = "Me", NestedModules = new[] { $"{powershellModuleName}.dll" }, RootModule = $"{powershellModuleName}.psm1", - CmdletsToExport = new[] { "Get-Calendar" }, - ManifestFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psm1" + CmdletsToExport = GetExportedCmdlets(), + ManifestFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psm1", + OutputLocation = context.PowershellModuleOutputDir, }; - + var psSettings = new PowershellSettings() { Arguments = new ProcessArgumentBuilder(), @@ -31,13 +35,14 @@ public class CopyOutputTask : FrostingTask // This feels a bit ugly/redundant seeing as I've defined the scriptParams above, but I'm leaving it as is // until I want to come back and clean this up properly - psSettings.Arguments.Append("path", ToPowershellSafeString(scriptParams.Path)); + psSettings.Arguments.Append("powershellModuleFileLocation", ToPowershellSafeString(scriptParams.PowershellModuleFileLocation)); psSettings.Arguments.Append("guid", ToPowershellSafeString(scriptParams.Guid.ToString())); psSettings.Arguments.Append("author", ToPowershellSafeString(scriptParams.Author)); psSettings.Arguments.Append("nestedModules", $"@({string.Join(",", scriptParams.NestedModules.Select(ToPowershellSafeString))})"); psSettings.Arguments.Append("rootModule", ToPowershellSafeString(scriptParams.RootModule)); psSettings.Arguments.Append("cmdletsToExport", $"@({string.Join(",", scriptParams.CmdletsToExport.Select(ToPowershellSafeString))})"); psSettings.Arguments.Append("manifestFileLocation", ToPowershellSafeString(scriptParams.ManifestFileLocation)); + psSettings.Arguments.Append("outputDir", ToPowershellSafeString(scriptParams.OutputLocation)); context.StartPowershellFile(context.CreateModuleManifestScript, psSettings); context.Log.Information($"Module output to: {context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment)}"); @@ -46,4 +51,19 @@ public class CopyOutputTask : FrostingTask } private static string ToPowershellSafeString(string unescapedString) => $"'{unescapedString}'"; + + private static List GetExportedCmdlets() + { + return ["Get-Calendar", .. GetGitRepoRegistrationVerbs()]; + } + + private static List GetGitRepoRegistrationVerbs() + { + // TODO: I should make some reference file for these verbs and name but that'd pull in powershell dependencies + // to the build and I'd like to avoid that. + // This isn't great but commands are unlikely to change that frequently + string[] verbs = ["Get", "New", "Remove", "Show"]; + var commandName = "GitRepoRegistration"; + return [.. verbs.Select(x => $"{x}-{commandName}")]; + } } \ No newline at end of file From 9665ed1ae3772c5c747dcb008c49b4df28557fa7 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 3 Sep 2026 21:47:28 +1000 Subject: [PATCH 51/58] build(git-provider): Bundle up minimal required module files --- build/Tasks/CopyOutputTask.cs | 57 +++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/build/Tasks/CopyOutputTask.cs b/build/Tasks/CopyOutputTask.cs index 32caea6..9a5bdde 100644 --- a/build/Tasks/CopyOutputTask.cs +++ b/build/Tasks/CopyOutputTask.cs @@ -1,10 +1,12 @@ ๏ปฟusing System; using System.Collections.Generic; +using System.IO; using System.Linq; using Cake.Core.Diagnostics; using Cake.Core.IO; using Cake.Frosting; using Cake.Powershell; +using Path = System.IO.Path; namespace Build.Tasks; @@ -47,6 +49,8 @@ public class CopyOutputTask : FrostingTask context.StartPowershellFile(context.CreateModuleManifestScript, psSettings); context.Log.Information($"Module output to: {context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment)}"); + BundleMinimalFiles(context); + base.Run(context); } @@ -66,4 +70,57 @@ public class CopyOutputTask : FrostingTask var commandName = "GitRepoRegistration"; return [.. verbs.Select(x => $"{x}-{commandName}")]; } + + private static void BundleMinimalFiles(BuildContext context) + { + // We're effectively replicating PostBuild.ps1 with all of this as Remove-Item at the end of it does not remove files the same way. + // TODO: See if that's something I can fix + var powershellModuleBuildLocation = context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment).FullPath; + // PowershellModule is here so that the script that adds to the users $Env:PSModulePath correctly resolves when using + // Import-Module "PowershellModule" + // TODO: Generate the script to add to the PSModulePath in the bundleOutputLocation directory + var bundleOutputLocation = Directory.CreateDirectory(Path.Combine(powershellModuleBuildLocation, "bundle")).FullName; + var moduleOutputLocation = Directory.CreateDirectory(Path.Combine(bundleOutputLocation, "PowershellModule")).FullName; + + context.Log.Information($"Bundle location: {bundleOutputLocation}"); + context.Log.Information($"Built file location: {powershellModuleBuildLocation}"); + + // PostBuild.ps1 for PowershellModule should have already been run at this point - it won't remove files, but it will copy the + // correct e_sqlite3.dll that we need. + // TODO: Investigate this later as I'm not entirely happy with this now + // // Get the location for the specific version of e_sqlite3.dll we need - PowerShell binary modules resolve dependent dlls + // // from the executing assembly directory first, and the built output of the module includes a lot of other dlls that + // // are already available with the dotnet runtime so there's no need for us to include them. + // // I mean, we could, but we'd also be terrible software engineers if we couldn't do something as basic as reducing files we need. + // // TODO: Account for other runtimes such as linux based ones where the dotnet runtime might _not_ provide these files for free. + // // It'd require looking into how the module loads for PowerShell on those operating systems, and I might never + // // get around to doing that because I use windows (currently). + // // TODO: Update this later to handle other runtimes, for now we hardcode to win-x64 because it's what I use + // var nativeSqliteDllLocation = System.IO.Path.Combine(powershellModuleBuildLocation, "runtimes", "win-x64", "native", "e_sqlite3.dll"); + // + // if (!File.Exists(nativeSqliteDllLocation)) + // { + // context.Log.Error($"BUNDLE FAILED: Unable to locate ${nativeSqliteDllLocation}"); + // return; + // } + // + // context.Log.Information($"Copying '${nativeSqliteDllLocation}' to '${bundleOutputLocation}'"); + // + // File.Copy(nativeSqliteDllLocation, System.IO.Path.Combine(bundleOutputLocation, System.IO.Path.GetFileName(nativeSqliteDllLocation))); + + var moduleCoreFiles = Directory.GetFiles(powershellModuleBuildLocation, "ModuleCore*", searchOption: SearchOption.TopDirectoryOnly); + var powershellModuleFiles = Directory.GetFiles(powershellModuleBuildLocation, "PowershellModule*", searchOption: SearchOption.TopDirectoryOnly); + var sqliteFiles = Directory.GetFiles(powershellModuleBuildLocation, "*SQLite*", searchOption: SearchOption.TopDirectoryOnly); + + string[] allFiles = [.. moduleCoreFiles, .. powershellModuleFiles, .. sqliteFiles]; + + context.Log.Information($"Copying {allFiles.Length} files to the module folder {moduleOutputLocation}"); + foreach (var file in allFiles) + { + context.Log.Information($"Copying {Path.GetFileName(file)}"); + File.Copy(file, Path.Combine(moduleOutputLocation, Path.GetFileName(file))); + } + + context.Log.Information($"Bundle files copied"); + } } \ No newline at end of file From 5cfc4819febf2a4569942e3557290d75a09ff8a2 Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 3 Sep 2026 22:08:48 +1000 Subject: [PATCH 52/58] build(git-provider): Generate initial import script --- build/Tasks/CopyOutputTask.cs | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/build/Tasks/CopyOutputTask.cs b/build/Tasks/CopyOutputTask.cs index 9a5bdde..2d531b0 100644 --- a/build/Tasks/CopyOutputTask.cs +++ b/build/Tasks/CopyOutputTask.cs @@ -122,5 +122,51 @@ public class CopyOutputTask : FrostingTask } context.Log.Information($"Bundle files copied"); + + GenerateModuleImportScript(bundleOutputLocation, context); + // TODO: zip the bundle directory contents to NulahModule.zip, and have a top level folder inside that called NulahModule. + // The intent is that users will drop this zip next to their $profile, and then extract the contents directly so everything + // is contained as it should be + } + + private static void GenerateModuleImportScript(string bundleLocation, BuildContext context) + { + context.Log.Information($"Generating module import script"); + + using var scriptFile = File.Create(Path.Combine(bundleLocation, "NulahPowershell.ps1")); + using var fileWriter = new StreamWriter(scriptFile); + // TODO: Document the set up function + // TODO: Create a ticket for fleshing out the set up function (or just do that work later) + fileWriter.Write( + """ + # To use, first copy all files to the same location as your $profile under ./NulahModule, then open your powershell profile located at $profile and add the following: + + # Script setup style: Using the setup script to import as needed (this method will also set custom prompts and other alias functions) + # . "$PSScriptRoot/NulahModule/NulahPowershell.ps1" + # SetupNulahPowershell + # Just the module: Use the following to just import just the powershell module + # Import-Module -Name "$PSScriptRoot/NulahModule/PowershellModule" + + function SetupNulahPowershell + { + # only add to our module path once + # The intent for this is that a user will have copied this bundle folder alongside their $profile + # location, eg, they'll have a folder called NulahModule that will contain everything within the bundle zip + $testBundleDirectory = $PSScriptRoot + $bundleInPath = ($Env:PSModulePath -split ';').TrimEnd('\') -contains $testBundleDirectory; + + if ($false -eq $bundleInPath) + { + $env:PSModulePath = @( + $env:PSModulePath + $testBundleDirectory + ) -Join [System.IO.Path]::PathSeparator + } + + Import-Module "PowershellModule" + } + """ + ); + context.Log.Information($"Module import script created"); } } \ No newline at end of file From a399b29df3abe591b2b12a2964600e7c6bee922b Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 4 Sep 2026 09:29:15 +1000 Subject: [PATCH 53/58] build(git-provider): Create bundle zip for module --- build/Tasks/CopyOutputTask.cs | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/build/Tasks/CopyOutputTask.cs b/build/Tasks/CopyOutputTask.cs index 2d531b0..8bccce5 100644 --- a/build/Tasks/CopyOutputTask.cs +++ b/build/Tasks/CopyOutputTask.cs @@ -1,6 +1,7 @@ ๏ปฟusing System; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; using Cake.Core.Diagnostics; using Cake.Core.IO; @@ -79,10 +80,15 @@ public class CopyOutputTask : FrostingTask // PowershellModule is here so that the script that adds to the users $Env:PSModulePath correctly resolves when using // Import-Module "PowershellModule" // TODO: Generate the script to add to the PSModulePath in the bundleOutputLocation directory - var bundleOutputLocation = Directory.CreateDirectory(Path.Combine(powershellModuleBuildLocation, "bundle")).FullName; - var moduleOutputLocation = Directory.CreateDirectory(Path.Combine(bundleOutputLocation, "PowershellModule")).FullName; - context.Log.Information($"Bundle location: {bundleOutputLocation}"); + // Top level bundle output location + var bundleRootLocation = Directory.CreateDirectory(Path.Combine(powershellModuleBuildLocation, "bundle")).FullName; + // Module folder location that will be zipped up and will contain any setup scripts/readme etc + var moduleFolderLocation = Directory.CreateDirectory(Path.Combine(bundleRootLocation, "NulahModule")).FullName; + // Location to put all the module dlls and other files + var powershelModuleOutputLocation = Directory.CreateDirectory(Path.Combine(moduleFolderLocation, "PowershellModule")).FullName; + + context.Log.Information($"Bundle location: {bundleRootLocation}"); context.Log.Information($"Built file location: {powershellModuleBuildLocation}"); // PostBuild.ps1 for PowershellModule should have already been run at this point - it won't remove files, but it will copy the @@ -114,19 +120,17 @@ public class CopyOutputTask : FrostingTask string[] allFiles = [.. moduleCoreFiles, .. powershellModuleFiles, .. sqliteFiles]; - context.Log.Information($"Copying {allFiles.Length} files to the module folder {moduleOutputLocation}"); + context.Log.Information($"Copying {allFiles.Length} files to the module folder {powershelModuleOutputLocation}"); foreach (var file in allFiles) { context.Log.Information($"Copying {Path.GetFileName(file)}"); - File.Copy(file, Path.Combine(moduleOutputLocation, Path.GetFileName(file))); + File.Copy(file, Path.Combine(powershelModuleOutputLocation, Path.GetFileName(file))); } context.Log.Information($"Bundle files copied"); - GenerateModuleImportScript(bundleOutputLocation, context); - // TODO: zip the bundle directory contents to NulahModule.zip, and have a top level folder inside that called NulahModule. - // The intent is that users will drop this zip next to their $profile, and then extract the contents directly so everything - // is contained as it should be + GenerateModuleImportScript(moduleFolderLocation, context); + CreateBundleZip(bundleRootLocation, moduleFolderLocation, context); } private static void GenerateModuleImportScript(string bundleLocation, BuildContext context) @@ -140,13 +144,13 @@ public class CopyOutputTask : FrostingTask fileWriter.Write( """ # To use, first copy all files to the same location as your $profile under ./NulahModule, then open your powershell profile located at $profile and add the following: - + # Script setup style: Using the setup script to import as needed (this method will also set custom prompts and other alias functions) # . "$PSScriptRoot/NulahModule/NulahPowershell.ps1" # SetupNulahPowershell # Just the module: Use the following to just import just the powershell module # Import-Module -Name "$PSScriptRoot/NulahModule/PowershellModule" - + function SetupNulahPowershell { # only add to our module path once @@ -169,4 +173,10 @@ public class CopyOutputTask : FrostingTask ); context.Log.Information($"Module import script created"); } + + private static void CreateBundleZip(string bundleLocation, string moduleFolderLocation, BuildContext context) + { + var bundleZipFileLocaiton = Path.Combine(bundleLocation, "NulahModule.zip"); + ZipFile.CreateFromDirectory(moduleFolderLocation, bundleZipFileLocaiton, CompressionLevel.Fastest, true); + } } \ No newline at end of file From 479c433765e8b7a0f74871a1617e63eece4ca0aa Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 4 Sep 2026 09:49:01 +1000 Subject: [PATCH 54/58] build(git-provider): Split bundle archive creation into separate task, update default task chain --- build/Tasks/CopyOutputTask.cs | 116 +--------------------- build/Tasks/CreateBundleArchiveTask.cs | 130 +++++++++++++++++++++++++ build/Tasks/DefaultTask.cs | 2 + 3 files changed, 134 insertions(+), 114 deletions(-) create mode 100644 build/Tasks/CreateBundleArchiveTask.cs diff --git a/build/Tasks/CopyOutputTask.cs b/build/Tasks/CopyOutputTask.cs index 8bccce5..f51763a 100644 --- a/build/Tasks/CopyOutputTask.cs +++ b/build/Tasks/CopyOutputTask.cs @@ -1,17 +1,15 @@ ๏ปฟusing System; using System.Collections.Generic; -using System.IO; -using System.IO.Compression; using System.Linq; using Cake.Core.Diagnostics; using Cake.Core.IO; using Cake.Frosting; using Cake.Powershell; -using Path = System.IO.Path; namespace Build.Tasks; [TaskName("CopyOutput")] +[IsDependeeOf(typeof(CreateBundleArchiveTask))] public class CopyOutputTask : FrostingTask { public override void Run(BuildContext context) @@ -48,9 +46,7 @@ public class CopyOutputTask : FrostingTask psSettings.Arguments.Append("outputDir", ToPowershellSafeString(scriptParams.OutputLocation)); context.StartPowershellFile(context.CreateModuleManifestScript, psSettings); - context.Log.Information($"Module output to: {context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment)}"); - - BundleMinimalFiles(context); + context.Log.Information($"Module files output to: {context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment)}"); base.Run(context); } @@ -71,112 +67,4 @@ public class CopyOutputTask : FrostingTask var commandName = "GitRepoRegistration"; return [.. verbs.Select(x => $"{x}-{commandName}")]; } - - private static void BundleMinimalFiles(BuildContext context) - { - // We're effectively replicating PostBuild.ps1 with all of this as Remove-Item at the end of it does not remove files the same way. - // TODO: See if that's something I can fix - var powershellModuleBuildLocation = context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment).FullPath; - // PowershellModule is here so that the script that adds to the users $Env:PSModulePath correctly resolves when using - // Import-Module "PowershellModule" - // TODO: Generate the script to add to the PSModulePath in the bundleOutputLocation directory - - // Top level bundle output location - var bundleRootLocation = Directory.CreateDirectory(Path.Combine(powershellModuleBuildLocation, "bundle")).FullName; - // Module folder location that will be zipped up and will contain any setup scripts/readme etc - var moduleFolderLocation = Directory.CreateDirectory(Path.Combine(bundleRootLocation, "NulahModule")).FullName; - // Location to put all the module dlls and other files - var powershelModuleOutputLocation = Directory.CreateDirectory(Path.Combine(moduleFolderLocation, "PowershellModule")).FullName; - - context.Log.Information($"Bundle location: {bundleRootLocation}"); - context.Log.Information($"Built file location: {powershellModuleBuildLocation}"); - - // PostBuild.ps1 for PowershellModule should have already been run at this point - it won't remove files, but it will copy the - // correct e_sqlite3.dll that we need. - // TODO: Investigate this later as I'm not entirely happy with this now - // // Get the location for the specific version of e_sqlite3.dll we need - PowerShell binary modules resolve dependent dlls - // // from the executing assembly directory first, and the built output of the module includes a lot of other dlls that - // // are already available with the dotnet runtime so there's no need for us to include them. - // // I mean, we could, but we'd also be terrible software engineers if we couldn't do something as basic as reducing files we need. - // // TODO: Account for other runtimes such as linux based ones where the dotnet runtime might _not_ provide these files for free. - // // It'd require looking into how the module loads for PowerShell on those operating systems, and I might never - // // get around to doing that because I use windows (currently). - // // TODO: Update this later to handle other runtimes, for now we hardcode to win-x64 because it's what I use - // var nativeSqliteDllLocation = System.IO.Path.Combine(powershellModuleBuildLocation, "runtimes", "win-x64", "native", "e_sqlite3.dll"); - // - // if (!File.Exists(nativeSqliteDllLocation)) - // { - // context.Log.Error($"BUNDLE FAILED: Unable to locate ${nativeSqliteDllLocation}"); - // return; - // } - // - // context.Log.Information($"Copying '${nativeSqliteDllLocation}' to '${bundleOutputLocation}'"); - // - // File.Copy(nativeSqliteDllLocation, System.IO.Path.Combine(bundleOutputLocation, System.IO.Path.GetFileName(nativeSqliteDllLocation))); - - var moduleCoreFiles = Directory.GetFiles(powershellModuleBuildLocation, "ModuleCore*", searchOption: SearchOption.TopDirectoryOnly); - var powershellModuleFiles = Directory.GetFiles(powershellModuleBuildLocation, "PowershellModule*", searchOption: SearchOption.TopDirectoryOnly); - var sqliteFiles = Directory.GetFiles(powershellModuleBuildLocation, "*SQLite*", searchOption: SearchOption.TopDirectoryOnly); - - string[] allFiles = [.. moduleCoreFiles, .. powershellModuleFiles, .. sqliteFiles]; - - context.Log.Information($"Copying {allFiles.Length} files to the module folder {powershelModuleOutputLocation}"); - foreach (var file in allFiles) - { - context.Log.Information($"Copying {Path.GetFileName(file)}"); - File.Copy(file, Path.Combine(powershelModuleOutputLocation, Path.GetFileName(file))); - } - - context.Log.Information($"Bundle files copied"); - - GenerateModuleImportScript(moduleFolderLocation, context); - CreateBundleZip(bundleRootLocation, moduleFolderLocation, context); - } - - private static void GenerateModuleImportScript(string bundleLocation, BuildContext context) - { - context.Log.Information($"Generating module import script"); - - using var scriptFile = File.Create(Path.Combine(bundleLocation, "NulahPowershell.ps1")); - using var fileWriter = new StreamWriter(scriptFile); - // TODO: Document the set up function - // TODO: Create a ticket for fleshing out the set up function (or just do that work later) - fileWriter.Write( - """ - # To use, first copy all files to the same location as your $profile under ./NulahModule, then open your powershell profile located at $profile and add the following: - - # Script setup style: Using the setup script to import as needed (this method will also set custom prompts and other alias functions) - # . "$PSScriptRoot/NulahModule/NulahPowershell.ps1" - # SetupNulahPowershell - # Just the module: Use the following to just import just the powershell module - # Import-Module -Name "$PSScriptRoot/NulahModule/PowershellModule" - - function SetupNulahPowershell - { - # only add to our module path once - # The intent for this is that a user will have copied this bundle folder alongside their $profile - # location, eg, they'll have a folder called NulahModule that will contain everything within the bundle zip - $testBundleDirectory = $PSScriptRoot - $bundleInPath = ($Env:PSModulePath -split ';').TrimEnd('\') -contains $testBundleDirectory; - - if ($false -eq $bundleInPath) - { - $env:PSModulePath = @( - $env:PSModulePath - $testBundleDirectory - ) -Join [System.IO.Path]::PathSeparator - } - - Import-Module "PowershellModule" - } - """ - ); - context.Log.Information($"Module import script created"); - } - - private static void CreateBundleZip(string bundleLocation, string moduleFolderLocation, BuildContext context) - { - var bundleZipFileLocaiton = Path.Combine(bundleLocation, "NulahModule.zip"); - ZipFile.CreateFromDirectory(moduleFolderLocation, bundleZipFileLocaiton, CompressionLevel.Fastest, true); - } } \ No newline at end of file diff --git a/build/Tasks/CreateBundleArchiveTask.cs b/build/Tasks/CreateBundleArchiveTask.cs new file mode 100644 index 0000000..54413b1 --- /dev/null +++ b/build/Tasks/CreateBundleArchiveTask.cs @@ -0,0 +1,130 @@ +๏ปฟusing System.IO; +using System.IO.Compression; +using Cake.Core.Diagnostics; +using Cake.Frosting; + +namespace Build.Tasks; + +[TaskName("CreateBundleArchive")] +[IsDependentOn(typeof(CopyOutputTask))] +public class CreateBundleArchiveTask : FrostingTask +{ + public override void Run(BuildContext context) + { + context.Log.Information($"Creating bundle archive"); + BundleMinimalFiles(context); + base.Run(context); + } + + private static void BundleMinimalFiles(BuildContext context) + { + // We're effectively replicating PostBuild.ps1 with all of this as Remove-Item at the end of it does not remove files the same way. + // TODO: See if that's something I can fix + var powershellModuleBuildLocation = context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment).FullPath; + // PowershellModule is here so that the script that adds to the users $Env:PSModulePath correctly resolves when using + // Import-Module "PowershellModule" + // TODO: Generate the script to add to the PSModulePath in the bundleOutputLocation directory + + // Top level bundle output location + var bundleRootLocation = Directory.CreateDirectory(Path.Combine(powershellModuleBuildLocation, "bundle")).FullName; + // Module folder location that will be zipped up and will contain any setup scripts/readme etc + var moduleFolderLocation = Directory.CreateDirectory(Path.Combine(bundleRootLocation, "NulahModule")).FullName; + // Location to put all the module dlls and other files + var powershelModuleOutputLocation = Directory.CreateDirectory(Path.Combine(moduleFolderLocation, "PowershellModule")).FullName; + + context.Log.Information($"Bundle location: {bundleRootLocation}"); + context.Log.Information($"Built file location: {powershellModuleBuildLocation}"); + + // PostBuild.ps1 for PowershellModule should have already been run at this point - it won't remove files, but it will copy the + // correct e_sqlite3.dll that we need. + // This code is all commented out because I'm not entirely satisfied with PostBuild.ps1 doing some things but + // this build doing slightly different. + // // Get the location for the specific version of e_sqlite3.dll we need - PowerShell binary modules resolve dependent dlls + // // from the executing assembly directory first, and the built output of the module includes a lot of other dlls that + // // are already available with the dotnet runtime so there's no need for us to include them. + // // I mean, we could, but we'd also be terrible software engineers if we couldn't do something as basic as reducing files we need. + // // TODO: Account for other runtimes such as linux based ones where the dotnet runtime might _not_ provide these files for free. + // // It'd require looking into how the module loads for PowerShell on those operating systems, and I might never + // // get around to doing that because I use windows (currently). + // // TODO: Update this later to handle other runtimes, for now we hardcode to win-x64 because it's what I use + // var nativeSqliteDllLocation = System.IO.Path.Combine(powershellModuleBuildLocation, "runtimes", "win-x64", "native", "e_sqlite3.dll"); + // + // if (!File.Exists(nativeSqliteDllLocation)) + // { + // context.Log.Error($"BUNDLE FAILED: Unable to locate ${nativeSqliteDllLocation}"); + // return; + // } + // + // context.Log.Information($"Copying '${nativeSqliteDllLocation}' to '${bundleOutputLocation}'"); + // + // File.Copy(nativeSqliteDllLocation, System.IO.Path.Combine(bundleOutputLocation, System.IO.Path.GetFileName(nativeSqliteDllLocation))); + + var moduleCoreFiles = Directory.GetFiles(powershellModuleBuildLocation, "ModuleCore*", searchOption: SearchOption.TopDirectoryOnly); + var powershellModuleFiles = Directory.GetFiles(powershellModuleBuildLocation, "PowershellModule*", searchOption: SearchOption.TopDirectoryOnly); + var sqliteFiles = Directory.GetFiles(powershellModuleBuildLocation, "*SQLite*", searchOption: SearchOption.TopDirectoryOnly); + + string[] allFiles = [.. moduleCoreFiles, .. powershellModuleFiles, .. sqliteFiles]; + + context.Log.Information($"Copying {allFiles.Length} files to the module folder {powershelModuleOutputLocation}"); + foreach (var file in allFiles) + { + context.Log.Information($"Copying {Path.GetFileName(file)}"); + File.Copy(file, Path.Combine(powershelModuleOutputLocation, Path.GetFileName(file))); + } + + context.Log.Information($"Bundle files copied"); + + GenerateModuleImportScript(moduleFolderLocation, context); + CreateBundleZip(bundleRootLocation, moduleFolderLocation, context); + } + + private static void GenerateModuleImportScript(string bundleLocation, BuildContext context) + { + context.Log.Information($"Generating module import script"); + var scriptFileName = Path.Combine(bundleLocation, "NulahPowershell.ps1"); + + using var scriptFile = File.Create(scriptFileName); + using var fileWriter = new StreamWriter(scriptFile); + // TODO: Document the set up function + // TODO: Create a ticket for fleshing out the set up function (or just do that work later) + fileWriter.Write( + """ + # To use, first copy all files to the same location as your $profile under ./NulahModule, then open your powershell profile located at $profile and add the following: + + # Script setup style: Using the setup script to import as needed (this method will also set custom prompts and other alias functions) + # . "$PSScriptRoot/NulahModule/NulahPowershell.ps1" + # SetupNulahPowershell + # Just the module: Use the following to just import just the powershell module + # Import-Module -Name "$PSScriptRoot/NulahModule/PowershellModule" + + function SetupNulahPowershell + { + # only add to our module path once + # The intent for this is that a user will have copied this bundle folder alongside their $profile + # location, eg, they'll have a folder called NulahModule that will contain everything within the bundle zip + $testBundleDirectory = $PSScriptRoot + $bundleInPath = ($Env:PSModulePath -split ';').TrimEnd('\') -contains $testBundleDirectory; + + if ($false -eq $bundleInPath) + { + $env:PSModulePath = @( + $env:PSModulePath + $testBundleDirectory + ) -Join [System.IO.Path]::PathSeparator + } + + Import-Module "PowershellModule" + } + """ + ); + context.Log.Information($"Module import script created at '{scriptFileName}'"); + } + + private static void CreateBundleZip(string bundleLocation, string moduleFolderLocation, BuildContext context) + { + var bundleZipFileLocaiton = Path.Combine(bundleLocation, "NulahModule.zip"); + context.Log.Information($"Bundling module into archive '{bundleZipFileLocaiton}'"); + ZipFile.CreateFromDirectory(moduleFolderLocation, bundleZipFileLocaiton, CompressionLevel.Fastest, true); + context.Log.Information($"Archive created at '{bundleZipFileLocaiton}'"); + } +} \ No newline at end of file diff --git a/build/Tasks/DefaultTask.cs b/build/Tasks/DefaultTask.cs index 200768c..7f4bf67 100644 --- a/build/Tasks/DefaultTask.cs +++ b/build/Tasks/DefaultTask.cs @@ -6,7 +6,9 @@ namespace Build.Tasks; // Consider this the "entry" point for builds, task order is defined by a chain of IsDependentOn [TaskName("Default")] +[IsDependentOn(typeof(BuildTask))] [IsDependentOn(typeof(CopyOutputTask))] +[IsDependentOn(typeof(CreateBundleArchiveTask))] public class DefaultTask : FrostingTask { public override void Run(ICakeContext context) From 1491a26a3e626c16714598703335de0c89710ec8 Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 4 Sep 2026 10:33:11 +1000 Subject: [PATCH 55/58] chore(build-props): Bump version number to 0.0.1 --- src/ModuleCore/Directory.Build.props | 2 +- src/PowershellModule/Directory.Build.props | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ModuleCore/Directory.Build.props b/src/ModuleCore/Directory.Build.props index afa6e9c..e0bdda9 100644 --- a/src/ModuleCore/Directory.Build.props +++ b/src/ModuleCore/Directory.Build.props @@ -1,6 +1,6 @@ ๏ปฟ - 0.0.0 + 0.0.1 dev \ No newline at end of file diff --git a/src/PowershellModule/Directory.Build.props b/src/PowershellModule/Directory.Build.props index afa6e9c..e0bdda9 100644 --- a/src/PowershellModule/Directory.Build.props +++ b/src/PowershellModule/Directory.Build.props @@ -1,6 +1,6 @@ ๏ปฟ - 0.0.0 + 0.0.1 dev \ No newline at end of file From fa0531f2c5ee981f227a41efd3525a423925c5ef Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 4 Sep 2026 10:46:34 +1000 Subject: [PATCH 56/58] refactor(build): move BuildContext to file, add BuildSuffix and CommitHash configuration options --- build/BuildContext.cs | 50 ++++++++++++++++++++++++++++++++++++++++ build/Program.cs | 48 ++++---------------------------------- build/Tasks/BuildTask.cs | 23 ++++++++++++++---- 3 files changed, 72 insertions(+), 49 deletions(-) create mode 100644 build/BuildContext.cs diff --git a/build/BuildContext.cs b/build/BuildContext.cs new file mode 100644 index 0000000..29634e9 --- /dev/null +++ b/build/BuildContext.cs @@ -0,0 +1,50 @@ +๏ปฟusing Cake.Common.IO; +using Cake.Common.IO.Paths; +using Cake.Core; +using Cake.Frosting; + +namespace Build; + +public class BuildContext : FrostingContext +{ + /// + /// Base source directory + /// + public ConvertableDirectoryPath BaseSourceLocation { get; set; } + + /// + /// Powershell module project folder + /// + public ConvertableDirectoryPath PowershellModuleProjectDirectory { get; set; } + + /// + /// Powershell module csproj location + /// + public ConvertableFilePath PowershellModuleCsproj { get; set; } + + /// + /// Output directory for built DLLs and powershell module manifest files + /// + public ConvertableDirectoryPath PowershellModuleOutputDir { get; set; } + + /// + /// Path to powershell script that creates the module manifest files + /// + public ConvertableFilePath CreateModuleManifestScript { get; set; } + + public string BuildSuffix { get; set; } + public bool DisableCommitHash { get; set; } + + public BuildContext(ICakeContext context) + : base(context) + { + BaseSourceLocation = context.Directory("../src"); + PowershellModuleProjectDirectory = BaseSourceLocation + context.Directory("PowershellModule"); + PowershellModuleCsproj = PowershellModuleProjectDirectory + context.File("PowershellModule.csproj"); + PowershellModuleOutputDir = context.Directory("../") + context.Directory("output") + context.Directory("PowershellModule"); + var buildScriptDirectory = context.Directory("./Scripts"); + CreateModuleManifestScript = buildScriptDirectory + context.File("CreateModuleManifest.ps1"); + BuildSuffix = context.Configuration.GetValue(nameof(BuildSuffix)); + DisableCommitHash = context.Configuration.GetBoolValue(nameof(DisableCommitHash)); + } +} \ No newline at end of file diff --git a/build/Program.cs b/build/Program.cs index ffb3738..1c348c5 100644 --- a/build/Program.cs +++ b/build/Program.cs @@ -1,9 +1,6 @@ -using Cake.Common; -using Cake.Common.IO; -using Cake.Common.IO.Paths; -using Cake.Core; using Cake.Frosting; -using Cake.Powershell; + +namespace Build; public static class Program { @@ -11,45 +8,8 @@ public static class Program { return new CakeHost() .UseContext() + // Uncomment this if you don't want to set the suffix via the run profile program arguments + //.UseCakeSetting(nameof(BuildContext.BuildSuffix), "") .Run(args); } -} - -public class BuildContext : FrostingContext -{ - /// - /// Base source directory - /// - public ConvertableDirectoryPath BaseSourceLocation { get; set; } - - /// - /// Powershell module project folder - /// - public ConvertableDirectoryPath PowershellModuleProjectDirectory { get; set; } - - /// - /// Powershell module csproj location - /// - public ConvertableFilePath PowershellModuleCsproj { get; set; } - - /// - /// Output directory for built DLLs and powershell module manifest files - /// - public ConvertableDirectoryPath PowershellModuleOutputDir { get; set; } - - /// - /// Path to powershell script that creates the module manifest files - /// - public ConvertableFilePath CreateModuleManifestScript { get; set; } - - public BuildContext(ICakeContext context) - : base(context) - { - BaseSourceLocation = context.Directory("../src"); - PowershellModuleProjectDirectory = BaseSourceLocation + context.Directory("PowershellModule"); - PowershellModuleCsproj = PowershellModuleProjectDirectory + context.File("PowershellModule.csproj"); - PowershellModuleOutputDir = context.Directory("../") + context.Directory("output") + context.Directory("PowershellModule"); - var buildScriptDirectory = context.Directory("./Scripts"); - CreateModuleManifestScript = buildScriptDirectory + context.File("CreateModuleManifest.ps1"); - } } \ No newline at end of file diff --git a/build/Tasks/BuildTask.cs b/build/Tasks/BuildTask.cs index c731bcb..d11cc79 100644 --- a/build/Tasks/BuildTask.cs +++ b/build/Tasks/BuildTask.cs @@ -17,14 +17,27 @@ public class BuildTask : FrostingTask var buildSettings = new DotNetBuildSettings() { - MSBuildSettings = new DotNetMSBuildSettings() - { - VersionSuffix = "cake" - }, + MSBuildSettings = new DotNetMSBuildSettings(), OutputDirectory = context.PowershellModuleOutputDir, - DiagnosticOutput = true + DiagnosticOutput = true, }; + // If a build suffix is provided, use it, otherwise whatever comes from Directory.Build.props will be used. + // It's not possible to set the actual version number for a build in this project as that is purely controlled + // from individual Directory.Build.props files + if (!string.IsNullOrEmpty(context.BuildSuffix)) + { + buildSettings.MSBuildSettings.VersionSuffix = context.BuildSuffix; + } + + // Disable SourceLink automatic commit hash addition. Documentation about how that package work is garbage and + // this property is only mentioned in breaking changes https://learn.microsoft.com/en-us/dotnet/core/compatibility/sdk/8.0/source-link + // which is pretty on par for shit like this. + if (context.DisableCommitHash) + { + buildSettings.MSBuildSettings.Properties.Add("IncludeSourceRevisionInInformationalVersion", ["false"]); + } + // /p:DebugSymbols=false buildSettings.MSBuildSettings.Properties.Add("DebugSymbols", ["false"]); // /p:DebugType=None From 69e0f824de69573bbf0475431eddd8fc4510e830 Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 4 Sep 2026 13:39:55 +1000 Subject: [PATCH 57/58] feat(build): Stamp version onto bundle archive --- build/Tasks/CreateBundleArchiveTask.cs | 47 +++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/build/Tasks/CreateBundleArchiveTask.cs b/build/Tasks/CreateBundleArchiveTask.cs index 54413b1..0abd876 100644 --- a/build/Tasks/CreateBundleArchiveTask.cs +++ b/build/Tasks/CreateBundleArchiveTask.cs @@ -1,5 +1,9 @@ -๏ปฟusing System.IO; +๏ปฟusing System.Diagnostics; +using System.IO; using System.IO.Compression; +using System.Text.RegularExpressions; +using Cake.Common.Build; +using Cake.Common.Tools.MSBuild; using Cake.Core.Diagnostics; using Cake.Frosting; @@ -75,7 +79,8 @@ public class CreateBundleArchiveTask : FrostingTask context.Log.Information($"Bundle files copied"); GenerateModuleImportScript(moduleFolderLocation, context); - CreateBundleZip(bundleRootLocation, moduleFolderLocation, context); + var moduleVersionForFilename = GetPowershellModuleVersion(powershelModuleOutputLocation); + CreateBundleZip(bundleRootLocation, moduleFolderLocation, moduleVersionForFilename, context); } private static void GenerateModuleImportScript(string bundleLocation, BuildContext context) @@ -120,11 +125,45 @@ public class CreateBundleArchiveTask : FrostingTask context.Log.Information($"Module import script created at '{scriptFileName}'"); } - private static void CreateBundleZip(string bundleLocation, string moduleFolderLocation, BuildContext context) + private static void CreateBundleZip(string bundleLocation, string moduleFolderLocation, string version, BuildContext context) { - var bundleZipFileLocaiton = Path.Combine(bundleLocation, "NulahModule.zip"); + var bundleZipFileLocaiton = Path.Combine(bundleLocation, $"NulahModule-{version}.zip"); context.Log.Information($"Bundling module into archive '{bundleZipFileLocaiton}'"); ZipFile.CreateFromDirectory(moduleFolderLocation, bundleZipFileLocaiton, CompressionLevel.Fastest, true); context.Log.Information($"Archive created at '{bundleZipFileLocaiton}'"); } + + private static string GetPowershellModuleVersion(string powershelModuleOutputLocation) + { + var moduleVersionInfo = FileVersionInfo.GetVersionInfo(Path.Combine(powershelModuleOutputLocation, "PowershellModule.dll")); + + // It's (almost) impossible to not have a product version tag here. There is a reason why the implementation + // returns a string? but I can't find it and I don't really care too much. If we have null return an empty string + // and append no version to the archive bundle. + if (moduleVersionInfo.ProductVersion == null) + { + return string.Empty; + } + + var versionRegex = new Regex(@"(\d+\.\d+\.\d+)(?:\-?([\w\-]+)\+?(\w+)?)?"); + var match = versionRegex.Match(moduleVersionInfo.ProductVersion); + if (match.Success) + { + // If we have 4 groups, we've got a version number, suffix, and commit hash, so we return the first 2 as is + // and the commit has capped to 8 characters + if (match.Groups.Count == 4) + { + return $"{match.Groups[1]}-{match.Groups[2]}+{match.Groups[3].Value.Substring(0, 8)}"; + } + + if (match.Groups.Count == 3) + { + return $"{match.Groups[1]}-{match.Groups[2]}"; + } + + return $"{match.Groups[1]}"; + } + + return string.Empty; + } } \ No newline at end of file From 5c09b7c5a894b8edf1134f6e99b2813909d634d2 Mon Sep 17 00:00:00 2001 From: Scott Date: Sun, 6 Sep 2026 09:33:35 +1000 Subject: [PATCH 58/58] feat: Add GitRepoRegistration cmdlets (#5) - adds GitRepoRegistration cmdlets - adds basic tests for GitRepoRegistration implementations - adds initial build project and scripts Refs: #5, #6 --- .gitattributes | 4 + Directory.Packages.props | 1 + PowershellModule.slnx | 39 +- build/BuildContext.cs | 61 +++ build/Helpers.cs | 48 +++ build/Program.cs | 46 +- build/Scripts/CreateModuleManifest.ps1 | 13 +- build/Tasks/BuildTask.cs | 25 +- build/Tasks/CleanTask.cs | 2 +- build/Tasks/CopyOutputTask.cs | 35 +- build/Tasks/CreateBundleArchiveTask.cs | 136 ++++++ build/Tasks/DefaultTask.cs | 5 +- build/Tasks/TagCommitTask.cs | 54 +++ docs/GitRepositoryRegistration.md | 133 ++++++ src/ModuleCore/Calendar/CalendarGenerator.cs | 2 +- src/ModuleCore/Database/DatabaseManager.cs | 86 ++++ src/ModuleCore/Directory.Build.props | 4 +- .../Git/GitRepoRegistrationManager.cs | 393 ++++++++++++++++++ src/ModuleCore/Git/Models/GitRegistration.cs | 21 + .../Git/Models/ParsedGitFolderDetails.cs | 17 + src/ModuleCore/ModuleCore.csproj | 12 +- src/PowershellHarness/CustomHost.cs | 22 +- src/PowershellHarness/Program.cs | 59 ++- .../Calendar/GetCalendarCommand.cs | 2 +- src/PowershellModule/Directory.Build.props | 4 +- .../Commands/GetGitRepoRegistrationCommand.cs | 22 + .../Git/Commands/GitCommands.cs | 6 + .../Commands/NewGitRepoRegistrationCommand.cs | 41 ++ .../RemoveGitRepoRegistrationCommand.cs | 43 ++ .../ShowGitRepoRegistrationCommand.cs | 42 ++ src/PowershellModule/Git/GitProvider.cs | 82 ++++ src/PowershellModule/Git/GitPsDriveInfo.cs | 11 + src/PowershellModule/PostBuild.ps1 | 28 ++ src/PowershellModule/PowershellModule.csproj | 35 +- src/PowershellModule/manifest.json | 5 - .../meta/PowershellModule.psd1 | 0 .../meta/PowershellModule.psm1 | 0 tests/ModuleTests/Calendar/BasicRenderTest.cs | 2 +- .../Calendar/MarkedDayRenderTests.cs | 2 +- .../BasicRenderTest/2026-06-01.verified.txt | 2 +- .../BasicRenderTest/2026-07-01.verified.txt | 2 +- .../BasicRenderTest/2026-08-01.verified.txt | 2 +- .../BasicRenderTest/cs-CZ.verified.txt | 2 +- .../BasicRenderTest/da-DK.verified.txt | 2 +- .../BasicRenderTest/en-AU.verified.txt | 2 +- .../BasicRenderTest/es-PR.verified.txt | 2 +- .../BasicRenderTest/fr-LU.verified.txt | 2 +- .../BasicRenderTest/nl-NL.verified.txt | 2 +- .../BasicRenderTest/te-IN.verified.txt | 2 +- .../DefaultMarkedDayRender.verified.txt | 2 +- .../DoubleWideMarkedDayRender.verified.txt | 2 +- .../EmojiMarkedDayRender.verified.txt | 2 +- .../LongMarkedDayRender.verified.txt | 2 +- .../SimpleMarkedDayRender.verified.txt | 2 +- .../StartOfDayRender.verified.txt | 2 +- .../Calendar/StartDayOfWeekTests.cs | 2 +- .../Calendar/TestData/CalendarTestDates.cs | 2 +- .../Calendar/TestData/CultureCodeTestData.cs | 2 +- tests/ModuleTests/Git/AddRegistrationTests.cs | 150 +++++++ .../BasicRepoRegistration_0.verified.txt | 2 + .../BasicRepoRegistration_1.verified.txt | 2 + .../BasicRepoRegistration_2.verified.txt | 2 + .../Git/TestData/AddRegistrationTestData.cs | 18 + tests/ModuleTests/ModuleTests.csproj | 2 +- tests/ModuleTests/TestConstants.cs | 2 +- tests/ModuleTests/TestDataEnumerator.cs | 2 +- 66 files changed, 1619 insertions(+), 142 deletions(-) create mode 100644 .gitattributes create mode 100644 build/BuildContext.cs create mode 100644 build/Helpers.cs create mode 100644 build/Tasks/CreateBundleArchiveTask.cs create mode 100644 build/Tasks/TagCommitTask.cs create mode 100644 docs/GitRepositoryRegistration.md create mode 100644 src/ModuleCore/Database/DatabaseManager.cs create mode 100644 src/ModuleCore/Git/GitRepoRegistrationManager.cs create mode 100644 src/ModuleCore/Git/Models/GitRegistration.cs create mode 100644 src/ModuleCore/Git/Models/ParsedGitFolderDetails.cs create mode 100644 src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs create mode 100644 src/PowershellModule/Git/Commands/GitCommands.cs create mode 100644 src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs create mode 100644 src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs create mode 100644 src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs create mode 100644 src/PowershellModule/Git/GitProvider.cs create mode 100644 src/PowershellModule/Git/GitPsDriveInfo.cs create mode 100644 src/PowershellModule/PostBuild.ps1 delete mode 100644 src/PowershellModule/manifest.json delete mode 100644 src/PowershellModule/meta/PowershellModule.psd1 delete mode 100644 src/PowershellModule/meta/PowershellModule.psm1 rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/BasicRenderTest/2026-06-01.verified.txt (92%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/BasicRenderTest/2026-07-01.verified.txt (92%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/BasicRenderTest/2026-08-01.verified.txt (92%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/BasicRenderTest/cs-CZ.verified.txt (81%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/BasicRenderTest/da-DK.verified.txt (92%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/BasicRenderTest/en-AU.verified.txt (92%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/BasicRenderTest/es-PR.verified.txt (90%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/BasicRenderTest/fr-LU.verified.txt (90%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/BasicRenderTest/nl-NL.verified.txt (81%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/BasicRenderTest/te-IN.verified.txt (88%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/MarkedDayRenderTests/DefaultMarkedDayRender.verified.txt (92%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/MarkedDayRenderTests/DoubleWideMarkedDayRender.verified.txt (91%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/MarkedDayRenderTests/EmojiMarkedDayRender.verified.txt (91%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/MarkedDayRenderTests/LongMarkedDayRender.verified.txt (90%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/MarkedDayRenderTests/SimpleMarkedDayRender.verified.txt (92%) rename tests/ModuleTests/Calendar/{snapshots => Snapshots}/StartDayOfWeekTests/StartOfDayRender.verified.txt (99%) 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/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ea8de59 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.verified.txt text eol=lf working-tree-encoding=UTF-8 +*.verified.xml text eol=lf working-tree-encoding=UTF-8 +*.verified.json text eol=lf working-tree-encoding=UTF-8 +*.verified.bin binary \ No newline at end of file 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/PowershellModule.slnx b/PowershellModule.slnx index 04cf69b..2f6dd79 100644 --- a/PowershellModule.slnx +++ b/PowershellModule.slnx @@ -1,20 +1,23 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/build/BuildContext.cs b/build/BuildContext.cs new file mode 100644 index 0000000..a4c2588 --- /dev/null +++ b/build/BuildContext.cs @@ -0,0 +1,61 @@ +using Cake.Common.IO; +using Cake.Common.IO.Paths; +using Cake.Core; +using Cake.Core.IO; +using Cake.Frosting; + +namespace Build; + +public class BuildContext : FrostingContext +{ + /// + /// Base source directory + /// + public DirectoryPath BaseSourceLocation { get; set; } + + /// + /// Powershell module project folder + /// + public ConvertableDirectoryPath PowershellModuleProjectDirectory { get; set; } + + /// + /// Powershell module csproj location + /// + public ConvertableFilePath PowershellModuleCsproj { get; set; } + + /// + /// Output directory for built DLLs and powershell module manifest files + /// + public ConvertableDirectoryPath PowershellModuleOutputDir { get; set; } + + /// + /// Path to powershell script that creates the module manifest files + /// + public ConvertableFilePath CreateModuleManifestScript { get; set; } + + /// + /// Suffix to tag the build with, defaults to pre-release. + /// + /// Immediately follows the version number and before the commit hash. + /// + /// + public string BuildSuffix { get; set; } + + /// + /// Disable the commit hash from being added + /// + public bool DisableCommitHash { get; set; } + + public BuildContext(ICakeContext context) + : base(context) + { + BaseSourceLocation = context.Directory("../src").Path.MakeAbsolute(context.Environment); + PowershellModuleProjectDirectory = BaseSourceLocation + context.Directory("PowershellModule"); + PowershellModuleCsproj = PowershellModuleProjectDirectory + context.File("PowershellModule.csproj"); + PowershellModuleOutputDir = context.Directory("../") + context.Directory("output") + context.Directory("PowershellModule"); + var buildScriptDirectory = context.Directory("./Scripts"); + CreateModuleManifestScript = buildScriptDirectory + context.File("CreateModuleManifest.ps1"); + BuildSuffix = context.Configuration.GetValue(nameof(BuildSuffix)) ?? "pre-release"; + DisableCommitHash = context.Configuration.GetBoolValue(nameof(DisableCommitHash)); + } +} \ No newline at end of file diff --git a/build/Helpers.cs b/build/Helpers.cs new file mode 100644 index 0000000..a8f2bcb --- /dev/null +++ b/build/Helpers.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using System.IO; +using System.Text.RegularExpressions; + +namespace Build; + +public class Helpers +{ + /// + /// Returns a formatted version of the assembly that contains PowerShell cmdlets + /// + /// + /// + public static string GetPowershellModuleVersion(string powershelModuleOutputLocation) + { + // When the project name changes, this dll will also need to be updated + var moduleVersionInfo = FileVersionInfo.GetVersionInfo(Path.Combine(powershelModuleOutputLocation, "PowershellModule.dll")); + + // It's (almost) impossible to not have a product version tag here. There is a reason why the implementation + // returns a string? but I can't find it and I don't really care too much. If we have null return an empty string + // and append no version to the archive bundle. + if (moduleVersionInfo.ProductVersion == null) + { + return string.Empty; + } + + var versionRegex = new Regex(@"(\d+\.\d+\.\d+)(?:\-?([\w\-]+)\+?(\w+)?)?"); + var match = versionRegex.Match(moduleVersionInfo.ProductVersion); + if (match.Success) + { + // If we have 4 groups, we've got a version number, suffix, and commit hash, so we return the first 2 as is + // and the commit has capped to 8 characters + if (match.Groups.Count == 4) + { + return $"{match.Groups[1]}-{match.Groups[2]}+{match.Groups[3].Value.Substring(0, 8)}"; + } + + if (match.Groups.Count == 3) + { + return $"{match.Groups[1]}-{match.Groups[2]}"; + } + + return $"{match.Groups[1]}"; + } + + return string.Empty; + } +} \ No newline at end of file diff --git a/build/Program.cs b/build/Program.cs index ffb3738..d94f389 100644 --- a/build/Program.cs +++ b/build/Program.cs @@ -1,9 +1,6 @@ -using Cake.Common; -using Cake.Common.IO; -using Cake.Common.IO.Paths; -using Cake.Core; using Cake.Frosting; -using Cake.Powershell; + +namespace Build; public static class Program { @@ -13,43 +10,4 @@ public static class Program .UseContext() .Run(args); } -} - -public class BuildContext : FrostingContext -{ - /// - /// Base source directory - /// - public ConvertableDirectoryPath BaseSourceLocation { get; set; } - - /// - /// Powershell module project folder - /// - public ConvertableDirectoryPath PowershellModuleProjectDirectory { get; set; } - - /// - /// Powershell module csproj location - /// - public ConvertableFilePath PowershellModuleCsproj { get; set; } - - /// - /// Output directory for built DLLs and powershell module manifest files - /// - public ConvertableDirectoryPath PowershellModuleOutputDir { get; set; } - - /// - /// Path to powershell script that creates the module manifest files - /// - public ConvertableFilePath CreateModuleManifestScript { get; set; } - - public BuildContext(ICakeContext context) - : base(context) - { - BaseSourceLocation = context.Directory("../src"); - PowershellModuleProjectDirectory = BaseSourceLocation + context.Directory("PowershellModule"); - PowershellModuleCsproj = PowershellModuleProjectDirectory + context.File("PowershellModule.csproj"); - PowershellModuleOutputDir = context.Directory("../") + context.Directory("output") + context.Directory("PowershellModule"); - var buildScriptDirectory = context.Directory("./Scripts"); - CreateModuleManifestScript = buildScriptDirectory + context.File("CreateModuleManifest.ps1"); - } } \ No newline at end of file diff --git a/build/Scripts/CreateModuleManifest.ps1 b/build/Scripts/CreateModuleManifest.ps1 index 4e3d920..d7dbd59 100644 --- a/build/Scripts/CreateModuleManifest.ps1 +++ b/build/Scripts/CreateModuleManifest.ps1 @@ -1,15 +1,18 @@ -๏ปฟparam ( - [string]$path, +param ( + [string]$powershellModuleFileLocation, [string]$guid, [string]$author, [string[]]$nestedModules, [string]$rootModule, [string[]]$cmdletsToExport, - [string]$manifestFileLocation + [string]$manifestFileLocation, + # not used for anything yet, but I should probably simplify the module locations as they get the output dir + # created in CopyOutputTask.cs + [string]$outputDir ) $manifestSplat = @{ - Path = "$path" + Path = "$powershellModuleFileLocation" GUID = "$guid" Author = "$author" NestedModules = @($nestedModules) @@ -21,4 +24,4 @@ $manifestSplat = @{ } New-ModuleManifest @manifestSplat -New-Item "$manifestFileLocation" -ItemType File \ No newline at end of file +New-Item "$manifestFileLocation" -ItemType File -Value "# This file is run when Import-Module `"PowershellModule`" is called" \ No newline at end of file diff --git a/build/Tasks/BuildTask.cs b/build/Tasks/BuildTask.cs index c731bcb..089ac75 100644 --- a/build/Tasks/BuildTask.cs +++ b/build/Tasks/BuildTask.cs @@ -1,4 +1,4 @@ -๏ปฟusing Cake.Common.Tools.DotNet; +using Cake.Common.Tools.DotNet; using Cake.Common.Tools.DotNet.Build; using Cake.Common.Tools.DotNet.MSBuild; using Cake.Core.Diagnostics; @@ -17,14 +17,27 @@ public class BuildTask : FrostingTask var buildSettings = new DotNetBuildSettings() { - MSBuildSettings = new DotNetMSBuildSettings() - { - VersionSuffix = "cake" - }, + MSBuildSettings = new DotNetMSBuildSettings(), OutputDirectory = context.PowershellModuleOutputDir, - DiagnosticOutput = true + DiagnosticOutput = true, }; + // If a build suffix is provided, use it, otherwise whatever comes from Directory.Build.props will be used. + // It's not possible to set the actual version number for a build in this project as that is purely controlled + // from individual Directory.Build.props files + if (!string.IsNullOrEmpty(context.BuildSuffix)) + { + buildSettings.MSBuildSettings.VersionSuffix = context.BuildSuffix; + } + + // Disable SourceLink automatic commit hash addition. Documentation about how that package work is garbage and + // this property is only mentioned in breaking changes https://learn.microsoft.com/en-us/dotnet/core/compatibility/sdk/8.0/source-link + // which is pretty on par for shit like this. + if (context.DisableCommitHash) + { + buildSettings.MSBuildSettings.Properties.Add("IncludeSourceRevisionInInformationalVersion", ["false"]); + } + // /p:DebugSymbols=false buildSettings.MSBuildSettings.Properties.Add("DebugSymbols", ["false"]); // /p:DebugType=None diff --git a/build/Tasks/CleanTask.cs b/build/Tasks/CleanTask.cs index 36ab859..7335f97 100644 --- a/build/Tasks/CleanTask.cs +++ b/build/Tasks/CleanTask.cs @@ -1,4 +1,4 @@ -๏ปฟusing Cake.Common; +using Cake.Common; using Cake.Common.Tools.DotNet; using Cake.Core.Diagnostics; using Cake.Frosting; diff --git a/build/Tasks/CopyOutputTask.cs b/build/Tasks/CopyOutputTask.cs index 175f82a..1bcd241 100644 --- a/build/Tasks/CopyOutputTask.cs +++ b/build/Tasks/CopyOutputTask.cs @@ -1,4 +1,5 @@ -๏ปฟusing System; +using System; +using System.Collections.Generic; using System.Linq; using Cake.Core.Diagnostics; using Cake.Core.IO; @@ -8,22 +9,26 @@ using Cake.Powershell; namespace Build.Tasks; [TaskName("CopyOutput")] +[IsDependeeOf(typeof(CreateBundleArchiveTask))] public class CopyOutputTask : FrostingTask { public override void Run(BuildContext context) { var powershellModuleName = "PowershellModule"; + // TODO: [#10] Refactor file paths used in build project to be more explict and easier to understand + // Probably don't create full file locations when I can pass the output dir in and have the script make the path var scriptParams = new { - Path = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psd1", + PowershellModuleFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psd1", Guid = Guid.Parse("5cdf4635-edb0-428c-8d9b-92d0bcd47443"), Author = "Me", NestedModules = new[] { $"{powershellModuleName}.dll" }, RootModule = $"{powershellModuleName}.psm1", - CmdletsToExport = new[] { "Get-Calendar" }, - ManifestFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psm1" + CmdletsToExport = GetExportedCmdlets(), + ManifestFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psm1", + OutputLocation = context.PowershellModuleOutputDir, }; - + var psSettings = new PowershellSettings() { Arguments = new ProcessArgumentBuilder(), @@ -31,19 +36,35 @@ public class CopyOutputTask : FrostingTask // This feels a bit ugly/redundant seeing as I've defined the scriptParams above, but I'm leaving it as is // until I want to come back and clean this up properly - psSettings.Arguments.Append("path", ToPowershellSafeString(scriptParams.Path)); + psSettings.Arguments.Append("powershellModuleFileLocation", ToPowershellSafeString(scriptParams.PowershellModuleFileLocation)); psSettings.Arguments.Append("guid", ToPowershellSafeString(scriptParams.Guid.ToString())); psSettings.Arguments.Append("author", ToPowershellSafeString(scriptParams.Author)); psSettings.Arguments.Append("nestedModules", $"@({string.Join(",", scriptParams.NestedModules.Select(ToPowershellSafeString))})"); psSettings.Arguments.Append("rootModule", ToPowershellSafeString(scriptParams.RootModule)); psSettings.Arguments.Append("cmdletsToExport", $"@({string.Join(",", scriptParams.CmdletsToExport.Select(ToPowershellSafeString))})"); psSettings.Arguments.Append("manifestFileLocation", ToPowershellSafeString(scriptParams.ManifestFileLocation)); + psSettings.Arguments.Append("outputDir", ToPowershellSafeString(scriptParams.OutputLocation)); context.StartPowershellFile(context.CreateModuleManifestScript, psSettings); - context.Log.Information($"Module output to: {context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment)}"); + context.Log.Information($"Module files output to: {context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment)}"); base.Run(context); } private static string ToPowershellSafeString(string unescapedString) => $"'{unescapedString}'"; + + private static List GetExportedCmdlets() + { + return ["Get-Calendar", .. GetGitRepoRegistrationVerbs()]; + } + + private static List GetGitRepoRegistrationVerbs() + { + // TODO: I should make some reference file for these verbs and name but that'd pull in powershell dependencies + // to the build and I'd like to avoid that. + // This isn't great but commands are unlikely to change that frequently + string[] verbs = ["Get", "New", "Remove", "Show"]; + var commandName = "GitRepoRegistration"; + return [.. verbs.Select(x => $"{x}-{commandName}")]; + } } \ No newline at end of file diff --git a/build/Tasks/CreateBundleArchiveTask.cs b/build/Tasks/CreateBundleArchiveTask.cs new file mode 100644 index 0000000..52a2313 --- /dev/null +++ b/build/Tasks/CreateBundleArchiveTask.cs @@ -0,0 +1,136 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Text.RegularExpressions; +using Cake.Common.Build; +using Cake.Common.Tools.MSBuild; +using Cake.Core.Diagnostics; +using Cake.Frosting; + +namespace Build.Tasks; + +[TaskName("CreateBundleArchive")] +[IsDependentOn(typeof(CopyOutputTask))] +public class CreateBundleArchiveTask : FrostingTask +{ + public override void Run(BuildContext context) + { + context.Log.Information($"Creating bundle archive"); + BundleMinimalFiles(context); + base.Run(context); + } + + private static void BundleMinimalFiles(BuildContext context) + { + // We're effectively replicating PostBuild.ps1 with all of this as Remove-Item at the end of it does not remove files the same way. + // TODO: See if that's something I can fix + var powershellModuleBuildLocation = context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment).FullPath; + // PowershellModule is here so that the script that adds to the users $Env:PSModulePath correctly resolves when using + // Import-Module "PowershellModule" + // TODO: Generate the script to add to the PSModulePath in the bundleOutputLocation directory + + // Top level bundle output location + var bundleRootLocation = Directory.CreateDirectory(Path.Combine(powershellModuleBuildLocation, "bundle")).FullName; + // Module folder location that will be zipped up and will contain any setup scripts/readme etc + var moduleFolderLocation = Directory.CreateDirectory(Path.Combine(bundleRootLocation, "NulahModule")).FullName; + // Location to put all the module dlls and other files + var powershelModuleOutputLocation = Directory.CreateDirectory(Path.Combine(moduleFolderLocation, "PowershellModule")).FullName; + + context.Log.Information($"Bundle location: {bundleRootLocation}"); + context.Log.Information($"Built file location: {powershellModuleBuildLocation}"); + + // PostBuild.ps1 for PowershellModule should have already been run at this point - it won't remove files, but it will copy the + // correct e_sqlite3.dll that we need. + // This code is all commented out because I'm not entirely satisfied with PostBuild.ps1 doing some things but + // this build doing slightly different. + // // Get the location for the specific version of e_sqlite3.dll we need - PowerShell binary modules resolve dependent dlls + // // from the executing assembly directory first, and the built output of the module includes a lot of other dlls that + // // are already available with the dotnet runtime so there's no need for us to include them. + // // I mean, we could, but we'd also be terrible software engineers if we couldn't do something as basic as reducing files we need. + // // TODO: Account for other runtimes such as linux based ones where the dotnet runtime might _not_ provide these files for free. + // // It'd require looking into how the module loads for PowerShell on those operating systems, and I might never + // // get around to doing that because I use windows (currently). + // // TODO: Update this later to handle other runtimes, for now we hardcode to win-x64 because it's what I use + // var nativeSqliteDllLocation = System.IO.Path.Combine(powershellModuleBuildLocation, "runtimes", "win-x64", "native", "e_sqlite3.dll"); + // + // if (!File.Exists(nativeSqliteDllLocation)) + // { + // context.Log.Error($"BUNDLE FAILED: Unable to locate ${nativeSqliteDllLocation}"); + // return; + // } + // + // context.Log.Information($"Copying '${nativeSqliteDllLocation}' to '${bundleOutputLocation}'"); + // + // File.Copy(nativeSqliteDllLocation, System.IO.Path.Combine(bundleOutputLocation, System.IO.Path.GetFileName(nativeSqliteDllLocation))); + + var moduleCoreFiles = Directory.GetFiles(powershellModuleBuildLocation, "ModuleCore*", searchOption: SearchOption.TopDirectoryOnly); + var powershellModuleFiles = Directory.GetFiles(powershellModuleBuildLocation, "PowershellModule*", searchOption: SearchOption.TopDirectoryOnly); + var sqliteFiles = Directory.GetFiles(powershellModuleBuildLocation, "*SQLite*", searchOption: SearchOption.TopDirectoryOnly); + + string[] allFiles = [.. moduleCoreFiles, .. powershellModuleFiles, .. sqliteFiles]; + + context.Log.Information($"Copying {allFiles.Length} files to the module folder {powershelModuleOutputLocation}"); + foreach (var file in allFiles) + { + context.Log.Information($"Copying {Path.GetFileName(file)}"); + File.Copy(file, Path.Combine(powershelModuleOutputLocation, Path.GetFileName(file))); + } + + context.Log.Information($"Bundle files copied"); + + GenerateModuleImportScript(moduleFolderLocation, context); + var moduleVersionForFilename = Helpers.GetPowershellModuleVersion(powershelModuleOutputLocation); + CreateBundleZip(bundleRootLocation, moduleFolderLocation, moduleVersionForFilename, context); + } + + private static void GenerateModuleImportScript(string bundleLocation, BuildContext context) + { + context.Log.Information($"Generating module import script"); + var scriptFileName = Path.Combine(bundleLocation, "NulahPowershell.ps1"); + + using var scriptFile = File.Create(scriptFileName); + using var fileWriter = new StreamWriter(scriptFile); + // TODO: Document the set up function + // TODO: Create a ticket for fleshing out the set up function (or just do that work later) + fileWriter.Write( + """ + # To use, first copy all files to the same location as your $profile under ./NulahModule, then open your powershell profile located at $profile and add the following: + + # Script setup style: Using the setup script to import as needed (this method will also set custom prompts and other alias functions) + # . "$PSScriptRoot/NulahModule/NulahPowershell.ps1" + # SetupNulahPowershell + # Just the module: Use the following to just import just the powershell module + # Import-Module -Name "$PSScriptRoot/NulahModule/PowershellModule" + + function SetupNulahPowershell + { + # only add to our module path once + # The intent for this is that a user will have copied this bundle folder alongside their $profile + # location, eg, they'll have a folder called NulahModule that will contain everything within the bundle zip + $testBundleDirectory = $PSScriptRoot + $bundleInPath = ($Env:PSModulePath -split ';').TrimEnd('\') -contains $testBundleDirectory; + + if ($false -eq $bundleInPath) + { + $env:PSModulePath = @( + $env:PSModulePath + $testBundleDirectory + ) -Join [System.IO.Path]::PathSeparator + } + + Import-Module "PowershellModule" + } + """ + ); + context.Log.Information($"Module import script created at '{scriptFileName}'"); + } + + private static void CreateBundleZip(string bundleLocation, string moduleFolderLocation, string version, BuildContext context) + { + var bundleZipFileLocaiton = Path.Combine(bundleLocation, $"NulahModule-{version}.zip"); + context.Log.Information($"Bundling module into archive '{bundleZipFileLocaiton}'"); + ZipFile.CreateFromDirectory(moduleFolderLocation, bundleZipFileLocaiton, CompressionLevel.Fastest, true); + context.Log.Information($"Archive created at '{bundleZipFileLocaiton}'"); + } +} \ No newline at end of file diff --git a/build/Tasks/DefaultTask.cs b/build/Tasks/DefaultTask.cs index 200768c..217c75c 100644 --- a/build/Tasks/DefaultTask.cs +++ b/build/Tasks/DefaultTask.cs @@ -1,4 +1,4 @@ -๏ปฟusing Cake.Core; +using Cake.Core; using Cake.Core.Diagnostics; using Cake.Frosting; @@ -6,7 +6,10 @@ namespace Build.Tasks; // Consider this the "entry" point for builds, task order is defined by a chain of IsDependentOn [TaskName("Default")] +[IsDependentOn(typeof(BuildTask))] +[IsDependentOn(typeof(TagCommitTask))] [IsDependentOn(typeof(CopyOutputTask))] +[IsDependentOn(typeof(CreateBundleArchiveTask))] public class DefaultTask : FrostingTask { public override void Run(ICakeContext context) diff --git a/build/Tasks/TagCommitTask.cs b/build/Tasks/TagCommitTask.cs new file mode 100644 index 0000000..35b53f8 --- /dev/null +++ b/build/Tasks/TagCommitTask.cs @@ -0,0 +1,54 @@ +using System; +using System.Diagnostics; +using Cake.Core.Diagnostics; +using Cake.Frosting; + +namespace Build.Tasks; + +[TaskName("TagCurrentCommitWithVersion")] +[IsDependentOn(typeof(BuildTask))] +public class TagCommitTask : FrostingTask +{ + public override void Run(BuildContext context) + { + // This task only really exists to create pre-release builds for pull requests. + // It'll tag the current commit with the same version that'll be used for the bundle archive filename. + // TODO: update build.ps1 in the solution root so it doesn't do this tagging task + TagCommit(context); + base.Run(context); + } + + private static void TagCommit(BuildContext context) + { + var moduleVersionForFilename = Helpers.GetPowershellModuleVersion(context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment).FullPath); + + var ps = new ProcessStartInfo("git", + ["-C", context.BaseSourceLocation.FullPath, "tag", moduleVersionForFilename]) + { + 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? + if (gitProcess is null) + { + throw new Exception("git failed to start") + { + Source = "git-process", + }; + } + + gitProcess.WaitForExit(); + + if (!gitProcess.StandardError.EndOfStream) + { + throw new Exception($"Unable to tag: {gitProcess.StandardError.ReadToEnd().Trim()}. Either remove the existing tag, or commit your changes before running a build"); + } + + context.Log.Information($"Tagged commit with {moduleVersionForFilename}"); + } +} \ No newline at end of file diff --git a/docs/GitRepositoryRegistration.md b/docs/GitRepositoryRegistration.md new file mode 100644 index 0000000..935dd8e --- /dev/null +++ b/docs/GitRepositoryRegistration.md @@ -0,0 +1,133 @@ +# Git Repo Registration + +- all commands support `-debug` +- at its simplest level these commands allow you to register a git repo against a simple name, and provide the ability to quickly pushd to a location + +Most `*-GitRepoRegistration` commands are expected to be run within a folder that is contained within a git repo. The only exceptions to this are any verbs that list information or change locations such as `Get-`, `Show-`, `Push-`, `Pop-`. + +Any exceptions to behaviours are outlined within the relevant command section. + +> ### Case-Sensitivity +> For all commands, the `Name` parameter is treated as _case-sensitive_, so multiple registrations can exist with the same name but different casing and point to different git repo locations or the same location - acting as an alias in a sense. +> +> While this isn't explicitly supported functionality, we don't do anything to prevent you having multiple registrations for the same git repo. Why should we after all? This set of commandlets are designed to make git repos more organised so how you use it is up to you. + +## New-GitRepoRegistration + +`New-GitRepoRegistration [-Name string]` + +Creates a new registration for the current directory, optionally registering against the given name. If `-Name` is not given the folder for the root level of the git repo will be used. + +Name registrations are _not_ case-sensitive, so registrations can be made using different cases for the same location, and multiple registrations can exist for the git repository. + +If `New-GitRepoRegistration` is used in 2 repositories with the same name but different locations, the 2nd call will fail as a registration will already exist by name. + +```pwsh +# Default registration without a name within a folder in a git repo +PS D:/Repos/Project-a> New-GitRepoRegistration -debug +DEBUG: Checking if current directory is a git repository... +DEBUG: ...location is a git repo! +DEBUG: No name given for registration, defaulting to git folder root. +DEBUG: Registered 'D:/Repos/Project-a' to name 'Project-a' + +# Named registration +PS D:/Repos/Project-a> New-GitRepoRegistration "test repo" -debug +DEBUG: Checking if current directory is a git repository... +DEBUG: ...location is a git repo! +DEBUG: Registered 'D:/Repos/Project-a' to name 'test repo' + +PS D:/Repos/Project-a> Get-GitRepoRegistration + +Name Location CurrentBranch +---- -------- ------------- +test-repo D:/Repos/Project-a main +Project-a D:/Repos/Project-a main +``` + +If the current directory is not in a git repo, a registration already exists by name or default folder, or if any other issue occurs such as git not being available an error will be thrown. + +## Get-GitRepoRegistration + +`Get-GitRepoRegistration` + +Returns all currently registered git repositories, as well as their current branch. + +```pwsh +PS D:/Repos/Project-a> Get-GitRepoRegistration + +Name Location CurrentBranch +---- -------- ------------- +test-repo D:/Repos/Project-a main +Project-a D:/Repos/Project-a main +``` + +The current branch is cached for 15 minutes for performance reasons so it may not be up to date if a git repo has recently had its branch changed. + +## Show-GitRepoRegistration + +`Show-GitRepoRegistration -Name string [-NoStack|-NoPushLocation]` + +Changes your location to the location of the git repo registered by the given `-Name` and your original location will be preserved and can be returned to at any time via `Pop-Location`/`Popd`. By default, this command is identical to calling +`pushd [repository directory]`. + +Using `-NoStack`/`-NoPushLocation` will not push your current location onto the stack, and will behave the same as `cd [repository directory]`/`Set-Location [repository directory]`. + +```pwsh +PS C:/> Show-GitRepoRegistration Project-a +PS D:/Repos/Project-a> Get-Location -stack + +Path +---- +C:\ + +PS D:/Repos/Project-a> cd ./build +PS D:/Repos/Project-a/Build> Get-Location -stack + +Path +---- +C:\ + +PS D:/Repos/Project-a/Build> popd +PS C:/> +``` + +## Remove-GitRepoRegistration + +`Remove-GitRepoRegistration [-Name string]` + +Removes a repo registration by the given name, or if no name is specified, attempts to remove the git repo registered by resolving the git repo. + +If `Name` is not provided then the command must be run in a location that is a git repo and the registration to remove will use the parent git repo folder name. If `Name` _is_ provided this command can be executed from any location. + +```pwsh +# Default registration without a name within a folder in a git repo +PS D:/Repos/Project-a> New-GitRepoRegistration + +# List registrations +PS D:/Repos/Project-a> Get-GitRepoRegistration + +Name Location CurrentBranch +---- -------- ------------- +Project-a D:/Repos/Project-a main +Test-Repo D:/Repos/Project-a main + +# Remove a registration from within a git repo +PS D:/Repos/Project-a> Remove-GitRepoRegistration +# No output indicates successful removal + +PS D:/Repos/Project-a> Get-GitRepoRegistration + +Name Location CurrentBranch +---- -------- ------------- +Test-Repo D:/Repos/Project-a main + +# Duplicate calls error if there is no registration +PS D:/Repos/Project-a> Remove-GitRepoRegistration +Remove-GitRepoRegistration: No registration exists for 'Project-a'. + +# Debug output via named argument +PS D:/Repos/Project-a> Remove-GitRepoRegistration -name Test-Repo -debug +DEBUG: Checking if current directory is a git repository... +DEBUG: ...location is a git repo! +DEBUG: Removed Test-Repo. +``` \ No newline at end of file diff --git a/src/ModuleCore/Calendar/CalendarGenerator.cs b/src/ModuleCore/Calendar/CalendarGenerator.cs index 8494fc9..daa81ce 100644 --- a/src/ModuleCore/Calendar/CalendarGenerator.cs +++ b/src/ModuleCore/Calendar/CalendarGenerator.cs @@ -1,4 +1,4 @@ -๏ปฟusing System.Globalization; +using System.Globalization; using System.Text; namespace ModuleCore.Calendar; diff --git a/src/ModuleCore/Database/DatabaseManager.cs b/src/ModuleCore/Database/DatabaseManager.cs new file mode 100644 index 0000000..f4e8afe --- /dev/null +++ b/src/ModuleCore/Database/DatabaseManager.cs @@ -0,0 +1,86 @@ +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(); + } + } + + /// + /// Runs the given action within a new database connection + /// + /// + public void InConnection(Action dbAction) + { + using var conn = new SQLiteConnection(_databaseLocation.FullName); + dbAction(conn); + } + + /// + /// Runs the given func in a new database connection, returning the result + /// + /// + /// + /// + 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; + } + + /// + /// Deletes the current database file. This will cause any future instance methods to fail on database action if + /// a new instance is not created. + /// + /// This method should be avoided unless calling from a test. + /// + /// + internal void DeleteDatabase() + { + _databaseLocation.Delete(); + } + + /// + /// 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/Directory.Build.props b/src/ModuleCore/Directory.Build.props index afa6e9c..dd32fa3 100644 --- a/src/ModuleCore/Directory.Build.props +++ b/src/ModuleCore/Directory.Build.props @@ -1,6 +1,6 @@ -๏ปฟ + - 0.0.0 + 0.0.1 dev \ No newline at end of file diff --git a/src/ModuleCore/Git/GitRepoRegistrationManager.cs b/src/ModuleCore/Git/GitRepoRegistrationManager.cs new file mode 100644 index 0000000..98cab87 --- /dev/null +++ b/src/ModuleCore/Git/GitRepoRegistrationManager.cs @@ -0,0 +1,393 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using ModuleCore.Database; +using ModuleCore.Git.Models; +using SQLite; + +namespace ModuleCore.Git; + +/// +/// Manages git repo registration, including creating any registration persistence via a backing +/// +public class GitRepoRegistrationManager +{ + private static readonly Lazy GitManagerInstance = new(() => new GitRepoRegistrationManager()); + private static Action? _debugWriterDelegate; + private readonly DatabaseManager _db; + private readonly ConcurrentDictionary _registrations; + + private GitRepoRegistrationManager(string? databaseName = null) + { + _registrations = new ConcurrentDictionary(); + // Regular usage of this constructor will never pass a database name in. Currently only tests should be hitting + // a code path that has a different database name + _db = new DatabaseManager(databaseName ?? "git.db"); + + InitialiseRegistrations(); + } + + /// + /// Returns the current instance. If no instance has been created, returns a new instance + /// and then the same instance every call after. + /// + public static GitRepoRegistrationManager Instance => GitManagerInstance.Value; + + /// + /// Always returns a new clean instance of GitManager + /// + internal static GitRepoRegistrationManager InternalFreshInstance(string databaseName) => new(databaseName); + + /// + /// Deletes the underlying database file. + /// + /// Avoid calling this outside of tests. + /// + /// + internal void DeleteDatabase() => _db.DeleteDatabase(); + + /// + /// Creates up any database tables and loads all previously saved git registrations. + /// + private void InitialiseRegistrations() + { + _debugWriterDelegate?.Invoke("Initialising GitManager from first run - this should only happen once."); + + _db.InConnection(conn => + { + var createTableResult = conn.CreateTable(); + + if (createTableResult == CreateTableResult.Created) + { + _debugWriterDelegate?.Invoke($"Created table {InternalGitRegistration.TableName}."); + } + }); + + _debugWriterDelegate?.Invoke("Loading previous registrations from database."); + + var registrations = _db.InConnection>(conn => + conn.Table() + .ToList() + ); + + foreach (var internalGitRegistration in registrations) + { + _debugWriterDelegate?.Invoke($"Loading {internalGitRegistration.Name} ({internalGitRegistration.Id}) from database..."); + if (!_registrations.TryAdd(internalGitRegistration.Name, internalGitRegistration)) + { + _debugWriterDelegate?.Invoke("...failed to restore - potential duplicate name."); + } + } + } + + /// + /// Registers a git repository based on an absolute location. If is null or empty, + /// the registration will use the folder name for the git repo at the top level. + /// + /// + /// + /// The normalised string the repository was registered against + public string RegisterRepo(string absoluteRepositoryLocation, string registrationName) + { + registrationName = string.IsNullOrWhiteSpace(registrationName) + ? new DirectoryInfo(absoluteRepositoryLocation).Name + : registrationName; + + var gitRegistration = new InternalGitRegistration + { + Name = registrationName, + Location = absoluteRepositoryLocation, + Id = Guid.CreateVersion7(), + }; + + return _db.InConnection(conn => + { + // Query if we already have a registration either by name. Previously we also checked by location, but I + // decided to stick with constraining to the name only, same as the key used for the dictionary. + // tbh this is a bit of a janky way to do exists when I have to pass the query in anyway, but I just didn't + // want to do null checks and a truthy check so I wrap it in a barely-valuable method. + var registrationExists = _db.Exists( + $""" + SELECT 1 + FROM {InternalGitRegistration.TableName} + WHERE Name = ? + """, + gitRegistration.Name + ); + + if (registrationExists) + { + throw new Exception($"A Git repo is already registered with the name {registrationName}."); + } + + // Insert the new record + conn.Insert(gitRegistration); + + if (_registrations.TryAdd(registrationName, gitRegistration)) + { + _debugWriterDelegate?.Invoke($"Registered '{gitRegistration.Location}' to name '{registrationName}'"); + return registrationName; + } + + // This error case should be unlikely, but if a registration was removed but the registrations wasn't updated + // correctly then we'd unable to re-add a repo with the same name + throw new Exception("An error occured during registration."); + }); + } + + /// + /// Unregisters a git repo registration by name. If no registration exists an exception will be thrown. + /// + /// + /// + public void UnregisterRepo(string registrationName) + { + _db.InConnection(conn => + { + var existingRegistration = conn.Query( + $""" + SELECT {nameof(InternalGitRegistration.Id)} + ,{nameof(InternalGitRegistration.Name)} + ,{nameof(InternalGitRegistration.Location)} + FROM {InternalGitRegistration.TableName} + WHERE {nameof(InternalGitRegistration.Name)} = ? + """, + registrationName) + .FirstOrDefault(); + + if (existingRegistration is null) + { + throw new Exception($"No registration exists for '{registrationName}'.") + { + Source = "unregister-repository", + }; + } + + var deleted = conn.Delete(existingRegistration.Id); + + // If we somehow found a registration but delete returned nothing, just return and assume we've already + // removed it from registrations. + // Seems a bit risky when you read it logically, but by this point the registration shouldn't exist so it doesn't + // matter. + if (deleted == 0) + { + return; + } + + // Remove by the name we get from the database instead of what was passed in + if (_registrations.TryRemove(registrationName, out var removedItem)) + { + _debugWriterDelegate?.Invoke($"Removed {registrationName}."); + return; + } + + // Weird error to throw, but by this stage we shouldn't have a git repo registered under this name + throw new Exception("Failed to remove registration - no registration exists.") + { + Source = "unregister-repository", + }; + }); + } + + /// + /// Returns all currently registered git repositories, including additional information such as the git repositories + /// current branch. + /// + /// + public List ListRepos() + { + return _registrations.Select(x => + new GitRegistration + { + Name = x.Value.Name, + Location = x.Value.Location, + CurrentBranch = x.Value.CurrentBranch, + } + ) + .ToList(); + } + + /// + /// Returns the file location for a git repo registration by name. + /// + /// + /// + /// + public string GetDirectoryForRegisteredRepo(string? registeredName) + { + if (string.IsNullOrEmpty(registeredName)) + { + throw new Exception("Name cannot be null"); + } + + if (_registrations.TryGetValue(registeredName, out var registration)) + { + return registration.Location; + } + + throw new Exception($"No git repo has been registered with the name {registeredName}"); + } + + /// + /// Returns the git root directory for any nested directory if git rev-parse --show-toplevel returns a value. + /// + /// Will always return a non-null value if the directory is a git repo, otherwise an exception will be thrown + /// + /// + /// Path to check if it or any of its parents contain a git repository + /// + /// + /// Git fails to start, returns an error (ie: the directory is not in a git repo), or the git process does not return + /// any output or error. + /// + public static ParsedGitFolderDetails IsGitRepo(string path) + { + _debugWriterDelegate?.Invoke("Checking if current directory is a git repository..."); + + var ps = new ProcessStartInfo("git", + ["-C", path, "rev-parse", "--show-toplevel"]) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + // If the user doesn't have git on their path, this will throw an exception that I don't have to do anything + // special with, it'll be unhandled and powershell will handle it + var gitProcess = Process.Start(ps); + + // This probably shouldn't be possible? Not really sure of the conditions where the process could be started + // but return null, but I'm going to consider that unrecoverable error territory + if (gitProcess is null) + { + throw new Exception("git failed to start") + { + Source = "git-process", + }; + } + + gitProcess.WaitForExit(); + + if (!gitProcess.StandardOutput.EndOfStream) + { + var directory = gitProcess.StandardOutput.ReadToEnd(); + // Gotta trim what we get as it might already have a newline character at the end + var dirInfo = new DirectoryInfo(directory.Trim()); + + _debugWriterDelegate?.Invoke("...location is a git repo!"); + + var repoFolderInfo = new ParsedGitFolderDetails + { + Directory = dirInfo.FullName, + Folder = dirInfo.Name, + }; + + return repoFolderInfo; + } + + if (!gitProcess.StandardError.EndOfStream) + { + throw new Exception(gitProcess.StandardError.ReadToEnd()) + { + Source = "git-not-found", + }; + } + + throw new Exception("Unable to determine if directory is repository: git command returned no output or errors.") + { + Source = "git-parse-failed", + }; + } + + /// + /// Registers an output for debug output. should be called as soon as the need for output + /// is no longer needed. + /// + /// + public static void SetDebugWriter(Action commandRuntime) + { + _debugWriterDelegate = commandRuntime; + } + + /// + /// Clears any output previously registered with + /// + public static void ClearDebugWriter() + { + _debugWriterDelegate = null; + } + + /// + /// Used for internal git registration and handles getting the current branch + /// + [Table(TableName)] + private class InternalGitRegistration + { + internal const string TableName = "GitRegistration"; + private string _currentBranch = string.Empty; + private long _nextCheckTime; + + [PrimaryKey] + public Guid Id { get; set; } + + [Indexed(Unique = true)] + public string Name { get; set; } = null!; + + public string Location { get; set; } = null!; + + /// + /// The current branch of the repository. This value is cached for 15 minutes after which it becomes stale and + /// will be refreshed on the next call to this property. + /// + public string CurrentBranch => GetCurrentBranch(); + + // TODO: [#13] Create GitManager to centralise calls to git process + private string GetCurrentBranch() + { + var now = DateTime.Now; + // git branch should be quick enough that even with a large number of registrations this shouldn't be that slow + // when doing Get-GitRepo, but regardless we still only get the current branch via git if it's been some amount + // of time since the last time we did. + if (now.Ticks < _nextCheckTime) + { + return _currentBranch; + } + + // use -C for the git command so we don't need to set the working directory and the git command can be run + // from anywhere against the appropriate location + var ps = new ProcessStartInfo("git", + ["-C", Location, "branch", "--show-current"]) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + // If the user doesn't have git on their path, this will throw an exception that I don't have to do anything + // special with, it'll be unhandled and powershell will handle it + var gitProcess = Process.Start(ps); + + // This probably shouldn't be possible? Not really sure of the conditions where the process could be started + // but return null, but I'm going to consider that unrecoverable error territory + if (gitProcess is null) + { + throw new Exception("git failed to start"); + } + + gitProcess.WaitForExit(); + + if (!gitProcess.StandardOutput.EndOfStream) + { + _currentBranch = gitProcess.StandardOutput.ReadToEnd(); + } + + if (!gitProcess.StandardError.EndOfStream) + { + _currentBranch = gitProcess.StandardError.ReadToEnd(); + } + + // Set the next check to be in the future so we don't hold up any list commands every time. + _nextCheckTime = now.AddMinutes(15).Ticks; + + // The branch name could (will) have a newline character at the end, so we trim that off + return _currentBranch.Trim(); + } + } +} \ 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..8c38d03 --- /dev/null +++ b/src/ModuleCore/Git/Models/GitRegistration.cs @@ -0,0 +1,21 @@ +namespace ModuleCore.Git.Models; + +/// +/// Details for a registered git repo +/// +public class GitRegistration +{ + /// + /// Display name for the registration. Can either be the name of the repo retrieved from git, or a user supplied + /// alias + /// + public required string Name { get; set; } + /// + /// Location on disk for the top level of the registered git repo. Not guaranteed to exist on disk + /// + public required string Location { get; set; } + /// + /// Current branch for the git repo + /// + public required string CurrentBranch { get; set; } +} \ No newline at end of file diff --git a/src/ModuleCore/Git/Models/ParsedGitFolderDetails.cs b/src/ModuleCore/Git/Models/ParsedGitFolderDetails.cs new file mode 100644 index 0000000..a785be0 --- /dev/null +++ b/src/ModuleCore/Git/Models/ParsedGitFolderDetails.cs @@ -0,0 +1,17 @@ +namespace ModuleCore.Git.Models; + +/// +/// The directory details of the directory returned from git rev-parse --show-toplevel +/// +public class ParsedGitFolderDetails +{ + /// + /// The full path to the top level folder containing a git repository + /// + public string Directory { get; init; } = null!; + + /// + /// The last folder name of the directory + /// + public string Folder { get; init; } = null!; +} \ No newline at end of file diff --git a/src/ModuleCore/ModuleCore.csproj b/src/ModuleCore/ModuleCore.csproj index 4ef1a45..dd723a8 100644 --- a/src/ModuleCore/ModuleCore.csproj +++ b/src/ModuleCore/ModuleCore.csproj @@ -1,4 +1,4 @@ -๏ปฟ + net10.0 @@ -7,4 +7,14 @@ latestmajor + + + <_Parameter1>ModuleTests + + + + + + + diff --git a/src/PowershellHarness/CustomHost.cs b/src/PowershellHarness/CustomHost.cs index 96ef22b..dcc81ac 100644 --- a/src/PowershellHarness/CustomHost.cs +++ b/src/PowershellHarness/CustomHost.cs @@ -121,6 +121,19 @@ public class CustomUiHost : PSHostUserInterface public string Output => output.ToString(); + private int? _nextChoiceOption; + + /// + /// Sets the next choice option to be used on the next call to . + /// + /// You'll probably get the + /// + /// + public void SetNextPromptChoice(int choiceOption) + { + _nextChoiceOption = choiceOption; + } + public override Dictionary Prompt(string caption, string message, System.Collections.ObjectModel.Collection descriptions) { throw new NotImplementedException("Prompt is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); @@ -128,7 +141,14 @@ public class CustomUiHost : PSHostUserInterface public override int PromptForChoice(string caption, string message, System.Collections.ObjectModel.Collection choices, int defaultChoice) { - throw new NotImplementedException("PromptForChoice is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + if (_nextChoiceOption is null) + { + throw new NotImplementedException("PromptForChoice is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + var choiceReturn = _nextChoiceOption.Value; + _nextChoiceOption = null; + return choiceReturn; } public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options) 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) { diff --git a/src/PowershellModule/Calendar/GetCalendarCommand.cs b/src/PowershellModule/Calendar/GetCalendarCommand.cs index f70d6e0..ff16bc3 100644 --- a/src/PowershellModule/Calendar/GetCalendarCommand.cs +++ b/src/PowershellModule/Calendar/GetCalendarCommand.cs @@ -1,4 +1,4 @@ -๏ปฟusing System; +using System; using System.Globalization; using System.Management.Automation; using ModuleCore.Calendar; diff --git a/src/PowershellModule/Directory.Build.props b/src/PowershellModule/Directory.Build.props index afa6e9c..dd32fa3 100644 --- a/src/PowershellModule/Directory.Build.props +++ b/src/PowershellModule/Directory.Build.props @@ -1,6 +1,6 @@ -๏ปฟ + - 0.0.0 + 0.0.1 dev \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs b/src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs new file mode 100644 index 0000000..506aee2 --- /dev/null +++ b/src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs @@ -0,0 +1,22 @@ +using System.Management.Automation; +using ModuleCore.Git; +using ModuleCore.Git.Models; + +namespace PowershellModule.Git.Commands; + +/// +/// Lists all currently registered git repositories, with additional information such as their current branch. +/// +[Cmdlet(VerbsCommon.Get, GitCommands.GitRepoRegistrationNoun)] +[OutputType(typeof(GitRegistration))] +public class GetGitRepoRegistrationCommand : PSCmdlet +{ + protected override void BeginProcessing() + { + var repos = GitRepoRegistrationManager.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 new file mode 100644 index 0000000..7fe1856 --- /dev/null +++ b/src/PowershellModule/Git/Commands/GitCommands.cs @@ -0,0 +1,6 @@ +namespace PowershellModule.Git.Commands; + +public class GitCommands +{ + public const string GitRepoRegistrationNoun = "GitRepoRegistration"; +} \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs b/src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs new file mode 100644 index 0000000..9e0729f --- /dev/null +++ b/src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs @@ -0,0 +1,41 @@ +using System; +using System.Management.Automation; +using ModuleCore.Git; + +namespace PowershellModule.Git.Commands; + +[Cmdlet(VerbsCommon.New, GitCommands.GitRepoRegistrationNoun)] +public sealed class NewGitRepoRegistrationCommand : PSCmdlet +{ + [Parameter( + Position = 0, + ValueFromPipeline = true, + HelpMessage = "Reference name for the repo")] + public string? Name { get; set; } + + protected override void BeginProcessing() + { + try + { + GitRepoRegistrationManager.SetDebugWriter(WriteDebug); + + // Test that we're in a git repo first. If we aren't (or git isn't available), this method will throw + // so we don't need to handle for null (yet). + var repoFolder = GitRepoRegistrationManager.IsGitRepo(SessionState.Path.CurrentLocation.Path); + + if (string.IsNullOrWhiteSpace(Name)) + { + WriteDebug("No name given for registration, defaulting to git folder root."); + } + + GitRepoRegistrationManager.Instance.RegisterRepo(repoFolder.Directory, Name ?? repoFolder.Folder); + GitRepoRegistrationManager.ClearDebugWriter(); + + base.BeginProcessing(); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, ex.Source, ErrorCategory.FromStdErr, null)); + } + } +} \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs b/src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs new file mode 100644 index 0000000..a79060e --- /dev/null +++ b/src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs @@ -0,0 +1,43 @@ +using System; +using System.Management.Automation; +using ModuleCore.Git; + +namespace PowershellModule.Git.Commands; + +[Cmdlet(VerbsCommon.Remove, GitCommands.GitRepoRegistrationNoun)] +public class RemoveGitRepoRegistrationCommand : PSCmdlet +{ + [Parameter( + Position = 0, + ValueFromPipeline = true, + HelpMessage = "Reference name for the repo")] + public string? Name { get; set; } + + protected override void BeginProcessing() + { + try + { + GitRepoRegistrationManager.SetDebugWriter(WriteDebug); + + // If we aren't given a value for the Name argument, default behaviour is to attempt to remove a registration + // by the current git repo folder name for the current location. + // If we have a name, don't bother testing for a git repo, just attempt to remove the registration by name + // regardless of where we're being called from + var registrationNameToRemove = string.IsNullOrEmpty(Name) + ? GitRepoRegistrationManager.IsGitRepo(SessionState.Path.CurrentLocation.Path).Folder + : Name; + + GitRepoRegistrationManager.Instance.UnregisterRepo(registrationNameToRemove); + + // Removing a registration works similar to registering a new one - we either remove by exact name, or by + // the folder if no name is given (so a user can remove a registration from a git repo they're currently in) + GitRepoRegistrationManager.ClearDebugWriter(); + + base.BeginProcessing(); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, ex.Source, ErrorCategory.FromStdErr, null)); + } + } +} \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs b/src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs new file mode 100644 index 0000000..e4eb647 --- /dev/null +++ b/src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs @@ -0,0 +1,42 @@ +using System.Management.Automation; +using ModuleCore.Git; + +namespace PowershellModule.Git.Commands; + +[Cmdlet(VerbsCommon.Show, GitCommands.GitRepoRegistrationNoun)] +public class ShowGitRepoRegistrationCommand : 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("NoPushLocation")] + public SwitchParameter NoStack { get; set; } + + protected override void BeginProcessing() + { + var location = GitRepoRegistrationManager.Instance.GetDirectoryForRegisteredRepo(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/GitProvider.cs b/src/PowershellModule/Git/GitProvider.cs new file mode 100644 index 0000000..b9ece49 --- /dev/null +++ b/src/PowershellModule/Git/GitProvider.cs @@ -0,0 +1,82 @@ +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"; + + 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); + } + + protected override PSDriveInfo RemoveDrive(PSDriveInfo drive) + { + 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) + { + return true; + } + + // I think this is needed to be able to cd into it + protected override bool ItemExists(string path) + { + 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 NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/PowershellModule/Git/GitPsDriveInfo.cs b/src/PowershellModule/Git/GitPsDriveInfo.cs new file mode 100644 index 0000000..b1c761d --- /dev/null +++ b/src/PowershellModule/Git/GitPsDriveInfo.cs @@ -0,0 +1,11 @@ +using System.Management.Automation; + +namespace PowershellModule.Git; + +public class GitPsDriveInfo : PSDriveInfo +{ + protected GitPsDriveInfo(PSDriveInfo driveInfo) + : base(driveInfo) + { + } +} \ No newline at end of file diff --git a/src/PowershellModule/PostBuild.ps1 b/src/PowershellModule/PostBuild.ps1 new file mode 100644 index 0000000..518b78a --- /dev/null +++ b/src/PowershellModule/PostBuild.ps1 @@ -0,0 +1,28 @@ +<# + .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*" + "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 c7f5801..7cb5192 100644 --- a/src/PowershellModule/PowershellModule.csproj +++ b/src/PowershellModule/PowershellModule.csproj @@ -1,20 +1,25 @@ - - net10.0 - PowershellModule - latestmajor - enable - + + net10.0 + PowershellModule + latestmajor + enable + true + - - - All - - - + + + All + + + - - - + + + + + + + diff --git a/src/PowershellModule/manifest.json b/src/PowershellModule/manifest.json deleted file mode 100644 index 3039b76..0000000 --- a/src/PowershellModule/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -๏ปฟ{ - "_TODO":[ - "add manifest details here maybe idk" - ] -} \ No newline at end of file diff --git a/src/PowershellModule/meta/PowershellModule.psd1 b/src/PowershellModule/meta/PowershellModule.psd1 deleted file mode 100644 index e69de29..0000000 diff --git a/src/PowershellModule/meta/PowershellModule.psm1 b/src/PowershellModule/meta/PowershellModule.psm1 deleted file mode 100644 index e69de29..0000000 diff --git a/tests/ModuleTests/Calendar/BasicRenderTest.cs b/tests/ModuleTests/Calendar/BasicRenderTest.cs index 3bc791f..b08f0bd 100644 --- a/tests/ModuleTests/Calendar/BasicRenderTest.cs +++ b/tests/ModuleTests/Calendar/BasicRenderTest.cs @@ -1,4 +1,4 @@ -๏ปฟusing ModuleCore.Calendar; +using ModuleCore.Calendar; using ModuleTests.Calendar.TestData; using System.Globalization; diff --git a/tests/ModuleTests/Calendar/MarkedDayRenderTests.cs b/tests/ModuleTests/Calendar/MarkedDayRenderTests.cs index 24af2ce..a169518 100644 --- a/tests/ModuleTests/Calendar/MarkedDayRenderTests.cs +++ b/tests/ModuleTests/Calendar/MarkedDayRenderTests.cs @@ -1,4 +1,4 @@ -๏ปฟusing System.Text; +using System.Text; using ModuleCore.Calendar; namespace ModuleTests.Calendar; diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-06-01.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-06-01.verified.txt similarity index 92% rename from tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-06-01.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-06-01.verified.txt index de75dd0..39f169d 100644 --- a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-06-01.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-06-01.verified.txt @@ -1,4 +1,4 @@ -๏ปฟโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ June 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ Mon โ”‚ Tue โ”‚ Wed โ”‚ Thu โ”‚ Fri โ”‚ Sat โ”‚ Sun โ”‚ diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-07-01.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-07-01.verified.txt similarity index 92% rename from tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-07-01.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-07-01.verified.txt index a74744c..0f44012 100644 --- a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-07-01.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-07-01.verified.txt @@ -1,4 +1,4 @@ -๏ปฟโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ July 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ Mon โ”‚ Tue โ”‚ Wed โ”‚ Thu โ”‚ Fri โ”‚ Sat โ”‚ Sun โ”‚ diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-08-01.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-08-01.verified.txt similarity index 92% rename from tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-08-01.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-08-01.verified.txt index 9598b49..f3c1199 100644 --- a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/2026-08-01.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-08-01.verified.txt @@ -1,4 +1,4 @@ -๏ปฟโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ August 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ Mon โ”‚ Tue โ”‚ Wed โ”‚ Thu โ”‚ Fri โ”‚ Sat โ”‚ Sun โ”‚ diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/cs-CZ.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/cs-CZ.verified.txt similarity index 81% rename from tests/ModuleTests/Calendar/snapshots/BasicRenderTest/cs-CZ.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/cs-CZ.verified.txt index 797c58a..a1a264b 100644 --- a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/cs-CZ.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/cs-CZ.verified.txt @@ -1,4 +1,4 @@ -๏ปฟโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ srpen 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”ค โ”‚ po โ”‚ รบt โ”‚ st โ”‚ ฤt โ”‚ pรก โ”‚ so โ”‚ ne โ”‚ diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/da-DK.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/da-DK.verified.txt similarity index 92% rename from tests/ModuleTests/Calendar/snapshots/BasicRenderTest/da-DK.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/da-DK.verified.txt index 58618a1..d69d3f7 100644 --- a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/da-DK.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/da-DK.verified.txt @@ -1,4 +1,4 @@ -๏ปฟโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ august 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ man โ”‚ tir โ”‚ ons โ”‚ tor โ”‚ fre โ”‚ lรธr โ”‚ sรธn โ”‚ diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/en-AU.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/en-AU.verified.txt similarity index 92% rename from tests/ModuleTests/Calendar/snapshots/BasicRenderTest/en-AU.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/en-AU.verified.txt index 9598b49..f3c1199 100644 --- a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/en-AU.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/en-AU.verified.txt @@ -1,4 +1,4 @@ -๏ปฟโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ August 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ Mon โ”‚ Tue โ”‚ Wed โ”‚ Thu โ”‚ Fri โ”‚ Sat โ”‚ Sun โ”‚ diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/es-PR.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/es-PR.verified.txt similarity index 90% rename from tests/ModuleTests/Calendar/snapshots/BasicRenderTest/es-PR.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/es-PR.verified.txt index 9395157..4a4de20 100644 --- a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/es-PR.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/es-PR.verified.txt @@ -1,4 +1,4 @@ -๏ปฟโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ agosto 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ lun. โ”‚ mar. โ”‚ miรฉ. โ”‚ jue. โ”‚ vie. โ”‚ sรกb. โ”‚ dom. โ”‚ diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/fr-LU.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/fr-LU.verified.txt similarity index 90% rename from tests/ModuleTests/Calendar/snapshots/BasicRenderTest/fr-LU.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/fr-LU.verified.txt index 101ef77..9a7e138 100644 --- a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/fr-LU.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/fr-LU.verified.txt @@ -1,4 +1,4 @@ -๏ปฟโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ aoรปt 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ lun. โ”‚ mar. โ”‚ mer. โ”‚ jeu. โ”‚ ven. โ”‚ sam. โ”‚ dim. โ”‚ diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/nl-NL.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/nl-NL.verified.txt similarity index 81% rename from tests/ModuleTests/Calendar/snapshots/BasicRenderTest/nl-NL.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/nl-NL.verified.txt index 1aac604..2a9615a 100644 --- a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/nl-NL.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/nl-NL.verified.txt @@ -1,4 +1,4 @@ -๏ปฟโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ augustus 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”ค โ”‚ ma โ”‚ di โ”‚ wo โ”‚ do โ”‚ vr โ”‚ za โ”‚ zo โ”‚ diff --git a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/te-IN.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/te-IN.verified.txt similarity index 88% rename from tests/ModuleTests/Calendar/snapshots/BasicRenderTest/te-IN.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/te-IN.verified.txt index 5568a20..bee6c08 100644 --- a/tests/ModuleTests/Calendar/snapshots/BasicRenderTest/te-IN.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/te-IN.verified.txt @@ -1,4 +1,4 @@ -๏ปฟโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ เฐ†เฐ—เฐธเฑเฐŸเฑ 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ เฐธเฑ‹เฐฎ โ”‚ เฐฎเฐ‚เฐ—เฐณ โ”‚ เฐฌเฑเฐง โ”‚ เฐ—เฑเฐฐเฑ โ”‚ เฐถเฑเฐ•เฑเฐฐ โ”‚ เฐถเฐจเฐฟ โ”‚ เฐ†เฐฆเฐฟ โ”‚ diff --git a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/DefaultMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/DefaultMarkedDayRender.verified.txt similarity index 92% rename from tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/DefaultMarkedDayRender.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/DefaultMarkedDayRender.verified.txt index 4181cc1..a134a32 100644 --- a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/DefaultMarkedDayRender.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/DefaultMarkedDayRender.verified.txt @@ -1,4 +1,4 @@ -๏ปฟโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ August 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ Mon โ”‚ Tue โ”‚ Wed โ”‚ Thu โ”‚ Fri โ”‚ Sat โ”‚ Sun โ”‚ diff --git a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/DoubleWideMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/DoubleWideMarkedDayRender.verified.txt similarity index 91% rename from tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/DoubleWideMarkedDayRender.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/DoubleWideMarkedDayRender.verified.txt index cc27f4f..3fb6ad7 100644 --- a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/DoubleWideMarkedDayRender.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/DoubleWideMarkedDayRender.verified.txt @@ -1,4 +1,4 @@ -๏ปฟDouble-wide characters will display incorrectly but still tested until rendering can be improved +Double-wide characters will display incorrectly but still tested until rendering can be improved โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ August 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ค diff --git a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/EmojiMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/EmojiMarkedDayRender.verified.txt similarity index 91% rename from tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/EmojiMarkedDayRender.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/EmojiMarkedDayRender.verified.txt index a02a725..fc7c092 100644 --- a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/EmojiMarkedDayRender.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/EmojiMarkedDayRender.verified.txt @@ -1,4 +1,4 @@ -๏ปฟEmojis will display incorrectly in text but output correctly in terminals with UTF-8 encoding. +Emojis will display incorrectly in text but output correctly in terminals with UTF-8 encoding. โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ August 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ค diff --git a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/LongMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/LongMarkedDayRender.verified.txt similarity index 90% rename from tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/LongMarkedDayRender.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/LongMarkedDayRender.verified.txt index a8ebbf6..14e0523 100644 --- a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/LongMarkedDayRender.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/LongMarkedDayRender.verified.txt @@ -1,4 +1,4 @@ -๏ปฟโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ August 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ Mon โ”‚ Tue โ”‚ Wed โ”‚ Thu โ”‚ Fri โ”‚ Sat โ”‚ Sun โ”‚ diff --git a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/SimpleMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/SimpleMarkedDayRender.verified.txt similarity index 92% rename from tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/SimpleMarkedDayRender.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/SimpleMarkedDayRender.verified.txt index f546aac..007bfcb 100644 --- a/tests/ModuleTests/Calendar/snapshots/MarkedDayRenderTests/SimpleMarkedDayRender.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/SimpleMarkedDayRender.verified.txt @@ -1,4 +1,4 @@ -๏ปฟโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ August 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ Mon โ”‚ Tue โ”‚ Wed โ”‚ Thu โ”‚ Fri โ”‚ Sat โ”‚ Sun โ”‚ diff --git a/tests/ModuleTests/Calendar/snapshots/StartDayOfWeekTests/StartOfDayRender.verified.txt b/tests/ModuleTests/Calendar/Snapshots/StartDayOfWeekTests/StartOfDayRender.verified.txt similarity index 99% rename from tests/ModuleTests/Calendar/snapshots/StartDayOfWeekTests/StartOfDayRender.verified.txt rename to tests/ModuleTests/Calendar/Snapshots/StartDayOfWeekTests/StartOfDayRender.verified.txt index 8393453..aaa4fa5 100644 --- a/tests/ModuleTests/Calendar/snapshots/StartDayOfWeekTests/StartOfDayRender.verified.txt +++ b/tests/ModuleTests/Calendar/Snapshots/StartDayOfWeekTests/StartOfDayRender.verified.txt @@ -1,4 +1,4 @@ -๏ปฟStart Day: Sunday +Start Day: Sunday โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ August 2026 โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ค diff --git a/tests/ModuleTests/Calendar/StartDayOfWeekTests.cs b/tests/ModuleTests/Calendar/StartDayOfWeekTests.cs index c6680c7..fb4c52c 100644 --- a/tests/ModuleTests/Calendar/StartDayOfWeekTests.cs +++ b/tests/ModuleTests/Calendar/StartDayOfWeekTests.cs @@ -1,4 +1,4 @@ -๏ปฟusing System.Text; +using System.Text; using ModuleCore.Calendar; namespace ModuleTests.Calendar; diff --git a/tests/ModuleTests/Calendar/TestData/CalendarTestDates.cs b/tests/ModuleTests/Calendar/TestData/CalendarTestDates.cs index 189b4a4..3c5b048 100644 --- a/tests/ModuleTests/Calendar/TestData/CalendarTestDates.cs +++ b/tests/ModuleTests/Calendar/TestData/CalendarTestDates.cs @@ -1,4 +1,4 @@ -๏ปฟnamespace ModuleTests.Calendar.TestData; +namespace ModuleTests.Calendar.TestData; public class CalendarTestDates : TestDataEnumerator { diff --git a/tests/ModuleTests/Calendar/TestData/CultureCodeTestData.cs b/tests/ModuleTests/Calendar/TestData/CultureCodeTestData.cs index 9bdf1f0..f72e6c3 100644 --- a/tests/ModuleTests/Calendar/TestData/CultureCodeTestData.cs +++ b/tests/ModuleTests/Calendar/TestData/CultureCodeTestData.cs @@ -1,4 +1,4 @@ -๏ปฟnamespace ModuleTests.Calendar.TestData; +namespace ModuleTests.Calendar.TestData; public class CultureCodeTestData : TestDataEnumerator { diff --git a/tests/ModuleTests/Git/AddRegistrationTests.cs b/tests/ModuleTests/Git/AddRegistrationTests.cs new file mode 100644 index 0000000..c862773 --- /dev/null +++ b/tests/ModuleTests/Git/AddRegistrationTests.cs @@ -0,0 +1,150 @@ +using System.Text; +using ModuleCore.Git; +using ModuleTests.Git.TestData; + +namespace ModuleTests.Git; + +// TODO: [#12] GitRepoRegistration tests should reset database at start of each test +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 = GitRepoRegistrationManager.InternalFreshInstance(nameof(BasicRepoRegistration)); + + try + { + 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); + } + finally + { + gitManager.DeleteDatabase(); + } + } + + [Fact] + public void RepoRegistrationWithEmptyName() + { + Settings.UseFileName(nameof(RepoRegistrationWithEmptyName)); + var gitManager = GitRepoRegistrationManager.InternalFreshInstance(nameof(RepoRegistrationWithEmptyName)); + var testRepoAbsolutePath = "Test:/some/test/repo"; + + try + { + var emptyName = gitManager.RegisterRepo(testRepoAbsolutePath, ""); + + Assert.Equal("repo", emptyName); + } + finally + { + gitManager.DeleteDatabase(); + } + } + + [Fact] + public void RepoRegistrationWithNullName() + { + Settings.UseFileName(nameof(RepoRegistrationWithNullName)); + var gitManager = GitRepoRegistrationManager.InternalFreshInstance(nameof(RepoRegistrationWithNullName)); + var testRepoAbsolutePath = "Test:/some/test/repo"; + try + { + // 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); + } + finally + { + gitManager.DeleteDatabase(); + } + } + + [Fact] + public void RepoRegistrationWithWhitespaceName() + { + Settings.UseFileName(nameof(RepoRegistrationWithWhitespaceName)); + + var gitManager = GitRepoRegistrationManager.InternalFreshInstance(nameof(RepoRegistrationWithWhitespaceName)); + var testRepoAbsolutePath = "Test:/some/test/repo"; + try + { + var whitespaceName = gitManager.RegisterRepo(testRepoAbsolutePath, " "); + + Assert.Equal("repo", whitespaceName); + } + finally + { + gitManager.DeleteDatabase(); + } + } + + [Fact] + public void DuplicateRepoRegistrationShouldFail() + { + Settings.UseFileName(nameof(RepoRegistrationWithWhitespaceName)); + var gitManager = GitRepoRegistrationManager.InternalFreshInstance(nameof(RepoRegistrationWithWhitespaceName)); + 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)); + + try + { + var firstRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, names.NormalSeparator); + 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]))); + } + finally + { + gitManager.DeleteDatabase(); + } + } + + [Fact] + public void DuplicateRepoRegistrationDifferentSlashShouldNotFail() + { + Settings.UseFileName(nameof(RepoRegistrationWithWhitespaceName)); + var gitManager = GitRepoRegistrationManager.InternalFreshInstance(nameof(RepoRegistrationWithWhitespaceName)); + 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)); + + try + { + var firstRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, names.NormalSeparator); + 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); + } + finally + { + gitManager.DeleteDatabase(); + } + } +} \ 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..7f2c4e6 --- /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..2d44e39 --- /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 diff --git a/tests/ModuleTests/ModuleTests.csproj b/tests/ModuleTests/ModuleTests.csproj index 7343cfd..4b97606 100644 --- a/tests/ModuleTests/ModuleTests.csproj +++ b/tests/ModuleTests/ModuleTests.csproj @@ -1,4 +1,4 @@ -๏ปฟ + net10.0 diff --git a/tests/ModuleTests/TestConstants.cs b/tests/ModuleTests/TestConstants.cs index e32b3cd..812da26 100644 --- a/tests/ModuleTests/TestConstants.cs +++ b/tests/ModuleTests/TestConstants.cs @@ -1,4 +1,4 @@ -๏ปฟnamespace ModuleTests; +namespace ModuleTests; public class TestConstants { diff --git a/tests/ModuleTests/TestDataEnumerator.cs b/tests/ModuleTests/TestDataEnumerator.cs index 92937f5..f756af4 100644 --- a/tests/ModuleTests/TestDataEnumerator.cs +++ b/tests/ModuleTests/TestDataEnumerator.cs @@ -1,4 +1,4 @@ -๏ปฟusing System.Collections; +using System.Collections; namespace ModuleTests;