blob: cb33488e4bbe07e1a5120354f92bdbe363e739e4 [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 logging
11import optparse
12import os
13import re
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000014import sys
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000015import tempfile
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000016import textwrap
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +000017import urlparse
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000018import urllib2
19
20try:
maruel@chromium.orgc98c0c52011-04-06 13:39:43 +000021 import readline # pylint: disable=F0401,W0611
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000022except ImportError:
23 pass
24
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000025try:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000026 import simplejson as json # pylint: disable=F0401
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000027except ImportError:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000028 try:
maruel@chromium.org725f1c32011-04-01 20:24:54 +000029 import json # pylint: disable=F0401
maruel@chromium.org2a74d372011-03-29 19:05:50 +000030 except ImportError:
31 # Fall back to the packaged version.
maruel@chromium.orgb35c00c2011-03-31 00:43:35 +000032 sys.path.append(os.path.join(os.path.dirname(__file__), 'third_party'))
dpranke@chromium.orgfe79c312011-04-01 20:15:52 +000033 import simplejson as json # pylint: disable=F0401
maruel@chromium.org2a74d372011-03-29 19:05:50 +000034
35
36from third_party import upload
37import breakpad # pylint: disable=W0611
maruel@chromium.org6f09cd92011-04-01 16:38:12 +000038import fix_encoding
maruel@chromium.org2a74d372011-03-29 19:05:50 +000039import presubmit_support
maruel@chromium.orgcab38e92011-04-09 00:30:51 +000040import rietveld
maruel@chromium.org2a74d372011-03-29 19:05:50 +000041import scm
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000042import subprocess2
maruel@chromium.org2a74d372011-03-29 19:05:50 +000043import watchlists
44
45
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000046DEFAULT_SERVER = 'http://codereview.appspot.com'
maruel@chromium.org0ba7f962011-01-11 22:13:58 +000047POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000048DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup'
49
maruel@chromium.org90541732011-04-01 17:54:18 +000050
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000051def DieWithError(message):
dpranke@chromium.org970c5222011-03-12 00:32:24 +000052 print >> sys.stderr, message
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000053 sys.exit(1)
54
55
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000056def RunCommand(args, error_ok=False, error_message=None, **kwargs):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000057 try:
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000058 return subprocess2.check_output(args, shell=False, **kwargs)
59 except subprocess2.CalledProcessError, e:
60 if not error_ok:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000061 DieWithError(
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000062 'Command "%s" failed.\n%s' % (
63 ' '.join(args), error_message or e.stdout or ''))
64 return e.stdout
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000065
66
67def RunGit(args, **kwargs):
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000068 """Returns stdout."""
69 return RunCommand(['git'] + args, **kwargs)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000070
71
72def RunGitWithCode(args):
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +000073 """Returns return code and stdout."""
74 out, code = subprocess2.communicate(['git'] + args, stdout=subprocess2.PIPE)
75 return code, out[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000076
77
78def usage(more):
79 def hook(fn):
80 fn.usage_more = more
81 return fn
82 return hook
83
84
maruel@chromium.org90541732011-04-01 17:54:18 +000085def ask_for_data(prompt):
86 try:
87 return raw_input(prompt)
88 except KeyboardInterrupt:
89 # Hide the exception.
90 sys.exit(1)
91
92
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000093def FixUrl(server):
94 """Fix a server url to defaults protocol to http:// if none is specified."""
95 if not server:
96 return server
97 if not re.match(r'[a-z]+\://.*', server):
98 return 'http://' + server
99 return server
100
101
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000102def MatchSvnGlob(url, base_url, glob_spec, allow_wildcards):
103 """Return the corresponding git ref if |base_url| together with |glob_spec|
104 matches the full |url|.
105
106 If |allow_wildcards| is true, |glob_spec| can contain wildcards (see below).
107 """
108 fetch_suburl, as_ref = glob_spec.split(':')
109 if allow_wildcards:
110 glob_match = re.match('(.+/)?(\*|{[^/]*})(/.+)?', fetch_suburl)
111 if glob_match:
112 # Parse specs like "branches/*/src:refs/remotes/svn/*" or
113 # "branches/{472,597,648}/src:refs/remotes/svn/*".
114 branch_re = re.escape(base_url)
115 if glob_match.group(1):
116 branch_re += '/' + re.escape(glob_match.group(1))
117 wildcard = glob_match.group(2)
118 if wildcard == '*':
119 branch_re += '([^/]*)'
120 else:
121 # Escape and replace surrounding braces with parentheses and commas
122 # with pipe symbols.
123 wildcard = re.escape(wildcard)
124 wildcard = re.sub('^\\\\{', '(', wildcard)
125 wildcard = re.sub('\\\\,', '|', wildcard)
126 wildcard = re.sub('\\\\}$', ')', wildcard)
127 branch_re += wildcard
128 if glob_match.group(3):
129 branch_re += re.escape(glob_match.group(3))
130 match = re.match(branch_re, url)
131 if match:
132 return re.sub('\*$', match.group(1), as_ref)
133
134 # Parse specs like "trunk/src:refs/remotes/origin/trunk".
135 if fetch_suburl:
136 full_url = base_url + '/' + fetch_suburl
137 else:
138 full_url = base_url
139 if full_url == url:
140 return as_ref
141 return None
142
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +0000143
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000144class Settings(object):
145 def __init__(self):
146 self.default_server = None
147 self.cc = None
148 self.root = None
149 self.is_git_svn = None
150 self.svn_branch = None
151 self.tree_status_url = None
152 self.viewvc_url = None
153 self.updated = False
154
155 def LazyUpdateIfNeeded(self):
156 """Updates the settings from a codereview.settings file, if available."""
157 if not self.updated:
158 cr_settings_file = FindCodereviewSettingsFile()
159 if cr_settings_file:
160 LoadCodereviewSettingsFromFile(cr_settings_file)
161 self.updated = True
162
163 def GetDefaultServerUrl(self, error_ok=False):
164 if not self.default_server:
165 self.LazyUpdateIfNeeded()
166 self.default_server = FixUrl(self._GetConfig('rietveld.server',
167 error_ok=True))
168 if error_ok:
169 return self.default_server
170 if not self.default_server:
171 error_message = ('Could not find settings file. You must configure '
172 'your review setup by running "git cl config".')
173 self.default_server = FixUrl(self._GetConfig(
174 'rietveld.server', error_message=error_message))
175 return self.default_server
176
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000177 def GetRoot(self):
178 if not self.root:
179 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
180 return self.root
181
182 def GetIsGitSvn(self):
183 """Return true if this repo looks like it's using git-svn."""
184 if self.is_git_svn is None:
185 # If you have any "svn-remote.*" config keys, we think you're using svn.
186 self.is_git_svn = RunGitWithCode(
187 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
188 return self.is_git_svn
189
190 def GetSVNBranch(self):
191 if self.svn_branch is None:
192 if not self.GetIsGitSvn():
193 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
194
195 # Try to figure out which remote branch we're based on.
196 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000197 # 1) iterate through our branch history and find the svn URL.
198 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000199
200 # regexp matching the git-svn line that contains the URL.
201 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
202
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000203 # We don't want to go through all of history, so read a line from the
204 # pipe at a time.
205 # The -100 is an arbitrary limit so we don't search forever.
206 cmd = ['git', 'log', '-100', '--pretty=medium']
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +0000207 proc = subprocess2.Popen(cmd, stdout=subprocess2.PIPE)
maruel@chromium.org740f9d72011-06-10 18:33:10 +0000208 url = None
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000209 for line in proc.stdout:
210 match = git_svn_re.match(line)
211 if match:
212 url = match.group(1)
213 proc.stdout.close() # Cut pipe.
214 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000215
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000216 if url:
217 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
218 remotes = RunGit(['config', '--get-regexp',
219 r'^svn-remote\..*\.url']).splitlines()
220 for remote in remotes:
221 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000222 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000223 remote = match.group(1)
224 base_url = match.group(2)
225 fetch_spec = RunGit(
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000226 ['config', 'svn-remote.%s.fetch' % remote],
227 error_ok=True).strip()
228 if fetch_spec:
229 self.svn_branch = MatchSvnGlob(url, base_url, fetch_spec, False)
230 if self.svn_branch:
231 break
232 branch_spec = RunGit(
233 ['config', 'svn-remote.%s.branches' % remote],
234 error_ok=True).strip()
235 if branch_spec:
236 self.svn_branch = MatchSvnGlob(url, base_url, branch_spec, True)
237 if self.svn_branch:
238 break
239 tag_spec = RunGit(
240 ['config', 'svn-remote.%s.tags' % remote],
241 error_ok=True).strip()
242 if tag_spec:
243 self.svn_branch = MatchSvnGlob(url, base_url, tag_spec, True)
244 if self.svn_branch:
245 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000246
247 if not self.svn_branch:
248 DieWithError('Can\'t guess svn branch -- try specifying it on the '
249 'command line')
250
251 return self.svn_branch
252
253 def GetTreeStatusUrl(self, error_ok=False):
254 if not self.tree_status_url:
255 error_message = ('You must configure your tree status URL by running '
256 '"git cl config".')
257 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
258 error_ok=error_ok,
259 error_message=error_message)
260 return self.tree_status_url
261
262 def GetViewVCUrl(self):
263 if not self.viewvc_url:
264 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
265 return self.viewvc_url
266
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000267 def GetDefaultCCList(self):
268 return self._GetConfig('rietveld.cc', error_ok=True)
269
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000270 def _GetConfig(self, param, **kwargs):
271 self.LazyUpdateIfNeeded()
272 return RunGit(['config', param], **kwargs).strip()
273
274
275settings = Settings()
276
277
278did_migrate_check = False
279def CheckForMigration():
280 """Migrate from the old issue format, if found.
281
282 We used to store the branch<->issue mapping in a file in .git, but it's
283 better to store it in the .git/config, since deleting a branch deletes that
284 branch's entry there.
285 """
286
287 # Don't run more than once.
288 global did_migrate_check
289 if did_migrate_check:
290 return
291
292 gitdir = RunGit(['rev-parse', '--git-dir']).strip()
293 storepath = os.path.join(gitdir, 'cl-mapping')
294 if os.path.exists(storepath):
295 print "old-style git-cl mapping file (%s) found; migrating." % storepath
296 store = open(storepath, 'r')
297 for line in store:
298 branch, issue = line.strip().split()
299 RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
300 issue])
301 store.close()
302 os.remove(storepath)
303 did_migrate_check = True
304
305
306def ShortBranchName(branch):
307 """Convert a name like 'refs/heads/foo' to just 'foo'."""
308 return branch.replace('refs/heads/', '')
309
310
311class Changelist(object):
312 def __init__(self, branchref=None):
313 # Poke settings so we get the "configure your server" message if necessary.
314 settings.GetDefaultServerUrl()
315 self.branchref = branchref
316 if self.branchref:
317 self.branch = ShortBranchName(self.branchref)
318 else:
319 self.branch = None
320 self.rietveld_server = None
321 self.upstream_branch = None
322 self.has_issue = False
323 self.issue = None
324 self.has_description = False
325 self.description = None
326 self.has_patchset = False
327 self.patchset = None
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000328 self._rpc_server = None
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000329 self.cc = None
330 self.watchers = ()
331
332 def GetCCList(self):
333 """Return the users cc'd on this CL.
334
335 Return is a string suitable for passing to gcl with the --cc flag.
336 """
337 if self.cc is None:
338 base_cc = settings .GetDefaultCCList()
339 more_cc = ','.join(self.watchers)
340 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
341 return self.cc
342
343 def SetWatchers(self, watchers):
344 """Set the list of email addresses that should be cc'd based on the changed
345 files in this CL.
346 """
347 self.watchers = watchers
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000348
349 def GetBranch(self):
350 """Returns the short branch name, e.g. 'master'."""
351 if not self.branch:
352 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
353 self.branch = ShortBranchName(self.branchref)
354 return self.branch
355
356 def GetBranchRef(self):
357 """Returns the full branch name, e.g. 'refs/heads/master'."""
358 self.GetBranch() # Poke the lazy loader.
359 return self.branchref
360
361 def FetchUpstreamTuple(self):
362 """Returns a tuple containg remote and remote ref,
363 e.g. 'origin', 'refs/heads/master'
364 """
365 remote = '.'
366 branch = self.GetBranch()
367 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
368 error_ok=True).strip()
369 if upstream_branch:
370 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
371 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000372 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
373 error_ok=True).strip()
374 if upstream_branch:
375 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000376 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000377 # Fall back on trying a git-svn upstream branch.
378 if settings.GetIsGitSvn():
379 upstream_branch = settings.GetSVNBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000380 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000381 # Else, try to guess the origin remote.
382 remote_branches = RunGit(['branch', '-r']).split()
383 if 'origin/master' in remote_branches:
384 # Fall back on origin/master if it exits.
385 remote = 'origin'
386 upstream_branch = 'refs/heads/master'
387 elif 'origin/trunk' in remote_branches:
388 # Fall back on origin/trunk if it exists. Generally a shared
389 # git-svn clone
390 remote = 'origin'
391 upstream_branch = 'refs/heads/trunk'
392 else:
393 DieWithError("""Unable to determine default branch to diff against.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000394Either pass complete "git diff"-style arguments, like
395 git cl upload origin/master
396or verify this branch is set up to track another (via the --track argument to
397"git checkout -b ...").""")
398
399 return remote, upstream_branch
400
401 def GetUpstreamBranch(self):
402 if self.upstream_branch is None:
403 remote, upstream_branch = self.FetchUpstreamTuple()
404 if remote is not '.':
405 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
406 self.upstream_branch = upstream_branch
407 return self.upstream_branch
408
409 def GetRemoteUrl(self):
410 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
411
412 Returns None if there is no remote.
413 """
414 remote = self.FetchUpstreamTuple()[0]
415 if remote == '.':
416 return None
417 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
418
419 def GetIssue(self):
420 if not self.has_issue:
421 CheckForMigration()
422 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
423 if issue:
424 self.issue = issue
425 self.rietveld_server = FixUrl(RunGit(
426 ['config', self._RietveldServer()], error_ok=True).strip())
427 else:
428 self.issue = None
429 if not self.rietveld_server:
430 self.rietveld_server = settings.GetDefaultServerUrl()
431 self.has_issue = True
432 return self.issue
433
434 def GetRietveldServer(self):
435 self.GetIssue()
436 return self.rietveld_server
437
438 def GetIssueURL(self):
439 """Get the URL for a particular issue."""
440 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
441
442 def GetDescription(self, pretty=False):
443 if not self.has_description:
444 if self.GetIssue():
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000445 self.description = self.RpcServer().get_description(
446 int(self.GetIssue())).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000447 self.has_description = True
448 if pretty:
449 wrapper = textwrap.TextWrapper()
450 wrapper.initial_indent = wrapper.subsequent_indent = ' '
451 return wrapper.fill(self.description)
452 return self.description
453
454 def GetPatchset(self):
455 if not self.has_patchset:
456 patchset = RunGit(['config', self._PatchsetSetting()],
457 error_ok=True).strip()
458 if patchset:
459 self.patchset = patchset
460 else:
461 self.patchset = None
462 self.has_patchset = True
463 return self.patchset
464
465 def SetPatchset(self, patchset):
466 """Set this branch's patchset. If patchset=0, clears the patchset."""
467 if patchset:
468 RunGit(['config', self._PatchsetSetting(), str(patchset)])
469 else:
470 RunGit(['config', '--unset', self._PatchsetSetting()],
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +0000471 stderr=subprocess2.PIPE, error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000472 self.has_patchset = False
473
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000474 def GetPatchSetDiff(self, issue):
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000475 patchset = self.RpcServer().get_issue_properties(
476 int(issue), False)['patchsets'][-1]
477 return self.RpcServer().get(
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000478 '/download/issue%s_%s.diff' % (issue, patchset))
479
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000480 def SetIssue(self, issue):
481 """Set this branch's issue. If issue=0, clears the issue."""
482 if issue:
483 RunGit(['config', self._IssueSetting(), str(issue)])
484 if self.rietveld_server:
485 RunGit(['config', self._RietveldServer(), self.rietveld_server])
486 else:
487 RunGit(['config', '--unset', self._IssueSetting()])
488 self.SetPatchset(0)
489 self.has_issue = False
490
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +0000491 def RunHook(self, committing, upstream_branch, may_prompt, verbose, author):
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000492 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000493 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip() or '.'
494 absroot = os.path.abspath(root)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000495
496 # We use the sha1 of HEAD as a name of this change.
497 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
bauerb@chromium.org512f1ef2011-04-20 15:17:57 +0000498 # Need to pass a relative path for msysgit.
499 files = scm.GIT.CaptureStatus([root], upstream_branch)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000500
501 issue = ConvertToInteger(self.GetIssue())
502 patchset = ConvertToInteger(self.GetPatchset())
503 if issue:
504 description = self.GetDescription()
505 else:
506 # If the change was never uploaded, use the log messages of all commits
507 # up to the branch point, as git cl upload will prefill the description
508 # with these log messages.
509 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
510 '%s...' % (upstream_branch)]).strip()
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000511
512 if not author:
maruel@chromium.org13f623c2011-07-22 16:02:23 +0000513 author = RunGit(['config', 'user.email']).strip() or None
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000514 change = presubmit_support.GitChange(
515 name,
516 description,
517 absroot,
518 files,
519 issue,
520 patchset,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000521 author)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000522
523 # Apply watchlists on upload.
524 if not committing:
525 watchlist = watchlists.Watchlists(change.RepositoryRoot())
526 files = [f.LocalPath() for f in change.AffectedFiles()]
527 self.SetWatchers(watchlist.GetWatchersForPaths(files))
528
529 try:
530 output = presubmit_support.DoPresubmitChecks(change, committing,
531 verbose=verbose, output_stream=sys.stdout, input_stream=sys.stdin,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +0000532 default_presubmit=None, may_prompt=may_prompt,
maruel@chromium.org239f4112011-06-03 20:08:23 +0000533 rietveld_obj=self.RpcServer())
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000534 except presubmit_support.PresubmitFailure, e:
535 DieWithError(
536 ('%s\nMaybe your depot_tools is out of date?\n'
537 'If all fails, contact maruel@') % e)
538
539 # TODO(dpranke): We should propagate the error out instead of calling
540 # exit().
541 if not output.should_continue():
542 sys.exit(1)
543
544 return output
545
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000546 def CloseIssue(self):
maruel@chromium.org607bb1b2011-06-01 23:43:11 +0000547 """Updates the description and closes the issue."""
548 issue = int(self.GetIssue())
549 self.RpcServer().update_description(issue, self.description)
550 return self.RpcServer().close_issue(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000551
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000552 def SetFlag(self, flag, value):
553 """Patchset must match."""
554 if not self.GetPatchset():
555 DieWithError('The patchset needs to match. Send another patchset.')
556 try:
557 return self.RpcServer().set_flag(
558 int(self.GetIssue()), int(self.GetPatchset()), flag, value)
559 except urllib2.HTTPError, e:
560 if e.code == 404:
561 DieWithError('The issue %s doesn\'t exist.' % self.GetIssue())
562 if e.code == 403:
563 DieWithError(
564 ('Access denied to issue %s. Maybe the patchset %s doesn\'t '
565 'match?') % (self.GetIssue(), self.GetPatchset()))
566 raise
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000567
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000568 def RpcServer(self):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000569 """Returns an upload.RpcServer() to access this review's rietveld instance.
570 """
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000571 if not self._rpc_server:
maruel@chromium.orgcab38e92011-04-09 00:30:51 +0000572 self.GetIssue()
573 self._rpc_server = rietveld.Rietveld(self.rietveld_server, None, None)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000574 return self._rpc_server
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000575
576 def _IssueSetting(self):
577 """Return the git setting that stores this change's issue."""
578 return 'branch.%s.rietveldissue' % self.GetBranch()
579
580 def _PatchsetSetting(self):
581 """Return the git setting that stores this change's most recent patchset."""
582 return 'branch.%s.rietveldpatchset' % self.GetBranch()
583
584 def _RietveldServer(self):
585 """Returns the git setting that stores this change's rietveld server."""
586 return 'branch.%s.rietveldserver' % self.GetBranch()
587
588
589def GetCodereviewSettingsInteractively():
590 """Prompt the user for settings."""
591 server = settings.GetDefaultServerUrl(error_ok=True)
592 prompt = 'Rietveld server (host[:port])'
593 prompt += ' [%s]' % (server or DEFAULT_SERVER)
maruel@chromium.org90541732011-04-01 17:54:18 +0000594 newserver = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000595 if not server and not newserver:
596 newserver = DEFAULT_SERVER
597 if newserver and newserver != server:
598 RunGit(['config', 'rietveld.server', newserver])
599
600 def SetProperty(initial, caption, name):
601 prompt = caption
602 if initial:
603 prompt += ' ("x" to clear) [%s]' % initial
maruel@chromium.org90541732011-04-01 17:54:18 +0000604 new_val = ask_for_data(prompt + ':')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000605 if new_val == 'x':
606 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
607 elif new_val and new_val != initial:
608 RunGit(['config', 'rietveld.' + name, new_val])
609
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000610 SetProperty(settings.GetDefaultCCList(), 'CC list', 'cc')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000611 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
612 'tree-status-url')
613 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
614
615 # TODO: configure a default branch to diff against, rather than this
616 # svn-based hackery.
617
618
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000619class ChangeDescription(object):
620 """Contains a parsed form of the change description."""
621 def __init__(self, subject, log_desc, reviewers):
622 self.subject = subject
623 self.log_desc = log_desc
624 self.reviewers = reviewers
625 self.description = self.log_desc
626
627 def Update(self):
628 initial_text = """# Enter a description of the change.
629# This will displayed on the codereview site.
630# The first line will also be used as the subject of the review.
631"""
632 initial_text += self.description
633 if 'R=' not in self.description and self.reviewers:
634 initial_text += '\nR=' + self.reviewers
635 if 'BUG=' not in self.description:
636 initial_text += '\nBUG='
637 if 'TEST=' not in self.description:
638 initial_text += '\nTEST='
639 self._ParseDescription(UserEditedLog(initial_text))
640
641 def _ParseDescription(self, description):
642 if not description:
643 self.description = description
644 return
645
646 parsed_lines = []
647 reviewers_regexp = re.compile('\s*R=(.+)')
648 reviewers = ''
649 subject = ''
650 for l in description.splitlines():
651 if not subject:
652 subject = l
653 matched_reviewers = reviewers_regexp.match(l)
654 if matched_reviewers:
655 reviewers = matched_reviewers.group(1)
656 parsed_lines.append(l)
657
658 self.description = '\n'.join(parsed_lines) + '\n'
659 self.subject = subject
660 self.reviewers = reviewers
661
662 def IsEmpty(self):
663 return not self.description
664
665
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000666def FindCodereviewSettingsFile(filename='codereview.settings'):
667 """Finds the given file starting in the cwd and going up.
668
669 Only looks up to the top of the repository unless an
670 'inherit-review-settings-ok' file exists in the root of the repository.
671 """
672 inherit_ok_file = 'inherit-review-settings-ok'
673 cwd = os.getcwd()
674 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
675 if os.path.isfile(os.path.join(root, inherit_ok_file)):
676 root = '/'
677 while True:
678 if filename in os.listdir(cwd):
679 if os.path.isfile(os.path.join(cwd, filename)):
680 return open(os.path.join(cwd, filename))
681 if cwd == root:
682 break
683 cwd = os.path.dirname(cwd)
684
685
686def LoadCodereviewSettingsFromFile(fileobj):
687 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000688 keyvals = {}
689 for line in fileobj.read().splitlines():
690 if not line or line.startswith("#"):
691 continue
692 k, v = line.split(": ", 1)
693 keyvals[k] = v
694
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000695 def SetProperty(name, setting, unset_error_ok=False):
696 fullname = 'rietveld.' + name
697 if setting in keyvals:
698 RunGit(['config', fullname, keyvals[setting]])
699 else:
700 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
701
702 SetProperty('server', 'CODE_REVIEW_SERVER')
703 # Only server setting is required. Other settings can be absent.
704 # In that case, we ignore errors raised during option deletion attempt.
705 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
706 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
707 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
708
709 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
710 #should be of the form
711 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
712 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
713 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
714 keyvals['ORIGIN_URL_CONFIG']])
715
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000716
717@usage('[repo root containing codereview.settings]')
718def CMDconfig(parser, args):
719 """edit configuration for this tree"""
720
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000721 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000722 if len(args) == 0:
723 GetCodereviewSettingsInteractively()
724 return 0
725
726 url = args[0]
727 if not url.endswith('codereview.settings'):
728 url = os.path.join(url, 'codereview.settings')
729
730 # Load code review settings and download hooks (if available).
731 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
732 return 0
733
734
735def CMDstatus(parser, args):
736 """show status of changelists"""
737 parser.add_option('--field',
738 help='print only specific field (desc|id|patch|url)')
739 (options, args) = parser.parse_args(args)
740
741 # TODO: maybe make show_branches a flag if necessary.
742 show_branches = not options.field
743
744 if show_branches:
745 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
746 if branches:
747 print 'Branches associated with reviews:'
748 for branch in sorted(branches.splitlines()):
749 cl = Changelist(branchref=branch)
750 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
751
752 cl = Changelist()
753 if options.field:
754 if options.field.startswith('desc'):
755 print cl.GetDescription()
756 elif options.field == 'id':
757 issueid = cl.GetIssue()
758 if issueid:
759 print issueid
760 elif options.field == 'patch':
761 patchset = cl.GetPatchset()
762 if patchset:
763 print patchset
764 elif options.field == 'url':
765 url = cl.GetIssueURL()
766 if url:
767 print url
768 else:
769 print
770 print 'Current branch:',
771 if not cl.GetIssue():
772 print 'no issue assigned.'
773 return 0
774 print cl.GetBranch()
775 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
776 print 'Issue description:'
777 print cl.GetDescription(pretty=True)
778 return 0
779
780
781@usage('[issue_number]')
782def CMDissue(parser, args):
783 """Set or display the current code review issue number.
784
785 Pass issue number 0 to clear the current issue.
786"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000787 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000788
789 cl = Changelist()
790 if len(args) > 0:
791 try:
792 issue = int(args[0])
793 except ValueError:
794 DieWithError('Pass a number to set the issue or none to list it.\n'
795 'Maybe you want to run git cl status?')
796 cl.SetIssue(issue)
797 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
798 return 0
799
800
801def CreateDescriptionFromLog(args):
802 """Pulls out the commit log to use as a base for the CL description."""
803 log_args = []
804 if len(args) == 1 and not args[0].endswith('.'):
805 log_args = [args[0] + '..']
806 elif len(args) == 1 and args[0].endswith('...'):
807 log_args = [args[0][:-1]]
808 elif len(args) == 2:
809 log_args = [args[0] + '..' + args[1]]
810 else:
811 log_args = args[:] # Hope for the best!
812 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
813
814
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000815def UserEditedLog(starting_text):
816 """Given some starting text, let the user edit it and return the result."""
817 editor = os.getenv('EDITOR', 'vi')
818
819 (file_handle, filename) = tempfile.mkstemp()
820 fileobj = os.fdopen(file_handle, 'w')
821 fileobj.write(starting_text)
822 fileobj.close()
823
824 # Open up the default editor in the system to get the CL description.
825 try:
826 cmd = '%s %s' % (editor, filename)
827 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
828 # Msysgit requires the usage of 'env' to be present.
829 cmd = 'env ' + cmd
830 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
maruel@chromium.org2a471072011-05-10 17:29:23 +0000831 try:
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +0000832 subprocess2.check_call(cmd, shell=True)
833 except subprocess2.CalledProcessError, e:
maruel@chromium.org2a471072011-05-10 17:29:23 +0000834 DieWithError('Editor returned %d' % e.returncode)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000835 fileobj = open(filename)
836 text = fileobj.read()
837 fileobj.close()
838 finally:
839 os.remove(filename)
840
841 if not text:
842 return
843
844 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
845 return stripcomment_re.sub('', text).strip()
846
847
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000848def ConvertToInteger(inputval):
849 """Convert a string to integer, but returns either an int or None."""
850 try:
851 return int(inputval)
852 except (TypeError, ValueError):
853 return None
854
855
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000856def CMDpresubmit(parser, args):
857 """run presubmit tests on the current changelist"""
858 parser.add_option('--upload', action='store_true',
859 help='Run upload hook instead of the push/dcommit hook')
860 (options, args) = parser.parse_args(args)
861
862 # Make sure index is up-to-date before running diff-index.
863 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
864 if RunGit(['diff-index', 'HEAD']):
865 # TODO(maruel): Is this really necessary?
866 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
867 return 1
868
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000869 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000870 if args:
871 base_branch = args[0]
872 else:
873 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000874 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000875
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000876 cl.RunHook(committing=not options.upload, upstream_branch=base_branch,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +0000877 may_prompt=False, verbose=options.verbose,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000878 author=None)
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000879 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000880
881
882@usage('[args to "git diff"]')
883def CMDupload(parser, args):
884 """upload the current changelist to codereview"""
885 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
886 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000887 parser.add_option('-f', action='store_true', dest='force',
888 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000889 parser.add_option('-m', dest='message', help='message for patch')
890 parser.add_option('-r', '--reviewers',
891 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000892 parser.add_option('--cc',
893 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000894 parser.add_option('--send-mail', action='store_true',
895 help='send email to reviewer immediately')
896 parser.add_option("--emulate_svn_auto_props", action="store_true",
897 dest="emulate_svn_auto_props",
898 help="Emulate Subversion's auto properties feature.")
899 parser.add_option("--desc_from_logs", action="store_true",
900 dest="from_logs",
901 help="""Squashes git commit logs into change description and
902 uses message as subject""")
maruel@chromium.org27bb3872011-05-30 20:33:19 +0000903 parser.add_option('-c', '--use-commit-queue', action='store_true',
904 help='tell the commit queue to commit this patchset')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000905 (options, args) = parser.parse_args(args)
906
907 # Make sure index is up-to-date before running diff-index.
908 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
909 if RunGit(['diff-index', 'HEAD']):
910 print 'Cannot upload with a dirty tree. You must commit locally first.'
911 return 1
912
913 cl = Changelist()
914 if args:
915 base_branch = args[0]
916 else:
917 # Default to diffing against the "upstream" branch.
918 base_branch = cl.GetUpstreamBranch()
919 args = [base_branch + "..."]
920
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000921 if not options.bypass_hooks and not options.force:
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +0000922 hook_results = cl.RunHook(committing=False, upstream_branch=base_branch,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +0000923 may_prompt=True,
maruel@chromium.org03b3bdc2011-06-14 13:04:12 +0000924 verbose=options.verbose,
925 author=None)
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000926 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000927 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000928
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000929
930 # --no-ext-diff is broken in some versions of Git, so try to work around
931 # this by overriding the environment (but there is still a problem if the
932 # git config key "diff.external" is used).
933 env = os.environ.copy()
934 if 'GIT_EXTERNAL_DIFF' in env:
935 del env['GIT_EXTERNAL_DIFF']
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +0000936 subprocess2.call(
937 ['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args, env=env)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000938
939 upload_args = ['--assume_yes'] # Don't ask about untracked files.
940 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000941 if options.emulate_svn_auto_props:
942 upload_args.append('--emulate_svn_auto_props')
943 if options.send_mail:
944 if not options.reviewers:
945 DieWithError("Must specify reviewers to send email.")
946 upload_args.append('--send_mail')
947 if options.from_logs and not options.message:
948 print 'Must set message for subject line if using desc_from_logs'
949 return 1
950
951 change_desc = None
952
953 if cl.GetIssue():
954 if options.message:
955 upload_args.extend(['--message', options.message])
956 upload_args.extend(['--issue', cl.GetIssue()])
957 print ("This branch is associated with issue %s. "
958 "Adding patch to that issue." % cl.GetIssue())
959 else:
960 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000961 change_desc = ChangeDescription(options.message, log_desc,
962 options.reviewers)
963 if not options.from_logs:
964 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000965
966 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000967 print "Description is empty; aborting."
968 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000969
970 upload_args.extend(['--message', change_desc.subject])
971 upload_args.extend(['--description', change_desc.description])
972 if change_desc.reviewers:
973 upload_args.extend(['--reviewers', change_desc.reviewers])
bauerb@chromium.orgae6df352011-04-06 17:40:39 +0000974 cc = ','.join(filter(None, (cl.GetCCList(), options.cc)))
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000975 if cc:
976 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000977
978 # Include the upstream repo's URL in the change -- this is useful for
979 # projects that have their source spread across multiple repos.
980 remote_url = None
981 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000982 # URL is dependent on the current directory.
983 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000984 if data:
985 keys = dict(line.split(': ', 1) for line in data.splitlines()
986 if ': ' in line)
987 remote_url = keys.get('URL', None)
988 else:
989 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
990 remote_url = (cl.GetRemoteUrl() + '@'
991 + cl.GetUpstreamBranch().split('/')[-1])
992 if remote_url:
993 upload_args.extend(['--base_url', remote_url])
994
995 try:
996 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
maruel@chromium.org9ce0dff2011-04-04 17:56:50 +0000997 except KeyboardInterrupt:
998 sys.exit(1)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000999 except:
1000 # If we got an exception after the user typed a description for their
1001 # change, back up the description before re-raising.
1002 if change_desc:
1003 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
1004 print '\nGot exception while uploading -- saving description to %s\n' \
1005 % backup_path
1006 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001007 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001008 backup_file.close()
1009 raise
1010
1011 if not cl.GetIssue():
1012 cl.SetIssue(issue)
1013 cl.SetPatchset(patchset)
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001014
1015 if options.use_commit_queue:
1016 cl.SetFlag('commit', '1')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001017 return 0
1018
1019
1020def SendUpstream(parser, args, cmd):
1021 """Common code for CmdPush and CmdDCommit
1022
1023 Squashed commit into a single.
1024 Updates changelog with metadata (e.g. pointer to review).
1025 Pushes/dcommits the code upstream.
1026 Updates review and closes.
1027 """
1028 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1029 help='bypass upload presubmit hook')
1030 parser.add_option('-m', dest='message',
1031 help="override review description")
1032 parser.add_option('-f', action='store_true', dest='force',
1033 help="force yes to questions (don't prompt)")
1034 parser.add_option('-c', dest='contributor',
1035 help="external contributor for patch (appended to " +
1036 "description and used as author for git). Should be " +
1037 "formatted as 'First Last <email@example.com>'")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001038 (options, args) = parser.parse_args(args)
1039 cl = Changelist()
1040
1041 if not args or cmd == 'push':
1042 # Default to merging against our best guess of the upstream branch.
1043 args = [cl.GetUpstreamBranch()]
1044
maruel@chromium.org13f623c2011-07-22 16:02:23 +00001045 if options.contributor:
1046 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1047 print "Please provide contibutor as 'First Last <email@example.com>'"
1048 return 1
1049
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001050 base_branch = args[0]
1051
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001052 # Make sure index is up-to-date before running diff-index.
1053 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001054 if RunGit(['diff-index', 'HEAD']):
1055 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1056 return 1
1057
1058 # This rev-list syntax means "show all commits not in my branch that
1059 # are in base_branch".
1060 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1061 base_branch]).splitlines()
1062 if upstream_commits:
1063 print ('Base branch "%s" has %d commits '
1064 'not in this branch.' % (base_branch, len(upstream_commits)))
1065 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1066 return 1
1067
1068 if cmd == 'dcommit':
1069 # This is the revision `svn dcommit` will commit on top of.
1070 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1071 '--pretty=format:%H'])
1072 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1073 if extra_commits:
1074 print ('This branch has %d additional commits not upstreamed yet.'
1075 % len(extra_commits.splitlines()))
1076 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1077 'before attempting to %s.' % (base_branch, cmd))
1078 return 1
1079
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001080 if not options.bypass_hooks and not options.force:
maruel@chromium.org13f623c2011-07-22 16:02:23 +00001081 author = None
1082 if options.contributor:
1083 author = re.search(r'\<(.*)\>', options.contributor).group(1)
bauerb@chromium.org6fb99c62011-04-18 15:57:28 +00001084 cl.RunHook(committing=True, upstream_branch=base_branch,
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001085 may_prompt=True, verbose=options.verbose,
maruel@chromium.org13f623c2011-07-22 16:02:23 +00001086 author=author)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001087
1088 if cmd == 'dcommit':
1089 # Check the tree status if the tree status URL is set.
1090 status = GetTreeStatus()
1091 if 'closed' == status:
1092 print ('The tree is closed. Please wait for it to reopen. Use '
1093 '"git cl dcommit -f" to commit on a closed tree.')
1094 return 1
1095 elif 'unknown' == status:
1096 print ('Unable to determine tree status. Please verify manually and '
1097 'use "git cl dcommit -f" to commit on a closed tree.')
1098
1099 description = options.message
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001100 if not description and cl.GetIssue():
1101 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001102
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001103 if not description:
1104 print 'No description set.'
1105 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1106 return 1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001107
maruel@chromium.orgcc73ad62011-07-06 17:39:26 +00001108 if cl.GetIssue():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001109 description += "\n\nReview URL: %s" % cl.GetIssueURL()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001110
1111 if options.contributor:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001112 description += "\nPatch from %s." % options.contributor
1113 print 'Description:', repr(description)
1114
1115 branches = [base_branch, cl.GetBranchRef()]
1116 if not options.force:
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +00001117 subprocess2.call(['git', 'diff', '--stat'] + branches)
maruel@chromium.org90541732011-04-01 17:54:18 +00001118 ask_for_data('About to commit; enter to confirm.')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001119
1120 # We want to squash all this branch's commits into one commit with the
1121 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001122 # We do this by doing a "reset --soft" to the base branch (which keeps
1123 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001124 MERGE_BRANCH = 'git-cl-commit'
1125 # Delete the merge branch if it already exists.
1126 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1127 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1128 RunGit(['branch', '-D', MERGE_BRANCH])
1129
1130 # We might be in a directory that's present in this branch but not in the
1131 # trunk. Move up to the top of the tree so that git commands that expect a
1132 # valid CWD won't fail after we check out the merge branch.
1133 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1134 if rel_base_path:
1135 os.chdir(rel_base_path)
1136
1137 # Stuff our change into the merge branch.
1138 # We wrap in a try...finally block so if anything goes wrong,
1139 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001140 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001141 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001142 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1143 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001144 if options.contributor:
1145 RunGit(['commit', '--author', options.contributor, '-m', description])
1146 else:
1147 RunGit(['commit', '-m', description])
1148 if cmd == 'push':
1149 # push the merge branch.
1150 remote, branch = cl.FetchUpstreamTuple()
1151 retcode, output = RunGitWithCode(
1152 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1153 logging.debug(output)
1154 else:
1155 # dcommit the merge branch.
bauerb@chromium.org2e64fa12011-05-05 11:13:44 +00001156 retcode, output = RunGitWithCode(['svn', 'dcommit',
1157 '--no-rebase', '--rmdir'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001158 finally:
1159 # And then swap back to the original branch and clean up.
1160 RunGit(['checkout', '-q', cl.GetBranch()])
1161 RunGit(['branch', '-D', MERGE_BRANCH])
1162
1163 if cl.GetIssue():
1164 if cmd == 'dcommit' and 'Committed r' in output:
1165 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1166 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001167 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1168 for l in output.splitlines(False))
1169 match = filter(None, match)
1170 if len(match) != 1:
1171 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1172 output)
1173 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001174 else:
1175 return 1
1176 viewvc_url = settings.GetViewVCUrl()
1177 if viewvc_url and revision:
1178 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1179 print ('Closing issue '
1180 '(you may be prompted for your codereview password)...')
1181 cl.CloseIssue()
1182 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001183
1184 if retcode == 0:
1185 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1186 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001187 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001188
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001189 return 0
1190
1191
1192@usage('[upstream branch to apply against]')
1193def CMDdcommit(parser, args):
1194 """commit the current changelist via git-svn"""
1195 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001196 message = """This doesn't appear to be an SVN repository.
1197If your project has a git mirror with an upstream SVN master, you probably need
1198to run 'git svn init', see your project's git mirror documentation.
1199If your project has a true writeable upstream repository, you probably want
1200to run 'git cl push' instead.
1201Choose wisely, if you get this wrong, your commit might appear to succeed but
1202will instead be silently ignored."""
1203 print(message)
maruel@chromium.org90541732011-04-01 17:54:18 +00001204 ask_for_data('[Press enter to dcommit or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001205 return SendUpstream(parser, args, 'dcommit')
1206
1207
1208@usage('[upstream branch to apply against]')
1209def CMDpush(parser, args):
1210 """commit the current changelist via git"""
1211 if settings.GetIsGitSvn():
1212 print('This appears to be an SVN repository.')
1213 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
maruel@chromium.org90541732011-04-01 17:54:18 +00001214 ask_for_data('[Press enter to push or ctrl-C to quit]')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001215 return SendUpstream(parser, args, 'push')
1216
1217
1218@usage('<patch url or issue id>')
1219def CMDpatch(parser, args):
1220 """patch in a code review"""
1221 parser.add_option('-b', dest='newbranch',
1222 help='create a new branch off trunk for the patch')
1223 parser.add_option('-f', action='store_true', dest='force',
1224 help='with -b, clobber any existing branch')
1225 parser.add_option('--reject', action='store_true', dest='reject',
1226 help='allow failed patches and spew .rej files')
1227 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1228 help="don't commit after patch applies")
1229 (options, args) = parser.parse_args(args)
1230 if len(args) != 1:
1231 parser.print_help()
1232 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001233 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001234
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001235 # TODO(maruel): Use apply_issue.py
1236
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001237 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001238 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001239 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001240 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001241 else:
1242 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001243 issue_url = FixUrl(issue_arg)
1244 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001245 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001246 DieWithError('Must pass an issue ID or full URL for '
1247 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001248 issue = match.group(1)
1249 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001250
1251 if options.newbranch:
1252 if options.force:
1253 RunGit(['branch', '-D', options.newbranch],
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +00001254 stderr=subprocess2.PIPE, error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001255 RunGit(['checkout', '-b', options.newbranch,
1256 Changelist().GetUpstreamBranch()])
1257
1258 # Switch up to the top-level directory, if necessary, in preparation for
1259 # applying the patch.
1260 top = RunGit(['rev-parse', '--show-cdup']).strip()
1261 if top:
1262 os.chdir(top)
1263
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001264 # Git patches have a/ at the beginning of source paths. We strip that out
1265 # with a sed script rather than the -p flag to patch so we can feed either
1266 # Git or svn-style patches into the same apply command.
1267 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +00001268 try:
1269 patch_data = subprocess2.check_output(
1270 ['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'], stdin=patch_data)
1271 except subprocess2.CalledProcessError:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001272 DieWithError('Git patch mungling failed.')
1273 logging.info(patch_data)
1274 # We use "git apply" to apply the patch instead of "patch" so that we can
1275 # pick up file adds.
1276 # The --index flag means: also insert into the index (so we catch adds).
1277 cmd = ['git', 'apply', '--index', '-p0']
1278 if options.reject:
1279 cmd.append('--reject')
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +00001280 try:
1281 subprocess2.check_call(cmd, stdin=patch_data, stdout=subprocess2.VOID)
1282 except subprocess2.CalledProcessError:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001283 DieWithError('Failed to apply the patch')
1284
1285 # If we had an issue, commit the current state and register the issue.
1286 if not options.nocommit:
1287 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1288 cl = Changelist()
1289 cl.SetIssue(issue)
1290 print "Committed patch."
1291 else:
1292 print "Patch applied to index."
1293 return 0
1294
1295
1296def CMDrebase(parser, args):
1297 """rebase current branch on top of svn repo"""
1298 # Provide a wrapper for git svn rebase to help avoid accidental
1299 # git svn dcommit.
1300 # It's the only command that doesn't use parser at all since we just defer
1301 # execution to git-svn.
maruel@chromium.org32f9f5e2011-09-14 13:41:47 +00001302 subprocess2.check_call(['git', 'svn', 'rebase'] + args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001303 return 0
1304
1305
1306def GetTreeStatus():
1307 """Fetches the tree status and returns either 'open', 'closed',
1308 'unknown' or 'unset'."""
1309 url = settings.GetTreeStatusUrl(error_ok=True)
1310 if url:
1311 status = urllib2.urlopen(url).read().lower()
1312 if status.find('closed') != -1 or status == '0':
1313 return 'closed'
1314 elif status.find('open') != -1 or status == '1':
1315 return 'open'
1316 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001317 return 'unset'
1318
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001319
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001320def GetTreeStatusReason():
1321 """Fetches the tree status from a json url and returns the message
1322 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001323 url = settings.GetTreeStatusUrl()
1324 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001325 connection = urllib2.urlopen(json_url)
1326 status = json.loads(connection.read())
1327 connection.close()
1328 return status['message']
1329
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001330
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001331def CMDtree(parser, args):
1332 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001333 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001334 status = GetTreeStatus()
1335 if 'unset' == status:
1336 print 'You must configure your tree status URL by running "git cl config".'
1337 return 2
1338
1339 print "The tree is %s" % status
1340 print
1341 print GetTreeStatusReason()
1342 if status != 'open':
1343 return 1
1344 return 0
1345
1346
1347def CMDupstream(parser, args):
1348 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001349 _, args = parser.parse_args(args)
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001350 if args:
1351 parser.error('Unrecognized args: %s' % ' '.join(args))
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001352 cl = Changelist()
1353 print cl.GetUpstreamBranch()
1354 return 0
1355
1356
maruel@chromium.org27bb3872011-05-30 20:33:19 +00001357def CMDset_commit(parser, args):
1358 """set the commit bit"""
1359 _, args = parser.parse_args(args)
1360 if args:
1361 parser.error('Unrecognized args: %s' % ' '.join(args))
1362 cl = Changelist()
1363 cl.SetFlag('commit', '1')
1364 return 0
1365
1366
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001367def Command(name):
1368 return getattr(sys.modules[__name__], 'CMD' + name, None)
1369
1370
1371def CMDhelp(parser, args):
1372 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001373 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001374 if len(args) == 1:
1375 return main(args + ['--help'])
1376 parser.print_help()
1377 return 0
1378
1379
1380def GenUsage(parser, command):
1381 """Modify an OptParse object with the function's documentation."""
1382 obj = Command(command)
1383 more = getattr(obj, 'usage_more', '')
1384 if command == 'help':
1385 command = '<command>'
1386 else:
1387 # OptParser.description prefer nicely non-formatted strings.
1388 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1389 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1390
1391
1392def main(argv):
1393 """Doesn't parse the arguments here, just find the right subcommand to
1394 execute."""
1395 # Do it late so all commands are listed.
1396 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1397 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1398 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1399
1400 # Create the option parse and add --verbose support.
1401 parser = optparse.OptionParser()
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001402 parser.add_option(
1403 '-v', '--verbose', action='count', default=0,
1404 help='Use 2 times for more debugging info')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001405 old_parser_args = parser.parse_args
1406 def Parse(args):
1407 options, args = old_parser_args(args)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001408 if options.verbose >= 2:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001409 logging.basicConfig(level=logging.DEBUG)
maruel@chromium.org899e1c12011-04-07 17:03:18 +00001410 elif options.verbose:
1411 logging.basicConfig(level=logging.INFO)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001412 else:
1413 logging.basicConfig(level=logging.WARNING)
1414 return options, args
1415 parser.parse_args = Parse
1416
1417 if argv:
1418 command = Command(argv[0])
1419 if command:
1420 # "fix" the usage and the description now that we know the subcommand.
1421 GenUsage(parser, argv[0])
1422 try:
1423 return command(parser, argv[1:])
1424 except urllib2.HTTPError, e:
1425 if e.code != 500:
1426 raise
1427 DieWithError(
1428 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1429 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1430
1431 # Not a known command. Default to help.
1432 GenUsage(parser, 'help')
1433 return CMDhelp(parser, argv)
1434
1435
1436if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001437 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001438 sys.exit(main(sys.argv[1:]))