blob: febbfa5861427abd583fddcbd63e70718185298a [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 Frysinger579111e2019-12-04 21:36:01 -050026import datetime
Mike Frysinger2e65c542016-03-08 16:17:00 -050027import os
28import sys
29
Mike Frysinger7b30f4d2019-10-15 01:18:38 -040030
31# Assert some minimum Python versions as we don't test or support any others.
32# We only support Python 2.7, and require 2.7.5+/3.4+ to include signal fix:
33# https://bugs.python.org/issue14173
34if sys.version_info < (2, 7, 5):
35 print('repohooks: error: Python-2.7.5+ is required', file=sys.stderr)
36 sys.exit(1)
37elif sys.version_info.major == 3 and sys.version_info < (3, 4):
38 # We don't actually test <Python-3.6. Hope for the best!
39 print('repohooks: error: Python-3.4+ is required', file=sys.stderr)
40 sys.exit(1)
41
42
Mike Frysinger2e65c542016-03-08 16:17:00 -050043_path = os.path.dirname(os.path.realpath(__file__))
44if sys.path[0] != _path:
45 sys.path.insert(0, _path)
46del _path
47
Mike Frysinger2ef213c2017-11-10 15:41:56 -050048# We have to import our local modules after the sys.path tweak. We can't use
49# relative imports because this is an executable program, not a module.
50# pylint: disable=wrong-import-position
Mike Frysingerb9608182016-10-20 20:45:04 -040051import rh
Mike Frysinger2e65c542016-03-08 16:17:00 -050052import rh.results
53import rh.config
54import rh.git
55import rh.hooks
Mike Frysingerce3ab292019-08-09 17:58:50 -040056import rh.sixish
Mike Frysinger2e65c542016-03-08 16:17:00 -050057import rh.terminal
58import rh.utils
59
60
61# Repohooks homepage.
62REPOHOOKS_URL = 'https://android.googlesource.com/platform/tools/repohooks/'
63
64
Josh Gao25abf4b2016-09-23 18:36:27 -070065class Output(object):
66 """Class for reporting hook status."""
67
68 COLOR = rh.terminal.Color()
69 COMMIT = COLOR.color(COLOR.CYAN, 'COMMIT')
70 RUNNING = COLOR.color(COLOR.YELLOW, 'RUNNING')
71 PASSED = COLOR.color(COLOR.GREEN, 'PASSED')
72 FAILED = COLOR.color(COLOR.RED, 'FAILED')
Jason Monk0886c912017-11-10 13:17:17 -050073 WARNING = COLOR.color(COLOR.YELLOW, 'WARNING')
Josh Gao25abf4b2016-09-23 18:36:27 -070074
Mike Frysinger42234b72019-02-15 16:21:41 -050075 def __init__(self, project_name):
Josh Gao25abf4b2016-09-23 18:36:27 -070076 """Create a new Output object for a specified project.
77
78 Args:
79 project_name: name of project.
Josh Gao25abf4b2016-09-23 18:36:27 -070080 """
81 self.project_name = project_name
Mike Frysinger42234b72019-02-15 16:21:41 -050082 self.num_hooks = None
Josh Gao25abf4b2016-09-23 18:36:27 -070083 self.hook_index = 0
84 self.success = True
Mike Frysinger579111e2019-12-04 21:36:01 -050085 self.start_time = datetime.datetime.now()
Josh Gao25abf4b2016-09-23 18:36:27 -070086
Mike Frysinger42234b72019-02-15 16:21:41 -050087 def set_num_hooks(self, num_hooks):
88 """Keep track of how many hooks we'll be running.
89
90 Args:
91 num_hooks: number of hooks to be run.
92 """
93 self.num_hooks = num_hooks
94
Josh Gao25abf4b2016-09-23 18:36:27 -070095 def commit_start(self, commit, commit_summary):
96 """Emit status for new commit.
97
98 Args:
99 commit: commit hash.
100 commit_summary: commit summary.
101 """
102 status_line = '[%s %s] %s' % (self.COMMIT, commit[0:12], commit_summary)
103 rh.terminal.print_status_line(status_line, print_newline=True)
104 self.hook_index = 1
105
106 def hook_start(self, hook_name):
107 """Emit status before the start of a hook.
108
109 Args:
110 hook_name: name of the hook.
111 """
112 status_line = '[%s %d/%d] %s' % (self.RUNNING, self.hook_index,
113 self.num_hooks, hook_name)
114 self.hook_index += 1
115 rh.terminal.print_status_line(status_line)
116
117 def hook_error(self, hook_name, error):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500118 """Print an error for a single hook.
Josh Gao25abf4b2016-09-23 18:36:27 -0700119
120 Args:
121 hook_name: name of the hook.
122 error: error string.
123 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500124 self.error(hook_name, error)
Josh Gao25abf4b2016-09-23 18:36:27 -0700125
Jason Monk0886c912017-11-10 13:17:17 -0500126 def hook_warning(self, hook_name, warning):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500127 """Print a warning for a single hook.
Jason Monk0886c912017-11-10 13:17:17 -0500128
129 Args:
130 hook_name: name of the hook.
131 warning: warning string.
132 """
133 status_line = '[%s] %s' % (self.WARNING, hook_name)
134 rh.terminal.print_status_line(status_line, print_newline=True)
135 print(warning, file=sys.stderr)
136
Mike Frysingera18d5f12019-02-15 16:27:35 -0500137 def error(self, header, error):
138 """Print a general error.
139
140 Args:
141 header: A unique identifier for the source of this error.
142 error: error string.
143 """
144 status_line = '[%s] %s' % (self.FAILED, header)
145 rh.terminal.print_status_line(status_line, print_newline=True)
146 print(error, file=sys.stderr)
147 self.success = False
148
Josh Gao25abf4b2016-09-23 18:36:27 -0700149 def finish(self):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500150 """Print summary for all the hooks."""
Mike Frysinger579111e2019-12-04 21:36:01 -0500151 status_line = '[%s] repohooks for %s %s in %s' % (
Josh Gao25abf4b2016-09-23 18:36:27 -0700152 self.PASSED if self.success else self.FAILED,
153 self.project_name,
Mike Frysinger579111e2019-12-04 21:36:01 -0500154 'passed' if self.success else 'failed',
155 rh.utils.timedelta_str(datetime.datetime.now() - self.start_time))
Josh Gao25abf4b2016-09-23 18:36:27 -0700156 rh.terminal.print_status_line(status_line, print_newline=True)
157
158
159def _process_hook_results(results):
160 """Returns an error string if an error occurred.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500161
162 Args:
Josh Gao25abf4b2016-09-23 18:36:27 -0700163 results: A list of HookResult objects, or None.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500164
165 Returns:
Josh Gao25abf4b2016-09-23 18:36:27 -0700166 error output if an error occurred, otherwise None
Jason Monk0886c912017-11-10 13:17:17 -0500167 warning output if an error occurred, otherwise None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500168 """
Josh Gao25abf4b2016-09-23 18:36:27 -0700169 if not results:
Jason Monk0886c912017-11-10 13:17:17 -0500170 return (None, None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500171
Jason Monk0886c912017-11-10 13:17:17 -0500172 error_ret = ''
173 warning_ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500174 for result in results:
175 if result:
Jason Monk0886c912017-11-10 13:17:17 -0500176 ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500177 if result.files:
Josh Gao25abf4b2016-09-23 18:36:27 -0700178 ret += ' FILES: %s' % (result.files,)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500179 lines = result.error.splitlines()
Josh Gao25abf4b2016-09-23 18:36:27 -0700180 ret += '\n'.join(' %s' % (x,) for x in lines)
Jason Monk0886c912017-11-10 13:17:17 -0500181 if result.is_warning():
182 warning_ret += ret
183 else:
184 error_ret += ret
Mike Frysinger2e65c542016-03-08 16:17:00 -0500185
Jason Monk0886c912017-11-10 13:17:17 -0500186 return (error_ret or None, warning_ret or None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500187
188
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700189def _get_project_config():
190 """Returns the configuration for a project.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500191
192 Expects to be called from within the project root.
193 """
Mike Frysingerca797702016-09-03 02:00:55 -0400194 global_paths = (
195 # Load the global config found in the manifest repo.
196 os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'),
197 # Load the global config found in the root of the repo checkout.
198 rh.git.find_repo_root(),
199 )
200 paths = (
201 # Load the config for this git repo.
202 '.',
203 )
Mike Frysinger828a0ee2019-08-05 17:42:04 -0400204 return rh.config.PreUploadConfig(paths=paths, global_paths=global_paths)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500205
206
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800207def _attempt_fixes(fixup_func_list, commit_list):
208 """Attempts to run |fixup_func_list| given |commit_list|."""
209 if len(fixup_func_list) != 1:
210 # Only single fixes will be attempted, since various fixes might
211 # interact with each other.
212 return
213
214 hook_name, commit, fixup_func = fixup_func_list[0]
215
216 if commit != commit_list[0]:
217 # If the commit is not at the top of the stack, git operations might be
218 # needed and might leave the working directory in a tricky state if the
219 # fix is attempted to run automatically (e.g. it might require manual
220 # merge conflict resolution). Refuse to run the fix in those cases.
221 return
222
223 prompt = ('An automatic fix can be attempted for the "%s" hook. '
224 'Do you want to run it?' % hook_name)
225 if not rh.terminal.boolean_prompt(prompt):
226 return
227
228 result = fixup_func()
229 if result:
230 print('Attempt to fix "%s" for commit "%s" failed: %s' %
231 (hook_name, commit, result),
232 file=sys.stderr)
233 else:
234 print('Fix successfully applied. Amend the current commit before '
235 'attempting to upload again.\n', file=sys.stderr)
236
237
Mike Frysinger42234b72019-02-15 16:21:41 -0500238def _run_project_hooks_in_cwd(project_name, proj_dir, output, commit_list=None):
239 """Run the project-specific hooks in the cwd.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500240
241 Args:
Mike Frysinger42234b72019-02-15 16:21:41 -0500242 project_name: The name of this project.
243 proj_dir: The directory for this project (for passing on in metadata).
244 output: Helper for summarizing output/errors to the user.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500245 commit_list: A list of commits to run hooks against. If None or empty
246 list then we'll automatically get the list of commits that would be
247 uploaded.
248
249 Returns:
250 False if any errors were found, else True.
251 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500252 try:
253 config = _get_project_config()
254 except rh.config.ValidationError as e:
255 output.error('Loading config files', str(e))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500256 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500257
258 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700259 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400260 if not hooks:
261 return True
262
Mike Frysinger42234b72019-02-15 16:21:41 -0500263 output.set_num_hooks(len(hooks))
264
Mike Frysinger2e65c542016-03-08 16:17:00 -0500265 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700266 try:
267 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800268 upstream_branch = rh.git.get_upstream_branch()
Mike Frysinger36d2ce62019-12-04 22:17:07 -0500269 except rh.utils.CalledProcessError as e:
Mike Frysingera18d5f12019-02-15 16:27:35 -0500270 output.error('Upstream remote/tracking branch lookup',
271 '%s\nDid you run repo start? Is your HEAD detached?' %
272 (e,))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500273 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500274
Mike Frysinger2e65c542016-03-08 16:17:00 -0500275 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800276 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Mike Frysingerfdd1a842019-08-09 13:45:57 -0400277 'REPO_PATH': os.path.relpath(proj_dir, rh.git.find_repo_root()),
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800278 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500279 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800280 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500281 })
282
Mike Frysingerb9608182016-10-20 20:45:04 -0400283 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500284
285 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700286 commit_list = rh.git.get_commits(
287 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500288
Mike Frysinger2e65c542016-03-08 16:17:00 -0500289 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800290 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700291
Mike Frysinger2e65c542016-03-08 16:17:00 -0500292 for commit in commit_list:
293 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400294 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500295 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400296 desc = rh.git.get_commit_desc(commit)
Mike Frysingerce3ab292019-08-09 17:58:50 -0400297 rh.sixish.setenv('PREUPLOAD_COMMIT_MESSAGE', desc)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500298
Josh Gao25abf4b2016-09-23 18:36:27 -0700299 commit_summary = desc.split('\n', 1)[0]
300 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500301
Josh Gao25abf4b2016-09-23 18:36:27 -0700302 for name, hook in hooks:
303 output.hook_start(name)
304 hook_results = hook(project, commit, desc, diff)
Jason Monk0886c912017-11-10 13:17:17 -0500305 (error, warning) = _process_hook_results(hook_results)
306 if error or warning:
307 if warning:
308 output.hook_warning(name, warning)
309 if error:
310 ret = False
311 output.hook_error(name, error)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800312 for result in hook_results:
313 if result.fixup_func:
314 fixup_func_list.append((name, commit,
315 result.fixup_func))
316
317 if fixup_func_list:
318 _attempt_fixes(fixup_func_list, commit_list)
Josh Gao25abf4b2016-09-23 18:36:27 -0700319
Mike Frysinger2e65c542016-03-08 16:17:00 -0500320 return ret
321
322
Mike Frysinger42234b72019-02-15 16:21:41 -0500323def _run_project_hooks(project_name, proj_dir=None, commit_list=None):
324 """Run the project-specific hooks in |proj_dir|.
325
326 Args:
327 project_name: The name of project to run hooks for.
328 proj_dir: If non-None, this is the directory the project is in. If None,
329 we'll ask repo.
330 commit_list: A list of commits to run hooks against. If None or empty
331 list then we'll automatically get the list of commits that would be
332 uploaded.
333
334 Returns:
335 False if any errors were found, else True.
336 """
337 output = Output(project_name)
338
339 if proj_dir is None:
340 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
Mike Frysinger70b78f02019-12-04 21:42:39 -0500341 result = rh.utils.run(cmd, capture_output=True)
Mike Frysinger36d2ce62019-12-04 22:17:07 -0500342 proj_dirs = result.stdout.split()
Mike Frysinger5ac20862019-06-05 22:50:49 -0400343 if not proj_dirs:
Mike Frysinger42234b72019-02-15 16:21:41 -0500344 print('%s cannot be found.' % project_name, file=sys.stderr)
345 print('Please specify a valid project.', file=sys.stderr)
346 return False
347 if len(proj_dirs) > 1:
348 print('%s is associated with multiple directories.' % project_name,
349 file=sys.stderr)
350 print('Please specify a directory to help disambiguate.',
351 file=sys.stderr)
352 return False
353 proj_dir = proj_dirs[0]
354
355 pwd = os.getcwd()
356 try:
357 # Hooks assume they are run from the root of the project.
358 os.chdir(proj_dir)
359 return _run_project_hooks_in_cwd(project_name, proj_dir, output,
360 commit_list=commit_list)
361 finally:
362 output.finish()
363 os.chdir(pwd)
364
365
Mike Frysinger2e65c542016-03-08 16:17:00 -0500366def main(project_list, worktree_list=None, **_kwargs):
367 """Main function invoked directly by repo.
368
369 We must use the name "main" as that is what repo requires.
370
371 This function will exit directly upon error so that repo doesn't print some
372 obscure error message.
373
374 Args:
375 project_list: List of projects to run on.
376 worktree_list: A list of directories. It should be the same length as
377 project_list, so that each entry in project_list matches with a
378 directory in worktree_list. If None, we will attempt to calculate
379 the directories automatically.
380 kwargs: Leave this here for forward-compatibility.
381 """
382 found_error = False
383 if not worktree_list:
384 worktree_list = [None] * len(project_list)
385 for project, worktree in zip(project_list, worktree_list):
386 if not _run_project_hooks(project, proj_dir=worktree):
387 found_error = True
Mike Frysingera18d5f12019-02-15 16:27:35 -0500388 # If a repo had failures, add a blank line to help break up the
389 # output. If there were no failures, then the output should be
390 # very minimal, so we don't add it then.
391 print('', file=sys.stderr)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500392
393 if found_error:
394 color = rh.terminal.Color()
395 print('%s: Preupload failed due to above error(s).\n'
396 'For more info, please see:\n%s' %
397 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
398 file=sys.stderr)
399 sys.exit(1)
400
401
402def _identify_project(path):
403 """Identify the repo project associated with the given path.
404
405 Returns:
406 A string indicating what project is associated with the path passed in or
407 a blank string upon failure.
408 """
409 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
Mike Frysinger70b78f02019-12-04 21:42:39 -0500410 return rh.utils.run(cmd, capture_output=True, redirect_stderr=True,
Mike Frysinger36d2ce62019-12-04 22:17:07 -0500411 cwd=path).stdout.strip()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500412
413
414def direct_main(argv):
415 """Run hooks directly (outside of the context of repo).
416
417 Args:
418 argv: The command line args to process.
419
420 Returns:
421 0 if no pre-upload failures, 1 if failures.
422
423 Raises:
424 BadInvocation: On some types of invocation errors.
425 """
426 parser = argparse.ArgumentParser(description=__doc__)
427 parser.add_argument('--dir', default=None,
428 help='The directory that the project lives in. If not '
429 'specified, use the git project root based on the cwd.')
430 parser.add_argument('--project', default=None,
431 help='The project repo path; this can affect how the '
432 'hooks get run, since some hooks are project-specific.'
433 'If not specified, `repo` will be used to figure this '
434 'out based on the dir.')
435 parser.add_argument('commits', nargs='*',
436 help='Check specific commits')
437 opts = parser.parse_args(argv)
438
439 # Check/normalize git dir; if unspecified, we'll use the root of the git
440 # project from CWD.
441 if opts.dir is None:
442 cmd = ['git', 'rev-parse', '--git-dir']
Mike Frysinger70b78f02019-12-04 21:42:39 -0500443 git_dir = rh.utils.run(cmd, capture_output=True,
Mike Frysinger36d2ce62019-12-04 22:17:07 -0500444 redirect_stderr=True).stdout.strip()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500445 if not git_dir:
446 parser.error('The current directory is not part of a git project.')
447 opts.dir = os.path.dirname(os.path.abspath(git_dir))
448 elif not os.path.isdir(opts.dir):
449 parser.error('Invalid dir: %s' % opts.dir)
Adrian Roos8ac865f2018-04-13 12:08:52 +0100450 elif not rh.git.is_git_repository(opts.dir):
451 parser.error('Not a git repository: %s' % opts.dir)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500452
453 # Identify the project if it wasn't specified; this _requires_ the repo
454 # tool to be installed and for the project to be part of a repo checkout.
455 if not opts.project:
456 opts.project = _identify_project(opts.dir)
457 if not opts.project:
458 parser.error("Repo couldn't identify the project of %s" % opts.dir)
459
460 if _run_project_hooks(opts.project, proj_dir=opts.dir,
461 commit_list=opts.commits):
462 return 0
Mike Frysinger5ac20862019-06-05 22:50:49 -0400463 return 1
Mike Frysinger2e65c542016-03-08 16:17:00 -0500464
465
466if __name__ == '__main__':
467 sys.exit(direct_main(sys.argv[1:]))