예제 #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)
예제 #2
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
예제 #3
0
def test_commit(mocker, input_data):
    """Tests core.commit and verifies the resulting commit object."""
    mocker.patch.object(formaldict.Schema,
                        'prompt',
                        autospec=True,
                        return_value=input_data)

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

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

    commit = core.CommitRange('HEAD~1..')[0]
    assert commit.is_parsed
    for key, value in input_data.items():
        assert getattr(commit, key) == value
예제 #4
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') == ''
예제 #5
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
예제 #6
0
    def __init__(self,
                 range='',
                 tag_match=None,
                 before=None,
                 after=None,
                 reverse=False):
        self._schema = _load_commit_schema()
        self._tag_match = tag_match
        self._before = before
        self._after = after
        self._reverse = reverse
        _check_git_version()

        # The special ":github/pr" range will do a range against the base
        # pull request branch
        if range == GITHUB_PR:
            range = _get_pull_request_range()

        # Ensure any remotes are fetched
        utils.shell('git --no-pager fetch -q')

        git_log_cmd = f'git --no-pager log {range} --no-merges'
        if before:
            git_log_cmd += f' --before={before}'
        if after:
            git_log_cmd += f' --after={after}'
        if reverse:
            git_log_cmd += f' --reverse'

        git_yaml_logs = _git_log_as_yaml(git_log_cmd)

        self._range = range

        return super().__init__([
            Commit(msg, self._schema, tag_match=self._tag_match)
            for msg in git_yaml_logs
        ])
예제 #7
0
def git_tidy_repo(tidy_config):
    """Create a git repo with structured commits for integration tests"""
    cwd = os.getcwd()
    os.chdir(tidy_config)

    utils.shell('git init .')
    utils.shell('git config user.email "*****@*****.**"')
    utils.shell('git config user.name "Your Name"')
    utils.shell('git commit --allow-empty -m $"Summary1 [skip ci]\n\n'
                'Description1\n\nType: api-break\nJira: WEB-1111"')
    utils.shell('git commit --allow-empty -m $"Summary2\n\nDescription2\n\n'
                'Type: bug\nJira: WEB-1112"')
    utils.shell('git tag v1.1')
    utils.shell('git commit --allow-empty -m $"Summary3\n\nType: trivial"')
    utils.shell('git tag dev1.2')
    utils.shell('git tag v1.2')
    utils.shell('git commit --allow-empty -m $"Summary4\n\nDescription4\n\n'
                'Type: feature\nJira: WEB-1113"')
    utils.shell('git commit --allow-empty -m $"Invalid5\n\n'
                'Type: feature\nJira: INVALID"')
    # Create a commit that uses the same delimiter structure as git-tidy
    # to create a scenario of an unparseable commit.
    utils.shell('git commit --allow-empty -m $"Invalid6\n\nUnparseable: *{*"')

    yield tidy_config

    os.chdir(cwd)
예제 #8
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