blob: fe4cdd8d56a0fa41c8ccd7048cf796c896c2ff03 [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 Frysingerb9608182016-10-20 20:45:04 -040040import rh
Mike Frysinger2e65c542016-03-08 16:17:00 -050041import 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
Josh Gao25abf4b2016-09-23 18:36:27 -070053class 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.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500119
120 Args:
Josh Gao25abf4b2016-09-23 18:36:27 -0700121 results: A list of HookResult objects, or None.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500122
123 Returns:
Josh Gao25abf4b2016-09-23 18:36:27 -0700124 error output if an error occurred, otherwise None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500125 """
Josh Gao25abf4b2016-09-23 18:36:27 -0700126 if not results:
127 return None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500128
Josh Gao25abf4b2016-09-23 18:36:27 -0700129 ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500130 for result in results:
131 if result:
Mike Frysinger2e65c542016-03-08 16:17:00 -0500132 if result.files:
Josh Gao25abf4b2016-09-23 18:36:27 -0700133 ret += ' FILES: %s' % (result.files,)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500134 lines = result.error.splitlines()
Josh Gao25abf4b2016-09-23 18:36:27 -0700135 ret += '\n'.join(' %s' % (x,) for x in lines)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500136
Josh Gao25abf4b2016-09-23 18:36:27 -0700137 return ret or None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500138
139
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700140def _get_project_config():
141 """Returns the configuration for a project.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500142
143 Expects to be called from within the project root.
144 """
Mike Frysingerca797702016-09-03 02:00:55 -0400145 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 )
Mike Frysinger2e65c542016-03-08 16:17:00 -0500155 try:
Mike Frysingerca797702016-09-03 02:00:55 -0400156 config = rh.config.PreSubmitConfig(paths=paths,
157 global_paths=global_paths)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500158 except rh.config.ValidationError as e:
159 print('invalid config file: %s' % (e,), file=sys.stderr)
160 sys.exit(1)
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700161 return config
Mike Frysinger2e65c542016-03-08 16:17:00 -0500162
163
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800164def _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
Mike Frysinger2e65c542016-03-08 16:17:00 -0500195def _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
Mike Frysinger558aff42016-04-04 16:02:55 -0400230 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700231 config = _get_project_config()
232 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400233 if not hooks:
234 return True
235
Mike Frysinger2e65c542016-03-08 16:17:00 -0500236 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700237 try:
238 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800239 upstream_branch = rh.git.get_upstream_branch()
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700240 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)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500244 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800245 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500246 'REPO_PATH': proj_dir,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800247 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500248 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800249 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500250 })
251
Josh Gao25abf4b2016-09-23 18:36:27 -0700252 output = Output(project_name, len(hooks))
Mike Frysingerb9608182016-10-20 20:45:04 -0400253 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500254
255 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700256 commit_list = rh.git.get_commits(
257 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500258
Mike Frysinger2e65c542016-03-08 16:17:00 -0500259 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800260 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700261
Mike Frysinger2e65c542016-03-08 16:17:00 -0500262 for commit in commit_list:
263 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400264 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500265 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400266 desc = rh.git.get_commit_desc(commit)
Nicolas Sylvain6f798862016-05-02 18:58:22 -0700267 os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
Mike Frysinger2e65c542016-03-08 16:17:00 -0500268
Josh Gao25abf4b2016-09-23 18:36:27 -0700269 commit_summary = desc.split('\n', 1)[0]
270 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500271
Josh Gao25abf4b2016-09-23 18:36:27 -0700272 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)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800279 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)
Josh Gao25abf4b2016-09-23 18:36:27 -0700286
287 output.finish()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500288 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:]))