From be24c3ad91695fe57904233021273f26580953e2 Mon Sep 17 00:00:00 2001 From: Geoff Lamrock Date: Thu, 21 Nov 2024 22:43:11 +1100 Subject: [PATCH] Adds new `cleanup` command (#56) --- .../Stack/CleanupStackCommandHandlerTests.cs | 208 ++++++++++++++++++ .../Commands/Stack/CleanupStackCommand.cs | 130 +++++++++++ src/Stack/Git/GitOperations.cs | 6 + src/Stack/Program.cs | 1 + 4 files changed, 345 insertions(+) create mode 100644 src/Stack.Tests/Commands/Stack/CleanupStackCommandHandlerTests.cs create mode 100644 src/Stack/Commands/Stack/CleanupStackCommand.cs diff --git a/src/Stack.Tests/Commands/Stack/CleanupStackCommandHandlerTests.cs b/src/Stack.Tests/Commands/Stack/CleanupStackCommandHandlerTests.cs new file mode 100644 index 0000000..ef414d5 --- /dev/null +++ b/src/Stack.Tests/Commands/Stack/CleanupStackCommandHandlerTests.cs @@ -0,0 +1,208 @@ +using FluentAssertions; +using NSubstitute; +using Stack.Commands; +using Stack.Config; +using Stack.Git; +using Stack.Infrastructure; +using Stack.Tests.Helpers; + +namespace Stack.Tests.Commands.Stack; + +public class CleanupStackCommandHandlerTests +{ + [Fact] + public async Task WhenBranchExistsLocally_ButNotInRemote_BranchIsDeletedLocally() + { + // Arrange + var gitOperations = Substitute.For(); + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = Substitute.For(); + var handler = new CleanupStackCommandHandler(inputProvider, outputProvider, gitOperations, stackConfig); + + var remoteUri = Some.HttpsUri().ToString(); + + gitOperations.GetRemoteUri().Returns(remoteUri); + gitOperations.GetCurrentBranch().Returns("branch-1"); + gitOperations.GetBranchesThatExistLocally(Arg.Any()).Returns(["branch-1", "branch-2"]); + gitOperations.GetBranchesThatExistInRemote(Arg.Any()).Returns(["branch-1"]); + + var stacks = new List( + [ + new("Stack1", remoteUri, "branch-1", ["branch-2"]) + ]); + stackConfig.Load().Returns(stacks); + + inputProvider.SelectStack(Arg.Any>(), Arg.Any()).Returns("Stack1"); + inputProvider.ConfirmCleanup().Returns(true); + + // Act + await handler.Handle(CleanupStackCommandInputs.Empty); + + // Assert + gitOperations.Received().DeleteLocalBranch("branch-2"); + } + + [Fact] + public async Task WhenBranchExistsLocally_AndInRemote_BranchIsNotDeletedLocally() + { + // Arrange + var gitOperations = Substitute.For(); + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = Substitute.For(); + var handler = new CleanupStackCommandHandler(inputProvider, outputProvider, gitOperations, stackConfig); + + var remoteUri = Some.HttpsUri().ToString(); + + gitOperations.GetRemoteUri().Returns(remoteUri); + gitOperations.GetCurrentBranch().Returns("branch-1"); + gitOperations.GetBranchesThatExistLocally(Arg.Any()).Returns(["branch-1", "branch-2"]); + gitOperations.GetBranchesThatExistInRemote(Arg.Any()).Returns(["branch-1", "branch-2"]); + + var stacks = new List( + [ + new("Stack1", remoteUri, "branch-1", ["branch-2"]) + ]); + stackConfig.Load().Returns(stacks); + + inputProvider.SelectStack(Arg.Any>(), Arg.Any()).Returns("Stack1"); + inputProvider.ConfirmCleanup().Returns(true); + + // Act + await handler.Handle(CleanupStackCommandInputs.Empty); + + // Assert + gitOperations.DidNotReceive().DeleteLocalBranch("branch-2"); + } + + [Fact] + public async Task WhenConfirmationIsFalse_DoesNotDeleteAnyBranches() + { + // Arrange + var gitOperations = Substitute.For(); + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = Substitute.For(); + var handler = new CleanupStackCommandHandler(inputProvider, outputProvider, gitOperations, stackConfig); + + var remoteUri = Some.HttpsUri().ToString(); + + gitOperations.GetRemoteUri().Returns(remoteUri); + gitOperations.GetCurrentBranch().Returns("branch-1"); + gitOperations.GetBranchesThatExistLocally(Arg.Any()).Returns(["branch-1", "branch-2"]); + gitOperations.GetBranchesThatExistInRemote(Arg.Any()).Returns(["branch-1"]); + + var stacks = new List( + [ + new("Stack1", remoteUri, "branch-1", ["branch-2"]) + ]); + stackConfig.Load().Returns(stacks); + + inputProvider.SelectStack(Arg.Any>(), Arg.Any()).Returns("Stack1"); + inputProvider.ConfirmCleanup().Returns(false); + + // Act + await handler.Handle(CleanupStackCommandInputs.Empty); + + // Assert + gitOperations.DidNotReceive().DeleteLocalBranch("branch-2"); + } + + [Fact] + public async Task WhenStackNameIsProvided_ItIsNotAskedFor() + { + // Arrange + var gitOperations = Substitute.For(); + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = Substitute.For(); + var handler = new CleanupStackCommandHandler(inputProvider, outputProvider, gitOperations, stackConfig); + + var remoteUri = Some.HttpsUri().ToString(); + + gitOperations.GetRemoteUri().Returns(remoteUri); + gitOperations.GetCurrentBranch().Returns("branch-1"); + gitOperations.GetBranchesThatExistLocally(Arg.Any()).Returns(["branch-1", "branch-2"]); + gitOperations.GetBranchesThatExistInRemote(Arg.Any()).Returns(["branch-1"]); + + var stacks = new List( + [ + new("Stack1", remoteUri, "branch-1", ["branch-2"]) + ]); + stackConfig.Load().Returns(stacks); + + inputProvider.ConfirmCleanup().Returns(true); + + // Act + await handler.Handle(new CleanupStackCommandInputs("Stack1", false)); + + // Assert + inputProvider.DidNotReceive().SelectStack(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task WhenForceIsProvided_ItIsNotAskedFor() + { + // Arrange + var gitOperations = Substitute.For(); + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = Substitute.For(); + var handler = new CleanupStackCommandHandler(inputProvider, outputProvider, gitOperations, stackConfig); + + var remoteUri = Some.HttpsUri().ToString(); + + gitOperations.GetRemoteUri().Returns(remoteUri); + gitOperations.GetCurrentBranch().Returns("branch-1"); + gitOperations.GetBranchesThatExistLocally(Arg.Any()).Returns(["branch-1", "branch-2"]); + gitOperations.GetBranchesThatExistInRemote(Arg.Any()).Returns(["branch-1"]); + + var stacks = new List( + [ + new("Stack1", remoteUri, "branch-1", ["branch-2"]) + ]); + stackConfig.Load().Returns(stacks); + + inputProvider.SelectStack(Arg.Any>(), Arg.Any()).Returns("Stack1"); + + // Act + await handler.Handle(new CleanupStackCommandInputs(null, true)); + + // Assert + inputProvider.DidNotReceive().ConfirmCleanup(); + } + + [Fact] + public async Task WhenStackNameIsProvided_ButStackDoesNotExist_Throws() + { + // Arrange + var gitOperations = Substitute.For(); + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = Substitute.For(); + var handler = new CleanupStackCommandHandler(inputProvider, outputProvider, gitOperations, stackConfig); + + var remoteUri = Some.HttpsUri().ToString(); + + gitOperations.GetRemoteUri().Returns(remoteUri); + gitOperations.GetCurrentBranch().Returns("branch-1"); + gitOperations.GetBranchesThatExistLocally(Arg.Any()).Returns(["branch-1", "branch-2"]); + gitOperations.GetBranchesThatExistInRemote(Arg.Any()).Returns(["branch-1"]); + + var stacks = new List( + [ + new("Stack1", remoteUri, "branch-1", ["branch-2"]) + ]); + stackConfig.Load().Returns(stacks); + + inputProvider.ConfirmCleanup().Returns(true); + + // Act and assert + var invalidStackName = Some.Name(); + await handler.Invoking(async h => await h.Handle(new CleanupStackCommandInputs(invalidStackName, false))) + .Should() + .ThrowAsync() + .WithMessage($"Stack '{invalidStackName}' not found."); + } +} diff --git a/src/Stack/Commands/Stack/CleanupStackCommand.cs b/src/Stack/Commands/Stack/CleanupStackCommand.cs new file mode 100644 index 0000000..96ef0fa --- /dev/null +++ b/src/Stack/Commands/Stack/CleanupStackCommand.cs @@ -0,0 +1,130 @@ + +using System.ComponentModel; +using Spectre.Console; +using Spectre.Console.Cli; +using Stack.Config; +using Stack.Git; +using Stack.Infrastructure; + +namespace Stack.Commands; + +public class CleanupStackCommandSettings : DryRunCommandSettingsBase +{ + [Description("The name of the stack to cleanup.")] + [CommandOption("-n|--name")] + public string? Name { get; init; } + + [Description("Cleanup the stack without prompting.")] + [CommandOption("-f|--force")] + public bool Force { get; init; } +} + +public class CleanupStackCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, CleanupStackCommandSettings settings) + { + await Task.CompletedTask; + + var console = AnsiConsole.Console; + var handler = new CleanupStackCommandHandler( + new CleanupStackCommandInputProvider(new ConsoleInputProvider(console)), + new ConsoleOutputProvider(console), + new GitOperations(console, settings.GetGitOperationSettings()), + new StackConfig()); + + await handler.Handle(new CleanupStackCommandInputs(settings.Name, settings.Force)); + + return 0; + } +} + +public interface ICleanupStackCommandInputProvider +{ + string SelectStack(List stacks, string currentBranch); + bool ConfirmCleanup(); +} + +public class CleanupStackCommandInputProvider(IInputProvider inputProvider) : ICleanupStackCommandInputProvider +{ + const string SelectStackPrompt = "Select stack:"; + const string CleanupStackPrompt = "Do you want to continue?"; + + public string SelectStack(List stacks, string currentBranch) + { + return inputProvider.Select(SelectStackPrompt, stacks.OrderByCurrentStackThenByName(currentBranch).Select(s => s.Name).ToArray()); + } + + public bool ConfirmCleanup() + { + return inputProvider.Confirm(CleanupStackPrompt); + } +} + +public record CleanupStackCommandInputs(string? Name, bool Force) +{ + public static CleanupStackCommandInputs Empty => new(null, false); +} + +public record CleanupStackCommandResponse(string? CleanedUpStackName); + +public class CleanupStackCommandHandler( + ICleanupStackCommandInputProvider inputProvider, + IOutputProvider outputProvider, + IGitOperations gitOperations, + IStackConfig stackConfig) +{ + public async Task Handle(CleanupStackCommandInputs inputs) + { + await Task.CompletedTask; + var stacks = stackConfig.Load(); + + var remoteUri = gitOperations.GetRemoteUri(); + var currentBranch = gitOperations.GetCurrentBranch(); + + var stacksForRemote = stacks.Where(s => s.RemoteUri.Equals(remoteUri, StringComparison.OrdinalIgnoreCase)).ToList(); + + var stackSelection = inputs.Name ?? inputProvider.SelectStack(stacksForRemote, currentBranch); + var stack = stacksForRemote.FirstOrDefault(s => s.Name.Equals(stackSelection, StringComparison.OrdinalIgnoreCase)); + + if (stack is null) + { + throw new InvalidOperationException($"Stack '{inputs.Name}' not found."); + } + + var branchesInTheStackThatExistLocally = gitOperations.GetBranchesThatExistLocally([.. stack.Branches]); + var branchesInTheStackThatExistInTheRemote = gitOperations.GetBranchesThatExistInRemote([.. stack.Branches]); + + var branchesToCleanUp = branchesInTheStackThatExistLocally.Except(branchesInTheStackThatExistInTheRemote).ToList(); + + if (branchesToCleanUp.Count == 0) + { + outputProvider.Information("No branches to clean up"); + return; + } + + if (!inputs.Force) + { + outputProvider.Information($"The following branches from stack {stack.Name.Stack()} will be deleted:"); + + foreach (var branch in branchesToCleanUp) + { + outputProvider.Information($" {branch.Branch()}"); + } + } + + if (inputs.Force || inputProvider.ConfirmCleanup()) + { + foreach (var branch in stack.Branches) + { + if (!branchesInTheStackThatExistInTheRemote.Contains(branch) && + branchesInTheStackThatExistLocally.Contains(branch)) + { + outputProvider.Information($"Deleting local branch {branch.Branch()}"); + gitOperations.DeleteLocalBranch(branch); + } + } + + outputProvider.Information($"Stack {stack.Name.Stack()} cleaned up"); + } + } +} diff --git a/src/Stack/Git/GitOperations.cs b/src/Stack/Git/GitOperations.cs index 82cac0f..1a2d99e 100644 --- a/src/Stack/Git/GitOperations.cs +++ b/src/Stack/Git/GitOperations.cs @@ -20,6 +20,7 @@ public interface IGitOperations void FetchBranches(string[] branches); void PullBranch(string branchName); void UpdateBranch(string branchName); + void DeleteLocalBranch(string branchName); void MergeFromLocalSourceBranch(string sourceBranchName); string GetCurrentBranch(); string GetDefaultBranch(); @@ -78,6 +79,11 @@ public void UpdateBranch(string branchName) PullBranch(branchName); } + public void DeleteLocalBranch(string branchName) + { + ExecuteGitCommand($"branch -D {branchName}"); + } + public void MergeFromLocalSourceBranch(string sourceBranchName) { ExecuteGitCommand($"merge {sourceBranchName}"); diff --git a/src/Stack/Program.cs b/src/Stack/Program.cs index a7e7519..2a84fc7 100644 --- a/src/Stack/Program.cs +++ b/src/Stack/Program.cs @@ -14,6 +14,7 @@ configure.AddCommand("list").WithDescription("List stacks."); configure.AddCommand("status").WithDescription("Shows the status of a stack."); configure.AddCommand("delete").WithDescription("Deletes a stack."); + configure.AddCommand("cleanup").WithDescription("Cleans up unused branches in a stack."); // Branch commands configure.AddCommand("switch").WithDescription("Switches to a branch in a stack.");