summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/manifest-format.txt7
-rw-r--r--error.py7
-rw-r--r--manifest_xml.py90
-rw-r--r--project.py266
-rw-r--r--subcmds/upload.py46
5 files changed, 384 insertions, 32 deletions
diff --git a/docs/manifest-format.txt b/docs/manifest-format.txt
index 2e1c8c35..c76df801 100644
--- a/docs/manifest-format.txt
+++ b/docs/manifest-format.txt
@@ -25,7 +25,8 @@ following DTD:
25 default?, 25 default?,
26 manifest-server?, 26 manifest-server?,
27 remove-project*, 27 remove-project*,
28 project*)> 28 project*,
29 repo-hooks?)>
29 30
30 <!ELEMENT notice (#PCDATA)> 31 <!ELEMENT notice (#PCDATA)>
31 32
@@ -49,6 +50,10 @@ following DTD:
49 50
50 <!ELEMENT remove-project (EMPTY)> 51 <!ELEMENT remove-project (EMPTY)>
51 <!ATTLIST remove-project name CDATA #REQUIRED> 52 <!ATTLIST remove-project name CDATA #REQUIRED>
53
54 <!ELEMENT repo-hooks (EMPTY)>
55 <!ATTLIST repo-hooks in-project CDATA #REQUIRED>
56 <!ATTLIST repo-hooks enabled-list CDATA #REQUIRED>
52 ]> 57 ]>
53 58
54A description of the elements and their attributes follows. 59A description of the elements and their attributes follows.
diff --git a/error.py b/error.py
index cb3b7258..52381581 100644
--- a/error.py
+++ b/error.py
@@ -75,3 +75,10 @@ class RepoChangedException(Exception):
75 """ 75 """
76 def __init__(self, extra_args=[]): 76 def __init__(self, extra_args=[]):
77 self.extra_args = extra_args 77 self.extra_args = extra_args
78
79class HookError(Exception):
80 """Thrown if a 'repo-hook' could not be run.
81
82 The common case is that the file wasn't present when we tried to run it.
83 """
84 pass
diff --git a/manifest_xml.py b/manifest_xml.py
index 0103cf55..0e6421f1 100644
--- a/manifest_xml.py
+++ b/manifest_xml.py
@@ -171,6 +171,14 @@ class XmlManifest(object):
171 ce.setAttribute('dest', c.dest) 171 ce.setAttribute('dest', c.dest)
172 e.appendChild(ce) 172 e.appendChild(ce)
173 173
174 if self._repo_hooks_project:
175 root.appendChild(doc.createTextNode(''))
176 e = doc.createElement('repo-hooks')
177 e.setAttribute('in-project', self._repo_hooks_project.name)
178 e.setAttribute('enabled-list',
179 ' '.join(self._repo_hooks_project.enabled_repo_hooks))
180 root.appendChild(e)
181
174 doc.writexml(fd, '', ' ', '\n', 'UTF-8') 182 doc.writexml(fd, '', ' ', '\n', 'UTF-8')
175 183
176 @property 184 @property
@@ -189,6 +197,11 @@ class XmlManifest(object):
189 return self._default 197 return self._default
190 198
191 @property 199 @property
200 def repo_hooks_project(self):
201 self._Load()
202 return self._repo_hooks_project
203
204 @property
192 def notice(self): 205 def notice(self):
193 self._Load() 206 self._Load()
194 return self._notice 207 return self._notice
@@ -207,6 +220,7 @@ class XmlManifest(object):
207 self._projects = {} 220 self._projects = {}
208 self._remotes = {} 221 self._remotes = {}
209 self._default = None 222 self._default = None
223 self._repo_hooks_project = None
210 self._notice = None 224 self._notice = None
211 self.branch = None 225 self.branch = None
212 self._manifest_server = None 226 self._manifest_server = None
@@ -239,15 +253,15 @@ class XmlManifest(object):
239 def _ParseManifest(self, is_root_file): 253 def _ParseManifest(self, is_root_file):
240 root = xml.dom.minidom.parse(self.manifestFile) 254 root = xml.dom.minidom.parse(self.manifestFile)
241 if not root or not root.childNodes: 255 if not root or not root.childNodes:
242 raise ManifestParseError, \ 256 raise ManifestParseError(
243 "no root node in %s" % \ 257 "no root node in %s" %
244 self.manifestFile 258 self.manifestFile)
245 259
246 config = root.childNodes[0] 260 config = root.childNodes[0]
247 if config.nodeName != 'manifest': 261 if config.nodeName != 'manifest':
248 raise ManifestParseError, \ 262 raise ManifestParseError(
249 "no <manifest> in %s" % \ 263 "no <manifest> in %s" %
250 self.manifestFile 264 self.manifestFile)
251 265
252 for node in config.childNodes: 266 for node in config.childNodes:
253 if node.nodeName == 'remove-project': 267 if node.nodeName == 'remove-project':
@@ -255,25 +269,30 @@ class XmlManifest(object):
255 try: 269 try:
256 del self._projects[name] 270 del self._projects[name]
257 except KeyError: 271 except KeyError:
258 raise ManifestParseError, \ 272 raise ManifestParseError(
259 'project %s not found' % \ 273 'project %s not found' %
260 (name) 274 (name))
275
276 # If the manifest removes the hooks project, treat it as if it deleted
277 # the repo-hooks element too.
278 if self._repo_hooks_project and (self._repo_hooks_project.name == name):
279 self._repo_hooks_project = None
261 280
262 for node in config.childNodes: 281 for node in config.childNodes:
263 if node.nodeName == 'remote': 282 if node.nodeName == 'remote':
264 remote = self._ParseRemote(node) 283 remote = self._ParseRemote(node)
265 if self._remotes.get(remote.name): 284 if self._remotes.get(remote.name):
266 raise ManifestParseError, \ 285 raise ManifestParseError(
267 'duplicate remote %s in %s' % \ 286 'duplicate remote %s in %s' %
268 (remote.name, self.manifestFile) 287 (remote.name, self.manifestFile))
269 self._remotes[remote.name] = remote 288 self._remotes[remote.name] = remote
270 289
271 for node in config.childNodes: 290 for node in config.childNodes:
272 if node.nodeName == 'default': 291 if node.nodeName == 'default':
273 if self._default is not None: 292 if self._default is not None:
274 raise ManifestParseError, \ 293 raise ManifestParseError(
275 'duplicate default in %s' % \ 294 'duplicate default in %s' %
276 (self.manifestFile) 295 (self.manifestFile))
277 self._default = self._ParseDefault(node) 296 self._default = self._ParseDefault(node)
278 if self._default is None: 297 if self._default is None:
279 self._default = _Default() 298 self._default = _Default()
@@ -281,29 +300,52 @@ class XmlManifest(object):
281 for node in config.childNodes: 300 for node in config.childNodes:
282 if node.nodeName == 'notice': 301 if node.nodeName == 'notice':
283 if self._notice is not None: 302 if self._notice is not None:
284 raise ManifestParseError, \ 303 raise ManifestParseError(
285 'duplicate notice in %s' % \ 304 'duplicate notice in %s' %
286 (self.manifestFile) 305 (self.manifestFile))
287 self._notice = self._ParseNotice(node) 306 self._notice = self._ParseNotice(node)
288 307
289 for node in config.childNodes: 308 for node in config.childNodes:
290 if node.nodeName == 'manifest-server': 309 if node.nodeName == 'manifest-server':
291 url = self._reqatt(node, 'url') 310 url = self._reqatt(node, 'url')
292 if self._manifest_server is not None: 311 if self._manifest_server is not None:
293 raise ManifestParseError, \ 312 raise ManifestParseError(
294 'duplicate manifest-server in %s' % \ 313 'duplicate manifest-server in %s' %
295 (self.manifestFile) 314 (self.manifestFile))
296 self._manifest_server = url 315 self._manifest_server = url
297 316
298 for node in config.childNodes: 317 for node in config.childNodes:
299 if node.nodeName == 'project': 318 if node.nodeName == 'project':
300 project = self._ParseProject(node) 319 project = self._ParseProject(node)
301 if self._projects.get(project.name): 320 if self._projects.get(project.name):
302 raise ManifestParseError, \ 321 raise ManifestParseError(
303 'duplicate project %s in %s' % \ 322 'duplicate project %s in %s' %
304 (project.name, self.manifestFile) 323 (project.name, self.manifestFile))
305 self._projects[project.name] = project 324 self._projects[project.name] = project
306 325
326 for node in config.childNodes:
327 if node.nodeName == 'repo-hooks':
328 # Get the name of the project and the (space-separated) list of enabled.
329 repo_hooks_project = self._reqatt(node, 'in-project')
330 enabled_repo_hooks = self._reqatt(node, 'enabled-list').split()
331
332 # Only one project can be the hooks project
333 if self._repo_hooks_project is not None:
334 raise ManifestParseError(
335 'duplicate repo-hooks in %s' %
336 (self.manifestFile))
337
338 # Store a reference to the Project.
339 try:
340 self._repo_hooks_project = self._projects[repo_hooks_project]
341 except KeyError:
342 raise ManifestParseError(
343 'project %s not found for repo-hooks' %
344 (repo_hooks_project))
345
346 # Store the enabled hooks in the Project object.
347 self._repo_hooks_project.enabled_repo_hooks = enabled_repo_hooks
348
307 def _AddMetaProjectMirror(self, m): 349 def _AddMetaProjectMirror(self, m):
308 name = None 350 name = None
309 m_url = m.GetRemote(m.remote.name).url 351 m_url = m.GetRemote(m.remote.name).url
diff --git a/project.py b/project.py
index 37f6d36a..49633f7f 100644
--- a/project.py
+++ b/project.py
@@ -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
15import traceback
15import errno 16import errno
16import filecmp 17import filecmp
17import os 18import os
@@ -24,7 +25,7 @@ import urllib2
24from color import Coloring 25from color import Coloring
25from git_command import GitCommand 26from git_command import GitCommand
26from git_config import GitConfig, IsId 27from git_config import GitConfig, IsId
27from error import GitError, ImportError, UploadError 28from error import GitError, HookError, ImportError, UploadError
28from error import ManifestInvalidRevisionError 29from error import ManifestInvalidRevisionError
29 30
30from git_refs import GitRefs, HEAD, R_HEADS, R_TAGS, R_PUB, R_M 31from 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
238class 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 print
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
237class Project(object): 481class 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]
diff --git a/subcmds/upload.py b/subcmds/upload.py
index 20822096..c561b8aa 100644
--- a/subcmds/upload.py
+++ b/subcmds/upload.py
@@ -19,7 +19,8 @@ import sys
19 19
20from command import InteractiveCommand 20from command import InteractiveCommand
21from editor import Editor 21from editor import Editor
22from error import UploadError 22from error import HookError, UploadError
23from project import RepoHook
23 24
24UNUSUAL_COMMIT_THRESHOLD = 5 25UNUSUAL_COMMIT_THRESHOLD = 5
25 26
@@ -120,6 +121,29 @@ Gerrit Code Review: http://code.google.com/p/gerrit/
120 type='string', action='append', dest='cc', 121 type='string', action='append', dest='cc',
121 help='Also send email to these email addresses.') 122 help='Also send email to these email addresses.')
122 123
124 # Options relating to upload hook. Note that verify and no-verify are NOT
125 # opposites of each other, which is why they store to different locations.
126 # We are using them to match 'git commit' syntax.
127 #
128 # Combinations:
129 # - no-verify=False, verify=False (DEFAULT):
130 # If stdout is a tty, can prompt about running upload hooks if needed.
131 # If user denies running hooks, the upload is cancelled. If stdout is
132 # not a tty and we would need to prompt about upload hooks, upload is
133 # cancelled.
134 # - no-verify=False, verify=True:
135 # Always run upload hooks with no prompt.
136 # - no-verify=True, verify=False:
137 # Never run upload hooks, but upload anyway (AKA bypass hooks).
138 # - no-verify=True, verify=True:
139 # Invalid
140 p.add_option('--no-verify',
141 dest='bypass_hooks', action='store_true',
142 help='Do not run the upload hook.')
143 p.add_option('--verify',
144 dest='allow_all_hooks', action='store_true',
145 help='Run the upload hook without prompting.')
146
123 def _SingleBranch(self, opt, branch, people): 147 def _SingleBranch(self, opt, branch, people):
124 project = branch.project 148 project = branch.project
125 name = branch.name 149 name = branch.name
@@ -313,17 +337,27 @@ Gerrit Code Review: http://code.google.com/p/gerrit/
313 reviewers = [] 337 reviewers = []
314 cc = [] 338 cc = []
315 339
340 for project in project_list:
341 avail = project.GetUploadableBranches()
342 if avail:
343 pending.append((project, avail))
344
345 if pending and (not opt.bypass_hooks):
346 hook = RepoHook('pre-upload', self.manifest.repo_hooks_project,
347 self.manifest.topdir, abort_if_user_denies=True)
348 pending_proj_names = [project.name for (project, avail) in pending]
349 try:
350 hook.Run(opt.allow_all_hooks, project_list=pending_proj_names)
351 except HookError, e:
352 print >>sys.stderr, "ERROR: %s" % str(e)
353 return
354
316 if opt.reviewers: 355 if opt.reviewers:
317 reviewers = _SplitEmails(opt.reviewers) 356 reviewers = _SplitEmails(opt.reviewers)
318 if opt.cc: 357 if opt.cc:
319 cc = _SplitEmails(opt.cc) 358 cc = _SplitEmails(opt.cc)
320 people = (reviewers,cc) 359 people = (reviewers,cc)
321 360
322 for project in project_list:
323 avail = project.GetUploadableBranches()
324 if avail:
325 pending.append((project, avail))
326
327 if not pending: 361 if not pending:
328 print >>sys.stdout, "no branches ready for upload" 362 print >>sys.stdout, "no branches ready for upload"
329 elif len(pending) == 1 and len(pending[0][1]) == 1: 363 elif len(pending) == 1 and len(pending[0][1]) == 1: