blob: 4e282c1323676be1a50ac23c4925566ca8f4e5c6 [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,
57 redirect_stdout=True, swallow_stderr=False):
58 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
66 proc = Popen(cmd, stdout=stdout, stderr=stderr)
67 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():
811 data = RunGit(['svn', 'info'])
812 if data:
813 keys = dict(line.split(': ', 1) for line in data.splitlines()
814 if ': ' in line)
815 remote_url = keys.get('URL', None)
816 else:
817 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
818 remote_url = (cl.GetRemoteUrl() + '@'
819 + cl.GetUpstreamBranch().split('/')[-1])
820 if remote_url:
821 upload_args.extend(['--base_url', remote_url])
822
823 try:
824 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
825 except:
826 # If we got an exception after the user typed a description for their
827 # change, back up the description before re-raising.
828 if change_desc:
829 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
830 print '\nGot exception while uploading -- saving description to %s\n' \
831 % backup_path
832 backup_file = open(backup_path, 'w')
833 backup_file.write(change_desc)
834 backup_file.close()
835 raise
836
837 if not cl.GetIssue():
838 cl.SetIssue(issue)
839 cl.SetPatchset(patchset)
840 return 0
841
842
843def SendUpstream(parser, args, cmd):
844 """Common code for CmdPush and CmdDCommit
845
846 Squashed commit into a single.
847 Updates changelog with metadata (e.g. pointer to review).
848 Pushes/dcommits the code upstream.
849 Updates review and closes.
850 """
851 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
852 help='bypass upload presubmit hook')
853 parser.add_option('-m', dest='message',
854 help="override review description")
855 parser.add_option('-f', action='store_true', dest='force',
856 help="force yes to questions (don't prompt)")
857 parser.add_option('-c', dest='contributor',
858 help="external contributor for patch (appended to " +
859 "description and used as author for git). Should be " +
860 "formatted as 'First Last <email@example.com>'")
861 parser.add_option('--tbr', action='store_true', dest='tbr',
862 help="short for 'to be reviewed', commit branch " +
863 "even without uploading for review")
864 (options, args) = parser.parse_args(args)
865 cl = Changelist()
866
867 if not args or cmd == 'push':
868 # Default to merging against our best guess of the upstream branch.
869 args = [cl.GetUpstreamBranch()]
870
871 base_branch = args[0]
872
chase@chromium.orgc76e6752011-01-10 18:17:12 +0000873 # Make sure index is up-to-date before running diff-index.
874 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000875 if RunGit(['diff-index', 'HEAD']):
876 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
877 return 1
878
879 # This rev-list syntax means "show all commits not in my branch that
880 # are in base_branch".
881 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
882 base_branch]).splitlines()
883 if upstream_commits:
884 print ('Base branch "%s" has %d commits '
885 'not in this branch.' % (base_branch, len(upstream_commits)))
886 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
887 return 1
888
889 if cmd == 'dcommit':
890 # This is the revision `svn dcommit` will commit on top of.
891 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
892 '--pretty=format:%H'])
893 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
894 if extra_commits:
895 print ('This branch has %d additional commits not upstreamed yet.'
896 % len(extra_commits.splitlines()))
897 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
898 'before attempting to %s.' % (base_branch, cmd))
899 return 1
900
901 if not options.force and not options.bypass_hooks:
902 RunHook(PREDCOMMIT_HOOK, upstream_branch=base_branch, error_ok=False)
903
904 if cmd == 'dcommit':
905 # Check the tree status if the tree status URL is set.
906 status = GetTreeStatus()
907 if 'closed' == status:
908 print ('The tree is closed. Please wait for it to reopen. Use '
909 '"git cl dcommit -f" to commit on a closed tree.')
910 return 1
911 elif 'unknown' == status:
912 print ('Unable to determine tree status. Please verify manually and '
913 'use "git cl dcommit -f" to commit on a closed tree.')
914
915 description = options.message
916 if not options.tbr:
917 # It is important to have these checks early. Not only for user
918 # convenience, but also because the cl object then caches the correct values
919 # of these fields even as we're juggling branches for setting up the commit.
920 if not cl.GetIssue():
921 print 'Current issue unknown -- has this branch been uploaded?'
922 print 'Use --tbr to commit without review.'
923 return 1
924
925 if not description:
926 description = cl.GetDescription()
927
928 if not description:
929 print 'No description set.'
930 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
931 return 1
932
933 description += "\n\nReview URL: %s" % cl.GetIssueURL()
934 else:
935 if not description:
936 # Submitting TBR. See if there's already a description in Rietveld, else
937 # create a template description. Eitherway, give the user a chance to edit
938 # it to fill in the TBR= field.
939 if cl.GetIssue():
940 description = cl.GetDescription()
941
942 if not description:
943 description = """# Enter a description of the change.
944# This will be used as the change log for the commit.
945
946"""
947 description += CreateDescriptionFromLog(args)
948
949 description = UserEditedLog(description + '\nTBR=')
950
951 if not description:
952 print "Description empty; aborting."
953 return 1
954
955 if options.contributor:
956 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
957 print "Please provide contibutor as 'First Last <email@example.com>'"
958 return 1
959 description += "\nPatch from %s." % options.contributor
960 print 'Description:', repr(description)
961
962 branches = [base_branch, cl.GetBranchRef()]
963 if not options.force:
964 subprocess.call(['git', 'diff', '--stat'] + branches)
965 raw_input("About to commit; enter to confirm.")
966
967 # We want to squash all this branch's commits into one commit with the
968 # proper description.
969 # We do this by doing a "merge --squash" into a new commit branch, then
970 # dcommitting that.
971 MERGE_BRANCH = 'git-cl-commit'
972 # Delete the merge branch if it already exists.
973 if RunGitWithCode(['show-ref', '--quiet', '--verify',
974 'refs/heads/' + MERGE_BRANCH])[0] == 0:
975 RunGit(['branch', '-D', MERGE_BRANCH])
976
977 # We might be in a directory that's present in this branch but not in the
978 # trunk. Move up to the top of the tree so that git commands that expect a
979 # valid CWD won't fail after we check out the merge branch.
980 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
981 if rel_base_path:
982 os.chdir(rel_base_path)
983
984 # Stuff our change into the merge branch.
985 # We wrap in a try...finally block so if anything goes wrong,
986 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +0000987 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000988 try:
989 RunGit(['checkout', '-q', '-b', MERGE_BRANCH, base_branch])
990 RunGit(['merge', '--squash', cl.GetBranchRef()])
991 if options.contributor:
992 RunGit(['commit', '--author', options.contributor, '-m', description])
993 else:
994 RunGit(['commit', '-m', description])
995 if cmd == 'push':
996 # push the merge branch.
997 remote, branch = cl.FetchUpstreamTuple()
998 retcode, output = RunGitWithCode(
999 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1000 logging.debug(output)
1001 else:
1002 # dcommit the merge branch.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001003 retcode, output = RunGitWithCode(['svn', 'dcommit', '--no-rebase'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001004 finally:
1005 # And then swap back to the original branch and clean up.
1006 RunGit(['checkout', '-q', cl.GetBranch()])
1007 RunGit(['branch', '-D', MERGE_BRANCH])
1008
1009 if cl.GetIssue():
1010 if cmd == 'dcommit' and 'Committed r' in output:
1011 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1012 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001013 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1014 for l in output.splitlines(False))
1015 match = filter(None, match)
1016 if len(match) != 1:
1017 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1018 output)
1019 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001020 else:
1021 return 1
1022 viewvc_url = settings.GetViewVCUrl()
1023 if viewvc_url and revision:
1024 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1025 print ('Closing issue '
1026 '(you may be prompted for your codereview password)...')
1027 cl.CloseIssue()
1028 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001029
1030 if retcode == 0:
1031 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1032 if os.path.isfile(hook):
1033 RunHook(hook, upstream_branch=base_branch, error_ok=True)
1034
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001035 return 0
1036
1037
1038@usage('[upstream branch to apply against]')
1039def CMDdcommit(parser, args):
1040 """commit the current changelist via git-svn"""
1041 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001042 message = """This doesn't appear to be an SVN repository.
1043If your project has a git mirror with an upstream SVN master, you probably need
1044to run 'git svn init', see your project's git mirror documentation.
1045If your project has a true writeable upstream repository, you probably want
1046to run 'git cl push' instead.
1047Choose wisely, if you get this wrong, your commit might appear to succeed but
1048will instead be silently ignored."""
1049 print(message)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001050 raw_input('[Press enter to dcommit or ctrl-C to quit]')
1051 return SendUpstream(parser, args, 'dcommit')
1052
1053
1054@usage('[upstream branch to apply against]')
1055def CMDpush(parser, args):
1056 """commit the current changelist via git"""
1057 if settings.GetIsGitSvn():
1058 print('This appears to be an SVN repository.')
1059 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
1060 raw_input('[Press enter to push or ctrl-C to quit]')
1061 return SendUpstream(parser, args, 'push')
1062
1063
1064@usage('<patch url or issue id>')
1065def CMDpatch(parser, args):
1066 """patch in a code review"""
1067 parser.add_option('-b', dest='newbranch',
1068 help='create a new branch off trunk for the patch')
1069 parser.add_option('-f', action='store_true', dest='force',
1070 help='with -b, clobber any existing branch')
1071 parser.add_option('--reject', action='store_true', dest='reject',
1072 help='allow failed patches and spew .rej files')
1073 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1074 help="don't commit after patch applies")
1075 (options, args) = parser.parse_args(args)
1076 if len(args) != 1:
1077 parser.print_help()
1078 return 1
1079 input = args[0]
1080
1081 if re.match(r'\d+', input):
1082 # Input is an issue id. Figure out the URL.
1083 issue = input
1084 server = settings.GetDefaultServerUrl()
1085 fetch = urllib2.urlopen('%s/%s' % (server, issue)).read()
1086 m = re.search(r'/download/issue[0-9]+_[0-9]+.diff', fetch)
1087 if not m:
1088 DieWithError('Must pass an issue ID or full URL for '
1089 '\'Download raw patch set\'')
1090 url = '%s%s' % (server, m.group(0).strip())
1091 else:
1092 # Assume it's a URL to the patch. Default to http.
1093 input = FixUrl(input)
1094 match = re.match(r'.*?/issue(\d+)_\d+.diff', input)
1095 if match:
1096 issue = match.group(1)
1097 url = input
1098 else:
1099 DieWithError('Must pass an issue ID or full URL for '
1100 '\'Download raw patch set\'')
1101
1102 if options.newbranch:
1103 if options.force:
1104 RunGit(['branch', '-D', options.newbranch],
1105 swallow_stderr=True, error_ok=True)
1106 RunGit(['checkout', '-b', options.newbranch,
1107 Changelist().GetUpstreamBranch()])
1108
1109 # Switch up to the top-level directory, if necessary, in preparation for
1110 # applying the patch.
1111 top = RunGit(['rev-parse', '--show-cdup']).strip()
1112 if top:
1113 os.chdir(top)
1114
1115 patch_data = urllib2.urlopen(url).read()
1116 # Git patches have a/ at the beginning of source paths. We strip that out
1117 # with a sed script rather than the -p flag to patch so we can feed either
1118 # Git or svn-style patches into the same apply command.
1119 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1120 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1121 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1122 patch_data = sed_proc.communicate(patch_data)[0]
1123 if sed_proc.returncode:
1124 DieWithError('Git patch mungling failed.')
1125 logging.info(patch_data)
1126 # We use "git apply" to apply the patch instead of "patch" so that we can
1127 # pick up file adds.
1128 # The --index flag means: also insert into the index (so we catch adds).
1129 cmd = ['git', 'apply', '--index', '-p0']
1130 if options.reject:
1131 cmd.append('--reject')
1132 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1133 patch_proc.communicate(patch_data)
1134 if patch_proc.returncode:
1135 DieWithError('Failed to apply the patch')
1136
1137 # If we had an issue, commit the current state and register the issue.
1138 if not options.nocommit:
1139 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1140 cl = Changelist()
1141 cl.SetIssue(issue)
1142 print "Committed patch."
1143 else:
1144 print "Patch applied to index."
1145 return 0
1146
1147
1148def CMDrebase(parser, args):
1149 """rebase current branch on top of svn repo"""
1150 # Provide a wrapper for git svn rebase to help avoid accidental
1151 # git svn dcommit.
1152 # It's the only command that doesn't use parser at all since we just defer
1153 # execution to git-svn.
1154 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1155 return 0
1156
1157
1158def GetTreeStatus():
1159 """Fetches the tree status and returns either 'open', 'closed',
1160 'unknown' or 'unset'."""
1161 url = settings.GetTreeStatusUrl(error_ok=True)
1162 if url:
1163 status = urllib2.urlopen(url).read().lower()
1164 if status.find('closed') != -1 or status == '0':
1165 return 'closed'
1166 elif status.find('open') != -1 or status == '1':
1167 return 'open'
1168 return 'unknown'
1169
1170 return 'unset'
1171
1172def GetTreeStatusReason():
1173 """Fetches the tree status from a json url and returns the message
1174 with the reason for the tree to be opened or closed."""
1175 # Don't import it at file level since simplejson is not installed by default
1176 # on python 2.5 and it is only used for git-cl tree which isn't often used,
1177 # forcing everyone to install simplejson isn't efficient.
1178 try:
1179 import simplejson as json
1180 except ImportError:
1181 try:
1182 import json
1183 # Some versions of python2.5 have an incomplete json module. Check to make
1184 # sure loads exists.
1185 json.loads
1186 except (ImportError, AttributeError):
1187 print >> sys.stderr, 'Please install simplejson'
1188 sys.exit(1)
1189
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001190 url = settings.GetTreeStatusUrl()
1191 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001192 connection = urllib2.urlopen(json_url)
1193 status = json.loads(connection.read())
1194 connection.close()
1195 return status['message']
1196
1197def CMDtree(parser, args):
1198 """show the status of the tree"""
1199 (options, args) = parser.parse_args(args)
1200 status = GetTreeStatus()
1201 if 'unset' == status:
1202 print 'You must configure your tree status URL by running "git cl config".'
1203 return 2
1204
1205 print "The tree is %s" % status
1206 print
1207 print GetTreeStatusReason()
1208 if status != 'open':
1209 return 1
1210 return 0
1211
1212
1213def CMDupstream(parser, args):
1214 """print the name of the upstream branch, if any"""
1215 (options, args) = parser.parse_args(args)
1216 cl = Changelist()
1217 print cl.GetUpstreamBranch()
1218 return 0
1219
1220
1221def Command(name):
1222 return getattr(sys.modules[__name__], 'CMD' + name, None)
1223
1224
1225def CMDhelp(parser, args):
1226 """print list of commands or help for a specific command"""
1227 (options, args) = parser.parse_args(args)
1228 if len(args) == 1:
1229 return main(args + ['--help'])
1230 parser.print_help()
1231 return 0
1232
1233
1234def GenUsage(parser, command):
1235 """Modify an OptParse object with the function's documentation."""
1236 obj = Command(command)
1237 more = getattr(obj, 'usage_more', '')
1238 if command == 'help':
1239 command = '<command>'
1240 else:
1241 # OptParser.description prefer nicely non-formatted strings.
1242 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1243 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1244
1245
1246def main(argv):
1247 """Doesn't parse the arguments here, just find the right subcommand to
1248 execute."""
1249 # Do it late so all commands are listed.
1250 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1251 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1252 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1253
1254 # Create the option parse and add --verbose support.
1255 parser = optparse.OptionParser()
1256 parser.add_option('-v', '--verbose', action='store_true')
1257 old_parser_args = parser.parse_args
1258 def Parse(args):
1259 options, args = old_parser_args(args)
1260 if options.verbose:
1261 logging.basicConfig(level=logging.DEBUG)
1262 else:
1263 logging.basicConfig(level=logging.WARNING)
1264 return options, args
1265 parser.parse_args = Parse
1266
1267 if argv:
1268 command = Command(argv[0])
1269 if command:
1270 # "fix" the usage and the description now that we know the subcommand.
1271 GenUsage(parser, argv[0])
1272 try:
1273 return command(parser, argv[1:])
1274 except urllib2.HTTPError, e:
1275 if e.code != 500:
1276 raise
1277 DieWithError(
1278 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1279 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1280
1281 # Not a known command. Default to help.
1282 GenUsage(parser, 'help')
1283 return CMDhelp(parser, argv)
1284
1285
1286if __name__ == '__main__':
1287 sys.exit(main(sys.argv[1:]))