blob: d0866ca3a22617a9be8e7a4e9ff3e3abc947f538 [file] [log] [blame]
maruel@chromium.org725f1c32011-04-01 20:24:54 +00001#!/usr/bin/env python
2# Copyright (c) 2011 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00006# Copyright (C) 2008 Evan Martin <martine@danga.com>
7
maruel@chromium.org725f1c32011-04-01 20:24:54 +00008"""A git-command for integrating reviews on Rietveld."""
9
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000010import errno
11import logging
12import optparse
13import os
14import re
15import subprocess
16import sys
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000017import tempfile
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000018import textwrap
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +000019import urlparse
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000020import urllib2
21
22try:
maruel@chromium.orgc98c0c52011-04-06 13:39:43 +000023 import readline # pylint: disable=F0401,W0611
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000024except ImportError:
25 pass
26
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000027try:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000028 import simplejson as json # pylint: disable=F0401
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000029except ImportError:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000030 try:
maruel@chromium.org725f1c32011-04-01 20:24:54 +000031 import json # pylint: disable=F0401
maruel@chromium.org2a74d372011-03-29 19:05:50 +000032 except ImportError:
33 # Fall back to the packaged version.
maruel@chromium.orgb35c00c2011-03-31 00:43:35 +000034 sys.path.append(os.path.join(os.path.dirname(__file__), 'third_party'))
dpranke@chromium.orgfe79c312011-04-01 20:15:52 +000035 import simplejson as json # pylint: disable=F0401
maruel@chromium.org2a74d372011-03-29 19:05:50 +000036
37
38from third_party import upload
39import breakpad # pylint: disable=W0611
maruel@chromium.org6f09cd92011-04-01 16:38:12 +000040import fix_encoding
maruel@chromium.org2a74d372011-03-29 19:05:50 +000041import presubmit_support
maruel@chromium.orgcab38e92011-04-09 00:30:51 +000042import rietveld
maruel@chromium.org2a74d372011-03-29 19:05:50 +000043import scm
44import watchlists
45
46
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000047
48DEFAULT_SERVER = 'http://codereview.appspot.com'
maruel@chromium.org0ba7f962011-01-11 22:13:58 +000049POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000050DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup'
51
maruel@chromium.org90541732011-04-01 17:54:18 +000052
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000053def DieWithError(message):
dpranke@chromium.org970c5222011-03-12 00:32:24 +000054 print >> sys.stderr, message
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000055 sys.exit(1)
56
57
58def Popen(cmd, **kwargs):
59 """Wrapper for subprocess.Popen() that logs and watch for cygwin issues"""
maruel@chromium.org899e1c12011-04-07 17:03:18 +000060 logging.debug('Popen: ' + ' '.join(cmd))
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000061 try:
62 return subprocess.Popen(cmd, **kwargs)
63 except OSError, e:
64 if e.errno == errno.EAGAIN and sys.platform == 'cygwin':
65 DieWithError(
66 'Visit '
67 'http://code.google.com/p/chromium/wiki/CygwinDllRemappingFailure to '
68 'learn how to fix this error; you need to rebase your cygwin dlls')
69 raise
70
71
72def RunCommand(cmd, error_ok=False, error_message=None,
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000073 redirect_stdout=True, swallow_stderr=False, **kwargs):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000074 if redirect_stdout:
75 stdout = subprocess.PIPE
76 else:
77 stdout = None
78 if swallow_stderr:
79 stderr = subprocess.PIPE
80 else:
81 stderr = None
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000082 proc = Popen(cmd, stdout=stdout, stderr=stderr, **kwargs)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000083 output = proc.communicate()[0]
84 if not error_ok and proc.returncode != 0:
85 DieWithError('Command "%s" failed.\n' % (' '.join(cmd)) +
86 (error_message or output or ''))
87 return output
88
89
90def RunGit(args, **kwargs):
91 cmd = ['git'] + args
92 return RunCommand(cmd, **kwargs)
93
94
95def RunGitWithCode(args):
96 proc = Popen(['git'] + args, stdout=subprocess.PIPE)
97 output = proc.communicate()[0]
98 return proc.returncode, output
99
100
101def usage(more):
102 def hook(fn):
103 fn.usage_more = more
104 return fn
105 return hook
106
107
maruel@chromium.org90541732011-04-01 17:54:18 +0000108def ask_for_data(prompt):
109 try:
110 return raw_input(prompt)
111 except KeyboardInterrupt:
112 # Hide the exception.
113 sys.exit(1)
114
115
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000116def FixUrl(server):
117 """Fix a server url to defaults protocol to http:// if none is specified."""
118 if not server:
119 return server
120 if not re.match(r'[a-z]+\://.*', server):
121 return 'http://' + server
122 return server
123
124
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000125def MatchSvnGlob(url, base_url, glob_spec, allow_wildcards):
126 """Return the corresponding git ref if |base_url| together with |glob_spec|
127 matches the full |url|.
128
129 If |allow_wildcards| is true, |glob_spec| can contain wildcards (see below).
130 """
131 fetch_suburl, as_ref = glob_spec.split(':')
132 if allow_wildcards:
133 glob_match = re.match('(.+/)?(\*|{[^/]*})(/.+)?', fetch_suburl)
134 if glob_match:
135 # Parse specs like "branches/*/src:refs/remotes/svn/*" or
136 # "branches/{472,597,648}/src:refs/remotes/svn/*".
137 branch_re = re.escape(base_url)
138 if glob_match.group(1):
139 branch_re += '/' + re.escape(glob_match.group(1))
140 wildcard = glob_match.group(2)
141 if wildcard == '*':
142 branch_re += '([^/]*)'
143 else:
144 # Escape and replace surrounding braces with parentheses and commas
145 # with pipe symbols.
146 wildcard = re.escape(wildcard)
147 wildcard = re.sub('^\\\\{', '(', wildcard)
148 wildcard = re.sub('\\\\,', '|', wildcard)
149 wildcard = re.sub('\\\\}$', ')', wildcard)
150 branch_re += wildcard
151 if glob_match.group(3):
152 branch_re += re.escape(glob_match.group(3))
153 match = re.match(branch_re, url)
154 if match:
155 return re.sub('\*$', match.group(1), as_ref)
156
157 # Parse specs like "trunk/src:refs/remotes/origin/trunk".
158 if fetch_suburl:
159 full_url = base_url + '/' + fetch_suburl
160 else:
161 full_url = base_url
162 if full_url == url:
163 return as_ref
164 return None
165
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000166class Settings(object):
167 def __init__(self):
168 self.default_server = None
169 self.cc = None
170 self.root = None
171 self.is_git_svn = None
172 self.svn_branch = None
173 self.tree_status_url = None
174 self.viewvc_url = None
175 self.updated = False
176
177 def LazyUpdateIfNeeded(self):
178 """Updates the settings from a codereview.settings file, if available."""
179 if not self.updated:
180 cr_settings_file = FindCodereviewSettingsFile()
181 if cr_settings_file:
182 LoadCodereviewSettingsFromFile(cr_settings_file)
183 self.updated = True
184
185 def GetDefaultServerUrl(self, error_ok=False):
186 if not self.default_server:
187 self.LazyUpdateIfNeeded()
188 self.default_server = FixUrl(self._GetConfig('rietveld.server',
189 error_ok=True))
190 if error_ok:
191 return self.default_server
192 if not self.default_server:
193 error_message = ('Could not find settings file. You must configure '
194 'your review setup by running "git cl config".')
195 self.default_server = FixUrl(self._GetConfig(
196 'rietveld.server', error_message=error_message))
197 return self.default_server
198
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000199 def GetRoot(self):
200 if not self.root:
201 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
202 return self.root
203
204 def GetIsGitSvn(self):
205 """Return true if this repo looks like it's using git-svn."""
206 if self.is_git_svn is None:
207 # If you have any "svn-remote.*" config keys, we think you're using svn.
208 self.is_git_svn = RunGitWithCode(
209 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
210 return self.is_git_svn
211
212 def GetSVNBranch(self):
213 if self.svn_branch is None:
214 if not self.GetIsGitSvn():
215 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
216
217 # Try to figure out which remote branch we're based on.
218 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000219 # 1) iterate through our branch history and find the svn URL.
220 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000221
222 # regexp matching the git-svn line that contains the URL.
223 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
224
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000225 # We don't want to go through all of history, so read a line from the
226 # pipe at a time.
227 # The -100 is an arbitrary limit so we don't search forever.
228 cmd = ['git', 'log', '-100', '--pretty=medium']
229 proc = Popen(cmd, stdout=subprocess.PIPE)
230 for line in proc.stdout:
231 match = git_svn_re.match(line)
232 if match:
233 url = match.group(1)
234 proc.stdout.close() # Cut pipe.
235 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000236
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000237 if url:
238 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
239 remotes = RunGit(['config', '--get-regexp',
240 r'^svn-remote\..*\.url']).splitlines()
241 for remote in remotes:
242 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000243 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000244 remote = match.group(1)
245 base_url = match.group(2)
246 fetch_spec = RunGit(
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000247 ['config', 'svn-remote.%s.fetch' % remote],
248 error_ok=True).strip()
249 if fetch_spec:
250 self.svn_branch = MatchSvnGlob(url, base_url, fetch_spec, False)
251 if self.svn_branch:
252 break
253 branch_spec = RunGit(
254 ['config', 'svn-remote.%s.branches' % remote],
255 error_ok=True).strip()
256 if branch_spec:
257 self.svn_branch = MatchSvnGlob(url, base_url, branch_spec, True)
258 if self.svn_branch:
259 break
260 tag_spec = RunGit(
261 ['config', 'svn-remote.%s.tags' % remote],
262 error_ok=True).strip()
263 if tag_spec:
264 self.svn_branch = MatchSvnGlob(url, base_url, tag_spec, True)
265 if self.svn_branch:
266 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000267
268 if not self.svn_branch:
269 DieWithError('Can\'t guess svn branch -- try specifying it on the '
270 'command line')
271
272 return self.svn_branch
273
274 def GetTreeStatusUrl(self, error_ok=False):
275 if not self.tree_status_url:
276 error_message = ('You must configure your tree status URL by running '
277 '"git cl config".')
278 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
279 error_ok=error_ok,
280 error_message=error_message)
281 return self.tree_status_url
282
283 def GetViewVCUrl(self):
284 if not self.viewvc_url:
285 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
286 return self.viewvc_url
287
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000288 def GetDefaultCCList(self):
289 return self._GetConfig('rietveld.cc', error_ok=True)
290
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000291 def _GetConfig(self, param, **kwargs):
292 self.LazyUpdateIfNeeded()
293 return RunGit(['config', param], **kwargs).strip()
294
295
296settings = Settings()
297
298
299did_migrate_check = False
300def CheckForMigration():
301 """Migrate from the old issue format, if found.
302
303 We used to store the branch<->issue mapping in a file in .git, but it's
304 better to store it in the .git/config, since deleting a branch deletes that
305 branch's entry there.
306 """
307
308 # Don't run more than once.
309 global did_migrate_check
310 if did_migrate_check:
311 return
312
313 gitdir = RunGit(['rev-parse', '--git-dir']).strip()
314 storepath = os.path.join(gitdir, 'cl-mapping')
315 if os.path.exists(storepath):
316 print "old-style git-cl mapping file (%s) found; migrating." % storepath
317 store = open(storepath, 'r')
318 for line in store:
319 branch, issue = line.strip().split()
320 RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
321 issue])
322 store.close()
323 os.remove(storepath)
324 did_migrate_check = True
325
326
327def ShortBranchName(branch):
328 """Convert a name like 'refs/heads/foo' to just 'foo'."""
329 return branch.replace('refs/heads/', '')
330
331
332class Changelist(object):
333 def __init__(self, branchref=None):
334 # Poke settings so we get the "configure your server" message if necessary.
335 settings.GetDefaultServerUrl()
336 self.branchref = branchref
337 if self.branchref:
338 self.branch = ShortBranchName(self.branchref)
339 else:
340 self.branch = None
341 self.rietveld_server = None
342 self.upstream_branch = None
343 self.has_issue = False
344 self.issue = None
345 self.has_description = False
346 self.description = None
347 self.has_patchset = False
348 self.patchset = None
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000349 self._rpc_server = None
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000350 self.cc = None
351 self.watchers = ()
352
353 def GetCCList(self):
354 """Return the users cc'd on this CL.
355
356 Return is a string suitable for passing to gcl with the --cc flag.
357 """
358 if self.cc is None:
359 base_cc = settings .GetDefaultCCList()
360 more_cc = ','.join(self.watchers)
361 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
362 return self.cc
363
364 def SetWatchers(self, watchers):
365 """Set the list of email addresses that should be cc'd based on the changed
366 files in this CL.
367 """
368 self.watchers = watchers
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000369
370 def GetBranch(self):
371 """Returns the short branch name, e.g. 'master'."""
372 if not self.branch:
373 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
374 self.branch = ShortBranchName(self.branchref)
375 return self.branch
376
377 def GetBranchRef(self):
378 """Returns the full branch name, e.g. 'refs/heads/master'."""
379 self.GetBranch() # Poke the lazy loader.
380 return self.branchref
381
382 def FetchUpstreamTuple(self):
383 """Returns a tuple containg remote and remote ref,
384 e.g. 'origin', 'refs/heads/master'
385 """
386 remote = '.'
387 branch = self.GetBranch()
388 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
389 error_ok=True).strip()
390 if upstream_branch:
391 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
392 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000393 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
394 error_ok=True).strip()
395 if upstream_branch:
396 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000397 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000398 # Fall back on trying a git-svn upstream branch.
399 if settings.GetIsGitSvn():
400 upstream_branch = settings.GetSVNBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000401 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000402 # Else, try to guess the origin remote.
403 remote_branches = RunGit(['branch', '-r']).split()
404 if 'origin/master' in remote_branches:
405 # Fall back on origin/master if it exits.
406 remote = 'origin'
407 upstream_branch = 'refs/heads/master'
408 elif 'origin/trunk' in remote_branches:
409 # Fall back on origin/trunk if it exists. Generally a shared
410 # git-svn clone
411 remote = 'origin'
412 upstream_branch = 'refs/heads/trunk'
413 else:
414 DieWithError("""Unable to determine default branch to diff against.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000415Either pass complete "git diff"-style arguments, like
416 git cl upload origin/master
417or verify this branch is set up to track another (via the --track argument to
418"git checkout -b ...").""")
419
420 return remote, upstream_branch
421
422 def GetUpstreamBranch(self):
423 if self.upstream_branch is None:
424 remote, upstream_branch = self.FetchUpstreamTuple()
425 if remote is not '.':
426 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
427 self.upstream_branch = upstream_branch
428 return self.upstream_branch
429
430 def GetRemoteUrl(self):
431 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
432
433 Returns None if there is no remote.
434 """
435 remote = self.FetchUpstreamTuple()[0]
436 if remote == '.':
437 return None
438 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
439
440 def GetIssue(self):
441 if not self.has_issue:
442 CheckForMigration()
443 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
444 if issue:
445 self.issue = issue
446 self.rietveld_server = FixUrl(RunGit(
447 ['config', self._RietveldServer()], error_ok=True).strip())
448 else:
449 self.issue = None
450 if not self.rietveld_server:
451 self.rietveld_server = settings.GetDefaultServerUrl()
452 self.has_issue = True
453 return self.issue
454
455 def GetRietveldServer(self):
456 self.GetIssue()
457 return self.rietveld_server
458
459 def GetIssueURL(self):
460 """Get the URL for a particular issue."""
461 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
462
463 def GetDescription(self, pretty=False):
464 if not self.has_description:
465 if self.GetIssue():
466 path = '/' + self.GetIssue() + '/description'
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000467 rpc_server = self.RpcServer()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000468 self.description = rpc_server.Send(path).strip()
469 self.has_description = True
470 if pretty:
471 wrapper = textwrap.TextWrapper()
472 wrapper.initial_indent = wrapper.subsequent_indent = ' '
473 return wrapper.fill(self.description)
474 return self.description
475
476 def GetPatchset(self):
477 if not self.has_patchset:
478 patchset = RunGit(['config', self._PatchsetSetting()],
479 error_ok=True).strip()
480 if patchset:
481 self.patchset = patchset
482 else:
483 self.patchset = None
484 self.has_patchset = True
485 return self.patchset
486
487 def SetPatchset(self, patchset):
488 """Set this branch's patchset. If patchset=0, clears the patchset."""
489 if patchset:
490 RunGit(['config', self._PatchsetSetting(), str(patchset)])
491 else:
492 RunGit(['config', '--unset', self._PatchsetSetting()],
493 swallow_stderr=True, error_ok=True)
494 self.has_patchset = False
495
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000496 def GetPatchSetDiff(self, issue):
497 # Grab the last patchset of the issue first.
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000498 data = json.loads(self.RpcServer().Send('/api/%s' % issue))
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000499 patchset = data['patchsets'][-1]
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000500 return self.RpcServer().Send(
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000501 '/download/issue%s_%s.diff' % (issue, patchset))
502
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000503 def SetIssue(self, issue):
504 """Set this branch's issue. If issue=0, clears the issue."""
505 if issue:
506 RunGit(['config', self._IssueSetting(), str(issue)])
507 if self.rietveld_server:
508 RunGit(['config', self._RietveldServer(), self.rietveld_server])
509 else:
510 RunGit(['config', '--unset', self._IssueSetting()])
511 self.SetPatchset(0)
512 self.has_issue = False
513
514 def CloseIssue(self):
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000515 rpc_server = self.RpcServer()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000516 # Newer versions of Rietveld require us to pass an XSRF token to POST, so
517 # we fetch it from the server. (The version used by Chromium has been
518 # modified so the token isn't required when closing an issue.)
519 xsrf_token = rpc_server.Send('/xsrf_token',
520 extra_headers={'X-Requesting-XSRF-Token': '1'})
521
522 # You cannot close an issue with a GET.
523 # We pass an empty string for the data so it is a POST rather than a GET.
524 data = [("description", self.description),
525 ("xsrf_token", xsrf_token)]
526 ctype, body = upload.EncodeMultipartFormData(data, [])
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000527 rpc_server.Send(
528 '/' + self.GetIssue() + '/close', payload=body, content_type=ctype)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000529
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000530 def RpcServer(self):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000531 """Returns an upload.RpcServer() to access this review's rietveld instance.
532 """
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000533 if not self._rpc_server:
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000534 self.GetIssue()
535 self._rpc_server = rietveld.Rietveld(self.rietveld_server, None, None)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000536 return self._rpc_server
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000537
538 def _IssueSetting(self):
539 """Return the git setting that stores this change's issue."""
540 return 'branch.%s.rietveldissue' % self.GetBranch()
541
542 def _PatchsetSetting(self):
543 """Return the git setting that stores this change's most recent patchset."""
544 return 'branch.%s.rietveldpatchset' % self.GetBranch()
545
546 def _RietveldServer(self):
547 """Returns the git setting that stores this change's rietveld server."""
548 return 'branch.%s.rietveldserver' % self.GetBranch()
549
550
551def GetCodereviewSettingsInteractively():
552 """Prompt the user for settings."""
553 server = settings.GetDefaultServerUrl(error_ok=True)
554 prompt = 'Rietveld server (host[:port])'
555 prompt += ' [%s]' % (server or DEFAULT_SERVER)
maruel@chromium.org90541732011-04-01 17:54:18 +0000556 newserver = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000557 if not server and not newserver:
558 newserver = DEFAULT_SERVER
559 if newserver and newserver != server:
560 RunGit(['config', 'rietveld.server', newserver])
561
562 def SetProperty(initial, caption, name):
563 prompt = caption
564 if initial:
565 prompt += ' ("x" to clear) [%s]' % initial
maruel@chromium.org90541732011-04-01 17:54:18 +0000566 new_val = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000567 if new_val == 'x':
568 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
569 elif new_val and new_val != initial:
570 RunGit(['config', 'rietveld.' + name, new_val])
571
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000572 SetProperty(settings.GetDefaultCCList(), 'CC list', 'cc')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000573 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
574 'tree-status-url')
575 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
576
577 # TODO: configure a default branch to diff against, rather than this
578 # svn-based hackery.
579
580
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000581class ChangeDescription(object):
582 """Contains a parsed form of the change description."""
583 def __init__(self, subject, log_desc, reviewers):
584 self.subject = subject
585 self.log_desc = log_desc
586 self.reviewers = reviewers
587 self.description = self.log_desc
588
589 def Update(self):
590 initial_text = """# Enter a description of the change.
591# This will displayed on the codereview site.
592# The first line will also be used as the subject of the review.
593"""
594 initial_text += self.description
595 if 'R=' not in self.description and self.reviewers:
596 initial_text += '\nR=' + self.reviewers
597 if 'BUG=' not in self.description:
598 initial_text += '\nBUG='
599 if 'TEST=' not in self.description:
600 initial_text += '\nTEST='
601 self._ParseDescription(UserEditedLog(initial_text))
602
603 def _ParseDescription(self, description):
604 if not description:
605 self.description = description
606 return
607
608 parsed_lines = []
609 reviewers_regexp = re.compile('\s*R=(.+)')
610 reviewers = ''
611 subject = ''
612 for l in description.splitlines():
613 if not subject:
614 subject = l
615 matched_reviewers = reviewers_regexp.match(l)
616 if matched_reviewers:
617 reviewers = matched_reviewers.group(1)
618 parsed_lines.append(l)
619
620 self.description = '\n'.join(parsed_lines) + '\n'
621 self.subject = subject
622 self.reviewers = reviewers
623
624 def IsEmpty(self):
625 return not self.description
626
627
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000628def FindCodereviewSettingsFile(filename='codereview.settings'):
629 """Finds the given file starting in the cwd and going up.
630
631 Only looks up to the top of the repository unless an
632 'inherit-review-settings-ok' file exists in the root of the repository.
633 """
634 inherit_ok_file = 'inherit-review-settings-ok'
635 cwd = os.getcwd()
636 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
637 if os.path.isfile(os.path.join(root, inherit_ok_file)):
638 root = '/'
639 while True:
640 if filename in os.listdir(cwd):
641 if os.path.isfile(os.path.join(cwd, filename)):
642 return open(os.path.join(cwd, filename))
643 if cwd == root:
644 break
645 cwd = os.path.dirname(cwd)
646
647
648def LoadCodereviewSettingsFromFile(fileobj):
649 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000650 keyvals = {}
651 for line in fileobj.read().splitlines():
652 if not line or line.startswith("#"):
653 continue
654 k, v = line.split(": ", 1)
655 keyvals[k] = v
656
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000657 def SetProperty(name, setting, unset_error_ok=False):
658 fullname = 'rietveld.' + name
659 if setting in keyvals:
660 RunGit(['config', fullname, keyvals[setting]])
661 else:
662 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
663
664 SetProperty('server', 'CODE_REVIEW_SERVER')
665 # Only server setting is required. Other settings can be absent.
666 # In that case, we ignore errors raised during option deletion attempt.
667 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
668 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
669 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
670
671 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
672 #should be of the form
673 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
674 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
675 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
676 keyvals['ORIGIN_URL_CONFIG']])
677
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000678
679@usage('[repo root containing codereview.settings]')
680def CMDconfig(parser, args):
681 """edit configuration for this tree"""
682
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000683 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000684 if len(args) == 0:
685 GetCodereviewSettingsInteractively()
686 return 0
687
688 url = args[0]
689 if not url.endswith('codereview.settings'):
690 url = os.path.join(url, 'codereview.settings')
691
692 # Load code review settings and download hooks (if available).
693 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
694 return 0
695
696
697def CMDstatus(parser, args):
698 """show status of changelists"""
699 parser.add_option('--field',
700 help='print only specific field (desc|id|patch|url)')
701 (options, args) = parser.parse_args(args)
702
703 # TODO: maybe make show_branches a flag if necessary.
704 show_branches = not options.field
705
706 if show_branches:
707 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
708 if branches:
709 print 'Branches associated with reviews:'
710 for branch in sorted(branches.splitlines()):
711 cl = Changelist(branchref=branch)
712 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
713
714 cl = Changelist()
715 if options.field:
716 if options.field.startswith('desc'):
717 print cl.GetDescription()
718 elif options.field == 'id':
719 issueid = cl.GetIssue()
720 if issueid:
721 print issueid
722 elif options.field == 'patch':
723 patchset = cl.GetPatchset()
724 if patchset:
725 print patchset
726 elif options.field == 'url':
727 url = cl.GetIssueURL()
728 if url:
729 print url
730 else:
731 print
732 print 'Current branch:',
733 if not cl.GetIssue():
734 print 'no issue assigned.'
735 return 0
736 print cl.GetBranch()
737 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
738 print 'Issue description:'
739 print cl.GetDescription(pretty=True)
740 return 0
741
742
743@usage('[issue_number]')
744def CMDissue(parser, args):
745 """Set or display the current code review issue number.
746
747 Pass issue number 0 to clear the current issue.
748"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000749 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000750
751 cl = Changelist()
752 if len(args) > 0:
753 try:
754 issue = int(args[0])
755 except ValueError:
756 DieWithError('Pass a number to set the issue or none to list it.\n'
757 'Maybe you want to run git cl status?')
758 cl.SetIssue(issue)
759 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
760 return 0
761
762
763def CreateDescriptionFromLog(args):
764 """Pulls out the commit log to use as a base for the CL description."""
765 log_args = []
766 if len(args) == 1 and not args[0].endswith('.'):
767 log_args = [args[0] + '..']
768 elif len(args) == 1 and args[0].endswith('...'):
769 log_args = [args[0][:-1]]
770 elif len(args) == 2:
771 log_args = [args[0] + '..' + args[1]]
772 else:
773 log_args = args[:] # Hope for the best!
774 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
775
776
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000777def UserEditedLog(starting_text):
778 """Given some starting text, let the user edit it and return the result."""
779 editor = os.getenv('EDITOR', 'vi')
780
781 (file_handle, filename) = tempfile.mkstemp()
782 fileobj = os.fdopen(file_handle, 'w')
783 fileobj.write(starting_text)
784 fileobj.close()
785
786 # Open up the default editor in the system to get the CL description.
787 try:
788 cmd = '%s %s' % (editor, filename)
789 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
790 # Msysgit requires the usage of 'env' to be present.
791 cmd = 'env ' + cmd
792 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
793 subprocess.check_call(cmd, shell=True)
794 fileobj = open(filename)
795 text = fileobj.read()
796 fileobj.close()
797 finally:
798 os.remove(filename)
799
800 if not text:
801 return
802
803 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
804 return stripcomment_re.sub('', text).strip()
805
806
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000807def ConvertToInteger(inputval):
808 """Convert a string to integer, but returns either an int or None."""
809 try:
810 return int(inputval)
811 except (TypeError, ValueError):
812 return None
813
814
maruel@chromium.org899e1c12011-04-07 17:03:18 +0000815def RunHook(committing, upstream_branch, rietveld_server, tbr, may_prompt,
816 verbose):
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000817 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000818 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip()
819 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000820 root = '.'
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000821 absroot = os.path.abspath(root)
822 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000823 raise Exception('Could not get root directory.')
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000824
825 # We use the sha1 of HEAD as a name of this change.
826 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
827 files = scm.GIT.CaptureStatus([root], upstream_branch)
828
829 cl = Changelist()
830 issue = ConvertToInteger(cl.GetIssue())
831 patchset = ConvertToInteger(cl.GetPatchset())
832 if issue:
833 description = cl.GetDescription()
834 else:
835 # If the change was never uploaded, use the log messages of all commits
836 # up to the branch point, as git cl upload will prefill the description
837 # with these log messages.
838 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000839 '%s...' % (upstream_branch)]).strip()
maruel@chromium.org58407af2011-04-12 23:15:57 +0000840 change = presubmit_support.GitChange(
841 name,
842 description,
843 absroot,
844 files,
845 issue,
846 patchset,
847 None)
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000848
849 # Apply watchlists on upload.
850 if not committing:
851 watchlist = watchlists.Watchlists(change.RepositoryRoot())
852 files = [f.LocalPath() for f in change.AffectedFiles()]
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000853 cl.SetWatchers(watchlist.GetWatchersForPaths(files))
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000854
maruel@chromium.org899e1c12011-04-07 17:03:18 +0000855 try:
856 output = presubmit_support.DoPresubmitChecks(change, committing,
857 verbose=verbose, output_stream=sys.stdout, input_stream=sys.stdin,
858 default_presubmit=None, may_prompt=may_prompt, tbr=tbr,
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000859 rietveld=cl.RpcServer())
maruel@chromium.org899e1c12011-04-07 17:03:18 +0000860 except presubmit_support.PresubmitFailure, e:
861 DieWithError(
862 ('%s\nMaybe your depot_tools is out of date?\n'
863 'If all fails, contact maruel@') % e)
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000864
865 # TODO(dpranke): We should propagate the error out instead of calling exit().
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000866 if not output.should_continue():
867 sys.exit(1)
dpranke@chromium.org37248c02011-03-12 04:36:48 +0000868
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000869 return output
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000870
871
872def CMDpresubmit(parser, args):
873 """run presubmit tests on the current changelist"""
874 parser.add_option('--upload', action='store_true',
875 help='Run upload hook instead of the push/dcommit hook')
876 (options, args) = parser.parse_args(args)
877
878 # Make sure index is up-to-date before running diff-index.
879 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
880 if RunGit(['diff-index', 'HEAD']):
881 # TODO(maruel): Is this really necessary?
882 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
883 return 1
884
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000885 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000886 if args:
887 base_branch = args[0]
888 else:
889 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000890 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000891
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000892 RunHook(committing=not options.upload, upstream_branch=base_branch,
893 rietveld_server=cl.GetRietveldServer(), tbr=False,
maruel@chromium.org899e1c12011-04-07 17:03:18 +0000894 may_prompt=False, verbose=options.verbose)
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000895 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000896
897
898@usage('[args to "git diff"]')
899def CMDupload(parser, args):
900 """upload the current changelist to codereview"""
901 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
902 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000903 parser.add_option('-f', action='store_true', dest='force',
904 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000905 parser.add_option('-m', dest='message', help='message for patch')
906 parser.add_option('-r', '--reviewers',
907 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000908 parser.add_option('--cc',
909 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000910 parser.add_option('--send-mail', action='store_true',
911 help='send email to reviewer immediately')
912 parser.add_option("--emulate_svn_auto_props", action="store_true",
913 dest="emulate_svn_auto_props",
914 help="Emulate Subversion's auto properties feature.")
915 parser.add_option("--desc_from_logs", action="store_true",
916 dest="from_logs",
917 help="""Squashes git commit logs into change description and
918 uses message as subject""")
919 (options, args) = parser.parse_args(args)
920
921 # Make sure index is up-to-date before running diff-index.
922 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
923 if RunGit(['diff-index', 'HEAD']):
924 print 'Cannot upload with a dirty tree. You must commit locally first.'
925 return 1
926
927 cl = Changelist()
928 if args:
929 base_branch = args[0]
930 else:
931 # Default to diffing against the "upstream" branch.
932 base_branch = cl.GetUpstreamBranch()
933 args = [base_branch + "..."]
934
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000935 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000936 hook_results = RunHook(committing=False, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000937 rietveld_server=cl.GetRietveldServer(), tbr=False,
maruel@chromium.org899e1c12011-04-07 17:03:18 +0000938 may_prompt=True, verbose=options.verbose)
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000939 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000940 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000941
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000942
943 # --no-ext-diff is broken in some versions of Git, so try to work around
944 # this by overriding the environment (but there is still a problem if the
945 # git config key "diff.external" is used).
946 env = os.environ.copy()
947 if 'GIT_EXTERNAL_DIFF' in env:
948 del env['GIT_EXTERNAL_DIFF']
949 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
950 env=env)
951
952 upload_args = ['--assume_yes'] # Don't ask about untracked files.
953 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000954 if options.emulate_svn_auto_props:
955 upload_args.append('--emulate_svn_auto_props')
956 if options.send_mail:
957 if not options.reviewers:
958 DieWithError("Must specify reviewers to send email.")
959 upload_args.append('--send_mail')
960 if options.from_logs and not options.message:
961 print 'Must set message for subject line if using desc_from_logs'
962 return 1
963
964 change_desc = None
965
966 if cl.GetIssue():
967 if options.message:
968 upload_args.extend(['--message', options.message])
969 upload_args.extend(['--issue', cl.GetIssue()])
970 print ("This branch is associated with issue %s. "
971 "Adding patch to that issue." % cl.GetIssue())
972 else:
973 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000974 change_desc = ChangeDescription(options.message, log_desc,
975 options.reviewers)
976 if not options.from_logs:
977 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000978
979 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000980 print "Description is empty; aborting."
981 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000982
983 upload_args.extend(['--message', change_desc.subject])
984 upload_args.extend(['--description', change_desc.description])
985 if change_desc.reviewers:
986 upload_args.extend(['--reviewers', change_desc.reviewers])
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000987 cc = ','.join(filter(None, (cl.GetCCList(), options.cc)))
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000988 if cc:
989 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000990
991 # Include the upstream repo's URL in the change -- this is useful for
992 # projects that have their source spread across multiple repos.
993 remote_url = None
994 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000995 # URL is dependent on the current directory.
996 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000997 if data:
998 keys = dict(line.split(': ', 1) for line in data.splitlines()
999 if ': ' in line)
1000 remote_url = keys.get('URL', None)
1001 else:
1002 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
1003 remote_url = (cl.GetRemoteUrl() + '@'
1004 + cl.GetUpstreamBranch().split('/')[-1])
1005 if remote_url:
1006 upload_args.extend(['--base_url', remote_url])
1007
1008 try:
1009 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
maruel@chromium.org9ce0dff2011-04-04 17:56:50 +00001010 except KeyboardInterrupt:
1011 sys.exit(1)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001012 except:
1013 # If we got an exception after the user typed a description for their
1014 # change, back up the description before re-raising.
1015 if change_desc:
1016 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
1017 print '\nGot exception while uploading -- saving description to %s\n' \
1018 % backup_path
1019 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001020 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001021 backup_file.close()
1022 raise
1023
1024 if not cl.GetIssue():
1025 cl.SetIssue(issue)
1026 cl.SetPatchset(patchset)
1027 return 0
1028
1029
1030def SendUpstream(parser, args, cmd):
1031 """Common code for CmdPush and CmdDCommit
1032
1033 Squashed commit into a single.
1034 Updates changelog with metadata (e.g. pointer to review).
1035 Pushes/dcommits the code upstream.
1036 Updates review and closes.
1037 """
1038 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1039 help='bypass upload presubmit hook')
1040 parser.add_option('-m', dest='message',
1041 help="override review description")
1042 parser.add_option('-f', action='store_true', dest='force',
1043 help="force yes to questions (don't prompt)")
1044 parser.add_option('-c', dest='contributor',
1045 help="external contributor for patch (appended to " +
1046 "description and used as author for git). Should be " +
1047 "formatted as 'First Last <email@example.com>'")
1048 parser.add_option('--tbr', action='store_true', dest='tbr',
1049 help="short for 'to be reviewed', commit branch " +
1050 "even without uploading for review")
1051 (options, args) = parser.parse_args(args)
1052 cl = Changelist()
1053
1054 if not args or cmd == 'push':
1055 # Default to merging against our best guess of the upstream branch.
1056 args = [cl.GetUpstreamBranch()]
1057
1058 base_branch = args[0]
1059
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001060 # Make sure index is up-to-date before running diff-index.
1061 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001062 if RunGit(['diff-index', 'HEAD']):
1063 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1064 return 1
1065
1066 # This rev-list syntax means "show all commits not in my branch that
1067 # are in base_branch".
1068 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1069 base_branch]).splitlines()
1070 if upstream_commits:
1071 print ('Base branch "%s" has %d commits '
1072 'not in this branch.' % (base_branch, len(upstream_commits)))
1073 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1074 return 1
1075
1076 if cmd == 'dcommit':
1077 # This is the revision `svn dcommit` will commit on top of.
1078 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1079 '--pretty=format:%H'])
1080 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1081 if extra_commits:
1082 print ('This branch has %d additional commits not upstreamed yet.'
1083 % len(extra_commits.splitlines()))
1084 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1085 'before attempting to %s.' % (base_branch, cmd))
1086 return 1
1087
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001088 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001089 RunHook(committing=True, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001090 rietveld_server=cl.GetRietveldServer(), tbr=options.tbr,
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001091 may_prompt=True, verbose=options.verbose)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001092
1093 if cmd == 'dcommit':
1094 # Check the tree status if the tree status URL is set.
1095 status = GetTreeStatus()
1096 if 'closed' == status:
1097 print ('The tree is closed. Please wait for it to reopen. Use '
1098 '"git cl dcommit -f" to commit on a closed tree.')
1099 return 1
1100 elif 'unknown' == status:
1101 print ('Unable to determine tree status. Please verify manually and '
1102 'use "git cl dcommit -f" to commit on a closed tree.')
1103
1104 description = options.message
1105 if not options.tbr:
1106 # It is important to have these checks early. Not only for user
1107 # convenience, but also because the cl object then caches the correct values
1108 # of these fields even as we're juggling branches for setting up the commit.
1109 if not cl.GetIssue():
1110 print 'Current issue unknown -- has this branch been uploaded?'
1111 print 'Use --tbr to commit without review.'
1112 return 1
1113
1114 if not description:
1115 description = cl.GetDescription()
1116
1117 if not description:
1118 print 'No description set.'
1119 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1120 return 1
1121
1122 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1123 else:
1124 if not description:
1125 # Submitting TBR. See if there's already a description in Rietveld, else
1126 # create a template description. Eitherway, give the user a chance to edit
1127 # it to fill in the TBR= field.
1128 if cl.GetIssue():
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001129 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001130
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001131 # TODO(dpranke): Update to use ChangeDescription object.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001132 if not description:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001133 description = """# Enter a description of the change.
1134# This will be used as the change log for the commit.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001135
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001136"""
1137 description += CreateDescriptionFromLog(args)
1138
1139 description = UserEditedLog(description + '\nTBR=')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001140
1141 if not description:
1142 print "Description empty; aborting."
1143 return 1
1144
1145 if options.contributor:
1146 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1147 print "Please provide contibutor as 'First Last <email@example.com>'"
1148 return 1
1149 description += "\nPatch from %s." % options.contributor
1150 print 'Description:', repr(description)
1151
1152 branches = [base_branch, cl.GetBranchRef()]
1153 if not options.force:
1154 subprocess.call(['git', 'diff', '--stat'] + branches)
maruel@chromium.org90541732011-04-01 17:54:18 +00001155 ask_for_data('About to commit; enter to confirm.')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001156
1157 # We want to squash all this branch's commits into one commit with the
1158 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001159 # We do this by doing a "reset --soft" to the base branch (which keeps
1160 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001161 MERGE_BRANCH = 'git-cl-commit'
1162 # Delete the merge branch if it already exists.
1163 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1164 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1165 RunGit(['branch', '-D', MERGE_BRANCH])
1166
1167 # We might be in a directory that's present in this branch but not in the
1168 # trunk. Move up to the top of the tree so that git commands that expect a
1169 # valid CWD won't fail after we check out the merge branch.
1170 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1171 if rel_base_path:
1172 os.chdir(rel_base_path)
1173
1174 # Stuff our change into the merge branch.
1175 # We wrap in a try...finally block so if anything goes wrong,
1176 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001177 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001178 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001179 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1180 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001181 if options.contributor:
1182 RunGit(['commit', '--author', options.contributor, '-m', description])
1183 else:
1184 RunGit(['commit', '-m', description])
1185 if cmd == 'push':
1186 # push the merge branch.
1187 remote, branch = cl.FetchUpstreamTuple()
1188 retcode, output = RunGitWithCode(
1189 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1190 logging.debug(output)
1191 else:
1192 # dcommit the merge branch.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001193 retcode, output = RunGitWithCode(['svn', 'dcommit', '--no-rebase'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001194 finally:
1195 # And then swap back to the original branch and clean up.
1196 RunGit(['checkout', '-q', cl.GetBranch()])
1197 RunGit(['branch', '-D', MERGE_BRANCH])
1198
1199 if cl.GetIssue():
1200 if cmd == 'dcommit' and 'Committed r' in output:
1201 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1202 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001203 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1204 for l in output.splitlines(False))
1205 match = filter(None, match)
1206 if len(match) != 1:
1207 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1208 output)
1209 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001210 else:
1211 return 1
1212 viewvc_url = settings.GetViewVCUrl()
1213 if viewvc_url and revision:
1214 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1215 print ('Closing issue '
1216 '(you may be prompted for your codereview password)...')
1217 cl.CloseIssue()
1218 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001219
1220 if retcode == 0:
1221 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1222 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001223 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001224
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001225 return 0
1226
1227
1228@usage('[upstream branch to apply against]')
1229def CMDdcommit(parser, args):
1230 """commit the current changelist via git-svn"""
1231 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001232 message = """This doesn't appear to be an SVN repository.
1233If your project has a git mirror with an upstream SVN master, you probably need
1234to run 'git svn init', see your project's git mirror documentation.
1235If your project has a true writeable upstream repository, you probably want
1236to run 'git cl push' instead.
1237Choose wisely, if you get this wrong, your commit might appear to succeed but
1238will instead be silently ignored."""
1239 print(message)
maruel@chromium.org90541732011-04-01 17:54:18 +00001240 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001241 return SendUpstream(parser, args, 'dcommit')
1242
1243
1244@usage('[upstream branch to apply against]')
1245def CMDpush(parser, args):
1246 """commit the current changelist via git"""
1247 if settings.GetIsGitSvn():
1248 print('This appears to be an SVN repository.')
1249 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
maruel@chromium.org90541732011-04-01 17:54:18 +00001250 ask_for_data('[Press enter to push or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001251 return SendUpstream(parser, args, 'push')
1252
1253
1254@usage('<patch url or issue id>')
1255def CMDpatch(parser, args):
1256 """patch in a code review"""
1257 parser.add_option('-b', dest='newbranch',
1258 help='create a new branch off trunk for the patch')
1259 parser.add_option('-f', action='store_true', dest='force',
1260 help='with -b, clobber any existing branch')
1261 parser.add_option('--reject', action='store_true', dest='reject',
1262 help='allow failed patches and spew .rej files')
1263 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1264 help="don't commit after patch applies")
1265 (options, args) = parser.parse_args(args)
1266 if len(args) != 1:
1267 parser.print_help()
1268 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001269 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001270
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001271 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001272 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001273 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001274 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001275 else:
1276 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001277 issue_url = FixUrl(issue_arg)
1278 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001279 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001280 DieWithError('Must pass an issue ID or full URL for '
1281 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001282 issue = match.group(1)
1283 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001284
1285 if options.newbranch:
1286 if options.force:
1287 RunGit(['branch', '-D', options.newbranch],
1288 swallow_stderr=True, error_ok=True)
1289 RunGit(['checkout', '-b', options.newbranch,
1290 Changelist().GetUpstreamBranch()])
1291
1292 # Switch up to the top-level directory, if necessary, in preparation for
1293 # applying the patch.
1294 top = RunGit(['rev-parse', '--show-cdup']).strip()
1295 if top:
1296 os.chdir(top)
1297
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001298 # Git patches have a/ at the beginning of source paths. We strip that out
1299 # with a sed script rather than the -p flag to patch so we can feed either
1300 # Git or svn-style patches into the same apply command.
1301 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1302 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1303 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1304 patch_data = sed_proc.communicate(patch_data)[0]
1305 if sed_proc.returncode:
1306 DieWithError('Git patch mungling failed.')
1307 logging.info(patch_data)
1308 # We use "git apply" to apply the patch instead of "patch" so that we can
1309 # pick up file adds.
1310 # The --index flag means: also insert into the index (so we catch adds).
1311 cmd = ['git', 'apply', '--index', '-p0']
1312 if options.reject:
1313 cmd.append('--reject')
1314 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1315 patch_proc.communicate(patch_data)
1316 if patch_proc.returncode:
1317 DieWithError('Failed to apply the patch')
1318
1319 # If we had an issue, commit the current state and register the issue.
1320 if not options.nocommit:
1321 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1322 cl = Changelist()
1323 cl.SetIssue(issue)
1324 print "Committed patch."
1325 else:
1326 print "Patch applied to index."
1327 return 0
1328
1329
1330def CMDrebase(parser, args):
1331 """rebase current branch on top of svn repo"""
1332 # Provide a wrapper for git svn rebase to help avoid accidental
1333 # git svn dcommit.
1334 # It's the only command that doesn't use parser at all since we just defer
1335 # execution to git-svn.
1336 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1337 return 0
1338
1339
1340def GetTreeStatus():
1341 """Fetches the tree status and returns either 'open', 'closed',
1342 'unknown' or 'unset'."""
1343 url = settings.GetTreeStatusUrl(error_ok=True)
1344 if url:
1345 status = urllib2.urlopen(url).read().lower()
1346 if status.find('closed') != -1 or status == '0':
1347 return 'closed'
1348 elif status.find('open') != -1 or status == '1':
1349 return 'open'
1350 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001351 return 'unset'
1352
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001353
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001354def GetTreeStatusReason():
1355 """Fetches the tree status from a json url and returns the message
1356 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001357 url = settings.GetTreeStatusUrl()
1358 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001359 connection = urllib2.urlopen(json_url)
1360 status = json.loads(connection.read())
1361 connection.close()
1362 return status['message']
1363
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001364
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001365def CMDtree(parser, args):
1366 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001367 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001368 status = GetTreeStatus()
1369 if 'unset' == status:
1370 print 'You must configure your tree status URL by running "git cl config".'
1371 return 2
1372
1373 print "The tree is %s" % status
1374 print
1375 print GetTreeStatusReason()
1376 if status != 'open':
1377 return 1
1378 return 0
1379
1380
1381def CMDupstream(parser, args):
1382 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001383 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001384 cl = Changelist()
1385 print cl.GetUpstreamBranch()
1386 return 0
1387
1388
1389def Command(name):
1390 return getattr(sys.modules[__name__], 'CMD' + name, None)
1391
1392
1393def CMDhelp(parser, args):
1394 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001395 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001396 if len(args) == 1:
1397 return main(args + ['--help'])
1398 parser.print_help()
1399 return 0
1400
1401
1402def GenUsage(parser, command):
1403 """Modify an OptParse object with the function's documentation."""
1404 obj = Command(command)
1405 more = getattr(obj, 'usage_more', '')
1406 if command == 'help':
1407 command = '<command>'
1408 else:
1409 # OptParser.description prefer nicely non-formatted strings.
1410 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1411 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1412
1413
1414def main(argv):
1415 """Doesn't parse the arguments here, just find the right subcommand to
1416 execute."""
1417 # Do it late so all commands are listed.
1418 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1419 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1420 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1421
1422 # Create the option parse and add --verbose support.
1423 parser = optparse.OptionParser()
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001424 parser.add_option(
1425 '-v', '--verbose', action='count', default=0,
1426 help='Use 2 times for more debugging info')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001427 old_parser_args = parser.parse_args
1428 def Parse(args):
1429 options, args = old_parser_args(args)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001430 if options.verbose >= 2:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001431 logging.basicConfig(level=logging.DEBUG)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001432 elif options.verbose:
1433 logging.basicConfig(level=logging.INFO)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001434 else:
1435 logging.basicConfig(level=logging.WARNING)
1436 return options, args
1437 parser.parse_args = Parse
1438
1439 if argv:
1440 command = Command(argv[0])
1441 if command:
1442 # "fix" the usage and the description now that we know the subcommand.
1443 GenUsage(parser, argv[0])
1444 try:
1445 return command(parser, argv[1:])
1446 except urllib2.HTTPError, e:
1447 if e.code != 500:
1448 raise
1449 DieWithError(
1450 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1451 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1452
1453 # Not a known command. Default to help.
1454 GenUsage(parser, 'help')
1455 return CMDhelp(parser, argv)
1456
1457
1458if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001459 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001460 sys.exit(main(sys.argv[1:]))