blob: 3ffa9ad7a5e86dc08da05de68a0757837b740164 [file] [log] [blame]
maruel@chromium.org725f1c32011-04-01 20:24:54 +00001#!/usr/bin/env python
2# Copyright (c) 2011 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00006# Copyright (C) 2008 Evan Martin <martine@danga.com>
7
maruel@chromium.org725f1c32011-04-01 20:24:54 +00008"""A git-command for integrating reviews on Rietveld."""
9
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000010import errno
11import logging
12import optparse
13import os
14import re
15import subprocess
16import sys
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000017import tempfile
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000018import textwrap
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +000019import urlparse
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000020import urllib2
21
22try:
maruel@chromium.orgc98c0c52011-04-06 13:39:43 +000023 import readline # pylint: disable=F0401,W0611
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000024except ImportError:
25 pass
26
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000027try:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000028 import simplejson as json # pylint: disable=F0401
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000029except ImportError:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000030 try:
maruel@chromium.org725f1c32011-04-01 20:24:54 +000031 import json # pylint: disable=F0401
maruel@chromium.org2a74d372011-03-29 19:05:50 +000032 except ImportError:
33 # Fall back to the packaged version.
maruel@chromium.orgb35c00c2011-03-31 00:43:35 +000034 sys.path.append(os.path.join(os.path.dirname(__file__), 'third_party'))
dpranke@chromium.orgfe79c312011-04-01 20:15:52 +000035 import simplejson as json # pylint: disable=F0401
maruel@chromium.org2a74d372011-03-29 19:05:50 +000036
37
38from third_party import upload
39import breakpad # pylint: disable=W0611
maruel@chromium.org6f09cd92011-04-01 16:38:12 +000040import fix_encoding
maruel@chromium.org2a74d372011-03-29 19:05:50 +000041import presubmit_support
maruel@chromium.orgcab38e92011-04-09 00:30:51 +000042import rietveld
maruel@chromium.org2a74d372011-03-29 19:05:50 +000043import scm
44import watchlists
45
46
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000047
48DEFAULT_SERVER = 'http://codereview.appspot.com'
maruel@chromium.org0ba7f962011-01-11 22:13:58 +000049POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000050DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup'
51
maruel@chromium.org90541732011-04-01 17:54:18 +000052
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000053def DieWithError(message):
dpranke@chromium.org970c5222011-03-12 00:32:24 +000054 print >> sys.stderr, message
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000055 sys.exit(1)
56
57
58def Popen(cmd, **kwargs):
59 """Wrapper for subprocess.Popen() that logs and watch for cygwin issues"""
maruel@chromium.org899e1c12011-04-07 17:03:18 +000060 logging.debug('Popen: ' + ' '.join(cmd))
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000061 try:
62 return subprocess.Popen(cmd, **kwargs)
63 except OSError, e:
64 if e.errno == errno.EAGAIN and sys.platform == 'cygwin':
65 DieWithError(
66 'Visit '
67 'http://code.google.com/p/chromium/wiki/CygwinDllRemappingFailure to '
68 'learn how to fix this error; you need to rebase your cygwin dlls')
69 raise
70
71
72def RunCommand(cmd, error_ok=False, error_message=None,
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000073 redirect_stdout=True, swallow_stderr=False, **kwargs):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000074 if redirect_stdout:
75 stdout = subprocess.PIPE
76 else:
77 stdout = None
78 if swallow_stderr:
79 stderr = subprocess.PIPE
80 else:
81 stderr = None
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000082 proc = Popen(cmd, stdout=stdout, stderr=stderr, **kwargs)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000083 output = proc.communicate()[0]
84 if not error_ok and proc.returncode != 0:
85 DieWithError('Command "%s" failed.\n' % (' '.join(cmd)) +
86 (error_message or output or ''))
87 return output
88
89
90def RunGit(args, **kwargs):
91 cmd = ['git'] + args
92 return RunCommand(cmd, **kwargs)
93
94
95def RunGitWithCode(args):
96 proc = Popen(['git'] + args, stdout=subprocess.PIPE)
97 output = proc.communicate()[0]
98 return proc.returncode, output
99
100
101def usage(more):
102 def hook(fn):
103 fn.usage_more = more
104 return fn
105 return hook
106
107
maruel@chromium.org90541732011-04-01 17:54:18 +0000108def ask_for_data(prompt):
109 try:
110 return raw_input(prompt)
111 except KeyboardInterrupt:
112 # Hide the exception.
113 sys.exit(1)
114
115
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000116def FixUrl(server):
117 """Fix a server url to defaults protocol to http:// if none is specified."""
118 if not server:
119 return server
120 if not re.match(r'[a-z]+\://.*', server):
121 return 'http://' + server
122 return server
123
124
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000125def MatchSvnGlob(url, base_url, glob_spec, allow_wildcards):
126 """Return the corresponding git ref if |base_url| together with |glob_spec|
127 matches the full |url|.
128
129 If |allow_wildcards| is true, |glob_spec| can contain wildcards (see below).
130 """
131 fetch_suburl, as_ref = glob_spec.split(':')
132 if allow_wildcards:
133 glob_match = re.match('(.+/)?(\*|{[^/]*})(/.+)?', fetch_suburl)
134 if glob_match:
135 # Parse specs like "branches/*/src:refs/remotes/svn/*" or
136 # "branches/{472,597,648}/src:refs/remotes/svn/*".
137 branch_re = re.escape(base_url)
138 if glob_match.group(1):
139 branch_re += '/' + re.escape(glob_match.group(1))
140 wildcard = glob_match.group(2)
141 if wildcard == '*':
142 branch_re += '([^/]*)'
143 else:
144 # Escape and replace surrounding braces with parentheses and commas
145 # with pipe symbols.
146 wildcard = re.escape(wildcard)
147 wildcard = re.sub('^\\\\{', '(', wildcard)
148 wildcard = re.sub('\\\\,', '|', wildcard)
149 wildcard = re.sub('\\\\}$', ')', wildcard)
150 branch_re += wildcard
151 if glob_match.group(3):
152 branch_re += re.escape(glob_match.group(3))
153 match = re.match(branch_re, url)
154 if match:
155 return re.sub('\*$', match.group(1), as_ref)
156
157 # Parse specs like "trunk/src:refs/remotes/origin/trunk".
158 if fetch_suburl:
159 full_url = base_url + '/' + fetch_suburl
160 else:
161 full_url = base_url
162 if full_url == url:
163 return as_ref
164 return None
165
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000166class Settings(object):
167 def __init__(self):
168 self.default_server = None
169 self.cc = None
170 self.root = None
171 self.is_git_svn = None
172 self.svn_branch = None
173 self.tree_status_url = None
174 self.viewvc_url = None
175 self.updated = False
176
177 def LazyUpdateIfNeeded(self):
178 """Updates the settings from a codereview.settings file, if available."""
179 if not self.updated:
180 cr_settings_file = FindCodereviewSettingsFile()
181 if cr_settings_file:
182 LoadCodereviewSettingsFromFile(cr_settings_file)
183 self.updated = True
184
185 def GetDefaultServerUrl(self, error_ok=False):
186 if not self.default_server:
187 self.LazyUpdateIfNeeded()
188 self.default_server = FixUrl(self._GetConfig('rietveld.server',
189 error_ok=True))
190 if error_ok:
191 return self.default_server
192 if not self.default_server:
193 error_message = ('Could not find settings file. You must configure '
194 'your review setup by running "git cl config".')
195 self.default_server = FixUrl(self._GetConfig(
196 'rietveld.server', error_message=error_message))
197 return self.default_server
198
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000199 def GetRoot(self):
200 if not self.root:
201 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
202 return self.root
203
204 def GetIsGitSvn(self):
205 """Return true if this repo looks like it's using git-svn."""
206 if self.is_git_svn is None:
207 # If you have any "svn-remote.*" config keys, we think you're using svn.
208 self.is_git_svn = RunGitWithCode(
209 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
210 return self.is_git_svn
211
212 def GetSVNBranch(self):
213 if self.svn_branch is None:
214 if not self.GetIsGitSvn():
215 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
216
217 # Try to figure out which remote branch we're based on.
218 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000219 # 1) iterate through our branch history and find the svn URL.
220 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000221
222 # regexp matching the git-svn line that contains the URL.
223 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
224
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000225 # We don't want to go through all of history, so read a line from the
226 # pipe at a time.
227 # The -100 is an arbitrary limit so we don't search forever.
228 cmd = ['git', 'log', '-100', '--pretty=medium']
229 proc = Popen(cmd, stdout=subprocess.PIPE)
maruel@chromium.org740f9d72011-06-10 18:33:10 +0000230 url = None
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000231 for line in proc.stdout:
232 match = git_svn_re.match(line)
233 if match:
234 url = match.group(1)
235 proc.stdout.close() # Cut pipe.
236 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000237
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000238 if url:
239 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
240 remotes = RunGit(['config', '--get-regexp',
241 r'^svn-remote\..*\.url']).splitlines()
242 for remote in remotes:
243 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000244 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000245 remote = match.group(1)
246 base_url = match.group(2)
247 fetch_spec = RunGit(
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000248 ['config', 'svn-remote.%s.fetch' % remote],
249 error_ok=True).strip()
250 if fetch_spec:
251 self.svn_branch = MatchSvnGlob(url, base_url, fetch_spec, False)
252 if self.svn_branch:
253 break
254 branch_spec = RunGit(
255 ['config', 'svn-remote.%s.branches' % remote],
256 error_ok=True).strip()
257 if branch_spec:
258 self.svn_branch = MatchSvnGlob(url, base_url, branch_spec, True)
259 if self.svn_branch:
260 break
261 tag_spec = RunGit(
262 ['config', 'svn-remote.%s.tags' % remote],
263 error_ok=True).strip()
264 if tag_spec:
265 self.svn_branch = MatchSvnGlob(url, base_url, tag_spec, True)
266 if self.svn_branch:
267 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000268
269 if not self.svn_branch:
270 DieWithError('Can\'t guess svn branch -- try specifying it on the '
271 'command line')
272
273 return self.svn_branch
274
275 def GetTreeStatusUrl(self, error_ok=False):
276 if not self.tree_status_url:
277 error_message = ('You must configure your tree status URL by running '
278 '"git cl config".')
279 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
280 error_ok=error_ok,
281 error_message=error_message)
282 return self.tree_status_url
283
284 def GetViewVCUrl(self):
285 if not self.viewvc_url:
286 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
287 return self.viewvc_url
288
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000289 def GetDefaultCCList(self):
290 return self._GetConfig('rietveld.cc', error_ok=True)
291
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000292 def _GetConfig(self, param, **kwargs):
293 self.LazyUpdateIfNeeded()
294 return RunGit(['config', param], **kwargs).strip()
295
296
297settings = Settings()
298
299
300did_migrate_check = False
301def CheckForMigration():
302 """Migrate from the old issue format, if found.
303
304 We used to store the branch<->issue mapping in a file in .git, but it's
305 better to store it in the .git/config, since deleting a branch deletes that
306 branch's entry there.
307 """
308
309 # Don't run more than once.
310 global did_migrate_check
311 if did_migrate_check:
312 return
313
314 gitdir = RunGit(['rev-parse', '--git-dir']).strip()
315 storepath = os.path.join(gitdir, 'cl-mapping')
316 if os.path.exists(storepath):
317 print "old-style git-cl mapping file (%s) found; migrating." % storepath
318 store = open(storepath, 'r')
319 for line in store:
320 branch, issue = line.strip().split()
321 RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
322 issue])
323 store.close()
324 os.remove(storepath)
325 did_migrate_check = True
326
327
328def ShortBranchName(branch):
329 """Convert a name like 'refs/heads/foo' to just 'foo'."""
330 return branch.replace('refs/heads/', '')
331
332
333class Changelist(object):
334 def __init__(self, branchref=None):
335 # Poke settings so we get the "configure your server" message if necessary.
336 settings.GetDefaultServerUrl()
337 self.branchref = branchref
338 if self.branchref:
339 self.branch = ShortBranchName(self.branchref)
340 else:
341 self.branch = None
342 self.rietveld_server = None
343 self.upstream_branch = None
344 self.has_issue = False
345 self.issue = None
346 self.has_description = False
347 self.description = None
348 self.has_patchset = False
349 self.patchset = None
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000350 self._rpc_server = None
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000351 self.cc = None
352 self.watchers = ()
353
354 def GetCCList(self):
355 """Return the users cc'd on this CL.
356
357 Return is a string suitable for passing to gcl with the --cc flag.
358 """
359 if self.cc is None:
360 base_cc = settings .GetDefaultCCList()
361 more_cc = ','.join(self.watchers)
362 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
363 return self.cc
364
365 def SetWatchers(self, watchers):
366 """Set the list of email addresses that should be cc'd based on the changed
367 files in this CL.
368 """
369 self.watchers = watchers
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000370
371 def GetBranch(self):
372 """Returns the short branch name, e.g. 'master'."""
373 if not self.branch:
374 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
375 self.branch = ShortBranchName(self.branchref)
376 return self.branch
377
378 def GetBranchRef(self):
379 """Returns the full branch name, e.g. 'refs/heads/master'."""
380 self.GetBranch() # Poke the lazy loader.
381 return self.branchref
382
383 def FetchUpstreamTuple(self):
384 """Returns a tuple containg remote and remote ref,
385 e.g. 'origin', 'refs/heads/master'
386 """
387 remote = '.'
388 branch = self.GetBranch()
389 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
390 error_ok=True).strip()
391 if upstream_branch:
392 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
393 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000394 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
395 error_ok=True).strip()
396 if upstream_branch:
397 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000398 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000399 # Fall back on trying a git-svn upstream branch.
400 if settings.GetIsGitSvn():
401 upstream_branch = settings.GetSVNBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000402 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000403 # Else, try to guess the origin remote.
404 remote_branches = RunGit(['branch', '-r']).split()
405 if 'origin/master' in remote_branches:
406 # Fall back on origin/master if it exits.
407 remote = 'origin'
408 upstream_branch = 'refs/heads/master'
409 elif 'origin/trunk' in remote_branches:
410 # Fall back on origin/trunk if it exists. Generally a shared
411 # git-svn clone
412 remote = 'origin'
413 upstream_branch = 'refs/heads/trunk'
414 else:
415 DieWithError("""Unable to determine default branch to diff against.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000416Either pass complete "git diff"-style arguments, like
417 git cl upload origin/master
418or verify this branch is set up to track another (via the --track argument to
419"git checkout -b ...").""")
420
421 return remote, upstream_branch
422
423 def GetUpstreamBranch(self):
424 if self.upstream_branch is None:
425 remote, upstream_branch = self.FetchUpstreamTuple()
426 if remote is not '.':
427 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
428 self.upstream_branch = upstream_branch
429 return self.upstream_branch
430
431 def GetRemoteUrl(self):
432 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
433
434 Returns None if there is no remote.
435 """
436 remote = self.FetchUpstreamTuple()[0]
437 if remote == '.':
438 return None
439 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
440
441 def GetIssue(self):
442 if not self.has_issue:
443 CheckForMigration()
444 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
445 if issue:
446 self.issue = issue
447 self.rietveld_server = FixUrl(RunGit(
448 ['config', self._RietveldServer()], error_ok=True).strip())
449 else:
450 self.issue = None
451 if not self.rietveld_server:
452 self.rietveld_server = settings.GetDefaultServerUrl()
453 self.has_issue = True
454 return self.issue
455
456 def GetRietveldServer(self):
457 self.GetIssue()
458 return self.rietveld_server
459
460 def GetIssueURL(self):
461 """Get the URL for a particular issue."""
462 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
463
464 def GetDescription(self, pretty=False):
465 if not self.has_description:
466 if self.GetIssue():
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000467 self.description = self.RpcServer().get_description(
468 int(self.GetIssue())).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000469 self.has_description = True
470 if pretty:
471 wrapper = textwrap.TextWrapper()
472 wrapper.initial_indent = wrapper.subsequent_indent = ' '
473 return wrapper.fill(self.description)
474 return self.description
475
476 def GetPatchset(self):
477 if not self.has_patchset:
478 patchset = RunGit(['config', self._PatchsetSetting()],
479 error_ok=True).strip()
480 if patchset:
481 self.patchset = patchset
482 else:
483 self.patchset = None
484 self.has_patchset = True
485 return self.patchset
486
487 def SetPatchset(self, patchset):
488 """Set this branch's patchset. If patchset=0, clears the patchset."""
489 if patchset:
490 RunGit(['config', self._PatchsetSetting(), str(patchset)])
491 else:
492 RunGit(['config', '--unset', self._PatchsetSetting()],
493 swallow_stderr=True, error_ok=True)
494 self.has_patchset = False
495
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000496 def GetPatchSetDiff(self, issue):
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000497 patchset = self.RpcServer().get_issue_properties(
498 int(issue), False)['patchsets'][-1]
499 return self.RpcServer().get(
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000500 '/download/issue%s_%s.diff' % (issue, patchset))
501
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000502 def SetIssue(self, issue):
503 """Set this branch's issue. If issue=0, clears the issue."""
504 if issue:
505 RunGit(['config', self._IssueSetting(), str(issue)])
506 if self.rietveld_server:
507 RunGit(['config', self._RietveldServer(), self.rietveld_server])
508 else:
509 RunGit(['config', '--unset', self._IssueSetting()])
510 self.SetPatchset(0)
511 self.has_issue = False
512
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000513 def RunHook(self, committing, upstream_branch, tbr, may_prompt, verbose):
514 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000515 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip() or '.'
516 absroot = os.path.abspath(root)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000517
518 # We use the sha1 of HEAD as a name of this change.
519 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000520 # Need to pass a relative path for msysgit.
521 files = scm.GIT.CaptureStatus([root], upstream_branch)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000522
523 issue = ConvertToInteger(self.GetIssue())
524 patchset = ConvertToInteger(self.GetPatchset())
525 if issue:
526 description = self.GetDescription()
527 else:
528 # If the change was never uploaded, use the log messages of all commits
529 # up to the branch point, as git cl upload will prefill the description
530 # with these log messages.
531 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
532 '%s...' % (upstream_branch)]).strip()
533 change = presubmit_support.GitChange(
534 name,
535 description,
536 absroot,
537 files,
538 issue,
539 patchset,
540 None)
541
542 # Apply watchlists on upload.
543 if not committing:
544 watchlist = watchlists.Watchlists(change.RepositoryRoot())
545 files = [f.LocalPath() for f in change.AffectedFiles()]
546 self.SetWatchers(watchlist.GetWatchersForPaths(files))
547
548 try:
549 output = presubmit_support.DoPresubmitChecks(change, committing,
550 verbose=verbose, output_stream=sys.stdout, input_stream=sys.stdin,
551 default_presubmit=None, may_prompt=may_prompt, tbr=tbr,
maruel@chromium.org239f4112011-06-03 20:08:23 +0000552 rietveld_obj=self.RpcServer())
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000553 except presubmit_support.PresubmitFailure, e:
554 DieWithError(
555 ('%s\nMaybe your depot_tools is out of date?\n'
556 'If all fails, contact maruel@') % e)
557
558 # TODO(dpranke): We should propagate the error out instead of calling
559 # exit().
560 if not output.should_continue():
561 sys.exit(1)
562
563 return output
564
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000565 def CloseIssue(self):
maruel@chromium.org607bb1b2011-06-01 23:43:11 +0000566 """Updates the description and closes the issue."""
567 issue = int(self.GetIssue())
568 self.RpcServer().update_description(issue, self.description)
569 return self.RpcServer().close_issue(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000570
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000571 def SetFlag(self, flag, value):
572 """Patchset must match."""
573 if not self.GetPatchset():
574 DieWithError('The patchset needs to match. Send another patchset.')
575 try:
576 return self.RpcServer().set_flag(
577 int(self.GetIssue()), int(self.GetPatchset()), flag, value)
578 except urllib2.HTTPError, e:
579 if e.code == 404:
580 DieWithError('The issue %s doesn\'t exist.' % self.GetIssue())
581 if e.code == 403:
582 DieWithError(
583 ('Access denied to issue %s. Maybe the patchset %s doesn\'t '
584 'match?') % (self.GetIssue(), self.GetPatchset()))
585 raise
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000586
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000587 def RpcServer(self):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000588 """Returns an upload.RpcServer() to access this review's rietveld instance.
589 """
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000590 if not self._rpc_server:
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000591 self.GetIssue()
592 self._rpc_server = rietveld.Rietveld(self.rietveld_server, None, None)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000593 return self._rpc_server
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000594
595 def _IssueSetting(self):
596 """Return the git setting that stores this change's issue."""
597 return 'branch.%s.rietveldissue' % self.GetBranch()
598
599 def _PatchsetSetting(self):
600 """Return the git setting that stores this change's most recent patchset."""
601 return 'branch.%s.rietveldpatchset' % self.GetBranch()
602
603 def _RietveldServer(self):
604 """Returns the git setting that stores this change's rietveld server."""
605 return 'branch.%s.rietveldserver' % self.GetBranch()
606
607
608def GetCodereviewSettingsInteractively():
609 """Prompt the user for settings."""
610 server = settings.GetDefaultServerUrl(error_ok=True)
611 prompt = 'Rietveld server (host[:port])'
612 prompt += ' [%s]' % (server or DEFAULT_SERVER)
maruel@chromium.org90541732011-04-01 17:54:18 +0000613 newserver = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000614 if not server and not newserver:
615 newserver = DEFAULT_SERVER
616 if newserver and newserver != server:
617 RunGit(['config', 'rietveld.server', newserver])
618
619 def SetProperty(initial, caption, name):
620 prompt = caption
621 if initial:
622 prompt += ' ("x" to clear) [%s]' % initial
maruel@chromium.org90541732011-04-01 17:54:18 +0000623 new_val = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000624 if new_val == 'x':
625 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
626 elif new_val and new_val != initial:
627 RunGit(['config', 'rietveld.' + name, new_val])
628
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000629 SetProperty(settings.GetDefaultCCList(), 'CC list', 'cc')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000630 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
631 'tree-status-url')
632 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
633
634 # TODO: configure a default branch to diff against, rather than this
635 # svn-based hackery.
636
637
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000638class ChangeDescription(object):
639 """Contains a parsed form of the change description."""
640 def __init__(self, subject, log_desc, reviewers):
641 self.subject = subject
642 self.log_desc = log_desc
643 self.reviewers = reviewers
644 self.description = self.log_desc
645
646 def Update(self):
647 initial_text = """# Enter a description of the change.
648# This will displayed on the codereview site.
649# The first line will also be used as the subject of the review.
650"""
651 initial_text += self.description
652 if 'R=' not in self.description and self.reviewers:
653 initial_text += '\nR=' + self.reviewers
654 if 'BUG=' not in self.description:
655 initial_text += '\nBUG='
656 if 'TEST=' not in self.description:
657 initial_text += '\nTEST='
658 self._ParseDescription(UserEditedLog(initial_text))
659
660 def _ParseDescription(self, description):
661 if not description:
662 self.description = description
663 return
664
665 parsed_lines = []
666 reviewers_regexp = re.compile('\s*R=(.+)')
667 reviewers = ''
668 subject = ''
669 for l in description.splitlines():
670 if not subject:
671 subject = l
672 matched_reviewers = reviewers_regexp.match(l)
673 if matched_reviewers:
674 reviewers = matched_reviewers.group(1)
675 parsed_lines.append(l)
676
677 self.description = '\n'.join(parsed_lines) + '\n'
678 self.subject = subject
679 self.reviewers = reviewers
680
681 def IsEmpty(self):
682 return not self.description
683
684
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000685def FindCodereviewSettingsFile(filename='codereview.settings'):
686 """Finds the given file starting in the cwd and going up.
687
688 Only looks up to the top of the repository unless an
689 'inherit-review-settings-ok' file exists in the root of the repository.
690 """
691 inherit_ok_file = 'inherit-review-settings-ok'
692 cwd = os.getcwd()
693 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
694 if os.path.isfile(os.path.join(root, inherit_ok_file)):
695 root = '/'
696 while True:
697 if filename in os.listdir(cwd):
698 if os.path.isfile(os.path.join(cwd, filename)):
699 return open(os.path.join(cwd, filename))
700 if cwd == root:
701 break
702 cwd = os.path.dirname(cwd)
703
704
705def LoadCodereviewSettingsFromFile(fileobj):
706 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000707 keyvals = {}
708 for line in fileobj.read().splitlines():
709 if not line or line.startswith("#"):
710 continue
711 k, v = line.split(": ", 1)
712 keyvals[k] = v
713
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000714 def SetProperty(name, setting, unset_error_ok=False):
715 fullname = 'rietveld.' + name
716 if setting in keyvals:
717 RunGit(['config', fullname, keyvals[setting]])
718 else:
719 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
720
721 SetProperty('server', 'CODE_REVIEW_SERVER')
722 # Only server setting is required. Other settings can be absent.
723 # In that case, we ignore errors raised during option deletion attempt.
724 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
725 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
726 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
727
728 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
729 #should be of the form
730 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
731 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
732 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
733 keyvals['ORIGIN_URL_CONFIG']])
734
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000735
736@usage('[repo root containing codereview.settings]')
737def CMDconfig(parser, args):
738 """edit configuration for this tree"""
739
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000740 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000741 if len(args) == 0:
742 GetCodereviewSettingsInteractively()
743 return 0
744
745 url = args[0]
746 if not url.endswith('codereview.settings'):
747 url = os.path.join(url, 'codereview.settings')
748
749 # Load code review settings and download hooks (if available).
750 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
751 return 0
752
753
754def CMDstatus(parser, args):
755 """show status of changelists"""
756 parser.add_option('--field',
757 help='print only specific field (desc|id|patch|url)')
758 (options, args) = parser.parse_args(args)
759
760 # TODO: maybe make show_branches a flag if necessary.
761 show_branches = not options.field
762
763 if show_branches:
764 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
765 if branches:
766 print 'Branches associated with reviews:'
767 for branch in sorted(branches.splitlines()):
768 cl = Changelist(branchref=branch)
769 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
770
771 cl = Changelist()
772 if options.field:
773 if options.field.startswith('desc'):
774 print cl.GetDescription()
775 elif options.field == 'id':
776 issueid = cl.GetIssue()
777 if issueid:
778 print issueid
779 elif options.field == 'patch':
780 patchset = cl.GetPatchset()
781 if patchset:
782 print patchset
783 elif options.field == 'url':
784 url = cl.GetIssueURL()
785 if url:
786 print url
787 else:
788 print
789 print 'Current branch:',
790 if not cl.GetIssue():
791 print 'no issue assigned.'
792 return 0
793 print cl.GetBranch()
794 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
795 print 'Issue description:'
796 print cl.GetDescription(pretty=True)
797 return 0
798
799
800@usage('[issue_number]')
801def CMDissue(parser, args):
802 """Set or display the current code review issue number.
803
804 Pass issue number 0 to clear the current issue.
805"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000806 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000807
808 cl = Changelist()
809 if len(args) > 0:
810 try:
811 issue = int(args[0])
812 except ValueError:
813 DieWithError('Pass a number to set the issue or none to list it.\n'
814 'Maybe you want to run git cl status?')
815 cl.SetIssue(issue)
816 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
817 return 0
818
819
820def CreateDescriptionFromLog(args):
821 """Pulls out the commit log to use as a base for the CL description."""
822 log_args = []
823 if len(args) == 1 and not args[0].endswith('.'):
824 log_args = [args[0] + '..']
825 elif len(args) == 1 and args[0].endswith('...'):
826 log_args = [args[0][:-1]]
827 elif len(args) == 2:
828 log_args = [args[0] + '..' + args[1]]
829 else:
830 log_args = args[:] # Hope for the best!
831 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
832
833
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000834def UserEditedLog(starting_text):
835 """Given some starting text, let the user edit it and return the result."""
836 editor = os.getenv('EDITOR', 'vi')
837
838 (file_handle, filename) = tempfile.mkstemp()
839 fileobj = os.fdopen(file_handle, 'w')
840 fileobj.write(starting_text)
841 fileobj.close()
842
843 # Open up the default editor in the system to get the CL description.
844 try:
845 cmd = '%s %s' % (editor, filename)
846 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
847 # Msysgit requires the usage of 'env' to be present.
848 cmd = 'env ' + cmd
849 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
maruel@chromium.org2a471072011-05-10 17:29:23 +0000850 try:
851 subprocess.check_call(cmd, shell=True)
852 except subprocess.CalledProcessError, e:
853 DieWithError('Editor returned %d' % e.returncode)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000854 fileobj = open(filename)
855 text = fileobj.read()
856 fileobj.close()
857 finally:
858 os.remove(filename)
859
860 if not text:
861 return
862
863 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
864 return stripcomment_re.sub('', text).strip()
865
866
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000867def ConvertToInteger(inputval):
868 """Convert a string to integer, but returns either an int or None."""
869 try:
870 return int(inputval)
871 except (TypeError, ValueError):
872 return None
873
874
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000875def CMDpresubmit(parser, args):
876 """run presubmit tests on the current changelist"""
877 parser.add_option('--upload', action='store_true',
878 help='Run upload hook instead of the push/dcommit hook')
879 (options, args) = parser.parse_args(args)
880
881 # Make sure index is up-to-date before running diff-index.
882 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
883 if RunGit(['diff-index', 'HEAD']):
884 # TODO(maruel): Is this really necessary?
885 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
886 return 1
887
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000888 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000889 if args:
890 base_branch = args[0]
891 else:
892 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000893 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000894
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000895 cl.RunHook(committing=not options.upload, upstream_branch=base_branch,
896 tbr=False, may_prompt=False, verbose=options.verbose)
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000897 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000898
899
900@usage('[args to "git diff"]')
901def CMDupload(parser, args):
902 """upload the current changelist to codereview"""
903 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
904 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000905 parser.add_option('-f', action='store_true', dest='force',
906 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000907 parser.add_option('-m', dest='message', help='message for patch')
908 parser.add_option('-r', '--reviewers',
909 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000910 parser.add_option('--cc',
911 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000912 parser.add_option('--send-mail', action='store_true',
913 help='send email to reviewer immediately')
914 parser.add_option("--emulate_svn_auto_props", action="store_true",
915 dest="emulate_svn_auto_props",
916 help="Emulate Subversion's auto properties feature.")
917 parser.add_option("--desc_from_logs", action="store_true",
918 dest="from_logs",
919 help="""Squashes git commit logs into change description and
920 uses message as subject""")
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000921 parser.add_option('-c', '--use-commit-queue', action='store_true',
922 help='tell the commit queue to commit this patchset')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000923 (options, args) = parser.parse_args(args)
924
925 # Make sure index is up-to-date before running diff-index.
926 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
927 if RunGit(['diff-index', 'HEAD']):
928 print 'Cannot upload with a dirty tree. You must commit locally first.'
929 return 1
930
931 cl = Changelist()
932 if args:
933 base_branch = args[0]
934 else:
935 # Default to diffing against the "upstream" branch.
936 base_branch = cl.GetUpstreamBranch()
937 args = [base_branch + "..."]
938
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000939 if not options.bypass_hooks and not options.force:
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000940 hook_results = cl.RunHook(committing=False, upstream_branch=base_branch,
941 tbr=False, may_prompt=True,
942 verbose=options.verbose)
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000943 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000944 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000945
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000946
947 # --no-ext-diff is broken in some versions of Git, so try to work around
948 # this by overriding the environment (but there is still a problem if the
949 # git config key "diff.external" is used).
950 env = os.environ.copy()
951 if 'GIT_EXTERNAL_DIFF' in env:
952 del env['GIT_EXTERNAL_DIFF']
953 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
954 env=env)
955
956 upload_args = ['--assume_yes'] # Don't ask about untracked files.
957 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000958 if options.emulate_svn_auto_props:
959 upload_args.append('--emulate_svn_auto_props')
960 if options.send_mail:
961 if not options.reviewers:
962 DieWithError("Must specify reviewers to send email.")
963 upload_args.append('--send_mail')
964 if options.from_logs and not options.message:
965 print 'Must set message for subject line if using desc_from_logs'
966 return 1
967
968 change_desc = None
969
970 if cl.GetIssue():
971 if options.message:
972 upload_args.extend(['--message', options.message])
973 upload_args.extend(['--issue', cl.GetIssue()])
974 print ("This branch is associated with issue %s. "
975 "Adding patch to that issue." % cl.GetIssue())
976 else:
977 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000978 change_desc = ChangeDescription(options.message, log_desc,
979 options.reviewers)
980 if not options.from_logs:
981 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000982
983 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000984 print "Description is empty; aborting."
985 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000986
987 upload_args.extend(['--message', change_desc.subject])
988 upload_args.extend(['--description', change_desc.description])
989 if change_desc.reviewers:
990 upload_args.extend(['--reviewers', change_desc.reviewers])
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000991 cc = ','.join(filter(None, (cl.GetCCList(), options.cc)))
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000992 if cc:
993 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000994
995 # Include the upstream repo's URL in the change -- this is useful for
996 # projects that have their source spread across multiple repos.
997 remote_url = None
998 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000999 # URL is dependent on the current directory.
1000 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001001 if data:
1002 keys = dict(line.split(': ', 1) for line in data.splitlines()
1003 if ': ' in line)
1004 remote_url = keys.get('URL', None)
1005 else:
1006 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
1007 remote_url = (cl.GetRemoteUrl() + '@'
1008 + cl.GetUpstreamBranch().split('/')[-1])
1009 if remote_url:
1010 upload_args.extend(['--base_url', remote_url])
1011
1012 try:
1013 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
maruel@chromium.org9ce0dff2011-04-04 17:56:50 +00001014 except KeyboardInterrupt:
1015 sys.exit(1)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001016 except:
1017 # If we got an exception after the user typed a description for their
1018 # change, back up the description before re-raising.
1019 if change_desc:
1020 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
1021 print '\nGot exception while uploading -- saving description to %s\n' \
1022 % backup_path
1023 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001024 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001025 backup_file.close()
1026 raise
1027
1028 if not cl.GetIssue():
1029 cl.SetIssue(issue)
1030 cl.SetPatchset(patchset)
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001031
1032 if options.use_commit_queue:
1033 cl.SetFlag('commit', '1')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001034 return 0
1035
1036
1037def SendUpstream(parser, args, cmd):
1038 """Common code for CmdPush and CmdDCommit
1039
1040 Squashed commit into a single.
1041 Updates changelog with metadata (e.g. pointer to review).
1042 Pushes/dcommits the code upstream.
1043 Updates review and closes.
1044 """
1045 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1046 help='bypass upload presubmit hook')
1047 parser.add_option('-m', dest='message',
1048 help="override review description")
1049 parser.add_option('-f', action='store_true', dest='force',
1050 help="force yes to questions (don't prompt)")
1051 parser.add_option('-c', dest='contributor',
1052 help="external contributor for patch (appended to " +
1053 "description and used as author for git). Should be " +
1054 "formatted as 'First Last <email@example.com>'")
1055 parser.add_option('--tbr', action='store_true', dest='tbr',
1056 help="short for 'to be reviewed', commit branch " +
1057 "even without uploading for review")
1058 (options, args) = parser.parse_args(args)
1059 cl = Changelist()
1060
1061 if not args or cmd == 'push':
1062 # Default to merging against our best guess of the upstream branch.
1063 args = [cl.GetUpstreamBranch()]
1064
1065 base_branch = args[0]
1066
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001067 # Make sure index is up-to-date before running diff-index.
1068 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001069 if RunGit(['diff-index', 'HEAD']):
1070 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1071 return 1
1072
1073 # This rev-list syntax means "show all commits not in my branch that
1074 # are in base_branch".
1075 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1076 base_branch]).splitlines()
1077 if upstream_commits:
1078 print ('Base branch "%s" has %d commits '
1079 'not in this branch.' % (base_branch, len(upstream_commits)))
1080 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1081 return 1
1082
1083 if cmd == 'dcommit':
1084 # This is the revision `svn dcommit` will commit on top of.
1085 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1086 '--pretty=format:%H'])
1087 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1088 if extra_commits:
1089 print ('This branch has %d additional commits not upstreamed yet.'
1090 % len(extra_commits.splitlines()))
1091 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1092 'before attempting to %s.' % (base_branch, cmd))
1093 return 1
1094
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001095 if not options.bypass_hooks and not options.force:
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +00001096 cl.RunHook(committing=True, upstream_branch=base_branch,
1097 tbr=options.tbr, may_prompt=True, verbose=options.verbose)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001098
1099 if cmd == 'dcommit':
1100 # Check the tree status if the tree status URL is set.
1101 status = GetTreeStatus()
1102 if 'closed' == status:
1103 print ('The tree is closed. Please wait for it to reopen. Use '
1104 '"git cl dcommit -f" to commit on a closed tree.')
1105 return 1
1106 elif 'unknown' == status:
1107 print ('Unable to determine tree status. Please verify manually and '
1108 'use "git cl dcommit -f" to commit on a closed tree.')
1109
1110 description = options.message
1111 if not options.tbr:
1112 # It is important to have these checks early. Not only for user
1113 # convenience, but also because the cl object then caches the correct values
1114 # of these fields even as we're juggling branches for setting up the commit.
1115 if not cl.GetIssue():
1116 print 'Current issue unknown -- has this branch been uploaded?'
1117 print 'Use --tbr to commit without review.'
1118 return 1
1119
1120 if not description:
1121 description = cl.GetDescription()
1122
1123 if not description:
1124 print 'No description set.'
1125 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1126 return 1
1127
1128 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1129 else:
1130 if not description:
1131 # Submitting TBR. See if there's already a description in Rietveld, else
1132 # create a template description. Eitherway, give the user a chance to edit
1133 # it to fill in the TBR= field.
1134 if cl.GetIssue():
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001135 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001136
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001137 # TODO(dpranke): Update to use ChangeDescription object.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001138 if not description:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001139 description = """# Enter a description of the change.
1140# This will be used as the change log for the commit.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001141
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001142"""
1143 description += CreateDescriptionFromLog(args)
1144
1145 description = UserEditedLog(description + '\nTBR=')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001146
1147 if not description:
1148 print "Description empty; aborting."
1149 return 1
1150
1151 if options.contributor:
1152 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1153 print "Please provide contibutor as 'First Last <email@example.com>'"
1154 return 1
1155 description += "\nPatch from %s." % options.contributor
1156 print 'Description:', repr(description)
1157
1158 branches = [base_branch, cl.GetBranchRef()]
1159 if not options.force:
1160 subprocess.call(['git', 'diff', '--stat'] + branches)
maruel@chromium.org90541732011-04-01 17:54:18 +00001161 ask_for_data('About to commit; enter to confirm.')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001162
1163 # We want to squash all this branch's commits into one commit with the
1164 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001165 # We do this by doing a "reset --soft" to the base branch (which keeps
1166 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001167 MERGE_BRANCH = 'git-cl-commit'
1168 # Delete the merge branch if it already exists.
1169 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1170 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1171 RunGit(['branch', '-D', MERGE_BRANCH])
1172
1173 # We might be in a directory that's present in this branch but not in the
1174 # trunk. Move up to the top of the tree so that git commands that expect a
1175 # valid CWD won't fail after we check out the merge branch.
1176 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1177 if rel_base_path:
1178 os.chdir(rel_base_path)
1179
1180 # Stuff our change into the merge branch.
1181 # We wrap in a try...finally block so if anything goes wrong,
1182 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001183 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001184 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001185 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1186 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001187 if options.contributor:
1188 RunGit(['commit', '--author', options.contributor, '-m', description])
1189 else:
1190 RunGit(['commit', '-m', description])
1191 if cmd == 'push':
1192 # push the merge branch.
1193 remote, branch = cl.FetchUpstreamTuple()
1194 retcode, output = RunGitWithCode(
1195 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1196 logging.debug(output)
1197 else:
1198 # dcommit the merge branch.
bauerb@chromium.org2e64fa12011-05-05 11:13:44 +00001199 retcode, output = RunGitWithCode(['svn', 'dcommit',
1200 '--no-rebase', '--rmdir'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001201 finally:
1202 # And then swap back to the original branch and clean up.
1203 RunGit(['checkout', '-q', cl.GetBranch()])
1204 RunGit(['branch', '-D', MERGE_BRANCH])
1205
1206 if cl.GetIssue():
1207 if cmd == 'dcommit' and 'Committed r' in output:
1208 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1209 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001210 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1211 for l in output.splitlines(False))
1212 match = filter(None, match)
1213 if len(match) != 1:
1214 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1215 output)
1216 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001217 else:
1218 return 1
1219 viewvc_url = settings.GetViewVCUrl()
1220 if viewvc_url and revision:
1221 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1222 print ('Closing issue '
1223 '(you may be prompted for your codereview password)...')
1224 cl.CloseIssue()
1225 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001226
1227 if retcode == 0:
1228 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1229 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001230 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001231
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001232 return 0
1233
1234
1235@usage('[upstream branch to apply against]')
1236def CMDdcommit(parser, args):
1237 """commit the current changelist via git-svn"""
1238 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001239 message = """This doesn't appear to be an SVN repository.
1240If your project has a git mirror with an upstream SVN master, you probably need
1241to run 'git svn init', see your project's git mirror documentation.
1242If your project has a true writeable upstream repository, you probably want
1243to run 'git cl push' instead.
1244Choose wisely, if you get this wrong, your commit might appear to succeed but
1245will instead be silently ignored."""
1246 print(message)
maruel@chromium.org90541732011-04-01 17:54:18 +00001247 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001248 return SendUpstream(parser, args, 'dcommit')
1249
1250
1251@usage('[upstream branch to apply against]')
1252def CMDpush(parser, args):
1253 """commit the current changelist via git"""
1254 if settings.GetIsGitSvn():
1255 print('This appears to be an SVN repository.')
1256 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
maruel@chromium.org90541732011-04-01 17:54:18 +00001257 ask_for_data('[Press enter to push or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001258 return SendUpstream(parser, args, 'push')
1259
1260
1261@usage('<patch url or issue id>')
1262def CMDpatch(parser, args):
1263 """patch in a code review"""
1264 parser.add_option('-b', dest='newbranch',
1265 help='create a new branch off trunk for the patch')
1266 parser.add_option('-f', action='store_true', dest='force',
1267 help='with -b, clobber any existing branch')
1268 parser.add_option('--reject', action='store_true', dest='reject',
1269 help='allow failed patches and spew .rej files')
1270 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1271 help="don't commit after patch applies")
1272 (options, args) = parser.parse_args(args)
1273 if len(args) != 1:
1274 parser.print_help()
1275 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001276 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001277
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001278 # TODO(maruel): Use apply_issue.py
1279
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001280 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001281 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001282 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001283 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001284 else:
1285 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001286 issue_url = FixUrl(issue_arg)
1287 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001288 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001289 DieWithError('Must pass an issue ID or full URL for '
1290 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001291 issue = match.group(1)
1292 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001293
1294 if options.newbranch:
1295 if options.force:
1296 RunGit(['branch', '-D', options.newbranch],
1297 swallow_stderr=True, error_ok=True)
1298 RunGit(['checkout', '-b', options.newbranch,
1299 Changelist().GetUpstreamBranch()])
1300
1301 # Switch up to the top-level directory, if necessary, in preparation for
1302 # applying the patch.
1303 top = RunGit(['rev-parse', '--show-cdup']).strip()
1304 if top:
1305 os.chdir(top)
1306
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001307 # Git patches have a/ at the beginning of source paths. We strip that out
1308 # with a sed script rather than the -p flag to patch so we can feed either
1309 # Git or svn-style patches into the same apply command.
1310 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1311 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1312 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1313 patch_data = sed_proc.communicate(patch_data)[0]
1314 if sed_proc.returncode:
1315 DieWithError('Git patch mungling failed.')
1316 logging.info(patch_data)
1317 # We use "git apply" to apply the patch instead of "patch" so that we can
1318 # pick up file adds.
1319 # The --index flag means: also insert into the index (so we catch adds).
1320 cmd = ['git', 'apply', '--index', '-p0']
1321 if options.reject:
1322 cmd.append('--reject')
1323 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1324 patch_proc.communicate(patch_data)
1325 if patch_proc.returncode:
1326 DieWithError('Failed to apply the patch')
1327
1328 # If we had an issue, commit the current state and register the issue.
1329 if not options.nocommit:
1330 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1331 cl = Changelist()
1332 cl.SetIssue(issue)
1333 print "Committed patch."
1334 else:
1335 print "Patch applied to index."
1336 return 0
1337
1338
1339def CMDrebase(parser, args):
1340 """rebase current branch on top of svn repo"""
1341 # Provide a wrapper for git svn rebase to help avoid accidental
1342 # git svn dcommit.
1343 # It's the only command that doesn't use parser at all since we just defer
1344 # execution to git-svn.
1345 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1346 return 0
1347
1348
1349def GetTreeStatus():
1350 """Fetches the tree status and returns either 'open', 'closed',
1351 'unknown' or 'unset'."""
1352 url = settings.GetTreeStatusUrl(error_ok=True)
1353 if url:
1354 status = urllib2.urlopen(url).read().lower()
1355 if status.find('closed') != -1 or status == '0':
1356 return 'closed'
1357 elif status.find('open') != -1 or status == '1':
1358 return 'open'
1359 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001360 return 'unset'
1361
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001362
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001363def GetTreeStatusReason():
1364 """Fetches the tree status from a json url and returns the message
1365 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001366 url = settings.GetTreeStatusUrl()
1367 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001368 connection = urllib2.urlopen(json_url)
1369 status = json.loads(connection.read())
1370 connection.close()
1371 return status['message']
1372
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001373
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001374def CMDtree(parser, args):
1375 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001376 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001377 status = GetTreeStatus()
1378 if 'unset' == status:
1379 print 'You must configure your tree status URL by running "git cl config".'
1380 return 2
1381
1382 print "The tree is %s" % status
1383 print
1384 print GetTreeStatusReason()
1385 if status != 'open':
1386 return 1
1387 return 0
1388
1389
1390def CMDupstream(parser, args):
1391 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001392 _, args = parser.parse_args(args)
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001393 if args:
1394 parser.error('Unrecognized args: %s' % ' '.join(args))
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001395 cl = Changelist()
1396 print cl.GetUpstreamBranch()
1397 return 0
1398
1399
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001400def CMDset_commit(parser, args):
1401 """set the commit bit"""
1402 _, args = parser.parse_args(args)
1403 if args:
1404 parser.error('Unrecognized args: %s' % ' '.join(args))
1405 cl = Changelist()
1406 cl.SetFlag('commit', '1')
1407 return 0
1408
1409
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001410def Command(name):
1411 return getattr(sys.modules[__name__], 'CMD' + name, None)
1412
1413
1414def CMDhelp(parser, args):
1415 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001416 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001417 if len(args) == 1:
1418 return main(args + ['--help'])
1419 parser.print_help()
1420 return 0
1421
1422
1423def GenUsage(parser, command):
1424 """Modify an OptParse object with the function's documentation."""
1425 obj = Command(command)
1426 more = getattr(obj, 'usage_more', '')
1427 if command == 'help':
1428 command = '<command>'
1429 else:
1430 # OptParser.description prefer nicely non-formatted strings.
1431 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1432 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1433
1434
1435def main(argv):
1436 """Doesn't parse the arguments here, just find the right subcommand to
1437 execute."""
1438 # Do it late so all commands are listed.
1439 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1440 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1441 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1442
1443 # Create the option parse and add --verbose support.
1444 parser = optparse.OptionParser()
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001445 parser.add_option(
1446 '-v', '--verbose', action='count', default=0,
1447 help='Use 2 times for more debugging info')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001448 old_parser_args = parser.parse_args
1449 def Parse(args):
1450 options, args = old_parser_args(args)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001451 if options.verbose >= 2:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001452 logging.basicConfig(level=logging.DEBUG)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001453 elif options.verbose:
1454 logging.basicConfig(level=logging.INFO)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001455 else:
1456 logging.basicConfig(level=logging.WARNING)
1457 return options, args
1458 parser.parse_args = Parse
1459
1460 if argv:
1461 command = Command(argv[0])
1462 if command:
1463 # "fix" the usage and the description now that we know the subcommand.
1464 GenUsage(parser, argv[0])
1465 try:
1466 return command(parser, argv[1:])
1467 except urllib2.HTTPError, e:
1468 if e.code != 500:
1469 raise
1470 DieWithError(
1471 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1472 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1473
1474 # Not a known command. Default to help.
1475 GenUsage(parser, 'help')
1476 return CMDhelp(parser, argv)
1477
1478
1479if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001480 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001481 sys.exit(main(sys.argv[1:]))