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/ModuleCore/Git/GitManager.cs b/src/ModuleCore/Git/GitManager.cs new file mode 100644 index 0000000..0d5af37 --- /dev/null +++ b/src/ModuleCore/Git/GitManager.cs @@ -0,0 +1,162 @@ +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; + + /// + /// 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() + { + Console.WriteLine($"{nameof(GitManager)} init"); + // Initialise the root container + _repositories = new InternalDirectory() + { + Name = DirectorySeparator, + InternalPath = DirectorySeparator + }; + } + + /// + /// Registers a git repository based on an absolute location. If is null or empty, + /// the registration will use the folder name for the git repo at the top level. + /// + /// + /// + /// 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) + { + 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; + } + } + + /// + /// 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) + { + 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/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/Git/GitProvider.cs b/src/PowershellModule/Git/GitProvider.cs new file mode 100644 index 0000000..e340c2b --- /dev/null +++ b/src/PowershellModule/Git/GitProvider.cs @@ -0,0 +1,86 @@ +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) + { + // 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 System.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..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 diff --git a/src/PowershellModule/Git/NewGitRepoCommand.cs b/src/PowershellModule/Git/NewGitRepoCommand.cs new file mode 100644 index 0000000..2b4bf6a --- /dev/null +++ b/src/PowershellModule/Git/NewGitRepoCommand.cs @@ -0,0 +1,114 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Management.Automation; +using ModuleCore.Git; + +namespace PowershellModule.Git; + +[Cmdlet(VerbsCommon.New, Noun)] +public class NewGitRepoCommand : PSCmdlet +{ + private const string Noun = "GitRepo"; + + [Parameter( + Position = 0, + ValueFromPipeline = true, + HelpMessage = "Reference name for the repo")] + public string? Name { get; set; } + + 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..."); + + 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(); + } + + 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!; + } +} \ 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 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 + + + + + diff --git a/tests/ModuleTests/Git/AddRegistrationTests.cs b/tests/ModuleTests/Git/AddRegistrationTests.cs new file mode 100644 index 0000000..6de31d6 --- /dev/null +++ b/tests/ModuleTests/Git/AddRegistrationTests.cs @@ -0,0 +1,77 @@ +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); + } + + [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 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