diff --git a/scm-core/src/main/java/sonia/scm/repository/Feature.java b/scm-core/src/main/java/sonia/scm/repository/Feature.java index 1db351267d..1bcaef4de5 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Feature.java +++ b/scm-core/src/main/java/sonia/scm/repository/Feature.java @@ -45,5 +45,10 @@ public enum Feature * The default branch of the repository is a combined branch of all * repository branches. */ - COMBINED_DEFAULT_BRANCH + COMBINED_DEFAULT_BRANCH, + /** + * The repository supports computation of incoming changes (either diff or list of changesets) of one branch + * in respect to another target branch. + */ + INCOMING_REVISION } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java index 7217d0e97a..32b633a67c 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java @@ -38,6 +38,8 @@ package sonia.scm.repository.api; import com.google.common.base.Preconditions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.NotSupportedFeatureException; +import sonia.scm.repository.Feature; import sonia.scm.repository.spi.DiffCommand; import sonia.scm.repository.spi.DiffCommandRequest; import sonia.scm.util.IOUtil; @@ -45,6 +47,7 @@ import sonia.scm.util.IOUtil; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -85,10 +88,12 @@ public final class DiffCommandBuilder * only be called from the {@link RepositoryService}. * * @param diffCommand implementation of {@link DiffCommand} + * @param supportedFeatures The supported features of the provider */ - DiffCommandBuilder(DiffCommand diffCommand) + DiffCommandBuilder(DiffCommand diffCommand, Set supportedFeatures) { this.diffCommand = diffCommand; + this.supportedFeatures = supportedFeatures; } //~--- methods -------------------------------------------------------------- @@ -174,7 +179,8 @@ public final class DiffCommandBuilder } /** - * Show the difference only for the given revision. + * Show the difference only for the given revision or (using {@link #setAncestorChangeset(String)}) between this + * and another revision. * * * @param revision revision for difference @@ -187,6 +193,22 @@ public final class DiffCommandBuilder return this; } + /** + * Compute the incoming changes of the branch set with {@link #setRevision(String)} in respect to the changeset given + * here. In other words: What changes would be new to the ancestor changeset given here when the branch would + * be merged into it. Requires feature {@link sonia.scm.repository.Feature#INCOMING_REVISION}! + * + * @return {@code this} + */ + public DiffCommandBuilder setAncestorChangeset(String revision) + { + if (!supportedFeatures.contains(Feature.INCOMING_REVISION)) { + throw new NotSupportedFeatureException(Feature.INCOMING_REVISION.name()); + } + request.setAncestorChangeset(revision); + + return this; + } //~--- get methods ---------------------------------------------------------- @@ -215,6 +237,7 @@ public final class DiffCommandBuilder /** implementation of the diff command */ private final DiffCommand diffCommand; + private Set supportedFeatures; /** request for the diff command implementation */ private final DiffCommandRequest request = new DiffCommandRequest(); diff --git a/scm-core/src/main/java/sonia/scm/repository/api/LogCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/LogCommandBuilder.java index 73062a0244..7b8e172661 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/LogCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/LogCommandBuilder.java @@ -39,10 +39,12 @@ import com.google.common.base.Objects; import com.google.common.collect.ImmutableList; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.NotSupportedFeatureException; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; import sonia.scm.repository.Changeset; import sonia.scm.repository.ChangesetPagingResult; +import sonia.scm.repository.Feature; import sonia.scm.repository.PreProcessorUtil; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryCacheKey; @@ -51,6 +53,7 @@ import sonia.scm.repository.spi.LogCommandRequest; import java.io.IOException; import java.io.Serializable; +import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -104,19 +107,20 @@ public final class LogCommandBuilder /** * Constructs a new {@link LogCommandBuilder}, this constructor should * only be called from the {@link RepositoryService}. - * - * @param cacheManager cache manager + * @param cacheManager cache manager * @param logCommand implementation of the {@link LogCommand} * @param repository repository to query * @param preProcessorUtil + * @param supportedFeatures The supported features of the provider */ LogCommandBuilder(CacheManager cacheManager, LogCommand logCommand, - Repository repository, PreProcessorUtil preProcessorUtil) + Repository repository, PreProcessorUtil preProcessorUtil, Set supportedFeatures) { this.cache = cacheManager.getCache(CACHE_NAME); this.logCommand = logCommand; this.repository = repository; this.preProcessorUtil = preProcessorUtil; + this.supportedFeatures = supportedFeatures; } //~--- methods -------------------------------------------------------------- @@ -397,7 +401,17 @@ public final class LogCommandBuilder return this; } + /** + * Compute the incoming changes of the branch set with {@link #setBranch(String)} in respect to the changeset given + * here. In other words: What changesets would be new to the ancestor changeset given here when the branch would + * be merged into it. Requires feature {@link sonia.scm.repository.Feature#INCOMING_REVISION}! + * + * @return {@code this} + */ public LogCommandBuilder setAncestorChangeset(String ancestorChangeset) { + if (!supportedFeatures.contains(Feature.INCOMING_REVISION)) { + throw new NotSupportedFeatureException(Feature.INCOMING_REVISION.name()); + } request.setAncestorChangeset(ancestorChangeset); return this; } @@ -527,6 +541,7 @@ public final class LogCommandBuilder /** Field description */ private final PreProcessorUtil preProcessorUtil; + private Set supportedFeatures; /** repository to query */ private final Repository repository; diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java index 881a374864..8fcfc937e5 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java @@ -15,7 +15,7 @@ import sonia.scm.repository.spi.MergeCommandRequest; * * To actually merge feature_branch into integration_branch do this: *

- *     repositoryService.gerMergeCommand()
+ *     repositoryService.getMergeCommand()
  *       .setBranchToMerge("feature_branch")
  *       .setTargetBranch("integration_branch")
  *       .executeMerge();
@@ -33,7 +33,7 @@ import sonia.scm.repository.spi.MergeCommandRequest;
  *
  * To check whether they can be merged without conflicts beforehand do this:
  * 

- *     repositoryService.gerMergeCommand()
+ *     repositoryService.getMergeCommand()
  *       .setBranchToMerge("feature_branch")
  *       .setTargetBranch("integration_branch")
  *       .dryRun()
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java
index fe0529e6b5..ad53c3a8f7 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java
@@ -221,7 +221,7 @@ public final class RepositoryService implements Closeable {
     logger.debug("create diff command for repository {}",
       repository.getNamespaceAndName());
 
-    return new DiffCommandBuilder(provider.getDiffCommand());
+    return new DiffCommandBuilder(provider.getDiffCommand(), provider.getSupportedFeatures());
   }
 
   /**
@@ -253,7 +253,7 @@ public final class RepositoryService implements Closeable {
       repository.getNamespaceAndName());
 
     return new LogCommandBuilder(cacheManager, provider.getLogCommand(),
-      repository, preProcessorUtil);
+      repository, preProcessorUtil, provider.getSupportedFeatures());
   }
 
   /**
@@ -363,8 +363,8 @@ public final class RepositoryService implements Closeable {
    *                                      by the implementation of the repository service provider.
    * @since 2.0.0
    */
-  public MergeCommandBuilder gerMergeCommand() {
-    logger.debug("create unbundle command for repository {}",
+  public MergeCommandBuilder getMergeCommand() {
+    logger.debug("create merge command for repository {}",
       repository.getNamespaceAndName());
 
     return new MergeCommandBuilder(provider.getMergeCommand());
diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java
index e2a2218d34..2a409482c8 100644
--- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java
+++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java
@@ -41,6 +41,8 @@ public class VndMediaType {
   public static final String PASSWORD_CHANGE = PREFIX + "passwordChange" + SUFFIX;
   @SuppressWarnings("squid:S2068")
   public static final String PASSWORD_OVERWRITE = PREFIX + "passwordOverwrite" + SUFFIX;
+  public static final String MERGE_RESULT = PREFIX + "mergeResult" + SUFFIX;
+  public static final String MERGE_COMMAND = PREFIX + "mergeCommand" + SUFFIX;
 
   public static final String ME = PREFIX + "me" + SUFFIX;
   public static final String SOURCE = PREFIX + "source" + SUFFIX;
diff --git a/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java b/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java
index 3481ccd0d1..8e1a6e5ef3 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java
@@ -48,6 +48,7 @@ import org.eclipse.jgit.lib.RepositoryCache;
 
 import sonia.scm.repository.GitRepositoryHandler;
 import sonia.scm.repository.spi.HookEventFacade;
+import sonia.scm.web.CollectingPackParserListener;
 import sonia.scm.web.GitReceiveHook;
 
 //~--- JDK imports ------------------------------------------------------------
@@ -64,10 +65,10 @@ public class ScmTransportProtocol extends TransportProtocol
 {
 
   /** Field description */
-  private static final String NAME = "scm";
+  public static final String NAME = "scm";
 
   /** Field description */
-  private static final Set SCHEMES = ImmutableSet.of("scm");
+  private static final Set SCHEMES = ImmutableSet.of(NAME);
 
   //~--- constructors ---------------------------------------------------------
 
@@ -234,6 +235,8 @@ public class ScmTransportProtocol extends TransportProtocol
 
         pack.setPreReceiveHook(hook);
         pack.setPostReceiveHook(hook);
+
+        CollectingPackParserListener.set(pack);
       }
 
       return pack;
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java
index 4d83d14d5d..95225c9e30 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitRepositoryHandler.java
@@ -43,7 +43,6 @@ import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import sonia.scm.SCMContextProvider;
-import sonia.scm.io.FileSystem;
 import sonia.scm.plugin.Extension;
 import sonia.scm.repository.spi.GitRepositoryServiceProvider;
 import sonia.scm.schedule.Scheduler;
@@ -97,7 +96,7 @@ public class GitRepositoryHandler
   private final GitWorkdirFactory workdirFactory;
 
   private Task task;
-  
+
   //~--- constructors ---------------------------------------------------------
 
   @Inject
@@ -126,7 +125,7 @@ public class GitRepositoryHandler
     scheduleGc(config.getGcExpression());
     super.setConfig(config);
   }
-  
+
   private void scheduleGc(String expression)
   {
     synchronized (LOCK){
@@ -142,7 +141,7 @@ public class GitRepositoryHandler
       }
     }
   }
-  
+
   /**
    * Method description
    *
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 5e9eac5230..be91d06361 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
@@ -4,8 +4,10 @@ import com.google.common.base.Strings;
 import org.apache.shiro.SecurityUtils;
 import org.apache.shiro.subject.Subject;
 import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.MergeCommand.FastForwardMode;
 import org.eclipse.jgit.api.MergeResult;
 import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.api.errors.RefNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.merge.MergeStrategy;
@@ -15,6 +17,7 @@ import org.slf4j.LoggerFactory;
 import sonia.scm.repository.GitWorkdirFactory;
 import sonia.scm.repository.InternalRepositoryException;
 import sonia.scm.repository.Person;
+import sonia.scm.repository.RepositoryPermissions;
 import sonia.scm.repository.api.MergeCommandResult;
 import sonia.scm.repository.api.MergeDryRunCommandResult;
 import sonia.scm.user.User;
@@ -22,6 +25,9 @@ import sonia.scm.user.User;
 import java.io.IOException;
 import java.text.MessageFormat;
 
+import static sonia.scm.ContextEntry.ContextBuilder.entity;
+import static sonia.scm.NotFoundException.notFound;
+
 public class GitMergeCommand extends AbstractGitCommand implements MergeCommand {
 
   private static final Logger logger = LoggerFactory.getLogger(GitMergeCommand.class);
@@ -40,6 +46,8 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
 
   @Override
   public MergeCommandResult merge(MergeCommandRequest request) {
+    RepositoryPermissions.push(context.getRepository().getId()).check();
+
     try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context)) {
       Repository repository = workingCopy.get();
       logger.debug("cloned repository to folder {}", repository.getWorkTree());
@@ -88,20 +96,43 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
       }
     }
 
-    private void checkOutTargetBranch() {
+    private void checkOutTargetBranch() throws IOException {
       try {
         clone.checkout().setName(target).call();
+      } catch (RefNotFoundException e) {
+        logger.trace("could not checkout target branch {} for merge directly; trying to create local branch", target, e);
+        checkOutTargetAsNewLocalBranch();
       } catch (GitAPIException e) {
         throw new InternalRepositoryException(context.getRepository(), "could not checkout target branch for merge: " + target, e);
       }
     }
 
+    private void checkOutTargetAsNewLocalBranch() throws IOException {
+      try {
+        ObjectId targetRevision = resolveRevision(target);
+        if (targetRevision == null) {
+          throw notFound(entity("revision", target).in(context.getRepository()));
+        }
+        clone.checkout().setStartPoint(targetRevision.getName()).setName(target).setCreateBranch(true).call();
+      } catch (RefNotFoundException e) {
+        logger.debug("could not checkout target branch {} for merge as local branch", target, e);
+        throw notFound(entity("revision", target).in(context.getRepository()));
+      } catch (GitAPIException e) {
+        throw new InternalRepositoryException(context.getRepository(), "could not checkout target branch for merge as local branch: " + target, e);
+      }
+    }
+
     private MergeResult doMergeInClone() throws IOException {
       MergeResult result;
       try {
+        ObjectId sourceRevision = resolveRevision(toMerge);
+        if (sourceRevision == null) {
+          throw notFound(entity("revision", toMerge).in(context.getRepository()));
+        }
         result = clone.merge()
+          .setFastForward(FastForwardMode.NO_FF)
           .setCommit(false) // we want to set the author manually
-          .include(toMerge, resolveRevision(toMerge))
+          .include(toMerge, sourceRevision)
           .call();
       } catch (GitAPIException e) {
         throw new InternalRepositoryException(context.getRepository(), "could not merge branch " + toMerge + " into " + target, e);
@@ -113,10 +144,12 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
       logger.debug("merged branch {} into {}", toMerge, target);
       Person authorToUse = determineAuthor();
       try {
-        clone.commit()
-          .setAuthor(authorToUse.getName(), authorToUse.getMail())
-          .setMessage(MessageFormat.format(determineMessageTemplate(), toMerge, target))
-          .call();
+        if (!clone.status().call().isClean()) {
+          clone.commit()
+            .setAuthor(authorToUse.getName(), authorToUse.getMail())
+            .setMessage(MessageFormat.format(determineMessageTemplate(), toMerge, target))
+            .call();
+        }
       } catch (GitAPIException e) {
         throw new InternalRepositoryException(context.getRepository(), "could not commit merge between branch " + toMerge + " and " + target, e);
       }
@@ -147,7 +180,7 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
       try {
         clone.push().call();
       } catch (GitAPIException e) {
-        throw new InternalRepositoryException(context.getRepository(), "could not push merged branch " + toMerge + " to origin", e);
+        throw new InternalRepositoryException(context.getRepository(), "could not push merged branch " + target + " to origin", e);
       }
       logger.debug("pushed merged branch {}", target);
     }
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java
index ae1af333bc..936962eaba 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java
@@ -34,11 +34,13 @@
 package sonia.scm.repository.spi;
 
 import com.google.common.collect.ImmutableSet;
+import sonia.scm.repository.Feature;
 import sonia.scm.repository.GitRepositoryHandler;
 import sonia.scm.repository.Repository;
 import sonia.scm.repository.api.Command;
 
 import java.io.IOException;
+import java.util.EnumSet;
 import java.util.Set;
 
 //~--- JDK imports ------------------------------------------------------------
@@ -66,6 +68,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
     Command.PULL,
     Command.MERGE
   );
+  protected static final Set FEATURES = EnumSet.of(Feature.INCOMING_REVISION);
   //J+
 
   //~--- constructors ---------------------------------------------------------
@@ -246,6 +249,10 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
     return new GitMergeCommand(context, repository, handler.getWorkdirFactory());
   }
 
+  @Override
+  public Set getSupportedFeatures() {
+    return FEATURES;
+  }
 //~--- fields ---------------------------------------------------------------
 
   /** Field description */
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java
index 22fce5f330..f12818aa80 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java
@@ -3,6 +3,7 @@ package sonia.scm.repository.spi;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ScmTransportProtocol;
 import org.eclipse.jgit.util.FileUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -45,12 +46,16 @@ public class SimpleGitWorkdirFactory implements GitWorkdirFactory {
 
   protected Repository cloneRepository(File bareRepository, File target) throws GitAPIException {
     return Git.cloneRepository()
-      .setURI(bareRepository.getAbsolutePath())
+      .setURI(createScmTransportProtocolUri(bareRepository))
       .setDirectory(target)
       .call()
       .getRepository();
   }
 
+  private String createScmTransportProtocolUri(File bareRepository) {
+    return ScmTransportProtocol.NAME + "://" + bareRepository.getAbsolutePath();
+  }
+
   private void close(Repository repository) {
     repository.close();
     try {
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 1fca7814ed..7e50b48b9a 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
@@ -6,20 +6,33 @@ import org.apache.shiro.subject.SimplePrincipalCollection;
 import org.apache.shiro.subject.Subject;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ScmTransportProtocol;
+import org.eclipse.jgit.transport.Transport;
+import org.junit.After;
+import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
+import sonia.scm.repository.GitRepositoryHandler;
 import sonia.scm.repository.Person;
+import sonia.scm.repository.PreProcessorUtil;
+import sonia.scm.repository.RepositoryManager;
+import sonia.scm.repository.api.HookContextFactory;
 import sonia.scm.repository.api.MergeCommandResult;
 import sonia.scm.user.User;
 
 import java.io.IOException;
 
+import static com.google.inject.util.Providers.of;
 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;
 
-@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini")
+@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret")
 public class GitMergeCommandTest extends AbstractGitCommandTestBase {
 
   private static final String REALM = "AdminRealm";
@@ -27,6 +40,27 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase {
   @Rule
   public ShiroRule shiro = new ShiroRule();
 
+  private ScmTransportProtocol scmTransportProtocol;
+
+  @Before
+  public void bindScmProtocol() {
+    HookContextFactory hookContextFactory = new HookContextFactory(mock(PreProcessorUtil.class));
+    RepositoryManager repositoryManager = mock(RepositoryManager.class);
+    HookEventFacade hookEventFacade = new HookEventFacade(of(repositoryManager), hookContextFactory);
+    GitRepositoryHandler gitRepositoryHandler = mock(GitRepositoryHandler.class);
+    scmTransportProtocol = new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler));
+
+    Transport.register(scmTransportProtocol);
+
+    when(gitRepositoryHandler.getRepositoryId(any())).thenReturn("1");
+    when(repositoryManager.get("1")).thenReturn(new sonia.scm.repository.Repository());
+  }
+
+  @After
+  public void unregisterScmProtocol() {
+    Transport.unregister(scmTransportProtocol);
+  }
+
   @Test
   public void shouldDetectMergeableBranches() {
     GitMergeCommand command = createCommand();
@@ -77,6 +111,30 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase {
     assertThat(new String(contentOfFileB)).isEqualTo("b\ncontent from branch\n");
   }
 
+  @Test
+  public void shouldNotMergeTwice() throws IOException, GitAPIException {
+    GitMergeCommand command = createCommand();
+    MergeCommandRequest request = new MergeCommandRequest();
+    request.setTargetBranch("master");
+    request.setBranchToMerge("mergeable");
+    request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det"));
+
+    MergeCommandResult mergeCommandResult = command.merge(request);
+
+    assertThat(mergeCommandResult.isSuccess()).isTrue();
+
+    Repository repository = createContext().open();
+    ObjectId firstMergeCommit = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call().iterator().next().getId();
+
+    MergeCommandResult secondMergeCommandResult = command.merge(request);
+
+    assertThat(secondMergeCommandResult.isSuccess()).isTrue();
+
+    ObjectId secondMergeCommit = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call().iterator().next().getId();
+
+    assertThat(secondMergeCommit).isEqualTo(firstMergeCommit);
+  }
+
   @Test
   public void shouldUseConfiguredCommitMessageTemplate() throws IOException, GitAPIException {
     GitMergeCommand command = createCommand();
@@ -111,11 +169,14 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase {
   }
 
   @Test
-  @SubjectAware(username = "admin", password = "secret")
   public void shouldTakeAuthorFromSubjectIfNotSet() throws IOException, GitAPIException {
+    SimplePrincipalCollection principals = new SimplePrincipalCollection();
+    principals.add("admin", REALM);
+    principals.add( new User("dirk", "Dirk Gently", "dirk@holistic.det"), REALM);
     shiro.setSubject(
       new Subject.Builder()
-        .principals(new SimplePrincipalCollection(new User("dirk", "Dirk Gently", "dirk@holistic.det"), REALM))
+        .principals(principals)
+        .authenticated(true)
         .buildSubject());
     GitMergeCommand command = createCommand();
     MergeCommandRequest request = new MergeCommandRequest();
@@ -133,6 +194,32 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase {
     assertThat(mergeAuthor.getEmailAddress()).isEqualTo("dirk@holistic.det");
   }
 
+  @Test
+  public void shouldMergeIntoNotDefaultBranch() throws IOException, GitAPIException {
+    GitMergeCommand command = createCommand();
+    MergeCommandRequest request = new MergeCommandRequest();
+    request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det"));
+    request.setTargetBranch("mergeable");
+    request.setBranchToMerge("master");
+
+    MergeCommandResult mergeCommandResult = command.merge(request);
+
+    Repository repository = createContext().open();
+    assertThat(mergeCommandResult.isSuccess()).isTrue();
+
+    Iterable commits = new Git(repository).log().add(repository.resolve("mergeable")).setMaxCount(1).call();
+    RevCommit mergeCommit = commits.iterator().next();
+    PersonIdent mergeAuthor = mergeCommit.getAuthorIdent();
+    String message = mergeCommit.getFullMessage();
+    assertThat(mergeAuthor.getName()).isEqualTo("Dirk Gently");
+    assertThat(mergeAuthor.getEmailAddress()).isEqualTo("dirk@holistic.det");
+    assertThat(message).contains("master", "mergeable");
+    // We expect the merge result of file b.txt here by looking up the sha hash of its content.
+    // If the file is missing (aka not merged correctly) this will throw a MissingObjectException:
+    byte[] contentOfFileB = repository.open(repository.resolve("9513e9c76e73f3e562fd8e4c909d0607113c77c6")).getBytes();
+    assertThat(new String(contentOfFileB)).isEqualTo("b\ncontent from branch\n");
+  }
+
   private GitMergeCommand createCommand() {
     return new GitMergeCommand(createContext(), repository, new SimpleGitWorkdirFactory());
   }
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java
index 0c39a1deb0..da26ebaf20 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java
@@ -2,14 +2,23 @@ package sonia.scm.repository.spi;
 
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ScmTransportProtocol;
+import org.eclipse.jgit.transport.Transport;
+import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
+import sonia.scm.repository.GitRepositoryHandler;
+import sonia.scm.repository.PreProcessorUtil;
+import sonia.scm.repository.RepositoryManager;
+import sonia.scm.repository.api.HookContextFactory;
 
 import java.io.File;
 import java.io.IOException;
 
+import static com.google.inject.util.Providers.of;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
 
@@ -18,6 +27,14 @@ public class SimpleGitWorkdirFactoryTest extends AbstractGitCommandTestBase {
   @Rule
   public TemporaryFolder temporaryFolder = new TemporaryFolder();
 
+  @Before
+  public void bindScmProtocol() {
+    HookContextFactory hookContextFactory = new HookContextFactory(mock(PreProcessorUtil.class));
+    HookEventFacade hookEventFacade = new HookEventFacade(of(mock(RepositoryManager.class)), hookContextFactory);
+    GitRepositoryHandler gitRepositoryHandler = mock(GitRepositoryHandler.class);
+    Transport.register(new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler)));
+  }
+
   @Test
   public void emptyPoolShouldCreateNewWorkdir() throws IOException {
     SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(temporaryFolder.newFolder());
diff --git a/scm-ui-components/packages/ui-components/src/apiclient.js b/scm-ui-components/packages/ui-components/src/apiclient.js
index 3fd90a2d7a..8c4bde6a72 100644
--- a/scm-ui-components/packages/ui-components/src/apiclient.js
+++ b/scm-ui-components/packages/ui-components/src/apiclient.js
@@ -1,8 +1,9 @@
 // @flow
 import {contextPath} from "./urls";
 
-export const NOT_FOUND_ERROR_MESSAGE = "not found";
-export const UNAUTHORIZED_ERROR_MESSAGE = "unauthorized";
+export const NOT_FOUND_ERROR = new Error("not found");
+export const UNAUTHORIZED_ERROR = new Error("unauthorized");
+export const CONFLICT_ERROR = new Error("conflict");
 
 const fetchOptions: RequestOptions = {
   credentials: "same-origin",
@@ -15,24 +16,26 @@ function handleStatusCode(response: Response) {
   if (!response.ok) {
     switch (response.status) {
       case 401:
-        return throwErrorWithMessage(response, UNAUTHORIZED_ERROR_MESSAGE);
+        return throwError(response, UNAUTHORIZED_ERROR);
       case 404:
-        return throwErrorWithMessage(response, NOT_FOUND_ERROR_MESSAGE);
+        return throwError(response, NOT_FOUND_ERROR);
+      case 409:
+        return throwError(response, CONFLICT_ERROR);
       default:
-        return throwErrorWithMessage(response, "server returned status code " + response.status);
+        return throwError(response, new Error("server returned status code " + response.status));
     }
 
   }
   return response;
 }
 
-function throwErrorWithMessage(response: Response, message: string) {
+function throwError(response: Response, err: Error) {
   return response.json().then(
     json => {
       throw Error(json.message);
     },
     () => {
-      throw Error(message);
+      throw err;
     }
   );
 }
diff --git a/scm-ui-components/packages/ui-components/src/index.js b/scm-ui-components/packages/ui-components/src/index.js
index 4648d5532d..91c41a9d25 100644
--- a/scm-ui-components/packages/ui-components/src/index.js
+++ b/scm-ui-components/packages/ui-components/src/index.js
@@ -25,7 +25,7 @@ export { default as Tooltip } from "./Tooltip";
 export { getPageFromMatch } from "./urls";
 export { default as Autocomplete} from "./Autocomplete";
 
-export { apiClient, NOT_FOUND_ERROR_MESSAGE, UNAUTHORIZED_ERROR_MESSAGE } from "./apiclient.js";
+export { apiClient, NOT_FOUND_ERROR, UNAUTHORIZED_ERROR, CONFLICT_ERROR } from "./apiclient.js";
 
 export * from "./avatar";
 export * from "./buttons";
diff --git a/scm-ui/src/modules/auth.js b/scm-ui/src/modules/auth.js
index 489f701a74..e9bccb8fbc 100644
--- a/scm-ui/src/modules/auth.js
+++ b/scm-ui/src/modules/auth.js
@@ -2,10 +2,7 @@
 import type { Me } from "@scm-manager/ui-types";
 import * as types from "./types";
 
-import {
-  apiClient,
-  UNAUTHORIZED_ERROR_MESSAGE
-} from "@scm-manager/ui-components";
+import { apiClient, UNAUTHORIZED_ERROR } from "@scm-manager/ui-components";
 import { isPending } from "./pending";
 import { getFailure } from "./failure";
 import {
@@ -190,7 +187,7 @@ export const fetchMe = (link: string) => {
         dispatch(fetchMeSuccess(me));
       })
       .catch((error: Error) => {
-        if (error.message === UNAUTHORIZED_ERROR_MESSAGE) {
+        if (error === UNAUTHORIZED_ERROR) {
           dispatch(fetchMeUnauthenticated());
         } else {
           dispatch(fetchMeFailure(error));
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java
index a0d73ad24e..658abbded8 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchRootResource.java
@@ -128,49 +128,6 @@ public class BranchRootResource {
     }
   }
 
-  @Path("{branch}/diffchangesets/{otherBranchName}")
-  @GET
-  @StatusCodes({
-    @ResponseCode(code = 200, condition = "success"),
-    @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
-    @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the changeset"),
-    @ResponseCode(code = 404, condition = "not found, no changesets available in the repository"),
-    @ResponseCode(code = 500, condition = "internal server error")
-  })
-  @Produces(VndMediaType.CHANGESET_COLLECTION)
-  @TypeHint(CollectionDto.class)
-  public Response changesetDiff(@PathParam("namespace") String namespace,
-                          @PathParam("name") String name,
-                          @PathParam("branch") String branchName,
-                          @PathParam("otherBranchName") String otherBranchName,
-                          @DefaultValue("0") @QueryParam("page") int page,
-                          @DefaultValue("10") @QueryParam("pageSize") int pageSize) throws Exception {
-    try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
-      List allBranches = repositoryService.getBranchesCommand().getBranches().getBranches();
-      if (allBranches.stream().noneMatch(branch -> branchName.equals(branch.getName()))) {
-        throw new NotFoundException("branch", branchName);
-      }
-      if (allBranches.stream().noneMatch(branch -> otherBranchName.equals(branch.getName()))) {
-        throw new NotFoundException("branch", otherBranchName);
-      }
-      Repository repository = repositoryService.getRepository();
-      RepositoryPermissions.read(repository).check();
-      ChangesetPagingResult changesets = new PagedLogCommandBuilder(repositoryService)
-        .page(page)
-        .pageSize(pageSize)
-        .create()
-        .setBranch(branchName)
-        .setAncestorChangeset(otherBranchName)
-        .getChangesets();
-      if (changesets != null && changesets.getChangesets() != null) {
-        PageResult pageResult = new PageResult<>(changesets.getChangesets(), changesets.getTotal());
-        return Response.ok(branchChangesetCollectionToDtoMapper.map(page, pageSize, pageResult, repository, branchName)).build();
-      } else {
-        return Response.ok().build();
-      }
-    }
-  }
-
   /**
    * Returns the branches for a repository.
    *
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java
index 8167e28f9a..7ab3ef25a8 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BranchToBranchDtoMapper.java
@@ -28,7 +28,6 @@ public abstract class BranchToBranchDtoMapper {
     Links.Builder linksBuilder = linkingTo()
       .self(resourceLinks.branch().self(namespaceAndName, target.getName()))
       .single(linkBuilder("history", resourceLinks.branch().history(namespaceAndName, target.getName())).build())
-      .single(linkBuilder("changesetDiff", resourceLinks.branch().changesetDiff(namespaceAndName, target.getName())).build())
       .single(linkBuilder("changeset", resourceLinks.changeset().changeset(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision())).build())
       .single(linkBuilder("source", resourceLinks.source().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), target.getRevision())).build());
     target.add(linksBuilder.build());
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java
index e236b54005..4d15b773cd 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java
@@ -25,7 +25,7 @@ public class DiffRootResource {
 
   public static final String HEADER_CONTENT_DISPOSITION = "Content-Disposition";
 
-  private static final String DIFF_FORMAT_VALUES_REGEX = "NATIVE|GIT|UNIFIED";
+  static final String DIFF_FORMAT_VALUES_REGEX = "NATIVE|GIT|UNIFIED";
 
   private final RepositoryServiceFactory serviceFactory;
 
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingChangesetCollectionToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingChangesetCollectionToDtoMapper.java
new file mode 100644
index 0000000000..a2aaabb1a2
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingChangesetCollectionToDtoMapper.java
@@ -0,0 +1,29 @@
+package sonia.scm.api.v2.resources;
+
+import sonia.scm.PageResult;
+import sonia.scm.repository.Changeset;
+import sonia.scm.repository.Repository;
+
+import javax.inject.Inject;
+
+public class IncomingChangesetCollectionToDtoMapper extends ChangesetCollectionToDtoMapper {
+
+
+  private final ResourceLinks resourceLinks;
+
+  @Inject
+  public IncomingChangesetCollectionToDtoMapper(ChangesetToChangesetDtoMapper changesetToChangesetDtoMapper, ResourceLinks resourceLinks) {
+    super(changesetToChangesetDtoMapper, resourceLinks);
+    this.resourceLinks = resourceLinks;
+  }
+
+  public CollectionDto map(int pageNumber, int pageSize, PageResult pageResult, Repository repository, String source, String target) {
+    return super.map(pageNumber, pageSize, pageResult, repository, () -> createSelfLink(repository, source, target));
+  }
+
+  private String createSelfLink(Repository repository, String source, String target) {
+    return resourceLinks.incoming().changesets(repository.getNamespace(), repository.getName(), source, target);
+  }
+
+
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java
new file mode 100644
index 0000000000..4c43485abd
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java
@@ -0,0 +1,153 @@
+package sonia.scm.api.v2.resources;
+
+import com.google.inject.Inject;
+import com.webcohesion.enunciate.metadata.rs.ResponseCode;
+import com.webcohesion.enunciate.metadata.rs.StatusCodes;
+import com.webcohesion.enunciate.metadata.rs.TypeHint;
+import sonia.scm.PageResult;
+import sonia.scm.repository.Changeset;
+import sonia.scm.repository.ChangesetPagingResult;
+import sonia.scm.repository.NamespaceAndName;
+import sonia.scm.repository.Repository;
+import sonia.scm.repository.RepositoryPermissions;
+import sonia.scm.repository.api.DiffFormat;
+import sonia.scm.repository.api.RepositoryService;
+import sonia.scm.repository.api.RepositoryServiceFactory;
+import sonia.scm.util.HttpUtil;
+import sonia.scm.web.VndMediaType;
+
+import javax.validation.constraints.Pattern;
+import javax.ws.rs.DefaultValue;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.StreamingOutput;
+import java.io.IOException;
+
+import static sonia.scm.api.v2.resources.DiffRootResource.DIFF_FORMAT_VALUES_REGEX;
+import static sonia.scm.api.v2.resources.DiffRootResource.HEADER_CONTENT_DISPOSITION;
+
+public class IncomingRootResource {
+
+
+  private final RepositoryServiceFactory serviceFactory;
+
+  private final IncomingChangesetCollectionToDtoMapper mapper;
+
+
+  @Inject
+  public IncomingRootResource(RepositoryServiceFactory serviceFactory, IncomingChangesetCollectionToDtoMapper incomingChangesetCollectionToDtoMapper) {
+    this.serviceFactory = serviceFactory;
+    this.mapper = incomingChangesetCollectionToDtoMapper;
+  }
+
+  /**
+   * Get the incoming changesets from source to target
+   * 

+ * Example: + *

+ * - master + * - | + * - _______________ ° m1 + * - e | + * - | ° m2 + * - ° e1 | + * - ______|_______ | + * - | | b + * - f a | + * - | | ° b1 + * - ° f1 ° a1 | + * - ° b2 + * - + *

+ * - /incoming/a/master/changesets -> a1 , e1 + * - /incoming/b/master/changesets -> b1 , b2 + * - /incoming/b/f/changesets -> b1 , b2, m2 + * - /incoming/f/b/changesets -> f1 , e1 + * - /incoming/a/b/changesets -> a1 , e1 + * - /incoming/a/b/changesets -> a1 , e1 + * + * @param namespace + * @param name + * @param source can be a changeset id or a branch name + * @param target can be a changeset id or a branch name + * @param page + * @param pageSize + * @return + * @throws Exception + */ + @Path("{source}/{target}/changesets") + @GET + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the changeset"), + @ResponseCode(code = 404, condition = "not found, no changesets available in the repository"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @Produces(VndMediaType.CHANGESET_COLLECTION) + @TypeHint(CollectionDto.class) + public Response incomingChangesets(@PathParam("namespace") String namespace, + @PathParam("name") String name, + @PathParam("source") String source, + @PathParam("target") String target, + @DefaultValue("0") @QueryParam("page") int page, + @DefaultValue("10") @QueryParam("pageSize") int pageSize) throws IOException { + try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { + Repository repository = repositoryService.getRepository(); + RepositoryPermissions.read(repository).check(); + ChangesetPagingResult changesets = new PagedLogCommandBuilder(repositoryService) + .page(page) + .pageSize(pageSize) + .create() + .setStartChangeset(source) + .setAncestorChangeset(target) + .getChangesets(); + if (changesets != null && changesets.getChangesets() != null) { + PageResult pageResult = new PageResult<>(changesets.getChangesets(), changesets.getTotal()); + return Response.ok(mapper.map(page, pageSize, pageResult, repository, source, target)).build(); + } else { + return Response.ok().build(); + } + } + } + + + @Path("{source}/{target}/diff") + @GET + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user has no privileges to read the changeset"), + @ResponseCode(code = 404, condition = "not found, no changesets available in the repository"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @Produces(VndMediaType.DIFF) + @TypeHint(CollectionDto.class) + public Response incomingDiff(@PathParam("namespace") String namespace, + @PathParam("name") String name, + @PathParam("source") String source, + @PathParam("target") String target, + @Pattern(regexp = DIFF_FORMAT_VALUES_REGEX) @DefaultValue("NATIVE") @QueryParam("format") String format) throws IOException { + + + HttpUtil.checkForCRLFInjection(source); + HttpUtil.checkForCRLFInjection(target); + DiffFormat diffFormat = DiffFormat.valueOf(format); + try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { + StreamingOutput responseEntry = output -> + repositoryService.getDiffCommand() + .setRevision(source) + .setAncestorChangeset(target) + .setFormat(diffFormat) + .retrieveContent(output); + + return Response.ok(responseEntry) + .header(HEADER_CONTENT_DISPOSITION, HttpUtil.createContentDispositionAttachmentHeader(String.format("%s-%s.diff", name, source))) + .build(); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java index e6cf6721a5..66eadaad7d 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java @@ -43,6 +43,8 @@ public class MapperModule extends AbstractModule { bind(ScmViolationExceptionToErrorDtoMapper.class).to(Mappers.getMapper(ScmViolationExceptionToErrorDtoMapper.class).getClass()); bind(ExceptionWithContextToErrorDtoMapper.class).to(Mappers.getMapper(ExceptionWithContextToErrorDtoMapper.class).getClass()); + bind(MergeResultToDtoMapper.class).to(Mappers.getMapper(MergeResultToDtoMapper.class).getClass()); + // no mapstruct required bind(UIPluginDtoMapper.class); bind(UIPluginDtoCollectionMapper.class); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeCommandDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeCommandDto.java new file mode 100644 index 0000000000..0661d6a4ef --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeCommandDto.java @@ -0,0 +1,14 @@ +package sonia.scm.api.v2.resources; + +import lombok.Getter; +import lombok.Setter; +import org.hibernate.validator.constraints.NotEmpty; + +@Getter @Setter +public class MergeCommandDto { + + @NotEmpty + private String sourceRevision; + @NotEmpty + private String targetRevision; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResource.java new file mode 100644 index 0000000000..63fa2274ec --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResource.java @@ -0,0 +1,87 @@ +package sonia.scm.api.v2.resources; + +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.HttpStatus; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.api.MergeCommandBuilder; +import sonia.scm.repository.api.MergeCommandResult; +import sonia.scm.repository.api.MergeDryRunCommandResult; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.validation.Valid; +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; + +@Slf4j +public class MergeResource { + + private final RepositoryServiceFactory serviceFactory; + private final MergeResultToDtoMapper mapper; + + @Inject + public MergeResource(RepositoryServiceFactory serviceFactory, MergeResultToDtoMapper mapper) { + this.serviceFactory = serviceFactory; + this.mapper = mapper; + } + + @POST + @Path("") + @Produces(VndMediaType.MERGE_RESULT) + @Consumes(VndMediaType.MERGE_COMMAND) + @StatusCodes({ + @ResponseCode(code = 204, condition = "merge has been executed successfully"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 403, condition = "not authorized, the current user does not have the privilege to write the repository"), + @ResponseCode(code = 409, condition = "The branches could not be merged automatically due to conflicts (conflicting files will be returned)"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response merge(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid MergeCommandDto mergeCommand) { + NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); + log.info("Merge in Repository {}/{} from {} to {}", namespace, name, mergeCommand.getSourceRevision(), mergeCommand.getTargetRevision()); + try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { + MergeCommandResult mergeCommandResult = createMergeCommand(mergeCommand, repositoryService).executeMerge(); + if (mergeCommandResult.isSuccess()) { + return Response.noContent().build(); + } else { + return Response.status(HttpStatus.SC_CONFLICT).entity(mapper.map(mergeCommandResult)).build(); + } + } + } + + @POST + @Path("dry-run/") + @StatusCodes({ + @ResponseCode(code = 204, condition = "merge can be done automatically"), + @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), + @ResponseCode(code = 409, condition = "The branches can not be merged automatically due to conflicts"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response dryRun(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid MergeCommandDto mergeCommand) { + NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, name); + log.info("Merge in Repository {}/{} from {} to {}", namespace, name, mergeCommand.getSourceRevision(), mergeCommand.getTargetRevision()); + try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { + MergeDryRunCommandResult mergeCommandResult = createMergeCommand(mergeCommand, repositoryService).dryRun(); + if (mergeCommandResult.isMergeable()) { + return Response.noContent().build(); + } else { + return Response.status(HttpStatus.SC_CONFLICT).build(); + } + } + } + + private MergeCommandBuilder createMergeCommand(MergeCommandDto mergeCommand, RepositoryService repositoryService) { + return repositoryService + .getMergeCommand() + .setBranchToMerge(mergeCommand.getSourceRevision()) + .setTargetBranch(mergeCommand.getTargetRevision()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultDto.java new file mode 100644 index 0000000000..fa523153cf --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultDto.java @@ -0,0 +1,12 @@ +package sonia.scm.api.v2.resources; + +import lombok.Getter; +import lombok.Setter; + +import java.util.Collection; + +@Getter +@Setter +public class MergeResultDto { + private Collection filesWithConflict; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultToDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultToDtoMapper.java new file mode 100644 index 0000000000..1dbbe8aacd --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultToDtoMapper.java @@ -0,0 +1,9 @@ +package sonia.scm.api.v2.resources; + +import org.mapstruct.Mapper; +import sonia.scm.repository.api.MergeCommandResult; + +@Mapper +public interface MergeResultToDtoMapper { + MergeResultDto map(MergeCommandResult result); +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java index f65235db0b..b884a37771 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java @@ -10,7 +10,6 @@ import sonia.scm.repository.RepositoryManager; import sonia.scm.web.VndMediaType; import javax.inject.Inject; -import javax.inject.Named; import javax.inject.Provider; import javax.validation.Valid; import javax.ws.rs.Consumes; @@ -44,6 +43,8 @@ public class RepositoryResource { private final Provider diffRootResource; private final Provider modificationsRootResource; private final Provider fileHistoryRootResource; + private final Provider mergeResource; + private final Provider incomingRootResource; @Inject public RepositoryResource( @@ -56,8 +57,9 @@ public class RepositoryResource { Provider permissionRootResource, Provider diffRootResource, Provider modificationsRootResource, - Provider fileHistoryRootResource - ) { + Provider fileHistoryRootResource, + Provider incomingRootResource, + Provider mergeResource) { this.dtoToRepositoryMapper = dtoToRepositoryMapper; this.manager = manager; this.repositoryToDtoMapper = repositoryToDtoMapper; @@ -71,6 +73,8 @@ public class RepositoryResource { this.diffRootResource = diffRootResource; this.modificationsRootResource = modificationsRootResource; this.fileHistoryRootResource = fileHistoryRootResource; + this.mergeResource = mergeResource; + this.incomingRootResource = incomingRootResource; } /** @@ -194,8 +198,18 @@ public class RepositoryResource { return permissionRootResource.get(); } - @Path("modifications/") - public ModificationsRootResource modifications() {return modificationsRootResource.get(); } + @Path("modifications/") + public ModificationsRootResource modifications() { + return modificationsRootResource.get(); + } + + @Path("incoming/") + public IncomingRootResource incoming() { + return incomingRootResource.get(); + } + + @Path("merge/") + public MergeResource merge() {return mergeResource.get(); } private Optional handleNotArchived(Throwable throwable) { if (throwable instanceof RepositoryIsNotArchivedException) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java index 29a4107aad..30ccb79735 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java @@ -6,6 +6,7 @@ import de.otto.edison.hal.Links; import org.mapstruct.AfterMapping; import org.mapstruct.Mapper; import org.mapstruct.MappingTarget; +import sonia.scm.repository.Feature; import sonia.scm.repository.HealthCheckFailure; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; @@ -55,6 +56,14 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper changesetList = Lists.newArrayList(new Changeset(id, Date.from(creationDate).getTime(), new Person(authorName, authorEmail), commit)); + when(changesetPagingResult.getChangesets()).thenReturn(changesetList); + when(changesetPagingResult.getTotal()).thenReturn(1); + when(logCommandBuilder.setPagingStart(0)).thenReturn(logCommandBuilder); + when(logCommandBuilder.setPagingLimit(10)).thenReturn(logCommandBuilder); + when(logCommandBuilder.setStartChangeset(anyString())).thenReturn(logCommandBuilder); + when(logCommandBuilder.setAncestorChangeset(anyString())).thenReturn(logCommandBuilder); + when(logCommandBuilder.getChangesets()).thenReturn(changesetPagingResult); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_CHANGESETS_URL + "src_changeset_id/target_changeset_id/changesets") + .accept(VndMediaType.CHANGESET_COLLECTION); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(200, response.getStatus()); + log.info("Response :{}", response.getContentAsString()); + assertTrue(response.getContentAsString().contains(String.format("\"id\":\"%s\"", id))); + assertTrue(response.getContentAsString().contains(String.format("\"name\":\"%s\"", authorName))); + assertTrue(response.getContentAsString().contains(String.format("\"mail\":\"%s\"", authorEmail))); + assertTrue(response.getContentAsString().contains(String.format("\"description\":\"%s\"", commit))); + } + + @Test + public void shouldGetSinglePageOfIncomingChangesets() throws Exception { + String id = "revision_123"; + Instant creationDate = Instant.now(); + String authorName = "name"; + String authorEmail = "em@i.l"; + String commit = "my branch commit"; + ChangesetPagingResult changesetPagingResult = mock(ChangesetPagingResult.class); + List changesetList = Lists.newArrayList(new Changeset(id, Date.from(creationDate).getTime(), new Person(authorName, authorEmail), commit)); + when(changesetPagingResult.getChangesets()).thenReturn(changesetList); + when(changesetPagingResult.getTotal()).thenReturn(1); + when(logCommandBuilder.setPagingStart(20)).thenReturn(logCommandBuilder); + when(logCommandBuilder.setPagingLimit(10)).thenReturn(logCommandBuilder); + when(logCommandBuilder.setStartChangeset(anyString())).thenReturn(logCommandBuilder); + when(logCommandBuilder.setAncestorChangeset(anyString())).thenReturn(logCommandBuilder); + when(logCommandBuilder.getChangesets()).thenReturn(changesetPagingResult); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_CHANGESETS_URL + "src_changeset_id/target_changeset_id/changesets?page=2") + .accept(VndMediaType.CHANGESET_COLLECTION); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(200, response.getStatus()); + assertTrue(response.getContentAsString().contains(String.format("\"id\":\"%s\"", id))); + assertTrue(response.getContentAsString().contains(String.format("\"name\":\"%s\"", authorName))); + assertTrue(response.getContentAsString().contains(String.format("\"mail\":\"%s\"", authorEmail))); + assertTrue(response.getContentAsString().contains(String.format("\"description\":\"%s\"", commit))); + } + + @Test + public void shouldGetDiffs() throws Exception { + when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent(any())).thenReturn(diffCommandBuilder); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()) + .isEqualTo(200); + String expectedHeader = "Content-Disposition"; + String expectedValue = "attachment; filename=\"repo-src_changeset_id.diff\"; filename*=utf-8''repo-src_changeset_id.diff"; + assertThat(response.getOutputHeaders().containsKey(expectedHeader)).isTrue(); + assertThat((String) response.getOutputHeaders().get("Content-Disposition").get(0)) + .contains(expectedValue); + } + + @Test + public void shouldGet404OnMissingRepository() throws URISyntaxException { + when(serviceFactory.create(any(NamespaceAndName.class))).thenThrow(new NotFoundException("Text", "x")); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(404, response.getStatus()); + } + + @Test + public void shouldGet404OnMissingRevision() throws Exception { + when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Text", "x")); + + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(404, response.getStatus()); + } + + @Test + public void shouldGet400OnCrlfInjection() throws Exception { + when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Text", "x")); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "ny%0D%0ASet-cookie:%20Tamper=3079675143472450634/ny%0D%0ASet-cookie:%20Tamper=3079675143472450634/diff") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(400, response.getStatus()); + assertThat(response.getContentAsString()).contains("parameter contains an illegal character"); + } + + @Test + public void shouldGet400OnUnknownFormat() throws Exception { + when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Test", "test")); + MockHttpRequest request = MockHttpRequest + .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff?format=Unknown") + .accept(VndMediaType.DIFF); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + assertEquals(400, response.getStatus()); + } + + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java new file mode 100644 index 0000000000..d47ff35e5f --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java @@ -0,0 +1,150 @@ +package sonia.scm.api.v2.resources; + +import com.google.common.io.Resources; +import com.google.inject.util.Providers; +import org.jboss.resteasy.core.Dispatcher; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.api.MergeCommandBuilder; +import sonia.scm.repository.api.MergeCommandResult; +import sonia.scm.repository.api.MergeDryRunCommandResult; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.repository.spi.MergeCommand; +import sonia.scm.web.VndMediaType; + +import java.net.URL; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class MergeResourceTest extends RepositoryTestBase { + + public static final String MERGE_URL = "/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/merge/"; + + private Dispatcher dispatcher; + @Mock + private RepositoryServiceFactory serviceFactory; + @Mock + private RepositoryService repositoryService; + @Mock + private MergeCommand mergeCommand; + @InjectMocks + private MergeCommandBuilder mergeCommandBuilder; + private MergeResultToDtoMapperImpl mapper = new MergeResultToDtoMapperImpl(); + + private MergeResource mergeResource; + + @BeforeEach + void init() { + mergeResource = new MergeResource(serviceFactory, mapper); + super.mergeResource = Providers.of(mergeResource); + dispatcher = DispatcherMock.createDispatcher(getRepositoryRootResource()); + } + + @Test + void shouldHandleIllegalInput() throws Exception { + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand_invalid.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL + "dry-run/") + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(400); + System.out.println(response.getContentAsString()); + } + + @Nested + class ExecutingMergeCommand { + + @BeforeEach + void initRepository() { + when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(repositoryService); + when(repositoryService.getMergeCommand()).thenReturn(mergeCommandBuilder); + } + + @Test + void shouldHandleSuccessfulMerge() throws Exception { + when(mergeCommand.merge(any())).thenReturn(MergeCommandResult.success()); + + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL) + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + } + + @Test + void shouldHandleFailedMerge() throws Exception { + when(mergeCommand.merge(any())).thenReturn(MergeCommandResult.failure(asList("file1", "file2"))); + + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL) + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(409); + assertThat(response.getContentAsString()).contains("file1", "file2"); + } + + @Test + void shouldHandleSuccessfulDryRun() throws Exception { + when(mergeCommand.dryRun(any())).thenReturn(new MergeDryRunCommandResult(true)); + + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL + "dry-run/") + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(204); + } + + @Test + void shouldHandleFailedDryRun() throws Exception { + when(mergeCommand.dryRun(any())).thenReturn(new MergeDryRunCommandResult(false)); + + URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); + byte[] mergeCommandJson = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post(MERGE_URL + "dry-run/") + .content(mergeCommandJson) + .contentType(VndMediaType.MERGE_COMMAND); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(409); + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java index 3d3b28ae51..b2daf0536c 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java @@ -21,6 +21,8 @@ public abstract class RepositoryTestBase { protected Provider modificationsRootResource; protected Provider fileHistoryRootResource; protected Provider repositoryCollectionResource; + protected Provider incomingRootResource; + protected Provider mergeResource; RepositoryRootResource getRepositoryRootResource() { @@ -36,7 +38,9 @@ public abstract class RepositoryTestBase { permissionRootResource, diffRootResource, modificationsRootResource, - fileHistoryRootResource)), repositoryCollectionResource); + fileHistoryRootResource, + incomingRootResource, + mergeResource)), repositoryCollectionResource); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index c2dc685306..435d8b4673 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -20,6 +20,7 @@ public class ResourceLinksMock { when(resourceLinks.group()).thenReturn(new ResourceLinks.GroupLinks(uriInfo)); when(resourceLinks.groupCollection()).thenReturn(new ResourceLinks.GroupCollectionLinks(uriInfo)); when(resourceLinks.repository()).thenReturn(new ResourceLinks.RepositoryLinks(uriInfo)); + when(resourceLinks.incoming()).thenReturn(new ResourceLinks.IncomingLinks(uriInfo)); when(resourceLinks.repositoryCollection()).thenReturn(new ResourceLinks.RepositoryCollectionLinks(uriInfo)); when(resourceLinks.tag()).thenReturn(new ResourceLinks.TagCollectionLinks(uriInfo)); when(resourceLinks.branchCollection()).thenReturn(new ResourceLinks.BranchCollectionLinks(uriInfo)); @@ -37,6 +38,7 @@ public class ResourceLinksMock { when(resourceLinks.uiPlugin()).thenReturn(new ResourceLinks.UIPluginLinks(uriInfo)); when(resourceLinks.authentication()).thenReturn(new ResourceLinks.AuthenticationLinks(uriInfo)); when(resourceLinks.index()).thenReturn(new ResourceLinks.IndexLinks(uriInfo)); + when(resourceLinks.merge()).thenReturn(new ResourceLinks.MergeLinks(uriInfo)); return resourceLinks; } diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand.json new file mode 100644 index 0000000000..dde0b6a413 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand.json @@ -0,0 +1,4 @@ +{ + "sourceRevision": "source", + "targetRevision": "target" +} diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand_invalid.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand_invalid.json new file mode 100644 index 0000000000..b2d1e5ab3f --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand_invalid.json @@ -0,0 +1,4 @@ +{ + "sourceRevision": "", + "targetRevision": "target" +}