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