diff --git a/README.md b/README.md index 0200660..19ce418 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ To create a stack: - Optionally either create a new branch from the source branch, or add an existing branch to the stack. - If you chose to create or add a branch you can switch to that branch to start work. +By default new branches are only created locally, you can either use the `--push` option or use the `stack push` command to push the branch to the remote. + ### Working within a stack Working within a stack is the same as working with Git as per normal, make your changes on the branch, commit them and push them to the remote. You likely have your own tooling and workflows for this, you can continue to use them. @@ -64,22 +66,22 @@ Once you've done some work on the first branch within the stack, at some point y The new branch will be created from the branch at the bottom of the stack and you can then switch to the branch if you would like to in order to make more changes. -### Updating a stack +By default new branches are only created locally, you can either use the `--push` option or use the `stack push` command to push the branch to the remote. + +### Syncing a stack After working on a stack of branches for a while, you might need to incorporate changes that have happened to your source branch from others. To do this: -- Run `stack update` -- Select the stack you wish to update -- Confirm the update +- Run `stack sync` +- Select the stack you wish to sync +- Confirm the sync Branches in the stack will be updated by: -- Fetching the latest changes from the remote for all branches in the stack, including the source branch. -- Merging from the source branch to the first branch in the stack. -- Pushing changes for the first branch to the remote. -- Merging from the first branch to the second branch in the stack (if one exists). -- Pushing changes for the second branch to the remote. -- Repeating this until all branches are updated. +- Fetching changes to the repository, pruning remote branches that no longer exist, the equivalent of running `git fetch --prune`. +- Pulling changes for all branches in the stack, including the source branch, the equivalent of running `stack pull`. +- Updating branches in order in the stack, the equivalent of running `stack update`. +- Pushing changes for all branches in the stack, the equivalent of running `stack push`. #### Rough edges @@ -286,9 +288,10 @@ OPTIONS: ### `stack pull` -```shell Pulls changes from the remote repository for a stack. +```shell + USAGE: stack pull [OPTIONS] @@ -303,9 +306,10 @@ OPTIONS: ### `stack push` -```shell Pushes changes to the remote repository for a stack. +```shell + USAGE: stack push [OPTIONS] @@ -319,6 +323,26 @@ OPTIONS: --max-batch-size The maximum number of branches to push changes for at once (default: 5) ``` +### `stack sync` + +Syncs a stack with the remote repository. Shortcut for `git fetch --prune`, `stack pull`, `stack update` and `stack push`. + +```shell + +USAGE: + stack sync [OPTIONS] + +OPTIONS: + -h, --help Prints help information + -v, --version Prints version information + --verbose Show verbose output + --working-dir The path to the directory containing the git repository. Defaults to the current directory + --dry-run Show what would happen without making any changes + -n, --name The name of the stack to update + -y, --yes Don't ask for confirmation before syncing the stack + --max-batch-size The maximum number of branches to push changes for at once (default: 5) +``` + ## GitHub commands ### `stack pr create` diff --git a/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs b/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs new file mode 100644 index 0000000..089a360 --- /dev/null +++ b/src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs @@ -0,0 +1,265 @@ +using FluentAssertions; +using NSubstitute; +using Stack.Commands; +using Stack.Config; +using Stack.Git; +using Stack.Tests.Helpers; +using Stack.Infrastructure; +using Stack.Commands.Helpers; +using Xunit.Abstractions; + +namespace Stack.Tests.Commands.Remote; + +public class SyncStackCommandHandlerTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public async Task WhenChangesExistOnTheSourceBranchOnTheRemote_PullsChanges_UpdatesBranches_AndPushesToRemote() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + using var repo = new TestGitRepositoryBuilder() + .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(sourceBranch, 5, b => b.PushToRemote()) + .Build(); + + var tipOfRemoteSourceBranch = repo.GetTipOfRemoteBranch(sourceBranch); + repo.GetCommitsReachableFromBranch(sourceBranch).Should().NotContain(tipOfRemoteSourceBranch); + + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = new TestOutputProvider(testOutputHelper); + var gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings); + var gitHubOperations = Substitute.For(); + var handler = new SyncStackCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); + + gitOperations.ChangeBranch(branch1); + + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); + var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, []); + var stacks = new List([stack1, stack2]); + stackConfig.Load().Returns(stacks); + + inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + inputProvider.Confirm(Questions.ConfirmSyncStack).Returns(true); + + // Act + await handler.Handle(new SyncStackCommandInputs(null, false, 5)); + + // Assert + repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfRemoteSourceBranch); + } + + [Fact] + public async Task WhenNameIsProvided_DoesNotAskForName_SyncsCorrectStack() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + using var repo = new TestGitRepositoryBuilder() + .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(sourceBranch, 5, b => b.PushToRemote()) + .Build(); + + var tipOfRemoteSourceBranch = repo.GetTipOfRemoteBranch(sourceBranch); + repo.GetCommitsReachableFromBranch(sourceBranch).Should().NotContain(tipOfRemoteSourceBranch); + + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = new TestOutputProvider(testOutputHelper); + var gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings); + var gitHubOperations = Substitute.For(); + var handler = new SyncStackCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); + + gitOperations.ChangeBranch(branch1); + + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); + var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, []); + var stacks = new List([stack1, stack2]); + stackConfig.Load().Returns(stacks); + + inputProvider.Confirm(Questions.ConfirmSyncStack).Returns(true); + + // Act + await handler.Handle(new SyncStackCommandInputs("Stack1", false, 5)); + + // Assert + repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfRemoteSourceBranch); + inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any()); + } + + [Fact] + public async Task WhenNoConfirmIsProvided_DoesNotAskForConfirmation() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + using var repo = new TestGitRepositoryBuilder() + .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(sourceBranch, 5, b => b.PushToRemote()) + .Build(); + + var tipOfRemoteSourceBranch = repo.GetTipOfRemoteBranch(sourceBranch); + repo.GetCommitsReachableFromBranch(sourceBranch).Should().NotContain(tipOfRemoteSourceBranch); + + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = new TestOutputProvider(testOutputHelper); + var gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings); + var gitHubOperations = Substitute.For(); + var handler = new SyncStackCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); + + gitOperations.ChangeBranch(branch1); + + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); + var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, []); + var stacks = new List([stack1, stack2]); + stackConfig.Load().Returns(stacks); + + inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + + // Act + await handler.Handle(new SyncStackCommandInputs(null, true, 5)); + + // Assert + repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfRemoteSourceBranch); + inputProvider.DidNotReceive().Confirm(Questions.ConfirmSyncStack); + } + + [Fact] + public async Task WhenNameIsProvided_ButStackDoesNotExist_Throws() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + using var repo = new TestGitRepositoryBuilder() + .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(sourceBranch, 5, b => b.PushToRemote()) + .Build(); + + var tipOfRemoteSourceBranch = repo.GetTipOfRemoteBranch(sourceBranch); + repo.GetCommitsReachableFromBranch(sourceBranch).Should().NotContain(tipOfRemoteSourceBranch); + + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = new TestOutputProvider(testOutputHelper); + var gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings); + var gitHubOperations = Substitute.For(); + var handler = new SyncStackCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); + + gitOperations.ChangeBranch(branch1); + + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); + var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, []); + var stacks = new List([stack1, stack2]); + stackConfig.Load().Returns(stacks); + + // Act and assert + var invalidStackName = Some.Name(); + await handler.Invoking(async h => await h.Handle(new SyncStackCommandInputs(invalidStackName, false, 5))) + .Should().ThrowAsync() + .WithMessage($"Stack '{invalidStackName}' not found."); + } + + [Fact] + public async Task WhenOnASpecificBranchInTheStack_TheSameBranchIsSetAsCurrentAfterTheSync() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + using var repo = new TestGitRepositoryBuilder() + .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(sourceBranch, 5, b => b.PushToRemote()) + .Build(); + + var tipOfRemoteSourceBranch = repo.GetTipOfRemoteBranch(sourceBranch); + repo.GetCommitsReachableFromBranch(sourceBranch).Should().NotContain(tipOfRemoteSourceBranch); + + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = new TestOutputProvider(testOutputHelper); + var gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings); + var gitHubOperations = Substitute.For(); + var handler = new SyncStackCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); + + // We are on a specific branch in the stack + gitOperations.ChangeBranch(branch1); + + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); + var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, []); + var stacks = new List([stack1, stack2]); + stackConfig.Load().Returns(stacks); + + inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + inputProvider.Confirm(Questions.ConfirmSyncStack).Returns(true); + + // Act + await handler.Handle(new SyncStackCommandInputs(null, false, 5)); + + // Assert + repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfRemoteSourceBranch); + gitOperations.GetCurrentBranch().Should().Be(branch1); + } + + [Fact] + public async Task WhenOnlyASingleStackExists_DoesNotAskForStackName_SyncsStack() + { + // Arrange + var sourceBranch = Some.BranchName(); + var branch1 = Some.BranchName(); + var branch2 = Some.BranchName(); + using var repo = new TestGitRepositoryBuilder() + .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote()) + .WithBranch(builder => builder.WithName(branch1).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(10).PushToRemote()) + .WithBranch(builder => builder.WithName(branch2).FromSourceBranch(branch1).WithNumberOfEmptyCommits(1).PushToRemote()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(sourceBranch, 5, b => b.PushToRemote()) + .Build(); + + var tipOfRemoteSourceBranch = repo.GetTipOfRemoteBranch(sourceBranch); + repo.GetCommitsReachableFromBranch(sourceBranch).Should().NotContain(tipOfRemoteSourceBranch); + + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = new TestOutputProvider(testOutputHelper); + var gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings); + var gitHubOperations = Substitute.For(); + var handler = new SyncStackCommandHandler(inputProvider, outputProvider, gitOperations, gitHubOperations, stackConfig); + + gitOperations.ChangeBranch(branch1); + + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1, branch2]); + var stacks = new List([stack1]); + stackConfig.Load().Returns(stacks); + + inputProvider.Confirm(Questions.ConfirmSyncStack).Returns(true); + + // Act + await handler.Handle(new SyncStackCommandInputs(null, false, 5)); + + // Assert + repo.GetCommitsReachableFromRemoteBranch(branch1).Should().Contain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromRemoteBranch(branch2).Should().Contain(tipOfRemoteSourceBranch); + gitOperations.GetCurrentBranch().Should().Be(branch1); + + inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any()); + } +} diff --git a/src/Stack/Commands/Helpers/Questions.cs b/src/Stack/Commands/Helpers/Questions.cs index 4c60cb6..7d6edea 100644 --- a/src/Stack/Commands/Helpers/Questions.cs +++ b/src/Stack/Commands/Helpers/Questions.cs @@ -11,6 +11,7 @@ public static class Questions public const string BranchName = "Branch name:"; public const string SelectSourceBranch = "Select a branch to start your stack from:"; public const string ConfirmUpdateStack = "Are you sure you want to update this stack?"; + public const string ConfirmSyncStack = "Are you sure you want to sync this stack with the remote repository?"; public const string ConfirmDeleteStack = "Are you sure you want to delete this stack?"; public const string ConfirmDeleteBranches = "Are you sure you want to delete these local branches?"; public const string ConfirmRemoveBranch = "Are you sure you want to remove this branch from the stack?"; diff --git a/src/Stack/Commands/Helpers/StackStatusHelpers.cs b/src/Stack/Commands/Helpers/StackStatusHelpers.cs index e53c465..e74cfa3 100644 --- a/src/Stack/Commands/Helpers/StackStatusHelpers.cs +++ b/src/Stack/Commands/Helpers/StackStatusHelpers.cs @@ -107,11 +107,18 @@ public static StackStatus GetStackStatus( string currentBranch, IOutputProvider outputProvider, IGitOperations gitOperations, - IGitHubOperations gitHubOperations) + IGitHubOperations gitHubOperations, + bool includePullRequestStatus = true) { - var statues = GetStackStatus([stack], currentBranch, outputProvider, gitOperations, gitHubOperations); - - return statues[stack]; + var statuses = GetStackStatus( + [stack], + currentBranch, + outputProvider, + gitOperations, + gitHubOperations, + includePullRequestStatus); + + return statuses[stack]; } public static void OutputStackStatus( diff --git a/src/Stack/Commands/Remote/SyncStackCommand.cs b/src/Stack/Commands/Remote/SyncStackCommand.cs new file mode 100644 index 0000000..3079ac9 --- /dev/null +++ b/src/Stack/Commands/Remote/SyncStackCommand.cs @@ -0,0 +1,190 @@ +using System.ComponentModel; +using Spectre.Console; +using Spectre.Console.Cli; +using Stack.Commands.Helpers; +using Stack.Config; +using Stack.Git; +using Stack.Infrastructure; + +namespace Stack.Commands; + +public class SyncStackCommandSettings : DryRunCommandSettingsBase +{ + [Description("The name of the stack to update.")] + [CommandOption("-n|--name")] + public string? Name { get; init; } + + [Description("Don't ask for confirmation before syncing the stack.")] + [CommandOption("-y|--yes")] + public bool NoConfirm { get; init; } + + [Description("The maximum number of branches to push changes for at once.")] + [CommandOption("--max-batch-size")] + [DefaultValue(5)] + public int MaxBatchSize { get; init; } = 5; +} + +public class SyncStackCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, SyncStackCommandSettings settings) + { + var console = AnsiConsole.Console; + var outputProvider = new ConsoleOutputProvider(console); + + var handler = new SyncStackCommandHandler( + new ConsoleInputProvider(console), + outputProvider, + new GitOperations(outputProvider, settings.GetGitOperationSettings()), + new GitHubOperations(outputProvider, settings.GetGitHubOperationSettings()), + new StackConfig()); + + await handler.Handle(new SyncStackCommandInputs(settings.Name, settings.NoConfirm, settings.MaxBatchSize)); + + return 0; + } +} + +public record SyncStackCommandInputs(string? Name, bool NoConfirm, int MaxBatchSize) +{ + public static SyncStackCommandInputs Empty => new(null, false, 5); +} + +public record SyncStackCommandResponse(); + +public class SyncStackCommandHandler( + IInputProvider inputProvider, + IOutputProvider outputProvider, + IGitOperations gitOperations, + IGitHubOperations gitHubOperations, + IStackConfig stackConfig) +{ + public async Task Handle(SyncStackCommandInputs inputs) + { + await Task.CompletedTask; + var stacks = stackConfig.Load(); + + var remoteUri = gitOperations.GetRemoteUri(); + + var stacksForRemote = stacks.Where(s => s.RemoteUri.Equals(remoteUri, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (stacksForRemote.Count == 0) + { + return new SyncStackCommandResponse(); + } + + var currentBranch = gitOperations.GetCurrentBranch(); + + var stack = inputProvider.SelectStack(outputProvider, inputs.Name, stacksForRemote, currentBranch); + + if (stack is null) + throw new InvalidOperationException($"Stack '{inputs.Name}' not found."); + + FetchChanges(); + + var status = StackStatusHelpers.GetStackStatus( + stack, + currentBranch, + outputProvider, + gitOperations, + gitHubOperations, + true); + + StackStatusHelpers.OutputStackStatus(stack, status, outputProvider); + + outputProvider.NewLine(); + + if (inputs.NoConfirm || inputProvider.Confirm(Questions.ConfirmSyncStack)) + { + outputProvider.Information($"Syncing stack {stack.Name.Stack()} with the remote repository"); + + PullChanges(stack); + + UpdateStack(stack, status); + + PushChanges(stack, inputs); + + if (stack.SourceBranch.Equals(currentBranch, StringComparison.InvariantCultureIgnoreCase) || + stack.Branches.Contains(currentBranch, StringComparer.OrdinalIgnoreCase)) + { + gitOperations.ChangeBranch(currentBranch); + } + } + + return new SyncStackCommandResponse(); + } + + private void FetchChanges() + { + outputProvider.Status("Fetching changes from remote repository", () => + { + gitOperations.Fetch(true); + }); + } + + private void PullChanges(Config.Stack stack) + { + var branchStatus = gitOperations.GetBranchStatuses([stack.SourceBranch, .. stack.Branches]); + + foreach (var branch in branchStatus.Where(b => b.Value.RemoteBranchExists)) + { + outputProvider.Information($"Pulling changes for {branch.Value.BranchName.Branch()} from remote"); + gitOperations.ChangeBranch(branch.Value.BranchName); + gitOperations.PullBranch(branch.Value.BranchName); + } + } + + private void UpdateStack(Config.Stack stack, StackStatus status) + { + void MergeFromSourceBranch(string branch, string sourceBranchName) + { + outputProvider.Information($"Merging {sourceBranchName.Branch()} into {branch.Branch()}"); + gitOperations.ChangeBranch(branch); + gitOperations.MergeFromLocalSourceBranch(sourceBranchName); + } + + var sourceBranch = stack.SourceBranch; + + foreach (var branch in stack.Branches) + { + var branchDetail = status.Branches[branch]; + + if (branchDetail.IsActive) + { + MergeFromSourceBranch(branch, sourceBranch); + sourceBranch = branch; + } + else + { + outputProvider.Debug($"Branch '{branch}' no longer exists on the remote repository or the associated pull request is no longer open. Skipping..."); + } + } + } + + private void PushChanges(Config.Stack stack, SyncStackCommandInputs inputs) + { + var branchStatus = gitOperations.GetBranchStatuses([.. stack.Branches]); + + var branchesThatHaveNotBeenPushedToRemote = branchStatus.Where(b => b.Value.RemoteTrackingBranchName is null).Select(b => b.Value.BranchName).ToList(); + + foreach (var branch in branchesThatHaveNotBeenPushedToRemote) + { + outputProvider.Information($"Pushing new branch {branch.Branch()} to remote"); + gitOperations.PushNewBranch(branch); + } + + var branchesInStackWithRemote = branchStatus.Where(b => b.Value.RemoteBranchExists).Select(b => b.Value.BranchName).ToList(); + + var branchGroupsToPush = branchesInStackWithRemote + .Select((b, i) => new { Index = i, Value = b }) + .GroupBy(b => b.Index / inputs.MaxBatchSize) + .Select(g => g.Select(b => b.Value).ToList()) + .ToList(); + + foreach (var branches in branchGroupsToPush) + { + outputProvider.Information($"Pushing changes for {string.Join(", ", branches.Select(b => b.Branch()))} to remote"); + + gitOperations.PushBranches([.. branches]); + } + } +} \ No newline at end of file diff --git a/src/Stack/Git/GitOperations.cs b/src/Stack/Git/GitOperations.cs index 176141b..5ebd84a 100644 --- a/src/Stack/Git/GitOperations.cs +++ b/src/Stack/Git/GitOperations.cs @@ -21,6 +21,7 @@ public interface IGitOperations void PushNewBranch(string branchName); void PushBranch(string branchName); void ChangeBranch(string branchName); + void Fetch(bool prune); void FetchBranches(string[] branches); void PullBranch(string branchName); void PushBranches(string[] branches); @@ -65,6 +66,11 @@ public void ChangeBranch(string branchName) ExecuteGitCommand($"checkout {branchName}"); } + public void Fetch(bool prune) + { + ExecuteGitCommand($"fetch origin {(prune ? "--prune" : string.Empty)}"); + } + public void FetchBranches(string[] branches) { ExecuteGitCommand($"fetch origin {string.Join(" ", branches)}"); diff --git a/src/Stack/Help/CommandNames.cs b/src/Stack/Help/CommandNames.cs index 07e6a9c..3f8b87f 100644 --- a/src/Stack/Help/CommandNames.cs +++ b/src/Stack/Help/CommandNames.cs @@ -18,4 +18,5 @@ public static class CommandNames public const string Remove = "remove"; public const string Pull = "pull"; public const string Push = "push"; + public const string Sync = "sync"; } diff --git a/src/Stack/Help/StackHelpProvider.cs b/src/Stack/Help/StackHelpProvider.cs index f422625..dc3eddc 100644 --- a/src/Stack/Help/StackHelpProvider.cs +++ b/src/Stack/Help/StackHelpProvider.cs @@ -11,7 +11,7 @@ public class StackHelpProvider(ICommandAppSettings settings) : HelpProvider(sett { { CommandGroups.Stack, [CommandNames.New, CommandNames.List, CommandNames.List, CommandNames.Delete, CommandNames.Status] }, { CommandGroups.Branch, [CommandNames.Switch, CommandNames.Update, CommandNames.Cleanup, CommandNames.Branch] }, - { CommandGroups.Remote, [CommandNames.Pull, CommandNames.Push] }, + { CommandGroups.Remote, [CommandNames.Pull, CommandNames.Push, CommandNames.Sync] }, { CommandGroups.GitHub, [CommandNames.Pr] }, { CommandGroups.Advanced, [CommandNames.Config] }, }; diff --git a/src/Stack/Program.cs b/src/Stack/Program.cs index 8d23bbe..720dda1 100644 --- a/src/Stack/Program.cs +++ b/src/Stack/Program.cs @@ -30,6 +30,7 @@ // Remote commands configure.AddCommand(CommandNames.Pull).WithDescription("Pulls changes from the remote repository for a stack."); configure.AddCommand(CommandNames.Push).WithDescription("Pushes changes to the remote repository for a stack."); + configure.AddCommand(CommandNames.Sync).WithDescription("Syncs a stack with the remote repository. Shortcut for `git fetch --prune`, `stack pull`, `stack update` and `stack push`."); // GitHub commands configure.AddBranch(CommandNames.Pr, pr =>