blob: fffc03c32cf4efc3b06744c664da8db2d87534b1 [file] [log] [blame]
Mike Frysinger2e65c542016-03-08 16:17:00 -05001#!/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
Mike Frysinger2e65c542016-03-08 16:17:00 -050026import 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
Mike Frysinger2ef213c2017-11-10 15:41:56 -050040# 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
Mike Frysingerb9608182016-10-20 20:45:04 -040043import rh
Mike Frysinger2e65c542016-03-08 16:17:00 -050044import 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
Josh Gao25abf4b2016-09-23 18:36:27 -070056class 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')
Jason Monk0886c912017-11-10 13:17:17 -050064 WARNING = COLOR.color(COLOR.YELLOW, 'WARNING')
Josh Gao25abf4b2016-09-23 18:36:27 -070065
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
Jason Monk0886c912017-11-10 13:17:17 -0500112 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
Josh Gao25abf4b2016-09-23 18:36:27 -0700123 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.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500134
135 Args:
Josh Gao25abf4b2016-09-23 18:36:27 -0700136 results: A list of HookResult objects, or None.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500137
138 Returns:
Josh Gao25abf4b2016-09-23 18:36:27 -0700139 error output if an error occurred, otherwise None
Jason Monk0886c912017-11-10 13:17:17 -0500140 warning output if an error occurred, otherwise None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500141 """
Josh Gao25abf4b2016-09-23 18:36:27 -0700142 if not results:
Jason Monk0886c912017-11-10 13:17:17 -0500143 return (None, None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500144
Jason Monk0886c912017-11-10 13:17:17 -0500145 error_ret = ''
146 warning_ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500147 for result in results:
148 if result:
Jason Monk0886c912017-11-10 13:17:17 -0500149 ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500150 if result.files:
Josh Gao25abf4b2016-09-23 18:36:27 -0700151 ret += ' FILES: %s' % (result.files,)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500152 lines = result.error.splitlines()
Josh Gao25abf4b2016-09-23 18:36:27 -0700153 ret += '\n'.join(' %s' % (x,) for x in lines)
Jason Monk0886c912017-11-10 13:17:17 -0500154 if result.is_warning():
155 warning_ret += ret
156 else:
157 error_ret += ret
Mike Frysinger2e65c542016-03-08 16:17:00 -0500158
Jason Monk0886c912017-11-10 13:17:17 -0500159 return (error_ret or None, warning_ret or None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500160
161
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700162def _get_project_config():
163 """Returns the configuration for a project.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500164
165 Expects to be called from within the project root.
166 """
Mike Frysingerca797702016-09-03 02:00:55 -0400167 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 )
Mike Frysinger2e65c542016-03-08 16:17:00 -0500177 try:
Mike Frysingerca797702016-09-03 02:00:55 -0400178 config = rh.config.PreSubmitConfig(paths=paths,
179 global_paths=global_paths)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500180 except rh.config.ValidationError as e:
181 print('invalid config file: %s' % (e,), file=sys.stderr)
182 sys.exit(1)
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700183 return config
Mike Frysinger2e65c542016-03-08 16:17:00 -0500184
185
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800186def _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
Mike Frysinger2e65c542016-03-08 16:17:00 -0500217def _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
Mike Frysinger558aff42016-04-04 16:02:55 -0400252 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700253 config = _get_project_config()
254 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400255 if not hooks:
256 return True
257
Mike Frysinger2e65c542016-03-08 16:17:00 -0500258 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700259 try:
260 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800261 upstream_branch = rh.git.get_upstream_branch()
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700262 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)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500266 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800267 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500268 'REPO_PATH': proj_dir,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800269 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500270 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800271 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500272 })
273
Josh Gao25abf4b2016-09-23 18:36:27 -0700274 output = Output(project_name, len(hooks))
Mike Frysingerb9608182016-10-20 20:45:04 -0400275 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500276
277 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700278 commit_list = rh.git.get_commits(
279 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500280
Mike Frysinger2e65c542016-03-08 16:17:00 -0500281 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800282 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700283
Mike Frysinger2e65c542016-03-08 16:17:00 -0500284 for commit in commit_list:
285 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400286 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500287 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400288 desc = rh.git.get_commit_desc(commit)
Nicolas Sylvain6f798862016-05-02 18:58:22 -0700289 os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
Mike Frysinger2e65c542016-03-08 16:17:00 -0500290
Josh Gao25abf4b2016-09-23 18:36:27 -0700291 commit_summary = desc.split('\n', 1)[0]
292 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500293
Josh Gao25abf4b2016-09-23 18:36:27 -0700294 for name, hook in hooks:
295 output.hook_start(name)
296 hook_results = hook(project, commit, desc, diff)
Jason Monk0886c912017-11-10 13:17:17 -0500297 (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)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800304 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)
Josh Gao25abf4b2016-09-23 18:36:27 -0700311
312 output.finish()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500313 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)
Adrian Roos8ac865f2018-04-13 12:08:52 +0100397 elif not rh.git.is_git_repository(opts.dir):
398 parser.error('Not a git repository: %s' % opts.dir)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500399
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:]))