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