diff --git a/README.md b/README.md index 19ce4180..f007cc79 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ The new branch will be created from the branch at the bottom of the stack and yo By default new branches are only created locally, you can either use the `--push` option or use the `stack push` command to push the branch to the remote. -### Syncing a stack +### Syncing a stack with the remote repository After working on a stack of branches for a while, you might need to incorporate changes that have happened to your source branch from others. To do this: @@ -83,11 +83,27 @@ Branches in the stack will be updated by: - Updating branches in order in the stack, the equivalent of running `stack update`. - Pushing changes for all branches in the stack, the equivalent of running `stack push`. -#### Rough edges +### Update strategies -Updating a stack, particularly if it has a number of branches in it, can result in lots of merge commits. I'm exploring whether there are any improvements that can be made here for merging. I'd also like to support updating via a rebase as well in the future. +There are two strategies that can be used to update branches in a stack. -If you merge a pull request using "Squash and merge" then you might find that the first update to a stack after that results in merge conflicts that you need to resolve. This is a bit of a pain, I'm exploring whether there are any improvements that can be made here, perhaps by first merging into the just-merged local branch instead of ignoring it. +#### Merge + +The default strategy is to merge each branch in the stack into the one directly below it, starting with the source branch. + +**Rough edges** + +Updating a stack using merge, particularly if it has a number of branches in it, can result in lots of merge commits. + +If you merge a pull request using "Squash and merge" then you might find that the first update to a stack after that results in merge conflicts that you need to resolve. This can be a bit of a pain. + +#### Rebase + +Each branch in the stack can be rebased on it's parent branch by using the `--rebase` option in the `sync` and `update` commands. To push changes to the remote after rebasing you'll need to use the `--force-with-lease` option. + +**Rough edges** + +If you merge a pull request using "Squash and merge" then you might find that the first update to a stack after that results in merge conflicts that you need to resolve. This can be a bit of a pain, however for each commit that existed on the branch that was merged if you select to take the new single commit that now exists generally it isn't too bad. ### Creating pull requests for the stack @@ -181,7 +197,7 @@ OPTIONS: ### `stack update` -Updates the branches for a stack by merging each branch. +Updates the branches for a stack by either merging or rebasing each branch. ```shell USAGE: @@ -195,6 +211,7 @@ OPTIONS: --dry-run Show what would happen without making any changes -n, --name The name of the stack to update -f, --force Force the update of the stack + --rebase Use rebase instead of merge when updating the stack ``` ### `stack switch` @@ -321,6 +338,7 @@ OPTIONS: --dry-run Show what would happen without making any changes -n, --name The name of the stack to push changes from the remote for --max-batch-size The maximum number of branches to push changes for at once (default: 5) + --force-with-lease Force push changes with lease ``` ### `stack sync` @@ -341,6 +359,7 @@ OPTIONS: -n, --name The name of the stack to update -y, --yes Don't ask for confirmation before syncing the stack --max-batch-size The maximum number of branches to push changes for at once (default: 5) + --rebase Use rebase instead of merge when updating the stack ``` ## GitHub commands diff --git a/src/Stack.Tests/Commands/Helpers/StackHelpersTests.cs b/src/Stack.Tests/Commands/Helpers/StackHelpersTests.cs index 4ab0e6a9..1ffd9e32 100644 --- a/src/Stack.Tests/Commands/Helpers/StackHelpersTests.cs +++ b/src/Stack.Tests/Commands/Helpers/StackHelpersTests.cs @@ -46,12 +46,13 @@ public void UpdateStack_WhenThereAreConflictsMergingBranches_AndUpdateIsContinue gitClient .When(g => g.MergeFromLocalSourceBranch(sourceBranch)) - .Throws(new MergeConflictException()); + .Throws(new ConflictException()); // Act StackHelpers.UpdateStack( stack, stackStatus, + UpdateStrategy.Merge, gitClient, inputProvider, outputProvider @@ -92,7 +93,7 @@ public void UpdateStack_WhenThereAreConflictsMergingBranches_AndUpdateIsAborted_ gitClient .When(g => g.MergeFromLocalSourceBranch(sourceBranch)) - .Throws(new MergeConflictException()); + .Throws(new ConflictException()); inputProvider .Select( @@ -105,6 +106,7 @@ public void UpdateStack_WhenThereAreConflictsMergingBranches_AndUpdateIsAborted_ var updateAction = () => StackHelpers.UpdateStack( stack, stackStatus, + UpdateStrategy.Merge, gitClient, inputProvider, outputProvider diff --git a/src/Stack.Tests/Commands/Remote/PushStackCommandHandlerTests.cs b/src/Stack.Tests/Commands/Remote/PushStackCommandHandlerTests.cs index 1528b005..08090d01 100644 --- a/src/Stack.Tests/Commands/Remote/PushStackCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Remote/PushStackCommandHandlerTests.cs @@ -93,7 +93,7 @@ public async Task WhenNameIsProvided_DoesNotAskForName_PushesChangesToRemoteForB inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); // Act - await handler.Handle(new PushStackCommandInputs("Stack1", 5)); + await handler.Handle(new PushStackCommandInputs("Stack1", 5, false)); // Assert repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfBranch1); @@ -139,7 +139,7 @@ public async Task WhenNameIsProvided_ButStackDoesNotExist_Throws() // Act and assert var invalidStackName = Some.Name(); - await handler.Invoking(async h => await h.Handle(new PushStackCommandInputs(invalidStackName, 5))) + await handler.Invoking(async h => await h.Handle(new PushStackCommandInputs(invalidStackName, 5, false))) .Should().ThrowAsync() .WithMessage($"Stack '{invalidStackName}' not found."); } @@ -224,7 +224,7 @@ public async Task WhenNumberOfBranchesIsGreaterThanMaxBatchSize_ChangesAreSucces inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); // Act - await handler.Handle(new PushStackCommandInputs(null, 1)); + await handler.Handle(new PushStackCommandInputs(null, 1, false)); // Assert repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfBranch1); @@ -273,4 +273,48 @@ public async Task WhenBranchDoesNotExistOnRemote_ItIsPushedToTheRemote() repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfBranch1); repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfBranch2); } + + [Fact] + public async Task WhenUsingForceWithLease_ChangesAreForcePushedToTheRemote() + { + // 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(branch1, 3, false) + .WithNumberOfEmptyCommits(branch2, 2, false) + .Build(); + + repo.RebaseCommits(branch1, sourceBranch); + repo.RebaseCommits(branch2, branch1); + + var tipOfBranch1 = repo.GetTipOfBranch(branch1); + var tipOfBranch2 = repo.GetTipOfBranch(branch2); + + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = new TestOutputProvider(testOutputHelper); + var gitClient = new GitClient(outputProvider, repo.GitClientSettings); + var handler = new PushStackCommandHandler(inputProvider, outputProvider, gitClient, stackConfig); + + gitClient.ChangeBranch(branch1); + + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); + var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, []); + var stacks = new List([stack1, stack2]); + stackConfig.Load().Returns(stacks); + + inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + + // Act + await handler.Handle(new PushStackCommandInputs(null, 5, true)); + + // Assert + repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfBranch1); + repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfBranch2); + } } diff --git a/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs b/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs index 4893dfb4..8e01f87d 100644 --- a/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs @@ -47,7 +47,7 @@ public async Task WhenChangesExistOnTheSourceBranchOnTheRemote_PullsChanges_Upda inputProvider.Confirm(Questions.ConfirmSyncStack).Returns(true); // Act - await handler.Handle(new SyncStackCommandInputs(null, false, 5)); + await handler.Handle(new SyncStackCommandInputs(null, false, 5, false)); // Assert repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch); @@ -88,7 +88,7 @@ public async Task WhenNameIsProvided_DoesNotAskForName_SyncsCorrectStack() inputProvider.Confirm(Questions.ConfirmSyncStack).Returns(true); // Act - await handler.Handle(new SyncStackCommandInputs("Stack1", false, 5)); + await handler.Handle(new SyncStackCommandInputs("Stack1", false, 5, false)); // Assert repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch); @@ -130,7 +130,7 @@ public async Task WhenNoConfirmIsProvided_DoesNotAskForConfirmation() inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); // Act - await handler.Handle(new SyncStackCommandInputs(null, true, 5)); + await handler.Handle(new SyncStackCommandInputs(null, true, 5, false)); // Assert repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch); @@ -171,7 +171,7 @@ public async Task WhenNameIsProvided_ButStackDoesNotExist_Throws() // Act and assert var invalidStackName = Some.Name(); - await handler.Invoking(async h => await h.Handle(new SyncStackCommandInputs(invalidStackName, false, 5))) + await handler.Invoking(async h => await h.Handle(new SyncStackCommandInputs(invalidStackName, false, 5, false))) .Should().ThrowAsync() .WithMessage($"Stack '{invalidStackName}' not found."); } @@ -212,7 +212,7 @@ public async Task WhenOnASpecificBranchInTheStack_TheSameBranchIsSetAsCurrentAft inputProvider.Confirm(Questions.ConfirmSyncStack).Returns(true); // Act - await handler.Handle(new SyncStackCommandInputs(null, false, 5)); + await handler.Handle(new SyncStackCommandInputs(null, false, 5, false)); // Assert repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch); @@ -253,7 +253,7 @@ public async Task WhenOnlyASingleStackExists_DoesNotAskForStackName_SyncsStack() inputProvider.Confirm(Questions.ConfirmSyncStack).Returns(true); // Act - await handler.Handle(new SyncStackCommandInputs(null, false, 5)); + await handler.Handle(new SyncStackCommandInputs(null, false, 5, false)); // Assert repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch); @@ -262,4 +262,49 @@ public async Task WhenOnlyASingleStackExists_DoesNotAskForStackName_SyncsStack() inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any()); } + + [Fact] + public async Task WhenUsingRebase_ChangesExistOnTheSourceBranchOnTheRemote_PullsChanges_UpdatesBranches_AndPushesToRemote() + { + // 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()) + .WithNumberOfEmptyCommits(b => b.OnBranch(branch1), 3) + .Build(); + + var tipOfRemoteSourceBranch = repo.GetTipOfRemoteBranch(sourceBranch); + repo.GetCommitsReachableFromBranch(sourceBranch).Should().NotContain(tipOfRemoteSourceBranch); + + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = new TestOutputProvider(testOutputHelper); + var gitClient = new GitClient(outputProvider, repo.GitClientSettings); + var gitHubClient = Substitute.For(); + var handler = new SyncStackCommandHandler(inputProvider, outputProvider, gitClient, gitHubClient, stackConfig); + + gitClient.ChangeBranch(branch1); + + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); + var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, []); + var stacks = new List([stack1, stack2]); + stackConfig.Load().Returns(stacks); + + inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + inputProvider.Confirm(Questions.ConfirmSyncStack).Returns(true); + + // Act + await handler.Handle(new SyncStackCommandInputs(null, false, 5, true)); + + // Assert + var tipOfBranch1 = repo.GetTipOfBranch(branch1); + repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfBranch1); + } } diff --git a/src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs b/src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs index 76d1ce4d..06cc492a 100644 --- a/src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs @@ -45,7 +45,7 @@ public async Task WhenMultipleBranchesExistInAStack_UpdatesAndMergesEachBranchIn inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); // Act - await handler.Handle(new UpdateStackCommandInputs(null)); + await handler.Handle(new UpdateStackCommandInputs(null, false)); // Assert repo.GetCommitsReachableFromBranch(branch1).Should().Contain(tipOfSourceBranch); @@ -84,7 +84,7 @@ public async Task WhenABranchInTheStackNoLongerExistsOnTheRemote_SkipsOverUpdati inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); // Act - await handler.Handle(new UpdateStackCommandInputs(null)); + await handler.Handle(new UpdateStackCommandInputs(null, false)); // Assert repo.GetCommitsReachableFromBranch(branch2).Should().Contain(tipOfSourceBranch); @@ -123,7 +123,7 @@ public async Task WhenABranchInTheStackExistsOnTheRemote_ButThePullRequestIsMerg gitHubClient.GetPullRequest(branch1).Returns(new GitHubPullRequest(1, Some.Name(), Some.Name(), GitHubPullRequestStates.Merged, Some.HttpsUri(), false)); // Act - await handler.Handle(new UpdateStackCommandInputs(null)); + await handler.Handle(new UpdateStackCommandInputs(null, false)); // Assert repo.GetCommitsReachableFromBranch(branch2).Should().Contain(tipOfSourceBranch); @@ -160,7 +160,7 @@ public async Task WhenNameIsProvided_DoesNotAskForName_UpdatesCorrectStack() stackConfig.Load().Returns(stacks); // Act - await handler.Handle(new UpdateStackCommandInputs("Stack1")); + await handler.Handle(new UpdateStackCommandInputs("Stack1", false)); // Assert repo.GetCommitsReachableFromBranch(branch1).Should().Contain(tipOfSourceBranch); @@ -201,7 +201,7 @@ public async Task WhenNameIsProvided_ButStackDoesNotExist_Throws() // Act and assert var invalidStackName = Some.Name(); - await handler.Invoking(async h => await h.Handle(new UpdateStackCommandInputs(invalidStackName))) + await handler.Invoking(async h => await h.Handle(new UpdateStackCommandInputs(invalidStackName, false))) .Should().ThrowAsync() .WithMessage($"Stack '{invalidStackName}' not found."); } @@ -242,7 +242,7 @@ public async Task WhenOnASpecificBranchInTheStack_TheSameBranchIsSetAsCurrentAft inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); // Act - await handler.Handle(new UpdateStackCommandInputs(null)); + await handler.Handle(new UpdateStackCommandInputs(null, false)); // Assert repo.GetCommitsReachableFromBranch(branch1).Should().Contain(tipOfSourceBranch); @@ -281,7 +281,7 @@ public async Task WhenOnlyASingleStackExists_DoesNotAskForStackName_UpdatesStack stackConfig.Load().Returns(stacks); // Act - await handler.Handle(new UpdateStackCommandInputs(null)); + await handler.Handle(new UpdateStackCommandInputs(null, false)); // Assert repo.GetCommitsReachableFromBranch(branch1).Should().Contain(tipOfSourceBranch); @@ -290,4 +290,47 @@ public async Task WhenOnlyASingleStackExists_DoesNotAskForStackName_UpdatesStack inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any()); } + + [Fact] + public async Task WhenUpdatingUsingRebase_AllBranchesInStackAreUpdated() + { + // 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 = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = new TestOutputProvider(testOutputHelper); + var gitClient = new GitClient(outputProvider, repo.GitClientSettings); + var gitHubClient = Substitute.For(); + var handler = new UpdateStackCommandHandler(inputProvider, outputProvider, gitClient, gitHubClient, stackConfig); + + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); + var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, []); + var stacks = new List([stack1, stack2]); + stackConfig.Load().Returns(stacks); + + inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + + // Act + await handler.Handle(new UpdateStackCommandInputs(null, true)); + + // 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)); + } } diff --git a/src/Stack.Tests/Git/GitClientTests.cs b/src/Stack.Tests/Git/GitClientTests.cs index 5d209539..aa1b4b36 100644 --- a/src/Stack.Tests/Git/GitClientTests.cs +++ b/src/Stack.Tests/Git/GitClientTests.cs @@ -39,7 +39,7 @@ public void MergeFromLocalSourceBranch_WhenConflictsOccur_ThrowsMergeConflictExc var merge = () => gitClient.MergeFromLocalSourceBranch(branch2); // Assert - merge.Should().Throw(); + merge.Should().Throw(); } [Fact] diff --git a/src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs b/src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs index 89d11cf3..5bb05d5f 100644 --- a/src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs +++ b/src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs @@ -151,6 +151,7 @@ public class TestGitRepositoryBuilder { List> branchBuilders = []; List> commitBuilders = []; + Dictionary config = new(); public TestGitRepositoryBuilder WithBranch(string branch) { @@ -222,6 +223,12 @@ public TestGitRepositoryBuilder WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf return this; } + public TestGitRepositoryBuilder WithConfig(string key, string value) + { + config[key] = value; + return this; + } + public TestGitRepository Build() { var remote = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N"), ".git"); @@ -256,6 +263,11 @@ public TestGitRepository Build() builder.Build(localRepo); } + foreach (var (key, value) in config) + { + localRepo.Config.Add(key, value); + } + return new TestGitRepository(localDirectory, remoteDirectory, localRepo); } @@ -314,6 +326,22 @@ public void Stage(string path) LibGit2Sharp.Commands.Stage(LocalRepository, path); } + public void RebaseCommits(string branchName, string sourceBranchName) + { + var branch = LocalRepository.Branches[branchName]; + var sourceBranch = LocalRepository.Branches[sourceBranchName]; + var remoteBranchName = branch.TrackedBranch.CanonicalName; + LocalRepository.Rebase.Start(branch, LocalRepository.Branches[remoteBranchName], sourceBranch, new Identity(Some.Name(), Some.Email()), new RebaseOptions()); + } + + public (int Ahead, int Behind) GetAheadBehind(string branchName) + { + var branch = LocalRepository.Branches[branchName]; + var remoteBranchName = branch.TrackedBranch.CanonicalName; + var historyDivergence = LocalRepository.ObjectDatabase.CalculateHistoryDivergence(branch.Tip, LocalRepository.Branches[remoteBranchName].Tip); + return (historyDivergence.AheadBy ?? 0, historyDivergence.BehindBy ?? 0); + } + public void Dispose() { GC.SuppressFinalize(this); diff --git a/src/Stack/Commands/Helpers/Questions.cs b/src/Stack/Commands/Helpers/Questions.cs index 1d6b71e9..68752237 100644 --- a/src/Stack/Commands/Helpers/Questions.cs +++ b/src/Stack/Commands/Helpers/Questions.cs @@ -24,5 +24,6 @@ public static class Questions public const string OpenPullRequests = "Open new pull requests in a browser?"; public const string CreatePullRequestAsDraft = "Create pull request as draft?"; public const string EditPullRequestBody = "Edit pull request body? This will open a file in your default editor."; - public const string ContinueOrAbortMerge = "Merge conflict(s) detected. Please either resolve the conflicts, commit the result and Continue, or Abort."; + 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."; } diff --git a/src/Stack/Commands/Helpers/StackHelpers.cs b/src/Stack/Commands/Helpers/StackHelpers.cs index 479b9bb7..8bfed1ec 100644 --- a/src/Stack/Commands/Helpers/StackHelpers.cs +++ b/src/Stack/Commands/Helpers/StackHelpers.cs @@ -321,6 +321,24 @@ public static void OutputBranchAndStackActions( } public static void UpdateStack( + Config.Stack stack, + StackStatus status, + UpdateStrategy? updateStrategy, + IGitClient gitClient, + IInputProvider inputProvider, + IOutputProvider outputProvider) + { + if (updateStrategy == UpdateStrategy.Rebase) + { + UpdateStackUsingRebase(stack, status, gitClient, inputProvider, outputProvider); + } + else + { + UpdateStackUsingMerge(stack, status, gitClient, inputProvider, outputProvider); + } + } + + public static void UpdateStackUsingMerge( Config.Stack stack, StackStatus status, IGitClient gitClient, @@ -361,6 +379,7 @@ public static void PullChanges(Config.Stack stack, IGitClient gitClient, IOutput public static void PushChanges( Config.Stack stack, int maxBatchSize, + bool forceWithLease, IGitClient gitClient, IOutputProvider outputProvider) { @@ -386,7 +405,7 @@ public static void PushChanges( { outputProvider.Information($"Pushing changes for {string.Join(", ", branches.Select(b => b.Branch()))} to remote"); - gitClient.PushBranches([.. branches]); + gitClient.PushBranches([.. branches], forceWithLease); } } @@ -399,7 +418,7 @@ static void MergeFromSourceBranch(string branch, string sourceBranchName, IGitCl { gitClient.MergeFromLocalSourceBranch(sourceBranchName); } - catch (MergeConflictException) + catch (ConflictException) { var action = inputProvider.Select( Questions.ContinueOrAbortMerge, @@ -418,10 +437,113 @@ static void MergeFromSourceBranch(string branch, string sourceBranchName, IGitCl } } } + + static void UpdateStackUsingRebase( + Config.Stack stack, + StackStatus status, + IGitClient gitClient, + IInputProvider inputProvider, + IOutputProvider outputProvider) + { + // + // When rebasing the stack, we'll use `git rebase --update-refs` from the + // lowest branch in the stack to pick up changes throughout all branches in the stack. + // Because there could be changes in any branch in the stack that aren't in the ones + // below it, we'll repeat this all the way from the bottom to the top of the stack. + // + // For example if we have a stack like this: + // main -> feature1 -> feature2 -> feature3 + // + // We'll rebase feature3 onto feature2, then feature3 onto feature1, and finally feature3 onto main. + // + string? branchToRebaseFrom = null; + + foreach (var branch in stack.Branches) + { + var branchDetail = status.Branches[branch]; + + if (branchDetail.IsActive) + { + branchToRebaseFrom = branch; + } + } + + if (branchToRebaseFrom is null) + { + outputProvider.Warning("No active branches found in the stack."); + return; + } + + var branchesToRebaseOnto = new List(stack.Branches); + branchesToRebaseOnto.Reverse(); + branchesToRebaseOnto.Remove(branchToRebaseFrom); + branchesToRebaseOnto.Add(stack.SourceBranch); + + foreach (var branchToRebaseOnto in branchesToRebaseOnto) + { + var branchDetail = status.Branches[branchToRebaseOnto]; + + if (branchDetail.IsActive) + { + RebaseFromSourceBranch(branchToRebaseFrom, branchToRebaseOnto, gitClient, inputProvider, outputProvider); + } + } + } + + static void RebaseFromSourceBranch(string branch, string sourceBranchName, IGitClient gitClient, IInputProvider inputProvider, IOutputProvider outputProvider) + { + outputProvider.Information($"Rebasing {branch.Branch()} onto {sourceBranchName.Branch()}"); + gitClient.ChangeBranch(branch); + + void HandleConflicts() + { + var action = inputProvider.Select( + Questions.ContinueOrAbortRebase, + [MergeConflictAction.Continue, MergeConflictAction.Abort], + a => a switch + { + MergeConflictAction.Continue => "Continue", + MergeConflictAction.Abort => "Abort", + _ => throw new InvalidOperationException("Invalid rebase conflict action.") + }); + + if (action == MergeConflictAction.Abort) + { + gitClient.AbortMerge(); + throw new Exception("Rebase aborted due to conflicts."); + } + else if (action == MergeConflictAction.Continue) + { + try + { + gitClient.ContinueRebase(); + } + catch (ConflictException) + { + HandleConflicts(); + } + } + } + + try + { + gitClient.RebaseFromLocalSourceBranch(sourceBranchName); + } + catch (ConflictException) + { + HandleConflicts(); + } + } } public enum MergeConflictAction { Abort, Continue +} + +public enum UpdateStrategy +{ + Merge, + Rebase } \ No newline at end of file diff --git a/src/Stack/Commands/Remote/PushStackCommand.cs b/src/Stack/Commands/Remote/PushStackCommand.cs index bb4ce3ed..54794f09 100644 --- a/src/Stack/Commands/Remote/PushStackCommand.cs +++ b/src/Stack/Commands/Remote/PushStackCommand.cs @@ -18,6 +18,10 @@ public class PushStackCommandSettings : DryRunCommandSettingsBase [CommandOption("--max-batch-size")] [DefaultValue(5)] public int MaxBatchSize { get; init; } = 5; + + [Description("Force push changes with lease.")] + [CommandOption("--force-with-lease")] + public bool ForceWithLease { get; init; } } public class PushStackCommand : AsyncCommand @@ -33,15 +37,18 @@ public override async Task ExecuteAsync(CommandContext context, PushStackCo new GitClient(outputProvider, settings.GetGitClientSettings()), new StackConfig()); - await handler.Handle(new PushStackCommandInputs(settings.Name, settings.MaxBatchSize)); + await handler.Handle(new PushStackCommandInputs( + settings.Name, + settings.MaxBatchSize, + settings.ForceWithLease)); return 0; } } -public record PushStackCommandInputs(string? Name, int MaxBatchSize) +public record PushStackCommandInputs(string? Name, int MaxBatchSize, bool ForceWithLease) { - public static PushStackCommandInputs Default => new(null, 5); + public static PushStackCommandInputs Default => new(null, 5, false); } public class PushStackCommandHandler( @@ -71,6 +78,6 @@ public async Task Handle(PushStackCommandInputs inputs) if (stack is null) throw new InvalidOperationException($"Stack '{inputs.Name}' not found."); - StackHelpers.PushChanges(stack, inputs.MaxBatchSize, gitClient, outputProvider); + StackHelpers.PushChanges(stack, inputs.MaxBatchSize, inputs.ForceWithLease, gitClient, outputProvider); } } diff --git a/src/Stack/Commands/Remote/SyncStackCommand.cs b/src/Stack/Commands/Remote/SyncStackCommand.cs index 9efb903b..0bc3a2f2 100644 --- a/src/Stack/Commands/Remote/SyncStackCommand.cs +++ b/src/Stack/Commands/Remote/SyncStackCommand.cs @@ -22,6 +22,10 @@ public class SyncStackCommandSettings : DryRunCommandSettingsBase [CommandOption("--max-batch-size")] [DefaultValue(5)] public int MaxBatchSize { get; init; } = 5; + + [Description("Use rebase instead of merge when updating the stack.")] + [CommandOption("--rebase")] + public bool Rebase { get; init; } } public class SyncStackCommand : AsyncCommand @@ -38,15 +42,23 @@ public override async Task ExecuteAsync(CommandContext context, SyncStackCo new GitHubClient(outputProvider, settings.GetGitHubClientSettings()), new StackConfig()); - await handler.Handle(new SyncStackCommandInputs(settings.Name, settings.NoConfirm, settings.MaxBatchSize)); + await handler.Handle(new SyncStackCommandInputs( + settings.Name, + settings.NoConfirm, + settings.MaxBatchSize, + settings.Rebase)); return 0; } } -public record SyncStackCommandInputs(string? Name, bool NoConfirm, int MaxBatchSize) +public record SyncStackCommandInputs( + string? Name, + bool NoConfirm, + int MaxBatchSize, + bool Rebase) { - public static SyncStackCommandInputs Empty => new(null, false, 5); + public static SyncStackCommandInputs Empty => new(null, false, 5, false); } public record SyncStackCommandResponse(); @@ -99,9 +111,15 @@ public async Task Handle(SyncStackCommandInputs inputs StackHelpers.PullChanges(stack, gitClient, outputProvider); - StackHelpers.UpdateStack(stack, status, gitClient, inputProvider, outputProvider); + StackHelpers.UpdateStack( + stack, + status, + inputs.Rebase ? UpdateStrategy.Rebase : UpdateStrategy.Merge, + gitClient, + inputProvider, + outputProvider); - StackHelpers.PushChanges(stack, inputs.MaxBatchSize, gitClient, outputProvider); + StackHelpers.PushChanges(stack, inputs.MaxBatchSize, inputs.Rebase, gitClient, outputProvider); if (stack.SourceBranch.Equals(currentBranch, StringComparison.InvariantCultureIgnoreCase) || stack.Branches.Contains(currentBranch, StringComparer.OrdinalIgnoreCase)) diff --git a/src/Stack/Commands/Stack/UpdateStackCommand.cs b/src/Stack/Commands/Stack/UpdateStackCommand.cs index a13a9f62..49321a86 100644 --- a/src/Stack/Commands/Stack/UpdateStackCommand.cs +++ b/src/Stack/Commands/Stack/UpdateStackCommand.cs @@ -14,6 +14,10 @@ public class UpdateStackCommandSettings : DryRunCommandSettingsBase [Description("The name of the stack to update.")] [CommandOption("-n|--name")] public string? Name { get; init; } + + [Description("Use rebase instead of merge when updating the stack.")] + [CommandOption("--rebase")] + public bool Rebase { get; init; } } public class UpdateStackCommand : AsyncCommand @@ -30,15 +34,15 @@ public override async Task ExecuteAsync(CommandContext context, UpdateStack new GitHubClient(outputProvider, settings.GetGitHubClientSettings()), new StackConfig()); - await handler.Handle(new UpdateStackCommandInputs(settings.Name)); + await handler.Handle(new UpdateStackCommandInputs(settings.Name, settings.Rebase)); return 0; } } -public record UpdateStackCommandInputs(string? Name) +public record UpdateStackCommandInputs(string? Name, bool Rebase) { - public static UpdateStackCommandInputs Empty => new((string?)null); + public static UpdateStackCommandInputs Empty => new(null, false); } public record UpdateStackCommandResponse(); @@ -79,7 +83,13 @@ public async Task Handle(UpdateStackCommandInputs in gitHubClient, false); - StackHelpers.UpdateStack(stack, status, gitClient, inputProvider, outputProvider); + StackHelpers.UpdateStack( + stack, + status, + inputs.Rebase ? UpdateStrategy.Rebase : UpdateStrategy.Merge, + gitClient, + inputProvider, + outputProvider); if (stack.SourceBranch.Equals(currentBranch, StringComparison.InvariantCultureIgnoreCase) || stack.Branches.Contains(currentBranch, StringComparer.OrdinalIgnoreCase)) diff --git a/src/Stack/Git/GitClient.cs b/src/Stack/Git/GitClient.cs index c13b2d36..d4da6779 100644 --- a/src/Stack/Git/GitClient.cs +++ b/src/Stack/Git/GitClient.cs @@ -14,7 +14,7 @@ public record Commit(string Sha, string Message); public record GitBranchStatus(string BranchName, string? RemoteTrackingBranchName, bool RemoteBranchExists, bool IsCurrentBranch, int Ahead, int Behind, Commit Tip); -public class MergeConflictException : Exception; +public class ConflictException : Exception; public interface IGitClient { @@ -25,11 +25,14 @@ public interface IGitClient void Fetch(bool prune); void FetchBranches(string[] branches); void PullBranch(string branchName); - void PushBranches(string[] branches); + void PushBranches(string[] branches, bool forceWithLease); void UpdateBranch(string branchName); void DeleteLocalBranch(string branchName); void MergeFromLocalSourceBranch(string sourceBranchName); + void RebaseFromLocalSourceBranch(string sourceBranchName); void AbortMerge(); + void AbortRebase(); + void ContinueRebase(); string GetCurrentBranch(); bool DoesLocalBranchExist(string branchName); bool DoesRemoteBranchExist(string branchName); @@ -83,9 +86,13 @@ public void PullBranch(string branchName) ExecuteGitCommand($"pull origin {branchName}"); } - public void PushBranches(string[] branches) + public void PushBranches(string[] branches, bool forceWithLease) { var command = $"push origin {string.Join(" ", branches)}"; + if (forceWithLease) + { + command += " --force-with-lease"; + } ExecuteGitCommand(command, true); } @@ -113,7 +120,20 @@ public void MergeFromLocalSourceBranch(string sourceBranchName) { if (exitCode > 0) { - return new MergeConflictException(); + return new ConflictException(); + } + + return null; + }); + } + + public void RebaseFromLocalSourceBranch(string sourceBranchName) + { + ExecuteGitCommand($"rebase {sourceBranchName} --update-refs", false, exitCode => + { + if (exitCode > 0) + { + return new ConflictException(); } return null; @@ -125,6 +145,24 @@ public void AbortMerge() ExecuteGitCommand("merge --abort"); } + public void AbortRebase() + { + ExecuteGitCommand("rebase --abort"); + } + + public void ContinueRebase() + { + ExecuteGitCommand($"rebase --continue", false, exitCode => + { + if (exitCode > 0) + { + return new ConflictException(); + } + + return null; + }); + } + public string GetCurrentBranch() { return ExecuteGitCommandAndReturnOutput("branch --show-current").Trim();