Compare commits

...
Author SHA1 Message Date
69e0f824de feat(build): Stamp version onto bundle archive 2026-09-04 13:39:55 +10:00
fa0531f2c5 refactor(build): move BuildContext to file, add BuildSuffix and CommitHash configuration options 2026-09-04 10:46:34 +10:00
1491a26a3e chore(build-props): Bump version number to 0.0.1 2026-09-04 10:33:11 +10:00
479c433765 build(git-provider): Split bundle archive creation into separate task, update default task chain 2026-09-04 09:49:01 +10:00
a399b29df3 build(git-provider): Create bundle zip for module 2026-09-04 09:29:15 +10:00
5cfc4819fe build(git-provider): Generate initial import script 2026-09-03 22:08:48 +10:00
9665ed1ae3 build(git-provider): Bundle up minimal required module files 2026-09-03 21:47:28 +10:00
270d545e56 build(git-provider): Add GitRepoRegistration commands to build 2026-09-03 20:42:20 +10:00
5448fa3f9e docs(git-provider): Change info box to be normal-markdown instead of Obsidian-markdown 2026-09-02 14:10:44 +10:00
78974c732c docs(git-provider): Update general section to outline case-sensitivity 2026-09-02 11:30:15 +10:00
e2caa604c5 docs(git-provider): Small documentation updates to verb usage and default behaviour for New-GitRepoRegistration 2026-09-02 11:19:17 +10:00
c8717df44a docs(git-provider): Document Remove-GitRepoRegistration 2026-09-02 11:18:36 +10:00
b91a868a54 feat(git-provider): Update Remove-GitRepoRegistration to work from any directory when -Name is given 2026-09-02 11:18:26 +10:00
6550da8c99 docs(git-provider): Update Show-GitRepoRegistration 2026-08-26 17:26:51 +10:00
0fbb0775c1 docs(git-provider): Document Show-GitRepoRegistration 2026-08-26 17:18:34 +10:00
d53e5b2fe4 feat(git-provider): Change alias of NoStack to NoPushLocation 2026-08-26 17:17:47 +10:00
52944936ed docs(git-provider): Document Get-GitRepoRegistration
- update comments for related classes and methods
2026-08-26 14:34:18 +10:00
5e0e4ad2be docs(git-provider): Document New-GitRepoRegistration 2026-08-26 14:17:06 +10:00
46eeb0cb1e feat(git-provider): Update debug output when registering
- output when no name is given
- update message when current location is confirmed to be a git repo
2026-08-26 14:16:48 +10:00
8d64b1aa9e fix(git-provider): Return after successful registration removal 2026-08-26 13:30:18 +10:00
7c6a47bfcb docs(git-provider): Add initial git command documentation
- create solution folder for docs
2026-08-26 13:16:20 +10:00
5528a756f9 refactor(git-provider): Rename noun to GitRepoRegistration 2026-08-25 17:16:06 +10:00
5f04ed4262 feat(git-provider): Set OutputType for GetGitRepoCommand 2026-08-25 14:54:31 +10:00
e49b322ec0 chore(git-provider): Rename class to match file name 2026-08-25 13:41:20 +10:00
50752c0699 chore: Remove unneeded files 2026-08-25 13:40:23 +10:00
9eb58698fa chore(git-provider): Code style 2026-08-25 13:37:47 +10:00
27117ce64b refactor(git-provider): remove SetGitRepoCommand 2026-08-25 11:19:58 +10:00
a666a49db0 feat(git-provider): Implement RemoveGitRepoCommand 2026-08-25 11:18:03 +10:00
cf446fc5dc refactor(git-provider): move IsGitRepo to GitManager, refactor NewGitRepoCommand 2026-08-25 08:41:29 +10:00
c48549117f chore(git-provider): Trim output when getting current branch 2026-08-24 17:35:53 +10:00
7b59b90572 chore(git-provider): Use -C when checking if current directory is a git repo
- move WriteDebug to IsGitRepo
- inline variable for SessionState.Path.CurrentLocation.Path
2026-08-24 17:10:20 +10:00
164df4ecf4 chore(git-provider): Code style 2026-08-24 16:46:35 +10:00
cf336da271 feat(git-provider): Load previous registrations, add debug hook
- add way to redirect debug output
- remove unique constraint on Location
2026-08-24 16:45:42 +10:00
633b35f7e9 feat(git-provider): Initial git registration storage in database
- update PostBuild.ps1 to not delete data directory created during debug
- move sqlite-net-pcl to ModuleCore
2026-08-24 16:02:51 +10:00
caa65ca7c3 feat(git-provider): Initial DatabaseManager 2026-08-24 14:48:48 +10:00
93717b4e43 chore(git-provider): Code style 2026-08-24 14:25:53 +10:00
54d5dd25b9 refactor(git-provider): Rename GitReg to GitRegistration
- rename private GitRegistration to InternalGitRegistration
2026-08-24 10:17:08 +10:00
39022e4186 feat(git-provider): Display current branch when listing registered repos
- change setters to init when parsing git folders
- change Console.WriteLine to Debug.WriteLine in GitManager constructor
2026-08-24 10:12:35 +10:00
52f7260bf4 chore(git-provider): Remove output from command constructors, change informational output to use WriteDebug 2026-08-24 10:02:10 +10:00
48ad65e86c refactor(git-provider): Centralise *-GitRepo noun name 2026-08-23 09:58:16 +10:00
740d35d3e7 feat(git-provider): Initial Show-GitRepo implementation 2026-08-23 09:53:33 +10:00
af5c9e43b6 feat(git-provider): Initial Get-GitRepo implementation 2026-08-23 09:25:45 +10:00
a0e29619a4 chore(git-provider): Move commands to command folder 2026-08-23 09:04:38 +10:00
2b3c7d0153 feat(git-provider): Remove concept of directory registrations and keep it simple 2026-08-21 16:53:54 +10:00
30e103f021 tests(git-provider): Add duplicated repo registration test 2026-08-21 16:53:54 +10:00
cf2660b6b2 tests(git-provider): Add fact tests for empty name registrations 2026-08-10 16:01:15 +10:00
09236e826a tests(git-provider): Add basic registration tests
- make ModuleCore internals visible to ModuleTests
2026-08-10 15:50:24 +10:00
b9f856e716 feat(git-provider): First pass of creating directories from New-GitRepo command
- move GitManager to ModuleCore
- refactor SetGitRepoCommand to own file
2026-08-10 14:54:15 +10:00
1363f6ce02 chore(git-provider): explore setting the location based on a child fragment match 2026-08-07 16:49:30 +10:00
dedea9213b refactor(git-provider): Basic manager class implementation and dummy Set-GitRepo command 2026-08-07 14:58:16 +10:00
6193d30029 feat(git-provider): add Sqlite to PowershellModule
- add CopyLocalLockFileAssemblies to PowershellModule.csproj
- add postbuild script to copy Sqlite files and remove unneeded files in debug output
- add sqlite-net-pcl 1.11.285
2026-08-07 11:05:14 +10:00
830ae2c560 feat(git-provider): add inital implementation for New-GitRepo 2026-08-07 08:51:54 +10:00
1b09d15937 feat(git-provider): More placeholder overrides for future use 2026-08-06 15:31:17 +10:00
05d6eece99 chore(powershell-harness): refactor command code, add git provider to runspace, output command results to console 2026-08-06 14:50:09 +10:00
e6665a588b feat(git-provider): Add basic GitRepo provider 2026-08-06 14:44:24 +10:00
6ae34b3c83 chore(custom-host): Add way to manage PromptForChoice 2026-08-06 14:44:03 +10:00
d1ec31c6e9 chore: add placeholder content when generating module manifest 2026-08-06 14:42:56 +10:00
35 changed files with 1391 additions and 93 deletions

View file

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

View file

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

50
build/BuildContext.cs Normal file
View file

@ -0,0 +1,50 @@
using Cake.Common.IO;
using Cake.Common.IO.Paths;
using Cake.Core;
using Cake.Frosting;
namespace Build;
public class BuildContext : FrostingContext
{
/// <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 string BuildSuffix { get; set; }
public bool DisableCommitHash { get; set; }
public BuildContext(ICakeContext context)
: base(context)
{
BaseSourceLocation = context.Directory("../src");
PowershellModuleProjectDirectory = BaseSourceLocation + context.Directory("PowershellModule");
PowershellModuleCsproj = PowershellModuleProjectDirectory + context.File("PowershellModule.csproj");
PowershellModuleOutputDir = context.Directory("../") + context.Directory("output") + context.Directory("PowershellModule");
var buildScriptDirectory = context.Directory("./Scripts");
CreateModuleManifestScript = buildScriptDirectory + context.File("CreateModuleManifest.ps1");
BuildSuffix = context.Configuration.GetValue(nameof(BuildSuffix));
DisableCommitHash = context.Configuration.GetBoolValue(nameof(DisableCommitHash));
}
}

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.Frosting;
using Cake.Powershell;
namespace Build;
public static class Program public static class Program
{ {
@ -11,45 +8,8 @@ public static class Program
{ {
return new CakeHost() return new CakeHost()
.UseContext<BuildContext>() .UseContext<BuildContext>()
// Uncomment this if you don't want to set the suffix via the run profile program arguments
//.UseCakeSetting(nameof(BuildContext.BuildSuffix), "<your suffix here>")
.Run(args); .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 ( param (
[string]$path, [string]$powershellModuleFileLocation,
[string]$guid, [string]$guid,
[string]$author, [string]$author,
[string[]]$nestedModules, [string[]]$nestedModules,
[string]$rootModule, [string]$rootModule,
[string[]]$cmdletsToExport, [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 = @{ $manifestSplat = @{
Path = "$path" Path = "$powershellModuleFileLocation"
GUID = "$guid" GUID = "$guid"
Author = "$author" Author = "$author"
NestedModules = @($nestedModules) NestedModules = @($nestedModules)
@ -21,4 +24,4 @@ $manifestSplat = @{
} }
New-ModuleManifest @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

@ -17,14 +17,27 @@ public class BuildTask : FrostingTask<BuildContext>
var buildSettings = new DotNetBuildSettings() var buildSettings = new DotNetBuildSettings()
{ {
MSBuildSettings = new DotNetMSBuildSettings() MSBuildSettings = new DotNetMSBuildSettings(),
{
VersionSuffix = "cake"
},
OutputDirectory = context.PowershellModuleOutputDir, 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 // /p:DebugSymbols=false
buildSettings.MSBuildSettings.Properties.Add("DebugSymbols", ["false"]); buildSettings.MSBuildSettings.Properties.Add("DebugSymbols", ["false"]);
// /p:DebugType=None // /p:DebugType=None

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using Cake.Core.Diagnostics; using Cake.Core.Diagnostics;
using Cake.Core.IO; using Cake.Core.IO;
@ -8,20 +9,24 @@ using Cake.Powershell;
namespace Build.Tasks; namespace Build.Tasks;
[TaskName("CopyOutput")] [TaskName("CopyOutput")]
[IsDependeeOf(typeof(CreateBundleArchiveTask))]
public class CopyOutputTask : FrostingTask<BuildContext> public class CopyOutputTask : FrostingTask<BuildContext>
{ {
public override void Run(BuildContext context) public override void Run(BuildContext context)
{ {
var powershellModuleName = "PowershellModule"; var powershellModuleName = "PowershellModule";
// TODO: probably don't create full file locations when I can pass the output dir in and have the script
// make the path
var scriptParams = new var scriptParams = new
{ {
Path = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psd1", PowershellModuleFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psd1",
Guid = Guid.Parse("5cdf4635-edb0-428c-8d9b-92d0bcd47443"), Guid = Guid.Parse("5cdf4635-edb0-428c-8d9b-92d0bcd47443"),
Author = "Me", Author = "Me",
NestedModules = new[] { $"{powershellModuleName}.dll" }, NestedModules = new[] { $"{powershellModuleName}.dll" },
RootModule = $"{powershellModuleName}.psm1", RootModule = $"{powershellModuleName}.psm1",
CmdletsToExport = new[] { "Get-Calendar" }, CmdletsToExport = GetExportedCmdlets(),
ManifestFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psm1" ManifestFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psm1",
OutputLocation = context.PowershellModuleOutputDir,
}; };
var psSettings = new PowershellSettings() 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 // 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 // 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("guid", ToPowershellSafeString(scriptParams.Guid.ToString()));
psSettings.Arguments.Append("author", ToPowershellSafeString(scriptParams.Author)); psSettings.Arguments.Append("author", ToPowershellSafeString(scriptParams.Author));
psSettings.Arguments.Append("nestedModules", $"@({string.Join(",", scriptParams.NestedModules.Select(ToPowershellSafeString))})"); psSettings.Arguments.Append("nestedModules", $"@({string.Join(",", scriptParams.NestedModules.Select(ToPowershellSafeString))})");
psSettings.Arguments.Append("rootModule", ToPowershellSafeString(scriptParams.RootModule)); psSettings.Arguments.Append("rootModule", ToPowershellSafeString(scriptParams.RootModule));
psSettings.Arguments.Append("cmdletsToExport", $"@({string.Join(",", scriptParams.CmdletsToExport.Select(ToPowershellSafeString))})"); psSettings.Arguments.Append("cmdletsToExport", $"@({string.Join(",", scriptParams.CmdletsToExport.Select(ToPowershellSafeString))})");
psSettings.Arguments.Append("manifestFileLocation", ToPowershellSafeString(scriptParams.ManifestFileLocation)); psSettings.Arguments.Append("manifestFileLocation", ToPowershellSafeString(scriptParams.ManifestFileLocation));
psSettings.Arguments.Append("outputDir", ToPowershellSafeString(scriptParams.OutputLocation));
context.StartPowershellFile(context.CreateModuleManifestScript, psSettings); 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); base.Run(context);
} }
private static string ToPowershellSafeString(string unescapedString) => $"'{unescapedString}'"; 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,169 @@
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 = 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}'");
}
private static string GetPowershellModuleVersion(string powershelModuleOutputLocation)
{
var moduleVersionInfo = FileVersionInfo.GetVersionInfo(Path.Combine(powershelModuleOutputLocation, "PowershellModule.dll"));
// It's (almost) impossible to not have a product version tag here. There is a reason why the implementation
// returns a string? but I can't find it and I don't really care too much. If we have null return an empty string
// and append no version to the archive bundle.
if (moduleVersionInfo.ProductVersion == null)
{
return string.Empty;
}
var versionRegex = new Regex(@"(\d+\.\d+\.\d+)(?:\-?([\w\-]+)\+?(\w+)?)?");
var match = versionRegex.Match(moduleVersionInfo.ProductVersion);
if (match.Success)
{
// If we have 4 groups, we've got a version number, suffix, and commit hash, so we return the first 2 as is
// and the commit has capped to 8 characters
if (match.Groups.Count == 4)
{
return $"{match.Groups[1]}-{match.Groups[2]}+{match.Groups[3].Value.Substring(0, 8)}";
}
if (match.Groups.Count == 3)
{
return $"{match.Groups[1]}-{match.Groups[2]}";
}
return $"{match.Groups[1]}";
}
return string.Empty;
}
}

View file

@ -6,7 +6,9 @@ namespace Build.Tasks;
// Consider this the "entry" point for builds, task order is defined by a chain of IsDependentOn // Consider this the "entry" point for builds, task order is defined by a chain of IsDependentOn
[TaskName("Default")] [TaskName("Default")]
[IsDependentOn(typeof(BuildTask))]
[IsDependentOn(typeof(CopyOutputTask))] [IsDependentOn(typeof(CopyOutputTask))]
[IsDependentOn(typeof(CreateBundleArchiveTask))]
public class DefaultTask : FrostingTask public class DefaultTask : FrostingTask
{ {
public override void Run(ICakeContext context) public override void Run(ICakeContext context)

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

@ -0,0 +1,64 @@
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();
}
}
public void InConnection(Action<SQLiteConnection> dbAction)
{
using var conn = new SQLiteConnection(_databaseLocation.FullName);
dbAction(conn);
}
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>
/// 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> <PropertyGroup>
<VersionPrefix>0.0.0</VersionPrefix> <VersionPrefix>0.0.1</VersionPrefix>
<VersionSuffix>dev</VersionSuffix> <VersionSuffix>dev</VersionSuffix>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View file

@ -0,0 +1,382 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using ModuleCore.Database;
using ModuleCore.Git.Models;
using SQLite;
namespace ModuleCore.Git;
// TODO: better name for this
public class GitManager
{
private static readonly Lazy<GitManager> GitManagerInstance = new(() => new GitManager());
private static Action<string>? _debugWriterDelegate;
private readonly DatabaseManager _db;
private readonly ConcurrentDictionary<string, InternalGitRegistration> _registrations;
private GitManager()
{
_registrations = new ConcurrentDictionary<string, InternalGitRegistration>();
_db = new DatabaseManager("git.db");
InitialiseRegistrations();
}
public static GitManager Instance => GitManagerInstance.Value;
/// <summary>
/// Always returns a new clean instance of GitManager
/// </summary>
internal static GitManager InternalFreshInstance => new();
/// <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.");
});
}
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();
}
public string GetRepo(string? registeredName)
{
if (string.IsNullOrEmpty(registeredName))
{
throw new Exception("Name cannot be null");
}
if (_registrations.TryGetValue(registeredName, out var registration))
{
return registration.Location;
}
throw new Exception($"No git repo has been registered with the name {registeredName}");
}
/// <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: not fully decided on if I want this feature or not, but keeping it in for now
private string GetCurrentBranch()
{
var now = DateTime.Now;
// git branch should be quick enough that even with a large number of registrations this shouldn't be that slow
// when doing Get-GitRepo, but regardless we still only get the current branch via git if it's been some amount
// of time since the last time we did.
if (now.Ticks < _nextCheckTime)
{
return _currentBranch;
}
// use -C for the git command so we don't need to set the working directory and the git command can be run
// from anywhere against the appropriate location
var ps = new ProcessStartInfo("git",
["-C", Location, "branch", "--show-current"])
{
RedirectStandardOutput = true,
RedirectStandardError = true,
};
// If the user doesn't have git on their path, this will throw an exception that I don't have to do anything
// special with, it'll be unhandled and powershell will handle it
var gitProcess = Process.Start(ps);
// This probably shouldn't be possible? Not really sure of the conditions where the process could be started
// but return null, but I'm going to consider that unrecoverable error territory
if (gitProcess is null)
{
throw new Exception("git failed to start");
}
gitProcess.WaitForExit();
if (!gitProcess.StandardOutput.EndOfStream)
{
_currentBranch = gitProcess.StandardOutput.ReadToEnd();
}
if (!gitProcess.StandardError.EndOfStream)
{
_currentBranch = gitProcess.StandardError.ReadToEnd();
}
// Set the next check to be in the future so we don't hold up any list commands every time.
_nextCheckTime = now.AddMinutes(15).Ticks;
// The branch name could (will) have a newline character at the end, so we trim that off
return _currentBranch.Trim();
}
}
}
/// <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

@ -0,0 +1,8 @@
namespace ModuleCore.Git.Models;
public class GitRegistration
{
public required string Name { get; set; }
public required string Location { get; set; }
public required string CurrentBranch { get; set; }
}

View file

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

View file

@ -121,6 +121,19 @@ public class CustomUiHost : PSHostUserInterface
public string Output => output.ToString(); 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) 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."); 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) 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) 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.Management.Automation.Runspaces;
using System.Text; using System.Text;
using PowershellModule.Calendar; using PowershellModule.Calendar;
using PowershellModule.Git;
namespace PowershellHarness; namespace PowershellHarness;
@ -36,8 +37,26 @@ class Program
var host = new CustomHost(Console.WindowWidth); var host = new CustomHost(Console.WindowWidth);
var runspace = InitialisePowershellHost(host); 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>()) foreach (var day in Enum.GetValues<DayOfWeek>())
{ {
InvokeCommand(runspace, GetCalendarCommand.FullName, [ InvokeCommand(runspace, GetCalendarCommand.FullName, [
@ -48,11 +67,15 @@ class Program
]); ]);
} }
// InvokeCommand(runspace, GetCalendarCommand.FullName, [ InvokeCommand(runspace, GetCalendarCommand.FullName, [
// new CommandParameter(nameof(GetCalendarCommand.MarkedDaySymbol), "🤫"), new CommandParameter(nameof(GetCalendarCommand.MarkedDaySymbol), "🤫"),
// new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), DayOfWeek.Saturday) 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.StartOfWeek), "Sunday")]);
}
private static void TestGitProvider(Runspace runspace)
{
} }
private static CommandParameter CreateCommand(string name, string? argument = null) private static CommandParameter CreateCommand(string name, string? argument = null)
@ -97,6 +120,10 @@ class Program
var getCalendarCommand = new SessionStateCmdletEntry(GetCalendarCommand.FullName, typeof(GetCalendarCommand), null); var getCalendarCommand = new SessionStateCmdletEntry(GetCalendarCommand.FullName, typeof(GetCalendarCommand), null);
initialSessionState.Commands.Add(getCalendarCommand); 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 // Create a runspace from the state, open and return it
var runspace = RunspaceFactory.CreateRunspace(host, initialSessionState); var runspace = RunspaceFactory.CreateRunspace(host, initialSessionState);
@ -115,15 +142,28 @@ class Program
using var pipeline = runspace.CreatePipeline(); using var pipeline = runspace.CreatePipeline();
// using var powershell = PowerShell.Create(runspace); // 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); var cmd = new Command(command);
if (parameters is not null) 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); cmd.Parameters.Add(commandParameter);
} }
} }
sb.AppendLine();
pipeline.Commands.Add(cmd); pipeline.Commands.Add(cmd);
// powershell.Commands.AddCommand(cmd); // powershell.Commands.AddCommand(cmd);
@ -133,8 +173,11 @@ class Program
// var results = powershell.Invoke(); // var results = powershell.Invoke();
foreach (var result in results) foreach (var result in results)
{ {
Console.Write(result); sb.AppendLine(result.ToString());
// Console.WriteLine(result);
} }
Console.WriteLine(sb);
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<VersionPrefix>0.0.0</VersionPrefix> <VersionPrefix>0.0.1</VersionPrefix>
<VersionSuffix>dev</VersionSuffix> <VersionSuffix>dev</VersionSuffix>
</PropertyGroup> </PropertyGroup>
</Project> </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 = GitManager.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
{
GitManager.SetDebugWriter(WriteDebug);
// Test that we're in a git repo first. If we aren't (or git isn't available), this method will throw
// so we don't need to handle for null (yet).
var repoFolder = GitManager.IsGitRepo(SessionState.Path.CurrentLocation.Path);
if (string.IsNullOrWhiteSpace(Name))
{
WriteDebug("No name given for registration, defaulting to git folder root.");
}
GitManager.Instance.RegisterRepo(repoFolder.Directory, Name ?? repoFolder.Folder);
GitManager.ClearDebugWriter();
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
{
GitManager.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)
? GitManager.IsGitRepo(SessionState.Path.CurrentLocation.Path).Folder
: Name;
GitManager.Instance.UnregisterRepo(registrationNameToRemove);
// Removing a registration works similar to registering a new one - we either remove by exact name, or by
// the folder if no name is given (so a user can remove a registration from a git repo they're currently in)
GitManager.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 = GitManager.Instance.GetRepo(Name);
// By default instead of doing the same as cd, we instead do pushd so a user can popd straight back to where
// they came from.
// TODO: incorporate this into the custom prompt when I develop that
if (!NoStack)
{
// Push the current location to the stack
// TODO: support named stacks. PowerShell *-Location commands support named stacks, but I don't personally
// use them myself so I haven't implemented them initially. I would like to in the future though, but right
// now it's low value to me.
// https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/set-location?view=powershell-7.6#example-4-set-the-current-location-to-a-named-stack
SessionState.Path.PushCurrentLocation(null);
}
SessionState.Path.SetLocation(location);
base.BeginProcessing();
}
}

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

@ -5,6 +5,7 @@
<AssemblyName>PowershellModule</AssemblyName> <AssemblyName>PowershellModule</AssemblyName>
<LangVersion>latestmajor</LangVersion> <LangVersion>latestmajor</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -17,4 +18,8 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ModuleCore\ModuleCore.csproj" /> <ProjectReference Include="..\ModuleCore\ModuleCore.csproj" />
</ItemGroup> </ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="pwsh.exe -file &quot;$(ProjectDir)PostBuild.ps1&quot; $(TargetDir)" />
</Target>
</Project> </Project>

View file

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

View file

@ -0,0 +1,117 @@
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);
}
[Fact]
public void DuplicateRepoRegistrationShouldFail()
{
Settings.UseFileName(nameof(RepoRegistrationWithWhitespaceName));
var gitManager = GitManager.InternalFreshInstance;
var testRepoAbsolutePath = "Test:/some/test/repo";
string[] paths = ["test", "nested", "path"];
var names = (NormalSeparator: string.Join(Path.DirectorySeparatorChar, paths), AltSeparator: string.Join(Path.AltDirectorySeparatorChar, paths));
var firstRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, names.NormalSeparator);
// TODO: make nested registrations fail in both directions and test
var secondRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, Path.Combine(paths[..1]));
Assert.Equal(Path.Combine(paths), firstRegistration);
Assert.Equal(Path.Combine(paths[..1]), secondRegistration);
Assert.Throws<Exception>(() => gitManager.RegisterRepo(testRepoAbsolutePath, Path.Combine(paths[..1])));
}
[Fact]
public void DuplicateRepoRegistrationDifferentSlashShouldNotFail()
{
Settings.UseFileName(nameof(RepoRegistrationWithWhitespaceName));
var gitManager = GitManager.InternalFreshInstance;
var testRepoAbsolutePath = "Test:/some/test/repo";
string[] paths = ["test", "nested", "path"];
var names = (NormalSeparator: string.Join(Path.DirectorySeparatorChar, paths), AltSeparator: string.Join(Path.AltDirectorySeparatorChar, paths));
var firstRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, names.NormalSeparator);
// TODO: make nested registrations fail in both directions and test
var secondRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, Path.Combine(paths[..1]));
var differentPathSeparatorRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, names.AltSeparator);
Assert.Equal(Path.Combine(paths), firstRegistration);
Assert.Equal(Path.Combine(paths[..1]), secondRegistration);
Assert.Equal(names.AltSeparator, differentPathSeparatorRegistration);
}
}

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