blob: ac976e75efc9938a3daee204d8ea3a2e406e02d9 [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)
Mike Frysingera65ecb92019-02-15 15:58:31 -0500182 return None
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)
Mike Frysingera65ecb92019-02-15 15:58:31 -0500239 return False
Mike Frysinger2e65c542016-03-08 16:17:00 -0500240 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)
Mike Frysingera65ecb92019-02-15 15:58:31 -0500245 return False
Mike Frysinger2e65c542016-03-08 16:17:00 -0500246 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()
Mike Frysingera65ecb92019-02-15 15:58:31 -0500254 if not config:
255 return False
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700256 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400257 if not hooks:
258 return True
259
Mike Frysinger2e65c542016-03-08 16:17:00 -0500260 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700261 try:
262 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800263 upstream_branch = rh.git.get_upstream_branch()
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700264 except rh.utils.RunCommandError as e:
265 print('upstream remote cannot be found: %s' % (e,), file=sys.stderr)
266 print('Did you run repo start?', file=sys.stderr)
Mike Frysingera65ecb92019-02-15 15:58:31 -0500267 return False
Mike Frysinger2e65c542016-03-08 16:17:00 -0500268 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800269 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500270 'REPO_PATH': proj_dir,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800271 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500272 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800273 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500274 })
275
Josh Gao25abf4b2016-09-23 18:36:27 -0700276 output = Output(project_name, len(hooks))
Mike Frysingerb9608182016-10-20 20:45:04 -0400277 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500278
279 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700280 commit_list = rh.git.get_commits(
281 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500282
Mike Frysinger2e65c542016-03-08 16:17:00 -0500283 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800284 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700285
Mike Frysinger2e65c542016-03-08 16:17:00 -0500286 for commit in commit_list:
287 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400288 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500289 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400290 desc = rh.git.get_commit_desc(commit)
Nicolas Sylvain6f798862016-05-02 18:58:22 -0700291 os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
Mike Frysinger2e65c542016-03-08 16:17:00 -0500292
Josh Gao25abf4b2016-09-23 18:36:27 -0700293 commit_summary = desc.split('\n', 1)[0]
294 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500295
Josh Gao25abf4b2016-09-23 18:36:27 -0700296 for name, hook in hooks:
297 output.hook_start(name)
298 hook_results = hook(project, commit, desc, diff)
Jason Monk0886c912017-11-10 13:17:17 -0500299 (error, warning) = _process_hook_results(hook_results)
300 if error or warning:
301 if warning:
302 output.hook_warning(name, warning)
303 if error:
304 ret = False
305 output.hook_error(name, error)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800306 for result in hook_results:
307 if result.fixup_func:
308 fixup_func_list.append((name, commit,
309 result.fixup_func))
310
311 if fixup_func_list:
312 _attempt_fixes(fixup_func_list, commit_list)
Josh Gao25abf4b2016-09-23 18:36:27 -0700313
314 output.finish()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500315 os.chdir(pwd)
316 return ret
317
318
319def main(project_list, worktree_list=None, **_kwargs):
320 """Main function invoked directly by repo.
321
322 We must use the name "main" as that is what repo requires.
323
324 This function will exit directly upon error so that repo doesn't print some
325 obscure error message.
326
327 Args:
328 project_list: List of projects to run on.
329 worktree_list: A list of directories. It should be the same length as
330 project_list, so that each entry in project_list matches with a
331 directory in worktree_list. If None, we will attempt to calculate
332 the directories automatically.
333 kwargs: Leave this here for forward-compatibility.
334 """
335 found_error = False
336 if not worktree_list:
337 worktree_list = [None] * len(project_list)
338 for project, worktree in zip(project_list, worktree_list):
339 if not _run_project_hooks(project, proj_dir=worktree):
340 found_error = True
341
342 if found_error:
343 color = rh.terminal.Color()
344 print('%s: Preupload failed due to above error(s).\n'
345 'For more info, please see:\n%s' %
346 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
347 file=sys.stderr)
348 sys.exit(1)
349
350
351def _identify_project(path):
352 """Identify the repo project associated with the given path.
353
354 Returns:
355 A string indicating what project is associated with the path passed in or
356 a blank string upon failure.
357 """
358 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
359 return rh.utils.run_command(cmd, capture_output=True, redirect_stderr=True,
360 cwd=path).output.strip()
361
362
363def direct_main(argv):
364 """Run hooks directly (outside of the context of repo).
365
366 Args:
367 argv: The command line args to process.
368
369 Returns:
370 0 if no pre-upload failures, 1 if failures.
371
372 Raises:
373 BadInvocation: On some types of invocation errors.
374 """
375 parser = argparse.ArgumentParser(description=__doc__)
376 parser.add_argument('--dir', default=None,
377 help='The directory that the project lives in. If not '
378 'specified, use the git project root based on the cwd.')
379 parser.add_argument('--project', default=None,
380 help='The project repo path; this can affect how the '
381 'hooks get run, since some hooks are project-specific.'
382 'If not specified, `repo` will be used to figure this '
383 'out based on the dir.')
384 parser.add_argument('commits', nargs='*',
385 help='Check specific commits')
386 opts = parser.parse_args(argv)
387
388 # Check/normalize git dir; if unspecified, we'll use the root of the git
389 # project from CWD.
390 if opts.dir is None:
391 cmd = ['git', 'rev-parse', '--git-dir']
392 git_dir = rh.utils.run_command(cmd, capture_output=True,
393 redirect_stderr=True).output.strip()
394 if not git_dir:
395 parser.error('The current directory is not part of a git project.')
396 opts.dir = os.path.dirname(os.path.abspath(git_dir))
397 elif not os.path.isdir(opts.dir):
398 parser.error('Invalid dir: %s' % opts.dir)
Adrian Roos8ac865f2018-04-13 12:08:52 +0100399 elif not rh.git.is_git_repository(opts.dir):
400 parser.error('Not a git repository: %s' % opts.dir)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500401
402 # Identify the project if it wasn't specified; this _requires_ the repo
403 # tool to be installed and for the project to be part of a repo checkout.
404 if not opts.project:
405 opts.project = _identify_project(opts.dir)
406 if not opts.project:
407 parser.error("Repo couldn't identify the project of %s" % opts.dir)
408
409 if _run_project_hooks(opts.project, proj_dir=opts.dir,
410 commit_list=opts.commits):
411 return 0
412 else:
413 return 1
414
415
416if __name__ == '__main__':
417 sys.exit(direct_main(sys.argv[1:]))