blob: a89b1e7e329411e53203b680f67e9a052b94807d [file] [log] [blame]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001#!/usr/bin/python
2# git-cl -- a git-command for integrating reviews on Rietveld
3# Copyright (C) 2008 Evan Martin <martine@danga.com>
4
5import errno
6import logging
7import optparse
8import os
9import re
10import subprocess
11import sys
12import tempfile
13import textwrap
14import upload
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +000015import urlparse
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000016import urllib2
17
18try:
19 import readline
20except ImportError:
21 pass
22
23try:
24 # Add the parent directory in case it's a depot_tools checkout.
25 depot_tools_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
26 sys.path.append(depot_tools_path)
27 import breakpad
28except ImportError:
29 pass
30
31DEFAULT_SERVER = 'http://codereview.appspot.com'
32PREDCOMMIT_HOOK = '.git/hooks/pre-cl-dcommit'
maruel@chromium.org0ba7f962011-01-11 22:13:58 +000033POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000034PREUPLOAD_HOOK = '.git/hooks/pre-cl-upload'
35DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup'
36
37def DieWithError(message):
38 print >>sys.stderr, message
39 sys.exit(1)
40
41
42def Popen(cmd, **kwargs):
43 """Wrapper for subprocess.Popen() that logs and watch for cygwin issues"""
44 logging.info('Popen: ' + ' '.join(cmd))
45 try:
46 return subprocess.Popen(cmd, **kwargs)
47 except OSError, e:
48 if e.errno == errno.EAGAIN and sys.platform == 'cygwin':
49 DieWithError(
50 'Visit '
51 'http://code.google.com/p/chromium/wiki/CygwinDllRemappingFailure to '
52 'learn how to fix this error; you need to rebase your cygwin dlls')
53 raise
54
55
56def RunCommand(cmd, error_ok=False, error_message=None,
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000057 redirect_stdout=True, swallow_stderr=False, **kwargs):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000058 if redirect_stdout:
59 stdout = subprocess.PIPE
60 else:
61 stdout = None
62 if swallow_stderr:
63 stderr = subprocess.PIPE
64 else:
65 stderr = None
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000066 proc = Popen(cmd, stdout=stdout, stderr=stderr, **kwargs)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000067 output = proc.communicate()[0]
68 if not error_ok and proc.returncode != 0:
69 DieWithError('Command "%s" failed.\n' % (' '.join(cmd)) +
70 (error_message or output or ''))
71 return output
72
73
74def RunGit(args, **kwargs):
75 cmd = ['git'] + args
76 return RunCommand(cmd, **kwargs)
77
78
79def RunGitWithCode(args):
80 proc = Popen(['git'] + args, stdout=subprocess.PIPE)
81 output = proc.communicate()[0]
82 return proc.returncode, output
83
84
85def usage(more):
86 def hook(fn):
87 fn.usage_more = more
88 return fn
89 return hook
90
91
92def FixUrl(server):
93 """Fix a server url to defaults protocol to http:// if none is specified."""
94 if not server:
95 return server
96 if not re.match(r'[a-z]+\://.*', server):
97 return 'http://' + server
98 return server
99
100
101class Settings(object):
102 def __init__(self):
103 self.default_server = None
104 self.cc = None
105 self.root = None
106 self.is_git_svn = None
107 self.svn_branch = None
108 self.tree_status_url = None
109 self.viewvc_url = None
110 self.updated = False
111
112 def LazyUpdateIfNeeded(self):
113 """Updates the settings from a codereview.settings file, if available."""
114 if not self.updated:
115 cr_settings_file = FindCodereviewSettingsFile()
116 if cr_settings_file:
117 LoadCodereviewSettingsFromFile(cr_settings_file)
118 self.updated = True
119
120 def GetDefaultServerUrl(self, error_ok=False):
121 if not self.default_server:
122 self.LazyUpdateIfNeeded()
123 self.default_server = FixUrl(self._GetConfig('rietveld.server',
124 error_ok=True))
125 if error_ok:
126 return self.default_server
127 if not self.default_server:
128 error_message = ('Could not find settings file. You must configure '
129 'your review setup by running "git cl config".')
130 self.default_server = FixUrl(self._GetConfig(
131 'rietveld.server', error_message=error_message))
132 return self.default_server
133
134 def GetCCList(self):
135 """Return the users cc'd on this CL.
136
137 Return is a string suitable for passing to gcl with the --cc flag.
138 """
139 if self.cc is None:
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000140 base_cc = self._GetConfig('rietveld.cc', error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000141 more_cc = self._GetConfig('rietveld.extracc', error_ok=True)
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000142 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000143 return self.cc
144
145 def GetRoot(self):
146 if not self.root:
147 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
148 return self.root
149
150 def GetIsGitSvn(self):
151 """Return true if this repo looks like it's using git-svn."""
152 if self.is_git_svn is None:
153 # If you have any "svn-remote.*" config keys, we think you're using svn.
154 self.is_git_svn = RunGitWithCode(
155 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
156 return self.is_git_svn
157
158 def GetSVNBranch(self):
159 if self.svn_branch is None:
160 if not self.GetIsGitSvn():
161 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
162
163 # Try to figure out which remote branch we're based on.
164 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000165 # 1) iterate through our branch history and find the svn URL.
166 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000167
168 # regexp matching the git-svn line that contains the URL.
169 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
170
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000171 # We don't want to go through all of history, so read a line from the
172 # pipe at a time.
173 # The -100 is an arbitrary limit so we don't search forever.
174 cmd = ['git', 'log', '-100', '--pretty=medium']
175 proc = Popen(cmd, stdout=subprocess.PIPE)
176 for line in proc.stdout:
177 match = git_svn_re.match(line)
178 if match:
179 url = match.group(1)
180 proc.stdout.close() # Cut pipe.
181 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000182
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000183 if url:
184 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
185 remotes = RunGit(['config', '--get-regexp',
186 r'^svn-remote\..*\.url']).splitlines()
187 for remote in remotes:
188 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000189 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000190 remote = match.group(1)
191 base_url = match.group(2)
192 fetch_spec = RunGit(
193 ['config', 'svn-remote.'+remote+'.fetch']).strip().split(':')
194 if fetch_spec[0]:
195 full_url = base_url + '/' + fetch_spec[0]
196 else:
197 full_url = base_url
198 if full_url == url:
199 self.svn_branch = fetch_spec[1]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000200 break
201
202 if not self.svn_branch:
203 DieWithError('Can\'t guess svn branch -- try specifying it on the '
204 'command line')
205
206 return self.svn_branch
207
208 def GetTreeStatusUrl(self, error_ok=False):
209 if not self.tree_status_url:
210 error_message = ('You must configure your tree status URL by running '
211 '"git cl config".')
212 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
213 error_ok=error_ok,
214 error_message=error_message)
215 return self.tree_status_url
216
217 def GetViewVCUrl(self):
218 if not self.viewvc_url:
219 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
220 return self.viewvc_url
221
222 def _GetConfig(self, param, **kwargs):
223 self.LazyUpdateIfNeeded()
224 return RunGit(['config', param], **kwargs).strip()
225
226
227settings = Settings()
228
229
230did_migrate_check = False
231def CheckForMigration():
232 """Migrate from the old issue format, if found.
233
234 We used to store the branch<->issue mapping in a file in .git, but it's
235 better to store it in the .git/config, since deleting a branch deletes that
236 branch's entry there.
237 """
238
239 # Don't run more than once.
240 global did_migrate_check
241 if did_migrate_check:
242 return
243
244 gitdir = RunGit(['rev-parse', '--git-dir']).strip()
245 storepath = os.path.join(gitdir, 'cl-mapping')
246 if os.path.exists(storepath):
247 print "old-style git-cl mapping file (%s) found; migrating." % storepath
248 store = open(storepath, 'r')
249 for line in store:
250 branch, issue = line.strip().split()
251 RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
252 issue])
253 store.close()
254 os.remove(storepath)
255 did_migrate_check = True
256
257
258def ShortBranchName(branch):
259 """Convert a name like 'refs/heads/foo' to just 'foo'."""
260 return branch.replace('refs/heads/', '')
261
262
263class Changelist(object):
264 def __init__(self, branchref=None):
265 # Poke settings so we get the "configure your server" message if necessary.
266 settings.GetDefaultServerUrl()
267 self.branchref = branchref
268 if self.branchref:
269 self.branch = ShortBranchName(self.branchref)
270 else:
271 self.branch = None
272 self.rietveld_server = None
273 self.upstream_branch = None
274 self.has_issue = False
275 self.issue = None
276 self.has_description = False
277 self.description = None
278 self.has_patchset = False
279 self.patchset = None
280
281 def GetBranch(self):
282 """Returns the short branch name, e.g. 'master'."""
283 if not self.branch:
284 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
285 self.branch = ShortBranchName(self.branchref)
286 return self.branch
287
288 def GetBranchRef(self):
289 """Returns the full branch name, e.g. 'refs/heads/master'."""
290 self.GetBranch() # Poke the lazy loader.
291 return self.branchref
292
293 def FetchUpstreamTuple(self):
294 """Returns a tuple containg remote and remote ref,
295 e.g. 'origin', 'refs/heads/master'
296 """
297 remote = '.'
298 branch = self.GetBranch()
299 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
300 error_ok=True).strip()
301 if upstream_branch:
302 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
303 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000304 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
305 error_ok=True).strip()
306 if upstream_branch:
307 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000308 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000309 # Fall back on trying a git-svn upstream branch.
310 if settings.GetIsGitSvn():
311 upstream_branch = settings.GetSVNBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000312 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000313 # Else, try to guess the origin remote.
314 remote_branches = RunGit(['branch', '-r']).split()
315 if 'origin/master' in remote_branches:
316 # Fall back on origin/master if it exits.
317 remote = 'origin'
318 upstream_branch = 'refs/heads/master'
319 elif 'origin/trunk' in remote_branches:
320 # Fall back on origin/trunk if it exists. Generally a shared
321 # git-svn clone
322 remote = 'origin'
323 upstream_branch = 'refs/heads/trunk'
324 else:
325 DieWithError("""Unable to determine default branch to diff against.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000326Either pass complete "git diff"-style arguments, like
327 git cl upload origin/master
328or verify this branch is set up to track another (via the --track argument to
329"git checkout -b ...").""")
330
331 return remote, upstream_branch
332
333 def GetUpstreamBranch(self):
334 if self.upstream_branch is None:
335 remote, upstream_branch = self.FetchUpstreamTuple()
336 if remote is not '.':
337 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
338 self.upstream_branch = upstream_branch
339 return self.upstream_branch
340
341 def GetRemoteUrl(self):
342 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
343
344 Returns None if there is no remote.
345 """
346 remote = self.FetchUpstreamTuple()[0]
347 if remote == '.':
348 return None
349 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
350
351 def GetIssue(self):
352 if not self.has_issue:
353 CheckForMigration()
354 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
355 if issue:
356 self.issue = issue
357 self.rietveld_server = FixUrl(RunGit(
358 ['config', self._RietveldServer()], error_ok=True).strip())
359 else:
360 self.issue = None
361 if not self.rietveld_server:
362 self.rietveld_server = settings.GetDefaultServerUrl()
363 self.has_issue = True
364 return self.issue
365
366 def GetRietveldServer(self):
367 self.GetIssue()
368 return self.rietveld_server
369
370 def GetIssueURL(self):
371 """Get the URL for a particular issue."""
372 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
373
374 def GetDescription(self, pretty=False):
375 if not self.has_description:
376 if self.GetIssue():
377 path = '/' + self.GetIssue() + '/description'
378 rpc_server = self._RpcServer()
379 self.description = rpc_server.Send(path).strip()
380 self.has_description = True
381 if pretty:
382 wrapper = textwrap.TextWrapper()
383 wrapper.initial_indent = wrapper.subsequent_indent = ' '
384 return wrapper.fill(self.description)
385 return self.description
386
387 def GetPatchset(self):
388 if not self.has_patchset:
389 patchset = RunGit(['config', self._PatchsetSetting()],
390 error_ok=True).strip()
391 if patchset:
392 self.patchset = patchset
393 else:
394 self.patchset = None
395 self.has_patchset = True
396 return self.patchset
397
398 def SetPatchset(self, patchset):
399 """Set this branch's patchset. If patchset=0, clears the patchset."""
400 if patchset:
401 RunGit(['config', self._PatchsetSetting(), str(patchset)])
402 else:
403 RunGit(['config', '--unset', self._PatchsetSetting()],
404 swallow_stderr=True, error_ok=True)
405 self.has_patchset = False
406
407 def SetIssue(self, issue):
408 """Set this branch's issue. If issue=0, clears the issue."""
409 if issue:
410 RunGit(['config', self._IssueSetting(), str(issue)])
411 if self.rietveld_server:
412 RunGit(['config', self._RietveldServer(), self.rietveld_server])
413 else:
414 RunGit(['config', '--unset', self._IssueSetting()])
415 self.SetPatchset(0)
416 self.has_issue = False
417
418 def CloseIssue(self):
419 rpc_server = self._RpcServer()
420 # Newer versions of Rietveld require us to pass an XSRF token to POST, so
421 # we fetch it from the server. (The version used by Chromium has been
422 # modified so the token isn't required when closing an issue.)
423 xsrf_token = rpc_server.Send('/xsrf_token',
424 extra_headers={'X-Requesting-XSRF-Token': '1'})
425
426 # You cannot close an issue with a GET.
427 # We pass an empty string for the data so it is a POST rather than a GET.
428 data = [("description", self.description),
429 ("xsrf_token", xsrf_token)]
430 ctype, body = upload.EncodeMultipartFormData(data, [])
431 rpc_server.Send('/' + self.GetIssue() + '/close', body, ctype)
432
433 def _RpcServer(self):
434 """Returns an upload.RpcServer() to access this review's rietveld instance.
435 """
436 server = self.GetRietveldServer()
437 return upload.GetRpcServer(server, save_cookies=True)
438
439 def _IssueSetting(self):
440 """Return the git setting that stores this change's issue."""
441 return 'branch.%s.rietveldissue' % self.GetBranch()
442
443 def _PatchsetSetting(self):
444 """Return the git setting that stores this change's most recent patchset."""
445 return 'branch.%s.rietveldpatchset' % self.GetBranch()
446
447 def _RietveldServer(self):
448 """Returns the git setting that stores this change's rietveld server."""
449 return 'branch.%s.rietveldserver' % self.GetBranch()
450
451
452def GetCodereviewSettingsInteractively():
453 """Prompt the user for settings."""
454 server = settings.GetDefaultServerUrl(error_ok=True)
455 prompt = 'Rietveld server (host[:port])'
456 prompt += ' [%s]' % (server or DEFAULT_SERVER)
457 newserver = raw_input(prompt + ': ')
458 if not server and not newserver:
459 newserver = DEFAULT_SERVER
460 if newserver and newserver != server:
461 RunGit(['config', 'rietveld.server', newserver])
462
463 def SetProperty(initial, caption, name):
464 prompt = caption
465 if initial:
466 prompt += ' ("x" to clear) [%s]' % initial
467 new_val = raw_input(prompt + ': ')
468 if new_val == 'x':
469 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
470 elif new_val and new_val != initial:
471 RunGit(['config', 'rietveld.' + name, new_val])
472
473 SetProperty(settings.GetCCList(), 'CC list', 'cc')
474 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
475 'tree-status-url')
476 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
477
478 # TODO: configure a default branch to diff against, rather than this
479 # svn-based hackery.
480
481
482def FindCodereviewSettingsFile(filename='codereview.settings'):
483 """Finds the given file starting in the cwd and going up.
484
485 Only looks up to the top of the repository unless an
486 'inherit-review-settings-ok' file exists in the root of the repository.
487 """
488 inherit_ok_file = 'inherit-review-settings-ok'
489 cwd = os.getcwd()
490 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
491 if os.path.isfile(os.path.join(root, inherit_ok_file)):
492 root = '/'
493 while True:
494 if filename in os.listdir(cwd):
495 if os.path.isfile(os.path.join(cwd, filename)):
496 return open(os.path.join(cwd, filename))
497 if cwd == root:
498 break
499 cwd = os.path.dirname(cwd)
500
501
502def LoadCodereviewSettingsFromFile(fileobj):
503 """Parse a codereview.settings file and updates hooks."""
504 def DownloadToFile(url, filename):
505 filename = os.path.join(settings.GetRoot(), filename)
506 contents = urllib2.urlopen(url).read()
507 fileobj = open(filename, 'w')
508 fileobj.write(contents)
509 fileobj.close()
510 os.chmod(filename, 0755)
511 return 0
512
513 keyvals = {}
514 for line in fileobj.read().splitlines():
515 if not line or line.startswith("#"):
516 continue
517 k, v = line.split(": ", 1)
518 keyvals[k] = v
519
520 def GetProperty(name):
521 return keyvals.get(name)
522
523 def SetProperty(name, setting, unset_error_ok=False):
524 fullname = 'rietveld.' + name
525 if setting in keyvals:
526 RunGit(['config', fullname, keyvals[setting]])
527 else:
528 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
529
530 SetProperty('server', 'CODE_REVIEW_SERVER')
531 # Only server setting is required. Other settings can be absent.
532 # In that case, we ignore errors raised during option deletion attempt.
533 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
534 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
535 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
536
537 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
538 #should be of the form
539 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
540 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
541 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
542 keyvals['ORIGIN_URL_CONFIG']])
543
544 # Update the hooks if the local hook files aren't present already.
545 if GetProperty('GITCL_PREUPLOAD') and not os.path.isfile(PREUPLOAD_HOOK):
546 DownloadToFile(GetProperty('GITCL_PREUPLOAD'), PREUPLOAD_HOOK)
547 if GetProperty('GITCL_PREDCOMMIT') and not os.path.isfile(PREDCOMMIT_HOOK):
548 DownloadToFile(GetProperty('GITCL_PREDCOMMIT'), PREDCOMMIT_HOOK)
549 return 0
550
551
552@usage('[repo root containing codereview.settings]')
553def CMDconfig(parser, args):
554 """edit configuration for this tree"""
555
556 (options, args) = parser.parse_args(args)
557 if len(args) == 0:
558 GetCodereviewSettingsInteractively()
559 return 0
560
561 url = args[0]
562 if not url.endswith('codereview.settings'):
563 url = os.path.join(url, 'codereview.settings')
564
565 # Load code review settings and download hooks (if available).
566 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
567 return 0
568
569
570def CMDstatus(parser, args):
571 """show status of changelists"""
572 parser.add_option('--field',
573 help='print only specific field (desc|id|patch|url)')
574 (options, args) = parser.parse_args(args)
575
576 # TODO: maybe make show_branches a flag if necessary.
577 show_branches = not options.field
578
579 if show_branches:
580 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
581 if branches:
582 print 'Branches associated with reviews:'
583 for branch in sorted(branches.splitlines()):
584 cl = Changelist(branchref=branch)
585 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
586
587 cl = Changelist()
588 if options.field:
589 if options.field.startswith('desc'):
590 print cl.GetDescription()
591 elif options.field == 'id':
592 issueid = cl.GetIssue()
593 if issueid:
594 print issueid
595 elif options.field == 'patch':
596 patchset = cl.GetPatchset()
597 if patchset:
598 print patchset
599 elif options.field == 'url':
600 url = cl.GetIssueURL()
601 if url:
602 print url
603 else:
604 print
605 print 'Current branch:',
606 if not cl.GetIssue():
607 print 'no issue assigned.'
608 return 0
609 print cl.GetBranch()
610 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
611 print 'Issue description:'
612 print cl.GetDescription(pretty=True)
613 return 0
614
615
616@usage('[issue_number]')
617def CMDissue(parser, args):
618 """Set or display the current code review issue number.
619
620 Pass issue number 0 to clear the current issue.
621"""
622 (options, args) = parser.parse_args(args)
623
624 cl = Changelist()
625 if len(args) > 0:
626 try:
627 issue = int(args[0])
628 except ValueError:
629 DieWithError('Pass a number to set the issue or none to list it.\n'
630 'Maybe you want to run git cl status?')
631 cl.SetIssue(issue)
632 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
633 return 0
634
635
636def CreateDescriptionFromLog(args):
637 """Pulls out the commit log to use as a base for the CL description."""
638 log_args = []
639 if len(args) == 1 and not args[0].endswith('.'):
640 log_args = [args[0] + '..']
641 elif len(args) == 1 and args[0].endswith('...'):
642 log_args = [args[0][:-1]]
643 elif len(args) == 2:
644 log_args = [args[0] + '..' + args[1]]
645 else:
646 log_args = args[:] # Hope for the best!
647 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
648
649
650def UserEditedLog(starting_text):
651 """Given some starting text, let the user edit it and return the result."""
652 editor = os.getenv('EDITOR', 'vi')
653
654 (file_handle, filename) = tempfile.mkstemp()
655 fileobj = os.fdopen(file_handle, 'w')
656 fileobj.write(starting_text)
657 fileobj.close()
658
659 ret = subprocess.call(editor + ' ' + filename, shell=True)
660 if ret != 0:
661 os.remove(filename)
662 return
663
664 fileobj = open(filename)
665 text = fileobj.read()
666 fileobj.close()
667 os.remove(filename)
668 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
669 return stripcomment_re.sub('', text).strip()
670
671
672def RunHook(hook, upstream_branch, error_ok=False):
673 """Run a given hook if it exists. By default, we fail on errors."""
674 hook = '%s/%s' % (settings.GetRoot(), hook)
675 if not os.path.exists(hook):
676 return
677 return RunCommand([hook, upstream_branch], error_ok=error_ok,
678 redirect_stdout=False)
679
680
681def CMDpresubmit(parser, args):
682 """run presubmit tests on the current changelist"""
683 parser.add_option('--upload', action='store_true',
684 help='Run upload hook instead of the push/dcommit hook')
685 (options, args) = parser.parse_args(args)
686
687 # Make sure index is up-to-date before running diff-index.
688 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
689 if RunGit(['diff-index', 'HEAD']):
690 # TODO(maruel): Is this really necessary?
691 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
692 return 1
693
694 if args:
695 base_branch = args[0]
696 else:
697 # Default to diffing against the "upstream" branch.
698 base_branch = Changelist().GetUpstreamBranch()
699
700 if options.upload:
701 print '*** Presubmit checks for UPLOAD would report: ***'
702 return not RunHook(PREUPLOAD_HOOK, upstream_branch=base_branch,
703 error_ok=True)
704 else:
705 print '*** Presubmit checks for DCOMMIT would report: ***'
706 return not RunHook(PREDCOMMIT_HOOK, upstream_branch=base_branch,
707 error_ok=True)
708
709
710@usage('[args to "git diff"]')
711def CMDupload(parser, args):
712 """upload the current changelist to codereview"""
713 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
714 help='bypass upload presubmit hook')
715 parser.add_option('-m', dest='message', help='message for patch')
716 parser.add_option('-r', '--reviewers',
717 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000718 parser.add_option('--cc',
719 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000720 parser.add_option('--send-mail', action='store_true',
721 help='send email to reviewer immediately')
722 parser.add_option("--emulate_svn_auto_props", action="store_true",
723 dest="emulate_svn_auto_props",
724 help="Emulate Subversion's auto properties feature.")
725 parser.add_option("--desc_from_logs", action="store_true",
726 dest="from_logs",
727 help="""Squashes git commit logs into change description and
728 uses message as subject""")
729 (options, args) = parser.parse_args(args)
730
731 # Make sure index is up-to-date before running diff-index.
732 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
733 if RunGit(['diff-index', 'HEAD']):
734 print 'Cannot upload with a dirty tree. You must commit locally first.'
735 return 1
736
737 cl = Changelist()
738 if args:
739 base_branch = args[0]
740 else:
741 # Default to diffing against the "upstream" branch.
742 base_branch = cl.GetUpstreamBranch()
743 args = [base_branch + "..."]
744
745 if not options.bypass_hooks:
746 RunHook(PREUPLOAD_HOOK, upstream_branch=base_branch, error_ok=False)
747
748 # --no-ext-diff is broken in some versions of Git, so try to work around
749 # this by overriding the environment (but there is still a problem if the
750 # git config key "diff.external" is used).
751 env = os.environ.copy()
752 if 'GIT_EXTERNAL_DIFF' in env:
753 del env['GIT_EXTERNAL_DIFF']
754 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
755 env=env)
756
757 upload_args = ['--assume_yes'] # Don't ask about untracked files.
758 upload_args.extend(['--server', cl.GetRietveldServer()])
759 if options.reviewers:
760 upload_args.extend(['--reviewers', options.reviewers])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000761 if options.emulate_svn_auto_props:
762 upload_args.append('--emulate_svn_auto_props')
763 if options.send_mail:
764 if not options.reviewers:
765 DieWithError("Must specify reviewers to send email.")
766 upload_args.append('--send_mail')
767 if options.from_logs and not options.message:
768 print 'Must set message for subject line if using desc_from_logs'
769 return 1
770
771 change_desc = None
772
773 if cl.GetIssue():
774 if options.message:
775 upload_args.extend(['--message', options.message])
776 upload_args.extend(['--issue', cl.GetIssue()])
777 print ("This branch is associated with issue %s. "
778 "Adding patch to that issue." % cl.GetIssue())
779 else:
780 log_desc = CreateDescriptionFromLog(args)
781 if options.from_logs:
782 # Uses logs as description and message as subject.
783 subject = options.message
784 change_desc = subject + '\n\n' + log_desc
785 else:
786 initial_text = """# Enter a description of the change.
787# This will displayed on the codereview site.
788# The first line will also be used as the subject of the review.
789"""
790 if 'BUG=' not in log_desc:
791 log_desc += '\nBUG='
792 if 'TEST=' not in log_desc:
793 log_desc += '\nTEST='
794 change_desc = UserEditedLog(initial_text + log_desc)
795 subject = ''
796 if change_desc:
797 subject = change_desc.splitlines()[0]
798 if not change_desc:
799 print "Description is empty; aborting."
800 return 1
801 upload_args.extend(['--message', subject])
802 upload_args.extend(['--description', change_desc])
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000803 cc = ','.join(filter(None, (settings.GetCCList(), options.cc)))
804 if cc:
805 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000806
807 # Include the upstream repo's URL in the change -- this is useful for
808 # projects that have their source spread across multiple repos.
809 remote_url = None
810 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000811 # URL is dependent on the current directory.
812 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000813 if data:
814 keys = dict(line.split(': ', 1) for line in data.splitlines()
815 if ': ' in line)
816 remote_url = keys.get('URL', None)
817 else:
818 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
819 remote_url = (cl.GetRemoteUrl() + '@'
820 + cl.GetUpstreamBranch().split('/')[-1])
821 if remote_url:
822 upload_args.extend(['--base_url', remote_url])
823
824 try:
825 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
826 except:
827 # If we got an exception after the user typed a description for their
828 # change, back up the description before re-raising.
829 if change_desc:
830 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
831 print '\nGot exception while uploading -- saving description to %s\n' \
832 % backup_path
833 backup_file = open(backup_path, 'w')
834 backup_file.write(change_desc)
835 backup_file.close()
836 raise
837
838 if not cl.GetIssue():
839 cl.SetIssue(issue)
840 cl.SetPatchset(patchset)
841 return 0
842
843
844def SendUpstream(parser, args, cmd):
845 """Common code for CmdPush and CmdDCommit
846
847 Squashed commit into a single.
848 Updates changelog with metadata (e.g. pointer to review).
849 Pushes/dcommits the code upstream.
850 Updates review and closes.
851 """
852 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
853 help='bypass upload presubmit hook')
854 parser.add_option('-m', dest='message',
855 help="override review description")
856 parser.add_option('-f', action='store_true', dest='force',
857 help="force yes to questions (don't prompt)")
858 parser.add_option('-c', dest='contributor',
859 help="external contributor for patch (appended to " +
860 "description and used as author for git). Should be " +
861 "formatted as 'First Last <email@example.com>'")
862 parser.add_option('--tbr', action='store_true', dest='tbr',
863 help="short for 'to be reviewed', commit branch " +
864 "even without uploading for review")
865 (options, args) = parser.parse_args(args)
866 cl = Changelist()
867
868 if not args or cmd == 'push':
869 # Default to merging against our best guess of the upstream branch.
870 args = [cl.GetUpstreamBranch()]
871
872 base_branch = args[0]
873
chase@chromium.orgc76e6752011-01-10 18:17:12 +0000874 # Make sure index is up-to-date before running diff-index.
875 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000876 if RunGit(['diff-index', 'HEAD']):
877 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
878 return 1
879
880 # This rev-list syntax means "show all commits not in my branch that
881 # are in base_branch".
882 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
883 base_branch]).splitlines()
884 if upstream_commits:
885 print ('Base branch "%s" has %d commits '
886 'not in this branch.' % (base_branch, len(upstream_commits)))
887 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
888 return 1
889
890 if cmd == 'dcommit':
891 # This is the revision `svn dcommit` will commit on top of.
892 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
893 '--pretty=format:%H'])
894 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
895 if extra_commits:
896 print ('This branch has %d additional commits not upstreamed yet.'
897 % len(extra_commits.splitlines()))
898 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
899 'before attempting to %s.' % (base_branch, cmd))
900 return 1
901
902 if not options.force and not options.bypass_hooks:
903 RunHook(PREDCOMMIT_HOOK, upstream_branch=base_branch, error_ok=False)
904
905 if cmd == 'dcommit':
906 # Check the tree status if the tree status URL is set.
907 status = GetTreeStatus()
908 if 'closed' == status:
909 print ('The tree is closed. Please wait for it to reopen. Use '
910 '"git cl dcommit -f" to commit on a closed tree.')
911 return 1
912 elif 'unknown' == status:
913 print ('Unable to determine tree status. Please verify manually and '
914 'use "git cl dcommit -f" to commit on a closed tree.')
915
916 description = options.message
917 if not options.tbr:
918 # It is important to have these checks early. Not only for user
919 # convenience, but also because the cl object then caches the correct values
920 # of these fields even as we're juggling branches for setting up the commit.
921 if not cl.GetIssue():
922 print 'Current issue unknown -- has this branch been uploaded?'
923 print 'Use --tbr to commit without review.'
924 return 1
925
926 if not description:
927 description = cl.GetDescription()
928
929 if not description:
930 print 'No description set.'
931 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
932 return 1
933
934 description += "\n\nReview URL: %s" % cl.GetIssueURL()
935 else:
936 if not description:
937 # Submitting TBR. See if there's already a description in Rietveld, else
938 # create a template description. Eitherway, give the user a chance to edit
939 # it to fill in the TBR= field.
940 if cl.GetIssue():
941 description = cl.GetDescription()
942
943 if not description:
944 description = """# Enter a description of the change.
945# This will be used as the change log for the commit.
946
947"""
948 description += CreateDescriptionFromLog(args)
949
950 description = UserEditedLog(description + '\nTBR=')
951
952 if not description:
953 print "Description empty; aborting."
954 return 1
955
956 if options.contributor:
957 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
958 print "Please provide contibutor as 'First Last <email@example.com>'"
959 return 1
960 description += "\nPatch from %s." % options.contributor
961 print 'Description:', repr(description)
962
963 branches = [base_branch, cl.GetBranchRef()]
964 if not options.force:
965 subprocess.call(['git', 'diff', '--stat'] + branches)
966 raw_input("About to commit; enter to confirm.")
967
968 # We want to squash all this branch's commits into one commit with the
969 # proper description.
970 # We do this by doing a "merge --squash" into a new commit branch, then
971 # dcommitting that.
972 MERGE_BRANCH = 'git-cl-commit'
973 # Delete the merge branch if it already exists.
974 if RunGitWithCode(['show-ref', '--quiet', '--verify',
975 'refs/heads/' + MERGE_BRANCH])[0] == 0:
976 RunGit(['branch', '-D', MERGE_BRANCH])
977
978 # We might be in a directory that's present in this branch but not in the
979 # trunk. Move up to the top of the tree so that git commands that expect a
980 # valid CWD won't fail after we check out the merge branch.
981 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
982 if rel_base_path:
983 os.chdir(rel_base_path)
984
985 # Stuff our change into the merge branch.
986 # We wrap in a try...finally block so if anything goes wrong,
987 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +0000988 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000989 try:
990 RunGit(['checkout', '-q', '-b', MERGE_BRANCH, base_branch])
991 RunGit(['merge', '--squash', cl.GetBranchRef()])
992 if options.contributor:
993 RunGit(['commit', '--author', options.contributor, '-m', description])
994 else:
995 RunGit(['commit', '-m', description])
996 if cmd == 'push':
997 # push the merge branch.
998 remote, branch = cl.FetchUpstreamTuple()
999 retcode, output = RunGitWithCode(
1000 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1001 logging.debug(output)
1002 else:
1003 # dcommit the merge branch.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001004 retcode, output = RunGitWithCode(['svn', 'dcommit', '--no-rebase'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001005 finally:
1006 # And then swap back to the original branch and clean up.
1007 RunGit(['checkout', '-q', cl.GetBranch()])
1008 RunGit(['branch', '-D', MERGE_BRANCH])
1009
1010 if cl.GetIssue():
1011 if cmd == 'dcommit' and 'Committed r' in output:
1012 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1013 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001014 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1015 for l in output.splitlines(False))
1016 match = filter(None, match)
1017 if len(match) != 1:
1018 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1019 output)
1020 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001021 else:
1022 return 1
1023 viewvc_url = settings.GetViewVCUrl()
1024 if viewvc_url and revision:
1025 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1026 print ('Closing issue '
1027 '(you may be prompted for your codereview password)...')
1028 cl.CloseIssue()
1029 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001030
1031 if retcode == 0:
1032 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1033 if os.path.isfile(hook):
1034 RunHook(hook, upstream_branch=base_branch, error_ok=True)
1035
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001036 return 0
1037
1038
1039@usage('[upstream branch to apply against]')
1040def CMDdcommit(parser, args):
1041 """commit the current changelist via git-svn"""
1042 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001043 message = """This doesn't appear to be an SVN repository.
1044If your project has a git mirror with an upstream SVN master, you probably need
1045to run 'git svn init', see your project's git mirror documentation.
1046If your project has a true writeable upstream repository, you probably want
1047to run 'git cl push' instead.
1048Choose wisely, if you get this wrong, your commit might appear to succeed but
1049will instead be silently ignored."""
1050 print(message)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001051 raw_input('[Press enter to dcommit or ctrl-C to quit]')
1052 return SendUpstream(parser, args, 'dcommit')
1053
1054
1055@usage('[upstream branch to apply against]')
1056def CMDpush(parser, args):
1057 """commit the current changelist via git"""
1058 if settings.GetIsGitSvn():
1059 print('This appears to be an SVN repository.')
1060 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
1061 raw_input('[Press enter to push or ctrl-C to quit]')
1062 return SendUpstream(parser, args, 'push')
1063
1064
1065@usage('<patch url or issue id>')
1066def CMDpatch(parser, args):
1067 """patch in a code review"""
1068 parser.add_option('-b', dest='newbranch',
1069 help='create a new branch off trunk for the patch')
1070 parser.add_option('-f', action='store_true', dest='force',
1071 help='with -b, clobber any existing branch')
1072 parser.add_option('--reject', action='store_true', dest='reject',
1073 help='allow failed patches and spew .rej files')
1074 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1075 help="don't commit after patch applies")
1076 (options, args) = parser.parse_args(args)
1077 if len(args) != 1:
1078 parser.print_help()
1079 return 1
1080 input = args[0]
1081
1082 if re.match(r'\d+', input):
1083 # Input is an issue id. Figure out the URL.
1084 issue = input
1085 server = settings.GetDefaultServerUrl()
1086 fetch = urllib2.urlopen('%s/%s' % (server, issue)).read()
1087 m = re.search(r'/download/issue[0-9]+_[0-9]+.diff', fetch)
1088 if not m:
1089 DieWithError('Must pass an issue ID or full URL for '
1090 '\'Download raw patch set\'')
1091 url = '%s%s' % (server, m.group(0).strip())
1092 else:
1093 # Assume it's a URL to the patch. Default to http.
1094 input = FixUrl(input)
1095 match = re.match(r'.*?/issue(\d+)_\d+.diff', input)
1096 if match:
1097 issue = match.group(1)
1098 url = input
1099 else:
1100 DieWithError('Must pass an issue ID or full URL for '
1101 '\'Download raw patch set\'')
1102
1103 if options.newbranch:
1104 if options.force:
1105 RunGit(['branch', '-D', options.newbranch],
1106 swallow_stderr=True, error_ok=True)
1107 RunGit(['checkout', '-b', options.newbranch,
1108 Changelist().GetUpstreamBranch()])
1109
1110 # Switch up to the top-level directory, if necessary, in preparation for
1111 # applying the patch.
1112 top = RunGit(['rev-parse', '--show-cdup']).strip()
1113 if top:
1114 os.chdir(top)
1115
1116 patch_data = urllib2.urlopen(url).read()
1117 # Git patches have a/ at the beginning of source paths. We strip that out
1118 # with a sed script rather than the -p flag to patch so we can feed either
1119 # Git or svn-style patches into the same apply command.
1120 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1121 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1122 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1123 patch_data = sed_proc.communicate(patch_data)[0]
1124 if sed_proc.returncode:
1125 DieWithError('Git patch mungling failed.')
1126 logging.info(patch_data)
1127 # We use "git apply" to apply the patch instead of "patch" so that we can
1128 # pick up file adds.
1129 # The --index flag means: also insert into the index (so we catch adds).
1130 cmd = ['git', 'apply', '--index', '-p0']
1131 if options.reject:
1132 cmd.append('--reject')
1133 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1134 patch_proc.communicate(patch_data)
1135 if patch_proc.returncode:
1136 DieWithError('Failed to apply the patch')
1137
1138 # If we had an issue, commit the current state and register the issue.
1139 if not options.nocommit:
1140 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1141 cl = Changelist()
1142 cl.SetIssue(issue)
1143 print "Committed patch."
1144 else:
1145 print "Patch applied to index."
1146 return 0
1147
1148
1149def CMDrebase(parser, args):
1150 """rebase current branch on top of svn repo"""
1151 # Provide a wrapper for git svn rebase to help avoid accidental
1152 # git svn dcommit.
1153 # It's the only command that doesn't use parser at all since we just defer
1154 # execution to git-svn.
1155 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1156 return 0
1157
1158
1159def GetTreeStatus():
1160 """Fetches the tree status and returns either 'open', 'closed',
1161 'unknown' or 'unset'."""
1162 url = settings.GetTreeStatusUrl(error_ok=True)
1163 if url:
1164 status = urllib2.urlopen(url).read().lower()
1165 if status.find('closed') != -1 or status == '0':
1166 return 'closed'
1167 elif status.find('open') != -1 or status == '1':
1168 return 'open'
1169 return 'unknown'
1170
1171 return 'unset'
1172
1173def GetTreeStatusReason():
1174 """Fetches the tree status from a json url and returns the message
1175 with the reason for the tree to be opened or closed."""
1176 # Don't import it at file level since simplejson is not installed by default
1177 # on python 2.5 and it is only used for git-cl tree which isn't often used,
1178 # forcing everyone to install simplejson isn't efficient.
1179 try:
1180 import simplejson as json
1181 except ImportError:
1182 try:
1183 import json
1184 # Some versions of python2.5 have an incomplete json module. Check to make
1185 # sure loads exists.
1186 json.loads
1187 except (ImportError, AttributeError):
1188 print >> sys.stderr, 'Please install simplejson'
1189 sys.exit(1)
1190
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001191 url = settings.GetTreeStatusUrl()
1192 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001193 connection = urllib2.urlopen(json_url)
1194 status = json.loads(connection.read())
1195 connection.close()
1196 return status['message']
1197
1198def CMDtree(parser, args):
1199 """show the status of the tree"""
1200 (options, args) = parser.parse_args(args)
1201 status = GetTreeStatus()
1202 if 'unset' == status:
1203 print 'You must configure your tree status URL by running "git cl config".'
1204 return 2
1205
1206 print "The tree is %s" % status
1207 print
1208 print GetTreeStatusReason()
1209 if status != 'open':
1210 return 1
1211 return 0
1212
1213
1214def CMDupstream(parser, args):
1215 """print the name of the upstream branch, if any"""
1216 (options, args) = parser.parse_args(args)
1217 cl = Changelist()
1218 print cl.GetUpstreamBranch()
1219 return 0
1220
1221
1222def Command(name):
1223 return getattr(sys.modules[__name__], 'CMD' + name, None)
1224
1225
1226def CMDhelp(parser, args):
1227 """print list of commands or help for a specific command"""
1228 (options, args) = parser.parse_args(args)
1229 if len(args) == 1:
1230 return main(args + ['--help'])
1231 parser.print_help()
1232 return 0
1233
1234
1235def GenUsage(parser, command):
1236 """Modify an OptParse object with the function's documentation."""
1237 obj = Command(command)
1238 more = getattr(obj, 'usage_more', '')
1239 if command == 'help':
1240 command = '<command>'
1241 else:
1242 # OptParser.description prefer nicely non-formatted strings.
1243 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1244 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1245
1246
1247def main(argv):
1248 """Doesn't parse the arguments here, just find the right subcommand to
1249 execute."""
1250 # Do it late so all commands are listed.
1251 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1252 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1253 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1254
1255 # Create the option parse and add --verbose support.
1256 parser = optparse.OptionParser()
1257 parser.add_option('-v', '--verbose', action='store_true')
1258 old_parser_args = parser.parse_args
1259 def Parse(args):
1260 options, args = old_parser_args(args)
1261 if options.verbose:
1262 logging.basicConfig(level=logging.DEBUG)
1263 else:
1264 logging.basicConfig(level=logging.WARNING)
1265 return options, args
1266 parser.parse_args = Parse
1267
1268 if argv:
1269 command = Command(argv[0])
1270 if command:
1271 # "fix" the usage and the description now that we know the subcommand.
1272 GenUsage(parser, argv[0])
1273 try:
1274 return command(parser, argv[1:])
1275 except urllib2.HTTPError, e:
1276 if e.code != 500:
1277 raise
1278 DieWithError(
1279 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1280 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1281
1282 # Not a known command. Default to help.
1283 GenUsage(parser, 'help')
1284 return CMDhelp(parser, argv)
1285
1286
1287if __name__ == '__main__':
1288 sys.exit(main(sys.argv[1:]))