Extract the repository deletion logic so it becomes reusable The code that deletes a repository is generally quite robust and as such it will be very useful for the pull-replication plugin. However, the logic is heavily baked into the plugin itself; it requires archiving settings read from the plugin config, and deletion listeners to notify downstream systems - both are not relevant to the pull-replication plugin (or anything else that might want to just delete a repo). Extract the repo deletion logic into a `RepositoryDelete` class, free from any nuances of the delete-project plugin itself, so it can be reused. The `FilesystemDeleteHandler` is now essentially delegating everything into that class. I've also taken the liberty to add a couple of missing tests for the notifying of downstream systems through the deletion listeners. Bug: Issue 16730 Change-Id: Ib28dbed344ef8a419cf7c318d0e87aadd7dadd5e
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandler.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandler.java index 053b2fc..80d4eea 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandler.java +++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandler.java
@@ -14,156 +14,37 @@ package com.googlesource.gerrit.plugins.deleteproject.fs; -import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE; - -import com.google.common.flogger.FluentLogger; -import com.google.common.io.MoreFiles; import com.google.gerrit.entities.Project; -import com.google.gerrit.extensions.api.changes.NotifyHandling; import com.google.gerrit.extensions.events.ProjectDeletedListener; import com.google.gerrit.extensions.registration.DynamicSet; -import com.google.gerrit.server.git.GitRepositoryManager; import com.google.inject.Inject; import com.googlesource.gerrit.plugins.deleteproject.Configuration; -import com.googlesource.gerrit.plugins.deleteproject.TimeMachine; -import java.io.File; 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 java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import org.apache.commons.io.FileUtils; +import java.util.Optional; import org.eclipse.jgit.errors.RepositoryNotFoundException; -import org.eclipse.jgit.lib.Repository; -import org.eclipse.jgit.lib.RepositoryCache; public class FilesystemDeleteHandler { - private static final FluentLogger log = FluentLogger.forEnclosingClass(); - private static final DateTimeFormatter FORMAT = - DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(ZoneId.of("UTC")); - - private final GitRepositoryManager repoManager; + private final RepositoryDelete repositoryDelete; private final DynamicSet<ProjectDeletedListener> deletedListeners; private final Configuration config; @Inject public FilesystemDeleteHandler( - GitRepositoryManager repoManager, + RepositoryDelete repositoryDelete, DynamicSet<ProjectDeletedListener> deletedListeners, Configuration config) { - this.repoManager = repoManager; + this.repositoryDelete = repositoryDelete; this.deletedListeners = deletedListeners; this.config = config; } public void delete(Project.NameKey project, boolean preserveGitRepository) throws IOException, RepositoryNotFoundException { - // Remove from the jgit cache - Repository repository = repoManager.openRepository(project); - cleanCache(repository); - if (!preserveGitRepository) { - Path repoPath = repository.getDirectory().toPath(); - String projectName = project.get(); - if (config.shouldArchiveDeletedRepos()) { - archiveGitRepository(projectName, repoPath); - } else { - deleteGitRepository(projectName, repoPath); - } - } - } - - private void cleanCache(Repository repository) { - repository.close(); - RepositoryCache.close(repository); - } - - private void archiveGitRepository(String projectName, Path repoPath) throws IOException { - Path basePath = getBasePath(repoPath, projectName); - Path renamedProjectDir = renameRepository(repoPath, basePath, projectName, "archived"); - try { - Path archive = getArchivePath(renamedProjectDir, basePath); - FileUtils.copyDirectory(renamedProjectDir.toFile(), archive.toFile()); - MoreFiles.deleteRecursively(renamedProjectDir, ALLOW_INSECURE); - } catch (IOException e) { - log.atWarning().withCause(e).log("Error trying to archive %s", renamedProjectDir); - } - } - - private Path getArchivePath(Path renamedProjectDir, Path basePath) { - Path configArchiveRepo = config.getArchiveFolder().toAbsolutePath(); - Path relativePath = basePath.relativize(renamedProjectDir); - return configArchiveRepo.resolve(relativePath); - } - - private void deleteGitRepository(String projectName, Path repoPath) throws IOException { - // Delete the repository from disk - Path basePath = getBasePath(repoPath, projectName); - Path trash = renameRepository(repoPath, basePath, projectName, "deleted"); - try { - MoreFiles.deleteRecursively(trash, ALLOW_INSECURE); - recursivelyDeleteEmptyParents(repoPath.toFile().getParentFile(), basePath.toFile()); - } catch (IOException e) { - // Only log if delete failed - repo already moved to trash. - log.atWarning().withCause(e).log("Error trying to delete %s or its parents", trash); - } finally { - sendProjectDeletedEvent(projectName); - } - } - - private Path getBasePath(Path repo, String projectName) { - Path projectPath = Paths.get(projectName); - return repo.getRoot() - .resolve(repo.subpath(0, repo.getNameCount() - projectPath.getNameCount())); - } - - private Path renameRepository(Path directory, Path basePath, String projectName, String option) - throws IOException { - Path newRepo = - basePath.resolve( - projectName + "." + FORMAT.format(TimeMachine.now()) + ".%" + option + "%.git"); - return Files.move(directory, newRepo, StandardCopyOption.ATOMIC_MOVE); - } - - /** - * Recursively delete the specified file and its parent files until we hit the file {@code Until} - * or the parent file is populated. This is used when we have a tree structure such as a/b/c/d.git - * and a/b/e.git - if we delete a/b/c/d.git, we no longer need a/b/c/. - */ - private void recursivelyDeleteEmptyParents(File file, File until) throws IOException { - if (file.equals(until)) { - return; - } - if (file.listFiles().length == 0) { - File parent = file.getParentFile(); - Files.delete(file.toPath()); - recursivelyDeleteEmptyParents(parent, until); - } - } - - private void sendProjectDeletedEvent(String projectName) { - if (!deletedListeners.iterator().hasNext()) { - return; - } - ProjectDeletedListener.Event event = - new ProjectDeletedListener.Event() { - @Override - public String getProjectName() { - return projectName; - } - - @Override - public NotifyHandling getNotify() { - return NotifyHandling.NONE; - } - }; - for (ProjectDeletedListener l : deletedListeners) { - try { - l.onProjectDeleted(event); - } catch (RuntimeException e) { - log.atWarning().withCause(e).log("Failure in ProjectDeletedListener"); - } - } + repositoryDelete.execute( + project, + preserveGitRepository, + config.shouldArchiveDeletedRepos(), + Optional.ofNullable(config.getArchiveFolder()), + deletedListeners); } }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/RepositoryDelete.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/RepositoryDelete.java new file mode 100644 index 0000000..661ac7c --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/RepositoryDelete.java
@@ -0,0 +1,202 @@ +// Copyright (C) 2023 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.googlesource.gerrit.plugins.deleteproject.fs; + +import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE; + +import com.google.common.flogger.FluentLogger; +import com.google.common.io.MoreFiles; +import com.google.gerrit.entities.Project; +import com.google.gerrit.extensions.api.changes.NotifyHandling; +import com.google.gerrit.extensions.events.ProjectDeletedListener; +import com.google.gerrit.extensions.registration.DynamicSet; +import com.google.gerrit.server.git.GitRepositoryManager; +import com.googlesource.gerrit.plugins.deleteproject.TimeMachine; +import java.io.File; +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 java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Optional; +import javax.inject.Inject; +import org.apache.commons.io.FileUtils; +import org.eclipse.jgit.errors.RepositoryNotFoundException; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.RepositoryCache; + +/** + * This class contains methods that remove a git repository from the filesystem and the jgit cache, + * and optionally notify downstream listeners. It can therefore be reused by other plugins who need + * to delete a git repository. + */ +public class RepositoryDelete { + + private final GitRepositoryManager repoManager; + + @Inject + public RepositoryDelete(GitRepositoryManager repoManager) { + this.repoManager = repoManager; + } + + private static final FluentLogger log = FluentLogger.forEnclosingClass(); + private static final DateTimeFormatter FORMAT = + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(ZoneId.of("UTC")); + + /** + * Removes a git repository from the filesystem and the jgit cache and optionally notifies + * downstream listeners. You can choose if the git repo should either be deleted or archived. + * + * <p>In order to delete the git directory, the logic will first rename the directory, a two-step + * process involving moving all the files in a different directory, and immediately deleting that + * directory. This helps release any open file handlers, which would on NFS filesystems prevent + * the directory from being empty (and therefore deletion would fail). For more details see <a + * href="https://bugs.chromium.org/p/gerrit/issues/detail?id=16730">...</a> + * + * @param project - the git repo name that is eligible for deletion + * @param preserveGitRepository - if true, just remove the repo from the git cache, but keep the + * repo on disk. + * @param archiveDeletedRepos - if true, copy the repo to an archived path, and delete the + * original directory. + * @param archivedFolder - only used when `archiveDeletedRepos` is true, provides the archived + * directory. + * @param deletedListeners - a set of `ProjectDeletedListener`s - when provided these listeners + * will be notified when a directory is deleted. This is not used for archiving. + * @throws RepositoryNotFoundException - if the repository does not exist + * @throws IOException - if any of the underlying operations during repo deletion fails + */ + public void execute( + Project.NameKey project, + boolean preserveGitRepository, + boolean archiveDeletedRepos, + Optional<Path> archivedFolder, + DynamicSet<ProjectDeletedListener> deletedListeners) + throws RepositoryNotFoundException, IOException { + Repository repository = repoManager.openRepository(project); + cleanCache(repository); + if (!preserveGitRepository) { + Path repoPath = repository.getDirectory().toPath(); + String projectName = project.get(); + if (archiveDeletedRepos) { + archiveGitRepository(projectName, repoPath, archivedFolder); + } else { + deleteGitRepository(projectName, repoPath, deletedListeners); + } + } + } + + private static void cleanCache(Repository repository) { + repository.close(); + RepositoryCache.close(repository); + } + + private static void archiveGitRepository( + String projectName, Path repoPath, Optional<Path> archivedFolder) throws IOException { + Path basePath = getBasePath(repoPath, projectName); + if (archivedFolder.isEmpty()) { + throw new IllegalArgumentException( + "An archive path must be provided for the " + basePath + " repo to be archived"); + } + Path renamedProjectDir = renameRepository(repoPath, basePath, projectName, "archived"); + try { + Path archive = getArchivePath(archivedFolder.get(), renamedProjectDir, basePath); + FileUtils.copyDirectory(renamedProjectDir.toFile(), archive.toFile()); + MoreFiles.deleteRecursively(renamedProjectDir, ALLOW_INSECURE); + } catch (IOException e) { + log.atWarning().withCause(e).log("Error trying to archive %s", renamedProjectDir); + } + } + + private static Path getArchivePath(Path archivedFolder, Path renamedProjectDir, Path basePath) { + Path configArchiveRepo = archivedFolder.toAbsolutePath(); + Path relativePath = basePath.relativize(renamedProjectDir); + return configArchiveRepo.resolve(relativePath); + } + + private static void deleteGitRepository( + String projectName, Path repoPath, DynamicSet<ProjectDeletedListener> deletedListeners) + throws IOException { + // Delete the repository from disk + Path basePath = getBasePath(repoPath, projectName); + Path trash = renameRepository(repoPath, basePath, projectName, "deleted"); + try { + MoreFiles.deleteRecursively(trash, ALLOW_INSECURE); + recursivelyDeleteEmptyParents(repoPath.toFile().getParentFile(), basePath.toFile()); + } catch (IOException e) { + // Only log if delete failed - repo already moved to trash. + log.atWarning().withCause(e).log("Error trying to delete %s or its parents", trash); + } finally { + sendProjectDeletedEvent(projectName, deletedListeners); + } + } + + private static Path getBasePath(Path repo, String projectName) { + Path projectPath = Paths.get(projectName); + return repo.getRoot() + .resolve(repo.subpath(0, repo.getNameCount() - projectPath.getNameCount())); + } + + private static Path renameRepository( + Path directory, Path basePath, String projectName, String option) throws IOException { + Path newRepo = + basePath.resolve( + projectName + "." + FORMAT.format(TimeMachine.now()) + ".%" + option + "%.git"); + return Files.move(directory, newRepo, StandardCopyOption.ATOMIC_MOVE); + } + + /** + * Recursively delete the specified file and its parent files until we hit the file {@code Until} + * or the parent file is populated. This is used when we have a tree structure such as a/b/c/d.git + * and a/b/e.git - if we delete a/b/c/d.git, we no longer need a/b/c/. + */ + private static void recursivelyDeleteEmptyParents(File file, File until) throws IOException { + if (file.equals(until)) { + return; + } + if (file.listFiles().length == 0) { + File parent = file.getParentFile(); + Files.delete(file.toPath()); + recursivelyDeleteEmptyParents(parent, until); + } + } + + private static void sendProjectDeletedEvent( + String projectName, DynamicSet<ProjectDeletedListener> deletedListeners) { + if (!deletedListeners.iterator().hasNext()) { + return; + } + ProjectDeletedListener.Event event = + new ProjectDeletedListener.Event() { + @Override + public String getProjectName() { + return projectName; + } + + @Override + public NotifyHandling getNotify() { + return NotifyHandling.NONE; + } + }; + for (ProjectDeletedListener l : deletedListeners) { + try { + l.onProjectDeleted(event); + } catch (RuntimeException e) { + log.atWarning().withCause(e).log("Failure in ProjectDeletedListener"); + } + } + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandlerTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandlerTest.java index 9cffd14..317de9b 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandlerTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandlerTest.java
@@ -14,128 +14,54 @@ package com.googlesource.gerrit.plugins.deleteproject.fs; -import static com.google.common.truth.Truth.assertThat; -import static org.mockito.Mockito.when; - import com.google.gerrit.entities.Project; import com.google.gerrit.extensions.events.ProjectDeletedListener; import com.google.gerrit.extensions.registration.DynamicSet; -import com.google.gerrit.server.git.GitRepositoryManager; import com.googlesource.gerrit.plugins.deleteproject.Configuration; -import java.io.IOException; -import java.nio.file.FileSystems; -import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.PathMatcher; -import java.util.stream.Stream; -import org.eclipse.jgit.internal.storage.file.FileRepository; -import org.eclipse.jgit.lib.Repository; +import java.util.Optional; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; @RunWith(MockitoJUnitRunner.class) public class FilesystemDeleteHandlerTest { - @Mock private GitRepositoryManager repoManager; + @Mock private RepositoryDelete repositoryDelete; @Mock private ProjectDeletedListener projectDeleteListener; @Mock private Configuration config; @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); - private DynamicSet<ProjectDeletedListener> deletedListener; - private FilesystemDeleteHandler fsDeleteHandler; + private DynamicSet<ProjectDeletedListener> deletedListeners; private Path basePath; @Before public void setUp() throws Exception { - basePath = tempFolder.newFolder().toPath().resolve("base"); - deletedListener = new DynamicSet<>(); - deletedListener.add("", projectDeleteListener); + basePath = tempFolder.newFolder().toPath().resolve("archive"); + deletedListeners = new DynamicSet<>(); + deletedListeners.add("", projectDeleteListener); } @Test - public void shouldDeleteRepository() throws Exception { - String repoName = "testRepo"; - Repository repository = createRepository(repoName); - Project.NameKey nameKey = Project.nameKey(repoName); - when(repoManager.openRepository(nameKey)).thenReturn(repository); - when(config.shouldArchiveDeletedRepos()).thenReturn(false); - fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config); - fsDeleteHandler.delete(nameKey, false); - assertThat(repository.getDirectory().exists()).isFalse(); - } + public void shouldExtractArchivingParamsFromConfig() throws Exception { + boolean doArchive = true; + Project.NameKey project = Project.NameKey.parse("testProject"); + boolean noPreserveGitRepository = false; - @Test - public void shouldDeleteEmptyParentFolders() throws Exception { - String repoName = "a/b/c"; - Repository repository = createRepository(repoName); - Project.NameKey nameKey = Project.nameKey(repoName); - when(repoManager.openRepository(nameKey)).thenReturn(repository); - fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config); - fsDeleteHandler.delete(nameKey, false); - assertThat(repository.getDirectory().exists()).isFalse(); - } + Mockito.when(config.shouldArchiveDeletedRepos()).thenReturn(doArchive); + Mockito.when(config.getArchiveFolder()).thenReturn(basePath); - @Test - public void shouldKeepCommonFolders() throws Exception { - String repoToDeleteName = "a/b/c/d"; - Repository repoToDelete = createRepository(repoToDeleteName); - - String repoToKeepName = "a/b/e"; - Repository repoToKeep = createRepository(repoToKeepName); - - Project.NameKey nameKey = Project.nameKey(repoToDeleteName); - when(repoManager.openRepository(nameKey)).thenReturn(repoToDelete); - fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config); - fsDeleteHandler.delete(nameKey, false); - assertThat(repoToDelete.getDirectory().exists()).isFalse(); - assertThat(repoToKeep.getDirectory().exists()).isTrue(); - } - - @Test - public void shouldPreserveRepository() throws Exception { - String repoName = "preservedRepo"; - Repository repository = createRepository(repoName); - Project.NameKey nameKey = Project.nameKey(repoName); - when(repoManager.openRepository(nameKey)).thenReturn(repository); - fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config); - fsDeleteHandler.delete(nameKey, true); - assertThat(repository.getDirectory().exists()).isTrue(); - } - - private FileRepository createRepository(String repoName) throws IOException { - Path repoPath = Files.createDirectories(basePath.resolve(repoName)); - Repository repository = new FileRepository(repoPath.toFile()); - repository.create(true); - return (FileRepository) repository; - } - - @Test - public void archiveRepository() throws Exception { - String repoName = "parent_project/p3"; - Repository repository = createRepository(repoName); - Path archiveFolder = basePath.resolve("test_archive"); - when(config.shouldArchiveDeletedRepos()).thenReturn(true); - when(config.getArchiveFolder()).thenReturn(archiveFolder); - Project.NameKey nameKey = Project.nameKey(repoName); - when(repoManager.openRepository(nameKey)).thenReturn(repository); - fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config); - fsDeleteHandler.delete(nameKey, false); - assertThat(repository.getDirectory().exists()).isFalse(); - String patternToVerify = archiveFolder.resolve(repoName).toString() + "*%archived%.git"; - assertThat(pathExistsWithPattern(archiveFolder, patternToVerify)).isTrue(); - } - - private boolean pathExistsWithPattern(Path archiveFolder, String patternToVerify) - throws IOException { - PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + patternToVerify); - try (Stream<Path> stream = Files.walk(archiveFolder)) { - return stream.anyMatch(matcher::matches); - } + FilesystemDeleteHandler filesystemDeleteHandler = + new FilesystemDeleteHandler(repositoryDelete, deletedListeners, config); + filesystemDeleteHandler.delete(project, noPreserveGitRepository); + Mockito.verify(repositoryDelete) + .execute( + project, noPreserveGitRepository, doArchive, Optional.of(basePath), deletedListeners); } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/RepositoryDeleteTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/RepositoryDeleteTest.java new file mode 100644 index 0000000..00799d7 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/RepositoryDeleteTest.java
@@ -0,0 +1,176 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.googlesource.gerrit.plugins.deleteproject.fs; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; + +import com.google.gerrit.entities.Project; +import com.google.gerrit.extensions.events.ProjectDeletedListener; +import com.google.gerrit.extensions.registration.DynamicSet; +import com.google.gerrit.server.git.GitRepositoryManager; +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.Optional; +import java.util.stream.Stream; +import org.eclipse.jgit.internal.storage.file.FileRepository; +import org.eclipse.jgit.lib.Repository; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class RepositoryDeleteTest { + + private static final boolean NO_ARCHIVE = false; + private static final boolean NO_PRESERVE_GIT_REPO = false; + private static final Optional<Path> NO_ARCHIVE_PATH = Optional.empty(); + + @Mock private GitRepositoryManager repoManager; + @Mock private ProjectDeletedListener projectDeleteListener; + + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + private DynamicSet<ProjectDeletedListener> deletedListeners; + private RepositoryDelete repositoryDelete; + private Path basePath; + + @Before + public void setUp() throws Exception { + deletedListeners = new DynamicSet<>(); + basePath = tempFolder.newFolder().toPath().resolve("base"); + } + + @Test + public void shouldDeleteRepository() throws Exception { + String repoName = "testRepo"; + Repository repository = createRepository(repoName); + Project.NameKey nameKey = Project.nameKey(repoName); + when(repoManager.openRepository(nameKey)).thenReturn(repository); + repositoryDelete = new RepositoryDelete(repoManager); + repositoryDelete.execute( + nameKey, NO_PRESERVE_GIT_REPO, NO_ARCHIVE, NO_ARCHIVE_PATH, deletedListeners); + assertThat(repository.getDirectory().exists()).isFalse(); + } + + @Test + public void shouldDeleteEmptyParentFolders() throws Exception { + String repoName = "a/b/c"; + Repository repository = createRepository(repoName); + Project.NameKey nameKey = Project.nameKey(repoName); + when(repoManager.openRepository(nameKey)).thenReturn(repository); + repositoryDelete = new RepositoryDelete(repoManager); + repositoryDelete.execute( + nameKey, NO_PRESERVE_GIT_REPO, NO_ARCHIVE, NO_ARCHIVE_PATH, deletedListeners); + assertThat(repository.getDirectory().exists()).isFalse(); + } + + @Test + public void shouldKeepCommonFolders() throws Exception { + String repoToDeleteName = "a/b/c/d"; + Repository repoToDelete = createRepository(repoToDeleteName); + + String repoToKeepName = "a/b/e"; + Repository repoToKeep = createRepository(repoToKeepName); + + Project.NameKey nameKey = Project.nameKey(repoToDeleteName); + when(repoManager.openRepository(nameKey)).thenReturn(repoToDelete); + repositoryDelete = new RepositoryDelete(repoManager); + repositoryDelete.execute( + nameKey, NO_PRESERVE_GIT_REPO, NO_ARCHIVE, NO_ARCHIVE_PATH, deletedListeners); + assertThat(repoToDelete.getDirectory().exists()).isFalse(); + assertThat(repoToKeep.getDirectory().exists()).isTrue(); + } + + @Test + public void shouldPreserveRepository() throws Exception { + String repoName = "preservedRepo"; + boolean preserveGitRepo = true; + Repository repository = createRepository(repoName); + Project.NameKey nameKey = Project.nameKey(repoName); + when(repoManager.openRepository(nameKey)).thenReturn(repository); + repositoryDelete = new RepositoryDelete(repoManager); + repositoryDelete.execute( + nameKey, preserveGitRepo, NO_ARCHIVE, NO_ARCHIVE_PATH, deletedListeners); + assertThat(repository.getDirectory().exists()).isTrue(); + } + + private FileRepository createRepository(String repoName) throws IOException { + Path repoPath = Files.createDirectories(basePath.resolve(repoName)); + Repository repository = new FileRepository(repoPath.toFile()); + repository.create(true); + return (FileRepository) repository; + } + + @Test + public void archiveRepository() throws Exception { + String repoName = "parent_project/p3"; + Repository repository = createRepository(repoName); + Path archiveFolder = basePath.resolve("test_archive"); + Project.NameKey nameKey = Project.nameKey(repoName); + when(repoManager.openRepository(nameKey)).thenReturn(repository); + repositoryDelete = new RepositoryDelete(repoManager); + repositoryDelete.execute( + nameKey, NO_PRESERVE_GIT_REPO, true, Optional.of(archiveFolder), deletedListeners); + assertThat(repository.getDirectory().exists()).isFalse(); + String patternToVerify = archiveFolder.resolve(repoName).toString() + "*%archived%.git"; + assertThat(pathExistsWithPattern(archiveFolder, patternToVerify)).isTrue(); + } + + @Test + public void shouldNotifyListenersOnSuccessfulRepoDeletion() throws Exception { + String repoName = "testRepo"; + Repository repository = createRepository(repoName); + Project.NameKey nameKey = Project.nameKey(repoName); + when(repoManager.openRepository(nameKey)).thenReturn(repository); + repositoryDelete = new RepositoryDelete(repoManager); + deletedListeners.add("", projectDeleteListener); + repositoryDelete.execute( + nameKey, NO_PRESERVE_GIT_REPO, NO_ARCHIVE, NO_ARCHIVE_PATH, deletedListeners); + Mockito.verify(projectDeleteListener).onProjectDeleted(any()); + } + + @Test + public void shouldNotNotifyListenersIfTheListenersSetIsEmpty() throws Exception { + String repoName = "testRepo"; + Repository repository = createRepository(repoName); + Project.NameKey nameKey = Project.nameKey(repoName); + when(repoManager.openRepository(nameKey)).thenReturn(repository); + repositoryDelete = new RepositoryDelete(repoManager); + repositoryDelete.execute( + nameKey, NO_PRESERVE_GIT_REPO, NO_ARCHIVE, NO_ARCHIVE_PATH, deletedListeners); + Mockito.verify(projectDeleteListener, never()).onProjectDeleted(any()); + } + + private boolean pathExistsWithPattern(Path archiveFolder, String patternToVerify) + throws IOException { + PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + patternToVerify); + try (Stream<Path> stream = Files.walk(archiveFolder)) { + return stream.anyMatch(matcher::matches); + } + } +}