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
19Normally this is loaded indirectly by repo itself, but it can be run directly
20when developing.
21"""
22
23from __future__ import print_function
24
25import argparse
26import datetime
27import os
28import sys
29
30
31# Assert some minimum Python versions as we don't test or support any others.
32# We only support Python 2.7, and require 2.7.5+/3.4+ to include signal fix:
33# https://bugs.python.org/issue14173
34if sys.version_info < (2, 7, 5):
35    print('repohooks: error: Python-2.7.5+ is required', file=sys.stderr)
36    sys.exit(1)
37elif sys.version_info.major == 3 and sys.version_info < (3, 5):
38    print('repohooks: error: Python-3.5+ is required', file=sys.stderr)
39    sys.exit(1)
40elif sys.version_info < (3, 6):
41    # We want to get people off of old versions of Python.
42    print('repohooks: warning: Python-3.6+ is going to be required; '
43          'please upgrade soon to maintain support.', file=sys.stderr)
44
45
46_path = os.path.dirname(os.path.realpath(__file__))
47if sys.path[0] != _path:
48    sys.path.insert(0, _path)
49del _path
50
51# We have to import our local modules after the sys.path tweak.  We can't use
52# relative imports because this is an executable program, not a module.
53# pylint: disable=wrong-import-position
54import rh
55import rh.results
56import rh.config
57import rh.git
58import rh.hooks
59import rh.sixish
60import rh.terminal
61import rh.utils
62
63
64# Repohooks homepage.
65REPOHOOKS_URL = 'https://android.googlesource.com/platform/tools/repohooks/'
66
67
68class Output(object):
69    """Class for reporting hook status."""
70
71    COLOR = rh.terminal.Color()
72    COMMIT = COLOR.color(COLOR.CYAN, 'COMMIT')
73    RUNNING = COLOR.color(COLOR.YELLOW, 'RUNNING')
74    PASSED = COLOR.color(COLOR.GREEN, 'PASSED')
75    FAILED = COLOR.color(COLOR.RED, 'FAILED')
76    WARNING = COLOR.color(COLOR.YELLOW, 'WARNING')
77
78    def __init__(self, project_name):
79        """Create a new Output object for a specified project.
80
81        Args:
82          project_name: name of project.
83        """
84        self.project_name = project_name
85        self.num_hooks = None
86        self.hook_index = 0
87        self.success = True
88        self.start_time = datetime.datetime.now()
89
90    def set_num_hooks(self, num_hooks):
91        """Keep track of how many hooks we'll be running.
92
93        Args:
94          num_hooks: number of hooks to be run.
95        """
96        self.num_hooks = num_hooks
97
98    def commit_start(self, commit, commit_summary):
99        """Emit status for new commit.
100
101        Args:
102          commit: commit hash.
103          commit_summary: commit summary.
104        """
105        status_line = '[%s %s] %s' % (self.COMMIT, commit[0:12], commit_summary)
106        rh.terminal.print_status_line(status_line, print_newline=True)
107        self.hook_index = 1
108
109    def hook_start(self, hook_name):
110        """Emit status before the start of a hook.
111
112        Args:
113          hook_name: name of the hook.
114        """
115        status_line = '[%s %d/%d] %s' % (self.RUNNING, self.hook_index,
116                                         self.num_hooks, hook_name)
117        self.hook_index += 1
118        rh.terminal.print_status_line(status_line)
119
120    def hook_error(self, hook_name, error):
121        """Print an error for a single hook.
122
123        Args:
124          hook_name: name of the hook.
125          error: error string.
126        """
127        self.error(hook_name, error)
128
129    def hook_warning(self, hook_name, warning):
130        """Print a warning for a single hook.
131
132        Args:
133          hook_name: name of the hook.
134          warning: warning string.
135        """
136        status_line = '[%s] %s' % (self.WARNING, hook_name)
137        rh.terminal.print_status_line(status_line, print_newline=True)
138        print(warning, file=sys.stderr)
139
140    def error(self, header, error):
141        """Print a general error.
142
143        Args:
144          header: A unique identifier for the source of this error.
145          error: error string.
146        """
147        status_line = '[%s] %s' % (self.FAILED, header)
148        rh.terminal.print_status_line(status_line, print_newline=True)
149        print(error, file=sys.stderr)
150        self.success = False
151
152    def finish(self):
153        """Print summary for all the hooks."""
154        status_line = '[%s] repohooks for %s %s in %s' % (
155            self.PASSED if self.success else self.FAILED,
156            self.project_name,
157            'passed' if self.success else 'failed',
158            rh.utils.timedelta_str(datetime.datetime.now() - self.start_time))
159        rh.terminal.print_status_line(status_line, print_newline=True)
160
161
162def _process_hook_results(results):
163    """Returns an error string if an error occurred.
164
165    Args:
166      results: A list of HookResult objects, or None.
167
168    Returns:
169      error output if an error occurred, otherwise None
170      warning output if an error occurred, otherwise None
171    """
172    if not results:
173        return (None, None)
174
175    # We track these as dedicated fields in case a hook doesn't output anything.
176    # We want to treat silent non-zero exits as failures too.
177    has_error = False
178    has_warning = False
179
180    error_ret = ''
181    warning_ret = ''
182    for result in results:
183        if result:
184            ret = ''
185            if result.files:
186                ret += '  FILES: %s' % (result.files,)
187            lines = result.error.splitlines()
188            ret += '\n'.join('    %s' % (x,) for x in lines)
189            if result.is_warning():
190                has_warning = True
191                warning_ret += ret
192            else:
193                has_error = True
194                error_ret += ret
195
196    return (error_ret if has_error else None,
197            warning_ret if has_warning else None)
198
199
200def _get_project_config():
201    """Returns the configuration for a project.
202
203    Expects to be called from within the project root.
204    """
205    global_paths = (
206        # Load the global config found in the manifest repo.
207        os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'),
208        # Load the global config found in the root of the repo checkout.
209        rh.git.find_repo_root(),
210    )
211    paths = (
212        # Load the config for this git repo.
213        '.',
214    )
215    return rh.config.PreUploadConfig(paths=paths, global_paths=global_paths)
216
217
218def _attempt_fixes(fixup_func_list, commit_list):
219    """Attempts to run |fixup_func_list| given |commit_list|."""
220    if len(fixup_func_list) != 1:
221        # Only single fixes will be attempted, since various fixes might
222        # interact with each other.
223        return
224
225    hook_name, commit, fixup_func = fixup_func_list[0]
226
227    if commit != commit_list[0]:
228        # If the commit is not at the top of the stack, git operations might be
229        # needed and might leave the working directory in a tricky state if the
230        # fix is attempted to run automatically (e.g. it might require manual
231        # merge conflict resolution). Refuse to run the fix in those cases.
232        return
233
234    prompt = ('An automatic fix can be attempted for the "%s" hook. '
235              'Do you want to run it?' % hook_name)
236    if not rh.terminal.boolean_prompt(prompt):
237        return
238
239    result = fixup_func()
240    if result:
241        print('Attempt to fix "%s" for commit "%s" failed: %s' %
242              (hook_name, commit, result),
243              file=sys.stderr)
244    else:
245        print('Fix successfully applied. Amend the current commit before '
246              'attempting to upload again.\n', file=sys.stderr)
247
248
249def _run_project_hooks_in_cwd(project_name, proj_dir, output, commit_list=None):
250    """Run the project-specific hooks in the cwd.
251
252    Args:
253      project_name: The name of this project.
254      proj_dir: The directory for this project (for passing on in metadata).
255      output: Helper for summarizing output/errors to the user.
256      commit_list: A list of commits to run hooks against.  If None or empty
257          list then we'll automatically get the list of commits that would be
258          uploaded.
259
260    Returns:
261      False if any errors were found, else True.
262    """
263    try:
264        config = _get_project_config()
265    except rh.config.ValidationError as e:
266        output.error('Loading config files', str(e))
267        return False
268
269    # If the repo has no pre-upload hooks enabled, then just return.
270    hooks = list(config.callable_hooks())
271    if not hooks:
272        return True
273
274    output.set_num_hooks(len(hooks))
275
276    # Set up the environment like repo would with the forall command.
277    try:
278        remote = rh.git.get_upstream_remote()
279        upstream_branch = rh.git.get_upstream_branch()
280    except rh.utils.CalledProcessError as e:
281        output.error('Upstream remote/tracking branch lookup',
282                     '%s\nDid you run repo start?  Is your HEAD detached?' %
283                     (e,))
284        return False
285
286    os.environ.update({
287        'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
288        'REPO_PATH': os.path.relpath(proj_dir, rh.git.find_repo_root()),
289        'REPO_PROJECT': project_name,
290        'REPO_REMOTE': remote,
291        'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
292    })
293
294    project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
295
296    if not commit_list:
297        commit_list = rh.git.get_commits(
298            ignore_merged_commits=config.ignore_merged_commits)
299
300    ret = True
301    fixup_func_list = []
302
303    for commit in commit_list:
304        # Mix in some settings for our hooks.
305        os.environ['PREUPLOAD_COMMIT'] = commit
306        diff = rh.git.get_affected_files(commit)
307        desc = rh.git.get_commit_desc(commit)
308        rh.sixish.setenv('PREUPLOAD_COMMIT_MESSAGE', desc)
309
310        commit_summary = desc.split('\n', 1)[0]
311        output.commit_start(commit=commit, commit_summary=commit_summary)
312
313        for name, hook in hooks:
314            output.hook_start(name)
315            hook_results = hook(project, commit, desc, diff)
316            (error, warning) = _process_hook_results(hook_results)
317            if error is not None or warning is not None:
318                if warning is not None:
319                    output.hook_warning(name, warning)
320                if error is not None:
321                    ret = False
322                    output.hook_error(name, error)
323                for result in hook_results:
324                    if result.fixup_func:
325                        fixup_func_list.append((name, commit,
326                                                result.fixup_func))
327
328    if fixup_func_list:
329        _attempt_fixes(fixup_func_list, commit_list)
330
331    return ret
332
333
334def _run_project_hooks(project_name, proj_dir=None, commit_list=None):
335    """Run the project-specific hooks in |proj_dir|.
336
337    Args:
338      project_name: The name of project to run hooks for.
339      proj_dir: If non-None, this is the directory the project is in.  If None,
340          we'll ask repo.
341      commit_list: A list of commits to run hooks against.  If None or empty
342          list then we'll automatically get the list of commits that would be
343          uploaded.
344
345    Returns:
346      False if any errors were found, else True.
347    """
348    output = Output(project_name)
349
350    if proj_dir is None:
351        cmd = ['repo', 'forall', project_name, '-c', 'pwd']
352        result = rh.utils.run(cmd, capture_output=True)
353        proj_dirs = result.stdout.split()
354        if not proj_dirs:
355            print('%s cannot be found.' % project_name, file=sys.stderr)
356            print('Please specify a valid project.', file=sys.stderr)
357            return False
358        if len(proj_dirs) > 1:
359            print('%s is associated with multiple directories.' % project_name,
360                  file=sys.stderr)
361            print('Please specify a directory to help disambiguate.',
362                  file=sys.stderr)
363            return False
364        proj_dir = proj_dirs[0]
365
366    pwd = os.getcwd()
367    try:
368        # Hooks assume they are run from the root of the project.
369        os.chdir(proj_dir)
370        return _run_project_hooks_in_cwd(project_name, proj_dir, output,
371                                         commit_list=commit_list)
372    finally:
373        output.finish()
374        os.chdir(pwd)
375
376
377def main(project_list, worktree_list=None, **_kwargs):
378    """Main function invoked directly by repo.
379
380    We must use the name "main" as that is what repo requires.
381
382    This function will exit directly upon error so that repo doesn't print some
383    obscure error message.
384
385    Args:
386      project_list: List of projects to run on.
387      worktree_list: A list of directories.  It should be the same length as
388          project_list, so that each entry in project_list matches with a
389          directory in worktree_list.  If None, we will attempt to calculate
390          the directories automatically.
391      kwargs: Leave this here for forward-compatibility.
392    """
393    found_error = False
394    if not worktree_list:
395        worktree_list = [None] * len(project_list)
396    for project, worktree in zip(project_list, worktree_list):
397        if not _run_project_hooks(project, proj_dir=worktree):
398            found_error = True
399            # If a repo had failures, add a blank line to help break up the
400            # output.  If there were no failures, then the output should be
401            # very minimal, so we don't add it then.
402            print('', file=sys.stderr)
403
404    if found_error:
405        color = rh.terminal.Color()
406        print('%s: Preupload failed due to above error(s).\n'
407              'For more info, please see:\n%s' %
408              (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
409              file=sys.stderr)
410        sys.exit(1)
411
412
413def _identify_project(path):
414    """Identify the repo project associated with the given path.
415
416    Returns:
417      A string indicating what project is associated with the path passed in or
418      a blank string upon failure.
419    """
420    cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
421    return rh.utils.run(cmd, capture_output=True, cwd=path).stdout.strip()
422
423
424def direct_main(argv):
425    """Run hooks directly (outside of the context of repo).
426
427    Args:
428      argv: The command line args to process.
429
430    Returns:
431      0 if no pre-upload failures, 1 if failures.
432
433    Raises:
434      BadInvocation: On some types of invocation errors.
435    """
436    parser = argparse.ArgumentParser(description=__doc__)
437    parser.add_argument('--dir', default=None,
438                        help='The directory that the project lives in.  If not '
439                        'specified, use the git project root based on the cwd.')
440    parser.add_argument('--project', default=None,
441                        help='The project repo path; this can affect how the '
442                        'hooks get run, since some hooks are project-specific.'
443                        'If not specified, `repo` will be used to figure this '
444                        'out based on the dir.')
445    parser.add_argument('commits', nargs='*',
446                        help='Check specific commits')
447    opts = parser.parse_args(argv)
448
449    # Check/normalize git dir; if unspecified, we'll use the root of the git
450    # project from CWD.
451    if opts.dir is None:
452        cmd = ['git', 'rev-parse', '--git-dir']
453        git_dir = rh.utils.run(cmd, capture_output=True).stdout.strip()
454        if not git_dir:
455            parser.error('The current directory is not part of a git project.')
456        opts.dir = os.path.dirname(os.path.abspath(git_dir))
457    elif not os.path.isdir(opts.dir):
458        parser.error('Invalid dir: %s' % opts.dir)
459    elif not rh.git.is_git_repository(opts.dir):
460        parser.error('Not a git repository: %s' % opts.dir)
461
462    # Identify the project if it wasn't specified; this _requires_ the repo
463    # tool to be installed and for the project to be part of a repo checkout.
464    if not opts.project:
465        opts.project = _identify_project(opts.dir)
466        if not opts.project:
467            parser.error("Repo couldn't identify the project of %s" % opts.dir)
468
469    if _run_project_hooks(opts.project, proj_dir=opts.dir,
470                          commit_list=opts.commits):
471        return 0
472    return 1
473
474
475if __name__ == '__main__':
476    sys.exit(direct_main(sys.argv[1:]))
477