feat: Add GitRepoRegistration cmdlets (#5)

- adds GitRepoRegistration cmdlets
- adds basic tests for GitRepoRegistration implementations
- adds initial build project and scripts

Refs: #5, #6
This commit is contained in:
Scott 2026-09-06 09:33:35 +10:00 committed by pascal_nulah
commit 5c09b7c5a8
66 changed files with 1619 additions and 142 deletions

4
.gitattributes vendored Normal file
View file

@ -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

View file

@ -4,6 +4,7 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="sqlite-net-pcl" Version="1.11.285" />
<PackageVersion Include="System.IO.Packaging" Version="10.0.10" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.10" />
<PackageVersion Include="Microsoft.PowerShell.SDK" Version="7.6.4" />

View file

@ -1,20 +1,23 @@
<Solution>
<Folder Name="/build/">
<Project Path="build/Build.csproj" />
</Folder>
<Folder Name="/harnesses/">
<Project Path="src/ModuleHarness/ModuleHarness.csproj" />
<Project Path="src/PowershellHarness/PowershellHarness.csproj" />
</Folder>
<Folder Name="/scripts/">
<File Path="build.ps1" />
</Folder>
<Folder Name="/solutionItems/">
<File Path="Directory.Packages.props" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ModuleTests/ModuleTests.csproj" />
</Folder>
<Project Path="src/ModuleCore/ModuleCore.csproj" />
<Project Path="src/PowershellModule/PowershellModule.csproj" />
<Folder Name="/build/">
<Project Path="build/Build.csproj" />
</Folder>
<Folder Name="/docs/">
<File Path="docs/GitRepositoryRegistration.md" />
</Folder>
<Folder Name="/harnesses/">
<Project Path="src/ModuleHarness/ModuleHarness.csproj" />
<Project Path="src/PowershellHarness/PowershellHarness.csproj" />
</Folder>
<Folder Name="/scripts/">
<File Path="build.ps1" />
</Folder>
<Folder Name="/solutionItems/">
<File Path="Directory.Packages.props" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ModuleTests/ModuleTests.csproj" />
</Folder>
<Project Path="src/ModuleCore/ModuleCore.csproj" />
<Project Path="src/PowershellModule/PowershellModule.csproj" />
</Solution>

61
build/BuildContext.cs Normal file
View file

@ -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
{
/// <summary>
/// Base source directory
/// </summary>
public DirectoryPath BaseSourceLocation { get; set; }
/// <summary>
/// Powershell module project folder
/// </summary>
public ConvertableDirectoryPath PowershellModuleProjectDirectory { get; set; }
/// <summary>
/// Powershell module csproj location
/// </summary>
public ConvertableFilePath PowershellModuleCsproj { get; set; }
/// <summary>
/// Output directory for built DLLs and powershell module manifest files
/// </summary>
public ConvertableDirectoryPath PowershellModuleOutputDir { get; set; }
/// <summary>
/// Path to powershell script that creates the module manifest files
/// </summary>
public ConvertableFilePath CreateModuleManifestScript { get; set; }
/// <summary>
/// Suffix to tag the build with, defaults to pre-release.
/// <para>
/// Immediately follows the version number and before the commit hash.
/// </para>
/// </summary>
public string BuildSuffix { get; set; }
/// <summary>
/// Disable the commit hash from being added
/// </summary>
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));
}
}

48
build/Helpers.cs Normal file
View file

@ -0,0 +1,48 @@
using System.Diagnostics;
using System.IO;
using System.Text.RegularExpressions;
namespace Build;
public class Helpers
{
/// <summary>
/// Returns a formatted version of the assembly that contains PowerShell cmdlets
/// </summary>
/// <param name="powershelModuleOutputLocation"></param>
/// <returns></returns>
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;
}
}

View file

@ -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
{
@ -14,42 +11,3 @@ public static class Program
.Run(args);
}
}
public class BuildContext : FrostingContext
{
/// <summary>
/// Base source directory
/// </summary>
public ConvertableDirectoryPath BaseSourceLocation { get; set; }
/// <summary>
/// Powershell module project folder
/// </summary>
public ConvertableDirectoryPath PowershellModuleProjectDirectory { get; set; }
/// <summary>
/// Powershell module csproj location
/// </summary>
public ConvertableFilePath PowershellModuleCsproj { get; set; }
/// <summary>
/// Output directory for built DLLs and powershell module manifest files
/// </summary>
public ConvertableDirectoryPath PowershellModuleOutputDir { get; set; }
/// <summary>
/// Path to powershell script that creates the module manifest files
/// </summary>
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");
}
}

View file

@ -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
New-Item "$manifestFileLocation" -ItemType File -Value "# This file is run when Import-Module `"PowershellModule`" is called"

View file

@ -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<BuildContext>
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

View file

@ -1,4 +1,4 @@
using Cake.Common;
using Cake.Common;
using Cake.Common.Tools.DotNet;
using Cake.Core.Diagnostics;
using Cake.Frosting;

View file

@ -1,4 +1,5 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Cake.Core.Diagnostics;
using Cake.Core.IO;
@ -8,20 +9,24 @@ using Cake.Powershell;
namespace Build.Tasks;
[TaskName("CopyOutput")]
[IsDependeeOf(typeof(CreateBundleArchiveTask))]
public class CopyOutputTask : FrostingTask<BuildContext>
{
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()
@ -31,19 +36,35 @@ public class CopyOutputTask : FrostingTask<BuildContext>
// 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<string> GetExportedCmdlets()
{
return ["Get-Calendar", .. GetGitRepoRegistrationVerbs()];
}
private static List<string> 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}")];
}
}

View file

@ -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<BuildContext>
{
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}'");
}
}

View file

@ -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)

View file

@ -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<BuildContext>
{
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}");
}
}

View file

@ -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.
```

View file

@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Text;
namespace ModuleCore.Calendar;

View file

@ -0,0 +1,86 @@
using SQLite;
namespace ModuleCore.Database;
public class DatabaseManager
{
private readonly FileInfo _databaseLocation;
/// <summary>
/// Creates a new manager for the given database file by name
/// </summary>
/// <param name="databaseName">
/// Filename for the database with no extension. Slashes are accepted and will create directories as needed.
/// </param>
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();
}
}
/// <summary>
/// Runs the given action within a new database connection
/// </summary>
/// <param name="dbAction"></param>
public void InConnection(Action<SQLiteConnection> dbAction)
{
using var conn = new SQLiteConnection(_databaseLocation.FullName);
dbAction(conn);
}
/// <summary>
/// Runs the given func in a new database connection, returning the result
/// </summary>
/// <param name="dbAction"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
public T InConnection<T>(Func<SQLiteConnection, T> dbAction)
{
using var conn = new SQLiteConnection(_databaseLocation.FullName);
return dbAction(conn);
}
/// <summary>
/// Returns a bool for the given query. Convenience method for <see cref="SQLiteConnection.ExecuteScalar"/>.
/// </summary>
/// <param name="query">A query starting with SELECT 1, optionally paramaterised with ?</param>
/// <param name="args">Parameter values</param>
/// <returns></returns>
public bool Exists(string query, params object[] args)
{
using var conn = new SQLiteConnection(_databaseLocation.FullName);
var exists = conn.ExecuteScalar<bool?>(query, args);
return exists ?? false;
}
/// <summary>
/// Deletes the current database file. This will cause any future instance methods to fail on database action if
/// a new instance is not created.
/// <para>
/// This method should be avoided unless calling from a test.
/// </para>
/// </summary>
internal void DeleteDatabase()
{
_databaseLocation.Delete();
}
/// <summary>
/// Removes double dots from the filename and removes the file extension
/// </summary>
/// <param name="filename"></param>
/// <returns></returns>
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));
}
}

View file

@ -1,6 +1,6 @@
<Project>
<Project>
<PropertyGroup>
<VersionPrefix>0.0.0</VersionPrefix>
<VersionPrefix>0.0.1</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,393 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using ModuleCore.Database;
using ModuleCore.Git.Models;
using SQLite;
namespace ModuleCore.Git;
/// <summary>
/// Manages git repo registration, including creating any registration persistence via a backing <see cref="DatabaseManager"/>
/// </summary>
public class GitRepoRegistrationManager
{
private static readonly Lazy<GitRepoRegistrationManager> GitManagerInstance = new(() => new GitRepoRegistrationManager());
private static Action<string>? _debugWriterDelegate;
private readonly DatabaseManager _db;
private readonly ConcurrentDictionary<string, InternalGitRegistration> _registrations;
private GitRepoRegistrationManager(string? databaseName = null)
{
_registrations = new ConcurrentDictionary<string, InternalGitRegistration>();
// 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();
}
/// <summary>
/// Returns the current <see cref="GitRepoRegistrationManager"/> instance. If no instance has been created, returns a new instance
/// and then the same instance every call after.
/// </summary>
public static GitRepoRegistrationManager Instance => GitManagerInstance.Value;
/// <summary>
/// Always returns a new clean instance of GitManager
/// </summary>
internal static GitRepoRegistrationManager InternalFreshInstance(string databaseName) => new(databaseName);
/// <summary>
/// Deletes the underlying database file.
/// <para>
/// Avoid calling this outside of tests.
/// </para>
/// </summary>
internal void DeleteDatabase() => _db.DeleteDatabase();
/// <summary>
/// Creates up any database tables and loads all previously saved git registrations.
/// </summary>
private void InitialiseRegistrations()
{
_debugWriterDelegate?.Invoke("Initialising GitManager from first run - this should only happen once.");
_db.InConnection(conn =>
{
var createTableResult = conn.CreateTable<InternalGitRegistration>();
if (createTableResult == CreateTableResult.Created)
{
_debugWriterDelegate?.Invoke($"Created table {InternalGitRegistration.TableName}.");
}
});
_debugWriterDelegate?.Invoke("Loading previous registrations from database.");
var registrations = _db.InConnection<List<InternalGitRegistration>>(conn =>
conn.Table<InternalGitRegistration>()
.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.");
}
}
}
/// <summary>
/// Registers a git repository based on an absolute location. If <paramref name="registrationName" /> is null or empty,
/// the registration will use the folder name for the git repo at the top level.
/// </summary>
/// <param name="absoluteRepositoryLocation"></param>
/// <param name="registrationName"></param>
/// <returns>The normalised string the repository was registered against</returns>
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<string>(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.");
});
}
/// <summary>
/// Unregisters a git repo registration by name. If no registration exists an exception will be thrown.
/// </summary>
/// <param name="registrationName"></param>
/// <exception cref="Exception"></exception>
public void UnregisterRepo(string registrationName)
{
_db.InConnection(conn =>
{
var existingRegistration = conn.Query<InternalGitRegistration>(
$"""
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<InternalGitRegistration>(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",
};
});
}
/// <summary>
/// Returns all currently registered git repositories, including additional information such as the git repositories
/// current branch.
/// </summary>
/// <returns></returns>
public List<GitRegistration> ListRepos()
{
return _registrations.Select(x =>
new GitRegistration
{
Name = x.Value.Name,
Location = x.Value.Location,
CurrentBranch = x.Value.CurrentBranch,
}
)
.ToList();
}
/// <summary>
/// Returns the file location for a git repo registration by name.
/// </summary>
/// <param name="registeredName"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
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}");
}
/// <summary>
/// Returns the git root directory for any nested directory if git rev-parse --show-toplevel returns a value.
/// <para>
/// Will always return a non-null value if the directory is a git repo, otherwise an exception will be thrown
/// </para>
/// </summary>
/// <param name="path">Path to check if it or any of its parents contain a git repository</param>
/// <returns></returns>
/// <exception cref="Exception">
/// 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.
/// </exception>
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",
};
}
/// <summary>
/// Registers an output for debug output. <see cref="ClearDebugWriter" /> should be called as soon as the need for output
/// is no longer needed.
/// </summary>
/// <param name="commandRuntime"></param>
public static void SetDebugWriter(Action<string> commandRuntime)
{
_debugWriterDelegate = commandRuntime;
}
/// <summary>
/// Clears any output previously registered with <see cref="SetDebugWriter" />
/// </summary>
public static void ClearDebugWriter()
{
_debugWriterDelegate = null;
}
/// <summary>
/// Used for internal git registration and handles getting the current branch
/// </summary>
[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!;
/// <summary>
/// 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.
/// </summary>
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();
}
}
}

View file

@ -0,0 +1,21 @@
namespace ModuleCore.Git.Models;
/// <summary>
/// Details for a registered git repo
/// </summary>
public class GitRegistration
{
/// <summary>
/// Display name for the registration. Can either be the name of the repo retrieved from git, or a user supplied
/// alias
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Location on disk for the top level of the registered git repo. Not guaranteed to exist on disk
/// </summary>
public required string Location { get; set; }
/// <summary>
/// Current branch for the git repo
/// </summary>
public required string CurrentBranch { get; set; }
}

View file

@ -0,0 +1,17 @@
namespace ModuleCore.Git.Models;
/// <summary>
/// The directory details of the directory returned from git rev-parse --show-toplevel
/// </summary>
public class ParsedGitFolderDetails
{
/// <summary>
/// The full path to the top level folder containing a git repository
/// </summary>
public string Directory { get; init; } = null!;
/// <summary>
/// The last folder name of the directory
/// </summary>
public string Folder { get; init; } = null!;
}

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@ -7,4 +7,14 @@
<LangVersion>latestmajor</LangVersion>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>ModuleTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<PackageReference Include="sqlite-net-pcl"/>
</ItemGroup>
</Project>

View file

@ -121,6 +121,19 @@ public class CustomUiHost : PSHostUserInterface
public string Output => output.ToString();
private int? _nextChoiceOption;
/// <summary>
/// Sets the next choice option to be used on the next call to <see cref="PromptForChoice"/>.
///
/// You'll probably get the
/// </summary>
/// <param name="choiceOption"></param>
public void SetNextPromptChoice(int choiceOption)
{
_nextChoiceOption = choiceOption;
}
public override Dictionary<string, PSObject> Prompt(string caption, string message, System.Collections.ObjectModel.Collection<FieldDescription> 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<ChoiceDescription> 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)

View file

@ -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<DayOfWeek>())
{
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);
@ -115,15 +142,28 @@ class Program
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)
{

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Globalization;
using System.Management.Automation;
using ModuleCore.Calendar;

View file

@ -1,6 +1,6 @@
<Project>
<Project>
<PropertyGroup>
<VersionPrefix>0.0.0</VersionPrefix>
<VersionPrefix>0.0.1</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,22 @@
using System.Management.Automation;
using ModuleCore.Git;
using ModuleCore.Git.Models;
namespace PowershellModule.Git.Commands;
/// <summary>
/// Lists all currently registered git repositories, with additional information such as their current branch.
/// </summary>
[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();
}
}

View file

@ -0,0 +1,6 @@
namespace PowershellModule.Git.Commands;
public class GitCommands
{
public const string GitRepoRegistrationNoun = "GitRepoRegistration";
}

View file

@ -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));
}
}
}

View file

@ -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));
}
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,11 @@
using System.Management.Automation;
namespace PowershellModule.Git;
public class GitPsDriveInfo : PSDriveInfo
{
protected GitPsDriveInfo(PSDriveInfo driveInfo)
: base(driveInfo)
{
}
}

View file

@ -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

View file

@ -1,20 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>PowershellModule</AssemblyName>
<LangVersion>latestmajor</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>PowershellModule</AssemblyName>
<LangVersion>latestmajor</LangVersion>
<Nullable>enable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="PowerShellStandard.Library" >
<PrivateAssets>All</PrivateAssets>
</PackageReference>
<PackageReference Include="System.Management.Automation" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="PowerShellStandard.Library">
<PrivateAssets>All</PrivateAssets>
</PackageReference>
<PackageReference Include="System.Management.Automation"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ModuleCore\ModuleCore.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ModuleCore\ModuleCore.csproj"/>
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="pwsh.exe -file &quot;$(ProjectDir)PostBuild.ps1&quot; $(TargetDir)"/>
</Target>
</Project>

View file

@ -1,5 +0,0 @@
{
"_TODO":[
"add manifest details here maybe idk"
]
}

View file

@ -1,4 +1,4 @@
using ModuleCore.Calendar;
using ModuleCore.Calendar;
using ModuleTests.Calendar.TestData;
using System.Globalization;

View file

@ -1,4 +1,4 @@
using System.Text;
using System.Text;
using ModuleCore.Calendar;
namespace ModuleTests.Calendar;

View file

@ -1,4 +1,4 @@
┌─────────────────────────────────────────┐
┌─────────────────────────────────────────┐
│ June 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │

View file

@ -1,4 +1,4 @@
┌─────────────────────────────────────────┐
┌─────────────────────────────────────────┐
│ July 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │

View file

@ -1,4 +1,4 @@
┌─────────────────────────────────────────┐
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │

View file

@ -1,4 +1,4 @@
┌──────────────────────────────────┐
┌──────────────────────────────────┐
│ srpen 2026 │
├────┬────┬────┬────┬────┬────┬────┤
│ po │ út │ st │ čt │ pá │ so │ ne │

View file

@ -1,4 +1,4 @@
┌─────────────────────────────────────────┐
┌─────────────────────────────────────────┐
│ august 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ man │ tir │ ons │ tor │ fre │ lør │ søn │

View file

@ -1,4 +1,4 @@
┌─────────────────────────────────────────┐
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │

View file

@ -1,4 +1,4 @@
┌────────────────────────────────────────────────┐
┌────────────────────────────────────────────────┐
│ agosto 2026 │
├──────┬──────┬──────┬──────┬──────┬──────┬──────┤
│ lun. │ mar. │ mié. │ jue. │ vie. │ sáb. │ dom. │

View file

@ -1,4 +1,4 @@
┌────────────────────────────────────────────────┐
┌────────────────────────────────────────────────┐
│ août 2026 │
├──────┬──────┬──────┬──────┬──────┬──────┬──────┤
│ lun. │ mar. │ mer. │ jeu. │ ven. │ sam. │ dim. │

View file

@ -1,4 +1,4 @@
┌──────────────────────────────────┐
┌──────────────────────────────────┐
│ augustus 2026 │
├────┬────┬────┬────┬────┬────┬────┤
│ ma │ di │ wo │ do │ vr │ za │ zo │

View file

@ -1,4 +1,4 @@
┌───────────────────────────────────────────────────────┐
┌───────────────────────────────────────────────────────┐
│ ఆగస్టు 2026 │
├───────┬───────┬───────┬───────┬───────┬───────┬───────┤
│ సోమ │ మంగళ │ బుధ │ గురు │ శుక్ర │ శని │ ఆది │

View file

@ -1,4 +1,4 @@
┌─────────────────────────────────────────┐
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │

View file

@ -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 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤

View file

@ -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 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤

View file

@ -1,4 +1,4 @@
┌──────────────────────────────────────────────────────────────┐
┌──────────────────────────────────────────────────────────────┐
│ August 2026 │
├────────┬────────┬────────┬────────┬────────┬────────┬────────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │

View file

@ -1,4 +1,4 @@
┌─────────────────────────────────────────┐
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │

View file

@ -1,4 +1,4 @@
Start Day: Sunday
Start Day: Sunday
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤

View file

@ -1,4 +1,4 @@
using System.Text;
using System.Text;
using ModuleCore.Calendar;
namespace ModuleTests.Calendar;

View file

@ -1,4 +1,4 @@
namespace ModuleTests.Calendar.TestData;
namespace ModuleTests.Calendar.TestData;
public class CalendarTestDates : TestDataEnumerator<DateTime>
{

View file

@ -1,4 +1,4 @@
namespace ModuleTests.Calendar.TestData;
namespace ModuleTests.Calendar.TestData;
public class CultureCodeTestData : TestDataEnumerator<string>
{

View file

@ -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<Exception>(() => 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();
}
}
}

View file

@ -0,0 +1,2 @@
Attempted to register: test
Registration result: test

View file

@ -0,0 +1,2 @@
Attempted to register: test\path
Registration result: test\path

View file

@ -0,0 +1,2 @@
Attempted to register: other/path
Registration result: other/path

View file

@ -0,0 +1,18 @@
using System.Linq;
namespace ModuleTests.Git.TestData;
public class AddRegistrationTestData : TestDataEnumerator<(int testId, string path)>
{
public AddRegistrationTestData()
{
Data = new List<string>()
{
"test",
$"test{Path.DirectorySeparatorChar}path",
$"other{Path.AltDirectorySeparatorChar}path"
}
.Select((x, i) => (i, x))
.ToList();
}
}

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>

View file

@ -1,4 +1,4 @@
namespace ModuleTests;
namespace ModuleTests;
public class TestConstants
{

View file

@ -1,4 +1,4 @@
using System.Collections;
using System.Collections;
namespace ModuleTests;