blob: e7ef56415bc22f5f23256d778be2b3327ef3fcbe [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
Thiébaud Weksteenea528202020-08-28 15:54:29 +0200277 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
278 rel_proj_dir = os.path.relpath(proj_dir, rh.git.find_repo_root())
279
Mike Frysinger2e65c542016-03-08 16:17:00 -0500280 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800281 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Thiébaud Weksteenea528202020-08-28 15:54:29 +0200282 'REPO_PATH': rel_proj_dir,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800283 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500284 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800285 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500286 })
287
Mike Frysinger2e65c542016-03-08 16:17:00 -0500288 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700289 commit_list = rh.git.get_commits(
290 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500291
Mike Frysinger2e65c542016-03-08 16:17:00 -0500292 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800293 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700294
Mike Frysinger2e65c542016-03-08 16:17:00 -0500295 for commit in commit_list:
296 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400297 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500298 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400299 desc = rh.git.get_commit_desc(commit)
Mike Frysingerce3ab292019-08-09 17:58:50 -0400300 rh.sixish.setenv('PREUPLOAD_COMMIT_MESSAGE', desc)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500301
Josh Gao25abf4b2016-09-23 18:36:27 -0700302 commit_summary = desc.split('\n', 1)[0]
303 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500304
Thiébaud Weksteenea528202020-08-28 15:54:29 +0200305 for name, hook, exclusion_scope in hooks:
Josh Gao25abf4b2016-09-23 18:36:27 -0700306 output.hook_start(name)
Thiébaud Weksteenea528202020-08-28 15:54:29 +0200307 if rel_proj_dir in exclusion_scope:
308 break
Josh Gao25abf4b2016-09-23 18:36:27 -0700309 hook_results = hook(project, commit, desc, diff)
Jason Monk0886c912017-11-10 13:17:17 -0500310 (error, warning) = _process_hook_results(hook_results)
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400311 if error is not None or warning is not None:
312 if warning is not None:
Jason Monk0886c912017-11-10 13:17:17 -0500313 output.hook_warning(name, warning)
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400314 if error is not None:
Jason Monk0886c912017-11-10 13:17:17 -0500315 ret = False
316 output.hook_error(name, error)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800317 for result in hook_results:
318 if result.fixup_func:
319 fixup_func_list.append((name, commit,
320 result.fixup_func))
321
322 if fixup_func_list:
323 _attempt_fixes(fixup_func_list, commit_list)
Josh Gao25abf4b2016-09-23 18:36:27 -0700324
Mike Frysinger2e65c542016-03-08 16:17:00 -0500325 return ret
326
327
Mike Frysinger42234b72019-02-15 16:21:41 -0500328def _run_project_hooks(project_name, proj_dir=None, commit_list=None):
329 """Run the project-specific hooks in |proj_dir|.
330
331 Args:
332 project_name: The name of project to run hooks for.
333 proj_dir: If non-None, this is the directory the project is in. If None,
334 we'll ask repo.
335 commit_list: A list of commits to run hooks against. If None or empty
336 list then we'll automatically get the list of commits that would be
337 uploaded.
338
339 Returns:
340 False if any errors were found, else True.
341 """
342 output = Output(project_name)
343
344 if proj_dir is None:
345 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
Mike Frysinger70b78f02019-12-04 21:42:39 -0500346 result = rh.utils.run(cmd, capture_output=True)
Mike Frysinger36d2ce62019-12-04 22:17:07 -0500347 proj_dirs = result.stdout.split()
Mike Frysinger5ac20862019-06-05 22:50:49 -0400348 if not proj_dirs:
Mike Frysinger42234b72019-02-15 16:21:41 -0500349 print('%s cannot be found.' % project_name, file=sys.stderr)
350 print('Please specify a valid project.', file=sys.stderr)
351 return False
352 if len(proj_dirs) > 1:
353 print('%s is associated with multiple directories.' % project_name,
354 file=sys.stderr)
355 print('Please specify a directory to help disambiguate.',
356 file=sys.stderr)
357 return False
358 proj_dir = proj_dirs[0]
359
360 pwd = os.getcwd()
361 try:
362 # Hooks assume they are run from the root of the project.
363 os.chdir(proj_dir)
364 return _run_project_hooks_in_cwd(project_name, proj_dir, output,
365 commit_list=commit_list)
366 finally:
367 output.finish()
368 os.chdir(pwd)
369
370
Mike Frysinger2e65c542016-03-08 16:17:00 -0500371def main(project_list, worktree_list=None, **_kwargs):
372 """Main function invoked directly by repo.
373
374 We must use the name "main" as that is what repo requires.
375
376 This function will exit directly upon error so that repo doesn't print some
377 obscure error message.
378
379 Args:
380 project_list: List of projects to run on.
381 worktree_list: A list of directories. It should be the same length as
382 project_list, so that each entry in project_list matches with a
383 directory in worktree_list. If None, we will attempt to calculate
384 the directories automatically.
385 kwargs: Leave this here for forward-compatibility.
386 """
387 found_error = False
388 if not worktree_list:
389 worktree_list = [None] * len(project_list)
390 for project, worktree in zip(project_list, worktree_list):
391 if not _run_project_hooks(project, proj_dir=worktree):
392 found_error = True
Mike Frysingera18d5f12019-02-15 16:27:35 -0500393 # If a repo had failures, add a blank line to help break up the
394 # output. If there were no failures, then the output should be
395 # very minimal, so we don't add it then.
396 print('', file=sys.stderr)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500397
398 if found_error:
399 color = rh.terminal.Color()
400 print('%s: Preupload failed due to above error(s).\n'
401 'For more info, please see:\n%s' %
402 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
403 file=sys.stderr)
404 sys.exit(1)
405
406
407def _identify_project(path):
408 """Identify the repo project associated with the given path.
409
410 Returns:
411 A string indicating what project is associated with the path passed in or
412 a blank string upon failure.
413 """
414 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
Mike Frysinger7bd2a9a2020-04-08 18:23:57 -0400415 return rh.utils.run(cmd, capture_output=True, cwd=path).stdout.strip()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500416
417
418def direct_main(argv):
419 """Run hooks directly (outside of the context of repo).
420
421 Args:
422 argv: The command line args to process.
423
424 Returns:
425 0 if no pre-upload failures, 1 if failures.
426
427 Raises:
428 BadInvocation: On some types of invocation errors.
429 """
430 parser = argparse.ArgumentParser(description=__doc__)
431 parser.add_argument('--dir', default=None,
432 help='The directory that the project lives in. If not '
433 'specified, use the git project root based on the cwd.')
434 parser.add_argument('--project', default=None,
435 help='The project repo path; this can affect how the '
436 'hooks get run, since some hooks are project-specific.'
437 'If not specified, `repo` will be used to figure this '
438 'out based on the dir.')
439 parser.add_argument('commits', nargs='*',
440 help='Check specific commits')
441 opts = parser.parse_args(argv)
442
443 # Check/normalize git dir; if unspecified, we'll use the root of the git
444 # project from CWD.
445 if opts.dir is None:
446 cmd = ['git', 'rev-parse', '--git-dir']
Mike Frysinger7bd2a9a2020-04-08 18:23:57 -0400447 git_dir = rh.utils.run(cmd, capture_output=True).stdout.strip()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500448 if not git_dir:
449 parser.error('The current directory is not part of a git project.')
450 opts.dir = os.path.dirname(os.path.abspath(git_dir))
451 elif not os.path.isdir(opts.dir):
452 parser.error('Invalid dir: %s' % opts.dir)
Adrian Roos8ac865f2018-04-13 12:08:52 +0100453 elif not rh.git.is_git_repository(opts.dir):
454 parser.error('Not a git repository: %s' % opts.dir)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500455
456 # Identify the project if it wasn't specified; this _requires_ the repo
457 # tool to be installed and for the project to be part of a repo checkout.
458 if not opts.project:
459 opts.project = _identify_project(opts.dir)
460 if not opts.project:
461 parser.error("Repo couldn't identify the project of %s" % opts.dir)
462
463 if _run_project_hooks(opts.project, proj_dir=opts.dir,
464 commit_list=opts.commits):
465 return 0
Mike Frysinger5ac20862019-06-05 22:50:49 -0400466 return 1
Mike Frysinger2e65c542016-03-08 16:17:00 -0500467
468
469if __name__ == '__main__':
470 sys.exit(direct_main(sys.argv[1:]))