Skip to content

Commit

Permalink
Adds stack sync command (#166)
Browse files Browse the repository at this point in the history
  • Loading branch information
geofflamrock authored Dec 30, 2024
1 parent 0f3ed93 commit bb81640
Show file tree
Hide file tree
Showing 9 changed files with 512 additions and 17 deletions.
48 changes: 36 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ To create a stack:
- Optionally either create a new branch from the source branch, or add an existing branch to the stack.
- If you chose to create or add a branch you can switch to that branch to start work.

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.

### Working within a stack

Working within a stack is the same as working with Git as per normal, make your changes on the branch, commit them and push them to the remote. You likely have your own tooling and workflows for this, you can continue to use them.
Expand All @@ -64,22 +66,22 @@ Once you've done some work on the first branch within the stack, at some point y

The new branch will be created from the branch at the bottom of the stack and you can then switch to the branch if you would like to in order to make more changes.

### Updating a stack
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

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:

- Run `stack update`
- Select the stack you wish to update
- Confirm the update
- Run `stack sync`
- Select the stack you wish to sync
- Confirm the sync

Branches in the stack will be updated by:

- Fetching the latest changes from the remote for all branches in the stack, including the source branch.
- Merging from the source branch to the first branch in the stack.
- Pushing changes for the first branch to the remote.
- Merging from the first branch to the second branch in the stack (if one exists).
- Pushing changes for the second branch to the remote.
- Repeating this until all branches are updated.
- Fetching changes to the repository, pruning remote branches that no longer exist, the equivalent of running `git fetch --prune`.
- Pulling changes for all branches in the stack, including the source branch, the equivalent of running `stack pull`.
- 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

Expand Down Expand Up @@ -286,9 +288,10 @@ OPTIONS:

### `stack pull`

```shell
Pulls changes from the remote repository for a stack.

```shell

USAGE:
stack pull [OPTIONS]

Expand All @@ -303,9 +306,10 @@ OPTIONS:

### `stack push`

```shell
Pushes changes to the remote repository for a stack.

```shell

USAGE:
stack push [OPTIONS]

Expand All @@ -319,6 +323,26 @@ OPTIONS:
--max-batch-size The maximum number of branches to push changes for at once (default: 5)
```

### `stack sync`

Syncs a stack with the remote repository. Shortcut for `git fetch --prune`, `stack pull`, `stack update` and `stack push`.

```shell

USAGE:
stack sync [OPTIONS]

OPTIONS:
-h, --help Prints help information
-v, --version Prints version information
--verbose Show verbose output
--working-dir The path to the directory containing the git repository. Defaults to the current directory
--dry-run Show what would happen without making any changes
-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)
```
## GitHub commands
### `stack pr create`
Expand Down
265 changes: 265 additions & 0 deletions src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
using FluentAssertions;
using NSubstitute;
using Stack.Commands;
using Stack.Config;
using Stack.Git;
using Stack.Tests.Helpers;
using Stack.Infrastructure;
using Stack.Commands.Helpers;
using Xunit.Abstractions;

namespace Stack.Tests.Commands.Remote;

public class SyncStackCommandHandlerTests(ITestOutputHelper testOutputHelper)
{
[Fact]
public async Task WhenChangesExistOnTheSourceBranchOnTheRemote_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())
.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 gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings);
var gitHubOperations = Substitute.For<IGitHubOperations>();
var handler = new SyncStackCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig);

gitOperations.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));

// Assert
repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch);
repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfRemoteSourceBranch);
}

[Fact]
public async Task WhenNameIsProvided_DoesNotAskForName_SyncsCorrectStack()
{
// 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())
.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 gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings);
var gitHubOperations = Substitute.For<IGitHubOperations>();
var handler = new SyncStackCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig);

gitOperations.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.Confirm(Questions.ConfirmSyncStack).Returns(true);

// Act
await handler.Handle(new SyncStackCommandInputs("Stack1", false, 5));

// Assert
repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch);
repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfRemoteSourceBranch);
inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any<string[]>());
}

[Fact]
public async Task WhenNoConfirmIsProvided_DoesNotAskForConfirmation()
{
// 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())
.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 gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings);
var gitHubOperations = Substitute.For<IGitHubOperations>();
var handler = new SyncStackCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig);

gitOperations.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 SyncStackCommandInputs(null, true, 5));

// Assert
repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch);
repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfRemoteSourceBranch);
inputProvider.DidNotReceive().Confirm(Questions.ConfirmSyncStack);
}

[Fact]
public async Task WhenNameIsProvided_ButStackDoesNotExist_Throws()
{
// 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())
.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 gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings);
var gitHubOperations = Substitute.For<IGitHubOperations>();
var handler = new SyncStackCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig);

gitOperations.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);

// Act and assert
var invalidStackName = Some.Name();
await handler.Invoking(async h => await h.Handle(new SyncStackCommandInputs(invalidStackName, false, 5)))
.Should().ThrowAsync<InvalidOperationException>()
.WithMessage($"Stack '{invalidStackName}' not found.");
}

[Fact]
public async Task WhenOnASpecificBranchInTheStack_TheSameBranchIsSetAsCurrentAfterTheSync()
{
// 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())
.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 gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings);
var gitHubOperations = Substitute.For<IGitHubOperations>();
var handler = new SyncStackCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig);

// We are on a specific branch in the stack
gitOperations.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));

// Assert
repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch);
repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfRemoteSourceBranch);
gitOperations.GetCurrentBranch().Should().Be(branch1);
}

[Fact]
public async Task WhenOnlyASingleStackExists_DoesNotAskForStackName_SyncsStack()
{
// 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())
.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 gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings);
var gitHubOperations = Substitute.For<IGitHubOperations>();
var handler = new SyncStackCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig);

gitOperations.ChangeBranch(branch1);

var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]);
var stacks = new List<Config.Stack>([stack1]);
stackConfig.Load().Returns(stacks);

inputProvider.Confirm(Questions.ConfirmSyncStack).Returns(true);

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

// Assert
repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch);
repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfRemoteSourceBranch);
gitOperations.GetCurrentBranch().Should().Be(branch1);

inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any<string[]>());
}
}
1 change: 1 addition & 0 deletions src/Stack/Commands/Helpers/Questions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public static class Questions
public const string BranchName = "Branch name:";
public const string SelectSourceBranch = "Select a branch to start your stack from:";
public const string ConfirmUpdateStack = "Are you sure you want to update this stack?";
public const string ConfirmSyncStack = "Are you sure you want to sync this stack with the remote repository?";
public const string ConfirmDeleteStack = "Are you sure you want to delete this stack?";
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?";
Expand Down
15 changes: 11 additions & 4 deletions src/Stack/Commands/Helpers/StackStatusHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,18 @@ public static StackStatus GetStackStatus(
string currentBranch,
IOutputProvider outputProvider,
IGitOperations gitOperations,
IGitHubOperations gitHubOperations)
IGitHubOperations gitHubOperations,
bool includePullRequestStatus = true)
{
var statues = GetStackStatus([stack], currentBranch, outputProvider, gitOperations, gitHubOperations);

return statues[stack];
var statuses = GetStackStatus(
[stack],
currentBranch,
outputProvider,
gitOperations,
gitHubOperations,
includePullRequestStatus);

return statuses[stack];
}

public static void OutputStackStatus(
Expand Down
Loading

0 comments on commit bb81640

Please sign in to comment.