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