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