blob: 5156bbf782a8930f45ec021571c47afd3c30dd5a [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
56def _process_hook_results(project, commit, commit_desc, results):
57 """Prints the hook error to stderr with project and commit context
58
59 Args:
60 project: The project name.
61 commit: The commit hash the errors belong to.
62 commit_desc: A string containing the commit message.
63 results: A list of HookResult objects.
64
65 Returns:
66 False if any errors were found, else True.
67 """
68 color = rh.terminal.Color()
69 def _print_banner():
70 print('%s: %s: hooks failed' %
71 (color.color(color.RED, 'ERROR'), project),
72 file=sys.stderr)
73
74 commit_summary = commit_desc.splitlines()[0]
75 print('COMMIT: %s (%s)' % (commit[0:12], commit_summary),
76 file=sys.stderr)
77
78 ret = True
79 for result in results:
80 if result:
81 if ret:
82 _print_banner()
83 ret = False
84
85 print('%s: %s' % (color.color(color.CYAN, 'HOOK'), result.hook),
86 file=sys.stderr)
87 if result.files:
88 print(' FILES: %s' % (result.files,), file=sys.stderr)
89 lines = result.error.splitlines()
90 print('\n'.join(' %s' % (x,) for x in lines), file=sys.stderr)
91 print('', file=sys.stderr)
92
93 return ret
94
95
96def _get_project_hooks():
97 """Returns a list of hooks that need to be run for a project.
98
99 Expects to be called from within the project root.
100 """
101 try:
102 config = rh.config.PreSubmitConfig()
103 except rh.config.ValidationError as e:
104 print('invalid config file: %s' % (e,), file=sys.stderr)
105 sys.exit(1)
106 return config.callable_hooks()
107
108
109def _run_project_hooks(project_name, proj_dir=None,
110 commit_list=None):
111 """For each project run its project specific hook from the hooks dictionary.
112
113 Args:
114 project_name: The name of project to run hooks for.
115 proj_dir: If non-None, this is the directory the project is in. If None,
116 we'll ask repo.
117 commit_list: A list of commits to run hooks against. If None or empty
118 list then we'll automatically get the list of commits that would be
119 uploaded.
120
121 Returns:
122 False if any errors were found, else True.
123 """
124 if proj_dir is None:
125 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
126 result = rh.utils.run_command(cmd, capture_output=True)
127 proj_dirs = result.output.split()
128 if len(proj_dirs) == 0:
129 print('%s cannot be found.' % project_name, file=sys.stderr)
130 print('Please specify a valid project.', file=sys.stderr)
131 return 0
132 if len(proj_dirs) > 1:
133 print('%s is associated with multiple directories.' % project_name,
134 file=sys.stderr)
135 print('Please specify a directory to help disambiguate.',
136 file=sys.stderr)
137 return 0
138 proj_dir = proj_dirs[0]
139
140 pwd = os.getcwd()
141 # Hooks assume they are run from the root of the project.
142 os.chdir(proj_dir)
143
Mike Frysinger558aff42016-04-04 16:02:55 -0400144 # If the repo has no pre-upload hooks enabled, then just return.
145 hooks = list(_get_project_hooks())
146 if not hooks:
147 return True
148
Mike Frysinger2e65c542016-03-08 16:17:00 -0500149 cmd = ['git', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']
150 result = rh.utils.run_command(cmd, capture_output=True)
151 remote_branch = result.output.strip()
152 if not remote_branch:
153 print('Your project %s doesn\'t track any remote repo.' % project_name,
154 file=sys.stderr)
155 remote = None
156 else:
157 remote, _branch = remote_branch.split('/', 1)
158
159 # Set up the environment like repo would with the forall command.
160 os.environ.update({
161 'REPO_PROJECT': project_name,
162 'REPO_PATH': proj_dir,
163 'REPO_REMOTE': remote,
164 })
165
166 project = Project(name=project_name, dir=proj_dir, remote=remote)
167
168 if not commit_list:
169 commit_list = rh.git.get_commits()
170
Mike Frysinger2e65c542016-03-08 16:17:00 -0500171 ret = True
172 for commit in commit_list:
173 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400174 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500175 diff = rh.git.get_affected_files(commit)
176
177 results = []
178 for hook in hooks:
179 hook_results = hook(project, commit, diff)
180 if hook_results:
181 results.extend(hook_results)
182 if results:
183 desc = rh.git.get_commit_desc(commit)
184 if not _process_hook_results(project.name, commit, desc, results):
185 ret = False
186
187 os.chdir(pwd)
188 return ret
189
190
191def main(project_list, worktree_list=None, **_kwargs):
192 """Main function invoked directly by repo.
193
194 We must use the name "main" as that is what repo requires.
195
196 This function will exit directly upon error so that repo doesn't print some
197 obscure error message.
198
199 Args:
200 project_list: List of projects to run on.
201 worktree_list: A list of directories. It should be the same length as
202 project_list, so that each entry in project_list matches with a
203 directory in worktree_list. If None, we will attempt to calculate
204 the directories automatically.
205 kwargs: Leave this here for forward-compatibility.
206 """
207 found_error = False
208 if not worktree_list:
209 worktree_list = [None] * len(project_list)
210 for project, worktree in zip(project_list, worktree_list):
211 if not _run_project_hooks(project, proj_dir=worktree):
212 found_error = True
213
214 if found_error:
215 color = rh.terminal.Color()
216 print('%s: Preupload failed due to above error(s).\n'
217 'For more info, please see:\n%s' %
218 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
219 file=sys.stderr)
220 sys.exit(1)
221
222
223def _identify_project(path):
224 """Identify the repo project associated with the given path.
225
226 Returns:
227 A string indicating what project is associated with the path passed in or
228 a blank string upon failure.
229 """
230 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
231 return rh.utils.run_command(cmd, capture_output=True, redirect_stderr=True,
232 cwd=path).output.strip()
233
234
235def direct_main(argv):
236 """Run hooks directly (outside of the context of repo).
237
238 Args:
239 argv: The command line args to process.
240
241 Returns:
242 0 if no pre-upload failures, 1 if failures.
243
244 Raises:
245 BadInvocation: On some types of invocation errors.
246 """
247 parser = argparse.ArgumentParser(description=__doc__)
248 parser.add_argument('--dir', default=None,
249 help='The directory that the project lives in. If not '
250 'specified, use the git project root based on the cwd.')
251 parser.add_argument('--project', default=None,
252 help='The project repo path; this can affect how the '
253 'hooks get run, since some hooks are project-specific.'
254 'If not specified, `repo` will be used to figure this '
255 'out based on the dir.')
256 parser.add_argument('commits', nargs='*',
257 help='Check specific commits')
258 opts = parser.parse_args(argv)
259
260 # Check/normalize git dir; if unspecified, we'll use the root of the git
261 # project from CWD.
262 if opts.dir is None:
263 cmd = ['git', 'rev-parse', '--git-dir']
264 git_dir = rh.utils.run_command(cmd, capture_output=True,
265 redirect_stderr=True).output.strip()
266 if not git_dir:
267 parser.error('The current directory is not part of a git project.')
268 opts.dir = os.path.dirname(os.path.abspath(git_dir))
269 elif not os.path.isdir(opts.dir):
270 parser.error('Invalid dir: %s' % opts.dir)
271 elif not os.path.isdir(os.path.join(opts.dir, '.git')):
272 parser.error('Not a git directory: %s' % opts.dir)
273
274 # Identify the project if it wasn't specified; this _requires_ the repo
275 # tool to be installed and for the project to be part of a repo checkout.
276 if not opts.project:
277 opts.project = _identify_project(opts.dir)
278 if not opts.project:
279 parser.error("Repo couldn't identify the project of %s" % opts.dir)
280
281 if _run_project_hooks(opts.project, proj_dir=opts.dir,
282 commit_list=opts.commits):
283 return 0
284 else:
285 return 1
286
287
288if __name__ == '__main__':
289 sys.exit(direct_main(sys.argv[1:]))