Skip to content

Commit

Permalink
Adds new cleanup command (#56)
Browse files Browse the repository at this point in the history
  • Loading branch information
geofflamrock authored Nov 21, 2024
1 parent 5326fc9 commit be24c3a
Show file tree
Hide file tree
Showing 4 changed files with 345 additions and 0 deletions.
208 changes: 208 additions & 0 deletions src/Stack.Tests/Commands/Stack/CleanupStackCommandHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -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<IGitOperations>();
var stackConfig = Substitute.For<IStackConfig>();
var inputProvider = Substitute.For<ICleanupStackCommandInputProvider>();
var outputProvider = Substitute.For<IOutputProvider>();
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<string[]>()).Returns(["branch-1", "branch-2"]);
gitOperations.GetBranchesThatExistInRemote(Arg.Any<string[]>()).Returns(["branch-1"]);

var stacks = new List<Config.Stack>(
[
new("Stack1", remoteUri, "branch-1", ["branch-2"])
]);
stackConfig.Load().Returns(stacks);

inputProvider.SelectStack(Arg.Any<List<Config.Stack>>(), Arg.Any<string>()).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<IGitOperations>();
var stackConfig = Substitute.For<IStackConfig>();
var inputProvider = Substitute.For<ICleanupStackCommandInputProvider>();
var outputProvider = Substitute.For<IOutputProvider>();
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<string[]>()).Returns(["branch-1", "branch-2"]);
gitOperations.GetBranchesThatExistInRemote(Arg.Any<string[]>()).Returns(["branch-1", "branch-2"]);

var stacks = new List<Config.Stack>(
[
new("Stack1", remoteUri, "branch-1", ["branch-2"])
]);
stackConfig.Load().Returns(stacks);

inputProvider.SelectStack(Arg.Any<List<Config.Stack>>(), Arg.Any<string>()).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<IGitOperations>();
var stackConfig = Substitute.For<IStackConfig>();
var inputProvider = Substitute.For<ICleanupStackCommandInputProvider>();
var outputProvider = Substitute.For<IOutputProvider>();
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<string[]>()).Returns(["branch-1", "branch-2"]);
gitOperations.GetBranchesThatExistInRemote(Arg.Any<string[]>()).Returns(["branch-1"]);

var stacks = new List<Config.Stack>(
[
new("Stack1", remoteUri, "branch-1", ["branch-2"])
]);
stackConfig.Load().Returns(stacks);

inputProvider.SelectStack(Arg.Any<List<Config.Stack>>(), Arg.Any<string>()).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<IGitOperations>();
var stackConfig = Substitute.For<IStackConfig>();
var inputProvider = Substitute.For<ICleanupStackCommandInputProvider>();
var outputProvider = Substitute.For<IOutputProvider>();
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<string[]>()).Returns(["branch-1", "branch-2"]);
gitOperations.GetBranchesThatExistInRemote(Arg.Any<string[]>()).Returns(["branch-1"]);

var stacks = new List<Config.Stack>(
[
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<List<Config.Stack>>(), Arg.Any<string>());
}

[Fact]
public async Task WhenForceIsProvided_ItIsNotAskedFor()
{
// Arrange
var gitOperations = Substitute.For<IGitOperations>();
var stackConfig = Substitute.For<IStackConfig>();
var inputProvider = Substitute.For<ICleanupStackCommandInputProvider>();
var outputProvider = Substitute.For<IOutputProvider>();
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<string[]>()).Returns(["branch-1", "branch-2"]);
gitOperations.GetBranchesThatExistInRemote(Arg.Any<string[]>()).Returns(["branch-1"]);

var stacks = new List<Config.Stack>(
[
new("Stack1", remoteUri, "branch-1", ["branch-2"])
]);
stackConfig.Load().Returns(stacks);

inputProvider.SelectStack(Arg.Any<List<Config.Stack>>(), Arg.Any<string>()).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<IGitOperations>();
var stackConfig = Substitute.For<IStackConfig>();
var inputProvider = Substitute.For<ICleanupStackCommandInputProvider>();
var outputProvider = Substitute.For<IOutputProvider>();
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<string[]>()).Returns(["branch-1", "branch-2"]);
gitOperations.GetBranchesThatExistInRemote(Arg.Any<string[]>()).Returns(["branch-1"]);

var stacks = new List<Config.Stack>(
[
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<InvalidOperationException>()
.WithMessage($"Stack '{invalidStackName}' not found.");
}
}
130 changes: 130 additions & 0 deletions src/Stack/Commands/Stack/CleanupStackCommand.cs
Original file line number Diff line number Diff line change
@@ -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<CleanupStackCommandSettings>
{
public override async Task<int> 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<Config.Stack> 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<Config.Stack> 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");
}
}
}
6 changes: 6 additions & 0 deletions src/Stack/Git/GitOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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}");
Expand Down
1 change: 1 addition & 0 deletions src/Stack/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
configure.AddCommand<ListStacksCommand>("list").WithDescription("List stacks.");
configure.AddCommand<StackStatusCommand>("status").WithDescription("Shows the status of a stack.");
configure.AddCommand<DeleteStackCommand>("delete").WithDescription("Deletes a stack.");
configure.AddCommand<CleanupStackCommand>("cleanup").WithDescription("Cleans up unused branches in a stack.");

// Branch commands
configure.AddCommand<StackSwitchCommand>("switch").WithDescription("Switches to a branch in a stack.");
Expand Down

0 comments on commit be24c3a

Please sign in to comment.