1#!/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 26import datetime 27import os 28import sys 29 30 31# Assert some minimum Python versions as we don't test or support any others. 32# We only support Python 2.7, and require 2.7.5+/3.4+ to include signal fix: 33# https://bugs.python.org/issue14173 34if sys.version_info < (2, 7, 5): 35 print('repohooks: error: Python-2.7.5+ is required', file=sys.stderr) 36 sys.exit(1) 37elif sys.version_info.major == 3 and sys.version_info < (3, 5): 38 print('repohooks: error: Python-3.5+ is required', file=sys.stderr) 39 sys.exit(1) 40elif sys.version_info < (3, 6): 41 # We want to get people off of old versions of Python. 42 print('repohooks: warning: Python-3.6+ is going to be required; ' 43 'please upgrade soon to maintain support.', file=sys.stderr) 44 45 46_path = os.path.dirname(os.path.realpath(__file__)) 47if sys.path[0] != _path: 48 sys.path.insert(0, _path) 49del _path 50 51# We have to import our local modules after the sys.path tweak. We can't use 52# relative imports because this is an executable program, not a module. 53# pylint: disable=wrong-import-position 54import rh 55import rh.results 56import rh.config 57import rh.git 58import rh.hooks 59import rh.sixish 60import rh.terminal 61import rh.utils 62 63 64# Repohooks homepage. 65REPOHOOKS_URL = 'https://android.googlesource.com/platform/tools/repohooks/' 66 67 68class Output(object): 69 """Class for reporting hook status.""" 70 71 COLOR = rh.terminal.Color() 72 COMMIT = COLOR.color(COLOR.CYAN, 'COMMIT') 73 RUNNING = COLOR.color(COLOR.YELLOW, 'RUNNING') 74 PASSED = COLOR.color(COLOR.GREEN, 'PASSED') 75 FAILED = COLOR.color(COLOR.RED, 'FAILED') 76 WARNING = COLOR.color(COLOR.YELLOW, 'WARNING') 77 78 def __init__(self, project_name): 79 """Create a new Output object for a specified project. 80 81 Args: 82 project_name: name of project. 83 """ 84 self.project_name = project_name 85 self.num_hooks = None 86 self.hook_index = 0 87 self.success = True 88 self.start_time = datetime.datetime.now() 89 90 def set_num_hooks(self, num_hooks): 91 """Keep track of how many hooks we'll be running. 92 93 Args: 94 num_hooks: number of hooks to be run. 95 """ 96 self.num_hooks = num_hooks 97 98 def commit_start(self, commit, commit_summary): 99 """Emit status for new commit. 100 101 Args: 102 commit: commit hash. 103 commit_summary: commit summary. 104 """ 105 status_line = '[%s %s] %s' % (self.COMMIT, commit[0:12], commit_summary) 106 rh.terminal.print_status_line(status_line, print_newline=True) 107 self.hook_index = 1 108 109 def hook_start(self, hook_name): 110 """Emit status before the start of a hook. 111 112 Args: 113 hook_name: name of the hook. 114 """ 115 status_line = '[%s %d/%d] %s' % (self.RUNNING, self.hook_index, 116 self.num_hooks, hook_name) 117 self.hook_index += 1 118 rh.terminal.print_status_line(status_line) 119 120 def hook_error(self, hook_name, error): 121 """Print an error for a single hook. 122 123 Args: 124 hook_name: name of the hook. 125 error: error string. 126 """ 127 self.error(hook_name, error) 128 129 def hook_warning(self, hook_name, warning): 130 """Print a warning for a single hook. 131 132 Args: 133 hook_name: name of the hook. 134 warning: warning string. 135 """ 136 status_line = '[%s] %s' % (self.WARNING, hook_name) 137 rh.terminal.print_status_line(status_line, print_newline=True) 138 print(warning, file=sys.stderr) 139 140 def error(self, header, error): 141 """Print a general error. 142 143 Args: 144 header: A unique identifier for the source of this error. 145 error: error string. 146 """ 147 status_line = '[%s] %s' % (self.FAILED, header) 148 rh.terminal.print_status_line(status_line, print_newline=True) 149 print(error, file=sys.stderr) 150 self.success = False 151 152 def finish(self): 153 """Print summary for all the hooks.""" 154 status_line = '[%s] repohooks for %s %s in %s' % ( 155 self.PASSED if self.success else self.FAILED, 156 self.project_name, 157 'passed' if self.success else 'failed', 158 rh.utils.timedelta_str(datetime.datetime.now() - self.start_time)) 159 rh.terminal.print_status_line(status_line, print_newline=True) 160 161 162def _process_hook_results(results): 163 """Returns an error string if an error occurred. 164 165 Args: 166 results: A list of HookResult objects, or None. 167 168 Returns: 169 error output if an error occurred, otherwise None 170 warning output if an error occurred, otherwise None 171 """ 172 if not results: 173 return (None, None) 174 175 # We track these as dedicated fields in case a hook doesn't output anything. 176 # We want to treat silent non-zero exits as failures too. 177 has_error = False 178 has_warning = False 179 180 error_ret = '' 181 warning_ret = '' 182 for result in results: 183 if result: 184 ret = '' 185 if result.files: 186 ret += ' FILES: %s' % (result.files,) 187 lines = result.error.splitlines() 188 ret += '\n'.join(' %s' % (x,) for x in lines) 189 if result.is_warning(): 190 has_warning = True 191 warning_ret += ret 192 else: 193 has_error = True 194 error_ret += ret 195 196 return (error_ret if has_error else None, 197 warning_ret if has_warning else None) 198 199 200def _get_project_config(): 201 """Returns the configuration for a project. 202 203 Expects to be called from within the project root. 204 """ 205 global_paths = ( 206 # Load the global config found in the manifest repo. 207 os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'), 208 # Load the global config found in the root of the repo checkout. 209 rh.git.find_repo_root(), 210 ) 211 paths = ( 212 # Load the config for this git repo. 213 '.', 214 ) 215 return rh.config.PreUploadConfig(paths=paths, global_paths=global_paths) 216 217 218def _attempt_fixes(fixup_func_list, commit_list): 219 """Attempts to run |fixup_func_list| given |commit_list|.""" 220 if len(fixup_func_list) != 1: 221 # Only single fixes will be attempted, since various fixes might 222 # interact with each other. 223 return 224 225 hook_name, commit, fixup_func = fixup_func_list[0] 226 227 if commit != commit_list[0]: 228 # If the commit is not at the top of the stack, git operations might be 229 # needed and might leave the working directory in a tricky state if the 230 # fix is attempted to run automatically (e.g. it might require manual 231 # merge conflict resolution). Refuse to run the fix in those cases. 232 return 233 234 prompt = ('An automatic fix can be attempted for the "%s" hook. ' 235 'Do you want to run it?' % hook_name) 236 if not rh.terminal.boolean_prompt(prompt): 237 return 238 239 result = fixup_func() 240 if result: 241 print('Attempt to fix "%s" for commit "%s" failed: %s' % 242 (hook_name, commit, result), 243 file=sys.stderr) 244 else: 245 print('Fix successfully applied. Amend the current commit before ' 246 'attempting to upload again.\n', file=sys.stderr) 247 248 249def _run_project_hooks_in_cwd(project_name, proj_dir, output, commit_list=None): 250 """Run the project-specific hooks in the cwd. 251 252 Args: 253 project_name: The name of this project. 254 proj_dir: The directory for this project (for passing on in metadata). 255 output: Helper for summarizing output/errors to the user. 256 commit_list: A list of commits to run hooks against. If None or empty 257 list then we'll automatically get the list of commits that would be 258 uploaded. 259 260 Returns: 261 False if any errors were found, else True. 262 """ 263 try: 264 config = _get_project_config() 265 except rh.config.ValidationError as e: 266 output.error('Loading config files', str(e)) 267 return False 268 269 # If the repo has no pre-upload hooks enabled, then just return. 270 hooks = list(config.callable_hooks()) 271 if not hooks: 272 return True 273 274 output.set_num_hooks(len(hooks)) 275 276 # Set up the environment like repo would with the forall command. 277 try: 278 remote = rh.git.get_upstream_remote() 279 upstream_branch = rh.git.get_upstream_branch() 280 except rh.utils.CalledProcessError as e: 281 output.error('Upstream remote/tracking branch lookup', 282 '%s\nDid you run repo start? Is your HEAD detached?' % 283 (e,)) 284 return False 285 286 os.environ.update({ 287 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch), 288 'REPO_PATH': os.path.relpath(proj_dir, rh.git.find_repo_root()), 289 'REPO_PROJECT': project_name, 290 'REPO_REMOTE': remote, 291 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote), 292 }) 293 294 project = rh.Project(name=project_name, dir=proj_dir, remote=remote) 295 296 if not commit_list: 297 commit_list = rh.git.get_commits( 298 ignore_merged_commits=config.ignore_merged_commits) 299 300 ret = True 301 fixup_func_list = [] 302 303 for commit in commit_list: 304 # Mix in some settings for our hooks. 305 os.environ['PREUPLOAD_COMMIT'] = commit 306 diff = rh.git.get_affected_files(commit) 307 desc = rh.git.get_commit_desc(commit) 308 rh.sixish.setenv('PREUPLOAD_COMMIT_MESSAGE', desc) 309 310 commit_summary = desc.split('\n', 1)[0] 311 output.commit_start(commit=commit, commit_summary=commit_summary) 312 313 for name, hook in hooks: 314 output.hook_start(name) 315 hook_results = hook(project, commit, desc, diff) 316 (error, warning) = _process_hook_results(hook_results) 317 if error is not None or warning is not None: 318 if warning is not None: 319 output.hook_warning(name, warning) 320 if error is not None: 321 ret = False 322 output.hook_error(name, error) 323 for result in hook_results: 324 if result.fixup_func: 325 fixup_func_list.append((name, commit, 326 result.fixup_func)) 327 328 if fixup_func_list: 329 _attempt_fixes(fixup_func_list, commit_list) 330 331 return ret 332 333 334def _run_project_hooks(project_name, proj_dir=None, commit_list=None): 335 """Run the project-specific hooks in |proj_dir|. 336 337 Args: 338 project_name: The name of project to run hooks for. 339 proj_dir: If non-None, this is the directory the project is in. If None, 340 we'll ask repo. 341 commit_list: A list of commits to run hooks against. If None or empty 342 list then we'll automatically get the list of commits that would be 343 uploaded. 344 345 Returns: 346 False if any errors were found, else True. 347 """ 348 output = Output(project_name) 349 350 if proj_dir is None: 351 cmd = ['repo', 'forall', project_name, '-c', 'pwd'] 352 result = rh.utils.run(cmd, capture_output=True) 353 proj_dirs = result.stdout.split() 354 if not proj_dirs: 355 print('%s cannot be found.' % project_name, file=sys.stderr) 356 print('Please specify a valid project.', file=sys.stderr) 357 return False 358 if len(proj_dirs) > 1: 359 print('%s is associated with multiple directories.' % project_name, 360 file=sys.stderr) 361 print('Please specify a directory to help disambiguate.', 362 file=sys.stderr) 363 return False 364 proj_dir = proj_dirs[0] 365 366 pwd = os.getcwd() 367 try: 368 # Hooks assume they are run from the root of the project. 369 os.chdir(proj_dir) 370 return _run_project_hooks_in_cwd(project_name, proj_dir, output, 371 commit_list=commit_list) 372 finally: 373 output.finish() 374 os.chdir(pwd) 375 376 377def main(project_list, worktree_list=None, **_kwargs): 378 """Main function invoked directly by repo. 379 380 We must use the name "main" as that is what repo requires. 381 382 This function will exit directly upon error so that repo doesn't print some 383 obscure error message. 384 385 Args: 386 project_list: List of projects to run on. 387 worktree_list: A list of directories. It should be the same length as 388 project_list, so that each entry in project_list matches with a 389 directory in worktree_list. If None, we will attempt to calculate 390 the directories automatically. 391 kwargs: Leave this here for forward-compatibility. 392 """ 393 found_error = False 394 if not worktree_list: 395 worktree_list = [None] * len(project_list) 396 for project, worktree in zip(project_list, worktree_list): 397 if not _run_project_hooks(project, proj_dir=worktree): 398 found_error = True 399 # If a repo had failures, add a blank line to help break up the 400 # output. If there were no failures, then the output should be 401 # very minimal, so we don't add it then. 402 print('', file=sys.stderr) 403 404 if found_error: 405 color = rh.terminal.Color() 406 print('%s: Preupload failed due to above error(s).\n' 407 'For more info, please see:\n%s' % 408 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL), 409 file=sys.stderr) 410 sys.exit(1) 411 412 413def _identify_project(path): 414 """Identify the repo project associated with the given path. 415 416 Returns: 417 A string indicating what project is associated with the path passed in or 418 a blank string upon failure. 419 """ 420 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}'] 421 return rh.utils.run(cmd, capture_output=True, cwd=path).stdout.strip() 422 423 424def direct_main(argv): 425 """Run hooks directly (outside of the context of repo). 426 427 Args: 428 argv: The command line args to process. 429 430 Returns: 431 0 if no pre-upload failures, 1 if failures. 432 433 Raises: 434 BadInvocation: On some types of invocation errors. 435 """ 436 parser = argparse.ArgumentParser(description=__doc__) 437 parser.add_argument('--dir', default=None, 438 help='The directory that the project lives in. If not ' 439 'specified, use the git project root based on the cwd.') 440 parser.add_argument('--project', default=None, 441 help='The project repo path; this can affect how the ' 442 'hooks get run, since some hooks are project-specific.' 443 'If not specified, `repo` will be used to figure this ' 444 'out based on the dir.') 445 parser.add_argument('commits', nargs='*', 446 help='Check specific commits') 447 opts = parser.parse_args(argv) 448 449 # Check/normalize git dir; if unspecified, we'll use the root of the git 450 # project from CWD. 451 if opts.dir is None: 452 cmd = ['git', 'rev-parse', '--git-dir'] 453 git_dir = rh.utils.run(cmd, capture_output=True).stdout.strip() 454 if not git_dir: 455 parser.error('The current directory is not part of a git project.') 456 opts.dir = os.path.dirname(os.path.abspath(git_dir)) 457 elif not os.path.isdir(opts.dir): 458 parser.error('Invalid dir: %s' % opts.dir) 459 elif not rh.git.is_git_repository(opts.dir): 460 parser.error('Not a git repository: %s' % opts.dir) 461 462 # Identify the project if it wasn't specified; this _requires_ the repo 463 # tool to be installed and for the project to be part of a repo checkout. 464 if not opts.project: 465 opts.project = _identify_project(opts.dir) 466 if not opts.project: 467 parser.error("Repo couldn't identify the project of %s" % opts.dir) 468 469 if _run_project_hooks(opts.project, proj_dir=opts.dir, 470 commit_list=opts.commits): 471 return 0 472 return 1 473 474 475if __name__ == '__main__': 476 sys.exit(direct_main(sys.argv[1:])) 477