Compare commits

..
70 changed files with 1 additions and 3147 deletions

4
.gitattributes vendored
View file

@ -1,4 +0,0 @@
*.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

2
.gitignore vendored
View file

@ -6,5 +6,3 @@ riderModule.iml
# Don't care about IDE specific things
/.idea
*.DotSettings.user
# Don't care about files created from the build script
output/

View file

@ -1,18 +0,0 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</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" />
<PackageVersion Include="PowerShellStandard.Library" Version="5.1.1" />
<PackageVersion Include="System.Management.Automation" Version="7.6.4" />
<PackageVersion Include="Cake.Frosting" Version="6.2.0" />
<PackageVersion Include="Cake.Powershell" Version="4.0.0" />
<PackageVersion Include="Verify.XunitV3" Version="31.27.0" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
</Project>

View file

@ -1,23 +0,0 @@
<Solution>
<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>

View file

@ -1,2 +0,0 @@
dotnet run --project build/Build.csproj -- $args
exit $LASTEXITCODE;

View file

@ -1 +0,0 @@
dotnet run --project ./build/Build.csproj -- "$@"

View file

@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RunWorkingDirectory>$(MSBuildProjectDirectory)</RunWorkingDirectory>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cake.Frosting"/>
<PackageReference Include="Cake.Powershell"/>
<PackageReference Include="System.IO.Packaging" />
<PackageReference Include="System.Security.Cryptography.Xml" />
</ItemGroup>
</Project>

View file

@ -1,61 +0,0 @@
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));
}
}

View file

@ -1,48 +0,0 @@
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,13 +0,0 @@
using Cake.Frosting;
namespace Build;
public static class Program
{
public static int Main(string[] args)
{
return new CakeHost()
.UseContext<BuildContext>()
.Run(args);
}
}

View file

@ -1,27 +0,0 @@
param (
[string]$powershellModuleFileLocation,
[string]$guid,
[string]$author,
[string[]]$nestedModules,
[string]$rootModule,
[string[]]$cmdletsToExport,
[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 = "$powershellModuleFileLocation"
GUID = "$guid"
Author = "$author"
NestedModules = @($nestedModules)
RootModule = "$rootModule"
CmdletsToExport = @($cmdletsToExport)
FunctionsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
}
New-ModuleManifest @manifestSplat
New-Item "$manifestFileLocation" -ItemType File -Value "# This file is run when Import-Module `"PowershellModule`" is called"

View file

@ -1,49 +0,0 @@
using Cake.Common.Tools.DotNet;
using Cake.Common.Tools.DotNet.Build;
using Cake.Common.Tools.DotNet.MSBuild;
using Cake.Core.Diagnostics;
using Cake.Frosting;
namespace Build.Tasks;
[TaskName("Build")]
[IsDependentOn(typeof(CleanTask))]
[IsDependeeOf(typeof(CopyOutputTask))]
public class BuildTask : FrostingTask<BuildContext>
{
public override void Run(BuildContext context)
{
context.Log.Information($"Building: {context.PowershellModuleCsproj}");
var buildSettings = new DotNetBuildSettings()
{
MSBuildSettings = new DotNetMSBuildSettings(),
OutputDirectory = context.PowershellModuleOutputDir,
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
buildSettings.MSBuildSettings.Properties.Add("DebugType", ["None"]);
context.DotNetBuild(context.PowershellModuleCsproj, buildSettings);
base.Run(context);
}
}

View file

@ -1,23 +0,0 @@
using Cake.Common;
using Cake.Common.Tools.DotNet;
using Cake.Core.Diagnostics;
using Cake.Frosting;
namespace Build.Tasks;
[TaskName("Clean")]
public class CleanTask : FrostingTask<BuildContext>
{
public override void Run(BuildContext context)
{
context.DotNetClean(context.PowershellModuleProjectDirectory);
var outDir = context.FileSystem.GetDirectory(context.PowershellModuleOutputDir);
if (outDir.Exists)
{
outDir.Delete(true);
}
base.Run(context);
}
}

View file

@ -1,70 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cake.Core.Diagnostics;
using Cake.Core.IO;
using Cake.Frosting;
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
{
PowershellModuleFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psd1",
Guid = Guid.Parse("5cdf4635-edb0-428c-8d9b-92d0bcd47443"),
Author = "Me",
NestedModules = new[] { $"{powershellModuleName}.dll" },
RootModule = $"{powershellModuleName}.psm1",
CmdletsToExport = GetExportedCmdlets(),
ManifestFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psm1",
OutputLocation = context.PowershellModuleOutputDir,
};
var psSettings = new PowershellSettings()
{
Arguments = new ProcessArgumentBuilder(),
};
// 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("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 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

@ -1,136 +0,0 @@
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,19 +0,0 @@
using Cake.Core;
using Cake.Core.Diagnostics;
using Cake.Frosting;
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)
{
base.Run(context);
}
}

View file

@ -1,54 +0,0 @@
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

@ -1,133 +0,0 @@
# 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,261 +0,0 @@
using System.Globalization;
using System.Text;
namespace ModuleCore.Calendar;
public sealed class CalendarGenerator
{
public const string DefaultMarkedOffSymbol = "x";
public const DayOfWeek DefaultStartOfWeek = DayOfWeek.Monday;
private readonly string _markedOffSymbol;
private readonly DayOfWeek _startOfWeek;
/// <summary>
/// Holds configured day abbreviations, based on <see cref="CultureInfo"/>.
/// <para>
/// Using english as the culture: Sunday = Sun, Monday = Mon and so on. This lookup also starts with
/// Sunday as the first day to match the <see cref="DayOfWeek"/> enum starting with Sunday.
/// </para>
/// </summary>
private readonly List<string> _daysOfWeekLookup;
/// <summary>
/// Used to work out how to pad columns for each day, based on the longest abbreviated day
/// </summary>
private readonly int _dayWidth;
private readonly CultureInfo _cultureInfo;
private readonly string _rolloverDaySymbol = "";
public CalendarGenerator(string? markedDaySymbol = null, DayOfWeek? startOfWeek = null)
{
_markedOffSymbol = markedDaySymbol ?? DefaultMarkedOffSymbol;
_startOfWeek = startOfWeek ?? DefaultStartOfWeek;
// Generate localised day of week lookup table
_daysOfWeekLookup = new List<string>(7);
_dayWidth = _markedOffSymbol.Length;
_cultureInfo = CultureInfo.CurrentCulture;
for (var i = 0; i < 7; i++)
{
var localisedDayAbbreviation = _cultureInfo.DateTimeFormat.GetAbbreviatedDayName((DayOfWeek)i);
// For unicode languages (such as Japanese and Chinese), the length of a day will be reported as 1,
// but will display in a way that makes them look short or offset.
// eg:
// ┌───────────────────────────┐
// │ 7月 │
// ├───┬───┬───┬───┬───┬───┬───┤
// │ 月 │ 火 │ 水 │ 木 │ 金 │ 土 │ 日 │
// ├───┴───┴───┴───┴───┴───┴───┤
// There's not really an easy fix I know of (yet) to resolve this (and I'm not really too bothered by it yet)
// so unfortunately it has to stay as a known bug as is currently
if (localisedDayAbbreviation.Length > _dayWidth)
{
_dayWidth = localisedDayAbbreviation.Length;
}
_daysOfWeekLookup.Add(localisedDayAbbreviation);
}
// Increase the width by 2 to include a space on either side
_dayWidth += 2;
}
/// <summary>
/// Renders the configured calendar for the given datetime
/// </summary>
/// <param name="month"></param>
/// <param name="todayOverride">Defaults to <see cref="DateTime.Now"/>, used to display elapsed days</param>
/// <returns></returns>
public string Render(DateTime month, DateTime? todayOverride = null)
{
var sb = new StringBuilder();
// Generate the header for the month/days of week
GenerateHeader(sb, month);
var calendarForMonth = GenerateCalendarForMonth(month, _markedOffSymbol, _startOfWeek, _rolloverDaySymbol, todayOverride ?? DateTime.Now);
for (var i = 0; i < calendarForMonth.WeeksInMonth; i++)
{
sb.Append("│");
for (var j = 0; j < 7; j++)
{
var index = j + i * 7;
// We align days to the right of their box, 1 off from the border.
// Sure I could center them, but honestly this is better to read.
sb.Append(calendarForMonth.Calendar[index].PadLeft(_dayWidth - 1));
sb.Append(" │");
}
sb.AppendLine();
}
// Cap the calendar off
GenerateSpacer(sb, "└", "─", "┴", "┘", true);
return sb.ToString();
}
private void GenerateHeader(StringBuilder sb, DateTime month)
{
// Top border for the calendar, with padder and seperator the same as there's no columns immediately
// under it
GenerateSpacer(sb, "┌", "─", "─", "┐");
var monthString = month.ToString("MMMM yyyy", _cultureInfo);
// total width of the calendar is width of the localised day calculated in the constructor, plus 7 extra
// to account for separators for each day.
// It shouldn't need to be said that the 7 here stands for days in a week
var calendarWidth = _dayWidth * 7 + 7;
// Calculate the offsets for
var offsets = (
left: Math.Floor(calendarWidth / 2.0 + monthString.Length / 2.0),
right: Math.Ceiling(calendarWidth / 2.0 - monthString.Length / 2.0)
);
sb.Append("│")
.Append(monthString.PadLeft((int)offsets.left))
.AppendLine("│".PadLeft((int)offsets.right));
// Top border for the week
GenerateSpacer(sb, "├", "─", "┬", "┤");
// Work out the week display based on start of week
var d = 0;
do
{
// Index into the day of week, wrapping if needed from the configured start of the week.
// The DayOfWeek enum starts with Sunday, so by default the start of the week is 0,
// But most calendars visually start with Monday as the start of the week which is our default start
// of week.
// We also pad the string to the width of the day (minus 2 for spaces either side of the day), if
// the marked day symbol is a long string.
// Why did I decide to support arbitrary length marked days? Why not?
sb.Append($"│ {_daysOfWeekLookup[(d + (int)_startOfWeek) % 7].PadLeft(_dayWidth - 2)} ");
d++;
} while (d < 7);
// End the week block
sb.AppendLine("│");
// bottom border
GenerateSpacer(sb, "├", "─", "┼", "┤");
}
private void GenerateSpacer(StringBuilder sb, string leftCap, string padder, string seperator, string rightCap, bool noNewLine = false)
{
sb.Append(leftCap);
for (var i = 0; i < 7; i++)
{
for (var j = 0; j < _dayWidth; j++)
{
sb.Append(padder);
}
if (i < 6)
{
sb.Append(seperator);
}
}
if (noNewLine)
{
sb.Append(rightCap);
}
else
{
sb.AppendLine(rightCap);
}
}
private MonthCalendar GenerateCalendarForMonth(DateTime month, string markedOffSymbol, DayOfWeek calendarStartOfWeek, string rolloverDaySymbol, DateTime today)
{
// TODO: use startOfWeek to offset the start of the calendar later
// TODO: I think I meant the calendar name header?
// Reset the month to the start
var startOfMonth = new DateTime(month.Year, month.Month, 1);
// If the visual start of the month is before the actual start day of the month, we need to visually shift the
// calendar "back" a week, and pad appropriately.
// Eg, if the start of the month is Sunday, but our visual start of the week is a Monday, then we need to
// make a new week to display Monday as the start, and then pad blank days to Sunday.
// This could probably be calculated better to avoid annoying to read logic but I'm drunk at this moment so the
// brain ain't there.
// For what its worth, I wasn't drunk for the rest of the code in this file, as hard as that is to believe!
var daysToPadToStartMonth = startOfMonth.DayOfWeek >= calendarStartOfWeek
? startOfMonth.DayOfWeek - calendarStartOfWeek
: 7 - (calendarStartOfWeek - startOfMonth.DayOfWeek);
var daysInMonth = DateTime.DaysInMonth(month.Year, month.Month);
// TODO: a lot of this could probably be simplified by simply taking the start day of the week, and then
// based on where it would be in the first week, work out if the month is 4, 5 or 6 weeks from that.
// all this needs is a pre-calculated table and that's it.
// Weeks have 7 days so there's only a fixed permutation of weeks based on the day of the week a month
// starts on, and months have anywhere from 28 to 31 days
// Increase the days by the previous months
var paddedDaysInMonth = daysInMonth + daysToPadToStartMonth;
// How many days to add to the end to make the absolute number of days (including any
// days that would visually roll over on a calendar)
// eg, if there are 2 "roll over" days from the previous month and this month has 31 days,
// Work out how many days we have left from 7, then get the _final_ number of days to make it a multiple
// _of_ 7.
// So this results in (33%7) == 5 - 7 == 2 + 33 == 35 % 7 == 0 which means we have a "complete" month
// including next month roll over.
// Visually we won't be showing those roll over days but it makes things easier if I decide to later.
// If the days in the month plus padded days is already a multiple of 7, we don't need to add any more days.
var daysToPadToEndMonth = paddedDaysInMonth % 7 != 0
? 7 - paddedDaysInMonth % 7
: 0;
paddedDaysInMonth += daysToPadToEndMonth;
// Round to the nearest week after padding for the previous months days.
// Naively allowing a loss of precision via int division because the paddedDaysInMonth should always
// be a multiple of 7.
// "should always" is going to be found not as true later
var weeksInMonth = paddedDaysInMonth / 7;
var cal = new List<string>();
for (var i = 0; i < daysToPadToStartMonth; i++)
{
cal.Add(rolloverDaySymbol);
}
// If we're in the same year and month, any days already elapsed should be marked off,
// otherwise we display all days.
// If the DateTime we're rendering is in the past we render all days as marked off
// TODO: maybe don't do that and display the calendar differently
var hideElapsedDays = today.Year == month.Year && today.Month == month.Month;
for (var i = 1; i < daysInMonth + 1; i++)
{
// if we're marking off elapsed days and we're not today or ahead, 'mark' it off
if (hideElapsedDays && i < today.Day)
{
cal.Add(markedOffSymbol);
}
else
{
cal.Add($"{i}");
}
}
// pad the rollover days at the end of the calendar
for (var i = 0; i < daysToPadToEndMonth; i++)
{
cal.Add(rolloverDaySymbol);
}
return new MonthCalendar
{
Calendar = cal,
WeeksInMonth = weeksInMonth
};
}
}
public class MonthCalendar
{
public List<string> Calendar { get; set; } = [];
public int WeeksInMonth { get; set; }
}

View file

@ -1,86 +0,0 @@
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 +0,0 @@
<Project>
<PropertyGroup>
<VersionPrefix>0.0.1</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
</PropertyGroup>
</Project>

View file

@ -1,393 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,17 +0,0 @@
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,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<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

@ -1,15 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latestmajor</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ModuleCore\ModuleCore.csproj" />
</ItemGroup>
</Project>

View file

@ -1,20 +0,0 @@
using System.Text;
using ModuleCore.Calendar;
namespace ModuleHarness;
class Program
{
static void Main(string[] args)
{
Console.OutputEncoding = Encoding.Unicode;
var a = new CalendarGenerator();
var lastMonth = a.Render(DateTime.Now.AddMonths(-1));
var now = a.Render(DateTime.Now);
var august = a.Render(DateTime.Now.AddMonths(1));
Console.Write(lastMonth);
Console.Write(now);
Console.Write(august);
}
}

View file

@ -1,236 +0,0 @@
using System.Collections.ObjectModel;
using System.Globalization;
using System.Management.Automation;
using System.Management.Automation.Host;
using System.Security;
using System.Text;
namespace PowershellHarness;
// A whole bunch of empty implementations just so cmdlets have access to anything within Host.Ui (and probably any cmdlets
// that prompt for information later)
// https://github.com/leechristensen/OffensivePowerShellTasking/blob/master/OffensivePowerShellTasking/CustomPSHost.cs#L13
public class CustomHost : PSHost
{
public override string Name => "Custom Host";
public override Version Version { get; } = new Version(0, 0, 0, 0);
public override Guid InstanceId { get; } = Guid.NewGuid();
private CustomUiHost _ui = new CustomUiHost();
public override CustomUiHost UI => _ui;
public override CultureInfo CurrentCulture { get; } = CultureInfo.CurrentCulture;
public override CultureInfo CurrentUICulture { get; } = CultureInfo.CurrentUICulture;
public CustomHost(int width, int height = 100)
{
_ui.RawUI.WindowSize = new() { Width = width, Height = height };
}
public override void EnterNestedPrompt()
{
throw new NotImplementedException("EnterNestedPrompt 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.");
}
public override void ExitNestedPrompt()
{
throw new NotImplementedException("ExitNestedPrompt 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.");
}
public override void NotifyBeginApplication()
{
}
public override void NotifyEndApplication()
{
}
public override void SetShouldExit(int exitCode)
{
}
}
// https://github.com/leechristensen/OffensivePowerShellTasking/blob/d1b498d874948f41e6dc053204c511fa6fc11c9c/OffensivePowerShellTasking/CustomPSHostUserInterface.cs#L8
public class CustomUiHost : PSHostUserInterface
{
// The only stuff that really matters to expose a host for any cmdlets
private readonly CustomRawUiHost _rawUI = new CustomRawUiHost();
public override CustomRawUiHost RawUI => _rawUI;
// Replace StringBuilder with whatever your preferred output method is (e.g. a socket or a named pipe)
public StringBuilder output { get; set; }
public CustomUiHost()
{
output = new StringBuilder();
}
public CustomUiHost(ref StringBuilder sb)
{
output = sb;
}
public override void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value)
{
//output.Append("!").Append(value);
}
public override void WriteLine()
{
//output.Append("!").Append("\n");
}
public override void WriteLine(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value)
{
//output.Append("!").Append(value + "\n");
}
public override void Write(string value)
{
//output.Append("!").Append(value);
}
public override void WriteDebugLine(string message)
{
//output.Append("!").AppendLine("DEBUG: " + message);
}
public override void WriteErrorLine(string value)
{
//output.Append("!").AppendLine("ERROR: " + value);
}
public override void WriteLine(string value)
{
//output.Append("!").AppendLine(value);
}
public override void WriteVerboseLine(string message)
{
//output.Append("!").AppendLine("VERBOSE: " + message);
}
public override void WriteWarningLine(string message)
{
//output.Append("!").AppendLine("WARNING: " + message);
}
public override void WriteProgress(long sourceId, ProgressRecord record)
{
}
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.");
}
public override int PromptForChoice(string caption, string message, System.Collections.ObjectModel.Collection<ChoiceDescription> choices, int defaultChoice)
{
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)
{
throw new NotImplementedException("PromptForCredential1 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.");
}
public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName)
{
throw new NotImplementedException("PromptForCredential2 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.");
}
public override string ReadLine()
{
throw new NotImplementedException("ReadLine 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.");
}
public override SecureString ReadLineAsSecureString()
{
throw new NotImplementedException("ReadLineAsSecureString 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.");
}
}
// https://github.com/leechristensen/OffensivePowerShellTasking/blob/master/OffensivePowerShellTasking/CustomPSRHostRawUserInterface.cs#L8
public class CustomRawUiHost : PSHostRawUserInterface
{
public override ConsoleColor BackgroundColor { get; set; } = ConsoleColor.Black;
public override Size BufferSize { get; set; } = new Size { Width = 100, Height = 1000 };
public override Coordinates CursorPosition { get; set; } = new Coordinates { X = 0, Y = 0 };
public override int CursorSize { get; set; } = 1;
public override void FlushInputBuffer()
{
throw new NotImplementedException("FlushInputBuffer is not implemented.");
}
public override ConsoleColor ForegroundColor { get; set; } = ConsoleColor.White;
public override BufferCell[,] GetBufferContents(Rectangle rectangle)
{
throw new NotImplementedException("GetBufferContents is not implemented.");
}
public override bool KeyAvailable
{
get { throw new NotImplementedException("KeyAvailable is not implemented."); }
}
public override Size MaxPhysicalWindowSize { get; } = new Size
{
Width = int.MaxValue,
Height = int.MaxValue
};
public override Size MaxWindowSize { get; } = new Size { Width = 100, Height = 100 };
public override KeyInfo ReadKey(ReadKeyOptions options)
{
throw new NotImplementedException("ReadKey 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.");
}
public override void ScrollBufferContents(Rectangle source, Coordinates destination, Rectangle clip, BufferCell fill)
{
throw new NotImplementedException("ScrollBufferContents is not implemented");
}
public override void SetBufferContents(Rectangle rectangle, BufferCell fill)
{
throw new NotImplementedException("SetBufferContents is not implemented.");
}
public override void SetBufferContents(Coordinates origin, BufferCell[,] contents)
{
throw new NotImplementedException("SetBufferContents is not implemented");
}
public override Coordinates WindowPosition { get; set; } = new Coordinates { X = 0, Y = 0 };
public override Size WindowSize { get; set; } = new Size { Width = 120, Height = 100 };
public override string WindowTitle { get; set; } = "";
}

View file

@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.PowerShell.SDK" />
<PackageReference Include="System.Security.Cryptography.Xml" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PowershellModule\PowershellModule.csproj" />
</ItemGroup>
</Project>

View file

@ -1,188 +0,0 @@
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using System.Text;
using PowershellModule.Calendar;
using PowershellModule.Git;
namespace PowershellHarness;
// https://github.com/FuseCP/FuseCP/blob/278a19dc06949600f25a1b4ed74d0419a8fa3fc2/FuseCP/Sources/FuseCP.Providers.HostedSolution.SfB2015/SfBBase.cs#L224
// some useful code in here tbh
class Program
{
static void Main(string[] args)
{
// I've gotta work out how to get the rider terminal to act closer to powershell because .PadLeft in powershell
// will correctly output lines when output with WriteOutput, but the Jetbrains debug worker (or whatever
// it is that rider spawns), has none of that and operates in its own world.
// Which is fine if you never want new lines.
// Ideally you'd just launch pwsh.exe and attach to that, but _that_ has its own issues as well.
// The new line issue might be related to how I've set up the host seeing as I just did a bunch of copy paste shit
// from something I found on github to get it across the line so I could debug it.
// I'll eventually revisit those hosts and rewrite them properly once I start adding more commands, and if I care.
// Threads & variables has all the visuals I need after all.
#if DEBUG
if (!System.Diagnostics.Debugger.IsAttached)
{
Console.BackgroundColor = ConsoleColor.Yellow;
Console.WriteLine(CenterText("Running without a debugger attached may result in lines not being output correctly"));
Console.ResetColor();
}
#endif
Console.WriteLine(CenterText(" PowerShell Debug Harness "));
Console.WriteLine($"Reported console window size: {Console.WindowWidth}x{Console.WindowHeight}");
Console.WriteLine($"Reported console buffer size: {Console.BufferWidth}x{Console.BufferHeight}");
Console.WriteLine(CenterText(" WIDTH ", '-'));
var host = new CustomHost(Console.WindowWidth);
var runspace = InitialisePowershellHost(host);
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, [
CreateCommand(nameof(GetCalendarCommand.MarkedDaySymbol), "faker"),
CreateCommand(nameof(GetCalendarCommand.StartOfWeek), day.ToString()),
CreateCommand(nameof(GetCalendarCommand.Date), "22/6/26"),
// CreateCommand(nameof(GetCalendarCommand.AlignRight))
]);
}
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)
{
if (argument is not null)
{
return new CommandParameter(name, argument);
}
return new CommandParameter(name);
}
static string CenterText(string text, char paddingChar = ' ')
{
return text.PadLeft((Console.WindowWidth + text.Length) / 2, paddingChar).PadRight(Console.WindowWidth, paddingChar);
}
/// <summary>
/// Initialises a PowerShell session and returns an open Runspace
/// </summary>
/// <param name="host"></param>
/// <returns></returns>
static Runspace InitialisePowershellHost(CustomHost host)
{
// Create the initial session state for the host. Yes, that 2 on the end does indicate that the underlying
// implementation for this is C++
// Welcome to the lands of fuck all documentation and figuring shit out from an increasingly shit internet
// where finding non-slop answers gets harder by the day as people close sources of information to prevent
// scraping. Fuck every single person involved modern AI.
// It wasn't much better finding documentation about the System.Management.* namespace before that, but it's
// definitely a lot worse because of it
var initialSessionState = InitialSessionState.CreateDefault2();
// No idea what the helpFileName should be. As is common with _a lot_ of the System.Management.* namespace,
// fuck all is actually documented with comments, or documented at all!
// From https://learn.microsoft.com/en-us/powershell/scripting/developer/hosting/creating-a-constrained-runspace?view=powershell-7.6
// (which yes, this section _is_ under a legacy category!), it's perfectly fine to leave it as null.
// We also don't really care as we're just doing this so we can test commands without having to deal with Rider
// and it's quirks around runnning a powershell terminal and attaching to it. Yeah it technically works, but
// it's way too common to end up with Rider refusing to build correctly or ensure the right dll is used for the module
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);
// A runspace is technically disposable, but we're not reimplementing a full host and we won't be doing anything
// that would require a fresh runspace multiple times over (yet), so we can just treat the lifetime of the
// disposable as application lifetime
runspace.Open();
return runspace;
}
static void InvokeCommand(Runspace runspace, string command, IEnumerable<CommandParameter>? parameters = null)
{
// Not too sure on the difference of runspace vs PowerShell here. Doesn't seem to make a difference either way
// and this is just a debug harness so it doesn't really matter for now
using var pipeline = runspace.CreatePipeline();
// using var powershell = PowerShell.Create(runspace);
// StringBuilder to store the output of this command including any output results (but not errors yet)
// this is just a rudimentary test and the pwsh debug profile should be used instead as it loads the module
// in a full powershell window with debugger attached. Just no automatic command running sadly.
var sb = new StringBuilder();
sb.Append(command);
var cmd = new Command(command);
if (parameters is not null)
{
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);
try
{
var results = pipeline.Invoke();
// var results = powershell.Invoke();
foreach (var result in results)
{
sb.AppendLine(result.ToString());
// Console.WriteLine(result);
}
Console.WriteLine(sb);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Unable to execute command {command}");
Console.Error.WriteLine(ex.GetBaseException().Message);
}
}
}

View file

@ -1,174 +0,0 @@
using System;
using System.Globalization;
using System.Management.Automation;
using ModuleCore.Calendar;
namespace PowershellModule.Calendar
{
[Cmdlet(VerbsCommon.Get, Noun)]
public class GetCalendarCommand : PSCmdlet
{
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
[Parameter(
Mandatory = false,
Position = 0)]
public string Date { get; set; }
[Parameter(
Mandatory = false,
Position = 1)]
public string MarkedDaySymbol { get; set; }
[Parameter(
Mandatory = false,
Position = 2)]
[ValidateSet("Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday")]
// pass a type in to get dynamic types
// NOTE: the generator gets called for _each_ character input, so ensure the values are idempotent for every
// keydown (and fast), or cached
// [ValidateSet(typeof(StartDayOfWeekGenerator))]
public string StartOfWeek { get; set; }
[Parameter(
Mandatory = false,
Position = 3)]
public SwitchParameter AlignRight { get; set; }
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
// Guaranteed to be initialised in BeginProcessing. If it's not, something is cooked in some other way
private CalendarGenerator _calendar = null!;
private const string Noun = "Calendar";
public const string FullName = $"{VerbsCommon.Get}-{Noun}";
private DateTime _dateToRender = DateTime.Now;
private int _leftPadding;
protected override void BeginProcessing()
{
// This is the first point in script execution where we'll have parameters populated
_calendar = new(
MarkedDaySymbol,
DayOfWeekFromString(StartOfWeek)
);
if (!string.IsNullOrWhiteSpace(Date))
{
_dateToRender = DateTime.ParseExact(Date, CultureInfo.CurrentCulture.DateTimeFormat.GetAllDateTimePatterns(), null);
}
if (AlignRight)
{
_leftPadding = Host.UI.RawUI.WindowSize.Width;
}
}
/// <summary>
/// Returns a <see cref="DayOfWeek"/> from a string.
/// <para>
/// Defaults to the calendars default start of week on null or invalid values.
/// </para>
/// </summary>
/// <param name="dayOfWeek"></param>
/// <returns></returns>
private static DayOfWeek DayOfWeekFromString(string? dayOfWeek)
{
if (dayOfWeek is null)
{
return CalendarGenerator.DefaultStartOfWeek;
}
return dayOfWeek.ToLowerInvariant() switch
{
"sunday" => DayOfWeek.Sunday,
"monday" => DayOfWeek.Monday,
"tuesday" => DayOfWeek.Tuesday,
"wednesday" => DayOfWeek.Wednesday,
"thursday" => DayOfWeek.Thursday,
"friday" => DayOfWeek.Friday,
"saturday" => DayOfWeek.Saturday,
_ => CalendarGenerator.DefaultStartOfWeek
};
}
protected override void ProcessRecord()
{
var calendar = _calendar.Render(_dateToRender);
// From _very_ basic testing, string split allocates the same as making a span and iterating over it.
// I'm assuming this is because a lot of the span code is what string.Split() does internally and the rest
// is trivially optimised out.
// Not too fussed currently but it's most likely because Pad methods also allocate a string, so they end up
// equivalent at the end
// OutputCalendarStringSplit(calendar);
OutputCalendar(calendar);
base.ProcessRecord();
}
private void OutputCalendarStringSplit(string calendar)
{
var splitCalendarLines = calendar.Split(Environment.NewLine);
foreach (var splitLines in splitCalendarLines)
{
// Have to output a newline character here
WriteObject(splitLines.PadLeft(_leftPadding));
}
}
private void OutputCalendar(string calendar)
{
// Work on a span - we avoid using string.Split() as this allocates on certain runtimes
var calendarSpan = calendar.AsSpan();
// Get the end of line for the calendar. We use Environment.NewLine as the calendar is built up using
// StringBuilder which uses Environment.NewLine for new lines so this should be consistent across operating systems
var firstNewlineIndex = calendarSpan.IndexOf(Environment.NewLine);
// Calculate how many lines the calendar is from how many new lines we have.
// Sure we could count the number of \r\n are present, but the last line might not end with
// a new line.
// Conveniently enough, int division works out in our favor here
var lines = calendarSpan.Length / firstNewlineIndex;
// We need to account for the size of the newline so we have an accurate length when outputting each line
// of the calendar below
var newLineLength = firstNewlineIndex + Environment.NewLine.Length;
for (int i = 0; i < lines; i++)
{
// The start of each calendar line offset starts at a position including the length of the newline,
// so in effect the start of the line is the length of the previous line output, plus however many
// characters the newline was that weren't output as we're taking control there
var startOfLine = i * newLineLength;
// End of the line is exclusive of any newline characters
var endOfLine = startOfLine + firstNewlineIndex;
var calendarLine = calendarSpan[startOfLine .. endOfLine];
WriteObject(calendarLine.ToString().PadLeft(_leftPadding));
}
}
}
// Was here to test dynamic day names but most people using the console will be using english command names
// so there's at least some assumption that they understand days of the week in english.
// It sucks but it remains at least consistent with the argument being in english as well.
// public class StartDayOfWeekGenerator : IValidateSetValuesGenerator
// {
// private string[] _cached { get; set; } = new string[7];
//
// public string[] GetValidValues()
// {
// if (_cached is null)
// {
// // _cached = Enumerable.Range(0, r.Next(3, 63)).Select(x => x.ToString()).ToArray();
// }
//
// return _cached;
// }
// }
}

View file

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

View file

@ -1,22 +0,0 @@
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

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

View file

@ -1,41 +0,0 @@
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

@ -1,43 +0,0 @@
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

@ -1,42 +0,0 @@
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

@ -1,82 +0,0 @@
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

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

View file

@ -1,28 +0,0 @@
<#
.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,25 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<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>
<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,48 +0,0 @@
using ModuleCore.Calendar;
using ModuleTests.Calendar.TestData;
using System.Globalization;
namespace ModuleTests.Calendar;
public class BasicRenderTest
{
private static readonly VerifySettings Settings;
static BasicRenderTest()
{
Settings = new VerifySettings();
var testBaseDirectory = Path.Join(TestConstants.SnapshotFolderName, nameof(BasicRenderTest));
Settings.UseDirectory(testBaseDirectory);
Settings.DisableDiff();
}
[Theory]
[ClassData(typeof(CalendarTestDates))]
public Task BasicCalendarRender(DateTime month)
{
Settings.UseFileName($"{month:yyyy-MM-dd}");
var calendar = new CalendarGenerator();
// Render the calendar with the start of the month as the today date so no elapsed marks are shown
var renderedMonth = calendar.Render(month, new DateTime(month.Year, month.Month, 1));
return Verify(renderedMonth, Settings);
}
[Theory]
[ClassData(typeof(CultureCodeTestData))]
public Task CultureInfoCalendarRender(string cultureCode)
{
Settings.UseFileName($"{cultureCode}");
Thread.CurrentThread.CurrentCulture = new CultureInfo(cultureCode);
var month = new DateTime(2026, 8, 1);
var calendar = new CalendarGenerator();
// Render the calendar with the start of the month as the today date so no elapsed marks are shown
var renderedMonth = calendar.Render(month, new DateTime(month.Year, month.Month, 1));
return Verify(renderedMonth, Settings);
}
}

View file

@ -1,86 +0,0 @@
using System.Text;
using ModuleCore.Calendar;
namespace ModuleTests.Calendar;
public class MarkedDayRenderTests
{
private static readonly VerifySettings Settings;
static MarkedDayRenderTests()
{
Settings = new VerifySettings();
var testBaseDirectory = Path.Join(TestConstants.SnapshotFolderName, nameof(MarkedDayRenderTests));
Settings.UseDirectory(testBaseDirectory);
Settings.DisableDiff();
}
[Fact]
public Task DefaultMarkedDayRender()
{
Settings.UseFileName(nameof(DefaultMarkedDayRender));
var date = new DateTime(2026, 8, 1);
var calendar = new CalendarGenerator();
var renderedMonth = calendar.Render(date, date.AddDays(15));
return Verify(renderedMonth, Settings);
}
[Fact]
public Task SimpleMarkedDayRender()
{
Settings.UseFileName(nameof(SimpleMarkedDayRender));
var date = new DateTime(2026, 8, 1);
var calendar = new CalendarGenerator("|");
var renderedMonth = calendar.Render(date, date.AddDays(15));
return Verify(renderedMonth, Settings);
}
[Fact]
public Task LongMarkedDayRender()
{
Settings.UseFileName(nameof(LongMarkedDayRender));
var date = new DateTime(2026, 8, 1);
var calendar = new CalendarGenerator("passed");
var renderedMonth = calendar.Render(date, date.AddDays(15));
return Verify(renderedMonth, Settings);
}
[Fact]
public Task EmojiMarkedDayRender()
{
Settings.UseFileName(nameof(EmojiMarkedDayRender));
var date = new DateTime(2026, 8, 1);
var calendar = new CalendarGenerator("🤫");
var sb = new StringBuilder();
sb.AppendLine("Emojis will display incorrectly in text but output correctly in terminals with UTF-8 encoding.")
.AppendLine(calendar.Render(date, date.AddDays(15)));
return Verify(sb, Settings);
}
[Fact]
public Task DoubleWideMarkedDayRender()
{
Settings.UseFileName(nameof(DoubleWideMarkedDayRender));
var date = new DateTime(2026, 8, 1);
// Using an asian-character to have a test covering the unfortunate reality of character rendering of
// characters that are visually 1 character in width, but render as double width or some other cursed form.
// Written language was a mistake but trying to render it on computers alongside a different one
// is arguably worse.
var calendar = new CalendarGenerator("火");
var sb = new StringBuilder();
sb.AppendLine("Double-wide characters will display incorrectly but still tested until rendering can be improved")
.AppendLine(calendar.Render(date, date.AddDays(15)));
return Verify(sb, Settings);
}
}

View file

@ -1,11 +0,0 @@
┌─────────────────────────────────────────┐
│ June 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │
│ 8 │ 9 │ 10 │ 11 │ 12 │ 13 │ 14 │
│ 15 │ 16 │ 17 │ 18 │ 19 │ 20 │ 21 │
│ 22 │ 23 │ 24 │ 25 │ 26 │ 27 │ 28 │
│ 29 │ 30 │ │ │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘

View file

@ -1,11 +0,0 @@
┌─────────────────────────────────────────┐
│ July 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ │ 1 │ 2 │ 3 │ 4 │ 5 │
│ 6 │ 7 │ 8 │ 9 │ 10 │ 11 │ 12 │
│ 13 │ 14 │ 15 │ 16 │ 17 │ 18 │ 19 │
│ 20 │ 21 │ 22 │ 23 │ 24 │ 25 │ 26 │
│ 27 │ 28 │ 29 │ 30 │ 31 │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘

View file

@ -1,12 +0,0 @@
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ │ │ │ │ 1 │ 2 │
│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘

View file

@ -1,12 +0,0 @@
┌──────────────────────────────────┐
│ srpen 2026 │
├────┬────┬────┬────┬────┬────┬────┤
│ po │ út │ st │ čt │ pá │ so │ ne │
├────┼────┼────┼────┼────┼────┼────┤
│ │ │ │ │ │ 1 │ 2 │
│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└────┴────┴────┴────┴────┴────┴────┘

View file

@ -1,12 +0,0 @@
┌─────────────────────────────────────────┐
│ august 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ man │ tir │ ons │ tor │ fre │ lør │ søn │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ │ │ │ │ 1 │ 2 │
│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘

View file

@ -1,12 +0,0 @@
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ │ │ │ │ 1 │ 2 │
│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘

View file

@ -1,12 +0,0 @@
┌────────────────────────────────────────────────┐
│ agosto 2026 │
├──────┬──────┬──────┬──────┬──────┬──────┬──────┤
│ lun. │ mar. │ mié. │ jue. │ vie. │ sáb. │ dom. │
├──────┼──────┼──────┼──────┼──────┼──────┼──────┤
│ │ │ │ │ │ 1 │ 2 │
│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┘

View file

@ -1,12 +0,0 @@
┌────────────────────────────────────────────────┐
│ août 2026 │
├──────┬──────┬──────┬──────┬──────┬──────┬──────┤
│ lun. │ mar. │ mer. │ jeu. │ ven. │ sam. │ dim. │
├──────┼──────┼──────┼──────┼──────┼──────┼──────┤
│ │ │ │ │ │ 1 │ 2 │
│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┘

View file

@ -1,12 +0,0 @@
┌──────────────────────────────────┐
│ augustus 2026 │
├────┬────┬────┬────┬────┬────┬────┤
│ ma │ di │ wo │ do │ vr │ za │ zo │
├────┼────┼────┼────┼────┼────┼────┤
│ │ │ │ │ │ 1 │ 2 │
│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└────┴────┴────┴────┴────┴────┴────┘

View file

@ -1,12 +0,0 @@
┌───────────────────────────────────────────────────────┐
│ ఆగస్టు 2026 │
├───────┬───────┬───────┬───────┬───────┬───────┬───────┤
│ సోమ │ మంగళ │ బుధ │ గురు │ శుక్ర │ శని │ ఆది │
├───────┼───────┼───────┼───────┼───────┼───────┼───────┤
│ │ │ │ │ │ 1 │ 2 │
│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└───────┴───────┴───────┴───────┴───────┴───────┴───────┘

View file

@ -1,12 +0,0 @@
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ │ │ │ │ x │ x │
│ x │ x │ x │ x │ x │ x │ x │
│ x │ x │ x │ x │ x │ x │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘

View file

@ -1,13 +0,0 @@
Double-wide characters will display incorrectly but still tested until rendering can be improved
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ │ │ │ │ 火 │ 火 │
│ 火 │ 火 │ 火 │ 火 │ 火 │ 火 │ 火 │
│ 火 │ 火 │ 火 │ 火 │ 火 │ 火 │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘

View file

@ -1,13 +0,0 @@
Emojis will display incorrectly in text but output correctly in terminals with UTF-8 encoding.
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ │ │ │ │ 🤫 │ 🤫 │
│ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │
│ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘

View file

@ -1,12 +0,0 @@
┌──────────────────────────────────────────────────────────────┐
│ August 2026 │
├────────┬────────┬────────┬────────┬────────┬────────┬────────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
├────────┼────────┼────────┼────────┼────────┼────────┼────────┤
│ │ │ │ │ │ passed │ passed │
│ passed │ passed │ passed │ passed │ passed │ passed │ passed │
│ passed │ passed │ passed │ passed │ passed │ passed │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└────────┴────────┴────────┴────────┴────────┴────────┴────────┘

View file

@ -1,12 +0,0 @@
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ │ │ │ │ | │ | │
│ | │ | │ | │ | │ | │ | │ | │
│ | │ | │ | │ | │ | │ | │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘

View file

@ -1,86 +0,0 @@
Start Day: Sunday
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Sun │ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ │ │ │ │ │ 1 │
│ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │
│ 9 │ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │
│ 16 │ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │
│ 23 │ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │
│ 30 │ 31 │ │ │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
Start Day: Monday
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ │ │ │ │ 1 │ 2 │
│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │
│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │
│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │
│ 31 │ │ │ │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
Start Day: Tuesday
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │ Mon │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ │ │ │ 1 │ 2 │ 3 │
│ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ 10 │
│ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ 17 │
│ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ 24 │
│ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ 31 │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
Start Day: Wednesday
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Wed │ Thu │ Fri │ Sat │ Sun │ Mon │ Tue │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ │ │ 1 │ 2 │ 3 │ 4 │
│ 5 │ 6 │ 7 │ 8 │ 9 │ 10 │ 11 │
│ 12 │ 13 │ 14 │ 15 │ 16 │ 17 │ 18 │
│ 19 │ 20 │ 21 │ 22 │ 23 │ 24 │ 25 │
│ 26 │ 27 │ 28 │ 29 │ 30 │ 31 │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
Start Day: Thursday
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Thu │ Fri │ Sat │ Sun │ Mon │ Tue │ Wed │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ │ 1 │ 2 │ 3 │ 4 │ 5 │
│ 6 │ 7 │ 8 │ 9 │ 10 │ 11 │ 12 │
│ 13 │ 14 │ 15 │ 16 │ 17 │ 18 │ 19 │
│ 20 │ 21 │ 22 │ 23 │ 24 │ 25 │ 26 │
│ 27 │ 28 │ 29 │ 30 │ 31 │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
Start Day: Friday
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Fri │ Sat │ Sun │ Mon │ Tue │ Wed │ Thu │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │
│ 7 │ 8 │ 9 │ 10 │ 11 │ 12 │ 13 │
│ 14 │ 15 │ 16 │ 17 │ 18 │ 19 │ 20 │
│ 21 │ 22 │ 23 │ 24 │ 25 │ 26 │ 27 │
│ 28 │ 29 │ 30 │ 31 │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
Start Day: Saturday
┌─────────────────────────────────────────┐
│ August 2026 │
├─────┬─────┬─────┬─────┬─────┬─────┬─────┤
│ Sat │ Sun │ Mon │ Tue │ Wed │ Thu │ Fri │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │
│ 8 │ 9 │ 10 │ 11 │ 12 │ 13 │ 14 │
│ 15 │ 16 │ 17 │ 18 │ 19 │ 20 │ 21 │
│ 22 │ 23 │ 24 │ 25 │ 26 │ 27 │ 28 │
│ 29 │ 30 │ 31 │ │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘

View file

@ -1,35 +0,0 @@
using System.Text;
using ModuleCore.Calendar;
namespace ModuleTests.Calendar;
public class StartDayOfWeekTests
{
private static readonly VerifySettings Settings;
static StartDayOfWeekTests()
{
Settings = new VerifySettings();
var testBaseDirectory = Path.Join(TestConstants.SnapshotFolderName, nameof(StartDayOfWeekTests));
Settings.UseDirectory(testBaseDirectory);
Settings.DisableDiff();
}
[Fact]
public Task StartOfDayRender()
{
Settings.UseFileName(nameof(StartOfDayRender));
var date = new DateTime(2026, 8, 1);
var sb = new StringBuilder();
foreach (var dayOfWeek in Enum.GetValues<DayOfWeek>())
{
var calendar = new CalendarGenerator(startOfWeek: dayOfWeek);
sb.AppendLine($"Start Day: {dayOfWeek}");
sb.AppendLine(calendar.Render(date, date));
}
return Verify(sb, Settings);
}
}

View file

@ -1,14 +0,0 @@
namespace ModuleTests.Calendar.TestData;
public class CalendarTestDates : TestDataEnumerator<DateTime>
{
public CalendarTestDates()
{
Data =
[
new DateTime(2026, 06, 01),
new DateTime(2026, 07, 01),
new DateTime(2026, 08, 01),
];
}
}

View file

@ -1,19 +0,0 @@
namespace ModuleTests.Calendar.TestData;
public class CultureCodeTestData : TestDataEnumerator<string>
{
public CultureCodeTestData()
{
Data =
[
"da-DK",
"en-AU",
"cs-CZ",
"es-PR",
"fr-LU",
"nl-NL",
// Will render unaligned due to the character encoding, here to catch if I fix this later
"te-IN"
];
}
}

View file

@ -1,150 +0,0 @@
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

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

View file

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

View file

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

View file

@ -1,18 +0,0 @@
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,25 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Verify.XunitV3" />
<PackageReference Include="xunit.v3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ModuleCore\ModuleCore.csproj" />
</ItemGroup>
</Project>

View file

@ -1,6 +0,0 @@
namespace ModuleTests;
public class TestConstants
{
public const string SnapshotFolderName = "Snapshots";
}

View file

@ -1,15 +0,0 @@
using System.Collections;
namespace ModuleTests;
public abstract class TestDataEnumerator<T> : IEnumerable<TheoryDataRow<T>>
{
protected IEnumerable<T> Data { get; init; } = [];
public IEnumerator<TheoryDataRow<T>> GetEnumerator()
{
return Data.Select(data => new TheoryDataRow<T>(data)).GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}