diff options
Diffstat (limited to 'project.py')
-rw-r--r-- | project.py | 266 |
1 files changed, 265 insertions, 1 deletions
@@ -12,6 +12,7 @@ | |||
12 | # See the License for the specific language governing permissions and | 12 | # See the License for the specific language governing permissions and |
13 | # limitations under the License. | 13 | # limitations under the License. |
14 | 14 | ||
15 | import traceback | ||
15 | import errno | 16 | import errno |
16 | import filecmp | 17 | import filecmp |
17 | import os | 18 | import os |
@@ -24,7 +25,7 @@ import urllib2 | |||
24 | from color import Coloring | 25 | from color import Coloring |
25 | from git_command import GitCommand | 26 | from git_command import GitCommand |
26 | from git_config import GitConfig, IsId | 27 | from git_config import GitConfig, IsId |
27 | from error import GitError, ImportError, UploadError | 28 | from error import GitError, HookError, ImportError, UploadError |
28 | from error import ManifestInvalidRevisionError | 29 | from error import ManifestInvalidRevisionError |
29 | 30 | ||
30 | from git_refs import GitRefs, HEAD, R_HEADS, R_TAGS, R_PUB, R_M | 31 | from git_refs import GitRefs, HEAD, R_HEADS, R_TAGS, R_PUB, R_M |
@@ -234,6 +235,249 @@ class RemoteSpec(object): | |||
234 | self.url = url | 235 | self.url = url |
235 | self.review = review | 236 | self.review = review |
236 | 237 | ||
238 | class RepoHook(object): | ||
239 | """A RepoHook contains information about a script to run as a hook. | ||
240 | |||
241 | Hooks are used to run a python script before running an upload (for instance, | ||
242 | to run presubmit checks). Eventually, we may have hooks for other actions. | ||
243 | |||
244 | This shouldn't be confused with files in the 'repo/hooks' directory. Those | ||
245 | files are copied into each '.git/hooks' folder for each project. Repo-level | ||
246 | hooks are associated instead with repo actions. | ||
247 | |||
248 | Hooks are always python. When a hook is run, we will load the hook into the | ||
249 | interpreter and execute its main() function. | ||
250 | """ | ||
251 | def __init__(self, | ||
252 | hook_type, | ||
253 | hooks_project, | ||
254 | topdir, | ||
255 | abort_if_user_denies=False): | ||
256 | """RepoHook constructor. | ||
257 | |||
258 | Params: | ||
259 | hook_type: A string representing the type of hook. This is also used | ||
260 | to figure out the name of the file containing the hook. For | ||
261 | example: 'pre-upload'. | ||
262 | hooks_project: The project containing the repo hooks. If you have a | ||
263 | manifest, this is manifest.repo_hooks_project. OK if this is None, | ||
264 | which will make the hook a no-op. | ||
265 | topdir: Repo's top directory (the one containing the .repo directory). | ||
266 | Scripts will run with CWD as this directory. If you have a manifest, | ||
267 | this is manifest.topdir | ||
268 | abort_if_user_denies: If True, we'll throw a HookError() if the user | ||
269 | doesn't allow us to run the hook. | ||
270 | """ | ||
271 | self._hook_type = hook_type | ||
272 | self._hooks_project = hooks_project | ||
273 | self._topdir = topdir | ||
274 | self._abort_if_user_denies = abort_if_user_denies | ||
275 | |||
276 | # Store the full path to the script for convenience. | ||
277 | if self._hooks_project: | ||
278 | self._script_fullpath = os.path.join(self._hooks_project.worktree, | ||
279 | self._hook_type + '.py') | ||
280 | else: | ||
281 | self._script_fullpath = None | ||
282 | |||
283 | def _GetHash(self): | ||
284 | """Return a hash of the contents of the hooks directory. | ||
285 | |||
286 | We'll just use git to do this. This hash has the property that if anything | ||
287 | changes in the directory we will return a different has. | ||
288 | |||
289 | SECURITY CONSIDERATION: | ||
290 | This hash only represents the contents of files in the hook directory, not | ||
291 | any other files imported or called by hooks. Changes to imported files | ||
292 | can change the script behavior without affecting the hash. | ||
293 | |||
294 | Returns: | ||
295 | A string representing the hash. This will always be ASCII so that it can | ||
296 | be printed to the user easily. | ||
297 | """ | ||
298 | assert self._hooks_project, "Must have hooks to calculate their hash." | ||
299 | |||
300 | # We will use the work_git object rather than just calling GetRevisionId(). | ||
301 | # That gives us a hash of the latest checked in version of the files that | ||
302 | # the user will actually be executing. Specifically, GetRevisionId() | ||
303 | # doesn't appear to change even if a user checks out a different version | ||
304 | # of the hooks repo (via git checkout) nor if a user commits their own revs. | ||
305 | # | ||
306 | # NOTE: Local (non-committed) changes will not be factored into this hash. | ||
307 | # I think this is OK, since we're really only worried about warning the user | ||
308 | # about upstream changes. | ||
309 | return self._hooks_project.work_git.rev_parse('HEAD') | ||
310 | |||
311 | def _GetMustVerb(self): | ||
312 | """Return 'must' if the hook is required; 'should' if not.""" | ||
313 | if self._abort_if_user_denies: | ||
314 | return 'must' | ||
315 | else: | ||
316 | return 'should' | ||
317 | |||
318 | def _CheckForHookApproval(self): | ||
319 | """Check to see whether this hook has been approved. | ||
320 | |||
321 | We'll look at the hash of all of the hooks. If this matches the hash that | ||
322 | the user last approved, we're done. If it doesn't, we'll ask the user | ||
323 | about approval. | ||
324 | |||
325 | Note that we ask permission for each individual hook even though we use | ||
326 | the hash of all hooks when detecting changes. We'd like the user to be | ||
327 | able to approve / deny each hook individually. We only use the hash of all | ||
328 | hooks because there is no other easy way to detect changes to local imports. | ||
329 | |||
330 | Returns: | ||
331 | True if this hook is approved to run; False otherwise. | ||
332 | |||
333 | Raises: | ||
334 | HookError: Raised if the user doesn't approve and abort_if_user_denies | ||
335 | was passed to the consturctor. | ||
336 | """ | ||
337 | hooks_dir = self._hooks_project.worktree | ||
338 | hooks_config = self._hooks_project.config | ||
339 | git_approval_key = 'repo.hooks.%s.approvedhash' % self._hook_type | ||
340 | |||
341 | # Get the last hash that the user approved for this hook; may be None. | ||
342 | old_hash = hooks_config.GetString(git_approval_key) | ||
343 | |||
344 | # Get the current hash so we can tell if scripts changed since approval. | ||
345 | new_hash = self._GetHash() | ||
346 | |||
347 | if old_hash is not None: | ||
348 | # User previously approved hook and asked not to be prompted again. | ||
349 | if new_hash == old_hash: | ||
350 | # Approval matched. We're done. | ||
351 | return True | ||
352 | else: | ||
353 | # Give the user a reason why we're prompting, since they last told | ||
354 | # us to "never ask again". | ||
355 | prompt = 'WARNING: Scripts have changed since %s was allowed.\n\n' % ( | ||
356 | self._hook_type) | ||
357 | else: | ||
358 | prompt = '' | ||
359 | |||
360 | # Prompt the user if we're not on a tty; on a tty we'll assume "no". | ||
361 | if sys.stdout.isatty(): | ||
362 | prompt += ('Repo %s run the script:\n' | ||
363 | ' %s\n' | ||
364 | '\n' | ||
365 | 'Do you want to allow this script to run ' | ||
366 | '(yes/yes-never-ask-again/NO)? ') % ( | ||
367 | self._GetMustVerb(), self._script_fullpath) | ||
368 | response = raw_input(prompt).lower() | ||
369 | |||
370 | |||
371 | # User is doing a one-time approval. | ||
372 | if response in ('y', 'yes'): | ||
373 | return True | ||
374 | elif response == 'yes-never-ask-again': | ||
375 | hooks_config.SetString(git_approval_key, new_hash) | ||
376 | return True | ||
377 | |||
378 | # For anything else, we'll assume no approval. | ||
379 | if self._abort_if_user_denies: | ||
380 | raise HookError('You must allow the %s hook or use --no-verify.' % | ||
381 | self._hook_type) | ||
382 | |||
383 | return False | ||
384 | |||
385 | def _ExecuteHook(self, **kwargs): | ||
386 | """Actually execute the given hook. | ||
387 | |||
388 | This will run the hook's 'main' function in our python interpreter. | ||
389 | |||
390 | Args: | ||
391 | kwargs: Keyword arguments to pass to the hook. These are often specific | ||
392 | to the hook type. For instance, pre-upload hooks will contain | ||
393 | a project_list. | ||
394 | """ | ||
395 | # Keep sys.path and CWD stashed away so that we can always restore them | ||
396 | # upon function exit. | ||
397 | orig_path = os.getcwd() | ||
398 | orig_syspath = sys.path | ||
399 | |||
400 | try: | ||
401 | # Always run hooks with CWD as topdir. | ||
402 | os.chdir(self._topdir) | ||
403 | |||
404 | # Put the hook dir as the first item of sys.path so hooks can do | ||
405 | # relative imports. We want to replace the repo dir as [0] so | ||
406 | # hooks can't import repo files. | ||
407 | sys.path = [os.path.dirname(self._script_fullpath)] + sys.path[1:] | ||
408 | |||
409 | # Exec, storing global context in the context dict. We catch exceptions | ||
410 | # and convert to a HookError w/ just the failing traceback. | ||
411 | context = {} | ||
412 | try: | ||
413 | execfile(self._script_fullpath, context) | ||
414 | except Exception: | ||
415 | raise HookError('%s\nFailed to import %s hook; see traceback above.' % ( | ||
416 | traceback.format_exc(), self._hook_type)) | ||
417 | |||
418 | # Running the script should have defined a main() function. | ||
419 | if 'main' not in context: | ||
420 | raise HookError('Missing main() in: "%s"' % self._script_fullpath) | ||
421 | |||
422 | |||
423 | # Add 'hook_should_take_kwargs' to the arguments to be passed to main. | ||
424 | # We don't actually want hooks to define their main with this argument-- | ||
425 | # it's there to remind them that their hook should always take **kwargs. | ||
426 | # For instance, a pre-upload hook should be defined like: | ||
427 | # def main(project_list, **kwargs): | ||
428 | # | ||
429 | # This allows us to later expand the API without breaking old hooks. | ||
430 | kwargs = kwargs.copy() | ||
431 | kwargs['hook_should_take_kwargs'] = True | ||
432 | |||
433 | # Call the main function in the hook. If the hook should cause the | ||
434 | # build to fail, it will raise an Exception. We'll catch that convert | ||
435 | # to a HookError w/ just the failing traceback. | ||
436 | try: | ||
437 | context['main'](**kwargs) | ||
438 | except Exception: | ||
439 | raise HookError('%s\nFailed to run main() for %s hook; see traceback ' | ||
440 | 'above.' % ( | ||
441 | traceback.format_exc(), self._hook_type)) | ||
442 | finally: | ||
443 | # Restore sys.path and CWD. | ||
444 | sys.path = orig_syspath | ||
445 | os.chdir(orig_path) | ||
446 | |||
447 | def Run(self, user_allows_all_hooks, **kwargs): | ||
448 | """Run the hook. | ||
449 | |||
450 | If the hook doesn't exist (because there is no hooks project or because | ||
451 | this particular hook is not enabled), this is a no-op. | ||
452 | |||
453 | Args: | ||
454 | user_allows_all_hooks: If True, we will never prompt about running the | ||
455 | hook--we'll just assume it's OK to run it. | ||
456 | kwargs: Keyword arguments to pass to the hook. These are often specific | ||
457 | to the hook type. For instance, pre-upload hooks will contain | ||
458 | a project_list. | ||
459 | |||
460 | Raises: | ||
461 | HookError: If there was a problem finding the hook or the user declined | ||
462 | to run a required hook (from _CheckForHookApproval). | ||
463 | """ | ||
464 | # No-op if there is no hooks project or if hook is disabled. | ||
465 | if ((not self._hooks_project) or | ||
466 | (self._hook_type not in self._hooks_project.enabled_repo_hooks)): | ||
467 | return | ||
468 | |||
469 | # Bail with a nice error if we can't find the hook. | ||
470 | if not os.path.isfile(self._script_fullpath): | ||
471 | raise HookError('Couldn\'t find repo hook: "%s"' % self._script_fullpath) | ||
472 | |||
473 | # Make sure the user is OK with running the hook. | ||
474 | if (not user_allows_all_hooks) and (not self._CheckForHookApproval()): | ||
475 | return | ||
476 | |||
477 | # Run the hook with the same version of python we're using. | ||
478 | self._ExecuteHook(**kwargs) | ||
479 | |||
480 | |||
237 | class Project(object): | 481 | class Project(object): |
238 | def __init__(self, | 482 | def __init__(self, |
239 | manifest, | 483 | manifest, |
@@ -275,6 +519,10 @@ class Project(object): | |||
275 | self.bare_git = self._GitGetByExec(self, bare=True) | 519 | self.bare_git = self._GitGetByExec(self, bare=True) |
276 | self.bare_ref = GitRefs(gitdir) | 520 | self.bare_ref = GitRefs(gitdir) |
277 | 521 | ||
522 | # This will be filled in if a project is later identified to be the | ||
523 | # project containing repo hooks. | ||
524 | self.enabled_repo_hooks = [] | ||
525 | |||
278 | @property | 526 | @property |
279 | def Exists(self): | 527 | def Exists(self): |
280 | return os.path.isdir(self.gitdir) | 528 | return os.path.isdir(self.gitdir) |
@@ -1457,6 +1705,22 @@ class Project(object): | |||
1457 | return r | 1705 | return r |
1458 | 1706 | ||
1459 | def __getattr__(self, name): | 1707 | def __getattr__(self, name): |
1708 | """Allow arbitrary git commands using pythonic syntax. | ||
1709 | |||
1710 | This allows you to do things like: | ||
1711 | git_obj.rev_parse('HEAD') | ||
1712 | |||
1713 | Since we don't have a 'rev_parse' method defined, the __getattr__ will | ||
1714 | run. We'll replace the '_' with a '-' and try to run a git command. | ||
1715 | Any other arguments will be passed to the git command. | ||
1716 | |||
1717 | Args: | ||
1718 | name: The name of the git command to call. Any '_' characters will | ||
1719 | be replaced with '-'. | ||
1720 | |||
1721 | Returns: | ||
1722 | A callable object that will try to call git with the named command. | ||
1723 | """ | ||
1460 | name = name.replace('_', '-') | 1724 | name = name.replace('_', '-') |
1461 | def runner(*args): | 1725 | def runner(*args): |
1462 | cmdv = [name] | 1726 | cmdv = [name] |