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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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`
Expand Down Expand Up @@ -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`
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/Stack.Tests/Commands/Helpers/StackHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -92,7 +93,7 @@ public void UpdateStack_WhenThereAreConflictsMergingBranches_AndUpdateIsAborted_

gitClient
.When(g => g.MergeFromLocalSourceBranch(sourceBranch))
.Throws(new MergeConflictException());
.Throws(new ConflictException());

inputProvider
.Select(
Expand All @@ -105,6 +106,7 @@ public void UpdateStack_WhenThereAreConflictsMergingBranches_AndUpdateIsAborted_
var updateAction = () => StackHelpers.UpdateStack(
stack,
stackStatus,
UpdateStrategy.Merge,
gitClient,
inputProvider,
outputProvider
Expand Down
50 changes: 47 additions & 3 deletions src/Stack.Tests/Commands/Remote/PushStackCommandHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public async Task WhenNameIsProvided_DoesNotAskForName_PushesChangesToRemoteForB
inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).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);
Expand Down Expand Up @@ -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<InvalidOperationException>()
.WithMessage($"Stack '{invalidStackName}' not found.");
}
Expand Down Expand Up @@ -224,7 +224,7 @@ public async Task WhenNumberOfBranchesIsGreaterThanMaxBatchSize_ChangesAreSucces
inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).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);
Expand Down Expand Up @@ -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<IStackConfig>();
var inputProvider = Substitute.For<IInputProvider>();
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<Config.Stack>([stack1, stack2]);
stackConfig.Load().Returns(stacks);

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

// Act
await handler.Handle(new PushStackCommandInputs(null, 5, true));

// Assert
repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfBranch1);
repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfBranch2);
}
}
57 changes: 51 additions & 6 deletions src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -130,7 +130,7 @@ public async Task WhenNoConfirmIsProvided_DoesNotAskForConfirmation()
inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).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);
Expand Down Expand Up @@ -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<InvalidOperationException>()
.WithMessage($"Stack '{invalidStackName}' not found.");
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -262,4 +262,49 @@ public async Task WhenOnlyASingleStackExists_DoesNotAskForStackName_SyncsStack()

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

[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<IStackConfig>();
var inputProvider = Substitute.For<IInputProvider>();
var outputProvider = new TestOutputProvider(testOutputHelper);
var gitClient = new GitClient(outputProvider, repo.GitClientSettings);
var gitHubClient = Substitute.For<IGitHubClient>();
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<Config.Stack>([stack1, stack2]);
stackConfig.Load().Returns(stacks);

inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).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);
}
}
57 changes: 50 additions & 7 deletions src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public async Task WhenMultipleBranchesExistInAStack_UpdatesAndMergesEachBranchIn
inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).Returns("Stack1");

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

// Assert
repo.GetCommitsReachableFromBranch(branch1).Should().Contain(tipOfSourceBranch);
Expand Down Expand Up @@ -84,7 +84,7 @@ public async Task WhenABranchInTheStackNoLongerExistsOnTheRemote_SkipsOverUpdati
inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).Returns("Stack1");

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

// Assert
repo.GetCommitsReachableFromBranch(branch2).Should().Contain(tipOfSourceBranch);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<InvalidOperationException>()
.WithMessage($"Stack '{invalidStackName}' not found.");
}
Expand Down Expand Up @@ -242,7 +242,7 @@ public async Task WhenOnASpecificBranchInTheStack_TheSameBranchIsSetAsCurrentAft
inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).Returns("Stack1");

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

// Assert
repo.GetCommitsReachableFromBranch(branch1).Should().Contain(tipOfSourceBranch);
Expand Down Expand Up @@ -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);
Expand All @@ -290,4 +290,47 @@ public async Task WhenOnlyASingleStackExists_DoesNotAskForStackName_UpdatesStack

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

[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<IStackConfig>();
var inputProvider = Substitute.For<IInputProvider>();
var outputProvider = new TestOutputProvider(testOutputHelper);
var gitClient = new GitClient(outputProvider, repo.GitClientSettings);
var gitHubClient = Substitute.For<IGitHubClient>();
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<Config.Stack>([stack1, stack2]);
stackConfig.Load().Returns(stacks);

inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).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));
}
}
Loading
Loading