• 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 concurrent.futures
24import datetime
25import os
26import signal
27import sys
28from typing import List, Optional
29
30
31# Assert some minimum Python versions as we don't test or support any others.
32# See README.md for what version we may require.
33if sys.version_info < (3, 6):
34    print('repohooks: error: Python-3.6+ is required', file=sys.stderr)
35    sys.exit(1)
36
37
38_path = os.path.dirname(os.path.realpath(__file__))
39if sys.path[0] != _path:
40    sys.path.insert(0, _path)
41del _path
42
43# We have to import our local modules after the sys.path tweak.  We can't use
44# relative imports because this is an executable program, not a module.
45# pylint: disable=wrong-import-position
46import rh
47import rh.results
48import rh.config
49import rh.git
50import rh.hooks
51import rh.terminal
52import rh.utils
53
54
55# Repohooks homepage.
56REPOHOOKS_URL = 'https://android.googlesource.com/platform/tools/repohooks/'
57
58
59class Output(object):
60    """Class for reporting hook status."""
61
62    COLOR = rh.terminal.Color()
63    COMMIT = COLOR.color(COLOR.CYAN, 'COMMIT')
64    RUNNING = COLOR.color(COLOR.YELLOW, 'RUNNING')
65    PASSED = COLOR.color(COLOR.GREEN, 'PASSED')
66    FAILED = COLOR.color(COLOR.RED, 'FAILED')
67    WARNING = COLOR.color(COLOR.YELLOW, 'WARNING')
68    FIXUP = COLOR.color(COLOR.MAGENTA, 'FIXUP')
69
70    # How long a hook is allowed to run before we warn that it is "too slow".
71    _SLOW_HOOK_DURATION = datetime.timedelta(seconds=30)
72
73    def __init__(self, project_name):
74        """Create a new Output object for a specified project.
75
76        Args:
77          project_name: name of project.
78        """
79        self.project_name = project_name
80        self.hooks = None
81        self.num_hooks = None
82        self.num_commits = None
83        self.commit_index = 0
84        self.success = True
85        self.start_time = datetime.datetime.now()
86        self.hook_start_time = None
87        # Cache number of invisible characters in our banner.
88        self._banner_esc_chars = len(self.COLOR.color(self.COLOR.YELLOW, ''))
89
90    def set_num_commits(self, num_commits: int) -> None:
91        """Keep track of how many commits we'll be running.
92
93        Args:
94          num_commits: Number of commits to be run.
95        """
96        self.num_commits = num_commits
97        self.commit_index = 1
98
99    def commit_start(self, hooks, commit, commit_summary):
100        """Emit status for new commit.
101
102        Args:
103          hooks: All the hooks to be run for this commit.
104          commit: commit hash.
105          commit_summary: commit summary.
106        """
107        status_line = (
108            f'[{self.COMMIT} '
109            f'{self.commit_index}/{self.num_commits} '
110            f'{commit[0:12]}] {commit_summary}'
111        )
112        rh.terminal.print_status_line(status_line, print_newline=True)
113        self.commit_index += 1
114
115        # Initialize the pending hooks line too.
116        self.hooks = set(hooks)
117        self.num_hooks = len(hooks)
118        self.hook_banner()
119
120    def hook_banner(self):
121        """Display the banner for current set of hooks."""
122        pending = ', '.join(x.name for x in self.hooks)
123        status_line = (
124            f'[{self.RUNNING} '
125            f'{self.num_hooks - len(self.hooks)}/{self.num_hooks}] '
126            f'{pending}'
127        )
128        if self._banner_esc_chars and sys.stderr.isatty():
129            cols = os.get_terminal_size(sys.stderr.fileno()).columns
130            status_line = status_line[0:cols + self._banner_esc_chars]
131        rh.terminal.print_status_line(status_line)
132
133    def hook_finish(self, hook, duration):
134        """Finish processing any per-hook state."""
135        self.hooks.remove(hook)
136        if duration >= self._SLOW_HOOK_DURATION:
137            d = rh.utils.timedelta_str(duration)
138            self.hook_warning(
139                hook,
140                f'This hook took {d} to finish which is fairly slow for '
141                'developers.\nPlease consider moving the check to the '
142                'server/CI system instead.')
143
144        # Show any hooks still pending.
145        if self.hooks:
146            self.hook_banner()
147
148    def hook_error(self, hook, error):
149        """Print an error for a single hook.
150
151        Args:
152          hook: The hook that generated the output.
153          error: error string.
154        """
155        self.error(f'{hook.name} hook', error)
156
157    def hook_warning(self, hook, warning):
158        """Print a warning for a single hook.
159
160        Args:
161          hook: The hook that generated the output.
162          warning: warning string.
163        """
164        status_line = f'[{self.WARNING}] {hook.name}'
165        rh.terminal.print_status_line(status_line, print_newline=True)
166        print(warning, file=sys.stderr)
167
168    def error(self, header, error):
169        """Print a general error.
170
171        Args:
172          header: A unique identifier for the source of this error.
173          error: error string.
174        """
175        status_line = f'[{self.FAILED}] {header}'
176        rh.terminal.print_status_line(status_line, print_newline=True)
177        print(error, file=sys.stderr)
178        self.success = False
179
180    def hook_fixups(
181        self,
182        project_results: rh.results.ProjectResults,
183        hook_results: List[rh.results.HookResult],
184    ) -> None:
185        """Display summary of possible fixups for a single hook."""
186        for result in (x for x in hook_results if x.fixup_cmd):
187            cmd = result.fixup_cmd + list(result.files)
188            for line in (
189                f'[{self.FIXUP}] {result.hook} has automated fixups available',
190                f'  cd {rh.shell.quote(project_results.workdir)} && \\',
191                f'    {rh.shell.cmd_to_str(cmd)}',
192            ):
193                rh.terminal.print_status_line(line, print_newline=True)
194
195    def finish(self):
196        """Print summary for all the hooks."""
197        header = self.PASSED if self.success else self.FAILED
198        status = 'passed' if self.success else 'failed'
199        d = rh.utils.timedelta_str(datetime.datetime.now() - self.start_time)
200        rh.terminal.print_status_line(
201            f'[{header}] repohooks for {self.project_name} {status} in {d}',
202            print_newline=True)
203
204
205def _process_hook_results(results):
206    """Returns an error string if an error occurred.
207
208    Args:
209      results: A list of HookResult objects, or None.
210
211    Returns:
212      error output if an error occurred, otherwise None
213      warning output if an error occurred, otherwise None
214    """
215    if not results:
216        return (None, None)
217
218    # We track these as dedicated fields in case a hook doesn't output anything.
219    # We want to treat silent non-zero exits as failures too.
220    has_error = False
221    has_warning = False
222
223    error_ret = ''
224    warning_ret = ''
225    for result in results:
226        if result or result.is_warning():
227            ret = ''
228            if result.files:
229                ret += f'  FILES: {rh.shell.cmd_to_str(result.files)}\n'
230            lines = result.error.splitlines()
231            ret += '\n'.join(f'    {x}' for x in lines)
232            if result.is_warning():
233                has_warning = True
234                warning_ret += ret
235            else:
236                has_error = True
237                error_ret += ret
238
239    return (error_ret if has_error else None,
240            warning_ret if has_warning else None)
241
242
243def _get_project_config(from_git=False):
244    """Returns the configuration for a project.
245
246    Args:
247      from_git: If true, we are called from git directly and repo should not be
248          used.
249    Expects to be called from within the project root.
250    """
251    if from_git:
252        global_paths = (rh.git.find_repo_root(),)
253    else:
254        global_paths = (
255            # Load the global config found in the manifest repo.
256            (os.path.join(rh.git.find_repo_root(), '.repo', 'manifests')),
257            # Load the global config found in the root of the repo checkout.
258            rh.git.find_repo_root(),
259        )
260
261    paths = (
262        # Load the config for this git repo.
263        '.',
264    )
265    return rh.config.PreUploadSettings(paths=paths, global_paths=global_paths)
266
267
268def _attempt_fixes(projects_results: List[rh.results.ProjectResults]) -> None:
269    """Attempts to fix fixable results."""
270    # Filter out any result that has a fixup.
271    fixups = []
272    for project_results in projects_results:
273        fixups.extend((project_results.workdir, x)
274                      for x in project_results.fixups)
275    if not fixups:
276        return
277
278    if len(fixups) > 1:
279        banner = f'Multiple fixups ({len(fixups)}) are available.'
280    else:
281        banner = 'Automated fixups are available.'
282    print(Output.COLOR.color(Output.COLOR.MAGENTA, banner), file=sys.stderr)
283
284    # If there's more than one fixup available, ask if they want to blindly run
285    # them all, or prompt for them one-by-one.
286    mode = 'some'
287    if len(fixups) > 1:
288        while True:
289            response = rh.terminal.str_prompt(
290                'What would you like to do',
291                ('Run (A)ll', 'Run (S)ome', '(D)ry-run', '(N)othing [default]'))
292            if not response:
293                print('', file=sys.stderr)
294                return
295            if response.startswith('a') or response.startswith('y'):
296                mode = 'all'
297                break
298            elif response.startswith('s'):
299                mode = 'some'
300                break
301            elif response.startswith('d'):
302                mode = 'dry-run'
303                break
304            elif response.startswith('n'):
305                print('', file=sys.stderr)
306                return
307
308    # Walk all the fixups and run them one-by-one.
309    for workdir, result in fixups:
310        if mode == 'some':
311            if not rh.terminal.boolean_prompt(
312                f'Run {result.hook} fixup for {result.commit}'
313            ):
314                continue
315
316        cmd = tuple(result.fixup_cmd) + tuple(result.files)
317        print(
318            f'\n[{Output.RUNNING}] cd {rh.shell.quote(workdir)} && '
319            f'{rh.shell.cmd_to_str(cmd)}', file=sys.stderr)
320        if mode == 'dry-run':
321            continue
322
323        cmd_result = rh.utils.run(cmd, cwd=workdir, check=False)
324        if cmd_result.returncode:
325            print(f'[{Output.WARNING}] command exited {cmd_result.returncode}',
326                  file=sys.stderr)
327        else:
328            print(f'[{Output.PASSED}] great success', file=sys.stderr)
329
330    print(f'\n[{Output.FIXUP}] Please amend & rebase your tree before '
331          'attempting to upload again.\n', file=sys.stderr)
332
333def _run_project_hooks_in_cwd(
334    project_name: str,
335    proj_dir: str,
336    output: Output,
337    jobs: Optional[int] = None,
338    from_git: bool = False,
339    commit_list: Optional[List[str]] = None,
340) -> rh.results.ProjectResults:
341    """Run the project-specific hooks in the cwd.
342
343    Args:
344      project_name: The name of this project.
345      proj_dir: The directory for this project (for passing on in metadata).
346      output: Helper for summarizing output/errors to the user.
347      jobs: How many hooks to run in parallel.
348      from_git: If true, we are called from git directly and repo should not be
349          used.
350      commit_list: A list of commits to run hooks against.  If None or empty
351          list then we'll automatically get the list of commits that would be
352          uploaded.
353
354    Returns:
355      All the results for this project.
356    """
357    ret = rh.results.ProjectResults(project_name, proj_dir)
358
359    try:
360        config = _get_project_config(from_git)
361    except rh.config.ValidationError as e:
362        output.error('Loading config files', str(e))
363        return ret._replace(internal_failure=True)
364
365    builtin_hooks = list(config.callable_builtin_hooks())
366    custom_hooks = list(config.callable_custom_hooks())
367
368    # If the repo has no pre-upload hooks enabled, then just return.
369    if not builtin_hooks and not custom_hooks:
370        return ret
371
372    # Set up the environment like repo would with the forall command.
373    try:
374        remote = rh.git.get_upstream_remote()
375        upstream_branch = rh.git.get_upstream_branch()
376    except rh.utils.CalledProcessError as e:
377        output.error('Upstream remote/tracking branch lookup',
378                     f'{e}\nDid you run repo start?  Is your HEAD detached?')
379        return ret._replace(internal_failure=True)
380
381    project = rh.Project(name=project_name, dir=proj_dir)
382    rel_proj_dir = os.path.relpath(proj_dir, rh.git.find_repo_root())
383
384    # Filter out the hooks to process.
385    builtin_hooks = [x for x in builtin_hooks if rel_proj_dir not in x.scope]
386    custom_hooks = [x for x in custom_hooks if rel_proj_dir not in x.scope]
387
388    if not builtin_hooks and not custom_hooks:
389        return ret
390
391    os.environ.update({
392        'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
393        'REPO_PATH': rel_proj_dir,
394        'REPO_PROJECT': project_name,
395        'REPO_REMOTE': remote,
396        'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
397    })
398
399    if not commit_list:
400        commit_list = rh.git.get_commits(
401            ignore_merged_commits=config.ignore_merged_commits)
402    output.set_num_commits(len(commit_list))
403
404    def _run_hook(hook, project, commit, desc, diff):
405        """Run a hook, gather stats, and process its results."""
406        start = datetime.datetime.now()
407        results = hook.hook(project, commit, desc, diff)
408        (error, warning) = _process_hook_results(results)
409        duration = datetime.datetime.now() - start
410        return (hook, results, error, warning, duration)
411
412    with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as executor:
413        for commit in commit_list:
414            # Mix in some settings for our hooks.
415            os.environ['PREUPLOAD_COMMIT'] = commit
416            diff = rh.git.get_affected_files(commit)
417            desc = rh.git.get_commit_desc(commit)
418            os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
419
420            commit_summary = desc.split('\n', 1)[0]
421            output.commit_start(builtin_hooks + custom_hooks, commit, commit_summary)
422
423            def run_hooks(hooks):
424                futures = (
425                    executor.submit(_run_hook, hook, project, commit, desc, diff)
426                    for hook in hooks
427                )
428                future_results = (
429                    x.result() for x in concurrent.futures.as_completed(futures)
430                )
431                for hook, hook_results, error, warning, duration in future_results:
432                    ret.add_results(hook_results)
433                    if error is not None or warning is not None:
434                        if warning is not None:
435                            output.hook_warning(hook, warning)
436                        if error is not None:
437                            output.hook_error(hook, error)
438                            output.hook_fixups(ret, hook_results)
439                    output.hook_finish(hook, duration)
440
441            run_hooks(builtin_hooks)
442            run_hooks(custom_hooks)
443
444    return ret
445
446
447def _run_project_hooks(
448    project_name: str,
449    proj_dir: Optional[str] = None,
450    jobs: Optional[int] = None,
451    from_git: bool = False,
452    commit_list: Optional[List[str]] = None,
453) -> rh.results.ProjectResults:
454    """Run the project-specific hooks in |proj_dir|.
455
456    Args:
457      project_name: The name of project to run hooks for.
458      proj_dir: If non-None, this is the directory the project is in.  If None,
459          we'll ask repo.
460      jobs: How many hooks to run in parallel.
461      from_git: If true, we are called from git directly and repo should not be
462          used.
463      commit_list: A list of commits to run hooks against.  If None or empty
464          list then we'll automatically get the list of commits that would be
465          uploaded.
466
467    Returns:
468      All the results for this project.
469    """
470    output = Output(project_name)
471
472    if proj_dir is None:
473        cmd = ['repo', 'forall', project_name, '-c', 'pwd']
474        result = rh.utils.run(cmd, capture_output=True)
475        proj_dirs = result.stdout.split()
476        if not proj_dirs:
477            print(f'{project_name} cannot be found.', file=sys.stderr)
478            print('Please specify a valid project.', file=sys.stderr)
479            return False
480        if len(proj_dirs) > 1:
481            print(f'{project_name} is associated with multiple directories.',
482                  file=sys.stderr)
483            print('Please specify a directory to help disambiguate.',
484                  file=sys.stderr)
485            return False
486        proj_dir = proj_dirs[0]
487
488    pwd = os.getcwd()
489    try:
490        # Hooks assume they are run from the root of the project.
491        os.chdir(proj_dir)
492        return _run_project_hooks_in_cwd(
493            project_name, proj_dir, output, jobs=jobs, from_git=from_git,
494            commit_list=commit_list)
495    finally:
496        output.finish()
497        os.chdir(pwd)
498
499
500def _run_projects_hooks(
501    project_list: List[str],
502    worktree_list: List[Optional[str]],
503    jobs: Optional[int] = None,
504    from_git: bool = False,
505    commit_list: Optional[List[str]] = None,
506) -> bool:
507    """Run all the hooks
508
509    Args:
510      project_list: List of project names.
511      worktree_list: List of project checkouts.
512      jobs: How many hooks to run in parallel.
513      from_git: If true, we are called from git directly and repo should not be
514          used.
515      commit_list: A list of commits to run hooks against.  If None or empty
516          list then we'll automatically get the list of commits that would be
517          uploaded.
518
519    Returns:
520      True if everything passed, else False.
521    """
522    results = []
523    for project, worktree in zip(project_list, worktree_list):
524        result = _run_project_hooks(
525            project,
526            proj_dir=worktree,
527            jobs=jobs,
528            from_git=from_git,
529            commit_list=commit_list,
530        )
531        results.append(result)
532        if result:
533            # If a repo had failures, add a blank line to help break up the
534            # output.  If there were no failures, then the output should be
535            # very minimal, so we don't add it then.
536            print('', file=sys.stderr)
537
538    _attempt_fixes(results)
539    return not any(results)
540
541
542def main(project_list, worktree_list=None, **_kwargs):
543    """Main function invoked directly by repo.
544
545    We must use the name "main" as that is what repo requires.
546
547    This function will exit directly upon error so that repo doesn't print some
548    obscure error message.
549
550    Args:
551      project_list: List of projects to run on.
552      worktree_list: A list of directories.  It should be the same length as
553          project_list, so that each entry in project_list matches with a
554          directory in worktree_list.  If None, we will attempt to calculate
555          the directories automatically.
556      kwargs: Leave this here for forward-compatibility.
557    """
558    if not worktree_list:
559        worktree_list = [None] * len(project_list)
560    if not _run_projects_hooks(project_list, worktree_list):
561        color = rh.terminal.Color()
562        print(color.color(color.RED, 'FATAL') +
563              ': Preupload failed due to above error(s).\n'
564              f'For more info, see: {REPOHOOKS_URL}',
565              file=sys.stderr)
566        sys.exit(1)
567
568
569def _identify_project(path, from_git=False):
570    """Identify the repo project associated with the given path.
571
572    Returns:
573      A string indicating what project is associated with the path passed in or
574      a blank string upon failure.
575    """
576    if from_git:
577        cmd = ['git', 'rev-parse', '--show-toplevel']
578        project_path = rh.utils.run(cmd, capture_output=True).stdout.strip()
579        cmd = ['git', 'rev-parse', '--show-superproject-working-tree']
580        superproject_path = rh.utils.run(
581            cmd, capture_output=True).stdout.strip()
582        module_path = project_path[len(superproject_path) + 1:]
583        cmd = ['git', 'config', '-f', '.gitmodules',
584               '--name-only', '--get-regexp', r'^submodule\..*\.path$',
585               f"^{module_path}$"]
586        module_name = rh.utils.run(cmd, cwd=superproject_path,
587                                   capture_output=True).stdout.strip()
588        return module_name[len('submodule.'):-len(".path")]
589    else:
590        cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
591        return rh.utils.run(cmd, capture_output=True, cwd=path).stdout.strip()
592
593
594def direct_main(argv):
595    """Run hooks directly (outside of the context of repo).
596
597    Args:
598      argv: The command line args to process.
599
600    Returns:
601      0 if no pre-upload failures, 1 if failures.
602
603    Raises:
604      BadInvocation: On some types of invocation errors.
605    """
606    parser = argparse.ArgumentParser(description=__doc__)
607    parser.add_argument('--git', action='store_true',
608                        help='This hook is called from git instead of repo')
609    parser.add_argument('--dir', default=None,
610                        help='The directory that the project lives in.  If not '
611                        'specified, use the git project root based on the cwd.')
612    parser.add_argument('--project', default=None,
613                        help='The project repo path; this can affect how the '
614                        'hooks get run, since some hooks are project-specific.'
615                        'If not specified, `repo` will be used to figure this '
616                        'out based on the dir.')
617    parser.add_argument('-j', '--jobs', type=int,
618                        help='Run up to this many hooks in parallel. Setting '
619                        'to 1 forces serial execution, and the default '
620                        'automatically chooses an appropriate number for the '
621                        'current system.')
622    parser.add_argument('commits', nargs='*',
623                        help='Check specific commits')
624    opts = parser.parse_args(argv)
625
626    # Check/normalize git dir; if unspecified, we'll use the root of the git
627    # project from CWD.
628    if opts.dir is None:
629        cmd = ['git', 'rev-parse', '--git-dir']
630        git_dir = rh.utils.run(cmd, capture_output=True).stdout.strip()
631        if not git_dir:
632            parser.error('The current directory is not part of a git project.')
633        opts.dir = os.path.dirname(os.path.abspath(git_dir))
634    elif not os.path.isdir(opts.dir):
635        parser.error(f'Invalid dir: {opts.dir}')
636    elif not rh.git.is_git_repository(opts.dir):
637        parser.error(f'Not a git repository: {opts.dir}')
638
639    # Identify the project if it wasn't specified; this _requires_ the repo
640    # tool to be installed and for the project to be part of a repo checkout.
641    if not opts.project:
642        opts.project = _identify_project(opts.dir, opts.git)
643        if not opts.project:
644            parser.error(f"Couldn't identify the project of {opts.dir}")
645
646    try:
647        if _run_projects_hooks([opts.project], [opts.dir], jobs=opts.jobs,
648                               from_git=opts.git, commit_list=opts.commits):
649            return 0
650    except KeyboardInterrupt:
651        print('Aborting execution early due to user interrupt', file=sys.stderr)
652        return 128 + signal.SIGINT
653    return 1
654
655
656if __name__ == '__main__':
657    sys.exit(direct_main(sys.argv[1:]))
658