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..c76fe524fd 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
@@ -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/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/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 334e0a696c..b064a36145 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 "./buttons";
 export * from "./config";
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/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..c4f76d0167 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,7 @@ public class RepositoryResource {
   private final Provider diffRootResource;
   private final Provider modificationsRootResource;
   private final Provider fileHistoryRootResource;
+  private final Provider mergeResource;
 
   @Inject
   public RepositoryResource(
@@ -56,8 +56,8 @@ public class RepositoryResource {
     Provider permissionRootResource,
     Provider diffRootResource,
     Provider modificationsRootResource,
-    Provider fileHistoryRootResource
-  ) {
+    Provider fileHistoryRootResource,
+    Provider mergeResource) {
     this.dtoToRepositoryMapper = dtoToRepositoryMapper;
     this.manager = manager;
     this.repositoryToDtoMapper = repositoryToDtoMapper;
@@ -71,6 +71,7 @@ public class RepositoryResource {
     this.diffRootResource = diffRootResource;
     this.modificationsRootResource = modificationsRootResource;
     this.fileHistoryRootResource = fileHistoryRootResource;
+    this.mergeResource = mergeResource;
   }
 
   /**
@@ -194,9 +195,12 @@ public class RepositoryResource {
     return permissionRootResource.get();
   }
 
- @Path("modifications/")
+  @Path("modifications/")
   public ModificationsRootResource modifications() {return modificationsRootResource.get(); }
 
+  @Path("merge/")
+  public MergeResource merge() {return mergeResource.get(); }
+
   private Optional handleNotArchived(Throwable throwable) {
     if (throwable instanceof RepositoryIsNotArchivedException) {
       return Optional.of(Response.status(Response.Status.PRECONDITION_FAILED).build());
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..fa1522bc37 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
@@ -55,6 +55,10 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper modificationsRootResource;
   protected Provider fileHistoryRootResource;
   protected Provider repositoryCollectionResource;
+  protected Provider mergeResource;
 
 
   RepositoryRootResource getRepositoryRootResource() {
@@ -36,7 +37,8 @@ public abstract class RepositoryTestBase {
       permissionRootResource,
       diffRootResource,
       modificationsRootResource,
-      fileHistoryRootResource)), repositoryCollectionResource);
+      fileHistoryRootResource,
+      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..0343888d6a 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
@@ -37,6 +37,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"
+}