blob: 447b8bf7f86ab1780a38f3abfb9409773866f11d [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
164def _run_project_hooks(project_name, proj_dir=None,
165 commit_list=None):
166 """For each project run its project specific hook from the hooks dictionary.
167
168 Args:
169 project_name: The name of project to run hooks for.
170 proj_dir: If non-None, this is the directory the project is in. If None,
171 we'll ask repo.
172 commit_list: A list of commits to run hooks against. If None or empty
173 list then we'll automatically get the list of commits that would be
174 uploaded.
175
176 Returns:
177 False if any errors were found, else True.
178 """
179 if proj_dir is None:
180 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
181 result = rh.utils.run_command(cmd, capture_output=True)
182 proj_dirs = result.output.split()
183 if len(proj_dirs) == 0:
184 print('%s cannot be found.' % project_name, file=sys.stderr)
185 print('Please specify a valid project.', file=sys.stderr)
186 return 0
187 if len(proj_dirs) > 1:
188 print('%s is associated with multiple directories.' % project_name,
189 file=sys.stderr)
190 print('Please specify a directory to help disambiguate.',
191 file=sys.stderr)
192 return 0
193 proj_dir = proj_dirs[0]
194
195 pwd = os.getcwd()
196 # Hooks assume they are run from the root of the project.
197 os.chdir(proj_dir)
198
Mike Frysinger558aff42016-04-04 16:02:55 -0400199 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700200 config = _get_project_config()
201 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400202 if not hooks:
203 return True
204
Mike Frysinger2e65c542016-03-08 16:17:00 -0500205 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700206 try:
207 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800208 upstream_branch = rh.git.get_upstream_branch()
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700209 except rh.utils.RunCommandError as e:
210 print('upstream remote cannot be found: %s' % (e,), file=sys.stderr)
211 print('Did you run repo start?', file=sys.stderr)
212 sys.exit(1)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500213 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800214 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500215 'REPO_PATH': proj_dir,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800216 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500217 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800218 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500219 })
220
Josh Gao25abf4b2016-09-23 18:36:27 -0700221 output = Output(project_name, len(hooks))
Mike Frysingerb9608182016-10-20 20:45:04 -0400222 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500223
224 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700225 commit_list = rh.git.get_commits(
226 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500227
Mike Frysinger2e65c542016-03-08 16:17:00 -0500228 ret = True
Josh Gao25abf4b2016-09-23 18:36:27 -0700229
Mike Frysinger2e65c542016-03-08 16:17:00 -0500230 for commit in commit_list:
231 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400232 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500233 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400234 desc = rh.git.get_commit_desc(commit)
Nicolas Sylvain6f798862016-05-02 18:58:22 -0700235 os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
Mike Frysinger2e65c542016-03-08 16:17:00 -0500236
Josh Gao25abf4b2016-09-23 18:36:27 -0700237 commit_summary = desc.split('\n', 1)[0]
238 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500239
Josh Gao25abf4b2016-09-23 18:36:27 -0700240 for name, hook in hooks:
241 output.hook_start(name)
242 hook_results = hook(project, commit, desc, diff)
243 error = _process_hook_results(hook_results)
244 if error:
245 ret = False
246 output.hook_error(name, error)
247
248 output.finish()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500249 os.chdir(pwd)
250 return ret
251
252
253def main(project_list, worktree_list=None, **_kwargs):
254 """Main function invoked directly by repo.
255
256 We must use the name "main" as that is what repo requires.
257
258 This function will exit directly upon error so that repo doesn't print some
259 obscure error message.
260
261 Args:
262 project_list: List of projects to run on.
263 worktree_list: A list of directories. It should be the same length as
264 project_list, so that each entry in project_list matches with a
265 directory in worktree_list. If None, we will attempt to calculate
266 the directories automatically.
267 kwargs: Leave this here for forward-compatibility.
268 """
269 found_error = False
270 if not worktree_list:
271 worktree_list = [None] * len(project_list)
272 for project, worktree in zip(project_list, worktree_list):
273 if not _run_project_hooks(project, proj_dir=worktree):
274 found_error = True
275
276 if found_error:
277 color = rh.terminal.Color()
278 print('%s: Preupload failed due to above error(s).\n'
279 'For more info, please see:\n%s' %
280 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
281 file=sys.stderr)
282 sys.exit(1)
283
284
285def _identify_project(path):
286 """Identify the repo project associated with the given path.
287
288 Returns:
289 A string indicating what project is associated with the path passed in or
290 a blank string upon failure.
291 """
292 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
293 return rh.utils.run_command(cmd, capture_output=True, redirect_stderr=True,
294 cwd=path).output.strip()
295
296
297def direct_main(argv):
298 """Run hooks directly (outside of the context of repo).
299
300 Args:
301 argv: The command line args to process.
302
303 Returns:
304 0 if no pre-upload failures, 1 if failures.
305
306 Raises:
307 BadInvocation: On some types of invocation errors.
308 """
309 parser = argparse.ArgumentParser(description=__doc__)
310 parser.add_argument('--dir', default=None,
311 help='The directory that the project lives in. If not '
312 'specified, use the git project root based on the cwd.')
313 parser.add_argument('--project', default=None,
314 help='The project repo path; this can affect how the '
315 'hooks get run, since some hooks are project-specific.'
316 'If not specified, `repo` will be used to figure this '
317 'out based on the dir.')
318 parser.add_argument('commits', nargs='*',
319 help='Check specific commits')
320 opts = parser.parse_args(argv)
321
322 # Check/normalize git dir; if unspecified, we'll use the root of the git
323 # project from CWD.
324 if opts.dir is None:
325 cmd = ['git', 'rev-parse', '--git-dir']
326 git_dir = rh.utils.run_command(cmd, capture_output=True,
327 redirect_stderr=True).output.strip()
328 if not git_dir:
329 parser.error('The current directory is not part of a git project.')
330 opts.dir = os.path.dirname(os.path.abspath(git_dir))
331 elif not os.path.isdir(opts.dir):
332 parser.error('Invalid dir: %s' % opts.dir)
333 elif not os.path.isdir(os.path.join(opts.dir, '.git')):
334 parser.error('Not a git directory: %s' % opts.dir)
335
336 # Identify the project if it wasn't specified; this _requires_ the repo
337 # tool to be installed and for the project to be part of a repo checkout.
338 if not opts.project:
339 opts.project = _identify_project(opts.dir)
340 if not opts.project:
341 parser.error("Repo couldn't identify the project of %s" % opts.dir)
342
343 if _run_project_hooks(opts.project, proj_dir=opts.dir,
344 commit_list=opts.commits):
345 return 0
346 else:
347 return 1
348
349
350if __name__ == '__main__':
351 sys.exit(direct_main(sys.argv[1:]))