diff --git a/README.md b/README.md index 3dce96ee..a2c0cca8 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ There are two strategies that can be used to update branches in a stack. The Git configuration key `stack.update.strategy` can be used to control the default update strategy on a global or per-repository basis. -The `merge` update strategy is used by default if no configuration is supplied. +You will be asked to select an update strategy if none is supplied or configured. ##### Merge diff --git a/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs b/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs index 1330dc55..04bc7661 100644 --- a/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs @@ -564,6 +564,114 @@ public async Task WhenGitConfigValueIsSetToRebase_ButMergeIsSpecified_DoesSyncUs repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteBranch1); // The merge should retain the tip of the branch } + [Fact] + public async Task WhenNotSpecifyingRebaseOrMerge_AndNoUpdateSettingExists_AndMergeIsSelected_DoesSyncUsingMerge() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + using var repo = new TestGitRepositoryBuilder() + .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(sourceBranch, 5, b => b.PushToRemote()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(branch1, 3, b => b.PushToRemote()) + .WithConfig("stack.update.strategy", "") + .Build(); + + var tipOfRemoteSourceBranch = repo.GetTipOfRemoteBranch(sourceBranch); + var tipOfRemoteBranch1 = repo.GetTipOfRemoteBranch(branch1); + repo.GetCommitsReachableFromBranch(sourceBranch).Should().NotContain(tipOfRemoteSourceBranch); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(repo.RemoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(stackBranch => stackBranch.WithName(branch1).WithChildBranch(b => b.WithName(branch2)))) + .WithStack(stack => stack + .WithName("Stack2") + .WithRemoteUri(repo.RemoteUri) + .WithSourceBranch(sourceBranch)) + .Build(); + var inputProvider = Substitute.For(); + var logger = new TestLogger(testOutputHelper); + var gitClient = new GitClient(logger, repo.GitClientSettings); + var gitHubClient = Substitute.For(); + var handler = new SyncStackCommandHandler(inputProvider, logger, gitClient, gitHubClient, stackConfig); + + gitClient.ChangeBranch(branch1); + + inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + inputProvider.Select(Questions.SelectUpdateStrategy, Arg.Any()).Returns(UpdateStrategy.Merge); + inputProvider.Confirm(Questions.ConfirmSyncStack).Returns(true); + + // Act + await handler.Handle(new SyncStackCommandInputs(null, 5, null, null, false, false)); + + // Assert + var tipOfBranch1 = repo.GetTipOfBranch(branch1); + repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfBranch1); + repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteBranch1); // The merge should retain the tip of the branch + } + + [Fact] + public async Task WhenNotSpecifyingRebaseOrMerge_AndNoUpdateSettingsExists_AndRebaseIsSelected_DoesSyncUsingRebase() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + using var repo = new TestGitRepositoryBuilder() + .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(sourceBranch, 5, b => b.PushToRemote()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(branch1, 3, b => b.PushToRemote()) + .WithConfig("stack.update.strategy", "") + .Build(); + + var tipOfRemoteSourceBranch = repo.GetTipOfRemoteBranch(sourceBranch); + var tipOfRemoteBranch1 = repo.GetTipOfRemoteBranch(branch1); + repo.GetCommitsReachableFromBranch(sourceBranch).Should().NotContain(tipOfRemoteSourceBranch); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(repo.RemoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(stackBranch => stackBranch.WithName(branch1).WithChildBranch(b => b.WithName(branch2)))) + .WithStack(stack => stack + .WithName("Stack2") + .WithRemoteUri(repo.RemoteUri) + .WithSourceBranch(sourceBranch)) + .Build(); + var inputProvider = Substitute.For(); + var logger = new TestLogger(testOutputHelper); + var gitClient = new GitClient(logger, repo.GitClientSettings); + var gitHubClient = Substitute.For(); + var handler = new SyncStackCommandHandler(inputProvider, logger, gitClient, gitHubClient, stackConfig); + + gitClient.ChangeBranch(branch1); + + inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + inputProvider.Select(Questions.SelectUpdateStrategy, Arg.Any()).Returns(UpdateStrategy.Rebase); + inputProvider.Confirm(Questions.ConfirmSyncStack).Returns(true); + + // Act + await handler.Handle(new SyncStackCommandInputs(null, 5, null, null, false, false)); + + // Assert + var tipOfBranch1 = repo.GetTipOfBranch(branch1); + repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfBranch1); + repo.GetCommitsReachableFromRemoteBranch(branch1).Should().NotContain(tipOfRemoteBranch1); // When doing a rebase we should no longer have the previous tip + } + [Fact] public async Task WhenBothRebaseAndMergeAreSpecified_AnErrorIsThrown() { diff --git a/src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs b/src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs index 8ea3c3e3..09f1f1e1 100644 --- a/src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs @@ -579,6 +579,105 @@ public async Task WhenGitConfigValueIsSetToMerge_ButRebaseIsSpecified_AllBranche repo.GetAheadBehind(branch2).Should().Be((20, 12)); } + [Fact] + public async Task WhenGitConfigValueDoesNotExist_AndRebaseIsSelected_AllBranchesInStackAreUpdatedUsingRebase() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + using var repo = new TestGitRepositoryBuilder() + .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithNumberOfEmptyCommits(b => b.OnBranch(sourceBranch).PushToRemote(), 5) + .WithNumberOfEmptyCommits(b => b.OnBranch(branch1).PushToRemote(), 3) + .WithNumberOfEmptyCommits(b => b.OnBranch(branch2).PushToRemote(), 1) + .Build(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(repo.RemoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(b1 => b1.WithName(branch1).WithChildBranch(b2 => b2.WithName(branch2)))) + .WithStack(stack => stack + .WithName("Stack2") + .WithRemoteUri(repo.RemoteUri) + .WithSourceBranch(sourceBranch)) + .Build(); + var inputProvider = Substitute.For(); + var logger = new TestLogger(testOutputHelper); + var gitClient = new GitClient(logger, repo.GitClientSettings); + var gitHubClient = Substitute.For(); + var handler = new UpdateStackCommandHandler(inputProvider, logger, gitClient, gitHubClient, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + inputProvider.Select(Questions.SelectUpdateStrategy, Arg.Any()).Returns(UpdateStrategy.Rebase); + + // Act + await handler.Handle(new UpdateStackCommandInputs(null, null, null)); + + // Assert + var tipOfSourceBranch = repo.GetTipOfBranch(sourceBranch); + var tipOfBranch1 = repo.GetTipOfBranch(branch1); + + repo.GetCommitsReachableFromBranch(branch1).Should().Contain(tipOfSourceBranch); + repo.GetCommitsReachableFromBranch(branch2).Should().Contain(tipOfSourceBranch); + repo.GetCommitsReachableFromBranch(branch2).Should().Contain(tipOfBranch1); + repo.GetAheadBehind(branch2).Should().Be((20, 12)); + } + + [Fact] + public async Task WhenGitConfigValueDoesNotExist_AndMergeIsSelected_AllBranchesInStackAreUpdatedUsingMerge() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + using var repo = new TestGitRepositoryBuilder() + .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithNumberOfEmptyCommits(b => b.OnBranch(sourceBranch).PushToRemote(), 5) + .WithNumberOfEmptyCommits(b => b.OnBranch(branch1).PushToRemote(), 3) + .WithNumberOfEmptyCommits(b => b.OnBranch(branch2).PushToRemote(), 1) + .WithConfig("stack.update.strategy", "") + .Build(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(repo.RemoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(b1 => b1.WithName(branch1).WithChildBranch(b2 => b2.WithName(branch2)))) + .WithStack(stack => stack + .WithName("Stack2") + .WithRemoteUri(repo.RemoteUri) + .WithSourceBranch(sourceBranch)) + .Build(); + var inputProvider = Substitute.For(); + var logger = new TestLogger(testOutputHelper); + var gitClient = new GitClient(logger, repo.GitClientSettings); + var gitHubClient = Substitute.For(); + var handler = new UpdateStackCommandHandler(inputProvider, logger, gitClient, gitHubClient, stackConfig); + + inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + inputProvider.Select(Questions.SelectUpdateStrategy, Arg.Any()).Returns(UpdateStrategy.Merge); + + // Act + await handler.Handle(new UpdateStackCommandInputs(null, null, null)); + + // Assert + var tipOfSourceBranch = repo.GetTipOfBranch(sourceBranch); + var tipOfBranch1 = repo.GetTipOfBranch(branch1); + + repo.GetCommitsReachableFromBranch(branch1).Should().Contain(tipOfSourceBranch); + repo.GetCommitsReachableFromBranch(branch2).Should().Contain(tipOfSourceBranch); + repo.GetCommitsReachableFromBranch(branch2).Should().Contain(tipOfBranch1); + repo.GetAheadBehind(branch2).Should().Be((10, 0)); + } + [Fact] public async Task WhenBothRebaseAndMergeAreSpecified_AnErrorIsThrown() { diff --git a/src/Stack/Commands/Helpers/Questions.cs b/src/Stack/Commands/Helpers/Questions.cs index 8f8afee6..41a3c1ea 100644 --- a/src/Stack/Commands/Helpers/Questions.cs +++ b/src/Stack/Commands/Helpers/Questions.cs @@ -22,4 +22,5 @@ public static class Questions public const string ContinueOrAbortMerge = "Conflict(s) detected during merge. Please either resolve the conflicts, commit the result and select Continue to continue merging, or Abort."; public const string ContinueOrAbortRebase = "Conflict(s) detected during rebase. Please either resolve the conflicts and select Continue to continue rebasing, or Abort."; public const string ConfirmMigrateConfig = "Are you sure you want to migrate the configuration file to the latest version? This will create a backup of the current configuration file."; + public const string SelectUpdateStrategy = "Select update strategy:"; } diff --git a/src/Stack/Commands/Helpers/StackHelpers.cs b/src/Stack/Commands/Helpers/StackHelpers.cs index dac163a5..580ded4f 100644 --- a/src/Stack/Commands/Helpers/StackHelpers.cs +++ b/src/Stack/Commands/Helpers/StackHelpers.cs @@ -498,7 +498,7 @@ public static void OutputBranchAndStackActions( return null; } - public static void UpdateStack( + public static UpdateStrategy UpdateStack( Config.Stack stack, StackStatus status, UpdateStrategy? specificUpdateStrategy, @@ -506,7 +506,7 @@ public static void UpdateStack( IInputProvider inputProvider, ILogger logger) { - var strategy = UpdateStrategy.Merge; + UpdateStrategy? strategy = null; if (specificUpdateStrategy is not null) { @@ -522,6 +522,20 @@ public static void UpdateStack( } } + if (strategy is null) + { + strategy = inputProvider.Select( + Questions.SelectUpdateStrategy, + [UpdateStrategy.Merge, UpdateStrategy.Rebase]); + + logger.Information($"{Questions.SelectUpdateStrategy} {strategy}"); + + logger.NewLine(); + logger.Information($"Run {$"git config stack.update.strategy {strategy.ToString()!.ToLowerInvariant()}".Example()} to configure this update strategy for the current repository."); + logger.Information($"Run {$"git config --global stack.update.strategy {strategy.ToString()!.ToLowerInvariant()}".Example()} to configure this update strategy for all repositories."); + logger.NewLine(); + } + if (strategy == UpdateStrategy.Rebase) { UpdateStackUsingRebase(stack, status, gitClient, inputProvider, logger); @@ -530,6 +544,8 @@ public static void UpdateStack( { UpdateStackUsingMerge(stack, status, gitClient, inputProvider, logger); } + + return strategy.Value; } public static void UpdateStackUsingMerge( @@ -539,6 +555,8 @@ public static void UpdateStackUsingMerge( IInputProvider inputProvider, ILogger logger) { + logger.Information($"Updating stack {status.Name.Stack()} using merge..."); + var allBranchLines = status.GetAllBranchLines(); foreach (var branchLine in allBranchLines) @@ -745,6 +763,8 @@ public static void UpdateStackUsingRebase( IInputProvider inputProvider, ILogger logger) { + logger.Information($"Updating stack {status.Name.Stack()} using rebase..."); + var allBranchLines = status.GetAllBranchLines(); foreach (var branchLine in allBranchLines) diff --git a/src/Stack/Commands/Remote/SyncStackCommand.cs b/src/Stack/Commands/Remote/SyncStackCommand.cs index bf7d5b06..f0064060 100644 --- a/src/Stack/Commands/Remote/SyncStackCommand.cs +++ b/src/Stack/Commands/Remote/SyncStackCommand.cs @@ -106,7 +106,7 @@ public override async Task Handle(SyncStackCommandInputs inputs) StackHelpers.PullChanges(stack, gitClient, logger); - StackHelpers.UpdateStack( + var updateStrategy = StackHelpers.UpdateStack( stack, status, inputs.Merge == true ? UpdateStrategy.Merge : inputs.Rebase == true ? UpdateStrategy.Rebase : null, @@ -114,7 +114,7 @@ public override async Task Handle(SyncStackCommandInputs inputs) inputProvider, logger); - var forceWithLease = inputs.Rebase == true || StackHelpers.GetUpdateStrategyConfigValue(gitClient) == UpdateStrategy.Rebase; + var forceWithLease = updateStrategy == UpdateStrategy.Rebase; if (!inputs.NoPush) StackHelpers.PushChanges(stack, inputs.MaxBatchSize, forceWithLease, gitClient, logger);