blob: eaf611e12542e33ff9003955d02fe47f31ac2ece [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
50import rh.terminal
51import rh.utils
52
53
54# Repohooks homepage.
55REPOHOOKS_URL = 'https://android.googlesource.com/platform/tools/repohooks/'
56
57
Josh Gao25abf4b2016-09-23 18:36:27 -070058class Output(object):
59 """Class for reporting hook status."""
60
61 COLOR = rh.terminal.Color()
62 COMMIT = COLOR.color(COLOR.CYAN, 'COMMIT')
63 RUNNING = COLOR.color(COLOR.YELLOW, 'RUNNING')
64 PASSED = COLOR.color(COLOR.GREEN, 'PASSED')
65 FAILED = COLOR.color(COLOR.RED, 'FAILED')
Jason Monk0886c912017-11-10 13:17:17 -050066 WARNING = COLOR.color(COLOR.YELLOW, 'WARNING')
Josh Gao25abf4b2016-09-23 18:36:27 -070067
Mike Frysinger42234b72019-02-15 16:21:41 -050068 def __init__(self, project_name):
Josh Gao25abf4b2016-09-23 18:36:27 -070069 """Create a new Output object for a specified project.
70
71 Args:
72 project_name: name of project.
Josh Gao25abf4b2016-09-23 18:36:27 -070073 """
74 self.project_name = project_name
Mike Frysinger42234b72019-02-15 16:21:41 -050075 self.num_hooks = None
Josh Gao25abf4b2016-09-23 18:36:27 -070076 self.hook_index = 0
77 self.success = True
Mike Frysinger579111e2019-12-04 21:36:01 -050078 self.start_time = datetime.datetime.now()
Josh Gao25abf4b2016-09-23 18:36:27 -070079
Mike Frysinger42234b72019-02-15 16:21:41 -050080 def set_num_hooks(self, num_hooks):
81 """Keep track of how many hooks we'll be running.
82
83 Args:
84 num_hooks: number of hooks to be run.
85 """
86 self.num_hooks = num_hooks
87
Josh Gao25abf4b2016-09-23 18:36:27 -070088 def commit_start(self, commit, commit_summary):
89 """Emit status for new commit.
90
91 Args:
92 commit: commit hash.
93 commit_summary: commit summary.
94 """
95 status_line = '[%s %s] %s' % (self.COMMIT, commit[0:12], commit_summary)
96 rh.terminal.print_status_line(status_line, print_newline=True)
97 self.hook_index = 1
98
99 def hook_start(self, hook_name):
100 """Emit status before the start of a hook.
101
102 Args:
103 hook_name: name of the hook.
104 """
105 status_line = '[%s %d/%d] %s' % (self.RUNNING, self.hook_index,
106 self.num_hooks, hook_name)
107 self.hook_index += 1
108 rh.terminal.print_status_line(status_line)
109
110 def hook_error(self, hook_name, error):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500111 """Print an error for a single hook.
Josh Gao25abf4b2016-09-23 18:36:27 -0700112
113 Args:
114 hook_name: name of the hook.
115 error: error string.
116 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500117 self.error(hook_name, error)
Josh Gao25abf4b2016-09-23 18:36:27 -0700118
Jason Monk0886c912017-11-10 13:17:17 -0500119 def hook_warning(self, hook_name, warning):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500120 """Print a warning for a single hook.
Jason Monk0886c912017-11-10 13:17:17 -0500121
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
Mike Frysingera18d5f12019-02-15 16:27:35 -0500130 def error(self, header, error):
131 """Print a general error.
132
133 Args:
134 header: A unique identifier for the source of this error.
135 error: error string.
136 """
137 status_line = '[%s] %s' % (self.FAILED, header)
138 rh.terminal.print_status_line(status_line, print_newline=True)
139 print(error, file=sys.stderr)
140 self.success = False
141
Josh Gao25abf4b2016-09-23 18:36:27 -0700142 def finish(self):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500143 """Print summary for all the hooks."""
Mike Frysinger579111e2019-12-04 21:36:01 -0500144 status_line = '[%s] repohooks for %s %s in %s' % (
Josh Gao25abf4b2016-09-23 18:36:27 -0700145 self.PASSED if self.success else self.FAILED,
146 self.project_name,
Mike Frysinger579111e2019-12-04 21:36:01 -0500147 'passed' if self.success else 'failed',
148 rh.utils.timedelta_str(datetime.datetime.now() - self.start_time))
Josh Gao25abf4b2016-09-23 18:36:27 -0700149 rh.terminal.print_status_line(status_line, print_newline=True)
150
151
152def _process_hook_results(results):
153 """Returns an error string if an error occurred.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500154
155 Args:
Josh Gao25abf4b2016-09-23 18:36:27 -0700156 results: A list of HookResult objects, or None.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500157
158 Returns:
Josh Gao25abf4b2016-09-23 18:36:27 -0700159 error output if an error occurred, otherwise None
Jason Monk0886c912017-11-10 13:17:17 -0500160 warning output if an error occurred, otherwise None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500161 """
Josh Gao25abf4b2016-09-23 18:36:27 -0700162 if not results:
Jason Monk0886c912017-11-10 13:17:17 -0500163 return (None, None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500164
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400165 # We track these as dedicated fields in case a hook doesn't output anything.
166 # We want to treat silent non-zero exits as failures too.
167 has_error = False
168 has_warning = False
169
Jason Monk0886c912017-11-10 13:17:17 -0500170 error_ret = ''
171 warning_ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500172 for result in results:
173 if result:
Jason Monk0886c912017-11-10 13:17:17 -0500174 ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500175 if result.files:
Josh Gao25abf4b2016-09-23 18:36:27 -0700176 ret += ' FILES: %s' % (result.files,)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500177 lines = result.error.splitlines()
Josh Gao25abf4b2016-09-23 18:36:27 -0700178 ret += '\n'.join(' %s' % (x,) for x in lines)
Jason Monk0886c912017-11-10 13:17:17 -0500179 if result.is_warning():
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400180 has_warning = True
Jason Monk0886c912017-11-10 13:17:17 -0500181 warning_ret += ret
182 else:
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400183 has_error = True
Jason Monk0886c912017-11-10 13:17:17 -0500184 error_ret += ret
Mike Frysinger2e65c542016-03-08 16:17:00 -0500185
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400186 return (error_ret if has_error else None,
187 warning_ret if has_warning else None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500188
189
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700190def _get_project_config():
191 """Returns the configuration for a project.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500192
193 Expects to be called from within the project root.
194 """
Mike Frysingerca797702016-09-03 02:00:55 -0400195 global_paths = (
196 # Load the global config found in the manifest repo.
197 os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'),
198 # Load the global config found in the root of the repo checkout.
199 rh.git.find_repo_root(),
200 )
201 paths = (
202 # Load the config for this git repo.
203 '.',
204 )
Mike Frysinger1baec122020-08-25 00:27:52 -0400205 return rh.config.PreUploadSettings(paths=paths, global_paths=global_paths)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500206
207
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800208def _attempt_fixes(fixup_func_list, commit_list):
209 """Attempts to run |fixup_func_list| given |commit_list|."""
210 if len(fixup_func_list) != 1:
211 # Only single fixes will be attempted, since various fixes might
212 # interact with each other.
213 return
214
215 hook_name, commit, fixup_func = fixup_func_list[0]
216
217 if commit != commit_list[0]:
218 # If the commit is not at the top of the stack, git operations might be
219 # needed and might leave the working directory in a tricky state if the
220 # fix is attempted to run automatically (e.g. it might require manual
221 # merge conflict resolution). Refuse to run the fix in those cases.
222 return
223
224 prompt = ('An automatic fix can be attempted for the "%s" hook. '
225 'Do you want to run it?' % hook_name)
226 if not rh.terminal.boolean_prompt(prompt):
227 return
228
229 result = fixup_func()
230 if result:
231 print('Attempt to fix "%s" for commit "%s" failed: %s' %
232 (hook_name, commit, result),
233 file=sys.stderr)
234 else:
235 print('Fix successfully applied. Amend the current commit before '
236 'attempting to upload again.\n', file=sys.stderr)
237
238
Mike Frysinger42234b72019-02-15 16:21:41 -0500239def _run_project_hooks_in_cwd(project_name, proj_dir, output, commit_list=None):
240 """Run the project-specific hooks in the cwd.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500241
242 Args:
Mike Frysinger42234b72019-02-15 16:21:41 -0500243 project_name: The name of this project.
244 proj_dir: The directory for this project (for passing on in metadata).
245 output: Helper for summarizing output/errors to the user.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500246 commit_list: A list of commits to run hooks against. If None or empty
247 list then we'll automatically get the list of commits that would be
248 uploaded.
249
250 Returns:
251 False if any errors were found, else True.
252 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500253 try:
254 config = _get_project_config()
255 except rh.config.ValidationError as e:
256 output.error('Loading config files', str(e))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500257 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500258
259 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700260 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400261 if not hooks:
262 return True
263
Mike Frysinger42234b72019-02-15 16:21:41 -0500264 output.set_num_hooks(len(hooks))
265
Mike Frysinger2e65c542016-03-08 16:17:00 -0500266 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700267 try:
268 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800269 upstream_branch = rh.git.get_upstream_branch()
Mike Frysinger36d2ce62019-12-04 22:17:07 -0500270 except rh.utils.CalledProcessError as e:
Mike Frysingera18d5f12019-02-15 16:27:35 -0500271 output.error('Upstream remote/tracking branch lookup',
272 '%s\nDid you run repo start? Is your HEAD detached?' %
273 (e,))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500274 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500275
Thiébaud Weksteenea528202020-08-28 15:54:29 +0200276 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
277 rel_proj_dir = os.path.relpath(proj_dir, rh.git.find_repo_root())
278
Mike Frysinger2e65c542016-03-08 16:17:00 -0500279 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800280 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Thiébaud Weksteenea528202020-08-28 15:54:29 +0200281 'REPO_PATH': rel_proj_dir,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800282 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500283 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800284 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500285 })
286
Mike Frysinger2e65c542016-03-08 16:17:00 -0500287 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 Frysinger737bf272020-07-15 00:55:02 -0400299 os.environ['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
Thiébaud Weksteenea528202020-08-28 15:54:29 +0200304 for name, hook, exclusion_scope in hooks:
Josh Gao25abf4b2016-09-23 18:36:27 -0700305 output.hook_start(name)
Thiébaud Weksteenea528202020-08-28 15:54:29 +0200306 if rel_proj_dir in exclusion_scope:
307 break
Josh Gao25abf4b2016-09-23 18:36:27 -0700308 hook_results = hook(project, commit, desc, diff)
Jason Monk0886c912017-11-10 13:17:17 -0500309 (error, warning) = _process_hook_results(hook_results)
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400310 if error is not None or warning is not None:
311 if warning is not None:
Jason Monk0886c912017-11-10 13:17:17 -0500312 output.hook_warning(name, warning)
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400313 if error is not None:
Jason Monk0886c912017-11-10 13:17:17 -0500314 ret = False
315 output.hook_error(name, error)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800316 for result in hook_results:
317 if result.fixup_func:
318 fixup_func_list.append((name, commit,
319 result.fixup_func))
320
321 if fixup_func_list:
322 _attempt_fixes(fixup_func_list, commit_list)
Josh Gao25abf4b2016-09-23 18:36:27 -0700323
Mike Frysinger2e65c542016-03-08 16:17:00 -0500324 return ret
325
326
Mike Frysinger42234b72019-02-15 16:21:41 -0500327def _run_project_hooks(project_name, proj_dir=None, commit_list=None):
328 """Run the project-specific hooks in |proj_dir|.
329
330 Args:
331 project_name: The name of project to run hooks for.
332 proj_dir: If non-None, this is the directory the project is in. If None,
333 we'll ask repo.
334 commit_list: A list of commits to run hooks against. If None or empty
335 list then we'll automatically get the list of commits that would be
336 uploaded.
337
338 Returns:
339 False if any errors were found, else True.
340 """
341 output = Output(project_name)
342
343 if proj_dir is None:
344 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
Mike Frysinger70b78f02019-12-04 21:42:39 -0500345 result = rh.utils.run(cmd, capture_output=True)
Mike Frysinger36d2ce62019-12-04 22:17:07 -0500346 proj_dirs = result.stdout.split()
Mike Frysinger5ac20862019-06-05 22:50:49 -0400347 if not proj_dirs:
Mike Frysinger42234b72019-02-15 16:21:41 -0500348 print('%s cannot be found.' % project_name, file=sys.stderr)
349 print('Please specify a valid project.', file=sys.stderr)
350 return False
351 if len(proj_dirs) > 1:
352 print('%s is associated with multiple directories.' % project_name,
353 file=sys.stderr)
354 print('Please specify a directory to help disambiguate.',
355 file=sys.stderr)
356 return False
357 proj_dir = proj_dirs[0]
358
359 pwd = os.getcwd()
360 try:
361 # Hooks assume they are run from the root of the project.
362 os.chdir(proj_dir)
363 return _run_project_hooks_in_cwd(project_name, proj_dir, output,
364 commit_list=commit_list)
365 finally:
366 output.finish()
367 os.chdir(pwd)
368
369
Mike Frysinger2e65c542016-03-08 16:17:00 -0500370def main(project_list, worktree_list=None, **_kwargs):
371 """Main function invoked directly by repo.
372
373 We must use the name "main" as that is what repo requires.
374
375 This function will exit directly upon error so that repo doesn't print some
376 obscure error message.
377
378 Args:
379 project_list: List of projects to run on.
380 worktree_list: A list of directories. It should be the same length as
381 project_list, so that each entry in project_list matches with a
382 directory in worktree_list. If None, we will attempt to calculate
383 the directories automatically.
384 kwargs: Leave this here for forward-compatibility.
385 """
386 found_error = False
387 if not worktree_list:
388 worktree_list = [None] * len(project_list)
389 for project, worktree in zip(project_list, worktree_list):
390 if not _run_project_hooks(project, proj_dir=worktree):
391 found_error = True
Mike Frysingera18d5f12019-02-15 16:27:35 -0500392 # If a repo had failures, add a blank line to help break up the
393 # output. If there were no failures, then the output should be
394 # very minimal, so we don't add it then.
395 print('', file=sys.stderr)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500396
397 if found_error:
398 color = rh.terminal.Color()
399 print('%s: Preupload failed due to above error(s).\n'
400 'For more info, please see:\n%s' %
401 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
402 file=sys.stderr)
403 sys.exit(1)
404
405
406def _identify_project(path):
407 """Identify the repo project associated with the given path.
408
409 Returns:
410 A string indicating what project is associated with the path passed in or
411 a blank string upon failure.
412 """
413 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
Mike Frysinger7bd2a9a2020-04-08 18:23:57 -0400414 return rh.utils.run(cmd, capture_output=True, cwd=path).stdout.strip()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500415
416
417def direct_main(argv):
418 """Run hooks directly (outside of the context of repo).
419
420 Args:
421 argv: The command line args to process.
422
423 Returns:
424 0 if no pre-upload failures, 1 if failures.
425
426 Raises:
427 BadInvocation: On some types of invocation errors.
428 """
429 parser = argparse.ArgumentParser(description=__doc__)
430 parser.add_argument('--dir', default=None,
431 help='The directory that the project lives in. If not '
432 'specified, use the git project root based on the cwd.')
433 parser.add_argument('--project', default=None,
434 help='The project repo path; this can affect how the '
435 'hooks get run, since some hooks are project-specific.'
436 'If not specified, `repo` will be used to figure this '
437 'out based on the dir.')
438 parser.add_argument('commits', nargs='*',
439 help='Check specific commits')
440 opts = parser.parse_args(argv)
441
442 # Check/normalize git dir; if unspecified, we'll use the root of the git
443 # project from CWD.
444 if opts.dir is None:
445 cmd = ['git', 'rev-parse', '--git-dir']
Mike Frysinger7bd2a9a2020-04-08 18:23:57 -0400446 git_dir = rh.utils.run(cmd, capture_output=True).stdout.strip()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500447 if not git_dir:
448 parser.error('The current directory is not part of a git project.')
449 opts.dir = os.path.dirname(os.path.abspath(git_dir))
450 elif not os.path.isdir(opts.dir):
451 parser.error('Invalid dir: %s' % opts.dir)
Adrian Roos8ac865f2018-04-13 12:08:52 +0100452 elif not rh.git.is_git_repository(opts.dir):
453 parser.error('Not a git repository: %s' % opts.dir)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500454
455 # Identify the project if it wasn't specified; this _requires_ the repo
456 # tool to be installed and for the project to be part of a repo checkout.
457 if not opts.project:
458 opts.project = _identify_project(opts.dir)
459 if not opts.project:
460 parser.error("Repo couldn't identify the project of %s" % opts.dir)
461
462 if _run_project_hooks(opts.project, proj_dir=opts.dir,
463 commit_list=opts.commits):
464 return 0
Mike Frysinger5ac20862019-06-05 22:50:49 -0400465 return 1
Mike Frysinger2e65c542016-03-08 16:17:00 -0500466
467
468if __name__ == '__main__':
469 sys.exit(direct_main(sys.argv[1:]))