blob: 61bda1e4105f59b899df546d2381761700b537d8 [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
26import collections
27import os
28import sys
29
30try:
31 __file__
32except NameError:
33 # Work around repo until it gets fixed.
34 # https://gerrit-review.googlesource.com/75481
35 __file__ = os.path.join(os.getcwd(), 'pre-upload.py')
36_path = os.path.dirname(os.path.realpath(__file__))
37if sys.path[0] != _path:
38 sys.path.insert(0, _path)
39del _path
40
41import 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
53Project = collections.namedtuple('Project', ['name', 'dir', 'remote'])
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
143def _get_project_hooks():
144 """Returns a list of hooks that need to be run for a project.
145
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)
164 return config.callable_hooks()
165
166
167def _run_project_hooks(project_name, proj_dir=None,
168 commit_list=None):
169 """For each project run its project specific hook from the hooks dictionary.
170
171 Args:
172 project_name: The name of project to run hooks for.
173 proj_dir: If non-None, this is the directory the project is in. If None,
174 we'll ask repo.
175 commit_list: A list of commits to run hooks against. If None or empty
176 list then we'll automatically get the list of commits that would be
177 uploaded.
178
179 Returns:
180 False if any errors were found, else True.
181 """
182 if proj_dir is None:
183 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
184 result = rh.utils.run_command(cmd, capture_output=True)
185 proj_dirs = result.output.split()
186 if len(proj_dirs) == 0:
187 print('%s cannot be found.' % project_name, file=sys.stderr)
188 print('Please specify a valid project.', file=sys.stderr)
189 return 0
190 if len(proj_dirs) > 1:
191 print('%s is associated with multiple directories.' % project_name,
192 file=sys.stderr)
193 print('Please specify a directory to help disambiguate.',
194 file=sys.stderr)
195 return 0
196 proj_dir = proj_dirs[0]
197
198 pwd = os.getcwd()
199 # Hooks assume they are run from the root of the project.
200 os.chdir(proj_dir)
201
Mike Frysinger558aff42016-04-04 16:02:55 -0400202 # If the repo has no pre-upload hooks enabled, then just return.
203 hooks = list(_get_project_hooks())
204 if not hooks:
205 return True
206
Mike Frysinger2e65c542016-03-08 16:17:00 -0500207 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700208 try:
209 remote = rh.git.get_upstream_remote()
210 except rh.utils.RunCommandError as e:
211 print('upstream remote cannot be found: %s' % (e,), file=sys.stderr)
212 print('Did you run repo start?', file=sys.stderr)
213 sys.exit(1)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500214 os.environ.update({
215 'REPO_PROJECT': project_name,
216 'REPO_PATH': proj_dir,
217 'REPO_REMOTE': remote,
218 })
219
Josh Gao25abf4b2016-09-23 18:36:27 -0700220 output = Output(project_name, len(hooks))
Mike Frysinger2e65c542016-03-08 16:17:00 -0500221 project = Project(name=project_name, dir=proj_dir, remote=remote)
222
223 if not commit_list:
224 commit_list = rh.git.get_commits()
225
Mike Frysinger2e65c542016-03-08 16:17:00 -0500226 ret = True
Josh Gao25abf4b2016-09-23 18:36:27 -0700227
Mike Frysinger2e65c542016-03-08 16:17:00 -0500228 for commit in commit_list:
229 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400230 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500231 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400232 desc = rh.git.get_commit_desc(commit)
Nicolas Sylvain6f798862016-05-02 18:58:22 -0700233 os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
Mike Frysinger2e65c542016-03-08 16:17:00 -0500234
Josh Gao25abf4b2016-09-23 18:36:27 -0700235 commit_summary = desc.split('\n', 1)[0]
236 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500237
Josh Gao25abf4b2016-09-23 18:36:27 -0700238 for name, hook in hooks:
239 output.hook_start(name)
240 hook_results = hook(project, commit, desc, diff)
241 error = _process_hook_results(hook_results)
242 if error:
243 ret = False
244 output.hook_error(name, error)
245
246 output.finish()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500247 os.chdir(pwd)
248 return ret
249
250
251def main(project_list, worktree_list=None, **_kwargs):
252 """Main function invoked directly by repo.
253
254 We must use the name "main" as that is what repo requires.
255
256 This function will exit directly upon error so that repo doesn't print some
257 obscure error message.
258
259 Args:
260 project_list: List of projects to run on.
261 worktree_list: A list of directories. It should be the same length as
262 project_list, so that each entry in project_list matches with a
263 directory in worktree_list. If None, we will attempt to calculate
264 the directories automatically.
265 kwargs: Leave this here for forward-compatibility.
266 """
267 found_error = False
268 if not worktree_list:
269 worktree_list = [None] * len(project_list)
270 for project, worktree in zip(project_list, worktree_list):
271 if not _run_project_hooks(project, proj_dir=worktree):
272 found_error = True
273
274 if found_error:
275 color = rh.terminal.Color()
276 print('%s: Preupload failed due to above error(s).\n'
277 'For more info, please see:\n%s' %
278 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
279 file=sys.stderr)
280 sys.exit(1)
281
282
283def _identify_project(path):
284 """Identify the repo project associated with the given path.
285
286 Returns:
287 A string indicating what project is associated with the path passed in or
288 a blank string upon failure.
289 """
290 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
291 return rh.utils.run_command(cmd, capture_output=True, redirect_stderr=True,
292 cwd=path).output.strip()
293
294
295def direct_main(argv):
296 """Run hooks directly (outside of the context of repo).
297
298 Args:
299 argv: The command line args to process.
300
301 Returns:
302 0 if no pre-upload failures, 1 if failures.
303
304 Raises:
305 BadInvocation: On some types of invocation errors.
306 """
307 parser = argparse.ArgumentParser(description=__doc__)
308 parser.add_argument('--dir', default=None,
309 help='The directory that the project lives in. If not '
310 'specified, use the git project root based on the cwd.')
311 parser.add_argument('--project', default=None,
312 help='The project repo path; this can affect how the '
313 'hooks get run, since some hooks are project-specific.'
314 'If not specified, `repo` will be used to figure this '
315 'out based on the dir.')
316 parser.add_argument('commits', nargs='*',
317 help='Check specific commits')
318 opts = parser.parse_args(argv)
319
320 # Check/normalize git dir; if unspecified, we'll use the root of the git
321 # project from CWD.
322 if opts.dir is None:
323 cmd = ['git', 'rev-parse', '--git-dir']
324 git_dir = rh.utils.run_command(cmd, capture_output=True,
325 redirect_stderr=True).output.strip()
326 if not git_dir:
327 parser.error('The current directory is not part of a git project.')
328 opts.dir = os.path.dirname(os.path.abspath(git_dir))
329 elif not os.path.isdir(opts.dir):
330 parser.error('Invalid dir: %s' % opts.dir)
331 elif not os.path.isdir(os.path.join(opts.dir, '.git')):
332 parser.error('Not a git directory: %s' % opts.dir)
333
334 # Identify the project if it wasn't specified; this _requires_ the repo
335 # tool to be installed and for the project to be part of a repo checkout.
336 if not opts.project:
337 opts.project = _identify_project(opts.dir)
338 if not opts.project:
339 parser.error("Repo couldn't identify the project of %s" % opts.dir)
340
341 if _run_project_hooks(opts.project, proj_dir=opts.dir,
342 commit_list=opts.commits):
343 return 0
344 else:
345 return 1
346
347
348if __name__ == '__main__':
349 sys.exit(direct_main(sys.argv[1:]))