From 7900b94011f6afaa5c60034ed08583e330d837fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Wed, 5 Dec 2018 13:52:14 +0100 Subject: [PATCH 01/24] Add resource for merges --- .../repository/api/MergeCommandBuilder.java | 4 +- .../scm/repository/api/RepositoryService.java | 4 +- .../main/java/sonia/scm/web/VndMediaType.java | 2 + .../scm/api/v2/resources/MapperModule.java | 2 + .../scm/api/v2/resources/MergeCommandDto.java | 14 ++ .../scm/api/v2/resources/MergeResource.java | 71 +++++++++ .../scm/api/v2/resources/MergeResultDto.java | 12 ++ .../v2/resources/MergeResultToDtoMapper.java | 9 ++ .../api/v2/resources/RepositoryResource.java | 12 +- .../api/v2/resources/MergeResourceTest.java | 150 ++++++++++++++++++ .../api/v2/resources/RepositoryTestBase.java | 4 +- .../sonia/scm/api/v2/mergeCommand.json | 4 + .../scm/api/v2/mergeCommand_invalid.json | 4 + 13 files changed, 283 insertions(+), 9 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeCommandDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResource.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResultToDtoMapper.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java create mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand.json create mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/mergeCommand_invalid.json 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-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..5af32cedad
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MergeResource.java
@@ -0,0 +1,71 @@
+package sonia.scm.api.v2.resources;
+
+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
+  @Produces(VndMediaType.MERGE_RESULT)
+  @Consumes(VndMediaType.MERGE_COMMAND)
+  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/")
+  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/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..0ccb923e45 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,7 @@ public abstract class RepositoryTestBase {
   protected Provider 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/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"
+}

From 8c9e38e395d8a12eaf0cce2abe27767756f8782e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= 
Date: Wed, 5 Dec 2018 15:32:30 +0100
Subject: [PATCH 02/24] Secure merge command

---
 .../main/java/sonia/scm/repository/spi/GitMergeCommand.java    | 3 +++
 1 file changed, 3 insertions(+)

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..26f8ad4a42 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
@@ -15,6 +15,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;
@@ -40,6 +41,8 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
 
   @Override
   public MergeCommandResult merge(MergeCommandRequest request) {
+    RepositoryPermissions.permissionWrite(context.getRepository().getId()).check();
+
     try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context)) {
       Repository repository = workingCopy.get();
       logger.debug("cloned repository to folder {}", repository.getWorkTree());

From 9a3b8c26fbaad2026126c498e8704a5061d7a93d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= 
Date: Wed, 5 Dec 2018 16:01:34 +0100
Subject: [PATCH 03/24] Fix unit test

---
 .../sonia/scm/repository/spi/GitMergeCommandTest.java    | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

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..2923d445ac 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
@@ -19,7 +19,7 @@ import java.io.IOException;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
-@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";
@@ -111,11 +111,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();

From 35f73cdc188a1b1a722dcb1f66fb41a9624b78ea Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= 
Date: Wed, 5 Dec 2018 16:09:38 +0100
Subject: [PATCH 04/24] Do not commit empty merges

---
 .../scm/repository/spi/GitMergeCommand.java   | 10 +++++---
 .../repository/spi/GitMergeCommandTest.java   | 25 +++++++++++++++++++
 2 files changed, 31 insertions(+), 4 deletions(-)

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 26f8ad4a42..888fef5b65 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
@@ -116,10 +116,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);
       }
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 2923d445ac..380619dfa5 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,6 +6,7 @@ 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;
@@ -77,6 +78,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();

From ea354690081850bbe1887effee606440c396200a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= 
Date: Wed, 5 Dec 2018 16:46:31 +0100
Subject: [PATCH 05/24] Add merge links to repository resource

---
 .../scm/api/v2/resources/MergeResource.java   |  1 +
 .../RepositoryToRepositoryDtoMapper.java      |  4 ++++
 .../scm/api/v2/resources/ResourceLinks.java   | 19 +++++++++++++++++++
 3 files changed, 24 insertions(+)

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
index 5af32cedad..5c484b3ce9 100644
--- 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
@@ -32,6 +32,7 @@ public class MergeResource {
   }
 
   @POST
+  @Path("")
   @Produces(VndMediaType.MERGE_RESULT)
   @Consumes(VndMediaType.MERGE_COMMAND)
   public Response merge(@PathParam("namespace") String namespace, @PathParam("name") String name, @Valid MergeCommandDto mergeCommand) {
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
Date: Wed, 5 Dec 2018 17:03:02 +0100
Subject: [PATCH 06/24] Add documentation

---
 .../sonia/scm/api/v2/resources/MergeResource.java | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

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
index 5c484b3ce9..63fa2274ec 100644
--- 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
@@ -1,5 +1,7 @@
 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;
@@ -35,6 +37,13 @@ public class MergeResource {
   @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());
@@ -50,6 +59,12 @@ public class MergeResource {
 
   @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());

From 641022ba0193ee6b6b62d9055f15be26b700c4e7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= 
Date: Wed, 5 Dec 2018 17:05:58 +0100
Subject: [PATCH 07/24] Fix unit test

---
 .../test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java  | 1 +
 1 file changed, 1 insertion(+)

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;
   }

From d889cf083149985d74980e339d180bdc26e7d242 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= 
Date: Wed, 5 Dec 2018 17:25:10 +0100
Subject: [PATCH 08/24] Fix permission check

---
 .../src/main/java/sonia/scm/repository/spi/GitMergeCommand.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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 888fef5b65..dc499df644 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
@@ -41,7 +41,7 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
 
   @Override
   public MergeCommandResult merge(MergeCommandRequest request) {
-    RepositoryPermissions.permissionWrite(context.getRepository().getId()).check();
+    RepositoryPermissions.push(context.getRepository().getId()).check();
 
     try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context)) {
       Repository repository = workingCopy.get();

From 6b2125687eac27e3cf04ec0d96a42a4ff367cd71 Mon Sep 17 00:00:00 2001
From: Mohamed Karray 
Date: Thu, 6 Dec 2018 08:46:22 +0100
Subject: [PATCH 09/24] add the Incoming Resource to get incoming changesets
 and diff

---
 .../repository/api/DiffCommandBuilder.java    |  13 +
 .../api/v2/resources/BranchRootResource.java  |  43 ---
 .../v2/resources/BranchToBranchDtoMapper.java |   1 -
 .../api/v2/resources/DiffRootResource.java    |   2 +-
 ...ncomingChangesetCollectionToDtoMapper.java |  29 ++
 .../v2/resources/IncomingRootResource.java    | 153 +++++++++++
 .../api/v2/resources/RepositoryResource.java  |   9 +-
 .../RepositoryToRepositoryDtoMapper.java      |   2 +
 .../scm/api/v2/resources/ResourceLinks.java   |  30 ++-
 .../resources/IncomingRootResourceTest.java   | 255 ++++++++++++++++++
 .../api/v2/resources/RepositoryTestBase.java  |   4 +-
 .../api/v2/resources/ResourceLinksMock.java   |   1 +
 12 files changed, 492 insertions(+), 50 deletions(-)
 create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingChangesetCollectionToDtoMapper.java
 create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java
 create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java

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..24735b564b 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
@@ -187,6 +187,19 @@ public final class DiffCommandBuilder
 
     return this;
   }
+  /**
+   * Show the difference between the ancestor changeset and a revision.
+   *
+   * @param revision ancestor revision
+   *
+   * @return {@code this}
+   */
+  public DiffCommandBuilder setAncestorChangeset(String revision)
+  {
+    request.setAncestorChangeset(revision);
+
+    return this;
+  }
 
   //~--- get methods ----------------------------------------------------------
 
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/RepositoryResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java index f65235db0b..b0013c51db 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 @@ -44,6 +44,7 @@ public class RepositoryResource { private final Provider diffRootResource; private final Provider modificationsRootResource; private final Provider fileHistoryRootResource; + private final Provider incomingRootResource; @Inject public RepositoryResource( @@ -56,8 +57,8 @@ public class RepositoryResource { Provider permissionRootResource, Provider diffRootResource, Provider modificationsRootResource, - Provider fileHistoryRootResource - ) { + Provider fileHistoryRootResource, + Provider incomingRootResource) { this.dtoToRepositoryMapper = dtoToRepositoryMapper; this.manager = manager; this.repositoryToDtoMapper = repositoryToDtoMapper; @@ -71,6 +72,7 @@ public class RepositoryResource { this.diffRootResource = diffRootResource; this.modificationsRootResource = modificationsRootResource; this.fileHistoryRootResource = fileHistoryRootResource; + this.incomingRootResource = incomingRootResource; } /** @@ -197,6 +199,9 @@ public class RepositoryResource { @Path("modifications/") public ModificationsRootResource modifications() {return modificationsRootResource.get(); } + @Path("incoming/") + public IncomingRootResource incoming() {return incomingRootResource.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..097afd8e25 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 @@ -58,6 +58,8 @@ 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/RepositoryTestBase.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java index 3d3b28ae51..861707bc5e 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,7 @@ public abstract class RepositoryTestBase { protected Provider modificationsRootResource; protected Provider fileHistoryRootResource; protected Provider repositoryCollectionResource; + protected Provider incomingRootResource; RepositoryRootResource getRepositoryRootResource() { @@ -36,7 +37,8 @@ public abstract class RepositoryTestBase { permissionRootResource, diffRootResource, modificationsRootResource, - fileHistoryRootResource)), repositoryCollectionResource); + fileHistoryRootResource, + incomingRootResource)), 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..1660b47f02 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)); From de6d52bad93a0d92e6afeedb17e74180da96ef69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Thu, 6 Dec 2018 10:49:37 +0100 Subject: [PATCH 10/24] Introduce feature for incoming changes --- .../src/main/java/sonia/scm/repository/Feature.java | 7 ++++++- .../sonia/scm/repository/api/DiffCommandBuilder.java | 9 +++++---- .../sonia/scm/repository/api/LogCommandBuilder.java | 7 +++++++ .../repository/spi/GitRepositoryServiceProvider.java | 10 ++++++++-- .../scm/api/v2/resources/RepositoryResource.java | 12 ++++++++---- .../resources/RepositoryToRepositoryDtoMapper.java | 7 +++++-- 6 files changed, 39 insertions(+), 13 deletions(-) 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..0ef65968c6 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 } 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 24735b564b..b31b28dd15 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 @@ -174,7 +174,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 @@ -188,9 +189,9 @@ public final class DiffCommandBuilder return this; } /** - * Show the difference between the ancestor changeset and a revision. - * - * @param revision ancestor revision + * 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}! * * @return {@code this} */ 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..f9747c37fc 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 @@ -397,6 +397,13 @@ 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}! + * + * @return {@code this} + */ public LogCommandBuilder setAncestorChangeset(String ancestorChangeset) { request.setAncestorChangeset(ancestorChangeset); return this; 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..ecd98b10e3 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 @@ -33,12 +33,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 ------------------------------------------------------------ @@ -52,7 +53,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider /** Field description */ //J- - public static final Set COMMANDS = ImmutableSet.of( + public static final Set COMMANDS = EnumSet.of( Command.BLAME, Command.BROWSE, Command.CAT, @@ -66,6 +67,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider Command.PULL, Command.MERGE ); + public static final Set FEATURES = EnumSet.of(Feature.INCOMING); //J+ //~--- constructors --------------------------------------------------------- @@ -246,6 +248,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-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 b0013c51db..a964819414 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 @@ -196,11 +196,15 @@ 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("incoming/") + public IncomingRootResource incoming() { + return incomingRootResource.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 097afd8e25..5bdc5ce655 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,11 +56,13 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper Date: Thu, 6 Dec 2018 11:35:56 +0100 Subject: [PATCH 11/24] Check feature in "incoming" commands --- .../main/java/sonia/scm/repository/Feature.java | 2 +- .../scm/repository/api/DiffCommandBuilder.java | 13 +++++++++++-- .../scm/repository/api/LogCommandBuilder.java | 16 ++++++++++++---- .../scm/repository/api/RepositoryService.java | 4 ++-- .../spi/GitRepositoryServiceProvider.java | 2 +- .../RepositoryToRepositoryDtoMapper.java | 2 +- 6 files changed, 28 insertions(+), 11 deletions(-) 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 0ef65968c6..1bcaef4de5 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Feature.java +++ b/scm-core/src/main/java/sonia/scm/repository/Feature.java @@ -50,5 +50,5 @@ public enum Feature * The repository supports computation of incoming changes (either diff or list of changesets) of one branch * in respect to another target branch. */ - INCOMING + 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 b31b28dd15..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 -------------------------------------------------------------- @@ -191,12 +196,15 @@ public final class DiffCommandBuilder /** * 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}! + * 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; @@ -229,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 f9747c37fc..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 -------------------------------------------------------------- @@ -400,11 +404,14 @@ public final class LogCommandBuilder /** * 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}! + * 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; } @@ -534,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/RepositoryService.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java index fe0529e6b5..c2dc706bf5 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()); } /** 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 ecd98b10e3..6c2c4423f9 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 @@ -67,7 +67,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider Command.PULL, Command.MERGE ); - public static final Set FEATURES = EnumSet.of(Feature.INCOMING); + public static final Set FEATURES = EnumSet.of(Feature.INCOMING_REVISION); //J+ //~--- constructors --------------------------------------------------------- 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 5bdc5ce655..e5d812cffb 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 @@ -56,7 +56,7 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper Date: Thu, 6 Dec 2018 18:01:39 +0100 Subject: [PATCH 12/24] use protected constant and mutableSet for final field --- .../java/sonia/scm/repository/GitRepositoryHandler.java | 7 +++---- .../scm/repository/spi/GitRepositoryServiceProvider.java | 5 +++-- 2 files changed, 6 insertions(+), 6 deletions(-) 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/GitRepositoryServiceProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java index 6c2c4423f9..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 @@ -33,6 +33,7 @@ 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; @@ -53,7 +54,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider /** Field description */ //J- - public static final Set COMMANDS = EnumSet.of( + public static final Set COMMANDS = ImmutableSet.of( Command.BLAME, Command.BROWSE, Command.CAT, @@ -67,7 +68,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider Command.PULL, Command.MERGE ); - public static final Set FEATURES = EnumSet.of(Feature.INCOMING_REVISION); + protected static final Set FEATURES = EnumSet.of(Feature.INCOMING_REVISION); //J+ //~--- constructors --------------------------------------------------------- From 540a525bc36c9358e3dcd7e0293a1a67d703a708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Fri, 7 Dec 2018 08:19:14 +0100 Subject: [PATCH 13/24] Use SCM protocol for internal merge command --- .../jgit/transport/ScmTransportProtocol.java | 4 ++-- .../spi/SimpleGitWorkdirFactory.java | 7 +++++- .../repository/spi/GitMergeCommandTest.java | 23 +++++++++++++++++++ .../spi/SimpleGitWorkdirFactoryTest.java | 17 ++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) 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..fc31f2d686 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 @@ -64,10 +64,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 --------------------------------------------------------- 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 380619dfa5..d0c7db6657 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 @@ -10,15 +10,26 @@ 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.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", username = "admin", password = "secret") public class GitMergeCommandTest extends AbstractGitCommandTestBase { @@ -28,6 +39,18 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { @Rule public ShiroRule shiro = new ShiroRule(); + @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); + Transport.register(new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler))); + + when(gitRepositoryHandler.getRepositoryId(any())).thenReturn("1"); + when(repositoryManager.get("1")).thenReturn(new sonia.scm.repository.Repository()); + } + @Test public void shouldDetectMergeableBranches() { GitMergeCommand command = createCommand(); 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()); From 261e41f8bfa5d1b6678511342af111cb2a84cb90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Fri, 7 Dec 2018 08:49:37 +0100 Subject: [PATCH 14/24] Handle not existing revisions in merge --- .../sonia/scm/repository/spi/GitMergeCommand.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 dc499df644..172c16dd17 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 @@ -6,6 +6,7 @@ import org.apache.shiro.subject.Subject; import org.eclipse.jgit.api.Git; 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; @@ -23,6 +24,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); @@ -94,6 +98,9 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand private void checkOutTargetBranch() { try { clone.checkout().setName(target).call(); + } catch (RefNotFoundException e) { + logger.debug("could not checkout target branch {} for merge", 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: " + target, e); } @@ -102,9 +109,13 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand 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() .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); From 830c155b3dc8f0224faca4bf3b060bf2115087d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Fri, 7 Dec 2018 10:10:50 +0100 Subject: [PATCH 15/24] Fix transport protocol --- .../java/org/eclipse/jgit/transport/ScmTransportProtocol.java | 3 +++ 1 file changed, 3 insertions(+) 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 fc31f2d686..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 ------------------------------------------------------------ @@ -234,6 +235,8 @@ public class ScmTransportProtocol extends TransportProtocol pack.setPreReceiveHook(hook); pack.setPostReceiveHook(hook); + + CollectingPackParserListener.set(pack); } return pack; From 8b97e00b36628dd38469a2f558c94fc0580c26c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Fri, 7 Dec 2018 13:28:21 +0100 Subject: [PATCH 16/24] Fix unit test --- .../sonia/scm/repository/spi/GitMergeCommandTest.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 d0c7db6657..e36d50aa43 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 @@ -38,13 +38,15 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { @Rule public ShiroRule shiro = new ShiroRule(); + private GitRepositoryHandler gitRepositoryHandler; + private HookEventFacade hookEventFacade; @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); + hookEventFacade = new HookEventFacade(of(repositoryManager), hookContextFactory); + gitRepositoryHandler = mock(GitRepositoryHandler.class); Transport.register(new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler))); when(gitRepositoryHandler.getRepositoryId(any())).thenReturn("1"); @@ -116,6 +118,8 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { Repository repository = createContext().open(); ObjectId firstMergeCommit = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call().iterator().next().getId(); + Transport.register(new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler))); + MergeCommandResult secondMergeCommandResult = command.merge(request); assertThat(secondMergeCommandResult.isSuccess()).isTrue(); From 438339ae495e135d7af684906f904dce70e827dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Fri, 7 Dec 2018 13:33:58 +0100 Subject: [PATCH 17/24] Unbind scm protocol after test --- .../repository/spi/GitMergeCommandTest.java | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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 e36d50aa43..88942e455f 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 @@ -12,6 +12,7 @@ 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; @@ -38,21 +39,28 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { @Rule public ShiroRule shiro = new ShiroRule(); - private GitRepositoryHandler gitRepositoryHandler; - private HookEventFacade hookEventFacade; + + private ScmTransportProtocol scmTransportProtocol; @Before public void bindScmProtocol() { HookContextFactory hookContextFactory = new HookContextFactory(mock(PreProcessorUtil.class)); RepositoryManager repositoryManager = mock(RepositoryManager.class); - hookEventFacade = new HookEventFacade(of(repositoryManager), hookContextFactory); - gitRepositoryHandler = mock(GitRepositoryHandler.class); - Transport.register(new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler))); + 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(); @@ -118,7 +126,7 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { Repository repository = createContext().open(); ObjectId firstMergeCommit = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call().iterator().next().getId(); - Transport.register(new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler))); +// Transport.register(new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler))); MergeCommandResult secondMergeCommandResult = command.merge(request); From 6193c1edda69b1c2adf40fff66b955f13074acc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Fri, 7 Dec 2018 14:26:06 +0100 Subject: [PATCH 18/24] update apiclient for 409 errors --- .../packages/ui-components/src/apiclient.js | 17 ++++++++++------- .../packages/ui-components/src/index.js | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/scm-ui-components/packages/ui-components/src/apiclient.js b/scm-ui-components/packages/ui-components/src/apiclient.js index 3fd90a2d7a..0cabb9429e 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 throwErrorWithMessage(response, UNAUTHORIZED_ERROR); case 404: - return throwErrorWithMessage(response, NOT_FOUND_ERROR_MESSAGE); + return throwErrorWithMessage(response, NOT_FOUND_ERROR); + case 409: + return throwErrorWithMessage(response, CONFLICT_ERROR); default: - return throwErrorWithMessage(response, "server returned status code " + response.status); + return throwErrorWithMessage(response, new Error("server returned status code " + response.status)); } } return response; } -function throwErrorWithMessage(response: Response, message: string) { +function throwErrorWithMessage(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"; From cc11f56bfa57066da1730306a580070e712fa80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Mon, 10 Dec 2018 08:11:09 +0100 Subject: [PATCH 19/24] use new created error to fix test --- scm-ui/src/modules/auth.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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)); From 040035e0843d1aa134095ae1c69afd44c54350e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maren=20S=C3=BCwer?= Date: Mon, 10 Dec 2018 08:42:29 +0100 Subject: [PATCH 20/24] renaming --- .../packages/ui-components/src/apiclient.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scm-ui-components/packages/ui-components/src/apiclient.js b/scm-ui-components/packages/ui-components/src/apiclient.js index 0cabb9429e..8c4bde6a72 100644 --- a/scm-ui-components/packages/ui-components/src/apiclient.js +++ b/scm-ui-components/packages/ui-components/src/apiclient.js @@ -16,20 +16,20 @@ function handleStatusCode(response: Response) { if (!response.ok) { switch (response.status) { case 401: - return throwErrorWithMessage(response, UNAUTHORIZED_ERROR); + return throwError(response, UNAUTHORIZED_ERROR); case 404: - return throwErrorWithMessage(response, NOT_FOUND_ERROR); + return throwError(response, NOT_FOUND_ERROR); case 409: - return throwErrorWithMessage(response, CONFLICT_ERROR); + return throwError(response, CONFLICT_ERROR); default: - return throwErrorWithMessage(response, new Error("server returned status code " + response.status)); + return throwError(response, new Error("server returned status code " + response.status)); } } return response; } -function throwErrorWithMessage(response: Response, err: Error) { +function throwError(response: Response, err: Error) { return response.json().then( json => { throw Error(json.message); From 8b518d320d8a703a94c559a139221b426ddd9c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Mon, 10 Dec 2018 08:59:19 +0100 Subject: [PATCH 21/24] Fix checkout of target branch --- .../main/java/sonia/scm/repository/spi/GitMergeCommand.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 172c16dd17..1d0e8f768b 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 @@ -95,9 +95,10 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand } } - private void checkOutTargetBranch() { + private void checkOutTargetBranch() throws IOException { try { - clone.checkout().setName(target).call(); + ObjectId targetRevision = resolveRevision(target); + clone.checkout().setName(targetRevision.getName()).call(); } catch (RefNotFoundException e) { logger.debug("could not checkout target branch {} for merge", target, e); throw notFound(entity("revision", target).in(context.getRepository())); From 0e333002dbf43db093d6187f3926950c144d340b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Mon, 10 Dec 2018 11:43:27 +0100 Subject: [PATCH 22/24] Create local branch for target during merge --- .../scm/repository/spi/GitMergeCommand.java | 24 ++++++++++++---- .../repository/spi/GitMergeCommandTest.java | 28 +++++++++++++++++-- 2 files changed, 45 insertions(+), 7 deletions(-) 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 1d0e8f768b..072fdff514 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 @@ -97,16 +97,30 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand private void checkOutTargetBranch() throws IOException { try { - ObjectId targetRevision = resolveRevision(target); - clone.checkout().setName(targetRevision.getName()).call(); + clone.checkout().setName(target).call(); } catch (RefNotFoundException e) { - logger.debug("could not checkout target branch {} for merge", target, e); - throw notFound(entity("revision", target).in(context.getRepository())); + 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 { @@ -164,7 +178,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/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java index 88942e455f..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 @@ -126,8 +126,6 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { Repository repository = createContext().open(); ObjectId firstMergeCommit = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call().iterator().next().getId(); -// Transport.register(new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler))); - MergeCommandResult secondMergeCommandResult = command.merge(request); assertThat(secondMergeCommandResult.isSuccess()).isTrue(); @@ -196,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()); } From a54faf123311edcf162cfb9209787df8685c3ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Mon, 10 Dec 2018 12:01:13 +0100 Subject: [PATCH 23/24] Do not merge with fast forward --- .../src/main/java/sonia/scm/repository/spi/GitMergeCommand.java | 2 ++ 1 file changed, 2 insertions(+) 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 072fdff514..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,6 +4,7 @@ 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; @@ -129,6 +130,7 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand 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, sourceRevision) .call(); From 2a45f250ed3415d9033cf34e8709a5f4e539d7dd Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Mon, 10 Dec 2018 12:20:12 +0000 Subject: [PATCH 24/24] Close branch feature/merge_endpoint