blob: f9a008c889b1eb7f0d6ae9afedd51d034e5d5f0a [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
198 def GetCCList(self):
199 """Return the users cc'd on this CL.
200
201 Return is a string suitable for passing to gcl with the --cc flag.
202 """
203 if self.cc is None:
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000204 base_cc = self._GetConfig('rietveld.cc', error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000205 more_cc = self._GetConfig('rietveld.extracc', error_ok=True)
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000206 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000207 return self.cc
208
209 def GetRoot(self):
210 if not self.root:
211 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
212 return self.root
213
214 def GetIsGitSvn(self):
215 """Return true if this repo looks like it's using git-svn."""
216 if self.is_git_svn is None:
217 # If you have any "svn-remote.*" config keys, we think you're using svn.
218 self.is_git_svn = RunGitWithCode(
219 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
220 return self.is_git_svn
221
222 def GetSVNBranch(self):
223 if self.svn_branch is None:
224 if not self.GetIsGitSvn():
225 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
226
227 # Try to figure out which remote branch we're based on.
228 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000229 # 1) iterate through our branch history and find the svn URL.
230 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000231
232 # regexp matching the git-svn line that contains the URL.
233 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
234
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000235 # We don't want to go through all of history, so read a line from the
236 # pipe at a time.
237 # The -100 is an arbitrary limit so we don't search forever.
238 cmd = ['git', 'log', '-100', '--pretty=medium']
239 proc = Popen(cmd, stdout=subprocess.PIPE)
240 for line in proc.stdout:
241 match = git_svn_re.match(line)
242 if match:
243 url = match.group(1)
244 proc.stdout.close() # Cut pipe.
245 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000246
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000247 if url:
248 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
249 remotes = RunGit(['config', '--get-regexp',
250 r'^svn-remote\..*\.url']).splitlines()
251 for remote in remotes:
252 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000253 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000254 remote = match.group(1)
255 base_url = match.group(2)
256 fetch_spec = RunGit(
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000257 ['config', 'svn-remote.%s.fetch' % remote],
258 error_ok=True).strip()
259 if fetch_spec:
260 self.svn_branch = MatchSvnGlob(url, base_url, fetch_spec, False)
261 if self.svn_branch:
262 break
263 branch_spec = RunGit(
264 ['config', 'svn-remote.%s.branches' % remote],
265 error_ok=True).strip()
266 if branch_spec:
267 self.svn_branch = MatchSvnGlob(url, base_url, branch_spec, True)
268 if self.svn_branch:
269 break
270 tag_spec = RunGit(
271 ['config', 'svn-remote.%s.tags' % remote],
272 error_ok=True).strip()
273 if tag_spec:
274 self.svn_branch = MatchSvnGlob(url, base_url, tag_spec, True)
275 if self.svn_branch:
276 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000277
278 if not self.svn_branch:
279 DieWithError('Can\'t guess svn branch -- try specifying it on the '
280 'command line')
281
282 return self.svn_branch
283
284 def GetTreeStatusUrl(self, error_ok=False):
285 if not self.tree_status_url:
286 error_message = ('You must configure your tree status URL by running '
287 '"git cl config".')
288 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
289 error_ok=error_ok,
290 error_message=error_message)
291 return self.tree_status_url
292
293 def GetViewVCUrl(self):
294 if not self.viewvc_url:
295 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
296 return self.viewvc_url
297
298 def _GetConfig(self, param, **kwargs):
299 self.LazyUpdateIfNeeded()
300 return RunGit(['config', param], **kwargs).strip()
301
302
303settings = Settings()
304
305
306did_migrate_check = False
307def CheckForMigration():
308 """Migrate from the old issue format, if found.
309
310 We used to store the branch<->issue mapping in a file in .git, but it's
311 better to store it in the .git/config, since deleting a branch deletes that
312 branch's entry there.
313 """
314
315 # Don't run more than once.
316 global did_migrate_check
317 if did_migrate_check:
318 return
319
320 gitdir = RunGit(['rev-parse', '--git-dir']).strip()
321 storepath = os.path.join(gitdir, 'cl-mapping')
322 if os.path.exists(storepath):
323 print "old-style git-cl mapping file (%s) found; migrating." % storepath
324 store = open(storepath, 'r')
325 for line in store:
326 branch, issue = line.strip().split()
327 RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
328 issue])
329 store.close()
330 os.remove(storepath)
331 did_migrate_check = True
332
333
334def ShortBranchName(branch):
335 """Convert a name like 'refs/heads/foo' to just 'foo'."""
336 return branch.replace('refs/heads/', '')
337
338
339class Changelist(object):
340 def __init__(self, branchref=None):
341 # Poke settings so we get the "configure your server" message if necessary.
342 settings.GetDefaultServerUrl()
343 self.branchref = branchref
344 if self.branchref:
345 self.branch = ShortBranchName(self.branchref)
346 else:
347 self.branch = None
348 self.rietveld_server = None
349 self.upstream_branch = None
350 self.has_issue = False
351 self.issue = None
352 self.has_description = False
353 self.description = None
354 self.has_patchset = False
355 self.patchset = None
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000356 self._rpc_server = None
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000357
358 def GetBranch(self):
359 """Returns the short branch name, e.g. 'master'."""
360 if not self.branch:
361 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
362 self.branch = ShortBranchName(self.branchref)
363 return self.branch
364
365 def GetBranchRef(self):
366 """Returns the full branch name, e.g. 'refs/heads/master'."""
367 self.GetBranch() # Poke the lazy loader.
368 return self.branchref
369
370 def FetchUpstreamTuple(self):
371 """Returns a tuple containg remote and remote ref,
372 e.g. 'origin', 'refs/heads/master'
373 """
374 remote = '.'
375 branch = self.GetBranch()
376 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
377 error_ok=True).strip()
378 if upstream_branch:
379 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
380 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000381 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
382 error_ok=True).strip()
383 if upstream_branch:
384 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000385 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000386 # Fall back on trying a git-svn upstream branch.
387 if settings.GetIsGitSvn():
388 upstream_branch = settings.GetSVNBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000389 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000390 # Else, try to guess the origin remote.
391 remote_branches = RunGit(['branch', '-r']).split()
392 if 'origin/master' in remote_branches:
393 # Fall back on origin/master if it exits.
394 remote = 'origin'
395 upstream_branch = 'refs/heads/master'
396 elif 'origin/trunk' in remote_branches:
397 # Fall back on origin/trunk if it exists. Generally a shared
398 # git-svn clone
399 remote = 'origin'
400 upstream_branch = 'refs/heads/trunk'
401 else:
402 DieWithError("""Unable to determine default branch to diff against.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000403Either pass complete "git diff"-style arguments, like
404 git cl upload origin/master
405or verify this branch is set up to track another (via the --track argument to
406"git checkout -b ...").""")
407
408 return remote, upstream_branch
409
410 def GetUpstreamBranch(self):
411 if self.upstream_branch is None:
412 remote, upstream_branch = self.FetchUpstreamTuple()
413 if remote is not '.':
414 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
415 self.upstream_branch = upstream_branch
416 return self.upstream_branch
417
418 def GetRemoteUrl(self):
419 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
420
421 Returns None if there is no remote.
422 """
423 remote = self.FetchUpstreamTuple()[0]
424 if remote == '.':
425 return None
426 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
427
428 def GetIssue(self):
429 if not self.has_issue:
430 CheckForMigration()
431 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
432 if issue:
433 self.issue = issue
434 self.rietveld_server = FixUrl(RunGit(
435 ['config', self._RietveldServer()], error_ok=True).strip())
436 else:
437 self.issue = None
438 if not self.rietveld_server:
439 self.rietveld_server = settings.GetDefaultServerUrl()
440 self.has_issue = True
441 return self.issue
442
443 def GetRietveldServer(self):
444 self.GetIssue()
445 return self.rietveld_server
446
447 def GetIssueURL(self):
448 """Get the URL for a particular issue."""
449 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
450
451 def GetDescription(self, pretty=False):
452 if not self.has_description:
453 if self.GetIssue():
454 path = '/' + self.GetIssue() + '/description'
455 rpc_server = self._RpcServer()
456 self.description = rpc_server.Send(path).strip()
457 self.has_description = True
458 if pretty:
459 wrapper = textwrap.TextWrapper()
460 wrapper.initial_indent = wrapper.subsequent_indent = ' '
461 return wrapper.fill(self.description)
462 return self.description
463
464 def GetPatchset(self):
465 if not self.has_patchset:
466 patchset = RunGit(['config', self._PatchsetSetting()],
467 error_ok=True).strip()
468 if patchset:
469 self.patchset = patchset
470 else:
471 self.patchset = None
472 self.has_patchset = True
473 return self.patchset
474
475 def SetPatchset(self, patchset):
476 """Set this branch's patchset. If patchset=0, clears the patchset."""
477 if patchset:
478 RunGit(['config', self._PatchsetSetting(), str(patchset)])
479 else:
480 RunGit(['config', '--unset', self._PatchsetSetting()],
481 swallow_stderr=True, error_ok=True)
482 self.has_patchset = False
483
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000484 def GetPatchSetDiff(self, issue):
485 # Grab the last patchset of the issue first.
486 data = json.loads(self._RpcServer().Send('/api/%s' % issue))
487 patchset = data['patchsets'][-1]
488 return self._RpcServer().Send(
489 '/download/issue%s_%s.diff' % (issue, patchset))
490
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000491 def SetIssue(self, issue):
492 """Set this branch's issue. If issue=0, clears the issue."""
493 if issue:
494 RunGit(['config', self._IssueSetting(), str(issue)])
495 if self.rietveld_server:
496 RunGit(['config', self._RietveldServer(), self.rietveld_server])
497 else:
498 RunGit(['config', '--unset', self._IssueSetting()])
499 self.SetPatchset(0)
500 self.has_issue = False
501
502 def CloseIssue(self):
503 rpc_server = self._RpcServer()
504 # Newer versions of Rietveld require us to pass an XSRF token to POST, so
505 # we fetch it from the server. (The version used by Chromium has been
506 # modified so the token isn't required when closing an issue.)
507 xsrf_token = rpc_server.Send('/xsrf_token',
508 extra_headers={'X-Requesting-XSRF-Token': '1'})
509
510 # You cannot close an issue with a GET.
511 # We pass an empty string for the data so it is a POST rather than a GET.
512 data = [("description", self.description),
513 ("xsrf_token", xsrf_token)]
514 ctype, body = upload.EncodeMultipartFormData(data, [])
515 rpc_server.Send('/' + self.GetIssue() + '/close', body, ctype)
516
517 def _RpcServer(self):
518 """Returns an upload.RpcServer() to access this review's rietveld instance.
519 """
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000520 if not self._rpc_server:
521 server = self.GetRietveldServer()
522 self._rpc_server = upload.GetRpcServer(server, save_cookies=True)
523 return self._rpc_server
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000524
525 def _IssueSetting(self):
526 """Return the git setting that stores this change's issue."""
527 return 'branch.%s.rietveldissue' % self.GetBranch()
528
529 def _PatchsetSetting(self):
530 """Return the git setting that stores this change's most recent patchset."""
531 return 'branch.%s.rietveldpatchset' % self.GetBranch()
532
533 def _RietveldServer(self):
534 """Returns the git setting that stores this change's rietveld server."""
535 return 'branch.%s.rietveldserver' % self.GetBranch()
536
537
538def GetCodereviewSettingsInteractively():
539 """Prompt the user for settings."""
540 server = settings.GetDefaultServerUrl(error_ok=True)
541 prompt = 'Rietveld server (host[:port])'
542 prompt += ' [%s]' % (server or DEFAULT_SERVER)
maruel@chromium.org90541732011-04-01 17:54:18 +0000543 newserver = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000544 if not server and not newserver:
545 newserver = DEFAULT_SERVER
546 if newserver and newserver != server:
547 RunGit(['config', 'rietveld.server', newserver])
548
549 def SetProperty(initial, caption, name):
550 prompt = caption
551 if initial:
552 prompt += ' ("x" to clear) [%s]' % initial
maruel@chromium.org90541732011-04-01 17:54:18 +0000553 new_val = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000554 if new_val == 'x':
555 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
556 elif new_val and new_val != initial:
557 RunGit(['config', 'rietveld.' + name, new_val])
558
559 SetProperty(settings.GetCCList(), 'CC list', 'cc')
560 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
561 'tree-status-url')
562 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
563
564 # TODO: configure a default branch to diff against, rather than this
565 # svn-based hackery.
566
567
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000568class ChangeDescription(object):
569 """Contains a parsed form of the change description."""
570 def __init__(self, subject, log_desc, reviewers):
571 self.subject = subject
572 self.log_desc = log_desc
573 self.reviewers = reviewers
574 self.description = self.log_desc
575
576 def Update(self):
577 initial_text = """# Enter a description of the change.
578# This will displayed on the codereview site.
579# The first line will also be used as the subject of the review.
580"""
581 initial_text += self.description
582 if 'R=' not in self.description and self.reviewers:
583 initial_text += '\nR=' + self.reviewers
584 if 'BUG=' not in self.description:
585 initial_text += '\nBUG='
586 if 'TEST=' not in self.description:
587 initial_text += '\nTEST='
588 self._ParseDescription(UserEditedLog(initial_text))
589
590 def _ParseDescription(self, description):
591 if not description:
592 self.description = description
593 return
594
595 parsed_lines = []
596 reviewers_regexp = re.compile('\s*R=(.+)')
597 reviewers = ''
598 subject = ''
599 for l in description.splitlines():
600 if not subject:
601 subject = l
602 matched_reviewers = reviewers_regexp.match(l)
603 if matched_reviewers:
604 reviewers = matched_reviewers.group(1)
605 parsed_lines.append(l)
606
607 self.description = '\n'.join(parsed_lines) + '\n'
608 self.subject = subject
609 self.reviewers = reviewers
610
611 def IsEmpty(self):
612 return not self.description
613
614
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000615def FindCodereviewSettingsFile(filename='codereview.settings'):
616 """Finds the given file starting in the cwd and going up.
617
618 Only looks up to the top of the repository unless an
619 'inherit-review-settings-ok' file exists in the root of the repository.
620 """
621 inherit_ok_file = 'inherit-review-settings-ok'
622 cwd = os.getcwd()
623 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
624 if os.path.isfile(os.path.join(root, inherit_ok_file)):
625 root = '/'
626 while True:
627 if filename in os.listdir(cwd):
628 if os.path.isfile(os.path.join(cwd, filename)):
629 return open(os.path.join(cwd, filename))
630 if cwd == root:
631 break
632 cwd = os.path.dirname(cwd)
633
634
635def LoadCodereviewSettingsFromFile(fileobj):
636 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000637 keyvals = {}
638 for line in fileobj.read().splitlines():
639 if not line or line.startswith("#"):
640 continue
641 k, v = line.split(": ", 1)
642 keyvals[k] = v
643
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000644 def SetProperty(name, setting, unset_error_ok=False):
645 fullname = 'rietveld.' + name
646 if setting in keyvals:
647 RunGit(['config', fullname, keyvals[setting]])
648 else:
649 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
650
651 SetProperty('server', 'CODE_REVIEW_SERVER')
652 # Only server setting is required. Other settings can be absent.
653 # In that case, we ignore errors raised during option deletion attempt.
654 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
655 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
656 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
657
658 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
659 #should be of the form
660 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
661 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
662 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
663 keyvals['ORIGIN_URL_CONFIG']])
664
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000665
666@usage('[repo root containing codereview.settings]')
667def CMDconfig(parser, args):
668 """edit configuration for this tree"""
669
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000670 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000671 if len(args) == 0:
672 GetCodereviewSettingsInteractively()
673 return 0
674
675 url = args[0]
676 if not url.endswith('codereview.settings'):
677 url = os.path.join(url, 'codereview.settings')
678
679 # Load code review settings and download hooks (if available).
680 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
681 return 0
682
683
684def CMDstatus(parser, args):
685 """show status of changelists"""
686 parser.add_option('--field',
687 help='print only specific field (desc|id|patch|url)')
688 (options, args) = parser.parse_args(args)
689
690 # TODO: maybe make show_branches a flag if necessary.
691 show_branches = not options.field
692
693 if show_branches:
694 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
695 if branches:
696 print 'Branches associated with reviews:'
697 for branch in sorted(branches.splitlines()):
698 cl = Changelist(branchref=branch)
699 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
700
701 cl = Changelist()
702 if options.field:
703 if options.field.startswith('desc'):
704 print cl.GetDescription()
705 elif options.field == 'id':
706 issueid = cl.GetIssue()
707 if issueid:
708 print issueid
709 elif options.field == 'patch':
710 patchset = cl.GetPatchset()
711 if patchset:
712 print patchset
713 elif options.field == 'url':
714 url = cl.GetIssueURL()
715 if url:
716 print url
717 else:
718 print
719 print 'Current branch:',
720 if not cl.GetIssue():
721 print 'no issue assigned.'
722 return 0
723 print cl.GetBranch()
724 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
725 print 'Issue description:'
726 print cl.GetDescription(pretty=True)
727 return 0
728
729
730@usage('[issue_number]')
731def CMDissue(parser, args):
732 """Set or display the current code review issue number.
733
734 Pass issue number 0 to clear the current issue.
735"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000736 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000737
738 cl = Changelist()
739 if len(args) > 0:
740 try:
741 issue = int(args[0])
742 except ValueError:
743 DieWithError('Pass a number to set the issue or none to list it.\n'
744 'Maybe you want to run git cl status?')
745 cl.SetIssue(issue)
746 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
747 return 0
748
749
750def CreateDescriptionFromLog(args):
751 """Pulls out the commit log to use as a base for the CL description."""
752 log_args = []
753 if len(args) == 1 and not args[0].endswith('.'):
754 log_args = [args[0] + '..']
755 elif len(args) == 1 and args[0].endswith('...'):
756 log_args = [args[0][:-1]]
757 elif len(args) == 2:
758 log_args = [args[0] + '..' + args[1]]
759 else:
760 log_args = args[:] # Hope for the best!
761 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
762
763
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000764def UserEditedLog(starting_text):
765 """Given some starting text, let the user edit it and return the result."""
766 editor = os.getenv('EDITOR', 'vi')
767
768 (file_handle, filename) = tempfile.mkstemp()
769 fileobj = os.fdopen(file_handle, 'w')
770 fileobj.write(starting_text)
771 fileobj.close()
772
773 # Open up the default editor in the system to get the CL description.
774 try:
775 cmd = '%s %s' % (editor, filename)
776 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
777 # Msysgit requires the usage of 'env' to be present.
778 cmd = 'env ' + cmd
779 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
780 subprocess.check_call(cmd, shell=True)
781 fileobj = open(filename)
782 text = fileobj.read()
783 fileobj.close()
784 finally:
785 os.remove(filename)
786
787 if not text:
788 return
789
790 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
791 return stripcomment_re.sub('', text).strip()
792
793
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000794def ConvertToInteger(inputval):
795 """Convert a string to integer, but returns either an int or None."""
796 try:
797 return int(inputval)
798 except (TypeError, ValueError):
799 return None
800
801
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000802def RunHook(committing, upstream_branch, rietveld_server, tbr, may_prompt):
803 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000804 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip()
805 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000806 root = '.'
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000807 absroot = os.path.abspath(root)
808 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000809 raise Exception('Could not get root directory.')
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000810
811 # We use the sha1 of HEAD as a name of this change.
812 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
813 files = scm.GIT.CaptureStatus([root], upstream_branch)
814
815 cl = Changelist()
816 issue = ConvertToInteger(cl.GetIssue())
817 patchset = ConvertToInteger(cl.GetPatchset())
818 if issue:
819 description = cl.GetDescription()
820 else:
821 # If the change was never uploaded, use the log messages of all commits
822 # up to the branch point, as git cl upload will prefill the description
823 # with these log messages.
824 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000825 '%s...' % (upstream_branch)]).strip()
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000826 change = presubmit_support.GitChange(name, description, absroot, files,
827 issue, patchset)
828
829 # Apply watchlists on upload.
830 if not committing:
831 watchlist = watchlists.Watchlists(change.RepositoryRoot())
832 files = [f.LocalPath() for f in change.AffectedFiles()]
833 watchers = watchlist.GetWatchersForPaths(files)
834 RunCommand(['git', 'config', '--replace-all',
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000835 'rietveld.extracc', ','.join(watchers)])
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000836
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000837 output = presubmit_support.DoPresubmitChecks(change, committing,
838 verbose=False, output_stream=sys.stdout, input_stream=sys.stdin,
839 default_presubmit=None, may_prompt=may_prompt, tbr=tbr,
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000840 host_url=cl.GetRietveldServer())
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000841
842 # TODO(dpranke): We should propagate the error out instead of calling exit().
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000843 if not output.should_continue():
844 sys.exit(1)
dpranke@chromium.org37248c02011-03-12 04:36:48 +0000845
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000846 return output
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000847
848
849def CMDpresubmit(parser, args):
850 """run presubmit tests on the current changelist"""
851 parser.add_option('--upload', action='store_true',
852 help='Run upload hook instead of the push/dcommit hook')
853 (options, args) = parser.parse_args(args)
854
855 # Make sure index is up-to-date before running diff-index.
856 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
857 if RunGit(['diff-index', 'HEAD']):
858 # TODO(maruel): Is this really necessary?
859 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
860 return 1
861
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000862 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000863 if args:
864 base_branch = args[0]
865 else:
866 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000867 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000868
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000869 RunHook(committing=not options.upload, upstream_branch=base_branch,
870 rietveld_server=cl.GetRietveldServer(), tbr=False,
871 may_prompt=False)
872 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000873
874
875@usage('[args to "git diff"]')
876def CMDupload(parser, args):
877 """upload the current changelist to codereview"""
878 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
879 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000880 parser.add_option('-f', action='store_true', dest='force',
881 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000882 parser.add_option('-m', dest='message', help='message for patch')
883 parser.add_option('-r', '--reviewers',
884 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000885 parser.add_option('--cc',
886 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000887 parser.add_option('--send-mail', action='store_true',
888 help='send email to reviewer immediately')
889 parser.add_option("--emulate_svn_auto_props", action="store_true",
890 dest="emulate_svn_auto_props",
891 help="Emulate Subversion's auto properties feature.")
892 parser.add_option("--desc_from_logs", action="store_true",
893 dest="from_logs",
894 help="""Squashes git commit logs into change description and
895 uses message as subject""")
896 (options, args) = parser.parse_args(args)
897
898 # Make sure index is up-to-date before running diff-index.
899 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
900 if RunGit(['diff-index', 'HEAD']):
901 print 'Cannot upload with a dirty tree. You must commit locally first.'
902 return 1
903
904 cl = Changelist()
905 if args:
906 base_branch = args[0]
907 else:
908 # Default to diffing against the "upstream" branch.
909 base_branch = cl.GetUpstreamBranch()
910 args = [base_branch + "..."]
911
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000912 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000913 hook_results = RunHook(committing=False, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000914 rietveld_server=cl.GetRietveldServer(), tbr=False,
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000915 may_prompt=True)
916 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000917 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000918
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000919
920 # --no-ext-diff is broken in some versions of Git, so try to work around
921 # this by overriding the environment (but there is still a problem if the
922 # git config key "diff.external" is used).
923 env = os.environ.copy()
924 if 'GIT_EXTERNAL_DIFF' in env:
925 del env['GIT_EXTERNAL_DIFF']
926 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
927 env=env)
928
929 upload_args = ['--assume_yes'] # Don't ask about untracked files.
930 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000931 if options.emulate_svn_auto_props:
932 upload_args.append('--emulate_svn_auto_props')
933 if options.send_mail:
934 if not options.reviewers:
935 DieWithError("Must specify reviewers to send email.")
936 upload_args.append('--send_mail')
937 if options.from_logs and not options.message:
938 print 'Must set message for subject line if using desc_from_logs'
939 return 1
940
941 change_desc = None
942
943 if cl.GetIssue():
944 if options.message:
945 upload_args.extend(['--message', options.message])
946 upload_args.extend(['--issue', cl.GetIssue()])
947 print ("This branch is associated with issue %s. "
948 "Adding patch to that issue." % cl.GetIssue())
949 else:
950 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000951 change_desc = ChangeDescription(options.message, log_desc,
952 options.reviewers)
953 if not options.from_logs:
954 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000955
956 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000957 print "Description is empty; aborting."
958 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000959
960 upload_args.extend(['--message', change_desc.subject])
961 upload_args.extend(['--description', change_desc.description])
962 if change_desc.reviewers:
963 upload_args.extend(['--reviewers', change_desc.reviewers])
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000964 cc = ','.join(filter(None, (settings.GetCCList(), options.cc)))
965 if cc:
966 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000967
968 # Include the upstream repo's URL in the change -- this is useful for
969 # projects that have their source spread across multiple repos.
970 remote_url = None
971 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000972 # URL is dependent on the current directory.
973 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000974 if data:
975 keys = dict(line.split(': ', 1) for line in data.splitlines()
976 if ': ' in line)
977 remote_url = keys.get('URL', None)
978 else:
979 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
980 remote_url = (cl.GetRemoteUrl() + '@'
981 + cl.GetUpstreamBranch().split('/')[-1])
982 if remote_url:
983 upload_args.extend(['--base_url', remote_url])
984
985 try:
986 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
maruel@chromium.org9ce0dff2011-04-04 17:56:50 +0000987 except KeyboardInterrupt:
988 sys.exit(1)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000989 except:
990 # If we got an exception after the user typed a description for their
991 # change, back up the description before re-raising.
992 if change_desc:
993 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
994 print '\nGot exception while uploading -- saving description to %s\n' \
995 % backup_path
996 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000997 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000998 backup_file.close()
999 raise
1000
1001 if not cl.GetIssue():
1002 cl.SetIssue(issue)
1003 cl.SetPatchset(patchset)
1004 return 0
1005
1006
1007def SendUpstream(parser, args, cmd):
1008 """Common code for CmdPush and CmdDCommit
1009
1010 Squashed commit into a single.
1011 Updates changelog with metadata (e.g. pointer to review).
1012 Pushes/dcommits the code upstream.
1013 Updates review and closes.
1014 """
1015 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1016 help='bypass upload presubmit hook')
1017 parser.add_option('-m', dest='message',
1018 help="override review description")
1019 parser.add_option('-f', action='store_true', dest='force',
1020 help="force yes to questions (don't prompt)")
1021 parser.add_option('-c', dest='contributor',
1022 help="external contributor for patch (appended to " +
1023 "description and used as author for git). Should be " +
1024 "formatted as 'First Last <email@example.com>'")
1025 parser.add_option('--tbr', action='store_true', dest='tbr',
1026 help="short for 'to be reviewed', commit branch " +
1027 "even without uploading for review")
1028 (options, args) = parser.parse_args(args)
1029 cl = Changelist()
1030
1031 if not args or cmd == 'push':
1032 # Default to merging against our best guess of the upstream branch.
1033 args = [cl.GetUpstreamBranch()]
1034
1035 base_branch = args[0]
1036
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001037 # Make sure index is up-to-date before running diff-index.
1038 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001039 if RunGit(['diff-index', 'HEAD']):
1040 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1041 return 1
1042
1043 # This rev-list syntax means "show all commits not in my branch that
1044 # are in base_branch".
1045 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1046 base_branch]).splitlines()
1047 if upstream_commits:
1048 print ('Base branch "%s" has %d commits '
1049 'not in this branch.' % (base_branch, len(upstream_commits)))
1050 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1051 return 1
1052
1053 if cmd == 'dcommit':
1054 # This is the revision `svn dcommit` will commit on top of.
1055 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1056 '--pretty=format:%H'])
1057 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1058 if extra_commits:
1059 print ('This branch has %d additional commits not upstreamed yet.'
1060 % len(extra_commits.splitlines()))
1061 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1062 'before attempting to %s.' % (base_branch, cmd))
1063 return 1
1064
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001065 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001066 RunHook(committing=True, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001067 rietveld_server=cl.GetRietveldServer(), tbr=options.tbr,
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001068 may_prompt=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001069
1070 if cmd == 'dcommit':
1071 # Check the tree status if the tree status URL is set.
1072 status = GetTreeStatus()
1073 if 'closed' == status:
1074 print ('The tree is closed. Please wait for it to reopen. Use '
1075 '"git cl dcommit -f" to commit on a closed tree.')
1076 return 1
1077 elif 'unknown' == status:
1078 print ('Unable to determine tree status. Please verify manually and '
1079 'use "git cl dcommit -f" to commit on a closed tree.')
1080
1081 description = options.message
1082 if not options.tbr:
1083 # It is important to have these checks early. Not only for user
1084 # convenience, but also because the cl object then caches the correct values
1085 # of these fields even as we're juggling branches for setting up the commit.
1086 if not cl.GetIssue():
1087 print 'Current issue unknown -- has this branch been uploaded?'
1088 print 'Use --tbr to commit without review.'
1089 return 1
1090
1091 if not description:
1092 description = cl.GetDescription()
1093
1094 if not description:
1095 print 'No description set.'
1096 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1097 return 1
1098
1099 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1100 else:
1101 if not description:
1102 # Submitting TBR. See if there's already a description in Rietveld, else
1103 # create a template description. Eitherway, give the user a chance to edit
1104 # it to fill in the TBR= field.
1105 if cl.GetIssue():
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001106 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001107
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001108 # TODO(dpranke): Update to use ChangeDescription object.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001109 if not description:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001110 description = """# Enter a description of the change.
1111# This will be used as the change log for the commit.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001112
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001113"""
1114 description += CreateDescriptionFromLog(args)
1115
1116 description = UserEditedLog(description + '\nTBR=')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001117
1118 if not description:
1119 print "Description empty; aborting."
1120 return 1
1121
1122 if options.contributor:
1123 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1124 print "Please provide contibutor as 'First Last <email@example.com>'"
1125 return 1
1126 description += "\nPatch from %s." % options.contributor
1127 print 'Description:', repr(description)
1128
1129 branches = [base_branch, cl.GetBranchRef()]
1130 if not options.force:
1131 subprocess.call(['git', 'diff', '--stat'] + branches)
maruel@chromium.org90541732011-04-01 17:54:18 +00001132 ask_for_data('About to commit; enter to confirm.')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001133
1134 # We want to squash all this branch's commits into one commit with the
1135 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001136 # We do this by doing a "reset --soft" to the base branch (which keeps
1137 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001138 MERGE_BRANCH = 'git-cl-commit'
1139 # Delete the merge branch if it already exists.
1140 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1141 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1142 RunGit(['branch', '-D', MERGE_BRANCH])
1143
1144 # We might be in a directory that's present in this branch but not in the
1145 # trunk. Move up to the top of the tree so that git commands that expect a
1146 # valid CWD won't fail after we check out the merge branch.
1147 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1148 if rel_base_path:
1149 os.chdir(rel_base_path)
1150
1151 # Stuff our change into the merge branch.
1152 # We wrap in a try...finally block so if anything goes wrong,
1153 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001154 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001155 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001156 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1157 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001158 if options.contributor:
1159 RunGit(['commit', '--author', options.contributor, '-m', description])
1160 else:
1161 RunGit(['commit', '-m', description])
1162 if cmd == 'push':
1163 # push the merge branch.
1164 remote, branch = cl.FetchUpstreamTuple()
1165 retcode, output = RunGitWithCode(
1166 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1167 logging.debug(output)
1168 else:
1169 # dcommit the merge branch.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001170 retcode, output = RunGitWithCode(['svn', 'dcommit', '--no-rebase'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001171 finally:
1172 # And then swap back to the original branch and clean up.
1173 RunGit(['checkout', '-q', cl.GetBranch()])
1174 RunGit(['branch', '-D', MERGE_BRANCH])
1175
1176 if cl.GetIssue():
1177 if cmd == 'dcommit' and 'Committed r' in output:
1178 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1179 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001180 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1181 for l in output.splitlines(False))
1182 match = filter(None, match)
1183 if len(match) != 1:
1184 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1185 output)
1186 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001187 else:
1188 return 1
1189 viewvc_url = settings.GetViewVCUrl()
1190 if viewvc_url and revision:
1191 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1192 print ('Closing issue '
1193 '(you may be prompted for your codereview password)...')
1194 cl.CloseIssue()
1195 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001196
1197 if retcode == 0:
1198 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1199 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001200 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001201
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001202 return 0
1203
1204
1205@usage('[upstream branch to apply against]')
1206def CMDdcommit(parser, args):
1207 """commit the current changelist via git-svn"""
1208 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001209 message = """This doesn't appear to be an SVN repository.
1210If your project has a git mirror with an upstream SVN master, you probably need
1211to run 'git svn init', see your project's git mirror documentation.
1212If your project has a true writeable upstream repository, you probably want
1213to run 'git cl push' instead.
1214Choose wisely, if you get this wrong, your commit might appear to succeed but
1215will instead be silently ignored."""
1216 print(message)
maruel@chromium.org90541732011-04-01 17:54:18 +00001217 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001218 return SendUpstream(parser, args, 'dcommit')
1219
1220
1221@usage('[upstream branch to apply against]')
1222def CMDpush(parser, args):
1223 """commit the current changelist via git"""
1224 if settings.GetIsGitSvn():
1225 print('This appears to be an SVN repository.')
1226 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
maruel@chromium.org90541732011-04-01 17:54:18 +00001227 ask_for_data('[Press enter to push or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001228 return SendUpstream(parser, args, 'push')
1229
1230
1231@usage('<patch url or issue id>')
1232def CMDpatch(parser, args):
1233 """patch in a code review"""
1234 parser.add_option('-b', dest='newbranch',
1235 help='create a new branch off trunk for the patch')
1236 parser.add_option('-f', action='store_true', dest='force',
1237 help='with -b, clobber any existing branch')
1238 parser.add_option('--reject', action='store_true', dest='reject',
1239 help='allow failed patches and spew .rej files')
1240 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1241 help="don't commit after patch applies")
1242 (options, args) = parser.parse_args(args)
1243 if len(args) != 1:
1244 parser.print_help()
1245 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001246 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001247
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001248 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001249 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001250 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001251 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001252 else:
1253 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001254 issue_url = FixUrl(issue_arg)
1255 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001256 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001257 DieWithError('Must pass an issue ID or full URL for '
1258 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001259 issue = match.group(1)
1260 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001261
1262 if options.newbranch:
1263 if options.force:
1264 RunGit(['branch', '-D', options.newbranch],
1265 swallow_stderr=True, error_ok=True)
1266 RunGit(['checkout', '-b', options.newbranch,
1267 Changelist().GetUpstreamBranch()])
1268
1269 # Switch up to the top-level directory, if necessary, in preparation for
1270 # applying the patch.
1271 top = RunGit(['rev-parse', '--show-cdup']).strip()
1272 if top:
1273 os.chdir(top)
1274
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001275 # Git patches have a/ at the beginning of source paths. We strip that out
1276 # with a sed script rather than the -p flag to patch so we can feed either
1277 # Git or svn-style patches into the same apply command.
1278 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1279 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1280 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1281 patch_data = sed_proc.communicate(patch_data)[0]
1282 if sed_proc.returncode:
1283 DieWithError('Git patch mungling failed.')
1284 logging.info(patch_data)
1285 # We use "git apply" to apply the patch instead of "patch" so that we can
1286 # pick up file adds.
1287 # The --index flag means: also insert into the index (so we catch adds).
1288 cmd = ['git', 'apply', '--index', '-p0']
1289 if options.reject:
1290 cmd.append('--reject')
1291 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1292 patch_proc.communicate(patch_data)
1293 if patch_proc.returncode:
1294 DieWithError('Failed to apply the patch')
1295
1296 # If we had an issue, commit the current state and register the issue.
1297 if not options.nocommit:
1298 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1299 cl = Changelist()
1300 cl.SetIssue(issue)
1301 print "Committed patch."
1302 else:
1303 print "Patch applied to index."
1304 return 0
1305
1306
1307def CMDrebase(parser, args):
1308 """rebase current branch on top of svn repo"""
1309 # Provide a wrapper for git svn rebase to help avoid accidental
1310 # git svn dcommit.
1311 # It's the only command that doesn't use parser at all since we just defer
1312 # execution to git-svn.
1313 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1314 return 0
1315
1316
1317def GetTreeStatus():
1318 """Fetches the tree status and returns either 'open', 'closed',
1319 'unknown' or 'unset'."""
1320 url = settings.GetTreeStatusUrl(error_ok=True)
1321 if url:
1322 status = urllib2.urlopen(url).read().lower()
1323 if status.find('closed') != -1 or status == '0':
1324 return 'closed'
1325 elif status.find('open') != -1 or status == '1':
1326 return 'open'
1327 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001328 return 'unset'
1329
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001330
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001331def GetTreeStatusReason():
1332 """Fetches the tree status from a json url and returns the message
1333 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001334 url = settings.GetTreeStatusUrl()
1335 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001336 connection = urllib2.urlopen(json_url)
1337 status = json.loads(connection.read())
1338 connection.close()
1339 return status['message']
1340
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001341
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001342def CMDtree(parser, args):
1343 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001344 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001345 status = GetTreeStatus()
1346 if 'unset' == status:
1347 print 'You must configure your tree status URL by running "git cl config".'
1348 return 2
1349
1350 print "The tree is %s" % status
1351 print
1352 print GetTreeStatusReason()
1353 if status != 'open':
1354 return 1
1355 return 0
1356
1357
1358def CMDupstream(parser, args):
1359 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001360 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001361 cl = Changelist()
1362 print cl.GetUpstreamBranch()
1363 return 0
1364
1365
1366def Command(name):
1367 return getattr(sys.modules[__name__], 'CMD' + name, None)
1368
1369
1370def CMDhelp(parser, args):
1371 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001372 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001373 if len(args) == 1:
1374 return main(args + ['--help'])
1375 parser.print_help()
1376 return 0
1377
1378
1379def GenUsage(parser, command):
1380 """Modify an OptParse object with the function's documentation."""
1381 obj = Command(command)
1382 more = getattr(obj, 'usage_more', '')
1383 if command == 'help':
1384 command = '<command>'
1385 else:
1386 # OptParser.description prefer nicely non-formatted strings.
1387 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1388 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1389
1390
1391def main(argv):
1392 """Doesn't parse the arguments here, just find the right subcommand to
1393 execute."""
1394 # Do it late so all commands are listed.
1395 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1396 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1397 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1398
1399 # Create the option parse and add --verbose support.
1400 parser = optparse.OptionParser()
1401 parser.add_option('-v', '--verbose', action='store_true')
1402 old_parser_args = parser.parse_args
1403 def Parse(args):
1404 options, args = old_parser_args(args)
1405 if options.verbose:
1406 logging.basicConfig(level=logging.DEBUG)
1407 else:
1408 logging.basicConfig(level=logging.WARNING)
1409 return options, args
1410 parser.parse_args = Parse
1411
1412 if argv:
1413 command = Command(argv[0])
1414 if command:
1415 # "fix" the usage and the description now that we know the subcommand.
1416 GenUsage(parser, argv[0])
1417 try:
1418 return command(parser, argv[1:])
1419 except urllib2.HTTPError, e:
1420 if e.code != 500:
1421 raise
1422 DieWithError(
1423 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1424 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1425
1426 # Not a known command. Default to help.
1427 GenUsage(parser, 'help')
1428 return CMDhelp(parser, argv)
1429
1430
1431if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001432 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001433 sys.exit(main(sys.argv[1:]))