# -*- coding: utf-8 -*-
#
# Copyright © 2012 - 2019 Michal Čihař <michal@cihar.com>
#
# This file is part of Weblate <https://weblate.org/>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
"""Git based version control system abstraction for Weblate needs."""

from __future__ import unicode_literals
import email.utils
import os
import os.path

from defusedxml import ElementTree

from django.conf import settings
from django.utils.functional import cached_property

from weblate.vcs.base import Repository, RepositoryException
from weblate.vcs.gpg import get_gpg_sign_key


class GitRepository(Repository):
    """Repository implementation for Git."""
    _cmd = 'git'
    _cmd_last_revision = [
        'log', '-n', '1', '--format=format:%H', 'HEAD'
    ]
    _cmd_last_remote_revision = [
        'log', '-n', '1', '--format=format:%H', '@{upstream}'
    ]
    _cmd_update_remote = ['fetch', 'origin']
    _cmd_push = ['push', 'origin']
    _cmd_list_changed_files = ['diff', '--name-status']

    name = 'Git'
    req_version = '1.6'
    default_branch = 'master'
    ref_to_remote = '..{0}'
    ref_from_remote = '{0}..'

    def is_valid(self):
        """Check whether this is a valid repository."""
        return (
            os.path.exists(os.path.join(self.path, '.git', 'config')) or
            os.path.exists(os.path.join(self.path, 'config'))
        )

    def init(self):
        """Initialize the repository."""
        self._popen(['init', self.path])

    def check_config(self):
        """Check VCS configuration."""
        # We directly set config as it takes same time as reading it
        self.set_config('push.default', 'current')

    @classmethod
    def _clone(cls, source, target, branch=None):
        """Clone repository."""
        cls._popen([
            'clone',
            '--depth', '1',
            '--no-single-branch',
            source, target
        ])

    def get_config(self, path):
        """Read entry from configuration."""
        return self.execute(
            ['config', path],
            needs_lock=False
        ).strip()

    def set_config(self, path, value):
        """Set entry in local configuration."""
        self.execute(
            ['config', '--replace-all', path, value]
        )

    def set_committer(self, name, mail):
        """Configure commiter name."""
        self.set_config('user.name', name)
        self.set_config('user.email', mail)

    def reset(self):
        """Reset working copy to match remote branch."""
        self.execute(['reset', '--hard', self.get_remote_branch_name()])
        self.clean_revision_cache()

    def rebase(self, abort=False):
        """Rebase working copy on top of remote branch."""
        if abort:
            self.execute(['rebase', '--abort'])
        else:
            self.execute(['rebase', self.get_remote_branch_name()])

    def has_rev(self, rev):
        try:
            self.execute(['rev-parse', '--verify', rev], needs_lock=False)
            return True
        except RepositoryException:
            return False

    def merge(self, abort=False, message=None):
        """Merge remote branch or reverts the merge."""
        tmp = 'weblate-merge-tmp'
        if abort:
            # Abort merge if there is one to abort
            if self.has_rev('MERGE_HEAD'):
                self.execute(['merge', '--abort'])
            # Checkout original branch (we might be on tmp)
            self.execute(['checkout', self.branch])
        else:
            self.delete_branch(tmp)
            # We don't do simple git merge origin/branch as that leads
            # to different merge order than expected and most GUI tools
            # then show confusing diff (not changes done by Weblate, but
            # changes merged into Weblate)
            remote = self.get_remote_branch_name()
            # Create local branch for upstream
            self.execute(['branch', tmp, remote])
            # Checkout upstream branch
            self.execute(['checkout', tmp])
            # Merge current Weblate changes, this can lead to conflict
            cmd = [
                'merge',
                '--message',
                message or "Merge branch '{}' into Weblate".format(remote),
            ]
            cmd.extend(self.get_gpg_sign_args())
            cmd.append(self.branch)
            self.execute(cmd)
            # Checkout branch with Weblate changes
            self.execute(['checkout', self.branch])
            # Merge temporary branch (this is fast forward so does not create
            # merge commit)
            self.execute(['merge', tmp])

        # Delete temporary branch
        self.delete_branch(tmp)

    def delete_branch(self, name):
        if self.has_branch(name):
            self.execute(['branch', '-D', name])

    def needs_commit(self, *filenames):
        """Check whether repository needs commit."""
        cmd = ('status', '--porcelain', '--') + filenames
        with self.lock:
            status = self.execute(cmd)
        return status != ''

    def show(self, revision):
        """Helper method to get content of revision.

        Used in tests.
        """
        return self.execute(['show', revision], needs_lock=False)

    @staticmethod
    def get_gpg_sign_args():
        sign_key = get_gpg_sign_key()
        if sign_key:
            return ['--gpg-sign={}'.format(sign_key)]
        return []

    def _get_revision_info(self, revision):
        """Return dictionary with detailed revision information."""
        text = self.execute(
            [
                'log',
                '-1',
                '--format=fuller',
                '--date=rfc',
                '--abbrev-commit',
                revision
            ],
            needs_lock=False
        )

        result = {
            'revision': revision,
        }

        message = []

        header = True

        for line in text.splitlines():
            if header:
                if not line:
                    header = False
                elif line.startswith('commit'):
                    result['shortrevision'] = line.split()[1]
                else:
                    name, value = line.strip().split(':', 1)
                    value = value.strip()
                    name = name.lower()
                    result[name] = value
                    if '@' in value:
                        parsed = email.utils.parseaddr(value)
                        result['{0}_name'.format(name)] = parsed[0]
                        result['{0}_email'.format(name)] = parsed[1]
            else:
                message.append(line.strip())

        result['message'] = '\n'.join(message)
        result['summary'] = message[0]

        return result

    def log_revisions(self, refspec):
        """Return revisin log for given refspec."""
        return self.execute(
            ['log', '--format=format:%H', refspec, '--'],
            needs_lock=False
        ).splitlines()

    @classmethod
    def _get_version(cls):
        """Return VCS program version."""
        return cls._popen(['--version']).split()[-1]

    def commit(self, message, author=None, timestamp=None, files=None):
        """Create new revision."""
        # Add files (some of them might not be in the index)
        if files:
            self.execute(['add', '--force', '--'] + files)
        else:
            self.execute(['add', self.path])

        # Build the commit command
        cmd = ['commit', '--message', message]
        if author:
            cmd.extend(['--author', author])
        if timestamp is not None:
            cmd.extend(['--date', timestamp.isoformat()])
        cmd.extend(self.get_gpg_sign_args())

        # Execute it
        self.execute(cmd)
        # Clean cache
        self.clean_revision_cache()

    def remove(self, files, message, author=None):
        """Remove files and creates new revision."""
        self.execute(['rm', '--force', '--'] + files)
        self.commit(message, author)

    def configure_remote(self, pull_url, push_url, branch):
        """Configure remote repository."""
        old_pull = None
        old_push = None
        # Parse existing remotes
        for remote in self.execute(['remote', '-v']).splitlines():
            name, url = remote.split('\t')
            if name != 'origin':
                continue
            if ' ' in url:
                url, kind = url.rsplit(' ', 1)
                if kind == '(fetch)':
                    old_pull = url
                elif kind == '(push)':
                    old_push = url

        if old_pull is None:
            # No origin existing
            self.execute(['remote', 'add', 'origin', pull_url])
        elif old_pull != pull_url:
            # URL changed?
            self.execute(['remote', 'set-url', 'origin', pull_url])

        if push_url is not None and old_push != push_url:
            self.execute(['remote', 'set-url', '--push', 'origin', push_url])

        # Set branch to track
        self.set_config(
            'remote.origin.fetch',
            '+refs/heads/{0}:refs/remotes/origin/{0}'.format(branch)
        )
        self.set_config(
            'branch.{0}.remote'.format(branch),
            'origin'
        )
        self.set_config(
            'branch.{0}.merge'.format(branch),
            'refs/heads/{0}'.format(branch)
        )

        self.branch = branch

    def has_branch(self, branch):
        # Get List of current branches in local repository
        # (we get additional * there indicating current branch)
        branches = [
            x.lstrip('*').strip()
            for x in self.execute(['branch']).splitlines()
        ]
        return branch in branches

    def configure_branch(self, branch):
        """Configure repository branch."""
        # Add branch
        if not self.has_branch(branch):
            self.execute(
                ['checkout', '-b', branch, 'origin/{0}'.format(branch)]
            )
        else:
            # Ensure it tracks correct upstream
            self.set_config(
                'branch.{0}.remote'.format(branch),
                'origin',
            )

        # Checkout
        self.execute(['checkout', branch])
        self.branch = branch

    def describe(self):
        """Verbosely describes current revision."""
        return self.execute(
            ['describe', '--always'],
            needs_lock=False
        ).strip()

    @classmethod
    def global_setup(cls):
        """Perform global settings"""
        merge_driver = cls.get_merge_driver('po')
        if merge_driver is not None:
            cls._popen([
                'config', '--global',
                'merge.weblate-merge-gettext-po.name',
                'Weblate merge driver for Gettext PO files'
            ])
            cls._popen([
                'config', '--global',
                'merge.weblate-merge-gettext-po.driver',
                '{0} %O %A %B'.format(merge_driver),
            ])

    def get_file(self, path, revision):
        """Return content of file at given revision."""
        return self.execute(
            ['show', '{0}:{1}'.format(revision, path)],
            needs_lock=False
        )

    def cleanup(self):
        """Remove not tracked files from the repository."""
        self.execute(['clean', '-f'])


class GitWithGerritRepository(GitRepository):

    name = 'Gerrit'

    _is_supported = None
    _version = None

    @classmethod
    def _get_version(cls):
        """Return VCS program version."""
        return cls._popen(['review', '--version'], err=True).split()[-1]

    def push(self):
        self.execute(['review', '--yes', self.branch])


class SubversionRepository(GitRepository):

    name = 'Subversion'
    req_version = '1.6'

    _cmd_update_remote = ['svn', 'fetch']
    _cmd_push = ['svn', 'dcommit']
    _is_supported = None
    _version = None

    _fetch_revision = None

    @classmethod
    def _get_version(cls):
        """Return VCS program version."""
        return cls._popen(['svn', '--version']).split()[2]

    @classmethod
    def is_stdlayout(cls, url):
        output = cls._popen(['svn', 'ls', url], fullcmd=True).splitlines()
        return 'trunk/' in output

    @classmethod
    def get_last_repo_revision(cls, url):
        output = cls._popen(
            ['svn', 'log', url, '--limit=1', '--xml'],
            fullcmd=True,
            raw=True,
        )
        tree = ElementTree.fromstring(output)
        entry = tree.find('logentry')
        if entry is not None:
            return entry.get('revision')
        return None

    @classmethod
    def get_remote_args(cls, source, target):
        result = ['--prefix=origin/', source, target]
        if cls.is_stdlayout(source):
            result.insert(0, '--stdlayout')
            revision = cls.get_last_repo_revision(source + '/trunk/')
        else:
            revision = cls.get_last_repo_revision(source)
        if revision:
            revision = '--revision={}:HEAD'.format(revision)

        return result, revision

    def configure_remote(self, pull_url, push_url, branch):
        """Initialize the git-svn repository.

        This does not support switching remote as it's quite complex:
        https://git.wiki.kernel.org/index.php/GitSvnSwitch

        The git svn init errors in case the URL is not matching.
        """
        try:
            existing = self.get_config('svn-remote.svn.url')
        except RepositoryException:
            existing = None
        if existing:
            # The URL is root of the repository, while we get full path
            if not pull_url.startswith(existing):
                raise RepositoryException(
                    -1, 'Can not switch subversion URL', ''
                )
            return
        args, self._fetch_revision = self.get_remote_args(pull_url, self.path)
        self.execute(['svn', 'init'] + args)

    def update_remote(self):
        """Update remote repository."""
        if self._fetch_revision:
            self.execute(self._cmd_update_remote + [self._fetch_revision])
            self._fetch_revision = None
        else:
            self.execute(self._cmd_update_remote + ['--parent'])
        self.clean_revision_cache()

    @classmethod
    def _clone(cls, source, target, branch=None):
        """Clone svn repository with git-svn."""
        args, revision = cls.get_remote_args(source, target)
        if revision:
            args.insert(0, revision)
        cls._popen(['svn', 'clone'] + args)

    def merge(self, abort=False, message=None):
        """Rebases. Git-svn does not support merge."""
        self.rebase(abort)

    def rebase(self, abort=False):
        """Rebase remote branch or reverts the rebase.
        Git-svn does not support merge.
        """
        if abort:
            self.execute(['rebase', '--abort'])
        else:
            self.execute(['svn', 'rebase'])

    @cached_property
    def last_remote_revision(self):
        """Return last remote revision."""
        return self.execute(
            [
                'log', '-n', '1', '--format=format:%H',
                self.get_remote_branch_name()
            ],
            needs_lock=False
        )

    def get_remote_branch_name(self):
        """Return the remote branch name: trunk if local branch is master,
        local branch otherwise.
        """
        if self.branch == 'master':
            fetch = self.get_config('svn-remote.svn.fetch')
            if 'origin/trunk' in fetch:
                return 'origin/trunk'
            if 'origin/git-svn' in fetch:
                return 'origin/git-svn'
        return 'origin/{0}'.format(self.branch)


class GithubRepository(GitRepository):

    name = 'GitHub'

    _cmd = 'hub'

    _is_supported = None
    _version = None

    @classmethod
    def is_supported(cls):
        if not settings.GITHUB_USERNAME:
            return False
        return super(GithubRepository, cls).is_supported()

    @classmethod
    def _getenv(cls):
        """Generate environment for process execution."""
        env = super(cls, GithubRepository)._getenv()
        # Add path to config if it exists
        userconfig = os.path.expanduser('~/.config/hub')
        if os.path.exists(userconfig):
            env['HUB_CONFIG'] = userconfig

        return env

    def create_pull_request(self, origin_branch, fork_branch):
        """Create pull request to merge branch in forked repository into
        branch of remote repository.
        """
        cmd = [
            'pull-request',
            '-f',
            '-h', '{0}:{1}'.format(settings.GITHUB_USERNAME, fork_branch),
            '-b', origin_branch,
            '-m', settings.DEFAULT_PULL_MESSAGE,
        ]
        self.execute(cmd)

    def push_to_fork(self, local_branch, fork_branch):
        """Push given local branch to branch in forked repository."""
        if settings.GITHUB_USERNAME:
            cmd_push = ['push', '--force', settings.GITHUB_USERNAME]
        else:
            cmd_push = ['push', 'origin']
        self.execute(cmd_push + ['{0}:{1}'.format(local_branch, fork_branch)])

    def fork(self):
        """Create fork of original repository if one doesn't exist yet."""
        remotes = self.execute(['remote']).splitlines()
        if settings.GITHUB_USERNAME not in remotes:
            self.execute(['fork'])

    def push(self):
        """Fork repository on Github, pushes changes to *-weblate branch
        on fork and creates pull request against original repository.
        """
        self.fork()
        if self.component is not None:
            fork_branch = 'weblate-{0}-{1}'.format(
                self.component.project.slug,
                self.component.slug,
            )
        else:
            fork_branch = '{0}-weblate'.format(self.branch)
        self.push_to_fork(self.branch, self.branch)
        self.push_to_fork(self.branch, fork_branch)
        try:
            self.create_pull_request(self.branch, fork_branch)
        except RepositoryException as error:
            if error.retcode == 1:
                # Pull request already exists.
                return
            raise

    def configure_remote(self, pull_url, push_url, branch):
        # We don't use push URL at all
        super(GithubRepository, self).configure_remote(pull_url, None, branch)

weblate/vcs/git.py
# -*- coding: utf-8 -*-
#
# Copyright © 2012 - 2019 Michal Čihař <michal@cihar.com>
#
# This file is part of Weblate <https://weblate.org/>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
"""Git based version control system abstraction for Weblate needs."""

from __future__ import unicode_literals
import email.utils
import os
import os.path

from defusedxml import ElementTree

from django.conf import settings
from django.utils.functional import cached_property

from weblate.vcs.base import Repository, RepositoryException
from weblate.vcs.gpg import get_gpg_sign_key


class GitRepository(Repository):
    """Repository implementation for Git."""
    _cmd = 'git'
    _cmd_last_revision = [
        'log', '-n', '1', '--format=format:%H', 'HEAD'
    ]
    _cmd_last_remote_revision = [
        'log', '-n', '1', '--format=format:%H', '@{upstream}'
    ]
    _cmd_update_remote = ['fetch', 'origin']
    _cmd_push = ['push', 'origin']
    _cmd_list_changed_files = ['diff', '--name-status']

    name = 'Git'
    req_version = '1.6'
    default_branch = 'master'
    ref_to_remote = '..{0}'
    ref_from_remote = '{0}..'

    def is_valid(self):
        """Check whether this is a valid repository."""
        return (
            os.path.exists(os.path.join(self.path, '.git', 'config')) or
            os.path.exists(os.path.join(self.path, 'config'))
        )

    def init(self):
        """Initialize the repository."""
        self._popen(['init', self.path])

    def check_config(self):
        """Check VCS configuration."""
        # We directly set config as it takes same time as reading it
        self.set_config('push.default', 'current')

    @classmethod
    def _clone(cls, source, target, branch=None):
        """Clone repository."""
        cls._popen([
            'clone',
            '--depth', '1',
            '--no-single-branch',
            source, target
        ])

    def get_config(self, path):
        """Read entry from configuration."""
        return self.execute(
            ['config', path],
            needs_lock=False
        ).strip()

    def set_config(self, path, value):
        """Set entry in local configuration."""
        self.execute(
            ['config', '--replace-all', path, value]
        )

    def set_committer(self, name, mail):
        """Configure commiter name."""
        self.set_config('user.name', name)
        self.set_config('user.email', mail)

    def reset(self):
        """Reset working copy to match remote branch."""
        self.execute(['reset', '--hard', self.get_remote_branch_name()])
        self.clean_revision_cache()

    def rebase(self, abort=False):
        """Rebase working copy on top of remote branch."""
        if abort:
            self.execute(['rebase', '--abort'])
        else:
            self.execute(['rebase', self.get_remote_branch_name()])

    def has_rev(self, rev):
        try:
            self.execute(['rev-parse', '--verify', rev], needs_lock=False)
            return True
        except RepositoryException:
            return False

    def merge(self, abort=False, message=None):
        """Merge remote branch or reverts the merge."""
        tmp = 'weblate-merge-tmp'
        if abort:
            # Abort merge if there is one to abort
            if self.has_rev('MERGE_HEAD'):
                self.execute(['merge', '--abort'])
            # Checkout original branch (we might be on tmp)
            self.execute(['checkout', self.branch])
        else:
            self.delete_branch(tmp)
            # We don't do simple git merge origin/branch as that leads
            # to different merge order than expected and most GUI tools
            # then show confusing diff (not changes done by Weblate, but
            # changes merged into Weblate)
            remote = self.get_remote_branch_name()
            # Create local branch for upstream
            self.execute(['branch', tmp, remote])
            # Checkout upstream branch
            self.execute(['checkout', tmp])
            # Merge current Weblate changes, this can lead to conflict
            cmd = [
                'merge',
                '--message',
                message or "Merge branch '{}' into Weblate".format(remote),
            ]
            cmd.extend(self.get_gpg_sign_args())
            cmd.append(self.branch)
            self.execute(cmd)
            # Checkout branch with Weblate changes
            self.execute(['checkout', self.branch])
            # Merge temporary branch (this is fast forward so does not create
            # merge commit)
            self.execute(['merge', tmp])

        # Delete temporary branch
        self.delete_branch(tmp)

    def delete_branch(self, name):
        if self.has_branch(name):
            self.execute(['branch', '-D', name])

    def needs_commit(self, *filenames):
        """Check whether repository needs commit."""
        cmd = ('status', '--porcelain', '--') + filenames
        with self.lock:
            status = self.execute(cmd)
        return status != ''

    def show(self, revision):
        """Helper method to get content of revision.

        Used in tests.
        """
        return self.execute(['show', revision], needs_lock=False)

    @staticmethod
    def get_gpg_sign_args():
        sign_key = get_gpg_sign_key()
        if sign_key:
            return ['--gpg-sign={}'.format(sign_key)]
        return []

    def _get_revision_info(self, revision):
        """Return dictionary with detailed revision information."""
        text = self.execute(
            [
                'log',
                '-1',
                '--format=fuller',
                '--date=rfc',
                '--abbrev-commit',
                revision
            ],
            needs_lock=False
        )

        result = {
            'revision': revision,
        }

        message = []

        header = True

        for line in text.splitlines():
            if header:
                if not line:
                    header = False
                elif line.startswith('commit'):
                    result['shortrevision'] = line.split()[1]
                else:
                    name, value = line.strip().split(':', 1)
                    value = value.strip()
                    name = name.lower()
                    result[name] = value
                    if '@' in value:
                        parsed = email.utils.parseaddr(value)
                        result['{0}_name'.format(name)] = parsed[0]
                        result['{0}_email'.format(name)] = parsed[1]
            else:
                message.append(line.strip())

        result['message'] = '\n'.join(message)
        result['summary'] = message[0]

        return result

    def log_revisions(self, refspec):
        """Return revisin log for given refspec."""
        return self.execute(
            ['log', '--format=format:%H', refspec, '--'],
            needs_lock=False
        ).splitlines()

    @classmethod
    def _get_version(cls):
        """Return VCS program version."""
        return cls._popen(['--version']).split()[-1]

    def commit(self, message, author=None, timestamp=None, files=None):
        """Create new revision."""
        # Add files (some of them might not be in the index)
        if files:
            self.execute(['add', '--force', '--'] + files)
        else:
            self.execute(['add', self.path])

        # Build the commit command
        cmd = ['commit', '--message', message]
        if author:
            cmd.extend(['--author', author])
        if timestamp is not None:
            cmd.extend(['--date', timestamp.isoformat()])
        cmd.extend(self.get_gpg_sign_args())

        # Execute it
        self.execute(cmd)
        # Clean cache
        self.clean_revision_cache()

    def remove(self, files, message, author=None):
        """Remove files and creates new revision."""
        self.execute(['rm', '--force', '--'] + files)
        self.commit(message, author)

    def configure_remote(self, pull_url, push_url, branch):
        """Configure remote repository."""
        old_pull = None
        old_push = None
        # Parse existing remotes
        for remote in self.execute(['remote', '-v']).splitlines():
            name, url = remote.split('\t')
            if name != 'origin':
                continue
            if ' ' in url:
                url, kind = url.rsplit(' ', 1)
                if kind == '(fetch)':
                    old_pull = url
                elif kind == '(push)':
                    old_push = url

        if old_pull is None:
            # No origin existing
            self.execute(['remote', 'add', 'origin', pull_url])
        elif old_pull != pull_url:
            # URL changed?
            self.execute(['remote', 'set-url', 'origin', pull_url])

        if push_url is not None and old_push != push_url:
            self.execute(['remote', 'set-url', '--push', 'origin', push_url])

        # Set branch to track
        self.set_config(
            'remote.origin.fetch',
            '+refs/heads/{0}:refs/remotes/origin/{0}'.format(branch)
        )
        self.set_config(
            'branch.{0}.remote'.format(branch),
            'origin'
        )
        self.set_config(
            'branch.{0}.merge'.format(branch),
            'refs/heads/{0}'.format(branch)
        )

        self.branch = branch

    def has_branch(self, branch):
        # Get List of current branches in local repository
        # (we get additional * there indicating current branch)
        branches = [
            x.lstrip('*').strip()
            for x in self.execute(['branch']).splitlines()
        ]
        return branch in branches

    def configure_branch(self, branch):
        """Configure repository branch."""
        # Add branch
        if not self.has_branch(branch):
            self.execute(
                ['checkout', '-b', branch, 'origin/{0}'.format(branch)]
            )
        else:
            # Ensure it tracks correct upstream
            self.set_config(
                'branch.{0}.remote'.format(branch),
                'origin',
            )

        # Checkout
        self.execute(['checkout', branch])
        self.branch = branch

    def describe(self):
        """Verbosely describes current revision."""
        return self.execute(
            ['describe', '--always'],
            needs_lock=False
        ).strip()

    @classmethod
    def global_setup(cls):
        """Perform global settings"""
        merge_driver = cls.get_merge_driver('po')
        if merge_driver is not None:
            cls._popen([
                'config', '--global',
                'merge.weblate-merge-gettext-po.name',
                'Weblate merge driver for Gettext PO files'
            ])
            cls._popen([
                'config', '--global',
                'merge.weblate-merge-gettext-po.driver',
                '{0} %O %A %B'.format(merge_driver),
            ])

    def get_file(self, path, revision):
        """Return content of file at given revision."""
        return self.execute(
            ['show', '{0}:{1}'.format(revision, path)],
            needs_lock=False
        )

    def cleanup(self):
        """Remove not tracked files from the repository."""
        self.execute(['clean', '-f'])


class GitWithGerritRepository(GitRepository):

    name = 'Gerrit'

    _is_supported = None
    _version = None

    @classmethod
    def _get_version(cls):
        """Return VCS program version."""
        return cls._popen(['review', '--version'], err=True).split()[-1]

    def push(self):
        if self.needs_push():
            self.execute(['review', '--yes', self.branch])


class SubversionRepository(GitRepository):

    name = 'Subversion'
    req_version = '1.6'

    _cmd_update_remote = ['svn', 'fetch']
    _cmd_push = ['svn', 'dcommit']
    _is_supported = None
    _version = None

    _fetch_revision = None

    @classmethod
    def _get_version(cls):
        """Return VCS program version."""
        return cls._popen(['svn', '--version']).split()[2]

    @classmethod
    def is_stdlayout(cls, url):
        output = cls._popen(['svn', 'ls', url], fullcmd=True).splitlines()
        return 'trunk/' in output

    @classmethod
    def get_last_repo_revision(cls, url):
        output = cls._popen(
            ['svn', 'log', url, '--limit=1', '--xml'],
            fullcmd=True,
            raw=True,
        )
        tree = ElementTree.fromstring(output)
        entry = tree.find('logentry')
        if entry is not None:
            return entry.get('revision')
        return None

    @classmethod
    def get_remote_args(cls, source, target):
        result = ['--prefix=origin/', source, target]
        if cls.is_stdlayout(source):
            result.insert(0, '--stdlayout')
            revision = cls.get_last_repo_revision(source + '/trunk/')
        else:
            revision = cls.get_last_repo_revision(source)
        if revision:
            revision = '--revision={}:HEAD'.format(revision)

        return result, revision

    def configure_remote(self, pull_url, push_url, branch):
        """Initialize the git-svn repository.

        This does not support switching remote as it's quite complex:
        https://git.wiki.kernel.org/index.php/GitSvnSwitch

        The git svn init errors in case the URL is not matching.
        """
        try:
            existing = self.get_config('svn-remote.svn.url')
        except RepositoryException:
            existing = None
        if existing:
            # The URL is root of the repository, while we get full path
            if not pull_url.startswith(existing):
                raise RepositoryException(
                    -1, 'Can not switch subversion URL', ''
                )
            return
        args, self._fetch_revision = self.get_remote_args(pull_url, self.path)
        self.execute(['svn', 'init'] + args)

    def update_remote(self):
        """Update remote repository."""
        if self._fetch_revision:
            self.execute(self._cmd_update_remote + [self._fetch_revision])
            self._fetch_revision = None
        else:
            self.execute(self._cmd_update_remote + ['--parent'])
        self.clean_revision_cache()

    @classmethod
    def _clone(cls, source, target, branch=None):
        """Clone svn repository with git-svn."""
        args, revision = cls.get_remote_args(source, target)
        if revision:
            args.insert(0, revision)
        cls._popen(['svn', 'clone'] + args)

    def merge(self, abort=False, message=None):
        """Rebases. Git-svn does not support merge."""
        self.rebase(abort)

    def rebase(self, abort=False):
        """Rebase remote branch or reverts the rebase.
        Git-svn does not support merge.
        """
        if abort:
            self.execute(['rebase', '--abort'])
        else:
            self.execute(['svn', 'rebase'])

    @cached_property
    def last_remote_revision(self):
        """Return last remote revision."""
        return self.execute(
            [
                'log', '-n', '1', '--format=format:%H',
                self.get_remote_branch_name()
            ],
            needs_lock=False
        )

    def get_remote_branch_name(self):
        """Return the remote branch name: trunk if local branch is master,
        local branch otherwise.
        """
        if self.branch == 'master':
            fetch = self.get_config('svn-remote.svn.fetch')
            if 'origin/trunk' in fetch:
                return 'origin/trunk'
            if 'origin/git-svn' in fetch:
                return 'origin/git-svn'
        return 'origin/{0}'.format(self.branch)


class GithubRepository(GitRepository):

    name = 'GitHub'

    _cmd = 'hub'

    _is_supported = None
    _version = None

    @classmethod
    def is_supported(cls):
        if not settings.GITHUB_USERNAME:
            return False
        return super(GithubRepository, cls).is_supported()

    @classmethod
    def _getenv(cls):
        """Generate environment for process execution."""
        env = super(cls, GithubRepository)._getenv()
        # Add path to config if it exists
        userconfig = os.path.expanduser('~/.config/hub')
        if os.path.exists(userconfig):
            env['HUB_CONFIG'] = userconfig

        return env

    def create_pull_request(self, origin_branch, fork_branch):
        """Create pull request to merge branch in forked repository into
        branch of remote repository.
        """
        cmd = [
            'pull-request',
            '-f',
            '-h', '{0}:{1}'.format(settings.GITHUB_USERNAME, fork_branch),
            '-b', origin_branch,
            '-m', settings.DEFAULT_PULL_MESSAGE,
        ]
        self.execute(cmd)

    def push_to_fork(self, local_branch, fork_branch):
        """Push given local branch to branch in forked repository."""
        if settings.GITHUB_USERNAME:
            cmd_push = ['push', '--force', settings.GITHUB_USERNAME]
        else:
            cmd_push = ['push', 'origin']
        self.execute(cmd_push + ['{0}:{1}'.format(local_branch, fork_branch)])

    def fork(self):
        """Create fork of original repository if one doesn't exist yet."""
        remotes = self.execute(['remote']).splitlines()
        if settings.GITHUB_USERNAME not in remotes:
            self.execute(['fork'])

    def push(self):
        """Fork repository on Github, pushes changes to *-weblate branch
        on fork and creates pull request against original repository.
        """
        self.fork()
        if self.component is not None:
            fork_branch = 'weblate-{0}-{1}'.format(
                self.component.project.slug,
                self.component.slug,
            )
        else:
            fork_branch = '{0}-weblate'.format(self.branch)
        self.push_to_fork(self.branch, self.branch)
        self.push_to_fork(self.branch, fork_branch)
        try:
            self.create_pull_request(self.branch, fork_branch)
        except RepositoryException as error:
            if error.retcode == 1:
                # Pull request already exists.
                return
            raise

    def configure_remote(self, pull_url, push_url, branch):
        # We don't use push URL at all
        super(GithubRepository, self).configure_remote(pull_url, None, branch)

weblate/vcs/git.py
diff --git a/ci/requirements-scrutinizer.txt b/ci/requirements-scrutinizer.txt
index cd3784e9ab7c..9e1e35228f18 100644
--- a/ci/requirements-scrutinizer.txt
+++ b/ci/requirements-scrutinizer.txt
@@ -6,6 +6,7 @@ pyuca>=1.1
 pytz>=2015.1
 phply
 chardet
+git-review
 django-redis
 hiredis
 python-bidi>=0.4.0
diff --git a/requirements-optional.txt b/requirements-optional.txt
index 47d0b78aef72..0e6902036886 100644
--- a/requirements-optional.txt
+++ b/requirements-optional.txt
@@ -4,6 +4,7 @@ pytz>=2015.1
 phply
 chardet
 Mercurial>=2.8; python_version < '3.0'
+git-review
 django-redis
 hiredis
 python-bidi>=0.4.0
diff --git a/weblate/trans/tests/data/test-base-repo.git.tar b/weblate/trans/tests/data/test-base-repo.git.tar
index 3d5a43cd439c..8e7fb2340035 100644
Binary files a/weblate/trans/tests/data/test-base-repo.git.tar and b/weblate/trans/tests/data/test-base-repo.git.tar differ
diff --git a/weblate/trans/tests/data/test-base-repo.hg.tar b/weblate/trans/tests/data/test-base-repo.hg.tar
index a7a5c0abe795..af322510be57 100644
Binary files a/weblate/trans/tests/data/test-base-repo.hg.tar and b/weblate/trans/tests/data/test-base-repo.hg.tar differ
diff --git a/weblate/trans/tests/data/test-base-repo.svn.tar b/weblate/trans/tests/data/test-base-repo.svn.tar
index 262204f5c1ca..9e3e061643c8 100644
Binary files a/weblate/trans/tests/data/test-base-repo.svn.tar and b/weblate/trans/tests/data/test-base-repo.svn.tar differ
diff --git a/weblate/vcs/git.py b/weblate/vcs/git.py
index 094f1b69225e..796f55666c50 100644
--- a/weblate/vcs/git.py
+++ b/weblate/vcs/git.py
@@ -378,7 +378,8 @@ def _get_version(cls):
         return cls._popen(['review', '--version'], err=True).split()[-1]
 
     def push(self):
-        self.execute(['review', '--yes', self.branch])
+        if self.needs_push():
+            self.execute(['review', '--yes', self.branch])
 
 
 class SubversionRepository(GitRepository):
diff --git a/weblate/vcs/tests/test_vcs.py b/weblate/vcs/tests/test_vcs.py
index 4c50e2147df3..e916ee4837c8 100644
--- a/weblate/vcs/tests/test_vcs.py
+++ b/weblate/vcs/tests/test_vcs.py
@@ -88,7 +88,6 @@ def test_is_supported_cache(self):
 class VCSGitTest(TestCase, RepoTestMixin, TempDirMixin):
     _class = GitRepository
     _vcs = 'git'
-    _can_push = True
     _sets_push = True
 
     def setUp(self):
@@ -100,6 +99,10 @@ def setUp(self):
 
         self.create_temp()
         self.repo = self.clone_repo(self.tempdir)
+        self.fixup_repo(self.repo)
+
+    def fixup_repo(self, repo):
+        return
 
     def clone_repo(self, path):
         return self._class.clone(
@@ -116,6 +119,8 @@ def add_remote_commit(self, conflict=False):
         tempdir = tempfile.mkdtemp()
         try:
             repo = self.clone_repo(tempdir)
+            self.fixup_repo(repo)
+
             with repo.lock:
                 repo.set_committer('Second Bar', 'second@example.net')
             if conflict:
@@ -210,19 +215,13 @@ def test_rebase_both(self):
     def test_merge_conflict(self):
         self.add_remote_commit(conflict=True)
         self.test_commit()
-        if self._can_push:
-            with self.assertRaises(RepositoryException):
-                self.test_merge()
-        else:
+        with self.assertRaises(RepositoryException):
             self.test_merge()
 
     def test_rebase_conflict(self):
         self.add_remote_commit(conflict=True)
         self.test_commit()
-        if self._can_push:
-            with self.assertRaises(RepositoryException):
-                self.test_rebase()
-        else:
+        with self.assertRaises(RepositoryException):
             self.test_rebase()
 
     def test_upstream_changes(self):
@@ -412,7 +411,22 @@ def test_get_file(self):
 class VCSGerritTest(VCSGitTest):
     _class = GitWithGerritRepository
     _vcs = 'git'
-    _can_push = False
+
+    def fixup_repo(self, repo):
+        # Create commit-msg hook, so that git-review doesn't try
+        # to create one
+        hook = os.path.join(repo.path, '.git', 'hooks', 'commit-msg')
+        with open(hook, 'w') as handle:
+            handle.write('#!/bin/sh\nexit 0\n')
+        os.chmod(hook, 0o755)
+
+    def add_remote_commit(self, conflict=False):
+        # Use Git to create changed upstream repo
+        self._class = GitRepository
+        try:
+            super(VCSGerritTest, self).add_remote_commit(conflict)
+        finally:
+            self._class = GitWithGerritRepository
 
 
 class VCSGithubTest(VCSGitTest):

Gerrit tests are failing on openSUSE
Gerrit tests are failing in openSUSE build, regardless of version since 3.1 release:
```
[  927s] ERROR: test_merge_both (weblate.vcs.tests.test_vcs.VCSGerritTest)
[  927s] ----------------------------------------------------------------------
[  927s] Traceback (most recent call last):
[  927s]   File "/home/abuild/rpmbuild/BUILD/Weblate-3.5.1/weblate/vcs/tests/test_vcs.py", line 201, in test_merge_both
[  927s]     self.add_remote_commit()
[  927s]   File "/home/abuild/rpmbuild/BUILD/Weblate-3.5.1/weblate/vcs/tests/test_vcs.py", line 139, in add_remote_commit
[  927s]     repo.push()
[  927s]   File "/home/abuild/rpmbuild/BUILD/Weblate-3.5.1/weblate/vcs/git.py", line 378, in push
[  927s]     self.execute(['review', '--yes', self.branch])
[  927s]   File "/home/abuild/rpmbuild/BUILD/Weblate-3.5.1/weblate/vcs/base.py", line 194, in execute
[  927s]     args, self.path, fullcmd=fullcmd, local=self.local
[  927s]   File "/home/abuild/rpmbuild/BUILD/Weblate-3.5.1/weblate/vcs/base.py", line 178, in _popen
[  927s]     output.decode('utf-8')
[  927s] weblate.vcs.base.RepositoryException: No '.gitreview' file found in this repository. We don't know where
[  927s] your gerrit is.
[  927s] Please manually create a remote named "gerrit" and try again. (1)
```

We have git and git-review pulled in the system. Full log:
[weblate.txt](https://github.com/WeblateOrg/weblate/files/3034061/weblate.txt)

