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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,13 @@ To use the rebase strategy, either:

To push changes to the remote after rebasing you'll need to use the `--force-with-lease` option.

**Rough edges**
**_Squash merges_**

A common pattern when using pull requests is to Squash Merge the pull request when merging into the target branch, squashing all the commits in the PR branch into a single commit. This causes issues when rebasing the rest of the child branches in the stack.

Stack has handling to detect when a squash merge happens during updating a stack using rebase as the update strategy. It will skip the commits that were squash merged, avoiding conflicts.

If you merge a pull request using "Squash and merge" then you might find that the first update to a stack after that results in merge conflicts that you need to resolve. This can be a bit of a pain, however for each commit that existed on the branch that was merged if you select to take the new single commit that now exists generally it isn't too bad.
The remote tracking branch for the branch that was squash merged needs to be deleted for this handling to be enabled.

### Creating pull requests

Expand Down
359 changes: 73 additions & 286 deletions src/Stack.Tests/Commands/Helpers/StackActionsTests.cs

Large diffs are not rendered by default.

28 changes: 21 additions & 7 deletions src/Stack.Tests/Git/ConflictResolutionDetectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ public async Task WaitForConflictResolution_WhenNotStarted_ReturnsNotStarted()
var logger = CreateLogger<ConflictResolutionDetectorTests>();
var git = new GitClient(XUnitLogger.CreateLogger<GitClient>(testOutputHelper), repo.LocalDirectoryPath);

var result = await ConflictResolutionDetector.WaitForConflictResolution(
var conflictResolutionDetector = new ConflictResolutionDetector();

var result = await conflictResolutionDetector.WaitForConflictResolution(
git,
logger,
ConflictOperationType.Merge,
Expand All @@ -64,6 +66,8 @@ public async Task WaitForConflictResolution_WhenMergeCompletes_ReturnsCompleted(
var logger = CreateLogger<ConflictResolutionDetectorTests>();
var git = new GitClient(XUnitLogger.CreateLogger<GitClient>(testOutputHelper), repo.LocalDirectoryPath);

var conflictResolutionDetector = new ConflictResolutionDetector();

var relFile = Some.Name();
var filePath = Path.Join(repo.LocalDirectoryPath, relFile);

Expand Down Expand Up @@ -99,7 +103,7 @@ public async Task WaitForConflictResolution_WhenMergeCompletes_ReturnsCompleted(
RunGit(repo.LocalDirectoryPath, "commit -m resolved-merge");
});

var result = await ConflictResolutionDetector.WaitForConflictResolution(
var result = await conflictResolutionDetector.WaitForConflictResolution(
git,
logger,
ConflictOperationType.Merge,
Expand Down Expand Up @@ -144,7 +148,9 @@ public async Task WaitForConflictResolution_WhenMergeAborted_ReturnsAborted()
// Abort after delay
var aborter = Task.Run(async () => { await Task.Delay(60); git.AbortMerge(); });

var result = await ConflictResolutionDetector.WaitForConflictResolution(
var conflictResolutionDetector = new ConflictResolutionDetector();

var result = await conflictResolutionDetector.WaitForConflictResolution(
git,
logger,
ConflictOperationType.Merge,
Expand Down Expand Up @@ -186,8 +192,10 @@ public async Task WaitForConflictResolution_WhenTimeoutReached_ReturnsTimeout()
git.ChangeBranch(branchBase);
try { git.MergeFromLocalSourceBranch(branchOther); } catch (ConflictException) { }

var conflictResolutionDetector = new ConflictResolutionDetector();

// Do not resolve or abort; should timeout
var result = await ConflictResolutionDetector.WaitForConflictResolution(
var result = await conflictResolutionDetector.WaitForConflictResolution(
git,
logger,
ConflictOperationType.Merge,
Expand Down Expand Up @@ -225,12 +233,14 @@ public async Task WaitForConflictResolution_WhenCancelled_Throws()
repo.Stage(relFile);
repo.Commit();

var conflictResolutionDetector = new ConflictResolutionDetector();

// start rebase that will conflict
try { git.RebaseFromLocalSourceBranch(branchBase); } catch (ConflictException) { }

using var cts = new CancellationTokenSource();
cts.CancelAfter(50);
var act = async () => await ConflictResolutionDetector.WaitForConflictResolution(
var act = async () => await conflictResolutionDetector.WaitForConflictResolution(
git,
logger,
ConflictOperationType.Rebase,
Expand Down Expand Up @@ -268,6 +278,8 @@ public async Task WaitForConflictResolution_WhenRebaseCompletes_ReturnsCompleted
repo.Stage(relFile);
repo.Commit();

var conflictResolutionDetector = new ConflictResolutionDetector();

git.ChangeBranch(featureBranch);
try { git.RebaseFromLocalSourceBranch(baseBranch); } catch (ConflictException) { }

Expand Down Expand Up @@ -299,7 +311,7 @@ public async Task WaitForConflictResolution_WhenRebaseCompletes_ReturnsCompleted
}
});

var result = await ConflictResolutionDetector.WaitForConflictResolution(
var result = await conflictResolutionDetector.WaitForConflictResolution(
git,
logger,
ConflictOperationType.Rebase,
Expand Down Expand Up @@ -338,12 +350,14 @@ public async Task WaitForConflictResolution_WhenRebaseAborted_ReturnsAborted()
repo.Stage(relFile);
repo.Commit();

var conflictResolutionDetector = new ConflictResolutionDetector();

git.ChangeBranch(featureBranch);
try { git.RebaseFromLocalSourceBranch(baseBranch); } catch (ConflictException) { }

var aborter = Task.Run(async () => { await Task.Delay(60); git.AbortRebase(); });

var result = await ConflictResolutionDetector.WaitForConflictResolution(
var result = await conflictResolutionDetector.WaitForConflictResolution(
git,
logger,
ConflictOperationType.Rebase,
Expand Down
64 changes: 64 additions & 0 deletions src/Stack.Tests/Helpers/TestGitRepositoryBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -372,12 +372,28 @@ public LibGit2Sharp.Commit GetTipOfRemoteBranch(string branchName)
return [.. LocalRepository.Branches];
}

public LibGit2Sharp.Branch ChangeBranch(string branchName)
{
return LibGit2Sharp.Commands.Checkout(LocalRepository, branchName);
}

public LibGit2Sharp.Commit Commit(string? message = null)
{
var signature = new Signature(Some.Name(), Some.Name(), DateTimeOffset.Now);
return LocalRepository.Commit(message ?? Some.Name(), signature, signature);
}

public LibGit2Sharp.Commit Commit(string relativePath, string contents, string? message = null)
{
var fullPath = Path.Combine(LocalDirectoryPath, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
File.WriteAllText(fullPath, contents);
LibGit2Sharp.Commands.Stage(LocalRepository, relativePath);

var signature = new Signature(Some.Name(), Some.Name(), DateTimeOffset.Now);
return LocalRepository.Commit(message ?? Some.Name(), signature, signature);
}

public void Stage(string path)
{
LibGit2Sharp.Commands.Stage(LocalRepository, path);
Expand Down Expand Up @@ -445,6 +461,54 @@ public void CreateCommitOnRemoteTrackingBranch(string branchName, string message
LocalRepository.Refs.UpdateTarget(remoteBranch.Reference, commit.Id);
}

/// <summary>
/// Creates a "squash merge" style commit on the target branch whose tree matches the tip of the branch being squashed.
/// This simulates a PR being squash merged into the target branch (e.g. source) without preserving the original commits.
/// The newly created commit is added to both the local and remote tracking refs of the target branch (if a remote exists).
/// </summary>
/// <param name="branchToSquashName">The feature/stack branch whose cumulative changes will be squashed.</param>
/// <param name="targetBranchName">The branch to receive the squash commit (typically the source branch).</param>
/// <param name="message">The commit message for the squash commit.</param>
/// <returns>The created squash commit.</returns>
public LibGit2Sharp.Commit CreateSquashCommitFromBranchOntoBranch(string branchToSquashName, string targetBranchName, string message)
{
var branchToSquash = LocalRepository.Branches[branchToSquashName];
var targetBranch = LocalRepository.Branches[targetBranchName];

if (branchToSquash is null)
{
throw new ArgumentException($"Branch '{branchToSquashName}' does not exist", nameof(branchToSquashName));
}

if (targetBranch is null)
{
throw new ArgumentException($"Target branch '{targetBranchName}' does not exist", nameof(targetBranchName));
}

var signature = new Signature(Some.Name(), Some.Email(), DateTimeOffset.Now);
var parent = targetBranch.Tip;
var tree = branchToSquash.Tip.Tree; // Use the full tree of the branch being squashed

var squashCommit = LocalRepository.ObjectDatabase.CreateCommit(
signature,
signature,
message,
tree,
new[] { parent },
false);

// Fast-forward the local target branch to the squash commit
LocalRepository.Refs.UpdateTarget(targetBranch.Reference, squashCommit.Id);

// Fast-forward the remote tracking branch if it exists
if (targetBranch.TrackedBranch is not null)
{
LocalRepository.Refs.UpdateTarget(targetBranch.TrackedBranch.Reference, squashCommit.Id);
}

return squashCommit;
}

public Worktree CreateWorktree(string branchName)
{
// Validate branch exists
Expand Down
Loading
Loading