blob: ba31e189e0fef1a1fd10d9bd3db342eaa66bb5d7 [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
42import scm
43import watchlists
44
45
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000046
47DEFAULT_SERVER = 'http://codereview.appspot.com'
maruel@chromium.org0ba7f962011-01-11 22:13:58 +000048POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000049DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup'
50
maruel@chromium.org90541732011-04-01 17:54:18 +000051
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000052def DieWithError(message):
dpranke@chromium.org970c5222011-03-12 00:32:24 +000053 print >> sys.stderr, message
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000054 sys.exit(1)
55
56
57def Popen(cmd, **kwargs):
58 """Wrapper for subprocess.Popen() that logs and watch for cygwin issues"""
maruel@chromium.org899e1c12011-04-07 17:03:18 +000059 logging.debug('Popen: ' + ' '.join(cmd))
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000060 try:
61 return subprocess.Popen(cmd, **kwargs)
62 except OSError, e:
63 if e.errno == errno.EAGAIN and sys.platform == 'cygwin':
64 DieWithError(
65 'Visit '
66 'http://code.google.com/p/chromium/wiki/CygwinDllRemappingFailure to '
67 'learn how to fix this error; you need to rebase your cygwin dlls')
68 raise
69
70
71def RunCommand(cmd, error_ok=False, error_message=None,
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000072 redirect_stdout=True, swallow_stderr=False, **kwargs):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000073 if redirect_stdout:
74 stdout = subprocess.PIPE
75 else:
76 stdout = None
77 if swallow_stderr:
78 stderr = subprocess.PIPE
79 else:
80 stderr = None
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000081 proc = Popen(cmd, stdout=stdout, stderr=stderr, **kwargs)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000082 output = proc.communicate()[0]
83 if not error_ok and proc.returncode != 0:
84 DieWithError('Command "%s" failed.\n' % (' '.join(cmd)) +
85 (error_message or output or ''))
86 return output
87
88
89def RunGit(args, **kwargs):
90 cmd = ['git'] + args
91 return RunCommand(cmd, **kwargs)
92
93
94def RunGitWithCode(args):
95 proc = Popen(['git'] + args, stdout=subprocess.PIPE)
96 output = proc.communicate()[0]
97 return proc.returncode, output
98
99
100def usage(more):
101 def hook(fn):
102 fn.usage_more = more
103 return fn
104 return hook
105
106
maruel@chromium.org90541732011-04-01 17:54:18 +0000107def ask_for_data(prompt):
108 try:
109 return raw_input(prompt)
110 except KeyboardInterrupt:
111 # Hide the exception.
112 sys.exit(1)
113
114
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000115def FixUrl(server):
116 """Fix a server url to defaults protocol to http:// if none is specified."""
117 if not server:
118 return server
119 if not re.match(r'[a-z]+\://.*', server):
120 return 'http://' + server
121 return server
122
123
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000124def MatchSvnGlob(url, base_url, glob_spec, allow_wildcards):
125 """Return the corresponding git ref if |base_url| together with |glob_spec|
126 matches the full |url|.
127
128 If |allow_wildcards| is true, |glob_spec| can contain wildcards (see below).
129 """
130 fetch_suburl, as_ref = glob_spec.split(':')
131 if allow_wildcards:
132 glob_match = re.match('(.+/)?(\*|{[^/]*})(/.+)?', fetch_suburl)
133 if glob_match:
134 # Parse specs like "branches/*/src:refs/remotes/svn/*" or
135 # "branches/{472,597,648}/src:refs/remotes/svn/*".
136 branch_re = re.escape(base_url)
137 if glob_match.group(1):
138 branch_re += '/' + re.escape(glob_match.group(1))
139 wildcard = glob_match.group(2)
140 if wildcard == '*':
141 branch_re += '([^/]*)'
142 else:
143 # Escape and replace surrounding braces with parentheses and commas
144 # with pipe symbols.
145 wildcard = re.escape(wildcard)
146 wildcard = re.sub('^\\\\{', '(', wildcard)
147 wildcard = re.sub('\\\\,', '|', wildcard)
148 wildcard = re.sub('\\\\}$', ')', wildcard)
149 branch_re += wildcard
150 if glob_match.group(3):
151 branch_re += re.escape(glob_match.group(3))
152 match = re.match(branch_re, url)
153 if match:
154 return re.sub('\*$', match.group(1), as_ref)
155
156 # Parse specs like "trunk/src:refs/remotes/origin/trunk".
157 if fetch_suburl:
158 full_url = base_url + '/' + fetch_suburl
159 else:
160 full_url = base_url
161 if full_url == url:
162 return as_ref
163 return None
164
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000165class Settings(object):
166 def __init__(self):
167 self.default_server = None
168 self.cc = None
169 self.root = None
170 self.is_git_svn = None
171 self.svn_branch = None
172 self.tree_status_url = None
173 self.viewvc_url = None
174 self.updated = False
175
176 def LazyUpdateIfNeeded(self):
177 """Updates the settings from a codereview.settings file, if available."""
178 if not self.updated:
179 cr_settings_file = FindCodereviewSettingsFile()
180 if cr_settings_file:
181 LoadCodereviewSettingsFromFile(cr_settings_file)
182 self.updated = True
183
184 def GetDefaultServerUrl(self, error_ok=False):
185 if not self.default_server:
186 self.LazyUpdateIfNeeded()
187 self.default_server = FixUrl(self._GetConfig('rietveld.server',
188 error_ok=True))
189 if error_ok:
190 return self.default_server
191 if not self.default_server:
192 error_message = ('Could not find settings file. You must configure '
193 'your review setup by running "git cl config".')
194 self.default_server = FixUrl(self._GetConfig(
195 'rietveld.server', error_message=error_message))
196 return self.default_server
197
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000198 def GetRoot(self):
199 if not self.root:
200 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
201 return self.root
202
203 def GetIsGitSvn(self):
204 """Return true if this repo looks like it's using git-svn."""
205 if self.is_git_svn is None:
206 # If you have any "svn-remote.*" config keys, we think you're using svn.
207 self.is_git_svn = RunGitWithCode(
208 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
209 return self.is_git_svn
210
211 def GetSVNBranch(self):
212 if self.svn_branch is None:
213 if not self.GetIsGitSvn():
214 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
215
216 # Try to figure out which remote branch we're based on.
217 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000218 # 1) iterate through our branch history and find the svn URL.
219 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000220
221 # regexp matching the git-svn line that contains the URL.
222 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
223
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000224 # We don't want to go through all of history, so read a line from the
225 # pipe at a time.
226 # The -100 is an arbitrary limit so we don't search forever.
227 cmd = ['git', 'log', '-100', '--pretty=medium']
228 proc = Popen(cmd, stdout=subprocess.PIPE)
229 for line in proc.stdout:
230 match = git_svn_re.match(line)
231 if match:
232 url = match.group(1)
233 proc.stdout.close() # Cut pipe.
234 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000235
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000236 if url:
237 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
238 remotes = RunGit(['config', '--get-regexp',
239 r'^svn-remote\..*\.url']).splitlines()
240 for remote in remotes:
241 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000242 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000243 remote = match.group(1)
244 base_url = match.group(2)
245 fetch_spec = RunGit(
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000246 ['config', 'svn-remote.%s.fetch' % remote],
247 error_ok=True).strip()
248 if fetch_spec:
249 self.svn_branch = MatchSvnGlob(url, base_url, fetch_spec, False)
250 if self.svn_branch:
251 break
252 branch_spec = RunGit(
253 ['config', 'svn-remote.%s.branches' % remote],
254 error_ok=True).strip()
255 if branch_spec:
256 self.svn_branch = MatchSvnGlob(url, base_url, branch_spec, True)
257 if self.svn_branch:
258 break
259 tag_spec = RunGit(
260 ['config', 'svn-remote.%s.tags' % remote],
261 error_ok=True).strip()
262 if tag_spec:
263 self.svn_branch = MatchSvnGlob(url, base_url, tag_spec, True)
264 if self.svn_branch:
265 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000266
267 if not self.svn_branch:
268 DieWithError('Can\'t guess svn branch -- try specifying it on the '
269 'command line')
270
271 return self.svn_branch
272
273 def GetTreeStatusUrl(self, error_ok=False):
274 if not self.tree_status_url:
275 error_message = ('You must configure your tree status URL by running '
276 '"git cl config".')
277 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
278 error_ok=error_ok,
279 error_message=error_message)
280 return self.tree_status_url
281
282 def GetViewVCUrl(self):
283 if not self.viewvc_url:
284 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
285 return self.viewvc_url
286
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000287 def GetDefaultCCList(self):
288 return self._GetConfig('rietveld.cc', error_ok=True)
289
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000290 def _GetConfig(self, param, **kwargs):
291 self.LazyUpdateIfNeeded()
292 return RunGit(['config', param], **kwargs).strip()
293
294
295settings = Settings()
296
297
298did_migrate_check = False
299def CheckForMigration():
300 """Migrate from the old issue format, if found.
301
302 We used to store the branch<->issue mapping in a file in .git, but it's
303 better to store it in the .git/config, since deleting a branch deletes that
304 branch's entry there.
305 """
306
307 # Don't run more than once.
308 global did_migrate_check
309 if did_migrate_check:
310 return
311
312 gitdir = RunGit(['rev-parse', '--git-dir']).strip()
313 storepath = os.path.join(gitdir, 'cl-mapping')
314 if os.path.exists(storepath):
315 print "old-style git-cl mapping file (%s) found; migrating." % storepath
316 store = open(storepath, 'r')
317 for line in store:
318 branch, issue = line.strip().split()
319 RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
320 issue])
321 store.close()
322 os.remove(storepath)
323 did_migrate_check = True
324
325
326def ShortBranchName(branch):
327 """Convert a name like 'refs/heads/foo' to just 'foo'."""
328 return branch.replace('refs/heads/', '')
329
330
331class Changelist(object):
332 def __init__(self, branchref=None):
333 # Poke settings so we get the "configure your server" message if necessary.
334 settings.GetDefaultServerUrl()
335 self.branchref = branchref
336 if self.branchref:
337 self.branch = ShortBranchName(self.branchref)
338 else:
339 self.branch = None
340 self.rietveld_server = None
341 self.upstream_branch = None
342 self.has_issue = False
343 self.issue = None
344 self.has_description = False
345 self.description = None
346 self.has_patchset = False
347 self.patchset = None
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000348 self._rpc_server = None
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000349 self.cc = None
350 self.watchers = ()
351
352 def GetCCList(self):
353 """Return the users cc'd on this CL.
354
355 Return is a string suitable for passing to gcl with the --cc flag.
356 """
357 if self.cc is None:
358 base_cc = settings .GetDefaultCCList()
359 more_cc = ','.join(self.watchers)
360 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
361 return self.cc
362
363 def SetWatchers(self, watchers):
364 """Set the list of email addresses that should be cc'd based on the changed
365 files in this CL.
366 """
367 self.watchers = watchers
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000368
369 def GetBranch(self):
370 """Returns the short branch name, e.g. 'master'."""
371 if not self.branch:
372 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
373 self.branch = ShortBranchName(self.branchref)
374 return self.branch
375
376 def GetBranchRef(self):
377 """Returns the full branch name, e.g. 'refs/heads/master'."""
378 self.GetBranch() # Poke the lazy loader.
379 return self.branchref
380
381 def FetchUpstreamTuple(self):
382 """Returns a tuple containg remote and remote ref,
383 e.g. 'origin', 'refs/heads/master'
384 """
385 remote = '.'
386 branch = self.GetBranch()
387 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
388 error_ok=True).strip()
389 if upstream_branch:
390 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
391 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000392 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
393 error_ok=True).strip()
394 if upstream_branch:
395 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000396 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000397 # Fall back on trying a git-svn upstream branch.
398 if settings.GetIsGitSvn():
399 upstream_branch = settings.GetSVNBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000400 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000401 # Else, try to guess the origin remote.
402 remote_branches = RunGit(['branch', '-r']).split()
403 if 'origin/master' in remote_branches:
404 # Fall back on origin/master if it exits.
405 remote = 'origin'
406 upstream_branch = 'refs/heads/master'
407 elif 'origin/trunk' in remote_branches:
408 # Fall back on origin/trunk if it exists. Generally a shared
409 # git-svn clone
410 remote = 'origin'
411 upstream_branch = 'refs/heads/trunk'
412 else:
413 DieWithError("""Unable to determine default branch to diff against.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000414Either pass complete "git diff"-style arguments, like
415 git cl upload origin/master
416or verify this branch is set up to track another (via the --track argument to
417"git checkout -b ...").""")
418
419 return remote, upstream_branch
420
421 def GetUpstreamBranch(self):
422 if self.upstream_branch is None:
423 remote, upstream_branch = self.FetchUpstreamTuple()
424 if remote is not '.':
425 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
426 self.upstream_branch = upstream_branch
427 return self.upstream_branch
428
429 def GetRemoteUrl(self):
430 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
431
432 Returns None if there is no remote.
433 """
434 remote = self.FetchUpstreamTuple()[0]
435 if remote == '.':
436 return None
437 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
438
439 def GetIssue(self):
440 if not self.has_issue:
441 CheckForMigration()
442 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
443 if issue:
444 self.issue = issue
445 self.rietveld_server = FixUrl(RunGit(
446 ['config', self._RietveldServer()], error_ok=True).strip())
447 else:
448 self.issue = None
449 if not self.rietveld_server:
450 self.rietveld_server = settings.GetDefaultServerUrl()
451 self.has_issue = True
452 return self.issue
453
454 def GetRietveldServer(self):
455 self.GetIssue()
456 return self.rietveld_server
457
458 def GetIssueURL(self):
459 """Get the URL for a particular issue."""
460 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
461
462 def GetDescription(self, pretty=False):
463 if not self.has_description:
464 if self.GetIssue():
465 path = '/' + self.GetIssue() + '/description'
466 rpc_server = self._RpcServer()
467 self.description = rpc_server.Send(path).strip()
468 self.has_description = True
469 if pretty:
470 wrapper = textwrap.TextWrapper()
471 wrapper.initial_indent = wrapper.subsequent_indent = ' '
472 return wrapper.fill(self.description)
473 return self.description
474
475 def GetPatchset(self):
476 if not self.has_patchset:
477 patchset = RunGit(['config', self._PatchsetSetting()],
478 error_ok=True).strip()
479 if patchset:
480 self.patchset = patchset
481 else:
482 self.patchset = None
483 self.has_patchset = True
484 return self.patchset
485
486 def SetPatchset(self, patchset):
487 """Set this branch's patchset. If patchset=0, clears the patchset."""
488 if patchset:
489 RunGit(['config', self._PatchsetSetting(), str(patchset)])
490 else:
491 RunGit(['config', '--unset', self._PatchsetSetting()],
492 swallow_stderr=True, error_ok=True)
493 self.has_patchset = False
494
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000495 def GetPatchSetDiff(self, issue):
496 # Grab the last patchset of the issue first.
497 data = json.loads(self._RpcServer().Send('/api/%s' % issue))
498 patchset = data['patchsets'][-1]
499 return self._RpcServer().Send(
500 '/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
513 def CloseIssue(self):
514 rpc_server = self._RpcServer()
515 # Newer versions of Rietveld require us to pass an XSRF token to POST, so
516 # we fetch it from the server. (The version used by Chromium has been
517 # modified so the token isn't required when closing an issue.)
518 xsrf_token = rpc_server.Send('/xsrf_token',
519 extra_headers={'X-Requesting-XSRF-Token': '1'})
520
521 # You cannot close an issue with a GET.
522 # We pass an empty string for the data so it is a POST rather than a GET.
523 data = [("description", self.description),
524 ("xsrf_token", xsrf_token)]
525 ctype, body = upload.EncodeMultipartFormData(data, [])
526 rpc_server.Send('/' + self.GetIssue() + '/close', body, ctype)
527
528 def _RpcServer(self):
529 """Returns an upload.RpcServer() to access this review's rietveld instance.
530 """
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000531 if not self._rpc_server:
532 server = self.GetRietveldServer()
533 self._rpc_server = upload.GetRpcServer(server, save_cookies=True)
534 return self._rpc_server
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000535
536 def _IssueSetting(self):
537 """Return the git setting that stores this change's issue."""
538 return 'branch.%s.rietveldissue' % self.GetBranch()
539
540 def _PatchsetSetting(self):
541 """Return the git setting that stores this change's most recent patchset."""
542 return 'branch.%s.rietveldpatchset' % self.GetBranch()
543
544 def _RietveldServer(self):
545 """Returns the git setting that stores this change's rietveld server."""
546 return 'branch.%s.rietveldserver' % self.GetBranch()
547
548
549def GetCodereviewSettingsInteractively():
550 """Prompt the user for settings."""
551 server = settings.GetDefaultServerUrl(error_ok=True)
552 prompt = 'Rietveld server (host[:port])'
553 prompt += ' [%s]' % (server or DEFAULT_SERVER)
maruel@chromium.org90541732011-04-01 17:54:18 +0000554 newserver = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000555 if not server and not newserver:
556 newserver = DEFAULT_SERVER
557 if newserver and newserver != server:
558 RunGit(['config', 'rietveld.server', newserver])
559
560 def SetProperty(initial, caption, name):
561 prompt = caption
562 if initial:
563 prompt += ' ("x" to clear) [%s]' % initial
maruel@chromium.org90541732011-04-01 17:54:18 +0000564 new_val = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000565 if new_val == 'x':
566 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
567 elif new_val and new_val != initial:
568 RunGit(['config', 'rietveld.' + name, new_val])
569
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000570 SetProperty(settings.GetDefaultCCList(), 'CC list', 'cc')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000571 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
572 'tree-status-url')
573 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
574
575 # TODO: configure a default branch to diff against, rather than this
576 # svn-based hackery.
577
578
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000579class ChangeDescription(object):
580 """Contains a parsed form of the change description."""
581 def __init__(self, subject, log_desc, reviewers):
582 self.subject = subject
583 self.log_desc = log_desc
584 self.reviewers = reviewers
585 self.description = self.log_desc
586
587 def Update(self):
588 initial_text = """# Enter a description of the change.
589# This will displayed on the codereview site.
590# The first line will also be used as the subject of the review.
591"""
592 initial_text += self.description
593 if 'R=' not in self.description and self.reviewers:
594 initial_text += '\nR=' + self.reviewers
595 if 'BUG=' not in self.description:
596 initial_text += '\nBUG='
597 if 'TEST=' not in self.description:
598 initial_text += '\nTEST='
599 self._ParseDescription(UserEditedLog(initial_text))
600
601 def _ParseDescription(self, description):
602 if not description:
603 self.description = description
604 return
605
606 parsed_lines = []
607 reviewers_regexp = re.compile('\s*R=(.+)')
608 reviewers = ''
609 subject = ''
610 for l in description.splitlines():
611 if not subject:
612 subject = l
613 matched_reviewers = reviewers_regexp.match(l)
614 if matched_reviewers:
615 reviewers = matched_reviewers.group(1)
616 parsed_lines.append(l)
617
618 self.description = '\n'.join(parsed_lines) + '\n'
619 self.subject = subject
620 self.reviewers = reviewers
621
622 def IsEmpty(self):
623 return not self.description
624
625
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000626def FindCodereviewSettingsFile(filename='codereview.settings'):
627 """Finds the given file starting in the cwd and going up.
628
629 Only looks up to the top of the repository unless an
630 'inherit-review-settings-ok' file exists in the root of the repository.
631 """
632 inherit_ok_file = 'inherit-review-settings-ok'
633 cwd = os.getcwd()
634 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
635 if os.path.isfile(os.path.join(root, inherit_ok_file)):
636 root = '/'
637 while True:
638 if filename in os.listdir(cwd):
639 if os.path.isfile(os.path.join(cwd, filename)):
640 return open(os.path.join(cwd, filename))
641 if cwd == root:
642 break
643 cwd = os.path.dirname(cwd)
644
645
646def LoadCodereviewSettingsFromFile(fileobj):
647 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000648 keyvals = {}
649 for line in fileobj.read().splitlines():
650 if not line or line.startswith("#"):
651 continue
652 k, v = line.split(": ", 1)
653 keyvals[k] = v
654
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000655 def SetProperty(name, setting, unset_error_ok=False):
656 fullname = 'rietveld.' + name
657 if setting in keyvals:
658 RunGit(['config', fullname, keyvals[setting]])
659 else:
660 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
661
662 SetProperty('server', 'CODE_REVIEW_SERVER')
663 # Only server setting is required. Other settings can be absent.
664 # In that case, we ignore errors raised during option deletion attempt.
665 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
666 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
667 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
668
669 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
670 #should be of the form
671 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
672 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
673 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
674 keyvals['ORIGIN_URL_CONFIG']])
675
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000676
677@usage('[repo root containing codereview.settings]')
678def CMDconfig(parser, args):
679 """edit configuration for this tree"""
680
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000681 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000682 if len(args) == 0:
683 GetCodereviewSettingsInteractively()
684 return 0
685
686 url = args[0]
687 if not url.endswith('codereview.settings'):
688 url = os.path.join(url, 'codereview.settings')
689
690 # Load code review settings and download hooks (if available).
691 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
692 return 0
693
694
695def CMDstatus(parser, args):
696 """show status of changelists"""
697 parser.add_option('--field',
698 help='print only specific field (desc|id|patch|url)')
699 (options, args) = parser.parse_args(args)
700
701 # TODO: maybe make show_branches a flag if necessary.
702 show_branches = not options.field
703
704 if show_branches:
705 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
706 if branches:
707 print 'Branches associated with reviews:'
708 for branch in sorted(branches.splitlines()):
709 cl = Changelist(branchref=branch)
710 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
711
712 cl = Changelist()
713 if options.field:
714 if options.field.startswith('desc'):
715 print cl.GetDescription()
716 elif options.field == 'id':
717 issueid = cl.GetIssue()
718 if issueid:
719 print issueid
720 elif options.field == 'patch':
721 patchset = cl.GetPatchset()
722 if patchset:
723 print patchset
724 elif options.field == 'url':
725 url = cl.GetIssueURL()
726 if url:
727 print url
728 else:
729 print
730 print 'Current branch:',
731 if not cl.GetIssue():
732 print 'no issue assigned.'
733 return 0
734 print cl.GetBranch()
735 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
736 print 'Issue description:'
737 print cl.GetDescription(pretty=True)
738 return 0
739
740
741@usage('[issue_number]')
742def CMDissue(parser, args):
743 """Set or display the current code review issue number.
744
745 Pass issue number 0 to clear the current issue.
746"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000747 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000748
749 cl = Changelist()
750 if len(args) > 0:
751 try:
752 issue = int(args[0])
753 except ValueError:
754 DieWithError('Pass a number to set the issue or none to list it.\n'
755 'Maybe you want to run git cl status?')
756 cl.SetIssue(issue)
757 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
758 return 0
759
760
761def CreateDescriptionFromLog(args):
762 """Pulls out the commit log to use as a base for the CL description."""
763 log_args = []
764 if len(args) == 1 and not args[0].endswith('.'):
765 log_args = [args[0] + '..']
766 elif len(args) == 1 and args[0].endswith('...'):
767 log_args = [args[0][:-1]]
768 elif len(args) == 2:
769 log_args = [args[0] + '..' + args[1]]
770 else:
771 log_args = args[:] # Hope for the best!
772 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
773
774
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000775def UserEditedLog(starting_text):
776 """Given some starting text, let the user edit it and return the result."""
777 editor = os.getenv('EDITOR', 'vi')
778
779 (file_handle, filename) = tempfile.mkstemp()
780 fileobj = os.fdopen(file_handle, 'w')
781 fileobj.write(starting_text)
782 fileobj.close()
783
784 # Open up the default editor in the system to get the CL description.
785 try:
786 cmd = '%s %s' % (editor, filename)
787 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
788 # Msysgit requires the usage of 'env' to be present.
789 cmd = 'env ' + cmd
790 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
791 subprocess.check_call(cmd, shell=True)
792 fileobj = open(filename)
793 text = fileobj.read()
794 fileobj.close()
795 finally:
796 os.remove(filename)
797
798 if not text:
799 return
800
801 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
802 return stripcomment_re.sub('', text).strip()
803
804
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000805def ConvertToInteger(inputval):
806 """Convert a string to integer, but returns either an int or None."""
807 try:
808 return int(inputval)
809 except (TypeError, ValueError):
810 return None
811
812
maruel@chromium.org899e1c12011-04-07 17:03:18 +0000813def RunHook(committing, upstream_branch, rietveld_server, tbr, may_prompt,
814 verbose):
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000815 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000816 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip()
817 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000818 root = '.'
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000819 absroot = os.path.abspath(root)
820 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000821 raise Exception('Could not get root directory.')
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000822
823 # We use the sha1 of HEAD as a name of this change.
824 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
825 files = scm.GIT.CaptureStatus([root], upstream_branch)
826
827 cl = Changelist()
828 issue = ConvertToInteger(cl.GetIssue())
829 patchset = ConvertToInteger(cl.GetPatchset())
830 if issue:
831 description = cl.GetDescription()
832 else:
833 # If the change was never uploaded, use the log messages of all commits
834 # up to the branch point, as git cl upload will prefill the description
835 # with these log messages.
836 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000837 '%s...' % (upstream_branch)]).strip()
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000838 change = presubmit_support.GitChange(name, description, absroot, files,
839 issue, patchset)
840
841 # Apply watchlists on upload.
842 if not committing:
843 watchlist = watchlists.Watchlists(change.RepositoryRoot())
844 files = [f.LocalPath() for f in change.AffectedFiles()]
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000845 cl.SetWatchers(watchlist.GetWatchersForPaths(files))
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000846
maruel@chromium.org899e1c12011-04-07 17:03:18 +0000847 try:
848 output = presubmit_support.DoPresubmitChecks(change, committing,
849 verbose=verbose, output_stream=sys.stdout, input_stream=sys.stdin,
850 default_presubmit=None, may_prompt=may_prompt, tbr=tbr,
851 host_url=cl.GetRietveldServer())
852 except presubmit_support.PresubmitFailure, e:
853 DieWithError(
854 ('%s\nMaybe your depot_tools is out of date?\n'
855 'If all fails, contact maruel@') % e)
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000856
857 # TODO(dpranke): We should propagate the error out instead of calling exit().
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000858 if not output.should_continue():
859 sys.exit(1)
dpranke@chromium.org37248c02011-03-12 04:36:48 +0000860
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000861 return output
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000862
863
864def CMDpresubmit(parser, args):
865 """run presubmit tests on the current changelist"""
866 parser.add_option('--upload', action='store_true',
867 help='Run upload hook instead of the push/dcommit hook')
868 (options, args) = parser.parse_args(args)
869
870 # Make sure index is up-to-date before running diff-index.
871 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
872 if RunGit(['diff-index', 'HEAD']):
873 # TODO(maruel): Is this really necessary?
874 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
875 return 1
876
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000877 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000878 if args:
879 base_branch = args[0]
880 else:
881 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000882 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000883
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000884 RunHook(committing=not options.upload, upstream_branch=base_branch,
885 rietveld_server=cl.GetRietveldServer(), tbr=False,
maruel@chromium.org899e1c12011-04-07 17:03:18 +0000886 may_prompt=False, verbose=options.verbose)
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000887 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000888
889
890@usage('[args to "git diff"]')
891def CMDupload(parser, args):
892 """upload the current changelist to codereview"""
893 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
894 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000895 parser.add_option('-f', action='store_true', dest='force',
896 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000897 parser.add_option('-m', dest='message', help='message for patch')
898 parser.add_option('-r', '--reviewers',
899 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000900 parser.add_option('--cc',
901 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000902 parser.add_option('--send-mail', action='store_true',
903 help='send email to reviewer immediately')
904 parser.add_option("--emulate_svn_auto_props", action="store_true",
905 dest="emulate_svn_auto_props",
906 help="Emulate Subversion's auto properties feature.")
907 parser.add_option("--desc_from_logs", action="store_true",
908 dest="from_logs",
909 help="""Squashes git commit logs into change description and
910 uses message as subject""")
911 (options, args) = parser.parse_args(args)
912
913 # Make sure index is up-to-date before running diff-index.
914 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
915 if RunGit(['diff-index', 'HEAD']):
916 print 'Cannot upload with a dirty tree. You must commit locally first.'
917 return 1
918
919 cl = Changelist()
920 if args:
921 base_branch = args[0]
922 else:
923 # Default to diffing against the "upstream" branch.
924 base_branch = cl.GetUpstreamBranch()
925 args = [base_branch + "..."]
926
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000927 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000928 hook_results = RunHook(committing=False, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000929 rietveld_server=cl.GetRietveldServer(), tbr=False,
maruel@chromium.org899e1c12011-04-07 17:03:18 +0000930 may_prompt=True, verbose=options.verbose)
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000931 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000932 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000933
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000934
935 # --no-ext-diff is broken in some versions of Git, so try to work around
936 # this by overriding the environment (but there is still a problem if the
937 # git config key "diff.external" is used).
938 env = os.environ.copy()
939 if 'GIT_EXTERNAL_DIFF' in env:
940 del env['GIT_EXTERNAL_DIFF']
941 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
942 env=env)
943
944 upload_args = ['--assume_yes'] # Don't ask about untracked files.
945 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000946 if options.emulate_svn_auto_props:
947 upload_args.append('--emulate_svn_auto_props')
948 if options.send_mail:
949 if not options.reviewers:
950 DieWithError("Must specify reviewers to send email.")
951 upload_args.append('--send_mail')
952 if options.from_logs and not options.message:
953 print 'Must set message for subject line if using desc_from_logs'
954 return 1
955
956 change_desc = None
957
958 if cl.GetIssue():
959 if options.message:
960 upload_args.extend(['--message', options.message])
961 upload_args.extend(['--issue', cl.GetIssue()])
962 print ("This branch is associated with issue %s. "
963 "Adding patch to that issue." % cl.GetIssue())
964 else:
965 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000966 change_desc = ChangeDescription(options.message, log_desc,
967 options.reviewers)
968 if not options.from_logs:
969 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000970
971 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000972 print "Description is empty; aborting."
973 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000974
975 upload_args.extend(['--message', change_desc.subject])
976 upload_args.extend(['--description', change_desc.description])
977 if change_desc.reviewers:
978 upload_args.extend(['--reviewers', change_desc.reviewers])
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000979 cc = ','.join(filter(None, (cl.GetCCList(), options.cc)))
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000980 if cc:
981 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000982
983 # Include the upstream repo's URL in the change -- this is useful for
984 # projects that have their source spread across multiple repos.
985 remote_url = None
986 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000987 # URL is dependent on the current directory.
988 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000989 if data:
990 keys = dict(line.split(': ', 1) for line in data.splitlines()
991 if ': ' in line)
992 remote_url = keys.get('URL', None)
993 else:
994 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
995 remote_url = (cl.GetRemoteUrl() + '@'
996 + cl.GetUpstreamBranch().split('/')[-1])
997 if remote_url:
998 upload_args.extend(['--base_url', remote_url])
999
1000 try:
1001 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
maruel@chromium.org9ce0dff2011-04-04 17:56:50 +00001002 except KeyboardInterrupt:
1003 sys.exit(1)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001004 except:
1005 # If we got an exception after the user typed a description for their
1006 # change, back up the description before re-raising.
1007 if change_desc:
1008 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
1009 print '\nGot exception while uploading -- saving description to %s\n' \
1010 % backup_path
1011 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001012 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001013 backup_file.close()
1014 raise
1015
1016 if not cl.GetIssue():
1017 cl.SetIssue(issue)
1018 cl.SetPatchset(patchset)
1019 return 0
1020
1021
1022def SendUpstream(parser, args, cmd):
1023 """Common code for CmdPush and CmdDCommit
1024
1025 Squashed commit into a single.
1026 Updates changelog with metadata (e.g. pointer to review).
1027 Pushes/dcommits the code upstream.
1028 Updates review and closes.
1029 """
1030 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1031 help='bypass upload presubmit hook')
1032 parser.add_option('-m', dest='message',
1033 help="override review description")
1034 parser.add_option('-f', action='store_true', dest='force',
1035 help="force yes to questions (don't prompt)")
1036 parser.add_option('-c', dest='contributor',
1037 help="external contributor for patch (appended to " +
1038 "description and used as author for git). Should be " +
1039 "formatted as 'First Last <email@example.com>'")
1040 parser.add_option('--tbr', action='store_true', dest='tbr',
1041 help="short for 'to be reviewed', commit branch " +
1042 "even without uploading for review")
1043 (options, args) = parser.parse_args(args)
1044 cl = Changelist()
1045
1046 if not args or cmd == 'push':
1047 # Default to merging against our best guess of the upstream branch.
1048 args = [cl.GetUpstreamBranch()]
1049
1050 base_branch = args[0]
1051
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001052 # Make sure index is up-to-date before running diff-index.
1053 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001054 if RunGit(['diff-index', 'HEAD']):
1055 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1056 return 1
1057
1058 # This rev-list syntax means "show all commits not in my branch that
1059 # are in base_branch".
1060 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1061 base_branch]).splitlines()
1062 if upstream_commits:
1063 print ('Base branch "%s" has %d commits '
1064 'not in this branch.' % (base_branch, len(upstream_commits)))
1065 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1066 return 1
1067
1068 if cmd == 'dcommit':
1069 # This is the revision `svn dcommit` will commit on top of.
1070 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1071 '--pretty=format:%H'])
1072 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1073 if extra_commits:
1074 print ('This branch has %d additional commits not upstreamed yet.'
1075 % len(extra_commits.splitlines()))
1076 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1077 'before attempting to %s.' % (base_branch, cmd))
1078 return 1
1079
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001080 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001081 RunHook(committing=True, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001082 rietveld_server=cl.GetRietveldServer(), tbr=options.tbr,
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001083 may_prompt=True, verbose=options.verbose)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001084
1085 if cmd == 'dcommit':
1086 # Check the tree status if the tree status URL is set.
1087 status = GetTreeStatus()
1088 if 'closed' == status:
1089 print ('The tree is closed. Please wait for it to reopen. Use '
1090 '"git cl dcommit -f" to commit on a closed tree.')
1091 return 1
1092 elif 'unknown' == status:
1093 print ('Unable to determine tree status. Please verify manually and '
1094 'use "git cl dcommit -f" to commit on a closed tree.')
1095
1096 description = options.message
1097 if not options.tbr:
1098 # It is important to have these checks early. Not only for user
1099 # convenience, but also because the cl object then caches the correct values
1100 # of these fields even as we're juggling branches for setting up the commit.
1101 if not cl.GetIssue():
1102 print 'Current issue unknown -- has this branch been uploaded?'
1103 print 'Use --tbr to commit without review.'
1104 return 1
1105
1106 if not description:
1107 description = cl.GetDescription()
1108
1109 if not description:
1110 print 'No description set.'
1111 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1112 return 1
1113
1114 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1115 else:
1116 if not description:
1117 # Submitting TBR. See if there's already a description in Rietveld, else
1118 # create a template description. Eitherway, give the user a chance to edit
1119 # it to fill in the TBR= field.
1120 if cl.GetIssue():
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001121 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001122
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001123 # TODO(dpranke): Update to use ChangeDescription object.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001124 if not description:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001125 description = """# Enter a description of the change.
1126# This will be used as the change log for the commit.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001127
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001128"""
1129 description += CreateDescriptionFromLog(args)
1130
1131 description = UserEditedLog(description + '\nTBR=')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001132
1133 if not description:
1134 print "Description empty; aborting."
1135 return 1
1136
1137 if options.contributor:
1138 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1139 print "Please provide contibutor as 'First Last <email@example.com>'"
1140 return 1
1141 description += "\nPatch from %s." % options.contributor
1142 print 'Description:', repr(description)
1143
1144 branches = [base_branch, cl.GetBranchRef()]
1145 if not options.force:
1146 subprocess.call(['git', 'diff', '--stat'] + branches)
maruel@chromium.org90541732011-04-01 17:54:18 +00001147 ask_for_data('About to commit; enter to confirm.')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001148
1149 # We want to squash all this branch's commits into one commit with the
1150 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001151 # We do this by doing a "reset --soft" to the base branch (which keeps
1152 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001153 MERGE_BRANCH = 'git-cl-commit'
1154 # Delete the merge branch if it already exists.
1155 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1156 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1157 RunGit(['branch', '-D', MERGE_BRANCH])
1158
1159 # We might be in a directory that's present in this branch but not in the
1160 # trunk. Move up to the top of the tree so that git commands that expect a
1161 # valid CWD won't fail after we check out the merge branch.
1162 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1163 if rel_base_path:
1164 os.chdir(rel_base_path)
1165
1166 # Stuff our change into the merge branch.
1167 # We wrap in a try...finally block so if anything goes wrong,
1168 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001169 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001170 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001171 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1172 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001173 if options.contributor:
1174 RunGit(['commit', '--author', options.contributor, '-m', description])
1175 else:
1176 RunGit(['commit', '-m', description])
1177 if cmd == 'push':
1178 # push the merge branch.
1179 remote, branch = cl.FetchUpstreamTuple()
1180 retcode, output = RunGitWithCode(
1181 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1182 logging.debug(output)
1183 else:
1184 # dcommit the merge branch.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001185 retcode, output = RunGitWithCode(['svn', 'dcommit', '--no-rebase'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001186 finally:
1187 # And then swap back to the original branch and clean up.
1188 RunGit(['checkout', '-q', cl.GetBranch()])
1189 RunGit(['branch', '-D', MERGE_BRANCH])
1190
1191 if cl.GetIssue():
1192 if cmd == 'dcommit' and 'Committed r' in output:
1193 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1194 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001195 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1196 for l in output.splitlines(False))
1197 match = filter(None, match)
1198 if len(match) != 1:
1199 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1200 output)
1201 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001202 else:
1203 return 1
1204 viewvc_url = settings.GetViewVCUrl()
1205 if viewvc_url and revision:
1206 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1207 print ('Closing issue '
1208 '(you may be prompted for your codereview password)...')
1209 cl.CloseIssue()
1210 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001211
1212 if retcode == 0:
1213 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1214 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001215 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001216
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001217 return 0
1218
1219
1220@usage('[upstream branch to apply against]')
1221def CMDdcommit(parser, args):
1222 """commit the current changelist via git-svn"""
1223 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001224 message = """This doesn't appear to be an SVN repository.
1225If your project has a git mirror with an upstream SVN master, you probably need
1226to run 'git svn init', see your project's git mirror documentation.
1227If your project has a true writeable upstream repository, you probably want
1228to run 'git cl push' instead.
1229Choose wisely, if you get this wrong, your commit might appear to succeed but
1230will instead be silently ignored."""
1231 print(message)
maruel@chromium.org90541732011-04-01 17:54:18 +00001232 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001233 return SendUpstream(parser, args, 'dcommit')
1234
1235
1236@usage('[upstream branch to apply against]')
1237def CMDpush(parser, args):
1238 """commit the current changelist via git"""
1239 if settings.GetIsGitSvn():
1240 print('This appears to be an SVN repository.')
1241 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
maruel@chromium.org90541732011-04-01 17:54:18 +00001242 ask_for_data('[Press enter to push or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001243 return SendUpstream(parser, args, 'push')
1244
1245
1246@usage('<patch url or issue id>')
1247def CMDpatch(parser, args):
1248 """patch in a code review"""
1249 parser.add_option('-b', dest='newbranch',
1250 help='create a new branch off trunk for the patch')
1251 parser.add_option('-f', action='store_true', dest='force',
1252 help='with -b, clobber any existing branch')
1253 parser.add_option('--reject', action='store_true', dest='reject',
1254 help='allow failed patches and spew .rej files')
1255 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1256 help="don't commit after patch applies")
1257 (options, args) = parser.parse_args(args)
1258 if len(args) != 1:
1259 parser.print_help()
1260 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001261 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001262
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001263 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001264 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001265 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001266 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001267 else:
1268 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001269 issue_url = FixUrl(issue_arg)
1270 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001271 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001272 DieWithError('Must pass an issue ID or full URL for '
1273 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001274 issue = match.group(1)
1275 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001276
1277 if options.newbranch:
1278 if options.force:
1279 RunGit(['branch', '-D', options.newbranch],
1280 swallow_stderr=True, error_ok=True)
1281 RunGit(['checkout', '-b', options.newbranch,
1282 Changelist().GetUpstreamBranch()])
1283
1284 # Switch up to the top-level directory, if necessary, in preparation for
1285 # applying the patch.
1286 top = RunGit(['rev-parse', '--show-cdup']).strip()
1287 if top:
1288 os.chdir(top)
1289
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001290 # Git patches have a/ at the beginning of source paths. We strip that out
1291 # with a sed script rather than the -p flag to patch so we can feed either
1292 # Git or svn-style patches into the same apply command.
1293 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1294 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1295 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1296 patch_data = sed_proc.communicate(patch_data)[0]
1297 if sed_proc.returncode:
1298 DieWithError('Git patch mungling failed.')
1299 logging.info(patch_data)
1300 # We use "git apply" to apply the patch instead of "patch" so that we can
1301 # pick up file adds.
1302 # The --index flag means: also insert into the index (so we catch adds).
1303 cmd = ['git', 'apply', '--index', '-p0']
1304 if options.reject:
1305 cmd.append('--reject')
1306 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1307 patch_proc.communicate(patch_data)
1308 if patch_proc.returncode:
1309 DieWithError('Failed to apply the patch')
1310
1311 # If we had an issue, commit the current state and register the issue.
1312 if not options.nocommit:
1313 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1314 cl = Changelist()
1315 cl.SetIssue(issue)
1316 print "Committed patch."
1317 else:
1318 print "Patch applied to index."
1319 return 0
1320
1321
1322def CMDrebase(parser, args):
1323 """rebase current branch on top of svn repo"""
1324 # Provide a wrapper for git svn rebase to help avoid accidental
1325 # git svn dcommit.
1326 # It's the only command that doesn't use parser at all since we just defer
1327 # execution to git-svn.
1328 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1329 return 0
1330
1331
1332def GetTreeStatus():
1333 """Fetches the tree status and returns either 'open', 'closed',
1334 'unknown' or 'unset'."""
1335 url = settings.GetTreeStatusUrl(error_ok=True)
1336 if url:
1337 status = urllib2.urlopen(url).read().lower()
1338 if status.find('closed') != -1 or status == '0':
1339 return 'closed'
1340 elif status.find('open') != -1 or status == '1':
1341 return 'open'
1342 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001343 return 'unset'
1344
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001345
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001346def GetTreeStatusReason():
1347 """Fetches the tree status from a json url and returns the message
1348 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001349 url = settings.GetTreeStatusUrl()
1350 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001351 connection = urllib2.urlopen(json_url)
1352 status = json.loads(connection.read())
1353 connection.close()
1354 return status['message']
1355
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001356
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001357def CMDtree(parser, args):
1358 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001359 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001360 status = GetTreeStatus()
1361 if 'unset' == status:
1362 print 'You must configure your tree status URL by running "git cl config".'
1363 return 2
1364
1365 print "The tree is %s" % status
1366 print
1367 print GetTreeStatusReason()
1368 if status != 'open':
1369 return 1
1370 return 0
1371
1372
1373def CMDupstream(parser, args):
1374 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001375 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001376 cl = Changelist()
1377 print cl.GetUpstreamBranch()
1378 return 0
1379
1380
1381def Command(name):
1382 return getattr(sys.modules[__name__], 'CMD' + name, None)
1383
1384
1385def CMDhelp(parser, args):
1386 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001387 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001388 if len(args) == 1:
1389 return main(args + ['--help'])
1390 parser.print_help()
1391 return 0
1392
1393
1394def GenUsage(parser, command):
1395 """Modify an OptParse object with the function's documentation."""
1396 obj = Command(command)
1397 more = getattr(obj, 'usage_more', '')
1398 if command == 'help':
1399 command = '<command>'
1400 else:
1401 # OptParser.description prefer nicely non-formatted strings.
1402 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1403 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1404
1405
1406def main(argv):
1407 """Doesn't parse the arguments here, just find the right subcommand to
1408 execute."""
1409 # Do it late so all commands are listed.
1410 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1411 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1412 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1413
1414 # Create the option parse and add --verbose support.
1415 parser = optparse.OptionParser()
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001416 parser.add_option(
1417 '-v', '--verbose', action='count', default=0,
1418 help='Use 2 times for more debugging info')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001419 old_parser_args = parser.parse_args
1420 def Parse(args):
1421 options, args = old_parser_args(args)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001422 if options.verbose >= 2:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001423 logging.basicConfig(level=logging.DEBUG)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001424 elif options.verbose:
1425 logging.basicConfig(level=logging.INFO)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001426 else:
1427 logging.basicConfig(level=logging.WARNING)
1428 return options, args
1429 parser.parse_args = Parse
1430
1431 if argv:
1432 command = Command(argv[0])
1433 if command:
1434 # "fix" the usage and the description now that we know the subcommand.
1435 GenUsage(parser, argv[0])
1436 try:
1437 return command(parser, argv[1:])
1438 except urllib2.HTTPError, e:
1439 if e.code != 500:
1440 raise
1441 DieWithError(
1442 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1443 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1444
1445 # Not a known command. Default to help.
1446 GenUsage(parser, 'help')
1447 return CMDhelp(parser, argv)
1448
1449
1450if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001451 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001452 sys.exit(main(sys.argv[1:]))