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 _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})')
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]
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 _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})')
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
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]
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]
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 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 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
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
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
def test_shell_stdout(): """Tests utils.shell_stdout()""" assert utils.shell_stdout('echo "hello world"') == 'hello world'