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