blob: 681a2696e907340070ce2dc7cae37f8f4dd90f56 [file] [log] [blame]
Mike Frysinger2e65c542016-03-08 16:17:00 -05001#!/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
Mike Frysinger2e65c542016-03-08 16:17:00 -050026import 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
Mike Frysinger2ef213c2017-11-10 15:41:56 -050040# 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
Mike Frysingerb9608182016-10-20 20:45:04 -040043import rh
Mike Frysinger2e65c542016-03-08 16:17:00 -050044import 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
Josh Gao25abf4b2016-09-23 18:36:27 -070056class 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')
Jason Monk0886c912017-11-10 13:17:17 -050064 WARNING = COLOR.color(COLOR.YELLOW, 'WARNING')
Josh Gao25abf4b2016-09-23 18:36:27 -070065
Mike Frysinger42234b72019-02-15 16:21:41 -050066 def __init__(self, project_name):
Josh Gao25abf4b2016-09-23 18:36:27 -070067 """Create a new Output object for a specified project.
68
69 Args:
70 project_name: name of project.
Josh Gao25abf4b2016-09-23 18:36:27 -070071 """
72 self.project_name = project_name
Mike Frysinger42234b72019-02-15 16:21:41 -050073 self.num_hooks = None
Josh Gao25abf4b2016-09-23 18:36:27 -070074 self.hook_index = 0
75 self.success = True
76
Mike Frysinger42234b72019-02-15 16:21:41 -050077 def set_num_hooks(self, num_hooks):
78 """Keep track of how many hooks we'll be running.
79
80 Args:
81 num_hooks: number of hooks to be run.
82 """
83 self.num_hooks = num_hooks
84
Josh Gao25abf4b2016-09-23 18:36:27 -070085 def commit_start(self, commit, commit_summary):
86 """Emit status for new commit.
87
88 Args:
89 commit: commit hash.
90 commit_summary: commit summary.
91 """
92 status_line = '[%s %s] %s' % (self.COMMIT, commit[0:12], commit_summary)
93 rh.terminal.print_status_line(status_line, print_newline=True)
94 self.hook_index = 1
95
96 def hook_start(self, hook_name):
97 """Emit status before the start of a hook.
98
99 Args:
100 hook_name: name of the hook.
101 """
102 status_line = '[%s %d/%d] %s' % (self.RUNNING, self.hook_index,
103 self.num_hooks, hook_name)
104 self.hook_index += 1
105 rh.terminal.print_status_line(status_line)
106
107 def hook_error(self, hook_name, error):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500108 """Print an error for a single hook.
Josh Gao25abf4b2016-09-23 18:36:27 -0700109
110 Args:
111 hook_name: name of the hook.
112 error: error string.
113 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500114 self.error(hook_name, error)
Josh Gao25abf4b2016-09-23 18:36:27 -0700115
Jason Monk0886c912017-11-10 13:17:17 -0500116 def hook_warning(self, hook_name, warning):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500117 """Print a warning for a single hook.
Jason Monk0886c912017-11-10 13:17:17 -0500118
119 Args:
120 hook_name: name of the hook.
121 warning: warning string.
122 """
123 status_line = '[%s] %s' % (self.WARNING, hook_name)
124 rh.terminal.print_status_line(status_line, print_newline=True)
125 print(warning, file=sys.stderr)
126
Mike Frysingera18d5f12019-02-15 16:27:35 -0500127 def error(self, header, error):
128 """Print a general error.
129
130 Args:
131 header: A unique identifier for the source of this error.
132 error: error string.
133 """
134 status_line = '[%s] %s' % (self.FAILED, header)
135 rh.terminal.print_status_line(status_line, print_newline=True)
136 print(error, file=sys.stderr)
137 self.success = False
138
Josh Gao25abf4b2016-09-23 18:36:27 -0700139 def finish(self):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500140 """Print summary for all the hooks."""
Josh Gao25abf4b2016-09-23 18:36:27 -0700141 status_line = '[%s] repohooks for %s %s' % (
142 self.PASSED if self.success else self.FAILED,
143 self.project_name,
144 'passed' if self.success else 'failed')
145 rh.terminal.print_status_line(status_line, print_newline=True)
146
147
148def _process_hook_results(results):
149 """Returns an error string if an error occurred.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500150
151 Args:
Josh Gao25abf4b2016-09-23 18:36:27 -0700152 results: A list of HookResult objects, or None.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500153
154 Returns:
Josh Gao25abf4b2016-09-23 18:36:27 -0700155 error output if an error occurred, otherwise None
Jason Monk0886c912017-11-10 13:17:17 -0500156 warning output if an error occurred, otherwise None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500157 """
Josh Gao25abf4b2016-09-23 18:36:27 -0700158 if not results:
Jason Monk0886c912017-11-10 13:17:17 -0500159 return (None, None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500160
Jason Monk0886c912017-11-10 13:17:17 -0500161 error_ret = ''
162 warning_ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500163 for result in results:
164 if result:
Jason Monk0886c912017-11-10 13:17:17 -0500165 ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500166 if result.files:
Josh Gao25abf4b2016-09-23 18:36:27 -0700167 ret += ' FILES: %s' % (result.files,)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500168 lines = result.error.splitlines()
Josh Gao25abf4b2016-09-23 18:36:27 -0700169 ret += '\n'.join(' %s' % (x,) for x in lines)
Jason Monk0886c912017-11-10 13:17:17 -0500170 if result.is_warning():
171 warning_ret += ret
172 else:
173 error_ret += ret
Mike Frysinger2e65c542016-03-08 16:17:00 -0500174
Jason Monk0886c912017-11-10 13:17:17 -0500175 return (error_ret or None, warning_ret or None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500176
177
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700178def _get_project_config():
179 """Returns the configuration for a project.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500180
181 Expects to be called from within the project root.
182 """
Mike Frysingerca797702016-09-03 02:00:55 -0400183 global_paths = (
184 # Load the global config found in the manifest repo.
185 os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'),
186 # Load the global config found in the root of the repo checkout.
187 rh.git.find_repo_root(),
188 )
189 paths = (
190 # Load the config for this git repo.
191 '.',
192 )
Mike Frysinger828a0ee2019-08-05 17:42:04 -0400193 return rh.config.PreUploadConfig(paths=paths, global_paths=global_paths)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500194
195
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800196def _attempt_fixes(fixup_func_list, commit_list):
197 """Attempts to run |fixup_func_list| given |commit_list|."""
198 if len(fixup_func_list) != 1:
199 # Only single fixes will be attempted, since various fixes might
200 # interact with each other.
201 return
202
203 hook_name, commit, fixup_func = fixup_func_list[0]
204
205 if commit != commit_list[0]:
206 # If the commit is not at the top of the stack, git operations might be
207 # needed and might leave the working directory in a tricky state if the
208 # fix is attempted to run automatically (e.g. it might require manual
209 # merge conflict resolution). Refuse to run the fix in those cases.
210 return
211
212 prompt = ('An automatic fix can be attempted for the "%s" hook. '
213 'Do you want to run it?' % hook_name)
214 if not rh.terminal.boolean_prompt(prompt):
215 return
216
217 result = fixup_func()
218 if result:
219 print('Attempt to fix "%s" for commit "%s" failed: %s' %
220 (hook_name, commit, result),
221 file=sys.stderr)
222 else:
223 print('Fix successfully applied. Amend the current commit before '
224 'attempting to upload again.\n', file=sys.stderr)
225
226
Mike Frysinger42234b72019-02-15 16:21:41 -0500227def _run_project_hooks_in_cwd(project_name, proj_dir, output, commit_list=None):
228 """Run the project-specific hooks in the cwd.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500229
230 Args:
Mike Frysinger42234b72019-02-15 16:21:41 -0500231 project_name: The name of this project.
232 proj_dir: The directory for this project (for passing on in metadata).
233 output: Helper for summarizing output/errors to the user.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500234 commit_list: A list of commits to run hooks against. If None or empty
235 list then we'll automatically get the list of commits that would be
236 uploaded.
237
238 Returns:
239 False if any errors were found, else True.
240 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500241 try:
242 config = _get_project_config()
243 except rh.config.ValidationError as e:
244 output.error('Loading config files', str(e))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500245 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500246
247 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700248 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400249 if not hooks:
250 return True
251
Mike Frysinger42234b72019-02-15 16:21:41 -0500252 output.set_num_hooks(len(hooks))
253
Mike Frysinger2e65c542016-03-08 16:17:00 -0500254 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700255 try:
256 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800257 upstream_branch = rh.git.get_upstream_branch()
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700258 except rh.utils.RunCommandError as e:
Mike Frysingera18d5f12019-02-15 16:27:35 -0500259 output.error('Upstream remote/tracking branch lookup',
260 '%s\nDid you run repo start? Is your HEAD detached?' %
261 (e,))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500262 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500263
Mike Frysinger2e65c542016-03-08 16:17:00 -0500264 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800265 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Mike Frysingerfdd1a842019-08-09 13:45:57 -0400266 'REPO_PATH': os.path.relpath(proj_dir, rh.git.find_repo_root()),
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800267 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500268 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800269 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500270 })
271
Mike Frysingerb9608182016-10-20 20:45:04 -0400272 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500273
274 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700275 commit_list = rh.git.get_commits(
276 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500277
Mike Frysinger2e65c542016-03-08 16:17:00 -0500278 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800279 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700280
Mike Frysinger2e65c542016-03-08 16:17:00 -0500281 for commit in commit_list:
282 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400283 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500284 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400285 desc = rh.git.get_commit_desc(commit)
Nicolas Sylvain6f798862016-05-02 18:58:22 -0700286 os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
Mike Frysinger2e65c542016-03-08 16:17:00 -0500287
Josh Gao25abf4b2016-09-23 18:36:27 -0700288 commit_summary = desc.split('\n', 1)[0]
289 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500290
Josh Gao25abf4b2016-09-23 18:36:27 -0700291 for name, hook in hooks:
292 output.hook_start(name)
293 hook_results = hook(project, commit, desc, diff)
Jason Monk0886c912017-11-10 13:17:17 -0500294 (error, warning) = _process_hook_results(hook_results)
295 if error or warning:
296 if warning:
297 output.hook_warning(name, warning)
298 if error:
299 ret = False
300 output.hook_error(name, error)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800301 for result in hook_results:
302 if result.fixup_func:
303 fixup_func_list.append((name, commit,
304 result.fixup_func))
305
306 if fixup_func_list:
307 _attempt_fixes(fixup_func_list, commit_list)
Josh Gao25abf4b2016-09-23 18:36:27 -0700308
Mike Frysinger2e65c542016-03-08 16:17:00 -0500309 return ret
310
311
Mike Frysinger42234b72019-02-15 16:21:41 -0500312def _run_project_hooks(project_name, proj_dir=None, commit_list=None):
313 """Run the project-specific hooks in |proj_dir|.
314
315 Args:
316 project_name: The name of project to run hooks for.
317 proj_dir: If non-None, this is the directory the project is in. If None,
318 we'll ask repo.
319 commit_list: A list of commits to run hooks against. If None or empty
320 list then we'll automatically get the list of commits that would be
321 uploaded.
322
323 Returns:
324 False if any errors were found, else True.
325 """
326 output = Output(project_name)
327
328 if proj_dir is None:
329 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
330 result = rh.utils.run_command(cmd, capture_output=True)
331 proj_dirs = result.output.split()
Mike Frysinger5ac20862019-06-05 22:50:49 -0400332 if not proj_dirs:
Mike Frysinger42234b72019-02-15 16:21:41 -0500333 print('%s cannot be found.' % project_name, file=sys.stderr)
334 print('Please specify a valid project.', file=sys.stderr)
335 return False
336 if len(proj_dirs) > 1:
337 print('%s is associated with multiple directories.' % project_name,
338 file=sys.stderr)
339 print('Please specify a directory to help disambiguate.',
340 file=sys.stderr)
341 return False
342 proj_dir = proj_dirs[0]
343
344 pwd = os.getcwd()
345 try:
346 # Hooks assume they are run from the root of the project.
347 os.chdir(proj_dir)
348 return _run_project_hooks_in_cwd(project_name, proj_dir, output,
349 commit_list=commit_list)
350 finally:
351 output.finish()
352 os.chdir(pwd)
353
354
Mike Frysinger2e65c542016-03-08 16:17:00 -0500355def main(project_list, worktree_list=None, **_kwargs):
356 """Main function invoked directly by repo.
357
358 We must use the name "main" as that is what repo requires.
359
360 This function will exit directly upon error so that repo doesn't print some
361 obscure error message.
362
363 Args:
364 project_list: List of projects to run on.
365 worktree_list: A list of directories. It should be the same length as
366 project_list, so that each entry in project_list matches with a
367 directory in worktree_list. If None, we will attempt to calculate
368 the directories automatically.
369 kwargs: Leave this here for forward-compatibility.
370 """
371 found_error = False
372 if not worktree_list:
373 worktree_list = [None] * len(project_list)
374 for project, worktree in zip(project_list, worktree_list):
375 if not _run_project_hooks(project, proj_dir=worktree):
376 found_error = True
Mike Frysingera18d5f12019-02-15 16:27:35 -0500377 # If a repo had failures, add a blank line to help break up the
378 # output. If there were no failures, then the output should be
379 # very minimal, so we don't add it then.
380 print('', file=sys.stderr)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500381
382 if found_error:
383 color = rh.terminal.Color()
384 print('%s: Preupload failed due to above error(s).\n'
385 'For more info, please see:\n%s' %
386 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
387 file=sys.stderr)
388 sys.exit(1)
389
390
391def _identify_project(path):
392 """Identify the repo project associated with the given path.
393
394 Returns:
395 A string indicating what project is associated with the path passed in or
396 a blank string upon failure.
397 """
398 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
399 return rh.utils.run_command(cmd, capture_output=True, redirect_stderr=True,
400 cwd=path).output.strip()
401
402
403def direct_main(argv):
404 """Run hooks directly (outside of the context of repo).
405
406 Args:
407 argv: The command line args to process.
408
409 Returns:
410 0 if no pre-upload failures, 1 if failures.
411
412 Raises:
413 BadInvocation: On some types of invocation errors.
414 """
415 parser = argparse.ArgumentParser(description=__doc__)
416 parser.add_argument('--dir', default=None,
417 help='The directory that the project lives in. If not '
418 'specified, use the git project root based on the cwd.')
419 parser.add_argument('--project', default=None,
420 help='The project repo path; this can affect how the '
421 'hooks get run, since some hooks are project-specific.'
422 'If not specified, `repo` will be used to figure this '
423 'out based on the dir.')
424 parser.add_argument('commits', nargs='*',
425 help='Check specific commits')
426 opts = parser.parse_args(argv)
427
428 # Check/normalize git dir; if unspecified, we'll use the root of the git
429 # project from CWD.
430 if opts.dir is None:
431 cmd = ['git', 'rev-parse', '--git-dir']
432 git_dir = rh.utils.run_command(cmd, capture_output=True,
433 redirect_stderr=True).output.strip()
434 if not git_dir:
435 parser.error('The current directory is not part of a git project.')
436 opts.dir = os.path.dirname(os.path.abspath(git_dir))
437 elif not os.path.isdir(opts.dir):
438 parser.error('Invalid dir: %s' % opts.dir)
Adrian Roos8ac865f2018-04-13 12:08:52 +0100439 elif not rh.git.is_git_repository(opts.dir):
440 parser.error('Not a git repository: %s' % opts.dir)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500441
442 # Identify the project if it wasn't specified; this _requires_ the repo
443 # tool to be installed and for the project to be part of a repo checkout.
444 if not opts.project:
445 opts.project = _identify_project(opts.dir)
446 if not opts.project:
447 parser.error("Repo couldn't identify the project of %s" % opts.dir)
448
449 if _run_project_hooks(opts.project, proj_dir=opts.dir,
450 commit_list=opts.commits):
451 return 0
Mike Frysinger5ac20862019-06-05 22:50:49 -0400452 return 1
Mike Frysinger2e65c542016-03-08 16:17:00 -0500453
454
455if __name__ == '__main__':
456 sys.exit(direct_main(sys.argv[1:]))