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