diff --git a/README.md b/README.md index e7607aeb..9562d082 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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`). @@ -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` @@ -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` @@ -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 diff --git a/src/Stack.Tests/Commands/Helpers/StackActionsTests.cs b/src/Stack.Tests/Commands/Helpers/StackActionsTests.cs index ba9a5e0c..42c09c1b 100644 --- a/src/Stack.Tests/Commands/Helpers/StackActionsTests.cs +++ b/src/Stack.Tests/Commands/Helpers/StackActionsTests.cs @@ -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(testOutputHelper); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var gitClient = Substitute.For(); + var gitHubClient = new TestGitHubRepositoryBuilder() + .WithPullRequest(inactiveBranch, pr => pr.Merged()) + .Build(); + var conflictResolutionDetector = Substitute.For(); + + var branchStatuses = new Dictionary + { + { 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()).Returns(branchStatuses); + + var stack = new Config.Stack( + "Stack1", + Some.HttpsUri().ToString(), + sourceBranch, + new List { new(inactiveBranch, []) }); + + var executionContext = new CliExecutionContext { WorkingDirectory = "/repo" }; + var factory = Substitute.For(); + factory.Create(executionContext.WorkingDirectory).Returns(gitClient); + factory.Create(Arg.Any()).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()); + } + + [Fact] + public async Task UpdateStack_UsingMerge_WhenBranchHasNoRemoteTrackingBranch_IsUpdated() + { + // Arrange + var sourceBranch = Some.BranchName(); + var localOnlyBranch = Some.BranchName(); + + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var gitClient = Substitute.For(); + var gitHubClient = Substitute.For(); + var conflictResolutionDetector = Substitute.For(); + + var branchStatuses = new Dictionary + { + { 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()).Returns(branchStatuses); + + var stack = new Config.Stack( + "Stack1", + Some.HttpsUri().ToString(), + sourceBranch, + new List { new(localOnlyBranch, []) }); + + var executionContext = new CliExecutionContext { WorkingDirectory = "/repo" }; + var factory = Substitute.For(); + factory.Create(executionContext.WorkingDirectory).Returns(gitClient); + factory.Create(Arg.Any()).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() { @@ -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(testOutputHelper); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var gitClient = Substitute.For(); + var gitHubClient = new TestGitHubRepositoryBuilder() + .WithPullRequest(inactiveBranch, pr => pr.Merged()) + .Build(); + var conflictResolutionDetector = Substitute.For(); + + var branchStatuses = new Dictionary + { + { 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()).Returns(branchStatuses); + + var stack = new Config.Stack( + "Stack1", + Some.HttpsUri().ToString(), + sourceBranch, + new List { new(inactiveBranch, []) }); + + var executionContext = new CliExecutionContext { WorkingDirectory = "/repo" }; + var factory = Substitute.For(); + factory.Create(executionContext.WorkingDirectory).Returns(gitClient); + factory.Create(Arg.Any()).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()); + gitClient.DidNotReceive().RebaseOntoNewParent(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateStack_UsingRebase_WhenBranchHasNoRemoteTrackingBranch_IsUpdated() + { + // Arrange + var sourceBranch = Some.BranchName(); + var localOnlyBranch = Some.BranchName(); + + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var gitClient = Substitute.For(); + var gitHubClient = Substitute.For(); + var conflictResolutionDetector = Substitute.For(); + + var branchStatuses = new Dictionary + { + { 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()).Returns(branchStatuses); + + var stack = new Config.Stack( + "Stack1", + Some.HttpsUri().ToString(), + sourceBranch, + new List { new(localOnlyBranch, []) }); + + var executionContext = new CliExecutionContext { WorkingDirectory = "/repo" }; + var factory = Substitute.For(); + factory.Create(executionContext.WorkingDirectory).Returns(gitClient); + factory.Create(Arg.Any()).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() { @@ -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(); + var gitHubClient = new TestGitHubRepositoryBuilder().NotAvailable().Build(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var gitClientFactory = Substitute.For(); + var conflictResolutionDetector = Substitute.For(); + + gitClient.GetCurrentBranch().Returns(sourceBranch); + gitClientFactory.Create(Arg.Any()).Returns(gitClient); + + var branchStatuses = new Dictionary + { + { sourceBranch, new GitBranchStatus(sourceBranch, $"origin/{sourceBranch}", true, true, 0, 0, new Commit(Some.Sha(), Some.Name()), null) } + }; + gitClient.GetBranchStatuses(Arg.Any()).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(); + } } \ No newline at end of file diff --git a/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs b/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs index 27a3ab45..f307bf2d 100644 --- a/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs @@ -62,7 +62,7 @@ public async Task WhenNameIsProvided_DoesNotAskForName_SyncsCorrectStack() inputProvider.Select(Questions.SelectUpdateStrategy, Arg.Any(), Arg.Any(), Arg.Any>()).Returns(Task.FromResult(UpdateStrategy.Merge)); // Act - await handler.Handle(new SyncStackCommandInputs("Stack1", 5, false, false, false, false), CancellationToken.None); + await handler.Handle(new SyncStackCommandInputs("Stack1", 5, false, false, false, false, false), CancellationToken.None); // Assert stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); @@ -111,7 +111,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, 5, false, false, false, false), CancellationToken.None)) + await handler.Invoking(async h => await h.Handle(new SyncStackCommandInputs(invalidStackName, 5, false, false, false, false, false), CancellationToken.None)) .Should().ThrowAsync() .WithMessage($"Stack '{invalidStackName}' not found."); } @@ -166,7 +166,7 @@ public async Task WhenOnASpecificBranchInTheStack_TheSameBranchIsSetAsCurrentAft inputProvider.Select(Questions.SelectUpdateStrategy, Arg.Any(), Arg.Any(), Arg.Any>()).Returns(Task.FromResult(UpdateStrategy.Merge)); // Act - await handler.Handle(new SyncStackCommandInputs(null, 5, false, false, false, false), CancellationToken.None); + await handler.Handle(new SyncStackCommandInputs(null, 5, false, false, false, false, false), CancellationToken.None); // Assert stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); @@ -219,7 +219,7 @@ public async Task WhenOnlyASingleStackExists_DoesNotAskForStackName_SyncsStack() inputProvider.Select(Questions.SelectUpdateStrategy, Arg.Any(), Arg.Any(), Arg.Any>()).Returns(Task.FromResult(UpdateStrategy.Merge)); // Act - await handler.Handle(new SyncStackCommandInputs(null, 5, false, false, false, false), CancellationToken.None); + await handler.Handle(new SyncStackCommandInputs(null, 5, false, false, false, false, false), CancellationToken.None); // Assert stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); @@ -276,7 +276,7 @@ public async Task WhenRebaseIsProvided_SyncsStackUsingRebase() inputProvider.Confirm(Questions.ConfirmSyncStack, Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); // Act - await handler.Handle(new SyncStackCommandInputs(null, 5, true, false, false, false), CancellationToken.None); + await handler.Handle(new SyncStackCommandInputs(null, 5, true, false, false, false, false), CancellationToken.None); // Assert stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); @@ -332,7 +332,7 @@ public async Task WhenMergeIsProvided_SyncsStackUsingMerge() inputProvider.Confirm(Questions.ConfirmSyncStack, Arg.Any()).Returns(true); // Act - await handler.Handle(new SyncStackCommandInputs(null, 5, false, true, false, false), CancellationToken.None); + await handler.Handle(new SyncStackCommandInputs(null, 5, false, true, false, false, false), CancellationToken.None); // Assert stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); @@ -388,7 +388,7 @@ public async Task WhenNotSpecifyingRebaseOrMerge_AndUpdateSettingIsRebase_SyncsS inputProvider.Confirm(Questions.ConfirmSyncStack, Arg.Any()).Returns(true); // Act - await handler.Handle(new SyncStackCommandInputs(null, 5, null, null, false, false), CancellationToken.None); + await handler.Handle(new SyncStackCommandInputs(null, 5, null, null, false, false, false), CancellationToken.None); // Assert stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); @@ -444,7 +444,7 @@ public async Task WhenNotSpecifyingRebaseOrMerge_AndUpdateSettingIsMerge_SyncsSt inputProvider.Confirm(Questions.ConfirmSyncStack, Arg.Any()).Returns(true); // Act - await handler.Handle(new SyncStackCommandInputs(null, 5, null, null, false, false), CancellationToken.None); + await handler.Handle(new SyncStackCommandInputs(null, 5, null, null, false, false, false), CancellationToken.None); // Assert stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); @@ -500,7 +500,7 @@ public async Task WhenGitConfigValueIsSetToMerge_ButRebaseIsSpecified_SyncsStack inputProvider.Confirm(Questions.ConfirmSyncStack, Arg.Any()).Returns(true); // Act - await handler.Handle(new SyncStackCommandInputs(null, 5, true, null, false, false), CancellationToken.None); + await handler.Handle(new SyncStackCommandInputs(null, 5, true, null, false, false, false), CancellationToken.None); // Assert stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); @@ -556,7 +556,7 @@ public async Task WhenGitConfigValueIsSetToRebase_ButMergeIsSpecified_SyncsStack inputProvider.Confirm(Questions.ConfirmSyncStack, Arg.Any()).Returns(true); // Act - await handler.Handle(new SyncStackCommandInputs(null, 5, null, true, false, false), CancellationToken.None); + await handler.Handle(new SyncStackCommandInputs(null, 5, null, true, false, false, false), CancellationToken.None); // Assert stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); @@ -613,7 +613,7 @@ public async Task WhenNotSpecifyingRebaseOrMerge_AndNoUpdateSettingExists_AndMer inputProvider.Confirm(Questions.ConfirmSyncStack, Arg.Any()).Returns(true); // Act - await handler.Handle(new SyncStackCommandInputs(null, 5, null, null, false, false), CancellationToken.None); + await handler.Handle(new SyncStackCommandInputs(null, 5, null, null, false, false, false), CancellationToken.None); // Assert stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); @@ -670,7 +670,7 @@ public async Task WhenNotSpecifyingRebaseOrMerge_AndNoUpdateSettingsExists_AndRe inputProvider.Confirm(Questions.ConfirmSyncStack, Arg.Any()).Returns(true); // Act - await handler.Handle(new SyncStackCommandInputs(null, 5, null, null, false, false), CancellationToken.None); + await handler.Handle(new SyncStackCommandInputs(null, 5, null, null, false, false, false), CancellationToken.None); // Assert stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); @@ -699,7 +699,7 @@ public async Task WhenBothRebaseAndMergeAreSpecified_AnErrorIsThrown() // Act and assert await handler - .Invoking(h => h.Handle(new SyncStackCommandInputs(null, 5, true, true, false, false), CancellationToken.None)) + .Invoking(h => h.Handle(new SyncStackCommandInputs(null, 5, true, true, false, false, false), CancellationToken.None)) .Should().ThrowAsync() .WithMessage("Cannot specify both rebase and merge."); } @@ -751,7 +751,7 @@ public async Task WhenConfirmOptionIsProvided_DoesNotAskForConfirmation() inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); // Act - await handler.Handle(new SyncStackCommandInputs(null, 5, false, false, true, false), CancellationToken.None); + await handler.Handle(new SyncStackCommandInputs(null, 5, false, false, true, false, false), CancellationToken.None); // Assert stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); @@ -809,7 +809,7 @@ public async Task WhenNoPushOptionIsProvided_DoesNotPushChangesToRemote() inputProvider.Confirm(Questions.ConfirmSyncStack, Arg.Any()).Returns(true); // Act - await handler.Handle(new SyncStackCommandInputs(null, 5, false, false, false, true), CancellationToken.None); + await handler.Handle(new SyncStackCommandInputs(null, 5, false, false, false, true, false), CancellationToken.None); // Assert stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); @@ -817,4 +817,110 @@ public async Task WhenNoPushOptionIsProvided_DoesNotPushChangesToRemote() stackActions.DidNotReceive().PushChanges(Arg.Any(), Arg.Any(), Arg.Any()); gitClient.Received().ChangeBranch(branch1); } + + [Fact] + public async Task WhenCheckPullRequestsIsTrue_UpdatesStackWithCheckPullRequestsEnabled() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(stackBranch => stackBranch.WithName(branch1)) + .WithBranch(stackBranch => stackBranch.WithName(branch2))) + .Build(); + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + var gitHubClient = Substitute.For(); + var stackActions = Substitute.For(); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var gitClientFactory = Substitute.For(); + var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; + var outputProvider = Substitute.For(); + var handler = new SyncStackCommandHandler(inputProvider, logger, outputProvider, displayProvider, gitClientFactory, executionContext, gitHubClient, stackConfig, stackActions); + + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); + + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(branch1); + gitClient.GetBranchStatuses(Arg.Any()).Returns(new Dictionary + { + { sourceBranch, new GitBranchStatus(sourceBranch, $"origin/{sourceBranch}", true, false, 0, 0, new Commit("abc1234", "Test commit message")) }, + { branch1, new GitBranchStatus(branch1, $"origin/{branch1}", true, true, 0, 0, new Commit("abc1234", "Test commit message")) }, + { branch2, new GitBranchStatus(branch2, $"origin/{branch2}", true, false, 0, 0, new Commit("def5678", "Test commit message")) } + }); + gitClient.GetConfigValue("stack.update.strategy").Returns((string?)null); + + inputProvider.Confirm(Questions.ConfirmSyncStack, Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + inputProvider.Select(Questions.SelectUpdateStrategy, Arg.Any(), Arg.Any(), Arg.Any>()).Returns(Task.FromResult(UpdateStrategy.Merge)); + + // Act + await handler.Handle(new SyncStackCommandInputs("Stack1", 5, false, false, false, false, true), CancellationToken.None); + + // Assert + stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); + await stackActions.Received().UpdateStack(Arg.Is(s => s.Name == "Stack1"), UpdateStrategy.Merge, Arg.Any(), true); + stackActions.Received().PushChanges(Arg.Is(s => s.Name == "Stack1"), 5, false); + gitClient.Received().ChangeBranch(branch1); + } + + [Fact] + public async Task WhenCheckPullRequestsIsFalse_UpdatesStackWithCheckPullRequestsDisabled() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(stackBranch => stackBranch.WithName(branch1)) + .WithBranch(stackBranch => stackBranch.WithName(branch2))) + .Build(); + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var gitClient = Substitute.For(); + var gitHubClient = Substitute.For(); + var stackActions = Substitute.For(); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var gitClientFactory = Substitute.For(); + var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; + var outputProvider = Substitute.For(); + var handler = new SyncStackCommandHandler(inputProvider, logger, outputProvider, displayProvider, gitClientFactory, executionContext, gitHubClient, stackConfig, stackActions); + + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); + + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(branch1); + gitClient.GetBranchStatuses(Arg.Any()).Returns(new Dictionary + { + { sourceBranch, new GitBranchStatus(sourceBranch, $"origin/{sourceBranch}", true, false, 0, 0, new Commit("abc1234", "Test commit message")) }, + { branch1, new GitBranchStatus(branch1, $"origin/{branch1}", true, true, 0, 0, new Commit("abc1234", "Test commit message")) }, + { branch2, new GitBranchStatus(branch2, $"origin/{branch2}", true, false, 0, 0, new Commit("def5678", "Test commit message")) } + }); + gitClient.GetConfigValue("stack.update.strategy").Returns((string?)null); + + inputProvider.Confirm(Questions.ConfirmSyncStack, Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + inputProvider.Select(Questions.SelectUpdateStrategy, Arg.Any(), Arg.Any(), Arg.Any>()).Returns(Task.FromResult(UpdateStrategy.Merge)); + + // Act + await handler.Handle(new SyncStackCommandInputs("Stack1", 5, false, false, false, false, false), CancellationToken.None); + + // Assert + stackActions.Received().PullChanges(Arg.Is(s => s.Name == "Stack1")); + await stackActions.Received().UpdateStack(Arg.Is(s => s.Name == "Stack1"), UpdateStrategy.Merge, Arg.Any(), false); + stackActions.Received().PushChanges(Arg.Is(s => s.Name == "Stack1"), 5, false); + gitClient.Received().ChangeBranch(branch1); + } } diff --git a/src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs b/src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs index d76c482a..79d8638e 100644 --- a/src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs @@ -49,7 +49,7 @@ public async Task WhenNameIsProvided_DoesNotAskForName_UpdatesCorrectStack() gitClient.GetCurrentBranch().Returns(branch1); // Act - await handler.Handle(new UpdateStackCommandInputs("Stack1", false, true), CancellationToken.None); + await handler.Handle(new UpdateStackCommandInputs("Stack1", false, true, false), CancellationToken.None); // Assert await inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any(), Arg.Any()); @@ -92,7 +92,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, false, false), CancellationToken.None)) + await handler.Invoking(async h => await h.Handle(new UpdateStackCommandInputs(invalidStackName, false, false, false), CancellationToken.None)) .Should().ThrowAsync() .WithMessage($"Stack '{invalidStackName}' not found."); } @@ -135,7 +135,7 @@ public async Task WhenOnASpecificBranchInTheStack_TheSameBranchIsSetAsCurrentAft inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); // Act - await handler.Handle(new UpdateStackCommandInputs(null, false, true), CancellationToken.None); + await handler.Handle(new UpdateStackCommandInputs(null, false, true, false), CancellationToken.None); // Assert current branch preserved gitClient.Received().ChangeBranch(branch1); @@ -172,7 +172,7 @@ public async Task WhenOnlyASingleStackExists_DoesNotAskForStackName_UpdatesStack gitClient.GetCurrentBranch().Returns(branch1); // Act - await handler.Handle(new UpdateStackCommandInputs(null, false, true), CancellationToken.None); + await handler.Handle(new UpdateStackCommandInputs(null, false, true, false), CancellationToken.None); // Assert await inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any(), Arg.Any()); @@ -215,7 +215,7 @@ public async Task WhenRebaseIsSpecified_StackIsUpdatedUsingRebase() gitClient.GetCurrentBranch().Returns(branch1); // Act - await handler.Handle(new UpdateStackCommandInputs(null, true, false), CancellationToken.None); + await handler.Handle(new UpdateStackCommandInputs(null, true, false, false), CancellationToken.None); // Assert await stackActions.Received().UpdateStack(Arg.Is(s => s.Name == "Stack1"), UpdateStrategy.Rebase, Arg.Any()); @@ -258,7 +258,7 @@ public async Task WhenGitConfigValueIsSetToRebase_StackIsUpdatedUsingRebase() gitClient.GetConfigValue("stack.update.strategy").Returns(UpdateStrategy.Rebase.ToString().ToLower()); // Act - await handler.Handle(new UpdateStackCommandInputs(null, null, null), CancellationToken.None); + await handler.Handle(new UpdateStackCommandInputs(null, null, null, false), CancellationToken.None); // Assert await stackActions.Received().UpdateStack(Arg.Is(s => s.Name == "Stack1"), UpdateStrategy.Rebase, Arg.Any()); @@ -300,7 +300,7 @@ public async Task WhenGitConfigValueIsSetToRebase_ButMergeIsSpecified_StackIsUpd gitClient.GetConfigValue("stack.update.strategy").Returns(UpdateStrategy.Rebase.ToString().ToLower()); // Act - await handler.Handle(new UpdateStackCommandInputs(null, null, true), CancellationToken.None); + await handler.Handle(new UpdateStackCommandInputs(null, null, true, false), CancellationToken.None); // Assert await stackActions.Received().UpdateStack(Arg.Is(s => s.Name == "Stack1"), UpdateStrategy.Merge, Arg.Any()); @@ -343,7 +343,7 @@ public async Task WhenGitConfigValueIsSetToMerge_StackIsUpdatedUsingMerge() gitClient.GetConfigValue("stack.update.strategy").Returns(UpdateStrategy.Merge.ToString().ToLower()); // Act - await handler.Handle(new UpdateStackCommandInputs(null, null, null), CancellationToken.None); + await handler.Handle(new UpdateStackCommandInputs(null, null, null, false), CancellationToken.None); // Assert await stackActions.Received().UpdateStack(Arg.Is(s => s.Name == "Stack1"), UpdateStrategy.Merge, Arg.Any()); @@ -386,7 +386,7 @@ public async Task WhenGitConfigValueIsSetToMerge_ButRebaseIsSpecified_StackIsUpd gitClient.GetConfigValue("stack.update.strategy").Returns(UpdateStrategy.Merge.ToString().ToLower()); // Act (rebase specified overrides config) - await handler.Handle(new UpdateStackCommandInputs(null, true, null), CancellationToken.None); + await handler.Handle(new UpdateStackCommandInputs(null, true, null, false), CancellationToken.None); // Assert await stackActions.Received().UpdateStack(Arg.Is(s => s.Name == "Stack1"), UpdateStrategy.Rebase, Arg.Any()); @@ -430,7 +430,7 @@ public async Task WhenGitConfigValueDoesNotExist_AndRebaseIsSelected_StackIsUpda gitClient.GetConfigValue("stack.update.strategy").Returns((string?)null); // Act - await handler.Handle(new UpdateStackCommandInputs(null, null, null), CancellationToken.None); + await handler.Handle(new UpdateStackCommandInputs(null, null, null, false), CancellationToken.None); // Assert await stackActions.Received().UpdateStack(Arg.Is(s => s.Name == "Stack1"), UpdateStrategy.Rebase, Arg.Any()); @@ -474,12 +474,90 @@ public async Task WhenGitConfigValueDoesNotExist_AndMergeIsSelected_StackIsUpdat gitClient.GetConfigValue("stack.update.strategy").Returns((string?)null); // Act - await handler.Handle(new UpdateStackCommandInputs(null, null, null), CancellationToken.None); + await handler.Handle(new UpdateStackCommandInputs(null, null, null, false), CancellationToken.None); // Assert await stackActions.Received().UpdateStack(Arg.Is(s => s.Name == "Stack1"), UpdateStrategy.Merge, Arg.Any()); } + [Fact] + public async Task WhenCheckPullRequestsIsTrue_StackIsUpdatedWithCheckPullRequestsEnabled() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(b1 => b1.WithName(branch1).WithChildBranch(b2 => b2.WithName(branch2)))) + .Build(); + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var gitClient = Substitute.For(); + var stackActions = Substitute.For(); + var gitClientFactory = Substitute.For(); + var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; + var handler = new UpdateStackCommandHandler(inputProvider, logger, displayProvider, gitClientFactory, executionContext, stackConfig, stackActions); + + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(branch1); + gitClient.GetConfigValue("stack.update.strategy").Returns((string?)null); + + // Act + await handler.Handle(new UpdateStackCommandInputs(null, null, null, true), CancellationToken.None); + + // Assert + await stackActions.Received().UpdateStack(Arg.Is(s => s.Name == "Stack1"), UpdateStrategy.Merge, Arg.Any(), true); + } + + [Fact] + public async Task WhenCheckPullRequestsIsFalse_StackIsUpdatedWithCheckPullRequestsDisabled() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + var remoteUri = Some.HttpsUri().ToString(); + + var stackConfig = new TestStackConfigBuilder() + .WithStack(stack => stack + .WithName("Stack1") + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(b1 => b1.WithName(branch1).WithChildBranch(b2 => b2.WithName(branch2)))) + .Build(); + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var gitClient = Substitute.For(); + var stackActions = Substitute.For(); + var gitClientFactory = Substitute.For(); + var executionContext = new CliExecutionContext { WorkingDirectory = "/some/path" }; + var handler = new UpdateStackCommandHandler(inputProvider, logger, displayProvider, gitClientFactory, executionContext, stackConfig, stackActions); + + gitClientFactory.Create(executionContext.WorkingDirectory).Returns(gitClient); + + inputProvider.Select(Questions.SelectStack, Arg.Any(), Arg.Any()).Returns("Stack1"); + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(branch1); + gitClient.GetConfigValue("stack.update.strategy").Returns((string?)null); + + // Act + await handler.Handle(new UpdateStackCommandInputs(null, null, null, false), CancellationToken.None); + + // Assert + await stackActions.Received().UpdateStack(Arg.Is(s => s.Name == "Stack1"), UpdateStrategy.Merge, Arg.Any(), false); + } + [Fact] public async Task WhenBothRebaseAndMergeAreSpecified_AnErrorIsThrown() { @@ -510,7 +588,7 @@ public async Task WhenBothRebaseAndMergeAreSpecified_AnErrorIsThrown() // Act and assert await handler - .Invoking(h => h.Handle(new UpdateStackCommandInputs(null, true, true), CancellationToken.None)) + .Invoking(h => h.Handle(new UpdateStackCommandInputs(null, true, true, false), CancellationToken.None)) .Should().ThrowAsync() .WithMessage("Cannot specify both rebase and merge."); } diff --git a/src/Stack.Tests/Helpers/TestGitHubRepositoryBuilder.cs b/src/Stack.Tests/Helpers/TestGitHubRepositoryBuilder.cs index d6728cfa..3935066b 100644 --- a/src/Stack.Tests/Helpers/TestGitHubRepositoryBuilder.cs +++ b/src/Stack.Tests/Helpers/TestGitHubRepositoryBuilder.cs @@ -5,6 +5,7 @@ namespace Stack.Tests.Helpers; public class TestGitHubRepositoryBuilder { readonly Dictionary pullRequests = new(); + bool available = true; public TestGitHubRepositoryBuilder WithPullRequest(string branch, Action? pullRequestBuilder = null) { @@ -15,9 +16,15 @@ public TestGitHubRepositoryBuilder WithPullRequest(string branch, Action PullRequests) : IGitHubClient +public class TestGitHubRepository(Dictionary PullRequests, bool available) : IGitHubClient { public Dictionary PullRequests { get; } = PullRequests; @@ -98,4 +105,12 @@ public GitHubPullRequest EditPullRequest(GitHubPullRequest pullRequest, string b public void OpenPullRequest(GitHubPullRequest pullRequest) { } + + public void ThrowIfNotAvailable() + { + if (!available) + { + throw new InvalidOperationException("GitHub client not available."); + } + } } \ No newline at end of file diff --git a/src/Stack/Commands/Helpers/StackActions.cs b/src/Stack/Commands/Helpers/StackActions.cs index b3a9994f..1f32939d 100644 --- a/src/Stack/Commands/Helpers/StackActions.cs +++ b/src/Stack/Commands/Helpers/StackActions.cs @@ -11,7 +11,7 @@ public interface IStackActions { void PullChanges(Config.Stack stack); void PushChanges(Config.Stack stack, int maxBatchSize, bool forceWithLease); - Task UpdateStack(Config.Stack stack, UpdateStrategy strategy, CancellationToken cancellationToken); + Task UpdateStack(Config.Stack stack, UpdateStrategy strategy, CancellationToken cancellationToken, bool checkPullRequests = false); } @@ -140,62 +140,75 @@ public void PushChanges( } } - public async Task UpdateStack(Config.Stack stack, UpdateStrategy strategy, CancellationToken cancellationToken) + public async Task UpdateStack(Config.Stack stack, UpdateStrategy strategy, CancellationToken cancellationToken, bool checkPullRequests = false) { var gitClient = GetDefaultGitClient(); - var currentBranch = gitClient.GetCurrentBranch(); - - var status = StackHelpers.GetStackStatus( - stack, - currentBranch, - logger, - gitClient, - gitHubClient, - true); - // Get branch statuses to check for worktrees List allBranchesInStack = [stack.SourceBranch, .. stack.AllBranchNames]; - var branchStatuses = gitClient.GetBranchStatuses([.. allBranchesInStack]); + + var branchStatuses = await displayProvider.DisplayStatus("Checking status of branches...", async ct => + { + await Task.CompletedTask; + return gitClient.GetBranchStatuses([.. allBranchesInStack]); + }, cancellationToken); + + if (!branchStatuses.ContainsKey(stack.SourceBranch)) + { + logger.SourceBranchDoesNotExist(stack.SourceBranch); + return; + } + + var pullRequests = new Dictionary(); + if (checkPullRequests) + { + gitHubClient.ThrowIfNotAvailable(); + + await displayProvider.DisplayStatus("Checking status of pull requests...", async ct => + { + await Task.CompletedTask; + pullRequests = gitHubClient.GetPullRequests(stack.AllBranchNames); + }, cancellationToken); + } if (strategy == UpdateStrategy.Rebase) { - await UpdateStackUsingRebase(stack, status, branchStatuses, cancellationToken); + await UpdateStackUsingRebase(stack, branchStatuses, pullRequests, cancellationToken); } else { - await UpdateStackUsingMerge(stack, status, branchStatuses, cancellationToken); + await UpdateStackUsingMerge(stack, branchStatuses, pullRequests, cancellationToken); } } private async Task UpdateStackUsingMerge( Config.Stack stack, - StackStatus status, Dictionary branchStatuses, + Dictionary pullRequests, CancellationToken cancellationToken) { - logger.UpdatingStackUsingMerge(status.Name); - - var allBranchLines = status.GetAllBranchLines(); + logger.UpdatingStackUsingMerge(stack.Name); - foreach (var branchLine in allBranchLines) + foreach (var branchLine in stack.GetAllBranchLines()) { - await UpdateBranchLineUsingMerge(branchLine, status.SourceBranch, branchStatuses, cancellationToken); + await UpdateBranchLineUsingMerge(branchLine, stack.SourceBranch, branchStatuses, pullRequests, cancellationToken); } } private async Task UpdateBranchLineUsingMerge( - List branchLine, - BranchDetailBase parentBranch, + List branchLine, + string parentBranchName, Dictionary branchStatuses, + Dictionary pullRequests, CancellationToken cancellationToken) { - var currentParentBranch = parentBranch; + var currentParentBranch = parentBranchName; foreach (var branch in branchLine) { - if (branch.IsActive) + var branchState = GetBranchState(branch.Name, branchStatuses, pullRequests); + if (branchState.IsActive) { - await MergeFromSourceBranch(branch.Name, currentParentBranch.Name, branchStatuses, cancellationToken); - currentParentBranch = branch; + await MergeFromSourceBranch(branch.Name, currentParentBranch, branchStatuses, cancellationToken); + currentParentBranch = branch.Name; } else { @@ -241,21 +254,25 @@ private async Task MergeFromSourceBranch(string branch, string sourceBranchName, private async Task UpdateStackUsingRebase( Config.Stack stack, - StackStatus status, Dictionary branchStatuses, + Dictionary pullRequests, CancellationToken cancellationToken) { - logger.UpdatingStackUsingRebase(status.Name); + logger.UpdatingStackUsingRebase(stack.Name); - var allBranchLines = status.GetAllBranchLines(); - - foreach (var branchLine in allBranchLines) + foreach (var branchLine in stack.GetAllBranchLines()) { - await UpdateBranchLineUsingRebase(status, branchLine, branchStatuses, cancellationToken); + await UpdateBranchLineUsingRebase(stack.Name, stack.SourceBranch, branchLine, branchStatuses, pullRequests, cancellationToken); } } - private async Task UpdateBranchLineUsingRebase(StackStatus status, List branchLine, Dictionary branchStatuses, CancellationToken cancellationToken) + private async Task UpdateBranchLineUsingRebase( + string stackName, + string sourceBranchName, + List branchLine, + Dictionary branchStatuses, + Dictionary pullRequests, + CancellationToken cancellationToken) { // // When rebasing the stack, we need to be able to pick up changes at each level of the stack. @@ -307,19 +324,21 @@ private async Task UpdateBranchLineUsingRebase(StackStatus status, List ", branchLine.Select(b => b.Name))); - List allBranchesInLine = [status.SourceBranch, .. branchLine]; + logger.RebasingStackForBranchLine(stackName, sourceBranchName, string.Join(" -> ", branchLine.Select(b => b.Name))); + List allBranchesInLine = [GetBranchState(sourceBranchName, branchStatuses, pullRequests), .. branchLine.Select(b => GetBranchState(b.Name, branchStatuses, pullRequests))]; foreach (var branch in branchLine) { - if (!branch.IsActive) + var branchState = allBranchesInLine.First(b => b.Name == branch.Name); + + if (!branchState.IsActive) { logger.TraceSkippingInactiveBranch(branch.Name); continue; } string? lowestInactiveBranchToReParentFrom = null; - List branchesToRebaseOnto = []; + var branchesToRebaseOnto = new List(); // Find all active branches above this one to // rebase onto. Also work out if there is any that @@ -344,8 +363,10 @@ private async Task UpdateBranchLineUsingRebase(StackStatus status, List b.Name == lowestInactiveBranchToReParentFrom) : null; - var couldRebaseOntoParent = lowestInactiveBranchToReParentFromDetail is not null && lowestInactiveBranchToReParentFromDetail.Exists; + BranchState? lowestInactiveBranchToReParentFromDetail = lowestInactiveBranchToReParentFrom is not null + ? allBranchesInLine.First(b => b.Name == lowestInactiveBranchToReParentFrom) + : null; + var couldRebaseOntoParent = lowestInactiveBranchToReParentFromDetail is { Exists: true }; var parentCommitToRebaseFrom = couldRebaseOntoParent ? GetCommitShaToReParentFrom(branch.Name, lowestInactiveBranchToReParentFrom!, branchToRebaseOnto.Name) : null; if (parentCommitToRebaseFrom is not null) @@ -360,6 +381,11 @@ private async Task UpdateBranchLineUsingRebase(StackStatus status, List branchStatuses, Dictionary? pullRequests) + { + return new BranchState(branchName, branchStatuses.GetValueOrDefault(branchName), pullRequests?.GetValueOrDefault(branchName)); + } + private string? GetCommitShaToReParentFrom(string branchToRebase, string lowestInactiveBranchToReParentFrom, string branchToRebaseOnto) { var gitClient = GetDefaultGitClient(); @@ -474,6 +500,36 @@ private async Task RebaseOntoNewParent( } }, cancellationToken); } + private readonly record struct BranchState(string Name, GitBranchStatus? BranchStatus, GitHubPullRequest? PullRequest) + { + public bool Exists => BranchStatus is not null; + public bool RemoteTrackingBranchExists => BranchStatus?.RemoteBranchExists ?? false; + public bool IsActive + { + get + { + if (BranchStatus is null) + { + return false; + } + + if (BranchStatus.RemoteTrackingBranchName is null) + { + // Branch has never been pushed to remote, consider it active + return true; + } + + if (!RemoteTrackingBranchExists) + { + // Remote tracking branch doesn't exist, consider it inactive + return false; + } + + // If there's no associated pull request, or if the pull request is not merged, consider it active + return PullRequest is null || PullRequest.State != GitHubPullRequestStates.Merged; + } + } + } } } @@ -500,6 +556,9 @@ internal static partial class LoggerExtensionMethods [LoggerMessage(Level = LogLevel.Trace, Message = "Branch {Branch} no longer exists on the remote repository or the associated pull request is no longer open. Skipping...")] public static partial void TraceSkippingInactiveBranch(this ILogger logger, string branch); + [LoggerMessage(Level = LogLevel.Warning, Message = "Source branch \"{SourceBranch}\" does not exist locally. Skipping update.")] + public static partial void SourceBranchDoesNotExist(this ILogger logger, string sourceBranch); + [LoggerMessage(Level = LogLevel.Debug, Message = "Merging {SourceBranch} into {Branch}")] public static partial void MergingBranch(this ILogger logger, string sourceBranch, string branch); diff --git a/src/Stack/Commands/Helpers/StackHelpers.cs b/src/Stack/Commands/Helpers/StackHelpers.cs index 7c567cab..4b6413e8 100644 --- a/src/Stack/Commands/Helpers/StackHelpers.cs +++ b/src/Stack/Commands/Helpers/StackHelpers.cs @@ -107,8 +107,13 @@ public static List GetStackStatus( ILogger logger, IGitClient gitClient, IGitHubClient gitHubClient, - bool includePullRequestStatus = true) + bool includePullRequestStatus) { + if (includePullRequestStatus) + { + gitHubClient.ThrowIfNotAvailable(); + } + var stacksToReturnStatusFor = new List(); var stacksOrderedByCurrentBranch = stacks @@ -219,7 +224,7 @@ public static StackStatus GetStackStatus( ILogger logger, IGitClient gitClient, IGitHubClient gitHubClient, - bool includePullRequestStatus = true) + bool includePullRequestStatus) { var statuses = GetStackStatus( [stack], @@ -521,7 +526,7 @@ public static async Task GetUpdateStrategy( public static string[] GetBranchesNeedingCleanup(Config.Stack stack, ILogger logger, IGitClient gitClient, IGitHubClient gitHubClient) { var currentBranch = gitClient.GetCurrentBranch(); - var stackStatus = GetStackStatus(stack, currentBranch, logger, gitClient, gitHubClient, true); + var stackStatus = GetStackStatus(stack, currentBranch, logger, gitClient, gitHubClient, false); return [.. stackStatus.GetAllBranches().Where(b => b.CouldBeCleanedUp).Select(b => b.Name)]; } diff --git a/src/Stack/Commands/PullRequests/CreatePullRequestsCommand.cs b/src/Stack/Commands/PullRequests/CreatePullRequestsCommand.cs index 760a2f5a..bb8c2019 100644 --- a/src/Stack/Commands/PullRequests/CreatePullRequestsCommand.cs +++ b/src/Stack/Commands/PullRequests/CreatePullRequestsCommand.cs @@ -82,7 +82,8 @@ public override async Task Handle(CreatePullRequestsCommandInputs inputs, Cancel currentBranch, logger, gitClient, - gitHubClient); + gitHubClient, + true); var pullRequestCreateActions = new List(); diff --git a/src/Stack/Commands/Remote/SyncStackCommand.cs b/src/Stack/Commands/Remote/SyncStackCommand.cs index aff68652..b803639d 100644 --- a/src/Stack/Commands/Remote/SyncStackCommand.cs +++ b/src/Stack/Commands/Remote/SyncStackCommand.cs @@ -32,6 +32,7 @@ public SyncStackCommand( Add(CommonOptions.Rebase); Add(CommonOptions.Merge); Add(CommonOptions.Confirm); + Add(CommonOptions.CheckPullRequests); Add(NoPush); } @@ -44,7 +45,8 @@ await handler.Handle( parseResult.GetValue(CommonOptions.Rebase), parseResult.GetValue(CommonOptions.Merge), parseResult.GetValue(CommonOptions.Confirm), - parseResult.GetValue(NoPush)), + parseResult.GetValue(NoPush), + parseResult.GetValue(CommonOptions.CheckPullRequests)), cancellationToken); } } @@ -55,9 +57,10 @@ public record SyncStackCommandInputs( bool? Rebase, bool? Merge, bool Confirm, - bool NoPush) + bool NoPush, + bool CheckPullRequests) { - public static SyncStackCommandInputs Empty => new(null, 5, null, null, false, false); + public static SyncStackCommandInputs Empty => new(null, 5, null, null, false, false, false); } public class SyncStackCommandHandler( @@ -115,7 +118,7 @@ await displayProvider.DisplayStatusWithSuccess("Fetching changes from remote rep logger, gitClient, gitHubClient, - true); + inputs.CheckPullRequests); }, cancellationToken); await StackHelpers.OutputStackStatus(status, outputProvider, cancellationToken); @@ -138,7 +141,7 @@ await displayProvider.DisplayStatusWithSuccess("Pulling changes from remote repo await displayProvider.DisplayStatus("Updating stack...", async (ct) => { - await stackActions.UpdateStack(stack, updateStrategy, ct); + await stackActions.UpdateStack(stack, updateStrategy, ct, inputs.CheckPullRequests); }, cancellationToken); var forceWithLease = updateStrategy == UpdateStrategy.Rebase; diff --git a/src/Stack/Commands/Stack/StackStatusCommand.cs b/src/Stack/Commands/Stack/StackStatusCommand.cs index e483715f..e8c957c9 100644 --- a/src/Stack/Commands/Stack/StackStatusCommand.cs +++ b/src/Stack/Commands/Stack/StackStatusCommand.cs @@ -88,9 +88,9 @@ public class StackStatusCommand : CommandWithOutput Description = "Show status of all stacks." }; - static readonly Option Full = new("--full") + static readonly Option CheckPullRequests = new("--check-pull-requests") { - Description = "Show full status including pull requests." + Description = "Include the status of pull requests in output." }; private readonly StackStatusCommandHandler handler; @@ -106,7 +106,7 @@ public StackStatusCommand( this.handler = handler; Add(CommonOptions.Stack); Add(All); - Add(Full); + Add(CheckPullRequests); } protected override async Task ExecuteAndReturnResponse(ParseResult parseResult, CancellationToken cancellationToken) @@ -115,7 +115,7 @@ protected override async Task ExecuteAndReturnRespon new StackStatusCommandInputs( parseResult.GetValue(CommonOptions.Stack), parseResult.GetValue(All), - parseResult.GetValue(Full)), + parseResult.GetValue(CheckPullRequests)), cancellationToken); } @@ -193,7 +193,7 @@ private static StackStatusCommandJsonOutputBranchDetail MapBranchDetail(BranchDe } } -public record StackStatusCommandInputs(string? Stack, bool All, bool Full); +public record StackStatusCommandInputs(string? Stack, bool All, bool CheckPullRequests); public record StackStatusCommandResponse(List Stacks); public class StackStatusCommandHandler( @@ -255,7 +255,7 @@ public override async Task Handle(StackStatusCommand logger, gitClient, gitHubClient, - inputs.Full); + inputs.CheckPullRequests); }, cancellationToken); return new StackStatusCommandResponse(stackStatusResults); diff --git a/src/Stack/Commands/Stack/UpdateStackCommand.cs b/src/Stack/Commands/Stack/UpdateStackCommand.cs index fce4947f..a30fc79e 100644 --- a/src/Stack/Commands/Stack/UpdateStackCommand.cs +++ b/src/Stack/Commands/Stack/UpdateStackCommand.cs @@ -24,6 +24,7 @@ public UpdateStackCommand( Add(CommonOptions.Stack); Add(CommonOptions.Rebase); Add(CommonOptions.Merge); + Add(CommonOptions.CheckPullRequests); } protected override async Task Execute(ParseResult parseResult, CancellationToken cancellationToken) @@ -32,14 +33,15 @@ await handler.Handle( new UpdateStackCommandInputs( parseResult.GetValue(CommonOptions.Stack), parseResult.GetValue(CommonOptions.Rebase), - parseResult.GetValue(CommonOptions.Merge)), + parseResult.GetValue(CommonOptions.Merge), + parseResult.GetValue(CommonOptions.CheckPullRequests)), cancellationToken); } } -public record UpdateStackCommandInputs(string? Stack, bool? Rebase, bool? Merge) +public record UpdateStackCommandInputs(string? Stack, bool? Rebase, bool? Merge, bool CheckPullRequests) { - public static UpdateStackCommandInputs Empty => new(null, null, null); + public static UpdateStackCommandInputs Empty => new(null, null, null, false); } public record UpdateStackCommandResponse(); @@ -87,7 +89,7 @@ public override async Task Handle(UpdateStackCommandInputs inputs, CancellationT await displayProvider.DisplayStatus("Updating stack...", async (ct) => { - await stackActions.UpdateStack(stack, updateStrategy, cancellationToken); + await stackActions.UpdateStack(stack, updateStrategy, cancellationToken, inputs.CheckPullRequests); }, cancellationToken); if (stack.SourceBranch.Equals(currentBranch, StringComparison.InvariantCultureIgnoreCase) || diff --git a/src/Stack/Git/CachingGitHubClient.cs b/src/Stack/Git/CachingGitHubClient.cs index a6d3661d..8fd8a9df 100644 --- a/src/Stack/Git/CachingGitHubClient.cs +++ b/src/Stack/Git/CachingGitHubClient.cs @@ -48,5 +48,7 @@ public void OpenPullRequest(GitHubPullRequest pullRequest) inner.OpenPullRequest(pullRequest); } + public void ThrowIfNotAvailable() => inner.ThrowIfNotAvailable(); + static string GetCacheKey(string branch) => $"pr:{branch}"; } diff --git a/src/Stack/Git/GitHubClient.cs b/src/Stack/Git/GitHubClient.cs index 8c24a020..c1b25ba9 100644 --- a/src/Stack/Git/GitHubClient.cs +++ b/src/Stack/Git/GitHubClient.cs @@ -48,7 +48,9 @@ public static string GetPullRequestDisplay(this GitHubPullRequest pullRequest) public interface IGitHubClient { + void ThrowIfNotAvailable(); GitHubPullRequest? GetPullRequest(string branch); + Dictionary GetPullRequests(IEnumerable branches) => branches.ToDictionary(b => b, GetPullRequest); GitHubPullRequest CreatePullRequest( string headBranch, string baseBranch, @@ -71,6 +73,23 @@ internal partial class GitHubClientJsonSerializerContext : JsonSerializerContext public class GitHubClient(ILogger logger, CliExecutionContext context) : IGitHubClient { + public bool IsAvailable => ProcessHelpers.DoesCommandExist("gh"); + + public bool IsAuthenticated => IsAvailable && ExecuteGitHubCommandAndReturnOutput("auth status").Contains("Logged in to"); + + public void ThrowIfNotAvailable() + { + if (!IsAvailable) + { + throw new InvalidOperationException("GitHub CLI (gh) is not installed or not available in PATH."); + } + + if (!IsAuthenticated) + { + throw new InvalidOperationException("GitHub CLI (gh) is not authenticated. Please run 'gh auth login' to authenticate."); + } + } + public GitHubPullRequest? GetPullRequest(string branch) { var output = ExecuteGitHubCommandAndReturnOutput($"pr list --json title,number,body,state,url,isDraft,headRefName --head {branch} --state all"); diff --git a/src/Stack/Git/ProcessHelpers.cs b/src/Stack/Git/ProcessHelpers.cs index 4e74388f..95d9fb53 100644 --- a/src/Stack/Git/ProcessHelpers.cs +++ b/src/Stack/Git/ProcessHelpers.cs @@ -86,6 +86,25 @@ public static ProcessExecutionResult ExecuteProcessAndReturnOutput( return result; } + + public static bool DoesCommandExist(string command) + { + var psi = new ProcessStartInfo + { + FileName = Environment.OSVersion.Platform == PlatformID.Win32NT ? "where" : "which", + Arguments = command, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process is null) return false; + + process.WaitForExit(); + return process.ExitCode == 0; + } } public class ProcessException(string message, string filePath, string command, int exitCode) : Exception(message) diff --git a/src/Stack/Git/SafeGitHubClient.cs b/src/Stack/Git/SafeGitHubClient.cs index ed002011..6ee87c19 100644 --- a/src/Stack/Git/SafeGitHubClient.cs +++ b/src/Stack/Git/SafeGitHubClient.cs @@ -38,6 +38,8 @@ public GitHubPullRequest EditPullRequest(GitHubPullRequest pullRequest, string b public void OpenPullRequest(GitHubPullRequest pullRequest) => inner.OpenPullRequest(pullRequest); + + public void ThrowIfNotAvailable() => inner.ThrowIfNotAvailable(); } internal static partial class LoggerExtensionMethods diff --git a/src/Stack/Infrastructure/CommonOptions.cs b/src/Stack/Infrastructure/CommonOptions.cs index b38dbab5..2be312ee 100644 --- a/src/Stack/Infrastructure/CommonOptions.cs +++ b/src/Stack/Infrastructure/CommonOptions.cs @@ -67,4 +67,10 @@ public static class CommonOptions Description = "The name of the parent branch to put the branch under.", Required = false }; + + public static Option CheckPullRequests { get; } = new Option("--check-pull-requests") + { + Description = "Check the status of pull requests as part of determining if a branch should be included when updating the stack.", + Required = false + }; } \ No newline at end of file