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