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 concurrent.futures 24import datetime 25import os 26import signal 27import sys 28from typing import List, Optional 29 30 31# Assert some minimum Python versions as we don't test or support any others. 32# See README.md for what version we may require. 33if sys.version_info < (3, 6): 34 print('repohooks: error: Python-3.6+ is required', file=sys.stderr) 35 sys.exit(1) 36 37 38_path = os.path.dirname(os.path.realpath(__file__)) 39if sys.path[0] != _path: 40 sys.path.insert(0, _path) 41del _path 42 43# We have to import our local modules after the sys.path tweak. We can't use 44# relative imports because this is an executable program, not a module. 45# pylint: disable=wrong-import-position 46import rh 47import rh.results 48import rh.config 49import rh.git 50import rh.hooks 51import rh.terminal 52import rh.utils 53 54 55# Repohooks homepage. 56REPOHOOKS_URL = 'https://android.googlesource.com/platform/tools/repohooks/' 57 58 59class 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') 67 WARNING = COLOR.color(COLOR.YELLOW, 'WARNING') 68 FIXUP = COLOR.color(COLOR.MAGENTA, 'FIXUP') 69 70 # How long a hook is allowed to run before we warn that it is "too slow". 71 _SLOW_HOOK_DURATION = datetime.timedelta(seconds=30) 72 73 def __init__(self, project_name): 74 """Create a new Output object for a specified project. 75 76 Args: 77 project_name: name of project. 78 """ 79 self.project_name = project_name 80 self.hooks = None 81 self.num_hooks = None 82 self.num_commits = None 83 self.commit_index = 0 84 self.success = True 85 self.start_time = datetime.datetime.now() 86 self.hook_start_time = None 87 # Cache number of invisible characters in our banner. 88 self._banner_esc_chars = len(self.COLOR.color(self.COLOR.YELLOW, '')) 89 90 def set_num_commits(self, num_commits: int) -> None: 91 """Keep track of how many commits we'll be running. 92 93 Args: 94 num_commits: Number of commits to be run. 95 """ 96 self.num_commits = num_commits 97 self.commit_index = 1 98 99 def commit_start(self, hooks, commit, commit_summary): 100 """Emit status for new commit. 101 102 Args: 103 hooks: All the hooks to be run for this commit. 104 commit: commit hash. 105 commit_summary: commit summary. 106 """ 107 status_line = ( 108 f'[{self.COMMIT} ' 109 f'{self.commit_index}/{self.num_commits} ' 110 f'{commit[0:12]}] {commit_summary}' 111 ) 112 rh.terminal.print_status_line(status_line, print_newline=True) 113 self.commit_index += 1 114 115 # Initialize the pending hooks line too. 116 self.hooks = set(hooks) 117 self.num_hooks = len(hooks) 118 self.hook_banner() 119 120 def hook_banner(self): 121 """Display the banner for current set of hooks.""" 122 pending = ', '.join(x.name for x in self.hooks) 123 status_line = ( 124 f'[{self.RUNNING} ' 125 f'{self.num_hooks - len(self.hooks)}/{self.num_hooks}] ' 126 f'{pending}' 127 ) 128 if self._banner_esc_chars and sys.stderr.isatty(): 129 cols = os.get_terminal_size(sys.stderr.fileno()).columns 130 status_line = status_line[0:cols + self._banner_esc_chars] 131 rh.terminal.print_status_line(status_line) 132 133 def hook_finish(self, hook, duration): 134 """Finish processing any per-hook state.""" 135 self.hooks.remove(hook) 136 if duration >= self._SLOW_HOOK_DURATION: 137 d = rh.utils.timedelta_str(duration) 138 self.hook_warning( 139 hook, 140 f'This hook took {d} to finish which is fairly slow for ' 141 'developers.\nPlease consider moving the check to the ' 142 'server/CI system instead.') 143 144 # Show any hooks still pending. 145 if self.hooks: 146 self.hook_banner() 147 148 def hook_error(self, hook, error): 149 """Print an error for a single hook. 150 151 Args: 152 hook: The hook that generated the output. 153 error: error string. 154 """ 155 self.error(f'{hook.name} hook', error) 156 157 def hook_warning(self, hook, warning): 158 """Print a warning for a single hook. 159 160 Args: 161 hook: The hook that generated the output. 162 warning: warning string. 163 """ 164 status_line = f'[{self.WARNING}] {hook.name}' 165 rh.terminal.print_status_line(status_line, print_newline=True) 166 print(warning, file=sys.stderr) 167 168 def error(self, header, error): 169 """Print a general error. 170 171 Args: 172 header: A unique identifier for the source of this error. 173 error: error string. 174 """ 175 status_line = f'[{self.FAILED}] {header}' 176 rh.terminal.print_status_line(status_line, print_newline=True) 177 print(error, file=sys.stderr) 178 self.success = False 179 180 def hook_fixups( 181 self, 182 project_results: rh.results.ProjectResults, 183 hook_results: List[rh.results.HookResult], 184 ) -> None: 185 """Display summary of possible fixups for a single hook.""" 186 for result in (x for x in hook_results if x.fixup_cmd): 187 cmd = result.fixup_cmd + list(result.files) 188 for line in ( 189 f'[{self.FIXUP}] {result.hook} has automated fixups available', 190 f' cd {rh.shell.quote(project_results.workdir)} && \\', 191 f' {rh.shell.cmd_to_str(cmd)}', 192 ): 193 rh.terminal.print_status_line(line, print_newline=True) 194 195 def finish(self): 196 """Print summary for all the hooks.""" 197 header = self.PASSED if self.success else self.FAILED 198 status = 'passed' if self.success else 'failed' 199 d = rh.utils.timedelta_str(datetime.datetime.now() - self.start_time) 200 rh.terminal.print_status_line( 201 f'[{header}] repohooks for {self.project_name} {status} in {d}', 202 print_newline=True) 203 204 205def _process_hook_results(results): 206 """Returns an error string if an error occurred. 207 208 Args: 209 results: A list of HookResult objects, or None. 210 211 Returns: 212 error output if an error occurred, otherwise None 213 warning output if an error occurred, otherwise None 214 """ 215 if not results: 216 return (None, None) 217 218 # We track these as dedicated fields in case a hook doesn't output anything. 219 # We want to treat silent non-zero exits as failures too. 220 has_error = False 221 has_warning = False 222 223 error_ret = '' 224 warning_ret = '' 225 for result in results: 226 if result or result.is_warning(): 227 ret = '' 228 if result.files: 229 ret += f' FILES: {rh.shell.cmd_to_str(result.files)}\n' 230 lines = result.error.splitlines() 231 ret += '\n'.join(f' {x}' for x in lines) 232 if result.is_warning(): 233 has_warning = True 234 warning_ret += ret 235 else: 236 has_error = True 237 error_ret += ret 238 239 return (error_ret if has_error else None, 240 warning_ret if has_warning else None) 241 242 243def _get_project_config(from_git=False): 244 """Returns the configuration for a project. 245 246 Args: 247 from_git: If true, we are called from git directly and repo should not be 248 used. 249 Expects to be called from within the project root. 250 """ 251 if from_git: 252 global_paths = (rh.git.find_repo_root(),) 253 else: 254 global_paths = ( 255 # Load the global config found in the manifest repo. 256 (os.path.join(rh.git.find_repo_root(), '.repo', 'manifests')), 257 # Load the global config found in the root of the repo checkout. 258 rh.git.find_repo_root(), 259 ) 260 261 paths = ( 262 # Load the config for this git repo. 263 '.', 264 ) 265 return rh.config.PreUploadSettings(paths=paths, global_paths=global_paths) 266 267 268def _attempt_fixes(projects_results: List[rh.results.ProjectResults]) -> None: 269 """Attempts to fix fixable results.""" 270 # Filter out any result that has a fixup. 271 fixups = [] 272 for project_results in projects_results: 273 fixups.extend((project_results.workdir, x) 274 for x in project_results.fixups) 275 if not fixups: 276 return 277 278 if len(fixups) > 1: 279 banner = f'Multiple fixups ({len(fixups)}) are available.' 280 else: 281 banner = 'Automated fixups are available.' 282 print(Output.COLOR.color(Output.COLOR.MAGENTA, banner), file=sys.stderr) 283 284 # If there's more than one fixup available, ask if they want to blindly run 285 # them all, or prompt for them one-by-one. 286 mode = 'some' 287 if len(fixups) > 1: 288 while True: 289 response = rh.terminal.str_prompt( 290 'What would you like to do', 291 ('Run (A)ll', 'Run (S)ome', '(D)ry-run', '(N)othing [default]')) 292 if not response: 293 print('', file=sys.stderr) 294 return 295 if response.startswith('a') or response.startswith('y'): 296 mode = 'all' 297 break 298 elif response.startswith('s'): 299 mode = 'some' 300 break 301 elif response.startswith('d'): 302 mode = 'dry-run' 303 break 304 elif response.startswith('n'): 305 print('', file=sys.stderr) 306 return 307 308 # Walk all the fixups and run them one-by-one. 309 for workdir, result in fixups: 310 if mode == 'some': 311 if not rh.terminal.boolean_prompt( 312 f'Run {result.hook} fixup for {result.commit}' 313 ): 314 continue 315 316 cmd = tuple(result.fixup_cmd) + tuple(result.files) 317 print( 318 f'\n[{Output.RUNNING}] cd {rh.shell.quote(workdir)} && ' 319 f'{rh.shell.cmd_to_str(cmd)}', file=sys.stderr) 320 if mode == 'dry-run': 321 continue 322 323 cmd_result = rh.utils.run(cmd, cwd=workdir, check=False) 324 if cmd_result.returncode: 325 print(f'[{Output.WARNING}] command exited {cmd_result.returncode}', 326 file=sys.stderr) 327 else: 328 print(f'[{Output.PASSED}] great success', file=sys.stderr) 329 330 print(f'\n[{Output.FIXUP}] Please amend & rebase your tree before ' 331 'attempting to upload again.\n', file=sys.stderr) 332 333def _run_project_hooks_in_cwd( 334 project_name: str, 335 proj_dir: str, 336 output: Output, 337 jobs: Optional[int] = None, 338 from_git: bool = False, 339 commit_list: Optional[List[str]] = None, 340) -> rh.results.ProjectResults: 341 """Run the project-specific hooks in the cwd. 342 343 Args: 344 project_name: The name of this project. 345 proj_dir: The directory for this project (for passing on in metadata). 346 output: Helper for summarizing output/errors to the user. 347 jobs: How many hooks to run in parallel. 348 from_git: If true, we are called from git directly and repo should not be 349 used. 350 commit_list: A list of commits to run hooks against. If None or empty 351 list then we'll automatically get the list of commits that would be 352 uploaded. 353 354 Returns: 355 All the results for this project. 356 """ 357 ret = rh.results.ProjectResults(project_name, proj_dir) 358 359 try: 360 config = _get_project_config(from_git) 361 except rh.config.ValidationError as e: 362 output.error('Loading config files', str(e)) 363 return ret._replace(internal_failure=True) 364 365 builtin_hooks = list(config.callable_builtin_hooks()) 366 custom_hooks = list(config.callable_custom_hooks()) 367 368 # If the repo has no pre-upload hooks enabled, then just return. 369 if not builtin_hooks and not custom_hooks: 370 return ret 371 372 # Set up the environment like repo would with the forall command. 373 try: 374 remote = rh.git.get_upstream_remote() 375 upstream_branch = rh.git.get_upstream_branch() 376 except rh.utils.CalledProcessError as e: 377 output.error('Upstream remote/tracking branch lookup', 378 f'{e}\nDid you run repo start? Is your HEAD detached?') 379 return ret._replace(internal_failure=True) 380 381 project = rh.Project(name=project_name, dir=proj_dir) 382 rel_proj_dir = os.path.relpath(proj_dir, rh.git.find_repo_root()) 383 384 # Filter out the hooks to process. 385 builtin_hooks = [x for x in builtin_hooks if rel_proj_dir not in x.scope] 386 custom_hooks = [x for x in custom_hooks if rel_proj_dir not in x.scope] 387 388 if not builtin_hooks and not custom_hooks: 389 return ret 390 391 os.environ.update({ 392 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch), 393 'REPO_PATH': rel_proj_dir, 394 'REPO_PROJECT': project_name, 395 'REPO_REMOTE': remote, 396 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote), 397 }) 398 399 if not commit_list: 400 commit_list = rh.git.get_commits( 401 ignore_merged_commits=config.ignore_merged_commits) 402 output.set_num_commits(len(commit_list)) 403 404 def _run_hook(hook, project, commit, desc, diff): 405 """Run a hook, gather stats, and process its results.""" 406 start = datetime.datetime.now() 407 results = hook.hook(project, commit, desc, diff) 408 (error, warning) = _process_hook_results(results) 409 duration = datetime.datetime.now() - start 410 return (hook, results, error, warning, duration) 411 412 with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as executor: 413 for commit in commit_list: 414 # Mix in some settings for our hooks. 415 os.environ['PREUPLOAD_COMMIT'] = commit 416 diff = rh.git.get_affected_files(commit) 417 desc = rh.git.get_commit_desc(commit) 418 os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc 419 420 commit_summary = desc.split('\n', 1)[0] 421 output.commit_start(builtin_hooks + custom_hooks, commit, commit_summary) 422 423 def run_hooks(hooks): 424 futures = ( 425 executor.submit(_run_hook, hook, project, commit, desc, diff) 426 for hook in hooks 427 ) 428 future_results = ( 429 x.result() for x in concurrent.futures.as_completed(futures) 430 ) 431 for hook, hook_results, error, warning, duration in future_results: 432 ret.add_results(hook_results) 433 if error is not None or warning is not None: 434 if warning is not None: 435 output.hook_warning(hook, warning) 436 if error is not None: 437 output.hook_error(hook, error) 438 output.hook_fixups(ret, hook_results) 439 output.hook_finish(hook, duration) 440 441 run_hooks(builtin_hooks) 442 run_hooks(custom_hooks) 443 444 return ret 445 446 447def _run_project_hooks( 448 project_name: str, 449 proj_dir: Optional[str] = None, 450 jobs: Optional[int] = None, 451 from_git: bool = False, 452 commit_list: Optional[List[str]] = None, 453) -> rh.results.ProjectResults: 454 """Run the project-specific hooks in |proj_dir|. 455 456 Args: 457 project_name: The name of project to run hooks for. 458 proj_dir: If non-None, this is the directory the project is in. If None, 459 we'll ask repo. 460 jobs: How many hooks to run in parallel. 461 from_git: If true, we are called from git directly and repo should not be 462 used. 463 commit_list: A list of commits to run hooks against. If None or empty 464 list then we'll automatically get the list of commits that would be 465 uploaded. 466 467 Returns: 468 All the results for this project. 469 """ 470 output = Output(project_name) 471 472 if proj_dir is None: 473 cmd = ['repo', 'forall', project_name, '-c', 'pwd'] 474 result = rh.utils.run(cmd, capture_output=True) 475 proj_dirs = result.stdout.split() 476 if not proj_dirs: 477 print(f'{project_name} cannot be found.', file=sys.stderr) 478 print('Please specify a valid project.', file=sys.stderr) 479 return False 480 if len(proj_dirs) > 1: 481 print(f'{project_name} is associated with multiple directories.', 482 file=sys.stderr) 483 print('Please specify a directory to help disambiguate.', 484 file=sys.stderr) 485 return False 486 proj_dir = proj_dirs[0] 487 488 pwd = os.getcwd() 489 try: 490 # Hooks assume they are run from the root of the project. 491 os.chdir(proj_dir) 492 return _run_project_hooks_in_cwd( 493 project_name, proj_dir, output, jobs=jobs, from_git=from_git, 494 commit_list=commit_list) 495 finally: 496 output.finish() 497 os.chdir(pwd) 498 499 500def _run_projects_hooks( 501 project_list: List[str], 502 worktree_list: List[Optional[str]], 503 jobs: Optional[int] = None, 504 from_git: bool = False, 505 commit_list: Optional[List[str]] = None, 506) -> bool: 507 """Run all the hooks 508 509 Args: 510 project_list: List of project names. 511 worktree_list: List of project checkouts. 512 jobs: How many hooks to run in parallel. 513 from_git: If true, we are called from git directly and repo should not be 514 used. 515 commit_list: A list of commits to run hooks against. If None or empty 516 list then we'll automatically get the list of commits that would be 517 uploaded. 518 519 Returns: 520 True if everything passed, else False. 521 """ 522 results = [] 523 for project, worktree in zip(project_list, worktree_list): 524 result = _run_project_hooks( 525 project, 526 proj_dir=worktree, 527 jobs=jobs, 528 from_git=from_git, 529 commit_list=commit_list, 530 ) 531 results.append(result) 532 if result: 533 # If a repo had failures, add a blank line to help break up the 534 # output. If there were no failures, then the output should be 535 # very minimal, so we don't add it then. 536 print('', file=sys.stderr) 537 538 _attempt_fixes(results) 539 return not any(results) 540 541 542def main(project_list, worktree_list=None, **_kwargs): 543 """Main function invoked directly by repo. 544 545 We must use the name "main" as that is what repo requires. 546 547 This function will exit directly upon error so that repo doesn't print some 548 obscure error message. 549 550 Args: 551 project_list: List of projects to run on. 552 worktree_list: A list of directories. It should be the same length as 553 project_list, so that each entry in project_list matches with a 554 directory in worktree_list. If None, we will attempt to calculate 555 the directories automatically. 556 kwargs: Leave this here for forward-compatibility. 557 """ 558 if not worktree_list: 559 worktree_list = [None] * len(project_list) 560 if not _run_projects_hooks(project_list, worktree_list): 561 color = rh.terminal.Color() 562 print(color.color(color.RED, 'FATAL') + 563 ': Preupload failed due to above error(s).\n' 564 f'For more info, see: {REPOHOOKS_URL}', 565 file=sys.stderr) 566 sys.exit(1) 567 568 569def _identify_project(path, from_git=False): 570 """Identify the repo project associated with the given path. 571 572 Returns: 573 A string indicating what project is associated with the path passed in or 574 a blank string upon failure. 575 """ 576 if from_git: 577 cmd = ['git', 'rev-parse', '--show-toplevel'] 578 project_path = rh.utils.run(cmd, capture_output=True).stdout.strip() 579 cmd = ['git', 'rev-parse', '--show-superproject-working-tree'] 580 superproject_path = rh.utils.run( 581 cmd, capture_output=True).stdout.strip() 582 module_path = project_path[len(superproject_path) + 1:] 583 cmd = ['git', 'config', '-f', '.gitmodules', 584 '--name-only', '--get-regexp', r'^submodule\..*\.path$', 585 f"^{module_path}$"] 586 module_name = rh.utils.run(cmd, cwd=superproject_path, 587 capture_output=True).stdout.strip() 588 return module_name[len('submodule.'):-len(".path")] 589 else: 590 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}'] 591 return rh.utils.run(cmd, capture_output=True, cwd=path).stdout.strip() 592 593 594def direct_main(argv): 595 """Run hooks directly (outside of the context of repo). 596 597 Args: 598 argv: The command line args to process. 599 600 Returns: 601 0 if no pre-upload failures, 1 if failures. 602 603 Raises: 604 BadInvocation: On some types of invocation errors. 605 """ 606 parser = argparse.ArgumentParser(description=__doc__) 607 parser.add_argument('--git', action='store_true', 608 help='This hook is called from git instead of repo') 609 parser.add_argument('--dir', default=None, 610 help='The directory that the project lives in. If not ' 611 'specified, use the git project root based on the cwd.') 612 parser.add_argument('--project', default=None, 613 help='The project repo path; this can affect how the ' 614 'hooks get run, since some hooks are project-specific.' 615 'If not specified, `repo` will be used to figure this ' 616 'out based on the dir.') 617 parser.add_argument('-j', '--jobs', type=int, 618 help='Run up to this many hooks in parallel. Setting ' 619 'to 1 forces serial execution, and the default ' 620 'automatically chooses an appropriate number for the ' 621 'current system.') 622 parser.add_argument('commits', nargs='*', 623 help='Check specific commits') 624 opts = parser.parse_args(argv) 625 626 # Check/normalize git dir; if unspecified, we'll use the root of the git 627 # project from CWD. 628 if opts.dir is None: 629 cmd = ['git', 'rev-parse', '--git-dir'] 630 git_dir = rh.utils.run(cmd, capture_output=True).stdout.strip() 631 if not git_dir: 632 parser.error('The current directory is not part of a git project.') 633 opts.dir = os.path.dirname(os.path.abspath(git_dir)) 634 elif not os.path.isdir(opts.dir): 635 parser.error(f'Invalid dir: {opts.dir}') 636 elif not rh.git.is_git_repository(opts.dir): 637 parser.error(f'Not a git repository: {opts.dir}') 638 639 # Identify the project if it wasn't specified; this _requires_ the repo 640 # tool to be installed and for the project to be part of a repo checkout. 641 if not opts.project: 642 opts.project = _identify_project(opts.dir, opts.git) 643 if not opts.project: 644 parser.error(f"Couldn't identify the project of {opts.dir}") 645 646 try: 647 if _run_projects_hooks([opts.project], [opts.dir], jobs=opts.jobs, 648 from_git=opts.git, commit_list=opts.commits): 649 return 0 650 except KeyboardInterrupt: 651 print('Aborting execution early due to user interrupt', file=sys.stderr) 652 return 128 + signal.SIGINT 653 return 1 654 655 656if __name__ == '__main__': 657 sys.exit(direct_main(sys.argv[1:])) 658