blob: 7e767862acd648b6155018e0acb0de501e779019 [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
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000513 def RunHook(self, committing, upstream_branch, tbr, may_prompt, verbose,
514 author):
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000515 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000516 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip() or '.'
517 absroot = os.path.abspath(root)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000518
519 # We use the sha1 of HEAD as a name of this change.
520 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000521 # Need to pass a relative path for msysgit.
522 files = scm.GIT.CaptureStatus([root], upstream_branch)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000523
524 issue = ConvertToInteger(self.GetIssue())
525 patchset = ConvertToInteger(self.GetPatchset())
526 if issue:
527 description = self.GetDescription()
528 else:
529 # If the change was never uploaded, use the log messages of all commits
530 # up to the branch point, as git cl upload will prefill the description
531 # with these log messages.
532 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
533 '%s...' % (upstream_branch)]).strip()
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000534
535 if not author:
536 author = RunGit(['config', 'user.email'])
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000537 change = presubmit_support.GitChange(
538 name,
539 description,
540 absroot,
541 files,
542 issue,
543 patchset,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000544 author)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000545
546 # Apply watchlists on upload.
547 if not committing:
548 watchlist = watchlists.Watchlists(change.RepositoryRoot())
549 files = [f.LocalPath() for f in change.AffectedFiles()]
550 self.SetWatchers(watchlist.GetWatchersForPaths(files))
551
552 try:
553 output = presubmit_support.DoPresubmitChecks(change, committing,
554 verbose=verbose, output_stream=sys.stdout, input_stream=sys.stdin,
555 default_presubmit=None, may_prompt=may_prompt, tbr=tbr,
maruel@chromium.org239f4112011-06-03 20:08:23 +0000556 rietveld_obj=self.RpcServer())
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000557 except presubmit_support.PresubmitFailure, e:
558 DieWithError(
559 ('%s\nMaybe your depot_tools is out of date?\n'
560 'If all fails, contact maruel@') % e)
561
562 # TODO(dpranke): We should propagate the error out instead of calling
563 # exit().
564 if not output.should_continue():
565 sys.exit(1)
566
567 return output
568
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000569 def CloseIssue(self):
maruel@chromium.org607bb1b2011-06-01 23:43:11 +0000570 """Updates the description and closes the issue."""
571 issue = int(self.GetIssue())
572 self.RpcServer().update_description(issue, self.description)
573 return self.RpcServer().close_issue(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000574
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000575 def SetFlag(self, flag, value):
576 """Patchset must match."""
577 if not self.GetPatchset():
578 DieWithError('The patchset needs to match. Send another patchset.')
579 try:
580 return self.RpcServer().set_flag(
581 int(self.GetIssue()), int(self.GetPatchset()), flag, value)
582 except urllib2.HTTPError, e:
583 if e.code == 404:
584 DieWithError('The issue %s doesn\'t exist.' % self.GetIssue())
585 if e.code == 403:
586 DieWithError(
587 ('Access denied to issue %s. Maybe the patchset %s doesn\'t '
588 'match?') % (self.GetIssue(), self.GetPatchset()))
589 raise
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000590
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000591 def RpcServer(self):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000592 """Returns an upload.RpcServer() to access this review's rietveld instance.
593 """
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000594 if not self._rpc_server:
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000595 self.GetIssue()
596 self._rpc_server = rietveld.Rietveld(self.rietveld_server, None, None)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000597 return self._rpc_server
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000598
599 def _IssueSetting(self):
600 """Return the git setting that stores this change's issue."""
601 return 'branch.%s.rietveldissue' % self.GetBranch()
602
603 def _PatchsetSetting(self):
604 """Return the git setting that stores this change's most recent patchset."""
605 return 'branch.%s.rietveldpatchset' % self.GetBranch()
606
607 def _RietveldServer(self):
608 """Returns the git setting that stores this change's rietveld server."""
609 return 'branch.%s.rietveldserver' % self.GetBranch()
610
611
612def GetCodereviewSettingsInteractively():
613 """Prompt the user for settings."""
614 server = settings.GetDefaultServerUrl(error_ok=True)
615 prompt = 'Rietveld server (host[:port])'
616 prompt += ' [%s]' % (server or DEFAULT_SERVER)
maruel@chromium.org90541732011-04-01 17:54:18 +0000617 newserver = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000618 if not server and not newserver:
619 newserver = DEFAULT_SERVER
620 if newserver and newserver != server:
621 RunGit(['config', 'rietveld.server', newserver])
622
623 def SetProperty(initial, caption, name):
624 prompt = caption
625 if initial:
626 prompt += ' ("x" to clear) [%s]' % initial
maruel@chromium.org90541732011-04-01 17:54:18 +0000627 new_val = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000628 if new_val == 'x':
629 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
630 elif new_val and new_val != initial:
631 RunGit(['config', 'rietveld.' + name, new_val])
632
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000633 SetProperty(settings.GetDefaultCCList(), 'CC list', 'cc')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000634 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
635 'tree-status-url')
636 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
637
638 # TODO: configure a default branch to diff against, rather than this
639 # svn-based hackery.
640
641
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000642class ChangeDescription(object):
643 """Contains a parsed form of the change description."""
644 def __init__(self, subject, log_desc, reviewers):
645 self.subject = subject
646 self.log_desc = log_desc
647 self.reviewers = reviewers
648 self.description = self.log_desc
649
650 def Update(self):
651 initial_text = """# Enter a description of the change.
652# This will displayed on the codereview site.
653# The first line will also be used as the subject of the review.
654"""
655 initial_text += self.description
656 if 'R=' not in self.description and self.reviewers:
657 initial_text += '\nR=' + self.reviewers
658 if 'BUG=' not in self.description:
659 initial_text += '\nBUG='
660 if 'TEST=' not in self.description:
661 initial_text += '\nTEST='
662 self._ParseDescription(UserEditedLog(initial_text))
663
664 def _ParseDescription(self, description):
665 if not description:
666 self.description = description
667 return
668
669 parsed_lines = []
670 reviewers_regexp = re.compile('\s*R=(.+)')
671 reviewers = ''
672 subject = ''
673 for l in description.splitlines():
674 if not subject:
675 subject = l
676 matched_reviewers = reviewers_regexp.match(l)
677 if matched_reviewers:
678 reviewers = matched_reviewers.group(1)
679 parsed_lines.append(l)
680
681 self.description = '\n'.join(parsed_lines) + '\n'
682 self.subject = subject
683 self.reviewers = reviewers
684
685 def IsEmpty(self):
686 return not self.description
687
688
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000689def FindCodereviewSettingsFile(filename='codereview.settings'):
690 """Finds the given file starting in the cwd and going up.
691
692 Only looks up to the top of the repository unless an
693 'inherit-review-settings-ok' file exists in the root of the repository.
694 """
695 inherit_ok_file = 'inherit-review-settings-ok'
696 cwd = os.getcwd()
697 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
698 if os.path.isfile(os.path.join(root, inherit_ok_file)):
699 root = '/'
700 while True:
701 if filename in os.listdir(cwd):
702 if os.path.isfile(os.path.join(cwd, filename)):
703 return open(os.path.join(cwd, filename))
704 if cwd == root:
705 break
706 cwd = os.path.dirname(cwd)
707
708
709def LoadCodereviewSettingsFromFile(fileobj):
710 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000711 keyvals = {}
712 for line in fileobj.read().splitlines():
713 if not line or line.startswith("#"):
714 continue
715 k, v = line.split(": ", 1)
716 keyvals[k] = v
717
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000718 def SetProperty(name, setting, unset_error_ok=False):
719 fullname = 'rietveld.' + name
720 if setting in keyvals:
721 RunGit(['config', fullname, keyvals[setting]])
722 else:
723 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
724
725 SetProperty('server', 'CODE_REVIEW_SERVER')
726 # Only server setting is required. Other settings can be absent.
727 # In that case, we ignore errors raised during option deletion attempt.
728 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
729 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
730 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
731
732 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
733 #should be of the form
734 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
735 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
736 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
737 keyvals['ORIGIN_URL_CONFIG']])
738
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000739
740@usage('[repo root containing codereview.settings]')
741def CMDconfig(parser, args):
742 """edit configuration for this tree"""
743
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000744 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000745 if len(args) == 0:
746 GetCodereviewSettingsInteractively()
747 return 0
748
749 url = args[0]
750 if not url.endswith('codereview.settings'):
751 url = os.path.join(url, 'codereview.settings')
752
753 # Load code review settings and download hooks (if available).
754 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
755 return 0
756
757
758def CMDstatus(parser, args):
759 """show status of changelists"""
760 parser.add_option('--field',
761 help='print only specific field (desc|id|patch|url)')
762 (options, args) = parser.parse_args(args)
763
764 # TODO: maybe make show_branches a flag if necessary.
765 show_branches = not options.field
766
767 if show_branches:
768 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
769 if branches:
770 print 'Branches associated with reviews:'
771 for branch in sorted(branches.splitlines()):
772 cl = Changelist(branchref=branch)
773 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
774
775 cl = Changelist()
776 if options.field:
777 if options.field.startswith('desc'):
778 print cl.GetDescription()
779 elif options.field == 'id':
780 issueid = cl.GetIssue()
781 if issueid:
782 print issueid
783 elif options.field == 'patch':
784 patchset = cl.GetPatchset()
785 if patchset:
786 print patchset
787 elif options.field == 'url':
788 url = cl.GetIssueURL()
789 if url:
790 print url
791 else:
792 print
793 print 'Current branch:',
794 if not cl.GetIssue():
795 print 'no issue assigned.'
796 return 0
797 print cl.GetBranch()
798 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
799 print 'Issue description:'
800 print cl.GetDescription(pretty=True)
801 return 0
802
803
804@usage('[issue_number]')
805def CMDissue(parser, args):
806 """Set or display the current code review issue number.
807
808 Pass issue number 0 to clear the current issue.
809"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000810 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000811
812 cl = Changelist()
813 if len(args) > 0:
814 try:
815 issue = int(args[0])
816 except ValueError:
817 DieWithError('Pass a number to set the issue or none to list it.\n'
818 'Maybe you want to run git cl status?')
819 cl.SetIssue(issue)
820 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
821 return 0
822
823
824def CreateDescriptionFromLog(args):
825 """Pulls out the commit log to use as a base for the CL description."""
826 log_args = []
827 if len(args) == 1 and not args[0].endswith('.'):
828 log_args = [args[0] + '..']
829 elif len(args) == 1 and args[0].endswith('...'):
830 log_args = [args[0][:-1]]
831 elif len(args) == 2:
832 log_args = [args[0] + '..' + args[1]]
833 else:
834 log_args = args[:] # Hope for the best!
835 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
836
837
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000838def UserEditedLog(starting_text):
839 """Given some starting text, let the user edit it and return the result."""
840 editor = os.getenv('EDITOR', 'vi')
841
842 (file_handle, filename) = tempfile.mkstemp()
843 fileobj = os.fdopen(file_handle, 'w')
844 fileobj.write(starting_text)
845 fileobj.close()
846
847 # Open up the default editor in the system to get the CL description.
848 try:
849 cmd = '%s %s' % (editor, filename)
850 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
851 # Msysgit requires the usage of 'env' to be present.
852 cmd = 'env ' + cmd
853 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
maruel@chromium.org2a471072011-05-10 17:29:23 +0000854 try:
855 subprocess.check_call(cmd, shell=True)
856 except subprocess.CalledProcessError, e:
857 DieWithError('Editor returned %d' % e.returncode)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000858 fileobj = open(filename)
859 text = fileobj.read()
860 fileobj.close()
861 finally:
862 os.remove(filename)
863
864 if not text:
865 return
866
867 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
868 return stripcomment_re.sub('', text).strip()
869
870
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000871def ConvertToInteger(inputval):
872 """Convert a string to integer, but returns either an int or None."""
873 try:
874 return int(inputval)
875 except (TypeError, ValueError):
876 return None
877
878
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000879def CMDpresubmit(parser, args):
880 """run presubmit tests on the current changelist"""
881 parser.add_option('--upload', action='store_true',
882 help='Run upload hook instead of the push/dcommit hook')
883 (options, args) = parser.parse_args(args)
884
885 # Make sure index is up-to-date before running diff-index.
886 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
887 if RunGit(['diff-index', 'HEAD']):
888 # TODO(maruel): Is this really necessary?
889 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
890 return 1
891
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000892 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000893 if args:
894 base_branch = args[0]
895 else:
896 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000897 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000898
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000899 cl.RunHook(committing=not options.upload, upstream_branch=base_branch,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000900 tbr=False, may_prompt=False, verbose=options.verbose,
901 author=None)
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000902 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000903
904
905@usage('[args to "git diff"]')
906def CMDupload(parser, args):
907 """upload the current changelist to codereview"""
908 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
909 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000910 parser.add_option('-f', action='store_true', dest='force',
911 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000912 parser.add_option('-m', dest='message', help='message for patch')
913 parser.add_option('-r', '--reviewers',
914 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000915 parser.add_option('--cc',
916 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000917 parser.add_option('--send-mail', action='store_true',
918 help='send email to reviewer immediately')
919 parser.add_option("--emulate_svn_auto_props", action="store_true",
920 dest="emulate_svn_auto_props",
921 help="Emulate Subversion's auto properties feature.")
922 parser.add_option("--desc_from_logs", action="store_true",
923 dest="from_logs",
924 help="""Squashes git commit logs into change description and
925 uses message as subject""")
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000926 parser.add_option('-c', '--use-commit-queue', action='store_true',
927 help='tell the commit queue to commit this patchset')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000928 (options, args) = parser.parse_args(args)
929
930 # Make sure index is up-to-date before running diff-index.
931 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
932 if RunGit(['diff-index', 'HEAD']):
933 print 'Cannot upload with a dirty tree. You must commit locally first.'
934 return 1
935
936 cl = Changelist()
937 if args:
938 base_branch = args[0]
939 else:
940 # Default to diffing against the "upstream" branch.
941 base_branch = cl.GetUpstreamBranch()
942 args = [base_branch + "..."]
943
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000944 if not options.bypass_hooks and not options.force:
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000945 hook_results = cl.RunHook(committing=False, upstream_branch=base_branch,
946 tbr=False, may_prompt=True,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000947 verbose=options.verbose,
948 author=None)
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000949 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000950 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000951
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000952
953 # --no-ext-diff is broken in some versions of Git, so try to work around
954 # this by overriding the environment (but there is still a problem if the
955 # git config key "diff.external" is used).
956 env = os.environ.copy()
957 if 'GIT_EXTERNAL_DIFF' in env:
958 del env['GIT_EXTERNAL_DIFF']
959 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
960 env=env)
961
962 upload_args = ['--assume_yes'] # Don't ask about untracked files.
963 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000964 if options.emulate_svn_auto_props:
965 upload_args.append('--emulate_svn_auto_props')
966 if options.send_mail:
967 if not options.reviewers:
968 DieWithError("Must specify reviewers to send email.")
969 upload_args.append('--send_mail')
970 if options.from_logs and not options.message:
971 print 'Must set message for subject line if using desc_from_logs'
972 return 1
973
974 change_desc = None
975
976 if cl.GetIssue():
977 if options.message:
978 upload_args.extend(['--message', options.message])
979 upload_args.extend(['--issue', cl.GetIssue()])
980 print ("This branch is associated with issue %s. "
981 "Adding patch to that issue." % cl.GetIssue())
982 else:
983 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000984 change_desc = ChangeDescription(options.message, log_desc,
985 options.reviewers)
986 if not options.from_logs:
987 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000988
989 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000990 print "Description is empty; aborting."
991 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000992
993 upload_args.extend(['--message', change_desc.subject])
994 upload_args.extend(['--description', change_desc.description])
995 if change_desc.reviewers:
996 upload_args.extend(['--reviewers', change_desc.reviewers])
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000997 cc = ','.join(filter(None, (cl.GetCCList(), options.cc)))
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000998 if cc:
999 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001000
1001 # Include the upstream repo's URL in the change -- this is useful for
1002 # projects that have their source spread across multiple repos.
1003 remote_url = None
1004 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +00001005 # URL is dependent on the current directory.
1006 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001007 if data:
1008 keys = dict(line.split(': ', 1) for line in data.splitlines()
1009 if ': ' in line)
1010 remote_url = keys.get('URL', None)
1011 else:
1012 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
1013 remote_url = (cl.GetRemoteUrl() + '@'
1014 + cl.GetUpstreamBranch().split('/')[-1])
1015 if remote_url:
1016 upload_args.extend(['--base_url', remote_url])
1017
1018 try:
1019 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
maruel@chromium.org9ce0dff2011-04-04 17:56:50 +00001020 except KeyboardInterrupt:
1021 sys.exit(1)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001022 except:
1023 # If we got an exception after the user typed a description for their
1024 # change, back up the description before re-raising.
1025 if change_desc:
1026 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
1027 print '\nGot exception while uploading -- saving description to %s\n' \
1028 % backup_path
1029 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001030 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001031 backup_file.close()
1032 raise
1033
1034 if not cl.GetIssue():
1035 cl.SetIssue(issue)
1036 cl.SetPatchset(patchset)
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001037
1038 if options.use_commit_queue:
1039 cl.SetFlag('commit', '1')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001040 return 0
1041
1042
1043def SendUpstream(parser, args, cmd):
1044 """Common code for CmdPush and CmdDCommit
1045
1046 Squashed commit into a single.
1047 Updates changelog with metadata (e.g. pointer to review).
1048 Pushes/dcommits the code upstream.
1049 Updates review and closes.
1050 """
1051 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1052 help='bypass upload presubmit hook')
1053 parser.add_option('-m', dest='message',
1054 help="override review description")
1055 parser.add_option('-f', action='store_true', dest='force',
1056 help="force yes to questions (don't prompt)")
1057 parser.add_option('-c', dest='contributor',
1058 help="external contributor for patch (appended to " +
1059 "description and used as author for git). Should be " +
1060 "formatted as 'First Last <email@example.com>'")
1061 parser.add_option('--tbr', action='store_true', dest='tbr',
1062 help="short for 'to be reviewed', commit branch " +
1063 "even without uploading for review")
1064 (options, args) = parser.parse_args(args)
1065 cl = Changelist()
1066
1067 if not args or cmd == 'push':
1068 # Default to merging against our best guess of the upstream branch.
1069 args = [cl.GetUpstreamBranch()]
1070
1071 base_branch = args[0]
1072
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001073 # Make sure index is up-to-date before running diff-index.
1074 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001075 if RunGit(['diff-index', 'HEAD']):
1076 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1077 return 1
1078
1079 # This rev-list syntax means "show all commits not in my branch that
1080 # are in base_branch".
1081 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1082 base_branch]).splitlines()
1083 if upstream_commits:
1084 print ('Base branch "%s" has %d commits '
1085 'not in this branch.' % (base_branch, len(upstream_commits)))
1086 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1087 return 1
1088
1089 if cmd == 'dcommit':
1090 # This is the revision `svn dcommit` will commit on top of.
1091 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1092 '--pretty=format:%H'])
1093 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1094 if extra_commits:
1095 print ('This branch has %d additional commits not upstreamed yet.'
1096 % len(extra_commits.splitlines()))
1097 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1098 'before attempting to %s.' % (base_branch, cmd))
1099 return 1
1100
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001101 if not options.bypass_hooks and not options.force:
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +00001102 cl.RunHook(committing=True, upstream_branch=base_branch,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +00001103 tbr=options.tbr, may_prompt=True, verbose=options.verbose,
1104 author=options.contributor)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001105
1106 if cmd == 'dcommit':
1107 # Check the tree status if the tree status URL is set.
1108 status = GetTreeStatus()
1109 if 'closed' == status:
1110 print ('The tree is closed. Please wait for it to reopen. Use '
1111 '"git cl dcommit -f" to commit on a closed tree.')
1112 return 1
1113 elif 'unknown' == status:
1114 print ('Unable to determine tree status. Please verify manually and '
1115 'use "git cl dcommit -f" to commit on a closed tree.')
1116
1117 description = options.message
1118 if not options.tbr:
1119 # It is important to have these checks early. Not only for user
1120 # convenience, but also because the cl object then caches the correct values
1121 # of these fields even as we're juggling branches for setting up the commit.
1122 if not cl.GetIssue():
1123 print 'Current issue unknown -- has this branch been uploaded?'
1124 print 'Use --tbr to commit without review.'
1125 return 1
1126
1127 if not description:
1128 description = cl.GetDescription()
1129
1130 if not description:
1131 print 'No description set.'
1132 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1133 return 1
1134
1135 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1136 else:
1137 if not description:
1138 # Submitting TBR. See if there's already a description in Rietveld, else
1139 # create a template description. Eitherway, give the user a chance to edit
1140 # it to fill in the TBR= field.
1141 if cl.GetIssue():
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001142 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001143
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001144 # TODO(dpranke): Update to use ChangeDescription object.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001145 if not description:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001146 description = """# Enter a description of the change.
1147# This will be used as the change log for the commit.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001148
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001149"""
1150 description += CreateDescriptionFromLog(args)
1151
1152 description = UserEditedLog(description + '\nTBR=')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001153
1154 if not description:
1155 print "Description empty; aborting."
1156 return 1
1157
1158 if options.contributor:
1159 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1160 print "Please provide contibutor as 'First Last <email@example.com>'"
1161 return 1
1162 description += "\nPatch from %s." % options.contributor
1163 print 'Description:', repr(description)
1164
1165 branches = [base_branch, cl.GetBranchRef()]
1166 if not options.force:
1167 subprocess.call(['git', 'diff', '--stat'] + branches)
maruel@chromium.org90541732011-04-01 17:54:18 +00001168 ask_for_data('About to commit; enter to confirm.')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001169
1170 # We want to squash all this branch's commits into one commit with the
1171 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001172 # We do this by doing a "reset --soft" to the base branch (which keeps
1173 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001174 MERGE_BRANCH = 'git-cl-commit'
1175 # Delete the merge branch if it already exists.
1176 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1177 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1178 RunGit(['branch', '-D', MERGE_BRANCH])
1179
1180 # We might be in a directory that's present in this branch but not in the
1181 # trunk. Move up to the top of the tree so that git commands that expect a
1182 # valid CWD won't fail after we check out the merge branch.
1183 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1184 if rel_base_path:
1185 os.chdir(rel_base_path)
1186
1187 # Stuff our change into the merge branch.
1188 # We wrap in a try...finally block so if anything goes wrong,
1189 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001190 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001191 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001192 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1193 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001194 if options.contributor:
1195 RunGit(['commit', '--author', options.contributor, '-m', description])
1196 else:
1197 RunGit(['commit', '-m', description])
1198 if cmd == 'push':
1199 # push the merge branch.
1200 remote, branch = cl.FetchUpstreamTuple()
1201 retcode, output = RunGitWithCode(
1202 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1203 logging.debug(output)
1204 else:
1205 # dcommit the merge branch.
bauerb@chromium.org2e64fa12011-05-05 11:13:44 +00001206 retcode, output = RunGitWithCode(['svn', 'dcommit',
1207 '--no-rebase', '--rmdir'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001208 finally:
1209 # And then swap back to the original branch and clean up.
1210 RunGit(['checkout', '-q', cl.GetBranch()])
1211 RunGit(['branch', '-D', MERGE_BRANCH])
1212
1213 if cl.GetIssue():
1214 if cmd == 'dcommit' and 'Committed r' in output:
1215 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1216 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001217 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1218 for l in output.splitlines(False))
1219 match = filter(None, match)
1220 if len(match) != 1:
1221 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1222 output)
1223 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001224 else:
1225 return 1
1226 viewvc_url = settings.GetViewVCUrl()
1227 if viewvc_url and revision:
1228 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1229 print ('Closing issue '
1230 '(you may be prompted for your codereview password)...')
1231 cl.CloseIssue()
1232 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001233
1234 if retcode == 0:
1235 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1236 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001237 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001238
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001239 return 0
1240
1241
1242@usage('[upstream branch to apply against]')
1243def CMDdcommit(parser, args):
1244 """commit the current changelist via git-svn"""
1245 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001246 message = """This doesn't appear to be an SVN repository.
1247If your project has a git mirror with an upstream SVN master, you probably need
1248to run 'git svn init', see your project's git mirror documentation.
1249If your project has a true writeable upstream repository, you probably want
1250to run 'git cl push' instead.
1251Choose wisely, if you get this wrong, your commit might appear to succeed but
1252will instead be silently ignored."""
1253 print(message)
maruel@chromium.org90541732011-04-01 17:54:18 +00001254 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001255 return SendUpstream(parser, args, 'dcommit')
1256
1257
1258@usage('[upstream branch to apply against]')
1259def CMDpush(parser, args):
1260 """commit the current changelist via git"""
1261 if settings.GetIsGitSvn():
1262 print('This appears to be an SVN repository.')
1263 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
maruel@chromium.org90541732011-04-01 17:54:18 +00001264 ask_for_data('[Press enter to push or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001265 return SendUpstream(parser, args, 'push')
1266
1267
1268@usage('<patch url or issue id>')
1269def CMDpatch(parser, args):
1270 """patch in a code review"""
1271 parser.add_option('-b', dest='newbranch',
1272 help='create a new branch off trunk for the patch')
1273 parser.add_option('-f', action='store_true', dest='force',
1274 help='with -b, clobber any existing branch')
1275 parser.add_option('--reject', action='store_true', dest='reject',
1276 help='allow failed patches and spew .rej files')
1277 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1278 help="don't commit after patch applies")
1279 (options, args) = parser.parse_args(args)
1280 if len(args) != 1:
1281 parser.print_help()
1282 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001283 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001284
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001285 # TODO(maruel): Use apply_issue.py
1286
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001287 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001288 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001289 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001290 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001291 else:
1292 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001293 issue_url = FixUrl(issue_arg)
1294 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001295 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001296 DieWithError('Must pass an issue ID or full URL for '
1297 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001298 issue = match.group(1)
1299 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001300
1301 if options.newbranch:
1302 if options.force:
1303 RunGit(['branch', '-D', options.newbranch],
1304 swallow_stderr=True, error_ok=True)
1305 RunGit(['checkout', '-b', options.newbranch,
1306 Changelist().GetUpstreamBranch()])
1307
1308 # Switch up to the top-level directory, if necessary, in preparation for
1309 # applying the patch.
1310 top = RunGit(['rev-parse', '--show-cdup']).strip()
1311 if top:
1312 os.chdir(top)
1313
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001314 # Git patches have a/ at the beginning of source paths. We strip that out
1315 # with a sed script rather than the -p flag to patch so we can feed either
1316 # Git or svn-style patches into the same apply command.
1317 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1318 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1319 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1320 patch_data = sed_proc.communicate(patch_data)[0]
1321 if sed_proc.returncode:
1322 DieWithError('Git patch mungling failed.')
1323 logging.info(patch_data)
1324 # We use "git apply" to apply the patch instead of "patch" so that we can
1325 # pick up file adds.
1326 # The --index flag means: also insert into the index (so we catch adds).
1327 cmd = ['git', 'apply', '--index', '-p0']
1328 if options.reject:
1329 cmd.append('--reject')
1330 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1331 patch_proc.communicate(patch_data)
1332 if patch_proc.returncode:
1333 DieWithError('Failed to apply the patch')
1334
1335 # If we had an issue, commit the current state and register the issue.
1336 if not options.nocommit:
1337 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1338 cl = Changelist()
1339 cl.SetIssue(issue)
1340 print "Committed patch."
1341 else:
1342 print "Patch applied to index."
1343 return 0
1344
1345
1346def CMDrebase(parser, args):
1347 """rebase current branch on top of svn repo"""
1348 # Provide a wrapper for git svn rebase to help avoid accidental
1349 # git svn dcommit.
1350 # It's the only command that doesn't use parser at all since we just defer
1351 # execution to git-svn.
1352 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1353 return 0
1354
1355
1356def GetTreeStatus():
1357 """Fetches the tree status and returns either 'open', 'closed',
1358 'unknown' or 'unset'."""
1359 url = settings.GetTreeStatusUrl(error_ok=True)
1360 if url:
1361 status = urllib2.urlopen(url).read().lower()
1362 if status.find('closed') != -1 or status == '0':
1363 return 'closed'
1364 elif status.find('open') != -1 or status == '1':
1365 return 'open'
1366 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001367 return 'unset'
1368
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001369
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001370def GetTreeStatusReason():
1371 """Fetches the tree status from a json url and returns the message
1372 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001373 url = settings.GetTreeStatusUrl()
1374 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001375 connection = urllib2.urlopen(json_url)
1376 status = json.loads(connection.read())
1377 connection.close()
1378 return status['message']
1379
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001380
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001381def CMDtree(parser, args):
1382 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001383 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001384 status = GetTreeStatus()
1385 if 'unset' == status:
1386 print 'You must configure your tree status URL by running "git cl config".'
1387 return 2
1388
1389 print "The tree is %s" % status
1390 print
1391 print GetTreeStatusReason()
1392 if status != 'open':
1393 return 1
1394 return 0
1395
1396
1397def CMDupstream(parser, args):
1398 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001399 _, args = parser.parse_args(args)
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001400 if args:
1401 parser.error('Unrecognized args: %s' % ' '.join(args))
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001402 cl = Changelist()
1403 print cl.GetUpstreamBranch()
1404 return 0
1405
1406
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001407def CMDset_commit(parser, args):
1408 """set the commit bit"""
1409 _, args = parser.parse_args(args)
1410 if args:
1411 parser.error('Unrecognized args: %s' % ' '.join(args))
1412 cl = Changelist()
1413 cl.SetFlag('commit', '1')
1414 return 0
1415
1416
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001417def Command(name):
1418 return getattr(sys.modules[__name__], 'CMD' + name, None)
1419
1420
1421def CMDhelp(parser, args):
1422 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001423 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001424 if len(args) == 1:
1425 return main(args + ['--help'])
1426 parser.print_help()
1427 return 0
1428
1429
1430def GenUsage(parser, command):
1431 """Modify an OptParse object with the function's documentation."""
1432 obj = Command(command)
1433 more = getattr(obj, 'usage_more', '')
1434 if command == 'help':
1435 command = '<command>'
1436 else:
1437 # OptParser.description prefer nicely non-formatted strings.
1438 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1439 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1440
1441
1442def main(argv):
1443 """Doesn't parse the arguments here, just find the right subcommand to
1444 execute."""
1445 # Do it late so all commands are listed.
1446 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1447 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1448 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1449
1450 # Create the option parse and add --verbose support.
1451 parser = optparse.OptionParser()
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001452 parser.add_option(
1453 '-v', '--verbose', action='count', default=0,
1454 help='Use 2 times for more debugging info')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001455 old_parser_args = parser.parse_args
1456 def Parse(args):
1457 options, args = old_parser_args(args)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001458 if options.verbose >= 2:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001459 logging.basicConfig(level=logging.DEBUG)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001460 elif options.verbose:
1461 logging.basicConfig(level=logging.INFO)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001462 else:
1463 logging.basicConfig(level=logging.WARNING)
1464 return options, args
1465 parser.parse_args = Parse
1466
1467 if argv:
1468 command = Command(argv[0])
1469 if command:
1470 # "fix" the usage and the description now that we know the subcommand.
1471 GenUsage(parser, argv[0])
1472 try:
1473 return command(parser, argv[1:])
1474 except urllib2.HTTPError, e:
1475 if e.code != 500:
1476 raise
1477 DieWithError(
1478 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1479 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1480
1481 # Not a known command. Default to help.
1482 GenUsage(parser, 'help')
1483 return CMDhelp(parser, argv)
1484
1485
1486if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001487 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001488 sys.exit(main(sys.argv[1:]))