blob: 517ee7915bfc2e41f43e1ffb3ebc2e2525e2bf46 [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):
108 """Print an error.
109
110 Args:
111 hook_name: name of the hook.
112 error: error string.
113 """
114 status_line = '[%s] %s' % (self.FAILED, hook_name)
115 rh.terminal.print_status_line(status_line, print_newline=True)
116 print(error, file=sys.stderr)
117 self.success = False
118
Jason Monk0886c912017-11-10 13:17:17 -0500119 def hook_warning(self, hook_name, warning):
120 """Print a warning.
121
122 Args:
123 hook_name: name of the hook.
124 warning: warning string.
125 """
126 status_line = '[%s] %s' % (self.WARNING, hook_name)
127 rh.terminal.print_status_line(status_line, print_newline=True)
128 print(warning, file=sys.stderr)
129
Josh Gao25abf4b2016-09-23 18:36:27 -0700130 def finish(self):
131 """Print repohook summary."""
132 status_line = '[%s] repohooks for %s %s' % (
133 self.PASSED if self.success else self.FAILED,
134 self.project_name,
135 'passed' if self.success else 'failed')
136 rh.terminal.print_status_line(status_line, print_newline=True)
137
138
139def _process_hook_results(results):
140 """Returns an error string if an error occurred.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500141
142 Args:
Josh Gao25abf4b2016-09-23 18:36:27 -0700143 results: A list of HookResult objects, or None.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500144
145 Returns:
Josh Gao25abf4b2016-09-23 18:36:27 -0700146 error output if an error occurred, otherwise None
Jason Monk0886c912017-11-10 13:17:17 -0500147 warning output if an error occurred, otherwise None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500148 """
Josh Gao25abf4b2016-09-23 18:36:27 -0700149 if not results:
Jason Monk0886c912017-11-10 13:17:17 -0500150 return (None, None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500151
Jason Monk0886c912017-11-10 13:17:17 -0500152 error_ret = ''
153 warning_ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500154 for result in results:
155 if result:
Jason Monk0886c912017-11-10 13:17:17 -0500156 ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500157 if result.files:
Josh Gao25abf4b2016-09-23 18:36:27 -0700158 ret += ' FILES: %s' % (result.files,)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500159 lines = result.error.splitlines()
Josh Gao25abf4b2016-09-23 18:36:27 -0700160 ret += '\n'.join(' %s' % (x,) for x in lines)
Jason Monk0886c912017-11-10 13:17:17 -0500161 if result.is_warning():
162 warning_ret += ret
163 else:
164 error_ret += ret
Mike Frysinger2e65c542016-03-08 16:17:00 -0500165
Jason Monk0886c912017-11-10 13:17:17 -0500166 return (error_ret or None, warning_ret or None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500167
168
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700169def _get_project_config():
170 """Returns the configuration for a project.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500171
172 Expects to be called from within the project root.
173 """
Mike Frysingerca797702016-09-03 02:00:55 -0400174 global_paths = (
175 # Load the global config found in the manifest repo.
176 os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'),
177 # Load the global config found in the root of the repo checkout.
178 rh.git.find_repo_root(),
179 )
180 paths = (
181 # Load the config for this git repo.
182 '.',
183 )
Mike Frysinger2e65c542016-03-08 16:17:00 -0500184 try:
Mike Frysingerca797702016-09-03 02:00:55 -0400185 config = rh.config.PreSubmitConfig(paths=paths,
186 global_paths=global_paths)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500187 except rh.config.ValidationError as e:
188 print('invalid config file: %s' % (e,), file=sys.stderr)
Mike Frysingera65ecb92019-02-15 15:58:31 -0500189 return None
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700190 return config
Mike Frysinger2e65c542016-03-08 16:17:00 -0500191
192
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800193def _attempt_fixes(fixup_func_list, commit_list):
194 """Attempts to run |fixup_func_list| given |commit_list|."""
195 if len(fixup_func_list) != 1:
196 # Only single fixes will be attempted, since various fixes might
197 # interact with each other.
198 return
199
200 hook_name, commit, fixup_func = fixup_func_list[0]
201
202 if commit != commit_list[0]:
203 # If the commit is not at the top of the stack, git operations might be
204 # needed and might leave the working directory in a tricky state if the
205 # fix is attempted to run automatically (e.g. it might require manual
206 # merge conflict resolution). Refuse to run the fix in those cases.
207 return
208
209 prompt = ('An automatic fix can be attempted for the "%s" hook. '
210 'Do you want to run it?' % hook_name)
211 if not rh.terminal.boolean_prompt(prompt):
212 return
213
214 result = fixup_func()
215 if result:
216 print('Attempt to fix "%s" for commit "%s" failed: %s' %
217 (hook_name, commit, result),
218 file=sys.stderr)
219 else:
220 print('Fix successfully applied. Amend the current commit before '
221 'attempting to upload again.\n', file=sys.stderr)
222
223
Mike Frysinger42234b72019-02-15 16:21:41 -0500224def _run_project_hooks_in_cwd(project_name, proj_dir, output, commit_list=None):
225 """Run the project-specific hooks in the cwd.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500226
227 Args:
Mike Frysinger42234b72019-02-15 16:21:41 -0500228 project_name: The name of this project.
229 proj_dir: The directory for this project (for passing on in metadata).
230 output: Helper for summarizing output/errors to the user.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500231 commit_list: A list of commits to run hooks against. If None or empty
232 list then we'll automatically get the list of commits that would be
233 uploaded.
234
235 Returns:
236 False if any errors were found, else True.
237 """
Mike Frysinger558aff42016-04-04 16:02:55 -0400238 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700239 config = _get_project_config()
Mike Frysingera65ecb92019-02-15 15:58:31 -0500240 if not config:
241 return False
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700242 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400243 if not hooks:
244 return True
245
Mike Frysinger42234b72019-02-15 16:21:41 -0500246 output.set_num_hooks(len(hooks))
247
Mike Frysinger2e65c542016-03-08 16:17:00 -0500248 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700249 try:
250 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800251 upstream_branch = rh.git.get_upstream_branch()
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700252 except rh.utils.RunCommandError as e:
253 print('upstream remote cannot be found: %s' % (e,), file=sys.stderr)
254 print('Did you run repo start?', file=sys.stderr)
Mike Frysingera65ecb92019-02-15 15:58:31 -0500255 return False
Mike Frysinger2e65c542016-03-08 16:17:00 -0500256 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800257 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500258 'REPO_PATH': proj_dir,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800259 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500260 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800261 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500262 })
263
Mike Frysingerb9608182016-10-20 20:45:04 -0400264 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500265
266 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700267 commit_list = rh.git.get_commits(
268 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500269
Mike Frysinger2e65c542016-03-08 16:17:00 -0500270 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800271 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700272
Mike Frysinger2e65c542016-03-08 16:17:00 -0500273 for commit in commit_list:
274 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400275 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500276 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400277 desc = rh.git.get_commit_desc(commit)
Nicolas Sylvain6f798862016-05-02 18:58:22 -0700278 os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
Mike Frysinger2e65c542016-03-08 16:17:00 -0500279
Josh Gao25abf4b2016-09-23 18:36:27 -0700280 commit_summary = desc.split('\n', 1)[0]
281 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500282
Josh Gao25abf4b2016-09-23 18:36:27 -0700283 for name, hook in hooks:
284 output.hook_start(name)
285 hook_results = hook(project, commit, desc, diff)
Jason Monk0886c912017-11-10 13:17:17 -0500286 (error, warning) = _process_hook_results(hook_results)
287 if error or warning:
288 if warning:
289 output.hook_warning(name, warning)
290 if error:
291 ret = False
292 output.hook_error(name, error)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800293 for result in hook_results:
294 if result.fixup_func:
295 fixup_func_list.append((name, commit,
296 result.fixup_func))
297
298 if fixup_func_list:
299 _attempt_fixes(fixup_func_list, commit_list)
Josh Gao25abf4b2016-09-23 18:36:27 -0700300
Mike Frysinger2e65c542016-03-08 16:17:00 -0500301 return ret
302
303
Mike Frysinger42234b72019-02-15 16:21:41 -0500304def _run_project_hooks(project_name, proj_dir=None, commit_list=None):
305 """Run the project-specific hooks in |proj_dir|.
306
307 Args:
308 project_name: The name of project to run hooks for.
309 proj_dir: If non-None, this is the directory the project is in. If None,
310 we'll ask repo.
311 commit_list: A list of commits to run hooks against. If None or empty
312 list then we'll automatically get the list of commits that would be
313 uploaded.
314
315 Returns:
316 False if any errors were found, else True.
317 """
318 output = Output(project_name)
319
320 if proj_dir is None:
321 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
322 result = rh.utils.run_command(cmd, capture_output=True)
323 proj_dirs = result.output.split()
324 if len(proj_dirs) == 0:
325 print('%s cannot be found.' % project_name, file=sys.stderr)
326 print('Please specify a valid project.', file=sys.stderr)
327 return False
328 if len(proj_dirs) > 1:
329 print('%s is associated with multiple directories.' % project_name,
330 file=sys.stderr)
331 print('Please specify a directory to help disambiguate.',
332 file=sys.stderr)
333 return False
334 proj_dir = proj_dirs[0]
335
336 pwd = os.getcwd()
337 try:
338 # Hooks assume they are run from the root of the project.
339 os.chdir(proj_dir)
340 return _run_project_hooks_in_cwd(project_name, proj_dir, output,
341 commit_list=commit_list)
342 finally:
343 output.finish()
344 os.chdir(pwd)
345
346
Mike Frysinger2e65c542016-03-08 16:17:00 -0500347def main(project_list, worktree_list=None, **_kwargs):
348 """Main function invoked directly by repo.
349
350 We must use the name "main" as that is what repo requires.
351
352 This function will exit directly upon error so that repo doesn't print some
353 obscure error message.
354
355 Args:
356 project_list: List of projects to run on.
357 worktree_list: A list of directories. It should be the same length as
358 project_list, so that each entry in project_list matches with a
359 directory in worktree_list. If None, we will attempt to calculate
360 the directories automatically.
361 kwargs: Leave this here for forward-compatibility.
362 """
363 found_error = False
364 if not worktree_list:
365 worktree_list = [None] * len(project_list)
366 for project, worktree in zip(project_list, worktree_list):
367 if not _run_project_hooks(project, proj_dir=worktree):
368 found_error = True
369
370 if found_error:
371 color = rh.terminal.Color()
372 print('%s: Preupload failed due to above error(s).\n'
373 'For more info, please see:\n%s' %
374 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
375 file=sys.stderr)
376 sys.exit(1)
377
378
379def _identify_project(path):
380 """Identify the repo project associated with the given path.
381
382 Returns:
383 A string indicating what project is associated with the path passed in or
384 a blank string upon failure.
385 """
386 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
387 return rh.utils.run_command(cmd, capture_output=True, redirect_stderr=True,
388 cwd=path).output.strip()
389
390
391def direct_main(argv):
392 """Run hooks directly (outside of the context of repo).
393
394 Args:
395 argv: The command line args to process.
396
397 Returns:
398 0 if no pre-upload failures, 1 if failures.
399
400 Raises:
401 BadInvocation: On some types of invocation errors.
402 """
403 parser = argparse.ArgumentParser(description=__doc__)
404 parser.add_argument('--dir', default=None,
405 help='The directory that the project lives in. If not '
406 'specified, use the git project root based on the cwd.')
407 parser.add_argument('--project', default=None,
408 help='The project repo path; this can affect how the '
409 'hooks get run, since some hooks are project-specific.'
410 'If not specified, `repo` will be used to figure this '
411 'out based on the dir.')
412 parser.add_argument('commits', nargs='*',
413 help='Check specific commits')
414 opts = parser.parse_args(argv)
415
416 # Check/normalize git dir; if unspecified, we'll use the root of the git
417 # project from CWD.
418 if opts.dir is None:
419 cmd = ['git', 'rev-parse', '--git-dir']
420 git_dir = rh.utils.run_command(cmd, capture_output=True,
421 redirect_stderr=True).output.strip()
422 if not git_dir:
423 parser.error('The current directory is not part of a git project.')
424 opts.dir = os.path.dirname(os.path.abspath(git_dir))
425 elif not os.path.isdir(opts.dir):
426 parser.error('Invalid dir: %s' % opts.dir)
Adrian Roos8ac865f2018-04-13 12:08:52 +0100427 elif not rh.git.is_git_repository(opts.dir):
428 parser.error('Not a git repository: %s' % opts.dir)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500429
430 # Identify the project if it wasn't specified; this _requires_ the repo
431 # tool to be installed and for the project to be part of a repo checkout.
432 if not opts.project:
433 opts.project = _identify_project(opts.dir)
434 if not opts.project:
435 parser.error("Repo couldn't identify the project of %s" % opts.dir)
436
437 if _run_project_hooks(opts.project, proj_dir=opts.dir,
438 commit_list=opts.commits):
439 return 0
440 else:
441 return 1
442
443
444if __name__ == '__main__':
445 sys.exit(direct_main(sys.argv[1:]))