Home | History | Annotate | Download | only in repohooks
      1 #!/usr/bin/python
      2 # -*- coding:utf-8 -*-
      3 # Copyright 2016 The Android Open Source Project
      4 #
      5 # Licensed under the Apache License, Version 2.0 (the "License");
      6 # you may not use this file except in compliance with the License.
      7 # You may obtain a copy of the License at
      8 #
      9 #      http://www.apache.org/licenses/LICENSE-2.0
     10 #
     11 # Unless required by applicable law or agreed to in writing, software
     12 # distributed under the License is distributed on an "AS IS" BASIS,
     13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     14 # See the License for the specific language governing permissions and
     15 # limitations under the License.
     16 
     17 """Repo pre-upload hook.
     18 
     19 Normally this is loaded indirectly by repo itself, but it can be run directly
     20 when developing.
     21 """
     22 
     23 from __future__ import print_function
     24 
     25 import argparse
     26 import os
     27 import sys
     28 
     29 try:
     30     __file__
     31 except NameError:
     32     # Work around repo until it gets fixed.
     33     # https://gerrit-review.googlesource.com/75481
     34     __file__ = os.path.join(os.getcwd(), 'pre-upload.py')
     35 _path = os.path.dirname(os.path.realpath(__file__))
     36 if sys.path[0] != _path:
     37     sys.path.insert(0, _path)
     38 del _path
     39 
     40 import rh
     41 import rh.results
     42 import rh.config
     43 import rh.git
     44 import rh.hooks
     45 import rh.terminal
     46 import rh.utils
     47 
     48 
     49 # Repohooks homepage.
     50 REPOHOOKS_URL = 'https://android.googlesource.com/platform/tools/repohooks/'
     51 
     52 
     53 class Output(object):
     54     """Class for reporting hook status."""
     55 
     56     COLOR = rh.terminal.Color()
     57     COMMIT = COLOR.color(COLOR.CYAN, 'COMMIT')
     58     RUNNING = COLOR.color(COLOR.YELLOW, 'RUNNING')
     59     PASSED = COLOR.color(COLOR.GREEN, 'PASSED')
     60     FAILED = COLOR.color(COLOR.RED, 'FAILED')
     61 
     62     def __init__(self, project_name, num_hooks):
     63         """Create a new Output object for a specified project.
     64 
     65         Args:
     66           project_name: name of project.
     67           num_hooks: number of hooks to be run.
     68         """
     69         self.project_name = project_name
     70         self.num_hooks = num_hooks
     71         self.hook_index = 0
     72         self.success = True
     73 
     74     def commit_start(self, commit, commit_summary):
     75         """Emit status for new commit.
     76 
     77         Args:
     78           commit: commit hash.
     79           commit_summary: commit summary.
     80         """
     81         status_line = '[%s %s] %s' % (self.COMMIT, commit[0:12], commit_summary)
     82         rh.terminal.print_status_line(status_line, print_newline=True)
     83         self.hook_index = 1
     84 
     85     def hook_start(self, hook_name):
     86         """Emit status before the start of a hook.
     87 
     88         Args:
     89           hook_name: name of the hook.
     90         """
     91         status_line = '[%s %d/%d] %s' % (self.RUNNING, self.hook_index,
     92                                          self.num_hooks, hook_name)
     93         self.hook_index += 1
     94         rh.terminal.print_status_line(status_line)
     95 
     96     def hook_error(self, hook_name, error):
     97         """Print an error.
     98 
     99         Args:
    100           hook_name: name of the hook.
    101           error: error string.
    102         """
    103         status_line = '[%s] %s' % (self.FAILED, hook_name)
    104         rh.terminal.print_status_line(status_line, print_newline=True)
    105         print(error, file=sys.stderr)
    106         self.success = False
    107 
    108     def finish(self):
    109         """Print repohook summary."""
    110         status_line = '[%s] repohooks for %s %s' % (
    111             self.PASSED if self.success else self.FAILED,
    112             self.project_name,
    113             'passed' if self.success else 'failed')
    114         rh.terminal.print_status_line(status_line, print_newline=True)
    115 
    116 
    117 def _process_hook_results(results):
    118     """Returns an error string if an error occurred.
    119 
    120     Args:
    121       results: A list of HookResult objects, or None.
    122 
    123     Returns:
    124       error output if an error occurred, otherwise None
    125     """
    126     if not results:
    127         return None
    128 
    129     ret = ''
    130     for result in results:
    131         if result:
    132             if result.files:
    133                 ret += '  FILES: %s' % (result.files,)
    134             lines = result.error.splitlines()
    135             ret += '\n'.join('    %s' % (x,) for x in lines)
    136 
    137     return ret or None
    138 
    139 
    140 def _get_project_config():
    141     """Returns the configuration for a project.
    142 
    143     Expects to be called from within the project root.
    144     """
    145     global_paths = (
    146         # Load the global config found in the manifest repo.
    147         os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'),
    148         # Load the global config found in the root of the repo checkout.
    149         rh.git.find_repo_root(),
    150     )
    151     paths = (
    152         # Load the config for this git repo.
    153         '.',
    154     )
    155     try:
    156         config = rh.config.PreSubmitConfig(paths=paths,
    157                                            global_paths=global_paths)
    158     except rh.config.ValidationError as e:
    159         print('invalid config file: %s' % (e,), file=sys.stderr)
    160         sys.exit(1)
    161     return config
    162 
    163 
    164 def _attempt_fixes(fixup_func_list, commit_list):
    165     """Attempts to run |fixup_func_list| given |commit_list|."""
    166     if len(fixup_func_list) != 1:
    167         # Only single fixes will be attempted, since various fixes might
    168         # interact with each other.
    169         return
    170 
    171     hook_name, commit, fixup_func = fixup_func_list[0]
    172 
    173     if commit != commit_list[0]:
    174         # If the commit is not at the top of the stack, git operations might be
    175         # needed and might leave the working directory in a tricky state if the
    176         # fix is attempted to run automatically (e.g. it might require manual
    177         # merge conflict resolution). Refuse to run the fix in those cases.
    178         return
    179 
    180     prompt = ('An automatic fix can be attempted for the "%s" hook. '
    181               'Do you want to run it?' % hook_name)
    182     if not rh.terminal.boolean_prompt(prompt):
    183         return
    184 
    185     result = fixup_func()
    186     if result:
    187         print('Attempt to fix "%s" for commit "%s" failed: %s' %
    188               (hook_name, commit, result),
    189               file=sys.stderr)
    190     else:
    191         print('Fix successfully applied. Amend the current commit before '
    192               'attempting to upload again.\n', file=sys.stderr)
    193 
    194 
    195 def _run_project_hooks(project_name, proj_dir=None,
    196                        commit_list=None):
    197     """For each project run its project specific hook from the hooks dictionary.
    198 
    199     Args:
    200       project_name: The name of project to run hooks for.
    201       proj_dir: If non-None, this is the directory the project is in.  If None,
    202           we'll ask repo.
    203       commit_list: A list of commits to run hooks against.  If None or empty
    204           list then we'll automatically get the list of commits that would be
    205           uploaded.
    206 
    207     Returns:
    208       False if any errors were found, else True.
    209     """
    210     if proj_dir is None:
    211         cmd = ['repo', 'forall', project_name, '-c', 'pwd']
    212         result = rh.utils.run_command(cmd, capture_output=True)
    213         proj_dirs = result.output.split()
    214         if len(proj_dirs) == 0:
    215             print('%s cannot be found.' % project_name, file=sys.stderr)
    216             print('Please specify a valid project.', file=sys.stderr)
    217             return 0
    218         if len(proj_dirs) > 1:
    219             print('%s is associated with multiple directories.' % project_name,
    220                   file=sys.stderr)
    221             print('Please specify a directory to help disambiguate.',
    222                   file=sys.stderr)
    223             return 0
    224         proj_dir = proj_dirs[0]
    225 
    226     pwd = os.getcwd()
    227     # Hooks assume they are run from the root of the project.
    228     os.chdir(proj_dir)
    229 
    230     # If the repo has no pre-upload hooks enabled, then just return.
    231     config = _get_project_config()
    232     hooks = list(config.callable_hooks())
    233     if not hooks:
    234         return True
    235 
    236     # Set up the environment like repo would with the forall command.
    237     try:
    238         remote = rh.git.get_upstream_remote()
    239         upstream_branch = rh.git.get_upstream_branch()
    240     except rh.utils.RunCommandError as e:
    241         print('upstream remote cannot be found: %s' % (e,), file=sys.stderr)
    242         print('Did you run repo start?', file=sys.stderr)
    243         sys.exit(1)
    244     os.environ.update({
    245         'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
    246         'REPO_PATH': proj_dir,
    247         'REPO_PROJECT': project_name,
    248         'REPO_REMOTE': remote,
    249         'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
    250     })
    251 
    252     output = Output(project_name, len(hooks))
    253     project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
    254 
    255     if not commit_list:
    256         commit_list = rh.git.get_commits(
    257             ignore_merged_commits=config.ignore_merged_commits)
    258 
    259     ret = True
    260     fixup_func_list = []
    261 
    262     for commit in commit_list:
    263         # Mix in some settings for our hooks.
    264         os.environ['PREUPLOAD_COMMIT'] = commit
    265         diff = rh.git.get_affected_files(commit)
    266         desc = rh.git.get_commit_desc(commit)
    267         os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
    268 
    269         commit_summary = desc.split('\n', 1)[0]
    270         output.commit_start(commit=commit, commit_summary=commit_summary)
    271 
    272         for name, hook in hooks:
    273             output.hook_start(name)
    274             hook_results = hook(project, commit, desc, diff)
    275             error = _process_hook_results(hook_results)
    276             if error:
    277                 ret = False
    278                 output.hook_error(name, error)
    279                 for result in hook_results:
    280                     if result.fixup_func:
    281                         fixup_func_list.append((name, commit,
    282                                                 result.fixup_func))
    283 
    284     if fixup_func_list:
    285         _attempt_fixes(fixup_func_list, commit_list)
    286 
    287     output.finish()
    288     os.chdir(pwd)
    289     return ret
    290 
    291 
    292 def main(project_list, worktree_list=None, **_kwargs):
    293     """Main function invoked directly by repo.
    294 
    295     We must use the name "main" as that is what repo requires.
    296 
    297     This function will exit directly upon error so that repo doesn't print some
    298     obscure error message.
    299 
    300     Args:
    301       project_list: List of projects to run on.
    302       worktree_list: A list of directories.  It should be the same length as
    303           project_list, so that each entry in project_list matches with a
    304           directory in worktree_list.  If None, we will attempt to calculate
    305           the directories automatically.
    306       kwargs: Leave this here for forward-compatibility.
    307     """
    308     found_error = False
    309     if not worktree_list:
    310         worktree_list = [None] * len(project_list)
    311     for project, worktree in zip(project_list, worktree_list):
    312         if not _run_project_hooks(project, proj_dir=worktree):
    313             found_error = True
    314 
    315     if found_error:
    316         color = rh.terminal.Color()
    317         print('%s: Preupload failed due to above error(s).\n'
    318               'For more info, please see:\n%s' %
    319               (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
    320               file=sys.stderr)
    321         sys.exit(1)
    322 
    323 
    324 def _identify_project(path):
    325     """Identify the repo project associated with the given path.
    326 
    327     Returns:
    328       A string indicating what project is associated with the path passed in or
    329       a blank string upon failure.
    330     """
    331     cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
    332     return rh.utils.run_command(cmd, capture_output=True, redirect_stderr=True,
    333                                 cwd=path).output.strip()
    334 
    335 
    336 def direct_main(argv):
    337     """Run hooks directly (outside of the context of repo).
    338 
    339     Args:
    340       argv: The command line args to process.
    341 
    342     Returns:
    343       0 if no pre-upload failures, 1 if failures.
    344 
    345     Raises:
    346       BadInvocation: On some types of invocation errors.
    347     """
    348     parser = argparse.ArgumentParser(description=__doc__)
    349     parser.add_argument('--dir', default=None,
    350                         help='The directory that the project lives in.  If not '
    351                         'specified, use the git project root based on the cwd.')
    352     parser.add_argument('--project', default=None,
    353                         help='The project repo path; this can affect how the '
    354                         'hooks get run, since some hooks are project-specific.'
    355                         'If not specified, `repo` will be used to figure this '
    356                         'out based on the dir.')
    357     parser.add_argument('commits', nargs='*',
    358                         help='Check specific commits')
    359     opts = parser.parse_args(argv)
    360 
    361     # Check/normalize git dir; if unspecified, we'll use the root of the git
    362     # project from CWD.
    363     if opts.dir is None:
    364         cmd = ['git', 'rev-parse', '--git-dir']
    365         git_dir = rh.utils.run_command(cmd, capture_output=True,
    366                                        redirect_stderr=True).output.strip()
    367         if not git_dir:
    368             parser.error('The current directory is not part of a git project.')
    369         opts.dir = os.path.dirname(os.path.abspath(git_dir))
    370     elif not os.path.isdir(opts.dir):
    371         parser.error('Invalid dir: %s' % opts.dir)
    372     elif not os.path.isdir(os.path.join(opts.dir, '.git')):
    373         parser.error('Not a git directory: %s' % opts.dir)
    374 
    375     # Identify the project if it wasn't specified; this _requires_ the repo
    376     # tool to be installed and for the project to be part of a repo checkout.
    377     if not opts.project:
    378         opts.project = _identify_project(opts.dir)
    379         if not opts.project:
    380             parser.error("Repo couldn't identify the project of %s" % opts.dir)
    381 
    382     if _run_project_hooks(opts.project, proj_dir=opts.dir,
    383                           commit_list=opts.commits):
    384         return 0
    385     else:
    386         return 1
    387 
    388 
    389 if __name__ == '__main__':
    390     sys.exit(direct_main(sys.argv[1:]))
    391