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