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