Merge branch 'stable-2.16'
* stable-2.16:
Bazel: Include eclipse-out directory in .bazelignore
Add explanatory comment to empty BUILD file(s)
Update mockito to 2.23.4
Upgrade bazlets to latest stable-2.16 to build with 2.16 API
Always pass ALLOW_INSECURE to MoreFiles.deleteRecursively
ArchiveRepositoryRemoverTest: Refactor assertions on repo content
Format external_plugin_deps.bzl with buildifier
Upgrade bazlets to latest stable-2.14 to build with 2.14.17 API
Upgrade bazlets to latest stable-2.14 to build with 2.14.16 API
Align Eclipse compiler settings with core Gerrit's
WORKSPACE: Make commented out lines spaces indent consistent
Add IT test
FilesystemDeleteHandler: Use more readable name for trash folder
Configure whether to allow user to select preserve
Introduce an archiving mechanism for deleted repos
bazlets: Replace native.git_repository with skylark rule
Harmonize external dependency names to use hyphen
Upgrade mockito to 2.23.0
Update bazlets to latest stable-2.14 to build with 2.14.15 API
Update bazlets to latest stable-2.14 to build with 2.14.14 API
Update bazlets to latest stable-2.14 to build with 2.14.13 API
FilesystemDeleteHandler: Don't instantiate event if there are no listeners
FilesystemDeleteHandler: Rename deletedListener to deletedListeners
DatabaseDeleteHandler.java: Remove unused Provider<InternalChangeQuery>
Add RecursiveDeleteOption.ALLOW_INSECURE for recursive deletion
Simplify DeleteTrashFolder
DeleteTrashFolders: Extract predicate to its own class
Add unit test for DeleteTrashFolders
Update bazlets to latest stable-2.14 to use 2.14.12 API
Migrate `tools/bazel.rc` to `.bazelrc`
Tidy up FilesystemDeleteHandler
Add unit tests for FilesystemDeleteHandler
Extract deletion preconditions to its own class
Adapt DeleteProjectIT to master's AbstractDaemonTest
createProjectOverAPI method, replacing the pre-master createProject
ones.
Change-Id: Ib0fb7be6160c3aabfa763b751bcb682fe2c54346
diff --git a/.bazelignore b/.bazelignore
new file mode 100644
index 0000000..30f1613
--- /dev/null
+++ b/.bazelignore
@@ -0,0 +1 @@
+eclipse-out
diff --git a/BUILD b/BUILD
index e0b0c19..2350be8 100644
--- a/BUILD
+++ b/BUILD
@@ -18,7 +18,7 @@
"Gerrit-SshModule: com.googlesource.gerrit.plugins.deleteproject.SshModule",
],
resource_jars = [":gr-delete-repo-static"],
- resources = glob(["src/main/resources/**/*"]),
+ deps = ["@commons-io//jar"],
)
genrule2(
@@ -57,5 +57,7 @@
visibility = ["//visibility:public"],
exports = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [
":delete-project__plugin",
+ "@commons-io//jar",
+ "@mockito//jar",
],
)
diff --git a/gr-delete-repo/gr-delete-repo.js b/gr-delete-repo/gr-delete-repo.js
index 0ce69c4..10957af 100644
--- a/gr-delete-repo/gr-delete-repo.js
+++ b/gr-delete-repo/gr-delete-repo.js
@@ -39,7 +39,7 @@
},
_handleDeleteRepo() {
- const endpoint = '/projects/' +
+ const endpoint = '/repos/' +
encodeURIComponent(this.repoName) + '/' +
this.actionId;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Configuration.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Configuration.java
index cd11508..e83befc 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Configuration.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Configuration.java
@@ -14,33 +14,64 @@
package com.googlesource.gerrit.plugins.deleteproject;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.stream.Collectors.toList;
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.annotations.PluginData;
import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.config.ConfigUtil;
import com.google.gerrit.server.config.PluginConfig;
import com.google.gerrit.server.config.PluginConfigFactory;
import com.google.inject.Inject;
import com.google.inject.Singleton;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
@Singleton
public class Configuration {
- private static String DELETED_PROJECTS_PARENT = "Deleted-Projects";
+ private static final Logger log = LoggerFactory.getLogger(Configuration.class);
+ private static final String DELETED_PROJECTS_PARENT = "Deleted-Projects";
+ private static final long DEFAULT_ARCHIVE_DURATION_DAYS = 180;
private final boolean allowDeletionWithTags;
+ private final boolean archiveDeletedRepos;
+ private final boolean enablePreserveOption;
private final boolean hideProjectOnPreserve;
+ private final long deleteArchivedReposAfter;
private final String deletedProjectsParent;
+ private final Path archiveFolder;
private final List<Pattern> protectedProjects;
+ private final PluginConfig cfg;
+
+ private File pluginData;
@Inject
- public Configuration(PluginConfigFactory pluginConfigFactory, @PluginName String pluginName) {
- PluginConfig cfg = pluginConfigFactory.getFromGerritConfig(pluginName);
- allowDeletionWithTags = cfg.getBoolean("allowDeletionOfReposWithTags", true);
- hideProjectOnPreserve = cfg.getBoolean("hideProjectOnPreserve", false);
- deletedProjectsParent = cfg.getString("parentForDeletedProjects", DELETED_PROJECTS_PARENT);
- protectedProjects =
+ public Configuration(
+ PluginConfigFactory pluginConfigFactory,
+ @PluginName String pluginName,
+ @PluginData File pluginData) {
+ this.cfg = pluginConfigFactory.getFromGerritConfig(pluginName);
+ this.pluginData = pluginData;
+ this.allowDeletionWithTags = cfg.getBoolean("allowDeletionOfReposWithTags", true);
+ this.hideProjectOnPreserve = cfg.getBoolean("hideProjectOnPreserve", false);
+ this.deletedProjectsParent = cfg.getString("parentForDeletedProjects", DELETED_PROJECTS_PARENT);
+ this.archiveDeletedRepos = cfg.getBoolean("archiveDeletedRepos", false);
+ this.enablePreserveOption = cfg.getBoolean("enablePreserveOption", true);
+ this.archiveFolder =
+ getArchiveFolderFromConfig(cfg.getString("archiveFolder", pluginData.toString()));
+ this.deleteArchivedReposAfter =
+ getArchiveDurationFromConfig(
+ Strings.nullToEmpty(cfg.getString("deleteArchivedReposAfter")));
+ this.protectedProjects =
Arrays.asList(cfg.getStringList("protectedProject"))
.stream()
.map(Pattern::compile)
@@ -62,4 +93,46 @@
public List<Pattern> protectedProjects() {
return protectedProjects;
}
+
+ public boolean shouldArchiveDeletedRepos() {
+ return archiveDeletedRepos;
+ }
+
+ public Path getArchiveFolder() {
+ return archiveFolder;
+ }
+
+ public long getArchiveDuration() {
+ return deleteArchivedReposAfter;
+ }
+
+ public boolean enablePreserveOption() {
+ return enablePreserveOption;
+ }
+
+ private Path getArchiveFolderFromConfig(String configValue) {
+ try {
+ return Files.createDirectories(Paths.get(configValue));
+ } catch (Exception e) {
+ log.warn(
+ "Failed to create folder {}: {}; using default path: {}",
+ configValue,
+ e.getMessage(),
+ pluginData);
+ return pluginData.toPath();
+ }
+ }
+
+ private long getArchiveDurationFromConfig(String configValue) {
+ try {
+ return ConfigUtil.getTimeUnit(
+ configValue, DAYS.toMillis(DEFAULT_ARCHIVE_DURATION_DAYS), MILLISECONDS);
+ } catch (IllegalArgumentException e) {
+ log.warn(
+ "The configured archive duration is not valid: {}; using the default value: {} days",
+ e.getMessage(),
+ DEFAULT_ARCHIVE_DURATION_DAYS);
+ return DAYS.toMillis(DEFAULT_ARCHIVE_DURATION_DAYS);
+ }
+ }
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteAction.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteAction.java
index 19ce6e3..040f7e4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteAction.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteAction.java
@@ -14,18 +14,15 @@
package com.googlesource.gerrit.plugins.deleteproject;
-import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.extensions.webui.UiAction;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.project.ProjectResource;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.googlesource.gerrit.plugins.deleteproject.cache.CacheDeleteHandler;
import com.googlesource.gerrit.plugins.deleteproject.database.DatabaseDeleteHandler;
import com.googlesource.gerrit.plugins.deleteproject.fs.FilesystemDeleteHandler;
-import com.googlesource.gerrit.plugins.deleteproject.projectconfig.ProjectConfigDeleteHandler;
public class DeleteAction extends DeleteProject implements UiAction<ProjectResource> {
private final ProtectedProjects protectedProjects;
@@ -36,25 +33,21 @@
DatabaseDeleteHandler dbHandler,
FilesystemDeleteHandler fsHandler,
CacheDeleteHandler cacheHandler,
- ProjectConfigDeleteHandler pcHandler,
Provider<CurrentUser> userProvider,
- @PluginName String pluginName,
DeleteLog deleteLog,
+ DeletePreconditions preConditions,
Configuration cfg,
HideProject hideProject,
- PermissionBackend permissionBackend,
NotesMigration migration) {
super(
dbHandler,
fsHandler,
cacheHandler,
- pcHandler,
userProvider,
- pluginName,
deleteLog,
+ preConditions,
cfg,
hideProject,
- permissionBackend,
migration);
this.protectedProjects = protectedProjects;
}
@@ -68,6 +61,6 @@
? String.format("Not allowed to delete %s", rsrc.getName())
: String.format("Delete project %s", rsrc.getName()))
.setEnabled(!protectedProjects.isProtected(rsrc))
- .setVisible(canDelete(rsrc));
+ .setVisible(preConditions.canDelete(rsrc));
}
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteCommand.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteCommand.java
index a804b3f..9fc119e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteCommand.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteCommand.java
@@ -22,12 +22,19 @@
import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
import java.io.IOException;
-import java.util.Collection;
import org.kohsuke.args4j.Argument;
import org.kohsuke.args4j.Option;
@CommandMetaData(name = "delete", description = "Delete specific project")
public final class DeleteCommand extends SshCommand {
+ private static final String FORCE_DELETE =
+ "%s - To really delete '%s', re-run with the --force flag.";
+ private static final String REALLY_DELETE =
+ "Really delete '%s'?\n"
+ + "This is an operation which permanently deletes data. This cannot be undone!\n"
+ + "If you are sure you wish to delete this project, re-run with the "
+ + "--yes-really-delete flag.\n";
+
@Argument(index = 0, required = true, metaVar = "NAME", usage = "project to delete")
private ProjectState projectState;
@@ -40,56 +47,48 @@
@Option(name = "--preserve-git-repository", usage = "don't delete git repository directory")
private boolean preserveGitRepository = false;
+ private final Configuration cfg;
private final DeleteProject deleteProject;
+ private final DeletePreconditions preConditions;
@Inject
- protected DeleteCommand(DeleteProject deleteProject) {
+ protected DeleteCommand(
+ Configuration cfg, DeleteProject deleteProject, DeletePreconditions preConditions) {
+ this.cfg = cfg;
this.deleteProject = deleteProject;
+ this.preConditions = preConditions;
}
@Override
public void run() throws Failure {
try {
+ if (preserveGitRepository && !cfg.enablePreserveOption()) {
+ throw new UnloggedFailure(
+ "Given the enablePreserveOption is configured to be false, "
+ + "the --preserve-git-repository option is not allowed.\n"
+ + "Please remove this option and retry.");
+ }
+
DeleteProject.Input input = new DeleteProject.Input();
input.force = force;
input.preserve = preserveGitRepository;
ProjectResource rsrc = new ProjectResource(projectState, user);
- deleteProject.assertDeletePermission(rsrc);
- deleteProject.assertCanDelete(rsrc, input);
+ preConditions.assertDeletePermission(rsrc);
if (!yesReallyDelete) {
- StringBuilder msgBuilder = new StringBuilder();
- msgBuilder.append("Really delete ");
- msgBuilder.append(rsrc.getName());
- msgBuilder.append("?\n");
- msgBuilder.append("This is an operation which permanently deletes ");
- msgBuilder.append("data. This cannot be undone!\n");
- msgBuilder.append("If you are sure you wish to delete this project, ");
- msgBuilder.append("re-run\n");
- msgBuilder.append("with the --yes-really-delete flag.\n");
- throw new UnloggedFailure(msgBuilder.toString());
+ throw new UnloggedFailure(String.format(REALLY_DELETE, rsrc.getName()));
}
if (!force) {
- Collection<String> warnings = deleteProject.getWarnings(rsrc);
- if (warnings != null && !warnings.isEmpty()) {
- StringBuilder msgBuilder = new StringBuilder();
- msgBuilder.append("There are warnings against deleting ");
- msgBuilder.append(rsrc.getName());
- msgBuilder.append(":\n");
- for (String warning : warnings) {
- msgBuilder.append(" * ");
- msgBuilder.append(warning);
- msgBuilder.append("\n");
- }
- msgBuilder.append("To really delete ");
- msgBuilder.append(rsrc.getName());
- msgBuilder.append(", re-run with the --force flag.");
- throw new UnloggedFailure(msgBuilder.toString());
+ try {
+ preConditions.assertHasOpenChanges(rsrc.getNameKey(), false);
+ } catch (CannotDeleteProjectException e) {
+ throw new UnloggedFailure(String.format(FORCE_DELETE, e.getMessage(), rsrc.getName()));
}
}
+ preConditions.assertCanBeDeleted(rsrc, input);
deleteProject.doDelete(rsrc, input);
} catch (RestApiException | OrmException | IOException e) {
throw die(e);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteLog.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteLog.java
index bc5a23a..374bb14 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteLog.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteLog.java
@@ -18,9 +18,9 @@
import com.google.common.collect.MultimapBuilder;
import com.google.gerrit.extensions.systemstatus.ServerInformation;
import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.AuditEvent;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.OutputFormat;
-import com.google.gerrit.server.AuditEvent;
import com.google.gerrit.server.audit.AuditService;
import com.google.gerrit.server.util.PluginLogFile;
import com.google.gerrit.server.util.SystemLog;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeletePreconditions.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeletePreconditions.java
new file mode 100644
index 0000000..a920639
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeletePreconditions.java
@@ -0,0 +1,191 @@
+// Copyright (C) 2018 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;
+
+import static com.googlesource.gerrit.plugins.deleteproject.DeleteOwnProjectCapability.DELETE_OWN_PROJECT;
+import static com.googlesource.gerrit.plugins.deleteproject.DeleteProjectCapability.DELETE_PROJECT;
+import static java.util.stream.Collectors.joining;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.project.ListChildProjects;
+import com.google.gerrit.server.submit.MergeOpRepoManager;
+import com.google.gerrit.server.submit.SubmoduleException;
+import com.google.gerrit.server.submit.SubmoduleOp;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.deleteproject.DeleteProject.Input;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+class DeletePreconditions {
+ private final Configuration config;
+ private final Provider<ListChildProjects> listChildProjectsProvider;
+ private final Provider<MergeOpRepoManager> mergeOpProvider;
+ private final String pluginName;
+ private final Provider<InternalChangeQuery> queryProvider;
+ private final GitRepositoryManager repoManager;
+ private final SubmoduleOp.Factory subOpFactory;
+ private final Provider<CurrentUser> userProvider;
+ private final ProtectedProjects protectedProjects;
+ private final PermissionBackend permissionBackend;
+
+ @Inject
+ public DeletePreconditions(
+ Configuration config,
+ Provider<ListChildProjects> listChildProjectsProvider,
+ Provider<MergeOpRepoManager> mergeOpProvider,
+ @PluginName String pluginName,
+ Provider<InternalChangeQuery> queryProvider,
+ GitRepositoryManager repoManager,
+ SubmoduleOp.Factory subOpFactory,
+ Provider<CurrentUser> userProvider,
+ ProtectedProjects protectedProjects,
+ PermissionBackend permissionBackend) {
+ this.config = config;
+ this.listChildProjectsProvider = listChildProjectsProvider;
+ this.mergeOpProvider = mergeOpProvider;
+ this.pluginName = pluginName;
+ this.queryProvider = queryProvider;
+ this.repoManager = repoManager;
+ this.subOpFactory = subOpFactory;
+ this.userProvider = userProvider;
+ this.protectedProjects = protectedProjects;
+ this.permissionBackend = permissionBackend;
+ }
+
+ void assertDeletePermission(ProjectResource rsrc) throws AuthException {
+ if (!canDelete(rsrc)) {
+ throw new AuthException("not allowed to delete project");
+ }
+ }
+
+ protected boolean canDelete(ProjectResource rsrc) {
+ PermissionBackend.WithUser userPermission = permissionBackend.user(userProvider.get());
+ return userPermission.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER)
+ || userPermission.testOrFalse(new PluginPermission(pluginName, DELETE_PROJECT))
+ || (userPermission.testOrFalse(new PluginPermission(pluginName, DELETE_OWN_PROJECT))
+ && userPermission
+ .project(rsrc.getNameKey())
+ .testOrFalse(ProjectPermission.WRITE_CONFIG));
+ }
+
+ void assertCanBeDeleted(ProjectResource rsrc, Input input) throws ResourceConflictException {
+ try {
+ protectedProjects.assertIsNotProtected(rsrc);
+ assertHasNoChildProjects(rsrc);
+ Project.NameKey projectNameKey = rsrc.getNameKey();
+ assertIsNotSubmodule(projectNameKey);
+ assertDeleteWithTags(projectNameKey, input != null && input.preserve);
+ assertHasOpenChanges(projectNameKey, input != null && input.force);
+ } catch (CannotDeleteProjectException e) {
+ throw new ResourceConflictException(e.getMessage());
+ }
+ }
+
+ public void assertHasOpenChanges(Project.NameKey projectNameKey, boolean force)
+ throws CannotDeleteProjectException {
+ if (!force) {
+ try {
+ List<ChangeData> openChanges = queryProvider.get().byProjectOpen(projectNameKey);
+ if (!openChanges.isEmpty()) {
+ throw new CannotDeleteProjectException(
+ String.format("Project '%s' has open changes.", projectNameKey.get()));
+ }
+ } catch (OrmException e) {
+ throw new CannotDeleteProjectException(
+ String.format("Unable to verify if '%s' has open changes.", projectNameKey.get()));
+ }
+ }
+ }
+
+ private void assertHasNoChildProjects(ProjectResource rsrc) throws CannotDeleteProjectException {
+ try {
+ List<ProjectInfo> children = listChildProjectsProvider.get().apply(rsrc);
+ if (!children.isEmpty()) {
+ throw new CannotDeleteProjectException(
+ "Cannot delete project because it has children: "
+ + children.stream().map(info -> info.name).collect(joining(",")));
+ }
+ } catch (PermissionBackendException | ResourceConflictException e) {
+ throw new CannotDeleteProjectException(
+ String.format("Unable to verify if '%s' has children projects.", rsrc.getName()));
+ }
+ }
+
+ private void assertIsNotSubmodule(Project.NameKey projectNameKey)
+ throws CannotDeleteProjectException {
+ try (Repository repo = repoManager.openRepository(projectNameKey);
+ MergeOpRepoManager mergeOp = mergeOpProvider.get()) {
+ Set<Branch.NameKey> branches = new HashSet<>();
+ for (Ref ref : repo.getRefDatabase().getRefs(RefNames.REFS_HEADS).values()) {
+ branches.add(new Branch.NameKey(projectNameKey, ref.getName()));
+ }
+ SubmoduleOp sub = subOpFactory.create(branches, mergeOp);
+ for (Branch.NameKey b : branches) {
+ if (!sub.superProjectSubscriptionsForSubmoduleBranch(b).isEmpty()) {
+ throw new CannotDeleteProjectException("Project is subscribed by other projects.");
+ }
+ }
+ } catch (RepositoryNotFoundException e) {
+ // we're trying to delete the repository,
+ // so this exception should not stop us
+ } catch (IOException | SubmoduleException e) {
+ throw new CannotDeleteProjectException("Project is subscribed by other projects.");
+ }
+ }
+
+ private void assertDeleteWithTags(Project.NameKey projectNameKey, boolean preserveGitRepository)
+ throws CannotDeleteProjectException {
+ if (!preserveGitRepository && !config.deletionWithTagsAllowed()) {
+ assertHasNoTags(projectNameKey);
+ }
+ }
+
+ private void assertHasNoTags(Project.NameKey projectNameKey) throws CannotDeleteProjectException {
+ try (Repository repo = repoManager.openRepository(projectNameKey)) {
+ if (!repo.getRefDatabase().getRefs(Constants.R_TAGS).isEmpty()) {
+ throw new CannotDeleteProjectException(
+ String.format("Project %s has tags", projectNameKey));
+ }
+ } catch (IOException e) {
+ throw new CannotDeleteProjectException(e);
+ }
+ }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProject.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProject.java
index 79f57ec..e3fa6b9 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProject.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProject.java
@@ -14,13 +14,6 @@
package com.googlesource.gerrit.plugins.deleteproject;
-import static com.googlesource.gerrit.plugins.deleteproject.DeleteOwnProjectCapability.DELETE_OWN_PROJECT;
-import static com.googlesource.gerrit.plugins.deleteproject.DeleteProjectCapability.DELETE_PROJECT;
-
-import com.google.gerrit.extensions.annotations.PluginName;
-import com.google.gerrit.extensions.api.access.PluginPermission;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
@@ -29,9 +22,6 @@
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.ProjectPermission;
import com.google.gerrit.server.project.ProjectResource;
import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
@@ -41,9 +31,7 @@
import com.googlesource.gerrit.plugins.deleteproject.cache.CacheDeleteHandler;
import com.googlesource.gerrit.plugins.deleteproject.database.DatabaseDeleteHandler;
import com.googlesource.gerrit.plugins.deleteproject.fs.FilesystemDeleteHandler;
-import com.googlesource.gerrit.plugins.deleteproject.projectconfig.ProjectConfigDeleteHandler;
import java.io.IOException;
-import java.util.Collection;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
@Singleton
@@ -53,16 +41,15 @@
boolean force;
}
+ protected final DeletePreconditions preConditions;
+
private final DatabaseDeleteHandler dbHandler;
private final FilesystemDeleteHandler fsHandler;
private final CacheDeleteHandler cacheHandler;
- private final ProjectConfigDeleteHandler pcHandler;
private final Provider<CurrentUser> userProvider;
- private final String pluginName;
private final DeleteLog deleteLog;
private final Configuration cfg;
private final HideProject hideProject;
- private PermissionBackend permissionBackend;
private NotesMigration migration;
@Inject
@@ -70,74 +57,33 @@
DatabaseDeleteHandler dbHandler,
FilesystemDeleteHandler fsHandler,
CacheDeleteHandler cacheHandler,
- ProjectConfigDeleteHandler pcHandler,
Provider<CurrentUser> userProvider,
- @PluginName String pluginName,
DeleteLog deleteLog,
+ DeletePreconditions preConditions,
Configuration cfg,
HideProject hideProject,
- PermissionBackend permissionBackend,
NotesMigration migration) {
this.dbHandler = dbHandler;
this.fsHandler = fsHandler;
this.cacheHandler = cacheHandler;
- this.pcHandler = pcHandler;
this.userProvider = userProvider;
- this.pluginName = pluginName;
this.deleteLog = deleteLog;
+ this.preConditions = preConditions;
this.cfg = cfg;
this.hideProject = hideProject;
- this.permissionBackend = permissionBackend;
this.migration = migration;
}
@Override
public Object apply(ProjectResource rsrc, Input input)
throws OrmException, IOException, RestApiException {
- assertDeletePermission(rsrc);
- assertCanDelete(rsrc, input);
-
- if (input == null || !input.force) {
- Collection<String> warnings = getWarnings(rsrc);
- if (!warnings.isEmpty()) {
- throw new ResourceConflictException(
- String.format("Project %s has open changes", rsrc.getName()));
- }
- }
+ preConditions.assertDeletePermission(rsrc);
+ preConditions.assertCanBeDeleted(rsrc, input);
doDelete(rsrc, input);
return Response.none();
}
- public void assertDeletePermission(ProjectResource rsrc) throws AuthException {
- if (!canDelete(rsrc)) {
- throw new AuthException("not allowed to delete project");
- }
- }
-
- protected boolean canDelete(ProjectResource rsrc) {
- PermissionBackend.WithUser userPermission = permissionBackend.currentUser();
- PermissionBackend.ForProject projectPermission = userPermission.project(rsrc.getNameKey());
- return userPermission.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER)
- || userPermission.testOrFalse(new PluginPermission(pluginName, DELETE_PROJECT))
- || (userPermission.testOrFalse(new PluginPermission(pluginName, DELETE_OWN_PROJECT))
- && projectPermission.testOrFalse(ProjectPermission.WRITE_CONFIG));
- }
-
- public void assertCanDelete(ProjectResource rsrc, Input input) throws ResourceConflictException {
- try {
- pcHandler.assertCanDelete(rsrc);
- dbHandler.assertCanDelete(rsrc.getProjectState().getProject());
- fsHandler.assertCanDelete(rsrc, input == null ? false : input.preserve);
- } catch (CannotDeleteProjectException e) {
- throw new ResourceConflictException(e.getMessage());
- }
- }
-
- public Collection<String> getWarnings(ProjectResource rsrc) throws OrmException {
- return dbHandler.getWarnings(rsrc.getProjectState().getProject());
- }
-
public void doDelete(ProjectResource rsrc, Input input)
throws OrmException, IOException, RestApiException {
Project project = rsrc.getProjectState().getProject();
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/HttpModule.java
index f07ef52..8feca79 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/HttpModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/HttpModule.java
@@ -18,13 +18,26 @@
import com.google.gerrit.extensions.webui.JavaScriptPlugin;
import com.google.gerrit.extensions.webui.WebUiPlugin;
import com.google.gerrit.httpd.plugins.HttpPluginModule;
+import com.google.inject.Inject;
public class HttpModule extends HttpPluginModule {
+ private final Configuration cfg;
+
+ @Inject
+ HttpModule(Configuration cfg) {
+ this.cfg = cfg;
+ }
+
@Override
protected void configureServlets() {
- DynamicSet.bind(binder(), WebUiPlugin.class)
- .toInstance(new JavaScriptPlugin("delete-project.js"));
- DynamicSet.bind(binder(), WebUiPlugin.class)
- .toInstance(new JavaScriptPlugin("gr-delete-repo.html"));
+ if (cfg.enablePreserveOption()) {
+ DynamicSet.bind(binder(), WebUiPlugin.class)
+ .toInstance(new JavaScriptPlugin("delete-project.js"));
+ DynamicSet.bind(binder(), WebUiPlugin.class)
+ .toInstance(new JavaScriptPlugin("gr-delete-repo.html"));
+ } else {
+ DynamicSet.bind(binder(), WebUiPlugin.class)
+ .toInstance(new JavaScriptPlugin("delete-project-with-preserve-disabled.js"));
+ }
}
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Module.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Module.java
index 857555e..4826666 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/Module.java
@@ -23,15 +23,23 @@
import com.google.gerrit.extensions.events.LifecycleListener;
import com.google.gerrit.extensions.restapi.RestApiModule;
import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
import com.google.inject.internal.UniqueAnnotations;
import com.googlesource.gerrit.plugins.deleteproject.cache.CacheDeleteHandler;
import com.googlesource.gerrit.plugins.deleteproject.database.DatabaseDeleteHandler;
+import com.googlesource.gerrit.plugins.deleteproject.fs.ArchiveRepositoryRemover;
import com.googlesource.gerrit.plugins.deleteproject.fs.DeleteTrashFolders;
import com.googlesource.gerrit.plugins.deleteproject.fs.FilesystemDeleteHandler;
-import com.googlesource.gerrit.plugins.deleteproject.projectconfig.ProjectConfigDeleteHandler;
public class Module extends AbstractModule {
+ private final boolean scheduleCleaning;
+
+ @Inject
+ Module(Configuration config) {
+ this.scheduleCleaning = config.getArchiveDuration() > 0;
+ }
+
@Override
protected void configure() {
bind(LifecycleListener.class).annotatedWith(UniqueAnnotations.create()).to(DeleteLog.class);
@@ -47,7 +55,13 @@
.to(DeleteOwnProjectCapability.class);
bind(DatabaseDeleteHandler.class);
bind(FilesystemDeleteHandler.class);
- bind(ProjectConfigDeleteHandler.class);
+ bind(DeletePreconditions.class);
+ if (scheduleCleaning) {
+ bind(LifecycleListener.class)
+ .annotatedWith(UniqueAnnotations.create())
+ .to(ArchiveRepositoryRemover.class);
+ }
+
install(
new RestApiModule() {
@Override
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/database/DatabaseDeleteHandler.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/database/DatabaseDeleteHandler.java
index 5db7fe5..fa4296d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/database/DatabaseDeleteHandler.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/database/DatabaseDeleteHandler.java
@@ -16,14 +16,11 @@
import static java.util.Collections.singleton;
-import com.google.common.collect.Lists;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.reviewdb.server.ReviewDbUtil;
import com.google.gerrit.server.StarredChangesUtil;
@@ -32,35 +29,22 @@
import com.google.gerrit.server.account.AccountsUpdate;
import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
import com.google.gerrit.server.change.AccountPatchReviewStore;
-import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.index.change.ChangeIndexer;
import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.query.account.InternalAccountQuery;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.submit.MergeOpRepoManager;
-import com.google.gerrit.server.submit.SubmoduleException;
-import com.google.gerrit.server.submit.SubmoduleOp;
import com.google.gwtorm.jdbc.JdbcSchema;
import com.google.gwtorm.server.OrmException;
import com.google.gwtorm.server.ResultSet;
import com.google.inject.Inject;
import com.google.inject.Provider;
-import com.googlesource.gerrit.plugins.deleteproject.CannotDeleteProjectException;
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.ArrayList;
-import java.util.Collection;
import java.util.Collections;
-import java.util.HashSet;
import java.util.List;
-import java.util.Set;
import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -68,10 +52,6 @@
private static final Logger log = LoggerFactory.getLogger(DatabaseDeleteHandler.class);
private final Provider<ReviewDb> dbProvider;
- private final Provider<InternalChangeQuery> queryProvider;
- private final GitRepositoryManager repoManager;
- private final SubmoduleOp.Factory subOpFactory;
- private final Provider<MergeOpRepoManager> ormProvider;
private final StarredChangesUtil starredChangesUtil;
private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
private final ChangeIndexer indexer;
@@ -81,20 +61,12 @@
@Inject
public DatabaseDeleteHandler(
Provider<ReviewDb> dbProvider,
- Provider<InternalChangeQuery> queryProvider,
- GitRepositoryManager repoManager,
- SubmoduleOp.Factory subOpFactory,
- Provider<MergeOpRepoManager> ormProvider,
StarredChangesUtil starredChangesUtil,
DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
ChangeIndexer indexer,
Provider<InternalAccountQuery> accountQueryProvider,
@UserInitiated Provider<AccountsUpdate> accountsUpdateProvider) {
this.dbProvider = dbProvider;
- this.queryProvider = queryProvider;
- this.repoManager = repoManager;
- this.subOpFactory = subOpFactory;
- this.ormProvider = ormProvider;
this.starredChangesUtil = starredChangesUtil;
this.accountPatchReviewStore = accountPatchReviewStore;
this.indexer = indexer;
@@ -102,18 +74,6 @@
this.accountsUpdateProvider = accountsUpdateProvider;
}
- public Collection<String> getWarnings(Project project) throws OrmException {
- Collection<String> ret = Lists.newArrayList();
-
- // Warn against open changes
- List<ChangeData> openChanges = queryProvider.get().byProjectOpen(project.getNameKey());
- if (openChanges.iterator().hasNext()) {
- ret.add(project.getName() + " has open changes");
- }
-
- return ret;
- }
-
public void delete(Project project) throws OrmException {
ReviewDb db = ReviewDbUtil.unwrapDb(dbProvider.get());
Connection conn = ((JdbcSchema) db).getConnection();
@@ -188,31 +148,6 @@
}
}
- public void assertCanDelete(Project project) throws CannotDeleteProjectException {
-
- Project.NameKey proj = project.getNameKey();
- try (Repository repo = repoManager.openRepository(proj);
- MergeOpRepoManager orm = ormProvider.get()) {
- Set<Branch.NameKey> branches = new HashSet<>();
- for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS)) {
- branches.add(new Branch.NameKey(proj, ref.getName()));
- }
- SubmoduleOp sub = subOpFactory.create(branches, orm);
- for (Branch.NameKey b : branches) {
- if (!sub.superProjectSubscriptionsForSubmoduleBranch(b).isEmpty()) {
- throw new CannotDeleteProjectException("Project is subscribed by other projects.");
- }
- }
- } catch (RepositoryNotFoundException e) {
- // we're trying to delete the repository,
- // so this exception should not stop us
- } catch (SubmoduleException e) {
- throw new CannotDeleteProjectException("Project has submodule.");
- } catch (IOException e) {
- throw new CannotDeleteProjectException("Project is subscribed by other projects.");
- }
- }
-
public void atomicDelete(ReviewDb db, Project project, List<Change.Id> changeIds)
throws OrmException {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/ArchiveRepositoryRemover.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/ArchiveRepositoryRemover.java
new file mode 100644
index 0000000..4cbf83b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/ArchiveRepositoryRemover.java
@@ -0,0 +1,131 @@
+// Copyright (C) 2018 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 static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.common.io.MoreFiles;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+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.attribute.FileTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class ArchiveRepositoryRemover implements LifecycleListener {
+
+ private final WorkQueue queue;
+ private final Provider<RepositoryCleanupTask> repositoryCleanupTaskProvider;
+ private ScheduledFuture<?> scheduledCleanupTask;
+
+ @Inject
+ ArchiveRepositoryRemover(
+ WorkQueue queue, Provider<RepositoryCleanupTask> repositoryCleanupTaskProvider) {
+ this.queue = queue;
+ this.repositoryCleanupTaskProvider = repositoryCleanupTaskProvider;
+ }
+
+ @Override
+ public void start() {
+ scheduledCleanupTask =
+ queue
+ .getDefaultQueue()
+ .scheduleAtFixedRate(
+ repositoryCleanupTaskProvider.get(),
+ SECONDS.toMillis(1),
+ TimeUnit.DAYS.toMillis(1),
+ MILLISECONDS);
+ }
+
+ @Override
+ public void stop() {
+ if (scheduledCleanupTask != null) {
+ scheduledCleanupTask.cancel(true);
+ scheduledCleanupTask = null;
+ }
+ }
+}
+
+class RepositoryCleanupTask implements Runnable {
+ private static final Logger logger = LoggerFactory.getLogger(RepositoryCleanupTask.class);
+
+ private final Configuration config;
+ private final String pluginName;
+
+ @Inject
+ RepositoryCleanupTask(Configuration config, @PluginName String pluginName) {
+ this.config = config;
+ this.pluginName = pluginName;
+ }
+
+ @Override
+ public void run() {
+ logger.info("Cleaning up expired git repositories...");
+ cleanUpOverdueRepositories();
+ logger.info("Cleaning up expired git repositories... Done");
+ }
+
+ @Override
+ public String toString() {
+ return String.format(
+ "[%s]: Clean up expired git repositories from the archive [%s]",
+ pluginName, config.getArchiveFolder());
+ }
+
+ private void cleanUpOverdueRepositories() {
+ for (Path path : listOverdueFiles(config.getArchiveDuration())) {
+ try {
+ MoreFiles.deleteRecursively(path, ALLOW_INSECURE);
+ } catch (IOException e) {
+ logger.warn("Error trying to clean the archived git repository: {}", path, e);
+ }
+ }
+ }
+
+ private List<Path> listOverdueFiles(long duration) {
+ List<Path> files = new ArrayList<>();
+ File targetDir = config.getArchiveFolder().toFile();
+ FileTime nowTime = FileTime.fromMillis(TimeMachine.now().toEpochMilli());
+
+ for (File repo : targetDir.listFiles()) {
+ try {
+ FileTime lastModifiedTime = Files.getLastModifiedTime(repo.toPath());
+ FileTime expires = FileTime.fromMillis(lastModifiedTime.toMillis() + duration);
+ if (nowTime.compareTo(expires) > 0) {
+ files.add(repo.toPath());
+ }
+ } catch (IOException e) {
+ logger.warn("Error trying to get last modified time for file: {} ", repo.toPath(), e);
+ }
+ }
+ return files;
+ }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFolders.java b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFolders.java
index 14214c4..bd63e1f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFolders.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFolders.java
@@ -13,49 +13,69 @@
// limitations under the License.
package com.googlesource.gerrit.plugins.deleteproject.fs;
+import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
+
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Sets;
+import com.google.common.io.MoreFiles;
import com.google.gerrit.extensions.events.LifecycleListener;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.RepositoryConfig;
import com.google.gerrit.server.config.SitePaths;
import com.google.inject.Inject;
import java.io.IOException;
-import java.nio.file.FileVisitResult;
+import java.nio.file.FileVisitOption;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.nio.file.SimpleFileVisitor;
-import java.nio.file.attribute.BasicFileAttributes;
import java.util.Set;
import java.util.regex.Pattern;
+import java.util.stream.Stream;
import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.RepositoryCache.FileKey;
-import org.eclipse.jgit.util.FS;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class DeleteTrashFolders implements LifecycleListener {
private static final Logger log = LoggerFactory.getLogger(DeleteTrashFolders.class);
- /**
- * Search for name which ends with a dot, 13 digits and the string ".deleted". A folder 'f' is
- * renamed to 'f.<currentTimeMillis>.deleted'. <currentTimeMillis> happens to be exactly 13 digits
- * for commits created between 2002 (before git was born) and 2285.
- */
- private static final Pattern TRASH_1 = Pattern.compile(".*\\.\\d{13}.deleted");
+ static class TrashFolderPredicate {
- /**
- * New trash folder name format. It adds % chars around the "deleted" string and keeps the ".git"
- * extension.
- */
- private static final Pattern TRASH_2 = Pattern.compile(".*\\.\\d{13}.%deleted%.git");
+ private TrashFolderPredicate() {
+ // Avoid this class being instantiated by using the default empty constructor
+ }
- @VisibleForTesting
- static final boolean isTrashFolderName(String fName) {
- return TRASH_1.matcher(fName).matches() || TRASH_2.matcher(fName).matches();
+ /**
+ * Search for name which ends with a dot, 13 digits and the string ".deleted". A folder 'f' is
+ * renamed to 'f.<currentTimeMillis>.deleted'. <currentTimeMillis> happens to be exactly 13
+ * digits for commits created between 2002 (before git was born) and 2285.
+ */
+ private static final Pattern TRASH_1 = Pattern.compile(".*\\.\\d{13}.deleted");
+
+ /**
+ * New trash folder name format. It adds % chars around the "deleted" string and keeps the
+ * ".git" extension.
+ */
+ private static final Pattern TRASH_2 = Pattern.compile(".*\\.\\d{13}.%deleted%.git");
+
+ /**
+ * Newer trash folder name format. Besides the changes in TRASH_2, it uses a timestamp format
+ * (YYYYMMddHHmmss) instead of the epoch one for increased readability.
+ */
+ private static final Pattern TRASH_3 = Pattern.compile(".*\\.\\d{14}.%deleted%.git");
+
+ @VisibleForTesting
+ static final boolean match(String fName) {
+ return TRASH_1.matcher(fName).matches()
+ || TRASH_2.matcher(fName).matches()
+ || TRASH_3.matcher(fName).matches();
+ }
+
+ static boolean match(Path dir) {
+ return match(dir.getFileName().toString());
+ }
}
private Set<Path> repoFolders;
+ private Thread thread;
@Inject
public DeleteTrashFolders(
@@ -65,70 +85,34 @@
repoFolders.addAll(repositoryCfg.getAllBasePaths());
}
- class TrashFolderRemover extends SimpleFileVisitor<Path> {
+ @Override
+ public void start() {
+ thread =
+ new Thread(() -> repoFolders.stream().forEach(this::evaluateIfTrash), "DeleteTrashFolders");
+ thread.start();
+ }
- @Override
- public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
- throws IOException {
- String fName = dir.getFileName().toString();
- if (isTrashFolderName(fName)) {
- log.warn("Will delete this folder: {}", dir);
- recursiveDelete(dir);
- return FileVisitResult.SKIP_SUBTREE;
- } else if (FileKey.isGitRepository(dir.toFile(), FS.DETECTED)) {
- // We are in a GITDIR and don't expect trash folders inside GITDIR's.
- return FileVisitResult.SKIP_SUBTREE;
- }
-
- return super.preVisitDirectory(dir, attrs);
- }
-
- /**
- * Recursively delete the specified file and all of its contents.
- *
- * @throws IOException
- */
- private void recursiveDelete(Path file) throws IOException {
- Files.walkFileTree(
- file,
- new SimpleFileVisitor<Path>() {
- @Override
- public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
- throws IOException {
- Files.delete(file);
- return FileVisitResult.CONTINUE;
- }
-
- @Override
- public FileVisitResult postVisitDirectory(Path dir, IOException e) throws IOException {
- if (e != null) {
- throw e;
- }
- Files.delete(dir);
- return FileVisitResult.CONTINUE;
- }
- });
+ private void evaluateIfTrash(Path folder) {
+ try (Stream<Path> dir = Files.walk(folder, FileVisitOption.FOLLOW_LINKS)) {
+ dir.filter(Files::isDirectory)
+ .filter(TrashFolderPredicate::match)
+ .forEach(this::recursivelyDelete);
+ } catch (IOException e) {
+ log.error("Failed to evaluate {}", folder, e);
}
}
- @Override
- public void start() {
- new Thread(
- () -> {
- for (Path folder : repoFolders) {
- if (!folder.toFile().exists()) {
- log.debug("Base path {} does not exist", folder);
- continue;
- }
- try {
- Files.walkFileTree(folder, new TrashFolderRemover());
- } catch (IOException e) {
- log.warn("Exception while trying to delete trash folders", e);
- }
- }
- },
- "DeleteTrashFolders")
- .start();
+ @VisibleForTesting
+ Thread getWorkerThread() {
+ return thread;
+ }
+
+ private void recursivelyDelete(Path folder) {
+ try {
+ MoreFiles.deleteRecursively(folder, ALLOW_INSECURE);
+ } catch (IOException e) {
+ log.error("Failed to delete {}", folder, e);
+ }
}
@Override
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 abba619..003f154 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,27 +14,27 @@
package com.googlesource.gerrit.plugins.deleteproject.fs;
+import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
+
+import com.google.common.io.MoreFiles;
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.reviewdb.client.Project;
import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.ProjectResource;
import com.google.inject.Inject;
-import com.googlesource.gerrit.plugins.deleteproject.CannotDeleteProjectException;
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.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
-import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
-import java.nio.file.attribute.BasicFileAttributes;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import org.apache.commons.io.FileUtils;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.RepositoryCache;
import org.slf4j.Logger;
@@ -42,18 +42,20 @@
public class FilesystemDeleteHandler {
private static final Logger log = LoggerFactory.getLogger(FilesystemDeleteHandler.class);
+ private static final DateTimeFormatter FORMAT =
+ DateTimeFormatter.ofPattern("YYYYMMddHHmmss").withZone(ZoneId.of("UTC"));
private final GitRepositoryManager repoManager;
- private final DynamicSet<ProjectDeletedListener> deletedListener;
+ private final DynamicSet<ProjectDeletedListener> deletedListeners;
private final Configuration config;
@Inject
public FilesystemDeleteHandler(
GitRepositoryManager repoManager,
- DynamicSet<ProjectDeletedListener> deletedListener,
+ DynamicSet<ProjectDeletedListener> deletedListeners,
Configuration config) {
this.repoManager = repoManager;
- this.deletedListener = deletedListener;
+ this.deletedListeners = deletedListeners;
this.config = config;
}
@@ -61,120 +63,68 @@
throws IOException, RepositoryNotFoundException {
// Remove from the jgit cache
Repository repository = repoManager.openRepository(project.getNameKey());
- File repoFile = repository.getDirectory();
cleanCache(repository);
if (!preserveGitRepository) {
- deleteGitRepository(project.getNameKey(), repoFile);
- }
- }
-
- public void assertCanDelete(ProjectResource rsrc, boolean preserveGitRepository)
- throws CannotDeleteProjectException {
- if (!preserveGitRepository && !config.deletionWithTagsAllowed()) {
- assertHasNoTags(rsrc);
- }
- }
-
- private void assertHasNoTags(ProjectResource rsrc) throws CannotDeleteProjectException {
- try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
- if (!repo.getRefDatabase().getRefsByPrefix(Constants.R_TAGS).isEmpty()) {
- throw new CannotDeleteProjectException(
- String.format("Project %s has tags", rsrc.getName()));
- }
- } catch (IOException e) {
- throw new CannotDeleteProjectException(e);
- }
- }
-
- private void deleteGitRepository(final Project.NameKey project, final File repoFile)
- throws IOException {
- // Delete the repository from disk
- Path basePath = getBasePath(repoFile.toPath(), project);
- Path trash = moveToTrash(repoFile.toPath(), basePath, project);
- boolean ok = false;
- try {
- recursiveDelete(trash);
- ok = true;
- } catch (IOException e) {
- // Only log if delete failed - repo already moved to trash.
- // Otherwise, listeners are never called.
- log.warn("Error trying to delete {}", trash, e);
- }
-
- // Delete parent folders if they are (now) empty
- if (ok) {
- try {
- recursiveDeleteParent(repoFile.getParentFile(), basePath.toFile());
- } catch (IOException e) {
- log.warn("Couldn't delete (empty) parents of {}", repoFile, e);
- }
- }
-
- // Send an event that the repository was deleted
- ProjectDeletedListener.Event event =
- new ProjectDeletedListener.Event() {
- @Override
- public String getProjectName() {
- return project.get();
- }
-
- @Override
- public NotifyHandling getNotify() {
- return NotifyHandling.NONE;
- }
- };
- for (ProjectDeletedListener l : deletedListener) {
- try {
- l.onProjectDeleted(event);
- } catch (RuntimeException e) {
- log.warn("Failure in ProjectDeletedListener", e);
+ Path repoPath = repository.getDirectory().toPath();
+ String projectName = project.getNameKey().get();
+ if (config.shouldArchiveDeletedRepos()) {
+ archiveGitRepository(projectName, repoPath);
+ } else {
+ deleteGitRepository(projectName, repoPath);
}
}
}
- private Path getBasePath(Path repo, Project.NameKey project) {
- Path projectPath = Paths.get(project.get());
- return repo.getRoot()
- .resolve(repo.subpath(0, repo.getNameCount() - projectPath.getNameCount()));
- }
-
- private Path moveToTrash(Path directory, Path basePath, Project.NameKey nameKey)
- throws IOException {
- Path trashRepo =
- basePath.resolve(nameKey.get() + "." + TimeMachine.now().toEpochMilli() + ".%deleted%.git");
- return Files.move(directory, trashRepo, StandardCopyOption.ATOMIC_MOVE);
- }
-
- private void cleanCache(final Repository repository) {
+ private void cleanCache(Repository repository) {
repository.close();
RepositoryCache.close(repository);
}
- /**
- * Recursively delete the specified file and all of its contents.
- *
- * @throws IOException
- */
- private void recursiveDelete(Path file) throws IOException {
- Files.walkFileTree(
- file,
- new SimpleFileVisitor<Path>() {
- @Override
- public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
- throws IOException {
- Files.delete(file);
- return FileVisitResult.CONTINUE;
- }
+ 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.warn("Error trying to archive {}", renamedProjectDir, e);
+ }
+ }
- @Override
- public FileVisitResult postVisitDirectory(Path dir, IOException e) throws IOException {
- if (e != null) {
- throw e;
- }
- Files.delete(dir);
- return FileVisitResult.CONTINUE;
- }
- });
+ 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.warn("Error trying to delete {} or its parents", trash, e);
+ } 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);
}
/**
@@ -182,14 +132,39 @@
* 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 recursiveDeleteParent(File file, File until) throws IOException {
+ 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());
- recursiveDeleteParent(parent, until);
+ 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.warn("Failure in ProjectDeletedListener", e);
+ }
}
}
}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 65f996d..c4f98ff 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -34,6 +34,19 @@
By default false.
+<a id="enablePreserveOption">
+`plugin.@PLUGIN@.enablePreserveOption`
+: Whether the "Preserve git repository" option is enabled for the user on the
+ UI and ssh delete-project command.
+
+ Disabling the preserve option means the user does not have access to the
+ preserve option on the UI and ssh delete-project command.
+
+ If this is set to false, then preserving deleted git repositories is
+ disabled.
+
+ By default true.
+
<a id="parentForDeletedProjects">
`plugin.@PLUGIN@.parentForDeletedProjects`
: The name of the project that is used as parent for all deleted
@@ -53,3 +66,59 @@
patterns.
By default not set.
+
+<a id="archiveDeletedRepos">
+`plugin.@PLUGIN@.archiveDeletedRepos`
+: Whether to archive repositories instead of deleting them.
+
+ Archiving the git repository means that the repository is stored
+ in a folder which is not visible to the users which, from the user
+ perspective, is equivalent to the repository being deleted. The
+ advantage is, archived repositories can be later recovered if, needed,
+ just by moving them back to their former name and location.
+ The target folder used to archive the repositories can be set by
+ [archiveFolder](#archiveFolder). Archived repositories are moved
+ under the [archiveFolder](#archiveFolder) and renamed to add a
+ timestamp and the %archived% suffix to the original name.
+ Archived repositories are kept in the archive for a time period which
+ can be set by [deleteArchivedReposAfter](#deleteArchivedReposAfter).
+
+ If this option is enabled, the project will not be deleted but rather
+ renamed and moved into the archive folder.
+
+ If the repository has been archived for a time period longer than
+ [deleteArchivedReposAfter](#deleteArchivedReposAfter), it will be
+ deleted from the archive by a periodic task which runs once a day.
+
+ By default false.
+
+<a id="archiveFolder">
+`plugin.@PLUGIN@.archiveFolder`
+: The absolute path of the archive folder to store archived repositories.
+
+ The git repository is archived to this target folder only if
+ [archiveDeletedRepos](#archiveDeletedRepos) is set to true.
+
+ By default `$site_path/data/delete-project`.
+
+<a id="deleteArchivedReposAfter">
+`plugin.@PLUGIN@.deleteArchivedReposAfter`
+: The time duration for the git repository to be archived.
+
+ The following suffixes are supported to define the time unit:\n
+ 1. d, day, days\n
+ 2. w, week, weeks (1 week is treated as 7 days)\n
+ 3. mon, month, months (1 month is treated as 30 days)\n
+ 4. y, year, years (1 year is treated as 365 days)\n
+
+ If not specified, the default time unit is in days.
+
+ The project git repository is archived to this target folder only if
+ [archiveDeletedRepos](#archiveDeletedRepos) is set to true.
+
+ If [archiveDeletedRepos](#archiveDeletedRepos) is set to true but this
+ option is set to zero, the periodic task will not be executed and the
+ archived repositories need to be deleted manually or using an external
+ task.
+
+ By default 180 (days).
diff --git a/src/main/resources/static/delete-project-with-preserve-disabled.js b/src/main/resources/static/delete-project-with-preserve-disabled.js
new file mode 100644
index 0000000..550ea6c
--- /dev/null
+++ b/src/main/resources/static/delete-project-with-preserve-disabled.js
@@ -0,0 +1,42 @@
+// Copyright (C) 2018 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.
+
+if (!window.Polymer) {
+ Gerrit.install(function(self) {
+ function onDeleteProject(c) {
+ var f = c.checkbox();
+ var b = c.button('Delete',
+ {onclick: function(){
+ c.call(
+ {force: f.checked, preserve: false},
+ function(r) {
+ c.hide();
+ window.alert('The project: "'
+ + c.project
+ + '" was deleted.'),
+ Gerrit.go('/admin/projects/');
+ });
+ }});
+ c.popup(c.div(
+ c.msg('Are you really sure you want to delete the project: "'
+ + c.project
+ + '"?'),
+ c.br(),
+ c.label(f, 'Delete project even if open changes exist?'),
+ c.br(),
+ b));
+ }
+ self.onAction('project', 'delete', onDeleteProject);
+ });
+}
\ No newline at end of file
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ConfigurationTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ConfigurationTest.java
index 3d069ef..8686916 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ConfigurationTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ConfigurationTest.java
@@ -19,30 +19,53 @@
import com.google.gerrit.server.config.PluginConfig;
import com.google.gerrit.server.config.PluginConfigFactory;
+import java.io.File;
+import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
import org.eclipse.jgit.lib.Config;
+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.junit.MockitoJUnitRunner;
@RunWith(MockitoJUnitRunner.class)
public class ConfigurationTest {
+ private static final long DEFAULT_ARCHIVE_DURATION_MS = TimeUnit.DAYS.toMillis(180);
+ private static final String CUSTOM_DURATION = "100";
private static final String CUSTOM_PARENT = "customParent";
+ private static final String INVALID_CUSTOM_FOLDER = "\0";
+ private static final String INVALID_ARCHIVE_DURATION = "180weeks180years";
private static final String PLUGIN_NAME = "delete-project";
@Mock private PluginConfigFactory pluginConfigFactoryMock;
+ @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+ private Path customArchiveFolder;
+ private File pluginDataDir;
private Configuration deleteConfig;
+ @Before
+ public void setUp() throws Exception {
+ pluginDataDir = tempFolder.newFolder("data");
+ customArchiveFolder = tempFolder.newFolder("archive").toPath();
+ }
+
@Test
public void defaultValuesAreLoaded() {
when(pluginConfigFactoryMock.getFromGerritConfig(PLUGIN_NAME))
.thenReturn(new PluginConfig(PLUGIN_NAME, new Config()));
- deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME);
+ deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginDataDir);
assertThat(deleteConfig.getDeletedProjectsParent()).isEqualTo("Deleted-Projects");
assertThat(deleteConfig.deletionWithTagsAllowed()).isTrue();
assertThat(deleteConfig.projectOnPreserveHidden()).isFalse();
+ assertThat(deleteConfig.shouldArchiveDeletedRepos()).isFalse();
+ assertThat(deleteConfig.getArchiveDuration()).isEqualTo(DEFAULT_ARCHIVE_DURATION_MS);
+ assertThat(deleteConfig.getArchiveFolder().toString()).isEqualTo(pluginDataDir.toString());
+ assertThat(deleteConfig.enablePreserveOption()).isTrue();
}
@Test
@@ -51,11 +74,55 @@
pluginConfig.setString("parentForDeletedProjects", CUSTOM_PARENT);
pluginConfig.setBoolean("allowDeletionOfReposWithTags", false);
pluginConfig.setBoolean("hideProjectOnPreserve", true);
+ pluginConfig.setBoolean("archiveDeletedRepos", true);
+ pluginConfig.setBoolean("enablePreserveOption", false);
+ pluginConfig.setString("deleteArchivedReposAfter", CUSTOM_DURATION);
+ pluginConfig.setString("archiveFolder", customArchiveFolder.toString());
+
when(pluginConfigFactoryMock.getFromGerritConfig(PLUGIN_NAME)).thenReturn(pluginConfig);
- deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME);
+ deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginDataDir);
assertThat(deleteConfig.getDeletedProjectsParent()).isEqualTo(CUSTOM_PARENT);
assertThat(deleteConfig.deletionWithTagsAllowed()).isFalse();
assertThat(deleteConfig.projectOnPreserveHidden()).isTrue();
+ assertThat(deleteConfig.shouldArchiveDeletedRepos()).isTrue();
+ assertThat(deleteConfig.enablePreserveOption()).isFalse();
+ assertThat(deleteConfig.getArchiveDuration()).isEqualTo(Long.parseLong(CUSTOM_DURATION));
+ assertThat(deleteConfig.getArchiveFolder().toString())
+ .isEqualTo(customArchiveFolder.toString());
+ }
+
+ @Test
+ public void archiveDurationWithUnitIsLoaded() {
+ PluginConfig pluginConfig = new PluginConfig(PLUGIN_NAME, new Config());
+ pluginConfig.setString("deleteArchivedReposAfter", CUSTOM_DURATION + "years");
+
+ when(pluginConfigFactoryMock.getFromGerritConfig(PLUGIN_NAME)).thenReturn(pluginConfig);
+ deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginDataDir);
+
+ assertThat(deleteConfig.getArchiveDuration())
+ .isEqualTo(TimeUnit.DAYS.toMillis(Long.parseLong(CUSTOM_DURATION)) * 365);
+ }
+
+ @Test
+ public void invalidArchiveDuration() {
+ PluginConfig pluginConfig = new PluginConfig(PLUGIN_NAME, new Config());
+ pluginConfig.setString("deleteArchivedReposAfter", INVALID_ARCHIVE_DURATION);
+
+ when(pluginConfigFactoryMock.getFromGerritConfig(PLUGIN_NAME)).thenReturn(pluginConfig);
+ deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginDataDir);
+
+ assertThat(deleteConfig.getArchiveDuration()).isEqualTo(DEFAULT_ARCHIVE_DURATION_MS);
+ }
+
+ @Test
+ public void invalidTargetArchiveFolder() {
+ PluginConfig pluginConfig = new PluginConfig(PLUGIN_NAME, new Config());
+ pluginConfig.setString("archiveFolder", INVALID_CUSTOM_FOLDER);
+
+ when(pluginConfigFactoryMock.getFromGerritConfig(PLUGIN_NAME)).thenReturn(pluginConfig);
+ deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginDataDir);
+
+ assertThat(deleteConfig.getArchiveFolder().toString()).isEqualTo(pluginDataDir.toString());
}
}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeletePreconditionsTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeletePreconditionsTest.java
new file mode 100644
index 0000000..168a17c
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeletePreconditionsTest.java
@@ -0,0 +1,170 @@
+// Copyright (C) 2018 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;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.deleteproject.DeleteOwnProjectCapability.DELETE_OWN_PROJECT;
+import static com.googlesource.gerrit.plugins.deleteproject.DeleteProjectCapability.DELETE_PROJECT;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.project.ListChildProjects;
+import com.google.gerrit.server.submit.MergeOpRepoManager;
+import com.google.gerrit.server.submit.SubmoduleOp;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class DeletePreconditionsTest {
+ private static final String PLUGIN_NAME = "delete-project";
+ private static final Project.NameKey PROJECT_NAMEKEY = new Project.NameKey("test-project");
+
+ @Mock private Configuration config;
+ @Mock private Provider<ListChildProjects> listChildProjectsProvider;
+ @Mock private Provider<MergeOpRepoManager> mergeOpProvider;
+ @Mock private Provider<InternalChangeQuery> queryProvider;
+ @Mock private GitRepositoryManager repoManager;
+ @Mock private SubmoduleOp.Factory subOpFactory;
+ @Mock private CurrentUser currentUser;
+ @Mock private Provider<CurrentUser> userProvider;
+ @Mock private ProjectState state;
+ @Mock private ProtectedProjects protectedProjects;
+ @Mock private PermissionBackend permissionBackend;
+ @Mock private PermissionBackend.WithUser userPermission;
+
+ @Rule public ExpectedException expectedException = ExpectedException.none();
+
+ private ProjectResource rsrc;
+ private DeletePreconditions preConditions;
+
+ @Before
+ public void setUp() {
+ when(userProvider.get()).thenReturn(currentUser);
+ rsrc = new ProjectResource(state, currentUser);
+ when(rsrc.getNameKey()).thenReturn(PROJECT_NAMEKEY);
+ preConditions =
+ new DeletePreconditions(
+ config,
+ listChildProjectsProvider,
+ mergeOpProvider,
+ PLUGIN_NAME,
+ queryProvider,
+ repoManager,
+ subOpFactory,
+ userProvider,
+ protectedProjects,
+ permissionBackend);
+ }
+
+ @Test
+ public void testUserCanDeleteIfAdmin() {
+ when(permissionBackend.user(currentUser)).thenReturn(userPermission);
+ when(userPermission.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER)).thenReturn(true);
+ assertThat(preConditions.canDelete(rsrc)).isTrue();
+ }
+
+ @Test
+ public void testUserCanDeleteIfHasDeletePermission() {
+ when(permissionBackend.user(currentUser)).thenReturn(userPermission);
+ when(userPermission.testOrFalse(new PluginPermission(PLUGIN_NAME, DELETE_PROJECT)))
+ .thenReturn(true);
+ assertThat(preConditions.canDelete(rsrc)).isTrue();
+ }
+
+ @Test
+ public void testUserCanDeleteIfIsOwnerAndHasDeleteOwnPermission() {
+ when(permissionBackend.user(currentUser)).thenReturn(userPermission);
+ when(userPermission.testOrFalse(new PluginPermission(PLUGIN_NAME, DELETE_OWN_PROJECT)))
+ .thenReturn(true);
+ PermissionBackend.ForProject projectPermission = mock(PermissionBackend.ForProject.class);
+ when(projectPermission.testOrFalse(ProjectPermission.WRITE_CONFIG)).thenReturn(true);
+ when(userPermission.project(PROJECT_NAMEKEY)).thenReturn(projectPermission);
+ assertThat(preConditions.canDelete(rsrc)).isTrue();
+ }
+
+ @Test
+ public void testUserCannotDelete() throws Exception {
+ when(permissionBackend.user(currentUser)).thenReturn(userPermission);
+ expectedException.expect(AuthException.class);
+ expectedException.expectMessage("not allowed to delete project");
+ preConditions.assertDeletePermission(rsrc);
+ }
+
+ @Test
+ public void testIsProtectedSoCannotBeDeleted() throws Exception {
+ doThrow(CannotDeleteProjectException.class).when(protectedProjects).assertIsNotProtected(rsrc);
+ expectedException.expect(ResourceConflictException.class);
+ preConditions.assertCanBeDeleted(rsrc, new DeleteProject.Input());
+ }
+
+ @Test
+ public void testHasChildrenSoCannotBeDeleted() throws Exception {
+ doNothing().when(protectedProjects).assertIsNotProtected(rsrc);
+ ListChildProjects childProjects = mock(ListChildProjects.class);
+ when(listChildProjectsProvider.get()).thenReturn(childProjects);
+ when(childProjects.apply(rsrc)).thenReturn(ImmutableList.of(new ProjectInfo()));
+ expectedException.expect(ResourceConflictException.class);
+ expectedException.expectMessage("Cannot delete project because it has children:");
+ preConditions.assertCanBeDeleted(rsrc, new DeleteProject.Input());
+ }
+
+ @Test
+ public void testAssertHasOpenChangesNoForceSet() throws Exception {
+ InternalChangeQuery queryChange = mock(InternalChangeQuery.class);
+ ChangeData cd = mock(ChangeData.class);
+ when(queryChange.byProjectOpen(PROJECT_NAMEKEY)).thenReturn(ImmutableList.of(cd));
+ when(queryProvider.get()).thenReturn(queryChange);
+ String expectedMessage = String.format("Project '%s' has open changes.", PROJECT_NAMEKEY.get());
+ expectedException.expectMessage(expectedMessage);
+ expectedException.expect(CannotDeleteProjectException.class);
+ preConditions.assertHasOpenChanges(PROJECT_NAMEKEY, false);
+ }
+
+ @Test
+ public void testUnableToAssertOpenChanges() throws Exception {
+ InternalChangeQuery queryChange = mock(InternalChangeQuery.class);
+ doThrow(OrmException.class).when(queryChange).byProjectOpen(PROJECT_NAMEKEY);
+ when(queryProvider.get()).thenReturn(queryChange);
+ String expectedMessage =
+ String.format("Unable to verify if '%s' has open changes.", PROJECT_NAMEKEY.get());
+ expectedException.expectMessage(expectedMessage);
+ expectedException.expect(CannotDeleteProjectException.class);
+ preConditions.assertHasOpenChanges(PROJECT_NAMEKEY, false);
+ }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProjectIT.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProjectIT.java
new file mode 100644
index 0000000..78ea95e
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/DeleteProjectIT.java
@@ -0,0 +1,322 @@
+// Copyright (C) 2018 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;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
+
+import com.google.common.base.Joiner;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.googlesource.gerrit.plugins.deleteproject.DeleteProject.Input;
+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.util.stream.Stream;
+import org.apache.commons.io.FileUtils;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@UseSsh
+@TestPlugin(
+ name = "delete-project",
+ sysModule = "com.googlesource.gerrit.plugins.deleteproject.Module",
+ sshModule = "com.googlesource.gerrit.plugins.deleteproject.SshModule",
+ httpModule = "com.googlesource.gerrit.plugins.deleteproject.HttpModule")
+public class DeleteProjectIT extends LightweightPluginDaemonTest {
+
+ private static final String PLUGIN = "delete-project";
+ private static final String ARCHIVE_FOLDER = "archiveFolder";
+ private static final String PARENT_FOLDER = "parentFolder";
+
+ private File archiveFolder;
+ private File projectDir;
+
+ @Before
+ public void setUpArchiveFolder() throws IOException {
+ archiveFolder = Files.createDirectories(Paths.get(ARCHIVE_FOLDER)).toFile();
+ projectDir = verifyProjectRepoExists(project);
+ }
+
+ @After
+ public void removeArchiveFolder() {
+ FileUtils.deleteQuietly(archiveFolder);
+ }
+
+ @Test
+ @UseLocalDisk
+ public void testHttpDeleteProjectForce() throws Exception {
+ RestResponse r = httpDeleteProjectHelper(true);
+ r.assertNoContent();
+ assertThat(projectDir.exists()).isFalse();
+ }
+
+ @Test
+ @UseLocalDisk
+ public void testHttpDeleteProjectNotForce() throws Exception {
+ createChange();
+ RestResponse r = httpDeleteProjectHelper(false);
+ r.assertConflict();
+ assertThat(projectDir.exists()).isTrue();
+ }
+
+ @Test
+ @UseLocalDisk
+ public void testHttpDeleteProjectWithWatches() throws Exception {
+ watch(project.get());
+ RestResponse r = httpDeleteProjectHelper(true);
+ r.assertNoContent();
+ assertThat(projectDir.exists()).isFalse();
+ }
+
+ @Test
+ @UseLocalDisk
+ public void testSshDeleteProjectWithoutOptions() throws Exception {
+ createChange();
+ String cmd = Joiner.on(" ").join(PLUGIN, "delete", project.get());
+ String expected =
+ String.format(
+ "Really delete '%s'?\n"
+ + "This is an operation which permanently deletes data. This cannot be undone!\n"
+ + "If you are sure you wish to delete this project, re-run with the --yes-really-delete flag.\n\n",
+ project.get());
+ adminSshSession.exec(cmd);
+
+ assertThat(projectDir.exists()).isTrue();
+ assertThat(adminSshSession.getError()).isEqualTo(expected);
+ }
+
+ @Test
+ @UseLocalDisk
+ public void testSshDeleteProjYesReallyDelete() throws Exception {
+ createChange();
+ String cmd = createDeleteCommand(project.get());
+ String expected =
+ String.format(
+ "Project '%s' has open changes. - To really delete '%s', re-run with the --force flag.%n",
+ project.get(), project.get());
+ adminSshSession.exec(cmd);
+
+ assertThat(projectDir.exists()).isTrue();
+ assertThat(adminSshSession.getError()).isEqualTo(expected);
+ }
+
+ @Test
+ @UseLocalDisk
+ public void testSshDeleteProjYesReallyDeleteForce() throws Exception {
+ createChange();
+ String cmd = createDeleteCommand("--force", project.get());
+ adminSshSession.exec(cmd);
+ assertThat(adminSshSession.getError()).isNull();
+ assertThat(projectDir.exists()).isFalse();
+ }
+
+ @Test
+ @UseLocalDisk
+ @GerritConfig(name = "plugin.delete-project.enablePreserveOption", value = "true")
+ public void testSshDeleteProjPreserveGitRepoEnabled() throws Exception {
+ String cmd = createDeleteCommand("--preserve-git-repository", project.get());
+ adminSshSession.exec(cmd);
+
+ assertThat(adminSshSession.getError()).isNull();
+ assertThat(projectDir.exists()).isTrue();
+ }
+
+ @Test
+ @UseLocalDisk
+ @GerritConfig(name = "plugin.delete-project.enablePreserveOption", value = "false")
+ public void testSshDeleteProjPreserveGitRepoNotEnabled() throws Exception {
+ String cmd = createDeleteCommand("--preserve-git-repository", project.get());
+ adminSshSession.exec(cmd);
+ String expected =
+ "Given the enablePreserveOption is configured to be false, "
+ + "the --preserve-git-repository option is not allowed.\n"
+ + "Please remove this option and retry.\n";
+ assertThat(adminSshSession.getError()).isEqualTo(expected);
+ assertThat(projectDir.exists()).isTrue();
+ }
+
+ @Test
+ @UseLocalDisk
+ @GerritConfig(name = "plugin.delete-project.hideProjectOnPreserve", value = "true")
+ public void testSshHideProject() throws Exception {
+ String cmd = createDeleteCommand("--preserve-git-repository", project.get());
+ adminSshSession.exec(cmd);
+
+ ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+ ProjectState state = cfg.getProject().getState();
+
+ assertThat(state).isEqualTo(ProjectState.HIDDEN);
+ assertThat(adminSshSession.getError()).isNull();
+ assertThat(projectDir.exists()).isTrue();
+ }
+
+ @Test
+ @UseLocalDisk
+ public void testDeleteProjWithChildren() throws Exception {
+ String childrenString = createProjectOverAPI("foo", project, true, null).get();
+ verifyProjectRepoExists(Project.NameKey.parse(childrenString));
+
+ String cmd = createDeleteCommand(project.get());
+ adminSshSession.exec(cmd);
+ assertThat(adminSshSession.getError())
+ .isEqualTo(
+ "fatal: Cannot delete project because it has children: " + childrenString + "\n");
+ assertThat(projectDir.exists()).isTrue();
+ }
+
+ @Test
+ @UseLocalDisk
+ public void testDeleteAllProject() throws Exception {
+ String name = allProjects.get();
+ String cmd = createDeleteCommand(name);
+ adminSshSession.exec(cmd);
+
+ assertThat(adminSshSession.getError())
+ .isEqualTo("fatal: Cannot delete project because it is protected against deletion" + "\n");
+ }
+
+ @Test
+ @UseLocalDisk
+ @GerritConfig(name = "plugin.delete-project.allowDeletionOfReposWithTags", value = "false")
+ public void testDeleteProjWithTags() throws Exception {
+ grant(project, "refs/tags/*", Permission.CREATE, false, REGISTERED_USERS);
+ pushTagOldCommitNotForce();
+
+ String cmd = createDeleteCommand(project.get());
+ adminSshSession.exec(cmd);
+ assertThat(adminSshSession.getError())
+ .isEqualTo("fatal: Project " + project.get() + " has tags" + "\n");
+ assertThat(projectDir.exists()).isTrue();
+ }
+
+ @Test
+ @UseLocalDisk
+ @GerritConfig(name = "plugin.delete-project.archiveDeletedRepos", value = "true")
+ @GerritConfig(name = "plugin.delete-project.archiveFolder", value = ARCHIVE_FOLDER)
+ public void testArchiveProject() throws Exception {
+ assertThat(archiveFolder.exists()).isTrue();
+ assertThat(isEmpty(archiveFolder.toPath())).isTrue();
+
+ String cmd = createDeleteCommand(project.get());
+ adminSshSession.exec(cmd);
+
+ assertThat(adminSshSession.getError()).isNull();
+ assertThat(isEmpty(archiveFolder.toPath())).isFalse();
+ assertThat(containsDeletedProject(archiveFolder.toPath(), project.get())).isTrue();
+ assertThat(projectDir.exists()).isFalse();
+ }
+
+ @Test
+ @UseLocalDisk
+ @GerritConfig(name = "plugin.delete-project.archiveDeletedRepos", value = "true")
+ @GerritConfig(name = "plugin.delete-project.archiveFolder", value = ARCHIVE_FOLDER)
+ public void testDeleteAndArchiveProjectWithParentFolder() throws Exception {
+ assertThat(archiveFolder.exists()).isTrue();
+ assertThat(isEmpty(archiveFolder.toPath())).isTrue();
+
+ String name = "pj1";
+ String projectName = createProjectOverAPI(name, null, true, null).get();
+ File projectDir = verifyProjectRepoExists(Project.NameKey.parse(projectName));
+
+ Path parentFolder = projectDir.toPath().getParent().resolve(PARENT_FOLDER).resolve(projectName);
+ parentFolder.toFile().mkdirs();
+ assertThat(parentFolder.toFile().exists()).isTrue();
+ assertThat(isEmpty(parentFolder)).isTrue();
+
+ Files.move(projectDir.toPath(), parentFolder, REPLACE_EXISTING);
+ assertThat(parentFolder.toFile().exists()).isTrue();
+ assertThat(isEmpty(parentFolder)).isFalse();
+
+ String cmd = createDeleteCommand(PARENT_FOLDER + "/" + projectName);
+ adminSshSession.exec(cmd);
+
+ assertThat(adminSshSession.getError()).isNull();
+ assertThat(isEmpty(archiveFolder.toPath())).isFalse();
+ assertThat(containsDeletedProject(archiveFolder.toPath().resolve(PARENT_FOLDER), name))
+ .isTrue();
+ assertThat(projectDir.exists()).isFalse();
+
+ assertThat(parentFolder.toFile().exists()).isFalse();
+ }
+
+ private File verifyProjectRepoExists(Project.NameKey name) throws IOException {
+ File projectDir;
+ try (Repository projectRepo = repoManager.openRepository(name)) {
+ projectDir = projectRepo.getDirectory();
+ }
+ assertThat(projectDir.exists()).isTrue();
+ return projectDir;
+ }
+
+ private RestResponse httpDeleteProjectHelper(boolean force) throws Exception {
+ setApiUser(user);
+ sender.clear();
+ String endPoint = "/projects/" + project.get() + "/delete-project~delete";
+ Input i = new Input();
+ i.force = force;
+
+ return adminRestSession.post(endPoint, i);
+ }
+
+ private String createDeleteCommand(String cmd, String... params) {
+ return Joiner.on(" ")
+ .join(PLUGIN, "delete", "--yes-really-delete", cmd, Joiner.on(" ").join(params));
+ }
+
+ private void pushTagOldCommitNotForce() throws Exception {
+ testRepo = cloneProject(project, user);
+ commitBuilder().ident(user.getIdent()).message("subject (" + System.nanoTime() + ")").create();
+ String tagName = "v1_" + System.nanoTime();
+
+ grant(project, "refs/for/refs/heads/master", Permission.SUBMIT, false, REGISTERED_USERS);
+ pushHead(testRepo, "refs/for/master%submit");
+
+ String tagRef = RefNames.REFS_TAGS + tagName;
+ PushResult r = pushHead(testRepo, tagRef, false, false);
+ RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
+ assertThat(refUpdate.getStatus()).named("LIGHTWEIGHT").isEqualTo(Status.OK);
+ }
+
+ private boolean isEmpty(Path dir) throws IOException {
+ try (Stream<Path> dirStream = Files.list(dir)) {
+ return !dirStream.iterator().hasNext();
+ }
+ }
+
+ private boolean containsDeletedProject(Path dir, String projectName) throws IOException {
+ try (Stream<Path> dirStream = Files.list(dir)) {
+ return dirStream.anyMatch(d -> d.toString().contains(projectName));
+ }
+ }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ProtectedProjectsTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ProtectedProjectsTest.java
index 9133f64..3b18ba3 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ProtectedProjectsTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/ProtectedProjectsTest.java
@@ -25,6 +25,7 @@
import com.google.gerrit.server.config.AllUsersNameProvider;
import com.google.gerrit.server.config.PluginConfig;
import com.google.gerrit.server.config.PluginConfigFactory;
+import java.io.File;
import java.util.List;
import org.eclipse.jgit.lib.Config;
import org.junit.Before;
@@ -44,6 +45,7 @@
private PluginConfig pluginConfig;
private Configuration deleteConfig;
private ProtectedProjects protectedProjects;
+ private File pluginData = new File("data");
@Before
public void setup() throws Exception {
@@ -51,7 +53,7 @@
when(allUsersMock.get()).thenReturn(new AllUsersName("All-Users"));
pluginConfig = new PluginConfig(PLUGIN_NAME, new Config());
when(pluginConfigFactoryMock.getFromGerritConfig(PLUGIN_NAME)).thenReturn(pluginConfig);
- deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME);
+ deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginData);
protectedProjects = new ProtectedProjects(allProjectsMock, allUsersMock, deleteConfig);
}
@@ -75,7 +77,7 @@
List<String> projects = ImmutableList.of("Custom-Parent", "^protected-.*");
pluginConfig.setStringList("protectedProject", projects);
when(pluginConfigFactoryMock.getFromGerritConfig(PLUGIN_NAME)).thenReturn(pluginConfig);
- deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME);
+ deleteConfig = new Configuration(pluginConfigFactoryMock, PLUGIN_NAME, pluginData);
assertThat(deleteConfig.protectedProjects()).hasSize(projects.size());
protectedProjects = new ProtectedProjects(allProjectsMock, allUsersMock, deleteConfig);
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/ArchiveRepositoryRemoverTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/ArchiveRepositoryRemoverTest.java
new file mode 100644
index 0000000..64634b9
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/ArchiveRepositoryRemoverTest.java
@@ -0,0 +1,153 @@
+// Copyright (C) 2018 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 java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isA;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.common.base.Joiner;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Provider;
+import com.googlesource.gerrit.plugins.deleteproject.Configuration;
+import com.googlesource.gerrit.plugins.deleteproject.TimeMachine;
+import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.List;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.StreamSupport;
+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.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ArchiveRepositoryRemoverTest {
+
+ private static final long ARCHIVE_DURATION = 1;
+ private static final long CLEANUP_INTERVAL = TimeUnit.DAYS.toMillis(1);
+ private static final int NUMBER_OF_REPOS = 10;
+ private static final String PLUGIN_NAME = "delete-project";
+
+ @Mock private ScheduledExecutorService executorMock;
+ @Mock private ScheduledFuture<?> scheduledFutureMock;
+ @Mock private WorkQueue workQueueMock;
+ @Mock private Provider<RepositoryCleanupTask> cleanupTaskProviderMock;
+ @Mock private Configuration configMock;
+
+ @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ private ArchiveRepositoryRemover remover;
+ private Path archiveRepo;
+
+ @Before
+ public void setUp() throws Exception {
+ when(cleanupTaskProviderMock.get()).thenReturn(new RepositoryCleanupTask(null, null));
+ when(workQueueMock.getDefaultQueue()).thenReturn(executorMock);
+ doReturn(scheduledFutureMock)
+ .when(executorMock)
+ .scheduleAtFixedRate(
+ isA(RepositoryCleanupTask.class), anyLong(), anyLong(), isA(TimeUnit.class));
+ remover = new ArchiveRepositoryRemover(workQueueMock, cleanupTaskProviderMock);
+ archiveRepo = tempFolder.newFolder("archive").toPath();
+ when(configMock.getArchiveFolder()).thenReturn(archiveRepo);
+ when(configMock.getArchiveDuration()).thenReturn(ARCHIVE_DURATION);
+ }
+
+ @Test
+ public void cleanUpOverdueRepositoriesTest() throws IOException {
+ setupArchiveFolder();
+ try {
+ TimeMachine.useFixedClockAt(
+ Instant.ofEpochMilli(Files.getLastModifiedTime(archiveRepo).toMillis())
+ .plusMillis(TimeUnit.DAYS.toMillis(ARCHIVE_DURATION) + 10));
+
+ RepositoryCleanupTask task = new RepositoryCleanupTask(configMock, PLUGIN_NAME);
+ task.run();
+ assertThat(task.toString())
+ .isEqualTo(
+ String.format(
+ "[%s]: Clean up expired git repositories from the archive [%s]",
+ PLUGIN_NAME, archiveRepo));
+ assertDirectoryContents(archiveRepo, true);
+ } finally {
+ TimeMachine.useSystemDefaultZoneClock();
+ }
+ }
+
+ @Test
+ public void testRepositoryCleanupTaskIsScheduledOnStart() {
+ remover.start();
+ verify(executorMock, times(1))
+ .scheduleAtFixedRate(
+ isA(RepositoryCleanupTask.class),
+ eq(SECONDS.toMillis(1)),
+ eq(CLEANUP_INTERVAL),
+ eq(TimeUnit.MILLISECONDS));
+ }
+
+ @Test
+ public void testRepositoryCleanupTaskIsCancelledOnStop() {
+ remover.start();
+ remover.stop();
+ verify(scheduledFutureMock, times(1)).cancel(true);
+ }
+
+ private void setupArchiveFolder() throws IOException {
+ for (int i = 0; i < NUMBER_OF_REPOS; i++) {
+ createRepository("Repo_" + i);
+ }
+ assertDirectoryContents(archiveRepo, false);
+ }
+
+ private FileRepository createRepository(String repoName) throws IOException {
+ Path repoPath = Files.createDirectories(archiveRepo.resolve(repoName));
+ Repository repository = new FileRepository(repoPath.toFile());
+ repository.create(true);
+ return (FileRepository) repository;
+ }
+
+ private void assertDirectoryContents(Path dir, boolean expectEmpty) throws IOException {
+ try (DirectoryStream<Path> dirStream = Files.newDirectoryStream(dir)) {
+ List<Path> paths = StreamSupport.stream(dirStream.spliterator(), false).collect(toList());
+ if (expectEmpty && !paths.isEmpty()) {
+ fail(
+ String.format(
+ "Expected dir [%s] to be empty but it contains: %s",
+ dir, Joiner.on(", ").join(paths)));
+ } else if (!expectEmpty && paths.isEmpty()) {
+ fail(String.format("Expected dir [%s] to be non-empty but it is empty", dir));
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFoldersTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFoldersTest.java
new file mode 100644
index 0000000..acb7a03
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/DeleteTrashFoldersTest.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2018 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.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.config.RepositoryConfig;
+import com.google.gerrit.server.config.SitePaths;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.Config;
+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.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class DeleteTrashFoldersTest {
+
+ @Mock private RepositoryConfig repositoryCfg;
+
+ @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ private Path basePath;
+ private DeleteTrashFolders trashFolders;
+
+ @Before
+ public void setUp() throws Exception {
+ SitePaths sitePaths = new SitePaths(tempFolder.newFolder("gerrit_site").toPath());
+ basePath = sitePaths.resolve("base");
+ Config cfg = new Config();
+ cfg.setString("gerrit", null, "basePath", basePath.toString());
+ when(repositoryCfg.getAllBasePaths()).thenReturn(ImmutableList.of());
+ trashFolders = new DeleteTrashFolders(sitePaths, cfg, repositoryCfg);
+ }
+
+ @Test
+ public void testStart() throws Exception {
+ FileRepository repoToDelete = createRepository("repo.1234567890123.deleted");
+ FileRepository repoToKeep = createRepository("anotherRepo.git");
+ trashFolders.start();
+ trashFolders.getWorkerThread().join();
+ assertThat(repoToDelete.getDirectory().exists()).isFalse();
+ assertThat(repoToKeep.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;
+ }
+}
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
new file mode 100644
index 0000000..c03b810
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/FilesystemDeleteHandlerTest.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2018 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.Mockito.when;
+
+import com.google.gerrit.extensions.events.ProjectDeletedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Project;
+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 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.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class FilesystemDeleteHandlerTest {
+
+ @Mock private GitRepositoryManager repoManager;
+ @Mock private ProjectDeletedListener projectDeleteListener;
+ @Mock private Configuration config;
+
+ @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ private DynamicSet<ProjectDeletedListener> deletedListener;
+ private FilesystemDeleteHandler fsDeleteHandler;
+ private Path basePath;
+
+ @Before
+ public void setUp() throws Exception {
+ basePath = tempFolder.newFolder().toPath().resolve("base");
+ deletedListener = new DynamicSet<>();
+ deletedListener.add("", projectDeleteListener);
+ }
+
+ @Test
+ public void shouldDeleteRepository() throws Exception {
+ String repoName = "testRepo";
+ Repository repository = createRepository(repoName);
+ Project.NameKey nameKey = new Project.NameKey(repoName);
+ Project project = new Project(nameKey);
+ when(repoManager.openRepository(nameKey)).thenReturn(repository);
+ when(config.shouldArchiveDeletedRepos()).thenReturn(false);
+ fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config);
+ fsDeleteHandler.delete(project, false);
+ assertThat(repository.getDirectory().exists()).isFalse();
+ }
+
+ @Test
+ public void shouldDeleteEmptyParentFolders() throws Exception {
+ String repoName = "a/b/c";
+ Repository repository = createRepository(repoName);
+ Project.NameKey nameKey = new Project.NameKey(repoName);
+ Project project = new Project(nameKey);
+ when(repoManager.openRepository(nameKey)).thenReturn(repository);
+ fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config);
+ fsDeleteHandler.delete(project, false);
+ 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 = new Project.NameKey(repoToDeleteName);
+ Project project = new Project(nameKey);
+ when(repoManager.openRepository(nameKey)).thenReturn(repoToDelete);
+ fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config);
+ fsDeleteHandler.delete(project, 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 = new Project.NameKey(repoName);
+ Project project = new Project(nameKey);
+ when(repoManager.openRepository(nameKey)).thenReturn(repository);
+ fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config);
+ fsDeleteHandler.delete(project, 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 = new Project.NameKey(repoName);
+ Project project = new Project(nameKey);
+ when(repoManager.openRepository(nameKey)).thenReturn(repository);
+ fsDeleteHandler = new FilesystemDeleteHandler(repoManager, deletedListener, config);
+ fsDeleteHandler.delete(project, 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);
+ }
+ }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/TrashFolderNameMatcherTest.java b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/TrashFolderNameMatcherTest.java
index 7b9b055..102d8d1 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/TrashFolderNameMatcherTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/deleteproject/fs/TrashFolderNameMatcherTest.java
@@ -15,7 +15,7 @@
package com.googlesource.gerrit.plugins.deleteproject.fs;
import static com.google.common.truth.Truth.assertThat;
-import static com.googlesource.gerrit.plugins.deleteproject.fs.DeleteTrashFolders.isTrashFolderName;
+import static com.googlesource.gerrit.plugins.deleteproject.fs.DeleteTrashFolders.TrashFolderPredicate.match;
import org.junit.Test;
@@ -30,6 +30,10 @@
matches("a.1234567890123.%deleted%.git");
matches("aa.1234567890123.%deleted%.git");
matches("a.b.c.1234567890123.%deleted%.git");
+
+ matches("a.20181010120101.%deleted%.git");
+ matches("aa.20181010120101.%deleted%.git");
+ matches("a.b.c.20181010120101.%deleted%.git");
}
@Test
@@ -47,17 +51,20 @@
// missing .git suffix
doesNotMatch("a.1234567890123.%deleted%");
+ doesNotMatch("a.20181010120101.%deleted%");
// additional characters after the "git" suffix
doesNotMatch("a.1234567890123.%deleted%.git.");
doesNotMatch("a.1234567890123.%deleted%.git.git");
+ doesNotMatch("a.20181010120101.%deleted%.git.");
+ doesNotMatch("a.20181010120101.%deleted%.git.git");
}
private void matches(String name) {
- assertThat(isTrashFolderName(name)).isTrue();
+ assertThat(match(name)).isTrue();
}
private void doesNotMatch(String name) {
- assertThat(isTrashFolderName(name)).isFalse();
+ assertThat(match(name)).isFalse();
}
}