blob: f15a9b71f34a8e134677333b7b5b73682b4e50a0 [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()
208 except rh.utils.RunCommandError as e:
209 print('upstream remote cannot be found: %s' % (e,), file=sys.stderr)
210 print('Did you run repo start?', file=sys.stderr)
211 sys.exit(1)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500212 os.environ.update({
213 'REPO_PROJECT': project_name,
214 'REPO_PATH': proj_dir,
215 'REPO_REMOTE': remote,
216 })
217
Josh Gao25abf4b2016-09-23 18:36:27 -0700218 output = Output(project_name, len(hooks))
Mike Frysingerb9608182016-10-20 20:45:04 -0400219 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500220
221 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700222 commit_list = rh.git.get_commits(
223 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500224
Mike Frysinger2e65c542016-03-08 16:17:00 -0500225 ret = True
Josh Gao25abf4b2016-09-23 18:36:27 -0700226
Mike Frysinger2e65c542016-03-08 16:17:00 -0500227 for commit in commit_list:
228 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400229 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500230 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400231 desc = rh.git.get_commit_desc(commit)
Nicolas Sylvain6f798862016-05-02 18:58:22 -0700232 os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
Mike Frysinger2e65c542016-03-08 16:17:00 -0500233
Josh Gao25abf4b2016-09-23 18:36:27 -0700234 commit_summary = desc.split('\n', 1)[0]
235 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500236
Josh Gao25abf4b2016-09-23 18:36:27 -0700237 for name, hook in hooks:
238 output.hook_start(name)
239 hook_results = hook(project, commit, desc, diff)
240 error = _process_hook_results(hook_results)
241 if error:
242 ret = False
243 output.hook_error(name, error)
244
245 output.finish()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500246 os.chdir(pwd)
247 return ret
248
249
250def main(project_list, worktree_list=None, **_kwargs):
251 """Main function invoked directly by repo.
252
253 We must use the name "main" as that is what repo requires.
254
255 This function will exit directly upon error so that repo doesn't print some
256 obscure error message.
257
258 Args:
259 project_list: List of projects to run on.
260 worktree_list: A list of directories. It should be the same length as
261 project_list, so that each entry in project_list matches with a
262 directory in worktree_list. If None, we will attempt to calculate
263 the directories automatically.
264 kwargs: Leave this here for forward-compatibility.
265 """
266 found_error = False
267 if not worktree_list:
268 worktree_list = [None] * len(project_list)
269 for project, worktree in zip(project_list, worktree_list):
270 if not _run_project_hooks(project, proj_dir=worktree):
271 found_error = True
272
273 if found_error:
274 color = rh.terminal.Color()
275 print('%s: Preupload failed due to above error(s).\n'
276 'For more info, please see:\n%s' %
277 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
278 file=sys.stderr)
279 sys.exit(1)
280
281
282def _identify_project(path):
283 """Identify the repo project associated with the given path.
284
285 Returns:
286 A string indicating what project is associated with the path passed in or
287 a blank string upon failure.
288 """
289 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
290 return rh.utils.run_command(cmd, capture_output=True, redirect_stderr=True,
291 cwd=path).output.strip()
292
293
294def direct_main(argv):
295 """Run hooks directly (outside of the context of repo).
296
297 Args:
298 argv: The command line args to process.
299
300 Returns:
301 0 if no pre-upload failures, 1 if failures.
302
303 Raises:
304 BadInvocation: On some types of invocation errors.
305 """
306 parser = argparse.ArgumentParser(description=__doc__)
307 parser.add_argument('--dir', default=None,
308 help='The directory that the project lives in. If not '
309 'specified, use the git project root based on the cwd.')
310 parser.add_argument('--project', default=None,
311 help='The project repo path; this can affect how the '
312 'hooks get run, since some hooks are project-specific.'
313 'If not specified, `repo` will be used to figure this '
314 'out based on the dir.')
315 parser.add_argument('commits', nargs='*',
316 help='Check specific commits')
317 opts = parser.parse_args(argv)
318
319 # Check/normalize git dir; if unspecified, we'll use the root of the git
320 # project from CWD.
321 if opts.dir is None:
322 cmd = ['git', 'rev-parse', '--git-dir']
323 git_dir = rh.utils.run_command(cmd, capture_output=True,
324 redirect_stderr=True).output.strip()
325 if not git_dir:
326 parser.error('The current directory is not part of a git project.')
327 opts.dir = os.path.dirname(os.path.abspath(git_dir))
328 elif not os.path.isdir(opts.dir):
329 parser.error('Invalid dir: %s' % opts.dir)
330 elif not os.path.isdir(os.path.join(opts.dir, '.git')):
331 parser.error('Not a git directory: %s' % opts.dir)
332
333 # Identify the project if it wasn't specified; this _requires_ the repo
334 # tool to be installed and for the project to be part of a repo checkout.
335 if not opts.project:
336 opts.project = _identify_project(opts.dir)
337 if not opts.project:
338 parser.error("Repo couldn't identify the project of %s" % opts.dir)
339
340 if _run_project_hooks(opts.project, proj_dir=opts.dir,
341 commit_list=opts.commits):
342 return 0
343 else:
344 return 1
345
346
347if __name__ == '__main__':
348 sys.exit(direct_main(sys.argv[1:]))