blob: cc8d24a9201b5b0e121faeafc6196bc63d18bebf [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
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
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.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700203 config = _get_project_config()
204 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400205 if not hooks:
206 return True
207
Mike Frysinger2e65c542016-03-08 16:17:00 -0500208 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700209 try:
210 remote = rh.git.get_upstream_remote()
211 except rh.utils.RunCommandError as e:
212 print('upstream remote cannot be found: %s' % (e,), file=sys.stderr)
213 print('Did you run repo start?', file=sys.stderr)
214 sys.exit(1)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500215 os.environ.update({
216 'REPO_PROJECT': project_name,
217 'REPO_PATH': proj_dir,
218 'REPO_REMOTE': remote,
219 })
220
Josh Gao25abf4b2016-09-23 18:36:27 -0700221 output = Output(project_name, len(hooks))
Mike Frysinger2e65c542016-03-08 16:17:00 -0500222 project = Project(name=project_name, dir=proj_dir, remote=remote)
223
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:]))