diff --git a/actions/gitweb b/actions/gitweb index 023b2c269..4393d48f8 100755 --- a/actions/gitweb +++ b/actions/gitweb @@ -12,6 +12,7 @@ import os import re import shutil import subprocess +import sys import time from plinth import action_utils @@ -23,6 +24,7 @@ logger = logging.getLogger(__name__) class ValidateRepoName(argparse.Action): """Validate a repository name and add .git extension if necessary.""" + def __call__(self, parser, namespace, values, option_string=None): RepositoryValidator()(values) if not values.endswith('.git'): @@ -32,6 +34,7 @@ class ValidateRepoName(argparse.Action): class ValidateRepoUrl(argparse.Action): """Validate a repository URL.""" + def __call__(self, parser, namespace, values, option_string=None): RepositoryValidator(input_should_be='url')(values) setattr(namespace, self.dest, values) @@ -86,6 +89,18 @@ def parse_arguments(): subparser.add_argument('--newname', required=True, action=ValidateRepoName, help='New name of the repository') + subparser = subparsers.add_parser( + 'set-default-branch', help='Set default branch of the repository') + subparser.add_argument('--name', required=True, action=ValidateRepoName, + help='Name of the repository') + subparser.add_argument('--branch', required=True, + help='Name of the branch') + + subparser = subparsers.add_parser( + 'get-branches', help='Get all the branches of the repository') + subparser.add_argument('--name', required=True, action=ValidateRepoName, + help='Name of the repository') + subparser = subparsers.add_parser('set-repo-description', help='Set description of the repository') subparser.add_argument('--name', required=True, action=ValidateRepoName, @@ -245,7 +260,7 @@ def _create_repo(arguments): """Create an empty repository.""" repo = arguments.name try: - subprocess.check_call(['git', 'init', '--bare', repo], + subprocess.check_call(['git', 'init', '-q', '--bare', repo], cwd=GIT_REPO_PATH) if not arguments.keep_ownership: subprocess.check_call(['chown', '-R', 'www-data:www-data', repo], @@ -261,6 +276,15 @@ def _create_repo(arguments): raise +def _get_default_branch(repo): + """Get default branch of the repository.""" + repo_path = os.path.join(GIT_REPO_PATH, repo) + + return subprocess.check_output( + ['git', '-C', repo_path, 'symbolic-ref', '--short', + 'HEAD']).decode().strip() + + def _get_repo_description(repo): """Set description of the repository.""" description_file = os.path.join(GIT_REPO_PATH, repo, 'description') @@ -325,6 +349,25 @@ def _set_access_status(repo, status): os.remove(private_file) +def _get_branches(repo): + """Return list of the branches in the repository.""" + output = subprocess.check_output( + ['git', '-C', repo, 'branch', '--format=%(refname:short)'], + cwd=GIT_REPO_PATH) + + return output.decode().strip().split() + + +def subcommand_get_branches(arguments): + """Check whether a branch exists in the repository.""" + repo = arguments.name + + print( + json.dumps( + dict(default_branch=_get_default_branch(repo), + branches=_get_branches(repo)))) + + def subcommand_rename_repo(arguments): """Rename a repository.""" oldpath = os.path.join(GIT_REPO_PATH, arguments.oldname) @@ -332,6 +375,20 @@ def subcommand_rename_repo(arguments): os.rename(oldpath, newpath) +def subcommand_set_default_branch(arguments): + """Set description of the repository.""" + repo = arguments.name + branch = arguments.branch + + if branch not in _get_branches(repo): + sys.exit('No such branch.') + + subprocess.check_call([ + 'git', '-C', repo, 'symbolic-ref', 'HEAD', + "refs/heads/{}".format(branch) + ], cwd=GIT_REPO_PATH) + + def subcommand_set_repo_description(arguments): """Set description of the repository.""" _set_repo_description(arguments.name, arguments.description) @@ -355,10 +412,13 @@ def subcommand_repo_info(arguments): print( json.dumps( - dict(name=arguments.name[:-4], - description=_get_repo_description(arguments.name), - owner=_get_repo_owner(arguments.name), - access=_get_access_status(arguments.name)))) + dict( + name=arguments.name[:-4], + description=_get_repo_description(arguments.name), + owner=_get_repo_owner(arguments.name), + access=_get_access_status(arguments.name), + default_branch=_get_default_branch(arguments.name), + ))) def subcommand_create_repo(arguments): diff --git a/plinth/modules/gitweb/__init__.py b/plinth/modules/gitweb/__init__.py index 9bd3a310a..4254ab9d7 100644 --- a/plinth/modules/gitweb/__init__.py +++ b/plinth/modules/gitweb/__init__.py @@ -18,9 +18,7 @@ from plinth.modules.users.components import UsersAndGroups from .forms import is_repo_url from .manifest import ( # noqa, pylint: disable=unused-import - GIT_REPO_PATH, - backup, - clients) + GIT_REPO_PATH, backup, clients) version = 1 @@ -233,6 +231,18 @@ def _rename_repo(oldname, newname): actions.superuser_run('gitweb', args) +def _set_default_branch(repo, branch): + """Set default branch of the repository.""" + args = [ + 'set-default-branch', + '--name', + repo, + '--branch', + branch, + ] + actions.superuser_run('gitweb', args) + + def _set_repo_description(repo, repo_description): """Set description of the repository.""" args = [ @@ -277,6 +287,9 @@ def edit_repo(form_initial, form_cleaned): else: _set_repo_access(repo, 'public') + if form_cleaned['default_branch'] != form_initial['default_branch']: + _set_default_branch(repo, form_cleaned['default_branch']) + def delete_repo(repo): """Delete a repository.""" diff --git a/plinth/modules/gitweb/forms.py b/plinth/modules/gitweb/forms.py index d1bdbc557..9501d8884 100644 --- a/plinth/modules/gitweb/forms.py +++ b/plinth/modules/gitweb/forms.py @@ -3,6 +3,7 @@ Django form for configuring Gitweb. """ +import json import re from urllib.parse import urlparse @@ -11,9 +12,23 @@ from django.core.exceptions import ValidationError from django.core.validators import URLValidator from django.utils.translation import ugettext_lazy as _ +from plinth import actions from plinth.modules import gitweb +def _get_branches(repo): + """Get all the branches in the repository.""" + branch_data = json.loads( + actions.run('gitweb', ['get-branches', '--name', repo])) + default_branch = branch_data['default_branch'] + branches = branch_data['branches'] + + if default_branch not in branches: + branches.insert(0, default_branch) + + return [(branch, branch) for branch in branches] + + def get_name_from_url(url): """Get a repository name from URL""" return urlparse(url).path.split('/')[-1] @@ -115,6 +130,16 @@ class EditRepoForm(CreateRepoForm): 'An alpha-numeric string that uniquely identifies a repository.'), ) + default_branch = forms.ChoiceField( + label=_('Default branch'), + help_text=_('Gitweb displays this as a default branch.')) + + def __init__(self, *args, **kwargs): + """Initialize the form with extra request argument.""" + super().__init__(*args, **kwargs) + branches = _get_branches(self.initial['name']) + self.fields['default_branch'].choices = branches + def clean_name(self): """Check if the name is valid.""" name = self.cleaned_data['name'] diff --git a/plinth/modules/gitweb/tests/gitweb.feature b/plinth/modules/gitweb/tests/gitweb.feature index 3719632a4..37a98aa5a 100644 --- a/plinth/modules/gitweb/tests/gitweb.feature +++ b/plinth/modules/gitweb/tests/gitweb.feature @@ -27,12 +27,6 @@ Scenario: Create private repository Then the repository should be listed as a private And the repository should be listed on gitweb -Scenario: Delete repository - Given the gitweb application is enabled - And a repository - When I delete the repository - Then the repository should not be listed - @backups Scenario: Backup and restore gitweb Given the gitweb application is enabled @@ -70,6 +64,13 @@ Scenario: Edit repository metadata And I set the metadata of the repository Then the metadata of the repository should be as set +Scenario: Edit default branch of the repository + Given the gitweb application is enabled + And a repository with the branch branch1 + When I set branch1 as a default branch + Then the gitweb site should show branch1 as a default repo branch + + Scenario: Access public repository with git client Given the gitweb application is enabled And a public repository @@ -87,6 +88,12 @@ Scenario: Access private repository with git client And the repository should be privately readable And the repository should be privately writable +Scenario: Delete repository + Given the gitweb application is enabled + And a repository + When I delete the repository + Then the repository should not be listed + Scenario: Disable gitweb application Given the gitweb application is enabled When I disable the gitweb application diff --git a/plinth/modules/gitweb/tests/test_actions.py b/plinth/modules/gitweb/tests/test_actions.py index 0224004e5..2f1bc062c 100644 --- a/plinth/modules/gitweb/tests/test_actions.py +++ b/plinth/modules/gitweb/tests/test_actions.py @@ -11,6 +11,15 @@ from unittest.mock import patch import pytest from django.forms import ValidationError +REPO_NAME = 'Test-repo' +REPO_DATA = { + 'name': REPO_NAME, + 'description': '', + 'owner': '', + 'access': 'private', + 'default_branch': 'master', +} + def _action_file(): """Return the path to the 'gitweb' actions file.""" @@ -25,6 +34,7 @@ gitweb_actions = imp.load_source('gitweb', _action_file()) @pytest.fixture(name='call_action') def fixture_call_action(tmpdir, capsys): """Run actions with custom repo root path.""" + def _call_action(args, **kwargs): gitweb_actions.GIT_REPO_PATH = str(tmpdir) with patch('argparse._sys.argv', ['gitweb'] + args): @@ -35,47 +45,85 @@ def fixture_call_action(tmpdir, capsys): return _call_action -def test_actions(call_action): - """Test gitweb actions script.""" - repo = 'Test-repo' - repo_renamed = 'Test-repo_2' - data = { - 'name': repo, - 'description': 'Test description', - 'owner': 'Test owner', - 'access': 'private' +@pytest.fixture(name='existing_repo') +def fixture_existing_repo(call_action): + """A fixture to create a repository.""" + try: + call_action(['delete-repo', '--name', REPO_NAME]) + except FileNotFoundError: + pass + + call_action([ + 'create-repo', '--name', REPO_NAME, '--description', '', '--owner', '', + '--is-private', '--keep-ownership' + ]) + + +def test_create_repo(call_action): + """Test creating a repository.""" + call_action([ + 'create-repo', '--name', REPO_NAME, '--description', '', '--owner', '', + '--is-private', '--keep-ownership' + ]) + + assert json.loads(call_action(['repo-info', '--name', + REPO_NAME])) == REPO_DATA + + +def test_change_repo_medatada(call_action, existing_repo): + """Test change a metadata of the repository.""" + new_data = { + 'name': REPO_NAME, + 'description': 'description2', + 'owner': 'owner2', + 'access': 'public', + 'default_branch': 'master', } - # Create repository call_action([ - 'create-repo', '--name', repo, '--description', data['description'], - '--owner', data['owner'], '--is-private', '--keep-ownership' + 'set-repo-description', '--name', REPO_NAME, '--description', + new_data['description'] ]) - assert json.loads(call_action(['repo-info', '--name', repo])) == data - - # Change metadata - data['description'] = 'Test description 2' - data['owner'] = 'Test owner 2' - data['access'] = 'public' - call_action([ - 'set-repo-description', '--name', repo, '--description', - data['description'] - ]) - call_action(['set-repo-owner', '--name', repo, '--owner', data['owner']]) call_action( - ['set-repo-access', '--name', repo, '--access', data['access']]) - assert json.loads(call_action(['repo-info', '--name', repo])) == data + ['set-repo-owner', '--name', REPO_NAME, '--owner', new_data['owner']]) + call_action([ + 'set-repo-access', '--name', REPO_NAME, '--access', new_data['access'] + ]) - # Rename repository - call_action(['rename-repo', '--oldname', repo, '--newname', repo_renamed]) - with pytest.raises(RuntimeError, match='Repository not found'): - call_action(['repo-info', '--name', repo]) - assert call_action(['repo-info', '--name', repo_renamed]) + assert json.loads(call_action(['repo-info', '--name', + REPO_NAME])) == new_data - # Delete repository - call_action(['delete-repo', '--name', repo_renamed]) + +def test_rename_repository(call_action, existing_repo): + """Test renaming a repository.""" + new_name = 'Test-repo_2' + + call_action(['rename-repo', '--oldname', REPO_NAME, '--newname', new_name]) with pytest.raises(RuntimeError, match='Repository not found'): - call_action(['repo-info', '--name', repo_renamed]) + call_action(['repo-info', '--name', REPO_NAME]) + + assert json.loads(call_action(['repo-info', '--name', new_name])) == { + **REPO_DATA, + **{ + 'name': new_name + } + } + + +def test_get_branches(call_action, existing_repo): + """Test getting all the branches of the repository.""" + assert json.loads(call_action(['get-branches', '--name', REPO_NAME])) == { + "default_branch": "master", + "branches": [] + } + + +def test_delete_repository(call_action, existing_repo): + """Test deleting a repository.""" + call_action(['delete-repo', '--name', REPO_NAME]) + + with pytest.raises(RuntimeError, match='Repository not found'): + call_action(['repo-info', '--name', REPO_NAME]) @pytest.mark.parametrize( diff --git a/plinth/modules/gitweb/tests/test_functional.py b/plinth/modules/gitweb/tests/test_functional.py index 2dbbbaf8c..090ed08b7 100644 --- a/plinth/modules/gitweb/tests/test_functional.py +++ b/plinth/modules/gitweb/tests/test_functional.py @@ -30,6 +30,13 @@ def gitweb_private_repo(session_browser): _create_repo(session_browser, 'Test-repo', 'private', True) +@given(parsers.parse('a repository with the branch {branch:w}')) +def _create_repo_with_branch(session_browser, branch): + _delete_repo(session_browser, 'Test-repo', ignore_missing=True) + _create_repo(session_browser, 'Test-repo', 'public') + _create_branch('Test-repo', branch) + + @given('both public and private repositories exist') def gitweb_public_and_private_repo(session_browser): _create_repo(session_browser, 'Test-repo', 'public', True) @@ -66,6 +73,11 @@ def gitweb_delete_repo(session_browser): _delete_repo(session_browser, 'Test-repo') +@when(parsers.parse('I set {branch:w} as a default branch')) +def gitweb_set_default_branch(session_browser, branch): + _set_default_branch(session_browser, 'Test-repo', branch) + + @when('I set the metadata of the repository') def gitweb_edit_repo_metadata(session_browser, gitweb_repo_metadata): _edit_repo_metadata(session_browser, 'Test-repo', gitweb_repo_metadata) @@ -76,6 +88,14 @@ def gitweb_using_git_client(): pass +@then( + parsers.parse( + 'the gitweb site should show {branch:w} as a default repo branch')) +def gitweb_site_check_default_repo_branch(session_browser, branch): + assert _get_gitweb_site_default_repo_branch(session_browser, + 'Test-repo') == branch + + @then('the repository should be restored') @then('the repository should be listed as a public') def gitweb_repo_should_exists(session_browser): @@ -142,6 +162,39 @@ def gitweb_repo_privately_writable(): url_git_extension=True) +def _create_branch(repo, branch): + """Create a branch on the remote repository.""" + repo_url = _get_repo_url(repo, with_auth=True) + + with _gitweb_temp_directory() as temp_directory: + repo_path = os.path.join(temp_directory, repo) + + _create_local_repo(repo_path) + + add_branch_commands = [['git', 'checkout', '-q', '-b', branch], + [ + 'git', '-c', 'user.name=Tester', '-c', + 'user.email=tester', 'commit', '-q', + '--allow-empty', '-m', 'test_branch1' + ], + ['git', 'push', '-q', '-f', repo_url, branch]] + for command in add_branch_commands: + subprocess.check_call(command, cwd=repo_path) + + +def _create_local_repo(path): + """Create a local repository.""" + shutil.rmtree(path, ignore_errors=True) + os.mkdir(path) + create_repo_commands = [ + 'git init -q', 'git config http.sslVerify false', + 'git -c "user.name=Tester" -c "user.email=tester" ' + 'commit -q --allow-empty -m "test"' + ] + for command in create_repo_commands: + subprocess.check_call(command, shell=True, cwd=path) + + def _create_repo(browser, repo, access=None, ok_if_exists=False): """Create repository.""" if not _repo_exists(browser, repo, access): @@ -187,6 +240,13 @@ def _edit_repo_metadata(browser, repo, metadata): functional.submit(browser) +def _get_gitweb_site_default_repo_branch(browser, repo): + functional.nav_to_module(browser, 'gitweb') + browser.find_by_css('a[href="/gitweb/{0}.git"]'.format(repo)).first.click() + + return browser.find_by_css('.head').first.text + + def _get_repo_metadata(browser, repo): """Get repository metadata.""" functional.nav_to_module(browser, 'gitweb') @@ -268,19 +328,23 @@ def _repo_is_writable(repo, with_auth=False, url_git_extension=False): if url_git_extension: url = url + '.git' - with _gitweb_temp_directory() as cwd: - subprocess.run(['mkdir', 'test-project'], check=True, cwd=cwd) - cwd = os.path.join(cwd, 'test-project') - prepare_git_repo_commands = [ - 'git init -q', 'git config http.sslVerify false', - 'git -c "user.name=Tester" -c "user.email=tester" ' - 'commit -q --allow-empty -m "test"' - ] - for command in prepare_git_repo_commands: - subprocess.run(command, shell=True, check=True, cwd=cwd) + with _gitweb_temp_directory() as temp_directory: + repo_directory = os.path.join(temp_directory, 'test-project') + _create_local_repo(repo_directory) + git_push_command = ['git', 'push', '-qf', url, 'master'] - return _gitweb_git_command_is_successful(git_push_command, cwd) + return _gitweb_git_command_is_successful(git_push_command, + repo_directory) + + +def _set_default_branch(browser, repo, branch): + """Set default branch of the repository.""" + functional.nav_to_module(browser, 'gitweb') + browser.find_link_by_href( + '/plinth/apps/gitweb/{}/edit/'.format(repo)).first.click() + browser.find_by_id('id_gitweb-default_branch').select(branch) + functional.submit(browser) def _set_repo_access(browser, repo, access): diff --git a/plinth/modules/gitweb/tests/test_views.py b/plinth/modules/gitweb/tests/test_views.py index 1882db367..11a459d7d 100644 --- a/plinth/modules/gitweb/tests/test_views.py +++ b/plinth/modules/gitweb/tests/test_views.py @@ -24,14 +24,16 @@ EXISTING_REPOS = [ 'description': '', 'owner': '', 'access': 'public', - 'is_private': False + 'is_private': False, + 'default_branch': 'master', }, { 'name': 'something2', 'description': '', 'owner': '', 'access': 'private', - 'is_private': True + 'is_private': True, + 'default_branch': 'master', }, ] @@ -48,12 +50,19 @@ def fixture_gitweb_urls(): def action_run(*args, **kwargs): """Action return values.""" - if args[1][0] == 'repo-info': + subcommand = args[1][0] + if subcommand == 'repo-info': return json.dumps(EXISTING_REPOS[0]) - if args[1][0] == 'check-repo-exists': + elif subcommand == 'check-repo-exists': return True + elif subcommand == 'get-branches': + return json.dumps({ + "default_branch": "master", + "branches": ["master", "branch1"] + }) + return None @@ -208,7 +217,8 @@ def test_edit_repository_view(rf): 'gitweb-name': 'something_other.git', 'gitweb-description': 'test-description', 'gitweb-owner': 'test-owner', - 'gitweb-is_private': True + 'gitweb-is_private': True, + 'gitweb-default_branch': 'branch1', } url = urls.reverse('gitweb:edit', kwargs={'name': EXISTING_REPOS[0]['name']}) @@ -277,7 +287,8 @@ def test_edit_repository_no_change_view(rf): form_data = { 'gitweb-name': EXISTING_REPOS[0]['name'], 'gitweb-description': EXISTING_REPOS[0]['description'], - 'gitweb-owner': EXISTING_REPOS[0]['owner'] + 'gitweb-owner': EXISTING_REPOS[0]['owner'], + 'gitweb-default_branch': EXISTING_REPOS[0]['default_branch'], } request = rf.post( urls.reverse('gitweb:edit', @@ -297,7 +308,8 @@ def test_edit_repository_failed_view(rf): form_data = { 'gitweb-name': 'something_other', 'gitweb-description': 'test-description', - 'gitweb-owner': 'test-owner' + 'gitweb-owner': 'test-owner', + 'gitweb-default_branch': 'master', } request = rf.post( urls.reverse('gitweb:edit',