diff --git a/LibGit2Sharp.Tests/CherryPickFixture.cs b/LibGit2Sharp.Tests/CherryPickFixture.cs new file mode 100644 index 000000000..8ce6b04a0 --- /dev/null +++ b/LibGit2Sharp.Tests/CherryPickFixture.cs @@ -0,0 +1,141 @@ +using System.IO; +using System.Linq; +using LibGit2Sharp.Tests.TestHelpers; +using Xunit; +using Xunit.Extensions; +using System; + +namespace LibGit2Sharp.Tests +{ + public class CherryPickFixture : BaseFixture + { + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CanCherryPick(bool fromDetachedHead) + { + string path = CloneMergeTestRepo(); + using (var repo = new Repository(path)) + { + if (fromDetachedHead) + { + repo.Checkout(repo.Head.Tip.Id.Sha); + } + + Commit commitToMerge = repo.Branches["fast_forward"].Tip; + + CherryPickResult result = repo.CherryPick(commitToMerge, Constants.Signature); + + Assert.Equal(CherryPickStatus.CherryPicked, result.Status); + Assert.Equal(cherryPickedCommitId, result.Commit.Id.Sha); + Assert.False(repo.Index.RetrieveStatus().Any()); + Assert.Equal(fromDetachedHead, repo.Info.IsHeadDetached); + Assert.Equal(commitToMerge.Author, result.Commit.Author); + Assert.Equal(Constants.Signature, result.Commit.Committer); + } + } + + [Fact] + public void CherryPickWithConflictDoesNotCommit() + { + const string firstBranchFileName = "first branch file.txt"; + const string secondBranchFileName = "second branch file.txt"; + const string sharedBranchFileName = "first+second branch file.txt"; + + string path = CloneStandardTestRepo(); + using (var repo = new Repository(path)) + { + var firstBranch = repo.CreateBranch("FirstBranch"); + firstBranch.Checkout(); + + // Commit with ONE new file to both first & second branch (SecondBranch is created on this commit). + AddFileCommitToRepo(repo, sharedBranchFileName); + + var secondBranch = repo.CreateBranch("SecondBranch"); + // Commit with ONE new file to first branch (FirstBranch moves forward as it is checked out, SecondBranch stays back one). + AddFileCommitToRepo(repo, firstBranchFileName); + AddFileCommitToRepo(repo, sharedBranchFileName, "The first branches comment"); // Change file in first branch + + secondBranch.Checkout(); + // Commit with ONE new file to second branch (FirstBranch and SecondBranch now point to separate commits that both have the same parent commit). + AddFileCommitToRepo(repo, secondBranchFileName); + AddFileCommitToRepo(repo, sharedBranchFileName, "The second branches comment"); // Change file in second branch + + CherryPickResult cherryPickResult = repo.CherryPick(repo.Branches["FirstBranch"].Tip, Constants.Signature); + + Assert.Equal(CherryPickStatus.Conflicts, cherryPickResult.Status); + + Assert.Null(cherryPickResult.Commit); + Assert.Equal(1, repo.Index.Conflicts.Count()); + + var conflict = repo.Index.Conflicts.First(); + var changes = repo.Diff.Compare(repo.Lookup(conflict.Theirs.Id), repo.Lookup(conflict.Ours.Id)); + + Assert.False(changes.IsBinaryComparison); + } + } + + [Theory] + [InlineData(CheckoutFileConflictStrategy.Ours)] + [InlineData(CheckoutFileConflictStrategy.Theirs)] + public void CanSpecifyConflictFileStrategy(CheckoutFileConflictStrategy conflictStrategy) + { + const string conflictFile = "a.txt"; + const string conflictBranchName = "conflicts"; + + string path = CloneMergeTestRepo(); + using (var repo = new Repository(path)) + { + Branch branch = repo.Branches[conflictBranchName]; + Assert.NotNull(branch); + + CherryPickOptions cherryPickOptions = new CherryPickOptions() + { + FileConflictStrategy = conflictStrategy, + }; + + CherryPickResult result = repo.CherryPick(branch.Tip, Constants.Signature, cherryPickOptions); + Assert.Equal(CherryPickStatus.Conflicts, result.Status); + + // Get the information on the conflict. + Conflict conflict = repo.Index.Conflicts[conflictFile]; + + Assert.NotNull(conflict); + Assert.NotNull(conflict.Theirs); + Assert.NotNull(conflict.Ours); + + // Get the blob containing the expected content. + Blob expectedBlob = null; + switch (conflictStrategy) + { + case CheckoutFileConflictStrategy.Theirs: + expectedBlob = repo.Lookup(conflict.Theirs.Id); + break; + case CheckoutFileConflictStrategy.Ours: + expectedBlob = repo.Lookup(conflict.Ours.Id); + break; + default: + throw new Exception("Unexpected FileConflictStrategy"); + } + + Assert.NotNull(expectedBlob); + + // Check the content of the file on disk matches what is expected. + string expectedContent = expectedBlob.GetContentText(new FilteringOptions(conflictFile)); + Assert.Equal(expectedContent, File.ReadAllText(Path.Combine(repo.Info.WorkingDirectory, conflictFile))); + } + } + + private Commit AddFileCommitToRepo(IRepository repository, string filename, string content = null) + { + Touch(repository.Info.WorkingDirectory, filename, content); + + repository.Index.Stage(filename); + + return repository.Commit("New commit", Constants.Signature, Constants.Signature); + } + + // Commit IDs of the checked in merge_testrepo + private const string cherryPickedCommitId = "74b37f366b6e1c682c1c9fe0c6b006cbe909cf91"; + } +} diff --git a/LibGit2Sharp.Tests/LibGit2Sharp.Tests.csproj b/LibGit2Sharp.Tests/LibGit2Sharp.Tests.csproj index b07fcec44..6a1fd4038 100644 --- a/LibGit2Sharp.Tests/LibGit2Sharp.Tests.csproj +++ b/LibGit2Sharp.Tests/LibGit2Sharp.Tests.csproj @@ -62,6 +62,7 @@ + diff --git a/LibGit2Sharp/CherryPickOptions.cs b/LibGit2Sharp/CherryPickOptions.cs new file mode 100644 index 000000000..ccb8b0b4f --- /dev/null +++ b/LibGit2Sharp/CherryPickOptions.cs @@ -0,0 +1,114 @@ +using LibGit2Sharp.Core; +using LibGit2Sharp.Handlers; + +namespace LibGit2Sharp +{ + /// + /// Options controlling CherryPick behavior. + /// + public sealed class CherryPickOptions : IConvertableToGitCheckoutOpts + { + /// + /// Initializes a new instance of the class. + /// By default the cherry pick will be committed if there are no conflicts. + /// + public CherryPickOptions() + { + CommitOnSuccess = true; + + FindRenames = true; + + // TODO: libgit2 should provide reasonable defaults for these + // values, but it currently does not. + RenameThreshold = 50; + TargetLimit = 200; + } + + /// + /// The Flags specifying what conditions are + /// reported through the OnCheckoutNotify delegate. + /// + public CheckoutNotifyFlags CheckoutNotifyFlags { get; set; } + + /// + /// Delegate that checkout progress will be reported through. + /// + public CheckoutProgressHandler OnCheckoutProgress { get; set; } + + /// + /// Delegate that checkout will notify callers of + /// certain conditions. The conditions that are reported is + /// controlled with the CheckoutNotifyFlags property. + /// + public CheckoutNotifyHandler OnCheckoutNotify { get; set; } + + /// + /// Commit the cherry pick if the cherry pick is successful. + /// + public bool CommitOnSuccess { get; set; } + + /// + /// When cherry picking a merge commit, the parent number to consider as + /// mainline, starting from offset 1. + /// + /// As a merge commit has multiple parents, cherry picking a merge commit + /// will reverse all the changes brought in by the merge except for + /// one parent's line of commits. The parent to preserve is called the + /// mainline, and must be specified by its number (i.e. offset). + /// + /// + public int Mainline { get; set; } + + /// + /// How to handle conflicts encountered during a merge. + /// + public MergeFileFavor MergeFileFavor { get; set; } + + /// + /// How Checkout should handle writing out conflicting index entries. + /// + public CheckoutFileConflictStrategy FileConflictStrategy { get; set; } + + /// + /// Find renames. Default is true. + /// + public bool FindRenames { get; set; } + + /// + /// Similarity to consider a file renamed (default 50). If + /// `FindRenames` is enabled, added files will be compared + /// with deleted files to determine their similarity. Files that are + /// more similar than the rename threshold (percentage-wise) will be + /// treated as a rename. + /// + public int RenameThreshold; + + /// + /// Maximum similarity sources to examine for renames (default 200). + /// If the number of rename candidates (add / delete pairs) is greater + /// than this value, inexact rename detection is aborted. + /// + /// This setting overrides the `merge.renameLimit` configuration value. + /// + public int TargetLimit; + + #region IConvertableToGitCheckoutOpts + + CheckoutCallbacks IConvertableToGitCheckoutOpts.GenerateCallbacks() + { + return CheckoutCallbacks.From(OnCheckoutProgress, OnCheckoutNotify); + } + + CheckoutStrategy IConvertableToGitCheckoutOpts.CheckoutStrategy + { + get + { + return CheckoutStrategy.GIT_CHECKOUT_SAFE | + CheckoutStrategy.GIT_CHECKOUT_ALLOW_CONFLICTS | + GitCheckoutOptsWrapper.CheckoutStrategyFromFileConflictStrategy(FileConflictStrategy); + } + } + + #endregion IConvertableToGitCheckoutOpts + } +} diff --git a/LibGit2Sharp/CherryPickResult.cs b/LibGit2Sharp/CherryPickResult.cs new file mode 100644 index 000000000..b93de8df5 --- /dev/null +++ b/LibGit2Sharp/CherryPickResult.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; + +namespace LibGit2Sharp +{ + /// + /// Class to report the result of a cherry picked. + /// + public class CherryPickResult + { + /// + /// Needed for mocking purposes. + /// + protected CherryPickResult() + { } + + internal CherryPickResult(CherryPickStatus status, Commit commit = null) + { + Commit = commit; + Status = status; + } + + /// + /// The resulting commit of the cherry pick. + /// + /// This will return null if the cherry pick was not committed. + /// This can happen if: + /// 1) The cherry pick resulted in conflicts. + /// 2) The option to not commit on success is set. + /// + /// + public virtual Commit Commit { get; private set; } + + /// + /// The status of the cherry pick. + /// + public virtual CherryPickStatus Status { get; private set; } + } + + /// + /// The status of what happened as a result of a cherry-pick. + /// + public enum CherryPickStatus + { + /// + /// The commit was successfully cherry picked. + /// + CherryPicked, + + /// + /// The cherry pick resulted in conflicts. + /// + Conflicts + } +} diff --git a/LibGit2Sharp/Core/GitCherryPickOptions.cs b/LibGit2Sharp/Core/GitCherryPickOptions.cs new file mode 100644 index 000000000..b06f22a7d --- /dev/null +++ b/LibGit2Sharp/Core/GitCherryPickOptions.cs @@ -0,0 +1,18 @@ +using System; +using System.Runtime.InteropServices; + +namespace LibGit2Sharp.Core +{ + [StructLayout(LayoutKind.Sequential)] + internal class GitCherryPickOptions + { + public uint Version = 1; + + // For merge commits, the "mainline" is treated as the parent + public uint Mainline = 0; + + public GitMergeOpts MergeOpts = new GitMergeOpts { Version = 1 }; + + public GitCheckoutOpts CheckoutOpts = new GitCheckoutOpts { version = 1 }; + } +} diff --git a/LibGit2Sharp/Core/NativeMethods.cs b/LibGit2Sharp/Core/NativeMethods.cs index e7bc842d1..55bbe19a4 100644 --- a/LibGit2Sharp/Core/NativeMethods.cs +++ b/LibGit2Sharp/Core/NativeMethods.cs @@ -1494,6 +1494,9 @@ internal static extern int git_treebuilder_insert( [DllImport(libgit2)] internal static extern int git_blob_is_binary(GitObjectSafeHandle blob); + + [DllImport(libgit2)] + internal static extern int git_cherry_pick(RepositorySafeHandle repo, GitObjectSafeHandle commit, GitCherryPickOptions options); } } // ReSharper restore InconsistentNaming diff --git a/LibGit2Sharp/Core/Proxy.cs b/LibGit2Sharp/Core/Proxy.cs index 852f3a587..3b1cc0294 100644 --- a/LibGit2Sharp/Core/Proxy.cs +++ b/LibGit2Sharp/Core/Proxy.cs @@ -268,6 +268,19 @@ public static void git_checkout_index(RepositorySafeHandle repo, GitObjectSafeHa #endregion + #region git_cherry_pick_ + + internal static void git_cherry_pick(RepositorySafeHandle repo, ObjectId commit, GitCherryPickOptions options) + { + using (ThreadAffinity()) + using (var nativeCommit = git_object_lookup(repo, commit, GitObjectType.Commit)) + { + int res = NativeMethods.git_cherry_pick(repo, nativeCommit, options); + Ensure.ZeroResult(res); + } + } + #endregion + #region git_clone_ public static RepositorySafeHandle git_clone( diff --git a/LibGit2Sharp/IRepository.cs b/LibGit2Sharp/IRepository.cs index d948a23d5..9dc915a68 100644 --- a/LibGit2Sharp/IRepository.cs +++ b/LibGit2Sharp/IRepository.cs @@ -215,7 +215,7 @@ public interface IRepository : IDisposable MergeResult Merge(Branch branch, Signature merger, MergeOptions options = null); /// - /// Merges changes from the commit into the branch pointed at by HEAD.. + /// Merges changes from the commit into the branch pointed at by HEAD. /// /// The commit to merge into branch pointed at by HEAD. /// The of who is performing the merge. @@ -223,6 +223,15 @@ public interface IRepository : IDisposable /// The of the merge. MergeResult Merge(string committish, Signature merger, MergeOptions options = null); + /// + /// Cherry picks changes from the commit into the branch pointed at by HEAD. + /// + /// The commit to cherry pick into branch pointed at by HEAD. + /// The of who is performing the cherry pick. + /// Specifies optional parameters controlling cherry pick behavior; if null, the defaults are used. + /// The of the merge. + CherryPickResult CherryPick(Commit commit, Signature committer, CherryPickOptions options = null); + /// /// Manipulate the currently ignored files. /// diff --git a/LibGit2Sharp/LibGit2Sharp.csproj b/LibGit2Sharp/LibGit2Sharp.csproj index 8d2f44ca6..1da4fc85a 100644 --- a/LibGit2Sharp/LibGit2Sharp.csproj +++ b/LibGit2Sharp/LibGit2Sharp.csproj @@ -73,6 +73,8 @@ + + @@ -81,6 +83,7 @@ + diff --git a/LibGit2Sharp/Repository.cs b/LibGit2Sharp/Repository.cs index f28f6fffe..b5bc2bfa4 100644 --- a/LibGit2Sharp/Repository.cs +++ b/LibGit2Sharp/Repository.cs @@ -1086,7 +1086,7 @@ internal MergeResult MergeFetchHeads(Signature merger, MergeOptions options) /// Revert the specified commit. /// /// The to revert. - /// The of who is performing the reverte. + /// The of who is performing the revert. /// controlling revert behavior. /// The result of the revert. public RevertResult Revert(Commit commit, Signature reverter, RevertOptions options = null) @@ -1112,7 +1112,7 @@ public RevertResult Revert(Commit commit, Signature reverter, RevertOptions opti GitRevertOpts gitRevertOpts = new GitRevertOpts() { - Mainline = (uint) options.Mainline, + Mainline = (uint)options.Mainline, MergeOpts = mergeOptions, CheckoutOpts = checkoutOptionsWrapper.Options, @@ -1120,10 +1120,10 @@ public RevertResult Revert(Commit commit, Signature reverter, RevertOptions opti Proxy.git_revert(handle, commit.Id.Oid, gitRevertOpts); - if(Index.IsFullyMerged) + if (Index.IsFullyMerged) { Commit revertCommit = null; - if(options.CommitOnSuccess) + if (options.CommitOnSuccess) { revertCommit = this.Commit(Info.Message, author: reverter, committer: reverter); } @@ -1139,6 +1139,63 @@ public RevertResult Revert(Commit commit, Signature reverter, RevertOptions opti return result; } + /// + /// Cherry-picks the specified commit. + /// + /// The to cherry-pick. + /// The of who is performing the cherry pick. + /// controlling cherry pick behavior. + /// The result of the cherry pick. + public CherryPickResult CherryPick(Commit commit, Signature committer, CherryPickOptions options = null) + { + Ensure.ArgumentNotNull(commit, "commit"); + Ensure.ArgumentNotNull(committer, "committer"); + + options = options ?? new CherryPickOptions(); + + CherryPickResult result = null; + + using (GitCheckoutOptsWrapper checkoutOptionsWrapper = new GitCheckoutOptsWrapper(options)) + { + var mergeOptions = new GitMergeOpts + { + Version = 1, + MergeFileFavorFlags = options.MergeFileFavor, + MergeTreeFlags = options.FindRenames ? GitMergeTreeFlags.GIT_MERGE_TREE_FIND_RENAMES : + GitMergeTreeFlags.GIT_MERGE_TREE_NORMAL, + RenameThreshold = (uint)options.RenameThreshold, + TargetLimit = (uint)options.TargetLimit, + }; + + GitCherryPickOptions gitCherryPickOpts = new GitCherryPickOptions() + { + Mainline = (uint)options.Mainline, + MergeOpts = mergeOptions, + + CheckoutOpts = checkoutOptionsWrapper.Options, + }; + + Proxy.git_cherry_pick(handle, commit.Id.Oid, gitCherryPickOpts); + + if (Index.IsFullyMerged) + { + Commit cherryPickCommit = null; + if (options.CommitOnSuccess) + { + cherryPickCommit = this.Commit(Info.Message, commit.Author, committer); + } + + result = new CherryPickResult(CherryPickStatus.CherryPicked, cherryPickCommit); + } + else + { + result = new CherryPickResult(CherryPickStatus.Conflicts); + } + } + + return result; + } + /// /// Internal implementation of merge. ///