diff --git a/README.md b/README.md index dbd3b42..52658a2 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ OPTIONS: ### `stack status` -Shows the status of a stack, including commits compared to other branches and the status of any associated pull requests. +Shows the status of a stack, including commits compared to other branches and optionally the status of any associated pull requests. ```shell USAGE: @@ -154,6 +154,7 @@ OPTIONS: --working-dir The path to the directory containing the git repository. Defaults to the current directory -n, --name The name of the stack to show the status of --all Show status of all stacks + --full Show full status including pull requests ``` ### `stack delete` diff --git a/src/Stack.Tests/Commands/Stack/StackStatusCommandHandlerTests.cs b/src/Stack.Tests/Commands/Stack/StackStatusCommandHandlerTests.cs index d55a43d..4ba41e4 100644 --- a/src/Stack.Tests/Commands/Stack/StackStatusCommandHandlerTests.cs +++ b/src/Stack.Tests/Commands/Stack/StackStatusCommandHandlerTests.cs @@ -16,15 +16,19 @@ public async Task WhenMultipleBranchesExistInAStack_AndOneHasAPullRequests_Retur { // Arrange var sourceBranch = Some.BranchName(); - var aBranch = Some.BranchName(); - var aSecondBranch = 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(aBranch).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) - .WithBranch(builder => builder.WithName(aSecondBranch).FromSourceBranch(aBranch).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) .WithNumberOfEmptyCommits(b => b.OnBranch(sourceBranch).PushToRemote(), 5) .Build(); + var tipOfSourceBranch = repo.GetTipOfBranch(sourceBranch); + var tipOfBranch1 = repo.GetTipOfBranch(branch1); + var tipOfBranch2 = repo.GetTipOfBranch(branch2); + var stackConfig = Substitute.For(); var inputProvider = Substitute.For(); var outputProvider = Substitute.For(); @@ -32,7 +36,7 @@ public async Task WhenMultipleBranchesExistInAStack_AndOneHasAPullRequests_Retur var gitHubOperations = Substitute.For(); var handler = new StackStatusCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); - var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [aBranch, aSecondBranch]); + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, []); var stacks = new List([stack1, stack2]); stackConfig.Load().Returns(stacks); @@ -45,24 +49,26 @@ public async Task WhenMultipleBranchesExistInAStack_AndOneHasAPullRequests_Retur var pr = new GitHubPullRequest(1, "PR title", "PR body", GitHubPullRequestStates.Open, Some.HttpsUri(), false); gitHubOperations - .GetPullRequest(aBranch) + .GetPullRequest(branch1) .Returns(pr); // Act - var response = await handler.Handle(new StackStatusCommandInputs(null, false)); + var response = await handler.Handle(new StackStatusCommandInputs(null, false, true)); // Assert var expectedBranchDetails = new Dictionary { - { aBranch, new BranchDetail { Status = new BranchStatus(true, true, 10, 5), PullRequest = pr } }, - { aSecondBranch, new BranchDetail { Status = new BranchStatus(true, true, 1, 0) } } + { sourceBranch, new BranchDetail { Status = new BranchStatus(true, true, true, 0, 0, 0, 0, new Commit(tipOfSourceBranch.Sha[..7], tipOfSourceBranch.Message.Trim())) } }, + { branch1, new BranchDetail { Status = new BranchStatus(true, true, false, 10, 5, 0, 0, new Commit(tipOfBranch1.Sha[..7], tipOfBranch1.Message.Trim())), PullRequest = pr } }, + { branch2, new BranchDetail { Status = new BranchStatus(true, true, false, 1, 0, 0, 0, new Commit(tipOfBranch2.Sha[..7], tipOfBranch2.Message.Trim())) } } }; - response.Statuses.Should().BeEquivalentTo(new Dictionary - { + response.Statuses.Should().BeEquivalentTo( + new Dictionary { - stack1, new(expectedBranchDetails) - } - }); + { + stack1, new(expectedBranchDetails) + } + }); } [Fact] @@ -70,15 +76,19 @@ public async Task WhenStackNameIsProvided_DoesNotAskForStack_ReturnsStatus() { // Arrange var sourceBranch = Some.BranchName(); - var aBranch = Some.BranchName(); - var aSecondBranch = 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(aBranch).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) - .WithBranch(builder => builder.WithName(aSecondBranch).FromSourceBranch(aBranch).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) .WithNumberOfEmptyCommits(b => b.OnBranch(sourceBranch).PushToRemote(), 5) .Build(); + var tipOfSourceBranch = repo.GetTipOfBranch(sourceBranch); + var tipOfBranch1 = repo.GetTipOfBranch(branch1); + var tipOfBranch2 = repo.GetTipOfBranch(branch2); + var stackConfig = Substitute.For(); var inputProvider = Substitute.For(); var outputProvider = Substitute.For(); @@ -86,7 +96,7 @@ public async Task WhenStackNameIsProvided_DoesNotAskForStack_ReturnsStatus() var gitHubOperations = Substitute.For(); var handler = new StackStatusCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); - var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [aBranch, aSecondBranch]); + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, []); var stacks = new List([stack1, stack2]); stackConfig.Load().Returns(stacks); @@ -99,17 +109,18 @@ public async Task WhenStackNameIsProvided_DoesNotAskForStack_ReturnsStatus() var pr = new GitHubPullRequest(1, "PR title", "PR body", GitHubPullRequestStates.Open, Some.HttpsUri(), false); gitHubOperations - .GetPullRequest(aBranch) + .GetPullRequest(branch1) .Returns(pr); // Act - var response = await handler.Handle(new StackStatusCommandInputs("Stack1", false)); + var response = await handler.Handle(new StackStatusCommandInputs("Stack1", false, true)); // Assert var expectedBranchDetails = new Dictionary { - { aBranch, new BranchDetail { Status = new BranchStatus(true, true, 10, 5), PullRequest = pr } }, - { aSecondBranch, new BranchDetail { Status = new BranchStatus(true, true, 1, 0) } } + { sourceBranch, new BranchDetail { Status = new BranchStatus(true, true, true, 0, 0, 0, 0, new Commit(tipOfSourceBranch.Sha[..7], tipOfSourceBranch.Message.Trim())) } }, + { branch1, new BranchDetail { Status = new BranchStatus(true, true, false, 10, 5, 0, 0, new Commit(tipOfBranch1.Sha[..7], tipOfBranch1.Message.Trim())), PullRequest = pr } }, + { branch2, new BranchDetail { Status = new BranchStatus(true, true, false, 1, 0, 0, 0, new Commit(tipOfBranch2.Sha[..7], tipOfBranch2.Message.Trim())) } } }; response.Statuses.Should().BeEquivalentTo(new Dictionary { @@ -126,17 +137,22 @@ public async Task WhenAllStacksAreRequested_ReturnsStatusOfEachStack() { // Arrange var sourceBranch = Some.BranchName(); - var aBranch = Some.BranchName(); - var aSecondBranch = Some.BranchName(); - var aThirdBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + var branch3 = Some.BranchName(); using var repo = new TestGitRepositoryBuilder() .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote()) - .WithBranch(builder => builder.WithName(aBranch).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) - .WithBranch(builder => builder.WithName(aSecondBranch).FromSourceBranch(aBranch).WithNumberOfEmptyCommits(1).PushToRemote()) - .WithBranch(builder => builder.WithName(aThirdBranch).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(3).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithBranch(builder => builder.WithName(branch3).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(3).PushToRemote()) .WithNumberOfEmptyCommits(b => b.OnBranch(sourceBranch).PushToRemote(), 5) .Build(); + var tipOfSourceBranch = repo.GetTipOfBranch(sourceBranch); + var tipOfBranch1 = repo.GetTipOfBranch(branch1); + var tipOfBranch2 = repo.GetTipOfBranch(branch2); + var tipOfBranch3 = repo.GetTipOfBranch(branch3); + var stackConfig = Substitute.For(); var inputProvider = Substitute.For(); var outputProvider = Substitute.For(); @@ -144,8 +160,8 @@ public async Task WhenAllStacksAreRequested_ReturnsStatusOfEachStack() var gitHubOperations = Substitute.For(); var handler = new StackStatusCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); - var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [aBranch, aSecondBranch]); - var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, [aThirdBranch]); + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); + var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, [branch3]); var stacks = new List([stack1, stack2]); stackConfig.Load().Returns(stacks); @@ -156,21 +172,23 @@ public async Task WhenAllStacksAreRequested_ReturnsStatusOfEachStack() var pr = new GitHubPullRequest(1, "PR title", "PR body", GitHubPullRequestStates.Open, Some.HttpsUri(), false); gitHubOperations - .GetPullRequest(aBranch) + .GetPullRequest(branch1) .Returns(pr); // Act - var response = await handler.Handle(new StackStatusCommandInputs(null, true)); + var response = await handler.Handle(new StackStatusCommandInputs(null, true, true)); // Assert var expectedBranchDetailsForStack1 = new Dictionary { - { aBranch, new BranchDetail { Status = new BranchStatus(true, true, 10, 5), PullRequest = pr } }, - { aSecondBranch, new BranchDetail { Status = new BranchStatus(true, true, 1, 0) } } + { sourceBranch, new BranchDetail { Status = new BranchStatus(true, true, true, 0, 0, 0, 0, new Commit(tipOfSourceBranch.Sha[..7], tipOfSourceBranch.Message.Trim())) } }, + { branch1, new BranchDetail { Status = new BranchStatus(true, true, false, 10, 5, 0, 0, new Commit(tipOfBranch1.Sha[..7], tipOfBranch1.Message.Trim())), PullRequest = pr } }, + { branch2, new BranchDetail { Status = new BranchStatus(true, true, false, 1, 0, 0, 0, new Commit(tipOfBranch2.Sha[..7], tipOfBranch2.Message.Trim())) } } }; var expectedBranchDetailsForStack2 = new Dictionary { - { aThirdBranch, new BranchDetail { Status = new BranchStatus(true, true, 3, 5) } } + { sourceBranch, new BranchDetail { Status = new BranchStatus(true, true, true, 0, 0, 0, 0, new Commit(tipOfSourceBranch.Sha[..7], tipOfSourceBranch.Message.Trim())) } }, + { branch3, new BranchDetail { Status = new BranchStatus(true, true, false, 3, 5, 0, 0, new Commit(tipOfBranch3.Sha[..7], tipOfBranch3.Message.Trim())) } } }; response.Statuses.Should().BeEquivalentTo(new Dictionary { @@ -188,17 +206,22 @@ public async Task WhenAllStacksAreRequested_WithStacksInMultipleRepositories_Ret { // Arrange var sourceBranch = Some.BranchName(); - var aBranch = Some.BranchName(); - var aSecondBranch = Some.BranchName(); - var aThirdBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + var branch3 = Some.BranchName(); using var repo = new TestGitRepositoryBuilder() .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote()) - .WithBranch(builder => builder.WithName(aBranch).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) - .WithBranch(builder => builder.WithName(aSecondBranch).FromSourceBranch(aBranch).WithNumberOfEmptyCommits(1).PushToRemote()) - .WithBranch(builder => builder.WithName(aThirdBranch).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(3).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithBranch(builder => builder.WithName(branch3).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(3).PushToRemote()) .WithNumberOfEmptyCommits(b => b.OnBranch(sourceBranch).PushToRemote(), 5) .Build(); + var tipOfSourceBranch = repo.GetTipOfBranch(sourceBranch); + var tipOfBranch1 = repo.GetTipOfBranch(branch1); + var tipOfBranch2 = repo.GetTipOfBranch(branch2); + var tipOfBranch3 = repo.GetTipOfBranch(branch3); + var stackConfig = Substitute.For(); var inputProvider = Substitute.For(); var outputProvider = Substitute.For(); @@ -206,8 +229,8 @@ public async Task WhenAllStacksAreRequested_WithStacksInMultipleRepositories_Ret var gitHubOperations = Substitute.For(); var handler = new StackStatusCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); - var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [aBranch, aSecondBranch]); - var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, [aThirdBranch]); + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); + var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, [branch3]); var stack3 = new Config.Stack("Stack2", Some.HttpsUri().ToString(), Some.BranchName(), [Some.BranchName()]); var stacks = new List([stack1, stack2]); stackConfig.Load().Returns(stacks); @@ -219,21 +242,23 @@ public async Task WhenAllStacksAreRequested_WithStacksInMultipleRepositories_Ret var pr = new GitHubPullRequest(1, "PR title", "PR body", GitHubPullRequestStates.Open, Some.HttpsUri(), false); gitHubOperations - .GetPullRequest(aBranch) + .GetPullRequest(branch1) .Returns(pr); // Act - var response = await handler.Handle(new StackStatusCommandInputs(null, true)); + var response = await handler.Handle(new StackStatusCommandInputs(null, true, true)); // Assert var expectedBranchDetailsForStack1 = new Dictionary { - { aBranch, new BranchDetail { Status = new BranchStatus(true, true, 10, 5), PullRequest = pr } }, - { aSecondBranch, new BranchDetail { Status = new BranchStatus(true, true, 1, 0) } } + { sourceBranch, new BranchDetail { Status = new BranchStatus(true, true, true, 0, 0, 0, 0, new Commit(tipOfSourceBranch.Sha[..7], tipOfSourceBranch.Message.Trim())) } }, + { branch1, new BranchDetail { Status = new BranchStatus(true, true, false, 10, 5, 0, 0, new Commit(tipOfBranch1.Sha[..7], tipOfBranch1.Message.Trim())), PullRequest = pr } }, + { branch2, new BranchDetail { Status = new BranchStatus(true, true, false, 1, 0, 0, 0, new Commit(tipOfBranch2.Sha[..7], tipOfBranch2.Message.Trim())) } } }; var expectedBranchDetailsForStack2 = new Dictionary { - { aThirdBranch, new BranchDetail { Status = new BranchStatus(true, true, 3, 5) } } + { sourceBranch, new BranchDetail { Status = new BranchStatus(true, true, true, 0, 0, 0, 0, new Commit(tipOfSourceBranch.Sha[..7], tipOfSourceBranch.Message.Trim())) } }, + { branch3, new BranchDetail { Status = new BranchStatus(true, true, false, 3, 5, 0, 0, new Commit(tipOfBranch3.Sha[..7], tipOfBranch3.Message.Trim())) } } }; response.Statuses.Should().BeEquivalentTo(new Dictionary { @@ -277,7 +302,7 @@ public async Task WhenStackNameIsProvided_ButStackDoesNotExist_Throws() // Act and assert var incorrectStackName = Some.Name(); await handler - .Invoking(async h => await h.Handle(new StackStatusCommandInputs(incorrectStackName, false))) + .Invoking(async h => await h.Handle(new StackStatusCommandInputs(incorrectStackName, false, false))) .Should().ThrowAsync() .WithMessage($"Stack '{incorrectStackName}' not found."); } @@ -287,15 +312,19 @@ public async Task WhenMultipleBranchesExistInAStack_AndOneNoLongerExistsOnTheRem { // Arrange var sourceBranch = Some.BranchName(); - var aBranch = Some.BranchName(); - var aSecondBranch = Some.BranchName(); - var aThirdBranch = Some.BranchName(); + var branch1 = "branch-1"; + var branch2 = "branch-2"; + var branch3 = "branch-3"; using var repo = new TestGitRepositoryBuilder() .WithBranch(builder => builder.WithName(sourceBranch).WithNumberOfEmptyCommits(5).PushToRemote()) - .WithBranch(builder => builder.WithName(aBranch).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10)) - .WithBranch(builder => builder.WithName(aSecondBranch).FromSourceBranch(aBranch).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10)) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) .Build(); + var tipOfSourceBranch = repo.GetTipOfBranch(sourceBranch); + var tipOfBranch1 = repo.GetTipOfBranch(branch1); + var tipOfBranch2 = repo.GetTipOfBranch(branch2); + var stackConfig = Substitute.For(); var inputProvider = Substitute.For(); var outputProvider = Substitute.For(); @@ -303,8 +332,8 @@ public async Task WhenMultipleBranchesExistInAStack_AndOneNoLongerExistsOnTheRem var gitHubOperations = Substitute.For(); var handler = new StackStatusCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); - var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [aBranch, aSecondBranch]); - var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, [aThirdBranch]); + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); + var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, [branch3]); var stacks = new List([stack1, stack2]); stackConfig.Load().Returns(stacks); @@ -316,17 +345,18 @@ public async Task WhenMultipleBranchesExistInAStack_AndOneNoLongerExistsOnTheRem var pr = new GitHubPullRequest(1, "PR title", "PR body", GitHubPullRequestStates.Open, Some.HttpsUri(), false); gitHubOperations - .GetPullRequest(aSecondBranch) + .GetPullRequest(branch2) .Returns(pr); // Act - var response = await handler.Handle(new StackStatusCommandInputs(null, false)); + var response = await handler.Handle(new StackStatusCommandInputs(null, false, true)); // Assert var expectedBranchDetails = new Dictionary { - { aBranch, new BranchDetail { Status = new BranchStatus(true, false, 0, 0) } }, - { aSecondBranch, new BranchDetail { Status = new BranchStatus(true, true, 11, 0), PullRequest = pr } } // The 11 commits are the 10 commits from the parent branch and one from this branch + { sourceBranch, new BranchDetail { Status = new BranchStatus(true, true, false, 0, 0, 0, 0, new Commit(tipOfSourceBranch.Sha[..7], tipOfSourceBranch.Message.Trim())) } }, + { branch1, new BranchDetail { Status = new BranchStatus(true, false, false, 0, 0, 0, 0, new Commit(tipOfBranch1.Sha[..7], tipOfBranch1.Message.Trim())) } }, + { branch2, new BranchDetail { Status = new BranchStatus(true, true, true, 11, 0, 0, 0, new Commit(tipOfBranch2.Sha[..7], tipOfBranch2.Message.Trim())), PullRequest = pr } } }; response.Statuses.Should().BeEquivalentTo(new Dictionary { @@ -341,14 +371,17 @@ public async Task WhenMultipleBranchesExistInAStack_AndOneNoLongerExistsOnTheRem { // Arrange var sourceBranch = Some.BranchName(); - var aBranch = Some.BranchName(); - var aSecondBranch = Some.BranchName(); - var aThirdBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + var branch3 = Some.BranchName(); using var repo = new TestGitRepositoryBuilder() .WithBranch(builder => builder.WithName(sourceBranch).WithNumberOfEmptyCommits(5).PushToRemote()) - .WithBranch(builder => builder.WithName(aSecondBranch).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(1).PushToRemote()) .Build(); + var tipOfSourceBranch = repo.GetTipOfBranch(sourceBranch); + var tipOfBranch2 = repo.GetTipOfBranch(branch2); + var stackConfig = Substitute.For(); var inputProvider = Substitute.For(); var outputProvider = Substitute.For(); @@ -356,8 +389,8 @@ public async Task WhenMultipleBranchesExistInAStack_AndOneNoLongerExistsOnTheRem var gitHubOperations = Substitute.For(); var handler = new StackStatusCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); - var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [aBranch, aSecondBranch]); - var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, [aThirdBranch]); + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); + var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, [branch3]); var stacks = new List([stack1, stack2]); stackConfig.Load().Returns(stacks); @@ -369,17 +402,18 @@ public async Task WhenMultipleBranchesExistInAStack_AndOneNoLongerExistsOnTheRem var pr = new GitHubPullRequest(1, "PR title", "PR body", GitHubPullRequestStates.Open, Some.HttpsUri(), false); gitHubOperations - .GetPullRequest(aSecondBranch) + .GetPullRequest(branch2) .Returns(pr); // Act - var response = await handler.Handle(new StackStatusCommandInputs(null, false)); + var response = await handler.Handle(new StackStatusCommandInputs(null, false, true)); // Assert var expectedBranchDetails = new Dictionary { - { aBranch, new BranchDetail { Status = new BranchStatus(false, false, 0, 0) } }, - { aSecondBranch, new BranchDetail { Status = new BranchStatus(true, true, 1, 0), PullRequest = pr } } + { sourceBranch, new BranchDetail { Status = new BranchStatus(true, true, false, 0, 0, 0, 0, new Commit(tipOfSourceBranch.Sha[..7], tipOfSourceBranch.Message.Trim())) } }, + { branch1, new BranchDetail { Status = new BranchStatus(false, false, false, 0, 0, 0, 0, null) } }, + { branch2, new BranchDetail { Status = new BranchStatus(true, true, true, 1, 0, 0, 0, new Commit(tipOfBranch2.Sha[..7], tipOfBranch2.Message.Trim())), PullRequest = pr } } }; response.Statuses.Should().BeEquivalentTo(new Dictionary { @@ -394,15 +428,19 @@ public async Task WhenOnlyOneStackExists_DoesNotAskForStackName_ReturnsStatus() { // Arrange var sourceBranch = Some.BranchName(); - var aBranch = Some.BranchName(); - var aSecondBranch = 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(aBranch).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) - .WithBranch(builder => builder.WithName(aSecondBranch).FromSourceBranch(aBranch).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) .WithNumberOfEmptyCommits(b => b.OnBranch(sourceBranch).PushToRemote(), 5) .Build(); + var tipOfSourceBranch = repo.GetTipOfBranch(sourceBranch); + var tipOfBranch1 = repo.GetTipOfBranch(branch1); + var tipOfBranch2 = repo.GetTipOfBranch(branch2); + var stackConfig = Substitute.For(); var inputProvider = Substitute.For(); var outputProvider = Substitute.For(); @@ -410,7 +448,7 @@ public async Task WhenOnlyOneStackExists_DoesNotAskForStackName_ReturnsStatus() var gitHubOperations = Substitute.For(); var handler = new StackStatusCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); - var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [aBranch, aSecondBranch]); + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); var stacks = new List([stack1]); stackConfig.Load().Returns(stacks); @@ -421,17 +459,18 @@ public async Task WhenOnlyOneStackExists_DoesNotAskForStackName_ReturnsStatus() var pr = new GitHubPullRequest(1, "PR title", "PR body", GitHubPullRequestStates.Open, Some.HttpsUri(), false); gitHubOperations - .GetPullRequest(aBranch) + .GetPullRequest(branch1) .Returns(pr); // Act - var response = await handler.Handle(new StackStatusCommandInputs(null, false)); + var response = await handler.Handle(new StackStatusCommandInputs(null, false, true)); // Assert var expectedBranchDetails = new Dictionary { - { aBranch, new BranchDetail { Status = new BranchStatus(true, true, 10, 5), PullRequest = pr } }, - { aSecondBranch, new BranchDetail { Status = new BranchStatus(true, true, 1, 0) } } + { sourceBranch, new BranchDetail { Status = new BranchStatus(true, true, true, 0, 0, 0, 0, new Commit(tipOfSourceBranch.Sha[..7], tipOfSourceBranch.Message.Trim())) } }, + { branch1, new BranchDetail { Status = new BranchStatus(true, true, false, 10, 5, 0, 0, new Commit(tipOfBranch1.Sha[..7], tipOfBranch1.Message.Trim())), PullRequest = pr } }, + { branch2, new BranchDetail { Status = new BranchStatus(true, true, false, 1, 0, 0, 0, new Commit(tipOfBranch2.Sha[..7], tipOfBranch2.Message.Trim())) } } }; response.Statuses.Should().BeEquivalentTo(new Dictionary { diff --git a/src/Stack.Tests/Git/GitBranchStatusParserTests.cs b/src/Stack.Tests/Git/GitBranchStatusParserTests.cs new file mode 100644 index 0000000..248988c --- /dev/null +++ b/src/Stack.Tests/Git/GitBranchStatusParserTests.cs @@ -0,0 +1,85 @@ +using FluentAssertions; +using Stack.Git; + +namespace Stack.Tests.Git; + +public class GitBranchStatusParserTests +{ + [Fact] + public void WhenBranchIsCurrentBranch_ReturnsCorrectStatus() + { + // Arrange + var branchStatus = "* main 1234567 [origin/main] Some message"; + + // Act + var result = GitBranchStatusParser.Parse(branchStatus); + + // Assert + result.Should().Be(new GitBranchStatus("main", "origin/main", true, true, 0, 0, new Commit("1234567", "Some message"))); + } + + [Fact] + public void WhenBranchIsAheadAndBehindItsRemoteTrackingBranch_ReturnsCorrectStatus() + { + // Arrange + var branchStatus = " main 1234567 [origin/main: ahead 1, behind 2] Some message"; + + // Act + var result = GitBranchStatusParser.Parse(branchStatus); + + // Assert + result.Should().Be(new GitBranchStatus("main", "origin/main", true, false, 1, 2, new Commit("1234567", "Some message"))); + } + + [Fact] + public void WhenBranchIsAheadOfItsRemoteTrackingBranch_ReturnsCorrectStatus() + { + // Arrange + var branchStatus = " main 1234567 [origin/main: ahead 1] Some message"; + + // Act + var result = GitBranchStatusParser.Parse(branchStatus); + + // Assert + result.Should().Be(new GitBranchStatus("main", "origin/main", true, false, 1, 0, new Commit("1234567", "Some message"))); + } + + [Fact] + public void WhenBranchIsBehindItsRemoteTrackingBranch_ReturnsCorrectStatus() + { + // Arrange + var branchStatus = " main 1234567 [origin/main: behind 2] Some message"; + + // Act + var result = GitBranchStatusParser.Parse(branchStatus); + + // Assert + result.Should().Be(new GitBranchStatus("main", "origin/main", true, false, 0, 2, new Commit("1234567", "Some message"))); + } + + [Fact] + public void WhenBranchIsNotTracked_ReturnsCorrectStatus() + { + // Arrange + var branchStatus = " main 1234567 Some message"; + + // Act + var result = GitBranchStatusParser.Parse(branchStatus); + + // Assert + result.Should().Be(new GitBranchStatus("main", null, false, false, 0, 0, new Commit("1234567", "Some message"))); + } + + [Fact] + public void WhenRemoteTrackingBranchIsGone_ReturnsCorrectStatus() + { + // Arrange + var branchStatus = " main 1234567 [origin/main: gone] Some message"; + + // Act + var result = GitBranchStatusParser.Parse(branchStatus); + + // Assert + result.Should().Be(new GitBranchStatus("main", "origin/main", false, false, 0, 0, new Commit("1234567", "Some message"))); + } +} diff --git a/src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs b/src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs index 14dfcf8..96a4944 100644 --- a/src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs +++ b/src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs @@ -56,7 +56,7 @@ public void Build(Repository repository, string defaultBranchName) } } - private static Commit CreateEmptyCommit(Repository repository, Branch branch, string message) + private static LibGit2Sharp.Commit CreateEmptyCommit(Repository repository, Branch branch, string message) { repository.Refs.UpdateTarget("HEAD", branch.CanonicalName); var signature = new Signature(Some.Name(), Some.Name(), DateTimeOffset.Now); @@ -238,7 +238,7 @@ public TestGitRepository Build() return new TestGitRepository(localDirectory, remoteDirectory, localRepo); } - private static Commit CreateInitialCommit(Repository repository) + private static LibGit2Sharp.Commit CreateInitialCommit(Repository repository) { var message = $"Initial commit"; var signature = new Signature(Some.Name(), Some.Name(), DateTimeOffset.Now); @@ -252,12 +252,12 @@ public class TestGitRepository(TemporaryDirectory LocalDirectory, TemporaryDirec public string RemoteUri => RemoteDirectory.DirectoryPath; public GitOperationSettings GitOperationSettings => new GitOperationSettings(false, false, LocalDirectory.DirectoryPath); - public Commit GetTipOfBranch(string branchName) + public LibGit2Sharp.Commit GetTipOfBranch(string branchName) { return LocalRepository.Branches[branchName].Tip; } - public List GetCommitsReachableFromBranch(string branchName) + public List GetCommitsReachableFromBranch(string branchName) { return [.. LocalRepository.Branches[branchName].Commits]; } diff --git a/src/Stack/Commands/Helpers/StackStatusHelpers.cs b/src/Stack/Commands/Helpers/StackStatusHelpers.cs index 232477f..e53c465 100644 --- a/src/Stack/Commands/Helpers/StackStatusHelpers.cs +++ b/src/Stack/Commands/Helpers/StackStatusHelpers.cs @@ -1,4 +1,5 @@ using System.Text; +using Microsoft.VisualBasic; using Spectre.Console; using Stack.Config; using Stack.Git; @@ -8,14 +9,14 @@ namespace Stack.Commands.Helpers; public class BranchDetail { - public BranchStatus Status { get; set; } = new(false, false, 0, 0); + public BranchStatus Status { get; set; } = new(false, false, false, 0, 0, 0, 0, null); public GitHubPullRequest? PullRequest { get; set; } public bool IsActive => Status.ExistsLocally && Status.ExistsInRemote && (PullRequest is null || PullRequest.State != GitHubPullRequestStates.Merged); public bool CouldBeCleanedUp => Status.ExistsLocally && (!Status.ExistsInRemote || PullRequest is not null && PullRequest.State == GitHubPullRequestStates.Merged); public bool HasPullRequest => PullRequest is not null && PullRequest.State != GitHubPullRequestStates.Closed; } -public record BranchStatus(bool ExistsLocally, bool ExistsInRemote, int Ahead, int Behind); +public record BranchStatus(bool ExistsLocally, bool ExistsInRemote, bool IsCurrentBranch, int AheadOfParent, int BehindParent, int AheadOfRemote, int BehindRemote, Commit? Tip); public record StackStatus(Dictionary Branches) { public string[] GetActiveBranches() => Branches.Where(b => b.Value.IsActive).Select(b => b.Key).ToArray(); @@ -28,7 +29,8 @@ public static class StackStatusHelpers string currentBranch, IOutputProvider outputProvider, IGitOperations gitOperations, - IGitHubOperations gitHubOperations) + IGitHubOperations gitHubOperations, + bool includePullRequestStatus = true) { var stacksToCheckStatusFor = new Dictionary(); @@ -37,65 +39,65 @@ public static class StackStatusHelpers .ToList() .ForEach(stack => stacksToCheckStatusFor.Add(stack, new StackStatus([]))); - outputProvider.Status("Checking status of remote branches...", () => + var allBranchesInStacks = stacks.SelectMany(s => new List([s.SourceBranch]).Concat(s.Branches)).Distinct().ToArray(); + + var branchStatuses = gitOperations.GetBranchStatuses(allBranchesInStacks); + + foreach (var (stack, status) in stacksToCheckStatusFor) { - foreach (var (stack, status) in stacksToCheckStatusFor) + var parentBranch = stack.SourceBranch; + + status.Branches.Add(stack.SourceBranch, new BranchDetail()); + branchStatuses.TryGetValue(stack.SourceBranch, out var sourceBranchStatus); + if (sourceBranchStatus is not null) { - var allBranchesInStack = new List([stack.SourceBranch]).Concat(stack.Branches).Distinct().ToArray(); - var branchesThatExistInRemote = gitOperations.GetBranchesThatExistInRemote(allBranchesInStack); - var branchesThatExistLocally = gitOperations.GetBranchesThatExistLocally(allBranchesInStack); + status.Branches[stack.SourceBranch].Status = new BranchStatus(true, sourceBranchStatus.RemoteBranchExists, sourceBranchStatus.IsCurrentBranch, 0, 0, sourceBranchStatus.Ahead, sourceBranchStatus.Behind, sourceBranchStatus.Tip); + } - gitOperations.FetchBranches(branchesThatExistInRemote); + foreach (var branch in stack.Branches) + { + status.Branches.Add(branch, new BranchDetail()); + branchStatuses.TryGetValue(branch, out var branchStatus); - void CheckRemoteBranch(string branch, string sourceBranch) + if (branchStatus is not null) { - var branchExistsLocally = branchesThatExistLocally.Contains(branch); - var (ahead, behind) = gitOperations.GetStatusOfRemoteBranch(branch, sourceBranch); - var branchStatus = new BranchStatus(branchExistsLocally, true, ahead, behind); - status.Branches[branch].Status = branchStatus; - } + var (aheadOfParent, behindParent) = branchStatus.RemoteBranchExists ? gitOperations.CompareBranches(branch, parentBranch) : (0, 0); - var parentBranch = stack.SourceBranch; - - foreach (var branch in stack.Branches) - { - status.Branches.Add(branch, new BranchDetail()); + status.Branches[branch].Status = new BranchStatus(true, branchStatus.RemoteBranchExists, branchStatus.IsCurrentBranch, aheadOfParent, behindParent, branchStatus.Ahead, branchStatus.Behind, branchStatus.Tip); - if (branchesThatExistInRemote.Contains(branch)) + if (branchStatus.RemoteBranchExists) { - CheckRemoteBranch(branch, parentBranch); parentBranch = branch; } - else - { - status.Branches[branch].Status = new BranchStatus(branchesThatExistLocally.Contains(branch), false, 0, 0); - } } } - }); + } - outputProvider.Status("Checking status of GitHub pull requests...", () => + if (includePullRequestStatus) { - foreach (var (stack, status) in stacksToCheckStatusFor) + outputProvider.Status("Checking status of GitHub pull requests...", () => { - try + foreach (var (stack, status) in stacksToCheckStatusFor) { - foreach (var branch in stack.Branches) + try { - var pr = gitHubOperations.GetPullRequest(branch); - - if (pr is not null) + foreach (var branch in stack.Branches) { - status.Branches[branch].PullRequest = pr; + var pr = gitHubOperations.GetPullRequest(branch); + + if (pr is not null) + { + status.Branches[branch].PullRequest = pr; + } } } + catch (Exception ex) + { + outputProvider.Warning($"Error checking GitHub pull requests: {ex.Message}"); + } } - catch (Exception ex) - { - outputProvider.Warning($"Error checking GitHub pull requests: {ex.Message}"); - } - } - }); + }); + } return stacksToCheckStatusFor; } @@ -114,22 +116,25 @@ public static StackStatus GetStackStatus( public static void OutputStackStatus( Dictionary stackStatuses, - IGitOperations gitOperations, IOutputProvider outputProvider) { foreach (var (stack, status) in stackStatuses) { - OutputStackStatus(stack, status, gitOperations, outputProvider); + OutputStackStatus(stack, status, outputProvider); + outputProvider.NewLine(); } } public static void OutputStackStatus( Config.Stack stack, StackStatus status, - IGitOperations gitOperations, IOutputProvider outputProvider) { - var header = $"{stack.Name.Stack()}: {stack.SourceBranch.Muted()}"; + var header = stack.SourceBranch.Branch(); + if (status.Branches.TryGetValue(stack.SourceBranch, out var sourceBranchStatus)) + { + header = GetBranchStatusOutput(stack.SourceBranch, null, sourceBranchStatus); + } var items = new List(); string parentBranch = stack.SourceBranch; @@ -138,7 +143,7 @@ public static void OutputStackStatus( { if (status.Branches.TryGetValue(branch, out var branchDetail)) { - items.Add(GetBranchAndPullRequestStatusOutput(branch, parentBranch, branchDetail, gitOperations)); + items.Add(GetBranchAndPullRequestStatusOutput(branch, parentBranch, branchDetail)); if (branchDetail.IsActive) { @@ -146,21 +151,21 @@ public static void OutputStackStatus( } } } + outputProvider.Information(stack.Name.Stack()); outputProvider.Tree(header, [.. items]); } public static string GetBranchAndPullRequestStatusOutput( string branch, - string parentBranch, - BranchDetail branchDetail, - IGitOperations gitOperations) + string? parentBranch, + BranchDetail branchDetail) { var branchNameBuilder = new StringBuilder(); - branchNameBuilder.Append(GetBranchStatusOutput(branch, parentBranch, branchDetail, gitOperations)); + branchNameBuilder.Append(GetBranchStatusOutput(branch, parentBranch, branchDetail)); if (branchDetail.PullRequest is not null) { - branchNameBuilder.Append($" {branchDetail.PullRequest.GetPullRequestDisplay()}"); + branchNameBuilder.Append($" {branchDetail.PullRequest.GetPullRequestDisplay()}"); } return branchNameBuilder.ToString(); @@ -168,51 +173,64 @@ public static string GetBranchAndPullRequestStatusOutput( public static string GetBranchStatusOutput( string branch, - string parentBranch, - BranchDetail branchDetail, - IGitOperations gitOperations) + string? parentBranch, + BranchDetail branchDetail) { var branchNameBuilder = new StringBuilder(); - var currentBranch = gitOperations.GetCurrentBranch(); - var branchIsMerged = - branchDetail.Status.ExistsInRemote == false || - branchDetail.Status.ExistsLocally == false || - branchDetail.PullRequest is not null && branchDetail.PullRequest.State == GitHubPullRequestStates.Merged; - var color = !branchDetail.IsActive ? "grey" : branch.Equals(currentBranch, StringComparison.OrdinalIgnoreCase) ? "blue" : null; - Decoration? decoration = !branchDetail.IsActive ? Decoration.Strikethrough : null; + var branchName = branchDetail.Status.IsCurrentBranch ? $"* {branch.Branch()}" : branch; + Color? color = branchDetail.Status.ExistsLocally ? null : Color.Grey; + Decoration? decoration = branchDetail.Status.ExistsLocally ? null : Decoration.Strikethrough; if (color is not null && decoration is not null) { - branchNameBuilder.Append($"[{decoration} {color}]{branch}[/]"); + branchNameBuilder.Append($"[{decoration} {color}]{branchName}[/]"); } else if (color is not null) { - branchNameBuilder.Append($"[{color}]{branch}[/]"); + branchNameBuilder.Append($"[{color}]{branchName}[/]"); } else if (decoration is not null) { - branchNameBuilder.Append($"[{decoration}]{branch}[/]"); + branchNameBuilder.Append($"[{decoration}]{branchName}[/]"); } else { - branchNameBuilder.Append(branch); + branchNameBuilder.Append(branchName); } if (branchDetail.IsActive) { - if (branchDetail.Status.Ahead > 0 && branchDetail.Status.Behind > 0) + if (branchDetail.Status.AheadOfRemote > 0 || branchDetail.Status.BehindRemote > 0) { - branchNameBuilder.Append($" [grey]({branchDetail.Status.Ahead} ahead, {branchDetail.Status.Behind} behind {parentBranch})[/]"); + branchNameBuilder.Append($" {branchDetail.Status.BehindRemote}{Emoji.Known.DownArrow}{branchDetail.Status.AheadOfRemote}{Emoji.Known.UpArrow}"); } - else if (branchDetail.Status.Ahead > 0) + + if (branchDetail.Status.AheadOfParent > 0 && branchDetail.Status.BehindParent > 0) { - branchNameBuilder.Append($" [grey]({branchDetail.Status.Ahead} ahead of {parentBranch})[/]"); + branchNameBuilder.Append($" ({branchDetail.Status.AheadOfParent} ahead, {branchDetail.Status.BehindParent} behind {parentBranch})".Muted()); } - else if (branchDetail.Status.Behind > 0) + else if (branchDetail.Status.AheadOfParent > 0) { - branchNameBuilder.Append($" [grey]({branchDetail.Status.Behind} behind {parentBranch})[/]"); + branchNameBuilder.Append($" ({branchDetail.Status.AheadOfParent} ahead of {parentBranch})".Muted()); } + else if (branchDetail.Status.BehindParent > 0) + { + branchNameBuilder.Append($" ({branchDetail.Status.BehindParent} behind {parentBranch})".Muted()); + } + } + else if (branchDetail.Status.ExistsLocally && !branchDetail.Status.ExistsInRemote) + { + branchNameBuilder.Append(" (remote branch deleted)".Muted()); + } + else if (branchDetail.PullRequest is not null && branchDetail.PullRequest.State == GitHubPullRequestStates.Merged) + { + branchNameBuilder.Append(" (pull request merged)".Muted()); + } + + if (branchDetail.Status.Tip is not null) + { + branchNameBuilder.Append($" {branchDetail.Status.Tip.Sha[..7]} {branchDetail.Status.Tip.Message}"); } return branchNameBuilder.ToString(); @@ -245,7 +263,7 @@ public static void OutputBranchAndStackCleanup( outputProvider.Information($"Run {$"stack delete --name \"{stack.Name}\"".Example()} to delete the stack."); } - if (status.Branches.Values.Any(branch => branch.Status.ExistsInRemote && branch.Status.ExistsLocally && branch.Status.Behind > 0)) + if (status.Branches.Values.Any(branch => branch.Status.ExistsInRemote && branch.Status.ExistsLocally && branch.Status.BehindParent > 0)) { outputProvider.NewLine(); outputProvider.Information("There are changes in source branches that have not been applied to the stack."); diff --git a/src/Stack/Commands/PullRequests/CreatePullRequestsCommand.cs b/src/Stack/Commands/PullRequests/CreatePullRequestsCommand.cs index 9f28f50..46c8168 100644 --- a/src/Stack/Commands/PullRequests/CreatePullRequestsCommand.cs +++ b/src/Stack/Commands/PullRequests/CreatePullRequestsCommand.cs @@ -100,7 +100,7 @@ public async Task Handle(CreatePullRequestsCo } } - StackStatusHelpers.OutputStackStatus(stack, status, gitOperations, outputProvider); + StackStatusHelpers.OutputStackStatus(stack, status, outputProvider); outputProvider.NewLine(); @@ -112,7 +112,7 @@ public async Task Handle(CreatePullRequestsCo outputProvider.NewLine(); - OutputUpdatedStackStatus(outputProvider, gitOperations, stack, status, pullRequestCreateActions); + OutputUpdatedStackStatus(outputProvider, stack, status, pullRequestCreateActions); outputProvider.NewLine(); @@ -229,7 +229,7 @@ private static List CreatePullRequests( return pullRequests; } - private static void OutputUpdatedStackStatus(IOutputProvider outputProvider, IGitOperations gitOperations, Config.Stack stack, StackStatus status, List pullRequestCreateActions) + private static void OutputUpdatedStackStatus(IOutputProvider outputProvider, Config.Stack stack, StackStatus status, List pullRequestCreateActions) { var branchDisplayItems = new List(); var parentBranch = stack.SourceBranch; @@ -239,12 +239,12 @@ private static void OutputUpdatedStackStatus(IOutputProvider outputProvider, IGi var branchDetail = status.Branches[branch]; if (branchDetail.PullRequest is not null && branchDetail.PullRequest.State != GitHubPullRequestStates.Closed) { - branchDisplayItems.Add(StackStatusHelpers.GetBranchAndPullRequestStatusOutput(branch, parentBranch, branchDetail, gitOperations)); + branchDisplayItems.Add(StackStatusHelpers.GetBranchAndPullRequestStatusOutput(branch, parentBranch, branchDetail)); } else { var action = pullRequestCreateActions.FirstOrDefault(a => a.HeadBranch == branch); - branchDisplayItems.Add($"{StackStatusHelpers.GetBranchStatusOutput(branch, parentBranch, branchDetail, gitOperations)} *NEW* {action?.Title}{(action?.Draft == true ? " (draft)".Muted() : string.Empty)}"); + branchDisplayItems.Add($"{StackStatusHelpers.GetBranchStatusOutput(branch, parentBranch, branchDetail)} *NEW* {action?.Title}{(action?.Draft == true ? " (draft)".Muted() : string.Empty)}"); } parentBranch = branch; } diff --git a/src/Stack/Commands/Stack/StackStatusCommand.cs b/src/Stack/Commands/Stack/StackStatusCommand.cs index 8c2b66a..2cc771d 100644 --- a/src/Stack/Commands/Stack/StackStatusCommand.cs +++ b/src/Stack/Commands/Stack/StackStatusCommand.cs @@ -17,6 +17,10 @@ public class StackStatusCommandSettings : CommandSettingsBase [Description("Show status of all stacks.")] [CommandOption("--all")] public bool All { get; init; } + + [Description("Show full status including pull requests.")] + [CommandOption("--full")] + public bool Full { get; init; } } public class StackStatusCommand : AsyncCommand @@ -33,13 +37,13 @@ public override async Task ExecuteAsync(CommandContext context, StackStatus new GitHubOperations(outputProvider, settings.GetGitHubOperationSettings()), new StackConfig()); - await handler.Handle(new StackStatusCommandInputs(settings.Name, settings.All)); + await handler.Handle(new StackStatusCommandInputs(settings.Name, settings.All, settings.Full)); return 0; } } -public record StackStatusCommandInputs(string? Name, bool All); +public record StackStatusCommandInputs(string? Name, bool All, bool Full); public record StackStatusCommandResponse(Dictionary Statuses); public class StackStatusCommandHandler( @@ -81,9 +85,15 @@ public async Task Handle(StackStatusCommandInputs in currentBranch, outputProvider, gitOperations, - gitHubOperations); + gitHubOperations, + inputs.Full); + + if (stackStatusResults.Count == 1) + { + outputProvider.NewLine(); + } - StackStatusHelpers.OutputStackStatus(stackStatusResults, gitOperations, outputProvider); + StackStatusHelpers.OutputStackStatus(stackStatusResults, outputProvider); if (stacksToCheckStatusFor.Count == 1) { diff --git a/src/Stack/Git/GitBranchStatusParser.cs b/src/Stack/Git/GitBranchStatusParser.cs new file mode 100644 index 0000000..238388b --- /dev/null +++ b/src/Stack/Git/GitBranchStatusParser.cs @@ -0,0 +1,31 @@ +using System.Text.RegularExpressions; + +namespace Stack.Git; + +public static class GitBranchStatusParser +{ + static Regex regex = new( + @"^(?\*)?\s*(?\S+)\s+(?\S+)\s*(\[(?[^:]+)?(?::\s*(?(ahead\s+(?\d+),\s*behind\s+(?\d+))|(ahead\s+(?\d+))|(behind\s+(?\d+))|(gone)))?\])?\s+(?.+)$", + RegexOptions.Compiled); + + public static GitBranchStatus? Parse(string branchStatus) + { + var match = regex.Match(branchStatus); + + if (match.Success) + { + var branchName = match.Groups["branchName"].Value; + var isCurrentBranch = match.Groups["isCurrentBranch"].Success; + var remoteTrackingBranchName = string.IsNullOrEmpty(match.Groups["remoteTrackingBranchName"].Value) ? null : match.Groups["remoteTrackingBranchName"].Value; + var ahead = match.Groups["ahead"].Success ? int.Parse(match.Groups["ahead"].Value) : (match.Groups["aheadOnly"].Success ? int.Parse(match.Groups["aheadOnly"].Value) : 0); + var behind = match.Groups["behind"].Success ? int.Parse(match.Groups["behind"].Value) : (match.Groups["behindOnly"].Success ? int.Parse(match.Groups["behindOnly"].Value) : 0); + var remoteBranchExists = remoteTrackingBranchName is not null && !match.Groups["status"].Value.Contains("gone"); + var sha = match.Groups["sha"].Value; + var message = match.Groups["message"].Value; + + return new GitBranchStatus(branchName, remoteTrackingBranchName, remoteBranchExists, isCurrentBranch, ahead, behind, new Commit(sha, message)); + } + + return null; + } +} \ No newline at end of file diff --git a/src/Stack/Git/GitOperations.cs b/src/Stack/Git/GitOperations.cs index 60c9b5a..d2a7f45 100644 --- a/src/Stack/Git/GitOperations.cs +++ b/src/Stack/Git/GitOperations.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Text; +using System.Text.RegularExpressions; using Octopus.Shellfish; using Stack.Infrastructure; @@ -10,7 +11,9 @@ public record GitOperationSettings(bool DryRun, bool Verbose, string? WorkingDir public static GitOperationSettings Default => new(false, false, null); } +public record Commit(string Sha, string Message); +public record GitBranchStatus(string BranchName, string? RemoteTrackingBranchName, bool RemoteBranchExists, bool IsCurrentBranch, int Ahead, int Behind, Commit Tip); public interface IGitOperations { @@ -31,6 +34,8 @@ public interface IGitOperations bool IsRemoteBranchFullyMerged(string branchName, string sourceBranchName); string[] GetBranchesThatHaveBeenMerged(string[] branches, string sourceBranchName); (int Ahead, int Behind) GetStatusOfRemoteBranch(string branchName, string sourceBranchName); + (int Ahead, int Behind) CompareBranches(string branchName, string sourceBranchName); + Dictionary GetBranchStatuses(string[] branches); string GetRemoteUri(); string[] GetLocalBranchesOrderedByMostRecentCommitterDate(); string GetRootOfRepository(); @@ -139,6 +144,32 @@ public string[] GetBranchesThatHaveBeenMerged(string[] branches, string sourceBr return (int.Parse(parts[0]), int.Parse(parts[1])); } + public (int Ahead, int Behind) CompareBranches(string branchName, string sourceBranchName) + { + var status = ExecuteGitCommandAndReturnOutput($"rev-list --left-right --count {branchName}...{sourceBranchName}").Trim(); + var parts = status.Split('\t'); + return (int.Parse(parts[0]), int.Parse(parts[1])); + } + + public Dictionary GetBranchStatuses(string[] branches) + { + var statuses = new Dictionary(); + + var gitBranchVerbose = ExecuteGitCommandAndReturnOutput("branch -vv").Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + + foreach (var branchStatus in gitBranchVerbose) + { + var status = GitBranchStatusParser.Parse(branchStatus); + + if (status is not null && branches.Contains(status.BranchName)) + { + statuses.Add(status.BranchName, status); + } + } + + return statuses; + } + public string GetRemoteUri() { return ExecuteGitCommandAndReturnOutput("remote get-url origin").Trim(); diff --git a/src/Stack/Infrastructure/ConsoleOutputProvider.cs b/src/Stack/Infrastructure/ConsoleOutputProvider.cs index 9daf30b..aca97ee 100644 --- a/src/Stack/Infrastructure/ConsoleOutputProvider.cs +++ b/src/Stack/Infrastructure/ConsoleOutputProvider.cs @@ -51,8 +51,8 @@ public void Rule(string message) public static class OutputStyleExtensionMethods { - public static string Stack(this string name) => $"[yellow]{name}[/]"; - public static string Branch(this string name) => $"[blue]{name}[/]"; - public static string Muted(this string name) => $"[grey]{name}[/]"; - public static string Example(this string name) => $"[aqua]{name}[/]"; + public static string Stack(this string name) => $"[{Color.Yellow}]{name}[/]"; + public static string Branch(this string name) => $"[{Color.Blue}]{name}[/]"; + public static string Muted(this string name) => $"[{Color.Grey}]{name}[/]"; + public static string Example(this string name) => $"[{Color.Aqua}]{name}[/]"; } \ No newline at end of file