diff --git a/README.md b/README.md index 60690443..e7607aeb 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,27 @@ Options: -?, -h, --help Show help and usage information ``` +#### `stack branch move` + +Move a branch to another location in a stack. + +```shell +Usage: + stack branch move [options] + +Options: + --working-dir The path to the directory containing the git repository. Defaults to the current directory. + --debug Show debug output. + --verbose Show verbose output. + --json Write output and log messages as JSON. Log messages will be written to stderr. + -s, --stack The name of the stack. + -b, --branch The name of the branch. + -p, --parent The name of the parent branch to put the branch under. + --re-parent-children Re-parent child branches to the current parent of the branch being moved. + --move-children Move child branches with the branch being moved. + -?, -h, --help Show help and usage information +``` + ### Remote commands #### `stack pull` diff --git a/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs b/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs new file mode 100644 index 00000000..9257219f --- /dev/null +++ b/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs @@ -0,0 +1,336 @@ +using FluentAssertions; +using NSubstitute; +using Meziantou.Extensions.Logging.Xunit; +using Stack.Commands; +using Stack.Commands.Helpers; +using Stack.Git; +using Stack.Infrastructure; +using Stack.Tests.Helpers; +using Xunit.Abstractions; +using Stack.Infrastructure.Settings; + +namespace Stack.Tests.Commands.Branch; + +public class MoveBranchCommandHandlerTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public async Task WhenMovingBranchWithoutChildren_MovesBranchToNewParent() + { + // Arrange + var sourceBranch = Some.BranchName(); + var firstBranch = Some.BranchName(); + var secondBranch = Some.BranchName(); + var branchToMove = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var outputProvider = Substitute.For(); + var gitClient = Substitute.For(); + var gitClientFactory = Substitute.For(); + var executionContext = new CliExecutionContext(); + gitClientFactory.Create(Arg.Any()).Returns(gitClient); + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(branch => branch.WithName(firstBranch)) + .WithBranch(branch => branch.WithName(secondBranch) + .WithChildBranch(child => child.WithName(branchToMove)))) + .Build(); + + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToMove); + inputProvider.Select(Questions.SelectParentBranch, Arg.Any(), Arg.Any()).Returns(firstBranch); + + // Act + await handler.Handle(new MoveBranchCommandInputs(null, null, null, null), CancellationToken.None); + + // Assert + stackConfig.Stacks.Should().BeEquivalentTo(new List + { + new("Stack1", remoteUri, sourceBranch, [ + new Config.Branch(firstBranch, [new Config.Branch(branchToMove, [])]), + new Config.Branch(secondBranch, []) + ]) + }); + } + + [Fact] + public async Task WhenMovingBranchWithChildren_AndMoveChildrenOption_MovesBranchWithChildren() + { + // Arrange + var sourceBranch = Some.BranchName(); + var firstBranch = Some.BranchName(); + var branchToMove = Some.BranchName(); + var childBranch = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + var outputProvider = Substitute.For(); + var gitClientFactory = Substitute.For(); + var executionContext = new CliExecutionContext(); + gitClientFactory.Create(Arg.Any()).Returns(gitClient); + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(branch => branch.WithName(firstBranch)) + .WithBranch(branch => branch.WithName(branchToMove) + .WithChildBranch(child => child.WithName(childBranch)))) + .Build(); + + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToMove); + inputProvider.Select(Questions.SelectParentBranch, Arg.Any(), Arg.Any()).Returns(firstBranch); + inputProvider.Select(Questions.MoveBranchChildAction, Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(MoveBranchChildAction.MoveChildren); + + // Act + await handler.Handle(new MoveBranchCommandInputs(null, null, null, null), CancellationToken.None); + + // Assert + stackConfig.Stacks.Should().BeEquivalentTo(new List + { + new("Stack1", remoteUri, sourceBranch, [ + new Config.Branch(firstBranch, [new Config.Branch(branchToMove, [new Config.Branch(childBranch, [])])]) + ]) + }); + } + + [Fact] + public async Task WhenMovingBranchWithChildren_AndReParentChildrenOption_MovesBranchAndReParentsChildren() + { + // Arrange + var sourceBranch = Some.BranchName(); + var firstBranch = Some.BranchName(); + var branchToMove = Some.BranchName(); + var childBranch = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + var gitClientFactory = Substitute.For(); + var executionContext = new CliExecutionContext(); + gitClientFactory.Create(Arg.Any()).Returns(gitClient); + var outputProvider = Substitute.For(); + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(branch => branch.WithName(firstBranch)) + .WithBranch(branch => branch.WithName(branchToMove) + .WithChildBranch(child => child.WithName(childBranch)))) + .Build(); + + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToMove); + inputProvider.Select(Questions.SelectParentBranch, Arg.Any(), Arg.Any()).Returns(firstBranch); + inputProvider.Select(Questions.MoveBranchChildAction, Arg.Any(), Arg.Any(), Arg.Any>()) + .Returns(MoveBranchChildAction.ReParentChildren); + + // Act + await handler.Handle(new MoveBranchCommandInputs(null, null, null, null), CancellationToken.None); + + // Assert + stackConfig.Stacks.Should().BeEquivalentTo(new List + { + new("Stack1", remoteUri, sourceBranch, [ + new Config.Branch(firstBranch, [new Config.Branch(branchToMove, [])]), + new Config.Branch(childBranch, []) + ]) + }); + } + + [Fact] + public async Task WhenMovingBranchToSourceBranch_MovesBranchToRootLevel() + { + // Arrange + var sourceBranch = Some.BranchName(); + var firstBranch = Some.BranchName(); + var branchToMove = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + var gitClientFactory = Substitute.For(); + var executionContext = new CliExecutionContext(); + gitClientFactory.Create(Arg.Any()).Returns(gitClient); + var outputProvider = Substitute.For(); + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(branch => branch.WithName(firstBranch) + .WithChildBranch(child => child.WithName(branchToMove)))) + .Build(); + + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToMove); + inputProvider.Select(Questions.SelectParentBranch, Arg.Any(), Arg.Any()).Returns(sourceBranch); + + // Act + await handler.Handle(new MoveBranchCommandInputs(null, null, null, null), CancellationToken.None); + + // Assert + stackConfig.Stacks.Should().BeEquivalentTo(new List + { + new("Stack1", remoteUri, sourceBranch, [ + new Config.Branch(firstBranch, []), + new Config.Branch(branchToMove, []) + ]) + }); + } + + [Fact] + public async Task WhenAllInputsProvided_DoesNotPromptUser() + { + // Arrange + var sourceBranch = Some.BranchName(); + var firstBranch = Some.BranchName(); + var branchToMove = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + var gitClientFactory = Substitute.For(); + var executionContext = new CliExecutionContext(); + gitClientFactory.Create(Arg.Any()).Returns(gitClient); + var outputProvider = Substitute.For(); + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(branch => branch.WithName(firstBranch)) + .WithBranch(branch => branch.WithName(branchToMove))) + .Build(); + + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); + + // Act + await handler.Handle(new MoveBranchCommandInputs("Stack1", branchToMove, firstBranch, MoveBranchChildAction.MoveChildren), CancellationToken.None); + + // Assert + stackConfig.Stacks.Should().BeEquivalentTo(new List + { + new("Stack1", remoteUri, sourceBranch, [ + new Config.Branch(firstBranch, [new Config.Branch(branchToMove, [])]) + ]) + }); + + inputProvider.ReceivedCalls().Should().BeEmpty(); + } + + [Fact] + public async Task WhenBranchNotFound_ThrowsException() + { + // Arrange + var sourceBranch = Some.BranchName(); + var firstBranch = Some.BranchName(); + var nonExistentBranch = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + var gitClientFactory = Substitute.For(); + var executionContext = new CliExecutionContext(); + gitClientFactory.Create(Arg.Any()).Returns(gitClient); + var outputProvider = Substitute.For(); + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(branch => branch.WithName(firstBranch))) + .Build(); + + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(nonExistentBranch); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => handler.Handle(new MoveBranchCommandInputs(null, null, null, null), CancellationToken.None)); + + exception.Message.Should().Contain($"Branch '{nonExistentBranch}' not found in stack 'Stack1'"); + } + + [Fact] + public async Task WhenBranchWithoutChildren_DoesNotPromptForChildAction() + { + // Arrange + var sourceBranch = Some.BranchName(); + var firstBranch = Some.BranchName(); + var branchToMove = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + var gitClientFactory = Substitute.For(); + var executionContext = new CliExecutionContext(); + gitClientFactory.Create(Arg.Any()).Returns(gitClient); + var outputProvider = Substitute.For(); + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(sourceBranch); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(branch => branch.WithName(firstBranch)) + .WithBranch(branch => branch.WithName(branchToMove))) + .Build(); + + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToMove); + inputProvider.Select(Questions.SelectParentBranch, Arg.Any(), Arg.Any()).Returns(firstBranch); + + // Act + await handler.Handle(new MoveBranchCommandInputs(null, null, null, null), CancellationToken.None); + + // Assert + await inputProvider.DidNotReceive().Select(Questions.MoveBranchChildAction, Arg.Any(), Arg.Any(), Arg.Any>()); + } +} \ No newline at end of file diff --git a/src/Stack.Tests/Commands/Branch/RemoveBranchCommandHandlerTests.cs b/src/Stack.Tests/Commands/Branch/RemoveBranchCommandHandlerTests.cs index 9453ac48..d018c9cb 100644 --- a/src/Stack.Tests/Commands/Branch/RemoveBranchCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Branch/RemoveBranchCommandHandlerTests.cs @@ -39,14 +39,14 @@ public async Task WhenNoInputsProvided_AsksForAllInputsAndConfirms_RemovesBranch var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToRemove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToRemove); inputProvider.Select(Questions.RemoveBranchChildAction, Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(RemoveBranchChildAction.RemoveChildren); inputProvider.Confirm(Questions.ConfirmRemoveBranch, Arg.Any()).Returns(true); @@ -87,13 +87,13 @@ public async Task WhenStackNameProvided_DoesNotAskForStackName_RemovesBranchFrom var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToRemove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToRemove); inputProvider.Confirm(Questions.ConfirmRemoveBranch, Arg.Any()).Returns(true); // Act @@ -133,7 +133,7 @@ public async Task WhenStackNameProvided_ButStackDoesNotExist_Throws() var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); @@ -172,7 +172,7 @@ public async Task WhenBranchNameProvided_DoesNotAskForBranchName_RemovesBranchFr var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); @@ -218,7 +218,7 @@ public async Task WhenBranchNameProvided_ButBranchDoesNotExistInStack_Throws() var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); @@ -255,13 +255,13 @@ public async Task WhenOnlyOneStackExists_DoesNotAskForStackName() var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToRemove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToRemove); inputProvider.Confirm(Questions.ConfirmRemoveBranch, Arg.Any()).Returns(true); // Act @@ -301,14 +301,14 @@ public async Task WhenConfirmProvided_DoesNotAskForConfirmation_RemovesBranchFro var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToRemove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToRemove); // Act await handler.Handle(new RemoveBranchCommandInputs(null, null, true), CancellationToken.None); @@ -344,14 +344,14 @@ public async Task WhenChildActionIsMoveChildrenToParent_RemovesBranchAndMovesChi var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToRemove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToRemove); inputProvider.Select(Questions.RemoveBranchChildAction, Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(RemoveBranchChildAction.MoveChildrenToParent); inputProvider.Confirm(Questions.ConfirmRemoveBranch, Arg.Any()).Returns(true); @@ -388,14 +388,14 @@ public async Task WhenChildActionIsRemoveChildren_RemovesBranchAndDeletesChildre var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToRemove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToRemove); inputProvider.Select(Questions.RemoveBranchChildAction, Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(RemoveBranchChildAction.RemoveChildren); inputProvider.Confirm(Questions.ConfirmRemoveBranch, Arg.Any()).Returns(true); @@ -432,14 +432,14 @@ public async Task WhenRemoveChildrenIsProvided_RemovesBranchAndDeletesChildren() var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToRemove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToRemove); inputProvider.Confirm(Questions.ConfirmRemoveBranch, Arg.Any()).Returns(true); // Act @@ -476,14 +476,14 @@ public async Task WhenMoveChildrenToParentIsProvided_RemovesBranchAndMovesChildr var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToRemove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToRemove); inputProvider.Confirm(Questions.ConfirmRemoveBranch, Arg.Any()).Returns(true); // Act @@ -519,14 +519,14 @@ public async Task WhenBranchHasNoChildren_DoesNotAskForChildAction() var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToRemove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToRemove); inputProvider.Confirm(Questions.ConfirmRemoveBranch, Arg.Any()).Returns(true); // Act @@ -563,14 +563,14 @@ public async Task WhenBranchHasNoChildrenButRemoveChildrenIsProvided_DoesNotAskF var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToRemove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToRemove); inputProvider.Confirm(Questions.ConfirmRemoveBranch, Arg.Any()).Returns(true); // Act @@ -607,14 +607,14 @@ public async Task WhenBranchHasNoChildrenButMoveChildrenToParentIsProvided_DoesN var gitClientFactory = Substitute.For(); var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; var handler = new RemoveBranchCommandHandler(inputProvider, logger, gitClientFactory, executionContext, stackConfig); - + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToRemove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToRemove); inputProvider.Confirm(Questions.ConfirmRemoveBranch, Arg.Any()).Returns(true); // Act diff --git a/src/Stack.Tests/Config/StackTests.cs b/src/Stack.Tests/Config/StackTests.cs index c01a4902..f42bf85f 100644 --- a/src/Stack.Tests/Config/StackTests.cs +++ b/src/Stack.Tests/Config/StackTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Stack.Commands; using Stack.Tests.Helpers; // Deliberately using a different namespace here to avoid needing to @@ -49,4 +50,272 @@ public void GetAllBranchLines_ReturnsAllRootToLeafPaths() ["A", "F", "G"] ], options => options.WithStrictOrdering()); } + + [Fact] + public void MoveBranch_WhenMovingBranchWithoutChildren_MovesBranchToNewLocation() + { + // Arrange: Structure: + // - A + // - B + // - C + var stack = new Config.Stack( + "TestStack", + Some.HttpsUri().ToString(), + "main", + [ + new Config.Branch("A", []), + new Config.Branch("B", [ + new Config.Branch("C", []) + ]) + ] + ); + + // Act: Move C from under B to under A + stack.MoveBranch("C", "A", MoveBranchChildAction.MoveChildren); + + // Assert: Structure should be: + // - A + // - C + // - B + stack.Branches.Should().BeEquivalentTo([ + new Config.Branch("A", [new Config.Branch("C", [])]), + new Config.Branch("B", []) + ]); + } + + [Fact] + public void MoveBranch_WhenMovingBranchToSourceBranch_MovesBranchToRootLevel() + { + // Arrange: Structure: + // - A + // - B + var stack = new Config.Stack( + "TestStack", + Some.HttpsUri().ToString(), + "main", + [ + new Config.Branch("A", [ + new Config.Branch("B", []) + ]) + ] + ); + + // Act: Move B to root level (under source branch) + stack.MoveBranch("B", "main", MoveBranchChildAction.MoveChildren); + + // Assert: Structure should be: + // - A + // - B + stack.Branches.Should().BeEquivalentTo([ + new Config.Branch("A", []), + new Config.Branch("B", []) + ]); + } + + [Fact] + public void MoveBranch_WhenMovingBranchWithChildren_AndMoveChildrenAction_MovesBranchWithAllChildren() + { + // Arrange: Structure: + // - A + // - B + // - C + // - D + var stack = new Config.Stack( + "TestStack", + Some.HttpsUri().ToString(), + "main", + [ + new Config.Branch("A", []), + new Config.Branch("B", [ + new Config.Branch("C", [ + new Config.Branch("D", []) + ]) + ]) + ] + ); + + // Act: Move C with its children from under B to under A + stack.MoveBranch("C", "A", MoveBranchChildAction.MoveChildren); + + // Assert: Structure should be: + // - A + // - C + // - D + // - B + stack.Branches.Should().BeEquivalentTo([ + new Config.Branch("A", [ + new Config.Branch("C", [ + new Config.Branch("D", []) + ]) + ]), + new Config.Branch("B", []) + ]); + } + + [Fact] + public void MoveBranch_WhenMovingBranchWithChildren_AndReParentChildrenAction_MovesBranchButLeavesChildrenBehind() + { + // Arrange: Structure: + // - A + // - B + // - C + // - D + // - E + var stack = new Config.Stack( + "TestStack", + Some.HttpsUri().ToString(), + "main", + [ + new Config.Branch("A", []), + new Config.Branch("B", [ + new Config.Branch("C", [ + new Config.Branch("D", []), + new Config.Branch("E", []) + ]) + ]) + ] + ); + + // Act: Move C but re-parent its children to B + stack.MoveBranch("C", "A", MoveBranchChildAction.ReParentChildren); + + // Assert: Structure should be: + // - A + // - C + // - B + // - D + // - E + stack.Branches.Should().BeEquivalentTo([ + new Config.Branch("A", [ + new Config.Branch("C", []) + ]), + new Config.Branch("B", [ + new Config.Branch("D", []), + new Config.Branch("E", []) + ]) + ]); + } + + [Fact] + public void MoveBranch_WhenMovingDeepNestedBranch_CorrectlyMovesFromAnyDepth() + { + // Arrange: Structure: + // - A + // - B + // - C + // - D + // - E + var stack = new Config.Stack( + "TestStack", + Some.HttpsUri().ToString(), + "main", + [ + new Config.Branch("A", [ + new Config.Branch("B", [ + new Config.Branch("C", [ + new Config.Branch("D", []) + ]) + ]) + ]), + new Config.Branch("E", []) + ] + ); + + // Act: Move deeply nested D to under E + stack.MoveBranch("D", "E", MoveBranchChildAction.MoveChildren); + + // Assert: Structure should be: + // - A + // - B + // - C + // - E + // - D + stack.Branches.Should().BeEquivalentTo([ + new Config.Branch("A", [ + new Config.Branch("B", [ + new Config.Branch("C", []) + ]) + ]), + new Config.Branch("E", [ + new Config.Branch("D", []) + ]) + ]); + } + + [Fact] + public void MoveBranch_WhenBranchNotFound_ThrowsException() + { + // Arrange + var stack = new Config.Stack( + "TestStack", + Some.HttpsUri().ToString(), + "main", + [ + new Config.Branch("A", []) + ] + ); + + // Act & Assert + var exception = Assert.Throws( + () => stack.MoveBranch("NonExistent", "A", MoveBranchChildAction.MoveChildren)); + + exception.Message.Should().Contain("Branch 'NonExistent' not found in stack"); + } + + [Fact] + public void MoveBranch_WhenNewParentNotFound_ThrowsException() + { + // Arrange + var stack = new Config.Stack( + "TestStack", + Some.HttpsUri().ToString(), + "main", + [ + new Config.Branch("A", [ + new Config.Branch("B", []) + ]) + ] + ); + + // Act & Assert + var exception = Assert.Throws( + () => stack.MoveBranch("B", "NonExistent", MoveBranchChildAction.MoveChildren)); + + exception.Message.Should().Contain("Parent branch 'NonExistent' not found in stack"); + } + + [Fact] + public void MoveBranch_WhenMovingRootLevelBranchWithChildrenToAnotherRootLevelBranch_WorksCorrectly() + { + // Arrange: Structure: + // - A + // - B + // - C + var stack = new Config.Stack( + "TestStack", + Some.HttpsUri().ToString(), + "main", + [ + new Config.Branch("A", [ + new Config.Branch("B", []) + ]), + new Config.Branch("C", []) + ] + ); + + // Act: Move A under C + stack.MoveBranch("A", "C", MoveBranchChildAction.MoveChildren); + + // Assert: Structure should be: + // - C + // - A + // - B + stack.Branches.Should().BeEquivalentTo([ + new Config.Branch("C", [ + new Config.Branch("A", [ + new Config.Branch("B", []) + ]) + ]) + ]); + } } \ No newline at end of file diff --git a/src/Stack/Commands/Branch/BranchCommand.cs b/src/Stack/Commands/Branch/BranchCommand.cs index 0d7f9d39..429273d1 100644 --- a/src/Stack/Commands/Branch/BranchCommand.cs +++ b/src/Stack/Commands/Branch/BranchCommand.cs @@ -5,11 +5,14 @@ public class BranchCommand : GroupCommand public BranchCommand( AddBranchCommand addBranchCommand, NewBranchCommand newBranchCommand, - RemoveBranchCommand removeBranchCommand) : base("branch", "Manage branches within a stack.") + RemoveBranchCommand removeBranchCommand, + MoveBranchCommand moveBranchCommand) + : base("branch", "Manage branches within a stack.") { Add(addBranchCommand); Add(newBranchCommand); Add(removeBranchCommand); + Add(moveBranchCommand); } } diff --git a/src/Stack/Commands/Branch/MoveBranchCommand.cs b/src/Stack/Commands/Branch/MoveBranchCommand.cs new file mode 100644 index 00000000..ada41c59 --- /dev/null +++ b/src/Stack/Commands/Branch/MoveBranchCommand.cs @@ -0,0 +1,148 @@ +using System.CommandLine; +using System.ComponentModel; +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 enum MoveBranchChildAction +{ + [Description("Move child branches with the branch being moved")] + MoveChildren, + + [Description("Re-parent child branches to the current parent of the branch being moved")] + ReParentChildren +} + +public class MoveBranchCommand : Command +{ + static readonly Option ReParentChildren = new("--re-parent-children") + { + Description = "Re-parent child branches to the current parent of the branch being moved." + }; + + static readonly Option MoveChildren = new("--move-children") + { + Description = "Move child branches with the branch being moved." + }; + + private readonly MoveBranchCommandHandler handler; + + public MoveBranchCommand( + MoveBranchCommandHandler handler, + CliExecutionContext executionContext, + IInputProvider inputProvider, + IOutputProvider outputProvider, + ILogger logger) + : base("move", "Move a branch to another location in a stack.", executionContext, inputProvider, outputProvider, logger) + { + this.handler = handler; + Add(CommonOptions.Stack); + Add(CommonOptions.Branch); + Add(CommonOptions.ParentBranch); + Add(ReParentChildren); + Add(MoveChildren); + } + + protected override async Task Execute(ParseResult parseResult, CancellationToken cancellationToken) + { + var reParentChildren = parseResult.GetValue(ReParentChildren); + var moveChildren = parseResult.GetValue(MoveChildren); + + if (reParentChildren && moveChildren) + { + throw new InvalidOperationException("Cannot specify both --re-parent-children and --move-children options."); + } + + await handler.Handle( + new MoveBranchCommandInputs( + parseResult.GetValue(CommonOptions.Stack), + parseResult.GetValue(CommonOptions.Branch), + parseResult.GetValue(CommonOptions.ParentBranch), + reParentChildren ? MoveBranchChildAction.ReParentChildren : moveChildren ? MoveBranchChildAction.MoveChildren : null), + cancellationToken); + } +} + +public record MoveBranchCommandInputs(string? StackName, string? BranchName, string? NewParentBranchName, MoveBranchChildAction? ChildAction = null) +{ + public static MoveBranchCommandInputs Empty => new(null, null, null, null); +} + +public class MoveBranchCommandHandler( + IInputProvider inputProvider, + ILogger logger, + IOutputProvider outputProvider, + IGitClientFactory gitClientFactory, + CliExecutionContext executionContext, + IStackConfig stackConfig) + : CommandHandlerBase +{ + public override async Task Handle(MoveBranchCommandInputs inputs, CancellationToken cancellationToken) + { + var gitClient = gitClientFactory.Create(executionContext.WorkingDirectory); + var remoteUri = gitClient.GetRemoteUri(); + var currentBranch = gitClient.GetCurrentBranch(); + + var stackData = stackConfig.Load(); + 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.StackName, stacksForRemote, currentBranch, cancellationToken); + if (stack is null) + { + throw new InvalidOperationException($"Stack '{inputs.StackName}' not found."); + } + + var branchName = await inputProvider.SelectBranch(logger, inputs.BranchName, stack, cancellationToken); + + if (!stack.AllBranchNames.Contains(branchName)) + { + throw new InvalidOperationException($"Branch '{branchName}' not found in stack '{stack.Name}'."); + } + + var newParentBranchName = await inputProvider.SelectParentBranch(logger, inputs.NewParentBranchName, stack, cancellationToken); + + // Get the branch being moved and check if it has children + var branchBeingMoved = stack.GetAllBranches().FirstOrDefault(b => b.Name.Equals(branchName, StringComparison.OrdinalIgnoreCase)); + var hasChildren = branchBeingMoved?.Children.Count > 0; + + MoveBranchChildAction childAction = MoveBranchChildAction.MoveChildren; // default + + if (hasChildren && inputs.ChildAction is null) + { + childAction = await inputProvider.Select( + Questions.MoveBranchChildAction, + [MoveBranchChildAction.MoveChildren, MoveBranchChildAction.ReParentChildren], + cancellationToken, + (action) => action.Humanize()); + } + else if (inputs.ChildAction is not null) + { + childAction = inputs.ChildAction.Value; + } + + stack.MoveBranch(branchName, newParentBranchName, childAction); + + stackConfig.Save(stackData); + + logger.BranchMovedInStack(branchName, stack.Name); + + await outputProvider.WriteMessage($"Run {$"stack sync --stack \"{stack.Name}\"".Example()} or {$"stack update --stack \"{stack.Name}\"".Example()} to synchronize the changes with your repository.", cancellationToken); + } +} + +internal static partial class LoggerExtensionMethods +{ + [LoggerMessage(Level = LogLevel.Information, Message = "Branch {Branch} moved in stack \"{Stack}\"")] + public static partial void BranchMovedInStack(this ILogger logger, string branch, string stack); +} \ No newline at end of file diff --git a/src/Stack/Commands/Branch/RemoveBranchCommand.cs b/src/Stack/Commands/Branch/RemoveBranchCommand.cs index 53f68db3..d6c9a0f3 100644 --- a/src/Stack/Commands/Branch/RemoveBranchCommand.cs +++ b/src/Stack/Commands/Branch/RemoveBranchCommand.cs @@ -90,7 +90,7 @@ public override async Task Handle(RemoveBranchCommandInputs inputs, Cancellation throw new InvalidOperationException($"Stack '{inputs.StackName}' not found."); } - var branchName = await inputProvider.SelectBranch(logger, inputs.BranchName, [.. stack.AllBranchNames], cancellationToken); + var branchName = await inputProvider.SelectBranch(logger, inputs.BranchName, stack, cancellationToken); if (!stack.AllBranchNames.Contains(branchName)) { diff --git a/src/Stack/Commands/Helpers/HumanizeEnumExtensionMethods.cs b/src/Stack/Commands/Helpers/HumanizeEnumExtensionMethods.cs index 1d0dc540..58b2be93 100644 --- a/src/Stack/Commands/Helpers/HumanizeEnumExtensionMethods.cs +++ b/src/Stack/Commands/Helpers/HumanizeEnumExtensionMethods.cs @@ -22,6 +22,16 @@ public static string Humanize(this RemoveBranchChildAction action) _ => throw new ArgumentOutOfRangeException(nameof(action)), }; } + + public static string Humanize(this MoveBranchChildAction action) + { + return action switch + { + MoveBranchChildAction.MoveChildren => "Move children branches with the branch being moved", + MoveBranchChildAction.ReParentChildren => "Re-parent children branches to the previous location", + _ => throw new ArgumentOutOfRangeException(nameof(action)), + }; + } } diff --git a/src/Stack/Commands/Helpers/InputProviderExtensionMethods.cs b/src/Stack/Commands/Helpers/InputProviderExtensionMethods.cs index 2317b728..6833951b 100644 --- a/src/Stack/Commands/Helpers/InputProviderExtensionMethods.cs +++ b/src/Stack/Commands/Helpers/InputProviderExtensionMethods.cs @@ -108,6 +108,40 @@ public static Task SelectBranch( return inputProvider.Select(logger, Questions.SelectBranch, name, branches, cancellationToken); } + public static async Task SelectBranch( + this IInputProvider inputProvider, + ILogger logger, + string? name, + Config.Stack stack, + CancellationToken cancellationToken) + { + void GetBranchNamesWithIndentation(Branch branch, List 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(); + foreach (var branch in stack.Branches) + { + GetBranchNamesWithIndentation(branch, allBranchNamesWithLevel); + } + + var branchSelection = (name ?? await inputProvider + .SelectGrouped( + Questions.SelectBranch, + [new ChoiceGroup(stack.SourceBranch, [.. allBranchNamesWithLevel])], + cancellationToken)) + .Trim(); + + logger.Answer(Questions.SelectBranch, branchSelection); + + return branchSelection; + } + public static async Task SelectParentBranch( this IInputProvider inputProvider, ILogger logger, diff --git a/src/Stack/Commands/Helpers/Questions.cs b/src/Stack/Commands/Helpers/Questions.cs index 5ec0d1aa..81b72c79 100644 --- a/src/Stack/Commands/Helpers/Questions.cs +++ b/src/Stack/Commands/Helpers/Questions.cs @@ -13,6 +13,7 @@ public static class Questions public const string ConfirmDeleteBranches = "Are you sure you want to delete these local branches?"; public const string ConfirmRemoveBranch = "Are you sure you want to remove this branch from the stack?"; public const string RemoveBranchChildAction = "What do you want to do with the children of this branch?"; + public const string MoveBranchChildAction = "What do you want to do with the children of the branch being moved?"; public const string AddOrCreateBranch = "Add or create a branch:"; public const string SelectPullRequestsToCreate = "Select branches to create pull requests for:"; public const string ConfirmCreatePullRequests = "Are you sure you want to create pull requests for branches in this stack?"; diff --git a/src/Stack/Config/Stack.cs b/src/Stack/Config/Stack.cs index 654fba09..8fd6d877 100644 --- a/src/Stack/Config/Stack.cs +++ b/src/Stack/Config/Stack.cs @@ -86,6 +86,138 @@ static bool RemoveBranch(Branch branch, string branchName, RemoveBranchChildActi return false; } + public void MoveBranch(string branchName, string newParentBranchName, MoveBranchChildAction childAction) + { + // First, find and extract the branch being moved + var (branchToMove, originalParentName, childrenToReParent) = ExtractBranch(branchName, childAction); + if (branchToMove is null) + { + throw new InvalidOperationException($"Branch '{branchName}' not found in stack."); + } + + // Add the moved branch to the new parent location + AddBranchToParent(branchToMove, newParentBranchName); + + // Re-parent children to their original location if needed + if (childrenToReParent.Count > 0) + { + foreach (var child in childrenToReParent) + { + AddBranchToParent(child, originalParentName); + } + } + } + + private (Branch? branchToMove, string originalParentName, List childrenToReParent) ExtractBranch(string branchName, MoveBranchChildAction childAction) + { + // Check root level branches + for (int i = 0; i < Branches.Count; i++) + { + var branch = Branches[i]; + if (branch.Name.Equals(branchName, StringComparison.OrdinalIgnoreCase)) + { + Branches.RemoveAt(i); + + if (childAction == MoveBranchChildAction.ReParentChildren) + { + // Children should be re-parented to the source branch (root level) + var childrenToReParent = branch.Children.ToList(); + return (new Branch(branch.Name, new List()), SourceBranch, childrenToReParent); + } + else + { + return (branch, SourceBranch, new List()); + } + } + } + + // Check nested branches + foreach (var branch in Branches) + { + var result = ExtractBranchFromChildren(branch, branchName, childAction); + if (result.branchToMove is not null) + { + return result; + } + } + + return (null, string.Empty, new List()); + } + + private static (Branch? branchToMove, string originalParentName, List childrenToReParent) ExtractBranchFromChildren(Branch parentBranch, string branchName, MoveBranchChildAction childAction) + { + for (int i = 0; i < parentBranch.Children.Count; i++) + { + var childBranch = parentBranch.Children[i]; + if (childBranch.Name.Equals(branchName, StringComparison.OrdinalIgnoreCase)) + { + parentBranch.Children.RemoveAt(i); + + if (childAction == MoveBranchChildAction.ReParentChildren) + { + // Children should be re-parented to the original parent + var childrenToReParent = childBranch.Children.ToList(); + return (new Branch(childBranch.Name, new List()), parentBranch.Name, childrenToReParent); + } + else + { + return (childBranch, parentBranch.Name, new List()); + } + } + } + + foreach (var child in parentBranch.Children) + { + var result = ExtractBranchFromChildren(child, branchName, childAction); + if (result.branchToMove is not null) + { + return result; + } + } + + return (null, string.Empty, new List()); + } + + private void AddBranchToParent(Branch branchToMove, string parentBranchName) + { + // If the parent is the source branch, add to root level + if (parentBranchName.Equals(SourceBranch, StringComparison.OrdinalIgnoreCase)) + { + Branches.Add(branchToMove); + return; + } + + // Find the parent branch and add as child + foreach (var branch in Branches) + { + if (AddBranchToParentInChildren(branch, branchToMove, parentBranchName)) + { + return; + } + } + + throw new InvalidOperationException($"Parent branch '{parentBranchName}' not found in stack."); + } + + private static bool AddBranchToParentInChildren(Branch currentBranch, Branch branchToMove, string parentBranchName) + { + if (currentBranch.Name.Equals(parentBranchName, StringComparison.OrdinalIgnoreCase)) + { + currentBranch.Children.Add(branchToMove); + return true; + } + + foreach (var child in currentBranch.Children) + { + if (AddBranchToParentInChildren(child, branchToMove, parentBranchName)) + { + return true; + } + } + + return false; + } + public List> GetAllBranchLines() { var allLines = new List>(); diff --git a/src/Stack/Infrastructure/HostApplicationBuilderExtensions.cs b/src/Stack/Infrastructure/HostApplicationBuilderExtensions.cs index d40e493e..1366758b 100644 --- a/src/Stack/Infrastructure/HostApplicationBuilderExtensions.cs +++ b/src/Stack/Infrastructure/HostApplicationBuilderExtensions.cs @@ -112,6 +112,7 @@ private static void RegisterCommandHandlers(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -138,6 +139,7 @@ private static void RegisterCommands(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient();