blob: 94bb961793d0e66a867e22e08538ba18ad2450af [file] [log] [blame]
Mike Frysinger76987782020-07-15 00:45:14 -04001#!/usr/bin/env python3
Mike Frysinger2e65c542016-03-08 16:17:00 -05002# -*- 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.
Mike Frysinger76987782020-07-15 00:45:14 -040032if sys.version_info < (3, 5):
Mike Frysinger07107e62020-07-15 00:40:47 -040033 print('repohooks: error: Python-3.5+ is required', file=sys.stderr)
Mike Frysinger7b30f4d2019-10-15 01:18:38 -040034 sys.exit(1)
35
36
Mike Frysinger2e65c542016-03-08 16:17:00 -050037_path = os.path.dirname(os.path.realpath(__file__))
38if sys.path[0] != _path:
39 sys.path.insert(0, _path)
40del _path
41
Mike Frysinger2ef213c2017-11-10 15:41:56 -050042# We have to import our local modules after the sys.path tweak. We can't use
43# relative imports because this is an executable program, not a module.
44# pylint: disable=wrong-import-position
Mike Frysingerb9608182016-10-20 20:45:04 -040045import rh
Mike Frysinger2e65c542016-03-08 16:17:00 -050046import rh.results
47import rh.config
48import rh.git
49import rh.hooks
Mike Frysingerce3ab292019-08-09 17:58:50 -040050import rh.sixish
Mike Frysinger2e65c542016-03-08 16:17:00 -050051import rh.terminal
52import rh.utils
53
54
55# Repohooks homepage.
56REPOHOOKS_URL = 'https://android.googlesource.com/platform/tools/repohooks/'
57
58
Josh Gao25abf4b2016-09-23 18:36:27 -070059class Output(object):
60 """Class for reporting hook status."""
61
62 COLOR = rh.terminal.Color()
63 COMMIT = COLOR.color(COLOR.CYAN, 'COMMIT')
64 RUNNING = COLOR.color(COLOR.YELLOW, 'RUNNING')
65 PASSED = COLOR.color(COLOR.GREEN, 'PASSED')
66 FAILED = COLOR.color(COLOR.RED, 'FAILED')
Jason Monk0886c912017-11-10 13:17:17 -050067 WARNING = COLOR.color(COLOR.YELLOW, 'WARNING')
Josh Gao25abf4b2016-09-23 18:36:27 -070068
Mike Frysinger42234b72019-02-15 16:21:41 -050069 def __init__(self, project_name):
Josh Gao25abf4b2016-09-23 18:36:27 -070070 """Create a new Output object for a specified project.
71
72 Args:
73 project_name: name of project.
Josh Gao25abf4b2016-09-23 18:36:27 -070074 """
75 self.project_name = project_name
Mike Frysinger42234b72019-02-15 16:21:41 -050076 self.num_hooks = None
Josh Gao25abf4b2016-09-23 18:36:27 -070077 self.hook_index = 0
78 self.success = True
Mike Frysinger579111e2019-12-04 21:36:01 -050079 self.start_time = datetime.datetime.now()
Josh Gao25abf4b2016-09-23 18:36:27 -070080
Mike Frysinger42234b72019-02-15 16:21:41 -050081 def set_num_hooks(self, num_hooks):
82 """Keep track of how many hooks we'll be running.
83
84 Args:
85 num_hooks: number of hooks to be run.
86 """
87 self.num_hooks = num_hooks
88
Josh Gao25abf4b2016-09-23 18:36:27 -070089 def commit_start(self, commit, commit_summary):
90 """Emit status for new commit.
91
92 Args:
93 commit: commit hash.
94 commit_summary: commit summary.
95 """
96 status_line = '[%s %s] %s' % (self.COMMIT, commit[0:12], commit_summary)
97 rh.terminal.print_status_line(status_line, print_newline=True)
98 self.hook_index = 1
99
100 def hook_start(self, hook_name):
101 """Emit status before the start of a hook.
102
103 Args:
104 hook_name: name of the hook.
105 """
106 status_line = '[%s %d/%d] %s' % (self.RUNNING, self.hook_index,
107 self.num_hooks, hook_name)
108 self.hook_index += 1
109 rh.terminal.print_status_line(status_line)
110
111 def hook_error(self, hook_name, error):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500112 """Print an error for a single hook.
Josh Gao25abf4b2016-09-23 18:36:27 -0700113
114 Args:
115 hook_name: name of the hook.
116 error: error string.
117 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500118 self.error(hook_name, error)
Josh Gao25abf4b2016-09-23 18:36:27 -0700119
Jason Monk0886c912017-11-10 13:17:17 -0500120 def hook_warning(self, hook_name, warning):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500121 """Print a warning for a single hook.
Jason Monk0886c912017-11-10 13:17:17 -0500122
123 Args:
124 hook_name: name of the hook.
125 warning: warning string.
126 """
127 status_line = '[%s] %s' % (self.WARNING, hook_name)
128 rh.terminal.print_status_line(status_line, print_newline=True)
129 print(warning, file=sys.stderr)
130
Mike Frysingera18d5f12019-02-15 16:27:35 -0500131 def error(self, header, error):
132 """Print a general error.
133
134 Args:
135 header: A unique identifier for the source of this error.
136 error: error string.
137 """
138 status_line = '[%s] %s' % (self.FAILED, header)
139 rh.terminal.print_status_line(status_line, print_newline=True)
140 print(error, file=sys.stderr)
141 self.success = False
142
Josh Gao25abf4b2016-09-23 18:36:27 -0700143 def finish(self):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500144 """Print summary for all the hooks."""
Mike Frysinger579111e2019-12-04 21:36:01 -0500145 status_line = '[%s] repohooks for %s %s in %s' % (
Josh Gao25abf4b2016-09-23 18:36:27 -0700146 self.PASSED if self.success else self.FAILED,
147 self.project_name,
Mike Frysinger579111e2019-12-04 21:36:01 -0500148 'passed' if self.success else 'failed',
149 rh.utils.timedelta_str(datetime.datetime.now() - self.start_time))
Josh Gao25abf4b2016-09-23 18:36:27 -0700150 rh.terminal.print_status_line(status_line, print_newline=True)
151
152
153def _process_hook_results(results):
154 """Returns an error string if an error occurred.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500155
156 Args:
Josh Gao25abf4b2016-09-23 18:36:27 -0700157 results: A list of HookResult objects, or None.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500158
159 Returns:
Josh Gao25abf4b2016-09-23 18:36:27 -0700160 error output if an error occurred, otherwise None
Jason Monk0886c912017-11-10 13:17:17 -0500161 warning output if an error occurred, otherwise None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500162 """
Josh Gao25abf4b2016-09-23 18:36:27 -0700163 if not results:
Jason Monk0886c912017-11-10 13:17:17 -0500164 return (None, None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500165
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400166 # We track these as dedicated fields in case a hook doesn't output anything.
167 # We want to treat silent non-zero exits as failures too.
168 has_error = False
169 has_warning = False
170
Jason Monk0886c912017-11-10 13:17:17 -0500171 error_ret = ''
172 warning_ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500173 for result in results:
174 if result:
Jason Monk0886c912017-11-10 13:17:17 -0500175 ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500176 if result.files:
Josh Gao25abf4b2016-09-23 18:36:27 -0700177 ret += ' FILES: %s' % (result.files,)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500178 lines = result.error.splitlines()
Josh Gao25abf4b2016-09-23 18:36:27 -0700179 ret += '\n'.join(' %s' % (x,) for x in lines)
Jason Monk0886c912017-11-10 13:17:17 -0500180 if result.is_warning():
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400181 has_warning = True
Jason Monk0886c912017-11-10 13:17:17 -0500182 warning_ret += ret
183 else:
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400184 has_error = True
Jason Monk0886c912017-11-10 13:17:17 -0500185 error_ret += ret
Mike Frysinger2e65c542016-03-08 16:17:00 -0500186
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400187 return (error_ret if has_error else None,
188 warning_ret if has_warning else None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500189
190
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700191def _get_project_config():
192 """Returns the configuration for a project.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500193
194 Expects to be called from within the project root.
195 """
Mike Frysingerca797702016-09-03 02:00:55 -0400196 global_paths = (
197 # Load the global config found in the manifest repo.
198 os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'),
199 # Load the global config found in the root of the repo checkout.
200 rh.git.find_repo_root(),
201 )
202 paths = (
203 # Load the config for this git repo.
204 '.',
205 )
Mike Frysinger1baec122020-08-25 00:27:52 -0400206 return rh.config.PreUploadSettings(paths=paths, global_paths=global_paths)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500207
208
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800209def _attempt_fixes(fixup_func_list, commit_list):
210 """Attempts to run |fixup_func_list| given |commit_list|."""
211 if len(fixup_func_list) != 1:
212 # Only single fixes will be attempted, since various fixes might
213 # interact with each other.
214 return
215
216 hook_name, commit, fixup_func = fixup_func_list[0]
217
218 if commit != commit_list[0]:
219 # If the commit is not at the top of the stack, git operations might be
220 # needed and might leave the working directory in a tricky state if the
221 # fix is attempted to run automatically (e.g. it might require manual
222 # merge conflict resolution). Refuse to run the fix in those cases.
223 return
224
225 prompt = ('An automatic fix can be attempted for the "%s" hook. '
226 'Do you want to run it?' % hook_name)
227 if not rh.terminal.boolean_prompt(prompt):
228 return
229
230 result = fixup_func()
231 if result:
232 print('Attempt to fix "%s" for commit "%s" failed: %s' %
233 (hook_name, commit, result),
234 file=sys.stderr)
235 else:
236 print('Fix successfully applied. Amend the current commit before '
237 'attempting to upload again.\n', file=sys.stderr)
238
239
Mike Frysinger42234b72019-02-15 16:21:41 -0500240def _run_project_hooks_in_cwd(project_name, proj_dir, output, commit_list=None):
241 """Run the project-specific hooks in the cwd.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500242
243 Args:
Mike Frysinger42234b72019-02-15 16:21:41 -0500244 project_name: The name of this project.
245 proj_dir: The directory for this project (for passing on in metadata).
246 output: Helper for summarizing output/errors to the user.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500247 commit_list: A list of commits to run hooks against. If None or empty
248 list then we'll automatically get the list of commits that would be
249 uploaded.
250
251 Returns:
252 False if any errors were found, else True.
253 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500254 try:
255 config = _get_project_config()
256 except rh.config.ValidationError as e:
257 output.error('Loading config files', str(e))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500258 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500259
260 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700261 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400262 if not hooks:
263 return True
264
Mike Frysinger42234b72019-02-15 16:21:41 -0500265 output.set_num_hooks(len(hooks))
266
Mike Frysinger2e65c542016-03-08 16:17:00 -0500267 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700268 try:
269 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800270 upstream_branch = rh.git.get_upstream_branch()
Mike Frysinger36d2ce62019-12-04 22:17:07 -0500271 except rh.utils.CalledProcessError as e:
Mike Frysingera18d5f12019-02-15 16:27:35 -0500272 output.error('Upstream remote/tracking branch lookup',
273 '%s\nDid you run repo start? Is your HEAD detached?' %
274 (e,))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500275 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500276
Mike Frysinger2e65c542016-03-08 16:17:00 -0500277 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800278 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Mike Frysingerfdd1a842019-08-09 13:45:57 -0400279 'REPO_PATH': os.path.relpath(proj_dir, rh.git.find_repo_root()),
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800280 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500281 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800282 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500283 })
284
Mike Frysingerb9608182016-10-20 20:45:04 -0400285 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500286
287 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700288 commit_list = rh.git.get_commits(
289 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500290
Mike Frysinger2e65c542016-03-08 16:17:00 -0500291 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800292 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700293
Mike Frysinger2e65c542016-03-08 16:17:00 -0500294 for commit in commit_list:
295 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400296 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500297 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400298 desc = rh.git.get_commit_desc(commit)
Mike Frysingerce3ab292019-08-09 17:58:50 -0400299 rh.sixish.setenv('PREUPLOAD_COMMIT_MESSAGE', desc)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500300
Josh Gao25abf4b2016-09-23 18:36:27 -0700301 commit_summary = desc.split('\n', 1)[0]
302 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500303
Josh Gao25abf4b2016-09-23 18:36:27 -0700304 for name, hook in hooks:
305 output.hook_start(name)
306 hook_results = hook(project, commit, desc, diff)
Jason Monk0886c912017-11-10 13:17:17 -0500307 (error, warning) = _process_hook_results(hook_results)
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400308 if error is not None or warning is not None:
309 if warning is not None:
Jason Monk0886c912017-11-10 13:17:17 -0500310 output.hook_warning(name, warning)
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400311 if error is not None:
Jason Monk0886c912017-11-10 13:17:17 -0500312 ret = False
313 output.hook_error(name, error)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800314 for result in hook_results:
315 if result.fixup_func:
316 fixup_func_list.append((name, commit,
317 result.fixup_func))
318
319 if fixup_func_list:
320 _attempt_fixes(fixup_func_list, commit_list)
Josh Gao25abf4b2016-09-23 18:36:27 -0700321
Mike Frysinger2e65c542016-03-08 16:17:00 -0500322 return ret
323
324
Mike Frysinger42234b72019-02-15 16:21:41 -0500325def _run_project_hooks(project_name, proj_dir=None, commit_list=None):
326 """Run the project-specific hooks in |proj_dir|.
327
328 Args:
329 project_name: The name of project to run hooks for.
330 proj_dir: If non-None, this is the directory the project is in. If None,
331 we'll ask repo.
332 commit_list: A list of commits to run hooks against. If None or empty
333 list then we'll automatically get the list of commits that would be
334 uploaded.
335
336 Returns:
337 False if any errors were found, else True.
338 """
339 output = Output(project_name)
340
341 if proj_dir is None:
342 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
Mike Frysinger70b78f02019-12-04 21:42:39 -0500343 result = rh.utils.run(cmd, capture_output=True)
Mike Frysinger36d2ce62019-12-04 22:17:07 -0500344 proj_dirs = result.stdout.split()
Mike Frysinger5ac20862019-06-05 22:50:49 -0400345 if not proj_dirs:
Mike Frysinger42234b72019-02-15 16:21:41 -0500346 print('%s cannot be found.' % project_name, file=sys.stderr)
347 print('Please specify a valid project.', file=sys.stderr)
348 return False
349 if len(proj_dirs) > 1:
350 print('%s is associated with multiple directories.' % project_name,
351 file=sys.stderr)
352 print('Please specify a directory to help disambiguate.',
353 file=sys.stderr)
354 return False
355 proj_dir = proj_dirs[0]
356
357 pwd = os.getcwd()
358 try:
359 # Hooks assume they are run from the root of the project.
360 os.chdir(proj_dir)
361 return _run_project_hooks_in_cwd(project_name, proj_dir, output,
362 commit_list=commit_list)
363 finally:
364 output.finish()
365 os.chdir(pwd)
366
367
Mike Frysinger2e65c542016-03-08 16:17:00 -0500368def main(project_list, worktree_list=None, **_kwargs):
369 """Main function invoked directly by repo.
370
371 We must use the name "main" as that is what repo requires.
372
373 This function will exit directly upon error so that repo doesn't print some
374 obscure error message.
375
376 Args:
377 project_list: List of projects to run on.
378 worktree_list: A list of directories. It should be the same length as
379 project_list, so that each entry in project_list matches with a
380 directory in worktree_list. If None, we will attempt to calculate
381 the directories automatically.
382 kwargs: Leave this here for forward-compatibility.
383 """
384 found_error = False
385 if not worktree_list:
386 worktree_list = [None] * len(project_list)
387 for project, worktree in zip(project_list, worktree_list):
388 if not _run_project_hooks(project, proj_dir=worktree):
389 found_error = True
Mike Frysingera18d5f12019-02-15 16:27:35 -0500390 # If a repo had failures, add a blank line to help break up the
391 # output. If there were no failures, then the output should be
392 # very minimal, so we don't add it then.
393 print('', file=sys.stderr)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500394
395 if found_error:
396 color = rh.terminal.Color()
397 print('%s: Preupload failed due to above error(s).\n'
398 'For more info, please see:\n%s' %
399 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
400 file=sys.stderr)
401 sys.exit(1)
402
403
404def _identify_project(path):
405 """Identify the repo project associated with the given path.
406
407 Returns:
408 A string indicating what project is associated with the path passed in or
409 a blank string upon failure.
410 """
411 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
Mike Frysinger7bd2a9a2020-04-08 18:23:57 -0400412 return rh.utils.run(cmd, capture_output=True, cwd=path).stdout.strip()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500413
414
415def direct_main(argv):
416 """Run hooks directly (outside of the context of repo).
417
418 Args:
419 argv: The command line args to process.
420
421 Returns:
422 0 if no pre-upload failures, 1 if failures.
423
424 Raises:
425 BadInvocation: On some types of invocation errors.
426 """
427 parser = argparse.ArgumentParser(description=__doc__)
428 parser.add_argument('--dir', default=None,
429 help='The directory that the project lives in. If not '
430 'specified, use the git project root based on the cwd.')
431 parser.add_argument('--project', default=None,
432 help='The project repo path; this can affect how the '
433 'hooks get run, since some hooks are project-specific.'
434 'If not specified, `repo` will be used to figure this '
435 'out based on the dir.')
436 parser.add_argument('commits', nargs='*',
437 help='Check specific commits')
438 opts = parser.parse_args(argv)
439
440 # Check/normalize git dir; if unspecified, we'll use the root of the git
441 # project from CWD.
442 if opts.dir is None:
443 cmd = ['git', 'rev-parse', '--git-dir']
Mike Frysinger7bd2a9a2020-04-08 18:23:57 -0400444 git_dir = rh.utils.run(cmd, capture_output=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:]))