blob: 8aed4b998dbbf4462008d0f6c86b22ae79406326 [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():
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000466 self.description = self.RpcServer().get_description(
467 int(self.GetIssue())).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000468 self.has_description = True
469 if pretty:
470 wrapper = textwrap.TextWrapper()
471 wrapper.initial_indent = wrapper.subsequent_indent = ' '
472 return wrapper.fill(self.description)
473 return self.description
474
475 def GetPatchset(self):
476 if not self.has_patchset:
477 patchset = RunGit(['config', self._PatchsetSetting()],
478 error_ok=True).strip()
479 if patchset:
480 self.patchset = patchset
481 else:
482 self.patchset = None
483 self.has_patchset = True
484 return self.patchset
485
486 def SetPatchset(self, patchset):
487 """Set this branch's patchset. If patchset=0, clears the patchset."""
488 if patchset:
489 RunGit(['config', self._PatchsetSetting(), str(patchset)])
490 else:
491 RunGit(['config', '--unset', self._PatchsetSetting()],
492 swallow_stderr=True, error_ok=True)
493 self.has_patchset = False
494
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000495 def GetPatchSetDiff(self, issue):
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000496 patchset = self.RpcServer().get_issue_properties(
497 int(issue), False)['patchsets'][-1]
498 return self.RpcServer().get(
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000499 '/download/issue%s_%s.diff' % (issue, patchset))
500
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000501 def SetIssue(self, issue):
502 """Set this branch's issue. If issue=0, clears the issue."""
503 if issue:
504 RunGit(['config', self._IssueSetting(), str(issue)])
505 if self.rietveld_server:
506 RunGit(['config', self._RietveldServer(), self.rietveld_server])
507 else:
508 RunGit(['config', '--unset', self._IssueSetting()])
509 self.SetPatchset(0)
510 self.has_issue = False
511
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000512 def RunHook(self, committing, upstream_branch, tbr, may_prompt, verbose):
513 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000514 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip() or '.'
515 absroot = os.path.abspath(root)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000516
517 # We use the sha1 of HEAD as a name of this change.
518 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000519 # Need to pass a relative path for msysgit.
520 files = scm.GIT.CaptureStatus([root], upstream_branch)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000521
522 issue = ConvertToInteger(self.GetIssue())
523 patchset = ConvertToInteger(self.GetPatchset())
524 if issue:
525 description = self.GetDescription()
526 else:
527 # If the change was never uploaded, use the log messages of all commits
528 # up to the branch point, as git cl upload will prefill the description
529 # with these log messages.
530 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
531 '%s...' % (upstream_branch)]).strip()
532 change = presubmit_support.GitChange(
533 name,
534 description,
535 absroot,
536 files,
537 issue,
538 patchset,
539 None)
540
541 # Apply watchlists on upload.
542 if not committing:
543 watchlist = watchlists.Watchlists(change.RepositoryRoot())
544 files = [f.LocalPath() for f in change.AffectedFiles()]
545 self.SetWatchers(watchlist.GetWatchersForPaths(files))
546
547 try:
548 output = presubmit_support.DoPresubmitChecks(change, committing,
549 verbose=verbose, output_stream=sys.stdout, input_stream=sys.stdin,
550 default_presubmit=None, may_prompt=may_prompt, tbr=tbr,
551 rietveld=self.RpcServer())
552 except presubmit_support.PresubmitFailure, e:
553 DieWithError(
554 ('%s\nMaybe your depot_tools is out of date?\n'
555 'If all fails, contact maruel@') % e)
556
557 # TODO(dpranke): We should propagate the error out instead of calling
558 # exit().
559 if not output.should_continue():
560 sys.exit(1)
561
562 return output
563
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000564 def CloseIssue(self):
maruel@chromium.org607bb1b2011-06-01 23:43:11 +0000565 """Updates the description and closes the issue."""
566 issue = int(self.GetIssue())
567 self.RpcServer().update_description(issue, self.description)
568 return self.RpcServer().close_issue(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000569
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000570 def SetFlag(self, flag, value):
571 """Patchset must match."""
572 if not self.GetPatchset():
573 DieWithError('The patchset needs to match. Send another patchset.')
574 try:
575 return self.RpcServer().set_flag(
576 int(self.GetIssue()), int(self.GetPatchset()), flag, value)
577 except urllib2.HTTPError, e:
578 if e.code == 404:
579 DieWithError('The issue %s doesn\'t exist.' % self.GetIssue())
580 if e.code == 403:
581 DieWithError(
582 ('Access denied to issue %s. Maybe the patchset %s doesn\'t '
583 'match?') % (self.GetIssue(), self.GetPatchset()))
584 raise
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000585
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000586 def RpcServer(self):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000587 """Returns an upload.RpcServer() to access this review's rietveld instance.
588 """
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000589 if not self._rpc_server:
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000590 self.GetIssue()
591 self._rpc_server = rietveld.Rietveld(self.rietveld_server, None, None)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000592 return self._rpc_server
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000593
594 def _IssueSetting(self):
595 """Return the git setting that stores this change's issue."""
596 return 'branch.%s.rietveldissue' % self.GetBranch()
597
598 def _PatchsetSetting(self):
599 """Return the git setting that stores this change's most recent patchset."""
600 return 'branch.%s.rietveldpatchset' % self.GetBranch()
601
602 def _RietveldServer(self):
603 """Returns the git setting that stores this change's rietveld server."""
604 return 'branch.%s.rietveldserver' % self.GetBranch()
605
606
607def GetCodereviewSettingsInteractively():
608 """Prompt the user for settings."""
609 server = settings.GetDefaultServerUrl(error_ok=True)
610 prompt = 'Rietveld server (host[:port])'
611 prompt += ' [%s]' % (server or DEFAULT_SERVER)
maruel@chromium.org90541732011-04-01 17:54:18 +0000612 newserver = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000613 if not server and not newserver:
614 newserver = DEFAULT_SERVER
615 if newserver and newserver != server:
616 RunGit(['config', 'rietveld.server', newserver])
617
618 def SetProperty(initial, caption, name):
619 prompt = caption
620 if initial:
621 prompt += ' ("x" to clear) [%s]' % initial
maruel@chromium.org90541732011-04-01 17:54:18 +0000622 new_val = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000623 if new_val == 'x':
624 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
625 elif new_val and new_val != initial:
626 RunGit(['config', 'rietveld.' + name, new_val])
627
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000628 SetProperty(settings.GetDefaultCCList(), 'CC list', 'cc')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000629 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
630 'tree-status-url')
631 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
632
633 # TODO: configure a default branch to diff against, rather than this
634 # svn-based hackery.
635
636
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000637class ChangeDescription(object):
638 """Contains a parsed form of the change description."""
639 def __init__(self, subject, log_desc, reviewers):
640 self.subject = subject
641 self.log_desc = log_desc
642 self.reviewers = reviewers
643 self.description = self.log_desc
644
645 def Update(self):
646 initial_text = """# Enter a description of the change.
647# This will displayed on the codereview site.
648# The first line will also be used as the subject of the review.
649"""
650 initial_text += self.description
651 if 'R=' not in self.description and self.reviewers:
652 initial_text += '\nR=' + self.reviewers
653 if 'BUG=' not in self.description:
654 initial_text += '\nBUG='
655 if 'TEST=' not in self.description:
656 initial_text += '\nTEST='
657 self._ParseDescription(UserEditedLog(initial_text))
658
659 def _ParseDescription(self, description):
660 if not description:
661 self.description = description
662 return
663
664 parsed_lines = []
665 reviewers_regexp = re.compile('\s*R=(.+)')
666 reviewers = ''
667 subject = ''
668 for l in description.splitlines():
669 if not subject:
670 subject = l
671 matched_reviewers = reviewers_regexp.match(l)
672 if matched_reviewers:
673 reviewers = matched_reviewers.group(1)
674 parsed_lines.append(l)
675
676 self.description = '\n'.join(parsed_lines) + '\n'
677 self.subject = subject
678 self.reviewers = reviewers
679
680 def IsEmpty(self):
681 return not self.description
682
683
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000684def FindCodereviewSettingsFile(filename='codereview.settings'):
685 """Finds the given file starting in the cwd and going up.
686
687 Only looks up to the top of the repository unless an
688 'inherit-review-settings-ok' file exists in the root of the repository.
689 """
690 inherit_ok_file = 'inherit-review-settings-ok'
691 cwd = os.getcwd()
692 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
693 if os.path.isfile(os.path.join(root, inherit_ok_file)):
694 root = '/'
695 while True:
696 if filename in os.listdir(cwd):
697 if os.path.isfile(os.path.join(cwd, filename)):
698 return open(os.path.join(cwd, filename))
699 if cwd == root:
700 break
701 cwd = os.path.dirname(cwd)
702
703
704def LoadCodereviewSettingsFromFile(fileobj):
705 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000706 keyvals = {}
707 for line in fileobj.read().splitlines():
708 if not line or line.startswith("#"):
709 continue
710 k, v = line.split(": ", 1)
711 keyvals[k] = v
712
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000713 def SetProperty(name, setting, unset_error_ok=False):
714 fullname = 'rietveld.' + name
715 if setting in keyvals:
716 RunGit(['config', fullname, keyvals[setting]])
717 else:
718 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
719
720 SetProperty('server', 'CODE_REVIEW_SERVER')
721 # Only server setting is required. Other settings can be absent.
722 # In that case, we ignore errors raised during option deletion attempt.
723 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
724 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
725 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
726
727 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
728 #should be of the form
729 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
730 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
731 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
732 keyvals['ORIGIN_URL_CONFIG']])
733
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000734
735@usage('[repo root containing codereview.settings]')
736def CMDconfig(parser, args):
737 """edit configuration for this tree"""
738
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000739 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000740 if len(args) == 0:
741 GetCodereviewSettingsInteractively()
742 return 0
743
744 url = args[0]
745 if not url.endswith('codereview.settings'):
746 url = os.path.join(url, 'codereview.settings')
747
748 # Load code review settings and download hooks (if available).
749 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
750 return 0
751
752
753def CMDstatus(parser, args):
754 """show status of changelists"""
755 parser.add_option('--field',
756 help='print only specific field (desc|id|patch|url)')
757 (options, args) = parser.parse_args(args)
758
759 # TODO: maybe make show_branches a flag if necessary.
760 show_branches = not options.field
761
762 if show_branches:
763 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
764 if branches:
765 print 'Branches associated with reviews:'
766 for branch in sorted(branches.splitlines()):
767 cl = Changelist(branchref=branch)
768 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
769
770 cl = Changelist()
771 if options.field:
772 if options.field.startswith('desc'):
773 print cl.GetDescription()
774 elif options.field == 'id':
775 issueid = cl.GetIssue()
776 if issueid:
777 print issueid
778 elif options.field == 'patch':
779 patchset = cl.GetPatchset()
780 if patchset:
781 print patchset
782 elif options.field == 'url':
783 url = cl.GetIssueURL()
784 if url:
785 print url
786 else:
787 print
788 print 'Current branch:',
789 if not cl.GetIssue():
790 print 'no issue assigned.'
791 return 0
792 print cl.GetBranch()
793 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
794 print 'Issue description:'
795 print cl.GetDescription(pretty=True)
796 return 0
797
798
799@usage('[issue_number]')
800def CMDissue(parser, args):
801 """Set or display the current code review issue number.
802
803 Pass issue number 0 to clear the current issue.
804"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000805 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000806
807 cl = Changelist()
808 if len(args) > 0:
809 try:
810 issue = int(args[0])
811 except ValueError:
812 DieWithError('Pass a number to set the issue or none to list it.\n'
813 'Maybe you want to run git cl status?')
814 cl.SetIssue(issue)
815 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
816 return 0
817
818
819def CreateDescriptionFromLog(args):
820 """Pulls out the commit log to use as a base for the CL description."""
821 log_args = []
822 if len(args) == 1 and not args[0].endswith('.'):
823 log_args = [args[0] + '..']
824 elif len(args) == 1 and args[0].endswith('...'):
825 log_args = [args[0][:-1]]
826 elif len(args) == 2:
827 log_args = [args[0] + '..' + args[1]]
828 else:
829 log_args = args[:] # Hope for the best!
830 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
831
832
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000833def UserEditedLog(starting_text):
834 """Given some starting text, let the user edit it and return the result."""
835 editor = os.getenv('EDITOR', 'vi')
836
837 (file_handle, filename) = tempfile.mkstemp()
838 fileobj = os.fdopen(file_handle, 'w')
839 fileobj.write(starting_text)
840 fileobj.close()
841
842 # Open up the default editor in the system to get the CL description.
843 try:
844 cmd = '%s %s' % (editor, filename)
845 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
846 # Msysgit requires the usage of 'env' to be present.
847 cmd = 'env ' + cmd
848 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
maruel@chromium.org2a471072011-05-10 17:29:23 +0000849 try:
850 subprocess.check_call(cmd, shell=True)
851 except subprocess.CalledProcessError, e:
852 DieWithError('Editor returned %d' % e.returncode)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000853 fileobj = open(filename)
854 text = fileobj.read()
855 fileobj.close()
856 finally:
857 os.remove(filename)
858
859 if not text:
860 return
861
862 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
863 return stripcomment_re.sub('', text).strip()
864
865
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000866def ConvertToInteger(inputval):
867 """Convert a string to integer, but returns either an int or None."""
868 try:
869 return int(inputval)
870 except (TypeError, ValueError):
871 return None
872
873
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000874def CMDpresubmit(parser, args):
875 """run presubmit tests on the current changelist"""
876 parser.add_option('--upload', action='store_true',
877 help='Run upload hook instead of the push/dcommit hook')
878 (options, args) = parser.parse_args(args)
879
880 # Make sure index is up-to-date before running diff-index.
881 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
882 if RunGit(['diff-index', 'HEAD']):
883 # TODO(maruel): Is this really necessary?
884 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
885 return 1
886
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000887 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000888 if args:
889 base_branch = args[0]
890 else:
891 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000892 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000893
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000894 cl.RunHook(committing=not options.upload, upstream_branch=base_branch,
895 tbr=False, may_prompt=False, verbose=options.verbose)
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000896 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000897
898
899@usage('[args to "git diff"]')
900def CMDupload(parser, args):
901 """upload the current changelist to codereview"""
902 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
903 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000904 parser.add_option('-f', action='store_true', dest='force',
905 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000906 parser.add_option('-m', dest='message', help='message for patch')
907 parser.add_option('-r', '--reviewers',
908 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000909 parser.add_option('--cc',
910 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000911 parser.add_option('--send-mail', action='store_true',
912 help='send email to reviewer immediately')
913 parser.add_option("--emulate_svn_auto_props", action="store_true",
914 dest="emulate_svn_auto_props",
915 help="Emulate Subversion's auto properties feature.")
916 parser.add_option("--desc_from_logs", action="store_true",
917 dest="from_logs",
918 help="""Squashes git commit logs into change description and
919 uses message as subject""")
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000920 parser.add_option('-c', '--use-commit-queue', action='store_true',
921 help='tell the commit queue to commit this patchset')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000922 (options, args) = parser.parse_args(args)
923
924 # Make sure index is up-to-date before running diff-index.
925 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
926 if RunGit(['diff-index', 'HEAD']):
927 print 'Cannot upload with a dirty tree. You must commit locally first.'
928 return 1
929
930 cl = Changelist()
931 if args:
932 base_branch = args[0]
933 else:
934 # Default to diffing against the "upstream" branch.
935 base_branch = cl.GetUpstreamBranch()
936 args = [base_branch + "..."]
937
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000938 if not options.bypass_hooks and not options.force:
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000939 hook_results = cl.RunHook(committing=False, upstream_branch=base_branch,
940 tbr=False, may_prompt=True,
941 verbose=options.verbose)
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000942 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000943 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000944
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000945
946 # --no-ext-diff is broken in some versions of Git, so try to work around
947 # this by overriding the environment (but there is still a problem if the
948 # git config key "diff.external" is used).
949 env = os.environ.copy()
950 if 'GIT_EXTERNAL_DIFF' in env:
951 del env['GIT_EXTERNAL_DIFF']
952 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
953 env=env)
954
955 upload_args = ['--assume_yes'] # Don't ask about untracked files.
956 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000957 if options.emulate_svn_auto_props:
958 upload_args.append('--emulate_svn_auto_props')
959 if options.send_mail:
960 if not options.reviewers:
961 DieWithError("Must specify reviewers to send email.")
962 upload_args.append('--send_mail')
963 if options.from_logs and not options.message:
964 print 'Must set message for subject line if using desc_from_logs'
965 return 1
966
967 change_desc = None
968
969 if cl.GetIssue():
970 if options.message:
971 upload_args.extend(['--message', options.message])
972 upload_args.extend(['--issue', cl.GetIssue()])
973 print ("This branch is associated with issue %s. "
974 "Adding patch to that issue." % cl.GetIssue())
975 else:
976 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000977 change_desc = ChangeDescription(options.message, log_desc,
978 options.reviewers)
979 if not options.from_logs:
980 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000981
982 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000983 print "Description is empty; aborting."
984 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000985
986 upload_args.extend(['--message', change_desc.subject])
987 upload_args.extend(['--description', change_desc.description])
988 if change_desc.reviewers:
989 upload_args.extend(['--reviewers', change_desc.reviewers])
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000990 cc = ','.join(filter(None, (cl.GetCCList(), options.cc)))
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000991 if cc:
992 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000993
994 # Include the upstream repo's URL in the change -- this is useful for
995 # projects that have their source spread across multiple repos.
996 remote_url = None
997 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000998 # URL is dependent on the current directory.
999 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001000 if data:
1001 keys = dict(line.split(': ', 1) for line in data.splitlines()
1002 if ': ' in line)
1003 remote_url = keys.get('URL', None)
1004 else:
1005 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
1006 remote_url = (cl.GetRemoteUrl() + '@'
1007 + cl.GetUpstreamBranch().split('/')[-1])
1008 if remote_url:
1009 upload_args.extend(['--base_url', remote_url])
1010
1011 try:
1012 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
maruel@chromium.org9ce0dff2011-04-04 17:56:50 +00001013 except KeyboardInterrupt:
1014 sys.exit(1)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001015 except:
1016 # If we got an exception after the user typed a description for their
1017 # change, back up the description before re-raising.
1018 if change_desc:
1019 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
1020 print '\nGot exception while uploading -- saving description to %s\n' \
1021 % backup_path
1022 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001023 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001024 backup_file.close()
1025 raise
1026
1027 if not cl.GetIssue():
1028 cl.SetIssue(issue)
1029 cl.SetPatchset(patchset)
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001030
1031 if options.use_commit_queue:
1032 cl.SetFlag('commit', '1')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001033 return 0
1034
1035
1036def SendUpstream(parser, args, cmd):
1037 """Common code for CmdPush and CmdDCommit
1038
1039 Squashed commit into a single.
1040 Updates changelog with metadata (e.g. pointer to review).
1041 Pushes/dcommits the code upstream.
1042 Updates review and closes.
1043 """
1044 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1045 help='bypass upload presubmit hook')
1046 parser.add_option('-m', dest='message',
1047 help="override review description")
1048 parser.add_option('-f', action='store_true', dest='force',
1049 help="force yes to questions (don't prompt)")
1050 parser.add_option('-c', dest='contributor',
1051 help="external contributor for patch (appended to " +
1052 "description and used as author for git). Should be " +
1053 "formatted as 'First Last <email@example.com>'")
1054 parser.add_option('--tbr', action='store_true', dest='tbr',
1055 help="short for 'to be reviewed', commit branch " +
1056 "even without uploading for review")
1057 (options, args) = parser.parse_args(args)
1058 cl = Changelist()
1059
1060 if not args or cmd == 'push':
1061 # Default to merging against our best guess of the upstream branch.
1062 args = [cl.GetUpstreamBranch()]
1063
1064 base_branch = args[0]
1065
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001066 # Make sure index is up-to-date before running diff-index.
1067 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001068 if RunGit(['diff-index', 'HEAD']):
1069 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1070 return 1
1071
1072 # This rev-list syntax means "show all commits not in my branch that
1073 # are in base_branch".
1074 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1075 base_branch]).splitlines()
1076 if upstream_commits:
1077 print ('Base branch "%s" has %d commits '
1078 'not in this branch.' % (base_branch, len(upstream_commits)))
1079 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1080 return 1
1081
1082 if cmd == 'dcommit':
1083 # This is the revision `svn dcommit` will commit on top of.
1084 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1085 '--pretty=format:%H'])
1086 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1087 if extra_commits:
1088 print ('This branch has %d additional commits not upstreamed yet.'
1089 % len(extra_commits.splitlines()))
1090 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1091 'before attempting to %s.' % (base_branch, cmd))
1092 return 1
1093
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001094 if not options.bypass_hooks and not options.force:
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +00001095 cl.RunHook(committing=True, upstream_branch=base_branch,
1096 tbr=options.tbr, may_prompt=True, verbose=options.verbose)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001097
1098 if cmd == 'dcommit':
1099 # Check the tree status if the tree status URL is set.
1100 status = GetTreeStatus()
1101 if 'closed' == status:
1102 print ('The tree is closed. Please wait for it to reopen. Use '
1103 '"git cl dcommit -f" to commit on a closed tree.')
1104 return 1
1105 elif 'unknown' == status:
1106 print ('Unable to determine tree status. Please verify manually and '
1107 'use "git cl dcommit -f" to commit on a closed tree.')
1108
1109 description = options.message
1110 if not options.tbr:
1111 # It is important to have these checks early. Not only for user
1112 # convenience, but also because the cl object then caches the correct values
1113 # of these fields even as we're juggling branches for setting up the commit.
1114 if not cl.GetIssue():
1115 print 'Current issue unknown -- has this branch been uploaded?'
1116 print 'Use --tbr to commit without review.'
1117 return 1
1118
1119 if not description:
1120 description = cl.GetDescription()
1121
1122 if not description:
1123 print 'No description set.'
1124 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1125 return 1
1126
1127 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1128 else:
1129 if not description:
1130 # Submitting TBR. See if there's already a description in Rietveld, else
1131 # create a template description. Eitherway, give the user a chance to edit
1132 # it to fill in the TBR= field.
1133 if cl.GetIssue():
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001134 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001135
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001136 # TODO(dpranke): Update to use ChangeDescription object.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001137 if not description:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001138 description = """# Enter a description of the change.
1139# This will be used as the change log for the commit.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001140
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001141"""
1142 description += CreateDescriptionFromLog(args)
1143
1144 description = UserEditedLog(description + '\nTBR=')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001145
1146 if not description:
1147 print "Description empty; aborting."
1148 return 1
1149
1150 if options.contributor:
1151 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1152 print "Please provide contibutor as 'First Last <email@example.com>'"
1153 return 1
1154 description += "\nPatch from %s." % options.contributor
1155 print 'Description:', repr(description)
1156
1157 branches = [base_branch, cl.GetBranchRef()]
1158 if not options.force:
1159 subprocess.call(['git', 'diff', '--stat'] + branches)
maruel@chromium.org90541732011-04-01 17:54:18 +00001160 ask_for_data('About to commit; enter to confirm.')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001161
1162 # We want to squash all this branch's commits into one commit with the
1163 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001164 # We do this by doing a "reset --soft" to the base branch (which keeps
1165 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001166 MERGE_BRANCH = 'git-cl-commit'
1167 # Delete the merge branch if it already exists.
1168 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1169 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1170 RunGit(['branch', '-D', MERGE_BRANCH])
1171
1172 # We might be in a directory that's present in this branch but not in the
1173 # trunk. Move up to the top of the tree so that git commands that expect a
1174 # valid CWD won't fail after we check out the merge branch.
1175 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1176 if rel_base_path:
1177 os.chdir(rel_base_path)
1178
1179 # Stuff our change into the merge branch.
1180 # We wrap in a try...finally block so if anything goes wrong,
1181 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001182 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001183 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001184 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1185 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001186 if options.contributor:
1187 RunGit(['commit', '--author', options.contributor, '-m', description])
1188 else:
1189 RunGit(['commit', '-m', description])
1190 if cmd == 'push':
1191 # push the merge branch.
1192 remote, branch = cl.FetchUpstreamTuple()
1193 retcode, output = RunGitWithCode(
1194 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1195 logging.debug(output)
1196 else:
1197 # dcommit the merge branch.
bauerb@chromium.org2e64fa12011-05-05 11:13:44 +00001198 retcode, output = RunGitWithCode(['svn', 'dcommit',
1199 '--no-rebase', '--rmdir'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001200 finally:
1201 # And then swap back to the original branch and clean up.
1202 RunGit(['checkout', '-q', cl.GetBranch()])
1203 RunGit(['branch', '-D', MERGE_BRANCH])
1204
1205 if cl.GetIssue():
1206 if cmd == 'dcommit' and 'Committed r' in output:
1207 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1208 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001209 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1210 for l in output.splitlines(False))
1211 match = filter(None, match)
1212 if len(match) != 1:
1213 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1214 output)
1215 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001216 else:
1217 return 1
1218 viewvc_url = settings.GetViewVCUrl()
1219 if viewvc_url and revision:
1220 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1221 print ('Closing issue '
1222 '(you may be prompted for your codereview password)...')
1223 cl.CloseIssue()
1224 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001225
1226 if retcode == 0:
1227 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1228 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001229 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001230
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001231 return 0
1232
1233
1234@usage('[upstream branch to apply against]')
1235def CMDdcommit(parser, args):
1236 """commit the current changelist via git-svn"""
1237 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001238 message = """This doesn't appear to be an SVN repository.
1239If your project has a git mirror with an upstream SVN master, you probably need
1240to run 'git svn init', see your project's git mirror documentation.
1241If your project has a true writeable upstream repository, you probably want
1242to run 'git cl push' instead.
1243Choose wisely, if you get this wrong, your commit might appear to succeed but
1244will instead be silently ignored."""
1245 print(message)
maruel@chromium.org90541732011-04-01 17:54:18 +00001246 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001247 return SendUpstream(parser, args, 'dcommit')
1248
1249
1250@usage('[upstream branch to apply against]')
1251def CMDpush(parser, args):
1252 """commit the current changelist via git"""
1253 if settings.GetIsGitSvn():
1254 print('This appears to be an SVN repository.')
1255 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
maruel@chromium.org90541732011-04-01 17:54:18 +00001256 ask_for_data('[Press enter to push or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001257 return SendUpstream(parser, args, 'push')
1258
1259
1260@usage('<patch url or issue id>')
1261def CMDpatch(parser, args):
1262 """patch in a code review"""
1263 parser.add_option('-b', dest='newbranch',
1264 help='create a new branch off trunk for the patch')
1265 parser.add_option('-f', action='store_true', dest='force',
1266 help='with -b, clobber any existing branch')
1267 parser.add_option('--reject', action='store_true', dest='reject',
1268 help='allow failed patches and spew .rej files')
1269 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1270 help="don't commit after patch applies")
1271 (options, args) = parser.parse_args(args)
1272 if len(args) != 1:
1273 parser.print_help()
1274 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001275 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001276
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001277 # TODO(maruel): Use apply_issue.py
1278
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001279 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001280 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001281 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001282 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001283 else:
1284 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001285 issue_url = FixUrl(issue_arg)
1286 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001287 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001288 DieWithError('Must pass an issue ID or full URL for '
1289 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001290 issue = match.group(1)
1291 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001292
1293 if options.newbranch:
1294 if options.force:
1295 RunGit(['branch', '-D', options.newbranch],
1296 swallow_stderr=True, error_ok=True)
1297 RunGit(['checkout', '-b', options.newbranch,
1298 Changelist().GetUpstreamBranch()])
1299
1300 # Switch up to the top-level directory, if necessary, in preparation for
1301 # applying the patch.
1302 top = RunGit(['rev-parse', '--show-cdup']).strip()
1303 if top:
1304 os.chdir(top)
1305
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001306 # Git patches have a/ at the beginning of source paths. We strip that out
1307 # with a sed script rather than the -p flag to patch so we can feed either
1308 # Git or svn-style patches into the same apply command.
1309 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1310 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1311 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1312 patch_data = sed_proc.communicate(patch_data)[0]
1313 if sed_proc.returncode:
1314 DieWithError('Git patch mungling failed.')
1315 logging.info(patch_data)
1316 # We use "git apply" to apply the patch instead of "patch" so that we can
1317 # pick up file adds.
1318 # The --index flag means: also insert into the index (so we catch adds).
1319 cmd = ['git', 'apply', '--index', '-p0']
1320 if options.reject:
1321 cmd.append('--reject')
1322 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1323 patch_proc.communicate(patch_data)
1324 if patch_proc.returncode:
1325 DieWithError('Failed to apply the patch')
1326
1327 # If we had an issue, commit the current state and register the issue.
1328 if not options.nocommit:
1329 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1330 cl = Changelist()
1331 cl.SetIssue(issue)
1332 print "Committed patch."
1333 else:
1334 print "Patch applied to index."
1335 return 0
1336
1337
1338def CMDrebase(parser, args):
1339 """rebase current branch on top of svn repo"""
1340 # Provide a wrapper for git svn rebase to help avoid accidental
1341 # git svn dcommit.
1342 # It's the only command that doesn't use parser at all since we just defer
1343 # execution to git-svn.
1344 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1345 return 0
1346
1347
1348def GetTreeStatus():
1349 """Fetches the tree status and returns either 'open', 'closed',
1350 'unknown' or 'unset'."""
1351 url = settings.GetTreeStatusUrl(error_ok=True)
1352 if url:
1353 status = urllib2.urlopen(url).read().lower()
1354 if status.find('closed') != -1 or status == '0':
1355 return 'closed'
1356 elif status.find('open') != -1 or status == '1':
1357 return 'open'
1358 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001359 return 'unset'
1360
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001361
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001362def GetTreeStatusReason():
1363 """Fetches the tree status from a json url and returns the message
1364 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001365 url = settings.GetTreeStatusUrl()
1366 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001367 connection = urllib2.urlopen(json_url)
1368 status = json.loads(connection.read())
1369 connection.close()
1370 return status['message']
1371
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001372
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001373def CMDtree(parser, args):
1374 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001375 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001376 status = GetTreeStatus()
1377 if 'unset' == status:
1378 print 'You must configure your tree status URL by running "git cl config".'
1379 return 2
1380
1381 print "The tree is %s" % status
1382 print
1383 print GetTreeStatusReason()
1384 if status != 'open':
1385 return 1
1386 return 0
1387
1388
1389def CMDupstream(parser, args):
1390 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001391 _, args = parser.parse_args(args)
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001392 if args:
1393 parser.error('Unrecognized args: %s' % ' '.join(args))
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001394 cl = Changelist()
1395 print cl.GetUpstreamBranch()
1396 return 0
1397
1398
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001399def CMDset_commit(parser, args):
1400 """set the commit bit"""
1401 _, args = parser.parse_args(args)
1402 if args:
1403 parser.error('Unrecognized args: %s' % ' '.join(args))
1404 cl = Changelist()
1405 cl.SetFlag('commit', '1')
1406 return 0
1407
1408
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001409def Command(name):
1410 return getattr(sys.modules[__name__], 'CMD' + name, None)
1411
1412
1413def CMDhelp(parser, args):
1414 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001415 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001416 if len(args) == 1:
1417 return main(args + ['--help'])
1418 parser.print_help()
1419 return 0
1420
1421
1422def GenUsage(parser, command):
1423 """Modify an OptParse object with the function's documentation."""
1424 obj = Command(command)
1425 more = getattr(obj, 'usage_more', '')
1426 if command == 'help':
1427 command = '<command>'
1428 else:
1429 # OptParser.description prefer nicely non-formatted strings.
1430 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1431 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1432
1433
1434def main(argv):
1435 """Doesn't parse the arguments here, just find the right subcommand to
1436 execute."""
1437 # Do it late so all commands are listed.
1438 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1439 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1440 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1441
1442 # Create the option parse and add --verbose support.
1443 parser = optparse.OptionParser()
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001444 parser.add_option(
1445 '-v', '--verbose', action='count', default=0,
1446 help='Use 2 times for more debugging info')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001447 old_parser_args = parser.parse_args
1448 def Parse(args):
1449 options, args = old_parser_args(args)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001450 if options.verbose >= 2:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001451 logging.basicConfig(level=logging.DEBUG)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001452 elif options.verbose:
1453 logging.basicConfig(level=logging.INFO)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001454 else:
1455 logging.basicConfig(level=logging.WARNING)
1456 return options, args
1457 parser.parse_args = Parse
1458
1459 if argv:
1460 command = Command(argv[0])
1461 if command:
1462 # "fix" the usage and the description now that we know the subcommand.
1463 GenUsage(parser, argv[0])
1464 try:
1465 return command(parser, argv[1:])
1466 except urllib2.HTTPError, e:
1467 if e.code != 500:
1468 raise
1469 DieWithError(
1470 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1471 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1472
1473 # Not a known command. Default to help.
1474 GenUsage(parser, 'help')
1475 return CMDhelp(parser, argv)
1476
1477
1478if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001479 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001480 sys.exit(main(sys.argv[1:]))