Exemple #1
0
def commit(no_verify=False, allow_empty=False, defaults=None):
    """
    Performs a tidy git commit.

    Args:
        no_verify (bool, default=False): True if ignoring
            pre-commit hooks
        allow_empty (bool, default=False): True if an empty
            commit should be allowed
        defaults (dict, default=None): Defaults to be used
            when prompting for commit attributes.

    Returns:
        subprocess.CompletedProcess: The result from running
        git commit. Returns the git pre-commit hook results if
        failing during hook execution.
    """
    # Run pre-commit hooks manually so that the commit will fail
    # before prompting the user for information
    hooks_path = utils.shell_stdout('git rev-parse --git-path hooks')
    pre_commit_hook = os.path.join(hooks_path, 'pre-commit')
    if not no_verify and os.path.exists(pre_commit_hook):
        result = utils.shell(pre_commit_hook, check=False)
        if result.returncode:
            return result

    # If there are no staged changes and we are not allowing empty
    # commits (the default git commit mode), short circuit and run
    # a failing git commit
    staged_changes = utils.shell_stdout('git diff --cached')
    if not staged_changes and not allow_empty:
        return utils.shell(f'git commit --no-verify', check=False)

    schema = _load_commit_schema(full=False)
    entry = schema.prompt(defaults=defaults)

    # Render the commit message from the validated entry
    commit_msg = ''
    if 'summary' in entry:
        commit_msg += f'{entry["summary"].strip()}\n\n'
    if 'description' in entry:
        commit_msg += f'{entry["description"].strip()}\n\n'

    for key, value in entry.items():
        if key not in ['summary', 'description']:
            key = key.replace('_', ' ').title().replace('_', '-').strip()
            commit_msg += f'{key}: {value.strip()}\n'

    commit_msg = commit_msg.strip()

    # Commit with git
    commit_cmd = 'git commit --no-verify'
    if allow_empty:
        commit_cmd += ' --allow-empty'
    with tempfile.NamedTemporaryFile(mode='w+') as commit_file:
        commit_file.write(commit_msg)
        commit_file.flush()

        return utils.shell(f'{commit_cmd} -F {commit_file.name}', check=False)
Exemple #2
0
def _check_git_version():
    """Verify git version"""
    git_version = utils.shell_stdout(
        "git --version | rev | cut -f 1 -d' ' | rev")
    if version.parse(git_version) < version.parse('2.22.0'):
        raise RuntimeError(
            f'Must have git version >= 2.22.0 (version = {git_version})')
Exemple #3
0
def get_pull_request():
    """Find the pull request in github

    Raises:
        NoPullRequestFoundError: If a pull request isn't found
        MultiplePullRequestsFoundError: If multiple pull requests are
            opened from the current branch
    """
    org_name, repo_name = get_org_and_repo_name()
    current_branch = utils.shell_stdout(
        "git --no-pager branch | grep \\* | cut -d ' ' -f2")

    try:
        prs = (GithubClient().get(f'/repos/{org_name}/{repo_name}/pulls'
                                  f'?head={org_name}:{current_branch}').json())
    except requests.exceptions.RequestException as exc:
        raise exceptions.GithubPullRequestAPIError(
            'An unexpected error occurred with the Github pull requests'
            ' API.') from exc

    if not prs:
        raise exceptions.NoGithubPullRequestFoundError(
            f'No pull requests found for branch "{current_branch}"')

    if len(prs) > 1:
        raise exceptions.MultipleGithubPullRequestsFoundError(
            f'Multiple pull requests found for branch "{current_branch}"')

    return prs[0]
Exemple #4
0
def squash(ref, no_verify=False, allow_empty=False):
    """
    Squashes all commits since the common ancestor of ref.

    Args:
        ref (str): The git reference to squash against. Every commit after
            the common ancestor of this reference will be squashed.
        no_verify (bool, default=False): True if ignoring
            pre-commit hooks
        allow_empty (bool, default=False): True if an empty
            commit should be allowed

    Raises:
        `NoSquashableCommitsError`: When no commits can be squashed.
        subprocess.CalledProcessError: If the first ``git reset`` call
            unexpectedly fails
        `NoGithubPullRequestFoundError`: When using ``:github/pr`` as
            the range and no pull requests are found.
        `MultipleGithubPullRequestsFoundError`: When using ``:github/pr`` as
            the range and multiple pull requests are found.

    Returns:
        subprocess.CompletedProcess: The commit result. The commit result
        contains either a failed pre-commit hook result or a successful/failed
        commit result.
    """
    ref = github.get_pull_request_base() if ref == GITHUB_PR else ref
    range = f'{ref}..'

    commits = CommitRange(range=range)
    if not commits:
        raise exceptions.NoSquashableCommitsError('No commits to squash')

    # If there is a valid commit, use it as the default values for the
    # squashed commit message. Note that we look for the last valid commit
    valid_commits = commits.filter('is_valid', True)
    last_valid_commit = valid_commits[-1] if valid_commits else None

    defaults = last_valid_commit.schema_data if last_valid_commit else {}

    # Reset to the common ancestor of the ref point
    common_ancestor = utils.shell_stdout(f'git merge-base {ref} HEAD')
    utils.shell(f'git reset --soft {common_ancestor}')

    try:
        # Prompt for the new commit message. Reset back to the last point
        # if anything goes wrong
        commit_result = commit(allow_empty=allow_empty,
                               no_verify=no_verify,
                               defaults=defaults)
    except (Exception, KeyboardInterrupt):
        utils.shell('git reset ORIG_HEAD')
        raise

    if commit_result.returncode != 0:
        utils.shell('git reset ORIG_HEAD')

    return commit_result
Exemple #5
0
def _check_git_version():
    """Verify git version"""
    git_version_out = utils.shell_stdout("git --version").split(' ')
    assert len(git_version_out) >= 2
    git_version = git_version_out[2]

    if version.parse(git_version) < version.parse('2.22.0'):
        raise RuntimeError(
            f'Must have git version >= 2.22.0 (version = {git_version})')
Exemple #6
0
def test_tidy_log():
    """
    Integration test for tidy-log
    """
    full_log = utils.shell_stdout('git tidy-log')
    assert full_log.startswith('# Unreleased')
    assert 'Commit could not be parsed.' in full_log
    assert '# v1.2' not in full_log  # dev1.2 takes precedence in this case
    assert '# dev1.2' in full_log
    assert '# v1.1' in full_log
Exemple #7
0
def get_org_and_repo_name():
    remote_url = utils.shell_stdout('git remote get-url origin')
    if not remote_url:
        raise exceptions.GithubConfigurationError(
            'Must have a remote named "origin" in order to work with Github.')

    org_name, repo_name = remote_url.split(':')[1].split('/')

    assert repo_name.endswith('.git')
    return org_name, repo_name[0:-4]
Exemple #8
0
def _git_log_as_yaml(git_log_cmd):
    """
    Outputs git log in a format parseable as YAML.

    Args:
        git_log_cmd (str): The primary "git log .." command.
            This function adds the "--format" parameter to
            it and cleans the resulting log.

    Returns:
        List[str]: The "git log" return value where every line can
        be parsed as YAML.
    """
    # NOTE(@wesleykendall) - The git log is rendered as YAML so that it
    # can be parsed as key/value pairs. There are some circumstances
    # that are not fixed where the log cannot be parsed as YAML
    # (e.g. having a trailer value start with a ":"). git-tidy still
    # provides the ability to filter unparsed commits.
    #
    # We assume the parsed commit always has the following keys:
    # sha: The full commit sha (%H). Must always be rendered first
    # author_name: The author name (%an)
    # author_email: The author email (%ae)
    delimiter = '\n<-------->'
    git_log_stdout = utils.shell_stdout(
        f'{git_log_cmd} '
        '--format="'
        'sha: %H%n'
        'author_name: %an%n'
        'author_email: %ae%n'
        'author_date: %ad%n'
        'committer_name: %cn%n'
        'committer_email: %ce%n'
        'committer_date: %cd%n'
        'summary: |%n%w(0, 4, 4)%s%n%w(0, 0, 0)'
        'description: |%n%w(0, 4, 4)%b%n%w(0, 0, 0)'
        'trailers: [*{*%(trailers:separator=*%x7d*%x2c*%x7b*)*}*]'
        f'%n{delimiter}"')

    # Escape any double quotes used in trailers
    git_log_stdout = re.sub(
        r'^trailers:.*$',
        lambda x: x.group().replace('"', r'\"'),
        git_log_stdout,
        flags=re.MULTILINE,
    )
    # Format all empty trailer dictionaries. This only happens when
    # there are no trailers
    git_log_stdout = re.sub(r'\*{\*\*}\*', r'{}', git_log_stdout)

    # Quote all trailer values
    git_log_stdout = re.sub(r'\*{\*(\w+: )', r'{\1"', git_log_stdout)
    git_log_stdout = re.sub(r'\*}\*', r'"}', git_log_stdout)

    return [msg for msg in git_log_stdout.split(delimiter) if msg]
Exemple #9
0
def test_squash_error_rollback(mocker):
    """Tests core.squash rolls back resets on commit error"""
    mocker.patch.object(
        formaldict.Schema,
        'prompt',
        autospec=True,
        side_effect=[
            {
                # The first commit
                'type': 'bug',
                'summary': 'summary',
                'description': 'description',
                'jira': 'WEB-9999',
            },
            # Throw an exception when trying to do the squash commit
            Exception,
        ],
    )

    with open('file_to_commit', 'w+') as f:
        f.write('Hello World')

    utils.shell('git add .')
    assert core.commit().returncode == 0

    # The first squash call throws an unexpected error and rolls back the reset
    with pytest.raises(Exception):
        core.squash('HEAD~1')

    assert utils.shell_stdout('git diff --cached') == ''

    # Make commit return a non-zero exit code on next squash commit
    mocker.patch(
        'tidy.core.commit',
        autospec=True,
        return_value=mocker.Mock(returncode=1),
    )

    # The next squash call has a commit error and rolls back the reset
    assert core.squash('HEAD~1').returncode == 1
    assert utils.shell_stdout('git diff --cached') == ''
Exemple #10
0
def test_squash(mocker):
    """Tests core.squash"""
    mocker.patch.object(
        formaldict.Schema,
        'prompt',
        autospec=True,
        side_effect=[
            {
                # The first commit
                'type': 'bug',
                'summary': 'summary',
                'description': 'description',
                'jira': 'WEB-9999',
            },
            {
                # The second commit
                'type': 'trivial',
                'summary': 'Fixing up something',
            },
            {
                # The third commit
                'type': 'trivial',
                'summary': 'Fixing up something else',
            },
            {
                # The commit when squashing all commits
                'type': 'bug',
                'summary': 'final summary',
                'description': 'final description',
                'jira': 'WEB-9999',
            },
        ],
    )

    for f_name in ['file_to_commit1', 'file_to_commit2', 'file_to_commit3']:
        with open(f_name, 'w+') as f:
            f.write('Hello World')

        utils.shell('git add .')
        assert core.commit().returncode == 0

    assert core.squash('HEAD~2').returncode == 0

    commit = utils.shell_stdout('git show --summary')
    assert ('    final summary\n'
            '    \n'
            '    final description\n'
            '    \n'
            '    Type: bug\n'
            '    Jira: WEB-9999') in commit
Exemple #11
0
    def date(self):
        """
        Parse the date of the tag

        Returns:
            datetime: The tag parsed as a datetime object.
        """
        if not hasattr(self, '_date'):
            try:
                self._date = dateutil.parser.parse(
                    utils.shell_stdout(f'git log -1 --format=%ad {self}'))
            except dateutil.parser.ParserError:
                self._date = None

        return self._date
Exemple #12
0
    def from_sha(cls, sha, tag_match=None):
        """
        Create a Tag object from a sha or return None if there is no
        associated tag

        Returns:
            Tag: A constructed tag or ``None`` if no tags contain the commit.
        """
        describe_cmd = f'git describe {sha} --contains'
        if tag_match:
            describe_cmd += f' --match={tag_match}'

        rev = (utils.shell_stdout(describe_cmd,
                                  check=False,
                                  stderr=subprocess.PIPE).replace(
                                      '~', ':').replace('^', ':'))
        return cls(rev.split(':')[0]) if rev else None
Exemple #13
0
def test_squash_diverging_branches(mocker):
    """Tests core.squash against a base branch that has diverged"""
    mocker.patch.object(
        formaldict.Schema,
        'prompt',
        autospec=True,
        side_effect=[
            {
                # The first commit on the base branch
                'type': 'bug',
                'summary': 'summary',
                'description': 'first master commit',
                'jira': 'WEB-9999',
            },
            {
                # The second commit on base after making squash branch
                'type': 'trivial',
                'summary': 'wont be seen in history',
            },
            {
                # The first commit on the branch to squash
                'type': 'trivial',
                'summary': 'Fixing up something',
            },
            {
                # The second commit on the branch to squash
                'type': 'trivial',
                'summary': 'Fixing up something else',
            },
            {
                # The commit when squashing all commits
                'type': 'bug',
                'summary': 'final summary',
                'description': 'final description',
                'jira': 'WEB-9999',
            },
        ],
    )

    # Make a commit that all branches will shared
    core.commit(allow_empty=True)

    # Make a branch that we will squash
    utils.shell('git branch test-squash')

    # Now commit against the base branch (i.e. make it diverge)
    core.commit(allow_empty=True)

    # Change branches and do a few more commits that will be squashed
    utils.shell('git checkout test-squash')

    core.commit(allow_empty=True)
    core.commit(allow_empty=True)

    assert core.squash('master', allow_empty=True).returncode == 0

    commits = utils.shell_stdout('git --no-pager log')

    # These commits disappeared when squashing
    assert 'Fixing up something' not in commits
    # First commit against master should be in log
    assert 'first master commit' in commits
    # Squashed commit should be in log
    assert 'final description' in commits
    # The divergent commit should not appear in history
    assert 'wont be seen' not in commits
Exemple #14
0
def test_shell_stdout():
    """Tests utils.shell_stdout()"""
    assert utils.shell_stdout('echo "hello world"') == 'hello world'