blob: 53258c6754788c6fc8448e371e8b8d0110c7e001 [file] [log] [blame]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001#!/usr/bin/python
2# git-cl -- a git-command for integrating reviews on Rietveld
3# Copyright (C) 2008 Evan Martin <martine@danga.com>
4
5import errno
6import logging
7import optparse
8import os
9import re
10import subprocess
11import sys
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000012import tempfile
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000013import textwrap
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +000014import urlparse
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000015import urllib2
16
17try:
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +000018 import readline # pylint: disable=W0611
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000019except ImportError:
20 pass
21
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000022try:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000023 import simplejson as json # pylint: disable=F0401
dpranke@chromium.org20254fc2011-03-22 18:28:59 +000024except ImportError:
maruel@chromium.org2a74d372011-03-29 19:05:50 +000025 try:
26 import json
27 except ImportError:
28 # Fall back to the packaged version.
maruel@chromium.orgb35c00c2011-03-31 00:43:35 +000029 sys.path.append(os.path.join(os.path.dirname(__file__), 'third_party'))
30 import simplejson as json
maruel@chromium.org2a74d372011-03-29 19:05:50 +000031
32
33from third_party import upload
34import breakpad # pylint: disable=W0611
maruel@chromium.org6f09cd92011-04-01 16:38:12 +000035import fix_encoding
maruel@chromium.org2a74d372011-03-29 19:05:50 +000036import presubmit_support
37import scm
38import watchlists
39
40
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000041
42DEFAULT_SERVER = 'http://codereview.appspot.com'
maruel@chromium.org0ba7f962011-01-11 22:13:58 +000043POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000044DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup'
45
46def DieWithError(message):
dpranke@chromium.org970c5222011-03-12 00:32:24 +000047 print >> sys.stderr, message
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000048 sys.exit(1)
49
50
51def Popen(cmd, **kwargs):
52 """Wrapper for subprocess.Popen() that logs and watch for cygwin issues"""
53 logging.info('Popen: ' + ' '.join(cmd))
54 try:
55 return subprocess.Popen(cmd, **kwargs)
56 except OSError, e:
57 if e.errno == errno.EAGAIN and sys.platform == 'cygwin':
58 DieWithError(
59 'Visit '
60 'http://code.google.com/p/chromium/wiki/CygwinDllRemappingFailure to '
61 'learn how to fix this error; you need to rebase your cygwin dlls')
62 raise
63
64
65def RunCommand(cmd, error_ok=False, error_message=None,
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000066 redirect_stdout=True, swallow_stderr=False, **kwargs):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000067 if redirect_stdout:
68 stdout = subprocess.PIPE
69 else:
70 stdout = None
71 if swallow_stderr:
72 stderr = subprocess.PIPE
73 else:
74 stderr = None
maruel@chromium.orgb92e4802011-03-03 20:22:00 +000075 proc = Popen(cmd, stdout=stdout, stderr=stderr, **kwargs)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +000076 output = proc.communicate()[0]
77 if not error_ok and proc.returncode != 0:
78 DieWithError('Command "%s" failed.\n' % (' '.join(cmd)) +
79 (error_message or output or ''))
80 return output
81
82
83def RunGit(args, **kwargs):
84 cmd = ['git'] + args
85 return RunCommand(cmd, **kwargs)
86
87
88def RunGitWithCode(args):
89 proc = Popen(['git'] + args, stdout=subprocess.PIPE)
90 output = proc.communicate()[0]
91 return proc.returncode, output
92
93
94def usage(more):
95 def hook(fn):
96 fn.usage_more = more
97 return fn
98 return hook
99
100
101def FixUrl(server):
102 """Fix a server url to defaults protocol to http:// if none is specified."""
103 if not server:
104 return server
105 if not re.match(r'[a-z]+\://.*', server):
106 return 'http://' + server
107 return server
108
109
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000110def MatchSvnGlob(url, base_url, glob_spec, allow_wildcards):
111 """Return the corresponding git ref if |base_url| together with |glob_spec|
112 matches the full |url|.
113
114 If |allow_wildcards| is true, |glob_spec| can contain wildcards (see below).
115 """
116 fetch_suburl, as_ref = glob_spec.split(':')
117 if allow_wildcards:
118 glob_match = re.match('(.+/)?(\*|{[^/]*})(/.+)?', fetch_suburl)
119 if glob_match:
120 # Parse specs like "branches/*/src:refs/remotes/svn/*" or
121 # "branches/{472,597,648}/src:refs/remotes/svn/*".
122 branch_re = re.escape(base_url)
123 if glob_match.group(1):
124 branch_re += '/' + re.escape(glob_match.group(1))
125 wildcard = glob_match.group(2)
126 if wildcard == '*':
127 branch_re += '([^/]*)'
128 else:
129 # Escape and replace surrounding braces with parentheses and commas
130 # with pipe symbols.
131 wildcard = re.escape(wildcard)
132 wildcard = re.sub('^\\\\{', '(', wildcard)
133 wildcard = re.sub('\\\\,', '|', wildcard)
134 wildcard = re.sub('\\\\}$', ')', wildcard)
135 branch_re += wildcard
136 if glob_match.group(3):
137 branch_re += re.escape(glob_match.group(3))
138 match = re.match(branch_re, url)
139 if match:
140 return re.sub('\*$', match.group(1), as_ref)
141
142 # Parse specs like "trunk/src:refs/remotes/origin/trunk".
143 if fetch_suburl:
144 full_url = base_url + '/' + fetch_suburl
145 else:
146 full_url = base_url
147 if full_url == url:
148 return as_ref
149 return None
150
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000151class Settings(object):
152 def __init__(self):
153 self.default_server = None
154 self.cc = None
155 self.root = None
156 self.is_git_svn = None
157 self.svn_branch = None
158 self.tree_status_url = None
159 self.viewvc_url = None
160 self.updated = False
161
162 def LazyUpdateIfNeeded(self):
163 """Updates the settings from a codereview.settings file, if available."""
164 if not self.updated:
165 cr_settings_file = FindCodereviewSettingsFile()
166 if cr_settings_file:
167 LoadCodereviewSettingsFromFile(cr_settings_file)
168 self.updated = True
169
170 def GetDefaultServerUrl(self, error_ok=False):
171 if not self.default_server:
172 self.LazyUpdateIfNeeded()
173 self.default_server = FixUrl(self._GetConfig('rietveld.server',
174 error_ok=True))
175 if error_ok:
176 return self.default_server
177 if not self.default_server:
178 error_message = ('Could not find settings file. You must configure '
179 'your review setup by running "git cl config".')
180 self.default_server = FixUrl(self._GetConfig(
181 'rietveld.server', error_message=error_message))
182 return self.default_server
183
184 def GetCCList(self):
185 """Return the users cc'd on this CL.
186
187 Return is a string suitable for passing to gcl with the --cc flag.
188 """
189 if self.cc is None:
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000190 base_cc = self._GetConfig('rietveld.cc', error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000191 more_cc = self._GetConfig('rietveld.extracc', error_ok=True)
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000192 self.cc = ','.join(filter(None, (base_cc, more_cc))) or ''
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000193 return self.cc
194
195 def GetRoot(self):
196 if not self.root:
197 self.root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
198 return self.root
199
200 def GetIsGitSvn(self):
201 """Return true if this repo looks like it's using git-svn."""
202 if self.is_git_svn is None:
203 # If you have any "svn-remote.*" config keys, we think you're using svn.
204 self.is_git_svn = RunGitWithCode(
205 ['config', '--get-regexp', r'^svn-remote\.'])[0] == 0
206 return self.is_git_svn
207
208 def GetSVNBranch(self):
209 if self.svn_branch is None:
210 if not self.GetIsGitSvn():
211 DieWithError('Repo doesn\'t appear to be a git-svn repo.')
212
213 # Try to figure out which remote branch we're based on.
214 # Strategy:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000215 # 1) iterate through our branch history and find the svn URL.
216 # 2) find the svn-remote that fetches from the URL.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000217
218 # regexp matching the git-svn line that contains the URL.
219 git_svn_re = re.compile(r'^\s*git-svn-id: (\S+)@', re.MULTILINE)
220
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000221 # We don't want to go through all of history, so read a line from the
222 # pipe at a time.
223 # The -100 is an arbitrary limit so we don't search forever.
224 cmd = ['git', 'log', '-100', '--pretty=medium']
225 proc = Popen(cmd, stdout=subprocess.PIPE)
226 for line in proc.stdout:
227 match = git_svn_re.match(line)
228 if match:
229 url = match.group(1)
230 proc.stdout.close() # Cut pipe.
231 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000232
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000233 if url:
234 svn_remote_re = re.compile(r'^svn-remote\.([^.]+)\.url (.*)$')
235 remotes = RunGit(['config', '--get-regexp',
236 r'^svn-remote\..*\.url']).splitlines()
237 for remote in remotes:
238 match = svn_remote_re.match(remote)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000239 if match:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000240 remote = match.group(1)
241 base_url = match.group(2)
242 fetch_spec = RunGit(
bauerb@chromium.org866276c2011-03-18 20:09:31 +0000243 ['config', 'svn-remote.%s.fetch' % remote],
244 error_ok=True).strip()
245 if fetch_spec:
246 self.svn_branch = MatchSvnGlob(url, base_url, fetch_spec, False)
247 if self.svn_branch:
248 break
249 branch_spec = RunGit(
250 ['config', 'svn-remote.%s.branches' % remote],
251 error_ok=True).strip()
252 if branch_spec:
253 self.svn_branch = MatchSvnGlob(url, base_url, branch_spec, True)
254 if self.svn_branch:
255 break
256 tag_spec = RunGit(
257 ['config', 'svn-remote.%s.tags' % remote],
258 error_ok=True).strip()
259 if tag_spec:
260 self.svn_branch = MatchSvnGlob(url, base_url, tag_spec, True)
261 if self.svn_branch:
262 break
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000263
264 if not self.svn_branch:
265 DieWithError('Can\'t guess svn branch -- try specifying it on the '
266 'command line')
267
268 return self.svn_branch
269
270 def GetTreeStatusUrl(self, error_ok=False):
271 if not self.tree_status_url:
272 error_message = ('You must configure your tree status URL by running '
273 '"git cl config".')
274 self.tree_status_url = self._GetConfig('rietveld.tree-status-url',
275 error_ok=error_ok,
276 error_message=error_message)
277 return self.tree_status_url
278
279 def GetViewVCUrl(self):
280 if not self.viewvc_url:
281 self.viewvc_url = self._GetConfig('rietveld.viewvc-url', error_ok=True)
282 return self.viewvc_url
283
284 def _GetConfig(self, param, **kwargs):
285 self.LazyUpdateIfNeeded()
286 return RunGit(['config', param], **kwargs).strip()
287
288
289settings = Settings()
290
291
292did_migrate_check = False
293def CheckForMigration():
294 """Migrate from the old issue format, if found.
295
296 We used to store the branch<->issue mapping in a file in .git, but it's
297 better to store it in the .git/config, since deleting a branch deletes that
298 branch's entry there.
299 """
300
301 # Don't run more than once.
302 global did_migrate_check
303 if did_migrate_check:
304 return
305
306 gitdir = RunGit(['rev-parse', '--git-dir']).strip()
307 storepath = os.path.join(gitdir, 'cl-mapping')
308 if os.path.exists(storepath):
309 print "old-style git-cl mapping file (%s) found; migrating." % storepath
310 store = open(storepath, 'r')
311 for line in store:
312 branch, issue = line.strip().split()
313 RunGit(['config', 'branch.%s.rietveldissue' % ShortBranchName(branch),
314 issue])
315 store.close()
316 os.remove(storepath)
317 did_migrate_check = True
318
319
320def ShortBranchName(branch):
321 """Convert a name like 'refs/heads/foo' to just 'foo'."""
322 return branch.replace('refs/heads/', '')
323
324
325class Changelist(object):
326 def __init__(self, branchref=None):
327 # Poke settings so we get the "configure your server" message if necessary.
328 settings.GetDefaultServerUrl()
329 self.branchref = branchref
330 if self.branchref:
331 self.branch = ShortBranchName(self.branchref)
332 else:
333 self.branch = None
334 self.rietveld_server = None
335 self.upstream_branch = None
336 self.has_issue = False
337 self.issue = None
338 self.has_description = False
339 self.description = None
340 self.has_patchset = False
341 self.patchset = None
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000342 self._rpc_server = None
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000343
344 def GetBranch(self):
345 """Returns the short branch name, e.g. 'master'."""
346 if not self.branch:
347 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip()
348 self.branch = ShortBranchName(self.branchref)
349 return self.branch
350
351 def GetBranchRef(self):
352 """Returns the full branch name, e.g. 'refs/heads/master'."""
353 self.GetBranch() # Poke the lazy loader.
354 return self.branchref
355
356 def FetchUpstreamTuple(self):
357 """Returns a tuple containg remote and remote ref,
358 e.g. 'origin', 'refs/heads/master'
359 """
360 remote = '.'
361 branch = self.GetBranch()
362 upstream_branch = RunGit(['config', 'branch.%s.merge' % branch],
363 error_ok=True).strip()
364 if upstream_branch:
365 remote = RunGit(['config', 'branch.%s.remote' % branch]).strip()
366 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000367 upstream_branch = RunGit(['config', 'rietveld.upstream-branch'],
368 error_ok=True).strip()
369 if upstream_branch:
370 remote = RunGit(['config', 'rietveld.upstream-remote']).strip()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000371 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000372 # Fall back on trying a git-svn upstream branch.
373 if settings.GetIsGitSvn():
374 upstream_branch = settings.GetSVNBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000375 else:
bauerb@chromium.orgade368c2011-03-01 08:57:50 +0000376 # Else, try to guess the origin remote.
377 remote_branches = RunGit(['branch', '-r']).split()
378 if 'origin/master' in remote_branches:
379 # Fall back on origin/master if it exits.
380 remote = 'origin'
381 upstream_branch = 'refs/heads/master'
382 elif 'origin/trunk' in remote_branches:
383 # Fall back on origin/trunk if it exists. Generally a shared
384 # git-svn clone
385 remote = 'origin'
386 upstream_branch = 'refs/heads/trunk'
387 else:
388 DieWithError("""Unable to determine default branch to diff against.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000389Either pass complete "git diff"-style arguments, like
390 git cl upload origin/master
391or verify this branch is set up to track another (via the --track argument to
392"git checkout -b ...").""")
393
394 return remote, upstream_branch
395
396 def GetUpstreamBranch(self):
397 if self.upstream_branch is None:
398 remote, upstream_branch = self.FetchUpstreamTuple()
399 if remote is not '.':
400 upstream_branch = upstream_branch.replace('heads', 'remotes/' + remote)
401 self.upstream_branch = upstream_branch
402 return self.upstream_branch
403
404 def GetRemoteUrl(self):
405 """Return the configured remote URL, e.g. 'git://example.org/foo.git/'.
406
407 Returns None if there is no remote.
408 """
409 remote = self.FetchUpstreamTuple()[0]
410 if remote == '.':
411 return None
412 return RunGit(['config', 'remote.%s.url' % remote], error_ok=True).strip()
413
414 def GetIssue(self):
415 if not self.has_issue:
416 CheckForMigration()
417 issue = RunGit(['config', self._IssueSetting()], error_ok=True).strip()
418 if issue:
419 self.issue = issue
420 self.rietveld_server = FixUrl(RunGit(
421 ['config', self._RietveldServer()], error_ok=True).strip())
422 else:
423 self.issue = None
424 if not self.rietveld_server:
425 self.rietveld_server = settings.GetDefaultServerUrl()
426 self.has_issue = True
427 return self.issue
428
429 def GetRietveldServer(self):
430 self.GetIssue()
431 return self.rietveld_server
432
433 def GetIssueURL(self):
434 """Get the URL for a particular issue."""
435 return '%s/%s' % (self.GetRietveldServer(), self.GetIssue())
436
437 def GetDescription(self, pretty=False):
438 if not self.has_description:
439 if self.GetIssue():
440 path = '/' + self.GetIssue() + '/description'
441 rpc_server = self._RpcServer()
442 self.description = rpc_server.Send(path).strip()
443 self.has_description = True
444 if pretty:
445 wrapper = textwrap.TextWrapper()
446 wrapper.initial_indent = wrapper.subsequent_indent = ' '
447 return wrapper.fill(self.description)
448 return self.description
449
450 def GetPatchset(self):
451 if not self.has_patchset:
452 patchset = RunGit(['config', self._PatchsetSetting()],
453 error_ok=True).strip()
454 if patchset:
455 self.patchset = patchset
456 else:
457 self.patchset = None
458 self.has_patchset = True
459 return self.patchset
460
461 def SetPatchset(self, patchset):
462 """Set this branch's patchset. If patchset=0, clears the patchset."""
463 if patchset:
464 RunGit(['config', self._PatchsetSetting(), str(patchset)])
465 else:
466 RunGit(['config', '--unset', self._PatchsetSetting()],
467 swallow_stderr=True, error_ok=True)
468 self.has_patchset = False
469
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000470 def GetPatchSetDiff(self, issue):
471 # Grab the last patchset of the issue first.
472 data = json.loads(self._RpcServer().Send('/api/%s' % issue))
473 patchset = data['patchsets'][-1]
474 return self._RpcServer().Send(
475 '/download/issue%s_%s.diff' % (issue, patchset))
476
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000477 def SetIssue(self, issue):
478 """Set this branch's issue. If issue=0, clears the issue."""
479 if issue:
480 RunGit(['config', self._IssueSetting(), str(issue)])
481 if self.rietveld_server:
482 RunGit(['config', self._RietveldServer(), self.rietveld_server])
483 else:
484 RunGit(['config', '--unset', self._IssueSetting()])
485 self.SetPatchset(0)
486 self.has_issue = False
487
488 def CloseIssue(self):
489 rpc_server = self._RpcServer()
490 # Newer versions of Rietveld require us to pass an XSRF token to POST, so
491 # we fetch it from the server. (The version used by Chromium has been
492 # modified so the token isn't required when closing an issue.)
493 xsrf_token = rpc_server.Send('/xsrf_token',
494 extra_headers={'X-Requesting-XSRF-Token': '1'})
495
496 # You cannot close an issue with a GET.
497 # We pass an empty string for the data so it is a POST rather than a GET.
498 data = [("description", self.description),
499 ("xsrf_token", xsrf_token)]
500 ctype, body = upload.EncodeMultipartFormData(data, [])
501 rpc_server.Send('/' + self.GetIssue() + '/close', body, ctype)
502
503 def _RpcServer(self):
504 """Returns an upload.RpcServer() to access this review's rietveld instance.
505 """
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +0000506 if not self._rpc_server:
507 server = self.GetRietveldServer()
508 self._rpc_server = upload.GetRpcServer(server, save_cookies=True)
509 return self._rpc_server
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000510
511 def _IssueSetting(self):
512 """Return the git setting that stores this change's issue."""
513 return 'branch.%s.rietveldissue' % self.GetBranch()
514
515 def _PatchsetSetting(self):
516 """Return the git setting that stores this change's most recent patchset."""
517 return 'branch.%s.rietveldpatchset' % self.GetBranch()
518
519 def _RietveldServer(self):
520 """Returns the git setting that stores this change's rietveld server."""
521 return 'branch.%s.rietveldserver' % self.GetBranch()
522
523
524def GetCodereviewSettingsInteractively():
525 """Prompt the user for settings."""
526 server = settings.GetDefaultServerUrl(error_ok=True)
527 prompt = 'Rietveld server (host[:port])'
528 prompt += ' [%s]' % (server or DEFAULT_SERVER)
529 newserver = raw_input(prompt + ': ')
530 if not server and not newserver:
531 newserver = DEFAULT_SERVER
532 if newserver and newserver != server:
533 RunGit(['config', 'rietveld.server', newserver])
534
535 def SetProperty(initial, caption, name):
536 prompt = caption
537 if initial:
538 prompt += ' ("x" to clear) [%s]' % initial
539 new_val = raw_input(prompt + ': ')
540 if new_val == 'x':
541 RunGit(['config', '--unset-all', 'rietveld.' + name], error_ok=True)
542 elif new_val and new_val != initial:
543 RunGit(['config', 'rietveld.' + name, new_val])
544
545 SetProperty(settings.GetCCList(), 'CC list', 'cc')
546 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL',
547 'tree-status-url')
548 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url')
549
550 # TODO: configure a default branch to diff against, rather than this
551 # svn-based hackery.
552
553
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000554class ChangeDescription(object):
555 """Contains a parsed form of the change description."""
556 def __init__(self, subject, log_desc, reviewers):
557 self.subject = subject
558 self.log_desc = log_desc
559 self.reviewers = reviewers
560 self.description = self.log_desc
561
562 def Update(self):
563 initial_text = """# Enter a description of the change.
564# This will displayed on the codereview site.
565# The first line will also be used as the subject of the review.
566"""
567 initial_text += self.description
568 if 'R=' not in self.description and self.reviewers:
569 initial_text += '\nR=' + self.reviewers
570 if 'BUG=' not in self.description:
571 initial_text += '\nBUG='
572 if 'TEST=' not in self.description:
573 initial_text += '\nTEST='
574 self._ParseDescription(UserEditedLog(initial_text))
575
576 def _ParseDescription(self, description):
577 if not description:
578 self.description = description
579 return
580
581 parsed_lines = []
582 reviewers_regexp = re.compile('\s*R=(.+)')
583 reviewers = ''
584 subject = ''
585 for l in description.splitlines():
586 if not subject:
587 subject = l
588 matched_reviewers = reviewers_regexp.match(l)
589 if matched_reviewers:
590 reviewers = matched_reviewers.group(1)
591 parsed_lines.append(l)
592
593 self.description = '\n'.join(parsed_lines) + '\n'
594 self.subject = subject
595 self.reviewers = reviewers
596
597 def IsEmpty(self):
598 return not self.description
599
600
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000601def FindCodereviewSettingsFile(filename='codereview.settings'):
602 """Finds the given file starting in the cwd and going up.
603
604 Only looks up to the top of the repository unless an
605 'inherit-review-settings-ok' file exists in the root of the repository.
606 """
607 inherit_ok_file = 'inherit-review-settings-ok'
608 cwd = os.getcwd()
609 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip())
610 if os.path.isfile(os.path.join(root, inherit_ok_file)):
611 root = '/'
612 while True:
613 if filename in os.listdir(cwd):
614 if os.path.isfile(os.path.join(cwd, filename)):
615 return open(os.path.join(cwd, filename))
616 if cwd == root:
617 break
618 cwd = os.path.dirname(cwd)
619
620
621def LoadCodereviewSettingsFromFile(fileobj):
622 """Parse a codereview.settings file and updates hooks."""
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000623 keyvals = {}
624 for line in fileobj.read().splitlines():
625 if not line or line.startswith("#"):
626 continue
627 k, v = line.split(": ", 1)
628 keyvals[k] = v
629
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000630 def SetProperty(name, setting, unset_error_ok=False):
631 fullname = 'rietveld.' + name
632 if setting in keyvals:
633 RunGit(['config', fullname, keyvals[setting]])
634 else:
635 RunGit(['config', '--unset-all', fullname], error_ok=unset_error_ok)
636
637 SetProperty('server', 'CODE_REVIEW_SERVER')
638 # Only server setting is required. Other settings can be absent.
639 # In that case, we ignore errors raised during option deletion attempt.
640 SetProperty('cc', 'CC_LIST', unset_error_ok=True)
641 SetProperty('tree-status-url', 'STATUS', unset_error_ok=True)
642 SetProperty('viewvc-url', 'VIEW_VC', unset_error_ok=True)
643
644 if 'PUSH_URL_CONFIG' in keyvals and 'ORIGIN_URL_CONFIG' in keyvals:
645 #should be of the form
646 #PUSH_URL_CONFIG: url.ssh://gitrw.chromium.org.pushinsteadof
647 #ORIGIN_URL_CONFIG: http://src.chromium.org/git
648 RunGit(['config', keyvals['PUSH_URL_CONFIG'],
649 keyvals['ORIGIN_URL_CONFIG']])
650
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000651
652@usage('[repo root containing codereview.settings]')
653def CMDconfig(parser, args):
654 """edit configuration for this tree"""
655
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000656 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000657 if len(args) == 0:
658 GetCodereviewSettingsInteractively()
659 return 0
660
661 url = args[0]
662 if not url.endswith('codereview.settings'):
663 url = os.path.join(url, 'codereview.settings')
664
665 # Load code review settings and download hooks (if available).
666 LoadCodereviewSettingsFromFile(urllib2.urlopen(url))
667 return 0
668
669
670def CMDstatus(parser, args):
671 """show status of changelists"""
672 parser.add_option('--field',
673 help='print only specific field (desc|id|patch|url)')
674 (options, args) = parser.parse_args(args)
675
676 # TODO: maybe make show_branches a flag if necessary.
677 show_branches = not options.field
678
679 if show_branches:
680 branches = RunGit(['for-each-ref', '--format=%(refname)', 'refs/heads'])
681 if branches:
682 print 'Branches associated with reviews:'
683 for branch in sorted(branches.splitlines()):
684 cl = Changelist(branchref=branch)
685 print " %10s: %s" % (cl.GetBranch(), cl.GetIssue())
686
687 cl = Changelist()
688 if options.field:
689 if options.field.startswith('desc'):
690 print cl.GetDescription()
691 elif options.field == 'id':
692 issueid = cl.GetIssue()
693 if issueid:
694 print issueid
695 elif options.field == 'patch':
696 patchset = cl.GetPatchset()
697 if patchset:
698 print patchset
699 elif options.field == 'url':
700 url = cl.GetIssueURL()
701 if url:
702 print url
703 else:
704 print
705 print 'Current branch:',
706 if not cl.GetIssue():
707 print 'no issue assigned.'
708 return 0
709 print cl.GetBranch()
710 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
711 print 'Issue description:'
712 print cl.GetDescription(pretty=True)
713 return 0
714
715
716@usage('[issue_number]')
717def CMDissue(parser, args):
718 """Set or display the current code review issue number.
719
720 Pass issue number 0 to clear the current issue.
721"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +0000722 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000723
724 cl = Changelist()
725 if len(args) > 0:
726 try:
727 issue = int(args[0])
728 except ValueError:
729 DieWithError('Pass a number to set the issue or none to list it.\n'
730 'Maybe you want to run git cl status?')
731 cl.SetIssue(issue)
732 print 'Issue number:', cl.GetIssue(), '(%s)' % cl.GetIssueURL()
733 return 0
734
735
736def CreateDescriptionFromLog(args):
737 """Pulls out the commit log to use as a base for the CL description."""
738 log_args = []
739 if len(args) == 1 and not args[0].endswith('.'):
740 log_args = [args[0] + '..']
741 elif len(args) == 1 and args[0].endswith('...'):
742 log_args = [args[0][:-1]]
743 elif len(args) == 2:
744 log_args = [args[0] + '..' + args[1]]
745 else:
746 log_args = args[:] # Hope for the best!
747 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args)
748
749
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000750def UserEditedLog(starting_text):
751 """Given some starting text, let the user edit it and return the result."""
752 editor = os.getenv('EDITOR', 'vi')
753
754 (file_handle, filename) = tempfile.mkstemp()
755 fileobj = os.fdopen(file_handle, 'w')
756 fileobj.write(starting_text)
757 fileobj.close()
758
759 # Open up the default editor in the system to get the CL description.
760 try:
761 cmd = '%s %s' % (editor, filename)
762 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys':
763 # Msysgit requires the usage of 'env' to be present.
764 cmd = 'env ' + cmd
765 # shell=True to allow the shell to handle all forms of quotes in $EDITOR.
766 subprocess.check_call(cmd, shell=True)
767 fileobj = open(filename)
768 text = fileobj.read()
769 fileobj.close()
770 finally:
771 os.remove(filename)
772
773 if not text:
774 return
775
776 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE)
777 return stripcomment_re.sub('', text).strip()
778
779
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000780def ConvertToInteger(inputval):
781 """Convert a string to integer, but returns either an int or None."""
782 try:
783 return int(inputval)
784 except (TypeError, ValueError):
785 return None
786
787
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000788def RunHook(committing, upstream_branch, rietveld_server, tbr, may_prompt):
789 """Calls sys.exit() if the hook fails; returns a HookResults otherwise."""
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000790 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip()
791 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000792 root = '.'
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000793 absroot = os.path.abspath(root)
794 if not root:
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000795 raise Exception('Could not get root directory.')
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000796
797 # We use the sha1 of HEAD as a name of this change.
798 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip()
799 files = scm.GIT.CaptureStatus([root], upstream_branch)
800
801 cl = Changelist()
802 issue = ConvertToInteger(cl.GetIssue())
803 patchset = ConvertToInteger(cl.GetPatchset())
804 if issue:
805 description = cl.GetDescription()
806 else:
807 # If the change was never uploaded, use the log messages of all commits
808 # up to the branch point, as git cl upload will prefill the description
809 # with these log messages.
810 description = RunCommand(['git', 'log', '--pretty=format:%s%n%n%b',
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000811 '%s...' % (upstream_branch)]).strip()
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000812 change = presubmit_support.GitChange(name, description, absroot, files,
813 issue, patchset)
814
815 # Apply watchlists on upload.
816 if not committing:
817 watchlist = watchlists.Watchlists(change.RepositoryRoot())
818 files = [f.LocalPath() for f in change.AffectedFiles()]
819 watchers = watchlist.GetWatchersForPaths(files)
820 RunCommand(['git', 'config', '--replace-all',
dpranke@chromium.orgdc276cb2011-03-14 21:30:10 +0000821 'rietveld.extracc', ','.join(watchers)])
dpranke@chromium.org23beb9e2011-03-11 23:20:54 +0000822
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000823 output = presubmit_support.DoPresubmitChecks(change, committing,
824 verbose=False, output_stream=sys.stdout, input_stream=sys.stdin,
825 default_presubmit=None, may_prompt=may_prompt, tbr=tbr,
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000826 host_url=cl.GetRietveldServer())
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000827
828 # TODO(dpranke): We should propagate the error out instead of calling exit().
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000829 if not output.should_continue():
830 sys.exit(1)
dpranke@chromium.org37248c02011-03-12 04:36:48 +0000831
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000832 return output
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000833
834
835def CMDpresubmit(parser, args):
836 """run presubmit tests on the current changelist"""
837 parser.add_option('--upload', action='store_true',
838 help='Run upload hook instead of the push/dcommit hook')
839 (options, args) = parser.parse_args(args)
840
841 # Make sure index is up-to-date before running diff-index.
842 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
843 if RunGit(['diff-index', 'HEAD']):
844 # TODO(maruel): Is this really necessary?
845 print 'Cannot presubmit with a dirty tree. You must commit locally first.'
846 return 1
847
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000848 cl = Changelist()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000849 if args:
850 base_branch = args[0]
851 else:
852 # Default to diffing against the "upstream" branch.
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000853 base_branch = cl.GetUpstreamBranch()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000854
dpranke@chromium.org0a2bb372011-03-25 01:16:22 +0000855 RunHook(committing=not options.upload, upstream_branch=base_branch,
856 rietveld_server=cl.GetRietveldServer(), tbr=False,
857 may_prompt=False)
858 return 0
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000859
860
861@usage('[args to "git diff"]')
862def CMDupload(parser, args):
863 """upload the current changelist to codereview"""
864 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
865 help='bypass upload presubmit hook')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000866 parser.add_option('-f', action='store_true', dest='force',
867 help="force yes to questions (don't prompt)")
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000868 parser.add_option('-m', dest='message', help='message for patch')
869 parser.add_option('-r', '--reviewers',
870 help='reviewer email addresses')
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000871 parser.add_option('--cc',
872 help='cc email addresses')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000873 parser.add_option('--send-mail', action='store_true',
874 help='send email to reviewer immediately')
875 parser.add_option("--emulate_svn_auto_props", action="store_true",
876 dest="emulate_svn_auto_props",
877 help="Emulate Subversion's auto properties feature.")
878 parser.add_option("--desc_from_logs", action="store_true",
879 dest="from_logs",
880 help="""Squashes git commit logs into change description and
881 uses message as subject""")
882 (options, args) = parser.parse_args(args)
883
884 # Make sure index is up-to-date before running diff-index.
885 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
886 if RunGit(['diff-index', 'HEAD']):
887 print 'Cannot upload with a dirty tree. You must commit locally first.'
888 return 1
889
890 cl = Changelist()
891 if args:
892 base_branch = args[0]
893 else:
894 # Default to diffing against the "upstream" branch.
895 base_branch = cl.GetUpstreamBranch()
896 args = [base_branch + "..."]
897
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000898 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000899 hook_results = RunHook(committing=False, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000900 rietveld_server=cl.GetRietveldServer(), tbr=False,
dpranke@chromium.org5ac21012011-03-16 02:58:25 +0000901 may_prompt=True)
902 if not options.reviewers and hook_results.reviewers:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000903 options.reviewers = hook_results.reviewers
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000904
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000905
906 # --no-ext-diff is broken in some versions of Git, so try to work around
907 # this by overriding the environment (but there is still a problem if the
908 # git config key "diff.external" is used).
909 env = os.environ.copy()
910 if 'GIT_EXTERNAL_DIFF' in env:
911 del env['GIT_EXTERNAL_DIFF']
912 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args,
913 env=env)
914
915 upload_args = ['--assume_yes'] # Don't ask about untracked files.
916 upload_args.extend(['--server', cl.GetRietveldServer()])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000917 if options.emulate_svn_auto_props:
918 upload_args.append('--emulate_svn_auto_props')
919 if options.send_mail:
920 if not options.reviewers:
921 DieWithError("Must specify reviewers to send email.")
922 upload_args.append('--send_mail')
923 if options.from_logs and not options.message:
924 print 'Must set message for subject line if using desc_from_logs'
925 return 1
926
927 change_desc = None
928
929 if cl.GetIssue():
930 if options.message:
931 upload_args.extend(['--message', options.message])
932 upload_args.extend(['--issue', cl.GetIssue()])
933 print ("This branch is associated with issue %s. "
934 "Adding patch to that issue." % cl.GetIssue())
935 else:
936 log_desc = CreateDescriptionFromLog(args)
dpranke@chromium.org20254fc2011-03-22 18:28:59 +0000937 change_desc = ChangeDescription(options.message, log_desc,
938 options.reviewers)
939 if not options.from_logs:
940 change_desc.Update()
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000941
942 if change_desc.IsEmpty():
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000943 print "Description is empty; aborting."
944 return 1
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000945
946 upload_args.extend(['--message', change_desc.subject])
947 upload_args.extend(['--description', change_desc.description])
948 if change_desc.reviewers:
949 upload_args.extend(['--reviewers', change_desc.reviewers])
maruel@chromium.orgb2a7c332011-02-25 20:30:37 +0000950 cc = ','.join(filter(None, (settings.GetCCList(), options.cc)))
951 if cc:
952 upload_args.extend(['--cc', cc])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000953
954 # Include the upstream repo's URL in the change -- this is useful for
955 # projects that have their source spread across multiple repos.
956 remote_url = None
957 if settings.GetIsGitSvn():
maruel@chromium.orgb92e4802011-03-03 20:22:00 +0000958 # URL is dependent on the current directory.
959 data = RunGit(['svn', 'info'], cwd=settings.GetRoot())
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000960 if data:
961 keys = dict(line.split(': ', 1) for line in data.splitlines()
962 if ': ' in line)
963 remote_url = keys.get('URL', None)
964 else:
965 if cl.GetRemoteUrl() and '/' in cl.GetUpstreamBranch():
966 remote_url = (cl.GetRemoteUrl() + '@'
967 + cl.GetUpstreamBranch().split('/')[-1])
968 if remote_url:
969 upload_args.extend(['--base_url', remote_url])
970
971 try:
972 issue, patchset = upload.RealMain(['upload'] + upload_args + args)
973 except:
974 # If we got an exception after the user typed a description for their
975 # change, back up the description before re-raising.
976 if change_desc:
977 backup_path = os.path.expanduser(DESCRIPTION_BACKUP_FILE)
978 print '\nGot exception while uploading -- saving description to %s\n' \
979 % backup_path
980 backup_file = open(backup_path, 'w')
dpranke@chromium.org970c5222011-03-12 00:32:24 +0000981 backup_file.write(change_desc.description)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +0000982 backup_file.close()
983 raise
984
985 if not cl.GetIssue():
986 cl.SetIssue(issue)
987 cl.SetPatchset(patchset)
988 return 0
989
990
991def SendUpstream(parser, args, cmd):
992 """Common code for CmdPush and CmdDCommit
993
994 Squashed commit into a single.
995 Updates changelog with metadata (e.g. pointer to review).
996 Pushes/dcommits the code upstream.
997 Updates review and closes.
998 """
999 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks',
1000 help='bypass upload presubmit hook')
1001 parser.add_option('-m', dest='message',
1002 help="override review description")
1003 parser.add_option('-f', action='store_true', dest='force',
1004 help="force yes to questions (don't prompt)")
1005 parser.add_option('-c', dest='contributor',
1006 help="external contributor for patch (appended to " +
1007 "description and used as author for git). Should be " +
1008 "formatted as 'First Last <email@example.com>'")
1009 parser.add_option('--tbr', action='store_true', dest='tbr',
1010 help="short for 'to be reviewed', commit branch " +
1011 "even without uploading for review")
1012 (options, args) = parser.parse_args(args)
1013 cl = Changelist()
1014
1015 if not args or cmd == 'push':
1016 # Default to merging against our best guess of the upstream branch.
1017 args = [cl.GetUpstreamBranch()]
1018
1019 base_branch = args[0]
1020
chase@chromium.orgc76e6752011-01-10 18:17:12 +00001021 # Make sure index is up-to-date before running diff-index.
1022 RunGit(['update-index', '--refresh', '-q'], error_ok=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001023 if RunGit(['diff-index', 'HEAD']):
1024 print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
1025 return 1
1026
1027 # This rev-list syntax means "show all commits not in my branch that
1028 # are in base_branch".
1029 upstream_commits = RunGit(['rev-list', '^' + cl.GetBranchRef(),
1030 base_branch]).splitlines()
1031 if upstream_commits:
1032 print ('Base branch "%s" has %d commits '
1033 'not in this branch.' % (base_branch, len(upstream_commits)))
1034 print 'Run "git merge %s" before attempting to %s.' % (base_branch, cmd)
1035 return 1
1036
1037 if cmd == 'dcommit':
1038 # This is the revision `svn dcommit` will commit on top of.
1039 svn_head = RunGit(['log', '--grep=^git-svn-id:', '-1',
1040 '--pretty=format:%H'])
1041 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch])
1042 if extra_commits:
1043 print ('This branch has %d additional commits not upstreamed yet.'
1044 % len(extra_commits.splitlines()))
1045 print ('Upstream "%s" or rebase this branch on top of the upstream trunk '
1046 'before attempting to %s.' % (base_branch, cmd))
1047 return 1
1048
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001049 if not options.bypass_hooks and not options.force:
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001050 RunHook(committing=True, upstream_branch=base_branch,
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001051 rietveld_server=cl.GetRietveldServer(), tbr=options.tbr,
dpranke@chromium.org5ac21012011-03-16 02:58:25 +00001052 may_prompt=True)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001053
1054 if cmd == 'dcommit':
1055 # Check the tree status if the tree status URL is set.
1056 status = GetTreeStatus()
1057 if 'closed' == status:
1058 print ('The tree is closed. Please wait for it to reopen. Use '
1059 '"git cl dcommit -f" to commit on a closed tree.')
1060 return 1
1061 elif 'unknown' == status:
1062 print ('Unable to determine tree status. Please verify manually and '
1063 'use "git cl dcommit -f" to commit on a closed tree.')
1064
1065 description = options.message
1066 if not options.tbr:
1067 # It is important to have these checks early. Not only for user
1068 # convenience, but also because the cl object then caches the correct values
1069 # of these fields even as we're juggling branches for setting up the commit.
1070 if not cl.GetIssue():
1071 print 'Current issue unknown -- has this branch been uploaded?'
1072 print 'Use --tbr to commit without review.'
1073 return 1
1074
1075 if not description:
1076 description = cl.GetDescription()
1077
1078 if not description:
1079 print 'No description set.'
1080 print 'Visit %s/edit to set it.' % (cl.GetIssueURL())
1081 return 1
1082
1083 description += "\n\nReview URL: %s" % cl.GetIssueURL()
1084 else:
1085 if not description:
1086 # Submitting TBR. See if there's already a description in Rietveld, else
1087 # create a template description. Eitherway, give the user a chance to edit
1088 # it to fill in the TBR= field.
1089 if cl.GetIssue():
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001090 description = cl.GetDescription()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001091
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001092 # TODO(dpranke): Update to use ChangeDescription object.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001093 if not description:
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001094 description = """# Enter a description of the change.
1095# This will be used as the change log for the commit.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001096
dpranke@chromium.org20254fc2011-03-22 18:28:59 +00001097"""
1098 description += CreateDescriptionFromLog(args)
1099
1100 description = UserEditedLog(description + '\nTBR=')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001101
1102 if not description:
1103 print "Description empty; aborting."
1104 return 1
1105
1106 if options.contributor:
1107 if not re.match('^.*\s<\S+@\S+>$', options.contributor):
1108 print "Please provide contibutor as 'First Last <email@example.com>'"
1109 return 1
1110 description += "\nPatch from %s." % options.contributor
1111 print 'Description:', repr(description)
1112
1113 branches = [base_branch, cl.GetBranchRef()]
1114 if not options.force:
1115 subprocess.call(['git', 'diff', '--stat'] + branches)
1116 raw_input("About to commit; enter to confirm.")
1117
1118 # We want to squash all this branch's commits into one commit with the
1119 # proper description.
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001120 # We do this by doing a "reset --soft" to the base branch (which keeps
1121 # the working copy the same), then dcommitting that.
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001122 MERGE_BRANCH = 'git-cl-commit'
1123 # Delete the merge branch if it already exists.
1124 if RunGitWithCode(['show-ref', '--quiet', '--verify',
1125 'refs/heads/' + MERGE_BRANCH])[0] == 0:
1126 RunGit(['branch', '-D', MERGE_BRANCH])
1127
1128 # We might be in a directory that's present in this branch but not in the
1129 # trunk. Move up to the top of the tree so that git commands that expect a
1130 # valid CWD won't fail after we check out the merge branch.
1131 rel_base_path = RunGit(['rev-parse', '--show-cdup']).strip()
1132 if rel_base_path:
1133 os.chdir(rel_base_path)
1134
1135 # Stuff our change into the merge branch.
1136 # We wrap in a try...finally block so if anything goes wrong,
1137 # we clean up the branches.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001138 retcode = -1
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001139 try:
bauerb@chromium.orgb4a75c42011-03-08 08:35:38 +00001140 RunGit(['checkout', '-q', '-b', MERGE_BRANCH])
1141 RunGit(['reset', '--soft', base_branch])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001142 if options.contributor:
1143 RunGit(['commit', '--author', options.contributor, '-m', description])
1144 else:
1145 RunGit(['commit', '-m', description])
1146 if cmd == 'push':
1147 # push the merge branch.
1148 remote, branch = cl.FetchUpstreamTuple()
1149 retcode, output = RunGitWithCode(
1150 ['push', '--porcelain', remote, 'HEAD:%s' % branch])
1151 logging.debug(output)
1152 else:
1153 # dcommit the merge branch.
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001154 retcode, output = RunGitWithCode(['svn', 'dcommit', '--no-rebase'])
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001155 finally:
1156 # And then swap back to the original branch and clean up.
1157 RunGit(['checkout', '-q', cl.GetBranch()])
1158 RunGit(['branch', '-D', MERGE_BRANCH])
1159
1160 if cl.GetIssue():
1161 if cmd == 'dcommit' and 'Committed r' in output:
1162 revision = re.match('.*?\nCommitted r(\\d+)', output, re.DOTALL).group(1)
1163 elif cmd == 'push' and retcode == 0:
maruel@chromium.orgdf947ea2011-01-12 20:44:54 +00001164 match = (re.match(r'.*?([a-f0-9]{7})\.\.([a-f0-9]{7})$', l)
1165 for l in output.splitlines(False))
1166 match = filter(None, match)
1167 if len(match) != 1:
1168 DieWithError("Couldn't parse ouput to extract the committed hash:\n%s" %
1169 output)
1170 revision = match[0].group(2)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001171 else:
1172 return 1
1173 viewvc_url = settings.GetViewVCUrl()
1174 if viewvc_url and revision:
1175 cl.description += ('\n\nCommitted: ' + viewvc_url + revision)
1176 print ('Closing issue '
1177 '(you may be prompted for your codereview password)...')
1178 cl.CloseIssue()
1179 cl.SetIssue(0)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001180
1181 if retcode == 0:
1182 hook = POSTUPSTREAM_HOOK_PATTERN % cmd
1183 if os.path.isfile(hook):
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001184 RunCommand([hook, base_branch], error_ok=True)
maruel@chromium.org0ba7f962011-01-11 22:13:58 +00001185
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001186 return 0
1187
1188
1189@usage('[upstream branch to apply against]')
1190def CMDdcommit(parser, args):
1191 """commit the current changelist via git-svn"""
1192 if not settings.GetIsGitSvn():
thakis@chromium.orgcde3bb62011-01-20 01:16:14 +00001193 message = """This doesn't appear to be an SVN repository.
1194If your project has a git mirror with an upstream SVN master, you probably need
1195to run 'git svn init', see your project's git mirror documentation.
1196If your project has a true writeable upstream repository, you probably want
1197to run 'git cl push' instead.
1198Choose wisely, if you get this wrong, your commit might appear to succeed but
1199will instead be silently ignored."""
1200 print(message)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001201 raw_input('[Press enter to dcommit or ctrl-C to quit]')
1202 return SendUpstream(parser, args, 'dcommit')
1203
1204
1205@usage('[upstream branch to apply against]')
1206def CMDpush(parser, args):
1207 """commit the current changelist via git"""
1208 if settings.GetIsGitSvn():
1209 print('This appears to be an SVN repository.')
1210 print('Are you sure you didn\'t mean \'git cl dcommit\'?')
1211 raw_input('[Press enter to push or ctrl-C to quit]')
1212 return SendUpstream(parser, args, 'push')
1213
1214
1215@usage('<patch url or issue id>')
1216def CMDpatch(parser, args):
1217 """patch in a code review"""
1218 parser.add_option('-b', dest='newbranch',
1219 help='create a new branch off trunk for the patch')
1220 parser.add_option('-f', action='store_true', dest='force',
1221 help='with -b, clobber any existing branch')
1222 parser.add_option('--reject', action='store_true', dest='reject',
1223 help='allow failed patches and spew .rej files')
1224 parser.add_option('-n', '--no-commit', action='store_true', dest='nocommit',
1225 help="don't commit after patch applies")
1226 (options, args) = parser.parse_args(args)
1227 if len(args) != 1:
1228 parser.print_help()
1229 return 1
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001230 issue_arg = args[0]
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001231
dpranke@chromium.org6a2d0832011-03-18 05:28:42 +00001232 if re.match(r'\d+', issue_arg):
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001233 # Input is an issue id. Figure out the URL.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001234 issue = issue_arg
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001235 patch_data = Changelist().GetPatchSetDiff(issue)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001236 else:
1237 # Assume it's a URL to the patch. Default to http.
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001238 issue_url = FixUrl(issue_arg)
1239 match = re.match(r'.*?/issue(\d+)_\d+.diff', issue_url)
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001240 if not match:
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001241 DieWithError('Must pass an issue ID or full URL for '
1242 '\'Download raw patch set\'')
maruel@chromium.orge77ebbf2011-03-29 20:35:38 +00001243 issue = match.group(1)
1244 patch_data = urllib2.urlopen(issue_arg).read()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001245
1246 if options.newbranch:
1247 if options.force:
1248 RunGit(['branch', '-D', options.newbranch],
1249 swallow_stderr=True, error_ok=True)
1250 RunGit(['checkout', '-b', options.newbranch,
1251 Changelist().GetUpstreamBranch()])
1252
1253 # Switch up to the top-level directory, if necessary, in preparation for
1254 # applying the patch.
1255 top = RunGit(['rev-parse', '--show-cdup']).strip()
1256 if top:
1257 os.chdir(top)
1258
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001259 # Git patches have a/ at the beginning of source paths. We strip that out
1260 # with a sed script rather than the -p flag to patch so we can feed either
1261 # Git or svn-style patches into the same apply command.
1262 # re.sub() should be used but flags=re.MULTILINE is only in python 2.7.
1263 sed_proc = Popen(['sed', '-e', 's|^--- a/|--- |; s|^+++ b/|+++ |'],
1264 stdin=subprocess.PIPE, stdout=subprocess.PIPE)
1265 patch_data = sed_proc.communicate(patch_data)[0]
1266 if sed_proc.returncode:
1267 DieWithError('Git patch mungling failed.')
1268 logging.info(patch_data)
1269 # We use "git apply" to apply the patch instead of "patch" so that we can
1270 # pick up file adds.
1271 # The --index flag means: also insert into the index (so we catch adds).
1272 cmd = ['git', 'apply', '--index', '-p0']
1273 if options.reject:
1274 cmd.append('--reject')
1275 patch_proc = Popen(cmd, stdin=subprocess.PIPE)
1276 patch_proc.communicate(patch_data)
1277 if patch_proc.returncode:
1278 DieWithError('Failed to apply the patch')
1279
1280 # If we had an issue, commit the current state and register the issue.
1281 if not options.nocommit:
1282 RunGit(['commit', '-m', 'patch from issue %s' % issue])
1283 cl = Changelist()
1284 cl.SetIssue(issue)
1285 print "Committed patch."
1286 else:
1287 print "Patch applied to index."
1288 return 0
1289
1290
1291def CMDrebase(parser, args):
1292 """rebase current branch on top of svn repo"""
1293 # Provide a wrapper for git svn rebase to help avoid accidental
1294 # git svn dcommit.
1295 # It's the only command that doesn't use parser at all since we just defer
1296 # execution to git-svn.
1297 RunGit(['svn', 'rebase'] + args, redirect_stdout=False)
1298 return 0
1299
1300
1301def GetTreeStatus():
1302 """Fetches the tree status and returns either 'open', 'closed',
1303 'unknown' or 'unset'."""
1304 url = settings.GetTreeStatusUrl(error_ok=True)
1305 if url:
1306 status = urllib2.urlopen(url).read().lower()
1307 if status.find('closed') != -1 or status == '0':
1308 return 'closed'
1309 elif status.find('open') != -1 or status == '1':
1310 return 'open'
1311 return 'unknown'
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001312 return 'unset'
1313
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001314
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001315def GetTreeStatusReason():
1316 """Fetches the tree status from a json url and returns the message
1317 with the reason for the tree to be opened or closed."""
msb@chromium.orgbf1a7ba2011-02-01 16:21:46 +00001318 url = settings.GetTreeStatusUrl()
1319 json_url = urlparse.urljoin(url, '/current?format=json')
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001320 connection = urllib2.urlopen(json_url)
1321 status = json.loads(connection.read())
1322 connection.close()
1323 return status['message']
1324
dpranke@chromium.org970c5222011-03-12 00:32:24 +00001325
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001326def CMDtree(parser, args):
1327 """show the status of the tree"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001328 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001329 status = GetTreeStatus()
1330 if 'unset' == status:
1331 print 'You must configure your tree status URL by running "git cl config".'
1332 return 2
1333
1334 print "The tree is %s" % status
1335 print
1336 print GetTreeStatusReason()
1337 if status != 'open':
1338 return 1
1339 return 0
1340
1341
1342def CMDupstream(parser, args):
1343 """print the name of the upstream branch, if any"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001344 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001345 cl = Changelist()
1346 print cl.GetUpstreamBranch()
1347 return 0
1348
1349
1350def Command(name):
1351 return getattr(sys.modules[__name__], 'CMD' + name, None)
1352
1353
1354def CMDhelp(parser, args):
1355 """print list of commands or help for a specific command"""
dpranke@chromium.org97ae58e2011-03-18 00:29:20 +00001356 _, args = parser.parse_args(args)
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001357 if len(args) == 1:
1358 return main(args + ['--help'])
1359 parser.print_help()
1360 return 0
1361
1362
1363def GenUsage(parser, command):
1364 """Modify an OptParse object with the function's documentation."""
1365 obj = Command(command)
1366 more = getattr(obj, 'usage_more', '')
1367 if command == 'help':
1368 command = '<command>'
1369 else:
1370 # OptParser.description prefer nicely non-formatted strings.
1371 parser.description = re.sub('[\r\n ]{2,}', ' ', obj.__doc__)
1372 parser.set_usage('usage: %%prog %s [options] %s' % (command, more))
1373
1374
1375def main(argv):
1376 """Doesn't parse the arguments here, just find the right subcommand to
1377 execute."""
1378 # Do it late so all commands are listed.
1379 CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join([
1380 ' %-10s %s' % (fn[3:], Command(fn[3:]).__doc__.split('\n')[0].strip())
1381 for fn in dir(sys.modules[__name__]) if fn.startswith('CMD')]))
1382
1383 # Create the option parse and add --verbose support.
1384 parser = optparse.OptionParser()
1385 parser.add_option('-v', '--verbose', action='store_true')
1386 old_parser_args = parser.parse_args
1387 def Parse(args):
1388 options, args = old_parser_args(args)
1389 if options.verbose:
1390 logging.basicConfig(level=logging.DEBUG)
1391 else:
1392 logging.basicConfig(level=logging.WARNING)
1393 return options, args
1394 parser.parse_args = Parse
1395
1396 if argv:
1397 command = Command(argv[0])
1398 if command:
1399 # "fix" the usage and the description now that we know the subcommand.
1400 GenUsage(parser, argv[0])
1401 try:
1402 return command(parser, argv[1:])
1403 except urllib2.HTTPError, e:
1404 if e.code != 500:
1405 raise
1406 DieWithError(
1407 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith '
1408 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e)))
1409
1410 # Not a known command. Default to help.
1411 GenUsage(parser, 'help')
1412 return CMDhelp(parser, argv)
1413
1414
1415if __name__ == '__main__':
maruel@chromium.org6f09cd92011-04-01 16:38:12 +00001416 fix_encoding.fix_encoding()
chase@chromium.orgcc51cd02010-12-23 00:48:39 +00001417 sys.exit(main(sys.argv[1:]))