blob: aff809ed9bdcb4623b2e54167757802ce16132a8 [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
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000015import textwrap
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +000016import urlparse
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000017import urllib2
18
19try:
maruel@chromium.orgc98c0c52011-04-06 13:39:43 +000020 import readline # pylint: disable=F0401,W0611
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000021except ImportError:
22 pass
23
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000024try:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000025 import simplejson as json # pylint: disable=F0401
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000026except ImportError:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000027 try:
maruel@chromium.org725f1c32011-04-01 20:24:54 +000028 import json # pylint: disable=F0401
maruel@chromium.org2a74d372011-03-29 19:05:50 +000029 except ImportError:
30 # Fall back to the packaged version.
maruel@chromium.orgb35c00c2011-03-31 00:43:35 +000031 sys.path.append(os.path.join(os.path.dirname(__file__), 'third_party'))
dpranke@chromium.orgfe79c312011-04-01 20:15:52 +000032 import simplejson as json # pylint: disable=F0401
maruel@chromium.org2a74d372011-03-29 19:05:50 +000033
34
35from third_party import upload
36import breakpad # pylint: disable=W0611
maruel@chromium.org6f09cd92011-04-01 16:38:12 +000037import fix_encoding
maruel@chromium.org0e0436a2011-10-25 13:32:41 +000038import gclient_utils
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
maruel@chromium.orgddd59412011-11-30 14:20:38 +000051# Initialized in main()
52settings = None
53
54
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000055def DieWithError(message):
dpranke@chromium.org970c5222011-03-12 00:32:24 +000056 print >> sys.stderr, message
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000057 sys.exit(1)
58
59
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000060def RunCommand(args, error_ok=False, error_message=None, **kwargs):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000061 try:
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000062 return subprocess2.check_output(args, shell=False, **kwargs)
63 except subprocess2.CalledProcessError, e:
64 if not error_ok:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000065 DieWithError(
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000066 'Command "%s" failed.\n%s' % (
67 ' '.join(args), error_message or e.stdout or ''))
68 return e.stdout
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000069
70
71def RunGit(args, **kwargs):
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000072 """Returns stdout."""
73 return RunCommand(['git'] + args, **kwargs)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000074
75
76def RunGitWithCode(args):
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000077 """Returns return code and stdout."""
78 out, code = subprocess2.communicate(['git'] + args, stdout=subprocess2.PIPE)
79 return code, out[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000080
81
82def usage(more):
83 def hook(fn):
84 fn.usage_more = more
85 return fn
86 return hook
87
88
maruel@chromium.org90541732011-04-01 17:54:18 +000089def ask_for_data(prompt):
90 try:
91 return raw_input(prompt)
92 except KeyboardInterrupt:
93 # Hide the exception.
94 sys.exit(1)
95
96
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000097def FixUrl(server):
98 """Fix a server url to defaults protocol to http:// if none is specified."""
99 if not server:
100 return server
101 if not re.match(r'[a-z]+\://.*', server):
102 return 'http://' + server
103 return server
104
105
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000106def MatchSvnGlob(url, base_url, glob_spec, allow_wildcards):
107 """Return the corresponding git ref if |base_url| together with |glob_spec|
108 matches the full |url|.
109
110 If |allow_wildcards| is true, |glob_spec| can contain wildcards (see below).
111 """
112 fetch_suburl, as_ref = glob_spec.split(':')
113 if allow_wildcards:
114 glob_match = re.match('(.+/)?(\*|{[^/]*})(/.+)?', fetch_suburl)
115 if glob_match:
116 # Parse specs like "branches/*/src:refs/remotes/svn/*" or
117 # "branches/{472,597,648}/src:refs/remotes/svn/*".
118 branch_re = re.escape(base_url)
119 if glob_match.group(1):
120 branch_re += '/' + re.escape(glob_match.group(1))
121 wildcard = glob_match.group(2)
122 if wildcard == '*':
123 branch_re += '([^/]*)'
124 else:
125 # Escape and replace surrounding braces with parentheses and commas
126 # with pipe symbols.
127 wildcard = re.escape(wildcard)
128 wildcard = re.sub('^\\\\{', '(', wildcard)
129 wildcard = re.sub('\\\\,', '|', wildcard)
130 wildcard = re.sub('\\\\}$', ')', wildcard)
131 branch_re += wildcard
132 if glob_match.group(3):
133 branch_re += re.escape(glob_match.group(3))
134 match = re.match(branch_re, url)
135 if match:
136 return re.sub('\*$', match.group(1), as_ref)
137
138 # Parse specs like "trunk/src:refs/remotes/origin/trunk".
139 if fetch_suburl:
140 full_url = base_url + '/' + fetch_suburl
141 else:
142 full_url = base_url
143 if full_url == url:
144 return as_ref
145 return None
146
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +0000147
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000148class Settings(object):
149 def __init__(self):
150 self.default_server = None
151 self.cc = None
152 self.root = None
153 self.is_git_svn = None
154 self.svn_branch = None
155 self.tree_status_url = None
156 self.viewvc_url = None
157 self.updated = False
maruel@chromium.orgddd59412011-11-30 14:20:38 +0000158 self.did_migrate_check = False
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000159
160 def LazyUpdateIfNeeded(self):
161 """Updates the settings from a codereview.settings file, if available."""
162 if not self.updated:
163 cr_settings_file = FindCodereviewSettingsFile()
164 if cr_settings_file:
165 LoadCodereviewSettingsFromFile(cr_settings_file)
166 self.updated = True
167
168 def GetDefaultServerUrl(self, error_ok=False):
169 if not self.default_server:
170 self.LazyUpdateIfNeeded()
171 self.default_server = FixUrl(self._GetConfig('rietveld.server',
172 error_ok=True))
173 if error_ok:
174 return self.default_server
175 if not self.default_server:
176 error_message = ('Could not find settings file. You must configure '
177 'your review setup by running "git cl config".')
178 self.default_server = FixUrl(self._GetConfig(
179 'rietveld.server', error_message=error_message))
180 return self.default_server
181
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000182 def GetRoot(self):
183 if not self.root:
184 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
185 return self.root
186
187 def GetIsGitSvn(self):
188 """Return true if this repo looks like it's using git-svn."""
189 if self.is_git_svn is None:
190 # If you have any "svn-remote.*" config keys, we think you're using svn.
191 self.is_git_svn = RunGitWithCode(
192 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
193 return self.is_git_svn
194
195 def GetSVNBranch(self):
196 if self.svn_branch is None:
197 if not self.GetIsGitSvn():
198 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
199
200 # Try to figure out which remote branch we're based on.
201 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000202 # 1) iterate through our branch history and find the svn URL.
203 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000204
205 # regexp matching the git-svn line that contains the URL.
206 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
207
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000208 # We don't want to go through all of history, so read a line from the
209 # pipe at a time.
210 # The -100 is an arbitrary limit so we don't search forever.
211 cmd = ['git', 'log', '-100', '--pretty=medium']
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +0000212 proc = subprocess2.Popen(cmd, stdout=subprocess2.PIPE)
maruel@chromium.org740f9d72011-06-10 18:33:10 +0000213 url = None
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000214 for line in proc.stdout:
215 match = git_svn_re.match(line)
216 if match:
217 url = match.group(1)
218 proc.stdout.close() # Cut pipe.
219 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000220
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000221 if url:
222 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
223 remotes = RunGit(['config', '--get-regexp',
224 r'^svn-remote\..*\.url']).splitlines()
225 for remote in remotes:
226 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000227 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000228 remote = match.group(1)
229 base_url = match.group(2)
230 fetch_spec = RunGit(
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000231 ['config', 'svn-remote.%s.fetch' % remote],
232 error_ok=True).strip()
233 if fetch_spec:
234 self.svn_branch = MatchSvnGlob(url, base_url, fetch_spec, False)
235 if self.svn_branch:
236 break
237 branch_spec = RunGit(
238 ['config', 'svn-remote.%s.branches' % remote],
239 error_ok=True).strip()
240 if branch_spec:
241 self.svn_branch = MatchSvnGlob(url, base_url, branch_spec, True)
242 if self.svn_branch:
243 break
244 tag_spec = RunGit(
245 ['config', 'svn-remote.%s.tags' % remote],
246 error_ok=True).strip()
247 if tag_spec:
248 self.svn_branch = MatchSvnGlob(url, base_url, tag_spec, True)
249 if self.svn_branch:
250 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000251
252 if not self.svn_branch:
253 DieWithError('Can\'t guess svn branch -- try specifying it on the '
254 'command line')
255
256 return self.svn_branch
257
258 def GetTreeStatusUrl(self, error_ok=False):
259 if not self.tree_status_url:
260 error_message = ('You must configure your tree status URL by running '
261 '"git cl config".')
262 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
263 error_ok=error_ok,
264 error_message=error_message)
265 return self.tree_status_url
266
267 def GetViewVCUrl(self):
268 if not self.viewvc_url:
269 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
270 return self.viewvc_url
271
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000272 def GetDefaultCCList(self):
273 return self._GetConfig('rietveld.cc', error_ok=True)
274
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000275 def _GetConfig(self, param, **kwargs):
276 self.LazyUpdateIfNeeded()
277 return RunGit(['config', param], **kwargs).strip()
278
279
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000280def CheckForMigration():
281 """Migrate from the old issue format, if found.
282
283 We used to store the branch<->issue mapping in a file in .git, but it's
284 better to store it in the .git/config, since deleting a branch deletes that
285 branch's entry there.
286 """
287
288 # Don't run more than once.
maruel@chromium.orgddd59412011-11-30 14:20:38 +0000289 if settings.did_migrate_check:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000290 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)
maruel@chromium.orgddd59412011-11-30 14:20:38 +0000303 settings.did_migrate_check = True
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000304
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
maruel@chromium.orgddd59412011-11-30 14:20:38 +0000645 if ('\nR=' not in self.description and
646 '\nTBR=' not in self.description and
647 self.reviewers):
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000648 initial_text += '\nR=' + self.reviewers
maruel@chromium.orgddd59412011-11-30 14:20:38 +0000649 if '\nBUG=' not in self.description:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000650 initial_text += '\nBUG='
maruel@chromium.orgddd59412011-11-30 14:20:38 +0000651 if '\nTEST=' not in self.description:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000652 initial_text += '\nTEST='
maruel@chromium.orgddd59412011-11-30 14:20:38 +0000653 initial_text = initial_text.rstrip('\n') + '\n'
maruel@chromium.org0e0436a2011-10-25 13:32:41 +0000654 content = gclient_utils.RunEditor(initial_text, True)
655 if not content:
656 DieWithError('Running editor failed')
657 content = re.compile(r'^#.*$', re.MULTILINE).sub('', content).strip()
658 if not content:
659 DieWithError('No CL description, aborting')
660 self._ParseDescription(content)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000661
662 def _ParseDescription(self, description):
maruel@chromium.orgddd59412011-11-30 14:20:38 +0000663 """Updates the list of reviewers and subject from the description."""
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000664 if not description:
665 self.description = description
666 return
667
maruel@chromium.orgddd59412011-11-30 14:20:38 +0000668 self.description = description.strip('\n') + '\n'
669 self.subject = description.split('\n', 1)[0]
670 # Retrieves all reviewer lines
671 regexp = re.compile(r'^\s*(TBR|R)=(.+)$', re.MULTILINE)
672 self.reviewers = ','.join(
673 i.group(2).strip() for i in regexp.finditer(self.description))
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000674
675 def IsEmpty(self):
676 return not self.description
677
678
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000679def FindCodereviewSettingsFile(filename='codereview.settings'):
680 """Finds the given file starting in the cwd and going up.
681
682 Only looks up to the top of the repository unless an
683 'inherit-review-settings-ok' file exists in the root of the repository.
684 """
685 inherit_ok_file = 'inherit-review-settings-ok'
686 cwd = os.getcwd()
687 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
688 if os.path.isfile(os.path.join(root, inherit_ok_file)):
689 root = '/'
690 while True:
691 if filename in os.listdir(cwd):
692 if os.path.isfile(os.path.join(cwd, filename)):
693 return open(os.path.join(cwd, filename))
694 if cwd == root:
695 break
696 cwd = os.path.dirname(cwd)
697
698
699def LoadCodereviewSettingsFromFile(fileobj):
700 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000701 keyvals = {}
702 for line in fileobj.read().splitlines():
703 if not line or line.startswith("#"):
704 continue
705 k, v = line.split(": ", 1)
706 keyvals[k] = v
707
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000708 def SetProperty(name, setting, unset_error_ok=False):
709 fullname = 'rietveld.' + name
710 if setting in keyvals:
711 RunGit(['config', fullname, keyvals[setting]])
712 else:
713 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
714
715 SetProperty('server', 'CODE_REVIEW_SERVER')
716 # Only server setting is required. Other settings can be absent.
717 # In that case, we ignore errors raised during option deletion attempt.
718 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
719 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
720 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
721
722 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
723 #should be of the form
724 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
725 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
726 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
727 keyvals['ORIGIN_URL_CONFIG']])
728
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000729
730@usage('[repo root containing codereview.settings]')
731def CMDconfig(parser, args):
732 """edit configuration for this tree"""
733
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000734 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000735 if len(args) == 0:
736 GetCodereviewSettingsInteractively()
737 return 0
738
739 url = args[0]
740 if not url.endswith('codereview.settings'):
741 url = os.path.join(url, 'codereview.settings')
742
743 # Load code review settings and download hooks (if available).
744 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
745 return 0
746
747
748def CMDstatus(parser, args):
749 """show status of changelists"""
750 parser.add_option('--field',
751 help='print only specific field (desc|id|patch|url)')
752 (options, args) = parser.parse_args(args)
753
754 # TODO: maybe make show_branches a flag if necessary.
755 show_branches = not options.field
756
757 if show_branches:
758 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
759 if branches:
760 print 'Branches associated with reviews:'
761 for branch in sorted(branches.splitlines()):
762 cl = Changelist(branchref=branch)
763 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
764
765 cl = Changelist()
766 if options.field:
767 if options.field.startswith('desc'):
768 print cl.GetDescription()
769 elif options.field == 'id':
770 issueid = cl.GetIssue()
771 if issueid:
772 print issueid
773 elif options.field == 'patch':
774 patchset = cl.GetPatchset()
775 if patchset:
776 print patchset
777 elif options.field == 'url':
778 url = cl.GetIssueURL()
779 if url:
780 print url
781 else:
782 print
783 print 'Current branch:',
784 if not cl.GetIssue():
785 print 'no issue assigned.'
786 return 0
787 print cl.GetBranch()
788 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
789 print 'Issue description:'
790 print cl.GetDescription(pretty=True)
791 return 0
792
793
794@usage('[issue_number]')
795def CMDissue(parser, args):
796 """Set or display the current code review issue number.
797
798 Pass issue number 0 to clear the current issue.
799"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000800 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000801
802 cl = Changelist()
803 if len(args) > 0:
804 try:
805 issue = int(args[0])
806 except ValueError:
807 DieWithError('Pass a number to set the issue or none to list it.\n'
808 'Maybe you want to run git cl status?')
809 cl.SetIssue(issue)
810 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
811 return 0
812
813
814def CreateDescriptionFromLog(args):
815 """Pulls out the commit log to use as a base for the CL description."""
816 log_args = []
817 if len(args) == 1 and not args[0].endswith('.'):
818 log_args = [args[0] + '..']
819 elif len(args) == 1 and args[0].endswith('...'):
820 log_args = [args[0][:-1]]
821 elif len(args) == 2:
822 log_args = [args[0] + '..' + args[1]]
823 else:
824 log_args = args[:] # Hope for the best!
825 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
826
827
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000828def ConvertToInteger(inputval):
829 """Convert a string to integer, but returns either an int or None."""
830 try:
831 return int(inputval)
832 except (TypeError, ValueError):
833 return None
834
835
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000836def CMDpresubmit(parser, args):
837 """run presubmit tests on the current changelist"""
838 parser.add_option('--upload', action='store_true',
839 help='Run upload hook instead of the push/dcommit hook')
840 (options, args) = parser.parse_args(args)
841
842 # Make sure index is up-to-date before running diff-index.
843 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
844 if RunGit(['diff-index', 'HEAD']):
845 # TODO(maruel): Is this really necessary?
846 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
847 return 1
848
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000849 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000850 if args:
851 base_branch = args[0]
852 else:
853 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000854 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000855
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000856 cl.RunHook(committing=not options.upload, upstream_branch=base_branch,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +0000857 may_prompt=False, verbose=options.verbose,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000858 author=None)
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000859 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000860
861
862@usage('[args to "git diff"]')
863def CMDupload(parser, args):
864 """upload the current changelist to codereview"""
865 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
866 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000867 parser.add_option('-f', action='store_true', dest='force',
868 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000869 parser.add_option('-m', dest='message', help='message for patch')
870 parser.add_option('-r', '--reviewers',
871 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000872 parser.add_option('--cc',
873 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000874 parser.add_option('--send-mail', action='store_true',
875 help='send email to reviewer immediately')
876 parser.add_option("--emulate_svn_auto_props", action="store_true",
877 dest="emulate_svn_auto_props",
878 help="Emulate Subversion's auto properties feature.")
879 parser.add_option("--desc_from_logs", action="store_true",
880 dest="from_logs",
881 help="""Squashes git commit logs into change description and
882 uses message as subject""")
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000883 parser.add_option('-c', '--use-commit-queue', action='store_true',
884 help='tell the commit queue to commit this patchset')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000885 (options, args) = parser.parse_args(args)
886
887 # Make sure index is up-to-date before running diff-index.
888 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
889 if RunGit(['diff-index', 'HEAD']):
890 print 'Cannot upload with a dirty tree. You must commit locally first.'
891 return 1
892
893 cl = Changelist()
894 if args:
895 base_branch = args[0]
896 else:
897 # Default to diffing against the "upstream" branch.
898 base_branch = cl.GetUpstreamBranch()
899 args = [base_branch + "..."]
900
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000901 if not options.bypass_hooks and not options.force:
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000902 hook_results = cl.RunHook(committing=False, upstream_branch=base_branch,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +0000903 may_prompt=True,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000904 verbose=options.verbose,
905 author=None)
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000906 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000907 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000908
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000909
910 # --no-ext-diff is broken in some versions of Git, so try to work around
911 # this by overriding the environment (but there is still a problem if the
912 # git config key "diff.external" is used).
913 env = os.environ.copy()
914 if 'GIT_EXTERNAL_DIFF' in env:
915 del env['GIT_EXTERNAL_DIFF']
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +0000916 subprocess2.call(
917 ['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args, env=env)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000918
919 upload_args = ['--assume_yes'] # Don't ask about untracked files.
920 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000921 if options.emulate_svn_auto_props:
922 upload_args.append('--emulate_svn_auto_props')
923 if options.send_mail:
924 if not options.reviewers:
925 DieWithError("Must specify reviewers to send email.")
926 upload_args.append('--send_mail')
927 if options.from_logs and not options.message:
928 print 'Must set message for subject line if using desc_from_logs'
929 return 1
930
931 change_desc = None
932
933 if cl.GetIssue():
934 if options.message:
935 upload_args.extend(['--message', options.message])
936 upload_args.extend(['--issue', cl.GetIssue()])
937 print ("This branch is associated with issue %s. "
938 "Adding patch to that issue." % cl.GetIssue())
939 else:
940 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000941 change_desc = ChangeDescription(options.message, log_desc,
942 options.reviewers)
943 if not options.from_logs:
944 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000945
946 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000947 print "Description is empty; aborting."
948 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000949
950 upload_args.extend(['--message', change_desc.subject])
951 upload_args.extend(['--description', change_desc.description])
952 if change_desc.reviewers:
953 upload_args.extend(['--reviewers', change_desc.reviewers])
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000954 cc = ','.join(filter(None, (cl.GetCCList(), options.cc)))
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000955 if cc:
956 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000957
958 # Include the upstream repo's URL in the change -- this is useful for
959 # projects that have their source spread across multiple repos.
960 remote_url = None
961 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000962 # URL is dependent on the current directory.
963 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000964 if data:
965 keys = dict(line.split(': ', 1) for line in data.splitlines()
966 if ': ' in line)
967 remote_url = keys.get('URL', None)
968 else:
969 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
970 remote_url = (cl.GetRemoteUrl() + '@'
971 + cl.GetUpstreamBranch().split('/')[-1])
972 if remote_url:
973 upload_args.extend(['--base_url', remote_url])
974
975 try:
976 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
maruel@chromium.org9ce0dff2011-04-04 17:56:50 +0000977 except KeyboardInterrupt:
978 sys.exit(1)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000979 except:
980 # If we got an exception after the user typed a description for their
981 # change, back up the description before re-raising.
982 if change_desc:
983 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
984 print '\nGot exception while uploading -- saving description to %s\n' \
985 % backup_path
986 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000987 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000988 backup_file.close()
989 raise
990
991 if not cl.GetIssue():
992 cl.SetIssue(issue)
993 cl.SetPatchset(patchset)
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000994
995 if options.use_commit_queue:
996 cl.SetFlag('commit', '1')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000997 return 0
998
999
1000def SendUpstream(parser, args, cmd):
1001 """Common code for CmdPush and CmdDCommit
1002
1003 Squashed commit into a single.
1004 Updates changelog with metadata (e.g. pointer to review).
1005 Pushes/dcommits the code upstream.
1006 Updates review and closes.
1007 """
1008 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1009 help='bypass upload presubmit hook')
1010 parser.add_option('-m', dest='message',
1011 help="override review description")
1012 parser.add_option('-f', action='store_true', dest='force',
1013 help="force yes to questions (don't prompt)")
1014 parser.add_option('-c', dest='contributor',
1015 help="external contributor for patch (appended to " +
1016 "description and used as author for git). Should be " +
1017 "formatted as 'First Last <email@example.com>'")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001018 (options, args) = parser.parse_args(args)
1019 cl = Changelist()
1020
1021 if not args or cmd == 'push':
1022 # Default to merging against our best guess of the upstream branch.
1023 args = [cl.GetUpstreamBranch()]
1024
maruel@chromium.org13f623c2011-07-22 16:02:23 +00001025 if options.contributor:
1026 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1027 print "Please provide contibutor as 'First Last <email@example.com>'"
1028 return 1
1029
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001030 base_branch = args[0]
1031
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001032 # Make sure index is up-to-date before running diff-index.
1033 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001034 if RunGit(['diff-index', 'HEAD']):
1035 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1036 return 1
1037
1038 # This rev-list syntax means "show all commits not in my branch that
1039 # are in base_branch".
1040 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1041 base_branch]).splitlines()
1042 if upstream_commits:
1043 print ('Base branch "%s" has %d commits '
1044 'not in this branch.' % (base_branch, len(upstream_commits)))
1045 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1046 return 1
1047
1048 if cmd == 'dcommit':
1049 # This is the revision `svn dcommit` will commit on top of.
1050 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1051 '--pretty=format:%H'])
1052 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1053 if extra_commits:
1054 print ('This branch has %d additional commits not upstreamed yet.'
1055 % len(extra_commits.splitlines()))
1056 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1057 'before attempting to %s.' % (base_branch, cmd))
1058 return 1
1059
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001060 if not options.bypass_hooks and not options.force:
maruel@chromium.org13f623c2011-07-22 16:02:23 +00001061 author = None
1062 if options.contributor:
1063 author = re.search(r'\<(.*)\>', options.contributor).group(1)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +00001064 cl.RunHook(committing=True, upstream_branch=base_branch,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001065 may_prompt=True, verbose=options.verbose,
maruel@chromium.org13f623c2011-07-22 16:02:23 +00001066 author=author)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001067
1068 if cmd == 'dcommit':
1069 # Check the tree status if the tree status URL is set.
1070 status = GetTreeStatus()
1071 if 'closed' == status:
1072 print ('The tree is closed. Please wait for it to reopen. Use '
1073 '"git cl dcommit -f" to commit on a closed tree.')
1074 return 1
1075 elif 'unknown' == status:
1076 print ('Unable to determine tree status. Please verify manually and '
1077 'use "git cl dcommit -f" to commit on a closed tree.')
1078
1079 description = options.message
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001080 if not description and cl.GetIssue():
1081 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001082
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001083 if not description:
1084 print 'No description set.'
1085 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1086 return 1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001087
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001088 if cl.GetIssue():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001089 description += "\n\nReview URL: %s" % cl.GetIssueURL()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001090
1091 if options.contributor:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001092 description += "\nPatch from %s." % options.contributor
1093 print 'Description:', repr(description)
1094
1095 branches = [base_branch, cl.GetBranchRef()]
1096 if not options.force:
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +00001097 subprocess2.call(['git', 'diff', '--stat'] + branches)
maruel@chromium.org90541732011-04-01 17:54:18 +00001098 ask_for_data('About to commit; enter to confirm.')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001099
1100 # We want to squash all this branch's commits into one commit with the
1101 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001102 # We do this by doing a "reset --soft" to the base branch (which keeps
1103 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001104 MERGE_BRANCH = 'git-cl-commit'
1105 # Delete the merge branch if it already exists.
1106 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1107 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1108 RunGit(['branch', '-D', MERGE_BRANCH])
1109
1110 # We might be in a directory that's present in this branch but not in the
1111 # trunk. Move up to the top of the tree so that git commands that expect a
1112 # valid CWD won't fail after we check out the merge branch.
1113 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1114 if rel_base_path:
1115 os.chdir(rel_base_path)
1116
1117 # Stuff our change into the merge branch.
1118 # We wrap in a try...finally block so if anything goes wrong,
1119 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001120 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001121 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001122 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1123 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001124 if options.contributor:
1125 RunGit(['commit', '--author', options.contributor, '-m', description])
1126 else:
1127 RunGit(['commit', '-m', description])
1128 if cmd == 'push':
1129 # push the merge branch.
1130 remote, branch = cl.FetchUpstreamTuple()
1131 retcode, output = RunGitWithCode(
1132 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1133 logging.debug(output)
1134 else:
1135 # dcommit the merge branch.
bauerb@chromium.org2e64fa12011-05-05 11:13:44 +00001136 retcode, output = RunGitWithCode(['svn', 'dcommit',
1137 '--no-rebase', '--rmdir'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001138 finally:
1139 # And then swap back to the original branch and clean up.
1140 RunGit(['checkout', '-q', cl.GetBranch()])
1141 RunGit(['branch', '-D', MERGE_BRANCH])
1142
1143 if cl.GetIssue():
1144 if cmd == 'dcommit' and 'Committed r' in output:
1145 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1146 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001147 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1148 for l in output.splitlines(False))
1149 match = filter(None, match)
1150 if len(match) != 1:
1151 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1152 output)
1153 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001154 else:
1155 return 1
1156 viewvc_url = settings.GetViewVCUrl()
1157 if viewvc_url and revision:
1158 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1159 print ('Closing issue '
1160 '(you may be prompted for your codereview password)...')
1161 cl.CloseIssue()
1162 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001163
1164 if retcode == 0:
1165 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1166 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001167 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001168
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001169 return 0
1170
1171
1172@usage('[upstream branch to apply against]')
1173def CMDdcommit(parser, args):
1174 """commit the current changelist via git-svn"""
1175 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001176 message = """This doesn't appear to be an SVN repository.
1177If your project has a git mirror with an upstream SVN master, you probably need
1178to run 'git svn init', see your project's git mirror documentation.
1179If your project has a true writeable upstream repository, you probably want
1180to run 'git cl push' instead.
1181Choose wisely, if you get this wrong, your commit might appear to succeed but
1182will instead be silently ignored."""
1183 print(message)
maruel@chromium.org90541732011-04-01 17:54:18 +00001184 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001185 return SendUpstream(parser, args, 'dcommit')
1186
1187
1188@usage('[upstream branch to apply against]')
1189def CMDpush(parser, args):
1190 """commit the current changelist via git"""
1191 if settings.GetIsGitSvn():
1192 print('This appears to be an SVN repository.')
1193 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
maruel@chromium.org90541732011-04-01 17:54:18 +00001194 ask_for_data('[Press enter to push or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001195 return SendUpstream(parser, args, 'push')
1196
1197
1198@usage('<patch url or issue id>')
1199def CMDpatch(parser, args):
1200 """patch in a code review"""
1201 parser.add_option('-b', dest='newbranch',
1202 help='create a new branch off trunk for the patch')
1203 parser.add_option('-f', action='store_true', dest='force',
1204 help='with -b, clobber any existing branch')
1205 parser.add_option('--reject', action='store_true', dest='reject',
1206 help='allow failed patches and spew .rej files')
1207 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1208 help="don't commit after patch applies")
1209 (options, args) = parser.parse_args(args)
1210 if len(args) != 1:
1211 parser.print_help()
1212 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001213 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001214
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001215 # TODO(maruel): Use apply_issue.py
1216
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001217 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001218 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001219 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001220 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001221 else:
1222 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001223 issue_url = FixUrl(issue_arg)
1224 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001225 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001226 DieWithError('Must pass an issue ID or full URL for '
1227 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001228 issue = match.group(1)
1229 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001230
1231 if options.newbranch:
1232 if options.force:
1233 RunGit(['branch', '-D', options.newbranch],
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +00001234 stderr=subprocess2.PIPE, error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001235 RunGit(['checkout', '-b', options.newbranch,
1236 Changelist().GetUpstreamBranch()])
1237
1238 # Switch up to the top-level directory, if necessary, in preparation for
1239 # applying the patch.
1240 top = RunGit(['rev-parse', '--show-cdup']).strip()
1241 if top:
1242 os.chdir(top)
1243
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001244 # Git patches have a/ at the beginning of source paths. We strip that out
1245 # with a sed script rather than the -p flag to patch so we can feed either
1246 # Git or svn-style patches into the same apply command.
1247 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +00001248 try:
1249 patch_data = subprocess2.check_output(
1250 ['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'], stdin=patch_data)
1251 except subprocess2.CalledProcessError:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001252 DieWithError('Git patch mungling failed.')
1253 logging.info(patch_data)
1254 # We use "git apply" to apply the patch instead of "patch" so that we can
1255 # pick up file adds.
1256 # The --index flag means: also insert into the index (so we catch adds).
1257 cmd = ['git', 'apply', '--index', '-p0']
1258 if options.reject:
1259 cmd.append('--reject')
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +00001260 try:
1261 subprocess2.check_call(cmd, stdin=patch_data, stdout=subprocess2.VOID)
1262 except subprocess2.CalledProcessError:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001263 DieWithError('Failed to apply the patch')
1264
1265 # If we had an issue, commit the current state and register the issue.
1266 if not options.nocommit:
1267 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1268 cl = Changelist()
1269 cl.SetIssue(issue)
1270 print "Committed patch."
1271 else:
1272 print "Patch applied to index."
1273 return 0
1274
1275
1276def CMDrebase(parser, args):
1277 """rebase current branch on top of svn repo"""
1278 # Provide a wrapper for git svn rebase to help avoid accidental
1279 # git svn dcommit.
1280 # It's the only command that doesn't use parser at all since we just defer
1281 # execution to git-svn.
maruel@chromium.org75075572011-10-10 19:55:28 +00001282 return subprocess2.call(['git', 'svn', 'rebase'] + args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001283
1284
1285def GetTreeStatus():
1286 """Fetches the tree status and returns either 'open', 'closed',
1287 'unknown' or 'unset'."""
1288 url = settings.GetTreeStatusUrl(error_ok=True)
1289 if url:
1290 status = urllib2.urlopen(url).read().lower()
1291 if status.find('closed') != -1 or status == '0':
1292 return 'closed'
1293 elif status.find('open') != -1 or status == '1':
1294 return 'open'
1295 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001296 return 'unset'
1297
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001298
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001299def GetTreeStatusReason():
1300 """Fetches the tree status from a json url and returns the message
1301 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001302 url = settings.GetTreeStatusUrl()
1303 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001304 connection = urllib2.urlopen(json_url)
1305 status = json.loads(connection.read())
1306 connection.close()
1307 return status['message']
1308
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001309
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001310def CMDtree(parser, args):
1311 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001312 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001313 status = GetTreeStatus()
1314 if 'unset' == status:
1315 print 'You must configure your tree status URL by running "git cl config".'
1316 return 2
1317
1318 print "The tree is %s" % status
1319 print
1320 print GetTreeStatusReason()
1321 if status != 'open':
1322 return 1
1323 return 0
1324
1325
1326def CMDupstream(parser, args):
1327 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001328 _, args = parser.parse_args(args)
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001329 if args:
1330 parser.error('Unrecognized args: %s' % ' '.join(args))
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001331 cl = Changelist()
1332 print cl.GetUpstreamBranch()
1333 return 0
1334
1335
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001336def CMDset_commit(parser, args):
1337 """set the commit bit"""
1338 _, args = parser.parse_args(args)
1339 if args:
1340 parser.error('Unrecognized args: %s' % ' '.join(args))
1341 cl = Changelist()
1342 cl.SetFlag('commit', '1')
1343 return 0
1344
1345
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001346def Command(name):
1347 return getattr(sys.modules[__name__], 'CMD' + name, None)
1348
1349
1350def CMDhelp(parser, args):
1351 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001352 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001353 if len(args) == 1:
1354 return main(args + ['--help'])
1355 parser.print_help()
1356 return 0
1357
1358
1359def GenUsage(parser, command):
1360 """Modify an OptParse object with the function's documentation."""
1361 obj = Command(command)
1362 more = getattr(obj, 'usage_more', '')
1363 if command == 'help':
1364 command = '<command>'
1365 else:
1366 # OptParser.description prefer nicely non-formatted strings.
1367 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1368 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1369
1370
1371def main(argv):
1372 """Doesn't parse the arguments here, just find the right subcommand to
1373 execute."""
maruel@chromium.orgddd59412011-11-30 14:20:38 +00001374 # Reload settings.
1375 global settings
1376 settings = Settings()
1377
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001378 # Do it late so all commands are listed.
1379 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1380 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1381 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1382
1383 # Create the option parse and add --verbose support.
1384 parser = optparse.OptionParser()
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001385 parser.add_option(
1386 '-v', '--verbose', action='count', default=0,
1387 help='Use 2 times for more debugging info')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001388 old_parser_args = parser.parse_args
1389 def Parse(args):
1390 options, args = old_parser_args(args)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001391 if options.verbose >= 2:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001392 logging.basicConfig(level=logging.DEBUG)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001393 elif options.verbose:
1394 logging.basicConfig(level=logging.INFO)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001395 else:
1396 logging.basicConfig(level=logging.WARNING)
1397 return options, args
1398 parser.parse_args = Parse
1399
1400 if argv:
1401 command = Command(argv[0])
1402 if command:
1403 # "fix" the usage and the description now that we know the subcommand.
1404 GenUsage(parser, argv[0])
1405 try:
1406 return command(parser, argv[1:])
1407 except urllib2.HTTPError, e:
1408 if e.code != 500:
1409 raise
1410 DieWithError(
1411 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1412 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1413
1414 # Not a known command. Default to help.
1415 GenUsage(parser, 'help')
1416 return CMDhelp(parser, argv)
1417
1418
1419if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001420 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001421 sys.exit(main(sys.argv[1:]))