diff --git a/rhodecode/apps/_base/__init__.py b/rhodecode/apps/_base/__init__.py --- a/rhodecode/apps/_base/__init__.py +++ b/rhodecode/apps/_base/__init__.py @@ -715,6 +715,7 @@ class BaseReferencesView(RepoAppView): { "name": _render("name", ref_name, files_url, closed), "name_raw": ref_name, + "closed": closed, "date": _render("date", commit.date), "date_raw": datetime_to_time(commit.date), "author": _render("author", commit.author), diff --git a/rhodecode/apps/repository/__init__.py b/rhodecode/apps/repository/__init__.py --- a/rhodecode/apps/repository/__init__.py +++ b/rhodecode/apps/repository/__init__.py @@ -591,6 +591,15 @@ def includeme(config): route_name='branches_home', request_method='GET', renderer='rhodecode:templates/branches/branches.mako') + config.add_route( + name='branch_remove', + pattern='/{repo_name:.*?[^/]}/{branch_name:.*?[^/]}/remove', repo_route=True, repo_accepted_types=['hg', 'git']) + config.add_view( + RepoBranchesView, + attr='remove_branch', + route_name='branch_remove', request_method='POST' + ) + # Bookmarks config.add_route( name='bookmarks_home', diff --git a/rhodecode/apps/repository/tests/test_repo_branches.py b/rhodecode/apps/repository/tests/test_repo_branches.py --- a/rhodecode/apps/repository/tests/test_repo_branches.py +++ b/rhodecode/apps/repository/tests/test_repo_branches.py @@ -19,6 +19,7 @@ import pytest from rhodecode.model.db import Repository from rhodecode.tests.routes import route_path +from rhodecode.tests import assert_session_flash @pytest.mark.usefixtures('autologin_user', 'app') @@ -33,3 +34,50 @@ class TestBranchesController(object): for commit_id, obj_name in repo.scm_instance().branches.items(): assert commit_id in response assert obj_name in response + + def test_landing_branch_delete(self, backend, csrf_token): + if backend.alias == 'svn': + pytest.skip("Not supported yet") + branch_related_data_per_backend = { + 'git': {'name': 'master'}, + 'hg': {'name': 'default'}, + } + response = self.app.post( + route_path('branch_remove', repo_name=backend.repo_name, + branch_name=branch_related_data_per_backend[backend.alias]['name']), + params={'csrf_token': csrf_token}, status=302) + assert_session_flash( + response, + f"This branch {branch_related_data_per_backend[backend.alias]['name']} cannot be removed as it's currently set as landing branch" + ) + + def test_delete_branch_by_repo_owner(self, backend, csrf_token): + if backend.alias in ('svn', 'hg'): + pytest.skip("Skipping for hg and svn") + branch_to_be_removed = 'remove_me' + repo = Repository.get_by_repo_name(backend.repo_name) + repo.scm_instance()._create_branch(branch_to_be_removed, repo.scm_instance().commit_ids[1]) + response = self.app.post( + route_path('branch_remove', repo_name=backend.repo_name, + branch_name=branch_to_be_removed), + params={'csrf_token': csrf_token}, status=302) + assert_session_flash(response, f"Branch {branch_to_be_removed} has been successfully deleted") + + def test_delete_branch_by_not_repo_owner(self, backend, csrf_token): + username = 'test_regular' + pwd = 'test12' + branch_related_data_per_backend = { + 'git': {'name': 'master', 'action': 'deleted'}, + 'hg': {'name': 'stable', 'action': 'closed'}, + } + if backend.alias == 'svn': + pytest.skip("Not supported yet") + self.app.post(route_path('login'), + {'username': username, + 'password': pwd}) + selected_branch = branch_related_data_per_backend[backend.alias]['name'] + response = self.app.post( + route_path('branch_remove', repo_name=backend.repo_name, + branch_name=selected_branch), + params={'csrf_token': csrf_token, 'username': username, 'password': pwd}, status=404) + assert response.status_code == 404 diff --git a/rhodecode/apps/repository/views/repo_branches.py b/rhodecode/apps/repository/views/repo_branches.py --- a/rhodecode/apps/repository/views/repo_branches.py +++ b/rhodecode/apps/repository/views/repo_branches.py @@ -18,11 +18,15 @@ import logging +from pyramid.httpexceptions import HTTPFound from rhodecode.apps._base import BaseReferencesView from rhodecode.lib import ext_json -from rhodecode.lib.auth import (LoginRequired, HasRepoPermissionAnyDecorator) +from rhodecode.lib import helpers as h +from rhodecode.lib.auth import (LoginRequired, HasRepoPermissionAnyDecorator, CSRFRequired) from rhodecode.model.scm import ScmModel +from rhodecode.model.meta import Session +from rhodecode.model.db import PullRequest log = logging.getLogger(__name__) @@ -33,15 +37,71 @@ class RepoBranchesView(BaseReferencesVie @HasRepoPermissionAnyDecorator( 'repository.read', 'repository.write', 'repository.admin') def branches(self): + partial_render = self.request.get_partial_renderer( + 'rhodecode:templates/data_table/_dt_elements.mako') + repo_name = self.db_repo_name c = self.load_default_context() self._prepare_and_set_clone_url(c) c.rhodecode_repo = self.rhodecode_vcs_repo c.repository_forks = ScmModel().get_forks(self.db_repo) - ref_items = self.rhodecode_vcs_repo.branches_all.items() data = self.load_refs_context( ref_items=ref_items, partials_template='branches/branches_data.mako') - + data_with_actions = [] + if self.db_repo.repo_type != 'svn': + for branch in data: + branch['action'] = partial_render( + f"branch_actions_{self.db_repo.repo_type}", branch['name_raw'], repo_name, closed=branch['closed'] + ) + data_with_actions.append(branch) + data = data_with_actions c.has_references = bool(data) c.data = ext_json.str_json(data) return self._get_template_context(c) + + @LoginRequired() + @HasRepoPermissionAnyDecorator('repository.write', 'repository.admin') + @CSRFRequired() + def remove_branch(self): + _ = self.request.translate + self.load_default_context() + repo = self.db_repo + repo_name = self.db_repo_name + repo_type = repo.repo_type + action = _('deleted') if repo_type == 'git' else _('closed') + redirect = HTTPFound(location=self.request.route_path('branches_home', repo_name=repo_name)) + branch_name = self.request.matchdict.get('branch_name') + if repo.landing_ref_name == branch_name: + h.flash( + _("This branch {} cannot be removed as it's currently set as landing branch").format(branch_name), + category='error' + ) + return redirect + if prs_related_to := Session().query(PullRequest).filter(PullRequest.target_repo_id == repo.repo_id, + PullRequest.status != PullRequest.STATUS_CLOSED).filter( + (PullRequest.source_ref.like(f'branch:{branch_name}:%')) | ( + PullRequest.target_ref.like(f'branch:{branch_name}:%')) + ).all(): + h.flash(_("Branch cannot be {} - it's used in following open Pull Request ids: {}").format(action, ','.join( + map(str, prs_related_to))), category='error') + return redirect + + match repo_type: + case 'git': + self.rhodecode_vcs_repo.delete_branch(branch_name) + case 'hg': + from rhodecode.lib.vcs.backends.base import Reference + self.rhodecode_vcs_repo._local_close( + source_ref=Reference(type='branch', name=branch_name, + commit_id=self.rhodecode_vcs_repo.branches[branch_name]), + target_ref=Reference(type='branch', name='', commit_id=None), + user_name=self.request.user.name, + user_email=self.request.user.email) + case _: + raise NotImplementedError('Branch deleting functionality not yet implemented') + ScmModel().mark_for_invalidation(repo_name) + self.rhodecode_vcs_repo._invalidate_prop_cache('commit_ids') + self.rhodecode_vcs_repo._invalidate_prop_cache('_refs') + self.rhodecode_vcs_repo._invalidate_prop_cache('branches') + h.flash(_("Branch {} has been successfully {}").format(branch_name, action), category='success') + return redirect diff --git a/rhodecode/lib/vcs/backends/git/repository.py b/rhodecode/lib/vcs/backends/git/repository.py --- a/rhodecode/lib/vcs/backends/git/repository.py +++ b/rhodecode/lib/vcs/backends/git/repository.py @@ -326,6 +326,9 @@ class GitRepository(BaseRepository): def _get_branches(self): return self._get_refs_entries(prefix='refs/heads/', strip_prefix=True) + def delete_branch(self, branch_name): + return self._remote.delete_branch(branch_name) + @CachedProperty def branches(self): return self._get_branches() diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -4585,6 +4585,12 @@ class PullRequest(Base, _PullRequestBase else: return f'' + def __str__(self): + if self.pull_request_id: + return f'#{self.pull_request_id}' + else: + return f'#{id(self)!r}' + reviewers = relationship('PullRequestReviewers', cascade="all, delete-orphan", back_populates='pull_request') statuses = relationship('ChangesetStatus', cascade="all, delete-orphan", back_populates='pull_request') comments = relationship('ChangesetComment', cascade="all, delete-orphan", back_populates='pull_request') diff --git a/rhodecode/public/js/rhodecode/routes.js b/rhodecode/public/js/rhodecode/routes.js --- a/rhodecode/public/js/rhodecode/routes.js +++ b/rhodecode/public/js/rhodecode/routes.js @@ -92,6 +92,7 @@ function registerRCRoutes() { pyroutes.register('auth_home', '/_admin/auth*traverse', []); pyroutes.register('bookmarks_home', '/%(repo_name)s/bookmarks', ['repo_name']); pyroutes.register('branches_home', '/%(repo_name)s/branches', ['repo_name']); + pyroutes.register('branch_remove', '/%(repo_name)s/%(branch_name)s/remove', ['repo_name', 'branch_name']); pyroutes.register('channelstream_connect', '/_admin/channelstream/connect', []); pyroutes.register('channelstream_proxy', '/_channelstream', []); pyroutes.register('channelstream_subscribe', '/_admin/channelstream/subscribe', []); diff --git a/rhodecode/templates/branches/branches.mako b/rhodecode/templates/branches/branches.mako --- a/rhodecode/templates/branches/branches.mako +++ b/rhodecode/templates/branches/branches.mako @@ -62,13 +62,8 @@ }; var branches_data = ${c.data|n}; - // object list - $('#obj_list_table').DataTable({ - data: branches_data, - dom: 'rtp', - pageLength: ${c.visual.dashboard_items}, - order: [[ 0, "asc" ]], - columns: [ + var repo_type = "${c.rhodecode_db_repo.repo_type}"; + var columns = [ { data: {"_": "name", "sort": "name_raw"}, title: "${_('Name')}", className: "td-tags" }, { data: {"_": "date", @@ -80,7 +75,22 @@ "type": Number}, title: "${_('Commit')}", className: "td-hash" }, { data: {"_": "compare", "sort": "compare"}, title: "${_('Compare')}", className: "td-compare" } - ], + ]; + if (repo_type !== 'svn') { + columns.push({ + data: { "_": "action", "sort": "action" }, + title: `${_('Action')}`, + className: "td-action", + orderable: false + }); + } + + $('#obj_list_table').DataTable({ + data: branches_data, + dom: 'rtp', + pageLength: ${c.visual.dashboard_items}, + order: [[ 0, "asc" ]], + columns: columns, language: { paginate: DEFAULT_GRID_PAGINATION, emptyTable: _gettext("No branches available yet.") diff --git a/rhodecode/templates/data_table/_dt_elements.mako b/rhodecode/templates/data_table/_dt_elements.mako --- a/rhodecode/templates/data_table/_dt_elements.mako +++ b/rhodecode/templates/data_table/_dt_elements.mako @@ -277,6 +277,30 @@ +<%def name="branch_actions_git(branch_name, repo_name, **kwargs)"> +
+ ${h.secure_form(h.route_path('branch_remove', repo_name=repo_name, branch_name=branch_name), request=request)} + + ${h.end_form()} +
+ + +<%def name="branch_actions_hg(branch_name, repo_name, **kwargs)"> +
+ %if not kwargs['closed']: + ${h.secure_form(h.route_path('branch_remove', repo_name=repo_name, branch_name=branch_name), request=request)} + + ${h.end_form()} + %endif +
+ + <%def name="user_group_actions(user_group_id, user_group_name)">
Edit diff --git a/rhodecode/tests/routes.py b/rhodecode/tests/routes.py --- a/rhodecode/tests/routes.py +++ b/rhodecode/tests/routes.py @@ -252,6 +252,7 @@ def get_url_defs(): "pullrequest_show_all_data": "/{repo_name}/pull-request-data", "bookmarks_home": "/{repo_name}/bookmarks", "branches_home": "/{repo_name}/branches", + "branch_remove": "/{repo_name}/{branch_name}/remove", "tags_home": "/{repo_name}/tags", "repo_changelog": "/{repo_name}/changelog", "repo_commits": "/{repo_name}/commits",