From 1b9911d9ae23f14a7e7a5330c47e63a99fe1469d Mon Sep 17 00:00:00 2001 From: Marija Savtchouk Date: Thu, 21 Jan 2021 15:08:57 +0000 Subject: [PATCH] Allow dir/file conflicts in virtual base commit on recursive merge. If RecursiveMerger finds multiple base commits, it tries to compute the virtual ancestor to use as a base for the three way merge. Currently, the content conflicts between ancestors are ignored (file staged with the conflict markers). If the path is a file in one ancestor and a dir in the other, it results in NoMergeBaseException (CONFLICTS_DURING_MERGE_BASE_CALCULATION). Allow these conflicts by ignoring this unmerged path in the virtual base. The merger will compute diff in the children instead and it can be further fixed manually if needed. Change-Id: Id59648ae1d6bdf300b26fff513c3204317b755ab Signed-off-by: Marija Savtchouk --- .../org/eclipse/jgit/merge/MergerTest.java | 264 ++++++++++++++++++ .../org/eclipse/jgit/merge/ResolveMerger.java | 23 +- 2 files changed, 277 insertions(+), 10 deletions(-) diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/merge/MergerTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/merge/MergerTest.java index e2ac89be9..eecf25be9 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/merge/MergerTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/merge/MergerTest.java @@ -1384,6 +1384,270 @@ public void checkMergeConflictInVirtualAncestor( git.merge().include(commitB).call(); } + /** + * Merging two commits with a file/dir conflict in the virtual ancestor. + * + *

+ * Those conflicts should be ignored, otherwise the found base can not be used by the + * RecursiveMerger. + *

+	 *  --------------
+	 * |              \
+	 * |         C1 - C4 --- ?     master
+	 * |        /          /
+	 * |  I - A1 - C2 - C3         second-branch
+	 * |   \            /
+	 * \    \          /
+	 *  ----A2--------             branch-to-merge
+	 *  
+ *

+ *

+ * Path "a" is initially a file in I and A1. It is changed to a directory in A2 + * ("branch-to-merge"). + *

+ * A2 is merged into "master" and "second-branch". The dir/file merge conflict is resolved + * manually, results in C4 and C3. + *

+ * While merging C3 and C4, A1 and A2 are the base commits found by the recursive merge that + * have the dir/file conflict. + */ + @Theory + public void checkFileDirMergeConflictInVirtualAncestor_NoConflictInChildren( + MergeStrategy strategy) + throws Exception { + if (!strategy.equals(MergeStrategy.RECURSIVE)) { + return; + } + + Git git = Git.wrap(db); + + // master + writeTrashFile("a", "initial content"); + git.add().addFilepattern("a").call(); + RevCommit commitI = git.commit().setMessage("Initial commit").call(); + + writeTrashFile("a", "content in Ancestor 1"); + git.add().addFilepattern("a").call(); + RevCommit commitA1 = git.commit().setMessage("Ancestor 1").call(); + + writeTrashFile("a", "content in Child 1 (commited on master)"); + git.add().addFilepattern("a").call(); + // commit C1M + git.commit().setMessage("Child 1 on master").call(); + + git.checkout().setCreateBranch(true).setStartPoint(commitI).setName("branch-to-merge").call(); + // "a" becomes a directory in A2 + git.rm().addFilepattern("a").call(); + writeTrashFile("a/content", "content in Ancestor 2 (commited on branch-to-merge)"); + git.add().addFilepattern("a/content").call(); + RevCommit commitA2 = git.commit().setMessage("Ancestor 2").call(); + + // second branch + git.checkout().setCreateBranch(true).setStartPoint(commitA1).setName("second-branch").call(); + writeTrashFile("a", "content in Child 2 (commited on second-branch)"); + git.add().addFilepattern("a").call(); + // commit C2S + git.commit().setMessage("Child 2 on second-branch").call(); + + // Merge branch-to-merge into second-branch + MergeResult mergeResult = git.merge().include(commitA2).setStrategy(strategy).call(); + assertEquals(mergeResult.getNewHead(), null); + assertEquals(mergeResult.getMergeStatus(), MergeStatus.CONFLICTING); + // Resolve the conflict manually, merge "a" as a file + git.rm().addFilepattern("a").call(); + git.rm().addFilepattern("a/content").call(); + writeTrashFile("a", "merge conflict resolution"); + git.add().addFilepattern("a").call(); + RevCommit commitC3S = git.commit().setMessage("Child 3 on second bug - resolve merge conflict") + .call(); + + // Merge branch-to-merge into master + git.checkout().setName("master").call(); + mergeResult = git.merge().include(commitA2).setStrategy(strategy).call(); + assertEquals(mergeResult.getNewHead(), null); + assertEquals(mergeResult.getMergeStatus(), MergeStatus.CONFLICTING); + + // Resolve the conflict manually - merge "a" as a file + git.rm().addFilepattern("a").call(); + git.rm().addFilepattern("a/content").call(); + writeTrashFile("a", "merge conflict resolution"); + git.add().addFilepattern("a").call(); + // commit C4M + git.commit().setMessage("Child 4 on master - resolve merge conflict").call(); + + // Merge C4M (second-branch) into master (C3S) + // Conflict in virtual base should be here, but there are no conflicts in + // children + mergeResult = git.merge().include(commitC3S).call(); + assertEquals(mergeResult.getMergeStatus(), MergeStatus.MERGED); + + } + + @Theory + public void checkFileDirMergeConflictInVirtualAncestor_ConflictInChildren_FileDir(MergeStrategy strategy) + throws Exception { + if (!strategy.equals(MergeStrategy.RECURSIVE)) { + return; + } + + Git git = Git.wrap(db); + + // master + writeTrashFile("a", "initial content"); + git.add().addFilepattern("a").call(); + RevCommit commitI = git.commit().setMessage("Initial commit").call(); + + writeTrashFile("a", "content in Ancestor 1"); + git.add().addFilepattern("a").call(); + RevCommit commitA1 = git.commit().setMessage("Ancestor 1").call(); + + writeTrashFile("a", "content in Child 1 (commited on master)"); + git.add().addFilepattern("a").call(); + // commit C1M + git.commit().setMessage("Child 1 on master").call(); + + git.checkout().setCreateBranch(true).setStartPoint(commitI).setName("branch-to-merge").call(); + + // "a" becomes a directory in A2 + git.rm().addFilepattern("a").call(); + writeTrashFile("a/content", "content in Ancestor 2 (commited on branch-to-merge)"); + git.add().addFilepattern("a/content").call(); + RevCommit commitA2 = git.commit().setMessage("Ancestor 2").call(); + + // second branch + git.checkout().setCreateBranch(true).setStartPoint(commitA1).setName("second-branch").call(); + writeTrashFile("a", "content in Child 2 (commited on second-branch)"); + git.add().addFilepattern("a").call(); + // commit C2S + git.commit().setMessage("Child 2 on second-branch").call(); + + // Merge branch-to-merge into second-branch + MergeResult mergeResult = git.merge().include(commitA2).setStrategy(strategy).call(); + assertEquals(mergeResult.getNewHead(), null); + assertEquals(mergeResult.getMergeStatus(), MergeStatus.CONFLICTING); + // Resolve the conflict manually - write a file + git.rm().addFilepattern("a").call(); + git.rm().addFilepattern("a/content").call(); + writeTrashFile("a", + "content in Child 3 (commited on second-branch) - merge conflict resolution"); + git.add().addFilepattern("a").call(); + RevCommit commitC3S = git.commit().setMessage("Child 3 on second bug - resolve merge conflict") + .call(); + + // Merge branch-to-merge into master + git.checkout().setName("master").call(); + mergeResult = git.merge().include(commitA2).setStrategy(strategy).call(); + assertEquals(mergeResult.getNewHead(), null); + assertEquals(mergeResult.getMergeStatus(), MergeStatus.CONFLICTING); + + // Resolve the conflict manually - write a file + git.rm().addFilepattern("a").call(); + git.rm().addFilepattern("a/content").call(); + writeTrashFile("a", "content in Child 4 (commited on master) - merge conflict resolution"); + git.add().addFilepattern("a").call(); + // commit C4M + git.commit().setMessage("Child 4 on master - resolve merge conflict").call(); + + // Merge C4M (second-branch) into master (C3S) + // Conflict in virtual base should be here + mergeResult = git.merge().include(commitC3S).call(); + assertEquals(mergeResult.getMergeStatus(), MergeStatus.CONFLICTING); + String expected = + "<<<<<<< HEAD\n" + "content in Child 4 (commited on master) - merge conflict resolution\n" + + "=======\n" + + "content in Child 3 (commited on second-branch) - merge conflict resolution\n" + + ">>>>>>> " + commitC3S.name() + "\n"; + assertEquals(expected, read("a")); + // Nothing was populated from the ancestors. + assertEquals( + "[a, mode:100644, stage:2, content:content in Child 4 (commited on master) - merge conflict resolution][a, mode:100644, stage:3, content:content in Child 3 (commited on second-branch) - merge conflict resolution]", + indexState(CONTENT)); + } + + /** + * Same test as above, but "a" is a dir in A1 and a file in A2 + */ + @Theory + public void checkFileDirMergeConflictInVirtualAncestor_ConflictInChildren_DirFile(MergeStrategy strategy) + throws Exception { + if (!strategy.equals(MergeStrategy.RECURSIVE)) { + return; + } + + Git git = Git.wrap(db); + + // master + writeTrashFile("a/content", "initial content"); + git.add().addFilepattern("a/content").call(); + RevCommit commitI = git.commit().setMessage("Initial commit").call(); + + writeTrashFile("a/content", "content in Ancestor 1"); + git.add().addFilepattern("a/content").call(); + RevCommit commitA1 = git.commit().setMessage("Ancestor 1").call(); + + writeTrashFile("a/content", "content in Child 1 (commited on master)"); + git.add().addFilepattern("a/content").call(); + // commit C1M + git.commit().setMessage("Child 1 on master").call(); + + git.checkout().setCreateBranch(true).setStartPoint(commitI).setName("branch-to-merge").call(); + + // "a" becomes a file in A2 + git.rm().addFilepattern("a/content").call(); + writeTrashFile("a", "content in Ancestor 2 (commited on branch-to-merge)"); + git.add().addFilepattern("a").call(); + RevCommit commitA2 = git.commit().setMessage("Ancestor 2").call(); + + // second branch + git.checkout().setCreateBranch(true).setStartPoint(commitA1).setName("second-branch").call(); + writeTrashFile("a/content", "content in Child 2 (commited on second-branch)"); + git.add().addFilepattern("a/content").call(); + // commit C2S + git.commit().setMessage("Child 2 on second-branch").call(); + + // Merge branch-to-merge into second-branch + MergeResult mergeResult = git.merge().include(commitA2).setStrategy(strategy).call(); + assertEquals(mergeResult.getNewHead(), null); + assertEquals(mergeResult.getMergeStatus(), MergeStatus.CONFLICTING); + // Resolve the conflict manually - write a file + git.rm().addFilepattern("a").call(); + git.rm().addFilepattern("a/content").call(); + deleteTrashFile("a/content"); + deleteTrashFile("a"); + writeTrashFile("a", "content in Child 3 (commited on second-branch) - merge conflict resolution"); + git.add().addFilepattern("a").call(); + RevCommit commitC3S = git.commit().setMessage("Child 3 on second bug - resolve merge conflict").call(); + + // Merge branch-to-merge into master + git.checkout().setName("master").call(); + mergeResult = git.merge().include(commitA2).setStrategy(strategy).call(); + assertEquals(mergeResult.getNewHead(), null); + assertEquals(mergeResult.getMergeStatus(), MergeStatus.CONFLICTING); + + // Resolve the conflict manually - write a file + git.rm().addFilepattern("a").call(); + git.rm().addFilepattern("a/content").call(); + deleteTrashFile("a/content"); + deleteTrashFile("a"); + writeTrashFile("a", "content in Child 4 (commited on master) - merge conflict resolution"); + git.add().addFilepattern("a").call(); + // commit C4M + git.commit().setMessage("Child 4 on master - resolve merge conflict").call(); + + // Merge C4M (second-branch) into master (C3S) + // Conflict in virtual base should be here + mergeResult = git.merge().include(commitC3S).call(); + assertEquals(mergeResult.getMergeStatus(), MergeStatus.CONFLICTING); + String expected = "<<<<<<< HEAD\n" + "content in Child 4 (commited on master) - merge conflict resolution\n" + + "=======\n" + "content in Child 3 (commited on second-branch) - merge conflict resolution\n" + + ">>>>>>> " + commitC3S.name() + "\n"; + assertEquals(expected, read("a")); + // Nothing was populated from the ancestors. + assertEquals( + "[a, mode:100644, stage:2, content:content in Child 4 (commited on master) - merge conflict resolution][a, mode:100644, stage:3, content:content in Child 3 (commited on second-branch) - merge conflict resolution]", + indexState(CONTENT)); + } + private void writeSubmodule(String path, ObjectId commit) throws IOException, ConfigInvalidException { addSubmoduleToIndex(path, commit); 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 6c217fdf2..4bfb38d28 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java @@ -703,18 +703,21 @@ protected boolean processEntry(CanonicalTreeParser base, // conflict between ours and theirs. file/folder conflicts between // base/index/workingTree and something else are not relevant or // detected later - if (nonTree(modeO) && !nonTree(modeT)) { + if (nonTree(modeO) != nonTree(modeT)) { + if (ignoreConflicts) { + // In case of merge failures, ignore this path instead of reporting unmerged, so + // a caller can use virtual commit. This will not result in files with conflict + // markers in the index/working tree. The actual diff on the path will be + // computed directly on children. + enterSubtree = false; + return true; + } if (nonTree(modeB)) add(tw.getRawPath(), base, DirCacheEntry.STAGE_1, EPOCH, 0); - add(tw.getRawPath(), ours, DirCacheEntry.STAGE_2, EPOCH, 0); - unmergedPaths.add(tw.getPathString()); - enterSubtree = false; - return true; - } - if (nonTree(modeT) && !nonTree(modeO)) { - if (nonTree(modeB)) - add(tw.getRawPath(), base, DirCacheEntry.STAGE_1, EPOCH, 0); - add(tw.getRawPath(), theirs, DirCacheEntry.STAGE_3, EPOCH, 0); + if (nonTree(modeO)) + add(tw.getRawPath(), ours, DirCacheEntry.STAGE_2, EPOCH, 0); + if (nonTree(modeT)) + add(tw.getRawPath(), theirs, DirCacheEntry.STAGE_3, EPOCH, 0); unmergedPaths.add(tw.getPathString()); enterSubtree = false; return true;