Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 145 additions & 8 deletions src/Stack.Tests/Commands/Branch/NewBranchCommandHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public async Task WhenNoInputsProvided_AsksForStackAndBranch_CreatesAndAddsBranc
inputProvider.Text(Questions.BranchName, Arg.Any<string>()).Returns(newBranch);

// Act
await handler.Handle(new NewBranchCommandInputs(null, null));
await handler.Handle(new NewBranchCommandInputs(null, null, null));

// Assert
stackConfig.Stacks.Should().BeEquivalentTo(new List<Config.Stack>
Expand Down Expand Up @@ -87,7 +87,7 @@ public async Task WhenStackNameProvided_DoesNotAskForStackName_CreatesAndAddsBra
inputProvider.Text(Questions.BranchName, Arg.Any<string>()).Returns(newBranch);

// Act
await handler.Handle(new NewBranchCommandInputs("Stack1", null));
await handler.Handle(new NewBranchCommandInputs("Stack1", null, null));

// Assert
inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any<string[]>());
Expand Down Expand Up @@ -125,7 +125,7 @@ public async Task WhenOnlyOneStackExists_DoesNotAskForStackName_CreatesAndAddsBr
inputProvider.Text(Questions.BranchName, Arg.Any<string>()).Returns(newBranch);

// Act
await handler.Handle(new NewBranchCommandInputs(null, null));
await handler.Handle(new NewBranchCommandInputs(null, null, null));

// Assert
inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any<string[]>());
Expand Down Expand Up @@ -165,7 +165,7 @@ public async Task WhenStackNameProvided_ButStackDoesNotExist_Throws()

// Act and assert
var invalidStackName = Some.Name();
await handler.Invoking(async h => await h.Handle(new NewBranchCommandInputs(invalidStackName, null)))
await handler.Invoking(async h => await h.Handle(new NewBranchCommandInputs(invalidStackName, null, null)))
.Should()
.ThrowAsync<InvalidOperationException>()
.WithMessage($"Stack '{invalidStackName}' not found.");
Expand Down Expand Up @@ -202,7 +202,7 @@ public async Task WhenBranchNameProvided_DoesNotAskForBranchName_CreatesAndAddsB
inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).Returns("Stack1");

// Act
await handler.Handle(new NewBranchCommandInputs(null, newBranch));
await handler.Handle(new NewBranchCommandInputs(null, newBranch, null));

// Assert
stackConfig.Stacks.Should().BeEquivalentTo(new List<Config.Stack>
Expand Down Expand Up @@ -243,7 +243,7 @@ public async Task WhenBranchNameProvided_ButBranchAlreadyExistLocally_Throws()

// Act and assert
var invalidBranchName = Some.Name();
await handler.Invoking(async h => await h.Handle(new NewBranchCommandInputs(null, anotherBranch)))
await handler.Invoking(async h => await h.Handle(new NewBranchCommandInputs(null, anotherBranch, null)))
.Should()
.ThrowAsync<InvalidOperationException>()
.WithMessage($"Branch '{anotherBranch}' already exists locally.");
Expand Down Expand Up @@ -281,7 +281,7 @@ public async Task WhenBranchNameProvided_ButBranchAlreadyExistsInStack_Throws()
inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).Returns("Stack1");

// Act and assert
await handler.Invoking(async h => await h.Handle(new NewBranchCommandInputs(null, newBranch)))
await handler.Invoking(async h => await h.Handle(new NewBranchCommandInputs(null, newBranch, null)))
.Should()
.ThrowAsync<InvalidOperationException>()
.WithMessage($"Branch '{newBranch}' already exists in stack 'Stack1'.");
Expand Down Expand Up @@ -366,7 +366,7 @@ public async Task WhenPushToTheRemoteFails_StillCreatesTheBranchLocallyAndAddsIt
inputProvider.Text(Questions.BranchName, Arg.Any<string>()).Returns(newBranch);

// Act
await handler.Handle(new NewBranchCommandInputs(null, null));
await handler.Handle(new NewBranchCommandInputs(null, null, null));

// Assert
stackConfig.Stacks.Should().BeEquivalentTo(new List<Config.Stack>
Expand All @@ -376,4 +376,141 @@ public async Task WhenPushToTheRemoteFails_StillCreatesTheBranchLocallyAndAddsIt
});
repo.GetBranches().Should().Contain(b => b.FriendlyName == newBranch && !b.IsTracking);
}

[Fact]
public async Task WhenV2Schema_AndParentBranchNotProvided_AsksForParentBranch_CreatesNewBranchUnderneathParent()
{
// Arrange
var sourceBranch = Some.BranchName();
var firstBranch = Some.BranchName();
var childBranch = Some.BranchName();
var newBranch = Some.BranchName();
using var repo = new TestGitRepositoryBuilder()
.WithBranch(sourceBranch)
.WithBranch(firstBranch)
.WithBranch(childBranch)
.Build();

var inputProvider = Substitute.For<IInputProvider>();
var logger = new TestLogger(testOutputHelper);
var gitClient = new GitClient(logger, repo.GitClientSettings);
var stackConfig = new TestStackConfigBuilder()
.WithSchemaVersion(SchemaVersion.V2)
.WithStack(stack => stack
.WithName("Stack1")
.WithRemoteUri(repo.RemoteUri)
.WithSourceBranch(sourceBranch)
.WithBranch(branch => branch.WithName(firstBranch).WithChildBranch(child => child.WithName(childBranch))))
.WithStack(stack => stack
.WithName("Stack2")
.WithRemoteUri(repo.RemoteUri)
.WithSourceBranch(sourceBranch))
.Build();
var handler = new NewBranchCommandHandler(inputProvider, logger, gitClient, stackConfig);

inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).Returns("Stack1");
inputProvider.Text(Questions.BranchName, Arg.Any<string>()).Returns(newBranch);
inputProvider.Select(Questions.SelectParentBranch, Arg.Any<string[]>()).Returns(firstBranch);

// Act
await handler.Handle(new NewBranchCommandInputs(null, null, null));

// Assert
stackConfig.Stacks.Should().BeEquivalentTo(new List<Config.Stack>
{
new("Stack1", repo.RemoteUri, sourceBranch, [new Config.Branch(firstBranch, [new Config.Branch(childBranch, []), new Config.Branch(newBranch, [])])]),
new("Stack2", repo.RemoteUri, sourceBranch, [])
});
gitClient.GetCurrentBranch().Should().Be(newBranch);
repo.GetBranches().Should().Contain(b => b.FriendlyName == newBranch && b.IsTracking);
}

[Fact]
public async Task WhenV2Schema_AndParentBranchProvided_DoesNotAskForParentBranch_CreatesNewBranchUnderneathParent()
{
// Arrange
var sourceBranch = Some.BranchName();
var firstBranch = Some.BranchName();
var childBranch = Some.BranchName();
var newBranch = Some.BranchName();
using var repo = new TestGitRepositoryBuilder()
.WithBranch(sourceBranch)
.WithBranch(firstBranch)
.WithBranch(childBranch)
.Build();

var inputProvider = Substitute.For<IInputProvider>();
var logger = new TestLogger(testOutputHelper);
var gitClient = new GitClient(logger, repo.GitClientSettings);
var stackConfig = new TestStackConfigBuilder()
.WithSchemaVersion(SchemaVersion.V2)
.WithStack(stack => stack
.WithName("Stack1")
.WithRemoteUri(repo.RemoteUri)
.WithSourceBranch(sourceBranch)
.WithBranch(branch => branch.WithName(firstBranch).WithChildBranch(child => child.WithName(childBranch))))
.WithStack(stack => stack
.WithName("Stack2")
.WithRemoteUri(repo.RemoteUri)
.WithSourceBranch(sourceBranch))
.Build();
var handler = new NewBranchCommandHandler(inputProvider, logger, gitClient, stackConfig);

inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).Returns("Stack1");
inputProvider.Text(Questions.BranchName, Arg.Any<string>()).Returns(newBranch);

// Act
await handler.Handle(new NewBranchCommandInputs(null, null, firstBranch));

// Assert
stackConfig.Stacks.Should().BeEquivalentTo(new List<Config.Stack>
{
new("Stack1", repo.RemoteUri, sourceBranch, [new Config.Branch(firstBranch, [new Config.Branch(childBranch, []), new Config.Branch(newBranch, [])])]),
new("Stack2", repo.RemoteUri, sourceBranch, [])
});
gitClient.GetCurrentBranch().Should().Be(newBranch);
repo.GetBranches().Should().Contain(b => b.FriendlyName == newBranch && b.IsTracking);

inputProvider.DidNotReceive().Select(Questions.SelectParentBranch, Arg.Any<string[]>());
}

[Fact]
public async Task WhenV1Schema_AndParentBranchProvided_ThrowsException()
{
// Arrange
var sourceBranch = Some.BranchName();
var firstBranch = Some.BranchName();
var childBranch = Some.BranchName();
var newBranch = Some.BranchName();
using var repo = new TestGitRepositoryBuilder()
.WithBranch(sourceBranch)
.WithBranch(firstBranch)
.WithBranch(childBranch)
.Build();

var inputProvider = Substitute.For<IInputProvider>();
var logger = new TestLogger(testOutputHelper);
var gitClient = new GitClient(logger, repo.GitClientSettings);
var stackConfig = new TestStackConfigBuilder()
.WithSchemaVersion(SchemaVersion.V1)
.WithStack(stack => stack
.WithName("Stack1")
.WithRemoteUri(repo.RemoteUri)
.WithSourceBranch(sourceBranch)
.WithBranch(branch => branch.WithName(firstBranch).WithChildBranch(child => child.WithName(childBranch))))
.WithStack(stack => stack
.WithName("Stack2")
.WithRemoteUri(repo.RemoteUri)
.WithSourceBranch(sourceBranch))
.Build();
var handler = new NewBranchCommandHandler(inputProvider, logger, gitClient, stackConfig);

inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).Returns("Stack1");
inputProvider.Text(Questions.BranchName, Arg.Any<string>()).Returns(newBranch);

// Act and assert
await handler.Invoking(h => h.Handle(new NewBranchCommandInputs(null, null, firstBranch)))
.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("Parent branches are not supported in stacks with schema version v1. Please migrate the stack to v2 format.");
}
}
9 changes: 8 additions & 1 deletion src/Stack.Tests/Helpers/TestStackConfigBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,24 @@ namespace Stack.Tests.Helpers;
public class TestStackConfigBuilder
{
readonly List<Action<TestStackBuilder>> stackBuilders = [];
SchemaVersion schemaVersion = SchemaVersion.V1;

public TestStackConfigBuilder WithStack(Action<TestStackBuilder> stackBuilder)
{
stackBuilders.Add(stackBuilder);
return this;
}

public TestStackConfigBuilder WithSchemaVersion(SchemaVersion schemaVersion)
{
this.schemaVersion = schemaVersion;
return this;
}

public TestStackConfig Build()
{
return new TestStackConfig(
new StackData(SchemaVersion.V1, [.. stackBuilders.Select(builder =>
new StackData(schemaVersion, [.. stackBuilders.Select(builder =>
{
var stackBuilder = new TestStackBuilder();
builder(stackBuilder);
Expand Down
53 changes: 41 additions & 12 deletions src/Stack/Commands/Branch/NewBranchCommand.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.ComponentModel;
using Spectre.Console;
using MoreLinq;
using MoreLinq.Extensions;
using Spectre.Console.Cli;
using Stack.Commands.Helpers;
using Stack.Config;
Expand All @@ -17,6 +18,10 @@ public class NewBranchCommandSettings : CommandSettingsBase
[Description("The name of the branch to create.")]
[CommandOption("-n|--name")]
public string? Name { get; init; }

[Description("The name of the parent branch to create the new branch from.")]
[CommandOption("-p|--parent")]
public string? Parent { get; init; }
}

public class NewBranchCommand : Command<NewBranchCommandSettings>
Expand All @@ -29,13 +34,13 @@ protected override async Task Execute(NewBranchCommandSettings settings)
new GitClient(StdErrLogger, settings.GetGitClientSettings()),
new FileStackConfig());

await handler.Handle(new NewBranchCommandInputs(settings.Stack, settings.Name));
await handler.Handle(new NewBranchCommandInputs(settings.Stack, settings.Name, settings.Parent));
}
}

public record NewBranchCommandInputs(string? StackName, string? BranchName)
public record NewBranchCommandInputs(string? StackName, string? BranchName, string? ParentBranchName)
{
public static NewBranchCommandInputs Empty => new(null, null);
public static NewBranchCommandInputs Empty => new(null, null, null);
}

public class NewBranchCommandHandler(
Expand Down Expand Up @@ -63,16 +68,18 @@ public override async Task Handle(NewBranchCommandInputs inputs)
return;
}

if (stackData.SchemaVersion == SchemaVersion.V1 && inputs.ParentBranchName is not null)
{
throw new InvalidOperationException("Parent branches are not supported in stacks with schema version v1. Please migrate the stack to v2 format.");
}

var stack = inputProvider.SelectStack(logger, inputs.StackName, stacksForRemote, currentBranch);

if (stack is null)
{
throw new InvalidOperationException($"Stack '{inputs.StackName}' not found.");
}

var deepestChildBranchFromFirstTree = stack.GetDeepestChildBranchFromFirstTree();
var sourceBranch = deepestChildBranchFromFirstTree?.Name ?? stack.SourceBranch;

var branchName = inputProvider.Text(logger, Questions.BranchName, inputs.BranchName, stack.GetDefaultBranchName());

if (stack.AllBranchNames.Contains(branchName))
Expand All @@ -85,14 +92,36 @@ public override async Task Handle(NewBranchCommandInputs inputs)
throw new InvalidOperationException($"Branch '{branchName}' already exists locally.");
}

logger.Information($"Creating branch {branchName.Branch()} from {sourceBranch.Branch()} in stack {stack.Name.Stack()}");
Branch? sourceBranch = null;

if (stackData.SchemaVersion == SchemaVersion.V1)
{
// In V1 schema there is only a single set of branches, we always add to the end.
sourceBranch = stack.GetAllBranches().LastOrDefault();
}
if (stackData.SchemaVersion == SchemaVersion.V2)
{
var parentBranchName = inputProvider.SelectParentBranch(logger, inputs.ParentBranchName, stack);

if (parentBranchName != stack.SourceBranch)
{
sourceBranch = stack.GetAllBranches().FirstOrDefault(b => b.Name.Equals(parentBranchName, StringComparison.OrdinalIgnoreCase));
if (sourceBranch is null)
{
throw new InvalidOperationException($"Branch '{parentBranchName}' not found in stack '{stack.Name}'.");
}
}
}

var sourceBranchName = sourceBranch?.Name ?? stack.SourceBranch;

logger.Information($"Creating branch {branchName.Branch()} from {sourceBranchName.Branch()} in stack {stack.Name.Stack()}");

gitClient.CreateNewBranch(branchName, sourceBranch);
gitClient.CreateNewBranch(branchName, sourceBranchName);

if (deepestChildBranchFromFirstTree is not null)
if (sourceBranch is not null)
{
// If the stack has branches, we add the new branch to the first branch's children
deepestChildBranchFromFirstTree.Children.Add(new Branch(branchName, []));
sourceBranch.Children.Add(new Branch(branchName, []));
}
else
{
Expand Down
33 changes: 31 additions & 2 deletions src/Stack/Commands/Helpers/InputProviderExtensionMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,38 @@ public static string SelectBranch(
this IInputProvider inputProvider,
ILogger logger,
string? name,
string[] branches)
string[] branches,
string question = Questions.SelectBranch)
{
return inputProvider.Select(logger, Questions.SelectBranch, name, branches);
return inputProvider.Select(logger, question, name, branches);
}

public static string SelectParentBranch(
this IInputProvider inputProvider,
ILogger logger,
string? name,
Config.Stack stack)
{
void GetBranchNamesWithIndentation(Branch branch, List<string> names, int level = 0)
{
names.Add($"{new string(' ', level * 2)}{branch.Name}");
foreach (var child in branch.Children)
{
GetBranchNamesWithIndentation(child, names, level + 1);
}
}

var allBranchNamesWithLevel = new List<string>();
foreach (var branch in stack.Branches)
{
GetBranchNamesWithIndentation(branch, allBranchNamesWithLevel, 1);
}

var branchSelection = name ?? inputProvider.Select(Questions.SelectParentBranch, [stack.SourceBranch, .. allBranchNamesWithLevel]).Trim();

logger.Information($"{Questions.SelectParentBranch} {branchSelection}");

return branchSelection;
}
}

1 change: 1 addition & 0 deletions src/Stack/Commands/Helpers/Questions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public static class Questions
public const string SelectBranch = "Select branch:";
public const string BranchName = "Branch name:";
public const string SelectSourceBranch = "Select a branch to start your stack from:";
public const string SelectParentBranch = "Select a branch to add branch as child of:";
public const string ConfirmSyncStack = "Are you sure you want to sync this stack with the remote repository?";
public const string ConfirmDeleteStack = "Are you sure you want to delete this stack?";
public const string ConfirmDeleteBranches = "Are you sure you want to delete these local branches?";
Expand Down
Loading