diff --git a/pom.xml b/pom.xml index 072caf00a4..0ad5a08d75 100644 --- a/pom.xml +++ b/pom.xml @@ -842,7 +842,7 @@ 1.4.0 - v5.4.0.201906121030-r-scm1 + v5.4.0.201906121030-r-scm2 1.9.0-scm3 diff --git a/scm-core/src/main/java/sonia/scm/NoChangesMadeException.java b/scm-core/src/main/java/sonia/scm/NoChangesMadeException.java new file mode 100644 index 0000000000..9bf0363398 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/NoChangesMadeException.java @@ -0,0 +1,18 @@ +package sonia.scm; + +import sonia.scm.repository.Repository; + +public class NoChangesMadeException extends BadRequestException { + public NoChangesMadeException(Repository repository, String branch) { + super(ContextEntry.ContextBuilder.entity(repository).build(), "no changes detected to branch " + branch); + } + + public NoChangesMadeException(Repository repository) { + super(ContextEntry.ContextBuilder.entity(repository).build(), "no changes detected"); + } + + @Override + public String getCode() { + return "40RaYIeeR1"; + } +} diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java index 3d0ea94536..4c8fcd7306 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java @@ -73,6 +73,13 @@ public interface PluginManager { */ List getAvailable(); + /** + * Returns all updatable plugins. + * + * @return a list of updatable plugins. + */ + List getUpdatable(); + /** * Installs the plugin with the given name from the list of available plugins. * @@ -93,4 +100,14 @@ public interface PluginManager { * Install all pending plugins and restart the scm context. */ void executePendingAndRestart(); + + /** + * Cancel all pending plugins. + */ + void cancelPending(); + + /** + * Update all installed plugins. + */ + void updateAll(); } diff --git a/scm-core/src/main/java/sonia/scm/repository/NoCommonHistoryException.java b/scm-core/src/main/java/sonia/scm/repository/NoCommonHistoryException.java new file mode 100644 index 0000000000..ddb7793d20 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/NoCommonHistoryException.java @@ -0,0 +1,22 @@ +package sonia.scm.repository; + +import sonia.scm.BadRequestException; + +import static java.util.Collections.emptyList; + +@SuppressWarnings("squid:MaximumInheritanceDepth") +public class NoCommonHistoryException extends BadRequestException { + + public NoCommonHistoryException() { + this("no common history"); + } + + public NoCommonHistoryException(String message) { + super(emptyList(), message); + } + + @Override + public String getCode() { + return "4iRct4avG1"; + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java index 18d4e11a7f..03f4361083 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/DiffCommandBuilder.java @@ -40,7 +40,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.repository.Feature; import sonia.scm.repository.spi.DiffCommand; -import sonia.scm.util.IOUtil; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -103,16 +102,12 @@ public final class DiffCommandBuilder extends AbstractDiffCommandBuilder implements WorkdirFactory } @Override - public WorkingCopy createWorkingCopy(C context) { + public WorkingCopy createWorkingCopy(C context, String initialBranch) { try { File directory = workdirProvider.createNewWorkdir(); - ParentAndClone parentAndClone = cloneRepository(context, directory); + ParentAndClone parentAndClone = cloneRepository(context, directory, initialBranch); return new WorkingCopy<>(parentAndClone.getClone(), parentAndClone.getParent(), this::close, directory); } catch (IOException e) { throw new InternalRepositoryException(getScmRepository(context), "could not clone repository in temporary directory", e); @@ -35,7 +35,7 @@ public abstract class SimpleWorkdirFactory implements WorkdirFactory // We do allow implementations to throw arbitrary exceptions here, so that we can handle them in close protected abstract void closeRepository(R repository) throws Exception; - protected abstract ParentAndClone cloneRepository(C context, File target) throws IOException; + protected abstract ParentAndClone cloneRepository(C context, File target, String initialBranch) throws IOException; private void close(R repository) { try { diff --git a/scm-core/src/main/java/sonia/scm/repository/util/WorkdirFactory.java b/scm-core/src/main/java/sonia/scm/repository/util/WorkdirFactory.java index 1b67c7f1eb..bddf03adaa 100644 --- a/scm-core/src/main/java/sonia/scm/repository/util/WorkdirFactory.java +++ b/scm-core/src/main/java/sonia/scm/repository/util/WorkdirFactory.java @@ -1,5 +1,5 @@ package sonia.scm.repository.util; public interface WorkdirFactory { - WorkingCopy createWorkingCopy(C context); + WorkingCopy createWorkingCopy(C context, String initialBranch); } diff --git a/scm-core/src/test/java/sonia/scm/repository/api/ModifyCommandBuilderTest.java b/scm-core/src/test/java/sonia/scm/repository/api/ModifyCommandBuilderTest.java index db9a247d65..6e46841cd2 100644 --- a/scm-core/src/test/java/sonia/scm/repository/api/ModifyCommandBuilderTest.java +++ b/scm-core/src/test/java/sonia/scm/repository/api/ModifyCommandBuilderTest.java @@ -1,7 +1,6 @@ package sonia.scm.repository.api; import com.google.common.io.ByteSource; -import com.sun.org.apache.xpath.internal.operations.Bool; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -85,15 +84,6 @@ class ModifyCommandBuilderTest { verify(worker).delete("toBeDeleted"); } - @Test - void shouldExecuteMove() throws IOException { - initCommand() - .moveFile("source", "target") - .execute(); - - verify(worker).move("source", "target"); - } - @Test void shouldExecuteCreateWithByteSourceContent() throws IOException { ArgumentCaptor nameCaptor = ArgumentCaptor.forClass(String.class); diff --git a/scm-core/src/test/java/sonia/scm/repository/util/SimpleWorkdirFactoryTest.java b/scm-core/src/test/java/sonia/scm/repository/util/SimpleWorkdirFactoryTest.java index 31aa11c604..4a1a1a4179 100644 --- a/scm-core/src/test/java/sonia/scm/repository/util/SimpleWorkdirFactoryTest.java +++ b/scm-core/src/test/java/sonia/scm/repository/util/SimpleWorkdirFactoryTest.java @@ -26,6 +26,8 @@ public class SimpleWorkdirFactoryTest { public TemporaryFolder temporaryFolder = new TemporaryFolder(); private SimpleWorkdirFactory simpleWorkdirFactory; + private String initialBranchForLastCloneCall; + @Before public void initFactory() throws IOException { WorkdirProvider workdirProvider = new WorkdirProvider(temporaryFolder.newFolder()); @@ -41,7 +43,8 @@ public class SimpleWorkdirFactoryTest { } @Override - protected ParentAndClone cloneRepository(Context context, File target) { + protected ParentAndClone cloneRepository(Context context, File target, String initialBranch) { + initialBranchForLastCloneCall = initialBranch; return new ParentAndClone<>(parent, clone); } }; @@ -50,7 +53,7 @@ public class SimpleWorkdirFactoryTest { @Test public void shouldCreateParentAndClone() { Context context = new Context(); - try (WorkingCopy workingCopy = simpleWorkdirFactory.createWorkingCopy(context)) { + try (WorkingCopy workingCopy = simpleWorkdirFactory.createWorkingCopy(context, null)) { assertThat(workingCopy.getCentralRepository()).isSameAs(parent); assertThat(workingCopy.getWorkingRepository()).isSameAs(clone); } @@ -59,7 +62,7 @@ public class SimpleWorkdirFactoryTest { @Test public void shouldCloseParent() throws IOException { Context context = new Context(); - try (WorkingCopy workingCopy = simpleWorkdirFactory.createWorkingCopy(context)) {} + try (WorkingCopy workingCopy = simpleWorkdirFactory.createWorkingCopy(context, null)) {} verify(parent).close(); } @@ -67,10 +70,18 @@ public class SimpleWorkdirFactoryTest { @Test public void shouldCloseClone() throws IOException { Context context = new Context(); - try (WorkingCopy workingCopy = simpleWorkdirFactory.createWorkingCopy(context)) {} + try (WorkingCopy workingCopy = simpleWorkdirFactory.createWorkingCopy(context, null)) {} verify(clone).close(); } + @Test + public void shouldPropagateInitialBranch() { + Context context = new Context(); + try (WorkingCopy workingCopy = simpleWorkdirFactory.createWorkingCopy(context, "some")) { + assertThat(initialBranchForLastCloneCall).isEqualTo("some"); + } + } + private static class Context {} } 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 7175d3b646..d726b992ca 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 @@ -42,7 +42,10 @@ import com.google.common.collect.Multimap; import org.eclipse.jgit.api.FetchCommand; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.attributes.Attribute; +import org.eclipse.jgit.attributes.Attributes; import org.eclipse.jgit.diff.DiffFormatter; +import org.eclipse.jgit.lfs.LfsPointer; import org.eclipse.jgit.lib.AnyObjectId; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; @@ -55,6 +58,7 @@ import org.eclipse.jgit.transport.FetchResult; import org.eclipse.jgit.transport.RefSpec; import org.eclipse.jgit.treewalk.TreeWalk; import org.eclipse.jgit.util.FS; +import org.eclipse.jgit.util.LfsFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.ContextEntry; @@ -65,10 +69,12 @@ import sonia.scm.web.GitUserAgentProvider; import javax.servlet.http.HttpServletRequest; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; +import static java.util.Optional.empty; import static java.util.Optional.of; //~--- JDK imports ------------------------------------------------------------ @@ -79,7 +85,7 @@ import static java.util.Optional.of; */ public final class GitUtil { - + private static final GitUserAgentProvider GIT_USER_AGENT_PROVIDER = new GitUserAgentProvider(); /** Field description */ @@ -325,14 +331,14 @@ public final class GitUtil return branch; } - + /** * Returns {@code true} if the provided reference name is a branch name. - * + * * @param refName reference name - * + * * @return {@code true} if the name is a branch name - * + * * @since 1.50 */ public static boolean isBranch(String refName) @@ -611,11 +617,11 @@ public final class GitUtil /** * Returns the name of the tag or {@code null} if the the ref is not a tag. - * + * * @param refName ref name - * + * * @return name of tag or {@link null} - * + * * @since 1.50 */ public static String getTagName(String refName) @@ -688,7 +694,7 @@ public final class GitUtil { //J- return fs.resolve(dir, DIRECTORY_OBJETCS).exists() - && fs.resolve(dir, DIRECTORY_REFS).exists() + && fs.resolve(dir, DIRECTORY_REFS).exists() &&!fs.resolve(dir, DIRECTORY_DOTGIT).exists(); //J+ } @@ -727,7 +733,26 @@ public final class GitUtil mergeBaseWalk.setRevFilter(RevFilter.MERGE_BASE); mergeBaseWalk.markStart(mergeBaseWalk.lookupCommit(revision1)); mergeBaseWalk.markStart(mergeBaseWalk.parseCommit(revision2)); - return mergeBaseWalk.next().getId(); + RevCommit ancestor = mergeBaseWalk.next(); + if (ancestor == null) { + String msg = "revisions %s and %s are not related and therefore do not have a common ancestor"; + throw new NoCommonHistoryException(String.format(msg, revision1.name(), revision2.name())); + } + return ancestor.getId(); + } + } + + public static Optional getLfsPointer(org.eclipse.jgit.lib.Repository repo, String path, RevCommit commit, TreeWalk treeWalk) throws IOException { + Attributes attributes = LfsFactory.getAttributesForPath(repo, path, commit); + + Attribute filter = attributes.get("filter"); + if (filter != null && "lfs".equals(filter.getValue())) { + ObjectId blobId = treeWalk.getObjectId(0); + try (InputStream is = repo.open(blobId, Constants.OBJ_BLOB).openStream()) { + return of(LfsPointer.parseLfsPointer(is)); + } + } else { + return empty(); } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java index bea110766b..bc4d939e91 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java @@ -140,8 +140,8 @@ class AbstractGitCommand } } - > R inClone(Function workerSupplier, GitWorkdirFactory workdirFactory) { - try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context)) { + > R inClone(Function workerSupplier, GitWorkdirFactory workdirFactory, String initialBranch) { + try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context, initialBranch)) { Repository repository = workingCopy.getWorkingRepository(); logger.debug("cloned repository to folder {}", repository.getWorkTree()); return workerSupplier.apply(new Git(repository)).run(); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/Differ.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/Differ.java index ca417550f4..0204ca4e3c 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/Differ.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/Differ.java @@ -55,7 +55,7 @@ final class Differ implements AutoCloseable { if (!Strings.isNullOrEmpty(request.getAncestorChangeset())) { ObjectId otherRevision = repository.resolve(request.getAncestorChangeset()); - ObjectId ancestorId = computeCommonAncestor(repository, revision, otherRevision); + ObjectId ancestorId = GitUtil.computeCommonAncestor(repository, revision, otherRevision); RevTree tree = walk.parseCommit(ancestorId).getTree(); treeWalk.addTree(tree); } @@ -82,10 +82,6 @@ final class Differ implements AutoCloseable { return new Differ(commit, walk, treeWalk); } - private static ObjectId computeCommonAncestor(org.eclipse.jgit.lib.Repository repository, ObjectId revision1, ObjectId revision2) throws IOException { - return GitUtil.computeCommonAncestor(repository, revision1, revision2); - } - private Diff diff() throws IOException { List entries = DiffEntry.scan(treeWalk); return new Diff(commit, entries); @@ -115,4 +111,5 @@ final class Differ implements AutoCloseable { return entries; } } + } 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 9796182b04..e8675068b9 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 @@ -58,11 +58,8 @@ public class GitBranchCommand extends AbstractGitCommand implements BranchComman @Override public Branch branch(BranchRequest request) { - try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context)) { + try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context, request.getParentBranch())) { Git clone = new Git(workingCopy.getWorkingRepository()); - if (request.getParentBranch() != null) { - clone.checkout().setName("origin/" + request.getParentBranch()).call(); - } Ref ref = clone.branchCreate().setName(request.getNewBranch()).call(); Iterable call = clone.push().add(request.getNewBranch()).call(); StreamSupport.stream(call.spliterator(), false) diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java index 2a254d96ce..5ec69cccdd 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java @@ -38,6 +38,7 @@ package sonia.scm.repository.spi; import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.google.common.collect.Maps; +import org.eclipse.jgit.lfs.LfsPointer; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectLoader; @@ -57,13 +58,17 @@ import sonia.scm.repository.GitSubModuleParser; import sonia.scm.repository.GitUtil; import sonia.scm.repository.Repository; import sonia.scm.repository.SubRepository; +import sonia.scm.store.Blob; +import sonia.scm.store.BlobStore; import sonia.scm.util.Util; +import sonia.scm.web.lfs.LfsBlobStoreFactory; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; @@ -86,18 +91,20 @@ public class GitBrowseCommand extends AbstractGitCommand */ private static final Logger logger = LoggerFactory.getLogger(GitBrowseCommand.class); + private final LfsBlobStoreFactory lfsBlobStoreFactory; //~--- constructors --------------------------------------------------------- /** * Constructs ... - * - * @param context + * @param context * @param repository + * @param lfsBlobStoreFactory */ - public GitBrowseCommand(GitContext context, Repository repository) + public GitBrowseCommand(GitContext context, Repository repository, LfsBlobStoreFactory lfsBlobStoreFactory) { super(context, repository); + this.lfsBlobStoreFactory = lfsBlobStoreFactory; } //~--- get methods ---------------------------------------------------------- @@ -167,7 +174,7 @@ public class GitBrowseCommand extends AbstractGitCommand * @throws IOException */ private FileObject createFileObject(org.eclipse.jgit.lib.Repository repo, - BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) + BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) throws IOException { FileObject file = new FileObject(); @@ -195,7 +202,6 @@ public class GitBrowseCommand extends AbstractGitCommand ObjectLoader loader = repo.open(treeWalk.getObjectId(0)); file.setDirectory(loader.getType() == Constants.OBJ_TREE); - file.setLength(loader.getSize()); // don't show message and date for directories to improve performance if (!file.isDirectory() &&!request.isDisableLastCommit()) @@ -203,6 +209,16 @@ public class GitBrowseCommand extends AbstractGitCommand logger.trace("fetch last commit for {} at {}", path, revId.getName()); RevCommit commit = getLatestCommit(repo, revId, path); + Optional lfsPointer = GitUtil.getLfsPointer(repo, path, commit, treeWalk); + + if (lfsPointer.isPresent()) { + BlobStore lfsBlobStore = lfsBlobStoreFactory.getLfsBlobStore(repository); + Blob blob = lfsBlobStore.get(lfsPointer.get().getOid().getName()); + file.setLength(blob.getSize()); + } else { + file.setLength(loader.getSize()); + } + if (commit != null) { file.setLastModified(GitUtil.getCommitTime(commit)); @@ -232,7 +248,7 @@ public class GitBrowseCommand extends AbstractGitCommand * @return */ private RevCommit getLatestCommit(org.eclipse.jgit.lib.Repository repo, - ObjectId revId, String path) + ObjectId revId, String path) { RevCommit result = null; RevWalk walk = null; @@ -339,7 +355,7 @@ public class GitBrowseCommand extends AbstractGitCommand } private FileObject findFirstMatch(org.eclipse.jgit.lib.Repository repo, - BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) throws IOException { + BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) throws IOException { String[] pathElements = request.getPath().split("/"); int currentDepth = 0; int limit = pathElements.length; @@ -364,7 +380,7 @@ public class GitBrowseCommand extends AbstractGitCommand @SuppressWarnings("unchecked") private Map getSubRepositories(org.eclipse.jgit.lib.Repository repo, - ObjectId revision) + ObjectId revision) throws IOException { if (logger.isDebugEnabled()) { @@ -375,7 +391,7 @@ public class GitBrowseCommand extends AbstractGitCommand Map subRepositories; try ( ByteArrayOutputStream baos = new ByteArrayOutputStream() ) { - new GitCatCommand(context, repository).getContent(repo, revision, + new GitCatCommand(context, repository, lfsBlobStoreFactory).getContent(repo, revision, PATH_MODULES, baos); subRepositories = GitSubModuleParser.parse(baos.toString()); } @@ -389,7 +405,7 @@ public class GitBrowseCommand extends AbstractGitCommand } private SubRepository getSubRepository(org.eclipse.jgit.lib.Repository repo, - ObjectId revId, String path) + ObjectId revId, String path) throws IOException { Map subRepositories = subrepositoryCache.get(revId); @@ -410,7 +426,7 @@ public class GitBrowseCommand extends AbstractGitCommand } //~--- fields --------------------------------------------------------------- - + /** sub repository cache */ private final Map> subrepositoryCache = Maps.newHashMap(); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitCatCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitCatCommand.java index 7477e0aee3..35ff4d6ac2 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitCatCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitCatCommand.java @@ -33,6 +33,7 @@ package sonia.scm.repository.spi; import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.lfs.LfsPointer; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectLoader; @@ -45,13 +46,18 @@ import org.eclipse.jgit.treewalk.filter.PathFilter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.repository.GitUtil; +import sonia.scm.store.Blob; +import sonia.scm.store.BlobStore; +import sonia.scm.util.IOUtil; import sonia.scm.util.Util; +import sonia.scm.web.lfs.LfsBlobStoreFactory; import java.io.Closeable; import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.Optional; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; @@ -61,15 +67,18 @@ public class GitCatCommand extends AbstractGitCommand implements CatCommand { private static final Logger logger = LoggerFactory.getLogger(GitCatCommand.class); - public GitCatCommand(GitContext context, sonia.scm.repository.Repository repository) { + private final LfsBlobStoreFactory lfsBlobStoreFactory; + + public GitCatCommand(GitContext context, sonia.scm.repository.Repository repository, LfsBlobStoreFactory lfsBlobStoreFactory) { super(context, repository); + this.lfsBlobStoreFactory = lfsBlobStoreFactory; } @Override public void getCatResult(CatCommandRequest request, OutputStream output) throws IOException { logger.debug("try to read content for {}", request); - try (ClosableObjectLoaderContainer closableObjectLoaderContainer = getLoader(request)) { - closableObjectLoaderContainer.objectLoader.copyTo(output); + try (Loader closableObjectLoaderContainer = getLoader(request)) { + closableObjectLoaderContainer.copyTo(output); } } @@ -80,18 +89,18 @@ public class GitCatCommand extends AbstractGitCommand implements CatCommand { } void getContent(org.eclipse.jgit.lib.Repository repo, ObjectId revId, String path, OutputStream output) throws IOException { - try (ClosableObjectLoaderContainer closableObjectLoaderContainer = getLoader(repo, revId, path)) { - closableObjectLoaderContainer.objectLoader.copyTo(output); + try (Loader closableObjectLoaderContainer = getLoader(repo, revId, path)) { + closableObjectLoaderContainer.copyTo(output); } } - private ClosableObjectLoaderContainer getLoader(CatCommandRequest request) throws IOException { + private Loader getLoader(CatCommandRequest request) throws IOException { org.eclipse.jgit.lib.Repository repo = open(); ObjectId revId = getCommitOrDefault(repo, request.getRevision()); return getLoader(repo, revId, request.getPath()); } - private ClosableObjectLoaderContainer getLoader(Repository repo, ObjectId revId, String path) throws IOException { + private Loader getLoader(Repository repo, ObjectId revId, String path) throws IOException { TreeWalk treeWalk = new TreeWalk(repo); treeWalk.setRecursive(Util.nonNull(path).contains("/")); @@ -116,21 +125,67 @@ public class GitCatCommand extends AbstractGitCommand implements CatCommand { treeWalk.setFilter(PathFilter.create(path)); if (treeWalk.next() && treeWalk.getFileMode(0).getObjectType() == Constants.OBJ_BLOB) { - ObjectId blobId = treeWalk.getObjectId(0); - ObjectLoader loader = repo.open(blobId); - - return new ClosableObjectLoaderContainer(loader, treeWalk, revWalk); + Optional lfsPointer = GitUtil.getLfsPointer(repo, path, entry, treeWalk); + if (lfsPointer.isPresent()) { + return loadFromLfsStore(treeWalk, revWalk, lfsPointer.get()); + } else { + return loadFromGit(repo, treeWalk, revWalk); + } } else { throw notFound(entity("Path", path).in("Revision", revId.getName()).in(repository)); } } - private static class ClosableObjectLoaderContainer implements Closeable { + private Loader loadFromGit(Repository repo, TreeWalk treeWalk, RevWalk revWalk) throws IOException { + ObjectId blobId = treeWalk.getObjectId(0); + ObjectLoader loader = repo.open(blobId); + + return new GitObjectLoaderWrapper(loader, treeWalk, revWalk); + } + + private Loader loadFromLfsStore(TreeWalk treeWalk, RevWalk revWalk, LfsPointer lfsPointer) throws IOException { + BlobStore lfsBlobStore = lfsBlobStoreFactory.getLfsBlobStore(repository); + Blob blob = lfsBlobStore.get(lfsPointer.getOid().getName()); + GitUtil.release(revWalk); + GitUtil.release(treeWalk); + return new BlobLoader(blob); + } + + private interface Loader extends Closeable { + void copyTo(OutputStream output) throws IOException; + + InputStream openStream() throws IOException; + } + + private static class BlobLoader implements Loader { + private final InputStream inputStream; + + private BlobLoader(Blob blob) throws IOException { + this.inputStream = blob.getInputStream(); + } + + @Override + public void copyTo(OutputStream output) throws IOException { + IOUtil.copy(inputStream, output); + } + + @Override + public InputStream openStream() { + return inputStream; + } + + @Override + public void close() throws IOException { + this.inputStream.close(); + } + } + + private static class GitObjectLoaderWrapper implements Loader { private final ObjectLoader objectLoader; private final TreeWalk treeWalk; private final RevWalk revWalk; - private ClosableObjectLoaderContainer(ObjectLoader objectLoader, TreeWalk treeWalk, RevWalk revWalk) { + private GitObjectLoaderWrapper(ObjectLoader objectLoader, TreeWalk treeWalk, RevWalk revWalk) { this.objectLoader = objectLoader; this.treeWalk = treeWalk; this.revWalk = revWalk; @@ -141,14 +196,22 @@ public class GitCatCommand extends AbstractGitCommand implements CatCommand { GitUtil.release(revWalk); GitUtil.release(treeWalk); } + + public void copyTo(OutputStream output) throws IOException { + this.objectLoader.copyTo(output); + } + + public InputStream openStream() throws IOException { + return objectLoader.openStream(); + } } private static class InputStreamWrapper extends FilterInputStream { - private final ClosableObjectLoaderContainer container; + private final Loader container; - private InputStreamWrapper(ClosableObjectLoaderContainer container) throws IOException { - super(container.objectLoader.openStream()); + private InputStreamWrapper(Loader container) throws IOException { + super(container.openStream()); this.container = container; } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java index 5d5f27806b..1ac64c1b5e 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java @@ -31,15 +31,12 @@ package sonia.scm.repository.spi; -//~--- non-JDK imports -------------------------------------------------------- - import org.eclipse.jgit.diff.DiffEntry; import org.eclipse.jgit.diff.DiffFormatter; import sonia.scm.repository.Repository; +import sonia.scm.repository.api.DiffCommandBuilder; -import java.io.BufferedOutputStream; import java.io.IOException; -import java.io.OutputStream; /** * @@ -52,22 +49,25 @@ public class GitDiffCommand extends AbstractGitCommand implements DiffCommand { } @Override - public void getDiffResult(DiffCommandRequest request, OutputStream output) throws IOException { + public DiffCommandBuilder.OutputStreamConsumer getDiffResult(DiffCommandRequest request) throws IOException { @SuppressWarnings("squid:S2095") // repository will be closed with the RepositoryService org.eclipse.jgit.lib.Repository repository = open(); - try (DiffFormatter formatter = new DiffFormatter(new BufferedOutputStream(output))) { - formatter.setRepository(repository); - Differ.Diff diff = Differ.diff(repository, request); + Differ.Diff diff = Differ.diff(repository, request); - for (DiffEntry e : diff.getEntries()) { - if (!e.getOldId().equals(e.getNewId())) { - formatter.format(e); + return output -> { + try (DiffFormatter formatter = new DiffFormatter(output)) { + formatter.setRepository(repository); + + for (DiffEntry e : diff.getEntries()) { + if (!e.getOldId().equals(e.getNewId())) { + formatter.format(e); + } } - } - formatter.flush(); - } + formatter.flush(); + } + }; } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLfsFilterContextListener.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLfsFilterContextListener.java new file mode 100644 index 0000000000..101c242d65 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLfsFilterContextListener.java @@ -0,0 +1,77 @@ +package sonia.scm.repository.spi; + +import com.google.common.io.ByteStreams; +import org.eclipse.jgit.attributes.FilterCommand; +import org.eclipse.jgit.attributes.FilterCommandRegistry; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.util.FS; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.SCMContextProvider; +import sonia.scm.plugin.Extension; + +import javax.inject.Inject; +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Pattern; + +import static java.nio.file.StandardOpenOption.CREATE; +import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; + +@Extension +public class GitLfsFilterContextListener implements ServletContextListener { + + public static final String GITCONFIG = "[filter \"lfs\"]\n" + + "clean = git-lfs clean -- %f\n" + + "smudge = git-lfs smudge -- %f\n" + + "process = git-lfs filter-process\n" + + "required = true\n"; + public static final Pattern COMMAND_NAME_PATTERN = Pattern.compile("git-lfs (smudge|clean) -- .*"); + + private static final Logger LOG = LoggerFactory.getLogger(GitLfsFilterContextListener.class); + + private final SCMContextProvider contextProvider; + + @Inject + public GitLfsFilterContextListener(SCMContextProvider contextProvider) { + this.contextProvider = contextProvider; + } + + @Override + public void contextInitialized(ServletContextEvent sce) { + Path gitconfig = contextProvider.getBaseDirectory().toPath().resolve("gitconfig"); + try { + Files.write(gitconfig, GITCONFIG.getBytes(Charset.defaultCharset()), TRUNCATE_EXISTING, CREATE); + FS.DETECTED.setGitSystemConfig(gitconfig.toFile()); + LOG.info("wrote git config file: {}", gitconfig); + } catch (IOException e) { + LOG.error("could not write git config in path {}; git lfs support may not work correctly", gitconfig, e); + } + FilterCommandRegistry.register(COMMAND_NAME_PATTERN, NoOpFilterCommand::new); + } + + @Override + public void contextDestroyed(ServletContextEvent sce) { + FilterCommandRegistry.unregister(COMMAND_NAME_PATTERN); + } + + private static class NoOpFilterCommand extends FilterCommand { + NoOpFilterCommand(Repository db, InputStream in, OutputStream out) { + super(in, out); + } + + @Override + public int run() throws IOException { + ByteStreams.copy(in, out); + in.close(); + out.close(); + return -1; + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java index b4a7954b02..5643c858b5 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java @@ -38,7 +38,7 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand @Override public MergeCommandResult merge(MergeCommandRequest request) { - return inClone(clone -> new MergeWorker(clone, request), workdirFactory); + return inClone(clone -> new MergeWorker(clone, request), workdirFactory, request.getTargetBranch()); } @Override @@ -72,7 +72,6 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand @Override MergeCommandResult run() throws IOException { - checkOutTargetBranch(); MergeResult result = doMergeInClone(); if (result.getMergeStatus().isSuccessful()) { doCommit(); @@ -83,10 +82,6 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand } } - private void checkOutTargetBranch() throws IOException { - checkOutBranch(target); - } - private MergeResult doMergeInClone() throws IOException { MergeResult result; try { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java index 0f7c9296a7..a68be0a4da 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java @@ -1,47 +1,46 @@ package sonia.scm.repository.spi; +import com.google.common.util.concurrent.Striped; import org.apache.commons.lang.StringUtils; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.lib.Constants; -import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.attributes.FilterCommandRegistry; import org.eclipse.jgit.revwalk.RevCommit; -import sonia.scm.BadRequestException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import sonia.scm.ConcurrentModificationException; -import sonia.scm.ContextEntry; +import sonia.scm.NoChangesMadeException; import sonia.scm.repository.GitWorkdirFactory; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Repository; +import sonia.scm.web.lfs.LfsBlobStoreFactory; import java.io.File; import java.io.IOException; -import java.nio.file.FileAlreadyExistsException; -import java.nio.file.Files; -import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.util.Optional; - -import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; -import static sonia.scm.AlreadyExistsException.alreadyExists; -import static sonia.scm.ContextEntry.ContextBuilder.entity; -import static sonia.scm.NotFoundException.notFound; -import static sonia.scm.ScmConstraintViolationException.Builder.doThrow; +import java.util.concurrent.locks.Lock; public class GitModifyCommand extends AbstractGitCommand implements ModifyCommand { - private final GitWorkdirFactory workdirFactory; + private static final Logger LOG = LoggerFactory.getLogger(GitModifyCommand.class); + private static final Striped REGISTER_LOCKS = Striped.lock(5); - GitModifyCommand(GitContext context, Repository repository, GitWorkdirFactory workdirFactory) { + private final GitWorkdirFactory workdirFactory; + private final LfsBlobStoreFactory lfsBlobStoreFactory; + + GitModifyCommand(GitContext context, Repository repository, GitWorkdirFactory workdirFactory, LfsBlobStoreFactory lfsBlobStoreFactory) { super(context, repository); this.workdirFactory = workdirFactory; + this.lfsBlobStoreFactory = lfsBlobStoreFactory; } @Override public String execute(ModifyCommandRequest request) { - return inClone(clone -> new ModifyWorker(clone, request), workdirFactory); + return inClone(clone -> new ModifyWorker(clone, request), workdirFactory, request.getBranch()); } - private class ModifyWorker extends GitCloneWorker implements Worker { + private class ModifyWorker extends GitCloneWorker implements ModifyWorkerHelper { private final File workDir; private final ModifyCommandRequest request; @@ -54,58 +53,43 @@ public class GitModifyCommand extends AbstractGitCommand implements ModifyComman @Override String run() throws IOException { - if (!StringUtils.isEmpty(request.getBranch())) { - checkOutBranch(request.getBranch()); - } - Ref head = getClone().getRepository().exactRef(Constants.HEAD); - doThrow().violation("branch has to be a valid branch, no revision", "branch", request.getBranch()).when(head == null || !head.isSymbolic()); getClone().getRepository().getFullBranch(); - if (!StringUtils.isEmpty(request.getExpectedRevision())) { - if (!request.getExpectedRevision().equals(getCurrentRevision().getName())) { - throw new ConcurrentModificationException("branch", request.getBranch() == null? "default": request.getBranch()); - } + if (!StringUtils.isEmpty(request.getExpectedRevision()) + && !request.getExpectedRevision().equals(getCurrentRevision().getName())) { + throw new ConcurrentModificationException("branch", request.getBranch() == null ? "default" : request.getBranch()); } for (ModifyCommandRequest.PartialRequest r : request.getRequests()) { r.execute(this); } - failIfNotChanged(NoChangesMadeException::new); + failIfNotChanged(() -> new NoChangesMadeException(repository, ModifyWorker.this.request.getBranch())); Optional revCommit = doCommit(request.getCommitMessage(), request.getAuthor()); push(); - return revCommit.orElseThrow(NoChangesMadeException::new).name(); + return revCommit.orElseThrow(() -> new NoChangesMadeException(repository, ModifyWorker.this.request.getBranch())).name(); } @Override - public void create(String toBeCreated, File file, boolean overwrite) throws IOException { - Path targetFile = new File(workDir, toBeCreated).toPath(); - createDirectories(targetFile); - if (overwrite) { - Files.move(file.toPath(), targetFile, REPLACE_EXISTING); - } else { + public void addFileToScm(String name, Path file) { + addToGitWithLfsSupport(name, file); + } + + private void addToGitWithLfsSupport(String path, Path targetFile) { + REGISTER_LOCKS.get(targetFile).lock(); + try { + LfsBlobStoreCleanFilterFactory cleanFilterFactory = new LfsBlobStoreCleanFilterFactory(lfsBlobStoreFactory, repository, targetFile); + + String registerKey = "git-lfs clean -- '" + path + "'"; + LOG.debug("register lfs filter command factory for command '{}'", registerKey); + FilterCommandRegistry.register(registerKey, cleanFilterFactory::createFilter); try { - Files.move(file.toPath(), targetFile); - } catch (FileAlreadyExistsException e) { - throw alreadyExists(createFileContext(toBeCreated)); + addFileToGit(path); + } catch (GitAPIException e) { + throwInternalRepositoryException("could not add file to index", e); + } finally { + LOG.debug("unregister lfs filter command factory for command \"{}\"", registerKey); + FilterCommandRegistry.unregister(registerKey); } - } - try { - addFileToGit(toBeCreated); - } catch (GitAPIException e) { - throwInternalRepositoryException("could not add new file to index", e); - } - } - - @Override - public void modify(String path, File file) throws IOException { - Path targetFile = new File(workDir, path).toPath(); - createDirectories(targetFile); - if (!targetFile.toFile().exists()) { - throw notFound(createFileContext(path)); - } - Files.move(file.toPath(), targetFile, REPLACE_EXISTING); - try { - addFileToGit(path); - } catch (GitAPIException e) { - throwInternalRepositoryException("could not add new file to index", e); + } finally { + REGISTER_LOCKS.get(targetFile).unlock(); } } @@ -114,13 +98,7 @@ public class GitModifyCommand extends AbstractGitCommand implements ModifyComman } @Override - public void delete(String toBeDeleted) throws IOException { - Path fileToBeDeleted = new File(workDir, toBeDeleted).toPath(); - try { - Files.delete(fileToBeDeleted); - } catch (NoSuchFileException e) { - throw notFound(createFileContext(toBeDeleted)); - } + public void doScmDelete(String toBeDeleted) { try { getClone().rm().addFilepattern(removeStartingPathSeparators(toBeDeleted)).call(); } catch (GitAPIException e) { @@ -128,6 +106,21 @@ public class GitModifyCommand extends AbstractGitCommand implements ModifyComman } } + @Override + public File getWorkDir() { + return workDir; + } + + @Override + public Repository getRepository() { + return repository; + } + + @Override + public String getBranch() { + return request.getBranch(); + } + private String removeStartingPathSeparators(String path) { while (path.startsWith(File.separator)) { path = path.substring(1); @@ -135,41 +128,8 @@ public class GitModifyCommand extends AbstractGitCommand implements ModifyComman return path; } - private void createDirectories(Path targetFile) throws IOException { - try { - Files.createDirectories(targetFile.getParent()); - } catch (FileAlreadyExistsException e) { - throw alreadyExists(createFileContext(targetFile.toString())); - } + private String throwInternalRepositoryException(String message, Exception e) { + throw new InternalRepositoryException(context.getRepository(), message, e); } - - private ContextEntry.ContextBuilder createFileContext(String path) { - ContextEntry.ContextBuilder contextBuilder = entity("file", path); - if (!StringUtils.isEmpty(request.getBranch())) { - contextBuilder.in("branch", request.getBranch()); - } - contextBuilder.in(context.getRepository()); - return contextBuilder; - } - - @Override - public void move(String sourcePath, String targetPath) { - - } - - private class NoChangesMadeException extends BadRequestException { - public NoChangesMadeException() { - super(ContextEntry.ContextBuilder.entity(context.getRepository()).build(), "no changes detected to branch " + ModifyWorker.this.request.getBranch()); - } - - @Override - public String getCode() { - return "40RaYIeeR1"; - } - } - } - - private String throwInternalRepositoryException(String message, Exception e) { - throw new InternalRepositoryException(context.getRepository(), message, e); } } 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 24dcff01d8..dc43b8d9b7 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 @@ -39,6 +39,7 @@ import sonia.scm.repository.Feature; import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.Repository; import sonia.scm.repository.api.Command; +import sonia.scm.web.lfs.LfsBlobStoreFactory; import java.io.IOException; import java.util.EnumSet; @@ -76,9 +77,10 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider //~--- constructors --------------------------------------------------------- - public GitRepositoryServiceProvider(GitRepositoryHandler handler, Repository repository, GitRepositoryConfigStoreProvider storeProvider) { + public GitRepositoryServiceProvider(GitRepositoryHandler handler, Repository repository, GitRepositoryConfigStoreProvider storeProvider, LfsBlobStoreFactory lfsBlobStoreFactory) { this.handler = handler; this.repository = repository; + this.lfsBlobStoreFactory = lfsBlobStoreFactory; this.context = new GitContext(handler.getDirectory(repository.getId()), repository, storeProvider); } @@ -143,7 +145,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider @Override public BrowseCommand getBrowseCommand() { - return new GitBrowseCommand(context, repository); + return new GitBrowseCommand(context, repository, lfsBlobStoreFactory); } /** @@ -155,7 +157,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider @Override public CatCommand getCatCommand() { - return new GitCatCommand(context, repository); + return new GitCatCommand(context, repository, lfsBlobStoreFactory); } /** @@ -271,7 +273,7 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider @Override public ModifyCommand getModifyCommand() { - return new GitModifyCommand(context, repository, handler.getWorkdirFactory()); + return new GitModifyCommand(context, repository, handler.getWorkdirFactory(), lfsBlobStoreFactory); } @Override @@ -281,11 +283,13 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider //~--- fields --------------------------------------------------------------- /** Field description */ - private GitContext context; + private final GitContext context; /** Field description */ - private GitRepositoryHandler handler; + private final GitRepositoryHandler handler; /** Field description */ - private Repository repository; + private final Repository repository; + + private final LfsBlobStoreFactory lfsBlobStoreFactory; } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java index 0730ffc9cf..547c6b25f8 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java @@ -39,6 +39,7 @@ import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; import sonia.scm.plugin.Extension; import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.Repository; +import sonia.scm.web.lfs.LfsBlobStoreFactory; /** * @@ -49,11 +50,13 @@ public class GitRepositoryServiceResolver implements RepositoryServiceResolver { private final GitRepositoryHandler handler; private final GitRepositoryConfigStoreProvider storeProvider; + private final LfsBlobStoreFactory lfsBlobStoreFactory; @Inject - public GitRepositoryServiceResolver(GitRepositoryHandler handler, GitRepositoryConfigStoreProvider storeProvider) { + public GitRepositoryServiceResolver(GitRepositoryHandler handler, GitRepositoryConfigStoreProvider storeProvider, LfsBlobStoreFactory lfsBlobStoreFactory) { this.handler = handler; this.storeProvider = storeProvider; + this.lfsBlobStoreFactory = lfsBlobStoreFactory; } @Override @@ -61,7 +64,7 @@ public class GitRepositoryServiceResolver implements RepositoryServiceResolver { GitRepositoryServiceProvider provider = null; if (GitRepositoryHandler.TYPE_NAME.equalsIgnoreCase(repository.getType())) { - provider = new GitRepositoryServiceProvider(handler, repository, storeProvider); + provider = new GitRepositoryServiceProvider(handler, repository, storeProvider, lfsBlobStoreFactory); } return provider; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsBlobStoreCleanFilter.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsBlobStoreCleanFilter.java new file mode 100644 index 0000000000..bb54ea162a --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsBlobStoreCleanFilter.java @@ -0,0 +1,83 @@ +package sonia.scm.repository.spi; + +import com.google.common.io.ByteStreams; +import org.eclipse.jgit.attributes.FilterCommand; +import org.eclipse.jgit.lfs.LfsPointer; +import org.eclipse.jgit.lfs.lib.AnyLongObjectId; +import org.eclipse.jgit.lfs.lib.Constants; +import org.eclipse.jgit.lfs.lib.LongObjectId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.store.Blob; +import sonia.scm.store.BlobStore; +import sonia.scm.util.IOUtil; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestOutputStream; + +/** + * Adapted version of JGit's {@link org.eclipse.jgit.lfs.CleanFilter} to write the + * lfs file directly to the lfs blob store. + */ +class LfsBlobStoreCleanFilter extends FilterCommand { + + private static final Logger LOG = LoggerFactory.getLogger(LfsBlobStoreCleanFilter.class); + + private final BlobStore lfsBlobStore; + private final Path targetFile; + + LfsBlobStoreCleanFilter(InputStream in, OutputStream out, BlobStore lfsBlobStore, Path targetFile) { + super(in, out); + this.lfsBlobStore = lfsBlobStore; + this.targetFile = targetFile; + } + + @Override + // Suppress warning for RuntimeException after check for wrong size, because mathematicians say this will never happen + @SuppressWarnings("squid:S00112") + public int run() throws IOException { + LOG.debug("running scm lfs filter for file {}", targetFile); + DigestOutputStream digestOutputStream = createDigestStream(); + try { + long size = ByteStreams.copy(in, digestOutputStream); + AnyLongObjectId loid = LongObjectId.fromRaw(digestOutputStream.getMessageDigest().digest()); + String hash = loid.getName(); + + Blob existingBlob = lfsBlobStore.get(hash); + if (existingBlob != null) { + LOG.debug("found existing lfs blob for oid {}", hash); + long blobSize = existingBlob.getSize(); + if (blobSize != size) { + throw new RuntimeException("lfs entry already exists for loid " + hash + " but has wrong size"); + } + } else { + LOG.debug("uploading new lfs blob for oid {}", hash); + Blob newBlob = lfsBlobStore.create(hash); + OutputStream outputStream = newBlob.getOutputStream(); + Files.copy(targetFile, outputStream); + newBlob.commit(); + } + + LfsPointer lfsPointer = new LfsPointer(loid, size); + lfsPointer.encode(out); + return -1; + } finally { + IOUtil.close(digestOutputStream); + IOUtil.close(in); + IOUtil.close(out); + } + } + + private DigestOutputStream createDigestStream() { + return new DigestOutputStream(new OutputStream() { + @Override + public void write(int b) { + // no further target here, we are just interested in the digest + } + }, Constants.newMessageDigest()); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsBlobStoreCleanFilterFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsBlobStoreCleanFilterFactory.java new file mode 100644 index 0000000000..d6de8e83df --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/LfsBlobStoreCleanFilterFactory.java @@ -0,0 +1,27 @@ +package sonia.scm.repository.spi; + +import org.eclipse.jgit.lib.Repository; +import sonia.scm.web.lfs.LfsBlobStoreFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Path; + +class LfsBlobStoreCleanFilterFactory { + + private final LfsBlobStoreFactory blobStoreFactory; + private final sonia.scm.repository.Repository repository; + private final Path targetFile; + + LfsBlobStoreCleanFilterFactory(LfsBlobStoreFactory blobStoreFactory, sonia.scm.repository.Repository repository, Path targetFile) { + this.blobStoreFactory = blobStoreFactory; + this.repository = repository; + this.targetFile = targetFile; + } + + @SuppressWarnings("squid:S1172") // suppress unused parameter to keep the api compatible to jgit's FilterCommandFactory + LfsBlobStoreCleanFilter createFilter(Repository db, InputStream in, OutputStream out) { + return new LfsBlobStoreCleanFilter(in, out, blobStoreFactory.getLfsBlobStore(repository), targetFile); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java index 9edcf0c0ea..a0fda7cd3b 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/SimpleGitWorkdirFactory.java @@ -2,6 +2,8 @@ package sonia.scm.repository.spi; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.transport.ScmTransportProtocol; import sonia.scm.repository.GitWorkdirFactory; @@ -11,6 +13,10 @@ import sonia.scm.repository.util.WorkdirProvider; import javax.inject.Inject; import java.io.File; +import java.io.IOException; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static sonia.scm.NotFoundException.notFound; public class SimpleGitWorkdirFactory extends SimpleWorkdirFactory implements GitWorkdirFactory { @@ -20,14 +26,23 @@ public class SimpleGitWorkdirFactory extends SimpleWorkdirFactory cloneRepository(GitContext context, File target) { + public ParentAndClone cloneRepository(GitContext context, File target, String initialBranch) { try { - return new ParentAndClone<>(null, Git.cloneRepository() + Repository clone = Git.cloneRepository() .setURI(createScmTransportProtocolUri(context.getDirectory())) .setDirectory(target) + .setBranch(initialBranch) .call() - .getRepository()); - } catch (GitAPIException e) { + .getRepository(); + + Ref head = clone.exactRef(Constants.HEAD); + + if (head == null || !head.isSymbolic() || (initialBranch != null && !head.getTarget().getName().endsWith(initialBranch))) { + throw notFound(entity("Branch", initialBranch).in(context.getRepository())); + } + + return new ParentAndClone<>(null, clone); + } catch (GitAPIException | IOException e) { throw new InternalRepositoryException(context.getRepository(), "could not clone working copy of repository", e); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java index 1feceba652..4b854f6209 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java @@ -171,6 +171,6 @@ public class GitBrowseCommandTest extends AbstractGitCommandTestBase { } private GitBrowseCommand createCommand() { - return new GitBrowseCommand(createContext(), repository); + return new GitBrowseCommand(createContext(), repository, null); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitCatCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitCatCommandTest.java index 0418bc3e61..eea8bc0017 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitCatCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitCatCommandTest.java @@ -39,12 +39,18 @@ import org.junit.Test; import org.junit.rules.ExpectedException; import sonia.scm.NotFoundException; import sonia.scm.repository.GitRepositoryConfig; +import sonia.scm.store.Blob; +import sonia.scm.store.BlobStore; +import sonia.scm.web.lfs.LfsBlobStoreFactory; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * Unit tests for {@link GitCatCommand}. @@ -136,7 +142,7 @@ public class GitCatCommandTest extends AbstractGitCommandTestBase { CatCommandRequest request = new CatCommandRequest(); request.setPath("b.txt"); - InputStream catResultStream = new GitCatCommand(createContext(), repository).getCatResultStream(request); + InputStream catResultStream = new GitCatCommand(createContext(), repository, null).getCatResultStream(request); assertEquals('b', catResultStream.read()); assertEquals('\n', catResultStream.read()); @@ -145,13 +151,38 @@ public class GitCatCommandTest extends AbstractGitCommandTestBase { catResultStream.close(); } + @Test + public void testLfsStream() throws IOException { + LfsBlobStoreFactory lfsBlobStoreFactory = mock(LfsBlobStoreFactory.class); + BlobStore blobStore = mock(BlobStore.class); + Blob blob = mock(Blob.class); + when(lfsBlobStoreFactory.getLfsBlobStore(repository)).thenReturn(blobStore); + when(blobStore.get("d2252bd9fde1bb2ae7531b432c48262c3cbe4df4376008986980de40a7c9cf8b")) + .thenReturn(blob); + when(blob.getInputStream()).thenReturn(new ByteArrayInputStream(new byte[]{'i', 's'})); + + CatCommandRequest request = new CatCommandRequest(); + request.setRevision("lfs-test"); + request.setPath("lfs-image.png"); + + InputStream catResultStream = new GitCatCommand(createContext(), repository, lfsBlobStoreFactory) + .getCatResultStream(request); + + assertEquals('i', catResultStream.read()); + assertEquals('s', catResultStream.read()); + + assertEquals(-1, catResultStream.read()); + + catResultStream.close(); + } + private String execute(CatCommandRequest request) throws IOException { String content = null; ByteArrayOutputStream baos = new ByteArrayOutputStream(); try { - new GitCatCommand(createContext(), repository).getCatResult(request, + new GitCatCommand(createContext(), repository, null).getCatResult(request, baos); } finally diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffCommandTest.java index fd9c45be5c..52932e83ae 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffCommandTest.java @@ -44,7 +44,7 @@ public class GitDiffCommandTest extends AbstractGitCommandTestBase { DiffCommandRequest diffCommandRequest = new DiffCommandRequest(); diffCommandRequest.setRevision("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); ByteArrayOutputStream output = new ByteArrayOutputStream(); - gitDiffCommand.getDiffResult(diffCommandRequest, output); + gitDiffCommand.getDiffResult(diffCommandRequest).accept(output); assertEquals(DIFF_FILE_A + DIFF_FILE_B, output.toString()); } @@ -54,7 +54,7 @@ public class GitDiffCommandTest extends AbstractGitCommandTestBase { DiffCommandRequest diffCommandRequest = new DiffCommandRequest(); diffCommandRequest.setRevision("test-branch"); ByteArrayOutputStream output = new ByteArrayOutputStream(); - gitDiffCommand.getDiffResult(diffCommandRequest, output); + gitDiffCommand.getDiffResult(diffCommandRequest).accept(output); assertEquals(DIFF_FILE_A + DIFF_FILE_B, output.toString()); } @@ -65,7 +65,7 @@ public class GitDiffCommandTest extends AbstractGitCommandTestBase { diffCommandRequest.setRevision("test-branch"); diffCommandRequest.setPath("a.txt"); ByteArrayOutputStream output = new ByteArrayOutputStream(); - gitDiffCommand.getDiffResult(diffCommandRequest, output); + gitDiffCommand.getDiffResult(diffCommandRequest).accept(output); assertEquals(DIFF_FILE_A, output.toString()); } @@ -76,7 +76,7 @@ public class GitDiffCommandTest extends AbstractGitCommandTestBase { diffCommandRequest.setRevision("master"); diffCommandRequest.setAncestorChangeset("test-branch"); ByteArrayOutputStream output = new ByteArrayOutputStream(); - gitDiffCommand.getDiffResult(diffCommandRequest, output); + gitDiffCommand.getDiffResult(diffCommandRequest).accept(output); assertEquals(DIFF_FILE_A_MULTIPLE_REVISIONS + DIFF_FILE_F_MULTIPLE_REVISIONS, output.toString()); } @@ -88,7 +88,7 @@ public class GitDiffCommandTest extends AbstractGitCommandTestBase { diffCommandRequest.setAncestorChangeset("test-branch"); diffCommandRequest.setPath("a.txt"); ByteArrayOutputStream output = new ByteArrayOutputStream(); - gitDiffCommand.getDiffResult(diffCommandRequest, output); + gitDiffCommand.getDiffResult(diffCommandRequest).accept(output); assertEquals(DIFF_FILE_A_MULTIPLE_REVISIONS, output.toString()); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java index 4fbf7eaf02..fd007d0f48 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommandTest.java @@ -20,12 +20,14 @@ import sonia.scm.NotFoundException; import sonia.scm.ScmConstraintViolationException; import sonia.scm.repository.Person; import sonia.scm.repository.util.WorkdirProvider; +import sonia.scm.web.lfs.LfsBlobStoreFactory; import java.io.File; import java.io.IOException; import java.nio.file.Files; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; @SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") public class GitModifyCommandTest extends AbstractGitCommandTestBase { @@ -37,6 +39,8 @@ public class GitModifyCommandTest extends AbstractGitCommandTestBase { @Rule public ShiroRule shiro = new ShiroRule(); + private final LfsBlobStoreFactory lfsBlobStoreFactory = mock(LfsBlobStoreFactory.class); + @Test public void shouldCreateCommit() throws IOException, GitAPIException { File newFile = Files.write(temporaryFolder.newFile().toPath(), "new content".getBytes()).toFile(); @@ -263,8 +267,8 @@ public class GitModifyCommandTest extends AbstractGitCommandTestBase { command.execute(request); } - @Test(expected = ScmConstraintViolationException.class) - public void shouldFailWithConstraintViolationIfBranchIsNoBranch() throws IOException { + @Test(expected = NotFoundException.class) + public void shouldFailWithNotFoundExceptionIfBranchIsNoBranch() throws IOException { File newFile = Files.write(temporaryFolder.newFile().toPath(), "irrelevant\n".getBytes()).toFile(); GitModifyCommand command = createCommand(); @@ -296,7 +300,7 @@ public class GitModifyCommandTest extends AbstractGitCommandTestBase { } private GitModifyCommand createCommand() { - return new GitModifyCommand(createContext(), repository, new SimpleGitWorkdirFactory(new WorkdirProvider())); + return new GitModifyCommand(createContext(), repository, new SimpleGitWorkdirFactory(new WorkdirProvider()), lfsBlobStoreFactory); } @FunctionalInterface diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java new file mode 100644 index 0000000000..b1a5c7bbcc --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_LFSTest.java @@ -0,0 +1,116 @@ +package sonia.scm.repository.spi; + +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import sonia.scm.repository.Person; +import sonia.scm.repository.util.WorkdirProvider; +import sonia.scm.store.Blob; +import sonia.scm.store.BlobStore; +import sonia.scm.web.lfs.LfsBlobStoreFactory; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") +public class GitModifyCommand_LFSTest extends AbstractGitCommandTestBase { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Rule + public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule(); + @Rule + public ShiroRule shiro = new ShiroRule(); + + private final LfsBlobStoreFactory lfsBlobStoreFactory = mock(LfsBlobStoreFactory.class); + + @Before + public void registerFilter() { + new GitLfsFilterContextListener(contextProvider).contextInitialized(null); + } + + @After + public void unregisterFilter() { + new GitLfsFilterContextListener(contextProvider).contextDestroyed(null); + } + + @Test + public void shouldCreateCommit() throws IOException, GitAPIException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + String newRef = createCommit("new_lfs.png", "new content", "fe32608c9ef5b6cf7e3f946480253ff76f24f4ec0678f3d0f07f9844cbff9601", outputStream); + + try (Git git = new Git(createContext().open())) { + RevCommit lastCommit = getLastCommit(git); + assertThat(lastCommit.getFullMessage()).isEqualTo("test commit"); + assertThat(lastCommit.getAuthorIdent().getName()).isEqualTo("Dirk Gently"); + assertThat(newRef).isEqualTo(lastCommit.toObjectId().name()); + } + + assertThat(outputStream.toString()).isEqualTo("new content"); + } + + @Test + public void shouldCreateSecondCommits() throws IOException, GitAPIException { + createCommit("new_lfs.png", "new content", "fe32608c9ef5b6cf7e3f946480253ff76f24f4ec0678f3d0f07f9844cbff9601", new ByteArrayOutputStream()); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + String newRef = createCommit("more_lfs.png", "more content", "2c2316737c9313956dfc0083da3a2a62ce259f66484f3e26440f0d1b02dd4128", outputStream); + + try (Git git = new Git(createContext().open())) { + RevCommit lastCommit = getLastCommit(git); + assertThat(lastCommit.getFullMessage()).isEqualTo("test commit"); + assertThat(lastCommit.getAuthorIdent().getName()).isEqualTo("Dirk Gently"); + assertThat(newRef).isEqualTo(lastCommit.toObjectId().name()); + } + + assertThat(outputStream.toString()).isEqualTo("more content"); + } + + private String createCommit(String fileName, String content, String hashOfContent, ByteArrayOutputStream outputStream) throws IOException { + BlobStore blobStore = mock(BlobStore.class); + Blob blob = mock(Blob.class); + when(lfsBlobStoreFactory.getLfsBlobStore(any())).thenReturn(blobStore); + when(blobStore.create(hashOfContent)).thenReturn(blob); + when(blobStore.get(hashOfContent)).thenReturn(null, blob); + when(blob.getOutputStream()).thenReturn(outputStream); + when(blob.getSize()).thenReturn((long) content.length()); + + File newFile = Files.write(temporaryFolder.newFile().toPath(), content.getBytes()).toFile(); + + GitModifyCommand command = createCommand(); + + ModifyCommandRequest request = new ModifyCommandRequest(); + request.setCommitMessage("test commit"); + request.addRequest(new ModifyCommandRequest.CreateFileRequest(fileName, newFile, false)); + request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); + + return command.execute(request); + } + + private RevCommit getLastCommit(Git git) throws GitAPIException { + return git.log().setMaxCount(1).call().iterator().next(); + } + + private GitModifyCommand createCommand() { + return new GitModifyCommand(createContext(), repository, new SimpleGitWorkdirFactory(new WorkdirProvider()), lfsBlobStoreFactory); + } + + @Override + protected String getZippedRepositoryResource() { + return "sonia/scm/repository/spi/scm-git-spi-lfs-test.zip"; + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java new file mode 100644 index 0000000000..89362d65cd --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java @@ -0,0 +1,88 @@ +package sonia.scm.repository.spi; + +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.errors.CorruptObjectException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.CanonicalTreeParser; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import sonia.scm.ScmConstraintViolationException; +import sonia.scm.repository.Person; +import sonia.scm.repository.util.WorkdirProvider; +import sonia.scm.web.lfs.LfsBlobStoreFactory; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +@SubjectAware(configuration = "classpath:sonia/scm/configuration/shiro.ini", username = "admin", password = "secret") +public class GitModifyCommand_withEmptyRepositoryTest extends AbstractGitCommandTestBase { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Rule + public BindTransportProtocolRule transportProtocolRule = new BindTransportProtocolRule(); + @Rule + public ShiroRule shiro = new ShiroRule(); + + private final LfsBlobStoreFactory lfsBlobStoreFactory = mock(LfsBlobStoreFactory.class); + + @Test + public void shouldCreateNewFileInEmptyRepository() throws IOException, GitAPIException { + File newFile = Files.write(temporaryFolder.newFile().toPath(), "new content".getBytes()).toFile(); + + GitModifyCommand command = createCommand(); + + ModifyCommandRequest request = new ModifyCommandRequest(); + request.setCommitMessage("test commit"); + request.addRequest(new ModifyCommandRequest.CreateFileRequest("new_file", newFile, false)); + request.setAuthor(new Person("Dirk Gently", "dirk@holistic.det")); + + command.execute(request); + + TreeAssertions assertions = canonicalTreeParser -> assertThat(canonicalTreeParser.findFile("new_file")).isTrue(); + + assertInTree(assertions); + } + + @Override + protected String getZippedRepositoryResource() { + return "sonia/scm/repository/spi/scm-git-empty-repo.zip"; + } + + private void assertInTree(TreeAssertions assertions) throws IOException, GitAPIException { + try (Git git = new Git(createContext().open())) { + RevCommit lastCommit = getLastCommit(git); + try (RevWalk walk = new RevWalk(git.getRepository())) { + RevCommit commit = walk.parseCommit(lastCommit); + ObjectId treeId = commit.getTree().getId(); + try (ObjectReader reader = git.getRepository().newObjectReader()) { + assertions.checkAssertions(new CanonicalTreeParser(null, reader, treeId)); + } + } + } + } + + private RevCommit getLastCommit(Git git) throws GitAPIException { + return git.log().setMaxCount(1).call().iterator().next(); + } + + private GitModifyCommand createCommand() { + return new GitModifyCommand(createContext(), repository, new SimpleGitWorkdirFactory(new WorkdirProvider()), lfsBlobStoreFactory); + } + + @FunctionalInterface + private interface TreeAssertions { + void checkAssertions(CanonicalTreeParser treeParser) throws CorruptObjectException; + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java index 1b3c730ef1..cb2d434f1e 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkdirFactoryTest.java @@ -20,8 +20,6 @@ import java.io.IOException; import static com.google.inject.util.Providers.of; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; public class SimpleGitWorkdirFactoryTest extends AbstractGitCommandTestBase { @@ -43,11 +41,11 @@ public class SimpleGitWorkdirFactoryTest extends AbstractGitCommandTestBase { } @Test - public void emptyPoolShouldCreateNewWorkdir() throws IOException { + public void emptyPoolShouldCreateNewWorkdir() { SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(workdirProvider); File masterRepo = createRepositoryDirectory(); - try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext())) { + try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext(), null)) { assertThat(workingCopy.getDirectory()) .exists() @@ -61,25 +59,37 @@ public class SimpleGitWorkdirFactoryTest extends AbstractGitCommandTestBase { } @Test - public void cloneFromPoolShouldNotBeReused() throws IOException { + public void shouldCheckoutInitialBranch() { + SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(workdirProvider); + + try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext(), "test-branch")) { + assertThat(new File(workingCopy.getWorkingRepository().getWorkTree(), "a.txt")) + .exists() + .isFile() + .hasContent("a and b"); + } + } + + @Test + public void cloneFromPoolShouldNotBeReused() { SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(workdirProvider); File firstDirectory; - try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext())) { + try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext(), null)) { firstDirectory = workingCopy.getDirectory(); } - try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext())) { + try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext(), null)) { File secondDirectory = workingCopy.getDirectory(); assertThat(secondDirectory).isNotEqualTo(firstDirectory); } } @Test - public void cloneFromPoolShouldBeDeletedOnClose() throws IOException { + public void cloneFromPoolShouldBeDeletedOnClose() { SimpleGitWorkdirFactory factory = new SimpleGitWorkdirFactory(workdirProvider); File directory; - try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext())) { + try (WorkingCopy workingCopy = factory.createWorkingCopy(createContext(), null)) { directory = workingCopy.getWorkingRepository().getWorkTree(); } assertThat(directory).doesNotExist(); diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-empty-repo.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-empty-repo.zip new file mode 100644 index 0000000000..fdd0af4483 Binary files /dev/null and b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-empty-repo.zip differ diff --git a/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-lfs-test.zip b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-lfs-test.zip new file mode 100644 index 0000000000..b97f519684 Binary files /dev/null and b/scm-plugins/scm-git-plugin/src/test/resources/sonia/scm/repository/spi/scm-git-spi-lfs-test.zip differ 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 8f689e9664..addda86090 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 diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchCommand.java index 750bfc1332..5e9e01c9d8 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBranchCommand.java @@ -33,19 +33,15 @@ package sonia.scm.repository.spi; import com.aragost.javahg.Changeset; import com.aragost.javahg.commands.CommitCommand; import com.aragost.javahg.commands.PullCommand; -import com.aragost.javahg.commands.UpdateCommand; import org.apache.shiro.SecurityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.repository.Branch; -import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Repository; import sonia.scm.repository.api.BranchRequest; import sonia.scm.repository.util.WorkingCopy; import sonia.scm.user.User; -import java.io.IOException; - /** * Mercurial implementation of the {@link BranchCommand}. * Note that this creates an empty commit to "persist" the new branch. @@ -63,11 +59,9 @@ public class HgBranchCommand extends AbstractCommand implements BranchCommand { @Override public Branch branch(BranchRequest request) { - try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(getContext())) { + try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(getContext(), request.getParentBranch())) { com.aragost.javahg.Repository repository = workingCopy.getWorkingRepository(); - checkoutParentBranchIfSpecified(request, repository); - Changeset emptyChangeset = createNewBranchWithEmptyCommit(request, repository); LOG.debug("Created new branch '{}' in repository {} with changeset {}", @@ -79,16 +73,6 @@ public class HgBranchCommand extends AbstractCommand implements BranchCommand { } } - private void checkoutParentBranchIfSpecified(BranchRequest request, com.aragost.javahg.Repository repository) { - if (request.getParentBranch() != null) { - try { - UpdateCommand.on(repository).rev(request.getParentBranch()).execute(); - } catch (IOException e) { - throw new InternalRepositoryException(getRepository(), "Could not check out parent branch " + request.getParentBranch(), e); - } - } - } - private Changeset createNewBranchWithEmptyCommit(BranchRequest request, com.aragost.javahg.Repository repository) { com.aragost.javahg.commands.BranchCommand.on(repository).set(request.getNewBranch()); User currentUser = SecurityUtils.getSubject().getPrincipals().oneByType(User.class); diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgDiffCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgDiffCommand.java index d705f0ac14..9e43e26014 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgDiffCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgDiffCommand.java @@ -39,13 +39,12 @@ import com.google.common.base.Strings; import com.google.common.io.ByteStreams; import com.google.common.io.Closeables; import sonia.scm.repository.Repository; +import sonia.scm.repository.api.DiffCommandBuilder; import sonia.scm.repository.api.DiffFormat; import sonia.scm.repository.spi.javahg.HgDiffInternalCommand; import sonia.scm.web.HgUtil; -import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; //~--- JDK imports ------------------------------------------------------------ @@ -71,41 +70,36 @@ public class HgDiffCommand extends AbstractCommand implements DiffCommand //~--- get methods ---------------------------------------------------------- @Override - public void getDiffResult(DiffCommandRequest request, OutputStream output) - throws IOException + public DiffCommandBuilder.OutputStreamConsumer getDiffResult(DiffCommandRequest request) { - com.aragost.javahg.Repository hgRepo = open(); + return output -> { + com.aragost.javahg.Repository hgRepo = open(); - HgDiffInternalCommand cmd = HgDiffInternalCommand.on(hgRepo); - DiffFormat format = request.getFormat(); + HgDiffInternalCommand cmd = HgDiffInternalCommand.on(hgRepo); + DiffFormat format = request.getFormat(); - if (format == DiffFormat.GIT) - { - cmd.git(); - } - - cmd.change(HgUtil.getRevision(request.getRevision())); - - InputStream inputStream = null; - - try - { - - if (!Strings.isNullOrEmpty(request.getPath())) + if (format == DiffFormat.GIT) { - inputStream = cmd.stream(hgRepo.file(request.getPath())); - } - else - { - inputStream = cmd.stream(); + cmd.git(); } - ByteStreams.copy(inputStream, output); + cmd.change(HgUtil.getRevision(request.getRevision())); - } - finally - { - Closeables.close(inputStream, true); - } + InputStream inputStream = null; + + try { + + if (!Strings.isNullOrEmpty(request.getPath())) { + inputStream = cmd.stream(hgRepo.file(request.getPath())); + } else { + inputStream = cmd.stream(); + } + + ByteStreams.copy(inputStream, output); + + } finally { + Closeables.close(inputStream, true); + } + }; } } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgModifyCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgModifyCommand.java new file mode 100644 index 0000000000..0294b6902b --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgModifyCommand.java @@ -0,0 +1,103 @@ +package sonia.scm.repository.spi; + +import com.aragost.javahg.Changeset; +import com.aragost.javahg.Repository; +import com.aragost.javahg.commands.CommitCommand; +import com.aragost.javahg.commands.ExecutionException; +import com.aragost.javahg.commands.PullCommand; +import com.aragost.javahg.commands.RemoveCommand; +import com.aragost.javahg.commands.StatusCommand; +import sonia.scm.NoChangesMadeException; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.util.WorkingCopy; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +public class HgModifyCommand implements ModifyCommand { + + private HgCommandContext context; + private final HgWorkdirFactory workdirFactory; + + public HgModifyCommand(HgCommandContext context, HgWorkdirFactory workdirFactory) { + this.context = context; + this.workdirFactory = workdirFactory; + } + + @Override + public String execute(ModifyCommandRequest request) { + + try (WorkingCopy workingCopy = workdirFactory.createWorkingCopy(context, request.getBranch())) { + Repository workingRepository = workingCopy.getWorkingRepository(); + request.getRequests().forEach( + partialRequest -> { + try { + partialRequest.execute(new ModifyWorkerHelper() { + + @Override + public void addFileToScm(String name, Path file) { + try { + addFileToHg(file.toFile()); + } catch (ExecutionException e) { + throwInternalRepositoryException("could not add new file to index", e); + } + } + + @Override + public void doScmDelete(String toBeDeleted) { + RemoveCommand.on(workingRepository).execute(toBeDeleted); + } + + @Override + public sonia.scm.repository.Repository getRepository() { + return context.getScmRepository(); + } + + @Override + public String getBranch() { + return request.getBranch(); + } + + public File getWorkDir() { + return workingRepository.getDirectory(); + } + + private void addFileToHg(File file) { + workingRepository.workingCopy().add(file.getAbsolutePath()); + } + }); + } catch (IOException e) { + throwInternalRepositoryException("could not execute command on repository", e); + } + } + ); + if (StatusCommand.on(workingRepository).lines().isEmpty()) { + throw new NoChangesMadeException(context.getScmRepository()); + } + CommitCommand.on(workingRepository).user(String.format("%s <%s>", request.getAuthor().getName(), request.getAuthor().getMail())).message(request.getCommitMessage()).execute(); + List execute = pullModifyChangesToCentralRepository(request, workingCopy); + return execute.get(0).getNode(); + } catch (ExecutionException e) { + throwInternalRepositoryException("could not execute command on repository", e); + return null; + } + } + + private List pullModifyChangesToCentralRepository(ModifyCommandRequest request, WorkingCopy workingCopy) { + try { + com.aragost.javahg.commands.PullCommand pullCommand = PullCommand.on(workingCopy.getCentralRepository()); + workdirFactory.configure(pullCommand); + return pullCommand.execute(workingCopy.getDirectory().getAbsolutePath()); + } catch (Exception e) { + throw new IntegrateChangesFromWorkdirException(context.getScmRepository(), + String.format("Could not pull modify changes from working copy to central repository for branch %s", request.getBranch()), + e); + } + } + + private String throwInternalRepositoryException(String message, Exception e) { + throw new InternalRepositoryException(context.getScmRepository(), message, e); + } +} diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java index d60e888cac..c80699add8 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java @@ -66,7 +66,8 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider Command.INCOMING, Command.OUTGOING, Command.PUSH, - Command.PULL + Command.PULL, + Command.MODIFY ); //J+ @@ -77,7 +78,7 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider //~--- constructors --------------------------------------------------------- HgRepositoryServiceProvider(HgRepositoryHandler handler, - HgHookManager hookManager, Repository repository) + HgHookManager hookManager, Repository repository) { this.repository = repository; this.handler = handler; @@ -238,6 +239,11 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider return new HgPushCommand(handler, context, repository); } + @Override + public ModifyCommand getModifyCommand() { + return new HgModifyCommand(context, handler.getWorkdirFactory()); + } + /** * Method description * diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceResolver.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceResolver.java index d6d04ee017..7519cb564d 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceResolver.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceResolver.java @@ -47,12 +47,12 @@ import sonia.scm.repository.Repository; public class HgRepositoryServiceResolver implements RepositoryServiceResolver { - private HgRepositoryHandler handler; - private HgHookManager hookManager; + private final HgRepositoryHandler handler; + private final HgHookManager hookManager; @Inject public HgRepositoryServiceResolver(HgRepositoryHandler handler, - HgHookManager hookManager) + HgHookManager hookManager) { this.handler = handler; this.hookManager = hookManager; diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/SimpleHgWorkdirFactory.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/SimpleHgWorkdirFactory.java index b9194145e6..619f8b0892 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/SimpleHgWorkdirFactory.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/SimpleHgWorkdirFactory.java @@ -1,8 +1,10 @@ package sonia.scm.repository.spi; +import com.aragost.javahg.BaseRepository; import com.aragost.javahg.Repository; import com.aragost.javahg.commands.CloneCommand; import com.aragost.javahg.commands.PullCommand; +import com.aragost.javahg.commands.flags.CloneCommandFlags; import sonia.scm.repository.util.SimpleWorkdirFactory; import sonia.scm.repository.util.WorkdirProvider; import sonia.scm.web.HgRepositoryEnvironmentBuilder; @@ -24,12 +26,19 @@ public class SimpleHgWorkdirFactory extends SimpleWorkdirFactory cloneRepository(HgCommandContext context, File target) throws IOException { + public ParentAndClone cloneRepository(HgCommandContext context, File target, String initialBranch) throws IOException { BiConsumer> repositoryMapBiConsumer = (repository, environment) -> hgRepositoryEnvironmentBuilder.get().buildFor(repository, null, environment); Repository centralRepository = context.openWithSpecialEnvironment(repositoryMapBiConsumer); - CloneCommand.on(centralRepository).execute(target.getAbsolutePath()); - return new ParentAndClone<>(centralRepository, Repository.open(target)); + CloneCommand cloneCommand = CloneCommandFlags.on(centralRepository); + if (initialBranch != null) { + cloneCommand.updaterev(initialBranch); + } + cloneCommand.execute(target.getAbsolutePath()); + + BaseRepository clone = Repository.open(target); + + return new ParentAndClone<>(centralRepository, clone); } @Override diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py index c123660a83..1871200389 100644 --- a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py +++ b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py @@ -1,8 +1,8 @@ # # Copyright (c) 2010, Sebastian Sdorra -# All rights reserved. +# aLL rights reserved. # -# Redistribution and use in source and binary forms, with or without +# rEDistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, @@ -34,7 +34,7 @@ Prints date, size and last message of files. """ from collections import defaultdict -from mercurial import cmdutil,util +from mercurial import scmutil cmdtable = {} @@ -122,7 +122,7 @@ class File_Object: return result class File_Walker: - + def __init__(self, sub_repositories, visitor): self.visitor = visitor self.sub_repositories = sub_repositories @@ -273,7 +273,7 @@ class File_Viewer: ('t', 'transport', False, 'format the output for command server'), ]) def fileview(ui, repo, **opts): - revCtx = repo[opts["revision"]] + revCtx = scmutil.revsingle(repo, opts["revision"]) subrepos = {} if not opts["disableSubRepositoryDetection"]: subrepos = collect_sub_repositories(revCtx) diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBranchCommandTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBranchCommandTest.java index 7976af8b3b..882dab05d0 100644 --- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBranchCommandTest.java +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBranchCommandTest.java @@ -2,7 +2,7 @@ package sonia.scm.repository.spi; import com.aragost.javahg.commands.PullCommand; import com.google.inject.util.Providers; -import org.assertj.core.api.Assertions; +import org.junit.Before; import org.junit.Test; import sonia.scm.repository.Branch; import sonia.scm.repository.HgTestUtil; @@ -10,30 +10,48 @@ import sonia.scm.repository.api.BranchRequest; import sonia.scm.repository.util.WorkdirProvider; import sonia.scm.web.HgRepositoryEnvironmentBuilder; -import java.io.IOException; import java.util.List; -public class HgBranchCommandTest extends AbstractHgCommandTestBase { - @Test - public void shouldCreateBranch() throws IOException { - Assertions.assertThat(readBranches()).filteredOn(b -> b.getName().equals("new_branch")).isEmpty(); +import static org.assertj.core.api.Assertions.assertThat; +public class HgBranchCommandTest extends AbstractHgCommandTestBase { + + private SimpleHgWorkdirFactory workdirFactory; + + @Before + public void initWorkdirFactory() { HgRepositoryEnvironmentBuilder hgRepositoryEnvironmentBuilder = new HgRepositoryEnvironmentBuilder(handler, HgTestUtil.createHookManager()); - SimpleHgWorkdirFactory workdirFactory = new SimpleHgWorkdirFactory(Providers.of(hgRepositoryEnvironmentBuilder), new WorkdirProvider()) { + workdirFactory = new SimpleHgWorkdirFactory(Providers.of(hgRepositoryEnvironmentBuilder), new WorkdirProvider()) { @Override public void configure(PullCommand pullCommand) { // we do not want to configure http hooks in this unit test } }; + } + @Test + public void shouldCreateBranch() { BranchRequest branchRequest = new BranchRequest(); branchRequest.setNewBranch("new_branch"); - new HgBranchCommand(cmdContext, repository, workdirFactory).branch(branchRequest); + Branch newBranch = new HgBranchCommand(cmdContext, repository, workdirFactory).branch(branchRequest); - Assertions.assertThat(readBranches()).filteredOn(b -> b.getName().equals("new_branch")).isNotEmpty(); + assertThat(readBranches()).filteredOn(b -> b.getName().equals("new_branch")).isNotEmpty(); + assertThat(cmdContext.open().changeset(newBranch.getRevision()).getParent1().getBranch()).isEqualTo("default"); + } + + @Test + public void shouldCreateBranchOnSpecificParent() { + BranchRequest branchRequest = new BranchRequest(); + branchRequest.setParentBranch("test-branch"); + branchRequest.setNewBranch("new_branch"); + + Branch newBranch = new HgBranchCommand(cmdContext, repository, workdirFactory).branch(branchRequest); + + assertThat(readBranches()).filteredOn(b -> b.getName().equals("new_branch")).isNotEmpty(); + assertThat(cmdContext.open().changeset(newBranch.getRevision()).getParent1().getBranch()).isEqualTo("test-branch"); } private List readBranches() { diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgModifyCommandTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgModifyCommandTest.java new file mode 100644 index 0000000000..862d2ab2ed --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgModifyCommandTest.java @@ -0,0 +1,164 @@ +package sonia.scm.repository.spi; + +import com.google.inject.util.Providers; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import sonia.scm.AlreadyExistsException; +import sonia.scm.NoChangesMadeException; +import sonia.scm.NotFoundException; +import sonia.scm.repository.HgHookManager; +import sonia.scm.repository.HgTestUtil; +import sonia.scm.repository.Person; +import sonia.scm.repository.util.WorkdirProvider; +import sonia.scm.web.HgRepositoryEnvironmentBuilder; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HgModifyCommandTest extends AbstractHgCommandTestBase { + + private HgModifyCommand hgModifyCommand; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Before + public void initHgModifyCommand() { + HgHookManager hookManager = HgTestUtil.createHookManager(); + HgRepositoryEnvironmentBuilder environmentBuilder = new HgRepositoryEnvironmentBuilder(handler, hookManager); + SimpleHgWorkdirFactory workdirFactory = new SimpleHgWorkdirFactory(Providers.of(environmentBuilder), new WorkdirProvider()) { + @Override + public void configure(com.aragost.javahg.commands.PullCommand pullCommand) { + // we do not want to configure http hooks in this unit test + } + }; + hgModifyCommand = new HgModifyCommand(cmdContext, workdirFactory + ); + } + + @Test + public void shouldRemoveFiles() { + ModifyCommandRequest request = new ModifyCommandRequest(); + request.addRequest(new ModifyCommandRequest.DeleteFileRequest("a.txt")); + request.setCommitMessage("this is great"); + request.setAuthor(new Person("Arthur Dent", "dent@hitchhiker.com")); + + String result = hgModifyCommand.execute(request); + + assertThat(cmdContext.open().tip().getNode()).isEqualTo(result); + } + + @Test + public void shouldCreateFilesWithoutOverwrite() throws IOException { + File testFile = temporaryFolder.newFile(); + + ModifyCommandRequest request = new ModifyCommandRequest(); + request.addRequest(new ModifyCommandRequest.CreateFileRequest("Answer.txt", testFile, false)); + request.setCommitMessage("I found the answer"); + request.setAuthor(new Person("Trillian Astra", "trillian@hitchhiker.com")); + + String changeSet = hgModifyCommand.execute(request); + + assertThat(cmdContext.open().tip().getNode()).isEqualTo(changeSet); + assertThat(cmdContext.open().tip().getAddedFiles().size()).isEqualTo(1); + } + + @Test + public void shouldOverwriteExistingFiles() throws IOException { + File testFile = temporaryFolder.newFile(); + + new FileOutputStream(testFile).write(42); + ModifyCommandRequest request2 = new ModifyCommandRequest(); + request2.addRequest(new ModifyCommandRequest.CreateFileRequest("a.txt", testFile, true)); + request2.setCommitMessage(" Now i really found the answer"); + request2.setAuthor(new Person("Trillian Astra", "trillian@hitchhiker.com")); + + String changeSet2 = hgModifyCommand.execute(request2); + + assertThat(cmdContext.open().tip().getNode()).isEqualTo(changeSet2); + assertThat(cmdContext.open().tip().getModifiedFiles().size()).isEqualTo(1); + assertThat(cmdContext.open().tip().getModifiedFiles().get(0)).isEqualTo("a.txt"); + } + + @Test(expected = AlreadyExistsException.class) + public void shouldThrowFileAlreadyExistsException() throws IOException { + + File testFile = temporaryFolder.newFile(); + new FileOutputStream(testFile).write(21); + + ModifyCommandRequest request = new ModifyCommandRequest(); + request.addRequest(new ModifyCommandRequest.CreateFileRequest("Answer.txt", testFile, false)); + request.setCommitMessage("I found the answer"); + request.setAuthor(new Person("Trillian Astra", "trillian@hitchhiker.com")); + + hgModifyCommand.execute(request); + + new FileOutputStream(testFile).write(42); + ModifyCommandRequest request2 = new ModifyCommandRequest(); + request2.addRequest(new ModifyCommandRequest.CreateFileRequest("Answer.txt", testFile, false)); + request2.setCommitMessage(" Now i really found the answer"); + request2.setAuthor(new Person("Trillian Astra", "trillian@hitchhiker.com")); + + hgModifyCommand.execute(request2); + } + + @Test + public void shouldModifyExistingFile() throws IOException { + File testFile = temporaryFolder.newFile("a.txt"); + + new FileOutputStream(testFile).write(42); + ModifyCommandRequest request2 = new ModifyCommandRequest(); + request2.addRequest(new ModifyCommandRequest.ModifyFileRequest("a.txt", testFile)); + request2.setCommitMessage(" Now i really found the answer"); + request2.setAuthor(new Person("Trillian Astra", "trillian@hitchhiker.com")); + + String changeSet2 = hgModifyCommand.execute(request2); + + assertThat(cmdContext.open().tip().getNode()).isEqualTo(changeSet2); + assertThat(cmdContext.open().tip().getModifiedFiles().size()).isEqualTo(1); + assertThat(cmdContext.open().tip().getModifiedFiles().get(0)).isEqualTo(testFile.getName()); + } + + @Test(expected = NotFoundException.class) + public void shouldThrowNotFoundExceptionIfFileDoesNotExist() throws IOException { + File testFile = temporaryFolder.newFile("Answer.txt"); + + new FileOutputStream(testFile).write(42); + ModifyCommandRequest request2 = new ModifyCommandRequest(); + request2.addRequest(new ModifyCommandRequest.ModifyFileRequest("Answer.txt", testFile)); + request2.setCommitMessage(" Now i really found the answer"); + request2.setAuthor(new Person("Trillian Astra", "trillian@hitchhiker.com")); + + hgModifyCommand.execute(request2); + } + + @Test(expected = NullPointerException.class) + public void shouldThrowNPEIfAuthorIsMissing() throws IOException { + File testFile = temporaryFolder.newFile(); + + ModifyCommandRequest request = new ModifyCommandRequest(); + request.addRequest(new ModifyCommandRequest.CreateFileRequest("Answer.txt", testFile, false)); + request.setCommitMessage("I found the answer"); + hgModifyCommand.execute(request); + } + + @Test(expected = NullPointerException.class) + public void shouldThrowNPEIfCommitMessageIsMissing() throws IOException { + File testFile = temporaryFolder.newFile(); + + ModifyCommandRequest request = new ModifyCommandRequest(); + request.addRequest(new ModifyCommandRequest.CreateFileRequest("Answer.txt", testFile, false)); + request.setAuthor(new Person("Trillian Astra", "trillian@hitchhiker.com")); + hgModifyCommand.execute(request); + } + + @Test(expected = NoChangesMadeException.class) + public void shouldThrowNoChangesMadeExceptionIfEmptyLocalChangesetAfterRequest() { + hgModifyCommand.execute(new ModifyCommandRequest()); + } +} diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnDiffCommand.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnDiffCommand.java index 4aaa12e28f..3bbde02844 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnDiffCommand.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnDiffCommand.java @@ -46,11 +46,10 @@ import org.tmatesoft.svn.core.wc.SVNRevision; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Repository; import sonia.scm.repository.SvnUtil; +import sonia.scm.repository.api.DiffCommandBuilder; import sonia.scm.repository.api.DiffFormat; import sonia.scm.util.Util; -import java.io.OutputStream; - //~--- JDK imports ------------------------------------------------------------ /** @@ -70,33 +69,34 @@ public class SvnDiffCommand extends AbstractSvnCommand implements DiffCommand { } @Override - public void getDiffResult(DiffCommandRequest request, OutputStream output) { + public DiffCommandBuilder.OutputStreamConsumer getDiffResult(DiffCommandRequest request) { logger.debug("create diff for {}", request); Preconditions.checkNotNull(request, "request is required"); - Preconditions.checkNotNull(output, "outputstream is required"); String path = request.getPath(); - SVNClientManager clientManager = null; - try { - SVNURL svnurl = context.createUrl(); - if (Util.isNotEmpty(path)) { - svnurl = svnurl.appendPath(path, true); + return output -> { + SVNClientManager clientManager = null; + try { + SVNURL svnurl = context.createUrl(); + if (Util.isNotEmpty(path)) { + svnurl = svnurl.appendPath(path, true); + } + clientManager = SVNClientManager.newInstance(); + SVNDiffClient diffClient = clientManager.getDiffClient(); + diffClient.setDiffGenerator(new SvnNewDiffGenerator(new SCMSvnDiffGenerator())); + + long currentRev = SvnUtil.getRevisionNumber(request.getRevision(), repository); + + diffClient.setGitDiffFormat(request.getFormat() == DiffFormat.GIT); + + diffClient.doDiff(svnurl, SVNRevision.HEAD, + SVNRevision.create(currentRev - 1), SVNRevision.create(currentRev), + SVNDepth.INFINITY, false, output); + } catch (SVNException ex) { + throw new InternalRepositoryException(repository, "could not create diff", ex); + } finally { + SvnUtil.dispose(clientManager); } - clientManager = SVNClientManager.newInstance(); - SVNDiffClient diffClient = clientManager.getDiffClient(); - diffClient.setDiffGenerator(new SvnNewDiffGenerator(new SCMSvnDiffGenerator())); - - long currentRev = SvnUtil.getRevisionNumber(request.getRevision(), repository); - - diffClient.setGitDiffFormat(request.getFormat() == DiffFormat.GIT); - - diffClient.doDiff(svnurl, SVNRevision.HEAD, - SVNRevision.create(currentRev - 1), SVNRevision.create(currentRev), - SVNDepth.INFINITY, false, output); - } catch (SVNException ex) { - throw new InternalRepositoryException(repository, "could not create diff", ex); - } finally { - SvnUtil.dispose(clientManager); - } + }; } } diff --git a/scm-ui/src/admin/plugins/components/PluginActionModal.js b/scm-ui/src/admin/plugins/components/PluginActionModal.js new file mode 100644 index 0000000000..884101042d --- /dev/null +++ b/scm-ui/src/admin/plugins/components/PluginActionModal.js @@ -0,0 +1,216 @@ +// @flow +import * as React from "react"; +import { + Button, + ButtonGroup, + ErrorNotification, + Modal +} from "@scm-manager/ui-components"; +import type { PendingPlugins, PluginCollection } from "@scm-manager/ui-types"; +import { translate } from "react-i18next"; +import SuccessNotification from "./SuccessNotification"; + +type Props = { + onClose: () => void, + actionType: string, + pendingPlugins?: PendingPlugins, + installedPlugins?: PluginCollection, + refresh: () => void, + execute: () => Promise, + description: string, + label: string, + + children?: React.Node, + + // context props + t: string => string +}; + +type State = { + loading: boolean, + success: boolean, + error?: Error +}; + +class PluginActionModal extends React.Component { + constructor(props: Props) { + super(props); + this.state = { + loading: false, + success: false + }; + } + + renderNotifications = () => { + const { children } = this.props; + const { error, success } = this.state; + if (error) { + return ; + } else if (success) { + return ; + } else { + return children; + } + }; + + executeAction = () => { + this.setState({ + loading: true + }); + + this.props + .execute() + .then(() => { + this.setState({ + success: true, + loading: false + }); + }) + .catch(error => { + this.setState({ + success: false, + loading: false, + error: error + }); + }); + }; + + renderModalContent = () => { + return ( + <> + {this.renderUpdatable()} + {this.renderInstallQueue()} + {this.renderUpdateQueue()} + {this.renderUninstallQueue()} + > + ); + }; + + renderUpdatable = () => { + const { installedPlugins, t } = this.props; + return ( + <> + {installedPlugins && + installedPlugins._embedded && + installedPlugins._embedded.plugins && ( + <> + {t("plugins.modal.updateQueue")} + + {installedPlugins._embedded.plugins + .filter(plugin => plugin._links && plugin._links.update) + .map(plugin => ( + {plugin.name} + ))} + + > + )} + > + ); + }; + + renderInstallQueue = () => { + const { pendingPlugins, t } = this.props; + return ( + <> + {pendingPlugins && + pendingPlugins._embedded && + pendingPlugins._embedded.new.length > 0 && ( + <> + {t("plugins.modal.installQueue")} + + {pendingPlugins._embedded.new.map(plugin => ( + {plugin.name} + ))} + + > + )} + > + ); + }; + + renderUpdateQueue = () => { + const { pendingPlugins, t } = this.props; + return ( + <> + {pendingPlugins && + pendingPlugins._embedded && + pendingPlugins._embedded.update.length > 0 && ( + <> + {t("plugins.modal.updateQueue")} + + {pendingPlugins._embedded.update.map(plugin => ( + {plugin.name} + ))} + + > + )} + > + ); + }; + + renderUninstallQueue = () => { + const { pendingPlugins, t } = this.props; + return ( + <> + {pendingPlugins && + pendingPlugins._embedded && + pendingPlugins._embedded.uninstall.length > 0 && ( + <> + {t("plugins.modal.uninstallQueue")} + + {pendingPlugins._embedded.uninstall.map(plugin => ( + {plugin.name} + ))} + + > + )} + > + ); + }; + + renderBody = () => { + return ( + <> + + + {this.props.description} + {this.renderModalContent()} + + + {this.renderNotifications()} + > + ); + }; + + renderFooter = () => { + const { onClose, t } = this.props; + const { loading, error, success } = this.state; + return ( + + + + + ); + }; + + render() { + const { onClose } = this.props; + return ( + + ); + } +} + +export default translate("admin")(PluginActionModal); diff --git a/scm-ui/ui-components/src/apiclient.js b/scm-ui/ui-components/src/apiclient.js index 2ad0cd1f9f..a656246c95 100644 --- a/scm-ui/ui-components/src/apiclient.js +++ b/scm-ui/ui-components/src/apiclient.js @@ -46,7 +46,7 @@ export function createUrl(url: string) { class ApiClient { get(url: string): Promise { - return fetch(createUrl(url), applyFetchOptions).then(handleFailure); + return fetch(createUrl(url), applyFetchOptions({})).then(handleFailure); } post(url: string, payload: any, contentType: string = "application/json") { diff --git a/scm-ui/ui-components/src/buttons/ButtonGroup.js b/scm-ui/ui-components/src/buttons/ButtonGroup.js index b73688ebbf..66b3f21052 100644 --- a/scm-ui/ui-components/src/buttons/ButtonGroup.js +++ b/scm-ui/ui-components/src/buttons/ButtonGroup.js @@ -14,7 +14,7 @@ class ButtonGroup extends React.Component { const childWrapper = []; React.Children.forEach(children, child => { if (child) { - childWrapper.push({child}); + childWrapper.push({child}); } }); diff --git a/scm-ui/ui-components/src/forms/MemberNameTagGroup.js b/scm-ui/ui-components/src/forms/MemberNameTagGroup.js index 20a078619d..73a5f8c723 100644 --- a/scm-ui/ui-components/src/forms/MemberNameTagGroup.js +++ b/scm-ui/ui-components/src/forms/MemberNameTagGroup.js @@ -7,20 +7,22 @@ import TagGroup from "./TagGroup"; type Props = { members: string[], memberListChanged: (string[]) => void, + label?: string, + helpText?: string, t: string => string }; class MemberNameTagGroup extends React.Component { render() { - const { members, t } = this.props; + const { members, label, helpText, t } = this.props; const membersExtended = members.map(id => { return { id, displayName: id, mail: "" }; }); return ( ); diff --git a/scm-ui/ui-components/src/layout/Level.js b/scm-ui/ui-components/src/layout/Level.js new file mode 100644 index 0000000000..5359de7c66 --- /dev/null +++ b/scm-ui/ui-components/src/layout/Level.js @@ -0,0 +1,21 @@ +//@flow +import * as React from "react"; +import classNames from "classnames"; + +type Props = { + className?: string, + left?: React.Node, + right?: React.Node +}; + +export default class Level extends React.Component { + render() { + const { className, left, right } = this.props; + return ( + + {left} + {right} + + ); + } +} diff --git a/scm-ui/ui-components/src/layout/index.js b/scm-ui/ui-components/src/layout/index.js index 7708c45c99..ac7279a06a 100644 --- a/scm-ui/ui-components/src/layout/index.js +++ b/scm-ui/ui-components/src/layout/index.js @@ -2,6 +2,7 @@ export { default as Footer } from "./Footer.js"; export { default as Header } from "./Header.js"; +export { default as Level } from "./Level.js"; export { default as Page } from "./Page.js"; export { default as PageActions } from "./PageActions.js"; export { default as Subtitle } from "./Subtitle.js"; diff --git a/scm-ui/ui-components/src/repos/Diff.js b/scm-ui/ui-components/src/repos/Diff.js index 7a5bcbf4a2..99a0e1d917 100644 --- a/scm-ui/ui-components/src/repos/Diff.js +++ b/scm-ui/ui-components/src/repos/Diff.js @@ -4,7 +4,8 @@ import DiffFile from "./DiffFile"; import type {DiffObjectProps, File} from "./DiffTypes"; type Props = DiffObjectProps & { - diff: File[] + diff: File[], + defaultCollapse?: boolean }; class Diff extends React.Component { @@ -17,7 +18,7 @@ class Diff extends React.Component { return ( <> {diff.map((file, index) => ( - + ))} > ); diff --git a/scm-ui/ui-components/src/repos/LoadingDiff.js b/scm-ui/ui-components/src/repos/LoadingDiff.js index 876b757f44..16f2d8e5f1 100644 --- a/scm-ui/ui-components/src/repos/LoadingDiff.js +++ b/scm-ui/ui-components/src/repos/LoadingDiff.js @@ -9,7 +9,8 @@ import Diff from "./Diff"; import type {DiffObjectProps, File} from "./DiffTypes"; type Props = DiffObjectProps & { - url: string + url: string, + defaultCollapse?: boolean }; type State = { diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetDiff.js b/scm-ui/ui-components/src/repos/changesets/ChangesetDiff.js index d5d3c4e665..5bc6b249dd 100644 --- a/scm-ui/ui-components/src/repos/changesets/ChangesetDiff.js +++ b/scm-ui/ui-components/src/repos/changesets/ChangesetDiff.js @@ -7,6 +7,7 @@ import {translate} from "react-i18next"; type Props = { changeset: Changeset, + defaultCollapse?: boolean, // context props t: string => string @@ -23,12 +24,12 @@ class ChangesetDiff extends React.Component { } render() { - const { changeset, t } = this.props; + const { changeset, defaultCollapse, t } = this.props; if (!this.isDiffSupported(changeset)) { return {t("changeset.diffNotSupported")}; } else { const url = this.createUrl(changeset); - return ; + return ; } } diff --git a/scm-ui/ui-types/src/Plugin.js b/scm-ui/ui-types/src/Plugin.js index c7612a8bf8..f5a621e1ff 100644 --- a/scm-ui/ui-types/src/Plugin.js +++ b/scm-ui/ui-types/src/Plugin.js @@ -17,6 +17,7 @@ export type Plugin = { }; export type PluginCollection = Collection & { + _links: Links, _embedded: { plugins: Plugin[] | string[] } diff --git a/scm-ui/ui-webapp/public/locales/de/admin.json b/scm-ui/ui-webapp/public/locales/de/admin.json index d4f3567059..af34c5b759 100644 --- a/scm-ui/ui-webapp/public/locales/de/admin.json +++ b/scm-ui/ui-webapp/public/locales/de/admin.json @@ -29,7 +29,11 @@ "installedNavLink": "Installiert", "availableNavLink": "Verfügbar" }, - "executePending": "Ausstehende Plugin-Änderungen ausführen", + "executePending": "Änderungen ausführen", + "outdatedPlugins": "{{count}} veraltetes Plugin", + "outdatedPlugins_plural": "{{count}} veraltete Plugins", + "updateAll": "Alle Plugins aktualisieren", + "cancelPending": "Änderungen abbrechen", "noPlugins": "Keine Plugins gefunden.", "modal": { "title": { @@ -48,6 +52,7 @@ "updateAndRestart": "Aktualisieren und Neustarten", "uninstallAndRestart": "Deinstallieren and Neustarten", "executeAndRestart": "Ausführen und Neustarten", + "updateAll": "Alle Plugins aktualisieren", "abort": "Abbrechen", "author": "Autor", "version": "Version", @@ -58,7 +63,9 @@ "successNotification": "Das Plugin wurde erfolgreich installiert. Um Änderungen an der UI zu sehen, muss die Seite neu geladen werden:", "reload": "jetzt neu laden", "restartNotification": "Der SCM-Manager Kontext sollte nur neu gestartet werden, wenn aktuell niemand damit arbeitet.", - "executePending": "Die folgenden Plugin-Änderungen werden ausgeführt. Anschließend wird der SCM-Manager Kontext neu gestartet." + "executePending": "Die folgenden Plugin-Änderungen werden ausgeführt. Anschließend wird der SCM-Manager Kontext neu gestartet.", + "cancelPending": "Die folgenden Plugin-Änderungen werden abgebrochen und zurückgesetzt.", + "updateAllInfo": "Die folgenden Plugins werden aktualisiert. Die Änderungen werden nach dem nächsten Neustart wirksam." } }, "repositoryRole": { diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index 20cb7b4418..cec34319ab 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -73,7 +73,8 @@ "errorTitle": "Fehler", "errorSubtitle": "Changesets konnten nicht abgerufen werden", "noChangesets": "Keine Changesets in diesem Branch gefunden.", - "branchSelectorLabel": "Branches" + "branchSelectorLabel": "Branches", + "collapseDiffs": "Auf-/Zuklappen" }, "changeset": { "description": "Beschreibung", diff --git a/scm-ui/ui-webapp/public/locales/en/admin.json b/scm-ui/ui-webapp/public/locales/en/admin.json index c23a88a920..c315d1d142 100644 --- a/scm-ui/ui-webapp/public/locales/en/admin.json +++ b/scm-ui/ui-webapp/public/locales/en/admin.json @@ -29,7 +29,11 @@ "installedNavLink": "Installed", "availableNavLink": "Available" }, - "executePending": "Execute pending plugin changes", + "executePending": "Execute changes", + "outdatedPlugins": "{{count}} outdated plugin", + "outdatedPlugins_plural": "{{count}} outdated plugins", + "updateAll": "Update all plugins", + "cancelPending": "Cancel changes", "noPlugins": "No plugins found.", "modal": { "title": { @@ -48,6 +52,7 @@ "updateAndRestart": "Update and Restart", "uninstallAndRestart": "Uninstall and Restart", "executeAndRestart": "Execute and Restart", + "updateAll": "Update all plugins", "abort": "Abort", "author": "Author", "version": "Version", @@ -58,7 +63,9 @@ "successNotification": "Successful installed plugin. You have to reload the page, to see ui changes:", "reload": "reload now", "restartNotification": "You should only restart the scm-manager context if no one else is currently working with it.", - "executePending": "The following plugin changes will be executed and after that the scm-manager context will be restarted." + "executePending": "The following plugin changes will be executed and after that the scm-manager context will be restarted.", + "cancelPending": "The following plugin changes will be canceled.", + "updateAllInfo": "The following plugin changes will be executed. You need to restart the scm-manager to make these changes effective." } }, "repositoryRole": { diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 29c0473ad9..76d21aa679 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -73,7 +73,8 @@ "errorTitle": "Error", "errorSubtitle": "Could not fetch changesets", "noChangesets": "No changesets found for this branch.", - "branchSelectorLabel": "Branches" + "branchSelectorLabel": "Branches", + "collapseDiffs": "Collapse" }, "changeset": { "description": "Description", diff --git a/scm-ui/ui-webapp/public/locales/es/admin.json b/scm-ui/ui-webapp/public/locales/es/admin.json index db9fade43c..155313ed99 100644 --- a/scm-ui/ui-webapp/public/locales/es/admin.json +++ b/scm-ui/ui-webapp/public/locales/es/admin.json @@ -30,6 +30,8 @@ "availableNavLink": "Disponibles" }, "executePending": "Ejecutar los complementos pendientes", + "updateAll": "Actualizar todos los complementos", + "cancelPending": "Cancelar los complementos pendientes", "noPlugins": "No se han encontrado complementos.", "modal": { "title": { diff --git a/scm-ui/ui-webapp/public/locales/es/repos.json b/scm-ui/ui-webapp/public/locales/es/repos.json index 08c9235593..efcbdd8c2e 100644 --- a/scm-ui/ui-webapp/public/locales/es/repos.json +++ b/scm-ui/ui-webapp/public/locales/es/repos.json @@ -73,7 +73,8 @@ "errorTitle": "Error", "errorSubtitle": "No se han podido recuperar los changesets", "noChangesets": "No se han encontrado changesets para esta rama branch.", - "branchSelectorLabel": "Ramas" + "branchSelectorLabel": "Ramas", + "collapseDiffs": "Colapso" }, "changeset": { "description": "Descripción", diff --git a/scm-ui/ui-webapp/src/admin/plugins/components/CancelPendingActionModal.js b/scm-ui/ui-webapp/src/admin/plugins/components/CancelPendingActionModal.js new file mode 100644 index 0000000000..6ca6400099 --- /dev/null +++ b/scm-ui/ui-webapp/src/admin/plugins/components/CancelPendingActionModal.js @@ -0,0 +1,42 @@ +// @flow + +import React from "react"; +import PluginActionModal from "./PluginActionModal"; +import type { PendingPlugins } from "@scm-manager/ui-types"; +import { apiClient } from "@scm-manager/ui-components"; +import { translate } from "react-i18next"; + +type Props = { + onClose: () => void, + refresh: () => void, + pendingPlugins: PendingPlugins, + + // context props + t: string => string +}; + +class CancelPendingActionModal extends React.Component { + render() { + const { onClose, pendingPlugins, t } = this.props; + + return ( + + ); + } + + cancelPending = () => { + const { pendingPlugins, refresh, onClose } = this.props; + return apiClient + .post(pendingPlugins._links.cancel.href) + .then(refresh) + .then(onClose); + }; +} + +export default translate("admin")(CancelPendingActionModal); diff --git a/scm-ui/ui-webapp/src/admin/plugins/components/ExecutePendingActionModal.js b/scm-ui/ui-webapp/src/admin/plugins/components/ExecutePendingActionModal.js new file mode 100644 index 0000000000..f5185691c0 --- /dev/null +++ b/scm-ui/ui-webapp/src/admin/plugins/components/ExecutePendingActionModal.js @@ -0,0 +1,45 @@ +// @flow + +import React from "react"; +import PluginActionModal from "./PluginActionModal"; +import type { PendingPlugins } from "@scm-manager/ui-types"; +import waitForRestart from "./waitForRestart"; +import { apiClient, Notification } from "@scm-manager/ui-components"; +import { translate } from "react-i18next"; + +type Props = { + onClose: () => void, + pendingPlugins: PendingPlugins, + + // context props + t: string => string +}; + +class ExecutePendingActionModal extends React.Component { + render() { + const { onClose, pendingPlugins, t } = this.props; + + return ( + + + {t("plugins.modal.restartNotification")} + + + ); + } + + executeAndRestart = () => { + const { pendingPlugins } = this.props; + return apiClient + .post(pendingPlugins._links.execute.href) + .then(waitForRestart); + }; +} + +export default translate("admin")(ExecutePendingActionModal); diff --git a/scm-ui/ui-webapp/src/admin/plugins/components/PluginModal.js b/scm-ui/ui-webapp/src/admin/plugins/components/PluginModal.js index cfbaed3ec3..83145a7648 100644 --- a/scm-ui/ui-webapp/src/admin/plugins/components/PluginModal.js +++ b/scm-ui/ui-webapp/src/admin/plugins/components/PluginModal.js @@ -114,6 +114,7 @@ class PluginModal extends React.Component { .catch(error => { this.setState({ loading: false, + success: false, error: error }); }); diff --git a/scm-ui/ui-webapp/src/admin/plugins/components/UpdateAllActionModal.js b/scm-ui/ui-webapp/src/admin/plugins/components/UpdateAllActionModal.js new file mode 100644 index 0000000000..1fdb67b7f2 --- /dev/null +++ b/scm-ui/ui-webapp/src/admin/plugins/components/UpdateAllActionModal.js @@ -0,0 +1,42 @@ +// @flow + +import React from "react"; +import PluginActionModal from "./PluginActionModal"; +import type { PluginCollection } from "@scm-manager/ui-types"; +import { apiClient } from "@scm-manager/ui-components"; +import { translate } from "react-i18next"; + +type Props = { + onClose: () => void, + refresh: () => void, + installedPlugins: PluginCollection, + + // context props + t: string => string +}; + +class UpdateAllActionModal extends React.Component { + render() { + const { onClose, installedPlugins, t } = this.props; + + return ( + + ); + } + + updateAll = () => { + const { installedPlugins, refresh, onClose } = this.props; + return apiClient + .post(installedPlugins._links.update.href) + .then(refresh) + .then(onClose); + }; +} + +export default translate("admin")(UpdateAllActionModal); diff --git a/scm-ui/ui-webapp/src/admin/plugins/containers/PluginsOverview.js b/scm-ui/ui-webapp/src/admin/plugins/containers/PluginsOverview.js index 530c272210..ab76593aec 100644 --- a/scm-ui/ui-webapp/src/admin/plugins/containers/PluginsOverview.js +++ b/scm-ui/ui-webapp/src/admin/plugins/containers/PluginsOverview.js @@ -5,11 +5,13 @@ import { translate } from "react-i18next"; import { compose } from "redux"; import type { PendingPlugins, PluginCollection } from "@scm-manager/ui-types"; import { + ButtonGroup, ErrorNotification, Loading, Notification, Subtitle, - Title + Title, + Button } from "@scm-manager/ui-components"; import { fetchPendingPlugins, @@ -27,7 +29,9 @@ import { } from "../../../modules/indexResource"; import PluginTopActions from "../components/PluginTopActions"; import PluginBottomActions from "../components/PluginBottomActions"; -import ExecutePendingAction from "../components/ExecutePendingAction"; +import ExecutePendingActionModal from "../components/ExecutePendingActionModal"; +import CancelPendingActionModal from "../components/CancelPendingActionModal"; +import UpdateAllActionModal from "../components/UpdateAllActionModal"; type Props = { loading: boolean, @@ -41,14 +45,29 @@ type Props = { pendingPlugins: PendingPlugins, // context objects - t: string => string, + t: (key: string, params?: Object) => string, // dispatched functions fetchPluginsByLink: (link: string) => void, fetchPendingPlugins: (link: string) => void }; -class PluginsOverview extends React.Component { +type State = { + showPendingModal: boolean, + showUpdateAllModal: boolean, + showCancelModal: boolean +}; + +class PluginsOverview extends React.Component { + constructor(props: Props, context: *) { + super(props, context); + this.state = { + showPendingModal: false, + showUpdateAllModal: false, + showCancelModal: false + }; + } + componentDidMount() { this.fetchPlugins(); } @@ -102,17 +121,72 @@ class PluginsOverview extends React.Component { }; createActions = () => { - const { pendingPlugins } = this.props; + const { pendingPlugins, collection, t } = this.props; + const buttons = []; + if ( pendingPlugins && pendingPlugins._links && pendingPlugins._links.execute ) { - return ; + buttons.push( + this.setState({ showPendingModal: true })} + /> + ); + } + + if ( + pendingPlugins && + pendingPlugins._links && + pendingPlugins._links.cancel + ) { + buttons.push( + this.setState({ showCancelModal: true })} + /> + ); + } + + if (collection && collection._links && collection._links.update) { + buttons.push( + this.setState({ showUpdateAllModal: true })} + /> + ); + } + + if (buttons.length > 0) { + return {buttons}; } return null; }; + computeUpdateAllSize = () => { + const { collection, t } = this.props; + const outdatedPlugins = collection._embedded.plugins.filter( + p => p._links.update + ).length; + return t("plugins.outdatedPlugins", { + count: outdatedPlugins + }); + }; + render() { const { loading, error, collection } = this.props; @@ -131,10 +205,47 @@ class PluginsOverview extends React.Component { {this.renderPluginsList()} {this.renderFooter(actions)} + {this.renderModals()} > ); } + renderModals = () => { + const { collection, pendingPlugins } = this.props; + const { + showPendingModal, + showCancelModal, + showUpdateAllModal + } = this.state; + + if (showPendingModal) { + return ( + this.setState({ showPendingModal: false })} + pendingPlugins={pendingPlugins} + /> + ); + } + if (showCancelModal) { + return ( + this.setState({ showCancelModal: false })} + refresh={this.fetchPlugins} + pendingPlugins={pendingPlugins} + /> + ); + } + if (showUpdateAllModal) { + return ( + this.setState({ showUpdateAllModal: false })} + refresh={this.fetchPlugins} + installedPlugins={collection} + /> + ); + } + }; + renderPluginsList() { const { collection, t } = this.props; diff --git a/scm-ui/ui-webapp/src/repos/containers/ChangesetView.js b/scm-ui/ui-webapp/src/repos/containers/ChangesetView.js index dc53b5d798..5a5af764c8 100644 --- a/scm-ui/ui-webapp/src/repos/containers/ChangesetView.js +++ b/scm-ui/ui-webapp/src/repos/containers/ChangesetView.js @@ -71,5 +71,5 @@ export default withRouter( connect( mapStateToProps, mapDispatchToProps - )(translate("changesets")(ChangesetView)) + )(translate("repos")(ChangesetView)) ); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java index 4d15b773cd..016c316500 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffRootResource.java @@ -4,6 +4,7 @@ import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.StatusCodes; import sonia.scm.NotFoundException; import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.api.DiffCommandBuilder; import sonia.scm.repository.api.DiffFormat; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; @@ -20,6 +21,7 @@ import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Response; import javax.ws.rs.core.StreamingOutput; +import java.io.IOException; public class DiffRootResource { @@ -55,20 +57,17 @@ public class DiffRootResource { @ResponseCode(code = 404, condition = "not found, no revision with the specified param for the repository available or repository not found"), @ResponseCode(code = 500, condition = "internal server error") }) - public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision , @Pattern(regexp = DIFF_FORMAT_VALUES_REGEX) @DefaultValue("NATIVE") @QueryParam("format") String format ){ + public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision , @Pattern(regexp = DIFF_FORMAT_VALUES_REGEX) @DefaultValue("NATIVE") @QueryParam("format") String format ) throws IOException { HttpUtil.checkForCRLFInjection(revision); DiffFormat diffFormat = DiffFormat.valueOf(format); try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { - StreamingOutput responseEntry = output -> { - repositoryService.getDiffCommand() - .setRevision(revision) - .setFormat(diffFormat) - .retrieveContent(output); - }; - return Response.ok(responseEntry) + DiffCommandBuilder.OutputStreamConsumer outputStreamConsumer = repositoryService.getDiffCommand() + .setRevision(revision) + .setFormat(diffFormat) + .retrieveContent(); + return Response.ok((StreamingOutput) outputStreamConsumer::accept) .header(HEADER_CONTENT_DISPOSITION, HttpUtil.createContentDispositionAttachmentHeader(String.format("%s-%s.diff", name, revision))) .build(); } } - } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java index 4c43485abd..cb27d9b5dc 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IncomingRootResource.java @@ -10,6 +10,7 @@ import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.api.DiffCommandBuilder; import sonia.scm.repository.api.DiffFormat; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; @@ -138,14 +139,13 @@ public class IncomingRootResource { HttpUtil.checkForCRLFInjection(target); DiffFormat diffFormat = DiffFormat.valueOf(format); try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { - StreamingOutput responseEntry = output -> - repositoryService.getDiffCommand() - .setRevision(source) - .setAncestorChangeset(target) - .setFormat(diffFormat) - .retrieveContent(output); + DiffCommandBuilder.OutputStreamConsumer outputStreamConsumer = repositoryService.getDiffCommand() + .setRevision(source) + .setAncestorChangeset(target) + .setFormat(diffFormat) + .retrieveContent(); - return Response.ok(responseEntry) + return Response.ok((StreamingOutput) outputStreamConsumer::accept) .header(HEADER_CONTENT_DISPOSITION, HttpUtil.createContentDispositionAttachmentHeader(String.format("%s-%s.diff", name, source))) .build(); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java index f0dff26757..eff83aee77 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java @@ -56,6 +56,21 @@ public class InstalledPluginResource { return Response.ok(collectionMapper.mapInstalled(plugins, available)).build(); } + /** + * Updates all installed plugins. + */ + @POST + @Path("/update") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + @TypeHint(CollectionDto.class) + public Response updateAll() { + pluginManager.updateAll(); + return Response.ok().build(); + } + /** * Returns the installed plugin with the given id. * diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java index 3997a5e7c5..7d4663829e 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java @@ -46,8 +46,6 @@ public class PendingPluginResource { }) @Produces(VndMediaType.PLUGIN_COLLECTION) public Response getPending() { - PluginPermissions.manage().check(); - List pending = pluginManager .getAvailable() .stream() @@ -71,8 +69,12 @@ public class PendingPluginResource { List updateDtos = updatePlugins.map(i -> mapper.mapInstalled(i, pending)).collect(toList()); List uninstallDtos = uninstallPlugins.map(i -> mapper.mapInstalled(i, pending)).collect(toList()); - if (!installDtos.isEmpty() || !updateDtos.isEmpty() || !uninstallDtos.isEmpty()) { + if ( + PluginPermissions.manage().isPermitted() && + (!installDtos.isEmpty() || !updateDtos.isEmpty() || !uninstallDtos.isEmpty()) + ) { linksBuilder.single(link("execute", resourceLinks.pendingPluginCollection().executePending())); + linksBuilder.single(link("cancel", resourceLinks.pendingPluginCollection().cancelPending())); } Embedded.Builder embedded = Embedded.embeddedBuilder(); @@ -106,8 +108,18 @@ public class PendingPluginResource { @ResponseCode(code = 500, condition = "internal server error") }) public Response executePending() { - PluginPermissions.manage().check(); pluginManager.executePendingAndRestart(); return Response.ok().build(); } + + @POST + @Path("/cancel") + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response cancelPending() { + pluginManager.cancelPending(); + return Response.ok().build(); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java index 3d817625c7..a301449c0d 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapper.java @@ -3,15 +3,15 @@ package sonia.scm.api.v2.resources; import com.google.inject.Inject; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.HalRepresentation; -import de.otto.edison.hal.Link; import de.otto.edison.hal.Links; import sonia.scm.plugin.AvailablePlugin; import sonia.scm.plugin.InstalledPlugin; -import sonia.scm.plugin.PluginPermissions; +import sonia.scm.plugin.PluginManager; import java.util.List; import static de.otto.edison.hal.Embedded.embeddedBuilder; +import static de.otto.edison.hal.Link.link; import static de.otto.edison.hal.Links.linkingTo; import static java.util.stream.Collectors.toList; @@ -19,11 +19,13 @@ public class PluginDtoCollectionMapper { private final ResourceLinks resourceLinks; private final PluginDtoMapper mapper; + private final PluginManager manager; @Inject - public PluginDtoCollectionMapper(ResourceLinks resourceLinks, PluginDtoMapper mapper) { + public PluginDtoCollectionMapper(ResourceLinks resourceLinks, PluginDtoMapper mapper, PluginManager manager) { this.resourceLinks = resourceLinks; this.mapper = mapper; + this.manager = manager; } public HalRepresentation mapInstalled(List plugins, List availablePlugins) { @@ -44,6 +46,11 @@ public class PluginDtoCollectionMapper { Links.Builder linksBuilder = linkingTo() .with(Links.linkingTo().self(baseUrl).build()); + + if (!manager.getUpdatable().isEmpty()) { + linksBuilder.single(link("update", resourceLinks.installedPluginCollection().update())); + } + return linksBuilder.build(); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index c36cfc09ad..8a7ce5a8b4 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -686,6 +686,10 @@ class ResourceLinks { String self() { return installedPluginCollectionLinkBuilder.method("installedPlugins").parameters().method("getInstalledPlugins").parameters().href(); } + + String update() { + return installedPluginCollectionLinkBuilder.method("installedPlugins").parameters().method("updateAll").parameters().href(); + } } public AvailablePluginLinks availablePlugin() { @@ -739,6 +743,10 @@ class ResourceLinks { return pendingPluginCollectionLinkBuilder.method("pendingPlugins").parameters().method("executePending").parameters().href(); } + String cancelPending() { + return pendingPluginCollectionLinkBuilder.method("pendingPlugins").parameters().method("cancelPending").parameters().href(); + } + String self() { return pendingPluginCollectionLinkBuilder.method("pendingPlugins").parameters().method("getPending").parameters().href(); } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index d54645f10d..07accbcc76 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -44,8 +44,8 @@ import sonia.scm.lifecycle.RestartEvent; import sonia.scm.version.Version; import javax.inject.Inject; -import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -72,7 +72,8 @@ public class DefaultPluginManager implements PluginManager { private final PluginLoader loader; private final PluginCenter center; private final PluginInstaller installer; - private final Collection pendingQueue = new ArrayList<>(); + private final Collection pendingInstallQueue = new ArrayList<>(); + private final Collection pendingUninstallQueue = new ArrayList<>(); private final PluginDependencyTracker dependencyTracker = new PluginDependencyTracker(); @Inject @@ -106,7 +107,7 @@ public class DefaultPluginManager implements PluginManager { } private Optional getPending(String name) { - return pendingQueue + return pendingInstallQueue .stream() .map(PendingPluginInstallation::getPlugin) .filter(filterByName(name)) @@ -138,6 +139,15 @@ public class DefaultPluginManager implements PluginManager { .collect(Collectors.toList()); } + @Override + public List getUpdatable() { + return getInstalled() + .stream() + .filter(p -> isUpdatable(p.getDescriptor().getInformation().getName())) + .filter(p -> !p.isMarkedForUninstall()) + .collect(Collectors.toList()); + } + private Predicate filterByName(String name) { return plugin -> name.equals(plugin.getDescriptor().getInformation().getName()); } @@ -179,7 +189,7 @@ public class DefaultPluginManager implements PluginManager { if (restartAfterInstallation) { restart("plugin installation"); } else { - pendingQueue.addAll(pendingInstallations); + pendingInstallQueue.addAll(pendingInstallations); updateMayUninstallFlag(); } } @@ -192,10 +202,7 @@ public class DefaultPluginManager implements PluginManager { .orElseThrow(() -> NotFoundException.notFound(entity(InstalledPlugin.class, name))); doThrow().violation("plugin is a core plugin and cannot be uninstalled").when(installed.isCore()); - dependencyTracker.removeInstalled(installed.getDescriptor()); - installed.setMarkedForUninstall(true); - - createMarkerFile(installed, InstalledPlugin.UNINSTALL_MARKER_FILENAME); + markForUninstall(installed); if (restartAfterInstallation) { restart("plugin installation"); @@ -215,18 +222,22 @@ public class DefaultPluginManager implements PluginManager { && dependencyTracker.mayUninstall(p.getDescriptor().getInformation().getName()); } - private void createMarkerFile(InstalledPlugin plugin, String markerFile) { + private void markForUninstall(InstalledPlugin plugin) { + dependencyTracker.removeInstalled(plugin.getDescriptor()); try { - Files.createFile(plugin.getDirectory().resolve(markerFile)); - } catch (IOException e) { - throw new PluginException("could not mark plugin " + plugin.getId() + " in path " + plugin.getDirectory() + "as " + markerFile, e); + Path file = Files.createFile(plugin.getDirectory().resolve(InstalledPlugin.UNINSTALL_MARKER_FILENAME)); + pendingUninstallQueue.add(new PendingPluginUninstallation(plugin, file)); + plugin.setMarkedForUninstall(true); + } catch (Exception e) { + dependencyTracker.addInstalled(plugin.getDescriptor()); + throw new PluginException("could not mark plugin " + plugin.getId() + " in path " + plugin.getDirectory() + "as " + InstalledPlugin.UNINSTALL_MARKER_FILENAME, e); } } @Override public void executePendingAndRestart() { PluginPermissions.manage().check(); - if (!pendingQueue.isEmpty() || getInstalled().stream().anyMatch(InstalledPlugin::isMarkedForUninstall)) { + if (!pendingInstallQueue.isEmpty() || getInstalled().stream().anyMatch(InstalledPlugin::isMarkedForUninstall)) { restart("execute pending plugin changes"); } } @@ -269,4 +280,25 @@ public class DefaultPluginManager implements PluginManager { private boolean isUpdatable(String name) { return getAvailable(name).isPresent() && !getPending(name).isPresent(); } + + @Override + public void cancelPending() { + PluginPermissions.manage().check(); + pendingUninstallQueue.forEach(PendingPluginUninstallation::cancel); + pendingInstallQueue.forEach(PendingPluginInstallation::cancel); + pendingUninstallQueue.clear(); + pendingInstallQueue.clear(); + updateMayUninstallFlag(); + } + + @Override + public void updateAll() { + PluginPermissions.manage().check(); + for (InstalledPlugin installedPlugin : getInstalled()) { + String pluginName = installedPlugin.getDescriptor().getInformation().getName(); + if (isUpdatable(pluginName)) { + install(pluginName, false); + } + } + } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java b/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java index fa59930a78..933121e992 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginInstallation.java @@ -29,7 +29,7 @@ class PendingPluginInstallation { try { Files.delete(file); } catch (IOException ex) { - throw new PluginFailedToCancelInstallationException("failed to cancel installation of plugin " + name, ex); + throw new PluginFailedToCancelInstallationException("failed to cancel plugin installation ", name, ex); } } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginUninstallation.java b/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginUninstallation.java new file mode 100644 index 0000000000..7cbbecd151 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PendingPluginUninstallation.java @@ -0,0 +1,32 @@ +package sonia.scm.plugin; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +class PendingPluginUninstallation { + + private static final Logger LOG = LoggerFactory.getLogger(PendingPluginUninstallation.class); + + private final InstalledPlugin plugin; + private final Path uninstallFile; + + PendingPluginUninstallation(InstalledPlugin plugin, Path uninstallFile) { + this.plugin = plugin; + this.uninstallFile = uninstallFile; + } + + void cancel() { + String name = plugin.getDescriptor().getInformation().getName(); + LOG.info("cancel uninstallation of plugin {}", name); + try { + Files.delete(uninstallFile); + plugin.setMarkedForUninstall(false); + } catch (IOException ex) { + throw new PluginFailedToCancelInstallationException("failed to cancel uninstallation", name, ex); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginDependencyTracker.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginDependencyTracker.java index a68b391b97..2609522555 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginDependencyTracker.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginDependencyTracker.java @@ -33,6 +33,10 @@ class PluginDependencyTracker { } private void removeDependency(String from, String to) { - plugins.get(to).remove(from); + Collection dependencies = plugins.get(to); + if (dependencies == null) { + throw new NullPointerException("inverse dependencies not found for " + to); + } + dependencies.remove(from); } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java index e3d6c123d6..1be5ecdcf3 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginFailedToCancelInstallationException.java @@ -1,7 +1,16 @@ package sonia.scm.plugin; -public class PluginFailedToCancelInstallationException extends RuntimeException { - public PluginFailedToCancelInstallationException(String message, Throwable cause) { - super(message, cause); +import sonia.scm.ExceptionWithContext; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; + +public class PluginFailedToCancelInstallationException extends ExceptionWithContext { + public PluginFailedToCancelInstallationException(String message, String name, Exception cause) { + super(entity("plugin", name).build(), message, cause); + } + + @Override + public String getCode() { + return "65RdZ5atX1"; } } diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index 9c5bb3f5f5..41e5737503 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -175,6 +175,14 @@ "40RaYIeeR1": { "displayName": "Es wurden keine Änderungen durchgeführt", "description": "Das Repository wurde nicht verändert. Daher konnte kein neuer Commit erzeugt werden." + }, + "4iRct4avG1": { + "displayName": "Die Revisionen haben keinen gemeinsamen Ursprung", + "description": "Die Historie der Revisionen hat keinen gemeinsamen Urspung und kann somit auch nicht gegen einen solchen verglichen werden." + }, + "65RdZ5atX1": { + "displayName": "Fehler beim Löschen von Plugin-Dateien", + "description": "Einige Dateien für die Plugin-Deinstallation konnten nicht gelöscht werden. Dieses kann zu Inkonsistenzen führen, so dass der SCM-Manager nicht mehr korrekt starten kann. Bitte prüfen Sie die Logs und bereinigen Sie das Plugin-Verzeichnis des SCM-Managers manuell. Um die Installation eines Plugins abzubrechen, löschen Sie die zugehörige smp Datei aus dem Plugin-Verzeichnis. Um ein Entfernen eines Plugins zu verhindern, entfernen Sie die Datei namens 'uninstall' aus dem entsprechenden Verzeichnis des Plugins." } }, "namespaceStrategies": { diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 1ffffd73b7..77f8d2f4b9 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -175,6 +175,14 @@ "40RaYIeeR1": { "displayName": "No changes were made", "description": "No changes were made to the files of the repository. Therefor no new commit could be created." + }, + "4iRct4avG1": { + "displayName": "The revisions have unrelated histories", + "description": "The revisions have unrelated histories. Therefor there is no common commit to compare with." + }, + "65RdZ5atX1": { + "displayName": "Error removing plugin files", + "description": "Some files to cancel the plugin (un)installation could not be deleted. This can lead to inconsistencies so that the SCM-Manager cannot restart properly. Please check the logs and clean up the plugin folder manually. To cancel the installation of a plugin, remove the corresponding smp file. To cancel the uninstallation, remove the file named 'uninstall' inside the directory for this plugin." } }, "namespaceStrategies": { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DiffResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DiffResourceTest.java index 01d5e22b53..33d4d7e9e1 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DiffResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DiffResourceTest.java @@ -91,7 +91,7 @@ public class DiffResourceTest extends RepositoryTestBase { public void shouldGetDiffs() throws Exception { when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); - when(diffCommandBuilder.retrieveContent(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent()).thenReturn(output -> {}); MockHttpRequest request = MockHttpRequest .get(DIFF_URL + "revision") .accept(VndMediaType.DIFF); @@ -123,7 +123,7 @@ public class DiffResourceTest extends RepositoryTestBase { public void shouldGet404OnMissingRevision() throws Exception { when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); - when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Text", "x")); + when(diffCommandBuilder.retrieveContent()).thenThrow(new NotFoundException("Text", "x")); MockHttpRequest request = MockHttpRequest .get(DIFF_URL + "revision") @@ -139,7 +139,7 @@ public class DiffResourceTest extends RepositoryTestBase { public void shouldGet400OnCrlfInjection() throws Exception { when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); - when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Text", "x")); + when(diffCommandBuilder.retrieveContent()).thenThrow(new NotFoundException("Text", "x")); MockHttpRequest request = MockHttpRequest .get(DIFF_URL + "ny%0D%0ASet-cookie:%20Tamper=3079675143472450634") @@ -153,7 +153,7 @@ public class DiffResourceTest extends RepositoryTestBase { public void shouldGet400OnUnknownFormat() throws Exception { when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); - when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Test", "test")); + when(diffCommandBuilder.retrieveContent()).thenThrow(new NotFoundException("Test", "test")); MockHttpRequest request = MockHttpRequest .get(DIFF_URL + "revision?format=Unknown") @@ -167,7 +167,7 @@ public class DiffResourceTest extends RepositoryTestBase { public void shouldAcceptDiffFormats() throws Exception { when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); - when(diffCommandBuilder.retrieveContent(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent()).thenReturn(output -> {}); Arrays.stream(DiffFormat.values()).map(DiffFormat::name).forEach( this::assertRequestOk diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java index b965c2f2c3..0c1f4235b9 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IncomingRootResourceTest.java @@ -171,7 +171,7 @@ public class IncomingRootResourceTest extends RepositoryTestBase { when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); - when(diffCommandBuilder.retrieveContent(any())).thenReturn(diffCommandBuilder); + when(diffCommandBuilder.retrieveContent()).thenReturn(output -> {}); MockHttpRequest request = MockHttpRequest .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff") .accept(VndMediaType.DIFF); @@ -206,7 +206,7 @@ public class IncomingRootResourceTest extends RepositoryTestBase { when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); - when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Text", "x")); + when(diffCommandBuilder.retrieveContent()).thenThrow(new NotFoundException("Text", "x")); MockHttpRequest request = MockHttpRequest .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff") @@ -223,7 +223,7 @@ public class IncomingRootResourceTest extends RepositoryTestBase { when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); - when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Text", "x")); + when(diffCommandBuilder.retrieveContent()).thenThrow(new NotFoundException("Text", "x")); MockHttpRequest request = MockHttpRequest .get(INCOMING_DIFF_URL + "ny%0D%0ASet-cookie:%20Tamper=3079675143472450634/ny%0D%0ASet-cookie:%20Tamper=3079675143472450634/diff") .accept(VndMediaType.DIFF); @@ -240,7 +240,7 @@ public class IncomingRootResourceTest extends RepositoryTestBase { when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder); when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder); when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder); - when(diffCommandBuilder.retrieveContent(any())).thenThrow(new NotFoundException("Test", "test")); + when(diffCommandBuilder.retrieveContent()).thenThrow(new NotFoundException("Test", "test")); MockHttpRequest request = MockHttpRequest .get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff?format=Unknown") .accept(VndMediaType.DIFF); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java index b9e720fc71..202b2d20f1 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MergeResourceTest.java @@ -3,6 +3,7 @@ package sonia.scm.api.v2.resources; import com.github.sdorra.shiro.SubjectAware; import com.google.common.io.Resources; import com.google.inject.util.Providers; +import org.apache.shiro.subject.PrincipalCollection; import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; import org.jboss.resteasy.core.Dispatcher; @@ -24,6 +25,7 @@ import sonia.scm.repository.api.MergeDryRunCommandResult; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.spi.MergeCommand; +import sonia.scm.user.User; import sonia.scm.web.VndMediaType; import java.net.URL; @@ -32,6 +34,7 @@ import static java.util.Arrays.asList; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static sonia.scm.repository.RepositoryTestData.createHeartOfGold; @@ -105,6 +108,7 @@ public class MergeResourceTest extends RepositoryTestBase { @Test void shouldHandleSuccessfulMerge() throws Exception { when(mergeCommand.merge(any())).thenReturn(MergeCommandResult.success()); + mockUser(); URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); byte[] mergeCommandJson = Resources.toByteArray(url); @@ -122,6 +126,7 @@ public class MergeResourceTest extends RepositoryTestBase { @Test void shouldHandleFailedMerge() throws Exception { when(mergeCommand.merge(any())).thenReturn(MergeCommandResult.failure(asList("file1", "file2"))); + mockUser(); URL url = Resources.getResource("sonia/scm/api/v2/mergeCommand.json"); byte[] mergeCommandJson = Resources.toByteArray(url); @@ -189,5 +194,12 @@ public class MergeResourceTest extends RepositoryTestBase { assertThat(response.getStatus()).isEqualTo(204); } + + + private void mockUser() { + PrincipalCollection collection = mock(PrincipalCollection.class); + when(subject.getPrincipals()).thenReturn(collection); + when(collection.oneByType(User.class)).thenReturn(new User("dummy")); + } } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java index e5587b78cd..da6e1b65d7 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PendingPluginResourceTest.java @@ -34,11 +34,8 @@ import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -74,12 +71,12 @@ class PendingPluginResourceTest { void mockMapper() { lenient().when(mapper.mapAvailable(any())).thenAnswer(invocation -> { PluginDto dto = new PluginDto(); - dto.setName(((AvailablePlugin)invocation.getArgument(0)).getDescriptor().getInformation().getName()); + dto.setName(((AvailablePlugin) invocation.getArgument(0)).getDescriptor().getInformation().getName()); return dto; }); lenient().when(mapper.mapInstalled(any(), any())).thenAnswer(invocation -> { PluginDto dto = new PluginDto(); - dto.setName(((InstalledPlugin)invocation.getArgument(0)).getDescriptor().getInformation().getName()); + dto.setName(((InstalledPlugin) invocation.getArgument(0)).getDescriptor().getInformation().getName()); return dto; }); } @@ -90,7 +87,7 @@ class PendingPluginResourceTest { @BeforeEach void bindSubject() { ThreadContext.bind(subject); - doNothing().when(subject).checkPermission("plugin:manage"); + lenient().when(subject.isPermitted("plugin:manage")).thenReturn(true); } @AfterEach @@ -113,7 +110,7 @@ class PendingPluginResourceTest { } @Test - void shouldGetPendingAvailablePluginListWithInstallLink() throws URISyntaxException, UnsupportedEncodingException { + void shouldGetPendingAvailablePluginListWithInstallAndCancelLink() throws URISyntaxException, UnsupportedEncodingException { AvailablePlugin availablePlugin = createAvailablePlugin("pending-available-plugin"); when(availablePlugin.isPending()).thenReturn(true); when(pluginManager.getAvailable()).thenReturn(singletonList(availablePlugin)); @@ -124,6 +121,7 @@ class PendingPluginResourceTest { assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); assertThat(response.getContentAsString()).contains("\"new\":[{\"name\":\"pending-available-plugin\""); assertThat(response.getContentAsString()).contains("\"execute\":{\"href\":\"/v2/plugins/pending/execute\"}"); + assertThat(response.getContentAsString()).contains("\"cancel\":{\"href\":\"/v2/plugins/pending/cancel\"}"); } @Test @@ -166,6 +164,17 @@ class PendingPluginResourceTest { assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); verify(pluginManager).executePendingAndRestart(); } + + @Test + void shouldCancelPendingPlugins() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.post("/v2/plugins/pending/cancel"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + verify(pluginManager).cancelPending(); + } + } @Nested @@ -174,7 +183,7 @@ class PendingPluginResourceTest { @BeforeEach void bindSubject() { ThreadContext.bind(subject); - doThrow(new ShiroException()).when(subject).checkPermission("plugin:manage"); + when(subject.isPermitted("plugin:manage")).thenReturn(false); } @AfterEach @@ -183,23 +192,18 @@ class PendingPluginResourceTest { } @Test - void shouldNotListPendingPlugins() throws URISyntaxException { + void shouldGetPendingAvailablePluginListWithoutInstallAndCancelLink() throws URISyntaxException, UnsupportedEncodingException { + AvailablePlugin availablePlugin = createAvailablePlugin("pending-available-plugin"); + when(availablePlugin.isPending()).thenReturn(true); + when(pluginManager.getAvailable()).thenReturn(singletonList(availablePlugin)); + MockHttpRequest request = MockHttpRequest.get("/v2/plugins/pending"); - dispatcher.invoke(request, response); - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); - verify(pluginManager, never()).executePendingAndRestart(); - } - - @Test - void shouldNotExecutePendingPlugins() throws URISyntaxException { - MockHttpRequest request = MockHttpRequest.post("/v2/plugins/pending/execute"); - - dispatcher.invoke(request, response); - - assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); - verify(pluginManager, never()).executePendingAndRestart(); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(response.getContentAsString()).contains("\"new\":[{\"name\":\"pending-available-plugin\""); + assertThat(response.getContentAsString()).doesNotContain("\"execute\":{\"href\":\"/v2/plugins/pending/execute\"}"); + assertThat(response.getContentAsString()).doesNotContain("\"cancel\":{\"href\":\"/v2/plugins/pending/cancel\"}"); } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapperTest.java index fb368d12f2..2d62534995 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginDtoCollectionMapperTest.java @@ -10,15 +10,19 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; +import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.plugin.AvailablePlugin; import sonia.scm.plugin.AvailablePluginDescriptor; import sonia.scm.plugin.InstalledPlugin; import sonia.scm.plugin.InstalledPluginDescriptor; import sonia.scm.plugin.PluginInformation; +import sonia.scm.plugin.PluginManager; import java.net.URI; +import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; @@ -34,6 +38,9 @@ class PluginDtoCollectionMapperTest { @InjectMocks PluginDtoMapperImpl pluginDtoMapper; + @Mock + PluginManager manager; + Subject subject = mock(Subject.class); ThreadState subjectThreadState = new SubjectThreadState(subject); @@ -43,6 +50,11 @@ class PluginDtoCollectionMapperTest { ThreadContext.bind(subject); } + @BeforeEach + void mockPluginManager() { + lenient().when(manager.getUpdatable()).thenReturn(new ArrayList<>()); + } + @AfterEach public void unbindSubject() { ThreadContext.unbindSubject(); @@ -51,7 +63,7 @@ class PluginDtoCollectionMapperTest { @Test void shouldMapInstalledPluginsWithoutUpdateWhenNoNewerVersionIsAvailable() { - PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); + PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper, manager); HalRepresentation result = mapper.mapInstalled( singletonList(createInstalledPlugin("scm-some-plugin", "1")), @@ -66,7 +78,7 @@ class PluginDtoCollectionMapperTest { @Test void shouldSetNewVersionInInstalledPluginWhenAvailableVersionIsNewer() { - PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); + PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper,manager); HalRepresentation result = mapper.mapInstalled( singletonList(createInstalledPlugin("scm-some-plugin", "1")), @@ -80,7 +92,7 @@ class PluginDtoCollectionMapperTest { @Test void shouldNotAddInstallLinkForNewVersionWhenNotPermitted() { when(subject.isPermitted("plugin:manage")).thenReturn(false); - PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); + PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper, manager); HalRepresentation result = mapper.mapInstalled( singletonList(createInstalledPlugin("scm-some-plugin", "1")), @@ -93,7 +105,7 @@ class PluginDtoCollectionMapperTest { @Test void shouldNotAddInstallLinkForNewVersionWhenInstallationIsPending() { when(subject.isPermitted("plugin:manage")).thenReturn(true); - PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); + PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper, manager); AvailablePlugin availablePlugin = createAvailablePlugin("scm-some-plugin", "2"); when(availablePlugin.isPending()).thenReturn(true); @@ -108,7 +120,7 @@ class PluginDtoCollectionMapperTest { @Test void shouldAddInstallLinkForNewVersionWhenPermitted() { when(subject.isPermitted("plugin:manage")).thenReturn(true); - PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); + PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper, manager); HalRepresentation result = mapper.mapInstalled( singletonList(createInstalledPlugin("scm-some-plugin", "1")), @@ -121,7 +133,7 @@ class PluginDtoCollectionMapperTest { @Test void shouldSetInstalledPluginPendingWhenCorrespondingAvailablePluginIsPending() { when(subject.isPermitted("plugin:manage")).thenReturn(true); - PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper); + PluginDtoCollectionMapper mapper = new PluginDtoCollectionMapper(resourceLinks, pluginDtoMapper, manager); AvailablePlugin availablePlugin = createAvailablePlugin("scm-some-plugin", "2"); when(availablePlugin.isPending()).thenReturn(true); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java index 057a05eb79..f817476848 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -20,6 +20,8 @@ import sonia.scm.ScmConstraintViolationException; import sonia.scm.event.ScmEventBus; import sonia.scm.lifecycle.RestartEvent; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Optional; @@ -30,11 +32,13 @@ import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static sonia.scm.plugin.PluginTestHelper.createAvailable; @@ -358,6 +362,24 @@ class DefaultPluginManagerTest { verify(mailPlugin).setMarkedForUninstall(true); } + @Test + void shouldNotChangeStateWhenUninstallFileCouldNotBeCreated() { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + InstalledPlugin reviewPlugin = createInstalled("scm-review-plugin"); + when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin")); + + when(reviewPlugin.getDirectory()).thenThrow(new PluginException("when the file could not be written an exception like this is thrown")); + + when(loader.getInstalledPlugins()).thenReturn(asList(mailPlugin, reviewPlugin)); + + manager.computeInstallationDependencies(); + + assertThrows(PluginException.class, () -> manager.uninstall("scm-review-plugin", false)); + + verify(mailPlugin, never()).setMarkedForUninstall(true); + assertThrows(ScmConstraintViolationException.class, () -> manager.uninstall("scm-mail-plugin", false)); + } + @Test void shouldThrowExceptionWhenUninstallingCorePlugin(@TempDirectory.TempDir Path temp) { InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); @@ -427,6 +449,71 @@ class DefaultPluginManagerTest { verify(eventBus).post(any(RestartEvent.class)); } + + @Test + void shouldUndoPendingInstallations(@TempDirectory.TempDir Path temp) throws IOException { + InstalledPlugin mailPlugin = createInstalled("scm-ssh-plugin"); + Path mailPluginPath = temp.resolve("scm-mail-plugin"); + Files.createDirectories(mailPluginPath); + when(mailPlugin.getDirectory()).thenReturn(mailPluginPath); + when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin)); + ArgumentCaptor uninstallCaptor = ArgumentCaptor.forClass(Boolean.class); + doNothing().when(mailPlugin).setMarkedForUninstall(uninstallCaptor.capture()); + + AvailablePlugin git = createAvailable("scm-git-plugin"); + when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); + PendingPluginInstallation gitPendingPluginInformation = mock(PendingPluginInstallation.class); + when(installer.install(git)).thenReturn(gitPendingPluginInformation); + + manager.install("scm-git-plugin", false); + manager.uninstall("scm-ssh-plugin", false); + + manager.cancelPending(); + + assertThat(mailPluginPath.resolve("uninstall")).doesNotExist(); + verify(gitPendingPluginInformation).cancel(); + Boolean lasUninstallMarkerSet = uninstallCaptor.getAllValues().get(uninstallCaptor.getAllValues().size() - 1); + assertThat(lasUninstallMarkerSet).isFalse(); + + Files.createFile(mailPluginPath.resolve("uninstall")); + + manager.cancelPending(); + verify(gitPendingPluginInformation, times(1)).cancel(); + assertThat(mailPluginPath.resolve("uninstall")).exists(); + } + + @Test + void shouldUpdateAllPlugins() { + InstalledPlugin mailPlugin = createInstalled("scm-mail-plugin"); + InstalledPlugin reviewPlugin = createInstalled("scm-review-plugin"); + + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(mailPlugin, reviewPlugin)); + + AvailablePlugin newMailPlugin = createAvailable("scm-mail-plugin", "2.0.0"); + AvailablePlugin newReviewPlugin = createAvailable("scm-review-plugin", "2.0.0"); + + when(center.getAvailable()).thenReturn(ImmutableSet.of(newMailPlugin, newReviewPlugin)); + + manager.updateAll(); + + verify(installer).install(newMailPlugin); + verify(installer).install(newReviewPlugin); + } + + + @Test + void shouldNotUpdateToOldPluginVersions() { + InstalledPlugin scriptPlugin = createInstalled("scm-script-plugin"); + + when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(scriptPlugin)); + AvailablePlugin oldScriptPlugin = createAvailable("scm-script-plugin", "0.9"); + + when(center.getAvailable()).thenReturn(ImmutableSet.of(oldScriptPlugin)); + + manager.updateAll(); + + verify(installer, never()).install(oldScriptPlugin); + } } @Nested @@ -482,5 +569,14 @@ class DefaultPluginManagerTest { assertThrows(AuthorizationException.class, () -> manager.executePendingAndRestart()); } + @Test + void shouldThrowAuthorizationExceptionsForCancelPending() { + assertThrows(AuthorizationException.class, () -> manager.cancelPending()); + } + + @Test + void shouldThrowAuthorizationExceptionsForUpdateAll() { + assertThrows(AuthorizationException.class, () -> manager.updateAll()); + } } } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginTestHelper.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginTestHelper.java index 2bd3dce1c2..00549c360c 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginTestHelper.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginTestHelper.java @@ -14,6 +14,13 @@ public class PluginTestHelper { return createAvailable(information); } + public static AvailablePlugin createAvailable(String name, String version) { + PluginInformation information = new PluginInformation(); + information.setName(name); + information.setVersion(version); + return createAvailable(information); + } + public static InstalledPlugin createInstalled(String name) { PluginInformation information = new PluginInformation(); information.setName(name);
{this.props.description}
{child}