diff --git a/README.md b/README.md index 52658a2b..d50cca2b 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,25 @@ OPTIONS: -f, --force Force removing the branch without prompting ``` +## Remote commands + +### `stack pull` + +```shell +Pulls changes from the remote repository for a stack. + +USAGE: + stack pull [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 pull changes from the remote for +``` + ## GitHub commands ### `stack pr create` diff --git a/src/Stack.Tests/Commands/Remote/PullStackCommandHandlerTests.cs b/src/Stack.Tests/Commands/Remote/PullStackCommandHandlerTests.cs new file mode 100644 index 00000000..a16ca6a1 --- /dev/null +++ b/src/Stack.Tests/Commands/Remote/PullStackCommandHandlerTests.cs @@ -0,0 +1,184 @@ +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 PullStackCommandHandlerTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public async Task WhenChangesExistOnTheRemote_TheyArePulledDownToTheLocalBranch() + { + // 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()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(branch1, 3, b => b.PushToRemote()) + .Build(); + + var tipOfRemoteSourceBranch = repo.GetTipOfRemoteBranch(sourceBranch); + var tipOfRemoteBranch1 = repo.GetTipOfRemoteBranch(branch1); + + repo.GetCommitsReachableFromBranch(sourceBranch).Should().NotContain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromBranch(branch1).Should().NotContain(tipOfRemoteBranch1); + + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = new TestOutputProvider(testOutputHelper); + var gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings); + var handler = new PullStackCommandHandler(inputProvider, outputProvider, gitOperations, 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 PullStackCommandInputs(null)); + + // Assert + repo.GetCommitsReachableFromBranch(sourceBranch).Should().Contain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromBranch(branch1).Should().Contain(tipOfRemoteBranch1); + } + + [Fact] + public async Task WhenNameIsProvided_DoesNotAskForName_PullsChangesFromRemoteForBranchesInStack() + { + // 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()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(branch1, 3, b => b.PushToRemote()) + .Build(); + + var tipOfRemoteSourceBranch = repo.GetTipOfRemoteBranch(sourceBranch); + var tipOfRemoteBranch1 = repo.GetTipOfRemoteBranch(branch1); + + repo.GetCommitsReachableFromBranch(sourceBranch).Should().NotContain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromBranch(branch1).Should().NotContain(tipOfRemoteBranch1); + + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = new TestOutputProvider(testOutputHelper); + var gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings); + var handler = new PullStackCommandHandler(inputProvider, outputProvider, gitOperations, 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 + await handler.Handle(new PullStackCommandInputs("Stack1")); + + // Assert + repo.GetCommitsReachableFromBranch(sourceBranch).Should().Contain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromBranch(branch1).Should().Contain(tipOfRemoteBranch1); + inputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any()); + } + + [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()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(branch1, 3, b => b.PushToRemote()) + .Build(); + + var tipOfRemoteSourceBranch = repo.GetTipOfRemoteBranch(sourceBranch); + var tipOfRemoteBranch1 = repo.GetTipOfRemoteBranch(branch1); + + repo.GetCommitsReachableFromBranch(sourceBranch).Should().NotContain(tipOfRemoteSourceBranch); + repo.GetCommitsReachableFromBranch(branch1).Should().NotContain(tipOfRemoteBranch1); + + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = new TestOutputProvider(testOutputHelper); + var gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings); + var handler = new PullStackCommandHandler(inputProvider, outputProvider, gitOperations, 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 and assert + var invalidStackName = Some.Name(); + await handler.Invoking(async h => await h.Handle(new PullStackCommandInputs(invalidStackName))) + .Should().ThrowAsync() + .WithMessage($"Stack '{invalidStackName}' not found."); + } + + [Fact] + public async Task WhenChangesExistOnTheRemote_ForABranchThatIsNotInTheStack_TheyAreNotPulledDownToTheLocalBranch() + { + // 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()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(branch1, 3, b => b.PushToRemote()) + .WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(branch2, 3, b => b.PushToRemote()) + .Build(); + + var tipOfRemoteBranch2 = repo.GetTipOfRemoteBranch(branch2); + + var stackConfig = Substitute.For(); + var inputProvider = Substitute.For(); + var outputProvider = new TestOutputProvider(testOutputHelper); + var gitOperations = new GitOperations(outputProvider, repo.GitOperationSettings); + var handler = new PullStackCommandHandler(inputProvider, outputProvider, gitOperations, stackConfig); + + gitOperations.ChangeBranch(branch1); + + var stack1 = new Config.Stack("Stack1", repo.RemoteUri, sourceBranch, [branch1]); + var stack2 = new Config.Stack("Stack2", repo.RemoteUri, sourceBranch, [branch2]); + var stacks = new List([stack1, stack2]); + stackConfig.Load().Returns(stacks); + + inputProvider.Select(Questions.SelectStack, Arg.Any()).Returns("Stack1"); + + // Act + await handler.Handle(new PullStackCommandInputs(null)); + + // Assert + repo.GetCommitsReachableFromBranch(branch2).Should().NotContain(tipOfRemoteBranch2); + } +} diff --git a/src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs b/src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs index 96a4944f..eab413fa 100644 --- a/src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs +++ b/src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs @@ -67,7 +67,7 @@ private static LibGit2Sharp.Commit CreateEmptyCommit(Repository repository, Bran public class CommitBuilder { - string? branchName; + Func? getBranchName; string? message; string? authorName; string? authorEmail; @@ -78,7 +78,13 @@ public class CommitBuilder public CommitBuilder OnBranch(string branch) { - this.branchName = branch; + getBranchName = (_) => branch; + return this; + } + + public CommitBuilder OnBranch(Func getBranchName) + { + this.getBranchName = getBranchName; return this; } @@ -118,8 +124,9 @@ public void Build(Repository repository) { Branch? branch = null; - if (branchName is not null) + if (getBranchName is not null) { + var branchName = getBranchName(repository); branch = repository.Branches[branchName]; } @@ -201,6 +208,20 @@ public TestGitRepositoryBuilder WithNumberOfEmptyCommits(Action c return this; } + public TestGitRepositoryBuilder WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(string branch, int number, Action commitBuilder) + { + for (var i = 0; i < number; i++) + { + commitBuilders.Add(b => + { + commitBuilder(b); + b.OnBranch(r => r.Branches[branch].TrackedBranch.CanonicalName); + b.AllowEmptyCommit(); + }); + } + return this; + } + public TestGitRepository Build() { var remote = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N"), ".git"); @@ -262,6 +283,13 @@ public LibGit2Sharp.Commit GetTipOfBranch(string branchName) return [.. LocalRepository.Branches[branchName].Commits]; } + public LibGit2Sharp.Commit GetTipOfRemoteBranch(string branchName) + { + var branch = LocalRepository.Branches[branchName]; + var remoteBranchName = branch.TrackedBranch.CanonicalName; + return LocalRepository.Branches[remoteBranchName].Tip; + } + public void Dispose() { GC.SuppressFinalize(this); diff --git a/src/Stack/Commands/Remote/PullStackCommand.cs b/src/Stack/Commands/Remote/PullStackCommand.cs new file mode 100644 index 00000000..6207183b --- /dev/null +++ b/src/Stack/Commands/Remote/PullStackCommand.cs @@ -0,0 +1,76 @@ +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 PullStackCommandSettings : DryRunCommandSettingsBase +{ + [Description("The name of the stack to pull changes from the remote for.")] + [CommandOption("-n|--name")] + public string? Name { get; init; } +} + +public class PullStackCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, PullStackCommandSettings settings) + { + var console = AnsiConsole.Console; + var outputProvider = new ConsoleOutputProvider(console); + + var handler = new PullStackCommandHandler( + new ConsoleInputProvider(console), + outputProvider, + new GitOperations(outputProvider, settings.GetGitOperationSettings()), + new StackConfig()); + + await handler.Handle(new PullStackCommandInputs(settings.Name)); + + return 0; + } +} + +public record PullStackCommandInputs(string? Name); +public class PullStackCommandHandler( + IInputProvider inputProvider, + IOutputProvider outputProvider, + IGitOperations gitOperations, + IStackConfig stackConfig) +{ + public async Task Handle(PullStackCommandInputs 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) + { + outputProvider.Information("No stacks found for current repository."); + return; + } + + 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."); + + 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); + } + + gitOperations.ChangeBranch(currentBranch); + } +} diff --git a/src/Stack/Help/CommandGroups.cs b/src/Stack/Help/CommandGroups.cs index 7c1e98c4..c3028c78 100644 --- a/src/Stack/Help/CommandGroups.cs +++ b/src/Stack/Help/CommandGroups.cs @@ -4,6 +4,7 @@ public static class CommandGroups { public const string Stack = "Stack"; public const string Branch = "Branch"; + public const string Remote = "Remote"; public const string GitHub = "GitHub"; public const string Advanced = "Advanced"; } diff --git a/src/Stack/Help/CommandNames.cs b/src/Stack/Help/CommandNames.cs index 351adbfe..3966ed2d 100644 --- a/src/Stack/Help/CommandNames.cs +++ b/src/Stack/Help/CommandNames.cs @@ -16,4 +16,5 @@ public static class CommandNames public const string Create = "create"; public const string Open = "open"; public const string Remove = "remove"; + public const string Pull = "pull"; } diff --git a/src/Stack/Help/StackHelpProvider.cs b/src/Stack/Help/StackHelpProvider.cs index 84cf6296..1a5b3316 100644 --- a/src/Stack/Help/StackHelpProvider.cs +++ b/src/Stack/Help/StackHelpProvider.cs @@ -11,6 +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] }, { CommandGroups.GitHub, [CommandNames.Pr] }, { CommandGroups.Advanced, [CommandNames.Config] }, }; diff --git a/src/Stack/Program.cs b/src/Stack/Program.cs index 34f82027..b1898fdd 100644 --- a/src/Stack/Program.cs +++ b/src/Stack/Program.cs @@ -27,6 +27,9 @@ branch.AddCommand(CommandNames.Remove).WithDescription("Removes a branch from a stack."); }); + // Remote commands + configure.AddCommand(CommandNames.Pull).WithDescription("Pulls changes from the remote repository for a stack."); + // GitHub commands configure.AddBranch(CommandNames.Pr, pr => {