blob: 0862dea366284804e51363cc3e53decd53afcf2d [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
Mike Frysinger7b30f4d2019-10-15 01:18:38 -040029
30# Assert some minimum Python versions as we don't test or support any others.
31# We only support Python 2.7, and require 2.7.5+/3.4+ to include signal fix:
32# https://bugs.python.org/issue14173
33if sys.version_info < (2, 7, 5):
34 print('repohooks: error: Python-2.7.5+ is required', file=sys.stderr)
35 sys.exit(1)
36elif sys.version_info.major == 3 and sys.version_info < (3, 4):
37 # We don't actually test <Python-3.6. Hope for the best!
38 print('repohooks: error: Python-3.4+ is required', file=sys.stderr)
39 sys.exit(1)
40
41
Mike Frysinger2e65c542016-03-08 16:17:00 -050042_path = os.path.dirname(os.path.realpath(__file__))
43if sys.path[0] != _path:
44 sys.path.insert(0, _path)
45del _path
46
Mike Frysinger2ef213c2017-11-10 15:41:56 -050047# We have to import our local modules after the sys.path tweak. We can't use
48# relative imports because this is an executable program, not a module.
49# pylint: disable=wrong-import-position
Mike Frysingerb9608182016-10-20 20:45:04 -040050import rh
Mike Frysinger2e65c542016-03-08 16:17:00 -050051import rh.results
52import rh.config
53import rh.git
54import rh.hooks
Mike Frysingerce3ab292019-08-09 17:58:50 -040055import rh.sixish
Mike Frysinger2e65c542016-03-08 16:17:00 -050056import rh.terminal
57import rh.utils
58
59
60# Repohooks homepage.
61REPOHOOKS_URL = 'https://android.googlesource.com/platform/tools/repohooks/'
62
63
Josh Gao25abf4b2016-09-23 18:36:27 -070064class Output(object):
65 """Class for reporting hook status."""
66
67 COLOR = rh.terminal.Color()
68 COMMIT = COLOR.color(COLOR.CYAN, 'COMMIT')
69 RUNNING = COLOR.color(COLOR.YELLOW, 'RUNNING')
70 PASSED = COLOR.color(COLOR.GREEN, 'PASSED')
71 FAILED = COLOR.color(COLOR.RED, 'FAILED')
Jason Monk0886c912017-11-10 13:17:17 -050072 WARNING = COLOR.color(COLOR.YELLOW, 'WARNING')
Josh Gao25abf4b2016-09-23 18:36:27 -070073
Mike Frysinger42234b72019-02-15 16:21:41 -050074 def __init__(self, project_name):
Josh Gao25abf4b2016-09-23 18:36:27 -070075 """Create a new Output object for a specified project.
76
77 Args:
78 project_name: name of project.
Josh Gao25abf4b2016-09-23 18:36:27 -070079 """
80 self.project_name = project_name
Mike Frysinger42234b72019-02-15 16:21:41 -050081 self.num_hooks = None
Josh Gao25abf4b2016-09-23 18:36:27 -070082 self.hook_index = 0
83 self.success = True
84
Mike Frysinger42234b72019-02-15 16:21:41 -050085 def set_num_hooks(self, num_hooks):
86 """Keep track of how many hooks we'll be running.
87
88 Args:
89 num_hooks: number of hooks to be run.
90 """
91 self.num_hooks = num_hooks
92
Josh Gao25abf4b2016-09-23 18:36:27 -070093 def commit_start(self, commit, commit_summary):
94 """Emit status for new commit.
95
96 Args:
97 commit: commit hash.
98 commit_summary: commit summary.
99 """
100 status_line = '[%s %s] %s' % (self.COMMIT, commit[0:12], commit_summary)
101 rh.terminal.print_status_line(status_line, print_newline=True)
102 self.hook_index = 1
103
104 def hook_start(self, hook_name):
105 """Emit status before the start of a hook.
106
107 Args:
108 hook_name: name of the hook.
109 """
110 status_line = '[%s %d/%d] %s' % (self.RUNNING, self.hook_index,
111 self.num_hooks, hook_name)
112 self.hook_index += 1
113 rh.terminal.print_status_line(status_line)
114
115 def hook_error(self, hook_name, error):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500116 """Print an error for a single hook.
Josh Gao25abf4b2016-09-23 18:36:27 -0700117
118 Args:
119 hook_name: name of the hook.
120 error: error string.
121 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500122 self.error(hook_name, error)
Josh Gao25abf4b2016-09-23 18:36:27 -0700123
Jason Monk0886c912017-11-10 13:17:17 -0500124 def hook_warning(self, hook_name, warning):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500125 """Print a warning for a single hook.
Jason Monk0886c912017-11-10 13:17:17 -0500126
127 Args:
128 hook_name: name of the hook.
129 warning: warning string.
130 """
131 status_line = '[%s] %s' % (self.WARNING, hook_name)
132 rh.terminal.print_status_line(status_line, print_newline=True)
133 print(warning, file=sys.stderr)
134
Mike Frysingera18d5f12019-02-15 16:27:35 -0500135 def error(self, header, error):
136 """Print a general error.
137
138 Args:
139 header: A unique identifier for the source of this error.
140 error: error string.
141 """
142 status_line = '[%s] %s' % (self.FAILED, header)
143 rh.terminal.print_status_line(status_line, print_newline=True)
144 print(error, file=sys.stderr)
145 self.success = False
146
Josh Gao25abf4b2016-09-23 18:36:27 -0700147 def finish(self):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500148 """Print summary for all the hooks."""
Josh Gao25abf4b2016-09-23 18:36:27 -0700149 status_line = '[%s] repohooks for %s %s' % (
150 self.PASSED if self.success else self.FAILED,
151 self.project_name,
152 'passed' if self.success else 'failed')
153 rh.terminal.print_status_line(status_line, print_newline=True)
154
155
156def _process_hook_results(results):
157 """Returns an error string if an error occurred.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500158
159 Args:
Josh Gao25abf4b2016-09-23 18:36:27 -0700160 results: A list of HookResult objects, or None.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500161
162 Returns:
Josh Gao25abf4b2016-09-23 18:36:27 -0700163 error output if an error occurred, otherwise None
Jason Monk0886c912017-11-10 13:17:17 -0500164 warning output if an error occurred, otherwise None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500165 """
Josh Gao25abf4b2016-09-23 18:36:27 -0700166 if not results:
Jason Monk0886c912017-11-10 13:17:17 -0500167 return (None, None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500168
Jason Monk0886c912017-11-10 13:17:17 -0500169 error_ret = ''
170 warning_ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500171 for result in results:
172 if result:
Jason Monk0886c912017-11-10 13:17:17 -0500173 ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500174 if result.files:
Josh Gao25abf4b2016-09-23 18:36:27 -0700175 ret += ' FILES: %s' % (result.files,)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500176 lines = result.error.splitlines()
Josh Gao25abf4b2016-09-23 18:36:27 -0700177 ret += '\n'.join(' %s' % (x,) for x in lines)
Jason Monk0886c912017-11-10 13:17:17 -0500178 if result.is_warning():
179 warning_ret += ret
180 else:
181 error_ret += ret
Mike Frysinger2e65c542016-03-08 16:17:00 -0500182
Jason Monk0886c912017-11-10 13:17:17 -0500183 return (error_ret or None, warning_ret or None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500184
185
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700186def _get_project_config():
187 """Returns the configuration for a project.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500188
189 Expects to be called from within the project root.
190 """
Mike Frysingerca797702016-09-03 02:00:55 -0400191 global_paths = (
192 # Load the global config found in the manifest repo.
193 os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'),
194 # Load the global config found in the root of the repo checkout.
195 rh.git.find_repo_root(),
196 )
197 paths = (
198 # Load the config for this git repo.
199 '.',
200 )
Mike Frysinger828a0ee2019-08-05 17:42:04 -0400201 return rh.config.PreUploadConfig(paths=paths, global_paths=global_paths)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500202
203
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800204def _attempt_fixes(fixup_func_list, commit_list):
205 """Attempts to run |fixup_func_list| given |commit_list|."""
206 if len(fixup_func_list) != 1:
207 # Only single fixes will be attempted, since various fixes might
208 # interact with each other.
209 return
210
211 hook_name, commit, fixup_func = fixup_func_list[0]
212
213 if commit != commit_list[0]:
214 # If the commit is not at the top of the stack, git operations might be
215 # needed and might leave the working directory in a tricky state if the
216 # fix is attempted to run automatically (e.g. it might require manual
217 # merge conflict resolution). Refuse to run the fix in those cases.
218 return
219
220 prompt = ('An automatic fix can be attempted for the "%s" hook. '
221 'Do you want to run it?' % hook_name)
222 if not rh.terminal.boolean_prompt(prompt):
223 return
224
225 result = fixup_func()
226 if result:
227 print('Attempt to fix "%s" for commit "%s" failed: %s' %
228 (hook_name, commit, result),
229 file=sys.stderr)
230 else:
231 print('Fix successfully applied. Amend the current commit before '
232 'attempting to upload again.\n', file=sys.stderr)
233
234
Mike Frysinger42234b72019-02-15 16:21:41 -0500235def _run_project_hooks_in_cwd(project_name, proj_dir, output, commit_list=None):
236 """Run the project-specific hooks in the cwd.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500237
238 Args:
Mike Frysinger42234b72019-02-15 16:21:41 -0500239 project_name: The name of this project.
240 proj_dir: The directory for this project (for passing on in metadata).
241 output: Helper for summarizing output/errors to the user.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500242 commit_list: A list of commits to run hooks against. If None or empty
243 list then we'll automatically get the list of commits that would be
244 uploaded.
245
246 Returns:
247 False if any errors were found, else True.
248 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500249 try:
250 config = _get_project_config()
251 except rh.config.ValidationError as e:
252 output.error('Loading config files', str(e))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500253 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500254
255 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700256 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400257 if not hooks:
258 return True
259
Mike Frysinger42234b72019-02-15 16:21:41 -0500260 output.set_num_hooks(len(hooks))
261
Mike Frysinger2e65c542016-03-08 16:17:00 -0500262 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700263 try:
264 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800265 upstream_branch = rh.git.get_upstream_branch()
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700266 except rh.utils.RunCommandError as e:
Mike Frysingera18d5f12019-02-15 16:27:35 -0500267 output.error('Upstream remote/tracking branch lookup',
268 '%s\nDid you run repo start? Is your HEAD detached?' %
269 (e,))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500270 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500271
Mike Frysinger2e65c542016-03-08 16:17:00 -0500272 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800273 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Mike Frysingerfdd1a842019-08-09 13:45:57 -0400274 'REPO_PATH': os.path.relpath(proj_dir, rh.git.find_repo_root()),
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800275 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500276 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800277 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500278 })
279
Mike Frysingerb9608182016-10-20 20:45:04 -0400280 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500281
282 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700283 commit_list = rh.git.get_commits(
284 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500285
Mike Frysinger2e65c542016-03-08 16:17:00 -0500286 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800287 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700288
Mike Frysinger2e65c542016-03-08 16:17:00 -0500289 for commit in commit_list:
290 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400291 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500292 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400293 desc = rh.git.get_commit_desc(commit)
Mike Frysingerce3ab292019-08-09 17:58:50 -0400294 rh.sixish.setenv('PREUPLOAD_COMMIT_MESSAGE', desc)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500295
Josh Gao25abf4b2016-09-23 18:36:27 -0700296 commit_summary = desc.split('\n', 1)[0]
297 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500298
Josh Gao25abf4b2016-09-23 18:36:27 -0700299 for name, hook in hooks:
300 output.hook_start(name)
301 hook_results = hook(project, commit, desc, diff)
Jason Monk0886c912017-11-10 13:17:17 -0500302 (error, warning) = _process_hook_results(hook_results)
303 if error or warning:
304 if warning:
305 output.hook_warning(name, warning)
306 if error:
307 ret = False
308 output.hook_error(name, error)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800309 for result in hook_results:
310 if result.fixup_func:
311 fixup_func_list.append((name, commit,
312 result.fixup_func))
313
314 if fixup_func_list:
315 _attempt_fixes(fixup_func_list, commit_list)
Josh Gao25abf4b2016-09-23 18:36:27 -0700316
Mike Frysinger2e65c542016-03-08 16:17:00 -0500317 return ret
318
319
Mike Frysinger42234b72019-02-15 16:21:41 -0500320def _run_project_hooks(project_name, proj_dir=None, commit_list=None):
321 """Run the project-specific hooks in |proj_dir|.
322
323 Args:
324 project_name: The name of project to run hooks for.
325 proj_dir: If non-None, this is the directory the project is in. If None,
326 we'll ask repo.
327 commit_list: A list of commits to run hooks against. If None or empty
328 list then we'll automatically get the list of commits that would be
329 uploaded.
330
331 Returns:
332 False if any errors were found, else True.
333 """
334 output = Output(project_name)
335
336 if proj_dir is None:
337 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
338 result = rh.utils.run_command(cmd, capture_output=True)
339 proj_dirs = result.output.split()
Mike Frysinger5ac20862019-06-05 22:50:49 -0400340 if not proj_dirs:
Mike Frysinger42234b72019-02-15 16:21:41 -0500341 print('%s cannot be found.' % project_name, file=sys.stderr)
342 print('Please specify a valid project.', file=sys.stderr)
343 return False
344 if len(proj_dirs) > 1:
345 print('%s is associated with multiple directories.' % project_name,
346 file=sys.stderr)
347 print('Please specify a directory to help disambiguate.',
348 file=sys.stderr)
349 return False
350 proj_dir = proj_dirs[0]
351
352 pwd = os.getcwd()
353 try:
354 # Hooks assume they are run from the root of the project.
355 os.chdir(proj_dir)
356 return _run_project_hooks_in_cwd(project_name, proj_dir, output,
357 commit_list=commit_list)
358 finally:
359 output.finish()
360 os.chdir(pwd)
361
362
Mike Frysinger2e65c542016-03-08 16:17:00 -0500363def main(project_list, worktree_list=None, **_kwargs):
364 """Main function invoked directly by repo.
365
366 We must use the name "main" as that is what repo requires.
367
368 This function will exit directly upon error so that repo doesn't print some
369 obscure error message.
370
371 Args:
372 project_list: List of projects to run on.
373 worktree_list: A list of directories. It should be the same length as
374 project_list, so that each entry in project_list matches with a
375 directory in worktree_list. If None, we will attempt to calculate
376 the directories automatically.
377 kwargs: Leave this here for forward-compatibility.
378 """
379 found_error = False
380 if not worktree_list:
381 worktree_list = [None] * len(project_list)
382 for project, worktree in zip(project_list, worktree_list):
383 if not _run_project_hooks(project, proj_dir=worktree):
384 found_error = True
Mike Frysingera18d5f12019-02-15 16:27:35 -0500385 # If a repo had failures, add a blank line to help break up the
386 # output. If there were no failures, then the output should be
387 # very minimal, so we don't add it then.
388 print('', file=sys.stderr)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500389
390 if found_error:
391 color = rh.terminal.Color()
392 print('%s: Preupload failed due to above error(s).\n'
393 'For more info, please see:\n%s' %
394 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
395 file=sys.stderr)
396 sys.exit(1)
397
398
399def _identify_project(path):
400 """Identify the repo project associated with the given path.
401
402 Returns:
403 A string indicating what project is associated with the path passed in or
404 a blank string upon failure.
405 """
406 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
407 return rh.utils.run_command(cmd, capture_output=True, redirect_stderr=True,
408 cwd=path).output.strip()
409
410
411def direct_main(argv):
412 """Run hooks directly (outside of the context of repo).
413
414 Args:
415 argv: The command line args to process.
416
417 Returns:
418 0 if no pre-upload failures, 1 if failures.
419
420 Raises:
421 BadInvocation: On some types of invocation errors.
422 """
423 parser = argparse.ArgumentParser(description=__doc__)
424 parser.add_argument('--dir', default=None,
425 help='The directory that the project lives in. If not '
426 'specified, use the git project root based on the cwd.')
427 parser.add_argument('--project', default=None,
428 help='The project repo path; this can affect how the '
429 'hooks get run, since some hooks are project-specific.'
430 'If not specified, `repo` will be used to figure this '
431 'out based on the dir.')
432 parser.add_argument('commits', nargs='*',
433 help='Check specific commits')
434 opts = parser.parse_args(argv)
435
436 # Check/normalize git dir; if unspecified, we'll use the root of the git
437 # project from CWD.
438 if opts.dir is None:
439 cmd = ['git', 'rev-parse', '--git-dir']
440 git_dir = rh.utils.run_command(cmd, capture_output=True,
441 redirect_stderr=True).output.strip()
442 if not git_dir:
443 parser.error('The current directory is not part of a git project.')
444 opts.dir = os.path.dirname(os.path.abspath(git_dir))
445 elif not os.path.isdir(opts.dir):
446 parser.error('Invalid dir: %s' % opts.dir)
Adrian Roos8ac865f2018-04-13 12:08:52 +0100447 elif not rh.git.is_git_repository(opts.dir):
448 parser.error('Not a git repository: %s' % opts.dir)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500449
450 # Identify the project if it wasn't specified; this _requires_ the repo
451 # tool to be installed and for the project to be part of a repo checkout.
452 if not opts.project:
453 opts.project = _identify_project(opts.dir)
454 if not opts.project:
455 parser.error("Repo couldn't identify the project of %s" % opts.dir)
456
457 if _run_project_hooks(opts.project, proj_dir=opts.dir,
458 commit_list=opts.commits):
459 return 0
Mike Frysinger5ac20862019-06-05 22:50:49 -0400460 return 1
Mike Frysinger2e65c542016-03-08 16:17:00 -0500461
462
463if __name__ == '__main__':
464 sys.exit(direct_main(sys.argv[1:]))