feat: Add GitRepoRegistration cmdlets (#5)
- adds GitRepoRegistration cmdlets - adds basic tests for GitRepoRegistration implementations - adds initial build project and scripts Refs: #5, #6
This commit is contained in:
parent
3547e32b6e
commit
5c09b7c5a8
66 changed files with 1619 additions and 142 deletions
|
|
@ -1,4 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace ModuleCore.Calendar;
|
||||
|
|
|
|||
86
src/ModuleCore/Database/DatabaseManager.cs
Normal file
86
src/ModuleCore/Database/DatabaseManager.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using SQLite;
|
||||
|
||||
namespace ModuleCore.Database;
|
||||
|
||||
public class DatabaseManager
|
||||
{
|
||||
private readonly FileInfo _databaseLocation;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new manager for the given database file by name
|
||||
/// </summary>
|
||||
/// <param name="databaseName">
|
||||
/// Filename for the database with no extension. Slashes are accepted and will create directories as needed.
|
||||
/// </param>
|
||||
public DatabaseManager(string databaseName)
|
||||
{
|
||||
// Even though the xmldoc says "with no extension", we strip off any extension regardless
|
||||
_databaseLocation = new FileInfo(Path.Combine(".", "data", $"{SanitiseFilename(databaseName)}.db"));
|
||||
|
||||
Directory.CreateDirectory(_databaseLocation.DirectoryName!);
|
||||
|
||||
if (!File.Exists(_databaseLocation.FullName))
|
||||
{
|
||||
var file = File.Create(_databaseLocation.FullName);
|
||||
file.Close();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the given action within a new database connection
|
||||
/// </summary>
|
||||
/// <param name="dbAction"></param>
|
||||
public void InConnection(Action<SQLiteConnection> dbAction)
|
||||
{
|
||||
using var conn = new SQLiteConnection(_databaseLocation.FullName);
|
||||
dbAction(conn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the given func in a new database connection, returning the result
|
||||
/// </summary>
|
||||
/// <param name="dbAction"></param>
|
||||
/// <typeparam name="T"></typeparam>
|
||||
/// <returns></returns>
|
||||
public T InConnection<T>(Func<SQLiteConnection, T> dbAction)
|
||||
{
|
||||
using var conn = new SQLiteConnection(_databaseLocation.FullName);
|
||||
return dbAction(conn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a bool for the given query. Convenience method for <see cref="SQLiteConnection.ExecuteScalar"/>.
|
||||
/// </summary>
|
||||
/// <param name="query">A query starting with SELECT 1, optionally paramaterised with ?</param>
|
||||
/// <param name="args">Parameter values</param>
|
||||
/// <returns></returns>
|
||||
public bool Exists(string query, params object[] args)
|
||||
{
|
||||
using var conn = new SQLiteConnection(_databaseLocation.FullName);
|
||||
var exists = conn.ExecuteScalar<bool?>(query, args);
|
||||
return exists ?? false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the current database file. This will cause any future instance methods to fail on database action if
|
||||
/// a new instance is not created.
|
||||
/// <para>
|
||||
/// This method should be avoided unless calling from a test.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal void DeleteDatabase()
|
||||
{
|
||||
_databaseLocation.Delete();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes double dots from the filename and removes the file extension
|
||||
/// </summary>
|
||||
/// <param name="filename"></param>
|
||||
/// <returns></returns>
|
||||
private string SanitiseFilename(string filename)
|
||||
{
|
||||
// Honestly not really needed seeing as its just me and this isn't coming from user supplied code, but eh.
|
||||
return Path.GetFileNameWithoutExtension(filename.Replace("..", string.Empty));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<Project>
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<VersionPrefix>0.0.0</VersionPrefix>
|
||||
<VersionPrefix>0.0.1</VersionPrefix>
|
||||
<VersionSuffix>dev</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
393
src/ModuleCore/Git/GitRepoRegistrationManager.cs
Normal file
393
src/ModuleCore/Git/GitRepoRegistrationManager.cs
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using ModuleCore.Database;
|
||||
using ModuleCore.Git.Models;
|
||||
using SQLite;
|
||||
|
||||
namespace ModuleCore.Git;
|
||||
|
||||
/// <summary>
|
||||
/// Manages git repo registration, including creating any registration persistence via a backing <see cref="DatabaseManager"/>
|
||||
/// </summary>
|
||||
public class GitRepoRegistrationManager
|
||||
{
|
||||
private static readonly Lazy<GitRepoRegistrationManager> GitManagerInstance = new(() => new GitRepoRegistrationManager());
|
||||
private static Action<string>? _debugWriterDelegate;
|
||||
private readonly DatabaseManager _db;
|
||||
private readonly ConcurrentDictionary<string, InternalGitRegistration> _registrations;
|
||||
|
||||
private GitRepoRegistrationManager(string? databaseName = null)
|
||||
{
|
||||
_registrations = new ConcurrentDictionary<string, InternalGitRegistration>();
|
||||
// Regular usage of this constructor will never pass a database name in. Currently only tests should be hitting
|
||||
// a code path that has a different database name
|
||||
_db = new DatabaseManager(databaseName ?? "git.db");
|
||||
|
||||
InitialiseRegistrations();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current <see cref="GitRepoRegistrationManager"/> instance. If no instance has been created, returns a new instance
|
||||
/// and then the same instance every call after.
|
||||
/// </summary>
|
||||
public static GitRepoRegistrationManager Instance => GitManagerInstance.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Always returns a new clean instance of GitManager
|
||||
/// </summary>
|
||||
internal static GitRepoRegistrationManager InternalFreshInstance(string databaseName) => new(databaseName);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the underlying database file.
|
||||
/// <para>
|
||||
/// Avoid calling this outside of tests.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal void DeleteDatabase() => _db.DeleteDatabase();
|
||||
|
||||
/// <summary>
|
||||
/// Creates up any database tables and loads all previously saved git registrations.
|
||||
/// </summary>
|
||||
private void InitialiseRegistrations()
|
||||
{
|
||||
_debugWriterDelegate?.Invoke("Initialising GitManager from first run - this should only happen once.");
|
||||
|
||||
_db.InConnection(conn =>
|
||||
{
|
||||
var createTableResult = conn.CreateTable<InternalGitRegistration>();
|
||||
|
||||
if (createTableResult == CreateTableResult.Created)
|
||||
{
|
||||
_debugWriterDelegate?.Invoke($"Created table {InternalGitRegistration.TableName}.");
|
||||
}
|
||||
});
|
||||
|
||||
_debugWriterDelegate?.Invoke("Loading previous registrations from database.");
|
||||
|
||||
var registrations = _db.InConnection<List<InternalGitRegistration>>(conn =>
|
||||
conn.Table<InternalGitRegistration>()
|
||||
.ToList()
|
||||
);
|
||||
|
||||
foreach (var internalGitRegistration in registrations)
|
||||
{
|
||||
_debugWriterDelegate?.Invoke($"Loading {internalGitRegistration.Name} ({internalGitRegistration.Id}) from database...");
|
||||
if (!_registrations.TryAdd(internalGitRegistration.Name, internalGitRegistration))
|
||||
{
|
||||
_debugWriterDelegate?.Invoke("...failed to restore - potential duplicate name.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a git repository based on an absolute location. If <paramref name="registrationName" /> is null or empty,
|
||||
/// the registration will use the folder name for the git repo at the top level.
|
||||
/// </summary>
|
||||
/// <param name="absoluteRepositoryLocation"></param>
|
||||
/// <param name="registrationName"></param>
|
||||
/// <returns>The normalised string the repository was registered against</returns>
|
||||
public string RegisterRepo(string absoluteRepositoryLocation, string registrationName)
|
||||
{
|
||||
registrationName = string.IsNullOrWhiteSpace(registrationName)
|
||||
? new DirectoryInfo(absoluteRepositoryLocation).Name
|
||||
: registrationName;
|
||||
|
||||
var gitRegistration = new InternalGitRegistration
|
||||
{
|
||||
Name = registrationName,
|
||||
Location = absoluteRepositoryLocation,
|
||||
Id = Guid.CreateVersion7(),
|
||||
};
|
||||
|
||||
return _db.InConnection<string>(conn =>
|
||||
{
|
||||
// Query if we already have a registration either by name. Previously we also checked by location, but I
|
||||
// decided to stick with constraining to the name only, same as the key used for the dictionary.
|
||||
// tbh this is a bit of a janky way to do exists when I have to pass the query in anyway, but I just didn't
|
||||
// want to do null checks and a truthy check so I wrap it in a barely-valuable method.
|
||||
var registrationExists = _db.Exists(
|
||||
$"""
|
||||
SELECT 1
|
||||
FROM {InternalGitRegistration.TableName}
|
||||
WHERE Name = ?
|
||||
""",
|
||||
gitRegistration.Name
|
||||
);
|
||||
|
||||
if (registrationExists)
|
||||
{
|
||||
throw new Exception($"A Git repo is already registered with the name {registrationName}.");
|
||||
}
|
||||
|
||||
// Insert the new record
|
||||
conn.Insert(gitRegistration);
|
||||
|
||||
if (_registrations.TryAdd(registrationName, gitRegistration))
|
||||
{
|
||||
_debugWriterDelegate?.Invoke($"Registered '{gitRegistration.Location}' to name '{registrationName}'");
|
||||
return registrationName;
|
||||
}
|
||||
|
||||
// This error case should be unlikely, but if a registration was removed but the registrations wasn't updated
|
||||
// correctly then we'd unable to re-add a repo with the same name
|
||||
throw new Exception("An error occured during registration.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters a git repo registration by name. If no registration exists an exception will be thrown.
|
||||
/// </summary>
|
||||
/// <param name="registrationName"></param>
|
||||
/// <exception cref="Exception"></exception>
|
||||
public void UnregisterRepo(string registrationName)
|
||||
{
|
||||
_db.InConnection(conn =>
|
||||
{
|
||||
var existingRegistration = conn.Query<InternalGitRegistration>(
|
||||
$"""
|
||||
SELECT {nameof(InternalGitRegistration.Id)}
|
||||
,{nameof(InternalGitRegistration.Name)}
|
||||
,{nameof(InternalGitRegistration.Location)}
|
||||
FROM {InternalGitRegistration.TableName}
|
||||
WHERE {nameof(InternalGitRegistration.Name)} = ?
|
||||
""",
|
||||
registrationName)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (existingRegistration is null)
|
||||
{
|
||||
throw new Exception($"No registration exists for '{registrationName}'.")
|
||||
{
|
||||
Source = "unregister-repository",
|
||||
};
|
||||
}
|
||||
|
||||
var deleted = conn.Delete<InternalGitRegistration>(existingRegistration.Id);
|
||||
|
||||
// If we somehow found a registration but delete returned nothing, just return and assume we've already
|
||||
// removed it from registrations.
|
||||
// Seems a bit risky when you read it logically, but by this point the registration shouldn't exist so it doesn't
|
||||
// matter.
|
||||
if (deleted == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove by the name we get from the database instead of what was passed in
|
||||
if (_registrations.TryRemove(registrationName, out var removedItem))
|
||||
{
|
||||
_debugWriterDelegate?.Invoke($"Removed {registrationName}.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Weird error to throw, but by this stage we shouldn't have a git repo registered under this name
|
||||
throw new Exception("Failed to remove registration - no registration exists.")
|
||||
{
|
||||
Source = "unregister-repository",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all currently registered git repositories, including additional information such as the git repositories
|
||||
/// current branch.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public List<GitRegistration> ListRepos()
|
||||
{
|
||||
return _registrations.Select(x =>
|
||||
new GitRegistration
|
||||
{
|
||||
Name = x.Value.Name,
|
||||
Location = x.Value.Location,
|
||||
CurrentBranch = x.Value.CurrentBranch,
|
||||
}
|
||||
)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the file location for a git repo registration by name.
|
||||
/// </summary>
|
||||
/// <param name="registeredName"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="Exception"></exception>
|
||||
public string GetDirectoryForRegisteredRepo(string? registeredName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(registeredName))
|
||||
{
|
||||
throw new Exception("Name cannot be null");
|
||||
}
|
||||
|
||||
if (_registrations.TryGetValue(registeredName, out var registration))
|
||||
{
|
||||
return registration.Location;
|
||||
}
|
||||
|
||||
throw new Exception($"No git repo has been registered with the name {registeredName}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the git root directory for any nested directory if git rev-parse --show-toplevel returns a value.
|
||||
/// <para>
|
||||
/// Will always return a non-null value if the directory is a git repo, otherwise an exception will be thrown
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="path">Path to check if it or any of its parents contain a git repository</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="Exception">
|
||||
/// Git fails to start, returns an error (ie: the directory is not in a git repo), or the git process does not return
|
||||
/// any output or error.
|
||||
/// </exception>
|
||||
public static ParsedGitFolderDetails IsGitRepo(string path)
|
||||
{
|
||||
_debugWriterDelegate?.Invoke("Checking if current directory is a git repository...");
|
||||
|
||||
var ps = new ProcessStartInfo("git",
|
||||
["-C", path, "rev-parse", "--show-toplevel"])
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
|
||||
// If the user doesn't have git on their path, this will throw an exception that I don't have to do anything
|
||||
// special with, it'll be unhandled and powershell will handle it
|
||||
var gitProcess = Process.Start(ps);
|
||||
|
||||
// This probably shouldn't be possible? Not really sure of the conditions where the process could be started
|
||||
// but return null, but I'm going to consider that unrecoverable error territory
|
||||
if (gitProcess is null)
|
||||
{
|
||||
throw new Exception("git failed to start")
|
||||
{
|
||||
Source = "git-process",
|
||||
};
|
||||
}
|
||||
|
||||
gitProcess.WaitForExit();
|
||||
|
||||
if (!gitProcess.StandardOutput.EndOfStream)
|
||||
{
|
||||
var directory = gitProcess.StandardOutput.ReadToEnd();
|
||||
// Gotta trim what we get as it might already have a newline character at the end
|
||||
var dirInfo = new DirectoryInfo(directory.Trim());
|
||||
|
||||
_debugWriterDelegate?.Invoke("...location is a git repo!");
|
||||
|
||||
var repoFolderInfo = new ParsedGitFolderDetails
|
||||
{
|
||||
Directory = dirInfo.FullName,
|
||||
Folder = dirInfo.Name,
|
||||
};
|
||||
|
||||
return repoFolderInfo;
|
||||
}
|
||||
|
||||
if (!gitProcess.StandardError.EndOfStream)
|
||||
{
|
||||
throw new Exception(gitProcess.StandardError.ReadToEnd())
|
||||
{
|
||||
Source = "git-not-found",
|
||||
};
|
||||
}
|
||||
|
||||
throw new Exception("Unable to determine if directory is repository: git command returned no output or errors.")
|
||||
{
|
||||
Source = "git-parse-failed",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an output for debug output. <see cref="ClearDebugWriter" /> should be called as soon as the need for output
|
||||
/// is no longer needed.
|
||||
/// </summary>
|
||||
/// <param name="commandRuntime"></param>
|
||||
public static void SetDebugWriter(Action<string> commandRuntime)
|
||||
{
|
||||
_debugWriterDelegate = commandRuntime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears any output previously registered with <see cref="SetDebugWriter" />
|
||||
/// </summary>
|
||||
public static void ClearDebugWriter()
|
||||
{
|
||||
_debugWriterDelegate = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used for internal git registration and handles getting the current branch
|
||||
/// </summary>
|
||||
[Table(TableName)]
|
||||
private class InternalGitRegistration
|
||||
{
|
||||
internal const string TableName = "GitRegistration";
|
||||
private string _currentBranch = string.Empty;
|
||||
private long _nextCheckTime;
|
||||
|
||||
[PrimaryKey]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[Indexed(Unique = true)]
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
public string Location { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The current branch of the repository. This value is cached for 15 minutes after which it becomes stale and
|
||||
/// will be refreshed on the next call to this property.
|
||||
/// </summary>
|
||||
public string CurrentBranch => GetCurrentBranch();
|
||||
|
||||
// TODO: [#13] Create GitManager to centralise calls to git process
|
||||
private string GetCurrentBranch()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
// git branch should be quick enough that even with a large number of registrations this shouldn't be that slow
|
||||
// when doing Get-GitRepo, but regardless we still only get the current branch via git if it's been some amount
|
||||
// of time since the last time we did.
|
||||
if (now.Ticks < _nextCheckTime)
|
||||
{
|
||||
return _currentBranch;
|
||||
}
|
||||
|
||||
// use -C for the git command so we don't need to set the working directory and the git command can be run
|
||||
// from anywhere against the appropriate location
|
||||
var ps = new ProcessStartInfo("git",
|
||||
["-C", Location, "branch", "--show-current"])
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
|
||||
// If the user doesn't have git on their path, this will throw an exception that I don't have to do anything
|
||||
// special with, it'll be unhandled and powershell will handle it
|
||||
var gitProcess = Process.Start(ps);
|
||||
|
||||
// This probably shouldn't be possible? Not really sure of the conditions where the process could be started
|
||||
// but return null, but I'm going to consider that unrecoverable error territory
|
||||
if (gitProcess is null)
|
||||
{
|
||||
throw new Exception("git failed to start");
|
||||
}
|
||||
|
||||
gitProcess.WaitForExit();
|
||||
|
||||
if (!gitProcess.StandardOutput.EndOfStream)
|
||||
{
|
||||
_currentBranch = gitProcess.StandardOutput.ReadToEnd();
|
||||
}
|
||||
|
||||
if (!gitProcess.StandardError.EndOfStream)
|
||||
{
|
||||
_currentBranch = gitProcess.StandardError.ReadToEnd();
|
||||
}
|
||||
|
||||
// Set the next check to be in the future so we don't hold up any list commands every time.
|
||||
_nextCheckTime = now.AddMinutes(15).Ticks;
|
||||
|
||||
// The branch name could (will) have a newline character at the end, so we trim that off
|
||||
return _currentBranch.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/ModuleCore/Git/Models/GitRegistration.cs
Normal file
21
src/ModuleCore/Git/Models/GitRegistration.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
namespace ModuleCore.Git.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Details for a registered git repo
|
||||
/// </summary>
|
||||
public class GitRegistration
|
||||
{
|
||||
/// <summary>
|
||||
/// Display name for the registration. Can either be the name of the repo retrieved from git, or a user supplied
|
||||
/// alias
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Location on disk for the top level of the registered git repo. Not guaranteed to exist on disk
|
||||
/// </summary>
|
||||
public required string Location { get; set; }
|
||||
/// <summary>
|
||||
/// Current branch for the git repo
|
||||
/// </summary>
|
||||
public required string CurrentBranch { get; set; }
|
||||
}
|
||||
17
src/ModuleCore/Git/Models/ParsedGitFolderDetails.cs
Normal file
17
src/ModuleCore/Git/Models/ParsedGitFolderDetails.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
namespace ModuleCore.Git.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The directory details of the directory returned from git rev-parse --show-toplevel
|
||||
/// </summary>
|
||||
public class ParsedGitFolderDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// The full path to the top level folder containing a git repository
|
||||
/// </summary>
|
||||
public string Directory { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The last folder name of the directory
|
||||
/// </summary>
|
||||
public string Folder { get; init; } = null!;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
|
|
@ -7,4 +7,14 @@
|
|||
<LangVersion>latestmajor</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>ModuleTests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="sqlite-net-pcl"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -121,6 +121,19 @@ public class CustomUiHost : PSHostUserInterface
|
|||
|
||||
public string Output => output.ToString();
|
||||
|
||||
private int? _nextChoiceOption;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the next choice option to be used on the next call to <see cref="PromptForChoice"/>.
|
||||
///
|
||||
/// You'll probably get the
|
||||
/// </summary>
|
||||
/// <param name="choiceOption"></param>
|
||||
public void SetNextPromptChoice(int choiceOption)
|
||||
{
|
||||
_nextChoiceOption = choiceOption;
|
||||
}
|
||||
|
||||
public override Dictionary<string, PSObject> Prompt(string caption, string message, System.Collections.ObjectModel.Collection<FieldDescription> descriptions)
|
||||
{
|
||||
throw new NotImplementedException("Prompt is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input.");
|
||||
|
|
@ -128,7 +141,14 @@ public class CustomUiHost : PSHostUserInterface
|
|||
|
||||
public override int PromptForChoice(string caption, string message, System.Collections.ObjectModel.Collection<ChoiceDescription> choices, int defaultChoice)
|
||||
{
|
||||
throw new NotImplementedException("PromptForChoice is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input.");
|
||||
if (_nextChoiceOption is null)
|
||||
{
|
||||
throw new NotImplementedException("PromptForChoice is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input.");
|
||||
}
|
||||
|
||||
var choiceReturn = _nextChoiceOption.Value;
|
||||
_nextChoiceOption = null;
|
||||
return choiceReturn;
|
||||
}
|
||||
|
||||
public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
using System.Management.Automation.Runspaces;
|
||||
using System.Text;
|
||||
using PowershellModule.Calendar;
|
||||
using PowershellModule.Git;
|
||||
|
||||
namespace PowershellHarness;
|
||||
|
||||
|
|
@ -36,8 +37,26 @@ class Program
|
|||
var host = new CustomHost(Console.WindowWidth);
|
||||
var runspace = InitialisePowershellHost(host);
|
||||
|
||||
// InvokeCommand(runspace, GetCalendarCommand.FullName);
|
||||
host.UI.SetNextPromptChoice(3);
|
||||
|
||||
InvokeCommand(runspace, "New-PSDrive", [
|
||||
CreateCommand("name", "git-test"),
|
||||
CreateCommand("PSProvider", "GitRepo"),
|
||||
CreateCommand("Root", "\\"),
|
||||
]);
|
||||
|
||||
InvokeCommand(runspace, "Set-Location",
|
||||
[
|
||||
// Technically this can just be the command but this is a bit easier
|
||||
CreateCommand("path", "git-test:/")
|
||||
]);
|
||||
|
||||
InvokeCommand(runspace, "Get-Location");
|
||||
}
|
||||
|
||||
private static void CalendarTestCommands(Runspace runspace)
|
||||
{
|
||||
InvokeCommand(runspace, GetCalendarCommand.FullName);
|
||||
foreach (var day in Enum.GetValues<DayOfWeek>())
|
||||
{
|
||||
InvokeCommand(runspace, GetCalendarCommand.FullName, [
|
||||
|
|
@ -48,11 +67,15 @@ class Program
|
|||
]);
|
||||
}
|
||||
|
||||
// InvokeCommand(runspace, GetCalendarCommand.FullName, [
|
||||
// new CommandParameter(nameof(GetCalendarCommand.MarkedDaySymbol), "🤫"),
|
||||
// new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), DayOfWeek.Saturday)
|
||||
// ]);
|
||||
// InvokeCommand(runspace, GetCalendarCommand.FullName, [new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), "Sunday")]);
|
||||
InvokeCommand(runspace, GetCalendarCommand.FullName, [
|
||||
new CommandParameter(nameof(GetCalendarCommand.MarkedDaySymbol), "🤫"),
|
||||
new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), DayOfWeek.Saturday)
|
||||
]);
|
||||
InvokeCommand(runspace, GetCalendarCommand.FullName, [new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), "Sunday")]);
|
||||
}
|
||||
|
||||
private static void TestGitProvider(Runspace runspace)
|
||||
{
|
||||
}
|
||||
|
||||
private static CommandParameter CreateCommand(string name, string? argument = null)
|
||||
|
|
@ -97,6 +120,10 @@ class Program
|
|||
var getCalendarCommand = new SessionStateCmdletEntry(GetCalendarCommand.FullName, typeof(GetCalendarCommand), null);
|
||||
initialSessionState.Commands.Add(getCalendarCommand);
|
||||
|
||||
|
||||
var gitProvider = new SessionStateProviderEntry(GitProvider.Name, typeof(GitProvider), null);
|
||||
initialSessionState.Providers.Add(gitProvider);
|
||||
|
||||
// Create a runspace from the state, open and return it
|
||||
var runspace = RunspaceFactory.CreateRunspace(host, initialSessionState);
|
||||
|
||||
|
|
@ -114,16 +141,29 @@ class Program
|
|||
// and this is just a debug harness so it doesn't really matter for now
|
||||
using var pipeline = runspace.CreatePipeline();
|
||||
// using var powershell = PowerShell.Create(runspace);
|
||||
|
||||
// StringBuilder to store the output of this command including any output results (but not errors yet)
|
||||
// this is just a rudimentary test and the pwsh debug profile should be used instead as it loads the module
|
||||
// in a full powershell window with debugger attached. Just no automatic command running sadly.
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.Append(command);
|
||||
|
||||
var cmd = new Command(command);
|
||||
if (parameters is not null)
|
||||
{
|
||||
foreach (var commandParameter in parameters)
|
||||
var param = parameters.ToList();
|
||||
sb.Append(' ')
|
||||
.AppendJoin(' ', param.Select(x => $"-{x.Name} {x.Value}"));
|
||||
|
||||
foreach (var commandParameter in param)
|
||||
{
|
||||
cmd.Parameters.Add(commandParameter);
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
|
||||
pipeline.Commands.Add(cmd);
|
||||
// powershell.Commands.AddCommand(cmd);
|
||||
|
||||
|
|
@ -133,8 +173,11 @@ class Program
|
|||
// var results = powershell.Invoke();
|
||||
foreach (var result in results)
|
||||
{
|
||||
Console.Write(result);
|
||||
sb.AppendLine(result.ToString());
|
||||
// Console.WriteLine(result);
|
||||
}
|
||||
|
||||
Console.WriteLine(sb);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Management.Automation;
|
||||
using ModuleCore.Calendar;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<Project>
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<VersionPrefix>0.0.0</VersionPrefix>
|
||||
<VersionPrefix>0.0.1</VersionPrefix>
|
||||
<VersionSuffix>dev</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using System.Management.Automation;
|
||||
using ModuleCore.Git;
|
||||
using ModuleCore.Git.Models;
|
||||
|
||||
namespace PowershellModule.Git.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Lists all currently registered git repositories, with additional information such as their current branch.
|
||||
/// </summary>
|
||||
[Cmdlet(VerbsCommon.Get, GitCommands.GitRepoRegistrationNoun)]
|
||||
[OutputType(typeof(GitRegistration))]
|
||||
public class GetGitRepoRegistrationCommand : PSCmdlet
|
||||
{
|
||||
protected override void BeginProcessing()
|
||||
{
|
||||
var repos = GitRepoRegistrationManager.Instance.ListRepos();
|
||||
|
||||
WriteObject(repos);
|
||||
|
||||
base.BeginProcessing();
|
||||
}
|
||||
}
|
||||
6
src/PowershellModule/Git/Commands/GitCommands.cs
Normal file
6
src/PowershellModule/Git/Commands/GitCommands.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace PowershellModule.Git.Commands;
|
||||
|
||||
public class GitCommands
|
||||
{
|
||||
public const string GitRepoRegistrationNoun = "GitRepoRegistration";
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
using System;
|
||||
using System.Management.Automation;
|
||||
using ModuleCore.Git;
|
||||
|
||||
namespace PowershellModule.Git.Commands;
|
||||
|
||||
[Cmdlet(VerbsCommon.New, GitCommands.GitRepoRegistrationNoun)]
|
||||
public sealed class NewGitRepoRegistrationCommand : PSCmdlet
|
||||
{
|
||||
[Parameter(
|
||||
Position = 0,
|
||||
ValueFromPipeline = true,
|
||||
HelpMessage = "Reference name for the repo")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
protected override void BeginProcessing()
|
||||
{
|
||||
try
|
||||
{
|
||||
GitRepoRegistrationManager.SetDebugWriter(WriteDebug);
|
||||
|
||||
// Test that we're in a git repo first. If we aren't (or git isn't available), this method will throw
|
||||
// so we don't need to handle for null (yet).
|
||||
var repoFolder = GitRepoRegistrationManager.IsGitRepo(SessionState.Path.CurrentLocation.Path);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Name))
|
||||
{
|
||||
WriteDebug("No name given for registration, defaulting to git folder root.");
|
||||
}
|
||||
|
||||
GitRepoRegistrationManager.Instance.RegisterRepo(repoFolder.Directory, Name ?? repoFolder.Folder);
|
||||
GitRepoRegistrationManager.ClearDebugWriter();
|
||||
|
||||
base.BeginProcessing();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteError(new ErrorRecord(ex, ex.Source, ErrorCategory.FromStdErr, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
using System;
|
||||
using System.Management.Automation;
|
||||
using ModuleCore.Git;
|
||||
|
||||
namespace PowershellModule.Git.Commands;
|
||||
|
||||
[Cmdlet(VerbsCommon.Remove, GitCommands.GitRepoRegistrationNoun)]
|
||||
public class RemoveGitRepoRegistrationCommand : PSCmdlet
|
||||
{
|
||||
[Parameter(
|
||||
Position = 0,
|
||||
ValueFromPipeline = true,
|
||||
HelpMessage = "Reference name for the repo")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
protected override void BeginProcessing()
|
||||
{
|
||||
try
|
||||
{
|
||||
GitRepoRegistrationManager.SetDebugWriter(WriteDebug);
|
||||
|
||||
// If we aren't given a value for the Name argument, default behaviour is to attempt to remove a registration
|
||||
// by the current git repo folder name for the current location.
|
||||
// If we have a name, don't bother testing for a git repo, just attempt to remove the registration by name
|
||||
// regardless of where we're being called from
|
||||
var registrationNameToRemove = string.IsNullOrEmpty(Name)
|
||||
? GitRepoRegistrationManager.IsGitRepo(SessionState.Path.CurrentLocation.Path).Folder
|
||||
: Name;
|
||||
|
||||
GitRepoRegistrationManager.Instance.UnregisterRepo(registrationNameToRemove);
|
||||
|
||||
// Removing a registration works similar to registering a new one - we either remove by exact name, or by
|
||||
// the folder if no name is given (so a user can remove a registration from a git repo they're currently in)
|
||||
GitRepoRegistrationManager.ClearDebugWriter();
|
||||
|
||||
base.BeginProcessing();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteError(new ErrorRecord(ex, ex.Source, ErrorCategory.FromStdErr, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
using System.Management.Automation;
|
||||
using ModuleCore.Git;
|
||||
|
||||
namespace PowershellModule.Git.Commands;
|
||||
|
||||
[Cmdlet(VerbsCommon.Show, GitCommands.GitRepoRegistrationNoun)]
|
||||
public class ShowGitRepoRegistrationCommand : PSCmdlet
|
||||
{
|
||||
[Parameter(
|
||||
Position = 0,
|
||||
ValueFromPipeline = true,
|
||||
Mandatory = true,
|
||||
HelpMessage = "Reference name for the repo")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[Parameter(
|
||||
Mandatory = false,
|
||||
HelpMessage = "Changes directory directly instead of using Set-Location")]
|
||||
[Alias("NoPushLocation")]
|
||||
public SwitchParameter NoStack { get; set; }
|
||||
|
||||
protected override void BeginProcessing()
|
||||
{
|
||||
var location = GitRepoRegistrationManager.Instance.GetDirectoryForRegisteredRepo(Name);
|
||||
|
||||
// By default instead of doing the same as cd, we instead do pushd so a user can popd straight back to where
|
||||
// they came from.
|
||||
// TODO: incorporate this into the custom prompt when I develop that
|
||||
if (!NoStack)
|
||||
{
|
||||
// Push the current location to the stack
|
||||
// TODO: support named stacks. PowerShell *-Location commands support named stacks, but I don't personally
|
||||
// use them myself so I haven't implemented them initially. I would like to in the future though, but right
|
||||
// now it's low value to me.
|
||||
// https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/set-location?view=powershell-7.6#example-4-set-the-current-location-to-a-named-stack
|
||||
SessionState.Path.PushCurrentLocation(null);
|
||||
}
|
||||
|
||||
SessionState.Path.SetLocation(location);
|
||||
base.BeginProcessing();
|
||||
}
|
||||
}
|
||||
82
src/PowershellModule/Git/GitProvider.cs
Normal file
82
src/PowershellModule/Git/GitProvider.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using System;
|
||||
using System.Management.Automation;
|
||||
using System.Management.Automation.Provider;
|
||||
|
||||
namespace PowershellModule.Git;
|
||||
|
||||
// https://learn.microsoft.com/en-us/powershell/scripting/developer/provider/accessdbprovidersample02?view=powershell-7.6
|
||||
[CmdletProvider(Name, ProviderCapabilities.None)]
|
||||
public class GitProvider : NavigationCmdletProvider
|
||||
{
|
||||
public const string Name = "GitRepo";
|
||||
|
||||
protected override PSDriveInfo NewDrive(PSDriveInfo drive)
|
||||
{
|
||||
// This is a bit of a hack to ensure that no drive is registered with a root location.
|
||||
// It seems to be how PSDrives are registered for Alias providers, and we technically don't have any concept
|
||||
// of a normal file system
|
||||
if (drive.Root != string.Empty)
|
||||
{
|
||||
throw new Exception("Drive root must be an empty string");
|
||||
}
|
||||
|
||||
return base.NewDrive(drive);
|
||||
}
|
||||
|
||||
protected override void NewItem(string path, string itemTypeName, object newItemValue)
|
||||
{
|
||||
//base.NewItem(path, itemTypeName, newItemValue);
|
||||
}
|
||||
|
||||
protected override PSDriveInfo RemoveDrive(PSDriveInfo drive)
|
||||
{
|
||||
return base.RemoveDrive(drive);
|
||||
}
|
||||
|
||||
protected override void GetChildNames(string path, ReturnContainers returnContainers)
|
||||
{
|
||||
// TODO: get all configured repos based on a path from the underlying GitPsDriveInfo then
|
||||
// output their repository names
|
||||
}
|
||||
|
||||
protected override void GetChildItems(string path, bool recurse)
|
||||
{
|
||||
// TODO: get all configured repos based on a path from the underlying GitPsDriveInfo then
|
||||
// output their repository locations and any other meta data I store
|
||||
}
|
||||
|
||||
// I think this is needed to be able to cd into it
|
||||
protected override bool IsItemContainer(string path)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// I think this is needed to be able to cd into it
|
||||
protected override bool ItemExists(string path)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override string MakePath(string parent, string child)
|
||||
{
|
||||
// Test to see what happens if set the location if the path matches a pretend repo registered with the name test
|
||||
// The result of the test is that yes, this does work, but there's also a shit load of calls to this method as
|
||||
// it traverses the entire directory path.
|
||||
// This seems to just be internal powershell behaviour so as long as this is quick the first time, everything
|
||||
// afterwards just always happens. But probably happens more seeing as I'm changing the location of the path
|
||||
// in this current SessionState and probably doesn't happen if this were a normal path traversal
|
||||
// where I don't set the location.
|
||||
if (child == "test")
|
||||
{
|
||||
SessionState.Path.SetLocation("F:/Repos/PowershellModule/src/PowershellModule/Git");
|
||||
return "F:/Repos/PowershellModule/src/PowershellModule/Git";
|
||||
}
|
||||
|
||||
return base.MakePath(parent, child);
|
||||
}
|
||||
|
||||
protected override bool IsValidPath(string path)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
11
src/PowershellModule/Git/GitPsDriveInfo.cs
Normal file
11
src/PowershellModule/Git/GitPsDriveInfo.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using System.Management.Automation;
|
||||
|
||||
namespace PowershellModule.Git;
|
||||
|
||||
public class GitPsDriveInfo : PSDriveInfo
|
||||
{
|
||||
protected GitPsDriveInfo(PSDriveInfo driveInfo)
|
||||
: base(driveInfo)
|
||||
{
|
||||
}
|
||||
}
|
||||
28
src/PowershellModule/PostBuild.ps1
Normal file
28
src/PowershellModule/PostBuild.ps1
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<#
|
||||
.SYNOPSIS
|
||||
Removes all non-core files from build output
|
||||
#>
|
||||
param(
|
||||
[Parameter(ValueFromPipeline = $true, Position = 0, Mandatory = $true)]
|
||||
$targetDir
|
||||
)
|
||||
|
||||
$nativeSqliteDllLocation = "$( $targetDir )runtimes\win-x64\native\e_sqlite3.dll";
|
||||
|
||||
if ($false -eq (Test-Path $nativeSqliteDllLocation))
|
||||
{
|
||||
Write-Error "POST BUILD FAILED: Unable to locate $nativeSqliteDllLocation"
|
||||
}
|
||||
|
||||
Write-Host "Copying '$nativeSqliteDllLocation' to '$targetDir'"
|
||||
Copy-Item -Path $nativeSqliteDllLocation -Destination $targetDir
|
||||
|
||||
# Because we use CopyLocalLockFileAssemblies Files that are needed for the module
|
||||
$allowList = @(
|
||||
"ModuleCore*"
|
||||
"PowershellModule*"
|
||||
"*SQLite*"
|
||||
"data"
|
||||
)
|
||||
Write-Host "Removing all non-module required files from '$targetDir'"
|
||||
Get-ChildItem -Path $targetDir -exclude $allowList | Remove-Item -Recurse
|
||||
|
|
@ -1,20 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AssemblyName>PowershellModule</AssemblyName>
|
||||
<LangVersion>latestmajor</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AssemblyName>PowershellModule</AssemblyName>
|
||||
<LangVersion>latestmajor</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="PowerShellStandard.Library" >
|
||||
<PrivateAssets>All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.Management.Automation" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="PowerShellStandard.Library">
|
||||
<PrivateAssets>All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.Management.Automation"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ModuleCore\ModuleCore.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ModuleCore\ModuleCore.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Exec Command="pwsh.exe -file "$(ProjectDir)PostBuild.ps1" $(TargetDir)"/>
|
||||
</Target>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"_TODO":[
|
||||
"add manifest details here maybe idk"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue