blob: d9b1b1e9fdb00e0534bcfc60eb1367022ba7a540 [file] [log] [blame]
Mike Frysinger76987782020-07-15 00:45:14 -04001#!/usr/bin/env python3
Mike Frysinger2e65c542016-03-08 16:17:00 -05002# Copyright 2016 The Android Open Source Project
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16"""Repo pre-upload hook.
17
18Normally this is loaded indirectly by repo itself, but it can be run directly
19when developing.
20"""
21
Mike Frysinger2e65c542016-03-08 16:17:00 -050022import argparse
Mike Frysinger579111e2019-12-04 21:36:01 -050023import datetime
Mike Frysinger2e65c542016-03-08 16:17:00 -050024import os
25import sys
26
Mike Frysinger7b30f4d2019-10-15 01:18:38 -040027
28# Assert some minimum Python versions as we don't test or support any others.
Mike Frysinger76987782020-07-15 00:45:14 -040029if sys.version_info < (3, 5):
Mike Frysinger07107e62020-07-15 00:40:47 -040030 print('repohooks: error: Python-3.5+ is required', file=sys.stderr)
Mike Frysinger7b30f4d2019-10-15 01:18:38 -040031 sys.exit(1)
32
33
Mike Frysinger2e65c542016-03-08 16:17:00 -050034_path = os.path.dirname(os.path.realpath(__file__))
35if sys.path[0] != _path:
36 sys.path.insert(0, _path)
37del _path
38
Mike Frysinger2ef213c2017-11-10 15:41:56 -050039# We have to import our local modules after the sys.path tweak. We can't use
40# relative imports because this is an executable program, not a module.
41# pylint: disable=wrong-import-position
Mike Frysingerb9608182016-10-20 20:45:04 -040042import rh
Mike Frysinger2e65c542016-03-08 16:17:00 -050043import rh.results
44import rh.config
45import rh.git
46import rh.hooks
47import rh.terminal
48import rh.utils
49
50
51# Repohooks homepage.
52REPOHOOKS_URL = 'https://android.googlesource.com/platform/tools/repohooks/'
53
54
Josh Gao25abf4b2016-09-23 18:36:27 -070055class Output(object):
56 """Class for reporting hook status."""
57
58 COLOR = rh.terminal.Color()
59 COMMIT = COLOR.color(COLOR.CYAN, 'COMMIT')
60 RUNNING = COLOR.color(COLOR.YELLOW, 'RUNNING')
61 PASSED = COLOR.color(COLOR.GREEN, 'PASSED')
62 FAILED = COLOR.color(COLOR.RED, 'FAILED')
Jason Monk0886c912017-11-10 13:17:17 -050063 WARNING = COLOR.color(COLOR.YELLOW, 'WARNING')
Josh Gao25abf4b2016-09-23 18:36:27 -070064
Mike Frysinger42234b72019-02-15 16:21:41 -050065 def __init__(self, project_name):
Josh Gao25abf4b2016-09-23 18:36:27 -070066 """Create a new Output object for a specified project.
67
68 Args:
69 project_name: name of project.
Josh Gao25abf4b2016-09-23 18:36:27 -070070 """
71 self.project_name = project_name
Mike Frysinger42234b72019-02-15 16:21:41 -050072 self.num_hooks = None
Josh Gao25abf4b2016-09-23 18:36:27 -070073 self.hook_index = 0
74 self.success = True
Mike Frysinger579111e2019-12-04 21:36:01 -050075 self.start_time = datetime.datetime.now()
Josh Gao25abf4b2016-09-23 18:36:27 -070076
Mike Frysinger42234b72019-02-15 16:21:41 -050077 def set_num_hooks(self, num_hooks):
78 """Keep track of how many hooks we'll be running.
79
80 Args:
81 num_hooks: number of hooks to be run.
82 """
83 self.num_hooks = num_hooks
84
Josh Gao25abf4b2016-09-23 18:36:27 -070085 def commit_start(self, commit, commit_summary):
86 """Emit status for new commit.
87
88 Args:
89 commit: commit hash.
90 commit_summary: commit summary.
91 """
92 status_line = '[%s %s] %s' % (self.COMMIT, commit[0:12], commit_summary)
93 rh.terminal.print_status_line(status_line, print_newline=True)
94 self.hook_index = 1
95
96 def hook_start(self, hook_name):
97 """Emit status before the start of a hook.
98
99 Args:
100 hook_name: name of the hook.
101 """
102 status_line = '[%s %d/%d] %s' % (self.RUNNING, self.hook_index,
103 self.num_hooks, hook_name)
104 self.hook_index += 1
105 rh.terminal.print_status_line(status_line)
106
107 def hook_error(self, hook_name, error):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500108 """Print an error for a single hook.
Josh Gao25abf4b2016-09-23 18:36:27 -0700109
110 Args:
111 hook_name: name of the hook.
112 error: error string.
113 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500114 self.error(hook_name, error)
Josh Gao25abf4b2016-09-23 18:36:27 -0700115
Jason Monk0886c912017-11-10 13:17:17 -0500116 def hook_warning(self, hook_name, warning):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500117 """Print a warning for a single hook.
Jason Monk0886c912017-11-10 13:17:17 -0500118
119 Args:
120 hook_name: name of the hook.
121 warning: warning string.
122 """
123 status_line = '[%s] %s' % (self.WARNING, hook_name)
124 rh.terminal.print_status_line(status_line, print_newline=True)
125 print(warning, file=sys.stderr)
126
Mike Frysingera18d5f12019-02-15 16:27:35 -0500127 def error(self, header, error):
128 """Print a general error.
129
130 Args:
131 header: A unique identifier for the source of this error.
132 error: error string.
133 """
134 status_line = '[%s] %s' % (self.FAILED, header)
135 rh.terminal.print_status_line(status_line, print_newline=True)
136 print(error, file=sys.stderr)
137 self.success = False
138
Josh Gao25abf4b2016-09-23 18:36:27 -0700139 def finish(self):
Mike Frysingera18d5f12019-02-15 16:27:35 -0500140 """Print summary for all the hooks."""
Mike Frysinger579111e2019-12-04 21:36:01 -0500141 status_line = '[%s] repohooks for %s %s in %s' % (
Josh Gao25abf4b2016-09-23 18:36:27 -0700142 self.PASSED if self.success else self.FAILED,
143 self.project_name,
Mike Frysinger579111e2019-12-04 21:36:01 -0500144 'passed' if self.success else 'failed',
145 rh.utils.timedelta_str(datetime.datetime.now() - self.start_time))
Josh Gao25abf4b2016-09-23 18:36:27 -0700146 rh.terminal.print_status_line(status_line, print_newline=True)
147
148
149def _process_hook_results(results):
150 """Returns an error string if an error occurred.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500151
152 Args:
Josh Gao25abf4b2016-09-23 18:36:27 -0700153 results: A list of HookResult objects, or None.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500154
155 Returns:
Josh Gao25abf4b2016-09-23 18:36:27 -0700156 error output if an error occurred, otherwise None
Jason Monk0886c912017-11-10 13:17:17 -0500157 warning output if an error occurred, otherwise None
Mike Frysinger2e65c542016-03-08 16:17:00 -0500158 """
Josh Gao25abf4b2016-09-23 18:36:27 -0700159 if not results:
Jason Monk0886c912017-11-10 13:17:17 -0500160 return (None, None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500161
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400162 # We track these as dedicated fields in case a hook doesn't output anything.
163 # We want to treat silent non-zero exits as failures too.
164 has_error = False
165 has_warning = False
166
Jason Monk0886c912017-11-10 13:17:17 -0500167 error_ret = ''
168 warning_ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500169 for result in results:
170 if result:
Jason Monk0886c912017-11-10 13:17:17 -0500171 ret = ''
Mike Frysinger2e65c542016-03-08 16:17:00 -0500172 if result.files:
Josh Gao25abf4b2016-09-23 18:36:27 -0700173 ret += ' FILES: %s' % (result.files,)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500174 lines = result.error.splitlines()
Josh Gao25abf4b2016-09-23 18:36:27 -0700175 ret += '\n'.join(' %s' % (x,) for x in lines)
Jason Monk0886c912017-11-10 13:17:17 -0500176 if result.is_warning():
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400177 has_warning = True
Jason Monk0886c912017-11-10 13:17:17 -0500178 warning_ret += ret
179 else:
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400180 has_error = True
Jason Monk0886c912017-11-10 13:17:17 -0500181 error_ret += ret
Mike Frysinger2e65c542016-03-08 16:17:00 -0500182
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400183 return (error_ret if has_error else None,
184 warning_ret if has_warning else None)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500185
186
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700187def _get_project_config():
188 """Returns the configuration for a project.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500189
190 Expects to be called from within the project root.
191 """
Mike Frysingerca797702016-09-03 02:00:55 -0400192 global_paths = (
193 # Load the global config found in the manifest repo.
194 os.path.join(rh.git.find_repo_root(), '.repo', 'manifests'),
195 # Load the global config found in the root of the repo checkout.
196 rh.git.find_repo_root(),
197 )
198 paths = (
199 # Load the config for this git repo.
200 '.',
201 )
Mike Frysinger1baec122020-08-25 00:27:52 -0400202 return rh.config.PreUploadSettings(paths=paths, global_paths=global_paths)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500203
204
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800205def _attempt_fixes(fixup_func_list, commit_list):
206 """Attempts to run |fixup_func_list| given |commit_list|."""
207 if len(fixup_func_list) != 1:
208 # Only single fixes will be attempted, since various fixes might
209 # interact with each other.
210 return
211
212 hook_name, commit, fixup_func = fixup_func_list[0]
213
214 if commit != commit_list[0]:
215 # If the commit is not at the top of the stack, git operations might be
216 # needed and might leave the working directory in a tricky state if the
217 # fix is attempted to run automatically (e.g. it might require manual
218 # merge conflict resolution). Refuse to run the fix in those cases.
219 return
220
221 prompt = ('An automatic fix can be attempted for the "%s" hook. '
222 'Do you want to run it?' % hook_name)
223 if not rh.terminal.boolean_prompt(prompt):
224 return
225
226 result = fixup_func()
227 if result:
228 print('Attempt to fix "%s" for commit "%s" failed: %s' %
229 (hook_name, commit, result),
230 file=sys.stderr)
231 else:
232 print('Fix successfully applied. Amend the current commit before '
233 'attempting to upload again.\n', file=sys.stderr)
234
235
Mike Frysinger42234b72019-02-15 16:21:41 -0500236def _run_project_hooks_in_cwd(project_name, proj_dir, output, commit_list=None):
237 """Run the project-specific hooks in the cwd.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500238
239 Args:
Mike Frysinger42234b72019-02-15 16:21:41 -0500240 project_name: The name of this project.
241 proj_dir: The directory for this project (for passing on in metadata).
242 output: Helper for summarizing output/errors to the user.
Mike Frysinger2e65c542016-03-08 16:17:00 -0500243 commit_list: A list of commits to run hooks against. If None or empty
244 list then we'll automatically get the list of commits that would be
245 uploaded.
246
247 Returns:
248 False if any errors were found, else True.
249 """
Mike Frysingera18d5f12019-02-15 16:27:35 -0500250 try:
251 config = _get_project_config()
252 except rh.config.ValidationError as e:
253 output.error('Loading config files', str(e))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500254 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500255
256 # If the repo has no pre-upload hooks enabled, then just return.
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700257 hooks = list(config.callable_hooks())
Mike Frysinger558aff42016-04-04 16:02:55 -0400258 if not hooks:
259 return True
260
Mike Frysinger42234b72019-02-15 16:21:41 -0500261 output.set_num_hooks(len(hooks))
262
Mike Frysinger2e65c542016-03-08 16:17:00 -0500263 # Set up the environment like repo would with the forall command.
Luis Hector Chavezd8f36752016-09-15 13:25:03 -0700264 try:
265 remote = rh.git.get_upstream_remote()
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800266 upstream_branch = rh.git.get_upstream_branch()
Mike Frysinger36d2ce62019-12-04 22:17:07 -0500267 except rh.utils.CalledProcessError as e:
Mike Frysingera18d5f12019-02-15 16:27:35 -0500268 output.error('Upstream remote/tracking branch lookup',
269 '%s\nDid you run repo start? Is your HEAD detached?' %
270 (e,))
Mike Frysingera65ecb92019-02-15 15:58:31 -0500271 return False
Mike Frysingera18d5f12019-02-15 16:27:35 -0500272
Thiébaud Weksteenea528202020-08-28 15:54:29 +0200273 project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
274 rel_proj_dir = os.path.relpath(proj_dir, rh.git.find_repo_root())
275
Mike Frysinger2e65c542016-03-08 16:17:00 -0500276 os.environ.update({
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800277 'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
Thiébaud Weksteenea528202020-08-28 15:54:29 +0200278 'REPO_PATH': rel_proj_dir,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800279 'REPO_PROJECT': project_name,
Mike Frysinger2e65c542016-03-08 16:17:00 -0500280 'REPO_REMOTE': remote,
Luis Hector Chaveze9db4eb2016-11-29 16:20:12 -0800281 'REPO_RREV': rh.git.get_remote_revision(upstream_branch, remote),
Mike Frysinger2e65c542016-03-08 16:17:00 -0500282 })
283
Mike Frysinger2e65c542016-03-08 16:17:00 -0500284 if not commit_list:
Luis Hector Chavez5c4c2932016-10-16 21:56:58 -0700285 commit_list = rh.git.get_commits(
286 ignore_merged_commits=config.ignore_merged_commits)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500287
Mike Frysinger2e65c542016-03-08 16:17:00 -0500288 ret = True
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800289 fixup_func_list = []
Josh Gao25abf4b2016-09-23 18:36:27 -0700290
Mike Frysinger2e65c542016-03-08 16:17:00 -0500291 for commit in commit_list:
292 # Mix in some settings for our hooks.
Mike Frysingerdc253622016-04-04 15:43:02 -0400293 os.environ['PREUPLOAD_COMMIT'] = commit
Mike Frysinger2e65c542016-03-08 16:17:00 -0500294 diff = rh.git.get_affected_files(commit)
Mike Frysinger76b1bc72016-04-20 16:50:16 -0400295 desc = rh.git.get_commit_desc(commit)
Mike Frysinger737bf272020-07-15 00:55:02 -0400296 os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
Mike Frysinger2e65c542016-03-08 16:17:00 -0500297
Josh Gao25abf4b2016-09-23 18:36:27 -0700298 commit_summary = desc.split('\n', 1)[0]
299 output.commit_start(commit=commit, commit_summary=commit_summary)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500300
Thiébaud Weksteenea528202020-08-28 15:54:29 +0200301 for name, hook, exclusion_scope in hooks:
Josh Gao25abf4b2016-09-23 18:36:27 -0700302 output.hook_start(name)
Thiébaud Weksteenea528202020-08-28 15:54:29 +0200303 if rel_proj_dir in exclusion_scope:
304 break
Josh Gao25abf4b2016-09-23 18:36:27 -0700305 hook_results = hook(project, commit, desc, diff)
Jason Monk0886c912017-11-10 13:17:17 -0500306 (error, warning) = _process_hook_results(hook_results)
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400307 if error is not None or warning is not None:
308 if warning is not None:
Jason Monk0886c912017-11-10 13:17:17 -0500309 output.hook_warning(name, warning)
Mike Frysingere5ad9af2020-03-27 22:29:01 -0400310 if error is not None:
Jason Monk0886c912017-11-10 13:17:17 -0500311 ret = False
312 output.hook_error(name, error)
Luis Hector Chavezdab680c2016-12-21 13:57:09 -0800313 for result in hook_results:
314 if result.fixup_func:
315 fixup_func_list.append((name, commit,
316 result.fixup_func))
317
318 if fixup_func_list:
319 _attempt_fixes(fixup_func_list, commit_list)
Josh Gao25abf4b2016-09-23 18:36:27 -0700320
Mike Frysinger2e65c542016-03-08 16:17:00 -0500321 return ret
322
323
Mike Frysinger42234b72019-02-15 16:21:41 -0500324def _run_project_hooks(project_name, proj_dir=None, commit_list=None):
325 """Run the project-specific hooks in |proj_dir|.
326
327 Args:
328 project_name: The name of project to run hooks for.
329 proj_dir: If non-None, this is the directory the project is in. If None,
330 we'll ask repo.
331 commit_list: A list of commits to run hooks against. If None or empty
332 list then we'll automatically get the list of commits that would be
333 uploaded.
334
335 Returns:
336 False if any errors were found, else True.
337 """
338 output = Output(project_name)
339
340 if proj_dir is None:
341 cmd = ['repo', 'forall', project_name, '-c', 'pwd']
Mike Frysinger70b78f02019-12-04 21:42:39 -0500342 result = rh.utils.run(cmd, capture_output=True)
Mike Frysinger36d2ce62019-12-04 22:17:07 -0500343 proj_dirs = result.stdout.split()
Mike Frysinger5ac20862019-06-05 22:50:49 -0400344 if not proj_dirs:
Mike Frysinger42234b72019-02-15 16:21:41 -0500345 print('%s cannot be found.' % project_name, file=sys.stderr)
346 print('Please specify a valid project.', file=sys.stderr)
347 return False
348 if len(proj_dirs) > 1:
349 print('%s is associated with multiple directories.' % project_name,
350 file=sys.stderr)
351 print('Please specify a directory to help disambiguate.',
352 file=sys.stderr)
353 return False
354 proj_dir = proj_dirs[0]
355
356 pwd = os.getcwd()
357 try:
358 # Hooks assume they are run from the root of the project.
359 os.chdir(proj_dir)
360 return _run_project_hooks_in_cwd(project_name, proj_dir, output,
361 commit_list=commit_list)
362 finally:
363 output.finish()
364 os.chdir(pwd)
365
366
Mike Frysinger2e65c542016-03-08 16:17:00 -0500367def main(project_list, worktree_list=None, **_kwargs):
368 """Main function invoked directly by repo.
369
370 We must use the name "main" as that is what repo requires.
371
372 This function will exit directly upon error so that repo doesn't print some
373 obscure error message.
374
375 Args:
376 project_list: List of projects to run on.
377 worktree_list: A list of directories. It should be the same length as
378 project_list, so that each entry in project_list matches with a
379 directory in worktree_list. If None, we will attempt to calculate
380 the directories automatically.
381 kwargs: Leave this here for forward-compatibility.
382 """
383 found_error = False
384 if not worktree_list:
385 worktree_list = [None] * len(project_list)
386 for project, worktree in zip(project_list, worktree_list):
387 if not _run_project_hooks(project, proj_dir=worktree):
388 found_error = True
Mike Frysingera18d5f12019-02-15 16:27:35 -0500389 # If a repo had failures, add a blank line to help break up the
390 # output. If there were no failures, then the output should be
391 # very minimal, so we don't add it then.
392 print('', file=sys.stderr)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500393
394 if found_error:
395 color = rh.terminal.Color()
396 print('%s: Preupload failed due to above error(s).\n'
397 'For more info, please see:\n%s' %
398 (color.color(color.RED, 'FATAL'), REPOHOOKS_URL),
399 file=sys.stderr)
400 sys.exit(1)
401
402
403def _identify_project(path):
404 """Identify the repo project associated with the given path.
405
406 Returns:
407 A string indicating what project is associated with the path passed in or
408 a blank string upon failure.
409 """
410 cmd = ['repo', 'forall', '.', '-c', 'echo ${REPO_PROJECT}']
Mike Frysinger7bd2a9a2020-04-08 18:23:57 -0400411 return rh.utils.run(cmd, capture_output=True, cwd=path).stdout.strip()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500412
413
414def direct_main(argv):
415 """Run hooks directly (outside of the context of repo).
416
417 Args:
418 argv: The command line args to process.
419
420 Returns:
421 0 if no pre-upload failures, 1 if failures.
422
423 Raises:
424 BadInvocation: On some types of invocation errors.
425 """
426 parser = argparse.ArgumentParser(description=__doc__)
427 parser.add_argument('--dir', default=None,
428 help='The directory that the project lives in. If not '
429 'specified, use the git project root based on the cwd.')
430 parser.add_argument('--project', default=None,
431 help='The project repo path; this can affect how the '
432 'hooks get run, since some hooks are project-specific.'
433 'If not specified, `repo` will be used to figure this '
434 'out based on the dir.')
435 parser.add_argument('commits', nargs='*',
436 help='Check specific commits')
437 opts = parser.parse_args(argv)
438
439 # Check/normalize git dir; if unspecified, we'll use the root of the git
440 # project from CWD.
441 if opts.dir is None:
442 cmd = ['git', 'rev-parse', '--git-dir']
Mike Frysinger7bd2a9a2020-04-08 18:23:57 -0400443 git_dir = rh.utils.run(cmd, capture_output=True).stdout.strip()
Mike Frysinger2e65c542016-03-08 16:17:00 -0500444 if not git_dir:
445 parser.error('The current directory is not part of a git project.')
446 opts.dir = os.path.dirname(os.path.abspath(git_dir))
447 elif not os.path.isdir(opts.dir):
448 parser.error('Invalid dir: %s' % opts.dir)
Adrian Roos8ac865f2018-04-13 12:08:52 +0100449 elif not rh.git.is_git_repository(opts.dir):
450 parser.error('Not a git repository: %s' % opts.dir)
Mike Frysinger2e65c542016-03-08 16:17:00 -0500451
452 # Identify the project if it wasn't specified; this _requires_ the repo
453 # tool to be installed and for the project to be part of a repo checkout.
454 if not opts.project:
455 opts.project = _identify_project(opts.dir)
456 if not opts.project:
457 parser.error("Repo couldn't identify the project of %s" % opts.dir)
458
459 if _run_project_hooks(opts.project, proj_dir=opts.dir,
460 commit_list=opts.commits):
461 return 0
Mike Frysinger5ac20862019-06-05 22:50:49 -0400462 return 1
Mike Frysinger2e65c542016-03-08 16:17:00 -0500463
464
465if __name__ == '__main__':
466 sys.exit(direct_main(sys.argv[1:]))