blob: 9566118c596a8f48457ab215c7bbfa0386110c2f [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 logging
11import optparse
12import os
13import re
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000014import sys
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000015import tempfile
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000016import textwrap
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +000017import urlparse
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000018import urllib2
19
20try:
maruel@chromium.orgc98c0c52011-04-06 13:39:43 +000021 import readline # pylint: disable=F0401,W0611
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000022except ImportError:
23 pass
24
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000025try:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000026 import simplejson as json # pylint: disable=F0401
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000027except ImportError:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000028 try:
maruel@chromium.org725f1c32011-04-01 20:24:54 +000029 import json # pylint: disable=F0401
maruel@chromium.org2a74d372011-03-29 19:05:50 +000030 except ImportError:
31 # Fall back to the packaged version.
maruel@chromium.orgb35c00c2011-03-31 00:43:35 +000032 sys.path.append(os.path.join(os.path.dirname(__file__), 'third_party'))
dpranke@chromium.orgfe79c312011-04-01 20:15:52 +000033 import simplejson as json # pylint: disable=F0401
maruel@chromium.org2a74d372011-03-29 19:05:50 +000034
35
36from third_party import upload
37import breakpad # pylint: disable=W0611
maruel@chromium.org6f09cd92011-04-01 16:38:12 +000038import fix_encoding
maruel@chromium.org2a74d372011-03-29 19:05:50 +000039import presubmit_support
maruel@chromium.orgcab38e92011-04-09 00:30:51 +000040import rietveld
maruel@chromium.org2a74d372011-03-29 19:05:50 +000041import scm
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000042import subprocess2
maruel@chromium.org2a74d372011-03-29 19:05:50 +000043import watchlists
44
45
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000046DEFAULT_SERVER = 'http://codereview.appspot.com'
maruel@chromium.org0ba7f962011-01-11 22:13:58 +000047POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000048DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup'
49
maruel@chromium.org90541732011-04-01 17:54:18 +000050
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000051def DieWithError(message):
dpranke@chromium.org970c5222011-03-12 00:32:24 +000052 print >> sys.stderr, message
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000053 sys.exit(1)
54
55
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000056def RunCommand(args, error_ok=False, error_message=None, **kwargs):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000057 try:
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000058 return subprocess2.check_output(args, shell=False, **kwargs)
59 except subprocess2.CalledProcessError, e:
60 if not error_ok:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000061 DieWithError(
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000062 'Command "%s" failed.\n%s' % (
63 ' '.join(args), error_message or e.stdout or ''))
64 return e.stdout
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000065
66
67def RunGit(args, **kwargs):
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000068 """Returns stdout."""
69 return RunCommand(['git'] + args, **kwargs)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000070
71
72def RunGitWithCode(args):
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000073 """Returns return code and stdout."""
74 out, code = subprocess2.communicate(['git'] + args, stdout=subprocess2.PIPE)
75 return code, out[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000076
77
78def usage(more):
79 def hook(fn):
80 fn.usage_more = more
81 return fn
82 return hook
83
84
maruel@chromium.org90541732011-04-01 17:54:18 +000085def ask_for_data(prompt):
86 try:
87 return raw_input(prompt)
88 except KeyboardInterrupt:
89 # Hide the exception.
90 sys.exit(1)
91
92
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000093def FixUrl(server):
94 """Fix a server url to defaults protocol to http:// if none is specified."""
95 if not server:
96 return server
97 if not re.match(r'[a-z]+\://.*', server):
98 return 'http://' + server
99 return server
100
101
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000102def MatchSvnGlob(url, base_url, glob_spec, allow_wildcards):
103 """Return the corresponding git ref if |base_url| together with |glob_spec|
104 matches the full |url|.
105
106 If |allow_wildcards| is true, |glob_spec| can contain wildcards (see below).
107 """
108 fetch_suburl, as_ref = glob_spec.split(':')
109 if allow_wildcards:
110 glob_match = re.match('(.+/)?(\*|{[^/]*})(/.+)?', fetch_suburl)
111 if glob_match:
112 # Parse specs like "branches/*/src:refs/remotes/svn/*" or
113 # "branches/{472,597,648}/src:refs/remotes/svn/*".
114 branch_re = re.escape(base_url)
115 if glob_match.group(1):
116 branch_re += '/' + re.escape(glob_match.group(1))
117 wildcard = glob_match.group(2)
118 if wildcard == '*':
119 branch_re += '([^/]*)'
120 else:
121 # Escape and replace surrounding braces with parentheses and commas
122 # with pipe symbols.
123 wildcard = re.escape(wildcard)
124 wildcard = re.sub('^\\\\{', '(', wildcard)
125 wildcard = re.sub('\\\\,', '|', wildcard)
126 wildcard = re.sub('\\\\}$', ')', wildcard)
127 branch_re += wildcard
128 if glob_match.group(3):
129 branch_re += re.escape(glob_match.group(3))
130 match = re.match(branch_re, url)
131 if match:
132 return re.sub('\*$', match.group(1), as_ref)
133
134 # Parse specs like "trunk/src:refs/remotes/origin/trunk".
135 if fetch_suburl:
136 full_url = base_url + '/' + fetch_suburl
137 else:
138 full_url = base_url
139 if full_url == url:
140 return as_ref
141 return None
142
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +0000143
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000144class Settings(object):
145 def __init__(self):
146 self.default_server = None
147 self.cc = None
148 self.root = None
149 self.is_git_svn = None
150 self.svn_branch = None
151 self.tree_status_url = None
152 self.viewvc_url = None
153 self.updated = False
154
155 def LazyUpdateIfNeeded(self):
156 """Updates the settings from a codereview.settings file, if available."""
157 if not self.updated:
158 cr_settings_file = FindCodereviewSettingsFile()
159 if cr_settings_file:
160 LoadCodereviewSettingsFromFile(cr_settings_file)
161 self.updated = True
162
163 def GetDefaultServerUrl(self, error_ok=False):
164 if not self.default_server:
165 self.LazyUpdateIfNeeded()
166 self.default_server = FixUrl(self._GetConfig('rietveld.server',
167 error_ok=True))
168 if error_ok:
169 return self.default_server
170 if not self.default_server:
171 error_message = ('Could not find settings file. You must configure '
172 'your review setup by running "git cl config".')
173 self.default_server = FixUrl(self._GetConfig(
174 'rietveld.server', error_message=error_message))
175 return self.default_server
176
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000177 def GetRoot(self):
178 if not self.root:
179 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
180 return self.root
181
182 def GetIsGitSvn(self):
183 """Return true if this repo looks like it's using git-svn."""
184 if self.is_git_svn is None:
185 # If you have any "svn-remote.*" config keys, we think you're using svn.
186 self.is_git_svn = RunGitWithCode(
187 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
188 return self.is_git_svn
189
190 def GetSVNBranch(self):
191 if self.svn_branch is None:
192 if not self.GetIsGitSvn():
193 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
194
195 # Try to figure out which remote branch we're based on.
196 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000197 # 1) iterate through our branch history and find the svn URL.
198 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000199
200 # regexp matching the git-svn line that contains the URL.
201 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
202
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000203 # We don't want to go through all of history, so read a line from the
204 # pipe at a time.
205 # The -100 is an arbitrary limit so we don't search forever.
206 cmd = ['git', 'log', '-100', '--pretty=medium']
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +0000207 proc = subprocess2.Popen(cmd, stdout=subprocess2.PIPE)
maruel@chromium.org740f9d72011-06-10 18:33:10 +0000208 url = None
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000209 for line in proc.stdout:
210 match = git_svn_re.match(line)
211 if match:
212 url = match.group(1)
213 proc.stdout.close() # Cut pipe.
214 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000215
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000216 if url:
217 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
218 remotes = RunGit(['config', '--get-regexp',
219 r'^svn-remote\..*\.url']).splitlines()
220 for remote in remotes:
221 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000222 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000223 remote = match.group(1)
224 base_url = match.group(2)
225 fetch_spec = RunGit(
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000226 ['config', 'svn-remote.%s.fetch' % remote],
227 error_ok=True).strip()
228 if fetch_spec:
229 self.svn_branch = MatchSvnGlob(url, base_url, fetch_spec, False)
230 if self.svn_branch:
231 break
232 branch_spec = RunGit(
233 ['config', 'svn-remote.%s.branches' % remote],
234 error_ok=True).strip()
235 if branch_spec:
236 self.svn_branch = MatchSvnGlob(url, base_url, branch_spec, True)
237 if self.svn_branch:
238 break
239 tag_spec = RunGit(
240 ['config', 'svn-remote.%s.tags' % remote],
241 error_ok=True).strip()
242 if tag_spec:
243 self.svn_branch = MatchSvnGlob(url, base_url, tag_spec, True)
244 if self.svn_branch:
245 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000246
247 if not self.svn_branch:
248 DieWithError('Can\'t guess svn branch -- try specifying it on the '
249 'command line')
250
251 return self.svn_branch
252
253 def GetTreeStatusUrl(self, error_ok=False):
254 if not self.tree_status_url:
255 error_message = ('You must configure your tree status URL by running '
256 '"git cl config".')
257 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
258 error_ok=error_ok,
259 error_message=error_message)
260 return self.tree_status_url
261
262 def GetViewVCUrl(self):
263 if not self.viewvc_url:
264 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
265 return self.viewvc_url
266
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000267 def GetDefaultCCList(self):
268 return self._GetConfig('rietveld.cc', error_ok=True)
269
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000270 def _GetConfig(self, param, **kwargs):
271 self.LazyUpdateIfNeeded()
272 return RunGit(['config', param], **kwargs).strip()
273
274
275settings = Settings()
276
277
278did_migrate_check = False
279def CheckForMigration():
280 """Migrate from the old issue format, if found.
281
282 We used to store the branch<->issue mapping in a file in .git, but it's
283 better to store it in the .git/config, since deleting a branch deletes that
284 branch's entry there.
285 """
286
287 # Don't run more than once.
288 global did_migrate_check
289 if did_migrate_check:
290 return
291
292 gitdir = RunGit(['rev-parse', '--git-dir']).strip()
293 storepath = os.path.join(gitdir, 'cl-mapping')
294 if os.path.exists(storepath):
295 print "old-style git-cl mapping file (%s) found; migrating." % storepath
296 store = open(storepath, 'r')
297 for line in store:
298 branch, issue = line.strip().split()
299 RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
300 issue])
301 store.close()
302 os.remove(storepath)
303 did_migrate_check = True
304
305
306def ShortBranchName(branch):
307 """Convert a name like 'refs/heads/foo' to just 'foo'."""
308 return branch.replace('refs/heads/', '')
309
310
311class Changelist(object):
312 def __init__(self, branchref=None):
313 # Poke settings so we get the "configure your server" message if necessary.
314 settings.GetDefaultServerUrl()
315 self.branchref = branchref
316 if self.branchref:
317 self.branch = ShortBranchName(self.branchref)
318 else:
319 self.branch = None
320 self.rietveld_server = None
321 self.upstream_branch = None
322 self.has_issue = False
323 self.issue = None
324 self.has_description = False
325 self.description = None
326 self.has_patchset = False
327 self.patchset = None
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000328 self._rpc_server = None
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000329 self.cc = None
330 self.watchers = ()
331
332 def GetCCList(self):
333 """Return the users cc'd on this CL.
334
335 Return is a string suitable for passing to gcl with the --cc flag.
336 """
337 if self.cc is None:
338 base_cc = settings .GetDefaultCCList()
339 more_cc = ','.join(self.watchers)
340 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
341 return self.cc
342
343 def SetWatchers(self, watchers):
344 """Set the list of email addresses that should be cc'd based on the changed
345 files in this CL.
346 """
347 self.watchers = watchers
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000348
349 def GetBranch(self):
350 """Returns the short branch name, e.g. 'master'."""
351 if not self.branch:
352 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
353 self.branch = ShortBranchName(self.branchref)
354 return self.branch
355
356 def GetBranchRef(self):
357 """Returns the full branch name, e.g. 'refs/heads/master'."""
358 self.GetBranch() # Poke the lazy loader.
359 return self.branchref
360
361 def FetchUpstreamTuple(self):
362 """Returns a tuple containg remote and remote ref,
363 e.g. 'origin', 'refs/heads/master'
364 """
365 remote = '.'
366 branch = self.GetBranch()
367 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
368 error_ok=True).strip()
369 if upstream_branch:
370 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
371 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000372 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
373 error_ok=True).strip()
374 if upstream_branch:
375 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000376 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000377 # Fall back on trying a git-svn upstream branch.
378 if settings.GetIsGitSvn():
379 upstream_branch = settings.GetSVNBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000380 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000381 # Else, try to guess the origin remote.
382 remote_branches = RunGit(['branch', '-r']).split()
383 if 'origin/master' in remote_branches:
384 # Fall back on origin/master if it exits.
385 remote = 'origin'
386 upstream_branch = 'refs/heads/master'
387 elif 'origin/trunk' in remote_branches:
388 # Fall back on origin/trunk if it exists. Generally a shared
389 # git-svn clone
390 remote = 'origin'
391 upstream_branch = 'refs/heads/trunk'
392 else:
393 DieWithError("""Unable to determine default branch to diff against.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000394Either pass complete "git diff"-style arguments, like
395 git cl upload origin/master
396or verify this branch is set up to track another (via the --track argument to
397"git checkout -b ...").""")
398
399 return remote, upstream_branch
400
401 def GetUpstreamBranch(self):
402 if self.upstream_branch is None:
403 remote, upstream_branch = self.FetchUpstreamTuple()
404 if remote is not '.':
405 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
406 self.upstream_branch = upstream_branch
407 return self.upstream_branch
408
409 def GetRemoteUrl(self):
410 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
411
412 Returns None if there is no remote.
413 """
414 remote = self.FetchUpstreamTuple()[0]
415 if remote == '.':
416 return None
417 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
418
419 def GetIssue(self):
420 if not self.has_issue:
421 CheckForMigration()
422 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
423 if issue:
424 self.issue = issue
425 self.rietveld_server = FixUrl(RunGit(
426 ['config', self._RietveldServer()], error_ok=True).strip())
427 else:
428 self.issue = None
429 if not self.rietveld_server:
430 self.rietveld_server = settings.GetDefaultServerUrl()
431 self.has_issue = True
432 return self.issue
433
434 def GetRietveldServer(self):
435 self.GetIssue()
436 return self.rietveld_server
437
438 def GetIssueURL(self):
439 """Get the URL for a particular issue."""
440 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
441
442 def GetDescription(self, pretty=False):
443 if not self.has_description:
444 if self.GetIssue():
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000445 self.description = self.RpcServer().get_description(
446 int(self.GetIssue())).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000447 self.has_description = True
448 if pretty:
449 wrapper = textwrap.TextWrapper()
450 wrapper.initial_indent = wrapper.subsequent_indent = ' '
451 return wrapper.fill(self.description)
452 return self.description
453
454 def GetPatchset(self):
455 if not self.has_patchset:
456 patchset = RunGit(['config', self._PatchsetSetting()],
457 error_ok=True).strip()
458 if patchset:
459 self.patchset = patchset
460 else:
461 self.patchset = None
462 self.has_patchset = True
463 return self.patchset
464
465 def SetPatchset(self, patchset):
466 """Set this branch's patchset. If patchset=0, clears the patchset."""
467 if patchset:
468 RunGit(['config', self._PatchsetSetting(), str(patchset)])
469 else:
470 RunGit(['config', '--unset', self._PatchsetSetting()],
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +0000471 stderr=subprocess2.PIPE, error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000472 self.has_patchset = False
473
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000474 def GetPatchSetDiff(self, issue):
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000475 patchset = self.RpcServer().get_issue_properties(
476 int(issue), False)['patchsets'][-1]
477 return self.RpcServer().get(
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000478 '/download/issue%s_%s.diff' % (issue, patchset))
479
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000480 def SetIssue(self, issue):
481 """Set this branch's issue. If issue=0, clears the issue."""
482 if issue:
483 RunGit(['config', self._IssueSetting(), str(issue)])
484 if self.rietveld_server:
485 RunGit(['config', self._RietveldServer(), self.rietveld_server])
486 else:
487 RunGit(['config', '--unset', self._IssueSetting()])
488 self.SetPatchset(0)
489 self.has_issue = False
490
asvitkine@chromium.org15169952011-09-27 14:30:53 +0000491 def GetChange(self, upstream_branch, author):
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000492 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip() or '.'
493 absroot = os.path.abspath(root)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000494
495 # We use the sha1 of HEAD as a name of this change.
496 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000497 # Need to pass a relative path for msysgit.
maruel@chromium.org2b38e9c2011-10-19 00:04:35 +0000498 try:
499 files = scm.GIT.CaptureStatus([root], upstream_branch)
500 except subprocess2.CalledProcessError:
501 DieWithError(
502 ('\nFailed to diff against upstream branch %s!\n\n'
503 'This branch probably doesn\'t exist anymore. To reset the\n'
504 'tracking branch, please run\n'
505 ' git branch --set-upstream %s trunk\n'
506 'replacing trunk with origin/master or the relevant branch') %
507 (upstream_branch, self.GetBranch()))
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000508
509 issue = ConvertToInteger(self.GetIssue())
510 patchset = ConvertToInteger(self.GetPatchset())
511 if issue:
512 description = self.GetDescription()
513 else:
514 # If the change was never uploaded, use the log messages of all commits
515 # up to the branch point, as git cl upload will prefill the description
516 # with these log messages.
517 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
518 '%s...' % (upstream_branch)]).strip()
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000519
520 if not author:
maruel@chromium.org13f623c2011-07-22 16:02:23 +0000521 author = RunGit(['config', 'user.email']).strip() or None
asvitkine@chromium.org15169952011-09-27 14:30:53 +0000522 return presubmit_support.GitChange(
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000523 name,
524 description,
525 absroot,
526 files,
527 issue,
528 patchset,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000529 author)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000530
asvitkine@chromium.org15169952011-09-27 14:30:53 +0000531 def RunHook(self, committing, upstream_branch, may_prompt, verbose, author):
532 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
533 change = self.GetChange(upstream_branch, author)
534
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000535 # Apply watchlists on upload.
536 if not committing:
537 watchlist = watchlists.Watchlists(change.RepositoryRoot())
538 files = [f.LocalPath() for f in change.AffectedFiles()]
539 self.SetWatchers(watchlist.GetWatchersForPaths(files))
540
541 try:
542 output = presubmit_support.DoPresubmitChecks(change, committing,
543 verbose=verbose, output_stream=sys.stdout, input_stream=sys.stdin,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +0000544 default_presubmit=None, may_prompt=may_prompt,
maruel@chromium.org239f4112011-06-03 20:08:23 +0000545 rietveld_obj=self.RpcServer())
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000546 except presubmit_support.PresubmitFailure, e:
547 DieWithError(
548 ('%s\nMaybe your depot_tools is out of date?\n'
549 'If all fails, contact maruel@') % e)
550
551 # TODO(dpranke): We should propagate the error out instead of calling
552 # exit().
553 if not output.should_continue():
554 sys.exit(1)
555
556 return output
557
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000558 def CloseIssue(self):
maruel@chromium.org607bb1b2011-06-01 23:43:11 +0000559 """Updates the description and closes the issue."""
560 issue = int(self.GetIssue())
561 self.RpcServer().update_description(issue, self.description)
562 return self.RpcServer().close_issue(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000563
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000564 def SetFlag(self, flag, value):
565 """Patchset must match."""
566 if not self.GetPatchset():
567 DieWithError('The patchset needs to match. Send another patchset.')
568 try:
569 return self.RpcServer().set_flag(
570 int(self.GetIssue()), int(self.GetPatchset()), flag, value)
571 except urllib2.HTTPError, e:
572 if e.code == 404:
573 DieWithError('The issue %s doesn\'t exist.' % self.GetIssue())
574 if e.code == 403:
575 DieWithError(
576 ('Access denied to issue %s. Maybe the patchset %s doesn\'t '
577 'match?') % (self.GetIssue(), self.GetPatchset()))
578 raise
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000579
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000580 def RpcServer(self):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000581 """Returns an upload.RpcServer() to access this review's rietveld instance.
582 """
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000583 if not self._rpc_server:
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000584 self.GetIssue()
585 self._rpc_server = rietveld.Rietveld(self.rietveld_server, None, None)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000586 return self._rpc_server
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000587
588 def _IssueSetting(self):
589 """Return the git setting that stores this change's issue."""
590 return 'branch.%s.rietveldissue' % self.GetBranch()
591
592 def _PatchsetSetting(self):
593 """Return the git setting that stores this change's most recent patchset."""
594 return 'branch.%s.rietveldpatchset' % self.GetBranch()
595
596 def _RietveldServer(self):
597 """Returns the git setting that stores this change's rietveld server."""
598 return 'branch.%s.rietveldserver' % self.GetBranch()
599
600
601def GetCodereviewSettingsInteractively():
602 """Prompt the user for settings."""
603 server = settings.GetDefaultServerUrl(error_ok=True)
604 prompt = 'Rietveld server (host[:port])'
605 prompt += ' [%s]' % (server or DEFAULT_SERVER)
maruel@chromium.org90541732011-04-01 17:54:18 +0000606 newserver = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000607 if not server and not newserver:
608 newserver = DEFAULT_SERVER
609 if newserver and newserver != server:
610 RunGit(['config', 'rietveld.server', newserver])
611
612 def SetProperty(initial, caption, name):
613 prompt = caption
614 if initial:
615 prompt += ' ("x" to clear) [%s]' % initial
maruel@chromium.org90541732011-04-01 17:54:18 +0000616 new_val = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000617 if new_val == 'x':
618 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
619 elif new_val and new_val != initial:
620 RunGit(['config', 'rietveld.' + name, new_val])
621
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000622 SetProperty(settings.GetDefaultCCList(), 'CC list', 'cc')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000623 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
624 'tree-status-url')
625 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
626
627 # TODO: configure a default branch to diff against, rather than this
628 # svn-based hackery.
629
630
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000631class ChangeDescription(object):
632 """Contains a parsed form of the change description."""
633 def __init__(self, subject, log_desc, reviewers):
634 self.subject = subject
635 self.log_desc = log_desc
636 self.reviewers = reviewers
637 self.description = self.log_desc
638
639 def Update(self):
640 initial_text = """# Enter a description of the change.
641# This will displayed on the codereview site.
642# The first line will also be used as the subject of the review.
643"""
644 initial_text += self.description
645 if 'R=' not in self.description and self.reviewers:
646 initial_text += '\nR=' + self.reviewers
647 if 'BUG=' not in self.description:
648 initial_text += '\nBUG='
649 if 'TEST=' not in self.description:
650 initial_text += '\nTEST='
651 self._ParseDescription(UserEditedLog(initial_text))
652
653 def _ParseDescription(self, description):
654 if not description:
655 self.description = description
656 return
657
658 parsed_lines = []
659 reviewers_regexp = re.compile('\s*R=(.+)')
660 reviewers = ''
661 subject = ''
662 for l in description.splitlines():
663 if not subject:
664 subject = l
665 matched_reviewers = reviewers_regexp.match(l)
666 if matched_reviewers:
667 reviewers = matched_reviewers.group(1)
668 parsed_lines.append(l)
669
670 self.description = '\n'.join(parsed_lines) + '\n'
671 self.subject = subject
672 self.reviewers = reviewers
673
674 def IsEmpty(self):
675 return not self.description
676
677
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000678def FindCodereviewSettingsFile(filename='codereview.settings'):
679 """Finds the given file starting in the cwd and going up.
680
681 Only looks up to the top of the repository unless an
682 'inherit-review-settings-ok' file exists in the root of the repository.
683 """
684 inherit_ok_file = 'inherit-review-settings-ok'
685 cwd = os.getcwd()
686 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
687 if os.path.isfile(os.path.join(root, inherit_ok_file)):
688 root = '/'
689 while True:
690 if filename in os.listdir(cwd):
691 if os.path.isfile(os.path.join(cwd, filename)):
692 return open(os.path.join(cwd, filename))
693 if cwd == root:
694 break
695 cwd = os.path.dirname(cwd)
696
697
698def LoadCodereviewSettingsFromFile(fileobj):
699 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000700 keyvals = {}
701 for line in fileobj.read().splitlines():
702 if not line or line.startswith("#"):
703 continue
704 k, v = line.split(": ", 1)
705 keyvals[k] = v
706
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000707 def SetProperty(name, setting, unset_error_ok=False):
708 fullname = 'rietveld.' + name
709 if setting in keyvals:
710 RunGit(['config', fullname, keyvals[setting]])
711 else:
712 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
713
714 SetProperty('server', 'CODE_REVIEW_SERVER')
715 # Only server setting is required. Other settings can be absent.
716 # In that case, we ignore errors raised during option deletion attempt.
717 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
718 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
719 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
720
721 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
722 #should be of the form
723 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
724 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
725 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
726 keyvals['ORIGIN_URL_CONFIG']])
727
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000728
729@usage('[repo root containing codereview.settings]')
730def CMDconfig(parser, args):
731 """edit configuration for this tree"""
732
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000733 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000734 if len(args) == 0:
735 GetCodereviewSettingsInteractively()
736 return 0
737
738 url = args[0]
739 if not url.endswith('codereview.settings'):
740 url = os.path.join(url, 'codereview.settings')
741
742 # Load code review settings and download hooks (if available).
743 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
744 return 0
745
746
747def CMDstatus(parser, args):
748 """show status of changelists"""
749 parser.add_option('--field',
750 help='print only specific field (desc|id|patch|url)')
751 (options, args) = parser.parse_args(args)
752
753 # TODO: maybe make show_branches a flag if necessary.
754 show_branches = not options.field
755
756 if show_branches:
757 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
758 if branches:
759 print 'Branches associated with reviews:'
760 for branch in sorted(branches.splitlines()):
761 cl = Changelist(branchref=branch)
762 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
763
764 cl = Changelist()
765 if options.field:
766 if options.field.startswith('desc'):
767 print cl.GetDescription()
768 elif options.field == 'id':
769 issueid = cl.GetIssue()
770 if issueid:
771 print issueid
772 elif options.field == 'patch':
773 patchset = cl.GetPatchset()
774 if patchset:
775 print patchset
776 elif options.field == 'url':
777 url = cl.GetIssueURL()
778 if url:
779 print url
780 else:
781 print
782 print 'Current branch:',
783 if not cl.GetIssue():
784 print 'no issue assigned.'
785 return 0
786 print cl.GetBranch()
787 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
788 print 'Issue description:'
789 print cl.GetDescription(pretty=True)
790 return 0
791
792
793@usage('[issue_number]')
794def CMDissue(parser, args):
795 """Set or display the current code review issue number.
796
797 Pass issue number 0 to clear the current issue.
798"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000799 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000800
801 cl = Changelist()
802 if len(args) > 0:
803 try:
804 issue = int(args[0])
805 except ValueError:
806 DieWithError('Pass a number to set the issue or none to list it.\n'
807 'Maybe you want to run git cl status?')
808 cl.SetIssue(issue)
809 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
810 return 0
811
812
813def CreateDescriptionFromLog(args):
814 """Pulls out the commit log to use as a base for the CL description."""
815 log_args = []
816 if len(args) == 1 and not args[0].endswith('.'):
817 log_args = [args[0] + '..']
818 elif len(args) == 1 and args[0].endswith('...'):
819 log_args = [args[0][:-1]]
820 elif len(args) == 2:
821 log_args = [args[0] + '..' + args[1]]
822 else:
823 log_args = args[:] # Hope for the best!
824 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
825
826
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000827def UserEditedLog(starting_text):
828 """Given some starting text, let the user edit it and return the result."""
829 editor = os.getenv('EDITOR', 'vi')
830
831 (file_handle, filename) = tempfile.mkstemp()
832 fileobj = os.fdopen(file_handle, 'w')
833 fileobj.write(starting_text)
834 fileobj.close()
835
836 # Open up the default editor in the system to get the CL description.
837 try:
838 cmd = '%s %s' % (editor, filename)
839 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
840 # Msysgit requires the usage of 'env' to be present.
841 cmd = 'env ' + cmd
842 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
maruel@chromium.org2a471072011-05-10 17:29:23 +0000843 try:
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +0000844 subprocess2.check_call(cmd, shell=True)
845 except subprocess2.CalledProcessError, e:
maruel@chromium.org2a471072011-05-10 17:29:23 +0000846 DieWithError('Editor returned %d' % e.returncode)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000847 fileobj = open(filename)
848 text = fileobj.read()
849 fileobj.close()
850 finally:
851 os.remove(filename)
852
853 if not text:
854 return
855
856 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
857 return stripcomment_re.sub('', text).strip()
858
859
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000860def ConvertToInteger(inputval):
861 """Convert a string to integer, but returns either an int or None."""
862 try:
863 return int(inputval)
864 except (TypeError, ValueError):
865 return None
866
867
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000868def CMDpresubmit(parser, args):
869 """run presubmit tests on the current changelist"""
870 parser.add_option('--upload', action='store_true',
871 help='Run upload hook instead of the push/dcommit hook')
872 (options, args) = parser.parse_args(args)
873
874 # Make sure index is up-to-date before running diff-index.
875 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
876 if RunGit(['diff-index', 'HEAD']):
877 # TODO(maruel): Is this really necessary?
878 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
879 return 1
880
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000881 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000882 if args:
883 base_branch = args[0]
884 else:
885 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000886 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000887
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000888 cl.RunHook(committing=not options.upload, upstream_branch=base_branch,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +0000889 may_prompt=False, verbose=options.verbose,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000890 author=None)
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000891 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000892
893
894@usage('[args to "git diff"]')
895def CMDupload(parser, args):
896 """upload the current changelist to codereview"""
897 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
898 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000899 parser.add_option('-f', action='store_true', dest='force',
900 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000901 parser.add_option('-m', dest='message', help='message for patch')
902 parser.add_option('-r', '--reviewers',
903 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000904 parser.add_option('--cc',
905 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000906 parser.add_option('--send-mail', action='store_true',
907 help='send email to reviewer immediately')
908 parser.add_option("--emulate_svn_auto_props", action="store_true",
909 dest="emulate_svn_auto_props",
910 help="Emulate Subversion's auto properties feature.")
911 parser.add_option("--desc_from_logs", action="store_true",
912 dest="from_logs",
913 help="""Squashes git commit logs into change description and
914 uses message as subject""")
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000915 parser.add_option('-c', '--use-commit-queue', action='store_true',
916 help='tell the commit queue to commit this patchset')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000917 (options, args) = parser.parse_args(args)
918
919 # Make sure index is up-to-date before running diff-index.
920 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
921 if RunGit(['diff-index', 'HEAD']):
922 print 'Cannot upload with a dirty tree. You must commit locally first.'
923 return 1
924
925 cl = Changelist()
926 if args:
927 base_branch = args[0]
928 else:
929 # Default to diffing against the "upstream" branch.
930 base_branch = cl.GetUpstreamBranch()
931 args = [base_branch + "..."]
932
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000933 if not options.bypass_hooks and not options.force:
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000934 hook_results = cl.RunHook(committing=False, upstream_branch=base_branch,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +0000935 may_prompt=True,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000936 verbose=options.verbose,
937 author=None)
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000938 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000939 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000940
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000941
942 # --no-ext-diff is broken in some versions of Git, so try to work around
943 # this by overriding the environment (but there is still a problem if the
944 # git config key "diff.external" is used).
945 env = os.environ.copy()
946 if 'GIT_EXTERNAL_DIFF' in env:
947 del env['GIT_EXTERNAL_DIFF']
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +0000948 subprocess2.call(
949 ['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args, env=env)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000950
951 upload_args = ['--assume_yes'] # Don't ask about untracked files.
952 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000953 if options.emulate_svn_auto_props:
954 upload_args.append('--emulate_svn_auto_props')
955 if options.send_mail:
956 if not options.reviewers:
957 DieWithError("Must specify reviewers to send email.")
958 upload_args.append('--send_mail')
959 if options.from_logs and not options.message:
960 print 'Must set message for subject line if using desc_from_logs'
961 return 1
962
963 change_desc = None
964
965 if cl.GetIssue():
966 if options.message:
967 upload_args.extend(['--message', options.message])
968 upload_args.extend(['--issue', cl.GetIssue()])
969 print ("This branch is associated with issue %s. "
970 "Adding patch to that issue." % cl.GetIssue())
971 else:
972 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000973 change_desc = ChangeDescription(options.message, log_desc,
974 options.reviewers)
975 if not options.from_logs:
976 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000977
978 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000979 print "Description is empty; aborting."
980 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000981
982 upload_args.extend(['--message', change_desc.subject])
983 upload_args.extend(['--description', change_desc.description])
984 if change_desc.reviewers:
985 upload_args.extend(['--reviewers', change_desc.reviewers])
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000986 cc = ','.join(filter(None, (cl.GetCCList(), options.cc)))
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000987 if cc:
988 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000989
990 # Include the upstream repo's URL in the change -- this is useful for
991 # projects that have their source spread across multiple repos.
992 remote_url = None
993 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000994 # URL is dependent on the current directory.
995 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000996 if data:
997 keys = dict(line.split(': ', 1) for line in data.splitlines()
998 if ': ' in line)
999 remote_url = keys.get('URL', None)
1000 else:
1001 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
1002 remote_url = (cl.GetRemoteUrl() + '@'
1003 + cl.GetUpstreamBranch().split('/')[-1])
1004 if remote_url:
1005 upload_args.extend(['--base_url', remote_url])
1006
1007 try:
1008 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
maruel@chromium.org9ce0dff2011-04-04 17:56:50 +00001009 except KeyboardInterrupt:
1010 sys.exit(1)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001011 except:
1012 # If we got an exception after the user typed a description for their
1013 # change, back up the description before re-raising.
1014 if change_desc:
1015 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
1016 print '\nGot exception while uploading -- saving description to %s\n' \
1017 % backup_path
1018 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001019 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001020 backup_file.close()
1021 raise
1022
1023 if not cl.GetIssue():
1024 cl.SetIssue(issue)
1025 cl.SetPatchset(patchset)
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001026
1027 if options.use_commit_queue:
1028 cl.SetFlag('commit', '1')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001029 return 0
1030
1031
1032def SendUpstream(parser, args, cmd):
1033 """Common code for CmdPush and CmdDCommit
1034
1035 Squashed commit into a single.
1036 Updates changelog with metadata (e.g. pointer to review).
1037 Pushes/dcommits the code upstream.
1038 Updates review and closes.
1039 """
1040 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1041 help='bypass upload presubmit hook')
1042 parser.add_option('-m', dest='message',
1043 help="override review description")
1044 parser.add_option('-f', action='store_true', dest='force',
1045 help="force yes to questions (don't prompt)")
1046 parser.add_option('-c', dest='contributor',
1047 help="external contributor for patch (appended to " +
1048 "description and used as author for git). Should be " +
1049 "formatted as 'First Last <email@example.com>'")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001050 (options, args) = parser.parse_args(args)
1051 cl = Changelist()
1052
1053 if not args or cmd == 'push':
1054 # Default to merging against our best guess of the upstream branch.
1055 args = [cl.GetUpstreamBranch()]
1056
maruel@chromium.org13f623c2011-07-22 16:02:23 +00001057 if options.contributor:
1058 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1059 print "Please provide contibutor as 'First Last <email@example.com>'"
1060 return 1
1061
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001062 base_branch = args[0]
1063
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001064 # Make sure index is up-to-date before running diff-index.
1065 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001066 if RunGit(['diff-index', 'HEAD']):
1067 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1068 return 1
1069
1070 # This rev-list syntax means "show all commits not in my branch that
1071 # are in base_branch".
1072 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1073 base_branch]).splitlines()
1074 if upstream_commits:
1075 print ('Base branch "%s" has %d commits '
1076 'not in this branch.' % (base_branch, len(upstream_commits)))
1077 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1078 return 1
1079
1080 if cmd == 'dcommit':
1081 # This is the revision `svn dcommit` will commit on top of.
1082 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1083 '--pretty=format:%H'])
1084 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1085 if extra_commits:
1086 print ('This branch has %d additional commits not upstreamed yet.'
1087 % len(extra_commits.splitlines()))
1088 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1089 'before attempting to %s.' % (base_branch, cmd))
1090 return 1
1091
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001092 if not options.bypass_hooks and not options.force:
maruel@chromium.org13f623c2011-07-22 16:02:23 +00001093 author = None
1094 if options.contributor:
1095 author = re.search(r'\<(.*)\>', options.contributor).group(1)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +00001096 cl.RunHook(committing=True, upstream_branch=base_branch,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001097 may_prompt=True, verbose=options.verbose,
maruel@chromium.org13f623c2011-07-22 16:02:23 +00001098 author=author)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001099
1100 if cmd == 'dcommit':
1101 # Check the tree status if the tree status URL is set.
1102 status = GetTreeStatus()
1103 if 'closed' == status:
1104 print ('The tree is closed. Please wait for it to reopen. Use '
1105 '"git cl dcommit -f" to commit on a closed tree.')
1106 return 1
1107 elif 'unknown' == status:
1108 print ('Unable to determine tree status. Please verify manually and '
1109 'use "git cl dcommit -f" to commit on a closed tree.')
1110
1111 description = options.message
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001112 if not description and cl.GetIssue():
1113 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001114
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001115 if not description:
1116 print 'No description set.'
1117 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1118 return 1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001119
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001120 if cl.GetIssue():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001121 description += "\n\nReview URL: %s" % cl.GetIssueURL()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001122
1123 if options.contributor:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001124 description += "\nPatch from %s." % options.contributor
1125 print 'Description:', repr(description)
1126
1127 branches = [base_branch, cl.GetBranchRef()]
1128 if not options.force:
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +00001129 subprocess2.call(['git', 'diff', '--stat'] + branches)
maruel@chromium.org90541732011-04-01 17:54:18 +00001130 ask_for_data('About to commit; enter to confirm.')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001131
1132 # We want to squash all this branch's commits into one commit with the
1133 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001134 # We do this by doing a "reset --soft" to the base branch (which keeps
1135 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001136 MERGE_BRANCH = 'git-cl-commit'
1137 # Delete the merge branch if it already exists.
1138 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1139 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1140 RunGit(['branch', '-D', MERGE_BRANCH])
1141
1142 # We might be in a directory that's present in this branch but not in the
1143 # trunk. Move up to the top of the tree so that git commands that expect a
1144 # valid CWD won't fail after we check out the merge branch.
1145 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1146 if rel_base_path:
1147 os.chdir(rel_base_path)
1148
1149 # Stuff our change into the merge branch.
1150 # We wrap in a try...finally block so if anything goes wrong,
1151 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001152 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001153 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001154 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1155 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001156 if options.contributor:
1157 RunGit(['commit', '--author', options.contributor, '-m', description])
1158 else:
1159 RunGit(['commit', '-m', description])
1160 if cmd == 'push':
1161 # push the merge branch.
1162 remote, branch = cl.FetchUpstreamTuple()
1163 retcode, output = RunGitWithCode(
1164 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1165 logging.debug(output)
1166 else:
1167 # dcommit the merge branch.
bauerb@chromium.org2e64fa12011-05-05 11:13:44 +00001168 retcode, output = RunGitWithCode(['svn', 'dcommit',
1169 '--no-rebase', '--rmdir'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001170 finally:
1171 # And then swap back to the original branch and clean up.
1172 RunGit(['checkout', '-q', cl.GetBranch()])
1173 RunGit(['branch', '-D', MERGE_BRANCH])
1174
1175 if cl.GetIssue():
1176 if cmd == 'dcommit' and 'Committed r' in output:
1177 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1178 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001179 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1180 for l in output.splitlines(False))
1181 match = filter(None, match)
1182 if len(match) != 1:
1183 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1184 output)
1185 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001186 else:
1187 return 1
1188 viewvc_url = settings.GetViewVCUrl()
1189 if viewvc_url and revision:
1190 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1191 print ('Closing issue '
1192 '(you may be prompted for your codereview password)...')
1193 cl.CloseIssue()
1194 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001195
1196 if retcode == 0:
1197 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1198 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001199 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001200
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001201 return 0
1202
1203
1204@usage('[upstream branch to apply against]')
1205def CMDdcommit(parser, args):
1206 """commit the current changelist via git-svn"""
1207 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001208 message = """This doesn't appear to be an SVN repository.
1209If your project has a git mirror with an upstream SVN master, you probably need
1210to run 'git svn init', see your project's git mirror documentation.
1211If your project has a true writeable upstream repository, you probably want
1212to run 'git cl push' instead.
1213Choose wisely, if you get this wrong, your commit might appear to succeed but
1214will instead be silently ignored."""
1215 print(message)
maruel@chromium.org90541732011-04-01 17:54:18 +00001216 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001217 return SendUpstream(parser, args, 'dcommit')
1218
1219
1220@usage('[upstream branch to apply against]')
1221def CMDpush(parser, args):
1222 """commit the current changelist via git"""
1223 if settings.GetIsGitSvn():
1224 print('This appears to be an SVN repository.')
1225 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
maruel@chromium.org90541732011-04-01 17:54:18 +00001226 ask_for_data('[Press enter to push or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001227 return SendUpstream(parser, args, 'push')
1228
1229
1230@usage('<patch url or issue id>')
1231def CMDpatch(parser, args):
1232 """patch in a code review"""
1233 parser.add_option('-b', dest='newbranch',
1234 help='create a new branch off trunk for the patch')
1235 parser.add_option('-f', action='store_true', dest='force',
1236 help='with -b, clobber any existing branch')
1237 parser.add_option('--reject', action='store_true', dest='reject',
1238 help='allow failed patches and spew .rej files')
1239 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1240 help="don't commit after patch applies")
1241 (options, args) = parser.parse_args(args)
1242 if len(args) != 1:
1243 parser.print_help()
1244 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001245 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001246
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001247 # TODO(maruel): Use apply_issue.py
1248
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001249 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001250 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001251 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001252 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001253 else:
1254 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001255 issue_url = FixUrl(issue_arg)
1256 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001257 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001258 DieWithError('Must pass an issue ID or full URL for '
1259 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001260 issue = match.group(1)
1261 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001262
1263 if options.newbranch:
1264 if options.force:
1265 RunGit(['branch', '-D', options.newbranch],
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +00001266 stderr=subprocess2.PIPE, error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001267 RunGit(['checkout', '-b', options.newbranch,
1268 Changelist().GetUpstreamBranch()])
1269
1270 # Switch up to the top-level directory, if necessary, in preparation for
1271 # applying the patch.
1272 top = RunGit(['rev-parse', '--show-cdup']).strip()
1273 if top:
1274 os.chdir(top)
1275
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001276 # Git patches have a/ at the beginning of source paths. We strip that out
1277 # with a sed script rather than the -p flag to patch so we can feed either
1278 # Git or svn-style patches into the same apply command.
1279 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +00001280 try:
1281 patch_data = subprocess2.check_output(
1282 ['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'], stdin=patch_data)
1283 except subprocess2.CalledProcessError:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001284 DieWithError('Git patch mungling failed.')
1285 logging.info(patch_data)
1286 # We use "git apply" to apply the patch instead of "patch" so that we can
1287 # pick up file adds.
1288 # The --index flag means: also insert into the index (so we catch adds).
1289 cmd = ['git', 'apply', '--index', '-p0']
1290 if options.reject:
1291 cmd.append('--reject')
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +00001292 try:
1293 subprocess2.check_call(cmd, stdin=patch_data, stdout=subprocess2.VOID)
1294 except subprocess2.CalledProcessError:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001295 DieWithError('Failed to apply the patch')
1296
1297 # If we had an issue, commit the current state and register the issue.
1298 if not options.nocommit:
1299 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1300 cl = Changelist()
1301 cl.SetIssue(issue)
1302 print "Committed patch."
1303 else:
1304 print "Patch applied to index."
1305 return 0
1306
1307
1308def CMDrebase(parser, args):
1309 """rebase current branch on top of svn repo"""
1310 # Provide a wrapper for git svn rebase to help avoid accidental
1311 # git svn dcommit.
1312 # It's the only command that doesn't use parser at all since we just defer
1313 # execution to git-svn.
maruel@chromium.org75075572011-10-10 19:55:28 +00001314 return subprocess2.call(['git', 'svn', 'rebase'] + args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001315
1316
1317def GetTreeStatus():
1318 """Fetches the tree status and returns either 'open', 'closed',
1319 'unknown' or 'unset'."""
1320 url = settings.GetTreeStatusUrl(error_ok=True)
1321 if url:
1322 status = urllib2.urlopen(url).read().lower()
1323 if status.find('closed') != -1 or status == '0':
1324 return 'closed'
1325 elif status.find('open') != -1 or status == '1':
1326 return 'open'
1327 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001328 return 'unset'
1329
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001330
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001331def GetTreeStatusReason():
1332 """Fetches the tree status from a json url and returns the message
1333 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001334 url = settings.GetTreeStatusUrl()
1335 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001336 connection = urllib2.urlopen(json_url)
1337 status = json.loads(connection.read())
1338 connection.close()
1339 return status['message']
1340
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001341
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001342def CMDtree(parser, args):
1343 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001344 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001345 status = GetTreeStatus()
1346 if 'unset' == status:
1347 print 'You must configure your tree status URL by running "git cl config".'
1348 return 2
1349
1350 print "The tree is %s" % status
1351 print
1352 print GetTreeStatusReason()
1353 if status != 'open':
1354 return 1
1355 return 0
1356
1357
1358def CMDupstream(parser, args):
1359 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001360 _, args = parser.parse_args(args)
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001361 if args:
1362 parser.error('Unrecognized args: %s' % ' '.join(args))
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001363 cl = Changelist()
1364 print cl.GetUpstreamBranch()
1365 return 0
1366
1367
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001368def CMDset_commit(parser, args):
1369 """set the commit bit"""
1370 _, args = parser.parse_args(args)
1371 if args:
1372 parser.error('Unrecognized args: %s' % ' '.join(args))
1373 cl = Changelist()
1374 cl.SetFlag('commit', '1')
1375 return 0
1376
1377
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001378def Command(name):
1379 return getattr(sys.modules[__name__], 'CMD' + name, None)
1380
1381
1382def CMDhelp(parser, args):
1383 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001384 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001385 if len(args) == 1:
1386 return main(args + ['--help'])
1387 parser.print_help()
1388 return 0
1389
1390
1391def GenUsage(parser, command):
1392 """Modify an OptParse object with the function's documentation."""
1393 obj = Command(command)
1394 more = getattr(obj, 'usage_more', '')
1395 if command == 'help':
1396 command = '<command>'
1397 else:
1398 # OptParser.description prefer nicely non-formatted strings.
1399 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1400 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1401
1402
1403def main(argv):
1404 """Doesn't parse the arguments here, just find the right subcommand to
1405 execute."""
1406 # Do it late so all commands are listed.
1407 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1408 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1409 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1410
1411 # Create the option parse and add --verbose support.
1412 parser = optparse.OptionParser()
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001413 parser.add_option(
1414 '-v', '--verbose', action='count', default=0,
1415 help='Use 2 times for more debugging info')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001416 old_parser_args = parser.parse_args
1417 def Parse(args):
1418 options, args = old_parser_args(args)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001419 if options.verbose >= 2:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001420 logging.basicConfig(level=logging.DEBUG)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001421 elif options.verbose:
1422 logging.basicConfig(level=logging.INFO)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001423 else:
1424 logging.basicConfig(level=logging.WARNING)
1425 return options, args
1426 parser.parse_args = Parse
1427
1428 if argv:
1429 command = Command(argv[0])
1430 if command:
1431 # "fix" the usage and the description now that we know the subcommand.
1432 GenUsage(parser, argv[0])
1433 try:
1434 return command(parser, argv[1:])
1435 except urllib2.HTTPError, e:
1436 if e.code != 500:
1437 raise
1438 DieWithError(
1439 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1440 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1441
1442 # Not a known command. Default to help.
1443 GenUsage(parser, 'help')
1444 return CMDhelp(parser, argv)
1445
1446
1447if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001448 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001449 sys.exit(main(sys.argv[1:]))