Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
460 changes: 452 additions & 8 deletions src/Stack.Tests/Commands/Helpers/StackActionsTests.cs

Large diffs are not rendered by default.

437 changes: 0 additions & 437 deletions src/Stack.Tests/Commands/Helpers/StackHelpersTests.cs

This file was deleted.

580 changes: 251 additions & 329 deletions src/Stack.Tests/Commands/Remote/SyncStackCommandHandlerTests.cs

Large diffs are not rendered by default.

497 changes: 127 additions & 370 deletions src/Stack.Tests/Commands/Stack/UpdateStackCommandHandlerTests.cs

Large diffs are not rendered by default.

249 changes: 248 additions & 1 deletion src/Stack/Commands/Helpers/StackActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ public interface IStackActions
{
void PullChanges(Config.Stack stack);
void PushChanges(Config.Stack stack, int maxBatchSize, bool forceWithLease);
void UpdateStack(Config.Stack stack, UpdateStrategy strategy);
}

public class StackActions(IGitClient gitClient, ILogger logger) : IStackActions
public class StackActions(IGitClient gitClient, IGitHubClient gitHubClient, IInputProvider inputProvider, ILogger logger) : IStackActions
{
public void PullChanges(Config.Stack stack)
{
Expand Down Expand Up @@ -65,5 +66,251 @@ public void PushChanges(
gitClient.PushBranches([.. branches], forceWithLease);
}
}

public void UpdateStack(Config.Stack stack, UpdateStrategy strategy)
{
var currentBranch = gitClient.GetCurrentBranch();

var status = StackHelpers.GetStackStatus(
stack,
currentBranch,
logger,
gitClient,
gitHubClient,
true);

if (strategy == UpdateStrategy.Rebase)
{
UpdateStackUsingRebase(stack, status);
}
else
{
UpdateStackUsingMerge(stack, status);
}
}

private void UpdateStackUsingMerge(
Config.Stack stack,
StackStatus status)
{
logger.Information($"Updating stack {status.Name.Stack()} using merge...");

var allBranchLines = status.GetAllBranchLines();

foreach (var branchLine in allBranchLines)
{
UpdateBranchLineUsingMerge(branchLine, status.SourceBranch);
}
}

public void UpdateBranchLineUsingMerge(
Copy link

Copilot AI Aug 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UpdateBranchLineUsingMerge method is marked as public but appears to be an internal implementation detail. Consider making it private to reduce the public API surface.

Suggested change
public void UpdateBranchLineUsingMerge(
private void UpdateBranchLineUsingMerge(

Copilot uses AI. Check for mistakes.
List<BranchDetail> branchLine,
BranchDetailBase parentBranch)
{
var currentParentBranch = parentBranch;
foreach (var branch in branchLine)
{
if (branch.IsActive)
{
MergeFromSourceBranch(branch.Name, currentParentBranch.Name);
currentParentBranch = branch;
}
else
{
logger.Debug($"Branch '{branch}' no longer exists on the remote repository or the associated pull request is no longer open. Skipping...");
}
}
}

private void MergeFromSourceBranch(string branch, string sourceBranchName)
{
logger.Information($"Merging {sourceBranchName.Branch()} into {branch.Branch()}");
gitClient.ChangeBranch(branch);

try
{
gitClient.MergeFromLocalSourceBranch(sourceBranchName);
}
catch (ConflictException)
{
var action = inputProvider.Select(
Questions.ContinueOrAbortMerge,
[MergeConflictAction.Continue, MergeConflictAction.Abort],
a => a switch
{
MergeConflictAction.Continue => "Continue",
MergeConflictAction.Abort => "Abort",
_ => throw new InvalidOperationException("Invalid merge conflict action.")
}); ;

if (action == MergeConflictAction.Abort)
{
gitClient.AbortMerge();
throw new Exception("Merge aborted due to conflicts.");
}
}
}

private void UpdateStackUsingRebase(
Config.Stack stack,
StackStatus status)
{
logger.Information($"Updating stack {status.Name.Stack()} using rebase...");

var allBranchLines = status.GetAllBranchLines();

foreach (var branchLine in allBranchLines)
{
UpdateBranchLineUsingRebase(status, branchLine);
}
}

private void UpdateBranchLineUsingRebase(StackStatus status, List<BranchDetail> branchLine)
{
//
// When rebasing the stack, we'll use `git rebase --update-refs` from the
// lowest branch in the stack to pick up changes throughout all branches in the stack.
// Because there could be changes in any branch in the stack that aren't in the ones
// below it, we'll repeat this all the way from the bottom to the top of the stack to
// ensure that all changes are applied in the correct order.
//
// For example if we have a stack like this:
// main -> feature1 -> feature2 -> feature3
//
// We'll rebase feature3 onto feature2, then feature3 onto feature1, and finally feature3 onto main.
//
// In addition to this, if the stack is in a state where one of the branches has been squash merged
// into the source branch, we'll want to rebase onto that branch directly using
// `git rebase --onto {sourceBranch} {oldParentBranch}` to ensure that the changes are
// applied correctly and to try and avoid merge conflicts during the rebase.
//
// For example if we have a stack like this:
// main
// -> feature1 (deleted in remote): Squash merged into main
// -> feature2
// -> feature3
//
// We'll rebase feature3 onto feature2 using a normal `git rebase feature2 --update-refs`,
// then feature3 onto main using `git rebase --onto main feature1 --update-refs` to replay
// all commits from feature3 (and therefore from feature2) on top of the latest commits of main
// which will include the squashed commit.
//
logger.Information($"Rebasing stack {status.Name.Stack()} for branch line: {status.SourceBranch.Name.Branch()} --> {string.Join(" -> ", branchLine.Select(b => b.Name.Branch()))}");

BranchDetail? lowestActionBranch = null;
foreach (var branch in branchLine)
{
if (branch.IsActive)
{
lowestActionBranch = branch;
}
}

if (lowestActionBranch is null)
{
logger.Warning("No active branches found in the stack.");
return;
}

string? branchToRebaseFrom = lowestActionBranch.Name;
string? lowestInactiveBranchToReParentFrom = null;

List<BranchDetailBase> branchesToRebaseOnto = [.. branchLine];
branchesToRebaseOnto.Reverse();
branchesToRebaseOnto.Remove(lowestActionBranch);
branchesToRebaseOnto.Add(status.SourceBranch);

List<BranchDetailBase> allBranchesInStack = [status.SourceBranch, .. branchLine];

foreach (var branchToRebaseOnto in branchesToRebaseOnto)
{
if (branchToRebaseOnto.IsActive)
{
var lowestInactiveBranchToReParentFromDetail = lowestInactiveBranchToReParentFrom is not null ? allBranchesInStack.First(b => b.Name == lowestInactiveBranchToReParentFrom) : null;
var shouldRebaseOntoParent = lowestInactiveBranchToReParentFromDetail is not null && lowestInactiveBranchToReParentFromDetail.Exists;

if (shouldRebaseOntoParent)
{
shouldRebaseOntoParent = gitClient.IsAncestor(branchToRebaseFrom, lowestInactiveBranchToReParentFrom!);
}

if (shouldRebaseOntoParent)
{
RebaseOntoNewParent(branchToRebaseFrom, branchToRebaseOnto.Name, lowestInactiveBranchToReParentFrom!);
}
else
{
RebaseFromSourceBranch(branchToRebaseFrom, branchToRebaseOnto.Name);
}
}
else if (lowestInactiveBranchToReParentFrom is null)
{
lowestInactiveBranchToReParentFrom = branchToRebaseOnto.Name;
}
}
}

private void RebaseFromSourceBranch(string branch, string sourceBranchName)
{
logger.Information($"Rebasing {branch.Branch()} onto {sourceBranchName.Branch()}");
gitClient.ChangeBranch(branch);

try
{
gitClient.RebaseFromLocalSourceBranch(sourceBranchName);
}
catch (ConflictException)
{
HandleConflictsDuringRebase();
}
}

private void RebaseOntoNewParent(
string branch,
string newParentBranchName,
string oldParentBranchName)
{
logger.Information($"Rebasing {branch.Branch()} onto new parent {newParentBranchName.Branch()}");
gitClient.ChangeBranch(branch);

try
{
gitClient.RebaseOntoNewParent(newParentBranchName, oldParentBranchName);
}
catch (ConflictException)
{
HandleConflictsDuringRebase();
}
}

private void HandleConflictsDuringRebase()
{
var action = inputProvider.Select(
Questions.ContinueOrAbortRebase,
[MergeConflictAction.Continue, MergeConflictAction.Abort],
a => a switch
{
MergeConflictAction.Continue => "Continue",
MergeConflictAction.Abort => "Abort",
_ => throw new InvalidOperationException("Invalid rebase conflict action.")
});

if (action == MergeConflictAction.Abort)
{
gitClient.AbortRebase();
throw new Exception("Rebase aborted due to conflicts.");
}
else if (action == MergeConflictAction.Continue)
{
try
{
gitClient.ContinueRebase();
}
catch (ConflictException)
{
HandleConflictsDuringRebase();
}
}
}
}
}
23 changes: 23 additions & 0 deletions src/Stack/Commands/Helpers/StackHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,29 @@ public static UpdateStrategy UpdateStack(
return strategy.Value;
}

public static UpdateStrategy GetUpdateStrategy(UpdateStrategy? specificUpdateStrategy, IGitClient gitClient, IInputProvider inputProvider, ILogger logger)
{
if (specificUpdateStrategy is not null)
{
return specificUpdateStrategy.Value;
}

var strategyFromConfig = GetUpdateStrategyConfigValue(gitClient);

if (strategyFromConfig is not null)
{
return strategyFromConfig.Value;
}

var strategy = inputProvider.Select(
Questions.SelectUpdateStrategy,
[UpdateStrategy.Merge, UpdateStrategy.Rebase]);

logger.Information($"{Questions.SelectUpdateStrategy} {strategy}");

return strategy;
}

public static void UpdateStackUsingMerge(
Config.Stack stack,
StackStatus status,
Expand Down
3 changes: 2 additions & 1 deletion src/Stack/Commands/Remote/PullStackCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ public class PullStackCommand : Command
protected override async Task Execute(ParseResult parseResult, CancellationToken cancellationToken)
{
var gitClient = new GitClient(StdErrLogger, new GitClientSettings(Verbose, WorkingDirectory));
var gitHubClient = new GitHubClient(StdErrLogger, new GitHubClientSettings(Verbose, WorkingDirectory));

var handler = new PullStackCommandHandler(
InputProvider,
StdErrLogger,
gitClient,
new FileStackConfig(),
new StackActions(gitClient, StdErrLogger));
new StackActions(gitClient, gitHubClient, InputProvider, StdErrLogger));

await handler.Handle(new PullStackCommandInputs(
parseResult.GetValue(CommonOptions.Stack)));
Expand Down
4 changes: 4 additions & 0 deletions src/Stack/Commands/Remote/PushStackCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,17 @@ public class PushStackCommand : Command
protected override async Task Execute(ParseResult parseResult, CancellationToken cancellationToken)
{
var gitClient = new GitClient(StdErrLogger, new GitClientSettings(Verbose, WorkingDirectory));
var gitHubClient = new GitHubClient(StdErrLogger, new GitHubClientSettings(Verbose, WorkingDirectory));

var handler = new PushStackCommandHandler(
InputProvider,
StdErrLogger,
gitClient,
new FileStackConfig(),
new StackActions(
gitClient,
gitHubClient,
InputProvider,
StdErrLogger));

await handler.Handle(new PushStackCommandInputs(
Expand Down
16 changes: 8 additions & 8 deletions src/Stack/Commands/Remote/SyncStackCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ public class SyncStackCommand : Command
protected override async Task Execute(ParseResult parseResult, CancellationToken cancellationToken)
{
var gitClient = new GitClient(StdErrLogger, new GitClientSettings(Verbose, WorkingDirectory));
var gitHubClient = new GitHubClient(StdErrLogger, new GitHubClientSettings(Verbose, WorkingDirectory));

var handler = new SyncStackCommandHandler(
InputProvider,
StdErrLogger,
gitClient,
new GitHubClient(StdErrLogger, new GitHubClientSettings(Verbose, WorkingDirectory)),
gitHubClient,
new FileStackConfig(),
new StackActions(gitClient, StdErrLogger));
new StackActions(gitClient, gitHubClient, InputProvider, StdErrLogger));

await handler.Handle(new SyncStackCommandInputs(
parseResult.GetValue(CommonOptions.Stack),
Expand Down Expand Up @@ -109,13 +111,11 @@ public override async Task Handle(SyncStackCommandInputs inputs)

stackActions.PullChanges(stack);

var updateStrategy = StackHelpers.UpdateStack(
stack,
status,
var updateStrategy = StackHelpers.GetUpdateStrategy(
inputs.Merge == true ? UpdateStrategy.Merge : inputs.Rebase == true ? UpdateStrategy.Rebase : null,
gitClient,
inputProvider,
logger);
gitClient, inputProvider, logger);

stackActions.UpdateStack(stack, updateStrategy);

var forceWithLease = updateStrategy == UpdateStrategy.Rebase;

Expand Down
Loading
Loading