From 4a0294e8f65515c8f8066b206f8ac5398eabe930 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Sep 2025 11:12:31 +0000 Subject: [PATCH 01/11] Implement MoveBranchCommand with tests - logic needs refinement for re-parenting Co-authored-by: geofflamrock <2915931+geofflamrock@users.noreply.github.com> --- .../Branch/MoveBranchCommandHandlerTests.cs | 308 ++++++++++++++++++ src/Stack.Tests/Config/StackTests.cs | 269 +++++++++++++++ src/Stack/Commands/Branch/BranchCommand.cs | 4 +- .../Commands/Branch/MoveBranchCommand.cs | 146 +++++++++ .../Helpers/HumanizeEnumExtensionMethods.cs | 10 + src/Stack/Commands/Helpers/Questions.cs | 1 + src/Stack/Config/Stack.cs | 126 +++++++ 7 files changed, 863 insertions(+), 1 deletion(-) create mode 100644 src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs create mode 100644 src/Stack/Commands/Branch/MoveBranchCommand.cs diff --git a/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs b/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs new file mode 100644 index 00000000..feb5b244 --- /dev/null +++ b/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs @@ -0,0 +1,308 @@ +using FluentAssertions; +using NSubstitute; +using Meziantou.Extensions.Logging.Xunit; +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.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 gitClient = 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(secondBranch) + .WithChildBranch(child => child.WithName(branchToMove)))) + .Build(); + + var handler = new MoveBranchCommandHandler(inputProvider, logger, gitClient, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + inputProvider.Select(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(); + 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, gitClient, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + inputProvider.Select(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(); + 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, gitClient, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + inputProvider.Select(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(); + 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, gitClient, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + inputProvider.Select(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(); + 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, gitClient, 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(); + 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, gitClient, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + inputProvider.Select(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(); + 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, gitClient, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + inputProvider.Select(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/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..8a0465bf 100644 --- a/src/Stack/Commands/Branch/BranchCommand.cs +++ b/src/Stack/Commands/Branch/BranchCommand.cs @@ -5,11 +5,13 @@ 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..3b2ac504 --- /dev/null +++ b/src/Stack/Commands/Branch/MoveBranchCommand.cs @@ -0,0 +1,146 @@ +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 children branches with the branch being moved")] + MoveChildren, + + [Description("Re-parent children branches to the previous location")] + ReParentChildren +} + +public class MoveBranchCommand : Command +{ + static readonly Option ReParentChildren = new("--re-parent-children") + { + Description = "Re-parent children branches to the previous location." + }; + + static readonly Option MoveChildren = new("--move-children") + { + Description = "Move children branches with the branch being moved." + }; + + private readonly MoveBranchCommandHandler handler; + + public MoveBranchCommand( + ILogger logger, + IDisplayProvider displayProvider, + IInputProvider inputProvider, + CliExecutionContext executionContext, + MoveBranchCommandHandler handler) + : base("move", "Move a branch to another location in a stack.", logger, displayProvider, inputProvider, executionContext) + { + 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, + IGitClient gitClient, + IStackConfig stackConfig) + : CommandHandlerBase +{ + public override async Task Handle(MoveBranchCommandInputs inputs, CancellationToken cancellationToken) + { + 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) + { + throw new InvalidOperationException("No stacks found for this repository."); + } + + 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.AllBranchNames], 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, + new[] { 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); + logger.SuggestStackUpdate(); + } +} + +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); + + [LoggerMessage(Level = LogLevel.Information, Message = "Run 'stack sync' or 'stack update' to synchronize the changes with Git.")] + public static partial void SuggestStackUpdate(this ILogger logger); +} \ No newline at end of file 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/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..2d87da94 100644 --- a/src/Stack/Config/Stack.cs +++ b/src/Stack/Config/Stack.cs @@ -86,6 +86,132 @@ 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, childrenToReParent) = ExtractBranch(branchName, childAction); + if (branchToMove is null) + { + throw new InvalidOperationException($"Branch '{branchName}' not found in stack."); + } + + // Then, add it to the new parent location + AddBranchToParent(branchToMove, newParentBranchName, childrenToReParent); + } + + private (Branch? branchToMove, 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) + { + // Move children to root level + var childrenToReParent = branch.Children.ToList(); + return (new Branch(branch.Name, new List()), childrenToReParent); + } + else + { + return (branch, 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, new List()); + } + + private static (Branch? branchToMove, 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) + { + // Re-parent children to the current parent + var childrenToReParent = childBranch.Children.ToList(); + parentBranch.Children.AddRange(childrenToReParent); + return (new Branch(childBranch.Name, new List()), new List()); + } + else + { + return (childBranch, new List()); + } + } + } + + foreach (var child in parentBranch.Children) + { + var result = ExtractBranchFromChildren(child, branchName, childAction); + if (result.branchToMove is not null) + { + return result; + } + } + + return (null, new List()); + } + + private void AddBranchToParent(Branch branchToMove, string newParentBranchName, List childrenToReParent) + { + // If the new parent is the source branch, add to root level + if (newParentBranchName.Equals(SourceBranch, StringComparison.OrdinalIgnoreCase)) + { + Branches.Add(branchToMove); + Branches.AddRange(childrenToReParent); + return; + } + + // Find the new parent branch and add as child + foreach (var branch in Branches) + { + if (AddBranchToParentInChildren(branch, branchToMove, newParentBranchName, childrenToReParent)) + { + return; + } + } + + throw new InvalidOperationException($"Parent branch '{newParentBranchName}' not found in stack."); + } + + private static bool AddBranchToParentInChildren(Branch currentBranch, Branch branchToMove, string newParentBranchName, List childrenToReParent) + { + if (currentBranch.Name.Equals(newParentBranchName, StringComparison.OrdinalIgnoreCase)) + { + currentBranch.Children.Add(branchToMove); + currentBranch.Children.AddRange(childrenToReParent); + return true; + } + + foreach (var child in currentBranch.Children) + { + if (AddBranchToParentInChildren(child, branchToMove, newParentBranchName, childrenToReParent)) + { + return true; + } + } + + return false; + } + public List> GetAllBranchLines() { var allLines = new List>(); From 064ebd3531c256a87f218e1e8d03d740542eb105 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Sep 2025 11:16:50 +0000 Subject: [PATCH 02/11] Complete MoveBranchCommand implementation with DI registration and documentation Co-authored-by: geofflamrock <2915931+geofflamrock@users.noreply.github.com> --- README.md | 19 +++++++ src/Stack/Config/Stack.cs | 56 ++++++++++--------- .../HostApplicationBuilderExtensions.cs | 2 + 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 60690443..503f67fb 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,25 @@ 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. + --verbose Show verbose output. + -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 children branches to the previous location. + --move-children Move children branches with the branch being moved. + -?, -h, --help Show help and usage information +``` + ### Remote commands #### `stack pull` diff --git a/src/Stack/Config/Stack.cs b/src/Stack/Config/Stack.cs index 2d87da94..8fd6d877 100644 --- a/src/Stack/Config/Stack.cs +++ b/src/Stack/Config/Stack.cs @@ -89,17 +89,26 @@ static bool RemoveBranch(Branch branch, string branchName, RemoveBranchChildActi public void MoveBranch(string branchName, string newParentBranchName, MoveBranchChildAction childAction) { // First, find and extract the branch being moved - var (branchToMove, childrenToReParent) = ExtractBranch(branchName, childAction); + var (branchToMove, originalParentName, childrenToReParent) = ExtractBranch(branchName, childAction); if (branchToMove is null) { throw new InvalidOperationException($"Branch '{branchName}' not found in stack."); } - // Then, add it to the new parent location - AddBranchToParent(branchToMove, newParentBranchName, childrenToReParent); + // 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, List childrenToReParent) ExtractBranch(string branchName, MoveBranchChildAction childAction) + private (Branch? branchToMove, string originalParentName, List childrenToReParent) ExtractBranch(string branchName, MoveBranchChildAction childAction) { // Check root level branches for (int i = 0; i < Branches.Count; i++) @@ -111,13 +120,13 @@ public void MoveBranch(string branchName, string newParentBranchName, MoveBranch if (childAction == MoveBranchChildAction.ReParentChildren) { - // Move children to root level + // Children should be re-parented to the source branch (root level) var childrenToReParent = branch.Children.ToList(); - return (new Branch(branch.Name, new List()), childrenToReParent); + return (new Branch(branch.Name, new List()), SourceBranch, childrenToReParent); } else { - return (branch, new List()); + return (branch, SourceBranch, new List()); } } } @@ -132,10 +141,10 @@ public void MoveBranch(string branchName, string newParentBranchName, MoveBranch } } - return (null, new List()); + return (null, string.Empty, new List()); } - private static (Branch? branchToMove, List childrenToReParent) ExtractBranchFromChildren(Branch parentBranch, string branchName, MoveBranchChildAction childAction) + private static (Branch? branchToMove, string originalParentName, List childrenToReParent) ExtractBranchFromChildren(Branch parentBranch, string branchName, MoveBranchChildAction childAction) { for (int i = 0; i < parentBranch.Children.Count; i++) { @@ -146,14 +155,13 @@ private static (Branch? branchToMove, List childrenToReParent) ExtractBr if (childAction == MoveBranchChildAction.ReParentChildren) { - // Re-parent children to the current parent + // Children should be re-parented to the original parent var childrenToReParent = childBranch.Children.ToList(); - parentBranch.Children.AddRange(childrenToReParent); - return (new Branch(childBranch.Name, new List()), new List()); + return (new Branch(childBranch.Name, new List()), parentBranch.Name, childrenToReParent); } else { - return (childBranch, new List()); + return (childBranch, parentBranch.Name, new List()); } } } @@ -167,43 +175,41 @@ private static (Branch? branchToMove, List childrenToReParent) ExtractBr } } - return (null, new List()); + return (null, string.Empty, new List()); } - private void AddBranchToParent(Branch branchToMove, string newParentBranchName, List childrenToReParent) + private void AddBranchToParent(Branch branchToMove, string parentBranchName) { - // If the new parent is the source branch, add to root level - if (newParentBranchName.Equals(SourceBranch, StringComparison.OrdinalIgnoreCase)) + // If the parent is the source branch, add to root level + if (parentBranchName.Equals(SourceBranch, StringComparison.OrdinalIgnoreCase)) { Branches.Add(branchToMove); - Branches.AddRange(childrenToReParent); return; } - // Find the new parent branch and add as child + // Find the parent branch and add as child foreach (var branch in Branches) { - if (AddBranchToParentInChildren(branch, branchToMove, newParentBranchName, childrenToReParent)) + if (AddBranchToParentInChildren(branch, branchToMove, parentBranchName)) { return; } } - throw new InvalidOperationException($"Parent branch '{newParentBranchName}' not found in stack."); + throw new InvalidOperationException($"Parent branch '{parentBranchName}' not found in stack."); } - private static bool AddBranchToParentInChildren(Branch currentBranch, Branch branchToMove, string newParentBranchName, List childrenToReParent) + private static bool AddBranchToParentInChildren(Branch currentBranch, Branch branchToMove, string parentBranchName) { - if (currentBranch.Name.Equals(newParentBranchName, StringComparison.OrdinalIgnoreCase)) + if (currentBranch.Name.Equals(parentBranchName, StringComparison.OrdinalIgnoreCase)) { currentBranch.Children.Add(branchToMove); - currentBranch.Children.AddRange(childrenToReParent); return true; } foreach (var child in currentBranch.Children) { - if (AddBranchToParentInChildren(child, branchToMove, newParentBranchName, childrenToReParent)) + if (AddBranchToParentInChildren(child, branchToMove, parentBranchName)) { return true; } 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(); From 3bec7435311d73a8465da1393620cdb1fd251641 Mon Sep 17 00:00:00 2001 From: Geoff Lamrock Date: Tue, 9 Sep 2025 19:57:38 +1000 Subject: [PATCH 03/11] Small tweaks --- src/Stack/Commands/Branch/BranchCommand.cs | 3 ++- src/Stack/Commands/Branch/MoveBranchCommand.cs | 13 +++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Stack/Commands/Branch/BranchCommand.cs b/src/Stack/Commands/Branch/BranchCommand.cs index 8a0465bf..429273d1 100644 --- a/src/Stack/Commands/Branch/BranchCommand.cs +++ b/src/Stack/Commands/Branch/BranchCommand.cs @@ -6,7 +6,8 @@ public BranchCommand( AddBranchCommand addBranchCommand, NewBranchCommand newBranchCommand, RemoveBranchCommand removeBranchCommand, - MoveBranchCommand moveBranchCommand) : base("branch", "Manage branches within a stack.") + MoveBranchCommand moveBranchCommand) + : base("branch", "Manage branches within a stack.") { Add(addBranchCommand); Add(newBranchCommand); diff --git a/src/Stack/Commands/Branch/MoveBranchCommand.cs b/src/Stack/Commands/Branch/MoveBranchCommand.cs index 3b2ac504..0565cd5b 100644 --- a/src/Stack/Commands/Branch/MoveBranchCommand.cs +++ b/src/Stack/Commands/Branch/MoveBranchCommand.cs @@ -11,10 +11,10 @@ namespace Stack.Commands; public enum MoveBranchChildAction { - [Description("Move children branches with the branch being moved")] + [Description("Move child branches with the branch being moved")] MoveChildren, - [Description("Re-parent children branches to the previous location")] + [Description("Re-parent child branches to the previous location")] ReParentChildren } @@ -22,12 +22,12 @@ public class MoveBranchCommand : Command { static readonly Option ReParentChildren = new("--re-parent-children") { - Description = "Re-parent children branches to the previous location." + Description = "Re-parent child branches to the previous location." }; static readonly Option MoveChildren = new("--move-children") { - Description = "Move children branches with the branch being moved." + Description = "Move child branches with the branch being moved." }; private readonly MoveBranchCommandHandler handler; @@ -90,7 +90,8 @@ public override async Task Handle(MoveBranchCommandInputs inputs, CancellationTo if (stacksForRemote.Count == 0) { - throw new InvalidOperationException("No stacks found for this repository."); + logger.NoStacksForRepository(); + return; } var stack = await inputProvider.SelectStack(logger, inputs.StackName, stacksForRemote, currentBranch, cancellationToken); @@ -118,7 +119,7 @@ public override async Task Handle(MoveBranchCommandInputs inputs, CancellationTo { childAction = await inputProvider.Select( Questions.MoveBranchChildAction, - new[] { MoveBranchChildAction.MoveChildren, MoveBranchChildAction.ReParentChildren }, + [MoveBranchChildAction.MoveChildren, MoveBranchChildAction.ReParentChildren], cancellationToken, (action) => action.Humanize()); } From 49907b998af90a773c15d7262215ec9a25d90b07 Mon Sep 17 00:00:00 2001 From: Geoff Lamrock Date: Tue, 9 Sep 2025 19:59:48 +1000 Subject: [PATCH 04/11] Change readme to match new descriptions --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 503f67fb..bf239463 100644 --- a/README.md +++ b/README.md @@ -384,8 +384,8 @@ Options: -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 children branches to the previous location. - --move-children Move children branches with the branch being moved. + --re-parent-children Re-parent child branches to the previous location. + --move-children Move child branches with the branch being moved. -?, -h, --help Show help and usage information ``` From aded82e677b5650551a588c30e81152a230b0b4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:10:03 +0000 Subject: [PATCH 05/11] Replace logger with IDisplayProvider for stack suggestion message Co-authored-by: geofflamrock <2915931+geofflamrock@users.noreply.github.com> --- .../Branch/MoveBranchCommandHandlerTests.cs | 21 ++++++++++++------- .../Commands/Branch/MoveBranchCommand.cs | 6 ++---- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs b/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs index feb5b244..6c6ce278 100644 --- a/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs @@ -25,6 +25,7 @@ public async Task WhenMovingBranchWithoutChildren_MovesBranchToNewParent() var inputProvider = Substitute.For(); var logger = XUnitLogger.CreateLogger(testOutputHelper); + var displayProvider = new TestDisplayProvider(testOutputHelper); var gitClient = Substitute.For(); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); @@ -39,7 +40,7 @@ public async Task WhenMovingBranchWithoutChildren_MovesBranchToNewParent() .WithChildBranch(child => child.WithName(branchToMove)))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); @@ -84,7 +85,8 @@ public async Task WhenMovingBranchWithChildren_AndMoveChildrenOption_MovesBranch .WithChildBranch(child => child.WithName(childBranch)))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, gitClient, stackConfig); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); @@ -130,7 +132,8 @@ public async Task WhenMovingBranchWithChildren_AndReParentChildrenOption_MovesBr .WithChildBranch(child => child.WithName(childBranch)))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, gitClient, stackConfig); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); @@ -175,7 +178,8 @@ public async Task WhenMovingBranchToSourceBranch_MovesBranchToRootLevel() .WithChildBranch(child => child.WithName(branchToMove)))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, gitClient, stackConfig); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); @@ -218,7 +222,8 @@ public async Task WhenAllInputsProvided_DoesNotPromptUser() .WithBranch(branch => branch.WithName(branchToMove))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, gitClient, stackConfig); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); // Act await handler.Handle(new MoveBranchCommandInputs("Stack1", branchToMove, firstBranch, MoveBranchChildAction.MoveChildren), CancellationToken.None); @@ -257,7 +262,8 @@ public async Task WhenBranchNotFound_ThrowsException() .WithBranch(branch => branch.WithName(firstBranch))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, gitClient, stackConfig); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(nonExistentBranch); @@ -293,7 +299,8 @@ public async Task WhenBranchWithoutChildren_DoesNotPromptForChildAction() .WithBranch(branch => branch.WithName(branchToMove))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, gitClient, stackConfig); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); diff --git a/src/Stack/Commands/Branch/MoveBranchCommand.cs b/src/Stack/Commands/Branch/MoveBranchCommand.cs index 0565cd5b..22e2f11e 100644 --- a/src/Stack/Commands/Branch/MoveBranchCommand.cs +++ b/src/Stack/Commands/Branch/MoveBranchCommand.cs @@ -76,6 +76,7 @@ public record MoveBranchCommandInputs(string? StackName, string? BranchName, str public class MoveBranchCommandHandler( IInputProvider inputProvider, ILogger logger, + IDisplayProvider displayProvider, IGitClient gitClient, IStackConfig stackConfig) : CommandHandlerBase @@ -133,7 +134,7 @@ public override async Task Handle(MoveBranchCommandInputs inputs, CancellationTo stackConfig.Save(stackData); logger.BranchMovedInStack(branchName, stack.Name); - logger.SuggestStackUpdate(); + await displayProvider.DisplayMessage($"Run {"stack sync".Example()} or {"stack update".Example()} to synchronize the changes with Git.", cancellationToken); } } @@ -141,7 +142,4 @@ 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); - - [LoggerMessage(Level = LogLevel.Information, Message = "Run 'stack sync' or 'stack update' to synchronize the changes with Git.")] - public static partial void SuggestStackUpdate(this ILogger logger); } \ No newline at end of file From 6cfb4cef6c9e4c081044688e78724d62ee1a2a2c Mon Sep 17 00:00:00 2001 From: Geoff Lamrock Date: Wed, 24 Sep 2025 17:53:53 +1000 Subject: [PATCH 06/11] Fixes after rebase --- .../Branch/MoveBranchCommandHandlerTests.cs | 29 +++++++++---------- .../Commands/Branch/MoveBranchCommand.cs | 15 +++++----- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs b/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs index 6c6ce278..9aeef717 100644 --- a/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs @@ -3,7 +3,6 @@ using Meziantou.Extensions.Logging.Xunit; using Stack.Commands; using Stack.Commands.Helpers; -using Stack.Config; using Stack.Git; using Stack.Infrastructure; using Stack.Tests.Helpers; @@ -25,7 +24,7 @@ public async Task WhenMovingBranchWithoutChildren_MovesBranchToNewParent() var inputProvider = Substitute.For(); var logger = XUnitLogger.CreateLogger(testOutputHelper); - var displayProvider = new TestDisplayProvider(testOutputHelper); + var outputProvider = Substitute.For(); var gitClient = Substitute.For(); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); @@ -40,7 +39,7 @@ public async Task WhenMovingBranchWithoutChildren_MovesBranchToNewParent() .WithChildBranch(child => child.WithName(branchToMove)))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); @@ -72,6 +71,7 @@ public async Task WhenMovingBranchWithChildren_AndMoveChildrenOption_MovesBranch var inputProvider = Substitute.For(); var logger = XUnitLogger.CreateLogger(testOutputHelper); var gitClient = Substitute.For(); + var outputProvider = Substitute.For(); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); @@ -85,8 +85,7 @@ public async Task WhenMovingBranchWithChildren_AndMoveChildrenOption_MovesBranch .WithChildBranch(child => child.WithName(childBranch)))) .Build(); - var displayProvider = new TestDisplayProvider(testOutputHelper); - var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); @@ -119,6 +118,7 @@ public async Task WhenMovingBranchWithChildren_AndReParentChildrenOption_MovesBr var inputProvider = Substitute.For(); var logger = XUnitLogger.CreateLogger(testOutputHelper); var gitClient = Substitute.For(); + var outputProvider = Substitute.For(); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); @@ -132,8 +132,7 @@ public async Task WhenMovingBranchWithChildren_AndReParentChildrenOption_MovesBr .WithChildBranch(child => child.WithName(childBranch)))) .Build(); - var displayProvider = new TestDisplayProvider(testOutputHelper); - var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); @@ -166,6 +165,7 @@ public async Task WhenMovingBranchToSourceBranch_MovesBranchToRootLevel() var inputProvider = Substitute.For(); var logger = XUnitLogger.CreateLogger(testOutputHelper); var gitClient = Substitute.For(); + var outputProvider = Substitute.For(); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); @@ -178,8 +178,7 @@ public async Task WhenMovingBranchToSourceBranch_MovesBranchToRootLevel() .WithChildBranch(child => child.WithName(branchToMove)))) .Build(); - var displayProvider = new TestDisplayProvider(testOutputHelper); - var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); @@ -210,6 +209,7 @@ public async Task WhenAllInputsProvided_DoesNotPromptUser() var inputProvider = Substitute.For(); var logger = XUnitLogger.CreateLogger(testOutputHelper); var gitClient = Substitute.For(); + var outputProvider = Substitute.For(); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); @@ -222,8 +222,7 @@ public async Task WhenAllInputsProvided_DoesNotPromptUser() .WithBranch(branch => branch.WithName(branchToMove))) .Build(); - var displayProvider = new TestDisplayProvider(testOutputHelper); - var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); // Act await handler.Handle(new MoveBranchCommandInputs("Stack1", branchToMove, firstBranch, MoveBranchChildAction.MoveChildren), CancellationToken.None); @@ -251,6 +250,7 @@ public async Task WhenBranchNotFound_ThrowsException() var inputProvider = Substitute.For(); var logger = XUnitLogger.CreateLogger(testOutputHelper); var gitClient = Substitute.For(); + var outputProvider = Substitute.For(); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); @@ -262,8 +262,7 @@ public async Task WhenBranchNotFound_ThrowsException() .WithBranch(branch => branch.WithName(firstBranch))) .Build(); - var displayProvider = new TestDisplayProvider(testOutputHelper); - var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(nonExistentBranch); @@ -287,6 +286,7 @@ public async Task WhenBranchWithoutChildren_DoesNotPromptForChildAction() var inputProvider = Substitute.For(); var logger = XUnitLogger.CreateLogger(testOutputHelper); var gitClient = Substitute.For(); + var outputProvider = Substitute.For(); gitClient.GetRemoteUri().Returns(remoteUri); gitClient.GetCurrentBranch().Returns(sourceBranch); @@ -299,8 +299,7 @@ public async Task WhenBranchWithoutChildren_DoesNotPromptForChildAction() .WithBranch(branch => branch.WithName(branchToMove))) .Build(); - var displayProvider = new TestDisplayProvider(testOutputHelper); - var handler = new MoveBranchCommandHandler(inputProvider, logger, displayProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); diff --git a/src/Stack/Commands/Branch/MoveBranchCommand.cs b/src/Stack/Commands/Branch/MoveBranchCommand.cs index 22e2f11e..0f22d1c5 100644 --- a/src/Stack/Commands/Branch/MoveBranchCommand.cs +++ b/src/Stack/Commands/Branch/MoveBranchCommand.cs @@ -33,12 +33,12 @@ public class MoveBranchCommand : Command private readonly MoveBranchCommandHandler handler; public MoveBranchCommand( - ILogger logger, - IDisplayProvider displayProvider, - IInputProvider inputProvider, + MoveBranchCommandHandler handler, CliExecutionContext executionContext, - MoveBranchCommandHandler handler) - : base("move", "Move a branch to another location in a stack.", logger, displayProvider, inputProvider, 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); @@ -76,7 +76,7 @@ public record MoveBranchCommandInputs(string? StackName, string? BranchName, str public class MoveBranchCommandHandler( IInputProvider inputProvider, ILogger logger, - IDisplayProvider displayProvider, + IOutputProvider outputProvider, IGitClient gitClient, IStackConfig stackConfig) : CommandHandlerBase @@ -134,7 +134,8 @@ public override async Task Handle(MoveBranchCommandInputs inputs, CancellationTo stackConfig.Save(stackData); logger.BranchMovedInStack(branchName, stack.Name); - await displayProvider.DisplayMessage($"Run {"stack sync".Example()} or {"stack update".Example()} to synchronize the changes with Git.", cancellationToken); + + 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); } } From 7b74aa01fca23970a3ae2ce7acc1af6e8dd249c1 Mon Sep 17 00:00:00 2001 From: Geoff Lamrock Date: Thu, 25 Sep 2025 17:23:36 +1000 Subject: [PATCH 07/11] Fixes after merge --- README.md | 2 ++ .../Branch/MoveBranchCommandHandlerTests.cs | 36 +++++++++++++++---- .../Commands/Branch/MoveBranchCommand.cs | 4 ++- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bf239463..29c9f3af 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,9 @@ Usage: 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. diff --git a/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs b/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs index 9aeef717..75b67b49 100644 --- a/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs @@ -7,6 +7,7 @@ using Stack.Infrastructure; using Stack.Tests.Helpers; using Xunit.Abstractions; +using Stack.Infrastructure.Settings; namespace Stack.Tests.Commands.Branch; @@ -26,6 +27,9 @@ public async Task WhenMovingBranchWithoutChildren_MovesBranchToNewParent() 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); @@ -39,7 +43,7 @@ public async Task WhenMovingBranchWithoutChildren_MovesBranchToNewParent() .WithChildBranch(child => child.WithName(branchToMove)))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); @@ -72,6 +76,9 @@ public async Task WhenMovingBranchWithChildren_AndMoveChildrenOption_MovesBranch 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); @@ -85,7 +92,7 @@ public async Task WhenMovingBranchWithChildren_AndMoveChildrenOption_MovesBranch .WithChildBranch(child => child.WithName(childBranch)))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); @@ -118,6 +125,9 @@ public async Task WhenMovingBranchWithChildren_AndReParentChildrenOption_MovesBr 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); @@ -132,7 +142,7 @@ public async Task WhenMovingBranchWithChildren_AndReParentChildrenOption_MovesBr .WithChildBranch(child => child.WithName(childBranch)))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); @@ -165,6 +175,9 @@ public async Task WhenMovingBranchToSourceBranch_MovesBranchToRootLevel() 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); @@ -178,7 +191,7 @@ public async Task WhenMovingBranchToSourceBranch_MovesBranchToRootLevel() .WithChildBranch(child => child.WithName(branchToMove)))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); @@ -209,6 +222,9 @@ public async Task WhenAllInputsProvided_DoesNotPromptUser() 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); @@ -222,7 +238,7 @@ public async Task WhenAllInputsProvided_DoesNotPromptUser() .WithBranch(branch => branch.WithName(branchToMove))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); // Act await handler.Handle(new MoveBranchCommandInputs("Stack1", branchToMove, firstBranch, MoveBranchChildAction.MoveChildren), CancellationToken.None); @@ -250,6 +266,9 @@ public async Task WhenBranchNotFound_ThrowsException() 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); @@ -262,7 +281,7 @@ public async Task WhenBranchNotFound_ThrowsException() .WithBranch(branch => branch.WithName(firstBranch))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(nonExistentBranch); @@ -286,6 +305,9 @@ public async Task WhenBranchWithoutChildren_DoesNotPromptForChildAction() 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); @@ -299,7 +321,7 @@ public async Task WhenBranchWithoutChildren_DoesNotPromptForChildAction() .WithBranch(branch => branch.WithName(branchToMove))) .Build(); - var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClient, stackConfig); + var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); diff --git a/src/Stack/Commands/Branch/MoveBranchCommand.cs b/src/Stack/Commands/Branch/MoveBranchCommand.cs index 0f22d1c5..bf5147f1 100644 --- a/src/Stack/Commands/Branch/MoveBranchCommand.cs +++ b/src/Stack/Commands/Branch/MoveBranchCommand.cs @@ -77,12 +77,14 @@ public class MoveBranchCommandHandler( IInputProvider inputProvider, ILogger logger, IOutputProvider outputProvider, - IGitClient gitClient, + 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(); From 787185beef186f8e4b2475d3557be6a06b9b42fc Mon Sep 17 00:00:00 2001 From: Geoff Lamrock Date: Thu, 25 Sep 2025 21:47:08 +1000 Subject: [PATCH 08/11] Improve branch selection in stack --- .../Commands/Branch/MoveBranchCommand.cs | 2 +- .../Commands/Branch/RemoveBranchCommand.cs | 2 +- .../Helpers/InputProviderExtensionMethods.cs | 34 +++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/Stack/Commands/Branch/MoveBranchCommand.cs b/src/Stack/Commands/Branch/MoveBranchCommand.cs index bf5147f1..fac23ce5 100644 --- a/src/Stack/Commands/Branch/MoveBranchCommand.cs +++ b/src/Stack/Commands/Branch/MoveBranchCommand.cs @@ -103,7 +103,7 @@ public override async Task Handle(MoveBranchCommandInputs inputs, CancellationTo 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/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/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, From f9608fe4dc2aca1a1ca11939126d9ef9ce859df6 Mon Sep 17 00:00:00 2001 From: Geoff Lamrock Date: Mon, 29 Sep 2025 19:21:08 +1000 Subject: [PATCH 09/11] Fix tests --- .../Branch/MoveBranchCommandHandlerTests.cs | 12 +++--- .../Branch/RemoveBranchCommandHandlerTests.cs | 38 +++++++++---------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs b/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs index 75b67b49..9257219f 100644 --- a/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Branch/MoveBranchCommandHandlerTests.cs @@ -46,7 +46,7 @@ public async Task WhenMovingBranchWithoutChildren_MovesBranchToNewParent() var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToMove); inputProvider.Select(Questions.SelectParentBranch, Arg.Any(), Arg.Any()).Returns(firstBranch); // Act @@ -95,7 +95,7 @@ public async Task WhenMovingBranchWithChildren_AndMoveChildrenOption_MovesBranch var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); + 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); @@ -145,7 +145,7 @@ public async Task WhenMovingBranchWithChildren_AndReParentChildrenOption_MovesBr var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); + 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); @@ -194,7 +194,7 @@ public async Task WhenMovingBranchToSourceBranch_MovesBranchToRootLevel() var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToMove); inputProvider.Select(Questions.SelectParentBranch, Arg.Any(), Arg.Any()).Returns(sourceBranch); // Act @@ -284,7 +284,7 @@ public async Task WhenBranchNotFound_ThrowsException() var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(nonExistentBranch); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(nonExistentBranch); // Act & Assert var exception = await Assert.ThrowsAsync( @@ -324,7 +324,7 @@ public async Task WhenBranchWithoutChildren_DoesNotPromptForChildAction() var handler = new MoveBranchCommandHandler(inputProvider, logger, outputProvider, gitClientFactory, executionContext, stackConfig); inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); - inputProvider.Select(Questions.SelectBranch, Arg.Any(), Arg.Any()).Returns(branchToMove); + inputProvider.SelectGrouped(Questions.SelectBranch, Arg.Any[]>(), Arg.Any()).Returns(branchToMove); inputProvider.Select(Questions.SelectParentBranch, Arg.Any(), Arg.Any()).Returns(firstBranch); // Act diff --git a/src/Stack.Tests/Commands/Branch/RemoveBranchCommandHandlerTests.cs b/src/Stack.Tests/Commands/Branch/RemoveBranchCommandHandlerTests.cs index 9453ac48..aed14dfa 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 From 088934e2e39ec58c1a09ddaaee6af1548e9004ae Mon Sep 17 00:00:00 2001 From: Geoff Lamrock Date: Mon, 29 Sep 2025 19:33:44 +1000 Subject: [PATCH 10/11] Fix tests after merge --- .../Branch/RemoveBranchCommandHandlerTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Stack.Tests/Commands/Branch/RemoveBranchCommandHandlerTests.cs b/src/Stack.Tests/Commands/Branch/RemoveBranchCommandHandlerTests.cs index aed14dfa..d018c9cb 100644 --- a/src/Stack.Tests/Commands/Branch/RemoveBranchCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Branch/RemoveBranchCommandHandlerTests.cs @@ -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 From 8597441de46e084f4fabcc1d00f403d4d089cb1a Mon Sep 17 00:00:00 2001 From: Geoff Lamrock Date: Mon, 29 Sep 2025 19:43:20 +1000 Subject: [PATCH 11/11] Improve descriptions --- README.md | 2 +- src/Stack/Commands/Branch/MoveBranchCommand.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 29c9f3af..e7607aeb 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ Options: -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 previous location. + --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 ``` diff --git a/src/Stack/Commands/Branch/MoveBranchCommand.cs b/src/Stack/Commands/Branch/MoveBranchCommand.cs index fac23ce5..ada41c59 100644 --- a/src/Stack/Commands/Branch/MoveBranchCommand.cs +++ b/src/Stack/Commands/Branch/MoveBranchCommand.cs @@ -14,7 +14,7 @@ public enum MoveBranchChildAction [Description("Move child branches with the branch being moved")] MoveChildren, - [Description("Re-parent child branches to the previous location")] + [Description("Re-parent child branches to the current parent of the branch being moved")] ReParentChildren } @@ -22,7 +22,7 @@ public class MoveBranchCommand : Command { static readonly Option ReParentChildren = new("--re-parent-children") { - Description = "Re-parent child branches to the previous location." + Description = "Re-parent child branches to the current parent of the branch being moved." }; static readonly Option MoveChildren = new("--move-children")