Merge pull request !2922 from rhodecode-enterprise-ce feature/RCCE-360__Repo-deletion

feature: implements the feature to delete a repository completely or leave backup
This commit is contained in:
Andrii Verbytskyi 2025-12-23 12:01:57 +00:00
commit d61a7389cb
8 changed files with 137 additions and 36 deletions

View file

@ -144,7 +144,7 @@ create_repo
delete_repo
-----------
.. py:function:: delete_repo(apiuser, repoid, forks=<Optional:''>)
.. py:function:: delete_repo(apiuser, repoid, fs_backup=True, forks=<Optional:''>)
Deletes a repository.
@ -158,6 +158,10 @@ delete_repo
:type apiuser: AuthUser
:param repoid: Set the repository name or repository ID.
:type repoid: str or int
:param fs_backup: When this parameter is set to True,
repo folder will not be deleted from the filesystem,
it will be renamed. The default is True.
:type fs_backup: bool
:param forks: Set to `detach` or `delete` forks from the |repo|.
:type forks: Optional(str)

View file

@ -16,11 +16,13 @@
# RhodeCode Enterprise Edition, including its added features, Support services,
# and proprietary license terms, please see https://rhodecode.com/licenses/
import json
import os
import pytest
from rhodecode.api.utils import Optional, OAttr
from rhodecode.api.tests.utils import build_data, api_call, assert_error, assert_ok
from rhodecode.lib.utils import get_rhodecode_repo_store_path
from rhodecode.model.db import Repository
from rhodecode.model.meta import Session
@ -156,6 +158,42 @@ class TestApi(object):
assert json_body["result"]["archived"] == is_archived
def test_api_delete_repo_with_backup(self, backend):
repo_name = self._create_repo(backend, is_archived=False)
id_, params = build_data(apikey=self.apikey, method="delete_repo", repoid=repo_name)
response = api_call(self.app, params)
assert response.status == "200 OK"
json_body = json.loads(response.body)
assert json_body["result"]["success"]
assert f"Deleted repository `{repo_name}`" == json_body["result"]["msg"]
assert repo_name in self._list_removed_repos_folders_only()
def test_api_delete_repo_without_backup(self, backend):
repo_name = self._create_repo(backend, is_archived=False)
id_, params = build_data(apikey=self.apikey, method="delete_repo", repoid=repo_name, fs_backup=False)
response = api_call(self.app, params)
assert response.status == "200 OK"
json_body = json.loads(response.body)
assert json_body["result"]["success"]
assert f"Deleted repository `{repo_name}`" == json_body["result"]["msg"]
assert repo_name not in self._list_removed_repos_folders_only()
def _list_removed_repos_folders_only(self):
temp_folder_with_repos = get_rhodecode_repo_store_path()
res = []
for r in os.listdir(temp_folder_with_repos):
if r.startswith("rm__"):
removed_repo_name = r.split("__", 2)[-1]
res.append(removed_repo_name)
return res
def _create_repo(self, backend, is_archived):
repo = backend.create_repo()
repo_name = repo.repo_name

View file

@ -1331,7 +1331,7 @@ def fork_repo(
@jsonrpc_method()
def delete_repo(request, apiuser, repoid, forks=Optional("")):
def delete_repo(request, apiuser, repoid, fs_backup=True, forks=Optional("")):
"""
Deletes a repository.
@ -1345,6 +1345,8 @@ def delete_repo(request, apiuser, repoid, forks=Optional("")):
:type apiuser: AuthUser
:param repoid: Set the repository name or repository ID.
:type repoid: str or int
:param fs_backup:
:type fs_backup: bool
:param forks: Set to `detach` or `delete` forks from the |repo|.
:type forks: Optional(str)
@ -1378,7 +1380,7 @@ def delete_repo(request, apiuser, repoid, forks=Optional("")):
raise JSONRPCError(f"Cannot delete `{repo.repo_name}` it still contains attached forks")
old_data = repo.get_api_data()
RepoModel().delete(repo, forks_action=ForksAction(str_fork_action))
RepoModel().delete(repo, forks_action=ForksAction(str_fork_action), fs_backup=fs_backup)
repo = audit_logger.RepoWrap(repo_id=None, repo_name=repo.repo_name)

View file

@ -27,6 +27,7 @@ from rhodecode.lib import helpers as h
from rhodecode.lib import audit_logger
from rhodecode.lib.auth import LoginRequired, HasRepoPermissionAnyDecorator, CSRFRequired, HasRepoPermissionAny
from rhodecode.lib.exceptions import AttachedForksError, AttachedPullRequestsError, AttachedArtifactsError
from rhodecode.lib.type_utils import str2bool
from rhodecode.lib.utils2 import safe_int
from rhodecode.lib.vcs import RepositoryError
from rhodecode.model.db import Session, UserFollowing, User, Repository
@ -143,7 +144,10 @@ class RepoSettingsAdvancedView(RepoAppView):
delete_cache = True
self._invalidate_remote_cache(delete=delete_cache)
delete_res = RepoModel().delete(self.db_repo)
backup = str2bool(
self.request.POST.get("backup", "false")
) # if this option is absent, this means that on FE side checkbox was unchecked
delete_res = RepoModel().delete(self.db_repo, fs_backup=backup)
fork_action = delete_res.fork_action_result.action
fork_cnt = delete_res.fork_action_result.fork_count

View file

@ -790,6 +790,7 @@ class RepoModel(BaseModel):
fs_remove: bool = True,
cur_user: User = None,
call_events: bool = True,
fs_backup: bool = True,
) -> DeleteRepoResult:
"""
Delete a given repository.
@ -797,11 +798,10 @@ class RepoModel(BaseModel):
:param repo:
:param forks_action:
NOTE: used only in API to enforce action;
If the repository is private, then forks will be removed.
If the repository is public, then forks will be detached.
:param fs_remove: remove(archive) repo from filesystem
NOTE: used only in API to enforce action;
If the repository is private, then forks will be removed.
If the repository is public, then forks will be detached.
:param fs_backup: flag that tells if the repo folder on filesystem should be renamed in a special format or removed completely
"""
repo = self._get_repo(repo)
if not repo:
@ -835,7 +835,7 @@ class RepoModel(BaseModel):
try:
self.sa.delete(repo)
if fs_remove:
self._delete_filesystem_repo(repo)
self._delete_filesystem_repo(repo, backup=fs_backup)
else:
log.debug("skipping removal from filesystem")
old_repo_dict.update(
@ -1115,7 +1115,7 @@ class RepoModel(BaseModel):
raise Exception("Was trying to rename to already existing dir %s" % new_path)
shutil.move(old_path, new_path)
def _delete_filesystem_repo(self, repo):
def _delete_filesystem_repo(self, repo, backup=True):
"""
removes repo from filesystem, the removal is actually made by
added rm__ prefix into dir, and rename internal .hg/.git dirs so this
@ -1123,9 +1123,9 @@ class RepoModel(BaseModel):
by reverting the renames on this repository
:param repo: repo object
:param backup: flag that tells if the repo folder on filesystem should be renamed in a special format or removed completely
"""
rm_path = os.path.join(self.repos_path, repo.repo_name)
repo_group = repo.group
log.info("delete_filesystem_repo: removing repository %s", rm_path)
# disable hg/git internal that it doesn't get detected as repo
alias = repo.repo_type
@ -1134,23 +1134,36 @@ class RepoModel(BaseModel):
config.set("extensions", "largefiles", "")
bare = getattr(repo.scm_instance(config=config), "bare", False)
# skip this for bare git repos
if not bare:
# disable VCS repo
vcs_path = os.path.join(rm_path, ".%s" % alias)
if os.path.exists(vcs_path):
shutil.move(vcs_path, os.path.join(rm_path, "rm__.%s" % alias))
if backup:
# skip this for bare git repos
if not bare:
# disable VCS repo
vcs_path = os.path.join(rm_path, ".%s" % alias)
if os.path.exists(vcs_path):
shutil.move(vcs_path, os.path.join(rm_path, "rm__.%s" % alias))
_now = datetime.datetime.now()
_ms = str(_now.microsecond).rjust(6, "0")
_d = "rm__{}__{}".format(_now.strftime("%Y%m%d_%H%M%S_" + _ms), repo.just_name)
if repo_group:
# if repository is in group, prefix the removal path with the group
args = repo_group.full_path_splitted + [_d]
_d = os.path.join(*args)
repo_group = repo.group
if os.path.isdir(rm_path):
shutil.move(rm_path, os.path.join(self.repos_path, _d))
_now = datetime.datetime.now()
_ms = str(_now.microsecond).rjust(6, "0")
_d = "rm__{}__{}".format(_now.strftime("%Y%m%d_%H%M%S_" + _ms), repo.just_name)
if repo_group:
# if repository is in group, prefix the removal path with the group
args = repo_group.full_path_splitted + [_d]
_d = os.path.join(*args)
if os.path.isdir(rm_path):
shutil.move(rm_path, os.path.join(self.repos_path, _d))
else:
# skip this for bare git repos
if not bare:
# disable VCS repo
vcs_path = os.path.join(rm_path, ".%s" % alias)
if os.path.exists(vcs_path):
shutil.rmtree(vcs_path)
if os.path.exists(rm_path):
shutil.rmtree(rm_path)
# finally cleanup diff-cache if it exists
cached_diffs_dir = repo.cached_diffs_dir

View file

@ -247,6 +247,12 @@
</button>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" id="backup" name="backup" value="true" checked>
${_('Keep backup in file system ')}
</label>
</div>
<div class="field" id="archive_help_field">
<span class="help-block">
${_('This repository will be renamed in a special way in order to make it inaccessible to RhodeCode Enterprise and its VCS systems. If you need to fully delete it from the file system, please do it manually, or with rhodecode-cleanup-repos command available in rhodecode-tools.')}
</span>
@ -311,5 +317,19 @@ $("#id_fork_of").select2({
}
})
});
// hide/show archive help field
const $checkbox = $('#backup');
const $helpField = $('#archive_help_field');
function syncHelpVisibility() {
$checkbox.val($checkbox.is(':checked') ? 'true' : 'false');
$helpField.toggle($checkbox.is(':checked'));
}
$checkbox.on('change', syncHelpVisibility)
syncHelpVisibility();
</script>

View file

@ -174,14 +174,6 @@ ${h.style_metatag(tag_type, tag)|n,trim}
Edit
</a>
</div>
<div class="grid_delete">
${h.secure_form(h.route_path('edit_repo_advanced_delete', repo_name=repo_name), request=request)}
<input class="btn btn-link btn-danger" id="remove_${repo_name}" name="remove_${repo_name}"
onclick="submitConfirm(event, this, _gettext('Confirm to delete this repository'), _gettext('Delete'), '${repo_name}')"
type="submit" value="Delete"
>
${h.end_form()}
</div>
</div>
</%def>

View file

@ -23,7 +23,7 @@ import tempfile
from rhodecode.lib.exceptions import AttachedForksError
from rhodecode.lib.utils import make_db_config
from rhodecode.lib.utils import make_db_config, get_rhodecode_repo_store_path
from rhodecode.model.db import Repository
from rhodecode.model.meta import Session
from rhodecode.model.repo import RepoModel, ForksAction
@ -42,6 +42,34 @@ class TestRepoModel(object):
assert Repository.get_by_repo_name(repo_name=backend.repo_name) is None
assert repo.repo_name not in repos
def test_remove_repo_with_backup(self, backend):
repo = backend.create_repo()
Session().commit()
RepoModel().delete(repo=repo)
Session().commit()
assert Repository.get_by_repo_name(repo_name=backend.repo_name) is None
assert repo.repo_name in self._list_removed_repos_folders_only()
def test_remove_repo_without_backup(self, backend):
repo = backend.create_repo()
Session().commit()
RepoModel().delete(repo=repo, fs_backup=False)
Session().commit()
assert Repository.get_by_repo_name(repo_name=backend.repo_name) is None
assert repo.repo_name not in self._list_removed_repos_folders_only()
def _list_removed_repos_folders_only(self):
temp_folder_with_repos = get_rhodecode_repo_store_path()
res = []
for r in os.listdir(temp_folder_with_repos):
if r.startswith("rm__"):
removed_repo_name = r.split("__", 2)[-1]
res.append(removed_repo_name)
return res
def test_remove_repo_forks_are_detached(self, backend):
repo = backend.create_repo()
Session().commit()