blob: f5ecd7fda143ebda919ad3533554db973a556959 [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"""
59 logging.info('Popen: ' + ' '.join(cmd))
60 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
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000813def RunHook(committing, upstream_branch, rietveld_server, tbr, may_prompt):
814 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000815 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip()
816 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000817 root = '.'
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000818 absroot = os.path.abspath(root)
819 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000820 raise Exception('Could not get root directory.')
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000821
822 # We use the sha1 of HEAD as a name of this change.
823 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
824 files = scm.GIT.CaptureStatus([root], upstream_branch)
825
826 cl = Changelist()
827 issue = ConvertToInteger(cl.GetIssue())
828 patchset = ConvertToInteger(cl.GetPatchset())
829 if issue:
830 description = cl.GetDescription()
831 else:
832 # If the change was never uploaded, use the log messages of all commits
833 # up to the branch point, as git cl upload will prefill the description
834 # with these log messages.
835 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000836 '%s...' % (upstream_branch)]).strip()
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000837 change = presubmit_support.GitChange(name, description, absroot, files,
838 issue, patchset)
839
840 # Apply watchlists on upload.
841 if not committing:
842 watchlist = watchlists.Watchlists(change.RepositoryRoot())
843 files = [f.LocalPath() for f in change.AffectedFiles()]
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000844 cl.SetWatchers(watchlist.GetWatchersForPaths(files))
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000845
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000846 output = presubmit_support.DoPresubmitChecks(change, committing,
847 verbose=False, output_stream=sys.stdout, input_stream=sys.stdin,
848 default_presubmit=None, may_prompt=may_prompt, tbr=tbr,
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000849 host_url=cl.GetRietveldServer())
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000850
851 # TODO(dpranke): We should propagate the error out instead of calling exit().
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000852 if not output.should_continue():
853 sys.exit(1)
dpranke@chromium.org37248c02011-03-12 04:36:48 +0000854
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000855 return output
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000856
857
858def CMDpresubmit(parser, args):
859 """run presubmit tests on the current changelist"""
860 parser.add_option('--upload', action='store_true',
861 help='Run upload hook instead of the push/dcommit hook')
862 (options, args) = parser.parse_args(args)
863
864 # Make sure index is up-to-date before running diff-index.
865 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
866 if RunGit(['diff-index', 'HEAD']):
867 # TODO(maruel): Is this really necessary?
868 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
869 return 1
870
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000871 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000872 if args:
873 base_branch = args[0]
874 else:
875 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000876 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000877
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000878 RunHook(committing=not options.upload, upstream_branch=base_branch,
879 rietveld_server=cl.GetRietveldServer(), tbr=False,
880 may_prompt=False)
881 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000882
883
884@usage('[args to "git diff"]')
885def CMDupload(parser, args):
886 """upload the current changelist to codereview"""
887 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
888 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000889 parser.add_option('-f', action='store_true', dest='force',
890 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000891 parser.add_option('-m', dest='message', help='message for patch')
892 parser.add_option('-r', '--reviewers',
893 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000894 parser.add_option('--cc',
895 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000896 parser.add_option('--send-mail', action='store_true',
897 help='send email to reviewer immediately')
898 parser.add_option("--emulate_svn_auto_props", action="store_true",
899 dest="emulate_svn_auto_props",
900 help="Emulate Subversion's auto properties feature.")
901 parser.add_option("--desc_from_logs", action="store_true",
902 dest="from_logs",
903 help="""Squashes git commit logs into change description and
904 uses message as subject""")
905 (options, args) = parser.parse_args(args)
906
907 # Make sure index is up-to-date before running diff-index.
908 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
909 if RunGit(['diff-index', 'HEAD']):
910 print 'Cannot upload with a dirty tree. You must commit locally first.'
911 return 1
912
913 cl = Changelist()
914 if args:
915 base_branch = args[0]
916 else:
917 # Default to diffing against the "upstream" branch.
918 base_branch = cl.GetUpstreamBranch()
919 args = [base_branch + "..."]
920
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000921 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000922 hook_results = RunHook(committing=False, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000923 rietveld_server=cl.GetRietveldServer(), tbr=False,
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000924 may_prompt=True)
925 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000926 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000927
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000928
929 # --no-ext-diff is broken in some versions of Git, so try to work around
930 # this by overriding the environment (but there is still a problem if the
931 # git config key "diff.external" is used).
932 env = os.environ.copy()
933 if 'GIT_EXTERNAL_DIFF' in env:
934 del env['GIT_EXTERNAL_DIFF']
935 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
936 env=env)
937
938 upload_args = ['--assume_yes'] # Don't ask about untracked files.
939 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000940 if options.emulate_svn_auto_props:
941 upload_args.append('--emulate_svn_auto_props')
942 if options.send_mail:
943 if not options.reviewers:
944 DieWithError("Must specify reviewers to send email.")
945 upload_args.append('--send_mail')
946 if options.from_logs and not options.message:
947 print 'Must set message for subject line if using desc_from_logs'
948 return 1
949
950 change_desc = None
951
952 if cl.GetIssue():
953 if options.message:
954 upload_args.extend(['--message', options.message])
955 upload_args.extend(['--issue', cl.GetIssue()])
956 print ("This branch is associated with issue %s. "
957 "Adding patch to that issue." % cl.GetIssue())
958 else:
959 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000960 change_desc = ChangeDescription(options.message, log_desc,
961 options.reviewers)
962 if not options.from_logs:
963 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000964
965 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000966 print "Description is empty; aborting."
967 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000968
969 upload_args.extend(['--message', change_desc.subject])
970 upload_args.extend(['--description', change_desc.description])
971 if change_desc.reviewers:
972 upload_args.extend(['--reviewers', change_desc.reviewers])
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000973 cc = ','.join(filter(None, (cl.GetCCList(), options.cc)))
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000974 if cc:
975 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000976
977 # Include the upstream repo's URL in the change -- this is useful for
978 # projects that have their source spread across multiple repos.
979 remote_url = None
980 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000981 # URL is dependent on the current directory.
982 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000983 if data:
984 keys = dict(line.split(': ', 1) for line in data.splitlines()
985 if ': ' in line)
986 remote_url = keys.get('URL', None)
987 else:
988 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
989 remote_url = (cl.GetRemoteUrl() + '@'
990 + cl.GetUpstreamBranch().split('/')[-1])
991 if remote_url:
992 upload_args.extend(['--base_url', remote_url])
993
994 try:
995 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
maruel@chromium.org9ce0dff2011-04-04 17:56:50 +0000996 except KeyboardInterrupt:
997 sys.exit(1)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000998 except:
999 # If we got an exception after the user typed a description for their
1000 # change, back up the description before re-raising.
1001 if change_desc:
1002 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
1003 print '\nGot exception while uploading -- saving description to %s\n' \
1004 % backup_path
1005 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001006 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001007 backup_file.close()
1008 raise
1009
1010 if not cl.GetIssue():
1011 cl.SetIssue(issue)
1012 cl.SetPatchset(patchset)
1013 return 0
1014
1015
1016def SendUpstream(parser, args, cmd):
1017 """Common code for CmdPush and CmdDCommit
1018
1019 Squashed commit into a single.
1020 Updates changelog with metadata (e.g. pointer to review).
1021 Pushes/dcommits the code upstream.
1022 Updates review and closes.
1023 """
1024 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1025 help='bypass upload presubmit hook')
1026 parser.add_option('-m', dest='message',
1027 help="override review description")
1028 parser.add_option('-f', action='store_true', dest='force',
1029 help="force yes to questions (don't prompt)")
1030 parser.add_option('-c', dest='contributor',
1031 help="external contributor for patch (appended to " +
1032 "description and used as author for git). Should be " +
1033 "formatted as 'First Last <email@example.com>'")
1034 parser.add_option('--tbr', action='store_true', dest='tbr',
1035 help="short for 'to be reviewed', commit branch " +
1036 "even without uploading for review")
1037 (options, args) = parser.parse_args(args)
1038 cl = Changelist()
1039
1040 if not args or cmd == 'push':
1041 # Default to merging against our best guess of the upstream branch.
1042 args = [cl.GetUpstreamBranch()]
1043
1044 base_branch = args[0]
1045
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001046 # Make sure index is up-to-date before running diff-index.
1047 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001048 if RunGit(['diff-index', 'HEAD']):
1049 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1050 return 1
1051
1052 # This rev-list syntax means "show all commits not in my branch that
1053 # are in base_branch".
1054 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1055 base_branch]).splitlines()
1056 if upstream_commits:
1057 print ('Base branch "%s" has %d commits '
1058 'not in this branch.' % (base_branch, len(upstream_commits)))
1059 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1060 return 1
1061
1062 if cmd == 'dcommit':
1063 # This is the revision `svn dcommit` will commit on top of.
1064 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1065 '--pretty=format:%H'])
1066 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1067 if extra_commits:
1068 print ('This branch has %d additional commits not upstreamed yet.'
1069 % len(extra_commits.splitlines()))
1070 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1071 'before attempting to %s.' % (base_branch, cmd))
1072 return 1
1073
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001074 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001075 RunHook(committing=True, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001076 rietveld_server=cl.GetRietveldServer(), tbr=options.tbr,
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001077 may_prompt=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001078
1079 if cmd == 'dcommit':
1080 # Check the tree status if the tree status URL is set.
1081 status = GetTreeStatus()
1082 if 'closed' == status:
1083 print ('The tree is closed. Please wait for it to reopen. Use '
1084 '"git cl dcommit -f" to commit on a closed tree.')
1085 return 1
1086 elif 'unknown' == status:
1087 print ('Unable to determine tree status. Please verify manually and '
1088 'use "git cl dcommit -f" to commit on a closed tree.')
1089
1090 description = options.message
1091 if not options.tbr:
1092 # It is important to have these checks early. Not only for user
1093 # convenience, but also because the cl object then caches the correct values
1094 # of these fields even as we're juggling branches for setting up the commit.
1095 if not cl.GetIssue():
1096 print 'Current issue unknown -- has this branch been uploaded?'
1097 print 'Use --tbr to commit without review.'
1098 return 1
1099
1100 if not description:
1101 description = cl.GetDescription()
1102
1103 if not description:
1104 print 'No description set.'
1105 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1106 return 1
1107
1108 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1109 else:
1110 if not description:
1111 # Submitting TBR. See if there's already a description in Rietveld, else
1112 # create a template description. Eitherway, give the user a chance to edit
1113 # it to fill in the TBR= field.
1114 if cl.GetIssue():
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001115 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001116
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001117 # TODO(dpranke): Update to use ChangeDescription object.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001118 if not description:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001119 description = """# Enter a description of the change.
1120# This will be used as the change log for the commit.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001121
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001122"""
1123 description += CreateDescriptionFromLog(args)
1124
1125 description = UserEditedLog(description + '\nTBR=')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001126
1127 if not description:
1128 print "Description empty; aborting."
1129 return 1
1130
1131 if options.contributor:
1132 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1133 print "Please provide contibutor as 'First Last <email@example.com>'"
1134 return 1
1135 description += "\nPatch from %s." % options.contributor
1136 print 'Description:', repr(description)
1137
1138 branches = [base_branch, cl.GetBranchRef()]
1139 if not options.force:
1140 subprocess.call(['git', 'diff', '--stat'] + branches)
maruel@chromium.org90541732011-04-01 17:54:18 +00001141 ask_for_data('About to commit; enter to confirm.')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001142
1143 # We want to squash all this branch's commits into one commit with the
1144 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001145 # We do this by doing a "reset --soft" to the base branch (which keeps
1146 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001147 MERGE_BRANCH = 'git-cl-commit'
1148 # Delete the merge branch if it already exists.
1149 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1150 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1151 RunGit(['branch', '-D', MERGE_BRANCH])
1152
1153 # We might be in a directory that's present in this branch but not in the
1154 # trunk. Move up to the top of the tree so that git commands that expect a
1155 # valid CWD won't fail after we check out the merge branch.
1156 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1157 if rel_base_path:
1158 os.chdir(rel_base_path)
1159
1160 # Stuff our change into the merge branch.
1161 # We wrap in a try...finally block so if anything goes wrong,
1162 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001163 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001164 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001165 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1166 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001167 if options.contributor:
1168 RunGit(['commit', '--author', options.contributor, '-m', description])
1169 else:
1170 RunGit(['commit', '-m', description])
1171 if cmd == 'push':
1172 # push the merge branch.
1173 remote, branch = cl.FetchUpstreamTuple()
1174 retcode, output = RunGitWithCode(
1175 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1176 logging.debug(output)
1177 else:
1178 # dcommit the merge branch.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001179 retcode, output = RunGitWithCode(['svn', 'dcommit', '--no-rebase'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001180 finally:
1181 # And then swap back to the original branch and clean up.
1182 RunGit(['checkout', '-q', cl.GetBranch()])
1183 RunGit(['branch', '-D', MERGE_BRANCH])
1184
1185 if cl.GetIssue():
1186 if cmd == 'dcommit' and 'Committed r' in output:
1187 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1188 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001189 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1190 for l in output.splitlines(False))
1191 match = filter(None, match)
1192 if len(match) != 1:
1193 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1194 output)
1195 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001196 else:
1197 return 1
1198 viewvc_url = settings.GetViewVCUrl()
1199 if viewvc_url and revision:
1200 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1201 print ('Closing issue '
1202 '(you may be prompted for your codereview password)...')
1203 cl.CloseIssue()
1204 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001205
1206 if retcode == 0:
1207 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1208 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001209 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001210
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001211 return 0
1212
1213
1214@usage('[upstream branch to apply against]')
1215def CMDdcommit(parser, args):
1216 """commit the current changelist via git-svn"""
1217 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001218 message = """This doesn't appear to be an SVN repository.
1219If your project has a git mirror with an upstream SVN master, you probably need
1220to run 'git svn init', see your project's git mirror documentation.
1221If your project has a true writeable upstream repository, you probably want
1222to run 'git cl push' instead.
1223Choose wisely, if you get this wrong, your commit might appear to succeed but
1224will instead be silently ignored."""
1225 print(message)
maruel@chromium.org90541732011-04-01 17:54:18 +00001226 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001227 return SendUpstream(parser, args, 'dcommit')
1228
1229
1230@usage('[upstream branch to apply against]')
1231def CMDpush(parser, args):
1232 """commit the current changelist via git"""
1233 if settings.GetIsGitSvn():
1234 print('This appears to be an SVN repository.')
1235 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
maruel@chromium.org90541732011-04-01 17:54:18 +00001236 ask_for_data('[Press enter to push or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001237 return SendUpstream(parser, args, 'push')
1238
1239
1240@usage('<patch url or issue id>')
1241def CMDpatch(parser, args):
1242 """patch in a code review"""
1243 parser.add_option('-b', dest='newbranch',
1244 help='create a new branch off trunk for the patch')
1245 parser.add_option('-f', action='store_true', dest='force',
1246 help='with -b, clobber any existing branch')
1247 parser.add_option('--reject', action='store_true', dest='reject',
1248 help='allow failed patches and spew .rej files')
1249 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1250 help="don't commit after patch applies")
1251 (options, args) = parser.parse_args(args)
1252 if len(args) != 1:
1253 parser.print_help()
1254 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001255 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001256
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001257 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001258 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001259 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001260 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001261 else:
1262 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001263 issue_url = FixUrl(issue_arg)
1264 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001265 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001266 DieWithError('Must pass an issue ID or full URL for '
1267 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001268 issue = match.group(1)
1269 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001270
1271 if options.newbranch:
1272 if options.force:
1273 RunGit(['branch', '-D', options.newbranch],
1274 swallow_stderr=True, error_ok=True)
1275 RunGit(['checkout', '-b', options.newbranch,
1276 Changelist().GetUpstreamBranch()])
1277
1278 # Switch up to the top-level directory, if necessary, in preparation for
1279 # applying the patch.
1280 top = RunGit(['rev-parse', '--show-cdup']).strip()
1281 if top:
1282 os.chdir(top)
1283
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001284 # Git patches have a/ at the beginning of source paths. We strip that out
1285 # with a sed script rather than the -p flag to patch so we can feed either
1286 # Git or svn-style patches into the same apply command.
1287 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1288 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1289 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1290 patch_data = sed_proc.communicate(patch_data)[0]
1291 if sed_proc.returncode:
1292 DieWithError('Git patch mungling failed.')
1293 logging.info(patch_data)
1294 # We use "git apply" to apply the patch instead of "patch" so that we can
1295 # pick up file adds.
1296 # The --index flag means: also insert into the index (so we catch adds).
1297 cmd = ['git', 'apply', '--index', '-p0']
1298 if options.reject:
1299 cmd.append('--reject')
1300 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1301 patch_proc.communicate(patch_data)
1302 if patch_proc.returncode:
1303 DieWithError('Failed to apply the patch')
1304
1305 # If we had an issue, commit the current state and register the issue.
1306 if not options.nocommit:
1307 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1308 cl = Changelist()
1309 cl.SetIssue(issue)
1310 print "Committed patch."
1311 else:
1312 print "Patch applied to index."
1313 return 0
1314
1315
1316def CMDrebase(parser, args):
1317 """rebase current branch on top of svn repo"""
1318 # Provide a wrapper for git svn rebase to help avoid accidental
1319 # git svn dcommit.
1320 # It's the only command that doesn't use parser at all since we just defer
1321 # execution to git-svn.
1322 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1323 return 0
1324
1325
1326def GetTreeStatus():
1327 """Fetches the tree status and returns either 'open', 'closed',
1328 'unknown' or 'unset'."""
1329 url = settings.GetTreeStatusUrl(error_ok=True)
1330 if url:
1331 status = urllib2.urlopen(url).read().lower()
1332 if status.find('closed') != -1 or status == '0':
1333 return 'closed'
1334 elif status.find('open') != -1 or status == '1':
1335 return 'open'
1336 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001337 return 'unset'
1338
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001339
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001340def GetTreeStatusReason():
1341 """Fetches the tree status from a json url and returns the message
1342 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001343 url = settings.GetTreeStatusUrl()
1344 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001345 connection = urllib2.urlopen(json_url)
1346 status = json.loads(connection.read())
1347 connection.close()
1348 return status['message']
1349
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001350
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001351def CMDtree(parser, args):
1352 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001353 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001354 status = GetTreeStatus()
1355 if 'unset' == status:
1356 print 'You must configure your tree status URL by running "git cl config".'
1357 return 2
1358
1359 print "The tree is %s" % status
1360 print
1361 print GetTreeStatusReason()
1362 if status != 'open':
1363 return 1
1364 return 0
1365
1366
1367def CMDupstream(parser, args):
1368 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001369 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001370 cl = Changelist()
1371 print cl.GetUpstreamBranch()
1372 return 0
1373
1374
1375def Command(name):
1376 return getattr(sys.modules[__name__], 'CMD' + name, None)
1377
1378
1379def CMDhelp(parser, args):
1380 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001381 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001382 if len(args) == 1:
1383 return main(args + ['--help'])
1384 parser.print_help()
1385 return 0
1386
1387
1388def GenUsage(parser, command):
1389 """Modify an OptParse object with the function's documentation."""
1390 obj = Command(command)
1391 more = getattr(obj, 'usage_more', '')
1392 if command == 'help':
1393 command = '<command>'
1394 else:
1395 # OptParser.description prefer nicely non-formatted strings.
1396 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1397 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1398
1399
1400def main(argv):
1401 """Doesn't parse the arguments here, just find the right subcommand to
1402 execute."""
1403 # Do it late so all commands are listed.
1404 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1405 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1406 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1407
1408 # Create the option parse and add --verbose support.
1409 parser = optparse.OptionParser()
1410 parser.add_option('-v', '--verbose', action='store_true')
1411 old_parser_args = parser.parse_args
1412 def Parse(args):
1413 options, args = old_parser_args(args)
1414 if options.verbose:
1415 logging.basicConfig(level=logging.DEBUG)
1416 else:
1417 logging.basicConfig(level=logging.WARNING)
1418 return options, args
1419 parser.parse_args = Parse
1420
1421 if argv:
1422 command = Command(argv[0])
1423 if command:
1424 # "fix" the usage and the description now that we know the subcommand.
1425 GenUsage(parser, argv[0])
1426 try:
1427 return command(parser, argv[1:])
1428 except urllib2.HTTPError, e:
1429 if e.code != 500:
1430 raise
1431 DieWithError(
1432 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1433 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1434
1435 # Not a known command. Default to help.
1436 GenUsage(parser, 'help')
1437 return CMDhelp(parser, argv)
1438
1439
1440if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001441 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001442 sys.exit(main(sys.argv[1:]))