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)
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
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
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') == ''
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
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 ])
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)
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