chromium/third_party/blink/tools/blinkpy/w3c/chromium_commit.py

# Copyright 2016 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

from typing import List, Optional

from blinkpy.common.host import Host
from blinkpy.common.system.executive import ScriptError
from blinkpy.w3c.common import is_file_exportable


class ChromiumCommit:

    def __init__(self,
                 host: Host,
                 sha: Optional[str] = None,
                 position: Optional[str] = None):
        """Initializes a ChomiumCommit object, given a sha or commit position.

        Args:
            host: A Host object.
            sha: A Chromium commit SHA hash.
            position: A commit position footer string of the form:
                    'Cr-Commit-Position: refs/heads/main@{#431915}'
                or just the commit position string:
                    'refs/heads/main@{#431915}'
        """
        self.project_config = host.project_config
        self.test_root = self.project_config.test_root
        self._git = host.git(path=self.project_config.project_root)

        assert sha or position, 'requires sha or position'
        assert not (sha and position), 'cannot accept both sha and position'

        if position:
            if position.startswith('Cr-Commit-Position: '):
                position = position[len('Cr-Commit-Position: '):]

            sha = self.position_to_sha(position)
        else:
            position = self.sha_to_position(sha)

        assert len(sha) == 40, 'Expected SHA-1 hash, got {}'.format(sha)
        assert sha and position, 'ChromiumCommit should have sha and position after __init__'
        self.sha = sha
        self.position = position

    def __str__(self) -> str:
        return '{} "{}"'.format(self.short_sha, self.subject())

    @property
    def short_sha(self) -> str:
        return self.sha[0:10]

    def num_behind_main(self) -> int:
        """Returns the number of commits this commit is behind origin/main.

        It is inclusive of this commit and of the latest commit.
        """
        return len(
            self._git.run([
                'rev-list',
                f'{self.sha}..origin/{self.host.project_config.gerrit_branch}',
            ]).splitlines())

    def position_to_sha(self, commit_position: str) -> str:
        return self._git.run(['crrev-parse', commit_position]).strip()

    def sha_to_position(self, sha: str) -> str:
        try:
            return self._git.run(['footers', '--position', sha]).strip()
        except ScriptError as e:
            # Commits from Gerrit CLs that have not yet been committed in
            # Chromium do not have a commit position.
            if 'Unable to infer commit position from footers' in e.message:
                return 'no-commit-position-yet'
            else:
                raise

    def subject(self) -> str:
        return self._git.run(['show', '--format=%s', '--no-patch',
                              self.sha]).strip()

    def body(self) -> str:
        return self._git.run(['show', '--format=%b', '--no-patch', self.sha])

    def author(self) -> str:
        return self._git.run(
            ['show', '--format=%aN <%aE>', '--no-patch', self.sha]).strip()

    def message(self) -> str:
        """Returns a string with a commit's subject and body."""
        return self._git.run(['show', '--format=%B', '--no-patch', self.sha])

    def change_id(self) -> str:
        """Returns the Change-Id footer if it is present."""
        return self._git.run(['footers', '--key', 'Change-Id',
                              self.sha]).strip()

    def filtered_changed_files(self) -> List[str]:
        """Returns a list of modified exportable files."""
        changed_files = self._git.run([
            'diff-tree',
            '--name-only',
            '--no-commit-id',
            '-r',
            self.sha,
            '--',
            self.test_root,
        ]).splitlines()
        return [
            f for f in changed_files
            if is_file_exportable(f, self.project_config)
        ]

    def format_patch(self) -> str:
        """Makes a patch with only exportable changes."""
        filtered_files = self.filtered_changed_files()
        if not filtered_files:
            return ''
        # Disable rename detection, which may allow a chained CL with renames
        # to export too early (https://crbug.com/40242850#comment8).
        return self._git.run([
            'format-patch',
            '-1',
            '--no-renames',
            '--stdout',
            self.sha,
            '--',
            *filtered_files,
        ])

    def url(self) -> str:
        """Returns a URL to view more information about this commit."""
        return f'https://chromium.googlesource.com/{self.project_config.gerrit_project}/+/{self.short_sha}'