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
63 changes: 35 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ This is where `stack` comes in: It lets you manage multiple branches that form t
- [Adding a new branch](#adding-a-new-branch)
- [Incorporating changes from the remote repository](#incorporating-changes-from-the-remote-repository)
- [Specifying an update strategy](#specifying-an-update-strategy)
- [Checking pull requests during update](#checking-pull-requests-during-update)
- [Creating pull requests](#creating-pull-requests)
- [Commands](#commands)

Expand Down Expand Up @@ -123,7 +124,7 @@ To use the merge strategy, either:

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.
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.

##### Rebase <!-- omit from toc -->

Expand All @@ -144,6 +145,10 @@ Stack has handling to detect when a squash merge happens during updating a stack

The remote tracking branch for the branch that was squash merged needs to be deleted for this handling to be enabled.

#### Checking pull requests during update

A branch will be skipped during the update of a stack if it's remote tracking branch has been deleted, which normally happens when a pull request is merged. If you don't delete the remote tracking branch when merging pull requests you can skip branches where the pull request is merged with the `--check-pull-requests` option.

### Creating pull requests

When you've made your changes you can create a set of pull requests that build off each other. This requires that you have the `gh` CLI installed on your path and authenticated (run `gh auth login`).
Expand Down Expand Up @@ -209,14 +214,14 @@ Usage:
stack status [options]

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.
--all Show status of all stacks.
--full Show full status including pull requests.
-?, -h, --help Show help and usage information
--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.
--all Show status of all stacks.
--check-pull-requests Include the status of pull requests in output.
-?, -h, --help Show help and usage information
```

#### `stack delete` <!-- omit from toc -->
Expand Down Expand Up @@ -266,14 +271,15 @@ Usage:
stack update [options]

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.
--rebase Use rebase when updating the stack. Overrides any setting in Git configuration.
--merge Use merge when updating the stack. Overrides any setting in Git configuration.
-?, -h, --help Show help and usage information
--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.
--rebase Use rebase when updating the stack. Overrides any setting in Git configuration.
--merge Use merge when updating the stack. Overrides any setting in Git configuration.
--check-pull-requests Check the status of pull requests when determining if a branch should be included in updating the stack.
-?, -h, --help Show help and usage information
```

#### `stack switch` <!-- omit from toc -->
Expand Down Expand Up @@ -438,17 +444,18 @@ Usage:
stack sync [options]

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.
--max-batch-size The maximum number of branches to process at once. [default: 5]
--rebase Use rebase when updating the stack. Overrides any setting in Git configuration.
--merge Use merge when updating the stack. Overrides any setting in Git configuration.
-y, --yes Confirm the command without prompting.
--no-push Don't push changes to the remote repository
-?, -h, --help Show help and usage information
--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.
--max-batch-size The maximum number of branches to process at once. [default: 5]
--rebase Use rebase when updating the stack. Overrides any setting in Git configuration.
--merge Use merge when updating the stack. Overrides any setting in Git configuration.
-y, --yes Confirm the command without prompting.
--check-pull-requests Check the status of pull requests as part of determining if a branch should be included when updating the stack.
--no-push Don't push changes to the remote repository
-?, -h, --help Show help and usage information
```

### GitHub commands <!-- omit from toc -->
Expand Down
212 changes: 212 additions & 0 deletions src/Stack.Tests/Commands/Helpers/StackActionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,92 @@ public async Task UpdateStack_UsingMerge_WhenConflictsResolved_CompletesSuccessf
gitClient.Received().ChangeBranch(feature);
}

[Fact]
public async Task UpdateStack_UsingMerge_WhenBranchHasMergedPullRequest_SkipsBranch()
{
// Arrange
var sourceBranch = Some.BranchName();
var inactiveBranch = Some.BranchName();

var logger = XUnitLogger.CreateLogger<StackActions>(testOutputHelper);
var displayProvider = new TestDisplayProvider(testOutputHelper);
var gitClient = Substitute.For<IGitClient>();
var gitHubClient = new TestGitHubRepositoryBuilder()
.WithPullRequest(inactiveBranch, pr => pr.Merged())
.Build();
var conflictResolutionDetector = Substitute.For<IConflictResolutionDetector>();

var branchStatuses = new Dictionary<string, GitBranchStatus>
{
{ sourceBranch, new GitBranchStatus(sourceBranch, $"origin/{sourceBranch}", true, true, 0, 0, new Commit(Some.Sha(), Some.Name())) },
{ inactiveBranch, new GitBranchStatus(inactiveBranch, $"origin/{inactiveBranch}", true, false, 0, 0, new Commit(Some.Sha(), Some.Name())) }
};

gitClient.GetBranchStatuses(Arg.Any<string[]>()).Returns(branchStatuses);

var stack = new Config.Stack(
"Stack1",
Some.HttpsUri().ToString(),
sourceBranch,
new List<Config.Branch> { new(inactiveBranch, []) });

var executionContext = new CliExecutionContext { WorkingDirectory = "/repo" };
var factory = Substitute.For<IGitClientFactory>();
factory.Create(executionContext.WorkingDirectory).Returns(gitClient);
factory.Create(Arg.Any<string>()).Returns(gitClient);

var actions = new StackActions(factory, executionContext, gitHubClient, logger, displayProvider, conflictResolutionDetector);

// Act
await actions.UpdateStack(stack, UpdateStrategy.Merge, CancellationToken.None, true);

// Assert
gitClient.DidNotReceive().ChangeBranch(inactiveBranch);
gitClient.DidNotReceive().MergeFromLocalSourceBranch(Arg.Any<string>());
}

[Fact]
public async Task UpdateStack_UsingMerge_WhenBranchHasNoRemoteTrackingBranch_IsUpdated()
{
// Arrange
var sourceBranch = Some.BranchName();
var localOnlyBranch = Some.BranchName();

var logger = XUnitLogger.CreateLogger<StackActions>(testOutputHelper);
var displayProvider = new TestDisplayProvider(testOutputHelper);
var gitClient = Substitute.For<IGitClient>();
var gitHubClient = Substitute.For<IGitHubClient>();
var conflictResolutionDetector = Substitute.For<IConflictResolutionDetector>();

var branchStatuses = new Dictionary<string, GitBranchStatus>
{
{ sourceBranch, new GitBranchStatus(sourceBranch, $"origin/{sourceBranch}", true, true, 0, 0, new Commit(Some.Sha(), Some.Name())) },
{ localOnlyBranch, new GitBranchStatus(localOnlyBranch, null, false, false, 0, 0, new Commit(Some.Sha(), Some.Name())) }
};

gitClient.GetBranchStatuses(Arg.Any<string[]>()).Returns(branchStatuses);

var stack = new Config.Stack(
"Stack1",
Some.HttpsUri().ToString(),
sourceBranch,
new List<Config.Branch> { new(localOnlyBranch, []) });

var executionContext = new CliExecutionContext { WorkingDirectory = "/repo" };
var factory = Substitute.For<IGitClientFactory>();
factory.Create(executionContext.WorkingDirectory).Returns(gitClient);
factory.Create(Arg.Any<string>()).Returns(gitClient);

var actions = new StackActions(factory, executionContext, gitHubClient, logger, displayProvider, conflictResolutionDetector);

// Act
await actions.UpdateStack(stack, UpdateStrategy.Merge, CancellationToken.None);

// Assert
gitClient.Received(1).ChangeBranch(localOnlyBranch);
gitClient.Received(1).MergeFromLocalSourceBranch(sourceBranch);
}

[Fact]
public async Task UpdateStack_UsingRebase_WhenConflictResolutionAborted_ThrowsAbortException()
{
Expand Down Expand Up @@ -160,6 +246,93 @@ public async Task UpdateStack_UsingRebase_WhenConflictsResolved_CompletesSuccess
gitClient.Received().ChangeBranch(feature);
}

[Fact]
public async Task UpdateStack_UsingRebase_WhenBranchHasMergedPullRequest_SkipsBranch()
{
// Arrange
var sourceBranch = Some.BranchName();
var inactiveBranch = Some.BranchName();

var logger = XUnitLogger.CreateLogger<StackActions>(testOutputHelper);
var displayProvider = new TestDisplayProvider(testOutputHelper);
var gitClient = Substitute.For<IGitClient>();
var gitHubClient = new TestGitHubRepositoryBuilder()
.WithPullRequest(inactiveBranch, pr => pr.Merged())
.Build();
var conflictResolutionDetector = Substitute.For<IConflictResolutionDetector>();

var branchStatuses = new Dictionary<string, GitBranchStatus>
{
{ sourceBranch, new GitBranchStatus(sourceBranch, $"origin/{sourceBranch}", true, true, 0, 0, new Commit(Some.Sha(), Some.Name())) },
{ inactiveBranch, new GitBranchStatus(inactiveBranch, $"origin/{inactiveBranch}", true, false, 0, 0, new Commit(Some.Sha(), Some.Name())) }
};

gitClient.GetBranchStatuses(Arg.Any<string[]>()).Returns(branchStatuses);

var stack = new Config.Stack(
"Stack1",
Some.HttpsUri().ToString(),
sourceBranch,
new List<Config.Branch> { new(inactiveBranch, []) });

var executionContext = new CliExecutionContext { WorkingDirectory = "/repo" };
var factory = Substitute.For<IGitClientFactory>();
factory.Create(executionContext.WorkingDirectory).Returns(gitClient);
factory.Create(Arg.Any<string>()).Returns(gitClient);

var actions = new StackActions(factory, executionContext, gitHubClient, logger, displayProvider, conflictResolutionDetector);

// Act
await actions.UpdateStack(stack, UpdateStrategy.Rebase, CancellationToken.None, true);

// Assert
gitClient.DidNotReceive().ChangeBranch(inactiveBranch);
gitClient.DidNotReceive().RebaseFromLocalSourceBranch(Arg.Any<string>());
gitClient.DidNotReceive().RebaseOntoNewParent(Arg.Any<string>(), Arg.Any<string>());
}

[Fact]
public async Task UpdateStack_UsingRebase_WhenBranchHasNoRemoteTrackingBranch_IsUpdated()
{
// Arrange
var sourceBranch = Some.BranchName();
var localOnlyBranch = Some.BranchName();

var logger = XUnitLogger.CreateLogger<StackActions>(testOutputHelper);
var displayProvider = new TestDisplayProvider(testOutputHelper);
var gitClient = Substitute.For<IGitClient>();
var gitHubClient = Substitute.For<IGitHubClient>();
var conflictResolutionDetector = Substitute.For<IConflictResolutionDetector>();

var branchStatuses = new Dictionary<string, GitBranchStatus>
{
{ sourceBranch, new GitBranchStatus(sourceBranch, $"origin/{sourceBranch}", true, true, 0, 0, new Commit(Some.Sha(), Some.Name())) },
{ localOnlyBranch, new GitBranchStatus(localOnlyBranch, null, false, false, 0, 0, new Commit(Some.Sha(), Some.Name())) }
};

gitClient.GetBranchStatuses(Arg.Any<string[]>()).Returns(branchStatuses);

var stack = new Config.Stack(
"Stack1",
Some.HttpsUri().ToString(),
sourceBranch,
new List<Config.Branch> { new(localOnlyBranch, []) });

var executionContext = new CliExecutionContext { WorkingDirectory = "/repo" };
var factory = Substitute.For<IGitClientFactory>();
factory.Create(executionContext.WorkingDirectory).Returns(gitClient);
factory.Create(Arg.Any<string>()).Returns(gitClient);

var actions = new StackActions(factory, executionContext, gitHubClient, logger, displayProvider, conflictResolutionDetector);

// Act
await actions.UpdateStack(stack, UpdateStrategy.Rebase, CancellationToken.None);

// Assert
gitClient.Received(1).ChangeBranch(localOnlyBranch);
gitClient.Received(1).RebaseFromLocalSourceBranch(sourceBranch);
}

[Fact]
public void PullChanges_WhenSomeBranchesHaveChanges_AndOthersDoNot_OnlyPullsChangesForBranchesThatNeedIt()
{
Expand Down Expand Up @@ -812,4 +985,43 @@ public async Task UpdateStack_UsingRebase_WhenBranchIsInWorktree_UsesWorktreeGit
worktreeGitClient.Received(1).RebaseFromLocalSourceBranch(sourceBranch);
gitClient.DidNotReceive().ChangeBranch(branchInWorktree); // Should not change branch since it's in a worktree
}

[Fact]
public async Task UpdateStack_WhenCheckingPullRequests_AndGitHubClientIsNotAvailable_Throws()
{
// Arrange
var sourceBranch = Some.BranchName();

var gitClient = Substitute.For<IGitClient>();
var gitHubClient = new TestGitHubRepositoryBuilder().NotAvailable().Build();
var logger = XUnitLogger.CreateLogger<StackActions>(testOutputHelper);
var displayProvider = new TestDisplayProvider(testOutputHelper);
var gitClientFactory = Substitute.For<IGitClientFactory>();
var conflictResolutionDetector = Substitute.For<IConflictResolutionDetector>();

gitClient.GetCurrentBranch().Returns(sourceBranch);
gitClientFactory.Create(Arg.Any<string>()).Returns(gitClient);

var branchStatuses = new Dictionary<string, GitBranchStatus>
{
{ sourceBranch, new GitBranchStatus(sourceBranch, $"origin/{sourceBranch}", true, true, 0, 0, new Commit(Some.Sha(), Some.Name()), null) }
};
gitClient.GetBranchStatuses(Arg.Any<string[]>()).Returns(branchStatuses);

var stack = new Config.Stack(
"Stack1",
Some.HttpsUri().ToString(),
sourceBranch,
[]
);

var executionContext = new CliExecutionContext();
var stackActions = new StackActions(gitClientFactory, executionContext, gitHubClient, logger, displayProvider, conflictResolutionDetector);

gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient);

// Act + Assert
await stackActions.Invoking(async a => await a.UpdateStack(stack, UpdateStrategy.Rebase, CancellationToken.None, true))
.Should().ThrowAsync<InvalidOperationException>();
}
}
Loading
Loading