blob: 66ac88b11bf0c59b2acc7e021f2dc6aeb16e10ea [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
Mike Frysingerce3ab292019-08-09 17:58:50 -040048import rh.sixish
Mike Frysinger2e65c542016-03-08 16:17:00 -050049import rh.terminal
50import rh.utils
51
52
53# Repohooks homepage.
54REPOHOOKS_URL = 'https://android.googlesource.com/platform/tools/repohooks/'
55
56
Josh Gao25abf4b2016-09-23 18:36:27 -070057class Output(object):
58 """Class for reporting hook status."""
59
60 COLOR = rh.terminal.Color()
61 COMMIT = COLOR.color(COLOR.CYAN, 'COMMIT')
62 RUNNING = COLOR.color(COLOR.YELLOW, 'RUNNING')
63 PASSED = COLOR.color(COLOR.GREEN, 'PASSED')
64 FAILED = COLOR.color(COLOR.RED, 'FAILED')
Jason Monk0886c912017-11-10 13:17:17 -050065 WARNING = COLOR.color(COLOR.YELLOW, 'WARNING')
Josh Gao25abf4b2016-09-23 18:36:27 -070066
Mike Frysinger42234b72019-02-15 16:21:41 -050067 def __init__(self, project_name):
Josh Gao25abf4b2016-09-23 18:36:27 -070068 """Create a new Output object for a specified project.
69
70 Args:
71 project_name: name of project.
Josh Gao25abf4b2016-09-23 18:36:27 -070072 """
73 self.project_name = project_name
Mike Frysinger42234b72019-02-15 16:21:41 -050074 self.num_hooks = None
Josh Gao25abf4b2016-09-23 18:36:27 -070075 self.hook_index = 0
76 self.success = True
77
Mike Frysinger42234b72019-02-15 16:21:41 -050078 def set_num_hooks(self, num_hooks):
79 """Keep track of how many hooks we'll be running.
80
81 Args:
82 num_hooks: number of hooks to be run.
83 """
84 self.num_hooks = num_hooks
85
Josh Gao25abf4b2016-09-23 18:36:27 -070086 def commit_start(self, commit, commit_summary):
87 """Emit status for new commit.
88
89 Args:
90 commit: commit hash.
91 commit_summary: commit summary.
92 """
93 status_line = '[%s %s] %s' % (self.COMMIT, commit[0:12], commit_summary)
94 rh.terminal.print_status_line(status_line, print_newline=True)
95 self.hook_index = 1
96
97 def hook_start(self, hook_name):
98 """Emit status before the start of a hook.
99
100 Args:
101 hook_name: name of the hook.
102 """
103 status_line = '[%s %d/%d] %s' % (self.RUNNING, self.hook_index,
104 self.num_hooks, hook_name)
105 self.hook_index += 1
106 rh.terminal.print_status_line(status_line)
107
108 def hook_error(self, hook_name, error):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500109 """Print an error for a single hook.
Josh Gao25abf4b2016-09-23 18:36:27 -0700110
111 Args:
112 hook_name: name of the hook.
113 error: error string.
114 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500115 self.error(hook_name, error)
Josh Gao25abf4b2016-09-23 18:36:27 -0700116
Jason Monk0886c912017-11-10 13:17:17 -0500117 def hook_warning(self, hook_name, warning):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500118 """Print a warning for a single hook.
Jason Monk0886c912017-11-10 13:17:17 -0500119
120 Args:
121 hook_name: name of the hook.
122 warning: warning string.
123 """
124 status_line = '[%s] %s' % (self.WARNING, hook_name)
125 rh.terminal.print_status_line(status_line, print_newline=True)
126 print(warning, file=sys.stderr)
127
Mike Frysingera18d5f12019-02-15 16:27:35 -0500128 def error(self, header, error):
129 """Print a general error.
130
131 Args:
132 header: A unique identifier for the source of this error.
133 error: error string.
134 """
135 status_line = '[%s] %s' % (self.FAILED, header)
136 rh.terminal.print_status_line(status_line, print_newline=True)
137 print(error, file=sys.stderr)
138 self.success = False
139
Josh Gao25abf4b2016-09-23 18:36:27 -0700140 def finish(self):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500141 """Print summary for all the hooks."""
Josh Gao25abf4b2016-09-23 18:36:27 -0700142 status_line = '[%s] repohooks for %s %s' % (
143 self.PASSED if self.success else self.FAILED,
144 self.project_name,
145 'passed' if self.success else 'failed')
146 rh.terminal.print_status_line(status_line, print_newline=True)
147
148
149def _process_hook_results(results):
150 """Returns an error string if an error occurred.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500151
152 Args:
Josh Gao25abf4b2016-09-23 18:36:27 -0700153 results: A list of HookResult objects, or None.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500154
155 Returns:
Josh Gao25abf4b2016-09-23 18:36:27 -0700156 error output if an error occurred, otherwise None
Jason Monk0886c912017-11-10 13:17:17 -0500157 warning output if an error occurred, otherwise None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500158 """
Josh Gao25abf4b2016-09-23 18:36:27 -0700159 if not results:
Jason Monk0886c912017-11-10 13:17:17 -0500160 return (None, None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500161
Jason Monk0886c912017-11-10 13:17:17 -0500162 error_ret = ''
163 warning_ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500164 for result in results:
165 if result:
Jason Monk0886c912017-11-10 13:17:17 -0500166 ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500167 if result.files:
Josh Gao25abf4b2016-09-23 18:36:27 -0700168 ret += ' FILES: %s' % (result.files,)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500169 lines = result.error.splitlines()
Josh Gao25abf4b2016-09-23 18:36:27 -0700170 ret += '\n'.join(' %s' % (x,) for x in lines)
Jason Monk0886c912017-11-10 13:17:17 -0500171 if result.is_warning():
172 warning_ret += ret
173 else:
174 error_ret += ret
Mike Frysinger2e65c542016-03-08 16:17:00 -0500175
Jason Monk0886c912017-11-10 13:17:17 -0500176 return (error_ret or None, warning_ret or None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500177
178
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700179def _get_project_config():
180 """Returns the configuration for a project.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500181
182 Expects to be called from within the project root.
183 """
Mike Frysingerca797702016-09-03 02:00:55 -0400184 global_paths = (
185 # Load the global config found in the manifest repo.
186 os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'),
187 # Load the global config found in the root of the repo checkout.
188 rh.git.find_repo_root(),
189 )
190 paths = (
191 # Load the config for this git repo.
192 '.',
193 )
Mike Frysinger828a0ee2019-08-05 17:42:04 -0400194 return rh.config.PreUploadConfig(paths=paths, global_paths=global_paths)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500195
196
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800197def _attempt_fixes(fixup_func_list, commit_list):
198 """Attempts to run |fixup_func_list| given |commit_list|."""
199 if len(fixup_func_list) != 1:
200 # Only single fixes will be attempted, since various fixes might
201 # interact with each other.
202 return
203
204 hook_name, commit, fixup_func = fixup_func_list[0]
205
206 if commit != commit_list[0]:
207 # If the commit is not at the top of the stack, git operations might be
208 # needed and might leave the working directory in a tricky state if the
209 # fix is attempted to run automatically (e.g. it might require manual
210 # merge conflict resolution). Refuse to run the fix in those cases.
211 return
212
213 prompt = ('An automatic fix can be attempted for the "%s" hook. '
214 'Do you want to run it?' % hook_name)
215 if not rh.terminal.boolean_prompt(prompt):
216 return
217
218 result = fixup_func()
219 if result:
220 print('Attempt to fix "%s" for commit "%s" failed: %s' %
221 (hook_name, commit, result),
222 file=sys.stderr)
223 else:
224 print('Fix successfully applied. Amend the current commit before '
225 'attempting to upload again.\n', file=sys.stderr)
226
227
Mike Frysinger42234b72019-02-15 16:21:41 -0500228def _run_project_hooks_in_cwd(project_name, proj_dir, output, commit_list=None):
229 """Run the project-specific hooks in the cwd.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500230
231 Args:
Mike Frysinger42234b72019-02-15 16:21:41 -0500232 project_name: The name of this project.
233 proj_dir: The directory for this project (for passing on in metadata).
234 output: Helper for summarizing output/errors to the user.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500235 commit_list: A list of commits to run hooks against. If None or empty
236 list then we'll automatically get the list of commits that would be
237 uploaded.
238
239 Returns:
240 False if any errors were found, else True.
241 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500242 try:
243 config = _get_project_config()
244 except rh.config.ValidationError as e:
245 output.error('Loading config files', str(e))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500246 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500247
248 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700249 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400250 if not hooks:
251 return True
252
Mike Frysinger42234b72019-02-15 16:21:41 -0500253 output.set_num_hooks(len(hooks))
254
Mike Frysinger2e65c542016-03-08 16:17:00 -0500255 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700256 try:
257 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800258 upstream_branch = rh.git.get_upstream_branch()
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700259 except rh.utils.RunCommandError as e:
Mike Frysingera18d5f12019-02-15 16:27:35 -0500260 output.error('Upstream remote/tracking branch lookup',
261 '%s\nDid you run repo start? Is your HEAD detached?' %
262 (e,))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500263 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500264
Mike Frysinger2e65c542016-03-08 16:17:00 -0500265 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800266 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Mike Frysingerfdd1a842019-08-09 13:45:57 -0400267 'REPO_PATH': os.path.relpath(proj_dir, rh.git.find_repo_root()),
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800268 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500269 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800270 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500271 })
272
Mike Frysingerb9608182016-10-20 20:45:04 -0400273 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500274
275 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700276 commit_list = rh.git.get_commits(
277 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500278
Mike Frysinger2e65c542016-03-08 16:17:00 -0500279 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800280 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700281
Mike Frysinger2e65c542016-03-08 16:17:00 -0500282 for commit in commit_list:
283 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400284 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500285 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400286 desc = rh.git.get_commit_desc(commit)
Mike Frysingerce3ab292019-08-09 17:58:50 -0400287 rh.sixish.setenv('PREUPLOAD_COMMIT_MESSAGE', desc)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500288
Josh Gao25abf4b2016-09-23 18:36:27 -0700289 commit_summary = desc.split('\n', 1)[0]
290 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500291
Josh Gao25abf4b2016-09-23 18:36:27 -0700292 for name, hook in hooks:
293 output.hook_start(name)
294 hook_results = hook(project, commit, desc, diff)
Jason Monk0886c912017-11-10 13:17:17 -0500295 (error, warning) = _process_hook_results(hook_results)
296 if error or warning:
297 if warning:
298 output.hook_warning(name, warning)
299 if error:
300 ret = False
301 output.hook_error(name, error)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800302 for result in hook_results:
303 if result.fixup_func:
304 fixup_func_list.append((name, commit,
305 result.fixup_func))
306
307 if fixup_func_list:
308 _attempt_fixes(fixup_func_list, commit_list)
Josh Gao25abf4b2016-09-23 18:36:27 -0700309
Mike Frysinger2e65c542016-03-08 16:17:00 -0500310 return ret
311
312
Mike Frysinger42234b72019-02-15 16:21:41 -0500313def _run_project_hooks(project_name, proj_dir=None, commit_list=None):
314 """Run the project-specific hooks in |proj_dir|.
315
316 Args:
317 project_name: The name of project to run hooks for.
318 proj_dir: If non-None, this is the directory the project is in. If None,
319 we'll ask repo.
320 commit_list: A list of commits to run hooks against. If None or empty
321 list then we'll automatically get the list of commits that would be
322 uploaded.
323
324 Returns:
325 False if any errors were found, else True.
326 """
327 output = Output(project_name)
328
329 if proj_dir is None:
330 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
331 result = rh.utils.run_command(cmd, capture_output=True)
332 proj_dirs = result.output.split()
Mike Frysinger5ac20862019-06-05 22:50:49 -0400333 if not proj_dirs:
Mike Frysinger42234b72019-02-15 16:21:41 -0500334 print('%s cannot be found.' % project_name, file=sys.stderr)
335 print('Please specify a valid project.', file=sys.stderr)
336 return False
337 if len(proj_dirs) > 1:
338 print('%s is associated with multiple directories.' % project_name,
339 file=sys.stderr)
340 print('Please specify a directory to help disambiguate.',
341 file=sys.stderr)
342 return False
343 proj_dir = proj_dirs[0]
344
345 pwd = os.getcwd()
346 try:
347 # Hooks assume they are run from the root of the project.
348 os.chdir(proj_dir)
349 return _run_project_hooks_in_cwd(project_name, proj_dir, output,
350 commit_list=commit_list)
351 finally:
352 output.finish()
353 os.chdir(pwd)
354
355
Mike Frysinger2e65c542016-03-08 16:17:00 -0500356def main(project_list, worktree_list=None, **_kwargs):
357 """Main function invoked directly by repo.
358
359 We must use the name "main" as that is what repo requires.
360
361 This function will exit directly upon error so that repo doesn't print some
362 obscure error message.
363
364 Args:
365 project_list: List of projects to run on.
366 worktree_list: A list of directories. It should be the same length as
367 project_list, so that each entry in project_list matches with a
368 directory in worktree_list. If None, we will attempt to calculate
369 the directories automatically.
370 kwargs: Leave this here for forward-compatibility.
371 """
372 found_error = False
373 if not worktree_list:
374 worktree_list = [None] * len(project_list)
375 for project, worktree in zip(project_list, worktree_list):
376 if not _run_project_hooks(project, proj_dir=worktree):
377 found_error = True
Mike Frysingera18d5f12019-02-15 16:27:35 -0500378 # If a repo had failures, add a blank line to help break up the
379 # output. If there were no failures, then the output should be
380 # very minimal, so we don't add it then.
381 print('', file=sys.stderr)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500382
383 if found_error:
384 color = rh.terminal.Color()
385 print('%s: Preupload failed due to above error(s).\n'
386 'For more info, please see:\n%s' %
387 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
388 file=sys.stderr)
389 sys.exit(1)
390
391
392def _identify_project(path):
393 """Identify the repo project associated with the given path.
394
395 Returns:
396 A string indicating what project is associated with the path passed in or
397 a blank string upon failure.
398 """
399 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
400 return rh.utils.run_command(cmd, capture_output=True, redirect_stderr=True,
401 cwd=path).output.strip()
402
403
404def direct_main(argv):
405 """Run hooks directly (outside of the context of repo).
406
407 Args:
408 argv: The command line args to process.
409
410 Returns:
411 0 if no pre-upload failures, 1 if failures.
412
413 Raises:
414 BadInvocation: On some types of invocation errors.
415 """
416 parser = argparse.ArgumentParser(description=__doc__)
417 parser.add_argument('--dir', default=None,
418 help='The directory that the project lives in. If not '
419 'specified, use the git project root based on the cwd.')
420 parser.add_argument('--project', default=None,
421 help='The project repo path; this can affect how the '
422 'hooks get run, since some hooks are project-specific.'
423 'If not specified, `repo` will be used to figure this '
424 'out based on the dir.')
425 parser.add_argument('commits', nargs='*',
426 help='Check specific commits')
427 opts = parser.parse_args(argv)
428
429 # Check/normalize git dir; if unspecified, we'll use the root of the git
430 # project from CWD.
431 if opts.dir is None:
432 cmd = ['git', 'rev-parse', '--git-dir']
433 git_dir = rh.utils.run_command(cmd, capture_output=True,
434 redirect_stderr=True).output.strip()
435 if not git_dir:
436 parser.error('The current directory is not part of a git project.')
437 opts.dir = os.path.dirname(os.path.abspath(git_dir))
438 elif not os.path.isdir(opts.dir):
439 parser.error('Invalid dir: %s' % opts.dir)
Adrian Roos8ac865f2018-04-13 12:08:52 +0100440 elif not rh.git.is_git_repository(opts.dir):
441 parser.error('Not a git repository: %s' % opts.dir)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500442
443 # Identify the project if it wasn't specified; this _requires_ the repo
444 # tool to be installed and for the project to be part of a repo checkout.
445 if not opts.project:
446 opts.project = _identify_project(opts.dir)
447 if not opts.project:
448 parser.error("Repo couldn't identify the project of %s" % opts.dir)
449
450 if _run_project_hooks(opts.project, proj_dir=opts.dir,
451 commit_list=opts.commits):
452 return 0
Mike Frysinger5ac20862019-06-05 22:50:49 -0400453 return 1
Mike Frysinger2e65c542016-03-08 16:17:00 -0500454
455
456if __name__ == '__main__':
457 sys.exit(direct_main(sys.argv[1:]))