diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Command.java b/scm-core/src/main/java/sonia/scm/repository/api/Command.java
index 2f844cfbfb..fa09cea2cb 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/Command.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/Command.java
@@ -66,6 +66,10 @@ public enum Command
/**
* @since 2.0
*/
- MODIFICATIONS
+ MODIFICATIONS,
+ /**
+ * @since 2.0
+ */
+ MERGE
}
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
new file mode 100644
index 0000000000..881a374864
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java
@@ -0,0 +1,143 @@
+package sonia.scm.repository.api;
+
+import com.google.common.base.Preconditions;
+import sonia.scm.repository.Person;
+import sonia.scm.repository.spi.MergeCommand;
+import sonia.scm.repository.spi.MergeCommandRequest;
+
+/**
+ * Use this {@link MergeCommandBuilder} to merge two branches of a repository ({@link #executeMerge()}) or to check if
+ * the branches could be merged without conflicts ({@link #dryRun()}). To do so, you have to specify the name of
+ * the target branch ({@link #setTargetBranch(String)}) and the name of the branch that should be merged
+ * ({@link #setBranchToMerge(String)}). Additionally you can specify an author that should be used for the commit
+ * ({@link #setAuthor(Person)}) and a message template ({@link #setMessageTemplate(String)}) if you are not doing a dry
+ * run only. If no author is specified, the logged in user and a default message will be used instead.
+ *
+ * To actually merge feature_branch into integration_branch do this:
+ *
+ * repositoryService.gerMergeCommand()
+ * .setBranchToMerge("feature_branch")
+ * .setTargetBranch("integration_branch")
+ * .executeMerge();
+ *
+ *
+ * If the merge is successful, the result will look like this:
+ *
+ * O <- Merge result (new head of integration_branch)
+ * |\
+ * | \
+ * old integration_branch -> O O <- feature_branch
+ * | |
+ * O O
+ *
+ *
+ * To check whether they can be merged without conflicts beforehand do this:
+ *
+ * repositoryService.gerMergeCommand()
+ * .setBranchToMerge("feature_branch")
+ * .setTargetBranch("integration_branch")
+ * .dryRun()
+ * .isMergeable();
+ *
+ *
+ * Keep in mind that you should always check the result of a merge even though you may have done a dry run
+ * beforehand, because the branches can change between the dry run and the actual merge.
+ *
+ * @since 2.0.0
+ */
+public class MergeCommandBuilder {
+
+ private final MergeCommand mergeCommand;
+ private final MergeCommandRequest request = new MergeCommandRequest();
+
+ MergeCommandBuilder(MergeCommand mergeCommand) {
+ this.mergeCommand = mergeCommand;
+ }
+
+ /**
+ * Use this to set the branch that should be merged into the target branch.
+ *
+ * This is mandatory.
+ *
+ * @return This builder instance.
+ */
+ public MergeCommandBuilder setBranchToMerge(String branchToMerge) {
+ request.setBranchToMerge(branchToMerge);
+ return this;
+ }
+
+ /**
+ * Use this to set the target branch the other branch should be merged into.
+ *
+ * This is mandatory.
+ *
+ * @return This builder instance.
+ */
+ public MergeCommandBuilder setTargetBranch(String targetBranch) {
+ request.setTargetBranch(targetBranch);
+ return this;
+ }
+
+ /**
+ * Use this to set the author of the merge commit manually. If this is omitted, the currently logged in user will be
+ * used instead.
+ *
+ * This is optional and for {@link #executeMerge()} only.
+ *
+ * @return This builder instance.
+ */
+ public MergeCommandBuilder setAuthor(Person author) {
+ request.setAuthor(author);
+ return this;
+ }
+
+ /**
+ * Use this to set a template for the commit message. If no message is set, a default message will be used.
+ *
+ * You can use the placeholder {0} for the branch to be merged and {1} for the target
+ * branch, eg.:
+ *
+ *
+ * ...setMessageTemplate("Merge of {0} into {1}")...
+ *
+ *
+ * This is optional and for {@link #executeMerge()} only.
+ *
+ * @return This builder instance.
+ */
+ public MergeCommandBuilder setMessageTemplate(String messageTemplate) {
+ request.setMessageTemplate(messageTemplate);
+ return this;
+ }
+
+ /**
+ * Use this to reset the command.
+ * @return This builder instance.
+ */
+ public MergeCommandBuilder reset() {
+ request.reset();
+ return this;
+ }
+
+ /**
+ * Use this to actually do the merge. If an automatic merge is not possible, {@link MergeCommandResult#isSuccess()}
+ * will return false.
+ *
+ * @return The result of the merge.
+ */
+ public MergeCommandResult executeMerge() {
+ Preconditions.checkArgument(request.isValid(), "revision to merge and target revision is required");
+ return mergeCommand.merge(request);
+ }
+
+ /**
+ * Use this to check whether the given branches can be merged autmatically. If this is possible,
+ * {@link MergeDryRunCommandResult#isMergeable()} will return true.
+ *
+ * @return The result whether the given branches can be merged automatically.
+ */
+ public MergeDryRunCommandResult dryRun() {
+ Preconditions.checkArgument(request.isValid(), "revision to merge and target revision is required");
+ return mergeCommand.dryRun(request);
+ }
+}
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandResult.java b/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandResult.java
new file mode 100644
index 0000000000..53f712cddc
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandResult.java
@@ -0,0 +1,44 @@
+package sonia.scm.repository.api;
+
+import java.util.Collection;
+import java.util.HashSet;
+
+import static java.util.Collections.emptyList;
+import static java.util.Collections.unmodifiableCollection;
+
+/**
+ * This class keeps the result of a merge of branches. Use {@link #isSuccess()} to check whether the merge was
+ * sucessfully executed. If the result is false the merge could not be done without conflicts. In this
+ * case you can use {@link #getFilesWithConflict()} to get a list of files with merge conflicts.
+ */
+public class MergeCommandResult {
+ private final Collection filesWithConflict;
+
+ private MergeCommandResult(Collection filesWithConflict) {
+ this.filesWithConflict = filesWithConflict;
+ }
+
+ public static MergeCommandResult success() {
+ return new MergeCommandResult(emptyList());
+ }
+
+ public static MergeCommandResult failure(Collection filesWithConflict) {
+ return new MergeCommandResult(new HashSet<>(filesWithConflict));
+ }
+
+ /**
+ * If this returns true, the merge was successfull. If this returns false there were
+ * merge conflicts. In this case you can use {@link #getFilesWithConflict()} to check what files could not be merged.
+ */
+ public boolean isSuccess() {
+ return filesWithConflict.isEmpty();
+ }
+
+ /**
+ * If the merge was not successful ({@link #isSuccess()} returns false) this will give you a list of
+ * file paths that could not be merged automatically.
+ */
+ public Collection getFilesWithConflict() {
+ return unmodifiableCollection(filesWithConflict);
+ }
+}
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MergeDryRunCommandResult.java b/scm-core/src/main/java/sonia/scm/repository/api/MergeDryRunCommandResult.java
new file mode 100644
index 0000000000..6ebb330aae
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/api/MergeDryRunCommandResult.java
@@ -0,0 +1,22 @@
+package sonia.scm.repository.api;
+
+/**
+ * This class keeps the result of a merge dry run. Use {@link #isMergeable()} to check whether an automatic merge is
+ * possible or not.
+ */
+public class MergeDryRunCommandResult {
+
+ private final boolean mergeable;
+
+ public MergeDryRunCommandResult(boolean mergeable) {
+ this.mergeable = mergeable;
+ }
+
+ /**
+ * This will return true, when an automatic merge is possible at the moment; false
+ * otherwise.
+ */
+ public boolean isMergeable() {
+ return mergeable;
+ }
+}
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 bdd6e4b320..fe0529e6b5 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
@@ -79,6 +79,7 @@ import java.util.stream.Stream;
* @apiviz.uses sonia.scm.repository.api.PushCommandBuilder
* @apiviz.uses sonia.scm.repository.api.BundleCommandBuilder
* @apiviz.uses sonia.scm.repository.api.UnbundleCommandBuilder
+ * @apiviz.uses sonia.scm.repository.api.MergeCommandBuilder
* @since 1.17
*/
@Slf4j
@@ -353,6 +354,22 @@ public final class RepositoryService implements Closeable {
repository);
}
+ /**
+ * The merge command executes a merge of two branches. It is possible to do a dry run to check, whether the given
+ * branches can be merged without conflicts.
+ *
+ * @return instance of {@link MergeCommandBuilder}
+ * @throws CommandNotSupportedException if the command is not supported
+ * by the implementation of the repository service provider.
+ * @since 2.0.0
+ */
+ public MergeCommandBuilder gerMergeCommand() {
+ logger.debug("create unbundle command for repository {}",
+ repository.getNamespaceAndName());
+
+ return new MergeCommandBuilder(provider.getMergeCommand());
+ }
+
/**
* Returns true if the command is supported by the repository service.
*
diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommand.java
new file mode 100644
index 0000000000..0a3680f6b3
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommand.java
@@ -0,0 +1,10 @@
+package sonia.scm.repository.spi;
+
+import sonia.scm.repository.api.MergeCommandResult;
+import sonia.scm.repository.api.MergeDryRunCommandResult;
+
+public interface MergeCommand {
+ MergeCommandResult merge(MergeCommandRequest request);
+
+ MergeDryRunCommandResult dryRun(MergeCommandRequest request);
+}
diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java
new file mode 100644
index 0000000000..baf03a0aef
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java
@@ -0,0 +1,93 @@
+package sonia.scm.repository.spi;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Objects;
+import com.google.common.base.Strings;
+import sonia.scm.Validateable;
+import sonia.scm.repository.Person;
+import sonia.scm.util.Util;
+
+import java.io.Serializable;
+
+public class MergeCommandRequest implements Validateable, Resetable, Serializable, Cloneable {
+
+ private static final long serialVersionUID = -2650236557922431528L;
+
+ private String branchToMerge;
+ private String targetBranch;
+ private Person author;
+ private String messageTemplate;
+
+ public String getBranchToMerge() {
+ return branchToMerge;
+ }
+
+ public void setBranchToMerge(String branchToMerge) {
+ this.branchToMerge = branchToMerge;
+ }
+
+ public String getTargetBranch() {
+ return targetBranch;
+ }
+
+ public void setTargetBranch(String targetBranch) {
+ this.targetBranch = targetBranch;
+ }
+
+ public Person getAuthor() {
+ return author;
+ }
+
+ public void setAuthor(Person author) {
+ this.author = author;
+ }
+
+ public String getMessageTemplate() {
+ return messageTemplate;
+ }
+
+ public void setMessageTemplate(String messageTemplate) {
+ this.messageTemplate = messageTemplate;
+ }
+
+ public boolean isValid() {
+ return !Strings.isNullOrEmpty(getBranchToMerge())
+ && !Strings.isNullOrEmpty(getTargetBranch());
+ }
+
+ public void reset() {
+ this.setBranchToMerge(null);
+ this.setTargetBranch(null);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) {
+ return false;
+ }
+
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+
+ final MergeCommandRequest other = (MergeCommandRequest) obj;
+
+ return Objects.equal(branchToMerge, other.branchToMerge)
+ && Objects.equal(targetBranch, other.targetBranch)
+ && Objects.equal(author, other.author);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(branchToMerge, targetBranch, author);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("branchToMerge", branchToMerge)
+ .add("targetBranch", targetBranch)
+ .add("author", author)
+ .toString();
+ }
+}
diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java
index c66c56c0f1..77201d1a72 100644
--- a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java
+++ b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java
@@ -251,4 +251,12 @@ public abstract class RepositoryServiceProvider implements Closeable
{
throw new CommandNotSupportedException(Command.UNBUNDLE);
}
+
+ /**
+ * @since 2.0
+ */
+ public MergeCommand getMergeCommand()
+ {
+ throw new CommandNotSupportedException(Command.MERGE);
+ }
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/CloseableWrapper.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/CloseableWrapper.java
new file mode 100644
index 0000000000..553f0f5a00
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/CloseableWrapper.java
@@ -0,0 +1,25 @@
+package sonia.scm.repository;
+
+import java.util.function.Consumer;
+
+public class CloseableWrapper implements AutoCloseable {
+
+ private final C wrapped;
+ private final Consumer cleanup;
+
+ public CloseableWrapper(C wrapped, Consumer cleanup) {
+ this.wrapped = wrapped;
+ this.cleanup = cleanup;
+ }
+
+ public C get() { return wrapped; }
+
+ @Override
+ public void close() {
+ try {
+ cleanup.accept(wrapped);
+ } catch (Throwable t) {
+ throw new RuntimeException(t);
+ }
+ }
+}
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 7ebc9b6f45..9da44d245f 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
@@ -90,6 +90,8 @@ public class GitRepositoryHandler
private static final Object LOCK = new Object();
private final Scheduler scheduler;
+
+ private final GitWorkdirFactory workdirFactory;
private Task task;
@@ -104,10 +106,11 @@ public class GitRepositoryHandler
* @param scheduler
*/
@Inject
- public GitRepositoryHandler(ConfigurationStoreFactory storeFactory, FileSystem fileSystem, Scheduler scheduler)
+ public GitRepositoryHandler(ConfigurationStoreFactory storeFactory, FileSystem fileSystem, Scheduler scheduler, GitWorkdirFactory workdirFactory)
{
super(storeFactory, fileSystem);
this.scheduler = scheduler;
+ this.workdirFactory = workdirFactory;
}
//~--- get methods ----------------------------------------------------------
@@ -234,4 +237,8 @@ public class GitRepositoryHandler
{
return new File(directory, DIRECTORY_REFS).exists();
}
+
+ public GitWorkdirFactory getWorkdirFactory() {
+ return workdirFactory;
+ }
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitWorkdirFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitWorkdirFactory.java
new file mode 100644
index 0000000000..f93713a221
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitWorkdirFactory.java
@@ -0,0 +1,8 @@
+package sonia.scm.repository;
+
+import sonia.scm.repository.spi.GitContext;
+import sonia.scm.repository.spi.WorkingCopy;
+
+public interface GitWorkdirFactory {
+ WorkingCopy createWorkingCopy(GitContext gitContext);
+}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java
index 2175846d5a..a10e16e200 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContext.java
@@ -106,6 +106,10 @@ public class GitContext implements Closeable
return repository;
}
+ File getDirectory() {
+ return directory;
+ }
+
//~--- fields ---------------------------------------------------------------
/** Field description */
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java
new file mode 100644
index 0000000000..05541e8295
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java
@@ -0,0 +1,169 @@
+package sonia.scm.repository.spi;
+
+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.MergeResult;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.MergeStrategy;
+import org.eclipse.jgit.merge.ResolveMerger;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import sonia.scm.repository.GitWorkdirFactory;
+import sonia.scm.repository.InternalRepositoryException;
+import sonia.scm.repository.Person;
+import sonia.scm.repository.api.MergeCommandResult;
+import sonia.scm.repository.api.MergeDryRunCommandResult;
+import sonia.scm.user.User;
+
+import java.io.IOException;
+import java.text.MessageFormat;
+
+public class GitMergeCommand extends AbstractGitCommand implements MergeCommand {
+
+ private static final Logger logger = LoggerFactory.getLogger(GitMergeCommand.class);
+
+ private static final String MERGE_COMMIT_MESSAGE_TEMPLATE = String.join("\n",
+ "Merge of branch {0} into {1}",
+ "",
+ "Automatic merge by SCM-Manager.");
+
+ private final GitWorkdirFactory workdirFactory;
+
+ GitMergeCommand(GitContext context, sonia.scm.repository.Repository repository, GitWorkdirFactory workdirFactory) {
+ super(context, repository);
+ this.workdirFactory = workdirFactory;
+ }
+
+ @Override
+ public MergeCommandResult merge(MergeCommandRequest request) {
+ try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context)) {
+ Repository repository = workingCopy.get();
+ logger.debug("cloned repository to folder {}", repository.getWorkTree());
+ return new MergeWorker(repository, request).merge();
+ } catch (IOException e) {
+ throw new InternalRepositoryException("could not clone repository for merge", e);
+ }
+ }
+
+ @Override
+ public MergeDryRunCommandResult dryRun(MergeCommandRequest request) {
+ try {
+ Repository repository = context.open();
+ ResolveMerger merger = (ResolveMerger) MergeStrategy.RECURSIVE.newMerger(repository, true);
+ return new MergeDryRunCommandResult(merger.merge(repository.resolve(request.getBranchToMerge()), repository.resolve(request.getTargetBranch())));
+ } catch (IOException e) {
+ throw new InternalRepositoryException("could not clone repository for merge", e);
+ }
+ }
+
+ private static class MergeWorker {
+
+ private final String target;
+ private final String toMerge;
+ private final Person author;
+ private final Git clone;
+ private final String messageTemplate;
+
+ private MergeWorker(Repository clone, MergeCommandRequest request) {
+ this.target = request.getTargetBranch();
+ this.toMerge = request.getBranchToMerge();
+ this.author = request.getAuthor();
+ this.messageTemplate = request.getMessageTemplate();
+ this.clone = new Git(clone);
+ }
+
+ private MergeCommandResult merge() throws IOException {
+ checkOutTargetBranch();
+ MergeResult result = doMergeInClone();
+ if (result.getMergeStatus().isSuccessful()) {
+ doCommit();
+ push();
+ return MergeCommandResult.success();
+ } else {
+ return analyseFailure(result);
+ }
+ }
+
+ private void checkOutTargetBranch() {
+ try {
+ clone.checkout().setName(target).call();
+ } catch (GitAPIException e) {
+ throw new InternalRepositoryException("could not checkout target branch for merge: " + target, e);
+ }
+ }
+
+ private MergeResult doMergeInClone() throws IOException {
+ MergeResult result;
+ try {
+ result = clone.merge()
+ .setCommit(false) // we want to set the author manually
+ .include(toMerge, resolveRevision(toMerge))
+ .call();
+ } catch (GitAPIException e) {
+ throw new InternalRepositoryException("could not merge branch " + toMerge + " into " + target, e);
+ }
+ return result;
+ }
+
+ private void doCommit() {
+ 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();
+ } catch (GitAPIException e) {
+ throw new InternalRepositoryException("could not commit merge between branch " + toMerge + " and " + target, e);
+ }
+ }
+
+ private String determineMessageTemplate() {
+ if (Strings.isNullOrEmpty(messageTemplate)) {
+ return MERGE_COMMIT_MESSAGE_TEMPLATE;
+ } else {
+ return messageTemplate;
+ }
+ }
+
+ private Person determineAuthor() {
+ if (author == null) {
+ Subject subject = SecurityUtils.getSubject();
+ User user = subject.getPrincipals().oneByType(User.class);
+ String name = user.getDisplayName();
+ String email = user.getMail();
+ logger.debug("no author set; using logged in user: {} <{}>", name, email);
+ return new Person(name, email);
+ } else {
+ return author;
+ }
+ }
+
+ private void push() {
+ try {
+ clone.push().call();
+ } catch (GitAPIException e) {
+ throw new InternalRepositoryException("could not push merged branch " + toMerge + " to origin", e);
+ }
+ logger.debug("pushed merged branch {}", target);
+ }
+
+ private MergeCommandResult analyseFailure(MergeResult result) {
+ logger.info("could not merged branch {} into {} due to conflict in paths {}", toMerge, target, result.getConflicts().keySet());
+ return MergeCommandResult.failure(result.getConflicts().keySet());
+ }
+
+ private ObjectId resolveRevision(String branchToMerge) throws IOException {
+ ObjectId resolved = clone.getRepository().resolve(branchToMerge);
+ if (resolved == null) {
+ return clone.getRepository().resolve("origin/" + branchToMerge);
+ } else {
+ return resolved;
+ }
+ }
+ }
+}
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 d60abd424d..a0a3074014 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
@@ -63,7 +63,8 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
Command.INCOMING,
Command.OUTGOING,
Command.PUSH,
- Command.PULL
+ Command.PULL,
+ Command.MERGE
);
//J+
@@ -240,7 +241,12 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
return new GitTagsCommand(context, repository);
}
- //~--- fields ---------------------------------------------------------------
+ @Override
+ public MergeCommand getMergeCommand() {
+ return new GitMergeCommand(context, repository, handler.getWorkdirFactory());
+ }
+
+//~--- fields ---------------------------------------------------------------
/** Field description */
private GitContext context;
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
new file mode 100644
index 0000000000..a8a54d3c0c
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java
@@ -0,0 +1,62 @@
+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.util.FileUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import sonia.scm.repository.GitWorkdirFactory;
+import sonia.scm.repository.InternalRepositoryException;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+public class SimpleGitWorkdirFactory implements GitWorkdirFactory {
+
+ private static final Logger logger = LoggerFactory.getLogger(SimpleGitWorkdirFactory.class);
+
+ private final File poolDirectory;
+
+ public SimpleGitWorkdirFactory() {
+ this(new File(System.getProperty("java.io.tmpdir"), "scmm-git-pool"));
+ }
+
+ public SimpleGitWorkdirFactory(File poolDirectory) {
+ this.poolDirectory = poolDirectory;
+ poolDirectory.mkdirs();
+ }
+
+ public WorkingCopy createWorkingCopy(GitContext gitContext) {
+ try {
+ Repository clone = cloneRepository(gitContext.getDirectory(), createNewWorkdir());
+ return new WorkingCopy(clone, this::close);
+ } catch (GitAPIException e) {
+ throw new InternalRepositoryException("could not clone working copy of repository", e);
+ } catch (IOException e) {
+ throw new InternalRepositoryException("could not create temporary directory for clone of repository", e);
+ }
+ }
+
+ private File createNewWorkdir() throws IOException {
+ return Files.createTempDirectory(poolDirectory.toPath(),"workdir").toFile();
+ }
+
+ protected Repository cloneRepository(File bareRepository, File target) throws GitAPIException {
+ return Git.cloneRepository()
+ .setURI(bareRepository.getAbsolutePath())
+ .setDirectory(target)
+ .call()
+ .getRepository();
+ }
+
+ private void close(Repository repository) {
+ repository.close();
+ try {
+ FileUtils.delete(repository.getWorkTree(), FileUtils.RECURSIVE);
+ } catch (IOException e) {
+ logger.warn("could not delete temporary git workdir '{}'", repository.getWorkTree(), e);
+ }
+ }
+}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/WorkingCopy.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/WorkingCopy.java
new file mode 100644
index 0000000000..fd0cba510b
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/WorkingCopy.java
@@ -0,0 +1,12 @@
+package sonia.scm.repository.spi;
+
+import org.eclipse.jgit.lib.Repository;
+import sonia.scm.repository.CloseableWrapper;
+
+import java.util.function.Consumer;
+
+public class WorkingCopy extends CloseableWrapper {
+ WorkingCopy(Repository wrapped, Consumer cleanup) {
+ super(wrapped, cleanup);
+ }
+}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitServletModule.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitServletModule.java
index e731e01a62..a3dac0e7d1 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitServletModule.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitServletModule.java
@@ -41,6 +41,8 @@ import org.mapstruct.factory.Mappers;
import sonia.scm.api.v2.resources.GitConfigDtoToGitConfigMapper;
import sonia.scm.api.v2.resources.GitConfigToGitConfigDtoMapper;
import sonia.scm.plugin.Extension;
+import sonia.scm.repository.GitWorkdirFactory;
+import sonia.scm.repository.spi.SimpleGitWorkdirFactory;
import sonia.scm.web.lfs.LfsBlobStoreFactory;
/**
@@ -63,5 +65,7 @@ public class GitServletModule extends ServletModule
bind(GitConfigDtoToGitConfigMapper.class).to(Mappers.getMapper(GitConfigDtoToGitConfigMapper.class).getClass());
bind(GitConfigToGitConfigDtoMapper.class).to(Mappers.getMapper(GitConfigToGitConfigDtoMapper.class).getClass());
+
+ bind(GitWorkdirFactory.class).to(SimpleGitWorkdirFactory.class);
}
}
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/CloseableWrapperTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/CloseableWrapperTest.java
new file mode 100644
index 0000000000..e92ee7abb5
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/CloseableWrapperTest.java
@@ -0,0 +1,29 @@
+package sonia.scm.repository;
+
+import org.junit.Test;
+
+import java.util.function.Consumer;
+
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+public class CloseableWrapperTest {
+
+ @Test
+ public void shouldExecuteGivenMethodAtClose() {
+ Consumer wrapped = new Consumer() {
+ // no this cannot be replaced with a lambda because otherwise we could not use Mockito#spy
+ @Override
+ public void accept(String s) {
+ }
+ };
+
+ Consumer closer = spy(wrapped);
+
+ try (CloseableWrapper wrapper = new CloseableWrapper<>("test", closer)) {
+ // nothing to do here
+ }
+
+ verify(closer).accept("test");
+ }
+}
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java
index a9969f27f8..ad731280c5 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryHandlerTest.java
@@ -61,6 +61,9 @@ public class GitRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase {
@Mock
private ConfigurationStoreFactory factory;
+ @Mock
+ private GitWorkdirFactory gitWorkdirFactory;
+
@Override
protected void checkDirectory(File directory) {
File head = new File(directory, "HEAD");
@@ -84,7 +87,7 @@ public class GitRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase {
protected RepositoryHandler createRepositoryHandler(ConfigurationStoreFactory factory,
File directory) {
GitRepositoryHandler repositoryHandler = new GitRepositoryHandler(factory,
- new DefaultFileSystem(), scheduler);
+ new DefaultFileSystem(), scheduler, gitWorkdirFactory);
repositoryHandler.init(contextProvider);
@@ -100,7 +103,7 @@ public class GitRepositoryHandlerTest extends SimpleRepositoryHandlerTestBase {
@Test
public void getDirectory() {
GitRepositoryHandler repositoryHandler = new GitRepositoryHandler(factory,
- new DefaultFileSystem(), scheduler);
+ new DefaultFileSystem(), scheduler, gitWorkdirFactory);
GitConfig gitConfig = new GitConfig();
gitConfig.setRepositoryDirectory(new File("/path"));
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java
index 496b71e656..7d56db00d7 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractGitCommandTestBase.java
@@ -50,7 +50,9 @@ public class AbstractGitCommandTestBase extends ZippedRepositoryTestBase
@After
public void close()
{
- context.close();
+ if (context != null) {
+ context.close();
+ }
}
/**
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
new file mode 100644
index 0000000000..1fca7814ed
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java
@@ -0,0 +1,139 @@
+package sonia.scm.repository.spi;
+
+import com.github.sdorra.shiro.ShiroRule;
+import com.github.sdorra.shiro.SubjectAware;
+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.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Rule;
+import org.junit.Test;
+import sonia.scm.repository.Person;
+import sonia.scm.repository.api.MergeCommandResult;
+import sonia.scm.user.User;
+
+import java.io.IOException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini")
+public class GitMergeCommandTest extends AbstractGitCommandTestBase {
+
+ private static final String REALM = "AdminRealm";
+
+ @Rule
+ public ShiroRule shiro = new ShiroRule();
+
+ @Test
+ public void shouldDetectMergeableBranches() {
+ GitMergeCommand command = createCommand();
+ MergeCommandRequest request = new MergeCommandRequest();
+ request.setBranchToMerge("mergeable");
+ request.setTargetBranch("master");
+
+ boolean mergeable = command.dryRun(request).isMergeable();
+
+ assertThat(mergeable).isTrue();
+ }
+
+ @Test
+ public void shouldDetectNotMergeableBranches() {
+ GitMergeCommand command = createCommand();
+ MergeCommandRequest request = new MergeCommandRequest();
+ request.setBranchToMerge("test-branch");
+ request.setTargetBranch("master");
+
+ boolean mergeable = command.dryRun(request).isMergeable();
+
+ assertThat(mergeable).isFalse();
+ }
+
+ @Test
+ public void shouldMergeMergeableBranches() 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();
+ Iterable commits = new Git(repository).log().add(repository.resolve("master")).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");
+ }
+
+ @Test
+ public void shouldUseConfiguredCommitMessageTemplate() 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"));
+ request.setMessageTemplate("simple");
+
+ MergeCommandResult mergeCommandResult = command.merge(request);
+
+ assertThat(mergeCommandResult.isSuccess()).isTrue();
+
+ Repository repository = createContext().open();
+ Iterable commits = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call();
+ RevCommit mergeCommit = commits.iterator().next();
+ String message = mergeCommit.getFullMessage();
+ assertThat(message).isEqualTo("simple");
+ }
+
+ @Test
+ public void shouldNotMergeConflictingBranches() {
+ GitMergeCommand command = createCommand();
+ MergeCommandRequest request = new MergeCommandRequest();
+ request.setBranchToMerge("test-branch");
+ request.setTargetBranch("master");
+
+ MergeCommandResult mergeCommandResult = command.merge(request);
+
+ assertThat(mergeCommandResult.isSuccess()).isFalse();
+ assertThat(mergeCommandResult.getFilesWithConflict()).containsExactly("a.txt");
+ }
+
+ @Test
+ @SubjectAware(username = "admin", password = "secret")
+ public void shouldTakeAuthorFromSubjectIfNotSet() throws IOException, GitAPIException {
+ shiro.setSubject(
+ new Subject.Builder()
+ .principals(new SimplePrincipalCollection(new User("dirk", "Dirk Gently", "dirk@holistic.det"), REALM))
+ .buildSubject());
+ GitMergeCommand command = createCommand();
+ MergeCommandRequest request = new MergeCommandRequest();
+ request.setTargetBranch("master");
+ request.setBranchToMerge("mergeable");
+
+ MergeCommandResult mergeCommandResult = command.merge(request);
+
+ assertThat(mergeCommandResult.isSuccess()).isTrue();
+
+ Repository repository = createContext().open();
+ Iterable mergeCommit = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call();
+ PersonIdent mergeAuthor = mergeCommit.iterator().next().getAuthorIdent();
+ assertThat(mergeAuthor.getName()).isEqualTo("Dirk Gently");
+ assertThat(mergeAuthor.getEmailAddress()).isEqualTo("dirk@holistic.det");
+ }
+
+ private GitMergeCommand createCommand() {
+ return new GitMergeCommand(createContext(), repository, new SimpleGitWorkdirFactory());
+ }
+}
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java
new file mode 100644
index 0000000000..0c39a1deb0
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java
@@ -0,0 +1,87 @@
+package sonia.scm.repository.spi;
+
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.io.IOException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+public class SimpleGitWorkdirFactoryTest extends AbstractGitCommandTestBase {
+
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void emptyPoolShouldCreateNewWorkdir() throws IOException {
+ SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(temporaryFolder.newFolder());
+ File masterRepo = createRepositoryDirectory();
+
+ try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext())) {
+
+ assertThat(workingCopy.get().getDirectory())
+ .exists()
+ .isNotEqualTo(masterRepo)
+ .isDirectory();
+ assertThat(new File(workingCopy.get().getWorkTree(), "a.txt"))
+ .exists()
+ .isFile()
+ .hasContent("a\nline for blame");
+ }
+ }
+
+ @Test
+ public void cloneFromPoolShouldBeClosed() throws IOException {
+ PoolWithSpy factory = new PoolWithSpy(temporaryFolder.newFolder());
+
+ try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext())) {
+ assertThat(workingCopy).isNotNull();
+ }
+ verify(factory.createdClone).close();
+ }
+
+ @Test
+ public void cloneFromPoolShouldNotBeReused() throws IOException {
+ SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(temporaryFolder.newFolder());
+
+ File firstDirectory;
+ try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext())) {
+ firstDirectory = workingCopy.get().getDirectory();
+ }
+ try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext())) {
+ File secondDirectory = workingCopy.get().getDirectory();
+ assertThat(secondDirectory).isNotEqualTo(firstDirectory);
+ }
+ }
+
+ @Test
+ public void cloneFromPoolShouldBeDeletedOnClose() throws IOException {
+ SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(temporaryFolder.newFolder());
+
+ File directory;
+ try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext())) {
+ directory = workingCopy.get().getWorkTree();
+ }
+ assertThat(directory).doesNotExist();
+ }
+
+ private static class PoolWithSpy extends SimpleGitWorkdirFactory {
+ PoolWithSpy(File poolDirectory) {
+ super(poolDirectory);
+ }
+
+ Repository createdClone;
+
+ @Override
+ protected Repository cloneRepository(File bareRepository, File destination) throws GitAPIException {
+ createdClone = spy(super.cloneRepository(bareRepository, destination));
+ return createdClone;
+ }
+ }
+}
diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-test.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-test.zip
index 3fbab0be38..8f689e9664 100644
Binary files a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-test.zip and b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-test.zip differ