blob: 86f6da0e489d6dd12d32504585277e9f96135ce3 [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')
64
65 def __init__(self, project_name, num_hooks):
66 """Create a new Output object for a specified project.
67
68 Args:
69 project_name: name of project.
70 num_hooks: number of hooks to be run.
71 """
72 self.project_name = project_name
73 self.num_hooks = num_hooks
74 self.hook_index = 0
75 self.success = True
76
77 def commit_start(self, commit, commit_summary):
78 """Emit status for new commit.
79
80 Args:
81 commit: commit hash.
82 commit_summary: commit summary.
83 """
84 status_line = '[%s %s] %s' % (self.COMMIT, commit[0:12], commit_summary)
85 rh.terminal.print_status_line(status_line, print_newline=True)
86 self.hook_index = 1
87
88 def hook_start(self, hook_name):
89 """Emit status before the start of a hook.
90
91 Args:
92 hook_name: name of the hook.
93 """
94 status_line = '[%s %d/%d] %s' % (self.RUNNING, self.hook_index,
95 self.num_hooks, hook_name)
96 self.hook_index += 1
97 rh.terminal.print_status_line(status_line)
98
99 def hook_error(self, hook_name, error):
100 """Print an error.
101
102 Args:
103 hook_name: name of the hook.
104 error: error string.
105 """
106 status_line = '[%s] %s' % (self.FAILED, hook_name)
107 rh.terminal.print_status_line(status_line, print_newline=True)
108 print(error, file=sys.stderr)
109 self.success = False
110
111 def finish(self):
112 """Print repohook summary."""
113 status_line = '[%s] repohooks for %s %s' % (
114 self.PASSED if self.success else self.FAILED,
115 self.project_name,
116 'passed' if self.success else 'failed')
117 rh.terminal.print_status_line(status_line, print_newline=True)
118
119
120def _process_hook_results(results):
121 """Returns an error string if an error occurred.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500122
123 Args:
Josh Gao25abf4b2016-09-23 18:36:27 -0700124 results: A list of HookResult objects, or None.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500125
126 Returns:
Josh Gao25abf4b2016-09-23 18:36:27 -0700127 error output if an error occurred, otherwise None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500128 """
Josh Gao25abf4b2016-09-23 18:36:27 -0700129 if not results:
130 return None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500131
Josh Gao25abf4b2016-09-23 18:36:27 -0700132 ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500133 for result in results:
134 if result:
Mike Frysinger2e65c542016-03-08 16:17:00 -0500135 if result.files:
Josh Gao25abf4b2016-09-23 18:36:27 -0700136 ret += ' FILES: %s' % (result.files,)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500137 lines = result.error.splitlines()
Josh Gao25abf4b2016-09-23 18:36:27 -0700138 ret += '\n'.join(' %s' % (x,) for x in lines)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500139
Josh Gao25abf4b2016-09-23 18:36:27 -0700140 return ret or None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500141
142
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700143def _get_project_config():
144 """Returns the configuration for a project.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500145
146 Expects to be called from within the project root.
147 """
Mike Frysingerca797702016-09-03 02:00:55 -0400148 global_paths = (
149 # Load the global config found in the manifest repo.
150 os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'),
151 # Load the global config found in the root of the repo checkout.
152 rh.git.find_repo_root(),
153 )
154 paths = (
155 # Load the config for this git repo.
156 '.',
157 )
Mike Frysinger2e65c542016-03-08 16:17:00 -0500158 try:
Mike Frysingerca797702016-09-03 02:00:55 -0400159 config = rh.config.PreSubmitConfig(paths=paths,
160 global_paths=global_paths)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500161 except rh.config.ValidationError as e:
162 print('invalid config file: %s' % (e,), file=sys.stderr)
163 sys.exit(1)
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700164 return config
Mike Frysinger2e65c542016-03-08 16:17:00 -0500165
166
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800167def _attempt_fixes(fixup_func_list, commit_list):
168 """Attempts to run |fixup_func_list| given |commit_list|."""
169 if len(fixup_func_list) != 1:
170 # Only single fixes will be attempted, since various fixes might
171 # interact with each other.
172 return
173
174 hook_name, commit, fixup_func = fixup_func_list[0]
175
176 if commit != commit_list[0]:
177 # If the commit is not at the top of the stack, git operations might be
178 # needed and might leave the working directory in a tricky state if the
179 # fix is attempted to run automatically (e.g. it might require manual
180 # merge conflict resolution). Refuse to run the fix in those cases.
181 return
182
183 prompt = ('An automatic fix can be attempted for the "%s" hook. '
184 'Do you want to run it?' % hook_name)
185 if not rh.terminal.boolean_prompt(prompt):
186 return
187
188 result = fixup_func()
189 if result:
190 print('Attempt to fix "%s" for commit "%s" failed: %s' %
191 (hook_name, commit, result),
192 file=sys.stderr)
193 else:
194 print('Fix successfully applied. Amend the current commit before '
195 'attempting to upload again.\n', file=sys.stderr)
196
197
Mike Frysinger2e65c542016-03-08 16:17:00 -0500198def _run_project_hooks(project_name, proj_dir=None,
199 commit_list=None):
200 """For each project run its project specific hook from the hooks dictionary.
201
202 Args:
203 project_name: The name of project to run hooks for.
204 proj_dir: If non-None, this is the directory the project is in. If None,
205 we'll ask repo.
206 commit_list: A list of commits to run hooks against. If None or empty
207 list then we'll automatically get the list of commits that would be
208 uploaded.
209
210 Returns:
211 False if any errors were found, else True.
212 """
213 if proj_dir is None:
214 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
215 result = rh.utils.run_command(cmd, capture_output=True)
216 proj_dirs = result.output.split()
217 if len(proj_dirs) == 0:
218 print('%s cannot be found.' % project_name, file=sys.stderr)
219 print('Please specify a valid project.', file=sys.stderr)
220 return 0
221 if len(proj_dirs) > 1:
222 print('%s is associated with multiple directories.' % project_name,
223 file=sys.stderr)
224 print('Please specify a directory to help disambiguate.',
225 file=sys.stderr)
226 return 0
227 proj_dir = proj_dirs[0]
228
229 pwd = os.getcwd()
230 # Hooks assume they are run from the root of the project.
231 os.chdir(proj_dir)
232
Mike Frysinger558aff42016-04-04 16:02:55 -0400233 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700234 config = _get_project_config()
235 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400236 if not hooks:
237 return True
238
Mike Frysinger2e65c542016-03-08 16:17:00 -0500239 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700240 try:
241 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800242 upstream_branch = rh.git.get_upstream_branch()
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700243 except rh.utils.RunCommandError as e:
244 print('upstream remote cannot be found: %s' % (e,), file=sys.stderr)
245 print('Did you run repo start?', file=sys.stderr)
246 sys.exit(1)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500247 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800248 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500249 'REPO_PATH': proj_dir,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800250 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500251 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800252 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500253 })
254
Josh Gao25abf4b2016-09-23 18:36:27 -0700255 output = Output(project_name, len(hooks))
Mike Frysingerb9608182016-10-20 20:45:04 -0400256 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500257
258 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700259 commit_list = rh.git.get_commits(
260 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500261
Mike Frysinger2e65c542016-03-08 16:17:00 -0500262 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800263 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700264
Mike Frysinger2e65c542016-03-08 16:17:00 -0500265 for commit in commit_list:
266 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400267 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500268 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400269 desc = rh.git.get_commit_desc(commit)
Nicolas Sylvain6f798862016-05-02 18:58:22 -0700270 os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
Mike Frysinger2e65c542016-03-08 16:17:00 -0500271
Josh Gao25abf4b2016-09-23 18:36:27 -0700272 commit_summary = desc.split('\n', 1)[0]
273 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500274
Josh Gao25abf4b2016-09-23 18:36:27 -0700275 for name, hook in hooks:
276 output.hook_start(name)
277 hook_results = hook(project, commit, desc, diff)
278 error = _process_hook_results(hook_results)
279 if error:
280 ret = False
281 output.hook_error(name, error)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800282 for result in hook_results:
283 if result.fixup_func:
284 fixup_func_list.append((name, commit,
285 result.fixup_func))
286
287 if fixup_func_list:
288 _attempt_fixes(fixup_func_list, commit_list)
Josh Gao25abf4b2016-09-23 18:36:27 -0700289
290 output.finish()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500291 os.chdir(pwd)
292 return ret
293
294
295def main(project_list, worktree_list=None, **_kwargs):
296 """Main function invoked directly by repo.
297
298 We must use the name "main" as that is what repo requires.
299
300 This function will exit directly upon error so that repo doesn't print some
301 obscure error message.
302
303 Args:
304 project_list: List of projects to run on.
305 worktree_list: A list of directories. It should be the same length as
306 project_list, so that each entry in project_list matches with a
307 directory in worktree_list. If None, we will attempt to calculate
308 the directories automatically.
309 kwargs: Leave this here for forward-compatibility.
310 """
311 found_error = False
312 if not worktree_list:
313 worktree_list = [None] * len(project_list)
314 for project, worktree in zip(project_list, worktree_list):
315 if not _run_project_hooks(project, proj_dir=worktree):
316 found_error = True
317
318 if found_error:
319 color = rh.terminal.Color()
320 print('%s: Preupload failed due to above error(s).\n'
321 'For more info, please see:\n%s' %
322 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
323 file=sys.stderr)
324 sys.exit(1)
325
326
327def _identify_project(path):
328 """Identify the repo project associated with the given path.
329
330 Returns:
331 A string indicating what project is associated with the path passed in or
332 a blank string upon failure.
333 """
334 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
335 return rh.utils.run_command(cmd, capture_output=True, redirect_stderr=True,
336 cwd=path).output.strip()
337
338
339def direct_main(argv):
340 """Run hooks directly (outside of the context of repo).
341
342 Args:
343 argv: The command line args to process.
344
345 Returns:
346 0 if no pre-upload failures, 1 if failures.
347
348 Raises:
349 BadInvocation: On some types of invocation errors.
350 """
351 parser = argparse.ArgumentParser(description=__doc__)
352 parser.add_argument('--dir', default=None,
353 help='The directory that the project lives in. If not '
354 'specified, use the git project root based on the cwd.')
355 parser.add_argument('--project', default=None,
356 help='The project repo path; this can affect how the '
357 'hooks get run, since some hooks are project-specific.'
358 'If not specified, `repo` will be used to figure this '
359 'out based on the dir.')
360 parser.add_argument('commits', nargs='*',
361 help='Check specific commits')
362 opts = parser.parse_args(argv)
363
364 # Check/normalize git dir; if unspecified, we'll use the root of the git
365 # project from CWD.
366 if opts.dir is None:
367 cmd = ['git', 'rev-parse', '--git-dir']
368 git_dir = rh.utils.run_command(cmd, capture_output=True,
369 redirect_stderr=True).output.strip()
370 if not git_dir:
371 parser.error('The current directory is not part of a git project.')
372 opts.dir = os.path.dirname(os.path.abspath(git_dir))
373 elif not os.path.isdir(opts.dir):
374 parser.error('Invalid dir: %s' % opts.dir)
375 elif not os.path.isdir(os.path.join(opts.dir, '.git')):
376 parser.error('Not a git directory: %s' % opts.dir)
377
378 # Identify the project if it wasn't specified; this _requires_ the repo
379 # tool to be installed and for the project to be part of a repo checkout.
380 if not opts.project:
381 opts.project = _identify_project(opts.dir)
382 if not opts.project:
383 parser.error("Repo couldn't identify the project of %s" % opts.dir)
384
385 if _run_project_hooks(opts.project, proj_dir=opts.dir,
386 commit_list=opts.commits):
387 return 0
388 else:
389 return 1
390
391
392if __name__ == '__main__':
393 sys.exit(direct_main(sys.argv[1:]))