blob: 01cf3a33b4f56cc00d9ea4fe534c16f1434e6ff9 [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 Frysinger2e65c542016-03-08 16:17:00 -050029_path = os.path.dirname(os.path.realpath(__file__))
30if sys.path[0] != _path:
31 sys.path.insert(0, _path)
32del _path
33
Mike Frysinger2ef213c2017-11-10 15:41:56 -050034# We have to import our local modules after the sys.path tweak. We can't use
35# relative imports because this is an executable program, not a module.
36# pylint: disable=wrong-import-position
Mike Frysingerb9608182016-10-20 20:45:04 -040037import rh
Mike Frysinger2e65c542016-03-08 16:17:00 -050038import rh.results
39import rh.config
40import rh.git
41import rh.hooks
Mike Frysingerce3ab292019-08-09 17:58:50 -040042import rh.sixish
Mike Frysinger2e65c542016-03-08 16:17:00 -050043import rh.terminal
44import rh.utils
45
46
47# Repohooks homepage.
48REPOHOOKS_URL = 'https://android.googlesource.com/platform/tools/repohooks/'
49
50
Josh Gao25abf4b2016-09-23 18:36:27 -070051class Output(object):
52 """Class for reporting hook status."""
53
54 COLOR = rh.terminal.Color()
55 COMMIT = COLOR.color(COLOR.CYAN, 'COMMIT')
56 RUNNING = COLOR.color(COLOR.YELLOW, 'RUNNING')
57 PASSED = COLOR.color(COLOR.GREEN, 'PASSED')
58 FAILED = COLOR.color(COLOR.RED, 'FAILED')
Jason Monk0886c912017-11-10 13:17:17 -050059 WARNING = COLOR.color(COLOR.YELLOW, 'WARNING')
Josh Gao25abf4b2016-09-23 18:36:27 -070060
Mike Frysinger42234b72019-02-15 16:21:41 -050061 def __init__(self, project_name):
Josh Gao25abf4b2016-09-23 18:36:27 -070062 """Create a new Output object for a specified project.
63
64 Args:
65 project_name: name of project.
Josh Gao25abf4b2016-09-23 18:36:27 -070066 """
67 self.project_name = project_name
Mike Frysinger42234b72019-02-15 16:21:41 -050068 self.num_hooks = None
Josh Gao25abf4b2016-09-23 18:36:27 -070069 self.hook_index = 0
70 self.success = True
71
Mike Frysinger42234b72019-02-15 16:21:41 -050072 def set_num_hooks(self, num_hooks):
73 """Keep track of how many hooks we'll be running.
74
75 Args:
76 num_hooks: number of hooks to be run.
77 """
78 self.num_hooks = num_hooks
79
Josh Gao25abf4b2016-09-23 18:36:27 -070080 def commit_start(self, commit, commit_summary):
81 """Emit status for new commit.
82
83 Args:
84 commit: commit hash.
85 commit_summary: commit summary.
86 """
87 status_line = '[%s %s] %s' % (self.COMMIT, commit[0:12], commit_summary)
88 rh.terminal.print_status_line(status_line, print_newline=True)
89 self.hook_index = 1
90
91 def hook_start(self, hook_name):
92 """Emit status before the start of a hook.
93
94 Args:
95 hook_name: name of the hook.
96 """
97 status_line = '[%s %d/%d] %s' % (self.RUNNING, self.hook_index,
98 self.num_hooks, hook_name)
99 self.hook_index += 1
100 rh.terminal.print_status_line(status_line)
101
102 def hook_error(self, hook_name, error):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500103 """Print an error for a single hook.
Josh Gao25abf4b2016-09-23 18:36:27 -0700104
105 Args:
106 hook_name: name of the hook.
107 error: error string.
108 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500109 self.error(hook_name, error)
Josh Gao25abf4b2016-09-23 18:36:27 -0700110
Jason Monk0886c912017-11-10 13:17:17 -0500111 def hook_warning(self, hook_name, warning):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500112 """Print a warning for a single hook.
Jason Monk0886c912017-11-10 13:17:17 -0500113
114 Args:
115 hook_name: name of the hook.
116 warning: warning string.
117 """
118 status_line = '[%s] %s' % (self.WARNING, hook_name)
119 rh.terminal.print_status_line(status_line, print_newline=True)
120 print(warning, file=sys.stderr)
121
Mike Frysingera18d5f12019-02-15 16:27:35 -0500122 def error(self, header, error):
123 """Print a general error.
124
125 Args:
126 header: A unique identifier for the source of this error.
127 error: error string.
128 """
129 status_line = '[%s] %s' % (self.FAILED, header)
130 rh.terminal.print_status_line(status_line, print_newline=True)
131 print(error, file=sys.stderr)
132 self.success = False
133
Josh Gao25abf4b2016-09-23 18:36:27 -0700134 def finish(self):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500135 """Print summary for all the hooks."""
Josh Gao25abf4b2016-09-23 18:36:27 -0700136 status_line = '[%s] repohooks for %s %s' % (
137 self.PASSED if self.success else self.FAILED,
138 self.project_name,
139 'passed' if self.success else 'failed')
140 rh.terminal.print_status_line(status_line, print_newline=True)
141
142
143def _process_hook_results(results):
144 """Returns an error string if an error occurred.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500145
146 Args:
Josh Gao25abf4b2016-09-23 18:36:27 -0700147 results: A list of HookResult objects, or None.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500148
149 Returns:
Josh Gao25abf4b2016-09-23 18:36:27 -0700150 error output if an error occurred, otherwise None
Jason Monk0886c912017-11-10 13:17:17 -0500151 warning output if an error occurred, otherwise None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500152 """
Josh Gao25abf4b2016-09-23 18:36:27 -0700153 if not results:
Jason Monk0886c912017-11-10 13:17:17 -0500154 return (None, None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500155
Jason Monk0886c912017-11-10 13:17:17 -0500156 error_ret = ''
157 warning_ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500158 for result in results:
159 if result:
Jason Monk0886c912017-11-10 13:17:17 -0500160 ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500161 if result.files:
Josh Gao25abf4b2016-09-23 18:36:27 -0700162 ret += ' FILES: %s' % (result.files,)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500163 lines = result.error.splitlines()
Josh Gao25abf4b2016-09-23 18:36:27 -0700164 ret += '\n'.join(' %s' % (x,) for x in lines)
Jason Monk0886c912017-11-10 13:17:17 -0500165 if result.is_warning():
166 warning_ret += ret
167 else:
168 error_ret += ret
Mike Frysinger2e65c542016-03-08 16:17:00 -0500169
Jason Monk0886c912017-11-10 13:17:17 -0500170 return (error_ret or None, warning_ret or None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500171
172
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700173def _get_project_config():
174 """Returns the configuration for a project.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500175
176 Expects to be called from within the project root.
177 """
Mike Frysingerca797702016-09-03 02:00:55 -0400178 global_paths = (
179 # Load the global config found in the manifest repo.
180 os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'),
181 # Load the global config found in the root of the repo checkout.
182 rh.git.find_repo_root(),
183 )
184 paths = (
185 # Load the config for this git repo.
186 '.',
187 )
Mike Frysinger828a0ee2019-08-05 17:42:04 -0400188 return rh.config.PreUploadConfig(paths=paths, global_paths=global_paths)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500189
190
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800191def _attempt_fixes(fixup_func_list, commit_list):
192 """Attempts to run |fixup_func_list| given |commit_list|."""
193 if len(fixup_func_list) != 1:
194 # Only single fixes will be attempted, since various fixes might
195 # interact with each other.
196 return
197
198 hook_name, commit, fixup_func = fixup_func_list[0]
199
200 if commit != commit_list[0]:
201 # If the commit is not at the top of the stack, git operations might be
202 # needed and might leave the working directory in a tricky state if the
203 # fix is attempted to run automatically (e.g. it might require manual
204 # merge conflict resolution). Refuse to run the fix in those cases.
205 return
206
207 prompt = ('An automatic fix can be attempted for the "%s" hook. '
208 'Do you want to run it?' % hook_name)
209 if not rh.terminal.boolean_prompt(prompt):
210 return
211
212 result = fixup_func()
213 if result:
214 print('Attempt to fix "%s" for commit "%s" failed: %s' %
215 (hook_name, commit, result),
216 file=sys.stderr)
217 else:
218 print('Fix successfully applied. Amend the current commit before '
219 'attempting to upload again.\n', file=sys.stderr)
220
221
Mike Frysinger42234b72019-02-15 16:21:41 -0500222def _run_project_hooks_in_cwd(project_name, proj_dir, output, commit_list=None):
223 """Run the project-specific hooks in the cwd.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500224
225 Args:
Mike Frysinger42234b72019-02-15 16:21:41 -0500226 project_name: The name of this project.
227 proj_dir: The directory for this project (for passing on in metadata).
228 output: Helper for summarizing output/errors to the user.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500229 commit_list: A list of commits to run hooks against. If None or empty
230 list then we'll automatically get the list of commits that would be
231 uploaded.
232
233 Returns:
234 False if any errors were found, else True.
235 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500236 try:
237 config = _get_project_config()
238 except rh.config.ValidationError as e:
239 output.error('Loading config files', str(e))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500240 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500241
242 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700243 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400244 if not hooks:
245 return True
246
Mike Frysinger42234b72019-02-15 16:21:41 -0500247 output.set_num_hooks(len(hooks))
248
Mike Frysinger2e65c542016-03-08 16:17:00 -0500249 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700250 try:
251 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800252 upstream_branch = rh.git.get_upstream_branch()
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700253 except rh.utils.RunCommandError as e:
Mike Frysingera18d5f12019-02-15 16:27:35 -0500254 output.error('Upstream remote/tracking branch lookup',
255 '%s\nDid you run repo start? Is your HEAD detached?' %
256 (e,))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500257 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500258
Mike Frysinger2e65c542016-03-08 16:17:00 -0500259 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800260 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Mike Frysingerfdd1a842019-08-09 13:45:57 -0400261 'REPO_PATH': os.path.relpath(proj_dir, rh.git.find_repo_root()),
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800262 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500263 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800264 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500265 })
266
Mike Frysingerb9608182016-10-20 20:45:04 -0400267 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500268
269 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700270 commit_list = rh.git.get_commits(
271 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500272
Mike Frysinger2e65c542016-03-08 16:17:00 -0500273 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800274 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700275
Mike Frysinger2e65c542016-03-08 16:17:00 -0500276 for commit in commit_list:
277 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400278 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500279 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400280 desc = rh.git.get_commit_desc(commit)
Mike Frysingerce3ab292019-08-09 17:58:50 -0400281 rh.sixish.setenv('PREUPLOAD_COMMIT_MESSAGE', desc)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500282
Josh Gao25abf4b2016-09-23 18:36:27 -0700283 commit_summary = desc.split('\n', 1)[0]
284 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500285
Josh Gao25abf4b2016-09-23 18:36:27 -0700286 for name, hook in hooks:
287 output.hook_start(name)
288 hook_results = hook(project, commit, desc, diff)
Jason Monk0886c912017-11-10 13:17:17 -0500289 (error, warning) = _process_hook_results(hook_results)
290 if error or warning:
291 if warning:
292 output.hook_warning(name, warning)
293 if error:
294 ret = False
295 output.hook_error(name, error)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800296 for result in hook_results:
297 if result.fixup_func:
298 fixup_func_list.append((name, commit,
299 result.fixup_func))
300
301 if fixup_func_list:
302 _attempt_fixes(fixup_func_list, commit_list)
Josh Gao25abf4b2016-09-23 18:36:27 -0700303
Mike Frysinger2e65c542016-03-08 16:17:00 -0500304 return ret
305
306
Mike Frysinger42234b72019-02-15 16:21:41 -0500307def _run_project_hooks(project_name, proj_dir=None, commit_list=None):
308 """Run the project-specific hooks in |proj_dir|.
309
310 Args:
311 project_name: The name of project to run hooks for.
312 proj_dir: If non-None, this is the directory the project is in. If None,
313 we'll ask repo.
314 commit_list: A list of commits to run hooks against. If None or empty
315 list then we'll automatically get the list of commits that would be
316 uploaded.
317
318 Returns:
319 False if any errors were found, else True.
320 """
321 output = Output(project_name)
322
323 if proj_dir is None:
324 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
325 result = rh.utils.run_command(cmd, capture_output=True)
326 proj_dirs = result.output.split()
Mike Frysinger5ac20862019-06-05 22:50:49 -0400327 if not proj_dirs:
Mike Frysinger42234b72019-02-15 16:21:41 -0500328 print('%s cannot be found.' % project_name, file=sys.stderr)
329 print('Please specify a valid project.', file=sys.stderr)
330 return False
331 if len(proj_dirs) > 1:
332 print('%s is associated with multiple directories.' % project_name,
333 file=sys.stderr)
334 print('Please specify a directory to help disambiguate.',
335 file=sys.stderr)
336 return False
337 proj_dir = proj_dirs[0]
338
339 pwd = os.getcwd()
340 try:
341 # Hooks assume they are run from the root of the project.
342 os.chdir(proj_dir)
343 return _run_project_hooks_in_cwd(project_name, proj_dir, output,
344 commit_list=commit_list)
345 finally:
346 output.finish()
347 os.chdir(pwd)
348
349
Mike Frysinger2e65c542016-03-08 16:17:00 -0500350def main(project_list, worktree_list=None, **_kwargs):
351 """Main function invoked directly by repo.
352
353 We must use the name "main" as that is what repo requires.
354
355 This function will exit directly upon error so that repo doesn't print some
356 obscure error message.
357
358 Args:
359 project_list: List of projects to run on.
360 worktree_list: A list of directories. It should be the same length as
361 project_list, so that each entry in project_list matches with a
362 directory in worktree_list. If None, we will attempt to calculate
363 the directories automatically.
364 kwargs: Leave this here for forward-compatibility.
365 """
366 found_error = False
367 if not worktree_list:
368 worktree_list = [None] * len(project_list)
369 for project, worktree in zip(project_list, worktree_list):
370 if not _run_project_hooks(project, proj_dir=worktree):
371 found_error = True
Mike Frysingera18d5f12019-02-15 16:27:35 -0500372 # If a repo had failures, add a blank line to help break up the
373 # output. If there were no failures, then the output should be
374 # very minimal, so we don't add it then.
375 print('', file=sys.stderr)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500376
377 if found_error:
378 color = rh.terminal.Color()
379 print('%s: Preupload failed due to above error(s).\n'
380 'For more info, please see:\n%s' %
381 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
382 file=sys.stderr)
383 sys.exit(1)
384
385
386def _identify_project(path):
387 """Identify the repo project associated with the given path.
388
389 Returns:
390 A string indicating what project is associated with the path passed in or
391 a blank string upon failure.
392 """
393 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
394 return rh.utils.run_command(cmd, capture_output=True, redirect_stderr=True,
395 cwd=path).output.strip()
396
397
398def direct_main(argv):
399 """Run hooks directly (outside of the context of repo).
400
401 Args:
402 argv: The command line args to process.
403
404 Returns:
405 0 if no pre-upload failures, 1 if failures.
406
407 Raises:
408 BadInvocation: On some types of invocation errors.
409 """
410 parser = argparse.ArgumentParser(description=__doc__)
411 parser.add_argument('--dir', default=None,
412 help='The directory that the project lives in. If not '
413 'specified, use the git project root based on the cwd.')
414 parser.add_argument('--project', default=None,
415 help='The project repo path; this can affect how the '
416 'hooks get run, since some hooks are project-specific.'
417 'If not specified, `repo` will be used to figure this '
418 'out based on the dir.')
419 parser.add_argument('commits', nargs='*',
420 help='Check specific commits')
421 opts = parser.parse_args(argv)
422
423 # Check/normalize git dir; if unspecified, we'll use the root of the git
424 # project from CWD.
425 if opts.dir is None:
426 cmd = ['git', 'rev-parse', '--git-dir']
427 git_dir = rh.utils.run_command(cmd, capture_output=True,
428 redirect_stderr=True).output.strip()
429 if not git_dir:
430 parser.error('The current directory is not part of a git project.')
431 opts.dir = os.path.dirname(os.path.abspath(git_dir))
432 elif not os.path.isdir(opts.dir):
433 parser.error('Invalid dir: %s' % opts.dir)
Adrian Roos8ac865f2018-04-13 12:08:52 +0100434 elif not rh.git.is_git_repository(opts.dir):
435 parser.error('Not a git repository: %s' % opts.dir)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500436
437 # Identify the project if it wasn't specified; this _requires_ the repo
438 # tool to be installed and for the project to be part of a repo checkout.
439 if not opts.project:
440 opts.project = _identify_project(opts.dir)
441 if not opts.project:
442 parser.error("Repo couldn't identify the project of %s" % opts.dir)
443
444 if _run_project_hooks(opts.project, proj_dir=opts.dir,
445 commit_list=opts.commits):
446 return 0
Mike Frysinger5ac20862019-06-05 22:50:49 -0400447 return 1
Mike Frysinger2e65c542016-03-08 16:17:00 -0500448
449
450if __name__ == '__main__':
451 sys.exit(direct_main(sys.argv[1:]))