Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
215b859
Show local status by default in status command
geofflamrock Dec 20, 2024
8084392
Make showing status much better and quicker
geofflamrock Dec 20, 2024
b30c303
Remove `--minimal` option for now
geofflamrock Dec 20, 2024
1b318b6
Separate git branch status parsing
geofflamrock Dec 20, 2024
24e37be
Revert change to detecting remote branches for other commands
geofflamrock Dec 20, 2024
4410d0b
Cleanup
geofflamrock Dec 20, 2024
369aba4
Adds pull command
geofflamrock Dec 20, 2024
bba932f
Tiny improvement to output when getting status of single stack
geofflamrock Dec 20, 2024
6b573fb
wip pull tests
geofflamrock Dec 20, 2024
1925602
Add more tests
geofflamrock Dec 24, 2024
e0f25ec
Merge branch 'main' into stack-status-local-by-default
geofflamrock Dec 27, 2024
785d5cd
Merge branch 'stack-status-local-by-default' into add-pull-command
geofflamrock Dec 27, 2024
4df13a8
Improve getting tip of remote branch
geofflamrock Dec 27, 2024
4b00fa2
Merge branch 'main' into stack-status-local-by-default
geofflamrock Dec 27, 2024
294c403
Merge branch 'stack-status-local-by-default' into add-pull-command
geofflamrock Dec 27, 2024
e5f81ec
Make formatting less noisy
geofflamrock Dec 27, 2024
0d2ad4e
Merge branch 'stack-status-local-by-default' into add-pull-command
geofflamrock Dec 27, 2024
b1eac2e
Update help
geofflamrock Dec 30, 2024
a0a8d7d
Update help and readme
geofflamrock Dec 30, 2024
9c64936
Merge branch 'stack-status-local-by-default' into add-pull-command
geofflamrock Dec 30, 2024
009017d
Cleanup
geofflamrock Dec 30, 2024
6b8b7dc
Cleanup
geofflamrock Dec 30, 2024
8c39898
Merge branch 'stack-status-local-by-default' into add-pull-command
geofflamrock Dec 30, 2024
c22f3a0
Merge branch 'main' into add-pull-command
geofflamrock Dec 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading