Skip to content

Commit

Permalink
Adds stack pull command (#160)
Browse files Browse the repository at this point in the history
  • Loading branch information
geofflamrock authored Dec 30, 2024
1 parent e53ba99 commit d5433bf
Show file tree
Hide file tree
Showing 8 changed files with 316 additions and 3 deletions.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
184 changes: 184 additions & 0 deletions src/Stack.Tests/Commands/Remote/PullStackCommandHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -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<IStackConfig>();
var inputProvider = Substitute.For<IInputProvider>();
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<Config.Stack>([stack1, stack2]);
stackConfig.Load().Returns(stacks);

inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).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<IStackConfig>();
var inputProvider = Substitute.For<IInputProvider>();
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<Config.Stack>([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<string[]>());
}

[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<IStackConfig>();
var inputProvider = Substitute.For<IInputProvider>();
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<Config.Stack>([stack1, stack2]);
stackConfig.Load().Returns(stacks);

inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).Returns("Stack1");

// Act and assert
var invalidStackName = Some.Name();
await handler.Invoking(async h => await h.Handle(new PullStackCommandInputs(invalidStackName)))
.Should().ThrowAsync<InvalidOperationException>()
.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<IStackConfig>();
var inputProvider = Substitute.For<IInputProvider>();
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<Config.Stack>([stack1, stack2]);
stackConfig.Load().Returns(stacks);

inputProvider.Select(Questions.SelectStack, Arg.Any<string[]>()).Returns("Stack1");

// Act
await handler.Handle(new PullStackCommandInputs(null));

// Assert
repo.GetCommitsReachableFromBranch(branch2).Should().NotContain(tipOfRemoteBranch2);
}
}
34 changes: 31 additions & 3 deletions src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ private static LibGit2Sharp.Commit CreateEmptyCommit(Repository repository, Bran

public class CommitBuilder
{
string? branchName;
Func<Repository, string>? getBranchName;
string? message;
string? authorName;
string? authorEmail;
Expand All @@ -78,7 +78,13 @@ public class CommitBuilder

public CommitBuilder OnBranch(string branch)
{
this.branchName = branch;
getBranchName = (_) => branch;
return this;
}

public CommitBuilder OnBranch(Func<Repository, string> getBranchName)
{
this.getBranchName = getBranchName;
return this;
}

Expand Down Expand Up @@ -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];
}

Expand Down Expand Up @@ -201,6 +208,20 @@ public TestGitRepositoryBuilder WithNumberOfEmptyCommits(Action<CommitBuilder> c
return this;
}

public TestGitRepositoryBuilder WithNumberOfEmptyCommitsOnRemoteTrackingBranchOf(string branch, int number, Action<CommitBuilder> 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");
Expand Down Expand Up @@ -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);
Expand Down
76 changes: 76 additions & 0 deletions src/Stack/Commands/Remote/PullStackCommand.cs
Original file line number Diff line number Diff line change
@@ -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<PullStackCommandSettings>
{
public override async Task<int> 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);
}
}
1 change: 1 addition & 0 deletions src/Stack/Help/CommandGroups.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
1 change: 1 addition & 0 deletions src/Stack/Help/CommandNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
1 change: 1 addition & 0 deletions src/Stack/Help/StackHelpProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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] },
};
Expand Down
3 changes: 3 additions & 0 deletions src/Stack/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
branch.AddCommand<RemoveBranchCommand>(CommandNames.Remove).WithDescription("Removes a branch from a stack.");
});

// Remote commands
configure.AddCommand<PullStackCommand>(CommandNames.Pull).WithDescription("Pulls changes from the remote repository for a stack.");

// GitHub commands
configure.AddBranch(CommandNames.Pr, pr =>
{
Expand Down

0 comments on commit d5433bf

Please sign in to comment.