diff --git a/src/Stack.Tests/Commands/Remote/ResetStackCommandHandlerTests.cs b/src/Stack.Tests/Commands/Remote/ResetStackCommandHandlerTests.cs new file mode 100644 index 00000000..32fde41d --- /dev/null +++ b/src/Stack.Tests/Commands/Remote/ResetStackCommandHandlerTests.cs @@ -0,0 +1,231 @@ +using FluentAssertions; +using Meziantou.Extensions.Logging.Xunit; +using NSubstitute; +using Stack.Commands; +using Stack.Commands.Helpers; +using Stack.Config; +using Stack.Git; +using Stack.Infrastructure; +using Stack.Infrastructure.Settings; +using Stack.Tests.Helpers; +using Xunit.Abstractions; + +namespace Stack.Tests.Commands.Remote; + +public class ResetStackCommandHandlerTests(ITestOutputHelper testOutputHelper) +{ + const string StackName = "Stack1"; + + [Fact] + public async Task WhenConfirmationIsDeclined_DoesNotResetStack() + { + // Arrange + var context = CreateContext(); + context.InputProvider + .Confirm(Questions.ConfirmResetStack, Arg.Any(), false) + .Returns(Task.FromResult(false)); + + // Act + await context.Handler.Handle(new ResetStackCommandInputs(StackName, false), CancellationToken.None); + + // Assert + await context.StackActions.DidNotReceive().ResetStack(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task WhenConfirmationIsAccepted_ResetsStack() + { + // Arrange + var context = CreateContext(); + context.InputProvider + .Confirm(Questions.ConfirmResetStack, Arg.Any(), false) + .Returns(Task.FromResult(true)); + + // Act + await context.Handler.Handle(new ResetStackCommandInputs(StackName, false), CancellationToken.None); + + // Assert + await context.StackActions.Received(1).ResetStack(Arg.Is(s => s.Name == StackName), Arg.Any()); + context.GitClient.Received(1).ChangeBranch(context.CurrentBranch); + await context.OutputProvider.Received().WriteLine(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task WhenConfirmOptionProvided_SkipsPromptAndResetsStack() + { + // Arrange + var context = CreateContext(); + + // Act + await context.Handler.Handle(new ResetStackCommandInputs(StackName, true), CancellationToken.None); + + // Assert + await context.StackActions.Received(1).ResetStack(Arg.Is(s => s.Name == StackName), Arg.Any()); + await context.InputProvider.DidNotReceive().Confirm(Questions.ConfirmResetStack, Arg.Any(), Arg.Any()); + await context.OutputProvider.DidNotReceive().WriteLine(Arg.Any(), Arg.Any()); + context.GitClient.Received(1).ChangeBranch(context.CurrentBranch); + } + + [Fact] + public async Task WhenStackNameNotProvided_WithMultipleStacks_AsksForSelection() + { + // Arrange + var otherStackName = "Stack2"; + var context = CreateContext((builder, remoteUri) => + { + var stack1Source = Some.BranchName(); + var stack1Branch = Some.BranchName(); + builder.WithStack(stack => stack + .WithName(StackName) + .WithRemoteUri(remoteUri) + .WithSourceBranch(stack1Source) + .WithBranch(b => b.WithName(stack1Branch))); + + var stack2Source = Some.BranchName(); + builder.WithStack(stack => stack + .WithName(otherStackName) + .WithRemoteUri(remoteUri) + .WithSourceBranch(stack2Source)); + }, currentBranchOverride: Some.BranchName()); + + context.InputProvider + .Select(Questions.SelectStack, Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(StackName)); + context.InputProvider + .Confirm(Questions.ConfirmResetStack, Arg.Any(), false) + .Returns(Task.FromResult(true)); + + // Act + await context.Handler.Handle(new ResetStackCommandInputs(null, false), CancellationToken.None); + + // Assert + await context.InputProvider.Received(1).Select(Questions.SelectStack, Arg.Any(), Arg.Any()); + await context.StackActions.Received(1).ResetStack(Arg.Is(s => s.Name == StackName), Arg.Any()); + } + + [Fact] + public async Task WhenStackNameNotProvided_WithSingleStack_DoesNotPromptForSelection() + { + // Arrange + var context = CreateContext(); + context.InputProvider + .Confirm(Questions.ConfirmResetStack, Arg.Any(), false) + .Returns(Task.FromResult(true)); + + // Act + await context.Handler.Handle(new ResetStackCommandInputs(null, false), CancellationToken.None); + + // Assert + await context.InputProvider.DidNotReceive().Select(Questions.SelectStack, Arg.Any(), Arg.Any()); + await context.StackActions.Received(1).ResetStack(Arg.Is(s => s.Name == StackName), Arg.Any()); + } + + private ResetStackCommandHandlerTestContext CreateContext( + Action? configureStacks = null, + string? currentBranchOverride = null) + { + var remoteUri = Some.HttpsUri().ToString(); + var stackConfigBuilder = new TestStackConfigBuilder(); + + if (configureStacks is null) + { + var sourceBranch = Some.BranchName(); + var featureBranch = Some.BranchName(); + stackConfigBuilder.WithStack(stack => stack + .WithName(StackName) + .WithRemoteUri(remoteUri) + .WithSourceBranch(sourceBranch) + .WithBranch(b => b.WithName(featureBranch))); + } + else + { + configureStacks(stackConfigBuilder, remoteUri); + } + + var stackConfig = stackConfigBuilder.Build(); + var stacks = stackConfig.Load().Stacks; + var currentBranch = currentBranchOverride ?? stacks.First().SourceBranch; + + var inputProvider = Substitute.For(); + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var outputProvider = Substitute.For(); + outputProvider.WriteLine(default!, default).ReturnsForAnyArgs(Task.CompletedTask); + outputProvider.WriteMessage(default!, default).ReturnsForAnyArgs(Task.CompletedTask); + outputProvider.WriteHeader(default!, default).ReturnsForAnyArgs(Task.CompletedTask); + outputProvider.WriteNewLine(default).ReturnsForAnyArgs(Task.CompletedTask); + + var gitClient = Substitute.For(); + gitClient.GetRemoteUri().Returns(remoteUri); + gitClient.GetCurrentBranch().Returns(currentBranch); + + gitClient.GetBranchStatuses(Arg.Any()).Returns(callInfo => + { + var dictionary = new Dictionary(); + foreach (var stack in stacks) + { + dictionary[stack.SourceBranch] = CreateBranchStatus(stack.SourceBranch, stack.SourceBranch.Equals(currentBranch, StringComparison.OrdinalIgnoreCase)); + foreach (var branch in stack.AllBranchNames) + { + dictionary[branch] = CreateBranchStatus(branch, branch.Equals(currentBranch, StringComparison.OrdinalIgnoreCase)); + } + } + + return dictionary; + }); + gitClient.CompareBranches(default!, default!).ReturnsForAnyArgs((0, 0)); + + var gitClientFactory = Substitute.For(); + gitClientFactory.Create(Arg.Any()).Returns(gitClient); + + var executionContext = new CliExecutionContext { WorkingDirectory = "/repo" }; + var gitHubClient = Substitute.For(); + var stackActions = Substitute.For(); + stackActions.ResetStack(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + + var handler = new ResetStackCommandHandler( + inputProvider, + logger, + displayProvider, + outputProvider, + gitClientFactory, + executionContext, + stackConfig, + gitHubClient, + stackActions); + + var defaultStack = stacks.FirstOrDefault(s => s.Name.Equals(StackName, StringComparison.OrdinalIgnoreCase)) ?? stacks.First(); + + return new ResetStackCommandHandlerTestContext( + handler, + inputProvider, + outputProvider, + gitClient, + stackActions, + stacks, + defaultStack, + currentBranch); + } + + private static GitBranchStatus CreateBranchStatus(string branchName, bool isCurrent) + { + return new GitBranchStatus( + branchName, + $"origin/{branchName}", + true, + isCurrent, + 0, + 0, + new Commit(Some.Sha(), "test")); + } + + private sealed record ResetStackCommandHandlerTestContext( + ResetStackCommandHandler Handler, + IInputProvider InputProvider, + IOutputProvider OutputProvider, + IGitClient GitClient, + IStackActions StackActions, + IReadOnlyList Stacks, + Config.Stack PrimaryStack, + string CurrentBranch); +} diff --git a/src/Stack.Tests/Integration/StackActionsTests.cs b/src/Stack.Tests/Integration/StackActionsTests.cs index 735802df..606c2945 100644 --- a/src/Stack.Tests/Integration/StackActionsTests.cs +++ b/src/Stack.Tests/Integration/StackActionsTests.cs @@ -544,6 +544,140 @@ public async Task UpdateStack_WhenUpdatingUsingRebase_AndFirstBranchWasSquashMer fourthBranchCommits.Should().NotContain(c => c.Sha == tipOfFirstBranch.Sha, "Fourth branch should not contain tip commit from first branch"); } + [Fact] + public async Task ResetStack_WhenBranchesHaveLocalCommits_ResetsToRemoteHeads() + { + // Arrange + var sourceBranch = Some.BranchName(); + var featureBranch = Some.BranchName(); + + using var repo = new TestGitRepositoryBuilder() + .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote().WithNumberOfEmptyCommits(1)) + .WithBranch(builder => builder.WithName(featureBranch).FromSourceBranch(sourceBranch).PushToRemote().WithNumberOfEmptyCommits(1)) + .Build(); + + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var gitHubClient = Substitute.For(); + var cliExecutionContext = new CliExecutionContext() { WorkingDirectory = repo.LocalDirectoryPath }; + var gitClientFactory = new TestGitClientFactory(testOutputHelper); + var conflictResolutionDetector = new ConflictResolutionDetector(); + + // Create local commits on source and feature branches without pushing + repo.ChangeBranch(sourceBranch); + var sourceFilePath = Path.Join(repo.LocalDirectoryPath, Some.Name()); + File.WriteAllText(sourceFilePath, "source local change"); + repo.Stage(Path.GetFileName(sourceFilePath)); + repo.Commit("Local change on source branch"); + + repo.ChangeBranch(featureBranch); + var featureFilePath = Path.Join(repo.LocalDirectoryPath, Some.Name()); + File.WriteAllText(featureFilePath, "feature local change"); + repo.Stage(Path.GetFileName(featureFilePath)); + repo.Commit("Local change on feature branch"); + + var remoteSourceTipBeforeReset = repo.GetTipOfRemoteBranch(sourceBranch); + var remoteFeatureTipBeforeReset = repo.GetTipOfRemoteBranch(featureBranch); + + repo.GetTipOfBranch(sourceBranch).Sha.Should().NotBe(remoteSourceTipBeforeReset.Sha, "source branch should diverge from remote before reset"); + repo.GetTipOfBranch(featureBranch).Sha.Should().NotBe(remoteFeatureTipBeforeReset.Sha, "feature branch should diverge from remote before reset"); + + var stack = new TestStackBuilder() + .WithSourceBranch(sourceBranch) + .WithBranch(b => b.WithName(featureBranch)) + .Build(); + + var stackActions = new StackActions(gitClientFactory, cliExecutionContext, gitHubClient, logger, displayProvider, conflictResolutionDetector); + + // Act + await stackActions.ResetStack(stack, CancellationToken.None); + + // Assert - local branches should match their remote tracking branches after reset + repo.GetTipOfBranch(sourceBranch).Sha.Should().Be(remoteSourceTipBeforeReset.Sha, "source branch should match remote after reset"); + repo.GetTipOfBranch(featureBranch).Sha.Should().Be(remoteFeatureTipBeforeReset.Sha, "feature branch should match remote after reset"); + } + + [Fact] + public async Task ResetStack_WhenBranchCheckedOutInWorktree_ResetsBranchUsingWorktreeClient() + { + // Arrange + var sourceBranch = Some.BranchName(); + var worktreeBranch = Some.BranchName(); + + using var repo = new TestGitRepositoryBuilder() + .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote().WithNumberOfEmptyCommits(1)) + .WithBranch(builder => builder.WithName(worktreeBranch).FromSourceBranch(sourceBranch).PushToRemote().WithNumberOfEmptyCommits(1)) + .Build(); + + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var gitHubClient = Substitute.For(); + var cliExecutionContext = new CliExecutionContext() { WorkingDirectory = repo.LocalDirectoryPath }; + var gitClientFactory = new TestGitClientFactory(testOutputHelper); + var conflictResolutionDetector = new ConflictResolutionDetector(); + + // Create a worktree for the branch and make a local commit there + repo.ChangeBranch(sourceBranch); + var worktree = repo.CreateWorktree(worktreeBranch); + var worktreeFile = Some.Name(); + repo.CommitInWorktree(worktree, worktreeFile, "worktree change", "Worktree local change"); + + var remoteTipBeforeReset = repo.GetTipOfRemoteBranch(worktreeBranch); + repo.GetTipOfBranch(worktreeBranch).Sha.Should().NotBe(remoteTipBeforeReset.Sha, "worktree branch should diverge from remote before reset"); + + var stack = new TestStackBuilder() + .WithSourceBranch(sourceBranch) + .WithBranch(b => b.WithName(worktreeBranch)) + .Build(); + + var stackActions = new StackActions(gitClientFactory, cliExecutionContext, gitHubClient, logger, displayProvider, conflictResolutionDetector); + + // Act + await stackActions.ResetStack(stack, CancellationToken.None); + + // Assert - local branch (including worktree) should match remote after reset + repo.GetTipOfBranch(worktreeBranch).Sha.Should().Be(remoteTipBeforeReset.Sha, "worktree branch should match remote after reset"); + } + + [Fact] + public async Task ResetStack_WhenBranchHasNoRemoteTrackingBranch_DoesNotModifyBranch() + { + // Arrange + var sourceBranch = Some.BranchName(); + var localOnlyBranch = Some.BranchName(); + + using var repo = new TestGitRepositoryBuilder() + .WithBranch(builder => builder.WithName(sourceBranch).PushToRemote().WithNumberOfEmptyCommits(1)) + .WithBranch(builder => builder.WithName(localOnlyBranch).FromSourceBranch(sourceBranch).WithNumberOfEmptyCommits(1)) // no push to remote + .Build(); + + var logger = XUnitLogger.CreateLogger(testOutputHelper); + var displayProvider = new TestDisplayProvider(testOutputHelper); + var gitHubClient = Substitute.For(); + var cliExecutionContext = new CliExecutionContext() { WorkingDirectory = repo.LocalDirectoryPath }; + var gitClientFactory = new TestGitClientFactory(testOutputHelper); + var conflictResolutionDetector = new ConflictResolutionDetector(); + + repo.ChangeBranch(localOnlyBranch); + var filePath = Path.Join(repo.LocalDirectoryPath, Some.Name()); + File.WriteAllText(filePath, "local-only change"); + repo.Stage(Path.GetFileName(filePath)); + var localCommit = repo.Commit("Local-only branch change"); + + var stack = new TestStackBuilder() + .WithSourceBranch(sourceBranch) + .WithBranch(b => b.WithName(localOnlyBranch)) + .Build(); + + var stackActions = new StackActions(gitClientFactory, cliExecutionContext, gitHubClient, logger, displayProvider, conflictResolutionDetector); + + // Act + await stackActions.ResetStack(stack, CancellationToken.None); + + // Assert - branch without remote tracking should remain unchanged + repo.GetTipOfBranch(localOnlyBranch).Sha.Should().Be(localCommit.Sha, "branch without remote tracking should not be modified by reset"); + } + [Fact] public void PushChanges_WhenChangesExistOnCurrentBranch_PushesChangesCorrectly() { diff --git a/src/Stack/Commands/Helpers/Questions.cs b/src/Stack/Commands/Helpers/Questions.cs index 5ec0d1aa..8a51451e 100644 --- a/src/Stack/Commands/Helpers/Questions.cs +++ b/src/Stack/Commands/Helpers/Questions.cs @@ -9,6 +9,7 @@ public static class Questions public const string SelectSourceBranch = "Select a branch to start your stack from:"; public const string SelectParentBranch = "Select a branch to add branch as child of:"; public const string ConfirmSyncStack = "Are you sure you want to sync this stack with the remote repository?"; + public const string ConfirmResetStack = "Are you sure you want to reset this stack to match the remote branches? Any local changes will be lost."; 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/StackActions.cs b/src/Stack/Commands/Helpers/StackActions.cs index b3a9994f..4b9646fa 100644 --- a/src/Stack/Commands/Helpers/StackActions.cs +++ b/src/Stack/Commands/Helpers/StackActions.cs @@ -12,6 +12,7 @@ public interface IStackActions void PullChanges(Config.Stack stack); void PushChanges(Config.Stack stack, int maxBatchSize, bool forceWithLease); Task UpdateStack(Config.Stack stack, UpdateStrategy strategy, CancellationToken cancellationToken); + Task ResetStack(Config.Stack stack, CancellationToken cancellationToken); } @@ -140,6 +141,70 @@ public void PushChanges( } } + public async Task ResetStack(Config.Stack stack, CancellationToken cancellationToken) + { + var gitClient = GetDefaultGitClient(); + var currentBranch = gitClient.GetCurrentBranch(); + + List allBranchesInStack = [stack.SourceBranch, .. stack.AllBranchNames]; + var branchStatuses = gitClient.GetBranchStatuses([.. allBranchesInStack]); + + foreach (var branch in allBranchesInStack) + { + if (!branchStatuses.TryGetValue(branch, out var status)) + { + logger.TraceMissingBranchStatus(branch); + continue; + } + + if (string.IsNullOrWhiteSpace(status.RemoteTrackingBranchName)) + { + logger.TraceSkippingResetBranchNoRemoteTracking(branch); + continue; + } + + if (!status.RemoteBranchExists) + { + logger.TraceSkippingResetBranchRemoteMissing(branch); + continue; + } + + var remoteTrackingBranch = status.RemoteTrackingBranchName; + + await displayProvider.DisplayStatusWithSuccess($"Resetting {branch} to {remoteTrackingBranch}", async ct => + { + await Task.CompletedTask; + + var branchGitClient = GetGitClientForBranch(branch, branchStatuses); + + var originalBranch = branchGitClient.GetCurrentBranch(); + var shouldRestoreOriginalBranch = status.WorktreePath is null && + !originalBranch.Equals(branch, StringComparison.OrdinalIgnoreCase); + + if (!originalBranch.Equals(branch, StringComparison.OrdinalIgnoreCase)) + { + branchGitClient.ChangeBranch(branch); + } + + logger.ResettingBranchToRemote(branch, remoteTrackingBranch!); + branchGitClient.ResetBranchToRemote(remoteTrackingBranch!); + + if (shouldRestoreOriginalBranch) + { + branchGitClient.ChangeBranch(originalBranch); + } + }, cancellationToken); + } + + // Ensure the default working directory returns to the original branch + var finalGitClient = GetDefaultGitClient(); + var finalCurrentBranch = finalGitClient.GetCurrentBranch(); + if (!finalCurrentBranch.Equals(currentBranch, StringComparison.OrdinalIgnoreCase)) + { + finalGitClient.ChangeBranch(currentBranch); + } + } + public async Task UpdateStack(Config.Stack stack, UpdateStrategy strategy, CancellationToken cancellationToken) { var gitClient = GetDefaultGitClient(); @@ -488,6 +553,18 @@ internal static partial class LoggerExtensionMethods [LoggerMessage(Level = LogLevel.Debug, Message = "Fetching changes for {Branches} from remote")] public static partial void FetchingNonCurrentBranches(this ILogger logger, string branches); + [LoggerMessage(Level = LogLevel.Trace, Message = "Branch {Branch} was not found locally. Skipping reset.")] + public static partial void TraceMissingBranchStatus(this ILogger logger, string branch); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Branch {Branch} does not have a remote tracking branch. Skipping reset.")] + public static partial void TraceSkippingResetBranchNoRemoteTracking(this ILogger logger, string branch); + + [LoggerMessage(Level = LogLevel.Trace, Message = "Remote tracking branch for {Branch} no longer exists. Skipping reset.")] + public static partial void TraceSkippingResetBranchRemoteMissing(this ILogger logger, string branch); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Resetting {Branch} to remote {RemoteTrackingBranch}")] + public static partial void ResettingBranchToRemote(this ILogger logger, string branch, string remoteTrackingBranch); + [LoggerMessage(Level = LogLevel.Debug, Message = "Pushing new branch {Branch} to remote")] public static partial void PushingNewBranch(this ILogger logger, string branch); diff --git a/src/Stack/Commands/Remote/ResetStackCommand.cs b/src/Stack/Commands/Remote/ResetStackCommand.cs new file mode 100644 index 00000000..6778be3b --- /dev/null +++ b/src/Stack/Commands/Remote/ResetStackCommand.cs @@ -0,0 +1,116 @@ +using System.CommandLine; +using Microsoft.Extensions.Logging; +using Stack.Commands.Helpers; +using Stack.Config; +using Stack.Git; +using Stack.Infrastructure; +using Stack.Infrastructure.Settings; + +namespace Stack.Commands; + +public class ResetStackCommand : Command +{ + private readonly ResetStackCommandHandler handler; + + public ResetStackCommand( + ResetStackCommandHandler handler, + CliExecutionContext executionContext, + IInputProvider inputProvider, + IOutputProvider outputProvider, + ILogger logger) + : base("reset", "Reset all branches in a stack to match their remote tracking branches.", executionContext, inputProvider, outputProvider, logger) + { + this.handler = handler; + Add(CommonOptions.Stack); + Add(CommonOptions.Confirm); + } + + protected override async Task Execute(ParseResult parseResult, CancellationToken cancellationToken) + { + await handler.Handle( + new ResetStackCommandInputs( + parseResult.GetValue(CommonOptions.Stack), + parseResult.GetValue(CommonOptions.Confirm)), + cancellationToken); + } +} + +public record ResetStackCommandInputs(string? Stack, bool Confirm); + +public class ResetStackCommandHandler( + IInputProvider inputProvider, + ILogger logger, + IDisplayProvider displayProvider, + IOutputProvider outputProvider, + IGitClientFactory gitClientFactory, + CliExecutionContext executionContext, + IStackConfig stackConfig, + IGitHubClient gitHubClient, + IStackActions stackActions) + : CommandHandlerBase +{ + public override async Task Handle(ResetStackCommandInputs inputs, CancellationToken cancellationToken) + { + var stackData = stackConfig.Load(); + + var gitClient = gitClientFactory.Create(executionContext.WorkingDirectory); + var remoteUri = gitClient.GetRemoteUri(); + var stacksForRemote = stackData.Stacks.Where(s => s.RemoteUri.Equals(remoteUri, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (stacksForRemote.Count == 0) + { + logger.NoStacksForRepository(); + return; + } + + var currentBranch = gitClient.GetCurrentBranch(); + + var stack = await inputProvider.SelectStack(logger, inputs.Stack, stacksForRemote, currentBranch, cancellationToken); + + if (stack is null) + throw new InvalidOperationException($"Stack '{inputs.Stack}' not found."); + + await displayProvider.DisplayStatusWithSuccess("Fetching changes from remote repository...", async ct => + { + await Task.CompletedTask; + gitClient.Fetch(false); + }, cancellationToken); + + if (!inputs.Confirm) + { + var status = await displayProvider.DisplayStatus("Checking stack status...", async ct => + { + await Task.CompletedTask; + return StackHelpers.GetStackStatus( + stack, + currentBranch, + logger, + gitClient, + gitHubClient, + false); + }, cancellationToken); + + await StackHelpers.OutputStackStatus(status, outputProvider, cancellationToken); + + if (!await inputProvider.Confirm(Questions.ConfirmResetStack, cancellationToken, false)) + { + return; + } + } + + await displayProvider.DisplayStatus("Resetting stack branches to remote...", async ct => + { + await stackActions.ResetStack(stack, ct); + }, cancellationToken); + + gitClient.ChangeBranch(currentBranch); + + logger.StackReset(stack.Name); + } +} + +internal static partial class LoggerExtensionMethods +{ + [LoggerMessage(3, LogLevel.Information, "Reset stack '{Stack}'.")] + public static partial void StackReset(this ILogger logger, string stack); +} diff --git a/src/Stack/Commands/StackRootCommand.cs b/src/Stack/Commands/StackRootCommand.cs index 04084f37..4a1532f5 100644 --- a/src/Stack/Commands/StackRootCommand.cs +++ b/src/Stack/Commands/StackRootCommand.cs @@ -14,6 +14,7 @@ public StackRootCommand( NewStackCommand newStackCommand, PullRequestsCommand pullRequestsCommand, PullStackCommand pullStackCommand, + ResetStackCommand resetStackCommand, PushStackCommand pushStackCommand, RenameStackCommand renameStackCommand, StackStatusCommand stackStatusCommand, @@ -29,6 +30,7 @@ public StackRootCommand( Add(newStackCommand); Add(pullRequestsCommand); Add(pullStackCommand); + Add(resetStackCommand); Add(pushStackCommand); Add(renameStackCommand); Add(stackStatusCommand); diff --git a/src/Stack/Git/GitClient.cs b/src/Stack/Git/GitClient.cs index 8954fdb8..33329853 100644 --- a/src/Stack/Git/GitClient.cs +++ b/src/Stack/Git/GitClient.cs @@ -49,6 +49,7 @@ public interface IGitClient void FetchBranchRefSpecs(string[] branchNames); void PushBranches(string[] branches, bool forceWithLease); void DeleteLocalBranch(string branchName); + void ResetBranchToRemote(string remoteTrackingBranchName); void MergeFromLocalSourceBranch(string sourceBranchName); void RebaseFromLocalSourceBranch(string sourceBranchName); @@ -247,6 +248,11 @@ public void DeleteLocalBranch(string branchName) ExecuteGitCommand($"branch -D {branchName}"); } + public void ResetBranchToRemote(string remoteTrackingBranchName) + { + ExecuteGitCommand($"reset --hard {remoteTrackingBranchName}"); + } + public void MergeFromLocalSourceBranch(string sourceBranchName) { ExecuteGitCommand($"merge {sourceBranchName}", false, (info, result) => diff --git a/src/Stack/Infrastructure/HostApplicationBuilderExtensions.cs b/src/Stack/Infrastructure/HostApplicationBuilderExtensions.cs index d40e493e..aad7107c 100644 --- a/src/Stack/Infrastructure/HostApplicationBuilderExtensions.cs +++ b/src/Stack/Infrastructure/HostApplicationBuilderExtensions.cs @@ -114,6 +114,7 @@ private static void RegisterCommandHandlers(IServiceCollection services) services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -140,6 +141,7 @@ private static void RegisterCommands(IServiceCollection services) services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient();