From 8210f29fe43ccd35e7d2ed3ed45a84a75b2717c4 Mon Sep 17 00:00:00 2001 From: Thomas Wolf Date: Mon, 12 Apr 2021 23:50:54 +0200 Subject: [PATCH] Implement ours/theirs content conflict resolution Git has different conflict resolution strategies: * There is a tree merge strategy "ours" which just ignores any changes from theirs ("-s ours"). JGit also has the mirror strategy "theirs" ignoring any changes from "ours". (This doesn't exist in C git.) Adapt StashApplyCommand and CherrypickCommand to be able to use those tree merge strategies. * For the resolve/recursive tree merge strategies, there are content conflict resolution strategies "ours" and "theirs", which resolve any conflict hunks by taking the "ours" or "theirs" hunk. In C git those correspond to "-Xours" or -Xtheirs". Implement that in MergeAlgorithm, and add API to set and pass through such a strategy for resolving content conflicts. * The "ours/theirs" content conflict resolution strategies also apply for binary files. Handle these cases in ResolveMerger. Note that the content conflict resolution strategies ("-X ours/theirs") do _not_ apply to modify/delete or delete/modify conflicts. Such conflicts are always reported as conflicts by C git. They do apply, however, if one side completely clears a file's content. Bug: 501111 Change-Id: I2c9c170c61c440a2ab9c387991e7a0c3ab960e07 Signed-off-by: Thomas Wolf Signed-off-by: Matthias Sohn --- .../jgit/pgm/internal/CLIText.properties | 3 + .../src/org/eclipse/jgit/pgm/Merge.java | 22 +- .../eclipse/jgit/pgm/internal/CLIText.java | 1 + .../jgit/api/CherryPickCommandTest.java | 93 ++++- .../eclipse/jgit/api/MergeCommandTest.java | 376 +++++++++++++++++- .../org/eclipse/jgit/api/PullCommandTest.java | 71 ++++ .../jgit/api/StashApplyCommandTest.java | 133 ++++++- .../eclipse/jgit/api/CherryPickCommand.java | 70 +++- .../org/eclipse/jgit/api/MergeCommand.java | 24 +- .../src/org/eclipse/jgit/api/PullCommand.java | 34 +- .../org/eclipse/jgit/api/RebaseCommand.java | 34 +- .../eclipse/jgit/api/StashApplyCommand.java | 89 +++-- .../jgit/merge/ContentMergeStrategy.java | 27 ++ .../eclipse/jgit/merge/MergeAlgorithm.java | 106 ++++- .../org/eclipse/jgit/merge/ResolveMerger.java | 116 ++++-- 15 files changed, 1085 insertions(+), 114 deletions(-) create mode 100644 org.eclipse.jgit/src/org/eclipse/jgit/merge/ContentMergeStrategy.java diff --git a/org.eclipse.jgit.pgm/resources/org/eclipse/jgit/pgm/internal/CLIText.properties b/org.eclipse.jgit.pgm/resources/org/eclipse/jgit/pgm/internal/CLIText.properties index 83846ee8e..38deab99a 100644 --- a/org.eclipse.jgit.pgm/resources/org/eclipse/jgit/pgm/internal/CLIText.properties +++ b/org.eclipse.jgit.pgm/resources/org/eclipse/jgit/pgm/internal/CLIText.properties @@ -115,6 +115,7 @@ metaVar_configFile=FILE metaVar_connProp=conn.prop metaVar_diffAlg=ALGORITHM metaVar_directory=DIRECTORY +metaVar_extraArgument=ours|theirs metaVar_file=FILE metaVar_filepattern=filepattern metaVar_gitDir=GIT_DIR @@ -217,6 +218,7 @@ timeInMilliSeconds={0} ms treeIsRequired=argument tree is required tooManyRefsGiven=Too many refs given unknownIoErrorStdout=An unknown I/O error occurred on standard output +unknownExtraArgument=unknown extra argument -X {0} specified unknownMergeStrategy=unknown merge strategy {0} specified unknownSubcommand=Unknown subcommand: {0} unmergedPaths=Unmerged paths: @@ -226,6 +228,7 @@ updating=Updating {0}..{1} usage_Aggressive=This option will cause gc to more aggressively optimize the repository at the expense of taking much more time usage_AlwaysFallback=Show uniquely abbreviated commit object as fallback usage_bareClone=Make a bare Git repository. That is, instead of creating [DIRECTORY] and placing the administrative files in [DIRECTORY]/.git, make the [DIRECTORY] itself the $GIT_DIR. +usage_extraArgument=Pass an extra argument to a merge driver. Currently supported are "-X ours" and "-X theirs". usage_mirrorClone=Set up a mirror of the source repository. This implies --bare. Compared to --bare, --mirror not only maps \ local branches of the source to local branches of the target, it maps all refs (including remote-tracking branches, notes etc.) \ and sets up a refspec configuration such that all these refs are overwritten by a git remote update in the target repository. diff --git a/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Merge.java b/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Merge.java index fdc449e06..ca4877fb3 100644 --- a/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Merge.java +++ b/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Merge.java @@ -24,6 +24,7 @@ import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.merge.ContentMergeStrategy; import org.eclipse.jgit.merge.MergeStrategy; import org.eclipse.jgit.merge.ResolveMerger.MergeFailureReason; import org.eclipse.jgit.pgm.internal.CLIText; @@ -69,6 +70,20 @@ void ffonly(@SuppressWarnings("unused") final boolean ignored) { @Option(name = "-m", usage = "usage_message") private String message; + private ContentMergeStrategy contentStrategy = null; + + @Option(name = "--strategy-option", aliases = { "-X" }, + metaVar = "metaVar_extraArgument", usage = "usage_extraArgument") + void extraArg(String name) { + if (ContentMergeStrategy.OURS.name().equalsIgnoreCase(name)) { + contentStrategy = ContentMergeStrategy.OURS; + } else if (ContentMergeStrategy.THEIRS.name().equalsIgnoreCase(name)) { + contentStrategy = ContentMergeStrategy.THEIRS; + } else { + throw die(MessageFormat.format(CLIText.get().unknownExtraArgument, name)); + } + } + /** {@inheritDoc} */ @Override protected void run() { @@ -96,8 +111,11 @@ protected void run() { Ref oldHead = getOldHead(); MergeResult result; try (Git git = new Git(db)) { - MergeCommand mergeCmd = git.merge().setStrategy(mergeStrategy) - .setSquash(squash).setFastForward(ff) + MergeCommand mergeCmd = git.merge() + .setStrategy(mergeStrategy) + .setContentMergeStrategy(contentStrategy) + .setSquash(squash) + .setFastForward(ff) .setCommit(!noCommit); if (srcRef != null) { mergeCmd.include(srcRef); diff --git a/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/internal/CLIText.java b/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/internal/CLIText.java index 991b3ba58..8e49a76a3 100644 --- a/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/internal/CLIText.java +++ b/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/internal/CLIText.java @@ -284,6 +284,7 @@ public static String fatalError(String message) { /***/ public String tooManyRefsGiven; /***/ public String treeIsRequired; /***/ public char[] unknownIoErrorStdout; + /***/ public String unknownExtraArgument; /***/ public String unknownMergeStrategy; /***/ public String unknownSubcommand; /***/ public String unmergedPaths; diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/CherryPickCommandTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/CherryPickCommandTest.java index 9dd129c33..f4f0ecd68 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/CherryPickCommandTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/CherryPickCommandTest.java @@ -34,6 +34,8 @@ import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ReflogReader; import org.eclipse.jgit.lib.RepositoryState; +import org.eclipse.jgit.merge.ContentMergeStrategy; +import org.eclipse.jgit.merge.MergeStrategy; import org.eclipse.jgit.merge.ResolveMerger.MergeFailureReason; import org.eclipse.jgit.revwalk.RevCommit; import org.junit.Test; @@ -193,7 +195,7 @@ public void testCherryPickConflictResolution() throws Exception { } @Test - public void testCherryPickConflictResolutionNoCOmmit() throws Exception { + public void testCherryPickConflictResolutionNoCommit() throws Exception { Git git = new Git(db); RevCommit sideCommit = prepareCherryPick(git); @@ -279,6 +281,70 @@ public void testCherryPickOverExecutableChangeOnNonExectuableFileSystem() } } + @Test + public void testCherryPickOurs() throws Exception { + try (Git git = new Git(db)) { + RevCommit sideCommit = prepareCherryPick(git); + + CherryPickResult result = git.cherryPick() + .include(sideCommit.getId()) + .setStrategy(MergeStrategy.OURS) + .call(); + assertEquals(CherryPickStatus.OK, result.getStatus()); + + String expected = "a(master)"; + checkFile(new File(db.getWorkTree(), "a"), expected); + } + } + + @Test + public void testCherryPickTheirs() throws Exception { + try (Git git = new Git(db)) { + RevCommit sideCommit = prepareCherryPick(git); + + CherryPickResult result = git.cherryPick() + .include(sideCommit.getId()) + .setStrategy(MergeStrategy.THEIRS) + .call(); + assertEquals(CherryPickStatus.OK, result.getStatus()); + + String expected = "a(side)"; + checkFile(new File(db.getWorkTree(), "a"), expected); + } + } + + @Test + public void testCherryPickXours() throws Exception { + try (Git git = new Git(db)) { + RevCommit sideCommit = prepareCherryPickStrategyOption(git); + + CherryPickResult result = git.cherryPick() + .include(sideCommit.getId()) + .setContentMergeStrategy(ContentMergeStrategy.OURS) + .call(); + assertEquals(CherryPickStatus.OK, result.getStatus()); + + String expected = "a\nmaster\nc\nd\n"; + checkFile(new File(db.getWorkTree(), "a"), expected); + } + } + + @Test + public void testCherryPickXtheirs() throws Exception { + try (Git git = new Git(db)) { + RevCommit sideCommit = prepareCherryPickStrategyOption(git); + + CherryPickResult result = git.cherryPick() + .include(sideCommit.getId()) + .setContentMergeStrategy(ContentMergeStrategy.THEIRS) + .call(); + assertEquals(CherryPickStatus.OK, result.getStatus()); + + String expected = "a\nside\nc\nd\n"; + checkFile(new File(db.getWorkTree(), "a"), expected); + } + } + @Test public void testCherryPickConflictMarkers() throws Exception { try (Git git = new Git(db)) { @@ -384,6 +450,31 @@ private RevCommit prepareCherryPick(Git git) throws Exception { return sideCommit; } + private RevCommit prepareCherryPickStrategyOption(Git git) + throws Exception { + // create, add and commit file a + writeTrashFile("a", "a\nb\nc\n"); + git.add().addFilepattern("a").call(); + RevCommit firstMasterCommit = git.commit().setMessage("first master") + .call(); + + // create and checkout side branch + createBranch(firstMasterCommit, "refs/heads/side"); + checkoutBranch("refs/heads/side"); + // modify, add and commit file a + writeTrashFile("a", "a\nside\nc\nd\n"); + git.add().addFilepattern("a").call(); + RevCommit sideCommit = git.commit().setMessage("side").call(); + + // checkout master branch + checkoutBranch("refs/heads/master"); + // modify, add and commit file a + writeTrashFile("a", "a\nmaster\nc\n"); + git.add().addFilepattern("a").call(); + git.commit().setMessage("second master").call(); + return sideCommit; + } + private void doCherryPickAndCheckResult(final Git git, final RevCommit sideCommit, final MergeFailureReason reason) throws Exception { diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/MergeCommandTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/MergeCommandTest.java index 8747c85de..bc4e9405e 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/MergeCommandTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/MergeCommandTest.java @@ -14,6 +14,7 @@ import static org.eclipse.jgit.lib.Constants.R_HEADS; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -25,6 +26,7 @@ import org.eclipse.jgit.api.MergeCommand.FastForwardMode; import org.eclipse.jgit.api.MergeResult.MergeStatus; +import org.eclipse.jgit.api.ResetCommand.ResetType; import org.eclipse.jgit.api.errors.InvalidMergeHeadsException; import org.eclipse.jgit.junit.RepositoryTestCase; import org.eclipse.jgit.junit.TestRepository; @@ -34,6 +36,7 @@ import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.RepositoryState; import org.eclipse.jgit.lib.Sets; +import org.eclipse.jgit.merge.ContentMergeStrategy; import org.eclipse.jgit.merge.MergeStrategy; import org.eclipse.jgit.merge.ResolveMerger.MergeFailureReason; import org.eclipse.jgit.revwalk.RevCommit; @@ -305,6 +308,200 @@ public void testContentMerge() throws Exception { } } + @Test + public void testContentMergeXtheirs() throws Exception { + try (Git git = new Git(db)) { + writeTrashFile("a", "1\na\n3\n"); + writeTrashFile("b", "1\nb\n3\n"); + writeTrashFile("c/c/c", "1\nc\n3\n"); + git.add().addFilepattern("a").addFilepattern("b") + .addFilepattern("c/c/c").call(); + RevCommit initialCommit = git.commit().setMessage("initial").call(); + + createBranch(initialCommit, "refs/heads/side"); + checkoutBranch("refs/heads/side"); + + writeTrashFile("a", "1\na(side)\n3\n4\n"); + writeTrashFile("b", "1\nb(side)\n3\n4\n"); + git.add().addFilepattern("a").addFilepattern("b").call(); + RevCommit secondCommit = git.commit().setMessage("side").call(); + + assertEquals("1\nb(side)\n3\n4\n", + read(new File(db.getWorkTree(), "b"))); + checkoutBranch("refs/heads/master"); + assertEquals("1\nb\n3\n", read(new File(db.getWorkTree(), "b"))); + + writeTrashFile("a", "1\na(main)\n3\n"); + writeTrashFile("c/c/c", "1\nc(main)\n3\n"); + git.add().addFilepattern("a").addFilepattern("c/c/c").call(); + git.commit().setMessage("main").call(); + + MergeResult result = git.merge().include(secondCommit.getId()) + .setStrategy(MergeStrategy.RESOLVE) + .setContentMergeStrategy(ContentMergeStrategy.THEIRS) + .call(); + assertEquals(MergeStatus.MERGED, result.getMergeStatus()); + + assertEquals("1\na(side)\n3\n4\n", + read(new File(db.getWorkTree(), "a"))); + assertEquals("1\nb(side)\n3\n4\n", + read(new File(db.getWorkTree(), "b"))); + assertEquals("1\nc(main)\n3\n", + read(new File(db.getWorkTree(), "c/c/c"))); + + assertNull(result.getConflicts()); + + assertEquals(RepositoryState.SAFE, db.getRepositoryState()); + } + } + + @Test + public void testContentMergeXours() throws Exception { + try (Git git = new Git(db)) { + writeTrashFile("a", "1\na\n3\n"); + writeTrashFile("b", "1\nb\n3\n"); + writeTrashFile("c/c/c", "1\nc\n3\n"); + git.add().addFilepattern("a").addFilepattern("b") + .addFilepattern("c/c/c").call(); + RevCommit initialCommit = git.commit().setMessage("initial").call(); + + createBranch(initialCommit, "refs/heads/side"); + checkoutBranch("refs/heads/side"); + + writeTrashFile("a", "1\na(side)\n3\n4\n"); + writeTrashFile("b", "1\nb(side)\n3\n4\n"); + git.add().addFilepattern("a").addFilepattern("b").call(); + RevCommit secondCommit = git.commit().setMessage("side").call(); + + assertEquals("1\nb(side)\n3\n4\n", + read(new File(db.getWorkTree(), "b"))); + checkoutBranch("refs/heads/master"); + assertEquals("1\nb\n3\n", read(new File(db.getWorkTree(), "b"))); + + writeTrashFile("a", "1\na(main)\n3\n"); + writeTrashFile("c/c/c", "1\nc(main)\n3\n"); + git.add().addFilepattern("a").addFilepattern("c/c/c").call(); + git.commit().setMessage("main").call(); + + MergeResult result = git.merge().include(secondCommit.getId()) + .setStrategy(MergeStrategy.RESOLVE) + .setContentMergeStrategy(ContentMergeStrategy.OURS).call(); + assertEquals(MergeStatus.MERGED, result.getMergeStatus()); + + assertEquals("1\na(main)\n3\n4\n", + read(new File(db.getWorkTree(), "a"))); + assertEquals("1\nb(side)\n3\n4\n", + read(new File(db.getWorkTree(), "b"))); + assertEquals("1\nc(main)\n3\n", + read(new File(db.getWorkTree(), "c/c/c"))); + + assertNull(result.getConflicts()); + + assertEquals(RepositoryState.SAFE, db.getRepositoryState()); + } + } + + @Test + public void testBinaryContentMerge() throws Exception { + try (Git git = new Git(db)) { + writeTrashFile(".gitattributes", "a binary"); + writeTrashFile("a", "initial"); + git.add().addFilepattern(".").call(); + RevCommit initialCommit = git.commit().setMessage("initial").call(); + + createBranch(initialCommit, "refs/heads/side"); + checkoutBranch("refs/heads/side"); + + writeTrashFile("a", "side"); + git.add().addFilepattern("a").call(); + RevCommit secondCommit = git.commit().setMessage("side").call(); + + checkoutBranch("refs/heads/master"); + + writeTrashFile("a", "main"); + git.add().addFilepattern("a").call(); + git.commit().setMessage("main").call(); + + MergeResult result = git.merge().include(secondCommit.getId()) + .setStrategy(MergeStrategy.RESOLVE).call(); + assertEquals(MergeStatus.CONFLICTING, result.getMergeStatus()); + + assertEquals("main", read(new File(db.getWorkTree(), "a"))); + + // Hmmm... there doesn't seem to be a way to figure out which files + // had a binary conflict from a MergeResult... + + assertEquals(RepositoryState.MERGING, db.getRepositoryState()); + } + } + + @Test + public void testBinaryContentMergeXtheirs() throws Exception { + try (Git git = new Git(db)) { + writeTrashFile(".gitattributes", "a binary"); + writeTrashFile("a", "initial"); + git.add().addFilepattern(".").call(); + RevCommit initialCommit = git.commit().setMessage("initial").call(); + + createBranch(initialCommit, "refs/heads/side"); + checkoutBranch("refs/heads/side"); + + writeTrashFile("a", "side"); + git.add().addFilepattern("a").call(); + RevCommit secondCommit = git.commit().setMessage("side").call(); + + checkoutBranch("refs/heads/master"); + + writeTrashFile("a", "main"); + git.add().addFilepattern("a").call(); + git.commit().setMessage("main").call(); + + MergeResult result = git.merge().include(secondCommit.getId()) + .setStrategy(MergeStrategy.RESOLVE) + .setContentMergeStrategy(ContentMergeStrategy.THEIRS) + .call(); + assertEquals(MergeStatus.MERGED, result.getMergeStatus()); + + assertEquals("side", read(new File(db.getWorkTree(), "a"))); + + assertNull(result.getConflicts()); + assertEquals(RepositoryState.SAFE, db.getRepositoryState()); + } + } + + @Test + public void testBinaryContentMergeXours() throws Exception { + try (Git git = new Git(db)) { + writeTrashFile(".gitattributes", "a binary"); + writeTrashFile("a", "initial"); + git.add().addFilepattern(".").call(); + RevCommit initialCommit = git.commit().setMessage("initial").call(); + + createBranch(initialCommit, "refs/heads/side"); + checkoutBranch("refs/heads/side"); + + writeTrashFile("a", "side"); + git.add().addFilepattern("a").call(); + RevCommit secondCommit = git.commit().setMessage("side").call(); + + checkoutBranch("refs/heads/master"); + + writeTrashFile("a", "main"); + git.add().addFilepattern("a").call(); + git.commit().setMessage("main").call(); + + MergeResult result = git.merge().include(secondCommit.getId()) + .setStrategy(MergeStrategy.RESOLVE) + .setContentMergeStrategy(ContentMergeStrategy.OURS).call(); + assertEquals(MergeStatus.MERGED, result.getMergeStatus()); + + assertEquals("main", read(new File(db.getWorkTree(), "a"))); + + assertNull(result.getConflicts()); + assertEquals(RepositoryState.SAFE, db.getRepositoryState()); + } + } + @Test public void testMergeTag() throws Exception { try (Git git = new Git(db)) { @@ -774,6 +971,51 @@ public void testDeletionAndConflict() throws Exception { @Test public void testDeletionOnMasterConflict() throws Exception { + try (Git git = new Git(db)) { + writeTrashFile("a", "1\na\n3\n"); + writeTrashFile("b", "1\nb\n3\n"); + git.add().addFilepattern("a").addFilepattern("b").call(); + RevCommit initialCommit = git.commit().setMessage("initial").call(); + + // create side branch and modify "a" + createBranch(initialCommit, "refs/heads/side"); + checkoutBranch("refs/heads/side"); + writeTrashFile("a", "1\na(side)\n3\n"); + git.add().addFilepattern("a").call(); + RevCommit secondCommit = git.commit().setMessage("side").call(); + + // delete a on master to generate conflict + checkoutBranch("refs/heads/master"); + git.rm().addFilepattern("a").call(); + RevCommit thirdCommit = git.commit().setMessage("main").call(); + + for (ContentMergeStrategy contentStrategy : ContentMergeStrategy + .values()) { + // merge side with master + MergeResult result = git.merge().include(secondCommit.getId()) + .setStrategy(MergeStrategy.RESOLVE) + .setContentMergeStrategy(contentStrategy) + .call(); + assertEquals("merge -X " + contentStrategy.name(), + MergeStatus.CONFLICTING, result.getMergeStatus()); + + // result should be 'a' conflicting with workspace content from + // side + assertTrue("merge -X " + contentStrategy.name(), + new File(db.getWorkTree(), "a").exists()); + assertEquals("merge -X " + contentStrategy.name(), + "1\na(side)\n3\n", + read(new File(db.getWorkTree(), "a"))); + assertEquals("merge -X " + contentStrategy.name(), "1\nb\n3\n", + read(new File(db.getWorkTree(), "b"))); + git.reset().setMode(ResetType.HARD).setRef(thirdCommit.name()) + .call(); + } + } + } + + @Test + public void testDeletionOnMasterTheirs() throws Exception { try (Git git = new Git(db)) { writeTrashFile("a", "1\na\n3\n"); writeTrashFile("b", "1\nb\n3\n"); @@ -794,18 +1036,102 @@ public void testDeletionOnMasterConflict() throws Exception { // merge side with master MergeResult result = git.merge().include(secondCommit.getId()) - .setStrategy(MergeStrategy.RESOLVE).call(); - assertEquals(MergeStatus.CONFLICTING, result.getMergeStatus()); + .setStrategy(MergeStrategy.THEIRS) + .call(); + assertEquals(MergeStatus.MERGED, result.getMergeStatus()); - // result should be 'a' conflicting with workspace content from side + // result should be 'a' assertTrue(new File(db.getWorkTree(), "a").exists()); - assertEquals("1\na(side)\n3\n", read(new File(db.getWorkTree(), "a"))); + assertEquals("1\na(side)\n3\n", + read(new File(db.getWorkTree(), "a"))); assertEquals("1\nb\n3\n", read(new File(db.getWorkTree(), "b"))); + assertTrue(git.status().call().isClean()); + } + } + + @Test + public void testDeletionOnMasterOurs() throws Exception { + try (Git git = new Git(db)) { + writeTrashFile("a", "1\na\n3\n"); + writeTrashFile("b", "1\nb\n3\n"); + git.add().addFilepattern("a").addFilepattern("b").call(); + RevCommit initialCommit = git.commit().setMessage("initial").call(); + + // create side branch and modify "a" + createBranch(initialCommit, "refs/heads/side"); + checkoutBranch("refs/heads/side"); + writeTrashFile("a", "1\na(side)\n3\n"); + git.add().addFilepattern("a").call(); + RevCommit secondCommit = git.commit().setMessage("side").call(); + + // delete a on master to generate conflict + checkoutBranch("refs/heads/master"); + git.rm().addFilepattern("a").call(); + git.commit().setMessage("main").call(); + + // merge side with master + MergeResult result = git.merge().include(secondCommit.getId()) + .setStrategy(MergeStrategy.OURS).call(); + assertEquals(MergeStatus.MERGED, result.getMergeStatus()); + + assertFalse(new File(db.getWorkTree(), "a").exists()); + assertEquals("1\nb\n3\n", read(new File(db.getWorkTree(), "b"))); + assertTrue(git.status().call().isClean()); } } @Test public void testDeletionOnSideConflict() throws Exception { + try (Git git = new Git(db)) { + writeTrashFile("a", "1\na\n3\n"); + writeTrashFile("b", "1\nb\n3\n"); + git.add().addFilepattern("a").addFilepattern("b").call(); + RevCommit initialCommit = git.commit().setMessage("initial").call(); + + // create side branch and delete "a" + createBranch(initialCommit, "refs/heads/side"); + checkoutBranch("refs/heads/side"); + git.rm().addFilepattern("a").call(); + RevCommit secondCommit = git.commit().setMessage("side").call(); + + // update a on master to generate conflict + checkoutBranch("refs/heads/master"); + writeTrashFile("a", "1\na(main)\n3\n"); + git.add().addFilepattern("a").call(); + RevCommit thirdCommit = git.commit().setMessage("main").call(); + + for (ContentMergeStrategy contentStrategy : ContentMergeStrategy + .values()) { + // merge side with master + MergeResult result = git.merge().include(secondCommit.getId()) + .setStrategy(MergeStrategy.RESOLVE) + .setContentMergeStrategy(contentStrategy) + .call(); + assertEquals("merge -X " + contentStrategy.name(), + MergeStatus.CONFLICTING, result.getMergeStatus()); + + assertTrue("merge -X " + contentStrategy.name(), + new File(db.getWorkTree(), "a").exists()); + assertEquals("merge -X " + contentStrategy.name(), + "1\na(main)\n3\n", + read(new File(db.getWorkTree(), "a"))); + assertEquals("merge -X " + contentStrategy.name(), "1\nb\n3\n", + read(new File(db.getWorkTree(), "b"))); + + assertNotNull("merge -X " + contentStrategy.name(), + result.getConflicts()); + assertEquals("merge -X " + contentStrategy.name(), 1, + result.getConflicts().size()); + assertEquals("merge -X " + contentStrategy.name(), 3, + result.getConflicts().get("a")[0].length); + git.reset().setMode(ResetType.HARD).setRef(thirdCommit.name()) + .call(); + } + } + } + + @Test + public void testDeletionOnSideTheirs() throws Exception { try (Git git = new Git(db)) { writeTrashFile("a", "1\na\n3\n"); writeTrashFile("b", "1\nb\n3\n"); @@ -826,15 +1152,45 @@ public void testDeletionOnSideConflict() throws Exception { // merge side with master MergeResult result = git.merge().include(secondCommit.getId()) - .setStrategy(MergeStrategy.RESOLVE).call(); - assertEquals(MergeStatus.CONFLICTING, result.getMergeStatus()); + .setStrategy(MergeStrategy.THEIRS).call(); + assertEquals(MergeStatus.MERGED, result.getMergeStatus()); + + assertFalse(new File(db.getWorkTree(), "a").exists()); + assertEquals("1\nb\n3\n", read(new File(db.getWorkTree(), "b"))); + assertTrue(git.status().call().isClean()); + } + } + + @Test + public void testDeletionOnSideOurs() throws Exception { + try (Git git = new Git(db)) { + writeTrashFile("a", "1\na\n3\n"); + writeTrashFile("b", "1\nb\n3\n"); + git.add().addFilepattern("a").addFilepattern("b").call(); + RevCommit initialCommit = git.commit().setMessage("initial").call(); + + // create side branch and delete "a" + createBranch(initialCommit, "refs/heads/side"); + checkoutBranch("refs/heads/side"); + git.rm().addFilepattern("a").call(); + RevCommit secondCommit = git.commit().setMessage("side").call(); + + // update a on master to generate conflict + checkoutBranch("refs/heads/master"); + writeTrashFile("a", "1\na(main)\n3\n"); + git.add().addFilepattern("a").call(); + git.commit().setMessage("main").call(); + + // merge side with master + MergeResult result = git.merge().include(secondCommit.getId()) + .setStrategy(MergeStrategy.OURS).call(); + assertEquals(MergeStatus.MERGED, result.getMergeStatus()); assertTrue(new File(db.getWorkTree(), "a").exists()); - assertEquals("1\na(main)\n3\n", read(new File(db.getWorkTree(), "a"))); + assertEquals("1\na(main)\n3\n", + read(new File(db.getWorkTree(), "a"))); assertEquals("1\nb\n3\n", read(new File(db.getWorkTree(), "b"))); - - assertEquals(1, result.getConflicts().size()); - assertEquals(3, result.getConflicts().get("a")[0].length); + assertTrue(git.status().call().isClean()); } } diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/PullCommandTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/PullCommandTest.java index e4af44e6f..9af77aa3e 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/PullCommandTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/PullCommandTest.java @@ -34,6 +34,8 @@ import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.RepositoryState; import org.eclipse.jgit.lib.StoredConfig; +import org.eclipse.jgit.merge.ContentMergeStrategy; +import org.eclipse.jgit.merge.MergeStrategy; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevSort; import org.eclipse.jgit.revwalk.RevWalk; @@ -153,6 +155,75 @@ public void testPullConflict() throws Exception { .getRepositoryState()); } + @Test + public void testPullConflictTheirs() throws Exception { + PullResult res = target.pull().call(); + // nothing to update since we don't have different data yet + assertTrue(res.getFetchResult().getTrackingRefUpdates().isEmpty()); + assertTrue(res.getMergeResult().getMergeStatus() + .equals(MergeStatus.ALREADY_UP_TO_DATE)); + + assertFileContentsEqual(targetFile, "Hello world"); + + // change the source file + writeToFile(sourceFile, "Source change"); + source.add().addFilepattern("SomeFile.txt").call(); + source.commit().setMessage("Source change in remote").call(); + + // change the target file + writeToFile(targetFile, "Target change"); + target.add().addFilepattern("SomeFile.txt").call(); + target.commit().setMessage("Target change in local").call(); + + res = target.pull().setStrategy(MergeStrategy.THEIRS).call(); + + assertTrue(res.isSuccessful()); + assertFileContentsEqual(targetFile, "Source change"); + assertEquals(RepositoryState.SAFE, + target.getRepository().getRepositoryState()); + assertTrue(target.status().call().isClean()); + } + + @Test + public void testPullConflictXtheirs() throws Exception { + PullResult res = target.pull().call(); + // nothing to update since we don't have different data yet + assertTrue(res.getFetchResult().getTrackingRefUpdates().isEmpty()); + assertTrue(res.getMergeResult().getMergeStatus() + .equals(MergeStatus.ALREADY_UP_TO_DATE)); + + assertFileContentsEqual(targetFile, "Hello world"); + + // change the source file + writeToFile(sourceFile, "a\nHello\nb\n"); + source.add().addFilepattern("SomeFile.txt").call(); + source.commit().setMessage("Multi-line change in remote").call(); + + // Pull again + res = target.pull().call(); + assertTrue(res.isSuccessful()); + assertFileContentsEqual(targetFile, "a\nHello\nb\n"); + + // change the source file + writeToFile(sourceFile, "a\nSource change\nb\n"); + source.add().addFilepattern("SomeFile.txt").call(); + source.commit().setMessage("Source change in remote").call(); + + // change the target file + writeToFile(targetFile, "a\nTarget change\nb\nc\n"); + target.add().addFilepattern("SomeFile.txt").call(); + target.commit().setMessage("Target change in local").call(); + + res = target.pull().setContentMergeStrategy(ContentMergeStrategy.THEIRS) + .call(); + + assertTrue(res.isSuccessful()); + assertFileContentsEqual(targetFile, "a\nSource change\nb\nc\n"); + assertEquals(RepositoryState.SAFE, + target.getRepository().getRepositoryState()); + assertTrue(target.status().call().isClean()); + } + @Test public void testPullWithUntrackedStash() throws Exception { target.pull().call(); diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StashApplyCommandTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StashApplyCommandTest.java index f109cbf50..49b31b1c4 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StashApplyCommandTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StashApplyCommandTest.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2012, GitHub Inc. and others + * Copyright (C) 2012, 2021 GitHub Inc. and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at @@ -28,6 +28,8 @@ import org.eclipse.jgit.junit.RepositoryTestCase; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.merge.ContentMergeStrategy; +import org.eclipse.jgit.merge.MergeStrategy; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.util.FileUtils; import org.junit.After; @@ -426,6 +428,135 @@ public void stashedContentMerge() throws Exception { read(PATH)); } + @Test + public void stashedContentMergeXtheirs() throws Exception { + writeTrashFile(PATH, "content\nmore content\n"); + git.add().addFilepattern(PATH).call(); + git.commit().setMessage("more content").call(); + + writeTrashFile(PATH, "content\nhead change\nmore content\n"); + git.add().addFilepattern(PATH).call(); + git.commit().setMessage("even content").call(); + + writeTrashFile(PATH, "content\nstashed change\nmore content\n"); + + RevCommit stashed = git.stashCreate().call(); + assertNotNull(stashed); + assertEquals("content\nhead change\nmore content\n", + read(committedFile)); + assertTrue(git.status().call().isClean()); + recorder.assertEvent(new String[] { PATH }, ChangeRecorder.EMPTY); + + writeTrashFile(PATH, "content\nmore content\ncommitted change\n"); + git.add().addFilepattern(PATH).call(); + git.commit().setMessage("committed change").call(); + recorder.assertNoEvent(); + + git.stashApply().setContentMergeStrategy(ContentMergeStrategy.THEIRS) + .call(); + recorder.assertEvent(new String[] { PATH }, ChangeRecorder.EMPTY); + Status status = new StatusCommand(db).call(); + assertEquals('[' + PATH + ']', status.getModified().toString()); + assertEquals( + "content\nstashed change\nmore content\ncommitted change\n", + read(PATH)); + } + + @Test + public void stashedContentMergeXours() throws Exception { + writeTrashFile(PATH, "content\nmore content\n"); + git.add().addFilepattern(PATH).call(); + git.commit().setMessage("more content").call(); + + writeTrashFile(PATH, "content\nhead change\nmore content\n"); + git.add().addFilepattern(PATH).call(); + git.commit().setMessage("even content").call(); + + writeTrashFile(PATH, "content\nstashed change\nmore content\n"); + + RevCommit stashed = git.stashCreate().call(); + assertNotNull(stashed); + assertEquals("content\nhead change\nmore content\n", + read(committedFile)); + assertTrue(git.status().call().isClean()); + recorder.assertEvent(new String[] { PATH }, ChangeRecorder.EMPTY); + + writeTrashFile(PATH, + "content\nnew head\nmore content\ncommitted change\n"); + git.add().addFilepattern(PATH).call(); + git.commit().setMessage("committed change").call(); + recorder.assertNoEvent(); + + git.stashApply().setContentMergeStrategy(ContentMergeStrategy.OURS) + .call(); + recorder.assertEvent(new String[] { PATH }, ChangeRecorder.EMPTY); + assertTrue(git.status().call().isClean()); + assertEquals("content\nnew head\nmore content\ncommitted change\n", + read(PATH)); + } + + @Test + public void stashedContentMergeTheirs() throws Exception { + writeTrashFile(PATH, "content\nmore content\n"); + git.add().addFilepattern(PATH).call(); + git.commit().setMessage("more content").call(); + + writeTrashFile(PATH, "content\nhead change\nmore content\n"); + git.add().addFilepattern(PATH).call(); + git.commit().setMessage("even content").call(); + + writeTrashFile(PATH, "content\nstashed change\nmore content\n"); + + RevCommit stashed = git.stashCreate().call(); + assertNotNull(stashed); + assertEquals("content\nhead change\nmore content\n", + read(committedFile)); + assertTrue(git.status().call().isClean()); + recorder.assertEvent(new String[] { PATH }, ChangeRecorder.EMPTY); + + writeTrashFile(PATH, "content\nmore content\ncommitted change\n"); + git.add().addFilepattern(PATH).call(); + git.commit().setMessage("committed change").call(); + recorder.assertNoEvent(); + + git.stashApply().setStrategy(MergeStrategy.THEIRS).call(); + recorder.assertEvent(new String[] { PATH }, ChangeRecorder.EMPTY); + Status status = new StatusCommand(db).call(); + assertEquals('[' + PATH + ']', status.getModified().toString()); + assertEquals("content\nstashed change\nmore content\n", read(PATH)); + } + + @Test + public void stashedContentMergeOurs() throws Exception { + writeTrashFile(PATH, "content\nmore content\n"); + git.add().addFilepattern(PATH).call(); + git.commit().setMessage("more content").call(); + + writeTrashFile(PATH, "content\nhead change\nmore content\n"); + git.add().addFilepattern(PATH).call(); + git.commit().setMessage("even content").call(); + + writeTrashFile(PATH, "content\nstashed change\nmore content\n"); + + RevCommit stashed = git.stashCreate().call(); + assertNotNull(stashed); + assertEquals("content\nhead change\nmore content\n", + read(committedFile)); + assertTrue(git.status().call().isClean()); + recorder.assertEvent(new String[] { PATH }, ChangeRecorder.EMPTY); + + writeTrashFile(PATH, "content\nmore content\ncommitted change\n"); + git.add().addFilepattern(PATH).call(); + git.commit().setMessage("committed change").call(); + recorder.assertNoEvent(); + + // Doesn't make any sense... should be a no-op + git.stashApply().setStrategy(MergeStrategy.OURS).call(); + recorder.assertNoEvent(); + assertTrue(git.status().call().isClean()); + assertEquals("content\nmore content\ncommitted change\n", read(PATH)); + } + @Test public void stashedApplyOnOtherBranch() throws Exception { writeTrashFile(PATH, "content\nmore content\n"); diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommand.java index 5d0154c6d..7922f9e72 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/CherryPickCommand.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2010, Christian Halstrick and others + * Copyright (C) 2010, 2021 Christian Halstrick and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at @@ -13,6 +13,7 @@ import java.text.MessageFormat; import java.util.LinkedList; import java.util.List; +import java.util.Map; import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException; import org.eclipse.jgit.api.errors.GitAPIException; @@ -35,9 +36,12 @@ import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Ref.Storage; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.merge.ContentMergeStrategy; import org.eclipse.jgit.merge.MergeMessageFormatter; import org.eclipse.jgit.merge.MergeStrategy; +import org.eclipse.jgit.merge.Merger; import org.eclipse.jgit.merge.ResolveMerger; +import org.eclipse.jgit.merge.ResolveMerger.MergeFailureReason; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.treewalk.FileTreeIterator; @@ -61,6 +65,8 @@ public class CherryPickCommand extends GitCommand { private MergeStrategy strategy = MergeStrategy.RECURSIVE; + private ContentMergeStrategy contentStrategy; + private Integer mainlineParentNumber; private boolean noCommit = false; @@ -121,16 +127,30 @@ public CherryPickResult call() throws GitAPIException, NoMessageException, String cherryPickName = srcCommit.getId().abbreviate(7).name() + " " + srcCommit.getShortMessage(); //$NON-NLS-1$ - ResolveMerger merger = (ResolveMerger) strategy.newMerger(repo); - merger.setWorkingTreeIterator(new FileTreeIterator(repo)); - merger.setBase(srcParent.getTree()); - merger.setCommitNames(new String[] { "BASE", ourName, //$NON-NLS-1$ - cherryPickName }); - if (merger.merge(newHead, srcCommit)) { - if (!merger.getModifiedFiles().isEmpty()) { + Merger merger = strategy.newMerger(repo); + merger.setProgressMonitor(monitor); + boolean noProblems; + Map failingPaths = null; + List unmergedPaths = null; + if (merger instanceof ResolveMerger) { + ResolveMerger resolveMerger = (ResolveMerger) merger; + resolveMerger.setContentMergeStrategy(contentStrategy); + resolveMerger.setCommitNames( + new String[] { "BASE", ourName, cherryPickName }); //$NON-NLS-1$ + resolveMerger + .setWorkingTreeIterator(new FileTreeIterator(repo)); + resolveMerger.setBase(srcParent.getTree()); + noProblems = merger.merge(newHead, srcCommit); + failingPaths = resolveMerger.getFailingPaths(); + unmergedPaths = resolveMerger.getUnmergedPaths(); + if (!resolveMerger.getModifiedFiles().isEmpty()) { repo.fireEvent(new WorkingTreeModifiedEvent( - merger.getModifiedFiles(), null)); + resolveMerger.getModifiedFiles(), null)); } + } else { + noProblems = merger.merge(newHead, srcCommit); + } + if (noProblems) { if (AnyObjectId.isEqual(newHead.getTree().getId(), merger.getResultTreeId())) { continue; @@ -153,24 +173,26 @@ public CherryPickResult call() throws GitAPIException, NoMessageException, } cherryPickedRefs.add(src); } else { - if (merger.failed()) { - return new CherryPickResult(merger.getFailingPaths()); + if (failingPaths != null && !failingPaths.isEmpty()) { + return new CherryPickResult(failingPaths); } // there are merge conflicts - String message = new MergeMessageFormatter() + String message; + if (unmergedPaths != null) { + message = new MergeMessageFormatter() .formatWithConflicts(srcCommit.getFullMessage(), - merger.getUnmergedPaths()); + unmergedPaths); + } else { + message = srcCommit.getFullMessage(); + } if (!noCommit) { repo.writeCherryPickHead(srcCommit.getId()); } repo.writeMergeCommitMsg(message); - repo.fireEvent(new WorkingTreeModifiedEvent( - merger.getModifiedFiles(), null)); - return CherryPickResult.CONFLICT; } } @@ -290,6 +312,22 @@ public CherryPickCommand setStrategy(MergeStrategy strategy) { return this; } + /** + * Sets the content merge strategy to use if the + * {@link #setStrategy(MergeStrategy) merge strategy} is "resolve" or + * "recursive". + * + * @param strategy + * the {@link ContentMergeStrategy} to be used + * @return {@code this} + * @since 5.12 + */ + public CherryPickCommand setContentMergeStrategy( + ContentMergeStrategy strategy) { + this.contentStrategy = strategy; + return this; + } + /** * Set the (1-based) parent number to diff against * diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/MergeCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/MergeCommand.java index d88f4ec56..c611f915a 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/MergeCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/MergeCommand.java @@ -1,7 +1,7 @@ /* * Copyright (C) 2010, Christian Halstrick - * Copyright (C) 2010-2014, Stefan Lay - * Copyright (C) 2016, Laurent Delaigue and others + * Copyright (C) 2010, 2014, Stefan Lay + * Copyright (C) 2016, 2021 Laurent Delaigue and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at @@ -45,6 +45,7 @@ import org.eclipse.jgit.lib.RefUpdate; import org.eclipse.jgit.lib.RefUpdate.Result; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.merge.ContentMergeStrategy; import org.eclipse.jgit.merge.MergeConfig; import org.eclipse.jgit.merge.MergeMessageFormatter; import org.eclipse.jgit.merge.MergeStrategy; @@ -71,6 +72,8 @@ public class MergeCommand extends GitCommand { private MergeStrategy mergeStrategy = MergeStrategy.RECURSIVE; + private ContentMergeStrategy contentStrategy; + private List commits = new LinkedList<>(); private Boolean squash; @@ -320,6 +323,7 @@ public MergeResult call() throws GitAPIException, NoHeadException, List unmergedPaths = null; if (merger instanceof ResolveMerger) { ResolveMerger resolveMerger = (ResolveMerger) merger; + resolveMerger.setContentMergeStrategy(contentStrategy); resolveMerger.setCommitNames(new String[] { "BASE", "HEAD", ref.getName() }); //$NON-NLS-1$ //$NON-NLS-2$ resolveMerger.setWorkingTreeIterator(new FileTreeIterator(repo)); @@ -472,6 +476,22 @@ public MergeCommand setStrategy(MergeStrategy mergeStrategy) { return this; } + /** + * Sets the content merge strategy to use if the + * {@link #setStrategy(MergeStrategy) merge strategy} is "resolve" or + * "recursive". + * + * @param strategy + * the {@link ContentMergeStrategy} to be used + * @return {@code this} + * @since 5.12 + */ + public MergeCommand setContentMergeStrategy(ContentMergeStrategy strategy) { + checkCallable(); + this.contentStrategy = strategy; + return this; + } + /** * Reference to a commit to be merged with the current head * diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/PullCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/PullCommand.java index 449250890..281ecfd01 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/PullCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/PullCommand.java @@ -1,7 +1,7 @@ /* * Copyright (C) 2010, Christian Halstrick * Copyright (C) 2010, Mathias Kinzler - * Copyright (C) 2016, Laurent Delaigue and others + * Copyright (C) 2016, 2021 Laurent Delaigue and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at @@ -43,6 +43,7 @@ import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.RepositoryState; import org.eclipse.jgit.lib.SubmoduleConfig.FetchRecurseSubmodulesMode; +import org.eclipse.jgit.merge.ContentMergeStrategy; import org.eclipse.jgit.merge.MergeStrategy; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; @@ -69,6 +70,8 @@ public class PullCommand extends TransportCommand { private MergeStrategy strategy = MergeStrategy.RECURSIVE; + private ContentMergeStrategy contentStrategy; + private TagOpt tagOption; private FastForwardMode fastForwardMode; @@ -275,8 +278,7 @@ public PullResult call() throws GitAPIException, JGitText.get().pullTaskName)); // we check the updates to see which of the updated branches - // corresponds - // to the remote branch name + // corresponds to the remote branch name AnyObjectId commitToMerge; if (isRemote) { Ref r = null; @@ -354,8 +356,11 @@ public PullResult call() throws GitAPIException, } RebaseCommand rebase = new RebaseCommand(repo); RebaseResult rebaseRes = rebase.setUpstream(commitToMerge) - .setUpstreamName(upstreamName).setProgressMonitor(monitor) - .setOperation(Operation.BEGIN).setStrategy(strategy) + .setProgressMonitor(monitor) + .setUpstreamName(upstreamName) + .setOperation(Operation.BEGIN) + .setStrategy(strategy) + .setContentMergeStrategy(contentStrategy) .setPreserveMerges( pullRebaseMode == BranchRebaseMode.PRESERVE) .call(); @@ -363,7 +368,9 @@ public PullResult call() throws GitAPIException, } else { MergeCommand merge = new MergeCommand(repo); MergeResult mergeRes = merge.include(upstreamName, commitToMerge) - .setStrategy(strategy).setProgressMonitor(monitor) + .setProgressMonitor(monitor) + .setStrategy(strategy) + .setContentMergeStrategy(contentStrategy) .setFastForward(getFastForwardMode()).call(); monitor.update(1); result = new PullResult(fetchRes, remote, mergeRes); @@ -441,6 +448,21 @@ public PullCommand setStrategy(MergeStrategy strategy) { return this; } + /** + * Sets the content merge strategy to use if the + * {@link #setStrategy(MergeStrategy) merge strategy} is "resolve" or + * "recursive". + * + * @param strategy + * the {@link ContentMergeStrategy} to be used + * @return {@code this} + * @since 5.12 + */ + public PullCommand setContentMergeStrategy(ContentMergeStrategy strategy) { + this.contentStrategy = strategy; + return this; + } + /** * Set the specification of annotated tag behavior during fetch * diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/RebaseCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/RebaseCommand.java index 836175dce..a26ffc2e6 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/RebaseCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/RebaseCommand.java @@ -1,6 +1,6 @@ /* * Copyright (C) 2010, 2013 Mathias Kinzler - * Copyright (C) 2016, Laurent Delaigue and others + * Copyright (C) 2016, 2021 Laurent Delaigue and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at @@ -65,6 +65,7 @@ import org.eclipse.jgit.lib.RefUpdate; import org.eclipse.jgit.lib.RefUpdate.Result; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.merge.ContentMergeStrategy; import org.eclipse.jgit.merge.MergeStrategy; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevSort; @@ -212,6 +213,8 @@ public enum Operation { private MergeStrategy strategy = MergeStrategy.RECURSIVE; + private ContentMergeStrategy contentStrategy; + private boolean preserveMerges = false; /** @@ -501,8 +504,11 @@ private RebaseResult cherryPickCommitFlattening(RevCommit commitToPick) String ourCommitName = getOurCommitName(); try (Git git = new Git(repo)) { CherryPickResult cherryPickResult = git.cherryPick() - .include(commitToPick).setOurCommitName(ourCommitName) - .setReflogPrefix(REFLOG_PREFIX).setStrategy(strategy) + .include(commitToPick) + .setOurCommitName(ourCommitName) + .setReflogPrefix(REFLOG_PREFIX) + .setStrategy(strategy) + .setContentMergeStrategy(contentStrategy) .call(); switch (cherryPickResult.getStatus()) { case FAILED: @@ -556,7 +562,8 @@ private RebaseResult cherryPickCommitPreservingMerges(RevCommit commitToPick) .include(commitToPick) .setOurCommitName(ourCommitName) .setReflogPrefix(REFLOG_PREFIX) - .setStrategy(strategy); + .setStrategy(strategy) + .setContentMergeStrategy(contentStrategy); if (isMerge) { pickCommand.setMainlineParentNumber(1); // We write a MERGE_HEAD and later commit explicitly @@ -592,6 +599,8 @@ private RebaseResult cherryPickCommitPreservingMerges(RevCommit commitToPick) MergeCommand merge = git.merge() .setFastForward(MergeCommand.FastForwardMode.NO_FF) .setProgressMonitor(monitor) + .setStrategy(strategy) + .setContentMergeStrategy(contentStrategy) .setCommit(false); for (int i = 1; i < commitToPick.getParentCount(); i++) merge.include(newParents.get(i)); @@ -1137,7 +1146,7 @@ else if (!isInteractive() && walk.isMergedInto(headCommit, upstream)) { } private List calculatePickList(RevCommit headCommit) - throws GitAPIException, NoHeadException, IOException { + throws IOException { List cherryPickList = new ArrayList<>(); try (RevWalk r = new RevWalk(repo)) { r.sort(RevSort.TOPO_KEEP_BRANCH_TOGETHER, true); @@ -1586,6 +1595,21 @@ public RebaseCommand setStrategy(MergeStrategy strategy) { return this; } + /** + * Sets the content merge strategy to use if the + * {@link #setStrategy(MergeStrategy) merge strategy} is "resolve" or + * "recursive". + * + * @param strategy + * the {@link ContentMergeStrategy} to be used + * @return {@code this} + * @since 5.12 + */ + public RebaseCommand setContentMergeStrategy(ContentMergeStrategy strategy) { + this.contentStrategy = strategy; + return this; + } + /** * Whether to preserve merges during rebase * diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java index 56b3992fc..1004d3e50 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2012, 2017 GitHub Inc. and others + * Copyright (C) 2012, 2021 GitHub Inc. and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at @@ -38,7 +38,9 @@ import org.eclipse.jgit.lib.ObjectReader; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.RepositoryState; +import org.eclipse.jgit.merge.ContentMergeStrategy; import org.eclipse.jgit.merge.MergeStrategy; +import org.eclipse.jgit.merge.Merger; import org.eclipse.jgit.merge.ResolveMerger; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevTree; @@ -71,6 +73,8 @@ public class StashApplyCommand extends GitCommand { private MergeStrategy strategy = MergeStrategy.RECURSIVE; + private ContentMergeStrategy contentStrategy; + /** * Create command to apply the changes of a stashed commit * @@ -166,16 +170,25 @@ public ObjectId call() throws GitAPIException, if (restoreUntracked && stashCommit.getParentCount() == 3) untrackedCommit = revWalk.parseCommit(stashCommit.getParent(2)); - ResolveMerger merger = (ResolveMerger) strategy.newMerger(repo); - merger.setCommitNames(new String[] { "stashed HEAD", "HEAD", //$NON-NLS-1$ //$NON-NLS-2$ - "stash" }); //$NON-NLS-1$ - merger.setBase(stashHeadCommit); - merger.setWorkingTreeIterator(new FileTreeIterator(repo)); - boolean mergeSucceeded = merger.merge(headCommit, stashCommit); - List modifiedByMerge = merger.getModifiedFiles(); - if (!modifiedByMerge.isEmpty()) { - repo.fireEvent( - new WorkingTreeModifiedEvent(modifiedByMerge, null)); + Merger merger = strategy.newMerger(repo); + boolean mergeSucceeded; + if (merger instanceof ResolveMerger) { + ResolveMerger resolveMerger = (ResolveMerger) merger; + resolveMerger + .setCommitNames(new String[] { "stashed HEAD", "HEAD", //$NON-NLS-1$ //$NON-NLS-2$ + "stash" }); //$NON-NLS-1$ + resolveMerger.setBase(stashHeadCommit); + resolveMerger + .setWorkingTreeIterator(new FileTreeIterator(repo)); + resolveMerger.setContentMergeStrategy(contentStrategy); + mergeSucceeded = resolveMerger.merge(headCommit, stashCommit); + List modifiedByMerge = resolveMerger.getModifiedFiles(); + if (!modifiedByMerge.isEmpty()) { + repo.fireEvent(new WorkingTreeModifiedEvent(modifiedByMerge, + null)); + } + } else { + mergeSucceeded = merger.merge(headCommit, stashCommit); } if (mergeSucceeded) { DirCache dc = repo.lockDirCache(); @@ -184,11 +197,14 @@ public ObjectId call() throws GitAPIException, dco.setFailOnConflict(true); dco.checkout(); // Ignoring failed deletes.... if (restoreIndex) { - ResolveMerger ixMerger = (ResolveMerger) strategy - .newMerger(repo, true); - ixMerger.setCommitNames(new String[] { "stashed HEAD", //$NON-NLS-1$ - "HEAD", "stashed index" }); //$NON-NLS-1$//$NON-NLS-2$ - ixMerger.setBase(stashHeadCommit); + Merger ixMerger = strategy.newMerger(repo, true); + if (ixMerger instanceof ResolveMerger) { + ResolveMerger resolveMerger = (ResolveMerger) ixMerger; + resolveMerger.setCommitNames(new String[] { "stashed HEAD", //$NON-NLS-1$ + "HEAD", "stashed index" }); //$NON-NLS-1$//$NON-NLS-2$ + resolveMerger.setBase(stashHeadCommit); + resolveMerger.setContentMergeStrategy(contentStrategy); + } boolean ok = ixMerger.merge(headCommit, stashIndexCommit); if (ok) { resetIndex(revWalk @@ -200,16 +216,20 @@ public ObjectId call() throws GitAPIException, } if (untrackedCommit != null) { - ResolveMerger untrackedMerger = (ResolveMerger) strategy - .newMerger(repo, true); - untrackedMerger.setCommitNames(new String[] { - "null", "HEAD", "untracked files" }); //$NON-NLS-1$//$NON-NLS-2$//$NON-NLS-3$ - // There is no common base for HEAD & untracked files - // because the commit for untracked files has no parent. If - // we use stashHeadCommit as common base (as in the other - // merges) we potentially report conflicts for files - // which are not even member of untracked files commit - untrackedMerger.setBase(null); + Merger untrackedMerger = strategy.newMerger(repo, true); + if (untrackedMerger instanceof ResolveMerger) { + ResolveMerger resolveMerger = (ResolveMerger) untrackedMerger; + resolveMerger.setCommitNames(new String[] { "null", "HEAD", //$NON-NLS-1$//$NON-NLS-2$ + "untracked files" }); //$NON-NLS-1$ + // There is no common base for HEAD & untracked files + // because the commit for untracked files has no parent. + // If we use stashHeadCommit as common base (as in the + // other merges) we potentially report conflicts for + // files which are not even member of untracked files + // commit. + resolveMerger.setBase(null); + resolveMerger.setContentMergeStrategy(contentStrategy); + } boolean ok = untrackedMerger.merge(headCommit, untrackedCommit); if (ok) { @@ -278,6 +298,23 @@ public StashApplyCommand setStrategy(MergeStrategy strategy) { return this; } + /** + * Sets the content merge strategy to use if the + * {@link #setStrategy(MergeStrategy) merge strategy} is "resolve" or + * "recursive". + * + * @param strategy + * the {@link ContentMergeStrategy} to be used + * @return {@code this} + * @since 5.12 + */ + public StashApplyCommand setContentMergeStrategy( + ContentMergeStrategy strategy) { + checkCallable(); + this.contentStrategy = strategy; + return this; + } + /** * Whether the command should restore untracked files * diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/merge/ContentMergeStrategy.java b/org.eclipse.jgit/src/org/eclipse/jgit/merge/ContentMergeStrategy.java new file mode 100644 index 000000000..6d568643d --- /dev/null +++ b/org.eclipse.jgit/src/org/eclipse/jgit/merge/ContentMergeStrategy.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2021, Thomas Wolf and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Distribution License v. 1.0 which is available at + * https://www.eclipse.org/org/documents/edl-v10.php. + * + * SPDX-License-Identifier: BSD-3-Clause + */ +package org.eclipse.jgit.merge; + +/** + * How to handle content conflicts. + * + * @since 5.12 + */ +public enum ContentMergeStrategy { + + /** Produce a conflict. */ + CONFLICT, + + /** Resolve the conflict hunk using the ours version. */ + OURS, + + /** Resolve the conflict hunk using the theirs version. */ + THEIRS +} \ No newline at end of file diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/merge/MergeAlgorithm.java b/org.eclipse.jgit/src/org/eclipse/jgit/merge/MergeAlgorithm.java index 27141c12c..80607351a 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/merge/MergeAlgorithm.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/merge/MergeAlgorithm.java @@ -14,6 +14,7 @@ import java.util.Iterator; import java.util.List; +import org.eclipse.jgit.annotations.NonNull; import org.eclipse.jgit.diff.DiffAlgorithm; import org.eclipse.jgit.diff.Edit; import org.eclipse.jgit.diff.EditList; @@ -28,8 +29,12 @@ * diff algorithm. */ public final class MergeAlgorithm { + private final DiffAlgorithm diffAlg; + @NonNull + private ContentMergeStrategy strategy = ContentMergeStrategy.CONFLICT; + /** * Creates a new MergeAlgorithm which uses * {@link org.eclipse.jgit.diff.HistogramDiff} as diff algorithm @@ -48,6 +53,30 @@ public MergeAlgorithm(DiffAlgorithm diff) { this.diffAlg = diff; } + /** + * Retrieves the {@link ContentMergeStrategy}. + * + * @return the {@link ContentMergeStrategy} in effect + * @since 5.12 + */ + @NonNull + public ContentMergeStrategy getContentMergeStrategy() { + return strategy; + } + + /** + * Sets the {@link ContentMergeStrategy}. + * + * @param strategy + * {@link ContentMergeStrategy} to set; if {@code null}, set + * {@link ContentMergeStrategy#CONFLICT} + * @since 5.12 + */ + public void setContentMergeStrategy(ContentMergeStrategy strategy) { + this.strategy = strategy == null ? ContentMergeStrategy.CONFLICT + : strategy; + } + // An special edit which acts as a sentinel value by marking the end the // list of edits private static final Edit END_EDIT = new Edit(Integer.MAX_VALUE, @@ -79,29 +108,54 @@ public MergeResult merge( if (theirs.size() != 0) { EditList theirsEdits = diffAlg.diff(cmp, base, theirs); if (!theirsEdits.isEmpty()) { - // we deleted, they modified -> Let their complete content - // conflict with empty text - result.add(1, 0, 0, ConflictState.FIRST_CONFLICTING_RANGE); - result.add(2, 0, theirs.size(), - ConflictState.NEXT_CONFLICTING_RANGE); - } else + // we deleted, they modified + switch (strategy) { + case OURS: + result.add(1, 0, 0, ConflictState.NO_CONFLICT); + break; + case THEIRS: + result.add(2, 0, theirs.size(), + ConflictState.NO_CONFLICT); + break; + default: + // Let their complete content conflict with empty text + result.add(1, 0, 0, + ConflictState.FIRST_CONFLICTING_RANGE); + result.add(2, 0, theirs.size(), + ConflictState.NEXT_CONFLICTING_RANGE); + break; + } + } else { // we deleted, they didn't modify -> Let our deletion win result.add(1, 0, 0, ConflictState.NO_CONFLICT); - } else + } + } else { // we and they deleted -> return a single chunk of nothing result.add(1, 0, 0, ConflictState.NO_CONFLICT); + } return result; } else if (theirs.size() == 0) { EditList oursEdits = diffAlg.diff(cmp, base, ours); if (!oursEdits.isEmpty()) { - // we modified, they deleted -> Let our complete content - // conflict with empty text - result.add(1, 0, ours.size(), - ConflictState.FIRST_CONFLICTING_RANGE); - result.add(2, 0, 0, ConflictState.NEXT_CONFLICTING_RANGE); - } else + // we modified, they deleted + switch (strategy) { + case OURS: + result.add(1, 0, ours.size(), ConflictState.NO_CONFLICT); + break; + case THEIRS: + result.add(2, 0, 0, ConflictState.NO_CONFLICT); + break; + default: + // Let our complete content conflict with empty text + result.add(1, 0, ours.size(), + ConflictState.FIRST_CONFLICTING_RANGE); + result.add(2, 0, 0, ConflictState.NEXT_CONFLICTING_RANGE); + break; + } + } else { // they deleted, we didn't modify -> Let their deletion win result.add(2, 0, 0, ConflictState.NO_CONFLICT); + } return result; } @@ -249,12 +303,26 @@ public MergeResult merge( // Add the conflict (Only if there is a conflict left to report) if (minBSize > 0 || BSizeDelta != 0) { - result.add(1, oursBeginB + commonPrefix, oursEndB - - commonSuffix, - ConflictState.FIRST_CONFLICTING_RANGE); - result.add(2, theirsBeginB + commonPrefix, theirsEndB - - commonSuffix, - ConflictState.NEXT_CONFLICTING_RANGE); + switch (strategy) { + case OURS: + result.add(1, oursBeginB + commonPrefix, + oursEndB - commonSuffix, + ConflictState.NO_CONFLICT); + break; + case THEIRS: + result.add(2, theirsBeginB + commonPrefix, + theirsEndB - commonSuffix, + ConflictState.NO_CONFLICT); + break; + default: + result.add(1, oursBeginB + commonPrefix, + oursEndB - commonSuffix, + ConflictState.FIRST_CONFLICTING_RANGE); + result.add(2, theirsBeginB + commonPrefix, + theirsEndB - commonSuffix, + ConflictState.NEXT_CONFLICTING_RANGE); + break; + } } // Add the common lines at end of conflict diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java b/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java index b01125898..776766286 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java @@ -37,6 +37,7 @@ import java.util.List; import java.util.Map; +import org.eclipse.jgit.annotations.NonNull; import org.eclipse.jgit.attributes.Attributes; import org.eclipse.jgit.diff.DiffAlgorithm; import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm; @@ -267,6 +268,13 @@ public enum MergeFailureReason { */ private int inCoreLimit; + /** + * The {@link ContentMergeStrategy} to use for "resolve" and "recursive" + * merges. + */ + @NonNull + private ContentMergeStrategy contentStrategy = ContentMergeStrategy.CONFLICT; + /** * Keeps {@link CheckoutMetadata} for {@link #checkout()} and * {@link #cleanUp()}. @@ -344,6 +352,29 @@ protected ResolveMerger(ObjectInserter inserter, Config config) { dircache = DirCache.newInCore(); } + /** + * Retrieves the content merge strategy for content conflicts. + * + * @return the {@link ContentMergeStrategy} in effect + * @since 5.12 + */ + @NonNull + public ContentMergeStrategy getContentMergeStrategy() { + return contentStrategy; + } + + /** + * Sets the content merge strategy for content conflicts. + * + * @param strategy + * {@link ContentMergeStrategy} to use + * @since 5.12 + */ + public void setContentMergeStrategy(ContentMergeStrategy strategy) { + contentStrategy = strategy == null ? ContentMergeStrategy.CONFLICT + : strategy; + } + /** {@inheritDoc} */ @Override protected boolean mergeImpl() throws IOException { @@ -654,7 +685,8 @@ protected boolean processEntry(CanonicalTreeParser base, add(tw.getRawPath(), ours, DirCacheEntry.STAGE_2, EPOCH, 0); add(tw.getRawPath(), theirs, DirCacheEntry.STAGE_3, EPOCH, 0); unmergedPaths.add(tw.getPathString()); - mergeResults.put(tw.getPathString(), new MergeResult<>(Collections.emptyList())); + mergeResults.put(tw.getPathString(), + new MergeResult<>(Collections.emptyList())); } return true; } @@ -760,6 +792,19 @@ protected boolean processEntry(CanonicalTreeParser base, unmergedPaths.add(tw.getPathString()); return true; } else if (!attributes.canBeContentMerged()) { + // File marked as binary + switch (getContentMergeStrategy()) { + case OURS: + keep(ourDce); + return true; + case THEIRS: + DirCacheEntry theirEntry = add(tw.getRawPath(), theirs, + DirCacheEntry.STAGE_0, EPOCH, 0); + addToCheckout(tw.getPathString(), theirEntry, attributes); + return true; + default: + break; + } add(tw.getRawPath(), base, DirCacheEntry.STAGE_1, EPOCH, 0); add(tw.getRawPath(), ours, DirCacheEntry.STAGE_2, EPOCH, 0); add(tw.getRawPath(), theirs, DirCacheEntry.STAGE_3, EPOCH, 0); @@ -774,8 +819,26 @@ protected boolean processEntry(CanonicalTreeParser base, return false; } - MergeResult result = contentMerge(base, ours, theirs, - attributes); + MergeResult result = null; + try { + result = contentMerge(base, ours, theirs, attributes, + getContentMergeStrategy()); + } catch (BinaryBlobException e) { + switch (getContentMergeStrategy()) { + case OURS: + keep(ourDce); + return true; + case THEIRS: + DirCacheEntry theirEntry = add(tw.getRawPath(), theirs, + DirCacheEntry.STAGE_0, EPOCH, 0); + addToCheckout(tw.getPathString(), theirEntry, attributes); + return true; + default: + result = new MergeResult<>(Collections.emptyList()); + result.setContainsConflicts(true); + break; + } + } if (ignoreConflicts) { result.setContainsConflicts(false); } @@ -802,9 +865,16 @@ protected boolean processEntry(CanonicalTreeParser base, mergeResults.put(tw.getPathString(), result); unmergedPaths.add(tw.getPathString()); } else { - MergeResult result = contentMerge(base, ours, - theirs, attributes); - + // Content merge strategy does not apply to delete-modify + // conflicts! + MergeResult result; + try { + result = contentMerge(base, ours, theirs, attributes, + ContentMergeStrategy.CONFLICT); + } catch (BinaryBlobException e) { + result = new MergeResult<>(Collections.emptyList()); + result.setContainsConflicts(true); + } if (ignoreConflicts) { // In case a conflict is detected the working tree file // is again filled with new content (containing conflict @@ -866,32 +936,26 @@ private static MergeResult createGitLinksMergeResult( * @param ours * @param theirs * @param attributes + * @param strategy * * @return the result of the content merge + * @throws BinaryBlobException + * if any of the blobs looks like a binary blob * @throws IOException */ private MergeResult contentMerge(CanonicalTreeParser base, CanonicalTreeParser ours, CanonicalTreeParser theirs, - Attributes attributes) - throws IOException { - RawText baseText; - RawText ourText; - RawText theirsText; - - try { - baseText = base == null ? RawText.EMPTY_TEXT : getRawText( - base.getEntryObjectId(), attributes); - ourText = ours == null ? RawText.EMPTY_TEXT : getRawText( - ours.getEntryObjectId(), attributes); - theirsText = theirs == null ? RawText.EMPTY_TEXT : getRawText( - theirs.getEntryObjectId(), attributes); - } catch (BinaryBlobException e) { - MergeResult r = new MergeResult<>(Collections.emptyList()); - r.setContainsConflicts(true); - return r; - } - return (mergeAlgorithm.merge(RawTextComparator.DEFAULT, baseText, - ourText, theirsText)); + Attributes attributes, ContentMergeStrategy strategy) + throws BinaryBlobException, IOException { + RawText baseText = base == null ? RawText.EMPTY_TEXT + : getRawText(base.getEntryObjectId(), attributes); + RawText ourText = ours == null ? RawText.EMPTY_TEXT + : getRawText(ours.getEntryObjectId(), attributes); + RawText theirsText = theirs == null ? RawText.EMPTY_TEXT + : getRawText(theirs.getEntryObjectId(), attributes); + mergeAlgorithm.setContentMergeStrategy(strategy); + return mergeAlgorithm.merge(RawTextComparator.DEFAULT, baseText, + ourText, theirsText); } private boolean isIndexDirty() {