diff --git a/README.md b/README.md index b5ffc111..c46f4740 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,22 @@ Options: -?, -h, --help Show help and usage information ``` +#### `stack rename` + +Rename a stack. + +```shell +Usage: + stack rename [options] + +Options: + --working-dir The path to the directory containing the git repository. Defaults to the current directory. + --verbose Show verbose output. + -s, --stack The name of the stack. + -n, --name The new name for the stack. + -?, -h, --help Show help and usage information +``` + ### Branch commands #### `stack update` diff --git a/src/Stack.Tests/Commands/Stack/RenameStackCommandHandlerTests.cs b/src/Stack.Tests/Commands/Stack/RenameStackCommandHandlerTests.cs new file mode 100644 index 00000000..c2411426 --- /dev/null +++ b/src/Stack.Tests/Commands/Stack/RenameStackCommandHandlerTests.cs @@ -0,0 +1,320 @@ +using FluentAssertions; +using NSubstitute; +using Meziantou.Extensions.Logging.Xunit; +using Microsoft.Extensions.Logging; +using Stack.Commands; +using Stack.Commands.Helpers; +using Stack.Config; +using Stack.Git; +using Stack.Infrastructure; +using Stack.Tests.Helpers; +using Xunit.Abstractions; + +namespace Stack.Tests.Commands.Stack; + +public class RenameStackCommandHandlerTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public async Task WhenNoInputsAreProvided_AsksForStackAndNewName_RenamesStack() + { + // Arrange + var sourceBranch = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch)) + .WithStack(stack => stack + .WithName("Stack2") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch)) + .Build(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var handler = new RenameStackCommandHandler(inputProvider, logger, gitClient, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + inputProvider.Text(Questions.StackName, Arg.Any()).Returns("RenamedStack"); + + // Act + await handler.Handle(RenameStackCommandInputs.Empty, CancellationToken.None); + + // Assert + stackConfig.Stacks.Should().HaveCount(2); + stackConfig.Stacks.Should().Contain(s => s.Name == "RenamedStack"); + stackConfig.Stacks.Should().Contain(s => s.Name == "Stack2"); + stackConfig.Stacks.Should().NotContain(s => s.Name == "Stack1"); + } + + [Fact] + public async Task WhenStackNameProvided_AsksOnlyForNewName_RenamesStack() + { + // Arrange + var sourceBranch = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch)) + .Build(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var handler = new RenameStackCommandHandler(inputProvider, logger, gitClient, stackConfig); + + inputProvider.Text(Questions.StackName, Arg.Any()).Returns("RenamedStack"); + + // Act + await handler.Handle(new RenameStackCommandInputs("Stack1", null), CancellationToken.None); + + // Assert + await inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any(), Arg.Any()); + stackConfig.Stacks.Should().HaveCount(1); + stackConfig.Stacks.Should().Contain(s => s.Name == "RenamedStack"); + } + + [Fact] + public async Task WhenNewNameProvided_AsksOnlyForStack_RenamesStack() + { + // Arrange + var sourceBranch = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch)) + .Build(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var handler = new RenameStackCommandHandler(inputProvider, logger, gitClient, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + + // Act + await handler.Handle(new RenameStackCommandInputs(null, "RenamedStack"), CancellationToken.None); + + // Assert + await inputProvider.DidNotReceive().Text(Questions.StackName, Arg.Any()); + stackConfig.Stacks.Should().HaveCount(1); + stackConfig.Stacks.Should().Contain(s => s.Name == "RenamedStack"); + } + + [Fact] + public async Task WhenBothInputsProvided_DoesNotAskForAnything_RenamesStack() + { + // Arrange + var sourceBranch = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch)) + .Build(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var handler = new RenameStackCommandHandler(inputProvider, logger, gitClient, stackConfig); + + // Act + await handler.Handle(new RenameStackCommandInputs("Stack1", "RenamedStack"), CancellationToken.None); + + // Assert + await inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any(), Arg.Any()); + await inputProvider.DidNotReceive().Text(Questions.StackName, Arg.Any()); + stackConfig.Stacks.Should().HaveCount(1); + stackConfig.Stacks.Should().Contain(s => s.Name == "RenamedStack"); + } + + [Fact] + public async Task WhenStackDoesNotExist_ThrowsException() + { + // Arrange + var sourceBranch = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch)) + .Build(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var handler = new RenameStackCommandHandler(inputProvider, logger, gitClient, stackConfig); + + // Act & Assert + var act = async () => await handler.Handle(new RenameStackCommandInputs("NonExistentStack", "NewName"), CancellationToken.None); + await act.Should().ThrowAsync().WithMessage("Stack not found."); + } + + [Fact] + public async Task WhenNewNameAlreadyExists_ThrowsException() + { + // Arrange + var sourceBranch = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch)) + .WithStack(stack => stack + .WithName("Stack2") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch)) + .Build(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var handler = new RenameStackCommandHandler(inputProvider, logger, gitClient, stackConfig); + + // Act & Assert + var act = async () => await handler.Handle(new RenameStackCommandInputs("Stack1", "Stack2"), CancellationToken.None); + await act.Should().ThrowAsync().WithMessage("A stack with the name 'Stack2' already exists for this remote."); + } + + [Fact] + public async Task WhenRenamingToSameName_DoesNotThrowException() + { + // Arrange + var sourceBranch = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch)) + .Build(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var handler = new RenameStackCommandHandler(inputProvider, logger, gitClient, stackConfig); + + // Act + await handler.Handle(new RenameStackCommandInputs("Stack1", "Stack1"), CancellationToken.None); + + // Assert + stackConfig.Stacks.Should().HaveCount(1); + stackConfig.Stacks.Should().Contain(s => s.Name == "Stack1"); + } + + [Fact] + public async Task WhenStacksWithSameNameExistAcrossDifferentRemotes_AllowsRename() + { + // Arrange + var sourceBranch = Some.BranchName(); + var remoteUri1 = Some.HttpsUri().ToString(); + var remoteUri2 = Some.HttpsUri().ToString(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri1) + .WithSourceBranch(sourceBranch)) + .WithStack(stack => stack + .WithName("ExistingName") + .WithRemoteUri(remoteUri2) + .WithSourceBranch(sourceBranch)) + .Build(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + + gitClient.GetRemoteUri().Returns(remoteUri1); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var handler = new RenameStackCommandHandler(inputProvider, logger, gitClient, stackConfig); + + // Act - Rename Stack1 to ExistingName (which exists on a different remote) + await handler.Handle(new RenameStackCommandInputs("Stack1", "ExistingName"), CancellationToken.None); + + // Assert + stackConfig.Stacks.Should().HaveCount(2); + stackConfig.Stacks.Should().Contain(s => s.Name == "ExistingName" && s.RemoteUri == remoteUri1); + stackConfig.Stacks.Should().Contain(s => s.Name == "ExistingName" && s.RemoteUri == remoteUri2); + stackConfig.Stacks.Should().NotContain(s => s.Name == "Stack1"); + } + + [Fact] + public async Task WhenNoStacksExistForRemote_LogsNoStacksMessageAndReturns() + { + // Arrange + var sourceBranch = Some.BranchName(); + var remoteUri1 = Some.HttpsUri().ToString(); + var remoteUri2 = Some.HttpsUri().ToString(); + + // Create a stack config with stacks only for a different remote + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri2) + .WithSourceBranch(sourceBranch)) + .Build(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + + gitClient.GetRemoteUri().Returns(remoteUri1); // Different remote than the stack + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var handler = new RenameStackCommandHandler(inputProvider, logger, gitClient, stackConfig); + + // Act + await handler.Handle(new RenameStackCommandInputs("Stack1", "NewName"), CancellationToken.None); + + // Assert + // Verify the stack config was not modified + stackConfig.Stacks.Should().HaveCount(1); + stackConfig.Stacks.Should().Contain(s => s.Name == "Stack1" && s.RemoteUri == remoteUri2); + } +} \ No newline at end of file diff --git a/src/Stack/Commands/Stack/RenameStackCommand.cs b/src/Stack/Commands/Stack/RenameStackCommand.cs new file mode 100644 index 00000000..d9abf7d9 --- /dev/null +++ b/src/Stack/Commands/Stack/RenameStackCommand.cs @@ -0,0 +1,108 @@ +using System.CommandLine; +using Microsoft.Extensions.Logging; +using Stack.Commands.Helpers; +using Stack.Config; +using Stack.Git; +using Stack.Infrastructure; +using Stack.Infrastructure.Settings; + +namespace Stack.Commands; + +public class RenameStackCommand : Command +{ + static new readonly Option Name = new("--name", "-n") + { + Description = "The new name for the stack.", + Required = false + }; + + private readonly RenameStackCommandHandler handler; + + public RenameStackCommand( + ILogger logger, + IDisplayProvider displayProvider, + IInputProvider inputProvider, + CliExecutionContext executionContext, + RenameStackCommandHandler handler) + : base("rename", "Rename a stack.", logger, displayProvider, inputProvider, executionContext) + { + this.handler = handler; + Add(CommonOptions.Stack); + Add(Name); + } + + protected override async Task Execute(ParseResult parseResult, CancellationToken cancellationToken) + { + await handler.Handle( + new RenameStackCommandInputs( + parseResult.GetValue(CommonOptions.Stack), + parseResult.GetValue(Name)), + cancellationToken); + } +} + +public record RenameStackCommandInputs(string? Stack, string? Name) +{ + public static RenameStackCommandInputs Empty => new(null, null); +} + +public record RenameStackCommandResponse(); + +public class RenameStackCommandHandler( + IInputProvider inputProvider, + ILogger logger, + IGitClient gitClient, + IStackConfig stackConfig) + : CommandHandlerBase +{ + public override async Task Handle(RenameStackCommandInputs inputs, CancellationToken cancellationToken) + { + var stackData = stackConfig.Load(); + + var remoteUri = gitClient.GetRemoteUri(); + var currentBranch = gitClient.GetCurrentBranch(); + + var stacksForRemote = stackData.Stacks.Where(s => s.RemoteUri.Equals(remoteUri, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (stacksForRemote.Count == 0) + { + logger.NoStacksForRepository(); + return; + } + + var stack = await inputProvider.SelectStack(logger, inputs.Stack, stacksForRemote, currentBranch, cancellationToken); + + if (stack is null) + { + throw new InvalidOperationException("Stack not found."); + } + + var newName = await inputProvider.Text(logger, Questions.StackName, inputs.Name, cancellationToken); + + // Validate that there's not another stack with the same name for the same remote + var existingStackWithSameName = stacksForRemote.FirstOrDefault(s => + s.Name.Equals(newName, StringComparison.OrdinalIgnoreCase) && + !s.Name.Equals(stack.Name, StringComparison.OrdinalIgnoreCase)); + + if (existingStackWithSameName is not null) + { + throw new InvalidOperationException($"A stack with the name '{newName}' already exists for this remote."); + } + + var renamedStack = stack.ChangeName(newName); + + // Update the stack in the collection + var stackIndex = stackData.Stacks.IndexOf(stack); + stackData.Stacks[stackIndex] = renamedStack; + + stackConfig.Save(stackData); + + logger.StackRenamed(stack.Name, newName); + } +} + +internal static partial class LoggerExtensionMethods +{ + [LoggerMessage(Level = LogLevel.Information, Message = "Stack \"{OldName}\" renamed to \"{NewName}\"")] + public static partial void StackRenamed(this ILogger logger, string oldName, string newName); +} \ No newline at end of file diff --git a/src/Stack/Commands/StackRootCommand.cs b/src/Stack/Commands/StackRootCommand.cs index 3b182663..04084f37 100644 --- a/src/Stack/Commands/StackRootCommand.cs +++ b/src/Stack/Commands/StackRootCommand.cs @@ -15,6 +15,7 @@ public StackRootCommand( PullRequestsCommand pullRequestsCommand, PullStackCommand pullStackCommand, PushStackCommand pushStackCommand, + RenameStackCommand renameStackCommand, StackStatusCommand stackStatusCommand, StackSwitchCommand stackSwitchCommand, SyncStackCommand syncStackCommand, @@ -29,6 +30,7 @@ public StackRootCommand( Add(pullRequestsCommand); Add(pullStackCommand); Add(pushStackCommand); + Add(renameStackCommand); Add(stackStatusCommand); Add(stackSwitchCommand); Add(syncStackCommand); diff --git a/src/Stack/Config/Stack.cs b/src/Stack/Config/Stack.cs index 69b5dadf..c00ba36f 100644 --- a/src/Stack/Config/Stack.cs +++ b/src/Stack/Config/Stack.cs @@ -95,6 +95,11 @@ public List> GetAllBranchLines() } return allLines; } + + public Stack ChangeName(string newName) + { + return this with { Name = newName }; + } } public record Branch(string Name, List Children) diff --git a/src/Stack/Infrastructure/HostApplicationBuilderExtensions.cs b/src/Stack/Infrastructure/HostApplicationBuilderExtensions.cs index a3b457a2..73137c4f 100644 --- a/src/Stack/Infrastructure/HostApplicationBuilderExtensions.cs +++ b/src/Stack/Infrastructure/HostApplicationBuilderExtensions.cs @@ -75,6 +75,7 @@ private static void RegisterCommandHandlers(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -99,6 +100,7 @@ private static void RegisterCommands(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient();