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