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