Compare commits

...
8 changed files with 152 additions and 130 deletions

View file

@ -1,4 +1,6 @@
namespace ModuleCore.Git; using System.Collections.Concurrent;
namespace ModuleCore.Git;
// TODO: better name for this // TODO: better name for this
public class GitManager public class GitManager
@ -11,23 +13,19 @@ public class GitManager
/// </summary> /// </summary>
internal static GitManager InternalFreshInstance => new(); internal static GitManager InternalFreshInstance => new();
/// <summary> private class GitRegistration
/// Simply <see cref="Path.DirectorySeparatorChar"/>.ToString() {
/// </summary> public required string Name { get; set; }
private static readonly string DirectorySeparator = Path.DirectorySeparatorChar.ToString(); public required string Location { get; set; }
}
private readonly InternalDirectory _repositories; private readonly ConcurrentDictionary<string, GitRegistration> _registrations;
private readonly Lock _readWriteLock = new();
private GitManager() private GitManager()
{ {
Console.WriteLine($"{nameof(GitManager)} init"); Console.WriteLine($"{nameof(GitManager)} init");
// Initialise the root container
_repositories = new InternalDirectory() _registrations = new ConcurrentDictionary<string, GitRegistration>();
{
Name = DirectorySeparator,
InternalPath = DirectorySeparator
};
} }
/// <summary> /// <summary>
@ -39,124 +37,45 @@ public class GitManager
/// <returns>The normalised string the repository was registered against</returns> /// <returns>The normalised string the repository was registered against</returns>
public string RegisterRepo(string absoluteRepositoryLocation, string registrationName) public string RegisterRepo(string absoluteRepositoryLocation, string registrationName)
{ {
// Depending on the caller, it might be possible that they've scripted automatic repo registration. Because I registrationName = string.IsNullOrWhiteSpace(registrationName)
// don't really want to account to all the subtle ways that can be parallised, I just naively lock on every
// registration attempt. This method should be quick regardless, and I could use ConcurrentDictionary except
// that means every instance of InternalDirectory would need it and yeah nah fuck that I can just lock at the
// top level
lock (_readWriteLock)
{
var normalisedName = NormaliseNamePath(string.IsNullOrWhiteSpace(registrationName)
? new DirectoryInfo(absoluteRepositoryLocation).Name ? new DirectoryInfo(absoluteRepositoryLocation).Name
: registrationName); : registrationName;
// Regardless of if we get a name or not, the fully qualified version for us if (_registrations.TryAdd(registrationName, new GitRegistration()
// starts with a /
var directorySegmentsFromName = NameToSegments(normalisedName);
var added = _repositories.Add(absoluteRepositoryLocation, directorySegmentsFromName);
// Not sure about this, the Add should throw any exceptions on duplicate/failures but for now I'll leave this
// here
if (added == null)
{ {
throw new Exception("Failed to register location"); Name = registrationName,
Location = absoluteRepositoryLocation,
}))
{
return registrationName;
} }
return normalisedName; throw new Exception($"Git repo already registered with the name {registrationName}");
}
} }
/// <summary> public List<GitReg> ListRepos()
/// Takes a name and returns it as a queue of its parts, starting with a root of <see cref="DirectorySeparator"/>
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
private Queue<string> NameToSegments(string name)
{ {
var segments = name.Split(DirectorySeparator); return _registrations.Select(x => new GitReg() { Name = x.Value.Name, Location = x.Value.Location }).ToList();
return segments.Length == 1
? new Queue<string>([DirectorySeparator, name])
: new Queue<string>([DirectorySeparator, ..segments]);
} }
/// <summary> public string GetRepo(string? registeredName)
/// Normalises the path separators in the given string to use Path.DirectorySeparatorChar
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
private string NormaliseNamePath(string name)
{ {
// Feels a bit hacky, but this will actually normalise a path to a valid form. So if the input is if (string.IsNullOrEmpty(registeredName))
// some/directory/paths, Path.GetRelativePath will normalise it to some\directory\paths, relative to ./ {
// which is kind of handy but I also just wish there was a Path method that would do this for me. I know that throw new Exception("Name cannot be null");
// the whole point of Path is that it's based on a file system, but file systems can also be arbitrary and not
// always be drive rooted.
// Either way, this works and saves me having to reimplement a worse method when it's more important that users
// are able to use file paths in whatever form they prefer, which means we leverage the internal implementation
// in a weird way.
return Path.GetRelativePath("./", name);
} }
private class InternalDirectory if (_registrations.TryGetValue(registeredName, out var registration))
{ {
/// <summary> return registration.Location;
/// Name of the folder this
/// </summary>
public string Name { get; set; } = null!;
public Dictionary<string, InternalDirectory> Children { get; set; } = [];
internal string InternalPath { get; set; }
/// <summary>
/// If not null, this is the absolute location of a registered git repository
/// </summary>
public string? FullRepositoryPath { get; set; }
/// <summary>
///
/// </summary>
/// <param name="absoluteRepositoryLocation"></param>
/// <param name="directorySegmentsFromName"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
internal InternalDirectory? Add(string absoluteRepositoryLocation, Queue<string> directorySegmentsFromName)
{
var topStack = directorySegmentsFromName.Dequeue();
if (topStack != Name)
{
// logically it shouldn't be possible to have a value on top of the stack that _doesn't_ exist, but
// just incase we throw as this should only happen if an Add is attempted on the root and the queue was
// not correctly rooted to /
throw new Exception($"Directory segment does not seem to exist: {topStack}");
} }
// We're at the end of the directory segments so we can safely say we're at the end of the tree so throw new Exception($"No git repo has been registered with the name {registeredName}");
// we add it to the relevant dictionary
if (directorySegmentsFromName.Count == 0)
{
FullRepositoryPath = absoluteRepositoryLocation;
return this;
}
var nextSegment = directorySegmentsFromName.Peek();
// Attempt to get the next level of the directory. If we don't have a key entry, create one
if (!Children.TryGetValue(nextSegment, out var nextChild))
{
nextChild = new InternalDirectory()
{
Name = nextSegment,
InternalPath = Path.Combine(InternalPath, nextSegment)
};
Children.Add(nextSegment, nextChild);
}
// add the next
return nextChild.Add(absoluteRepositoryLocation, directorySegmentsFromName);
}
} }
} }
public class GitReg
{
public required string Name { get; set; }
public required string Location { get; set; }
}

View file

@ -0,0 +1,17 @@
using System.Management.Automation;
using ModuleCore.Git;
namespace PowershellModule.Git.Commands;
[Cmdlet(VerbsCommon.Get, GitCommands.GitRepoNoun)]
public class ListGitRepoCommand : PSCmdlet
{
protected override void BeginProcessing()
{
var repos = GitManager.Instance.ListRepos();
WriteObject(repos);
base.BeginProcessing();
}
}

View file

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

View file

@ -4,13 +4,11 @@ using System.IO;
using System.Management.Automation; using System.Management.Automation;
using ModuleCore.Git; using ModuleCore.Git;
namespace PowershellModule.Git; namespace PowershellModule.Git.Commands;
[Cmdlet(VerbsCommon.New, Noun)] [Cmdlet(VerbsCommon.New, GitCommands.GitRepoNoun)]
public class NewGitRepoCommand : PSCmdlet public sealed class NewGitRepoCommand : PSCmdlet
{ {
private const string Noun = "GitRepo";
[Parameter( [Parameter(
Position = 0, Position = 0,
ValueFromPipeline = true, ValueFromPipeline = true,

View file

@ -2,13 +2,12 @@
using System.Management.Automation; using System.Management.Automation;
using ModuleCore.Git; using ModuleCore.Git;
namespace PowershellModule.Git; namespace PowershellModule.Git.Commands;
[Cmdlet(VerbsCommon.Set, Noun)] // TODO: decide if I want to use this verb instead of show. Currently this implementation is under Show-GitRepo
[Cmdlet(VerbsCommon.Set, GitCommands.GitRepoNoun)]
public class SetGitRepoCommand : PSCmdlet public class SetGitRepoCommand : PSCmdlet
{ {
private const string Noun = "GitRepo";
public SetGitRepoCommand() public SetGitRepoCommand()
{ {
Console.WriteLine($"{nameof(NewGitRepoCommand)} init"); Console.WriteLine($"{nameof(NewGitRepoCommand)} init");

View file

@ -0,0 +1,43 @@
using System;
using System.Management.Automation;
using ModuleCore.Git;
namespace PowershellModule.Git.Commands;
[Cmdlet(VerbsCommon.Show, GitCommands.GitRepoNoun)]
public class ShowGitRepoCommand : 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("NoSetLocation")]
public SwitchParameter NoStack { get; set; }
protected override void BeginProcessing()
{
var location = GitManager.Instance.GetRepo(Name);
// By default instead of doing the same as cd, we instead do pushd so a user can popd straight back to where
// they came from.
// TODO: incorporate this into the custom prompt when I develop that
if (!NoStack)
{
// Push the current location to the stack
// TODO: support named stacks. PowerShell *-Location commands support named stacks, but I don't personally
// use them myself so I haven't implemented them initially. I would like to in the future though, but right
// now it's low value to me.
// https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/set-location?view=powershell-7.6#example-4-set-the-current-location-to-a-named-stack
SessionState.Path.PushCurrentLocation(null);
}
SessionState.Path.SetLocation(location);
base.BeginProcessing();
}
}

View file

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

View file

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