From 29a6b42fceef31af3cd34224a8ba2dceb95623e5 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Mon, 24 Jun 2024 15:42:15 +0200 Subject: [PATCH] Check for external merge tools during merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Thomas Zerr Co-authored-by: René Pfeuffer --- gradle/changelog/external_merge_tools.yml | 2 + .../api/MergeDryRunCommandResult.java | 28 +++- .../repository/api/MergePreventReason.java | 35 +++++ .../api/MergePreventReasonType.java | 44 ++++++ .../scm/repository/spi/AttributeAnalyzer.java | 119 +++++++++++++++++ .../scm/repository/spi/GitMergeCommand.java | 43 ++++-- .../repository/spi/AttributeAnalyzerTest.java | 125 ++++++++++++++++++ ....java => GitMergeCommandConflictTest.java} | 9 +- .../repository/spi/GitMergeCommandTest.java | 65 +++++++-- .../scm/repository/mergetool.gitattributes | 20 +++ .../spi/scm-git-attributes-spi-test.zip | Bin 0 -> 39532 bytes 11 files changed, 465 insertions(+), 25 deletions(-) create mode 100644 gradle/changelog/external_merge_tools.yml create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/MergePreventReason.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/MergePreventReasonType.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AttributeAnalyzer.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AttributeAnalyzerTest.java rename scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/{GitMergeCommand_Conflict_Test.java => GitMergeCommandConflictTest.java} (91%) create mode 100644 scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/mergetool.gitattributes create mode 100644 scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-attributes-spi-test.zip diff --git a/gradle/changelog/external_merge_tools.yml b/gradle/changelog/external_merge_tools.yml new file mode 100644 index 0000000000..abb526faa8 --- /dev/null +++ b/gradle/changelog/external_merge_tools.yml @@ -0,0 +1,2 @@ +- type: added + description: Check for external merge tools during merge diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MergeDryRunCommandResult.java b/scm-core/src/main/java/sonia/scm/repository/api/MergeDryRunCommandResult.java index c1a1c42933..b78ca3fdfa 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/MergeDryRunCommandResult.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/MergeDryRunCommandResult.java @@ -21,9 +21,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository.api; +import java.util.Collection; +import java.util.List; + /** * This class keeps the result of a merge dry run. Use {@link #isMergeable()} to check whether an automatic merge is * possible or not. @@ -31,9 +34,25 @@ package sonia.scm.repository.api; public class MergeDryRunCommandResult { private final boolean mergeable; + private final Collection reasons; + /** + * Creates a result and sets the reason to FILE_CONFLICTS. + * + * @deprecated Please use {@link MergeDryRunCommandResult#MergeDryRunCommandResult(boolean, Collection)} + * instead and specify a concrete reason. + */ + @Deprecated public MergeDryRunCommandResult(boolean mergeable) { + this(mergeable, List.of(new MergePreventReason(MergePreventReasonType.FILE_CONFLICTS))); + } + + /** + * @since 3.3.0 + */ + public MergeDryRunCommandResult(boolean mergeable, Collection reasons) { this.mergeable = mergeable; + this.reasons = reasons; } /** @@ -43,4 +62,11 @@ public class MergeDryRunCommandResult { public boolean isMergeable() { return mergeable; } + + /** + * This will return the reasons why the merge via the internal merge command is not possible. + */ + public Collection getReasons() { + return reasons; + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MergePreventReason.java b/scm-core/src/main/java/sonia/scm/repository/api/MergePreventReason.java new file mode 100644 index 0000000000..ded3a3491f --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/MergePreventReason.java @@ -0,0 +1,35 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.api; + +import lombok.Value; + +/** + * @since 3.3.0 + */ +@Value +public class MergePreventReason { + MergePreventReasonType type; +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MergePreventReasonType.java b/scm-core/src/main/java/sonia/scm/repository/api/MergePreventReasonType.java new file mode 100644 index 0000000000..96904cd6fc --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/MergePreventReasonType.java @@ -0,0 +1,44 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.api; + +/** + * @since 3.3.0 + */ +public enum MergePreventReasonType { + /** + File conflicts are the typical reason why merges are not possible. Common examples are: + - File added on both branches + - File modified on one branch but deleted on the other + - File modified on both branches + Reference: {@link sonia.scm.repository.spi.MergeConflictResult.ConflictTypes} + */ + FILE_CONFLICTS, + /** + * It is also possible that we cannot perform a merge properly because files would be affected which have to be merged by an external merge tool. + * For git these merge tools are configured in the .gitattributes file inside the repository. + */ + EXTERNAL_MERGE_TOOL +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AttributeAnalyzer.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AttributeAnalyzer.java new file mode 100644 index 0000000000..290518c6b8 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AttributeAnalyzer.java @@ -0,0 +1,119 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import com.google.inject.assistedinject.Assisted; +import jakarta.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.attributes.Attributes; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.util.LfsFactory; +import sonia.scm.NotFoundException; +import sonia.scm.repository.GitUtil; +import sonia.scm.repository.Modified; + +import java.io.IOException; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Slf4j +class AttributeAnalyzer { + + private static final String MERGE_TOOL_ATTRIBUTE_KEY = "merge"; + private final GitContext context; + private final GitModificationsCommand modificationsCommand; + + @Inject + AttributeAnalyzer(@Assisted GitContext context, GitModificationsCommand modificationsCommand) { + this.context = context; + this.modificationsCommand = modificationsCommand; + } + + Optional getAttributes(RevCommit commit, String path) throws NotFoundException { + try (Repository repository = context.open()) { + Attributes attributesForPath = LfsFactory.getAttributesForPath(repository, path, commit); + if (attributesForPath.isEmpty()) { + return Optional.empty(); + } + return Optional.of(attributesForPath); + } catch (IOException e) { + log.debug("Failed to get attributes", e); + return Optional.empty(); + } + } + + boolean hasExternalMergeToolConflicts(String source, String target) { + try (Repository repo = context.open()) { + String commonAncestorRevision = GitUtil.computeCommonAncestor(repo, GitUtil.getRevisionId(repo, source), GitUtil.getRevisionId(repo, target)).name(); + return findExternalMergeToolConflicts(source, target, commonAncestorRevision); + } catch (IOException | NotFoundException e) { + log.debug("Failed to read/parse '.gitattributes' files", e); + return false; + } + } + + boolean findExternalMergeToolConflicts(String source, String target, String commonAncestor) throws IOException { + List changesInBoth = getPossiblyAffectedPaths(source, target, commonAncestor); + RevCommit targetCommit = getTargetCommit(target); + + for (String path : changesInBoth) { + Optional attributes = getAttributes(targetCommit, path); + if (attributes.isPresent() && attributes.get().get(MERGE_TOOL_ATTRIBUTE_KEY) != null) { + return true; + } + } + return false; + } + + private List getPossiblyAffectedPaths(String source, String target, String commonAncestor) { + Collection fromSourceToAncestor = findModifiedPaths(source, commonAncestor); + Collection fromTargetToAncestor = findModifiedPaths(target, commonAncestor); + return fromSourceToAncestor.stream().filter(fromTargetToAncestor::contains).toList(); + } + + private Collection findModifiedPaths(String baseRevision, String targetRevision) { + return modificationsCommand.getModifications(baseRevision, targetRevision).getModified() + .stream().map(Modified::getPath).collect(Collectors.toSet()); + } + + RevCommit getTargetCommit(String target) throws IOException { + try (Repository repository = context.open()) { + RevWalk rw = new org.eclipse.jgit.revwalk.RevWalk(repository); + return GitUtil.getCommit(repository, rw, repository.findRef(target)); + } catch (IOException e) { + log.debug("Failed to get target commit", e); + throw e; + } + } + + public interface Factory { + AttributeAnalyzer create(GitContext context); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java index cd31673199..c99cccb586 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java @@ -35,7 +35,6 @@ import org.eclipse.jgit.lib.IndexDiff; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectReader; import org.eclipse.jgit.lib.Repository; -import org.eclipse.jgit.merge.ResolveMerger; import org.eclipse.jgit.treewalk.CanonicalTreeParser; import org.eclipse.jgit.treewalk.filter.PathFilter; import sonia.scm.repository.GitRepositoryHandler; @@ -43,11 +42,15 @@ import sonia.scm.repository.GitWorkingCopyFactory; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.api.MergeCommandResult; import sonia.scm.repository.api.MergeDryRunCommandResult; +import sonia.scm.repository.api.MergePreventReason; +import sonia.scm.repository.api.MergePreventReasonType; import sonia.scm.repository.api.MergeStrategy; import sonia.scm.repository.api.MergeStrategyNotSupportedException; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.util.Set; import static org.eclipse.jgit.merge.MergeStrategy.RECURSIVE; @@ -57,7 +60,7 @@ import static sonia.scm.NotFoundException.notFound; public class GitMergeCommand extends AbstractGitCommand implements MergeCommand { private final GitWorkingCopyFactory workingCopyFactory; - + private final AttributeAnalyzer attributeAnalyzer; private static final Set STRATEGIES = ImmutableSet.of( MergeStrategy.MERGE_COMMIT, MergeStrategy.FAST_FORWARD_IF_POSSIBLE, @@ -66,13 +69,14 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand ); @Inject - GitMergeCommand(@Assisted GitContext context, GitRepositoryHandler handler) { - this(context, handler.getWorkingCopyFactory()); + GitMergeCommand(@Assisted GitContext context, GitRepositoryHandler handler, AttributeAnalyzer attributeAnalyzer) { + this(context, handler.getWorkingCopyFactory(), attributeAnalyzer); } - GitMergeCommand(@Assisted GitContext context, GitWorkingCopyFactory workingCopyFactory) { + GitMergeCommand(@Assisted GitContext context, GitWorkingCopyFactory workingCopyFactory, AttributeAnalyzer attributeAnalyzer) { super(context); this.workingCopyFactory = workingCopyFactory; + this.attributeAnalyzer = attributeAnalyzer; } @Override @@ -86,7 +90,7 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand } private MergeCommandResult mergeWithStrategy(MergeCommandRequest request) { - switch(request.getMergeStrategy()) { + switch (request.getMergeStrategy()) { case SQUASH: return inClone(clone -> new GitMergeWithSquash(clone, request, context, repository), workingCopyFactory, request.getTargetBranch()); @@ -103,21 +107,32 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand throw new MergeStrategyNotSupportedException(repository, request.getMergeStrategy()); } } - + @Override public MergeDryRunCommandResult dryRun(MergeCommandRequest request) { - try { - Repository repository = context.open(); - ResolveMerger merger = (ResolveMerger) RECURSIVE.newMerger(repository, true); - return new MergeDryRunCommandResult( - merger.merge( - resolveRevisionOrThrowNotFound(repository, request.getBranchToMerge()), - resolveRevisionOrThrowNotFound(repository, request.getTargetBranch()))); + try (Repository repository = context.open()) { + List mergePreventReasons = new ArrayList<>(2); + if (attributeAnalyzer.hasExternalMergeToolConflicts(request.getBranchToMerge(), request.getTargetBranch())) { + mergePreventReasons.add(new MergePreventReason(MergePreventReasonType.EXTERNAL_MERGE_TOOL)); + } + + if (!isMergeableWithoutFileConflicts(repository, request.getBranchToMerge(), request.getTargetBranch())) { + mergePreventReasons.add(new MergePreventReason(MergePreventReasonType.FILE_CONFLICTS)); + } + + return new MergeDryRunCommandResult(mergePreventReasons.isEmpty(), mergePreventReasons); } catch (IOException e) { throw new InternalRepositoryException(context.getRepository(), "could not clone repository for merge", e); } } + private boolean isMergeableWithoutFileConflicts(Repository repository, String sourceRevision, String targetRevision) throws IOException { + return RECURSIVE.newMerger(repository, true).merge( + resolveRevisionOrThrowNotFound(repository,sourceRevision), + resolveRevisionOrThrowNotFound(repository, targetRevision) + ); + } + @Override public boolean isSupported(MergeStrategy strategy) { return STRATEGIES.contains(strategy); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AttributeAnalyzerTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AttributeAnalyzerTest.java new file mode 100644 index 0000000000..fe65d3eb9d --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AttributeAnalyzerTest.java @@ -0,0 +1,125 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import org.eclipse.jgit.attributes.Attribute; +import org.eclipse.jgit.attributes.Attributes; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +import java.io.IOException; +import java.util.Collection; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(MockitoJUnitRunner.class) +public class AttributeAnalyzerTest extends AbstractGitCommandTestBase { + + private AttributeAnalyzer attributeAnalyzer; + + @Before + public void initAnalyzer() { + attributeAnalyzer = new AttributeAnalyzer(createContext(), new GitModificationsCommand(createContext())); + } + + @Test + public void shouldNotGetAttributesIfFileDoesNotExist() throws IOException { + RevCommit commit = attributeAnalyzer.getTargetCommit("main"); + Optional attributes = attributeAnalyzer.getAttributes(commit,"text1234.txt"); + + assertThat(attributes).isNotPresent(); + } + + @Test + public void shouldNotGetAttributesIfDoesNotExistForFilePattern() throws IOException { + RevCommit commit = attributeAnalyzer.getTargetCommit("main"); + Optional attributes = attributeAnalyzer.getAttributes(commit,"text.txt"); + + assertThat(attributes).isNotPresent(); + } + + @Test + public void shouldGetAttributes() throws IOException { + RevCommit commit = attributeAnalyzer.getTargetCommit("main"); + Optional attributes = attributeAnalyzer.getAttributes(commit,"text.ipr"); + + assertThat(attributes).isPresent(); + Collection attributeCollection = attributes.get().getAll(); + Attribute firstAttribute = attributeCollection.iterator().next(); + assertThat(firstAttribute.getKey()).isEqualTo("merge"); + assertThat(firstAttribute.getValue()).isEqualTo("mps"); + } + + @Test + public void shouldCheckIfMergeIsPreventedByExternalMergeTools_WithNoGitAttributes() { + String source = "change_possibly_needing_merge_tool"; + String target = "removed_git_attributes"; + assertThat(attributeAnalyzer.hasExternalMergeToolConflicts(source, target)).isFalse(); + } + + @Test + public void shouldCheckIfMergeIsPreventedByExternalMergeTools_WithEmptyGitAttributes() { + String source = "change_possibly_needing_merge_tool"; + String target = "empty_git_attributes"; + assertThat(attributeAnalyzer.hasExternalMergeToolConflicts(source, target)).isFalse(); + } + + @Test + public void shouldCheckIfMergeIsPreventedByExternalMergeTools_PatternFoundButNoMergeToolConfigured() { + String source = "change_possibly_needing_merge_tool"; + String target = "removed_merge_tool_from_attributes"; + assertThat(attributeAnalyzer.hasExternalMergeToolConflicts(source, target)).isFalse(); + } + + @Test + public void shouldCheckIfMergeIsPreventedByExternalMergeTools_WithoutMergeToolRelevantChange() { + String source = "change_not_needing_merge_tool"; + String target = "conflicting_change_not_needing_merge_tool"; + assertThat(attributeAnalyzer.hasExternalMergeToolConflicts(source, target)).isFalse(); + } + + @Test + public void shouldCheckIfMergeIsPreventedByExternalMergeTools_WithMergeToolRelevantChangeButFastForwardable() { + String source = "change_possibly_needing_merge_tool"; + String target = "main"; + assertThat(attributeAnalyzer.hasExternalMergeToolConflicts(source, target)).isFalse(); + } + + @Test + public void shouldCheckIfMergeIsPreventedByExternalMergeTools_WithMergeToolRelevantChangeAndPossibleConflict() { + String source = "change_possibly_needing_merge_tool"; + String target = "conflicting_change_possibly_needing_merge_tool"; + assertThat(attributeAnalyzer.hasExternalMergeToolConflicts(source, target)).isTrue(); + } + + @Override + protected String getZippedRepositoryResource() { + return "sonia/scm/repository/spi/scm-git-attributes-spi-test.zip"; + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommand_Conflict_Test.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandConflictTest.java similarity index 91% rename from scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommand_Conflict_Test.java rename to scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandConflictTest.java index 31fad05474..ab8d680b2b 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommand_Conflict_Test.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandConflictTest.java @@ -34,11 +34,14 @@ import sonia.scm.repository.work.WorkdirProvider; import java.io.IOException; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static sonia.scm.repository.spi.MergeConflictResult.ConflictTypes.BOTH_MODIFIED; import static sonia.scm.repository.spi.MergeConflictResult.ConflictTypes.DELETED_BY_THEM; import static sonia.scm.repository.spi.MergeConflictResult.ConflictTypes.DELETED_BY_US; -public class GitMergeCommand_Conflict_Test extends AbstractGitCommandTestBase { +public class GitMergeCommandConflictTest extends AbstractGitCommandTestBase { static final String DIFF_HEADER = "diff --git a/Main.java b/Main.java"; static final String DIFF_FILE_CONFLICT = "--- a/Main.java\n" + @@ -93,7 +96,9 @@ public class GitMergeCommand_Conflict_Test extends AbstractGitCommandTestBase { } private MergeConflictResult computeMergeConflictResult(String branchToMerge, String targetBranch) { - GitMergeCommand gitMergeCommand = new GitMergeCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry())); + AttributeAnalyzer attributeAnalyzer = mock(AttributeAnalyzer.class); + when(attributeAnalyzer.hasExternalMergeToolConflicts(any(), any())).thenReturn(false); + GitMergeCommand gitMergeCommand = new GitMergeCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry()), attributeAnalyzer); MergeCommandRequest mergeCommandRequest = new MergeCommandRequest(); mergeCommandRequest.setBranchToMerge(branchToMerge); mergeCommandRequest.setTargetBranch(targetBranch); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java index b112302eba..af46c03022 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java @@ -40,6 +40,9 @@ import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; import org.junit.jupiter.api.Assertions; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.NoChangesMadeException; import sonia.scm.NotFoundException; import sonia.scm.repository.Added; @@ -47,6 +50,9 @@ import sonia.scm.repository.GitTestHelper; import sonia.scm.repository.GitWorkingCopyFactory; import sonia.scm.repository.Person; import sonia.scm.repository.api.MergeCommandResult; +import sonia.scm.repository.api.MergeDryRunCommandResult; +import sonia.scm.repository.api.MergePreventReason; +import sonia.scm.repository.api.MergePreventReasonType; import sonia.scm.repository.api.MergeStrategy; import sonia.scm.repository.work.NoneCachingWorkingCopyPool; import sonia.scm.repository.work.WorkdirProvider; @@ -61,7 +67,9 @@ import java.util.function.Consumer; import java.util.function.Function; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +@RunWith(MockitoJUnitRunner.class) @SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") public class GitMergeCommandTest extends AbstractGitCommandTestBase { @@ -71,6 +79,8 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { public ShiroRule shiro = new ShiroRule(); @Rule public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule(); + @Mock + private AttributeAnalyzer attributeAnalyzer; @BeforeClass public static void setSigner() { @@ -84,21 +94,60 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { request.setBranchToMerge("mergeable"); request.setTargetBranch("master"); - boolean mergeable = command.dryRun(request).isMergeable(); + MergeDryRunCommandResult result = command.dryRun(request); - assertThat(mergeable).isTrue(); + assertThat(result.isMergeable()).isTrue(); + assertThat(result.getReasons()).isEmpty(); } @Test - public void shouldDetectNotMergeableBranches() { + public void shouldDetectNotMergeableBranches_FileConflict() { GitMergeCommand command = createCommand(); MergeCommandRequest request = new MergeCommandRequest(); request.setBranchToMerge("test-branch"); request.setTargetBranch("master"); - boolean mergeable = command.dryRun(request).isMergeable(); + MergeDryRunCommandResult result = command.dryRun(request); - assertThat(mergeable).isFalse(); + assertThat(result.isMergeable()).isFalse(); + assertThat(result.getReasons().size()).isEqualTo(1); + assertThat(result.getReasons().stream().toList().get(0).getType()).isEqualTo(MergePreventReasonType.FILE_CONFLICTS); + } + + @Test + public void shouldDetectNotMergeableBranches_ExternalMergeTool() { + String source = "mergeable"; + String target = "master"; + when(attributeAnalyzer.hasExternalMergeToolConflicts(source, target)).thenReturn(true); + GitMergeCommand command = createCommand(); + MergeCommandRequest request = new MergeCommandRequest(); + request.setBranchToMerge(source); + request.setTargetBranch(target); + + MergeDryRunCommandResult result = command.dryRun(request); + + assertThat(result.isMergeable()).isFalse(); + assertThat(result.getReasons().size()).isEqualTo(1); + assertThat(result.getReasons().stream().toList().get(0).getType()).isEqualTo(MergePreventReasonType.EXTERNAL_MERGE_TOOL); + } + + @Test + public void shouldDetectNotMergeableBranches_ExternalMergeToolAndFileConflict() { + String source = "test-branch"; + String target = "master"; + when(attributeAnalyzer.hasExternalMergeToolConflicts(source, target)).thenReturn(true); + GitMergeCommand command = createCommand(); + MergeCommandRequest request = new MergeCommandRequest(); + request.setBranchToMerge(source); + request.setTargetBranch(target); + + MergeDryRunCommandResult result = command.dryRun(request); + + assertThat(result.isMergeable()).isFalse(); + assertThat(result.getReasons().size()).isEqualTo(2); + List reasons = result.getReasons().stream().toList(); + assertThat(reasons.get(0).getType()).isEqualTo(MergePreventReasonType.EXTERNAL_MERGE_TOOL); + assertThat(reasons.get(1).getType()).isEqualTo(MergePreventReasonType.FILE_CONFLICTS); } @Test @@ -192,7 +241,7 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { } @Test - public void shouldNotMergeConflictingBranches() { + public void shouldNotMergeConflictingBranches_FileConflict() { GitMergeCommand command = createCommand(); MergeCommandRequest request = new MergeCommandRequest(); request.setBranchToMerge("test-branch"); @@ -419,7 +468,7 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { } @Test(expected = NotFoundException.class) - public void shouldHandleNotExistingTargetBranchInDryRun() { + public void shouldHandleNotExistingTargetBranchInDryRun() throws IOException { GitMergeCommand command = createCommand(); MergeCommandRequest request = new MergeCommandRequest(); request.setTargetBranch("not_existing"); @@ -517,7 +566,7 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { } private GitMergeCommand createCommand(Consumer interceptor) { - return new GitMergeCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry())) { + return new GitMergeCommand(createContext(), new SimpleGitWorkingCopyFactory(new NoneCachingWorkingCopyPool(new WorkdirProvider(null, repositoryLocationResolver)), new SimpleMeterRegistry()), attributeAnalyzer) { @Override > R inClone(Function workerSupplier, GitWorkingCopyFactory workingCopyFactory, String initialBranch) { Function interceptedWorkerSupplier = git -> { diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/mergetool.gitattributes b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/mergetool.gitattributes new file mode 100644 index 0000000000..a89bfa8c8c --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/mergetool.gitattributes @@ -0,0 +1,20 @@ +*.ipr text merge=mps +*.bat text +*.txt text +*.iml text +*.xml text +*.java text +*.mpr text merge=mps +*.css text +*.html text +*.dtd text +*.sh text +dependencies text merge=mps +generated text merge=mps +*.mps text merge=mps +trace.info text merge=mps +*.mpl text merge=mps +*.msd text merge=mps +*.devkit text merge=mps +*.mpsr text merge=mps +*.model text merge=mps diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-attributes-spi-test.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-attributes-spi-test.zip new file mode 100644 index 0000000000000000000000000000000000000000..0347210cd8dbe980d49f49414ac63aec5ad4531f GIT binary patch literal 39532 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 KWc%NNzy1qo=ygZ{ literal 0 HcmV?d00001