blob: 552c0945dc7f4b72d732f0c82d6530380f1c41ac [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
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000012import textwrap
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +000013import urlparse
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000014import urllib2
15
16try:
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +000017 import readline # pylint: disable=W0611
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000018except ImportError:
19 pass
20
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +000021# TODO(dpranke): don't use relative import.
22import upload # pylint: disable=W0403
dpranke@chromium.org0c20c2f2011-03-22 18:13:37 +000023
24# TODO(dpranke): move this file up a directory so we don't need this.
25depot_tools_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
26sys.path.append(depot_tools_path)
27
28import breakpad # pylint: disable=W0611
29
30import presubmit_support
31import scm
32import watchlists
33
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000034
35DEFAULT_SERVER = 'http://codereview.appspot.com'
maruel@chromium.org0ba7f962011-01-11 22:13:58 +000036POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000037DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup'
38
39def DieWithError(message):
dpranke@chromium.org970c5222011-03-12 00:32:24 +000040 print >> sys.stderr, message
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000041 sys.exit(1)
42
43
44def Popen(cmd, **kwargs):
45 """Wrapper for subprocess.Popen() that logs and watch for cygwin issues"""
46 logging.info('Popen: ' + ' '.join(cmd))
47 try:
48 return subprocess.Popen(cmd, **kwargs)
49 except OSError, e:
50 if e.errno == errno.EAGAIN and sys.platform == 'cygwin':
51 DieWithError(
52 'Visit '
53 'http://code.google.com/p/chromium/wiki/CygwinDllRemappingFailure to '
54 'learn how to fix this error; you need to rebase your cygwin dlls')
55 raise
56
57
58def RunCommand(cmd, error_ok=False, error_message=None,
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000059 redirect_stdout=True, swallow_stderr=False, **kwargs):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000060 if redirect_stdout:
61 stdout = subprocess.PIPE
62 else:
63 stdout = None
64 if swallow_stderr:
65 stderr = subprocess.PIPE
66 else:
67 stderr = None
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000068 proc = Popen(cmd, stdout=stdout, stderr=stderr, **kwargs)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000069 output = proc.communicate()[0]
70 if not error_ok and proc.returncode != 0:
71 DieWithError('Command "%s" failed.\n' % (' '.join(cmd)) +
72 (error_message or output or ''))
73 return output
74
75
76def RunGit(args, **kwargs):
77 cmd = ['git'] + args
78 return RunCommand(cmd, **kwargs)
79
80
81def RunGitWithCode(args):
82 proc = Popen(['git'] + args, stdout=subprocess.PIPE)
83 output = proc.communicate()[0]
84 return proc.returncode, output
85
86
87def usage(more):
88 def hook(fn):
89 fn.usage_more = more
90 return fn
91 return hook
92
93
94def FixUrl(server):
95 """Fix a server url to defaults protocol to http:// if none is specified."""
96 if not server:
97 return server
98 if not re.match(r'[a-z]+\://.*', server):
99 return 'http://' + server
100 return server
101
102
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000103def MatchSvnGlob(url, base_url, glob_spec, allow_wildcards):
104 """Return the corresponding git ref if |base_url| together with |glob_spec|
105 matches the full |url|.
106
107 If |allow_wildcards| is true, |glob_spec| can contain wildcards (see below).
108 """
109 fetch_suburl, as_ref = glob_spec.split(':')
110 if allow_wildcards:
111 glob_match = re.match('(.+/)?(\*|{[^/]*})(/.+)?', fetch_suburl)
112 if glob_match:
113 # Parse specs like "branches/*/src:refs/remotes/svn/*" or
114 # "branches/{472,597,648}/src:refs/remotes/svn/*".
115 branch_re = re.escape(base_url)
116 if glob_match.group(1):
117 branch_re += '/' + re.escape(glob_match.group(1))
118 wildcard = glob_match.group(2)
119 if wildcard == '*':
120 branch_re += '([^/]*)'
121 else:
122 # Escape and replace surrounding braces with parentheses and commas
123 # with pipe symbols.
124 wildcard = re.escape(wildcard)
125 wildcard = re.sub('^\\\\{', '(', wildcard)
126 wildcard = re.sub('\\\\,', '|', wildcard)
127 wildcard = re.sub('\\\\}$', ')', wildcard)
128 branch_re += wildcard
129 if glob_match.group(3):
130 branch_re += re.escape(glob_match.group(3))
131 match = re.match(branch_re, url)
132 if match:
133 return re.sub('\*$', match.group(1), as_ref)
134
135 # Parse specs like "trunk/src:refs/remotes/origin/trunk".
136 if fetch_suburl:
137 full_url = base_url + '/' + fetch_suburl
138 else:
139 full_url = base_url
140 if full_url == url:
141 return as_ref
142 return None
143
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000144class Settings(object):
145 def __init__(self):
146 self.default_server = None
147 self.cc = None
148 self.root = None
149 self.is_git_svn = None
150 self.svn_branch = None
151 self.tree_status_url = None
152 self.viewvc_url = None
153 self.updated = False
154
155 def LazyUpdateIfNeeded(self):
156 """Updates the settings from a codereview.settings file, if available."""
157 if not self.updated:
158 cr_settings_file = FindCodereviewSettingsFile()
159 if cr_settings_file:
160 LoadCodereviewSettingsFromFile(cr_settings_file)
161 self.updated = True
162
163 def GetDefaultServerUrl(self, error_ok=False):
164 if not self.default_server:
165 self.LazyUpdateIfNeeded()
166 self.default_server = FixUrl(self._GetConfig('rietveld.server',
167 error_ok=True))
168 if error_ok:
169 return self.default_server
170 if not self.default_server:
171 error_message = ('Could not find settings file. You must configure '
172 'your review setup by running "git cl config".')
173 self.default_server = FixUrl(self._GetConfig(
174 'rietveld.server', error_message=error_message))
175 return self.default_server
176
177 def GetCCList(self):
178 """Return the users cc'd on this CL.
179
180 Return is a string suitable for passing to gcl with the --cc flag.
181 """
182 if self.cc is None:
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000183 base_cc = self._GetConfig('rietveld.cc', error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000184 more_cc = self._GetConfig('rietveld.extracc', error_ok=True)
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000185 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000186 return self.cc
187
188 def GetRoot(self):
189 if not self.root:
190 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
191 return self.root
192
193 def GetIsGitSvn(self):
194 """Return true if this repo looks like it's using git-svn."""
195 if self.is_git_svn is None:
196 # If you have any "svn-remote.*" config keys, we think you're using svn.
197 self.is_git_svn = RunGitWithCode(
198 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
199 return self.is_git_svn
200
201 def GetSVNBranch(self):
202 if self.svn_branch is None:
203 if not self.GetIsGitSvn():
204 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
205
206 # Try to figure out which remote branch we're based on.
207 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000208 # 1) iterate through our branch history and find the svn URL.
209 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000210
211 # regexp matching the git-svn line that contains the URL.
212 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
213
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000214 # We don't want to go through all of history, so read a line from the
215 # pipe at a time.
216 # The -100 is an arbitrary limit so we don't search forever.
217 cmd = ['git', 'log', '-100', '--pretty=medium']
218 proc = Popen(cmd, stdout=subprocess.PIPE)
219 for line in proc.stdout:
220 match = git_svn_re.match(line)
221 if match:
222 url = match.group(1)
223 proc.stdout.close() # Cut pipe.
224 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000225
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000226 if url:
227 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
228 remotes = RunGit(['config', '--get-regexp',
229 r'^svn-remote\..*\.url']).splitlines()
230 for remote in remotes:
231 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000232 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000233 remote = match.group(1)
234 base_url = match.group(2)
235 fetch_spec = RunGit(
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000236 ['config', 'svn-remote.%s.fetch' % remote],
237 error_ok=True).strip()
238 if fetch_spec:
239 self.svn_branch = MatchSvnGlob(url, base_url, fetch_spec, False)
240 if self.svn_branch:
241 break
242 branch_spec = RunGit(
243 ['config', 'svn-remote.%s.branches' % remote],
244 error_ok=True).strip()
245 if branch_spec:
246 self.svn_branch = MatchSvnGlob(url, base_url, branch_spec, True)
247 if self.svn_branch:
248 break
249 tag_spec = RunGit(
250 ['config', 'svn-remote.%s.tags' % remote],
251 error_ok=True).strip()
252 if tag_spec:
253 self.svn_branch = MatchSvnGlob(url, base_url, tag_spec, True)
254 if self.svn_branch:
255 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000256
257 if not self.svn_branch:
258 DieWithError('Can\'t guess svn branch -- try specifying it on the '
259 'command line')
260
261 return self.svn_branch
262
263 def GetTreeStatusUrl(self, error_ok=False):
264 if not self.tree_status_url:
265 error_message = ('You must configure your tree status URL by running '
266 '"git cl config".')
267 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
268 error_ok=error_ok,
269 error_message=error_message)
270 return self.tree_status_url
271
272 def GetViewVCUrl(self):
273 if not self.viewvc_url:
274 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
275 return self.viewvc_url
276
277 def _GetConfig(self, param, **kwargs):
278 self.LazyUpdateIfNeeded()
279 return RunGit(['config', param], **kwargs).strip()
280
281
282settings = Settings()
283
284
285did_migrate_check = False
286def CheckForMigration():
287 """Migrate from the old issue format, if found.
288
289 We used to store the branch<->issue mapping in a file in .git, but it's
290 better to store it in the .git/config, since deleting a branch deletes that
291 branch's entry there.
292 """
293
294 # Don't run more than once.
295 global did_migrate_check
296 if did_migrate_check:
297 return
298
299 gitdir = RunGit(['rev-parse', '--git-dir']).strip()
300 storepath = os.path.join(gitdir, 'cl-mapping')
301 if os.path.exists(storepath):
302 print "old-style git-cl mapping file (%s) found; migrating." % storepath
303 store = open(storepath, 'r')
304 for line in store:
305 branch, issue = line.strip().split()
306 RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
307 issue])
308 store.close()
309 os.remove(storepath)
310 did_migrate_check = True
311
312
313def ShortBranchName(branch):
314 """Convert a name like 'refs/heads/foo' to just 'foo'."""
315 return branch.replace('refs/heads/', '')
316
317
318class Changelist(object):
319 def __init__(self, branchref=None):
320 # Poke settings so we get the "configure your server" message if necessary.
321 settings.GetDefaultServerUrl()
322 self.branchref = branchref
323 if self.branchref:
324 self.branch = ShortBranchName(self.branchref)
325 else:
326 self.branch = None
327 self.rietveld_server = None
328 self.upstream_branch = None
329 self.has_issue = False
330 self.issue = None
331 self.has_description = False
332 self.description = None
333 self.has_patchset = False
334 self.patchset = None
dpranke@chromium.org0c20c2f2011-03-22 18:13:37 +0000335 self.tbr = False
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000336
337 def GetBranch(self):
338 """Returns the short branch name, e.g. 'master'."""
339 if not self.branch:
340 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
341 self.branch = ShortBranchName(self.branchref)
342 return self.branch
343
344 def GetBranchRef(self):
345 """Returns the full branch name, e.g. 'refs/heads/master'."""
346 self.GetBranch() # Poke the lazy loader.
347 return self.branchref
348
349 def FetchUpstreamTuple(self):
350 """Returns a tuple containg remote and remote ref,
351 e.g. 'origin', 'refs/heads/master'
352 """
353 remote = '.'
354 branch = self.GetBranch()
355 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
356 error_ok=True).strip()
357 if upstream_branch:
358 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
359 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000360 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
361 error_ok=True).strip()
362 if upstream_branch:
363 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000364 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000365 # Fall back on trying a git-svn upstream branch.
366 if settings.GetIsGitSvn():
367 upstream_branch = settings.GetSVNBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000368 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000369 # Else, try to guess the origin remote.
370 remote_branches = RunGit(['branch', '-r']).split()
371 if 'origin/master' in remote_branches:
372 # Fall back on origin/master if it exits.
373 remote = 'origin'
374 upstream_branch = 'refs/heads/master'
375 elif 'origin/trunk' in remote_branches:
376 # Fall back on origin/trunk if it exists. Generally a shared
377 # git-svn clone
378 remote = 'origin'
379 upstream_branch = 'refs/heads/trunk'
380 else:
381 DieWithError("""Unable to determine default branch to diff against.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000382Either pass complete "git diff"-style arguments, like
383 git cl upload origin/master
384or verify this branch is set up to track another (via the --track argument to
385"git checkout -b ...").""")
386
387 return remote, upstream_branch
388
389 def GetUpstreamBranch(self):
390 if self.upstream_branch is None:
391 remote, upstream_branch = self.FetchUpstreamTuple()
392 if remote is not '.':
393 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
394 self.upstream_branch = upstream_branch
395 return self.upstream_branch
396
397 def GetRemoteUrl(self):
398 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
399
400 Returns None if there is no remote.
401 """
402 remote = self.FetchUpstreamTuple()[0]
403 if remote == '.':
404 return None
405 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
406
407 def GetIssue(self):
408 if not self.has_issue:
409 CheckForMigration()
410 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
411 if issue:
412 self.issue = issue
413 self.rietveld_server = FixUrl(RunGit(
414 ['config', self._RietveldServer()], error_ok=True).strip())
415 else:
416 self.issue = None
417 if not self.rietveld_server:
418 self.rietveld_server = settings.GetDefaultServerUrl()
419 self.has_issue = True
420 return self.issue
421
422 def GetRietveldServer(self):
423 self.GetIssue()
424 return self.rietveld_server
425
426 def GetIssueURL(self):
427 """Get the URL for a particular issue."""
428 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
429
430 def GetDescription(self, pretty=False):
431 if not self.has_description:
432 if self.GetIssue():
433 path = '/' + self.GetIssue() + '/description'
434 rpc_server = self._RpcServer()
435 self.description = rpc_server.Send(path).strip()
436 self.has_description = True
437 if pretty:
438 wrapper = textwrap.TextWrapper()
439 wrapper.initial_indent = wrapper.subsequent_indent = ' '
440 return wrapper.fill(self.description)
441 return self.description
442
443 def GetPatchset(self):
444 if not self.has_patchset:
445 patchset = RunGit(['config', self._PatchsetSetting()],
446 error_ok=True).strip()
447 if patchset:
448 self.patchset = patchset
449 else:
450 self.patchset = None
451 self.has_patchset = True
452 return self.patchset
453
454 def SetPatchset(self, patchset):
455 """Set this branch's patchset. If patchset=0, clears the patchset."""
456 if patchset:
457 RunGit(['config', self._PatchsetSetting(), str(patchset)])
458 else:
459 RunGit(['config', '--unset', self._PatchsetSetting()],
460 swallow_stderr=True, error_ok=True)
461 self.has_patchset = False
462
463 def SetIssue(self, issue):
464 """Set this branch's issue. If issue=0, clears the issue."""
465 if issue:
466 RunGit(['config', self._IssueSetting(), str(issue)])
467 if self.rietveld_server:
468 RunGit(['config', self._RietveldServer(), self.rietveld_server])
469 else:
470 RunGit(['config', '--unset', self._IssueSetting()])
471 self.SetPatchset(0)
472 self.has_issue = False
473
474 def CloseIssue(self):
475 rpc_server = self._RpcServer()
476 # Newer versions of Rietveld require us to pass an XSRF token to POST, so
477 # we fetch it from the server. (The version used by Chromium has been
478 # modified so the token isn't required when closing an issue.)
479 xsrf_token = rpc_server.Send('/xsrf_token',
480 extra_headers={'X-Requesting-XSRF-Token': '1'})
481
482 # You cannot close an issue with a GET.
483 # We pass an empty string for the data so it is a POST rather than a GET.
484 data = [("description", self.description),
485 ("xsrf_token", xsrf_token)]
486 ctype, body = upload.EncodeMultipartFormData(data, [])
487 rpc_server.Send('/' + self.GetIssue() + '/close', body, ctype)
488
489 def _RpcServer(self):
490 """Returns an upload.RpcServer() to access this review's rietveld instance.
491 """
492 server = self.GetRietveldServer()
493 return upload.GetRpcServer(server, save_cookies=True)
494
495 def _IssueSetting(self):
496 """Return the git setting that stores this change's issue."""
497 return 'branch.%s.rietveldissue' % self.GetBranch()
498
499 def _PatchsetSetting(self):
500 """Return the git setting that stores this change's most recent patchset."""
501 return 'branch.%s.rietveldpatchset' % self.GetBranch()
502
503 def _RietveldServer(self):
504 """Returns the git setting that stores this change's rietveld server."""
505 return 'branch.%s.rietveldserver' % self.GetBranch()
506
507
508def GetCodereviewSettingsInteractively():
509 """Prompt the user for settings."""
510 server = settings.GetDefaultServerUrl(error_ok=True)
511 prompt = 'Rietveld server (host[:port])'
512 prompt += ' [%s]' % (server or DEFAULT_SERVER)
513 newserver = raw_input(prompt + ': ')
514 if not server and not newserver:
515 newserver = DEFAULT_SERVER
516 if newserver and newserver != server:
517 RunGit(['config', 'rietveld.server', newserver])
518
519 def SetProperty(initial, caption, name):
520 prompt = caption
521 if initial:
522 prompt += ' ("x" to clear) [%s]' % initial
523 new_val = raw_input(prompt + ': ')
524 if new_val == 'x':
525 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
526 elif new_val and new_val != initial:
527 RunGit(['config', 'rietveld.' + name, new_val])
528
529 SetProperty(settings.GetCCList(), 'CC list', 'cc')
530 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
531 'tree-status-url')
532 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
533
534 # TODO: configure a default branch to diff against, rather than this
535 # svn-based hackery.
536
537
538def FindCodereviewSettingsFile(filename='codereview.settings'):
539 """Finds the given file starting in the cwd and going up.
540
541 Only looks up to the top of the repository unless an
542 'inherit-review-settings-ok' file exists in the root of the repository.
543 """
544 inherit_ok_file = 'inherit-review-settings-ok'
545 cwd = os.getcwd()
546 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
547 if os.path.isfile(os.path.join(root, inherit_ok_file)):
548 root = '/'
549 while True:
550 if filename in os.listdir(cwd):
551 if os.path.isfile(os.path.join(cwd, filename)):
552 return open(os.path.join(cwd, filename))
553 if cwd == root:
554 break
555 cwd = os.path.dirname(cwd)
556
557
558def LoadCodereviewSettingsFromFile(fileobj):
559 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000560 keyvals = {}
561 for line in fileobj.read().splitlines():
562 if not line or line.startswith("#"):
563 continue
564 k, v = line.split(": ", 1)
565 keyvals[k] = v
566
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000567 def SetProperty(name, setting, unset_error_ok=False):
568 fullname = 'rietveld.' + name
569 if setting in keyvals:
570 RunGit(['config', fullname, keyvals[setting]])
571 else:
572 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
573
574 SetProperty('server', 'CODE_REVIEW_SERVER')
575 # Only server setting is required. Other settings can be absent.
576 # In that case, we ignore errors raised during option deletion attempt.
577 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
578 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
579 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
580
581 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
582 #should be of the form
583 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
584 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
585 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
586 keyvals['ORIGIN_URL_CONFIG']])
587
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000588
589@usage('[repo root containing codereview.settings]')
590def CMDconfig(parser, args):
591 """edit configuration for this tree"""
592
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000593 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000594 if len(args) == 0:
595 GetCodereviewSettingsInteractively()
596 return 0
597
598 url = args[0]
599 if not url.endswith('codereview.settings'):
600 url = os.path.join(url, 'codereview.settings')
601
602 # Load code review settings and download hooks (if available).
603 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
604 return 0
605
606
607def CMDstatus(parser, args):
608 """show status of changelists"""
609 parser.add_option('--field',
610 help='print only specific field (desc|id|patch|url)')
611 (options, args) = parser.parse_args(args)
612
613 # TODO: maybe make show_branches a flag if necessary.
614 show_branches = not options.field
615
616 if show_branches:
617 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
618 if branches:
619 print 'Branches associated with reviews:'
620 for branch in sorted(branches.splitlines()):
621 cl = Changelist(branchref=branch)
622 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
623
624 cl = Changelist()
625 if options.field:
626 if options.field.startswith('desc'):
627 print cl.GetDescription()
628 elif options.field == 'id':
629 issueid = cl.GetIssue()
630 if issueid:
631 print issueid
632 elif options.field == 'patch':
633 patchset = cl.GetPatchset()
634 if patchset:
635 print patchset
636 elif options.field == 'url':
637 url = cl.GetIssueURL()
638 if url:
639 print url
640 else:
641 print
642 print 'Current branch:',
643 if not cl.GetIssue():
644 print 'no issue assigned.'
645 return 0
646 print cl.GetBranch()
647 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
648 print 'Issue description:'
649 print cl.GetDescription(pretty=True)
650 return 0
651
652
653@usage('[issue_number]')
654def CMDissue(parser, args):
655 """Set or display the current code review issue number.
656
657 Pass issue number 0 to clear the current issue.
658"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000659 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000660
661 cl = Changelist()
662 if len(args) > 0:
663 try:
664 issue = int(args[0])
665 except ValueError:
666 DieWithError('Pass a number to set the issue or none to list it.\n'
667 'Maybe you want to run git cl status?')
668 cl.SetIssue(issue)
669 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
670 return 0
671
672
673def CreateDescriptionFromLog(args):
674 """Pulls out the commit log to use as a base for the CL description."""
675 log_args = []
676 if len(args) == 1 and not args[0].endswith('.'):
677 log_args = [args[0] + '..']
678 elif len(args) == 1 and args[0].endswith('...'):
679 log_args = [args[0][:-1]]
680 elif len(args) == 2:
681 log_args = [args[0] + '..' + args[1]]
682 else:
683 log_args = args[:] # Hope for the best!
684 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
685
686
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000687def ConvertToInteger(inputval):
688 """Convert a string to integer, but returns either an int or None."""
689 try:
690 return int(inputval)
691 except (TypeError, ValueError):
692 return None
693
694
dpranke@chromium.org0c20c2f2011-03-22 18:13:37 +0000695class GitChangeDescription(presubmit_support.ChangeDescription):
696 def UserEdit(self):
697 header = (
698 "# Enter a description of the change.\n"
699 "# This will displayed on the codereview site.\n"
700 "# The first line will also be used as the subject of the review.\n"
701 "\n")
702 edited_text = self.editor(header + self.EditableDescription())
703 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
704 self.Parse(stripcomment_re.sub('', edited_text).strip())
705
706
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000707def RunHook(committing, upstream_branch, rietveld_server, tbr, may_prompt):
708 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000709 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip()
710 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000711 root = '.'
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000712 absroot = os.path.abspath(root)
713 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000714 raise Exception('Could not get root directory.')
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000715
716 # We use the sha1 of HEAD as a name of this change.
717 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
718 files = scm.GIT.CaptureStatus([root], upstream_branch)
719
720 cl = Changelist()
721 issue = ConvertToInteger(cl.GetIssue())
722 patchset = ConvertToInteger(cl.GetPatchset())
723 if issue:
724 description = cl.GetDescription()
725 else:
726 # If the change was never uploaded, use the log messages of all commits
727 # up to the branch point, as git cl upload will prefill the description
728 # with these log messages.
729 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000730 '%s...' % (upstream_branch)]).strip()
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000731 change = presubmit_support.GitChange(name, description, absroot, files,
732 issue, patchset)
733
734 # Apply watchlists on upload.
735 if not committing:
736 watchlist = watchlists.Watchlists(change.RepositoryRoot())
737 files = [f.LocalPath() for f in change.AffectedFiles()]
738 watchers = watchlist.GetWatchersForPaths(files)
739 RunCommand(['git', 'config', '--replace-all',
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000740 'rietveld.extracc', ','.join(watchers)])
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000741
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000742 output = presubmit_support.DoPresubmitChecks(change, committing,
743 verbose=False, output_stream=sys.stdout, input_stream=sys.stdin,
744 default_presubmit=None, may_prompt=may_prompt, tbr=tbr,
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000745 host_url=cl.GetRietveldServer())
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000746
747 # TODO(dpranke): We should propagate the error out instead of calling exit().
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000748 if not output.should_continue():
749 sys.exit(1)
dpranke@chromium.org37248c02011-03-12 04:36:48 +0000750
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000751 return output
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000752
753
754def CMDpresubmit(parser, args):
755 """run presubmit tests on the current changelist"""
756 parser.add_option('--upload', action='store_true',
757 help='Run upload hook instead of the push/dcommit hook')
758 (options, args) = parser.parse_args(args)
759
760 # Make sure index is up-to-date before running diff-index.
761 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
762 if RunGit(['diff-index', 'HEAD']):
763 # TODO(maruel): Is this really necessary?
764 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
765 return 1
766
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000767 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000768 if args:
769 base_branch = args[0]
770 else:
771 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000772 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000773
774 if options.upload:
775 print '*** Presubmit checks for UPLOAD would report: ***'
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000776 RunHook(committing=False, upstream_branch=base_branch,
dpranke@chromium.org0c20c2f2011-03-22 18:13:37 +0000777 rietveld_server=cl.GetRietveldServer(), tbr=cl.tbr,
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000778 may_prompt=False)
779 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000780 else:
781 print '*** Presubmit checks for DCOMMIT would report: ***'
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000782 RunHook(committing=True, upstream_branch=base_branch,
dpranke@chromium.org0c20c2f2011-03-22 18:13:37 +0000783 rietveld_server=cl.GetRietveldServer, tbr=cl.tbr,
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000784 may_prompt=False)
785 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000786
787
788@usage('[args to "git diff"]')
789def CMDupload(parser, args):
790 """upload the current changelist to codereview"""
791 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
792 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000793 parser.add_option('-f', action='store_true', dest='force',
794 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000795 parser.add_option('-m', dest='message', help='message for patch')
796 parser.add_option('-r', '--reviewers',
797 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000798 parser.add_option('--cc',
799 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000800 parser.add_option('--send-mail', action='store_true',
801 help='send email to reviewer immediately')
802 parser.add_option("--emulate_svn_auto_props", action="store_true",
803 dest="emulate_svn_auto_props",
804 help="Emulate Subversion's auto properties feature.")
805 parser.add_option("--desc_from_logs", action="store_true",
806 dest="from_logs",
807 help="""Squashes git commit logs into change description and
808 uses message as subject""")
809 (options, args) = parser.parse_args(args)
810
811 # Make sure index is up-to-date before running diff-index.
812 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
813 if RunGit(['diff-index', 'HEAD']):
814 print 'Cannot upload with a dirty tree. You must commit locally first.'
815 return 1
816
817 cl = Changelist()
818 if args:
819 base_branch = args[0]
820 else:
821 # Default to diffing against the "upstream" branch.
822 base_branch = cl.GetUpstreamBranch()
823 args = [base_branch + "..."]
824
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000825 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000826 hook_results = RunHook(committing=False, upstream_branch=base_branch,
dpranke@chromium.org0c20c2f2011-03-22 18:13:37 +0000827 rietveld_server=cl.GetRietveldServer(), tbr=cl.tbr,
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000828 may_prompt=True)
829 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org0c20c2f2011-03-22 18:13:37 +0000830 options.reviewers = ','.join(hook_results.reviewers)
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000831
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000832
833 # --no-ext-diff is broken in some versions of Git, so try to work around
834 # this by overriding the environment (but there is still a problem if the
835 # git config key "diff.external" is used).
836 env = os.environ.copy()
837 if 'GIT_EXTERNAL_DIFF' in env:
838 del env['GIT_EXTERNAL_DIFF']
839 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
840 env=env)
841
842 upload_args = ['--assume_yes'] # Don't ask about untracked files.
843 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000844 if options.emulate_svn_auto_props:
845 upload_args.append('--emulate_svn_auto_props')
846 if options.send_mail:
847 if not options.reviewers:
848 DieWithError("Must specify reviewers to send email.")
849 upload_args.append('--send_mail')
850 if options.from_logs and not options.message:
851 print 'Must set message for subject line if using desc_from_logs'
852 return 1
853
854 change_desc = None
855
856 if cl.GetIssue():
857 if options.message:
858 upload_args.extend(['--message', options.message])
859 upload_args.extend(['--issue', cl.GetIssue()])
860 print ("This branch is associated with issue %s. "
861 "Adding patch to that issue." % cl.GetIssue())
862 else:
863 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org0c20c2f2011-03-22 18:13:37 +0000864 change_desc = GitChangeDescription(subject=options.message,
865 description=log_desc, reviewers=options.reviewers, tbr=cl.tbr)
866 if not options.from_logs and (not options.force):
867 change_desc.UserEdit()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000868
869 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000870 print "Description is empty; aborting."
871 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000872
873 upload_args.extend(['--message', change_desc.subject])
874 upload_args.extend(['--description', change_desc.description])
875 if change_desc.reviewers:
876 upload_args.extend(['--reviewers', change_desc.reviewers])
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000877 cc = ','.join(filter(None, (settings.GetCCList(), options.cc)))
878 if cc:
879 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000880
881 # Include the upstream repo's URL in the change -- this is useful for
882 # projects that have their source spread across multiple repos.
883 remote_url = None
884 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000885 # URL is dependent on the current directory.
886 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000887 if data:
888 keys = dict(line.split(': ', 1) for line in data.splitlines()
889 if ': ' in line)
890 remote_url = keys.get('URL', None)
891 else:
892 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
893 remote_url = (cl.GetRemoteUrl() + '@'
894 + cl.GetUpstreamBranch().split('/')[-1])
895 if remote_url:
896 upload_args.extend(['--base_url', remote_url])
897
898 try:
899 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
900 except:
901 # If we got an exception after the user typed a description for their
902 # change, back up the description before re-raising.
903 if change_desc:
904 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
905 print '\nGot exception while uploading -- saving description to %s\n' \
906 % backup_path
907 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000908 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000909 backup_file.close()
910 raise
911
912 if not cl.GetIssue():
913 cl.SetIssue(issue)
914 cl.SetPatchset(patchset)
915 return 0
916
917
918def SendUpstream(parser, args, cmd):
919 """Common code for CmdPush and CmdDCommit
920
921 Squashed commit into a single.
922 Updates changelog with metadata (e.g. pointer to review).
923 Pushes/dcommits the code upstream.
924 Updates review and closes.
925 """
926 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
927 help='bypass upload presubmit hook')
928 parser.add_option('-m', dest='message',
929 help="override review description")
930 parser.add_option('-f', action='store_true', dest='force',
931 help="force yes to questions (don't prompt)")
932 parser.add_option('-c', dest='contributor',
933 help="external contributor for patch (appended to " +
934 "description and used as author for git). Should be " +
935 "formatted as 'First Last <email@example.com>'")
936 parser.add_option('--tbr', action='store_true', dest='tbr',
937 help="short for 'to be reviewed', commit branch " +
938 "even without uploading for review")
939 (options, args) = parser.parse_args(args)
940 cl = Changelist()
941
942 if not args or cmd == 'push':
943 # Default to merging against our best guess of the upstream branch.
944 args = [cl.GetUpstreamBranch()]
945
946 base_branch = args[0]
947
chase@chromium.orgc76e6752011-01-10 18:17:12 +0000948 # Make sure index is up-to-date before running diff-index.
949 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000950 if RunGit(['diff-index', 'HEAD']):
951 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
952 return 1
953
954 # This rev-list syntax means "show all commits not in my branch that
955 # are in base_branch".
956 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
957 base_branch]).splitlines()
958 if upstream_commits:
959 print ('Base branch "%s" has %d commits '
960 'not in this branch.' % (base_branch, len(upstream_commits)))
961 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
962 return 1
963
964 if cmd == 'dcommit':
965 # This is the revision `svn dcommit` will commit on top of.
966 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
967 '--pretty=format:%H'])
968 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
969 if extra_commits:
970 print ('This branch has %d additional commits not upstreamed yet.'
971 % len(extra_commits.splitlines()))
972 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
973 'before attempting to %s.' % (base_branch, cmd))
974 return 1
975
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000976 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000977 RunHook(committing=True, upstream_branch=base_branch,
dpranke@chromium.org0c20c2f2011-03-22 18:13:37 +0000978 rietveld_server=cl.GetRietveldServer(), tbr=(cl.tbr or options.tbr),
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000979 may_prompt=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000980
981 if cmd == 'dcommit':
982 # Check the tree status if the tree status URL is set.
983 status = GetTreeStatus()
984 if 'closed' == status:
985 print ('The tree is closed. Please wait for it to reopen. Use '
986 '"git cl dcommit -f" to commit on a closed tree.')
987 return 1
988 elif 'unknown' == status:
989 print ('Unable to determine tree status. Please verify manually and '
990 'use "git cl dcommit -f" to commit on a closed tree.')
991
992 description = options.message
993 if not options.tbr:
994 # It is important to have these checks early. Not only for user
995 # convenience, but also because the cl object then caches the correct values
996 # of these fields even as we're juggling branches for setting up the commit.
997 if not cl.GetIssue():
998 print 'Current issue unknown -- has this branch been uploaded?'
999 print 'Use --tbr to commit without review.'
1000 return 1
1001
1002 if not description:
1003 description = cl.GetDescription()
1004
1005 if not description:
1006 print 'No description set.'
1007 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1008 return 1
1009
1010 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1011 else:
1012 if not description:
1013 # Submitting TBR. See if there's already a description in Rietveld, else
1014 # create a template description. Eitherway, give the user a chance to edit
1015 # it to fill in the TBR= field.
1016 if cl.GetIssue():
dpranke@chromium.org0c20c2f2011-03-22 18:13:37 +00001017 change_desc = GitChangeDescription(description=cl.GetDescription())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001018
1019 if not description:
dpranke@chromium.org0c20c2f2011-03-22 18:13:37 +00001020 log_desc = CreateDescriptionFromLog(args)
1021 change_desc = GitChangeDescription(description=log_desc, tbr=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001022
dpranke@chromium.org0c20c2f2011-03-22 18:13:37 +00001023 if not options.force:
1024 change_desc.UserEdit()
1025 description = change_desc.description
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001026
1027 if not description:
1028 print "Description empty; aborting."
1029 return 1
1030
1031 if options.contributor:
1032 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1033 print "Please provide contibutor as 'First Last <email@example.com>'"
1034 return 1
1035 description += "\nPatch from %s." % options.contributor
1036 print 'Description:', repr(description)
1037
1038 branches = [base_branch, cl.GetBranchRef()]
1039 if not options.force:
1040 subprocess.call(['git', 'diff', '--stat'] + branches)
1041 raw_input("About to commit; enter to confirm.")
1042
1043 # We want to squash all this branch's commits into one commit with the
1044 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001045 # We do this by doing a "reset --soft" to the base branch (which keeps
1046 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001047 MERGE_BRANCH = 'git-cl-commit'
1048 # Delete the merge branch if it already exists.
1049 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1050 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1051 RunGit(['branch', '-D', MERGE_BRANCH])
1052
1053 # We might be in a directory that's present in this branch but not in the
1054 # trunk. Move up to the top of the tree so that git commands that expect a
1055 # valid CWD won't fail after we check out the merge branch.
1056 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1057 if rel_base_path:
1058 os.chdir(rel_base_path)
1059
1060 # Stuff our change into the merge branch.
1061 # We wrap in a try...finally block so if anything goes wrong,
1062 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001063 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001064 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001065 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1066 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001067 if options.contributor:
1068 RunGit(['commit', '--author', options.contributor, '-m', description])
1069 else:
1070 RunGit(['commit', '-m', description])
1071 if cmd == 'push':
1072 # push the merge branch.
1073 remote, branch = cl.FetchUpstreamTuple()
1074 retcode, output = RunGitWithCode(
1075 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1076 logging.debug(output)
1077 else:
1078 # dcommit the merge branch.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001079 retcode, output = RunGitWithCode(['svn', 'dcommit', '--no-rebase'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001080 finally:
1081 # And then swap back to the original branch and clean up.
1082 RunGit(['checkout', '-q', cl.GetBranch()])
1083 RunGit(['branch', '-D', MERGE_BRANCH])
1084
1085 if cl.GetIssue():
1086 if cmd == 'dcommit' and 'Committed r' in output:
1087 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1088 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001089 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1090 for l in output.splitlines(False))
1091 match = filter(None, match)
1092 if len(match) != 1:
1093 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1094 output)
1095 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001096 else:
1097 return 1
1098 viewvc_url = settings.GetViewVCUrl()
1099 if viewvc_url and revision:
1100 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1101 print ('Closing issue '
1102 '(you may be prompted for your codereview password)...')
1103 cl.CloseIssue()
1104 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001105
1106 if retcode == 0:
1107 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1108 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001109 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001110
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001111 return 0
1112
1113
1114@usage('[upstream branch to apply against]')
1115def CMDdcommit(parser, args):
1116 """commit the current changelist via git-svn"""
1117 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001118 message = """This doesn't appear to be an SVN repository.
1119If your project has a git mirror with an upstream SVN master, you probably need
1120to run 'git svn init', see your project's git mirror documentation.
1121If your project has a true writeable upstream repository, you probably want
1122to run 'git cl push' instead.
1123Choose wisely, if you get this wrong, your commit might appear to succeed but
1124will instead be silently ignored."""
1125 print(message)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001126 raw_input('[Press enter to dcommit or ctrl-C to quit]')
1127 return SendUpstream(parser, args, 'dcommit')
1128
1129
1130@usage('[upstream branch to apply against]')
1131def CMDpush(parser, args):
1132 """commit the current changelist via git"""
1133 if settings.GetIsGitSvn():
1134 print('This appears to be an SVN repository.')
1135 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
1136 raw_input('[Press enter to push or ctrl-C to quit]')
1137 return SendUpstream(parser, args, 'push')
1138
1139
1140@usage('<patch url or issue id>')
1141def CMDpatch(parser, args):
1142 """patch in a code review"""
1143 parser.add_option('-b', dest='newbranch',
1144 help='create a new branch off trunk for the patch')
1145 parser.add_option('-f', action='store_true', dest='force',
1146 help='with -b, clobber any existing branch')
1147 parser.add_option('--reject', action='store_true', dest='reject',
1148 help='allow failed patches and spew .rej files')
1149 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1150 help="don't commit after patch applies")
1151 (options, args) = parser.parse_args(args)
1152 if len(args) != 1:
1153 parser.print_help()
1154 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001155 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001156
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001157 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001158 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001159 issue = issue_arg
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001160 server = settings.GetDefaultServerUrl()
1161 fetch = urllib2.urlopen('%s/%s' % (server, issue)).read()
1162 m = re.search(r'/download/issue[0-9]+_[0-9]+.diff', fetch)
1163 if not m:
1164 DieWithError('Must pass an issue ID or full URL for '
1165 '\'Download raw patch set\'')
1166 url = '%s%s' % (server, m.group(0).strip())
1167 else:
1168 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001169 issue_url = FixUrl(issue_arg)
1170 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001171 if match:
1172 issue = match.group(1)
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001173 url = issue_arg
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001174 else:
1175 DieWithError('Must pass an issue ID or full URL for '
1176 '\'Download raw patch set\'')
1177
1178 if options.newbranch:
1179 if options.force:
1180 RunGit(['branch', '-D', options.newbranch],
1181 swallow_stderr=True, error_ok=True)
1182 RunGit(['checkout', '-b', options.newbranch,
1183 Changelist().GetUpstreamBranch()])
1184
1185 # Switch up to the top-level directory, if necessary, in preparation for
1186 # applying the patch.
1187 top = RunGit(['rev-parse', '--show-cdup']).strip()
1188 if top:
1189 os.chdir(top)
1190
1191 patch_data = urllib2.urlopen(url).read()
1192 # Git patches have a/ at the beginning of source paths. We strip that out
1193 # with a sed script rather than the -p flag to patch so we can feed either
1194 # Git or svn-style patches into the same apply command.
1195 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1196 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1197 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1198 patch_data = sed_proc.communicate(patch_data)[0]
1199 if sed_proc.returncode:
1200 DieWithError('Git patch mungling failed.')
1201 logging.info(patch_data)
1202 # We use "git apply" to apply the patch instead of "patch" so that we can
1203 # pick up file adds.
1204 # The --index flag means: also insert into the index (so we catch adds).
1205 cmd = ['git', 'apply', '--index', '-p0']
1206 if options.reject:
1207 cmd.append('--reject')
1208 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1209 patch_proc.communicate(patch_data)
1210 if patch_proc.returncode:
1211 DieWithError('Failed to apply the patch')
1212
1213 # If we had an issue, commit the current state and register the issue.
1214 if not options.nocommit:
1215 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1216 cl = Changelist()
1217 cl.SetIssue(issue)
1218 print "Committed patch."
1219 else:
1220 print "Patch applied to index."
1221 return 0
1222
1223
1224def CMDrebase(parser, args):
1225 """rebase current branch on top of svn repo"""
1226 # Provide a wrapper for git svn rebase to help avoid accidental
1227 # git svn dcommit.
1228 # It's the only command that doesn't use parser at all since we just defer
1229 # execution to git-svn.
1230 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1231 return 0
1232
1233
1234def GetTreeStatus():
1235 """Fetches the tree status and returns either 'open', 'closed',
1236 'unknown' or 'unset'."""
1237 url = settings.GetTreeStatusUrl(error_ok=True)
1238 if url:
1239 status = urllib2.urlopen(url).read().lower()
1240 if status.find('closed') != -1 or status == '0':
1241 return 'closed'
1242 elif status.find('open') != -1 or status == '1':
1243 return 'open'
1244 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001245 return 'unset'
1246
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001247
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001248def GetTreeStatusReason():
1249 """Fetches the tree status from a json url and returns the message
1250 with the reason for the tree to be opened or closed."""
1251 # Don't import it at file level since simplejson is not installed by default
1252 # on python 2.5 and it is only used for git-cl tree which isn't often used,
1253 # forcing everyone to install simplejson isn't efficient.
1254 try:
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001255 import simplejson as json # pylint: disable=F0401
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001256 except ImportError:
1257 try:
1258 import json
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001259 except ImportError:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001260 print >> sys.stderr, 'Please install simplejson'
1261 sys.exit(1)
1262
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001263 url = settings.GetTreeStatusUrl()
1264 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001265 connection = urllib2.urlopen(json_url)
1266 status = json.loads(connection.read())
1267 connection.close()
1268 return status['message']
1269
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001270
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001271def CMDtree(parser, args):
1272 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001273 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001274 status = GetTreeStatus()
1275 if 'unset' == status:
1276 print 'You must configure your tree status URL by running "git cl config".'
1277 return 2
1278
1279 print "The tree is %s" % status
1280 print
1281 print GetTreeStatusReason()
1282 if status != 'open':
1283 return 1
1284 return 0
1285
1286
1287def CMDupstream(parser, args):
1288 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001289 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001290 cl = Changelist()
1291 print cl.GetUpstreamBranch()
1292 return 0
1293
1294
1295def Command(name):
1296 return getattr(sys.modules[__name__], 'CMD' + name, None)
1297
1298
1299def CMDhelp(parser, args):
1300 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001301 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001302 if len(args) == 1:
1303 return main(args + ['--help'])
1304 parser.print_help()
1305 return 0
1306
1307
1308def GenUsage(parser, command):
1309 """Modify an OptParse object with the function's documentation."""
1310 obj = Command(command)
1311 more = getattr(obj, 'usage_more', '')
1312 if command == 'help':
1313 command = '<command>'
1314 else:
1315 # OptParser.description prefer nicely non-formatted strings.
1316 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1317 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1318
1319
1320def main(argv):
1321 """Doesn't parse the arguments here, just find the right subcommand to
1322 execute."""
1323 # Do it late so all commands are listed.
1324 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1325 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1326 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1327
1328 # Create the option parse and add --verbose support.
1329 parser = optparse.OptionParser()
1330 parser.add_option('-v', '--verbose', action='store_true')
1331 old_parser_args = parser.parse_args
1332 def Parse(args):
1333 options, args = old_parser_args(args)
1334 if options.verbose:
1335 logging.basicConfig(level=logging.DEBUG)
1336 else:
1337 logging.basicConfig(level=logging.WARNING)
1338 return options, args
1339 parser.parse_args = Parse
1340
1341 if argv:
1342 command = Command(argv[0])
1343 if command:
1344 # "fix" the usage and the description now that we know the subcommand.
1345 GenUsage(parser, argv[0])
1346 try:
1347 return command(parser, argv[1:])
1348 except urllib2.HTTPError, e:
1349 if e.code != 500:
1350 raise
1351 DieWithError(
1352 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1353 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1354
1355 # Not a known command. Default to help.
1356 GenUsage(parser, 'help')
1357 return CMDhelp(parser, argv)
1358
1359
1360if __name__ == '__main__':
1361 sys.exit(main(sys.argv[1:]))