From e0a56a1369a2d772494262b6f780e600456cfba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Thu, 1 Sep 2022 15:31:33 +0200 Subject: [PATCH] Implement commit search features for git (#2111) Implements the required features for the commit search plugin for git. --- gradle/changelog/commit_search_git.yaml | 2 + .../repository/api/HookChangesetBuilder.java | 83 ++---- .../scm/repository/GitChangesetConverter.java | 12 +- .../repository/GitHookChangesetCollector.java | 217 ---------------- .../java/sonia/scm/repository/GitUtil.java | 30 +++ .../scm/repository/spi/GitBranchCommand.java | 83 ++++-- .../repository/spi/GitChangesetsCommand.java | 125 ++++++++++ .../spi/GitHookChangesetCollector.java | 236 ++++++++++++++++++ .../spi/GitHookChangesetProvider.java | 10 +- .../spi/GitRepositoryServiceProvider.java | 8 +- .../scm/repository/spi/GitTagCommand.java | 73 ++++-- .../repository/spi/GitBranchCommandTest.java | 43 +++- .../spi/GitChangesetsCommandTest.java | 76 ++++++ .../spi/GitHookChangesetCollectorTest.java | 211 ++++++++++++++++ .../scm/repository/spi/GitTagCommandTest.java | 72 ++++-- 15 files changed, 925 insertions(+), 356 deletions(-) create mode 100644 gradle/changelog/commit_search_git.yaml delete mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHookChangesetCollector.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitChangesetsCommand.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookChangesetCollector.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitChangesetsCommandTest.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitHookChangesetCollectorTest.java diff --git a/gradle/changelog/commit_search_git.yaml b/gradle/changelog/commit_search_git.yaml new file mode 100644 index 0000000000..2ce13b64cc --- /dev/null +++ b/gradle/changelog/commit_search_git.yaml @@ -0,0 +1,2 @@ +- type: added + description: Implement commit search features for git ([#2111](https://github.com/scm-manager/scm-manager/pull/2111)) diff --git a/scm-core/src/main/java/sonia/scm/repository/api/HookChangesetBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/HookChangesetBuilder.java index e8d1f2063f..f3b2a4a19b 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/HookChangesetBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/HookChangesetBuilder.java @@ -26,13 +26,10 @@ package sonia.scm.repository.api; //~--- non-JDK imports -------------------------------------------------------- -import com.google.common.base.Function; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import sonia.scm.io.DeepCopy; import sonia.scm.repository.Changeset; import sonia.scm.repository.PreProcessorUtil; @@ -41,13 +38,8 @@ import sonia.scm.repository.spi.HookChangesetProvider; import sonia.scm.repository.spi.HookChangesetRequest; import sonia.scm.repository.spi.HookChangesetResponse; -//~--- JDK imports ------------------------------------------------------------ - import java.io.IOException; - import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.StreamSupport; /** * The {@link HookChangesetBuilder} is able to return all {@link Changeset}s @@ -113,67 +105,36 @@ public final class HookChangesetBuilder { HookChangesetResponse hookChangesetResponse = provider.handleRequest(request); Iterable changesets = hookChangesetResponse.getChangesets(); - - if (!disablePreProcessors) - { - changesets = Iterables.transform(changesets, - new Function() - { - - @Override - public Changeset apply(Changeset c) - { - Changeset copy = null; - - try - { - copy = DeepCopy.copy(c); - preProcessorUtil.prepareForReturn(repository, copy); - } - catch (IOException ex) - { - logger.error("could not create a copy of changeset", ex); - } - - if (copy == null) - { - copy = c; - } - - return copy; - } - - }); - } - - return changesets; + return applyPreprocessorsIfNotDisabled(changesets); } public Iterable getRemovedChangesets() { HookChangesetResponse hookChangesetResponse = provider.handleRequest(request); Iterable changesets = hookChangesetResponse.getRemovedChangesets(); + return applyPreprocessorsIfNotDisabled(changesets); + } - if (!disablePreProcessors) - { - changesets = StreamSupport.stream(changesets.spliterator(), false).map(c -> { - Changeset copy = null; - - try { - copy = DeepCopy.copy(c); - preProcessorUtil.prepareForReturn(repository, copy); - } catch (IOException ex) { - logger.error("could not create a copy of changeset", ex); - } - - if (copy == null) { - return c; - } - - return copy; - }).collect(Collectors.toList()); + private Iterable applyPreprocessorsIfNotDisabled(Iterable changesets) { + if (disablePreProcessors) { + return changesets; } - return changesets; + return Iterables.transform(changesets, c -> { + Changeset copy = null; + + try { + copy = DeepCopy.copy(c); + preProcessorUtil.prepareForReturn(repository, copy); + } catch (IOException ex) { + logger.error("could not create a copy of changeset", ex); + } + + if (copy == null) { + copy = c; + } + + return copy; + }); } //~--- set methods ---------------------------------------------------------- diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java index 2984be829f..cf7e56f9a0 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java @@ -65,12 +65,8 @@ public class GitChangesetConverter implements Closeable { this.treeWalk = new TreeWalk(repository); } - public Changeset createChangeset(RevCommit commit) { - return createChangeset(commit, Collections.emptyList()); - } - - public Changeset createChangeset(RevCommit commit, String branch) { - return createChangeset(commit, Lists.newArrayList(branch)); + public Changeset createChangeset(RevCommit commit, String... branches) { + return createChangeset(commit, Arrays.asList(branches)); } public Changeset createChangeset(RevCommit commit, List branches) { @@ -112,7 +108,7 @@ public class GitChangesetConverter implements Closeable { changeset.getTags().addAll(Lists.newArrayList(tagCollection)); } - changeset.setBranches(branches); + changeset.setBranches(new ArrayList<>(branches)); Signature signature = createSignature(commit); if (signature != null) { @@ -142,7 +138,7 @@ public class GitChangesetConverter implements Closeable { } Optional publicKeyById = gpg.findPublicKey(publicKeyId); - if (!publicKeyById.isPresent()) { + if (publicKeyById.isEmpty()) { // key not found return new Signature(publicKeyId, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet()); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHookChangesetCollector.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHookChangesetCollector.java deleted file mode 100644 index 4565e17754..0000000000 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHookChangesetCollector.java +++ /dev/null @@ -1,217 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2020-present Cloudogu GmbH and Contributors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package sonia.scm.repository; - -//~--- non-JDK imports -------------------------------------------------------- - -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; - -import org.eclipse.jgit.lib.ObjectId; -import org.eclipse.jgit.revwalk.RevCommit; -import org.eclipse.jgit.revwalk.RevSort; -import org.eclipse.jgit.revwalk.RevWalk; -import org.eclipse.jgit.transport.ReceiveCommand; -import org.eclipse.jgit.transport.ReceivePack; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import sonia.scm.web.CollectingPackParserListener; - -//~--- JDK imports ------------------------------------------------------------ - -import java.io.IOException; - -import java.util.List; -import java.util.Map; - -/** - * - * @author Sebastian Sdorra - */ -public class GitHookChangesetCollector -{ - - /** - * the logger for GitHookChangesetCollector - */ - private static final Logger logger = - LoggerFactory.getLogger(GitHookChangesetCollector.class); - - //~--- constructors --------------------------------------------------------- - - /** - * Constructs a new instance - * - * - * @param rpack - * @param receiveCommands - */ - public GitHookChangesetCollector(GitChangesetConverterFactory converterFactory, ReceivePack rpack, - List receiveCommands) - { - this.converterFactory = converterFactory; - this.rpack = rpack; - this.receiveCommands = receiveCommands; - this.listener = CollectingPackParserListener.get(rpack); - } - - //~--- methods -------------------------------------------------------------- - - /** - * Collect all new changesets from the received hook. - * - * @return new changesets - */ - public List collectChangesets() - { - Map changesets = Maps.newLinkedHashMap(); - - try ( - org.eclipse.jgit.lib.Repository repository = rpack.getRepository(); - RevWalk walk = rpack.getRevWalk(); - GitChangesetConverter converter = converterFactory.create(repository, walk) - ) { - repository.incrementOpen(); - - for (ReceiveCommand rc : receiveCommands) - { - String ref = rc.getRefName(); - - logger.trace("handle receive command, type={}, ref={}, result={}", rc.getType(), ref, rc.getResult()); - - if (rc.getType() == ReceiveCommand.Type.DELETE) - { - logger.debug("skip delete of ref {}", ref); - } - else if (! GitUtil.isBranch(ref)) - { - logger.debug("skip ref {}, because it is not a branch", ref); - } - else - { - try - { - collectChangesets(changesets, converter, walk, rc); - } - catch (IOException ex) - { - StringBuilder builder = new StringBuilder(); - - builder.append("could not handle receive command, type="); - builder.append(rc.getType()).append(", ref="); - builder.append(rc.getRefName()).append(", result="); - builder.append(rc.getResult()); - - logger.error(builder.toString(), ex); - } - } - } - - } - catch (Exception ex) - { - logger.error("could not collect changesets", ex); - } - - return Lists.newArrayList(changesets.values()); - } - - private void collectChangesets(Map changesets, - GitChangesetConverter converter, RevWalk walk, ReceiveCommand rc) - throws IOException - { - ObjectId newId = rc.getNewId(); - - String branch = GitUtil.getBranch(rc.getRefName()); - - walk.reset(); - walk.sort(RevSort.TOPO); - walk.sort(RevSort.REVERSE, true); - - logger.trace("mark {} as start for rev walk", newId.getName()); - - walk.markStart(walk.parseCommit(newId)); - - ObjectId oldId = rc.getOldId(); - - if ((oldId != null) && !oldId.equals(ObjectId.zeroId())) - { - logger.trace("mark {} as uninteresting for rev walk", oldId.getName()); - - walk.markUninteresting(walk.parseCommit(oldId)); - } - - RevCommit commit = walk.next(); - - while (commit != null) - { - String id = commit.getId().name(); - Changeset changeset = changesets.get(id); - - if (changeset != null) - { - logger.trace( - "commit {} already received durring this push, add branch {} to the commit", - commit, branch); - changeset.getBranches().add(branch); - } - else - { - - // only append new commits - if (listener.isNew(commit)) - { - - // parse commit body to avoid npe - walk.parseBody(commit); - - changeset = converter.createChangeset(commit, branch); - - logger.trace("retrieve commit {} for hook", changeset.getId()); - - changesets.put(id, changeset); - } - else - { - logger.trace("commit {} was already received", commit.getId()); - } - } - - commit = walk.next(); - } - } - - //~--- fields --------------------------------------------------------------- - - /** listener to track new objects */ - private final CollectingPackParserListener listener; - - private final List receiveCommands; - - private final GitChangesetConverterFactory converterFactory; - private final ReceivePack rpack; -} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java index f1b4832885..bad75b3641 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java @@ -68,8 +68,10 @@ import java.io.IOException; import java.io.InputStream; import java.util.Collections; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; import static java.util.Optional.empty; import static java.util.Optional.of; @@ -233,6 +235,17 @@ public final class GitUtil { return Strings.nullToEmpty(refName).startsWith(PREFIX_HEADS); } + /** + * Returns {@code true} if the provided reference name is a tag name. + * + * @param refName reference name + * @return {@code true} if the name is a tag name + * @since 2.39.0 + */ + public static boolean isTag(String refName) { + return Strings.nullToEmpty(refName).startsWith(PREFIX_TAG); + } + public static Ref getBranchIdOrCurrentHead(org.eclipse.jgit.lib.Repository gitRepository, String requestedBranch) throws IOException { if (Strings.isNullOrEmpty(requestedBranch)) { logger.trace("no default branch configured, use repository head as default"); @@ -700,4 +713,21 @@ public final class GitUtil { .setRefSpecs(new RefSpec(REF_SPEC)) .setTagOpt(TagOpt.FETCH_TAGS); } + + public static Stream getAllCommits(org.eclipse.jgit.lib.Repository repository, RevWalk revWalk) throws IOException { + return repository.getRefDatabase() + .getRefs() + .stream() + .map(ref -> getCommitFromRef(ref, revWalk)) + .filter(Objects::nonNull); + } + + public static RevCommit getCommitFromRef(Ref ref, RevWalk revWalk) { + try { + return getCommit(null, revWalk, ref); + } catch (IOException e) { + logger.info("could not get commit for {}", ref, e); + return null; + } + } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java index fd2ddf013d..49949d74cf 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java @@ -27,9 +27,14 @@ package sonia.scm.repository.spi; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.CannotDeleteCurrentBranchException; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.ReceiveCommand; import sonia.scm.event.ScmEventBus; import sonia.scm.repository.Branch; +import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.GitUtil; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.PostReceiveRepositoryHookEvent; @@ -44,28 +49,41 @@ import sonia.scm.repository.api.HookFeature; import javax.inject.Inject; import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Set; -import static java.util.Collections.*; +import static java.util.Collections.emptyList; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; +import static org.eclipse.jgit.lib.ObjectId.zeroId; import static sonia.scm.ContextEntry.ContextBuilder.entity; public class GitBranchCommand extends AbstractGitCommand implements BranchCommand { private final HookContextFactory hookContextFactory; private final ScmEventBus eventBus; + private final GitChangesetConverterFactory converterFactory; @Inject - GitBranchCommand(GitContext context, HookContextFactory hookContextFactory, ScmEventBus eventBus) { + GitBranchCommand(GitContext context, HookContextFactory hookContextFactory, ScmEventBus eventBus, GitChangesetConverterFactory converterFactory) { super(context); this.hookContextFactory = hookContextFactory; this.eventBus = eventBus; + this.converterFactory = converterFactory; } @Override public Branch branch(BranchRequest request) { try (Git git = new Git(context.open())) { - RepositoryHookEvent hookEvent = createBranchHookEvent(BranchHookContextProvider.createHookEvent(request.getNewBranch())); + ObjectId newRef; + if (request.getParentBranch() == null) { + newRef = git.log().call().iterator().next(); + } else { + newRef = getRef(git.getRepository(), request.getParentBranch()); + } + RepositoryHookEvent hookEvent = createBranchHookEvent(createHookEvent(request.getNewBranch(), newRef)); eventBus.post(new PreReceiveRepositoryHookEvent(hookEvent)); Ref ref = git.branchCreate().setStartPoint(request.getParentBranch()).setName(request.getNewBranch()).call(); eventBus.post(new PostReceiveRepositoryHookEvent(hookEvent)); @@ -78,7 +96,8 @@ public class GitBranchCommand extends AbstractGitCommand implements BranchComman @Override public void deleteOrClose(String branchName) { try (Git gitRepo = new Git(context.open())) { - RepositoryHookEvent hookEvent = createBranchHookEvent(BranchHookContextProvider.deleteHookEvent(branchName)); + ObjectId oldRef = getRef(gitRepo.getRepository(), branchName); + RepositoryHookEvent hookEvent = createBranchHookEvent(deleteHookEvent(branchName, oldRef)); eventBus.post(new PreReceiveRepositoryHookEvent(hookEvent)); gitRepo .branchDelete() @@ -98,21 +117,31 @@ public class GitBranchCommand extends AbstractGitCommand implements BranchComman return new RepositoryHookEvent(context, this.context.getRepository(), RepositoryHookType.PRE_RECEIVE); } - private static class BranchHookContextProvider extends HookContextProvider { + private ObjectId getRef(Repository gitRepo, String branch) { + try { + return gitRepo.getRefDatabase().findRef("refs/heads/" + branch).getObjectId(); + } catch (IOException e) { + throw new InternalRepositoryException(repository, "error reading ref for branch", e); + } + } + + private BranchHookContextProvider createHookEvent(String newBranch, ObjectId objectId) { + return new BranchHookContextProvider(singletonList(newBranch), emptyList(), objectId); + } + + private BranchHookContextProvider deleteHookEvent(String deletedBranch, ObjectId oldObjectId) { + return new BranchHookContextProvider(emptyList(), singletonList(deletedBranch), oldObjectId); + } + + private class BranchHookContextProvider extends HookContextProvider { private final List newBranches; private final List deletedBranches; + private final ObjectId objectId; - private BranchHookContextProvider(List newBranches, List deletedBranches) { + private BranchHookContextProvider(List newBranches, List deletedBranches, ObjectId objectId) { this.newBranches = newBranches; this.deletedBranches = deletedBranches; - } - - static BranchHookContextProvider createHookEvent(String newBranch) { - return new BranchHookContextProvider(singletonList(newBranch), emptyList()); - } - - static BranchHookContextProvider deleteHookEvent(String deletedBranch) { - return new BranchHookContextProvider(emptyList(), singletonList(deletedBranch)); + this.objectId = objectId; } @Override @@ -137,7 +166,31 @@ public class GitBranchCommand extends AbstractGitCommand implements BranchComman @Override public HookChangesetProvider getChangesetProvider() { - return r -> new HookChangesetResponse(emptyList()); + Repository gitRepo; + try { + gitRepo = context.open(); + } catch (IOException e) { + throw new InternalRepositoryException(repository, "failed to open repository for post receive hook after internal change", e); + } + + Collection receiveCommands = new ArrayList<>(); + newBranches.stream() + .map(branch -> new ReceiveCommand(zeroId(), objectId, "refs/heads/" + branch)) + .forEach(receiveCommands::add); + deletedBranches.stream() + .map(branch -> new ReceiveCommand(objectId, zeroId(), "refs/heads/" + branch)) + .forEach(receiveCommands::add); + return x -> { + GitHookChangesetCollector collector = + GitHookChangesetCollector.collectChangesets( + converterFactory, + receiveCommands, + gitRepo, + new RevWalk(gitRepo), + commit -> false // we cannot create new commits with this tag command + ); + return new HookChangesetResponse(collector.getAddedChangesets(), collector.getRemovedChangesets()); + }; } } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitChangesetsCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitChangesetsCommand.java new file mode 100644 index 0000000000..7e56672fcf --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitChangesetsCommand.java @@ -0,0 +1,125 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.GitChangesetConverter; +import sonia.scm.repository.GitChangesetConverterFactory; +import sonia.scm.repository.GitUtil; +import sonia.scm.repository.InternalRepositoryException; + +import javax.inject.Inject; +import java.io.IOException; +import java.util.Comparator; +import java.util.Iterator; +import java.util.Optional; +import java.util.stream.Collectors; + +@Slf4j +public class GitChangesetsCommand extends AbstractGitCommand implements ChangesetsCommand { + + private final GitChangesetConverterFactory converterFactory; + + @Inject + GitChangesetsCommand(GitContext context, GitChangesetConverterFactory converterFactory) { + super(context); + this.converterFactory = converterFactory; + } + + @Override + public Iterable getChangesets(ChangesetsCommandRequest request) { + try { + log.debug("computing changesets for repository {}", repository); + Repository gitRepository = open(); + + try (RevWalk revWalk = new RevWalk(gitRepository)) { + revWalk.markStart(GitUtil.getAllCommits(gitRepository, revWalk).collect(Collectors.toList())); + log.trace("got git iterator for all changesets for repository {}", repository); + Iterator iterator = revWalk.iterator(); + return () -> new ChangesetIterator(iterator, revWalk, gitRepository); + } finally { + log.trace("returned iterator for all changesets for repository {}", gitRepository); + } + } catch (IOException e) { + throw new InternalRepositoryException(context.getRepository(), "failed to get latest commit", e); + } + } + + @Override + public Optional getLatestChangeset() { + try { + Repository repository = open(); + + try (RevWalk revWalk = new RevWalk(repository)) { + return GitUtil.getAllCommits(repository, revWalk) + .max(new ByCommitDateComparator()) + .map(commit -> converterFactory.create(repository, revWalk).createChangeset(commit)); + } + } catch (IOException e) { + throw new InternalRepositoryException(context.getRepository(), "failed to get latest commit", e); + } + } + + private class ChangesetIterator implements Iterator { + + private final Iterator iterator; + private final GitChangesetConverter changesetConverter; + private final RevWalk revWalk; + + ChangesetIterator(Iterator iterator, RevWalk revWalk, Repository gitRepository) { + this.iterator = iterator; + this.changesetConverter = converterFactory.create(gitRepository, revWalk); + this.revWalk = revWalk; + } + + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public Changeset next() { + try { + log.trace("mapping changeset for repository {}", repository); + return changesetConverter.createChangeset(revWalk.parseCommit(iterator.next())); + } catch (IOException e) { + throw new InternalRepositoryException(context.getRepository(), "failed to create changeset for single git revision", e); + } + } + } + + private static class ByCommitDateComparator implements Comparator { + @Override + public int compare(RevCommit rev1, RevCommit rev2) { + long commitTime1 = rev1.getCommitTime(); + long commitTime2 = rev2.getCommitTime(); + return Long.compare(commitTime1, commitTime2); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookChangesetCollector.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookChangesetCollector.java new file mode 100644 index 0000000000..b6be9b4454 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookChangesetCollector.java @@ -0,0 +1,236 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import com.google.common.collect.Maps; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevSort; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.ReceiveCommand; +import org.eclipse.jgit.transport.ReceivePack; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.GitChangesetConverter; +import sonia.scm.repository.GitChangesetConverterFactory; +import sonia.scm.repository.GitUtil; +import sonia.scm.web.CollectingPackParserListener; + +import java.io.IOException; +import java.util.Collection; +import java.util.Map; + +import static java.util.Collections.unmodifiableCollection; + +/** + * @author Sebastian Sdorra + */ +class GitHookChangesetCollector { + + private static final Logger LOG = LoggerFactory.getLogger(GitHookChangesetCollector.class); + + + private final Collection receiveCommands; + + private final GitChangesetConverterFactory converterFactory; + + /** + * listener to track new objects + */ + private final NewCommitDetector newCommitDetector; + + private final Repository repository; + private final RevWalk walk; + + private final Map addedChangesets = Maps.newLinkedHashMap(); + private final Map removedChangesets = Maps.newLinkedHashMap(); + + private GitHookChangesetCollector(GitChangesetConverterFactory converterFactory, Collection receiveCommands, NewCommitDetector newCommitDetector, Repository repository, RevWalk walk) { + this.converterFactory = converterFactory; + this.receiveCommands = receiveCommands; + this.newCommitDetector = newCommitDetector; + this.repository = repository; + this.walk = walk; + } + + static GitHookChangesetCollector collectChangesets(GitChangesetConverterFactory converterFactory, Collection receiveCommands, ReceivePack rpack) { + try (Repository repository = rpack.getRepository(); + RevWalk walk = rpack.getRevWalk()) { + CollectingPackParserListener listener = CollectingPackParserListener.get(rpack); + return collectChangesets(converterFactory, receiveCommands, repository, walk, listener::isNew); + } + } + + static GitHookChangesetCollector collectChangesets(GitChangesetConverterFactory converterFactory, Collection receiveCommands, Repository repository, RevWalk walk, NewCommitDetector newCommitDetector) { + GitHookChangesetCollector gitHookChangesetCollector = new GitHookChangesetCollector(converterFactory, receiveCommands, newCommitDetector, repository, walk); + gitHookChangesetCollector.collectChangesets(); + return gitHookChangesetCollector; + } + + /** + * Collect all new changesets from the received hook. Afterwards, the results can be + * retrieved with {@link #getAddedChangesets()} and {@link #getRemovedChangesets()} + */ + private void collectChangesets() { + try (GitChangesetConverter converter = converterFactory.create(repository, walk)) { + repository.incrementOpen(); + + for (ReceiveCommand rc : receiveCommands) { + String ref = rc.getRefName(); + + LOG.trace("handle receive command, type={}, ref={}, result={}", rc.getType(), ref, rc.getResult()); + + handle(repository, walk, converter, rc, ref); + } + } catch (Exception ex) { + LOG.error("could not collect changesets", ex); + } + } + + Iterable getAddedChangesets() { + return unmodifiableCollection(addedChangesets.values()); + } + + Iterable getRemovedChangesets() { + return unmodifiableCollection(removedChangesets.values()); + } + + void handle(Repository repository, RevWalk walk, GitChangesetConverter converter, ReceiveCommand rc, String ref) { + try { + if (!(GitUtil.isBranch(ref) || GitUtil.isTag(ref))) { + LOG.debug("skip ref {}, because it is neither branch nor tag", ref); + } else if (rc.getType() == ReceiveCommand.Type.UPDATE_NONFASTFORWARD) { + LOG.debug("handle deleted/added ref {}", ref); + collectRemovedChangeset(repository, walk, converter, rc); + collectAddedChangesets(converter, walk, rc, ref); + } else if (rc.getType() == ReceiveCommand.Type.DELETE) { + LOG.debug("handle deleted ref {}", ref); + collectRemovedChangeset(repository, walk, converter, rc); + } else { + LOG.debug("handle added ref {}", ref); + collectAddedChangesets(converter, walk, rc, ref); + } + } catch (IOException ex) { + String message = "could not handle receive command, type=" + + rc.getType() + ", ref=" + + rc.getRefName() + ", result=" + + rc.getResult(); + + LOG.error(message, ex); + } + } + + private void collectAddedChangesets(GitChangesetConverter converter, + RevWalk walk, + ReceiveCommand rc, + String ref) + throws IOException { + walk.reset(); + ObjectId newId = rc.getNewId(); + + String branch = GitUtil.getBranch(rc.getRefName()); + + walk.sort(RevSort.TOPO); + walk.sort(RevSort.REVERSE, true); + + LOG.trace("mark {} as start for rev walk", newId.getName()); + + walk.markStart(walk.parseCommit(newId)); + + ObjectId oldId = rc.getOldId(); + + if ((oldId != null) && !oldId.equals(ObjectId.zeroId())) { + LOG.trace("mark {} as uninteresting for rev walk", oldId.getName()); + walk.markUninteresting(walk.parseCommit(oldId)); + } + + RevCommit commit = walk.next(); + + while (commit != null) { + String id = commit.getId().name(); + Changeset changeset = addedChangesets.get(id); + + if (changeset != null) { + if (GitUtil.isBranch(ref)) { + LOG.trace( + "commit {} already received during this push, add branch {} to the commit", + commit, branch); + changeset.getBranches().add(branch); + } + } else if (newCommitDetector.isNew(commit)) { + // only append new commits + addToCollection(addedChangesets, converter, walk, commit, id, branch); + } else { + LOG.trace("commit {} was already received", commit.getId()); + } + + commit = walk.next(); + } + } + + private void collectRemovedChangeset(Repository repository, RevWalk walk, GitChangesetConverter converter, ReceiveCommand rc) throws IOException { + walk.reset(); + ObjectId oldId = rc.getOldId(); + + walk.markStart(walk.parseCommit(oldId)); + GitUtil.getAllCommits(repository, walk).forEach(c -> { + try { + walk.markUninteresting(c); + } catch (IOException e) { + throw new IllegalStateException("failed to mark commit as to be ignored", e); + } + }); + + RevCommit commit = walk.next(); + + while (commit != null) { + String id = commit.getId().name(); + Changeset changeset = removedChangesets.get(id); + + if (changeset == null) { + addToCollection(removedChangesets, converter, walk, commit, id); + } + + commit = walk.next(); + } + } + + private void addToCollection(Map changesets, GitChangesetConverter converter, RevWalk walk, RevCommit commit, String id, String... branches) throws IOException { + // parse commit body to avoid npe + walk.parseBody(commit); + + Changeset newChangeset = converter.createChangeset(commit, branches); + + LOG.trace("retrieve commit {} for hook", newChangeset.getId()); + + changesets.put(id, newChangeset); + } + + interface NewCommitDetector { + boolean isNew(RevCommit commit); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookChangesetProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookChangesetProvider.java index 1dbe652371..fc56b66c3c 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookChangesetProvider.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookChangesetProvider.java @@ -24,15 +24,9 @@ package sonia.scm.repository.spi; -//~--- non-JDK imports -------------------------------------------------------- - import org.eclipse.jgit.transport.ReceiveCommand; import org.eclipse.jgit.transport.ReceivePack; - import sonia.scm.repository.GitChangesetConverterFactory; -import sonia.scm.repository.GitHookChangesetCollector; - -//~--- JDK imports ------------------------------------------------------------ import java.util.List; @@ -58,8 +52,8 @@ public class GitHookChangesetProvider implements HookChangesetProvider { @Override public synchronized HookChangesetResponse handleRequest(HookChangesetRequest request) { if (response == null) { - GitHookChangesetCollector collector = new GitHookChangesetCollector(converterFactory, receivePack, receiveCommands); - response = new HookChangesetResponse(collector.collectChangesets()); + GitHookChangesetCollector collector = GitHookChangesetCollector.collectChangesets(converterFactory, receiveCommands, receivePack); + response = new HookChangesetResponse(collector.getAddedChangesets(), collector.getRemovedChangesets()); } return response; } 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 38337906eb..2a4cbd1023 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 @@ -59,7 +59,8 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider { Command.UNBUNDLE, Command.MIRROR, Command.FILE_LOCK, - Command.BRANCH_DETAILS + Command.BRANCH_DETAILS, + Command.CHANGESETS ); protected static final Set FEATURES = EnumSet.of( @@ -192,6 +193,11 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider { return commandInjector.getInstance(GitBranchDetailsCommand.class); } + @Override + public ChangesetsCommand getChangesetsCommand() { + return commandInjector.getInstance(GitChangesetsCommand.class); + } + @Override public Set getSupportedCommands() { return COMMANDS; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java index 8e462e463a..493d3b382a 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitTagCommand.java @@ -35,7 +35,9 @@ import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevObject; import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.ReceiveCommand; import sonia.scm.event.ScmEventBus; +import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.GitUtil; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.PostReceiveRepositoryHookEvent; @@ -53,6 +55,8 @@ import sonia.scm.user.User; import javax.inject.Inject; import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.Set; @@ -60,18 +64,23 @@ import java.util.Set; import static java.util.Collections.emptyList; import static java.util.Collections.singleton; import static java.util.Collections.singletonList; +import static org.eclipse.jgit.lib.ObjectId.fromString; +import static org.eclipse.jgit.lib.ObjectId.zeroId; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; public class GitTagCommand extends AbstractGitCommand implements TagCommand { + public static final String REFS_TAGS_PREFIX = "refs/tags/"; private final HookContextFactory hookContextFactory; private final ScmEventBus eventBus; + private final GitChangesetConverterFactory converterFactory; @Inject - GitTagCommand(GitContext context, HookContextFactory hookContextFactory, ScmEventBus eventBus) { + GitTagCommand(GitContext context, HookContextFactory hookContextFactory, ScmEventBus eventBus, GitChangesetConverterFactory converterFactory) { super(context); this.hookContextFactory = hookContextFactory; this.eventBus = eventBus; + this.converterFactory = converterFactory; } @Override @@ -105,7 +114,7 @@ public class GitTagCommand extends AbstractGitCommand implements TagCommand { Tag tag = new Tag(name, revision, tagTime); - RepositoryHookEvent hookEvent = createTagHookEvent(TagHookContextProvider.createHookEvent(tag)); + RepositoryHookEvent hookEvent = createTagHookEvent(createHookEvent(tag), RepositoryHookType.PRE_RECEIVE); eventBus.post(new PreReceiveRepositoryHookEvent(hookEvent)); User user = SecurityUtils.getSubject().getPrincipals().oneByType(User.class); @@ -140,7 +149,7 @@ public class GitTagCommand extends AbstractGitCommand implements TagCommand { // Deleting a non-existent tag is a valid action and simply succeeds without // anything happening. - if (!tagRef.isPresent()) { + if (tagRef.isEmpty()) { return; } @@ -150,26 +159,37 @@ public class GitTagCommand extends AbstractGitCommand implements TagCommand { tag = new Tag(name, commit.name(), tagTime); } - RepositoryHookEvent hookEvent = createTagHookEvent(TagHookContextProvider.deleteHookEvent(tag)); - eventBus.post(new PreReceiveRepositoryHookEvent(hookEvent)); + eventBus.post(new PreReceiveRepositoryHookEvent( + createTagHookEvent(deleteHookEvent(tag), RepositoryHookType.PRE_RECEIVE) + )); git.tagDelete().setTags(name).call(); - eventBus.post(new PostReceiveRepositoryHookEvent(hookEvent)); + eventBus.post(new PostReceiveRepositoryHookEvent( + createTagHookEvent(deleteHookEvent(tag), RepositoryHookType.POST_RECEIVE) + )); } catch (GitAPIException | IOException e) { throw new InternalRepositoryException(repository, "could not delete tag " + name, e); } } private Optional findTagRef(Git git, String name) throws GitAPIException { - final String tagRef = "refs/tags/" + name; + final String tagRef = REFS_TAGS_PREFIX + name; return git.tagList().call().stream().filter(it -> it.getName().equals(tagRef)).findAny(); } - private RepositoryHookEvent createTagHookEvent(TagHookContextProvider hookEvent) { + private RepositoryHookEvent createTagHookEvent(TagHookContextProvider hookEvent, RepositoryHookType type) { HookContext context = hookContextFactory.createContext(hookEvent, this.context.getRepository()); - return new RepositoryHookEvent(context, this.context.getRepository(), RepositoryHookType.PRE_RECEIVE); + return new RepositoryHookEvent(context, this.context.getRepository(), type); } - private static class TagHookContextProvider extends HookContextProvider { + private TagHookContextProvider createHookEvent(Tag newTag) { + return new TagHookContextProvider(singletonList(newTag), emptyList()); + } + + private TagHookContextProvider deleteHookEvent(Tag deletedTag) { + return new TagHookContextProvider(emptyList(), singletonList(deletedTag)); + } + + private class TagHookContextProvider extends HookContextProvider { private final List newTags; private final List deletedTags; @@ -178,14 +198,6 @@ public class GitTagCommand extends AbstractGitCommand implements TagCommand { this.deletedTags = deletedTags; } - static TagHookContextProvider createHookEvent(Tag newTag) { - return new TagHookContextProvider(singletonList(newTag), emptyList()); - } - - static TagHookContextProvider deleteHookEvent(Tag deletedTag) { - return new TagHookContextProvider(emptyList(), singletonList(deletedTag)); - } - @Override public Set getSupportedFeatures() { return singleton(HookFeature.TAG_PROVIDER); @@ -208,7 +220,30 @@ public class GitTagCommand extends AbstractGitCommand implements TagCommand { @Override public HookChangesetProvider getChangesetProvider() { - return r -> new HookChangesetResponse(emptyList()); + Collection receiveCommands = new ArrayList<>(); + newTags.stream() + .map(tag -> new ReceiveCommand(zeroId(), fromString(tag.getRevision()), REFS_TAGS_PREFIX + tag.getName())) + .forEach(receiveCommands::add); + deletedTags.stream() + .map(tag -> new ReceiveCommand(fromString(tag.getRevision()), zeroId(), REFS_TAGS_PREFIX + tag.getName())) + .forEach(receiveCommands::add); + return x -> { + Repository gitRepo; + try { + gitRepo = context.open(); + } catch (IOException e) { + throw new InternalRepositoryException(repository, "failed to open repository for post receive hook after internal change", e); + } + GitHookChangesetCollector collector = + GitHookChangesetCollector.collectChangesets( + converterFactory, + receiveCommands, + gitRepo, + new RevWalk(gitRepo), + commit -> false // we cannot create new commits with this tag command + ); + return new HookChangesetResponse(collector.getAddedChangesets(), collector.getRemovedChangesets()); + }; } } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java index 1fba957da5..d697b37d27 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBranchCommandTest.java @@ -24,6 +24,8 @@ package sonia.scm.repository.spi; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; @@ -32,9 +34,14 @@ import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.event.ScmEventBus; import sonia.scm.repository.Branch; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.GitChangesetConverter; +import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.PostReceiveRepositoryHookEvent; +import sonia.scm.repository.PreProcessorUtil; import sonia.scm.repository.PreReceiveRepositoryHookEvent; import sonia.scm.repository.api.BranchRequest; +import sonia.scm.repository.api.HookChangesetBuilder; import sonia.scm.repository.api.HookContext; import sonia.scm.repository.api.HookContextFactory; @@ -48,14 +55,30 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; - @RunWith(MockitoJUnitRunner.class) public class GitBranchCommandTest extends AbstractGitCommandTestBase { @Mock + private PreProcessorUtil preProcessorUtil; private HookContextFactory hookContextFactory; @Mock private ScmEventBus eventBus; + @Mock + private GitChangesetConverterFactory converterFactory; + + @Before + public void mockConverterFactory() { + GitChangesetConverter gitChangesetConverter = mock(GitChangesetConverter.class); + when(converterFactory.create(any(), any())) + .thenReturn(gitChangesetConverter); + when(gitChangesetConverter.createChangeset(any(), (String[]) any())) + .thenAnswer(invocation -> { + RevCommit revCommit = invocation.getArgument(0, RevCommit.class); + Changeset changeset = new Changeset(revCommit.name(), null, null); + return changeset; + }); + hookContextFactory = new HookContextFactory(preProcessorUtil); + } @Test public void shouldCreateBranchWithDefinedSourceBranch() throws IOException { @@ -108,7 +131,7 @@ public class GitBranchCommandTest extends AbstractGitCommandTestBase { } private GitBranchCommand createCommand() { - return new GitBranchCommand(createContext(), hookContextFactory, eventBus); + return new GitBranchCommand(createContext(), hookContextFactory, eventBus, converterFactory); } private List readBranches(GitContext context) throws IOException { @@ -119,7 +142,6 @@ public class GitBranchCommandTest extends AbstractGitCommandTestBase { public void shouldPostCreateEvents() { ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); doNothing().when(eventBus).post(captor.capture()); - when(hookContextFactory.createContext(any(), any())).thenAnswer(this::createMockedContext); BranchRequest branchRequest = new BranchRequest(); branchRequest.setParentBranch("mergeable"); @@ -140,7 +162,6 @@ public class GitBranchCommandTest extends AbstractGitCommandTestBase { public void shouldPostDeleteEvents() { ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); doNothing().when(eventBus).post(captor.capture()); - when(hookContextFactory.createContext(any(), any())).thenAnswer(this::createMockedContext); createCommand().deleteOrClose("squash"); @@ -151,11 +172,15 @@ public class GitBranchCommandTest extends AbstractGitCommandTestBase { PreReceiveRepositoryHookEvent event = (PreReceiveRepositoryHookEvent) events.get(0); assertThat(event.getContext().getBranchProvider().getDeletedOrClosed()).containsExactly("squash"); assertThat(event.getContext().getBranchProvider().getCreatedOrModified()).isEmpty(); - } - private HookContext createMockedContext(InvocationOnMock invocation) { - HookContext mock = mock(HookContext.class); - when(mock.getBranchProvider()).thenReturn(((HookContextProvider) invocation.getArgument(0)).getBranchProvider()); - return mock; + HookChangesetBuilder changesetProvider = event.getContext().getChangesetProvider(); + assertThat(changesetProvider.getChangesets()).isEmpty(); + assertThat(changesetProvider.getRemovedChangesets()) + .extracting("id") + .containsExactly( + "35597e9e98fe53167266583848bfef985c2adb27", + "f360a8738e4a29333786c5817f97a2c912814536", + "d1dfecbfd5b4a2f77fe40e1bde29e640f7f944be" + ); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitChangesetsCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitChangesetsCommandTest.java new file mode 100644 index 0000000000..722a6b76e2 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitChangesetsCommandTest.java @@ -0,0 +1,76 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import org.junit.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.GitTestHelper; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +public class GitChangesetsCommandTest extends AbstractGitCommandTestBase { + + @Test + public void shouldFindLatestCommit() { + GitChangesetsCommand command = new GitChangesetsCommand(createContext(), GitTestHelper.createConverterFactory()); + Optional changeset = command.getLatestChangeset(); + assertThat(changeset).get().extracting("id").isEqualTo("a8495c0335a13e6e432df90b3727fa91943189a7"); + } + + @Test + public void shouldListAllRevisions() { + GitChangesetsCommand command = new GitChangesetsCommand(createContext(), GitTestHelper.createConverterFactory()); + Iterable changesets = command.getChangesets(new ChangesetsCommandRequest()); + assertThat(changesets) + .extracting("id") + .hasSize(19) + .contains( + "a8495c0335a13e6e432df90b3727fa91943189a7", + "03ca33468c2094249973d0ca11b80243a20de368", + "9e93d8631675a89615fac56b09209686146ff3c0", + "383b954b27e052db6880d57f1c860dc208795247", + "1fcebf45a215a43f0713a57b807d55e8387a6d70", + "9f28cf5eb3a4df05d284c6f2d276c20c0f0e5b6c", + "674ca9a2208df60224b0f33beeea5259b374d2d0", + "35597e9e98fe53167266583848bfef985c2adb27", + "f360a8738e4a29333786c5817f97a2c912814536", + "d1dfecbfd5b4a2f77fe40e1bde29e640f7f944be", + "d81ad6c63d7e2162308d69637b339dedd1d9201c", + "6a2abf9dca5cff5d76720d1276e1112d9c75ea60", + "2f95f02d9c568594d31e78464bd11a96c62e3f91", + "fcd0ef1831e4002ac43ea539f4094334c79ea9ec", + "86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1", + "91b99de908fcd04772798a31c308a64aea1a5523", + "3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4", + "592d797cd36432e591416e8b2b98154f4f163411", + "435df2f061add3589cb326cc64be9b9c3897ceca" + ); + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitHookChangesetCollectorTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitHookChangesetCollectorTest.java new file mode 100644 index 0000000000..227aedf21d --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitHookChangesetCollectorTest.java @@ -0,0 +1,211 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +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.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.ReceiveCommand; +import org.eclipse.jgit.transport.ReceivePack; +import org.junit.Before; +import org.junit.Test; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.GitChangesetConverter; +import sonia.scm.repository.GitChangesetConverterFactory; +import sonia.scm.web.CollectingPackParserListener; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.util.Arrays.asList; +import static org.eclipse.jgit.lib.ObjectId.fromString; +import static org.eclipse.jgit.lib.ObjectId.zeroId; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class GitHookChangesetCollectorTest extends AbstractGitCommandTestBase { + + private final ReceivePack rpack = mock(ReceivePack.class); + private final Collection receiveCommands = new ArrayList<>(); + private final CollectingPackParserListener listener = mock(CollectingPackParserListener.class); + + private final GitChangesetConverterFactory converterFactory = mock(GitChangesetConverterFactory.class); + private final GitChangesetConverter converter = mock(GitChangesetConverter.class); + + private GitHookChangesetCollector collector; + + @Before + public void init() throws IOException { + + GitContext context = createContext(); + Repository repository = context.open(); + RevWalk revWalk = new RevWalk(repository); + when(rpack.getRepository()).thenReturn(repository); + when(rpack.getRevWalk()).thenReturn(revWalk); + when(rpack.getPackParserListener()).thenReturn(listener); + when(converterFactory.create(repository, revWalk)).thenReturn(converter); + when(converter.createChangeset(any(), (String[]) any())) + .thenAnswer(invocation -> new Changeset(invocation.getArgument(0, RevCommit.class).name(), null, null)); + } + + @Test + public void shouldCreateEmptyCollectionsWithoutChanges() { + collector = GitHookChangesetCollector.collectChangesets(converterFactory, receiveCommands, rpack); + + assertThat(collector.getAddedChangesets()).isEmpty(); + assertThat(collector.getRemovedChangesets()).isEmpty(); + } + + @Test + public void shouldFindAddedChangesetsFromNewBranch() { + receiveCommands.add( + new ReceiveCommand( + zeroId(), + fromString("91b99de908fcd04772798a31c308a64aea1a5523"), + "refs/heads/mergeable") + ); + mockNewCommits( + "91b99de908fcd04772798a31c308a64aea1a5523", + "3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4", + "592d797cd36432e591416e8b2b98154f4f163411"); + + collector = GitHookChangesetCollector.collectChangesets(converterFactory, receiveCommands, rpack); + + assertThat(collector.getAddedChangesets()) + .extracting("id") + .contains( + "91b99de908fcd04772798a31c308a64aea1a5523", + "592d797cd36432e591416e8b2b98154f4f163411"); + assertThat(collector.getRemovedChangesets()).isEmpty(); + } + + @Test + public void shouldFindAddedChangesetsFromNewBranchesOnce() throws IOException, GitAPIException { + new Git(createContext().open()).branchCreate().setStartPoint("mergeable").setName("second").call(); + receiveCommands.add( + new ReceiveCommand( + zeroId(), + fromString("91b99de908fcd04772798a31c308a64aea1a5523"), + "refs/heads/mergeable") + ); + receiveCommands.add( + new ReceiveCommand( + zeroId(), + fromString("91b99de908fcd04772798a31c308a64aea1a5523"), + "refs/heads/second") + ); + mockNewCommits( + "91b99de908fcd04772798a31c308a64aea1a5523", + "3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4", + "592d797cd36432e591416e8b2b98154f4f163411"); + + collector = GitHookChangesetCollector.collectChangesets(converterFactory, receiveCommands, rpack); + + assertThat(collector.getAddedChangesets()) + .extracting("id") + .hasSize(2) + .contains( + "91b99de908fcd04772798a31c308a64aea1a5523", + "592d797cd36432e591416e8b2b98154f4f163411"); + assertThat(collector.getRemovedChangesets()).isEmpty(); + } + + @Test + public void shouldFindAddedChangesetsFromChangedBranchWithoutIteratingOldCommits() { + receiveCommands.add( + new ReceiveCommand( + fromString("592d797cd36432e591416e8b2b98154f4f163411"), + fromString("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"), + "refs/heads/test-branch") + ); + mockNewCommits("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + + collector = GitHookChangesetCollector.collectChangesets(converterFactory, receiveCommands, rpack); + + assertThat(collector.getAddedChangesets()) + .extracting("id") + .contains("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + assertThat(collector.getRemovedChangesets()).isEmpty(); + + verify(listener, never()).isNew(argThat(argument -> argument.name().equals("592d797cd36432e591416e8b2b98154f4f163411"))); + } + + @Test + public void shouldFindRemovedChangesetsFromDeletedBranch() throws IOException, GitAPIException { + new Git(createContext().open()).branchDelete().setBranchNames("test-branch").setForce(true).call(); + receiveCommands.add( + new ReceiveCommand( + fromString("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"), + zeroId(), + "refs/heads/test-branch", + ReceiveCommand.Type.DELETE) + ); + mockNewCommits("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + + collector = GitHookChangesetCollector.collectChangesets(converterFactory, receiveCommands, rpack); + + assertThat(collector.getAddedChangesets()).isEmpty(); + assertThat(collector.getRemovedChangesets()) + .extracting("id") + .contains("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + + verify(listener, never()).isNew(argThat(argument -> argument.name().equals("592d797cd36432e591416e8b2b98154f4f163411"))); + } + + @Test + public void shouldFindRemovedAndAddedChangesetsFromNonFastForwardChanged() throws IOException, GitAPIException { + new Git(createContext().open()).branchDelete().setBranchNames("test-branch").setForce(true).call(); + receiveCommands.add( + new ReceiveCommand( + fromString("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"), + fromString("91b99de908fcd04772798a31c308a64aea1a5523"), + "refs/heads/test-branch", + ReceiveCommand.Type.UPDATE_NONFASTFORWARD) + ); + mockNewCommits("91b99de908fcd04772798a31c308a64aea1a5523"); + + collector = GitHookChangesetCollector.collectChangesets(converterFactory, receiveCommands, rpack); + + assertThat(collector.getAddedChangesets()) + .extracting("id") + .contains("91b99de908fcd04772798a31c308a64aea1a5523"); + assertThat(collector.getRemovedChangesets()) + .extracting("id") + .contains("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); + } + + private void mockNewCommits(String... objectIds) { + when(listener.isNew(any())) + .thenAnswer(invocation -> asList(objectIds).contains(invocation.getArgument(0, RevCommit.class).name())); + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java index 353daa0ebc..f4258f2a58 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitTagCommandTest.java @@ -28,24 +28,33 @@ import org.apache.shiro.SecurityUtils; import org.apache.shiro.mgt.DefaultSecurityManager; import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; -import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.event.ScmEventBus; +import sonia.scm.repository.Changeset; +import sonia.scm.repository.GitChangesetConverter; +import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.GitTestHelper; import sonia.scm.repository.PostReceiveRepositoryHookEvent; +import sonia.scm.repository.PreProcessorUtil; import sonia.scm.repository.PreReceiveRepositoryHookEvent; import sonia.scm.repository.Tag; -import sonia.scm.repository.api.HookContext; +import sonia.scm.repository.api.HookChangesetBuilder; import sonia.scm.repository.api.HookContextFactory; -import sonia.scm.repository.api.TagDeleteRequest; +import sonia.scm.repository.api.HookTagProvider; import sonia.scm.repository.api.TagCreateRequest; +import sonia.scm.repository.api.TagDeleteRequest; import sonia.scm.security.GPG; import sonia.scm.util.MockUtil; @@ -66,12 +75,15 @@ public class GitTagCommandTest extends AbstractGitCommandTestBase { private GPG gpg; @Mock + private PreProcessorUtil preProcessorUtil; private HookContextFactory hookContextFactory; @Mock private ScmEventBus eventBus; - private Subject subject; + @Mock + private GitChangesetConverterFactory converterFactory; + @Before public void setSigner() { @@ -81,10 +93,24 @@ public class GitTagCommandTest extends AbstractGitCommandTestBase { @Before public void bindThreadContext() { SecurityUtils.setSecurityManager(new DefaultSecurityManager()); - subject = MockUtil.createUserSubject(SecurityUtils.getSecurityManager()); + Subject subject = MockUtil.createUserSubject(SecurityUtils.getSecurityManager()); ThreadContext.bind(subject); } + @Before + public void mockConverterFactory() { + GitChangesetConverter gitChangesetConverter = mock(GitChangesetConverter.class); + when(converterFactory.create(any(), any())) + .thenReturn(gitChangesetConverter); + when(gitChangesetConverter.createChangeset(any(), (String[]) any())) + .thenAnswer(invocation -> { + RevCommit revCommit = invocation.getArgument(0, RevCommit.class); + Changeset changeset = new Changeset(revCommit.name(), null, null); + return changeset; + }); + hookContextFactory = new HookContextFactory(preProcessorUtil); + } + @After public void unbindThreadContext() { ThreadContext.unbindSubject(); @@ -105,7 +131,6 @@ public class GitTagCommandTest extends AbstractGitCommandTestBase { public void shouldPostCreateEvent() { ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); doNothing().when(eventBus).post(captor.capture()); - when(hookContextFactory.createContext(any(), any())).thenAnswer(this::createMockedContext); createCommand().create(new TagCreateRequest("592d797cd36432e591416e8b2b98154f4f163411", "newtag")); @@ -131,26 +156,36 @@ public class GitTagCommandTest extends AbstractGitCommandTestBase { } @Test - public void shouldPostDeleteEvent() { + public void shouldPostDeleteEvent() throws IOException, GitAPIException { + Git git = new Git(createContext().open()); + git.tag().setName("to-be-deleted").setObjectId(getCommit("383b954b27e052db6880d57f1c860dc208795247")).call(); + git.branchDelete().setBranchNames("rename").setForce(true).call(); + ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); doNothing().when(eventBus).post(captor.capture()); - when(hookContextFactory.createContext(any(), any())).thenAnswer(this::createMockedContext); - createCommand().delete(new TagDeleteRequest("test-tag")); + createCommand().delete(new TagDeleteRequest("to-be-deleted")); List events = captor.getAllValues(); assertThat(events.get(0)).isInstanceOf(PreReceiveRepositoryHookEvent.class); assertThat(events.get(1)).isInstanceOf(PostReceiveRepositoryHookEvent.class); PreReceiveRepositoryHookEvent event = (PreReceiveRepositoryHookEvent) events.get(0); - assertThat(event.getContext().getTagProvider().getCreatedTags()).isEmpty(); - final Tag deletedTag = event.getContext().getTagProvider().getDeletedTags().get(0); - assertThat(deletedTag.getName()).isEqualTo("test-tag"); - assertThat(deletedTag.getRevision()).isEqualTo("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1"); + HookTagProvider tagProvider = event.getContext().getTagProvider(); + assertThat(tagProvider.getCreatedTags()).isEmpty(); + Tag deletedTag = tagProvider.getDeletedTags().get(0); + assertThat(deletedTag.getName()).isEqualTo("to-be-deleted"); + assertThat(deletedTag.getRevision()).isEqualTo("383b954b27e052db6880d57f1c860dc208795247"); + + HookChangesetBuilder changesetProvider = event.getContext().getChangesetProvider(); + assertThat(changesetProvider.getChangesets()).isEmpty(); + assertThat(changesetProvider.getRemovedChangesets()) + .extracting("id") + .containsExactly("383b954b27e052db6880d57f1c860dc208795247"); } private GitTagCommand createCommand() { - return new GitTagCommand(createContext(), hookContextFactory, eventBus); + return new GitTagCommand(createContext(), hookContextFactory, eventBus, converterFactory); } private List readTags(GitContext context) throws IOException { @@ -162,9 +197,10 @@ public class GitTagCommandTest extends AbstractGitCommandTestBase { return tags.stream().filter(t -> name.equals(t.getName())).findFirst(); } - private HookContext createMockedContext(InvocationOnMock invocation) { - HookContext mock = mock(HookContext.class); - when(mock.getTagProvider()).thenReturn(((HookContextProvider) invocation.getArgument(0)).getTagProvider()); - return mock; + private RevCommit getCommit(String revision) throws IOException { + ObjectId commitId = ObjectId.fromString(revision); + try (RevWalk revWalk = new RevWalk(createContext().open())) { + return revWalk.parseCommit(commitId); + } } }