blob: e59749f7710dcf8b43d308ec7e00777cadf3c033 [file] [log] [blame]
maruel@chromium.org725f1c32011-04-01 20:24:54 +00001#!/usr/bin/env python
2# Copyright (c) 2011 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00006# Copyright (C) 2008 Evan Martin <martine@danga.com>
7
maruel@chromium.org725f1c32011-04-01 20:24:54 +00008"""A git-command for integrating reviews on Rietveld."""
9
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000010import errno
11import logging
12import optparse
13import os
14import re
15import subprocess
16import sys
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000017import tempfile
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000018import textwrap
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +000019import urlparse
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000020import urllib2
21
22try:
maruel@chromium.orgc98c0c52011-04-06 13:39:43 +000023 import readline # pylint: disable=F0401,W0611
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000024except ImportError:
25 pass
26
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000027try:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000028 import simplejson as json # pylint: disable=F0401
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000029except ImportError:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000030 try:
maruel@chromium.org725f1c32011-04-01 20:24:54 +000031 import json # pylint: disable=F0401
maruel@chromium.org2a74d372011-03-29 19:05:50 +000032 except ImportError:
33 # Fall back to the packaged version.
maruel@chromium.orgb35c00c2011-03-31 00:43:35 +000034 sys.path.append(os.path.join(os.path.dirname(__file__), 'third_party'))
dpranke@chromium.orgfe79c312011-04-01 20:15:52 +000035 import simplejson as json # pylint: disable=F0401
maruel@chromium.org2a74d372011-03-29 19:05:50 +000036
37
38from third_party import upload
39import breakpad # pylint: disable=W0611
maruel@chromium.org6f09cd92011-04-01 16:38:12 +000040import fix_encoding
maruel@chromium.org2a74d372011-03-29 19:05:50 +000041import presubmit_support
maruel@chromium.orgcab38e92011-04-09 00:30:51 +000042import rietveld
maruel@chromium.org2a74d372011-03-29 19:05:50 +000043import scm
44import watchlists
45
46
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000047
48DEFAULT_SERVER = 'http://codereview.appspot.com'
maruel@chromium.org0ba7f962011-01-11 22:13:58 +000049POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000050DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup'
51
maruel@chromium.org90541732011-04-01 17:54:18 +000052
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000053def DieWithError(message):
dpranke@chromium.org970c5222011-03-12 00:32:24 +000054 print >> sys.stderr, message
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000055 sys.exit(1)
56
57
58def Popen(cmd, **kwargs):
59 """Wrapper for subprocess.Popen() that logs and watch for cygwin issues"""
maruel@chromium.org899e1c12011-04-07 17:03:18 +000060 logging.debug('Popen: ' + ' '.join(cmd))
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000061 try:
62 return subprocess.Popen(cmd, **kwargs)
63 except OSError, e:
64 if e.errno == errno.EAGAIN and sys.platform == 'cygwin':
65 DieWithError(
66 'Visit '
67 'http://code.google.com/p/chromium/wiki/CygwinDllRemappingFailure to '
68 'learn how to fix this error; you need to rebase your cygwin dlls')
69 raise
70
71
72def RunCommand(cmd, error_ok=False, error_message=None,
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000073 redirect_stdout=True, swallow_stderr=False, **kwargs):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000074 if redirect_stdout:
75 stdout = subprocess.PIPE
76 else:
77 stdout = None
78 if swallow_stderr:
79 stderr = subprocess.PIPE
80 else:
81 stderr = None
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000082 proc = Popen(cmd, stdout=stdout, stderr=stderr, **kwargs)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000083 output = proc.communicate()[0]
84 if not error_ok and proc.returncode != 0:
85 DieWithError('Command "%s" failed.\n' % (' '.join(cmd)) +
86 (error_message or output or ''))
87 return output
88
89
90def RunGit(args, **kwargs):
91 cmd = ['git'] + args
92 return RunCommand(cmd, **kwargs)
93
94
95def RunGitWithCode(args):
96 proc = Popen(['git'] + args, stdout=subprocess.PIPE)
97 output = proc.communicate()[0]
98 return proc.returncode, output
99
100
101def usage(more):
102 def hook(fn):
103 fn.usage_more = more
104 return fn
105 return hook
106
107
maruel@chromium.org90541732011-04-01 17:54:18 +0000108def ask_for_data(prompt):
109 try:
110 return raw_input(prompt)
111 except KeyboardInterrupt:
112 # Hide the exception.
113 sys.exit(1)
114
115
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000116def FixUrl(server):
117 """Fix a server url to defaults protocol to http:// if none is specified."""
118 if not server:
119 return server
120 if not re.match(r'[a-z]+\://.*', server):
121 return 'http://' + server
122 return server
123
124
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000125def MatchSvnGlob(url, base_url, glob_spec, allow_wildcards):
126 """Return the corresponding git ref if |base_url| together with |glob_spec|
127 matches the full |url|.
128
129 If |allow_wildcards| is true, |glob_spec| can contain wildcards (see below).
130 """
131 fetch_suburl, as_ref = glob_spec.split(':')
132 if allow_wildcards:
133 glob_match = re.match('(.+/)?(\*|{[^/]*})(/.+)?', fetch_suburl)
134 if glob_match:
135 # Parse specs like "branches/*/src:refs/remotes/svn/*" or
136 # "branches/{472,597,648}/src:refs/remotes/svn/*".
137 branch_re = re.escape(base_url)
138 if glob_match.group(1):
139 branch_re += '/' + re.escape(glob_match.group(1))
140 wildcard = glob_match.group(2)
141 if wildcard == '*':
142 branch_re += '([^/]*)'
143 else:
144 # Escape and replace surrounding braces with parentheses and commas
145 # with pipe symbols.
146 wildcard = re.escape(wildcard)
147 wildcard = re.sub('^\\\\{', '(', wildcard)
148 wildcard = re.sub('\\\\,', '|', wildcard)
149 wildcard = re.sub('\\\\}$', ')', wildcard)
150 branch_re += wildcard
151 if glob_match.group(3):
152 branch_re += re.escape(glob_match.group(3))
153 match = re.match(branch_re, url)
154 if match:
155 return re.sub('\*$', match.group(1), as_ref)
156
157 # Parse specs like "trunk/src:refs/remotes/origin/trunk".
158 if fetch_suburl:
159 full_url = base_url + '/' + fetch_suburl
160 else:
161 full_url = base_url
162 if full_url == url:
163 return as_ref
164 return None
165
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000166class Settings(object):
167 def __init__(self):
168 self.default_server = None
169 self.cc = None
170 self.root = None
171 self.is_git_svn = None
172 self.svn_branch = None
173 self.tree_status_url = None
174 self.viewvc_url = None
175 self.updated = False
176
177 def LazyUpdateIfNeeded(self):
178 """Updates the settings from a codereview.settings file, if available."""
179 if not self.updated:
180 cr_settings_file = FindCodereviewSettingsFile()
181 if cr_settings_file:
182 LoadCodereviewSettingsFromFile(cr_settings_file)
183 self.updated = True
184
185 def GetDefaultServerUrl(self, error_ok=False):
186 if not self.default_server:
187 self.LazyUpdateIfNeeded()
188 self.default_server = FixUrl(self._GetConfig('rietveld.server',
189 error_ok=True))
190 if error_ok:
191 return self.default_server
192 if not self.default_server:
193 error_message = ('Could not find settings file. You must configure '
194 'your review setup by running "git cl config".')
195 self.default_server = FixUrl(self._GetConfig(
196 'rietveld.server', error_message=error_message))
197 return self.default_server
198
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000199 def GetRoot(self):
200 if not self.root:
201 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
202 return self.root
203
204 def GetIsGitSvn(self):
205 """Return true if this repo looks like it's using git-svn."""
206 if self.is_git_svn is None:
207 # If you have any "svn-remote.*" config keys, we think you're using svn.
208 self.is_git_svn = RunGitWithCode(
209 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
210 return self.is_git_svn
211
212 def GetSVNBranch(self):
213 if self.svn_branch is None:
214 if not self.GetIsGitSvn():
215 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
216
217 # Try to figure out which remote branch we're based on.
218 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000219 # 1) iterate through our branch history and find the svn URL.
220 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000221
222 # regexp matching the git-svn line that contains the URL.
223 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
224
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000225 # We don't want to go through all of history, so read a line from the
226 # pipe at a time.
227 # The -100 is an arbitrary limit so we don't search forever.
228 cmd = ['git', 'log', '-100', '--pretty=medium']
229 proc = Popen(cmd, stdout=subprocess.PIPE)
maruel@chromium.org740f9d72011-06-10 18:33:10 +0000230 url = None
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000231 for line in proc.stdout:
232 match = git_svn_re.match(line)
233 if match:
234 url = match.group(1)
235 proc.stdout.close() # Cut pipe.
236 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000237
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000238 if url:
239 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
240 remotes = RunGit(['config', '--get-regexp',
241 r'^svn-remote\..*\.url']).splitlines()
242 for remote in remotes:
243 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000244 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000245 remote = match.group(1)
246 base_url = match.group(2)
247 fetch_spec = RunGit(
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000248 ['config', 'svn-remote.%s.fetch' % remote],
249 error_ok=True).strip()
250 if fetch_spec:
251 self.svn_branch = MatchSvnGlob(url, base_url, fetch_spec, False)
252 if self.svn_branch:
253 break
254 branch_spec = RunGit(
255 ['config', 'svn-remote.%s.branches' % remote],
256 error_ok=True).strip()
257 if branch_spec:
258 self.svn_branch = MatchSvnGlob(url, base_url, branch_spec, True)
259 if self.svn_branch:
260 break
261 tag_spec = RunGit(
262 ['config', 'svn-remote.%s.tags' % remote],
263 error_ok=True).strip()
264 if tag_spec:
265 self.svn_branch = MatchSvnGlob(url, base_url, tag_spec, True)
266 if self.svn_branch:
267 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000268
269 if not self.svn_branch:
270 DieWithError('Can\'t guess svn branch -- try specifying it on the '
271 'command line')
272
273 return self.svn_branch
274
275 def GetTreeStatusUrl(self, error_ok=False):
276 if not self.tree_status_url:
277 error_message = ('You must configure your tree status URL by running '
278 '"git cl config".')
279 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
280 error_ok=error_ok,
281 error_message=error_message)
282 return self.tree_status_url
283
284 def GetViewVCUrl(self):
285 if not self.viewvc_url:
286 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
287 return self.viewvc_url
288
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000289 def GetDefaultCCList(self):
290 return self._GetConfig('rietveld.cc', error_ok=True)
291
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000292 def _GetConfig(self, param, **kwargs):
293 self.LazyUpdateIfNeeded()
294 return RunGit(['config', param], **kwargs).strip()
295
296
297settings = Settings()
298
299
300did_migrate_check = False
301def CheckForMigration():
302 """Migrate from the old issue format, if found.
303
304 We used to store the branch<->issue mapping in a file in .git, but it's
305 better to store it in the .git/config, since deleting a branch deletes that
306 branch's entry there.
307 """
308
309 # Don't run more than once.
310 global did_migrate_check
311 if did_migrate_check:
312 return
313
314 gitdir = RunGit(['rev-parse', '--git-dir']).strip()
315 storepath = os.path.join(gitdir, 'cl-mapping')
316 if os.path.exists(storepath):
317 print "old-style git-cl mapping file (%s) found; migrating." % storepath
318 store = open(storepath, 'r')
319 for line in store:
320 branch, issue = line.strip().split()
321 RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
322 issue])
323 store.close()
324 os.remove(storepath)
325 did_migrate_check = True
326
327
328def ShortBranchName(branch):
329 """Convert a name like 'refs/heads/foo' to just 'foo'."""
330 return branch.replace('refs/heads/', '')
331
332
333class Changelist(object):
334 def __init__(self, branchref=None):
335 # Poke settings so we get the "configure your server" message if necessary.
336 settings.GetDefaultServerUrl()
337 self.branchref = branchref
338 if self.branchref:
339 self.branch = ShortBranchName(self.branchref)
340 else:
341 self.branch = None
342 self.rietveld_server = None
343 self.upstream_branch = None
344 self.has_issue = False
345 self.issue = None
346 self.has_description = False
347 self.description = None
348 self.has_patchset = False
349 self.patchset = None
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000350 self._rpc_server = None
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000351 self.cc = None
352 self.watchers = ()
353
354 def GetCCList(self):
355 """Return the users cc'd on this CL.
356
357 Return is a string suitable for passing to gcl with the --cc flag.
358 """
359 if self.cc is None:
360 base_cc = settings .GetDefaultCCList()
361 more_cc = ','.join(self.watchers)
362 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
363 return self.cc
364
365 def SetWatchers(self, watchers):
366 """Set the list of email addresses that should be cc'd based on the changed
367 files in this CL.
368 """
369 self.watchers = watchers
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000370
371 def GetBranch(self):
372 """Returns the short branch name, e.g. 'master'."""
373 if not self.branch:
374 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
375 self.branch = ShortBranchName(self.branchref)
376 return self.branch
377
378 def GetBranchRef(self):
379 """Returns the full branch name, e.g. 'refs/heads/master'."""
380 self.GetBranch() # Poke the lazy loader.
381 return self.branchref
382
383 def FetchUpstreamTuple(self):
384 """Returns a tuple containg remote and remote ref,
385 e.g. 'origin', 'refs/heads/master'
386 """
387 remote = '.'
388 branch = self.GetBranch()
389 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
390 error_ok=True).strip()
391 if upstream_branch:
392 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
393 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000394 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
395 error_ok=True).strip()
396 if upstream_branch:
397 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000398 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000399 # Fall back on trying a git-svn upstream branch.
400 if settings.GetIsGitSvn():
401 upstream_branch = settings.GetSVNBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000402 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000403 # Else, try to guess the origin remote.
404 remote_branches = RunGit(['branch', '-r']).split()
405 if 'origin/master' in remote_branches:
406 # Fall back on origin/master if it exits.
407 remote = 'origin'
408 upstream_branch = 'refs/heads/master'
409 elif 'origin/trunk' in remote_branches:
410 # Fall back on origin/trunk if it exists. Generally a shared
411 # git-svn clone
412 remote = 'origin'
413 upstream_branch = 'refs/heads/trunk'
414 else:
415 DieWithError("""Unable to determine default branch to diff against.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000416Either pass complete "git diff"-style arguments, like
417 git cl upload origin/master
418or verify this branch is set up to track another (via the --track argument to
419"git checkout -b ...").""")
420
421 return remote, upstream_branch
422
423 def GetUpstreamBranch(self):
424 if self.upstream_branch is None:
425 remote, upstream_branch = self.FetchUpstreamTuple()
426 if remote is not '.':
427 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
428 self.upstream_branch = upstream_branch
429 return self.upstream_branch
430
431 def GetRemoteUrl(self):
432 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
433
434 Returns None if there is no remote.
435 """
436 remote = self.FetchUpstreamTuple()[0]
437 if remote == '.':
438 return None
439 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
440
441 def GetIssue(self):
442 if not self.has_issue:
443 CheckForMigration()
444 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
445 if issue:
446 self.issue = issue
447 self.rietveld_server = FixUrl(RunGit(
448 ['config', self._RietveldServer()], error_ok=True).strip())
449 else:
450 self.issue = None
451 if not self.rietveld_server:
452 self.rietveld_server = settings.GetDefaultServerUrl()
453 self.has_issue = True
454 return self.issue
455
456 def GetRietveldServer(self):
457 self.GetIssue()
458 return self.rietveld_server
459
460 def GetIssueURL(self):
461 """Get the URL for a particular issue."""
462 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
463
464 def GetDescription(self, pretty=False):
465 if not self.has_description:
466 if self.GetIssue():
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000467 self.description = self.RpcServer().get_description(
468 int(self.GetIssue())).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000469 self.has_description = True
470 if pretty:
471 wrapper = textwrap.TextWrapper()
472 wrapper.initial_indent = wrapper.subsequent_indent = ' '
473 return wrapper.fill(self.description)
474 return self.description
475
476 def GetPatchset(self):
477 if not self.has_patchset:
478 patchset = RunGit(['config', self._PatchsetSetting()],
479 error_ok=True).strip()
480 if patchset:
481 self.patchset = patchset
482 else:
483 self.patchset = None
484 self.has_patchset = True
485 return self.patchset
486
487 def SetPatchset(self, patchset):
488 """Set this branch's patchset. If patchset=0, clears the patchset."""
489 if patchset:
490 RunGit(['config', self._PatchsetSetting(), str(patchset)])
491 else:
492 RunGit(['config', '--unset', self._PatchsetSetting()],
493 swallow_stderr=True, error_ok=True)
494 self.has_patchset = False
495
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000496 def GetPatchSetDiff(self, issue):
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000497 patchset = self.RpcServer().get_issue_properties(
498 int(issue), False)['patchsets'][-1]
499 return self.RpcServer().get(
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000500 '/download/issue%s_%s.diff' % (issue, patchset))
501
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000502 def SetIssue(self, issue):
503 """Set this branch's issue. If issue=0, clears the issue."""
504 if issue:
505 RunGit(['config', self._IssueSetting(), str(issue)])
506 if self.rietveld_server:
507 RunGit(['config', self._RietveldServer(), self.rietveld_server])
508 else:
509 RunGit(['config', '--unset', self._IssueSetting()])
510 self.SetPatchset(0)
511 self.has_issue = False
512
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +0000513 def RunHook(self, committing, upstream_branch, may_prompt, verbose, author):
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000514 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000515 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip() or '.'
516 absroot = os.path.abspath(root)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000517
518 # We use the sha1 of HEAD as a name of this change.
519 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000520 # Need to pass a relative path for msysgit.
521 files = scm.GIT.CaptureStatus([root], upstream_branch)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000522
523 issue = ConvertToInteger(self.GetIssue())
524 patchset = ConvertToInteger(self.GetPatchset())
525 if issue:
526 description = self.GetDescription()
527 else:
528 # If the change was never uploaded, use the log messages of all commits
529 # up to the branch point, as git cl upload will prefill the description
530 # with these log messages.
531 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
532 '%s...' % (upstream_branch)]).strip()
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000533
534 if not author:
maruel@chromium.org63f78b02011-06-14 13:32:54 +0000535 author = RunGit(['config', 'user.email']).strip()
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000536 change = presubmit_support.GitChange(
537 name,
538 description,
539 absroot,
540 files,
541 issue,
542 patchset,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000543 author)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000544
545 # Apply watchlists on upload.
546 if not committing:
547 watchlist = watchlists.Watchlists(change.RepositoryRoot())
548 files = [f.LocalPath() for f in change.AffectedFiles()]
549 self.SetWatchers(watchlist.GetWatchersForPaths(files))
550
551 try:
552 output = presubmit_support.DoPresubmitChecks(change, committing,
553 verbose=verbose, output_stream=sys.stdout, input_stream=sys.stdin,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +0000554 default_presubmit=None, may_prompt=may_prompt,
maruel@chromium.org239f4112011-06-03 20:08:23 +0000555 rietveld_obj=self.RpcServer())
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000556 except presubmit_support.PresubmitFailure, e:
557 DieWithError(
558 ('%s\nMaybe your depot_tools is out of date?\n'
559 'If all fails, contact maruel@') % e)
560
561 # TODO(dpranke): We should propagate the error out instead of calling
562 # exit().
563 if not output.should_continue():
564 sys.exit(1)
565
566 return output
567
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000568 def CloseIssue(self):
maruel@chromium.org607bb1b2011-06-01 23:43:11 +0000569 """Updates the description and closes the issue."""
570 issue = int(self.GetIssue())
571 self.RpcServer().update_description(issue, self.description)
572 return self.RpcServer().close_issue(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000573
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000574 def SetFlag(self, flag, value):
575 """Patchset must match."""
576 if not self.GetPatchset():
577 DieWithError('The patchset needs to match. Send another patchset.')
578 try:
579 return self.RpcServer().set_flag(
580 int(self.GetIssue()), int(self.GetPatchset()), flag, value)
581 except urllib2.HTTPError, e:
582 if e.code == 404:
583 DieWithError('The issue %s doesn\'t exist.' % self.GetIssue())
584 if e.code == 403:
585 DieWithError(
586 ('Access denied to issue %s. Maybe the patchset %s doesn\'t '
587 'match?') % (self.GetIssue(), self.GetPatchset()))
588 raise
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000589
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000590 def RpcServer(self):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000591 """Returns an upload.RpcServer() to access this review's rietveld instance.
592 """
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000593 if not self._rpc_server:
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000594 self.GetIssue()
595 self._rpc_server = rietveld.Rietveld(self.rietveld_server, None, None)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000596 return self._rpc_server
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000597
598 def _IssueSetting(self):
599 """Return the git setting that stores this change's issue."""
600 return 'branch.%s.rietveldissue' % self.GetBranch()
601
602 def _PatchsetSetting(self):
603 """Return the git setting that stores this change's most recent patchset."""
604 return 'branch.%s.rietveldpatchset' % self.GetBranch()
605
606 def _RietveldServer(self):
607 """Returns the git setting that stores this change's rietveld server."""
608 return 'branch.%s.rietveldserver' % self.GetBranch()
609
610
611def GetCodereviewSettingsInteractively():
612 """Prompt the user for settings."""
613 server = settings.GetDefaultServerUrl(error_ok=True)
614 prompt = 'Rietveld server (host[:port])'
615 prompt += ' [%s]' % (server or DEFAULT_SERVER)
maruel@chromium.org90541732011-04-01 17:54:18 +0000616 newserver = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000617 if not server and not newserver:
618 newserver = DEFAULT_SERVER
619 if newserver and newserver != server:
620 RunGit(['config', 'rietveld.server', newserver])
621
622 def SetProperty(initial, caption, name):
623 prompt = caption
624 if initial:
625 prompt += ' ("x" to clear) [%s]' % initial
maruel@chromium.org90541732011-04-01 17:54:18 +0000626 new_val = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000627 if new_val == 'x':
628 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
629 elif new_val and new_val != initial:
630 RunGit(['config', 'rietveld.' + name, new_val])
631
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000632 SetProperty(settings.GetDefaultCCList(), 'CC list', 'cc')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000633 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
634 'tree-status-url')
635 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
636
637 # TODO: configure a default branch to diff against, rather than this
638 # svn-based hackery.
639
640
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000641class ChangeDescription(object):
642 """Contains a parsed form of the change description."""
643 def __init__(self, subject, log_desc, reviewers):
644 self.subject = subject
645 self.log_desc = log_desc
646 self.reviewers = reviewers
647 self.description = self.log_desc
648
649 def Update(self):
650 initial_text = """# Enter a description of the change.
651# This will displayed on the codereview site.
652# The first line will also be used as the subject of the review.
653"""
654 initial_text += self.description
655 if 'R=' not in self.description and self.reviewers:
656 initial_text += '\nR=' + self.reviewers
657 if 'BUG=' not in self.description:
658 initial_text += '\nBUG='
659 if 'TEST=' not in self.description:
660 initial_text += '\nTEST='
661 self._ParseDescription(UserEditedLog(initial_text))
662
663 def _ParseDescription(self, description):
664 if not description:
665 self.description = description
666 return
667
668 parsed_lines = []
669 reviewers_regexp = re.compile('\s*R=(.+)')
670 reviewers = ''
671 subject = ''
672 for l in description.splitlines():
673 if not subject:
674 subject = l
675 matched_reviewers = reviewers_regexp.match(l)
676 if matched_reviewers:
677 reviewers = matched_reviewers.group(1)
678 parsed_lines.append(l)
679
680 self.description = '\n'.join(parsed_lines) + '\n'
681 self.subject = subject
682 self.reviewers = reviewers
683
684 def IsEmpty(self):
685 return not self.description
686
687
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000688def FindCodereviewSettingsFile(filename='codereview.settings'):
689 """Finds the given file starting in the cwd and going up.
690
691 Only looks up to the top of the repository unless an
692 'inherit-review-settings-ok' file exists in the root of the repository.
693 """
694 inherit_ok_file = 'inherit-review-settings-ok'
695 cwd = os.getcwd()
696 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
697 if os.path.isfile(os.path.join(root, inherit_ok_file)):
698 root = '/'
699 while True:
700 if filename in os.listdir(cwd):
701 if os.path.isfile(os.path.join(cwd, filename)):
702 return open(os.path.join(cwd, filename))
703 if cwd == root:
704 break
705 cwd = os.path.dirname(cwd)
706
707
708def LoadCodereviewSettingsFromFile(fileobj):
709 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000710 keyvals = {}
711 for line in fileobj.read().splitlines():
712 if not line or line.startswith("#"):
713 continue
714 k, v = line.split(": ", 1)
715 keyvals[k] = v
716
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000717 def SetProperty(name, setting, unset_error_ok=False):
718 fullname = 'rietveld.' + name
719 if setting in keyvals:
720 RunGit(['config', fullname, keyvals[setting]])
721 else:
722 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
723
724 SetProperty('server', 'CODE_REVIEW_SERVER')
725 # Only server setting is required. Other settings can be absent.
726 # In that case, we ignore errors raised during option deletion attempt.
727 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
728 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
729 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
730
731 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
732 #should be of the form
733 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
734 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
735 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
736 keyvals['ORIGIN_URL_CONFIG']])
737
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000738
739@usage('[repo root containing codereview.settings]')
740def CMDconfig(parser, args):
741 """edit configuration for this tree"""
742
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000743 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000744 if len(args) == 0:
745 GetCodereviewSettingsInteractively()
746 return 0
747
748 url = args[0]
749 if not url.endswith('codereview.settings'):
750 url = os.path.join(url, 'codereview.settings')
751
752 # Load code review settings and download hooks (if available).
753 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
754 return 0
755
756
757def CMDstatus(parser, args):
758 """show status of changelists"""
759 parser.add_option('--field',
760 help='print only specific field (desc|id|patch|url)')
761 (options, args) = parser.parse_args(args)
762
763 # TODO: maybe make show_branches a flag if necessary.
764 show_branches = not options.field
765
766 if show_branches:
767 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
768 if branches:
769 print 'Branches associated with reviews:'
770 for branch in sorted(branches.splitlines()):
771 cl = Changelist(branchref=branch)
772 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
773
774 cl = Changelist()
775 if options.field:
776 if options.field.startswith('desc'):
777 print cl.GetDescription()
778 elif options.field == 'id':
779 issueid = cl.GetIssue()
780 if issueid:
781 print issueid
782 elif options.field == 'patch':
783 patchset = cl.GetPatchset()
784 if patchset:
785 print patchset
786 elif options.field == 'url':
787 url = cl.GetIssueURL()
788 if url:
789 print url
790 else:
791 print
792 print 'Current branch:',
793 if not cl.GetIssue():
794 print 'no issue assigned.'
795 return 0
796 print cl.GetBranch()
797 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
798 print 'Issue description:'
799 print cl.GetDescription(pretty=True)
800 return 0
801
802
803@usage('[issue_number]')
804def CMDissue(parser, args):
805 """Set or display the current code review issue number.
806
807 Pass issue number 0 to clear the current issue.
808"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000809 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000810
811 cl = Changelist()
812 if len(args) > 0:
813 try:
814 issue = int(args[0])
815 except ValueError:
816 DieWithError('Pass a number to set the issue or none to list it.\n'
817 'Maybe you want to run git cl status?')
818 cl.SetIssue(issue)
819 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
820 return 0
821
822
823def CreateDescriptionFromLog(args):
824 """Pulls out the commit log to use as a base for the CL description."""
825 log_args = []
826 if len(args) == 1 and not args[0].endswith('.'):
827 log_args = [args[0] + '..']
828 elif len(args) == 1 and args[0].endswith('...'):
829 log_args = [args[0][:-1]]
830 elif len(args) == 2:
831 log_args = [args[0] + '..' + args[1]]
832 else:
833 log_args = args[:] # Hope for the best!
834 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
835
836
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000837def UserEditedLog(starting_text):
838 """Given some starting text, let the user edit it and return the result."""
839 editor = os.getenv('EDITOR', 'vi')
840
841 (file_handle, filename) = tempfile.mkstemp()
842 fileobj = os.fdopen(file_handle, 'w')
843 fileobj.write(starting_text)
844 fileobj.close()
845
846 # Open up the default editor in the system to get the CL description.
847 try:
848 cmd = '%s %s' % (editor, filename)
849 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
850 # Msysgit requires the usage of 'env' to be present.
851 cmd = 'env ' + cmd
852 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
maruel@chromium.org2a471072011-05-10 17:29:23 +0000853 try:
854 subprocess.check_call(cmd, shell=True)
855 except subprocess.CalledProcessError, e:
856 DieWithError('Editor returned %d' % e.returncode)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000857 fileobj = open(filename)
858 text = fileobj.read()
859 fileobj.close()
860 finally:
861 os.remove(filename)
862
863 if not text:
864 return
865
866 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
867 return stripcomment_re.sub('', text).strip()
868
869
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000870def ConvertToInteger(inputval):
871 """Convert a string to integer, but returns either an int or None."""
872 try:
873 return int(inputval)
874 except (TypeError, ValueError):
875 return None
876
877
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000878def CMDpresubmit(parser, args):
879 """run presubmit tests on the current changelist"""
880 parser.add_option('--upload', action='store_true',
881 help='Run upload hook instead of the push/dcommit hook')
882 (options, args) = parser.parse_args(args)
883
884 # Make sure index is up-to-date before running diff-index.
885 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
886 if RunGit(['diff-index', 'HEAD']):
887 # TODO(maruel): Is this really necessary?
888 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
889 return 1
890
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000891 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000892 if args:
893 base_branch = args[0]
894 else:
895 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000896 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000897
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000898 cl.RunHook(committing=not options.upload, upstream_branch=base_branch,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +0000899 may_prompt=False, verbose=options.verbose,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000900 author=None)
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000901 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000902
903
904@usage('[args to "git diff"]')
905def CMDupload(parser, args):
906 """upload the current changelist to codereview"""
907 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
908 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000909 parser.add_option('-f', action='store_true', dest='force',
910 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000911 parser.add_option('-m', dest='message', help='message for patch')
912 parser.add_option('-r', '--reviewers',
913 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000914 parser.add_option('--cc',
915 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000916 parser.add_option('--send-mail', action='store_true',
917 help='send email to reviewer immediately')
918 parser.add_option("--emulate_svn_auto_props", action="store_true",
919 dest="emulate_svn_auto_props",
920 help="Emulate Subversion's auto properties feature.")
921 parser.add_option("--desc_from_logs", action="store_true",
922 dest="from_logs",
923 help="""Squashes git commit logs into change description and
924 uses message as subject""")
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000925 parser.add_option('-c', '--use-commit-queue', action='store_true',
926 help='tell the commit queue to commit this patchset')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000927 (options, args) = parser.parse_args(args)
928
929 # Make sure index is up-to-date before running diff-index.
930 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
931 if RunGit(['diff-index', 'HEAD']):
932 print 'Cannot upload with a dirty tree. You must commit locally first.'
933 return 1
934
935 cl = Changelist()
936 if args:
937 base_branch = args[0]
938 else:
939 # Default to diffing against the "upstream" branch.
940 base_branch = cl.GetUpstreamBranch()
941 args = [base_branch + "..."]
942
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000943 if not options.bypass_hooks and not options.force:
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000944 hook_results = cl.RunHook(committing=False, upstream_branch=base_branch,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +0000945 may_prompt=True,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000946 verbose=options.verbose,
947 author=None)
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000948 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000949 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000950
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000951
952 # --no-ext-diff is broken in some versions of Git, so try to work around
953 # this by overriding the environment (but there is still a problem if the
954 # git config key "diff.external" is used).
955 env = os.environ.copy()
956 if 'GIT_EXTERNAL_DIFF' in env:
957 del env['GIT_EXTERNAL_DIFF']
958 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
959 env=env)
960
961 upload_args = ['--assume_yes'] # Don't ask about untracked files.
962 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000963 if options.emulate_svn_auto_props:
964 upload_args.append('--emulate_svn_auto_props')
965 if options.send_mail:
966 if not options.reviewers:
967 DieWithError("Must specify reviewers to send email.")
968 upload_args.append('--send_mail')
969 if options.from_logs and not options.message:
970 print 'Must set message for subject line if using desc_from_logs'
971 return 1
972
973 change_desc = None
974
975 if cl.GetIssue():
976 if options.message:
977 upload_args.extend(['--message', options.message])
978 upload_args.extend(['--issue', cl.GetIssue()])
979 print ("This branch is associated with issue %s. "
980 "Adding patch to that issue." % cl.GetIssue())
981 else:
982 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000983 change_desc = ChangeDescription(options.message, log_desc,
984 options.reviewers)
985 if not options.from_logs:
986 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000987
988 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000989 print "Description is empty; aborting."
990 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000991
992 upload_args.extend(['--message', change_desc.subject])
993 upload_args.extend(['--description', change_desc.description])
994 if change_desc.reviewers:
995 upload_args.extend(['--reviewers', change_desc.reviewers])
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000996 cc = ','.join(filter(None, (cl.GetCCList(), options.cc)))
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000997 if cc:
998 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000999
1000 # Include the upstream repo's URL in the change -- this is useful for
1001 # projects that have their source spread across multiple repos.
1002 remote_url = None
1003 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +00001004 # URL is dependent on the current directory.
1005 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001006 if data:
1007 keys = dict(line.split(': ', 1) for line in data.splitlines()
1008 if ': ' in line)
1009 remote_url = keys.get('URL', None)
1010 else:
1011 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
1012 remote_url = (cl.GetRemoteUrl() + '@'
1013 + cl.GetUpstreamBranch().split('/')[-1])
1014 if remote_url:
1015 upload_args.extend(['--base_url', remote_url])
1016
1017 try:
1018 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
maruel@chromium.org9ce0dff2011-04-04 17:56:50 +00001019 except KeyboardInterrupt:
1020 sys.exit(1)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001021 except:
1022 # If we got an exception after the user typed a description for their
1023 # change, back up the description before re-raising.
1024 if change_desc:
1025 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
1026 print '\nGot exception while uploading -- saving description to %s\n' \
1027 % backup_path
1028 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001029 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001030 backup_file.close()
1031 raise
1032
1033 if not cl.GetIssue():
1034 cl.SetIssue(issue)
1035 cl.SetPatchset(patchset)
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001036
1037 if options.use_commit_queue:
1038 cl.SetFlag('commit', '1')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001039 return 0
1040
1041
1042def SendUpstream(parser, args, cmd):
1043 """Common code for CmdPush and CmdDCommit
1044
1045 Squashed commit into a single.
1046 Updates changelog with metadata (e.g. pointer to review).
1047 Pushes/dcommits the code upstream.
1048 Updates review and closes.
1049 """
1050 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1051 help='bypass upload presubmit hook')
1052 parser.add_option('-m', dest='message',
1053 help="override review description")
1054 parser.add_option('-f', action='store_true', dest='force',
1055 help="force yes to questions (don't prompt)")
1056 parser.add_option('-c', dest='contributor',
1057 help="external contributor for patch (appended to " +
1058 "description and used as author for git). Should be " +
1059 "formatted as 'First Last <email@example.com>'")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001060 (options, args) = parser.parse_args(args)
1061 cl = Changelist()
1062
1063 if not args or cmd == 'push':
1064 # Default to merging against our best guess of the upstream branch.
1065 args = [cl.GetUpstreamBranch()]
1066
1067 base_branch = args[0]
1068
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001069 # Make sure index is up-to-date before running diff-index.
1070 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001071 if RunGit(['diff-index', 'HEAD']):
1072 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1073 return 1
1074
1075 # This rev-list syntax means "show all commits not in my branch that
1076 # are in base_branch".
1077 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1078 base_branch]).splitlines()
1079 if upstream_commits:
1080 print ('Base branch "%s" has %d commits '
1081 'not in this branch.' % (base_branch, len(upstream_commits)))
1082 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1083 return 1
1084
1085 if cmd == 'dcommit':
1086 # This is the revision `svn dcommit` will commit on top of.
1087 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1088 '--pretty=format:%H'])
1089 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1090 if extra_commits:
1091 print ('This branch has %d additional commits not upstreamed yet.'
1092 % len(extra_commits.splitlines()))
1093 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1094 'before attempting to %s.' % (base_branch, cmd))
1095 return 1
1096
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001097 if not options.bypass_hooks and not options.force:
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +00001098 cl.RunHook(committing=True, upstream_branch=base_branch,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001099 may_prompt=True, verbose=options.verbose,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +00001100 author=options.contributor)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001101
1102 if cmd == 'dcommit':
1103 # Check the tree status if the tree status URL is set.
1104 status = GetTreeStatus()
1105 if 'closed' == status:
1106 print ('The tree is closed. Please wait for it to reopen. Use '
1107 '"git cl dcommit -f" to commit on a closed tree.')
1108 return 1
1109 elif 'unknown' == status:
1110 print ('Unable to determine tree status. Please verify manually and '
1111 'use "git cl dcommit -f" to commit on a closed tree.')
1112
1113 description = options.message
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001114 if not description and cl.GetIssue():
1115 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001116
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001117 if not description:
1118 print 'No description set.'
1119 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1120 return 1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001121
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001122 if cl.GetIssue():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001123 description += "\n\nReview URL: %s" % cl.GetIssueURL()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001124
1125 if options.contributor:
1126 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1127 print "Please provide contibutor as 'First Last <email@example.com>'"
1128 return 1
1129 description += "\nPatch from %s." % options.contributor
1130 print 'Description:', repr(description)
1131
1132 branches = [base_branch, cl.GetBranchRef()]
1133 if not options.force:
1134 subprocess.call(['git', 'diff', '--stat'] + branches)
maruel@chromium.org90541732011-04-01 17:54:18 +00001135 ask_for_data('About to commit; enter to confirm.')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001136
1137 # We want to squash all this branch's commits into one commit with the
1138 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001139 # We do this by doing a "reset --soft" to the base branch (which keeps
1140 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001141 MERGE_BRANCH = 'git-cl-commit'
1142 # Delete the merge branch if it already exists.
1143 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1144 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1145 RunGit(['branch', '-D', MERGE_BRANCH])
1146
1147 # We might be in a directory that's present in this branch but not in the
1148 # trunk. Move up to the top of the tree so that git commands that expect a
1149 # valid CWD won't fail after we check out the merge branch.
1150 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1151 if rel_base_path:
1152 os.chdir(rel_base_path)
1153
1154 # Stuff our change into the merge branch.
1155 # We wrap in a try...finally block so if anything goes wrong,
1156 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001157 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001158 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001159 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1160 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001161 if options.contributor:
1162 RunGit(['commit', '--author', options.contributor, '-m', description])
1163 else:
1164 RunGit(['commit', '-m', description])
1165 if cmd == 'push':
1166 # push the merge branch.
1167 remote, branch = cl.FetchUpstreamTuple()
1168 retcode, output = RunGitWithCode(
1169 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1170 logging.debug(output)
1171 else:
1172 # dcommit the merge branch.
bauerb@chromium.org2e64fa12011-05-05 11:13:44 +00001173 retcode, output = RunGitWithCode(['svn', 'dcommit',
1174 '--no-rebase', '--rmdir'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001175 finally:
1176 # And then swap back to the original branch and clean up.
1177 RunGit(['checkout', '-q', cl.GetBranch()])
1178 RunGit(['branch', '-D', MERGE_BRANCH])
1179
1180 if cl.GetIssue():
1181 if cmd == 'dcommit' and 'Committed r' in output:
1182 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1183 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001184 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1185 for l in output.splitlines(False))
1186 match = filter(None, match)
1187 if len(match) != 1:
1188 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1189 output)
1190 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001191 else:
1192 return 1
1193 viewvc_url = settings.GetViewVCUrl()
1194 if viewvc_url and revision:
1195 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1196 print ('Closing issue '
1197 '(you may be prompted for your codereview password)...')
1198 cl.CloseIssue()
1199 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001200
1201 if retcode == 0:
1202 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1203 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001204 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001205
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001206 return 0
1207
1208
1209@usage('[upstream branch to apply against]')
1210def CMDdcommit(parser, args):
1211 """commit the current changelist via git-svn"""
1212 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001213 message = """This doesn't appear to be an SVN repository.
1214If your project has a git mirror with an upstream SVN master, you probably need
1215to run 'git svn init', see your project's git mirror documentation.
1216If your project has a true writeable upstream repository, you probably want
1217to run 'git cl push' instead.
1218Choose wisely, if you get this wrong, your commit might appear to succeed but
1219will instead be silently ignored."""
1220 print(message)
maruel@chromium.org90541732011-04-01 17:54:18 +00001221 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001222 return SendUpstream(parser, args, 'dcommit')
1223
1224
1225@usage('[upstream branch to apply against]')
1226def CMDpush(parser, args):
1227 """commit the current changelist via git"""
1228 if settings.GetIsGitSvn():
1229 print('This appears to be an SVN repository.')
1230 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
maruel@chromium.org90541732011-04-01 17:54:18 +00001231 ask_for_data('[Press enter to push or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001232 return SendUpstream(parser, args, 'push')
1233
1234
1235@usage('<patch url or issue id>')
1236def CMDpatch(parser, args):
1237 """patch in a code review"""
1238 parser.add_option('-b', dest='newbranch',
1239 help='create a new branch off trunk for the patch')
1240 parser.add_option('-f', action='store_true', dest='force',
1241 help='with -b, clobber any existing branch')
1242 parser.add_option('--reject', action='store_true', dest='reject',
1243 help='allow failed patches and spew .rej files')
1244 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1245 help="don't commit after patch applies")
1246 (options, args) = parser.parse_args(args)
1247 if len(args) != 1:
1248 parser.print_help()
1249 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001250 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001251
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001252 # TODO(maruel): Use apply_issue.py
1253
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001254 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001255 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001256 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001257 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001258 else:
1259 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001260 issue_url = FixUrl(issue_arg)
1261 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001262 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001263 DieWithError('Must pass an issue ID or full URL for '
1264 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001265 issue = match.group(1)
1266 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001267
1268 if options.newbranch:
1269 if options.force:
1270 RunGit(['branch', '-D', options.newbranch],
1271 swallow_stderr=True, error_ok=True)
1272 RunGit(['checkout', '-b', options.newbranch,
1273 Changelist().GetUpstreamBranch()])
1274
1275 # Switch up to the top-level directory, if necessary, in preparation for
1276 # applying the patch.
1277 top = RunGit(['rev-parse', '--show-cdup']).strip()
1278 if top:
1279 os.chdir(top)
1280
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001281 # Git patches have a/ at the beginning of source paths. We strip that out
1282 # with a sed script rather than the -p flag to patch so we can feed either
1283 # Git or svn-style patches into the same apply command.
1284 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1285 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1286 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1287 patch_data = sed_proc.communicate(patch_data)[0]
1288 if sed_proc.returncode:
1289 DieWithError('Git patch mungling failed.')
1290 logging.info(patch_data)
1291 # We use "git apply" to apply the patch instead of "patch" so that we can
1292 # pick up file adds.
1293 # The --index flag means: also insert into the index (so we catch adds).
1294 cmd = ['git', 'apply', '--index', '-p0']
1295 if options.reject:
1296 cmd.append('--reject')
1297 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1298 patch_proc.communicate(patch_data)
1299 if patch_proc.returncode:
1300 DieWithError('Failed to apply the patch')
1301
1302 # If we had an issue, commit the current state and register the issue.
1303 if not options.nocommit:
1304 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1305 cl = Changelist()
1306 cl.SetIssue(issue)
1307 print "Committed patch."
1308 else:
1309 print "Patch applied to index."
1310 return 0
1311
1312
1313def CMDrebase(parser, args):
1314 """rebase current branch on top of svn repo"""
1315 # Provide a wrapper for git svn rebase to help avoid accidental
1316 # git svn dcommit.
1317 # It's the only command that doesn't use parser at all since we just defer
1318 # execution to git-svn.
1319 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1320 return 0
1321
1322
1323def GetTreeStatus():
1324 """Fetches the tree status and returns either 'open', 'closed',
1325 'unknown' or 'unset'."""
1326 url = settings.GetTreeStatusUrl(error_ok=True)
1327 if url:
1328 status = urllib2.urlopen(url).read().lower()
1329 if status.find('closed') != -1 or status == '0':
1330 return 'closed'
1331 elif status.find('open') != -1 or status == '1':
1332 return 'open'
1333 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001334 return 'unset'
1335
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001336
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001337def GetTreeStatusReason():
1338 """Fetches the tree status from a json url and returns the message
1339 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001340 url = settings.GetTreeStatusUrl()
1341 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001342 connection = urllib2.urlopen(json_url)
1343 status = json.loads(connection.read())
1344 connection.close()
1345 return status['message']
1346
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001347
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001348def CMDtree(parser, args):
1349 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001350 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001351 status = GetTreeStatus()
1352 if 'unset' == status:
1353 print 'You must configure your tree status URL by running "git cl config".'
1354 return 2
1355
1356 print "The tree is %s" % status
1357 print
1358 print GetTreeStatusReason()
1359 if status != 'open':
1360 return 1
1361 return 0
1362
1363
1364def CMDupstream(parser, args):
1365 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001366 _, args = parser.parse_args(args)
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001367 if args:
1368 parser.error('Unrecognized args: %s' % ' '.join(args))
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001369 cl = Changelist()
1370 print cl.GetUpstreamBranch()
1371 return 0
1372
1373
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001374def CMDset_commit(parser, args):
1375 """set the commit bit"""
1376 _, args = parser.parse_args(args)
1377 if args:
1378 parser.error('Unrecognized args: %s' % ' '.join(args))
1379 cl = Changelist()
1380 cl.SetFlag('commit', '1')
1381 return 0
1382
1383
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001384def Command(name):
1385 return getattr(sys.modules[__name__], 'CMD' + name, None)
1386
1387
1388def CMDhelp(parser, args):
1389 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001390 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001391 if len(args) == 1:
1392 return main(args + ['--help'])
1393 parser.print_help()
1394 return 0
1395
1396
1397def GenUsage(parser, command):
1398 """Modify an OptParse object with the function's documentation."""
1399 obj = Command(command)
1400 more = getattr(obj, 'usage_more', '')
1401 if command == 'help':
1402 command = '<command>'
1403 else:
1404 # OptParser.description prefer nicely non-formatted strings.
1405 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1406 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1407
1408
1409def main(argv):
1410 """Doesn't parse the arguments here, just find the right subcommand to
1411 execute."""
1412 # Do it late so all commands are listed.
1413 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1414 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1415 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1416
1417 # Create the option parse and add --verbose support.
1418 parser = optparse.OptionParser()
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001419 parser.add_option(
1420 '-v', '--verbose', action='count', default=0,
1421 help='Use 2 times for more debugging info')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001422 old_parser_args = parser.parse_args
1423 def Parse(args):
1424 options, args = old_parser_args(args)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001425 if options.verbose >= 2:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001426 logging.basicConfig(level=logging.DEBUG)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001427 elif options.verbose:
1428 logging.basicConfig(level=logging.INFO)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001429 else:
1430 logging.basicConfig(level=logging.WARNING)
1431 return options, args
1432 parser.parse_args = Parse
1433
1434 if argv:
1435 command = Command(argv[0])
1436 if command:
1437 # "fix" the usage and the description now that we know the subcommand.
1438 GenUsage(parser, argv[0])
1439 try:
1440 return command(parser, argv[1:])
1441 except urllib2.HTTPError, e:
1442 if e.code != 500:
1443 raise
1444 DieWithError(
1445 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1446 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1447
1448 # Not a known command. Default to help.
1449 GenUsage(parser, 'help')
1450 return CMDhelp(parser, argv)
1451
1452
1453if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001454 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001455 sys.exit(main(sys.argv[1:]))