From 0046c78b409d67e0981d6cd331d1d53e99d52c99 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 28 Jan 2021 12:35:18 +0100 Subject: [PATCH] Git import and export (#1507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * create git bundle command * create git unbundle command * Apply suggestions from code review Co-authored-by: René Pfeuffer --- CHANGELOG.md | 1 + .../repository/api/BundleCommandBuilder.java | 56 +++++---- .../scm/repository/spi/BundleCommand.java | 6 +- scm-plugins/scm-git-plugin/build.gradle | 1 + .../scm/repository/spi/GitBundleCommand.java | 110 ++++++++++++++++++ .../spi/GitRepositoryServiceProvider.java | 18 ++- .../repository/spi/GitUnbundleCommand.java | 80 +++++++++++++ .../repository/spi/GitBundleCommandTest.java | 103 ++++++++++++++++ .../spi/GitUnbundleCommandTest.java | 103 ++++++++++++++++ .../scm/repository/spi/SvnBundleCommand.java | 7 ++ .../resources/RepositoryExportResource.java | 61 ++++++---- 11 files changed, 489 insertions(+), 57 deletions(-) create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBundleCommand.java create mode 100644 scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitUnbundleCommand.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBundleCommandTest.java create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitUnbundleCommandTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 411330fb5e..d84623a44c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Font ttf-dejavu included oci image ([#1498](https://github.com/scm-manager/scm-manager/issues/1498)) - Repository import and export with metadata for Subversion ([#1501](https://github.com/scm-manager/scm-manager/pull/1501)) - API for store rename/delete in update steps ([#1505](https://github.com/scm-manager/scm-manager/pull/1505)) +- Add import and export for Git via dump file ([#1507](https://github.com/scm-manager/scm-manager/pull/1507)) ### Changed - Directory name for git LFS files ([#1504](https://github.com/scm-manager/scm-manager/pull/1504)) diff --git a/scm-core/src/main/java/sonia/scm/repository/api/BundleCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/BundleCommandBuilder.java index 20adcad6b5..ad6209c229 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/BundleCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/BundleCommandBuilder.java @@ -51,10 +51,11 @@ import static com.google.common.base.Preconditions.checkNotNull; * @author Sebastian Sdorra * @since 1.43 */ -public final class BundleCommandBuilder -{ +public final class BundleCommandBuilder { - /** logger for BundleCommandBuilder */ + /** + * logger for BundleCommandBuilder + */ private static final Logger logger = LoggerFactory.getLogger(BundleCommandBuilder.class); @@ -63,12 +64,10 @@ public final class BundleCommandBuilder /** * Constructs a new {@link BundleCommandBuilder}. * - * * @param bundleCommand bundle command implementation - * @param repository repository + * @param repository repository */ - BundleCommandBuilder(BundleCommand bundleCommand, Repository repository) - { + BundleCommandBuilder(BundleCommand bundleCommand, Repository repository) { this.bundleCommand = bundleCommand; this.repository = repository; } @@ -79,13 +78,11 @@ public final class BundleCommandBuilder * Dumps the repository to the given {@link File}. * * @param outputFile output file - * * @return bundle response - * * @throws IOException */ public BundleResponse bundle(File outputFile) throws IOException { - checkArgument((outputFile != null) &&!outputFile.exists(), + checkArgument((outputFile != null) && !outputFile.exists(), "file is null or exists already"); BundleCommandRequest request = @@ -100,16 +97,12 @@ public final class BundleCommandBuilder /** * Dumps the repository to the given {@link OutputStream}. * - * * @param outputStream output stream - * * @return bundle response - * * @throws IOException */ public BundleResponse bundle(OutputStream outputStream) - throws IOException - { + throws IOException { checkNotNull(outputStream, "output stream is required"); logger.info("bundle {} to output stream", repository); @@ -122,36 +115,37 @@ public final class BundleCommandBuilder * Dumps the repository to the given {@link ByteSink}. * * @param sink byte sink - * * @return bundle response - * * @throws IOException */ public BundleResponse bundle(ByteSink sink) - throws IOException - { + throws IOException { checkNotNull(sink, "byte sink is required"); logger.info("bundle {} to byte sink", sink); return bundleCommand.bundle(new BundleCommandRequest(sink)); } + /** + * Checks for the file extension of the bundled repository + * + * @return the file extension of the bundle + */ + public String getFileExtension() { + return bundleCommand.getFileExtension(); + } + /** * Converts an {@link OutputStream} into a {@link ByteSink}. * - * * @param outputStream ouput stream to convert - * * @return converted byte sink */ - private ByteSink asByteSink(final OutputStream outputStream) - { - return new ByteSink() - { + private ByteSink asByteSink(final OutputStream outputStream) { + return new ByteSink() { @Override - public OutputStream openStream() throws IOException - { + public OutputStream openStream() throws IOException { return outputStream; } }; @@ -159,9 +153,13 @@ public final class BundleCommandBuilder //~--- fields --------------------------------------------------------------- - /** bundle command implementation */ + /** + * bundle command implementation + */ private final BundleCommand bundleCommand; - /** repository */ + /** + * repository + */ private final Repository repository; } diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/BundleCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/BundleCommand.java index 71830d7f45..d7d34a0b6a 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/BundleCommand.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/BundleCommand.java @@ -51,6 +51,10 @@ public interface BundleCommand * * @throws IOException */ - public BundleResponse bundle(BundleCommandRequest request) + BundleResponse bundle(BundleCommandRequest request) throws IOException; + + default String getFileExtension() { + return "tar"; + } } diff --git a/scm-plugins/scm-git-plugin/build.gradle b/scm-plugins/scm-git-plugin/build.gradle index 9021785a04..d1c5783d4d 100644 --- a/scm-plugins/scm-git-plugin/build.gradle +++ b/scm-plugins/scm-git-plugin/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation "sonia.jgit:org.eclipse.jgit.http.server:${jgitVersion}" implementation "sonia.jgit:org.eclipse.jgit.lfs.server:${jgitVersion}" implementation "sonia.jgit:org.eclipse.jgit.gpg.bc:${jgitVersion}" + implementation libraries.commonsCompress testImplementation libraries.shiroUnit } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBundleCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBundleCommand.java new file mode 100644 index 0000000000..aa9fe002c8 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBundleCommand.java @@ -0,0 +1,110 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package sonia.scm.repository.spi; + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import sonia.scm.ContextEntry; +import sonia.scm.repository.api.BundleResponse; +import sonia.scm.repository.api.ExportFailedException; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +public class GitBundleCommand extends AbstractGitCommand implements BundleCommand { + + private static final String TAR_ARCHIVE = "tar"; + + public GitBundleCommand(GitContext context) { + super(context); + } + + @Override + public BundleResponse bundle(BundleCommandRequest request) throws IOException { + Path repoDir = context.getDirectory().toPath(); + if (Files.exists(repoDir)) { + try (OutputStream os = request.getArchive().openStream(); + BufferedOutputStream bos = new BufferedOutputStream(os); + TarArchiveOutputStream taos = new TarArchiveOutputStream(bos)) { + + createTarEntryForFiles("", repoDir, taos); + taos.finish(); + } + } else { + throw new ExportFailedException( + ContextEntry.ContextBuilder.noContext(), + "Could not export repository. Repository directory does not exist." + ); + } + return new BundleResponse(0); + } + + @Override + public String getFileExtension() { + return TAR_ARCHIVE; + } + + private void createTarEntryForFiles(String path, Path fileOrDir, TarArchiveOutputStream taos) throws IOException { + try (Stream files = Files.list(fileOrDir)) { + if (files != null) { + files + .filter(this::shouldIncludeFile) + .forEach(f -> bundleFileOrDir(path, f, taos)); + } + } + } + + private void bundleFileOrDir(String path, Path fileOrDir, TarArchiveOutputStream taos) { + try { + String filePath = path + "/" + fileOrDir.getFileName().toString(); + if (Files.isDirectory(fileOrDir)) { + createTarEntryForFiles(filePath, fileOrDir, taos); + } else { + createArchiveEntryForFile(filePath, fileOrDir, taos); + } + } catch (IOException e) { + throw new ExportFailedException( + ContextEntry.ContextBuilder.noContext(), + "Could not export repository. Error on bundling files.", + e + ); + } + } + + private boolean shouldIncludeFile(Path filePath) { + return !filePath.getFileName().toString().equals("config"); + } + + private void createArchiveEntryForFile(String filePath, Path path, TarArchiveOutputStream taos) throws IOException { + TarArchiveEntry entry = new TarArchiveEntry(filePath); + entry.setSize(path.toFile().length()); + taos.putArchiveEntry(entry); + Files.copy(path, taos); + taos.closeArchiveEntry(); + } +} 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 d2df924e7d..8d94234dd6 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 @@ -34,11 +34,9 @@ import java.util.EnumSet; import java.util.Set; /** - * * @author Sebastian Sdorra */ -public class GitRepositoryServiceProvider extends RepositoryServiceProvider -{ +public class GitRepositoryServiceProvider extends RepositoryServiceProvider { public static final Set COMMANDS = ImmutableSet.of( Command.BLAME, @@ -56,7 +54,9 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider Command.PUSH, Command.PULL, Command.MERGE, - Command.MODIFY + Command.MODIFY, + Command.BUNDLE, + Command.UNBUNDLE ); protected static final Set FEATURES = EnumSet.of(Feature.INCOMING_REVISION); @@ -161,6 +161,16 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider return commandInjector.getInstance(GitModifyCommand.class); } + @Override + public BundleCommand getBundleCommand() { + return new GitBundleCommand(context); + } + + @Override + public UnbundleCommand getUnbundleCommand() { + return new GitUnbundleCommand(context); + } + @Override public Set getSupportedCommands() { return COMMANDS; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitUnbundleCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitUnbundleCommand.java new file mode 100644 index 0000000000..1e0927cfec --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitUnbundleCommand.java @@ -0,0 +1,80 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package sonia.scm.repository.spi; + +import com.google.common.io.ByteSource; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.repository.api.UnbundleResponse; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class GitUnbundleCommand extends AbstractGitCommand implements UnbundleCommand { + + private static final Logger LOG = LoggerFactory.getLogger(GitUnbundleCommand.class); + + GitUnbundleCommand(GitContext context) { + super(context); + } + + @Override + public UnbundleResponse unbundle(UnbundleCommandRequest request) throws IOException { + ByteSource archive = checkNotNull(request.getArchive(),"archive is required"); + Path repositoryDir = context.getDirectory().toPath(); + LOG.debug("archive repository {} to {}", repositoryDir, archive); + + if (!Files.exists(repositoryDir)) { + Files.createDirectories(repositoryDir); + } + + unbundleRepositoryFromRequest(request, repositoryDir); + return new UnbundleResponse(0); + } + + private void unbundleRepositoryFromRequest(UnbundleCommandRequest request, Path repositoryDir) throws IOException { + try (TarArchiveInputStream tais = new TarArchiveInputStream(request.getArchive().openBufferedStream())) { + TarArchiveEntry entry; + while ((entry = tais.getNextTarEntry()) != null) { + Path filePath = repositoryDir.resolve(entry.getName()); + createDirectoriesIfNestedFile(filePath); + Files.copy(tais, filePath, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + private void createDirectoriesIfNestedFile(Path filePath) throws IOException { + Path directory = filePath.getParent(); + if (!Files.exists(directory)) { + Files.createDirectories(directory); + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBundleCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBundleCommandTest.java new file mode 100644 index 0000000000..e6aa739447 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBundleCommandTest.java @@ -0,0 +1,103 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package sonia.scm.repository.spi; + +import com.google.common.io.ByteSink; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.utils.IOUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GitBundleCommandTest { + + private GitBundleCommand bundleCommand; + + @Test + void shouldBundleRepository(@TempDir Path temp) throws IOException { + Path repoDir = mockGitContextWithRepoDir(temp); + if (!Files.exists(repoDir)) { + Files.createDirectories(repoDir); + } + String content = "readme testdata"; + addFileToRepoDir(repoDir, "README.md", content); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + BundleCommandRequest bundleCommandRequest = createBundleCommandRequest(baos); + bundleCommand.bundle(bundleCommandRequest); + + assertStreamContainsContent(baos, content); + } + + @Test + void shouldReturnTarForGitBundleCommand() { + bundleCommand = new GitBundleCommand(mock(GitContext.class)); + String fileExtension = bundleCommand.getFileExtension(); + assertThat(fileExtension).isEqualTo("tar"); + } + + private void addFileToRepoDir(Path repoDir, String filename, String content) throws IOException { + Path file = repoDir.resolve(filename); + Files.copy(new ByteArrayInputStream(content.getBytes()), file, StandardCopyOption.REPLACE_EXISTING); + } + + private Path mockGitContextWithRepoDir(Path temp) { + GitContext gitContext = mock(GitContext.class); + bundleCommand = new GitBundleCommand(gitContext); + Path repoDir = Paths.get(temp.toString(), "repository"); + when(gitContext.getDirectory()).thenReturn(repoDir.toFile()); + return repoDir; + } + + private BundleCommandRequest createBundleCommandRequest(ByteArrayOutputStream baos) { + ByteSink byteSink = new ByteSink() { + @Override + public OutputStream openStream() { + return baos; + } + }; + return new BundleCommandRequest(byteSink); + } + + private void assertStreamContainsContent(ByteArrayOutputStream baos, String content) throws IOException { + TarArchiveInputStream tais = new TarArchiveInputStream(new BufferedInputStream(new ByteArrayInputStream(baos.toByteArray()))); + tais.getNextEntry(); + + byte[] result = IOUtils.toByteArray(tais); + assertThat(new String(result)).isEqualTo(content); + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitUnbundleCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitUnbundleCommandTest.java new file mode 100644 index 0000000000..fdb29167b2 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitUnbundleCommandTest.java @@ -0,0 +1,103 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package sonia.scm.repository.spi; + +import com.google.common.io.ByteSource; +import com.google.common.io.Files; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.annotation.Nonnull; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GitUnbundleCommandTest extends AbstractGitCommandTestBase { + private GitContext gitContext; + private GitUnbundleCommand unbundleCommand; + + @BeforeEach + void initCommand() { + gitContext = mock(GitContext.class); + unbundleCommand = new GitUnbundleCommand(gitContext); + } + + @Test + void shouldUnbundleRepositoryFiles(@TempDir Path temp) throws IOException { + String filePath = "test-input"; + String fileContent = "HeartOfGold"; + UnbundleCommandRequest unbundleCommandRequest = createUnbundleCommandRequestForFile(temp, filePath, fileContent); + + unbundleCommand.unbundle(unbundleCommandRequest); + + assertFileWithContentWasCreated(temp, filePath, fileContent); + } + + @Test + void shouldUnbundleNestedRepositoryFiles(@TempDir Path temp) throws IOException { + String filePath = "objects/pack/test-input"; + String fileContent = "hitchhiker"; + UnbundleCommandRequest unbundleCommandRequest = createUnbundleCommandRequestForFile(temp, filePath, fileContent); + + unbundleCommand.unbundle(unbundleCommandRequest); + + assertFileWithContentWasCreated(temp, filePath, fileContent); + } + + private void assertFileWithContentWasCreated(@TempDir Path temp, String filePath, String fileContent) throws IOException { + File createdFile = temp.resolve(filePath).toFile(); + assertThat(createdFile).exists(); + assertThat(Files.readLines(createdFile, StandardCharsets.UTF_8).get(0)).isEqualTo(fileContent); + } + + private UnbundleCommandRequest createUnbundleCommandRequestForFile(Path temp, String filePath, String fileContent) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + TarArchiveOutputStream taos = new TarArchiveOutputStream(baos); + addEntry(taos, filePath, fileContent); + taos.finish(); + + when(gitContext.getDirectory()).thenReturn(temp.toFile()); + ByteSource byteSource = ByteSource.wrap(baos.toByteArray()); + UnbundleCommandRequest unbundleCommandRequest = new UnbundleCommandRequest(byteSource); + return unbundleCommandRequest; + } + + private void addEntry(TarArchiveOutputStream taos, String name, String input) throws IOException { + TarArchiveEntry entry = new TarArchiveEntry(name); + byte[] data = input.getBytes(); + entry.setSize(data.length); + taos.putArchiveEntry(entry); + taos.write(data); + taos.closeArchiveEntry(); + } +} diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBundleCommand.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBundleCommand.java index 9537bee326..ff1dbf889f 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBundleCommand.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBundleCommand.java @@ -51,6 +51,8 @@ public class SvnBundleCommand extends AbstractSvnCommand implements BundleCommand { + private static final String DUMP = "dump"; + public SvnBundleCommand(SvnContext context) { super(context); @@ -103,4 +105,9 @@ public class SvnBundleCommand extends AbstractSvnCommand return response; } + + @Override + public String getFileExtension() { + return DUMP; + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryExportResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryExportResource.java index c1e77b1553..4ecde33aa6 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryExportResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryExportResource.java @@ -38,6 +38,7 @@ import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.api.BundleCommandBuilder; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; @@ -183,39 +184,53 @@ public class RepositoryExportResource { } private Response exportRepository(Repository repository, boolean compressed) { - StreamingOutput output = os -> { - try (RepositoryService service = serviceFactory.create(repository)) { - if (compressed) { - GzipCompressorOutputStream gzipCompressorOutputStream = new GzipCompressorOutputStream(os); - service.getBundleCommand().bundle(gzipCompressorOutputStream); - gzipCompressorOutputStream.finish(); - } else { - service.getBundleCommand().bundle(os); + StreamingOutput output; + String fileExtension; + try (final RepositoryService service = serviceFactory.create(repository)) { + BundleCommandBuilder bundleCommand = service.getBundleCommand(); + fileExtension = resolveFileExtension(bundleCommand, compressed); + output = os -> { + try { + if (compressed) { + GzipCompressorOutputStream gzipCompressorOutputStream = new GzipCompressorOutputStream(os); + bundleCommand.bundle(gzipCompressorOutputStream); + gzipCompressorOutputStream.finish(); + } else { + bundleCommand.bundle(os); + } + } catch (IOException e) { + throw new InternalRepositoryException(repository, "repository export failed", e); } - } catch (IOException e) { - throw new InternalRepositoryException(repository, "repository export failed", e); - } - }; + }; + } - return createResponse(repository, compressed, output); + return createResponse(repository, fileExtension, compressed, output); } - private Response createResponse(Repository repository, boolean compressed, StreamingOutput output) { + private Response createResponse(Repository repository, String fileExtension, boolean compressed, StreamingOutput output) { return Response .ok(output, compressed ? "application/x-gzip" : MediaType.APPLICATION_OCTET_STREAM) - .header("content-disposition", createContentDispositionHeaderValue(repository, compressed ? "dump.gz" : "dump")) + .header("content-disposition", createContentDispositionHeaderValue(repository, fileExtension)) .build(); } - private String createContentDispositionHeaderValue(Repository repository, String filetype) { + private String resolveFileExtension(BundleCommandBuilder bundleCommand, boolean compressed) { + if (compressed) { + return bundleCommand.getFileExtension() + ".gz"; + } else { + return bundleCommand.getFileExtension(); + } + } + + private String createContentDispositionHeaderValue(Repository repository, String fileExtension) { String timestamp = createFormattedTimestamp(); - return String.format( - "attachment; filename = %s-%s-%s.%s", - repository.getNamespace(), - repository.getName(), - timestamp, - filetype - ); + return String.format( + "attachment; filename = %s-%s-%s.%s", + repository.getNamespace(), + repository.getName(), + timestamp, + fileExtension + ); } private String createFormattedTimestamp() {