blob: 5cf2580e4c785ad74346191087a2c9dee2a3980d [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
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000514 def RunHook(self, committing, upstream_branch, tbr, may_prompt, verbose):
515 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000516 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip() or '.'
517 absroot = os.path.abspath(root)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000518
519 # We use the sha1 of HEAD as a name of this change.
520 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000521 # Need to pass a relative path for msysgit.
522 files = scm.GIT.CaptureStatus([root], upstream_branch)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000523
524 issue = ConvertToInteger(self.GetIssue())
525 patchset = ConvertToInteger(self.GetPatchset())
526 if issue:
527 description = self.GetDescription()
528 else:
529 # If the change was never uploaded, use the log messages of all commits
530 # up to the branch point, as git cl upload will prefill the description
531 # with these log messages.
532 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
533 '%s...' % (upstream_branch)]).strip()
534 change = presubmit_support.GitChange(
535 name,
536 description,
537 absroot,
538 files,
539 issue,
540 patchset,
541 None)
542
543 # Apply watchlists on upload.
544 if not committing:
545 watchlist = watchlists.Watchlists(change.RepositoryRoot())
546 files = [f.LocalPath() for f in change.AffectedFiles()]
547 self.SetWatchers(watchlist.GetWatchersForPaths(files))
548
549 try:
550 output = presubmit_support.DoPresubmitChecks(change, committing,
551 verbose=verbose, output_stream=sys.stdout, input_stream=sys.stdin,
552 default_presubmit=None, may_prompt=may_prompt, tbr=tbr,
553 rietveld=self.RpcServer())
554 except presubmit_support.PresubmitFailure, e:
555 DieWithError(
556 ('%s\nMaybe your depot_tools is out of date?\n'
557 'If all fails, contact maruel@') % e)
558
559 # TODO(dpranke): We should propagate the error out instead of calling
560 # exit().
561 if not output.should_continue():
562 sys.exit(1)
563
564 return output
565
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000566 def CloseIssue(self):
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000567 rpc_server = self.RpcServer()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000568 # Newer versions of Rietveld require us to pass an XSRF token to POST, so
569 # we fetch it from the server. (The version used by Chromium has been
570 # modified so the token isn't required when closing an issue.)
571 xsrf_token = rpc_server.Send('/xsrf_token',
572 extra_headers={'X-Requesting-XSRF-Token': '1'})
573
574 # You cannot close an issue with a GET.
575 # We pass an empty string for the data so it is a POST rather than a GET.
576 data = [("description", self.description),
577 ("xsrf_token", xsrf_token)]
578 ctype, body = upload.EncodeMultipartFormData(data, [])
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000579 rpc_server.Send(
580 '/' + self.GetIssue() + '/close', payload=body, content_type=ctype)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000581
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000582 def RpcServer(self):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000583 """Returns an upload.RpcServer() to access this review's rietveld instance.
584 """
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000585 if not self._rpc_server:
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000586 self.GetIssue()
587 self._rpc_server = rietveld.Rietveld(self.rietveld_server, None, None)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000588 return self._rpc_server
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000589
590 def _IssueSetting(self):
591 """Return the git setting that stores this change's issue."""
592 return 'branch.%s.rietveldissue' % self.GetBranch()
593
594 def _PatchsetSetting(self):
595 """Return the git setting that stores this change's most recent patchset."""
596 return 'branch.%s.rietveldpatchset' % self.GetBranch()
597
598 def _RietveldServer(self):
599 """Returns the git setting that stores this change's rietveld server."""
600 return 'branch.%s.rietveldserver' % self.GetBranch()
601
602
603def GetCodereviewSettingsInteractively():
604 """Prompt the user for settings."""
605 server = settings.GetDefaultServerUrl(error_ok=True)
606 prompt = 'Rietveld server (host[:port])'
607 prompt += ' [%s]' % (server or DEFAULT_SERVER)
maruel@chromium.org90541732011-04-01 17:54:18 +0000608 newserver = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000609 if not server and not newserver:
610 newserver = DEFAULT_SERVER
611 if newserver and newserver != server:
612 RunGit(['config', 'rietveld.server', newserver])
613
614 def SetProperty(initial, caption, name):
615 prompt = caption
616 if initial:
617 prompt += ' ("x" to clear) [%s]' % initial
maruel@chromium.org90541732011-04-01 17:54:18 +0000618 new_val = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000619 if new_val == 'x':
620 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
621 elif new_val and new_val != initial:
622 RunGit(['config', 'rietveld.' + name, new_val])
623
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000624 SetProperty(settings.GetDefaultCCList(), 'CC list', 'cc')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000625 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
626 'tree-status-url')
627 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
628
629 # TODO: configure a default branch to diff against, rather than this
630 # svn-based hackery.
631
632
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000633class ChangeDescription(object):
634 """Contains a parsed form of the change description."""
635 def __init__(self, subject, log_desc, reviewers):
636 self.subject = subject
637 self.log_desc = log_desc
638 self.reviewers = reviewers
639 self.description = self.log_desc
640
641 def Update(self):
642 initial_text = """# Enter a description of the change.
643# This will displayed on the codereview site.
644# The first line will also be used as the subject of the review.
645"""
646 initial_text += self.description
647 if 'R=' not in self.description and self.reviewers:
648 initial_text += '\nR=' + self.reviewers
649 if 'BUG=' not in self.description:
650 initial_text += '\nBUG='
651 if 'TEST=' not in self.description:
652 initial_text += '\nTEST='
653 self._ParseDescription(UserEditedLog(initial_text))
654
655 def _ParseDescription(self, description):
656 if not description:
657 self.description = description
658 return
659
660 parsed_lines = []
661 reviewers_regexp = re.compile('\s*R=(.+)')
662 reviewers = ''
663 subject = ''
664 for l in description.splitlines():
665 if not subject:
666 subject = l
667 matched_reviewers = reviewers_regexp.match(l)
668 if matched_reviewers:
669 reviewers = matched_reviewers.group(1)
670 parsed_lines.append(l)
671
672 self.description = '\n'.join(parsed_lines) + '\n'
673 self.subject = subject
674 self.reviewers = reviewers
675
676 def IsEmpty(self):
677 return not self.description
678
679
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000680def FindCodereviewSettingsFile(filename='codereview.settings'):
681 """Finds the given file starting in the cwd and going up.
682
683 Only looks up to the top of the repository unless an
684 'inherit-review-settings-ok' file exists in the root of the repository.
685 """
686 inherit_ok_file = 'inherit-review-settings-ok'
687 cwd = os.getcwd()
688 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
689 if os.path.isfile(os.path.join(root, inherit_ok_file)):
690 root = '/'
691 while True:
692 if filename in os.listdir(cwd):
693 if os.path.isfile(os.path.join(cwd, filename)):
694 return open(os.path.join(cwd, filename))
695 if cwd == root:
696 break
697 cwd = os.path.dirname(cwd)
698
699
700def LoadCodereviewSettingsFromFile(fileobj):
701 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000702 keyvals = {}
703 for line in fileobj.read().splitlines():
704 if not line or line.startswith("#"):
705 continue
706 k, v = line.split(": ", 1)
707 keyvals[k] = v
708
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000709 def SetProperty(name, setting, unset_error_ok=False):
710 fullname = 'rietveld.' + name
711 if setting in keyvals:
712 RunGit(['config', fullname, keyvals[setting]])
713 else:
714 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
715
716 SetProperty('server', 'CODE_REVIEW_SERVER')
717 # Only server setting is required. Other settings can be absent.
718 # In that case, we ignore errors raised during option deletion attempt.
719 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
720 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
721 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
722
723 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
724 #should be of the form
725 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
726 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
727 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
728 keyvals['ORIGIN_URL_CONFIG']])
729
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000730
731@usage('[repo root containing codereview.settings]')
732def CMDconfig(parser, args):
733 """edit configuration for this tree"""
734
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000735 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000736 if len(args) == 0:
737 GetCodereviewSettingsInteractively()
738 return 0
739
740 url = args[0]
741 if not url.endswith('codereview.settings'):
742 url = os.path.join(url, 'codereview.settings')
743
744 # Load code review settings and download hooks (if available).
745 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
746 return 0
747
748
749def CMDstatus(parser, args):
750 """show status of changelists"""
751 parser.add_option('--field',
752 help='print only specific field (desc|id|patch|url)')
753 (options, args) = parser.parse_args(args)
754
755 # TODO: maybe make show_branches a flag if necessary.
756 show_branches = not options.field
757
758 if show_branches:
759 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
760 if branches:
761 print 'Branches associated with reviews:'
762 for branch in sorted(branches.splitlines()):
763 cl = Changelist(branchref=branch)
764 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
765
766 cl = Changelist()
767 if options.field:
768 if options.field.startswith('desc'):
769 print cl.GetDescription()
770 elif options.field == 'id':
771 issueid = cl.GetIssue()
772 if issueid:
773 print issueid
774 elif options.field == 'patch':
775 patchset = cl.GetPatchset()
776 if patchset:
777 print patchset
778 elif options.field == 'url':
779 url = cl.GetIssueURL()
780 if url:
781 print url
782 else:
783 print
784 print 'Current branch:',
785 if not cl.GetIssue():
786 print 'no issue assigned.'
787 return 0
788 print cl.GetBranch()
789 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
790 print 'Issue description:'
791 print cl.GetDescription(pretty=True)
792 return 0
793
794
795@usage('[issue_number]')
796def CMDissue(parser, args):
797 """Set or display the current code review issue number.
798
799 Pass issue number 0 to clear the current issue.
800"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000801 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000802
803 cl = Changelist()
804 if len(args) > 0:
805 try:
806 issue = int(args[0])
807 except ValueError:
808 DieWithError('Pass a number to set the issue or none to list it.\n'
809 'Maybe you want to run git cl status?')
810 cl.SetIssue(issue)
811 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
812 return 0
813
814
815def CreateDescriptionFromLog(args):
816 """Pulls out the commit log to use as a base for the CL description."""
817 log_args = []
818 if len(args) == 1 and not args[0].endswith('.'):
819 log_args = [args[0] + '..']
820 elif len(args) == 1 and args[0].endswith('...'):
821 log_args = [args[0][:-1]]
822 elif len(args) == 2:
823 log_args = [args[0] + '..' + args[1]]
824 else:
825 log_args = args[:] # Hope for the best!
826 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
827
828
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000829def UserEditedLog(starting_text):
830 """Given some starting text, let the user edit it and return the result."""
831 editor = os.getenv('EDITOR', 'vi')
832
833 (file_handle, filename) = tempfile.mkstemp()
834 fileobj = os.fdopen(file_handle, 'w')
835 fileobj.write(starting_text)
836 fileobj.close()
837
838 # Open up the default editor in the system to get the CL description.
839 try:
840 cmd = '%s %s' % (editor, filename)
841 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
842 # Msysgit requires the usage of 'env' to be present.
843 cmd = 'env ' + cmd
844 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
maruel@chromium.org2a471072011-05-10 17:29:23 +0000845 try:
846 subprocess.check_call(cmd, shell=True)
847 except subprocess.CalledProcessError, e:
848 DieWithError('Editor returned %d' % e.returncode)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000849 fileobj = open(filename)
850 text = fileobj.read()
851 fileobj.close()
852 finally:
853 os.remove(filename)
854
855 if not text:
856 return
857
858 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
859 return stripcomment_re.sub('', text).strip()
860
861
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000862def ConvertToInteger(inputval):
863 """Convert a string to integer, but returns either an int or None."""
864 try:
865 return int(inputval)
866 except (TypeError, ValueError):
867 return None
868
869
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000870def CMDpresubmit(parser, args):
871 """run presubmit tests on the current changelist"""
872 parser.add_option('--upload', action='store_true',
873 help='Run upload hook instead of the push/dcommit hook')
874 (options, args) = parser.parse_args(args)
875
876 # Make sure index is up-to-date before running diff-index.
877 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
878 if RunGit(['diff-index', 'HEAD']):
879 # TODO(maruel): Is this really necessary?
880 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
881 return 1
882
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000883 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000884 if args:
885 base_branch = args[0]
886 else:
887 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000888 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000889
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000890 cl.RunHook(committing=not options.upload, upstream_branch=base_branch,
891 tbr=False, may_prompt=False, verbose=options.verbose)
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000892 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000893
894
895@usage('[args to "git diff"]')
896def CMDupload(parser, args):
897 """upload the current changelist to codereview"""
898 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
899 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000900 parser.add_option('-f', action='store_true', dest='force',
901 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000902 parser.add_option('-m', dest='message', help='message for patch')
903 parser.add_option('-r', '--reviewers',
904 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000905 parser.add_option('--cc',
906 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000907 parser.add_option('--send-mail', action='store_true',
908 help='send email to reviewer immediately')
909 parser.add_option("--emulate_svn_auto_props", action="store_true",
910 dest="emulate_svn_auto_props",
911 help="Emulate Subversion's auto properties feature.")
912 parser.add_option("--desc_from_logs", action="store_true",
913 dest="from_logs",
914 help="""Squashes git commit logs into change description and
915 uses message as subject""")
916 (options, args) = parser.parse_args(args)
917
918 # Make sure index is up-to-date before running diff-index.
919 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
920 if RunGit(['diff-index', 'HEAD']):
921 print 'Cannot upload with a dirty tree. You must commit locally first.'
922 return 1
923
924 cl = Changelist()
925 if args:
926 base_branch = args[0]
927 else:
928 # Default to diffing against the "upstream" branch.
929 base_branch = cl.GetUpstreamBranch()
930 args = [base_branch + "..."]
931
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000932 if not options.bypass_hooks and not options.force:
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000933 hook_results = cl.RunHook(committing=False, upstream_branch=base_branch,
934 tbr=False, may_prompt=True,
935 verbose=options.verbose)
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000936 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000937 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000938
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000939
940 # --no-ext-diff is broken in some versions of Git, so try to work around
941 # this by overriding the environment (but there is still a problem if the
942 # git config key "diff.external" is used).
943 env = os.environ.copy()
944 if 'GIT_EXTERNAL_DIFF' in env:
945 del env['GIT_EXTERNAL_DIFF']
946 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
947 env=env)
948
949 upload_args = ['--assume_yes'] # Don't ask about untracked files.
950 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000951 if options.emulate_svn_auto_props:
952 upload_args.append('--emulate_svn_auto_props')
953 if options.send_mail:
954 if not options.reviewers:
955 DieWithError("Must specify reviewers to send email.")
956 upload_args.append('--send_mail')
957 if options.from_logs and not options.message:
958 print 'Must set message for subject line if using desc_from_logs'
959 return 1
960
961 change_desc = None
962
963 if cl.GetIssue():
964 if options.message:
965 upload_args.extend(['--message', options.message])
966 upload_args.extend(['--issue', cl.GetIssue()])
967 print ("This branch is associated with issue %s. "
968 "Adding patch to that issue." % cl.GetIssue())
969 else:
970 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000971 change_desc = ChangeDescription(options.message, log_desc,
972 options.reviewers)
973 if not options.from_logs:
974 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000975
976 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000977 print "Description is empty; aborting."
978 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000979
980 upload_args.extend(['--message', change_desc.subject])
981 upload_args.extend(['--description', change_desc.description])
982 if change_desc.reviewers:
983 upload_args.extend(['--reviewers', change_desc.reviewers])
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000984 cc = ','.join(filter(None, (cl.GetCCList(), options.cc)))
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000985 if cc:
986 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000987
988 # Include the upstream repo's URL in the change -- this is useful for
989 # projects that have their source spread across multiple repos.
990 remote_url = None
991 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000992 # URL is dependent on the current directory.
993 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000994 if data:
995 keys = dict(line.split(': ', 1) for line in data.splitlines()
996 if ': ' in line)
997 remote_url = keys.get('URL', None)
998 else:
999 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
1000 remote_url = (cl.GetRemoteUrl() + '@'
1001 + cl.GetUpstreamBranch().split('/')[-1])
1002 if remote_url:
1003 upload_args.extend(['--base_url', remote_url])
1004
1005 try:
1006 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
maruel@chromium.org9ce0dff2011-04-04 17:56:50 +00001007 except KeyboardInterrupt:
1008 sys.exit(1)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001009 except:
1010 # If we got an exception after the user typed a description for their
1011 # change, back up the description before re-raising.
1012 if change_desc:
1013 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
1014 print '\nGot exception while uploading -- saving description to %s\n' \
1015 % backup_path
1016 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001017 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001018 backup_file.close()
1019 raise
1020
1021 if not cl.GetIssue():
1022 cl.SetIssue(issue)
1023 cl.SetPatchset(patchset)
1024 return 0
1025
1026
1027def SendUpstream(parser, args, cmd):
1028 """Common code for CmdPush and CmdDCommit
1029
1030 Squashed commit into a single.
1031 Updates changelog with metadata (e.g. pointer to review).
1032 Pushes/dcommits the code upstream.
1033 Updates review and closes.
1034 """
1035 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1036 help='bypass upload presubmit hook')
1037 parser.add_option('-m', dest='message',
1038 help="override review description")
1039 parser.add_option('-f', action='store_true', dest='force',
1040 help="force yes to questions (don't prompt)")
1041 parser.add_option('-c', dest='contributor',
1042 help="external contributor for patch (appended to " +
1043 "description and used as author for git). Should be " +
1044 "formatted as 'First Last <email@example.com>'")
1045 parser.add_option('--tbr', action='store_true', dest='tbr',
1046 help="short for 'to be reviewed', commit branch " +
1047 "even without uploading for review")
1048 (options, args) = parser.parse_args(args)
1049 cl = Changelist()
1050
1051 if not args or cmd == 'push':
1052 # Default to merging against our best guess of the upstream branch.
1053 args = [cl.GetUpstreamBranch()]
1054
1055 base_branch = args[0]
1056
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001057 # Make sure index is up-to-date before running diff-index.
1058 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001059 if RunGit(['diff-index', 'HEAD']):
1060 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1061 return 1
1062
1063 # This rev-list syntax means "show all commits not in my branch that
1064 # are in base_branch".
1065 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1066 base_branch]).splitlines()
1067 if upstream_commits:
1068 print ('Base branch "%s" has %d commits '
1069 'not in this branch.' % (base_branch, len(upstream_commits)))
1070 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1071 return 1
1072
1073 if cmd == 'dcommit':
1074 # This is the revision `svn dcommit` will commit on top of.
1075 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1076 '--pretty=format:%H'])
1077 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1078 if extra_commits:
1079 print ('This branch has %d additional commits not upstreamed yet.'
1080 % len(extra_commits.splitlines()))
1081 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1082 'before attempting to %s.' % (base_branch, cmd))
1083 return 1
1084
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001085 if not options.bypass_hooks and not options.force:
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +00001086 cl.RunHook(committing=True, upstream_branch=base_branch,
1087 tbr=options.tbr, may_prompt=True, verbose=options.verbose)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001088
1089 if cmd == 'dcommit':
1090 # Check the tree status if the tree status URL is set.
1091 status = GetTreeStatus()
1092 if 'closed' == status:
1093 print ('The tree is closed. Please wait for it to reopen. Use '
1094 '"git cl dcommit -f" to commit on a closed tree.')
1095 return 1
1096 elif 'unknown' == status:
1097 print ('Unable to determine tree status. Please verify manually and '
1098 'use "git cl dcommit -f" to commit on a closed tree.')
1099
1100 description = options.message
1101 if not options.tbr:
1102 # It is important to have these checks early. Not only for user
1103 # convenience, but also because the cl object then caches the correct values
1104 # of these fields even as we're juggling branches for setting up the commit.
1105 if not cl.GetIssue():
1106 print 'Current issue unknown -- has this branch been uploaded?'
1107 print 'Use --tbr to commit without review.'
1108 return 1
1109
1110 if not description:
1111 description = cl.GetDescription()
1112
1113 if not description:
1114 print 'No description set.'
1115 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1116 return 1
1117
1118 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1119 else:
1120 if not description:
1121 # Submitting TBR. See if there's already a description in Rietveld, else
1122 # create a template description. Eitherway, give the user a chance to edit
1123 # it to fill in the TBR= field.
1124 if cl.GetIssue():
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001125 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001126
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001127 # TODO(dpranke): Update to use ChangeDescription object.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001128 if not description:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001129 description = """# Enter a description of the change.
1130# This will be used as the change log for the commit.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001131
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001132"""
1133 description += CreateDescriptionFromLog(args)
1134
1135 description = UserEditedLog(description + '\nTBR=')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001136
1137 if not description:
1138 print "Description empty; aborting."
1139 return 1
1140
1141 if options.contributor:
1142 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1143 print "Please provide contibutor as 'First Last <email@example.com>'"
1144 return 1
1145 description += "\nPatch from %s." % options.contributor
1146 print 'Description:', repr(description)
1147
1148 branches = [base_branch, cl.GetBranchRef()]
1149 if not options.force:
1150 subprocess.call(['git', 'diff', '--stat'] + branches)
maruel@chromium.org90541732011-04-01 17:54:18 +00001151 ask_for_data('About to commit; enter to confirm.')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001152
1153 # We want to squash all this branch's commits into one commit with the
1154 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001155 # We do this by doing a "reset --soft" to the base branch (which keeps
1156 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001157 MERGE_BRANCH = 'git-cl-commit'
1158 # Delete the merge branch if it already exists.
1159 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1160 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1161 RunGit(['branch', '-D', MERGE_BRANCH])
1162
1163 # We might be in a directory that's present in this branch but not in the
1164 # trunk. Move up to the top of the tree so that git commands that expect a
1165 # valid CWD won't fail after we check out the merge branch.
1166 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1167 if rel_base_path:
1168 os.chdir(rel_base_path)
1169
1170 # Stuff our change into the merge branch.
1171 # We wrap in a try...finally block so if anything goes wrong,
1172 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001173 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001174 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001175 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1176 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001177 if options.contributor:
1178 RunGit(['commit', '--author', options.contributor, '-m', description])
1179 else:
1180 RunGit(['commit', '-m', description])
1181 if cmd == 'push':
1182 # push the merge branch.
1183 remote, branch = cl.FetchUpstreamTuple()
1184 retcode, output = RunGitWithCode(
1185 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1186 logging.debug(output)
1187 else:
1188 # dcommit the merge branch.
bauerb@chromium.org2e64fa12011-05-05 11:13:44 +00001189 retcode, output = RunGitWithCode(['svn', 'dcommit',
1190 '--no-rebase', '--rmdir'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001191 finally:
1192 # And then swap back to the original branch and clean up.
1193 RunGit(['checkout', '-q', cl.GetBranch()])
1194 RunGit(['branch', '-D', MERGE_BRANCH])
1195
1196 if cl.GetIssue():
1197 if cmd == 'dcommit' and 'Committed r' in output:
1198 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1199 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001200 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1201 for l in output.splitlines(False))
1202 match = filter(None, match)
1203 if len(match) != 1:
1204 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1205 output)
1206 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001207 else:
1208 return 1
1209 viewvc_url = settings.GetViewVCUrl()
1210 if viewvc_url and revision:
1211 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1212 print ('Closing issue '
1213 '(you may be prompted for your codereview password)...')
1214 cl.CloseIssue()
1215 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001216
1217 if retcode == 0:
1218 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1219 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001220 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001221
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001222 return 0
1223
1224
1225@usage('[upstream branch to apply against]')
1226def CMDdcommit(parser, args):
1227 """commit the current changelist via git-svn"""
1228 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001229 message = """This doesn't appear to be an SVN repository.
1230If your project has a git mirror with an upstream SVN master, you probably need
1231to run 'git svn init', see your project's git mirror documentation.
1232If your project has a true writeable upstream repository, you probably want
1233to run 'git cl push' instead.
1234Choose wisely, if you get this wrong, your commit might appear to succeed but
1235will instead be silently ignored."""
1236 print(message)
maruel@chromium.org90541732011-04-01 17:54:18 +00001237 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001238 return SendUpstream(parser, args, 'dcommit')
1239
1240
1241@usage('[upstream branch to apply against]')
1242def CMDpush(parser, args):
1243 """commit the current changelist via git"""
1244 if settings.GetIsGitSvn():
1245 print('This appears to be an SVN repository.')
1246 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
maruel@chromium.org90541732011-04-01 17:54:18 +00001247 ask_for_data('[Press enter to push or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001248 return SendUpstream(parser, args, 'push')
1249
1250
1251@usage('<patch url or issue id>')
1252def CMDpatch(parser, args):
1253 """patch in a code review"""
1254 parser.add_option('-b', dest='newbranch',
1255 help='create a new branch off trunk for the patch')
1256 parser.add_option('-f', action='store_true', dest='force',
1257 help='with -b, clobber any existing branch')
1258 parser.add_option('--reject', action='store_true', dest='reject',
1259 help='allow failed patches and spew .rej files')
1260 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1261 help="don't commit after patch applies")
1262 (options, args) = parser.parse_args(args)
1263 if len(args) != 1:
1264 parser.print_help()
1265 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001266 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001267
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001268 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001269 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001270 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001271 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001272 else:
1273 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001274 issue_url = FixUrl(issue_arg)
1275 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001276 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001277 DieWithError('Must pass an issue ID or full URL for '
1278 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001279 issue = match.group(1)
1280 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001281
1282 if options.newbranch:
1283 if options.force:
1284 RunGit(['branch', '-D', options.newbranch],
1285 swallow_stderr=True, error_ok=True)
1286 RunGit(['checkout', '-b', options.newbranch,
1287 Changelist().GetUpstreamBranch()])
1288
1289 # Switch up to the top-level directory, if necessary, in preparation for
1290 # applying the patch.
1291 top = RunGit(['rev-parse', '--show-cdup']).strip()
1292 if top:
1293 os.chdir(top)
1294
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001295 # Git patches have a/ at the beginning of source paths. We strip that out
1296 # with a sed script rather than the -p flag to patch so we can feed either
1297 # Git or svn-style patches into the same apply command.
1298 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1299 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1300 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1301 patch_data = sed_proc.communicate(patch_data)[0]
1302 if sed_proc.returncode:
1303 DieWithError('Git patch mungling failed.')
1304 logging.info(patch_data)
1305 # We use "git apply" to apply the patch instead of "patch" so that we can
1306 # pick up file adds.
1307 # The --index flag means: also insert into the index (so we catch adds).
1308 cmd = ['git', 'apply', '--index', '-p0']
1309 if options.reject:
1310 cmd.append('--reject')
1311 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1312 patch_proc.communicate(patch_data)
1313 if patch_proc.returncode:
1314 DieWithError('Failed to apply the patch')
1315
1316 # If we had an issue, commit the current state and register the issue.
1317 if not options.nocommit:
1318 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1319 cl = Changelist()
1320 cl.SetIssue(issue)
1321 print "Committed patch."
1322 else:
1323 print "Patch applied to index."
1324 return 0
1325
1326
1327def CMDrebase(parser, args):
1328 """rebase current branch on top of svn repo"""
1329 # Provide a wrapper for git svn rebase to help avoid accidental
1330 # git svn dcommit.
1331 # It's the only command that doesn't use parser at all since we just defer
1332 # execution to git-svn.
1333 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1334 return 0
1335
1336
1337def GetTreeStatus():
1338 """Fetches the tree status and returns either 'open', 'closed',
1339 'unknown' or 'unset'."""
1340 url = settings.GetTreeStatusUrl(error_ok=True)
1341 if url:
1342 status = urllib2.urlopen(url).read().lower()
1343 if status.find('closed') != -1 or status == '0':
1344 return 'closed'
1345 elif status.find('open') != -1 or status == '1':
1346 return 'open'
1347 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001348 return 'unset'
1349
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001350
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001351def GetTreeStatusReason():
1352 """Fetches the tree status from a json url and returns the message
1353 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001354 url = settings.GetTreeStatusUrl()
1355 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001356 connection = urllib2.urlopen(json_url)
1357 status = json.loads(connection.read())
1358 connection.close()
1359 return status['message']
1360
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001361
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001362def CMDtree(parser, args):
1363 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001364 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001365 status = GetTreeStatus()
1366 if 'unset' == status:
1367 print 'You must configure your tree status URL by running "git cl config".'
1368 return 2
1369
1370 print "The tree is %s" % status
1371 print
1372 print GetTreeStatusReason()
1373 if status != 'open':
1374 return 1
1375 return 0
1376
1377
1378def CMDupstream(parser, args):
1379 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001380 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001381 cl = Changelist()
1382 print cl.GetUpstreamBranch()
1383 return 0
1384
1385
1386def Command(name):
1387 return getattr(sys.modules[__name__], 'CMD' + name, None)
1388
1389
1390def CMDhelp(parser, args):
1391 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001392 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001393 if len(args) == 1:
1394 return main(args + ['--help'])
1395 parser.print_help()
1396 return 0
1397
1398
1399def GenUsage(parser, command):
1400 """Modify an OptParse object with the function's documentation."""
1401 obj = Command(command)
1402 more = getattr(obj, 'usage_more', '')
1403 if command == 'help':
1404 command = '<command>'
1405 else:
1406 # OptParser.description prefer nicely non-formatted strings.
1407 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1408 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1409
1410
1411def main(argv):
1412 """Doesn't parse the arguments here, just find the right subcommand to
1413 execute."""
1414 # Do it late so all commands are listed.
1415 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1416 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1417 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1418
1419 # Create the option parse and add --verbose support.
1420 parser = optparse.OptionParser()
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001421 parser.add_option(
1422 '-v', '--verbose', action='count', default=0,
1423 help='Use 2 times for more debugging info')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001424 old_parser_args = parser.parse_args
1425 def Parse(args):
1426 options, args = old_parser_args(args)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001427 if options.verbose >= 2:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001428 logging.basicConfig(level=logging.DEBUG)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001429 elif options.verbose:
1430 logging.basicConfig(level=logging.INFO)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001431 else:
1432 logging.basicConfig(level=logging.WARNING)
1433 return options, args
1434 parser.parse_args = Parse
1435
1436 if argv:
1437 command = Command(argv[0])
1438 if command:
1439 # "fix" the usage and the description now that we know the subcommand.
1440 GenUsage(parser, argv[0])
1441 try:
1442 return command(parser, argv[1:])
1443 except urllib2.HTTPError, e:
1444 if e.code != 500:
1445 raise
1446 DieWithError(
1447 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1448 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1449
1450 # Not a known command. Default to help.
1451 GenUsage(parser, 'help')
1452 return CMDhelp(parser, argv)
1453
1454
1455if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001456 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001457 sys.exit(main(sys.argv[1:]))